diff --git a/Dockerfile b/Dockerfile index 95e1f04..7aedc38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ +ARG TAG + FROM node:latest # Install dependencies @@ -13,10 +15,11 @@ RUN curl -s https://api.github.com/repos/5rahim/seanime/releases/latest | grep ' TAG=$(cat tag.file) && \ wget https://github.com/5rahim/seanime/archive/refs/tags/${TAG}.tar.gz && \ tar -xzvf ${TAG}.tar.gz && \ - rm ${TAG}.tar.gz tag.file + rm ${TAG}.tar.gz tag.file && \ + mv seanime-* seanime # Set working directory to the extracted source code -WORKDIR /seanime-${TAG} +WORKDIR /seanime # Build the web interface RUN cd seanime-web && \ diff --git a/seanime-2.9.10/.github/FUNDING.yml b/seanime-2.9.10/.github/FUNDING.yml new file mode 100644 index 0000000..a499337 --- /dev/null +++ b/seanime-2.9.10/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: 5rahim +buy_me_a_coffee: 5rahim diff --git a/seanime-2.9.10/.github/ISSUE_TEMPLATE/bug_report.yml b/seanime-2.9.10/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..3770e0d --- /dev/null +++ b/seanime-2.9.10/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,111 @@ +name: Bug report +description: Report a bug you encountered +title: 'bug: ' +labels: + - bug +body: + - type: checkboxes + id: '1' + attributes: + label: Checklist + description: Please follow the general troubleshooting steps first + options: + - label: >- + My version of the app is the latest available + required: true + - label: >- + I have checked open and closed [issues](https://github.com/5rahim/seanime/issues) + required: true + - label: >- + I have checked the [docs](https://seanime.rahim.app/docs/faq) for a fix + required: true + - type: dropdown + id: '2' + attributes: + label: Bug Severity + description: Select the severity of the bug. Anything below "Panic" means the app doesn't crash. + options: + - Not sure + - Panic / Crash + - Usability is affected + - Low + validations: + required: true + - type: dropdown + id: '3' + attributes: + label: Bug Area + description: Select the general area of the app or process during which the bug occurred. + options: + - Other + - Authentication + - Configuration + - Anime Library + - Transcoding / Media Streaming + - Torrent Streaming + - Online Streaming + - Manga + - Settings + - Offline mode + - AniList + - UI / Web Interface + - Desktop app + validations: + required: true + - type: textarea + id: '4' + attributes: + label: Bug Description / Steps to Reproduce + description: Precisely describe the bug you encountered and the steps to reproduce it. Avoid vague descriptions. + validations: + required: true + - type: textarea + id: '5' + attributes: + label: Expected Behavior + description: Describe what you expected to happen. + - type: textarea + id: '6' + attributes: + label: Screenshots + description: If applicable, add screenshots of the bug + - type: textarea + id: '7' + attributes: + label: Logs + description: If applicable, add terminal output, browser console logs or stack traces. You can use [pastebin](https://pastebin.com) to share large logs. + validations: + required: true + - type: checkboxes + id: '8' + attributes: + label: Debugging Checklist + description: Confirm you have included at least some of the following debugging information. If you haven't, please do so before submitting the issue. + options: + - label: >- + I have included error messages + required: false + - label: >- + I have included server logs + required: false + - label: >- + I have included browser console logs + required: false + - type: input + id: '9' + attributes: + label: App Version + description: Enter the version of Seanime you are using. + placeholder: v1.0.0 + validations: + required: true + - type: dropdown + id: '10' + attributes: + label: Operating System + options: + - Windows + - Linux + - MacOS + validations: + required: true diff --git a/seanime-2.9.10/.github/ISSUE_TEMPLATE/feature_request.yml b/seanime-2.9.10/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..01691af --- /dev/null +++ b/seanime-2.9.10/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,39 @@ +name: Feature Request +description: Suggest an idea for the project +title: 'feature request: ' +labels: + - request +body: + - type: checkboxes + id: '1' + attributes: + label: Checklist + description: >- + Please check the following before submitting a feature request. If you + are unable to check all the boxes, please provide more information in the + description. + options: + - label: >- + I checked that this feature has not been requested before + required: true + - label: >- + I checked that this feature is not in the "Not planned" list + required: true + - label: >- + This feature will benefit the majority of users + - type: textarea + id: '2' + attributes: + label: Problem Description / Use Case + description: >- + Provide a detailed description of the problem you are facing or the use case you have in mind. + validations: + required: true + - type: textarea + id: '3' + attributes: + label: Proposed Solution + description: >- + Provide a detailed description of the solution you'd like to see. If you have any ideas on how to implement the feature, please include them here. + validations: + required: true diff --git a/seanime-2.9.10/.github/scripts/generate_release_notes.go b/seanime-2.9.10/.github/scripts/generate_release_notes.go new file mode 100644 index 0000000..ce823cc --- /dev/null +++ b/seanime-2.9.10/.github/scripts/generate_release_notes.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func main() { + const inFile = "CHANGELOG.md" + const outFile = "whats-new.md" + + // Get the path to the changelog + changelogPath := filepath.Join(".", inFile) + + // Read the changelog content + content, err := os.ReadFile(changelogPath) + if err != nil { + fmt.Println("Error reading changelog:", err) + return + } + + // Convert the content to a string + changelog := string(content) + + // Extract everything between the first and second "## " headers + sections := strings.Split(changelog, "## ") + if len(sections) < 2 { + fmt.Println("Not enough headers found in the changelog.") + return + } + + // We only care about the first section + changelog = sections[1] + + // Remove everything after the next header (if any) + changelog = strings.Split(changelog, "## ")[0] + + // Remove the first line (which is the title of the first section) + lines := strings.Split(changelog, "\n") + if len(lines) > 1 { + changelog = strings.Join(lines[1:], "\n") + } + + // Trim newlines + changelog = strings.TrimSpace(changelog) + + // Write the extracted content to the output file + outPath := filepath.Join(".", outFile) + if err := os.WriteFile(outPath, []byte(changelog), 0644); err != nil { + fmt.Println("Error writing to file:", err) + return + } + + fmt.Printf("Changelog content written to %s\n", outPath) +} diff --git a/seanime-2.9.10/.github/scripts/generate_updater_latest.go b/seanime-2.9.10/.github/scripts/generate_updater_latest.go new file mode 100644 index 0000000..4bc2225 --- /dev/null +++ b/seanime-2.9.10/.github/scripts/generate_updater_latest.go @@ -0,0 +1,107 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + DownloadUrl = "https://github.com/5rahim/seanime/releases/latest/download/" +) + +func main() { + // Retrieve version from environment variable + version := os.Getenv("APP_VERSION") + if version == "" { + version = "1.0.0" // Default to '1.0.0' if not set + } + + // Define the asset filenames + assets := map[string]struct { + Asset string + AppZip string + Sig string + }{ + "MacOS_arm64": { + Asset: fmt.Sprintf("seanime-desktop-%s_MacOS_arm64.app.tar.gz", version), + Sig: fmt.Sprintf("seanime-desktop-%s_MacOS_arm64.app.tar.gz.sig", version), + }, + "MacOS_x86_64": { + Asset: fmt.Sprintf("seanime-desktop-%s_MacOS_x86_64.app.tar.gz", version), + Sig: fmt.Sprintf("seanime-desktop-%s_MacOS_x86_64.app.tar.gz.sig", version), + }, + "Linux_x86_64": { + Asset: fmt.Sprintf("seanime-desktop-%s_Linux_x86_64.AppImage", version), + Sig: fmt.Sprintf("seanime-desktop-%s_Linux_x86_64.AppImage.sig", version), + }, + "Windows_x86_64": { + AppZip: fmt.Sprintf("seanime-desktop-%s_Windows_x86_64.exe", version), + Sig: fmt.Sprintf("seanime-desktop-%s_Windows_x86_64.sig", version), + }, + } + + // Function to generate URL based on asset names + generateURL := func(filename string) string { + return fmt.Sprintf("%s%s", DownloadUrl, filename) + } + + // Prepare the JSON structure + latestJSON := map[string]interface{}{ + "version": version, + "pub_date": time.Now().Format(time.RFC3339), // Change to the actual publish date + "platforms": map[string]map[string]string{ + "linux-x86_64": { + "url": generateURL(assets["Linux_x86_64"].Asset), + "signature": getContent(assets["Linux_x86_64"].Sig), + }, + "windows-x86_64": { + "url": generateURL(assets["Windows_x86_64"].AppZip), + "signature": getContent(assets["Windows_x86_64"].Sig), + }, + "darwin-x86_64": { + "url": generateURL(assets["MacOS_x86_64"].Asset), + "signature": getContent(assets["MacOS_x86_64"].Sig), + }, + "darwin-aarch64": { + "url": generateURL(assets["MacOS_arm64"].Asset), + "signature": getContent(assets["MacOS_arm64"].Sig), + }, + }, + } + + // Remove non-existent assets + for platform, asset := range latestJSON["platforms"].(map[string]map[string]string) { + if asset["signature"] == "" { + delete(latestJSON["platforms"].(map[string]map[string]string), platform) + } + } + + // Write to latest.json + outputPath := filepath.Join(".", "latest.json") + file, err := os.Create(outputPath) + if err != nil { + fmt.Println("Error creating file:", err) + return + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(latestJSON); err != nil { + fmt.Println("Error writing JSON to file:", err) + return + } + + fmt.Printf("Generated %s successfully.\n", outputPath) +} + +func getContent(filename string) string { + fileContent, err := os.ReadFile(filepath.Join(".", filename)) + if err != nil { + return "" + } + return string(fileContent) +} diff --git a/seanime-2.9.10/.github/workflows/electron-build.yml.new b/seanime-2.9.10/.github/workflows/electron-build.yml.new new file mode 100644 index 0000000..b7c022c --- /dev/null +++ b/seanime-2.9.10/.github/workflows/electron-build.yml.new @@ -0,0 +1,151 @@ +name: Build Electron App + +on: + workflow_call: + outputs: + appVersion: + description: "The version of the app" + value: ${{ jobs.build-electron.outputs.app_version }} + +jobs: + build-electron: + strategy: + fail-fast: false + matrix: + # IDs: + # - seanime-denshi-darwin-arm + # - seanime-denshi-darwin-intel + # - seanime-denshi-linux + # - seanime-denshi-windows + include: + # For Mac Universal + - os: 'macos-latest' + id: 'seanime-denshi-darwin-arm64' + go_binary_id: 'seanime-server-darwin' # Artifact: go-seanime-server-darwin (contains both arm64 and x86_64) + electron_args: '--mac --arm64' + # For Intel-based macs + - os: 'macos-latest' + id: 'seanime-denshi-darwin-x64' + go_binary_id: 'seanime-server-darwin' # Artifact: go-seanime-server-darwin (contains both arm64 and x86_64) + electron_args: '--mac --x64' + # For Linux + - os: 'ubuntu-latest' + id: 'seanime-denshi-linux-x64' + go_binary_id: 'seanime-server-linux' # Artifact: go-seanime-server-linux (contains x86_64) + electron_args: '--linux' + # For Windows + - os: 'windows-latest' + id: 'seanime-denshi-windows-x64' + go_binary_id: 'seanime-server-windows' # Artifact: go-seanime-server-windows (contains x86_64) + electron_args: '--win' + + runs-on: ${{ matrix.os }} + outputs: + app_version: ${{ steps.get-version.outputs.version }} + + steps: + - name: Checkout code 📂 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js 📦 + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Get package version 📦 + id: get-version + run: | + NODE_VERSION=$(node -p "require('./seanime-denshi/package.json').version") + echo "version=$NODE_VERSION" >> $GITHUB_OUTPUT + shell: bash + + # Install dependencies + - name: Install dependencies (Ubuntu) 📦 + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libnss3-dev libxss-dev libasound2-dev + + # Download the web folders + - name: Download web folder artifact 📥 + uses: actions/download-artifact@v4 + with: + name: web-denshi + path: web-denshi + + # Move web-denshi folder into seanime-denshi + - name: Move web-denshi folder 🚚 + run: mv web-denshi seanime-denshi/ + shell: bash + + - name: Ensure binaries folder exists (UNIX) + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' + run: mkdir -p ./seanime-denshi/binaries + + - name: Ensure binaries folder exists (Windows) + if: matrix.os == 'windows-latest' + run: mkdir .\seanime-denshi\binaries + + # Download the server binaries based on matrix.go_binary_id + - name: Download server binaries 📥 + uses: actions/download-artifact@v4 + with: + name: go-${{ matrix.go_binary_id }} + path: ./seanime-denshi/binaries + + # Extract server binaries + - name: Extract server binaries (macOS x64) 📂 + if: matrix.os == 'macos-latest' && matrix.id == 'seanime-denshi-darwin-x64' + # Extracts seanime-server-darwin-arm64 and seanime-server-darwin-amd64 + # Only keep seanime-server-darwin-amd64 + run: | + tar -xf ./seanime-denshi/binaries/binaries-${{ matrix.go_binary_id }}.tar -C ./seanime-denshi/binaries + # Remove the other binary + rm -rf ./seanime-denshi/binaries/seanime-server-darwin-arm64 + + - name: Extract server binaries (macOS arm64) 📂 + if: matrix.os == 'macos-latest' && matrix.id == 'seanime-denshi-darwin-arm64' + # Extracts seanime-server-darwin-arm64 and seanime-server-darwin-amd64 + # Only keep seanime-server-darwin-arm64 + run: | + tar -xf ./seanime-denshi/binaries/binaries-${{ matrix.go_binary_id }}.tar -C ./seanime-denshi/binaries + # Remove the other binary + rm -rf ./seanime-denshi/binaries/seanime-server-darwin-amd64 + + - name: Extract server binaries (Linux) 📂 + if: matrix.os == 'ubuntu-latest' && matrix.id == 'seanime-denshi-linux-x64' + # Extracts seanime-server-linux-amd64 + run: tar -xf ./seanime-denshi/binaries/binaries-${{ matrix.go_binary_id }}.tar -C ./seanime-denshi/binaries + + - name: Extract server binaries (Windows) 📂 + if: matrix.os == 'windows-latest' + # Extracts seanime-server-windows-amd64 + run: 7z x ".\seanime-denshi\binaries\binaries-${{ matrix.go_binary_id }}.zip" "-o./seanime-denshi/binaries/" + + # Copy app icon + - name: Copy app icon 📝 + run: | + mkdir -p ./seanime-denshi/assets + cp ./seanime-desktop/src-tauri/app-icon.png ./seanime-denshi/assets/ + shell: bash + + # Install and build + - name: Install and build 📦️ + run: | + cd seanime-denshi + npm install + npm run build -- ${{ matrix.electron_args }} + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Upload the artifacts + - name: Upload Electron artifacts 📤 + uses: actions/upload-artifact@v4 + with: + name: electron-${{ matrix.id }} + path: | + ./seanime-denshi-* + ./seanime-denshi/dist/*.yml diff --git a/seanime-2.9.10/.github/workflows/release-draft-new.yml.new b/seanime-2.9.10/.github/workflows/release-draft-new.yml.new new file mode 100644 index 0000000..0655fbd --- /dev/null +++ b/seanime-2.9.10/.github/workflows/release-draft-new.yml.new @@ -0,0 +1,700 @@ +name: Release Draft + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + + build-webapp: # TODO Uncomment if building web + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Web + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + # outputs in "seanime-web/out/" and "seanime-web/out-desktop/" + - name: Install dependencies and build Next.js app + run: | + cd seanime-web/ + npm install + npm run build + npm run build:desktop + npm run build:denshi + cd .. + # Upload the output to be used in the next job + - name: Upload web folder + uses: actions/upload-artifact@v4 + with: + name: web + path: seanime-web/out # output dir of build + - name: Upload web folder (Tauri) + uses: actions/upload-artifact@v4 + with: + name: web-desktop + path: seanime-web/out-desktop # output dir of build:desktop + - name: Upload web folder (Electron) + uses: actions/upload-artifact@v4 + with: + name: web-denshi + path: seanime-web/out-denshi # output dir of build:denshi + + build-server: + needs: build-webapp # TODO Uncomment if building web + runs-on: ${{ matrix.os }} + + strategy: + matrix: + # 6 binaries: 2 for Windows, 2 for Linux, 2 for macOS + include: + # This is the systray version of the Windows binary used for the server build + - os: macos-latest # seanime-server-systray-windows.exe + id: seanime-server-systray-windows + go_flags: -trimpath -buildmode=exe -ldflags="-s -w -H=windowsgui -extldflags '-static'" + + # This is the non-systray version of the Windows binary used for the Tauri Windows build + - os: windows-latest # seanime-server-windows.exe + id: seanime-server-windows + go_flags: -trimpath -ldflags="-s -w" -tags=nosystray + + # These are the Linux binaries used for the server build and the Tauri Linux build + - os: ubuntu-latest # seanime-server-linux-arm64, seanime-server-linux-amd64 + id: seanime-server-linux + go_flags: -trimpath -ldflags="-s -w" + + # These are the macOS binaries used for the server build and the Tauri macOS build + - os: macos-latest # seanime-server-darwin-arm64, seanime-server-darwin-amd64 + id: seanime-server-darwin + go_env: CGO_ENABLED=0 + go_flags: -trimpath -ldflags="-s -w" + steps: + - name: Checkout code ⬇️ + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history + fetch-tags: true # Fetch all tags + set-safe-directory: true # Add repo path as safe.directory + + - name: Fetch all tags # Fetch all tags (again? can't hurt) + run: git fetch --force --tags + + # Go + - name: Set up Go ⬇️ + uses: actions/setup-go@v5 + with: + go-version: '1.24.3' + + # Download the web folders + # TODO Uncomment if building web + - name: Download web folder artifact + uses: actions/download-artifact@v4 + with: + name: web + path: web + + # Create the binary destination folder + # ./binaries + # |--- ... + - name: Create binary destination folder (UNIX) 🗃️ + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' + run: mkdir -p binaries + - name: Create binary destination folder (Windows) 🗃️ + if: matrix.os == 'windows-latest' + run: mkdir -p binaries + shell: bash + + + #--- + + # ONLY for Windows systray build (seanime-server-systray-windows) + # For the Windows systray build (built on macOS runner), we need to install the necessary dependencies + - name: Install C dependencies ⬇️ # macos windows systray build + if: matrix.id == 'seanime-server-systray-windows' + run: | + brew install filosottile/musl-cross/musl-cross + brew install llvm + brew install mingw-w64 + + # Build the Windows systray binary + # ./binaries/seanime-server-systray-windows.exe + - name: Build Windows Systray 📦️ + if: matrix.id == 'seanime-server-systray-windows' + env: + GOARCH: amd64 + GOOS: windows + CGO_ENABLED: 1 + CC: x86_64-w64-mingw32-gcc + CXX: x86_64-w64-mingw32-g++ + run: | + go build -o seanime-server-systray-windows.exe ${{ matrix.go_flags }} . + + # Build the Windows non-systray binary + # ./seanime-server-windows.exe + - name: Build Windows Non-Systray 📦️ + if: matrix.id == 'seanime-server-windows' + env: + GOARCH: amd64 + GOOS: windows + CGO_ENABLED: 0 + run: | + go build -o seanime-server-windows.exe ${{ matrix.go_flags }} . + shell: bash + + # Build the Linux binaries + # ./seanime-server-linux-amd64 + # ./seanime-server-linux-arm64 + - name: Build Linux 📦️ + if: matrix.id == 'seanime-server-linux' + run: | + CGO_ENABLED=0 GOARCH=amd64 go build -o seanime-server-linux-amd64 ${{ matrix.go_flags }} . + CGO_ENABLED=0 GOARCH=arm64 go build -o seanime-server-linux-arm64 ${{ matrix.go_flags }} . + + # Build the macOS binaries + # ./seanime-server-darwin-amd64 + # ./seanime-server-darwin-arm64 + - name: Build macOS 📦️ + if: matrix.id == 'seanime-server-darwin' + run: | + CGO_ENABLED=0 GOARCH=amd64 go build -o seanime-server-darwin-amd64 ${{ matrix.go_flags }} . + CGO_ENABLED=0 GOARCH=arm64 go build -o seanime-server-darwin-arm64 ${{ matrix.go_flags }} . + + # Tar the binaries + - name: Tar the binaries (UNIX) 🗃️ + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' + # binaries-seanime-server-darwin.tar + # binaries-seanime-server-linux.tar + # binaries-seanime-server-systray-windows.tar + run: | + tar -cf binaries-${{ matrix.id }}.tar seanime-server-* + + # Zip the binaries + - name: Zip the binaries (Windows) 🗃️ + if: matrix.os == 'windows-latest' + # binaries-seanime-server-windows.zip + run: | + 7z a "binaries-${{ matrix.id }}.zip" seanime-server-* + + # Upload the binaries to be used in the next job + - name: Upload binary folder (UNIX) 📤 + uses: actions/upload-artifact@v4 + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' + with: + # go-seanime-server-linux + # go-seanime-server-darwin + # go-seanime-server-systray-windows + name: go-${{ matrix.id }} + path: binaries-${{ matrix.id }}.tar + + - name: Upload binary folder (Windows) 📤 + uses: actions/upload-artifact@v4 + if: matrix.os == 'windows-latest' + with: + # go-seanime-server-windows + name: go-${{ matrix.id }} + path: binaries-${{ matrix.id }}.zip + + + build-tauri: + needs: build-server + + strategy: + fail-fast: false + matrix: + # IDs: + # - seanime-desktop-darwin-arm + # - seanime-desktop-darwin-intel + # - seanime-desktop-linux + # - seanime-desktop-windows + include: + # For Arm-based macs (M1 and above). + - os: 'macos-latest' + id: 'seanime-desktop-darwin-arm' + go_binary_id: 'seanime-server-darwin' # Artifact: go-seanime-server-darwin (contains both arm64 and x86_64) + args: '--target aarch64-apple-darwin' + # For Intel-based macs. + - os: 'macos-latest' + id: 'seanime-desktop-darwin-intel' + go_binary_id: 'seanime-server-darwin' # Artifact: go-seanime-server-darwin (contains both arm64 and x86_64) + args: '--target x86_64-apple-darwin' + # For Linux + - os: 'ubuntu-22.04' # for Linux + id: 'seanime-desktop-linux' # Artifact: go-seanime-server-linux (contains both arm64 and x86_64) + go_binary_id: 'seanime-server-linux' + args: '' + # For Windows + - os: 'windows-latest' # for Windows + id: 'seanime-desktop-windows' # Artifact: go-seanime-server-windows (contains x86_64) + go_binary_id: 'seanime-server-windows' + args: '' + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies (Ubuntu) ⬇️ + if: matrix.os == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Install Rust stable ⬇️ + uses: dtolnay/rust-toolchain@stable + with: + # Those targets are only used on macOS runners so it's in an `if` to slightly speed up windows and linux builds. + targets: ${{ matrix.id == 'seanime-desktop-darwin-intel' && 'x86_64-apple-darwin' || matrix.id == 'seanime-desktop-darwin-arm' && 'aarch64-apple-darwin' || '' }} + + - name: Setup node ⬇️ + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Tauri CLI ⬇️ + run: | + cd seanime-desktop + npm install + + - name: Rust cache ⬇️ + uses: swatinem/rust-cache@v2 + with: + workspaces: './seanime-desktop/src-tauri -> target' + + + # Download the web folder + # TODO Uncomment if building web + - name: Download web folder artifact + uses: actions/download-artifact@v4 + with: + name: web-desktop + path: web-desktop + + # Download the server binaries depending on matrix.go_binary_id + - name: Download server binaries 📥 + uses: actions/download-artifact@v4 + with: + # go-seanime-server-windows or + # go-seanime-server-linux or + # go-seanime-server-darwin + name: go-${{ matrix.go_binary_id }} + path: ./seanime-desktop/src-tauri/binaries + + - name: Extract server binaries (UNIX) 📂 + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + run: tar -xf ./seanime-desktop/src-tauri/binaries/binaries-${{ matrix.go_binary_id }}.tar -C ./seanime-desktop/src-tauri/binaries + - name: Extract server binaries (Windows) 📂 + if: matrix.os == 'windows-latest' + run: 7z x ".\seanime-desktop\src-tauri\binaries\binaries-${{ matrix.go_binary_id }}.zip" "-o./seanime-desktop/src-tauri/binaries/" + + + # ----------------------------------------------------------------- delete + - name: Print downloaded binaries (UNIX) + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + run: ls -la ./seanime-desktop/src-tauri/binaries + - name: Print downloaded binaries (Windows) + if: matrix.os == 'windows-latest' + run: dir ./seanime-desktop/src-tauri/binaries + # ----------------------------------------------------------------- delete + + - name: Determine target triple (UNIX) 🎯 + # id: target_triple + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + run: | + TARGET_TRIPLE=$(rustc -Vv | grep host | cut -f2 -d' ') + echo "TARGET_TRIPLE=${TARGET_TRIPLE}" >> $GITHUB_ENV + + - name: Determine target triple (Windows) 🎯 + # id: target_triple + if: matrix.os == 'windows-latest' + run: | + $TARGET_TRIPLE = rustc -Vv | Select-String "host:" | ForEach-Object {$_.Line.split(" ")[1]} + echo "TARGET_TRIPLE=$TARGET_TRIPLE" >> $env:GITHUB_ENV + shell: pwsh + + # seanime-server-windows.exe -> seanime-x86_64-pc-windows-msvc.exe + - name: Rename sidecar binary (Windows) 📝 + if: matrix.id == 'seanime-desktop-windows' + run: | + powershell -Command "Rename-Item -Path ./seanime-desktop/src-tauri/binaries/seanime-server-windows.exe -NewName seanime-${{ env.TARGET_TRIPLE }}.exe" + + # seanime-server-linux-amd64 -> seanime-unknown-linux-musl + - name: Rename sidecar binaries (Linux) 📝 + if: matrix.id == 'seanime-desktop-linux' + run: | + mv ./seanime-desktop/src-tauri/binaries/seanime-server-linux-amd64 ./seanime-desktop/src-tauri/binaries/seanime-${{ env.TARGET_TRIPLE }} + + # seanime-server-darwin-amd64 -> seanime-x86_64-apple-darwin + - name: Rename sidecar binaries (MacOS Intel) 📝 + if: matrix.id == 'seanime-desktop-darwin-intel' + # Here we hardcode the target triple because the macOS runner is ARM based + run: | + mv ./seanime-desktop/src-tauri/binaries/seanime-server-darwin-amd64 ./seanime-desktop/src-tauri/binaries/seanime-x86_64-apple-darwin + + # seanime-server-darwin-arm64 -> seanime-aarch64-apple-darwin + - name: Rename sidecar binaries (MacOS Arm) 📝 + if: matrix.id == 'seanime-desktop-darwin-arm' + run: | + mv ./seanime-desktop/src-tauri/binaries/seanime-server-darwin-arm64 ./seanime-desktop/src-tauri/binaries/seanime-${{ env.TARGET_TRIPLE }} + + # ----------------------------------------------------------------- delete + - name: Print downloaded binaries + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + run: ls -la ./seanime-desktop/src-tauri/binaries + - name: Print downloaded binaries + if: matrix.os == 'windows-latest' + run: dir ./seanime-desktop/src-tauri/binaries + # ----------------------------------------------------------------- delete + + # Build Tauri + - name: Run Tauri action 🚀 + id: tauri-action + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + # APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + # APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + # APPLE_ID: ${{ secrets.APPLE_ID }} + # APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + # APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + with: + projectPath: './seanime-desktop' + args: ${{ matrix.args }} + updaterJsonPreferNsis: true + + - name: Rename Tauri artifacts (UNIX) 📝 + # ./ + # |- seanime-desktop-darwin-arm.app.tar.gz + # |- seanime-desktop-darwin-arm.app.tar.gz.sig <- Signature + # |- seanime-desktop-darwin-intel.app.tar.gz + # |- seanime-desktop-darwin-intel.app.tar.gz.sig <- Signature + # |- seanime-desktop-linux.AppImage <- UNCOMPRESSED + # |- seanime-desktop-linux.AppImage.sig <- Signature UNCOMPRESSED + # |- seanime-desktop-windows-setup.exe <- UNCOMPRESSED + # |- seanime-desktop-windows-setup.exe.sig <- Signature UNCOMPRESSED + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + # We hardcode the macOS target triple because the macOS runner is ARM based and builds both arm64 and x86_64 + run: | + if [ -f ./seanime-desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz ]; then + mv ./seanime-desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz ./seanime-desktop-darwin-arm.app.tar.gz + mv ./seanime-desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz.sig ./seanime-desktop-darwin-arm.app.tar.gz.sig + + elif [ -f ./seanime-desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz ]; then + mv ./seanime-desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz ./seanime-desktop-darwin-intel.app.tar.gz + mv ./seanime-desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz.sig ./seanime-desktop-darwin-intel.app.tar.gz.sig + + elif [ -f ./seanime-desktop/src-tauri/target/release/bundle/appimage/Seanime\ Desktop_${{ steps.tauri-action.outputs.appVersion }}_amd64.AppImage ]; then + mv ./seanime-desktop/src-tauri/target/release/bundle/appimage/Seanime\ Desktop_${{ steps.tauri-action.outputs.appVersion }}_amd64.AppImage ./seanime-desktop-linux.AppImage + mv ./seanime-desktop/src-tauri/target/release/bundle/appimage/Seanime\ Desktop_${{ steps.tauri-action.outputs.appVersion }}_amd64.AppImage.sig ./seanime-desktop-linux.AppImage.sig + fi + + - name: Rename Tauri artifacts (Windows) 📝 + if: matrix.os == 'windows-latest' + run: | + powershell -Command "Move-Item -Path './seanime-desktop/src-tauri/target/release/bundle/nsis/Seanime Desktop_${{ steps.tauri-action.outputs.appVersion }}_x64-setup.exe' -Destination './seanime-desktop-windows-setup.exe'" + powershell -Command "Move-Item -Path './seanime-desktop/src-tauri/target/release/bundle/nsis/Seanime Desktop_${{ steps.tauri-action.outputs.appVersion }}_x64-setup.exe.sig' -Destination './seanime-desktop-windows-setup.exe.sig'" + + - name: Tar the Tauri artifacts (Linux) 🗃️ + if: matrix.os == 'ubuntu-22.04' + # Note: The macOS artifacts are already packaged, so we don't need to compress them + # Compress the Linux AppImage, not the signature + run: | + if [ -f ./seanime-desktop-linux.AppImage ]; then + tar -czf seanime-desktop-linux.AppImage.tar.gz seanime-desktop-linux.AppImage + fi + - name: Zip the Tauri artifacts (Windows) 🗃️ + if: matrix.os == 'windows-latest' + # Compress the Windows setup, not the signature + run: | + 7z a seanime-desktop-windows-setup.exe.zip seanime-desktop-windows-setup.exe + + # ----------------------------------------------------------------- delete + - name: Print all + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + run: ls -la . + - name: Print downloaded binaries + if: matrix.os == 'windows-latest' + run: dir . + # ----------------------------------------------------------------- delete + + # Upload the Tauri artifacts to be used in the next job + - name: Upload tauri artifacts 📤 + uses: actions/upload-artifact@v4 + with: + # Artifact IDs: + # tauri-seanime-server-darwin-arm + # tauri-seanime-server-darwin-intel + # tauri-seanime-server-linux + # tauri-seanime-server-windows + name: tauri-${{ matrix.id }} + path: | + ./seanime-desktop-darwin-arm.app.tar.gz + ./seanime-desktop-darwin-arm.app.tar.gz.sig + ./seanime-desktop-darwin-intel.app.tar.gz + ./seanime-desktop-darwin-intel.app.tar.gz.sig + ./seanime-desktop-linux.AppImage + ./seanime-desktop-linux.AppImage.tar.gz + ./seanime-desktop-linux.AppImage.sig + ./seanime-desktop-windows-setup.exe + ./seanime-desktop-windows-setup.exe.zip + ./seanime-desktop-windows-setup.exe.sig + + + build-electron: + needs: build-server + uses: electron-build.yml.new + + release: + runs-on: ubuntu-latest + needs: [ build-server, build-tauri, build-electron ] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download tauri artifacts 📥 + uses: actions/download-artifact@v4 + with: + pattern: tauri-* + path: ./artifacts + merge-multiple: true + + - name: Download electron artifacts 📥 + uses: actions/download-artifact@v4 + with: + pattern: electron-* + path: ./artifacts + merge-multiple: true + + - name: Determine version from tag name 🔎 + run: | + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION=${GITHUB_REF/refs\/tags\/v/} + echo "Version extracted from tag: $VERSION" + elif [[ "$GITHUB_REF" == refs/tags/* ]]; then + VERSION=${GITHUB_REF/refs\/tags\//} + echo "Version extracted from tag: $VERSION" + else + echo "Warning: No tag associated with this run. Defaulting to version 0.1.0." + VERSION="0.1.0" + fi + echo "VERSION=$VERSION" >> $GITHUB_ENV + + - name: Print version + run: echo "Version is ${{ env.VERSION }}" + + - name: Download server binaries 📥 + uses: actions/download-artifact@v4 + with: + pattern: go-* + path: ./artifacts + # ./artifacts + # |- binaries-seanime-server-darwin.tar (contains 2) + # |- binaries-seanime-server-linux.tar (contains 2) + # |- binaries-seanime-server-systray-windows.tar (contains 1) + merge-multiple: true + + - name: Print all artifacts + run: ls -la ./artifacts + + - name: Extract - Rename - Archive server binaries 📂 + # ./artifacts + # |- ... + # \/ /binaries-seanime-server-darwin.tar + # |- seanime-server-darwin-amd64 -> ../seanime -> ../seanime-${{ env.VERSION }}_MacOS_arm64.tar.gz + # |- seanime-server-darwin-arm64 -> ../seanime -> ../seanime-${{ env.VERSION }}_MacOS_x86_64.tar.gz + # \/ /binaries-seanime-server-darwin.tar + # |- seanime-server-linux-amd64 -> ../seanime -> ../seanime-${{ env.VERSION }}_Linux_x86_64.tar.gz + # |- seanime-server-linux-arm64 -> ../seanime -> ../seanime-${{ env.VERSION }}_Linux_arm64.tar.gz + # \/ /binaries-seanime-server-systray-windows.tar + # |- seanime-server-systray-windows.exe -> ../seanime.exe -> ../seanime-${{ env.VERSION }}_Windows_x86_64.zip + run: | + if [ -f ./artifacts/binaries-seanime-server-darwin.tar ]; then + # Extract binaries + tar -xf ./artifacts/binaries-seanime-server-darwin.tar -C ./artifacts + + # Rename & compress binaries + mv ./artifacts/seanime-server-darwin-amd64 ./seanime + tar czf ./seanime-${{ env.VERSION }}_MacOS_x86_64.tar.gz ./seanime + rm -rf ./seanime + + + mv ./artifacts/seanime-server-darwin-arm64 ./seanime + tar czf ./seanime-${{ env.VERSION }}_MacOS_arm64.tar.gz ./seanime + rm -rf ./seanime + fi + + if [ -f ./artifacts/binaries-seanime-server-linux.tar ]; then + # Extract binaries + tar -xf ./artifacts/binaries-seanime-server-linux.tar -C ./artifacts + + # Rename & compress binaries + mv ./artifacts/seanime-server-linux-amd64 ./seanime + tar czf ./seanime-${{ env.VERSION }}_Linux_x86_64.tar.gz ./seanime + rm -rf ./seanime + + + mv ./artifacts/seanime-server-linux-arm64 ./seanime + tar czf ./seanime-${{ env.VERSION }}_Linux_arm64.tar.gz ./seanime + rm -rf ./seanime + fi + + if [ -f ./artifacts/binaries-seanime-server-systray-windows.tar ]; then + # Extract binaries + tar -xf ./artifacts/binaries-seanime-server-systray-windows.tar -C ./artifacts + + # Rename & compress binaries + mv ./artifacts/seanime-server-systray-windows.exe ./seanime.exe + 7z a ./seanime-${{ env.VERSION }}_Windows_x86_64.zip ./seanime.exe + rm -rf ./seanime.exe + fi + shell: bash + + - name: Print all artifacts + run: ls -la ./artifacts + + - name: Move & Rename Tauri assets 📝🗃️ + # Move Tauri assets to the root directory and rename them + # ./artifacts + # |- seanime-desktop-darwin-arm.app.tar.gz -> ../seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz + # |- seanime-desktop-darwin-arm.app.tar.gz.sig -> ../seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz.sig + # |- seanime-desktop-darwin-intel.app.tar.gz -> ../seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz + # |- seanime-desktop-darwin-intel.app.tar.gz.sig -> ../seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz.sig + # |- seanime-desktop-linux.AppImage -> ../seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage + # |- seanime-desktop-linux.AppImage.tar.gz -> ../seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.tar.gz + # |- seanime-desktop-linux.AppImage.sig -> ../seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.sig + # |- seanime-desktop-windows-setup.exe -> ../seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe + # |- seanime-desktop-windows-setup.exe.zip -> ../seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe.zip + # |- seanime-desktop-windows-setup.exe.sig -> ../seanime-desktop-${{ env.VERSION }}_Windows_x86_64.sig + run: | + if [ -f ./artifacts/seanime-desktop-darwin-arm.app.tar.gz ]; then + mv ./artifacts/seanime-desktop-darwin-arm.app.tar.gz ./seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz + mv ./artifacts/seanime-desktop-darwin-arm.app.tar.gz.sig ./seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz.sig + fi + if [ -f ./artifacts/seanime-desktop-darwin-intel.app.tar.gz ]; then + mv ./artifacts/seanime-desktop-darwin-intel.app.tar.gz ./seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz + mv ./artifacts/seanime-desktop-darwin-intel.app.tar.gz.sig ./seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz.sig + fi + if [ -f ./artifacts/seanime-desktop-linux.AppImage.tar.gz ]; then + mv ./artifacts/seanime-desktop-linux.AppImage ./seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage + mv ./artifacts/seanime-desktop-linux.AppImage.tar.gz ./seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.tar.gz + mv ./artifacts/seanime-desktop-linux.AppImage.sig ./seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.sig + fi + if [ -f ./artifacts/seanime-desktop-windows-setup.exe.zip ]; then + mv ./artifacts/seanime-desktop-windows-setup.exe ./seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe + mv ./artifacts/seanime-desktop-windows-setup.exe.zip ./seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe.zip + mv ./artifacts/seanime-desktop-windows-setup.exe.sig ./seanime-desktop-${{ env.VERSION }}_Windows_x86_64.sig + fi + + - name: Move & Rename Electron assets 📝🗃️ + # Move Electron assets to the root directory and rename them + run: | + if [ -f ./artifacts/seanime-denshi-darwin-arm64.dmg ]; then + mv ./artifacts/seanime-denshi-darwin-arm64.dmg ./seanime-denshi-${{ env.VERSION }}_MacOS_arm64.dmg + fi + if [ -f ./artifacts/seanime-denshi-darwin-x64.dmg ]; then + mv ./artifacts/seanime-denshi-darwin-x64.dmg ./seanime-denshi-${{ env.VERSION }}_MacOS_x64.dmg + fi + if [ -f ./artifacts/seanime-denshi-linux-x64.AppImage ]; then + mv ./artifacts/seanime-denshi-linux-x64.AppImage ./seanime-denshi-${{ env.VERSION }}_Linux_x64.AppImage + fi + if [ -f ./artifacts/seanime-denshi-windows-x64.exe ]; then + mv ./artifacts/seanime-denshi-windows-x64.exe ./seanime-denshi-${{ env.VERSION }}_Windows_x64.exe + fi + + # Copy electron-builder YML files if they exist + find ./artifacts -name "*.yml" -exec cp {} ./ \; + + - name: Print all + run: ls -la . + + # Go + - name: Set up Go ⬇️ + uses: actions/setup-go@v5 + with: + go-version: '1.24.3' + + # Build the Go script + - name: Build Go scripts 🛠️ + run: | + go build -o generate_updater_latest ./.github/scripts/generate_updater_latest.go + go build -o generate_release_notes ./.github/scripts/generate_release_notes.go + + # Run the Go scripts + - name: Generate latest.json 📦️ + env: + APP_VERSION: ${{ env.VERSION }} + run: ./generate_updater_latest + - name: Generate release notes 📦️ + env: + APP_VERSION: ${{ env.VERSION }} + run: ./generate_release_notes + + - name: Read release notes 🔍 + id: read_release_notes + run: | + BODY=$(cat whats-new.md) + echo "RELEASE_BODY<> $GITHUB_ENV + echo "$BODY" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Create release draft 🚀🚀🚀 + id: create_release + uses: softprops/action-gh-release@v2 + with: + fail_on_unmatched_files: false + files: | + latest.json + latest.yml + latest-linux.yml + latest-mac.yml + latest-mac-arm64.yml + # Tauri Desktop builds + seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz + seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz.sig + seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz + seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz.sig + seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage + seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.tar.gz + seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.sig + seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe + seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe.zip + seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe.sig + # Electron Desktop builds + seanime-denshi-${{ env.VERSION }}_MacOS_arm64.dmg + seanime-denshi-${{ env.VERSION }}_MacOS_x64.dmg + seanime-denshi-${{ env.VERSION }}_Linux_x64.AppImage + seanime-denshi-${{ env.VERSION }}_Windows_x64.exe + # Server builds + seanime-${{ env.VERSION }}_MacOS_x86_64.tar.gz + seanime-${{ env.VERSION }}_MacOS_arm64.tar.gz + seanime-${{ env.VERSION }}_Linux_x86_64.tar.gz + seanime-${{ env.VERSION }}_Linux_arm64.tar.gz + seanime-${{ env.VERSION }}_Windows_x86_64.zip + token: ${{ secrets.GITHUB_TOKEN }} + tag_name: v${{ env.VERSION }} + release_name: v${{ env.VERSION }} + draft: true + prerelease: false + body: | + ## What's new? + + ${{ env.RELEASE_BODY }} + + --- + [Open an issue](https://github.com/5rahim/seanime/issues/new/choose) diff --git a/seanime-2.9.10/.github/workflows/release-draft.yml b/seanime-2.9.10/.github/workflows/release-draft.yml new file mode 100644 index 0000000..151e5c5 --- /dev/null +++ b/seanime-2.9.10/.github/workflows/release-draft.yml @@ -0,0 +1,653 @@ +name: Release Draft + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + + build-webapp: # TODO Uncomment if building web + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Web + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + # outputs in "seanime-web/out/" and "seanime-web/out-desktop/" + - name: Install dependencies and build Next.js app + run: | + cd seanime-web/ + npm install + npm run build + npm run build:desktop + cd .. + # Upload the output to be used in the next job + - name: Upload web folder + uses: actions/upload-artifact@v4 + with: + name: web + path: seanime-web/out # output dir of build + - name: Upload web folder (desktop) + uses: actions/upload-artifact@v4 + with: + name: web-desktop + path: seanime-web/out-desktop # output dir of build:desktop + + build-server: + needs: build-webapp # TODO Uncomment if building web + runs-on: ${{ matrix.os }} + + strategy: + matrix: + # 6 binaries: 2 for Windows, 2 for Linux, 2 for macOS + include: + # This is the systray version of the Windows binary used for the server build + - os: macos-latest # seanime-server-systray-windows.exe + id: seanime-server-systray-windows + go_flags: -trimpath -buildmode=exe -ldflags="-s -w -H=windowsgui -extldflags '-static'" + + # This is the non-systray version of the Windows binary used for the Tauri Windows build + - os: windows-latest # seanime-server-windows.exe + id: seanime-server-windows + go_flags: -trimpath -ldflags="-s -w" -tags=nosystray + + # These are the Linux binaries used for the server build and the Tauri Linux build + - os: ubuntu-latest # seanime-server-linux-arm64, seanime-server-linux-amd64 + id: seanime-server-linux + go_flags: -trimpath -ldflags="-s -w" + + # These are the macOS binaries used for the server build and the Tauri macOS build + - os: macos-latest # seanime-server-darwin-arm64, seanime-server-darwin-amd64 + id: seanime-server-darwin + go_env: CGO_ENABLED=0 + go_flags: -trimpath -ldflags="-s -w" + steps: + - name: Checkout code ⬇️ + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history + fetch-tags: true # Fetch all tags + set-safe-directory: true # Add repo path as safe.directory + + - name: Fetch all tags # Fetch all tags (again? can't hurt) + run: git fetch --force --tags + + # Go + - name: Set up Go ⬇️ + uses: actions/setup-go@v5 + with: + go-version: '1.24.1' + + # Download the web folders + # TODO Uncomment if building web + - name: Download web folder artifact + uses: actions/download-artifact@v4 + with: + name: web + path: web + + # Create the binary destination folder + # ./binaries + # |--- ... + - name: Create binary destination folder (UNIX) 🗃️ + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' + run: mkdir -p binaries + - name: Create binary destination folder (Windows) 🗃️ + if: matrix.os == 'windows-latest' + run: mkdir -p binaries + shell: bash + + + #--- + + # ONLY for Windows systray build (seanime-server-systray-windows) + # For the Windows systray build (built on macOS runner), we need to install the necessary dependencies + - name: Install C dependencies ⬇️ # macos windows systray build + if: matrix.id == 'seanime-server-systray-windows' + run: | + brew install filosottile/musl-cross/musl-cross + brew install llvm + brew install mingw-w64 + + # Build the Windows systray binary + # ./binaries/seanime-server-systray-windows.exe + - name: Build Windows Systray 📦️ + if: matrix.id == 'seanime-server-systray-windows' + env: + GOARCH: amd64 + GOOS: windows + CGO_ENABLED: 1 + CC: x86_64-w64-mingw32-gcc + CXX: x86_64-w64-mingw32-g++ + run: | + go build -o seanime-server-systray-windows.exe ${{ matrix.go_flags }} . + + # Build the Windows non-systray binary + # ./seanime-server-windows.exe + - name: Build Windows Non-Systray 📦️ + if: matrix.id == 'seanime-server-windows' + env: + GOARCH: amd64 + GOOS: windows + CGO_ENABLED: 0 + run: | + go build -o seanime-server-windows.exe ${{ matrix.go_flags }} . + shell: bash + + # Build the Linux binaries + # ./seanime-server-linux-amd64 + # ./seanime-server-linux-arm64 + - name: Build Linux 📦️ + if: matrix.id == 'seanime-server-linux' + run: | + CGO_ENABLED=0 GOARCH=amd64 go build -o seanime-server-linux-amd64 ${{ matrix.go_flags }} . + CGO_ENABLED=0 GOARCH=arm64 go build -o seanime-server-linux-arm64 ${{ matrix.go_flags }} . + + # Build the macOS binaries + # ./seanime-server-darwin-amd64 + # ./seanime-server-darwin-arm64 + - name: Build macOS 📦️ + if: matrix.id == 'seanime-server-darwin' + run: | + CGO_ENABLED=0 GOARCH=amd64 go build -o seanime-server-darwin-amd64 ${{ matrix.go_flags }} . + CGO_ENABLED=0 GOARCH=arm64 go build -o seanime-server-darwin-arm64 ${{ matrix.go_flags }} . + + # Tar the binaries + - name: Tar the binaries (UNIX) 🗃️ + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' + # binaries-seanime-server-darwin.tar + # binaries-seanime-server-linux.tar + # binaries-seanime-server-systray-windows.tar + run: | + tar -cf binaries-${{ matrix.id }}.tar seanime-server-* + + # Zip the binaries + - name: Zip the binaries (Windows) 🗃️ + if: matrix.os == 'windows-latest' + # binaries-seanime-server-windows.zip + run: | + 7z a "binaries-${{ matrix.id }}.zip" seanime-server-* + + # Upload the binaries to be used in the next job + - name: Upload binary folder (UNIX) 📤 + uses: actions/upload-artifact@v4 + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' + with: + # go-seanime-server-linux + # go-seanime-server-darwin + # go-seanime-server-systray-windows + name: go-${{ matrix.id }} + path: binaries-${{ matrix.id }}.tar + + - name: Upload binary folder (Windows) 📤 + uses: actions/upload-artifact@v4 + if: matrix.os == 'windows-latest' + with: + # go-seanime-server-windows + name: go-${{ matrix.id }} + path: binaries-${{ matrix.id }}.zip + + + build-tauri: + needs: build-server + + strategy: + fail-fast: false + matrix: + # IDs: + # - seanime-desktop-darwin-arm + # - seanime-desktop-darwin-intel + # - seanime-desktop-linux + # - seanime-desktop-windows + include: + # For Arm-based macs (M1 and above). + - os: 'macos-latest' + id: 'seanime-desktop-darwin-arm' + go_binary_id: 'seanime-server-darwin' # Artifact: go-seanime-server-darwin (contains both arm64 and x86_64) + args: '--target aarch64-apple-darwin' + # For Intel-based macs. + - os: 'macos-latest' + id: 'seanime-desktop-darwin-intel' + go_binary_id: 'seanime-server-darwin' # Artifact: go-seanime-server-darwin (contains both arm64 and x86_64) + args: '--target x86_64-apple-darwin' + # For Linux + - os: 'ubuntu-22.04' # for Linux + id: 'seanime-desktop-linux' # Artifact: go-seanime-server-linux (contains both arm64 and x86_64) + go_binary_id: 'seanime-server-linux' + args: '' + # For Windows + - os: 'windows-latest' # for Windows + id: 'seanime-desktop-windows' # Artifact: go-seanime-server-windows (contains x86_64) + go_binary_id: 'seanime-server-windows' + args: '' + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies (Ubuntu) ⬇️ + if: matrix.os == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Install Rust stable ⬇️ + uses: dtolnay/rust-toolchain@stable + with: + # Those targets are only used on macOS runners so it's in an `if` to slightly speed up windows and linux builds. + targets: ${{ matrix.id == 'seanime-desktop-darwin-intel' && 'x86_64-apple-darwin' || matrix.id == 'seanime-desktop-darwin-arm' && 'aarch64-apple-darwin' || '' }} + + - name: Setup node ⬇️ + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Tauri CLI ⬇️ + run: | + cd seanime-desktop + npm install + + - name: Rust cache ⬇️ + uses: swatinem/rust-cache@v2 + with: + workspaces: './seanime-desktop/src-tauri -> target' + + + # Download the web folder + # TODO Uncomment if building web + - name: Download web folder artifact + uses: actions/download-artifact@v4 + with: + name: web-desktop + path: web-desktop + + # Download the server binaries depending on matrix.go_binary_id + - name: Download server binaries 📥 + uses: actions/download-artifact@v4 + with: + # go-seanime-server-windows or + # go-seanime-server-linux or + # go-seanime-server-darwin + name: go-${{ matrix.go_binary_id }} + path: ./seanime-desktop/src-tauri/binaries + + - name: Extract server binaries (UNIX) 📂 + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + run: tar -xf ./seanime-desktop/src-tauri/binaries/binaries-${{ matrix.go_binary_id }}.tar -C ./seanime-desktop/src-tauri/binaries + - name: Extract server binaries (Windows) 📂 + if: matrix.os == 'windows-latest' + run: 7z x ".\seanime-desktop\src-tauri\binaries\binaries-${{ matrix.go_binary_id }}.zip" "-o./seanime-desktop/src-tauri/binaries/" + + + # ----------------------------------------------------------------- delete + - name: Print downloaded binaries (UNIX) + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + run: ls -la ./seanime-desktop/src-tauri/binaries + - name: Print downloaded binaries (Windows) + if: matrix.os == 'windows-latest' + run: dir ./seanime-desktop/src-tauri/binaries + # ----------------------------------------------------------------- delete + + - name: Determine target triple (UNIX) 🎯 + # id: target_triple + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + run: | + TARGET_TRIPLE=$(rustc -Vv | grep host | cut -f2 -d' ') + echo "TARGET_TRIPLE=${TARGET_TRIPLE}" >> $GITHUB_ENV + + - name: Determine target triple (Windows) 🎯 + # id: target_triple + if: matrix.os == 'windows-latest' + run: | + $TARGET_TRIPLE = rustc -Vv | Select-String "host:" | ForEach-Object {$_.Line.split(" ")[1]} + echo "TARGET_TRIPLE=$TARGET_TRIPLE" >> $env:GITHUB_ENV + shell: pwsh + + # seanime-server-windows.exe -> seanime-x86_64-pc-windows-msvc.exe + - name: Rename sidecar binary (Windows) 📝 + if: matrix.id == 'seanime-desktop-windows' + run: | + powershell -Command "Rename-Item -Path ./seanime-desktop/src-tauri/binaries/seanime-server-windows.exe -NewName seanime-${{ env.TARGET_TRIPLE }}.exe" + + # seanime-server-linux-amd64 -> seanime-unknown-linux-musl + - name: Rename sidecar binaries (Linux) 📝 + if: matrix.id == 'seanime-desktop-linux' + run: | + mv ./seanime-desktop/src-tauri/binaries/seanime-server-linux-amd64 ./seanime-desktop/src-tauri/binaries/seanime-${{ env.TARGET_TRIPLE }} + + # seanime-server-darwin-amd64 -> seanime-x86_64-apple-darwin + - name: Rename sidecar binaries (MacOS Intel) 📝 + if: matrix.id == 'seanime-desktop-darwin-intel' + # Here we hardcode the target triple because the macOS runner is ARM based + run: | + mv ./seanime-desktop/src-tauri/binaries/seanime-server-darwin-amd64 ./seanime-desktop/src-tauri/binaries/seanime-x86_64-apple-darwin + + # seanime-server-darwin-arm64 -> seanime-aarch64-apple-darwin + - name: Rename sidecar binaries (MacOS Arm) 📝 + if: matrix.id == 'seanime-desktop-darwin-arm' + run: | + mv ./seanime-desktop/src-tauri/binaries/seanime-server-darwin-arm64 ./seanime-desktop/src-tauri/binaries/seanime-${{ env.TARGET_TRIPLE }} + + # ----------------------------------------------------------------- delete + - name: Print downloaded binaries + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + run: ls -la ./seanime-desktop/src-tauri/binaries + - name: Print downloaded binaries + if: matrix.os == 'windows-latest' + run: dir ./seanime-desktop/src-tauri/binaries + # ----------------------------------------------------------------- delete + + # Build Tauri + - name: Run Tauri action 🚀 + id: tauri-action + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + # APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + # APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + # APPLE_ID: ${{ secrets.APPLE_ID }} + # APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + # APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + with: + projectPath: './seanime-desktop' + args: ${{ matrix.args }} + updaterJsonPreferNsis: true + + - name: Rename Tauri artifacts (UNIX) 📝 + # ./ + # |- seanime-desktop-darwin-arm.app.tar.gz + # |- seanime-desktop-darwin-arm.app.tar.gz.sig <- Signature + # |- seanime-desktop-darwin-intel.app.tar.gz + # |- seanime-desktop-darwin-intel.app.tar.gz.sig <- Signature + # |- seanime-desktop-linux.AppImage <- UNCOMPRESSED + # |- seanime-desktop-linux.AppImage.sig <- Signature UNCOMPRESSED + # |- seanime-desktop-windows-setup.exe <- UNCOMPRESSED + # |- seanime-desktop-windows-setup.exe.sig <- Signature UNCOMPRESSED + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + # We hardcode the macOS target triple because the macOS runner is ARM based and builds both arm64 and x86_64 + run: | + if [ -f ./seanime-desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz ]; then + mv ./seanime-desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz ./seanime-desktop-darwin-arm.app.tar.gz + mv ./seanime-desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz.sig ./seanime-desktop-darwin-arm.app.tar.gz.sig + + elif [ -f ./seanime-desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz ]; then + mv ./seanime-desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz ./seanime-desktop-darwin-intel.app.tar.gz + mv ./seanime-desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/Seanime\ Desktop.app.tar.gz.sig ./seanime-desktop-darwin-intel.app.tar.gz.sig + + elif [ -f ./seanime-desktop/src-tauri/target/release/bundle/appimage/Seanime\ Desktop_${{ steps.tauri-action.outputs.appVersion }}_amd64.AppImage ]; then + mv ./seanime-desktop/src-tauri/target/release/bundle/appimage/Seanime\ Desktop_${{ steps.tauri-action.outputs.appVersion }}_amd64.AppImage ./seanime-desktop-linux.AppImage + mv ./seanime-desktop/src-tauri/target/release/bundle/appimage/Seanime\ Desktop_${{ steps.tauri-action.outputs.appVersion }}_amd64.AppImage.sig ./seanime-desktop-linux.AppImage.sig + fi + + - name: Rename Tauri artifacts (Windows) 📝 + if: matrix.os == 'windows-latest' + run: | + powershell -Command "Move-Item -Path './seanime-desktop/src-tauri/target/release/bundle/nsis/Seanime Desktop_${{ steps.tauri-action.outputs.appVersion }}_x64-setup.exe' -Destination './seanime-desktop-windows-setup.exe'" + powershell -Command "Move-Item -Path './seanime-desktop/src-tauri/target/release/bundle/nsis/Seanime Desktop_${{ steps.tauri-action.outputs.appVersion }}_x64-setup.exe.sig' -Destination './seanime-desktop-windows-setup.exe.sig'" + + - name: Tar the Tauri artifacts (Linux) 🗃️ + if: matrix.os == 'ubuntu-22.04' + # Note: The macOS artifacts are already packaged, so we don't need to compress them + # Compress the Linux AppImage, not the signature + run: | + if [ -f ./seanime-desktop-linux.AppImage ]; then + tar -czf seanime-desktop-linux.AppImage.tar.gz seanime-desktop-linux.AppImage + fi + - name: Zip the Tauri artifacts (Windows) 🗃️ + if: matrix.os == 'windows-latest' + # Compress the Windows setup, not the signature + run: | + 7z a seanime-desktop-windows-setup.exe.zip seanime-desktop-windows-setup.exe + + # ----------------------------------------------------------------- delete + - name: Print all + if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' + run: ls -la . + - name: Print downloaded binaries + if: matrix.os == 'windows-latest' + run: dir . + # ----------------------------------------------------------------- delete + + # Upload the Tauri artifacts to be used in the next job + - name: Upload tauri artifacts 📤 + uses: actions/upload-artifact@v4 + with: + # Artifact IDs: + # tauri-seanime-server-darwin-arm + # tauri-seanime-server-darwin-intel + # tauri-seanime-server-linux + # tauri-seanime-server-windows + name: tauri-${{ matrix.id }} + path: | + ./seanime-desktop-darwin-arm.app.tar.gz + ./seanime-desktop-darwin-arm.app.tar.gz.sig + ./seanime-desktop-darwin-intel.app.tar.gz + ./seanime-desktop-darwin-intel.app.tar.gz.sig + ./seanime-desktop-linux.AppImage + ./seanime-desktop-linux.AppImage.tar.gz + ./seanime-desktop-linux.AppImage.sig + ./seanime-desktop-windows-setup.exe + ./seanime-desktop-windows-setup.exe.zip + ./seanime-desktop-windows-setup.exe.sig + + + release: + runs-on: ubuntu-latest + needs: [ build-server, build-tauri ] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download tauri artifacts 📥 + uses: actions/download-artifact@v4 + with: + pattern: tauri-* + path: ./artifacts + merge-multiple: true + + - name: Determine version from tag name 🔎 + run: | + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION=${GITHUB_REF/refs\/tags\/v/} + echo "Version extracted from tag: $VERSION" + elif [[ "$GITHUB_REF" == refs/tags/* ]]; then + VERSION=${GITHUB_REF/refs\/tags\//} + echo "Version extracted from tag: $VERSION" + else + echo "Warning: No tag associated with this run. Defaulting to version 0.1.0." + VERSION="0.1.0" + fi + echo "VERSION=$VERSION" >> $GITHUB_ENV + + - name: Print version + run: echo "Version is ${{ env.VERSION }}" + + - name: Download server binaries 📥 + uses: actions/download-artifact@v4 + with: + pattern: go-* + path: ./artifacts + # ./artifacts + # |- binaries-seanime-server-darwin.tar (contains 2) + # |- binaries-seanime-server-linux.tar (contains 2) + # |- binaries-seanime-server-systray-windows.tar (contains 1) + merge-multiple: true + + - name: Print all artifacts + run: ls -la ./artifacts + + - name: Extract - Rename - Archive server binaries 📂 + # ./artifacts + # |- ... + # \/ /binaries-seanime-server-darwin.tar + # |- seanime-server-darwin-amd64 -> ../seanime -> ../seanime-${{ env.VERSION }}_MacOS_arm64.tar.gz + # |- seanime-server-darwin-arm64 -> ../seanime -> ../seanime-${{ env.VERSION }}_MacOS_x86_64.tar.gz + # \/ /binaries-seanime-server-darwin.tar + # |- seanime-server-linux-amd64 -> ../seanime -> ../seanime-${{ env.VERSION }}_Linux_x86_64.tar.gz + # |- seanime-server-linux-arm64 -> ../seanime -> ../seanime-${{ env.VERSION }}_Linux_arm64.tar.gz + # \/ /binaries-seanime-server-systray-windows.tar + # |- seanime-server-systray-windows.exe -> ../seanime.exe -> ../seanime-${{ env.VERSION }}_Windows_x86_64.zip + run: | + if [ -f ./artifacts/binaries-seanime-server-darwin.tar ]; then + # Extract binaries + tar -xf ./artifacts/binaries-seanime-server-darwin.tar -C ./artifacts + + # Rename & compress binaries + mv ./artifacts/seanime-server-darwin-amd64 ./seanime + tar czf ./seanime-${{ env.VERSION }}_MacOS_x86_64.tar.gz ./seanime + rm -rf ./seanime + + + mv ./artifacts/seanime-server-darwin-arm64 ./seanime + tar czf ./seanime-${{ env.VERSION }}_MacOS_arm64.tar.gz ./seanime + rm -rf ./seanime + fi + + if [ -f ./artifacts/binaries-seanime-server-linux.tar ]; then + # Extract binaries + tar -xf ./artifacts/binaries-seanime-server-linux.tar -C ./artifacts + + # Rename & compress binaries + mv ./artifacts/seanime-server-linux-amd64 ./seanime + tar czf ./seanime-${{ env.VERSION }}_Linux_x86_64.tar.gz ./seanime + rm -rf ./seanime + + + mv ./artifacts/seanime-server-linux-arm64 ./seanime + tar czf ./seanime-${{ env.VERSION }}_Linux_arm64.tar.gz ./seanime + rm -rf ./seanime + fi + + if [ -f ./artifacts/binaries-seanime-server-systray-windows.tar ]; then + # Extract binaries + tar -xf ./artifacts/binaries-seanime-server-systray-windows.tar -C ./artifacts + + # Rename & compress binaries + mv ./artifacts/seanime-server-systray-windows.exe ./seanime.exe + 7z a ./seanime-${{ env.VERSION }}_Windows_x86_64.zip ./seanime.exe + rm -rf ./seanime.exe + fi + shell: bash + + - name: Print all artifacts + run: ls -la ./artifacts + + - name: Move & Rename Tauri assets 📝🗃️ + # Move Tauri assets to the root directory and rename them + # ./artifacts + # |- seanime-desktop-darwin-arm.app.tar.gz -> ../seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz + # |- seanime-desktop-darwin-arm.app.tar.gz.sig -> ../seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz.sig + # |- seanime-desktop-darwin-intel.app.tar.gz -> ../seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz + # |- seanime-desktop-darwin-intel.app.tar.gz.sig -> ../seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz.sig + # |- seanime-desktop-linux.AppImage -> ../seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage + # |- seanime-desktop-linux.AppImage.tar.gz -> ../seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.tar.gz + # |- seanime-desktop-linux.AppImage.sig -> ../seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.sig + # |- seanime-desktop-windows-setup.exe -> ../seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe + # |- seanime-desktop-windows-setup.exe.zip -> ../seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe.zip + # |- seanime-desktop-windows-setup.exe.sig -> ../seanime-desktop-${{ env.VERSION }}_Windows_x86_64.sig + run: | + if [ -f ./artifacts/seanime-desktop-darwin-arm.app.tar.gz ]; then + mv ./artifacts/seanime-desktop-darwin-arm.app.tar.gz ./seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz + mv ./artifacts/seanime-desktop-darwin-arm.app.tar.gz.sig ./seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz.sig + fi + if [ -f ./artifacts/seanime-desktop-darwin-intel.app.tar.gz ]; then + mv ./artifacts/seanime-desktop-darwin-intel.app.tar.gz ./seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz + mv ./artifacts/seanime-desktop-darwin-intel.app.tar.gz.sig ./seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz.sig + fi + if [ -f ./artifacts/seanime-desktop-linux.AppImage.tar.gz ]; then + mv ./artifacts/seanime-desktop-linux.AppImage ./seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage + mv ./artifacts/seanime-desktop-linux.AppImage.tar.gz ./seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.tar.gz + mv ./artifacts/seanime-desktop-linux.AppImage.sig ./seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.sig + fi + if [ -f ./artifacts/seanime-desktop-windows-setup.exe.zip ]; then + mv ./artifacts/seanime-desktop-windows-setup.exe ./seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe + mv ./artifacts/seanime-desktop-windows-setup.exe.zip ./seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe.zip + mv ./artifacts/seanime-desktop-windows-setup.exe.sig ./seanime-desktop-${{ env.VERSION }}_Windows_x86_64.sig + fi + + - name: Print all + run: ls -la . + + # Go + - name: Set up Go ⬇️ + uses: actions/setup-go@v5 + with: + go-version: '1.24.1' + + # Build the Go script + - name: Build Go scripts 🛠️ + run: | + go build -o generate_updater_latest ./.github/scripts/generate_updater_latest.go + go build -o generate_release_notes ./.github/scripts/generate_release_notes.go + + # Run the Go scripts + - name: Generate latest.json 📦️ + env: + APP_VERSION: ${{ env.VERSION }} + run: ./generate_updater_latest + - name: Generate release notes 📦️ + env: + APP_VERSION: ${{ env.VERSION }} + run: ./generate_release_notes + + - name: Read release notes 🔍 + id: read_release_notes + run: | + BODY=$(cat whats-new.md) + echo "RELEASE_BODY<> $GITHUB_ENV + echo "$BODY" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Create release draft 🚀🚀🚀 + id: create_release + uses: softprops/action-gh-release@v2 + with: + fail_on_unmatched_files: false + files: | + latest.json + seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz + seanime-desktop-${{ env.VERSION }}_MacOS_arm64.app.tar.gz.sig + seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz + seanime-desktop-${{ env.VERSION }}_MacOS_x86_64.app.tar.gz.sig + seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage + seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.tar.gz + seanime-desktop-${{ env.VERSION }}_Linux_x86_64.AppImage.sig + seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe + seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe.zip + seanime-desktop-${{ env.VERSION }}_Windows_x86_64.exe.sig + seanime-${{ env.VERSION }}_MacOS_x86_64.tar.gz + seanime-${{ env.VERSION }}_MacOS_arm64.tar.gz + seanime-${{ env.VERSION }}_Linux_x86_64.tar.gz + seanime-${{ env.VERSION }}_Linux_arm64.tar.gz + seanime-${{ env.VERSION }}_Windows_x86_64.zip + token: ${{ secrets.GITHUB_TOKEN }} + tag_name: v${{ env.VERSION }} + release_name: v${{ env.VERSION }} + draft: true + prerelease: false + body: | + ## What's new? + + ${{ env.RELEASE_BODY }} + + --- + [Open an issue](https://github.com/5rahim/seanime/issues/new/choose) diff --git a/seanime-2.9.10/.gitignore b/seanime-2.9.10/.gitignore new file mode 100644 index 0000000..dc6d171 --- /dev/null +++ b/seanime-2.9.10/.gitignore @@ -0,0 +1,91 @@ +.idea +.junie +logs/ +*.db +.run/ +testdata/ +.vscode +.cursor +.DS_Store +*/.DS_Store + +Dockerfile +Dockerfile.dev +dev.dockerfile +.dockerignore + +go.work + +_docker-compose.yml + +logs +web/ +web-desktop/ +web-denshi/ +out/ +./assets/ + +test/testdata/**/*.json +test/sample +test/providers.json +test/db.json +test/providers.json +test/config.json +test/config.toml +whats-new.md + +*/mock_data.json + +TODO-priv.md + +## +## Parser +## +seanime-parser/.git + +## +## Web +## +seanime-web/node_modules +seanime-web/out +seanime-web/out-desktop +seanime-web/out-denshi +seanime-web/web +seanime-web/.next +seanime-web/.idea +# dependencies +seanime-web/.pnp +seanime-web/.pnp.js +seanime-web/.yarn/install-state.gz +# testing +seanime-web/coverage +# next.js +# production +seanime-web/build +# misc +seanime-web/.DS_Store +seanime-web/*.pem +# debug +seanime-web/npm-debug.log* +seanime-web/yarn-debug.log* +seanime-web/yarn-error.log* +# local env files +seanime-web/.env*.local +# vercel +seanime-web/.vercel +# typescript +seanime-web/*.tsbuildinfo +seanime-web/next-env.d.ts +seanime-web/snapshot +seanime-web/logs +seanime-web/analyze +seanime-web/TODO-priv.md +seanime-web/CHANGELOG-priv.md + +codegen/generated/hooks.mdx + + +internal/extension_repo/goja_onlinestream_test/test1.ts +internal/extension_repo/goja_plugin_test + +*.sh diff --git a/seanime-2.9.10/.golangci.yml b/seanime-2.9.10/.golangci.yml new file mode 100644 index 0000000..9ec8cec --- /dev/null +++ b/seanime-2.9.10/.golangci.yml @@ -0,0 +1,11 @@ +run: + concurrency: 4 + timeout: 1m + issues-exit-code: 1 + tests: true + +linters: + disable-all: true + + enable: + - exhaustruct diff --git a/seanime-2.9.10/CHANGELOG.md b/seanime-2.9.10/CHANGELOG.md new file mode 100644 index 0000000..15741a8 --- /dev/null +++ b/seanime-2.9.10/CHANGELOG.md @@ -0,0 +1,1223 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## v2.9.10 + +- ⚡️ Plugins: Added Schedule and Filler management hooks +- 🦺 TorBox: Fixed streaming uncached torrents +- 🦺 Nakama (Sharing): Do not share unmatched entries +- 🦺 Nakama (Sharing): Fixed unwatched count in detailed library view +- 🦺 Server Password: Fixed auth redirection on iOS +- 🦺 Server: Update anime collection in modules when manually refreshing +- 🦺 Torrent/Debrid streaming: Lowered episode list cache duration + +## v2.9.9 + +- 🦺 Fixed torrent streaming for desktop players + +## v2.9.8 + +- 🦺 External Player Link: Fixed torrent streaming links +- 🦺 VLC, MPC-HC: Fixed input URI encoding +- 🦺 M3u8 Proxy: Potential fix for missed rewrites +- 🦺 Server Password: Do not load page before authentication +- 🦺 Online streaming: Do not always restore fullscreen +- 🦺 Fixed some UI bugs + +## v2.9.7 + +- ⚡️ Nakama: Better default titles with MPV +- ⚡️ External Player Links: New variables for custom scheme #345 + - {mediaTitle}, {episodeNumber}, {formattedTitle}, {scheme} +- 🦺 Fixed Auto Downloader not working with Debrid +- 🦺 Auto Play: Use same torrent when playback is started from previous selection +- 🦺 Nakama: Fixed external player link starting playback on system player +- 🦺 Online streaming: Fixed m3u8 Proxy skipping some URIs #396 +- 🦺 Fixed VLC progress tracking for local file playback #398 +- 🦺 Plugin Hooks: Fixed some events being ignored +- 🦺 Online streaming: Invalidate all episode queries when emptying cache +- 🏗️️ Online streaming: Display errors in the UI + +## v2.9.6 + +- 🦺 Fixed server crash caused by navigating to 'Schedule' page + +## v2.9.5 + +- ⚡️ Updated Discord RPC: Media title used as activity name, links +- ⚡️ Offline mode: Option to auto save currently watched/read media locally #376 +- ⚡️ Offline mode: Bulk select media to save locally #377 +- ⚡️ Metadata: Prefer TVDB title when AniDB isn't up-to-date +- ⚡️ Scan summaries: Search input for filenames +- 🦺 Potential fixes for high memory usage and app slowdowns +- 🦺 Torrent list: Fixed 'Stop seeding all' button pausing downloading torrents +- 🦺 Playground: Fixed UI crash caused by console logs +- 🦺 Scanner: Fixed matching being messed up by "Part" keyword in filenames +- 🦺 Parser: Fixed folder names with single-word titles being ignored +- 🦺 Online streaming: Don't hide button for adult entries +- 🦺 Online streaming: Fixed wrong episode selection when page is loaded #384 +- 🦺 Potential fix for auto play not being canceled +- 🦺 Nakama: Fixed host's list data being added to anime that aren't in the collection +- 🦺 External Player Link: Fixed incorrect stream URL when server password is set +- 🦺 Media player: Use filepaths for comparison when loading media instead of filenames +- 🦺 Nakama: Fixed case sensitivity issue when comparing file paths on Windows +- 🦺 Fixed external player links by encoding stream URL if it contains a query parameter #387 +- 🦺 Playlists: Fixed playlist deletion +- 🏗️ Slight changes to the online streaming page for more clarity +- 🏗️ Settings: Added memory profiling to 'logs' section +- 🏗️ Anime: Removed (obsolete) manual TVDB metadata fetching option +- 🏗️ Perf(Extensions): Do not download payload when checking for updates + +## v2.9.4 + +- ⚡️ Migrated to Seanime's own anime metadata API +- ⚡️ Release calendar: Watch status is now shown in popovers +- 🦺 Fixed schedule missing some anime entries due to custom lists +- 🦺 Watch history: Fixed resumed playback not working for local files +- 🦺 Fixed streaming anime with no AniList schedule and no episode count +- 🦺 Fixed 'Upload local lists to AniList' button not working +- 🦺 Fixed repeated entries in 'Currently watching' list on the AniList page + +## v2.9.3 + +- ⚡️ Plugins: Added Textarea component, 'onSelect' event for input/textarea +- 🦺 Fixed release calendar missing long-running series +- 🦺 Include in Library: Fixed 'repeating' entries not showing up + +## v2.9.2 + +- ⚡️ Discover: Added 'Top of the Season', genre filters to more sections +- ⚡️ Nakama: Detailed library view now available for shared library +- ⚡️ TorBox: Optimized TorBox file list query - @MidnightKittenCat +- ⚡️ Episode pagination: Bumped number of items per page to 24 +- 🦺 Nakama: Fixed dropdown menu not showing up for shared anime +- 🦺 Nakama: Base unwatched count on shared episodes +- 🦺 Scanner: Fixed modal having 'Use anilist data' checked off by default +- 🦺 UI: Revert to modal for AniList entry editor on media cards +- 🦺 Plugins: Allow programmatic tray opening on mobile +- 🦺 Fixed incorrect dates in AniList entry editor #356 +- 🦺 UI: Revert incorrect video element CSS causing pixelation #355 + +## v2.9.1 + +- 🦺 Server Password: Fixed token validation on public endpoints +- 🦺 Server Password: Fixed login from non-localhost, HTTP clients #350 +- ⚡️ Release calendar: Option to disable image transitions +- ⚡️ Manga: Double page offset keybindings - @Ari-03 +- 🦺 Plugin: Fixed newMediaCardContextMenuItem and other APIs +- 🦺 Fixed IINA settings not being applied +- 🏗️ Downgraded Next.js and React Compiler + - Potential solution for client-side rendering errors #349 + +## v2.9.0 + +- 🎉 New feature: Nakama - Communication between Seanime instances + - You can now communicate with other Seanime instances over the internet +- 🎉 Nakama: Watch together (Alpha) + - Watch (local media, torrent or debrid streams) together with friends with playback syncing + - Peers will stream from the host with synchronized playback +- 🎉 Nakama: Share your anime library (Alpha) + - Share your local anime library with other Seanime instances or consume your remote library +- ✨ Local account + - By default, Seanime no longer requires an AniList account and stores everything locally +- ✨ Server password + - Lock your exposed Seanime instance by adding a password in your config file +- ✨ Manga: Local source extension (Alpha) + - New built-in extension for reading your local manga (CBZ, ZIP, Images) +- ✨ New schedule calendar +- ✨ macOS: Support for IINA media player +- ✨ Toggle offline mode without restarting the server +- ✨ New getting started screen +- ⚡️ Discord: Pausing anime does not remove activity anymore +- ⚡️ UI: New setting option to unpin menu items from the sidebar +- ⚡️ UI: Added pagination for long episode lists +- ⚡️ Online streaming: Episode number grid view +- ⚡️ Performance: Plugins: Deduplicate and batch events +- ⚡️ Discord: Added option to show media title in activity status (arRPC only) - @kyoruno +- ⚡️ PWA support (HTTPS only) - @HyperKiko +- ⚡️ MPV/IINA: Pass custom arguments +- ⚡️ Discord: Keep activity when anime is paused +- ⚡️ UI: Updated some animations +- 🦺 Fixed multiple Plugin API issues +- 🦺 Goja: Added OpenSSL support to CryptoJS binding +- 🦺 Fixed filecache EOF error +- 🦺 Fixed offline syncing + +## v2.8.5 + +- 🦺 Fixed scraping for manga extensions +- 🦺 Library: Fixed bulks actions not available for unreleased anime +- 🦺 Auto Downloader: Button not showing up for finished anime +- 🦺 Online streaming: Fixed 'auto next episode' not working for some anime + +## v2.8.4 + +- ⚡️ Plugin development improvements + - New Discord Rich Presence event hooks + - New bindings for watch history, torrent client, auto downloader, external player link, filler manager + - Plugins in development mode that experience a fatal error can now be reloaded multiple times + - Uncaught exceptions are now correctly logged in the browser devtool console +- 🦺 Fixed macOS/iOS client-side exception caused by 'upath' #238 +- 🦺 Removed 'add to list' buttons in manga download modal media cards +- 🦺 Manga: Fixed reader keybinding editing not working on macOS desktop +- 🦺 Fixed AniList page filters not persisting +- 🦺 Fixed 'Advanced Search' input not being emptied when resetting search params +- 🦺 Extensions: Fixed caught exceptions being logged as empty objects +- 🦺 Fixed extension market button disabled by custom background image +- 🦺 Fixed Plugin APIs + - Fixed DOM manipulation methods not working + - Correctly remove DOM elements created by plugin when unloaded + - Fixed incorrectly named hooks + - Fixed manga bindings for promises + - Fixed select and radio group tray components + - Fixed incorrect event object field mapping (Breaking) +- 🏗️ Frontend: Replace 'upath' dependency + +## v2.8.3 + +- ⚡️ Updated Playground +- ⚡️ Discover page: Play the trailer on hover; carousel buttons +- 🦺 Playground: Fix online streaming search options missing media object +- 🦺 Discord: Fixed anime rich presence displaying old episodes +- 🦺 Discord: Fixed manga rich presence activity #282 +- 🦺 Library: Fixed anime unwatched count for shows not in the library +- 🦺 Library: Fixed filtering for shows not in the library +- 🦺 Library: Fixed 'Show unwatched only' filter +- 🦺 Torrent search: Fixed Nyaa batch search with 'any' resolution +- 🏗️ Torrent Search: Truncate displayed language label number + +## v2.8.2 + +- ✨ UI: Custom CSS support +- ✨ In-app extension marketplace + - Find extensions to install directly from the interface +- ⚡️ Discord: Rich Presence anime activity with progress track +- ⚡️ Torrent: New 'Nyaa (Non-English)' built-in extension with smart search +- ⚡️ Torrent search: Added labels for audio, video, subtitles, dubs +- ⚡️ Torrent search: Improved non-smart search UI +- ⚡️ Extensions: Built-in extensions now support user preferences + - API Urls are now configurable for some built-in extensions +- ⚡️ Extensions: Auto check for updates with notification +- ⚡️ Extensions: Added media object to Online streaming search options +- ⚡️ Extensions: User config (preferences) now accessible with '$getUserPreference' global function +- ⚡️ UI Settings: Color scheme live preview #277 +- ⚡️ Manga: Fullscreen toggle on mobile (Android) #279 +- 🦺 Library: Fixed genre selector making library disappear #275 +- 🦺 Online streaming: Fixed search query being altered +- 🦺 Fixed offline mode infinite loading screen (regression from v2.7.2) #278 +- 🦺 Extensions: Fixed playground console output #276 +- 🦺 Extensions: Fixed JS extension pool memory leak +- 🦺 Extensions: Fixed Plugin Actions API +- 🏗️ Removed Cloudflare bypass from ComicK extension +- 🏗️ Extensions: Deprecated 'getMagnetLinkFromTorrentData' in favor of '$torrentUtils.getMagnetLinkFromTorrentData' +- 🏗️ Plugins: New 'ctx.anime' API +- 🏗️ Server: Use binary (IEC) measurement on Windows and Linux #280 +- 🏗️ Extensions: Updated and fixed type declaration files +- 🏗️ Extensions: New 'semverConstraint' field + +## v2.8.1 + +- 🦺 Fixed runtime error when launching the app for the first time +- 🦺 Fixed torrent search episode input +- 🦺 Fixed update popup showing empty "Updates you've missed" + +## v2.8.0 + +- 🎉 Plugins: A powerful new way to extend and customize Seanime + - Build your own features using a wide range of APIs — all in JavaScript. +- ✨ Playback: Faster media tracking, better responsiveness + - Faster autoplay, progress tracking, playlists +- ✨ Torrent streaming: Improved performance and responsiveness + - Streams start up to 2x faster, movies start up to 50x faster +- ✨ Server: DNS over HTTPS support +- ✨ Manga: Refresh all sources at once #233 +- ✨ Library/Streaming: Episode list now includes specials included by AniList in main count +- ✨ Torrent search: Sorting options #253 +- ✨ Debrid streaming: Improved stream startup time +- ✨ Library: New 'Most/least recent watch' sorting options (w/ watch history enabled) #244 +- ✨ Extensions: Ability to edit the code of installed extensions +- ⚡️ Streaming: Added Nyaa as a fallback provider for auto select +- ⚡️ Manga: Unread count badge now takes into account selected scanlator and language +- ⚡️ Torrent list: Stop all completed torrents #250 +- ⚡️ Library/Streaming: Improved handling of discrepancies between AniList and AniDB +- ⚡️ Library: Show episode summaries by default #265 +- ⚡️ UI: Option to hide episode summaries and episode filename +- ⚡️ AniList: Option to clear date field when editing entry +- ⚡️ Extensions: New 'Update all' button to update all extensions at once +- ⚡️ Extensions: Added 'payloadURI' as an alternative to pasting extension code +- ⚡️ Extensions: 'Development mode' that allows loading source code from a file in the manifest +- ⚡️ Torrent streaming: Option to change cache directory +- ⚡️ Manga: Selecting a language will now filter scanlator options and vice versa +- ⚡️ Discover page: Context menu for 'Airing Schedule' items #267 - @kyoruno +- ⚡️ Added AniList button to preview modals #264 - @kyoruno +- 🦺 Fixed AnimeTosho smart search #260 +- 🦺 AutoPlay: Fixed autoplay starting erroneously +- 🦺 Scanner: Fixed local file parsing with multiple directories +- 🦺 Scanner: Fixed resolved symlinks being ignored #251 +- 🦺 Scanner: Removed post-matching validation causing some files to be unmatched #246 +- 🦺 Library: Fixed 'unwatched episode count' not showing with 'repeating' status +- 🦺 Library: Fixed incorrect episode offset for some anime +- 🦺 Torrent search: Fixed excessive API requests being sent during search query typing +- 🦺 Parser: Fixed crash caused by parsing 'SxExxx-SxExxx' +- 🦺 Video Proxy: Fixed streaming .mp4 media files - @kRYstall9 +- 🦺 Extensions: Fixed bug causing invalid extensions to be uninstallable from UI +- 🦺 Extensions: Fixed concurrent fetch requests and concurrent executions +- 🏗️ Debrid streaming changes + - Added visual feedback when video is being sent to media player + - Removed stream integrity check for faster startup +- 🏗️ Refactored websocket system + - New bidirectional communication between client and server + - Better handling of silent websocket connection closure +- 🏗️ Refactored extension system + - Usage of runtime pools for better performance and concurrency + - Improved JS bindings/bridges +- 🏗️ Web UI: Added data attributes to HTML elements +- 🏗️ Offline mode: Syncing now caches downloaded chapters if refetching +- 🏗️ BREAKING(Extensions): Content provider extension methods are now run in separate runtimes + - State sharing across methods no longer works but concurrent execution is now possible +- ⬆️ Migrated to Go 1.24.1 +- ⬆️ Updated dependencies + +## v2.7.5 + +- 🦺 Extensions: Fixed runtime errors caused by concurrent requests +- 🦺 Manga: Removed light novels from manga library #234 +- 🦺 Fixed torrent stream overlay blocking UI #243 +- 🏗️ Server: Removed DNS resolver fallback + +## v2.7.4 + +- 🚑️ Fixed infinite loading screen when launching app for the first time +- ⚡️ External player link: Option to encode file path to Base64 (v2.7.3) +- 🦺 Desktop: Fixed startup failing due to long AniList request (v2.7.3) +- 🦺 Debrid: Fixed downloading to nonexistent destination (v2.7.3) +- 🦺 Anime library: Fixed external player link not working due to incorrect un-escaping (v2.7.3) +- 🦺 Small UI fixes (v2.7.3) +- 🏗️ Server: Support serving Base64 encoded file paths (v2.7.3) + +## v2.7.3 + +- ⚡️ External player link: Option to encode file path to Base64 +- 🦺 Desktop: Fixed startup failing due to long AniList request #232 +- 🦺 Debrid: Fixed downloading to nonexistent destination #237 +- 🦺 Anime library: Fixed external player link not working due to incorrect un-escaping #240 +- 🦺 Small UI fixes +- 🏗️ Server: Support serving Base64 encoded file paths + +## v2.7.2 + +- 🦺 Fixed error alert regression +- 🦺 Anime library: Fixed downloading to library root #231 +- 🦺 Fixed getting log file contents on Linux +- 🏗️ Use library for 'copy to clipboard' feature + +## v2.7.1 + +- ⚡️ Transcoding: Support for Apple VideoToolbox hardware acceleration +- ⚡️ Manga: New built-in extension +- 🦺 Fixed hardware acceleration regression +- 🦺 Fixed client cookie regression causing external player links to fail +- 🦺 Fixed Direct Play regression #224 +- 🦺 Anime library: Fixed selecting multiple episodes to download at once #223 +- 🦺 Desktop: Fixed copy to clipboard +- 🦺 Fixed UI inconsistencies +- 🏗️ Extensions: Removed non-working manga extension +- 🏗️ Improved logging in some areas +- 🏗️ Desktop: Refactored macOS fullscreen + +## v2.7.0 + +- ✨ Updated design +- ✨ Command palette (Experimental) + - Quickly browse, search, perform actions, with more options to come + - Allows navigation with keyboard only #46 +- ✨ Preview cards + - Preview an anime/manga by right-clicking on a media card +- ✨ Library: Filtering options #210 + - Filter to see only anime with unseen episodes and manga with unread chapters #175 (Works if chapters are cached) + - New sorting options: Aired recently, Highest unwatched count, ... +- ✨ New UI Settings + - 'Continue watching' sorting, card customization + - Show unseen count for anime cards #209 +- ⚡️ Torrent/Debrid streaming: 'Auto play next episode' now works with manually selected batches #211 + - This works only if the user did not select the file manually +- ⚡️ Server: Reduced memory usage, improved performance +- ⚡️ Discord Rich Presence now works with online & media streaming +- ⚡️ 'Continue watching' UI setting options, defaults to 'Aired recently' + - BREAKING: Manga unread count badge needs to be reactivated in settings +- ⚡️ Torrent streaming: Slow seeding mode #200 +- ⚡️ Debrid streaming: Auto-select file option +- ⚡️ Quick action menu #197 + - Open preview cards, more options to come +- ⚡️ Revamped Settings page +- ⚡️ Anime library: Improved Direct Play performance +- ⚡️ Quickly add media to AniList from its card +- 🦺 Torrent streaming: Fixed auto-selected file from batches not being downloaded #215 + - Fixed piece prioritization +- 🦺 Debrid streaming: Fixed streaming shows with no AniDB mapping +- 🦺 Anime library: 'Remove empty directories' now works for other library folders +- 🦺 Anime library: Download destination check now takes all library paths into account +- 🦺 Online streaming: Fixed 'auto next' not playing the last episode +- 🦺 Server: Fixed empty user agent header leading to some failed requests +- 🦺 Anime library: Ignore AppleDouble files on macOS #208 +- 🦺 Manga: Fixed synonyms not being taken into account for auto matching +- 🦺 Manga: Fixed genre link opening anime in advanced search +- 🦺 Extension Playground: Fixed anime torrent provider search input empty value +- 🦺 Continuity: Ignore watch history above a certain threshold +- 🦺 Online streaming: Fixed selecting highest quality by default +- 🦺 Fixed Auto Downloader queuing same items +- 🦺 Manga: Fixed pagination when filtering by language/scanlator #217 +- 🦺 Manga: Fixed page layout overflowing on mobile +- 🦺 Torrent streaming: Fixed incorrect download/upload speeds +- 🦺 Anime library: Fixed special episode sorting +- 🏗️ Server: Migrated API from Fiber (FastHTTP) to Echo (HTTP) +- 🏗 External media players: Increased retries when streaming +- 🏗 Torrent streaming: Serve stream from main server +- 🏗 Watch history: Bumped limit from 50 to 100 +- 🏗 Integrated player: Merged both online & media streaming players + - BREAKING: Auto play, Auto next, Auto skip player settings have been reset to 'off' +- 🏗 Renaming and Removals + - Scanner: Renamed 'matching data' checkbox + - Torrent/Debrid streaming: Renamed 'Manually select file' to 'Auto select file' + - Removed 'Use legacy episode cards' option + - 'Fluid' media page header layout is now the default +- ⬆️ Migrated to Go 1.23.5 +- ⬆️ Updated dependencies + +## v2.6.2 + +- ⚡️ Advanced search: Maintain search params during navigation #195 +- 🦺 Torrent streaming: Fixed playback issue +- 🦺 Auto Downloader: Fixed list not updating correctly after batch creation +- 🔧 Torrent streaming: Reverted to using separate streaming server + +## v2.6.1 + +- ⚡️ Anime library: Filtering by year now takes into account the season year +- ⚡️ Torrent streaming: Custom stream URL address setting #182 +- 🦺 Scanner: Fixed duplicated files due to incorrect path comparison +- 🦺 Use AniList season year instead of start year for media cards #193 +- 🏗️ Issue recorder: Increase data cap limit + +## v2.6.0 + +- ✨ In-app issue log recorder + - Record browser, network and server logs from an issue you encounter in the app and generate an anonymized file to send for bug reports +- ⚡️ Auto Downloader: Added support for batch creation of rules #180 +- ⚡️ Scanner: Improved default matching algorithm +- ⚡️ Scanner: Option to choose different matching algorithms +- ⚡️ Scanner: Improved filename parser, support for SxPx format +- ⚡️ Scanner: Reduced log file sizes and forced logging to single file per scan +- ⚡️ Improved Discover manga page +- ⚡️ New manga filters for country and format #191 +- ⚡️ Torrent streaming: Serve streams from main server (Experimental) + - Lower memory usage, removes need for separate server +- ⚡️ Auto deletion of log files older than 14 days #184 +- ⚡️ Online streaming: Added 'f' keybinding to restore fullscreen #186 +- 💄 Media page banner image customization #185 +- 💄 Media banner layout customization +- 💄 Updated user interface settings page +- 💄 Updated some styles +- 💄 Added 'Fix border rendering artifacts' option to UI settings +- 🦺 Fixed Auto Downloader form #187 +- 🦺 Streaming: Fixed auto-select for media with very long titles +- 🦺 Fixed torrent streaming on VLC +- 🦺 Fixed MPV resumed playback with watch continuity enabled +- 🦺 Desktop: Fixed sidebar menu item selection +- 🏗️ Auto Downloader: Set minimum refresh interval to 15 minutes (BREAKING) + - If your refresh interval less than 15 minutes, it will be force set to 20 minutes. Update the settings accordingly. +- 🏗️ Moved 'watch continuity' setting to 'Seanime' tab + +## v2.5.2 + +- 🦺 Fixed SeaDex extension #179 +- 🦺 Fixed Auto Downloader title comparison +- 🦺 Fixed m3u8 proxy HTTP/2 runtime error on Linux +- 🦺 Fixed Auto Downloader array fields +- 🦺 Fixed online streaming error caused by decimals +- 🦺 Fixed manual progress tracking cancellation +- 🦺 Fixed playback manager deadlock +- 🦺 Desktop: Fixed external player links +- 🦺 Desktop: Fixed local file downloading (macOS) +- 🦺 Desktop: Fixed 'open in browser' links (macOS) +- 🦺 Desktop: Fixed torrent list UI glitches (macOS) +- 🏗️ Desktop: Added 'reload' button to loading screen +- ⬆️ Updated filename parser + - Fixes aggressive episode number parsing in rare cases +- ⬆️ Updated dependencies +- 🔑 Updated license to GPL-3.0 + +## v2.5.1 + +- 💄 Updated built-in media player theme +- 🦺 Fixed Auto Downloader form fields (regression) +- 🦺 Fixed online streaming extension API url (regression) +- ⬆️ Migrated to Go 1.23.4 +- ⬆️ Updated dependencies + +## v2.5.0 + +- ⚡️ UI: Improved rendering performance +- ⚡️ Online streaming: Built-in Animepahe extension (Experimental) +- ⚡️ Desktop: Automatically restart server process when it crashes/exits +- ⚡️ Desktop: Added 'Restart server' button when server process is terminated +- ⚡️ Auto progress update now works for built-in media player +- ⚡️ Desktop: Back/Forward navigation buttons #171 +- ⚡️ Open search page by clicking on media genres and ranks #172 +- ⚡️ Support for AniList 'repeat' field #169 +- ⚡️ Ignore dropped anime in missing episodes #170 +- ⚡️ Improved media player error logging +- ⚡️ Online streaming: m3u8 video proxy support +- ⚡️ Ability to add to AniList individually in 'Resolve unknown media' +- 🦺 Fixed TorBox failed archive extraction +- 🦺 Fixed incorrect 'user-preferred' title languages +- 🦺 Fixed One Piece streaming episode list +- 🦺 Added workaround for macOS video player fullscreen issue #168 + - Clicking 'Hide from Dock' from the tray will solve the issue +- 🦺 Fixed torrent streaming runtime error edge case +- 🦺 Fixed scanner 'Do not use AniList data' runtime error +- 🦺 Fixed Transmission host setting not being applied +- 🦺 Javascript VM: Fixed runtime panics caused by 'fetch' data races +- 🦺 Online streaming: Fixed scroll to current episode +- 🦺 Online streaming: Fixed selecting highest/default quality by default +- 🦺 Fixed UI inconsistencies +- 🏗️ Removed 'Hianime' online streaming extension +- 🏗️ Real Debrid: Select all files by default +- 🏗️ UI: Improved media card virtualized grid performance +- 🏗️ Javascript VM: Added 'url' property to fetch binding +- 🏗️ Reduced online streaming cache duration +- 🏗️ Core: Do not print stack traces concurrently +- 🏗️ UI: Use React Compiler (Experimental) +- ⬆️ Updated dependencies + +## v2.4.2 + +- ⚡️ 'Include in library' will keep displaying shows when caught up +- ⚡️ Settings: Open data directory button +- 🦺 Desktop: Fixed authentication issue on macOS +- ⚡️ Desktop: Force single instance +- ⚡️ Desktop: Try to shut down server on force exit +- ⚡️ Desktop: Disallow update from Web UI +- 🦺 Desktop: Fixed 'toggle visibility' +- 🦺 Desktop: Fixed 'server process terminated' issue + +## v2.4.1 + +- ⚡️ Desktop: Close to minimize to tray + - The close button no longer exits the app, but minimizes it to the system tray + - Exit the app by right-clicking the tray icon and selecting 'Quit Seanime' +- ⚡️ Qbittorrent: Custom tag settings #140 +- 🦺 Fixed Linux server requiring libc +- 🦺 Desktop: Fixed 'toggle visibility' + +## v2.4.0 + +- 🚀 Desktop app + - You can now download the new desktop app for Windows, macOS, and Linux + - The desktop app is a standalone GUI that embeds its own server +- 🦺 Anime library: Fixed toggle lock button +- 🦺 Torrent streaming: Fixed file previews +- 🏗️ Rename 'enhanced scanning' +- 🔨 Updated release workflow + +## v2.3.0 + +- ✨ Real-Debrid support for streaming and downloading +- ⚡️ Manga: Unread chapter count badge +- ⚡️ HTTPS support for qBittorrent and Transmission +- ⚡️ Online streaming: Theater mode +- 🦺 Scanner: Fixed NC false-positive edge case +- 🦺 Fixed pause/resume action for qBittorrent v5 #157 +- 🏗️ Added fallback update endpoint & security check +- 🏗️ Fixed update notification reliability +- 🏗️ Fixed cron concurrency issue + + +## v2.2.3 + +- 🦺 Offline: Fixed episode images not showing up without an internet connection + - Remove and add saved series again to fix the issue +- 🦺 Offline: Download only used images +- 🦺 Debrid streaming: Fixed MPV --title flag +- 🦺 Debrid streaming: Fixed stream cancellation +- ⚡️ Media streaming: Custom FFmpeg hardware acceleration options +- 🏗️ Moved filename parser to separate package + +## v2.2.2 + +- ✨ Debrid Streaming: Auto select (Experimental) +- ⚡️ Scanner: Improved episode normalization logic +- ⚡️ Debrid Streaming: Retry mechanism for stream URL checks +- ⚡️ Online streaming: New "Include in library" setting +- ⚡️ Online streaming: Show fetched image & filler metadata on episode cards +- ⚡️ Settings: Torrent client "None" option +- 💄 UI: Integrated online streaming view in anime page +- 🦺 Fixed custom background images not showing up #148 +- 🦺 Fixed external player link for downloaded Specials/NC files #139 +- 🦺 Fixed "contains" filter for Auto Downloader #149 +- 🏗️ Merged "Default to X view" and "Include in library" settings for torrent & debrid streaming +- 🏗️ Made library path optional for onboarding and removable in settings +- 🏗️ Updated empty library screen +- 🏗️ Fix Go toolchain issue #150 + +## v2.2.1 + +- ⚡️ New getting started page +- ⚡️ Auto Downloader: Added 'additional terms' filter option +- 🦺 Torrent streaming: Fixed auto-select runtime error +- 🦺 Fixed auto-scanning runtime error +- 🦺 Fixed issue with inexistant log directory +- 🦺 Torrent streaming: Fixed runtime error caused by missing settings +- 🦺 Fixed scan summaries of unresolved files not showing up + +## v2.2.0 + +- 🎉 New offline mode + - New local data system with granular updates, removing the need for re-downloading metadata each time. Option for automatic local data refreshing. Support for media streaming. Better user interface for offline mode. +- 🎉 Debrid support starting with TorBox integration + - TorBox is now supported for downloading/auto-downloading and streaming torrents. + - Automatic local downloading once a torrent is ready +- 🎉 Watch continuity / Resumable playback + - Resume where you left off across all playback types (downloaded, online streaming, torrent/debrid streaming) +- ✨ Support for multiple library directories +- ✨ Export & import anime library data +- ⚡️ Improved scanner and matcher + - Matcher now prioritizes distance comparisons to avoid erroneous matches +- ⚡️ Extensions: User configs +- ⚡️ Improved Auto Downloader title comparisons #134 + - New ‘Verify season’ optional setting to improve accuracy if needed +- ⚡️ Online streaming: Manual match +- ⚡️ Torrent streaming: Change default torrent client host #132 +- ⚡️ JS Extensions: Torrent data to magnet link global helper function #138 +- ⚡️ Media streaming: Direct play only option +- ⚡️ Built-in player: Discrete controls (Hide controls when seeking) +- ⚡️ Built-in player: Auto skip intro, outro +- ⚡️ Support for more video extensions #144 +- 🦺 Fixed Semver version comparison implementation (affects migrations) +- 🦺 Fixed Auto Downloader form #133 +- 🦺 Fixed ‘continue watching’ button for non-downloaded media #135 +- 🦺 Fixed Hianime extension +- 🦺 Fixed specials not working with external player link for torrent streaming #139 +- 🦺 Fixed some specials not being streamable +- 🏗️ Refactored metadata provider code +- 🏗️ New documentation website + +## v2.1.1 + +- ✨ Discover: New 'Schedule' and 'Missed sequels' section +- ⚡️ Self update: Replace current process on Linux #114 +- ⚡️ Auto play next episode now works for torrent streaming (with auto-select enabled) +- ⚡️ Anime media cards persist list data across pages +- 🦺 Fixed duplicated playback events when 'hide top navbar' is enabled #117 +- 🦺 Fixed UI inconsistencies & layout shifts due to scrollbar +- 🦺 Fixed anime media card trailers +- 🦺 Fixed nested popovers not opening on Firefox +- 🏗️ UI: Added desktop-specific components for future desktop app +- 🏗️ Added separate build processes for frontend + +## v2.1.0 + +- ✨ Manage logs from the web interface +- ✨ Extensions: Improved Javascript interpreter + - New Cheerio-like HTML parsing API + - New CryptoJS API bindings +- ✨ Extensions: Typescript/Javascript Playground + - Test your extension code interactively from the web interface +- ✨ AnimeTosho: 'Best release' filter +- ✨ Manga: New built-in "ComicK (Multi)" extension + - Supports language & scanlator filters +- ✨ Auto play next episode for Desktop media players + - Enable this in the media player settings +- ✨ Manga extension API now support language & scanlator filters +- ⚡️ Playlist creation filters +- ⚡️ Unmatch select files instead of all +- ⚡️ New option to download files to device #110 +- ⚡️ Progress modal key bindings #111 + - 'u' to update progress & 'space' to play next episode +- 🦺 Extensions Fixed JS runtime 'fetch' not working with non-JSON responses +- 🦺 qBittorrent login fix +- 🏗️ Updated extension SDK + - Breaking changes for third-party extensions + +## v2.0.3 + +- ✨ Settings: Choose default manga source +- 🦺 Fixed 'resolve unmatched' feature + - Fixed incorrect hydration when manually resolving unmatched files +- 🦺 Torrent streaming: Fixed external player link on Android +- 🦺 UI: Display characters for undownloaded anime +- 🏗️ Updated extension SDK + +## v2.0.2 + +- ✨ Ignore files +- ⚡️ Improved 'resolve unmatched' feature + - Select individual files to match / ignore + - Suggestions are fetched faster +- 🦺 Torrent streaming: Fixed MPV cache +- 🦺 Fixed manual match overwriting locked files +- 🦺 Fixed episode summaries + +## v2.0.1 + +- ✨ Torrent streaming: Show previously selected torrent +- ✨ Support for AniList 'repeating' status +- 🦺 Fixed External Player Link not working on Android +- 🦺 Fixed UI inconsistencies +- 🦺 Fixed SeaDex provider + +## v2.0.0 + +- 🎉 Extension System + - Create or install torrent provider, online streaming, and manga source extensions + - Support for JavaScript, TypeScript, and Go + - Easily share extensions by hosting them on GitHub or any public URL + - Extensions are sandboxed for security and have access only to essential APIs +- 🎉 Windows System Tray App + - Seanime now runs as a system tray app on Windows, offering quick and easy access +- 🎉 External Media Player Link (Custom scheme) + - Open media in external player apps like VLC, MX Player, Outplayer, and more, using custom URL schemes + - Stream both downloaded media and torrents directly to your preferred player that supports custom schemes +- ✨ Torrent Streaming Enhancements + - Stream torrents to other devices using the external player link settings + - Manually select files for torrent streaming (#103) + - View torrent streaming episodes alongside downloaded ones in your library + - Improved handling of Specials & Adult content (#103) + - Torrent streaming now passes filenames to media players (#99) + - Option to switch to torrent streaming view if media isn't in your library +- ⚡️ Enhanced Auto Downloader + - Improved accuracy with a new option to filter by release group using multiple queries +- ✨ UI Enhancements + - Customize your experience with new user interface settings + - Updated design for media cards, episode cards, headers, and more +- ✨ Manga Enhancements + - Manually match manga series for more accurate results + - Updated page layout +- ✨ Notifications + - Stay informed with new in-app notifications +- ⚡️ Smart Search Improvements + - Enhanced search results for current torrent providers + - Reduced latency for torrent searches +- ⚡️ Media Streaming Enhancements + - Defaults to the cache directory for storing video segments, removing the need for a transcode directory +- ⚡️ Library Enhancements + - Filter by title in the detailed library view (#102) + - More options for Discord Rich Presence (#104) +- 🦺 Bug Fixes & Stability + - Fixed incorrect listing on the schedule calendar + - Resolved runtime error when manually syncing offline progress + - Resolved runtime error caused by torrent streaming + - Corrected links on the AniList page's manga cards +- 🏗️ Logging & Output + - Continuous logging of terminal output to a file in the logs directory + - FFmpeg crashes are now logged to a file + - Enforced absolute paths for the `-datadir` flag +- 🏗️ Codebase Improvements + - Refactored code related to the AniList API for better consistency + - Enhanced modularity of the codebase for easier maintenance + - Updated release workflow and dependencies + +## v1.7.3 + +- ⚡️ Perf: Optimized queries + - Start-up time is reduced + - Editing list entries has lower latency + - Fetching larger AniList collections is now up to 5 times faster +- 💄 UI: Updated components + - Larger media cards + - Updated episode grid items + - Use AniList color gradients for scores + - Improved consistency across components +- ⚡️ Automatically add new media to AniList collection when downloading first episode +- 🦺 Transmission: Escape special characters in password +- 🦺 UI: Escape parentheses in image filenames + +## v1.7.2 + +- ⚡️ Scanner: Support more file extensions +- ⚡️ Removed third-party app startup check if the application path is not set +- 🦺 Auto update: Fixed update deleting unrelated files in the same directory +- 🦺 Media streaming: Fixed direct play using wrong content type #94 +- 🦺 Torrent streaming: Fixed inaccurate file download percentage for batches #96 + +## v1.7.1 + +- 🦺 Media streaming: Fixed direct play returning the same file for different episodes +- 🦺 Torrent streaming: Fixed playing individual episode from batch torrents #93 +- 🦺 Torrent streaming: Fixed panic caused by torrent file not being found +- 🦺 Fixed crash caused by terminating MPV programmatically / stopping torrent stream +- 🦺 Fixed 'manga feature not enabled' error when opening the web interface #90 +- 🦺 Fixed manga list being named 'watching' instead of 'reading' +- 🦺 Media streaming: Fixed 'file already closed' error with direct play +- 🦺 Torrent streaming: Fixed persistent loading bar when torrent stream fails to start +- 🦺 Schedule: Fixed calendar having inaccurate dates for aired episodes +- 🦺 Media streaming: Fixed byte range request failing when video player requests end bytes first (direct play) +- 🏗️ Media streaming: Refactored direct play file cache system +- 🏗️ Scan summaries: Use preferred titles +- 🏗️ Internal refactoring for code consistency + +## v1.7.0 + +- ✨ Improved anime library page + - New detailed view with stats, filters and sorting options +- ✨ Revamped manga page + - Updated layout with dynamic header and genre filters + - Page now only shows current, paused and planned entries +- ✨ Improved 'Schedule' page: New calendar view for upcoming episodes +- ✨ Improved 'Discover' page: Support for manga +- ✨ Improved 'AniList' page + - Updated layout with new filters, sorting options and support for manga lists + - New stats section for anime and manga +- ✨ Global search now supports manga +- ✨ Online streaming: Added support for dubs +- ✨ Media streaming: Auto play and auto next #77 +- ⚡️ 'None' option for torrent provider #85 + - This option disables torrent-related UI elements and features +- ⚡️ Torrent streaming: Added filler metadata +- ⚡️ Ability to fetch metadata for shows that are not in the library +- ⚡️ MPV: Added retry mechanism for connection errors +- ⚡️ Perf: Improved speed when saving settings +- ⚡️ Perf: Virtualize media lists for better performance if there are many entries +- ⚡️ Transcoding: Option to toggle JASSUB offscreen rendering +- ⚡️ Online streaming: Refactored media player controls +- ⚡️ UI: Improved layout for media streaming & online streaming +- ⚡️ UI: Added indicator for missing episodes on media cards +- 🦺 Media streaming: Fixed direct play #82 +- 🦺 Media streaming: Fixed font files not loading properly +- 🦺 Transcoding: Set default hardware accel device to auto on Windows +- 🦺 Torrent streaming: Fixed manual selection not working with batches #86 +- 🦺 Online streaming: Fixed episode being changed when switching providers +- 🦺 Playlists: Fixed list not updating when a playlist is started +- 🦺 UI: Make global search bar clickable on mobile +- 🦺 Online streaming: Fixed Zoro provider +- 🦺 Fixed terminal errors from manga requests +- ⬆️ Updated dependencies + +## v1.6.0 + +- 🚀 The web interface is now bundled with the binary + - Seanime now ships without the `web` directory + - This should solve issues with auto updates on Windows +- 🎉 Media streaming: Direct play support + - Seanime will now, automatically attempt to play media files directly without transcoding if the client supports the codecs +- ✨ Metadata: View filler episodes #74 + - Fetch additional metadata to highlight filler episodes +- ✨ Setting: Refresh library on startup +- ⚡️ Scanner: Support for symbolic links +- 🚀 Transcoding: JASSUB files are now embedded in the binary + - No need to download JASSUB files separately unless you need to support old browsers +- 🦺 Media streaming: Fixed subtitle rendering issues + - This should solve issues with subtitles not showing up in the media player +- 🦺 Scanner: Fixed runtime error when files aren't matched by the autoscanner +- 🦺 Media streaming: Fixed JASSUB on iOS +- 🦺 Fixed crash caused by concurrent logs +- 🏗️ BREAKING: Media streaming: Metadata extraction done using FFprobe only +- 🔨 Updated release workflow +- ⬆️ Updated dependencies + +## v1.5.5 + +- ⚡️ Manga reader fullscreen mode (hide the bar) + - You can now toggle the manga reader bar by clicking the middle of the page or pressing `b` on desktop + - Click the cog icon to toggle the option on mobile +- ⚡️ Changed manga reader defaults based on screen size + - Clicking `Reset defaults for (mode)` will now take into account the screen size +- 🦺 Fixed list not updating after editing entry on 'My lists' page +- 🦺 Fixed manga list not updating after deleting entry +- 🦺 Fixed score and recommendations not updating when navigating between series + +## v1.5.4 + +- ⚡️ Added episode info to Torrent Streaming view #69 +- ⚡️ Custom anime lists are now shown in 'My Lists' page #70 +- 🦺 Fixed active playlist info not showing up on the web UI +- 🦺 Torrent streaming: Fixed manual selection not working when episode is already watched +- 🦺 Torrent Streaming: Fixed transition + +## v1.5.3 + +- ✨ Self update (Experimental) + - Update Seanime to the latest version directly from the web UI +- 🦺 Media streaming: Fixed issue with media player not using JASSUB #65 +- 🦺 Online streaming: Fixed progress syncing #66 +- 🦺 Fixed .tar.gz decompression error when downloading new releases on macOS +- 🦺 Fixed some layout issues +- 🏗️ Changed default subtitle renderer styles on mobile #65 +- 🏗️ Use binary path as working directory variable by default + - Fixes macOS startup process and other issues +- 🏗️ Added `server.usebinarypath` field to config.toml + - Enforces the use of binary path as working directory variable + - Defaults to `true`. Set to `false` to use the system's working directory +- 🏗️ Removed `-truewd` flag +- 🏗️ Disabled Fiber file compression + +## v1.5.2 + +- 🦺 Fixed transcoding not starting (regression in v1.5.1) +- 🦺 Fixed Discover page header opacity issues +- 🦺 Fixed runtime error caused by missing settings +- 🏗️ Reduced latency when reading local files + +## v1.5.1 + +- ⚡️ Reduced memory usage +- ⚡️ Automatic Transcoding cache cleanup on server startup +- 🚀 Added Docker image for Linux arm64 #63 +- 🚑️ Fixed occasional runtime error caused by internal module +- 💄 UI: Improved stream page layouts +- 🦺 Fixed Transcode playback error when switching episodes +- 🦺 Fixed MPV regression caused by custom path being ignored +- 🦺 Fixed hanging request when re-enabling Torrent streaming after initialization failure +- 🦺 Fixed error log coming from Torrent streaming internal package +- 🦺 Fixed 'change default AniList client ID' not working +- 🏗️ Moved 'change default AniList client ID' to config.toml +- 🔨 Updated release workflow + +## v1.5.0 + +This release introduces two major features: Transcoding and Torrent streaming. +Thank you to everyone who has supported the project so far. + +- 🎉 New: Media streaming / Transcoding (Experimental) + - Watch your downloaded episodes on any device with a web browser using dynamic transcoding + - Support for hardware acceleration (QSV, NVENC, VAAPI) + - Dynamic quality selection based on bandwidth (HLS) +- 🎉 New: Torrent streaming (Experimental) + - Stream torrents directly from the server to your media player + - Automatic selection with no input required, click and play + - Auto-selection of single episodes from batch torrents + - Support for seeding in the background after streaming +- ✨ Added ability to view studios' other works + - Click on the studio name to view some of their other works +- ✨ Added settings option to open web UI & torrent client on startup +- ⚡️ Updated terminal logs +- ⚡️ Improved support for AniList score options #51 + - You can now use decimal scores +- ⚡️ Added ability to change default AniList client ID for authentication +- 💄 UI: Moved UI customization page to the settings page +- 💄 UI: Improved data table component on mobile devices +- 🦺 Fixed failed websocket connection due to protocol mismatch #50 +- 🏗️ Playback blocked on secondary devices unless media streaming is enabled +- 🏗️ Online streaming is stable +- 🏗️ Refactored MPV integration + +## v1.4.3 + +- ⚡️ Manga: Improved pagination + - Pagination between chapters downloaded from different sources is now possible +- ⚡️ Manga: Source selection is now unique to each series +- ⚡️ Manga: Added page container width setting for reader +- ⚡️ UI: Improved handling of custom colors + - Added additional preset color options + - Fixes #43 +- ⚡️ Missing episodes are now grouped per series to avoid clutter +- 🦺 Fixed slow animation when loading manga page +- 🦺 Fixed some UI inconsistencies +- 🏗️ Removed playback state logs + +## v1.4.2 + +- 🎉 Customize UI colors + - You can now easily customize the background and accent colors of the UI +- ✨ Docker image + - Seanime is now available as a Docker image. Check DOCKER.md for more information +- ⚡️ Added '--truewd' flag to force to Seanime use the binary's directory as the working directory + - This solves issues encountered on macOS +- ⚡️ Environment variables are now read before initializing the config file + - This solves issues with setting up Docker containers +- 🦺 Fixed episode card size setting being ignored in anime page +- 🦺 Fixed incorrect 'releasing' badge being shown in media cards when hovering + +## v1.4.1 + +- ✨ Play random episode button +- ⚡️ Scanner: Improved absolute episode number detection and normalization +- 🦺 MPV: Fixed multiple instances launching when using 'Play next episode' +- 🦺 Progress tracking: Fixed progress overwriting when viewing already watched episodes with 'Auto update' on +- 🦺 Manga: Fixed disappearing chapter table +- 🦺 Scanner: Fixed panic caused by failed episode normalization +- 🦺 Offline: Disable Auto Downloader when offline +- 🦺 Manga: Fixed download list not updating properly +- 🦺 Offline: Fixed crash when snapshotting entries with missing metadata +- 💄 Removed legacy anime page layout +- 💄 Fixed some design inconsistencies +- 🏗️ Scanner: Generate scan summary after manual match +- 🏗️ Core: Refactored web interface codebase + - New code structure + - More maintainable and less bloated code + - Code generation for API routes and types + +## v1.4.0 + +- 🎉 New feature: Offline mode + - Watch anime/read manga in the ‘offline view’ with metadata and images + - Track your progress and manage your lists offline and sync when you’re back online +- 🎉 New feature: Download Chapters (Experimental) + - Download from multiple sources without hassle + - Persistent download queue, interruption handling, error detection +- ✨ Manga: Added more sources + - Mangadex, Mangapill, Manganato +- ✨ Anime: Improved NSFW support + - Search engine now supports Nyaa Sukebei + - Hide NSFW media from your library +- ⚡️ Manga: Improved reader + - Reader settings are now unique to each manga series + - Automatic reloading of failed pages + - Progress bar and page selection + - Support for more image formats +- ⚡️ Added manga to advanced search +- ⚡️ Unified chapter lists with new toggles +- 💄 New settings page layout +- 💄 Added fade effect to media entry banner image +- 🦺 Scanner: Force media ID when resolving unmatched files +- 🦺 Manga: Fixed page indexing for Mangasee +- 🦺 Fixed incorrect start dates +- 🦺 Progress tracking: Fixed incorrect progress number being used when Episode 0 is included +- 🦺 UI: Fixed issues related to scrollbar visibility +- 🏗️ Core: Built-in image proxy +- ⬆️ Updated Next.js & switched to Turbopack + +## v1.3.0 + +- ✨ Discord Rich Presence + - Anime & Manga activity + options to disable either one #30 + - Enable this in your settings under the ‘Features’ section +- ✨ Command line flags + - Use `--datadir` to override the default data directory and use multiple Seanime instances +- ✨ Overhauled Manga Reader + - Added ‘Double Page’ layout + - Page layout customization + - Pagination key bindings + - Fixes spacing issues #31 + - Note: This introduces breaking changes in the cache system, the migration will be handled automatically. +- ⚡️MAL manga progress syncing +- ⚡️Enable/Disable or Blur NSFW search results +- 🦺 Fixed MAL anime progress syncing using wrong IDs +- 🦺 Fixed MAL token refreshing +- 🦺 Fixed error toasts on authentication +- 🏗️ Removed built-in ‘List Sync’ feature + - Note: Use MAL-Sync instead +- 🏗️ Refactored config code +- 🏗️ Implemented automatic version migration system + - Some breaking changes will be handled automatically + +## v1.2.0 + +- 🎉 New feature: Manga (Experimental) + - Read manga chapters and sync your progress +- ✨ Added "Best releases" filter for Smart Search + - Currently powered by SeaDex with limited results +- ⚡️ Improved TVDB mappings for missing episode images +- ⚡️ Added YouTube embeds for trailers +- 🦺 Fixed TVDB metadata reloading + - You can now reload TVDB metadata without having to empty the cache first +- 🏗️ Improved Discover page + - Reduced number of requests to AniList with caching + - Faster loading times, lazy loading, more responsive actions +- 🏗️ Improved file cacher (Manga/Online streaming/TVDB metadata) + - Faster I/O operations by leveraging partitioned buckets + - Less overhead and memory usage + +## v1.1.2 + +- ✨ Added support for TVDB images + - Fix missing episode images by fetching complementary TVDB metadata for specific media +- ⚡️ Improved smart search results for AnimeTosho +- ⚡️ Unresolved file manager sends fewer requests +- 🚑️ Fixed runtime error caused by Auto Downloader +- 🚑️ Fixed bug introduced in v1.1.1 making some pages inaccessible +- 🦺 Removed ambiguous "add to collection" button +- 🦺 Fixed start and completion dates not showing when modifying AniList entries on "My Lists" pages +- 🦺 Fixed Auto Downloader skipping last episodes +- 🦺 Fixed smart search torrent previews +- 🦺 Fixed trailers +- 🏗️ Refactored episode metadata code + +## v1.1.1 + +This release introduced a major bug, skip to v1.1.2+ + +- ✨ Added support for TVDB images + - Fix missing episode images by fetching complementary TVDB metadata for specific media +- ⚡️ Improved smart search results for AnimeTosho +- ⚡️ Unresolved file manager sends fewer requests +- 🚑️ Fixed runtime error caused by Auto Downloader +- 🦺 Fixed Auto Downloader skipping last episodes +- 🦺 Fixed smart search torrent previews +- 🦺 Fixed trailers +- 🏗️ Refactored episode metadata code + +## v1.1.0 + +- 🎉 New feature: Online streaming + - Stream (most) anime from online sources without any additional configuration +- ✨ Added “Play next episode” button in progress modal +- ✨ Added trailers +- ⚡️Improved torrent search for AnimeTosho +- ⚡️Improved auto file section for torrent downloads + - Seanime can now select the right episode files in multi-season batches, and will only fail when it can’t tell seasons apart + - Feature now available for Transmission v4 +- ⚡️ Custom background images are now visible on all pages +- ⚡️ Added ability to un-match in unknown media resolver +- 🦺 Fixed authentication #26 +- 🦺 Fixed torrent name parsing edge case #24 +- 🦺 Fixed ‘resume torrent’ button for qBittorrent client #23 +- 🦺 Fixed files with episode number ‘0’ not appearing in Playlist creation +- 🦺 Fixed panic caused by torrent search for anime with no AniDB metadata +- 🦺 Fixed incorrect in-app settings documentation for assets #21 +- 🦺 Fixed anime title text clipping #22 +- 🦺 Fixed frontend Playlist UI issues +- 🦺 Added in-app note for auto scan +- 🏗️ Playlists are now stable +- 🏗️ Refactored old/unstable code +- 🏗️ Refactored all tests + +## v1.0.0 + +- 🎉 Updated UI + - Smoother navigation + - Completely refactored components + - Some layout changes +- 🎉 New feature: Transmission v4 support (Experimental) +- 🎉 New feature: UI Customization + - Customize the main pages to your liking in the new UI settings page + - Note: More customization options will be added in future releases +- 🎉 New feature: Playlists (Experimental) + - Create a queue of episodes and play them in order, (almost) seamlessly +- 🎉 New feature: Auto scan + - Automatically scan your library for new files when they are added or removed + - You don't need to manually refresh entries anymore +- ⚡️ Refactored progress tracking + - Progress tracking is now completely server-side, making it more reliable +- ⚡️ Improved MPV support + - MPV will now play a new file without opening a new instance +- ⚡️ Added ability to remove active torrents +- 🏗️ Updated config file options + - The logs directory has been moved to the config directory and is now configurable + - The web directory path is now configurable (though not recommended to change it) + - Usage of environment variables for paths is now supported +- 🏗️ Updated terminal logs +- 🏗️ Refactored torrent handlers +- 🦺 "Download missing only" now works with AnimeTosho +- 🦺 Fixed client-side crash caused by empty scan summary +- 🦺 Various bug fixes and improvements +- ⬆️ Updated dependencies + +## 0.4.0 + +- 🎉 Added support for **AnimeTosho** + - Smart search now returns more results with AnimeTosho as a provider + - You can change the torrent provider for search and auto-download in the in-app settings + - Not blocked as often by ISPs #16 +- ✨ Added ability to silence missing episode notifications for specific media +- ⚡️ Improved scanning accuracy + - Fixed various issues related to title parsing, matching and metadata hydration +- ⚡️ Improved runtime error recovery during scanning + - Scanner will now try to skip problematic files instead of stopping the entire process + - Stack traces are now logged in scan summaries when runtime errors occur at a file level, making debugging easier +- ⚡️ Auto Downloader will now add queued episode magnets from the server +- 💄 Minor redesign of the empty library page +- 🦺 Fixed issue with static file serving #18 +- 🦺 Fixed panic caused by episode normalization #17 +- ⬆️ Updated dependencies +- ⬆️ Migrated to Go 1.22 +- 🔨 Updated release workflow + +## 0.3.0 + +- 🏗️ **BREAKING:** Unified server and web interface + - The web interface is now served from the server process instead of a separate one + - The configuration file is now named `config.toml` + - This update will reset your config variables (not settings) +- 🏗️ Handle runtime errors gracefully + - Seanime will now try to recover from runtime errors and display the stack trace +- ⚡️ Support for different server host and port + - Changing the server host and port will not break the web interface anymore +- ✨ Added update notifications + - Seanime will now check for updates on startup and notify you if a new version is available (can be disabled in settings) + - You can also download the update from the Web UI +- ⚡️ Added ability to download ".torrent" files #11 +- ⚡️ Improved MPV support + - Refactored the implementation to be less error-prone + - You can now specify the MPV binary file path in the settings +- 🦺 Fixed bug causing scanner to keep deleted files in the database +- 🦺 Fixed UI issues related to Auto Downloader notification badge and scanner dialog +- 🦺 Fixed duplicated UI items caused by AniList custom lists +- 🏗️ Refactored web interface code structure +- ⬆️ Updated dependencies + +## 0.2.1 + +- ✨ Added MPV support (Experimental) #5 +- 🦺 Fixed issue with local storage key value limit +- 🦺 Fixed crash caused by incorrect title parsing #7 +- 🦺 Fixed hanging requests caused by settings update #8 + +## 0.2.0 + +- 🎉 New feature: Track progress on MyAnimeList + - You can now link your MyAnimeList account to Seanime and automatically update your progress +- 🎉 New feature: Sync anime lists between AniList and MyAnimeList (Experimental) + - New interface to sync your anime lists when you link your MyAnimeList account +- 🎉 New feature: Automatically download new episodes + - Add rules (filters) that specify which episodes to download based on parameters such as release group, resolution, episode numbers + - Seanime will automatically parse the Nyaa RSS feed and download new episodes based on your rules +- ✨ Added scan summaries + - You can now read detailed summaries of your latest scan results, allowing you to see how files were matched +- ✨ Added ability to automatically update progress without confirmation when you finish an episode +- ⚡️ Improved handling of AniList rate limits + - Seanime will now pause and resume requests when rate limits are reached without throwing errors. This fixes the largest issue pertaining to scanning. +- ⚡️ AniList media with incorrect mapping to AniDB will be accessible in a limited view (without metadata) instead of being hidden +- ⚡️ Enhanced scanning mode is now stable and more accurate +- 💄 UI improvements +- 🦺 Fixed various UX issues +- ⬆️ Updated dependencies + +## 0.1.6 + +- 🦺 Fixed crash caused by custom lists on Anilist + +## 0.1.5 + +- 🚑️ Fixed scanning error caused by non-existent database entries +- ⬆️ Updated dependencies + +## 0.1.4 + +- ⚡️ Added ability to resolve hidden media + - Before this update, media absent from your Anilist collection would not appear in your library even if they were successfully scanned. +- 🦺 Fixed crash caused by manually matching media +- 🦺 Fixed client-side crash caused by an empty Anilist collection +- 🦺 Fixed rate limit issue when adding media to Anilist collection during scanning +- 🦺 Fixed some UX issues +- ⬆️ Updated dependencies + +## 0.1.3 + +- ✨ Added scanner logs + - Logs will appear in the `logs` folder in the directory as the executable +- ⚡️ New filename parser +- ⚡️ Improved standard scanning mode accuracy + - The scanner now takes into account media sequel/prequel relationships when comparing filenames to Anilist entries +- 🦺 Fixed unmatched file manager +- 🏗️ Refactored code and tests +- ⬆️ Updated dependencies +- 🔨 Updated release workflow + +## 0.1.2 + +- 🚑️ Fixed incorrect redirection to non-existent page + +## 0.1.1 + +- ✨ Added ability to hide audience score +- ✨ Added ability to delete Anilist list entries +- ✨ Added ability to delete files and remove empty folders +- 🦺 Fixed issue where the app would crash when opening the torrent list page +- 🦺 Fixed minor issues + +## 0.1.0 + +- 🎉 Alpha release + diff --git a/seanime-2.9.10/CONTRIBUTING.md b/seanime-2.9.10/CONTRIBUTING.md new file mode 100644 index 0000000..73f8d1a --- /dev/null +++ b/seanime-2.9.10/CONTRIBUTING.md @@ -0,0 +1,74 @@ +# Contribution Guide + +All contributions are welcome _if_ they are in the scope of the project. If you're not sure about something, feel free to ask. + +## Guidelines + +- Make sure you are familiar with Go and React. +- Your contributions must be small and focused. If you want to add a new feature that requires substantial changes or additions to the codebase, please contact the dev first. +- Make sure your changes are in line with the project's goals (Create a feature request if you're unsure). +- Make sure your changes are well tested and do not introduce any new issues or regressions. +- You should try and make your changes against the **most active branch**, which is usually the `main` branch but + may be different when a new version is being developed. + +## How to contribute + +1. Create an issue before starting work on a feature or a bug fix. +2. Fork the repository, clone it, and create a new branch. + + ```shell + # Clone your fork of the repo + git clone https://github.com//seanime.git + # Navigate to the directory + cd seanime + # Assign to a remote called "upstream" + git remote add upstream https://github.com/5rahim/seanime.git + ``` + +3. Get the latest changes from the original repository. + + ```shell + git fetch --all + git rebase upstream/main + ``` + +4. Create a new branch for your feature or bug fix off of the `main` branch. + + ```shell + git checkout -b main + ``` + +5. Make your changes, test and commit them. + +6. Locally rebase your changes on top of the latest changes from the original repository. + + ```shell + git pull --rebase upstream main + ``` + +7. Push your changes to your fork. + + ```shell + git push -u origin + ``` + +8. Create a pull request to the `main` branch of the original repository. + +9. Wait for the maintainers to review your pull request. + +10. Make changes if requested. + +11. Once your pull request is approved, it will be merged. + +12. Keep your fork in sync with the original repository. + + ```shell + git fetch --all + git checkout main + git rebase upstream/main + git push -u origin main + ``` + +## Areas + +[Issues](https://github.com/5rahim/seanime/issues?q=is%3Aissue+is%3Aopen+label%3A%22open+to+contribution%22) diff --git a/seanime-2.9.10/DEVELOPMENT_AND_BUILD.md b/seanime-2.9.10/DEVELOPMENT_AND_BUILD.md new file mode 100644 index 0000000..a30078b --- /dev/null +++ b/seanime-2.9.10/DEVELOPMENT_AND_BUILD.md @@ -0,0 +1,192 @@ +# Seanime Development and Build Guide + +## Prerequisites + +- Go 1.23+ +- Node.js 18+ and npm + +## Build Process + +### 1. Building the Web Interface + +1. Build the web interface: + ```bash + npm run build + ``` + +2. After the build completes, a new `out` directory will be created inside `seanime-web`. + +3. Move the contents of the `out` directory to a new `web` directory at the root of the project. + +### 2. Building the Server + +Choose the appropriate command based on your target platform: + +1. **Windows (System Tray)**: + ```bash + set CGO_ENABLED=1 + go build -o seanime.exe -trimpath -ldflags="-s -w -H=windowsgui -extldflags '-static'" + ``` + +2. **Windows (No System Tray)** - Used by the desktop app: + ```bash + go build -o seanime.exe -trimpath -ldflags="-s -w" -tags=nosystray + ``` + +3. **Linux/macOS**: + ```bash + go build -o seanime -trimpath -ldflags="-s -w" + ``` + +**Important**: The web interface must be built first before building the server. + +--- + +## Development Guide + +### Getting Started + +The project is built with: +- Backend: Go server with REST API endpoints +- Frontend: React/Next.js web interface + +For development, you should be familiar with both Go and React. + +### Setting Up the Development Environment + +#### Server Development + +1. **Development environment**: + - Create a dummy directory that will be used as the data directory during development. + - Create a dummy `web` folder at the root containing at least one file, or simply do the _Building the Web Interface_ step of the build process. (This is required for the server to start.) + +2. **Run the server**: + ```bash + go run main.go --datadir="path/to/datadir" + ``` + + - This will generate all the files needed in the `path/to/datadir` directory. + +3. **Configure the development server**: + - Change the port in the `config.toml` located in the development data directory to `43000`. The web interface will connect to this port during development. Change the host to `0.0.0.0` to allow connections from other devices. + - Re-run the server with the updated configuration. + + The server will be available at `http://127.0.0.1:43000`. + +#### Web Interface Development + +1. **Navigate to the web directory**: + ```bash + cd seanime-web + ``` + +2. **Install dependencies**: + ```bash + npm install + ``` + +3. **Start the development server**: + ```bash + npm run dev + ``` + + The development web interface will be accessible at `http://127.0.0.1:43210`. + +**Note**: During development, the web interface is served by the Next.js development server on port `43210`. +The Next.js development environment is configured such that all requests are made to the Go server running on port `43000`. + +### Understanding the Codebase Architecture + +#### API and Route Handlers + +The backend follows a well-defined structure: + +1. **Routes Declaration**: + - All routes are registered in `internal/handlers/routes.go` + - Each route is associated with a specific handler method + +2. **Handler Implementation**: + - Handler methods are defined in `internal/handlers/` directory + - Handlers are documented with comments above each declaration (similar to OpenAPI) + +3. **Automated Type Generation**: + - The comments above route handlers serve as documentation for automatic type generation + - Types for the frontend are generated in: + - `seanime-web/api/generated/types.ts` + - `seanime-web/api/generated/endpoint.types.ts` + - `seanime-web/api/generated/hooks_template.ts` + +#### Updating API Types + +After modifying route handlers or structs used by the frontend, you must regenerate the TypeScript types: + +```bash +# Run the code generator +go generate ./codegen/main.go +``` + +#### AniList GraphQL API Integration + +The project integrates with the AniList GraphQL API: + +1. **GraphQL Queries**: + - Queries are defined in `internal/anilist/queries/*.graphql` + - Generated using `gqlgenc` + +2. **Updating GraphQL Schema**: + If you modify the GraphQL schema, run these commands: + +```bash +go get github.com/Yamashou/gqlgenc@v0.25.4 +``` +```bash +cd internal/api/anilist +``` +```bash +go run github.com/Yamashou/gqlgenc +``` +```bash +cd ../../.. +``` +```bash +go mod tidy +``` + +3. **Client Implementation**: + - Generated queries and types are in `internal/api/anilist/client_gen.go` + - A wrapper implementation in `internal/api/anilist/client.go` provides a cleaner interface + - The wrapper also includes a mock client for testing + +### Running Tests + +**Important**: Run tests individually rather than all at once. + +#### Test Configuration + +1. Create a dummy AniList account for testing +2. Obtain an access token (from browser) +3. Create/edit `test/config.toml` using `config.example.toml` as a template + +#### Writing Tests + +Tests use the `test_utils` package which provides: +- `InitTestProvider` method to initialize the test configuration +- Flags to enable/disable specific test categories + +Example: +```go +func TestSomething(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + // Test code here +} +``` + +#### Testing with Third-Party Apps + +Some tests interact with applications like Transmission and qBittorrent: +- Ensure these applications are installed and running +- Configure `test/config.toml` with appropriate connection details + +## Notes and Warnings + +- hls.js versions 1.6.0 and above may cause appendBuffer fatal errors diff --git a/seanime-2.9.10/LICENSE b/seanime-2.9.10/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/seanime-2.9.10/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/seanime-2.9.10/README.md b/seanime-2.9.10/README.md new file mode 100644 index 0000000..8e25539 --- /dev/null +++ b/seanime-2.9.10/README.md @@ -0,0 +1,147 @@ +

+ +preview + +

+ +

Seanime

+ +

+preview +

+ +

+ Documentation | + Latest release | + Screenshots | + Tutorials | + Discord +

+ + + + +
+Leave a star if you like the project! ⭐️ +
+ +## About + +Seanime is a **media server** with a **web interface** and **desktop app** for watching anime, managing your local library, and reading manga. + +## Features + + +- Cross-platform web interface and desktop app +- Complete AniList integration (browse, manage, score, discover, etc.) +- Offline mode for both anime and manga +- Scan your local library in seconds, no renaming needed +- Integrated torrent search engine +- Stream torrents directly to your media player without downloading using Bittorrent, Torbox and Real-Debrid +- Support for qBittorrent, Transmission, Torbox and Real-Debrid for downloading +- Auto-downloading for new episodes with custom filters +- MPV, VLC, MPC-HC, and mobile player app support for watching +- Transcoding and direct play for streaming to any device web browser +- Online streaming with support for multiple web sources & extensions +- Read and download manga chapters with support for multiple sources & extensions +- Extension system for adding new sources +- Schedule for tracking upcoming or missed episodes +- Customizable UI +- And more + +## Get started + +Read the installation guide to set up Seanime on your device. + +

+ +How to install Seanime + +

+ +## Goal + +This is a one-person project and may not meet every use case. If it doesn’t fully fit your needs, other tools might be a better match. + +### Not planned + +- Support for other providers such as Trakt, SIMKL, etc. +- Support for other media players +- Dedicated clients (TV, mobile, etc.) +- Support for other languages (translations) + +Consider sponsoring or sharing the project if you want to see more features implemented. + +## Sponsors + +The maintenance of this project is made possible by the sponsors. + +

+User avatar: tobenaii +User avatar: TorBox-App +

+ +## Development and Build + +Building from source is straightforward, you'll need Node.js and Go installed on your system. +Development and testing might require additional configuration. + +[Read more here](https://github.com/5rahim/seanime/blob/main/DEVELOPMENT_AND_BUILD.md) + +## Screenshots + +### Scanning + +preview + +### Watching + +preview + +### Downloading + +preview + +### Manga + +preview + +### Torrent streaming + +preview + +### Debrid streaming + +preview + +
+View more + +### Online streaming + +preview + +### Discover + +preview + +### AniList integration + +preview + +
+ +## Disclaimer + +Seanime and its developer do not host, store, or distribute any content found within the application. All content metadata, including images, are sourced from publicly available APIs such as AniList, AniDB and TheTVDB. +Furthermore, Seanime does not endorse or promote piracy in any form. It is the user's responsibility to ensure that they are in compliance with their local laws and regulations. diff --git a/seanime-2.9.10/codegen/README.md b/seanime-2.9.10/codegen/README.md new file mode 100644 index 0000000..aa46f0a --- /dev/null +++ b/seanime-2.9.10/codegen/README.md @@ -0,0 +1,10 @@ +# Codegen + +Run after adding/removing/updating: +- A struct returned by a route handler. +- A route handler. +- A route endpoint. + +Code is generated in the `./codegen` directory and in `../seanime-web/src/api/generated`. + +Make sure the web codebase is up-to-date after running this script. diff --git a/seanime-2.9.10/codegen/generated/handlers.json b/seanime-2.9.10/codegen/generated/handlers.json new file mode 100644 index 0000000..82c4987 --- /dev/null +++ b/seanime-2.9.10/codegen/generated/handlers.json @@ -0,0 +1,9625 @@ +[ + { + "name": "HandleGetAnimeCollection", + "trimmedName": "GetAnimeCollection", + "comments": [ + "HandleGetAnimeCollection", + "", + "\t@summary returns the user's AniList anime collection.", + "\t@desc Calling GET will return the cached anime collection.", + "\t@desc The manga collection is also refreshed in the background, and upon completion, a WebSocket event is sent.", + "\t@desc Calling POST will refetch both the anime and manga collections.", + "\t@returns anilist.AnimeCollection", + "\t@route /api/v1/anilist/collection [GET,POST]", + "" + ], + "filepath": "internal/handlers/anilist.go", + "filename": "anilist.go", + "api": { + "summary": "returns the user's AniList anime collection.", + "descriptions": [ + "Calling GET will return the cached anime collection.", + "The manga collection is also refreshed in the background, and upon completion, a WebSocket event is sent.", + "Calling POST will refetch both the anime and manga collections." + ], + "endpoint": "/api/v1/anilist/collection", + "methods": [ + "GET", + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "anilist.AnimeCollection", + "returnGoType": "anilist.AnimeCollection", + "returnTypescriptType": "AL_AnimeCollection" + } + }, + { + "name": "HandleGetRawAnimeCollection", + "trimmedName": "GetRawAnimeCollection", + "comments": [ + "HandleGetRawAnimeCollection", + "", + "\t@summary returns the user's AniList anime collection without filtering out custom lists.", + "\t@desc Calling GET will return the cached anime collection.", + "\t@returns anilist.AnimeCollection", + "\t@route /api/v1/anilist/collection/raw [GET,POST]", + "" + ], + "filepath": "internal/handlers/anilist.go", + "filename": "anilist.go", + "api": { + "summary": "returns the user's AniList anime collection without filtering out custom lists.", + "descriptions": [ + "Calling GET will return the cached anime collection." + ], + "endpoint": "/api/v1/anilist/collection/raw", + "methods": [ + "GET", + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "anilist.AnimeCollection", + "returnGoType": "anilist.AnimeCollection", + "returnTypescriptType": "AL_AnimeCollection" + } + }, + { + "name": "HandleEditAnilistListEntry", + "trimmedName": "EditAnilistListEntry", + "comments": [ + "HandleEditAnilistListEntry", + "", + "\t@summary updates the user's list entry on Anilist.", + "\t@desc This is used to edit an entry on AniList.", + "\t@desc The \"type\" field is used to determine if the entry is an anime or manga and refreshes the collection accordingly.", + "\t@desc The client should refetch collection-dependent queries after this mutation.", + "\t@returns true", + "\t@route /api/v1/anilist/list-entry [POST]", + "" + ], + "filepath": "internal/handlers/anilist.go", + "filename": "anilist.go", + "api": { + "summary": "updates the user's list entry on Anilist.", + "descriptions": [ + "This is used to edit an entry on AniList.", + "The \"type\" field is used to determine if the entry is an anime or manga and refreshes the collection accordingly.", + "The client should refetch collection-dependent queries after this mutation." + ], + "endpoint": "/api/v1/anilist/list-entry", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "anilist.MediaListStatus", + "usedStructType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "required": false, + "descriptions": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "StartDate", + "jsonName": "startedAt", + "goType": "anilist.FuzzyDateInput", + "usedStructType": "anilist.FuzzyDateInput", + "typescriptType": "AL_FuzzyDateInput", + "required": false, + "descriptions": [] + }, + { + "name": "EndDate", + "jsonName": "completedAt", + "goType": "anilist.FuzzyDateInput", + "usedStructType": "anilist.FuzzyDateInput", + "typescriptType": "AL_FuzzyDateInput", + "required": false, + "descriptions": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "true", + "returnGoType": "true", + "returnTypescriptType": "true" + } + }, + { + "name": "HandleGetAnilistAnimeDetails", + "trimmedName": "GetAnilistAnimeDetails", + "comments": [ + "HandleGetAnilistAnimeDetails", + "", + "\t@summary returns more details about an AniList anime entry.", + "\t@desc This fetches more fields omitted from the base queries.", + "\t@param id - int - true - \"The AniList anime ID\"", + "\t@returns anilist.AnimeDetailsById_Media", + "\t@route /api/v1/anilist/media-details/{id} [GET]", + "" + ], + "filepath": "internal/handlers/anilist.go", + "filename": "anilist.go", + "api": { + "summary": "returns more details about an AniList anime entry.", + "descriptions": [ + "This fetches more fields omitted from the base queries." + ], + "endpoint": "/api/v1/anilist/media-details/{id}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "The AniList anime ID" + ] + } + ], + "bodyFields": [], + "returns": "anilist.AnimeDetailsById_Media", + "returnGoType": "anilist.AnimeDetailsById_Media", + "returnTypescriptType": "AL_AnimeDetailsById_Media" + } + }, + { + "name": "HandleGetAnilistStudioDetails", + "trimmedName": "GetAnilistStudioDetails", + "comments": [ + "HandleGetAnilistStudioDetails", + "", + "\t@summary returns details about a studio.", + "\t@desc This fetches media produced by the studio.", + "\t@param id - int - true - \"The AniList studio ID\"", + "\t@returns anilist.StudioDetails", + "\t@route /api/v1/anilist/studio-details/{id} [GET]", + "" + ], + "filepath": "internal/handlers/anilist.go", + "filename": "anilist.go", + "api": { + "summary": "returns details about a studio.", + "descriptions": [ + "This fetches media produced by the studio." + ], + "endpoint": "/api/v1/anilist/studio-details/{id}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "The AniList studio ID" + ] + } + ], + "bodyFields": [], + "returns": "anilist.StudioDetails", + "returnGoType": "anilist.StudioDetails", + "returnTypescriptType": "AL_StudioDetails" + } + }, + { + "name": "HandleDeleteAnilistListEntry", + "trimmedName": "DeleteAnilistListEntry", + "comments": [ + "HandleDeleteAnilistListEntry", + "", + "\t@summary deletes an entry from the user's AniList list.", + "\t@desc This is used to delete an entry on AniList.", + "\t@desc The \"type\" field is used to determine if the entry is an anime or manga and refreshes the collection accordingly.", + "\t@desc The client should refetch collection-dependent queries after this mutation.", + "\t@route /api/v1/anilist/list-entry [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/anilist.go", + "filename": "anilist.go", + "api": { + "summary": "deletes an entry from the user's AniList list.", + "descriptions": [ + "This is used to delete an entry on AniList.", + "The \"type\" field is used to determine if the entry is an anime or manga and refreshes the collection accordingly.", + "The client should refetch collection-dependent queries after this mutation." + ], + "endpoint": "/api/v1/anilist/list-entry", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleAnilistListAnime", + "trimmedName": "AnilistListAnime", + "comments": [ + "HandleAnilistListAnime", + "", + "\t@summary returns a list of anime based on the search parameters.", + "\t@desc This is used by the \"Discover\" and \"Advanced Search\".", + "\t@route /api/v1/anilist/list-anime [POST]", + "\t@returns anilist.ListAnime", + "" + ], + "filepath": "internal/handlers/anilist.go", + "filename": "anilist.go", + "api": { + "summary": "returns a list of anime based on the search parameters.", + "descriptions": [ + "This is used by the \"Discover\" and \"Advanced Search\"." + ], + "endpoint": "/api/v1/anilist/list-anime", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Page", + "jsonName": "page", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Search", + "jsonName": "search", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [] + }, + { + "name": "PerPage", + "jsonName": "perPage", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Sort", + "jsonName": "sort", + "goType": "[]anilist.MediaSort", + "usedStructType": "anilist.MediaSort", + "typescriptType": "Array\u003cAL_MediaSort\u003e", + "required": false, + "descriptions": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "[]anilist.MediaStatus", + "usedStructType": "anilist.MediaStatus", + "typescriptType": "Array\u003cAL_MediaStatus\u003e", + "required": false, + "descriptions": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "descriptions": [] + }, + { + "name": "AverageScoreGreater", + "jsonName": "averageScore_greater", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "anilist.MediaSeason", + "usedStructType": "anilist.MediaSeason", + "typescriptType": "AL_MediaSeason", + "required": false, + "descriptions": [] + }, + { + "name": "SeasonYear", + "jsonName": "seasonYear", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "anilist.MediaFormat", + "usedStructType": "anilist.MediaFormat", + "typescriptType": "AL_MediaFormat", + "required": false, + "descriptions": [] + }, + { + "name": "IsAdult", + "jsonName": "isAdult", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": false, + "descriptions": [] + } + ], + "returns": "anilist.ListAnime", + "returnGoType": "anilist.ListAnime", + "returnTypescriptType": "AL_ListAnime" + } + }, + { + "name": "HandleAnilistListRecentAiringAnime", + "trimmedName": "AnilistListRecentAiringAnime", + "comments": [ + "HandleAnilistListRecentAiringAnime", + "", + "\t@summary returns a list of recently aired anime.", + "\t@desc This is used by the \"Schedule\" page to display recently aired anime.", + "\t@route /api/v1/anilist/list-recent-anime [POST]", + "\t@returns anilist.ListRecentAnime", + "" + ], + "filepath": "internal/handlers/anilist.go", + "filename": "anilist.go", + "api": { + "summary": "returns a list of recently aired anime.", + "descriptions": [ + "This is used by the \"Schedule\" page to display recently aired anime." + ], + "endpoint": "/api/v1/anilist/list-recent-anime", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Page", + "jsonName": "page", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Search", + "jsonName": "search", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [] + }, + { + "name": "PerPage", + "jsonName": "perPage", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "AiringAtGreater", + "jsonName": "airingAt_greater", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "AiringAtLesser", + "jsonName": "airingAt_lesser", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "NotYetAired", + "jsonName": "notYetAired", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": false, + "descriptions": [] + }, + { + "name": "Sort", + "jsonName": "sort", + "goType": "[]anilist.AiringSort", + "usedStructType": "anilist.AiringSort", + "typescriptType": "Array\u003cAL_AiringSort\u003e", + "required": false, + "descriptions": [] + } + ], + "returns": "anilist.ListRecentAnime", + "returnGoType": "anilist.ListRecentAnime", + "returnTypescriptType": "AL_ListRecentAnime" + } + }, + { + "name": "HandleAnilistListMissedSequels", + "trimmedName": "AnilistListMissedSequels", + "comments": [ + "HandleAnilistListMissedSequels", + "", + "\t@summary returns a list of sequels not in the user's list.", + "\t@desc This is used by the \"Discover\" page to display sequels the user may have missed.", + "\t@route /api/v1/anilist/list-missed-sequels [GET]", + "\t@returns []anilist.BaseAnime", + "" + ], + "filepath": "internal/handlers/anilist.go", + "filename": "anilist.go", + "api": { + "summary": "returns a list of sequels not in the user's list.", + "descriptions": [ + "This is used by the \"Discover\" page to display sequels the user may have missed." + ], + "endpoint": "/api/v1/anilist/list-missed-sequels", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]anilist.BaseAnime", + "returnGoType": "anilist.BaseAnime", + "returnTypescriptType": "Array\u003cAL_BaseAnime\u003e" + } + }, + { + "name": "HandleGetAniListStats", + "trimmedName": "GetAniListStats", + "comments": [ + "HandleGetAniListStats", + "", + "\t@summary returns the anilist stats.", + "\t@desc This returns the AniList stats for the user.", + "\t@route /api/v1/anilist/stats [GET]", + "\t@returns anilist.Stats", + "" + ], + "filepath": "internal/handlers/anilist.go", + "filename": "anilist.go", + "api": { + "summary": "returns the anilist stats.", + "descriptions": [ + "This returns the AniList stats for the user." + ], + "endpoint": "/api/v1/anilist/stats", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "anilist.Stats", + "returnGoType": "anilist.Stats", + "returnTypescriptType": "AL_Stats" + } + }, + { + "name": "HandleGetAnimeEpisodeCollection", + "trimmedName": "GetAnimeEpisodeCollection", + "comments": [ + "HandleGetAnimeEpisodeCollection", + "", + "\t@summary gets list of main episodes", + "\t@desc This returns a list of main episodes for the given AniList anime media id.", + "\t@desc It also loads the episode list into the different modules.", + "\t@returns anime.EpisodeCollection", + "\t@param id - int - true - \"AniList anime media ID\"", + "\t@route /api/v1/anime/episode-collection/{id} [GET]", + "" + ], + "filepath": "internal/handlers/anime.go", + "filename": "anime.go", + "api": { + "summary": "gets list of main episodes", + "descriptions": [ + "This returns a list of main episodes for the given AniList anime media id.", + "It also loads the episode list into the different modules." + ], + "endpoint": "/api/v1/anime/episode-collection/{id}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "AniList anime media ID" + ] + } + ], + "bodyFields": [], + "returns": "anime.EpisodeCollection", + "returnGoType": "anime.EpisodeCollection", + "returnTypescriptType": "Anime_EpisodeCollection" + } + }, + { + "name": "HandleGetLibraryCollection", + "trimmedName": "GetLibraryCollection", + "comments": [ + "HandleGetLibraryCollection", + "", + "\t@summary returns the main local anime collection.", + "\t@desc This creates a new LibraryCollection struct and returns it.", + "\t@desc This is used to get the main anime collection of the user.", + "\t@desc It uses the cached Anilist anime collection for the GET method.", + "\t@desc It refreshes the AniList anime collection if the POST method is used.", + "\t@route /api/v1/library/collection [GET,POST]", + "\t@returns anime.LibraryCollection", + "" + ], + "filepath": "internal/handlers/anime_collection.go", + "filename": "anime_collection.go", + "api": { + "summary": "returns the main local anime collection.", + "descriptions": [ + "This creates a new LibraryCollection struct and returns it.", + "This is used to get the main anime collection of the user.", + "It uses the cached Anilist anime collection for the GET method.", + "It refreshes the AniList anime collection if the POST method is used." + ], + "endpoint": "/api/v1/library/collection", + "methods": [ + "GET", + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "anime.LibraryCollection", + "returnGoType": "anime.LibraryCollection", + "returnTypescriptType": "Anime_LibraryCollection" + } + }, + { + "name": "HandleGetAnimeCollectionSchedule", + "trimmedName": "GetAnimeCollectionSchedule", + "comments": [ + "HandleGetAnimeCollectionSchedule", + "", + "\t@summary returns anime collection schedule", + "\t@desc This is used by the \"Schedule\" page to display the anime schedule.", + "\t@route /api/v1/library/schedule [GET]", + "\t@returns []anime.ScheduleItem", + "" + ], + "filepath": "internal/handlers/anime_collection.go", + "filename": "anime_collection.go", + "api": { + "summary": "returns anime collection schedule", + "descriptions": [ + "This is used by the \"Schedule\" page to display the anime schedule." + ], + "endpoint": "/api/v1/library/schedule", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]anime.ScheduleItem", + "returnGoType": "anime.ScheduleItem", + "returnTypescriptType": "Array\u003cAnime_ScheduleItem\u003e" + } + }, + { + "name": "HandleAddUnknownMedia", + "trimmedName": "AddUnknownMedia", + "comments": [ + "HandleAddUnknownMedia", + "", + "\t@summary adds the given media to the user's AniList planning collections", + "\t@desc Since media not found in the user's AniList collection are not displayed in the library, this route is used to add them.", + "\t@desc The response is ignored in the frontend, the client should just refetch the entire library collection.", + "\t@route /api/v1/library/unknown-media [POST]", + "\t@returns anilist.AnimeCollection", + "" + ], + "filepath": "internal/handlers/anime_collection.go", + "filename": "anime_collection.go", + "api": { + "summary": "adds the given media to the user's AniList planning collections", + "descriptions": [ + "Since media not found in the user's AniList collection are not displayed in the library, this route is used to add them.", + "The response is ignored in the frontend, the client should just refetch the entire library collection." + ], + "endpoint": "/api/v1/library/unknown-media", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "usedStructType": "", + "typescriptType": "Array\u003cnumber\u003e", + "required": true, + "descriptions": [] + } + ], + "returns": "anilist.AnimeCollection", + "returnGoType": "anilist.AnimeCollection", + "returnTypescriptType": "AL_AnimeCollection" + } + }, + { + "name": "HandleGetAnimeEntry", + "trimmedName": "GetAnimeEntry", + "comments": [ + "HandleGetAnimeEntry", + "", + "\t@summary return a media entry for the given AniList anime media id.", + "\t@desc This is used by the anime media entry pages to get all the data about the anime.", + "\t@desc This includes episodes and metadata (if any), AniList list data, download info...", + "\t@route /api/v1/library/anime-entry/{id} [GET]", + "\t@param id - int - true - \"AniList anime media ID\"", + "\t@returns anime.Entry", + "" + ], + "filepath": "internal/handlers/anime_entries.go", + "filename": "anime_entries.go", + "api": { + "summary": "return a media entry for the given AniList anime media id.", + "descriptions": [ + "This is used by the anime media entry pages to get all the data about the anime.", + "This includes episodes and metadata (if any), AniList list data, download info..." + ], + "endpoint": "/api/v1/library/anime-entry/{id}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "AniList anime media ID" + ] + } + ], + "bodyFields": [], + "returns": "anime.Entry", + "returnGoType": "anime.Entry", + "returnTypescriptType": "Anime_Entry" + } + }, + { + "name": "HandleAnimeEntryBulkAction", + "trimmedName": "AnimeEntryBulkAction", + "comments": [ + "HandleAnimeEntryBulkAction", + "", + "\t@summary perform given action on all the local files for the given media id.", + "\t@desc This is used to unmatch or toggle the lock status of all the local files for a specific media entry", + "\t@desc The response is not used in the frontend. The client should just refetch the entire media entry data.", + "\t@route /api/v1/library/anime-entry/bulk-action [PATCH]", + "\t@returns []anime.LocalFile", + "" + ], + "filepath": "internal/handlers/anime_entries.go", + "filename": "anime_entries.go", + "api": { + "summary": "perform given action on all the local files for the given media id.", + "descriptions": [ + "This is used to unmatch or toggle the lock status of all the local files for a specific media entry", + "The response is not used in the frontend. The client should just refetch the entire media entry data." + ], + "endpoint": "/api/v1/library/anime-entry/bulk-action", + "methods": [ + "PATCH" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Action", + "jsonName": "action", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "[]anime.LocalFile", + "returnGoType": "anime.LocalFile", + "returnTypescriptType": "Array\u003cAnime_LocalFile\u003e" + } + }, + { + "name": "HandleOpenAnimeEntryInExplorer", + "trimmedName": "OpenAnimeEntryInExplorer", + "comments": [ + "HandleOpenAnimeEntryInExplorer", + "", + "\t@summary opens the directory of a media entry in the file explorer.", + "\t@desc This finds a common directory for all media entry local files and opens it in the file explorer.", + "\t@desc Returns 'true' whether the operation was successful or not, errors are ignored.", + "\t@route /api/v1/library/anime-entry/open-in-explorer [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/anime_entries.go", + "filename": "anime_entries.go", + "api": { + "summary": "opens the directory of a media entry in the file explorer.", + "descriptions": [ + "This finds a common directory for all media entry local files and opens it in the file explorer.", + "Returns 'true' whether the operation was successful or not, errors are ignored." + ], + "endpoint": "/api/v1/library/anime-entry/open-in-explorer", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleFetchAnimeEntrySuggestions", + "trimmedName": "FetchAnimeEntrySuggestions", + "comments": [ + "HandleFetchAnimeEntrySuggestions", + "", + "\t@summary returns a list of media suggestions for files in the given directory.", + "\t@desc This is used by the \"Resolve unmatched media\" feature to suggest media entries for the local files in the given directory.", + "\t@desc If some matches files are found in the directory, it will ignore them and base the suggestions on the remaining files.", + "\t@route /api/v1/library/anime-entry/suggestions [POST]", + "\t@returns []anilist.BaseAnime", + "" + ], + "filepath": "internal/handlers/anime_entries.go", + "filename": "anime_entries.go", + "api": { + "summary": "returns a list of media suggestions for files in the given directory.", + "descriptions": [ + "This is used by the \"Resolve unmatched media\" feature to suggest media entries for the local files in the given directory.", + "If some matches files are found in the directory, it will ignore them and base the suggestions on the remaining files." + ], + "endpoint": "/api/v1/library/anime-entry/suggestions", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Dir", + "jsonName": "dir", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "[]anilist.BaseAnime", + "returnGoType": "anilist.BaseAnime", + "returnTypescriptType": "Array\u003cAL_BaseAnime\u003e" + } + }, + { + "name": "HandleAnimeEntryManualMatch", + "trimmedName": "AnimeEntryManualMatch", + "comments": [ + "HandleAnimeEntryManualMatch", + "", + "\t@summary matches un-matched local files in the given directory to the given media.", + "\t@desc It is used by the \"Resolve unmatched media\" feature to manually match local files to a specific media entry.", + "\t@desc Matching involves the use of scanner.FileHydrator. It will also lock the files.", + "\t@desc The response is not used in the frontend. The client should just refetch the entire library collection.", + "\t@route /api/v1/library/anime-entry/manual-match [POST]", + "\t@returns []anime.LocalFile", + "" + ], + "filepath": "internal/handlers/anime_entries.go", + "filename": "anime_entries.go", + "api": { + "summary": "matches un-matched local files in the given directory to the given media.", + "descriptions": [ + "It is used by the \"Resolve unmatched media\" feature to manually match local files to a specific media entry.", + "Matching involves the use of scanner.FileHydrator. It will also lock the files.", + "The response is not used in the frontend. The client should just refetch the entire library collection." + ], + "endpoint": "/api/v1/library/anime-entry/manual-match", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Paths", + "jsonName": "paths", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "[]anime.LocalFile", + "returnGoType": "anime.LocalFile", + "returnTypescriptType": "Array\u003cAnime_LocalFile\u003e" + } + }, + { + "name": "HandleGetMissingEpisodes", + "trimmedName": "GetMissingEpisodes", + "comments": [ + "HandleGetMissingEpisodes", + "", + "\t@summary returns a list of episodes missing from the user's library collection", + "\t@desc It detects missing episodes by comparing the user's AniList collection 'next airing' data with the local files.", + "\t@desc This route can be called multiple times, as it does not bypass the cache.", + "\t@route /api/v1/library/missing-episodes [GET]", + "\t@returns anime.MissingEpisodes", + "" + ], + "filepath": "internal/handlers/anime_entries.go", + "filename": "anime_entries.go", + "api": { + "summary": "returns a list of episodes missing from the user's library collection", + "descriptions": [ + "It detects missing episodes by comparing the user's AniList collection 'next airing' data with the local files.", + "This route can be called multiple times, as it does not bypass the cache." + ], + "endpoint": "/api/v1/library/missing-episodes", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "anime.MissingEpisodes", + "returnGoType": "anime.MissingEpisodes", + "returnTypescriptType": "Anime_MissingEpisodes" + } + }, + { + "name": "HandleGetAnimeEntrySilenceStatus", + "trimmedName": "GetAnimeEntrySilenceStatus", + "comments": [ + "HandleGetAnimeEntrySilenceStatus", + "", + "\t@summary returns the silence status of a media entry.", + "\t@param id - int - true - \"The ID of the media entry.\"", + "\t@route /api/v1/library/anime-entry/silence/{id} [GET]", + "\t@returns models.SilencedMediaEntry", + "" + ], + "filepath": "internal/handlers/anime_entries.go", + "filename": "anime_entries.go", + "api": { + "summary": "returns the silence status of a media entry.", + "descriptions": [], + "endpoint": "/api/v1/library/anime-entry/silence/{id}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "The ID of the media entry." + ] + } + ], + "bodyFields": [], + "returns": "models.SilencedMediaEntry", + "returnGoType": "models.SilencedMediaEntry", + "returnTypescriptType": "Models_SilencedMediaEntry" + } + }, + { + "name": "HandleToggleAnimeEntrySilenceStatus", + "trimmedName": "ToggleAnimeEntrySilenceStatus", + "comments": [ + "HandleToggleAnimeEntrySilenceStatus", + "", + "\t@summary toggles the silence status of a media entry.", + "\t@desc The missing episodes should be re-fetched after this.", + "\t@route /api/v1/library/anime-entry/silence [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/anime_entries.go", + "filename": "anime_entries.go", + "api": { + "summary": "toggles the silence status of a media entry.", + "descriptions": [ + "The missing episodes should be re-fetched after this." + ], + "endpoint": "/api/v1/library/anime-entry/silence", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleUpdateAnimeEntryProgress", + "trimmedName": "UpdateAnimeEntryProgress", + "comments": [ + "HandleUpdateAnimeEntryProgress", + "", + "\t@summary update the progress of the given anime media entry.", + "\t@desc This is used to update the progress of the given anime media entry on AniList.", + "\t@desc The response is not used in the frontend, the client should just refetch the entire media entry data.", + "\t@desc NOTE: This is currently only used by the 'Online streaming' feature since anime progress updates are handled by the Playback Manager.", + "\t@route /api/v1/library/anime-entry/update-progress [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/anime_entries.go", + "filename": "anime_entries.go", + "api": { + "summary": "update the progress of the given anime media entry.", + "descriptions": [ + "This is used to update the progress of the given anime media entry on AniList.", + "The response is not used in the frontend, the client should just refetch the entire media entry data.", + "NOTE: This is currently only used by the 'Online streaming' feature since anime progress updates are handled by the Playback Manager." + ], + "endpoint": "/api/v1/library/anime-entry/update-progress", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "MalId", + "jsonName": "malId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "TotalEpisodes", + "jsonName": "totalEpisodes", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleUpdateAnimeEntryRepeat", + "trimmedName": "UpdateAnimeEntryRepeat", + "comments": [ + "HandleUpdateAnimeEntryRepeat", + "", + "\t@summary update the repeat value of the given anime media entry.", + "\t@desc This is used to update the repeat value of the given anime media entry on AniList.", + "\t@desc The response is not used in the frontend, the client should just refetch the entire media entry data.", + "\t@route /api/v1/library/anime-entry/update-repeat [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/anime_entries.go", + "filename": "anime_entries.go", + "api": { + "summary": "update the repeat value of the given anime media entry.", + "descriptions": [ + "This is used to update the repeat value of the given anime media entry on AniList.", + "The response is not used in the frontend, the client should just refetch the entire media entry data." + ], + "endpoint": "/api/v1/library/anime-entry/update-repeat", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Repeat", + "jsonName": "repeat", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleLogin", + "trimmedName": "Login", + "comments": [ + "HandleLogin", + "", + "\t@summary logs in the user by saving the JWT token in the database.", + "\t@desc This is called when the JWT token is obtained from AniList after logging in with redirection on the client.", + "\t@desc It also fetches the Viewer data from AniList and saves it in the database.", + "\t@desc It creates a new handlers.Status and refreshes App modules.", + "\t@route /api/v1/auth/login [POST]", + "\t@returns handlers.Status", + "" + ], + "filepath": "internal/handlers/auth.go", + "filename": "auth.go", + "api": { + "summary": "logs in the user by saving the JWT token in the database.", + "descriptions": [ + "This is called when the JWT token is obtained from AniList after logging in with redirection on the client.", + "It also fetches the Viewer data from AniList and saves it in the database.", + "It creates a new handlers.Status and refreshes App modules." + ], + "endpoint": "/api/v1/auth/login", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Token", + "jsonName": "token", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "handlers.Status", + "returnGoType": "handlers.Status", + "returnTypescriptType": "Status" + } + }, + { + "name": "HandleLogout", + "trimmedName": "Logout", + "comments": [ + "HandleLogout", + "", + "\t@summary logs out the user by removing JWT token from the database.", + "\t@desc It removes JWT token and Viewer data from the database.", + "\t@desc It creates a new handlers.Status and refreshes App modules.", + "\t@route /api/v1/auth/logout [POST]", + "\t@returns handlers.Status", + "" + ], + "filepath": "internal/handlers/auth.go", + "filename": "auth.go", + "api": { + "summary": "logs out the user by removing JWT token from the database.", + "descriptions": [ + "It removes JWT token and Viewer data from the database.", + "It creates a new handlers.Status and refreshes App modules." + ], + "endpoint": "/api/v1/auth/logout", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "handlers.Status", + "returnGoType": "handlers.Status", + "returnTypescriptType": "Status" + } + }, + { + "name": "HandleRunAutoDownloader", + "trimmedName": "RunAutoDownloader", + "comments": [ + "HandleRunAutoDownloader", + "", + "\t@summary tells the AutoDownloader to check for new episodes if enabled.", + "\t@desc This will run the AutoDownloader if it is enabled.", + "\t@desc It does nothing if the AutoDownloader is disabled.", + "\t@route /api/v1/auto-downloader/run [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/auto_downloader.go", + "filename": "auto_downloader.go", + "api": { + "summary": "tells the AutoDownloader to check for new episodes if enabled.", + "descriptions": [ + "This will run the AutoDownloader if it is enabled.", + "It does nothing if the AutoDownloader is disabled." + ], + "endpoint": "/api/v1/auto-downloader/run", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetAutoDownloaderRule", + "trimmedName": "GetAutoDownloaderRule", + "comments": [ + "HandleGetAutoDownloaderRule", + "", + "\t@summary returns the rule with the given DB id.", + "\t@desc This is used to get a specific rule, useful for editing.", + "\t@route /api/v1/auto-downloader/rule/{id} [GET]", + "\t@param id - int - true - \"The DB id of the rule\"", + "\t@returns anime.AutoDownloaderRule", + "" + ], + "filepath": "internal/handlers/auto_downloader.go", + "filename": "auto_downloader.go", + "api": { + "summary": "returns the rule with the given DB id.", + "descriptions": [ + "This is used to get a specific rule, useful for editing." + ], + "endpoint": "/api/v1/auto-downloader/rule/{id}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "The DB id of the rule" + ] + } + ], + "bodyFields": [], + "returns": "anime.AutoDownloaderRule", + "returnGoType": "anime.AutoDownloaderRule", + "returnTypescriptType": "Anime_AutoDownloaderRule" + } + }, + { + "name": "HandleGetAutoDownloaderRulesByAnime", + "trimmedName": "GetAutoDownloaderRulesByAnime", + "comments": [ + "HandleGetAutoDownloaderRulesByAnime", + "", + "\t@summary returns the rules with the given media id.", + "\t@route /api/v1/auto-downloader/rule/anime/{id} [GET]", + "\t@param id - int - true - \"The AniList anime id of the rules\"", + "\t@returns []anime.AutoDownloaderRule", + "" + ], + "filepath": "internal/handlers/auto_downloader.go", + "filename": "auto_downloader.go", + "api": { + "summary": "returns the rules with the given media id.", + "descriptions": [], + "endpoint": "/api/v1/auto-downloader/rule/anime/{id}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "The AniList anime id of the rules" + ] + } + ], + "bodyFields": [], + "returns": "[]anime.AutoDownloaderRule", + "returnGoType": "anime.AutoDownloaderRule", + "returnTypescriptType": "Array\u003cAnime_AutoDownloaderRule\u003e" + } + }, + { + "name": "HandleGetAutoDownloaderRules", + "trimmedName": "GetAutoDownloaderRules", + "comments": [ + "HandleGetAutoDownloaderRules", + "", + "\t@summary returns all rules.", + "\t@desc This is used to list all rules. It returns an empty slice if there are no rules.", + "\t@route /api/v1/auto-downloader/rules [GET]", + "\t@returns []anime.AutoDownloaderRule", + "" + ], + "filepath": "internal/handlers/auto_downloader.go", + "filename": "auto_downloader.go", + "api": { + "summary": "returns all rules.", + "descriptions": [ + "This is used to list all rules. It returns an empty slice if there are no rules." + ], + "endpoint": "/api/v1/auto-downloader/rules", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]anime.AutoDownloaderRule", + "returnGoType": "anime.AutoDownloaderRule", + "returnTypescriptType": "Array\u003cAnime_AutoDownloaderRule\u003e" + } + }, + { + "name": "HandleCreateAutoDownloaderRule", + "trimmedName": "CreateAutoDownloaderRule", + "comments": [ + "HandleCreateAutoDownloaderRule", + "", + "\t@summary creates a new rule.", + "\t@desc The body should contain the same fields as entities.AutoDownloaderRule.", + "\t@desc It returns the created rule.", + "\t@route /api/v1/auto-downloader/rule [POST]", + "\t@returns anime.AutoDownloaderRule", + "" + ], + "filepath": "internal/handlers/auto_downloader.go", + "filename": "auto_downloader.go", + "api": { + "summary": "creates a new rule.", + "descriptions": [ + "The body should contain the same fields as entities.AutoDownloaderRule.", + "It returns the created rule." + ], + "endpoint": "/api/v1/auto-downloader/rule", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "ReleaseGroups", + "jsonName": "releaseGroups", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "Resolutions", + "jsonName": "resolutions", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "AdditionalTerms", + "jsonName": "additionalTerms", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "ComparisonTitle", + "jsonName": "comparisonTitle", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "TitleComparisonType", + "jsonName": "titleComparisonType", + "goType": "anime.AutoDownloaderRuleTitleComparisonType", + "usedStructType": "anime.AutoDownloaderRuleTitleComparisonType", + "typescriptType": "Anime_AutoDownloaderRuleTitleComparisonType", + "required": true, + "descriptions": [] + }, + { + "name": "EpisodeType", + "jsonName": "episodeType", + "goType": "anime.AutoDownloaderRuleEpisodeType", + "usedStructType": "anime.AutoDownloaderRuleEpisodeType", + "typescriptType": "Anime_AutoDownloaderRuleEpisodeType", + "required": true, + "descriptions": [] + }, + { + "name": "EpisodeNumbers", + "jsonName": "episodeNumbers", + "goType": "[]int", + "usedStructType": "", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "descriptions": [] + }, + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "anime.AutoDownloaderRule", + "returnGoType": "anime.AutoDownloaderRule", + "returnTypescriptType": "Anime_AutoDownloaderRule" + } + }, + { + "name": "HandleUpdateAutoDownloaderRule", + "trimmedName": "UpdateAutoDownloaderRule", + "comments": [ + "HandleUpdateAutoDownloaderRule", + "", + "\t@summary updates a rule.", + "\t@desc The body should contain the same fields as entities.AutoDownloaderRule.", + "\t@desc It returns the updated rule.", + "\t@route /api/v1/auto-downloader/rule [PATCH]", + "\t@returns anime.AutoDownloaderRule", + "" + ], + "filepath": "internal/handlers/auto_downloader.go", + "filename": "auto_downloader.go", + "api": { + "summary": "updates a rule.", + "descriptions": [ + "The body should contain the same fields as entities.AutoDownloaderRule.", + "It returns the updated rule." + ], + "endpoint": "/api/v1/auto-downloader/rule", + "methods": [ + "PATCH" + ], + "params": [], + "bodyFields": [ + { + "name": "Rule", + "jsonName": "rule", + "goType": "anime.AutoDownloaderRule", + "usedStructType": "anime.AutoDownloaderRule", + "typescriptType": "Anime_AutoDownloaderRule", + "required": false, + "descriptions": [] + } + ], + "returns": "anime.AutoDownloaderRule", + "returnGoType": "anime.AutoDownloaderRule", + "returnTypescriptType": "Anime_AutoDownloaderRule" + } + }, + { + "name": "HandleDeleteAutoDownloaderRule", + "trimmedName": "DeleteAutoDownloaderRule", + "comments": [ + "HandleDeleteAutoDownloaderRule", + "", + "\t@summary deletes a rule.", + "\t@desc It returns 'true' if the rule was deleted.", + "\t@route /api/v1/auto-downloader/rule/{id} [DELETE]", + "\t@param id - int - true - \"The DB id of the rule\"", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/auto_downloader.go", + "filename": "auto_downloader.go", + "api": { + "summary": "deletes a rule.", + "descriptions": [ + "It returns 'true' if the rule was deleted." + ], + "endpoint": "/api/v1/auto-downloader/rule/{id}", + "methods": [ + "DELETE" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "The DB id of the rule" + ] + } + ], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetAutoDownloaderItems", + "trimmedName": "GetAutoDownloaderItems", + "comments": [ + "HandleGetAutoDownloaderItems", + "", + "\t@summary returns all queued items.", + "\t@desc Queued items are episodes that are downloaded but not scanned or not yet downloaded.", + "\t@desc The AutoDownloader uses these items in order to not download the same episode twice.", + "\t@route /api/v1/auto-downloader/items [GET]", + "\t@returns []models.AutoDownloaderItem", + "" + ], + "filepath": "internal/handlers/auto_downloader.go", + "filename": "auto_downloader.go", + "api": { + "summary": "returns all queued items.", + "descriptions": [ + "Queued items are episodes that are downloaded but not scanned or not yet downloaded.", + "The AutoDownloader uses these items in order to not download the same episode twice." + ], + "endpoint": "/api/v1/auto-downloader/items", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]models.AutoDownloaderItem", + "returnGoType": "models.AutoDownloaderItem", + "returnTypescriptType": "Array\u003cModels_AutoDownloaderItem\u003e" + } + }, + { + "name": "HandleDeleteAutoDownloaderItem", + "trimmedName": "DeleteAutoDownloaderItem", + "comments": [ + "HandleDeleteAutoDownloaderItem", + "", + "\t@summary delete a queued item.", + "\t@desc This is used to remove a queued item from the list.", + "\t@desc Returns 'true' if the item was deleted.", + "\t@route /api/v1/auto-downloader/item [DELETE]", + "\t@param id - int - true - \"The DB id of the item\"", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/auto_downloader.go", + "filename": "auto_downloader.go", + "api": { + "summary": "delete a queued item.", + "descriptions": [ + "This is used to remove a queued item from the list.", + "Returns 'true' if the item was deleted." + ], + "endpoint": "/api/v1/auto-downloader/item", + "methods": [ + "DELETE" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "The DB id of the item" + ] + } + ], + "bodyFields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "uint", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleUpdateContinuityWatchHistoryItem", + "trimmedName": "UpdateContinuityWatchHistoryItem", + "comments": [ + "HandleUpdateContinuityWatchHistoryItem", + "", + "\t@summary Updates watch history item.", + "\t@desc This endpoint is used to update a watch history item.", + "\t@desc Since this is low priority, we ignore any errors.", + "\t@route /api/v1/continuity/item [PATCH]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/continuity.go", + "filename": "continuity.go", + "api": { + "summary": "Updates watch history item.", + "descriptions": [ + "This endpoint is used to update a watch history item.", + "Since this is low priority, we ignore any errors." + ], + "endpoint": "/api/v1/continuity/item", + "methods": [ + "PATCH" + ], + "params": [], + "bodyFields": [ + { + "name": "Options", + "jsonName": "options", + "goType": "continuity.UpdateWatchHistoryItemOptions", + "usedStructType": "continuity.UpdateWatchHistoryItemOptions", + "typescriptType": "Continuity_UpdateWatchHistoryItemOptions", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetContinuityWatchHistoryItem", + "trimmedName": "GetContinuityWatchHistoryItem", + "comments": [ + "HandleGetContinuityWatchHistoryItem", + "", + "\t@summary Returns a watch history item.", + "\t@desc This endpoint is used to retrieve a watch history item.", + "\t@route /api/v1/continuity/item/{id} [GET]", + "\t@param id - int - true - \"AniList anime media ID\"", + "\t@returns continuity.WatchHistoryItemResponse", + "" + ], + "filepath": "internal/handlers/continuity.go", + "filename": "continuity.go", + "api": { + "summary": "Returns a watch history item.", + "descriptions": [ + "This endpoint is used to retrieve a watch history item." + ], + "endpoint": "/api/v1/continuity/item/{id}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "AniList anime media ID" + ] + } + ], + "bodyFields": [], + "returns": "continuity.WatchHistoryItemResponse", + "returnGoType": "continuity.WatchHistoryItemResponse", + "returnTypescriptType": "Continuity_WatchHistoryItemResponse" + } + }, + { + "name": "HandleGetContinuityWatchHistory", + "trimmedName": "GetContinuityWatchHistory", + "comments": [ + "HandleGetContinuityWatchHistory", + "", + "\t@summary Returns the continuity watch history", + "\t@desc This endpoint is used to retrieve all watch history items.", + "\t@route /api/v1/continuity/history [GET]", + "\t@returns continuity.WatchHistory", + "" + ], + "filepath": "internal/handlers/continuity.go", + "filename": "continuity.go", + "api": { + "summary": "Returns the continuity watch history", + "descriptions": [ + "This endpoint is used to retrieve all watch history items." + ], + "endpoint": "/api/v1/continuity/history", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "continuity.WatchHistory", + "returnGoType": "continuity.WatchHistory", + "returnTypescriptType": "Continuity_WatchHistory" + } + }, + { + "name": "HandleGetDebridSettings", + "trimmedName": "GetDebridSettings", + "comments": [ + "HandleGetDebridSettings", + "", + "\t@summary get debrid settings.", + "\t@desc This returns the debrid settings.", + "\t@returns models.DebridSettings", + "\t@route /api/v1/debrid/settings [GET]", + "" + ], + "filepath": "internal/handlers/debrid.go", + "filename": "debrid.go", + "api": { + "summary": "get debrid settings.", + "descriptions": [ + "This returns the debrid settings." + ], + "endpoint": "/api/v1/debrid/settings", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "models.DebridSettings", + "returnGoType": "models.DebridSettings", + "returnTypescriptType": "Models_DebridSettings" + } + }, + { + "name": "HandleSaveDebridSettings", + "trimmedName": "SaveDebridSettings", + "comments": [ + "HandleSaveDebridSettings", + "", + "\t@summary save debrid settings.", + "\t@desc This saves the debrid settings.", + "\t@desc The client should refetch the server status.", + "\t@returns models.DebridSettings", + "\t@route /api/v1/debrid/settings [PATCH]", + "" + ], + "filepath": "internal/handlers/debrid.go", + "filename": "debrid.go", + "api": { + "summary": "save debrid settings.", + "descriptions": [ + "This saves the debrid settings.", + "The client should refetch the server status." + ], + "endpoint": "/api/v1/debrid/settings", + "methods": [ + "PATCH" + ], + "params": [], + "bodyFields": [ + { + "name": "Settings", + "jsonName": "settings", + "goType": "models.DebridSettings", + "usedStructType": "models.DebridSettings", + "typescriptType": "Models_DebridSettings", + "required": true, + "descriptions": [] + } + ], + "returns": "models.DebridSettings", + "returnGoType": "models.DebridSettings", + "returnTypescriptType": "Models_DebridSettings" + } + }, + { + "name": "HandleDebridAddTorrents", + "trimmedName": "DebridAddTorrents", + "comments": [ + "HandleDebridAddTorrents", + "", + "\t@summary add torrent to debrid.", + "\t@desc This adds a torrent to the debrid service.", + "\t@returns bool", + "\t@route /api/v1/debrid/torrents [POST]", + "" + ], + "filepath": "internal/handlers/debrid.go", + "filename": "debrid.go", + "api": { + "summary": "add torrent to debrid.", + "descriptions": [ + "This adds a torrent to the debrid service." + ], + "endpoint": "/api/v1/debrid/torrents", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Torrents", + "jsonName": "torrents", + "goType": "[]hibiketorrent.AnimeTorrent", + "usedStructType": "hibiketorrent.AnimeTorrent", + "typescriptType": "Array\u003cHibikeTorrent_AnimeTorrent\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "usedStructType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "required": false, + "descriptions": [] + }, + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleDebridDownloadTorrent", + "trimmedName": "DebridDownloadTorrent", + "comments": [ + "HandleDebridDownloadTorrent", + "", + "\t@summary download torrent from debrid.", + "\t@desc Manually downloads a torrent from the debrid service locally.", + "\t@returns bool", + "\t@route /api/v1/debrid/torrents/download [POST]", + "" + ], + "filepath": "internal/handlers/debrid.go", + "filename": "debrid.go", + "api": { + "summary": "download torrent from debrid.", + "descriptions": [ + "Manually downloads a torrent from the debrid service locally." + ], + "endpoint": "/api/v1/debrid/torrents/download", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "TorrentItem", + "jsonName": "torrentItem", + "goType": "debrid.TorrentItem", + "usedStructType": "debrid.TorrentItem", + "typescriptType": "Debrid_TorrentItem", + "required": true, + "descriptions": [] + }, + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleDebridCancelDownload", + "trimmedName": "DebridCancelDownload", + "comments": [ + "HandleDebridCancelDownload", + "", + "\t@summary cancel download from debrid.", + "\t@desc This cancels a download from the debrid service.", + "\t@returns bool", + "\t@route /api/v1/debrid/torrents/cancel [POST]", + "" + ], + "filepath": "internal/handlers/debrid.go", + "filename": "debrid.go", + "api": { + "summary": "cancel download from debrid.", + "descriptions": [ + "This cancels a download from the debrid service." + ], + "endpoint": "/api/v1/debrid/torrents/cancel", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "ItemID", + "jsonName": "itemID", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleDebridDeleteTorrent", + "trimmedName": "DebridDeleteTorrent", + "comments": [ + "HandleDebridDeleteTorrent", + "", + "\t@summary remove torrent from debrid.", + "\t@desc This removes a torrent from the debrid service.", + "\t@returns bool", + "\t@route /api/v1/debrid/torrent [DELETE]", + "" + ], + "filepath": "internal/handlers/debrid.go", + "filename": "debrid.go", + "api": { + "summary": "remove torrent from debrid.", + "descriptions": [ + "This removes a torrent from the debrid service." + ], + "endpoint": "/api/v1/debrid/torrent", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [ + { + "name": "TorrentItem", + "jsonName": "torrentItem", + "goType": "debrid.TorrentItem", + "usedStructType": "debrid.TorrentItem", + "typescriptType": "Debrid_TorrentItem", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleDebridGetTorrents", + "trimmedName": "DebridGetTorrents", + "comments": [ + "HandleDebridGetTorrents", + "", + "\t@summary get torrents from debrid.", + "\t@desc This gets the torrents from the debrid service.", + "\t@returns []debrid.TorrentItem", + "\t@route /api/v1/debrid/torrents [GET]", + "" + ], + "filepath": "internal/handlers/debrid.go", + "filename": "debrid.go", + "api": { + "summary": "get torrents from debrid.", + "descriptions": [ + "This gets the torrents from the debrid service." + ], + "endpoint": "/api/v1/debrid/torrents", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]debrid.TorrentItem", + "returnGoType": "debrid.TorrentItem", + "returnTypescriptType": "Array\u003cDebrid_TorrentItem\u003e" + } + }, + { + "name": "HandleDebridGetTorrentInfo", + "trimmedName": "DebridGetTorrentInfo", + "comments": [ + "HandleDebridGetTorrentInfo", + "", + "\t@summary get torrent info from debrid.", + "\t@desc This gets the torrent info from the debrid service.", + "\t@returns debrid.TorrentInfo", + "\t@route /api/v1/debrid/torrents/info [POST]", + "" + ], + "filepath": "internal/handlers/debrid.go", + "filename": "debrid.go", + "api": { + "summary": "get torrent info from debrid.", + "descriptions": [ + "This gets the torrent info from the debrid service." + ], + "endpoint": "/api/v1/debrid/torrents/info", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "hibiketorrent.AnimeTorrent", + "usedStructType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "required": true, + "descriptions": [] + } + ], + "returns": "debrid.TorrentInfo", + "returnGoType": "debrid.TorrentInfo", + "returnTypescriptType": "Debrid_TorrentInfo" + } + }, + { + "name": "HandleDebridGetTorrentFilePreviews", + "trimmedName": "DebridGetTorrentFilePreviews", + "comments": [ + "HandleDebridGetTorrentFilePreviews", + "", + "\t@summary get list of torrent files", + "\t@returns []debrid_client.FilePreview", + "\t@route /api/v1/debrid/torrents/file-previews [POST]", + "" + ], + "filepath": "internal/handlers/debrid.go", + "filename": "debrid.go", + "api": { + "summary": "get list of torrent files", + "descriptions": [], + "endpoint": "/api/v1/debrid/torrents/file-previews", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "hibiketorrent.AnimeTorrent", + "usedStructType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "required": false, + "descriptions": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "usedStructType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "required": false, + "descriptions": [] + } + ], + "returns": "[]debrid_client.FilePreview", + "returnGoType": "debrid_client.FilePreview", + "returnTypescriptType": "Array\u003cDebridClient_FilePreview\u003e" + } + }, + { + "name": "HandleDebridStartStream", + "trimmedName": "DebridStartStream", + "comments": [ + "HandleDebridStartStream", + "", + "\t@summary start stream from debrid.", + "\t@desc This starts streaming a torrent from the debrid service.", + "\t@returns bool", + "\t@route /api/v1/debrid/stream/start [POST]", + "" + ], + "filepath": "internal/handlers/debrid.go", + "filename": "debrid.go", + "api": { + "summary": "start stream from debrid.", + "descriptions": [ + "This starts streaming a torrent from the debrid service." + ], + "endpoint": "/api/v1/debrid/stream/start", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "AniDBEpisode", + "jsonName": "aniDBEpisode", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "AutoSelect", + "jsonName": "autoSelect", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "hibiketorrent.AnimeTorrent", + "usedStructType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "required": false, + "descriptions": [] + }, + { + "name": "FileId", + "jsonName": "fileId", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "FileIndex", + "jsonName": "fileIndex", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "PlaybackType", + "jsonName": "playbackType", + "goType": "debrid_client.StreamPlaybackType", + "usedStructType": "debrid_client.StreamPlaybackType", + "typescriptType": "DebridClient_StreamPlaybackType", + "required": true, + "descriptions": [] + }, + { + "name": "ClientId", + "jsonName": "clientId", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleDebridCancelStream", + "trimmedName": "DebridCancelStream", + "comments": [ + "HandleDebridCancelStream", + "", + "\t@summary cancel stream from debrid.", + "\t@desc This cancels a stream from the debrid service.", + "\t@returns bool", + "\t@route /api/v1/debrid/stream/cancel [POST]", + "" + ], + "filepath": "internal/handlers/debrid.go", + "filename": "debrid.go", + "api": { + "summary": "cancel stream from debrid.", + "descriptions": [ + "This cancels a stream from the debrid service." + ], + "endpoint": "/api/v1/debrid/stream/cancel", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Options", + "jsonName": "options", + "goType": "debrid_client.CancelStreamOptions", + "usedStructType": "debrid_client.CancelStreamOptions", + "typescriptType": "DebridClient_CancelStreamOptions", + "required": false, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleDirectorySelector", + "trimmedName": "DirectorySelector", + "comments": [ + "HandleDirectorySelector", + "", + "\t@summary returns directory content based on the input path.", + "\t@desc This used by the directory selector component to get directory validation and suggestions.", + "\t@desc It returns subdirectories based on the input path.", + "\t@desc It returns 500 error if the directory does not exist (or cannot be accessed).", + "\t@route /api/v1/directory-selector [POST]", + "\t@returns handlers.DirectorySelectorResponse", + "" + ], + "filepath": "internal/handlers/directory_selector.go", + "filename": "directory_selector.go", + "api": { + "summary": "returns directory content based on the input path.", + "descriptions": [ + "This used by the directory selector component to get directory validation and suggestions.", + "It returns subdirectories based on the input path.", + "It returns 500 error if the directory does not exist (or cannot be accessed)." + ], + "endpoint": "/api/v1/directory-selector", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Input", + "jsonName": "input", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "handlers.DirectorySelectorResponse", + "returnGoType": "handlers.DirectorySelectorResponse", + "returnTypescriptType": "DirectorySelectorResponse" + } + }, + { + "name": "HandleDirectstreamPlayLocalFile", + "trimmedName": "DirectstreamPlayLocalFile", + "comments": [ + "HandleDirectstreamPlayLocalFile", + "", + "\t@summary request local file stream.", + "\t@desc This requests a local file stream and returns the media container to start the playback.", + "\t@returns mediastream.MediaContainer", + "\t@route /api/v1/directstream/play/localfile [POST]", + "" + ], + "filepath": "internal/handlers/directstream.go", + "filename": "directstream.go", + "api": { + "summary": "request local file stream.", + "descriptions": [ + "This requests a local file stream and returns the media container to start the playback." + ], + "endpoint": "/api/v1/directstream/play/localfile", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "ClientId", + "jsonName": "clientId", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "mediastream.MediaContainer", + "returnGoType": "mediastream.MediaContainer", + "returnTypescriptType": "Mediastream_MediaContainer" + } + }, + { + "name": "HandleSetDiscordMangaActivity", + "trimmedName": "SetDiscordMangaActivity", + "comments": [ + "HandleSetDiscordMangaActivity", + "", + "\t@summary sets manga activity for discord rich presence.", + "\t@route /api/v1/discord/presence/manga [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/discord.go", + "filename": "discord.go", + "api": { + "summary": "sets manga activity for discord rich presence.", + "descriptions": [], + "endpoint": "/api/v1/discord/presence/manga", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Chapter", + "jsonName": "chapter", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleSetDiscordLegacyAnimeActivity", + "trimmedName": "SetDiscordLegacyAnimeActivity", + "comments": [ + "HandleSetDiscordLegacyAnimeActivity", + "", + "\t@summary sets anime activity for discord rich presence.", + "\t@route /api/v1/discord/presence/legacy-anime [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/discord.go", + "filename": "discord.go", + "api": { + "summary": "sets anime activity for discord rich presence.", + "descriptions": [], + "endpoint": "/api/v1/discord/presence/legacy-anime", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "IsMovie", + "jsonName": "isMovie", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleSetDiscordAnimeActivityWithProgress", + "trimmedName": "SetDiscordAnimeActivityWithProgress", + "comments": [ + "HandleSetDiscordAnimeActivityWithProgress", + "", + "\t@summary sets anime activity for discord rich presence with progress.", + "\t@route /api/v1/discord/presence/anime [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/discord.go", + "filename": "discord.go", + "api": { + "summary": "sets anime activity for discord rich presence with progress.", + "descriptions": [], + "endpoint": "/api/v1/discord/presence/anime", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "IsMovie", + "jsonName": "isMovie", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "TotalEpisodes", + "jsonName": "totalEpisodes", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "CurrentEpisodeCount", + "jsonName": "currentEpisodeCount", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "EpisodeTitle", + "jsonName": "episodeTitle", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleUpdateDiscordAnimeActivityWithProgress", + "trimmedName": "UpdateDiscordAnimeActivityWithProgress", + "comments": [ + "HandleUpdateDiscordAnimeActivityWithProgress", + "", + "\t@summary updates the anime activity for discord rich presence with progress.", + "\t@route /api/v1/discord/presence/anime-update [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/discord.go", + "filename": "discord.go", + "api": { + "summary": "updates the anime activity for discord rich presence with progress.", + "descriptions": [], + "endpoint": "/api/v1/discord/presence/anime-update", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Paused", + "jsonName": "paused", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleCancelDiscordActivity", + "trimmedName": "CancelDiscordActivity", + "comments": [ + "HandleCancelDiscordActivity", + "", + "\t@summary cancels the current discord rich presence activity.", + "\t@route /api/v1/discord/presence/cancel [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/discord.go", + "filename": "discord.go", + "api": { + "summary": "cancels the current discord rich presence activity.", + "descriptions": [], + "endpoint": "/api/v1/discord/presence/cancel", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetDocs", + "trimmedName": "GetDocs", + "comments": [ + "HandleGetDocs", + "", + "\t@summary returns the API documentation", + "\t@route /api/v1/internal/docs [GET]", + "\t@returns []handlers.ApiDocsGroup", + "" + ], + "filepath": "internal/handlers/docs.go", + "filename": "docs.go", + "api": { + "summary": "returns the API documentation", + "descriptions": [], + "endpoint": "/api/v1/internal/docs", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]handlers.ApiDocsGroup", + "returnGoType": "handlers.ApiDocsGroup", + "returnTypescriptType": "Array\u003cApiDocsGroup\u003e" + } + }, + { + "name": "HandleDownloadTorrentFile", + "trimmedName": "DownloadTorrentFile", + "comments": [ + "HandleDownloadTorrentFile", + "", + "\t@summary downloads torrent files to the destination folder", + "\t@route /api/v1/download-torrent-file [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/download.go", + "filename": "download.go", + "api": { + "summary": "downloads torrent files to the destination folder", + "descriptions": [], + "endpoint": "/api/v1/download-torrent-file", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "DownloadUrls", + "jsonName": "download_urls", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "usedStructType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "required": false, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleDownloadRelease", + "trimmedName": "DownloadRelease", + "comments": [ + "HandleDownloadRelease", + "", + "\t@summary downloads selected release asset to the destination folder.", + "\t@desc Downloads the selected release asset to the destination folder and extracts it if possible.", + "\t@desc If the extraction fails, the error message will be returned in the successful response.", + "\t@desc The successful response will contain the destination path of the extracted files.", + "\t@desc It only returns an error if the download fails.", + "\t@route /api/v1/download-release [POST]", + "\t@returns handlers.DownloadReleaseResponse", + "" + ], + "filepath": "internal/handlers/download.go", + "filename": "download.go", + "api": { + "summary": "downloads selected release asset to the destination folder.", + "descriptions": [ + "Downloads the selected release asset to the destination folder and extracts it if possible.", + "If the extraction fails, the error message will be returned in the successful response.", + "The successful response will contain the destination path of the extracted files.", + "It only returns an error if the download fails." + ], + "endpoint": "/api/v1/download-release", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "DownloadUrl", + "jsonName": "download_url", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "handlers.DownloadReleaseResponse", + "returnGoType": "handlers.DownloadReleaseResponse", + "returnTypescriptType": "DownloadReleaseResponse" + } + }, + { + "name": "HandleOpenInExplorer", + "trimmedName": "OpenInExplorer", + "comments": [ + "HandleOpenInExplorer", + "", + "\t@summary opens the given directory in the file explorer.", + "\t@desc It returns 'true' whether the operation was successful or not.", + "\t@route /api/v1/open-in-explorer [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/explorer.go", + "filename": "explorer.go", + "api": { + "summary": "opens the given directory in the file explorer.", + "descriptions": [ + "It returns 'true' whether the operation was successful or not." + ], + "endpoint": "/api/v1/open-in-explorer", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleFetchExternalExtensionData", + "trimmedName": "FetchExternalExtensionData", + "comments": [ + "HandleFetchExternalExtensionData", + "", + "\t@summary returns the extension data from the given manifest uri.", + "\t@route /api/v1/extensions/external/fetch [POST]", + "\t@returns extension.Extension", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns the extension data from the given manifest uri.", + "descriptions": [], + "endpoint": "/api/v1/extensions/external/fetch", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "ManifestURI", + "jsonName": "manifestUri", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "extension.Extension", + "returnGoType": "extension.Extension", + "returnTypescriptType": "Extension_Extension" + } + }, + { + "name": "HandleInstallExternalExtension", + "trimmedName": "InstallExternalExtension", + "comments": [ + "HandleInstallExternalExtension", + "", + "\t@summary installs the extension from the given manifest uri.", + "\t@route /api/v1/extensions/external/install [POST]", + "\t@returns extension_repo.ExtensionInstallResponse", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "installs the extension from the given manifest uri.", + "descriptions": [], + "endpoint": "/api/v1/extensions/external/install", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "ManifestURI", + "jsonName": "manifestUri", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "extension_repo.ExtensionInstallResponse", + "returnGoType": "extension_repo.ExtensionInstallResponse", + "returnTypescriptType": "ExtensionRepo_ExtensionInstallResponse" + } + }, + { + "name": "HandleUninstallExternalExtension", + "trimmedName": "UninstallExternalExtension", + "comments": [ + "HandleUninstallExternalExtension", + "", + "\t@summary uninstalls the extension with the given ID.", + "\t@route /api/v1/extensions/external/uninstall [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "uninstalls the extension with the given ID.", + "descriptions": [], + "endpoint": "/api/v1/extensions/external/uninstall", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleUpdateExtensionCode", + "trimmedName": "UpdateExtensionCode", + "comments": [ + "HandleUpdateExtensionCode", + "", + "\t@summary updates the extension code with the given ID and reloads the extensions.", + "\t@route /api/v1/extensions/external/edit-payload [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "updates the extension code with the given ID and reloads the extensions.", + "descriptions": [], + "endpoint": "/api/v1/extensions/external/edit-payload", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleReloadExternalExtensions", + "trimmedName": "ReloadExternalExtensions", + "comments": [ + "HandleReloadExternalExtensions", + "", + "\t@summary reloads the external extensions.", + "\t@route /api/v1/extensions/external/reload [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "reloads the external extensions.", + "descriptions": [], + "endpoint": "/api/v1/extensions/external/reload", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleReloadExternalExtension", + "trimmedName": "ReloadExternalExtension", + "comments": [ + "HandleReloadExternalExtension", + "", + "\t@summary reloads the external extension with the given ID.", + "\t@route /api/v1/extensions/external/reload [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "reloads the external extension with the given ID.", + "descriptions": [], + "endpoint": "/api/v1/extensions/external/reload", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleListExtensionData", + "trimmedName": "ListExtensionData", + "comments": [ + "HandleListExtensionData", + "", + "\t@summary returns the loaded extensions", + "\t@route /api/v1/extensions/list [GET]", + "\t@returns []extension.Extension", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns the loaded extensions", + "descriptions": [], + "endpoint": "/api/v1/extensions/list", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]extension.Extension", + "returnGoType": "extension.Extension", + "returnTypescriptType": "Array\u003cExtension_Extension\u003e" + } + }, + { + "name": "HandleGetExtensionPayload", + "trimmedName": "GetExtensionPayload", + "comments": [ + "HandleGetExtensionPayload", + "", + "\t@summary returns the payload of the extension with the given ID.", + "\t@route /api/v1/extensions/payload/{id} [GET]", + "\t@returns string", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns the payload of the extension with the given ID.", + "descriptions": [], + "endpoint": "/api/v1/extensions/payload/{id}", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "string", + "returnGoType": "string", + "returnTypescriptType": "string" + } + }, + { + "name": "HandleListDevelopmentModeExtensions", + "trimmedName": "ListDevelopmentModeExtensions", + "comments": [ + "HandleListDevelopmentModeExtensions", + "", + "\t@summary returns the development mode extensions", + "\t@route /api/v1/extensions/list/development [GET]", + "\t@returns []extension.Extension", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns the development mode extensions", + "descriptions": [], + "endpoint": "/api/v1/extensions/list/development", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]extension.Extension", + "returnGoType": "extension.Extension", + "returnTypescriptType": "Array\u003cExtension_Extension\u003e" + } + }, + { + "name": "HandleGetAllExtensions", + "trimmedName": "GetAllExtensions", + "comments": [ + "HandleGetAllExtensions", + "", + "\t@summary returns all loaded and invalid extensions.", + "\t@route /api/v1/extensions/all [POST]", + "\t@returns extension_repo.AllExtensions", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns all loaded and invalid extensions.", + "descriptions": [], + "endpoint": "/api/v1/extensions/all", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "WithUpdates", + "jsonName": "withUpdates", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "extension_repo.AllExtensions", + "returnGoType": "extension_repo.AllExtensions", + "returnTypescriptType": "ExtensionRepo_AllExtensions" + } + }, + { + "name": "HandleGetExtensionUpdateData", + "trimmedName": "GetExtensionUpdateData", + "comments": [ + "HandleGetExtensionUpdateData", + "", + "\t@summary returns the update data that were found for the extensions.", + "\t@route /api/v1/extensions/updates [GET]", + "\t@returns []extension_repo.UpdateData", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns the update data that were found for the extensions.", + "descriptions": [], + "endpoint": "/api/v1/extensions/updates", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]extension_repo.UpdateData", + "returnGoType": "extension_repo.UpdateData", + "returnTypescriptType": "Array\u003cExtensionRepo_UpdateData\u003e" + } + }, + { + "name": "HandleListMangaProviderExtensions", + "trimmedName": "ListMangaProviderExtensions", + "comments": [ + "HandleListMangaProviderExtensions", + "", + "\t@summary returns the installed manga providers.", + "\t@route /api/v1/extensions/list/manga-provider [GET]", + "\t@returns []extension_repo.MangaProviderExtensionItem", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns the installed manga providers.", + "descriptions": [], + "endpoint": "/api/v1/extensions/list/manga-provider", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]extension_repo.MangaProviderExtensionItem", + "returnGoType": "extension_repo.MangaProviderExtensionItem", + "returnTypescriptType": "Array\u003cExtensionRepo_MangaProviderExtensionItem\u003e" + } + }, + { + "name": "HandleListOnlinestreamProviderExtensions", + "trimmedName": "ListOnlinestreamProviderExtensions", + "comments": [ + "HandleListOnlinestreamProviderExtensions", + "", + "\t@summary returns the installed online streaming providers.", + "\t@route /api/v1/extensions/list/onlinestream-provider [GET]", + "\t@returns []extension_repo.OnlinestreamProviderExtensionItem", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns the installed online streaming providers.", + "descriptions": [], + "endpoint": "/api/v1/extensions/list/onlinestream-provider", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]extension_repo.OnlinestreamProviderExtensionItem", + "returnGoType": "extension_repo.OnlinestreamProviderExtensionItem", + "returnTypescriptType": "Array\u003cExtensionRepo_OnlinestreamProviderExtensionItem\u003e" + } + }, + { + "name": "HandleListAnimeTorrentProviderExtensions", + "trimmedName": "ListAnimeTorrentProviderExtensions", + "comments": [ + "HandleListAnimeTorrentProviderExtensions", + "", + "\t@summary returns the installed torrent providers.", + "\t@route /api/v1/extensions/list/anime-torrent-provider [GET]", + "\t@returns []extension_repo.AnimeTorrentProviderExtensionItem", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns the installed torrent providers.", + "descriptions": [], + "endpoint": "/api/v1/extensions/list/anime-torrent-provider", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]extension_repo.AnimeTorrentProviderExtensionItem", + "returnGoType": "extension_repo.AnimeTorrentProviderExtensionItem", + "returnTypescriptType": "Array\u003cExtensionRepo_AnimeTorrentProviderExtensionItem\u003e" + } + }, + { + "name": "HandleGetPluginSettings", + "trimmedName": "GetPluginSettings", + "comments": [ + "HandleGetPluginSettings", + "", + "\t@summary returns the plugin settings.", + "\t@route /api/v1/extensions/plugin-settings [GET]", + "\t@returns extension_repo.StoredPluginSettingsData", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns the plugin settings.", + "descriptions": [], + "endpoint": "/api/v1/extensions/plugin-settings", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "extension_repo.StoredPluginSettingsData", + "returnGoType": "extension_repo.StoredPluginSettingsData", + "returnTypescriptType": "ExtensionRepo_StoredPluginSettingsData" + } + }, + { + "name": "HandleSetPluginSettingsPinnedTrays", + "trimmedName": "SetPluginSettingsPinnedTrays", + "comments": [ + "HandleSetPluginSettingsPinnedTrays", + "", + "\t@summary sets the pinned trays in the plugin settings.", + "\t@route /api/v1/extensions/plugin-settings/pinned-trays [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "sets the pinned trays in the plugin settings.", + "descriptions": [], + "endpoint": "/api/v1/extensions/plugin-settings/pinned-trays", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "PinnedTrayPluginIds", + "jsonName": "pinnedTrayPluginIds", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGrantPluginPermissions", + "trimmedName": "GrantPluginPermissions", + "comments": [ + "HandleGrantPluginPermissions", + "", + "\t@summary grants the plugin permissions to the extension with the given ID.", + "\t@route /api/v1/extensions/plugin-permissions/grant [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "grants the plugin permissions to the extension with the given ID.", + "descriptions": [], + "endpoint": "/api/v1/extensions/plugin-permissions/grant", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleRunExtensionPlaygroundCode", + "trimmedName": "RunExtensionPlaygroundCode", + "comments": [ + "HandleRunExtensionPlaygroundCode", + "", + "\t@summary runs the code in the extension playground.", + "\t@desc Returns the logs", + "\t@route /api/v1/extensions/playground/run [POST]", + "\t@returns extension_playground.RunPlaygroundCodeResponse", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "runs the code in the extension playground.", + "descriptions": [ + "Returns the logs" + ], + "endpoint": "/api/v1/extensions/playground/run", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Params", + "jsonName": "params", + "goType": "extension_playground.RunPlaygroundCodeParams", + "usedStructType": "extension_playground.RunPlaygroundCodeParams", + "typescriptType": "RunPlaygroundCodeParams", + "required": false, + "descriptions": [] + } + ], + "returns": "extension_playground.RunPlaygroundCodeResponse", + "returnGoType": "extension_playground.RunPlaygroundCodeResponse", + "returnTypescriptType": "RunPlaygroundCodeResponse" + } + }, + { + "name": "HandleGetExtensionUserConfig", + "trimmedName": "GetExtensionUserConfig", + "comments": [ + "HandleGetExtensionUserConfig", + "", + "\t@summary returns the user config definition and current values for the extension with the given ID.", + "\t@route /api/v1/extensions/user-config/{id} [GET]", + "\t@returns extension_repo.ExtensionUserConfig", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns the user config definition and current values for the extension with the given ID.", + "descriptions": [], + "endpoint": "/api/v1/extensions/user-config/{id}", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "extension_repo.ExtensionUserConfig", + "returnGoType": "extension_repo.ExtensionUserConfig", + "returnTypescriptType": "ExtensionRepo_ExtensionUserConfig" + } + }, + { + "name": "HandleSaveExtensionUserConfig", + "trimmedName": "SaveExtensionUserConfig", + "comments": [ + "HandleSaveExtensionUserConfig", + "", + "\t@summary saves the user config for the extension with the given ID and reloads it.", + "\t@route /api/v1/extensions/user-config [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "saves the user config for the extension with the given ID and reloads it.", + "descriptions": [], + "endpoint": "/api/v1/extensions/user-config", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Version", + "jsonName": "version", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Values", + "jsonName": "values", + "goType": "map[string]string", + "usedStructType": "", + "typescriptType": "Record\u003cstring, string\u003e", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetMarketplaceExtensions", + "trimmedName": "GetMarketplaceExtensions", + "comments": [ + "HandleGetMarketplaceExtensions", + "", + "\t@summary returns the marketplace extensions.", + "\t@route /api/v1/extensions/marketplace [GET]", + "\t@returns []extension.Extension", + "" + ], + "filepath": "internal/handlers/extensions.go", + "filename": "extensions.go", + "api": { + "summary": "returns the marketplace extensions.", + "descriptions": [], + "endpoint": "/api/v1/extensions/marketplace", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]extension.Extension", + "returnGoType": "extension.Extension", + "returnTypescriptType": "Array\u003cExtension_Extension\u003e" + } + }, + { + "name": "HandleGetFileCacheTotalSize", + "trimmedName": "GetFileCacheTotalSize", + "comments": [ + "HandleGetFileCacheTotalSize", + "", + "\t@summary returns the total size of cache files.", + "\t@desc The total size of the cache files is returned in human-readable format.", + "\t@route /api/v1/filecache/total-size [GET]", + "\t@returns string", + "" + ], + "filepath": "internal/handlers/filecache.go", + "filename": "filecache.go", + "api": { + "summary": "returns the total size of cache files.", + "descriptions": [ + "The total size of the cache files is returned in human-readable format." + ], + "endpoint": "/api/v1/filecache/total-size", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "string", + "returnGoType": "string", + "returnTypescriptType": "string" + } + }, + { + "name": "HandleRemoveFileCacheBucket", + "trimmedName": "RemoveFileCacheBucket", + "comments": [ + "HandleRemoveFileCacheBucket", + "", + "\t@summary deletes all buckets with the given prefix.", + "\t@desc The bucket value is the prefix of the cache files that should be deleted.", + "\t@desc Returns 'true' if the operation was successful.", + "\t@route /api/v1/filecache/bucket [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/filecache.go", + "filename": "filecache.go", + "api": { + "summary": "deletes all buckets with the given prefix.", + "descriptions": [ + "The bucket value is the prefix of the cache files that should be deleted.", + "Returns 'true' if the operation was successful." + ], + "endpoint": "/api/v1/filecache/bucket", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [ + { + "name": "Bucket", + "jsonName": "bucket", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetFileCacheMediastreamVideoFilesTotalSize", + "trimmedName": "GetFileCacheMediastreamVideoFilesTotalSize", + "comments": [ + "HandleGetFileCacheMediastreamVideoFilesTotalSize", + "", + "\t@summary returns the total size of cached video file data.", + "\t@desc The total size of the cache video file data is returned in human-readable format.", + "\t@route /api/v1/filecache/mediastream/videofiles/total-size [GET]", + "\t@returns string", + "" + ], + "filepath": "internal/handlers/filecache.go", + "filename": "filecache.go", + "api": { + "summary": "returns the total size of cached video file data.", + "descriptions": [ + "The total size of the cache video file data is returned in human-readable format." + ], + "endpoint": "/api/v1/filecache/mediastream/videofiles/total-size", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "string", + "returnGoType": "string", + "returnTypescriptType": "string" + } + }, + { + "name": "HandleClearFileCacheMediastreamVideoFiles", + "trimmedName": "ClearFileCacheMediastreamVideoFiles", + "comments": [ + "HandleClearFileCacheMediastreamVideoFiles", + "", + "\t@summary deletes the contents of the mediastream video file cache directory.", + "\t@desc Returns 'true' if the operation was successful.", + "\t@route /api/v1/filecache/mediastream/videofiles [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/filecache.go", + "filename": "filecache.go", + "api": { + "summary": "deletes the contents of the mediastream video file cache directory.", + "descriptions": [ + "Returns 'true' if the operation was successful." + ], + "endpoint": "/api/v1/filecache/mediastream/videofiles", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleSetOfflineMode", + "trimmedName": "SetOfflineMode", + "comments": [ + "HandleSetOfflineMode", + "", + "\t@summary sets the offline mode.", + "\t@desc Returns true if the offline mode is active, false otherwise.", + "\t@route /api/v1/local/offline [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "sets the offline mode.", + "descriptions": [ + "Returns true if the offline mode is active, false otherwise." + ], + "endpoint": "/api/v1/local/offline", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleLocalGetTrackedMediaItems", + "trimmedName": "LocalGetTrackedMediaItems", + "comments": [ + "HandleLocalGetTrackedMediaItems", + "", + "\t@summary gets all tracked media.", + "\t@route /api/v1/local/track [GET]", + "\t@returns []local.TrackedMediaItem", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "gets all tracked media.", + "descriptions": [], + "endpoint": "/api/v1/local/track", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]local.TrackedMediaItem", + "returnGoType": "local.TrackedMediaItem", + "returnTypescriptType": "Array\u003cLocal_TrackedMediaItem\u003e" + } + }, + { + "name": "HandleLocalAddTrackedMedia", + "trimmedName": "LocalAddTrackedMedia", + "comments": [ + "HandleLocalAddTrackedMedia", + "", + "\t@summary adds one or multiple media to be tracked for offline sync.", + "\t@route /api/v1/local/track [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "adds one or multiple media to be tracked for offline sync.", + "descriptions": [], + "endpoint": "/api/v1/local/track", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nMediaId int `json:\"mediaId\"`\nType string `json:\"type\"`}", + "usedStructType": "", + "typescriptType": "Array\u003c{ mediaId: number; type: string; }\u003e", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleLocalRemoveTrackedMedia", + "trimmedName": "LocalRemoveTrackedMedia", + "comments": [ + "HandleLocalRemoveTrackedMedia", + "", + "\t@summary remove media from being tracked for offline sync.", + "\t@desc This will remove anime from being tracked for offline sync and delete any associated data.", + "\t@route /api/v1/local/track [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "remove media from being tracked for offline sync.", + "descriptions": [ + "This will remove anime from being tracked for offline sync and delete any associated data." + ], + "endpoint": "/api/v1/local/track", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleLocalGetIsMediaTracked", + "trimmedName": "LocalGetIsMediaTracked", + "comments": [ + "HandleLocalGetIsMediaTracked", + "", + "\t@summary checks if media is being tracked for offline sync.", + "\t@route /api/v1/local/track/{id}/{type} [GET]", + "\t@param id - int - true - \"AniList anime media ID\"", + "\t@param type - string - true - \"Type of media (anime/manga)\"", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "checks if media is being tracked for offline sync.", + "descriptions": [], + "endpoint": "/api/v1/local/track/{id}/{type}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "AniList anime media ID" + ] + }, + { + "name": "type", + "jsonName": "type", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [ + "Type of media (anime/manga)" + ] + } + ], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleLocalSyncData", + "trimmedName": "LocalSyncData", + "comments": [ + "HandleLocalSyncData", + "", + "\t@summary syncs local data with AniList.", + "\t@route /api/v1/local/local [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "syncs local data with AniList.", + "descriptions": [], + "endpoint": "/api/v1/local/local", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleLocalGetSyncQueueState", + "trimmedName": "LocalGetSyncQueueState", + "comments": [ + "HandleLocalGetSyncQueueState", + "", + "\t@summary gets the current sync queue state.", + "\t@desc This will return the list of media that are currently queued for syncing.", + "\t@route /api/v1/local/queue [GET]", + "\t@returns local.QueueState", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "gets the current sync queue state.", + "descriptions": [ + "This will return the list of media that are currently queued for syncing." + ], + "endpoint": "/api/v1/local/queue", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "local.QueueState", + "returnGoType": "local.QueueState", + "returnTypescriptType": "Local_QueueState" + } + }, + { + "name": "HandleLocalSyncAnilistData", + "trimmedName": "LocalSyncAnilistData", + "comments": [ + "HandleLocalSyncAnilistData", + "", + "\t@summary syncs AniList data with local.", + "\t@route /api/v1/local/anilist [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "syncs AniList data with local.", + "descriptions": [], + "endpoint": "/api/v1/local/anilist", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleLocalSetHasLocalChanges", + "trimmedName": "LocalSetHasLocalChanges", + "comments": [ + "HandleLocalSetHasLocalChanges", + "", + "\t@summary sets the flag to determine if there are local changes that need to be synced with AniList.", + "\t@route /api/v1/local/updated [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "sets the flag to determine if there are local changes that need to be synced with AniList.", + "descriptions": [], + "endpoint": "/api/v1/local/updated", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Updated", + "jsonName": "updated", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleLocalGetHasLocalChanges", + "trimmedName": "LocalGetHasLocalChanges", + "comments": [ + "HandleLocalGetHasLocalChanges", + "", + "\t@summary gets the flag to determine if there are local changes that need to be synced with AniList.", + "\t@route /api/v1/local/updated [GET]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "gets the flag to determine if there are local changes that need to be synced with AniList.", + "descriptions": [], + "endpoint": "/api/v1/local/updated", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleLocalGetLocalStorageSize", + "trimmedName": "LocalGetLocalStorageSize", + "comments": [ + "HandleLocalGetLocalStorageSize", + "", + "\t@summary gets the size of the local storage in a human-readable format.", + "\t@route /api/v1/local/storage/size [GET]", + "\t@returns string", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "gets the size of the local storage in a human-readable format.", + "descriptions": [], + "endpoint": "/api/v1/local/storage/size", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "string", + "returnGoType": "string", + "returnTypescriptType": "string" + } + }, + { + "name": "HandleLocalSyncSimulatedDataToAnilist", + "trimmedName": "LocalSyncSimulatedDataToAnilist", + "comments": [ + "HandleLocalSyncSimulatedDataToAnilist", + "", + "\t@summary syncs the simulated data to AniList.", + "\t@route /api/v1/local/sync-simulated-to-anilist [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/local.go", + "filename": "local.go", + "api": { + "summary": "syncs the simulated data to AniList.", + "descriptions": [], + "endpoint": "/api/v1/local/sync-simulated-to-anilist", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetLocalFiles", + "trimmedName": "GetLocalFiles", + "comments": [ + "HandleGetLocalFiles", + "", + "\t@summary returns all local files.", + "\t@desc Reminder that local files are scanned from the library path.", + "\t@route /api/v1/library/local-files [GET]", + "\t@returns []anime.LocalFile", + "" + ], + "filepath": "internal/handlers/localfiles.go", + "filename": "localfiles.go", + "api": { + "summary": "returns all local files.", + "descriptions": [ + "Reminder that local files are scanned from the library path." + ], + "endpoint": "/api/v1/library/local-files", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]anime.LocalFile", + "returnGoType": "anime.LocalFile", + "returnTypescriptType": "Array\u003cAnime_LocalFile\u003e" + } + }, + { + "name": "HandleImportLocalFiles", + "trimmedName": "ImportLocalFiles", + "comments": [ + "HandleImportLocalFiles", + "", + "\t@summary imports local files from the given path.", + "\t@desc This will import local files from the given path.", + "\t@desc The response is ignored, the client should refetch the entire library collection and media entry.", + "\t@route /api/v1/library/local-files/import [POST]", + "" + ], + "filepath": "internal/handlers/localfiles.go", + "filename": "localfiles.go", + "api": { + "summary": "imports local files from the given path.", + "descriptions": [ + "This will import local files from the given path.", + "The response is ignored, the client should refetch the entire library collection and media entry." + ], + "endpoint": "/api/v1/library/local-files/import", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "DataFilePath", + "jsonName": "dataFilePath", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleLocalFileBulkAction", + "trimmedName": "LocalFileBulkAction", + "comments": [ + "HandleLocalFileBulkAction", + "", + "\t@summary performs an action on all local files.", + "\t@desc This will perform the given action on all local files.", + "\t@desc The response is ignored, the client should refetch the entire library collection and media entry.", + "\t@route /api/v1/library/local-files [POST]", + "\t@returns []anime.LocalFile", + "" + ], + "filepath": "internal/handlers/localfiles.go", + "filename": "localfiles.go", + "api": { + "summary": "performs an action on all local files.", + "descriptions": [ + "This will perform the given action on all local files.", + "The response is ignored, the client should refetch the entire library collection and media entry." + ], + "endpoint": "/api/v1/library/local-files", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Action", + "jsonName": "action", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "[]anime.LocalFile", + "returnGoType": "anime.LocalFile", + "returnTypescriptType": "Array\u003cAnime_LocalFile\u003e" + } + }, + { + "name": "HandleUpdateLocalFileData", + "trimmedName": "UpdateLocalFileData", + "comments": [ + "HandleUpdateLocalFileData", + "", + "\t@summary updates the local file with the given path.", + "\t@desc This will update the local file with the given path.", + "\t@desc The response is ignored, the client should refetch the entire library collection and media entry.", + "\t@route /api/v1/library/local-file [PATCH]", + "\t@returns []anime.LocalFile", + "" + ], + "filepath": "internal/handlers/localfiles.go", + "filename": "localfiles.go", + "api": { + "summary": "updates the local file with the given path.", + "descriptions": [ + "This will update the local file with the given path.", + "The response is ignored, the client should refetch the entire library collection and media entry." + ], + "endpoint": "/api/v1/library/local-file", + "methods": [ + "PATCH" + ], + "params": [], + "bodyFields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Metadata", + "jsonName": "metadata", + "goType": "anime.LocalFileMetadata", + "usedStructType": "anime.LocalFileMetadata", + "typescriptType": "Anime_LocalFileMetadata", + "required": false, + "descriptions": [] + }, + { + "name": "Locked", + "jsonName": "locked", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "Ignored", + "jsonName": "ignored", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "[]anime.LocalFile", + "returnGoType": "anime.LocalFile", + "returnTypescriptType": "Array\u003cAnime_LocalFile\u003e" + } + }, + { + "name": "HandleUpdateLocalFiles", + "trimmedName": "UpdateLocalFiles", + "comments": [ + "HandleUpdateLocalFiles", + "", + "\t@summary updates local files with the given paths.", + "\t@desc The client should refetch the entire library collection and media entry.", + "\t@route /api/v1/library/local-files [PATCH]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/localfiles.go", + "filename": "localfiles.go", + "api": { + "summary": "updates local files with the given paths.", + "descriptions": [ + "The client should refetch the entire library collection and media entry." + ], + "endpoint": "/api/v1/library/local-files", + "methods": [ + "PATCH" + ], + "params": [], + "bodyFields": [ + { + "name": "Paths", + "jsonName": "paths", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "Action", + "jsonName": "action", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleDeleteLocalFiles", + "trimmedName": "DeleteLocalFiles", + "comments": [ + "HandleDeleteLocalFiles", + "", + "\t@summary deletes local files with the given paths.", + "\t@desc This will delete the local files with the given paths.", + "\t@desc The client should refetch the entire library collection and media entry.", + "\t@route /api/v1/library/local-files [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/localfiles.go", + "filename": "localfiles.go", + "api": { + "summary": "deletes local files with the given paths.", + "descriptions": [ + "This will delete the local files with the given paths.", + "The client should refetch the entire library collection and media entry." + ], + "endpoint": "/api/v1/library/local-files", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [ + { + "name": "Paths", + "jsonName": "paths", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleRemoveEmptyDirectories", + "trimmedName": "RemoveEmptyDirectories", + "comments": [ + "HandleRemoveEmptyDirectories", + "", + "\t@summary removes empty directories.", + "\t@desc This will remove empty directories in the library path.", + "\t@route /api/v1/library/empty-directories [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/localfiles.go", + "filename": "localfiles.go", + "api": { + "summary": "removes empty directories.", + "descriptions": [ + "This will remove empty directories in the library path." + ], + "endpoint": "/api/v1/library/empty-directories", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleMALAuth", + "trimmedName": "MALAuth", + "comments": [ + "HandleMALAuth", + "", + "\t@summary fetches the access and refresh tokens for the given code.", + "\t@desc This is used to authenticate the user with MyAnimeList.", + "\t@desc It will save the info in the database, effectively logging the user in.", + "\t@desc The client should re-fetch the server status after this.", + "\t@route /api/v1/mal/auth [POST]", + "\t@returns handlers.MalAuthResponse", + "" + ], + "filepath": "internal/handlers/mal.go", + "filename": "mal.go", + "api": { + "summary": "fetches the access and refresh tokens for the given code.", + "descriptions": [ + "This is used to authenticate the user with MyAnimeList.", + "It will save the info in the database, effectively logging the user in.", + "The client should re-fetch the server status after this." + ], + "endpoint": "/api/v1/mal/auth", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Code", + "jsonName": "code", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "State", + "jsonName": "state", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "CodeVerifier", + "jsonName": "code_verifier", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "handlers.MalAuthResponse", + "returnGoType": "handlers.MalAuthResponse", + "returnTypescriptType": "MalAuthResponse" + } + }, + { + "name": "HandleEditMALListEntryProgress", + "trimmedName": "EditMALListEntryProgress", + "comments": [ + "HandleEditMALListEntryProgress", + "", + "\t@summary updates the progress of a MAL list entry.", + "\t@route /api/v1/mal/list-entry/progress [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/mal.go", + "filename": "mal.go", + "api": { + "summary": "updates the progress of a MAL list entry.", + "descriptions": [], + "endpoint": "/api/v1/mal/list-entry/progress", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleMALLogout", + "trimmedName": "MALLogout", + "comments": [ + "HandleMALLogout", + "", + "\t@summary logs the user out of MyAnimeList.", + "\t@desc This will delete the MAL info from the database, effectively logging the user out.", + "\t@desc The client should re-fetch the server status after this.", + "\t@route /api/v1/mal/logout [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/mal.go", + "filename": "mal.go", + "api": { + "summary": "logs the user out of MyAnimeList.", + "descriptions": [ + "This will delete the MAL info from the database, effectively logging the user out.", + "The client should re-fetch the server status after this." + ], + "endpoint": "/api/v1/mal/logout", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetAnilistMangaCollection", + "trimmedName": "GetAnilistMangaCollection", + "comments": [ + "HandleGetAnilistMangaCollection", + "", + "\t@summary returns the user's AniList manga collection.", + "\t@route /api/v1/manga/anilist/collection [GET]", + "\t@returns anilist.MangaCollection", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns the user's AniList manga collection.", + "descriptions": [], + "endpoint": "/api/v1/manga/anilist/collection", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [ + { + "name": "BypassCache", + "jsonName": "bypassCache", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "anilist.MangaCollection", + "returnGoType": "anilist.MangaCollection", + "returnTypescriptType": "AL_MangaCollection" + } + }, + { + "name": "HandleGetRawAnilistMangaCollection", + "trimmedName": "GetRawAnilistMangaCollection", + "comments": [ + "HandleGetRawAnilistMangaCollection", + "", + "\t@summary returns the user's AniList manga collection.", + "\t@route /api/v1/manga/anilist/collection/raw [GET,POST]", + "\t@returns anilist.MangaCollection", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns the user's AniList manga collection.", + "descriptions": [], + "endpoint": "/api/v1/manga/anilist/collection/raw", + "methods": [ + "GET", + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "anilist.MangaCollection", + "returnGoType": "anilist.MangaCollection", + "returnTypescriptType": "AL_MangaCollection" + } + }, + { + "name": "HandleGetMangaCollection", + "trimmedName": "GetMangaCollection", + "comments": [ + "HandleGetMangaCollection", + "", + "\t@summary returns the user's main manga collection.", + "\t@desc This is an object that contains all the user's manga entries in a structured format.", + "\t@route /api/v1/manga/collection [GET]", + "\t@returns manga.Collection", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns the user's main manga collection.", + "descriptions": [ + "This is an object that contains all the user's manga entries in a structured format." + ], + "endpoint": "/api/v1/manga/collection", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "manga.Collection", + "returnGoType": "manga.Collection", + "returnTypescriptType": "Manga_Collection" + } + }, + { + "name": "HandleGetMangaEntry", + "trimmedName": "GetMangaEntry", + "comments": [ + "HandleGetMangaEntry", + "", + "\t@summary returns a manga entry for the given AniList manga id.", + "\t@desc This is used by the manga media entry pages to get all the data about the anime. It includes metadata and AniList list data.", + "\t@route /api/v1/manga/entry/{id} [GET]", + "\t@param id - int - true - \"AniList manga media ID\"", + "\t@returns manga.Entry", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns a manga entry for the given AniList manga id.", + "descriptions": [ + "This is used by the manga media entry pages to get all the data about the anime. It includes metadata and AniList list data." + ], + "endpoint": "/api/v1/manga/entry/{id}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "AniList manga media ID" + ] + } + ], + "bodyFields": [], + "returns": "manga.Entry", + "returnGoType": "manga.Entry", + "returnTypescriptType": "Manga_Entry" + } + }, + { + "name": "HandleGetMangaEntryDetails", + "trimmedName": "GetMangaEntryDetails", + "comments": [ + "HandleGetMangaEntryDetails", + "", + "\t@summary returns more details about an AniList manga entry.", + "\t@desc This fetches more fields omitted from the base queries.", + "\t@route /api/v1/manga/entry/{id}/details [GET]", + "\t@param id - int - true - \"AniList manga media ID\"", + "\t@returns anilist.MangaDetailsById_Media", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns more details about an AniList manga entry.", + "descriptions": [ + "This fetches more fields omitted from the base queries." + ], + "endpoint": "/api/v1/manga/entry/{id}/details", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "AniList manga media ID" + ] + } + ], + "bodyFields": [], + "returns": "anilist.MangaDetailsById_Media", + "returnGoType": "anilist.MangaDetailsById_Media", + "returnTypescriptType": "AL_MangaDetailsById_Media" + } + }, + { + "name": "HandleGetMangaLatestChapterNumbersMap", + "trimmedName": "GetMangaLatestChapterNumbersMap", + "comments": [ + "HandleGetMangaLatestChapterNumbersMap", + "", + "\t@summary returns the latest chapter number for all manga entries.", + "\t@route /api/v1/manga/latest-chapter-numbers [GET]", + "\t@returns map[int][]manga.MangaLatestChapterNumberItem", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns the latest chapter number for all manga entries.", + "descriptions": [], + "endpoint": "/api/v1/manga/latest-chapter-numbers", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "map[int][]manga.MangaLatestChapterNumberItem", + "returnGoType": "manga.MangaLatestChapterNumberItem", + "returnTypescriptType": "Record\u003cnumber, Array\u003cManga_MangaLatestChapterNumberItem\u003e\u003e" + } + }, + { + "name": "HandleRefetchMangaChapterContainers", + "trimmedName": "RefetchMangaChapterContainers", + "comments": [ + "HandleRefetchMangaChapterContainers", + "", + "\t@summary refetches the chapter containers for all manga entries previously cached.", + "\t@route /api/v1/manga/refetch-chapter-containers [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "refetches the chapter containers for all manga entries previously cached.", + "descriptions": [], + "endpoint": "/api/v1/manga/refetch-chapter-containers", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "SelectedProviderMap", + "jsonName": "selectedProviderMap", + "goType": "map[int]string", + "usedStructType": "", + "typescriptType": "Record\u003cnumber, string\u003e", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleEmptyMangaEntryCache", + "trimmedName": "EmptyMangaEntryCache", + "comments": [ + "HandleEmptyMangaEntryCache", + "", + "\t@summary empties the cache for a manga entry.", + "\t@desc This will empty the cache for a manga entry (chapter lists and pages), allowing the client to fetch fresh data.", + "\t@desc HandleGetMangaEntryChapters should be called after this to fetch the new chapter list.", + "\t@desc Returns 'true' if the operation was successful.", + "\t@route /api/v1/manga/entry/cache [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "empties the cache for a manga entry.", + "descriptions": [ + "This will empty the cache for a manga entry (chapter lists and pages), allowing the client to fetch fresh data.", + "HandleGetMangaEntryChapters should be called after this to fetch the new chapter list.", + "Returns 'true' if the operation was successful." + ], + "endpoint": "/api/v1/manga/entry/cache", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetMangaEntryChapters", + "trimmedName": "GetMangaEntryChapters", + "comments": [ + "HandleGetMangaEntryChapters", + "", + "\t@summary returns the chapters for a manga entry based on the provider.", + "\t@route /api/v1/manga/chapters [POST]", + "\t@returns manga.ChapterContainer", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns the chapters for a manga entry based on the provider.", + "descriptions": [], + "endpoint": "/api/v1/manga/chapters", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "manga.ChapterContainer", + "returnGoType": "manga.ChapterContainer", + "returnTypescriptType": "Manga_ChapterContainer" + } + }, + { + "name": "HandleGetMangaEntryPages", + "trimmedName": "GetMangaEntryPages", + "comments": [ + "HandleGetMangaEntryPages", + "", + "\t@summary returns the pages for a manga entry based on the provider and chapter id.", + "\t@desc This will return the pages for a manga chapter.", + "\t@desc If the app is offline and the chapter is not downloaded, it will return an error.", + "\t@desc If the app is online and the chapter is not downloaded, it will return the pages from the provider.", + "\t@desc If the chapter is downloaded, it will return the appropriate struct.", + "\t@desc If 'double page' is requested, it will fetch image sizes and include the dimensions in the response.", + "\t@route /api/v1/manga/pages [POST]", + "\t@returns manga.PageContainer", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns the pages for a manga entry based on the provider and chapter id.", + "descriptions": [ + "This will return the pages for a manga chapter.", + "If the app is offline and the chapter is not downloaded, it will return an error.", + "If the app is online and the chapter is not downloaded, it will return the pages from the provider.", + "If the chapter is downloaded, it will return the appropriate struct.", + "If 'double page' is requested, it will fetch image sizes and include the dimensions in the response." + ], + "endpoint": "/api/v1/manga/pages", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "ChapterId", + "jsonName": "chapterId", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "DoublePage", + "jsonName": "doublePage", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "manga.PageContainer", + "returnGoType": "manga.PageContainer", + "returnTypescriptType": "Manga_PageContainer" + } + }, + { + "name": "HandleGetMangaEntryDownloadedChapters", + "trimmedName": "GetMangaEntryDownloadedChapters", + "comments": [ + "HandleGetMangaEntryDownloadedChapters", + "", + "\t@summary returns all download chapters for a manga entry,", + "\t@route /api/v1/manga/downloaded-chapters/{id} [GET]", + "\t@param id - int - true - \"AniList manga media ID\"", + "\t@returns []manga.ChapterContainer", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns all download chapters for a manga entry,", + "descriptions": [], + "endpoint": "/api/v1/manga/downloaded-chapters/{id}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "AniList manga media ID" + ] + } + ], + "bodyFields": [], + "returns": "[]manga.ChapterContainer", + "returnGoType": "manga.ChapterContainer", + "returnTypescriptType": "Array\u003cManga_ChapterContainer\u003e" + } + }, + { + "name": "HandleAnilistListManga", + "trimmedName": "AnilistListManga", + "comments": [ + "HandleAnilistListManga", + "", + "\t@summary returns a list of manga based on the search parameters.", + "\t@desc This is used by \"Advanced Search\" and search function.", + "\t@route /api/v1/manga/anilist/list [POST]", + "\t@returns anilist.ListManga", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns a list of manga based on the search parameters.", + "descriptions": [ + "This is used by \"Advanced Search\" and search function." + ], + "endpoint": "/api/v1/manga/anilist/list", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Page", + "jsonName": "page", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Search", + "jsonName": "search", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [] + }, + { + "name": "PerPage", + "jsonName": "perPage", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Sort", + "jsonName": "sort", + "goType": "[]anilist.MediaSort", + "usedStructType": "anilist.MediaSort", + "typescriptType": "Array\u003cAL_MediaSort\u003e", + "required": false, + "descriptions": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "[]anilist.MediaStatus", + "usedStructType": "anilist.MediaStatus", + "typescriptType": "Array\u003cAL_MediaStatus\u003e", + "required": false, + "descriptions": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "descriptions": [] + }, + { + "name": "AverageScoreGreater", + "jsonName": "averageScore_greater", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "CountryOfOrigin", + "jsonName": "countryOfOrigin", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [] + }, + { + "name": "IsAdult", + "jsonName": "isAdult", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": false, + "descriptions": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "anilist.MediaFormat", + "usedStructType": "anilist.MediaFormat", + "typescriptType": "AL_MediaFormat", + "required": false, + "descriptions": [] + } + ], + "returns": "anilist.ListManga", + "returnGoType": "anilist.ListManga", + "returnTypescriptType": "AL_ListManga" + } + }, + { + "name": "HandleUpdateMangaProgress", + "trimmedName": "UpdateMangaProgress", + "comments": [ + "HandleUpdateMangaProgress", + "", + "\t@summary updates the progress of a manga entry.", + "\t@desc Note: MyAnimeList is not supported", + "\t@route /api/v1/manga/update-progress [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "updates the progress of a manga entry.", + "descriptions": [ + "Note: MyAnimeList is not supported" + ], + "endpoint": "/api/v1/manga/update-progress", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "MalId", + "jsonName": "malId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "ChapterNumber", + "jsonName": "chapterNumber", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "TotalChapters", + "jsonName": "totalChapters", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleMangaManualSearch", + "trimmedName": "MangaManualSearch", + "comments": [ + "HandleMangaManualSearch", + "", + "\t@summary returns search results for a manual search.", + "\t@desc Returns search results for a manual search.", + "\t@route /api/v1/manga/search [POST]", + "\t@returns []hibikemanga.SearchResult", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns search results for a manual search.", + "descriptions": [ + "Returns search results for a manual search." + ], + "endpoint": "/api/v1/manga/search", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Query", + "jsonName": "query", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "[]hibikemanga.SearchResult", + "returnGoType": "hibikemanga.SearchResult", + "returnTypescriptType": "Array\u003cHibikeManga_SearchResult\u003e" + } + }, + { + "name": "HandleMangaManualMapping", + "trimmedName": "MangaManualMapping", + "comments": [ + "HandleMangaManualMapping", + "", + "\t@summary manually maps a manga entry to a manga ID from the provider.", + "\t@desc This is used to manually map a manga entry to a manga ID from the provider.", + "\t@desc The client should re-fetch the chapter container after this.", + "\t@route /api/v1/manga/manual-mapping [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "manually maps a manga entry to a manga ID from the provider.", + "descriptions": [ + "This is used to manually map a manga entry to a manga ID from the provider.", + "The client should re-fetch the chapter container after this." + ], + "endpoint": "/api/v1/manga/manual-mapping", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "MangaId", + "jsonName": "mangaId", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetMangaMapping", + "trimmedName": "GetMangaMapping", + "comments": [ + "HandleGetMangaMapping", + "", + "\t@summary returns the mapping for a manga entry.", + "\t@desc This is used to get the mapping for a manga entry.", + "\t@desc An empty string is returned if there's no manual mapping. If there is, the manga ID will be returned.", + "\t@route /api/v1/manga/get-mapping [POST]", + "\t@returns manga.MappingResponse", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns the mapping for a manga entry.", + "descriptions": [ + "This is used to get the mapping for a manga entry.", + "An empty string is returned if there's no manual mapping. If there is, the manga ID will be returned." + ], + "endpoint": "/api/v1/manga/get-mapping", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "manga.MappingResponse", + "returnGoType": "manga.MappingResponse", + "returnTypescriptType": "Manga_MappingResponse" + } + }, + { + "name": "HandleRemoveMangaMapping", + "trimmedName": "RemoveMangaMapping", + "comments": [ + "HandleRemoveMangaMapping", + "", + "\t@summary removes the mapping for a manga entry.", + "\t@desc This is used to remove the mapping for a manga entry.", + "\t@desc The client should re-fetch the chapter container after this.", + "\t@route /api/v1/manga/remove-mapping [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "removes the mapping for a manga entry.", + "descriptions": [ + "This is used to remove the mapping for a manga entry.", + "The client should re-fetch the chapter container after this." + ], + "endpoint": "/api/v1/manga/remove-mapping", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetLocalMangaPage", + "trimmedName": "GetLocalMangaPage", + "comments": [ + "HandleGetLocalMangaPage", + "", + "\t@summary returns a local manga page.", + "\t@route /api/v1/manga/local-page/{path} [GET]", + "\t@returns manga.PageContainer", + "" + ], + "filepath": "internal/handlers/manga.go", + "filename": "manga.go", + "api": { + "summary": "returns a local manga page.", + "descriptions": [], + "endpoint": "/api/v1/manga/local-page/{path}", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "manga.PageContainer", + "returnGoType": "manga.PageContainer", + "returnTypescriptType": "Manga_PageContainer" + } + }, + { + "name": "HandleDownloadMangaChapters", + "trimmedName": "DownloadMangaChapters", + "comments": [ + "HandleDownloadMangaChapters", + "", + "\t@summary adds chapters to the download queue.", + "\t@route /api/v1/manga/download-chapters [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/manga_download.go", + "filename": "manga_download.go", + "api": { + "summary": "adds chapters to the download queue.", + "descriptions": [], + "endpoint": "/api/v1/manga/download-chapters", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "ChapterIds", + "jsonName": "chapterIds", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "StartNow", + "jsonName": "startNow", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetMangaDownloadData", + "trimmedName": "GetMangaDownloadData", + "comments": [ + "HandleGetMangaDownloadData", + "", + "\t@summary returns the download data for a specific media.", + "\t@desc This is used to display information about the downloaded and queued chapters in the UI.", + "\t@desc If the 'cached' parameter is false, it will refresh the data by rescanning the download folder.", + "\t@route /api/v1/manga/download-data [POST]", + "\t@returns manga.MediaDownloadData", + "" + ], + "filepath": "internal/handlers/manga_download.go", + "filename": "manga_download.go", + "api": { + "summary": "returns the download data for a specific media.", + "descriptions": [ + "This is used to display information about the downloaded and queued chapters in the UI.", + "If the 'cached' parameter is false, it will refresh the data by rescanning the download folder." + ], + "endpoint": "/api/v1/manga/download-data", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Cached", + "jsonName": "cached", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "manga.MediaDownloadData", + "returnGoType": "manga.MediaDownloadData", + "returnTypescriptType": "Manga_MediaDownloadData" + } + }, + { + "name": "HandleGetMangaDownloadQueue", + "trimmedName": "GetMangaDownloadQueue", + "comments": [ + "HandleGetMangaDownloadQueue", + "", + "\t@summary returns the items in the download queue.", + "\t@route /api/v1/manga/download-queue [GET]", + "\t@returns []models.ChapterDownloadQueueItem", + "" + ], + "filepath": "internal/handlers/manga_download.go", + "filename": "manga_download.go", + "api": { + "summary": "returns the items in the download queue.", + "descriptions": [], + "endpoint": "/api/v1/manga/download-queue", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]models.ChapterDownloadQueueItem", + "returnGoType": "models.ChapterDownloadQueueItem", + "returnTypescriptType": "Array\u003cModels_ChapterDownloadQueueItem\u003e" + } + }, + { + "name": "HandleStartMangaDownloadQueue", + "trimmedName": "StartMangaDownloadQueue", + "comments": [ + "HandleStartMangaDownloadQueue", + "", + "\t@summary starts the download queue if it's not already running.", + "\t@desc This will start the download queue if it's not already running.", + "\t@desc Returns 'true' whether the queue was started or not.", + "\t@route /api/v1/manga/download-queue/start [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/manga_download.go", + "filename": "manga_download.go", + "api": { + "summary": "starts the download queue if it's not already running.", + "descriptions": [ + "This will start the download queue if it's not already running.", + "Returns 'true' whether the queue was started or not." + ], + "endpoint": "/api/v1/manga/download-queue/start", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleStopMangaDownloadQueue", + "trimmedName": "StopMangaDownloadQueue", + "comments": [ + "HandleStopMangaDownloadQueue", + "", + "\t@summary stops the manga download queue.", + "\t@desc This will stop the manga download queue.", + "\t@desc Returns 'true' whether the queue was stopped or not.", + "\t@route /api/v1/manga/download-queue/stop [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/manga_download.go", + "filename": "manga_download.go", + "api": { + "summary": "stops the manga download queue.", + "descriptions": [ + "This will stop the manga download queue.", + "Returns 'true' whether the queue was stopped or not." + ], + "endpoint": "/api/v1/manga/download-queue/stop", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleClearAllChapterDownloadQueue", + "trimmedName": "ClearAllChapterDownloadQueue", + "comments": [ + "HandleClearAllChapterDownloadQueue", + "", + "\t@summary clears all chapters from the download queue.", + "\t@desc This will clear all chapters from the download queue.", + "\t@desc Returns 'true' whether the queue was cleared or not.", + "\t@desc This will also send a websocket event telling the client to refetch the download queue.", + "\t@route /api/v1/manga/download-queue [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/manga_download.go", + "filename": "manga_download.go", + "api": { + "summary": "clears all chapters from the download queue.", + "descriptions": [ + "This will clear all chapters from the download queue.", + "Returns 'true' whether the queue was cleared or not.", + "This will also send a websocket event telling the client to refetch the download queue." + ], + "endpoint": "/api/v1/manga/download-queue", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleResetErroredChapterDownloadQueue", + "trimmedName": "ResetErroredChapterDownloadQueue", + "comments": [ + "HandleResetErroredChapterDownloadQueue", + "", + "\t@summary resets the errored chapters in the download queue.", + "\t@desc This will reset the errored chapters in the download queue, so they can be re-downloaded.", + "\t@desc Returns 'true' whether the queue was reset or not.", + "\t@desc This will also send a websocket event telling the client to refetch the download queue.", + "\t@route /api/v1/manga/download-queue/reset-errored [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/manga_download.go", + "filename": "manga_download.go", + "api": { + "summary": "resets the errored chapters in the download queue.", + "descriptions": [ + "This will reset the errored chapters in the download queue, so they can be re-downloaded.", + "Returns 'true' whether the queue was reset or not.", + "This will also send a websocket event telling the client to refetch the download queue." + ], + "endpoint": "/api/v1/manga/download-queue/reset-errored", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleDeleteMangaDownloadedChapters", + "trimmedName": "DeleteMangaDownloadedChapters", + "comments": [ + "HandleDeleteMangaDownloadedChapters", + "", + "\t@summary deletes downloaded chapters.", + "\t@desc This will delete downloaded chapters from the filesystem.", + "\t@desc Returns 'true' whether the chapters were deleted or not.", + "\t@desc The client should refetch the download data after this.", + "\t@route /api/v1/manga/download-chapter [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/manga_download.go", + "filename": "manga_download.go", + "api": { + "summary": "deletes downloaded chapters.", + "descriptions": [ + "This will delete downloaded chapters from the filesystem.", + "Returns 'true' whether the chapters were deleted or not.", + "The client should refetch the download data after this." + ], + "endpoint": "/api/v1/manga/download-chapter", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [ + { + "name": "DownloadIds", + "jsonName": "downloadIds", + "goType": "[]chapter_downloader.DownloadID", + "usedStructType": "chapter_downloader.DownloadID", + "typescriptType": "Array\u003cChapterDownloader_DownloadID\u003e", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetMangaDownloadsList", + "trimmedName": "GetMangaDownloadsList", + "comments": [ + "HandleGetMangaDownloadsList", + "", + "\t@summary displays the list of downloaded manga.", + "\t@desc This analyzes the download folder and returns a well-formatted structure for displaying downloaded manga.", + "\t@desc It returns a list of manga.DownloadListItem where the media data might be nil if it's not in the AniList collection.", + "\t@route /api/v1/manga/downloads [GET]", + "\t@returns []manga.DownloadListItem", + "" + ], + "filepath": "internal/handlers/manga_download.go", + "filename": "manga_download.go", + "api": { + "summary": "displays the list of downloaded manga.", + "descriptions": [ + "This analyzes the download folder and returns a well-formatted structure for displaying downloaded manga.", + "It returns a list of manga.DownloadListItem where the media data might be nil if it's not in the AniList collection." + ], + "endpoint": "/api/v1/manga/downloads", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]manga.DownloadListItem", + "returnGoType": "manga.DownloadListItem", + "returnTypescriptType": "Array\u003cManga_DownloadListItem\u003e" + } + }, + { + "name": "HandleTestDump", + "trimmedName": "TestDump", + "comments": [ + "HandleTestDump", + "", + "\t@summary this is a dummy handler for testing purposes.", + "\t@route /api/v1/test-dump [POST]", + "" + ], + "filepath": "internal/handlers/manual_dump.go", + "filename": "manual_dump.go", + "api": { + "summary": "this is a dummy handler for testing purposes.", + "descriptions": [], + "endpoint": "/api/v1/test-dump", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleStartDefaultMediaPlayer", + "trimmedName": "StartDefaultMediaPlayer", + "comments": [ + "HandleStartDefaultMediaPlayer", + "", + "\t@summary launches the default media player (vlc or mpc-hc).", + "\t@route /api/v1/media-player/start [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/mediaplayer.go", + "filename": "mediaplayer.go", + "api": { + "summary": "launches the default media player (vlc or mpc-hc).", + "descriptions": [], + "endpoint": "/api/v1/media-player/start", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetMediastreamSettings", + "trimmedName": "GetMediastreamSettings", + "comments": [ + "HandleGetMediastreamSettings", + "", + "\t@summary get mediastream settings.", + "\t@desc This returns the mediastream settings.", + "\t@returns models.MediastreamSettings", + "\t@route /api/v1/mediastream/settings [GET]", + "" + ], + "filepath": "internal/handlers/mediastream.go", + "filename": "mediastream.go", + "api": { + "summary": "get mediastream settings.", + "descriptions": [ + "This returns the mediastream settings." + ], + "endpoint": "/api/v1/mediastream/settings", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "models.MediastreamSettings", + "returnGoType": "models.MediastreamSettings", + "returnTypescriptType": "Models_MediastreamSettings" + } + }, + { + "name": "HandleSaveMediastreamSettings", + "trimmedName": "SaveMediastreamSettings", + "comments": [ + "HandleSaveMediastreamSettings", + "", + "\t@summary save mediastream settings.", + "\t@desc This saves the mediastream settings.", + "\t@returns models.MediastreamSettings", + "\t@route /api/v1/mediastream/settings [PATCH]", + "" + ], + "filepath": "internal/handlers/mediastream.go", + "filename": "mediastream.go", + "api": { + "summary": "save mediastream settings.", + "descriptions": [ + "This saves the mediastream settings." + ], + "endpoint": "/api/v1/mediastream/settings", + "methods": [ + "PATCH" + ], + "params": [], + "bodyFields": [ + { + "name": "Settings", + "jsonName": "settings", + "goType": "models.MediastreamSettings", + "usedStructType": "models.MediastreamSettings", + "typescriptType": "Models_MediastreamSettings", + "required": true, + "descriptions": [] + } + ], + "returns": "models.MediastreamSettings", + "returnGoType": "models.MediastreamSettings", + "returnTypescriptType": "Models_MediastreamSettings" + } + }, + { + "name": "HandleRequestMediastreamMediaContainer", + "trimmedName": "RequestMediastreamMediaContainer", + "comments": [ + "HandleRequestMediastreamMediaContainer", + "", + "\t@summary request media stream.", + "\t@desc This requests a media stream and returns the media container to start the playback.", + "\t@returns mediastream.MediaContainer", + "\t@route /api/v1/mediastream/request [POST]", + "" + ], + "filepath": "internal/handlers/mediastream.go", + "filename": "mediastream.go", + "api": { + "summary": "request media stream.", + "descriptions": [ + "This requests a media stream and returns the media container to start the playback." + ], + "endpoint": "/api/v1/mediastream/request", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "StreamType", + "jsonName": "streamType", + "goType": "mediastream.StreamType", + "usedStructType": "mediastream.StreamType", + "typescriptType": "Mediastream_StreamType", + "required": true, + "descriptions": [] + }, + { + "name": "AudioStreamIndex", + "jsonName": "audioStreamIndex", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "ClientId", + "jsonName": "clientId", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "mediastream.MediaContainer", + "returnGoType": "mediastream.MediaContainer", + "returnTypescriptType": "Mediastream_MediaContainer" + } + }, + { + "name": "HandlePreloadMediastreamMediaContainer", + "trimmedName": "PreloadMediastreamMediaContainer", + "comments": [ + "HandlePreloadMediastreamMediaContainer", + "", + "\t@summary preloads media stream for playback.", + "\t@desc This preloads a media stream by extracting the media information and attachments.", + "\t@returns bool", + "\t@route /api/v1/mediastream/preload [POST]", + "" + ], + "filepath": "internal/handlers/mediastream.go", + "filename": "mediastream.go", + "api": { + "summary": "preloads media stream for playback.", + "descriptions": [ + "This preloads a media stream by extracting the media information and attachments." + ], + "endpoint": "/api/v1/mediastream/preload", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "StreamType", + "jsonName": "streamType", + "goType": "mediastream.StreamType", + "usedStructType": "mediastream.StreamType", + "typescriptType": "Mediastream_StreamType", + "required": true, + "descriptions": [] + }, + { + "name": "AudioStreamIndex", + "jsonName": "audioStreamIndex", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleMediastreamShutdownTranscodeStream", + "trimmedName": "MediastreamShutdownTranscodeStream", + "comments": [ + "HandleMediastreamShutdownTranscodeStream", + "", + "\t@summary shuts down the transcode stream", + "\t@desc This requests the transcoder to shut down. It should be called when unmounting the player (playback is no longer needed).", + "\t@desc This will also send an events.MediastreamShutdownStream event.", + "\t@desc It will not return any error and is safe to call multiple times.", + "\t@returns bool", + "\t@route /api/v1/mediastream/shutdown-transcode [POST]", + "" + ], + "filepath": "internal/handlers/mediastream.go", + "filename": "mediastream.go", + "api": { + "summary": "shuts down the transcode stream", + "descriptions": [ + "This requests the transcoder to shut down. It should be called when unmounting the player (playback is no longer needed).", + "This will also send an events.MediastreamShutdownStream event.", + "It will not return any error and is safe to call multiple times." + ], + "endpoint": "/api/v1/mediastream/shutdown-transcode", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandlePopulateFillerData", + "trimmedName": "PopulateFillerData", + "comments": [ + "HandlePopulateFillerData", + "", + "\t@summary fetches and caches filler data for the given media.", + "\t@desc This will fetch and cache filler data for the given media.", + "\t@returns true", + "\t@route /api/v1/metadata-provider/filler [POST]", + "" + ], + "filepath": "internal/handlers/metadata.go", + "filename": "metadata.go", + "api": { + "summary": "fetches and caches filler data for the given media.", + "descriptions": [ + "This will fetch and cache filler data for the given media." + ], + "endpoint": "/api/v1/metadata-provider/filler", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "true", + "returnGoType": "true", + "returnTypescriptType": "true" + } + }, + { + "name": "HandleRemoveFillerData", + "trimmedName": "RemoveFillerData", + "comments": [ + "HandleRemoveFillerData", + "", + "\t@summary removes filler data cache.", + "\t@desc This will remove the filler data cache for the given media.", + "\t@returns bool", + "\t@route /api/v1/metadata-provider/filler [DELETE]", + "" + ], + "filepath": "internal/handlers/metadata.go", + "filename": "metadata.go", + "api": { + "summary": "removes filler data cache.", + "descriptions": [ + "This will remove the filler data cache for the given media." + ], + "endpoint": "/api/v1/metadata-provider/filler", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleNakamaWebSocket", + "trimmedName": "NakamaWebSocket", + "comments": [ + "HandleNakamaWebSocket handles WebSocket connections for Nakama peers", + "", + "\t@summary handles WebSocket connections for Nakama peers.", + "\t@desc This endpoint handles WebSocket connections from Nakama peers when this instance is acting as a host.", + "\t@route /api/v1/nakama/ws [GET]", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "handles WebSocket connections for Nakama peers.", + "descriptions": [ + "This endpoint handles WebSocket connections from Nakama peers when this instance is acting as a host." + ], + "endpoint": "/api/v1/nakama/ws", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleSendNakamaMessage", + "trimmedName": "SendNakamaMessage", + "comments": [ + "HandleSendNakamaMessage", + "", + "\t@summary sends a custom message through Nakama.", + "\t@desc This allows sending custom messages to connected peers or the host.", + "\t@route /api/v1/nakama/message [POST]", + "\t@returns nakama.MessageResponse", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "sends a custom message through Nakama.", + "descriptions": [ + "This allows sending custom messages to connected peers or the host." + ], + "endpoint": "/api/v1/nakama/message", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MessageType", + "jsonName": "messageType", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "", + "usedStructType": "", + "typescriptType": "any", + "required": true, + "descriptions": [] + }, + { + "name": "PeerID", + "jsonName": "peerId", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [] + } + ], + "returns": "nakama.MessageResponse", + "returnGoType": "nakama.MessageResponse", + "returnTypescriptType": "Nakama_MessageResponse" + } + }, + { + "name": "HandleGetNakamaAnimeLibrary", + "trimmedName": "GetNakamaAnimeLibrary", + "comments": [ + "HandleGetNakamaAnimeLibrary", + "", + "\t@summary shares the local anime collection with Nakama clients.", + "\t@desc This creates a new LibraryCollection struct and returns it.", + "\t@desc This is used to share the local anime collection with Nakama clients.", + "\t@route /api/v1/nakama/host/anime/library/collection [GET]", + "\t@returns nakama.NakamaAnimeLibrary", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "shares the local anime collection with Nakama clients.", + "descriptions": [ + "This creates a new LibraryCollection struct and returns it.", + "This is used to share the local anime collection with Nakama clients." + ], + "endpoint": "/api/v1/nakama/host/anime/library/collection", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "nakama.NakamaAnimeLibrary", + "returnGoType": "nakama.NakamaAnimeLibrary", + "returnTypescriptType": "Nakama_NakamaAnimeLibrary" + } + }, + { + "name": "HandleGetNakamaAnimeLibraryCollection", + "trimmedName": "GetNakamaAnimeLibraryCollection", + "comments": [ + "HandleGetNakamaAnimeLibraryCollection", + "", + "\t@summary shares the local anime collection with Nakama clients.", + "\t@desc This creates a new LibraryCollection struct and returns it.", + "\t@desc This is used to share the local anime collection with Nakama clients.", + "\t@route /api/v1/nakama/host/anime/library/collection [GET]", + "\t@returns anime.LibraryCollection", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "shares the local anime collection with Nakama clients.", + "descriptions": [ + "This creates a new LibraryCollection struct and returns it.", + "This is used to share the local anime collection with Nakama clients." + ], + "endpoint": "/api/v1/nakama/host/anime/library/collection", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "anime.LibraryCollection", + "returnGoType": "anime.LibraryCollection", + "returnTypescriptType": "Anime_LibraryCollection" + } + }, + { + "name": "HandleGetNakamaAnimeLibraryFiles", + "trimmedName": "GetNakamaAnimeLibraryFiles", + "comments": [ + "HandleGetNakamaAnimeLibraryFiles", + "", + "\t@summary return the local files for the given AniList anime media id.", + "\t@desc This is used by the anime media entry pages to get all the data about the anime.", + "\t@route /api/v1/nakama/host/anime/library/files/{id} [POST]", + "\t@param id - int - true - \"AniList anime media ID\"", + "\t@returns []anime.LocalFile", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "return the local files for the given AniList anime media id.", + "descriptions": [ + "This is used by the anime media entry pages to get all the data about the anime." + ], + "endpoint": "/api/v1/nakama/host/anime/library/files/{id}", + "methods": [ + "POST" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "AniList anime media ID" + ] + } + ], + "bodyFields": [], + "returns": "[]anime.LocalFile", + "returnGoType": "anime.LocalFile", + "returnTypescriptType": "Array\u003cAnime_LocalFile\u003e" + } + }, + { + "name": "HandleGetNakamaAnimeAllLibraryFiles", + "trimmedName": "GetNakamaAnimeAllLibraryFiles", + "comments": [ + "HandleGetNakamaAnimeAllLibraryFiles", + "", + "\t@summary return all the local files for the host.", + "\t@desc This is used to share the local anime collection with Nakama clients.", + "\t@route /api/v1/nakama/host/anime/library/files [POST]", + "\t@returns []anime.LocalFile", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "return all the local files for the host.", + "descriptions": [ + "This is used to share the local anime collection with Nakama clients." + ], + "endpoint": "/api/v1/nakama/host/anime/library/files", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "[]anime.LocalFile", + "returnGoType": "anime.LocalFile", + "returnTypescriptType": "Array\u003cAnime_LocalFile\u003e" + } + }, + { + "name": "HandleNakamaPlayVideo", + "trimmedName": "NakamaPlayVideo", + "comments": [ + "HandleNakamaPlayVideo", + "", + "\t@summary plays the media from the host.", + "\t@route /api/v1/nakama/play [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "plays the media from the host.", + "descriptions": [], + "endpoint": "/api/v1/nakama/play", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "AniDBEpisode", + "jsonName": "anidbEpisode", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleNakamaHostTorrentstreamServeStream", + "trimmedName": "NakamaHostTorrentstreamServeStream", + "comments": [ + "Note: This is not used anymore. Each peer will independently stream the torrent.", + "route /api/v1/nakama/host/torrentstream/stream", + "Allows peers to stream the currently playing torrent.", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "", + "descriptions": [], + "endpoint": "", + "methods": null, + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleNakamaHostDebridstreamServeStream", + "trimmedName": "NakamaHostDebridstreamServeStream", + "comments": [ + "route /api/v1/nakama/host/debridstream/stream", + "Allows peers to stream the currently playing torrent.", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "", + "descriptions": [], + "endpoint": "", + "methods": null, + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleNakamaHostGetDebridstreamURL", + "trimmedName": "NakamaHostGetDebridstreamURL", + "comments": [ + "route /api/v1/nakama/host/debridstream/url", + "Returns the debrid stream URL for direct access by peers to avoid host bandwidth usage", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "", + "descriptions": [], + "endpoint": "", + "methods": null, + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleNakamaHostAnimeLibraryServeStream", + "trimmedName": "NakamaHostAnimeLibraryServeStream", + "comments": [ + "route /api/v1/nakama/host/anime/library/stream?path={base64_encoded_path}", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "", + "descriptions": [], + "endpoint": "", + "methods": null, + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleNakamaProxyStream", + "trimmedName": "NakamaProxyStream", + "comments": [ + "route /api/v1/nakama/stream", + "Proxies stream requests to the host. It inserts the Nakama password in the headers.", + "It checks if the password is valid.", + "For debrid streams, it redirects directly to the debrid service to avoid host bandwidth usage.", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "", + "descriptions": [], + "endpoint": "", + "methods": null, + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleNakamaReconnectToHost", + "trimmedName": "NakamaReconnectToHost", + "comments": [ + "HandleNakamaReconnectToHost", + "", + "\t@summary reconnects to the Nakama host.", + "\t@desc This attempts to reconnect to the configured Nakama host if the connection was lost.", + "\t@route /api/v1/nakama/reconnect [POST]", + "\t@returns nakama.MessageResponse", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "reconnects to the Nakama host.", + "descriptions": [ + "This attempts to reconnect to the configured Nakama host if the connection was lost." + ], + "endpoint": "/api/v1/nakama/reconnect", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "nakama.MessageResponse", + "returnGoType": "nakama.MessageResponse", + "returnTypescriptType": "Nakama_MessageResponse" + } + }, + { + "name": "HandleNakamaRemoveStaleConnections", + "trimmedName": "NakamaRemoveStaleConnections", + "comments": [ + "HandleNakamaRemoveStaleConnections", + "", + "\t@summary removes stale peer connections.", + "\t@desc This removes peer connections that haven't responded to ping messages for a while.", + "\t@route /api/v1/nakama/cleanup [POST]", + "\t@returns nakama.MessageResponse", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "removes stale peer connections.", + "descriptions": [ + "This removes peer connections that haven't responded to ping messages for a while." + ], + "endpoint": "/api/v1/nakama/cleanup", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "nakama.MessageResponse", + "returnGoType": "nakama.MessageResponse", + "returnTypescriptType": "Nakama_MessageResponse" + } + }, + { + "name": "HandleNakamaCreateWatchParty", + "trimmedName": "NakamaCreateWatchParty", + "comments": [ + "HandleNakamaCreateWatchParty", + "", + "\t@summary creates a new watch party session.", + "\t@desc This creates a new watch party that peers can join to watch content together in sync.", + "\t@route /api/v1/nakama/watch-party/create [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "creates a new watch party session.", + "descriptions": [ + "This creates a new watch party that peers can join to watch content together in sync." + ], + "endpoint": "/api/v1/nakama/watch-party/create", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Settings", + "jsonName": "settings", + "goType": "nakama.WatchPartySessionSettings", + "usedStructType": "nakama.WatchPartySessionSettings", + "typescriptType": "Nakama_WatchPartySessionSettings", + "required": false, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleNakamaJoinWatchParty", + "trimmedName": "NakamaJoinWatchParty", + "comments": [ + "HandleNakamaJoinWatchParty", + "", + "\t@summary joins an existing watch party.", + "\t@desc This allows a peer to join an active watch party session.", + "\t@route /api/v1/nakama/watch-party/join [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "joins an existing watch party.", + "descriptions": [ + "This allows a peer to join an active watch party session." + ], + "endpoint": "/api/v1/nakama/watch-party/join", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleNakamaLeaveWatchParty", + "trimmedName": "NakamaLeaveWatchParty", + "comments": [ + "HandleNakamaLeaveWatchParty", + "", + "\t@summary leaves the current watch party.", + "\t@desc This removes the user from the active watch party session.", + "\t@route /api/v1/nakama/watch-party/leave [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/nakama.go", + "filename": "nakama.go", + "api": { + "summary": "leaves the current watch party.", + "descriptions": [ + "This removes the user from the active watch party session." + ], + "endpoint": "/api/v1/nakama/watch-party/leave", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetOnlineStreamEpisodeList", + "trimmedName": "GetOnlineStreamEpisodeList", + "comments": [ + "HandleGetOnlineStreamEpisodeList", + "", + "\t@summary returns the episode list for the given media and provider.", + "\t@desc It returns the episode list for the given media and provider.", + "\t@desc The episodes are cached using a file cache.", + "\t@desc The episode list is just a list of episodes with no video sources, it's what the client uses to display the episodes and subsequently fetch the sources.", + "\t@desc The episode list might be nil or empty if nothing could be found, but the media will always be returned.", + "\t@route /api/v1/onlinestream/episode-list [POST]", + "\t@returns onlinestream.EpisodeListResponse", + "" + ], + "filepath": "internal/handlers/onlinestream.go", + "filename": "onlinestream.go", + "api": { + "summary": "returns the episode list for the given media and provider.", + "descriptions": [ + "It returns the episode list for the given media and provider.", + "The episodes are cached using a file cache.", + "The episode list is just a list of episodes with no video sources, it's what the client uses to display the episodes and subsequently fetch the sources.", + "The episode list might be nil or empty if nothing could be found, but the media will always be returned." + ], + "endpoint": "/api/v1/onlinestream/episode-list", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Dubbed", + "jsonName": "dubbed", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [] + } + ], + "returns": "onlinestream.EpisodeListResponse", + "returnGoType": "onlinestream.EpisodeListResponse", + "returnTypescriptType": "Onlinestream_EpisodeListResponse" + } + }, + { + "name": "HandleGetOnlineStreamEpisodeSource", + "trimmedName": "GetOnlineStreamEpisodeSource", + "comments": [ + "HandleGetOnlineStreamEpisodeSource", + "", + "\t@summary returns the video sources for the given media, episode number and provider.", + "\t@route /api/v1/onlinestream/episode-source [POST]", + "\t@returns onlinestream.EpisodeSource", + "" + ], + "filepath": "internal/handlers/onlinestream.go", + "filename": "onlinestream.go", + "api": { + "summary": "returns the video sources for the given media, episode number and provider.", + "descriptions": [], + "endpoint": "/api/v1/onlinestream/episode-source", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Dubbed", + "jsonName": "dubbed", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "onlinestream.EpisodeSource", + "returnGoType": "onlinestream.EpisodeSource", + "returnTypescriptType": "Onlinestream_EpisodeSource" + } + }, + { + "name": "HandleOnlineStreamEmptyCache", + "trimmedName": "OnlineStreamEmptyCache", + "comments": [ + "HandleOnlineStreamEmptyCache", + "", + "\t@summary empties the cache for the given media.", + "\t@route /api/v1/onlinestream/cache [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/onlinestream.go", + "filename": "onlinestream.go", + "api": { + "summary": "empties the cache for the given media.", + "descriptions": [], + "endpoint": "/api/v1/onlinestream/cache", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleOnlinestreamManualSearch", + "trimmedName": "OnlinestreamManualSearch", + "comments": [ + "HandleOnlinestreamManualSearch", + "", + "\t@summary returns search results for a manual search.", + "\t@desc Returns search results for a manual search.", + "\t@route /api/v1/onlinestream/search [POST]", + "\t@returns []hibikeonlinestream.SearchResult", + "" + ], + "filepath": "internal/handlers/onlinestream.go", + "filename": "onlinestream.go", + "api": { + "summary": "returns search results for a manual search.", + "descriptions": [ + "Returns search results for a manual search." + ], + "endpoint": "/api/v1/onlinestream/search", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Query", + "jsonName": "query", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Dubbed", + "jsonName": "dubbed", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "[]hibikeonlinestream.SearchResult", + "returnGoType": "hibikeonlinestream.SearchResult", + "returnTypescriptType": "Array\u003cHibikeOnlinestream_SearchResult\u003e" + } + }, + { + "name": "HandleOnlinestreamManualMapping", + "trimmedName": "OnlinestreamManualMapping", + "comments": [ + "HandleOnlinestreamManualMapping", + "", + "\t@summary manually maps an anime entry to an anime ID from the provider.", + "\t@desc This is used to manually map an anime entry to an anime ID from the provider.", + "\t@desc The client should re-fetch the chapter container after this.", + "\t@route /api/v1/onlinestream/manual-mapping [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/onlinestream.go", + "filename": "onlinestream.go", + "api": { + "summary": "manually maps an anime entry to an anime ID from the provider.", + "descriptions": [ + "This is used to manually map an anime entry to an anime ID from the provider.", + "The client should re-fetch the chapter container after this." + ], + "endpoint": "/api/v1/onlinestream/manual-mapping", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "AnimeId", + "jsonName": "animeId", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetOnlinestreamMapping", + "trimmedName": "GetOnlinestreamMapping", + "comments": [ + "HandleGetOnlinestreamMapping", + "", + "\t@summary returns the mapping for an anime entry.", + "\t@desc This is used to get the mapping for an anime entry.", + "\t@desc An empty string is returned if there's no manual mapping. If there is, the anime ID will be returned.", + "\t@route /api/v1/onlinestream/get-mapping [POST]", + "\t@returns onlinestream.MappingResponse", + "" + ], + "filepath": "internal/handlers/onlinestream.go", + "filename": "onlinestream.go", + "api": { + "summary": "returns the mapping for an anime entry.", + "descriptions": [ + "This is used to get the mapping for an anime entry.", + "An empty string is returned if there's no manual mapping. If there is, the anime ID will be returned." + ], + "endpoint": "/api/v1/onlinestream/get-mapping", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "onlinestream.MappingResponse", + "returnGoType": "onlinestream.MappingResponse", + "returnTypescriptType": "Onlinestream_MappingResponse" + } + }, + { + "name": "HandleRemoveOnlinestreamMapping", + "trimmedName": "RemoveOnlinestreamMapping", + "comments": [ + "HandleRemoveOnlinestreamMapping", + "", + "\t@summary removes the mapping for an anime entry.", + "\t@desc This is used to remove the mapping for an anime entry.", + "\t@desc The client should re-fetch the chapter container after this.", + "\t@route /api/v1/onlinestream/remove-mapping [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/onlinestream.go", + "filename": "onlinestream.go", + "api": { + "summary": "removes the mapping for an anime entry.", + "descriptions": [ + "This is used to remove the mapping for an anime entry.", + "The client should re-fetch the chapter container after this." + ], + "endpoint": "/api/v1/onlinestream/remove-mapping", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandlePlaybackPlayVideo", + "trimmedName": "PlaybackPlayVideo", + "comments": [ + "HandlePlaybackPlayVideo", + "", + "\t@summary plays the video with the given path using the default media player.", + "\t@desc This tells the Playback Manager to play the video using the default media player and start tracking progress.", + "\t@desc This returns 'true' if the video was successfully played.", + "\t@route /api/v1/playback-manager/play [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/playback_manager.go", + "filename": "playback_manager.go", + "api": { + "summary": "plays the video with the given path using the default media player.", + "descriptions": [ + "This tells the Playback Manager to play the video using the default media player and start tracking progress.", + "This returns 'true' if the video was successfully played." + ], + "endpoint": "/api/v1/playback-manager/play", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandlePlaybackPlayRandomVideo", + "trimmedName": "PlaybackPlayRandomVideo", + "comments": [ + "HandlePlaybackPlayRandomVideo", + "", + "\t@summary plays a random, unwatched video using the default media player.", + "\t@desc This tells the Playback Manager to play a random, unwatched video using the media player and start tracking progress.", + "\t@desc It respects the user's progress data and will prioritize \"current\" and \"repeating\" media if they are many of them.", + "\t@desc This returns 'true' if the video was successfully played.", + "\t@route /api/v1/playback-manager/play-random [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/playback_manager.go", + "filename": "playback_manager.go", + "api": { + "summary": "plays a random, unwatched video using the default media player.", + "descriptions": [ + "This tells the Playback Manager to play a random, unwatched video using the media player and start tracking progress.", + "It respects the user's progress data and will prioritize \"current\" and \"repeating\" media if they are many of them.", + "This returns 'true' if the video was successfully played." + ], + "endpoint": "/api/v1/playback-manager/play-random", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandlePlaybackSyncCurrentProgress", + "trimmedName": "PlaybackSyncCurrentProgress", + "comments": [ + "HandlePlaybackSyncCurrentProgress", + "", + "\t@summary updates the AniList progress of the currently playing media.", + "\t@desc This is called after 'Update progress' is clicked when watching a media.", + "\t@desc This route returns the media ID of the currently playing media, so the client can refetch the media entry data.", + "\t@route /api/v1/playback-manager/sync-current-progress [POST]", + "\t@returns int", + "" + ], + "filepath": "internal/handlers/playback_manager.go", + "filename": "playback_manager.go", + "api": { + "summary": "updates the AniList progress of the currently playing media.", + "descriptions": [ + "This is called after 'Update progress' is clicked when watching a media.", + "This route returns the media ID of the currently playing media, so the client can refetch the media entry data." + ], + "endpoint": "/api/v1/playback-manager/sync-current-progress", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "int", + "returnGoType": "int", + "returnTypescriptType": "number" + } + }, + { + "name": "HandlePlaybackPlayNextEpisode", + "trimmedName": "PlaybackPlayNextEpisode", + "comments": [ + "HandlePlaybackPlayNextEpisode", + "", + "\t@summary plays the next episode of the currently playing media.", + "\t@desc This will play the next episode of the currently playing media.", + "\t@desc This is non-blocking so the client should prevent multiple calls until the next status is received.", + "\t@route /api/v1/playback-manager/next-episode [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/playback_manager.go", + "filename": "playback_manager.go", + "api": { + "summary": "plays the next episode of the currently playing media.", + "descriptions": [ + "This will play the next episode of the currently playing media.", + "This is non-blocking so the client should prevent multiple calls until the next status is received." + ], + "endpoint": "/api/v1/playback-manager/next-episode", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandlePlaybackGetNextEpisode", + "trimmedName": "PlaybackGetNextEpisode", + "comments": [ + "HandlePlaybackGetNextEpisode", + "", + "\t@summary gets the next episode of the currently playing media.", + "\t@desc This is used by the client's autoplay feature", + "\t@route /api/v1/playback-manager/next-episode [GET]", + "\t@returns *anime.LocalFile", + "" + ], + "filepath": "internal/handlers/playback_manager.go", + "filename": "playback_manager.go", + "api": { + "summary": "gets the next episode of the currently playing media.", + "descriptions": [ + "This is used by the client's autoplay feature" + ], + "endpoint": "/api/v1/playback-manager/next-episode", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "*anime.LocalFile", + "returnGoType": "anime.LocalFile", + "returnTypescriptType": "Anime_LocalFile" + } + }, + { + "name": "HandlePlaybackAutoPlayNextEpisode", + "trimmedName": "PlaybackAutoPlayNextEpisode", + "comments": [ + "HandlePlaybackAutoPlayNextEpisode", + "", + "\t@summary plays the next episode of the currently playing media.", + "\t@desc This will play the next episode of the currently playing media.", + "\t@route /api/v1/playback-manager/autoplay-next-episode [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/playback_manager.go", + "filename": "playback_manager.go", + "api": { + "summary": "plays the next episode of the currently playing media.", + "descriptions": [ + "This will play the next episode of the currently playing media." + ], + "endpoint": "/api/v1/playback-manager/autoplay-next-episode", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandlePlaybackStartPlaylist", + "trimmedName": "PlaybackStartPlaylist", + "comments": [ + "HandlePlaybackStartPlaylist", + "", + "\t@summary starts playing a playlist.", + "\t@desc The client should refetch playlists.", + "\t@route /api/v1/playback-manager/start-playlist [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/playback_manager.go", + "filename": "playback_manager.go", + "api": { + "summary": "starts playing a playlist.", + "descriptions": [ + "The client should refetch playlists." + ], + "endpoint": "/api/v1/playback-manager/start-playlist", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "DbId", + "jsonName": "dbId", + "goType": "uint", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandlePlaybackCancelCurrentPlaylist", + "trimmedName": "PlaybackCancelCurrentPlaylist", + "comments": [ + "HandlePlaybackCancelCurrentPlaylist", + "", + "\t@summary ends the current playlist.", + "\t@desc This will stop the current playlist. This is non-blocking.", + "\t@route /api/v1/playback-manager/cancel-playlist [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/playback_manager.go", + "filename": "playback_manager.go", + "api": { + "summary": "ends the current playlist.", + "descriptions": [ + "This will stop the current playlist. This is non-blocking." + ], + "endpoint": "/api/v1/playback-manager/cancel-playlist", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandlePlaybackPlaylistNext", + "trimmedName": "PlaybackPlaylistNext", + "comments": [ + "HandlePlaybackPlaylistNext", + "", + "\t@summary moves to the next item in the current playlist.", + "\t@desc This is non-blocking so the client should prevent multiple calls until the next status is received.", + "\t@route /api/v1/playback-manager/playlist-next [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/playback_manager.go", + "filename": "playback_manager.go", + "api": { + "summary": "moves to the next item in the current playlist.", + "descriptions": [ + "This is non-blocking so the client should prevent multiple calls until the next status is received." + ], + "endpoint": "/api/v1/playback-manager/playlist-next", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandlePlaybackStartManualTracking", + "trimmedName": "PlaybackStartManualTracking", + "comments": [ + "HandlePlaybackStartManualTracking", + "", + "\t@summary starts manual tracking of a media.", + "\t@desc Used for tracking progress of media that is not played through any integrated media player.", + "\t@desc This should only be used for trackable episodes (episodes that count towards progress).", + "\t@desc This returns 'true' if the tracking was successfully started.", + "\t@route /api/v1/playback-manager/manual-tracking/start [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/playback_manager.go", + "filename": "playback_manager.go", + "api": { + "summary": "starts manual tracking of a media.", + "descriptions": [ + "Used for tracking progress of media that is not played through any integrated media player.", + "This should only be used for trackable episodes (episodes that count towards progress).", + "This returns 'true' if the tracking was successfully started." + ], + "endpoint": "/api/v1/playback-manager/manual-tracking/start", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "ClientId", + "jsonName": "clientId", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandlePlaybackCancelManualTracking", + "trimmedName": "PlaybackCancelManualTracking", + "comments": [ + "HandlePlaybackCancelManualTracking", + "", + "\t@summary cancels manual tracking of a media.", + "\t@desc This will stop the server from expecting progress updates for the media.", + "\t@route /api/v1/playback-manager/manual-tracking/cancel [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/playback_manager.go", + "filename": "playback_manager.go", + "api": { + "summary": "cancels manual tracking of a media.", + "descriptions": [ + "This will stop the server from expecting progress updates for the media." + ], + "endpoint": "/api/v1/playback-manager/manual-tracking/cancel", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleCreatePlaylist", + "trimmedName": "CreatePlaylist", + "comments": [ + "HandleCreatePlaylist", + "", + "\t@summary creates a new playlist.", + "\t@desc This will create a new playlist with the given name and local file paths.", + "\t@desc The response is ignored, the client should re-fetch the playlists after this.", + "\t@route /api/v1/playlist [POST]", + "\t@returns anime.Playlist", + "" + ], + "filepath": "internal/handlers/playlist.go", + "filename": "playlist.go", + "api": { + "summary": "creates a new playlist.", + "descriptions": [ + "This will create a new playlist with the given name and local file paths.", + "The response is ignored, the client should re-fetch the playlists after this." + ], + "endpoint": "/api/v1/playlist", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Paths", + "jsonName": "paths", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + } + ], + "returns": "anime.Playlist", + "returnGoType": "anime.Playlist", + "returnTypescriptType": "Anime_Playlist" + } + }, + { + "name": "HandleGetPlaylists", + "trimmedName": "GetPlaylists", + "comments": [ + "HandleGetPlaylists", + "", + "\t@summary returns all playlists.", + "\t@route /api/v1/playlists [GET]", + "\t@returns []anime.Playlist", + "" + ], + "filepath": "internal/handlers/playlist.go", + "filename": "playlist.go", + "api": { + "summary": "returns all playlists.", + "descriptions": [], + "endpoint": "/api/v1/playlists", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]anime.Playlist", + "returnGoType": "anime.Playlist", + "returnTypescriptType": "Array\u003cAnime_Playlist\u003e" + } + }, + { + "name": "HandleUpdatePlaylist", + "trimmedName": "UpdatePlaylist", + "comments": [ + "HandleUpdatePlaylist", + "", + "\t@summary updates a playlist.", + "\t@returns the updated playlist", + "\t@desc The response is ignored, the client should re-fetch the playlists after this.", + "\t@route /api/v1/playlist [PATCH]", + "\t@param id - int - true - \"The ID of the playlist to update.\"", + "\t@returns anime.Playlist", + "" + ], + "filepath": "internal/handlers/playlist.go", + "filename": "playlist.go", + "api": { + "summary": "updates a playlist.", + "descriptions": [ + "The response is ignored, the client should re-fetch the playlists after this." + ], + "endpoint": "/api/v1/playlist", + "methods": [ + "PATCH" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "The ID of the playlist to update." + ] + } + ], + "bodyFields": [ + { + "name": "DbId", + "jsonName": "dbId", + "goType": "uint", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Paths", + "jsonName": "paths", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + } + ], + "returns": "anime.Playlist", + "returnGoType": "anime.Playlist", + "returnTypescriptType": "Anime_Playlist" + } + }, + { + "name": "HandleDeletePlaylist", + "trimmedName": "DeletePlaylist", + "comments": [ + "HandleDeletePlaylist", + "", + "\t@summary deletes a playlist.", + "\t@route /api/v1/playlist [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/playlist.go", + "filename": "playlist.go", + "api": { + "summary": "deletes a playlist.", + "descriptions": [], + "endpoint": "/api/v1/playlist", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [ + { + "name": "DbId", + "jsonName": "dbId", + "goType": "uint", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetPlaylistEpisodes", + "trimmedName": "GetPlaylistEpisodes", + "comments": [ + "HandleGetPlaylistEpisodes", + "", + "\t@summary returns all the local files of a playlist media entry that have not been watched.", + "\t@route /api/v1/playlist/episodes/{id}/{progress} [GET]", + "\t@param id - int - true - \"The ID of the media entry.\"", + "\t@param progress - int - true - \"The progress of the media entry.\"", + "\t@returns []anime.LocalFile", + "" + ], + "filepath": "internal/handlers/playlist.go", + "filename": "playlist.go", + "api": { + "summary": "returns all the local files of a playlist media entry that have not been watched.", + "descriptions": [], + "endpoint": "/api/v1/playlist/episodes/{id}/{progress}", + "methods": [ + "GET" + ], + "params": [ + { + "name": "id", + "jsonName": "id", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "The ID of the media entry." + ] + }, + { + "name": "progress", + "jsonName": "progress", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [ + "The progress of the media entry." + ] + } + ], + "bodyFields": [], + "returns": "[]anime.LocalFile", + "returnGoType": "anime.LocalFile", + "returnTypescriptType": "Array\u003cAnime_LocalFile\u003e" + } + }, + { + "name": "HandleInstallLatestUpdate", + "trimmedName": "InstallLatestUpdate", + "comments": [ + "HandleInstallLatestUpdate", + "", + "\t@summary installs the latest update.", + "\t@desc This will install the latest update and launch the new version.", + "\t@route /api/v1/install-update [POST]", + "\t@returns handlers.Status", + "" + ], + "filepath": "internal/handlers/releases.go", + "filename": "releases.go", + "api": { + "summary": "installs the latest update.", + "descriptions": [ + "This will install the latest update and launch the new version." + ], + "endpoint": "/api/v1/install-update", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "FallbackDestination", + "jsonName": "fallback_destination", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "handlers.Status", + "returnGoType": "handlers.Status", + "returnTypescriptType": "Status" + } + }, + { + "name": "HandleGetLatestUpdate", + "trimmedName": "GetLatestUpdate", + "comments": [ + "HandleGetLatestUpdate", + "", + "\t@summary returns the latest update.", + "\t@desc This will return the latest update.", + "\t@desc If an error occurs, it will return an empty update.", + "\t@route /api/v1/latest-update [GET]", + "\t@returns updater.Update", + "" + ], + "filepath": "internal/handlers/releases.go", + "filename": "releases.go", + "api": { + "summary": "returns the latest update.", + "descriptions": [ + "This will return the latest update.", + "If an error occurs, it will return an empty update." + ], + "endpoint": "/api/v1/latest-update", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "updater.Update", + "returnGoType": "updater.Update", + "returnTypescriptType": "Updater_Update" + } + }, + { + "name": "HandleGetChangelog", + "trimmedName": "GetChangelog", + "comments": [ + "HandleGetChangelog", + "", + "\t@summary returns the changelog for versions greater than or equal to the given version.", + "\t@route /api/v1/changelog [GET]", + "\t@param before query string true \"The version to get the changelog for.\"", + "\t@returns string", + "" + ], + "filepath": "internal/handlers/releases.go", + "filename": "releases.go", + "api": { + "summary": "returns the changelog for versions greater than or equal to the given version.", + "descriptions": [], + "endpoint": "/api/v1/changelog", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "string", + "returnGoType": "string", + "returnTypescriptType": "string" + } + }, + { + "name": "HandleSaveIssueReport", + "trimmedName": "SaveIssueReport", + "comments": [ + "HandleSaveIssueReport", + "", + "\t@summary saves the issue report in memory.", + "\t@route /api/v1/report/issue [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/report.go", + "filename": "report.go", + "api": { + "summary": "saves the issue report in memory.", + "descriptions": [], + "endpoint": "/api/v1/report/issue", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "ClickLogs", + "jsonName": "clickLogs", + "goType": "[]report.ClickLog", + "usedStructType": "report.ClickLog", + "typescriptType": "Array\u003cReport_ClickLog\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "NetworkLogs", + "jsonName": "networkLogs", + "goType": "[]report.NetworkLog", + "usedStructType": "report.NetworkLog", + "typescriptType": "Array\u003cReport_NetworkLog\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "ReactQueryLogs", + "jsonName": "reactQueryLogs", + "goType": "[]report.ReactQueryLog", + "usedStructType": "report.ReactQueryLog", + "typescriptType": "Array\u003cReport_ReactQueryLog\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "ConsoleLogs", + "jsonName": "consoleLogs", + "goType": "[]report.ConsoleLog", + "usedStructType": "report.ConsoleLog", + "typescriptType": "Array\u003cReport_ConsoleLog\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "IsAnimeLibraryIssue", + "jsonName": "isAnimeLibraryIssue", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleDownloadIssueReport", + "trimmedName": "DownloadIssueReport", + "comments": [ + "HandleDownloadIssueReport", + "", + "\t@summary generates and downloads the issue report file.", + "\t@route /api/v1/report/issue/download [GET]", + "\t@returns report.IssueReport", + "" + ], + "filepath": "internal/handlers/report.go", + "filename": "report.go", + "api": { + "summary": "generates and downloads the issue report file.", + "descriptions": [], + "endpoint": "/api/v1/report/issue/download", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "report.IssueReport", + "returnGoType": "report.IssueReport", + "returnTypescriptType": "Report_IssueReport" + } + }, + { + "name": "HandleScanLocalFiles", + "trimmedName": "ScanLocalFiles", + "comments": [ + "HandleScanLocalFiles", + "", + "\t@summary scans the user's library.", + "\t@desc This will scan the user's library.", + "\t@desc The response is ignored, the client should re-fetch the library after this.", + "\t@route /api/v1/library/scan [POST]", + "\t@returns []anime.LocalFile", + "" + ], + "filepath": "internal/handlers/scan.go", + "filename": "scan.go", + "api": { + "summary": "scans the user's library.", + "descriptions": [ + "This will scan the user's library.", + "The response is ignored, the client should re-fetch the library after this." + ], + "endpoint": "/api/v1/library/scan", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Enhanced", + "jsonName": "enhanced", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "SkipLockedFiles", + "jsonName": "skipLockedFiles", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "SkipIgnoredFiles", + "jsonName": "skipIgnoredFiles", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "[]anime.LocalFile", + "returnGoType": "anime.LocalFile", + "returnTypescriptType": "Array\u003cAnime_LocalFile\u003e" + } + }, + { + "name": "HandleGetScanSummaries", + "trimmedName": "GetScanSummaries", + "comments": [ + "HandleGetScanSummaries", + "", + "\t@summary returns the latest scan summaries.", + "\t@route /api/v1/library/scan-summaries [GET]", + "\t@returns []summary.ScanSummaryItem", + "" + ], + "filepath": "internal/handlers/scan_summary.go", + "filename": "scan_summary.go", + "api": { + "summary": "returns the latest scan summaries.", + "descriptions": [], + "endpoint": "/api/v1/library/scan-summaries", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]summary.ScanSummaryItem", + "returnGoType": "summary.ScanSummaryItem", + "returnTypescriptType": "Array\u003cSummary_ScanSummaryItem\u003e" + } + }, + { + "name": "HandleGetSettings", + "trimmedName": "GetSettings", + "comments": [ + "HandleGetSettings", + "", + "\t@summary returns the app settings.", + "\t@route /api/v1/settings [GET]", + "\t@returns models.Settings", + "" + ], + "filepath": "internal/handlers/settings.go", + "filename": "settings.go", + "api": { + "summary": "returns the app settings.", + "descriptions": [], + "endpoint": "/api/v1/settings", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "models.Settings", + "returnGoType": "models.Settings", + "returnTypescriptType": "Models_Settings" + } + }, + { + "name": "HandleGettingStarted", + "trimmedName": "GettingStarted", + "comments": [ + "HandleGettingStarted", + "", + "\t@summary updates the app settings.", + "\t@desc This will update the app settings.", + "\t@desc The client should re-fetch the server status after this.", + "\t@route /api/v1/start [POST]", + "\t@returns handlers.Status", + "" + ], + "filepath": "internal/handlers/settings.go", + "filename": "settings.go", + "api": { + "summary": "updates the app settings.", + "descriptions": [ + "This will update the app settings.", + "The client should re-fetch the server status after this." + ], + "endpoint": "/api/v1/start", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Library", + "jsonName": "library", + "goType": "models.LibrarySettings", + "usedStructType": "models.LibrarySettings", + "typescriptType": "Models_LibrarySettings", + "required": true, + "descriptions": [] + }, + { + "name": "MediaPlayer", + "jsonName": "mediaPlayer", + "goType": "models.MediaPlayerSettings", + "usedStructType": "models.MediaPlayerSettings", + "typescriptType": "Models_MediaPlayerSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "models.TorrentSettings", + "usedStructType": "models.TorrentSettings", + "typescriptType": "Models_TorrentSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Anilist", + "jsonName": "anilist", + "goType": "models.AnilistSettings", + "usedStructType": "models.AnilistSettings", + "typescriptType": "Models_AnilistSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Discord", + "jsonName": "discord", + "goType": "models.DiscordSettings", + "usedStructType": "models.DiscordSettings", + "typescriptType": "Models_DiscordSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Manga", + "jsonName": "manga", + "goType": "models.MangaSettings", + "usedStructType": "models.MangaSettings", + "typescriptType": "Models_MangaSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Notifications", + "jsonName": "notifications", + "goType": "models.NotificationSettings", + "usedStructType": "models.NotificationSettings", + "typescriptType": "Models_NotificationSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Nakama", + "jsonName": "nakama", + "goType": "models.NakamaSettings", + "usedStructType": "models.NakamaSettings", + "typescriptType": "Models_NakamaSettings", + "required": true, + "descriptions": [] + }, + { + "name": "EnableTranscode", + "jsonName": "enableTranscode", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "EnableTorrentStreaming", + "jsonName": "enableTorrentStreaming", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "DebridProvider", + "jsonName": "debridProvider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "DebridApiKey", + "jsonName": "debridApiKey", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "handlers.Status", + "returnGoType": "handlers.Status", + "returnTypescriptType": "Status" + } + }, + { + "name": "HandleSaveSettings", + "trimmedName": "SaveSettings", + "comments": [ + "HandleSaveSettings", + "", + "\t@summary updates the app settings.", + "\t@desc This will update the app settings.", + "\t@desc The client should re-fetch the server status after this.", + "\t@route /api/v1/settings [PATCH]", + "\t@returns handlers.Status", + "" + ], + "filepath": "internal/handlers/settings.go", + "filename": "settings.go", + "api": { + "summary": "updates the app settings.", + "descriptions": [ + "This will update the app settings.", + "The client should re-fetch the server status after this." + ], + "endpoint": "/api/v1/settings", + "methods": [ + "PATCH" + ], + "params": [], + "bodyFields": [ + { + "name": "Library", + "jsonName": "library", + "goType": "models.LibrarySettings", + "usedStructType": "models.LibrarySettings", + "typescriptType": "Models_LibrarySettings", + "required": true, + "descriptions": [] + }, + { + "name": "MediaPlayer", + "jsonName": "mediaPlayer", + "goType": "models.MediaPlayerSettings", + "usedStructType": "models.MediaPlayerSettings", + "typescriptType": "Models_MediaPlayerSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "models.TorrentSettings", + "usedStructType": "models.TorrentSettings", + "typescriptType": "Models_TorrentSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Anilist", + "jsonName": "anilist", + "goType": "models.AnilistSettings", + "usedStructType": "models.AnilistSettings", + "typescriptType": "Models_AnilistSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Discord", + "jsonName": "discord", + "goType": "models.DiscordSettings", + "usedStructType": "models.DiscordSettings", + "typescriptType": "Models_DiscordSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Manga", + "jsonName": "manga", + "goType": "models.MangaSettings", + "usedStructType": "models.MangaSettings", + "typescriptType": "Models_MangaSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Notifications", + "jsonName": "notifications", + "goType": "models.NotificationSettings", + "usedStructType": "models.NotificationSettings", + "typescriptType": "Models_NotificationSettings", + "required": true, + "descriptions": [] + }, + { + "name": "Nakama", + "jsonName": "nakama", + "goType": "models.NakamaSettings", + "usedStructType": "models.NakamaSettings", + "typescriptType": "Models_NakamaSettings", + "required": true, + "descriptions": [] + } + ], + "returns": "handlers.Status", + "returnGoType": "handlers.Status", + "returnTypescriptType": "Status" + } + }, + { + "name": "HandleSaveAutoDownloaderSettings", + "trimmedName": "SaveAutoDownloaderSettings", + "comments": [ + "HandleSaveAutoDownloaderSettings", + "", + "\t@summary updates the auto-downloader settings.", + "\t@route /api/v1/settings/auto-downloader [PATCH]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/settings.go", + "filename": "settings.go", + "api": { + "summary": "updates the auto-downloader settings.", + "descriptions": [], + "endpoint": "/api/v1/settings/auto-downloader", + "methods": [ + "PATCH" + ], + "params": [], + "bodyFields": [ + { + "name": "Interval", + "jsonName": "interval", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "DownloadAutomatically", + "jsonName": "downloadAutomatically", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "EnableEnhancedQueries", + "jsonName": "enableEnhancedQueries", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "EnableSeasonCheck", + "jsonName": "enableSeasonCheck", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "UseDebrid", + "jsonName": "useDebrid", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "NewStatus", + "trimmedName": "NewStatus", + "comments": [ + "NewStatus returns a new Status struct.", + "It uses the RouteCtx to get the App instance containing the Database instance.", + "" + ], + "filepath": "internal/handlers/status.go", + "filename": "status.go", + "api": { + "summary": "", + "descriptions": [], + "endpoint": "", + "methods": null, + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetStatus", + "trimmedName": "GetStatus", + "comments": [ + "HandleGetStatus", + "", + "\t@summary returns the server status.", + "\t@desc The server status includes app info, auth info and settings.", + "\t@desc The client uses this to set the UI.", + "\t@desc It is called on every page load to get the most up-to-date data.", + "\t@desc It should be called right after updating the settings.", + "\t@route /api/v1/status [GET]", + "\t@returns handlers.Status", + "" + ], + "filepath": "internal/handlers/status.go", + "filename": "status.go", + "api": { + "summary": "returns the server status.", + "descriptions": [ + "The server status includes app info, auth info and settings.", + "The client uses this to set the UI.", + "It is called on every page load to get the most up-to-date data.", + "It should be called right after updating the settings." + ], + "endpoint": "/api/v1/status", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "handlers.Status", + "returnGoType": "handlers.Status", + "returnTypescriptType": "Status" + } + }, + { + "name": "HandleGetLogFilenames", + "trimmedName": "GetLogFilenames", + "comments": [ + "HandleGetLogFilenames", + "", + "\t@summary returns the log filenames.", + "\t@desc This returns the filenames of all log files in the logs directory.", + "\t@route /api/v1/logs/filenames [GET]", + "\t@returns []string", + "" + ], + "filepath": "internal/handlers/status.go", + "filename": "status.go", + "api": { + "summary": "returns the log filenames.", + "descriptions": [ + "This returns the filenames of all log files in the logs directory." + ], + "endpoint": "/api/v1/logs/filenames", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]string", + "returnGoType": "string", + "returnTypescriptType": "Array\u003cstring\u003e" + } + }, + { + "name": "HandleDeleteLogs", + "trimmedName": "DeleteLogs", + "comments": [ + "HandleDeleteLogs", + "", + "\t@summary deletes certain log files.", + "\t@desc This deletes the log files with the given filenames.", + "\t@route /api/v1/logs [DELETE]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/status.go", + "filename": "status.go", + "api": { + "summary": "deletes certain log files.", + "descriptions": [ + "This deletes the log files with the given filenames." + ], + "endpoint": "/api/v1/logs", + "methods": [ + "DELETE" + ], + "params": [], + "bodyFields": [ + { + "name": "Filenames", + "jsonName": "filenames", + "goType": "[]string", + "usedStructType": "", + "typescriptType": "Array\u003cstring\u003e", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetLatestLogContent", + "trimmedName": "GetLatestLogContent", + "comments": [ + "HandleGetLatestLogContent", + "", + "\t@summary returns the content of the latest server log file.", + "\t@desc This returns the content of the most recent seanime- log file after flushing logs.", + "\t@route /api/v1/logs/latest [GET]", + "\t@returns string", + "" + ], + "filepath": "internal/handlers/status.go", + "filename": "status.go", + "api": { + "summary": "returns the content of the latest server log file.", + "descriptions": [ + "This returns the content of the most recent seanime- log file after flushing logs." + ], + "endpoint": "/api/v1/logs/latest", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "string", + "returnGoType": "string", + "returnTypescriptType": "string" + } + }, + { + "name": "HandleGetAnnouncements", + "trimmedName": "GetAnnouncements", + "comments": [ + "HandleGetAnnouncements", + "", + "\t@summary returns the server announcements.", + "\t@desc This returns the announcements for the server.", + "\t@route /api/v1/announcements [POST]", + "\t@returns []updater.Announcement", + "" + ], + "filepath": "internal/handlers/status.go", + "filename": "status.go", + "api": { + "summary": "returns the server announcements.", + "descriptions": [ + "This returns the announcements for the server." + ], + "endpoint": "/api/v1/announcements", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Platform", + "jsonName": "platform", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "[]updater.Announcement", + "returnGoType": "updater.Announcement", + "returnTypescriptType": "Array\u003cUpdater_Announcement\u003e" + } + }, + { + "name": "HandleGetMemoryStats", + "trimmedName": "GetMemoryStats", + "comments": [ + "HandleGetMemoryStats", + "", + "\t@summary returns current memory statistics.", + "\t@desc This returns real-time memory usage statistics from the Go runtime.", + "\t@route /api/v1/memory/stats [GET]", + "\t@returns handlers.MemoryStatsResponse", + "" + ], + "filepath": "internal/handlers/status.go", + "filename": "status.go", + "api": { + "summary": "returns current memory statistics.", + "descriptions": [ + "This returns real-time memory usage statistics from the Go runtime." + ], + "endpoint": "/api/v1/memory/stats", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "handlers.MemoryStatsResponse", + "returnGoType": "handlers.MemoryStatsResponse", + "returnTypescriptType": "MemoryStatsResponse" + } + }, + { + "name": "HandleGetMemoryProfile", + "trimmedName": "GetMemoryProfile", + "comments": [ + "HandleGetMemoryProfile", + "", + "\t@summary generates and returns a memory profile.", + "\t@desc This generates a memory profile that can be analyzed with go tool pprof.", + "\t@desc Query parameters: heap=true for heap profile, allocs=true for alloc profile.", + "\t@route /api/v1/memory/profile [GET]", + "\t@returns nil", + "" + ], + "filepath": "internal/handlers/status.go", + "filename": "status.go", + "api": { + "summary": "generates and returns a memory profile.", + "descriptions": [ + "This generates a memory profile that can be analyzed with go tool pprof.", + "Query parameters: heap=true for heap profile, allocs=true for alloc profile." + ], + "endpoint": "/api/v1/memory/profile", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "nil", + "returnGoType": "nil", + "returnTypescriptType": "null" + } + }, + { + "name": "HandleGetGoRoutineProfile", + "trimmedName": "GetGoRoutineProfile", + "comments": [ + "HandleGetGoRoutineProfile", + "", + "\t@summary generates and returns a goroutine profile.", + "\t@desc This generates a goroutine profile showing all running goroutines and their stack traces.", + "\t@route /api/v1/memory/goroutine [GET]", + "\t@returns nil", + "" + ], + "filepath": "internal/handlers/status.go", + "filename": "status.go", + "api": { + "summary": "generates and returns a goroutine profile.", + "descriptions": [ + "This generates a goroutine profile showing all running goroutines and their stack traces." + ], + "endpoint": "/api/v1/memory/goroutine", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "nil", + "returnGoType": "nil", + "returnTypescriptType": "null" + } + }, + { + "name": "HandleGetCPUProfile", + "trimmedName": "GetCPUProfile", + "comments": [ + "HandleGetCPUProfile", + "", + "\t@summary generates and returns a CPU profile.", + "\t@desc This generates a CPU profile for the specified duration (default 30 seconds).", + "\t@desc Query parameter: duration=30 for duration in seconds.", + "\t@route /api/v1/memory/cpu [GET]", + "\t@returns nil", + "" + ], + "filepath": "internal/handlers/status.go", + "filename": "status.go", + "api": { + "summary": "generates and returns a CPU profile.", + "descriptions": [ + "This generates a CPU profile for the specified duration (default 30 seconds).", + "Query parameter: duration=30 for duration in seconds." + ], + "endpoint": "/api/v1/memory/cpu", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "nil", + "returnGoType": "nil", + "returnTypescriptType": "null" + } + }, + { + "name": "HandleForceGC", + "trimmedName": "ForceGC", + "comments": [ + "HandleForceGC", + "", + "\t@summary forces garbage collection and returns memory stats.", + "\t@desc This forces a garbage collection cycle and returns the updated memory statistics.", + "\t@route /api/v1/memory/gc [POST]", + "\t@returns handlers.MemoryStatsResponse", + "" + ], + "filepath": "internal/handlers/status.go", + "filename": "status.go", + "api": { + "summary": "forces garbage collection and returns memory stats.", + "descriptions": [ + "This forces a garbage collection cycle and returns the updated memory statistics." + ], + "endpoint": "/api/v1/memory/gc", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "handlers.MemoryStatsResponse", + "returnGoType": "handlers.MemoryStatsResponse", + "returnTypescriptType": "MemoryStatsResponse" + } + }, + { + "name": "HandleGetTheme", + "trimmedName": "GetTheme", + "comments": [ + "HandleGetTheme", + "", + "\t@summary returns the theme settings.", + "\t@route /api/v1/theme [GET]", + "\t@returns models.Theme", + "" + ], + "filepath": "internal/handlers/theme.go", + "filename": "theme.go", + "api": { + "summary": "returns the theme settings.", + "descriptions": [], + "endpoint": "/api/v1/theme", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "models.Theme", + "returnGoType": "models.Theme", + "returnTypescriptType": "Models_Theme" + } + }, + { + "name": "HandleUpdateTheme", + "trimmedName": "UpdateTheme", + "comments": [ + "HandleUpdateTheme", + "", + "\t@summary updates the theme settings.", + "\t@desc The server status should be re-fetched after this on the client.", + "\t@route /api/v1/theme [PATCH]", + "\t@returns models.Theme", + "" + ], + "filepath": "internal/handlers/theme.go", + "filename": "theme.go", + "api": { + "summary": "updates the theme settings.", + "descriptions": [ + "The server status should be re-fetched after this on the client." + ], + "endpoint": "/api/v1/theme", + "methods": [ + "PATCH" + ], + "params": [], + "bodyFields": [ + { + "name": "Theme", + "jsonName": "theme", + "goType": "models.Theme", + "usedStructType": "models.Theme", + "typescriptType": "Models_Theme", + "required": true, + "descriptions": [] + } + ], + "returns": "models.Theme", + "returnGoType": "models.Theme", + "returnTypescriptType": "Models_Theme" + } + }, + { + "name": "HandleGetActiveTorrentList", + "trimmedName": "GetActiveTorrentList", + "comments": [ + "HandleGetActiveTorrentList", + "", + "\t@summary returns all active torrents.", + "\t@desc This handler is used by the client to display the active torrents.", + "", + "\t@route /api/v1/torrent-client/list [GET]", + "\t@returns []torrent_client.Torrent", + "" + ], + "filepath": "internal/handlers/torrent_client.go", + "filename": "torrent_client.go", + "api": { + "summary": "returns all active torrents.", + "descriptions": [ + "This handler is used by the client to display the active torrents." + ], + "endpoint": "/api/v1/torrent-client/list", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "[]torrent_client.Torrent", + "returnGoType": "torrent_client.Torrent", + "returnTypescriptType": "Array\u003cTorrentClient_Torrent\u003e" + } + }, + { + "name": "HandleTorrentClientAction", + "trimmedName": "TorrentClientAction", + "comments": [ + "HandleTorrentClientAction", + "", + "\t@summary performs an action on a torrent.", + "\t@desc This handler is used to pause, resume or remove a torrent.", + "\t@route /api/v1/torrent-client/action [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/torrent_client.go", + "filename": "torrent_client.go", + "api": { + "summary": "performs an action on a torrent.", + "descriptions": [ + "This handler is used to pause, resume or remove a torrent." + ], + "endpoint": "/api/v1/torrent-client/action", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Action", + "jsonName": "action", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "Dir", + "jsonName": "dir", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleTorrentClientDownload", + "trimmedName": "TorrentClientDownload", + "comments": [ + "HandleTorrentClientDownload", + "", + "\t@summary adds torrents to the torrent client.", + "\t@desc It fetches the magnets from the provided URLs and adds them to the torrent client.", + "\t@desc If smart select is enabled, it will try to select the best torrent based on the missing episodes.", + "\t@route /api/v1/torrent-client/download [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/torrent_client.go", + "filename": "torrent_client.go", + "api": { + "summary": "adds torrents to the torrent client.", + "descriptions": [ + "It fetches the magnets from the provided URLs and adds them to the torrent client.", + "If smart select is enabled, it will try to select the best torrent based on the missing episodes." + ], + "endpoint": "/api/v1/torrent-client/download", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Torrents", + "jsonName": "torrents", + "goType": "[]hibiketorrent.AnimeTorrent", + "usedStructType": "hibiketorrent.AnimeTorrent", + "typescriptType": "Array\u003cHibikeTorrent_AnimeTorrent\u003e", + "required": true, + "descriptions": [] + }, + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "SmartSelect", + "jsonName": "smartSelect", + "goType": "__STRUCT__", + "inlineStructType": "struct{\nEnabled bool `json:\"enabled\"`\nMissingEpisodeNumbers []int `json:\"missingEpisodeNumbers\"`}", + "usedStructType": "", + "typescriptType": "{ enabled: boolean; missingEpisodeNumbers: Array\u003cnumber\u003e; }", + "required": true, + "descriptions": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "usedStructType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "required": false, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleTorrentClientAddMagnetFromRule", + "trimmedName": "TorrentClientAddMagnetFromRule", + "comments": [ + "HandleTorrentClientAddMagnetFromRule", + "", + "\t@summary adds magnets to the torrent client based on the AutoDownloader item.", + "\t@desc This is used to download torrents that were queued by the AutoDownloader.", + "\t@desc The item will be removed from the queue if the magnet was added successfully.", + "\t@desc The AutoDownloader items should be re-fetched after this.", + "\t@route /api/v1/torrent-client/rule-magnet [POST]", + "\t@returns bool", + "" + ], + "filepath": "internal/handlers/torrent_client.go", + "filename": "torrent_client.go", + "api": { + "summary": "adds magnets to the torrent client based on the AutoDownloader item.", + "descriptions": [ + "This is used to download torrents that were queued by the AutoDownloader.", + "The item will be removed from the queue if the magnet was added successfully.", + "The AutoDownloader items should be re-fetched after this." + ], + "endpoint": "/api/v1/torrent-client/rule-magnet", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MagnetUrl", + "jsonName": "magnetUrl", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "RuleId", + "jsonName": "ruleId", + "goType": "uint", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "QueuedItemId", + "jsonName": "queuedItemId", + "goType": "uint", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleSearchTorrent", + "trimmedName": "SearchTorrent", + "comments": [ + "HandleSearchTorrent", + "", + "\t@summary searches torrents and returns a list of torrents and their previews.", + "\t@desc This will search for torrents and return a list of torrents with previews.", + "\t@desc If smart search is enabled, it will filter the torrents based on search parameters.", + "\t@route /api/v1/torrent/search [POST]", + "\t@returns torrent.SearchData", + "" + ], + "filepath": "internal/handlers/torrent_search.go", + "filename": "torrent_search.go", + "api": { + "summary": "searches torrents and returns a list of torrents and their previews.", + "descriptions": [ + "This will search for torrents and return a list of torrents with previews.", + "If smart search is enabled, it will filter the torrents based on search parameters." + ], + "endpoint": "/api/v1/torrent/search", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [ + "\"smart\" or \"simple\"", + "", + "\"smart\" or \"simple\"" + ] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [] + }, + { + "name": "Query", + "jsonName": "query", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Batch", + "jsonName": "batch", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": false, + "descriptions": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "usedStructType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "required": false, + "descriptions": [] + }, + { + "name": "AbsoluteOffset", + "jsonName": "absoluteOffset", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "Resolution", + "jsonName": "resolution", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": false, + "descriptions": [] + }, + { + "name": "BestRelease", + "jsonName": "bestRelease", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": false, + "descriptions": [] + } + ], + "returns": "torrent.SearchData", + "returnGoType": "torrent.SearchData", + "returnTypescriptType": "Torrent_SearchData" + } + }, + { + "name": "HandleGetTorrentstreamSettings", + "trimmedName": "GetTorrentstreamSettings", + "comments": [ + "HandleGetTorrentstreamSettings", + "", + "\t@summary get torrentstream settings.", + "\t@desc This returns the torrentstream settings.", + "\t@returns models.TorrentstreamSettings", + "\t@route /api/v1/torrentstream/settings [GET]", + "" + ], + "filepath": "internal/handlers/torrentstream.go", + "filename": "torrentstream.go", + "api": { + "summary": "get torrentstream settings.", + "descriptions": [ + "This returns the torrentstream settings." + ], + "endpoint": "/api/v1/torrentstream/settings", + "methods": [ + "GET" + ], + "params": [], + "bodyFields": [], + "returns": "models.TorrentstreamSettings", + "returnGoType": "models.TorrentstreamSettings", + "returnTypescriptType": "Models_TorrentstreamSettings" + } + }, + { + "name": "HandleSaveTorrentstreamSettings", + "trimmedName": "SaveTorrentstreamSettings", + "comments": [ + "HandleSaveTorrentstreamSettings", + "", + "\t@summary save torrentstream settings.", + "\t@desc This saves the torrentstream settings.", + "\t@desc The client should refetch the server status.", + "\t@returns models.TorrentstreamSettings", + "\t@route /api/v1/torrentstream/settings [PATCH]", + "" + ], + "filepath": "internal/handlers/torrentstream.go", + "filename": "torrentstream.go", + "api": { + "summary": "save torrentstream settings.", + "descriptions": [ + "This saves the torrentstream settings.", + "The client should refetch the server status." + ], + "endpoint": "/api/v1/torrentstream/settings", + "methods": [ + "PATCH" + ], + "params": [], + "bodyFields": [ + { + "name": "Settings", + "jsonName": "settings", + "goType": "models.TorrentstreamSettings", + "usedStructType": "models.TorrentstreamSettings", + "typescriptType": "Models_TorrentstreamSettings", + "required": true, + "descriptions": [] + } + ], + "returns": "models.TorrentstreamSettings", + "returnGoType": "models.TorrentstreamSettings", + "returnTypescriptType": "Models_TorrentstreamSettings" + } + }, + { + "name": "HandleGetTorrentstreamTorrentFilePreviews", + "trimmedName": "GetTorrentstreamTorrentFilePreviews", + "comments": [ + "HandleGetTorrentstreamTorrentFilePreviews", + "", + "\t@summary get list of torrent files from a batch", + "\t@desc This returns a list of file previews from the torrent", + "\t@returns []torrentstream.FilePreview", + "\t@route /api/v1/torrentstream/torrent-file-previews [POST]", + "" + ], + "filepath": "internal/handlers/torrentstream.go", + "filename": "torrentstream.go", + "api": { + "summary": "get list of torrent files from a batch", + "descriptions": [ + "This returns a list of file previews from the torrent" + ], + "endpoint": "/api/v1/torrentstream/torrent-file-previews", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "hibiketorrent.AnimeTorrent", + "usedStructType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "required": false, + "descriptions": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "usedStructType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "required": false, + "descriptions": [] + } + ], + "returns": "[]torrentstream.FilePreview", + "returnGoType": "torrentstream.FilePreview", + "returnTypescriptType": "Array\u003cTorrentstream_FilePreview\u003e" + } + }, + { + "name": "HandleTorrentstreamStartStream", + "trimmedName": "TorrentstreamStartStream", + "comments": [ + "HandleTorrentstreamStartStream", + "", + "\t@summary starts a torrent stream.", + "\t@desc This starts the entire streaming process.", + "\t@returns bool", + "\t@route /api/v1/torrentstream/start [POST]", + "" + ], + "filepath": "internal/handlers/torrentstream.go", + "filename": "torrentstream.go", + "api": { + "summary": "starts a torrent stream.", + "descriptions": [ + "This starts the entire streaming process." + ], + "endpoint": "/api/v1/torrentstream/start", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + }, + { + "name": "AniDBEpisode", + "jsonName": "aniDBEpisode", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + }, + { + "name": "AutoSelect", + "jsonName": "autoSelect", + "goType": "bool", + "usedStructType": "", + "typescriptType": "boolean", + "required": true, + "descriptions": [] + }, + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "hibiketorrent.AnimeTorrent", + "usedStructType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "required": false, + "descriptions": [] + }, + { + "name": "FileIndex", + "jsonName": "fileIndex", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": false, + "descriptions": [] + }, + { + "name": "PlaybackType", + "jsonName": "playbackType", + "goType": "torrentstream.PlaybackType", + "usedStructType": "torrentstream.PlaybackType", + "typescriptType": "Torrentstream_PlaybackType", + "required": true, + "descriptions": [] + }, + { + "name": "ClientId", + "jsonName": "clientId", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] + } + ], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleTorrentstreamStopStream", + "trimmedName": "TorrentstreamStopStream", + "comments": [ + "HandleTorrentstreamStopStream", + "", + "\t@summary stop a torrent stream.", + "\t@desc This stops the entire streaming process and drops the torrent if it's below a threshold.", + "\t@desc This is made to be used while the stream is running.", + "\t@returns bool", + "\t@route /api/v1/torrentstream/stop [POST]", + "" + ], + "filepath": "internal/handlers/torrentstream.go", + "filename": "torrentstream.go", + "api": { + "summary": "stop a torrent stream.", + "descriptions": [ + "This stops the entire streaming process and drops the torrent if it's below a threshold.", + "This is made to be used while the stream is running." + ], + "endpoint": "/api/v1/torrentstream/stop", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleTorrentstreamDropTorrent", + "trimmedName": "TorrentstreamDropTorrent", + "comments": [ + "HandleTorrentstreamDropTorrent", + "", + "\t@summary drops a torrent stream.", + "\t@desc This stops the entire streaming process and drops the torrent completely.", + "\t@desc This is made to be used to force drop a torrent.", + "\t@returns bool", + "\t@route /api/v1/torrentstream/drop [POST]", + "" + ], + "filepath": "internal/handlers/torrentstream.go", + "filename": "torrentstream.go", + "api": { + "summary": "drops a torrent stream.", + "descriptions": [ + "This stops the entire streaming process and drops the torrent completely.", + "This is made to be used to force drop a torrent." + ], + "endpoint": "/api/v1/torrentstream/drop", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "HandleGetTorrentstreamBatchHistory", + "trimmedName": "GetTorrentstreamBatchHistory", + "comments": [ + "HandleGetTorrentstreamBatchHistory", + "", + "\t@summary returns the most recent batch selected.", + "\t@desc This returns the most recent batch selected.", + "\t@returns torrentstream.BatchHistoryResponse", + "\t@route /api/v1/torrentstream/batch-history [POST]", + "" + ], + "filepath": "internal/handlers/torrentstream.go", + "filename": "torrentstream.go", + "api": { + "summary": "returns the most recent batch selected.", + "descriptions": [ + "This returns the most recent batch selected." + ], + "endpoint": "/api/v1/torrentstream/batch-history", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "usedStructType": "", + "typescriptType": "number", + "required": true, + "descriptions": [] + } + ], + "returns": "torrentstream.BatchHistoryResponse", + "returnGoType": "torrentstream.BatchHistoryResponse", + "returnTypescriptType": "Torrentstream_BatchHistoryResponse" + } + }, + { + "name": "HandleTorrentstreamServeStream", + "trimmedName": "TorrentstreamServeStream", + "comments": [ + "route /api/v1/torrentstream/stream/*", + "" + ], + "filepath": "internal/handlers/torrentstream.go", + "filename": "torrentstream.go", + "api": { + "summary": "", + "descriptions": [], + "endpoint": "", + "methods": null, + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + }, + { + "name": "webSocketEventHandler", + "trimmedName": "webSocketEventHandler", + "comments": [ + "webSocketEventHandler creates a new websocket handler for real-time event communication", + "" + ], + "filepath": "internal/handlers/websocket.go", + "filename": "websocket.go", + "api": { + "summary": "", + "descriptions": [], + "endpoint": "", + "methods": null, + "params": [], + "bodyFields": [], + "returns": "bool", + "returnGoType": "bool", + "returnTypescriptType": "boolean" + } + } +] diff --git a/seanime-2.9.10/codegen/generated/hooks.json b/seanime-2.9.10/codegen/generated/hooks.json new file mode 100644 index 0000000..b4523b4 --- /dev/null +++ b/seanime-2.9.10/codegen/generated/hooks.json @@ -0,0 +1,6776 @@ +[ + { + "package": "anilist", + "goStruct": { + "filepath": "../internal/api/anilist/hook_events.go", + "filename": "hook_events.go", + "name": "ListMissedSequelsRequestedEvent", + "formattedName": "AL_ListMissedSequelsRequestedEvent", + "package": "anilist", + "fields": [ + { + "name": "AnimeCollectionWithRelations", + "jsonName": "animeCollectionWithRelations", + "goType": "AnimeCollectionWithRelations", + "typescriptType": "AL_AnimeCollectionWithRelations", + "usedTypescriptType": "AL_AnimeCollectionWithRelations", + "usedStructName": "anilist.AnimeCollectionWithRelations", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Variables", + "jsonName": "variables", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Query", + "jsonName": "query", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "List", + "jsonName": "list", + "goType": "[]BaseAnime", + "typescriptType": "Array\u003cAL_BaseAnime\u003e", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ListMissedSequelsRequestedEvent is triggered when the list missed sequels request is requested.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist", + "goStruct": { + "filepath": "../internal/api/anilist/hook_events.go", + "filename": "hook_events.go", + "name": "ListMissedSequelsEvent", + "formattedName": "AL_ListMissedSequelsEvent", + "package": "anilist", + "fields": [ + { + "name": "List", + "jsonName": "list", + "goType": "[]BaseAnime", + "typescriptType": "Array\u003cAL_BaseAnime\u003e", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "animap", + "goStruct": { + "filepath": "../internal/api/animap/hook_events.go", + "filename": "hook_events.go", + "name": "AnimapMediaRequestedEvent", + "formattedName": "Animap_AnimapMediaRequestedEvent", + "package": "animap", + "fields": [ + { + "name": "From", + "jsonName": "from", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Id", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Anime", + "typescriptType": "Animap_Anime", + "usedTypescriptType": "Animap_Anime", + "usedStructName": "animap.Anime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimapMediaRequestedEvent is triggered when the Animap media is requested.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "animap", + "goStruct": { + "filepath": "../internal/api/animap/hook_events.go", + "filename": "hook_events.go", + "name": "AnimapMediaEvent", + "formattedName": "Animap_AnimapMediaEvent", + "package": "animap", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "Anime", + "typescriptType": "Animap_Anime", + "usedTypescriptType": "Animap_Anime", + "usedStructName": "animap.Anime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimapMediaEvent is triggered after processing AnimapMedia." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anizip", + "goStruct": { + "filepath": "../internal/api/anizip/hook_events.go", + "filename": "hook_events.go", + "name": "AnizipMediaRequestedEvent", + "formattedName": "Anizip_AnizipMediaRequestedEvent", + "package": "anizip", + "fields": [ + { + "name": "From", + "jsonName": "from", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Id", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "Anizip_Media", + "usedTypescriptType": "Anizip_Media", + "usedStructName": "anizip.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnizipMediaRequestedEvent is triggered when the AniZip media is requested.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anizip", + "goStruct": { + "filepath": "../internal/api/anizip/hook_events.go", + "filename": "hook_events.go", + "name": "AnizipMediaEvent", + "formattedName": "Anizip_AnizipMediaEvent", + "package": "anizip", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "Anizip_Media", + "usedTypescriptType": "Anizip_Media", + "usedStructName": "anizip.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnizipMediaEvent is triggered after processing AnizipMedia." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "metadata", + "goStruct": { + "filepath": "../internal/api/metadata/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeMetadataRequestedEvent", + "formattedName": "Metadata_AnimeMetadataRequestedEvent", + "package": "metadata", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnimeMetadata", + "jsonName": "animeMetadata", + "goType": "AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeMetadataRequestedEvent is triggered when anime metadata is requested and right before the metadata is processed.", + " This event is followed by [AnimeMetadataEvent] which is triggered when the metadata is available.", + " Prevent default to skip the default behavior and return the modified metadata.", + " If the modified metadata is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "metadata", + "goStruct": { + "filepath": "../internal/api/metadata/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeMetadataEvent", + "formattedName": "Metadata_AnimeMetadataEvent", + "package": "metadata", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnimeMetadata", + "jsonName": "animeMetadata", + "goType": "AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeMetadataEvent is triggered when anime metadata is available and is about to be returned.", + " Anime metadata can be requested in many places, ranging from displaying the anime entry to starting a torrent stream.", + " This event is triggered after [AnimeMetadataRequestedEvent].", + " If the modified metadata is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "metadata", + "goStruct": { + "filepath": "../internal/api/metadata/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEpisodeMetadataRequestedEvent", + "formattedName": "Metadata_AnimeEpisodeMetadataRequestedEvent", + "package": "metadata", + "fields": [ + { + "name": "EpisodeMetadata", + "jsonName": "animeEpisodeMetadata", + "goType": "EpisodeMetadata", + "typescriptType": "Metadata_EpisodeMetadata", + "usedTypescriptType": "Metadata_EpisodeMetadata", + "usedStructName": "metadata.EpisodeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEpisodeMetadataRequestedEvent is triggered when anime episode metadata is requested.", + " Prevent default to skip the default behavior and return the overridden metadata.", + " This event is triggered before [AnimeEpisodeMetadataEvent].", + " If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "metadata", + "goStruct": { + "filepath": "../internal/api/metadata/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEpisodeMetadataEvent", + "formattedName": "Metadata_AnimeEpisodeMetadataEvent", + "package": "metadata", + "fields": [ + { + "name": "EpisodeMetadata", + "jsonName": "animeEpisodeMetadata", + "goType": "EpisodeMetadata", + "typescriptType": "Metadata_EpisodeMetadata", + "usedTypescriptType": "Metadata_EpisodeMetadata", + "usedStructName": "metadata.EpisodeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEpisodeMetadataEvent is triggered when anime episode metadata is available and is about to be returned.", + " In the current implementation, episode metadata is requested for display purposes. It is used to get a more complete metadata object since the original AnimeMetadata object is not complete.", + " This event is triggered after [AnimeEpisodeMetadataRequestedEvent].", + " If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "continuity", + "goStruct": { + "filepath": "../internal/continuity/hook_events.go", + "filename": "hook_events.go", + "name": "WatchHistoryItemRequestedEvent", + "formattedName": "Continuity_WatchHistoryItemRequestedEvent", + "package": "continuity", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WatchHistoryItem", + "jsonName": "watchHistoryItem", + "goType": "WatchHistoryItem", + "typescriptType": "Continuity_WatchHistoryItem", + "usedTypescriptType": "Continuity_WatchHistoryItem", + "usedStructName": "continuity.WatchHistoryItem", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " WatchHistoryItemRequestedEvent is triggered when a watch history item is requested.", + " Prevent default to skip getting the watch history item from the file cache, in this case the event should have a valid WatchHistoryItem object or set it to nil to indicate that the watch history item was not found." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "continuity", + "goStruct": { + "filepath": "../internal/continuity/hook_events.go", + "filename": "hook_events.go", + "name": "WatchHistoryItemUpdatedEvent", + "formattedName": "Continuity_WatchHistoryItemUpdatedEvent", + "package": "continuity", + "fields": [ + { + "name": "WatchHistoryItem", + "jsonName": "watchHistoryItem", + "goType": "WatchHistoryItem", + "typescriptType": "Continuity_WatchHistoryItem", + "usedTypescriptType": "Continuity_WatchHistoryItem", + "usedStructName": "continuity.WatchHistoryItem", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " WatchHistoryItemUpdatedEvent is triggered when a watch history item is updated." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "continuity", + "goStruct": { + "filepath": "../internal/continuity/hook_events.go", + "filename": "hook_events.go", + "name": "WatchHistoryLocalFileEpisodeItemRequestedEvent", + "formattedName": "Continuity_WatchHistoryLocalFileEpisodeItemRequestedEvent", + "package": "continuity", + "fields": [ + { + "name": "Path", + "jsonName": "Path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WatchHistoryItem", + "jsonName": "watchHistoryItem", + "goType": "WatchHistoryItem", + "typescriptType": "Continuity_WatchHistoryItem", + "usedTypescriptType": "Continuity_WatchHistoryItem", + "usedStructName": "continuity.WatchHistoryItem", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "continuity", + "goStruct": { + "filepath": "../internal/continuity/hook_events.go", + "filename": "hook_events.go", + "name": "WatchHistoryStreamEpisodeItemRequestedEvent", + "formattedName": "Continuity_WatchHistoryStreamEpisodeItemRequestedEvent", + "package": "continuity", + "fields": [ + { + "name": "Episode", + "jsonName": "Episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WatchHistoryItem", + "jsonName": "watchHistoryItem", + "goType": "WatchHistoryItem", + "typescriptType": "Continuity_WatchHistoryItem", + "usedTypescriptType": "Continuity_WatchHistoryItem", + "usedStructName": "continuity.WatchHistoryItem", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "debrid_client", + "goStruct": { + "filepath": "../internal/debrid/client/hook_events.go", + "filename": "hook_events.go", + "name": "DebridAutoSelectTorrentsFetchedEvent", + "formattedName": "DebridClient_DebridAutoSelectTorrentsFetchedEvent", + "package": "debrid_client", + "fields": [ + { + "name": "Torrents", + "jsonName": "Torrents", + "goType": "[]hibiketorrent.AnimeTorrent", + "typescriptType": "Array\u003cHibikeTorrent_AnimeTorrent\u003e", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " DebridAutoSelectTorrentsFetchedEvent is triggered when the torrents are fetched for auto select.", + " The torrents are sorted by seeders from highest to lowest.", + " This event is triggered before the top 3 torrents are analyzed." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "debrid_client", + "goStruct": { + "filepath": "../internal/debrid/client/hook_events.go", + "filename": "hook_events.go", + "name": "DebridSkipStreamCheckEvent", + "formattedName": "DebridClient_DebridSkipStreamCheckEvent", + "package": "debrid_client", + "fields": [ + { + "name": "StreamURL", + "jsonName": "streamURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Retries", + "jsonName": "retries", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RetryDelay", + "jsonName": "retryDelay", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " in seconds" + ] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " DebridSkipStreamCheckEvent is triggered when the debrid client is about to skip the stream check.", + " Prevent default to enable the stream check." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "debrid_client", + "goStruct": { + "filepath": "../internal/debrid/client/hook_events.go", + "filename": "hook_events.go", + "name": "DebridSendStreamToMediaPlayerEvent", + "formattedName": "DebridClient_DebridSendStreamToMediaPlayerEvent", + "package": "debrid_client", + "fields": [ + { + "name": "WindowTitle", + "jsonName": "windowTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamURL", + "jsonName": "streamURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AniDbEpisode", + "jsonName": "aniDbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PlaybackType", + "jsonName": "playbackType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " DebridSendStreamToMediaPlayerEvent is triggered when the debrid client is about to send a stream to the media player.", + " Prevent default to skip the playback." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "debrid_client", + "goStruct": { + "filepath": "../internal/debrid/client/hook_events.go", + "filename": "hook_events.go", + "name": "DebridLocalDownloadRequestedEvent", + "formattedName": "DebridClient_DebridLocalDownloadRequestedEvent", + "package": "debrid_client", + "fields": [ + { + "name": "TorrentName", + "jsonName": "torrentName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadUrl", + "jsonName": "downloadUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " DebridLocalDownloadRequestedEvent is triggered when Seanime is about to download a debrid torrent locally.", + " Prevent default to skip the default download and override the download." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "discordrpc_presence", + "goStruct": { + "filepath": "../internal/discordrpc/presence/hook_events.go", + "filename": "hook_events.go", + "name": "DiscordPresenceAnimeActivityRequestedEvent", + "formattedName": "DiscordRPC_DiscordPresenceAnimeActivityRequestedEvent", + "package": "discordrpc_presence", + "fields": [ + { + "name": "AnimeActivity", + "jsonName": "animeActivity", + "goType": "AnimeActivity", + "typescriptType": "DiscordRPC_AnimeActivity", + "usedTypescriptType": "DiscordRPC_AnimeActivity", + "usedStructName": "discordrpc_presence.AnimeActivity", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Details", + "jsonName": "details", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DetailsURL", + "jsonName": "detailsUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "state", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartTimestamp", + "jsonName": "startTimestamp", + "goType": "int64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EndTimestamp", + "jsonName": "endTimestamp", + "goType": "int64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LargeImage", + "jsonName": "largeImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LargeText", + "jsonName": "largeText", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LargeURL", + "jsonName": "largeUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to large image, if any" + ] + }, + { + "name": "SmallImage", + "jsonName": "smallImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SmallText", + "jsonName": "smallText", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SmallURL", + "jsonName": "smallUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to small image, if any" + ] + }, + { + "name": "Buttons", + "jsonName": "buttons", + "goType": "[]discordrpc_client.Button", + "typescriptType": "Array\u003cDiscordRPC_Button\u003e", + "usedTypescriptType": "DiscordRPC_Button", + "usedStructName": "discordrpc_client.Button", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Instance", + "jsonName": "instance", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StatusDisplayType", + "jsonName": "statusDisplayType", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " DiscordPresenceAnimeActivityRequestedEvent is triggered when anime activity is requested, after the [animeActivity] is processed, and right before the activity is sent to queue.", + " There is no guarantee as to when or if the activity will be successfully sent to discord.", + " Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed.", + " Prevent default to stop the activity from being sent to discord." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "discordrpc_presence", + "goStruct": { + "filepath": "../internal/discordrpc/presence/hook_events.go", + "filename": "hook_events.go", + "name": "DiscordPresenceMangaActivityRequestedEvent", + "formattedName": "DiscordRPC_DiscordPresenceMangaActivityRequestedEvent", + "package": "discordrpc_presence", + "fields": [ + { + "name": "MangaActivity", + "jsonName": "mangaActivity", + "goType": "MangaActivity", + "typescriptType": "DiscordRPC_MangaActivity", + "usedTypescriptType": "DiscordRPC_MangaActivity", + "usedStructName": "discordrpc_presence.MangaActivity", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Details", + "jsonName": "details", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DetailsURL", + "jsonName": "detailsUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "state", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartTimestamp", + "jsonName": "startTimestamp", + "goType": "int64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EndTimestamp", + "jsonName": "endTimestamp", + "goType": "int64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LargeImage", + "jsonName": "largeImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LargeText", + "jsonName": "largeText", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LargeURL", + "jsonName": "largeUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to large image, if any" + ] + }, + { + "name": "SmallImage", + "jsonName": "smallImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SmallText", + "jsonName": "smallText", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SmallURL", + "jsonName": "smallUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to small image, if any" + ] + }, + { + "name": "Buttons", + "jsonName": "buttons", + "goType": "[]discordrpc_client.Button", + "typescriptType": "Array\u003cDiscordRPC_Button\u003e", + "usedTypescriptType": "DiscordRPC_Button", + "usedStructName": "discordrpc_client.Button", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Instance", + "jsonName": "instance", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StatusDisplayType", + "jsonName": "statusDisplayType", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " DiscordPresenceMangaActivityRequestedEvent is triggered when manga activity is requested, after the [mangaActivity] is processed, and right before the activity is sent to queue.", + " There is no guarantee as to when or if the activity will be successfully sent to discord.", + " Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed.", + " Prevent default to stop the activity from being sent to discord." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "discordrpc_presence", + "goStruct": { + "filepath": "../internal/discordrpc/presence/hook_events.go", + "filename": "hook_events.go", + "name": "DiscordPresenceClientClosedEvent", + "formattedName": "DiscordRPC_DiscordPresenceClientClosedEvent", + "package": "discordrpc_presence", + "fields": [ + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " DiscordPresenceClientClosedEvent is triggered when the discord rpc client is closed." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryRequestedEvent", + "formattedName": "Anime_AnimeEntryRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Entry", + "jsonName": "entry", + "goType": "Entry", + "typescriptType": "Anime_Entry", + "usedTypescriptType": "Anime_Entry", + "usedStructName": "anime.Entry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryRequestedEvent is triggered when an anime entry is requested.", + " Prevent default to skip the default behavior and return the modified entry.", + " This event is triggered before [AnimeEntryEvent].", + " If the modified entry is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryEvent", + "formattedName": "Anime_AnimeEntryEvent", + "package": "anime", + "fields": [ + { + "name": "Entry", + "jsonName": "entry", + "goType": "Entry", + "typescriptType": "Anime_Entry", + "usedTypescriptType": "Anime_Entry", + "usedStructName": "anime.Entry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryEvent is triggered when the media entry is being returned.", + " This event is triggered after [AnimeEntryRequestedEvent]." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryFillerHydrationEvent", + "formattedName": "Anime_AnimeEntryFillerHydrationEvent", + "package": "anime", + "fields": [ + { + "name": "Entry", + "jsonName": "entry", + "goType": "Entry", + "typescriptType": "Anime_Entry", + "usedTypescriptType": "Anime_Entry", + "usedStructName": "anime.Entry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryFillerHydrationEvent is triggered when the filler data is being added to the media entry.", + " This event is triggered after [AnimeEntryEvent].", + " Prevent default to skip the filler data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryLibraryDataRequestedEvent", + "formattedName": "Anime_AnimeEntryLibraryDataRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "EntryLocalFiles", + "jsonName": "entryLocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CurrentProgress", + "jsonName": "currentProgress", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryLibraryDataRequestedEvent is triggered when the app requests the library data for a media entry.", + " This is triggered before [AnimeEntryLibraryDataEvent]." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryLibraryDataEvent", + "formattedName": "Anime_AnimeEntryLibraryDataEvent", + "package": "anime", + "fields": [ + { + "name": "EntryLibraryData", + "jsonName": "entryLibraryData", + "goType": "EntryLibraryData", + "typescriptType": "Anime_EntryLibraryData", + "usedTypescriptType": "Anime_EntryLibraryData", + "usedStructName": "anime.EntryLibraryData", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryLibraryDataEvent is triggered when the library data is being added to the media entry.", + " This is triggered after [AnimeEntryLibraryDataRequestedEvent]." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryManualMatchBeforeSaveEvent", + "formattedName": "Anime_AnimeEntryManualMatchBeforeSaveEvent", + "package": "anime", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Paths", + "jsonName": "paths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MatchedLocalFiles", + "jsonName": "matchedLocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryManualMatchBeforeSaveEvent is triggered when the user manually matches local files to a media entry.", + " Prevent default to skip saving the local files." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "MissingEpisodesRequestedEvent", + "formattedName": "Anime_MissingEpisodesRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SilencedMediaIds", + "jsonName": "silencedMediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MissingEpisodes", + "jsonName": "missingEpisodes", + "goType": "MissingEpisodes", + "typescriptType": "Anime_MissingEpisodes", + "usedTypescriptType": "Anime_MissingEpisodes", + "usedStructName": "anime.MissingEpisodes", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MissingEpisodesRequestedEvent is triggered when the user requests the missing episodes for the entire library.", + " Prevent default to skip the default process and return the modified missing episodes." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "MissingEpisodesEvent", + "formattedName": "Anime_MissingEpisodesEvent", + "package": "anime", + "fields": [ + { + "name": "MissingEpisodes", + "jsonName": "missingEpisodes", + "goType": "MissingEpisodes", + "typescriptType": "Anime_MissingEpisodes", + "usedTypescriptType": "Anime_MissingEpisodes", + "usedStructName": "anime.MissingEpisodes", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MissingEpisodesEvent is triggered when the missing episodes are being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeLibraryCollectionRequestedEvent", + "formattedName": "Anime_AnimeLibraryCollectionRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LibraryCollection", + "jsonName": "libraryCollection", + "goType": "LibraryCollection", + "typescriptType": "Anime_LibraryCollection", + "usedTypescriptType": "Anime_LibraryCollection", + "usedStructName": "anime.LibraryCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeLibraryCollectionRequestedEvent is triggered when the user requests the library collection.", + " Prevent default to skip the default process and return the modified library collection.", + " If the modified library collection is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeLibraryCollectionEvent", + "formattedName": "Anime_AnimeLibraryCollectionEvent", + "package": "anime", + "fields": [ + { + "name": "LibraryCollection", + "jsonName": "libraryCollection", + "goType": "LibraryCollection", + "typescriptType": "Anime_LibraryCollection", + "usedTypescriptType": "Anime_LibraryCollection", + "usedStructName": "anime.LibraryCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeLibraryCollectionEvent is triggered when the user requests the library collection." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeLibraryStreamCollectionRequestedEvent", + "formattedName": "Anime_AnimeLibraryStreamCollectionRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LibraryCollection", + "jsonName": "libraryCollection", + "goType": "LibraryCollection", + "typescriptType": "Anime_LibraryCollection", + "usedTypescriptType": "Anime_LibraryCollection", + "usedStructName": "anime.LibraryCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeLibraryStreamCollectionRequestedEvent is triggered when the user requests the library stream collection.", + " This is called when the user enables \"Include in library\" for either debrid/online/torrent streamings." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeLibraryStreamCollectionEvent", + "formattedName": "Anime_AnimeLibraryStreamCollectionEvent", + "package": "anime", + "fields": [ + { + "name": "StreamCollection", + "jsonName": "streamCollection", + "goType": "StreamCollection", + "typescriptType": "Anime_StreamCollection", + "usedTypescriptType": "Anime_StreamCollection", + "usedStructName": "anime.StreamCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeLibraryStreamCollectionEvent is triggered when the library stream collection is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryDownloadInfoRequestedEvent", + "formattedName": "Anime_AnimeEntryDownloadInfoRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeMetadata", + "jsonName": "AnimeMetadata", + "goType": "metadata.AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "Progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "Status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EntryDownloadInfo", + "jsonName": "entryDownloadInfo", + "goType": "EntryDownloadInfo", + "typescriptType": "Anime_EntryDownloadInfo", + "usedTypescriptType": "Anime_EntryDownloadInfo", + "usedStructName": "anime.EntryDownloadInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryDownloadInfoRequestedEvent is triggered when the app requests the download info for a media entry.", + " This is triggered before [AnimeEntryDownloadInfoEvent]." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryDownloadInfoEvent", + "formattedName": "Anime_AnimeEntryDownloadInfoEvent", + "package": "anime", + "fields": [ + { + "name": "EntryDownloadInfo", + "jsonName": "entryDownloadInfo", + "goType": "EntryDownloadInfo", + "typescriptType": "Anime_EntryDownloadInfo", + "usedTypescriptType": "Anime_EntryDownloadInfo", + "usedStructName": "anime.EntryDownloadInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryDownloadInfoEvent is triggered when the download info is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEpisodeCollectionRequestedEvent", + "formattedName": "Anime_AnimeEpisodeCollectionRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Metadata", + "jsonName": "metadata", + "goType": "metadata.AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeCollection", + "jsonName": "episodeCollection", + "goType": "EpisodeCollection", + "typescriptType": "Anime_EpisodeCollection", + "usedTypescriptType": "Anime_EpisodeCollection", + "usedStructName": "anime.EpisodeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEpisodeCollectionRequestedEvent is triggered when the episode collection is being requested.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEpisodeCollectionEvent", + "formattedName": "Anime_AnimeEpisodeCollectionEvent", + "package": "anime", + "fields": [ + { + "name": "EpisodeCollection", + "jsonName": "episodeCollection", + "goType": "EpisodeCollection", + "typescriptType": "Anime_EpisodeCollection", + "usedTypescriptType": "Anime_EpisodeCollection", + "usedStructName": "anime.EpisodeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEpisodeCollectionEvent is triggered when the episode collection is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anime", + "goStruct": { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeScheduleItemsEvent", + "formattedName": "Anime_AnimeScheduleItemsEvent", + "package": "anime", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Items", + "jsonName": "items", + "goType": "[]ScheduleItem", + "typescriptType": "Array\u003cAnime_ScheduleItem\u003e", + "usedTypescriptType": "Anime_ScheduleItem", + "usedStructName": "anime.ScheduleItem", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeScheduleItemsEvent is triggered when the schedule items are being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "autodownloader", + "goStruct": { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderRunStartedEvent", + "formattedName": "AutoDownloader_AutoDownloaderRunStartedEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Rules", + "jsonName": "rules", + "goType": "[]anime.AutoDownloaderRule", + "typescriptType": "Array\u003cAnime_AutoDownloaderRule\u003e", + "usedTypescriptType": "Anime_AutoDownloaderRule", + "usedStructName": "anime.AutoDownloaderRule", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderRunStartedEvent is triggered when the autodownloader starts checking for new episodes.", + " Prevent default to abort the run." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "autodownloader", + "goStruct": { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderTorrentsFetchedEvent", + "formattedName": "AutoDownloader_AutoDownloaderTorrentsFetchedEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Torrents", + "jsonName": "torrents", + "goType": "[]NormalizedTorrent", + "typescriptType": "Array\u003cAutoDownloader_NormalizedTorrent\u003e", + "usedTypescriptType": "AutoDownloader_NormalizedTorrent", + "usedStructName": "autodownloader.NormalizedTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderTorrentsFetchedEvent is triggered at the beginning of a run, when the autodownloader fetches torrents from the provider." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "autodownloader", + "goStruct": { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderMatchVerifiedEvent", + "formattedName": "AutoDownloader_AutoDownloaderMatchVerifiedEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "NormalizedTorrent", + "typescriptType": "AutoDownloader_NormalizedTorrent", + "usedTypescriptType": "AutoDownloader_NormalizedTorrent", + "usedStructName": "autodownloader.NormalizedTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rule", + "jsonName": "rule", + "goType": "anime.AutoDownloaderRule", + "typescriptType": "Anime_AutoDownloaderRule", + "usedTypescriptType": "Anime_AutoDownloaderRule", + "usedStructName": "anime.AutoDownloaderRule", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ListEntry", + "jsonName": "listEntry", + "goType": "anilist.AnimeListEntry", + "typescriptType": "AL_AnimeListEntry", + "usedTypescriptType": "AL_AnimeListEntry", + "usedStructName": "anilist.AnimeListEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalEntry", + "jsonName": "localEntry", + "goType": "anime.LocalFileWrapperEntry", + "typescriptType": "Anime_LocalFileWrapperEntry", + "usedTypescriptType": "Anime_LocalFileWrapperEntry", + "usedStructName": "anime.LocalFileWrapperEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MatchFound", + "jsonName": "matchFound", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderMatchVerifiedEvent is triggered when a torrent is verified to follow a rule.", + " Prevent default to abort the download if the match is found." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "autodownloader", + "goStruct": { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderSettingsUpdatedEvent", + "formattedName": "AutoDownloader_AutoDownloaderSettingsUpdatedEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Settings", + "jsonName": "settings", + "goType": "models.AutoDownloaderSettings", + "typescriptType": "Models_AutoDownloaderSettings", + "usedTypescriptType": "Models_AutoDownloaderSettings", + "usedStructName": "models.AutoDownloaderSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderSettingsUpdatedEvent is triggered when the autodownloader settings are updated" + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "autodownloader", + "goStruct": { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderBeforeDownloadTorrentEvent", + "formattedName": "AutoDownloader_AutoDownloaderBeforeDownloadTorrentEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "NormalizedTorrent", + "typescriptType": "AutoDownloader_NormalizedTorrent", + "usedTypescriptType": "AutoDownloader_NormalizedTorrent", + "usedStructName": "autodownloader.NormalizedTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rule", + "jsonName": "rule", + "goType": "anime.AutoDownloaderRule", + "typescriptType": "Anime_AutoDownloaderRule", + "usedTypescriptType": "Anime_AutoDownloaderRule", + "usedStructName": "anime.AutoDownloaderRule", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Items", + "jsonName": "items", + "goType": "[]models.AutoDownloaderItem", + "typescriptType": "Array\u003cModels_AutoDownloaderItem\u003e", + "usedTypescriptType": "Models_AutoDownloaderItem", + "usedStructName": "models.AutoDownloaderItem", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderBeforeDownloadTorrentEvent is triggered when the autodownloader is about to download a torrent.", + " Prevent default to abort the download." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "autodownloader", + "goStruct": { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderAfterDownloadTorrentEvent", + "formattedName": "AutoDownloader_AutoDownloaderAfterDownloadTorrentEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "NormalizedTorrent", + "typescriptType": "AutoDownloader_NormalizedTorrent", + "usedTypescriptType": "AutoDownloader_NormalizedTorrent", + "usedStructName": "autodownloader.NormalizedTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rule", + "jsonName": "rule", + "goType": "anime.AutoDownloaderRule", + "typescriptType": "Anime_AutoDownloaderRule", + "usedTypescriptType": "Anime_AutoDownloaderRule", + "usedStructName": "anime.AutoDownloaderRule", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderAfterDownloadTorrentEvent is triggered when the autodownloader has downloaded a torrent." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "fillermanager", + "goStruct": { + "filepath": "../internal/library/fillermanager/hook_events.go", + "filename": "hook_events.go", + "name": "HydrateFillerDataRequestedEvent", + "formattedName": "HydrateFillerDataRequestedEvent", + "package": "fillermanager", + "fields": [ + { + "name": "Entry", + "jsonName": "entry", + "goType": "anime.Entry", + "typescriptType": "Anime_Entry", + "usedTypescriptType": "Anime_Entry", + "usedStructName": "anime.Entry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " HydrateFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for an entry.", + " This is used by the local file episode list.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "fillermanager", + "goStruct": { + "filepath": "../internal/library/fillermanager/hook_events.go", + "filename": "hook_events.go", + "name": "HydrateOnlinestreamFillerDataRequestedEvent", + "formattedName": "HydrateOnlinestreamFillerDataRequestedEvent", + "package": "fillermanager", + "fields": [ + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "[]onlinestream.Episode", + "typescriptType": "Array\u003cOnlinestream_Episode\u003e", + "usedTypescriptType": "Onlinestream_Episode", + "usedStructName": "onlinestream.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " HydrateOnlinestreamFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for online streaming episodes.", + " This is used by the online streaming episode list.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "fillermanager", + "goStruct": { + "filepath": "../internal/library/fillermanager/hook_events.go", + "filename": "hook_events.go", + "name": "HydrateEpisodeFillerDataRequestedEvent", + "formattedName": "HydrateEpisodeFillerDataRequestedEvent", + "package": "fillermanager", + "fields": [ + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "[]anime.Episode", + "typescriptType": "Array\u003cAnime_Episode\u003e", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " HydrateEpisodeFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for specific episodes.", + " This is used by the torrent and debrid streaming episode list.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "playbackmanager", + "goStruct": { + "filepath": "../internal/library/playbackmanager/hook_events.go", + "filename": "hook_events.go", + "name": "LocalFilePlaybackRequestedEvent", + "formattedName": "PlaybackManager_LocalFilePlaybackRequestedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " LocalFilePlaybackRequestedEvent is triggered when a local file is requested to be played.", + " Prevent default to skip the default playback and override the playback." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "playbackmanager", + "goStruct": { + "filepath": "../internal/library/playbackmanager/hook_events.go", + "filename": "hook_events.go", + "name": "StreamPlaybackRequestedEvent", + "formattedName": "PlaybackManager_StreamPlaybackRequestedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "WindowTitle", + "jsonName": "windowTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AniDbEpisode", + "jsonName": "aniDbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " StreamPlaybackRequestedEvent is triggered when a stream is requested to be played.", + " Prevent default to skip the default playback and override the playback." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "playbackmanager", + "goStruct": { + "filepath": "../internal/library/playbackmanager/hook_events.go", + "filename": "hook_events.go", + "name": "PlaybackBeforeTrackingEvent", + "formattedName": "PlaybackManager_PlaybackBeforeTrackingEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "IsStream", + "jsonName": "isStream", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " PlaybackBeforeTrackingEvent is triggered just before the playback tracking starts.", + " Prevent default to skip playback tracking." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "playbackmanager", + "goStruct": { + "filepath": "../internal/library/playbackmanager/hook_events.go", + "filename": "hook_events.go", + "name": "PlaybackLocalFileDetailsRequestedEvent", + "formattedName": "PlaybackManager_PlaybackLocalFileDetailsRequestedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeListEntry", + "jsonName": "animeListEntry", + "goType": "anilist.AnimeListEntry", + "typescriptType": "AL_AnimeListEntry", + "usedTypescriptType": "AL_AnimeListEntry", + "usedStructName": "anilist.AnimeListEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFile", + "jsonName": "localFile", + "goType": "anime.LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFileWrapperEntry", + "jsonName": "localFileWrapperEntry", + "goType": "anime.LocalFileWrapperEntry", + "typescriptType": "Anime_LocalFileWrapperEntry", + "usedTypescriptType": "Anime_LocalFileWrapperEntry", + "usedStructName": "anime.LocalFileWrapperEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " PlaybackLocalFileDetailsRequestedEvent is triggered when the local files details for a specific path are requested.", + " This event is triggered right after the media player loads an episode.", + " The playback manager uses the local files details to track the progress, propose next episodes, etc.", + " In the current implementation, the details are fetched by selecting the local file from the database and making requests to retrieve the media and anime list entry.", + " Prevent default to skip the default fetching and override the details." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "playbackmanager", + "goStruct": { + "filepath": "../internal/library/playbackmanager/hook_events.go", + "filename": "hook_events.go", + "name": "PlaybackStreamDetailsRequestedEvent", + "formattedName": "PlaybackManager_PlaybackStreamDetailsRequestedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnimeListEntry", + "jsonName": "animeListEntry", + "goType": "anilist.AnimeListEntry", + "typescriptType": "AL_AnimeListEntry", + "usedTypescriptType": "AL_AnimeListEntry", + "usedStructName": "anilist.AnimeListEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " PlaybackStreamDetailsRequestedEvent is triggered when the stream details are requested.", + " Prevent default to skip the default fetching and override the details.", + " In the current implementation, the details are fetched by selecting the anime from the anime collection. If nothing is found, the stream is still tracked." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanStartedEvent", + "formattedName": "Scanner_ScanStartedEvent", + "package": "scanner", + "fields": [ + { + "name": "LibraryPath", + "jsonName": "libraryPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "OtherLibraryPaths", + "jsonName": "otherLibraryPaths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Enhanced", + "jsonName": "enhanced", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SkipLocked", + "jsonName": "skipLocked", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SkipIgnored", + "jsonName": "skipIgnored", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanStartedEvent is triggered when the scanning process begins.", + " Prevent default to skip the rest of the scanning process and return the local files." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanFilePathsRetrievedEvent", + "formattedName": "Scanner_ScanFilePathsRetrievedEvent", + "package": "scanner", + "fields": [ + { + "name": "FilePaths", + "jsonName": "filePaths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanFilePathsRetrievedEvent is triggered when the file paths to scan are retrieved.", + " The event includes file paths from all directories to scan.", + " The event includes file paths of local files that will be skipped." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanLocalFilesParsedEvent", + "formattedName": "Scanner_ScanLocalFilesParsedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanLocalFilesParsedEvent is triggered right after the file paths are parsed into local file objects.", + " The event does not include local files that are skipped." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanCompletedEvent", + "formattedName": "Scanner_ScanCompletedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " in milliseconds" + ] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanCompletedEvent is triggered when the scanning process finishes.", + " The event includes all the local files (skipped and scanned) to be inserted as a new entry.", + " Right after this event, the local files will be inserted as a new entry." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanMediaFetcherStartedEvent", + "formattedName": "Scanner_ScanMediaFetcherStartedEvent", + "package": "scanner", + "fields": [ + { + "name": "Enhanced", + "jsonName": "enhanced", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanMediaFetcherStartedEvent is triggered right before Seanime starts fetching media to be matched against the local files." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanMediaFetcherCompletedEvent", + "formattedName": "Scanner_ScanMediaFetcherCompletedEvent", + "package": "scanner", + "fields": [ + { + "name": "AllMedia", + "jsonName": "allMedia", + "goType": "[]anilist.CompleteAnime", + "typescriptType": "Array\u003cAL_CompleteAnime\u003e", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UnknownMediaIds", + "jsonName": "unknownMediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanMediaFetcherCompletedEvent is triggered when the media fetcher completes.", + " The event includes all the media fetched from AniList.", + " The event includes the media IDs that are not in the user's collection." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanMatchingStartedEvent", + "formattedName": "Scanner_ScanMatchingStartedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NormalizedMedia", + "jsonName": "normalizedMedia", + "goType": "[]anime.NormalizedMedia", + "typescriptType": "Array\u003cAnime_NormalizedMedia\u003e", + "usedTypescriptType": "Anime_NormalizedMedia", + "usedStructName": "anime.NormalizedMedia", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Algorithm", + "jsonName": "algorithm", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Threshold", + "jsonName": "threshold", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanMatchingStartedEvent is triggered when the matching process begins.", + " Prevent default to skip the default matching, in which case modified local files will be used." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanLocalFileMatchedEvent", + "formattedName": "Scanner_ScanLocalFileMatchedEvent", + "package": "scanner", + "fields": [ + { + "name": "Match", + "jsonName": "match", + "goType": "anime.NormalizedMedia", + "typescriptType": "Anime_NormalizedMedia", + "usedTypescriptType": "Anime_NormalizedMedia", + "usedStructName": "anime.NormalizedMedia", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Found", + "jsonName": "found", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFile", + "jsonName": "localFile", + "goType": "anime.LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanLocalFileMatchedEvent is triggered when a local file is matched with media and before the match is analyzed.", + " Prevent default to skip the default analysis and override the match." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanMatchingCompletedEvent", + "formattedName": "Scanner_ScanMatchingCompletedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanMatchingCompletedEvent is triggered when the matching process completes." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanHydrationStartedEvent", + "formattedName": "Scanner_ScanHydrationStartedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AllMedia", + "jsonName": "allMedia", + "goType": "[]anime.NormalizedMedia", + "typescriptType": "Array\u003cAnime_NormalizedMedia\u003e", + "usedTypescriptType": "Anime_NormalizedMedia", + "usedStructName": "anime.NormalizedMedia", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanHydrationStartedEvent is triggered when the file hydration process begins.", + " Prevent default to skip the rest of the hydration process, in which case the event's local files will be used." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanLocalFileHydrationStartedEvent", + "formattedName": "Scanner_ScanLocalFileHydrationStartedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFile", + "jsonName": "localFile", + "goType": "anime.LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anime.NormalizedMedia", + "typescriptType": "Anime_NormalizedMedia", + "usedTypescriptType": "Anime_NormalizedMedia", + "usedStructName": "anime.NormalizedMedia", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanLocalFileHydrationStartedEvent is triggered when a local file's metadata is about to be hydrated.", + " Prevent default to skip the default hydration and override the hydration." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "scanner", + "goStruct": { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanLocalFileHydratedEvent", + "formattedName": "Scanner_ScanLocalFileHydratedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFile", + "jsonName": "localFile", + "goType": "anime.LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanLocalFileHydratedEvent is triggered when a local file's metadata is hydrated" + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "manga", + "goStruct": { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaEntryRequestedEvent", + "formattedName": "Manga_MangaEntryRequestedEvent", + "package": "manga", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Entry", + "jsonName": "entry", + "goType": "Entry", + "typescriptType": "Manga_Entry", + "usedTypescriptType": "Manga_Entry", + "usedStructName": "manga.Entry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaEntryRequestedEvent is triggered when a manga entry is requested.", + " Prevent default to skip the default behavior and return the modified entry.", + " If the modified entry is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "manga", + "goStruct": { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaEntryEvent", + "formattedName": "Manga_MangaEntryEvent", + "package": "manga", + "fields": [ + { + "name": "Entry", + "jsonName": "entry", + "goType": "Entry", + "typescriptType": "Manga_Entry", + "usedTypescriptType": "Manga_Entry", + "usedStructName": "manga.Entry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaEntryEvent is triggered when the manga entry is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "manga", + "goStruct": { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaLibraryCollectionRequestedEvent", + "formattedName": "Manga_MangaLibraryCollectionRequestedEvent", + "package": "manga", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaLibraryCollectionRequestedEvent is triggered when the manga library collection is being requested." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "manga", + "goStruct": { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaLibraryCollectionEvent", + "formattedName": "Manga_MangaLibraryCollectionEvent", + "package": "manga", + "fields": [ + { + "name": "LibraryCollection", + "jsonName": "libraryCollection", + "goType": "Collection", + "typescriptType": "Manga_Collection", + "usedTypescriptType": "Manga_Collection", + "usedStructName": "manga.Collection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaLibraryCollectionEvent is triggered when the manga library collection is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "manga", + "goStruct": { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaDownloadedChapterContainersRequestedEvent", + "formattedName": "Manga_MangaDownloadedChapterContainersRequestedEvent", + "package": "manga", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChapterContainers", + "jsonName": "chapterContainers", + "goType": "[]ChapterContainer", + "typescriptType": "Array\u003cManga_ChapterContainer\u003e", + "usedTypescriptType": "Manga_ChapterContainer", + "usedStructName": "manga.ChapterContainer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaDownloadedChapterContainersRequestedEvent is triggered when the manga downloaded chapter containers are being requested.", + " Prevent default to skip the default behavior and return the modified chapter containers.", + " If the modified chapter containers are nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "manga", + "goStruct": { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaDownloadedChapterContainersEvent", + "formattedName": "Manga_MangaDownloadedChapterContainersEvent", + "package": "manga", + "fields": [ + { + "name": "ChapterContainers", + "jsonName": "chapterContainers", + "goType": "[]ChapterContainer", + "typescriptType": "Array\u003cManga_ChapterContainer\u003e", + "usedTypescriptType": "Manga_ChapterContainer", + "usedStructName": "manga.ChapterContainer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaDownloadedChapterContainersEvent is triggered when the manga downloaded chapter containers are being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "manga", + "goStruct": { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaLatestChapterNumbersMapEvent", + "formattedName": "Manga_MangaLatestChapterNumbersMapEvent", + "package": "manga", + "fields": [ + { + "name": "LatestChapterNumbersMap", + "jsonName": "latestChapterNumbersMap", + "goType": "map[int][]MangaLatestChapterNumberItem", + "typescriptType": "Record\u003cnumber, Array\u003cManga_MangaLatestChapterNumberItem\u003e\u003e", + "usedTypescriptType": "Manga_MangaLatestChapterNumberItem", + "usedStructName": "manga.MangaLatestChapterNumberItem", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaLatestChapterNumbersMapEvent is triggered when the manga latest chapter numbers map is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "manga", + "goStruct": { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaDownloadMapEvent", + "formattedName": "Manga_MangaDownloadMapEvent", + "package": "manga", + "fields": [ + { + "name": "MediaMap", + "jsonName": "mediaMap", + "goType": "MediaMap", + "typescriptType": "Manga_MediaMap", + "usedTypescriptType": "Manga_MediaMap", + "usedStructName": "manga.MediaMap", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaDownloadMapEvent is triggered when the manga download map has been updated.", + " This map is used to tell the client which chapters have been downloaded." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "manga", + "goStruct": { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaChapterContainerRequestedEvent", + "formattedName": "Manga_MangaChapterContainerRequestedEvent", + "package": "manga", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Titles", + "jsonName": "titles", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterContainer", + "jsonName": "chapterContainer", + "goType": "ChapterContainer", + "typescriptType": "Manga_ChapterContainer", + "usedTypescriptType": "Manga_ChapterContainer", + "usedStructName": "manga.ChapterContainer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaChapterContainerRequestedEvent is triggered when the manga chapter container is being requested.", + " This event happens before the chapter container is fetched from the cache or provider.", + " Prevent default to skip the default behavior and return the modified chapter container.", + " If the modified chapter container is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "manga", + "goStruct": { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaChapterContainerEvent", + "formattedName": "Manga_MangaChapterContainerEvent", + "package": "manga", + "fields": [ + { + "name": "ChapterContainer", + "jsonName": "chapterContainer", + "goType": "ChapterContainer", + "typescriptType": "Manga_ChapterContainer", + "usedTypescriptType": "Manga_ChapterContainer", + "usedStructName": "manga.ChapterContainer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaChapterContainerEvent is triggered when the manga chapter container is being returned.", + " This event happens after the chapter container is fetched from the cache or provider." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "mediaplayer", + "goStruct": { + "filepath": "../internal/mediaplayers/mediaplayer/hook_events.go", + "filename": "hook_events.go", + "name": "MediaPlayerLocalFileTrackingRequestedEvent", + "formattedName": "MediaPlayerLocalFileTrackingRequestedEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "StartRefreshDelay", + "jsonName": "startRefreshDelay", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RefreshDelay", + "jsonName": "refreshDelay", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxRetries", + "jsonName": "maxRetries", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MediaPlayerLocalFileTrackingRequestedEvent is triggered when the playback manager wants to track the progress of a local file.", + " Prevent default to stop tracking." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "mediaplayer", + "goStruct": { + "filepath": "../internal/mediaplayers/mediaplayer/hook_events.go", + "filename": "hook_events.go", + "name": "MediaPlayerStreamTrackingRequestedEvent", + "formattedName": "MediaPlayerStreamTrackingRequestedEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "StartRefreshDelay", + "jsonName": "startRefreshDelay", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RefreshDelay", + "jsonName": "refreshDelay", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxRetries", + "jsonName": "maxRetries", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxRetriesAfterStart", + "jsonName": "maxRetriesAfterStart", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MediaPlayerStreamTrackingRequestedEvent is triggered when the playback manager wants to track the progress of a stream.", + " Prevent default to stop tracking." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetAnimeEvent", + "formattedName": "GetAnimeEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "Anime", + "jsonName": "anime", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetAnimeDetailsEvent", + "formattedName": "GetAnimeDetailsEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "Anime", + "jsonName": "anime", + "goType": "anilist.AnimeDetailsById_Media", + "typescriptType": "AL_AnimeDetailsById_Media", + "usedTypescriptType": "AL_AnimeDetailsById_Media", + "usedStructName": "anilist.AnimeDetailsById_Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetMangaEvent", + "formattedName": "GetMangaEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "Manga", + "jsonName": "manga", + "goType": "anilist.BaseManga", + "typescriptType": "AL_BaseManga", + "usedTypescriptType": "AL_BaseManga", + "usedStructName": "anilist.BaseManga", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetMangaDetailsEvent", + "formattedName": "GetMangaDetailsEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "Manga", + "jsonName": "manga", + "goType": "anilist.MangaDetailsById_Media", + "typescriptType": "AL_MangaDetailsById_Media", + "usedTypescriptType": "AL_MangaDetailsById_Media", + "usedStructName": "anilist.MangaDetailsById_Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetCachedAnimeCollectionEvent", + "formattedName": "GetCachedAnimeCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetCachedMangaCollectionEvent", + "formattedName": "GetCachedMangaCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetAnimeCollectionEvent", + "formattedName": "GetAnimeCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetMangaCollectionEvent", + "formattedName": "GetMangaCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetCachedRawAnimeCollectionEvent", + "formattedName": "GetCachedRawAnimeCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetCachedRawMangaCollectionEvent", + "formattedName": "GetCachedRawMangaCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetRawAnimeCollectionEvent", + "formattedName": "GetRawAnimeCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetRawMangaCollectionEvent", + "formattedName": "GetRawMangaCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetStudioDetailsEvent", + "formattedName": "GetStudioDetailsEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "Studio", + "jsonName": "studio", + "goType": "anilist.StudioDetails", + "typescriptType": "AL_StudioDetails", + "usedTypescriptType": "AL_StudioDetails", + "usedStructName": "anilist.StudioDetails", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PreUpdateEntryEvent", + "formattedName": "PreUpdateEntryEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScoreRaw", + "jsonName": "scoreRaw", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartedAt", + "jsonName": "startedAt", + "goType": "anilist.FuzzyDateInput", + "typescriptType": "AL_FuzzyDateInput", + "usedTypescriptType": "AL_FuzzyDateInput", + "usedStructName": "anilist.FuzzyDateInput", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompletedAt", + "jsonName": "completedAt", + "goType": "anilist.FuzzyDateInput", + "typescriptType": "AL_FuzzyDateInput", + "usedTypescriptType": "AL_FuzzyDateInput", + "usedStructName": "anilist.FuzzyDateInput", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " PreUpdateEntryEvent is triggered when an entry is about to be updated.", + " Prevent default to skip the default update and override the update." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PostUpdateEntryEvent", + "formattedName": "PostUpdateEntryEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PreUpdateEntryProgressEvent", + "formattedName": "PreUpdateEntryProgressEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TotalCount", + "jsonName": "totalCount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " PreUpdateEntryProgressEvent is triggered when an entry's progress is about to be updated.", + " Prevent default to skip the default update and override the update." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PostUpdateEntryProgressEvent", + "formattedName": "PostUpdateEntryProgressEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PreUpdateEntryRepeatEvent", + "formattedName": "PreUpdateEntryRepeatEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Repeat", + "jsonName": "repeat", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " PreUpdateEntryRepeatEvent is triggered when an entry's repeat is about to be updated.", + " Prevent default to skip the default update and override the update." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "anilist_platform", + "goStruct": { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PostUpdateEntryRepeatEvent", + "formattedName": "PostUpdateEntryRepeatEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "torrentstream", + "goStruct": { + "filepath": "../internal/torrentstream/hook_events.go", + "filename": "hook_events.go", + "name": "TorrentStreamAutoSelectTorrentsFetchedEvent", + "formattedName": "Torrentstream_TorrentStreamAutoSelectTorrentsFetchedEvent", + "package": "torrentstream", + "fields": [ + { + "name": "Torrents", + "jsonName": "Torrents", + "goType": "[]hibiketorrent.AnimeTorrent", + "typescriptType": "Array\u003cHibikeTorrent_AnimeTorrent\u003e", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " TorrentStreamAutoSelectTorrentsFetchedEvent is triggered when the torrents are fetched for auto select.", + " The torrents are sorted by seeders from highest to lowest.", + " This event is triggered before the top 3 torrents are analyzed." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + }, + { + "package": "torrentstream", + "goStruct": { + "filepath": "../internal/torrentstream/hook_events.go", + "filename": "hook_events.go", + "name": "TorrentStreamSendStreamToMediaPlayerEvent", + "formattedName": "Torrentstream_TorrentStreamSendStreamToMediaPlayerEvent", + "package": "torrentstream", + "fields": [ + { + "name": "WindowTitle", + "jsonName": "windowTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamURL", + "jsonName": "streamURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AniDbEpisode", + "jsonName": "aniDbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PlaybackType", + "jsonName": "playbackType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " TorrentStreamSendStreamToMediaPlayerEvent is triggered when the torrent stream is about to send a stream to the media player.", + " Prevent default to skip the default playback and override the playback." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + } + } +] diff --git a/seanime-2.9.10/codegen/generated/public_structs.json b/seanime-2.9.10/codegen/generated/public_structs.json new file mode 100644 index 0000000..a7d0f8d --- /dev/null +++ b/seanime-2.9.10/codegen/generated/public_structs.json @@ -0,0 +1,81976 @@ +[ + { + "filepath": "../internal/api/anilist/client.go", + "filename": "client.go", + "name": "AnilistClientImpl", + "formattedName": "AL_AnilistClientImpl", + "package": "anilist", + "fields": [ + { + "name": "Client", + "jsonName": "Client", + "goType": "Client", + "typescriptType": "AL_Client", + "usedTypescriptType": "AL_Client", + "usedStructName": "anilist.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "token", + "jsonName": "token", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [ + " The token used for authentication with the AniList API" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "Client", + "formattedName": "AL_Client", + "package": "anilist", + "fields": [ + { + "name": "Client", + "jsonName": "Client", + "goType": "clientv2.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "clientv2.Client", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnime", + "formattedName": "AL_BaseAnime", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IDMal", + "jsonName": "idMal", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaStatus", + "typescriptType": "AL_MediaStatus", + "usedTypescriptType": "AL_MediaStatus", + "usedStructName": "anilist.MediaStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "MediaSeason", + "typescriptType": "AL_MediaSeason", + "usedTypescriptType": "AL_MediaSeason", + "usedStructName": "anilist.MediaSeason", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "MediaType", + "typescriptType": "AL_MediaType", + "usedTypescriptType": "AL_MediaType", + "usedStructName": "anilist.MediaType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SeasonYear", + "jsonName": "seasonYear", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BannerImage", + "jsonName": "bannerImage", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Synonyms", + "jsonName": "synonyms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsAdult", + "jsonName": "isAdult", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CountryOfOrigin", + "jsonName": "countryOfOrigin", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Trailer", + "jsonName": "trailer", + "goType": "BaseAnime_Trailer", + "typescriptType": "AL_BaseAnime_Trailer", + "usedTypescriptType": "AL_BaseAnime_Trailer", + "usedStructName": "anilist.BaseAnime_Trailer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "BaseAnime_Title", + "typescriptType": "AL_BaseAnime_Title", + "usedTypescriptType": "AL_BaseAnime_Title", + "usedStructName": "anilist.BaseAnime_Title", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CoverImage", + "jsonName": "coverImage", + "goType": "BaseAnime_CoverImage", + "typescriptType": "AL_BaseAnime_CoverImage", + "usedTypescriptType": "AL_BaseAnime_CoverImage", + "usedStructName": "anilist.BaseAnime_CoverImage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "startDate", + "goType": "BaseAnime_StartDate", + "typescriptType": "AL_BaseAnime_StartDate", + "usedTypescriptType": "AL_BaseAnime_StartDate", + "usedStructName": "anilist.BaseAnime_StartDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EndDate", + "jsonName": "endDate", + "goType": "BaseAnime_EndDate", + "typescriptType": "AL_BaseAnime_EndDate", + "usedTypescriptType": "AL_BaseAnime_EndDate", + "usedStructName": "anilist.BaseAnime_EndDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NextAiringEpisode", + "jsonName": "nextAiringEpisode", + "goType": "BaseAnime_NextAiringEpisode", + "typescriptType": "AL_BaseAnime_NextAiringEpisode", + "usedTypescriptType": "AL_BaseAnime_NextAiringEpisode", + "usedStructName": "anilist.BaseAnime_NextAiringEpisode", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime", + "formattedName": "AL_CompleteAnime", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IDMal", + "jsonName": "idMal", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaStatus", + "typescriptType": "AL_MediaStatus", + "usedTypescriptType": "AL_MediaStatus", + "usedStructName": "anilist.MediaStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "MediaSeason", + "typescriptType": "AL_MediaSeason", + "usedTypescriptType": "AL_MediaSeason", + "usedStructName": "anilist.MediaSeason", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SeasonYear", + "jsonName": "seasonYear", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "MediaType", + "typescriptType": "AL_MediaType", + "usedTypescriptType": "AL_MediaType", + "usedStructName": "anilist.MediaType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BannerImage", + "jsonName": "bannerImage", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Synonyms", + "jsonName": "synonyms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsAdult", + "jsonName": "isAdult", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CountryOfOrigin", + "jsonName": "countryOfOrigin", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Trailer", + "jsonName": "trailer", + "goType": "CompleteAnime_Trailer", + "typescriptType": "AL_CompleteAnime_Trailer", + "usedTypescriptType": "AL_CompleteAnime_Trailer", + "usedStructName": "anilist.CompleteAnime_Trailer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "CompleteAnime_Title", + "typescriptType": "AL_CompleteAnime_Title", + "usedTypescriptType": "AL_CompleteAnime_Title", + "usedStructName": "anilist.CompleteAnime_Title", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CoverImage", + "jsonName": "coverImage", + "goType": "CompleteAnime_CoverImage", + "typescriptType": "AL_CompleteAnime_CoverImage", + "usedTypescriptType": "AL_CompleteAnime_CoverImage", + "usedStructName": "anilist.CompleteAnime_CoverImage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "startDate", + "goType": "CompleteAnime_StartDate", + "typescriptType": "AL_CompleteAnime_StartDate", + "usedTypescriptType": "AL_CompleteAnime_StartDate", + "usedStructName": "anilist.CompleteAnime_StartDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EndDate", + "jsonName": "endDate", + "goType": "CompleteAnime_EndDate", + "typescriptType": "AL_CompleteAnime_EndDate", + "usedTypescriptType": "AL_CompleteAnime_EndDate", + "usedStructName": "anilist.CompleteAnime_EndDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NextAiringEpisode", + "jsonName": "nextAiringEpisode", + "goType": "CompleteAnime_NextAiringEpisode", + "typescriptType": "AL_CompleteAnime_NextAiringEpisode", + "usedTypescriptType": "AL_CompleteAnime_NextAiringEpisode", + "usedStructName": "anilist.CompleteAnime_NextAiringEpisode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Relations", + "jsonName": "relations", + "goType": "CompleteAnime_Relations", + "typescriptType": "AL_CompleteAnime_Relations", + "usedTypescriptType": "AL_CompleteAnime_Relations", + "usedStructName": "anilist.CompleteAnime_Relations", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseCharacter", + "formattedName": "AL_BaseCharacter", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsFavourite", + "jsonName": "isFavourite", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Gender", + "jsonName": "gender", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Age", + "jsonName": "age", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DateOfBirth", + "jsonName": "dateOfBirth", + "goType": "BaseCharacter_DateOfBirth", + "typescriptType": "AL_BaseCharacter_DateOfBirth", + "usedTypescriptType": "AL_BaseCharacter_DateOfBirth", + "usedStructName": "anilist.BaseCharacter_DateOfBirth", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "BaseCharacter_Name", + "typescriptType": "AL_BaseCharacter_Name", + "usedTypescriptType": "AL_BaseCharacter_Name", + "usedStructName": "anilist.BaseCharacter_Name", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "BaseCharacter_Image", + "typescriptType": "AL_BaseCharacter_Image", + "usedTypescriptType": "AL_BaseCharacter_Image", + "usedStructName": "anilist.BaseCharacter_Image", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeSchedule", + "formattedName": "AL_AnimeSchedule", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IDMal", + "jsonName": "idMal", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Previous", + "jsonName": "previous", + "goType": "AnimeSchedule_Previous", + "typescriptType": "AL_AnimeSchedule_Previous", + "usedTypescriptType": "AL_AnimeSchedule_Previous", + "usedStructName": "anilist.AnimeSchedule_Previous", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Upcoming", + "jsonName": "upcoming", + "goType": "AnimeSchedule_Upcoming", + "typescriptType": "AL_AnimeSchedule_Upcoming", + "usedTypescriptType": "AL_AnimeSchedule_Upcoming", + "usedStructName": "anilist.AnimeSchedule_Upcoming", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseManga", + "formattedName": "AL_BaseManga", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IDMal", + "jsonName": "idMal", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaStatus", + "typescriptType": "AL_MediaStatus", + "usedTypescriptType": "AL_MediaStatus", + "usedStructName": "anilist.MediaStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "MediaSeason", + "typescriptType": "AL_MediaSeason", + "usedTypescriptType": "AL_MediaSeason", + "usedStructName": "anilist.MediaSeason", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "MediaType", + "typescriptType": "AL_MediaType", + "usedTypescriptType": "AL_MediaType", + "usedStructName": "anilist.MediaType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BannerImage", + "jsonName": "bannerImage", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Chapters", + "jsonName": "chapters", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Volumes", + "jsonName": "volumes", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Synonyms", + "jsonName": "synonyms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsAdult", + "jsonName": "isAdult", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CountryOfOrigin", + "jsonName": "countryOfOrigin", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "BaseManga_Title", + "typescriptType": "AL_BaseManga_Title", + "usedTypescriptType": "AL_BaseManga_Title", + "usedStructName": "anilist.BaseManga_Title", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CoverImage", + "jsonName": "coverImage", + "goType": "BaseManga_CoverImage", + "typescriptType": "AL_BaseManga_CoverImage", + "usedTypescriptType": "AL_BaseManga_CoverImage", + "usedStructName": "anilist.BaseManga_CoverImage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "startDate", + "goType": "BaseManga_StartDate", + "typescriptType": "AL_BaseManga_StartDate", + "usedTypescriptType": "AL_BaseManga_StartDate", + "usedStructName": "anilist.BaseManga_StartDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EndDate", + "jsonName": "endDate", + "goType": "BaseManga_EndDate", + "typescriptType": "AL_BaseManga_EndDate", + "usedTypescriptType": "AL_BaseManga_EndDate", + "usedStructName": "anilist.BaseManga_EndDate", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UserFormatStats", + "formattedName": "AL_UserFormatStats", + "package": "anilist", + "fields": [ + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UserGenreStats", + "formattedName": "AL_UserGenreStats", + "package": "anilist", + "fields": [ + { + "name": "Genre", + "jsonName": "genre", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UserStatusStats", + "formattedName": "AL_UserStatusStats", + "package": "anilist", + "fields": [ + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UserScoreStats", + "formattedName": "AL_UserScoreStats", + "package": "anilist", + "fields": [ + { + "name": "Score", + "jsonName": "score", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UserStudioStats", + "formattedName": "AL_UserStudioStats", + "package": "anilist", + "fields": [ + { + "name": "Studio", + "jsonName": "studio", + "goType": "UserStudioStats_Studio", + "typescriptType": "AL_UserStudioStats_Studio", + "usedTypescriptType": "AL_UserStudioStats_Studio", + "usedStructName": "anilist.UserStudioStats_Studio", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UserStartYearStats", + "formattedName": "AL_UserStartYearStats", + "package": "anilist", + "fields": [ + { + "name": "StartYear", + "jsonName": "startYear", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UserReleaseYearStats", + "formattedName": "AL_UserReleaseYearStats", + "package": "anilist", + "fields": [ + { + "name": "ReleaseYear", + "jsonName": "releaseYear", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnime_Trailer", + "formattedName": "AL_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnime_Title", + "formattedName": "AL_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnime_CoverImage", + "formattedName": "AL_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnime_StartDate", + "formattedName": "AL_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnime_EndDate", + "formattedName": "AL_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnime_NextAiringEpisode", + "formattedName": "AL_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_Trailer", + "formattedName": "AL_CompleteAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_Title", + "formattedName": "AL_CompleteAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_CoverImage", + "formattedName": "AL_CompleteAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_StartDate", + "formattedName": "AL_CompleteAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_EndDate", + "formattedName": "AL_CompleteAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_NextAiringEpisode", + "formattedName": "AL_CompleteAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer", + "formattedName": "AL_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_Relations_Edges_Node_BaseAnime_Title", + "formattedName": "AL_CompleteAnime_Relations_Edges_Node_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage", + "formattedName": "AL_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate", + "formattedName": "AL_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate", + "formattedName": "AL_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode", + "formattedName": "AL_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_Relations_Edges", + "formattedName": "AL_CompleteAnime_Relations_Edges", + "package": "anilist", + "fields": [ + { + "name": "RelationType", + "jsonName": "relationType", + "goType": "MediaRelation", + "typescriptType": "AL_MediaRelation", + "usedTypescriptType": "AL_MediaRelation", + "usedStructName": "anilist.MediaRelation", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Node", + "jsonName": "node", + "goType": "BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnime_Relations", + "formattedName": "AL_CompleteAnime_Relations", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]CompleteAnime_Relations_Edges", + "typescriptType": "Array\u003cAL_CompleteAnime_Relations_Edges\u003e", + "usedTypescriptType": "AL_CompleteAnime_Relations_Edges", + "usedStructName": "anilist.CompleteAnime_Relations_Edges", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseCharacter_DateOfBirth", + "formattedName": "AL_BaseCharacter_DateOfBirth", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseCharacter_Name", + "formattedName": "AL_BaseCharacter_Name", + "package": "anilist", + "fields": [ + { + "name": "Full", + "jsonName": "full", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Alternative", + "jsonName": "alternative", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseCharacter_Image", + "formattedName": "AL_BaseCharacter_Image", + "package": "anilist", + "fields": [ + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeSchedule_Previous_Nodes", + "formattedName": "AL_AnimeSchedule_Previous_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeSchedule_Previous", + "formattedName": "AL_AnimeSchedule_Previous", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeSchedule_Previous_Nodes", + "typescriptType": "Array\u003cAL_AnimeSchedule_Previous_Nodes\u003e", + "usedTypescriptType": "AL_AnimeSchedule_Previous_Nodes", + "usedStructName": "anilist.AnimeSchedule_Previous_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeSchedule_Upcoming_Nodes", + "formattedName": "AL_AnimeSchedule_Upcoming_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeSchedule_Upcoming", + "formattedName": "AL_AnimeSchedule_Upcoming", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeSchedule_Upcoming_Nodes", + "typescriptType": "Array\u003cAL_AnimeSchedule_Upcoming_Nodes\u003e", + "usedTypescriptType": "AL_AnimeSchedule_Upcoming_Nodes", + "usedStructName": "anilist.AnimeSchedule_Upcoming_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseManga_Title", + "formattedName": "AL_BaseManga_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseManga_CoverImage", + "formattedName": "AL_BaseManga_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseManga_StartDate", + "formattedName": "AL_BaseManga_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseManga_EndDate", + "formattedName": "AL_BaseManga_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UserStudioStats_Studio", + "formattedName": "AL_UserStudioStats_Studio", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsAnimationStudio", + "jsonName": "isAnimationStudio", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection_MediaListCollection_Lists_Entries_StartedAt", + "formattedName": "AL_AnimeCollection_MediaListCollection_Lists_Entries_StartedAt", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt", + "formattedName": "AL_AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Trailer", + "formattedName": "AL_AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Title", + "formattedName": "AL_AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_CoverImage", + "formattedName": "AL_AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_StartDate", + "formattedName": "AL_AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_EndDate", + "formattedName": "AL_AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_NextAiringEpisode", + "formattedName": "AL_AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection_MediaListCollection_Lists_Entries", + "formattedName": "AL_AnimeCollection_MediaListCollection_Lists_Entries", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Notes", + "jsonName": "notes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Repeat", + "jsonName": "repeat", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Private", + "jsonName": "private", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartedAt", + "jsonName": "startedAt", + "goType": "AnimeCollection_MediaListCollection_Lists_Entries_StartedAt", + "typescriptType": "AL_AnimeCollection_MediaListCollection_Lists_Entries_StartedAt", + "usedTypescriptType": "AL_AnimeCollection_MediaListCollection_Lists_Entries_StartedAt", + "usedStructName": "anilist.AnimeCollection_MediaListCollection_Lists_Entries_StartedAt", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompletedAt", + "jsonName": "completedAt", + "goType": "AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt", + "typescriptType": "AL_AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt", + "usedTypescriptType": "AL_AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt", + "usedStructName": "anilist.AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection_MediaListCollection_Lists", + "formattedName": "AL_AnimeCollection_MediaListCollection_Lists", + "package": "anilist", + "fields": [ + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsCustomList", + "jsonName": "isCustomList", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Entries", + "jsonName": "entries", + "goType": "[]AnimeCollection_MediaListCollection_Lists_Entries", + "typescriptType": "Array\u003cAL_AnimeCollection_MediaListCollection_Lists_Entries\u003e", + "usedTypescriptType": "AL_AnimeCollection_MediaListCollection_Lists_Entries", + "usedStructName": "anilist.AnimeCollection_MediaListCollection_Lists_Entries", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection_MediaListCollection", + "formattedName": "AL_AnimeCollection_MediaListCollection", + "package": "anilist", + "fields": [ + { + "name": "Lists", + "jsonName": "lists", + "goType": "[]AnimeCollection_MediaListCollection_Lists", + "typescriptType": "Array\u003cAL_AnimeCollection_MediaListCollection_Lists\u003e", + "usedTypescriptType": "AL_AnimeCollection_MediaListCollection_Lists", + "usedStructName": "anilist.AnimeCollection_MediaListCollection_Lists", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Trailer", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Title", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_CoverImage", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_StartDate", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_EndDate", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_NextAiringEpisode", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges", + "package": "anilist", + "fields": [ + { + "name": "RelationType", + "jsonName": "relationType", + "goType": "MediaRelation", + "typescriptType": "AL_MediaRelation", + "usedTypescriptType": "AL_MediaRelation", + "usedStructName": "anilist.MediaRelation", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Node", + "jsonName": "node", + "goType": "BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges", + "typescriptType": "Array\u003cAL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges\u003e", + "usedTypescriptType": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges", + "usedStructName": "anilist.AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Notes", + "jsonName": "notes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Repeat", + "jsonName": "repeat", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Private", + "jsonName": "private", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartedAt", + "jsonName": "startedAt", + "goType": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt", + "typescriptType": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt", + "usedTypescriptType": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt", + "usedStructName": "anilist.AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompletedAt", + "jsonName": "completedAt", + "goType": "AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt", + "typescriptType": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt", + "usedTypescriptType": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt", + "usedStructName": "anilist.AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "CompleteAnime", + "typescriptType": "AL_CompleteAnime", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection_Lists", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists", + "package": "anilist", + "fields": [ + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsCustomList", + "jsonName": "isCustomList", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Entries", + "jsonName": "entries", + "goType": "[]AnimeCollectionWithRelations_MediaListCollection_Lists_Entries", + "typescriptType": "Array\u003cAL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries\u003e", + "usedTypescriptType": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries", + "usedStructName": "anilist.AnimeCollectionWithRelations_MediaListCollection_Lists_Entries", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations_MediaListCollection", + "formattedName": "AL_AnimeCollectionWithRelations_MediaListCollection", + "package": "anilist", + "fields": [ + { + "name": "Lists", + "jsonName": "lists", + "goType": "[]AnimeCollectionWithRelations_MediaListCollection_Lists", + "typescriptType": "Array\u003cAL_AnimeCollectionWithRelations_MediaListCollection_Lists\u003e", + "usedTypescriptType": "AL_AnimeCollectionWithRelations_MediaListCollection_Lists", + "usedStructName": "anilist.AnimeCollectionWithRelations_MediaListCollection_Lists", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeByMalId_Media_BaseAnime_Trailer", + "formattedName": "AL_BaseAnimeByMalId_Media_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeByMalId_Media_BaseAnime_Title", + "formattedName": "AL_BaseAnimeByMalId_Media_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeByMalId_Media_BaseAnime_CoverImage", + "formattedName": "AL_BaseAnimeByMalId_Media_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeByMalId_Media_BaseAnime_StartDate", + "formattedName": "AL_BaseAnimeByMalId_Media_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeByMalId_Media_BaseAnime_EndDate", + "formattedName": "AL_BaseAnimeByMalId_Media_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeByMalId_Media_BaseAnime_NextAiringEpisode", + "formattedName": "AL_BaseAnimeByMalId_Media_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeById_Media_BaseAnime_Trailer", + "formattedName": "AL_BaseAnimeById_Media_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeById_Media_BaseAnime_Title", + "formattedName": "AL_BaseAnimeById_Media_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeById_Media_BaseAnime_CoverImage", + "formattedName": "AL_BaseAnimeById_Media_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeById_Media_BaseAnime_StartDate", + "formattedName": "AL_BaseAnimeById_Media_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeById_Media_BaseAnime_EndDate", + "formattedName": "AL_BaseAnimeById_Media_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeById_Media_BaseAnime_NextAiringEpisode", + "formattedName": "AL_BaseAnimeById_Media_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseAnimeByIds_Page_PageInfo", + "formattedName": "AL_SearchBaseAnimeByIds_Page_PageInfo", + "package": "anilist", + "fields": [ + { + "name": "HasNextPage", + "jsonName": "hasNextPage", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseAnimeByIds_Page_Media_BaseAnime_Trailer", + "formattedName": "AL_SearchBaseAnimeByIds_Page_Media_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseAnimeByIds_Page_Media_BaseAnime_Title", + "formattedName": "AL_SearchBaseAnimeByIds_Page_Media_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseAnimeByIds_Page_Media_BaseAnime_CoverImage", + "formattedName": "AL_SearchBaseAnimeByIds_Page_Media_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseAnimeByIds_Page_Media_BaseAnime_StartDate", + "formattedName": "AL_SearchBaseAnimeByIds_Page_Media_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseAnimeByIds_Page_Media_BaseAnime_EndDate", + "formattedName": "AL_SearchBaseAnimeByIds_Page_Media_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseAnimeByIds_Page_Media_BaseAnime_NextAiringEpisode", + "formattedName": "AL_SearchBaseAnimeByIds_Page_Media_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseAnimeByIds_Page", + "formattedName": "AL_SearchBaseAnimeByIds_Page", + "package": "anilist", + "fields": [ + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "SearchBaseAnimeByIds_Page_PageInfo", + "typescriptType": "AL_SearchBaseAnimeByIds_Page_PageInfo", + "usedTypescriptType": "AL_SearchBaseAnimeByIds_Page_PageInfo", + "usedStructName": "anilist.SearchBaseAnimeByIds_Page_PageInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "[]BaseAnime", + "typescriptType": "Array\u003cAL_BaseAnime\u003e", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_Trailer", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_Title", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_CoverImage", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_StartDate", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_EndDate", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_NextAiringEpisode", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_Relations_Edges", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_Relations_Edges", + "package": "anilist", + "fields": [ + { + "name": "RelationType", + "jsonName": "relationType", + "goType": "MediaRelation", + "typescriptType": "AL_MediaRelation", + "usedTypescriptType": "AL_MediaRelation", + "usedStructName": "anilist.MediaRelation", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Node", + "jsonName": "node", + "goType": "BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeById_Media_CompleteAnime_Relations", + "formattedName": "AL_CompleteAnimeById_Media_CompleteAnime_Relations", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]CompleteAnimeById_Media_CompleteAnime_Relations_Edges", + "typescriptType": "Array\u003cAL_CompleteAnimeById_Media_CompleteAnime_Relations_Edges\u003e", + "usedTypescriptType": "AL_CompleteAnimeById_Media_CompleteAnime_Relations_Edges", + "usedStructName": "anilist.CompleteAnimeById_Media_CompleteAnime_Relations_Edges", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Trailer", + "formattedName": "AL_AnimeDetailsById_Media_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_StartDate", + "formattedName": "AL_AnimeDetailsById_Media_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_EndDate", + "formattedName": "AL_AnimeDetailsById_Media_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Studios_Nodes", + "formattedName": "AL_AnimeDetailsById_Media_Studios_Nodes", + "package": "anilist", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Studios", + "formattedName": "AL_AnimeDetailsById_Media_Studios", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeDetailsById_Media_Studios_Nodes", + "typescriptType": "Array\u003cAL_AnimeDetailsById_Media_Studios_Nodes\u003e", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Studios_Nodes", + "usedStructName": "anilist.AnimeDetailsById_Media_Studios_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth", + "formattedName": "AL_AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name", + "formattedName": "AL_AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name", + "package": "anilist", + "fields": [ + { + "name": "Full", + "jsonName": "full", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Alternative", + "jsonName": "alternative", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Image", + "formattedName": "AL_AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Image", + "package": "anilist", + "fields": [ + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Characters_Edges", + "formattedName": "AL_AnimeDetailsById_Media_Characters_Edges", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Role", + "jsonName": "role", + "goType": "CharacterRole", + "typescriptType": "AL_CharacterRole", + "usedTypescriptType": "AL_CharacterRole", + "usedStructName": "anilist.CharacterRole", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Node", + "jsonName": "node", + "goType": "BaseCharacter", + "typescriptType": "AL_BaseCharacter", + "usedTypescriptType": "AL_BaseCharacter", + "usedStructName": "anilist.BaseCharacter", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Characters", + "formattedName": "AL_AnimeDetailsById_Media_Characters", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]AnimeDetailsById_Media_Characters_Edges", + "typescriptType": "Array\u003cAL_AnimeDetailsById_Media_Characters_Edges\u003e", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Characters_Edges", + "usedStructName": "anilist.AnimeDetailsById_Media_Characters_Edges", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Staff_Edges_Node_Name", + "formattedName": "AL_AnimeDetailsById_Media_Staff_Edges_Node_Name", + "package": "anilist", + "fields": [ + { + "name": "Full", + "jsonName": "full", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Staff_Edges_Node", + "formattedName": "AL_AnimeDetailsById_Media_Staff_Edges_Node", + "package": "anilist", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "AnimeDetailsById_Media_Staff_Edges_Node_Name", + "typescriptType": "AL_AnimeDetailsById_Media_Staff_Edges_Node_Name", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Staff_Edges_Node_Name", + "usedStructName": "anilist.AnimeDetailsById_Media_Staff_Edges_Node_Name", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Staff_Edges", + "formattedName": "AL_AnimeDetailsById_Media_Staff_Edges", + "package": "anilist", + "fields": [ + { + "name": "Role", + "jsonName": "role", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Node", + "jsonName": "node", + "goType": "AnimeDetailsById_Media_Staff_Edges_Node", + "typescriptType": "AL_AnimeDetailsById_Media_Staff_Edges_Node", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Staff_Edges_Node", + "usedStructName": "anilist.AnimeDetailsById_Media_Staff_Edges_Node", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Staff", + "formattedName": "AL_AnimeDetailsById_Media_Staff", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]AnimeDetailsById_Media_Staff_Edges", + "typescriptType": "Array\u003cAL_AnimeDetailsById_Media_Staff_Edges\u003e", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Staff_Edges", + "usedStructName": "anilist.AnimeDetailsById_Media_Staff_Edges", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Rankings", + "formattedName": "AL_AnimeDetailsById_Media_Rankings", + "package": "anilist", + "fields": [ + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "MediaRankType", + "typescriptType": "AL_MediaRankType", + "usedTypescriptType": "AL_MediaRankType", + "usedStructName": "anilist.MediaRankType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Rank", + "jsonName": "rank", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AllTime", + "jsonName": "allTime", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "MediaSeason", + "typescriptType": "AL_MediaSeason", + "usedTypescriptType": "AL_MediaSeason", + "usedStructName": "anilist.MediaSeason", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer", + "formattedName": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "formattedName": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "formattedName": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "formattedName": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "package": "anilist", + "fields": [ + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "formattedName": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IDMal", + "jsonName": "idMal", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaStatus", + "typescriptType": "AL_MediaStatus", + "usedTypescriptType": "AL_MediaStatus", + "usedStructName": "anilist.MediaStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsAdult", + "jsonName": "isAdult", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "MediaSeason", + "typescriptType": "AL_MediaSeason", + "usedTypescriptType": "AL_MediaSeason", + "usedStructName": "anilist.MediaSeason", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "MediaType", + "typescriptType": "AL_MediaType", + "usedTypescriptType": "AL_MediaType", + "usedStructName": "anilist.MediaType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Trailer", + "jsonName": "trailer", + "goType": "AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer", + "typescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer", + "usedStructName": "anilist.AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "startDate", + "goType": "AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "typescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "usedStructName": "anilist.AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CoverImage", + "jsonName": "coverImage", + "goType": "AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "typescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "usedStructName": "anilist.AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BannerImage", + "jsonName": "bannerImage", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "typescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "usedStructName": "anilist.AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Recommendations_Edges_Node", + "formattedName": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node", + "package": "anilist", + "fields": [ + { + "name": "MediaRecommendation", + "jsonName": "mediaRecommendation", + "goType": "AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "typescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "usedStructName": "anilist.AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Recommendations_Edges", + "formattedName": "AL_AnimeDetailsById_Media_Recommendations_Edges", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "AnimeDetailsById_Media_Recommendations_Edges_Node", + "typescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges_Node", + "usedStructName": "anilist.AnimeDetailsById_Media_Recommendations_Edges_Node", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Recommendations", + "formattedName": "AL_AnimeDetailsById_Media_Recommendations", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]AnimeDetailsById_Media_Recommendations_Edges", + "typescriptType": "Array\u003cAL_AnimeDetailsById_Media_Recommendations_Edges\u003e", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Recommendations_Edges", + "usedStructName": "anilist.AnimeDetailsById_Media_Recommendations_Edges", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Trailer", + "formattedName": "AL_AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Title", + "formattedName": "AL_AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_CoverImage", + "formattedName": "AL_AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_StartDate", + "formattedName": "AL_AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_EndDate", + "formattedName": "AL_AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_NextAiringEpisode", + "formattedName": "AL_AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Relations_Edges", + "formattedName": "AL_AnimeDetailsById_Media_Relations_Edges", + "package": "anilist", + "fields": [ + { + "name": "RelationType", + "jsonName": "relationType", + "goType": "MediaRelation", + "typescriptType": "AL_MediaRelation", + "usedTypescriptType": "AL_MediaRelation", + "usedStructName": "anilist.MediaRelation", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Node", + "jsonName": "node", + "goType": "BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media_Relations", + "formattedName": "AL_AnimeDetailsById_Media_Relations", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]AnimeDetailsById_Media_Relations_Edges", + "typescriptType": "Array\u003cAL_AnimeDetailsById_Media_Relations_Edges\u003e", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Relations_Edges", + "usedStructName": "anilist.AnimeDetailsById_Media_Relations_Edges", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsById_Media", + "formattedName": "AL_AnimeDetailsById_Media", + "package": "anilist", + "fields": [ + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AverageScore", + "jsonName": "averageScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Popularity", + "jsonName": "popularity", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Trailer", + "jsonName": "trailer", + "goType": "AnimeDetailsById_Media_Trailer", + "typescriptType": "AL_AnimeDetailsById_Media_Trailer", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Trailer", + "usedStructName": "anilist.AnimeDetailsById_Media_Trailer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "startDate", + "goType": "AnimeDetailsById_Media_StartDate", + "typescriptType": "AL_AnimeDetailsById_Media_StartDate", + "usedTypescriptType": "AL_AnimeDetailsById_Media_StartDate", + "usedStructName": "anilist.AnimeDetailsById_Media_StartDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EndDate", + "jsonName": "endDate", + "goType": "AnimeDetailsById_Media_EndDate", + "typescriptType": "AL_AnimeDetailsById_Media_EndDate", + "usedTypescriptType": "AL_AnimeDetailsById_Media_EndDate", + "usedStructName": "anilist.AnimeDetailsById_Media_EndDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studios", + "jsonName": "studios", + "goType": "AnimeDetailsById_Media_Studios", + "typescriptType": "AL_AnimeDetailsById_Media_Studios", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Studios", + "usedStructName": "anilist.AnimeDetailsById_Media_Studios", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Characters", + "jsonName": "characters", + "goType": "AnimeDetailsById_Media_Characters", + "typescriptType": "AL_AnimeDetailsById_Media_Characters", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Characters", + "usedStructName": "anilist.AnimeDetailsById_Media_Characters", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "AnimeDetailsById_Media_Staff", + "typescriptType": "AL_AnimeDetailsById_Media_Staff", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Staff", + "usedStructName": "anilist.AnimeDetailsById_Media_Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rankings", + "jsonName": "rankings", + "goType": "[]AnimeDetailsById_Media_Rankings", + "typescriptType": "Array\u003cAL_AnimeDetailsById_Media_Rankings\u003e", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Rankings", + "usedStructName": "anilist.AnimeDetailsById_Media_Rankings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Recommendations", + "jsonName": "recommendations", + "goType": "AnimeDetailsById_Media_Recommendations", + "typescriptType": "AL_AnimeDetailsById_Media_Recommendations", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Recommendations", + "usedStructName": "anilist.AnimeDetailsById_Media_Recommendations", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Relations", + "jsonName": "relations", + "goType": "AnimeDetailsById_Media_Relations", + "typescriptType": "AL_AnimeDetailsById_Media_Relations", + "usedTypescriptType": "AL_AnimeDetailsById_Media_Relations", + "usedStructName": "anilist.AnimeDetailsById_Media_Relations", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListAnime_Page_PageInfo", + "formattedName": "AL_ListAnime_Page_PageInfo", + "package": "anilist", + "fields": [ + { + "name": "HasNextPage", + "jsonName": "hasNextPage", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Total", + "jsonName": "total", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PerPage", + "jsonName": "perPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CurrentPage", + "jsonName": "currentPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LastPage", + "jsonName": "lastPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListAnime_Page_Media_BaseAnime_Trailer", + "formattedName": "AL_ListAnime_Page_Media_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListAnime_Page_Media_BaseAnime_Title", + "formattedName": "AL_ListAnime_Page_Media_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListAnime_Page_Media_BaseAnime_CoverImage", + "formattedName": "AL_ListAnime_Page_Media_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListAnime_Page_Media_BaseAnime_StartDate", + "formattedName": "AL_ListAnime_Page_Media_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListAnime_Page_Media_BaseAnime_EndDate", + "formattedName": "AL_ListAnime_Page_Media_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListAnime_Page_Media_BaseAnime_NextAiringEpisode", + "formattedName": "AL_ListAnime_Page_Media_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListAnime_Page", + "formattedName": "AL_ListAnime_Page", + "package": "anilist", + "fields": [ + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "ListAnime_Page_PageInfo", + "typescriptType": "AL_ListAnime_Page_PageInfo", + "usedTypescriptType": "AL_ListAnime_Page_PageInfo", + "usedStructName": "anilist.ListAnime_Page_PageInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "[]BaseAnime", + "typescriptType": "Array\u003cAL_BaseAnime\u003e", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListRecentAnime_Page_PageInfo", + "formattedName": "AL_ListRecentAnime_Page_PageInfo", + "package": "anilist", + "fields": [ + { + "name": "HasNextPage", + "jsonName": "hasNextPage", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Total", + "jsonName": "total", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PerPage", + "jsonName": "perPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CurrentPage", + "jsonName": "currentPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LastPage", + "jsonName": "lastPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Trailer", + "formattedName": "AL_ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Title", + "formattedName": "AL_ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_CoverImage", + "formattedName": "AL_ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_StartDate", + "formattedName": "AL_ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_EndDate", + "formattedName": "AL_ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_NextAiringEpisode", + "formattedName": "AL_ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListRecentAnime_Page_AiringSchedules", + "formattedName": "AL_ListRecentAnime_Page_AiringSchedules", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListRecentAnime_Page", + "formattedName": "AL_ListRecentAnime_Page", + "package": "anilist", + "fields": [ + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "ListRecentAnime_Page_PageInfo", + "typescriptType": "AL_ListRecentAnime_Page_PageInfo", + "usedTypescriptType": "AL_ListRecentAnime_Page_PageInfo", + "usedStructName": "anilist.ListRecentAnime_Page_PageInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AiringSchedules", + "jsonName": "airingSchedules", + "goType": "[]ListRecentAnime_Page_AiringSchedules", + "typescriptType": "Array\u003cAL_ListRecentAnime_Page_AiringSchedules\u003e", + "usedTypescriptType": "AL_ListRecentAnime_Page_AiringSchedules", + "usedStructName": "anilist.ListRecentAnime_Page_AiringSchedules", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes", + "formattedName": "AL_AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous", + "formattedName": "AL_AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes", + "usedStructName": "anilist.AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes", + "formattedName": "AL_AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming", + "formattedName": "AL_AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes", + "usedStructName": "anilist.AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Ongoing", + "formattedName": "AL_AnimeAiringSchedule_Ongoing", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "[]AnimeSchedule", + "typescriptType": "Array\u003cAL_AnimeSchedule\u003e", + "usedTypescriptType": "AL_AnimeSchedule", + "usedStructName": "anilist.AnimeSchedule", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes", + "formattedName": "AL_AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous", + "formattedName": "AL_AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes", + "usedStructName": "anilist.AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes", + "formattedName": "AL_AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming", + "formattedName": "AL_AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes", + "usedStructName": "anilist.AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_OngoingNext", + "formattedName": "AL_AnimeAiringSchedule_OngoingNext", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "[]AnimeSchedule", + "typescriptType": "Array\u003cAL_AnimeSchedule\u003e", + "usedTypescriptType": "AL_AnimeSchedule", + "usedStructName": "anilist.AnimeSchedule", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes", + "formattedName": "AL_AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous", + "formattedName": "AL_AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes", + "usedStructName": "anilist.AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes", + "formattedName": "AL_AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming", + "formattedName": "AL_AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes", + "usedStructName": "anilist.AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Upcoming", + "formattedName": "AL_AnimeAiringSchedule_Upcoming", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "[]AnimeSchedule", + "typescriptType": "Array\u003cAL_AnimeSchedule\u003e", + "usedTypescriptType": "AL_AnimeSchedule", + "usedStructName": "anilist.AnimeSchedule", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes", + "formattedName": "AL_AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous", + "formattedName": "AL_AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes", + "usedStructName": "anilist.AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes", + "formattedName": "AL_AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming", + "formattedName": "AL_AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes", + "usedStructName": "anilist.AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_UpcomingNext", + "formattedName": "AL_AnimeAiringSchedule_UpcomingNext", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "[]AnimeSchedule", + "typescriptType": "Array\u003cAL_AnimeSchedule\u003e", + "usedTypescriptType": "AL_AnimeSchedule", + "usedStructName": "anilist.AnimeSchedule", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes", + "formattedName": "AL_AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous", + "formattedName": "AL_AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes", + "usedStructName": "anilist.AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes", + "formattedName": "AL_AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming", + "formattedName": "AL_AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes", + "usedStructName": "anilist.AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule_Preceding", + "formattedName": "AL_AnimeAiringSchedule_Preceding", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "[]AnimeSchedule", + "typescriptType": "Array\u003cAL_AnimeSchedule\u003e", + "usedTypescriptType": "AL_AnimeSchedule", + "usedStructName": "anilist.AnimeSchedule", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes", + "formattedName": "AL_AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous", + "formattedName": "AL_AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes", + "usedStructName": "anilist.AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes", + "formattedName": "AL_AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming", + "formattedName": "AL_AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes", + "typescriptType": "Array\u003cAL_AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes\u003e", + "usedTypescriptType": "AL_AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes", + "usedStructName": "anilist.AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringScheduleRaw_Page", + "formattedName": "AL_AnimeAiringScheduleRaw_Page", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "[]AnimeSchedule", + "typescriptType": "Array\u003cAL_AnimeSchedule\u003e", + "usedTypescriptType": "AL_AnimeSchedule", + "usedStructName": "anilist.AnimeSchedule", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UpdateMediaListEntry_SaveMediaListEntry", + "formattedName": "AL_UpdateMediaListEntry_SaveMediaListEntry", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UpdateMediaListEntryProgress_SaveMediaListEntry", + "formattedName": "AL_UpdateMediaListEntryProgress_SaveMediaListEntry", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "DeleteEntry_DeleteMediaListEntry", + "formattedName": "AL_DeleteEntry_DeleteMediaListEntry", + "package": "anilist", + "fields": [ + { + "name": "Deleted", + "jsonName": "deleted", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UpdateMediaListEntryRepeat_SaveMediaListEntry", + "formattedName": "AL_UpdateMediaListEntryRepeat_SaveMediaListEntry", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaCollection_MediaListCollection_Lists_Entries_StartedAt", + "formattedName": "AL_MangaCollection_MediaListCollection_Lists_Entries_StartedAt", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaCollection_MediaListCollection_Lists_Entries_CompletedAt", + "formattedName": "AL_MangaCollection_MediaListCollection_Lists_Entries_CompletedAt", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_Title", + "formattedName": "AL_MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_CoverImage", + "formattedName": "AL_MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_StartDate", + "formattedName": "AL_MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_EndDate", + "formattedName": "AL_MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaCollection_MediaListCollection_Lists_Entries", + "formattedName": "AL_MangaCollection_MediaListCollection_Lists_Entries", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Notes", + "jsonName": "notes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Repeat", + "jsonName": "repeat", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Private", + "jsonName": "private", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartedAt", + "jsonName": "startedAt", + "goType": "MangaCollection_MediaListCollection_Lists_Entries_StartedAt", + "typescriptType": "AL_MangaCollection_MediaListCollection_Lists_Entries_StartedAt", + "usedTypescriptType": "AL_MangaCollection_MediaListCollection_Lists_Entries_StartedAt", + "usedStructName": "anilist.MangaCollection_MediaListCollection_Lists_Entries_StartedAt", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompletedAt", + "jsonName": "completedAt", + "goType": "MangaCollection_MediaListCollection_Lists_Entries_CompletedAt", + "typescriptType": "AL_MangaCollection_MediaListCollection_Lists_Entries_CompletedAt", + "usedTypescriptType": "AL_MangaCollection_MediaListCollection_Lists_Entries_CompletedAt", + "usedStructName": "anilist.MangaCollection_MediaListCollection_Lists_Entries_CompletedAt", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "BaseManga", + "typescriptType": "AL_BaseManga", + "usedTypescriptType": "AL_BaseManga", + "usedStructName": "anilist.BaseManga", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaCollection_MediaListCollection_Lists", + "formattedName": "AL_MangaCollection_MediaListCollection_Lists", + "package": "anilist", + "fields": [ + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsCustomList", + "jsonName": "isCustomList", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Entries", + "jsonName": "entries", + "goType": "[]MangaCollection_MediaListCollection_Lists_Entries", + "typescriptType": "Array\u003cAL_MangaCollection_MediaListCollection_Lists_Entries\u003e", + "usedTypescriptType": "AL_MangaCollection_MediaListCollection_Lists_Entries", + "usedStructName": "anilist.MangaCollection_MediaListCollection_Lists_Entries", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaCollection_MediaListCollection", + "formattedName": "AL_MangaCollection_MediaListCollection", + "package": "anilist", + "fields": [ + { + "name": "Lists", + "jsonName": "lists", + "goType": "[]MangaCollection_MediaListCollection_Lists", + "typescriptType": "Array\u003cAL_MangaCollection_MediaListCollection_Lists\u003e", + "usedTypescriptType": "AL_MangaCollection_MediaListCollection_Lists", + "usedStructName": "anilist.MangaCollection_MediaListCollection_Lists", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseManga_Page_PageInfo", + "formattedName": "AL_SearchBaseManga_Page_PageInfo", + "package": "anilist", + "fields": [ + { + "name": "HasNextPage", + "jsonName": "hasNextPage", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseManga_Page_Media_BaseManga_Title", + "formattedName": "AL_SearchBaseManga_Page_Media_BaseManga_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseManga_Page_Media_BaseManga_CoverImage", + "formattedName": "AL_SearchBaseManga_Page_Media_BaseManga_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseManga_Page_Media_BaseManga_StartDate", + "formattedName": "AL_SearchBaseManga_Page_Media_BaseManga_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseManga_Page_Media_BaseManga_EndDate", + "formattedName": "AL_SearchBaseManga_Page_Media_BaseManga_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseManga_Page", + "formattedName": "AL_SearchBaseManga_Page", + "package": "anilist", + "fields": [ + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "SearchBaseManga_Page_PageInfo", + "typescriptType": "AL_SearchBaseManga_Page_PageInfo", + "usedTypescriptType": "AL_SearchBaseManga_Page_PageInfo", + "usedStructName": "anilist.SearchBaseManga_Page_PageInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "[]BaseManga", + "typescriptType": "Array\u003cAL_BaseManga\u003e", + "usedTypescriptType": "AL_BaseManga", + "usedStructName": "anilist.BaseManga", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseMangaById_Media_BaseManga_Title", + "formattedName": "AL_BaseMangaById_Media_BaseManga_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseMangaById_Media_BaseManga_CoverImage", + "formattedName": "AL_BaseMangaById_Media_BaseManga_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseMangaById_Media_BaseManga_StartDate", + "formattedName": "AL_BaseMangaById_Media_BaseManga_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseMangaById_Media_BaseManga_EndDate", + "formattedName": "AL_BaseMangaById_Media_BaseManga_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Rankings", + "formattedName": "AL_MangaDetailsById_Media_Rankings", + "package": "anilist", + "fields": [ + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "MediaRankType", + "typescriptType": "AL_MediaRankType", + "usedTypescriptType": "AL_MediaRankType", + "usedStructName": "anilist.MediaRankType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Rank", + "jsonName": "rank", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AllTime", + "jsonName": "allTime", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "MediaSeason", + "typescriptType": "AL_MediaSeason", + "usedTypescriptType": "AL_MediaSeason", + "usedStructName": "anilist.MediaSeason", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth", + "formattedName": "AL_MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name", + "formattedName": "AL_MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name", + "package": "anilist", + "fields": [ + { + "name": "Full", + "jsonName": "full", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Alternative", + "jsonName": "alternative", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Image", + "formattedName": "AL_MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Image", + "package": "anilist", + "fields": [ + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Characters_Edges", + "formattedName": "AL_MangaDetailsById_Media_Characters_Edges", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Role", + "jsonName": "role", + "goType": "CharacterRole", + "typescriptType": "AL_CharacterRole", + "usedTypescriptType": "AL_CharacterRole", + "usedStructName": "anilist.CharacterRole", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Node", + "jsonName": "node", + "goType": "BaseCharacter", + "typescriptType": "AL_BaseCharacter", + "usedTypescriptType": "AL_BaseCharacter", + "usedStructName": "anilist.BaseCharacter", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Characters", + "formattedName": "AL_MangaDetailsById_Media_Characters", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]MangaDetailsById_Media_Characters_Edges", + "typescriptType": "Array\u003cAL_MangaDetailsById_Media_Characters_Edges\u003e", + "usedTypescriptType": "AL_MangaDetailsById_Media_Characters_Edges", + "usedStructName": "anilist.MangaDetailsById_Media_Characters_Edges", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "formattedName": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "formattedName": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "formattedName": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate", + "formattedName": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "formattedName": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IDMal", + "jsonName": "idMal", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaStatus", + "typescriptType": "AL_MediaStatus", + "usedTypescriptType": "AL_MediaStatus", + "usedStructName": "anilist.MediaStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "MediaSeason", + "typescriptType": "AL_MediaSeason", + "usedTypescriptType": "AL_MediaSeason", + "usedStructName": "anilist.MediaSeason", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "MediaType", + "typescriptType": "AL_MediaType", + "usedTypescriptType": "AL_MediaType", + "usedStructName": "anilist.MediaType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BannerImage", + "jsonName": "bannerImage", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Chapters", + "jsonName": "chapters", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Volumes", + "jsonName": "volumes", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Synonyms", + "jsonName": "synonyms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsAdult", + "jsonName": "isAdult", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CountryOfOrigin", + "jsonName": "countryOfOrigin", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "typescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "usedTypescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "usedStructName": "anilist.MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CoverImage", + "jsonName": "coverImage", + "goType": "MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "typescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "usedTypescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "usedStructName": "anilist.MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "startDate", + "goType": "MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "typescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "usedTypescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "usedStructName": "anilist.MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EndDate", + "jsonName": "endDate", + "goType": "MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate", + "typescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate", + "usedTypescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate", + "usedStructName": "anilist.MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Recommendations_Edges_Node", + "formattedName": "AL_MangaDetailsById_Media_Recommendations_Edges_Node", + "package": "anilist", + "fields": [ + { + "name": "MediaRecommendation", + "jsonName": "mediaRecommendation", + "goType": "MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "typescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "usedTypescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "usedStructName": "anilist.MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Recommendations_Edges", + "formattedName": "AL_MangaDetailsById_Media_Recommendations_Edges", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "MangaDetailsById_Media_Recommendations_Edges_Node", + "typescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node", + "usedTypescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges_Node", + "usedStructName": "anilist.MangaDetailsById_Media_Recommendations_Edges_Node", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Recommendations", + "formattedName": "AL_MangaDetailsById_Media_Recommendations", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]MangaDetailsById_Media_Recommendations_Edges", + "typescriptType": "Array\u003cAL_MangaDetailsById_Media_Recommendations_Edges\u003e", + "usedTypescriptType": "AL_MangaDetailsById_Media_Recommendations_Edges", + "usedStructName": "anilist.MangaDetailsById_Media_Recommendations_Edges", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Relations_Edges_Node_BaseManga_Title", + "formattedName": "AL_MangaDetailsById_Media_Relations_Edges_Node_BaseManga_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Relations_Edges_Node_BaseManga_CoverImage", + "formattedName": "AL_MangaDetailsById_Media_Relations_Edges_Node_BaseManga_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Relations_Edges_Node_BaseManga_StartDate", + "formattedName": "AL_MangaDetailsById_Media_Relations_Edges_Node_BaseManga_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Relations_Edges_Node_BaseManga_EndDate", + "formattedName": "AL_MangaDetailsById_Media_Relations_Edges_Node_BaseManga_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Relations_Edges", + "formattedName": "AL_MangaDetailsById_Media_Relations_Edges", + "package": "anilist", + "fields": [ + { + "name": "RelationType", + "jsonName": "relationType", + "goType": "MediaRelation", + "typescriptType": "AL_MediaRelation", + "usedTypescriptType": "AL_MediaRelation", + "usedStructName": "anilist.MediaRelation", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Node", + "jsonName": "node", + "goType": "BaseManga", + "typescriptType": "AL_BaseManga", + "usedTypescriptType": "AL_BaseManga", + "usedStructName": "anilist.BaseManga", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media_Relations", + "formattedName": "AL_MangaDetailsById_Media_Relations", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]MangaDetailsById_Media_Relations_Edges", + "typescriptType": "Array\u003cAL_MangaDetailsById_Media_Relations_Edges\u003e", + "usedTypescriptType": "AL_MangaDetailsById_Media_Relations_Edges", + "usedStructName": "anilist.MangaDetailsById_Media_Relations_Edges", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsById_Media", + "formattedName": "AL_MangaDetailsById_Media", + "package": "anilist", + "fields": [ + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rankings", + "jsonName": "rankings", + "goType": "[]MangaDetailsById_Media_Rankings", + "typescriptType": "Array\u003cAL_MangaDetailsById_Media_Rankings\u003e", + "usedTypescriptType": "AL_MangaDetailsById_Media_Rankings", + "usedStructName": "anilist.MangaDetailsById_Media_Rankings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Characters", + "jsonName": "characters", + "goType": "MangaDetailsById_Media_Characters", + "typescriptType": "AL_MangaDetailsById_Media_Characters", + "usedTypescriptType": "AL_MangaDetailsById_Media_Characters", + "usedStructName": "anilist.MangaDetailsById_Media_Characters", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Recommendations", + "jsonName": "recommendations", + "goType": "MangaDetailsById_Media_Recommendations", + "typescriptType": "AL_MangaDetailsById_Media_Recommendations", + "usedTypescriptType": "AL_MangaDetailsById_Media_Recommendations", + "usedStructName": "anilist.MangaDetailsById_Media_Recommendations", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Relations", + "jsonName": "relations", + "goType": "MangaDetailsById_Media_Relations", + "typescriptType": "AL_MangaDetailsById_Media_Relations", + "usedTypescriptType": "AL_MangaDetailsById_Media_Relations", + "usedStructName": "anilist.MangaDetailsById_Media_Relations", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListManga_Page_PageInfo", + "formattedName": "AL_ListManga_Page_PageInfo", + "package": "anilist", + "fields": [ + { + "name": "HasNextPage", + "jsonName": "hasNextPage", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Total", + "jsonName": "total", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PerPage", + "jsonName": "perPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CurrentPage", + "jsonName": "currentPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LastPage", + "jsonName": "lastPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListManga_Page_Media_BaseManga_Title", + "formattedName": "AL_ListManga_Page_Media_BaseManga_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListManga_Page_Media_BaseManga_CoverImage", + "formattedName": "AL_ListManga_Page_Media_BaseManga_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListManga_Page_Media_BaseManga_StartDate", + "formattedName": "AL_ListManga_Page_Media_BaseManga_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListManga_Page_Media_BaseManga_EndDate", + "formattedName": "AL_ListManga_Page_Media_BaseManga_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListManga_Page", + "formattedName": "AL_ListManga_Page", + "package": "anilist", + "fields": [ + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "ListManga_Page_PageInfo", + "typescriptType": "AL_ListManga_Page_PageInfo", + "usedTypescriptType": "AL_ListManga_Page_PageInfo", + "usedStructName": "anilist.ListManga_Page_PageInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "[]BaseManga", + "typescriptType": "Array\u003cAL_BaseManga\u003e", + "usedTypescriptType": "AL_BaseManga", + "usedStructName": "anilist.BaseManga", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ViewerStats_Viewer_Statistics_Anime_Studios_UserStudioStats_Studio", + "formattedName": "AL_ViewerStats_Viewer_Statistics_Anime_Studios_UserStudioStats_Studio", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsAnimationStudio", + "jsonName": "isAnimationStudio", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ViewerStats_Viewer_Statistics_Anime", + "formattedName": "AL_ViewerStats_Viewer_Statistics_Anime", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodesWatched", + "jsonName": "episodesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Formats", + "jsonName": "formats", + "goType": "[]UserFormatStats", + "typescriptType": "Array\u003cAL_UserFormatStats\u003e", + "usedTypescriptType": "AL_UserFormatStats", + "usedStructName": "anilist.UserFormatStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]UserGenreStats", + "typescriptType": "Array\u003cAL_UserGenreStats\u003e", + "usedTypescriptType": "AL_UserGenreStats", + "usedStructName": "anilist.UserGenreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Statuses", + "jsonName": "statuses", + "goType": "[]UserStatusStats", + "typescriptType": "Array\u003cAL_UserStatusStats\u003e", + "usedTypescriptType": "AL_UserStatusStats", + "usedStructName": "anilist.UserStatusStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studios", + "jsonName": "studios", + "goType": "[]UserStudioStats", + "typescriptType": "Array\u003cAL_UserStudioStats\u003e", + "usedTypescriptType": "AL_UserStudioStats", + "usedStructName": "anilist.UserStudioStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Scores", + "jsonName": "scores", + "goType": "[]UserScoreStats", + "typescriptType": "Array\u003cAL_UserScoreStats\u003e", + "usedTypescriptType": "AL_UserScoreStats", + "usedStructName": "anilist.UserScoreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartYears", + "jsonName": "startYears", + "goType": "[]UserStartYearStats", + "typescriptType": "Array\u003cAL_UserStartYearStats\u003e", + "usedTypescriptType": "AL_UserStartYearStats", + "usedStructName": "anilist.UserStartYearStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReleaseYears", + "jsonName": "releaseYears", + "goType": "[]UserReleaseYearStats", + "typescriptType": "Array\u003cAL_UserReleaseYearStats\u003e", + "usedTypescriptType": "AL_UserReleaseYearStats", + "usedStructName": "anilist.UserReleaseYearStats", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ViewerStats_Viewer_Statistics_Manga_Studios_UserStudioStats_Studio", + "formattedName": "AL_ViewerStats_Viewer_Statistics_Manga_Studios_UserStudioStats_Studio", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsAnimationStudio", + "jsonName": "isAnimationStudio", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ViewerStats_Viewer_Statistics_Manga", + "formattedName": "AL_ViewerStats_Viewer_Statistics_Manga", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Formats", + "jsonName": "formats", + "goType": "[]UserFormatStats", + "typescriptType": "Array\u003cAL_UserFormatStats\u003e", + "usedTypescriptType": "AL_UserFormatStats", + "usedStructName": "anilist.UserFormatStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]UserGenreStats", + "typescriptType": "Array\u003cAL_UserGenreStats\u003e", + "usedTypescriptType": "AL_UserGenreStats", + "usedStructName": "anilist.UserGenreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Statuses", + "jsonName": "statuses", + "goType": "[]UserStatusStats", + "typescriptType": "Array\u003cAL_UserStatusStats\u003e", + "usedTypescriptType": "AL_UserStatusStats", + "usedStructName": "anilist.UserStatusStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studios", + "jsonName": "studios", + "goType": "[]UserStudioStats", + "typescriptType": "Array\u003cAL_UserStudioStats\u003e", + "usedTypescriptType": "AL_UserStudioStats", + "usedStructName": "anilist.UserStudioStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Scores", + "jsonName": "scores", + "goType": "[]UserScoreStats", + "typescriptType": "Array\u003cAL_UserScoreStats\u003e", + "usedTypescriptType": "AL_UserScoreStats", + "usedStructName": "anilist.UserScoreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartYears", + "jsonName": "startYears", + "goType": "[]UserStartYearStats", + "typescriptType": "Array\u003cAL_UserStartYearStats\u003e", + "usedTypescriptType": "AL_UserStartYearStats", + "usedStructName": "anilist.UserStartYearStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReleaseYears", + "jsonName": "releaseYears", + "goType": "[]UserReleaseYearStats", + "typescriptType": "Array\u003cAL_UserReleaseYearStats\u003e", + "usedTypescriptType": "AL_UserReleaseYearStats", + "usedStructName": "anilist.UserReleaseYearStats", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ViewerStats_Viewer_Statistics", + "formattedName": "AL_ViewerStats_Viewer_Statistics", + "package": "anilist", + "fields": [ + { + "name": "Anime", + "jsonName": "anime", + "goType": "ViewerStats_Viewer_Statistics_Anime", + "typescriptType": "AL_ViewerStats_Viewer_Statistics_Anime", + "usedTypescriptType": "AL_ViewerStats_Viewer_Statistics_Anime", + "usedStructName": "anilist.ViewerStats_Viewer_Statistics_Anime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Manga", + "jsonName": "manga", + "goType": "ViewerStats_Viewer_Statistics_Manga", + "typescriptType": "AL_ViewerStats_Viewer_Statistics_Manga", + "usedTypescriptType": "AL_ViewerStats_Viewer_Statistics_Manga", + "usedStructName": "anilist.ViewerStats_Viewer_Statistics_Manga", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ViewerStats_Viewer", + "formattedName": "AL_ViewerStats_Viewer", + "package": "anilist", + "fields": [ + { + "name": "Statistics", + "jsonName": "statistics", + "goType": "ViewerStats_Viewer_Statistics", + "typescriptType": "AL_ViewerStats_Viewer_Statistics", + "usedTypescriptType": "AL_ViewerStats_Viewer_Statistics", + "usedStructName": "anilist.ViewerStats_Viewer_Statistics", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "StudioDetails_Studio_Media_Nodes_BaseAnime_Trailer", + "formattedName": "AL_StudioDetails_Studio_Media_Nodes_BaseAnime_Trailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "StudioDetails_Studio_Media_Nodes_BaseAnime_Title", + "formattedName": "AL_StudioDetails_Studio_Media_Nodes_BaseAnime_Title", + "package": "anilist", + "fields": [ + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "StudioDetails_Studio_Media_Nodes_BaseAnime_CoverImage", + "formattedName": "AL_StudioDetails_Studio_Media_Nodes_BaseAnime_CoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "StudioDetails_Studio_Media_Nodes_BaseAnime_StartDate", + "formattedName": "AL_StudioDetails_Studio_Media_Nodes_BaseAnime_StartDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "StudioDetails_Studio_Media_Nodes_BaseAnime_EndDate", + "formattedName": "AL_StudioDetails_Studio_Media_Nodes_BaseAnime_EndDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "StudioDetails_Studio_Media_Nodes_BaseAnime_NextAiringEpisode", + "formattedName": "AL_StudioDetails_Studio_Media_Nodes_BaseAnime_NextAiringEpisode", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "StudioDetails_Studio_Media", + "formattedName": "AL_StudioDetails_Studio_Media", + "package": "anilist", + "fields": [ + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]BaseAnime", + "typescriptType": "Array\u003cAL_BaseAnime\u003e", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "StudioDetails_Studio", + "formattedName": "AL_StudioDetails_Studio", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsAnimationStudio", + "jsonName": "isAnimationStudio", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "StudioDetails_Studio_Media", + "typescriptType": "AL_StudioDetails_Studio_Media", + "usedTypescriptType": "AL_StudioDetails_Studio_Media", + "usedStructName": "anilist.StudioDetails_Studio_Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "GetViewer_Viewer_Avatar", + "formattedName": "AL_GetViewer_Viewer_Avatar", + "package": "anilist", + "fields": [ + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "GetViewer_Viewer_Options", + "formattedName": "AL_GetViewer_Viewer_Options", + "package": "anilist", + "fields": [ + { + "name": "DisplayAdultContent", + "jsonName": "displayAdultContent", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AiringNotifications", + "jsonName": "airingNotifications", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ProfileColor", + "jsonName": "profileColor", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "GetViewer_Viewer", + "formattedName": "AL_GetViewer_Viewer", + "package": "anilist", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Avatar", + "jsonName": "avatar", + "goType": "GetViewer_Viewer_Avatar", + "typescriptType": "AL_GetViewer_Viewer_Avatar", + "usedTypescriptType": "AL_GetViewer_Viewer_Avatar", + "usedStructName": "anilist.GetViewer_Viewer_Avatar", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BannerImage", + "jsonName": "bannerImage", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsBlocked", + "jsonName": "isBlocked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Options", + "jsonName": "options", + "goType": "GetViewer_Viewer_Options", + "typescriptType": "AL_GetViewer_Viewer_Options", + "usedTypescriptType": "AL_GetViewer_Viewer_Options", + "usedStructName": "anilist.GetViewer_Viewer_Options", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollection", + "formattedName": "AL_AnimeCollection", + "package": "anilist", + "fields": [ + { + "name": "MediaListCollection", + "jsonName": "MediaListCollection", + "goType": "AnimeCollection_MediaListCollection", + "typescriptType": "AL_AnimeCollection_MediaListCollection", + "usedTypescriptType": "AL_AnimeCollection_MediaListCollection", + "usedStructName": "anilist.AnimeCollection_MediaListCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeCollectionWithRelations", + "formattedName": "AL_AnimeCollectionWithRelations", + "package": "anilist", + "fields": [ + { + "name": "MediaListCollection", + "jsonName": "MediaListCollection", + "goType": "AnimeCollectionWithRelations_MediaListCollection", + "typescriptType": "AL_AnimeCollectionWithRelations_MediaListCollection", + "usedTypescriptType": "AL_AnimeCollectionWithRelations_MediaListCollection", + "usedStructName": "anilist.AnimeCollectionWithRelations_MediaListCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeByMalID", + "formattedName": "AL_BaseAnimeByMalID", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "Media", + "goType": "BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseAnimeByID", + "formattedName": "AL_BaseAnimeByID", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "Media", + "goType": "BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseAnimeByIds", + "formattedName": "AL_SearchBaseAnimeByIds", + "package": "anilist", + "fields": [ + { + "name": "Page", + "jsonName": "Page", + "goType": "SearchBaseAnimeByIds_Page", + "typescriptType": "AL_SearchBaseAnimeByIds_Page", + "usedTypescriptType": "AL_SearchBaseAnimeByIds_Page", + "usedStructName": "anilist.SearchBaseAnimeByIds_Page", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "CompleteAnimeByID", + "formattedName": "AL_CompleteAnimeByID", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "Media", + "goType": "CompleteAnime", + "typescriptType": "AL_CompleteAnime", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeDetailsByID", + "formattedName": "AL_AnimeDetailsByID", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "Media", + "goType": "AnimeDetailsById_Media", + "typescriptType": "AL_AnimeDetailsById_Media", + "usedTypescriptType": "AL_AnimeDetailsById_Media", + "usedStructName": "anilist.AnimeDetailsById_Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListAnime", + "formattedName": "AL_ListAnime", + "package": "anilist", + "fields": [ + { + "name": "Page", + "jsonName": "Page", + "goType": "ListAnime_Page", + "typescriptType": "AL_ListAnime_Page", + "usedTypescriptType": "AL_ListAnime_Page", + "usedStructName": "anilist.ListAnime_Page", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListRecentAnime", + "formattedName": "AL_ListRecentAnime", + "package": "anilist", + "fields": [ + { + "name": "Page", + "jsonName": "Page", + "goType": "ListRecentAnime_Page", + "typescriptType": "AL_ListRecentAnime_Page", + "usedTypescriptType": "AL_ListRecentAnime_Page", + "usedStructName": "anilist.ListRecentAnime_Page", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringSchedule", + "formattedName": "AL_AnimeAiringSchedule", + "package": "anilist", + "fields": [ + { + "name": "Ongoing", + "jsonName": "ongoing", + "goType": "AnimeAiringSchedule_Ongoing", + "typescriptType": "AL_AnimeAiringSchedule_Ongoing", + "usedTypescriptType": "AL_AnimeAiringSchedule_Ongoing", + "usedStructName": "anilist.AnimeAiringSchedule_Ongoing", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OngoingNext", + "jsonName": "ongoingNext", + "goType": "AnimeAiringSchedule_OngoingNext", + "typescriptType": "AL_AnimeAiringSchedule_OngoingNext", + "usedTypescriptType": "AL_AnimeAiringSchedule_OngoingNext", + "usedStructName": "anilist.AnimeAiringSchedule_OngoingNext", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Upcoming", + "jsonName": "upcoming", + "goType": "AnimeAiringSchedule_Upcoming", + "typescriptType": "AL_AnimeAiringSchedule_Upcoming", + "usedTypescriptType": "AL_AnimeAiringSchedule_Upcoming", + "usedStructName": "anilist.AnimeAiringSchedule_Upcoming", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UpcomingNext", + "jsonName": "upcomingNext", + "goType": "AnimeAiringSchedule_UpcomingNext", + "typescriptType": "AL_AnimeAiringSchedule_UpcomingNext", + "usedTypescriptType": "AL_AnimeAiringSchedule_UpcomingNext", + "usedStructName": "anilist.AnimeAiringSchedule_UpcomingNext", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Preceding", + "jsonName": "preceding", + "goType": "AnimeAiringSchedule_Preceding", + "typescriptType": "AL_AnimeAiringSchedule_Preceding", + "usedTypescriptType": "AL_AnimeAiringSchedule_Preceding", + "usedStructName": "anilist.AnimeAiringSchedule_Preceding", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "AnimeAiringScheduleRaw", + "formattedName": "AL_AnimeAiringScheduleRaw", + "package": "anilist", + "fields": [ + { + "name": "Page", + "jsonName": "Page", + "goType": "AnimeAiringScheduleRaw_Page", + "typescriptType": "AL_AnimeAiringScheduleRaw_Page", + "usedTypescriptType": "AL_AnimeAiringScheduleRaw_Page", + "usedStructName": "anilist.AnimeAiringScheduleRaw_Page", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UpdateMediaListEntry", + "formattedName": "AL_UpdateMediaListEntry", + "package": "anilist", + "fields": [ + { + "name": "SaveMediaListEntry", + "jsonName": "SaveMediaListEntry", + "goType": "UpdateMediaListEntry_SaveMediaListEntry", + "typescriptType": "AL_UpdateMediaListEntry_SaveMediaListEntry", + "usedTypescriptType": "AL_UpdateMediaListEntry_SaveMediaListEntry", + "usedStructName": "anilist.UpdateMediaListEntry_SaveMediaListEntry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UpdateMediaListEntryProgress", + "formattedName": "AL_UpdateMediaListEntryProgress", + "package": "anilist", + "fields": [ + { + "name": "SaveMediaListEntry", + "jsonName": "SaveMediaListEntry", + "goType": "UpdateMediaListEntryProgress_SaveMediaListEntry", + "typescriptType": "AL_UpdateMediaListEntryProgress_SaveMediaListEntry", + "usedTypescriptType": "AL_UpdateMediaListEntryProgress_SaveMediaListEntry", + "usedStructName": "anilist.UpdateMediaListEntryProgress_SaveMediaListEntry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "DeleteEntry", + "formattedName": "AL_DeleteEntry", + "package": "anilist", + "fields": [ + { + "name": "DeleteMediaListEntry", + "jsonName": "DeleteMediaListEntry", + "goType": "DeleteEntry_DeleteMediaListEntry", + "typescriptType": "AL_DeleteEntry_DeleteMediaListEntry", + "usedTypescriptType": "AL_DeleteEntry_DeleteMediaListEntry", + "usedStructName": "anilist.DeleteEntry_DeleteMediaListEntry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "UpdateMediaListEntryRepeat", + "formattedName": "AL_UpdateMediaListEntryRepeat", + "package": "anilist", + "fields": [ + { + "name": "SaveMediaListEntry", + "jsonName": "SaveMediaListEntry", + "goType": "UpdateMediaListEntryRepeat_SaveMediaListEntry", + "typescriptType": "AL_UpdateMediaListEntryRepeat_SaveMediaListEntry", + "usedTypescriptType": "AL_UpdateMediaListEntryRepeat_SaveMediaListEntry", + "usedStructName": "anilist.UpdateMediaListEntryRepeat_SaveMediaListEntry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaCollection", + "formattedName": "AL_MangaCollection", + "package": "anilist", + "fields": [ + { + "name": "MediaListCollection", + "jsonName": "MediaListCollection", + "goType": "MangaCollection_MediaListCollection", + "typescriptType": "AL_MangaCollection_MediaListCollection", + "usedTypescriptType": "AL_MangaCollection_MediaListCollection", + "usedStructName": "anilist.MangaCollection_MediaListCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "SearchBaseManga", + "formattedName": "AL_SearchBaseManga", + "package": "anilist", + "fields": [ + { + "name": "Page", + "jsonName": "Page", + "goType": "SearchBaseManga_Page", + "typescriptType": "AL_SearchBaseManga_Page", + "usedTypescriptType": "AL_SearchBaseManga_Page", + "usedStructName": "anilist.SearchBaseManga_Page", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "BaseMangaByID", + "formattedName": "AL_BaseMangaByID", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "Media", + "goType": "BaseManga", + "typescriptType": "AL_BaseManga", + "usedTypescriptType": "AL_BaseManga", + "usedStructName": "anilist.BaseManga", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "MangaDetailsByID", + "formattedName": "AL_MangaDetailsByID", + "package": "anilist", + "fields": [ + { + "name": "Media", + "jsonName": "Media", + "goType": "MangaDetailsById_Media", + "typescriptType": "AL_MangaDetailsById_Media", + "usedTypescriptType": "AL_MangaDetailsById_Media", + "usedStructName": "anilist.MangaDetailsById_Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ListManga", + "formattedName": "AL_ListManga", + "package": "anilist", + "fields": [ + { + "name": "Page", + "jsonName": "Page", + "goType": "ListManga_Page", + "typescriptType": "AL_ListManga_Page", + "usedTypescriptType": "AL_ListManga_Page", + "usedStructName": "anilist.ListManga_Page", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "ViewerStats", + "formattedName": "AL_ViewerStats", + "package": "anilist", + "fields": [ + { + "name": "Viewer", + "jsonName": "Viewer", + "goType": "ViewerStats_Viewer", + "typescriptType": "AL_ViewerStats_Viewer", + "usedTypescriptType": "AL_ViewerStats_Viewer", + "usedStructName": "anilist.ViewerStats_Viewer", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "StudioDetails", + "formattedName": "AL_StudioDetails", + "package": "anilist", + "fields": [ + { + "name": "Studio", + "jsonName": "Studio", + "goType": "StudioDetails_Studio", + "typescriptType": "AL_StudioDetails_Studio", + "usedTypescriptType": "AL_StudioDetails_Studio", + "usedStructName": "anilist.StudioDetails_Studio", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_gen.go", + "filename": "client_gen.go", + "name": "GetViewer", + "formattedName": "AL_GetViewer", + "package": "anilist", + "fields": [ + { + "name": "Viewer", + "jsonName": "Viewer", + "goType": "GetViewer_Viewer", + "typescriptType": "AL_GetViewer_Viewer", + "usedTypescriptType": "AL_GetViewer_Viewer", + "usedStructName": "anilist.GetViewer_Viewer", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/client_mock.go", + "filename": "client_mock.go", + "name": "MockAnilistClientImpl", + "formattedName": "AL_MockAnilistClientImpl", + "package": "anilist", + "fields": [ + { + "name": "realAnilistClient", + "jsonName": "realAnilistClient", + "goType": "AnilistClient", + "typescriptType": "AL_AnilistClient", + "usedTypescriptType": "AL_AnilistClient", + "usedStructName": "anilist.AnilistClient", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " MockAnilistClientImpl is a mock implementation of the AnilistClient, used for tests.", + " It uses the real implementation of the AnilistClient to make requests then populates a cache with the results.", + " This is to avoid making repeated requests to the AniList API during tests but still have realistic data." + ] + }, + { + "filepath": "../internal/api/anilist/client_mock.go", + "filename": "client_mock.go", + "name": "TestModifyAnimeCollectionEntryInput", + "formattedName": "AL_TestModifyAnimeCollectionEntryInput", + "package": "anilist", + "fields": [ + { + "name": "Status", + "jsonName": "Status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "Progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "Score", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AiredEpisodes", + "jsonName": "AiredEpisodes", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NextAiringEpisode", + "jsonName": "NextAiringEpisode", + "goType": "BaseAnime_NextAiringEpisode", + "typescriptType": "AL_BaseAnime_NextAiringEpisode", + "usedTypescriptType": "AL_BaseAnime_NextAiringEpisode", + "usedStructName": "anilist.BaseAnime_NextAiringEpisode", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/collection_helper.go", + "filename": "collection_helper.go", + "name": "AnimeListEntry", + "formattedName": "AL_AnimeListEntry", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "AnimeCollection_MediaListCollection_Lists_Entries", + "typescriptType": "AL_AnimeCollection_MediaListCollection_Lists_Entries", + "usedTypescriptType": "AL_AnimeCollection_MediaListCollection_Lists_Entries", + "declaredValues": [], + "usedStructName": "anilist.AnimeCollection_MediaListCollection_Lists_Entries" + }, + "comments": [] + }, + { + "filepath": "../internal/api/anilist/collection_helper.go", + "filename": "collection_helper.go", + "name": "AnimeList", + "formattedName": "AL_AnimeList", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "AnimeCollection_MediaListCollection_Lists", + "typescriptType": "AL_AnimeCollection_MediaListCollection_Lists", + "usedTypescriptType": "AL_AnimeCollection_MediaListCollection_Lists", + "declaredValues": [], + "usedStructName": "anilist.AnimeCollection_MediaListCollection_Lists" + }, + "comments": [] + }, + { + "filepath": "../internal/api/anilist/collection_helper.go", + "filename": "collection_helper.go", + "name": "EntryDate", + "formattedName": "AL_EntryDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/hook_events.go", + "filename": "hook_events.go", + "name": "ListMissedSequelsRequestedEvent", + "formattedName": "AL_ListMissedSequelsRequestedEvent", + "package": "anilist", + "fields": [ + { + "name": "AnimeCollectionWithRelations", + "jsonName": "animeCollectionWithRelations", + "goType": "AnimeCollectionWithRelations", + "typescriptType": "AL_AnimeCollectionWithRelations", + "usedTypescriptType": "AL_AnimeCollectionWithRelations", + "usedStructName": "anilist.AnimeCollectionWithRelations", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Variables", + "jsonName": "variables", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Query", + "jsonName": "query", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "List", + "jsonName": "list", + "goType": "[]BaseAnime", + "typescriptType": "Array\u003cAL_BaseAnime\u003e", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ListMissedSequelsRequestedEvent is triggered when the list missed sequels request is requested.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/api/anilist/hook_events.go", + "filename": "hook_events.go", + "name": "ListMissedSequelsEvent", + "formattedName": "AL_ListMissedSequelsEvent", + "package": "anilist", + "fields": [ + { + "name": "List", + "jsonName": "list", + "goType": "[]BaseAnime", + "typescriptType": "Array\u003cAL_BaseAnime\u003e", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/api/anilist/manga.go", + "filename": "manga.go", + "name": "MangaList", + "formattedName": "AL_MangaList", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "MangaCollection_MediaListCollection_Lists", + "typescriptType": "AL_MangaCollection_MediaListCollection_Lists", + "usedTypescriptType": "AL_MangaCollection_MediaListCollection_Lists", + "declaredValues": [], + "usedStructName": "anilist.MangaCollection_MediaListCollection_Lists" + }, + "comments": [] + }, + { + "filepath": "../internal/api/anilist/manga.go", + "filename": "manga.go", + "name": "MangaListEntry", + "formattedName": "AL_MangaListEntry", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "MangaCollection_MediaListCollection_Lists_Entries", + "typescriptType": "AL_MangaCollection_MediaListCollection_Lists_Entries", + "usedTypescriptType": "AL_MangaCollection_MediaListCollection_Lists_Entries", + "declaredValues": [], + "usedStructName": "anilist.MangaCollection_MediaListCollection_Lists_Entries" + }, + "comments": [] + }, + { + "filepath": "../internal/api/anilist/media.go", + "filename": "media.go", + "name": "BaseAnimeCache", + "formattedName": "AL_BaseAnimeCache", + "package": "anilist", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "" + ] + }, + { + "filepath": "../internal/api/anilist/media.go", + "filename": "media.go", + "name": "CompleteAnimeCache", + "formattedName": "AL_CompleteAnimeCache", + "package": "anilist", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "" + ] + }, + { + "filepath": "../internal/api/anilist/media_tree.go", + "filename": "media_tree.go", + "name": "CompleteAnimeRelationTree", + "formattedName": "AL_CompleteAnimeRelationTree", + "package": "anilist", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "" + ] + }, + { + "filepath": "../internal/api/anilist/media_tree.go", + "filename": "media_tree.go", + "name": "FetchMediaTreeRelation", + "formattedName": "AL_FetchMediaTreeRelation", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"sequels\"", + "\"prequels\"", + "\"all\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ActivityLikeNotification", + "formattedName": "AL_ActivityLikeNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ActivityID", + "jsonName": "activityId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Activity", + "jsonName": "activity", + "goType": "ActivityUnion", + "typescriptType": "AL_ActivityUnion", + "usedTypescriptType": "AL_ActivityUnion", + "usedStructName": "anilist.ActivityUnion", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a activity is liked" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ActivityMentionNotification", + "formattedName": "AL_ActivityMentionNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ActivityID", + "jsonName": "activityId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Activity", + "jsonName": "activity", + "goType": "ActivityUnion", + "typescriptType": "AL_ActivityUnion", + "usedTypescriptType": "AL_ActivityUnion", + "usedStructName": "anilist.ActivityUnion", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when authenticated user is @ mentioned in activity or reply" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ActivityMessageNotification", + "formattedName": "AL_ActivityMessageNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ActivityID", + "jsonName": "activityId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Message", + "jsonName": "message", + "goType": "MessageActivity", + "typescriptType": "AL_MessageActivity", + "usedTypescriptType": "AL_MessageActivity", + "usedStructName": "anilist.MessageActivity", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a user is send an activity message" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ActivityReply", + "formattedName": "AL_ActivityReply", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ActivityID", + "jsonName": "activityId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Text", + "jsonName": "text", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LikeCount", + "jsonName": "likeCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsLiked", + "jsonName": "isLiked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Likes", + "jsonName": "likes", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Replay to an activity item" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ActivityReplyLikeNotification", + "formattedName": "AL_ActivityReplyLikeNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ActivityID", + "jsonName": "activityId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Activity", + "jsonName": "activity", + "goType": "ActivityUnion", + "typescriptType": "AL_ActivityUnion", + "usedTypescriptType": "AL_ActivityUnion", + "usedStructName": "anilist.ActivityUnion", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a activity reply is liked" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ActivityReplyNotification", + "formattedName": "AL_ActivityReplyNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ActivityID", + "jsonName": "activityId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Activity", + "jsonName": "activity", + "goType": "ActivityUnion", + "typescriptType": "AL_ActivityUnion", + "usedTypescriptType": "AL_ActivityUnion", + "usedStructName": "anilist.ActivityUnion", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a user replies to the authenticated users activity" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ActivityReplySubscribedNotification", + "formattedName": "AL_ActivityReplySubscribedNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ActivityID", + "jsonName": "activityId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Activity", + "jsonName": "activity", + "goType": "ActivityUnion", + "typescriptType": "AL_ActivityUnion", + "usedTypescriptType": "AL_ActivityUnion", + "usedStructName": "anilist.ActivityUnion", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a user replies to activity the authenticated user has replied to" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "AiringNotification", + "formattedName": "AL_AiringNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeID", + "jsonName": "animeId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Contexts", + "jsonName": "contexts", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when an episode of anime airs" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "AiringProgression", + "formattedName": "AL_AiringProgression", + "package": "anilist", + "fields": [ + { + "name": "Episode", + "jsonName": "episode", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Watching", + "jsonName": "watching", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Score \u0026 Watcher stats for airing anime by episode and mid-week" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "AiringSchedule", + "formattedName": "AL_AiringSchedule", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Media Airing Schedule. NOTE: We only aim to guarantee that FUTURE airing data is present and accurate." + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "AiringScheduleConnection", + "formattedName": "AL_AiringScheduleConnection", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]AiringScheduleEdge", + "typescriptType": "Array\u003cAL_AiringScheduleEdge\u003e", + "usedTypescriptType": "AL_AiringScheduleEdge", + "usedStructName": "anilist.AiringScheduleEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]AiringSchedule", + "typescriptType": "Array\u003cAL_AiringSchedule\u003e", + "usedTypescriptType": "AL_AiringSchedule", + "usedStructName": "anilist.AiringSchedule", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "AiringScheduleEdge", + "formattedName": "AL_AiringScheduleEdge", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "AiringSchedule", + "typescriptType": "AL_AiringSchedule", + "usedTypescriptType": "AL_AiringSchedule", + "usedStructName": "anilist.AiringSchedule", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AiringSchedule connection edge" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "AiringScheduleInput", + "formattedName": "AL_AiringScheduleInput", + "package": "anilist", + "fields": [ + { + "name": "AiringAt", + "jsonName": "airingAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TimeUntilAiring", + "jsonName": "timeUntilAiring", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "AniChartHighlightInput", + "formattedName": "AL_AniChartHighlightInput", + "package": "anilist", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Highlight", + "jsonName": "highlight", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "AniChartUser", + "formattedName": "AL_AniChartUser", + "package": "anilist", + "fields": [ + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Settings", + "jsonName": "settings", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Highlights", + "jsonName": "highlights", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Character", + "formattedName": "AL_Character", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "CharacterName", + "typescriptType": "AL_CharacterName", + "usedTypescriptType": "AL_CharacterName", + "usedStructName": "anilist.CharacterName", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "CharacterImage", + "typescriptType": "AL_CharacterImage", + "usedTypescriptType": "AL_CharacterImage", + "usedStructName": "anilist.CharacterImage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Gender", + "jsonName": "gender", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DateOfBirth", + "jsonName": "dateOfBirth", + "goType": "FuzzyDate", + "typescriptType": "AL_FuzzyDate", + "usedTypescriptType": "AL_FuzzyDate", + "usedStructName": "anilist.FuzzyDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Age", + "jsonName": "age", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BloodType", + "jsonName": "bloodType", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsFavourite", + "jsonName": "isFavourite", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsFavouriteBlocked", + "jsonName": "isFavouriteBlocked", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "MediaConnection", + "typescriptType": "AL_MediaConnection", + "usedTypescriptType": "AL_MediaConnection", + "usedStructName": "anilist.MediaConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Favourites", + "jsonName": "favourites", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ModNotes", + "jsonName": "modNotes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A character that features in an anime or manga" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "CharacterConnection", + "formattedName": "AL_CharacterConnection", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]CharacterEdge", + "typescriptType": "Array\u003cAL_CharacterEdge\u003e", + "usedTypescriptType": "AL_CharacterEdge", + "usedStructName": "anilist.CharacterEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]Character", + "typescriptType": "Array\u003cAL_Character\u003e", + "usedTypescriptType": "AL_Character", + "usedStructName": "anilist.Character", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "CharacterEdge", + "formattedName": "AL_CharacterEdge", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "Character", + "typescriptType": "AL_Character", + "usedTypescriptType": "AL_Character", + "usedStructName": "anilist.Character", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Role", + "jsonName": "role", + "goType": "CharacterRole", + "typescriptType": "AL_CharacterRole", + "usedTypescriptType": "AL_CharacterRole", + "usedStructName": "anilist.CharacterRole", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VoiceActors", + "jsonName": "voiceActors", + "goType": "[]Staff", + "typescriptType": "Array\u003cAL_Staff\u003e", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VoiceActorRoles", + "jsonName": "voiceActorRoles", + "goType": "[]StaffRoleType", + "typescriptType": "Array\u003cAL_StaffRoleType\u003e", + "usedTypescriptType": "AL_StaffRoleType", + "usedStructName": "anilist.StaffRoleType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "[]Media", + "typescriptType": "Array\u003cAL_Media\u003e", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FavouriteOrder", + "jsonName": "favouriteOrder", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Character connection edge" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "CharacterImage", + "formattedName": "AL_CharacterImage", + "package": "anilist", + "fields": [ + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "CharacterName", + "formattedName": "AL_CharacterName", + "package": "anilist", + "fields": [ + { + "name": "First", + "jsonName": "first", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Middle", + "jsonName": "middle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Last", + "jsonName": "last", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Full", + "jsonName": "full", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Alternative", + "jsonName": "alternative", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AlternativeSpoiler", + "jsonName": "alternativeSpoiler", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " The names of the character" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "CharacterNameInput", + "formattedName": "AL_CharacterNameInput", + "package": "anilist", + "fields": [ + { + "name": "First", + "jsonName": "first", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Middle", + "jsonName": "middle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Last", + "jsonName": "last", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Alternative", + "jsonName": "alternative", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AlternativeSpoiler", + "jsonName": "alternativeSpoiler", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " The names of the character" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "CharacterSubmission", + "formattedName": "AL_CharacterSubmission", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Character", + "jsonName": "character", + "goType": "Character", + "typescriptType": "AL_Character", + "usedTypescriptType": "AL_Character", + "usedStructName": "anilist.Character", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Submission", + "jsonName": "submission", + "goType": "Character", + "typescriptType": "AL_Character", + "usedTypescriptType": "AL_Character", + "usedStructName": "anilist.Character", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Submitter", + "jsonName": "submitter", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Assignee", + "jsonName": "assignee", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "SubmissionStatus", + "typescriptType": "AL_SubmissionStatus", + "usedTypescriptType": "AL_SubmissionStatus", + "usedStructName": "anilist.SubmissionStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Notes", + "jsonName": "notes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Source", + "jsonName": "source", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Locked", + "jsonName": "locked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A submission for a character that features in an anime or manga" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "CharacterSubmissionConnection", + "formattedName": "AL_CharacterSubmissionConnection", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]CharacterSubmissionEdge", + "typescriptType": "Array\u003cAL_CharacterSubmissionEdge\u003e", + "usedTypescriptType": "AL_CharacterSubmissionEdge", + "usedStructName": "anilist.CharacterSubmissionEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]CharacterSubmission", + "typescriptType": "Array\u003cAL_CharacterSubmission\u003e", + "usedTypescriptType": "AL_CharacterSubmission", + "usedStructName": "anilist.CharacterSubmission", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "CharacterSubmissionEdge", + "formattedName": "AL_CharacterSubmissionEdge", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "CharacterSubmission", + "typescriptType": "AL_CharacterSubmission", + "usedTypescriptType": "AL_CharacterSubmission", + "usedStructName": "anilist.CharacterSubmission", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Role", + "jsonName": "role", + "goType": "CharacterRole", + "typescriptType": "AL_CharacterRole", + "usedTypescriptType": "AL_CharacterRole", + "usedStructName": "anilist.CharacterRole", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VoiceActors", + "jsonName": "voiceActors", + "goType": "[]Staff", + "typescriptType": "Array\u003cAL_Staff\u003e", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SubmittedVoiceActors", + "jsonName": "submittedVoiceActors", + "goType": "[]StaffSubmission", + "typescriptType": "Array\u003cAL_StaffSubmission\u003e", + "usedTypescriptType": "AL_StaffSubmission", + "usedStructName": "anilist.StaffSubmission", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " CharacterSubmission connection edge" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Deleted", + "formattedName": "AL_Deleted", + "package": "anilist", + "fields": [ + { + "name": "Deleted", + "jsonName": "deleted", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Deleted data type" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Favourites", + "formattedName": "AL_Favourites", + "package": "anilist", + "fields": [ + { + "name": "Anime", + "jsonName": "anime", + "goType": "MediaConnection", + "typescriptType": "AL_MediaConnection", + "usedTypescriptType": "AL_MediaConnection", + "usedStructName": "anilist.MediaConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Manga", + "jsonName": "manga", + "goType": "MediaConnection", + "typescriptType": "AL_MediaConnection", + "usedTypescriptType": "AL_MediaConnection", + "usedStructName": "anilist.MediaConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Characters", + "jsonName": "characters", + "goType": "CharacterConnection", + "typescriptType": "AL_CharacterConnection", + "usedTypescriptType": "AL_CharacterConnection", + "usedStructName": "anilist.CharacterConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "StaffConnection", + "typescriptType": "AL_StaffConnection", + "usedTypescriptType": "AL_StaffConnection", + "usedStructName": "anilist.StaffConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studios", + "jsonName": "studios", + "goType": "StudioConnection", + "typescriptType": "AL_StudioConnection", + "usedTypescriptType": "AL_StudioConnection", + "usedStructName": "anilist.StudioConnection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User's favourite anime, manga, characters, staff \u0026 studios" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "FollowingNotification", + "formattedName": "AL_FollowingNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when the authenticated user is followed by another user" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "FormatStats", + "formattedName": "AL_FormatStats", + "package": "anilist", + "fields": [ + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Amount", + "jsonName": "amount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User's format statistics" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "FuzzyDate", + "formattedName": "AL_FuzzyDate", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Date object that allows for incomplete date values (fuzzy)" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "FuzzyDateInput", + "formattedName": "AL_FuzzyDateInput", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Date object that allows for incomplete date values (fuzzy)" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "GenreStats", + "formattedName": "AL_GenreStats", + "package": "anilist", + "fields": [ + { + "name": "Genre", + "jsonName": "genre", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Amount", + "jsonName": "amount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TimeWatched", + "jsonName": "timeWatched", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User's genre statistics" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "InternalPage", + "formattedName": "AL_InternalPage", + "package": "anilist", + "fields": [ + { + "name": "MediaSubmissions", + "jsonName": "mediaSubmissions", + "goType": "[]MediaSubmission", + "typescriptType": "Array\u003cAL_MediaSubmission\u003e", + "usedTypescriptType": "AL_MediaSubmission", + "usedStructName": "anilist.MediaSubmission", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CharacterSubmissions", + "jsonName": "characterSubmissions", + "goType": "[]CharacterSubmission", + "typescriptType": "Array\u003cAL_CharacterSubmission\u003e", + "usedTypescriptType": "AL_CharacterSubmission", + "usedStructName": "anilist.CharacterSubmission", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StaffSubmissions", + "jsonName": "staffSubmissions", + "goType": "[]StaffSubmission", + "typescriptType": "Array\u003cAL_StaffSubmission\u003e", + "usedTypescriptType": "AL_StaffSubmission", + "usedStructName": "anilist.StaffSubmission", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RevisionHistory", + "jsonName": "revisionHistory", + "goType": "[]RevisionHistory", + "typescriptType": "Array\u003cAL_RevisionHistory\u003e", + "usedTypescriptType": "AL_RevisionHistory", + "usedStructName": "anilist.RevisionHistory", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Reports", + "jsonName": "reports", + "goType": "[]Report", + "typescriptType": "Array\u003cAL_Report\u003e", + "usedTypescriptType": "AL_Report", + "usedStructName": "anilist.Report", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ModActions", + "jsonName": "modActions", + "goType": "[]ModAction", + "typescriptType": "Array\u003cAL_ModAction\u003e", + "usedTypescriptType": "AL_ModAction", + "usedStructName": "anilist.ModAction", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserBlockSearch", + "jsonName": "userBlockSearch", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Users", + "jsonName": "users", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "[]Media", + "typescriptType": "Array\u003cAL_Media\u003e", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Characters", + "jsonName": "characters", + "goType": "[]Character", + "typescriptType": "Array\u003cAL_Character\u003e", + "usedTypescriptType": "AL_Character", + "usedStructName": "anilist.Character", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "[]Staff", + "typescriptType": "Array\u003cAL_Staff\u003e", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studios", + "jsonName": "studios", + "goType": "[]Studio", + "typescriptType": "Array\u003cAL_Studio\u003e", + "usedTypescriptType": "AL_Studio", + "usedStructName": "anilist.Studio", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaList", + "jsonName": "mediaList", + "goType": "[]MediaList", + "typescriptType": "Array\u003cAL_MediaList\u003e", + "usedTypescriptType": "AL_MediaList", + "usedStructName": "anilist.MediaList", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AiringSchedules", + "jsonName": "airingSchedules", + "goType": "[]AiringSchedule", + "typescriptType": "Array\u003cAL_AiringSchedule\u003e", + "usedTypescriptType": "AL_AiringSchedule", + "usedStructName": "anilist.AiringSchedule", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaTrends", + "jsonName": "mediaTrends", + "goType": "[]MediaTrend", + "typescriptType": "Array\u003cAL_MediaTrend\u003e", + "usedTypescriptType": "AL_MediaTrend", + "usedStructName": "anilist.MediaTrend", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Notifications", + "jsonName": "notifications", + "goType": "[]NotificationUnion", + "typescriptType": "Array\u003cAL_NotificationUnion\u003e", + "usedTypescriptType": "AL_NotificationUnion", + "usedStructName": "anilist.NotificationUnion", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Followers", + "jsonName": "followers", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Following", + "jsonName": "following", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Activities", + "jsonName": "activities", + "goType": "[]ActivityUnion", + "typescriptType": "Array\u003cAL_ActivityUnion\u003e", + "usedTypescriptType": "AL_ActivityUnion", + "usedStructName": "anilist.ActivityUnion", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ActivityReplies", + "jsonName": "activityReplies", + "goType": "[]ActivityReply", + "typescriptType": "Array\u003cAL_ActivityReply\u003e", + "usedTypescriptType": "AL_ActivityReply", + "usedStructName": "anilist.ActivityReply", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Threads", + "jsonName": "threads", + "goType": "[]Thread", + "typescriptType": "Array\u003cAL_Thread\u003e", + "usedTypescriptType": "AL_Thread", + "usedStructName": "anilist.Thread", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ThreadComments", + "jsonName": "threadComments", + "goType": "[]ThreadComment", + "typescriptType": "Array\u003cAL_ThreadComment\u003e", + "usedTypescriptType": "AL_ThreadComment", + "usedStructName": "anilist.ThreadComment", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Reviews", + "jsonName": "reviews", + "goType": "[]Review", + "typescriptType": "Array\u003cAL_Review\u003e", + "usedTypescriptType": "AL_Review", + "usedStructName": "anilist.Review", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Recommendations", + "jsonName": "recommendations", + "goType": "[]Recommendation", + "typescriptType": "Array\u003cAL_Recommendation\u003e", + "usedTypescriptType": "AL_Recommendation", + "usedStructName": "anilist.Recommendation", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Likes", + "jsonName": "likes", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Page of data (Used for internal use only)" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ListActivity", + "formattedName": "AL_ListActivity", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "ActivityType", + "typescriptType": "AL_ActivityType", + "usedTypescriptType": "AL_ActivityType", + "usedStructName": "anilist.ActivityType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReplyCount", + "jsonName": "replyCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsLocked", + "jsonName": "isLocked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsSubscribed", + "jsonName": "isSubscribed", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LikeCount", + "jsonName": "likeCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsLiked", + "jsonName": "isLiked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsPinned", + "jsonName": "isPinned", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Replies", + "jsonName": "replies", + "goType": "[]ActivityReply", + "typescriptType": "Array\u003cAL_ActivityReply\u003e", + "usedTypescriptType": "AL_ActivityReply", + "usedStructName": "anilist.ActivityReply", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Likes", + "jsonName": "likes", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User list activity (anime \u0026 manga updates)" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ListActivityOption", + "formattedName": "AL_ListActivityOption", + "package": "anilist", + "fields": [ + { + "name": "Disabled", + "jsonName": "disabled", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ListActivityOptionInput", + "formattedName": "AL_ListActivityOptionInput", + "package": "anilist", + "fields": [ + { + "name": "Disabled", + "jsonName": "disabled", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ListScoreStats", + "formattedName": "AL_ListScoreStats", + "package": "anilist", + "fields": [ + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StandardDeviation", + "jsonName": "standardDeviation", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User's list score statistics" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Media", + "formattedName": "AL_Media", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IDMal", + "jsonName": "idMal", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "MediaTitle", + "typescriptType": "AL_MediaTitle", + "usedTypescriptType": "AL_MediaTitle", + "usedStructName": "anilist.MediaTitle", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "MediaType", + "typescriptType": "AL_MediaType", + "usedTypescriptType": "AL_MediaType", + "usedStructName": "anilist.MediaType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaStatus", + "typescriptType": "AL_MediaStatus", + "usedTypescriptType": "AL_MediaStatus", + "usedStructName": "anilist.MediaStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "startDate", + "goType": "FuzzyDate", + "typescriptType": "AL_FuzzyDate", + "usedTypescriptType": "AL_FuzzyDate", + "usedStructName": "anilist.FuzzyDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EndDate", + "jsonName": "endDate", + "goType": "FuzzyDate", + "typescriptType": "AL_FuzzyDate", + "usedTypescriptType": "AL_FuzzyDate", + "usedStructName": "anilist.FuzzyDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "MediaSeason", + "typescriptType": "AL_MediaSeason", + "usedTypescriptType": "AL_MediaSeason", + "usedStructName": "anilist.MediaSeason", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SeasonYear", + "jsonName": "seasonYear", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SeasonInt", + "jsonName": "seasonInt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Chapters", + "jsonName": "chapters", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Volumes", + "jsonName": "volumes", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CountryOfOrigin", + "jsonName": "countryOfOrigin", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsLicensed", + "jsonName": "isLicensed", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Source", + "jsonName": "source", + "goType": "MediaSource", + "typescriptType": "AL_MediaSource", + "usedTypescriptType": "AL_MediaSource", + "usedStructName": "anilist.MediaSource", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Hashtag", + "jsonName": "hashtag", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Trailer", + "jsonName": "trailer", + "goType": "MediaTrailer", + "typescriptType": "AL_MediaTrailer", + "usedTypescriptType": "AL_MediaTrailer", + "usedStructName": "anilist.MediaTrailer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CoverImage", + "jsonName": "coverImage", + "goType": "MediaCoverImage", + "typescriptType": "AL_MediaCoverImage", + "usedTypescriptType": "AL_MediaCoverImage", + "usedStructName": "anilist.MediaCoverImage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BannerImage", + "jsonName": "bannerImage", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Synonyms", + "jsonName": "synonyms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AverageScore", + "jsonName": "averageScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Popularity", + "jsonName": "popularity", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsLocked", + "jsonName": "isLocked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Trending", + "jsonName": "trending", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Favourites", + "jsonName": "favourites", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Tags", + "jsonName": "tags", + "goType": "[]MediaTag", + "typescriptType": "Array\u003cAL_MediaTag\u003e", + "usedTypescriptType": "AL_MediaTag", + "usedStructName": "anilist.MediaTag", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Relations", + "jsonName": "relations", + "goType": "MediaConnection", + "typescriptType": "AL_MediaConnection", + "usedTypescriptType": "AL_MediaConnection", + "usedStructName": "anilist.MediaConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Characters", + "jsonName": "characters", + "goType": "CharacterConnection", + "typescriptType": "AL_CharacterConnection", + "usedTypescriptType": "AL_CharacterConnection", + "usedStructName": "anilist.CharacterConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "StaffConnection", + "typescriptType": "AL_StaffConnection", + "usedTypescriptType": "AL_StaffConnection", + "usedStructName": "anilist.StaffConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studios", + "jsonName": "studios", + "goType": "StudioConnection", + "typescriptType": "AL_StudioConnection", + "usedTypescriptType": "AL_StudioConnection", + "usedStructName": "anilist.StudioConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsFavourite", + "jsonName": "isFavourite", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsFavouriteBlocked", + "jsonName": "isFavouriteBlocked", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsAdult", + "jsonName": "isAdult", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NextAiringEpisode", + "jsonName": "nextAiringEpisode", + "goType": "AiringSchedule", + "typescriptType": "AL_AiringSchedule", + "usedTypescriptType": "AL_AiringSchedule", + "usedStructName": "anilist.AiringSchedule", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AiringSchedule", + "jsonName": "airingSchedule", + "goType": "AiringScheduleConnection", + "typescriptType": "AL_AiringScheduleConnection", + "usedTypescriptType": "AL_AiringScheduleConnection", + "usedStructName": "anilist.AiringScheduleConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Trends", + "jsonName": "trends", + "goType": "MediaTrendConnection", + "typescriptType": "AL_MediaTrendConnection", + "usedTypescriptType": "AL_MediaTrendConnection", + "usedStructName": "anilist.MediaTrendConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ExternalLinks", + "jsonName": "externalLinks", + "goType": "[]MediaExternalLink", + "typescriptType": "Array\u003cAL_MediaExternalLink\u003e", + "usedTypescriptType": "AL_MediaExternalLink", + "usedStructName": "anilist.MediaExternalLink", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StreamingEpisodes", + "jsonName": "streamingEpisodes", + "goType": "[]MediaStreamingEpisode", + "typescriptType": "Array\u003cAL_MediaStreamingEpisode\u003e", + "usedTypescriptType": "AL_MediaStreamingEpisode", + "usedStructName": "anilist.MediaStreamingEpisode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rankings", + "jsonName": "rankings", + "goType": "[]MediaRank", + "typescriptType": "Array\u003cAL_MediaRank\u003e", + "usedTypescriptType": "AL_MediaRank", + "usedStructName": "anilist.MediaRank", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaListEntry", + "jsonName": "mediaListEntry", + "goType": "MediaList", + "typescriptType": "AL_MediaList", + "usedTypescriptType": "AL_MediaList", + "usedStructName": "anilist.MediaList", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Reviews", + "jsonName": "reviews", + "goType": "ReviewConnection", + "typescriptType": "AL_ReviewConnection", + "usedTypescriptType": "AL_ReviewConnection", + "usedStructName": "anilist.ReviewConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Recommendations", + "jsonName": "recommendations", + "goType": "RecommendationConnection", + "typescriptType": "AL_RecommendationConnection", + "usedTypescriptType": "AL_RecommendationConnection", + "usedStructName": "anilist.RecommendationConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Stats", + "jsonName": "stats", + "goType": "MediaStats", + "typescriptType": "AL_MediaStats", + "usedTypescriptType": "AL_MediaStats", + "usedStructName": "anilist.MediaStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AutoCreateForumThread", + "jsonName": "autoCreateForumThread", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsRecommendationBlocked", + "jsonName": "isRecommendationBlocked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsReviewBlocked", + "jsonName": "isReviewBlocked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ModNotes", + "jsonName": "modNotes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Anime or Manga" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaCharacter", + "formattedName": "AL_MediaCharacter", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Role", + "jsonName": "role", + "goType": "CharacterRole", + "typescriptType": "AL_CharacterRole", + "usedTypescriptType": "AL_CharacterRole", + "usedStructName": "anilist.CharacterRole", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RoleNotes", + "jsonName": "roleNotes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DubGroup", + "jsonName": "dubGroup", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CharacterName", + "jsonName": "characterName", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Character", + "jsonName": "character", + "goType": "Character", + "typescriptType": "AL_Character", + "usedTypescriptType": "AL_Character", + "usedStructName": "anilist.Character", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VoiceActor", + "jsonName": "voiceActor", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Internal - Media characters separated" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaConnection", + "formattedName": "AL_MediaConnection", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]MediaEdge", + "typescriptType": "Array\u003cAL_MediaEdge\u003e", + "usedTypescriptType": "AL_MediaEdge", + "usedStructName": "anilist.MediaEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]Media", + "typescriptType": "Array\u003cAL_Media\u003e", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaCoverImage", + "formattedName": "AL_MediaCoverImage", + "package": "anilist", + "fields": [ + { + "name": "ExtraLarge", + "jsonName": "extraLarge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaDataChangeNotification", + "formattedName": "AL_MediaDataChangeNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Reason", + "jsonName": "reason", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a media entry's data was changed in a significant way impacting users' list tracking" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaDeletionNotification", + "formattedName": "AL_MediaDeletionNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DeletedMediaTitle", + "jsonName": "deletedMediaTitle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Reason", + "jsonName": "reason", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a media tracked in a user's list is deleted from the site" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaEdge", + "formattedName": "AL_MediaEdge", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RelationType", + "jsonName": "relationType", + "goType": "MediaRelation", + "typescriptType": "AL_MediaRelation", + "usedTypescriptType": "AL_MediaRelation", + "usedStructName": "anilist.MediaRelation", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsMainStudio", + "jsonName": "isMainStudio", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Characters", + "jsonName": "characters", + "goType": "[]Character", + "typescriptType": "Array\u003cAL_Character\u003e", + "usedTypescriptType": "AL_Character", + "usedStructName": "anilist.Character", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CharacterRole", + "jsonName": "characterRole", + "goType": "CharacterRole", + "typescriptType": "AL_CharacterRole", + "usedTypescriptType": "AL_CharacterRole", + "usedStructName": "anilist.CharacterRole", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CharacterName", + "jsonName": "characterName", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RoleNotes", + "jsonName": "roleNotes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DubGroup", + "jsonName": "dubGroup", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StaffRole", + "jsonName": "staffRole", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VoiceActors", + "jsonName": "voiceActors", + "goType": "[]Staff", + "typescriptType": "Array\u003cAL_Staff\u003e", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VoiceActorRoles", + "jsonName": "voiceActorRoles", + "goType": "[]StaffRoleType", + "typescriptType": "Array\u003cAL_StaffRoleType\u003e", + "usedTypescriptType": "AL_StaffRoleType", + "usedStructName": "anilist.StaffRoleType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FavouriteOrder", + "jsonName": "favouriteOrder", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Media connection edge" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaExternalLink", + "formattedName": "AL_MediaExternalLink", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SiteID", + "jsonName": "siteId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "ExternalLinkType", + "typescriptType": "AL_ExternalLinkType", + "usedTypescriptType": "AL_ExternalLinkType", + "usedStructName": "anilist.ExternalLinkType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Color", + "jsonName": "color", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Icon", + "jsonName": "icon", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Notes", + "jsonName": "notes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsDisabled", + "jsonName": "isDisabled", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " An external link to another site related to the media or staff member" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaExternalLinkInput", + "formattedName": "AL_MediaExternalLinkInput", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " An external link to another site related to the media" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaList", + "formattedName": "AL_MediaList", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ProgressVolumes", + "jsonName": "progressVolumes", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Repeat", + "jsonName": "repeat", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Priority", + "jsonName": "priority", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Private", + "jsonName": "private", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Notes", + "jsonName": "notes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "HiddenFromStatusLists", + "jsonName": "hiddenFromStatusLists", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CustomLists", + "jsonName": "customLists", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AdvancedScores", + "jsonName": "advancedScores", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartedAt", + "jsonName": "startedAt", + "goType": "FuzzyDate", + "typescriptType": "AL_FuzzyDate", + "usedTypescriptType": "AL_FuzzyDate", + "usedStructName": "anilist.FuzzyDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompletedAt", + "jsonName": "completedAt", + "goType": "FuzzyDate", + "typescriptType": "AL_FuzzyDate", + "usedTypescriptType": "AL_FuzzyDate", + "usedStructName": "anilist.FuzzyDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " List of anime or manga" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaListCollection", + "formattedName": "AL_MediaListCollection", + "package": "anilist", + "fields": [ + { + "name": "Lists", + "jsonName": "lists", + "goType": "[]MediaListGroup", + "typescriptType": "Array\u003cAL_MediaListGroup\u003e", + "usedTypescriptType": "AL_MediaListGroup", + "usedStructName": "anilist.MediaListGroup", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "HasNextChunk", + "jsonName": "hasNextChunk", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StatusLists", + "jsonName": "statusLists", + "goType": "[][]MediaList", + "typescriptType": "Array\u003cArray\u003cAL_MediaList\u003e\u003e", + "usedTypescriptType": "AL_MediaList", + "usedStructName": "anilist.[]MediaList", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CustomLists", + "jsonName": "customLists", + "goType": "[][]MediaList", + "typescriptType": "Array\u003cArray\u003cAL_MediaList\u003e\u003e", + "usedTypescriptType": "AL_MediaList", + "usedStructName": "anilist.[]MediaList", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " List of anime or manga" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaListGroup", + "formattedName": "AL_MediaListGroup", + "package": "anilist", + "fields": [ + { + "name": "Entries", + "jsonName": "entries", + "goType": "[]MediaList", + "typescriptType": "Array\u003cAL_MediaList\u003e", + "usedTypescriptType": "AL_MediaList", + "usedStructName": "anilist.MediaList", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsCustomList", + "jsonName": "isCustomList", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsSplitCompletedList", + "jsonName": "isSplitCompletedList", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " List group of anime or manga entries" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaListOptions", + "formattedName": "AL_MediaListOptions", + "package": "anilist", + "fields": [ + { + "name": "ScoreFormat", + "jsonName": "scoreFormat", + "goType": "ScoreFormat", + "typescriptType": "AL_ScoreFormat", + "usedTypescriptType": "AL_ScoreFormat", + "usedStructName": "anilist.ScoreFormat", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RowOrder", + "jsonName": "rowOrder", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UseLegacyLists", + "jsonName": "useLegacyLists", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeList", + "jsonName": "animeList", + "goType": "MediaListTypeOptions", + "typescriptType": "AL_MediaListTypeOptions", + "usedTypescriptType": "AL_MediaListTypeOptions", + "usedStructName": "anilist.MediaListTypeOptions", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaList", + "jsonName": "mangaList", + "goType": "MediaListTypeOptions", + "typescriptType": "AL_MediaListTypeOptions", + "usedTypescriptType": "AL_MediaListTypeOptions", + "usedStructName": "anilist.MediaListTypeOptions", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SharedTheme", + "jsonName": "sharedTheme", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SharedThemeEnabled", + "jsonName": "sharedThemeEnabled", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A user's list options" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaListOptionsInput", + "formattedName": "AL_MediaListOptionsInput", + "package": "anilist", + "fields": [ + { + "name": "SectionOrder", + "jsonName": "sectionOrder", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SplitCompletedSectionByFormat", + "jsonName": "splitCompletedSectionByFormat", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CustomLists", + "jsonName": "customLists", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AdvancedScoring", + "jsonName": "advancedScoring", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AdvancedScoringEnabled", + "jsonName": "advancedScoringEnabled", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Theme", + "jsonName": "theme", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A user's list options for anime or manga lists" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaListTypeOptions", + "formattedName": "AL_MediaListTypeOptions", + "package": "anilist", + "fields": [ + { + "name": "SectionOrder", + "jsonName": "sectionOrder", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SplitCompletedSectionByFormat", + "jsonName": "splitCompletedSectionByFormat", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Theme", + "jsonName": "theme", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CustomLists", + "jsonName": "customLists", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AdvancedScoring", + "jsonName": "advancedScoring", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AdvancedScoringEnabled", + "jsonName": "advancedScoringEnabled", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A user's list options for anime or manga lists" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaMergeNotification", + "formattedName": "AL_MediaMergeNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DeletedMediaTitles", + "jsonName": "deletedMediaTitles", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Reason", + "jsonName": "reason", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a media entry is merged into another for a user who had it on their list" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaRank", + "formattedName": "AL_MediaRank", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Rank", + "jsonName": "rank", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "MediaRankType", + "typescriptType": "AL_MediaRankType", + "usedTypescriptType": "AL_MediaRankType", + "usedStructName": "anilist.MediaRankType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "MediaSeason", + "typescriptType": "AL_MediaSeason", + "usedTypescriptType": "AL_MediaSeason", + "usedStructName": "anilist.MediaSeason", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AllTime", + "jsonName": "allTime", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " The ranking of a media in a particular time span and format compared to other media" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaStats", + "formattedName": "AL_MediaStats", + "package": "anilist", + "fields": [ + { + "name": "ScoreDistribution", + "jsonName": "scoreDistribution", + "goType": "[]ScoreDistribution", + "typescriptType": "Array\u003cAL_ScoreDistribution\u003e", + "usedTypescriptType": "AL_ScoreDistribution", + "usedStructName": "anilist.ScoreDistribution", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StatusDistribution", + "jsonName": "statusDistribution", + "goType": "[]StatusDistribution", + "typescriptType": "Array\u003cAL_StatusDistribution\u003e", + "usedTypescriptType": "AL_StatusDistribution", + "usedStructName": "anilist.StatusDistribution", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AiringProgression", + "jsonName": "airingProgression", + "goType": "[]AiringProgression", + "typescriptType": "Array\u003cAL_AiringProgression\u003e", + "usedTypescriptType": "AL_AiringProgression", + "usedStructName": "anilist.AiringProgression", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A media's statistics" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaStreamingEpisode", + "formattedName": "AL_MediaStreamingEpisode", + "package": "anilist", + "fields": [ + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Data and links to legal streaming episodes on external sites" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaSubmission", + "formattedName": "AL_MediaSubmission", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Submitter", + "jsonName": "submitter", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Assignee", + "jsonName": "assignee", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "SubmissionStatus", + "typescriptType": "AL_SubmissionStatus", + "usedTypescriptType": "AL_SubmissionStatus", + "usedStructName": "anilist.SubmissionStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SubmitterStats", + "jsonName": "submitterStats", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Notes", + "jsonName": "notes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Source", + "jsonName": "source", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Changes", + "jsonName": "changes", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Locked", + "jsonName": "locked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Submission", + "jsonName": "submission", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Characters", + "jsonName": "characters", + "goType": "[]MediaSubmissionComparison", + "typescriptType": "Array\u003cAL_MediaSubmissionComparison\u003e", + "usedTypescriptType": "AL_MediaSubmissionComparison", + "usedStructName": "anilist.MediaSubmissionComparison", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "[]MediaSubmissionComparison", + "typescriptType": "Array\u003cAL_MediaSubmissionComparison\u003e", + "usedTypescriptType": "AL_MediaSubmissionComparison", + "usedStructName": "anilist.MediaSubmissionComparison", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studios", + "jsonName": "studios", + "goType": "[]MediaSubmissionComparison", + "typescriptType": "Array\u003cAL_MediaSubmissionComparison\u003e", + "usedTypescriptType": "AL_MediaSubmissionComparison", + "usedStructName": "anilist.MediaSubmissionComparison", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Relations", + "jsonName": "relations", + "goType": "[]MediaEdge", + "typescriptType": "Array\u003cAL_MediaEdge\u003e", + "usedTypescriptType": "AL_MediaEdge", + "usedStructName": "anilist.MediaEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ExternalLinks", + "jsonName": "externalLinks", + "goType": "[]MediaSubmissionComparison", + "typescriptType": "Array\u003cAL_MediaSubmissionComparison\u003e", + "usedTypescriptType": "AL_MediaSubmissionComparison", + "usedStructName": "anilist.MediaSubmissionComparison", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Media submission" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaSubmissionComparison", + "formattedName": "AL_MediaSubmissionComparison", + "package": "anilist", + "fields": [ + { + "name": "Submission", + "jsonName": "submission", + "goType": "MediaSubmissionEdge", + "typescriptType": "AL_MediaSubmissionEdge", + "usedTypescriptType": "AL_MediaSubmissionEdge", + "usedStructName": "anilist.MediaSubmissionEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Character", + "jsonName": "character", + "goType": "MediaCharacter", + "typescriptType": "AL_MediaCharacter", + "usedTypescriptType": "AL_MediaCharacter", + "usedStructName": "anilist.MediaCharacter", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "StaffEdge", + "typescriptType": "AL_StaffEdge", + "usedTypescriptType": "AL_StaffEdge", + "usedStructName": "anilist.StaffEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studio", + "jsonName": "studio", + "goType": "StudioEdge", + "typescriptType": "AL_StudioEdge", + "usedTypescriptType": "AL_StudioEdge", + "usedStructName": "anilist.StudioEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ExternalLink", + "jsonName": "externalLink", + "goType": "MediaExternalLink", + "typescriptType": "AL_MediaExternalLink", + "usedTypescriptType": "AL_MediaExternalLink", + "usedStructName": "anilist.MediaExternalLink", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Media submission with comparison to current data" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaSubmissionEdge", + "formattedName": "AL_MediaSubmissionEdge", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CharacterRole", + "jsonName": "characterRole", + "goType": "CharacterRole", + "typescriptType": "AL_CharacterRole", + "usedTypescriptType": "AL_CharacterRole", + "usedStructName": "anilist.CharacterRole", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StaffRole", + "jsonName": "staffRole", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RoleNotes", + "jsonName": "roleNotes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DubGroup", + "jsonName": "dubGroup", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CharacterName", + "jsonName": "characterName", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsMain", + "jsonName": "isMain", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Character", + "jsonName": "character", + "goType": "Character", + "typescriptType": "AL_Character", + "usedTypescriptType": "AL_Character", + "usedStructName": "anilist.Character", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CharacterSubmission", + "jsonName": "characterSubmission", + "goType": "Character", + "typescriptType": "AL_Character", + "usedTypescriptType": "AL_Character", + "usedStructName": "anilist.Character", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VoiceActor", + "jsonName": "voiceActor", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VoiceActorSubmission", + "jsonName": "voiceActorSubmission", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StaffSubmission", + "jsonName": "staffSubmission", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studio", + "jsonName": "studio", + "goType": "Studio", + "typescriptType": "AL_Studio", + "usedTypescriptType": "AL_Studio", + "usedStructName": "anilist.Studio", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ExternalLink", + "jsonName": "externalLink", + "goType": "MediaExternalLink", + "typescriptType": "AL_MediaExternalLink", + "usedTypescriptType": "AL_MediaExternalLink", + "usedStructName": "anilist.MediaExternalLink", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaTag", + "formattedName": "AL_MediaTag", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Category", + "jsonName": "category", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rank", + "jsonName": "rank", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsGeneralSpoiler", + "jsonName": "isGeneralSpoiler", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsMediaSpoiler", + "jsonName": "isMediaSpoiler", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsAdult", + "jsonName": "isAdult", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A tag that describes a theme or element of the media" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaTitle", + "formattedName": "AL_MediaTitle", + "package": "anilist", + "fields": [ + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " The official titles of the media in various languages" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaTitleInput", + "formattedName": "AL_MediaTitleInput", + "package": "anilist", + "fields": [ + { + "name": "Romaji", + "jsonName": "romaji", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "English", + "jsonName": "english", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " The official titles of the media in various languages" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaTrailer", + "formattedName": "AL_MediaTrailer", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Site", + "jsonName": "site", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thumbnail", + "jsonName": "thumbnail", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Media trailer or advertisement" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaTrend", + "formattedName": "AL_MediaTrend", + "package": "anilist", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Date", + "jsonName": "date", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Trending", + "jsonName": "trending", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AverageScore", + "jsonName": "averageScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Popularity", + "jsonName": "popularity", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "InProgress", + "jsonName": "inProgress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Releasing", + "jsonName": "releasing", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Daily media statistics" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaTrendConnection", + "formattedName": "AL_MediaTrendConnection", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]MediaTrendEdge", + "typescriptType": "Array\u003cAL_MediaTrendEdge\u003e", + "usedTypescriptType": "AL_MediaTrendEdge", + "usedStructName": "anilist.MediaTrendEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]MediaTrend", + "typescriptType": "Array\u003cAL_MediaTrend\u003e", + "usedTypescriptType": "AL_MediaTrend", + "usedStructName": "anilist.MediaTrend", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaTrendEdge", + "formattedName": "AL_MediaTrendEdge", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "MediaTrend", + "typescriptType": "AL_MediaTrend", + "usedTypescriptType": "AL_MediaTrend", + "usedStructName": "anilist.MediaTrend", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Media trend connection edge" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MessageActivity", + "formattedName": "AL_MessageActivity", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RecipientID", + "jsonName": "recipientId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MessengerID", + "jsonName": "messengerId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "ActivityType", + "typescriptType": "AL_ActivityType", + "usedTypescriptType": "AL_ActivityType", + "usedStructName": "anilist.ActivityType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReplyCount", + "jsonName": "replyCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Message", + "jsonName": "message", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsLocked", + "jsonName": "isLocked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsSubscribed", + "jsonName": "isSubscribed", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LikeCount", + "jsonName": "likeCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsLiked", + "jsonName": "isLiked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsPrivate", + "jsonName": "isPrivate", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Recipient", + "jsonName": "recipient", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Messenger", + "jsonName": "messenger", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Replies", + "jsonName": "replies", + "goType": "[]ActivityReply", + "typescriptType": "Array\u003cAL_ActivityReply\u003e", + "usedTypescriptType": "AL_ActivityReply", + "usedStructName": "anilist.ActivityReply", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Likes", + "jsonName": "likes", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User message activity" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ModAction", + "formattedName": "AL_ModAction", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Mod", + "jsonName": "mod", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "ModActionType", + "typescriptType": "AL_ModActionType", + "usedTypescriptType": "AL_ModActionType", + "usedStructName": "anilist.ModActionType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ObjectID", + "jsonName": "objectId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ObjectType", + "jsonName": "objectType", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "data", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Mutation", + "formattedName": "AL_Mutation", + "package": "anilist", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "NotificationOption", + "formattedName": "AL_NotificationOption", + "package": "anilist", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification option" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "NotificationOptionInput", + "formattedName": "AL_NotificationOptionInput", + "package": "anilist", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification option input" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Page", + "formattedName": "AL_Page", + "package": "anilist", + "fields": [ + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Users", + "jsonName": "users", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "[]Media", + "typescriptType": "Array\u003cAL_Media\u003e", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Characters", + "jsonName": "characters", + "goType": "[]Character", + "typescriptType": "Array\u003cAL_Character\u003e", + "usedTypescriptType": "AL_Character", + "usedStructName": "anilist.Character", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "[]Staff", + "typescriptType": "Array\u003cAL_Staff\u003e", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studios", + "jsonName": "studios", + "goType": "[]Studio", + "typescriptType": "Array\u003cAL_Studio\u003e", + "usedTypescriptType": "AL_Studio", + "usedStructName": "anilist.Studio", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaList", + "jsonName": "mediaList", + "goType": "[]MediaList", + "typescriptType": "Array\u003cAL_MediaList\u003e", + "usedTypescriptType": "AL_MediaList", + "usedStructName": "anilist.MediaList", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AiringSchedules", + "jsonName": "airingSchedules", + "goType": "[]AiringSchedule", + "typescriptType": "Array\u003cAL_AiringSchedule\u003e", + "usedTypescriptType": "AL_AiringSchedule", + "usedStructName": "anilist.AiringSchedule", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaTrends", + "jsonName": "mediaTrends", + "goType": "[]MediaTrend", + "typescriptType": "Array\u003cAL_MediaTrend\u003e", + "usedTypescriptType": "AL_MediaTrend", + "usedStructName": "anilist.MediaTrend", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Notifications", + "jsonName": "notifications", + "goType": "[]NotificationUnion", + "typescriptType": "Array\u003cAL_NotificationUnion\u003e", + "usedTypescriptType": "AL_NotificationUnion", + "usedStructName": "anilist.NotificationUnion", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Followers", + "jsonName": "followers", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Following", + "jsonName": "following", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Activities", + "jsonName": "activities", + "goType": "[]ActivityUnion", + "typescriptType": "Array\u003cAL_ActivityUnion\u003e", + "usedTypescriptType": "AL_ActivityUnion", + "usedStructName": "anilist.ActivityUnion", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ActivityReplies", + "jsonName": "activityReplies", + "goType": "[]ActivityReply", + "typescriptType": "Array\u003cAL_ActivityReply\u003e", + "usedTypescriptType": "AL_ActivityReply", + "usedStructName": "anilist.ActivityReply", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Threads", + "jsonName": "threads", + "goType": "[]Thread", + "typescriptType": "Array\u003cAL_Thread\u003e", + "usedTypescriptType": "AL_Thread", + "usedStructName": "anilist.Thread", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ThreadComments", + "jsonName": "threadComments", + "goType": "[]ThreadComment", + "typescriptType": "Array\u003cAL_ThreadComment\u003e", + "usedTypescriptType": "AL_ThreadComment", + "usedStructName": "anilist.ThreadComment", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Reviews", + "jsonName": "reviews", + "goType": "[]Review", + "typescriptType": "Array\u003cAL_Review\u003e", + "usedTypescriptType": "AL_Review", + "usedStructName": "anilist.Review", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Recommendations", + "jsonName": "recommendations", + "goType": "[]Recommendation", + "typescriptType": "Array\u003cAL_Recommendation\u003e", + "usedTypescriptType": "AL_Recommendation", + "usedStructName": "anilist.Recommendation", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Likes", + "jsonName": "likes", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Page of data" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "PageInfo", + "formattedName": "AL_PageInfo", + "package": "anilist", + "fields": [ + { + "name": "Total", + "jsonName": "total", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PerPage", + "jsonName": "perPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CurrentPage", + "jsonName": "currentPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LastPage", + "jsonName": "lastPage", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "HasNextPage", + "jsonName": "hasNextPage", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ParsedMarkdown", + "formattedName": "AL_ParsedMarkdown", + "package": "anilist", + "fields": [ + { + "name": "HTML", + "jsonName": "html", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Provides the parsed markdown as html" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Query", + "formattedName": "AL_Query", + "package": "anilist", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Recommendation", + "formattedName": "AL_Recommendation", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Rating", + "jsonName": "rating", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserRating", + "jsonName": "userRating", + "goType": "RecommendationRating", + "typescriptType": "AL_RecommendationRating", + "usedTypescriptType": "AL_RecommendationRating", + "usedStructName": "anilist.RecommendationRating", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaRecommendation", + "jsonName": "mediaRecommendation", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Media recommendation" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "RecommendationConnection", + "formattedName": "AL_RecommendationConnection", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]RecommendationEdge", + "typescriptType": "Array\u003cAL_RecommendationEdge\u003e", + "usedTypescriptType": "AL_RecommendationEdge", + "usedStructName": "anilist.RecommendationEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]Recommendation", + "typescriptType": "Array\u003cAL_Recommendation\u003e", + "usedTypescriptType": "AL_Recommendation", + "usedStructName": "anilist.Recommendation", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "RecommendationEdge", + "formattedName": "AL_RecommendationEdge", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "Recommendation", + "typescriptType": "AL_Recommendation", + "usedTypescriptType": "AL_Recommendation", + "usedStructName": "anilist.Recommendation", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Recommendation connection edge" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "RelatedMediaAdditionNotification", + "formattedName": "AL_RelatedMediaAdditionNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when new media is added to the site" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Report", + "formattedName": "AL_Report", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Reporter", + "jsonName": "reporter", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Reported", + "jsonName": "reported", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Reason", + "jsonName": "reason", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Cleared", + "jsonName": "cleared", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Review", + "formattedName": "AL_Review", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaType", + "jsonName": "mediaType", + "goType": "MediaType", + "typescriptType": "AL_MediaType", + "usedTypescriptType": "AL_MediaType", + "usedStructName": "anilist.MediaType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Summary", + "jsonName": "summary", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Body", + "jsonName": "body", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rating", + "jsonName": "rating", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RatingAmount", + "jsonName": "ratingAmount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserRating", + "jsonName": "userRating", + "goType": "ReviewRating", + "typescriptType": "AL_ReviewRating", + "usedTypescriptType": "AL_ReviewRating", + "usedStructName": "anilist.ReviewRating", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Private", + "jsonName": "private", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A Review that features in an anime or manga" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ReviewConnection", + "formattedName": "AL_ReviewConnection", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]ReviewEdge", + "typescriptType": "Array\u003cAL_ReviewEdge\u003e", + "usedTypescriptType": "AL_ReviewEdge", + "usedStructName": "anilist.ReviewEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]Review", + "typescriptType": "Array\u003cAL_Review\u003e", + "usedTypescriptType": "AL_Review", + "usedStructName": "anilist.Review", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ReviewEdge", + "formattedName": "AL_ReviewEdge", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "Review", + "typescriptType": "AL_Review", + "usedTypescriptType": "AL_Review", + "usedStructName": "anilist.Review", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Review connection edge" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "RevisionHistory", + "formattedName": "AL_RevisionHistory", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Action", + "jsonName": "action", + "goType": "RevisionHistoryAction", + "typescriptType": "AL_RevisionHistoryAction", + "usedTypescriptType": "AL_RevisionHistoryAction", + "usedStructName": "anilist.RevisionHistoryAction", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Changes", + "jsonName": "changes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "AL_Media", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Character", + "jsonName": "character", + "goType": "Character", + "typescriptType": "AL_Character", + "usedTypescriptType": "AL_Character", + "usedStructName": "anilist.Character", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studio", + "jsonName": "studio", + "goType": "Studio", + "typescriptType": "AL_Studio", + "usedTypescriptType": "AL_Studio", + "usedStructName": "anilist.Studio", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ExternalLink", + "jsonName": "externalLink", + "goType": "MediaExternalLink", + "typescriptType": "AL_MediaExternalLink", + "usedTypescriptType": "AL_MediaExternalLink", + "usedStructName": "anilist.MediaExternalLink", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Feed of mod edit activity" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ScoreDistribution", + "formattedName": "AL_ScoreDistribution", + "package": "anilist", + "fields": [ + { + "name": "Score", + "jsonName": "score", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Amount", + "jsonName": "amount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A user's list score distribution." + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "SiteStatistics", + "formattedName": "AL_SiteStatistics", + "package": "anilist", + "fields": [ + { + "name": "Users", + "jsonName": "users", + "goType": "SiteTrendConnection", + "typescriptType": "AL_SiteTrendConnection", + "usedTypescriptType": "AL_SiteTrendConnection", + "usedStructName": "anilist.SiteTrendConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Anime", + "jsonName": "anime", + "goType": "SiteTrendConnection", + "typescriptType": "AL_SiteTrendConnection", + "usedTypescriptType": "AL_SiteTrendConnection", + "usedStructName": "anilist.SiteTrendConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Manga", + "jsonName": "manga", + "goType": "SiteTrendConnection", + "typescriptType": "AL_SiteTrendConnection", + "usedTypescriptType": "AL_SiteTrendConnection", + "usedStructName": "anilist.SiteTrendConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Characters", + "jsonName": "characters", + "goType": "SiteTrendConnection", + "typescriptType": "AL_SiteTrendConnection", + "usedTypescriptType": "AL_SiteTrendConnection", + "usedStructName": "anilist.SiteTrendConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "SiteTrendConnection", + "typescriptType": "AL_SiteTrendConnection", + "usedTypescriptType": "AL_SiteTrendConnection", + "usedStructName": "anilist.SiteTrendConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studios", + "jsonName": "studios", + "goType": "SiteTrendConnection", + "typescriptType": "AL_SiteTrendConnection", + "usedTypescriptType": "AL_SiteTrendConnection", + "usedStructName": "anilist.SiteTrendConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Reviews", + "jsonName": "reviews", + "goType": "SiteTrendConnection", + "typescriptType": "AL_SiteTrendConnection", + "usedTypescriptType": "AL_SiteTrendConnection", + "usedStructName": "anilist.SiteTrendConnection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "SiteTrend", + "formattedName": "AL_SiteTrend", + "package": "anilist", + "fields": [ + { + "name": "Date", + "jsonName": "date", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Change", + "jsonName": "change", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Daily site statistics" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "SiteTrendConnection", + "formattedName": "AL_SiteTrendConnection", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]SiteTrendEdge", + "typescriptType": "Array\u003cAL_SiteTrendEdge\u003e", + "usedTypescriptType": "AL_SiteTrendEdge", + "usedStructName": "anilist.SiteTrendEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]SiteTrend", + "typescriptType": "Array\u003cAL_SiteTrend\u003e", + "usedTypescriptType": "AL_SiteTrend", + "usedStructName": "anilist.SiteTrend", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "SiteTrendEdge", + "formattedName": "AL_SiteTrendEdge", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "SiteTrend", + "typescriptType": "AL_SiteTrend", + "usedTypescriptType": "AL_SiteTrend", + "usedStructName": "anilist.SiteTrend", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Site trend connection edge" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Staff", + "formattedName": "AL_Staff", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "StaffName", + "typescriptType": "AL_StaffName", + "usedTypescriptType": "AL_StaffName", + "usedStructName": "anilist.StaffName", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "StaffLanguage", + "typescriptType": "AL_StaffLanguage", + "usedTypescriptType": "AL_StaffLanguage", + "usedStructName": "anilist.StaffLanguage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LanguageV2", + "jsonName": "languageV2", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "StaffImage", + "typescriptType": "AL_StaffImage", + "usedTypescriptType": "AL_StaffImage", + "usedStructName": "anilist.StaffImage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PrimaryOccupations", + "jsonName": "primaryOccupations", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Gender", + "jsonName": "gender", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DateOfBirth", + "jsonName": "dateOfBirth", + "goType": "FuzzyDate", + "typescriptType": "AL_FuzzyDate", + "usedTypescriptType": "AL_FuzzyDate", + "usedStructName": "anilist.FuzzyDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DateOfDeath", + "jsonName": "dateOfDeath", + "goType": "FuzzyDate", + "typescriptType": "AL_FuzzyDate", + "usedTypescriptType": "AL_FuzzyDate", + "usedStructName": "anilist.FuzzyDate", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Age", + "jsonName": "age", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "YearsActive", + "jsonName": "yearsActive", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "HomeTown", + "jsonName": "homeTown", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BloodType", + "jsonName": "bloodType", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsFavourite", + "jsonName": "isFavourite", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsFavouriteBlocked", + "jsonName": "isFavouriteBlocked", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StaffMedia", + "jsonName": "staffMedia", + "goType": "MediaConnection", + "typescriptType": "AL_MediaConnection", + "usedTypescriptType": "AL_MediaConnection", + "usedStructName": "anilist.MediaConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Characters", + "jsonName": "characters", + "goType": "CharacterConnection", + "typescriptType": "AL_CharacterConnection", + "usedTypescriptType": "AL_CharacterConnection", + "usedStructName": "anilist.CharacterConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CharacterMedia", + "jsonName": "characterMedia", + "goType": "MediaConnection", + "typescriptType": "AL_MediaConnection", + "usedTypescriptType": "AL_MediaConnection", + "usedStructName": "anilist.MediaConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Submitter", + "jsonName": "submitter", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SubmissionStatus", + "jsonName": "submissionStatus", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SubmissionNotes", + "jsonName": "submissionNotes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Favourites", + "jsonName": "favourites", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ModNotes", + "jsonName": "modNotes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Voice actors or production staff" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StaffConnection", + "formattedName": "AL_StaffConnection", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]StaffEdge", + "typescriptType": "Array\u003cAL_StaffEdge\u003e", + "usedTypescriptType": "AL_StaffEdge", + "usedStructName": "anilist.StaffEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]Staff", + "typescriptType": "Array\u003cAL_Staff\u003e", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StaffEdge", + "formattedName": "AL_StaffEdge", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Role", + "jsonName": "role", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FavouriteOrder", + "jsonName": "favouriteOrder", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Staff connection edge" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StaffImage", + "formattedName": "AL_StaffImage", + "package": "anilist", + "fields": [ + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StaffName", + "formattedName": "AL_StaffName", + "package": "anilist", + "fields": [ + { + "name": "First", + "jsonName": "first", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Middle", + "jsonName": "middle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Last", + "jsonName": "last", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Full", + "jsonName": "full", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Alternative", + "jsonName": "alternative", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserPreferred", + "jsonName": "userPreferred", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " The names of the staff member" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StaffNameInput", + "formattedName": "AL_StaffNameInput", + "package": "anilist", + "fields": [ + { + "name": "First", + "jsonName": "first", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Middle", + "jsonName": "middle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Last", + "jsonName": "last", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Native", + "jsonName": "native", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Alternative", + "jsonName": "alternative", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " The names of the staff member" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StaffRoleType", + "formattedName": "AL_StaffRoleType", + "package": "anilist", + "fields": [ + { + "name": "VoiceActor", + "jsonName": "voiceActor", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RoleNotes", + "jsonName": "roleNotes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DubGroup", + "jsonName": "dubGroup", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Voice actor role for a character" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StaffStats", + "formattedName": "AL_StaffStats", + "package": "anilist", + "fields": [ + { + "name": "Staff", + "jsonName": "staff", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Amount", + "jsonName": "amount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TimeWatched", + "jsonName": "timeWatched", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User's staff statistics" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StaffSubmission", + "formattedName": "AL_StaffSubmission", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Submission", + "jsonName": "submission", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Submitter", + "jsonName": "submitter", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Assignee", + "jsonName": "assignee", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "SubmissionStatus", + "typescriptType": "AL_SubmissionStatus", + "usedTypescriptType": "AL_SubmissionStatus", + "usedStructName": "anilist.SubmissionStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Notes", + "jsonName": "notes", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Source", + "jsonName": "source", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Locked", + "jsonName": "locked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A submission for a staff that features in an anime or manga" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StatusDistribution", + "formattedName": "AL_StatusDistribution", + "package": "anilist", + "fields": [ + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Amount", + "jsonName": "amount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " The distribution of the watching/reading status of media or a user's list" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Studio", + "formattedName": "AL_Studio", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsAnimationStudio", + "jsonName": "isAnimationStudio", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "MediaConnection", + "typescriptType": "AL_MediaConnection", + "usedTypescriptType": "AL_MediaConnection", + "usedStructName": "anilist.MediaConnection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsFavourite", + "jsonName": "isFavourite", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Favourites", + "jsonName": "favourites", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Animation or production company" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StudioConnection", + "formattedName": "AL_StudioConnection", + "package": "anilist", + "fields": [ + { + "name": "Edges", + "jsonName": "edges", + "goType": "[]StudioEdge", + "typescriptType": "Array\u003cAL_StudioEdge\u003e", + "usedTypescriptType": "AL_StudioEdge", + "usedStructName": "anilist.StudioEdge", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nodes", + "jsonName": "nodes", + "goType": "[]Studio", + "typescriptType": "Array\u003cAL_Studio\u003e", + "usedTypescriptType": "AL_Studio", + "usedStructName": "anilist.Studio", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageInfo", + "jsonName": "pageInfo", + "goType": "PageInfo", + "typescriptType": "AL_PageInfo", + "usedTypescriptType": "AL_PageInfo", + "usedStructName": "anilist.PageInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StudioEdge", + "formattedName": "AL_StudioEdge", + "package": "anilist", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "Studio", + "typescriptType": "AL_Studio", + "usedTypescriptType": "AL_Studio", + "usedStructName": "anilist.Studio", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsMain", + "jsonName": "isMain", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FavouriteOrder", + "jsonName": "favouriteOrder", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Studio connection edge" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StudioStats", + "formattedName": "AL_StudioStats", + "package": "anilist", + "fields": [ + { + "name": "Studio", + "jsonName": "studio", + "goType": "Studio", + "typescriptType": "AL_Studio", + "usedTypescriptType": "AL_Studio", + "usedStructName": "anilist.Studio", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Amount", + "jsonName": "amount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TimeWatched", + "jsonName": "timeWatched", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User's studio statistics" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "TagStats", + "formattedName": "AL_TagStats", + "package": "anilist", + "fields": [ + { + "name": "Tag", + "jsonName": "tag", + "goType": "MediaTag", + "typescriptType": "AL_MediaTag", + "usedTypescriptType": "AL_MediaTag", + "usedStructName": "anilist.MediaTag", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Amount", + "jsonName": "amount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TimeWatched", + "jsonName": "timeWatched", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User's tag statistics" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "TextActivity", + "formattedName": "AL_TextActivity", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "ActivityType", + "typescriptType": "AL_ActivityType", + "usedTypescriptType": "AL_ActivityType", + "usedStructName": "anilist.ActivityType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReplyCount", + "jsonName": "replyCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Text", + "jsonName": "text", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsLocked", + "jsonName": "isLocked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsSubscribed", + "jsonName": "isSubscribed", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LikeCount", + "jsonName": "likeCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsLiked", + "jsonName": "isLiked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsPinned", + "jsonName": "isPinned", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Replies", + "jsonName": "replies", + "goType": "[]ActivityReply", + "typescriptType": "Array\u003cAL_ActivityReply\u003e", + "usedTypescriptType": "AL_ActivityReply", + "usedStructName": "anilist.ActivityReply", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Likes", + "jsonName": "likes", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User text activity" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "Thread", + "formattedName": "AL_Thread", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Body", + "jsonName": "body", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReplyUserID", + "jsonName": "replyUserId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReplyCommentID", + "jsonName": "replyCommentId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReplyCount", + "jsonName": "replyCount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ViewCount", + "jsonName": "viewCount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsLocked", + "jsonName": "isLocked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsSticky", + "jsonName": "isSticky", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsSubscribed", + "jsonName": "isSubscribed", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LikeCount", + "jsonName": "likeCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsLiked", + "jsonName": "isLiked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RepliedAt", + "jsonName": "repliedAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReplyUser", + "jsonName": "replyUser", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Likes", + "jsonName": "likes", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Categories", + "jsonName": "categories", + "goType": "[]ThreadCategory", + "typescriptType": "Array\u003cAL_ThreadCategory\u003e", + "usedTypescriptType": "AL_ThreadCategory", + "usedStructName": "anilist.ThreadCategory", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaCategories", + "jsonName": "mediaCategories", + "goType": "[]Media", + "typescriptType": "Array\u003cAL_Media\u003e", + "usedTypescriptType": "AL_Media", + "usedStructName": "anilist.Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Forum Thread" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ThreadCategory", + "formattedName": "AL_ThreadCategory", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " A forum thread category" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ThreadComment", + "formattedName": "AL_ThreadComment", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ThreadID", + "jsonName": "threadId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Comment", + "jsonName": "comment", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LikeCount", + "jsonName": "likeCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsLiked", + "jsonName": "isLiked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Thread", + "jsonName": "thread", + "goType": "Thread", + "typescriptType": "AL_Thread", + "usedTypescriptType": "AL_Thread", + "usedStructName": "anilist.Thread", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Likes", + "jsonName": "likes", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChildComments", + "jsonName": "childComments", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsLocked", + "jsonName": "isLocked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Forum Thread Comment" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ThreadCommentLikeNotification", + "formattedName": "AL_ThreadCommentLikeNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CommentID", + "jsonName": "commentId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thread", + "jsonName": "thread", + "goType": "Thread", + "typescriptType": "AL_Thread", + "usedTypescriptType": "AL_Thread", + "usedStructName": "anilist.Thread", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Comment", + "jsonName": "comment", + "goType": "ThreadComment", + "typescriptType": "AL_ThreadComment", + "usedTypescriptType": "AL_ThreadComment", + "usedStructName": "anilist.ThreadComment", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a thread comment is liked" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ThreadCommentMentionNotification", + "formattedName": "AL_ThreadCommentMentionNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CommentID", + "jsonName": "commentId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thread", + "jsonName": "thread", + "goType": "Thread", + "typescriptType": "AL_Thread", + "usedTypescriptType": "AL_Thread", + "usedStructName": "anilist.Thread", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Comment", + "jsonName": "comment", + "goType": "ThreadComment", + "typescriptType": "AL_ThreadComment", + "usedTypescriptType": "AL_ThreadComment", + "usedStructName": "anilist.ThreadComment", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when authenticated user is @ mentioned in a forum thread comment" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ThreadCommentReplyNotification", + "formattedName": "AL_ThreadCommentReplyNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CommentID", + "jsonName": "commentId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thread", + "jsonName": "thread", + "goType": "Thread", + "typescriptType": "AL_Thread", + "usedTypescriptType": "AL_Thread", + "usedStructName": "anilist.Thread", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Comment", + "jsonName": "comment", + "goType": "ThreadComment", + "typescriptType": "AL_ThreadComment", + "usedTypescriptType": "AL_ThreadComment", + "usedStructName": "anilist.ThreadComment", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a user replies to your forum thread comment" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ThreadCommentSubscribedNotification", + "formattedName": "AL_ThreadCommentSubscribedNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CommentID", + "jsonName": "commentId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thread", + "jsonName": "thread", + "goType": "Thread", + "typescriptType": "AL_Thread", + "usedTypescriptType": "AL_Thread", + "usedStructName": "anilist.Thread", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Comment", + "jsonName": "comment", + "goType": "ThreadComment", + "typescriptType": "AL_ThreadComment", + "usedTypescriptType": "AL_ThreadComment", + "usedStructName": "anilist.ThreadComment", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a user replies to a subscribed forum thread" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ThreadLikeNotification", + "formattedName": "AL_ThreadLikeNotification", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserID", + "jsonName": "userId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "NotificationType", + "typescriptType": "AL_NotificationType", + "usedTypescriptType": "AL_NotificationType", + "usedStructName": "anilist.NotificationType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ThreadID", + "jsonName": "threadId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Context", + "jsonName": "context", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Thread", + "jsonName": "thread", + "goType": "Thread", + "typescriptType": "AL_Thread", + "usedTypescriptType": "AL_Thread", + "usedStructName": "anilist.Thread", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Comment", + "jsonName": "comment", + "goType": "ThreadComment", + "typescriptType": "AL_ThreadComment", + "usedTypescriptType": "AL_ThreadComment", + "usedStructName": "anilist.ThreadComment", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "AL_User", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Notification for when a thread is liked" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "User", + "formattedName": "AL_User", + "package": "anilist", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "About", + "jsonName": "about", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Avatar", + "jsonName": "avatar", + "goType": "UserAvatar", + "typescriptType": "AL_UserAvatar", + "usedTypescriptType": "AL_UserAvatar", + "usedStructName": "anilist.UserAvatar", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BannerImage", + "jsonName": "bannerImage", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsFollowing", + "jsonName": "isFollowing", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsFollower", + "jsonName": "isFollower", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsBlocked", + "jsonName": "isBlocked", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Bans", + "jsonName": "bans", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Options", + "jsonName": "options", + "goType": "UserOptions", + "typescriptType": "AL_UserOptions", + "usedTypescriptType": "AL_UserOptions", + "usedStructName": "anilist.UserOptions", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaListOptions", + "jsonName": "mediaListOptions", + "goType": "MediaListOptions", + "typescriptType": "AL_MediaListOptions", + "usedTypescriptType": "AL_MediaListOptions", + "usedStructName": "anilist.MediaListOptions", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Favourites", + "jsonName": "favourites", + "goType": "Favourites", + "typescriptType": "AL_Favourites", + "usedTypescriptType": "AL_Favourites", + "usedStructName": "anilist.Favourites", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Statistics", + "jsonName": "statistics", + "goType": "UserStatisticTypes", + "typescriptType": "AL_UserStatisticTypes", + "usedTypescriptType": "AL_UserStatisticTypes", + "usedStructName": "anilist.UserStatisticTypes", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UnreadNotificationCount", + "jsonName": "unreadNotificationCount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SiteURL", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DonatorTier", + "jsonName": "donatorTier", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DonatorBadge", + "jsonName": "donatorBadge", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ModeratorRoles", + "jsonName": "moderatorRoles", + "goType": "[]ModRole", + "typescriptType": "Array\u003cAL_ModRole\u003e", + "usedTypescriptType": "AL_ModRole", + "usedStructName": "anilist.ModRole", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Stats", + "jsonName": "stats", + "goType": "UserStats", + "typescriptType": "AL_UserStats", + "usedTypescriptType": "AL_UserStats", + "usedStructName": "anilist.UserStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ModeratorStatus", + "jsonName": "moderatorStatus", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PreviousNames", + "jsonName": "previousNames", + "goType": "[]UserPreviousName", + "typescriptType": "Array\u003cAL_UserPreviousName\u003e", + "usedTypescriptType": "AL_UserPreviousName", + "usedStructName": "anilist.UserPreviousName", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A user" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserActivityHistory", + "formattedName": "AL_UserActivityHistory", + "package": "anilist", + "fields": [ + { + "name": "Date", + "jsonName": "date", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Amount", + "jsonName": "amount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Level", + "jsonName": "level", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A user's activity history stats." + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserAvatar", + "formattedName": "AL_UserAvatar", + "package": "anilist", + "fields": [ + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A user's avatars" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserCountryStatistic", + "formattedName": "AL_UserCountryStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Country", + "jsonName": "country", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserFormatStatistic", + "formattedName": "AL_UserFormatStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "MediaFormat", + "typescriptType": "AL_MediaFormat", + "usedTypescriptType": "AL_MediaFormat", + "usedStructName": "anilist.MediaFormat", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserGenreStatistic", + "formattedName": "AL_UserGenreStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Genre", + "jsonName": "genre", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserLengthStatistic", + "formattedName": "AL_UserLengthStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Length", + "jsonName": "length", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserModData", + "formattedName": "AL_UserModData", + "package": "anilist", + "fields": [ + { + "name": "Alts", + "jsonName": "alts", + "goType": "[]User", + "typescriptType": "Array\u003cAL_User\u003e", + "usedTypescriptType": "AL_User", + "usedStructName": "anilist.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Bans", + "jsonName": "bans", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IP", + "jsonName": "ip", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Counts", + "jsonName": "counts", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Privacy", + "jsonName": "privacy", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Email", + "jsonName": "email", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User data for moderators" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserOptions", + "formattedName": "AL_UserOptions", + "package": "anilist", + "fields": [ + { + "name": "TitleLanguage", + "jsonName": "titleLanguage", + "goType": "UserTitleLanguage", + "typescriptType": "AL_UserTitleLanguage", + "usedTypescriptType": "AL_UserTitleLanguage", + "usedStructName": "anilist.UserTitleLanguage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DisplayAdultContent", + "jsonName": "displayAdultContent", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AiringNotifications", + "jsonName": "airingNotifications", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ProfileColor", + "jsonName": "profileColor", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NotificationOptions", + "jsonName": "notificationOptions", + "goType": "[]NotificationOption", + "typescriptType": "Array\u003cAL_NotificationOption\u003e", + "usedTypescriptType": "AL_NotificationOption", + "usedStructName": "anilist.NotificationOption", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Timezone", + "jsonName": "timezone", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ActivityMergeTime", + "jsonName": "activityMergeTime", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StaffNameLanguage", + "jsonName": "staffNameLanguage", + "goType": "UserStaffNameLanguage", + "typescriptType": "AL_UserStaffNameLanguage", + "usedTypescriptType": "AL_UserStaffNameLanguage", + "usedStructName": "anilist.UserStaffNameLanguage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RestrictMessagesToFollowing", + "jsonName": "restrictMessagesToFollowing", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DisabledListActivity", + "jsonName": "disabledListActivity", + "goType": "[]ListActivityOption", + "typescriptType": "Array\u003cAL_ListActivityOption\u003e", + "usedTypescriptType": "AL_ListActivityOption", + "usedStructName": "anilist.ListActivityOption", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A user's general options" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserPreviousName", + "formattedName": "AL_UserPreviousName", + "package": "anilist", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A user's previous name" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserReleaseYearStatistic", + "formattedName": "AL_UserReleaseYearStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReleaseYear", + "jsonName": "releaseYear", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserScoreStatistic", + "formattedName": "AL_UserScoreStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserStaffStatistic", + "formattedName": "AL_UserStaffStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserStartYearStatistic", + "formattedName": "AL_UserStartYearStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartYear", + "jsonName": "startYear", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserStatisticTypes", + "formattedName": "AL_UserStatisticTypes", + "package": "anilist", + "fields": [ + { + "name": "Anime", + "jsonName": "anime", + "goType": "UserStatistics", + "typescriptType": "AL_UserStatistics", + "usedTypescriptType": "AL_UserStatistics", + "usedStructName": "anilist.UserStatistics", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Manga", + "jsonName": "manga", + "goType": "UserStatistics", + "typescriptType": "AL_UserStatistics", + "usedTypescriptType": "AL_UserStatistics", + "usedStructName": "anilist.UserStatistics", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserStatistics", + "formattedName": "AL_UserStatistics", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StandardDeviation", + "jsonName": "standardDeviation", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodesWatched", + "jsonName": "episodesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VolumesRead", + "jsonName": "volumesRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Formats", + "jsonName": "formats", + "goType": "[]UserFormatStatistic", + "typescriptType": "Array\u003cAL_UserFormatStatistic\u003e", + "usedTypescriptType": "AL_UserFormatStatistic", + "usedStructName": "anilist.UserFormatStatistic", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Statuses", + "jsonName": "statuses", + "goType": "[]UserStatusStatistic", + "typescriptType": "Array\u003cAL_UserStatusStatistic\u003e", + "usedTypescriptType": "AL_UserStatusStatistic", + "usedStructName": "anilist.UserStatusStatistic", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Scores", + "jsonName": "scores", + "goType": "[]UserScoreStatistic", + "typescriptType": "Array\u003cAL_UserScoreStatistic\u003e", + "usedTypescriptType": "AL_UserScoreStatistic", + "usedStructName": "anilist.UserScoreStatistic", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Lengths", + "jsonName": "lengths", + "goType": "[]UserLengthStatistic", + "typescriptType": "Array\u003cAL_UserLengthStatistic\u003e", + "usedTypescriptType": "AL_UserLengthStatistic", + "usedStructName": "anilist.UserLengthStatistic", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReleaseYears", + "jsonName": "releaseYears", + "goType": "[]UserReleaseYearStatistic", + "typescriptType": "Array\u003cAL_UserReleaseYearStatistic\u003e", + "usedTypescriptType": "AL_UserReleaseYearStatistic", + "usedStructName": "anilist.UserReleaseYearStatistic", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartYears", + "jsonName": "startYears", + "goType": "[]UserStartYearStatistic", + "typescriptType": "Array\u003cAL_UserStartYearStatistic\u003e", + "usedTypescriptType": "AL_UserStartYearStatistic", + "usedStructName": "anilist.UserStartYearStatistic", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]UserGenreStatistic", + "typescriptType": "Array\u003cAL_UserGenreStatistic\u003e", + "usedTypescriptType": "AL_UserGenreStatistic", + "usedStructName": "anilist.UserGenreStatistic", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Tags", + "jsonName": "tags", + "goType": "[]UserTagStatistic", + "typescriptType": "Array\u003cAL_UserTagStatistic\u003e", + "usedTypescriptType": "AL_UserTagStatistic", + "usedStructName": "anilist.UserTagStatistic", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Countries", + "jsonName": "countries", + "goType": "[]UserCountryStatistic", + "typescriptType": "Array\u003cAL_UserCountryStatistic\u003e", + "usedTypescriptType": "AL_UserCountryStatistic", + "usedStructName": "anilist.UserCountryStatistic", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VoiceActors", + "jsonName": "voiceActors", + "goType": "[]UserVoiceActorStatistic", + "typescriptType": "Array\u003cAL_UserVoiceActorStatistic\u003e", + "usedTypescriptType": "AL_UserVoiceActorStatistic", + "usedStructName": "anilist.UserVoiceActorStatistic", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Staff", + "jsonName": "staff", + "goType": "[]UserStaffStatistic", + "typescriptType": "Array\u003cAL_UserStaffStatistic\u003e", + "usedTypescriptType": "AL_UserStaffStatistic", + "usedStructName": "anilist.UserStaffStatistic", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studios", + "jsonName": "studios", + "goType": "[]UserStudioStatistic", + "typescriptType": "Array\u003cAL_UserStudioStatistic\u003e", + "usedTypescriptType": "AL_UserStudioStatistic", + "usedStructName": "anilist.UserStudioStatistic", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserStats", + "formattedName": "AL_UserStats", + "package": "anilist", + "fields": [ + { + "name": "WatchedTime", + "jsonName": "watchedTime", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ActivityHistory", + "jsonName": "activityHistory", + "goType": "[]UserActivityHistory", + "typescriptType": "Array\u003cAL_UserActivityHistory\u003e", + "usedTypescriptType": "AL_UserActivityHistory", + "usedStructName": "anilist.UserActivityHistory", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeStatusDistribution", + "jsonName": "animeStatusDistribution", + "goType": "[]StatusDistribution", + "typescriptType": "Array\u003cAL_StatusDistribution\u003e", + "usedTypescriptType": "AL_StatusDistribution", + "usedStructName": "anilist.StatusDistribution", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaStatusDistribution", + "jsonName": "mangaStatusDistribution", + "goType": "[]StatusDistribution", + "typescriptType": "Array\u003cAL_StatusDistribution\u003e", + "usedTypescriptType": "AL_StatusDistribution", + "usedStructName": "anilist.StatusDistribution", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeScoreDistribution", + "jsonName": "animeScoreDistribution", + "goType": "[]ScoreDistribution", + "typescriptType": "Array\u003cAL_ScoreDistribution\u003e", + "usedTypescriptType": "AL_ScoreDistribution", + "usedStructName": "anilist.ScoreDistribution", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaScoreDistribution", + "jsonName": "mangaScoreDistribution", + "goType": "[]ScoreDistribution", + "typescriptType": "Array\u003cAL_ScoreDistribution\u003e", + "usedTypescriptType": "AL_ScoreDistribution", + "usedStructName": "anilist.ScoreDistribution", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeListScores", + "jsonName": "animeListScores", + "goType": "ListScoreStats", + "typescriptType": "AL_ListScoreStats", + "usedTypescriptType": "AL_ListScoreStats", + "usedStructName": "anilist.ListScoreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaListScores", + "jsonName": "mangaListScores", + "goType": "ListScoreStats", + "typescriptType": "AL_ListScoreStats", + "usedTypescriptType": "AL_ListScoreStats", + "usedStructName": "anilist.ListScoreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FavouredGenresOverview", + "jsonName": "favouredGenresOverview", + "goType": "[]GenreStats", + "typescriptType": "Array\u003cAL_GenreStats\u003e", + "usedTypescriptType": "AL_GenreStats", + "usedStructName": "anilist.GenreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FavouredGenres", + "jsonName": "favouredGenres", + "goType": "[]GenreStats", + "typescriptType": "Array\u003cAL_GenreStats\u003e", + "usedTypescriptType": "AL_GenreStats", + "usedStructName": "anilist.GenreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FavouredTags", + "jsonName": "favouredTags", + "goType": "[]TagStats", + "typescriptType": "Array\u003cAL_TagStats\u003e", + "usedTypescriptType": "AL_TagStats", + "usedStructName": "anilist.TagStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FavouredActors", + "jsonName": "favouredActors", + "goType": "[]StaffStats", + "typescriptType": "Array\u003cAL_StaffStats\u003e", + "usedTypescriptType": "AL_StaffStats", + "usedStructName": "anilist.StaffStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FavouredStaff", + "jsonName": "favouredStaff", + "goType": "[]StaffStats", + "typescriptType": "Array\u003cAL_StaffStats\u003e", + "usedTypescriptType": "AL_StaffStats", + "usedStructName": "anilist.StaffStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FavouredStudios", + "jsonName": "favouredStudios", + "goType": "[]StudioStats", + "typescriptType": "Array\u003cAL_StudioStats\u003e", + "usedTypescriptType": "AL_StudioStats", + "usedStructName": "anilist.StudioStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FavouredYears", + "jsonName": "favouredYears", + "goType": "[]YearStats", + "typescriptType": "Array\u003cAL_YearStats\u003e", + "usedTypescriptType": "AL_YearStats", + "usedStructName": "anilist.YearStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FavouredFormats", + "jsonName": "favouredFormats", + "goType": "[]FormatStats", + "typescriptType": "Array\u003cAL_FormatStats\u003e", + "usedTypescriptType": "AL_FormatStats", + "usedStructName": "anilist.FormatStats", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " A user's statistics" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserStatusStatistic", + "formattedName": "AL_UserStatusStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserStudioStatistic", + "formattedName": "AL_UserStudioStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studio", + "jsonName": "studio", + "goType": "Studio", + "typescriptType": "AL_Studio", + "usedTypescriptType": "AL_Studio", + "usedStructName": "anilist.Studio", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserTagStatistic", + "formattedName": "AL_UserTagStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Tag", + "jsonName": "tag", + "goType": "MediaTag", + "typescriptType": "AL_MediaTag", + "usedTypescriptType": "AL_MediaTag", + "usedStructName": "anilist.MediaTag", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserVoiceActorStatistic", + "formattedName": "AL_UserVoiceActorStatistic", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIds", + "jsonName": "mediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VoiceActor", + "jsonName": "voiceActor", + "goType": "Staff", + "typescriptType": "AL_Staff", + "usedTypescriptType": "AL_Staff", + "usedStructName": "anilist.Staff", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CharacterIds", + "jsonName": "characterIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "YearStats", + "formattedName": "AL_YearStats", + "package": "anilist", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Amount", + "jsonName": "amount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " User's year statistics" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ActivitySort", + "formattedName": "AL_ActivitySort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"PINNED\"" + ] + }, + "comments": [ + " Activity sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ActivityType", + "formattedName": "AL_ActivityType", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"TEXT\"", + "\"ANIME_LIST\"", + "\"MANGA_LIST\"", + "\"MESSAGE\"", + "\"MEDIA_LIST\"" + ] + }, + "comments": [ + " Activity type enum." + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "AiringSort", + "formattedName": "AL_AiringSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"MEDIA_ID\"", + "\"MEDIA_ID_DESC\"", + "\"TIME\"", + "\"TIME_DESC\"", + "\"EPISODE\"", + "\"EPISODE_DESC\"" + ] + }, + "comments": [ + " Airing schedule sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "CharacterRole", + "formattedName": "AL_CharacterRole", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"MAIN\"", + "\"SUPPORTING\"", + "\"BACKGROUND\"" + ] + }, + "comments": [ + " The role the character plays in the media" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "CharacterSort", + "formattedName": "AL_CharacterSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"ROLE\"", + "\"ROLE_DESC\"", + "\"SEARCH_MATCH\"", + "\"FAVOURITES\"", + "\"FAVOURITES_DESC\"", + "\"RELEVANCE\"" + ] + }, + "comments": [ + " Character sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ExternalLinkMediaType", + "formattedName": "AL_ExternalLinkMediaType", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ANIME\"", + "\"MANGA\"", + "\"STAFF\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ExternalLinkType", + "formattedName": "AL_ExternalLinkType", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"INFO\"", + "\"STREAMING\"", + "\"SOCIAL\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "LikeableType", + "formattedName": "AL_LikeableType", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"THREAD\"", + "\"THREAD_COMMENT\"", + "\"ACTIVITY\"", + "\"ACTIVITY_REPLY\"" + ] + }, + "comments": [ + " Types that can be liked" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaFormat", + "formattedName": "AL_MediaFormat", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"TV\"", + "\"TV_SHORT\"", + "\"MOVIE\"", + "\"SPECIAL\"", + "\"OVA\"", + "\"ONA\"", + "\"MUSIC\"", + "\"MANGA\"", + "\"NOVEL\"", + "\"ONE_SHOT\"" + ] + }, + "comments": [ + " The format the media was released in" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaListSort", + "formattedName": "AL_MediaListSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"MEDIA_ID\"", + "\"MEDIA_ID_DESC\"", + "\"SCORE\"", + "\"SCORE_DESC\"", + "\"STATUS\"", + "\"STATUS_DESC\"", + "\"PROGRESS\"", + "\"PROGRESS_DESC\"", + "\"PROGRESS_VOLUMES\"", + "\"PROGRESS_VOLUMES_DESC\"", + "\"REPEAT\"", + "\"REPEAT_DESC\"", + "\"PRIORITY\"", + "\"PRIORITY_DESC\"", + "\"STARTED_ON\"", + "\"STARTED_ON_DESC\"", + "\"FINISHED_ON\"", + "\"FINISHED_ON_DESC\"", + "\"ADDED_TIME\"", + "\"ADDED_TIME_DESC\"", + "\"UPDATED_TIME\"", + "\"UPDATED_TIME_DESC\"", + "\"MEDIA_TITLE_ROMAJI\"", + "\"MEDIA_TITLE_ROMAJI_DESC\"", + "\"MEDIA_TITLE_ENGLISH\"", + "\"MEDIA_TITLE_ENGLISH_DESC\"", + "\"MEDIA_TITLE_NATIVE\"", + "\"MEDIA_TITLE_NATIVE_DESC\"", + "\"MEDIA_POPULARITY\"", + "\"MEDIA_POPULARITY_DESC\"" + ] + }, + "comments": [ + " Media list sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaListStatus", + "formattedName": "AL_MediaListStatus", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"CURRENT\"", + "\"PLANNING\"", + "\"COMPLETED\"", + "\"DROPPED\"", + "\"PAUSED\"", + "\"REPEATING\"" + ] + }, + "comments": [ + " Media list watching/reading status enum." + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaRankType", + "formattedName": "AL_MediaRankType", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"RATED\"", + "\"POPULAR\"" + ] + }, + "comments": [ + " The type of ranking" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaRelation", + "formattedName": "AL_MediaRelation", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ADAPTATION\"", + "\"PREQUEL\"", + "\"SEQUEL\"", + "\"PARENT\"", + "\"SIDE_STORY\"", + "\"CHARACTER\"", + "\"SUMMARY\"", + "\"ALTERNATIVE\"", + "\"SPIN_OFF\"", + "\"OTHER\"", + "\"SOURCE\"", + "\"COMPILATION\"", + "\"CONTAINS\"" + ] + }, + "comments": [ + " Type of relation media has to its parent." + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaSeason", + "formattedName": "AL_MediaSeason", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"WINTER\"", + "\"SPRING\"", + "\"SUMMER\"", + "\"FALL\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaSort", + "formattedName": "AL_MediaSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"TITLE_ROMAJI\"", + "\"TITLE_ROMAJI_DESC\"", + "\"TITLE_ENGLISH\"", + "\"TITLE_ENGLISH_DESC\"", + "\"TITLE_NATIVE\"", + "\"TITLE_NATIVE_DESC\"", + "\"TYPE\"", + "\"TYPE_DESC\"", + "\"FORMAT\"", + "\"FORMAT_DESC\"", + "\"START_DATE\"", + "\"START_DATE_DESC\"", + "\"END_DATE\"", + "\"END_DATE_DESC\"", + "\"SCORE\"", + "\"SCORE_DESC\"", + "\"POPULARITY\"", + "\"POPULARITY_DESC\"", + "\"TRENDING\"", + "\"TRENDING_DESC\"", + "\"EPISODES\"", + "\"EPISODES_DESC\"", + "\"DURATION\"", + "\"DURATION_DESC\"", + "\"STATUS\"", + "\"STATUS_DESC\"", + "\"CHAPTERS\"", + "\"CHAPTERS_DESC\"", + "\"VOLUMES\"", + "\"VOLUMES_DESC\"", + "\"UPDATED_AT\"", + "\"UPDATED_AT_DESC\"", + "\"SEARCH_MATCH\"", + "\"FAVOURITES\"", + "\"FAVOURITES_DESC\"" + ] + }, + "comments": [ + " Media sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaSource", + "formattedName": "AL_MediaSource", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ORIGINAL\"", + "\"MANGA\"", + "\"LIGHT_NOVEL\"", + "\"VISUAL_NOVEL\"", + "\"VIDEO_GAME\"", + "\"OTHER\"", + "\"NOVEL\"", + "\"DOUJINSHI\"", + "\"ANIME\"", + "\"WEB_NOVEL\"", + "\"LIVE_ACTION\"", + "\"GAME\"", + "\"COMIC\"", + "\"MULTIMEDIA_PROJECT\"", + "\"PICTURE_BOOK\"" + ] + }, + "comments": [ + " Source type the media was adapted from" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaStatus", + "formattedName": "AL_MediaStatus", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"FINISHED\"", + "\"RELEASING\"", + "\"NOT_YET_RELEASED\"", + "\"CANCELLED\"", + "\"HIATUS\"" + ] + }, + "comments": [ + " The current releasing status of the media" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaTrendSort", + "formattedName": "AL_MediaTrendSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"MEDIA_ID\"", + "\"MEDIA_ID_DESC\"", + "\"DATE\"", + "\"DATE_DESC\"", + "\"SCORE\"", + "\"SCORE_DESC\"", + "\"POPULARITY\"", + "\"POPULARITY_DESC\"", + "\"TRENDING\"", + "\"TRENDING_DESC\"", + "\"EPISODE\"", + "\"EPISODE_DESC\"" + ] + }, + "comments": [ + " Media trend sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "MediaType", + "formattedName": "AL_MediaType", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ANIME\"", + "\"MANGA\"" + ] + }, + "comments": [ + " Media type enum, anime or manga." + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ModActionType", + "formattedName": "AL_ModActionType", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"NOTE\"", + "\"BAN\"", + "\"DELETE\"", + "\"EDIT\"", + "\"EXPIRE\"", + "\"REPORT\"", + "\"RESET\"", + "\"ANON\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ModRole", + "formattedName": "AL_ModRole", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ADMIN\"", + "\"LEAD_DEVELOPER\"", + "\"DEVELOPER\"", + "\"LEAD_COMMUNITY\"", + "\"COMMUNITY\"", + "\"DISCORD_COMMUNITY\"", + "\"LEAD_ANIME_DATA\"", + "\"ANIME_DATA\"", + "\"LEAD_MANGA_DATA\"", + "\"MANGA_DATA\"", + "\"LEAD_SOCIAL_MEDIA\"", + "\"SOCIAL_MEDIA\"", + "\"RETIRED\"", + "\"CHARACTER_DATA\"", + "\"STAFF_DATA\"" + ] + }, + "comments": [ + " Mod role enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "NotificationType", + "formattedName": "AL_NotificationType", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ACTIVITY_MESSAGE\"", + "\"ACTIVITY_REPLY\"", + "\"FOLLOWING\"", + "\"ACTIVITY_MENTION\"", + "\"THREAD_COMMENT_MENTION\"", + "\"THREAD_SUBSCRIBED\"", + "\"THREAD_COMMENT_REPLY\"", + "\"AIRING\"", + "\"ACTIVITY_LIKE\"", + "\"ACTIVITY_REPLY_LIKE\"", + "\"THREAD_LIKE\"", + "\"THREAD_COMMENT_LIKE\"", + "\"ACTIVITY_REPLY_SUBSCRIBED\"", + "\"RELATED_MEDIA_ADDITION\"", + "\"MEDIA_DATA_CHANGE\"", + "\"MEDIA_MERGE\"", + "\"MEDIA_DELETION\"" + ] + }, + "comments": [ + " Notification type enum" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "RecommendationRating", + "formattedName": "AL_RecommendationRating", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"NO_RATING\"", + "\"RATE_UP\"", + "\"RATE_DOWN\"" + ] + }, + "comments": [ + " Recommendation rating enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "RecommendationSort", + "formattedName": "AL_RecommendationSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"RATING\"", + "\"RATING_DESC\"" + ] + }, + "comments": [ + " Recommendation sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ReviewRating", + "formattedName": "AL_ReviewRating", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"NO_VOTE\"", + "\"UP_VOTE\"", + "\"DOWN_VOTE\"" + ] + }, + "comments": [ + " Review rating enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ReviewSort", + "formattedName": "AL_ReviewSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"SCORE\"", + "\"SCORE_DESC\"", + "\"RATING\"", + "\"RATING_DESC\"", + "\"CREATED_AT\"", + "\"CREATED_AT_DESC\"", + "\"UPDATED_AT\"", + "\"UPDATED_AT_DESC\"" + ] + }, + "comments": [ + " Review sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "RevisionHistoryAction", + "formattedName": "AL_RevisionHistoryAction", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"CREATE\"", + "\"EDIT\"" + ] + }, + "comments": [ + " Revision history actions" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ScoreFormat", + "formattedName": "AL_ScoreFormat", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"POINT_100\"", + "\"POINT_10_DECIMAL\"", + "\"POINT_10\"", + "\"POINT_5\"", + "\"POINT_3\"" + ] + }, + "comments": [ + " Media list scoring type" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "SiteTrendSort", + "formattedName": "AL_SiteTrendSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"DATE\"", + "\"DATE_DESC\"", + "\"COUNT\"", + "\"COUNT_DESC\"", + "\"CHANGE\"", + "\"CHANGE_DESC\"" + ] + }, + "comments": [ + " Site trend sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StaffLanguage", + "formattedName": "AL_StaffLanguage", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"JAPANESE\"", + "\"ENGLISH\"", + "\"KOREAN\"", + "\"ITALIAN\"", + "\"SPANISH\"", + "\"PORTUGUESE\"", + "\"FRENCH\"", + "\"GERMAN\"", + "\"HEBREW\"", + "\"HUNGARIAN\"" + ] + }, + "comments": [ + " The primary language of the voice actor" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StaffSort", + "formattedName": "AL_StaffSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"ROLE\"", + "\"ROLE_DESC\"", + "\"LANGUAGE\"", + "\"LANGUAGE_DESC\"", + "\"SEARCH_MATCH\"", + "\"FAVOURITES\"", + "\"FAVOURITES_DESC\"", + "\"RELEVANCE\"" + ] + }, + "comments": [ + " Staff sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "StudioSort", + "formattedName": "AL_StudioSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"NAME\"", + "\"NAME_DESC\"", + "\"SEARCH_MATCH\"", + "\"FAVOURITES\"", + "\"FAVOURITES_DESC\"" + ] + }, + "comments": [ + " Studio sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "SubmissionSort", + "formattedName": "AL_SubmissionSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"" + ] + }, + "comments": [ + " Submission sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "SubmissionStatus", + "formattedName": "AL_SubmissionStatus", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"PENDING\"", + "\"REJECTED\"", + "\"PARTIALLY_ACCEPTED\"", + "\"ACCEPTED\"" + ] + }, + "comments": [ + " Submission status" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ThreadCommentSort", + "formattedName": "AL_ThreadCommentSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"" + ] + }, + "comments": [ + " Thread comments sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "ThreadSort", + "formattedName": "AL_ThreadSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"TITLE\"", + "\"TITLE_DESC\"", + "\"CREATED_AT\"", + "\"CREATED_AT_DESC\"", + "\"UPDATED_AT\"", + "\"UPDATED_AT_DESC\"", + "\"REPLIED_AT\"", + "\"REPLIED_AT_DESC\"", + "\"REPLY_COUNT\"", + "\"REPLY_COUNT_DESC\"", + "\"VIEW_COUNT\"", + "\"VIEW_COUNT_DESC\"", + "\"IS_STICKY\"", + "\"SEARCH_MATCH\"" + ] + }, + "comments": [ + " Thread sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserSort", + "formattedName": "AL_UserSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"USERNAME\"", + "\"USERNAME_DESC\"", + "\"WATCHED_TIME\"", + "\"WATCHED_TIME_DESC\"", + "\"CHAPTERS_READ\"", + "\"CHAPTERS_READ_DESC\"", + "\"SEARCH_MATCH\"" + ] + }, + "comments": [ + " User sort enums" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserStaffNameLanguage", + "formattedName": "AL_UserStaffNameLanguage", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ROMAJI_WESTERN\"", + "\"ROMAJI\"", + "\"NATIVE\"" + ] + }, + "comments": [ + " The language the user wants to see staff and character names in" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserStatisticsSort", + "formattedName": "AL_UserStatisticsSort", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ID\"", + "\"ID_DESC\"", + "\"COUNT\"", + "\"COUNT_DESC\"", + "\"PROGRESS\"", + "\"PROGRESS_DESC\"", + "\"MEAN_SCORE\"", + "\"MEAN_SCORE_DESC\"" + ] + }, + "comments": [ + " User statistics sort enum" + ] + }, + { + "filepath": "../internal/api/anilist/models_gen.go", + "filename": "models_gen.go", + "name": "UserTitleLanguage", + "formattedName": "AL_UserTitleLanguage", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"ROMAJI\"", + "\"ENGLISH\"", + "\"NATIVE\"", + "\"ROMAJI_STYLISED\"", + "\"ENGLISH_STYLISED\"", + "\"NATIVE_STYLISED\"" + ] + }, + "comments": [ + " The language the user wants to see media titles in" + ] + }, + { + "filepath": "../internal/api/anilist/stats.go", + "filename": "stats.go", + "name": "Stats", + "formattedName": "AL_Stats", + "package": "anilist", + "fields": [ + { + "name": "AnimeStats", + "jsonName": "animeStats", + "goType": "AnimeStats", + "typescriptType": "AL_AnimeStats", + "usedTypescriptType": "AL_AnimeStats", + "usedStructName": "anilist.AnimeStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaStats", + "jsonName": "mangaStats", + "goType": "MangaStats", + "typescriptType": "AL_MangaStats", + "usedTypescriptType": "AL_MangaStats", + "usedStructName": "anilist.MangaStats", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/stats.go", + "filename": "stats.go", + "name": "AnimeStats", + "formattedName": "AL_AnimeStats", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MinutesWatched", + "jsonName": "minutesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodesWatched", + "jsonName": "episodesWatched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]UserGenreStats", + "typescriptType": "Array\u003cAL_UserGenreStats\u003e", + "usedTypescriptType": "AL_UserGenreStats", + "usedStructName": "anilist.UserGenreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Formats", + "jsonName": "formats", + "goType": "[]UserFormatStats", + "typescriptType": "Array\u003cAL_UserFormatStats\u003e", + "usedTypescriptType": "AL_UserFormatStats", + "usedStructName": "anilist.UserFormatStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Statuses", + "jsonName": "statuses", + "goType": "[]UserStatusStats", + "typescriptType": "Array\u003cAL_UserStatusStats\u003e", + "usedTypescriptType": "AL_UserStatusStats", + "usedStructName": "anilist.UserStatusStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Studios", + "jsonName": "studios", + "goType": "[]UserStudioStats", + "typescriptType": "Array\u003cAL_UserStudioStats\u003e", + "usedTypescriptType": "AL_UserStudioStats", + "usedStructName": "anilist.UserStudioStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Scores", + "jsonName": "scores", + "goType": "[]UserScoreStats", + "typescriptType": "Array\u003cAL_UserScoreStats\u003e", + "usedTypescriptType": "AL_UserScoreStats", + "usedStructName": "anilist.UserScoreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartYears", + "jsonName": "startYears", + "goType": "[]UserStartYearStats", + "typescriptType": "Array\u003cAL_UserStartYearStats\u003e", + "usedTypescriptType": "AL_UserStartYearStats", + "usedStructName": "anilist.UserStartYearStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReleaseYears", + "jsonName": "releaseYears", + "goType": "[]UserReleaseYearStats", + "typescriptType": "Array\u003cAL_UserReleaseYearStats\u003e", + "usedTypescriptType": "AL_UserReleaseYearStats", + "usedStructName": "anilist.UserReleaseYearStats", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/stats.go", + "filename": "stats.go", + "name": "MangaStats", + "formattedName": "AL_MangaStats", + "package": "anilist", + "fields": [ + { + "name": "Count", + "jsonName": "count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChaptersRead", + "jsonName": "chaptersRead", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MeanScore", + "jsonName": "meanScore", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]UserGenreStats", + "typescriptType": "Array\u003cAL_UserGenreStats\u003e", + "usedTypescriptType": "AL_UserGenreStats", + "usedStructName": "anilist.UserGenreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Statuses", + "jsonName": "statuses", + "goType": "[]UserStatusStats", + "typescriptType": "Array\u003cAL_UserStatusStats\u003e", + "usedTypescriptType": "AL_UserStatusStats", + "usedStructName": "anilist.UserStatusStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Scores", + "jsonName": "scores", + "goType": "[]UserScoreStats", + "typescriptType": "Array\u003cAL_UserScoreStats\u003e", + "usedTypescriptType": "AL_UserScoreStats", + "usedStructName": "anilist.UserScoreStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartYears", + "jsonName": "startYears", + "goType": "[]UserStartYearStats", + "typescriptType": "Array\u003cAL_UserStartYearStats\u003e", + "usedTypescriptType": "AL_UserStartYearStats", + "usedStructName": "anilist.UserStartYearStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReleaseYears", + "jsonName": "releaseYears", + "goType": "[]UserReleaseYearStats", + "typescriptType": "Array\u003cAL_UserReleaseYearStats\u003e", + "usedTypescriptType": "AL_UserReleaseYearStats", + "usedStructName": "anilist.UserReleaseYearStats", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anilist/utils.go", + "filename": "utils.go", + "name": "GetSeasonKind", + "formattedName": "AL_GetSeasonKind", + "package": "anilist", + "fields": [], + "aliasOf": { + "goType": "int", + "typescriptType": "number", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/api/animap/animap.go", + "filename": "animap.go", + "name": "Anime", + "formattedName": "Animap_Anime", + "package": "animap", + "fields": [ + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Titles", + "jsonName": "titles", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "startDate", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " YYYY-MM-DD" + ] + }, + { + "name": "EndDate", + "jsonName": "endDate", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " YYYY-MM-DD" + ] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Finished, Airing, Upcoming, etc." + ] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " TV, OVA, Movie, etc." + ] + }, + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "map[string]Episode", + "typescriptType": "Record\u003cstring, Animap_Episode\u003e", + "usedTypescriptType": "Animap_Episode", + "usedStructName": "animap.Episode", + "required": false, + "public": true, + "comments": [ + " Indexed by AniDB episode number, \"1\", \"S1\", etc." + ] + }, + { + "name": "Mappings", + "jsonName": "mappings", + "goType": "AnimeMapping", + "typescriptType": "Animap_AnimeMapping", + "usedTypescriptType": "Animap_AnimeMapping", + "usedStructName": "animap.AnimeMapping", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/animap/animap.go", + "filename": "animap.go", + "name": "AnimeMapping", + "formattedName": "Animap_AnimeMapping", + "package": "animap", + "fields": [ + { + "name": "AnidbID", + "jsonName": "anidb_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnilistID", + "jsonName": "anilist_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "KitsuID", + "jsonName": "kitsu_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TheTvdbID", + "jsonName": "thetvdb_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TheMovieDbID", + "jsonName": "themoviedb_id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " Can be int or string, forced to string" + ] + }, + { + "name": "MalID", + "jsonName": "mal_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LivechartID", + "jsonName": "livechart_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimePlanetID", + "jsonName": "animeplanet_id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " Can be int or string, forced to string" + ] + }, + { + "name": "AnisearchID", + "jsonName": "anisearch_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SimklID", + "jsonName": "simkl_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NotifyMoeID", + "jsonName": "notifymoe_id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimecountdownID", + "jsonName": "animecountdown_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/animap/animap.go", + "filename": "animap.go", + "name": "Episode", + "formattedName": "Animap_Episode", + "package": "animap", + "fields": [ + { + "name": "AnidbEpisode", + "jsonName": "anidbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnidbId", + "jsonName": "anidbEid", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TvdbId", + "jsonName": "tvdbEid", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TvdbShowId", + "jsonName": "tvdbShowId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AirDate", + "jsonName": "airDate", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " YYYY-MM-DD" + ] + }, + { + "name": "AnidbTitle", + "jsonName": "anidbTitle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " Title of the episode from AniDB" + ] + }, + { + "name": "TvdbTitle", + "jsonName": "tvdbTitle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " Title of the episode from TVDB" + ] + }, + { + "name": "Overview", + "jsonName": "overview", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Runtime", + "jsonName": "runtime", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [ + " minutes" + ] + }, + { + "name": "Length", + "jsonName": "length", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " Xm" + ] + }, + { + "name": "SeasonNumber", + "jsonName": "seasonNumber", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SeasonName", + "jsonName": "seasonName", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Number", + "jsonName": "number", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AbsoluteNumber", + "jsonName": "absoluteNumber", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/animap/animap.go", + "filename": "animap.go", + "name": "Cache", + "formattedName": "Animap_Cache", + "package": "animap", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "" + ] + }, + { + "filepath": "../internal/api/animap/hook_events.go", + "filename": "hook_events.go", + "name": "AnimapMediaRequestedEvent", + "formattedName": "Animap_AnimapMediaRequestedEvent", + "package": "animap", + "fields": [ + { + "name": "From", + "jsonName": "from", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Id", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Anime", + "typescriptType": "Animap_Anime", + "usedTypescriptType": "Animap_Anime", + "usedStructName": "animap.Anime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimapMediaRequestedEvent is triggered when the Animap media is requested.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/api/animap/hook_events.go", + "filename": "hook_events.go", + "name": "AnimapMediaEvent", + "formattedName": "Animap_AnimapMediaEvent", + "package": "animap", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "Anime", + "typescriptType": "Animap_Anime", + "usedTypescriptType": "Animap_Anime", + "usedStructName": "animap.Anime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimapMediaEvent is triggered after processing AnimapMedia." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/api/anizip/anizip.go", + "filename": "anizip.go", + "name": "Episode", + "formattedName": "Anizip_Episode", + "package": "anizip", + "fields": [ + { + "name": "TvdbEid", + "jsonName": "tvdbEid", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AirDate", + "jsonName": "airdate", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SeasonNumber", + "jsonName": "seasonNumber", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AbsoluteEpisodeNumber", + "jsonName": "absoluteEpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Summary", + "jsonName": "summary", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Overview", + "jsonName": "overview", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Runtime", + "jsonName": "runtime", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Length", + "jsonName": "length", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnidbEid", + "jsonName": "anidbEid", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rating", + "jsonName": "rating", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anizip/anizip.go", + "filename": "anizip.go", + "name": "Mappings", + "formattedName": "Anizip_Mappings", + "package": "anizip", + "fields": [ + { + "name": "AnimeplanetID", + "jsonName": "animeplanet_id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "KitsuID", + "jsonName": "kitsu_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MalID", + "jsonName": "mal_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnilistID", + "jsonName": "anilist_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnisearchID", + "jsonName": "anisearch_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnidbID", + "jsonName": "anidb_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NotifymoeID", + "jsonName": "notifymoe_id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LivechartID", + "jsonName": "livechart_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ThetvdbID", + "jsonName": "thetvdb_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ImdbID", + "jsonName": "imdb_id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ThemoviedbID", + "jsonName": "themoviedb_id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anizip/anizip.go", + "filename": "anizip.go", + "name": "Media", + "formattedName": "Anizip_Media", + "package": "anizip", + "fields": [ + { + "name": "Titles", + "jsonName": "titles", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "map[string]Episode", + "typescriptType": "Record\u003cstring, Anizip_Episode\u003e", + "usedTypescriptType": "Anizip_Episode", + "usedStructName": "anizip.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeCount", + "jsonName": "episodeCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SpecialCount", + "jsonName": "specialCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Mappings", + "jsonName": "mappings", + "goType": "Mappings", + "typescriptType": "Anizip_Mappings", + "usedTypescriptType": "Anizip_Mappings", + "usedStructName": "anizip.Mappings", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/anizip/anizip.go", + "filename": "anizip.go", + "name": "Cache", + "formattedName": "Anizip_Cache", + "package": "anizip", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "" + ] + }, + { + "filepath": "../internal/api/anizip/hook_events.go", + "filename": "hook_events.go", + "name": "AnizipMediaRequestedEvent", + "formattedName": "Anizip_AnizipMediaRequestedEvent", + "package": "anizip", + "fields": [ + { + "name": "From", + "jsonName": "from", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Id", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "Anizip_Media", + "usedTypescriptType": "Anizip_Media", + "usedStructName": "anizip.Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnizipMediaRequestedEvent is triggered when the AniZip media is requested.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/api/anizip/hook_events.go", + "filename": "hook_events.go", + "name": "AnizipMediaEvent", + "formattedName": "Anizip_AnizipMediaEvent", + "package": "anizip", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "Anizip_Media", + "usedTypescriptType": "Anizip_Media", + "usedStructName": "anizip.Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnizipMediaEvent is triggered after processing AnizipMedia." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/api/filler/filler.go", + "filename": "filler.go", + "name": "SearchOptions", + "formattedName": "SearchOptions", + "package": "filler", + "fields": [ + { + "name": "Titles", + "jsonName": "Titles", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/filler/filler.go", + "filename": "filler.go", + "name": "SearchResult", + "formattedName": "SearchResult", + "package": "filler", + "fields": [ + { + "name": "Slug", + "jsonName": "Slug", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "Title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/filler/filler.go", + "filename": "filler.go", + "name": "Data", + "formattedName": "Data", + "package": "filler", + "fields": [ + { + "name": "FillerEpisodes", + "jsonName": "fillerEpisodes", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/filler/filler.go", + "filename": "filler.go", + "name": "AnimeFillerList", + "formattedName": "AnimeFillerList", + "package": "filler", + "fields": [ + { + "name": "baseUrl", + "jsonName": "baseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "userAgent", + "jsonName": "userAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/anime.go", + "filename": "anime.go", + "name": "BasicAnime", + "formattedName": "MAL_BasicAnime", + "package": "mal", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MainPicture", + "jsonName": "main_picture", + "goType": "MAL_BasicAnime_MainPicture", + "inlineStructType": "struct{\nMedium string `json:\"medium\"`\nLarge string `json:\"large\"`}", + "typescriptType": "MAL_BasicAnime_MainPicture", + "usedTypescriptType": "{ medium: string; large: string; }", + "usedStructName": "mal.BasicAnime_MainPicture", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AlternativeTitles", + "jsonName": "alternative_titles", + "goType": "MAL_BasicAnime_AlternativeTitles", + "inlineStructType": "struct{\nSynonyms []string `json:\"synonyms\"`\nEn string `json:\"en\"`\nJa string `json:\"ja\"`}", + "typescriptType": "MAL_BasicAnime_AlternativeTitles", + "usedTypescriptType": "{ synonyms: Array\u003cstring\u003e; en: string; ja: string; }", + "usedStructName": "mal.BasicAnime_AlternativeTitles", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "start_date", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EndDate", + "jsonName": "end_date", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartSeason", + "jsonName": "start_season", + "goType": "MAL_BasicAnime_StartSeason", + "inlineStructType": "struct{\nYear int `json:\"year\"`\nSeason string `json:\"season\"`}", + "typescriptType": "MAL_BasicAnime_StartSeason", + "usedTypescriptType": "{ year: number; season: string; }", + "usedStructName": "mal.BasicAnime_StartSeason", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Synopsis", + "jsonName": "synopsis", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NSFW", + "jsonName": "nsfw", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumEpisodes", + "jsonName": "num_episodes", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Mean", + "jsonName": "mean", + "goType": "float32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Rank", + "jsonName": "rank", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Popularity", + "jsonName": "popularity", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaType", + "jsonName": "media_type", + "goType": "MediaType", + "typescriptType": "MAL_MediaType", + "usedTypescriptType": "MAL_MediaType", + "usedStructName": "mal.MediaType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaStatus", + "typescriptType": "MAL_MediaStatus", + "usedTypescriptType": "MAL_MediaStatus", + "usedStructName": "mal.MediaStatus", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/anime.go", + "filename": "anime.go", + "name": "BasicAnime_MainPicture", + "formattedName": "MAL_BasicAnime_MainPicture", + "package": "mal", + "fields": [ + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/anime.go", + "filename": "anime.go", + "name": "BasicAnime_AlternativeTitles", + "formattedName": "MAL_BasicAnime_AlternativeTitles", + "package": "mal", + "fields": [ + { + "name": "Synonyms", + "jsonName": "synonyms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "En", + "jsonName": "en", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Ja", + "jsonName": "ja", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/anime.go", + "filename": "anime.go", + "name": "BasicAnime_StartSeason", + "formattedName": "MAL_BasicAnime_StartSeason", + "package": "mal", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/anime.go", + "filename": "anime.go", + "name": "AnimeListEntry", + "formattedName": "MAL_AnimeListEntry", + "package": "mal", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "MAL_AnimeListEntry_Node", + "inlineStructType": "struct{\nID int `json:\"id\"`\nTitle string `json:\"title\"`\nMainPicture __STRUCT__ `json:\"main_picture\"`}", + "typescriptType": "MAL_AnimeListEntry_Node", + "usedTypescriptType": "{ id: number; title: string; main_picture: { medium: string; large: string; }; }", + "usedStructName": "mal.AnimeListEntry_Node", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ListStatus", + "jsonName": "list_status", + "goType": "MAL_AnimeListEntry_ListStatus", + "inlineStructType": "struct{\nStatus MediaListStatus `json:\"status\"`\nIsRewatching bool `json:\"is_rewatching\"`\nNumEpisodesWatched int `json:\"num_episodes_watched\"`\nScore int `json:\"score\"`\nUpdatedAt string `json:\"updated_at\"`}", + "typescriptType": "MAL_AnimeListEntry_ListStatus", + "usedTypescriptType": "{ status: MediaListStatus; is_rewatching: boolean; num_episodes_watched: number; score: number; updated_at: string; }", + "usedStructName": "mal.AnimeListEntry_ListStatus", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/anime.go", + "filename": "anime.go", + "name": "AnimeListEntry_Node", + "formattedName": "MAL_AnimeListEntry_Node", + "package": "mal", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MainPicture", + "jsonName": "main_picture", + "goType": "__STRUCT__", + "inlineStructType": "struct{\nMedium string `json:\"medium\"`\nLarge string `json:\"large\"`}", + "typescriptType": "{ medium: string; large: string; }", + "usedTypescriptType": "{ medium: string; large: string; }", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/anime.go", + "filename": "anime.go", + "name": "AnimeListEntry_ListStatus", + "formattedName": "MAL_AnimeListEntry_ListStatus", + "package": "mal", + "fields": [ + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "MAL_MediaListStatus", + "usedTypescriptType": "MAL_MediaListStatus", + "usedStructName": "mal.MediaListStatus", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsRewatching", + "jsonName": "is_rewatching", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumEpisodesWatched", + "jsonName": "num_episodes_watched", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updated_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/anime.go", + "filename": "anime.go", + "name": "AnimeListProgressParams", + "formattedName": "MAL_AnimeListProgressParams", + "package": "mal", + "fields": [ + { + "name": "NumEpisodesWatched", + "jsonName": "NumEpisodesWatched", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/anime.go", + "filename": "anime.go", + "name": "AnimeListStatusParams", + "formattedName": "MAL_AnimeListStatusParams", + "package": "mal", + "fields": [ + { + "name": "Status", + "jsonName": "Status", + "goType": "MediaListStatus", + "typescriptType": "MAL_MediaListStatus", + "usedTypescriptType": "MAL_MediaListStatus", + "usedStructName": "mal.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsRewatching", + "jsonName": "IsRewatching", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NumEpisodesWatched", + "jsonName": "NumEpisodesWatched", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "Score", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/manga.go", + "filename": "manga.go", + "name": "BasicManga", + "formattedName": "MAL_BasicManga", + "package": "mal", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MainPicture", + "jsonName": "main_picture", + "goType": "MAL_BasicManga_MainPicture", + "inlineStructType": "struct{\nMedium string `json:\"medium\"`\nLarge string `json:\"large\"`}", + "typescriptType": "MAL_BasicManga_MainPicture", + "usedTypescriptType": "{ medium: string; large: string; }", + "usedStructName": "mal.BasicManga_MainPicture", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AlternativeTitles", + "jsonName": "alternative_titles", + "goType": "MAL_BasicManga_AlternativeTitles", + "inlineStructType": "struct{\nSynonyms []string `json:\"synonyms\"`\nEn string `json:\"en\"`\nJa string `json:\"ja\"`}", + "typescriptType": "MAL_BasicManga_AlternativeTitles", + "usedTypescriptType": "{ synonyms: Array\u003cstring\u003e; en: string; ja: string; }", + "usedStructName": "mal.BasicManga_AlternativeTitles", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "start_date", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EndDate", + "jsonName": "end_date", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Synopsis", + "jsonName": "synopsis", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NSFW", + "jsonName": "nsfw", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumVolumes", + "jsonName": "num_volumes", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumChapters", + "jsonName": "num_chapters", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Mean", + "jsonName": "mean", + "goType": "float32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Rank", + "jsonName": "rank", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Popularity", + "jsonName": "popularity", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaType", + "jsonName": "media_type", + "goType": "MediaType", + "typescriptType": "MAL_MediaType", + "usedTypescriptType": "MAL_MediaType", + "usedStructName": "mal.MediaType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "MediaStatus", + "typescriptType": "MAL_MediaStatus", + "usedTypescriptType": "MAL_MediaStatus", + "usedStructName": "mal.MediaStatus", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/manga.go", + "filename": "manga.go", + "name": "BasicManga_MainPicture", + "formattedName": "MAL_BasicManga_MainPicture", + "package": "mal", + "fields": [ + { + "name": "Medium", + "jsonName": "medium", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Large", + "jsonName": "large", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/manga.go", + "filename": "manga.go", + "name": "BasicManga_AlternativeTitles", + "formattedName": "MAL_BasicManga_AlternativeTitles", + "package": "mal", + "fields": [ + { + "name": "Synonyms", + "jsonName": "synonyms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "En", + "jsonName": "en", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Ja", + "jsonName": "ja", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/manga.go", + "filename": "manga.go", + "name": "MangaListEntry", + "formattedName": "MAL_MangaListEntry", + "package": "mal", + "fields": [ + { + "name": "Node", + "jsonName": "node", + "goType": "MAL_MangaListEntry_Node", + "inlineStructType": "struct{\nID int `json:\"id\"`\nTitle string `json:\"title\"`\nMainPicture __STRUCT__ `json:\"main_picture\"`}", + "typescriptType": "MAL_MangaListEntry_Node", + "usedTypescriptType": "{ id: number; title: string; main_picture: { medium: string; large: string; }; }", + "usedStructName": "mal.MangaListEntry_Node", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ListStatus", + "jsonName": "list_status", + "goType": "MAL_MangaListEntry_ListStatus", + "inlineStructType": "struct{\nStatus MediaListStatus `json:\"status\"`\nIsRereading bool `json:\"is_rereading\"`\nNumVolumesRead int `json:\"num_volumes_read\"`\nNumChaptersRead int `json:\"num_chapters_read\"`\nScore int `json:\"score\"`\nUpdatedAt string `json:\"updated_at\"`}", + "typescriptType": "MAL_MangaListEntry_ListStatus", + "usedTypescriptType": "{ status: MediaListStatus; is_rereading: boolean; num_volumes_read: number; num_chapters_read: number; score: number; updated_at: string; }", + "usedStructName": "mal.MangaListEntry_ListStatus", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/manga.go", + "filename": "manga.go", + "name": "MangaListEntry_Node", + "formattedName": "MAL_MangaListEntry_Node", + "package": "mal", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MainPicture", + "jsonName": "main_picture", + "goType": "__STRUCT__", + "inlineStructType": "struct{\nMedium string `json:\"medium\"`\nLarge string `json:\"large\"`}", + "typescriptType": "{ medium: string; large: string; }", + "usedTypescriptType": "{ medium: string; large: string; }", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/manga.go", + "filename": "manga.go", + "name": "MangaListEntry_ListStatus", + "formattedName": "MAL_MangaListEntry_ListStatus", + "package": "mal", + "fields": [ + { + "name": "Status", + "jsonName": "status", + "goType": "MediaListStatus", + "typescriptType": "MAL_MediaListStatus", + "usedTypescriptType": "MAL_MediaListStatus", + "usedStructName": "mal.MediaListStatus", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsRereading", + "jsonName": "is_rereading", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumVolumesRead", + "jsonName": "num_volumes_read", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumChaptersRead", + "jsonName": "num_chapters_read", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updated_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/manga.go", + "filename": "manga.go", + "name": "MangaListProgressParams", + "formattedName": "MAL_MangaListProgressParams", + "package": "mal", + "fields": [ + { + "name": "NumChaptersRead", + "jsonName": "NumChaptersRead", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/manga.go", + "filename": "manga.go", + "name": "MangaListStatusParams", + "formattedName": "MAL_MangaListStatusParams", + "package": "mal", + "fields": [ + { + "name": "Status", + "jsonName": "Status", + "goType": "MediaListStatus", + "typescriptType": "MAL_MediaListStatus", + "usedTypescriptType": "MAL_MediaListStatus", + "usedStructName": "mal.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsRereading", + "jsonName": "IsRereading", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NumChaptersRead", + "jsonName": "NumChaptersRead", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "Score", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/search.go", + "filename": "search.go", + "name": "SearchResultPayload", + "formattedName": "MAL_SearchResultPayload", + "package": "mal", + "fields": [ + { + "name": "MediaType", + "jsonName": "media_type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartYear", + "jsonName": "start_year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Aired", + "jsonName": "aired", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/search.go", + "filename": "search.go", + "name": "SearchResultAnime", + "formattedName": "MAL_SearchResultAnime", + "package": "mal", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ImageURL", + "jsonName": "image_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ThumbnailURL", + "jsonName": "thumbnail_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "SearchResultPayload", + "typescriptType": "MAL_SearchResultPayload", + "usedTypescriptType": "MAL_SearchResultPayload", + "usedStructName": "mal.SearchResultPayload", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ESScore", + "jsonName": "es_score", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/search.go", + "filename": "search.go", + "name": "SearchResult", + "formattedName": "MAL_SearchResult", + "package": "mal", + "fields": [ + { + "name": "Categories", + "jsonName": "categories", + "goType": "[]__STRUCT__", + "typescriptType": "Array\u003c{ type: string; items: Array\u003cSearchResultAnime\u003e; }\u003e", + "usedTypescriptType": "{ type: string; items: Array\u003cSearchResultAnime\u003e; }", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/search.go", + "filename": "search.go", + "name": "SearchCache", + "formattedName": "MAL_SearchCache", + "package": "mal", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "" + ] + }, + { + "filepath": "../internal/api/mal/types.go", + "filename": "types.go", + "name": "RequestOptions", + "formattedName": "MAL_RequestOptions", + "package": "mal", + "fields": [ + { + "name": "AccessToken", + "jsonName": "AccessToken", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RefreshToken", + "jsonName": "RefreshToken", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ExpiresAt", + "jsonName": "ExpiresAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/mal/types.go", + "filename": "types.go", + "name": "MediaType", + "formattedName": "MAL_MediaType", + "package": "mal", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"tv\"", + "\"ova\"", + "\"movie\"", + "\"special\"", + "\"ona\"", + "\"music\"", + "\"manga\"", + "\"novel\"", + "\"oneshot\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/api/mal/types.go", + "filename": "types.go", + "name": "MediaStatus", + "formattedName": "MAL_MediaStatus", + "package": "mal", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"finished_airing\"", + "\"currently_airing\"", + "\"not_yet_aired\"", + "\"finished\"", + "\"currently_publishing\"", + "\"not_yet_published\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/api/mal/types.go", + "filename": "types.go", + "name": "MediaListStatus", + "formattedName": "MAL_MediaListStatus", + "package": "mal", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"reading\"", + "\"watching\"", + "\"completed\"", + "\"on_hold\"", + "\"dropped\"", + "\"plan_to_watch\"", + "\"plan_to_read\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/api/mal/wrapper.go", + "filename": "wrapper.go", + "name": "Wrapper", + "formattedName": "MAL_Wrapper", + "package": "mal", + "fields": [ + { + "name": "AccessToken", + "jsonName": "AccessToken", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "client", + "jsonName": "client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/metadata/anime.go", + "filename": "anime.go", + "name": "AnimeWrapperImpl", + "formattedName": "Metadata_AnimeWrapperImpl", + "package": "metadata", + "fields": [ + { + "name": "metadata", + "jsonName": "metadata", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "baseAnime", + "jsonName": "baseAnime", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "fileCacher", + "jsonName": "fileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/metadata/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeMetadataRequestedEvent", + "formattedName": "Metadata_AnimeMetadataRequestedEvent", + "package": "metadata", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnimeMetadata", + "jsonName": "animeMetadata", + "goType": "AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeMetadataRequestedEvent is triggered when anime metadata is requested and right before the metadata is processed.", + " This event is followed by [AnimeMetadataEvent] which is triggered when the metadata is available.", + " Prevent default to skip the default behavior and return the modified metadata.", + " If the modified metadata is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/api/metadata/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeMetadataEvent", + "formattedName": "Metadata_AnimeMetadataEvent", + "package": "metadata", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnimeMetadata", + "jsonName": "animeMetadata", + "goType": "AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeMetadataEvent is triggered when anime metadata is available and is about to be returned.", + " Anime metadata can be requested in many places, ranging from displaying the anime entry to starting a torrent stream.", + " This event is triggered after [AnimeMetadataRequestedEvent].", + " If the modified metadata is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/api/metadata/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEpisodeMetadataRequestedEvent", + "formattedName": "Metadata_AnimeEpisodeMetadataRequestedEvent", + "package": "metadata", + "fields": [ + { + "name": "EpisodeMetadata", + "jsonName": "animeEpisodeMetadata", + "goType": "EpisodeMetadata", + "typescriptType": "Metadata_EpisodeMetadata", + "usedTypescriptType": "Metadata_EpisodeMetadata", + "usedStructName": "metadata.EpisodeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEpisodeMetadataRequestedEvent is triggered when anime episode metadata is requested.", + " Prevent default to skip the default behavior and return the overridden metadata.", + " This event is triggered before [AnimeEpisodeMetadataEvent].", + " If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/api/metadata/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEpisodeMetadataEvent", + "formattedName": "Metadata_AnimeEpisodeMetadataEvent", + "package": "metadata", + "fields": [ + { + "name": "EpisodeMetadata", + "jsonName": "animeEpisodeMetadata", + "goType": "EpisodeMetadata", + "typescriptType": "Metadata_EpisodeMetadata", + "usedTypescriptType": "Metadata_EpisodeMetadata", + "usedStructName": "metadata.EpisodeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEpisodeMetadataEvent is triggered when anime episode metadata is available and is about to be returned.", + " In the current implementation, episode metadata is requested for display purposes. It is used to get a more complete metadata object since the original AnimeMetadata object is not complete.", + " This event is triggered after [AnimeEpisodeMetadataRequestedEvent].", + " If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/api/metadata/provider.go", + "filename": "provider.go", + "name": "ProviderImpl", + "formattedName": "Metadata_ProviderImpl", + "package": "metadata", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "fileCacher", + "jsonName": "fileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeMetadataCache", + "jsonName": "animeMetadataCache", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "singleflight", + "jsonName": "singleflight", + "goType": "singleflight.Group", + "typescriptType": "Group", + "usedTypescriptType": "Group", + "usedStructName": "singleflight.Group", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/metadata/provider.go", + "filename": "provider.go", + "name": "NewProviderImplOptions", + "formattedName": "Metadata_NewProviderImplOptions", + "package": "metadata", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FileCacher", + "jsonName": "FileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/metadata/types.go", + "filename": "types.go", + "name": "Platform", + "formattedName": "Metadata_Platform", + "package": "metadata", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"anilist\"", + "\"mal\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/api/metadata/types.go", + "filename": "types.go", + "name": "AnimeMetadata", + "formattedName": "Metadata_AnimeMetadata", + "package": "metadata", + "fields": [ + { + "name": "Titles", + "jsonName": "titles", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "map[string]EpisodeMetadata", + "typescriptType": "Record\u003cstring, Metadata_EpisodeMetadata\u003e", + "usedTypescriptType": "Metadata_EpisodeMetadata", + "usedStructName": "metadata.EpisodeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeCount", + "jsonName": "episodeCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SpecialCount", + "jsonName": "specialCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Mappings", + "jsonName": "mappings", + "goType": "AnimeMappings", + "typescriptType": "Metadata_AnimeMappings", + "usedTypescriptType": "Metadata_AnimeMappings", + "usedStructName": "metadata.AnimeMappings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "currentEpisodeCount", + "jsonName": "", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/metadata/types.go", + "filename": "types.go", + "name": "AnimeMappings", + "formattedName": "Metadata_AnimeMappings", + "package": "metadata", + "fields": [ + { + "name": "AnimeplanetId", + "jsonName": "animeplanetId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "KitsuId", + "jsonName": "kitsuId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MalId", + "jsonName": "malId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnilistId", + "jsonName": "anilistId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnisearchId", + "jsonName": "anisearchId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnidbId", + "jsonName": "anidbId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NotifymoeId", + "jsonName": "notifymoeId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LivechartId", + "jsonName": "livechartId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ThetvdbId", + "jsonName": "thetvdbId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ImdbId", + "jsonName": "imdbId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ThemoviedbId", + "jsonName": "themoviedbId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/api/metadata/types.go", + "filename": "types.go", + "name": "EpisodeMetadata", + "formattedName": "Metadata_EpisodeMetadata", + "package": "metadata", + "fields": [ + { + "name": "AnidbId", + "jsonName": "anidbId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TvdbId", + "jsonName": "tvdbId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AirDate", + "jsonName": "airDate", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Length", + "jsonName": "length", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Summary", + "jsonName": "summary", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Overview", + "jsonName": "overview", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SeasonNumber", + "jsonName": "seasonNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AbsoluteEpisodeNumber", + "jsonName": "absoluteEpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnidbEid", + "jsonName": "anidbEid", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HasImage", + "jsonName": "hasImage", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Indicates if the episode has a real image" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/continuity/history.go", + "filename": "history.go", + "name": "WatchHistory", + "formattedName": "Continuity_WatchHistory", + "package": "continuity", + "fields": [], + "aliasOf": { + "goType": "map[int]WatchHistoryItem", + "typescriptType": "Record\u003cnumber, Continuity_WatchHistoryItem\u003e", + "declaredValues": null, + "usedStructName": "continuity.WatchHistoryItem" + }, + "comments": null + }, + { + "filepath": "../internal/continuity/history.go", + "filename": "history.go", + "name": "WatchHistoryItem", + "formattedName": "Continuity_WatchHistoryItem", + "package": "continuity", + "fields": [ + { + "name": "Kind", + "jsonName": "kind", + "goType": "Kind", + "typescriptType": "Continuity_Kind", + "usedTypescriptType": "Continuity_Kind", + "usedStructName": "continuity.Kind", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filepath", + "jsonName": "filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CurrentTime", + "jsonName": "currentTime", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeAdded", + "jsonName": "timeAdded", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TimeUpdated", + "jsonName": "timeUpdated", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/continuity/history.go", + "filename": "history.go", + "name": "WatchHistoryItemResponse", + "formattedName": "Continuity_WatchHistoryItemResponse", + "package": "continuity", + "fields": [ + { + "name": "Item", + "jsonName": "item", + "goType": "WatchHistoryItem", + "typescriptType": "Continuity_WatchHistoryItem", + "usedTypescriptType": "Continuity_WatchHistoryItem", + "usedStructName": "continuity.WatchHistoryItem", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Found", + "jsonName": "found", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/continuity/history.go", + "filename": "history.go", + "name": "UpdateWatchHistoryItemOptions", + "formattedName": "Continuity_UpdateWatchHistoryItemOptions", + "package": "continuity", + "fields": [ + { + "name": "CurrentTime", + "jsonName": "currentTime", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filepath", + "jsonName": "filepath", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Kind", + "jsonName": "kind", + "goType": "Kind", + "typescriptType": "Continuity_Kind", + "usedTypescriptType": "Continuity_Kind", + "usedStructName": "continuity.Kind", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/continuity/hook_events.go", + "filename": "hook_events.go", + "name": "WatchHistoryItemRequestedEvent", + "formattedName": "Continuity_WatchHistoryItemRequestedEvent", + "package": "continuity", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WatchHistoryItem", + "jsonName": "watchHistoryItem", + "goType": "WatchHistoryItem", + "typescriptType": "Continuity_WatchHistoryItem", + "usedTypescriptType": "Continuity_WatchHistoryItem", + "usedStructName": "continuity.WatchHistoryItem", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " WatchHistoryItemRequestedEvent is triggered when a watch history item is requested.", + " Prevent default to skip getting the watch history item from the file cache, in this case the event should have a valid WatchHistoryItem object or set it to nil to indicate that the watch history item was not found." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/continuity/hook_events.go", + "filename": "hook_events.go", + "name": "WatchHistoryItemUpdatedEvent", + "formattedName": "Continuity_WatchHistoryItemUpdatedEvent", + "package": "continuity", + "fields": [ + { + "name": "WatchHistoryItem", + "jsonName": "watchHistoryItem", + "goType": "WatchHistoryItem", + "typescriptType": "Continuity_WatchHistoryItem", + "usedTypescriptType": "Continuity_WatchHistoryItem", + "usedStructName": "continuity.WatchHistoryItem", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " WatchHistoryItemUpdatedEvent is triggered when a watch history item is updated." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/continuity/hook_events.go", + "filename": "hook_events.go", + "name": "WatchHistoryLocalFileEpisodeItemRequestedEvent", + "formattedName": "Continuity_WatchHistoryLocalFileEpisodeItemRequestedEvent", + "package": "continuity", + "fields": [ + { + "name": "Path", + "jsonName": "Path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WatchHistoryItem", + "jsonName": "watchHistoryItem", + "goType": "WatchHistoryItem", + "typescriptType": "Continuity_WatchHistoryItem", + "usedTypescriptType": "Continuity_WatchHistoryItem", + "usedStructName": "continuity.WatchHistoryItem", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/continuity/hook_events.go", + "filename": "hook_events.go", + "name": "WatchHistoryStreamEpisodeItemRequestedEvent", + "formattedName": "Continuity_WatchHistoryStreamEpisodeItemRequestedEvent", + "package": "continuity", + "fields": [ + { + "name": "Episode", + "jsonName": "Episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WatchHistoryItem", + "jsonName": "watchHistoryItem", + "goType": "WatchHistoryItem", + "typescriptType": "Continuity_WatchHistoryItem", + "usedTypescriptType": "Continuity_WatchHistoryItem", + "usedStructName": "continuity.WatchHistoryItem", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/continuity/manager.go", + "filename": "manager.go", + "name": "Manager", + "formattedName": "Continuity_Manager", + "package": "continuity", + "fields": [ + { + "name": "fileCacher", + "jsonName": "fileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "db", + "jsonName": "db", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "watchHistoryFileCacheBucket", + "jsonName": "watchHistoryFileCacheBucket", + "goType": "filecache.Bucket", + "typescriptType": "Filecache_Bucket", + "usedTypescriptType": "Filecache_Bucket", + "usedStructName": "filecache.Bucket", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "externalPlayerEpisodeDetails", + "jsonName": "externalPlayerEpisodeDetails", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "Settings", + "typescriptType": "Continuity_Settings", + "usedTypescriptType": "Continuity_Settings", + "usedStructName": "continuity.Settings", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/continuity/manager.go", + "filename": "manager.go", + "name": "ExternalPlayerEpisodeDetails", + "formattedName": "Continuity_ExternalPlayerEpisodeDetails", + "package": "continuity", + "fields": [ + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filepath", + "jsonName": "filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/continuity/manager.go", + "filename": "manager.go", + "name": "Settings", + "formattedName": "Continuity_Settings", + "package": "continuity", + "fields": [ + { + "name": "WatchContinuityEnabled", + "jsonName": "WatchContinuityEnabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/continuity/manager.go", + "filename": "manager.go", + "name": "Kind", + "formattedName": "Continuity_Kind", + "package": "continuity", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"onlinestream\"", + "\"mediastream\"", + "\"external_player\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/continuity/manager.go", + "filename": "manager.go", + "name": "NewManagerOptions", + "formattedName": "Continuity_NewManagerOptions", + "package": "continuity", + "fields": [ + { + "name": "FileCacher", + "jsonName": "FileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/app.go", + "filename": "app.go", + "name": "App", + "formattedName": "INTERNAL_App", + "package": "core", + "fields": [ + { + "name": "Config", + "jsonName": "Config", + "goType": "Config", + "typescriptType": "INTERNAL_Config", + "usedTypescriptType": "INTERNAL_Config", + "usedStructName": "core.Config", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentClientRepository", + "jsonName": "TorrentClientRepository", + "goType": "torrent_client.Repository", + "typescriptType": "TorrentClient_Repository", + "usedTypescriptType": "TorrentClient_Repository", + "usedStructName": "torrent_client.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentRepository", + "jsonName": "TorrentRepository", + "goType": "torrent.Repository", + "typescriptType": "Torrent_Repository", + "usedTypescriptType": "Torrent_Repository", + "usedStructName": "torrent.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DebridClientRepository", + "jsonName": "DebridClientRepository", + "goType": "debrid_client.Repository", + "typescriptType": "DebridClient_Repository", + "usedTypescriptType": "DebridClient_Repository", + "usedStructName": "debrid_client.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Watcher", + "jsonName": "Watcher", + "goType": "scanner.Watcher", + "typescriptType": "Scanner_Watcher", + "usedTypescriptType": "Scanner_Watcher", + "usedStructName": "scanner.Watcher", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnilistClient", + "jsonName": "AnilistClient", + "goType": "anilist.AnilistClient", + "typescriptType": "AL_AnilistClient", + "usedTypescriptType": "AL_AnilistClient", + "usedStructName": "anilist.AnilistClient", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnilistPlatform", + "jsonName": "AnilistPlatform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OfflinePlatform", + "jsonName": "OfflinePlatform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalManager", + "jsonName": "LocalManager", + "goType": "local.Manager", + "typescriptType": "Local_Manager", + "usedTypescriptType": "Local_Manager", + "usedStructName": "local.Manager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FillerManager", + "jsonName": "FillerManager", + "goType": "fillermanager.FillerManager", + "typescriptType": "FillerManager", + "usedTypescriptType": "FillerManager", + "usedStructName": "fillermanager.FillerManager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManager", + "typescriptType": "Events_WSEventManager", + "usedTypescriptType": "Events_WSEventManager", + "usedStructName": "events.WSEventManager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AutoDownloader", + "jsonName": "AutoDownloader", + "goType": "autodownloader.AutoDownloader", + "typescriptType": "AutoDownloader_AutoDownloader", + "usedTypescriptType": "AutoDownloader_AutoDownloader", + "usedStructName": "autodownloader.AutoDownloader", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ExtensionRepository", + "jsonName": "ExtensionRepository", + "goType": "extension_repo.Repository", + "typescriptType": "ExtensionRepo_Repository", + "usedTypescriptType": "ExtensionRepo_Repository", + "usedStructName": "extension_repo.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ExtensionPlaygroundRepository", + "jsonName": "ExtensionPlaygroundRepository", + "goType": "extension_playground.PlaygroundRepository", + "typescriptType": "PlaygroundRepository", + "usedTypescriptType": "PlaygroundRepository", + "usedStructName": "extension_playground.PlaygroundRepository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DirectStreamManager", + "jsonName": "DirectStreamManager", + "goType": "directstream.Manager", + "typescriptType": "Directstream_Manager", + "usedTypescriptType": "Directstream_Manager", + "usedStructName": "directstream.Manager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NativePlayer", + "jsonName": "NativePlayer", + "goType": "nativeplayer.NativePlayer", + "typescriptType": "NativePlayer_NativePlayer", + "usedTypescriptType": "NativePlayer_NativePlayer", + "usedStructName": "nativeplayer.NativePlayer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaPlayer", + "jsonName": "MediaPlayer", + "goType": "INTERNAL_App_MediaPlayer", + "inlineStructType": "struct{\nVLC vlc.VLC\nMpcHc mpchc.MpcHc\nMpv mpv.Mpv\nIina iina.Iina}", + "typescriptType": "INTERNAL_App_MediaPlayer", + "usedTypescriptType": "{ VLC: VLC; MpcHc: MpcHc; Mpv: Mpv; Iina: Iina; }", + "usedStructName": "core.App_MediaPlayer", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaPlayerRepository", + "jsonName": "MediaPlayerRepository", + "goType": "mediaplayer.Repository", + "typescriptType": "Repository", + "usedTypescriptType": "Repository", + "usedStructName": "mediaplayer.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Version", + "jsonName": "Version", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Updater", + "jsonName": "Updater", + "goType": "updater.Updater", + "typescriptType": "Updater_Updater", + "usedTypescriptType": "Updater_Updater", + "usedStructName": "updater.Updater", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AutoScanner", + "jsonName": "AutoScanner", + "goType": "autoscanner.AutoScanner", + "typescriptType": "AutoScanner_AutoScanner", + "usedTypescriptType": "AutoScanner_AutoScanner", + "usedStructName": "autoscanner.AutoScanner", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PlaybackManager", + "jsonName": "PlaybackManager", + "goType": "playbackmanager.PlaybackManager", + "typescriptType": "PlaybackManager_PlaybackManager", + "usedTypescriptType": "PlaybackManager_PlaybackManager", + "usedStructName": "playbackmanager.PlaybackManager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FileCacher", + "jsonName": "FileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OnlinestreamRepository", + "jsonName": "OnlinestreamRepository", + "goType": "onlinestream.Repository", + "typescriptType": "Onlinestream_Repository", + "usedTypescriptType": "Onlinestream_Repository", + "usedStructName": "onlinestream.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaRepository", + "jsonName": "MangaRepository", + "goType": "manga.Repository", + "typescriptType": "Manga_Repository", + "usedTypescriptType": "Manga_Repository", + "usedStructName": "manga.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DiscordPresence", + "jsonName": "DiscordPresence", + "goType": "discordrpc_presence.Presence", + "typescriptType": "DiscordRPC_Presence", + "usedTypescriptType": "DiscordRPC_Presence", + "usedStructName": "discordrpc_presence.Presence", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaDownloader", + "jsonName": "MangaDownloader", + "goType": "manga.Downloader", + "typescriptType": "Manga_Downloader", + "usedTypescriptType": "Manga_Downloader", + "usedStructName": "manga.Downloader", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ContinuityManager", + "jsonName": "ContinuityManager", + "goType": "continuity.Manager", + "typescriptType": "Continuity_Manager", + "usedTypescriptType": "Continuity_Manager", + "usedStructName": "continuity.Manager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Cleanups", + "jsonName": "Cleanups", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OnRefreshAnilistCollectionFuncs", + "jsonName": "OnRefreshAnilistCollectionFuncs", + "goType": "", + "typescriptType": "any", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OnFlushLogs", + "jsonName": "OnFlushLogs", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediastreamRepository", + "jsonName": "MediastreamRepository", + "goType": "mediastream.Repository", + "typescriptType": "Mediastream_Repository", + "usedTypescriptType": "Mediastream_Repository", + "usedStructName": "mediastream.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentstreamRepository", + "jsonName": "TorrentstreamRepository", + "goType": "torrentstream.Repository", + "typescriptType": "Torrentstream_Repository", + "usedTypescriptType": "Torrentstream_Repository", + "usedStructName": "torrentstream.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FeatureFlags", + "jsonName": "FeatureFlags", + "goType": "FeatureFlags", + "typescriptType": "INTERNAL_FeatureFlags", + "usedTypescriptType": "INTERNAL_FeatureFlags", + "usedStructName": "core.FeatureFlags", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Settings", + "jsonName": "Settings", + "goType": "models.Settings", + "typescriptType": "Models_Settings", + "usedTypescriptType": "Models_Settings", + "usedStructName": "models.Settings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SecondarySettings", + "jsonName": "SecondarySettings", + "goType": "INTERNAL_App_SecondarySettings", + "inlineStructType": "struct{\nMediastream models.MediastreamSettings\nTorrentstream models.TorrentstreamSettings\nDebrid models.DebridSettings}", + "typescriptType": "INTERNAL_App_SecondarySettings", + "usedTypescriptType": "{ Mediastream: MediastreamSettings; Torrentstream: TorrentstreamSettings; Debrid: DebridSettings; }", + "usedStructName": "core.App_SecondarySettings", + "required": true, + "public": true, + "comments": [ + " Struct for other settings sent to clientN" + ] + }, + { + "name": "SelfUpdater", + "jsonName": "SelfUpdater", + "goType": "updater.SelfUpdater", + "typescriptType": "Updater_SelfUpdater", + "usedTypescriptType": "Updater_SelfUpdater", + "usedStructName": "updater.SelfUpdater", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReportRepository", + "jsonName": "ReportRepository", + "goType": "report.Repository", + "typescriptType": "Report_Repository", + "usedTypescriptType": "Report_Repository", + "usedStructName": "report.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TotalLibrarySize", + "jsonName": "TotalLibrarySize", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Initialized in modules.go" + ] + }, + { + "name": "LibraryDir", + "jsonName": "LibraryDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsDesktopSidecar", + "jsonName": "IsDesktopSidecar", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "animeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "rawAnimeCollection", + "jsonName": "rawAnimeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": false, + "comments": [ + " (retains custom lists)" + ] + }, + { + "name": "mangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "rawMangaCollection", + "jsonName": "rawMangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": false, + "comments": [ + " (retains custom lists)" + ] + }, + { + "name": "user", + "jsonName": "user", + "goType": "user.User", + "typescriptType": "User", + "usedTypescriptType": "User", + "usedStructName": "user.User", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "previousVersion", + "jsonName": "previousVersion", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "moduleMu", + "jsonName": "moduleMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "HookManager", + "jsonName": "HookManager", + "goType": "hook.Manager", + "typescriptType": "Manager", + "usedTypescriptType": "Manager", + "usedStructName": "hook.Manager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ServerReady", + "jsonName": "ServerReady", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Whether the Anilist data from the first request has been fetched" + ] + }, + { + "name": "isOffline", + "jsonName": "isOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "NakamaManager", + "jsonName": "NakamaManager", + "goType": "nakama.Manager", + "typescriptType": "Nakama_Manager", + "usedTypescriptType": "Nakama_Manager", + "usedStructName": "nakama.Manager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ServerPasswordHash", + "jsonName": "ServerPasswordHash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " SHA-256 hash of the server password" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/app.go", + "filename": "app.go", + "name": "App_MediaPlayer", + "formattedName": "INTERNAL_App_MediaPlayer", + "package": "core", + "fields": [ + { + "name": "VLC", + "jsonName": "VLC", + "goType": "vlc.VLC", + "typescriptType": "VLC", + "usedTypescriptType": "VLC", + "usedStructName": "vlc.VLC", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MpcHc", + "jsonName": "MpcHc", + "goType": "mpchc.MpcHc", + "typescriptType": "MpcHc", + "usedTypescriptType": "MpcHc", + "usedStructName": "mpchc.MpcHc", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Mpv", + "jsonName": "Mpv", + "goType": "mpv.Mpv", + "typescriptType": "Mpv", + "usedTypescriptType": "Mpv", + "usedStructName": "mpv.Mpv", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Iina", + "jsonName": "Iina", + "goType": "iina.Iina", + "typescriptType": "Iina", + "usedTypescriptType": "Iina", + "usedStructName": "iina.Iina", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/app.go", + "filename": "app.go", + "name": "App_SecondarySettings", + "formattedName": "INTERNAL_App_SecondarySettings", + "package": "core", + "fields": [ + { + "name": "Mediastream", + "jsonName": "Mediastream", + "goType": "models.MediastreamSettings", + "typescriptType": "Models_MediastreamSettings", + "usedTypescriptType": "Models_MediastreamSettings", + "usedStructName": "models.MediastreamSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Torrentstream", + "jsonName": "Torrentstream", + "goType": "models.TorrentstreamSettings", + "typescriptType": "Models_TorrentstreamSettings", + "usedTypescriptType": "Models_TorrentstreamSettings", + "usedStructName": "models.TorrentstreamSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Debrid", + "jsonName": "Debrid", + "goType": "models.DebridSettings", + "typescriptType": "Models_DebridSettings", + "usedTypescriptType": "Models_DebridSettings", + "usedStructName": "models.DebridSettings", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config", + "formattedName": "INTERNAL_Config", + "package": "core", + "fields": [ + { + "name": "Version", + "jsonName": "Version", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Server", + "jsonName": "Server", + "goType": "INTERNAL_Config_Server", + "inlineStructType": "struct{\nHost string\nPort int\nOffline bool\nUseBinaryPath bool\nSystray bool\nDoHUrl string\nPassword string}", + "typescriptType": "INTERNAL_Config_Server", + "usedTypescriptType": "{ Host: string; Port: number; Offline: boolean; UseBinaryPath: boolean; Systray: boolean; DoHUrl: string; Password: string; }", + "usedStructName": "core.Config_Server", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "INTERNAL_Config_Database", + "inlineStructType": "struct{\nName string}", + "typescriptType": "INTERNAL_Config_Database", + "usedTypescriptType": "{ Name: string; }", + "usedStructName": "core.Config_Database", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Web", + "jsonName": "Web", + "goType": "INTERNAL_Config_Web", + "inlineStructType": "struct{\nAssetDir string}", + "typescriptType": "INTERNAL_Config_Web", + "usedTypescriptType": "{ AssetDir: string; }", + "usedStructName": "core.Config_Web", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Logs", + "jsonName": "Logs", + "goType": "INTERNAL_Config_Logs", + "inlineStructType": "struct{\nDir string}", + "typescriptType": "INTERNAL_Config_Logs", + "usedTypescriptType": "{ Dir: string; }", + "usedStructName": "core.Config_Logs", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Cache", + "jsonName": "Cache", + "goType": "INTERNAL_Config_Cache", + "inlineStructType": "struct{\nDir string\nTranscodeDir string}", + "typescriptType": "INTERNAL_Config_Cache", + "usedTypescriptType": "{ Dir: string; TranscodeDir: string; }", + "usedStructName": "core.Config_Cache", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Offline", + "jsonName": "Offline", + "goType": "INTERNAL_Config_Offline", + "inlineStructType": "struct{\nDir string\nAssetDir string}", + "typescriptType": "INTERNAL_Config_Offline", + "usedTypescriptType": "{ Dir: string; AssetDir: string; }", + "usedStructName": "core.Config_Offline", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Manga", + "jsonName": "Manga", + "goType": "INTERNAL_Config_Manga", + "inlineStructType": "struct{\nDownloadDir string\nLocalDir string}", + "typescriptType": "INTERNAL_Config_Manga", + "usedTypescriptType": "{ DownloadDir: string; LocalDir: string; }", + "usedStructName": "core.Config_Manga", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "Data", + "goType": "INTERNAL_Config_Data", + "inlineStructType": "struct{\nAppDataDir string\nWorkingDir string}", + "typescriptType": "INTERNAL_Config_Data", + "usedTypescriptType": "{ AppDataDir: string; WorkingDir: string; }", + "usedStructName": "core.Config_Data", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Extensions", + "jsonName": "Extensions", + "goType": "INTERNAL_Config_Extensions", + "inlineStructType": "struct{\nDir string}", + "typescriptType": "INTERNAL_Config_Extensions", + "usedTypescriptType": "{ Dir: string; }", + "usedStructName": "core.Config_Extensions", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Anilist", + "jsonName": "Anilist", + "goType": "INTERNAL_Config_Anilist", + "inlineStructType": "struct{\nClientID string}", + "typescriptType": "INTERNAL_Config_Anilist", + "usedTypescriptType": "{ ClientID: string; }", + "usedStructName": "core.Config_Anilist", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Experimental", + "jsonName": "Experimental", + "goType": "INTERNAL_Config_Experimental", + "inlineStructType": "struct{\nMainServerTorrentStreaming bool}", + "typescriptType": "INTERNAL_Config_Experimental", + "usedTypescriptType": "{ MainServerTorrentStreaming: boolean; }", + "usedStructName": "core.Config_Experimental", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config_Server", + "formattedName": "INTERNAL_Config_Server", + "package": "core", + "fields": [ + { + "name": "Host", + "jsonName": "Host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Port", + "jsonName": "Port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Offline", + "jsonName": "Offline", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UseBinaryPath", + "jsonName": "UseBinaryPath", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Makes $SEANIME_WORKING_DIR point to the binary's directory" + ] + }, + { + "name": "Systray", + "jsonName": "Systray", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DoHUrl", + "jsonName": "DoHUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Password", + "jsonName": "Password", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config_Database", + "formattedName": "INTERNAL_Config_Database", + "package": "core", + "fields": [ + { + "name": "Name", + "jsonName": "Name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config_Web", + "formattedName": "INTERNAL_Config_Web", + "package": "core", + "fields": [ + { + "name": "AssetDir", + "jsonName": "AssetDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config_Logs", + "formattedName": "INTERNAL_Config_Logs", + "package": "core", + "fields": [ + { + "name": "Dir", + "jsonName": "Dir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config_Cache", + "formattedName": "INTERNAL_Config_Cache", + "package": "core", + "fields": [ + { + "name": "Dir", + "jsonName": "Dir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TranscodeDir", + "jsonName": "TranscodeDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config_Offline", + "formattedName": "INTERNAL_Config_Offline", + "package": "core", + "fields": [ + { + "name": "Dir", + "jsonName": "Dir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AssetDir", + "jsonName": "AssetDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config_Manga", + "formattedName": "INTERNAL_Config_Manga", + "package": "core", + "fields": [ + { + "name": "DownloadDir", + "jsonName": "DownloadDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalDir", + "jsonName": "LocalDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config_Data", + "formattedName": "INTERNAL_Config_Data", + "package": "core", + "fields": [ + { + "name": "AppDataDir", + "jsonName": "AppDataDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WorkingDir", + "jsonName": "WorkingDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config_Extensions", + "formattedName": "INTERNAL_Config_Extensions", + "package": "core", + "fields": [ + { + "name": "Dir", + "jsonName": "Dir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config_Anilist", + "formattedName": "INTERNAL_Config_Anilist", + "package": "core", + "fields": [ + { + "name": "ClientID", + "jsonName": "ClientID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "Config_Experimental", + "formattedName": "INTERNAL_Config_Experimental", + "package": "core", + "fields": [ + { + "name": "MainServerTorrentStreaming", + "jsonName": "MainServerTorrentStreaming", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/config.go", + "filename": "config.go", + "name": "ConfigOptions", + "formattedName": "INTERNAL_ConfigOptions", + "package": "core", + "fields": [ + { + "name": "DataDir", + "jsonName": "DataDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " The path to the Seanime data directory, if any" + ] + }, + { + "name": "OnVersionChange", + "jsonName": "OnVersionChange", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EmbeddedLogo", + "jsonName": "EmbeddedLogo", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " The embedded logo" + ] + }, + { + "name": "IsDesktopSidecar", + "jsonName": "IsDesktopSidecar", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Run as the desktop sidecar" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/echo.go", + "filename": "echo.go", + "name": "CustomJSONSerializer", + "formattedName": "INTERNAL_CustomJSONSerializer", + "package": "core", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/core/feature_flags.go", + "filename": "feature_flags.go", + "name": "FeatureFlags", + "formattedName": "INTERNAL_FeatureFlags", + "package": "core", + "fields": [ + { + "name": "MainServerTorrentStreaming", + "jsonName": "MainServerTorrentStreaming", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/core/feature_flags.go", + "filename": "feature_flags.go", + "name": "ExperimentalFeatureFlags", + "formattedName": "INTERNAL_ExperimentalFeatureFlags", + "package": "core", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/core/flags.go", + "filename": "flags.go", + "name": "SeanimeFlags", + "formattedName": "INTERNAL_SeanimeFlags", + "package": "core", + "fields": [ + { + "name": "DataDir", + "jsonName": "DataDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Update", + "jsonName": "Update", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsDesktopSidecar", + "jsonName": "IsDesktopSidecar", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/cron/cron.go", + "filename": "cron.go", + "name": "JobCtx", + "formattedName": "JobCtx", + "package": "cron", + "fields": [ + { + "name": "App", + "jsonName": "App", + "goType": "core.App", + "typescriptType": "INTERNAL_App", + "usedTypescriptType": "INTERNAL_App", + "usedStructName": "core.App", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/db/db.go", + "filename": "db.go", + "name": "Database", + "formattedName": "DB_Database", + "package": "db", + "fields": [ + { + "name": "gormdb", + "jsonName": "gormdb", + "goType": "gorm.DB", + "typescriptType": "DB", + "usedTypescriptType": "DB", + "usedStructName": "gorm.DB", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CurrMediaFillers", + "jsonName": "CurrMediaFillers", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/db/media_filler.go", + "filename": "media_filler.go", + "name": "MediaFillerItem", + "formattedName": "DB_MediaFillerItem", + "package": "db", + "fields": [ + { + "name": "DbId", + "jsonName": "dbId", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Slug", + "jsonName": "slug", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastFetchedAt", + "jsonName": "lastFetchedAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FillerEpisodes", + "jsonName": "fillerEpisodes", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "BaseModel", + "formattedName": "Models_BaseModel", + "package": "models", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "Token", + "formattedName": "Models_Token", + "package": "models", + "fields": [ + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "Account", + "formattedName": "Models_Account", + "package": "models", + "fields": [ + { + "name": "Username", + "jsonName": "username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Token", + "jsonName": "token", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Viewer", + "jsonName": "viewer", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "LocalFiles", + "formattedName": "Models_LocalFiles", + "package": "models", + "fields": [ + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "Settings", + "formattedName": "Models_Settings", + "package": "models", + "fields": [ + { + "name": "Library", + "jsonName": "library", + "goType": "LibrarySettings", + "typescriptType": "Models_LibrarySettings", + "usedTypescriptType": "Models_LibrarySettings", + "usedStructName": "models.LibrarySettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaPlayer", + "jsonName": "mediaPlayer", + "goType": "MediaPlayerSettings", + "typescriptType": "Models_MediaPlayerSettings", + "usedTypescriptType": "Models_MediaPlayerSettings", + "usedStructName": "models.MediaPlayerSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "TorrentSettings", + "typescriptType": "Models_TorrentSettings", + "usedTypescriptType": "Models_TorrentSettings", + "usedStructName": "models.TorrentSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Manga", + "jsonName": "manga", + "goType": "MangaSettings", + "typescriptType": "Models_MangaSettings", + "usedTypescriptType": "Models_MangaSettings", + "usedStructName": "models.MangaSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Anilist", + "jsonName": "anilist", + "goType": "AnilistSettings", + "typescriptType": "Models_AnilistSettings", + "usedTypescriptType": "Models_AnilistSettings", + "usedStructName": "models.AnilistSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ListSync", + "jsonName": "listSync", + "goType": "ListSyncSettings", + "typescriptType": "Models_ListSyncSettings", + "usedTypescriptType": "Models_ListSyncSettings", + "usedStructName": "models.ListSyncSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AutoDownloader", + "jsonName": "autoDownloader", + "goType": "AutoDownloaderSettings", + "typescriptType": "Models_AutoDownloaderSettings", + "usedTypescriptType": "Models_AutoDownloaderSettings", + "usedStructName": "models.AutoDownloaderSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Discord", + "jsonName": "discord", + "goType": "DiscordSettings", + "typescriptType": "Models_DiscordSettings", + "usedTypescriptType": "Models_DiscordSettings", + "usedStructName": "models.DiscordSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Notifications", + "jsonName": "notifications", + "goType": "NotificationSettings", + "typescriptType": "Models_NotificationSettings", + "usedTypescriptType": "Models_NotificationSettings", + "usedStructName": "models.NotificationSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nakama", + "jsonName": "nakama", + "goType": "NakamaSettings", + "typescriptType": "Models_NakamaSettings", + "usedTypescriptType": "Models_NakamaSettings", + "usedStructName": "models.NakamaSettings", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "AnilistSettings", + "formattedName": "Models_AnilistSettings", + "package": "models", + "fields": [ + { + "name": "HideAudienceScore", + "jsonName": "hideAudienceScore", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableAdultContent", + "jsonName": "enableAdultContent", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BlurAdultContent", + "jsonName": "blurAdultContent", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "LibrarySettings", + "formattedName": "Models_LibrarySettings", + "package": "models", + "fields": [ + { + "name": "LibraryPath", + "jsonName": "libraryPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoUpdateProgress", + "jsonName": "autoUpdateProgress", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisableUpdateCheck", + "jsonName": "disableUpdateCheck", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TorrentProvider", + "jsonName": "torrentProvider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoScan", + "jsonName": "autoScan", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableOnlinestream", + "jsonName": "enableOnlinestream", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IncludeOnlineStreamingInLibrary", + "jsonName": "includeOnlineStreamingInLibrary", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisableAnimeCardTrailers", + "jsonName": "disableAnimeCardTrailers", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableManga", + "jsonName": "enableManga", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DOHProvider", + "jsonName": "dohProvider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "OpenTorrentClientOnStart", + "jsonName": "openTorrentClientOnStart", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "OpenWebURLOnStart", + "jsonName": "openWebURLOnStart", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RefreshLibraryOnStart", + "jsonName": "refreshLibraryOnStart", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoPlayNextEpisode", + "jsonName": "autoPlayNextEpisode", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableWatchContinuity", + "jsonName": "enableWatchContinuity", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LibraryPaths", + "jsonName": "libraryPaths", + "goType": "LibraryPaths", + "typescriptType": "Models_LibraryPaths", + "usedTypescriptType": "Models_LibraryPaths", + "usedStructName": "models.LibraryPaths", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoSyncOfflineLocalData", + "jsonName": "autoSyncOfflineLocalData", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ScannerMatchingThreshold", + "jsonName": "scannerMatchingThreshold", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ScannerMatchingAlgorithm", + "jsonName": "scannerMatchingAlgorithm", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoSyncToLocalAccount", + "jsonName": "autoSyncToLocalAccount", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoSaveCurrentMediaOffline", + "jsonName": "autoSaveCurrentMediaOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "LibraryPaths", + "formattedName": "Models_LibraryPaths", + "package": "models", + "fields": [], + "aliasOf": { + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "declaredValues": null + }, + "comments": null + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "NakamaSettings", + "formattedName": "Models_NakamaSettings", + "package": "models", + "fields": [ + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Username", + "jsonName": "username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsHost", + "jsonName": "isHost", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HostPassword", + "jsonName": "hostPassword", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RemoteServerURL", + "jsonName": "remoteServerURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RemoteServerPassword", + "jsonName": "remoteServerPassword", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IncludeNakamaAnimeLibrary", + "jsonName": "includeNakamaAnimeLibrary", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HostShareLocalAnimeLibrary", + "jsonName": "hostShareLocalAnimeLibrary", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HostUnsharedAnimeIds", + "jsonName": "hostUnsharedAnimeIds", + "goType": "IntSlice", + "typescriptType": "Models_IntSlice", + "usedTypescriptType": "Models_IntSlice", + "usedStructName": "models.IntSlice", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HostEnablePortForwarding", + "jsonName": "hostEnablePortForwarding", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "IntSlice", + "formattedName": "Models_IntSlice", + "package": "models", + "fields": [], + "aliasOf": { + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "declaredValues": null + }, + "comments": null + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "MangaSettings", + "formattedName": "Models_MangaSettings", + "package": "models", + "fields": [ + { + "name": "DefaultProvider", + "jsonName": "defaultMangaProvider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoUpdateProgress", + "jsonName": "mangaAutoUpdateProgress", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalSourceDirectory", + "jsonName": "mangaLocalSourceDirectory", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "MediaPlayerSettings", + "formattedName": "Models_MediaPlayerSettings", + "package": "models", + "fields": [ + { + "name": "Default", + "jsonName": "defaultPlayer", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " \"vlc\" or \"mpc-hc\"" + ] + }, + { + "name": "Host", + "jsonName": "host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VlcUsername", + "jsonName": "vlcUsername", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VlcPassword", + "jsonName": "vlcPassword", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VlcPort", + "jsonName": "vlcPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VlcPath", + "jsonName": "vlcPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MpcPort", + "jsonName": "mpcPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MpcPath", + "jsonName": "mpcPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MpvSocket", + "jsonName": "mpvSocket", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MpvPath", + "jsonName": "mpvPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MpvArgs", + "jsonName": "mpvArgs", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IinaSocket", + "jsonName": "iinaSocket", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IinaPath", + "jsonName": "iinaPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IinaArgs", + "jsonName": "iinaArgs", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "TorrentSettings", + "formattedName": "Models_TorrentSettings", + "package": "models", + "fields": [ + { + "name": "Default", + "jsonName": "defaultTorrentClient", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QBittorrentPath", + "jsonName": "qbittorrentPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QBittorrentHost", + "jsonName": "qbittorrentHost", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QBittorrentPort", + "jsonName": "qbittorrentPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QBittorrentUsername", + "jsonName": "qbittorrentUsername", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QBittorrentPassword", + "jsonName": "qbittorrentPassword", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QBittorrentTags", + "jsonName": "qbittorrentTags", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TransmissionPath", + "jsonName": "transmissionPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TransmissionHost", + "jsonName": "transmissionHost", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TransmissionPort", + "jsonName": "transmissionPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TransmissionUsername", + "jsonName": "transmissionUsername", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TransmissionPassword", + "jsonName": "transmissionPassword", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ShowActiveTorrentCount", + "jsonName": "showActiveTorrentCount", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HideTorrentList", + "jsonName": "hideTorrentList", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "ListSyncSettings", + "formattedName": "Models_ListSyncSettings", + "package": "models", + "fields": [ + { + "name": "Automatic", + "jsonName": "automatic", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Origin", + "jsonName": "origin", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "DiscordSettings", + "formattedName": "Models_DiscordSettings", + "package": "models", + "fields": [ + { + "name": "EnableRichPresence", + "jsonName": "enableRichPresence", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableAnimeRichPresence", + "jsonName": "enableAnimeRichPresence", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableMangaRichPresence", + "jsonName": "enableMangaRichPresence", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RichPresenceHideSeanimeRepositoryButton", + "jsonName": "richPresenceHideSeanimeRepositoryButton", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RichPresenceShowAniListMediaButton", + "jsonName": "richPresenceShowAniListMediaButton", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RichPresenceShowAniListProfileButton", + "jsonName": "richPresenceShowAniListProfileButton", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RichPresenceUseMediaTitleStatus", + "jsonName": "richPresenceUseMediaTitleStatus", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "NotificationSettings", + "formattedName": "Models_NotificationSettings", + "package": "models", + "fields": [ + { + "name": "DisableNotifications", + "jsonName": "disableNotifications", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisableAutoDownloaderNotifications", + "jsonName": "disableAutoDownloaderNotifications", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisableAutoScannerNotifications", + "jsonName": "disableAutoScannerNotifications", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "Mal", + "formattedName": "Models_Mal", + "package": "models", + "fields": [ + { + "name": "Username", + "jsonName": "username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AccessToken", + "jsonName": "accessToken", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RefreshToken", + "jsonName": "refreshToken", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TokenExpiresAt", + "jsonName": "tokenExpiresAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "ScanSummary", + "formattedName": "Models_ScanSummary", + "package": "models", + "fields": [ + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "AutoDownloaderRule", + "formattedName": "Models_AutoDownloaderRule", + "package": "models", + "fields": [ + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "AutoDownloaderItem", + "formattedName": "Models_AutoDownloaderItem", + "package": "models", + "fields": [ + { + "name": "RuleID", + "jsonName": "ruleId", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Link", + "jsonName": "link", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Magnet", + "jsonName": "magnet", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TorrentName", + "jsonName": "torrentName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Downloaded", + "jsonName": "downloaded", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "AutoDownloaderSettings", + "formattedName": "Models_AutoDownloaderSettings", + "package": "models", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Interval", + "jsonName": "interval", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadAutomatically", + "jsonName": "downloadAutomatically", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableEnhancedQueries", + "jsonName": "enableEnhancedQueries", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableSeasonCheck", + "jsonName": "enableSeasonCheck", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UseDebrid", + "jsonName": "useDebrid", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "SilencedMediaEntry", + "formattedName": "Models_SilencedMediaEntry", + "package": "models", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "Theme", + "formattedName": "Models_Theme", + "package": "models", + "fields": [ + { + "name": "EnableColorSettings", + "jsonName": "enableColorSettings", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BackgroundColor", + "jsonName": "backgroundColor", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AccentColor", + "jsonName": "accentColor", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SidebarBackgroundColor", + "jsonName": "sidebarBackgroundColor", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " DEPRECATED" + ] + }, + { + "name": "AnimeEntryScreenLayout", + "jsonName": "animeEntryScreenLayout", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " DEPRECATED" + ] + }, + { + "name": "ExpandSidebarOnHover", + "jsonName": "expandSidebarOnHover", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HideTopNavbar", + "jsonName": "hideTopNavbar", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableMediaCardBlurredBackground", + "jsonName": "enableMediaCardBlurredBackground", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LibraryScreenCustomBackgroundImage", + "jsonName": "libraryScreenCustomBackgroundImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LibraryScreenCustomBackgroundOpacity", + "jsonName": "libraryScreenCustomBackgroundOpacity", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SmallerEpisodeCarouselSize", + "jsonName": "smallerEpisodeCarouselSize", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LibraryScreenBannerType", + "jsonName": "libraryScreenBannerType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LibraryScreenCustomBannerImage", + "jsonName": "libraryScreenCustomBannerImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LibraryScreenCustomBannerPosition", + "jsonName": "libraryScreenCustomBannerPosition", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LibraryScreenCustomBannerOpacity", + "jsonName": "libraryScreenCustomBannerOpacity", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisableLibraryScreenGenreSelector", + "jsonName": "disableLibraryScreenGenreSelector", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LibraryScreenCustomBackgroundBlur", + "jsonName": "libraryScreenCustomBackgroundBlur", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableMediaPageBlurredBackground", + "jsonName": "enableMediaPageBlurredBackground", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisableSidebarTransparency", + "jsonName": "disableSidebarTransparency", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UseLegacyEpisodeCard", + "jsonName": "useLegacyEpisodeCard", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " DEPRECATED" + ] + }, + { + "name": "DisableCarouselAutoScroll", + "jsonName": "disableCarouselAutoScroll", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaPageBannerType", + "jsonName": "mediaPageBannerType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaPageBannerSize", + "jsonName": "mediaPageBannerSize", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaPageBannerInfoBoxSize", + "jsonName": "mediaPageBannerInfoBoxSize", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ShowEpisodeCardAnimeInfo", + "jsonName": "showEpisodeCardAnimeInfo", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ContinueWatchingDefaultSorting", + "jsonName": "continueWatchingDefaultSorting", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnimeLibraryCollectionDefaultSorting", + "jsonName": "animeLibraryCollectionDefaultSorting", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MangaLibraryCollectionDefaultSorting", + "jsonName": "mangaLibraryCollectionDefaultSorting", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ShowAnimeUnwatchedCount", + "jsonName": "showAnimeUnwatchedCount", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ShowMangaUnreadCount", + "jsonName": "showMangaUnreadCount", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HideEpisodeCardDescription", + "jsonName": "hideEpisodeCardDescription", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HideDownloadedEpisodeCardFilename", + "jsonName": "hideDownloadedEpisodeCardFilename", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CustomCSS", + "jsonName": "customCSS", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MobileCustomCSS", + "jsonName": "mobileCustomCSS", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UnpinnedMenuItems", + "jsonName": "unpinnedMenuItems", + "goType": "StringSlice", + "typescriptType": "Models_StringSlice", + "usedTypescriptType": "Models_StringSlice", + "usedStructName": "models.StringSlice", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "PlaylistEntry", + "formattedName": "Models_PlaylistEntry", + "package": "models", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "ChapterDownloadQueueItem", + "formattedName": "Models_ChapterDownloadQueueItem", + "package": "models", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterID", + "jsonName": "chapterId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterNumber", + "jsonName": "chapterNumber", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PageData", + "jsonName": "pageData", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " Contains map of page index to page details" + ] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "MediastreamSettings", + "formattedName": "Models_MediastreamSettings", + "package": "models", + "fields": [ + { + "name": "TranscodeEnabled", + "jsonName": "transcodeEnabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TranscodeHwAccel", + "jsonName": "transcodeHwAccel", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TranscodeThreads", + "jsonName": "transcodeThreads", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TranscodePreset", + "jsonName": "transcodePreset", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisableAutoSwitchToDirectPlay", + "jsonName": "disableAutoSwitchToDirectPlay", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DirectPlayOnly", + "jsonName": "directPlayOnly", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PreTranscodeEnabled", + "jsonName": "preTranscodeEnabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PreTranscodeLibraryDir", + "jsonName": "preTranscodeLibraryDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FfmpegPath", + "jsonName": "ffmpegPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FfprobePath", + "jsonName": "ffprobePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TranscodeHwAccelCustomSettings", + "jsonName": "transcodeHwAccelCustomSettings", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "TorrentstreamSettings", + "formattedName": "Models_TorrentstreamSettings", + "package": "models", + "fields": [ + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoSelect", + "jsonName": "autoSelect", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PreferredResolution", + "jsonName": "preferredResolution", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisableIPV6", + "jsonName": "disableIPV6", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadDir", + "jsonName": "downloadDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AddToLibrary", + "jsonName": "addToLibrary", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TorrentClientHost", + "jsonName": "torrentClientHost", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TorrentClientPort", + "jsonName": "torrentClientPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamingServerHost", + "jsonName": "streamingServerHost", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamingServerPort", + "jsonName": "streamingServerPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IncludeInLibrary", + "jsonName": "includeInLibrary", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamUrlAddress", + "jsonName": "streamUrlAddress", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SlowSeeding", + "jsonName": "slowSeeding", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "TorrentstreamHistory", + "formattedName": "Models_TorrentstreamHistory", + "package": "models", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "MediaFiller", + "formattedName": "Models_MediaFiller", + "package": "models", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Slug", + "jsonName": "slug", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastFetchedAt", + "jsonName": "lastFetchedAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "data", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "MangaMapping", + "formattedName": "Models_MangaMapping", + "package": "models", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MangaID", + "jsonName": "mangaId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " ID from search result, used to fetch chapters" + ] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "MangaChapterContainer", + "formattedName": "Models_MangaChapterContainer", + "package": "models", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterID", + "jsonName": "chapterId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "data", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "OnlinestreamMapping", + "formattedName": "Models_OnlinestreamMapping", + "package": "models", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnimeID", + "jsonName": "anime_id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " ID from search result, used to fetch episodes" + ] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "DebridSettings", + "formattedName": "Models_DebridSettings", + "package": "models", + "fields": [ + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ApiKey", + "jsonName": "apiKey", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IncludeDebridStreamInLibrary", + "jsonName": "includeDebridStreamInLibrary", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamAutoSelect", + "jsonName": "streamAutoSelect", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamPreferredResolution", + "jsonName": "streamPreferredResolution", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "DebridTorrentItem", + "formattedName": "Models_DebridTorrentItem", + "package": "models", + "fields": [ + { + "name": "TorrentItemID", + "jsonName": "torrentItemId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "PluginData", + "formattedName": "Models_PluginData", + "package": "models", + "fields": [ + { + "name": "PluginID", + "jsonName": "pluginId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "data", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.BaseModel" + ] + }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "StringSlice", + "formattedName": "Models_StringSlice", + "package": "models", + "fields": [], + "aliasOf": { + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "declaredValues": null + }, + "comments": null + }, + { + "filepath": "../internal/debrid/client/hook_events.go", + "filename": "hook_events.go", + "name": "DebridAutoSelectTorrentsFetchedEvent", + "formattedName": "DebridClient_DebridAutoSelectTorrentsFetchedEvent", + "package": "debrid_client", + "fields": [ + { + "name": "Torrents", + "jsonName": "Torrents", + "goType": "[]hibiketorrent.AnimeTorrent", + "typescriptType": "Array\u003cHibikeTorrent_AnimeTorrent\u003e", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " DebridAutoSelectTorrentsFetchedEvent is triggered when the torrents are fetched for auto select.", + " The torrents are sorted by seeders from highest to lowest.", + " This event is triggered before the top 3 torrents are analyzed." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/debrid/client/hook_events.go", + "filename": "hook_events.go", + "name": "DebridSkipStreamCheckEvent", + "formattedName": "DebridClient_DebridSkipStreamCheckEvent", + "package": "debrid_client", + "fields": [ + { + "name": "StreamURL", + "jsonName": "streamURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Retries", + "jsonName": "retries", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RetryDelay", + "jsonName": "retryDelay", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " in seconds" + ] + } + ], + "comments": [ + " DebridSkipStreamCheckEvent is triggered when the debrid client is about to skip the stream check.", + " Prevent default to enable the stream check." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/debrid/client/hook_events.go", + "filename": "hook_events.go", + "name": "DebridSendStreamToMediaPlayerEvent", + "formattedName": "DebridClient_DebridSendStreamToMediaPlayerEvent", + "package": "debrid_client", + "fields": [ + { + "name": "WindowTitle", + "jsonName": "windowTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamURL", + "jsonName": "streamURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AniDbEpisode", + "jsonName": "aniDbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PlaybackType", + "jsonName": "playbackType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " DebridSendStreamToMediaPlayerEvent is triggered when the debrid client is about to send a stream to the media player.", + " Prevent default to skip the playback." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/debrid/client/hook_events.go", + "filename": "hook_events.go", + "name": "DebridLocalDownloadRequestedEvent", + "formattedName": "DebridClient_DebridLocalDownloadRequestedEvent", + "package": "debrid_client", + "fields": [ + { + "name": "TorrentName", + "jsonName": "torrentName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadUrl", + "jsonName": "downloadUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " DebridLocalDownloadRequestedEvent is triggered when Seanime is about to download a debrid torrent locally.", + " Prevent default to skip the default download and override the download." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/debrid/client/previews.go", + "filename": "previews.go", + "name": "FilePreview", + "formattedName": "DebridClient_FilePreview", + "package": "debrid_client", + "fields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisplayPath", + "jsonName": "displayPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisplayTitle", + "jsonName": "displayTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RelativeEpisodeNumber", + "jsonName": "relativeEpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsLikely", + "jsonName": "isLikely", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Index", + "jsonName": "index", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileId", + "jsonName": "fileId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/client/previews.go", + "filename": "previews.go", + "name": "GetTorrentFilePreviewsOptions", + "formattedName": "DebridClient_GetTorrentFilePreviewsOptions", + "package": "debrid_client", + "fields": [ + { + "name": "Torrent", + "jsonName": "Torrent", + "goType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Magnet", + "jsonName": "Magnet", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "EpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AbsoluteOffset", + "jsonName": "AbsoluteOffset", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/client/repository.go", + "filename": "repository.go", + "name": "Repository", + "formattedName": "DebridClient_Repository", + "package": "debrid_client", + "fields": [ + { + "name": "provider", + "jsonName": "provider", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "db", + "jsonName": "db", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "models.DebridSettings", + "typescriptType": "Models_DebridSettings", + "usedTypescriptType": "Models_DebridSettings", + "usedStructName": "models.DebridSettings", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ctxMap", + "jsonName": "ctxMap", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "downloadLoopCancelFunc", + "jsonName": "downloadLoopCancelFunc", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "torrentRepository", + "jsonName": "torrentRepository", + "goType": "torrent.Repository", + "typescriptType": "Torrent_Repository", + "usedTypescriptType": "Torrent_Repository", + "usedStructName": "torrent.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "directStreamManager", + "jsonName": "directStreamManager", + "goType": "directstream.Manager", + "typescriptType": "Directstream_Manager", + "usedTypescriptType": "Directstream_Manager", + "usedStructName": "directstream.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackManager", + "jsonName": "playbackManager", + "goType": "playbackmanager.PlaybackManager", + "typescriptType": "PlaybackManager_PlaybackManager", + "usedTypescriptType": "PlaybackManager_PlaybackManager", + "usedStructName": "playbackmanager.PlaybackManager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "streamManager", + "jsonName": "streamManager", + "goType": "StreamManager", + "typescriptType": "DebridClient_StreamManager", + "usedTypescriptType": "DebridClient_StreamManager", + "usedStructName": "debrid_client.StreamManager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "completeAnimeCache", + "jsonName": "completeAnimeCache", + "goType": "anilist.CompleteAnimeCache", + "typescriptType": "AL_CompleteAnimeCache", + "usedTypescriptType": "AL_CompleteAnimeCache", + "usedStructName": "anilist.CompleteAnimeCache", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "platform", + "jsonName": "platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "previousStreamOptions", + "jsonName": "previousStreamOptions", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/client/repository.go", + "filename": "repository.go", + "name": "NewRepositoryOptions", + "formattedName": "DebridClient_NewRepositoryOptions", + "package": "debrid_client", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentRepository", + "jsonName": "TorrentRepository", + "goType": "torrent.Repository", + "typescriptType": "Torrent_Repository", + "usedTypescriptType": "Torrent_Repository", + "usedStructName": "torrent.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PlaybackManager", + "jsonName": "PlaybackManager", + "goType": "playbackmanager.PlaybackManager", + "typescriptType": "PlaybackManager_PlaybackManager", + "usedTypescriptType": "PlaybackManager_PlaybackManager", + "usedStructName": "playbackmanager.PlaybackManager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DirectStreamManager", + "jsonName": "DirectStreamManager", + "goType": "directstream.Manager", + "typescriptType": "Directstream_Manager", + "usedTypescriptType": "Directstream_Manager", + "usedStructName": "directstream.Manager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/client/stream.go", + "filename": "stream.go", + "name": "StreamManager", + "formattedName": "DebridClient_StreamManager", + "package": "debrid_client", + "fields": [ + { + "name": "repository", + "jsonName": "repository", + "goType": "Repository", + "typescriptType": "DebridClient_Repository", + "usedTypescriptType": "DebridClient_Repository", + "usedStructName": "debrid_client.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentTorrentItemId", + "jsonName": "currentTorrentItemId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "downloadCtxCancelFunc", + "jsonName": "downloadCtxCancelFunc", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentStreamUrl", + "jsonName": "currentStreamUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "playbackSubscriberCtxCancelFunc", + "jsonName": "playbackSubscriberCtxCancelFunc", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/client/stream.go", + "filename": "stream.go", + "name": "StreamPlaybackType", + "formattedName": "DebridClient_StreamPlaybackType", + "package": "debrid_client", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"none\"", + "\"noneAndAwait\"", + "\"default\"", + "\"nativeplayer\"", + "\"externalPlayerLink\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/debrid/client/stream.go", + "filename": "stream.go", + "name": "StreamStatus", + "formattedName": "DebridClient_StreamStatus", + "package": "debrid_client", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"downloading\"", + "\"ready\"", + "\"failed\"", + "\"started\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/debrid/client/stream.go", + "filename": "stream.go", + "name": "StreamState", + "formattedName": "DebridClient_StreamState", + "package": "debrid_client", + "fields": [ + { + "name": "Status", + "jsonName": "status", + "goType": "StreamStatus", + "typescriptType": "DebridClient_StreamStatus", + "usedTypescriptType": "DebridClient_StreamStatus", + "usedStructName": "debrid_client.StreamStatus", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TorrentName", + "jsonName": "torrentName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Message", + "jsonName": "message", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/client/stream.go", + "filename": "stream.go", + "name": "StartStreamOptions", + "formattedName": "DebridClient_StartStreamOptions", + "package": "debrid_client", + "fields": [ + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "EpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " RELATIVE Episode number to identify the file" + ] + }, + { + "name": "AniDBEpisode", + "jsonName": "AniDBEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Anizip episode" + ] + }, + { + "name": "Torrent", + "jsonName": "Torrent", + "goType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [ + " Selected torrent" + ] + }, + { + "name": "FileId", + "jsonName": "FileId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " File ID or index" + ] + }, + { + "name": "FileIndex", + "jsonName": "FileIndex", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [ + " Index of the file to stream (Manual selection)" + ] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ClientId", + "jsonName": "ClientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PlaybackType", + "jsonName": "PlaybackType", + "goType": "StreamPlaybackType", + "typescriptType": "DebridClient_StreamPlaybackType", + "usedTypescriptType": "DebridClient_StreamPlaybackType", + "usedStructName": "debrid_client.StreamPlaybackType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoSelect", + "jsonName": "AutoSelect", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/client/stream.go", + "filename": "stream.go", + "name": "CancelStreamOptions", + "formattedName": "DebridClient_CancelStreamOptions", + "package": "debrid_client", + "fields": [ + { + "name": "RemoveTorrent", + "jsonName": "removeTorrent", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/debrid/debrid.go", + "filename": "debrid.go", + "name": "AddTorrentOptions", + "formattedName": "Debrid_AddTorrentOptions", + "package": "debrid", + "fields": [ + { + "name": "MagnetLink", + "jsonName": "magnetLink", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "InfoHash", + "jsonName": "infoHash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SelectFileId", + "jsonName": "selectFileId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Real-Debrid only, ID, IDs, or \"all\"" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/debrid/debrid.go", + "filename": "debrid.go", + "name": "StreamTorrentOptions", + "formattedName": "Debrid_StreamTorrentOptions", + "package": "debrid", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileId", + "jsonName": "fileId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " ID or index of the file to stream" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/debrid/debrid.go", + "filename": "debrid.go", + "name": "GetTorrentInfoOptions", + "formattedName": "Debrid_GetTorrentInfoOptions", + "package": "debrid", + "fields": [ + { + "name": "MagnetLink", + "jsonName": "magnetLink", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "InfoHash", + "jsonName": "infoHash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/debrid/debrid.go", + "filename": "debrid.go", + "name": "DownloadTorrentOptions", + "formattedName": "Debrid_DownloadTorrentOptions", + "package": "debrid", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileId", + "jsonName": "fileId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " ID or index of the file to download" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/debrid/debrid.go", + "filename": "debrid.go", + "name": "TorrentItem", + "formattedName": "Debrid_TorrentItem", + "package": "debrid", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Name of the torrent or file" + ] + }, + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " SHA1 hash of the torrent" + ] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Size of the selected files (size in bytes)" + ] + }, + { + "name": "FormattedSize", + "jsonName": "formattedSize", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Formatted size of the selected files" + ] + }, + { + "name": "CompletionPercentage", + "jsonName": "completionPercentage", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Progress percentage (0 to 100)" + ] + }, + { + "name": "ETA", + "jsonName": "eta", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Formatted estimated time remaining" + ] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "TorrentItemStatus", + "typescriptType": "Debrid_TorrentItemStatus", + "usedTypescriptType": "Debrid_TorrentItemStatus", + "usedStructName": "debrid.TorrentItemStatus", + "required": true, + "public": true, + "comments": [ + " Current download status" + ] + }, + { + "name": "AddedAt", + "jsonName": "added", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Date when the torrent was added, RFC3339 format" + ] + }, + { + "name": "Speed", + "jsonName": "speed", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " Current download speed (optional, present in downloading state)" + ] + }, + { + "name": "Seeders", + "jsonName": "seeders", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [ + " Number of seeders (optional, present in downloading state)" + ] + }, + { + "name": "IsReady", + "jsonName": "isReady", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Whether the torrent is ready to be downloaded" + ] + }, + { + "name": "Files", + "jsonName": "files", + "goType": "[]TorrentItemFile", + "typescriptType": "Array\u003cDebrid_TorrentItemFile\u003e", + "usedTypescriptType": "Debrid_TorrentItemFile", + "usedStructName": "debrid.TorrentItemFile", + "required": false, + "public": true, + "comments": [ + " List of files in the torrent (optional)" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/debrid/debrid.go", + "filename": "debrid.go", + "name": "TorrentItemFile", + "formattedName": "Debrid_TorrentItemFile", + "package": "debrid", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " ID of the file, usually the index" + ] + }, + { + "name": "Index", + "jsonName": "index", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/debrid/debrid.go", + "filename": "debrid.go", + "name": "TorrentItemStatus", + "formattedName": "Debrid_TorrentItemStatus", + "package": "debrid", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"downloading\"", + "\"completed\"", + "\"seeding\"", + "\"error\"", + "\"stalled\"", + "\"paused\"", + "\"other\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/debrid/debrid/debrid.go", + "filename": "debrid.go", + "name": "TorrentItemInstantAvailability", + "formattedName": "Debrid_TorrentItemInstantAvailability", + "package": "debrid", + "fields": [ + { + "name": "CachedFiles", + "jsonName": "cachedFiles", + "goType": "map[string]CachedFile", + "typescriptType": "Record\u003cstring, Debrid_CachedFile\u003e", + "usedTypescriptType": "Debrid_CachedFile", + "usedStructName": "debrid.CachedFile", + "required": false, + "public": true, + "comments": [ + " Key is the file ID (or index)" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/debrid/debrid.go", + "filename": "debrid.go", + "name": "TorrentInfo", + "formattedName": "Debrid_TorrentInfo", + "package": "debrid", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " ID of the torrent if added to the debrid service" + ] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Files", + "jsonName": "files", + "goType": "[]TorrentItemFile", + "typescriptType": "Array\u003cDebrid_TorrentItemFile\u003e", + "usedTypescriptType": "Debrid_TorrentItemFile", + "usedStructName": "debrid.TorrentItemFile", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/debrid/debrid.go", + "filename": "debrid.go", + "name": "CachedFile", + "formattedName": "Debrid_CachedFile", + "package": "debrid", + "fields": [ + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/debrid/debrid.go", + "filename": "debrid.go", + "name": "Settings", + "formattedName": "Debrid_Settings", + "package": "debrid", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/realdebrid/realdebrid.go", + "filename": "realdebrid.go", + "name": "RealDebrid", + "formattedName": "RealDebrid", + "package": "realdebrid", + "fields": [ + { + "name": "baseUrl", + "jsonName": "baseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "apiKey", + "jsonName": "apiKey", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "client", + "jsonName": "client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/realdebrid/realdebrid.go", + "filename": "realdebrid.go", + "name": "ErrorResponse", + "formattedName": "ErrorResponse", + "package": "realdebrid", + "fields": [ + { + "name": "Error", + "jsonName": "error", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ErrorDetails", + "jsonName": "error_details", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ErrorCode", + "jsonName": "error_code", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/realdebrid/realdebrid.go", + "filename": "realdebrid.go", + "name": "Torrent", + "formattedName": "Torrent", + "package": "realdebrid", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filename", + "jsonName": "filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Bytes", + "jsonName": "bytes", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Host", + "jsonName": "host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Split", + "jsonName": "split", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Added", + "jsonName": "added", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Links", + "jsonName": "links", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Ended", + "jsonName": "ended", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Speed", + "jsonName": "speed", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Seeders", + "jsonName": "seeders", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/realdebrid/realdebrid.go", + "filename": "realdebrid.go", + "name": "TorrentInfo", + "formattedName": "TorrentInfo", + "package": "realdebrid", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filename", + "jsonName": "filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "OriginalFilename", + "jsonName": "original_filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Bytes", + "jsonName": "bytes", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Size of selected files" + ] + }, + { + "name": "OriginalBytes", + "jsonName": "original_bytes", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Size of the torrent" + ] + }, + { + "name": "Host", + "jsonName": "host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Split", + "jsonName": "split", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Added", + "jsonName": "added", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Files", + "jsonName": "files", + "goType": "[]TorrentInfoFile", + "typescriptType": "Array\u003cTorrentInfoFile\u003e", + "usedTypescriptType": "TorrentInfoFile", + "usedStructName": "realdebrid.TorrentInfoFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Links", + "jsonName": "links", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Ended", + "jsonName": "ended", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Speed", + "jsonName": "speed", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Seeders", + "jsonName": "seeders", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/realdebrid/realdebrid.go", + "filename": "realdebrid.go", + "name": "TorrentInfoFile", + "formattedName": "TorrentInfoFile", + "package": "realdebrid", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g. \"/Big Buck Bunny/Big Buck Bunny.mp4\"" + ] + }, + { + "name": "Bytes", + "jsonName": "bytes", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Selected", + "jsonName": "selected", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " 1 if selected, 0 if not" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/realdebrid/realdebrid.go", + "filename": "realdebrid.go", + "name": "InstantAvailabilityItem", + "formattedName": "InstantAvailabilityItem", + "package": "realdebrid", + "fields": [ + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Files", + "jsonName": "files", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nFilename string `json:\"filename\"`\nFilesize int `json:\"filesize\"`}", + "typescriptType": "Array\u003c{ filename: string; filesize: number; }\u003e", + "usedTypescriptType": "{ filename: string; filesize: number; }", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/torbox/torbox.go", + "filename": "torbox.go", + "name": "TorBox", + "formattedName": "TorBox", + "package": "torbox", + "fields": [ + { + "name": "baseUrl", + "jsonName": "baseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "apiKey", + "jsonName": "apiKey", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "client", + "jsonName": "client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/torbox/torbox.go", + "filename": "torbox.go", + "name": "Response", + "formattedName": "Response", + "package": "torbox", + "fields": [ + { + "name": "Success", + "jsonName": "success", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Detail", + "jsonName": "detail", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "data", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/torbox/torbox.go", + "filename": "torbox.go", + "name": "File", + "formattedName": "File", + "package": "torbox", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MD5", + "jsonName": "md5", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "S3Path", + "jsonName": "s3_path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MimeType", + "jsonName": "mimetype", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ShortName", + "jsonName": "short_name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/torbox/torbox.go", + "filename": "torbox.go", + "name": "Torrent", + "formattedName": "Torrent", + "package": "torbox", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "created_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updated_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Magnet", + "jsonName": "magnet", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Active", + "jsonName": "active", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AuthID", + "jsonName": "auth_id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadState", + "jsonName": "download_state", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Seeds", + "jsonName": "seeds", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Peers", + "jsonName": "peers", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Ratio", + "jsonName": "ratio", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadSpeed", + "jsonName": "download_speed", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UploadSpeed", + "jsonName": "upload_speed", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ETA", + "jsonName": "eta", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Server", + "jsonName": "server", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TorrentFile", + "jsonName": "torrent_file", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ExpiresAt", + "jsonName": "expires_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadPresent", + "jsonName": "download_present", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadFinished", + "jsonName": "download_finished", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Files", + "jsonName": "files", + "goType": "[]File", + "typescriptType": "Array\u003cFile\u003e", + "usedTypescriptType": "File", + "usedStructName": "torbox.File", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "InactiveCheck", + "jsonName": "inactive_check", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Availability", + "jsonName": "availability", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/torbox/torbox.go", + "filename": "torbox.go", + "name": "TorrentInfo", + "formattedName": "TorrentInfo", + "package": "torbox", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Files", + "jsonName": "files", + "goType": "[]TorrentInfoFile", + "typescriptType": "Array\u003cTorrentInfoFile\u003e", + "usedTypescriptType": "TorrentInfoFile", + "usedStructName": "torbox.TorrentInfoFile", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/torbox/torbox.go", + "filename": "torbox.go", + "name": "TorrentInfoFile", + "formattedName": "TorrentInfoFile", + "package": "torbox", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g. \"Big Buck Bunny/Big Buck Bunny.mp4\"" + ] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/debrid/torbox/torbox.go", + "filename": "torbox.go", + "name": "InstantAvailabilityItem", + "formattedName": "InstantAvailabilityItem", + "package": "torbox", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Files", + "jsonName": "files", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nName string `json:\"name\"`\nSize int64 `json:\"size\"`}", + "typescriptType": "Array\u003c{ name: string; size: number; }\u003e", + "usedTypescriptType": "{ name: string; size: number; }", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/directstream/debridstream.go", + "filename": "debridstream.go", + "name": "DebridStream", + "formattedName": "Directstream_DebridStream", + "package": "directstream", + "fields": [ + { + "name": "streamUrl", + "jsonName": "streamUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "contentLength", + "jsonName": "contentLength", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "torrent", + "jsonName": "torrent", + "goType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "streamReadyCh", + "jsonName": "streamReadyCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Closed by the initiator when the stream is ready" + ] + }, + { + "name": "httpStream", + "jsonName": "httpStream", + "goType": "httputil.FileStream", + "typescriptType": "FileStream", + "usedTypescriptType": "FileStream", + "usedStructName": "httputil.FileStream", + "required": false, + "public": false, + "comments": [ + " Shared file-backed cache for multiple readers" + ] + }, + { + "name": "cacheMu", + "jsonName": "cacheMu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [ + " Protects httpStream access" + ] + } + ], + "comments": [ + " DebridStream is a stream that is a torrent." + ], + "embeddedStructNames": [ + "directstream.BaseStream" + ] + }, + { + "filepath": "../internal/directstream/debridstream.go", + "filename": "debridstream.go", + "name": "PlayDebridStreamOptions", + "formattedName": "Directstream_PlayDebridStreamOptions", + "package": "directstream", + "fields": [ + { + "name": "StreamUrl", + "jsonName": "StreamUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "EpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " RELATIVE Episode number to identify the file" + ] + }, + { + "name": "AnidbEpisode", + "jsonName": "AnidbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Anizip episode" + ] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Torrent", + "jsonName": "Torrent", + "goType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [ + " Selected torrent" + ] + }, + { + "name": "FileId", + "jsonName": "FileId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " File ID or index" + ] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ClientId", + "jsonName": "ClientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoSelect", + "jsonName": "AutoSelect", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/directstream/debridstream.go", + "filename": "debridstream.go", + "name": "StreamInfo", + "formattedName": "Directstream_StreamInfo", + "package": "directstream", + "fields": [ + { + "name": "ContentType", + "jsonName": "ContentType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ContentLength", + "jsonName": "ContentLength", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/directstream/localfile.go", + "filename": "localfile.go", + "name": "LocalFileStream", + "formattedName": "Directstream_LocalFileStream", + "package": "directstream", + "fields": [ + { + "name": "localFile", + "jsonName": "localFile", + "goType": "anime.LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " LocalFileStream is a stream that is a local file." + ], + "embeddedStructNames": [ + "directstream.BaseStream" + ] + }, + { + "filepath": "../internal/directstream/localfile.go", + "filename": "localfile.go", + "name": "PlayLocalFileOptions", + "formattedName": "Directstream_PlayLocalFileOptions", + "package": "directstream", + "fields": [ + { + "name": "ClientId", + "jsonName": "ClientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "Path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/directstream/manager.go", + "filename": "manager.go", + "name": "Manager", + "formattedName": "Directstream_Manager", + "package": "directstream", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "continuityManager", + "jsonName": "continuityManager", + "goType": "continuity.Manager", + "typescriptType": "Continuity_Manager", + "usedTypescriptType": "Continuity_Manager", + "usedStructName": "continuity.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "discordPresence", + "jsonName": "discordPresence", + "goType": "discordrpc_presence.Presence", + "typescriptType": "DiscordRPC_Presence", + "usedTypescriptType": "DiscordRPC_Presence", + "usedStructName": "discordrpc_presence.Presence", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "platform", + "jsonName": "platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "refreshAnimeCollectionFunc", + "jsonName": "refreshAnimeCollectionFunc", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " This function is called to refresh the AniList collection" + ] + }, + { + "name": "nativePlayer", + "jsonName": "nativePlayer", + "goType": "nativeplayer.NativePlayer", + "typescriptType": "NativePlayer_NativePlayer", + "usedTypescriptType": "NativePlayer_NativePlayer", + "usedStructName": "nativeplayer.NativePlayer", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "nativePlayerSubscriber", + "jsonName": "nativePlayerSubscriber", + "goType": "nativeplayer.Subscriber", + "typescriptType": "NativePlayer_Subscriber", + "usedTypescriptType": "NativePlayer_Subscriber", + "usedStructName": "nativeplayer.Subscriber", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackMu", + "jsonName": "playbackMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackCtx", + "jsonName": "playbackCtx", + "goType": "context.Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "context.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackCtxCancelFunc", + "jsonName": "playbackCtxCancelFunc", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentStream", + "jsonName": "currentStream", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " The current stream being played" + ] + }, + { + "name": "currentStreamEpisodeCollection", + "jsonName": "currentStreamEpisodeCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "Settings", + "typescriptType": "Directstream_Settings", + "usedTypescriptType": "Directstream_Settings", + "usedStructName": "directstream.Settings", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "isOffline", + "jsonName": "isOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeCollection", + "jsonName": "animeCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "animeCache", + "jsonName": "animeCache", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "parserCache", + "jsonName": "parserCache", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " Manager handles direct stream playback and progress tracking for the built-in video player.", + " It is similar to [playbackmanager.PlaybackManager]." + ] + }, + { + "filepath": "../internal/directstream/manager.go", + "filename": "manager.go", + "name": "Settings", + "formattedName": "Directstream_Settings", + "package": "directstream", + "fields": [ + { + "name": "AutoPlayNextEpisode", + "jsonName": "AutoPlayNextEpisode", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoUpdateProgress", + "jsonName": "AutoUpdateProgress", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Manager handles direct stream playback and progress tracking for the built-in video player.", + " It is similar to [playbackmanager.PlaybackManager]." + ] + }, + { + "filepath": "../internal/directstream/manager.go", + "filename": "manager.go", + "name": "NewManagerOptions", + "formattedName": "Directstream_NewManagerOptions", + "package": "directstream", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ContinuityManager", + "jsonName": "ContinuityManager", + "goType": "continuity.Manager", + "typescriptType": "Continuity_Manager", + "usedTypescriptType": "Continuity_Manager", + "usedStructName": "continuity.Manager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DiscordPresence", + "jsonName": "DiscordPresence", + "goType": "discordrpc_presence.Presence", + "typescriptType": "DiscordRPC_Presence", + "usedTypescriptType": "DiscordRPC_Presence", + "usedStructName": "discordrpc_presence.Presence", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RefreshAnimeCollectionFunc", + "jsonName": "RefreshAnimeCollectionFunc", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsOffline", + "jsonName": "IsOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NativePlayer", + "jsonName": "NativePlayer", + "goType": "nativeplayer.NativePlayer", + "typescriptType": "NativePlayer_NativePlayer", + "usedTypescriptType": "NativePlayer_NativePlayer", + "usedStructName": "nativeplayer.NativePlayer", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Manager handles direct stream playback and progress tracking for the built-in video player.", + " It is similar to [playbackmanager.PlaybackManager]." + ] + }, + { + "filepath": "../internal/directstream/stream.go", + "filename": "stream.go", + "name": "BaseStream", + "formattedName": "Directstream_BaseStream", + "package": "directstream", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "clientId", + "jsonName": "clientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "contentType", + "jsonName": "contentType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "contentTypeOnce", + "jsonName": "contentTypeOnce", + "goType": "sync.Once", + "typescriptType": "Once", + "usedTypescriptType": "Once", + "usedStructName": "sync.Once", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "episode", + "jsonName": "episode", + "goType": "anime.Episode", + "typescriptType": "Anime_Episode", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "listEntryData", + "jsonName": "listEntryData", + "goType": "anime.EntryListData", + "typescriptType": "Anime_EntryListData", + "usedTypescriptType": "Anime_EntryListData", + "usedStructName": "anime.EntryListData", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "episodeCollection", + "jsonName": "episodeCollection", + "goType": "anime.EpisodeCollection", + "typescriptType": "Anime_EpisodeCollection", + "usedTypescriptType": "Anime_EpisodeCollection", + "usedStructName": "anime.EpisodeCollection", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackInfo", + "jsonName": "playbackInfo", + "goType": "nativeplayer.PlaybackInfo", + "typescriptType": "NativePlayer_PlaybackInfo", + "usedTypescriptType": "NativePlayer_PlaybackInfo", + "usedStructName": "nativeplayer.PlaybackInfo", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackInfoErr", + "jsonName": "playbackInfoErr", + "goType": "error", + "typescriptType": "Directstream_error", + "usedTypescriptType": "Directstream_error", + "usedStructName": "directstream.error", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "playbackInfoOnce", + "jsonName": "playbackInfoOnce", + "goType": "sync.Once", + "typescriptType": "Once", + "usedTypescriptType": "Once", + "usedStructName": "sync.Once", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "subtitleEventCache", + "jsonName": "subtitleEventCache", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "terminateOnce", + "jsonName": "terminateOnce", + "goType": "sync.Once", + "typescriptType": "Once", + "usedTypescriptType": "Once", + "usedStructName": "sync.Once", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "serveContentCancelFunc", + "jsonName": "serveContentCancelFunc", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "filename", + "jsonName": "filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [ + " Name of the file being streamed, if applicable" + ] + }, + { + "name": "activeSubtitleStreams", + "jsonName": "activeSubtitleStreams", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "manager", + "jsonName": "manager", + "goType": "Manager", + "typescriptType": "Directstream_Manager", + "usedTypescriptType": "Directstream_Manager", + "usedStructName": "directstream.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "updateProgress", + "jsonName": "updateProgress", + "goType": "sync.Once", + "typescriptType": "Once", + "usedTypescriptType": "Once", + "usedStructName": "sync.Once", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/directstream/stream_helpers.go", + "filename": "stream_helpers.go", + "name": "StreamCacheReadSeekCloser", + "formattedName": "Directstream_StreamCacheReadSeekCloser", + "package": "directstream", + "fields": [ + { + "name": "stream", + "jsonName": "stream", + "goType": "streamcache.Stream", + "typescriptType": "Stream", + "usedTypescriptType": "Stream", + "usedStructName": "streamcache.Stream", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "streamReader", + "jsonName": "streamReader", + "goType": "streamcache.Reader", + "typescriptType": "Reader", + "usedTypescriptType": "Reader", + "usedStructName": "streamcache.Reader", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "originalReader", + "jsonName": "originalReader", + "goType": "io.ReadSeekCloser", + "typescriptType": "ReadSeekCloser", + "usedTypescriptType": "ReadSeekCloser", + "usedStructName": "io.ReadSeekCloser", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/directstream/subtitles.go", + "filename": "subtitles.go", + "name": "SubtitleStream", + "formattedName": "Directstream_SubtitleStream", + "package": "directstream", + "fields": [ + { + "name": "stream", + "jsonName": "stream", + "goType": "Stream", + "typescriptType": "Directstream_Stream", + "usedTypescriptType": "Directstream_Stream", + "usedStructName": "directstream.Stream", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "parser", + "jsonName": "parser", + "goType": "mkvparser.MetadataParser", + "typescriptType": "MKVParser_MetadataParser", + "usedTypescriptType": "MKVParser_MetadataParser", + "usedStructName": "mkvparser.MetadataParser", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "reader", + "jsonName": "reader", + "goType": "io.ReadSeekCloser", + "typescriptType": "ReadSeekCloser", + "usedTypescriptType": "ReadSeekCloser", + "usedStructName": "io.ReadSeekCloser", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "offset", + "jsonName": "offset", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "completed", + "jsonName": "completed", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [ + " ran until the EOF" + ] + }, + { + "name": "cleanupFunc", + "jsonName": "cleanupFunc", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "stopOnce", + "jsonName": "stopOnce", + "goType": "sync.Once", + "typescriptType": "Once", + "usedTypescriptType": "Once", + "usedStructName": "sync.Once", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/directstream/torrentstream.go", + "filename": "torrentstream.go", + "name": "TorrentStream", + "formattedName": "Directstream_TorrentStream", + "package": "directstream", + "fields": [ + { + "name": "torrent", + "jsonName": "torrent", + "goType": "torrent.Torrent", + "typescriptType": "Torrent_Torrent", + "usedTypescriptType": "Torrent_Torrent", + "usedStructName": "torrent.Torrent", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "file", + "jsonName": "file", + "goType": "torrent.File", + "typescriptType": "Torrent_File", + "usedTypescriptType": "Torrent_File", + "usedStructName": "torrent.File", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "streamReadyCh", + "jsonName": "streamReadyCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Closed by the initiator when the stream is ready" + ] + } + ], + "comments": [ + " TorrentStream is a stream that is a torrent." + ], + "embeddedStructNames": [ + "directstream.BaseStream" + ] + }, + { + "filepath": "../internal/directstream/torrentstream.go", + "filename": "torrentstream.go", + "name": "PlayTorrentStreamOptions", + "formattedName": "Directstream_PlayTorrentStreamOptions", + "package": "directstream", + "fields": [ + { + "name": "ClientId", + "jsonName": "ClientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "EpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnidbEpisode", + "jsonName": "AnidbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Torrent", + "jsonName": "Torrent", + "goType": "torrent.Torrent", + "typescriptType": "Torrent_Torrent", + "usedTypescriptType": "Torrent_Torrent", + "usedStructName": "torrent.Torrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "File", + "jsonName": "File", + "goType": "torrent.File", + "typescriptType": "Torrent_File", + "usedTypescriptType": "Torrent_File", + "usedStructName": "torrent.File", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/discordrpc/client/activity.go", + "filename": "activity.go", + "name": "Activity", + "formattedName": "DiscordRPC_Activity", + "package": "discordrpc_client", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Details", + "jsonName": "details", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DetailsURL", + "jsonName": "details_url", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to details" + ] + }, + { + "name": "State", + "jsonName": "state", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StateURL", + "jsonName": "state_url", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to state" + ] + }, + { + "name": "Timestamps", + "jsonName": "timestamps", + "goType": "Timestamps", + "typescriptType": "DiscordRPC_Timestamps", + "usedTypescriptType": "DiscordRPC_Timestamps", + "usedStructName": "discordrpc_client.Timestamps", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Assets", + "jsonName": "assets", + "goType": "Assets", + "typescriptType": "DiscordRPC_Assets", + "usedTypescriptType": "DiscordRPC_Assets", + "usedStructName": "discordrpc_client.Assets", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Party", + "jsonName": "party", + "goType": "Party", + "typescriptType": "DiscordRPC_Party", + "usedTypescriptType": "DiscordRPC_Party", + "usedStructName": "discordrpc_client.Party", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Secrets", + "jsonName": "secrets", + "goType": "Secrets", + "typescriptType": "DiscordRPC_Secrets", + "usedTypescriptType": "DiscordRPC_Secrets", + "usedStructName": "discordrpc_client.Secrets", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Buttons", + "jsonName": "buttons", + "goType": "[]Button", + "typescriptType": "Array\u003cDiscordRPC_Button\u003e", + "usedTypescriptType": "DiscordRPC_Button", + "usedStructName": "discordrpc_client.Button", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Instance", + "jsonName": "instance", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StatusDisplayType", + "jsonName": "status_display_type", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [ + " 1 = name, 2 = details, 3 = state" + ] + } + ], + "comments": [ + " Activity holds the data for discord rich presence", + "", + " See https://discord.com/developers/docs/game-sdk/activities#data-models-activity-struct" + ] + }, + { + "filepath": "../internal/discordrpc/client/activity.go", + "filename": "activity.go", + "name": "Timestamps", + "formattedName": "DiscordRPC_Timestamps", + "package": "discordrpc_client", + "fields": [ + { + "name": "Start", + "jsonName": "start", + "goType": "Epoch", + "typescriptType": "DiscordRPC_Epoch", + "usedTypescriptType": "DiscordRPC_Epoch", + "usedStructName": "discordrpc_client.Epoch", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "End", + "jsonName": "end", + "goType": "Epoch", + "typescriptType": "DiscordRPC_Epoch", + "usedTypescriptType": "DiscordRPC_Epoch", + "usedStructName": "discordrpc_client.Epoch", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Timestamps holds unix timestamps for start and/or end of the game", + "", + " See https://discord.com/developers/docs/game-sdk/activities#data-models-activitytimestamps-struct" + ] + }, + { + "filepath": "../internal/discordrpc/client/activity.go", + "filename": "activity.go", + "name": "Epoch", + "formattedName": "DiscordRPC_Epoch", + "package": "discordrpc_client", + "fields": [], + "comments": [ + " Epoch wrapper around time.Time to ensure times are sent as a unix epoch int" + ], + "embeddedStructNames": [ + "time.Time" + ] + }, + { + "filepath": "../internal/discordrpc/client/activity.go", + "filename": "activity.go", + "name": "Assets", + "formattedName": "DiscordRPC_Assets", + "package": "discordrpc_client", + "fields": [ + { + "name": "LargeImage", + "jsonName": "large_image", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LargeText", + "jsonName": "large_text", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LargeURL", + "jsonName": "large_url", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to large image, if any" + ] + }, + { + "name": "SmallImage", + "jsonName": "small_image", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SmallText", + "jsonName": "small_text", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SmallURL", + "jsonName": "small_url", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to small image, if any" + ] + } + ], + "comments": [ + " Assets passes image references for inclusion in rich presence", + "", + " See https://discord.com/developers/docs/game-sdk/activities#data-models-activityassets-struct" + ] + }, + { + "filepath": "../internal/discordrpc/client/activity.go", + "filename": "activity.go", + "name": "Party", + "formattedName": "DiscordRPC_Party", + "package": "discordrpc_client", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [ + " seems to be element [0] is count and [1] is max" + ] + } + ], + "comments": [ + " Party holds information for the current party of the player" + ] + }, + { + "filepath": "../internal/discordrpc/client/activity.go", + "filename": "activity.go", + "name": "Secrets", + "formattedName": "DiscordRPC_Secrets", + "package": "discordrpc_client", + "fields": [ + { + "name": "Join", + "jsonName": "join", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Spectate", + "jsonName": "spectate", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Match", + "jsonName": "match", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Secrets holds secrets for Rich Presence joining and spectating" + ] + }, + { + "filepath": "../internal/discordrpc/client/activity.go", + "filename": "activity.go", + "name": "Button", + "formattedName": "DiscordRPC_Button", + "package": "discordrpc_client", + "fields": [ + { + "name": "Label", + "jsonName": "label", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Url", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/discordrpc/client/client.go", + "filename": "client.go", + "name": "Client", + "formattedName": "DiscordRPC_Client", + "package": "discordrpc_client", + "fields": [ + { + "name": "ClientID", + "jsonName": "ClientID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Socket", + "jsonName": "Socket", + "goType": "discordrpc_ipc.Socket", + "typescriptType": "Socket", + "usedTypescriptType": "Socket", + "usedStructName": "discordrpc_ipc.Socket", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Client wrapper for the Discord RPC client" + ] + }, + { + "filepath": "../internal/discordrpc/client/command.go", + "filename": "command.go", + "name": "Payload", + "formattedName": "DiscordRPC_Payload", + "package": "discordrpc_client", + "fields": [ + { + "name": "Cmd", + "jsonName": "cmd", + "goType": "command", + "typescriptType": "DiscordRPC_command", + "usedTypescriptType": "DiscordRPC_command", + "usedStructName": "discordrpc_client.command", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Args", + "jsonName": "args", + "goType": "Args", + "typescriptType": "DiscordRPC_Args", + "usedTypescriptType": "DiscordRPC_Args", + "usedStructName": "discordrpc_client.Args", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Event", + "jsonName": "evt", + "goType": "event", + "typescriptType": "DiscordRPC_event", + "usedTypescriptType": "DiscordRPC_event", + "usedStructName": "discordrpc_client.event", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "data", + "goType": "Data", + "typescriptType": "DiscordRPC_Data", + "usedTypescriptType": "DiscordRPC_Data", + "usedStructName": "discordrpc_client.Data", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Nonce", + "jsonName": "nonce", + "goType": "uuid.UUID", + "typescriptType": "UUID", + "usedTypescriptType": "UUID", + "usedStructName": "uuid.UUID", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/discordrpc/client/events.go", + "filename": "events.go", + "name": "ActivityEventData", + "formattedName": "DiscordRPC_ActivityEventData", + "package": "discordrpc_client", + "fields": [ + { + "name": "Secret", + "jsonName": "secret", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "User", + "typescriptType": "DiscordRPC_User", + "usedTypescriptType": "DiscordRPC_User", + "usedStructName": "discordrpc_client.User", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/discordrpc/client/types.go", + "filename": "types.go", + "name": "Data", + "formattedName": "DiscordRPC_Data", + "package": "discordrpc_client", + "fields": [ + { + "name": "Code", + "jsonName": "code", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Message", + "jsonName": "message", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Data section of the RPC response" + ] + }, + { + "filepath": "../internal/discordrpc/client/types.go", + "filename": "types.go", + "name": "Args", + "formattedName": "DiscordRPC_Args", + "package": "discordrpc_client", + "fields": [ + { + "name": "Pid", + "jsonName": "pid", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Activity", + "jsonName": "activity", + "goType": "Activity", + "typescriptType": "DiscordRPC_Activity", + "usedTypescriptType": "DiscordRPC_Activity", + "usedStructName": "discordrpc_client.Activity", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Args seems to contain the most data, Pid here is mandatory" + ] + }, + { + "filepath": "../internal/discordrpc/client/types.go", + "filename": "types.go", + "name": "User", + "formattedName": "DiscordRPC_User", + "package": "discordrpc_client", + "fields": [ + { + "name": "Id", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Username", + "jsonName": "username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Discriminator", + "jsonName": "discriminator", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Avatar", + "jsonName": "avatar", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/discordrpc/ipc/ipc.go", + "filename": "ipc.go", + "name": "Socket", + "formattedName": "Socket", + "package": "discordrpc_ipc", + "fields": [], + "comments": [ + " Socket extends net.Conn methods" + ], + "embeddedStructNames": [ + "net.Conn" + ] + }, + { + "filepath": "../internal/discordrpc/presence/hook_events.go", + "filename": "hook_events.go", + "name": "DiscordPresenceAnimeActivityRequestedEvent", + "formattedName": "DiscordRPC_DiscordPresenceAnimeActivityRequestedEvent", + "package": "discordrpc_presence", + "fields": [ + { + "name": "AnimeActivity", + "jsonName": "animeActivity", + "goType": "AnimeActivity", + "typescriptType": "DiscordRPC_AnimeActivity", + "usedTypescriptType": "DiscordRPC_AnimeActivity", + "usedStructName": "discordrpc_presence.AnimeActivity", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Details", + "jsonName": "details", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DetailsURL", + "jsonName": "detailsUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "state", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartTimestamp", + "jsonName": "startTimestamp", + "goType": "int64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EndTimestamp", + "jsonName": "endTimestamp", + "goType": "int64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LargeImage", + "jsonName": "largeImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LargeText", + "jsonName": "largeText", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LargeURL", + "jsonName": "largeUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to large image, if any" + ] + }, + { + "name": "SmallImage", + "jsonName": "smallImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SmallText", + "jsonName": "smallText", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SmallURL", + "jsonName": "smallUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to small image, if any" + ] + }, + { + "name": "Buttons", + "jsonName": "buttons", + "goType": "[]discordrpc_client.Button", + "typescriptType": "Array\u003cDiscordRPC_Button\u003e", + "usedTypescriptType": "DiscordRPC_Button", + "usedStructName": "discordrpc_client.Button", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Instance", + "jsonName": "instance", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StatusDisplayType", + "jsonName": "statusDisplayType", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " DiscordPresenceAnimeActivityRequestedEvent is triggered when anime activity is requested, after the [animeActivity] is processed, and right before the activity is sent to queue.", + " There is no guarantee as to when or if the activity will be successfully sent to discord.", + " Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed.", + " Prevent default to stop the activity from being sent to discord." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/discordrpc/presence/hook_events.go", + "filename": "hook_events.go", + "name": "DiscordPresenceMangaActivityRequestedEvent", + "formattedName": "DiscordRPC_DiscordPresenceMangaActivityRequestedEvent", + "package": "discordrpc_presence", + "fields": [ + { + "name": "MangaActivity", + "jsonName": "mangaActivity", + "goType": "MangaActivity", + "typescriptType": "DiscordRPC_MangaActivity", + "usedTypescriptType": "DiscordRPC_MangaActivity", + "usedStructName": "discordrpc_presence.MangaActivity", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Details", + "jsonName": "details", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DetailsURL", + "jsonName": "detailsUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "state", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartTimestamp", + "jsonName": "startTimestamp", + "goType": "int64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EndTimestamp", + "jsonName": "endTimestamp", + "goType": "int64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LargeImage", + "jsonName": "largeImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LargeText", + "jsonName": "largeText", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LargeURL", + "jsonName": "largeUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to large image, if any" + ] + }, + { + "name": "SmallImage", + "jsonName": "smallImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SmallText", + "jsonName": "smallText", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SmallURL", + "jsonName": "smallUrl", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " URL to small image, if any" + ] + }, + { + "name": "Buttons", + "jsonName": "buttons", + "goType": "[]discordrpc_client.Button", + "typescriptType": "Array\u003cDiscordRPC_Button\u003e", + "usedTypescriptType": "DiscordRPC_Button", + "usedStructName": "discordrpc_client.Button", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Instance", + "jsonName": "instance", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StatusDisplayType", + "jsonName": "statusDisplayType", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " DiscordPresenceMangaActivityRequestedEvent is triggered when manga activity is requested, after the [mangaActivity] is processed, and right before the activity is sent to queue.", + " There is no guarantee as to when or if the activity will be successfully sent to discord.", + " Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed.", + " Prevent default to stop the activity from being sent to discord." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/discordrpc/presence/hook_events.go", + "filename": "hook_events.go", + "name": "DiscordPresenceClientClosedEvent", + "formattedName": "DiscordRPC_DiscordPresenceClientClosedEvent", + "package": "discordrpc_presence", + "fields": [], + "comments": [ + " DiscordPresenceClientClosedEvent is triggered when the discord rpc client is closed." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/discordrpc/presence/presence.go", + "filename": "presence.go", + "name": "Presence", + "formattedName": "DiscordRPC_Presence", + "package": "discordrpc_presence", + "fields": [ + { + "name": "client", + "jsonName": "client", + "goType": "discordrpc_client.Client", + "typescriptType": "DiscordRPC_Client", + "usedTypescriptType": "DiscordRPC_Client", + "usedStructName": "discordrpc_client.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "models.DiscordSettings", + "typescriptType": "Models_DiscordSettings", + "usedTypescriptType": "Models_DiscordSettings", + "usedStructName": "models.DiscordSettings", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "hasSent", + "jsonName": "hasSent", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "username", + "jsonName": "username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeActivity", + "jsonName": "animeActivity", + "goType": "AnimeActivity", + "typescriptType": "DiscordRPC_AnimeActivity", + "usedTypescriptType": "DiscordRPC_AnimeActivity", + "usedStructName": "discordrpc_presence.AnimeActivity", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "lastAnimeActivityUpdateSent", + "jsonName": "lastAnimeActivityUpdateSent", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "lastSent", + "jsonName": "lastSent", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "eventQueue", + "jsonName": "eventQueue", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "cancelFunc", + "jsonName": "cancelFunc", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [ + " Cancel function for the event loop context" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/discordrpc/presence/presence.go", + "filename": "presence.go", + "name": "AnimeActivity", + "formattedName": "DiscordRPC_AnimeActivity", + "package": "discordrpc_presence", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsMovie", + "jsonName": "isMovie", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Paused", + "jsonName": "paused", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalEpisodes", + "jsonName": "totalEpisodes", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CurrentEpisodeCount", + "jsonName": "currentEpisodeCount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeTitle", + "jsonName": "episodeTitle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/discordrpc/presence/presence.go", + "filename": "presence.go", + "name": "LegacyAnimeActivity", + "formattedName": "DiscordRPC_LegacyAnimeActivity", + "package": "discordrpc_presence", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsMovie", + "jsonName": "isMovie", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/discordrpc/presence/presence.go", + "filename": "presence.go", + "name": "MangaActivity", + "formattedName": "DiscordRPC_MangaActivity", + "package": "discordrpc_presence", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Chapter", + "jsonName": "chapter", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/events/events.go", + "filename": "events.go", + "name": "WebsocketClientEventType", + "formattedName": "Events_WebsocketClientEventType", + "package": "events", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"native-player\"", + "\"nakama\"", + "\"plugin\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/events/events.go", + "filename": "events.go", + "name": "WebsocketClientEvent", + "formattedName": "Events_WebsocketClientEvent", + "package": "events", + "fields": [ + { + "name": "ClientID", + "jsonName": "clientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "WebsocketClientEventType", + "typescriptType": "Events_WebsocketClientEventType", + "usedTypescriptType": "Events_WebsocketClientEventType", + "usedStructName": "events.WebsocketClientEventType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/events/websocket.go", + "filename": "websocket.go", + "name": "GlobalWSEventManagerWrapper", + "formattedName": "Events_GlobalWSEventManagerWrapper", + "package": "events", + "fields": [ + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/events/websocket.go", + "filename": "websocket.go", + "name": "WSEventManager", + "formattedName": "Events_WSEventManager", + "package": "events", + "fields": [ + { + "name": "Conns", + "jsonName": "Conns", + "goType": "[]WSConn", + "typescriptType": "Array\u003cEvents_WSConn\u003e", + "usedTypescriptType": "Events_WSConn", + "usedStructName": "events.WSConn", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "hasHadConnection", + "jsonName": "hasHadConnection", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "eventMu", + "jsonName": "eventMu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "clientEventSubscribers", + "jsonName": "clientEventSubscribers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "clientNativePlayerEventSubscribers", + "jsonName": "clientNativePlayerEventSubscribers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "nakamaEventSubscribers", + "jsonName": "nakamaEventSubscribers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/events/websocket.go", + "filename": "websocket.go", + "name": "ClientEventSubscriber", + "formattedName": "Events_ClientEventSubscriber", + "package": "events", + "fields": [ + { + "name": "Channel", + "jsonName": "Channel", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "closed", + "jsonName": "closed", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/events/websocket.go", + "filename": "websocket.go", + "name": "WSConn", + "formattedName": "Events_WSConn", + "package": "events", + "fields": [ + { + "name": "ID", + "jsonName": "ID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Conn", + "jsonName": "Conn", + "goType": "websocket.Conn", + "typescriptType": "Conn", + "usedTypescriptType": "Conn", + "usedStructName": "websocket.Conn", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/events/websocket.go", + "filename": "websocket.go", + "name": "WSEvent", + "formattedName": "Events_WSEvent", + "package": "events", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/events/websocket_mock.go", + "filename": "websocket_mock.go", + "name": "MockWSEventManager", + "formattedName": "Events_MockWSEventManager", + "package": "events", + "fields": [ + { + "name": "Conn", + "jsonName": "Conn", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ClientEventSubscribers", + "jsonName": "ClientEventSubscribers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/events/websocket_mock.go", + "filename": "websocket_mock.go", + "name": "MockWSEvent", + "formattedName": "Events_MockWSEvent", + "package": "events", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/bank.go", + "filename": "bank.go", + "name": "UnifiedBank", + "formattedName": "Extension_UnifiedBank", + "package": "extension", + "fields": [ + { + "name": "extensions", + "jsonName": "extensions", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "extensionAddedCh", + "jsonName": "extensionAddedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "extensionRemovedCh", + "jsonName": "extensionRemovedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "Type", + "formattedName": "Extension_Type", + "package": "extension", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"anime-torrent-provider\"", + "\"manga-provider\"", + "\"onlinestream-provider\"", + "\"plugin\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "Language", + "formattedName": "Extension_Language", + "package": "extension", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"javascript\"", + "\"typescript\"", + "\"go\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "PluginPermissionScope", + "formattedName": "Extension_PluginPermissionScope", + "package": "extension", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "Extension", + "formattedName": "Extension_Extension", + "package": "extension", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g. \"extension-example\"" + ] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g. \"Extension\"" + ] + }, + { + "name": "Version", + "jsonName": "version", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g. \"1.0.0\"" + ] + }, + { + "name": "SemverConstraint", + "jsonName": "semverConstraint", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ManifestURI", + "jsonName": "manifestURI", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g. \"http://cdn.something.app/extensions/extension-example/manifest.json\"" + ] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "Language", + "typescriptType": "Extension_Language", + "usedTypescriptType": "Extension_Language", + "usedStructName": "extension.Language", + "required": true, + "public": true, + "comments": [ + " e.g. \"go\"" + ] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "Type", + "typescriptType": "Extension_Type", + "usedTypescriptType": "Extension_Type", + "usedStructName": "extension.Type", + "required": true, + "public": true, + "comments": [ + " e.g. \"anime-torrent-provider\"" + ] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g. \"This extension provides torrents\"" + ] + }, + { + "name": "Author", + "jsonName": "author", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g. \"Seanime\"" + ] + }, + { + "name": "Icon", + "jsonName": "icon", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Website", + "jsonName": "website", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Lang", + "jsonName": "lang", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Permissions", + "jsonName": "permissions", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " NOT IMPLEMENTED" + ] + }, + { + "name": "UserConfig", + "jsonName": "userConfig", + "goType": "UserConfig", + "typescriptType": "Extension_UserConfig", + "usedTypescriptType": "Extension_UserConfig", + "usedStructName": "extension.UserConfig", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PayloadURI", + "jsonName": "payloadURI", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Plugin", + "jsonName": "plugin", + "goType": "PluginManifest", + "typescriptType": "Extension_PluginManifest", + "usedTypescriptType": "Extension_PluginManifest", + "usedStructName": "extension.PluginManifest", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsDevelopment", + "jsonName": "isDevelopment", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SavedUserConfig", + "jsonName": "", + "goType": "SavedUserConfig", + "typescriptType": "Extension_SavedUserConfig", + "usedTypescriptType": "Extension_SavedUserConfig", + "usedStructName": "extension.SavedUserConfig", + "required": false, + "public": true, + "comments": [ + " Contains the saved user config for the extension" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "InvalidExtensionErrorCode", + "formattedName": "Extension_InvalidExtensionErrorCode", + "package": "extension", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"invalid_manifest\"", + "\"invalid_payload\"", + "\"user_config_error\"", + "\"invalid_authorization\"", + "\"plugin_permissions_not_granted\"", + "\"invalid_semver_constraint\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "InvalidExtension", + "formattedName": "Extension_InvalidExtension", + "package": "extension", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Extension", + "jsonName": "extension", + "goType": "Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Reason", + "jsonName": "reason", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Code", + "jsonName": "code", + "goType": "InvalidExtensionErrorCode", + "typescriptType": "Extension_InvalidExtensionErrorCode", + "usedTypescriptType": "Extension_InvalidExtensionErrorCode", + "usedStructName": "extension.InvalidExtensionErrorCode", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PluginPermissionDescription", + "jsonName": "pluginPermissionDescription", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "UserConfig", + "formattedName": "Extension_UserConfig", + "package": "extension", + "fields": [ + { + "name": "Version", + "jsonName": "version", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RequiresConfig", + "jsonName": "requiresConfig", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Fields", + "jsonName": "fields", + "goType": "[]ConfigField", + "typescriptType": "Array\u003cExtension_ConfigField\u003e", + "usedTypescriptType": "Extension_ConfigField", + "usedStructName": "extension.ConfigField", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "Preferences", + "formattedName": "Extension_Preferences", + "package": "extension", + "fields": [ + { + "name": "Fields", + "jsonName": "fields", + "goType": "[]ConfigField", + "typescriptType": "Array\u003cExtension_ConfigField\u003e", + "usedTypescriptType": "Extension_ConfigField", + "usedStructName": "extension.ConfigField", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "SavedUserConfig", + "formattedName": "Extension_SavedUserConfig", + "package": "extension", + "fields": [ + { + "name": "Version", + "jsonName": "version", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Values", + "jsonName": "values", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "ConfigField", + "formattedName": "Extension_ConfigField", + "package": "extension", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "ConfigFieldType", + "typescriptType": "Extension_ConfigFieldType", + "usedTypescriptType": "Extension_ConfigFieldType", + "usedStructName": "extension.ConfigFieldType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Label", + "jsonName": "label", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Options", + "jsonName": "options", + "goType": "[]ConfigFieldSelectOption", + "typescriptType": "Array\u003cExtension_ConfigFieldSelectOption\u003e", + "usedTypescriptType": "Extension_ConfigFieldSelectOption", + "usedStructName": "extension.ConfigFieldSelectOption", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Default", + "jsonName": "default", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "ConfigFieldType", + "formattedName": "Extension_ConfigFieldType", + "package": "extension", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"text\"", + "\"switch\"", + "\"select\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/extension/extension.go", + "filename": "extension.go", + "name": "ConfigFieldSelectOption", + "formattedName": "Extension_ConfigFieldSelectOption", + "package": "extension", + "fields": [ + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Label", + "jsonName": "label", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/manga/types.go", + "filename": "types.go", + "name": "Settings", + "formattedName": "HibikeManga_Settings", + "package": "hibikemanga", + "fields": [ + { + "name": "SupportsMultiScanlator", + "jsonName": "supportsMultiScanlator", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SupportsMultiLanguage", + "jsonName": "supportsMultiLanguage", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/manga/types.go", + "filename": "types.go", + "name": "SearchOptions", + "formattedName": "HibikeManga_SearchOptions", + "package": "hibikemanga", + "fields": [ + { + "name": "Query", + "jsonName": "query", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/manga/types.go", + "filename": "types.go", + "name": "SearchResult", + "formattedName": "HibikeManga_SearchResult", + "package": "hibikemanga", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Synonyms", + "jsonName": "synonyms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SearchRating", + "jsonName": "searchRating", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/manga/types.go", + "filename": "types.go", + "name": "ChapterDetails", + "formattedName": "HibikeManga_ChapterDetails", + "package": "hibikemanga", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Chapter", + "jsonName": "chapter", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Index", + "jsonName": "index", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Scanlator", + "jsonName": "scanlator", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rating", + "jsonName": "rating", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalIsPDF", + "jsonName": "localIsPDF", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/manga/types.go", + "filename": "types.go", + "name": "ChapterPage", + "formattedName": "HibikeManga_ChapterPage", + "package": "hibikemanga", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Index", + "jsonName": "index", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Headers", + "jsonName": "headers", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Buf", + "jsonName": "", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/onlinestream/types.go", + "filename": "types.go", + "name": "SearchOptions", + "formattedName": "HibikeOnlinestream_SearchOptions", + "package": "hibikeonlinestream", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "HibikeOnlinestream_Media", + "usedTypescriptType": "HibikeOnlinestream_Media", + "usedStructName": "hibikeonlinestream.Media", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Query", + "jsonName": "query", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Dub", + "jsonName": "dub", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/onlinestream/types.go", + "filename": "types.go", + "name": "Media", + "formattedName": "HibikeOnlinestream_Media", + "package": "hibikeonlinestream", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IDMal", + "jsonName": "idMal", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EnglishTitle", + "jsonName": "englishTitle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RomajiTitle", + "jsonName": "romajiTitle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeCount", + "jsonName": "episodeCount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Synonyms", + "jsonName": "synonyms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsAdult", + "jsonName": "isAdult", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "startDate", + "goType": "FuzzyDate", + "typescriptType": "HibikeOnlinestream_FuzzyDate", + "usedTypescriptType": "HibikeOnlinestream_FuzzyDate", + "usedStructName": "hibikeonlinestream.FuzzyDate", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/onlinestream/types.go", + "filename": "types.go", + "name": "FuzzyDate", + "formattedName": "HibikeOnlinestream_FuzzyDate", + "package": "hibikeonlinestream", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/onlinestream/types.go", + "filename": "types.go", + "name": "Settings", + "formattedName": "HibikeOnlinestream_Settings", + "package": "hibikeonlinestream", + "fields": [ + { + "name": "EpisodeServers", + "jsonName": "episodeServers", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SupportsDub", + "jsonName": "supportsDub", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/onlinestream/types.go", + "filename": "types.go", + "name": "SearchResult", + "formattedName": "HibikeOnlinestream_SearchResult", + "package": "hibikeonlinestream", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SubOrDub", + "jsonName": "subOrDub", + "goType": "SubOrDub", + "typescriptType": "HibikeOnlinestream_SubOrDub", + "usedTypescriptType": "HibikeOnlinestream_SubOrDub", + "usedStructName": "hibikeonlinestream.SubOrDub", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/onlinestream/types.go", + "filename": "types.go", + "name": "EpisodeDetails", + "formattedName": "HibikeOnlinestream_EpisodeDetails", + "package": "hibikeonlinestream", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Number", + "jsonName": "number", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/onlinestream/types.go", + "filename": "types.go", + "name": "EpisodeServer", + "formattedName": "HibikeOnlinestream_EpisodeServer", + "package": "hibikeonlinestream", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Server", + "jsonName": "server", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Headers", + "jsonName": "headers", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VideoSources", + "jsonName": "videoSources", + "goType": "[]VideoSource", + "typescriptType": "Array\u003cHibikeOnlinestream_VideoSource\u003e", + "usedTypescriptType": "HibikeOnlinestream_VideoSource", + "usedStructName": "hibikeonlinestream.VideoSource", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/onlinestream/types.go", + "filename": "types.go", + "name": "SubOrDub", + "formattedName": "HibikeOnlinestream_SubOrDub", + "package": "hibikeonlinestream", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"sub\"", + "\"dub\"", + "\"both\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/onlinestream/types.go", + "filename": "types.go", + "name": "VideoSourceType", + "formattedName": "HibikeOnlinestream_VideoSourceType", + "package": "hibikeonlinestream", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"mp4\"", + "\"m3u8\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/onlinestream/types.go", + "filename": "types.go", + "name": "VideoSource", + "formattedName": "HibikeOnlinestream_VideoSource", + "package": "hibikeonlinestream", + "fields": [ + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "VideoSourceType", + "typescriptType": "HibikeOnlinestream_VideoSourceType", + "usedTypescriptType": "HibikeOnlinestream_VideoSourceType", + "usedStructName": "hibikeonlinestream.VideoSourceType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Quality", + "jsonName": "quality", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Subtitles", + "jsonName": "subtitles", + "goType": "[]VideoSubtitle", + "typescriptType": "Array\u003cHibikeOnlinestream_VideoSubtitle\u003e", + "usedTypescriptType": "HibikeOnlinestream_VideoSubtitle", + "usedStructName": "hibikeonlinestream.VideoSubtitle", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/onlinestream/types.go", + "filename": "types.go", + "name": "VideoSubtitle", + "formattedName": "HibikeOnlinestream_VideoSubtitle", + "package": "hibikeonlinestream", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsDefault", + "jsonName": "isDefault", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/torrent/types.go", + "filename": "types.go", + "name": "AnimeProviderType", + "formattedName": "HibikeTorrent_AnimeProviderType", + "package": "hibiketorrent", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"main\"", + "\"special\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/torrent/types.go", + "filename": "types.go", + "name": "AnimeProviderSmartSearchFilter", + "formattedName": "HibikeTorrent_AnimeProviderSmartSearchFilter", + "package": "hibiketorrent", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"batch\"", + "\"episodeNumber\"", + "\"resolution\"", + "\"query\"", + "\"bestReleases\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/torrent/types.go", + "filename": "types.go", + "name": "AnimeProviderSettings", + "formattedName": "HibikeTorrent_AnimeProviderSettings", + "package": "hibiketorrent", + "fields": [ + { + "name": "CanSmartSearch", + "jsonName": "canSmartSearch", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SmartSearchFilters", + "jsonName": "smartSearchFilters", + "goType": "[]AnimeProviderSmartSearchFilter", + "typescriptType": "Array\u003cHibikeTorrent_AnimeProviderSmartSearchFilter\u003e", + "usedTypescriptType": "HibikeTorrent_AnimeProviderSmartSearchFilter", + "usedStructName": "hibiketorrent.AnimeProviderSmartSearchFilter", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SupportsAdult", + "jsonName": "supportsAdult", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "AnimeProviderType", + "typescriptType": "HibikeTorrent_AnimeProviderType", + "usedTypescriptType": "HibikeTorrent_AnimeProviderType", + "usedStructName": "hibiketorrent.AnimeProviderType", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/torrent/types.go", + "filename": "types.go", + "name": "Media", + "formattedName": "HibikeTorrent_Media", + "package": "hibiketorrent", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IDMal", + "jsonName": "idMal", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Format", + "jsonName": "format", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EnglishTitle", + "jsonName": "englishTitle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RomajiTitle", + "jsonName": "romajiTitle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeCount", + "jsonName": "episodeCount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AbsoluteSeasonOffset", + "jsonName": "absoluteSeasonOffset", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Synonyms", + "jsonName": "synonyms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsAdult", + "jsonName": "isAdult", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartDate", + "jsonName": "startDate", + "goType": "FuzzyDate", + "typescriptType": "HibikeTorrent_FuzzyDate", + "usedTypescriptType": "HibikeTorrent_FuzzyDate", + "usedStructName": "hibiketorrent.FuzzyDate", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/torrent/types.go", + "filename": "types.go", + "name": "FuzzyDate", + "formattedName": "HibikeTorrent_FuzzyDate", + "package": "hibiketorrent", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/torrent/types.go", + "filename": "types.go", + "name": "AnimeSearchOptions", + "formattedName": "HibikeTorrent_AnimeSearchOptions", + "package": "hibiketorrent", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "HibikeTorrent_Media", + "usedTypescriptType": "HibikeTorrent_Media", + "usedStructName": "hibiketorrent.Media", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Query", + "jsonName": "query", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/torrent/types.go", + "filename": "types.go", + "name": "AnimeSmartSearchOptions", + "formattedName": "HibikeTorrent_AnimeSmartSearchOptions", + "package": "hibiketorrent", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "Media", + "typescriptType": "HibikeTorrent_Media", + "usedTypescriptType": "HibikeTorrent_Media", + "usedStructName": "hibiketorrent.Media", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Query", + "jsonName": "query", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Batch", + "jsonName": "batch", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Resolution", + "jsonName": "resolution", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnidbAID", + "jsonName": "anidbAID", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnidbEID", + "jsonName": "anidbEID", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BestReleases", + "jsonName": "bestReleases", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/torrent/types.go", + "filename": "types.go", + "name": "AnimeTorrent", + "formattedName": "HibikeTorrent_AnimeTorrent", + "package": "hibiketorrent", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Date", + "jsonName": "date", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FormattedSize", + "jsonName": "formattedSize", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Seeders", + "jsonName": "seeders", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Leechers", + "jsonName": "leechers", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadCount", + "jsonName": "downloadCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Link", + "jsonName": "link", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadUrl", + "jsonName": "downloadUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MagnetLink", + "jsonName": "magnetLink", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "InfoHash", + "jsonName": "infoHash", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Resolution", + "jsonName": "resolution", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsBatch", + "jsonName": "isBatch", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReleaseGroup", + "jsonName": "releaseGroup", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsBestRelease", + "jsonName": "isBestRelease", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Confirmed", + "jsonName": "confirmed", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/hibike/vendor_extension.go", + "filename": "vendor_extension.go", + "name": "SelectOption", + "formattedName": "SelectOption", + "package": "hibikextension", + "fields": [ + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Label", + "jsonName": "label", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/manga_provider.go", + "filename": "manga_provider.go", + "name": "MangaProviderExtensionImpl", + "formattedName": "Extension_MangaProviderExtensionImpl", + "package": "extension", + "fields": [ + { + "name": "ext", + "jsonName": "ext", + "goType": "Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "provider", + "jsonName": "provider", + "goType": "hibikemanga.Provider", + "typescriptType": "HibikeManga_Provider", + "usedTypescriptType": "HibikeManga_Provider", + "usedStructName": "hibikemanga.Provider", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/onlinestream_provider.go", + "filename": "onlinestream_provider.go", + "name": "OnlinestreamProviderExtensionImpl", + "formattedName": "Extension_OnlinestreamProviderExtensionImpl", + "package": "extension", + "fields": [ + { + "name": "ext", + "jsonName": "ext", + "goType": "Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "provider", + "jsonName": "provider", + "goType": "hibikeonlinestream.Provider", + "typescriptType": "HibikeOnlinestream_Provider", + "usedTypescriptType": "HibikeOnlinestream_Provider", + "usedStructName": "hibikeonlinestream.Provider", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/plugin.go", + "filename": "plugin.go", + "name": "PluginManifest", + "formattedName": "Extension_PluginManifest", + "package": "extension", + "fields": [ + { + "name": "Version", + "jsonName": "version", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Permissions", + "jsonName": "permissions", + "goType": "PluginPermissions", + "typescriptType": "Extension_PluginPermissions", + "usedTypescriptType": "Extension_PluginPermissions", + "usedStructName": "extension.PluginPermissions", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/plugin.go", + "filename": "plugin.go", + "name": "PluginPermissions", + "formattedName": "Extension_PluginPermissions", + "package": "extension", + "fields": [ + { + "name": "Scopes", + "jsonName": "scopes", + "goType": "[]PluginPermissionScope", + "typescriptType": "Array\u003cExtension_PluginPermissionScope\u003e", + "usedTypescriptType": "Extension_PluginPermissionScope", + "usedStructName": "extension.PluginPermissionScope", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Allow", + "jsonName": "allow", + "goType": "PluginAllowlist", + "typescriptType": "Extension_PluginAllowlist", + "usedTypescriptType": "Extension_PluginAllowlist", + "usedStructName": "extension.PluginAllowlist", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/plugin.go", + "filename": "plugin.go", + "name": "PluginAllowlist", + "formattedName": "Extension_PluginAllowlist", + "package": "extension", + "fields": [ + { + "name": "ReadPaths", + "jsonName": "readPaths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WritePaths", + "jsonName": "writePaths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CommandScopes", + "jsonName": "commandScopes", + "goType": "[]CommandScope", + "typescriptType": "Array\u003cExtension_CommandScope\u003e", + "usedTypescriptType": "Extension_CommandScope", + "usedStructName": "extension.CommandScope", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " PluginAllowlist is a list of system permissions that the plugin is asking for.", + "", + " The user must acknowledge these permissions before the plugin can be loaded." + ] + }, + { + "filepath": "../internal/extension/plugin.go", + "filename": "plugin.go", + "name": "CommandScope", + "formattedName": "Extension_CommandScope", + "package": "extension", + "fields": [ + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Command", + "jsonName": "command", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Args", + "jsonName": "args", + "goType": "[]CommandArg", + "typescriptType": "Array\u003cExtension_CommandArg\u003e", + "usedTypescriptType": "Extension_CommandArg", + "usedStructName": "extension.CommandArg", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " CommandScope defines a specific command or set of commands that can be executed", + " with specific arguments and validation rules." + ] + }, + { + "filepath": "../internal/extension/plugin.go", + "filename": "plugin.go", + "name": "CommandArg", + "formattedName": "Extension_CommandArg", + "package": "extension", + "fields": [ + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Validator", + "jsonName": "validator", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " CommandArg represents an argument for a command" + ] + }, + { + "filepath": "../internal/extension/plugin.go", + "filename": "plugin.go", + "name": "PluginExtensionImpl", + "formattedName": "Extension_PluginExtensionImpl", + "package": "extension", + "fields": [ + { + "name": "ext", + "jsonName": "ext", + "goType": "Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension/torrent_provider.go", + "filename": "torrent_provider.go", + "name": "AnimeTorrentProviderExtensionImpl", + "formattedName": "Extension_AnimeTorrentProviderExtensionImpl", + "package": "extension", + "fields": [ + { + "name": "ext", + "jsonName": "ext", + "goType": "Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "provider", + "jsonName": "provider", + "goType": "hibiketorrent.AnimeProvider", + "typescriptType": "HibikeTorrent_AnimeProvider", + "usedTypescriptType": "HibikeTorrent_AnimeProvider", + "usedStructName": "hibiketorrent.AnimeProvider", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_playground/playground.go", + "filename": "playground.go", + "name": "PlaygroundRepository", + "formattedName": "PlaygroundRepository", + "package": "extension_playground", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "platform", + "jsonName": "platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "baseAnimeCache", + "jsonName": "baseAnimeCache", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "baseMangaCache", + "jsonName": "baseMangaCache", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "gojaRuntimeManager", + "jsonName": "gojaRuntimeManager", + "goType": "goja_runtime.Manager", + "typescriptType": "Manager", + "usedTypescriptType": "Manager", + "usedStructName": "goja_runtime.Manager", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_playground/playground.go", + "filename": "playground.go", + "name": "RunPlaygroundCodeResponse", + "formattedName": "RunPlaygroundCodeResponse", + "package": "extension_playground", + "fields": [ + { + "name": "Logs", + "jsonName": "logs", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_playground/playground.go", + "filename": "playground.go", + "name": "RunPlaygroundCodeParams", + "formattedName": "RunPlaygroundCodeParams", + "package": "extension_playground", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "extension.Type", + "typescriptType": "Extension_Type", + "usedTypescriptType": "Extension_Type", + "usedStructName": "extension.Type", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "extension.Language", + "typescriptType": "Extension_Language", + "usedTypescriptType": "Extension_Language", + "usedStructName": "extension.Language", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Code", + "jsonName": "code", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Inputs", + "jsonName": "inputs", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Function", + "jsonName": "function", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_playground/playground.go", + "filename": "playground.go", + "name": "PlaygroundDebugLogger", + "formattedName": "PlaygroundDebugLogger", + "package": "extension_playground", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "buff", + "jsonName": "buff", + "goType": "bytes.Buffer", + "typescriptType": "Buffer", + "usedTypescriptType": "Buffer", + "usedStructName": "bytes.Buffer", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/external.go", + "filename": "external.go", + "name": "ExtensionInstallResponse", + "formattedName": "ExtensionRepo_ExtensionInstallResponse", + "package": "extension_repo", + "fields": [ + { + "name": "Message", + "jsonName": "message", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/external_plugin.go", + "filename": "external_plugin.go", + "name": "StoredPluginSettingsData", + "formattedName": "ExtensionRepo_StoredPluginSettingsData", + "package": "extension_repo", + "fields": [ + { + "name": "PinnedTrayPluginIds", + "jsonName": "pinnedTrayPluginIds", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PluginGrantedPermissions", + "jsonName": "pluginGrantedPermissions", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [ + " Extension ID -\u003e Permission Hash" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/goja_anime_torrent_provider.go", + "filename": "goja_anime_torrent_provider.go", + "name": "GojaAnimeTorrentProvider", + "formattedName": "ExtensionRepo_GojaAnimeTorrentProvider", + "package": "extension_repo", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "extension_repo.gojaProviderBase" + ] + }, + { + "filepath": "../internal/extension_repo/goja_manga_provider.go", + "filename": "goja_manga_provider.go", + "name": "GojaMangaProvider", + "formattedName": "ExtensionRepo_GojaMangaProvider", + "package": "extension_repo", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "extension_repo.gojaProviderBase" + ] + }, + { + "filepath": "../internal/extension_repo/goja_onlinestream_provider.go", + "filename": "goja_onlinestream_provider.go", + "name": "GojaOnlinestreamProvider", + "formattedName": "ExtensionRepo_GojaOnlinestreamProvider", + "package": "extension_repo", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "extension_repo.gojaProviderBase" + ] + }, + { + "filepath": "../internal/extension_repo/goja_plugin.go", + "filename": "goja_plugin.go", + "name": "GojaPlugin", + "formattedName": "ExtensionRepo_GojaPlugin", + "package": "extension_repo", + "fields": [ + { + "name": "ext", + "jsonName": "ext", + "goType": "extension.Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "pool", + "jsonName": "pool", + "goType": "goja_runtime.Pool", + "typescriptType": "Pool", + "usedTypescriptType": "Pool", + "usedStructName": "goja_runtime.Pool", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "runtimeManager", + "jsonName": "runtimeManager", + "goType": "goja_runtime.Manager", + "typescriptType": "Manager", + "usedTypescriptType": "Manager", + "usedStructName": "goja_runtime.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "store", + "jsonName": "store", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "storage", + "jsonName": "storage", + "goType": "plugin.Storage", + "typescriptType": "Storage", + "usedTypescriptType": "Storage", + "usedStructName": "plugin.Storage", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ui", + "jsonName": "ui", + "goType": "plugin_ui.UI", + "typescriptType": "UI", + "usedTypescriptType": "UI", + "usedStructName": "plugin_ui.UI", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "scheduler", + "jsonName": "scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "loader", + "jsonName": "loader", + "goType": "goja.Runtime", + "typescriptType": "Runtime", + "usedTypescriptType": "Runtime", + "usedStructName": "goja.Runtime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "unbindHookFuncs", + "jsonName": "unbindHookFuncs", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "interrupted", + "jsonName": "interrupted", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/goja_plugin_test.go", + "filename": "goja_plugin_test.go", + "name": "TestPluginOptions", + "formattedName": "ExtensionRepo_TestPluginOptions", + "package": "extension_repo", + "fields": [ + { + "name": "ID", + "jsonName": "ID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "Payload", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "Language", + "goType": "extension.Language", + "typescriptType": "Extension_Language", + "usedTypescriptType": "Extension_Language", + "usedStructName": "extension.Language", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Permissions", + "jsonName": "Permissions", + "goType": "extension.PluginPermissions", + "typescriptType": "Extension_PluginPermissions", + "usedTypescriptType": "Extension_PluginPermissions", + "usedStructName": "extension.PluginPermissions", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PoolSize", + "jsonName": "PoolSize", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SetupHooks", + "jsonName": "SetupHooks", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " TestPluginOptions contains options for initializing a test plugin" + ] + }, + { + "filepath": "../internal/extension_repo/mapper.go", + "filename": "mapper.go", + "name": "FieldMapper", + "formattedName": "ExtensionRepo_FieldMapper", + "package": "extension_repo", + "fields": [], + "comments": [ + " FieldMapper provides custom mapping between Go and JavaScript property names.", + "", + " It is similar to the builtin \"uncapFieldNameMapper\" but also converts", + " all uppercase identifiers to their lowercase equivalent (eg. \"GET\" -\u003e \"get\").", + " It also checks for JSON tags and uses them if they exist." + ] + }, + { + "filepath": "../internal/extension_repo/repository.go", + "filename": "repository.go", + "name": "Repository", + "formattedName": "ExtensionRepo_Repository", + "package": "extension_repo", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "fileCacher", + "jsonName": "fileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "extensionDir", + "jsonName": "extensionDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "gojaExtensions", + "jsonName": "gojaExtensions", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "gojaRuntimeManager", + "jsonName": "gojaRuntimeManager", + "goType": "goja_runtime.Manager", + "typescriptType": "Manager", + "usedTypescriptType": "Manager", + "usedStructName": "goja_runtime.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "extensionBank", + "jsonName": "extensionBank", + "goType": "extension.UnifiedBank", + "typescriptType": "Extension_UnifiedBank", + "usedTypescriptType": "Extension_UnifiedBank", + "usedStructName": "extension.UnifiedBank", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "invalidExtensions", + "jsonName": "invalidExtensions", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "hookManager", + "jsonName": "hookManager", + "goType": "hook.Manager", + "typescriptType": "Manager", + "usedTypescriptType": "Manager", + "usedStructName": "hook.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "client", + "jsonName": "client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "builtinExtensions", + "jsonName": "builtinExtensions", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "updateData", + "jsonName": "updateData", + "goType": "[]UpdateData", + "typescriptType": "Array\u003cExtensionRepo_UpdateData\u003e", + "usedTypescriptType": "ExtensionRepo_UpdateData", + "usedStructName": "extension_repo.UpdateData", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "updateDataMu", + "jsonName": "updateDataMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "firstExternalExtensionLoadedFunc", + "jsonName": "firstExternalExtensionLoadedFunc", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/repository.go", + "filename": "repository.go", + "name": "AllExtensions", + "formattedName": "ExtensionRepo_AllExtensions", + "package": "extension_repo", + "fields": [ + { + "name": "Extensions", + "jsonName": "extensions", + "goType": "[]extension.Extension", + "typescriptType": "Array\u003cExtension_Extension\u003e", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "InvalidExtensions", + "jsonName": "invalidExtensions", + "goType": "[]extension.InvalidExtension", + "typescriptType": "Array\u003cExtension_InvalidExtension\u003e", + "usedTypescriptType": "Extension_InvalidExtension", + "usedStructName": "extension.InvalidExtension", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "InvalidUserConfigExtensions", + "jsonName": "invalidUserConfigExtensions", + "goType": "[]extension.InvalidExtension", + "typescriptType": "Array\u003cExtension_InvalidExtension\u003e", + "usedTypescriptType": "Extension_InvalidExtension", + "usedStructName": "extension.InvalidExtension", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "HasUpdate", + "jsonName": "hasUpdate", + "goType": "[]UpdateData", + "typescriptType": "Array\u003cExtensionRepo_UpdateData\u003e", + "usedTypescriptType": "ExtensionRepo_UpdateData", + "usedStructName": "extension_repo.UpdateData", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/repository.go", + "filename": "repository.go", + "name": "UpdateData", + "formattedName": "ExtensionRepo_UpdateData", + "package": "extension_repo", + "fields": [ + { + "name": "ExtensionID", + "jsonName": "extensionID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ManifestURI", + "jsonName": "manifestURI", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Version", + "jsonName": "version", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/repository.go", + "filename": "repository.go", + "name": "MangaProviderExtensionItem", + "formattedName": "ExtensionRepo_MangaProviderExtensionItem", + "package": "extension_repo", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Lang", + "jsonName": "lang", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " ISO 639-1 language code" + ] + }, + { + "name": "Settings", + "jsonName": "settings", + "goType": "hibikemanga.Settings", + "typescriptType": "HibikeManga_Settings", + "usedTypescriptType": "HibikeManga_Settings", + "usedStructName": "hibikemanga.Settings", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/repository.go", + "filename": "repository.go", + "name": "OnlinestreamProviderExtensionItem", + "formattedName": "ExtensionRepo_OnlinestreamProviderExtensionItem", + "package": "extension_repo", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Lang", + "jsonName": "lang", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " ISO 639-1 language code" + ] + }, + { + "name": "EpisodeServers", + "jsonName": "episodeServers", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SupportsDub", + "jsonName": "supportsDub", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/repository.go", + "filename": "repository.go", + "name": "AnimeTorrentProviderExtensionItem", + "formattedName": "ExtensionRepo_AnimeTorrentProviderExtensionItem", + "package": "extension_repo", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Lang", + "jsonName": "lang", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " ISO 639-1 language code" + ] + }, + { + "name": "Settings", + "jsonName": "settings", + "goType": "hibiketorrent.AnimeProviderSettings", + "typescriptType": "HibikeTorrent_AnimeProviderSettings", + "usedTypescriptType": "HibikeTorrent_AnimeProviderSettings", + "usedStructName": "hibiketorrent.AnimeProviderSettings", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/repository.go", + "filename": "repository.go", + "name": "NewRepositoryOptions", + "formattedName": "ExtensionRepo_NewRepositoryOptions", + "package": "extension_repo", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ExtensionDir", + "jsonName": "ExtensionDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FileCacher", + "jsonName": "FileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "HookManager", + "jsonName": "HookManager", + "goType": "hook.Manager", + "typescriptType": "Manager", + "usedTypescriptType": "Manager", + "usedStructName": "hook.Manager", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/testdir/_gogoanime_external.go", + "filename": "_gogoanime_external.go", + "name": "Gogoanime", + "formattedName": "Gogoanime", + "package": "main", + "fields": [ + { + "name": "BaseURL", + "jsonName": "BaseURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AjaxURL", + "jsonName": "AjaxURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/testdir/_gogoanime_external.go", + "filename": "_gogoanime_external.go", + "name": "GogoCDN", + "formattedName": "GogoCDN", + "package": "main", + "fields": [ + { + "name": "client", + "jsonName": "client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "serverName", + "jsonName": "serverName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "keys", + "jsonName": "keys", + "goType": "cdnKeys", + "typescriptType": "cdnKeys", + "usedTypescriptType": "cdnKeys", + "usedStructName": "main.cdnKeys", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "referrer", + "jsonName": "referrer", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/testdir/_gogoanime_external.go", + "filename": "_gogoanime_external.go", + "name": "StreamSB", + "formattedName": "StreamSB", + "package": "main", + "fields": [ + { + "name": "Host", + "jsonName": "Host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Host2", + "jsonName": "Host2", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/testdir/_mangapill_external.go", + "filename": "_mangapill_external.go", + "name": "Mangapill", + "formattedName": "Mangapill", + "package": "main", + "fields": [ + { + "name": "Url", + "jsonName": "Url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/testdir/_my_anime_torrent_provider.go", + "filename": "_my_anime_torrent_provider.go", + "name": "MyAnimeTorrentProvider", + "formattedName": "MyAnimeTorrentProvider", + "package": "main", + "fields": [ + { + "name": "url", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "client", + "jsonName": "client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/testdir/_my_online_streaming_provider.go", + "filename": "_my_online_streaming_provider.go", + "name": "Provider", + "formattedName": "Provider", + "package": "main", + "fields": [ + { + "name": "url", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "client", + "jsonName": "client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/extension_repo/userconfig.go", + "filename": "userconfig.go", + "name": "ExtensionUserConfig", + "formattedName": "ExtensionRepo_ExtensionUserConfig", + "package": "extension_repo", + "fields": [ + { + "name": "UserConfig", + "jsonName": "userConfig", + "goType": "extension.UserConfig", + "typescriptType": "Extension_UserConfig", + "usedTypescriptType": "Extension_UserConfig", + "usedStructName": "extension.UserConfig", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SavedUserConfig", + "jsonName": "savedUserConfig", + "goType": "extension.SavedUserConfig", + "typescriptType": "Extension_SavedUserConfig", + "usedTypescriptType": "Extension_SavedUserConfig", + "usedStructName": "extension.SavedUserConfig", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/goja/goja_bindings/fetch.go", + "filename": "fetch.go", + "name": "Fetch", + "formattedName": "Fetch", + "package": "goja_bindings", + "fields": [ + { + "name": "vm", + "jsonName": "vm", + "goType": "goja.Runtime", + "typescriptType": "Runtime", + "usedTypescriptType": "Runtime", + "usedStructName": "goja.Runtime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "fetchSem", + "jsonName": "fetchSem", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "vmResponseCh", + "jsonName": "vmResponseCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/goja/goja_bindings/fieldmapper.go", + "filename": "fieldmapper.go", + "name": "DefaultFieldMapper", + "formattedName": "DefaultFieldMapper", + "package": "goja_bindings", + "fields": [], + "comments": [ + " DefaultFieldMapper provides custom mapping between Go and JavaScript methods names.", + "", + " It is similar to the builtin \"uncapFieldNameMapper\" but also converts", + " all uppercase identifiers to their lowercase equivalent (eg. \"GET\" -\u003e \"get\")." + ] + }, + { + "filepath": "../internal/goja/goja_runtime/goja_runtime_manager.go", + "filename": "goja_runtime_manager.go", + "name": "Manager", + "formattedName": "Manager", + "package": "goja_runtime", + "fields": [ + { + "name": "pluginPools", + "jsonName": "pluginPools", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "basePool", + "jsonName": "basePool", + "goType": "Pool", + "typescriptType": "Pool", + "usedTypescriptType": "Pool", + "usedStructName": "goja_runtime.Pool", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " Manager manages a shared pool of Goja runtimes for all extensions." + ] + }, + { + "filepath": "../internal/goja/goja_runtime/goja_runtime_manager.go", + "filename": "goja_runtime_manager.go", + "name": "Pool", + "formattedName": "Pool", + "package": "goja_runtime", + "fields": [ + { + "name": "sp", + "jsonName": "sp", + "goType": "sync.Pool", + "typescriptType": "Pool", + "usedTypescriptType": "Pool", + "usedStructName": "sync.Pool", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "factory", + "jsonName": "factory", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "size", + "jsonName": "size", + "goType": "int32", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "metrics", + "jsonName": "metrics", + "goType": "metrics", + "typescriptType": "metrics", + "usedTypescriptType": "metrics", + "usedStructName": "goja_runtime.metrics", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/handlers/directory_selector.go", + "filename": "directory_selector.go", + "name": "DirectoryInfo", + "formattedName": "DirectoryInfo", + "package": "handlers", + "fields": [ + { + "name": "FullPath", + "jsonName": "fullPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FolderName", + "jsonName": "folderName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/handlers/directory_selector.go", + "filename": "directory_selector.go", + "name": "DirectorySelectorResponse", + "formattedName": "DirectorySelectorResponse", + "package": "handlers", + "fields": [ + { + "name": "FullPath", + "jsonName": "fullPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Exists", + "jsonName": "exists", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BasePath", + "jsonName": "basePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Suggestions", + "jsonName": "suggestions", + "goType": "[]DirectoryInfo", + "typescriptType": "Array\u003cDirectoryInfo\u003e", + "usedTypescriptType": "DirectoryInfo", + "usedStructName": "handlers.DirectoryInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Content", + "jsonName": "content", + "goType": "[]DirectoryInfo", + "typescriptType": "Array\u003cDirectoryInfo\u003e", + "usedTypescriptType": "DirectoryInfo", + "usedStructName": "handlers.DirectoryInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/handlers/docs.go", + "filename": "docs.go", + "name": "ApiDocsGroup", + "formattedName": "ApiDocsGroup", + "package": "handlers", + "fields": [ + { + "name": "Filename", + "jsonName": "filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Handlers", + "jsonName": "handlers", + "goType": "[]RouteHandler", + "typescriptType": "Array\u003cRouteHandler\u003e", + "usedTypescriptType": "RouteHandler", + "usedStructName": "handlers.RouteHandler", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/handlers/docs.go", + "filename": "docs.go", + "name": "RouteHandler", + "formattedName": "RouteHandler", + "package": "handlers", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TrimmedName", + "jsonName": "trimmedName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Comments", + "jsonName": "comments", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Filepath", + "jsonName": "filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filename", + "jsonName": "filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Api", + "jsonName": "api", + "goType": "RouteHandlerApi", + "typescriptType": "RouteHandlerApi", + "usedTypescriptType": "RouteHandlerApi", + "usedStructName": "handlers.RouteHandlerApi", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/handlers/docs.go", + "filename": "docs.go", + "name": "RouteHandlerApi", + "formattedName": "RouteHandlerApi", + "package": "handlers", + "fields": [ + { + "name": "Summary", + "jsonName": "summary", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Descriptions", + "jsonName": "descriptions", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Endpoint", + "jsonName": "endpoint", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Methods", + "jsonName": "methods", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Params", + "jsonName": "params", + "goType": "[]RouteHandlerParam", + "typescriptType": "Array\u003cRouteHandlerParam\u003e", + "usedTypescriptType": "RouteHandlerParam", + "usedStructName": "handlers.RouteHandlerParam", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BodyFields", + "jsonName": "bodyFields", + "goType": "[]RouteHandlerParam", + "typescriptType": "Array\u003cRouteHandlerParam\u003e", + "usedTypescriptType": "RouteHandlerParam", + "usedStructName": "handlers.RouteHandlerParam", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Returns", + "jsonName": "returns", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReturnGoType", + "jsonName": "returnGoType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReturnTypescriptType", + "jsonName": "returnTypescriptType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/handlers/docs.go", + "filename": "docs.go", + "name": "RouteHandlerParam", + "formattedName": "RouteHandlerParam", + "package": "handlers", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "JsonName", + "jsonName": "jsonName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "GoType", + "jsonName": "goType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g., []models.User" + ] + }, + { + "name": "UsedStructType", + "jsonName": "usedStructType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g., models.User" + ] + }, + { + "name": "TypescriptType", + "jsonName": "typescriptType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g., Array\u003cUser\u003e" + ] + }, + { + "name": "Required", + "jsonName": "required", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Descriptions", + "jsonName": "descriptions", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/handlers/download.go", + "filename": "download.go", + "name": "DownloadReleaseResponse", + "formattedName": "DownloadReleaseResponse", + "package": "handlers", + "fields": [ + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Error", + "jsonName": "error", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/handlers/mal.go", + "filename": "mal.go", + "name": "MalAuthResponse", + "formattedName": "MalAuthResponse", + "package": "handlers", + "fields": [ + { + "name": "AccessToken", + "jsonName": "access_token", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RefreshToken", + "jsonName": "refresh_token", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ExpiresIn", + "jsonName": "expires_in", + "goType": "int32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TokenType", + "jsonName": "token_type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/handlers/manual_dump.go", + "filename": "manual_dump.go", + "name": "RequestBody", + "formattedName": "RequestBody", + "package": "handlers", + "fields": [ + { + "name": "Dir", + "jsonName": "dir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Username", + "jsonName": "userName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/handlers/response.go", + "filename": "response.go", + "name": "SeaResponse", + "formattedName": "SeaResponse", + "package": "handlers", + "fields": [ + { + "name": "Error", + "jsonName": "error", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "data", + "goType": "R", + "typescriptType": "R", + "usedTypescriptType": "R", + "usedStructName": "handlers.R", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " SeaResponse is a generic response type for the API.", + " It is used to return data or errors." + ] + }, + { + "filepath": "../internal/handlers/routes.go", + "filename": "routes.go", + "name": "Handler", + "formattedName": "Handler", + "package": "handlers", + "fields": [ + { + "name": "App", + "jsonName": "App", + "goType": "core.App", + "typescriptType": "INTERNAL_App", + "usedTypescriptType": "INTERNAL_App", + "usedStructName": "core.App", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/handlers/status.go", + "filename": "status.go", + "name": "Status", + "formattedName": "Status", + "package": "handlers", + "fields": [ + { + "name": "OS", + "jsonName": "os", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ClientDevice", + "jsonName": "clientDevice", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ClientPlatform", + "jsonName": "clientPlatform", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ClientUserAgent", + "jsonName": "clientUserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DataDir", + "jsonName": "dataDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "User", + "jsonName": "user", + "goType": "user.User", + "typescriptType": "User", + "usedTypescriptType": "User", + "usedStructName": "user.User", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Settings", + "jsonName": "settings", + "goType": "models.Settings", + "typescriptType": "Models_Settings", + "usedTypescriptType": "Models_Settings", + "usedStructName": "models.Settings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Version", + "jsonName": "version", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VersionName", + "jsonName": "versionName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ThemeSettings", + "jsonName": "themeSettings", + "goType": "models.Theme", + "typescriptType": "Models_Theme", + "usedTypescriptType": "Models_Theme", + "usedStructName": "models.Theme", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsOffline", + "jsonName": "isOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediastreamSettings", + "jsonName": "mediastreamSettings", + "goType": "models.MediastreamSettings", + "typescriptType": "Models_MediastreamSettings", + "usedTypescriptType": "Models_MediastreamSettings", + "usedStructName": "models.MediastreamSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentstreamSettings", + "jsonName": "torrentstreamSettings", + "goType": "models.TorrentstreamSettings", + "typescriptType": "Models_TorrentstreamSettings", + "usedTypescriptType": "Models_TorrentstreamSettings", + "usedStructName": "models.TorrentstreamSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DebridSettings", + "jsonName": "debridSettings", + "goType": "models.DebridSettings", + "typescriptType": "Models_DebridSettings", + "usedTypescriptType": "Models_DebridSettings", + "usedStructName": "models.DebridSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnilistClientID", + "jsonName": "anilistClientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Updating", + "jsonName": "updating", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " If true, a new screen will be displayed" + ] + }, + { + "name": "IsDesktopSidecar", + "jsonName": "isDesktopSidecar", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " The server is running as a desktop sidecar" + ] + }, + { + "name": "FeatureFlags", + "jsonName": "featureFlags", + "goType": "core.FeatureFlags", + "typescriptType": "INTERNAL_FeatureFlags", + "usedTypescriptType": "INTERNAL_FeatureFlags", + "usedStructName": "core.FeatureFlags", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ServerReady", + "jsonName": "serverReady", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ServerHasPassword", + "jsonName": "serverHasPassword", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Status is a struct containing the user data, settings, and OS.", + " It is used by the client in various places to access necessary information." + ] + }, + { + "filepath": "../internal/handlers/status.go", + "filename": "status.go", + "name": "MemoryStatsResponse", + "formattedName": "MemoryStatsResponse", + "package": "handlers", + "fields": [ + { + "name": "Alloc", + "jsonName": "alloc", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes allocated and not yet freed" + ] + }, + { + "name": "TotalAlloc", + "jsonName": "totalAlloc", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes allocated (even if freed)" + ] + }, + { + "name": "Sys", + "jsonName": "sys", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes obtained from system" + ] + }, + { + "name": "Lookups", + "jsonName": "lookups", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " number of pointer lookups" + ] + }, + { + "name": "Mallocs", + "jsonName": "mallocs", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " number of mallocs" + ] + }, + { + "name": "Frees", + "jsonName": "frees", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " number of frees" + ] + }, + { + "name": "HeapAlloc", + "jsonName": "heapAlloc", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes allocated and not yet freed" + ] + }, + { + "name": "HeapSys", + "jsonName": "heapSys", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes obtained from system" + ] + }, + { + "name": "HeapIdle", + "jsonName": "heapIdle", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes in idle spans" + ] + }, + { + "name": "HeapInuse", + "jsonName": "heapInuse", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes in non-idle span" + ] + }, + { + "name": "HeapReleased", + "jsonName": "heapReleased", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes released to OS" + ] + }, + { + "name": "HeapObjects", + "jsonName": "heapObjects", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " total number of allocated objects" + ] + }, + { + "name": "StackInuse", + "jsonName": "stackInuse", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes used by stack allocator" + ] + }, + { + "name": "StackSys", + "jsonName": "stackSys", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes obtained from system for stack allocator" + ] + }, + { + "name": "MSpanInuse", + "jsonName": "mSpanInuse", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes used by mspan structures" + ] + }, + { + "name": "MSpanSys", + "jsonName": "mSpanSys", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes obtained from system for mspan structures" + ] + }, + { + "name": "MCacheInuse", + "jsonName": "mCacheInuse", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes used by mcache structures" + ] + }, + { + "name": "MCacheSys", + "jsonName": "mCacheSys", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes obtained from system for mcache structures" + ] + }, + { + "name": "BuckHashSys", + "jsonName": "buckHashSys", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes used by the profiling bucket hash table" + ] + }, + { + "name": "GCSys", + "jsonName": "gcSys", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes used for garbage collection system metadata" + ] + }, + { + "name": "OtherSys", + "jsonName": "otherSys", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " bytes used for other system allocations" + ] + }, + { + "name": "NextGC", + "jsonName": "nextGC", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " next collection will happen when HeapAlloc ≥ this amount" + ] + }, + { + "name": "LastGC", + "jsonName": "lastGC", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " time the last garbage collection finished" + ] + }, + { + "name": "PauseTotalNs", + "jsonName": "pauseTotalNs", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " cumulative nanoseconds in GC stop-the-world pauses" + ] + }, + { + "name": "PauseNs", + "jsonName": "pauseNs", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " nanoseconds in recent GC stop-the-world pause" + ] + }, + { + "name": "NumGC", + "jsonName": "numGC", + "goType": "uint32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " number of completed GC cycles" + ] + }, + { + "name": "NumForcedGC", + "jsonName": "numForcedGC", + "goType": "uint32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " number of GC cycles that were forced by the application calling the GC function" + ] + }, + { + "name": "GCCPUFraction", + "jsonName": "gcCPUFraction", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " fraction of this program's available CPU time used by the GC since the program started" + ] + }, + { + "name": "EnableGC", + "jsonName": "enableGC", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " boolean that indicates GC is enabled" + ] + }, + { + "name": "DebugGC", + "jsonName": "debugGC", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " boolean that indicates GC debug mode is enabled" + ] + }, + { + "name": "NumGoroutine", + "jsonName": "numGoroutine", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " number of goroutines" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/hook/hook.go", + "filename": "hook.go", + "name": "Handler", + "formattedName": "Handler", + "package": "hook", + "fields": [ + { + "name": "Func", + "jsonName": "Func", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Id", + "jsonName": "Id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Priority", + "jsonName": "Priority", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Handler defines a single Hook handler.", + " Multiple handlers can share the same id.", + " If Id is not explicitly set it will be autogenerated by Hook.Add and Hook.AddHandler." + ] + }, + { + "filepath": "../internal/hook/hook.go", + "filename": "hook.go", + "name": "Hook", + "formattedName": "Hook", + "package": "hook", + "fields": [ + { + "name": "handlers", + "jsonName": "handlers", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " Hook defines a generic concurrent safe structure for managing event hooks.", + " When using custom event it must embed the base [hook.Event].", + "", + " Example:", + "", + "\ttype CustomEvent struct {", + "\t\thook.Event", + "\t\tSomeField int", + "\t}", + "", + "\th := Hook[*CustomEvent]{}", + "", + "\th.BindFunc(func(e *CustomEvent) error {", + "\t\tprintln(e.SomeField)", + "", + "\t\treturn e.Next()", + "\t})", + "", + "\th.Trigger(\u0026CustomEvent{ SomeField: 123 })" + ] + }, + { + "filepath": "../internal/hook/hooks.go", + "filename": "hooks.go", + "name": "ManagerImpl", + "formattedName": "ManagerImpl", + "package": "hook", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetAnime", + "jsonName": "onGetAnime", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetAnimeDetails", + "jsonName": "onGetAnimeDetails", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetManga", + "jsonName": "onGetManga", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetMangaDetails", + "jsonName": "onGetMangaDetails", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetAnimeCollection", + "jsonName": "onGetAnimeCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetMangaCollection", + "jsonName": "onGetMangaCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetCachedAnimeCollection", + "jsonName": "onGetCachedAnimeCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetCachedMangaCollection", + "jsonName": "onGetCachedMangaCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetRawAnimeCollection", + "jsonName": "onGetRawAnimeCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetRawMangaCollection", + "jsonName": "onGetRawMangaCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetCachedRawAnimeCollection", + "jsonName": "onGetCachedRawAnimeCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetCachedRawMangaCollection", + "jsonName": "onGetCachedRawMangaCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onGetStudioDetails", + "jsonName": "onGetStudioDetails", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onPreUpdateEntry", + "jsonName": "onPreUpdateEntry", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onPostUpdateEntry", + "jsonName": "onPostUpdateEntry", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onPreUpdateEntryProgress", + "jsonName": "onPreUpdateEntryProgress", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onPostUpdateEntryProgress", + "jsonName": "onPostUpdateEntryProgress", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onPreUpdateEntryRepeat", + "jsonName": "onPreUpdateEntryRepeat", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onPostUpdateEntryRepeat", + "jsonName": "onPostUpdateEntryRepeat", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEntryRequested", + "jsonName": "onAnimeEntryRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEntry", + "jsonName": "onAnimeEntry", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEntryFillerHydration", + "jsonName": "onAnimeEntryFillerHydration", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEntryLibraryDataRequested", + "jsonName": "onAnimeEntryLibraryDataRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEntryLibraryData", + "jsonName": "onAnimeEntryLibraryData", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEntryManualMatchBeforeSave", + "jsonName": "onAnimeEntryManualMatchBeforeSave", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMissingEpisodesRequested", + "jsonName": "onMissingEpisodesRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMissingEpisodes", + "jsonName": "onMissingEpisodes", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEntryDownloadInfoRequested", + "jsonName": "onAnimeEntryDownloadInfoRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEntryDownloadInfo", + "jsonName": "onAnimeEntryDownloadInfo", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEpisodeCollectionRequested", + "jsonName": "onAnimeEpisodeCollectionRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEpisodeCollection", + "jsonName": "onAnimeEpisodeCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeLibraryCollectionRequested", + "jsonName": "onAnimeLibraryCollectionRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeLibraryCollection", + "jsonName": "onAnimeLibraryCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeLibraryStreamCollectionRequested", + "jsonName": "onAnimeLibraryStreamCollectionRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeLibraryStreamCollection", + "jsonName": "onAnimeLibraryStreamCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeScheduleItems", + "jsonName": "onAnimeScheduleItems", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAutoDownloaderMatchVerified", + "jsonName": "onAutoDownloaderMatchVerified", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAutoDownloaderRunStarted", + "jsonName": "onAutoDownloaderRunStarted", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAutoDownloaderRunCompleted", + "jsonName": "onAutoDownloaderRunCompleted", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAutoDownloaderSettingsUpdated", + "jsonName": "onAutoDownloaderSettingsUpdated", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAutoDownloaderTorrentsFetched", + "jsonName": "onAutoDownloaderTorrentsFetched", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAutoDownloaderBeforeDownloadTorrent", + "jsonName": "onAutoDownloaderBeforeDownloadTorrent", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAutoDownloaderAfterDownloadTorrent", + "jsonName": "onAutoDownloaderAfterDownloadTorrent", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanStarted", + "jsonName": "onScanStarted", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanFilePathsRetrieved", + "jsonName": "onScanFilePathsRetrieved", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanLocalFilesParsed", + "jsonName": "onScanLocalFilesParsed", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanCompleted", + "jsonName": "onScanCompleted", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanMediaFetcherStarted", + "jsonName": "onScanMediaFetcherStarted", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanMediaFetcherCompleted", + "jsonName": "onScanMediaFetcherCompleted", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanMatchingStarted", + "jsonName": "onScanMatchingStarted", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanLocalFileMatched", + "jsonName": "onScanLocalFileMatched", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanMatchingCompleted", + "jsonName": "onScanMatchingCompleted", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanHydrationStarted", + "jsonName": "onScanHydrationStarted", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanLocalFileHydrationStarted", + "jsonName": "onScanLocalFileHydrationStarted", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onScanLocalFileHydrated", + "jsonName": "onScanLocalFileHydrated", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeMetadataRequested", + "jsonName": "onAnimeMetadataRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeMetadata", + "jsonName": "onAnimeMetadata", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEpisodeMetadataRequested", + "jsonName": "onAnimeEpisodeMetadataRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimeEpisodeMetadata", + "jsonName": "onAnimeEpisodeMetadata", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMangaEntryRequested", + "jsonName": "onMangaEntryRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMangaEntry", + "jsonName": "onMangaEntry", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMangaLibraryCollectionRequested", + "jsonName": "onMangaLibraryCollectionRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMangaLibraryCollection", + "jsonName": "onMangaLibraryCollection", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMangaDownloadedChapterContainersRequested", + "jsonName": "onMangaDownloadedChapterContainersRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMangaDownloadedChapterContainers", + "jsonName": "onMangaDownloadedChapterContainers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMangaLatestChapterNumbersMap", + "jsonName": "onMangaLatestChapterNumbersMap", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMangaDownloadMap", + "jsonName": "onMangaDownloadMap", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMangaChapterContainerRequested", + "jsonName": "onMangaChapterContainerRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMangaChapterContainer", + "jsonName": "onMangaChapterContainer", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onLocalFilePlaybackRequested", + "jsonName": "onLocalFilePlaybackRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onPlaybackBeforeTracking", + "jsonName": "onPlaybackBeforeTracking", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onStreamPlaybackRequested", + "jsonName": "onStreamPlaybackRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onPlaybackLocalFileDetailsRequested", + "jsonName": "onPlaybackLocalFileDetailsRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onPlaybackStreamDetailsRequested", + "jsonName": "onPlaybackStreamDetailsRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMediaPlayerLocalFileTrackingRequested", + "jsonName": "onMediaPlayerLocalFileTrackingRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onMediaPlayerStreamTrackingRequested", + "jsonName": "onMediaPlayerStreamTrackingRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onDebridAutoSelectTorrentsFetched", + "jsonName": "onDebridAutoSelectTorrentsFetched", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onDebridSendStreamToMediaPlayer", + "jsonName": "onDebridSendStreamToMediaPlayer", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onDebridLocalDownloadRequested", + "jsonName": "onDebridLocalDownloadRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onDebridSkipStreamCheck", + "jsonName": "onDebridSkipStreamCheck", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onTorrentStreamAutoSelectTorrentsFetched", + "jsonName": "onTorrentStreamAutoSelectTorrentsFetched", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onTorrentStreamSendStreamToMediaPlayer", + "jsonName": "onTorrentStreamSendStreamToMediaPlayer", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onWatchHistoryItemRequested", + "jsonName": "onWatchHistoryItemRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onWatchHistoryItemUpdated", + "jsonName": "onWatchHistoryItemUpdated", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onWatchHistoryLocalFileEpisodeItemRequested", + "jsonName": "onWatchHistoryLocalFileEpisodeItemRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onWatchHistoryStreamEpisodeItemRequested", + "jsonName": "onWatchHistoryStreamEpisodeItemRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onDiscordPresenceAnimeActivityRequested", + "jsonName": "onDiscordPresenceAnimeActivityRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onDiscordPresenceMangaActivityRequested", + "jsonName": "onDiscordPresenceMangaActivityRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onDiscordPresenceClientClosed", + "jsonName": "onDiscordPresenceClientClosed", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onListMissedSequelsRequested", + "jsonName": "onListMissedSequelsRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onListMissedSequels", + "jsonName": "onListMissedSequels", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnizipMediaRequested", + "jsonName": "onAnizipMediaRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnizipMedia", + "jsonName": "onAnizipMedia", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimapMediaRequested", + "jsonName": "onAnimapMediaRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onAnimapMedia", + "jsonName": "onAnimapMedia", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onHydrateFillerDataRequested", + "jsonName": "onHydrateFillerDataRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onHydrateOnlinestreamFillerDataRequested", + "jsonName": "onHydrateOnlinestreamFillerDataRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onHydrateEpisodeFillerDataRequested", + "jsonName": "onHydrateEpisodeFillerDataRequested", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/hook/hooks.go", + "filename": "hooks.go", + "name": "NewHookManagerOptions", + "formattedName": "NewHookManagerOptions", + "package": "hook", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/hook_resolver/hook_resolver.go", + "filename": "hook_resolver.go", + "name": "Event", + "formattedName": "Event", + "package": "hook_resolver", + "fields": [ + { + "name": "next", + "jsonName": "next", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "preventDefault", + "jsonName": "preventDefault", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "DefaultPrevented", + "jsonName": "defaultPrevented", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Event implements [Resolver] and it is intended to be used as a base", + " Hook event that you can embed in your custom typed event structs.", + "", + " Example:", + "", + "\ttype CustomEvent struct {", + "\t\thook.Event", + "", + "\t\tSomeField int", + "\t}" + ] + }, + { + "filepath": "../internal/library/anime/autodownloader_rule.go", + "filename": "autodownloader_rule.go", + "name": "AutoDownloaderRuleTitleComparisonType", + "formattedName": "Anime_AutoDownloaderRuleTitleComparisonType", + "package": "anime", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"contains\"", + "\"likely\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/library/anime/autodownloader_rule.go", + "filename": "autodownloader_rule.go", + "name": "AutoDownloaderRuleEpisodeType", + "formattedName": "Anime_AutoDownloaderRuleEpisodeType", + "package": "anime", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"recent\"", + "\"selected\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/library/anime/autodownloader_rule.go", + "filename": "autodownloader_rule.go", + "name": "AutoDownloaderRule", + "formattedName": "Anime_AutoDownloaderRule", + "package": "anime", + "fields": [ + { + "name": "DbID", + "jsonName": "dbId", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Will be set when fetched from the database" + ] + }, + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReleaseGroups", + "jsonName": "releaseGroups", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Resolutions", + "jsonName": "resolutions", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ComparisonTitle", + "jsonName": "comparisonTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TitleComparisonType", + "jsonName": "titleComparisonType", + "goType": "AutoDownloaderRuleTitleComparisonType", + "typescriptType": "Anime_AutoDownloaderRuleTitleComparisonType", + "usedTypescriptType": "Anime_AutoDownloaderRuleTitleComparisonType", + "usedStructName": "anime.AutoDownloaderRuleTitleComparisonType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeType", + "jsonName": "episodeType", + "goType": "AutoDownloaderRuleEpisodeType", + "typescriptType": "Anime_AutoDownloaderRuleEpisodeType", + "usedTypescriptType": "Anime_AutoDownloaderRuleEpisodeType", + "usedStructName": "anime.AutoDownloaderRuleEpisodeType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumbers", + "jsonName": "episodeNumbers", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AdditionalTerms", + "jsonName": "additionalTerms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/collection.go", + "filename": "collection.go", + "name": "LibraryCollection", + "formattedName": "Anime_LibraryCollection", + "package": "anime", + "fields": [ + { + "name": "ContinueWatchingList", + "jsonName": "continueWatchingList", + "goType": "[]Episode", + "typescriptType": "Array\u003cAnime_Episode\u003e", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Lists", + "jsonName": "lists", + "goType": "[]LibraryCollectionList", + "typescriptType": "Array\u003cAnime_LibraryCollectionList\u003e", + "usedTypescriptType": "Anime_LibraryCollectionList", + "usedStructName": "anime.LibraryCollectionList", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UnmatchedLocalFiles", + "jsonName": "unmatchedLocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UnmatchedGroups", + "jsonName": "unmatchedGroups", + "goType": "[]UnmatchedGroup", + "typescriptType": "Array\u003cAnime_UnmatchedGroup\u003e", + "usedTypescriptType": "Anime_UnmatchedGroup", + "usedStructName": "anime.UnmatchedGroup", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IgnoredLocalFiles", + "jsonName": "ignoredLocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UnknownGroups", + "jsonName": "unknownGroups", + "goType": "[]UnknownGroup", + "typescriptType": "Array\u003cAnime_UnknownGroup\u003e", + "usedTypescriptType": "Anime_UnknownGroup", + "usedStructName": "anime.UnknownGroup", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Stats", + "jsonName": "stats", + "goType": "LibraryCollectionStats", + "typescriptType": "Anime_LibraryCollectionStats", + "usedTypescriptType": "Anime_LibraryCollectionStats", + "usedStructName": "anime.LibraryCollectionStats", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Stream", + "jsonName": "stream", + "goType": "StreamCollection", + "typescriptType": "Anime_StreamCollection", + "usedTypescriptType": "Anime_StreamCollection", + "usedStructName": "anime.StreamCollection", + "required": false, + "public": true, + "comments": [ + " Hydrated by the route handler" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/collection.go", + "filename": "collection.go", + "name": "StreamCollection", + "formattedName": "Anime_StreamCollection", + "package": "anime", + "fields": [ + { + "name": "ContinueWatchingList", + "jsonName": "continueWatchingList", + "goType": "[]Episode", + "typescriptType": "Array\u003cAnime_Episode\u003e", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Anime", + "jsonName": "anime", + "goType": "[]anilist.BaseAnime", + "typescriptType": "Array\u003cAL_BaseAnime\u003e", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ListData", + "jsonName": "listData", + "goType": "map[int]EntryListData", + "typescriptType": "Record\u003cnumber, Anime_EntryListData\u003e", + "usedTypescriptType": "Anime_EntryListData", + "usedStructName": "anime.EntryListData", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/collection.go", + "filename": "collection.go", + "name": "LibraryCollectionListType", + "formattedName": "Anime_LibraryCollectionListType", + "package": "anime", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/library/anime/collection.go", + "filename": "collection.go", + "name": "LibraryCollectionStats", + "formattedName": "Anime_LibraryCollectionStats", + "package": "anime", + "fields": [ + { + "name": "TotalEntries", + "jsonName": "totalEntries", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalFiles", + "jsonName": "totalFiles", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalShows", + "jsonName": "totalShows", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalMovies", + "jsonName": "totalMovies", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalSpecials", + "jsonName": "totalSpecials", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalSize", + "jsonName": "totalSize", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/collection.go", + "filename": "collection.go", + "name": "LibraryCollectionList", + "formattedName": "Anime_LibraryCollectionList", + "package": "anime", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Entries", + "jsonName": "entries", + "goType": "[]LibraryCollectionEntry", + "typescriptType": "Array\u003cAnime_LibraryCollectionEntry\u003e", + "usedTypescriptType": "Anime_LibraryCollectionEntry", + "usedStructName": "anime.LibraryCollectionEntry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/collection.go", + "filename": "collection.go", + "name": "LibraryCollectionEntry", + "formattedName": "Anime_LibraryCollectionEntry", + "package": "anime", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EntryLibraryData", + "jsonName": "libraryData", + "goType": "EntryLibraryData", + "typescriptType": "Anime_EntryLibraryData", + "usedTypescriptType": "Anime_EntryLibraryData", + "usedStructName": "anime.EntryLibraryData", + "required": false, + "public": true, + "comments": [ + " Library data" + ] + }, + { + "name": "NakamaEntryLibraryData", + "jsonName": "nakamaLibraryData", + "goType": "NakamaEntryLibraryData", + "typescriptType": "Anime_NakamaEntryLibraryData", + "usedTypescriptType": "Anime_NakamaEntryLibraryData", + "usedStructName": "anime.NakamaEntryLibraryData", + "required": false, + "public": true, + "comments": [ + " Library data from Nakama" + ] + }, + { + "name": "EntryListData", + "jsonName": "listData", + "goType": "EntryListData", + "typescriptType": "Anime_EntryListData", + "usedTypescriptType": "Anime_EntryListData", + "usedStructName": "anime.EntryListData", + "required": false, + "public": true, + "comments": [ + " AniList list data" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/collection.go", + "filename": "collection.go", + "name": "UnmatchedGroup", + "formattedName": "Anime_UnmatchedGroup", + "package": "anime", + "fields": [ + { + "name": "Dir", + "jsonName": "dir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Suggestions", + "jsonName": "suggestions", + "goType": "[]anilist.BaseAnime", + "typescriptType": "Array\u003cAL_BaseAnime\u003e", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/collection.go", + "filename": "collection.go", + "name": "UnknownGroup", + "formattedName": "Anime_UnknownGroup", + "package": "anime", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/collection.go", + "filename": "collection.go", + "name": "NewLibraryCollectionOptions", + "formattedName": "Anime_NewLibraryCollectionOptions", + "package": "anime", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "AnimeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry.go", + "filename": "entry.go", + "name": "Entry", + "formattedName": "Anime_Entry", + "package": "anime", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EntryListData", + "jsonName": "listData", + "goType": "EntryListData", + "typescriptType": "Anime_EntryListData", + "usedTypescriptType": "Anime_EntryListData", + "usedStructName": "anime.EntryListData", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EntryLibraryData", + "jsonName": "libraryData", + "goType": "EntryLibraryData", + "typescriptType": "Anime_EntryLibraryData", + "usedTypescriptType": "Anime_EntryLibraryData", + "usedStructName": "anime.EntryLibraryData", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EntryDownloadInfo", + "jsonName": "downloadInfo", + "goType": "EntryDownloadInfo", + "typescriptType": "Anime_EntryDownloadInfo", + "usedTypescriptType": "Anime_EntryDownloadInfo", + "usedStructName": "anime.EntryDownloadInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "[]Episode", + "typescriptType": "Array\u003cAnime_Episode\u003e", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NextEpisode", + "jsonName": "nextEpisode", + "goType": "Episode", + "typescriptType": "Anime_Episode", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnidbId", + "jsonName": "anidbId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CurrentEpisodeCount", + "jsonName": "currentEpisodeCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsNakamaEntry", + "jsonName": "_isNakamaEntry", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NakamaLibraryData", + "jsonName": "nakamaLibraryData", + "goType": "NakamaEntryLibraryData", + "typescriptType": "Anime_NakamaEntryLibraryData", + "usedTypescriptType": "Anime_NakamaEntryLibraryData", + "usedStructName": "anime.NakamaEntryLibraryData", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry.go", + "filename": "entry.go", + "name": "EntryListData", + "formattedName": "Anime_EntryListData", + "package": "anime", + "fields": [ + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Repeat", + "jsonName": "repeat", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartedAt", + "jsonName": "startedAt", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompletedAt", + "jsonName": "completedAt", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry.go", + "filename": "entry.go", + "name": "NewEntryOptions", + "formattedName": "Anime_NewEntryOptions", + "package": "anime", + "fields": [ + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [ + " All local files" + ] + }, + { + "name": "AnimeCollection", + "jsonName": "AnimeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsSimulated", + "jsonName": "IsSimulated", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " If the account is simulated" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry.go", + "filename": "entry.go", + "name": "Discrepancy", + "formattedName": "Anime_Discrepancy", + "package": "anime", + "fields": [], + "aliasOf": { + "goType": "int", + "typescriptType": "number", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry_download_info.go", + "filename": "entry_download_info.go", + "name": "EntryDownloadInfo", + "formattedName": "Anime_EntryDownloadInfo", + "package": "anime", + "fields": [ + { + "name": "EpisodesToDownload", + "jsonName": "episodesToDownload", + "goType": "[]EntryDownloadEpisode", + "typescriptType": "Array\u003cAnime_EntryDownloadEpisode\u003e", + "usedTypescriptType": "Anime_EntryDownloadEpisode", + "usedStructName": "anime.EntryDownloadEpisode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CanBatch", + "jsonName": "canBatch", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BatchAll", + "jsonName": "batchAll", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HasInaccurateSchedule", + "jsonName": "hasInaccurateSchedule", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Rewatch", + "jsonName": "rewatch", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AbsoluteOffset", + "jsonName": "absoluteOffset", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry_download_info.go", + "filename": "entry_download_info.go", + "name": "EntryDownloadEpisode", + "formattedName": "Anime_EntryDownloadEpisode", + "package": "anime", + "fields": [ + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AniDBEpisode", + "jsonName": "aniDBEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "Episode", + "typescriptType": "Anime_Episode", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry_download_info.go", + "filename": "entry_download_info.go", + "name": "NewEntryDownloadInfoOptions", + "formattedName": "Anime_NewEntryDownloadInfoOptions", + "package": "anime", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeMetadata", + "jsonName": "AnimeMetadata", + "goType": "metadata.AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "Progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "Status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry_library_data.go", + "filename": "entry_library_data.go", + "name": "EntryLibraryData", + "formattedName": "Anime_EntryLibraryData", + "package": "anime", + "fields": [ + { + "name": "AllFilesLocked", + "jsonName": "allFilesLocked", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SharedPath", + "jsonName": "sharedPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UnwatchedCount", + "jsonName": "unwatchedCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MainFileCount", + "jsonName": "mainFileCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry_library_data.go", + "filename": "entry_library_data.go", + "name": "NakamaEntryLibraryData", + "formattedName": "Anime_NakamaEntryLibraryData", + "package": "anime", + "fields": [ + { + "name": "UnwatchedCount", + "jsonName": "unwatchedCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MainFileCount", + "jsonName": "mainFileCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry_library_data.go", + "filename": "entry_library_data.go", + "name": "NewEntryLibraryDataOptions", + "formattedName": "Anime_NewEntryLibraryDataOptions", + "package": "anime", + "fields": [ + { + "name": "EntryLocalFiles", + "jsonName": "EntryLocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CurrentProgress", + "jsonName": "CurrentProgress", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry_simple.go", + "filename": "entry_simple.go", + "name": "SimpleEntry", + "formattedName": "Anime_SimpleEntry", + "package": "anime", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EntryListData", + "jsonName": "listData", + "goType": "EntryListData", + "typescriptType": "Anime_EntryListData", + "usedTypescriptType": "Anime_EntryListData", + "usedStructName": "anime.EntryListData", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EntryLibraryData", + "jsonName": "libraryData", + "goType": "EntryLibraryData", + "typescriptType": "Anime_EntryLibraryData", + "usedTypescriptType": "Anime_EntryLibraryData", + "usedStructName": "anime.EntryLibraryData", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "[]Episode", + "typescriptType": "Array\u003cAnime_Episode\u003e", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NextEpisode", + "jsonName": "nextEpisode", + "goType": "Episode", + "typescriptType": "Anime_Episode", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CurrentEpisodeCount", + "jsonName": "currentEpisodeCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry_simple.go", + "filename": "entry_simple.go", + "name": "SimpleEntryListData", + "formattedName": "Anime_SimpleEntryListData", + "package": "anime", + "fields": [ + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartedAt", + "jsonName": "startedAt", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompletedAt", + "jsonName": "completedAt", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/entry_simple.go", + "filename": "entry_simple.go", + "name": "NewSimpleAnimeEntryOptions", + "formattedName": "Anime_NewSimpleAnimeEntryOptions", + "package": "anime", + "fields": [ + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [ + " All local files" + ] + }, + { + "name": "AnimeCollection", + "jsonName": "AnimeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/episode.go", + "filename": "episode.go", + "name": "Episode", + "formattedName": "Anime_Episode", + "package": "anime", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "LocalFileType", + "typescriptType": "Anime_LocalFileType", + "usedTypescriptType": "Anime_LocalFileType", + "usedStructName": "anime.LocalFileType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisplayTitle", + "jsonName": "displayTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g, Show: \"Episode 1\", Movie: \"Violet Evergarden The Movie\"" + ] + }, + { + "name": "EpisodeTitle", + "jsonName": "episodeTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g, \"Shibuya Incident - Gate, Open\"" + ] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AniDBEpisode", + "jsonName": "aniDBEpisode", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " AniDB episode number" + ] + }, + { + "name": "AbsoluteEpisodeNumber", + "jsonName": "absoluteEpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ProgressNumber", + "jsonName": "progressNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Usually the same as EpisodeNumber, unless there is a discrepancy between AniList and AniDB" + ] + }, + { + "name": "LocalFile", + "jsonName": "localFile", + "goType": "LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsDownloaded", + "jsonName": "isDownloaded", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Is in the local files" + ] + }, + { + "name": "EpisodeMetadata", + "jsonName": "episodeMetadata", + "goType": "EpisodeMetadata", + "typescriptType": "Anime_EpisodeMetadata", + "usedTypescriptType": "Anime_EpisodeMetadata", + "usedStructName": "anime.EpisodeMetadata", + "required": false, + "public": true, + "comments": [ + " (image, airDate, length, summary, overview)" + ] + }, + { + "name": "FileMetadata", + "jsonName": "fileMetadata", + "goType": "LocalFileMetadata", + "typescriptType": "Anime_LocalFileMetadata", + "usedTypescriptType": "Anime_LocalFileMetadata", + "usedStructName": "anime.LocalFileMetadata", + "required": false, + "public": true, + "comments": [ + " (episode, aniDBEpisode, type...)" + ] + }, + { + "name": "IsInvalid", + "jsonName": "isInvalid", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " No AniDB data" + ] + }, + { + "name": "MetadataIssue", + "jsonName": "metadataIssue", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " Alerts the user that there is a discrepancy between AniList and AniDB" + ] + }, + { + "name": "BaseAnime", + "jsonName": "baseAnime", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsNakamaEpisode", + "jsonName": "_isNakamaEpisode", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/episode.go", + "filename": "episode.go", + "name": "EpisodeMetadata", + "formattedName": "Anime_EpisodeMetadata", + "package": "anime", + "fields": [ + { + "name": "AnidbId", + "jsonName": "anidbId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AirDate", + "jsonName": "airDate", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Length", + "jsonName": "length", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Summary", + "jsonName": "summary", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Overview", + "jsonName": "overview", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsFiller", + "jsonName": "isFiller", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "HasImage", + "jsonName": "hasImage", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [ + " Indicates if the episode has a real image" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/episode.go", + "filename": "episode.go", + "name": "NewEpisodeOptions", + "formattedName": "Anime_NewEpisodeOptions", + "package": "anime", + "fields": [ + { + "name": "LocalFile", + "jsonName": "LocalFile", + "goType": "LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeMetadata", + "jsonName": "AnimeMetadata", + "goType": "metadata.AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [ + " optional" + ] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OptionalAniDBEpisode", + "jsonName": "OptionalAniDBEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ProgressOffset", + "jsonName": "ProgressOffset", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsDownloaded", + "jsonName": "IsDownloaded", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [ + " optional" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/episode.go", + "filename": "episode.go", + "name": "NewSimpleEpisodeOptions", + "formattedName": "Anime_NewSimpleEpisodeOptions", + "package": "anime", + "fields": [ + { + "name": "LocalFile", + "jsonName": "LocalFile", + "goType": "LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsDownloaded", + "jsonName": "IsDownloaded", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/episode_collection.go", + "filename": "episode_collection.go", + "name": "EpisodeCollection", + "formattedName": "Anime_EpisodeCollection", + "package": "anime", + "fields": [ + { + "name": "HasMappingError", + "jsonName": "hasMappingError", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "[]Episode", + "typescriptType": "Array\u003cAnime_Episode\u003e", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Metadata", + "jsonName": "metadata", + "goType": "metadata.AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/episode_collection.go", + "filename": "episode_collection.go", + "name": "NewEpisodeCollectionOptions", + "formattedName": "Anime_NewEpisodeCollectionOptions", + "package": "anime", + "fields": [ + { + "name": "AnimeMetadata", + "jsonName": "AnimeMetadata", + "goType": "metadata.AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/episode_collection.go", + "filename": "episode_collection.go", + "name": "NewEpisodeCollectionFromLocalFilesOptions", + "formattedName": "Anime_NewEpisodeCollectionFromLocalFilesOptions", + "package": "anime", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeCollection", + "jsonName": "AnimeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryRequestedEvent", + "formattedName": "Anime_AnimeEntryRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Entry", + "jsonName": "entry", + "goType": "Entry", + "typescriptType": "Anime_Entry", + "usedTypescriptType": "Anime_Entry", + "usedStructName": "anime.Entry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryRequestedEvent is triggered when an anime entry is requested.", + " Prevent default to skip the default behavior and return the modified entry.", + " This event is triggered before [AnimeEntryEvent].", + " If the modified entry is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryEvent", + "formattedName": "Anime_AnimeEntryEvent", + "package": "anime", + "fields": [ + { + "name": "Entry", + "jsonName": "entry", + "goType": "Entry", + "typescriptType": "Anime_Entry", + "usedTypescriptType": "Anime_Entry", + "usedStructName": "anime.Entry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryEvent is triggered when the media entry is being returned.", + " This event is triggered after [AnimeEntryRequestedEvent]." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryFillerHydrationEvent", + "formattedName": "Anime_AnimeEntryFillerHydrationEvent", + "package": "anime", + "fields": [ + { + "name": "Entry", + "jsonName": "entry", + "goType": "Entry", + "typescriptType": "Anime_Entry", + "usedTypescriptType": "Anime_Entry", + "usedStructName": "anime.Entry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryFillerHydrationEvent is triggered when the filler data is being added to the media entry.", + " This event is triggered after [AnimeEntryEvent].", + " Prevent default to skip the filler data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryLibraryDataRequestedEvent", + "formattedName": "Anime_AnimeEntryLibraryDataRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "EntryLocalFiles", + "jsonName": "entryLocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CurrentProgress", + "jsonName": "currentProgress", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryLibraryDataRequestedEvent is triggered when the app requests the library data for a media entry.", + " This is triggered before [AnimeEntryLibraryDataEvent]." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryLibraryDataEvent", + "formattedName": "Anime_AnimeEntryLibraryDataEvent", + "package": "anime", + "fields": [ + { + "name": "EntryLibraryData", + "jsonName": "entryLibraryData", + "goType": "EntryLibraryData", + "typescriptType": "Anime_EntryLibraryData", + "usedTypescriptType": "Anime_EntryLibraryData", + "usedStructName": "anime.EntryLibraryData", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryLibraryDataEvent is triggered when the library data is being added to the media entry.", + " This is triggered after [AnimeEntryLibraryDataRequestedEvent]." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryManualMatchBeforeSaveEvent", + "formattedName": "Anime_AnimeEntryManualMatchBeforeSaveEvent", + "package": "anime", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Paths", + "jsonName": "paths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MatchedLocalFiles", + "jsonName": "matchedLocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryManualMatchBeforeSaveEvent is triggered when the user manually matches local files to a media entry.", + " Prevent default to skip saving the local files." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "MissingEpisodesRequestedEvent", + "formattedName": "Anime_MissingEpisodesRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SilencedMediaIds", + "jsonName": "silencedMediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MissingEpisodes", + "jsonName": "missingEpisodes", + "goType": "MissingEpisodes", + "typescriptType": "Anime_MissingEpisodes", + "usedTypescriptType": "Anime_MissingEpisodes", + "usedStructName": "anime.MissingEpisodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MissingEpisodesRequestedEvent is triggered when the user requests the missing episodes for the entire library.", + " Prevent default to skip the default process and return the modified missing episodes." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "MissingEpisodesEvent", + "formattedName": "Anime_MissingEpisodesEvent", + "package": "anime", + "fields": [ + { + "name": "MissingEpisodes", + "jsonName": "missingEpisodes", + "goType": "MissingEpisodes", + "typescriptType": "Anime_MissingEpisodes", + "usedTypescriptType": "Anime_MissingEpisodes", + "usedStructName": "anime.MissingEpisodes", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MissingEpisodesEvent is triggered when the missing episodes are being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeLibraryCollectionRequestedEvent", + "formattedName": "Anime_AnimeLibraryCollectionRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LibraryCollection", + "jsonName": "libraryCollection", + "goType": "LibraryCollection", + "typescriptType": "Anime_LibraryCollection", + "usedTypescriptType": "Anime_LibraryCollection", + "usedStructName": "anime.LibraryCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeLibraryCollectionRequestedEvent is triggered when the user requests the library collection.", + " Prevent default to skip the default process and return the modified library collection.", + " If the modified library collection is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeLibraryCollectionEvent", + "formattedName": "Anime_AnimeLibraryCollectionEvent", + "package": "anime", + "fields": [ + { + "name": "LibraryCollection", + "jsonName": "libraryCollection", + "goType": "LibraryCollection", + "typescriptType": "Anime_LibraryCollection", + "usedTypescriptType": "Anime_LibraryCollection", + "usedStructName": "anime.LibraryCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeLibraryCollectionEvent is triggered when the user requests the library collection." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeLibraryStreamCollectionRequestedEvent", + "formattedName": "Anime_AnimeLibraryStreamCollectionRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LibraryCollection", + "jsonName": "libraryCollection", + "goType": "LibraryCollection", + "typescriptType": "Anime_LibraryCollection", + "usedTypescriptType": "Anime_LibraryCollection", + "usedStructName": "anime.LibraryCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeLibraryStreamCollectionRequestedEvent is triggered when the user requests the library stream collection.", + " This is called when the user enables \"Include in library\" for either debrid/online/torrent streamings." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeLibraryStreamCollectionEvent", + "formattedName": "Anime_AnimeLibraryStreamCollectionEvent", + "package": "anime", + "fields": [ + { + "name": "StreamCollection", + "jsonName": "streamCollection", + "goType": "StreamCollection", + "typescriptType": "Anime_StreamCollection", + "usedTypescriptType": "Anime_StreamCollection", + "usedStructName": "anime.StreamCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeLibraryStreamCollectionEvent is triggered when the library stream collection is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryDownloadInfoRequestedEvent", + "formattedName": "Anime_AnimeEntryDownloadInfoRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeMetadata", + "jsonName": "AnimeMetadata", + "goType": "metadata.AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "Progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "Status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EntryDownloadInfo", + "jsonName": "entryDownloadInfo", + "goType": "EntryDownloadInfo", + "typescriptType": "Anime_EntryDownloadInfo", + "usedTypescriptType": "Anime_EntryDownloadInfo", + "usedStructName": "anime.EntryDownloadInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryDownloadInfoRequestedEvent is triggered when the app requests the download info for a media entry.", + " This is triggered before [AnimeEntryDownloadInfoEvent]." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEntryDownloadInfoEvent", + "formattedName": "Anime_AnimeEntryDownloadInfoEvent", + "package": "anime", + "fields": [ + { + "name": "EntryDownloadInfo", + "jsonName": "entryDownloadInfo", + "goType": "EntryDownloadInfo", + "typescriptType": "Anime_EntryDownloadInfo", + "usedTypescriptType": "Anime_EntryDownloadInfo", + "usedStructName": "anime.EntryDownloadInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEntryDownloadInfoEvent is triggered when the download info is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEpisodeCollectionRequestedEvent", + "formattedName": "Anime_AnimeEpisodeCollectionRequestedEvent", + "package": "anime", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Metadata", + "jsonName": "metadata", + "goType": "metadata.AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeCollection", + "jsonName": "episodeCollection", + "goType": "EpisodeCollection", + "typescriptType": "Anime_EpisodeCollection", + "usedTypescriptType": "Anime_EpisodeCollection", + "usedStructName": "anime.EpisodeCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEpisodeCollectionRequestedEvent is triggered when the episode collection is being requested.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeEpisodeCollectionEvent", + "formattedName": "Anime_AnimeEpisodeCollectionEvent", + "package": "anime", + "fields": [ + { + "name": "EpisodeCollection", + "jsonName": "episodeCollection", + "goType": "EpisodeCollection", + "typescriptType": "Anime_EpisodeCollection", + "usedTypescriptType": "Anime_EpisodeCollection", + "usedStructName": "anime.EpisodeCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeEpisodeCollectionEvent is triggered when the episode collection is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/hook_events.go", + "filename": "hook_events.go", + "name": "AnimeScheduleItemsEvent", + "formattedName": "Anime_AnimeScheduleItemsEvent", + "package": "anime", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Items", + "jsonName": "items", + "goType": "[]ScheduleItem", + "typescriptType": "Array\u003cAnime_ScheduleItem\u003e", + "usedTypescriptType": "Anime_ScheduleItem", + "usedStructName": "anime.ScheduleItem", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AnimeScheduleItemsEvent is triggered when the schedule items are being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/anime/localfile.go", + "filename": "localfile.go", + "name": "LocalFileType", + "formattedName": "Anime_LocalFileType", + "package": "anime", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"main\"", + "\"special\"", + "\"nc\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/library/anime/localfile.go", + "filename": "localfile.go", + "name": "LocalFile", + "formattedName": "Anime_LocalFile", + "package": "anime", + "fields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ParsedData", + "jsonName": "parsedInfo", + "goType": "LocalFileParsedData", + "typescriptType": "Anime_LocalFileParsedData", + "usedTypescriptType": "Anime_LocalFileParsedData", + "usedStructName": "anime.LocalFileParsedData", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ParsedFolderData", + "jsonName": "parsedFolderInfo", + "goType": "[]LocalFileParsedData", + "typescriptType": "Array\u003cAnime_LocalFileParsedData\u003e", + "usedTypescriptType": "Anime_LocalFileParsedData", + "usedStructName": "anime.LocalFileParsedData", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Metadata", + "jsonName": "metadata", + "goType": "LocalFileMetadata", + "typescriptType": "Anime_LocalFileMetadata", + "usedTypescriptType": "Anime_LocalFileMetadata", + "usedStructName": "anime.LocalFileMetadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Locked", + "jsonName": "locked", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Ignored", + "jsonName": "ignored", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Unused for now" + ] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/localfile.go", + "filename": "localfile.go", + "name": "LocalFileMetadata", + "formattedName": "Anime_LocalFileMetadata", + "package": "anime", + "fields": [ + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AniDBEpisode", + "jsonName": "aniDBEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "LocalFileType", + "typescriptType": "Anime_LocalFileType", + "usedTypescriptType": "Anime_LocalFileType", + "usedStructName": "anime.LocalFileType", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/localfile.go", + "filename": "localfile.go", + "name": "LocalFileParsedData", + "formattedName": "Anime_LocalFileParsedData", + "package": "anime", + "fields": [ + { + "name": "Original", + "jsonName": "original", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReleaseGroup", + "jsonName": "releaseGroup", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Season", + "jsonName": "season", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SeasonRange", + "jsonName": "seasonRange", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Part", + "jsonName": "part", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PartRange", + "jsonName": "partRange", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeRange", + "jsonName": "episodeRange", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeTitle", + "jsonName": "episodeTitle", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/localfile_wrapper.go", + "filename": "localfile_wrapper.go", + "name": "LocalFileWrapper", + "formattedName": "Anime_LocalFileWrapper", + "package": "anime", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalEntries", + "jsonName": "localEntries", + "goType": "[]LocalFileWrapperEntry", + "typescriptType": "Array\u003cAnime_LocalFileWrapperEntry\u003e", + "usedTypescriptType": "Anime_LocalFileWrapperEntry", + "usedStructName": "anime.LocalFileWrapperEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UnmatchedLocalFiles", + "jsonName": "unmatchedLocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/localfile_wrapper.go", + "filename": "localfile_wrapper.go", + "name": "LocalFileWrapperEntry", + "formattedName": "Anime_LocalFileWrapperEntry", + "package": "anime", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/missing_episodes.go", + "filename": "missing_episodes.go", + "name": "MissingEpisodes", + "formattedName": "Anime_MissingEpisodes", + "package": "anime", + "fields": [ + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "[]Episode", + "typescriptType": "Array\u003cAnime_Episode\u003e", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SilencedEpisodes", + "jsonName": "silencedEpisodes", + "goType": "[]Episode", + "typescriptType": "Array\u003cAnime_Episode\u003e", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/missing_episodes.go", + "filename": "missing_episodes.go", + "name": "NewMissingEpisodesOptions", + "formattedName": "Anime_NewMissingEpisodesOptions", + "package": "anime", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "AnimeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SilencedMediaIds", + "jsonName": "SilencedMediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/normalized_media.go", + "filename": "normalized_media.go", + "name": "NormalizedMedia", + "formattedName": "Anime_NormalizedMedia", + "package": "anime", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "anilist.BaseAnime" + ] + }, + { + "filepath": "../internal/library/anime/normalized_media.go", + "filename": "normalized_media.go", + "name": "NormalizedMediaCache", + "formattedName": "Anime_NormalizedMediaCache", + "package": "anime", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "" + ] + }, + { + "filepath": "../internal/library/anime/playlist.go", + "filename": "playlist.go", + "name": "Playlist", + "formattedName": "Anime_Playlist", + "package": "anime", + "fields": [ + { + "name": "DbId", + "jsonName": "dbId", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " DbId is the database ID of the models.PlaylistEntry" + ] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Name is the name of the playlist" + ] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [ + " LocalFiles is a list of local files in the playlist, in order" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/schedule.go", + "filename": "schedule.go", + "name": "ScheduleItem", + "formattedName": "Anime_ScheduleItem", + "package": "anime", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Time", + "jsonName": "time", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DateTime", + "jsonName": "dateTime", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsMovie", + "jsonName": "isMovie", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsSeasonFinale", + "jsonName": "isSeasonFinale", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/test_helpers.go", + "filename": "test_helpers.go", + "name": "MockHydratedLocalFileOptions", + "formattedName": "Anime_MockHydratedLocalFileOptions", + "package": "anime", + "fields": [ + { + "name": "FilePath", + "jsonName": "FilePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LibraryPath", + "jsonName": "LibraryPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MetadataEpisode", + "jsonName": "MetadataEpisode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MetadataAniDbEpisode", + "jsonName": "MetadataAniDbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MetadataType", + "jsonName": "MetadataType", + "goType": "LocalFileType", + "typescriptType": "Anime_LocalFileType", + "usedTypescriptType": "Anime_LocalFileType", + "usedStructName": "anime.LocalFileType", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/anime/test_helpers.go", + "filename": "test_helpers.go", + "name": "MockHydratedLocalFileWrapperOptionsMetadata", + "formattedName": "Anime_MockHydratedLocalFileWrapperOptionsMetadata", + "package": "anime", + "fields": [ + { + "name": "MetadataEpisode", + "jsonName": "MetadataEpisode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MetadataAniDbEpisode", + "jsonName": "MetadataAniDbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MetadataType", + "jsonName": "MetadataType", + "goType": "LocalFileType", + "typescriptType": "Anime_LocalFileType", + "usedTypescriptType": "Anime_LocalFileType", + "usedStructName": "anime.LocalFileType", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/autodownloader/autodownloader.go", + "filename": "autodownloader.go", + "name": "AutoDownloader", + "formattedName": "AutoDownloader_AutoDownloader", + "package": "autodownloader", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "torrentClientRepository", + "jsonName": "torrentClientRepository", + "goType": "torrent_client.Repository", + "typescriptType": "TorrentClient_Repository", + "usedTypescriptType": "TorrentClient_Repository", + "usedStructName": "torrent_client.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "torrentRepository", + "jsonName": "torrentRepository", + "goType": "torrent.Repository", + "typescriptType": "Torrent_Repository", + "usedTypescriptType": "Torrent_Repository", + "usedStructName": "torrent.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "debridClientRepository", + "jsonName": "debridClientRepository", + "goType": "debrid_client.Repository", + "typescriptType": "DebridClient_Repository", + "usedTypescriptType": "DebridClient_Repository", + "usedStructName": "debrid_client.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "database", + "jsonName": "database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeCollection", + "jsonName": "animeCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "models.AutoDownloaderSettings", + "typescriptType": "Models_AutoDownloaderSettings", + "usedTypescriptType": "Models_AutoDownloaderSettings", + "usedStructName": "models.AutoDownloaderSettings", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settingsUpdatedCh", + "jsonName": "settingsUpdatedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "stopCh", + "jsonName": "stopCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "startCh", + "jsonName": "startCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "debugTrace", + "jsonName": "debugTrace", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "isOffline", + "jsonName": "isOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/autodownloader/autodownloader.go", + "filename": "autodownloader.go", + "name": "NewAutoDownloaderOptions", + "formattedName": "AutoDownloader_NewAutoDownloaderOptions", + "package": "autodownloader", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentClientRepository", + "jsonName": "TorrentClientRepository", + "goType": "torrent_client.Repository", + "typescriptType": "TorrentClient_Repository", + "usedTypescriptType": "TorrentClient_Repository", + "usedStructName": "torrent_client.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentRepository", + "jsonName": "TorrentRepository", + "goType": "torrent.Repository", + "typescriptType": "Torrent_Repository", + "usedTypescriptType": "Torrent_Repository", + "usedStructName": "torrent.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DebridClientRepository", + "jsonName": "DebridClientRepository", + "goType": "debrid_client.Repository", + "typescriptType": "DebridClient_Repository", + "usedTypescriptType": "DebridClient_Repository", + "usedStructName": "debrid_client.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsOffline", + "jsonName": "IsOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/autodownloader/autodownloader_torrent.go", + "filename": "autodownloader_torrent.go", + "name": "NormalizedTorrent", + "formattedName": "AutoDownloader_NormalizedTorrent", + "package": "autodownloader", + "fields": [ + { + "name": "ParsedData", + "jsonName": "parsedData", + "goType": "habari.Metadata", + "typescriptType": "Habari_Metadata", + "usedTypescriptType": "Habari_Metadata", + "usedStructName": "habari.Metadata", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "magnet", + "jsonName": "magnet", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [ + " Access using GetMagnet()" + ] + } + ], + "comments": [], + "embeddedStructNames": [ + "hibiketorrent.AnimeTorrent" + ] + }, + { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderRunStartedEvent", + "formattedName": "AutoDownloader_AutoDownloaderRunStartedEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Rules", + "jsonName": "rules", + "goType": "[]anime.AutoDownloaderRule", + "typescriptType": "Array\u003cAnime_AutoDownloaderRule\u003e", + "usedTypescriptType": "Anime_AutoDownloaderRule", + "usedStructName": "anime.AutoDownloaderRule", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderRunStartedEvent is triggered when the autodownloader starts checking for new episodes.", + " Prevent default to abort the run." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderTorrentsFetchedEvent", + "formattedName": "AutoDownloader_AutoDownloaderTorrentsFetchedEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Torrents", + "jsonName": "torrents", + "goType": "[]NormalizedTorrent", + "typescriptType": "Array\u003cAutoDownloader_NormalizedTorrent\u003e", + "usedTypescriptType": "AutoDownloader_NormalizedTorrent", + "usedStructName": "autodownloader.NormalizedTorrent", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderTorrentsFetchedEvent is triggered at the beginning of a run, when the autodownloader fetches torrents from the provider." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderMatchVerifiedEvent", + "formattedName": "AutoDownloader_AutoDownloaderMatchVerifiedEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "NormalizedTorrent", + "typescriptType": "AutoDownloader_NormalizedTorrent", + "usedTypescriptType": "AutoDownloader_NormalizedTorrent", + "usedStructName": "autodownloader.NormalizedTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rule", + "jsonName": "rule", + "goType": "anime.AutoDownloaderRule", + "typescriptType": "Anime_AutoDownloaderRule", + "usedTypescriptType": "Anime_AutoDownloaderRule", + "usedStructName": "anime.AutoDownloaderRule", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ListEntry", + "jsonName": "listEntry", + "goType": "anilist.AnimeListEntry", + "typescriptType": "AL_AnimeListEntry", + "usedTypescriptType": "AL_AnimeListEntry", + "usedStructName": "anilist.AnimeListEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalEntry", + "jsonName": "localEntry", + "goType": "anime.LocalFileWrapperEntry", + "typescriptType": "Anime_LocalFileWrapperEntry", + "usedTypescriptType": "Anime_LocalFileWrapperEntry", + "usedStructName": "anime.LocalFileWrapperEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MatchFound", + "jsonName": "matchFound", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderMatchVerifiedEvent is triggered when a torrent is verified to follow a rule.", + " Prevent default to abort the download if the match is found." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderSettingsUpdatedEvent", + "formattedName": "AutoDownloader_AutoDownloaderSettingsUpdatedEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Settings", + "jsonName": "settings", + "goType": "models.AutoDownloaderSettings", + "typescriptType": "Models_AutoDownloaderSettings", + "usedTypescriptType": "Models_AutoDownloaderSettings", + "usedStructName": "models.AutoDownloaderSettings", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderSettingsUpdatedEvent is triggered when the autodownloader settings are updated" + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderBeforeDownloadTorrentEvent", + "formattedName": "AutoDownloader_AutoDownloaderBeforeDownloadTorrentEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "NormalizedTorrent", + "typescriptType": "AutoDownloader_NormalizedTorrent", + "usedTypescriptType": "AutoDownloader_NormalizedTorrent", + "usedStructName": "autodownloader.NormalizedTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rule", + "jsonName": "rule", + "goType": "anime.AutoDownloaderRule", + "typescriptType": "Anime_AutoDownloaderRule", + "usedTypescriptType": "Anime_AutoDownloaderRule", + "usedStructName": "anime.AutoDownloaderRule", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Items", + "jsonName": "items", + "goType": "[]models.AutoDownloaderItem", + "typescriptType": "Array\u003cModels_AutoDownloaderItem\u003e", + "usedTypescriptType": "Models_AutoDownloaderItem", + "usedStructName": "models.AutoDownloaderItem", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderBeforeDownloadTorrentEvent is triggered when the autodownloader is about to download a torrent.", + " Prevent default to abort the download." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/autodownloader/hook_events.go", + "filename": "hook_events.go", + "name": "AutoDownloaderAfterDownloadTorrentEvent", + "formattedName": "AutoDownloader_AutoDownloaderAfterDownloadTorrentEvent", + "package": "autodownloader", + "fields": [ + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "NormalizedTorrent", + "typescriptType": "AutoDownloader_NormalizedTorrent", + "usedTypescriptType": "AutoDownloader_NormalizedTorrent", + "usedStructName": "autodownloader.NormalizedTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rule", + "jsonName": "rule", + "goType": "anime.AutoDownloaderRule", + "typescriptType": "Anime_AutoDownloaderRule", + "usedTypescriptType": "Anime_AutoDownloaderRule", + "usedStructName": "anime.AutoDownloaderRule", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " AutoDownloaderAfterDownloadTorrentEvent is triggered when the autodownloader has downloaded a torrent." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/autoscanner/autoscanner.go", + "filename": "autoscanner.go", + "name": "AutoScanner", + "formattedName": "AutoScanner_AutoScanner", + "package": "autoscanner", + "fields": [ + { + "name": "fileActionCh", + "jsonName": "fileActionCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Used to notify the scanner that a file action has occurred." + ] + }, + { + "name": "waiting", + "jsonName": "waiting", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [ + " Used to prevent multiple scans from occurring at the same time." + ] + }, + { + "name": "missedAction", + "jsonName": "missedAction", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [ + " Used to indicate that a file action was missed while scanning." + ] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "scannedCh", + "jsonName": "scannedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "waitTime", + "jsonName": "waitTime", + "goType": "time.Duration", + "typescriptType": "Duration", + "usedTypescriptType": "Duration", + "usedStructName": "time.Duration", + "required": false, + "public": false, + "comments": [ + " Wait time to listen to additional changes before triggering a scan." + ] + }, + { + "name": "enabled", + "jsonName": "enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "models.LibrarySettings", + "typescriptType": "Models_LibrarySettings", + "usedTypescriptType": "Models_LibrarySettings", + "usedStructName": "models.LibrarySettings", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "platform", + "jsonName": "platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "db", + "jsonName": "db", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [ + " Database instance is required to update the local files." + ] + }, + { + "name": "autoDownloader", + "jsonName": "autoDownloader", + "goType": "autodownloader.AutoDownloader", + "typescriptType": "AutoDownloader_AutoDownloader", + "usedTypescriptType": "AutoDownloader_AutoDownloader", + "usedStructName": "autodownloader.AutoDownloader", + "required": false, + "public": false, + "comments": [ + " AutoDownloader instance is required to refresh queue." + ] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logsDir", + "jsonName": "logsDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/autoscanner/autoscanner.go", + "filename": "autoscanner.go", + "name": "NewAutoScannerOptions", + "formattedName": "AutoScanner_NewAutoScannerOptions", + "package": "autoscanner", + "fields": [ + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Enabled", + "jsonName": "Enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoDownloader", + "jsonName": "AutoDownloader", + "goType": "autodownloader.AutoDownloader", + "typescriptType": "AutoDownloader_AutoDownloader", + "usedTypescriptType": "AutoDownloader_AutoDownloader", + "usedStructName": "autodownloader.AutoDownloader", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WaitTime", + "jsonName": "WaitTime", + "goType": "time.Duration", + "typescriptType": "Duration", + "usedTypescriptType": "Duration", + "usedStructName": "time.Duration", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LogsDir", + "jsonName": "LogsDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/filesystem/mediapath.go", + "filename": "mediapath.go", + "name": "SeparatedFilePath", + "formattedName": "Filesystem_SeparatedFilePath", + "package": "filesystem", + "fields": [ + { + "name": "Filename", + "jsonName": "Filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Dirnames", + "jsonName": "Dirnames", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PrefixPath", + "jsonName": "PrefixPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/fillermanager/fillermanager.go", + "filename": "fillermanager.go", + "name": "FillerManager", + "formattedName": "FillerManager", + "package": "fillermanager", + "fields": [ + { + "name": "db", + "jsonName": "db", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "fillerApi", + "jsonName": "fillerApi", + "goType": "filler.API", + "typescriptType": "API", + "usedTypescriptType": "API", + "usedStructName": "filler.API", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/fillermanager/fillermanager.go", + "filename": "fillermanager.go", + "name": "NewFillerManagerOptions", + "formattedName": "NewFillerManagerOptions", + "package": "fillermanager", + "fields": [ + { + "name": "DB", + "jsonName": "DB", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/fillermanager/hook_events.go", + "filename": "hook_events.go", + "name": "HydrateFillerDataRequestedEvent", + "formattedName": "HydrateFillerDataRequestedEvent", + "package": "fillermanager", + "fields": [ + { + "name": "Entry", + "jsonName": "entry", + "goType": "anime.Entry", + "typescriptType": "Anime_Entry", + "usedTypescriptType": "Anime_Entry", + "usedStructName": "anime.Entry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " HydrateFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for an entry.", + " This is used by the local file episode list.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/fillermanager/hook_events.go", + "filename": "hook_events.go", + "name": "HydrateOnlinestreamFillerDataRequestedEvent", + "formattedName": "HydrateOnlinestreamFillerDataRequestedEvent", + "package": "fillermanager", + "fields": [ + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "[]onlinestream.Episode", + "typescriptType": "Array\u003cOnlinestream_Episode\u003e", + "usedTypescriptType": "Onlinestream_Episode", + "usedStructName": "onlinestream.Episode", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " HydrateOnlinestreamFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for online streaming episodes.", + " This is used by the online streaming episode list.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/fillermanager/hook_events.go", + "filename": "hook_events.go", + "name": "HydrateEpisodeFillerDataRequestedEvent", + "formattedName": "HydrateEpisodeFillerDataRequestedEvent", + "package": "fillermanager", + "fields": [ + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "[]anime.Episode", + "typescriptType": "Array\u003cAnime_Episode\u003e", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " HydrateEpisodeFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for specific episodes.", + " This is used by the torrent and debrid streaming episode list.", + " Prevent default to skip the default behavior and return your own data." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/playbackmanager/hook_events.go", + "filename": "hook_events.go", + "name": "LocalFilePlaybackRequestedEvent", + "formattedName": "PlaybackManager_LocalFilePlaybackRequestedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " LocalFilePlaybackRequestedEvent is triggered when a local file is requested to be played.", + " Prevent default to skip the default playback and override the playback." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/playbackmanager/hook_events.go", + "filename": "hook_events.go", + "name": "StreamPlaybackRequestedEvent", + "formattedName": "PlaybackManager_StreamPlaybackRequestedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "WindowTitle", + "jsonName": "windowTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AniDbEpisode", + "jsonName": "aniDbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " StreamPlaybackRequestedEvent is triggered when a stream is requested to be played.", + " Prevent default to skip the default playback and override the playback." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/playbackmanager/hook_events.go", + "filename": "hook_events.go", + "name": "PlaybackBeforeTrackingEvent", + "formattedName": "PlaybackManager_PlaybackBeforeTrackingEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "IsStream", + "jsonName": "isStream", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " PlaybackBeforeTrackingEvent is triggered just before the playback tracking starts.", + " Prevent default to skip playback tracking." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/playbackmanager/hook_events.go", + "filename": "hook_events.go", + "name": "PlaybackLocalFileDetailsRequestedEvent", + "formattedName": "PlaybackManager_PlaybackLocalFileDetailsRequestedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeListEntry", + "jsonName": "animeListEntry", + "goType": "anilist.AnimeListEntry", + "typescriptType": "AL_AnimeListEntry", + "usedTypescriptType": "AL_AnimeListEntry", + "usedStructName": "anilist.AnimeListEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFile", + "jsonName": "localFile", + "goType": "anime.LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFileWrapperEntry", + "jsonName": "localFileWrapperEntry", + "goType": "anime.LocalFileWrapperEntry", + "typescriptType": "Anime_LocalFileWrapperEntry", + "usedTypescriptType": "Anime_LocalFileWrapperEntry", + "usedStructName": "anime.LocalFileWrapperEntry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " PlaybackLocalFileDetailsRequestedEvent is triggered when the local files details for a specific path are requested.", + " This event is triggered right after the media player loads an episode.", + " The playback manager uses the local files details to track the progress, propose next episodes, etc.", + " In the current implementation, the details are fetched by selecting the local file from the database and making requests to retrieve the media and anime list entry.", + " Prevent default to skip the default fetching and override the details." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/playbackmanager/hook_events.go", + "filename": "hook_events.go", + "name": "PlaybackStreamDetailsRequestedEvent", + "formattedName": "PlaybackManager_PlaybackStreamDetailsRequestedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnimeListEntry", + "jsonName": "animeListEntry", + "goType": "anilist.AnimeListEntry", + "typescriptType": "AL_AnimeListEntry", + "usedTypescriptType": "AL_AnimeListEntry", + "usedStructName": "anilist.AnimeListEntry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " PlaybackStreamDetailsRequestedEvent is triggered when the stream details are requested.", + " Prevent default to skip the default fetching and override the details.", + " In the current implementation, the details are fetched by selecting the anime from the anime collection. If nothing is found, the stream is still tracked." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/playbackmanager/manual_tracking.go", + "filename": "manual_tracking.go", + "name": "ManualTrackingState", + "formattedName": "PlaybackManager_ManualTrackingState", + "package": "playbackmanager", + "fields": [ + { + "name": "EpisodeNumber", + "jsonName": "EpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CurrentProgress", + "jsonName": "CurrentProgress", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalEpisodes", + "jsonName": "TotalEpisodes", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/manual_tracking.go", + "filename": "manual_tracking.go", + "name": "StartManualProgressTrackingOptions", + "formattedName": "PlaybackManager_StartManualProgressTrackingOptions", + "package": "playbackmanager", + "fields": [ + { + "name": "ClientId", + "jsonName": "ClientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "EpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/play_random_episode.go", + "filename": "play_random_episode.go", + "name": "StartRandomVideoOptions", + "formattedName": "PlaybackManager_StartRandomVideoOptions", + "package": "playbackmanager", + "fields": [ + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ClientId", + "jsonName": "ClientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "PlaybackType", + "formattedName": "PlaybackManager_PlaybackType", + "package": "playbackmanager", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"localfile\"", + "\"stream\"", + "\"manual\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "PlaybackManager", + "formattedName": "PlaybackManager_PlaybackManager", + "package": "playbackmanager", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaPlayerRepository", + "jsonName": "MediaPlayerRepository", + "goType": "mediaplayer.Repository", + "typescriptType": "Repository", + "usedTypescriptType": "Repository", + "usedStructName": "mediaplayer.Repository", + "required": false, + "public": true, + "comments": [ + " MediaPlayerRepository is used to control the media player" + ] + }, + { + "name": "continuityManager", + "jsonName": "continuityManager", + "goType": "continuity.Manager", + "typescriptType": "Continuity_Manager", + "usedTypescriptType": "Continuity_Manager", + "usedStructName": "continuity.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "Settings", + "typescriptType": "PlaybackManager_Settings", + "usedTypescriptType": "PlaybackManager_Settings", + "usedStructName": "playbackmanager.Settings", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "discordPresence", + "jsonName": "discordPresence", + "goType": "discordrpc_presence.Presence", + "typescriptType": "DiscordRPC_Presence", + "usedTypescriptType": "DiscordRPC_Presence", + "usedStructName": "discordrpc_presence.Presence", + "required": false, + "public": false, + "comments": [ + " DiscordPresence is used to update the user's Discord presence" + ] + }, + { + "name": "mediaPlayerRepoSubscriber", + "jsonName": "mediaPlayerRepoSubscriber", + "goType": "mediaplayer.RepositorySubscriber", + "typescriptType": "RepositorySubscriber", + "usedTypescriptType": "RepositorySubscriber", + "usedStructName": "mediaplayer.RepositorySubscriber", + "required": false, + "public": false, + "comments": [ + " Used to listen for media player events" + ] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "platform", + "jsonName": "platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "refreshAnimeCollectionFunc", + "jsonName": "refreshAnimeCollectionFunc", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " This function is called to refresh the AniList collection" + ] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "eventMu", + "jsonName": "eventMu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cancel", + "jsonName": "cancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "historyMap", + "jsonName": "historyMap", + "goType": "map[string]PlaybackState", + "typescriptType": "Record\u003cstring, PlaybackManager_PlaybackState\u003e", + "usedTypescriptType": "PlaybackManager_PlaybackState", + "usedStructName": "playbackmanager.PlaybackState", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentPlaybackType", + "jsonName": "currentPlaybackType", + "goType": "PlaybackType", + "typescriptType": "PlaybackManager_PlaybackType", + "usedTypescriptType": "PlaybackManager_PlaybackType", + "usedStructName": "playbackmanager.PlaybackType", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "currentMediaPlaybackStatus", + "jsonName": "currentMediaPlaybackStatus", + "goType": "mediaplayer.PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": false, + "comments": [ + " The current video playback status (can be nil)" + ] + }, + { + "name": "autoPlayMu", + "jsonName": "autoPlayMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "nextEpisodeLocalFile", + "jsonName": "nextEpisodeLocalFile", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " The next episode's local file (for local file playback)" + ] + }, + { + "name": "currentMediaListEntry", + "jsonName": "currentMediaListEntry", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " List Entry for the current video playback" + ] + }, + { + "name": "currentLocalFile", + "jsonName": "currentLocalFile", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Local file for the current video playback" + ] + }, + { + "name": "currentLocalFileWrapperEntry", + "jsonName": "currentLocalFileWrapperEntry", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " This contains the current media entry local file data" + ] + }, + { + "name": "currentStreamEpisode", + "jsonName": "currentStreamEpisode", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "currentStreamMedia", + "jsonName": "currentStreamMedia", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "currentStreamAniDbEpisode", + "jsonName": "currentStreamAniDbEpisode", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "manualTrackingCtx", + "jsonName": "manualTrackingCtx", + "goType": "context.Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "context.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "manualTrackingCtxCancel", + "jsonName": "manualTrackingCtxCancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "manualTrackingPlaybackState", + "jsonName": "manualTrackingPlaybackState", + "goType": "PlaybackState", + "typescriptType": "PlaybackManager_PlaybackState", + "usedTypescriptType": "PlaybackManager_PlaybackState", + "usedStructName": "playbackmanager.PlaybackState", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "currentManualTrackingState", + "jsonName": "currentManualTrackingState", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "manualTrackingWg", + "jsonName": "manualTrackingWg", + "goType": "sync.WaitGroup", + "typescriptType": "WaitGroup", + "usedTypescriptType": "WaitGroup", + "usedStructName": "sync.WaitGroup", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playlistHub", + "jsonName": "playlistHub", + "goType": "playlistHub", + "typescriptType": "PlaybackManager_playlistHub", + "usedTypescriptType": "PlaybackManager_playlistHub", + "usedStructName": "playbackmanager.playlistHub", + "required": false, + "public": false, + "comments": [ + " The playlist hub" + ] + }, + { + "name": "isOffline", + "jsonName": "isOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeCollection", + "jsonName": "animeCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "playbackStatusSubscribers", + "jsonName": "playbackStatusSubscribers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "PlaybackStatusSubscriber", + "formattedName": "PlaybackManager_PlaybackStatusSubscriber", + "package": "playbackmanager", + "fields": [ + { + "name": "EventCh", + "jsonName": "EventCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "canceled", + "jsonName": "canceled", + "goType": "atomic.Bool", + "typescriptType": "Bool", + "usedTypescriptType": "Bool", + "usedStructName": "atomic.Bool", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "PlaybackStartingEvent", + "formattedName": "PlaybackManager_PlaybackStartingEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Filepath", + "jsonName": "Filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PlaybackType", + "jsonName": "PlaybackType", + "goType": "PlaybackType", + "typescriptType": "PlaybackManager_PlaybackType", + "usedTypescriptType": "PlaybackManager_PlaybackType", + "usedStructName": "playbackmanager.PlaybackType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AniDbEpisode", + "jsonName": "AniDbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "EpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WindowTitle", + "jsonName": "WindowTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "PlaybackStatusChangedEvent", + "formattedName": "PlaybackManager_PlaybackStatusChangedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Status", + "jsonName": "Status", + "goType": "mediaplayer.PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "State", + "goType": "PlaybackState", + "typescriptType": "PlaybackManager_PlaybackState", + "usedTypescriptType": "PlaybackManager_PlaybackState", + "usedStructName": "playbackmanager.PlaybackState", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "VideoStartedEvent", + "formattedName": "PlaybackManager_VideoStartedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Filename", + "jsonName": "Filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filepath", + "jsonName": "Filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "VideoStoppedEvent", + "formattedName": "PlaybackManager_VideoStoppedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Reason", + "jsonName": "Reason", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "VideoCompletedEvent", + "formattedName": "PlaybackManager_VideoCompletedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Filename", + "jsonName": "Filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "StreamStateChangedEvent", + "formattedName": "PlaybackManager_StreamStateChangedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "State", + "jsonName": "State", + "goType": "PlaybackState", + "typescriptType": "PlaybackManager_PlaybackState", + "usedTypescriptType": "PlaybackManager_PlaybackState", + "usedStructName": "playbackmanager.PlaybackState", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "StreamStatusChangedEvent", + "formattedName": "PlaybackManager_StreamStatusChangedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Status", + "jsonName": "Status", + "goType": "mediaplayer.PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "StreamStartedEvent", + "formattedName": "PlaybackManager_StreamStartedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Filename", + "jsonName": "Filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filepath", + "jsonName": "Filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "StreamStoppedEvent", + "formattedName": "PlaybackManager_StreamStoppedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Reason", + "jsonName": "Reason", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "StreamCompletedEvent", + "formattedName": "PlaybackManager_StreamCompletedEvent", + "package": "playbackmanager", + "fields": [ + { + "name": "Filename", + "jsonName": "Filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "PlaybackStateType", + "formattedName": "PlaybackManager_PlaybackStateType", + "package": "playbackmanager", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "PlaybackState", + "formattedName": "PlaybackManager_PlaybackState", + "package": "playbackmanager", + "fields": [ + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " The episode number" + ] + }, + { + "name": "AniDbEpisode", + "jsonName": "aniDbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " The AniDB episode number" + ] + }, + { + "name": "MediaTitle", + "jsonName": "mediaTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " The title of the media" + ] + }, + { + "name": "MediaCoverImage", + "jsonName": "mediaCoverImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " The cover image of the media" + ] + }, + { + "name": "MediaTotalEpisodes", + "jsonName": "mediaTotalEpisodes", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " The total number of episodes" + ] + }, + { + "name": "Filename", + "jsonName": "filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " The filename" + ] + }, + { + "name": "CompletionPercentage", + "jsonName": "completionPercentage", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " The completion percentage" + ] + }, + { + "name": "CanPlayNext", + "jsonName": "canPlayNext", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Whether the next episode can be played" + ] + }, + { + "name": "ProgressUpdated", + "jsonName": "progressUpdated", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Whether the progress has been updated" + ] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " The media ID" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "NewPlaybackManagerOptions", + "formattedName": "PlaybackManager_NewPlaybackManagerOptions", + "package": "playbackmanager", + "fields": [ + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RefreshAnimeCollectionFunc", + "jsonName": "RefreshAnimeCollectionFunc", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [ + " This function is called to refresh the AniList collection" + ] + }, + { + "name": "DiscordPresence", + "jsonName": "DiscordPresence", + "goType": "discordrpc_presence.Presence", + "typescriptType": "DiscordRPC_Presence", + "usedTypescriptType": "DiscordRPC_Presence", + "usedStructName": "discordrpc_presence.Presence", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsOffline", + "jsonName": "IsOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ContinuityManager", + "jsonName": "ContinuityManager", + "goType": "continuity.Manager", + "typescriptType": "Continuity_Manager", + "usedTypescriptType": "Continuity_Manager", + "usedStructName": "continuity.Manager", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "Settings", + "formattedName": "PlaybackManager_Settings", + "package": "playbackmanager", + "fields": [ + { + "name": "AutoPlayNextEpisode", + "jsonName": "AutoPlayNextEpisode", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playback_manager.go", + "filename": "playback_manager.go", + "name": "StartPlayingOptions", + "formattedName": "PlaybackManager_StartPlayingOptions", + "package": "playbackmanager", + "fields": [ + { + "name": "Payload", + "jsonName": "Payload", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " url or path" + ] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ClientId", + "jsonName": "ClientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playlist.go", + "filename": "playlist.go", + "name": "PlaylistState", + "formattedName": "PlaybackManager_PlaylistState", + "package": "playbackmanager", + "fields": [ + { + "name": "Current", + "jsonName": "current", + "goType": "PlaylistStateItem", + "typescriptType": "PlaybackManager_PlaylistStateItem", + "usedTypescriptType": "PlaybackManager_PlaylistStateItem", + "usedStructName": "playbackmanager.PlaylistStateItem", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Next", + "jsonName": "next", + "goType": "PlaylistStateItem", + "typescriptType": "PlaybackManager_PlaylistStateItem", + "usedTypescriptType": "PlaybackManager_PlaylistStateItem", + "usedStructName": "playbackmanager.PlaylistStateItem", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Remaining", + "jsonName": "remaining", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/playlist.go", + "filename": "playlist.go", + "name": "PlaylistStateItem", + "formattedName": "PlaybackManager_PlaylistStateItem", + "package": "playbackmanager", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaImage", + "jsonName": "mediaImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/stream_magnet.go", + "filename": "stream_magnet.go", + "name": "StreamMagnetRequestOptions", + "formattedName": "PlaybackManager_StreamMagnetRequestOptions", + "package": "playbackmanager", + "fields": [ + { + "name": "MagnetLink", + "jsonName": "magnet_link", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " magnet link to stream" + ] + }, + { + "name": "OptionalMediaId", + "jsonName": "optionalMediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [ + " optional media ID to associate with the magnet link" + ] + }, + { + "name": "Untracked", + "jsonName": "untracked", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/stream_magnet.go", + "filename": "stream_magnet.go", + "name": "TrackedStreamMagnetRequestResponse", + "formattedName": "PlaybackManager_TrackedStreamMagnetRequestResponse", + "package": "playbackmanager", + "fields": [ + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " episode number of the magnet link" + ] + }, + { + "name": "EpisodeCollection", + "jsonName": "episodeCollection", + "goType": "anime.EpisodeCollection", + "typescriptType": "Anime_EpisodeCollection", + "usedTypescriptType": "Anime_EpisodeCollection", + "usedStructName": "anime.EpisodeCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/playbackmanager/stream_magnet.go", + "filename": "stream_magnet.go", + "name": "TrackedStreamMagnetOptions", + "formattedName": "PlaybackManager_TrackedStreamMagnetOptions", + "package": "playbackmanager", + "fields": [ + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AniDBEpisode", + "jsonName": "anidbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanStartedEvent", + "formattedName": "Scanner_ScanStartedEvent", + "package": "scanner", + "fields": [ + { + "name": "LibraryPath", + "jsonName": "libraryPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "OtherLibraryPaths", + "jsonName": "otherLibraryPaths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Enhanced", + "jsonName": "enhanced", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SkipLocked", + "jsonName": "skipLocked", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SkipIgnored", + "jsonName": "skipIgnored", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanStartedEvent is triggered when the scanning process begins.", + " Prevent default to skip the rest of the scanning process and return the local files." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanFilePathsRetrievedEvent", + "formattedName": "Scanner_ScanFilePathsRetrievedEvent", + "package": "scanner", + "fields": [ + { + "name": "FilePaths", + "jsonName": "filePaths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanFilePathsRetrievedEvent is triggered when the file paths to scan are retrieved.", + " The event includes file paths from all directories to scan.", + " The event includes file paths of local files that will be skipped." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanLocalFilesParsedEvent", + "formattedName": "Scanner_ScanLocalFilesParsedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanLocalFilesParsedEvent is triggered right after the file paths are parsed into local file objects.", + " The event does not include local files that are skipped." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanCompletedEvent", + "formattedName": "Scanner_ScanCompletedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " in milliseconds" + ] + } + ], + "comments": [ + " ScanCompletedEvent is triggered when the scanning process finishes.", + " The event includes all the local files (skipped and scanned) to be inserted as a new entry.", + " Right after this event, the local files will be inserted as a new entry." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanMediaFetcherStartedEvent", + "formattedName": "Scanner_ScanMediaFetcherStartedEvent", + "package": "scanner", + "fields": [ + { + "name": "Enhanced", + "jsonName": "enhanced", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanMediaFetcherStartedEvent is triggered right before Seanime starts fetching media to be matched against the local files." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanMediaFetcherCompletedEvent", + "formattedName": "Scanner_ScanMediaFetcherCompletedEvent", + "package": "scanner", + "fields": [ + { + "name": "AllMedia", + "jsonName": "allMedia", + "goType": "[]anilist.CompleteAnime", + "typescriptType": "Array\u003cAL_CompleteAnime\u003e", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UnknownMediaIds", + "jsonName": "unknownMediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanMediaFetcherCompletedEvent is triggered when the media fetcher completes.", + " The event includes all the media fetched from AniList.", + " The event includes the media IDs that are not in the user's collection." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanMatchingStartedEvent", + "formattedName": "Scanner_ScanMatchingStartedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NormalizedMedia", + "jsonName": "normalizedMedia", + "goType": "[]anime.NormalizedMedia", + "typescriptType": "Array\u003cAnime_NormalizedMedia\u003e", + "usedTypescriptType": "Anime_NormalizedMedia", + "usedStructName": "anime.NormalizedMedia", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Algorithm", + "jsonName": "algorithm", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Threshold", + "jsonName": "threshold", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanMatchingStartedEvent is triggered when the matching process begins.", + " Prevent default to skip the default matching, in which case modified local files will be used." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanLocalFileMatchedEvent", + "formattedName": "Scanner_ScanLocalFileMatchedEvent", + "package": "scanner", + "fields": [ + { + "name": "Match", + "jsonName": "match", + "goType": "anime.NormalizedMedia", + "typescriptType": "Anime_NormalizedMedia", + "usedTypescriptType": "Anime_NormalizedMedia", + "usedStructName": "anime.NormalizedMedia", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Found", + "jsonName": "found", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFile", + "jsonName": "localFile", + "goType": "anime.LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanLocalFileMatchedEvent is triggered when a local file is matched with media and before the match is analyzed.", + " Prevent default to skip the default analysis and override the match." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanMatchingCompletedEvent", + "formattedName": "Scanner_ScanMatchingCompletedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanMatchingCompletedEvent is triggered when the matching process completes." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanHydrationStartedEvent", + "formattedName": "Scanner_ScanHydrationStartedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AllMedia", + "jsonName": "allMedia", + "goType": "[]anime.NormalizedMedia", + "typescriptType": "Array\u003cAnime_NormalizedMedia\u003e", + "usedTypescriptType": "Anime_NormalizedMedia", + "usedStructName": "anime.NormalizedMedia", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanHydrationStartedEvent is triggered when the file hydration process begins.", + " Prevent default to skip the rest of the hydration process, in which case the event's local files will be used." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanLocalFileHydrationStartedEvent", + "formattedName": "Scanner_ScanLocalFileHydrationStartedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFile", + "jsonName": "localFile", + "goType": "anime.LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anime.NormalizedMedia", + "typescriptType": "Anime_NormalizedMedia", + "usedTypescriptType": "Anime_NormalizedMedia", + "usedStructName": "anime.NormalizedMedia", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanLocalFileHydrationStartedEvent is triggered when a local file's metadata is about to be hydrated.", + " Prevent default to skip the default hydration and override the hydration." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hook_events.go", + "filename": "hook_events.go", + "name": "ScanLocalFileHydratedEvent", + "formattedName": "Scanner_ScanLocalFileHydratedEvent", + "package": "scanner", + "fields": [ + { + "name": "LocalFile", + "jsonName": "localFile", + "goType": "anime.LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ScanLocalFileHydratedEvent is triggered when a local file's metadata is hydrated" + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/library/scanner/hydrator.go", + "filename": "hydrator.go", + "name": "FileHydrator", + "formattedName": "Scanner_FileHydrator", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [ + " Local files to hydrate" + ] + }, + { + "name": "AllMedia", + "jsonName": "AllMedia", + "goType": "[]anime.NormalizedMedia", + "typescriptType": "Array\u003cAnime_NormalizedMedia\u003e", + "usedTypescriptType": "Anime_NormalizedMedia", + "usedStructName": "anime.NormalizedMedia", + "required": false, + "public": true, + "comments": [ + " All media used to hydrate local files" + ] + }, + { + "name": "CompleteAnimeCache", + "jsonName": "CompleteAnimeCache", + "goType": "anilist.CompleteAnimeCache", + "typescriptType": "AL_CompleteAnimeCache", + "usedTypescriptType": "AL_CompleteAnimeCache", + "usedStructName": "anilist.CompleteAnimeCache", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnilistRateLimiter", + "jsonName": "AnilistRateLimiter", + "goType": "limiter.Limiter", + "typescriptType": "Limiter", + "usedTypescriptType": "Limiter", + "usedStructName": "limiter.Limiter", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScanLogger", + "jsonName": "ScanLogger", + "goType": "ScanLogger", + "typescriptType": "Scanner_ScanLogger", + "usedTypescriptType": "Scanner_ScanLogger", + "usedStructName": "scanner.ScanLogger", + "required": false, + "public": true, + "comments": [ + " optional" + ] + }, + { + "name": "ScanSummaryLogger", + "jsonName": "ScanSummaryLogger", + "goType": "summary.ScanSummaryLogger", + "typescriptType": "Summary_ScanSummaryLogger", + "usedTypescriptType": "Summary_ScanSummaryLogger", + "usedStructName": "summary.ScanSummaryLogger", + "required": false, + "public": true, + "comments": [ + " optional" + ] + }, + { + "name": "ForceMediaId", + "jsonName": "ForceMediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " optional - force all local files to have this media ID" + ] + } + ], + "comments": [ + " FileHydrator hydrates the metadata of all (matched) LocalFiles.", + " LocalFiles should already have their media ID hydrated." + ] + }, + { + "filepath": "../internal/library/scanner/matcher.go", + "filename": "matcher.go", + "name": "Matcher", + "formattedName": "Scanner_Matcher", + "package": "scanner", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaContainer", + "jsonName": "MediaContainer", + "goType": "MediaContainer", + "typescriptType": "Scanner_MediaContainer", + "usedTypescriptType": "Scanner_MediaContainer", + "usedStructName": "scanner.MediaContainer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompleteAnimeCache", + "jsonName": "CompleteAnimeCache", + "goType": "anilist.CompleteAnimeCache", + "typescriptType": "AL_CompleteAnimeCache", + "usedTypescriptType": "AL_CompleteAnimeCache", + "usedStructName": "anilist.CompleteAnimeCache", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScanLogger", + "jsonName": "ScanLogger", + "goType": "ScanLogger", + "typescriptType": "Scanner_ScanLogger", + "usedTypescriptType": "Scanner_ScanLogger", + "usedStructName": "scanner.ScanLogger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScanSummaryLogger", + "jsonName": "ScanSummaryLogger", + "goType": "summary.ScanSummaryLogger", + "typescriptType": "Summary_ScanSummaryLogger", + "usedTypescriptType": "Summary_ScanSummaryLogger", + "usedStructName": "summary.ScanSummaryLogger", + "required": false, + "public": true, + "comments": [ + " optional" + ] + }, + { + "name": "Algorithm", + "jsonName": "Algorithm", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Threshold", + "jsonName": "Threshold", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/scanner/media_container.go", + "filename": "media_container.go", + "name": "MediaContainerOptions", + "formattedName": "Scanner_MediaContainerOptions", + "package": "scanner", + "fields": [ + { + "name": "AllMedia", + "jsonName": "AllMedia", + "goType": "[]anilist.CompleteAnime", + "typescriptType": "Array\u003cAL_CompleteAnime\u003e", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScanLogger", + "jsonName": "ScanLogger", + "goType": "ScanLogger", + "typescriptType": "Scanner_ScanLogger", + "usedTypescriptType": "Scanner_ScanLogger", + "usedStructName": "scanner.ScanLogger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/scanner/media_container.go", + "filename": "media_container.go", + "name": "MediaContainer", + "formattedName": "Scanner_MediaContainer", + "package": "scanner", + "fields": [ + { + "name": "NormalizedMedia", + "jsonName": "NormalizedMedia", + "goType": "[]anime.NormalizedMedia", + "typescriptType": "Array\u003cAnime_NormalizedMedia\u003e", + "usedTypescriptType": "Anime_NormalizedMedia", + "usedStructName": "anime.NormalizedMedia", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScanLogger", + "jsonName": "ScanLogger", + "goType": "ScanLogger", + "typescriptType": "Scanner_ScanLogger", + "usedTypescriptType": "Scanner_ScanLogger", + "usedStructName": "scanner.ScanLogger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "engTitles", + "jsonName": "engTitles", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "romTitles", + "jsonName": "romTitles", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "synonyms", + "jsonName": "synonyms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "allMedia", + "jsonName": "allMedia", + "goType": "[]anilist.CompleteAnime", + "typescriptType": "Array\u003cAL_CompleteAnime\u003e", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/scanner/media_fetcher.go", + "filename": "media_fetcher.go", + "name": "MediaFetcher", + "formattedName": "Scanner_MediaFetcher", + "package": "scanner", + "fields": [ + { + "name": "AllMedia", + "jsonName": "AllMedia", + "goType": "[]anilist.CompleteAnime", + "typescriptType": "Array\u003cAL_CompleteAnime\u003e", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CollectionMediaIds", + "jsonName": "CollectionMediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UnknownMediaIds", + "jsonName": "UnknownMediaIds", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [ + " Media IDs that are not in the user's collection" + ] + }, + { + "name": "AnimeCollectionWithRelations", + "jsonName": "AnimeCollectionWithRelations", + "goType": "anilist.AnimeCollectionWithRelations", + "typescriptType": "AL_AnimeCollectionWithRelations", + "usedTypescriptType": "AL_AnimeCollectionWithRelations", + "usedStructName": "anilist.AnimeCollectionWithRelations", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScanLogger", + "jsonName": "ScanLogger", + "goType": "ScanLogger", + "typescriptType": "Scanner_ScanLogger", + "usedTypescriptType": "Scanner_ScanLogger", + "usedStructName": "scanner.ScanLogger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MediaFetcher holds all anilist.BaseAnime that will be used for the comparison process" + ] + }, + { + "filepath": "../internal/library/scanner/media_fetcher.go", + "filename": "media_fetcher.go", + "name": "MediaFetcherOptions", + "formattedName": "Scanner_MediaFetcherOptions", + "package": "scanner", + "fields": [ + { + "name": "Enhanced", + "jsonName": "Enhanced", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompleteAnimeCache", + "jsonName": "CompleteAnimeCache", + "goType": "anilist.CompleteAnimeCache", + "typescriptType": "AL_CompleteAnimeCache", + "usedTypescriptType": "AL_CompleteAnimeCache", + "usedStructName": "anilist.CompleteAnimeCache", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnilistRateLimiter", + "jsonName": "AnilistRateLimiter", + "goType": "limiter.Limiter", + "typescriptType": "Limiter", + "usedTypescriptType": "Limiter", + "usedStructName": "limiter.Limiter", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DisableAnimeCollection", + "jsonName": "DisableAnimeCollection", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ScanLogger", + "jsonName": "ScanLogger", + "goType": "ScanLogger", + "typescriptType": "Scanner_ScanLogger", + "usedTypescriptType": "Scanner_ScanLogger", + "usedStructName": "scanner.ScanLogger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/scanner/media_tree_analysis.go", + "filename": "media_tree_analysis.go", + "name": "MediaTreeAnalysisOptions", + "formattedName": "Scanner_MediaTreeAnalysisOptions", + "package": "scanner", + "fields": [ + { + "name": "tree", + "jsonName": "tree", + "goType": "anilist.CompleteAnimeRelationTree", + "typescriptType": "AL_CompleteAnimeRelationTree", + "usedTypescriptType": "AL_CompleteAnimeRelationTree", + "usedStructName": "anilist.CompleteAnimeRelationTree", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "rateLimiter", + "jsonName": "rateLimiter", + "goType": "limiter.Limiter", + "typescriptType": "Limiter", + "usedTypescriptType": "Limiter", + "usedStructName": "limiter.Limiter", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/scanner/media_tree_analysis.go", + "filename": "media_tree_analysis.go", + "name": "MediaTreeAnalysis", + "formattedName": "Scanner_MediaTreeAnalysis", + "package": "scanner", + "fields": [ + { + "name": "branches", + "jsonName": "branches", + "goType": "[]MediaTreeAnalysisBranch", + "typescriptType": "Array\u003cScanner_MediaTreeAnalysisBranch\u003e", + "usedTypescriptType": "Scanner_MediaTreeAnalysisBranch", + "usedStructName": "scanner.MediaTreeAnalysisBranch", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/scanner/media_tree_analysis.go", + "filename": "media_tree_analysis.go", + "name": "MediaTreeAnalysisBranch", + "formattedName": "Scanner_MediaTreeAnalysisBranch", + "package": "scanner", + "fields": [ + { + "name": "media", + "jsonName": "media", + "goType": "anilist.CompleteAnime", + "typescriptType": "AL_CompleteAnime", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeMetadata", + "jsonName": "animeMetadata", + "goType": "metadata.AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "minPartAbsoluteEpisodeNumber", + "jsonName": "minPartAbsoluteEpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "maxPartAbsoluteEpisodeNumber", + "jsonName": "maxPartAbsoluteEpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "minAbsoluteEpisode", + "jsonName": "minAbsoluteEpisode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "maxAbsoluteEpisode", + "jsonName": "maxAbsoluteEpisode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "totalEpisodeCount", + "jsonName": "totalEpisodeCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "noAbsoluteEpisodesFound", + "jsonName": "noAbsoluteEpisodesFound", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/scanner/scan.go", + "filename": "scan.go", + "name": "Scanner", + "formattedName": "Scanner_Scanner", + "package": "scanner", + "fields": [ + { + "name": "DirPath", + "jsonName": "DirPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "OtherDirPaths", + "jsonName": "OtherDirPaths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Enhanced", + "jsonName": "Enhanced", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ExistingLocalFiles", + "jsonName": "ExistingLocalFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SkipLockedFiles", + "jsonName": "SkipLockedFiles", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SkipIgnoredFiles", + "jsonName": "SkipIgnoredFiles", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ScanSummaryLogger", + "jsonName": "ScanSummaryLogger", + "goType": "summary.ScanSummaryLogger", + "typescriptType": "Summary_ScanSummaryLogger", + "usedTypescriptType": "Summary_ScanSummaryLogger", + "usedStructName": "summary.ScanSummaryLogger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScanLogger", + "jsonName": "ScanLogger", + "goType": "ScanLogger", + "typescriptType": "Scanner_ScanLogger", + "usedTypescriptType": "Scanner_ScanLogger", + "usedStructName": "scanner.ScanLogger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MatchingThreshold", + "jsonName": "MatchingThreshold", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MatchingAlgorithm", + "jsonName": "MatchingAlgorithm", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/scanner/scan_logger.go", + "filename": "scan_logger.go", + "name": "ScanLogger", + "formattedName": "Scanner_ScanLogger", + "package": "scanner", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logFile", + "jsonName": "logFile", + "goType": "os.File", + "typescriptType": "File", + "usedTypescriptType": "File", + "usedStructName": "os.File", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "buffer", + "jsonName": "buffer", + "goType": "bytes.Buffer", + "typescriptType": "Buffer", + "usedTypescriptType": "Buffer", + "usedStructName": "bytes.Buffer", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " ScanLogger is a custom logger struct for scanning operations." + ] + }, + { + "filepath": "../internal/library/scanner/watcher.go", + "filename": "watcher.go", + "name": "Watcher", + "formattedName": "Scanner_Watcher", + "package": "scanner", + "fields": [ + { + "name": "Watcher", + "jsonName": "Watcher", + "goType": "fsnotify.Watcher", + "typescriptType": "Watcher", + "usedTypescriptType": "Watcher", + "usedStructName": "fsnotify.Watcher", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TotalSize", + "jsonName": "TotalSize", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Watcher is a custom file system event watcher" + ] + }, + { + "filepath": "../internal/library/scanner/watcher.go", + "filename": "watcher.go", + "name": "NewWatcherOptions", + "formattedName": "Scanner_NewWatcherOptions", + "package": "scanner", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/scanner/watcher.go", + "filename": "watcher.go", + "name": "WatchLibraryFilesOptions", + "formattedName": "Scanner_WatchLibraryFilesOptions", + "package": "scanner", + "fields": [ + { + "name": "LibraryPaths", + "jsonName": "LibraryPaths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/summary/scan_summary.go", + "filename": "scan_summary.go", + "name": "LogType", + "formattedName": "Summary_LogType", + "package": "summary", + "fields": [], + "aliasOf": { + "goType": "int", + "typescriptType": "number", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/library/summary/scan_summary.go", + "filename": "scan_summary.go", + "name": "ScanSummaryLogger", + "formattedName": "Summary_ScanSummaryLogger", + "package": "summary", + "fields": [ + { + "name": "Logs", + "jsonName": "Logs", + "goType": "[]ScanSummaryLog", + "typescriptType": "Array\u003cSummary_ScanSummaryLog\u003e", + "usedTypescriptType": "Summary_ScanSummaryLog", + "usedStructName": "summary.ScanSummaryLog", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AllMedia", + "jsonName": "AllMedia", + "goType": "[]anime.NormalizedMedia", + "typescriptType": "Array\u003cAnime_NormalizedMedia\u003e", + "usedTypescriptType": "Anime_NormalizedMedia", + "usedStructName": "anime.NormalizedMedia", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeCollection", + "jsonName": "AnimeCollection", + "goType": "anilist.AnimeCollectionWithRelations", + "typescriptType": "AL_AnimeCollectionWithRelations", + "usedTypescriptType": "AL_AnimeCollectionWithRelations", + "usedStructName": "anilist.AnimeCollectionWithRelations", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/summary/scan_summary.go", + "filename": "scan_summary.go", + "name": "ScanSummaryLog", + "formattedName": "Summary_ScanSummaryLog", + "package": "summary", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FilePath", + "jsonName": "filePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Level", + "jsonName": "level", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Message", + "jsonName": "message", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/summary/scan_summary.go", + "filename": "scan_summary.go", + "name": "ScanSummary", + "formattedName": "Summary_ScanSummary", + "package": "summary", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Groups", + "jsonName": "groups", + "goType": "[]ScanSummaryGroup", + "typescriptType": "Array\u003cSummary_ScanSummaryGroup\u003e", + "usedTypescriptType": "Summary_ScanSummaryGroup", + "usedStructName": "summary.ScanSummaryGroup", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UnmatchedFiles", + "jsonName": "unmatchedFiles", + "goType": "[]ScanSummaryFile", + "typescriptType": "Array\u003cSummary_ScanSummaryFile\u003e", + "usedTypescriptType": "Summary_ScanSummaryFile", + "usedStructName": "summary.ScanSummaryFile", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/summary/scan_summary.go", + "filename": "scan_summary.go", + "name": "ScanSummaryFile", + "formattedName": "Summary_ScanSummaryFile", + "package": "summary", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFile", + "jsonName": "localFile", + "goType": "anime.LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logs", + "jsonName": "logs", + "goType": "[]ScanSummaryLog", + "typescriptType": "Array\u003cSummary_ScanSummaryLog\u003e", + "usedTypescriptType": "Summary_ScanSummaryLog", + "usedStructName": "summary.ScanSummaryLog", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/summary/scan_summary.go", + "filename": "scan_summary.go", + "name": "ScanSummaryGroup", + "formattedName": "Summary_ScanSummaryGroup", + "package": "summary", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Files", + "jsonName": "files", + "goType": "[]ScanSummaryFile", + "typescriptType": "Array\u003cSummary_ScanSummaryFile\u003e", + "usedTypescriptType": "Summary_ScanSummaryFile", + "usedStructName": "summary.ScanSummaryFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaTitle", + "jsonName": "mediaTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaImage", + "jsonName": "mediaImage", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaIsInCollection", + "jsonName": "mediaIsInCollection", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Whether the media is in the user's AniList collection" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/library/summary/scan_summary.go", + "filename": "scan_summary.go", + "name": "ScanSummaryItem", + "formattedName": "Summary_ScanSummaryItem", + "package": "summary", + "fields": [ + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScanSummary", + "jsonName": "scanSummary", + "goType": "ScanSummary", + "typescriptType": "Summary_ScanSummary", + "usedTypescriptType": "Summary_ScanSummary", + "usedStructName": "summary.ScanSummary", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/database.go", + "filename": "database.go", + "name": "Database", + "formattedName": "Local_Database", + "package": "local", + "fields": [ + { + "name": "gormdb", + "jsonName": "gormdb", + "goType": "gorm.DB", + "typescriptType": "DB", + "usedTypescriptType": "DB", + "usedStructName": "gorm.DB", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/database_models.go", + "filename": "database_models.go", + "name": "BaseModel", + "formattedName": "Local_BaseModel", + "package": "local", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/database_models.go", + "filename": "database_models.go", + "name": "Settings", + "formattedName": "Local_Settings", + "package": "local", + "fields": [ + { + "name": "Updated", + "jsonName": "updated", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "local.BaseModel" + ] + }, + { + "filepath": "../internal/local/database_models.go", + "filename": "database_models.go", + "name": "LocalCollection", + "formattedName": "Local_LocalCollection", + "package": "local", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " \"anime\" or \"manga\"" + ] + }, + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " Marshalled struct" + ] + } + ], + "comments": [ + " LocalCollection is an anilist collection that is stored locally for offline use.", + " It is meant to be kept in sync with the real AniList collection when online." + ], + "embeddedStructNames": [ + "local.BaseModel" + ] + }, + { + "filepath": "../internal/local/database_models.go", + "filename": "database_models.go", + "name": "TrackedMedia", + "formattedName": "Local_TrackedMedia", + "package": "local", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " \"anime\" or \"manga\"" + ] + } + ], + "comments": [ + " TrackedMedia tracks media that should be stored locally." + ], + "embeddedStructNames": [ + "local.BaseModel" + ] + }, + { + "filepath": "../internal/local/database_models.go", + "filename": "database_models.go", + "name": "AnimeSnapshot", + "formattedName": "Local_AnimeSnapshot", + "package": "local", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnimeMetadata", + "jsonName": "animeMetadata", + "goType": "LocalAnimeMetadata", + "typescriptType": "Local_LocalAnimeMetadata", + "usedTypescriptType": "Local_LocalAnimeMetadata", + "usedStructName": "local.LocalAnimeMetadata", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BannerImagePath", + "jsonName": "bannerImagePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CoverImagePath", + "jsonName": "coverImagePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeImagePaths", + "jsonName": "episodeImagePaths", + "goType": "StringMap", + "typescriptType": "Local_StringMap", + "usedTypescriptType": "Local_StringMap", + "usedStructName": "local.StringMap", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReferenceKey", + "jsonName": "referenceKey", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "local.BaseModel" + ] + }, + { + "filepath": "../internal/local/database_models.go", + "filename": "database_models.go", + "name": "MangaSnapshot", + "formattedName": "Local_MangaSnapshot", + "package": "local", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterContainers", + "jsonName": "chapterContainers", + "goType": "LocalMangaChapterContainers", + "typescriptType": "Local_LocalMangaChapterContainers", + "usedTypescriptType": "Local_LocalMangaChapterContainers", + "usedStructName": "local.LocalMangaChapterContainers", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BannerImagePath", + "jsonName": "bannerImagePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CoverImagePath", + "jsonName": "coverImagePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReferenceKey", + "jsonName": "referenceKey", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "local.BaseModel" + ] + }, + { + "filepath": "../internal/local/database_models.go", + "filename": "database_models.go", + "name": "SimulatedCollection", + "formattedName": "Local_SimulatedCollection", + "package": "local", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " \"anime\" or \"manga\"" + ] + }, + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " Marshalled struct" + ] + } + ], + "comments": [ + " SimulatedCollection is used for users without an account." + ], + "embeddedStructNames": [ + "local.BaseModel" + ] + }, + { + "filepath": "../internal/local/database_models.go", + "filename": "database_models.go", + "name": "StringMap", + "formattedName": "Local_StringMap", + "package": "local", + "fields": [], + "aliasOf": { + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "declaredValues": null + }, + "comments": null + }, + { + "filepath": "../internal/local/database_models.go", + "filename": "database_models.go", + "name": "LocalMangaChapterContainers", + "formattedName": "Local_LocalMangaChapterContainers", + "package": "local", + "fields": [], + "aliasOf": { + "goType": "[]manga.ChapterContainer", + "typescriptType": "Array\u003cManga_ChapterContainer\u003e", + "declaredValues": null, + "usedStructName": "manga.ChapterContainer" + }, + "comments": null + }, + { + "filepath": "../internal/local/diff.go", + "filename": "diff.go", + "name": "Diff", + "formattedName": "Local_Diff", + "package": "local", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/diff.go", + "filename": "diff.go", + "name": "DiffType", + "formattedName": "Local_DiffType", + "package": "local", + "fields": [], + "aliasOf": { + "goType": "int", + "typescriptType": "number", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/local/diff.go", + "filename": "diff.go", + "name": "GetAnimeDiffOptions", + "formattedName": "Local_GetAnimeDiffOptions", + "package": "local", + "fields": [ + { + "name": "Collection", + "jsonName": "Collection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalCollection", + "jsonName": "LocalCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "LocalFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TrackedAnime", + "jsonName": "TrackedAnime", + "goType": "map[int]TrackedMedia", + "typescriptType": "Record\u003cnumber, Local_TrackedMedia\u003e", + "usedTypescriptType": "Local_TrackedMedia", + "usedStructName": "local.TrackedMedia", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Snapshots", + "jsonName": "Snapshots", + "goType": "map[int]AnimeSnapshot", + "typescriptType": "Record\u003cnumber, Local_AnimeSnapshot\u003e", + "usedTypescriptType": "Local_AnimeSnapshot", + "usedStructName": "local.AnimeSnapshot", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/diff.go", + "filename": "diff.go", + "name": "AnimeDiffResult", + "formattedName": "Local_AnimeDiffResult", + "package": "local", + "fields": [ + { + "name": "AnimeEntry", + "jsonName": "AnimeEntry", + "goType": "anilist.AnimeListEntry", + "typescriptType": "AL_AnimeListEntry", + "usedTypescriptType": "AL_AnimeListEntry", + "usedStructName": "anilist.AnimeListEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeSnapshot", + "jsonName": "AnimeSnapshot", + "goType": "AnimeSnapshot", + "typescriptType": "Local_AnimeSnapshot", + "usedTypescriptType": "Local_AnimeSnapshot", + "usedStructName": "local.AnimeSnapshot", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DiffType", + "jsonName": "DiffType", + "goType": "DiffType", + "typescriptType": "Local_DiffType", + "usedTypescriptType": "Local_DiffType", + "usedStructName": "local.DiffType", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/diff.go", + "filename": "diff.go", + "name": "GetMangaDiffOptions", + "formattedName": "Local_GetMangaDiffOptions", + "package": "local", + "fields": [ + { + "name": "Collection", + "jsonName": "Collection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalCollection", + "jsonName": "LocalCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadedChapterContainers", + "jsonName": "DownloadedChapterContainers", + "goType": "[]manga.ChapterContainer", + "typescriptType": "Array\u003cManga_ChapterContainer\u003e", + "usedTypescriptType": "Manga_ChapterContainer", + "usedStructName": "manga.ChapterContainer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TrackedManga", + "jsonName": "TrackedManga", + "goType": "map[int]TrackedMedia", + "typescriptType": "Record\u003cnumber, Local_TrackedMedia\u003e", + "usedTypescriptType": "Local_TrackedMedia", + "usedStructName": "local.TrackedMedia", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Snapshots", + "jsonName": "Snapshots", + "goType": "map[int]MangaSnapshot", + "typescriptType": "Record\u003cnumber, Local_MangaSnapshot\u003e", + "usedTypescriptType": "Local_MangaSnapshot", + "usedStructName": "local.MangaSnapshot", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/diff.go", + "filename": "diff.go", + "name": "MangaDiffResult", + "formattedName": "Local_MangaDiffResult", + "package": "local", + "fields": [ + { + "name": "MangaEntry", + "jsonName": "MangaEntry", + "goType": "anilist.MangaListEntry", + "typescriptType": "AL_MangaListEntry", + "usedTypescriptType": "AL_MangaListEntry", + "usedStructName": "anilist.MangaListEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaSnapshot", + "jsonName": "MangaSnapshot", + "goType": "MangaSnapshot", + "typescriptType": "Local_MangaSnapshot", + "usedTypescriptType": "Local_MangaSnapshot", + "usedStructName": "local.MangaSnapshot", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DiffType", + "jsonName": "DiffType", + "goType": "DiffType", + "typescriptType": "Local_DiffType", + "usedTypescriptType": "Local_DiffType", + "usedStructName": "local.DiffType", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/manager.go", + "filename": "manager.go", + "name": "ManagerImpl", + "formattedName": "Local_ManagerImpl", + "package": "local", + "fields": [ + { + "name": "db", + "jsonName": "db", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "localDb", + "jsonName": "localDb", + "goType": "Database", + "typescriptType": "Local_Database", + "usedTypescriptType": "Local_Database", + "usedStructName": "local.Database", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "localDir", + "jsonName": "localDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "localAssetsDir", + "jsonName": "localAssetsDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "isOffline", + "jsonName": "isOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mangaRepository", + "jsonName": "mangaRepository", + "goType": "manga.Repository", + "typescriptType": "Manga_Repository", + "usedTypescriptType": "Manga_Repository", + "usedStructName": "manga.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "offlineMetadataProvider", + "jsonName": "offlineMetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "anilistPlatform", + "jsonName": "anilistPlatform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "syncer", + "jsonName": "syncer", + "goType": "Syncer", + "typescriptType": "Local_Syncer", + "usedTypescriptType": "Local_Syncer", + "usedStructName": "local.Syncer", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "localAnimeCollection", + "jsonName": "localAnimeCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "localMangaCollection", + "jsonName": "localMangaCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "animeCollection", + "jsonName": "animeCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mangaCollection", + "jsonName": "mangaCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "downloadedChapterContainers", + "jsonName": "downloadedChapterContainers", + "goType": "[]manga.ChapterContainer", + "typescriptType": "Array\u003cManga_ChapterContainer\u003e", + "usedTypescriptType": "Manga_ChapterContainer", + "usedStructName": "manga.ChapterContainer", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "localFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "RefreshAnilistCollectionsFunc", + "jsonName": "RefreshAnilistCollectionsFunc", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/manager.go", + "filename": "manager.go", + "name": "TrackedMediaItem", + "formattedName": "Local_TrackedMediaItem", + "package": "local", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnimeEntry", + "jsonName": "animeEntry", + "goType": "anilist.AnimeListEntry", + "typescriptType": "AL_AnimeListEntry", + "usedTypescriptType": "AL_AnimeListEntry", + "usedStructName": "anilist.AnimeListEntry", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaEntry", + "jsonName": "mangaEntry", + "goType": "anilist.MangaListEntry", + "typescriptType": "AL_MangaListEntry", + "usedTypescriptType": "AL_MangaListEntry", + "usedStructName": "anilist.MangaListEntry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/manager.go", + "filename": "manager.go", + "name": "NewManagerOptions", + "formattedName": "Local_NewManagerOptions", + "package": "local", + "fields": [ + { + "name": "LocalDir", + "jsonName": "LocalDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AssetDir", + "jsonName": "AssetDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaRepository", + "jsonName": "MangaRepository", + "goType": "manga.Repository", + "typescriptType": "Manga_Repository", + "usedTypescriptType": "Manga_Repository", + "usedStructName": "manga.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnilistPlatform", + "jsonName": "AnilistPlatform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsOffline", + "jsonName": "IsOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/metadata.go", + "filename": "metadata.go", + "name": "OfflineMetadataProvider", + "formattedName": "Local_OfflineMetadataProvider", + "package": "local", + "fields": [ + { + "name": "manager", + "jsonName": "manager", + "goType": "ManagerImpl", + "typescriptType": "Local_ManagerImpl", + "usedTypescriptType": "Local_ManagerImpl", + "usedStructName": "local.ManagerImpl", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeSnapshots", + "jsonName": "animeSnapshots", + "goType": "map[int]AnimeSnapshot", + "typescriptType": "Record\u003cnumber, Local_AnimeSnapshot\u003e", + "usedTypescriptType": "Local_AnimeSnapshot", + "usedStructName": "local.AnimeSnapshot", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeMetadataCache", + "jsonName": "animeMetadataCache", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " OfflineMetadataProvider replaces the metadata provider only when offline" + ] + }, + { + "filepath": "../internal/local/metadata.go", + "filename": "metadata.go", + "name": "OfflineAnimeMetadataWrapper", + "formattedName": "Local_OfflineAnimeMetadataWrapper", + "package": "local", + "fields": [ + { + "name": "anime", + "jsonName": "anime", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadata", + "jsonName": "metadata", + "goType": "metadata.AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/sync.go", + "filename": "sync.go", + "name": "Syncer", + "formattedName": "Local_Syncer", + "package": "local", + "fields": [ + { + "name": "animeJobQueue", + "jsonName": "animeJobQueue", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mangaJobQueue", + "jsonName": "mangaJobQueue", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "failedAnimeQueue", + "jsonName": "failedAnimeQueue", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "failedMangaQueue", + "jsonName": "failedMangaQueue", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "trackedAnimeMap", + "jsonName": "trackedAnimeMap", + "goType": "map[int]TrackedMedia", + "typescriptType": "Record\u003cnumber, Local_TrackedMedia\u003e", + "usedTypescriptType": "Local_TrackedMedia", + "usedStructName": "local.TrackedMedia", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "trackedMangaMap", + "jsonName": "trackedMangaMap", + "goType": "map[int]TrackedMedia", + "typescriptType": "Record\u003cnumber, Local_TrackedMedia\u003e", + "usedTypescriptType": "Local_TrackedMedia", + "usedStructName": "local.TrackedMedia", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "manager", + "jsonName": "manager", + "goType": "ManagerImpl", + "typescriptType": "Local_ManagerImpl", + "usedTypescriptType": "Local_ManagerImpl", + "usedStructName": "local.ManagerImpl", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "shouldUpdateLocalCollections", + "jsonName": "shouldUpdateLocalCollections", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "doneUpdatingLocalCollections", + "jsonName": "doneUpdatingLocalCollections", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "queueState", + "jsonName": "queueState", + "goType": "QueueState", + "typescriptType": "Local_QueueState", + "usedTypescriptType": "Local_QueueState", + "usedStructName": "local.QueueState", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "queueStateMu", + "jsonName": "queueStateMu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/sync.go", + "filename": "sync.go", + "name": "QueueState", + "formattedName": "Local_QueueState", + "package": "local", + "fields": [ + { + "name": "AnimeTasks", + "jsonName": "animeTasks", + "goType": "map[int]QueueMediaTask", + "typescriptType": "Record\u003cnumber, Local_QueueMediaTask\u003e", + "usedTypescriptType": "Local_QueueMediaTask", + "usedStructName": "local.QueueMediaTask", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaTasks", + "jsonName": "mangaTasks", + "goType": "map[int]QueueMediaTask", + "typescriptType": "Record\u003cnumber, Local_QueueMediaTask\u003e", + "usedTypescriptType": "Local_QueueMediaTask", + "usedStructName": "local.QueueMediaTask", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/sync.go", + "filename": "sync.go", + "name": "QueueMediaTask", + "formattedName": "Local_QueueMediaTask", + "package": "local", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/sync.go", + "filename": "sync.go", + "name": "AnimeTask", + "formattedName": "Local_AnimeTask", + "package": "local", + "fields": [ + { + "name": "Diff", + "jsonName": "Diff", + "goType": "AnimeDiffResult", + "typescriptType": "Local_AnimeDiffResult", + "usedTypescriptType": "Local_AnimeDiffResult", + "usedStructName": "local.AnimeDiffResult", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/local/sync.go", + "filename": "sync.go", + "name": "MangaTask", + "formattedName": "Local_MangaTask", + "package": "local", + "fields": [ + { + "name": "Diff", + "jsonName": "Diff", + "goType": "MangaDiffResult", + "typescriptType": "Local_MangaDiffResult", + "usedTypescriptType": "Local_MangaDiffResult", + "usedStructName": "local.MangaDiffResult", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/chapter_container.go", + "filename": "chapter_container.go", + "name": "ChapterContainer", + "formattedName": "Manga_ChapterContainer", + "package": "manga", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Chapters", + "jsonName": "chapters", + "goType": "[]hibikemanga.ChapterDetails", + "typescriptType": "Array\u003cHibikeManga_ChapterDetails\u003e", + "usedTypescriptType": "HibikeManga_ChapterDetails", + "usedStructName": "hibikemanga.ChapterDetails", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/chapter_container.go", + "filename": "chapter_container.go", + "name": "GetMangaChapterContainerOptions", + "formattedName": "Manga_GetMangaChapterContainerOptions", + "package": "manga", + "fields": [ + { + "name": "Provider", + "jsonName": "Provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Titles", + "jsonName": "Titles", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "Year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/chapter_container.go", + "filename": "chapter_container.go", + "name": "MangaLatestChapterNumberItem", + "formattedName": "Manga_MangaLatestChapterNumberItem", + "package": "manga", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Scanlator", + "jsonName": "scanlator", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Number", + "jsonName": "number", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/chapter_container_mapping.go", + "filename": "chapter_container_mapping.go", + "name": "MappingResponse", + "formattedName": "Manga_MappingResponse", + "package": "manga", + "fields": [ + { + "name": "MangaID", + "jsonName": "mangaId", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/chapter_page_container.go", + "filename": "chapter_page_container.go", + "name": "PageContainer", + "formattedName": "Manga_PageContainer", + "package": "manga", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterId", + "jsonName": "chapterId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Pages", + "jsonName": "pages", + "goType": "[]hibikemanga.ChapterPage", + "typescriptType": "Array\u003cHibikeManga_ChapterPage\u003e", + "usedTypescriptType": "HibikeManga_ChapterPage", + "usedStructName": "hibikemanga.ChapterPage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PageDimensions", + "jsonName": "pageDimensions", + "goType": "map[int]PageDimension", + "typescriptType": "Record\u003cnumber, Manga_PageDimension\u003e", + "usedTypescriptType": "Manga_PageDimension", + "usedStructName": "manga.PageDimension", + "required": false, + "public": true, + "comments": [ + " Indexed by page number" + ] + }, + { + "name": "IsDownloaded", + "jsonName": "isDownloaded", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " TODO remove" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/chapter_page_container.go", + "filename": "chapter_page_container.go", + "name": "PageDimension", + "formattedName": "Manga_PageDimension", + "package": "manga", + "fields": [ + { + "name": "Width", + "jsonName": "width", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Height", + "jsonName": "height", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/collection.go", + "filename": "collection.go", + "name": "CollectionStatusType", + "formattedName": "Manga_CollectionStatusType", + "package": "manga", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/manga/collection.go", + "filename": "collection.go", + "name": "Collection", + "formattedName": "Manga_Collection", + "package": "manga", + "fields": [ + { + "name": "Lists", + "jsonName": "lists", + "goType": "[]CollectionList", + "typescriptType": "Array\u003cManga_CollectionList\u003e", + "usedTypescriptType": "Manga_CollectionList", + "usedStructName": "manga.CollectionList", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/collection.go", + "filename": "collection.go", + "name": "CollectionList", + "formattedName": "Manga_CollectionList", + "package": "manga", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Entries", + "jsonName": "entries", + "goType": "[]CollectionEntry", + "typescriptType": "Array\u003cManga_CollectionEntry\u003e", + "usedTypescriptType": "Manga_CollectionEntry", + "usedStructName": "manga.CollectionEntry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/collection.go", + "filename": "collection.go", + "name": "CollectionEntry", + "formattedName": "Manga_CollectionEntry", + "package": "manga", + "fields": [ + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseManga", + "typescriptType": "AL_BaseManga", + "usedTypescriptType": "AL_BaseManga", + "usedStructName": "anilist.BaseManga", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EntryListData", + "jsonName": "listData", + "goType": "EntryListData", + "typescriptType": "Manga_EntryListData", + "usedTypescriptType": "Manga_EntryListData", + "usedStructName": "manga.EntryListData", + "required": false, + "public": true, + "comments": [ + " AniList list data" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/collection.go", + "filename": "collection.go", + "name": "NewCollectionOptions", + "formattedName": "Manga_NewCollectionOptions", + "package": "manga", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "MangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/download.go", + "filename": "download.go", + "name": "Downloader", + "formattedName": "Manga_Downloader", + "package": "manga", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "database", + "jsonName": "database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "downloadDir", + "jsonName": "downloadDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "chapterDownloader", + "jsonName": "chapterDownloader", + "goType": "chapter_downloader.Downloader", + "typescriptType": "ChapterDownloader_Downloader", + "usedTypescriptType": "ChapterDownloader_Downloader", + "usedStructName": "chapter_downloader.Downloader", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "repository", + "jsonName": "repository", + "goType": "Repository", + "typescriptType": "Manga_Repository", + "usedTypescriptType": "Manga_Repository", + "usedStructName": "manga.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "filecacher", + "jsonName": "filecacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mediaMap", + "jsonName": "mediaMap", + "goType": "MediaMap", + "typescriptType": "Manga_MediaMap", + "usedTypescriptType": "Manga_MediaMap", + "usedStructName": "manga.MediaMap", + "required": false, + "public": false, + "comments": [ + " Refreshed on start and after each download" + ] + }, + { + "name": "mediaMapMu", + "jsonName": "mediaMapMu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "chapterDownloadedCh", + "jsonName": "chapterDownloadedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "readingDownloadDir", + "jsonName": "readingDownloadDir", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "isOffline", + "jsonName": "isOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/download.go", + "filename": "download.go", + "name": "MediaMap", + "formattedName": "Manga_MediaMap", + "package": "manga", + "fields": [], + "aliasOf": { + "goType": "map[int]ProviderDownloadMap", + "typescriptType": "Record\u003cnumber, Manga_ProviderDownloadMap\u003e", + "declaredValues": null, + "usedStructName": "manga.ProviderDownloadMap" + }, + "comments": null + }, + { + "filepath": "../internal/manga/download.go", + "filename": "download.go", + "name": "ProviderDownloadMap", + "formattedName": "Manga_ProviderDownloadMap", + "package": "manga", + "fields": [], + "aliasOf": { + "goType": "map[string][]ProviderDownloadMapChapterInfo", + "typescriptType": "Record\u003cstring, Array\u003cManga_ProviderDownloadMapChapterInfo\u003e\u003e", + "declaredValues": null, + "usedStructName": "manga.ProviderDownloadMapChapterInfo" + }, + "comments": null + }, + { + "filepath": "../internal/manga/download.go", + "filename": "download.go", + "name": "ProviderDownloadMapChapterInfo", + "formattedName": "Manga_ProviderDownloadMapChapterInfo", + "package": "manga", + "fields": [ + { + "name": "ChapterID", + "jsonName": "chapterId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterNumber", + "jsonName": "chapterNumber", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/download.go", + "filename": "download.go", + "name": "MediaDownloadData", + "formattedName": "Manga_MediaDownloadData", + "package": "manga", + "fields": [ + { + "name": "Downloaded", + "jsonName": "downloaded", + "goType": "ProviderDownloadMap", + "typescriptType": "Manga_ProviderDownloadMap", + "usedTypescriptType": "Manga_ProviderDownloadMap", + "usedStructName": "manga.ProviderDownloadMap", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Queued", + "jsonName": "queued", + "goType": "ProviderDownloadMap", + "typescriptType": "Manga_ProviderDownloadMap", + "usedTypescriptType": "Manga_ProviderDownloadMap", + "usedStructName": "manga.ProviderDownloadMap", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/download.go", + "filename": "download.go", + "name": "NewDownloaderOptions", + "formattedName": "Manga_NewDownloaderOptions", + "package": "manga", + "fields": [ + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DownloadDir", + "jsonName": "DownloadDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Repository", + "jsonName": "Repository", + "goType": "Repository", + "typescriptType": "Manga_Repository", + "usedTypescriptType": "Manga_Repository", + "usedStructName": "manga.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsOffline", + "jsonName": "IsOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/download.go", + "filename": "download.go", + "name": "DownloadChapterOptions", + "formattedName": "Manga_DownloadChapterOptions", + "package": "manga", + "fields": [ + { + "name": "Provider", + "jsonName": "Provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterId", + "jsonName": "ChapterId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartNow", + "jsonName": "StartNow", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/download.go", + "filename": "download.go", + "name": "NewDownloadListOptions", + "formattedName": "Manga_NewDownloadListOptions", + "package": "manga", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "MangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/download.go", + "filename": "download.go", + "name": "DownloadListItem", + "formattedName": "Manga_DownloadListItem", + "package": "manga", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseManga", + "typescriptType": "AL_BaseManga", + "usedTypescriptType": "AL_BaseManga", + "usedStructName": "anilist.BaseManga", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DownloadData", + "jsonName": "downloadData", + "goType": "ProviderDownloadMap", + "typescriptType": "Manga_ProviderDownloadMap", + "usedTypescriptType": "Manga_ProviderDownloadMap", + "usedStructName": "manga.ProviderDownloadMap", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/downloader/chapter_downloader.go", + "filename": "chapter_downloader.go", + "name": "Downloader", + "formattedName": "ChapterDownloader_Downloader", + "package": "chapter_downloader", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "database", + "jsonName": "database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "downloadDir", + "jsonName": "downloadDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "downloadMu", + "jsonName": "downloadMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cancelChannels", + "jsonName": "cancelChannels", + "goType": "map[DownloadID]", + "typescriptType": "Record\u003cDownloadID, any\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "queue", + "jsonName": "queue", + "goType": "Queue", + "typescriptType": "ChapterDownloader_Queue", + "usedTypescriptType": "ChapterDownloader_Queue", + "usedStructName": "chapter_downloader.Queue", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cancelCh", + "jsonName": "cancelCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Close to cancel the download process" + ] + }, + { + "name": "runCh", + "jsonName": "runCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Receives a signal to download the next item" + ] + }, + { + "name": "chapterDownloadedCh", + "jsonName": "chapterDownloadedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Sends a signal when a chapter has been downloaded" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/downloader/chapter_downloader.go", + "filename": "chapter_downloader.go", + "name": "DownloadID", + "formattedName": "ChapterDownloader_DownloadID", + "package": "chapter_downloader", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterId", + "jsonName": "chapterId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterNumber", + "jsonName": "chapterNumber", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/downloader/chapter_downloader.go", + "filename": "chapter_downloader.go", + "name": "Registry", + "formattedName": "ChapterDownloader_Registry", + "package": "chapter_downloader", + "fields": [], + "aliasOf": { + "goType": "map[int]PageInfo", + "typescriptType": "Record\u003cnumber, ChapterDownloader_PageInfo\u003e", + "declaredValues": null, + "usedStructName": "chapter_downloader.PageInfo" + }, + "comments": null + }, + { + "filepath": "../internal/manga/downloader/chapter_downloader.go", + "filename": "chapter_downloader.go", + "name": "PageInfo", + "formattedName": "ChapterDownloader_PageInfo", + "package": "chapter_downloader", + "fields": [ + { + "name": "Index", + "jsonName": "index", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filename", + "jsonName": "filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "OriginalURL", + "jsonName": "original_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Width", + "jsonName": "width", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Height", + "jsonName": "height", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/downloader/chapter_downloader.go", + "filename": "chapter_downloader.go", + "name": "NewDownloaderOptions", + "formattedName": "ChapterDownloader_NewDownloaderOptions", + "package": "chapter_downloader", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DownloadDir", + "jsonName": "DownloadDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/downloader/chapter_downloader.go", + "filename": "chapter_downloader.go", + "name": "DownloadOptions", + "formattedName": "ChapterDownloader_DownloadOptions", + "package": "chapter_downloader", + "fields": [ + { + "name": "Pages", + "jsonName": "Pages", + "goType": "[]hibikemanga.ChapterPage", + "typescriptType": "Array\u003cHibikeManga_ChapterPage\u003e", + "usedTypescriptType": "HibikeManga_ChapterPage", + "usedStructName": "hibikemanga.ChapterPage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartNow", + "jsonName": "StartNow", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "chapter_downloader.DownloadID" + ] + }, + { + "filepath": "../internal/manga/downloader/queue.go", + "filename": "queue.go", + "name": "Queue", + "formattedName": "ChapterDownloader_Queue", + "package": "chapter_downloader", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "db", + "jsonName": "db", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "current", + "jsonName": "current", + "goType": "QueueInfo", + "typescriptType": "ChapterDownloader_QueueInfo", + "usedTypescriptType": "ChapterDownloader_QueueInfo", + "usedStructName": "chapter_downloader.QueueInfo", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "runCh", + "jsonName": "runCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Channel to tell downloader to run the next item" + ] + }, + { + "name": "active", + "jsonName": "active", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/downloader/queue.go", + "filename": "queue.go", + "name": "QueueStatus", + "formattedName": "ChapterDownloader_QueueStatus", + "package": "chapter_downloader", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"not_started\"", + "\"downloading\"", + "\"errored\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/manga/downloader/queue.go", + "filename": "queue.go", + "name": "QueueInfo", + "formattedName": "ChapterDownloader_QueueInfo", + "package": "chapter_downloader", + "fields": [ + { + "name": "Pages", + "jsonName": "Pages", + "goType": "[]hibikemanga.ChapterPage", + "typescriptType": "Array\u003cHibikeManga_ChapterPage\u003e", + "usedTypescriptType": "HibikeManga_ChapterPage", + "usedStructName": "hibikemanga.ChapterPage", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DownloadedUrls", + "jsonName": "DownloadedUrls", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "Status", + "goType": "QueueStatus", + "typescriptType": "ChapterDownloader_QueueStatus", + "usedTypescriptType": "ChapterDownloader_QueueStatus", + "usedStructName": "chapter_downloader.QueueStatus", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "chapter_downloader.DownloadID" + ] + }, + { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaEntryRequestedEvent", + "formattedName": "Manga_MangaEntryRequestedEvent", + "package": "manga", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Entry", + "jsonName": "entry", + "goType": "Entry", + "typescriptType": "Manga_Entry", + "usedTypescriptType": "Manga_Entry", + "usedStructName": "manga.Entry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaEntryRequestedEvent is triggered when a manga entry is requested.", + " Prevent default to skip the default behavior and return the modified entry.", + " If the modified entry is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaEntryEvent", + "formattedName": "Manga_MangaEntryEvent", + "package": "manga", + "fields": [ + { + "name": "Entry", + "jsonName": "entry", + "goType": "Entry", + "typescriptType": "Manga_Entry", + "usedTypescriptType": "Manga_Entry", + "usedStructName": "manga.Entry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaEntryEvent is triggered when the manga entry is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaLibraryCollectionRequestedEvent", + "formattedName": "Manga_MangaLibraryCollectionRequestedEvent", + "package": "manga", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaLibraryCollectionRequestedEvent is triggered when the manga library collection is being requested." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaLibraryCollectionEvent", + "formattedName": "Manga_MangaLibraryCollectionEvent", + "package": "manga", + "fields": [ + { + "name": "LibraryCollection", + "jsonName": "libraryCollection", + "goType": "Collection", + "typescriptType": "Manga_Collection", + "usedTypescriptType": "Manga_Collection", + "usedStructName": "manga.Collection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaLibraryCollectionEvent is triggered when the manga library collection is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaDownloadedChapterContainersRequestedEvent", + "formattedName": "Manga_MangaDownloadedChapterContainersRequestedEvent", + "package": "manga", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChapterContainers", + "jsonName": "chapterContainers", + "goType": "[]ChapterContainer", + "typescriptType": "Array\u003cManga_ChapterContainer\u003e", + "usedTypescriptType": "Manga_ChapterContainer", + "usedStructName": "manga.ChapterContainer", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaDownloadedChapterContainersRequestedEvent is triggered when the manga downloaded chapter containers are being requested.", + " Prevent default to skip the default behavior and return the modified chapter containers.", + " If the modified chapter containers are nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaDownloadedChapterContainersEvent", + "formattedName": "Manga_MangaDownloadedChapterContainersEvent", + "package": "manga", + "fields": [ + { + "name": "ChapterContainers", + "jsonName": "chapterContainers", + "goType": "[]ChapterContainer", + "typescriptType": "Array\u003cManga_ChapterContainer\u003e", + "usedTypescriptType": "Manga_ChapterContainer", + "usedStructName": "manga.ChapterContainer", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaDownloadedChapterContainersEvent is triggered when the manga downloaded chapter containers are being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaLatestChapterNumbersMapEvent", + "formattedName": "Manga_MangaLatestChapterNumbersMapEvent", + "package": "manga", + "fields": [ + { + "name": "LatestChapterNumbersMap", + "jsonName": "latestChapterNumbersMap", + "goType": "map[int][]MangaLatestChapterNumberItem", + "typescriptType": "Record\u003cnumber, Array\u003cManga_MangaLatestChapterNumberItem\u003e\u003e", + "usedTypescriptType": "Manga_MangaLatestChapterNumberItem", + "usedStructName": "manga.MangaLatestChapterNumberItem", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaLatestChapterNumbersMapEvent is triggered when the manga latest chapter numbers map is being returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaDownloadMapEvent", + "formattedName": "Manga_MangaDownloadMapEvent", + "package": "manga", + "fields": [ + { + "name": "MediaMap", + "jsonName": "mediaMap", + "goType": "MediaMap", + "typescriptType": "Manga_MediaMap", + "usedTypescriptType": "Manga_MediaMap", + "usedStructName": "manga.MediaMap", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaDownloadMapEvent is triggered when the manga download map has been updated.", + " This map is used to tell the client which chapters have been downloaded." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaChapterContainerRequestedEvent", + "formattedName": "Manga_MangaChapterContainerRequestedEvent", + "package": "manga", + "fields": [ + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Titles", + "jsonName": "titles", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterContainer", + "jsonName": "chapterContainer", + "goType": "ChapterContainer", + "typescriptType": "Manga_ChapterContainer", + "usedTypescriptType": "Manga_ChapterContainer", + "usedStructName": "manga.ChapterContainer", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaChapterContainerRequestedEvent is triggered when the manga chapter container is being requested.", + " This event happens before the chapter container is fetched from the cache or provider.", + " Prevent default to skip the default behavior and return the modified chapter container.", + " If the modified chapter container is nil, an error will be returned." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/manga/hook_events.go", + "filename": "hook_events.go", + "name": "MangaChapterContainerEvent", + "formattedName": "Manga_MangaChapterContainerEvent", + "package": "manga", + "fields": [ + { + "name": "ChapterContainer", + "jsonName": "chapterContainer", + "goType": "ChapterContainer", + "typescriptType": "Manga_ChapterContainer", + "usedTypescriptType": "Manga_ChapterContainer", + "usedStructName": "manga.ChapterContainer", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " MangaChapterContainerEvent is triggered when the manga chapter container is being returned.", + " This event happens after the chapter container is fetched from the cache or provider." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/manga/manga_entry.go", + "filename": "manga_entry.go", + "name": "Entry", + "formattedName": "Manga_Entry", + "package": "manga", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseManga", + "typescriptType": "AL_BaseManga", + "usedTypescriptType": "AL_BaseManga", + "usedStructName": "anilist.BaseManga", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EntryListData", + "jsonName": "listData", + "goType": "EntryListData", + "typescriptType": "Manga_EntryListData", + "usedTypescriptType": "Manga_EntryListData", + "usedStructName": "manga.EntryListData", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/manga_entry.go", + "filename": "manga_entry.go", + "name": "EntryListData", + "formattedName": "Manga_EntryListData", + "package": "manga", + "fields": [ + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Score", + "jsonName": "score", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Repeat", + "jsonName": "repeat", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartedAt", + "jsonName": "startedAt", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompletedAt", + "jsonName": "completedAt", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/manga_entry.go", + "filename": "manga_entry.go", + "name": "NewEntryOptions", + "formattedName": "Manga_NewEntryOptions", + "package": "manga", + "fields": [ + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FileCacher", + "jsonName": "FileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaCollection", + "jsonName": "MangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/_template.go", + "filename": "_template.go", + "name": "Template", + "formattedName": "Manga_Template", + "package": "manga_providers", + "fields": [ + { + "name": "Url", + "jsonName": "Url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/comick.go", + "filename": "comick.go", + "name": "ComicK", + "formattedName": "Manga_ComicK", + "package": "manga_providers", + "fields": [ + { + "name": "Url", + "jsonName": "Url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "req.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "req.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/comick.go", + "filename": "comick.go", + "name": "ComicKResultItem", + "formattedName": "Manga_ComicKResultItem", + "package": "manga_providers", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HID", + "jsonName": "hid", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Slug", + "jsonName": "slug", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Country", + "jsonName": "country", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Rating", + "jsonName": "rating", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BayesianRating", + "jsonName": "bayesian_rating", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RatingCount", + "jsonName": "rating_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FollowCount", + "jsonName": "follow_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "desc", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastChapter", + "jsonName": "last_chapter", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TranslationCompleted", + "jsonName": "translation_completed", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ViewCount", + "jsonName": "view_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ContentRating", + "jsonName": "content_rating", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Demographic", + "jsonName": "demographic", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UploadedAt", + "jsonName": "uploaded_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Genres", + "jsonName": "genres", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "created_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserFollowCount", + "jsonName": "user_follow_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MuComics", + "jsonName": "mu_comics", + "goType": "Manga_ComicKResultItem_MuComics", + "inlineStructType": "struct{\nYear int `json:\"year\"`}", + "typescriptType": "Manga_ComicKResultItem_MuComics", + "usedTypescriptType": "{ year: number; }", + "usedStructName": "manga_providers.ComicKResultItem_MuComics", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MdTitles", + "jsonName": "md_titles", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nTitle string `json:\"title\"`}", + "typescriptType": "Array\u003c{ title: string; }\u003e", + "usedTypescriptType": "{ title: string; }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MdCovers", + "jsonName": "md_covers", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nW int `json:\"w\"`\nH int `json:\"h\"`\nB2Key string `json:\"b2key\"`}", + "typescriptType": "Array\u003c{ w: number; h: number; b2key: string; }\u003e", + "usedTypescriptType": "{ w: number; h: number; b2key: string; }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Highlight", + "jsonName": "highlight", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/comick.go", + "filename": "comick.go", + "name": "ComicKResultItem_MuComics", + "formattedName": "Manga_ComicKResultItem_MuComics", + "package": "manga_providers", + "fields": [ + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/comick.go", + "filename": "comick.go", + "name": "Comic", + "formattedName": "Manga_Comic", + "package": "manga_providers", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HID", + "jsonName": "hid", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Country", + "jsonName": "country", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Links", + "jsonName": "links", + "goType": "Manga_Comic_Links", + "inlineStructType": "struct{\nAL string `json:\"al\"`\nAP string `json:\"ap\"`\nBW string `json:\"bw\"`\nKT string `json:\"kt\"`\nMU string `json:\"mu\"`\nAMZ string `json:\"amz\"`\nCDJ string `json:\"cdj\"`\nEBJ string `json:\"ebj\"`\nMAL string `json:\"mal\"`\nRAW string `json:\"raw\"`}", + "typescriptType": "Manga_Comic_Links", + "usedTypescriptType": "{ al: string; ap: string; bw: string; kt: string; mu: string; amz: string; cdj: string; ebj: string; mal: string; raw: string; }", + "usedStructName": "manga_providers.Comic_Links", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastChapter", + "jsonName": "last_chapter", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterCount", + "jsonName": "chapter_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Demographic", + "jsonName": "demographic", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hentai", + "jsonName": "hentai", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserFollowCount", + "jsonName": "user_follow_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FollowRank", + "jsonName": "follow_rank", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CommentCount", + "jsonName": "comment_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FollowCount", + "jsonName": "follow_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "desc", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Parsed", + "jsonName": "parsed", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Slug", + "jsonName": "slug", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Mismatch", + "jsonName": "mismatch", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BayesianRating", + "jsonName": "bayesian_rating", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RatingCount", + "jsonName": "rating_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ContentRating", + "jsonName": "content_rating", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TranslationCompleted", + "jsonName": "translation_completed", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RelateFrom", + "jsonName": "relate_from", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Mies", + "jsonName": "mies", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MdTitles", + "jsonName": "md_titles", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nTitle string `json:\"title\"`}", + "typescriptType": "Array\u003c{ title: string; }\u003e", + "usedTypescriptType": "{ title: string; }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MdComicMdGenres", + "jsonName": "md_comic_md_genres", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nMdGenres __STRUCT__ `json:\"md_genres\"`}", + "typescriptType": "Array\u003c{ md_genres: { name: string; type: any; slug: string; group: string; }; }\u003e", + "usedTypescriptType": "{ md_genres: { name: string; type: any; slug: string; group: string; }; }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MuComics", + "jsonName": "mu_comics", + "goType": "Manga_Comic_MuComics", + "inlineStructType": "struct{\nLicensedInEnglish `json:\"licensed_in_english\"`\nMuComicCategories []__STRUCT__ `json:\"mu_comic_categories\"`}", + "typescriptType": "Manga_Comic_MuComics", + "usedTypescriptType": "{ licensed_in_english: any; mu_comic_categories: Array\u003c{ mu_categories: { title: string; slug: string; }; positive_vote: number; negative_vote: number; }\u003e; }", + "usedStructName": "manga_providers.Comic_MuComics", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MdCovers", + "jsonName": "md_covers", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nVol `json:\"vol\"`\nW int `json:\"w\"`\nH int `json:\"h\"`\nB2Key string `json:\"b2key\"`}", + "typescriptType": "Array\u003c{ vol: any; w: number; h: number; b2key: string; }\u003e", + "usedTypescriptType": "{ vol: any; w: number; h: number; b2key: string; }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Iso6391", + "jsonName": "iso639_1", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LangName", + "jsonName": "lang_name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LangNative", + "jsonName": "lang_native", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/comick.go", + "filename": "comick.go", + "name": "Comic_Links", + "formattedName": "Manga_Comic_Links", + "package": "manga_providers", + "fields": [ + { + "name": "AL", + "jsonName": "al", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AP", + "jsonName": "ap", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BW", + "jsonName": "bw", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "KT", + "jsonName": "kt", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MU", + "jsonName": "mu", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AMZ", + "jsonName": "amz", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CDJ", + "jsonName": "cdj", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EBJ", + "jsonName": "ebj", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MAL", + "jsonName": "mal", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RAW", + "jsonName": "raw", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/comick.go", + "filename": "comick.go", + "name": "Comic_MuComics", + "formattedName": "Manga_Comic_MuComics", + "package": "manga_providers", + "fields": [ + { + "name": "LicensedInEnglish", + "jsonName": "licensed_in_english", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MuComicCategories", + "jsonName": "mu_comic_categories", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nMuCategories __STRUCT__ `json:\"mu_categories\"`\nPositiveVote int `json:\"positive_vote\"`\nNegativeVote int `json:\"negative_vote\"`}", + "typescriptType": "Array\u003c{ mu_categories: { title: string; slug: string; }; positive_vote: number; negative_vote: number; }\u003e", + "usedTypescriptType": "{ mu_categories: { title: string; slug: string; }; positive_vote: number; negative_vote: number; }", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/comick.go", + "filename": "comick.go", + "name": "ComicChapter", + "formattedName": "Manga_ComicChapter", + "package": "manga_providers", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Chap", + "jsonName": "chap", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Vol", + "jsonName": "vol", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Lang", + "jsonName": "lang", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "created_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updated_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpCount", + "jsonName": "up_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownCount", + "jsonName": "down_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "GroupName", + "jsonName": "group_name", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "HID", + "jsonName": "hid", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MdImages", + "jsonName": "md_images", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nName string `json:\"name\"`\nW int `json:\"w\"`\nH int `json:\"h\"`\nS int `json:\"s\"`\nB2Key string `json:\"b2key\"`}", + "typescriptType": "Array\u003c{ name: string; w: number; h: number; s: number; b2key: string; }\u003e", + "usedTypescriptType": "{ name: string; w: number; h: number; s: number; b2key: string; }", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/comick_multi.go", + "filename": "comick_multi.go", + "name": "ComicKMulti", + "formattedName": "Manga_ComicKMulti", + "package": "manga_providers", + "fields": [ + { + "name": "Url", + "jsonName": "Url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "req.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "req.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/local.go", + "filename": "local.go", + "name": "Local", + "formattedName": "Manga_Local", + "package": "manga_providers", + "fields": [ + { + "name": "dir", + "jsonName": "dir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [ + " Directory to scan for manga" + ] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentChapterPath", + "jsonName": "currentChapterPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "currentZipCloser", + "jsonName": "currentZipCloser", + "goType": "io.Closer", + "typescriptType": "Closer", + "usedTypescriptType": "Closer", + "usedStructName": "io.Closer", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentPages", + "jsonName": "currentPages", + "goType": "map[string]loadedPage", + "typescriptType": "Record\u003cstring, Manga_loadedPage\u003e", + "usedTypescriptType": "Manga_loadedPage", + "usedStructName": "manga_providers.loadedPage", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/local_parser.go", + "filename": "local_parser.go", + "name": "ScannedChapterFile", + "formattedName": "Manga_ScannedChapterFile", + "package": "manga_providers", + "fields": [ + { + "name": "Chapter", + "jsonName": "Chapter", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " can be a single chapter or a range of chapters" + ] + }, + { + "name": "MangaTitle", + "jsonName": "MangaTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " typically comes before the chapter number" + ] + }, + { + "name": "ChapterTitle", + "jsonName": "ChapterTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " typically comes after the chapter number" + ] + }, + { + "name": "Volume", + "jsonName": "Volume", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " typically comes after the chapter number" + ] + }, + { + "name": "IsPDF", + "jsonName": "IsPDF", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/local_parser.go", + "filename": "local_parser.go", + "name": "TokenType", + "formattedName": "Manga_TokenType", + "package": "manga_providers", + "fields": [], + "aliasOf": { + "goType": "int", + "typescriptType": "number", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/manga/providers/local_parser.go", + "filename": "local_parser.go", + "name": "Token", + "formattedName": "Manga_Token", + "package": "manga_providers", + "fields": [ + { + "name": "Type", + "jsonName": "Type", + "goType": "TokenType", + "typescriptType": "Manga_TokenType", + "usedTypescriptType": "Manga_TokenType", + "usedStructName": "manga_providers.TokenType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "Value", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Position", + "jsonName": "Position", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsChapter", + "jsonName": "IsChapter", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsVolume", + "jsonName": "IsVolume", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Token represents a parsed token from the filename" + ] + }, + { + "filepath": "../internal/manga/providers/local_parser.go", + "filename": "local_parser.go", + "name": "Lexer", + "formattedName": "Manga_Lexer", + "package": "manga_providers", + "fields": [ + { + "name": "input", + "jsonName": "input", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "position", + "jsonName": "position", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "tokens", + "jsonName": "tokens", + "goType": "[]Token", + "typescriptType": "Array\u003cManga_Token\u003e", + "usedTypescriptType": "Manga_Token", + "usedStructName": "manga_providers.Token", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentToken", + "jsonName": "currentToken", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [ + " Lexer handles the tokenization of the filename" + ] + }, + { + "filepath": "../internal/manga/providers/local_parser.go", + "filename": "local_parser.go", + "name": "Parser", + "formattedName": "Manga_Parser", + "package": "manga_providers", + "fields": [ + { + "name": "tokens", + "jsonName": "tokens", + "goType": "[]Token", + "typescriptType": "Array\u003cManga_Token\u003e", + "usedTypescriptType": "Manga_Token", + "usedStructName": "manga_providers.Token", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "result", + "jsonName": "result", + "goType": "ScannedChapterFile", + "typescriptType": "Manga_ScannedChapterFile", + "usedTypescriptType": "Manga_ScannedChapterFile", + "usedStructName": "manga_providers.ScannedChapterFile", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " Parser handles the semantic analysis of tokens" + ] + }, + { + "filepath": "../internal/manga/providers/local_parser.go", + "filename": "local_parser.go", + "name": "ScannedPageFile", + "formattedName": "Manga_ScannedPageFile", + "package": "manga_providers", + "fields": [ + { + "name": "Number", + "jsonName": "Number", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filename", + "jsonName": "Filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Ext", + "jsonName": "Ext", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/mangadex.go", + "filename": "mangadex.go", + "name": "Mangadex", + "formattedName": "Manga_Mangadex", + "package": "manga_providers", + "fields": [ + { + "name": "Url", + "jsonName": "Url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BaseUrl", + "jsonName": "BaseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "req.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "req.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/mangadex.go", + "filename": "mangadex.go", + "name": "MangadexManga", + "formattedName": "Manga_MangadexManga", + "package": "manga_providers", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Attributes", + "jsonName": "Attributes", + "goType": "MangadexMangeAttributes", + "typescriptType": "Manga_MangadexMangeAttributes", + "usedTypescriptType": "Manga_MangadexMangeAttributes", + "usedStructName": "manga_providers.MangadexMangeAttributes", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Relationships", + "jsonName": "relationships", + "goType": "[]MangadexMangaRelationship", + "typescriptType": "Array\u003cManga_MangadexMangaRelationship\u003e", + "usedTypescriptType": "Manga_MangadexMangaRelationship", + "usedStructName": "manga_providers.MangadexMangaRelationship", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/mangadex.go", + "filename": "mangadex.go", + "name": "MangadexMangeAttributes", + "formattedName": "Manga_MangadexMangeAttributes", + "package": "manga_providers", + "fields": [ + { + "name": "AltTitles", + "jsonName": "altTitles", + "goType": "[]map[string]string", + "typescriptType": "Array\u003cRecord\u003cstring, string\u003e\u003e", + "usedStructName": "manga_providers.map[string]string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/mangadex.go", + "filename": "mangadex.go", + "name": "MangadexMangaRelationship", + "formattedName": "Manga_MangadexMangaRelationship", + "package": "manga_providers", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Related", + "jsonName": "related", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Attributes", + "jsonName": "attributes", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/mangadex.go", + "filename": "mangadex.go", + "name": "MangadexErrorResponse", + "formattedName": "Manga_MangadexErrorResponse", + "package": "manga_providers", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Code", + "jsonName": "code", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Detail", + "jsonName": "detail", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/mangadex.go", + "filename": "mangadex.go", + "name": "MangadexChapterData", + "formattedName": "Manga_MangadexChapterData", + "package": "manga_providers", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Attributes", + "jsonName": "attributes", + "goType": "MangadexChapterAttributes", + "typescriptType": "Manga_MangadexChapterAttributes", + "usedTypescriptType": "Manga_MangadexChapterAttributes", + "usedStructName": "manga_providers.MangadexChapterAttributes", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/mangadex.go", + "filename": "mangadex.go", + "name": "MangadexChapterAttributes", + "formattedName": "Manga_MangadexChapterAttributes", + "package": "manga_providers", + "fields": [ + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Volume", + "jsonName": "volume", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Chapter", + "jsonName": "chapter", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpdatedAt", + "jsonName": "updatedAt", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/mangafire.go", + "filename": "mangafire.go", + "name": "Mangafire", + "formattedName": "Manga_Mangafire", + "package": "manga_providers", + "fields": [ + { + "name": "Url", + "jsonName": "Url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "req.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "req.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/manganato.go", + "filename": "manganato.go", + "name": "Manganato", + "formattedName": "Manga_Manganato", + "package": "manga_providers", + "fields": [ + { + "name": "Url", + "jsonName": "Url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "req.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "req.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/manganato.go", + "filename": "manganato.go", + "name": "ManganatoSearchResult", + "formattedName": "Manga_ManganatoSearchResult", + "package": "manga_providers", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NameUnsigned", + "jsonName": "nameunsigned", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastChapter", + "jsonName": "lastchapter", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Author", + "jsonName": "author", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StoryLink", + "jsonName": "story_link", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/mangapill.go", + "filename": "mangapill.go", + "name": "Mangapill", + "formattedName": "Manga_Mangapill", + "package": "manga_providers", + "fields": [ + { + "name": "Url", + "jsonName": "Url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "req.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "req.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/providers/weebcentral.go", + "filename": "weebcentral.go", + "name": "WeebCentral", + "formattedName": "Manga_WeebCentral", + "package": "manga_providers", + "fields": [ + { + "name": "Url", + "jsonName": "Url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "req.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "req.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/repository.go", + "filename": "repository.go", + "name": "Repository", + "formattedName": "Manga_Repository", + "package": "manga", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "fileCacher", + "jsonName": "fileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cacheDir", + "jsonName": "cacheDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "providerExtensionBank", + "jsonName": "providerExtensionBank", + "goType": "extension.UnifiedBank", + "typescriptType": "Extension_UnifiedBank", + "usedTypescriptType": "Extension_UnifiedBank", + "usedStructName": "extension.UnifiedBank", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "serverUri", + "jsonName": "serverUri", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "downloadDir", + "jsonName": "downloadDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "db", + "jsonName": "db", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "models.Settings", + "typescriptType": "Models_Settings", + "usedTypescriptType": "Models_Settings", + "usedStructName": "models.Settings", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/manga/repository.go", + "filename": "repository.go", + "name": "NewRepositoryOptions", + "formattedName": "Manga_NewRepositoryOptions", + "package": "manga", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CacheDir", + "jsonName": "CacheDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileCacher", + "jsonName": "FileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ServerURI", + "jsonName": "ServerURI", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WsEventManager", + "jsonName": "WsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DownloadDir", + "jsonName": "DownloadDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/iina/iina.go", + "filename": "iina.go", + "name": "Playback", + "formattedName": "Playback", + "package": "iina", + "fields": [ + { + "name": "Filename", + "jsonName": "Filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Paused", + "jsonName": "Paused", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Position", + "jsonName": "Position", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "Duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsRunning", + "jsonName": "IsRunning", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filepath", + "jsonName": "Filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/iina/iina.go", + "filename": "iina.go", + "name": "Iina", + "formattedName": "Iina", + "package": "iina", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Playback", + "jsonName": "Playback", + "goType": "Playback", + "typescriptType": "Playback", + "usedTypescriptType": "Playback", + "usedStructName": "iina.Playback", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SocketName", + "jsonName": "SocketName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AppPath", + "jsonName": "AppPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Args", + "jsonName": "Args", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackMu", + "jsonName": "playbackMu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cancel", + "jsonName": "cancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [ + " Cancel function for the context" + ] + }, + { + "name": "subscribers", + "jsonName": "subscribers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [ + " Subscribers to the iina events" + ] + }, + { + "name": "conn", + "jsonName": "conn", + "goType": "mpvipc.Connection", + "typescriptType": "Connection", + "usedTypescriptType": "Connection", + "usedStructName": "mpvipc.Connection", + "required": false, + "public": false, + "comments": [ + " Reference to the mpv connection (iina uses mpv IPC)" + ] + }, + { + "name": "cmd", + "jsonName": "cmd", + "goType": "exec.Cmd", + "typescriptType": "Cmd", + "usedTypescriptType": "Cmd", + "usedStructName": "exec.Cmd", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "prevSocketName", + "jsonName": "prevSocketName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "exitedCh", + "jsonName": "exitedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/iina/iina.go", + "filename": "iina.go", + "name": "Subscriber", + "formattedName": "Subscriber", + "package": "iina", + "fields": [ + { + "name": "eventCh", + "jsonName": "eventCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "closedCh", + "jsonName": "closedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/hook_events.go", + "filename": "hook_events.go", + "name": "MediaPlayerLocalFileTrackingRequestedEvent", + "formattedName": "MediaPlayerLocalFileTrackingRequestedEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "StartRefreshDelay", + "jsonName": "startRefreshDelay", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RefreshDelay", + "jsonName": "refreshDelay", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxRetries", + "jsonName": "maxRetries", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MediaPlayerLocalFileTrackingRequestedEvent is triggered when the playback manager wants to track the progress of a local file.", + " Prevent default to stop tracking." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/hook_events.go", + "filename": "hook_events.go", + "name": "MediaPlayerStreamTrackingRequestedEvent", + "formattedName": "MediaPlayerStreamTrackingRequestedEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "StartRefreshDelay", + "jsonName": "startRefreshDelay", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RefreshDelay", + "jsonName": "refreshDelay", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxRetries", + "jsonName": "maxRetries", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxRetriesAfterStart", + "jsonName": "maxRetriesAfterStart", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MediaPlayerStreamTrackingRequestedEvent is triggered when the playback manager wants to track the progress of a stream.", + " Prevent default to stop tracking." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "PlaybackType", + "formattedName": "PlaybackType", + "package": "mediaplayer", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"file\"", + "\"stream\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "Repository", + "formattedName": "Repository", + "package": "mediaplayer", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Default", + "jsonName": "Default", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VLC", + "jsonName": "VLC", + "goType": "vlc2.VLC", + "typescriptType": "VLC", + "usedTypescriptType": "VLC", + "usedStructName": "vlc2.VLC", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MpcHc", + "jsonName": "MpcHc", + "goType": "mpchc2.MpcHc", + "typescriptType": "MpcHc", + "usedTypescriptType": "MpcHc", + "usedStructName": "mpchc2.MpcHc", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Mpv", + "jsonName": "Mpv", + "goType": "mpv.Mpv", + "typescriptType": "Mpv", + "usedTypescriptType": "Mpv", + "usedStructName": "mpv.Mpv", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Iina", + "jsonName": "Iina", + "goType": "iina.Iina", + "typescriptType": "Iina", + "usedTypescriptType": "Iina", + "usedStructName": "iina.Iina", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "continuityManager", + "jsonName": "continuityManager", + "goType": "continuity.Manager", + "typescriptType": "Continuity_Manager", + "usedTypescriptType": "Continuity_Manager", + "usedStructName": "continuity.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playerInUse", + "jsonName": "playerInUse", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "completionThreshold", + "jsonName": "completionThreshold", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "isRunning", + "jsonName": "isRunning", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "currentPlaybackStatus", + "jsonName": "currentPlaybackStatus", + "goType": "PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "subscribers", + "jsonName": "subscribers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cancel", + "jsonName": "cancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "exitedCh", + "jsonName": "exitedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Closed when the media player exits" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "NewRepositoryOptions", + "formattedName": "NewRepositoryOptions", + "package": "mediaplayer", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Default", + "jsonName": "Default", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VLC", + "jsonName": "VLC", + "goType": "vlc2.VLC", + "typescriptType": "VLC", + "usedTypescriptType": "VLC", + "usedStructName": "vlc2.VLC", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MpcHc", + "jsonName": "MpcHc", + "goType": "mpchc2.MpcHc", + "typescriptType": "MpcHc", + "usedTypescriptType": "MpcHc", + "usedStructName": "mpchc2.MpcHc", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Mpv", + "jsonName": "Mpv", + "goType": "mpv.Mpv", + "typescriptType": "Mpv", + "usedTypescriptType": "Mpv", + "usedStructName": "mpv.Mpv", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Iina", + "jsonName": "Iina", + "goType": "iina.Iina", + "typescriptType": "Iina", + "usedTypescriptType": "Iina", + "usedStructName": "iina.Iina", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ContinuityManager", + "jsonName": "ContinuityManager", + "goType": "continuity.Manager", + "typescriptType": "Continuity_Manager", + "usedTypescriptType": "Continuity_Manager", + "usedStructName": "continuity.Manager", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "RepositorySubscriber", + "formattedName": "RepositorySubscriber", + "package": "mediaplayer", + "fields": [ + { + "name": "EventCh", + "jsonName": "EventCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "TrackingStartedEvent", + "formattedName": "TrackingStartedEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "Status", + "jsonName": "Status", + "goType": "PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "TrackingRetryEvent", + "formattedName": "TrackingRetryEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "Reason", + "jsonName": "Reason", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "VideoCompletedEvent", + "formattedName": "VideoCompletedEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "Status", + "jsonName": "Status", + "goType": "PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "TrackingStoppedEvent", + "formattedName": "TrackingStoppedEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "Reason", + "jsonName": "Reason", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "PlaybackStatusEvent", + "formattedName": "PlaybackStatusEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "Status", + "jsonName": "Status", + "goType": "PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "StreamingTrackingStartedEvent", + "formattedName": "StreamingTrackingStartedEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "Status", + "jsonName": "Status", + "goType": "PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "StreamingTrackingRetryEvent", + "formattedName": "StreamingTrackingRetryEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "Reason", + "jsonName": "Reason", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "StreamingVideoCompletedEvent", + "formattedName": "StreamingVideoCompletedEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "Status", + "jsonName": "Status", + "goType": "PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "StreamingTrackingStoppedEvent", + "formattedName": "StreamingTrackingStoppedEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "Reason", + "jsonName": "Reason", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "StreamingPlaybackStatusEvent", + "formattedName": "StreamingPlaybackStatusEvent", + "package": "mediaplayer", + "fields": [ + { + "name": "Status", + "jsonName": "Status", + "goType": "PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mediaplayer/repository.go", + "filename": "repository.go", + "name": "PlaybackStatus", + "formattedName": "PlaybackStatus", + "package": "mediaplayer", + "fields": [ + { + "name": "CompletionPercentage", + "jsonName": "completionPercentage", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Playing", + "jsonName": "playing", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filename", + "jsonName": "filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " in ms" + ] + }, + { + "name": "Filepath", + "jsonName": "filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CurrentTimeInSeconds", + "jsonName": "currentTimeInSeconds", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " in seconds" + ] + }, + { + "name": "DurationInSeconds", + "jsonName": "durationInSeconds", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " in seconds" + ] + }, + { + "name": "PlaybackType", + "jsonName": "playbackType", + "goType": "PlaybackType", + "typescriptType": "PlaybackType", + "usedTypescriptType": "PlaybackType", + "usedStructName": "mediaplayer.PlaybackType", + "required": true, + "public": true, + "comments": [ + " \"file\", \"stream\"" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mpchc/mpc_hc.go", + "filename": "mpc_hc.go", + "name": "MpcHc", + "formattedName": "MpcHc", + "package": "mpchc", + "fields": [ + { + "name": "Host", + "jsonName": "Host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Port", + "jsonName": "Port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "Path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mpchc/variables.go", + "filename": "variables.go", + "name": "Variables", + "formattedName": "Variables", + "package": "mpchc", + "fields": [ + { + "name": "Version", + "jsonName": "version", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "File", + "jsonName": "file", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FilePath", + "jsonName": "filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileDir", + "jsonName": "filedir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "state", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StateString", + "jsonName": "statestring", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Position", + "jsonName": "position", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PositionString", + "jsonName": "positionstring", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DurationString", + "jsonName": "durationstring", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VolumeLevel", + "jsonName": "volumelevel", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Muted", + "jsonName": "muted", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mpv/mpv.go", + "filename": "mpv.go", + "name": "Playback", + "formattedName": "Playback", + "package": "mpv", + "fields": [ + { + "name": "Filename", + "jsonName": "Filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Paused", + "jsonName": "Paused", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Position", + "jsonName": "Position", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "Duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsRunning", + "jsonName": "IsRunning", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filepath", + "jsonName": "Filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mpv/mpv.go", + "filename": "mpv.go", + "name": "Mpv", + "formattedName": "Mpv", + "package": "mpv", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Playback", + "jsonName": "Playback", + "goType": "Playback", + "typescriptType": "Playback", + "usedTypescriptType": "Playback", + "usedStructName": "mpv.Playback", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SocketName", + "jsonName": "SocketName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AppPath", + "jsonName": "AppPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Args", + "jsonName": "Args", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackMu", + "jsonName": "playbackMu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cancel", + "jsonName": "cancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [ + " Cancel function for the context" + ] + }, + { + "name": "subscribers", + "jsonName": "subscribers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [ + " Subscribers to the mpv events" + ] + }, + { + "name": "conn", + "jsonName": "conn", + "goType": "mpvipc.Connection", + "typescriptType": "Connection", + "usedTypescriptType": "Connection", + "usedStructName": "mpvipc.Connection", + "required": false, + "public": false, + "comments": [ + " Reference to the mpv connection" + ] + }, + { + "name": "cmd", + "jsonName": "cmd", + "goType": "exec.Cmd", + "typescriptType": "Cmd", + "usedTypescriptType": "Cmd", + "usedStructName": "exec.Cmd", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "prevSocketName", + "jsonName": "prevSocketName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "exitedCh", + "jsonName": "exitedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mpv/mpv.go", + "filename": "mpv.go", + "name": "Subscriber", + "formattedName": "Subscriber", + "package": "mpv", + "fields": [ + { + "name": "eventCh", + "jsonName": "eventCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "closedCh", + "jsonName": "closedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediaplayers/mpvipc/mpvipc.go", + "filename": "mpvipc.go", + "name": "Connection", + "formattedName": "Connection", + "package": "mpvipc", + "fields": [ + { + "name": "client", + "jsonName": "client", + "goType": "net.Conn", + "typescriptType": "Conn", + "usedTypescriptType": "Conn", + "usedStructName": "net.Conn", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "socketName", + "jsonName": "socketName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "lastRequest", + "jsonName": "lastRequest", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "waitingRequests", + "jsonName": "waitingRequests", + "goType": "map[uint]", + "typescriptType": "Record\u003cnumber, any\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "lastListener", + "jsonName": "lastListener", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "eventListeners", + "jsonName": "eventListeners", + "goType": "map[uint]", + "typescriptType": "Record\u003cnumber, any\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "lastCloseWaiter", + "jsonName": "lastCloseWaiter", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "closeWaiters", + "jsonName": "closeWaiters", + "goType": "map[uint]", + "typescriptType": "Record\u003cnumber, any\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "lock", + "jsonName": "lock", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " Connection represents a connection to a mpv IPC socket" + ] + }, + { + "filepath": "../internal/mediaplayers/mpvipc/mpvipc.go", + "filename": "mpvipc.go", + "name": "Event", + "formattedName": "Event", + "package": "mpvipc", + "fields": [ + { + "name": "Name", + "jsonName": "event", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Reason", + "jsonName": "reason", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Prefix", + "jsonName": "prefix", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Level", + "jsonName": "level", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Text", + "jsonName": "text", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "data", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Event represents an event received from mpv. For a list of all possible", + " events, see https://mpv.io/manual/master/#list-of-events" + ] + }, + { + "filepath": "../internal/mediaplayers/vlc/browse.go", + "filename": "browse.go", + "name": "File", + "formattedName": "File", + "package": "vlc", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " file or dir" + ] + }, + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AccessTime", + "jsonName": "access_time", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UID", + "jsonName": "uid", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CreationTime", + "jsonName": "creation_time", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "GID", + "jsonName": "gid", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ModificationTime", + "jsonName": "modification_time", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Mode", + "jsonName": "mode", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URI", + "jsonName": "uri", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " File struct represents a single item in the browsed directory. Can be a file or a dir" + ] + }, + { + "filepath": "../internal/mediaplayers/vlc/playlist.go", + "filename": "playlist.go", + "name": "Node", + "formattedName": "Node", + "package": "vlc", + "fields": [ + { + "name": "Ro", + "jsonName": "ro", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " node or leaf" + ] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "URI", + "jsonName": "uri", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Current", + "jsonName": "current", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Children", + "jsonName": "children", + "goType": "[]Node", + "typescriptType": "Array\u003cNode\u003e", + "usedTypescriptType": "Node", + "usedStructName": "vlc.Node", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Node structure (node or leaf type) is the basic element of VLC's playlist tree representation.", + " Leafs are playlist items. Nodes are playlists or folders inside playlists." + ] + }, + { + "filepath": "../internal/mediaplayers/vlc/status.go", + "filename": "status.go", + "name": "Status", + "formattedName": "Status", + "package": "vlc", + "fields": [ + { + "name": "Fullscreen", + "jsonName": "fullscreen", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Stats", + "jsonName": "stats", + "goType": "Stats", + "typescriptType": "Stats", + "usedTypescriptType": "Stats", + "usedStructName": "vlc.Stats", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AspectRatio", + "jsonName": "aspectratio", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AudioDelay", + "jsonName": "audiodelay", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "APIVersion", + "jsonName": "apiversion", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CurrentPlID", + "jsonName": "currentplid", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Time", + "jsonName": "time", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Volume", + "jsonName": "volume", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Length", + "jsonName": "length", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Random", + "jsonName": "random", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AudioFilters", + "jsonName": "audiofilters", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rate", + "jsonName": "rate", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VideoEffects", + "jsonName": "videoeffects", + "goType": "VideoEffects", + "typescriptType": "VideoEffects", + "usedTypescriptType": "VideoEffects", + "usedStructName": "vlc.VideoEffects", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "state", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Loop", + "jsonName": "loop", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Version", + "jsonName": "version", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Position", + "jsonName": "position", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Information", + "jsonName": "information", + "goType": "Information", + "typescriptType": "Information", + "usedTypescriptType": "Information", + "usedStructName": "vlc.Information", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Repeat", + "jsonName": "repeat", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SubtitleDelay", + "jsonName": "subtitledelay", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Equalizer", + "jsonName": "equalizer", + "goType": "[]Equalizer", + "typescriptType": "Array\u003cEqualizer\u003e", + "usedTypescriptType": "Equalizer", + "usedStructName": "vlc.Equalizer", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Status contains information related to the VLC instance status. Use parseStatus to parse the response from a", + " status.go function." + ] + }, + { + "filepath": "../internal/mediaplayers/vlc/status.go", + "filename": "status.go", + "name": "Stats", + "formattedName": "Stats", + "package": "vlc", + "fields": [ + { + "name": "InputBitRate", + "jsonName": "inputbitrate", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SentBytes", + "jsonName": "sentbytes", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LosABuffers", + "jsonName": "lostabuffers", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AveragedEMuxBitrate", + "jsonName": "averagedemuxbitrate", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReadPackets", + "jsonName": "readpackets", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DemuxReadPackets", + "jsonName": "demuxreadpackets", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LostPictures", + "jsonName": "lostpictures", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisplayedPictures", + "jsonName": "displayedpictures", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SentPackets", + "jsonName": "sentpackets", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DemuxReadBytes", + "jsonName": "demuxreadbytes", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DemuxBitRate", + "jsonName": "demuxbitrate", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PlayedABuffers", + "jsonName": "playedabuffers", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DemuxDiscontinuity", + "jsonName": "demuxdiscontinuity", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DecodeAudio", + "jsonName": "decodedaudio", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SendBitRate", + "jsonName": "sendbitrate", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReadBytes", + "jsonName": "readbytes", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AverageInputBitRate", + "jsonName": "averageinputbitrate", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DemuxCorrupted", + "jsonName": "demuxcorrupted", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DecodedVideo", + "jsonName": "decodedvideo", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Stats contains certain statistics of a VLC instance. A Stats variable is included in Status" + ] + }, + { + "filepath": "../internal/mediaplayers/vlc/status.go", + "filename": "status.go", + "name": "VideoEffects", + "formattedName": "VideoEffects", + "package": "vlc", + "fields": [ + { + "name": "Hue", + "jsonName": "hue", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Saturation", + "jsonName": "saturation", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Contrast", + "jsonName": "contrast", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Brightness", + "jsonName": "brightness", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Gamma", + "jsonName": "gamma", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " VideoEffects contains the current video effects configuration. A VideoEffects variable is included in Status" + ] + }, + { + "filepath": "../internal/mediaplayers/vlc/status.go", + "filename": "status.go", + "name": "Information", + "formattedName": "Information", + "package": "vlc", + "fields": [ + { + "name": "Chapter", + "jsonName": "chapter", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Chapters", + "jsonName": "chapters", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Category", + "jsonName": "category", + "goType": "map[string]__STRUCT__", + "inlineStructType": "map[string]struct{\nFilename string `json:\"filename\"`\nCodec string `json:\"Codec\"`\nChannels string `json:\"Channels\"`\nBitsPerSample string `json:\"Bits_per_sample\"`\nType string `json:\"Type\"`\nSampleRate string `json:\"Sample_rate\"`}", + "typescriptType": "Record\u003cstring, { filename: string; Codec: string; Channels: string; Bits_per_sample: string; Type: string; Sample_rate: string; }\u003e", + "usedTypescriptType": "{ filename: string; Codec: string; Channels: string; Bits_per_sample: string; Type: string; Sample_rate: string; }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Titles", + "jsonName": "titles", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Information contains information related to the item currently being played. It is also part of Status" + ] + }, + { + "filepath": "../internal/mediaplayers/vlc/status.go", + "filename": "status.go", + "name": "Equalizer", + "formattedName": "Equalizer", + "package": "vlc", + "fields": [ + { + "name": "Presets", + "jsonName": "presets", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Bands", + "jsonName": "bands", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Preamp", + "jsonName": "preamp", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Equalizer contains information related to the equalizer configuration. An Equalizer variable is included in Status" + ] + }, + { + "filepath": "../internal/mediaplayers/vlc/vlc.go", + "filename": "vlc.go", + "name": "VLC", + "formattedName": "VLC", + "package": "vlc", + "fields": [ + { + "name": "Host", + "jsonName": "Host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Port", + "jsonName": "Port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Password", + "jsonName": "Password", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "Path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " VLC struct represents an http interface enabled VLC instance. Build using NewVLC()" + ] + }, + { + "filepath": "../internal/mediastream/optimizer/optimizer.go", + "filename": "optimizer.go", + "name": "Quality", + "formattedName": "Quality", + "package": "optimizer", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"low\"", + "\"medium\"", + "\"high\"", + "\"max\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/mediastream/optimizer/optimizer.go", + "filename": "optimizer.go", + "name": "Optimizer", + "formattedName": "Optimizer", + "package": "optimizer", + "fields": [ + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "libraryDir", + "jsonName": "libraryDir", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "concurrentTasks", + "jsonName": "concurrentTasks", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/optimizer/optimizer.go", + "filename": "optimizer.go", + "name": "NewOptimizerOptions", + "formattedName": "NewOptimizerOptions", + "package": "optimizer", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/optimizer/optimizer.go", + "filename": "optimizer.go", + "name": "StartMediaOptimizationOptions", + "formattedName": "StartMediaOptimizationOptions", + "package": "optimizer", + "fields": [ + { + "name": "Filepath", + "jsonName": "Filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Quality", + "jsonName": "Quality", + "goType": "Quality", + "typescriptType": "Quality", + "usedTypescriptType": "Quality", + "usedStructName": "optimizer.Quality", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AudioChannelIndex", + "jsonName": "AudioChannelIndex", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaInfo", + "jsonName": "MediaInfo", + "goType": "videofile.MediaInfo", + "typescriptType": "MediaInfo", + "usedTypescriptType": "MediaInfo", + "usedStructName": "videofile.MediaInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/playback.go", + "filename": "playback.go", + "name": "StreamType", + "formattedName": "Mediastream_StreamType", + "package": "mediastream", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"transcode\"", + "\"optimized\"", + "\"direct\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/mediastream/playback.go", + "filename": "playback.go", + "name": "PlaybackManager", + "formattedName": "Mediastream_PlaybackManager", + "package": "mediastream", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentMediaContainer", + "jsonName": "currentMediaContainer", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " The current media being played." + ] + }, + { + "name": "repository", + "jsonName": "repository", + "goType": "Repository", + "typescriptType": "Mediastream_Repository", + "usedTypescriptType": "Mediastream_Repository", + "usedStructName": "mediastream.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mediaContainers", + "jsonName": "mediaContainers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [ + " Temporary cache for the media containers." + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/playback.go", + "filename": "playback.go", + "name": "PlaybackState", + "formattedName": "Mediastream_PlaybackState", + "package": "mediastream", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " The media ID" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/playback.go", + "filename": "playback.go", + "name": "MediaContainer", + "formattedName": "Mediastream_MediaContainer", + "package": "mediastream", + "fields": [ + { + "name": "Filepath", + "jsonName": "filePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamType", + "jsonName": "streamType", + "goType": "StreamType", + "typescriptType": "Mediastream_StreamType", + "usedTypescriptType": "Mediastream_StreamType", + "usedStructName": "mediastream.StreamType", + "required": true, + "public": true, + "comments": [ + " Tells the frontend how to play the media." + ] + }, + { + "name": "StreamUrl", + "jsonName": "streamUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " The relative endpoint to stream the media." + ] + }, + { + "name": "MediaInfo", + "jsonName": "mediaInfo", + "goType": "videofile.MediaInfo", + "typescriptType": "MediaInfo", + "usedTypescriptType": "MediaInfo", + "usedStructName": "videofile.MediaInfo", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/repository.go", + "filename": "repository.go", + "name": "Repository", + "formattedName": "Mediastream_Repository", + "package": "mediastream", + "fields": [ + { + "name": "transcoder", + "jsonName": "transcoder", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "optimizer", + "jsonName": "optimizer", + "goType": "optimizer.Optimizer", + "typescriptType": "Optimizer", + "usedTypescriptType": "Optimizer", + "usedStructName": "optimizer.Optimizer", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "playbackManager", + "jsonName": "playbackManager", + "goType": "PlaybackManager", + "typescriptType": "Mediastream_PlaybackManager", + "usedTypescriptType": "Mediastream_PlaybackManager", + "usedStructName": "mediastream.PlaybackManager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mediaInfoExtractor", + "jsonName": "mediaInfoExtractor", + "goType": "videofile.MediaInfoExtractor", + "typescriptType": "MediaInfoExtractor", + "usedTypescriptType": "MediaInfoExtractor", + "usedStructName": "videofile.MediaInfoExtractor", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "fileCacher", + "jsonName": "fileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "reqMu", + "jsonName": "reqMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cacheDir", + "jsonName": "cacheDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [ + " where attachments are stored" + ] + }, + { + "name": "transcodeDir", + "jsonName": "transcodeDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [ + " where stream segments are stored" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/repository.go", + "filename": "repository.go", + "name": "NewRepositoryOptions", + "formattedName": "Mediastream_NewRepositoryOptions", + "package": "mediastream", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FileCacher", + "jsonName": "FileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/repository.go", + "filename": "repository.go", + "name": "StartMediaOptimizationOptions", + "formattedName": "Mediastream_StartMediaOptimizationOptions", + "package": "mediastream", + "fields": [ + { + "name": "Filepath", + "jsonName": "Filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Quality", + "jsonName": "Quality", + "goType": "optimizer.Quality", + "typescriptType": "Quality", + "usedTypescriptType": "Quality", + "usedStructName": "optimizer.Quality", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AudioChannelIndex", + "jsonName": "AudioChannelIndex", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/audiostream.go", + "filename": "audiostream.go", + "name": "AudioStream", + "formattedName": "AudioStream", + "package": "transcoder", + "fields": [ + { + "name": "index", + "jsonName": "index", + "goType": "int32", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "Settings", + "typescriptType": "Settings", + "usedTypescriptType": "Settings", + "usedStructName": "transcoder.Settings", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "transcoder.Stream" + ] + }, + { + "filepath": "../internal/mediastream/transcoder/filestream.go", + "filename": "filestream.go", + "name": "FileStream", + "formattedName": "FileStream", + "package": "transcoder", + "fields": [ + { + "name": "ready", + "jsonName": "ready", + "goType": "sync.WaitGroup", + "typescriptType": "WaitGroup", + "usedTypescriptType": "WaitGroup", + "usedStructName": "sync.WaitGroup", + "required": false, + "public": false, + "comments": [ + " A WaitGroup to synchronize go routines." + ] + }, + { + "name": "err", + "jsonName": "err", + "goType": "error", + "typescriptType": "error", + "usedTypescriptType": "error", + "usedStructName": "transcoder.error", + "required": true, + "public": false, + "comments": [ + " An error that might occur during processing." + ] + }, + { + "name": "Path", + "jsonName": "Path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " The path of the file." + ] + }, + { + "name": "Out", + "jsonName": "Out", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " The output path." + ] + }, + { + "name": "Keyframes", + "jsonName": "Keyframes", + "goType": "Keyframe", + "typescriptType": "Keyframe", + "usedTypescriptType": "Keyframe", + "usedStructName": "transcoder.Keyframe", + "required": false, + "public": true, + "comments": [ + " The keyframes of the video." + ] + }, + { + "name": "Info", + "jsonName": "Info", + "goType": "videofile.MediaInfo", + "typescriptType": "MediaInfo", + "usedTypescriptType": "MediaInfo", + "usedStructName": "videofile.MediaInfo", + "required": false, + "public": true, + "comments": [ + " The media information of the file." + ] + }, + { + "name": "videos", + "jsonName": "videos", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [ + " A map of video streams." + ] + }, + { + "name": "audios", + "jsonName": "audios", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [ + " A map of audio streams." + ] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "Settings", + "typescriptType": "Settings", + "usedTypescriptType": "Settings", + "usedStructName": "transcoder.Settings", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " FileStream represents a stream of file data.", + " It holds the keyframes, media information, video streams, and audio streams." + ] + }, + { + "filepath": "../internal/mediastream/transcoder/hwaccel.go", + "filename": "hwaccel.go", + "name": "HwAccelOptions", + "formattedName": "HwAccelOptions", + "package": "transcoder", + "fields": [ + { + "name": "Kind", + "jsonName": "Kind", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Preset", + "jsonName": "Preset", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CustomSettings", + "jsonName": "CustomSettings", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/keyframes.go", + "filename": "keyframes.go", + "name": "Keyframe", + "formattedName": "Keyframe", + "package": "transcoder", + "fields": [ + { + "name": "Sha", + "jsonName": "Sha", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Keyframes", + "jsonName": "Keyframes", + "goType": "[]float64", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsDone", + "jsonName": "IsDone", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "info", + "jsonName": "info", + "goType": "KeyframeInfo", + "typescriptType": "KeyframeInfo", + "usedTypescriptType": "KeyframeInfo", + "usedStructName": "transcoder.KeyframeInfo", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/keyframes.go", + "filename": "keyframes.go", + "name": "KeyframeInfo", + "formattedName": "KeyframeInfo", + "package": "transcoder", + "fields": [ + { + "name": "mutex", + "jsonName": "mutex", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ready", + "jsonName": "ready", + "goType": "sync.WaitGroup", + "typescriptType": "WaitGroup", + "usedTypescriptType": "WaitGroup", + "usedStructName": "sync.WaitGroup", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "listeners", + "jsonName": "listeners", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/quality.go", + "filename": "quality.go", + "name": "Quality", + "formattedName": "Quality", + "package": "transcoder", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"240p\"", + "\"360p\"", + "\"480p\"", + "\"720p\"", + "\"1080p\"", + "\"1440p\"", + "\"4k\"", + "\"8k\"", + "\"original\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/settings.go", + "filename": "settings.go", + "name": "HwAccelSettings", + "formattedName": "HwAccelSettings", + "package": "transcoder", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DecodeFlags", + "jsonName": "decodeFlags", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EncodeFlags", + "jsonName": "encodeFlags", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScaleFilter", + "jsonName": "scaleFilter", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WithForcedIdr", + "jsonName": "removeForcedIdr", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/stream.go", + "filename": "stream.go", + "name": "Flags", + "formattedName": "Flags", + "package": "transcoder", + "fields": [], + "aliasOf": { + "goType": "int32", + "typescriptType": "number", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/stream.go", + "filename": "stream.go", + "name": "Stream", + "formattedName": "Stream", + "package": "transcoder", + "fields": [ + { + "name": "kind", + "jsonName": "kind", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "handle", + "jsonName": "handle", + "goType": "StreamHandle", + "typescriptType": "StreamHandle", + "usedTypescriptType": "StreamHandle", + "usedStructName": "transcoder.StreamHandle", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "file", + "jsonName": "file", + "goType": "FileStream", + "typescriptType": "FileStream", + "usedTypescriptType": "FileStream", + "usedStructName": "transcoder.FileStream", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "segments", + "jsonName": "segments", + "goType": "[]Segment", + "typescriptType": "Array\u003cSegment\u003e", + "usedTypescriptType": "Segment", + "usedStructName": "transcoder.Segment", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "heads", + "jsonName": "heads", + "goType": "[]Head", + "typescriptType": "Array\u003cHead\u003e", + "usedTypescriptType": "Head", + "usedStructName": "transcoder.Head", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "segmentsLock", + "jsonName": "segmentsLock", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "headsLock", + "jsonName": "headsLock", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "Settings", + "typescriptType": "Settings", + "usedTypescriptType": "Settings", + "usedStructName": "transcoder.Settings", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "killCh", + "jsonName": "killCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "ctx", + "jsonName": "ctx", + "goType": "context.Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "context.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cancel", + "jsonName": "cancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/stream.go", + "filename": "stream.go", + "name": "Segment", + "formattedName": "Segment", + "package": "transcoder", + "fields": [ + { + "name": "channel", + "jsonName": "channel", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "encoder", + "jsonName": "encoder", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/stream.go", + "filename": "stream.go", + "name": "Head", + "formattedName": "Head", + "package": "transcoder", + "fields": [ + { + "name": "segment", + "jsonName": "segment", + "goType": "int32", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "end", + "jsonName": "end", + "goType": "int32", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "command", + "jsonName": "command", + "goType": "exec.Cmd", + "typescriptType": "Cmd", + "usedTypescriptType": "Cmd", + "usedStructName": "exec.Cmd", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "stdin", + "jsonName": "stdin", + "goType": "io.WriteCloser", + "typescriptType": "WriteCloser", + "usedTypescriptType": "WriteCloser", + "usedStructName": "io.WriteCloser", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/tracker.go", + "filename": "tracker.go", + "name": "ClientInfo", + "formattedName": "ClientInfo", + "package": "transcoder", + "fields": [ + { + "name": "client", + "jsonName": "client", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "quality", + "jsonName": "quality", + "goType": "Quality", + "typescriptType": "Quality", + "usedTypescriptType": "Quality", + "usedStructName": "transcoder.Quality", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "audio", + "jsonName": "audio", + "goType": "int32", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "head", + "jsonName": "head", + "goType": "int32", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/tracker.go", + "filename": "tracker.go", + "name": "Tracker", + "formattedName": "Tracker", + "package": "transcoder", + "fields": [ + { + "name": "clients", + "jsonName": "clients", + "goType": "map[string]ClientInfo", + "typescriptType": "Record\u003cstring, ClientInfo\u003e", + "usedTypescriptType": "ClientInfo", + "usedStructName": "transcoder.ClientInfo", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "visitDate", + "jsonName": "visitDate", + "goType": "map[string]time.Time", + "typescriptType": "Record\u003cstring, string\u003e", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "lastUsage", + "jsonName": "lastUsage", + "goType": "map[string]time.Time", + "typescriptType": "Record\u003cstring, string\u003e", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "transcoder", + "jsonName": "transcoder", + "goType": "Transcoder", + "typescriptType": "Transcoder", + "usedTypescriptType": "Transcoder", + "usedStructName": "transcoder.Transcoder", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "deletedStream", + "jsonName": "deletedStream", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "killCh", + "jsonName": "killCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Close channel to stop tracker" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/transcoder.go", + "filename": "transcoder.go", + "name": "Transcoder", + "formattedName": "Transcoder", + "package": "transcoder", + "fields": [ + { + "name": "streams", + "jsonName": "streams", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "clientChan", + "jsonName": "clientChan", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "tracker", + "jsonName": "tracker", + "goType": "Tracker", + "typescriptType": "Tracker", + "usedTypescriptType": "Tracker", + "usedStructName": "transcoder.Tracker", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "Settings", + "typescriptType": "Settings", + "usedTypescriptType": "Settings", + "usedStructName": "transcoder.Settings", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/transcoder.go", + "filename": "transcoder.go", + "name": "Settings", + "formattedName": "Settings", + "package": "transcoder", + "fields": [ + { + "name": "StreamDir", + "jsonName": "StreamDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HwAccel", + "jsonName": "HwAccel", + "goType": "HwAccelSettings", + "typescriptType": "HwAccelSettings", + "usedTypescriptType": "HwAccelSettings", + "usedStructName": "transcoder.HwAccelSettings", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FfmpegPath", + "jsonName": "FfmpegPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FfprobePath", + "jsonName": "FfprobePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/transcoder.go", + "filename": "transcoder.go", + "name": "NewTranscoderOptions", + "formattedName": "NewTranscoderOptions", + "package": "transcoder", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "HwAccelKind", + "jsonName": "HwAccelKind", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Preset", + "jsonName": "Preset", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TempOutDir", + "jsonName": "TempOutDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FfmpegPath", + "jsonName": "FfmpegPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FfprobePath", + "jsonName": "FfprobePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HwAccelCustomSettings", + "jsonName": "HwAccelCustomSettings", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/transcoder/videostream.go", + "filename": "videostream.go", + "name": "VideoStream", + "formattedName": "VideoStream", + "package": "transcoder", + "fields": [ + { + "name": "quality", + "jsonName": "quality", + "goType": "Quality", + "typescriptType": "Quality", + "usedTypescriptType": "Quality", + "usedStructName": "transcoder.Quality", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "Settings", + "typescriptType": "Settings", + "usedTypescriptType": "Settings", + "usedStructName": "transcoder.Settings", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "transcoder.Stream" + ] + }, + { + "filepath": "../internal/mediastream/videofile/info.go", + "filename": "info.go", + "name": "MediaInfo", + "formattedName": "MediaInfo", + "package": "videofile", + "fields": [ + { + "name": "ready", + "jsonName": "ready", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "Sha", + "jsonName": "sha", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Extension", + "jsonName": "extension", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MimeCodec", + "jsonName": "mimeCodec", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "float32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Container", + "jsonName": "container", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Video", + "jsonName": "video", + "goType": "Video", + "typescriptType": "Video", + "usedTypescriptType": "Video", + "usedStructName": "videofile.Video", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Videos", + "jsonName": "videos", + "goType": "[]Video", + "typescriptType": "Array\u003cVideo\u003e", + "usedTypescriptType": "Video", + "usedStructName": "videofile.Video", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Audios", + "jsonName": "audios", + "goType": "[]Audio", + "typescriptType": "Array\u003cAudio\u003e", + "usedTypescriptType": "Audio", + "usedStructName": "videofile.Audio", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Subtitles", + "jsonName": "subtitles", + "goType": "[]Subtitle", + "typescriptType": "Array\u003cSubtitle\u003e", + "usedTypescriptType": "Subtitle", + "usedStructName": "videofile.Subtitle", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Fonts", + "jsonName": "fonts", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Chapters", + "jsonName": "chapters", + "goType": "[]Chapter", + "typescriptType": "Array\u003cChapter\u003e", + "usedTypescriptType": "Chapter", + "usedStructName": "videofile.Chapter", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/videofile/info.go", + "filename": "info.go", + "name": "Video", + "formattedName": "Video", + "package": "videofile", + "fields": [ + { + "name": "Codec", + "jsonName": "codec", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MimeCodec", + "jsonName": "mimeCodec", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Quality", + "jsonName": "quality", + "goType": "Quality", + "typescriptType": "Quality", + "usedTypescriptType": "Quality", + "usedStructName": "videofile.Quality", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Width", + "jsonName": "width", + "goType": "uint32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Height", + "jsonName": "height", + "goType": "uint32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Bitrate", + "jsonName": "bitrate", + "goType": "uint32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/videofile/info.go", + "filename": "info.go", + "name": "Audio", + "formattedName": "Audio", + "package": "videofile", + "fields": [ + { + "name": "Index", + "jsonName": "index", + "goType": "uint32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Codec", + "jsonName": "codec", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MimeCodec", + "jsonName": "mimeCodec", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsDefault", + "jsonName": "isDefault", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsForced", + "jsonName": "isForced", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Channels", + "jsonName": "channels", + "goType": "uint32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/videofile/info.go", + "filename": "info.go", + "name": "Subtitle", + "formattedName": "Subtitle", + "package": "videofile", + "fields": [ + { + "name": "Index", + "jsonName": "index", + "goType": "uint32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Codec", + "jsonName": "codec", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Extension", + "jsonName": "extension", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsDefault", + "jsonName": "isDefault", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsForced", + "jsonName": "isForced", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsExternal", + "jsonName": "isExternal", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Link", + "jsonName": "link", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/videofile/info.go", + "filename": "info.go", + "name": "Chapter", + "formattedName": "Chapter", + "package": "videofile", + "fields": [ + { + "name": "StartTime", + "jsonName": "startTime", + "goType": "float32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EndTime", + "jsonName": "endTime", + "goType": "float32", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/videofile/info.go", + "filename": "info.go", + "name": "MediaInfoExtractor", + "formattedName": "MediaInfoExtractor", + "package": "videofile", + "fields": [ + { + "name": "fileCacher", + "jsonName": "fileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/mediastream/videofile/video_quality.go", + "filename": "video_quality.go", + "name": "Quality", + "formattedName": "Quality", + "package": "videofile", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"240p\"", + "\"360p\"", + "\"480p\"", + "\"720p\"", + "\"1080p\"", + "\"1440p\"", + "\"4k\"", + "\"8k\"", + "\"original\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/mkvparser/metadata.go", + "filename": "metadata.go", + "name": "TrackType", + "formattedName": "MKVParser_TrackType", + "package": "mkvparser", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"video\"", + "\"audio\"", + "\"subtitle\"", + "\"logo\"", + "\"buttons\"", + "\"complex\"", + "\"unknown\"" + ] + }, + "comments": [ + " TrackType represents the type of a Matroska track." + ] + }, + { + "filepath": "../internal/mkvparser/metadata.go", + "filename": "metadata.go", + "name": "AttachmentType", + "formattedName": "MKVParser_AttachmentType", + "package": "mkvparser", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"font\"", + "\"subtitle\"", + "\"other\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/mkvparser/metadata.go", + "filename": "metadata.go", + "name": "TrackInfo", + "formattedName": "MKVParser_TrackInfo", + "package": "mkvparser", + "fields": [ + { + "name": "Number", + "jsonName": "number", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UID", + "jsonName": "uid", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "TrackType", + "typescriptType": "MKVParser_TrackType", + "usedTypescriptType": "MKVParser_TrackType", + "usedStructName": "mkvparser.TrackType", + "required": true, + "public": true, + "comments": [ + " \"video\", \"audio\", \"subtitle\", etc." + ] + }, + { + "name": "CodecID", + "jsonName": "codecID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " Best effort language code" + ] + }, + { + "name": "LanguageIETF", + "jsonName": "languageIETF", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " IETF language code" + ] + }, + { + "name": "Default", + "jsonName": "default", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Forced", + "jsonName": "forced", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CodecPrivate", + "jsonName": "codecPrivate", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " Raw CodecPrivate data, often used for subtitle headers (e.g., ASS/SSA styles)" + ] + }, + { + "name": "Video", + "jsonName": "video", + "goType": "VideoTrack", + "typescriptType": "MKVParser_VideoTrack", + "usedTypescriptType": "MKVParser_VideoTrack", + "usedStructName": "mkvparser.VideoTrack", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Audio", + "jsonName": "audio", + "goType": "AudioTrack", + "typescriptType": "MKVParser_AudioTrack", + "usedTypescriptType": "MKVParser_AudioTrack", + "usedStructName": "mkvparser.AudioTrack", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "contentEncodings", + "jsonName": "", + "goType": "ContentEncodings", + "typescriptType": "MKVParser_ContentEncodings", + "usedTypescriptType": "MKVParser_ContentEncodings", + "usedStructName": "mkvparser.ContentEncodings", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "defaultDuration", + "jsonName": "", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " in ns" + ] + } + ], + "comments": [ + " TrackInfo holds extracted information about a media track." + ] + }, + { + "filepath": "../internal/mkvparser/metadata.go", + "filename": "metadata.go", + "name": "ChapterInfo", + "formattedName": "MKVParser_ChapterInfo", + "package": "mkvparser", + "fields": [ + { + "name": "UID", + "jsonName": "uid", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Start", + "jsonName": "start", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Start time in seconds" + ] + }, + { + "name": "End", + "jsonName": "end", + "goType": "float64", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [ + " End time in seconds" + ] + }, + { + "name": "Text", + "jsonName": "text", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Languages", + "jsonName": "languages", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " Legacy 3-letter language codes" + ] + }, + { + "name": "LanguagesIETF", + "jsonName": "languagesIETF", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " IETF language tags" + ] + } + ], + "comments": [ + " ChapterInfo holds extracted information about a chapter." + ] + }, + { + "filepath": "../internal/mkvparser/metadata.go", + "filename": "metadata.go", + "name": "AttachmentInfo", + "formattedName": "MKVParser_AttachmentInfo", + "package": "mkvparser", + "fields": [ + { + "name": "UID", + "jsonName": "uid", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filename", + "jsonName": "filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Mimetype", + "jsonName": "mimetype", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "AttachmentType", + "typescriptType": "MKVParser_AttachmentType", + "usedTypescriptType": "MKVParser_AttachmentType", + "usedStructName": "mkvparser.AttachmentType", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " Data loaded into memory" + ] + }, + { + "name": "IsCompressed", + "jsonName": "", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Whether the data is compressed" + ] + } + ], + "comments": [ + " AttachmentInfo holds extracted information about an attachment." + ] + }, + { + "filepath": "../internal/mkvparser/metadata.go", + "filename": "metadata.go", + "name": "Metadata", + "formattedName": "MKVParser_Metadata", + "package": "mkvparser", + "fields": [ + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Duration in seconds" + ] + }, + { + "name": "TimecodeScale", + "jsonName": "timecodeScale", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Original timecode scale from Info" + ] + }, + { + "name": "MuxingApp", + "jsonName": "muxingApp", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WritingApp", + "jsonName": "writingApp", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Tracks", + "jsonName": "tracks", + "goType": "[]TrackInfo", + "typescriptType": "Array\u003cMKVParser_TrackInfo\u003e", + "usedTypescriptType": "MKVParser_TrackInfo", + "usedStructName": "mkvparser.TrackInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VideoTracks", + "jsonName": "videoTracks", + "goType": "[]TrackInfo", + "typescriptType": "Array\u003cMKVParser_TrackInfo\u003e", + "usedTypescriptType": "MKVParser_TrackInfo", + "usedStructName": "mkvparser.TrackInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AudioTracks", + "jsonName": "audioTracks", + "goType": "[]TrackInfo", + "typescriptType": "Array\u003cMKVParser_TrackInfo\u003e", + "usedTypescriptType": "MKVParser_TrackInfo", + "usedStructName": "mkvparser.TrackInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SubtitleTracks", + "jsonName": "subtitleTracks", + "goType": "[]TrackInfo", + "typescriptType": "Array\u003cMKVParser_TrackInfo\u003e", + "usedTypescriptType": "MKVParser_TrackInfo", + "usedStructName": "mkvparser.TrackInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Chapters", + "jsonName": "chapters", + "goType": "[]ChapterInfo", + "typescriptType": "Array\u003cMKVParser_ChapterInfo\u003e", + "usedTypescriptType": "MKVParser_ChapterInfo", + "usedStructName": "mkvparser.ChapterInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Attachments", + "jsonName": "attachments", + "goType": "[]AttachmentInfo", + "typescriptType": "Array\u003cMKVParser_AttachmentInfo\u003e", + "usedTypescriptType": "MKVParser_AttachmentInfo", + "usedStructName": "mkvparser.AttachmentInfo", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MimeCodec", + "jsonName": "mimeCodec", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " RFC 6381 codec string" + ] + }, + { + "name": "Error", + "jsonName": "", + "goType": "error", + "typescriptType": "MKVParser_error", + "usedTypescriptType": "MKVParser_error", + "usedStructName": "mkvparser.error", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Metadata holds all extracted metadata." + ] + }, + { + "filepath": "../internal/mkvparser/mkvparser.go", + "filename": "mkvparser.go", + "name": "SubtitleEvent", + "formattedName": "MKVParser_SubtitleEvent", + "package": "mkvparser", + "fields": [ + { + "name": "TrackNumber", + "jsonName": "trackNumber", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Text", + "jsonName": "text", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Content" + ] + }, + { + "name": "StartTime", + "jsonName": "startTime", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Start time in seconds" + ] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Duration in seconds" + ] + }, + { + "name": "CodecID", + "jsonName": "codecID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g., \"S_TEXT/ASS\", \"S_TEXT/UTF8\"" + ] + }, + { + "name": "ExtraData", + "jsonName": "extraData", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "HeadPos", + "jsonName": "", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Position in the stream" + ] + } + ], + "comments": [ + " SubtitleEvent holds information for a single subtitle entry." + ] + }, + { + "filepath": "../internal/mkvparser/mkvparser.go", + "filename": "mkvparser.go", + "name": "MetadataParser", + "formattedName": "MKVParser_MetadataParser", + "package": "mkvparser", + "fields": [ + { + "name": "reader", + "jsonName": "reader", + "goType": "io.ReadSeeker", + "typescriptType": "ReadSeeker", + "usedTypescriptType": "ReadSeeker", + "usedStructName": "io.ReadSeeker", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "realLogger", + "jsonName": "realLogger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "parseErr", + "jsonName": "parseErr", + "goType": "error", + "typescriptType": "MKVParser_error", + "usedTypescriptType": "MKVParser_error", + "usedStructName": "mkvparser.error", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "parseOnce", + "jsonName": "parseOnce", + "goType": "sync.Once", + "typescriptType": "Once", + "usedTypescriptType": "Once", + "usedStructName": "sync.Once", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadataOnce", + "jsonName": "metadataOnce", + "goType": "sync.Once", + "typescriptType": "Once", + "usedTypescriptType": "Once", + "usedStructName": "sync.Once", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "timecodeScale", + "jsonName": "timecodeScale", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "currentTrack", + "jsonName": "currentTrack", + "goType": "TrackInfo", + "typescriptType": "MKVParser_TrackInfo", + "usedTypescriptType": "MKVParser_TrackInfo", + "usedStructName": "mkvparser.TrackInfo", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "tracks", + "jsonName": "tracks", + "goType": "[]TrackInfo", + "typescriptType": "Array\u003cMKVParser_TrackInfo\u003e", + "usedTypescriptType": "MKVParser_TrackInfo", + "usedStructName": "mkvparser.TrackInfo", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "info", + "jsonName": "info", + "goType": "Info", + "typescriptType": "MKVParser_Info", + "usedTypescriptType": "MKVParser_Info", + "usedStructName": "mkvparser.Info", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "chapters", + "jsonName": "chapters", + "goType": "[]ChapterInfo", + "typescriptType": "Array\u003cMKVParser_ChapterInfo\u003e", + "usedTypescriptType": "MKVParser_ChapterInfo", + "usedStructName": "mkvparser.ChapterInfo", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "attachments", + "jsonName": "attachments", + "goType": "[]AttachmentInfo", + "typescriptType": "Array\u003cMKVParser_AttachmentInfo\u003e", + "usedTypescriptType": "MKVParser_AttachmentInfo", + "usedStructName": "mkvparser.AttachmentInfo", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "extractedMetadata", + "jsonName": "extractedMetadata", + "goType": "Metadata", + "typescriptType": "MKVParser_Metadata", + "usedTypescriptType": "MKVParser_Metadata", + "usedStructName": "mkvparser.Metadata", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " MetadataParser parses Matroska metadata from a file." + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "Info", + "formattedName": "MKVParser_Info", + "package": "mkvparser", + "fields": [ + { + "name": "Title", + "jsonName": "Title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MuxingApp", + "jsonName": "MuxingApp", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WritingApp", + "jsonName": "WritingApp", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimecodeScale", + "jsonName": "TimecodeScale", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "Duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DateUTC", + "jsonName": "DateUTC", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Info element and its children" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "TrackEntry", + "formattedName": "MKVParser_TrackEntry", + "package": "mkvparser", + "fields": [ + { + "name": "TrackNumber", + "jsonName": "TrackNumber", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TrackUID", + "jsonName": "TrackUID", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TrackType", + "jsonName": "TrackType", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FlagEnabled", + "jsonName": "FlagEnabled", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FlagDefault", + "jsonName": "FlagDefault", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FlagForced", + "jsonName": "FlagForced", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DefaultDuration", + "jsonName": "DefaultDuration", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "Name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "Language", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LanguageIETF", + "jsonName": "LanguageIETF", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CodecID", + "jsonName": "CodecID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CodecPrivate", + "jsonName": "CodecPrivate", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Video", + "jsonName": "Video", + "goType": "VideoTrack", + "typescriptType": "MKVParser_VideoTrack", + "usedTypescriptType": "MKVParser_VideoTrack", + "usedStructName": "mkvparser.VideoTrack", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Audio", + "jsonName": "Audio", + "goType": "AudioTrack", + "typescriptType": "MKVParser_AudioTrack", + "usedTypescriptType": "MKVParser_AudioTrack", + "usedStructName": "mkvparser.AudioTrack", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ContentEncodings", + "jsonName": "ContentEncodings", + "goType": "ContentEncodings", + "typescriptType": "MKVParser_ContentEncodings", + "usedTypescriptType": "MKVParser_ContentEncodings", + "usedStructName": "mkvparser.ContentEncodings", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " TrackEntry represents a track in the MKV file" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "VideoTrack", + "formattedName": "MKVParser_VideoTrack", + "package": "mkvparser", + "fields": [ + { + "name": "PixelWidth", + "jsonName": "PixelWidth", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PixelHeight", + "jsonName": "PixelHeight", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " VideoTrack contains video-specific track data" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "AudioTrack", + "formattedName": "MKVParser_AudioTrack", + "package": "mkvparser", + "fields": [ + { + "name": "SamplingFrequency", + "jsonName": "SamplingFrequency", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Channels", + "jsonName": "Channels", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BitDepth", + "jsonName": "BitDepth", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AudioTrack contains audio-specific track data" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "ContentEncodings", + "formattedName": "MKVParser_ContentEncodings", + "package": "mkvparser", + "fields": [ + { + "name": "ContentEncoding", + "jsonName": "ContentEncoding", + "goType": "[]ContentEncoding", + "typescriptType": "Array\u003cMKVParser_ContentEncoding\u003e", + "usedTypescriptType": "MKVParser_ContentEncoding", + "usedStructName": "mkvparser.ContentEncoding", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ContentEncodings contains information about how the track data is encoded" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "ContentEncoding", + "formattedName": "MKVParser_ContentEncoding", + "package": "mkvparser", + "fields": [ + { + "name": "ContentEncodingOrder", + "jsonName": "ContentEncodingOrder", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ContentEncodingScope", + "jsonName": "ContentEncodingScope", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ContentEncodingType", + "jsonName": "ContentEncodingType", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ContentCompression", + "jsonName": "ContentCompression", + "goType": "ContentCompression", + "typescriptType": "MKVParser_ContentCompression", + "usedTypescriptType": "MKVParser_ContentCompression", + "usedStructName": "mkvparser.ContentCompression", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ContentEncoding describes a single encoding applied to the track data" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "ContentCompression", + "formattedName": "MKVParser_ContentCompression", + "package": "mkvparser", + "fields": [ + { + "name": "ContentCompAlgo", + "jsonName": "ContentCompAlgo", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ContentCompSettings", + "jsonName": "ContentCompSettings", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ContentCompression describes how the track data is compressed" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "ChapterAtom", + "formattedName": "MKVParser_ChapterAtom", + "package": "mkvparser", + "fields": [ + { + "name": "ChapterUID", + "jsonName": "ChapterUID", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterTimeStart", + "jsonName": "ChapterTimeStart", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterTimeEnd", + "jsonName": "ChapterTimeEnd", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapterDisplay", + "jsonName": "ChapterDisplay", + "goType": "[]ChapterDisplay", + "typescriptType": "Array\u003cMKVParser_ChapterDisplay\u003e", + "usedTypescriptType": "MKVParser_ChapterDisplay", + "usedStructName": "mkvparser.ChapterDisplay", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ChapterAtom represents a single chapter point" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "ChapterDisplay", + "formattedName": "MKVParser_ChapterDisplay", + "package": "mkvparser", + "fields": [ + { + "name": "ChapString", + "jsonName": "ChapString", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ChapLanguage", + "jsonName": "ChapLanguage", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ChapLanguageIETF", + "jsonName": "ChapLanguageIETF", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ChapterDisplay contains displayable chapter information" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "AttachedFile", + "formattedName": "MKVParser_AttachedFile", + "package": "mkvparser", + "fields": [ + { + "name": "FileDescription", + "jsonName": "FileDescription", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileName", + "jsonName": "FileName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileMimeType", + "jsonName": "FileMimeType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileData", + "jsonName": "FileData", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FileUID", + "jsonName": "FileUID", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " AttachedFile represents a file attached to the MKV container" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "Block", + "formattedName": "MKVParser_Block", + "package": "mkvparser", + "fields": [ + { + "name": "TrackNumber", + "jsonName": "TrackNumber", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Timecode", + "jsonName": "Timecode", + "goType": "int16", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "Data", + "goType": "[]string", + "typescriptType": "Array\u003cArray\u003cstring\u003e\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Block represents a data block in the MKV file" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "BlockGroup", + "formattedName": "MKVParser_BlockGroup", + "package": "mkvparser", + "fields": [ + { + "name": "Block", + "jsonName": "Block", + "goType": "Block", + "typescriptType": "MKVParser_Block", + "usedTypescriptType": "MKVParser_Block", + "usedStructName": "mkvparser.Block", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BlockDuration", + "jsonName": "BlockDuration", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " BlockGroup represents a group of blocks with additional information" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "Cluster", + "formattedName": "MKVParser_Cluster", + "package": "mkvparser", + "fields": [ + { + "name": "Timecode", + "jsonName": "Timecode", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SimpleBlock", + "jsonName": "SimpleBlock", + "goType": "[]Block", + "typescriptType": "Array\u003cMKVParser_Block\u003e", + "usedTypescriptType": "MKVParser_Block", + "usedStructName": "mkvparser.Block", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BlockGroup", + "jsonName": "BlockGroup", + "goType": "[]BlockGroup", + "typescriptType": "Array\u003cMKVParser_BlockGroup\u003e", + "usedTypescriptType": "MKVParser_BlockGroup", + "usedStructName": "mkvparser.BlockGroup", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Cluster represents a cluster of blocks in the MKV file" + ] + }, + { + "filepath": "../internal/mkvparser/structs.go", + "filename": "structs.go", + "name": "Tracks", + "formattedName": "MKVParser_Tracks", + "package": "mkvparser", + "fields": [ + { + "name": "TrackEntry", + "jsonName": "TrackEntry", + "goType": "[]TrackEntry", + "typescriptType": "Array\u003cMKVParser_TrackEntry\u003e", + "usedTypescriptType": "MKVParser_TrackEntry", + "usedStructName": "mkvparser.TrackEntry", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Tracks element and its children" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "Manager", + "formattedName": "Nakama_Manager", + "package": "nakama", + "fields": [ + { + "name": "serverHost", + "jsonName": "serverHost", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "serverPort", + "jsonName": "serverPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "username", + "jsonName": "username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "models.NakamaSettings", + "typescriptType": "Models_NakamaSettings", + "usedTypescriptType": "Models_NakamaSettings", + "usedStructName": "models.NakamaSettings", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "platform", + "jsonName": "platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackManager", + "jsonName": "playbackManager", + "goType": "playbackmanager.PlaybackManager", + "typescriptType": "PlaybackManager_PlaybackManager", + "usedTypescriptType": "PlaybackManager_PlaybackManager", + "usedStructName": "playbackmanager.PlaybackManager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "torrentstreamRepository", + "jsonName": "torrentstreamRepository", + "goType": "torrentstream.Repository", + "typescriptType": "Torrentstream_Repository", + "usedTypescriptType": "Torrentstream_Repository", + "usedStructName": "torrentstream.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "debridClientRepository", + "jsonName": "debridClientRepository", + "goType": "debrid_client.Repository", + "typescriptType": "DebridClient_Repository", + "usedTypescriptType": "DebridClient_Repository", + "usedStructName": "debrid_client.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "peerId", + "jsonName": "peerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "peerConnections", + "jsonName": "peerConnections", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "hostConnection", + "jsonName": "hostConnection", + "goType": "HostConnection", + "typescriptType": "Nakama_HostConnection", + "usedTypescriptType": "Nakama_HostConnection", + "usedStructName": "nakama.HostConnection", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "hostConnectionCtx", + "jsonName": "hostConnectionCtx", + "goType": "context.Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "context.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "hostConnectionCancel", + "jsonName": "hostConnectionCancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "hostMu", + "jsonName": "hostMu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "reconnecting", + "jsonName": "reconnecting", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [ + " Flag to prevent multiple concurrent reconnection attempts" + ] + }, + { + "name": "cancel", + "jsonName": "cancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ctx", + "jsonName": "ctx", + "goType": "context.Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "context.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "messageHandlers", + "jsonName": "messageHandlers", + "goType": "map[MessageType]", + "typescriptType": "Record\u003cMessageType, any\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "handlerMu", + "jsonName": "handlerMu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cleanups", + "jsonName": "cleanups", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "reqClient", + "jsonName": "reqClient", + "goType": "req.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "req.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "watchPartyManager", + "jsonName": "watchPartyManager", + "goType": "WatchPartyManager", + "typescriptType": "Nakama_WatchPartyManager", + "usedTypescriptType": "Nakama_WatchPartyManager", + "usedStructName": "nakama.WatchPartyManager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "previousPath", + "jsonName": "previousPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [ + " latest file streamed by the peer - real path on the host" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "NewManagerOptions", + "formattedName": "Nakama_NewManagerOptions", + "package": "nakama", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PlaybackManager", + "jsonName": "PlaybackManager", + "goType": "playbackmanager.PlaybackManager", + "typescriptType": "PlaybackManager_PlaybackManager", + "usedTypescriptType": "PlaybackManager_PlaybackManager", + "usedStructName": "playbackmanager.PlaybackManager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentstreamRepository", + "jsonName": "TorrentstreamRepository", + "goType": "torrentstream.Repository", + "typescriptType": "Torrentstream_Repository", + "usedTypescriptType": "Torrentstream_Repository", + "usedStructName": "torrentstream.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DebridClientRepository", + "jsonName": "DebridClientRepository", + "goType": "debrid_client.Repository", + "typescriptType": "DebridClient_Repository", + "usedTypescriptType": "DebridClient_Repository", + "usedStructName": "debrid_client.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ServerHost", + "jsonName": "ServerHost", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ServerPort", + "jsonName": "ServerPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "ConnectionType", + "formattedName": "Nakama_ConnectionType", + "package": "nakama", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"host\"", + "\"peer\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "MessageType", + "formattedName": "Nakama_MessageType", + "package": "nakama", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"auth\"", + "\"auth_reply\"", + "\"ping\"", + "\"pong\"", + "\"error\"", + "\"custom\"" + ] + }, + "comments": [ + " MessageType represents the type of message being sent" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "Message", + "formattedName": "Nakama_Message", + "package": "nakama", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "MessageType", + "typescriptType": "Nakama_MessageType", + "usedTypescriptType": "Nakama_MessageType", + "usedStructName": "nakama.MessageType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RequestID", + "jsonName": "requestId", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Message represents a message sent between Nakama instances" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "PeerConnection", + "formattedName": "Nakama_PeerConnection", + "package": "nakama", + "fields": [ + { + "name": "ID", + "jsonName": "ID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Internal connection ID (websocket)" + ] + }, + { + "name": "PeerId", + "jsonName": "PeerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " UUID generated by the peer (primary identifier)" + ] + }, + { + "name": "Username", + "jsonName": "Username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Display name (kept for UI purposes)" + ] + }, + { + "name": "Conn", + "jsonName": "Conn", + "goType": "websocket.Conn", + "typescriptType": "Conn", + "usedTypescriptType": "Conn", + "usedStructName": "websocket.Conn", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ConnectionType", + "jsonName": "ConnectionType", + "goType": "ConnectionType", + "typescriptType": "Nakama_ConnectionType", + "usedTypescriptType": "Nakama_ConnectionType", + "usedStructName": "nakama.ConnectionType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Authenticated", + "jsonName": "Authenticated", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastPing", + "jsonName": "LastPing", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " PeerConnection represents a connection from a peer to this host" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "HostConnection", + "formattedName": "Nakama_HostConnection", + "package": "nakama", + "fields": [ + { + "name": "URL", + "jsonName": "URL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PeerId", + "jsonName": "PeerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " UUID generated by this peer instance" + ] + }, + { + "name": "Username", + "jsonName": "Username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Conn", + "jsonName": "Conn", + "goType": "websocket.Conn", + "typescriptType": "Conn", + "usedTypescriptType": "Conn", + "usedStructName": "websocket.Conn", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Authenticated", + "jsonName": "Authenticated", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastPing", + "jsonName": "LastPing", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "reconnectTimer", + "jsonName": "reconnectTimer", + "goType": "time.Timer", + "typescriptType": "Timer", + "usedTypescriptType": "Timer", + "usedStructName": "time.Timer", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " HostConnection represents this instance's connection to a host" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "NakamaEvent", + "formattedName": "Nakama_NakamaEvent", + "package": "nakama", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " NakamaEvent represents events sent to the client" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "AuthPayload", + "formattedName": "Nakama_AuthPayload", + "package": "nakama", + "fields": [ + { + "name": "Password", + "jsonName": "password", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PeerId", + "jsonName": "peerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " UUID generated by the peer" + ] + } + ], + "comments": [ + " AuthPayload represents authentication data" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "AuthReplyPayload", + "formattedName": "Nakama_AuthReplyPayload", + "package": "nakama", + "fields": [ + { + "name": "Success", + "jsonName": "success", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Message", + "jsonName": "message", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Username", + "jsonName": "username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PeerId", + "jsonName": "peerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Echo back the peer's UUID" + ] + } + ], + "comments": [ + " AuthReplyPayload represents authentication response" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "ErrorPayload", + "formattedName": "Nakama_ErrorPayload", + "package": "nakama", + "fields": [ + { + "name": "Message", + "jsonName": "message", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Code", + "jsonName": "code", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " ErrorPayload represents error messages" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "HostConnectionStatus", + "formattedName": "Nakama_HostConnectionStatus", + "package": "nakama", + "fields": [ + { + "name": "Connected", + "jsonName": "connected", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Authenticated", + "jsonName": "authenticated", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastPing", + "jsonName": "lastPing", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PeerId", + "jsonName": "peerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Username", + "jsonName": "username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " HostConnectionStatus represents the status of the host connection" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "NakamaStatus", + "formattedName": "Nakama_NakamaStatus", + "package": "nakama", + "fields": [ + { + "name": "IsHost", + "jsonName": "isHost", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ConnectedPeers", + "jsonName": "connectedPeers", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsConnectedToHost", + "jsonName": "isConnectedToHost", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HostConnectionStatus", + "jsonName": "hostConnectionStatus", + "goType": "HostConnectionStatus", + "typescriptType": "Nakama_HostConnectionStatus", + "usedTypescriptType": "Nakama_HostConnectionStatus", + "usedStructName": "nakama.HostConnectionStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CurrentWatchPartySession", + "jsonName": "currentWatchPartySession", + "goType": "WatchPartySession", + "typescriptType": "Nakama_WatchPartySession", + "usedTypescriptType": "Nakama_WatchPartySession", + "usedStructName": "nakama.WatchPartySession", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " NakamaStatus represents the overall status of Nakama connections" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "MessageResponse", + "formattedName": "Nakama_MessageResponse", + "package": "nakama", + "fields": [ + { + "name": "Success", + "jsonName": "success", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Message", + "jsonName": "message", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " MessageResponse represents a response to message sending requests" + ] + }, + { + "filepath": "../internal/nakama/nakama.go", + "filename": "nakama.go", + "name": "ClientEvent", + "formattedName": "Nakama_ClientEvent", + "package": "nakama", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/share.go", + "filename": "share.go", + "name": "HydrateHostAnimeLibraryOptions", + "formattedName": "Nakama_HydrateHostAnimeLibraryOptions", + "package": "nakama", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "AnimeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LibraryCollection", + "jsonName": "LibraryCollection", + "goType": "anime.LibraryCollection", + "typescriptType": "Anime_LibraryCollection", + "usedTypescriptType": "Anime_LibraryCollection", + "usedStructName": "anime.LibraryCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/share.go", + "filename": "share.go", + "name": "NakamaAnimeLibrary", + "formattedName": "Nakama_NakamaAnimeLibrary", + "package": "nakama", + "fields": [ + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartyManager", + "formattedName": "Nakama_WatchPartyManager", + "package": "nakama", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "manager", + "jsonName": "manager", + "goType": "Manager", + "typescriptType": "Nakama_Manager", + "usedTypescriptType": "Nakama_Manager", + "usedStructName": "nakama.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentSession", + "jsonName": "currentSession", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Current watch party session" + ] + }, + { + "name": "sessionCtx", + "jsonName": "sessionCtx", + "goType": "context.Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "context.Context", + "required": false, + "public": false, + "comments": [ + " Context for the current watch party session" + ] + }, + { + "name": "sessionCtxCancel", + "jsonName": "sessionCtxCancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [ + " Cancel function for the current watch party session" + ] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [ + " Mutex for the watch party manager" + ] + }, + { + "name": "lastSeekTime", + "jsonName": "lastSeekTime", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [ + " Time of last seek operation" + ] + }, + { + "name": "seekCooldown", + "jsonName": "seekCooldown", + "goType": "time.Duration", + "typescriptType": "Duration", + "usedTypescriptType": "Duration", + "usedStructName": "time.Duration", + "required": false, + "public": false, + "comments": [ + " Minimum time between seeks" + ] + }, + { + "name": "catchUpCancel", + "jsonName": "catchUpCancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [ + " Cancel function for catch-up operations" + ] + }, + { + "name": "catchUpMu", + "jsonName": "catchUpMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [ + " Mutex for catch-up operations" + ] + }, + { + "name": "pendingSeekTime", + "jsonName": "pendingSeekTime", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [ + " When a seek was initiated" + ] + }, + { + "name": "pendingSeekPosition", + "jsonName": "pendingSeekPosition", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Position we're seeking to" + ] + }, + { + "name": "seekMu", + "jsonName": "seekMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [ + " Mutex for seek state" + ] + }, + { + "name": "bufferWaitStart", + "jsonName": "bufferWaitStart", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [ + " When we started waiting for peers to buffer" + ] + }, + { + "name": "isWaitingForBuffers", + "jsonName": "isWaitingForBuffers", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [ + " Whether we're currently waiting for peers to be ready" + ] + }, + { + "name": "bufferMu", + "jsonName": "bufferMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [ + " Mutex for buffer state changes" + ] + }, + { + "name": "statusReportTicker", + "jsonName": "statusReportTicker", + "goType": "time.Ticker", + "typescriptType": "Ticker", + "usedTypescriptType": "Ticker", + "usedStructName": "time.Ticker", + "required": false, + "public": false, + "comments": [ + " Ticker for peer status reporting" + ] + }, + { + "name": "statusReportCancel", + "jsonName": "statusReportCancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [ + " Cancel function for status reporting" + ] + }, + { + "name": "waitForPeersCancel", + "jsonName": "waitForPeersCancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [ + " Cancel function for waitForPeersReady goroutine" + ] + }, + { + "name": "bufferDetectionMu", + "jsonName": "bufferDetectionMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [ + " Mutex for buffering detection state" + ] + }, + { + "name": "lastPosition", + "jsonName": "lastPosition", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Last known playback position" + ] + }, + { + "name": "lastPositionTime", + "jsonName": "lastPositionTime", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [ + " When we last updated the position" + ] + }, + { + "name": "stallCount", + "jsonName": "stallCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Number of consecutive stalls detected" + ] + }, + { + "name": "lastPlayState", + "jsonName": "lastPlayState", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [ + " Last known play/pause state to detect rapid changes" + ] + }, + { + "name": "lastPlayStateTime", + "jsonName": "lastPlayStateTime", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [ + " When we last changed play state" + ] + }, + { + "name": "sequenceMu", + "jsonName": "sequenceMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [ + " Mutex for sequence number operations" + ] + }, + { + "name": "sendSequence", + "jsonName": "sendSequence", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Current sequence number for outgoing messages" + ] + }, + { + "name": "lastRxSequence", + "jsonName": "lastRxSequence", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Latest received sequence number" + ] + }, + { + "name": "peerPlaybackListener", + "jsonName": "peerPlaybackListener", + "goType": "playbackmanager.PlaybackStatusSubscriber", + "typescriptType": "PlaybackManager_PlaybackStatusSubscriber", + "usedTypescriptType": "PlaybackManager_PlaybackStatusSubscriber", + "usedStructName": "playbackmanager.PlaybackStatusSubscriber", + "required": false, + "public": false, + "comments": [ + " Listener for playback status changes (can be nil)" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartySession", + "formattedName": "Nakama_WatchPartySession", + "package": "nakama", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Participants", + "jsonName": "participants", + "goType": "map[string]WatchPartySessionParticipant", + "typescriptType": "Record\u003cstring, Nakama_WatchPartySessionParticipant\u003e", + "usedTypescriptType": "Nakama_WatchPartySessionParticipant", + "usedStructName": "nakama.WatchPartySessionParticipant", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Settings", + "jsonName": "settings", + "goType": "WatchPartySessionSettings", + "typescriptType": "Nakama_WatchPartySessionSettings", + "usedTypescriptType": "Nakama_WatchPartySessionSettings", + "usedStructName": "nakama.WatchPartySessionSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CurrentMediaInfo", + "jsonName": "currentMediaInfo", + "goType": "WatchPartySessionMediaInfo", + "typescriptType": "Nakama_WatchPartySessionMediaInfo", + "usedTypescriptType": "Nakama_WatchPartySessionMediaInfo", + "usedStructName": "nakama.WatchPartySessionMediaInfo", + "required": false, + "public": true, + "comments": [ + " can be nil if not set" + ] + }, + { + "name": "IsRelayMode", + "jsonName": "isRelayMode", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Whether this session is in relay mode" + ] + }, + { + "name": "mu", + "jsonName": "", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartySessionParticipant", + "formattedName": "Nakama_WatchPartySessionParticipant", + "package": "nakama", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " PeerID (UUID) for unique identification" + ] + }, + { + "name": "Username", + "jsonName": "username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Display name" + ] + }, + { + "name": "IsHost", + "jsonName": "isHost", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CanControl", + "jsonName": "canControl", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsReady", + "jsonName": "isReady", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastSeen", + "jsonName": "lastSeen", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Latency", + "jsonName": "latency", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " in milliseconds" + ] + }, + { + "name": "IsBuffering", + "jsonName": "isBuffering", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BufferHealth", + "jsonName": "bufferHealth", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " 0.0 to 1.0, how much buffer is available" + ] + }, + { + "name": "PlaybackStatus", + "jsonName": "playbackStatus", + "goType": "mediaplayer.PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [ + " Current playback status" + ] + }, + { + "name": "IsRelayOrigin", + "jsonName": "isRelayOrigin", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Whether this peer is the origin for relay mode" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartySessionMediaInfo", + "formattedName": "Nakama_WatchPartySessionMediaInfo", + "package": "nakama", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AniDBEpisode", + "jsonName": "aniDbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamType", + "jsonName": "streamType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " \"file\", \"torrent\", \"debrid\", \"online\"" + ] + }, + { + "name": "StreamPath", + "jsonName": "streamPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " URL for stream playback (e.g. /api/v1/nakama/stream?type=file\u0026path=...)" + ] + }, + { + "name": "OnlineStreamParams", + "jsonName": "onlineStreamParams", + "goType": "OnlineStreamParams", + "typescriptType": "Nakama_OnlineStreamParams", + "usedTypescriptType": "Nakama_OnlineStreamParams", + "usedStructName": "nakama.OnlineStreamParams", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OptionalTorrentStreamStartOptions", + "jsonName": "optionalTorrentStreamStartOptions", + "goType": "torrentstream.StartStreamOptions", + "typescriptType": "Torrentstream_StartStreamOptions", + "usedTypescriptType": "Torrentstream_StartStreamOptions", + "usedStructName": "torrentstream.StartStreamOptions", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "OnlineStreamParams", + "formattedName": "Nakama_OnlineStreamParams", + "package": "nakama", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Server", + "jsonName": "server", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Dubbed", + "jsonName": "dubbed", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Quality", + "jsonName": "quality", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartySessionSettings", + "formattedName": "Nakama_WatchPartySessionSettings", + "package": "nakama", + "fields": [ + { + "name": "SyncThreshold", + "jsonName": "syncThreshold", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Seconds of desync before forcing sync" + ] + }, + { + "name": "MaxBufferWaitTime", + "jsonName": "maxBufferWaitTime", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Max time to wait for buffering peers (seconds)" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartyCreatedPayload", + "formattedName": "Nakama_WatchPartyCreatedPayload", + "package": "nakama", + "fields": [ + { + "name": "Session", + "jsonName": "session", + "goType": "WatchPartySession", + "typescriptType": "Nakama_WatchPartySession", + "usedTypescriptType": "Nakama_WatchPartySession", + "usedStructName": "nakama.WatchPartySession", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Events" + ] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartyJoinPayload", + "formattedName": "Nakama_WatchPartyJoinPayload", + "package": "nakama", + "fields": [ + { + "name": "PeerId", + "jsonName": "peerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Username", + "jsonName": "username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Events" + ] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartyLeavePayload", + "formattedName": "Nakama_WatchPartyLeavePayload", + "package": "nakama", + "fields": [ + { + "name": "PeerId", + "jsonName": "peerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Events" + ] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartyPlaybackStatusPayload", + "formattedName": "Nakama_WatchPartyPlaybackStatusPayload", + "package": "nakama", + "fields": [ + { + "name": "PlaybackStatus", + "jsonName": "playbackStatus", + "goType": "mediaplayer.PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Unix nano timestamp" + ] + }, + { + "name": "SequenceNumber", + "jsonName": "sequenceNumber", + "goType": "uint64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " For episode changes" + ] + } + ], + "comments": [ + " Events" + ] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartyStateChangedPayload", + "formattedName": "Nakama_WatchPartyStateChangedPayload", + "package": "nakama", + "fields": [ + { + "name": "Session", + "jsonName": "session", + "goType": "WatchPartySession", + "typescriptType": "Nakama_WatchPartySession", + "usedTypescriptType": "Nakama_WatchPartySession", + "usedStructName": "nakama.WatchPartySession", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Events" + ] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartyPeerStatusPayload", + "formattedName": "Nakama_WatchPartyPeerStatusPayload", + "package": "nakama", + "fields": [ + { + "name": "PeerId", + "jsonName": "peerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PlaybackStatus", + "jsonName": "playbackStatus", + "goType": "mediaplayer.PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsBuffering", + "jsonName": "isBuffering", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BufferHealth", + "jsonName": "bufferHealth", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " 0.0 to 1.0" + ] + }, + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Events" + ] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartyBufferUpdatePayload", + "formattedName": "Nakama_WatchPartyBufferUpdatePayload", + "package": "nakama", + "fields": [ + { + "name": "PeerId", + "jsonName": "peerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsBuffering", + "jsonName": "isBuffering", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BufferHealth", + "jsonName": "bufferHealth", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Events" + ] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartyEnableRelayModePayload", + "formattedName": "Nakama_WatchPartyEnableRelayModePayload", + "package": "nakama", + "fields": [ + { + "name": "PeerId", + "jsonName": "peerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " PeerID of the peer to promote to origin" + ] + } + ], + "comments": [ + " Events" + ] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartyRelayModeOriginStreamStartedPayload", + "formattedName": "Nakama_WatchPartyRelayModeOriginStreamStartedPayload", + "package": "nakama", + "fields": [ + { + "name": "Filename", + "jsonName": "filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filepath", + "jsonName": "filepath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamType", + "jsonName": "streamType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "OptionalLocalPath", + "jsonName": "optionalLocalPath", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OptionalTorrentStreamStartOptions", + "jsonName": "optionalTorrentStreamStartOptions", + "goType": "torrentstream.StartStreamOptions", + "typescriptType": "Torrentstream_StartStreamOptions", + "usedTypescriptType": "Torrentstream_StartStreamOptions", + "usedStructName": "torrentstream.StartStreamOptions", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OptionalDebridStreamStartOptions", + "jsonName": "optionalDebridStreamStartOptions", + "goType": "debrid_client.StartStreamOptions", + "typescriptType": "DebridClient_StartStreamOptions", + "usedTypescriptType": "DebridClient_StartStreamOptions", + "usedStructName": "debrid_client.StartStreamOptions", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "mediaplayer.PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "state", + "goType": "playbackmanager.PlaybackState", + "typescriptType": "PlaybackManager_PlaybackState", + "usedTypescriptType": "PlaybackManager_PlaybackState", + "usedStructName": "playbackmanager.PlaybackState", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Events" + ] + }, + { + "filepath": "../internal/nakama/watch_party.go", + "filename": "watch_party.go", + "name": "WatchPartyRelayModeOriginPlaybackStatusPayload", + "formattedName": "Nakama_WatchPartyRelayModeOriginPlaybackStatusPayload", + "package": "nakama", + "fields": [ + { + "name": "Status", + "jsonName": "status", + "goType": "mediaplayer.PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "state", + "goType": "playbackmanager.PlaybackState", + "typescriptType": "PlaybackManager_PlaybackState", + "usedTypescriptType": "PlaybackManager_PlaybackState", + "usedStructName": "playbackmanager.PlaybackState", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Events" + ] + }, + { + "filepath": "../internal/nakama/watch_party_host.go", + "filename": "watch_party_host.go", + "name": "CreateWatchOptions", + "formattedName": "Nakama_CreateWatchOptions", + "package": "nakama", + "fields": [ + { + "name": "Settings", + "jsonName": "settings", + "goType": "WatchPartySessionSettings", + "typescriptType": "Nakama_WatchPartySessionSettings", + "usedTypescriptType": "Nakama_WatchPartySessionSettings", + "usedStructName": "nakama.WatchPartySessionSettings", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/watch_party_onlinestream.go", + "filename": "watch_party_onlinestream.go", + "name": "OnlineStreamStartedEventPayload", + "formattedName": "Nakama_OnlineStreamStartedEventPayload", + "package": "nakama", + "fields": [ + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Server", + "jsonName": "server", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Dubbed", + "jsonName": "dubbed", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Quality", + "jsonName": "quality", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nakama/watch_party_onlinestream.go", + "filename": "watch_party_onlinestream.go", + "name": "OnlineStreamCommand", + "formattedName": "Nakama_OnlineStreamCommand", + "package": "nakama", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"start\"", + "\"play\"", + "\"pause\"", + "\"seek\"", + "\"seekTo\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/nakama/watch_party_onlinestream.go", + "filename": "watch_party_onlinestream.go", + "name": "OnlineStreamCommandPayload", + "formattedName": "Nakama_OnlineStreamCommandPayload", + "package": "nakama", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "OnlineStreamCommand", + "typescriptType": "Nakama_OnlineStreamCommand", + "usedTypescriptType": "Nakama_OnlineStreamCommand", + "usedStructName": "nakama.OnlineStreamCommand", + "required": true, + "public": true, + "comments": [ + " The command type" + ] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "", + "typescriptType": "any", + "required": false, + "public": true, + "comments": [ + " Optional payload for the command" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "ServerEvent", + "formattedName": "NativePlayer_ServerEvent", + "package": "nativeplayer", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"open-and-await\"", + "\"watch\"", + "\"subtitle-event\"", + "\"set-tracks\"", + "\"pause\"", + "\"resume\"", + "\"seek\"", + "\"error\"", + "\"add-subtitle-track\"", + "\"terminate\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "ClientEvent", + "formattedName": "NativePlayer_ClientEvent", + "package": "nativeplayer", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"video-paused\"", + "\"video-resumed\"", + "\"video-completed\"", + "\"video-ended\"", + "\"video-seeked\"", + "\"video-error\"", + "\"loaded-metadata\"", + "\"subtitle-file-uploaded\"", + "\"video-terminated\"", + "\"video-time-update\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "PlayerEvent", + "formattedName": "NativePlayer_PlayerEvent", + "package": "nativeplayer", + "fields": [ + { + "name": "ClientId", + "jsonName": "clientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "ClientEvent", + "typescriptType": "NativePlayer_ClientEvent", + "usedTypescriptType": "NativePlayer_ClientEvent", + "usedStructName": "nativeplayer.ClientEvent", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "BaseVideoEvent", + "formattedName": "NativePlayer_BaseVideoEvent", + "package": "nativeplayer", + "fields": [ + { + "name": "ClientId", + "jsonName": "clientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "VideoPausedEvent", + "formattedName": "NativePlayer_VideoPausedEvent", + "package": "nativeplayer", + "fields": [ + { + "name": "CurrentTime", + "jsonName": "currentTime", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "nativeplayer.BaseVideoEvent" + ] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "VideoResumedEvent", + "formattedName": "NativePlayer_VideoResumedEvent", + "package": "nativeplayer", + "fields": [ + { + "name": "CurrentTime", + "jsonName": "currentTime", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "nativeplayer.BaseVideoEvent" + ] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "VideoEndedEvent", + "formattedName": "NativePlayer_VideoEndedEvent", + "package": "nativeplayer", + "fields": [ + { + "name": "AutoNext", + "jsonName": "autoNext", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "nativeplayer.BaseVideoEvent" + ] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "VideoErrorEvent", + "formattedName": "NativePlayer_VideoErrorEvent", + "package": "nativeplayer", + "fields": [ + { + "name": "Error", + "jsonName": "error", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "nativeplayer.BaseVideoEvent" + ] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "VideoSeekedEvent", + "formattedName": "NativePlayer_VideoSeekedEvent", + "package": "nativeplayer", + "fields": [ + { + "name": "CurrentTime", + "jsonName": "currentTime", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "nativeplayer.BaseVideoEvent" + ] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "VideoStatusEvent", + "formattedName": "NativePlayer_VideoStatusEvent", + "package": "nativeplayer", + "fields": [ + { + "name": "Status", + "jsonName": "status", + "goType": "PlaybackStatus", + "typescriptType": "NativePlayer_PlaybackStatus", + "usedTypescriptType": "NativePlayer_PlaybackStatus", + "usedStructName": "nativeplayer.PlaybackStatus", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "nativeplayer.BaseVideoEvent" + ] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "VideoLoadedMetadataEvent", + "formattedName": "NativePlayer_VideoLoadedMetadataEvent", + "package": "nativeplayer", + "fields": [ + { + "name": "CurrentTime", + "jsonName": "currentTime", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "nativeplayer.BaseVideoEvent" + ] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "SubtitleFileUploadedEvent", + "formattedName": "NativePlayer_SubtitleFileUploadedEvent", + "package": "nativeplayer", + "fields": [ + { + "name": "Filename", + "jsonName": "filename", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Content", + "jsonName": "content", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "nativeplayer.BaseVideoEvent" + ] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "VideoTerminatedEvent", + "formattedName": "NativePlayer_VideoTerminatedEvent", + "package": "nativeplayer", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "nativeplayer.BaseVideoEvent" + ] + }, + { + "filepath": "../internal/nativeplayer/events.go", + "filename": "events.go", + "name": "VideoCompletedEvent", + "formattedName": "NativePlayer_VideoCompletedEvent", + "package": "nativeplayer", + "fields": [ + { + "name": "CurrentTime", + "jsonName": "currentTime", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "nativeplayer.BaseVideoEvent" + ] + }, + { + "filepath": "../internal/nativeplayer/nativeplayer.go", + "filename": "nativeplayer.go", + "name": "StreamType", + "formattedName": "NativePlayer_StreamType", + "package": "nativeplayer", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"torrent\"", + "\"localfile\"", + "\"debrid\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/nativeplayer/nativeplayer.go", + "filename": "nativeplayer.go", + "name": "PlaybackInfo", + "formattedName": "NativePlayer_PlaybackInfo", + "package": "nativeplayer", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamType", + "jsonName": "streamType", + "goType": "StreamType", + "typescriptType": "NativePlayer_StreamType", + "usedTypescriptType": "NativePlayer_StreamType", + "usedStructName": "nativeplayer.StreamType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MimeType", + "jsonName": "mimeType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g. \"video/mp4\", \"video/webm\"" + ] + }, + { + "name": "StreamUrl", + "jsonName": "streamUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " URL of the stream" + ] + }, + { + "name": "ContentLength", + "jsonName": "contentLength", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " Size of the stream in bytes" + ] + }, + { + "name": "MkvMetadata", + "jsonName": "mkvMetadata", + "goType": "mkvparser.Metadata", + "typescriptType": "MKVParser_Metadata", + "usedTypescriptType": "MKVParser_Metadata", + "usedStructName": "mkvparser.Metadata", + "required": false, + "public": true, + "comments": [ + " nil if not ebml" + ] + }, + { + "name": "EntryListData", + "jsonName": "entryListData", + "goType": "anime.EntryListData", + "typescriptType": "Anime_EntryListData", + "usedTypescriptType": "Anime_EntryListData", + "usedStructName": "anime.EntryListData", + "required": false, + "public": true, + "comments": [ + " nil if not in list" + ] + }, + { + "name": "Episode", + "jsonName": "episode", + "goType": "anime.Episode", + "typescriptType": "Anime_Episode", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MkvMetadataParser", + "jsonName": "", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nativeplayer/nativeplayer.go", + "filename": "nativeplayer.go", + "name": "NativePlayer", + "formattedName": "NativePlayer_NativePlayer", + "package": "nativeplayer", + "fields": [ + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "clientPlayerEventSubscriber", + "jsonName": "clientPlayerEventSubscriber", + "goType": "events.ClientEventSubscriber", + "typescriptType": "Events_ClientEventSubscriber", + "usedTypescriptType": "Events_ClientEventSubscriber", + "usedStructName": "events.ClientEventSubscriber", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackStatusMu", + "jsonName": "playbackStatusMu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackStatus", + "jsonName": "playbackStatus", + "goType": "PlaybackStatus", + "typescriptType": "NativePlayer_PlaybackStatus", + "usedTypescriptType": "NativePlayer_PlaybackStatus", + "usedStructName": "nativeplayer.PlaybackStatus", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "seekedEventCancelFunc", + "jsonName": "seekedEventCancelFunc", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "subscribers", + "jsonName": "subscribers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nativeplayer/nativeplayer.go", + "filename": "nativeplayer.go", + "name": "PlaybackStatus", + "formattedName": "NativePlayer_PlaybackStatus", + "package": "nativeplayer", + "fields": [ + { + "name": "ClientId", + "jsonName": "ClientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Url", + "jsonName": "Url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Paused", + "jsonName": "Paused", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CurrentTime", + "jsonName": "CurrentTime", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "Duration", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nativeplayer/nativeplayer.go", + "filename": "nativeplayer.go", + "name": "Subscriber", + "formattedName": "NativePlayer_Subscriber", + "package": "nativeplayer", + "fields": [ + { + "name": "eventCh", + "jsonName": "eventCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/nativeplayer/nativeplayer.go", + "filename": "nativeplayer.go", + "name": "NewNativePlayerOptions", + "formattedName": "NativePlayer_NewNativePlayerOptions", + "package": "nativeplayer", + "fields": [ + { + "name": "WsEventManager", + "jsonName": "WsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/notifier/notifier.go", + "filename": "notifier.go", + "name": "Notifier", + "formattedName": "Notifier", + "package": "notifier", + "fields": [ + { + "name": "dataDir", + "jsonName": "dataDir", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logoPath", + "jsonName": "logoPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/notifier/notifier.go", + "filename": "notifier.go", + "name": "Notification", + "formattedName": "Notification", + "package": "notifier", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"Auto Downloader\"", + "\"Auto Scanner\"", + "\"Debrid\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/onlinestream/manual_mapping.go", + "filename": "manual_mapping.go", + "name": "MappingResponse", + "formattedName": "Onlinestream_MappingResponse", + "package": "onlinestream", + "fields": [ + { + "name": "AnimeId", + "jsonName": "animeId", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/providers/_animepahe.go", + "filename": "_animepahe.go", + "name": "Animepahe", + "formattedName": "Onlinestream_Animepahe", + "package": "onlinestream_providers", + "fields": [ + { + "name": "BaseURL", + "jsonName": "BaseURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/providers/_animepahe.go", + "filename": "_animepahe.go", + "name": "AnimepaheSearchResult", + "formattedName": "Onlinestream_AnimepaheSearchResult", + "package": "onlinestream_providers", + "fields": [ + { + "name": "Data", + "jsonName": "data", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nID int `json:\"id\"`\nTitle string `json:\"title\"`\nYear int `json:\"year\"`\nPoster string `json:\"poster\"`\nType string `json:\"type\"`\nSession string `json:\"session\"`}", + "typescriptType": "Array\u003c{ id: number; title: string; year: number; poster: string; type: string; session: string; }\u003e", + "usedTypescriptType": "{ id: number; title: string; year: number; poster: string; type: string; session: string; }", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/providers/gogoanime.go", + "filename": "gogoanime.go", + "name": "Gogoanime", + "formattedName": "Onlinestream_Gogoanime", + "package": "onlinestream_providers", + "fields": [ + { + "name": "BaseURL", + "jsonName": "BaseURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AjaxURL", + "jsonName": "AjaxURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/providers/zoro.go", + "filename": "zoro.go", + "name": "Zoro", + "formattedName": "Onlinestream_Zoro", + "package": "onlinestream_providers", + "fields": [ + { + "name": "BaseURL", + "jsonName": "BaseURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/repository.go", + "filename": "repository.go", + "name": "Repository", + "formattedName": "Onlinestream_Repository", + "package": "onlinestream", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "providerExtensionBank", + "jsonName": "providerExtensionBank", + "goType": "extension.UnifiedBank", + "typescriptType": "Extension_UnifiedBank", + "usedTypescriptType": "Extension_UnifiedBank", + "usedStructName": "extension.UnifiedBank", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "fileCacher", + "jsonName": "fileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "platform", + "jsonName": "platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "anilistBaseAnimeCache", + "jsonName": "anilistBaseAnimeCache", + "goType": "anilist.BaseAnimeCache", + "typescriptType": "AL_BaseAnimeCache", + "usedTypescriptType": "AL_BaseAnimeCache", + "usedStructName": "anilist.BaseAnimeCache", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "db", + "jsonName": "db", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/repository.go", + "filename": "repository.go", + "name": "Episode", + "formattedName": "Onlinestream_Episode", + "package": "onlinestream", + "fields": [ + { + "name": "Number", + "jsonName": "number", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Image", + "jsonName": "image", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsFiller", + "jsonName": "isFiller", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/repository.go", + "filename": "repository.go", + "name": "EpisodeSource", + "formattedName": "Onlinestream_EpisodeSource", + "package": "onlinestream", + "fields": [ + { + "name": "Number", + "jsonName": "number", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VideoSources", + "jsonName": "videoSources", + "goType": "[]VideoSource", + "typescriptType": "Array\u003cOnlinestream_VideoSource\u003e", + "usedTypescriptType": "Onlinestream_VideoSource", + "usedStructName": "onlinestream.VideoSource", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Subtitles", + "jsonName": "subtitles", + "goType": "[]Subtitle", + "typescriptType": "Array\u003cOnlinestream_Subtitle\u003e", + "usedTypescriptType": "Onlinestream_Subtitle", + "usedStructName": "onlinestream.Subtitle", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/repository.go", + "filename": "repository.go", + "name": "VideoSource", + "formattedName": "Onlinestream_VideoSource", + "package": "onlinestream", + "fields": [ + { + "name": "Server", + "jsonName": "server", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Headers", + "jsonName": "headers", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Quality", + "jsonName": "quality", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/repository.go", + "filename": "repository.go", + "name": "EpisodeListResponse", + "formattedName": "Onlinestream_EpisodeListResponse", + "package": "onlinestream", + "fields": [ + { + "name": "Episodes", + "jsonName": "episodes", + "goType": "[]Episode", + "typescriptType": "Array\u003cOnlinestream_Episode\u003e", + "usedTypescriptType": "Onlinestream_Episode", + "usedStructName": "onlinestream.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/repository.go", + "filename": "repository.go", + "name": "Subtitle", + "formattedName": "Onlinestream_Subtitle", + "package": "onlinestream", + "fields": [ + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/repository.go", + "filename": "repository.go", + "name": "NewRepositoryOptions", + "formattedName": "Onlinestream_NewRepositoryOptions", + "package": "onlinestream", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FileCacher", + "jsonName": "FileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/sources/gogocdn.go", + "filename": "gogocdn.go", + "name": "GogoCDN", + "formattedName": "Onlinestream_GogoCDN", + "package": "onlinestream_sources", + "fields": [ + { + "name": "client", + "jsonName": "client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "serverName", + "jsonName": "serverName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "keys", + "jsonName": "keys", + "goType": "cdnKeys", + "typescriptType": "Onlinestream_cdnKeys", + "usedTypescriptType": "Onlinestream_cdnKeys", + "usedStructName": "onlinestream_sources.cdnKeys", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "referrer", + "jsonName": "referrer", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/sources/megacloud.go", + "filename": "megacloud.go", + "name": "MegaCloud", + "formattedName": "Onlinestream_MegaCloud", + "package": "onlinestream_sources", + "fields": [ + { + "name": "Script", + "jsonName": "Script", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Sources", + "jsonName": "Sources", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/sources/streamsb.go", + "filename": "streamsb.go", + "name": "StreamSB", + "formattedName": "Onlinestream_StreamSB", + "package": "onlinestream_sources", + "fields": [ + { + "name": "Host", + "jsonName": "Host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Host2", + "jsonName": "Host2", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/onlinestream/sources/streamtape.go", + "filename": "streamtape.go", + "name": "Streamtape", + "formattedName": "Onlinestream_Streamtape", + "package": "onlinestream_sources", + "fields": [ + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/platforms/anilist_platform/anilist_platform.go", + "filename": "anilist_platform.go", + "name": "AnilistPlatform", + "formattedName": "AnilistPlatform", + "package": "anilist_platform", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "username", + "jsonName": "username", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "anilistClient", + "jsonName": "anilistClient", + "goType": "anilist.AnilistClient", + "typescriptType": "AL_AnilistClient", + "usedTypescriptType": "AL_AnilistClient", + "usedStructName": "anilist.AnilistClient", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeCollection", + "jsonName": "animeCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "rawAnimeCollection", + "jsonName": "rawAnimeCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mangaCollection", + "jsonName": "mangaCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "rawMangaCollection", + "jsonName": "rawMangaCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "isOffline", + "jsonName": "isOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "offlinePlatformEnabled", + "jsonName": "offlinePlatformEnabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "baseAnimeCache", + "jsonName": "baseAnimeCache", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetAnimeEvent", + "formattedName": "GetAnimeEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "Anime", + "jsonName": "anime", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetAnimeDetailsEvent", + "formattedName": "GetAnimeDetailsEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "Anime", + "jsonName": "anime", + "goType": "anilist.AnimeDetailsById_Media", + "typescriptType": "AL_AnimeDetailsById_Media", + "usedTypescriptType": "AL_AnimeDetailsById_Media", + "usedStructName": "anilist.AnimeDetailsById_Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetMangaEvent", + "formattedName": "GetMangaEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "Manga", + "jsonName": "manga", + "goType": "anilist.BaseManga", + "typescriptType": "AL_BaseManga", + "usedTypescriptType": "AL_BaseManga", + "usedStructName": "anilist.BaseManga", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetMangaDetailsEvent", + "formattedName": "GetMangaDetailsEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "Manga", + "jsonName": "manga", + "goType": "anilist.MangaDetailsById_Media", + "typescriptType": "AL_MangaDetailsById_Media", + "usedTypescriptType": "AL_MangaDetailsById_Media", + "usedStructName": "anilist.MangaDetailsById_Media", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetCachedAnimeCollectionEvent", + "formattedName": "GetCachedAnimeCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetCachedMangaCollectionEvent", + "formattedName": "GetCachedMangaCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetAnimeCollectionEvent", + "formattedName": "GetAnimeCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetMangaCollectionEvent", + "formattedName": "GetMangaCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetCachedRawAnimeCollectionEvent", + "formattedName": "GetCachedRawAnimeCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetCachedRawMangaCollectionEvent", + "formattedName": "GetCachedRawMangaCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetRawAnimeCollectionEvent", + "formattedName": "GetRawAnimeCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetRawMangaCollectionEvent", + "formattedName": "GetRawMangaCollectionEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "GetStudioDetailsEvent", + "formattedName": "GetStudioDetailsEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "Studio", + "jsonName": "studio", + "goType": "anilist.StudioDetails", + "typescriptType": "AL_StudioDetails", + "usedTypescriptType": "AL_StudioDetails", + "usedStructName": "anilist.StudioDetails", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PreUpdateEntryEvent", + "formattedName": "PreUpdateEntryEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScoreRaw", + "jsonName": "scoreRaw", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartedAt", + "jsonName": "startedAt", + "goType": "anilist.FuzzyDateInput", + "typescriptType": "AL_FuzzyDateInput", + "usedTypescriptType": "AL_FuzzyDateInput", + "usedStructName": "anilist.FuzzyDateInput", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompletedAt", + "jsonName": "completedAt", + "goType": "anilist.FuzzyDateInput", + "typescriptType": "AL_FuzzyDateInput", + "usedTypescriptType": "AL_FuzzyDateInput", + "usedStructName": "anilist.FuzzyDateInput", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " PreUpdateEntryEvent is triggered when an entry is about to be updated.", + " Prevent default to skip the default update and override the update." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PostUpdateEntryEvent", + "formattedName": "PostUpdateEntryEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PreUpdateEntryProgressEvent", + "formattedName": "PreUpdateEntryProgressEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TotalCount", + "jsonName": "totalCount", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "anilist.MediaListStatus", + "typescriptType": "AL_MediaListStatus", + "usedTypescriptType": "AL_MediaListStatus", + "usedStructName": "anilist.MediaListStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " PreUpdateEntryProgressEvent is triggered when an entry's progress is about to be updated.", + " Prevent default to skip the default update and override the update." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PostUpdateEntryProgressEvent", + "formattedName": "PostUpdateEntryProgressEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PreUpdateEntryRepeatEvent", + "formattedName": "PreUpdateEntryRepeatEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Repeat", + "jsonName": "repeat", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " PreUpdateEntryRepeatEvent is triggered when an entry's repeat is about to be updated.", + " Prevent default to skip the default update and override the update." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/anilist_platform/hook_events.go", + "filename": "hook_events.go", + "name": "PostUpdateEntryRepeatEvent", + "formattedName": "PostUpdateEntryRepeatEvent", + "package": "anilist_platform", + "fields": [ + { + "name": "MediaID", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/platforms/offline_platform/offline_platform.go", + "filename": "offline_platform.go", + "name": "OfflinePlatform", + "formattedName": "OfflinePlatform", + "package": "offline_platform", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "localManager", + "jsonName": "localManager", + "goType": "local.Manager", + "typescriptType": "Local_Manager", + "usedTypescriptType": "Local_Manager", + "usedStructName": "local.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "client", + "jsonName": "client", + "goType": "anilist.AnilistClient", + "typescriptType": "AL_AnilistClient", + "usedTypescriptType": "AL_AnilistClient", + "usedStructName": "anilist.AnilistClient", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " OfflinePlatform used when offline.", + " It provides the same API as the anilist_platform.AnilistPlatform but some methods are no-op." + ] + }, + { + "filepath": "../internal/platforms/simulated_platform/helpers.go", + "filename": "helpers.go", + "name": "CollectionWrapper", + "formattedName": "CollectionWrapper", + "package": "simulated_platform", + "fields": [ + { + "name": "platform", + "jsonName": "platform", + "goType": "SimulatedPlatform", + "typescriptType": "SimulatedPlatform", + "usedTypescriptType": "SimulatedPlatform", + "usedStructName": "simulated_platform.SimulatedPlatform", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "isAnime", + "jsonName": "isAnime", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [ + " CollectionWrapper provides an ambivalent interface for anime and manga collections" + ] + }, + { + "filepath": "../internal/platforms/simulated_platform/simulated_platform.go", + "filename": "simulated_platform.go", + "name": "SimulatedPlatform", + "formattedName": "SimulatedPlatform", + "package": "simulated_platform", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "localManager", + "jsonName": "localManager", + "goType": "local.Manager", + "typescriptType": "Local_Manager", + "usedTypescriptType": "Local_Manager", + "usedStructName": "local.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "client", + "jsonName": "client", + "goType": "anilist.AnilistClient", + "typescriptType": "AL_AnilistClient", + "usedTypescriptType": "AL_AnilistClient", + "usedStructName": "anilist.AnilistClient", + "required": false, + "public": false, + "comments": [ + " should only receive an unauthenticated client" + ] + }, + { + "name": "animeCollection", + "jsonName": "animeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mangaCollection", + "jsonName": "mangaCollection", + "goType": "anilist.MangaCollection", + "typescriptType": "AL_MangaCollection", + "usedTypescriptType": "AL_MangaCollection", + "usedStructName": "anilist.MangaCollection", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "collectionMu", + "jsonName": "collectionMu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [ + " used to protect access to collections" + ] + }, + { + "name": "lastAnimeCollectionRefetchTime", + "jsonName": "lastAnimeCollectionRefetchTime", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [ + " used to prevent refetching too many times" + ] + }, + { + "name": "lastMangaCollectionRefetchTime", + "jsonName": "lastMangaCollectionRefetchTime", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [ + " used to prevent refetching too many times" + ] + }, + { + "name": "anilistRateLimit", + "jsonName": "anilistRateLimit", + "goType": "limiter.Limiter", + "typescriptType": "Limiter", + "usedTypescriptType": "Limiter", + "usedStructName": "limiter.Limiter", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " SimulatedPlatform used when the user is not authenticated to AniList.", + " It acts as a dummy account using simulated collections stored locally." + ] + }, + { + "filepath": "../internal/plugin/anilist.go", + "filename": "anilist.go", + "name": "Anilist", + "formattedName": "Anilist", + "package": "plugin", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "AppContextImpl", + "typescriptType": "AppContextImpl", + "usedTypescriptType": "AppContextImpl", + "usedStructName": "plugin.AppContextImpl", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ext", + "jsonName": "ext", + "goType": "extension.Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/anime.go", + "filename": "anime.go", + "name": "Anime", + "formattedName": "Anime", + "package": "plugin", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "AppContextImpl", + "typescriptType": "AppContextImpl", + "usedTypescriptType": "AppContextImpl", + "usedStructName": "plugin.AppContextImpl", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "vm", + "jsonName": "vm", + "goType": "goja.Runtime", + "typescriptType": "Runtime", + "usedTypescriptType": "Runtime", + "usedStructName": "goja.Runtime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ext", + "jsonName": "ext", + "goType": "extension.Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "scheduler", + "jsonName": "scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/app_context.go", + "filename": "app_context.go", + "name": "AppContextModules", + "formattedName": "AppContextModules", + "package": "plugin", + "fields": [ + { + "name": "IsOffline", + "jsonName": "IsOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeLibraryPaths", + "jsonName": "AnimeLibraryPaths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "usedStructName": "plugin.[]string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnilistPlatform", + "jsonName": "AnilistPlatform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PlaybackManager", + "jsonName": "PlaybackManager", + "goType": "playbackmanager.PlaybackManager", + "typescriptType": "PlaybackManager_PlaybackManager", + "usedTypescriptType": "PlaybackManager_PlaybackManager", + "usedStructName": "playbackmanager.PlaybackManager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediaPlayerRepository", + "jsonName": "MediaPlayerRepository", + "goType": "mediaplayer.Repository", + "typescriptType": "Repository", + "usedTypescriptType": "Repository", + "usedStructName": "mediaplayer.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MangaRepository", + "jsonName": "MangaRepository", + "goType": "manga.Repository", + "typescriptType": "Manga_Repository", + "usedTypescriptType": "Manga_Repository", + "usedStructName": "manga.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DiscordPresence", + "jsonName": "DiscordPresence", + "goType": "discordrpc_presence.Presence", + "typescriptType": "DiscordRPC_Presence", + "usedTypescriptType": "DiscordRPC_Presence", + "usedStructName": "discordrpc_presence.Presence", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentClientRepository", + "jsonName": "TorrentClientRepository", + "goType": "torrent_client.Repository", + "typescriptType": "TorrentClient_Repository", + "usedTypescriptType": "TorrentClient_Repository", + "usedStructName": "torrent_client.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ContinuityManager", + "jsonName": "ContinuityManager", + "goType": "continuity.Manager", + "typescriptType": "Continuity_Manager", + "usedTypescriptType": "Continuity_Manager", + "usedStructName": "continuity.Manager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AutoScanner", + "jsonName": "AutoScanner", + "goType": "autoscanner.AutoScanner", + "typescriptType": "AutoScanner_AutoScanner", + "usedTypescriptType": "AutoScanner_AutoScanner", + "usedStructName": "autoscanner.AutoScanner", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AutoDownloader", + "jsonName": "AutoDownloader", + "goType": "autodownloader.AutoDownloader", + "typescriptType": "AutoDownloader_AutoDownloader", + "usedTypescriptType": "AutoDownloader_AutoDownloader", + "usedStructName": "autodownloader.AutoDownloader", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FileCacher", + "jsonName": "FileCacher", + "goType": "filecache.Cacher", + "typescriptType": "Filecache_Cacher", + "usedTypescriptType": "Filecache_Cacher", + "usedStructName": "filecache.Cacher", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OnlinestreamRepository", + "jsonName": "OnlinestreamRepository", + "goType": "onlinestream.Repository", + "typescriptType": "Onlinestream_Repository", + "usedTypescriptType": "Onlinestream_Repository", + "usedStructName": "onlinestream.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediastreamRepository", + "jsonName": "MediastreamRepository", + "goType": "mediastream.Repository", + "typescriptType": "Mediastream_Repository", + "usedTypescriptType": "Mediastream_Repository", + "usedStructName": "mediastream.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentstreamRepository", + "jsonName": "TorrentstreamRepository", + "goType": "torrentstream.Repository", + "typescriptType": "Torrentstream_Repository", + "usedTypescriptType": "Torrentstream_Repository", + "usedStructName": "torrentstream.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FillerManager", + "jsonName": "FillerManager", + "goType": "fillermanager.FillerManager", + "typescriptType": "FillerManager", + "usedTypescriptType": "FillerManager", + "usedStructName": "fillermanager.FillerManager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OnRefreshAnilistAnimeCollection", + "jsonName": "OnRefreshAnilistAnimeCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "OnRefreshAnilistMangaCollection", + "jsonName": "OnRefreshAnilistMangaCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/app_context.go", + "filename": "app_context.go", + "name": "AppContextImpl", + "formattedName": "AppContextImpl", + "package": "plugin", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeLibraryPaths", + "jsonName": "animeLibraryPaths", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "database", + "jsonName": "database", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "playbackManager", + "jsonName": "playbackManager", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mediaplayerRepo", + "jsonName": "mediaplayerRepo", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mangaRepository", + "jsonName": "mangaRepository", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "anilistPlatform", + "jsonName": "anilistPlatform", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "discordPresence", + "jsonName": "discordPresence", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "fillerManager", + "jsonName": "fillerManager", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "torrentClientRepository", + "jsonName": "torrentClientRepository", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "torrentstreamRepository", + "jsonName": "torrentstreamRepository", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mediastreamRepository", + "jsonName": "mediastreamRepository", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "onlinestreamRepository", + "jsonName": "onlinestreamRepository", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "continuityManager", + "jsonName": "continuityManager", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "autoScanner", + "jsonName": "autoScanner", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "autoDownloader", + "jsonName": "autoDownloader", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "fileCacher", + "jsonName": "fileCacher", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "onRefreshAnilistAnimeCollection", + "jsonName": "onRefreshAnilistAnimeCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "onRefreshAnilistMangaCollection", + "jsonName": "onRefreshAnilistMangaCollection", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "isOffline", + "jsonName": "isOffline", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/cron.go", + "filename": "cron.go", + "name": "Cron", + "formattedName": "Cron", + "package": "plugin", + "fields": [ + { + "name": "timezone", + "jsonName": "timezone", + "goType": "time.Location", + "typescriptType": "Location", + "usedTypescriptType": "Location", + "usedStructName": "time.Location", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ticker", + "jsonName": "ticker", + "goType": "time.Ticker", + "typescriptType": "Ticker", + "usedTypescriptType": "Ticker", + "usedStructName": "time.Ticker", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "startTimer", + "jsonName": "startTimer", + "goType": "time.Timer", + "typescriptType": "Timer", + "usedTypescriptType": "Timer", + "usedStructName": "time.Timer", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "tickerDone", + "jsonName": "tickerDone", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "jobs", + "jsonName": "jobs", + "goType": "[]CronJob", + "typescriptType": "Array\u003cCronJob\u003e", + "usedTypescriptType": "CronJob", + "usedStructName": "plugin.CronJob", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "interval", + "jsonName": "interval", + "goType": "time.Duration", + "typescriptType": "Duration", + "usedTypescriptType": "Duration", + "usedStructName": "time.Duration", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mux", + "jsonName": "mux", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "scheduler", + "jsonName": "scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " Cron is a crontab-like struct for tasks/jobs scheduling." + ] + }, + { + "filepath": "../internal/plugin/cron.go", + "filename": "cron.go", + "name": "CronJob", + "formattedName": "CronJob", + "package": "plugin", + "fields": [ + { + "name": "fn", + "jsonName": "fn", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "schedule", + "jsonName": "schedule", + "goType": "Schedule", + "typescriptType": "Schedule", + "usedTypescriptType": "Schedule", + "usedStructName": "plugin.Schedule", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "id", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "scheduler", + "jsonName": "scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " CronJob defines a single registered cron job." + ] + }, + { + "filepath": "../internal/plugin/cron.go", + "filename": "cron.go", + "name": "Moment", + "formattedName": "Moment", + "package": "plugin", + "fields": [ + { + "name": "Minute", + "jsonName": "minute", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hour", + "jsonName": "hour", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Day", + "jsonName": "day", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Month", + "jsonName": "month", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DayOfWeek", + "jsonName": "dayOfWeek", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Moment represents a parsed single time moment." + ] + }, + { + "filepath": "../internal/plugin/cron.go", + "filename": "cron.go", + "name": "Schedule", + "formattedName": "Schedule", + "package": "plugin", + "fields": [ + { + "name": "Minutes", + "jsonName": "minutes", + "goType": "map[int]__STRUCT__", + "inlineStructType": "map[int]struct{\n}", + "typescriptType": "Record\u003cnumber, { }\u003e", + "usedTypescriptType": "{ }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Hours", + "jsonName": "hours", + "goType": "map[int]__STRUCT__", + "inlineStructType": "map[int]struct{\n}", + "typescriptType": "Record\u003cnumber, { }\u003e", + "usedTypescriptType": "{ }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Days", + "jsonName": "days", + "goType": "map[int]__STRUCT__", + "inlineStructType": "map[int]struct{\n}", + "typescriptType": "Record\u003cnumber, { }\u003e", + "usedTypescriptType": "{ }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Months", + "jsonName": "months", + "goType": "map[int]__STRUCT__", + "inlineStructType": "map[int]struct{\n}", + "typescriptType": "Record\u003cnumber, { }\u003e", + "usedTypescriptType": "{ }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DaysOfWeek", + "jsonName": "daysOfWeek", + "goType": "map[int]__STRUCT__", + "inlineStructType": "map[int]struct{\n}", + "typescriptType": "Record\u003cnumber, { }\u003e", + "usedTypescriptType": "{ }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "rawExpr", + "jsonName": "rawExpr", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [ + " Schedule stores parsed information for each time component when a cron job should run." + ] + }, + { + "filepath": "../internal/plugin/database.go", + "filename": "database.go", + "name": "Database", + "formattedName": "Database", + "package": "plugin", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "AppContextImpl", + "typescriptType": "AppContextImpl", + "usedTypescriptType": "AppContextImpl", + "usedStructName": "plugin.AppContextImpl", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ext", + "jsonName": "ext", + "goType": "extension.Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/downloader.go", + "filename": "downloader.go", + "name": "DownloadStatus", + "formattedName": "DownloadStatus", + "package": "plugin", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"downloading\"", + "\"completed\"", + "\"cancelled\"", + "\"error\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/plugin/downloader.go", + "filename": "downloader.go", + "name": "DownloadProgress", + "formattedName": "DownloadProgress", + "package": "plugin", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Destination", + "jsonName": "destination", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalBytes", + "jsonName": "totalBytes", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalSize", + "jsonName": "totalSize", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Speed", + "jsonName": "speed", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Percentage", + "jsonName": "percentage", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Error", + "jsonName": "error", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LastUpdateTime", + "jsonName": "lastUpdate", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StartTime", + "jsonName": "startTime", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "lastBytes", + "jsonName": "", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/manga.go", + "filename": "manga.go", + "name": "Manga", + "formattedName": "Manga", + "package": "plugin", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "AppContextImpl", + "typescriptType": "AppContextImpl", + "usedTypescriptType": "AppContextImpl", + "usedStructName": "plugin.AppContextImpl", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "vm", + "jsonName": "vm", + "goType": "goja.Runtime", + "typescriptType": "Runtime", + "usedTypescriptType": "Runtime", + "usedStructName": "goja.Runtime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ext", + "jsonName": "ext", + "goType": "extension.Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "scheduler", + "jsonName": "scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/manga.go", + "filename": "manga.go", + "name": "GetChapterContainerOptions", + "formattedName": "GetChapterContainerOptions", + "package": "plugin", + "fields": [ + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Provider", + "jsonName": "Provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Titles", + "jsonName": "Titles", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "Year", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/playback.go", + "filename": "playback.go", + "name": "Playback", + "formattedName": "Playback", + "package": "plugin", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "AppContextImpl", + "typescriptType": "AppContextImpl", + "usedTypescriptType": "AppContextImpl", + "usedStructName": "plugin.AppContextImpl", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "vm", + "jsonName": "vm", + "goType": "goja.Runtime", + "typescriptType": "Runtime", + "usedTypescriptType": "Runtime", + "usedStructName": "goja.Runtime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ext", + "jsonName": "ext", + "goType": "extension.Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "scheduler", + "jsonName": "scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/playback.go", + "filename": "playback.go", + "name": "PlaybackMPV", + "formattedName": "PlaybackMPV", + "package": "plugin", + "fields": [ + { + "name": "mpv", + "jsonName": "mpv", + "goType": "mpv.Mpv", + "typescriptType": "Mpv", + "usedTypescriptType": "Mpv", + "usedStructName": "mpv.Mpv", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playback", + "jsonName": "playback", + "goType": "Playback", + "typescriptType": "Playback", + "usedTypescriptType": "Playback", + "usedStructName": "plugin.Playback", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/playback.go", + "filename": "playback.go", + "name": "PlaybackEvent", + "formattedName": "PlaybackEvent", + "package": "plugin", + "fields": [ + { + "name": "IsVideoStarted", + "jsonName": "isVideoStarted", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsVideoStopped", + "jsonName": "isVideoStopped", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsVideoCompleted", + "jsonName": "isVideoCompleted", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsStreamStarted", + "jsonName": "isStreamStarted", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsStreamStopped", + "jsonName": "isStreamStopped", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsStreamCompleted", + "jsonName": "isStreamCompleted", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartedEvent", + "jsonName": "startedEvent", + "goType": "__STRUCT__", + "typescriptType": "{ filename: string; }", + "usedTypescriptType": "{ filename: string; }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "StoppedEvent", + "jsonName": "stoppedEvent", + "goType": "__STRUCT__", + "typescriptType": "{ reason: string; }", + "usedTypescriptType": "{ reason: string; }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompletedEvent", + "jsonName": "completedEvent", + "goType": "__STRUCT__", + "typescriptType": "{ filename: string; }", + "usedTypescriptType": "{ filename: string; }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "state", + "goType": "playbackmanager.PlaybackState", + "typescriptType": "PlaybackManager_PlaybackState", + "usedTypescriptType": "PlaybackManager_PlaybackState", + "usedStructName": "playbackmanager.PlaybackState", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "mediaplayer.PlaybackStatus", + "typescriptType": "PlaybackStatus", + "usedTypescriptType": "PlaybackStatus", + "usedStructName": "mediaplayer.PlaybackStatus", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/storage.go", + "filename": "storage.go", + "name": "Storage", + "formattedName": "Storage", + "package": "plugin", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "AppContextImpl", + "typescriptType": "AppContextImpl", + "usedTypescriptType": "AppContextImpl", + "usedStructName": "plugin.AppContextImpl", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ext", + "jsonName": "ext", + "goType": "extension.Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "runtime", + "jsonName": "runtime", + "goType": "goja.Runtime", + "typescriptType": "Runtime", + "usedTypescriptType": "Runtime", + "usedStructName": "goja.Runtime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "pluginDataCache", + "jsonName": "pluginDataCache", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [ + " Cache to avoid repeated database calls" + ] + }, + { + "name": "keyDataCache", + "jsonName": "keyDataCache", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [ + " Cache to avoid repeated database calls" + ] + }, + { + "name": "keySubscribers", + "jsonName": "keySubscribers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [ + " Subscribers for key changes" + ] + }, + { + "name": "scheduler", + "jsonName": "scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " Storage is used to store data for an extension.", + " A new instance is created for each extension." + ] + }, + { + "filepath": "../internal/plugin/store.go", + "filename": "store.go", + "name": "Store", + "formattedName": "Store", + "package": "plugin", + "fields": [ + { + "name": "data", + "jsonName": "data", + "goType": "map[K]T", + "typescriptType": "Record\u003cK, T\u003e", + "usedTypescriptType": "T", + "usedStructName": "plugin.T", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "keySubscribers", + "jsonName": "keySubscribers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "deleted", + "jsonName": "deleted", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [ + " Store defines a concurrent safe in memory key-value data store.", + " A new instance is created for each extension." + ] + }, + { + "filepath": "../internal/plugin/store.go", + "filename": "store.go", + "name": "StoreKeySubscriber", + "formattedName": "StoreKeySubscriber", + "package": "plugin", + "fields": [ + { + "name": "Key", + "jsonName": "Key", + "goType": "K", + "typescriptType": "K", + "usedTypescriptType": "K", + "usedStructName": "plugin.K", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Channel", + "jsonName": "Channel", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/system.go", + "filename": "system.go", + "name": "AsyncCmd", + "formattedName": "AsyncCmd", + "package": "plugin", + "fields": [ + { + "name": "cmd", + "jsonName": "cmd", + "goType": "exec.Cmd", + "typescriptType": "Cmd", + "usedTypescriptType": "Cmd", + "usedStructName": "exec.Cmd", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "appContext", + "jsonName": "appContext", + "goType": "AppContextImpl", + "typescriptType": "AppContextImpl", + "usedTypescriptType": "AppContextImpl", + "usedStructName": "plugin.AppContextImpl", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "scheduler", + "jsonName": "scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "vm", + "jsonName": "vm", + "goType": "goja.Runtime", + "typescriptType": "Runtime", + "usedTypescriptType": "Runtime", + "usedStructName": "goja.Runtime", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/system.go", + "filename": "system.go", + "name": "CmdHelper", + "formattedName": "CmdHelper", + "package": "plugin", + "fields": [ + { + "name": "cmd", + "jsonName": "cmd", + "goType": "exec.Cmd", + "typescriptType": "Cmd", + "usedTypescriptType": "Cmd", + "usedStructName": "exec.Cmd", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "stdout", + "jsonName": "stdout", + "goType": "io.ReadCloser", + "typescriptType": "ReadCloser", + "usedTypescriptType": "ReadCloser", + "usedStructName": "io.ReadCloser", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "stderr", + "jsonName": "stderr", + "goType": "io.ReadCloser", + "typescriptType": "ReadCloser", + "usedTypescriptType": "ReadCloser", + "usedStructName": "io.ReadCloser", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "appContext", + "jsonName": "appContext", + "goType": "AppContextImpl", + "typescriptType": "AppContextImpl", + "usedTypescriptType": "AppContextImpl", + "usedStructName": "plugin.AppContextImpl", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "scheduler", + "jsonName": "scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "vm", + "jsonName": "vm", + "goType": "goja.Runtime", + "typescriptType": "Runtime", + "usedTypescriptType": "Runtime", + "usedStructName": "goja.Runtime", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/_scheduler.go", + "filename": "_scheduler.go", + "name": "Job", + "formattedName": "Job", + "package": "plugin_ui", + "fields": [ + { + "name": "fn", + "jsonName": "fn", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "resultCh", + "jsonName": "resultCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "async", + "jsonName": "async", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [ + " Flag to indicate if the job is async (doesn't need to wait for result)" + ] + } + ], + "comments": [ + " Job represents a task to be executed in the VM" + ] + }, + { + "filepath": "../internal/plugin/ui/_scheduler.go", + "filename": "_scheduler.go", + "name": "Scheduler", + "formattedName": "Scheduler", + "package": "plugin_ui", + "fields": [ + { + "name": "jobQueue", + "jsonName": "jobQueue", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "ctx", + "jsonName": "ctx", + "goType": "context.Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "context.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "context", + "jsonName": "context", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cancel", + "jsonName": "cancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wg", + "jsonName": "wg", + "goType": "sync.WaitGroup", + "typescriptType": "WaitGroup", + "usedTypescriptType": "WaitGroup", + "usedStructName": "sync.WaitGroup", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentJob", + "jsonName": "currentJob", + "goType": "Job", + "typescriptType": "Job", + "usedTypescriptType": "Job", + "usedStructName": "plugin_ui.Job", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentJobLock", + "jsonName": "currentJobLock", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " Scheduler handles all VM operations added concurrently in a single goroutine", + " Any goroutine that needs to execute a VM operation must schedule it because the UI VM isn't thread safe" + ] + }, + { + "filepath": "../internal/plugin/ui/action.go", + "filename": "action.go", + "name": "ActionManager", + "formattedName": "ActionManager", + "package": "plugin_ui", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animePageButtons", + "jsonName": "animePageButtons", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animePageDropdownItems", + "jsonName": "animePageDropdownItems", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeLibraryDropdownItems", + "jsonName": "animeLibraryDropdownItems", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mangaPageButtons", + "jsonName": "mangaPageButtons", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mediaCardContextMenuItems", + "jsonName": "mediaCardContextMenuItems", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "episodeCardContextMenuItems", + "jsonName": "episodeCardContextMenuItems", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "episodeGridItemMenuItems", + "jsonName": "episodeGridItemMenuItems", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " ActionManager", + "", + " Actions are buttons, dropdown items, and context menu items that are displayed in certain places in the UI.", + " They are defined in the plugin code and are used to trigger events.", + "", + " The ActionManager is responsible for registering, rendering, and handling events for actions." + ] + }, + { + "filepath": "../internal/plugin/ui/action.go", + "filename": "action.go", + "name": "BaseActionProps", + "formattedName": "BaseActionProps", + "package": "plugin_ui", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Label", + "jsonName": "label", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Style", + "jsonName": "style", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/action.go", + "filename": "action.go", + "name": "BaseAction", + "formattedName": "BaseAction", + "package": "plugin_ui", + "fields": [], + "comments": [ + " Base action struct that all action types embed" + ], + "embeddedStructNames": [ + "plugin_ui.BaseActionProps" + ] + }, + { + "filepath": "../internal/plugin/ui/action.go", + "filename": "action.go", + "name": "AnimePageButton", + "formattedName": "AnimePageButton", + "package": "plugin_ui", + "fields": [ + { + "name": "Intent", + "jsonName": "intent", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "plugin_ui.BaseAction" + ] + }, + { + "filepath": "../internal/plugin/ui/action.go", + "filename": "action.go", + "name": "EpisodeCardContextMenuItem", + "formattedName": "EpisodeCardContextMenuItem", + "package": "plugin_ui", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "plugin_ui.BaseAction" + ] + }, + { + "filepath": "../internal/plugin/ui/action.go", + "filename": "action.go", + "name": "EpisodeGridItemMenuItem", + "formattedName": "EpisodeGridItemMenuItem", + "package": "plugin_ui", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "plugin_ui.BaseAction" + ] + }, + { + "filepath": "../internal/plugin/ui/action.go", + "filename": "action.go", + "name": "MangaPageButton", + "formattedName": "MangaPageButton", + "package": "plugin_ui", + "fields": [ + { + "name": "Intent", + "jsonName": "intent", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "plugin_ui.BaseAction" + ] + }, + { + "filepath": "../internal/plugin/ui/action.go", + "filename": "action.go", + "name": "AnimePageDropdownMenuItem", + "formattedName": "AnimePageDropdownMenuItem", + "package": "plugin_ui", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "plugin_ui.BaseAction" + ] + }, + { + "filepath": "../internal/plugin/ui/action.go", + "filename": "action.go", + "name": "AnimeLibraryDropdownMenuItem", + "formattedName": "AnimeLibraryDropdownMenuItem", + "package": "plugin_ui", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "plugin_ui.BaseAction" + ] + }, + { + "filepath": "../internal/plugin/ui/action.go", + "filename": "action.go", + "name": "MediaCardContextMenuItemFor", + "formattedName": "MediaCardContextMenuItemFor", + "package": "plugin_ui", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"anime\"", + "\"manga\"", + "\"both\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/action.go", + "filename": "action.go", + "name": "MediaCardContextMenuItem", + "formattedName": "MediaCardContextMenuItem", + "package": "plugin_ui", + "fields": [ + { + "name": "For", + "jsonName": "for", + "goType": "MediaCardContextMenuItemFor", + "typescriptType": "MediaCardContextMenuItemFor", + "usedTypescriptType": "MediaCardContextMenuItemFor", + "usedStructName": "plugin_ui.MediaCardContextMenuItemFor", + "required": true, + "public": true, + "comments": [ + " anime, manga, both" + ] + } + ], + "comments": [], + "embeddedStructNames": [ + "plugin_ui.BaseAction" + ] + }, + { + "filepath": "../internal/plugin/ui/command.go", + "filename": "command.go", + "name": "CommandPaletteManager", + "formattedName": "CommandPaletteManager", + "package": "plugin_ui", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "updateMutex", + "jsonName": "updateMutex", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "lastUpdated", + "jsonName": "lastUpdated", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "componentManager", + "jsonName": "componentManager", + "goType": "ComponentManager", + "typescriptType": "ComponentManager", + "usedTypescriptType": "ComponentManager", + "usedStructName": "plugin_ui.ComponentManager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "placeholder", + "jsonName": "placeholder", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "keyboardShortcut", + "jsonName": "keyboardShortcut", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "registered", + "jsonName": "registered", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "items", + "jsonName": "items", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "renderedItems", + "jsonName": "renderedItems", + "goType": "[]CommandItemJSON", + "typescriptType": "Array\u003cCommandItemJSON\u003e", + "usedTypescriptType": "CommandItemJSON", + "usedStructName": "plugin_ui.CommandItemJSON", + "required": false, + "public": false, + "comments": [ + " Store rendered items when setItems is called" + ] + } + ], + "comments": [ + " CommandPaletteManager is a manager for the command palette.", + " Unlike the Tray, command palette items are not reactive to state changes.", + " They are only rendered when the setItems function is called or the refresh function is called." + ] + }, + { + "filepath": "../internal/plugin/ui/command.go", + "filename": "command.go", + "name": "CommandItemJSON", + "formattedName": "CommandItemJSON", + "package": "plugin_ui", + "fields": [ + { + "name": "Index", + "jsonName": "index", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Label", + "jsonName": "label", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FilterType", + "jsonName": "filterType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Heading", + "jsonName": "heading", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Components", + "jsonName": "components", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/command.go", + "filename": "command.go", + "name": "NewCommandPaletteOptions", + "formattedName": "NewCommandPaletteOptions", + "package": "plugin_ui", + "fields": [ + { + "name": "Placeholder", + "jsonName": "placeholder", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "KeyboardShortcut", + "jsonName": "keyboardShortcut", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/component_utils.go", + "filename": "component_utils.go", + "name": "ComponentProp", + "formattedName": "ComponentProp", + "package": "plugin_ui", + "fields": [ + { + "name": "Name", + "jsonName": "Name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g. \"label\"" + ] + }, + { + "name": "Type", + "jsonName": "Type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " e.g. \"string\"" + ] + }, + { + "name": "Default", + "jsonName": "Default", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [ + " Is set if the prop is not provided, if not set and required is false, the prop will not be included in the component" + ] + }, + { + "name": "Required", + "jsonName": "Required", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " If true an no default value is provided, the component will throw a type error" + ] + }, + { + "name": "Validate", + "jsonName": "Validate", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [ + " Optional validation function" + ] + }, + { + "name": "OptionalFirstArg", + "jsonName": "OptionalFirstArg", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " If true, it can be the first argument to declaring the component as a shorthand (e.g. tray.button(\"Click me\") instead of tray.button({label: \"Click me\"}))" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/components.go", + "filename": "components.go", + "name": "ComponentManager", + "formattedName": "ComponentManager", + "package": "plugin_ui", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "lastRenderedComponents", + "jsonName": "lastRenderedComponents", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [ + " ComponentManager is used to register components.", + " Any higher-order UI system must use this to register components. (Tray)" + ] + }, + { + "filepath": "../internal/plugin/ui/context.go", + "filename": "context.go", + "name": "BatchedPluginEvents", + "formattedName": "BatchedPluginEvents", + "package": "plugin_ui", + "fields": [ + { + "name": "Events", + "jsonName": "events", + "goType": "[]ServerPluginEvent", + "typescriptType": "Array\u003cServerPluginEvent\u003e", + "usedTypescriptType": "ServerPluginEvent", + "usedStructName": "plugin_ui.ServerPluginEvent", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " BatchedPluginEvents represents a collection of plugin events to be sent together" + ] + }, + { + "filepath": "../internal/plugin/ui/context.go", + "filename": "context.go", + "name": "BatchedEvents", + "formattedName": "BatchedEvents", + "package": "plugin_ui", + "fields": [ + { + "name": "Events", + "jsonName": "events", + "goType": "[]events.WebsocketClientEvent", + "typescriptType": "Array\u003cEvents_WebsocketClientEvent\u003e", + "usedTypescriptType": "Events_WebsocketClientEvent", + "usedStructName": "events.WebsocketClientEvent", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " BatchedEvents represents a collection of events to be sent together" + ] + }, + { + "filepath": "../internal/plugin/ui/context.go", + "filename": "context.go", + "name": "Context", + "formattedName": "Context", + "package": "plugin_ui", + "fields": [ + { + "name": "ui", + "jsonName": "ui", + "goType": "UI", + "typescriptType": "UI", + "usedTypescriptType": "UI", + "usedStructName": "plugin_ui.UI", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ext", + "jsonName": "ext", + "goType": "extension.Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "fetchSem", + "jsonName": "fetchSem", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Semaphore for concurrent fetch requests" + ] + }, + { + "name": "vm", + "jsonName": "vm", + "goType": "goja.Runtime", + "typescriptType": "Runtime", + "usedTypescriptType": "Runtime", + "usedStructName": "goja.Runtime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "states", + "jsonName": "states", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "stateSubscribers", + "jsonName": "stateSubscribers", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "scheduler", + "jsonName": "scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": false, + "comments": [ + " Schedule VM executions concurrently and execute them in order." + ] + }, + { + "name": "wsSubscriber", + "jsonName": "wsSubscriber", + "goType": "events.ClientEventSubscriber", + "typescriptType": "Events_ClientEventSubscriber", + "usedTypescriptType": "Events_ClientEventSubscriber", + "usedStructName": "events.ClientEventSubscriber", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "eventBus", + "jsonName": "eventBus", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [ + " map[string]map[string]*EventListener (event -\u003e listenerID -\u003e listener)" + ] + }, + { + "name": "contextObj", + "jsonName": "contextObj", + "goType": "goja.Object", + "typescriptType": "Object", + "usedTypescriptType": "Object", + "usedStructName": "goja.Object", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "fieldRefCount", + "jsonName": "fieldRefCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Number of field refs registered" + ] + }, + { + "name": "exceptionCount", + "jsonName": "exceptionCount", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Number of exceptions that have occurred" + ] + }, + { + "name": "effectStack", + "jsonName": "effectStack", + "goType": "map[string]bool", + "typescriptType": "Record\u003cstring, boolean\u003e", + "required": false, + "public": false, + "comments": [ + " Track currently executing effects to prevent infinite loops" + ] + }, + { + "name": "effectCalls", + "jsonName": "effectCalls", + "goType": "map[string][]time.Time", + "typescriptType": "Record\u003cstring, Array\u003cstring\u003e\u003e", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [ + " Track effect calls within time window" + ] + }, + { + "name": "updateBatchMu", + "jsonName": "updateBatchMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "pendingStateUpdates", + "jsonName": "pendingStateUpdates", + "goType": "map[string]__STRUCT__", + "inlineStructType": "map[string]struct{\n}", + "typescriptType": "Record\u003cstring, { }\u003e", + "usedTypescriptType": "{ }", + "required": false, + "public": false, + "comments": [ + " Set of state IDs with pending updates" + ] + }, + { + "name": "updateBatchTimer", + "jsonName": "updateBatchTimer", + "goType": "time.Timer", + "typescriptType": "Timer", + "usedTypescriptType": "Timer", + "usedStructName": "time.Timer", + "required": false, + "public": false, + "comments": [ + " Timer for flushing batched updates" + ] + }, + { + "name": "eventBatchMu", + "jsonName": "eventBatchMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "pendingClientEvents", + "jsonName": "pendingClientEvents", + "goType": "[]ServerPluginEvent", + "typescriptType": "Array\u003cServerPluginEvent\u003e", + "usedTypescriptType": "ServerPluginEvent", + "usedStructName": "plugin_ui.ServerPluginEvent", + "required": false, + "public": false, + "comments": [ + " Queue of pending events to send to client" + ] + }, + { + "name": "eventBatchTimer", + "jsonName": "eventBatchTimer", + "goType": "time.Timer", + "typescriptType": "Timer", + "usedTypescriptType": "Timer", + "usedStructName": "time.Timer", + "required": false, + "public": false, + "comments": [ + " Timer for flushing batched events" + ] + }, + { + "name": "eventBatchSize", + "jsonName": "eventBatchSize", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Current size of the event batch" + ] + }, + { + "name": "lastUIUpdateAt", + "jsonName": "lastUIUpdateAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "uiUpdateMu", + "jsonName": "uiUpdateMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "webviewManager", + "jsonName": "webviewManager", + "goType": "WebviewManager", + "typescriptType": "WebviewManager", + "usedTypescriptType": "WebviewManager", + "usedStructName": "plugin_ui.WebviewManager", + "required": false, + "public": false, + "comments": [ + " UNUSED" + ] + }, + { + "name": "screenManager", + "jsonName": "screenManager", + "goType": "ScreenManager", + "typescriptType": "ScreenManager", + "usedTypescriptType": "ScreenManager", + "usedStructName": "plugin_ui.ScreenManager", + "required": false, + "public": false, + "comments": [ + " Listen for screen events, send screen actions" + ] + }, + { + "name": "trayManager", + "jsonName": "trayManager", + "goType": "TrayManager", + "typescriptType": "TrayManager", + "usedTypescriptType": "TrayManager", + "usedStructName": "plugin_ui.TrayManager", + "required": false, + "public": false, + "comments": [ + " Register and manage tray" + ] + }, + { + "name": "actionManager", + "jsonName": "actionManager", + "goType": "ActionManager", + "typescriptType": "ActionManager", + "usedTypescriptType": "ActionManager", + "usedStructName": "plugin_ui.ActionManager", + "required": false, + "public": false, + "comments": [ + " Register and manage actions" + ] + }, + { + "name": "formManager", + "jsonName": "formManager", + "goType": "FormManager", + "typescriptType": "FormManager", + "usedTypescriptType": "FormManager", + "usedStructName": "plugin_ui.FormManager", + "required": false, + "public": false, + "comments": [ + " Register and manage forms" + ] + }, + { + "name": "toastManager", + "jsonName": "toastManager", + "goType": "ToastManager", + "typescriptType": "ToastManager", + "usedTypescriptType": "ToastManager", + "usedStructName": "plugin_ui.ToastManager", + "required": false, + "public": false, + "comments": [ + " Register and manage toasts" + ] + }, + { + "name": "commandPaletteManager", + "jsonName": "commandPaletteManager", + "goType": "CommandPaletteManager", + "typescriptType": "CommandPaletteManager", + "usedTypescriptType": "CommandPaletteManager", + "usedStructName": "plugin_ui.CommandPaletteManager", + "required": false, + "public": false, + "comments": [ + " Register and manage command palette" + ] + }, + { + "name": "domManager", + "jsonName": "domManager", + "goType": "DOMManager", + "typescriptType": "DOMManager", + "usedTypescriptType": "DOMManager", + "usedStructName": "plugin_ui.DOMManager", + "required": false, + "public": false, + "comments": [ + " DOM manipulation manager" + ] + }, + { + "name": "notificationManager", + "jsonName": "notificationManager", + "goType": "NotificationManager", + "typescriptType": "NotificationManager", + "usedTypescriptType": "NotificationManager", + "usedStructName": "plugin_ui.NotificationManager", + "required": false, + "public": false, + "comments": [ + " Register and manage notifications" + ] + }, + { + "name": "atomicCleanupCounter", + "jsonName": "atomicCleanupCounter", + "goType": "atomic.Int64", + "typescriptType": "Int64", + "usedTypescriptType": "Int64", + "usedStructName": "atomic.Int64", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onCleanupFns", + "jsonName": "onCleanupFns", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cron", + "jsonName": "cron", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "registeredInlineEventHandlers", + "jsonName": "registeredInlineEventHandlers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " Context manages the entire plugin UI during its lifecycle" + ] + }, + { + "filepath": "../internal/plugin/ui/context.go", + "filename": "context.go", + "name": "State", + "formattedName": "State", + "package": "plugin_ui", + "fields": [ + { + "name": "ID", + "jsonName": "ID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "Value", + "goType": "goja.Value", + "typescriptType": "Value", + "usedTypescriptType": "Value", + "usedStructName": "goja.Value", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/context.go", + "filename": "context.go", + "name": "EventListener", + "formattedName": "EventListener", + "package": "plugin_ui", + "fields": [ + { + "name": "ID", + "jsonName": "ID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ListenTo", + "jsonName": "ListenTo", + "goType": "[]ClientEventType", + "typescriptType": "Array\u003cClientEventType\u003e", + "usedTypescriptType": "ClientEventType", + "usedStructName": "plugin_ui.ClientEventType", + "required": false, + "public": true, + "comments": [ + " Optional event type to listen for" + ] + }, + { + "name": "queue", + "jsonName": "queue", + "goType": "[]ClientPluginEvent", + "typescriptType": "Array\u003cClientPluginEvent\u003e", + "usedTypescriptType": "ClientPluginEvent", + "usedStructName": "plugin_ui.ClientPluginEvent", + "required": false, + "public": false, + "comments": [ + " Queue for event payloads" + ] + }, + { + "name": "callback", + "jsonName": "callback", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Callback function to process events" + ] + }, + { + "name": "closed", + "jsonName": "closed", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " EventListener is used by Goja methods to listen for events from the client" + ] + }, + { + "filepath": "../internal/plugin/ui/dom.go", + "filename": "dom.go", + "name": "DOMManager", + "formattedName": "DOMManager", + "package": "plugin_ui", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "elementObservers", + "jsonName": "elementObservers", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "eventListeners", + "jsonName": "eventListeners", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " DOMManager handles DOM manipulation requests from plugins" + ] + }, + { + "filepath": "../internal/plugin/ui/dom.go", + "filename": "dom.go", + "name": "ElementObserver", + "formattedName": "ElementObserver", + "package": "plugin_ui", + "fields": [ + { + "name": "ID", + "jsonName": "ID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Selector", + "jsonName": "Selector", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Callback", + "jsonName": "Callback", + "goType": "goja.Callable", + "typescriptType": "Callable", + "usedTypescriptType": "Callable", + "usedStructName": "goja.Callable", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/dom.go", + "filename": "dom.go", + "name": "DOMEventListener", + "formattedName": "DOMEventListener", + "package": "plugin_ui", + "fields": [ + { + "name": "ID", + "jsonName": "ID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ElementId", + "jsonName": "ElementId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EventType", + "jsonName": "EventType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Callback", + "jsonName": "Callback", + "goType": "goja.Callable", + "typescriptType": "Callable", + "usedTypescriptType": "Callable", + "usedStructName": "goja.Callable", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/dom.go", + "filename": "dom.go", + "name": "QueryElementOptions", + "formattedName": "QueryElementOptions", + "package": "plugin_ui", + "fields": [ + { + "name": "WithInnerHTML", + "jsonName": "withInnerHTML", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WithOuterHTML", + "jsonName": "withOuterHTML", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IdentifyChildren", + "jsonName": "identifyChildren", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientEventType", + "formattedName": "ClientEventType", + "package": "plugin_ui", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"tray:render\"", + "\"tray:list-icons\"", + "\"tray:opened\"", + "\"tray:closed\"", + "\"tray:clicked\"", + "\"command-palette:list\"", + "\"command-palette:opened\"", + "\"command-palette:closed\"", + "\"command-palette:render\"", + "\"command-palette:input\"", + "\"command-palette:item-selected\"", + "\"action:anime-page-buttons:render\"", + "\"action:anime-page-dropdown-items:render\"", + "\"action:manga-page-buttons:render\"", + "\"action:media-card-context-menu-items:render\"", + "\"action:anime-library-dropdown-items:render\"", + "\"action:episode-card-context-menu-items:render\"", + "\"action:episode-grid-item-menu-items:render\"", + "\"action:clicked\"", + "\"form:submitted\"", + "\"screen:changed\"", + "\"handler:triggered\"", + "\"field-ref:send-value\"", + "\"dom:query-result\"", + "\"dom:query-one-result\"", + "\"dom:observe-result\"", + "\"dom:stop-observe\"", + "\"dom:create-result\"", + "\"dom:element-updated\"", + "\"dom:event-triggered\"", + "\"dom:ready\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientPluginEvent", + "formattedName": "ClientPluginEvent", + "package": "plugin_ui", + "fields": [ + { + "name": "ExtensionID", + "jsonName": "extensionId", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "ClientEventType", + "typescriptType": "ClientEventType", + "usedTypescriptType": "ClientEventType", + "usedStructName": "plugin_ui.ClientEventType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ClientPluginEvent is an event received from the client" + ] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientRenderTrayEventPayload", + "formattedName": "ClientRenderTrayEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientListTrayIconsEventPayload", + "formattedName": "ClientListTrayIconsEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientTrayOpenedEventPayload", + "formattedName": "ClientTrayOpenedEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientTrayClosedEventPayload", + "formattedName": "ClientTrayClosedEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientTrayClickedEventPayload", + "formattedName": "ClientTrayClickedEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientActionRenderAnimePageButtonsEventPayload", + "formattedName": "ClientActionRenderAnimePageButtonsEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientActionRenderAnimePageDropdownItemsEventPayload", + "formattedName": "ClientActionRenderAnimePageDropdownItemsEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientActionRenderMangaPageButtonsEventPayload", + "formattedName": "ClientActionRenderMangaPageButtonsEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientActionRenderMediaCardContextMenuItemsEventPayload", + "formattedName": "ClientActionRenderMediaCardContextMenuItemsEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientActionRenderAnimeLibraryDropdownItemsEventPayload", + "formattedName": "ClientActionRenderAnimeLibraryDropdownItemsEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientActionRenderEpisodeCardContextMenuItemsEventPayload", + "formattedName": "ClientActionRenderEpisodeCardContextMenuItemsEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientActionRenderEpisodeGridItemMenuItemsEventPayload", + "formattedName": "ClientActionRenderEpisodeGridItemMenuItemsEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientListCommandPalettesEventPayload", + "formattedName": "ClientListCommandPalettesEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientCommandPaletteOpenedEventPayload", + "formattedName": "ClientCommandPaletteOpenedEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientCommandPaletteClosedEventPayload", + "formattedName": "ClientCommandPaletteClosedEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientActionClickedEventPayload", + "formattedName": "ClientActionClickedEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "ActionID", + "jsonName": "actionId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Event", + "jsonName": "event", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientEventHandlerTriggeredEventPayload", + "formattedName": "ClientEventHandlerTriggeredEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "HandlerName", + "jsonName": "handlerName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Event", + "jsonName": "event", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientFormSubmittedEventPayload", + "formattedName": "ClientFormSubmittedEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "FormName", + "jsonName": "formName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "data", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientScreenChangedEventPayload", + "formattedName": "ClientScreenChangedEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Pathname", + "jsonName": "pathname", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Query", + "jsonName": "query", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientFieldRefSendValueEventPayload", + "formattedName": "ClientFieldRefSendValueEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "FieldRef", + "jsonName": "fieldRef", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "value", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientRenderCommandPaletteEventPayload", + "formattedName": "ClientRenderCommandPaletteEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientCommandPaletteItemSelectedEventPayload", + "formattedName": "ClientCommandPaletteItemSelectedEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "ItemID", + "jsonName": "itemId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientCommandPaletteInputEventPayload", + "formattedName": "ClientCommandPaletteInputEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientDOMEventTriggeredEventPayload", + "formattedName": "ClientDOMEventTriggeredEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "ElementId", + "jsonName": "elementId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EventType", + "jsonName": "eventType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Event", + "jsonName": "event", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientDOMQueryResultEventPayload", + "formattedName": "ClientDOMQueryResultEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "RequestID", + "jsonName": "requestId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Elements", + "jsonName": "elements", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientDOMQueryOneResultEventPayload", + "formattedName": "ClientDOMQueryOneResultEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "RequestID", + "jsonName": "requestId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Element", + "jsonName": "element", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientDOMObserveResultEventPayload", + "formattedName": "ClientDOMObserveResultEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "ObserverId", + "jsonName": "observerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Elements", + "jsonName": "elements", + "goType": "[]", + "typescriptType": "Array\u003cany\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientDOMCreateResultEventPayload", + "formattedName": "ClientDOMCreateResultEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "RequestID", + "jsonName": "requestId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Element", + "jsonName": "element", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientDOMElementUpdatedEventPayload", + "formattedName": "ClientDOMElementUpdatedEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "ElementId", + "jsonName": "elementId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Action", + "jsonName": "action", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Result", + "jsonName": "result", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RequestID", + "jsonName": "requestId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientDOMStopObserveEventPayload", + "formattedName": "ClientDOMStopObserveEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "ObserverId", + "jsonName": "observerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ClientDOMReadyEventPayload", + "formattedName": "ClientDOMReadyEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerEventType", + "formattedName": "ServerEventType", + "package": "plugin_ui", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"tray:updated\"", + "\"tray:icon\"", + "\"tray:badge-updated\"", + "\"tray:open\"", + "\"tray:close\"", + "\"command-palette:info\"", + "\"command-palette:updated\"", + "\"command-palette:open\"", + "\"command-palette:close\"", + "\"command-palette:get-input\"", + "\"command-palette:set-input\"", + "\"action:anime-page-buttons:updated\"", + "\"action:anime-page-dropdown-items:updated\"", + "\"action:manga-page-buttons:updated\"", + "\"action:media-card-context-menu-items:updated\"", + "\"action:episode-card-context-menu-items:updated\"", + "\"action:episode-grid-item-menu-items:updated\"", + "\"action:anime-library-dropdown-items:updated\"", + "\"form:reset\"", + "\"form:set-values\"", + "\"field-ref:set-value\"", + "\"fatal-error\"", + "\"screen:navigate-to\"", + "\"screen:reload\"", + "\"screen:get-current\"", + "\"dom:query\"", + "\"dom:query-one\"", + "\"dom:observe\"", + "\"dom:stop-observe\"", + "\"dom:create\"", + "\"dom:manipulate\"", + "\"dom:observe-in-view\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerPluginEvent", + "formattedName": "ServerPluginEvent", + "package": "plugin_ui", + "fields": [ + { + "name": "ExtensionID", + "jsonName": "extensionId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Extension ID must be set" + ] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "ServerEventType", + "typescriptType": "ServerEventType", + "usedTypescriptType": "ServerEventType", + "usedStructName": "plugin_ui.ServerEventType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Payload", + "jsonName": "payload", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ServerPluginEvent is an event sent to the client" + ] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerTrayUpdatedEventPayload", + "formattedName": "ServerTrayUpdatedEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Components", + "jsonName": "components", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerCommandPaletteUpdatedEventPayload", + "formattedName": "ServerCommandPaletteUpdatedEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Placeholder", + "jsonName": "placeholder", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Items", + "jsonName": "items", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerTrayOpenEventPayload", + "formattedName": "ServerTrayOpenEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "ExtensionID", + "jsonName": "extensionId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerTrayCloseEventPayload", + "formattedName": "ServerTrayCloseEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "ExtensionID", + "jsonName": "extensionId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerTrayIconEventPayload", + "formattedName": "ServerTrayIconEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "ExtensionID", + "jsonName": "extensionId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ExtensionName", + "jsonName": "extensionName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IconURL", + "jsonName": "iconUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WithContent", + "jsonName": "withContent", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TooltipText", + "jsonName": "tooltipText", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BadgeNumber", + "jsonName": "badgeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BadgeIntent", + "jsonName": "badgeIntent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Width", + "jsonName": "width", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MinHeight", + "jsonName": "minHeight", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerTrayBadgeUpdatedEventPayload", + "formattedName": "ServerTrayBadgeUpdatedEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "BadgeNumber", + "jsonName": "badgeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BadgeIntent", + "jsonName": "badgeIntent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerFormResetEventPayload", + "formattedName": "ServerFormResetEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "FormName", + "jsonName": "formName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FieldToReset", + "jsonName": "fieldToReset", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " If not set, the form will be reset" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerFormSetValuesEventPayload", + "formattedName": "ServerFormSetValuesEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "FormName", + "jsonName": "formName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Data", + "jsonName": "data", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerFieldRefSetValueEventPayload", + "formattedName": "ServerFieldRefSetValueEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "FieldRef", + "jsonName": "fieldRef", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "value", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerFieldRefGetValueEventPayload", + "formattedName": "ServerFieldRefGetValueEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "FieldRef", + "jsonName": "fieldRef", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerFatalErrorEventPayload", + "formattedName": "ServerFatalErrorEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Error", + "jsonName": "error", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerScreenNavigateToEventPayload", + "formattedName": "ServerScreenNavigateToEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerActionRenderAnimePageButtonsEventPayload", + "formattedName": "ServerActionRenderAnimePageButtonsEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Buttons", + "jsonName": "buttons", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerActionRenderAnimePageDropdownItemsEventPayload", + "formattedName": "ServerActionRenderAnimePageDropdownItemsEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Items", + "jsonName": "items", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerActionRenderMangaPageButtonsEventPayload", + "formattedName": "ServerActionRenderMangaPageButtonsEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Buttons", + "jsonName": "buttons", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerActionRenderMediaCardContextMenuItemsEventPayload", + "formattedName": "ServerActionRenderMediaCardContextMenuItemsEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Items", + "jsonName": "items", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerActionRenderAnimeLibraryDropdownItemsEventPayload", + "formattedName": "ServerActionRenderAnimeLibraryDropdownItemsEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Items", + "jsonName": "items", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerActionRenderEpisodeCardContextMenuItemsEventPayload", + "formattedName": "ServerActionRenderEpisodeCardContextMenuItemsEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Items", + "jsonName": "items", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerActionRenderEpisodeGridItemMenuItemsEventPayload", + "formattedName": "ServerActionRenderEpisodeGridItemMenuItemsEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Items", + "jsonName": "items", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerScreenReloadEventPayload", + "formattedName": "ServerScreenReloadEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerCommandPaletteInfoEventPayload", + "formattedName": "ServerCommandPaletteInfoEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Placeholder", + "jsonName": "placeholder", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "KeyboardShortcut", + "jsonName": "keyboardShortcut", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerCommandPaletteOpenEventPayload", + "formattedName": "ServerCommandPaletteOpenEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerCommandPaletteCloseEventPayload", + "formattedName": "ServerCommandPaletteCloseEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerCommandPaletteGetInputEventPayload", + "formattedName": "ServerCommandPaletteGetInputEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerCommandPaletteSetInputEventPayload", + "formattedName": "ServerCommandPaletteSetInputEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Value", + "jsonName": "value", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerScreenGetCurrentEventPayload", + "formattedName": "ServerScreenGetCurrentEventPayload", + "package": "plugin_ui", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerDOMQueryEventPayload", + "formattedName": "ServerDOMQueryEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Selector", + "jsonName": "selector", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RequestID", + "jsonName": "requestId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WithInnerHTML", + "jsonName": "withInnerHTML", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WithOuterHTML", + "jsonName": "withOuterHTML", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IdentifyChildren", + "jsonName": "identifyChildren", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Add DOM event payloads" + ] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerDOMQueryOneEventPayload", + "formattedName": "ServerDOMQueryOneEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Selector", + "jsonName": "selector", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RequestID", + "jsonName": "requestId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WithInnerHTML", + "jsonName": "withInnerHTML", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WithOuterHTML", + "jsonName": "withOuterHTML", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IdentifyChildren", + "jsonName": "identifyChildren", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerDOMObserveEventPayload", + "formattedName": "ServerDOMObserveEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Selector", + "jsonName": "selector", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ObserverId", + "jsonName": "observerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WithInnerHTML", + "jsonName": "withInnerHTML", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WithOuterHTML", + "jsonName": "withOuterHTML", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IdentifyChildren", + "jsonName": "identifyChildren", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerDOMStopObserveEventPayload", + "formattedName": "ServerDOMStopObserveEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "ObserverId", + "jsonName": "observerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerDOMCreateEventPayload", + "formattedName": "ServerDOMCreateEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "TagName", + "jsonName": "tagName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RequestID", + "jsonName": "requestId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerDOMManipulateEventPayload", + "formattedName": "ServerDOMManipulateEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "ElementId", + "jsonName": "elementId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Action", + "jsonName": "action", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Params", + "jsonName": "params", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RequestID", + "jsonName": "requestId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/events.go", + "filename": "events.go", + "name": "ServerDOMObserveInViewEventPayload", + "formattedName": "ServerDOMObserveInViewEventPayload", + "package": "plugin_ui", + "fields": [ + { + "name": "Selector", + "jsonName": "selector", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ObserverId", + "jsonName": "observerId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WithInnerHTML", + "jsonName": "withInnerHTML", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WithOuterHTML", + "jsonName": "withOuterHTML", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IdentifyChildren", + "jsonName": "identifyChildren", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Margin", + "jsonName": "margin", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/form.go", + "filename": "form.go", + "name": "FormManager", + "formattedName": "FormManager", + "package": "plugin_ui", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/form.go", + "filename": "form.go", + "name": "FormField", + "formattedName": "FormField", + "package": "plugin_ui", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Label", + "jsonName": "label", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Placeholder", + "jsonName": "placeholder", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "value", + "goType": "", + "typescriptType": "any", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Options", + "jsonName": "options", + "goType": "[]FormFieldOption", + "typescriptType": "Array\u003cFormFieldOption\u003e", + "usedTypescriptType": "FormFieldOption", + "usedStructName": "plugin_ui.FormFieldOption", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Props", + "jsonName": "props", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/form.go", + "filename": "form.go", + "name": "FormFieldOption", + "formattedName": "FormFieldOption", + "package": "plugin_ui", + "fields": [ + { + "name": "Label", + "jsonName": "label", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "value", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/form.go", + "filename": "form.go", + "name": "Form", + "formattedName": "Form", + "package": "plugin_ui", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Props", + "jsonName": "props", + "goType": "FormProps", + "typescriptType": "FormProps", + "usedTypescriptType": "FormProps", + "usedStructName": "plugin_ui.FormProps", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "manager", + "jsonName": "manager", + "goType": "FormManager", + "typescriptType": "FormManager", + "usedTypescriptType": "FormManager", + "usedStructName": "plugin_ui.FormManager", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/form.go", + "filename": "form.go", + "name": "FormProps", + "formattedName": "FormProps", + "package": "plugin_ui", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Fields", + "jsonName": "fields", + "goType": "[]FormField", + "typescriptType": "Array\u003cFormField\u003e", + "usedTypescriptType": "FormField", + "usedStructName": "plugin_ui.FormField", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/notification.go", + "filename": "notification.go", + "name": "NotificationManager", + "formattedName": "NotificationManager", + "package": "plugin_ui", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/screen.go", + "filename": "screen.go", + "name": "ScreenManager", + "formattedName": "ScreenManager", + "package": "plugin_ui", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/toast.go", + "filename": "toast.go", + "name": "ToastManager", + "formattedName": "ToastManager", + "package": "plugin_ui", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/tray.go", + "filename": "tray.go", + "name": "TrayManager", + "formattedName": "TrayManager", + "package": "plugin_ui", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "tray", + "jsonName": "tray", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "lastUpdatedAt", + "jsonName": "lastUpdatedAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "updateMutex", + "jsonName": "updateMutex", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "componentManager", + "jsonName": "componentManager", + "goType": "ComponentManager", + "typescriptType": "ComponentManager", + "usedTypescriptType": "ComponentManager", + "usedStructName": "plugin_ui.ComponentManager", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/tray.go", + "filename": "tray.go", + "name": "Tray", + "formattedName": "Tray", + "package": "plugin_ui", + "fields": [ + { + "name": "WithContent", + "jsonName": "withContent", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IconURL", + "jsonName": "iconUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TooltipText", + "jsonName": "tooltipText", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BadgeNumber", + "jsonName": "badgeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BadgeIntent", + "jsonName": "badgeIntent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Width", + "jsonName": "width", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MinHeight", + "jsonName": "minHeight", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "renderFunc", + "jsonName": "renderFunc", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "trayManager", + "jsonName": "trayManager", + "goType": "TrayManager", + "typescriptType": "TrayManager", + "usedTypescriptType": "TrayManager", + "usedStructName": "plugin_ui.TrayManager", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/tray.go", + "filename": "tray.go", + "name": "Component", + "formattedName": "Component", + "package": "plugin_ui", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Props", + "jsonName": "props", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Key", + "jsonName": "key", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/ui.go", + "filename": "ui.go", + "name": "UI", + "formattedName": "UI", + "package": "plugin_ui", + "fields": [ + { + "name": "ext", + "jsonName": "ext", + "goType": "extension.Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "context", + "jsonName": "context", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "vm", + "jsonName": "vm", + "goType": "goja.Runtime", + "typescriptType": "Runtime", + "usedTypescriptType": "Runtime", + "usedStructName": "goja.Runtime", + "required": false, + "public": false, + "comments": [ + " VM executing the UI" + ] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "appContext", + "jsonName": "appContext", + "goType": "plugin.AppContext", + "typescriptType": "AppContext", + "usedTypescriptType": "AppContext", + "usedStructName": "plugin.AppContext", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "scheduler", + "jsonName": "scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "lastException", + "jsonName": "lastException", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "destroyedCh", + "jsonName": "destroyedCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "destroyed", + "jsonName": "destroyed", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [ + " UI registry, unique to a plugin and VM" + ] + }, + { + "filepath": "../internal/plugin/ui/ui.go", + "filename": "ui.go", + "name": "NewUIOptions", + "formattedName": "NewUIOptions", + "package": "plugin_ui", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VM", + "jsonName": "VM", + "goType": "goja.Runtime", + "typescriptType": "Runtime", + "usedTypescriptType": "Runtime", + "usedStructName": "goja.Runtime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSManager", + "jsonName": "WSManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Scheduler", + "jsonName": "Scheduler", + "goType": "goja_util.Scheduler", + "typescriptType": "Scheduler", + "usedTypescriptType": "Scheduler", + "usedStructName": "goja_util.Scheduler", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Extension", + "jsonName": "Extension", + "goType": "extension.Extension", + "typescriptType": "Extension_Extension", + "usedTypescriptType": "Extension_Extension", + "usedStructName": "extension.Extension", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/plugin/ui/ui.go", + "filename": "ui.go", + "name": "BatchedClientEvents", + "formattedName": "BatchedClientEvents", + "package": "plugin_ui", + "fields": [ + { + "name": "Events", + "jsonName": "events", + "goType": "[]map[string]", + "typescriptType": "Array\u003cRecord\u003cstring, any\u003e\u003e", + "usedStructName": "plugin_ui.map[string]", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Add this new type to handle batched events from the client" + ] + }, + { + "filepath": "../internal/plugin/ui/webview.go", + "filename": "webview.go", + "name": "WebviewManager", + "formattedName": "WebviewManager", + "package": "plugin_ui", + "fields": [ + { + "name": "ctx", + "jsonName": "ctx", + "goType": "Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "plugin_ui.Context", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/report/report.go", + "filename": "report.go", + "name": "ClickLog", + "formattedName": "Report_ClickLog", + "package": "report", + "fields": [ + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Element", + "jsonName": "element", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PageURL", + "jsonName": "pageUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Text", + "jsonName": "text", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ClassName", + "jsonName": "className", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/report/report.go", + "filename": "report.go", + "name": "NetworkLog", + "formattedName": "Report_NetworkLog", + "package": "report", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Method", + "jsonName": "method", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PageURL", + "jsonName": "pageUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Duration", + "jsonName": "duration", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DataPreview", + "jsonName": "dataPreview", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Body", + "jsonName": "body", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/report/report.go", + "filename": "report.go", + "name": "ReactQueryLog", + "formattedName": "Report_ReactQueryLog", + "package": "report", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PageURL", + "jsonName": "pageUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Error", + "jsonName": "error", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DataPreview", + "jsonName": "dataPreview", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DataType", + "jsonName": "dataType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/report/report.go", + "filename": "report.go", + "name": "ConsoleLog", + "formattedName": "Report_ConsoleLog", + "package": "report", + "fields": [ + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Content", + "jsonName": "content", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PageURL", + "jsonName": "pageUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/report/report.go", + "filename": "report.go", + "name": "UnlockedLocalFile", + "formattedName": "Report_UnlockedLocalFile", + "package": "report", + "fields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MediaId", + "jsonName": "mediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/report/report.go", + "filename": "report.go", + "name": "IssueReport", + "formattedName": "Report_IssueReport", + "package": "report", + "fields": [ + { + "name": "CreatedAt", + "jsonName": "createdAt", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "userAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AppVersion", + "jsonName": "appVersion", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "OS", + "jsonName": "os", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Arch", + "jsonName": "arch", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ClickLogs", + "jsonName": "clickLogs", + "goType": "[]ClickLog", + "typescriptType": "Array\u003cReport_ClickLog\u003e", + "usedTypescriptType": "Report_ClickLog", + "usedStructName": "report.ClickLog", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NetworkLogs", + "jsonName": "networkLogs", + "goType": "[]NetworkLog", + "typescriptType": "Array\u003cReport_NetworkLog\u003e", + "usedTypescriptType": "Report_NetworkLog", + "usedStructName": "report.NetworkLog", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReactQueryLogs", + "jsonName": "reactQueryLogs", + "goType": "[]ReactQueryLog", + "typescriptType": "Array\u003cReport_ReactQueryLog\u003e", + "usedTypescriptType": "Report_ReactQueryLog", + "usedStructName": "report.ReactQueryLog", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ConsoleLogs", + "jsonName": "consoleLogs", + "goType": "[]ConsoleLog", + "typescriptType": "Array\u003cReport_ConsoleLog\u003e", + "usedTypescriptType": "Report_ConsoleLog", + "usedStructName": "report.ConsoleLog", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UnlockedLocalFiles", + "jsonName": "unlockedLocalFiles", + "goType": "[]UnlockedLocalFile", + "typescriptType": "Array\u003cReport_UnlockedLocalFile\u003e", + "usedTypescriptType": "Report_UnlockedLocalFile", + "usedStructName": "report.UnlockedLocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ScanLogs", + "jsonName": "scanLogs", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ServerLogs", + "jsonName": "serverLogs", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ServerStatus", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/report/repository.go", + "filename": "repository.go", + "name": "Repository", + "formattedName": "Report_Repository", + "package": "report", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "savedIssueReport", + "jsonName": "savedIssueReport", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/report/repository.go", + "filename": "repository.go", + "name": "SaveIssueReportOptions", + "formattedName": "Report_SaveIssueReportOptions", + "package": "report", + "fields": [ + { + "name": "LogsDir", + "jsonName": "logsDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "userAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ClickLogs", + "jsonName": "clickLogs", + "goType": "[]ClickLog", + "typescriptType": "Array\u003cReport_ClickLog\u003e", + "usedTypescriptType": "Report_ClickLog", + "usedStructName": "report.ClickLog", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NetworkLogs", + "jsonName": "networkLogs", + "goType": "[]NetworkLog", + "typescriptType": "Array\u003cReport_NetworkLog\u003e", + "usedTypescriptType": "Report_NetworkLog", + "usedStructName": "report.NetworkLog", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReactQueryLogs", + "jsonName": "reactQueryLogs", + "goType": "[]ReactQueryLog", + "typescriptType": "Array\u003cReport_ReactQueryLog\u003e", + "usedTypescriptType": "Report_ReactQueryLog", + "usedStructName": "report.ReactQueryLog", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ConsoleLogs", + "jsonName": "consoleLogs", + "goType": "[]ConsoleLog", + "typescriptType": "Array\u003cReport_ConsoleLog\u003e", + "usedTypescriptType": "Report_ConsoleLog", + "usedStructName": "report.ConsoleLog", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LocalFiles", + "jsonName": "localFiles", + "goType": "[]anime.LocalFile", + "typescriptType": "Array\u003cAnime_LocalFile\u003e", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Settings", + "jsonName": "settings", + "goType": "models.Settings", + "typescriptType": "Models_Settings", + "usedTypescriptType": "Models_Settings", + "usedStructName": "models.Settings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DebridSettings", + "jsonName": "debridSettings", + "goType": "models.DebridSettings", + "typescriptType": "Models_DebridSettings", + "usedTypescriptType": "Models_DebridSettings", + "usedStructName": "models.DebridSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IsAnimeLibraryIssue", + "jsonName": "isAnimeLibraryIssue", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ServerStatus", + "jsonName": "serverStatus", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/test_utils/data.go", + "filename": "data.go", + "name": "Config", + "formattedName": "Config", + "package": "test_utils", + "fields": [ + { + "name": "Provider", + "jsonName": "Provider", + "goType": "ProviderConfig", + "typescriptType": "ProviderConfig", + "usedTypescriptType": "ProviderConfig", + "usedStructName": "test_utils.ProviderConfig", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "Path", + "goType": "PathConfig", + "typescriptType": "PathConfig", + "usedTypescriptType": "PathConfig", + "usedStructName": "test_utils.PathConfig", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "DatabaseConfig", + "typescriptType": "DatabaseConfig", + "usedTypescriptType": "DatabaseConfig", + "usedStructName": "test_utils.DatabaseConfig", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Flags", + "jsonName": "Flags", + "goType": "FlagsConfig", + "typescriptType": "FlagsConfig", + "usedTypescriptType": "FlagsConfig", + "usedStructName": "test_utils.FlagsConfig", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/test_utils/data.go", + "filename": "data.go", + "name": "FlagsConfig", + "formattedName": "FlagsConfig", + "package": "test_utils", + "fields": [ + { + "name": "EnableAnilistTests", + "jsonName": "EnableAnilistTests", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableAnilistMutationTests", + "jsonName": "EnableAnilistMutationTests", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableMalTests", + "jsonName": "EnableMalTests", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableMalMutationTests", + "jsonName": "EnableMalMutationTests", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableMediaPlayerTests", + "jsonName": "EnableMediaPlayerTests", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableTorrentClientTests", + "jsonName": "EnableTorrentClientTests", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableTorrentstreamTests", + "jsonName": "EnableTorrentstreamTests", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/test_utils/data.go", + "filename": "data.go", + "name": "ProviderConfig", + "formattedName": "ProviderConfig", + "package": "test_utils", + "fields": [ + { + "name": "AnilistJwt", + "jsonName": "AnilistJwt", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnilistUsername", + "jsonName": "AnilistUsername", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MalJwt", + "jsonName": "MalJwt", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QbittorrentHost", + "jsonName": "QbittorrentHost", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QbittorrentPort", + "jsonName": "QbittorrentPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QbittorrentUsername", + "jsonName": "QbittorrentUsername", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QbittorrentPassword", + "jsonName": "QbittorrentPassword", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QbittorrentPath", + "jsonName": "QbittorrentPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TransmissionHost", + "jsonName": "TransmissionHost", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TransmissionPort", + "jsonName": "TransmissionPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TransmissionPath", + "jsonName": "TransmissionPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TransmissionUsername", + "jsonName": "TransmissionUsername", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TransmissionPassword", + "jsonName": "TransmissionPassword", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MpcHost", + "jsonName": "MpcHost", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MpcPort", + "jsonName": "MpcPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MpcPath", + "jsonName": "MpcPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VlcHost", + "jsonName": "VlcHost", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VlcPort", + "jsonName": "VlcPort", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VlcPassword", + "jsonName": "VlcPassword", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "VlcPath", + "jsonName": "VlcPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MpvPath", + "jsonName": "MpvPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MpvSocket", + "jsonName": "MpvSocket", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IinaPath", + "jsonName": "IinaPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IinaSocket", + "jsonName": "IinaSocket", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TorBoxApiKey", + "jsonName": "TorBoxApiKey", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RealDebridApiKey", + "jsonName": "RealDebridApiKey", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/test_utils/data.go", + "filename": "data.go", + "name": "PathConfig", + "formattedName": "PathConfig", + "package": "test_utils", + "fields": [ + { + "name": "DataDir", + "jsonName": "DataDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/test_utils/data.go", + "filename": "data.go", + "name": "DatabaseConfig", + "formattedName": "DatabaseConfig", + "package": "test_utils", + "fields": [ + { + "name": "Name", + "jsonName": "Name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/application/client.go", + "filename": "client.go", + "name": "Client", + "formattedName": "Client", + "package": "qbittorrent_application", + "fields": [ + { + "name": "BaseUrl", + "jsonName": "BaseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/client.go", + "filename": "client.go", + "name": "Client", + "formattedName": "Client", + "package": "qbittorrent", + "fields": [ + { + "name": "baseURL", + "jsonName": "baseURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "client", + "jsonName": "client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "Username", + "jsonName": "Username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Password", + "jsonName": "Password", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Port", + "jsonName": "Port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Host", + "jsonName": "Host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "Path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisableBinaryUse", + "jsonName": "DisableBinaryUse", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Tags", + "jsonName": "Tags", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Application", + "jsonName": "Application", + "goType": "qbittorrent_application.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "qbittorrent_application.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Log", + "jsonName": "Log", + "goType": "qbittorrent_log.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "qbittorrent_log.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RSS", + "jsonName": "RSS", + "goType": "qbittorrent_rss.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "qbittorrent_rss.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Search", + "jsonName": "Search", + "goType": "qbittorrent_search.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "qbittorrent_search.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Sync", + "jsonName": "Sync", + "goType": "qbittorrent_sync.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "qbittorrent_sync.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Torrent", + "jsonName": "Torrent", + "goType": "qbittorrent_torrent.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "qbittorrent_torrent.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Transfer", + "jsonName": "Transfer", + "goType": "qbittorrent_transfer.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "qbittorrent_transfer.Client", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/client.go", + "filename": "client.go", + "name": "NewClientOptions", + "formattedName": "NewClientOptions", + "package": "qbittorrent", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Username", + "jsonName": "Username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Password", + "jsonName": "Password", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Port", + "jsonName": "Port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Host", + "jsonName": "Host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "Path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisableBinaryUse", + "jsonName": "DisableBinaryUse", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Tags", + "jsonName": "Tags", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/log/client.go", + "filename": "client.go", + "name": "Client", + "formattedName": "Client", + "package": "qbittorrent_log", + "fields": [ + { + "name": "BaseUrl", + "jsonName": "BaseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/add_torrents_options.go", + "filename": "add_torrents_options.go", + "name": "AddTorrentsOptions", + "formattedName": "AddTorrentsOptions", + "package": "qbittorrent_model", + "fields": [ + { + "name": "Savepath", + "jsonName": "savepath", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Cookie", + "jsonName": "cookie", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Category", + "jsonName": "category", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SkipChecking", + "jsonName": "skip_checking", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Paused", + "jsonName": "paused", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RootFolder", + "jsonName": "root_folder", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rename", + "jsonName": "rename", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UpLimit", + "jsonName": "upLimit", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DlLimit", + "jsonName": "dlLimit", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "UseAutoTMM", + "jsonName": "useAutoTMM", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "SequentialDownload", + "jsonName": "sequentialDownload", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FirstLastPiecePrio", + "jsonName": "firstLastPiecePrio", + "goType": "bool", + "typescriptType": "boolean", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Tags", + "jsonName": "tags", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/build_info.go", + "filename": "build_info.go", + "name": "BuildInfo", + "formattedName": "BuildInfo", + "package": "qbittorrent_model", + "fields": [ + { + "name": "QT", + "jsonName": "qt", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LibTorrent", + "jsonName": "libtorrent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Boost", + "jsonName": "boost", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "OpenSSL", + "jsonName": "openssl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Bitness", + "jsonName": "bitness", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/category.go", + "filename": "category.go", + "name": "Category", + "formattedName": "Category", + "package": "qbittorrent_model", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SavePath", + "jsonName": "savePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/get_log_options.go", + "filename": "get_log_options.go", + "name": "GetLogOptions", + "formattedName": "GetLogOptions", + "package": "qbittorrent_model", + "fields": [ + { + "name": "Normal", + "jsonName": "Normal", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Info", + "jsonName": "Info", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Warning", + "jsonName": "Warning", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Critical", + "jsonName": "Critical", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastKnownID", + "jsonName": "LastKnownID", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/get_torrents_list_options.go", + "filename": "get_torrents_list_options.go", + "name": "GetTorrentListOptions", + "formattedName": "GetTorrentListOptions", + "package": "qbittorrent_model", + "fields": [ + { + "name": "Filter", + "jsonName": "Filter", + "goType": "TorrentListFilter", + "typescriptType": "TorrentListFilter", + "usedTypescriptType": "TorrentListFilter", + "usedStructName": "qbittorrent_model.TorrentListFilter", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Category", + "jsonName": "Category", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Sort", + "jsonName": "Sort", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Reverse", + "jsonName": "Reverse", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Limit", + "jsonName": "Limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Offset", + "jsonName": "Offset", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hashes", + "jsonName": "Hashes", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/get_torrents_list_options.go", + "filename": "get_torrents_list_options.go", + "name": "TorrentListFilter", + "formattedName": "TorrentListFilter", + "package": "qbittorrent_model", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"all\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/log_entry.go", + "filename": "log_entry.go", + "name": "LogEntry", + "formattedName": "LogEntry", + "package": "qbittorrent_model", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Message", + "jsonName": "message", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "LogType", + "typescriptType": "LogType", + "usedTypescriptType": "LogType", + "usedStructName": "qbittorrent_model.LogType", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/log_entry.go", + "filename": "log_entry.go", + "name": "LogType", + "formattedName": "LogType", + "package": "qbittorrent_model", + "fields": [], + "aliasOf": { + "goType": "int", + "typescriptType": "number", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/peer.go", + "filename": "peer.go", + "name": "Peer", + "formattedName": "Peer", + "package": "qbittorrent_model", + "fields": [ + { + "name": "Client", + "jsonName": "client", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Connection", + "jsonName": "connection", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Country", + "jsonName": "country", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CountryCode", + "jsonName": "country_code", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DLSpeed", + "jsonName": "dlSpeed", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Downloaded", + "jsonName": "downloaded", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Files", + "jsonName": "files", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Flags", + "jsonName": "flags", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FlagsDescription", + "jsonName": "flags_desc", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IP", + "jsonName": "ip", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Port", + "jsonName": "port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Relevance", + "jsonName": "relevance", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ULSpeed", + "jsonName": "up_speed", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Uploaded", + "jsonName": "uploaded", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/peer_log_entry.go", + "filename": "peer_log_entry.go", + "name": "PeerLogEntry", + "formattedName": "PeerLogEntry", + "package": "qbittorrent_model", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IP", + "jsonName": "ip", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Blocked", + "jsonName": "blocked", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Reason", + "jsonName": "reason", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/preferences.go", + "filename": "preferences.go", + "name": "Preferences", + "formattedName": "Preferences", + "package": "qbittorrent_model", + "fields": [ + { + "name": "Locale", + "jsonName": "locale", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CreateSubfolderEnabled", + "jsonName": "create_subfolder_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StartPausedEnabled", + "jsonName": "start_paused_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoDeleteMode", + "jsonName": "auto_delete_mode", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PreallocateAll", + "jsonName": "preallocate_all", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IncompleteFilesExt", + "jsonName": "incomplete_files_ext", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoTmmEnabled", + "jsonName": "auto_tmm_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TorrentChangedTmmEnabled", + "jsonName": "torrent_changed_tmm_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SavePathChangedTmmEnabled", + "jsonName": "save_path_changed_tmm_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CategoryChangedTmmEnabled", + "jsonName": "category_changed_tmm_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SavePath", + "jsonName": "save_path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TempPathEnabled", + "jsonName": "temp_path_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TempPath", + "jsonName": "temp_path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ScanDirs", + "jsonName": "scan_dirs", + "goType": "map[string]", + "typescriptType": "Record\u003cstring, any\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ExportDir", + "jsonName": "export_dir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ExportDirFin", + "jsonName": "export_dir_fin", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MailNotificationEnabled", + "jsonName": "mail_notification_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MailNotificationSender", + "jsonName": "mail_notification_sender", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MailNotificationEmail", + "jsonName": "mail_notification_email", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MailNotificationSmtp", + "jsonName": "mail_notification_smtp", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MailNotificationSslEnabled", + "jsonName": "mail_notification_ssl_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MailNotificationAuthEnabled", + "jsonName": "mail_notification_auth_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MailNotificationUsername", + "jsonName": "mail_notification_username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MailNotificationPassword", + "jsonName": "mail_notification_password", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutorunEnabled", + "jsonName": "autorun_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutorunProgram", + "jsonName": "autorun_program", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QueueingEnabled", + "jsonName": "queueing_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxActiveDownloads", + "jsonName": "max_active_downloads", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxActiveTorrents", + "jsonName": "max_active_torrents", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxActiveUploads", + "jsonName": "max_active_uploads", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DontCountSlowTorrents", + "jsonName": "dont_count_slow_torrents", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SlowTorrentDlRateThreshold", + "jsonName": "slow_torrent_dl_rate_threshold", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SlowTorrentUlRateThreshold", + "jsonName": "slow_torrent_ul_rate_threshold", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SlowTorrentInactiveTimer", + "jsonName": "slow_torrent_inactive_timer", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxRatioEnabled", + "jsonName": "max_ratio_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxRatio", + "jsonName": "max_ratio", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxRatioAct", + "jsonName": "max_ratio_act", + "goType": "MaxRatioAction", + "typescriptType": "MaxRatioAction", + "usedTypescriptType": "MaxRatioAction", + "usedStructName": "qbittorrent_model.MaxRatioAction", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ListenPort", + "jsonName": "listen_port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Upnp", + "jsonName": "upnp", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RandomPort", + "jsonName": "random_port", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DlLimit", + "jsonName": "dl_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpLimit", + "jsonName": "up_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxConnec", + "jsonName": "max_connec", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxConnecPerTorrent", + "jsonName": "max_connec_per_torrent", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxUploads", + "jsonName": "max_uploads", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxUploadsPerTorrent", + "jsonName": "max_uploads_per_torrent", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EnableUtp", + "jsonName": "enable_utp", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LimitUtpRate", + "jsonName": "limit_utp_rate", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LimitTcpOverhead", + "jsonName": "limit_tcp_overhead", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LimitLanPeers", + "jsonName": "limit_lan_peers", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AltDlLimit", + "jsonName": "alt_dl_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AltUpLimit", + "jsonName": "alt_up_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SchedulerEnabled", + "jsonName": "scheduler_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ScheduleFromHour", + "jsonName": "schedule_from_hour", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ScheduleFromMin", + "jsonName": "schedule_from_min", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ScheduleToHour", + "jsonName": "schedule_to_hour", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ScheduleToMin", + "jsonName": "schedule_to_min", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SchedulerDays", + "jsonName": "scheduler_days", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Dht", + "jsonName": "dht", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DhtSameAsBT", + "jsonName": "dhtSameAsBT", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DhtPort", + "jsonName": "dht_port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Pex", + "jsonName": "pex", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Lsd", + "jsonName": "lsd", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Encryption", + "jsonName": "encryption", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AnonymousMode", + "jsonName": "anonymous_mode", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ProxyType", + "jsonName": "proxy_type", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ProxyIp", + "jsonName": "proxy_ip", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ProxyPort", + "jsonName": "proxy_port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ProxyPeerConnections", + "jsonName": "proxy_peer_connections", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ForceProxy", + "jsonName": "force_proxy", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ProxyAuthEnabled", + "jsonName": "proxy_auth_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ProxyUsername", + "jsonName": "proxy_username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ProxyPassword", + "jsonName": "proxy_password", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IpFilterEnabled", + "jsonName": "ip_filter_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IpFilterPath", + "jsonName": "ip_filter_path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IpFilterTrackers", + "jsonName": "ip_filter_trackers", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WebUiDomainList", + "jsonName": "web_ui_domain_list", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WebUiAddress", + "jsonName": "web_ui_address", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WebUiPort", + "jsonName": "web_ui_port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WebUiUpnp", + "jsonName": "web_ui_upnp", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WebUiUsername", + "jsonName": "web_ui_username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WebUiPassword", + "jsonName": "web_ui_password", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WebUiCsrfProtectionEnabled", + "jsonName": "web_ui_csrf_protection_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WebUiClickjackingProtectionEnabled", + "jsonName": "web_ui_clickjacking_protection_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BypassLocalAuth", + "jsonName": "bypass_local_auth", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BypassAuthSubnetWhitelistEnabled", + "jsonName": "bypass_auth_subnet_whitelist_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BypassAuthSubnetWhitelist", + "jsonName": "bypass_auth_subnet_whitelist", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AlternativeWebuiEnabled", + "jsonName": "alternative_webui_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AlternativeWebuiPath", + "jsonName": "alternative_webui_path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UseHttps", + "jsonName": "use_https", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SslKey", + "jsonName": "ssl_key", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SslCert", + "jsonName": "ssl_cert", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DyndnsEnabled", + "jsonName": "dyndns_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DyndnsService", + "jsonName": "dyndns_service", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DyndnsUsername", + "jsonName": "dyndns_username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DyndnsPassword", + "jsonName": "dyndns_password", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DyndnsDomain", + "jsonName": "dyndns_domain", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RssRefreshInterval", + "jsonName": "rss_refresh_interval", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RssMaxArticlesPerFeed", + "jsonName": "rss_max_articles_per_feed", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RssProcessingEnabled", + "jsonName": "rss_processing_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RssAutoDownloadingEnabled", + "jsonName": "rss_auto_downloading_enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/preferences.go", + "filename": "preferences.go", + "name": "MaxRatioAction", + "formattedName": "MaxRatioAction", + "package": "qbittorrent_model", + "fields": [], + "aliasOf": { + "goType": "int", + "typescriptType": "number", + "declaredValues": [ + "0" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/rule_definition.go", + "filename": "rule_definition.go", + "name": "RuleDefinition", + "formattedName": "RuleDefinition", + "package": "qbittorrent_model", + "fields": [ + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MustContain", + "jsonName": "mustContain", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MustNotContain", + "jsonName": "mustNotContain", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UseRegex", + "jsonName": "useRegex", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeFilter", + "jsonName": "episodeFilter", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SmartFilter", + "jsonName": "smartFilter", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PreviouslyMatchedEpisodes", + "jsonName": "previouslyMatchedEpisodes", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AffectedFeeds", + "jsonName": "affectedFeeds", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IgnoreDays", + "jsonName": "ignoreDays", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastMatch", + "jsonName": "lastMatch", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AddPaused", + "jsonName": "addPaused", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AssignedCategory", + "jsonName": "assignedCategory", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SavePath", + "jsonName": "savePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/search_plugin.go", + "filename": "search_plugin.go", + "name": "SearchPlugin", + "formattedName": "SearchPlugin", + "package": "qbittorrent_model", + "fields": [ + { + "name": "Enabled", + "jsonName": "enabled", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FullName", + "jsonName": "fullName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SupportedCategories", + "jsonName": "supportedCategories", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Version", + "jsonName": "version", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/search_result.go", + "filename": "search_result.go", + "name": "SearchResult", + "formattedName": "SearchResult", + "package": "qbittorrent_model", + "fields": [ + { + "name": "DescriptionLink", + "jsonName": "descrLink", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileName", + "jsonName": "fileName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileSize", + "jsonName": "fileSize", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileUrl", + "jsonName": "fileUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumLeechers", + "jsonName": "nbLeechers", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumSeeders", + "jsonName": "nbSeeders", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SiteUrl", + "jsonName": "siteUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/search_results_paging.go", + "filename": "search_results_paging.go", + "name": "SearchResultsPaging", + "formattedName": "SearchResultsPaging", + "package": "qbittorrent_model", + "fields": [ + { + "name": "Results", + "jsonName": "results", + "goType": "[]SearchResult", + "typescriptType": "Array\u003cSearchResult\u003e", + "usedTypescriptType": "SearchResult", + "usedStructName": "qbittorrent_model.SearchResult", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Total", + "jsonName": "total", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/search_status.go", + "filename": "search_status.go", + "name": "SearchStatus", + "formattedName": "SearchStatus", + "package": "qbittorrent_model", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Total", + "jsonName": "total", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/server_state.go", + "filename": "server_state.go", + "name": "ServerState", + "formattedName": "ServerState", + "package": "qbittorrent_model", + "fields": [ + { + "name": "AlltimeDl", + "jsonName": "alltime_dl", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AlltimeUl", + "jsonName": "alltime_ul", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AverageTimeQueue", + "jsonName": "average_time_queue", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FreeSpaceOnDisk", + "jsonName": "free_space_on_disk", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "GlobalRatio", + "jsonName": "global_ratio", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "QueuedIoJobs", + "jsonName": "queued_io_jobs", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReadCacheHits", + "jsonName": "read_cache_hits", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReadCacheOverload", + "jsonName": "read_cache_overload", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalBuffersSize", + "jsonName": "total_buffers_size", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalPeerConnections", + "jsonName": "total_peer_connections", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalQueuedSize", + "jsonName": "total_queued_size", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalWastedSession", + "jsonName": "total_wasted_session", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WriteCacheOverload", + "jsonName": "write_cache_overload", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "qbittorrent_model.TransferInfo" + ] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/sync_main_data.go", + "filename": "sync_main_data.go", + "name": "SyncMainData", + "formattedName": "SyncMainData", + "package": "qbittorrent_model", + "fields": [ + { + "name": "RID", + "jsonName": "rid", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FullUpdate", + "jsonName": "full_update", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Torrents", + "jsonName": "torrents", + "goType": "map[string]Torrent", + "typescriptType": "Record\u003cstring, Torrent\u003e", + "usedTypescriptType": "Torrent", + "usedStructName": "qbittorrent_model.Torrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentsRemoved", + "jsonName": "torrents_removed", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Categories", + "jsonName": "categories", + "goType": "map[string]Category", + "typescriptType": "Record\u003cstring, Category\u003e", + "usedTypescriptType": "Category", + "usedStructName": "qbittorrent_model.Category", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CategoriesRemoved", + "jsonName": "categories_removed", + "goType": "map[string]Category", + "typescriptType": "Record\u003cstring, Category\u003e", + "usedTypescriptType": "Category", + "usedStructName": "qbittorrent_model.Category", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Queueing", + "jsonName": "queueing", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ServerState", + "jsonName": "server_state", + "goType": "ServerState", + "typescriptType": "ServerState", + "usedTypescriptType": "ServerState", + "usedStructName": "qbittorrent_model.ServerState", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/sync_peers_data.go", + "filename": "sync_peers_data.go", + "name": "SyncPeersData", + "formattedName": "SyncPeersData", + "package": "qbittorrent_model", + "fields": [ + { + "name": "FullUpdate", + "jsonName": "full_update", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Peers", + "jsonName": "peers", + "goType": "map[string]Peer", + "typescriptType": "Record\u003cstring, Peer\u003e", + "usedTypescriptType": "Peer", + "usedStructName": "qbittorrent_model.Peer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "RID", + "jsonName": "rid", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ShowFlags", + "jsonName": "show_flags", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/torrent.go", + "filename": "torrent.go", + "name": "Torrent", + "formattedName": "Torrent", + "package": "qbittorrent_model", + "fields": [ + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Dlspeed", + "jsonName": "dlspeed", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Upspeed", + "jsonName": "upspeed", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Priority", + "jsonName": "priority", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumSeeds", + "jsonName": "num_seeds", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumComplete", + "jsonName": "num_complete", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumLeechs", + "jsonName": "num_leechs", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumIncomplete", + "jsonName": "num_incomplete", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Ratio", + "jsonName": "ratio", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Eta", + "jsonName": "eta", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "state", + "goType": "TorrentState", + "typescriptType": "TorrentState", + "usedTypescriptType": "TorrentState", + "usedStructName": "qbittorrent_model.TorrentState", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SeqDl", + "jsonName": "seq_dl", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FLPiecePrio", + "jsonName": "f_l_piece_prio", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Category", + "jsonName": "category", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SuperSeeding", + "jsonName": "super_seeding", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ForceStart", + "jsonName": "force_start", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AddedOn", + "jsonName": "added_on", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AmountLeft", + "jsonName": "amount_left", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AutoTmm", + "jsonName": "auto_tmm", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Availability", + "jsonName": "availability", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Completed", + "jsonName": "completed", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CompletionOn", + "jsonName": "completion_on", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ContentPath", + "jsonName": "content_path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DlLimit", + "jsonName": "dl_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadPath", + "jsonName": "download_path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Downloaded", + "jsonName": "downloaded", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadedSession", + "jsonName": "downloaded_session", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "InfohashV1", + "jsonName": "infohash_v1", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "InfohashV2", + "jsonName": "infohash_v2", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "LastActivity", + "jsonName": "last_activity", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MagnetUri", + "jsonName": "magnet_uri", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxRatio", + "jsonName": "max_ratio", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MaxSeedingTime", + "jsonName": "max_seeding_time", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RatioLimit", + "jsonName": "ratio_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SavePath", + "jsonName": "save_path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SeedingTime", + "jsonName": "seeding_time", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SeedingTimeLimit", + "jsonName": "seeding_time_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SeenComplete", + "jsonName": "seen_complete", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Tags", + "jsonName": "tags", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeActive", + "jsonName": "time_active", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalSize", + "jsonName": "total_size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Tracker", + "jsonName": "tracker", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TrackersCount", + "jsonName": "trackers_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpLimit", + "jsonName": "up_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Uploaded", + "jsonName": "uploaded", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UploadedSession", + "jsonName": "uploaded_session", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/torrent.go", + "filename": "torrent.go", + "name": "TorrentState", + "formattedName": "TorrentState", + "package": "qbittorrent_model", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"error\"", + "\"missingFiles\"", + "\"uploading\"", + "\"pausedUP\"", + "\"stoppedUP\"", + "\"queuedUP\"", + "\"stalledUP\"", + "\"checkingUP\"", + "\"forcedUP\"", + "\"allocating\"", + "\"downloading\"", + "\"metaDL\"", + "\"pausedDL\"", + "\"stoppedDL\"", + "\"queuedDL\"", + "\"stalledDL\"", + "\"checkingDL\"", + "\"forceDL\"", + "\"checkingResumeData\"", + "\"moving\"", + "\"unknown\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/torrent_content.go", + "filename": "torrent_content.go", + "name": "TorrentContent", + "formattedName": "TorrentContent", + "package": "qbittorrent_model", + "fields": [ + { + "name": "Name", + "jsonName": "\tname", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "\tsize", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "\tprogress", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Priority", + "jsonName": "\tpriority", + "goType": "TorrentPriority", + "typescriptType": "TorrentPriority", + "usedTypescriptType": "TorrentPriority", + "usedStructName": "qbittorrent_model.TorrentPriority", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsSeed", + "jsonName": "\tis_seed", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PieceRange", + "jsonName": "\tpiece_range", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Availability", + "jsonName": "\tavailability", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/torrent_content.go", + "filename": "torrent_content.go", + "name": "TorrentPriority", + "formattedName": "TorrentPriority", + "package": "qbittorrent_model", + "fields": [], + "aliasOf": { + "goType": "int", + "typescriptType": "number", + "declaredValues": [ + "0" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/torrent_piece_state.go", + "filename": "torrent_piece_state.go", + "name": "TorrentPieceState", + "formattedName": "TorrentPieceState", + "package": "qbittorrent_model", + "fields": [], + "aliasOf": { + "goType": "int", + "typescriptType": "number", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/torrent_properties.go", + "filename": "torrent_properties.go", + "name": "TorrentProperties", + "formattedName": "TorrentProperties", + "package": "qbittorrent_model", + "fields": [ + { + "name": "SavePath", + "jsonName": "save_path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CreationDate", + "jsonName": "creation_date", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PieceSize", + "jsonName": "piece_size", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Comment", + "jsonName": "comment", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalWasted", + "jsonName": "total_wasted", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalUploaded", + "jsonName": "total_uploaded", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalUploadedSession", + "jsonName": "total_uploaded_session", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalDownloaded", + "jsonName": "total_downloaded", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalDownloadedSession", + "jsonName": "total_downloaded_session", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpLimit", + "jsonName": "up_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DlLimit", + "jsonName": "dl_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TimeElapsed", + "jsonName": "time_elapsed", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SeedingTime", + "jsonName": "seeding_time", + "goType": "time.Duration", + "typescriptType": "Duration", + "usedTypescriptType": "Duration", + "usedStructName": "time.Duration", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NbConnections", + "jsonName": "nb_connections", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NbConnectionsLimit", + "jsonName": "nb_connections_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ShareRatio", + "jsonName": "share_ratio", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AdditionDate", + "jsonName": "addition_date", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompletionDate", + "jsonName": "completion_date", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CreatedBy", + "jsonName": "created_by", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DlSpeedAvg", + "jsonName": "dl_speed_avg", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DlSpeed", + "jsonName": "dl_speed", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Eta", + "jsonName": "eta", + "goType": "time.Duration", + "typescriptType": "Duration", + "usedTypescriptType": "Duration", + "usedStructName": "time.Duration", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LastSeen", + "jsonName": "last_seen", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Peers", + "jsonName": "peers", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PeersTotal", + "jsonName": "peers_total", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PiecesHave", + "jsonName": "pieces_have", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PiecesNum", + "jsonName": "pieces_num", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Reannounce", + "jsonName": "reannounce", + "goType": "time.Duration", + "typescriptType": "Duration", + "usedTypescriptType": "Duration", + "usedStructName": "time.Duration", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Seeds", + "jsonName": "seeds", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SeedsTotal", + "jsonName": "seeds_total", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TotalSize", + "jsonName": "total_size", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpSpeedAvg", + "jsonName": "up_speed_avg", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpSpeed", + "jsonName": "up_speed", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/torrent_tracker.go", + "filename": "torrent_tracker.go", + "name": "TorrentTracker", + "formattedName": "TorrentTracker", + "package": "qbittorrent_model", + "fields": [ + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "TrackerStatus", + "typescriptType": "TrackerStatus", + "usedTypescriptType": "TrackerStatus", + "usedStructName": "qbittorrent_model.TrackerStatus", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Tier", + "jsonName": "tier", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumPeers", + "jsonName": "num_peers", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumSeeds", + "jsonName": "num_seeds", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumLeeches", + "jsonName": "num_leeches", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumDownloaded", + "jsonName": "num_downloaded", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Message", + "jsonName": "msg", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/torrent_tracker.go", + "filename": "torrent_tracker.go", + "name": "TrackerStatus", + "formattedName": "TrackerStatus", + "package": "qbittorrent_model", + "fields": [], + "aliasOf": { + "goType": "int", + "typescriptType": "number", + "declaredValues": [] + }, + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/transfer_info.go", + "filename": "transfer_info.go", + "name": "TransferInfo", + "formattedName": "TransferInfo", + "package": "qbittorrent_model", + "fields": [ + { + "name": "ConnectionStatus", + "jsonName": "connection_status", + "goType": "ConnectionStatus", + "typescriptType": "ConnectionStatus", + "usedTypescriptType": "ConnectionStatus", + "usedStructName": "qbittorrent_model.ConnectionStatus", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DhtNodes", + "jsonName": "dht_nodes", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DlInfoData", + "jsonName": "dl_info_data", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DlInfoSpeed", + "jsonName": "dl_info_speed", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DlRateLimit", + "jsonName": "dl_rate_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpInfoData", + "jsonName": "up_info_data", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpInfoSpeed", + "jsonName": "up_info_speed", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpRateLimit", + "jsonName": "up_rate_limit", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UseAltSpeedLimits", + "jsonName": "use_alt_speed_limits", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Queueing", + "jsonName": "queueing", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RefreshInterval", + "jsonName": "refresh_interval", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/model/transfer_info.go", + "filename": "transfer_info.go", + "name": "ConnectionStatus", + "formattedName": "ConnectionStatus", + "package": "qbittorrent_model", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"connected\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/rss/client.go", + "filename": "client.go", + "name": "Client", + "formattedName": "Client", + "package": "qbittorrent_rss", + "fields": [ + { + "name": "BaseUrl", + "jsonName": "BaseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/search/client.go", + "filename": "client.go", + "name": "Client", + "formattedName": "Client", + "package": "qbittorrent_search", + "fields": [ + { + "name": "BaseUrl", + "jsonName": "BaseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/sync/client.go", + "filename": "client.go", + "name": "Client", + "formattedName": "Client", + "package": "qbittorrent_sync", + "fields": [ + { + "name": "BaseUrl", + "jsonName": "BaseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/torrent/client.go", + "filename": "client.go", + "name": "Client", + "formattedName": "Client", + "package": "qbittorrent_torrent", + "fields": [ + { + "name": "BaseUrl", + "jsonName": "BaseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/qbittorrent/transfer/client.go", + "filename": "client.go", + "name": "Client", + "formattedName": "Client", + "package": "qbittorrent_transfer", + "fields": [ + { + "name": "BaseUrl", + "jsonName": "BaseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Client", + "jsonName": "Client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/torrent_client/repository.go", + "filename": "repository.go", + "name": "Repository", + "formattedName": "TorrentClient_Repository", + "package": "torrent_client", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "qBittorrentClient", + "jsonName": "qBittorrentClient", + "goType": "qbittorrent.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "qbittorrent.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "transmission", + "jsonName": "transmission", + "goType": "transmission.Transmission", + "typescriptType": "Transmission", + "usedTypescriptType": "Transmission", + "usedStructName": "transmission.Transmission", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "torrentRepository", + "jsonName": "torrentRepository", + "goType": "torrent.Repository", + "typescriptType": "Torrent_Repository", + "usedTypescriptType": "Torrent_Repository", + "usedStructName": "torrent.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "provider", + "jsonName": "provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "activeTorrentCountCtxCancel", + "jsonName": "activeTorrentCountCtxCancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "activeTorrentCount", + "jsonName": "activeTorrentCount", + "goType": "ActiveCount", + "typescriptType": "TorrentClient_ActiveCount", + "usedTypescriptType": "TorrentClient_ActiveCount", + "usedStructName": "torrent_client.ActiveCount", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/torrent_client/repository.go", + "filename": "repository.go", + "name": "NewRepositoryOptions", + "formattedName": "TorrentClient_NewRepositoryOptions", + "package": "torrent_client", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "QbittorrentClient", + "jsonName": "QbittorrentClient", + "goType": "qbittorrent.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "qbittorrent.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Transmission", + "jsonName": "Transmission", + "goType": "transmission.Transmission", + "typescriptType": "Transmission", + "usedTypescriptType": "Transmission", + "usedStructName": "transmission.Transmission", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentRepository", + "jsonName": "TorrentRepository", + "goType": "torrent.Repository", + "typescriptType": "Torrent_Repository", + "usedTypescriptType": "Torrent_Repository", + "usedStructName": "torrent.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Provider", + "jsonName": "Provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/torrent_client/repository.go", + "filename": "repository.go", + "name": "ActiveCount", + "formattedName": "TorrentClient_ActiveCount", + "package": "torrent_client", + "fields": [ + { + "name": "Downloading", + "jsonName": "downloading", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Seeding", + "jsonName": "seeding", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Paused", + "jsonName": "paused", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/torrent_client/smart_select.go", + "filename": "smart_select.go", + "name": "SmartSelectParams", + "formattedName": "TorrentClient_SmartSelectParams", + "package": "torrent_client", + "fields": [ + { + "name": "Torrent", + "jsonName": "Torrent", + "goType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumbers", + "jsonName": "EpisodeNumbers", + "goType": "[]int", + "typescriptType": "Array\u003cnumber\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.CompleteAnime", + "typescriptType": "AL_CompleteAnime", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Destination", + "jsonName": "Destination", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ShouldAddTorrent", + "jsonName": "ShouldAddTorrent", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/torrent_client/torrent.go", + "filename": "torrent.go", + "name": "Torrent", + "formattedName": "TorrentClient_Torrent", + "package": "torrent_client", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Hash", + "jsonName": "hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Seeds", + "jsonName": "seeds", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UpSpeed", + "jsonName": "upSpeed", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownSpeed", + "jsonName": "downSpeed", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Eta", + "jsonName": "eta", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "TorrentStatus", + "typescriptType": "TorrentClient_TorrentStatus", + "usedTypescriptType": "TorrentClient_TorrentStatus", + "usedStructName": "torrent_client.TorrentStatus", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ContentPath", + "jsonName": "contentPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/torrent_client/torrent.go", + "filename": "torrent.go", + "name": "TorrentStatus", + "formattedName": "TorrentClient_TorrentStatus", + "package": "torrent_client", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"downloading\"", + "\"seeding\"", + "\"paused\"", + "\"other\"", + "\"stopped\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/transmission/transmission.go", + "filename": "transmission.go", + "name": "Transmission", + "formattedName": "Transmission", + "package": "transmission", + "fields": [ + { + "name": "Client", + "jsonName": "Client", + "goType": "transmissionrpc.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "transmissionrpc.Client", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Path", + "jsonName": "Path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrent_clients/transmission/transmission.go", + "filename": "transmission.go", + "name": "NewTransmissionOptions", + "formattedName": "NewTransmissionOptions", + "package": "transmission", + "fields": [ + { + "name": "Path", + "jsonName": "Path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Username", + "jsonName": "Username", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Password", + "jsonName": "Password", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Host", + "jsonName": "Host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Default: 127.0.0.1" + ] + }, + { + "name": "Port", + "jsonName": "Port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/analyzer/analyzer.go", + "filename": "analyzer.go", + "name": "Analyzer", + "formattedName": "Analyzer", + "package": "torrent_analyzer", + "fields": [ + { + "name": "files", + "jsonName": "files", + "goType": "[]File", + "typescriptType": "Array\u003cFile\u003e", + "usedTypescriptType": "File", + "usedStructName": "torrent_analyzer.File", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "media", + "jsonName": "media", + "goType": "anilist.CompleteAnime", + "typescriptType": "AL_CompleteAnime", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "platform", + "jsonName": "platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "forceMatch", + "jsonName": "forceMatch", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/analyzer/analyzer.go", + "filename": "analyzer.go", + "name": "Analysis", + "formattedName": "Analysis", + "package": "torrent_analyzer", + "fields": [ + { + "name": "files", + "jsonName": "files", + "goType": "[]File", + "typescriptType": "Array\u003cFile\u003e", + "usedTypescriptType": "File", + "usedStructName": "torrent_analyzer.File", + "required": false, + "public": false, + "comments": [ + " Hydrated after scanFiles is called" + ] + }, + { + "name": "selectedFiles", + "jsonName": "selectedFiles", + "goType": "[]File", + "typescriptType": "Array\u003cFile\u003e", + "usedTypescriptType": "File", + "usedStructName": "torrent_analyzer.File", + "required": false, + "public": false, + "comments": [ + " Hydrated after findCorrespondingFiles is called" + ] + }, + { + "name": "media", + "jsonName": "media", + "goType": "anilist.CompleteAnime", + "typescriptType": "AL_CompleteAnime", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/analyzer/analyzer.go", + "filename": "analyzer.go", + "name": "File", + "formattedName": "File", + "package": "torrent_analyzer", + "fields": [ + { + "name": "index", + "jsonName": "index", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "localFile", + "jsonName": "localFile", + "goType": "anime.LocalFile", + "typescriptType": "Anime_LocalFile", + "usedTypescriptType": "Anime_LocalFile", + "usedStructName": "anime.LocalFile", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/analyzer/analyzer.go", + "filename": "analyzer.go", + "name": "NewAnalyzerOptions", + "formattedName": "NewAnalyzerOptions", + "package": "torrent_analyzer", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Filepaths", + "jsonName": "Filepaths", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " Filepath of the torrent files" + ] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.CompleteAnime", + "typescriptType": "AL_CompleteAnime", + "usedTypescriptType": "AL_CompleteAnime", + "usedStructName": "anilist.CompleteAnime", + "required": false, + "public": true, + "comments": [ + " The media to compare the files with" + ] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ForceMatch", + "jsonName": "ForceMatch", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/animetosho/animetosho.go", + "filename": "animetosho.go", + "name": "Torrent", + "formattedName": "AnimeTosho_Torrent", + "package": "animetosho", + "fields": [ + { + "name": "Id", + "jsonName": "id", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Link", + "jsonName": "link", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Timestamp", + "jsonName": "timestamp", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Status", + "jsonName": "status", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ToshoId", + "jsonName": "tosho_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NyaaId", + "jsonName": "nyaa_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NyaaSubdom", + "jsonName": "nyaa_subdom", + "goType": "", + "typescriptType": "any", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AniDexId", + "jsonName": "anidex_id", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentUrl", + "jsonName": "torrent_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "InfoHash", + "jsonName": "info_hash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "InfoHashV2", + "jsonName": "info_hash_v2", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MagnetUri", + "jsonName": "magnet_uri", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Seeders", + "jsonName": "seeders", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Leechers", + "jsonName": "leechers", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TorrentDownloadCount", + "jsonName": "torrent_download_count", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TrackerUpdated", + "jsonName": "tracker_updated", + "goType": "", + "typescriptType": "any", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NzbUrl", + "jsonName": "nzb_url", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TotalSize", + "jsonName": "total_size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NumFiles", + "jsonName": "num_files", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AniDbAid", + "jsonName": "anidb_aid", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AniDbEid", + "jsonName": "anidb_eid", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AniDbFid", + "jsonName": "anidb_fid", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ArticleUrl", + "jsonName": "article_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ArticleTitle", + "jsonName": "article_title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WebsiteUrl", + "jsonName": "website_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/animetosho/provider.go", + "filename": "provider.go", + "name": "Provider", + "formattedName": "AnimeTosho_Provider", + "package": "animetosho", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "sneedexNyaaIDs", + "jsonName": "sneedexNyaaIDs", + "goType": "map[int]__STRUCT__", + "inlineStructType": "map[int]struct{\n}", + "typescriptType": "Record\u003cnumber, { }\u003e", + "usedTypescriptType": "{ }", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/nyaa/nyaa.go", + "filename": "nyaa.go", + "name": "Torrent", + "formattedName": "Torrent", + "package": "nyaa", + "fields": [ + { + "name": "Category", + "jsonName": "category", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Date", + "jsonName": "date", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Seeders", + "jsonName": "seeders", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Leechers", + "jsonName": "leechers", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Downloads", + "jsonName": "downloads", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsTrusted", + "jsonName": "isTrusted", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsRemake", + "jsonName": "isRemake", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Comments", + "jsonName": "comments", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Link", + "jsonName": "link", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "GUID", + "jsonName": "guid", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CategoryID", + "jsonName": "categoryID", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "InfoHash", + "jsonName": "infoHash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/nyaa/nyaa.go", + "filename": "nyaa.go", + "name": "BuildURLOptions", + "formattedName": "BuildURLOptions", + "package": "nyaa", + "fields": [ + { + "name": "Provider", + "jsonName": "Provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Query", + "jsonName": "Query", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Category", + "jsonName": "Category", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "SortBy", + "jsonName": "SortBy", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Filter", + "jsonName": "Filter", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/nyaa/nyaa.go", + "filename": "nyaa.go", + "name": "Comment", + "formattedName": "Comment", + "package": "nyaa", + "fields": [ + { + "name": "User", + "jsonName": "user", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Date", + "jsonName": "date", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Text", + "jsonName": "text", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/nyaa/provider.go", + "filename": "provider.go", + "name": "Provider", + "formattedName": "Provider", + "package": "nyaa", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "category", + "jsonName": "category", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "baseUrl", + "jsonName": "baseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/nyaa/sukebei_provider.go", + "filename": "sukebei_provider.go", + "name": "SukebeiProvider", + "formattedName": "SukebeiProvider", + "package": "nyaa", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "baseUrl", + "jsonName": "baseUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/seadex/provider.go", + "filename": "provider.go", + "name": "Provider", + "formattedName": "Provider", + "package": "seadex", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "seadex", + "jsonName": "seadex", + "goType": "SeaDex", + "typescriptType": "SeaDex", + "usedTypescriptType": "SeaDex", + "usedStructName": "seadex.SeaDex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/seadex/seadex.go", + "filename": "seadex.go", + "name": "SeaDex", + "formattedName": "SeaDex", + "package": "seadex", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "uri", + "jsonName": "uri", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/seadex/seadex.go", + "filename": "seadex.go", + "name": "Torrent", + "formattedName": "Torrent", + "package": "seadex", + "fields": [ + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Date", + "jsonName": "date", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Link", + "jsonName": "link", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "InfoHash", + "jsonName": "infoHash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReleaseGroup", + "jsonName": "releaseGroup", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/seadex/types.go", + "filename": "types.go", + "name": "RecordsResponse", + "formattedName": "RecordsResponse", + "package": "seadex", + "fields": [ + { + "name": "Items", + "jsonName": "items", + "goType": "[]RecordItem", + "typescriptType": "Array\u003cRecordItem\u003e", + "usedTypescriptType": "RecordItem", + "usedStructName": "seadex.RecordItem", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/seadex/types.go", + "filename": "types.go", + "name": "RecordItem", + "formattedName": "RecordItem", + "package": "seadex", + "fields": [ + { + "name": "AlID", + "jsonName": "alID", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CollectionID", + "jsonName": "collectionId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CollectionName", + "jsonName": "collectionName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Comparison", + "jsonName": "comparison", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Created", + "jsonName": "created", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Expand", + "jsonName": "expand", + "goType": "RecordItem_Expand", + "inlineStructType": "struct{\nTrs []Tr `json:\"trs\"`}", + "typescriptType": "RecordItem_Expand", + "usedTypescriptType": "{ trs: Array\u003cTr\u003e; }", + "usedStructName": "seadex.RecordItem_Expand", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Trs", + "jsonName": "trs", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Updated", + "jsonName": "updated", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Incomplete", + "jsonName": "incomplete", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Notes", + "jsonName": "notes", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TheoreticalBest", + "jsonName": "theoreticalBest", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/seadex/types.go", + "filename": "types.go", + "name": "RecordItem_Expand", + "formattedName": "RecordItem_Expand", + "package": "seadex", + "fields": [ + { + "name": "Trs", + "jsonName": "trs", + "goType": "[]Tr", + "typescriptType": "Array\u003cTr\u003e", + "usedTypescriptType": "Tr", + "usedStructName": "seadex.Tr", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/seadex/types.go", + "filename": "types.go", + "name": "Tr", + "formattedName": "Tr", + "package": "seadex", + "fields": [ + { + "name": "Created", + "jsonName": "created", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CollectionID", + "jsonName": "collectionId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CollectionName", + "jsonName": "collectionName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DualAudio", + "jsonName": "dualAudio", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Files", + "jsonName": "files", + "goType": "[]TrFile", + "typescriptType": "Array\u003cTrFile\u003e", + "usedTypescriptType": "TrFile", + "usedStructName": "seadex.TrFile", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "InfoHash", + "jsonName": "infoHash", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsBest", + "jsonName": "isBest", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ReleaseGroup", + "jsonName": "releaseGroup", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Tracker", + "jsonName": "tracker", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/seadex/types.go", + "filename": "types.go", + "name": "TrFile", + "formattedName": "TrFile", + "package": "seadex", + "fields": [ + { + "name": "Length", + "jsonName": "length", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/torrent/repository.go", + "filename": "repository.go", + "name": "Repository", + "formattedName": "Torrent_Repository", + "package": "torrent", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "extensionBank", + "jsonName": "extensionBank", + "goType": "extension.UnifiedBank", + "typescriptType": "Extension_UnifiedBank", + "usedTypescriptType": "Extension_UnifiedBank", + "usedStructName": "extension.UnifiedBank", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeProviderSearchCaches", + "jsonName": "animeProviderSearchCaches", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "animeProviderSmartSearchCaches", + "jsonName": "animeProviderSmartSearchCaches", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "RepositorySettings", + "typescriptType": "Torrent_RepositorySettings", + "usedTypescriptType": "Torrent_RepositorySettings", + "usedStructName": "torrent.RepositorySettings", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/torrent/repository.go", + "filename": "repository.go", + "name": "RepositorySettings", + "formattedName": "Torrent_RepositorySettings", + "package": "torrent", + "fields": [ + { + "name": "DefaultAnimeProvider", + "jsonName": "DefaultAnimeProvider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Default torrent provider" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/torrent/repository.go", + "filename": "repository.go", + "name": "NewRepositoryOptions", + "formattedName": "Torrent_NewRepositoryOptions", + "package": "torrent", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/torrent/search.go", + "filename": "search.go", + "name": "AnimeSearchType", + "formattedName": "Torrent_AnimeSearchType", + "package": "torrent", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"smart\"", + "\"simple\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/torrents/torrent/search.go", + "filename": "search.go", + "name": "AnimeSearchOptions", + "formattedName": "Torrent_AnimeSearchOptions", + "package": "torrent", + "fields": [ + { + "name": "Provider", + "jsonName": "Provider", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "Type", + "goType": "AnimeSearchType", + "typescriptType": "Torrent_AnimeSearchType", + "usedTypescriptType": "Torrent_AnimeSearchType", + "usedStructName": "torrent.AnimeSearchType", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Query", + "jsonName": "Query", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Batch", + "jsonName": "Batch", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "EpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BestReleases", + "jsonName": "BestReleases", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Resolution", + "jsonName": "Resolution", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/torrent/search.go", + "filename": "search.go", + "name": "Preview", + "formattedName": "Torrent_Preview", + "package": "torrent", + "fields": [ + { + "name": "Episode", + "jsonName": "episode", + "goType": "anime.Episode", + "typescriptType": "Anime_Episode", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [ + " nil if batch" + ] + }, + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/torrent/search.go", + "filename": "search.go", + "name": "TorrentMetadata", + "formattedName": "Torrent_TorrentMetadata", + "package": "torrent", + "fields": [ + { + "name": "Distance", + "jsonName": "distance", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Metadata", + "jsonName": "metadata", + "goType": "habari.Metadata", + "typescriptType": "Habari_Metadata", + "usedTypescriptType": "Habari_Metadata", + "usedStructName": "habari.Metadata", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrents/torrent/search.go", + "filename": "search.go", + "name": "SearchData", + "formattedName": "Torrent_SearchData", + "package": "torrent", + "fields": [ + { + "name": "Torrents", + "jsonName": "torrents", + "goType": "[]hibiketorrent.AnimeTorrent", + "typescriptType": "Array\u003cHibikeTorrent_AnimeTorrent\u003e", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [ + " Torrents found" + ] + }, + { + "name": "Previews", + "jsonName": "previews", + "goType": "[]Preview", + "typescriptType": "Array\u003cTorrent_Preview\u003e", + "usedTypescriptType": "Torrent_Preview", + "usedStructName": "torrent.Preview", + "required": false, + "public": true, + "comments": [ + " TorrentPreview for each torrent" + ] + }, + { + "name": "TorrentMetadata", + "jsonName": "torrentMetadata", + "goType": "map[string]TorrentMetadata", + "typescriptType": "Record\u003cstring, Torrent_TorrentMetadata\u003e", + "usedTypescriptType": "Torrent_TorrentMetadata", + "usedStructName": "torrent.TorrentMetadata", + "required": false, + "public": true, + "comments": [ + " Torrent metadata" + ] + }, + { + "name": "DebridInstantAvailability", + "jsonName": "debridInstantAvailability", + "goType": "map[string]debrid.TorrentItemInstantAvailability", + "typescriptType": "Record\u003cstring, Debrid_TorrentItemInstantAvailability\u003e", + "usedTypescriptType": "Debrid_TorrentItemInstantAvailability", + "usedStructName": "debrid.TorrentItemInstantAvailability", + "required": false, + "public": true, + "comments": [ + " Debrid instant availability" + ] + }, + { + "name": "AnimeMetadata", + "jsonName": "animeMetadata", + "goType": "metadata.AnimeMetadata", + "typescriptType": "Metadata_AnimeMetadata", + "usedTypescriptType": "Metadata_AnimeMetadata", + "usedStructName": "metadata.AnimeMetadata", + "required": false, + "public": true, + "comments": [ + " Animap media" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/client.go", + "filename": "client.go", + "name": "Client", + "formattedName": "Torrentstream_Client", + "package": "torrentstream", + "fields": [ + { + "name": "repository", + "jsonName": "repository", + "goType": "Repository", + "typescriptType": "Torrentstream_Repository", + "usedTypescriptType": "Torrentstream_Repository", + "usedStructName": "torrentstream.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "torrentClient", + "jsonName": "torrentClient", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "currentTorrent", + "jsonName": "currentTorrent", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "currentFile", + "jsonName": "currentFile", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "currentTorrentStatus", + "jsonName": "currentTorrentStatus", + "goType": "TorrentStatus", + "typescriptType": "Torrentstream_TorrentStatus", + "usedTypescriptType": "Torrentstream_TorrentStatus", + "usedStructName": "torrentstream.TorrentStatus", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "cancelFunc", + "jsonName": "cancelFunc", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "stopCh", + "jsonName": "stopCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Closed when the media player stops" + ] + }, + { + "name": "mediaPlayerPlaybackStatusCh", + "jsonName": "mediaPlayerPlaybackStatusCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Continuously receives playback status" + ] + }, + { + "name": "timeSinceLoggedSeeding", + "jsonName": "timeSinceLoggedSeeding", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "lastSpeedCheck", + "jsonName": "lastSpeedCheck", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [ + " Track the last time we checked speeds" + ] + }, + { + "name": "lastBytesCompleted", + "jsonName": "lastBytesCompleted", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Track the last bytes completed" + ] + }, + { + "name": "lastBytesWrittenData", + "jsonName": "lastBytesWrittenData", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Track the last bytes written data" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/client.go", + "filename": "client.go", + "name": "TorrentStatus", + "formattedName": "Torrentstream_TorrentStatus", + "package": "torrentstream", + "fields": [ + { + "name": "UploadProgress", + "jsonName": "uploadProgress", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadProgress", + "jsonName": "downloadProgress", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ProgressPercentage", + "jsonName": "progressPercentage", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DownloadSpeed", + "jsonName": "downloadSpeed", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UploadSpeed", + "jsonName": "uploadSpeed", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Seeders", + "jsonName": "seeders", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/client.go", + "filename": "client.go", + "name": "NewClientOptions", + "formattedName": "Torrentstream_NewClientOptions", + "package": "torrentstream", + "fields": [ + { + "name": "Repository", + "jsonName": "Repository", + "goType": "Repository", + "typescriptType": "Torrentstream_Repository", + "usedTypescriptType": "Torrentstream_Repository", + "usedStructName": "torrentstream.Repository", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/collection.go", + "filename": "collection.go", + "name": "StreamCollection", + "formattedName": "Torrentstream_StreamCollection", + "package": "torrentstream", + "fields": [ + { + "name": "ContinueWatchingList", + "jsonName": "continueWatchingList", + "goType": "[]anime.Episode", + "typescriptType": "Array\u003cAnime_Episode\u003e", + "usedTypescriptType": "Anime_Episode", + "usedStructName": "anime.Episode", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Anime", + "jsonName": "anime", + "goType": "[]anilist.BaseAnime", + "typescriptType": "Array\u003cAL_BaseAnime\u003e", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ListData", + "jsonName": "listData", + "goType": "map[int]anime.EntryListData", + "typescriptType": "Record\u003cnumber, Anime_EntryListData\u003e", + "usedTypescriptType": "Anime_EntryListData", + "usedStructName": "anime.EntryListData", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/collection.go", + "filename": "collection.go", + "name": "HydrateStreamCollectionOptions", + "formattedName": "Torrentstream_HydrateStreamCollectionOptions", + "package": "torrentstream", + "fields": [ + { + "name": "AnimeCollection", + "jsonName": "AnimeCollection", + "goType": "anilist.AnimeCollection", + "typescriptType": "AL_AnimeCollection", + "usedTypescriptType": "AL_AnimeCollection", + "usedStructName": "anilist.AnimeCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "LibraryCollection", + "jsonName": "LibraryCollection", + "goType": "anime.LibraryCollection", + "typescriptType": "Anime_LibraryCollection", + "usedTypescriptType": "Anime_LibraryCollection", + "usedStructName": "anime.LibraryCollection", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/events.go", + "filename": "events.go", + "name": "TorrentLoadingStatusState", + "formattedName": "Torrentstream_TorrentLoadingStatusState", + "package": "torrentstream", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"LOADING\"", + "\"SEARCHING_TORRENTS\"", + "\"CHECKING_TORRENT\"", + "\"ADDING_TORRENT\"", + "\"SELECTING_FILE\"", + "\"STARTING_SERVER\"", + "\"SENDING_STREAM_TO_MEDIA_PLAYER\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/torrentstream/events.go", + "filename": "events.go", + "name": "TorrentStreamState", + "formattedName": "Torrentstream_TorrentStreamState", + "package": "torrentstream", + "fields": [ + { + "name": "State", + "jsonName": "state", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/history.go", + "filename": "history.go", + "name": "BatchHistoryResponse", + "formattedName": "Torrentstream_BatchHistoryResponse", + "package": "torrentstream", + "fields": [ + { + "name": "Torrent", + "jsonName": "torrent", + "goType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/hook_events.go", + "filename": "hook_events.go", + "name": "TorrentStreamAutoSelectTorrentsFetchedEvent", + "formattedName": "Torrentstream_TorrentStreamAutoSelectTorrentsFetchedEvent", + "package": "torrentstream", + "fields": [ + { + "name": "Torrents", + "jsonName": "Torrents", + "goType": "[]hibiketorrent.AnimeTorrent", + "typescriptType": "Array\u003cHibikeTorrent_AnimeTorrent\u003e", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " TorrentStreamAutoSelectTorrentsFetchedEvent is triggered when the torrents are fetched for auto select.", + " The torrents are sorted by seeders from highest to lowest.", + " This event is triggered before the top 3 torrents are analyzed." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/torrentstream/hook_events.go", + "filename": "hook_events.go", + "name": "TorrentStreamSendStreamToMediaPlayerEvent", + "formattedName": "Torrentstream_TorrentStreamSendStreamToMediaPlayerEvent", + "package": "torrentstream", + "fields": [ + { + "name": "WindowTitle", + "jsonName": "windowTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "StreamURL", + "jsonName": "streamURL", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AniDbEpisode", + "jsonName": "aniDbEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PlaybackType", + "jsonName": "playbackType", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " TorrentStreamSendStreamToMediaPlayerEvent is triggered when the torrent stream is about to send a stream to the media player.", + " Prevent default to skip the default playback and override the playback." + ], + "embeddedStructNames": [ + "hook_resolver.Event" + ] + }, + { + "filepath": "../internal/torrentstream/previews.go", + "filename": "previews.go", + "name": "FilePreview", + "formattedName": "Torrentstream_FilePreview", + "package": "torrentstream", + "fields": [ + { + "name": "Path", + "jsonName": "path", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisplayPath", + "jsonName": "displayPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "DisplayTitle", + "jsonName": "displayTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RelativeEpisodeNumber", + "jsonName": "relativeEpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsLikely", + "jsonName": "isLikely", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Index", + "jsonName": "index", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/previews.go", + "filename": "previews.go", + "name": "GetTorrentFilePreviewsOptions", + "formattedName": "Torrentstream_GetTorrentFilePreviewsOptions", + "package": "torrentstream", + "fields": [ + { + "name": "Torrent", + "jsonName": "Torrent", + "goType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Magnet", + "jsonName": "Magnet", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "EpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AbsoluteOffset", + "jsonName": "AbsoluteOffset", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Media", + "jsonName": "Media", + "goType": "anilist.BaseAnime", + "typescriptType": "AL_BaseAnime", + "usedTypescriptType": "AL_BaseAnime", + "usedStructName": "anilist.BaseAnime", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/repository.go", + "filename": "repository.go", + "name": "Repository", + "formattedName": "Torrentstream_Repository", + "package": "torrentstream", + "fields": [ + { + "name": "client", + "jsonName": "client", + "goType": "Client", + "typescriptType": "Torrentstream_Client", + "usedTypescriptType": "Torrentstream_Client", + "usedStructName": "torrentstream.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "handler", + "jsonName": "handler", + "goType": "handler", + "typescriptType": "Torrentstream_handler", + "usedTypescriptType": "Torrentstream_handler", + "usedStructName": "torrentstream.handler", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playback", + "jsonName": "playback", + "goType": "playback", + "typescriptType": "Torrentstream_playback", + "usedTypescriptType": "Torrentstream_playback", + "usedStructName": "torrentstream.playback", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "settings", + "jsonName": "settings", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " None by default, set and refreshed by [SetSettings]" + ] + }, + { + "name": "selectionHistoryMap", + "jsonName": "selectionHistoryMap", + "goType": "", + "typescriptType": "any", + "required": false, + "public": false, + "comments": [ + " Key: AniList media ID" + ] + }, + { + "name": "torrentRepository", + "jsonName": "torrentRepository", + "goType": "torrent.Repository", + "typescriptType": "Torrent_Repository", + "usedTypescriptType": "Torrent_Repository", + "usedStructName": "torrent.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "baseAnimeCache", + "jsonName": "baseAnimeCache", + "goType": "anilist.BaseAnimeCache", + "typescriptType": "AL_BaseAnimeCache", + "usedTypescriptType": "AL_BaseAnimeCache", + "usedStructName": "anilist.BaseAnimeCache", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "completeAnimeCache", + "jsonName": "completeAnimeCache", + "goType": "anilist.CompleteAnimeCache", + "typescriptType": "AL_CompleteAnimeCache", + "usedTypescriptType": "AL_CompleteAnimeCache", + "usedStructName": "anilist.CompleteAnimeCache", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "platform", + "jsonName": "platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "metadataProvider", + "jsonName": "metadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "playbackManager", + "jsonName": "playbackManager", + "goType": "playbackmanager.PlaybackManager", + "typescriptType": "PlaybackManager_PlaybackManager", + "usedTypescriptType": "PlaybackManager_PlaybackManager", + "usedStructName": "playbackmanager.PlaybackManager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mediaPlayerRepository", + "jsonName": "mediaPlayerRepository", + "goType": "mediaplayer.Repository", + "typescriptType": "Repository", + "usedTypescriptType": "Repository", + "usedStructName": "mediaplayer.Repository", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mediaPlayerRepositorySubscriber", + "jsonName": "mediaPlayerRepositorySubscriber", + "goType": "mediaplayer.RepositorySubscriber", + "typescriptType": "RepositorySubscriber", + "usedTypescriptType": "RepositorySubscriber", + "usedStructName": "mediaplayer.RepositorySubscriber", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "nativePlayerSubscriber", + "jsonName": "nativePlayerSubscriber", + "goType": "nativeplayer.Subscriber", + "typescriptType": "NativePlayer_Subscriber", + "usedTypescriptType": "NativePlayer_Subscriber", + "usedStructName": "nativeplayer.Subscriber", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "directStreamManager", + "jsonName": "directStreamManager", + "goType": "directstream.Manager", + "typescriptType": "Directstream_Manager", + "usedTypescriptType": "Directstream_Manager", + "usedStructName": "directstream.Manager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "nativePlayer", + "jsonName": "nativePlayer", + "goType": "nativeplayer.NativePlayer", + "typescriptType": "NativePlayer_NativePlayer", + "usedTypescriptType": "NativePlayer_NativePlayer", + "usedStructName": "nativeplayer.NativePlayer", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "db", + "jsonName": "db", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onEpisodeCollectionChanged", + "jsonName": "onEpisodeCollectionChanged", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "previousStreamOptions", + "jsonName": "previousStreamOptions", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/repository.go", + "filename": "repository.go", + "name": "Settings", + "formattedName": "Torrentstream_Settings", + "package": "torrentstream", + "fields": [ + { + "name": "Host", + "jsonName": "Host", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Port", + "jsonName": "Port", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [], + "embeddedStructNames": [ + "models.TorrentstreamSettings" + ] + }, + { + "filepath": "../internal/torrentstream/repository.go", + "filename": "repository.go", + "name": "NewRepositoryOptions", + "formattedName": "Torrentstream_NewRepositoryOptions", + "package": "torrentstream", + "fields": [ + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentRepository", + "jsonName": "TorrentRepository", + "goType": "torrent.Repository", + "typescriptType": "Torrent_Repository", + "usedTypescriptType": "Torrent_Repository", + "usedStructName": "torrent.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "BaseAnimeCache", + "jsonName": "BaseAnimeCache", + "goType": "anilist.BaseAnimeCache", + "typescriptType": "AL_BaseAnimeCache", + "usedTypescriptType": "AL_BaseAnimeCache", + "usedStructName": "anilist.BaseAnimeCache", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CompleteAnimeCache", + "jsonName": "CompleteAnimeCache", + "goType": "anilist.CompleteAnimeCache", + "typescriptType": "AL_CompleteAnimeCache", + "usedTypescriptType": "AL_CompleteAnimeCache", + "usedStructName": "anilist.CompleteAnimeCache", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "platform.Platform", + "typescriptType": "Platform", + "usedTypescriptType": "Platform", + "usedStructName": "platform.Platform", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MetadataProvider", + "jsonName": "MetadataProvider", + "goType": "metadata.Provider", + "typescriptType": "Metadata_Provider", + "usedTypescriptType": "Metadata_Provider", + "usedStructName": "metadata.Provider", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PlaybackManager", + "jsonName": "PlaybackManager", + "goType": "playbackmanager.PlaybackManager", + "typescriptType": "PlaybackManager_PlaybackManager", + "usedTypescriptType": "PlaybackManager_PlaybackManager", + "usedStructName": "playbackmanager.PlaybackManager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "WSEventManager", + "jsonName": "WSEventManager", + "goType": "events.WSEventManagerInterface", + "typescriptType": "Events_WSEventManagerInterface", + "usedTypescriptType": "Events_WSEventManagerInterface", + "usedStructName": "events.WSEventManagerInterface", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Database", + "jsonName": "Database", + "goType": "db.Database", + "typescriptType": "DB_Database", + "usedTypescriptType": "DB_Database", + "usedStructName": "db.Database", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DirectStreamManager", + "jsonName": "DirectStreamManager", + "goType": "directstream.Manager", + "typescriptType": "Directstream_Manager", + "usedTypescriptType": "Directstream_Manager", + "usedStructName": "directstream.Manager", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "NativePlayer", + "jsonName": "NativePlayer", + "goType": "nativeplayer.NativePlayer", + "typescriptType": "NativePlayer_NativePlayer", + "usedTypescriptType": "NativePlayer_NativePlayer", + "usedStructName": "nativeplayer.NativePlayer", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/stream.go", + "filename": "stream.go", + "name": "PlaybackType", + "formattedName": "Torrentstream_PlaybackType", + "package": "torrentstream", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"default\"", + "\"externalPlayerLink\"", + "\"nativeplayer\"", + "\"none\"", + "\"noneAndAwait\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/torrentstream/stream.go", + "filename": "stream.go", + "name": "StartStreamOptions", + "formattedName": "Torrentstream_StartStreamOptions", + "package": "torrentstream", + "fields": [ + { + "name": "MediaId", + "jsonName": "MediaId", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "EpisodeNumber", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [ + " RELATIVE Episode number to identify the file" + ] + }, + { + "name": "AniDBEpisode", + "jsonName": "AniDBEpisode", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Animap episode" + ] + }, + { + "name": "AutoSelect", + "jsonName": "AutoSelect", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Automatically select the best file to stream" + ] + }, + { + "name": "Torrent", + "jsonName": "Torrent", + "goType": "hibiketorrent.AnimeTorrent", + "typescriptType": "HibikeTorrent_AnimeTorrent", + "usedTypescriptType": "HibikeTorrent_AnimeTorrent", + "usedStructName": "hibiketorrent.AnimeTorrent", + "required": false, + "public": true, + "comments": [ + " Selected torrent (Manual selection)" + ] + }, + { + "name": "FileIndex", + "jsonName": "FileIndex", + "goType": "int", + "typescriptType": "number", + "required": false, + "public": true, + "comments": [ + " Index of the file to stream (Manual selection)" + ] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ClientId", + "jsonName": "ClientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PlaybackType", + "jsonName": "PlaybackType", + "goType": "PlaybackType", + "typescriptType": "Torrentstream_PlaybackType", + "usedTypescriptType": "Torrentstream_PlaybackType", + "usedStructName": "torrentstream.PlaybackType", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/torrentstream/stream.go", + "filename": "stream.go", + "name": "StartUntrackedStreamOptions", + "formattedName": "Torrentstream_StartUntrackedStreamOptions", + "package": "torrentstream", + "fields": [ + { + "name": "Magnet", + "jsonName": "Magnet", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "FileIndex", + "jsonName": "FileIndex", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "WindowTitle", + "jsonName": "WindowTitle", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UserAgent", + "jsonName": "UserAgent", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ClientId", + "jsonName": "ClientId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PlaybackType", + "jsonName": "PlaybackType", + "goType": "PlaybackType", + "typescriptType": "Torrentstream_PlaybackType", + "usedTypescriptType": "Torrentstream_PlaybackType", + "usedStructName": "torrentstream.PlaybackType", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/troubleshooter/logs.go", + "filename": "logs.go", + "name": "AnalysisResult", + "formattedName": "AnalysisResult", + "package": "troubleshooter", + "fields": [ + { + "name": "Items", + "jsonName": "items", + "goType": "[]AnalysisResultItem", + "typescriptType": "Array\u003cAnalysisResultItem\u003e", + "usedTypescriptType": "AnalysisResultItem", + "usedStructName": "troubleshooter.AnalysisResultItem", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/troubleshooter/logs.go", + "filename": "logs.go", + "name": "AnalysisResultItem", + "formattedName": "AnalysisResultItem", + "package": "troubleshooter", + "fields": [ + { + "name": "Observation", + "jsonName": "observation", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Recommendation", + "jsonName": "recommendation", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Severity", + "jsonName": "severity", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Errors", + "jsonName": "errors", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Warnings", + "jsonName": "warnings", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logs", + "jsonName": "logs", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/troubleshooter/logs.go", + "filename": "logs.go", + "name": "RuleBuilder", + "formattedName": "RuleBuilder", + "package": "troubleshooter", + "fields": [ + { + "name": "name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "description", + "jsonName": "description", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "conditions", + "jsonName": "conditions", + "goType": "[]condition", + "typescriptType": "Array\u003ccondition\u003e", + "usedTypescriptType": "condition", + "usedStructName": "troubleshooter.condition", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "platforms", + "jsonName": "platforms", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "branches", + "jsonName": "branches", + "goType": "[]branch", + "typescriptType": "Array\u003cbranch\u003e", + "usedTypescriptType": "branch", + "usedStructName": "troubleshooter.branch", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "defaultBranch", + "jsonName": "defaultBranch", + "goType": "branch", + "typescriptType": "branch", + "usedTypescriptType": "branch", + "usedStructName": "troubleshooter.branch", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "state", + "jsonName": "state", + "goType": "AppState", + "typescriptType": "AppState", + "usedTypescriptType": "AppState", + "usedStructName": "troubleshooter.AppState", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " RuleBuilder provides a fluent interface for building rules" + ] + }, + { + "filepath": "../internal/troubleshooter/logs.go", + "filename": "logs.go", + "name": "BranchBuilder", + "formattedName": "BranchBuilder", + "package": "troubleshooter", + "fields": [ + { + "name": "rule", + "jsonName": "rule", + "goType": "RuleBuilder", + "typescriptType": "RuleBuilder", + "usedTypescriptType": "RuleBuilder", + "usedStructName": "troubleshooter.RuleBuilder", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "branch", + "jsonName": "branch", + "goType": "branch", + "typescriptType": "branch", + "usedTypescriptType": "branch", + "usedStructName": "troubleshooter.branch", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [ + " BranchBuilder helps build conditional branches" + ] + }, + { + "filepath": "../internal/troubleshooter/logs.go", + "filename": "logs.go", + "name": "LogLine", + "formattedName": "LogLine", + "package": "troubleshooter", + "fields": [ + { + "name": "Timestamp", + "jsonName": "Timestamp", + "goType": "time.Time", + "typescriptType": "string", + "usedStructName": "time.Time", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Line", + "jsonName": "Line", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Module", + "jsonName": "Module", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Level", + "jsonName": "Level", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Message", + "jsonName": "Message", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " LogLine represents a parsed log line" + ] + }, + { + "filepath": "../internal/troubleshooter/troubleshooter.go", + "filename": "troubleshooter.go", + "name": "Troubleshooter", + "formattedName": "Troubleshooter", + "package": "troubleshooter", + "fields": [ + { + "name": "logsDir", + "jsonName": "logsDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "rules", + "jsonName": "rules", + "goType": "[]RuleBuilder", + "typescriptType": "Array\u003cRuleBuilder\u003e", + "usedTypescriptType": "RuleBuilder", + "usedStructName": "troubleshooter.RuleBuilder", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "state", + "jsonName": "state", + "goType": "AppState", + "typescriptType": "AppState", + "usedTypescriptType": "AppState", + "usedStructName": "troubleshooter.AppState", + "required": false, + "public": false, + "comments": [ + " For accessing app state like settings" + ] + }, + { + "name": "modules", + "jsonName": "modules", + "goType": "Modules", + "typescriptType": "Modules", + "usedTypescriptType": "Modules", + "usedStructName": "troubleshooter.Modules", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "clientParams", + "jsonName": "clientParams", + "goType": "ClientParams", + "typescriptType": "ClientParams", + "usedTypescriptType": "ClientParams", + "usedStructName": "troubleshooter.ClientParams", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "currentResult", + "jsonName": "currentResult", + "goType": "Result", + "typescriptType": "Result", + "usedTypescriptType": "Result", + "usedStructName": "troubleshooter.Result", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/troubleshooter/troubleshooter.go", + "filename": "troubleshooter.go", + "name": "Modules", + "formattedName": "Modules", + "package": "troubleshooter", + "fields": [ + { + "name": "MediaPlayerRepository", + "jsonName": "MediaPlayerRepository", + "goType": "mediaplayer.Repository", + "typescriptType": "Repository", + "usedTypescriptType": "Repository", + "usedStructName": "mediaplayer.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OnlinestreamRepository", + "jsonName": "OnlinestreamRepository", + "goType": "onlinestream.Repository", + "typescriptType": "Onlinestream_Repository", + "usedTypescriptType": "Onlinestream_Repository", + "usedStructName": "onlinestream.Repository", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentstreamRepository", + "jsonName": "TorrentstreamRepository", + "goType": "torrentstream.Repository", + "typescriptType": "Torrentstream_Repository", + "usedTypescriptType": "Torrentstream_Repository", + "usedStructName": "torrentstream.Repository", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/troubleshooter/troubleshooter.go", + "filename": "troubleshooter.go", + "name": "NewTroubleshooterOptions", + "formattedName": "NewTroubleshooterOptions", + "package": "troubleshooter", + "fields": [ + { + "name": "LogsDir", + "jsonName": "LogsDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Logger", + "jsonName": "Logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "State", + "jsonName": "State", + "goType": "AppState", + "typescriptType": "AppState", + "usedTypescriptType": "AppState", + "usedStructName": "troubleshooter.AppState", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/troubleshooter/troubleshooter.go", + "filename": "troubleshooter.go", + "name": "AppState", + "formattedName": "AppState", + "package": "troubleshooter", + "fields": [ + { + "name": "Settings", + "jsonName": "Settings", + "goType": "models.Settings", + "typescriptType": "Models_Settings", + "usedTypescriptType": "Models_Settings", + "usedStructName": "models.Settings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TorrentstreamSettings", + "jsonName": "TorrentstreamSettings", + "goType": "models.TorrentstreamSettings", + "typescriptType": "Models_TorrentstreamSettings", + "usedTypescriptType": "Models_TorrentstreamSettings", + "usedStructName": "models.TorrentstreamSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "MediastreamSettings", + "jsonName": "MediastreamSettings", + "goType": "models.MediastreamSettings", + "typescriptType": "Models_MediastreamSettings", + "usedTypescriptType": "Models_MediastreamSettings", + "usedStructName": "models.MediastreamSettings", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DebridSettings", + "jsonName": "DebridSettings", + "goType": "models.DebridSettings", + "typescriptType": "Models_DebridSettings", + "usedTypescriptType": "Models_DebridSettings", + "usedStructName": "models.DebridSettings", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/troubleshooter/troubleshooter.go", + "filename": "troubleshooter.go", + "name": "Result", + "formattedName": "Result", + "package": "troubleshooter", + "fields": [ + { + "name": "Items", + "jsonName": "items", + "goType": "[]ResultItem", + "typescriptType": "Array\u003cResultItem\u003e", + "usedTypescriptType": "ResultItem", + "usedStructName": "troubleshooter.ResultItem", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/troubleshooter/troubleshooter.go", + "filename": "troubleshooter.go", + "name": "ResultItem", + "formattedName": "ResultItem", + "package": "troubleshooter", + "fields": [ + { + "name": "Module", + "jsonName": "module", + "goType": "Module", + "typescriptType": "Module", + "usedTypescriptType": "Module", + "usedStructName": "troubleshooter.Module", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Observation", + "jsonName": "observation", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Recommendation", + "jsonName": "recommendation", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Level", + "jsonName": "level", + "goType": "Level", + "typescriptType": "Level", + "usedTypescriptType": "Level", + "usedStructName": "troubleshooter.Level", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Errors", + "jsonName": "errors", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Warnings", + "jsonName": "warnings", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Logs", + "jsonName": "logs", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/troubleshooter/troubleshooter.go", + "filename": "troubleshooter.go", + "name": "Module", + "formattedName": "Module", + "package": "troubleshooter", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"Playback\"", + "\"Media player\"", + "\"Anime library\"", + "\"Media streaming\"", + "\"Torrent streaming\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/troubleshooter/troubleshooter.go", + "filename": "troubleshooter.go", + "name": "Level", + "formattedName": "Level", + "package": "troubleshooter", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"error\"", + "\"warning\"", + "\"info\"", + "\"debug\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/troubleshooter/troubleshooter.go", + "filename": "troubleshooter.go", + "name": "ClientParams", + "formattedName": "ClientParams", + "package": "troubleshooter", + "fields": [ + { + "name": "LibraryPlaybackOption", + "jsonName": "libraryPlaybackOption", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " \"desktop_media_player\" or \"media_streaming\" or \"external_player_link\"" + ] + }, + { + "name": "TorrentOrDebridPlaybackOption", + "jsonName": "torrentOrDebridPlaybackOption", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " \"desktop_torrent_player\" or \"external_player_link\"" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/updater/announcement.go", + "filename": "announcement.go", + "name": "AnnouncementType", + "formattedName": "Updater_AnnouncementType", + "package": "updater", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"toast\"", + "\"dialog\"", + "\"banner\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/updater/announcement.go", + "filename": "announcement.go", + "name": "AnnouncementSeverity", + "formattedName": "Updater_AnnouncementSeverity", + "package": "updater", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"info\"", + "\"warning\"", + "\"error\"", + "\"critical\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/updater/announcement.go", + "filename": "announcement.go", + "name": "AnnouncementAction", + "formattedName": "Updater_AnnouncementAction", + "package": "updater", + "fields": [ + { + "name": "Label", + "jsonName": "label", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "URL", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/updater/announcement.go", + "filename": "announcement.go", + "name": "AnnouncementConditions", + "formattedName": "Updater_AnnouncementConditions", + "package": "updater", + "fields": [ + { + "name": "OS", + "jsonName": "os", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " [\"windows\", \"darwin\", \"linux\"]" + ] + }, + { + "name": "Platform", + "jsonName": "platform", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " [\"tauri\", \"web\", \"denshi\"]" + ] + }, + { + "name": "VersionConstraint", + "jsonName": "versionConstraint", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " e.g. \"\u003c= 2.9.0\", \"2.9.0\"" + ] + }, + { + "name": "UserSettingsPath", + "jsonName": "userSettingsPath", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " JSON path to check in user settings" + ] + }, + { + "name": "UserSettingsValue", + "jsonName": "userSettingsValue", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [ + " Expected values at that path" + ] + } + ], + "comments": [] + }, + { + "filepath": "../internal/updater/announcement.go", + "filename": "announcement.go", + "name": "Announcement", + "formattedName": "Updater_Announcement", + "package": "updater", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " Unique identifier for tracking" + ] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [ + " Title for dialogs/banners" + ] + }, + { + "name": "Message", + "jsonName": "message", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " The message to display" + ] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "AnnouncementType", + "typescriptType": "Updater_AnnouncementType", + "usedTypescriptType": "Updater_AnnouncementType", + "usedStructName": "updater.AnnouncementType", + "required": true, + "public": true, + "comments": [ + " The type of announcement" + ] + }, + { + "name": "Severity", + "jsonName": "severity", + "goType": "AnnouncementSeverity", + "typescriptType": "Updater_AnnouncementSeverity", + "usedTypescriptType": "Updater_AnnouncementSeverity", + "usedStructName": "updater.AnnouncementSeverity", + "required": true, + "public": true, + "comments": [ + " Severity level" + ] + }, + { + "name": "Date", + "jsonName": "date", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [ + " Date of the announcement" + ] + }, + { + "name": "NotDismissible", + "jsonName": "notDismissible", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Can user dismiss it" + ] + }, + { + "name": "Conditions", + "jsonName": "conditions", + "goType": "AnnouncementConditions", + "typescriptType": "Updater_AnnouncementConditions", + "usedTypescriptType": "Updater_AnnouncementConditions", + "usedStructName": "updater.AnnouncementConditions", + "required": false, + "public": true, + "comments": [ + " Advanced targeting" + ] + }, + { + "name": "Actions", + "jsonName": "actions", + "goType": "[]AnnouncementAction", + "typescriptType": "Array\u003cUpdater_AnnouncementAction\u003e", + "usedTypescriptType": "Updater_AnnouncementAction", + "usedStructName": "updater.AnnouncementAction", + "required": false, + "public": true, + "comments": [ + " Action buttons" + ] + }, + { + "name": "Priority", + "jsonName": "priority", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/updater/check.go", + "filename": "check.go", + "name": "GitHubResponse", + "formattedName": "Updater_GitHubResponse", + "package": "updater", + "fields": [ + { + "name": "Url", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "AssetsUrl", + "jsonName": "assets_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "UploadUrl", + "jsonName": "upload_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HtmlUrl", + "jsonName": "html_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ID", + "jsonName": "id", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NodeID", + "jsonName": "node_id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TagName", + "jsonName": "tag_name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TargetCommitish", + "jsonName": "target_commitish", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Draft", + "jsonName": "draft", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Prerelease", + "jsonName": "prerelease", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "CreatedAt", + "jsonName": "created_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PublishedAt", + "jsonName": "published_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Assets", + "jsonName": "assets", + "goType": "[]__STRUCT__", + "inlineStructType": "[]struct{\nUrl string `json:\"url\"`\nID int64 `json:\"id\"`\nNodeID string `json:\"node_id\"`\nName string `json:\"name\"`\nLabel string `json:\"label\"`\nContentType string `json:\"content_type\"`\nState string `json:\"state\"`\nSize int64 `json:\"size\"`\nDownloadCount int64 `json:\"download_count\"`\nCreatedAt string `json:\"created_at\"`\nUpdatedAt string `json:\"updated_at\"`\nBrowserDownloadURL string `json:\"browser_download_url\"`}", + "typescriptType": "Array\u003c{ url: string; id: number; node_id: string; name: string; label: string; content_type: string; state: string; size: number; download_count: number; created_at: string; updated_at: string; browser_download_url: string; }\u003e", + "usedTypescriptType": "{ url: string; id: number; node_id: string; name: string; label: string; content_type: string; state: string; size: number; download_count: number; created_at: string; updated_at: string; browser_download_url: string; }", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TarballURL", + "jsonName": "tarball_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ZipballURL", + "jsonName": "zipball_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Body", + "jsonName": "body", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/updater/check.go", + "filename": "check.go", + "name": "DocsResponse", + "formattedName": "Updater_DocsResponse", + "package": "updater", + "fields": [ + { + "name": "Release", + "jsonName": "release", + "goType": "Release", + "typescriptType": "Updater_Release", + "usedTypescriptType": "Updater_Release", + "usedStructName": "updater.Release", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/updater/check.go", + "filename": "check.go", + "name": "Release", + "formattedName": "Updater_Release", + "package": "updater", + "fields": [ + { + "name": "Url", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HtmlUrl", + "jsonName": "html_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NodeId", + "jsonName": "node_id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "TagName", + "jsonName": "tag_name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Body", + "jsonName": "body", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "PublishedAt", + "jsonName": "published_at", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Released", + "jsonName": "released", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Version", + "jsonName": "version", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Assets", + "jsonName": "assets", + "goType": "[]ReleaseAsset", + "typescriptType": "Array\u003cUpdater_ReleaseAsset\u003e", + "usedTypescriptType": "Updater_ReleaseAsset", + "usedStructName": "updater.ReleaseAsset", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/updater/check.go", + "filename": "check.go", + "name": "ReleaseAsset", + "formattedName": "Updater_ReleaseAsset", + "package": "updater", + "fields": [ + { + "name": "Url", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Id", + "jsonName": "id", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "NodeId", + "jsonName": "node_id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ContentType", + "jsonName": "content_type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Uploaded", + "jsonName": "uploaded", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "BrowserDownloadUrl", + "jsonName": "browser_download_url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/updater/selfupdate.go", + "filename": "selfupdate.go", + "name": "SelfUpdater", + "formattedName": "Updater_SelfUpdater", + "package": "updater", + "fields": [ + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "breakLoopCh", + "jsonName": "breakLoopCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "originalExePath", + "jsonName": "originalExePath", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "updater", + "jsonName": "updater", + "goType": "Updater", + "typescriptType": "Updater_Updater", + "usedTypescriptType": "Updater_Updater", + "usedStructName": "updater.Updater", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "fallbackDest", + "jsonName": "fallbackDest", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "tmpExecutableName", + "jsonName": "tmpExecutableName", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/updater/updater.go", + "filename": "updater.go", + "name": "Updater", + "formattedName": "Updater_Updater", + "package": "updater", + "fields": [ + { + "name": "CurrentVersion", + "jsonName": "CurrentVersion", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "hasCheckedForUpdate", + "jsonName": "hasCheckedForUpdate", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "LatestRelease", + "jsonName": "LatestRelease", + "goType": "Release", + "typescriptType": "Updater_Release", + "usedTypescriptType": "Updater_Release", + "usedStructName": "updater.Release", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "checkForUpdate", + "jsonName": "checkForUpdate", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "client", + "jsonName": "client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wsEventManager", + "jsonName": "wsEventManager", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "announcements", + "jsonName": "announcements", + "goType": "[]Announcement", + "typescriptType": "Array\u003cUpdater_Announcement\u003e", + "usedTypescriptType": "Updater_Announcement", + "usedStructName": "updater.Announcement", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/updater/updater.go", + "filename": "updater.go", + "name": "Update", + "formattedName": "Updater_Update", + "package": "updater", + "fields": [ + { + "name": "Release", + "jsonName": "release", + "goType": "Release", + "typescriptType": "Updater_Release", + "usedTypescriptType": "Updater_Release", + "usedStructName": "updater.Release", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "CurrentVersion", + "jsonName": "current_version", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Type", + "jsonName": "type", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/user/user.go", + "filename": "user.go", + "name": "User", + "formattedName": "User", + "package": "user", + "fields": [ + { + "name": "Viewer", + "jsonName": "viewer", + "goType": "anilist.GetViewer_Viewer", + "typescriptType": "AL_GetViewer_Viewer", + "usedTypescriptType": "AL_GetViewer_Viewer", + "usedStructName": "anilist.GetViewer_Viewer", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Token", + "jsonName": "token", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "IsSimulated", + "jsonName": "isSimulated", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/cachedreadseeker.go", + "filename": "cachedreadseeker.go", + "name": "CachedReadSeeker", + "formattedName": "Util_CachedReadSeeker", + "package": "util", + "fields": [ + { + "name": "src", + "jsonName": "src", + "goType": "io.ReadSeekCloser", + "typescriptType": "ReadSeekCloser", + "usedTypescriptType": "ReadSeekCloser", + "usedStructName": "io.ReadSeekCloser", + "required": false, + "public": false, + "comments": [ + " underlying source" + ] + }, + { + "name": "cache", + "jsonName": "cache", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": false, + "comments": [ + " bytes read so far" + ] + }, + { + "name": "pos", + "jsonName": "pos", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " current read position" + ] + } + ], + "comments": [ + " CachedReadSeeker wraps an io.ReadSeekCloser and caches bytes as they are read.", + " It implements io.ReadSeeker, allowing seeking within the already-cached", + " range without hitting the underlying reader again.", + " Additional reads beyond the cache will append to the cache automatically." + ] + }, + { + "filepath": "../internal/util/comparison/matching.go", + "filename": "matching.go", + "name": "LevenshteinResult", + "formattedName": "Comparison_LevenshteinResult", + "package": "comparison", + "fields": [ + { + "name": "OriginalValue", + "jsonName": "OriginalValue", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "Value", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Distance", + "jsonName": "Distance", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " LevenshteinResult is a struct that holds a string and its Levenshtein distance compared to another string." + ] + }, + { + "filepath": "../internal/util/comparison/matching.go", + "filename": "matching.go", + "name": "JaroWinklerResult", + "formattedName": "Comparison_JaroWinklerResult", + "package": "comparison", + "fields": [ + { + "name": "OriginalValue", + "jsonName": "OriginalValue", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "Value", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rating", + "jsonName": "Rating", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " JaroWinklerResult is a struct that holds a string and its JaroWinkler distance compared to another string." + ] + }, + { + "filepath": "../internal/util/comparison/matching.go", + "filename": "matching.go", + "name": "JaccardResult", + "formattedName": "Comparison_JaccardResult", + "package": "comparison", + "fields": [ + { + "name": "OriginalValue", + "jsonName": "OriginalValue", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "Value", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rating", + "jsonName": "Rating", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " JaccardResult is a struct that holds a string and its Jaccard distance compared to another string." + ] + }, + { + "filepath": "../internal/util/comparison/matching.go", + "filename": "matching.go", + "name": "SorensenDiceResult", + "formattedName": "Comparison_SorensenDiceResult", + "package": "comparison", + "fields": [ + { + "name": "OriginalValue", + "jsonName": "OriginalValue", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Value", + "jsonName": "Value", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rating", + "jsonName": "Rating", + "goType": "float64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/crashlog/crashlog.go", + "filename": "crashlog.go", + "name": "CrashLogger", + "formattedName": "CrashLogger", + "package": "crashlog", + "fields": [ + { + "name": "logDir", + "jsonName": "logDir", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/crashlog/crashlog.go", + "filename": "crashlog.go", + "name": "CrashLoggerArea", + "formattedName": "CrashLoggerArea", + "package": "crashlog", + "fields": [ + { + "name": "name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logBuffer", + "jsonName": "logBuffer", + "goType": "bytes.Buffer", + "typescriptType": "Buffer", + "usedTypescriptType": "Buffer", + "usedStructName": "bytes.Buffer", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ctx", + "jsonName": "ctx", + "goType": "context.Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "context.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cancelFunc", + "jsonName": "cancelFunc", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/filecache/filecache.go", + "filename": "filecache.go", + "name": "CacheStore", + "formattedName": "Filecache_CacheStore", + "package": "filecache", + "fields": [ + { + "name": "filePath", + "jsonName": "filePath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "data", + "jsonName": "data", + "goType": "map[string]cacheItem", + "typescriptType": "Record\u003cstring, Filecache_cacheItem\u003e", + "usedTypescriptType": "Filecache_cacheItem", + "usedStructName": "filecache.cacheItem", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " CacheStore represents a single-process, file-based, key/value cache store." + ] + }, + { + "filepath": "../internal/util/filecache/filecache.go", + "filename": "filecache.go", + "name": "Bucket", + "formattedName": "Filecache_Bucket", + "package": "filecache", + "fields": [ + { + "name": "name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "ttl", + "jsonName": "ttl", + "goType": "time.Duration", + "typescriptType": "Duration", + "usedTypescriptType": "Duration", + "usedStructName": "time.Duration", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " Bucket represents a cache bucket with a name and TTL." + ] + }, + { + "filepath": "../internal/util/filecache/filecache.go", + "filename": "filecache.go", + "name": "PermanentBucket", + "formattedName": "Filecache_PermanentBucket", + "package": "filecache", + "fields": [ + { + "name": "name", + "jsonName": "name", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/filecache/filecache.go", + "filename": "filecache.go", + "name": "Cacher", + "formattedName": "Filecache_Cacher", + "package": "filecache", + "fields": [ + { + "name": "dir", + "jsonName": "dir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "stores", + "jsonName": "stores", + "goType": "map[string]CacheStore", + "typescriptType": "Record\u003cstring, Filecache_CacheStore\u003e", + "usedTypescriptType": "Filecache_CacheStore", + "usedStructName": "filecache.CacheStore", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " Cacher represents a single-process, file-based, key/value cache." + ] + }, + { + "filepath": "../internal/util/goja/scheduler.go", + "filename": "scheduler.go", + "name": "Job", + "formattedName": "Job", + "package": "goja_util", + "fields": [ + { + "name": "fn", + "jsonName": "fn", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "resultCh", + "jsonName": "resultCh", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "async", + "jsonName": "async", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [ + " Flag to indicate if the job is async (doesn't need to wait for result)" + ] + } + ], + "comments": [ + " Job represents a task to be executed in the VM" + ] + }, + { + "filepath": "../internal/util/goja/scheduler.go", + "filename": "scheduler.go", + "name": "Scheduler", + "formattedName": "Scheduler", + "package": "goja_util", + "fields": [ + { + "name": "jobQueue", + "jsonName": "jobQueue", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "ctx", + "jsonName": "ctx", + "goType": "context.Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "context.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cancel", + "jsonName": "cancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "wg", + "jsonName": "wg", + "goType": "sync.WaitGroup", + "typescriptType": "WaitGroup", + "usedTypescriptType": "WaitGroup", + "usedStructName": "sync.WaitGroup", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentJob", + "jsonName": "currentJob", + "goType": "Job", + "typescriptType": "Job", + "usedTypescriptType": "Job", + "usedStructName": "goja_util.Job", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "currentJobLock", + "jsonName": "currentJobLock", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "onException", + "jsonName": "onException", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + } + ], + "comments": [ + " Scheduler handles all VM operations added concurrently in a single goroutine", + " Any goroutine that needs to execute a VM operation must schedule it because the UI VM isn't thread safe" + ] + }, + { + "filepath": "../internal/util/hmac_auth.go", + "filename": "hmac_auth.go", + "name": "TokenClaims", + "formattedName": "Util_TokenClaims", + "package": "util", + "fields": [ + { + "name": "Endpoint", + "jsonName": "endpoint", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " The endpoint this token is valid for" + ] + }, + { + "name": "IssuedAt", + "jsonName": "iat", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ExpiresAt", + "jsonName": "exp", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/hmac_auth.go", + "filename": "hmac_auth.go", + "name": "HMACAuth", + "formattedName": "Util_HMACAuth", + "package": "util", + "fields": [ + { + "name": "secret", + "jsonName": "secret", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ttl", + "jsonName": "ttl", + "goType": "time.Duration", + "typescriptType": "Duration", + "usedTypescriptType": "Duration", + "usedStructName": "time.Duration", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/http/filestream.go", + "filename": "filestream.go", + "name": "FileStream", + "formattedName": "FileStream", + "package": "httputil", + "fields": [ + { + "name": "length", + "jsonName": "length", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "file", + "jsonName": "file", + "goType": "os.File", + "typescriptType": "File", + "usedTypescriptType": "File", + "usedStructName": "os.File", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "closed", + "jsonName": "closed", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "pieces", + "jsonName": "pieces", + "goType": "map[int64]piece", + "typescriptType": "Record\u003cnumber, piece\u003e", + "usedTypescriptType": "piece", + "usedStructName": "httputil.piece", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "readers", + "jsonName": "readers", + "goType": "[]FileStreamReader", + "typescriptType": "Array\u003cFileStreamReader\u003e", + "usedTypescriptType": "FileStreamReader", + "usedStructName": "httputil.FileStreamReader", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "readersMu", + "jsonName": "readersMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "ctx", + "jsonName": "ctx", + "goType": "context.Context", + "typescriptType": "Context", + "usedTypescriptType": "Context", + "usedStructName": "context.Context", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "cancel", + "jsonName": "cancel", + "goType": "context.CancelFunc", + "typescriptType": "CancelFunc", + "usedTypescriptType": "CancelFunc", + "usedStructName": "context.CancelFunc", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " FileStream saves a HTTP file being streamed to disk.", + " It allows multiple readers to read the file concurrently.", + " It works by being fed the stream from the HTTP response body. It will simultaneously write to disk and to the HTTP writer." + ] + }, + { + "filepath": "../internal/util/http/http_range.go", + "filename": "http_range.go", + "name": "Range", + "formattedName": "Range", + "package": "httputil", + "fields": [ + { + "name": "Start", + "jsonName": "Start", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Length", + "jsonName": "Length", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " Range specifies the byte range to be sent to the client." + ] + }, + { + "filepath": "../internal/util/http/httprs.go", + "filename": "httprs.go", + "name": "HttpReadSeeker", + "formattedName": "HttpReadSeeker", + "package": "httputil", + "fields": [ + { + "name": "url", + "jsonName": "url", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [ + " The URL of the resource" + ] + }, + { + "name": "client", + "jsonName": "client", + "goType": "http.Client", + "typescriptType": "Client", + "usedTypescriptType": "Client", + "usedStructName": "http.Client", + "required": false, + "public": false, + "comments": [ + " HTTP client to use for requests" + ] + }, + { + "name": "resp", + "jsonName": "resp", + "goType": "http.Response", + "typescriptType": "Response", + "usedTypescriptType": "Response", + "usedStructName": "http.Response", + "required": false, + "public": false, + "comments": [ + " Current response" + ] + }, + { + "name": "offset", + "jsonName": "offset", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Current offset in the resource" + ] + }, + { + "name": "size", + "jsonName": "size", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Size of the resource, -1 if unknown" + ] + }, + { + "name": "readBuf", + "jsonName": "readBuf", + "goType": "string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": false, + "comments": [ + " Buffer for reading" + ] + }, + { + "name": "readOffset", + "jsonName": "readOffset", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Current offset in readBuf" + ] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [ + " Mutex for thread safety" + ] + }, + { + "name": "rateLimiter", + "jsonName": "rateLimiter", + "goType": "limiter.Limiter", + "typescriptType": "Limiter", + "usedTypescriptType": "Limiter", + "usedStructName": "limiter.Limiter", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " HttpReadSeeker implements io.ReadSeeker for HTTP responses", + " It allows seeking within an HTTP response by using HTTP Range requests" + ] + }, + { + "filepath": "../internal/util/image_downloader/image_downloader.go", + "filename": "image_downloader.go", + "name": "ImageDownloader", + "formattedName": "ImageDownloader", + "package": "image_downloader", + "fields": [ + { + "name": "downloadDir", + "jsonName": "downloadDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "registry", + "jsonName": "registry", + "goType": "Registry", + "typescriptType": "Registry", + "usedTypescriptType": "Registry", + "usedStructName": "image_downloader.Registry", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "cancelChannel", + "jsonName": "cancelChannel", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "actionMu", + "jsonName": "actionMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "registryMu", + "jsonName": "registryMu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/image_downloader/image_downloader.go", + "filename": "image_downloader.go", + "name": "Registry", + "formattedName": "Registry", + "package": "image_downloader", + "fields": [ + { + "name": "content", + "jsonName": "content", + "goType": "RegistryContent", + "typescriptType": "RegistryContent", + "usedTypescriptType": "RegistryContent", + "usedStructName": "image_downloader.RegistryContent", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "downloadDir", + "jsonName": "downloadDir", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "registryPath", + "jsonName": "registryPath", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/image_downloader/image_downloader.go", + "filename": "image_downloader.go", + "name": "RegistryContent", + "formattedName": "RegistryContent", + "package": "image_downloader", + "fields": [ + { + "name": "UrlToId", + "jsonName": "url_to_id", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IdToUrl", + "jsonName": "id_to_url", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "IdToExt", + "jsonName": "id_to_ext", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/limited_read_seeker.go", + "filename": "limited_read_seeker.go", + "name": "LimitedReadSeeker", + "formattedName": "Util_LimitedReadSeeker", + "package": "util", + "fields": [ + { + "name": "rs", + "jsonName": "rs", + "goType": "io.ReadSeeker", + "typescriptType": "ReadSeeker", + "usedTypescriptType": "ReadSeeker", + "usedStructName": "io.ReadSeeker", + "required": false, + "public": false, + "comments": [ + " The underlying ReadSeeker" + ] + }, + { + "name": "offset", + "jsonName": "offset", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Current read position relative to start" + ] + }, + { + "name": "limit", + "jsonName": "limit", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Maximum number of bytes that can be read" + ] + }, + { + "name": "basePos", + "jsonName": "basePos", + "goType": "int64", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [ + " Original position in the underlying ReadSeeker" + ] + } + ], + "comments": [ + " LimitedReadSeeker wraps an io.ReadSeeker and limits the number of bytes", + " that can be read from it." + ] + }, + { + "filepath": "../internal/util/limiter/limiter.go", + "filename": "limiter.go", + "name": "Limiter", + "formattedName": "Limiter", + "package": "limiter", + "fields": [ + { + "name": "tick", + "jsonName": "tick", + "goType": "time.Duration", + "typescriptType": "Duration", + "usedTypescriptType": "Duration", + "usedStructName": "time.Duration", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "count", + "jsonName": "count", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "entries", + "jsonName": "entries", + "goType": "[]time.Time", + "typescriptType": "Array\u003cstring\u003e", + "usedStructName": "time.Time", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "index", + "jsonName": "index", + "goType": "uint", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/map.go", + "filename": "map.go", + "name": "RWMutexMap", + "formattedName": "Util_RWMutexMap", + "package": "util", + "fields": [ + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "dirty", + "jsonName": "dirty", + "goType": "map[]", + "typescriptType": "Record\u003cany, any\u003e", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " RWMutexMap is an implementation of mapInterface using a sync.RWMutex." + ] + }, + { + "filepath": "../internal/util/parallel/parallel.go", + "filename": "parallel.go", + "name": "SettledResults", + "formattedName": "SettledResults", + "package": "parallel", + "fields": [ + { + "name": "Collection", + "jsonName": "Collection", + "goType": "[]T", + "typescriptType": "Array\u003cT\u003e", + "usedTypescriptType": "T", + "usedStructName": "parallel.T", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Fulfilled", + "jsonName": "Fulfilled", + "goType": "map[T]R", + "typescriptType": "Record\u003cT, R\u003e", + "usedTypescriptType": "R", + "usedStructName": "parallel.R", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Results", + "jsonName": "Results", + "goType": "[]R", + "typescriptType": "Array\u003cR\u003e", + "usedTypescriptType": "R", + "usedStructName": "parallel.R", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Rejected", + "jsonName": "Rejected", + "goType": "map[T]error", + "typescriptType": "Record\u003cT, error\u003e", + "usedTypescriptType": "error", + "usedStructName": "parallel.error", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/pool.go", + "filename": "pool.go", + "name": "Pool", + "formattedName": "Util_Pool", + "package": "util", + "fields": [], + "comments": [], + "embeddedStructNames": [ + "sync.Pool" + ] + }, + { + "filepath": "../internal/util/proxies/image_proxy.go", + "filename": "image_proxy.go", + "name": "ImageProxy", + "formattedName": "Util_ImageProxy", + "package": "util", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/util/result/boundedcache.go", + "filename": "boundedcache.go", + "name": "BoundedCache", + "formattedName": "BoundedCache", + "package": "result", + "fields": [ + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.RWMutex", + "typescriptType": "RWMutex", + "usedTypescriptType": "RWMutex", + "usedStructName": "sync.RWMutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "capacity", + "jsonName": "capacity", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "items", + "jsonName": "items", + "goType": "map[K]list.Element", + "typescriptType": "Record\u003cK, Element\u003e", + "usedTypescriptType": "Element", + "usedStructName": "list.Element", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "order", + "jsonName": "order", + "goType": "list.List", + "typescriptType": "List", + "usedTypescriptType": "List", + "usedStructName": "list.List", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " BoundedCache implements an LRU cache with a maximum capacity" + ] + }, + { + "filepath": "../internal/util/result/resultcache.go", + "filename": "resultcache.go", + "name": "Cache", + "formattedName": "Cache", + "package": "result", + "fields": [ + { + "name": "store", + "jsonName": "store", + "goType": "util.RWMutexMap", + "typescriptType": "Util_RWMutexMap", + "usedTypescriptType": "Util_RWMutexMap", + "usedStructName": "util.RWMutexMap", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/result/resultmap.go", + "filename": "resultmap.go", + "name": "Map", + "formattedName": "Map", + "package": "result", + "fields": [ + { + "name": "store", + "jsonName": "store", + "goType": "util.RWMutexMap", + "typescriptType": "Util_RWMutexMap", + "usedTypescriptType": "Util_RWMutexMap", + "usedStructName": "util.RWMutexMap", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/util/round_tripper.go", + "filename": "round_tripper.go", + "name": "RetryConfig", + "formattedName": "Util_RetryConfig", + "package": "util", + "fields": [ + { + "name": "MaxRetries", + "jsonName": "MaxRetries", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "RetryDelay", + "jsonName": "RetryDelay", + "goType": "time.Duration", + "typescriptType": "Duration", + "usedTypescriptType": "Duration", + "usedStructName": "time.Duration", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "TimeoutOnly", + "jsonName": "TimeoutOnly", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [ + " Only retry on timeout errors" + ] + } + ], + "comments": [ + " RetryConfig configures the retry behavior" + ] + }, + { + "filepath": "../internal/util/round_tripper.go", + "filename": "round_tripper.go", + "name": "Options", + "formattedName": "Util_Options", + "package": "util", + "fields": [ + { + "name": "AddMissingHeaders", + "jsonName": "AddMissingHeaders", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Headers", + "jsonName": "Headers", + "goType": "map[string]string", + "typescriptType": "Record\u003cstring, string\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [ + " Options the option to set custom headers" + ] + }, + { + "filepath": "../internal/util/torrentutil/torrentutil.go", + "filename": "torrentutil.go", + "name": "ReadSeeker", + "formattedName": "ReadSeeker", + "package": "torrentutil", + "fields": [ + { + "name": "id", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": false, + "comments": [] + }, + { + "name": "torrent", + "jsonName": "torrent", + "goType": "torrent.Torrent", + "typescriptType": "Torrent_Torrent", + "usedTypescriptType": "Torrent_Torrent", + "usedStructName": "torrent.Torrent", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "file", + "jsonName": "file", + "goType": "torrent.File", + "typescriptType": "Torrent_File", + "usedTypescriptType": "Torrent_File", + "usedStructName": "torrent.File", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "reader", + "jsonName": "reader", + "goType": "torrent.Reader", + "typescriptType": "Torrent_Reader", + "usedTypescriptType": "Torrent_Reader", + "usedStructName": "torrent.Reader", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "priorityManager", + "jsonName": "priorityManager", + "goType": "priorityManager", + "typescriptType": "priorityManager", + "usedTypescriptType": "priorityManager", + "usedStructName": "torrentutil.priorityManager", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "logger", + "jsonName": "logger", + "goType": "zerolog.Logger", + "typescriptType": "Logger", + "usedTypescriptType": "Logger", + "usedStructName": "zerolog.Logger", + "required": false, + "public": false, + "comments": [] + } + ], + "comments": [ + " ReadSeeker implements io.ReadSeekCloser for a torrent file being streamed.", + " It allows dynamic prioritization of pieces when seeking, optimized for streaming", + " and supports multiple concurrent readers on the same file." + ] + }, + { + "filepath": "../internal/util/useragent.go", + "filename": "useragent.go", + "name": "ClientInfo", + "formattedName": "Util_ClientInfo", + "package": "util", + "fields": [ + { + "name": "Device", + "jsonName": "Device", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Platform", + "jsonName": "Platform", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/vendor_habari/vendor_habari.go", + "filename": "vendor_habari.go", + "name": "Metadata", + "formattedName": "Habari_Metadata", + "package": "vendor_habari", + "fields": [ + { + "name": "SeasonNumber", + "jsonName": "season_number", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "PartNumber", + "jsonName": "part_number", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FormattedTitle", + "jsonName": "formatted_title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AnimeType", + "jsonName": "anime_type", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Year", + "jsonName": "year", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "AudioTerm", + "jsonName": "audio_term", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "DeviceCompatibility", + "jsonName": "device_compatibility", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumber", + "jsonName": "episode_number", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "OtherEpisodeNumber", + "jsonName": "other_episode_number", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeNumberAlt", + "jsonName": "episode_number_alt", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "EpisodeTitle", + "jsonName": "episode_title", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FileChecksum", + "jsonName": "file_checksum", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FileExtension", + "jsonName": "file_extension", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "FileName", + "jsonName": "file_name", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Language", + "jsonName": "language", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReleaseGroup", + "jsonName": "release_group", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReleaseInformation", + "jsonName": "release_information", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "ReleaseVersion", + "jsonName": "release_version", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Source", + "jsonName": "source", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "Subtitles", + "jsonName": "subtitles", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VideoResolution", + "jsonName": "video_resolution", + "goType": "string", + "typescriptType": "string", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VideoTerm", + "jsonName": "video_term", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + }, + { + "name": "VolumeNumber", + "jsonName": "volume_number", + "goType": "[]string", + "typescriptType": "Array\u003cstring\u003e", + "required": false, + "public": true, + "comments": [] + } + ], + "comments": [] + } +] diff --git a/seanime-2.9.10/codegen/internal/examples/structs1.go b/seanime-2.9.10/codegen/internal/examples/structs1.go new file mode 100644 index 0000000..5f0785a --- /dev/null +++ b/seanime-2.9.10/codegen/internal/examples/structs1.go @@ -0,0 +1,26 @@ +package codegen + +import ( + "seanime/internal/api/anilist" + hibiketorrent "seanime/internal/extension/hibike/torrent" +) + +//type Struct1 struct { +// Struct2 +//} +// +//type Struct2 struct { +// Text string `json:"text"` +//} + +//type Struct3 []string + +type Struct4 struct { + Torrents []hibiketorrent.AnimeTorrent `json:"torrents"` + Destination string `json:"destination"` + SmartSelect struct { + Enabled bool `json:"enabled"` + MissingEpisodeNumbers []int `json:"missingEpisodeNumbers"` + } `json:"smartSelect"` + Media *anilist.BaseAnime `json:"media"` +} diff --git a/seanime-2.9.10/codegen/internal/generate_handlers.go b/seanime-2.9.10/codegen/internal/generate_handlers.go new file mode 100644 index 0000000..bfee019 --- /dev/null +++ b/seanime-2.9.10/codegen/internal/generate_handlers.go @@ -0,0 +1,308 @@ +package codegen + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" +) + +type ( + RouteHandler struct { + Name string `json:"name"` + TrimmedName string `json:"trimmedName"` + Comments []string `json:"comments"` + Filepath string `json:"filepath"` + Filename string `json:"filename"` + Api *RouteHandlerApi `json:"api"` + } + + RouteHandlerApi struct { + Summary string `json:"summary"` + Descriptions []string `json:"descriptions"` + Endpoint string `json:"endpoint"` + Methods []string `json:"methods"` + Params []*RouteHandlerParam `json:"params"` + BodyFields []*RouteHandlerParam `json:"bodyFields"` + Returns string `json:"returns"` + ReturnGoType string `json:"returnGoType"` + ReturnTypescriptType string `json:"returnTypescriptType"` + } + + RouteHandlerParam struct { + Name string `json:"name"` + JsonName string `json:"jsonName"` + GoType string `json:"goType"` // e.g., []models.User + InlineStructType string `json:"inlineStructType,omitempty"` // e.g., struct{Test string `json:"test"`} + UsedStructType string `json:"usedStructType"` // e.g., models.User + TypescriptType string `json:"typescriptType"` // e.g., Array + Required bool `json:"required"` + Descriptions []string `json:"descriptions"` + } +) + +func GenerateHandlers(dir string, outDir string) { + + handlers := make([]*RouteHandler, 0) + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if !strings.HasSuffix(info.Name(), ".go") || strings.HasPrefix(info.Name(), "_") { + return nil + } + + // Parse the file + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ParseComments) + if err != nil { + return err + } + + for _, decl := range file.Decls { + // Check if the declaration is a function + fn, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + + // Check if the function has comments + if fn.Doc == nil { + continue + } + + // Get the comments + comments := strings.Split(fn.Doc.Text(), "\n") + if len(comments) == 0 { + continue + } + + // Get the function name + name := fn.Name.Name + trimmedName := strings.TrimPrefix(name, "Handle") + + // Get the filename + filep := strings.ReplaceAll(strings.ReplaceAll(path, "\\", "/"), "../", "") + filename := filepath.Base(path) + + // Get the endpoint + endpoint := "" + var methods []string + params := make([]*RouteHandlerParam, 0) + summary := "" + descriptions := make([]string, 0) + returns := "bool" + + for _, comment := range comments { + cmt := strings.TrimSpace(strings.TrimPrefix(comment, "//")) + if strings.HasPrefix(cmt, "@summary") { + summary = strings.TrimSpace(strings.TrimPrefix(cmt, "@summary")) + } + + if strings.HasPrefix(cmt, "@desc") { + descriptions = append(descriptions, strings.TrimSpace(strings.TrimPrefix(cmt, "@desc"))) + } + + if strings.HasPrefix(cmt, "@route") { + endpointParts := strings.Split(strings.TrimSpace(strings.TrimPrefix(cmt, "@route")), " ") + if len(endpointParts) == 2 { + endpoint = endpointParts[0] + methods = strings.Split(endpointParts[1][1:len(endpointParts[1])-1], ",") + } + } + + if strings.HasPrefix(cmt, "@param") { + paramParts := strings.Split(strings.TrimSpace(strings.TrimPrefix(cmt, "@param")), " - ") + if len(paramParts) == 4 { + required := paramParts[2] == "true" + params = append(params, &RouteHandlerParam{ + Name: paramParts[0], + JsonName: paramParts[0], + GoType: paramParts[1], + TypescriptType: goTypeToTypescriptType(paramParts[1]), + Required: required, + Descriptions: []string{strings.ReplaceAll(paramParts[3], "\"", "")}, + }) + } + } + + if strings.HasPrefix(cmt, "@returns") { + returns = strings.TrimSpace(strings.TrimPrefix(cmt, "@returns")) + } + } + + bodyFields := make([]*RouteHandlerParam, 0) + // To get the request body fields, we need to look at the function body for a struct called "body" + + // Get the function body + body := fn.Body + if body != nil { + for _, stmt := range body.List { + // Check if the statement is a declaration + declStmt, ok := stmt.(*ast.DeclStmt) + if !ok { + continue + } + // Check if the declaration is a gen decl + genDecl, ok := declStmt.Decl.(*ast.GenDecl) + if !ok { + continue + } + // Check if the declaration is a type + if genDecl.Tok != token.TYPE { + continue + } + // Check if the type is a struct + if len(genDecl.Specs) != 1 { + continue + } + typeSpec, ok := genDecl.Specs[0].(*ast.TypeSpec) + if !ok { + continue + } + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + // Check if the struct is called "body" + if typeSpec.Name.Name != "body" { + continue + } + + // Get the fields + for _, field := range structType.Fields.List { + // Get the field name + fieldName := field.Names[0].Name + + // Get the field type + fieldType := field.Type + + jsonName := fieldName + // Get the field tag + required := !jsonFieldOmitEmpty(field) + jsonField := jsonFieldName(field) + if jsonField != "" { + jsonName = jsonField + } + + // Get field comments + fieldComments := make([]string, 0) + cmtsTxt := field.Doc.Text() + if cmtsTxt != "" { + fieldComments = strings.Split(cmtsTxt, "\n") + } + for _, cmt := range fieldComments { + cmt = strings.TrimSpace(strings.TrimPrefix(cmt, "//")) + if cmt != "" { + fieldComments = append(fieldComments, cmt) + } + } + + switch fieldType.(type) { + case *ast.StarExpr: + required = false + } + + goType := fieldTypeString(fieldType) + goTypeUnformatted := fieldTypeUnformattedString(fieldType) + packageName := "handlers" + if strings.Contains(goTypeUnformatted, ".") { + parts := strings.Split(goTypeUnformatted, ".") + packageName = parts[0] + } + + tsType := fieldTypeToTypescriptType(fieldType, packageName) + + usedStructType := goTypeUnformatted + switch goTypeUnformatted { + case "string", "int", "int64", "float64", "float32", "bool", "nil", "uint", "uint64", "uint32", "uint16", "uint8", "byte", "rune", "[]byte", "interface{}", "error": + usedStructType = "" + } + + // Add the request body field + bodyFields = append(bodyFields, &RouteHandlerParam{ + Name: fieldName, + JsonName: jsonName, + GoType: goType, + UsedStructType: usedStructType, + TypescriptType: tsType, + Required: required, + Descriptions: fieldComments, + }) + + // Check if it's an inline struct and capture its definition + if structType, ok := fieldType.(*ast.StructType); ok { + bodyFields[len(bodyFields)-1].InlineStructType = formatInlineStruct(structType) + } else { + // Check if it's a slice of inline structs + if arrayType, ok := fieldType.(*ast.ArrayType); ok { + if structType, ok := arrayType.Elt.(*ast.StructType); ok { + bodyFields[len(bodyFields)-1].InlineStructType = "[]" + formatInlineStruct(structType) + } + } + // Check if it's a map with inline struct values + if mapType, ok := fieldType.(*ast.MapType); ok { + if structType, ok := mapType.Value.(*ast.StructType); ok { + bodyFields[len(bodyFields)-1].InlineStructType = "map[" + fieldTypeString(mapType.Key) + "]" + formatInlineStruct(structType) + } + } + } + } + } + } + + // Add the route handler + routeHandler := &RouteHandler{ + Name: name, + TrimmedName: trimmedName, + Comments: comments, + Filepath: filep, + Filename: filename, + Api: &RouteHandlerApi{ + Summary: summary, + Descriptions: descriptions, + Endpoint: endpoint, + Methods: methods, + Params: params, + BodyFields: bodyFields, + Returns: returns, + ReturnGoType: getUnformattedGoType(returns), + ReturnTypescriptType: stringGoTypeToTypescriptType(returns), + }, + } + + handlers = append(handlers, routeHandler) + + } + + return nil + }) + if err != nil { + panic(err) + } + + // Write structs to file + _ = os.MkdirAll(outDir, os.ModePerm) + file, err := os.Create(outDir + "/handlers.json") + if err != nil { + fmt.Println("Error:", err) + return + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(handlers); err != nil { + fmt.Println("Error:", err) + return + } + + return +} diff --git a/seanime-2.9.10/codegen/internal/generate_hook_events_handlers.go b/seanime-2.9.10/codegen/internal/generate_hook_events_handlers.go new file mode 100644 index 0000000..f9da0f4 --- /dev/null +++ b/seanime-2.9.10/codegen/internal/generate_hook_events_handlers.go @@ -0,0 +1,146 @@ +package codegen + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// GenerateHandlerHookEvents generates hook_events.go file for handlers +func GenerateHandlerHookEvents(handlersJsonPath string, outputDir string) { + // Create output directory if it doesn't exist + err := os.MkdirAll(outputDir, os.ModePerm) + if err != nil { + panic(err) + } + + // Read handlers.json + handlersJson, err := os.ReadFile(handlersJsonPath) + if err != nil { + panic(err) + } + + // Parse handlers.json + var handlers []RouteHandler + err = json.Unmarshal(handlersJson, &handlers) + if err != nil { + panic(err) + } + + // Create hook_events.go file + outFilePath := filepath.Join(outputDir, "hook_events.go") + f, err := os.Create(outFilePath) + if err != nil { + panic(err) + } + defer f.Close() + + // Write package declaration and imports + f.WriteString("package handlers\n\n") + f.WriteString("import (\n") + //f.WriteString("\t\"seanime/internal/hook_resolver\"\n") + + imports := []string{ + "\"seanime/internal/api/anilist\"", + "\"seanime/internal/api/tvdb\"", + "\"seanime/internal/continuity\"", + "\"seanime/internal/database/models\"", + "\"seanime/internal/debrid/client\"", + "\"seanime/internal/debrid/debrid\"", + "\"seanime/internal/extension\"", + "hibikemanga \"seanime/internal/extension/hibike/manga\"", + "hibikeonlinestream \"seanime/internal/extension/hibike/onlinestream\"", + "hibiketorrent \"seanime/internal/extension/hibike/torrent\"", + "\"seanime/internal/extension_playground\"", + "\"seanime/internal/extension_repo\"", + "\"seanime/internal/hook_resolver\"", + "\"seanime/internal/library/anime\"", + "\"seanime/internal/library/summary\"", + "\"seanime/internal/manga\"", + "\"seanime/internal/manga/downloader\"", + "\"seanime/internal/mediastream\"", + "\"seanime/internal/onlinestream\"", + "\"seanime/internal/report\"", + "\"seanime/internal/sync\"", + "\"seanime/internal/torrent_clients/torrent_client\"", + "\"seanime/internal/torrents/torrent\"", + "\"seanime/internal/torrentstream\"", + "\"seanime/internal/updater\"", + } + + for _, imp := range imports { + f.WriteString("\t" + imp + "\n") + } + + f.WriteString(")\n\n") + + // Generate events for each handler + for _, handler := range handlers { + // Skip if handler name is empty or doesn't start with 'Handle' + if handler.Name == "" || !strings.HasPrefix(handler.Name, "Handle") { + continue + } + + // Generate the "Requested" event + f.WriteString(fmt.Sprintf("// %sRequestedEvent is triggered when %s is requested.\n", handler.Name, handler.TrimmedName)) + f.WriteString("// Prevent default to skip the default behavior and return your own data.\n") + f.WriteString(fmt.Sprintf("type %sRequestedEvent struct {\n", handler.Name)) + f.WriteString("\thook_resolver.Event\n") + + // Add path parameters + for _, param := range handler.Api.Params { + f.WriteString(fmt.Sprintf("\t%s %s `json:\"%s\"`\n", pascalCase(param.Name), param.GoType, param.JsonName)) + } + + // Add body fields + for _, field := range handler.Api.BodyFields { + goType := field.GoType + if goType == "__STRUCT__" || goType == "[]__STRUCT__" || (strings.HasPrefix(goType, "map[") && strings.Contains(goType, "__STRUCT__")) { + goType = field.InlineStructType + } + goType = strings.Replace(goType, "handlers.", "", 1) + addPointer := isCustomStruct(goType) + if addPointer { + goType = "*" + goType + } + f.WriteString(fmt.Sprintf("\t%s %s `json:\"%s\"`\n", pascalCase(field.Name), goType, field.JsonName)) + } + + // If handler returns something other than bool or true, add a Data field to store the result + if handler.Api.ReturnGoType != "" && handler.Api.ReturnGoType != "true" && handler.Api.ReturnGoType != "bool" { + returnGoType := strings.Replace(handler.Api.ReturnGoType, "handlers.", "", 1) + addPointer := isCustomStruct(returnGoType) + if addPointer { + returnGoType = "*" + returnGoType + } + f.WriteString(fmt.Sprintf("\t// Empty data object, will be used if the hook prevents the default behavior\n")) + f.WriteString(fmt.Sprintf("\tData %s `json:\"data\"`\n", returnGoType)) + } + + f.WriteString("}\n\n") + + // Generate the response event if handler returns something other than bool or true + if handler.Api.ReturnGoType != "" && handler.Api.ReturnGoType != "true" && handler.Api.ReturnGoType != "bool" { + returnGoType := strings.Replace(handler.Api.ReturnGoType, "handlers.", "", 1) + addPointer := isCustomStruct(returnGoType) + if addPointer { + returnGoType = "*" + returnGoType + } + f.WriteString(fmt.Sprintf("// %sEvent is triggered after processing %s.\n", handler.Name, handler.TrimmedName)) + f.WriteString(fmt.Sprintf("type %sEvent struct {\n", handler.Name)) + f.WriteString("\thook_resolver.Event\n") + f.WriteString(fmt.Sprintf("\tData %s `json:\"data\"`\n", returnGoType)) + f.WriteString("}\n\n") + } + } + + cmd := exec.Command("gofmt", "-w", outFilePath) + cmd.Run() +} + +func pascalCase(s string) string { + return strings.ReplaceAll(strings.Title(strings.ReplaceAll(s, "_", " ")), " ", "") +} diff --git a/seanime-2.9.10/codegen/internal/generate_plugin_events.go b/seanime-2.9.10/codegen/internal/generate_plugin_events.go new file mode 100644 index 0000000..e3bde73 --- /dev/null +++ b/seanime-2.9.10/codegen/internal/generate_plugin_events.go @@ -0,0 +1,797 @@ +package codegen + +import ( + "cmp" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "slices" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var ( + additionalStructNamesForHooks = []string{ + "discordrpc_presence.MangaActivity", + "discordrpc_presence.AnimeActivity", + "discordrpc_presence.LegacyAnimeActivity", + "anilist.ListAnime", + "anilist.ListManga", + "anilist.MediaSort", + "anilist.ListRecentAnime", + "anilist.AnimeCollectionWithRelations", + "onlinestream.Episode", + "continuity.WatchHistoryItem", + "continuity.WatchHistoryItemResponse", + "continuity.UpdateWatchHistoryItemOptions", + "continuity.WatchHistory", + "torrent_client.Torrent", + } +) + +func GeneratePluginEventFile(inFilePath string, outDir string) { + // Parse the input file + file, err := parser.ParseFile(token.NewFileSet(), inFilePath, nil, parser.ParseComments) + if err != nil { + panic(err) + } + + // Create output directory if it doesn't exist + _ = os.MkdirAll(outDir, os.ModePerm) + + const OutFileName = "plugin-events.ts" + + // Create output file + f, err := os.Create(filepath.Join(outDir, OutFileName)) + if err != nil { + panic(err) + } + defer f.Close() + + // Write imports + f.WriteString(`// This file is auto-generated. Do not edit. + import { useWebsocketPluginMessageListener, useWebsocketSender } from "@/app/(main)/_hooks/handle-websockets" + import { useCallback } from "react" + +`) + + // Extract client and server event types + clientEvents := make([]string, 0) + serverEvents := make([]string, 0) + clientPayloads := make(map[string]string) + serverPayloads := make(map[string]string) + clientEventValues := make(map[string]string) + serverEventValues := make(map[string]string) + + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + + // Find const declarations + if genDecl.Tok == token.CONST { + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + if len(valueSpec.Names) == 1 && len(valueSpec.Values) == 1 { + name := valueSpec.Names[0].Name + if strings.HasPrefix(name, "Client") && strings.HasSuffix(name, "Event") { + eventName := name[len("Client") : len(name)-len("Event")] + // Get the string literal value for the enum + if basicLit, ok := valueSpec.Values[0].(*ast.BasicLit); ok { + eventValue := strings.Trim(basicLit.Value, "\"") + clientEvents = append(clientEvents, eventName) + // Get payload type name + payloadType := name + "Payload" + clientPayloads[eventName] = payloadType + // Store the original string value + clientEventValues[eventName] = eventValue + } + } else if strings.HasPrefix(name, "Server") && strings.HasSuffix(name, "Event") { + eventName := name[len("Server") : len(name)-len("Event")] + // Get the string literal value for the enum + if basicLit, ok := valueSpec.Values[0].(*ast.BasicLit); ok { + eventValue := strings.Trim(basicLit.Value, "\"") + serverEvents = append(serverEvents, eventName) + // Get payload type name + payloadType := name + "Payload" + serverPayloads[eventName] = payloadType + // Store the original string value + serverEventValues[eventName] = eventValue + } + } + } + } + } + } + + // Write enums + f.WriteString("export enum PluginClientEvents {\n") + for _, event := range clientEvents { + enumName := toPascalCase(event) + f.WriteString(fmt.Sprintf(" %s = \"%s\",\n", enumName, clientEventValues[event])) + } + f.WriteString("}\n\n") + + f.WriteString("export enum PluginServerEvents {\n") + for _, event := range serverEvents { + enumName := toPascalCase(event) + f.WriteString(fmt.Sprintf(" %s = \"%s\",\n", enumName, serverEventValues[event])) + } + f.WriteString("}\n\n") + + // Write client to server section + f.WriteString("/////////////////////////////////////////////////////////////////////////////////////\n") + f.WriteString("// Client to server\n") + f.WriteString("/////////////////////////////////////////////////////////////////////////////////////\n\n") + + // Write client event types and hooks + for _, event := range clientEvents { + // Get the payload type + payloadType := clientPayloads[event] + payloadFound := false + + // Find the payload type in the AST + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + + if genDecl.Tok == token.TYPE { + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + if typeSpec.Name.Name == payloadType { + payloadFound = true + // Write the payload type + f.WriteString(fmt.Sprintf("export type Plugin_Client_%sEventPayload = {\n", toPascalCase(event))) + + if structType, ok := typeSpec.Type.(*ast.StructType); ok { + for _, field := range structType.Fields.List { + if len(field.Names) > 0 { + fieldName := jsonFieldName(field) + fieldType := fieldTypeToTypescriptType(field.Type, "") + f.WriteString(fmt.Sprintf(" %s: %s\n", fieldName, fieldType)) + } + } + } + + f.WriteString("}\n\n") + + // Write the hook + hookName := fmt.Sprintf("usePluginSend%sEvent", toPascalCase(event)) + f.WriteString(fmt.Sprintf("export function %s() {\n", hookName)) + f.WriteString(" const { sendPluginMessage } = useWebsocketSender()\n") + f.WriteString("\n") + f.WriteString(fmt.Sprintf(" const send%sEvent = useCallback((payload: Plugin_Client_%sEventPayload, extensionID?: string) => {\n", + toPascalCase(event), toPascalCase(event))) + f.WriteString(fmt.Sprintf(" sendPluginMessage(PluginClientEvents.%s, payload, extensionID)\n", + toPascalCase(event))) + f.WriteString(" }, [])\n") + f.WriteString("\n") + f.WriteString(" return {\n") + f.WriteString(fmt.Sprintf(" send%sEvent,\n", toPascalCase(event))) + f.WriteString(" }\n") + f.WriteString("}\n\n") + } + } + } + } + + // If payload type not found, write empty object type + if !payloadFound { + f.WriteString(fmt.Sprintf("export type Plugin_Client_%sEventPayload = {}\n\n", toPascalCase(event))) + + // Write the hook + hookName := fmt.Sprintf("usePluginSend%sEvent", toPascalCase(event)) + f.WriteString(fmt.Sprintf("export function %s() {\n", hookName)) + f.WriteString(" const { sendPluginMessage } = useWebsocketSender()\n") + f.WriteString("\n") + f.WriteString(fmt.Sprintf(" const sendPlugin%sEvent = useCallback((payload: Plugin_Client_%sEventPayload, extensionID?: string) => {\n", + toPascalCase(event), toPascalCase(event))) + f.WriteString(fmt.Sprintf(" sendPluginMessage(PluginClientEvents.%s, payload, extensionID)\n", + toPascalCase(event))) + f.WriteString(" }, [])\n") + f.WriteString("\n") + f.WriteString(" return {\n") + f.WriteString(fmt.Sprintf(" send%sEvent,\n", toPascalCase(event))) + f.WriteString(" }\n") + f.WriteString("}\n\n") + } + } + + // Write server to client section + f.WriteString("/////////////////////////////////////////////////////////////////////////////////////\n") + f.WriteString("// Server to client\n") + f.WriteString("/////////////////////////////////////////////////////////////////////////////////////\n\n") + + // Write server event types and hooks + for _, event := range serverEvents { + // Get the payload type + payloadType := serverPayloads[event] + payloadFound := false + + // Find the payload type in the AST + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + + if genDecl.Tok == token.TYPE { + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + if typeSpec.Name.Name == payloadType { + payloadFound = true + // Write the payload type + f.WriteString(fmt.Sprintf("export type Plugin_Server_%sEventPayload = {\n", toPascalCase(event))) + + if structType, ok := typeSpec.Type.(*ast.StructType); ok { + for _, field := range structType.Fields.List { + if len(field.Names) > 0 { + fieldName := jsonFieldName(field) + fieldType := fieldTypeToTypescriptType(field.Type, "") + f.WriteString(fmt.Sprintf(" %s: %s\n", fieldName, fieldType)) + } + } + } + + f.WriteString("}\n\n") + + // Write the hook + hookName := fmt.Sprintf("usePluginListen%sEvent", toPascalCase(event)) + f.WriteString(fmt.Sprintf("export function %s(cb: (payload: Plugin_Server_%sEventPayload, extensionId: string) => void, extensionID: string) {\n", + hookName, toPascalCase(event))) + f.WriteString(" return useWebsocketPluginMessageListener({\n") + f.WriteString(" extensionId: extensionID,\n") + f.WriteString(fmt.Sprintf(" type: PluginServerEvents.%s,\n", toPascalCase(event))) + f.WriteString(" onMessage: cb,\n") + f.WriteString(" })\n") + f.WriteString("}\n\n") + } + } + } + } + + // If payload type not found, write empty object type + if !payloadFound { + f.WriteString(fmt.Sprintf("export type Plugin_Server_%sEventPayload = {}\n\n", toPascalCase(event))) + + // Write the hook + hookName := fmt.Sprintf("usePluginListen%sEvent", toPascalCase(event)) + f.WriteString(fmt.Sprintf("export function %s(cb: (payload: Plugin_Server_%sEventPayload, extensionId: string) => void, extensionID: string) {\n", + hookName, toPascalCase(event))) + f.WriteString(" return useWebsocketPluginMessageListener({\n") + f.WriteString(" extensionId: extensionID,\n") + f.WriteString(fmt.Sprintf(" type: PluginServerEvents.%s,\n", toPascalCase(event))) + f.WriteString(" onMessage: cb,\n") + f.WriteString(" })\n") + f.WriteString("}\n\n") + } + } +} + +var execptions = map[string]string{ + "playbackmanager": "PlaybackManager ", +} + +func toPascalCase(s string) string { + if exception, ok := execptions[s]; ok { + return exception + } + s = strings.ReplaceAll(s, "-", " ") + s = strings.ReplaceAll(s, "_", " ") + s = cases.Title(language.English, cases.NoLower).String(s) + return strings.ReplaceAll(s, " ", "") +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type HookEventDefinition struct { + Package string `json:"package"` + GoStruct *GoStruct `json:"goStruct"` +} + +func GeneratePluginHooksDefinitionFile(outDir string, publicStructsFilePath string, genOutDir string) { + // Create output file + f, err := os.Create(filepath.Join(outDir, "app.d.ts")) + if err != nil { + panic(err) + } + defer f.Close() + + mdFile, err := os.Create(filepath.Join(genOutDir, "hooks.mdx")) + if err != nil { + panic(err) + } + defer mdFile.Close() + + goStructs := LoadPublicStructs(publicStructsFilePath) + + // e.g. map["models.User"]*GoStruct + goStructsMap := make(map[string]*GoStruct) + + for _, goStruct := range goStructs { + goStructsMap[goStruct.Package+"."+goStruct.Name] = goStruct + } + + // Expand the structs with embedded structs + for _, goStruct := range goStructs { + for _, embeddedStructType := range goStruct.EmbeddedStructTypes { + if embeddedStructType != "" { + if usedStruct, ok := goStructsMap[embeddedStructType]; ok { + for _, usedField := range usedStruct.Fields { + goStruct.Fields = append(goStruct.Fields, usedField) + } + } + } + } + } + + // Key = package + eventGoStructsMap := make(map[string][]*GoStruct) + for _, goStruct := range goStructs { + if goStruct.Filename == "hook_events.go" { + if _, ok := eventGoStructsMap[goStruct.Package]; !ok { + eventGoStructsMap[goStruct.Package] = make([]*GoStruct, 0) + } + eventGoStructsMap[goStruct.Package] = append(eventGoStructsMap[goStruct.Package], goStruct) + } + } + + // Create `hooks.json` + hookEventDefinitions := make([]*HookEventDefinition, 0) + for _, goStruct := range goStructs { + if goStruct.Filename == "hook_events.go" { + hookEventDefinitions = append(hookEventDefinitions, &HookEventDefinition{ + Package: goStruct.Package, + GoStruct: goStruct, + }) + } + } + jsonFile, err := os.Create(filepath.Join(genOutDir, "hooks.json")) + if err != nil { + panic(err) + } + defer jsonFile.Close() + encoder := json.NewEncoder(jsonFile) + encoder.SetIndent("", " ") + if err := encoder.Encode(hookEventDefinitions); err != nil { + fmt.Println("Error:", err) + return + } + + //////////////////////////////////////////////////// + // Write `app.d.ts` + // Write namespace declaration + //////////////////////////////////////////////////// + f.WriteString("declare namespace $app {\n") + + packageNames := make([]string, 0) + for packageName := range eventGoStructsMap { + packageNames = append(packageNames, packageName) + } + slices.Sort(packageNames) + + ////////////////////////////////////////////////////////// + // Get referenced structs so we can write them at the end + ////////////////////////////////////////////////////////// + sharedStructs := make([]*GoStruct, 0) + otherStructs := make([]*GoStruct, 0) + + // Go through all the event structs' fields, and get the types that are structs + sharedStructsMap := make(map[string]*GoStruct) + for _, goStructs := range eventGoStructsMap { + for _, goStruct := range goStructs { + for _, field := range goStruct.Fields { + if isCustomStruct(field.GoType) { + if _, ok := sharedStructsMap[field.GoType]; !ok && goStructsMap[field.UsedStructType] != nil { + sharedStructsMap[field.UsedStructType] = goStructsMap[field.UsedStructType] + } + } + } + } + } + + // Add additional structs to otherStructs + for _, structName := range additionalStructNamesForHooks { + if _, ok := sharedStructsMap[structName]; !ok { + sharedStructsMap[structName] = goStructsMap[structName] + } + } + + for _, goStruct := range sharedStructsMap { + //fmt.Println(goStruct.FormattedName) + if goStruct.Package != "" { + sharedStructs = append(sharedStructs, goStruct) + } + } + + referencedStructsMap, ok := getReferencedStructsRecursively(sharedStructs, otherStructs, goStructsMap) + if !ok { + panic("Failed to get referenced structs") + } + + for _, packageName := range packageNames { + writePackageEventGoStructs(f, packageName, eventGoStructsMap[packageName], goStructsMap) + } + + f.WriteString(" ///////////////////////////////////////////////////////////////////////////////////////////////////////////////\n") + f.WriteString(" ///////////////////////////////////////////////////////////////////////////////////////////////////////////////\n") + f.WriteString(" ///////////////////////////////////////////////////////////////////////////////////////////////////////////////\n\n") + + referencedStructs := make([]*GoStruct, 0) + for _, goStruct := range referencedStructsMap { + //fmt.Println(goStruct.FormattedName) + referencedStructs = append(referencedStructs, goStruct) + } + slices.SortFunc(referencedStructs, func(a, b *GoStruct) int { + return strings.Compare(a.FormattedName, b.FormattedName) + }) + + // Write the shared structs at the end + for _, goStruct := range referencedStructs { + if goStruct.Package != "" { + writeEventTypescriptType(f, goStruct, make(map[string]*GoStruct)) + } + } + + f.WriteString("}\n") + + // Generate markdown documentation + writeMarkdownFile(mdFile, hookEventDefinitions, referencedStructsMap, referencedStructs) + +} + +func writePackageEventGoStructs(f *os.File, packageName string, goStructs []*GoStruct, allGoStructs map[string]*GoStruct) { + // Header comment block + f.WriteString(fmt.Sprintf("\n /**\n * @package %s\n */\n\n", packageName)) + + // Declare the hook functions + for _, goStruct := range goStructs { + // Write comments + comments := "" + comments += fmt.Sprintf("\n * @event %s\n", goStruct.Name) + comments += fmt.Sprintf(" * @file %s\n", strings.TrimPrefix(goStruct.Filepath, "../")) + + shouldAddPreventDefault := false + + if len(goStruct.Comments) > 0 { + comments += fmt.Sprintf(" * @description\n") + } + for _, comment := range goStruct.Comments { + if strings.Contains(strings.ToLower(comment), "prevent default") { + shouldAddPreventDefault = true + } + comments += fmt.Sprintf(" * %s\n", strings.TrimSpace(comment)) + } + f.WriteString(fmt.Sprintf(" /**%s */\n", comments)) + + //////// Write hook function + f.WriteString(fmt.Sprintf(" function on%s(cb: (event: %s) => void): void;\n\n", strings.TrimSuffix(goStruct.Name, "Event"), goStruct.Name)) + + /////// Write event interface + f.WriteString(fmt.Sprintf(" interface %s {\n", goStruct.Name)) + f.WriteString(fmt.Sprintf(" next(): void;\n\n")) + if shouldAddPreventDefault { + f.WriteString(fmt.Sprintf(" preventDefault(): void;\n\n")) + } + // Write the fields + for _, field := range goStruct.Fields { + if field.Name == "next" || field.Name == "preventDefault" || field.Name == "DefaultPrevented" { + continue + } + if field.JsonName == "" { + continue + } + // Field type + fieldNameSuffix := "" + if !field.Required { + fieldNameSuffix = "?" + } + + if len(field.Comments) > 0 { + f.WriteString(fmt.Sprintf(" /**\n")) + for _, cmt := range field.Comments { + f.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(cmt))) + } + f.WriteString(fmt.Sprintf(" */\n")) + } + + typeText := field.TypescriptType + + f.WriteString(fmt.Sprintf(" %s%s: %s;\n", field.JsonName, fieldNameSuffix, typeText)) + } + f.WriteString(fmt.Sprintf(" }\n\n")) + + } +} + +func writeEventTypescriptType(f *os.File, goStruct *GoStruct, writtenTypes map[string]*GoStruct) { + f.WriteString(" /**\n") + f.WriteString(fmt.Sprintf(" * - Filepath: %s\n", strings.TrimPrefix(goStruct.Filepath, "../"))) + if len(goStruct.Comments) > 0 { + f.WriteString(fmt.Sprintf(" * @description\n")) + for _, cmt := range goStruct.Comments { + f.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(cmt))) + } + } + f.WriteString(" */\n") + + if len(goStruct.Fields) > 0 { + f.WriteString(fmt.Sprintf(" interface %s {\n", goStruct.FormattedName)) + for _, field := range goStruct.Fields { + fieldNameSuffix := "" + if !field.Required { + fieldNameSuffix = "?" + } + if field.JsonName == "" { + continue + } + + if len(field.Comments) > 0 { + f.WriteString(fmt.Sprintf(" /**\n")) + for _, cmt := range field.Comments { + f.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(cmt))) + } + f.WriteString(fmt.Sprintf(" */\n")) + } + + typeText := field.TypescriptType + if typeText == "Habari_Metadata" { + typeText = "$habari.Metadata" + } + + f.WriteString(fmt.Sprintf(" %s%s: %s;\n", field.JsonName, fieldNameSuffix, typeText)) + } + f.WriteString(" }\n\n") + } + + if goStruct.AliasOf != nil { + if goStruct.AliasOf.DeclaredValues != nil && len(goStruct.AliasOf.DeclaredValues) > 0 { + union := "" + if len(goStruct.AliasOf.DeclaredValues) > 5 { + union = strings.Join(goStruct.AliasOf.DeclaredValues, " |\n ") + } else { + union = strings.Join(goStruct.AliasOf.DeclaredValues, " | ") + } + f.WriteString(fmt.Sprintf(" export type %s = %s;\n\n", goStruct.FormattedName, union)) + } else { + f.WriteString(fmt.Sprintf(" export type %s = %s;\n\n", goStruct.FormattedName, goStruct.AliasOf.TypescriptType)) + } + } + + // Add the struct to the written types + writtenTypes[goStruct.Package+"."+goStruct.Name] = goStruct +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// writeMarkdownFile generates a well-formatted Markdown documentation for hooks +func writeMarkdownFile(mdFile *os.File, hookEventDefinitions []*HookEventDefinition, referencedStructsMap map[string]*GoStruct, referencedStructs []*GoStruct) { + + mdFile.WriteString("---\n") + mdFile.WriteString("title: Hooks\n") + mdFile.WriteString("description: How to use hooks\n") + mdFile.WriteString("---") + mdFile.WriteString("\n\n") + + // Group hooks by package + packageHooks := make(map[string][]*HookEventDefinition) + for _, hook := range hookEventDefinitions { + packageHooks[hook.Package] = append(packageHooks[hook.Package], hook) + } + + // Sort packages alphabetically + packageNames := make([]string, 0, len(packageHooks)) + for pkg := range packageHooks { + packageNames = append(packageNames, pkg) + } + slices.Sort(packageNames) + + // Write each package section + for _, pkg := range packageNames { + hooks := packageHooks[pkg] + + mdFile.WriteString(fmt.Sprintf("\n", pkg)) + mdFile.WriteString(fmt.Sprintf("# %s\n\n", toPascalCase(pkg))) + + // Write each hook in the package + for _, hook := range hooks { + goStruct := hook.GoStruct + eventName := goStruct.Name + hookName := fmt.Sprintf("on%s", strings.TrimSuffix(eventName, "Event")) + + mdFile.WriteString(fmt.Sprintf("\n", strings.ToLower(strings.TrimSuffix(eventName, "Event")))) + mdFile.WriteString(fmt.Sprintf("## %s\n\n", hookName)) + + // Write description + if len(goStruct.Comments) > 0 { + for _, comment := range goStruct.Comments { + mdFile.WriteString(fmt.Sprintf("%s\n", strings.TrimSpace(comment))) + } + mdFile.WriteString("\n") + } + + // Check if it has preventDefault + hasPreventDefault := false + for _, comment := range goStruct.Comments { + if strings.Contains(strings.ToLower(comment), "prevent default") { + hasPreventDefault = true + break + } + } + + if hasPreventDefault { + mdFile.WriteString("**Can prevent default:** Yes\n\n") + } else { + mdFile.WriteString("**Can prevent default:** No\n\n") + } + + // Write event interface + mdFile.WriteString("**Event Interface:**\n\n") + mdFile.WriteString("```typescript\n") + mdFile.WriteString(fmt.Sprintf("interface %s {\n", eventName)) + mdFile.WriteString(" next();\n") + if hasPreventDefault { + mdFile.WriteString(" preventDefault();\n") + } + + // Write fields + for _, field := range goStruct.Fields { + if field.Name == "next" || field.Name == "preventDefault" || field.Name == "DefaultPrevented" { + continue + } + if field.JsonName == "" { + continue + } + + fieldNameSuffix := "" + if !field.Required { + fieldNameSuffix = "?" + } + + // Add comments if available + if len(field.Comments) > 0 { + mdFile.WriteString("\n /**\n") + for _, comment := range field.Comments { + mdFile.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(comment))) + } + mdFile.WriteString(" */\n") + } + + mdFile.WriteString(fmt.Sprintf(" %s%s: %s;\n", field.JsonName, fieldNameSuffix, field.TypescriptType)) + } + + mdFile.WriteString("}\n") + mdFile.WriteString("```\n\n") + + referenced := make([]*GoStruct, 0) + for _, field := range goStruct.Fields { + if !isCustomStruct(field.GoType) { + continue + } + goStruct, ok := referencedStructsMap[field.UsedStructType] + if !ok { + continue + } + referenced = append(referenced, goStruct) + } + + // Add a list of referenced structs links + if len(referenced) > 0 { + mdFile.WriteString("**Event types:**\n\n") + } + for _, goStruct := range referenced { + mdFile.WriteString(fmt.Sprintf("- [%s](#%s)\n", goStruct.FormattedName, goStruct.FormattedName)) + } + mdFile.WriteString("\n") + + // Add example usage + mdFile.WriteString("**Example:**\n\n") + mdFile.WriteString("```typescript\n") + mdFile.WriteString(fmt.Sprintf("$app.%s((e) => {\n", hookName)) + + // Generate example code based on fields + for _, field := range goStruct.Fields { + if field.Name == "next" || field.Name == "preventDefault" || field.Name == "DefaultPrevented" { + continue + } + + mdFile.WriteString(fmt.Sprintf(" // console.log(e.%s);\n", field.JsonName)) + } + + if hasPreventDefault { + mdFile.WriteString("\n // Prevent default behavior if needed\n") + mdFile.WriteString(" // e.preventDefault();\n") + } + + mdFile.WriteString(" \n e.next();\n") + mdFile.WriteString("});\n") + mdFile.WriteString("```\n\n") + + // Add separator between hooks + mdFile.WriteString("---\n\n") + } + } + + // Write the referenced structs + if len(referencedStructs) > 0 { + mdFile.WriteString("\n# Referenced Types\n\n") + } + for _, goStruct := range referencedStructs { + + mdFile.WriteString(fmt.Sprintf("#### %s\n\n", goStruct.FormattedName)) + mdFile.WriteString(fmt.Sprintf("
\n\n", goStruct.FormattedName)) + mdFile.WriteString(fmt.Sprintf("**Filepath:** `%s`\n\n", strings.TrimPrefix(goStruct.Filepath, "../"))) + + if len(goStruct.Fields) > 0 { + mdFile.WriteString("**Fields:**\n\n") + + mdFile.WriteString("\n") + mdFile.WriteString("Fields\n") + mdFile.WriteString("\n") + mdFile.WriteString("\n") + mdFile.WriteString("Property\n") + mdFile.WriteString("Type\n") + mdFile.WriteString("Description\n") + mdFile.WriteString("\n") + mdFile.WriteString("\n") + mdFile.WriteString("\n") + for _, field := range goStruct.Fields { + mdFile.WriteString(fmt.Sprintf("\n")) + mdFile.WriteString(fmt.Sprintf("%s\n", field.JsonName)) + + typeContainsReference := false + if field.UsedStructType != "" && isCustomStruct(field.UsedStructType) { + typeContainsReference = true + } + if typeContainsReference { + link := fmt.Sprintf("`%s`", field.UsedTypescriptType, field.TypescriptType) + mdFile.WriteString(fmt.Sprintf("%s\n", link)) + } else { + mdFile.WriteString(fmt.Sprintf("`%s`\n", field.TypescriptType)) + } + mdFile.WriteString(fmt.Sprintf("%s\n", cmp.Or(strings.Join(field.Comments, "\n"), "-"))) + mdFile.WriteString("\n") + } + mdFile.WriteString("\n") + mdFile.WriteString("
\n") + + } + + if goStruct.AliasOf != nil { + if goStruct.AliasOf.DeclaredValues != nil && len(goStruct.AliasOf.DeclaredValues) > 0 { + union := "" + if len(goStruct.AliasOf.DeclaredValues) > 5 { + union = strings.Join(goStruct.AliasOf.DeclaredValues, " |\n ") + } else { + union = strings.Join(goStruct.AliasOf.DeclaredValues, " | ") + } + mdFile.WriteString(fmt.Sprintf("`%s`\n\n", union)) + } else { + mdFile.WriteString(fmt.Sprintf("`%s`\n\n", goStruct.AliasOf.TypescriptType)) + } + } + + mdFile.WriteString("\n") + } +} diff --git a/seanime-2.9.10/codegen/internal/generate_structs.go b/seanime-2.9.10/codegen/internal/generate_structs.go new file mode 100644 index 0000000..56e2851 --- /dev/null +++ b/seanime-2.9.10/codegen/internal/generate_structs.go @@ -0,0 +1,810 @@ +package codegen + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "strings" + "unicode" +) + +type GoStruct struct { + Filepath string `json:"filepath"` + Filename string `json:"filename"` + Name string `json:"name"` + FormattedName string `json:"formattedName"` // name with package prefix e.g. models.User => Models_User + Package string `json:"package"` + Fields []*GoStructField `json:"fields"` + AliasOf *GoAlias `json:"aliasOf,omitempty"` + Comments []string `json:"comments"` + EmbeddedStructTypes []string `json:"embeddedStructNames,omitempty"` +} + +type GoAlias struct { + GoType string `json:"goType"` + TypescriptType string `json:"typescriptType"` + UsedTypescriptType string `json:"usedTypescriptType,omitempty"` + DeclaredValues []string `json:"declaredValues"` + UsedStructType string `json:"usedStructName,omitempty"` +} + +type GoStructField struct { + Name string `json:"name"` + JsonName string `json:"jsonName"` + // e.g. map[string]models.User + GoType string `json:"goType"` + // e.g. []struct{Test string `json:"test"`, Test2 string `json:"test2"`} + InlineStructType string `json:"inlineStructType,omitempty"` + // e.g. User + TypescriptType string `json:"typescriptType"` + // e.g. TypescriptType = Array => UsedTypescriptType = Models_User + UsedTypescriptType string `json:"usedTypescriptType,omitempty"` + // e.g. GoType = map[string]models.User => TypescriptType = User => UsedStructType = models.User + UsedStructType string `json:"usedStructName,omitempty"` + // If no 'omitempty' and not a pointer + Required bool `json:"required"` + Public bool `json:"public"` + Comments []string `json:"comments"` +} + +var typePrefixesByPackage = map[string]string{ + "anilist": "AL_", + "auto_downloader": "AutoDownloader_", + "autodownloader": "AutoDownloader_", + "entities": "", + "db": "DB_", + "db_bridge": "DB_", + "models": "Models_", + "playbackmanager": "PlaybackManager_", + "torrent_client": "TorrentClient_", + "events": "Events_", + "torrent": "Torrent_", + "manga": "Manga_", + "autoscanner": "AutoScanner_", + "listsync": "ListSync_", + "util": "Util_", + "scanner": "Scanner_", + "offline": "Offline_", + "discordrpc": "DiscordRPC_", + "discordrpc_presence": "DiscordRPC_", + "anizip": "Anizip_", + "animap": "Animap_", + "onlinestream": "Onlinestream_", + "onlinestream_providers": "Onlinestream_", + "onlinestream_sources": "Onlinestream_", + "manga_providers": "Manga_", + "chapter_downloader": "ChapterDownloader_", + "manga_downloader": "MangaDownloader_", + "docs": "INTERNAL_", + "tvdb": "TVDB_", + "metadata": "Metadata_", + "mappings": "Mappings_", + "mal": "MAL_", + "handlers": "", + "animetosho": "AnimeTosho_", + "updater": "Updater_", + "anime": "Anime_", + "anime_types": "Anime_", + "summary": "Summary_", + "filesystem": "Filesystem_", + "filecache": "Filecache_", + "core": "INTERNAL_", + "comparison": "Comparison_", + "mediastream": "Mediastream_", + "torrentstream": "Torrentstream_", + "extension": "Extension_", + "extension_repo": "ExtensionRepo_", + //"vendor_hibike_manga": "HibikeManga_", + //"vendor_hibike_onlinestream": "HibikeOnlinestream_", + //"vendor_hibike_torrent": "HibikeTorrent_", + //"vendor_hibike_mediaplayer": "HibikeMediaPlayer_", + //"vendor_hibike_extension": "HibikeExtension_", + "hibikemanga": "HibikeManga_", + "hibikeonlinestream": "HibikeOnlinestream_", + "hibiketorrent": "HibikeTorrent_", + "hibikemediaplayer": "HibikeMediaPlayer_", + "hibikeextension": "HibikeExtension_", + "continuity": "Continuity_", + "local": "Local_", + "debrid": "Debrid_", + "debrid_client": "DebridClient_", + "report": "Report_", + "habari": "Habari_", + "vendor_habari": "Habari_", + "discordrpc_client": "DiscordRPC_", + "directstream": "Directstream_", + "nativeplayer": "NativePlayer_", + "mkvparser": "MKVParser_", + "nakama": "Nakama_", +} + +func getTypePrefix(packageName string) string { + if prefix, ok := typePrefixesByPackage[packageName]; ok { + return prefix + } + return "" +} + +func ExtractStructs(dir string, outDir string) { + + structs := make([]*GoStruct, 0) + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(info.Name(), ".go") { + res, err := getGoStructsFromFile(path, info) + if err != nil { + return err + } + structs = append(structs, res...) + } + return nil + }) + if err != nil { + fmt.Println("Error:", err) + return + } + + // Write structs to file + _ = os.MkdirAll(outDir, os.ModePerm) + file, err := os.Create(outDir + "/public_structs.json") + if err != nil { + fmt.Println("Error:", err) + return + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(structs); err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println("Public structs extracted and saved to public_structs.json") +} + +func getGoStructsFromFile(path string, info os.FileInfo) (structs []*GoStruct, err error) { + + // Parse the Go file + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ParseComments) + if err != nil { + return nil, err + } + packageName := file.Name.Name + + // Extract public structs + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + + // + // Go through each type declaration + // + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + if !typeSpec.Name.IsExported() { + continue + } + + // + // The type declaration is an alias + // e.g. alias.Name: string, typeSpec.Name.Name: MediaListStatus + // + alias, ok := typeSpec.Type.(*ast.Ident) + if ok { + + if alias.Name == typeSpec.Name.Name { + continue + } + goStruct := goStructFromAlias(path, info, genDecl, typeSpec, packageName, alias, file) + structs = append(structs, goStruct) + continue + } + + // + // The type declaration is a struct + // + structType, ok := typeSpec.Type.(*ast.StructType) + if ok { + + subStructs := make([]*GoStruct, 0) + for _, field := range structType.Fields.List { + if field.Names != nil && len(field.Names) > 0 { + + subStructType, ok := field.Type.(*ast.StructType) + if ok { + name := fmt.Sprintf("%s_%s", typeSpec.Name.Name, field.Names[0].Name) + subStruct := goStructFromStruct(path, info, genDecl, name, packageName, subStructType) + subStructs = append(subStructs, subStruct) + continue + } + + } + } + + goStruct := goStructFromStruct(path, info, genDecl, typeSpec.Name.Name, packageName, structType) + + // Replace struct fields with sub structs + for _, field := range goStruct.Fields { + if field.GoType == "__STRUCT__" { + for _, subStruct := range subStructs { + if subStruct.Name == fmt.Sprintf("%s_%s", typeSpec.Name.Name, field.Name) { + field.GoType = subStruct.FormattedName + field.TypescriptType = subStruct.FormattedName + field.UsedStructType = fmt.Sprintf("%s.%s", subStruct.Package, subStruct.Name) + break + } + } + } + } + + structs = append(structs, goStruct) + structs = append(structs, subStructs...) + continue + } + + mapType, ok := typeSpec.Type.(*ast.MapType) + if ok { + goStruct := &GoStruct{ + Filepath: path, + Filename: info.Name(), + Name: typeSpec.Name.Name, + FormattedName: getTypePrefix(packageName) + typeSpec.Name.Name, + Package: packageName, + Fields: make([]*GoStructField, 0), + } + + usedStructType, usedStructPkgName := getUsedStructType(mapType, packageName) + + goStruct.AliasOf = &GoAlias{ + GoType: fieldTypeString(mapType), + TypescriptType: fieldTypeToTypescriptType(mapType, usedStructPkgName), + UsedStructType: usedStructType, + } + + structs = append(structs, goStruct) + continue + } + + sliceType, ok := typeSpec.Type.(*ast.ArrayType) + if ok { + goStruct := &GoStruct{ + Filepath: path, + Filename: info.Name(), + Name: typeSpec.Name.Name, + FormattedName: getTypePrefix(packageName) + typeSpec.Name.Name, + Package: packageName, + Fields: make([]*GoStructField, 0), + } + + usedStructType, usedStructPkgName := getUsedStructType(sliceType, packageName) + + goStruct.AliasOf = &GoAlias{ + GoType: fieldTypeString(sliceType), + TypescriptType: fieldTypeToTypescriptType(sliceType, usedStructPkgName), + UsedStructType: usedStructType, + } + + structs = append(structs, goStruct) + continue + } + + } + } + return structs, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Example: +// +// type User struct { +// ID int `json:"id"` +// Name string `json:"name"` +// } +func goStructFromStruct(path string, info os.FileInfo, genDecl *ast.GenDecl, name string, packageName string, structType *ast.StructType) *GoStruct { + // Get comments + comments := make([]string, 0) + if genDecl.Doc != nil && genDecl.Doc.List != nil && len(genDecl.Doc.List) > 0 { + for _, comment := range genDecl.Doc.List { + comments = append(comments, strings.TrimPrefix(comment.Text, "//")) + } + } + + goStruct := &GoStruct{ + Filepath: filepath.ToSlash(path), + Filename: info.Name(), + Name: name, + FormattedName: getTypePrefix(packageName) + name, + Package: packageName, + Fields: make([]*GoStructField, 0), + EmbeddedStructTypes: make([]string, 0), + Comments: comments, + } + + // Get fields + for _, field := range structType.Fields.List { + if field.Names == nil || len(field.Names) == 0 { + if len(field.Names) == 0 { + switch field.Type.(type) { + case *ast.Ident, *ast.StarExpr, *ast.SelectorExpr: + usedStructType, _ := getUsedStructType(field.Type, packageName) + goStruct.EmbeddedStructTypes = append(goStruct.EmbeddedStructTypes, usedStructType) + } + } + continue + } + // Get fields comments + comments := make([]string, 0) + if field.Comment != nil && field.Comment.List != nil && len(field.Comment.List) > 0 { + for _, comment := range field.Comment.List { + comments = append(comments, strings.TrimPrefix(comment.Text, "//")) + } + } + + required := true + if field.Tag != nil { + tag := reflect.StructTag(field.Tag.Value[1 : len(field.Tag.Value)-1]) + jsonTag := tag.Get("json") + if jsonTag != "" { + jsonParts := strings.Split(jsonTag, ",") + if len(jsonParts) > 1 && jsonParts[1] == "omitempty" { + required = false + } + } + } + switch field.Type.(type) { + case *ast.StarExpr, *ast.ArrayType, *ast.MapType, *ast.SelectorExpr: + required = false + } + fieldName := field.Names[0].Name + + usedStructType, usedStructPkgName := getUsedStructType(field.Type, packageName) + + tsType := fieldTypeToTypescriptType(field.Type, usedStructPkgName) + + goStructField := &GoStructField{ + Name: fieldName, + JsonName: jsonFieldName(field), + GoType: fieldTypeString(field.Type), + TypescriptType: tsType, + UsedTypescriptType: fieldTypeToUsedTypescriptType(tsType), + Required: required, + Public: field.Names[0].IsExported(), + UsedStructType: usedStructType, + Comments: comments, + } + + // If it's an inline struct, capture the full definition as a string + if goStructField.GoType == "__STRUCT__" { + if structType, ok := field.Type.(*ast.StructType); ok { + goStructField.InlineStructType = formatInlineStruct(structType) + } + } else { + // Check if it's a slice of inline structs + if arrayType, ok := field.Type.(*ast.ArrayType); ok { + if structType, ok := arrayType.Elt.(*ast.StructType); ok { + goStructField.InlineStructType = "[]" + formatInlineStruct(structType) + } + } + // Check if it's a map with inline struct values + if mapType, ok := field.Type.(*ast.MapType); ok { + if structType, ok := mapType.Value.(*ast.StructType); ok { + goStructField.InlineStructType = "map[" + fieldTypeString(mapType.Key) + "]" + formatInlineStruct(structType) + } + } + } + + goStruct.Fields = append(goStruct.Fields, goStructField) + } + return goStruct +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func goStructFromAlias(path string, info os.FileInfo, genDecl *ast.GenDecl, typeSpec *ast.TypeSpec, packageName string, alias *ast.Ident, file *ast.File) *GoStruct { + // Get comments + comments := make([]string, 0) + if genDecl.Doc != nil && genDecl.Doc.List != nil && len(genDecl.Doc.List) > 0 { + for _, comment := range genDecl.Doc.List { + comments = append(comments, strings.TrimPrefix(comment.Text, "//")) + } + } + + usedStructType, usedStructPkgName := getUsedStructType(typeSpec.Type, packageName) + tsType := fieldTypeToTypescriptType(typeSpec.Type, usedStructPkgName) + + goStruct := &GoStruct{ + Filepath: filepath.ToSlash(path), + Filename: info.Name(), + Name: typeSpec.Name.Name, + Package: packageName, + FormattedName: getTypePrefix(packageName) + typeSpec.Name.Name, + Fields: make([]*GoStructField, 0), + Comments: comments, + AliasOf: &GoAlias{ + GoType: alias.Name, + TypescriptType: tsType, + UsedTypescriptType: fieldTypeToUsedTypescriptType(tsType), + UsedStructType: usedStructType, + }, + } + + // Get declared values - useful for building enums or union types + // e.g. const Something AliasType = "something" + goStruct.AliasOf.DeclaredValues = make([]string, 0) + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.CONST { + continue + } + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + valueSpecType := fieldTypeString(valueSpec.Type) + if len(valueSpec.Names) == 1 && valueSpec.Names[0].IsExported() && valueSpecType == typeSpec.Name.Name { + for _, value := range valueSpec.Values { + name, ok := value.(*ast.BasicLit) + if !ok { + continue + } + goStruct.AliasOf.DeclaredValues = append(goStruct.AliasOf.DeclaredValues, name.Value) + } + } + } + } + return goStruct +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// getUsedStructType returns the used struct type for a given type declaration. +// For example, if the type declaration is `map[string]models.User`, the used struct type is `models.User`. +// If the type declaration is `[]User`, the used struct type is `{packageName}.User`. +func getUsedStructType(expr ast.Expr, packageName string) (string, string) { + usedStructType := fieldTypeToUsedStructType(expr) + + switch usedStructType { + case "string", "bool", "byte", "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64": + return "", "" + case "__STRUCT__": + return "", "" + } + + if usedStructType != "__STRUCT__" && usedStructType != "" && !strings.Contains(usedStructType, ".") { + usedStructType = packageName + "." + usedStructType + } + + pkgName := strings.Split(usedStructType, ".")[0] + + return usedStructType, pkgName +} + +// fieldTypeString returns the field type as a string. +// For example, if the field type is `[]*models.User`, the return value is `[]models.User`. +// If the field type is `[]InternalStruct`, the return value is `[]InternalStruct`. +func fieldTypeString(fieldType ast.Expr) string { + switch t := fieldType.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + //return "*" + fieldTypeString(t.X) + return fieldTypeString(t.X) + case *ast.ArrayType: + if fieldTypeString(t.Elt) == "byte" { + return "string" + } + return "[]" + fieldTypeString(t.Elt) + case *ast.MapType: + return "map[" + fieldTypeString(t.Key) + "]" + fieldTypeString(t.Value) + case *ast.SelectorExpr: + return fieldTypeString(t.X) + "." + t.Sel.Name + case *ast.StructType: + return "__STRUCT__" + default: + return "" + } +} + +// fieldTypeToTypescriptType returns the field type as a string in TypeScript format. +// For example, if the field type is `[]*models.User`, the return value is `Array`. +func fieldTypeToTypescriptType(fieldType ast.Expr, usedStructPkgName string) string { + switch t := fieldType.(type) { + case *ast.Ident: + switch t.Name { + case "string": + return "string" + case "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64": + return "number" + case "bool": + return "boolean" + case "byte": + return "string" + case "time.Time": + return "string" + case "nil": + return "null" + default: + return getTypePrefix(usedStructPkgName) + t.Name + } + case *ast.StarExpr: + return fieldTypeToTypescriptType(t.X, usedStructPkgName) + case *ast.ArrayType: + if fieldTypeToTypescriptType(t.Elt, usedStructPkgName) == "byte" { + return "string" + } + return "Array<" + fieldTypeToTypescriptType(t.Elt, usedStructPkgName) + ">" + case *ast.MapType: + return "Record<" + fieldTypeToTypescriptType(t.Key, usedStructPkgName) + ", " + fieldTypeToTypescriptType(t.Value, usedStructPkgName) + ">" + case *ast.SelectorExpr: + if t.Sel.Name == "Time" { + return "string" + } + return getTypePrefix(usedStructPkgName) + t.Sel.Name + case *ast.StructType: + s := "{ " + for _, field := range t.Fields.List { + s += jsonFieldName(field) + ": " + fieldTypeToTypescriptType(field.Type, usedStructPkgName) + "; " + } + s += "}" + return s + default: + return "any" + } +} + +func stringGoTypeToTypescriptType(goType string) string { + switch goType { + case "string": + return "string" + case "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64": + return "number" + case "nil": + return "null" + case "bool": + return "boolean" + case "time.Time": + return "string" + } + + if strings.HasPrefix(goType, "[]") { + return "Array<" + stringGoTypeToTypescriptType(goType[2:]) + ">" + } + + if strings.HasPrefix(goType, "*") { + return stringGoTypeToTypescriptType(goType[1:]) + } + + if strings.HasPrefix(goType, "map[") { + s := strings.TrimPrefix(goType, "map[") + key := "" + value := "" + for i, c := range s { + if c == ']' { + key = s[:i] + value = s[i+1:] + break + } + } + return "Record<" + stringGoTypeToTypescriptType(key) + ", " + stringGoTypeToTypescriptType(value) + ">" + } + + if strings.Contains(goType, ".") { + parts := strings.Split(goType, ".") + return getTypePrefix(parts[0]) + parts[1] + } + + return goType +} + +func goTypeToTypescriptType(goType string) string { + switch goType { + case "string": + return "string" + case "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64": + return "number" + case "bool": + return "boolean" + case "nil": + return "null" + case "time.Time": + return "string" + default: + return "unknown" + } +} + +// fieldTypeUnformattedString returns the field type as a string without formatting. +// For example, if the field type is `[]*models.User`, the return value is `models.User`. +// /!\ Caveat: this assumes that the map key is always a string. +func fieldTypeUnformattedString(fieldType ast.Expr) string { + switch t := fieldType.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + //return "*" + fieldTypeString(t.X) + return fieldTypeUnformattedString(t.X) + case *ast.ArrayType: + return fieldTypeUnformattedString(t.Elt) + case *ast.MapType: + return fieldTypeUnformattedString(t.Value) + case *ast.SelectorExpr: + return fieldTypeString(t.X) + "." + t.Sel.Name + default: + return "" + } +} + +// fieldTypeToUsedStructType returns the used struct type for a given field type. +// For example, if the field type is `[]*models.User`, the return value is `models.User`. +func fieldTypeToUsedStructType(fieldType ast.Expr) string { + switch t := fieldType.(type) { + case *ast.StarExpr: + return fieldTypeString(t.X) + case *ast.ArrayType: + return fieldTypeString(t.Elt) + case *ast.MapType: + return fieldTypeUnformattedString(t.Value) + case *ast.SelectorExpr: + return fieldTypeString(t) + case *ast.Ident: + return t.Name + case *ast.StructType: + return "__STRUCT__" + default: + return "" + } +} + +func jsonFieldName(field *ast.Field) string { + if field.Tag != nil { + tag := reflect.StructTag(strings.ReplaceAll(field.Tag.Value[1:len(field.Tag.Value)-1], "\\\"", "\"")) + jsonTag := tag.Get("json") + if jsonTag != "" { + jsonParts := strings.Split(jsonTag, ",") + if jsonParts[0] == "-" { + return "" + } + if jsonParts[0] != "" { + return jsonParts[0] + } + return jsonParts[0] + } + } + return field.Names[0].Name +} + +func jsonFieldOmitEmpty(field *ast.Field) bool { + if field.Tag != nil { + tag := reflect.StructTag(strings.ReplaceAll(field.Tag.Value[1:len(field.Tag.Value)-1], "\\\"", "\"")) + jsonTag := tag.Get("json") + if jsonTag != "" { + jsonParts := strings.Split(jsonTag, ",") + return len(jsonParts) > 1 && jsonParts[1] == "omitempty" + } + } + return false +} + +func isCustomStruct(goType string) bool { + return goTypeToTypescriptType(goType) == "unknown" +} + +var nameExceptions = map[string]string{"OAuth2": "oauth2"} + +func convertGoToJSName(name string) string { + if v, ok := nameExceptions[name]; ok { + return v + } + + startUppercase := make([]rune, 0, len(name)) + + for _, c := range name { + if c != '_' && !unicode.IsUpper(c) && !unicode.IsDigit(c) { + break + } + + startUppercase = append(startUppercase, c) + } + + totalStartUppercase := len(startUppercase) + + // all uppercase eg. "JSON" -> "json" + if len(name) == totalStartUppercase { + return strings.ToLower(name) + } + + // eg. "JSONField" -> "jsonField" + if totalStartUppercase > 1 { + return strings.ToLower(name[0:totalStartUppercase-1]) + name[totalStartUppercase-1:] + } + + // eg. "GetField" -> "getField" + if totalStartUppercase == 1 { + return strings.ToLower(name[0:1]) + name[1:] + } + + return name +} + +// fieldTypeToUsedTypescriptType extracts the core TypeScript type from complex type expressions +// For example, if the type is Array, it returns Models_User +// If the type is Record, it returns Models_User +func fieldTypeToUsedTypescriptType(tsType string) string { + // Handle arrays: Array -> Type + if strings.HasPrefix(tsType, "Array<") && strings.HasSuffix(tsType, ">") { + innerType := strings.TrimPrefix(strings.TrimSuffix(tsType, ">"), "Array<") + return fieldTypeToUsedTypescriptType(innerType) + } + + // Handle records: Record -> Value + if strings.HasPrefix(tsType, "Record<") && strings.HasSuffix(tsType, ">") { + innerType := strings.TrimPrefix(strings.TrimSuffix(tsType, ">"), "Record<") + // Find the comma that separates key and value + commaIndex := -1 + bracketCount := 0 + for i, char := range innerType { + if char == '<' { + bracketCount++ + } else if char == '>' { + bracketCount-- + } else if char == ',' && bracketCount == 0 { + commaIndex = i + break + } + } + + if commaIndex != -1 { + valueType := strings.TrimSpace(innerType[commaIndex+1:]) + return fieldTypeToUsedTypescriptType(valueType) + } + } + + // Handle primitive types + switch tsType { + case "string", "number", "boolean", "any", "null", "undefined": + return "" + } + + return tsType +} + +// formatInlineStruct formats an inline struct definition as a string +// e.g. struct{Test string `json:"test"`, Test2 string `json:"test2"`} +func formatInlineStruct(structType *ast.StructType) string { + result := "struct{\n" + + for i, field := range structType.Fields.List { + if i > 0 { + result += "\n" + } + + if field.Names != nil && len(field.Names) > 0 { + result += field.Names[0].Name + " " + fieldTypeString(field.Type) + + if field.Tag != nil { + result += " " + field.Tag.Value + } + } else { + result += fieldTypeString(field.Type) + } + } + + result += "}" + return result +} diff --git a/seanime-2.9.10/codegen/internal/generate_structs_test.go b/seanime-2.9.10/codegen/internal/generate_structs_test.go new file mode 100644 index 0000000..a187941 --- /dev/null +++ b/seanime-2.9.10/codegen/internal/generate_structs_test.go @@ -0,0 +1,23 @@ +package codegen + +import ( + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "testing" +) + +func TestGetGoStructsFromFile(t *testing.T) { + + testPath := filepath.Join(".", "examples", "structs1.go") + + info, err := os.Stat(testPath) + require.NoError(t, err) + + goStructs, err := getGoStructsFromFile(testPath, info) + require.NoError(t, err) + + spew.Dump(goStructs) + +} diff --git a/seanime-2.9.10/codegen/internal/generate_ts_endpoints.go b/seanime-2.9.10/codegen/internal/generate_ts_endpoints.go new file mode 100644 index 0000000..4813086 --- /dev/null +++ b/seanime-2.9.10/codegen/internal/generate_ts_endpoints.go @@ -0,0 +1,465 @@ +package codegen + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/samber/lo" +) + +const ( + typescriptEndpointsFileName = "endpoints.ts" + typescriptEndpointTypesFileName = "endpoint.types.ts" + typescriptHooksFileName = "hooks_template.ts" + goEndpointsFileName = "endpoints.go" + space = " " +) + +var additionalStructNamesForEndpoints = []string{} + +func GenerateTypescriptEndpointsFile(handlersJsonPath string, structsJsonPath string, outDir string, eventDir string) []string { + handlers := LoadHandlers(handlersJsonPath) + structs := LoadPublicStructs(structsJsonPath) + + _ = os.MkdirAll(outDir, os.ModePerm) + f, err := os.Create(filepath.Join(outDir, typescriptEndpointsFileName)) + if err != nil { + panic(err) + } + defer f.Close() + + typeF, err := os.Create(filepath.Join(outDir, typescriptEndpointTypesFileName)) + if err != nil { + panic(err) + } + defer typeF.Close() + + hooksF, err := os.Create(filepath.Join(outDir, typescriptHooksFileName)) + if err != nil { + panic(err) + } + defer hooksF.Close() + + f.WriteString("// This code was generated by codegen/main.go. DO NOT EDIT.\n\n") + + f.WriteString(`export type ApiEndpoints = Record> + +`) + + f.WriteString("export const API_ENDPOINTS = {\n") + + groupedByFile := make(map[string][]*RouteHandler) + for _, handler := range handlers { + if _, ok := groupedByFile[handler.Filename]; !ok { + groupedByFile[handler.Filename] = make([]*RouteHandler, 0) + } + groupedByFile[handler.Filename] = append(groupedByFile[handler.Filename], handler) + } + + filenames := make([]string, 0) + for k := range groupedByFile { + filenames = append(filenames, k) + } + + slices.SortStableFunc(filenames, func(i, j string) int { + return strings.Compare(i, j) + }) + + // Store the endpoints + endpointsMap := make(map[string]string) + + for _, filename := range filenames { + routes := groupedByFile[filename] + if len(routes) == 0 { + continue + } + + if lo.EveryBy(routes, func(route *RouteHandler) bool { + return route.Api == nil || len(route.Api.Methods) == 0 + }) { + continue + } + + groupName := strings.ToUpper(strings.TrimSuffix(filename, ".go")) + + writeLine(f, fmt.Sprintf("\t%s: {", groupName)) // USERS: { + + for _, route := range groupedByFile[filename] { + if route.Api == nil || len(route.Api.Methods) == 0 { + continue + } + + if len(route.Api.Descriptions) > 0 { + writeLine(f, " /**") + f.WriteString(fmt.Sprintf(" * @description\n")) + f.WriteString(fmt.Sprintf(" * Route %s\n", route.Api.Summary)) + for _, cmt := range route.Api.Descriptions { + writeLine(f, fmt.Sprintf(" * %s", strings.TrimSpace(cmt))) + } + writeLine(f, " */") + } + + writeLine(f, fmt.Sprintf("\t\t%s: {", strings.TrimPrefix(route.Name, "Handle"))) // GetAnimeCollection: { + + methodStr := "" + if len(route.Api.Methods) > 1 { + methodStr = fmt.Sprintf("\"%s\"", strings.Join(route.Api.Methods, "\", \"")) + } else { + methodStr = fmt.Sprintf("\"%s\"", route.Api.Methods[0]) + } + + endpointsMap[strings.TrimPrefix(route.Name, "Handle")] = getEndpointKey(route.Name, groupName) + + writeLine(f, fmt.Sprintf("\t\t\tkey: \"%s\",", getEndpointKey(route.Name, groupName))) + + writeLine(f, fmt.Sprintf("\t\t\tmethods: [%s],", methodStr)) // methods: ['GET'], + + writeLine(f, fmt.Sprintf("\t\t\tendpoint: \"%s\",", route.Api.Endpoint)) // path: '/api/v1/anilist/collection', + + writeLine(f, "\t\t},") // }, + } + + writeLine(f, "\t},") // }, + } + + f.WriteString("} satisfies ApiEndpoints\n\n") + + referenceGoStructs := make([]string, 0) + for _, filename := range filenames { + routes := groupedByFile[filename] + if len(routes) == 0 { + continue + } + for _, route := range groupedByFile[filename] { + if route.Api == nil || len(route.Api.Methods) == 0 { + continue + } + if len(route.Api.Params) == 0 && len(route.Api.BodyFields) == 0 { + continue + } + for _, param := range route.Api.BodyFields { + if param.UsedStructType != "" { + referenceGoStructs = append(referenceGoStructs, param.UsedStructType) + } + } + for _, param := range route.Api.Params { + if param.UsedStructType != "" { + referenceGoStructs = append(referenceGoStructs, param.UsedStructType) + } + } + } + } + referenceGoStructs = lo.Uniq(referenceGoStructs) + + typeF.WriteString("// This code was generated by codegen/main.go. DO NOT EDIT.\n\n") + + // + // Imports + // + importedTypes := make([]string, 0) + // + for _, structName := range referenceGoStructs { + parts := strings.Split(structName, ".") + if len(parts) != 2 { + continue + } + + var goStruct *GoStruct + for _, s := range structs { + if s.Name == parts[1] && s.Package == parts[0] { + goStruct = s + break + } + } + + if goStruct == nil { + continue + } + + importedTypes = append(importedTypes, goStruct.FormattedName) + } + + for _, otherStrctName := range additionalStructNamesForEndpoints { + importedTypes = append(importedTypes, stringGoTypeToTypescriptType(otherStrctName)) + } + // + slices.SortStableFunc(importedTypes, func(i, j string) int { + return strings.Compare(i, j) + }) + typeF.WriteString("import type {\n") + for _, typeName := range importedTypes { + typeF.WriteString(fmt.Sprintf(" %s,\n", typeName)) + } + typeF.WriteString("} from \"@/api/generated/types.ts\"\n\n") + + // + // Types + // + + for _, filename := range filenames { + routes := groupedByFile[filename] + if len(routes) == 0 { + continue + } + + typeF.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n") + typeF.WriteString(fmt.Sprintf("// %s\n", strings.TrimSuffix(filename, ".go"))) + typeF.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n\n") + + for _, route := range groupedByFile[filename] { + if route.Api == nil || len(route.Api.Methods) == 0 { + continue + } + + if len(route.Api.Params) == 0 && len(route.Api.BodyFields) == 0 { + continue + } + + typeF.WriteString("/**\n") + typeF.WriteString(fmt.Sprintf(" * - Filepath: %s\n", filepath.ToSlash(strings.TrimPrefix(route.Filepath, "..\\")))) + typeF.WriteString(fmt.Sprintf(" * - Filename: %s\n", route.Filename)) + typeF.WriteString(fmt.Sprintf(" * - Endpoint: %s\n", route.Api.Endpoint)) + if len(route.Api.Summary) > 0 { + typeF.WriteString(fmt.Sprintf(" * @description\n")) + typeF.WriteString(fmt.Sprintf(" * Route %s\n", strings.TrimSpace(route.Api.Summary))) + } + typeF.WriteString(" */\n") + typeF.WriteString(fmt.Sprintf("export type %s_Variables = {\n", strings.TrimPrefix(route.Name, "Handle"))) // export type EditAnimeEntry_Variables = { + + addedBodyFields := false + for _, param := range route.Api.BodyFields { + writeParamField(typeF, route, param) // mediaId: number; + if param.UsedStructType != "" { + referenceGoStructs = append(referenceGoStructs, param.UsedStructType) + } + addedBodyFields = true + } + + if !addedBodyFields { + for _, param := range route.Api.Params { + writeParamField(typeF, route, param) // mediaId: number; + if param.UsedStructType != "" { + referenceGoStructs = append(referenceGoStructs, param.UsedStructType) + } + } + } + + writeLine(typeF, "}\n") + } + + } + + generateHooksFile(hooksF, groupedByFile, filenames) + + generateEventFile(eventDir, endpointsMap) + + return referenceGoStructs +} + +func generateHooksFile(f *os.File, groupedHandlers map[string][]*RouteHandler, filenames []string) { + + queryTemplate := `// export function use{handlerName}({props}) { +// return useServerQuery{<}{TData}{TVar}{>}({ +// endpoint: API_ENDPOINTS.{groupName}.{handlerName}.endpoint{endpointSuffix}, +// method: API_ENDPOINTS.{groupName}.{handlerName}.methods[%d], +// queryKey: [API_ENDPOINTS.{groupName}.{handlerName}.key], +// enabled: true, +// }) +// } + +` + mutationTemplate := `// export function use{handlerName}({props}) { +// return useServerMutation{<}{TData}{TVar}{>}({ +// endpoint: API_ENDPOINTS.{groupName}.{handlerName}.endpoint{endpointSuffix}, +// method: API_ENDPOINTS.{groupName}.{handlerName}.methods[%d], +// mutationKey: [API_ENDPOINTS.{groupName}.{handlerName}.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +` + + tmpGroupTmpls := make(map[string][]string) + + for _, filename := range filenames { + routes := groupedHandlers[filename] + if len(routes) == 0 { + continue + } + + if lo.EveryBy(routes, func(route *RouteHandler) bool { + return route.Api == nil || len(route.Api.Methods) == 0 + }) { + continue + } + + f.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n") + f.WriteString(fmt.Sprintf("// %s\n", strings.TrimSuffix(filename, ".go"))) + f.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n\n") + + tmpls := make([]string, 0) + for _, route := range groupedHandlers[filename] { + if route.Api == nil || len(route.Api.Methods) == 0 { + continue + } + + for i, method := range route.Api.Methods { + + tmpl := "" + + if method == "GET" { + getTemplate := strings.ReplaceAll(queryTemplate, "{handlerName}", strings.TrimPrefix(route.Name, "Handle")) + getTemplate = strings.ReplaceAll(getTemplate, "{groupName}", strings.ToUpper(strings.TrimSuffix(filename, ".go"))) + getTemplate = strings.ReplaceAll(getTemplate, "{method}", "GET") + tmpl = getTemplate + } + + if method == "POST" || method == "PATCH" || method == "PUT" || method == "DELETE" { + mutTemplate := strings.ReplaceAll(mutationTemplate, "{handlerName}", strings.TrimPrefix(route.Name, "Handle")) + mutTemplate = strings.ReplaceAll(mutTemplate, "{groupName}", strings.ToUpper(strings.TrimSuffix(filename, ".go"))) + mutTemplate = strings.ReplaceAll(mutTemplate, "{method}", method) + tmpl = mutTemplate + } + + tmpl = strings.ReplaceAll(tmpl, "%d", strconv.Itoa(i)) + + if len(route.Api.ReturnTypescriptType) == 0 { + tmpl = strings.ReplaceAll(tmpl, "{<}", "") + tmpl = strings.ReplaceAll(tmpl, "{TData}", "") + tmpl = strings.ReplaceAll(tmpl, "{TVar}", "") + tmpl = strings.ReplaceAll(tmpl, "{>}", "") + } else { + tmpl = strings.ReplaceAll(tmpl, "{<}", "<") + tmpl = strings.ReplaceAll(tmpl, "{TData}", route.Api.ReturnTypescriptType) + tmpl = strings.ReplaceAll(tmpl, "{>}", ">") + } + + if len(route.Api.Params) == 0 { + tmpl = strings.ReplaceAll(tmpl, "{endpointSuffix}", "") + tmpl = strings.ReplaceAll(tmpl, "{props}", "") + } else { + props := "" + for _, param := range route.Api.Params { + props += fmt.Sprintf(`%s: %s, `, param.JsonName, param.TypescriptType) + } + tmpl = strings.ReplaceAll(tmpl, "{props}", props[:len(props)-2]) + endpointSuffix := "" + for _, param := range route.Api.Params { + endpointSuffix += fmt.Sprintf(`.replace("{%s}", String(%s))`, param.JsonName, param.JsonName) + } + tmpl = strings.ReplaceAll(tmpl, "{endpointSuffix}", endpointSuffix) + } + + if len(route.Api.BodyFields) == 0 { + tmpl = strings.ReplaceAll(tmpl, "{TVar}", "") + } else { + tmpl = strings.ReplaceAll(tmpl, "{TVar}", fmt.Sprintf(", %s", strings.TrimPrefix(route.Name, "Handle")+"_Variables")) + } + + tmpls = append(tmpls, tmpl) + f.WriteString(tmpl) + + } + + } + tmpGroupTmpls[strings.TrimSuffix(filename, ".go")] = tmpls + } + + //for filename, tmpls := range tmpGroupTmpls { + // hooksF, err := os.Create(filepath.Join("../seanime-web/src/api/hooks", filename+".hooks.ts")) + // if err != nil { + // panic(err) + // } + // defer hooksF.Close() + // + // for _, tmpl := range tmpls { + // hooksF.WriteString(tmpl) + // } + //} + +} + +func generateEventFile(eventDir string, endpointsMap map[string]string) { + fp := filepath.Join(eventDir, goEndpointsFileName) + file, err := os.Create(fp) + if err != nil { + panic(err) + } + defer file.Close() + + file.WriteString("// This code was generated by codegen/main.go. DO NOT EDIT.\n") + file.WriteString("package events\n\n") + + // file.WriteString(fmt"var Endpoint = map[string]string{\n") + + endpoints := []string{} + for endpoint := range endpointsMap { + endpoints = append(endpoints, endpoint) + } + slices.SortStableFunc(endpoints, func(i, j string) int { + return strings.Compare(i, j) + }) + + goFmtSpacing := "" + + file.WriteString("const (\n") + for _, endpoint := range endpoints { + file.WriteString(fmt.Sprintf(" %sEndpoint%s= \"%s\"\n", endpoint, goFmtSpacing, endpointsMap[endpoint])) + } + file.WriteString(")\n") + + cmd := exec.Command("gofmt", "-w", fp) + cmd.Run() + +} + +func writeParamField(f *os.File, handler *RouteHandler, param *RouteHandlerParam) { + if len(param.Descriptions) > 0 { + writeLine(f, "\t/**") + for _, cmt := range param.Descriptions { + writeLine(f, fmt.Sprintf("\t * %s", strings.TrimSpace(cmt))) + } + writeLine(f, "\t */") + } + fieldSuffix := "" + if !param.Required { + fieldSuffix = "?" + } + writeLine(f, fmt.Sprintf("\t%s%s: %s", param.JsonName, fieldSuffix, param.TypescriptType)) +} + +func getEndpointKey(s string, groupName string) string { + s = strings.TrimPrefix(s, "Handle") + var result string + for i, v := range s { + if i > 0 && v >= 'A' && v <= 'Z' { + result += "-" + } + + result += string(v) + } + result = strings.ToLower(result) + if strings.Contains(result, "t-v-d-b") { + result = strings.Replace(result, "t-v-d-b", "tvdb", 1) + } + if strings.Contains(result, "m-a-l") { + result = strings.Replace(result, "m-a-l", "mal", 1) + } + return strings.ReplaceAll(groupName, "_", "-") + "-" + result +} + +func writeLine(file *os.File, template string) { + template = strings.ReplaceAll(template, "\t", space) + file.WriteString(fmt.Sprintf(template + "\n")) +} diff --git a/seanime-2.9.10/codegen/internal/generate_types.go b/seanime-2.9.10/codegen/internal/generate_types.go new file mode 100644 index 0000000..ddee7c5 --- /dev/null +++ b/seanime-2.9.10/codegen/internal/generate_types.go @@ -0,0 +1,334 @@ +package codegen + +import ( + "cmp" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +const ( + typescriptFileName = "types.ts" +) + +// Structs that are not directly referenced by the API routes but are needed for the Typescript file. +var additionalStructNames = []string{ + "torrentstream.TorrentLoadingStatus", + "torrentstream.TorrentStatus", + "debrid_client.StreamState", + "extension_repo.TrayPluginExtensionItem", + "vendor_habari.Metadata", + "nativeplayer.PlaybackInfo", + "nativeplayer.ServerEvent", + "nativeplayer.ClientEvent", + "mkvparser.SubtitleEvent", + "nakama.NakamaStatus", +} + +// GenerateTypescriptFile generates a Typescript file containing the types for the API routes parameters and responses based on the Docs struct. +func GenerateTypescriptFile(docsFilePath string, publicStructsFilePath string, outDir string, goStructStrs []string) { + + handlers := LoadHandlers(docsFilePath) + + goStructs := LoadPublicStructs(publicStructsFilePath) + + // e.g. map["models.User"]*GoStruct + goStructsMap := make(map[string]*GoStruct) + + for _, goStruct := range goStructs { + goStructsMap[goStruct.Package+"."+goStruct.Name] = goStruct + } + + // Expand the structs with embedded structs + for _, goStruct := range goStructs { + for _, embeddedStructType := range goStruct.EmbeddedStructTypes { + if embeddedStructType != "" { + if usedStruct, ok := goStructsMap[embeddedStructType]; ok { + for _, usedField := range usedStruct.Fields { + goStruct.Fields = append(goStruct.Fields, usedField) + } + } + } + } + } + + // Create the typescript file + _ = os.MkdirAll(outDir, os.ModePerm) + file, err := os.Create(filepath.Join(outDir, typescriptFileName)) + if err != nil { + panic(err) + } + defer file.Close() + + // Write the typescript file + file.WriteString("// This code was generated by codegen/main.go. DO NOT EDIT.\n\n") + + // Get all the returned structs from the routes + // e.g. @returns models.User + structStrMap := make(map[string]int) + for _, str := range goStructStrs { + if _, ok := structStrMap[str]; ok { + structStrMap[str]++ + } else { + structStrMap[str] = 1 + } + } + for _, handler := range handlers { + if handler.Api != nil { + switch handler.Api.ReturnTypescriptType { + case "null", "string", "number", "boolean": + continue + } + + if _, ok := structStrMap[handler.Api.ReturnGoType]; ok { + structStrMap[handler.Api.ReturnGoType]++ + } else { + structStrMap[handler.Api.ReturnGoType] = 1 + } + } + } + + // Isolate the structs that are returned more than once + sharedStructStrs := make([]string, 0) + otherStructStrs := make([]string, 0) + + for k, v := range structStrMap { + if v > 1 { + sharedStructStrs = append(sharedStructStrs, k) + } else { + otherStructStrs = append(otherStructStrs, k) + } + } + + // Now that we have the returned structs, store them in slices + sharedStructs := make([]*GoStruct, 0) + otherStructs := make([]*GoStruct, 0) + + for _, structStr := range sharedStructStrs { + // e.g. "models.User" + structStrParts := strings.Split(structStr, ".") + if len(structStrParts) != 2 { + continue + } + + // Find the struct + goStruct, ok := goStructsMap[structStr] + if ok { + sharedStructs = append(sharedStructs, goStruct) + } + + } + for _, structStr := range otherStructStrs { + // e.g. "models.User" + structStrParts := strings.Split(structStr, ".") + if len(structStrParts) != 2 { + continue + } + + // Find the struct + goStruct, ok := goStructsMap[structStr] + if ok { + otherStructs = append(otherStructs, goStruct) + } + } + + // Add additional structs to otherStructs + for _, structName := range additionalStructNames { + if goStruct, ok := goStructsMap[structName]; ok { + otherStructs = append(otherStructs, goStruct) + } + } + + //------------------------- + + referencedStructs, ok := getReferencedStructsRecursively(sharedStructs, otherStructs, goStructsMap) + if !ok { + panic("Failed to get referenced structs") + } + + // Keep track of written Typescript types + // This is to avoid name collisions + writtenTypes := make(map[string]*GoStruct) + + // Group the structs by package + structsByPackage := make(map[string][]*GoStruct) + for _, goStruct := range referencedStructs { + if _, ok := structsByPackage[goStruct.Package]; !ok { + structsByPackage[goStruct.Package] = make([]*GoStruct, 0) + } + structsByPackage[goStruct.Package] = append(structsByPackage[goStruct.Package], goStruct) + } + + packages := make([]string, 0) + for k := range structsByPackage { + packages = append(packages, k) + } + + slices.SortStableFunc(packages, func(i, j string) int { + return cmp.Compare(i, j) + }) + + file.WriteString("export type Nullish = T | null | undefined\n\n") + + for _, pkg := range packages { + + file.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n") + file.WriteString(fmt.Sprintf("// %s\n", strings.ReplaceAll(cases.Title(language.English, cases.Compact).String(strings.ReplaceAll(pkg, "_", " ")), " ", ""))) + file.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n\n") + + structs := structsByPackage[pkg] + slices.SortStableFunc(structs, func(i, j *GoStruct) int { + return cmp.Compare(i.FormattedName, j.FormattedName) + }) + + // Write the shared structs first + for _, goStruct := range structs { + + writeTypescriptType(file, goStruct, writtenTypes) + + } + + } + + //for _, goStruct := range referencedStructs { + // + // writeTypescriptType(file, goStruct, writtenTypes) + // + //} + +} + +// getReferencedStructsRecursively returns a map of GoStructs that are referenced by the fields of sharedStructs and otherStructs. +func getReferencedStructsRecursively(sharedStructs, otherStructs []*GoStruct, goStructsMap map[string]*GoStruct) (map[string]*GoStruct, bool) { + allStructs := make(map[string]*GoStruct) + for _, sharedStruct := range sharedStructs { + allStructs[sharedStruct.Package+"."+sharedStruct.Name] = sharedStruct + } + for _, otherStruct := range otherStructs { + allStructs[otherStruct.Package+"."+otherStruct.Name] = otherStruct + } + + // Keep track of the structs that have been visited + + referencedStructs := make(map[string]*GoStruct) + + for _, strct := range allStructs { + getReferencedStructs(strct, referencedStructs, goStructsMap) + } + + return referencedStructs, true +} + +func getReferencedStructs(goStruct *GoStruct, referencedStructs map[string]*GoStruct, goStructsMap map[string]*GoStruct) { + if _, ok := referencedStructs[goStruct.Package+"."+goStruct.Name]; ok { + return + } + referencedStructs[goStruct.Package+"."+goStruct.Name] = goStruct + for _, field := range goStruct.Fields { + if field.UsedStructType != "" { + if usedStruct, ok := goStructsMap[field.UsedStructType]; ok { + getReferencedStructs(usedStruct, referencedStructs, goStructsMap) + } + } + } + if goStruct.AliasOf != nil { + if usedStruct, ok := goStructsMap[goStruct.AliasOf.UsedStructType]; ok { + getReferencedStructs(usedStruct, referencedStructs, goStructsMap) + } + } +} + +func writeTypescriptType(f *os.File, goStruct *GoStruct, writtenTypes map[string]*GoStruct) { + f.WriteString("/**\n") + f.WriteString(fmt.Sprintf(" * - Filepath: %s\n", strings.TrimPrefix(goStruct.Filepath, "../"))) + f.WriteString(fmt.Sprintf(" * - Filename: %s\n", goStruct.Filename)) + f.WriteString(fmt.Sprintf(" * - Package: %s\n", goStruct.Package)) + if len(goStruct.Comments) > 0 { + f.WriteString(fmt.Sprintf(" * @description\n")) + for _, cmt := range goStruct.Comments { + f.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(cmt))) + } + } + f.WriteString(" */\n") + + if len(goStruct.Fields) > 0 { + f.WriteString(fmt.Sprintf("export type %s = {\n", goStruct.FormattedName)) + for _, field := range goStruct.Fields { + if field.JsonName == "" { + continue + } + fieldNameSuffix := "" + if !field.Required { + fieldNameSuffix = "?" + } + + if len(field.Comments) > 0 { + f.WriteString(fmt.Sprintf(" /**\n")) + for _, cmt := range field.Comments { + f.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(cmt))) + } + f.WriteString(fmt.Sprintf(" */\n")) + } + + typeText := field.TypescriptType + //if !field.Required { + // switch typeText { + // case "string", "number", "boolean": + // default: + // typeText = "Nullish<" + typeText + ">" + // } + //} + + f.WriteString(fmt.Sprintf(" %s%s: %s\n", field.JsonName, fieldNameSuffix, typeText)) + } + f.WriteString("}\n\n") + } + + if goStruct.AliasOf != nil { + if goStruct.AliasOf.DeclaredValues != nil && len(goStruct.AliasOf.DeclaredValues) > 0 { + union := "" + if len(goStruct.AliasOf.DeclaredValues) > 5 { + union = strings.Join(goStruct.AliasOf.DeclaredValues, " |\n ") + } else { + union = strings.Join(goStruct.AliasOf.DeclaredValues, " | ") + } + f.WriteString(fmt.Sprintf("export type %s = %s\n\n", goStruct.FormattedName, union)) + } else { + f.WriteString(fmt.Sprintf("export type %s = %s\n\n", goStruct.FormattedName, goStruct.AliasOf.TypescriptType)) + } + } + + // Add the struct to the written types + writtenTypes[goStruct.Package+"."+goStruct.Name] = goStruct +} + +func getUnformattedGoType(goType string) string { + if strings.HasPrefix(goType, "[]") { + return getUnformattedGoType(goType[2:]) + } + + if strings.HasPrefix(goType, "*") { + return getUnformattedGoType(goType[1:]) + } + + if strings.HasPrefix(goType, "map[") { + s := strings.TrimPrefix(goType, "map[") + value := "" + for i, c := range s { + if c == ']' { + value = s[i+1:] + break + } + } + return getUnformattedGoType(value) + } + + return goType +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/seanime-2.9.10/codegen/internal/loaders.go b/seanime-2.9.10/codegen/internal/loaders.go new file mode 100644 index 0000000..adbd59f --- /dev/null +++ b/seanime-2.9.10/codegen/internal/loaders.go @@ -0,0 +1,34 @@ +package codegen + +import ( + "encoding/json" + "os" +) + +func LoadHandlers(path string) []*RouteHandler { + var handlers []*RouteHandler + docsContent, err := os.ReadFile(path) + if err != nil { + panic(err) + } + err = json.Unmarshal(docsContent, &handlers) + if err != nil { + panic(err) + } + return handlers +} + +func LoadPublicStructs(path string) []*GoStruct { + var goStructs []*GoStruct + structsContent, err := os.ReadFile(path) + if err != nil { + panic(err) + } + + err = json.Unmarshal(structsContent, &goStructs) + if err != nil { + panic(err) + } + + return goStructs +} diff --git a/seanime-2.9.10/codegen/main.go b/seanime-2.9.10/codegen/main.go new file mode 100644 index 0000000..8746910 --- /dev/null +++ b/seanime-2.9.10/codegen/main.go @@ -0,0 +1,56 @@ +//go:generate go run main.go --skipHandlers=false --skipStructs=false --skipTypes=false --skipPluginEvents=false --skipHookEvents=false --skipHandlerHookEvents=false +package main + +import ( + "flag" + codegen "seanime/codegen/internal" +) + +func main() { + + var skipHandlers bool + flag.BoolVar(&skipHandlers, "skipHandlers", false, "Skip generating docs") + + var skipStructs bool + flag.BoolVar(&skipStructs, "skipStructs", false, "Skip generating structs") + + var skipTypes bool + flag.BoolVar(&skipTypes, "skipTypes", false, "Skip generating types") + + var skipPluginEvents bool + flag.BoolVar(&skipPluginEvents, "skipPluginEvents", false, "Skip generating plugin events") + + var skipHookEvents bool + flag.BoolVar(&skipHookEvents, "skipHookEvents", false, "Skip generating hook events") + + var skipHandlerHookEvents bool + flag.BoolVar(&skipHandlerHookEvents, "skipHandlerHookEvents", false, "Skip generating handler hook events") + + flag.Parse() + + if !skipHandlers { + codegen.GenerateHandlers("../internal/handlers", "./generated") + } + + if !skipStructs { + codegen.ExtractStructs("../internal", "./generated") + } + + if !skipTypes { + goStructStrs := codegen.GenerateTypescriptEndpointsFile("./generated/handlers.json", "./generated/public_structs.json", "../seanime-web/src/api/generated", "../internal/events") + codegen.GenerateTypescriptFile("./generated/handlers.json", "./generated/public_structs.json", "../seanime-web/src/api/generated", goStructStrs) + } + + // if !skipHandlerHookEvents { + // codegen.GenerateHandlerHookEvents("./generated/handlers.json", "../internal/handlers") + // } + + if !skipPluginEvents { + codegen.GeneratePluginEventFile("../internal/plugin/ui/events.go", "../seanime-web/src/app/(main)/_features/plugin/generated") + } + + if !skipHookEvents { + codegen.GeneratePluginHooksDefinitionFile("../internal/extension_repo/goja_plugin_types", "./generated/public_structs.json", "./generated") + } + +} diff --git a/seanime-2.9.10/docs/images/2/rec_media_streaming-phone-01.gif b/seanime-2.9.10/docs/images/2/rec_media_streaming-phone-01.gif new file mode 100644 index 0000000..8b57928 Binary files /dev/null and b/seanime-2.9.10/docs/images/2/rec_media_streaming-phone-01.gif differ diff --git a/seanime-2.9.10/docs/images/4/anilist_01--sq.jpg b/seanime-2.9.10/docs/images/4/anilist_01--sq.jpg new file mode 100644 index 0000000..a997338 Binary files /dev/null and b/seanime-2.9.10/docs/images/4/anilist_01--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/anime-downloading--sq.jpg b/seanime-2.9.10/docs/images/4/anime-downloading--sq.jpg new file mode 100644 index 0000000..5948eaa Binary files /dev/null and b/seanime-2.9.10/docs/images/4/anime-downloading--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/anime-entry-torrent-stream--sq.jpg b/seanime-2.9.10/docs/images/4/anime-entry-torrent-stream--sq.jpg new file mode 100644 index 0000000..9ab49c2 Binary files /dev/null and b/seanime-2.9.10/docs/images/4/anime-entry-torrent-stream--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/anime-library_desktop_01--sq.jpg b/seanime-2.9.10/docs/images/4/anime-library_desktop_01--sq.jpg new file mode 100644 index 0000000..8ea3e8d Binary files /dev/null and b/seanime-2.9.10/docs/images/4/anime-library_desktop_01--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/command-palette_01--sq.jpg b/seanime-2.9.10/docs/images/4/command-palette_01--sq.jpg new file mode 100644 index 0000000..3f0367d Binary files /dev/null and b/seanime-2.9.10/docs/images/4/command-palette_01--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/detailed-library-view--sq.jpg b/seanime-2.9.10/docs/images/4/detailed-library-view--sq.jpg new file mode 100644 index 0000000..b3e842a Binary files /dev/null and b/seanime-2.9.10/docs/images/4/detailed-library-view--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/discover_01--sq.jpg b/seanime-2.9.10/docs/images/4/discover_01--sq.jpg new file mode 100644 index 0000000..ed2e092 Binary files /dev/null and b/seanime-2.9.10/docs/images/4/discover_01--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/discover_02--sq.jpg b/seanime-2.9.10/docs/images/4/discover_02--sq.jpg new file mode 100644 index 0000000..7ef6ea8 Binary files /dev/null and b/seanime-2.9.10/docs/images/4/discover_02--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/github-banner-sq.png b/seanime-2.9.10/docs/images/4/github-banner-sq.png new file mode 100644 index 0000000..f1ca252 Binary files /dev/null and b/seanime-2.9.10/docs/images/4/github-banner-sq.png differ diff --git a/seanime-2.9.10/docs/images/4/manga-entry_01--sq.jpg b/seanime-2.9.10/docs/images/4/manga-entry_01--sq.jpg new file mode 100644 index 0000000..5b00c4f Binary files /dev/null and b/seanime-2.9.10/docs/images/4/manga-entry_01--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/manga-library--sq.jpg b/seanime-2.9.10/docs/images/4/manga-library--sq.jpg new file mode 100644 index 0000000..b5f93da Binary files /dev/null and b/seanime-2.9.10/docs/images/4/manga-library--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/manga-reader_01--sq.jpg b/seanime-2.9.10/docs/images/4/manga-reader_01--sq.jpg new file mode 100644 index 0000000..f8f919c Binary files /dev/null and b/seanime-2.9.10/docs/images/4/manga-reader_01--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/manga-reader_02--sq.jpg b/seanime-2.9.10/docs/images/4/manga-reader_02--sq.jpg new file mode 100644 index 0000000..b242de2 Binary files /dev/null and b/seanime-2.9.10/docs/images/4/manga-reader_02--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/online-streaming--sq.jpg b/seanime-2.9.10/docs/images/4/online-streaming--sq.jpg new file mode 100644 index 0000000..2a10aec Binary files /dev/null and b/seanime-2.9.10/docs/images/4/online-streaming--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/playlist--sq.jpg b/seanime-2.9.10/docs/images/4/playlist--sq.jpg new file mode 100644 index 0000000..dd6d8da Binary files /dev/null and b/seanime-2.9.10/docs/images/4/playlist--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/preview-card_01--sq.jpg b/seanime-2.9.10/docs/images/4/preview-card_01--sq.jpg new file mode 100644 index 0000000..1a6540e Binary files /dev/null and b/seanime-2.9.10/docs/images/4/preview-card_01--sq.jpg differ diff --git a/seanime-2.9.10/docs/images/4/rec-anime-watching_01.gif b/seanime-2.9.10/docs/images/4/rec-anime-watching_01.gif new file mode 100644 index 0000000..8364de9 Binary files /dev/null and b/seanime-2.9.10/docs/images/4/rec-anime-watching_01.gif differ diff --git a/seanime-2.9.10/docs/images/4/rec-anime-watching_02.gif b/seanime-2.9.10/docs/images/4/rec-anime-watching_02.gif new file mode 100644 index 0000000..f893a24 Binary files /dev/null and b/seanime-2.9.10/docs/images/4/rec-anime-watching_02.gif differ diff --git a/seanime-2.9.10/docs/images/4/rec-debridstream.gif b/seanime-2.9.10/docs/images/4/rec-debridstream.gif new file mode 100644 index 0000000..f40db2c Binary files /dev/null and b/seanime-2.9.10/docs/images/4/rec-debridstream.gif differ diff --git a/seanime-2.9.10/docs/images/4/rec-scanning.gif b/seanime-2.9.10/docs/images/4/rec-scanning.gif new file mode 100644 index 0000000..52d1202 Binary files /dev/null and b/seanime-2.9.10/docs/images/4/rec-scanning.gif differ diff --git a/seanime-2.9.10/docs/images/4/rec-torrentstream.gif b/seanime-2.9.10/docs/images/4/rec-torrentstream.gif new file mode 100644 index 0000000..9927be5 Binary files /dev/null and b/seanime-2.9.10/docs/images/4/rec-torrentstream.gif differ diff --git a/seanime-2.9.10/docs/images/github-banner-10.png b/seanime-2.9.10/docs/images/github-banner-10.png new file mode 100644 index 0000000..6daef5b Binary files /dev/null and b/seanime-2.9.10/docs/images/github-banner-10.png differ diff --git a/seanime-2.9.10/docs/images/github-banner-6.png b/seanime-2.9.10/docs/images/github-banner-6.png new file mode 100644 index 0000000..7ccc8e8 Binary files /dev/null and b/seanime-2.9.10/docs/images/github-banner-6.png differ diff --git a/seanime-2.9.10/docs/images/logo.png b/seanime-2.9.10/docs/images/logo.png new file mode 100644 index 0000000..70ae2e2 Binary files /dev/null and b/seanime-2.9.10/docs/images/logo.png differ diff --git a/seanime-2.9.10/docs/images/logo_2.png b/seanime-2.9.10/docs/images/logo_2.png new file mode 100644 index 0000000..f100a7a Binary files /dev/null and b/seanime-2.9.10/docs/images/logo_2.png differ diff --git a/seanime-2.9.10/docs/images/sea.png b/seanime-2.9.10/docs/images/sea.png new file mode 100644 index 0000000..031cef6 Binary files /dev/null and b/seanime-2.9.10/docs/images/sea.png differ diff --git a/seanime-2.9.10/go.mod b/seanime-2.9.10/go.mod new file mode 100644 index 0000000..8a046d8 --- /dev/null +++ b/seanime-2.9.10/go.mod @@ -0,0 +1,213 @@ +module seanime + +go 1.24.3 + +require ( + fyne.io/systray v1.11.0 + github.com/5rahim/go-astisub v0.2.1 + github.com/5rahim/gomkv v0.2.1 + github.com/5rahim/habari v0.1.7 + github.com/Masterminds/semver/v3 v3.4.0 + github.com/Microsoft/go-winio v0.6.2 + github.com/PuerkitoBio/goquery v1.10.3 + github.com/Yamashou/gqlgenc v0.25.4 + github.com/adrg/strutil v0.3.1 + github.com/anacrolix/log v0.16.0 + github.com/anacrolix/torrent v1.58.1 + github.com/bmatcuk/doublestar/v4 v4.9.1 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/cli/browser v1.3.0 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc + github.com/dop251/goja v0.0.0-20250531102226-cb187b08699c + github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 + github.com/dustin/go-humanize v1.0.1 + github.com/evanw/esbuild v0.25.8 + github.com/fsnotify/fsnotify v1.9.0 + github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 + github.com/glebarez/sqlite v1.11.0 + github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 + github.com/goccy/go-json v0.10.5 + github.com/gocolly/colly v1.2.0 + github.com/gonutz/w32/v2 v2.12.1 + github.com/google/go-querystring v1.1.0 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/hekmon/transmissionrpc/v3 v3.0.0 + github.com/huin/goupnp v1.3.0 + github.com/imroc/req/v3 v3.54.0 + github.com/kr/pretty v0.3.1 + github.com/labstack/echo/v4 v4.13.4 + github.com/mileusna/useragent v1.3.5 + github.com/mmcdole/gofeed v1.3.0 + github.com/ncruces/go-dns v1.2.7 + github.com/neilotoole/streamcache v0.3.5 + github.com/nwaples/rardecode/v2 v2.1.1 + github.com/pkg/errors v0.9.1 + github.com/rs/zerolog v1.34.0 + github.com/samber/lo v1.51.0 + github.com/samber/mo v1.15.0 + github.com/sourcegraph/conc v0.3.0 + github.com/spf13/viper v1.20.1 + github.com/stretchr/testify v1.10.0 + github.com/xfrr/goffmpeg v1.0.0 + github.com/ziflex/lecho/v3 v3.8.0 + golang.org/x/crypto v0.41.0 + golang.org/x/image v0.30.0 + golang.org/x/net v0.43.0 + golang.org/x/term v0.34.0 + golang.org/x/text v0.28.0 + golang.org/x/time v0.12.0 + gopkg.in/vansante/go-ffprobe.v2 v2.2.1 + gorm.io/gorm v1.30.1 +) + +require github.com/Eyevinn/hls-m3u8 v0.6.0 + +require ( + github.com/99designs/gqlgen v0.17.54 // indirect + github.com/RoaringBitmap/roaring v1.2.3 // indirect + github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0 // indirect + github.com/alecthomas/atomic v0.1.0-alpha2 // indirect + github.com/anacrolix/chansync v0.4.1-0.20240627045151-1aa1ac392fe8 // indirect + github.com/anacrolix/dht/v2 v2.19.2-0.20221121215055-066ad8494444 // indirect + github.com/anacrolix/envpprof v1.3.0 // indirect + github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca // indirect + github.com/anacrolix/go-libutp v1.3.2 // indirect + github.com/anacrolix/missinggo v1.3.0 // indirect + github.com/anacrolix/missinggo/perf v1.0.0 // indirect + github.com/anacrolix/missinggo/v2 v2.7.4 // indirect + github.com/anacrolix/mmsg v1.0.1 // indirect + github.com/anacrolix/multiless v0.4.0 // indirect + github.com/anacrolix/stm v0.4.0 // indirect + github.com/anacrolix/sync v0.5.1 // indirect + github.com/anacrolix/upnp v0.1.4 // indirect + github.com/anacrolix/utp v0.1.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/antchfx/htmlquery v1.3.2 // indirect + github.com/antchfx/xmlquery v1.4.1 // indirect + github.com/antchfx/xpath v1.3.1 // indirect + github.com/asticode/go-astikit v0.20.0 // indirect + github.com/asticode/go-astits v1.8.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/benbjohnson/immutable v0.3.0 // indirect + github.com/bits-and-blooms/bitset v1.2.2 // indirect + github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect + github.com/cespare/xxhash v1.1.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect + github.com/edsrzf/mmap-go v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect + github.com/go-llsqlite/crawshaw v0.5.2-0.20240425034140-f30eb7704568 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/btree v1.1.2 // indirect + github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hekmon/cunits/v2 v2.1.0 // indirect + github.com/huandu/xstrings v1.3.2 // indirect + github.com/icholy/digest v1.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kennygrant/sanitize v1.2.4 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.3 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/minio/sha256-simd v1.0.0 // indirect + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/mschoch/smat v0.2.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-varint v0.0.6 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/neilotoole/fifomu v0.1.2 // indirect + github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pion/datachannel v1.5.9 // indirect + github.com/pion/dtls/v3 v3.0.3 // indirect + github.com/pion/ice/v4 v4.0.2 // indirect + github.com/pion/interceptor v0.1.37 // indirect + github.com/pion/logging v0.2.2 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.14 // indirect + github.com/pion/rtp v1.8.9 // indirect + github.com/pion/sctp v1.8.33 // indirect + github.com/pion/sdp/v3 v3.0.9 // indirect + github.com/pion/srtp/v3 v3.0.4 // indirect + github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/turn/v4 v4.0.0 // indirect + github.com/pion/webrtc/v4 v4.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/protolambda/ctxlock v0.1.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.53.0 // indirect + github.com/refraction-networking/utls v1.7.3 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect + github.com/sosodev/duration v1.3.1 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect + github.com/temoto/robotstxt v1.1.2 // indirect + github.com/tidwall/btree v1.6.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/vektah/gqlparser/v2 v2.5.16 // indirect + github.com/wlynxg/anet v0.0.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.etcd.io/bbolt v1.3.6 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/mock v0.5.2 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/sync v0.16.0 + golang.org/x/sys v0.35.0 // indirect + golang.org/x/tools v0.35.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + lukechampine.com/blake3 v1.1.6 // indirect + modernc.org/libc v1.41.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.23.1 // indirect + zombiezen.com/go/sqlite v0.13.1 // indirect +) diff --git a/seanime-2.9.10/go.sum b/seanime-2.9.10/go.sum new file mode 100644 index 0000000..2d1b4d2 --- /dev/null +++ b/seanime-2.9.10/go.sum @@ -0,0 +1,800 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk= +crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4= +filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU= +filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/5rahim/go-astisub v0.2.1 h1:DVPOJmrIIY5i5yyQgJzFqfakuZpbiWzioRKPacNW4BY= +github.com/5rahim/go-astisub v0.2.1/go.mod h1:UgSAUWCMt+ifn227w/g1n7XSlqDYFHMYPYAl/84jljU= +github.com/5rahim/gomkv v0.2.1 h1:Xl1H64vke40XLg4QjdNfnDYWmwV9pJNGSlYi3b4jrSY= +github.com/5rahim/gomkv v0.2.1/go.mod h1:yRpTeQRAG46ozjeaydjJWB7FiMtlqavTkM1vfqtv7j8= +github.com/5rahim/habari v0.1.7 h1:MBcsneiZPEL+bIWHXqhcrht2Pjubi2rWn8O7WQHbJkA= +github.com/5rahim/habari v0.1.7/go.mod h1:0nBj4/5OxTAoIICP4P3+/YJGNf8L7w+gnU1ivj7nFJA= +github.com/99designs/gqlgen v0.17.54 h1:AsF49k/7RJlwA00RQYsYN0T8cQuaosnV/7G1dHC3Uh8= +github.com/99designs/gqlgen v0.17.54/go.mod h1:77/+pVe6zlTsz++oUg2m8VLgzdUPHxjoAG3BxI5y8Rc= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Eyevinn/hls-m3u8 v0.6.0 h1:i4eyofj5zStgUPcy+UwUQ4oOgcLJVGbrw4XOcxVMVw8= +github.com/Eyevinn/hls-m3u8 v0.6.0/go.mod h1:9jzVfwCo1+TC6yz+TKDBt9gIshzI9fhVE7M5AhcOSnQ= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w= +github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI= +github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo= +github.com/RoaringBitmap/roaring v1.2.3 h1:yqreLINqIrX22ErkKI0vY47/ivtJr6n+kMhVOVmhWBY= +github.com/RoaringBitmap/roaring v1.2.3/go.mod h1:plvDsJQpxOC5bw8LRteu/MLWHsHez/3y6cubLI4/1yE= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/Yamashou/gqlgenc v0.25.4 h1:b+RMy15GX1p9rtMWvjivX3kxyhROypHh/THruHRRjcE= +github.com/Yamashou/gqlgenc v0.25.4/go.mod h1:G0g1N81xpIklVdnyboW1zwOHcj/n4hNfhTwfN29Rjig= +github.com/adrg/strutil v0.3.1 h1:OLvSS7CSJO8lBii4YmBt8jiK9QOtB9CzCzwl4Ic/Fz4= +github.com/adrg/strutil v0.3.1/go.mod h1:8h90y18QLrs11IBffcGX3NW/GFBXCMcNg4M7H6MspPA= +github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0 h1:byYvvbfSo3+9efR4IeReh77gVs4PnNDR3AMOE9NJ7a0= +github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0/go.mod h1:q37NoqncT41qKc048STsifIt69LfUJ8SrWWcz/yam5k= +github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk= +github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o= +github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8= +github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI= +github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48= +github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/anacrolix/chansync v0.4.1-0.20240627045151-1aa1ac392fe8 h1:eyb0bBaQKMOh5Se/Qg54shijc8K4zpQiOjEhKFADkQM= +github.com/anacrolix/chansync v0.4.1-0.20240627045151-1aa1ac392fe8/go.mod h1:DZsatdsdXxD0WiwcGl0nJVwyjCKMDv+knl1q2iBjA2k= +github.com/anacrolix/dht/v2 v2.19.2-0.20221121215055-066ad8494444 h1:8V0K09lrGoeT2KRJNOtspA7q+OMxGwQqK/Ug0IiaaRE= +github.com/anacrolix/dht/v2 v2.19.2-0.20221121215055-066ad8494444/go.mod h1:MctKM1HS5YYDb3F30NGJxLE+QPuqWoT5ReW/4jt8xew= +github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= +github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= +github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4= +github.com/anacrolix/envpprof v1.3.0 h1:WJt9bpuT7A/CDCxPOv/eeZqHWlle/Y0keJUvc6tcJDk= +github.com/anacrolix/envpprof v1.3.0/go.mod h1:7QIG4CaX1uexQ3tqd5+BRa/9e2D02Wcertl6Yh0jCB0= +github.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8= +github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca h1:aiiGqSQWjtVNdi8zUMfA//IrM8fPkv2bWwZVPbDe0wg= +github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca/go.mod h1:MN3ve08Z3zSV/rTuX/ouI4lNdlfTxgdafQJiLzyNRB8= +github.com/anacrolix/go-libutp v1.3.2 h1:WswiaxTIogchbkzNgGHuHRfbrYLpv4o290mlvcx+++M= +github.com/anacrolix/go-libutp v1.3.2/go.mod h1:fCUiEnXJSe3jsPG554A200Qv+45ZzIIyGEvE56SHmyA= +github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= +github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= +github.com/anacrolix/log v0.13.1/go.mod h1:D4+CvN8SnruK6zIFS/xPoRJmtvtnxs+CSfDQ+BFxZ68= +github.com/anacrolix/log v0.14.2/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2RdinFLdY= +github.com/anacrolix/log v0.16.0 h1:DSuyb5kAJwl3Y0X1TRcStVrTS9ST9b0BHW+7neE4Xho= +github.com/anacrolix/log v0.16.0/go.mod h1:m0poRtlr41mriZlXBQ9SOVZ8yZBkLjOkDhd5Li5pITA= +github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62 h1:P04VG6Td13FHMgS5ZBcJX23NPC/fiC4cp9bXwYujdYM= +github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM= +github.com/anacrolix/missinggo v0.0.0-20180725070939-60ef2fbf63df/go.mod h1:kwGiTUTZ0+p4vAz3VbAI5a30t2YbvemcmspjKwrAz5s= +github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= +github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= +github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y= +github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw= +github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc= +github.com/anacrolix/missinggo/perf v1.0.0 h1:7ZOGYziGEBytW49+KmYGTaNfnwUqP1HBsy6BqESAJVw= +github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ= +github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY= +github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA= +github.com/anacrolix/missinggo/v2 v2.7.4 h1:47h5OXoPV8JbA/ACA+FLwKdYbAinuDO8osc2Cu9xkxg= +github.com/anacrolix/missinggo/v2 v2.7.4/go.mod h1:vVO5FEziQm+NFmJesc7StpkquZk+WJFCaL0Wp//2sa0= +github.com/anacrolix/mmsg v1.0.1 h1:TxfpV7kX70m3f/O7ielL/2I3OFkMPjrRCPo7+4X5AWw= +github.com/anacrolix/mmsg v1.0.1/go.mod h1:x8kRaJY/dCrY9Al0PEcj1mb/uFHwP6GCJ9fLl4thEPc= +github.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM= +github.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE48WBCi/OOrMM= +github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg= +github.com/anacrolix/stm v0.4.0 h1:tOGvuFwaBjeu1u9X1eIh9TX8OEedEiEQ1se1FjhFnXY= +github.com/anacrolix/stm v0.4.0/go.mod h1:GCkwqWoAsP7RfLW+jw+Z0ovrt2OO7wRzcTtFYMYY5t8= +github.com/anacrolix/sync v0.0.0-20180808010631-44578de4e778/go.mod h1:s735Etp3joe/voe2sdaXLcqDdJSay1O0OPnM0ystjqk= +github.com/anacrolix/sync v0.3.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g= +github.com/anacrolix/sync v0.5.1 h1:FbGju6GqSjzVoTgcXTUKkF041lnZkG5P0C3T5RL3SGc= +github.com/anacrolix/sync v0.5.1/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g= +github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= +github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= +github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8= +github.com/anacrolix/torrent v1.58.1 h1:6FP+KH57b1gyT2CpVL9fEqf9MGJEgh3xw1VA8rI0pW8= +github.com/anacrolix/torrent v1.58.1/go.mod h1:/7ZdLuHNKgtCE1gjYJCfbtG9JodBcDaF5ip5EUWRtk8= +github.com/anacrolix/upnp v0.1.4 h1:+2t2KA6QOhm/49zeNyeVwDu1ZYS9dB9wfxyVvh/wk7U= +github.com/anacrolix/upnp v0.1.4/go.mod h1:Qyhbqo69gwNWvEk1xNTXsS5j7hMHef9hdr984+9fIic= +github.com/anacrolix/utp v0.1.0 h1:FOpQOmIwYsnENnz7tAGohA+r6iXpRjrq8ssKSre2Cp4= +github.com/anacrolix/utp v0.1.0/go.mod h1:MDwc+vsGEq7RMw6lr2GKOEqjWny5hO5OZXRVNaBJ2Dk= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/antchfx/htmlquery v1.3.2 h1:85YdttVkR1rAY+Oiv/nKI4FCimID+NXhDn82kz3mEvs= +github.com/antchfx/htmlquery v1.3.2/go.mod h1:1mbkcEgEarAokJiWhTfr4hR06w/q2ZZjnYLrDt6CTUk= +github.com/antchfx/xmlquery v1.4.1 h1:YgpSwbeWvLp557YFTi8E3z6t6/hYjmFEtiEKbDfEbl0= +github.com/antchfx/xmlquery v1.4.1/go.mod h1:lKezcT8ELGt8kW5L+ckFMTbgdR61/odpPgDv8Gvi1fI= +github.com/antchfx/xpath v1.3.1 h1:PNbFuUqHwWl0xRjvUPjJ95Agbmdj2uzzIwmQKgu4oCk= +github.com/antchfx/xpath v1.3.1/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8= +github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= +github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg= +github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI= +github.com/benbjohnson/immutable v0.3.0 h1:TVRhuZx2wG9SZ0LRdqlbs9S5BZ6Y24hJEHTCgWHZEIw= +github.com/benbjohnson/immutable v0.3.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= +github.com/bits-and-blooms/bitset v1.2.2 h1:J5gbX05GpMdBjCvQ9MteIg2KKDExr7DrgK+Yc15FvIk= +github.com/bits-and-blooms/bitset v1.2.2/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= +github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= +github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8= +github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY= +github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM= +github.com/dop251/goja v0.0.0-20250531102226-cb187b08699c h1:In87uFQZsuGfjDDNfWnzMVY6JVTwc8XYMl6W2DAmNjk= +github.com/dop251/goja v0.0.0-20250531102226-cb187b08699c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= +github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 h1:fuHXpEVTTk7TilRdfGRLHpiTD6tnT0ihEowCfWjlFvw= +github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0= +github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ= +github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= +github.com/evanw/esbuild v0.25.8 h1:nSMdIN7nu2UH6APeDSpaQnz90JOPJxcVZe9DfI0ezjc= +github.com/evanw/esbuild v0.25.8/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 h1:ygs9POGDQpQGLJPlq4+0LBUmMBNox1N4JSpw+OETcvI= +github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= +github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= +github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= +github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 h1:OyQmpAN302wAopDgwVjgs2HkFawP9ahIEqkUYz7V7CA= +github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916/go.mod h1:DADrR88ONKPPeSGjFp5iEN55Arx3fi2qXZeKCYDpbmU= +github.com/go-llsqlite/crawshaw v0.5.2-0.20240425034140-f30eb7704568 h1:3EpZo8LxIzF4q3BT+vttQQlRfA6uTtTb/cxVisWa5HM= +github.com/go-llsqlite/crawshaw v0.5.2-0.20240425034140-f30eb7704568/go.mod h1:/YJdV7uBQaYDE0fwe4z3wwJIZBJxdYzd38ICggWqtaE= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI= +github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gonutz/w32/v2 v2.12.1 h1:ZTWg6ZlETDfWK1Qxx+rdWQdQWZwfhiXoyvxzFYdgsUY= +github.com/gonutz/w32/v2 v2.12.1/go.mod h1:MgtHx0AScDVNKyB+kjyPder4xIi3XAcHS6LDDU2DmdE= +github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 h1:gD0vax+4I+mAj+jEChEf25Ia07Jq7kYOFO5PPhAxFl4= +github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hekmon/cunits/v2 v2.1.0 h1:k6wIjc4PlacNOHwKEMBgWV2/c8jyD4eRMs5mR1BBhI0= +github.com/hekmon/cunits/v2 v2.1.0/go.mod h1:9r1TycXYXaTmEWlAIfFV8JT+Xo59U96yUJAYHxzii2M= +github.com/hekmon/transmissionrpc/v3 v3.0.0 h1:0Fb11qE0IBh4V4GlOwHNYpqpjcYDp5GouolwrpmcUDQ= +github.com/hekmon/transmissionrpc/v3 v3.0.0/go.mod h1:38SlNhFzinVUuY87wGj3acOmRxeYZAZfrj6Re7UgCDg= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= +github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= +github.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= +github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= +github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= +github.com/imroc/req/v3 v3.54.0 h1:kwWJSpT7OvjJ/Q8ykp+69Ye5H486RKDcgEoepw1Ren4= +github.com/imroc/req/v3 v3.54.0/go.mod h1:P8gCJjG/XNUFeP6WOi40VAXfYwT+uPM00xvoBWiwzUQ= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= +github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws= +github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= +github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY= +github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/ncruces/go-dns v1.2.7 h1:NMA7vFqXUl+nBhGFlleLyo2ni3Lqv3v+qFWZidzRemI= +github.com/ncruces/go-dns v1.2.7/go.mod h1:SqmhVMBd8Wr7hsu3q6yTt6/Jno/xLMrbse/JLOMBo1Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/neilotoole/fifomu v0.1.2 h1:sgJhcOTlEXGVj/nS5Bb8/qV+1wgmk+KPavcNuDw0rDM= +github.com/neilotoole/fifomu v0.1.2/go.mod h1:9di2j+xBgr+nX6IPmpwQVxKt6yzgPLk9WXEj/aLwcao= +github.com/neilotoole/streamcache v0.3.5 h1:8YVgTcd3OpTC46zduXJE4WAhTjxQCEBHYVGDgQ6R3ss= +github.com/neilotoole/streamcache v0.3.5/go.mod h1:yYJLcdAWI6jMeSSIfQ8vIydWxsrdAdGGNbYMXvWKV6M= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= +github.com/nwaples/rardecode/v2 v2.1.1 h1:OJaYalXdliBUXPmC8CZGQ7oZDxzX1/5mQmgn0/GASew= +github.com/nwaples/rardecode/v2 v2.1.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA= +github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE= +github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM= +github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU= +github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s= +github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg= +github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= +github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= +github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk= +github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw= +github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= +github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= +github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= +github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= +github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= +github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= +github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8= +github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE= +github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI= +github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/refraction-networking/utls v1.7.3 h1:L0WRhHY7Oq1T0zkdzVZMR6zWZv+sXbHB9zcuvsAEqCo= +github.com/refraction-networking/utls v1.7.3/go.mod h1:TUhh27RHMGtQvjQq+RyO11P6ZNQNBb3N0v7wsEjKAIQ= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 h1:Lt9DzQALzHoDwMBGJ6v8ObDPR0dzr2a6sXTB1Fq7IHs= +github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= +github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= +github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= +github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= +github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/samber/mo v1.15.0 h1:fxe9ouq0Mo7obixDigMJnmaDusT1yrvWHcqM8MmNDNU= +github.com/samber/mo v1.15.0/go.mod h1:BfkrCPuYzVG3ZljnZB783WIJIGk1mcZr9c9CPf8tAxs= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs= +github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= +github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= +github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= +github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= +github.com/tidwall/btree v1.6.0 h1:LDZfKfQIBHGHWSwckhXI0RPSXzlo+KYdjK7FWSqOzzg= +github.com/tidwall/btree v1.6.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY= +github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8= +github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= +github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= +github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xfrr/goffmpeg v1.0.0 h1:trxuLNb9ys50YlV7gTVNAII9J0r00WWqCGTE46Gc3XU= +github.com/xfrr/goffmpeg v1.0.0/go.mod h1:zjLRiirHnip+/hVAT3lVE3QZ6SGynr0hcctUMNNISdQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/ziflex/lecho/v3 v3.8.0 h1:de/IyTw5jykpb0GKGk7Di5Y6IeeLpr2PdEBvTvsktD0= +github.com/ziflex/lecho/v3 v3.8.0/go.mod h1:2GzFCQn/W809nLzikFiHkubtU08QRXyE6+VQ9nAhHPE= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= +golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/vansante/go-ffprobe.v2 v2.2.1 h1:sFV08OT1eZ1yroLCZVClIVd9YySgCh9eGjBWO0oRayI= +gopkg.in/vansante/go-ffprobe.v2 v2.2.1/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= +gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= +lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +zombiezen.com/go/sqlite v0.13.1 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o= +zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4= diff --git a/seanime-2.9.10/internal/README.md b/seanime-2.9.10/internal/README.md new file mode 100644 index 0000000..a58c40c --- /dev/null +++ b/seanime-2.9.10/internal/README.md @@ -0,0 +1,83 @@ +

+preview +

+ +

Seanime Server

+ +- `api`: Third-party APIs + - `anilist`: AniList structs and methods + - `anizip`: Metadata API + - `filler`: Filler API + - `listsync` + - `mal`: MyAnimeList API + - `mappings`: Mapping API + - `metadata`: **Metadata module** for anime + - `tvdb`: TheTVDB API +- `constants`: Version, keys +- `core` + - `app.go`: **Shared app struct** + - `config.go`: Configuration + - `extensions.go`: Load built-in extensions + - `fiber.go`: HTTP server + - `watcher.go`: Library watcher +- `cron`: Background tasks +- `database` + - `db`: **Database module** + - `db_bridge`: Helper methods to avoid circular dependencies + - `models`: Database models +- `debrid`: **Debrid module** + - `debrid`: Structs and interfaces + - `client`: **Debrid repository** for streaming, download + - `torbox` + - `realdebrid` +- `discordrpc`: Discord RPC + - `client` + - `ipc` + - `presence`: **Discord Rich Presence module** +- `events`: **Websocket Event Manager module** and constants +- `extensions`: Structs and interfaces +- `extension_playground`: **Extension Playground module** +- `extension_repo`: **Extension Repository module** +- `handlers`: API handlers +- `library` + - `anime`: Library structs and methods + - `autodownloader` **Auto downloader module** + - `autoscanner`: **Auto scanner module** + - `filesystem`: File system methods + - `playbackmanager`: **Playback Manager module** for progress tracking + - `scanner`: **Scanner module** + - `summary`: Scan summary +- `manga`: Manga structs and **Manga Downloader module** + - `downloader`: Chapter downloader structs and methods + - `providers`: Online provider structs and methods +- `mediaplayers` + - `mediaplayer`: **Media Player Repository** module + - `mpchc` + - `mpv` + - `mpvipc` + - `vlc` +- `mediastream`: **Media Stream Repository** module + - `transcoder`: Transcoder + - `videofile`: Media metadata +- `notifier` +- `onlinestream`: **Onlinestream module** + - `providers`: Stream providers + - `sources`: Video server sources +- `platforms` + - `platform`: Platform structs and methods + - `anilist_platform` + - `local_platform` +- `test_utils`: Test methods +- `torrentstream`: **Torrent Stream Repository** module +- `sync`: **Sync/Offline module** +- `test_utils`: Test methods +- `torrent_clients` + - `torrent_client`: **Torrent Client Repository** module + - `qbittorrent` + - `transmission` +- `torrents` + - `analyzer`: Scan and identify torrent files + - `animetosho` + - `nyaa` + - `seadex` + - `torrent`: Torrent structs and methods diff --git a/seanime-2.9.10/internal/api/anilist/.gqlgenc.yml b/seanime-2.9.10/internal/api/anilist/.gqlgenc.yml new file mode 100644 index 0000000..4178c1a --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/.gqlgenc.yml @@ -0,0 +1,14 @@ +model: + filename: ./models_gen.go +client: + filename: ./client_gen.go +models: + DateTime: + model: github.com/99designs/gqlgen/graphql.Time +endpoint: + url: https://graphql.anilist.co +query: + - "./queries/*.graphql" +generate: + clientV2: true + clientInterfaceName: "GithubGraphQLClient" \ No newline at end of file diff --git a/seanime-2.9.10/internal/api/anilist/client.go b/seanime-2.9.10/internal/api/anilist/client.go new file mode 100644 index 0000000..cda98de --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/client.go @@ -0,0 +1,407 @@ +package anilist + +import ( + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "net/http" + "seanime/internal/events" + "seanime/internal/util" + "strconv" + "time" + + "github.com/Yamashou/gqlgenc/clientv2" + "github.com/Yamashou/gqlgenc/graphqljson" + "github.com/goccy/go-json" + "github.com/rs/zerolog" +) + +var ( + // ErrNotAuthenticated is returned when trying to access an Anilist API endpoint that requires authentication, + // but the client is not authenticated. + ErrNotAuthenticated = errors.New("not authenticated") +) + +type AnilistClient interface { + IsAuthenticated() bool + AnimeCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollection, error) + AnimeCollectionWithRelations(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollectionWithRelations, error) + BaseAnimeByMalID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByMalID, error) + BaseAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByID, error) + SearchBaseAnimeByIds(ctx context.Context, ids []*int, page *int, perPage *int, status []*MediaStatus, inCollection *bool, sort []*MediaSort, season *MediaSeason, year *int, genre *string, format *MediaFormat, interceptors ...clientv2.RequestInterceptor) (*SearchBaseAnimeByIds, error) + CompleteAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*CompleteAnimeByID, error) + AnimeDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*AnimeDetailsByID, error) + ListAnime(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, season *MediaSeason, seasonYear *int, format *MediaFormat, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListAnime, error) + ListRecentAnime(ctx context.Context, page *int, perPage *int, airingAtGreater *int, airingAtLesser *int, notYetAired *bool, interceptors ...clientv2.RequestInterceptor) (*ListRecentAnime, error) + UpdateMediaListEntry(ctx context.Context, mediaID *int, status *MediaListStatus, scoreRaw *int, progress *int, startedAt *FuzzyDateInput, completedAt *FuzzyDateInput, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntry, error) + UpdateMediaListEntryProgress(ctx context.Context, mediaID *int, progress *int, status *MediaListStatus, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryProgress, error) + UpdateMediaListEntryRepeat(ctx context.Context, mediaID *int, repeat *int, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryRepeat, error) + DeleteEntry(ctx context.Context, mediaListEntryID *int, interceptors ...clientv2.RequestInterceptor) (*DeleteEntry, error) + MangaCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*MangaCollection, error) + SearchBaseManga(ctx context.Context, page *int, perPage *int, sort []*MediaSort, search *string, status []*MediaStatus, interceptors ...clientv2.RequestInterceptor) (*SearchBaseManga, error) + BaseMangaByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseMangaByID, error) + MangaDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*MangaDetailsByID, error) + ListManga(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, startDateGreater *string, startDateLesser *string, format *MediaFormat, countryOfOrigin *string, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListManga, error) + ViewerStats(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*ViewerStats, error) + StudioDetails(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*StudioDetails, error) + GetViewer(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*GetViewer, error) + AnimeAiringSchedule(ctx context.Context, ids []*int, season *MediaSeason, seasonYear *int, previousSeason *MediaSeason, previousSeasonYear *int, nextSeason *MediaSeason, nextSeasonYear *int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringSchedule, error) + AnimeAiringScheduleRaw(ctx context.Context, ids []*int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringScheduleRaw, error) +} + +type ( + // AnilistClientImpl is a wrapper around the AniList API client. + AnilistClientImpl struct { + Client *Client + logger *zerolog.Logger + token string // The token used for authentication with the AniList API + } +) + +// NewAnilistClient creates a new AnilistClientImpl with the given token. +// The token is used for authorization when making requests to the AniList API. +func NewAnilistClient(token string) *AnilistClientImpl { + ac := &AnilistClientImpl{ + token: token, + Client: &Client{ + Client: clientv2.NewClient(http.DefaultClient, "https://graphql.anilist.co", nil, + func(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}, next clientv2.RequestInterceptorFunc) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if len(token) > 0 { + req.Header.Set("Authorization", "Bearer "+token) + } + return next(ctx, req, gqlInfo, res) + }), + }, + logger: util.NewLogger(), + } + + ac.Client.Client.CustomDo = ac.customDoFunc + + return ac +} + +func (ac *AnilistClientImpl) IsAuthenticated() bool { + if ac.Client == nil || ac.Client.Client == nil { + return false + } + if len(ac.token) == 0 { + return false + } + // If the token is not empty, we are authenticated + return true +} + +//////////////////////////////// +// Authenticated +//////////////////////////////// + +func (ac *AnilistClientImpl) UpdateMediaListEntry(ctx context.Context, mediaID *int, status *MediaListStatus, scoreRaw *int, progress *int, startedAt *FuzzyDateInput, completedAt *FuzzyDateInput, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntry, error) { + if !ac.IsAuthenticated() { + return nil, ErrNotAuthenticated + } + ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry") + return ac.Client.UpdateMediaListEntry(ctx, mediaID, status, scoreRaw, progress, startedAt, completedAt, interceptors...) +} + +func (ac *AnilistClientImpl) UpdateMediaListEntryProgress(ctx context.Context, mediaID *int, progress *int, status *MediaListStatus, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryProgress, error) { + if !ac.IsAuthenticated() { + return nil, ErrNotAuthenticated + } + ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry progress") + return ac.Client.UpdateMediaListEntryProgress(ctx, mediaID, progress, status, interceptors...) +} + +func (ac *AnilistClientImpl) UpdateMediaListEntryRepeat(ctx context.Context, mediaID *int, repeat *int, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryRepeat, error) { + if !ac.IsAuthenticated() { + return nil, ErrNotAuthenticated + } + ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry repeat") + return ac.Client.UpdateMediaListEntryRepeat(ctx, mediaID, repeat, interceptors...) +} + +func (ac *AnilistClientImpl) DeleteEntry(ctx context.Context, mediaListEntryID *int, interceptors ...clientv2.RequestInterceptor) (*DeleteEntry, error) { + if !ac.IsAuthenticated() { + return nil, ErrNotAuthenticated + } + ac.logger.Debug().Int("entryId", *mediaListEntryID).Msg("anilist: Deleting media list entry") + return ac.Client.DeleteEntry(ctx, mediaListEntryID, interceptors...) +} + +func (ac *AnilistClientImpl) AnimeCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollection, error) { + if !ac.IsAuthenticated() { + return nil, ErrNotAuthenticated + } + ac.logger.Debug().Msg("anilist: Fetching anime collection") + return ac.Client.AnimeCollection(ctx, userName, interceptors...) +} + +func (ac *AnilistClientImpl) AnimeCollectionWithRelations(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollectionWithRelations, error) { + if !ac.IsAuthenticated() { + return nil, ErrNotAuthenticated + } + ac.logger.Debug().Msg("anilist: Fetching anime collection with relations") + return ac.Client.AnimeCollectionWithRelations(ctx, userName, interceptors...) +} + +func (ac *AnilistClientImpl) GetViewer(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*GetViewer, error) { + if !ac.IsAuthenticated() { + return nil, ErrNotAuthenticated + } + ac.logger.Debug().Msg("anilist: Fetching viewer") + return ac.Client.GetViewer(ctx, interceptors...) +} + +func (ac *AnilistClientImpl) MangaCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*MangaCollection, error) { + if !ac.IsAuthenticated() { + return nil, ErrNotAuthenticated + } + ac.logger.Debug().Msg("anilist: Fetching manga collection") + return ac.Client.MangaCollection(ctx, userName, interceptors...) +} + +func (ac *AnilistClientImpl) ViewerStats(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*ViewerStats, error) { + if !ac.IsAuthenticated() { + return nil, ErrNotAuthenticated + } + ac.logger.Debug().Msg("anilist: Fetching stats") + return ac.Client.ViewerStats(ctx, interceptors...) +} + +//////////////////////////////// +// Not authenticated +//////////////////////////////// + +func (ac *AnilistClientImpl) BaseAnimeByMalID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByMalID, error) { + return ac.Client.BaseAnimeByMalID(ctx, id, interceptors...) +} + +func (ac *AnilistClientImpl) BaseAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByID, error) { + ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching anime") + return ac.Client.BaseAnimeByID(ctx, id, interceptors...) +} + +func (ac *AnilistClientImpl) AnimeDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*AnimeDetailsByID, error) { + ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching anime details") + return ac.Client.AnimeDetailsByID(ctx, id, interceptors...) +} + +func (ac *AnilistClientImpl) CompleteAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*CompleteAnimeByID, error) { + ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching complete media") + return ac.Client.CompleteAnimeByID(ctx, id, interceptors...) +} + +func (ac *AnilistClientImpl) ListAnime(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, season *MediaSeason, seasonYear *int, format *MediaFormat, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListAnime, error) { + ac.logger.Debug().Msg("anilist: Fetching media list") + return ac.Client.ListAnime(ctx, page, search, perPage, sort, status, genres, averageScoreGreater, season, seasonYear, format, isAdult, interceptors...) +} + +func (ac *AnilistClientImpl) ListRecentAnime(ctx context.Context, page *int, perPage *int, airingAtGreater *int, airingAtLesser *int, notYetAired *bool, interceptors ...clientv2.RequestInterceptor) (*ListRecentAnime, error) { + ac.logger.Debug().Msg("anilist: Fetching recent media list") + return ac.Client.ListRecentAnime(ctx, page, perPage, airingAtGreater, airingAtLesser, notYetAired, interceptors...) +} + +func (ac *AnilistClientImpl) SearchBaseManga(ctx context.Context, page *int, perPage *int, sort []*MediaSort, search *string, status []*MediaStatus, interceptors ...clientv2.RequestInterceptor) (*SearchBaseManga, error) { + ac.logger.Debug().Msg("anilist: Searching manga") + return ac.Client.SearchBaseManga(ctx, page, perPage, sort, search, status, interceptors...) +} + +func (ac *AnilistClientImpl) BaseMangaByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseMangaByID, error) { + ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching manga") + return ac.Client.BaseMangaByID(ctx, id, interceptors...) +} + +func (ac *AnilistClientImpl) MangaDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*MangaDetailsByID, error) { + ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching manga details") + return ac.Client.MangaDetailsByID(ctx, id, interceptors...) +} + +func (ac *AnilistClientImpl) ListManga(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, startDateGreater *string, startDateLesser *string, format *MediaFormat, countryOfOrigin *string, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListManga, error) { + ac.logger.Debug().Msg("anilist: Fetching manga list") + return ac.Client.ListManga(ctx, page, search, perPage, sort, status, genres, averageScoreGreater, startDateGreater, startDateLesser, format, countryOfOrigin, isAdult, interceptors...) +} + +func (ac *AnilistClientImpl) StudioDetails(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*StudioDetails, error) { + ac.logger.Debug().Int("studioId", *id).Msg("anilist: Fetching studio details") + return ac.Client.StudioDetails(ctx, id, interceptors...) +} + +func (ac *AnilistClientImpl) SearchBaseAnimeByIds(ctx context.Context, ids []*int, page *int, perPage *int, status []*MediaStatus, inCollection *bool, sort []*MediaSort, season *MediaSeason, year *int, genre *string, format *MediaFormat, interceptors ...clientv2.RequestInterceptor) (*SearchBaseAnimeByIds, error) { + ac.logger.Debug().Msg("anilist: Searching anime by ids") + return ac.Client.SearchBaseAnimeByIds(ctx, ids, page, perPage, status, inCollection, sort, season, year, genre, format, interceptors...) +} + +func (ac *AnilistClientImpl) AnimeAiringSchedule(ctx context.Context, ids []*int, season *MediaSeason, seasonYear *int, previousSeason *MediaSeason, previousSeasonYear *int, nextSeason *MediaSeason, nextSeasonYear *int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringSchedule, error) { + ac.logger.Debug().Msg("anilist: Fetching schedule") + return ac.Client.AnimeAiringSchedule(ctx, ids, season, seasonYear, previousSeason, previousSeasonYear, nextSeason, nextSeasonYear, interceptors...) +} + +func (ac *AnilistClientImpl) AnimeAiringScheduleRaw(ctx context.Context, ids []*int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringScheduleRaw, error) { + ac.logger.Debug().Msg("anilist: Fetching schedule") + return ac.Client.AnimeAiringScheduleRaw(ctx, ids, interceptors...) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var sentRateLimitWarningTime = time.Now().Add(-10 * time.Second) + +// customDoFunc is a custom request interceptor function that handles rate limiting and retries. +func (ac *AnilistClientImpl) customDoFunc(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}) (err error) { + var rlRemainingStr string + + reqTime := time.Now() + defer func() { + timeSince := time.Since(reqTime) + formattedDur := timeSince.Truncate(time.Millisecond).String() + if err != nil { + ac.logger.Error().Str("duration", formattedDur).Str("rlr", rlRemainingStr).Err(err).Msg("anilist: Failed Request") + } else { + if timeSince > 900*time.Millisecond { + ac.logger.Warn().Str("rtt", formattedDur).Str("rlr", rlRemainingStr).Msg("anilist: Successful Request (slow)") + } else { + ac.logger.Info().Str("rtt", formattedDur).Str("rlr", rlRemainingStr).Msg("anilist: Successful Request") + } + } + }() + + client := http.DefaultClient + var resp *http.Response + + retryCount := 2 + + for i := 0; i < retryCount; i++ { + + // Reset response body for retry + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + + // Recreate the request body if it was read in a previous attempt + if req.GetBody != nil { + newBody, err := req.GetBody() + if err != nil { + return fmt.Errorf("failed to get request body: %w", err) + } + req.Body = newBody + } + + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + + rlRemainingStr = resp.Header.Get("X-Ratelimit-Remaining") + rlRetryAfterStr := resp.Header.Get("Retry-After") + //println("Remaining:", rlRemainingStr, " | RetryAfter:", rlRetryAfterStr) + + // If we have a rate limit, sleep for the time + rlRetryAfter, err := strconv.Atoi(rlRetryAfterStr) + if err == nil { + ac.logger.Warn().Msgf("anilist: Rate limited, retrying in %d seconds", rlRetryAfter+1) + if time.Since(sentRateLimitWarningTime) > 10*time.Second { + events.GlobalWSEventManager.SendEvent(events.WarningToast, "anilist: Rate limited, retrying in "+strconv.Itoa(rlRetryAfter+1)+" seconds") + sentRateLimitWarningTime = time.Now() + } + select { + case <-time.After(time.Duration(rlRetryAfter+1) * time.Second): + continue + } + } + + if rlRemainingStr == "" { + select { + case <-time.After(5 * time.Second): + continue + } + } + + break + } + + defer resp.Body.Close() + + if resp.Header.Get("Content-Encoding") == "gzip" { + resp.Body, err = gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("gzip decode failed: %w", err) + } + } + + var body []byte + body, err = io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + err = parseResponse(body, resp.StatusCode, res) + return +} + +func parseResponse(body []byte, httpCode int, result interface{}) error { + errResponse := &clientv2.ErrorResponse{} + isKOCode := httpCode < 200 || 299 < httpCode + if isKOCode { + errResponse.NetworkError = &clientv2.HTTPError{ + Code: httpCode, + Message: fmt.Sprintf("Response body %s", string(body)), + } + } + + // some servers return a graphql error with a non OK http code, try anyway to parse the body + if err := unmarshal(body, result); err != nil { + var gqlErr *clientv2.GqlErrorList + if errors.As(err, &gqlErr) { + errResponse.GqlErrors = &gqlErr.Errors + } else if !isKOCode { + return err + } + } + + if errResponse.HasErrors() { + return errResponse + } + + return nil +} + +// response is a GraphQL layer response from a handler. +type response struct { + Data json.RawMessage `json:"data"` + Errors json.RawMessage `json:"errors"` +} + +func unmarshal(data []byte, res interface{}) error { + ParseDataWhenErrors := false + resp := response{} + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("failed to decode data %s: %w", string(data), err) + } + + var err error + if resp.Errors != nil && len(resp.Errors) > 0 { + // try to parse standard graphql error + err = &clientv2.GqlErrorList{} + if e := json.Unmarshal(data, err); e != nil { + return fmt.Errorf("faild to parse graphql errors. Response content %s - %w", string(data), e) + } + + // if ParseDataWhenErrors is true, try to parse data as well + if !ParseDataWhenErrors { + return err + } + } + + if errData := graphqljson.UnmarshalData(resp.Data, res); errData != nil { + // if ParseDataWhenErrors is true, and we failed to unmarshal data, return the actual error + if ParseDataWhenErrors { + return err + } + + return fmt.Errorf("failed to decode data into response %s: %w", string(data), errData) + } + + return err +} diff --git a/seanime-2.9.10/internal/api/anilist/client_gen.go b/seanime-2.9.10/internal/api/anilist/client_gen.go new file mode 100644 index 0000000..123335e --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/client_gen.go @@ -0,0 +1,9542 @@ +// Code generated by github.com/Yamashou/gqlgenc, DO NOT EDIT. + +package anilist + +import ( + "context" + "net/http" + + "github.com/Yamashou/gqlgenc/clientv2" +) + +type GithubGraphQLClient interface { + AnimeCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollection, error) + AnimeCollectionWithRelations(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollectionWithRelations, error) + BaseAnimeByMalID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByMalID, error) + BaseAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByID, error) + SearchBaseAnimeByIds(ctx context.Context, ids []*int, page *int, perPage *int, status []*MediaStatus, inCollection *bool, sort []*MediaSort, season *MediaSeason, year *int, genre *string, format *MediaFormat, interceptors ...clientv2.RequestInterceptor) (*SearchBaseAnimeByIds, error) + CompleteAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*CompleteAnimeByID, error) + AnimeDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*AnimeDetailsByID, error) + ListAnime(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, season *MediaSeason, seasonYear *int, format *MediaFormat, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListAnime, error) + ListRecentAnime(ctx context.Context, page *int, perPage *int, airingAtGreater *int, airingAtLesser *int, notYetAired *bool, interceptors ...clientv2.RequestInterceptor) (*ListRecentAnime, error) + AnimeAiringSchedule(ctx context.Context, ids []*int, season *MediaSeason, seasonYear *int, previousSeason *MediaSeason, previousSeasonYear *int, nextSeason *MediaSeason, nextSeasonYear *int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringSchedule, error) + AnimeAiringScheduleRaw(ctx context.Context, ids []*int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringScheduleRaw, error) + UpdateMediaListEntry(ctx context.Context, mediaID *int, status *MediaListStatus, scoreRaw *int, progress *int, startedAt *FuzzyDateInput, completedAt *FuzzyDateInput, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntry, error) + UpdateMediaListEntryProgress(ctx context.Context, mediaID *int, progress *int, status *MediaListStatus, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryProgress, error) + DeleteEntry(ctx context.Context, mediaListEntryID *int, interceptors ...clientv2.RequestInterceptor) (*DeleteEntry, error) + UpdateMediaListEntryRepeat(ctx context.Context, mediaID *int, repeat *int, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryRepeat, error) + MangaCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*MangaCollection, error) + SearchBaseManga(ctx context.Context, page *int, perPage *int, sort []*MediaSort, search *string, status []*MediaStatus, interceptors ...clientv2.RequestInterceptor) (*SearchBaseManga, error) + BaseMangaByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseMangaByID, error) + MangaDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*MangaDetailsByID, error) + ListManga(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, startDateGreater *string, startDateLesser *string, format *MediaFormat, countryOfOrigin *string, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListManga, error) + ViewerStats(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*ViewerStats, error) + StudioDetails(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*StudioDetails, error) + GetViewer(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*GetViewer, error) +} + +type Client struct { + Client *clientv2.Client +} + +func NewClient(cli *http.Client, baseURL string, options *clientv2.Options, interceptors ...clientv2.RequestInterceptor) GithubGraphQLClient { + return &Client{Client: clientv2.NewClient(cli, baseURL, options, interceptors...)} +} + +type BaseAnime struct { + ID int "json:\"id\" graphql:\"id\"" + IDMal *int "json:\"idMal,omitempty\" graphql:\"idMal\"" + SiteURL *string "json:\"siteUrl,omitempty\" graphql:\"siteUrl\"" + Status *MediaStatus "json:\"status,omitempty\" graphql:\"status\"" + Season *MediaSeason "json:\"season,omitempty\" graphql:\"season\"" + Type *MediaType "json:\"type,omitempty\" graphql:\"type\"" + Format *MediaFormat "json:\"format,omitempty\" graphql:\"format\"" + SeasonYear *int "json:\"seasonYear,omitempty\" graphql:\"seasonYear\"" + BannerImage *string "json:\"bannerImage,omitempty\" graphql:\"bannerImage\"" + Episodes *int "json:\"episodes,omitempty\" graphql:\"episodes\"" + Synonyms []*string "json:\"synonyms,omitempty\" graphql:\"synonyms\"" + IsAdult *bool "json:\"isAdult,omitempty\" graphql:\"isAdult\"" + CountryOfOrigin *string "json:\"countryOfOrigin,omitempty\" graphql:\"countryOfOrigin\"" + MeanScore *int "json:\"meanScore,omitempty\" graphql:\"meanScore\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + Genres []*string "json:\"genres,omitempty\" graphql:\"genres\"" + Duration *int "json:\"duration,omitempty\" graphql:\"duration\"" + Trailer *BaseAnime_Trailer "json:\"trailer,omitempty\" graphql:\"trailer\"" + Title *BaseAnime_Title "json:\"title,omitempty\" graphql:\"title\"" + CoverImage *BaseAnime_CoverImage "json:\"coverImage,omitempty\" graphql:\"coverImage\"" + StartDate *BaseAnime_StartDate "json:\"startDate,omitempty\" graphql:\"startDate\"" + EndDate *BaseAnime_EndDate "json:\"endDate,omitempty\" graphql:\"endDate\"" + NextAiringEpisode *BaseAnime_NextAiringEpisode "json:\"nextAiringEpisode,omitempty\" graphql:\"nextAiringEpisode\"" +} + +func (t *BaseAnime) GetID() int { + if t == nil { + t = &BaseAnime{} + } + return t.ID +} +func (t *BaseAnime) GetIDMal() *int { + if t == nil { + t = &BaseAnime{} + } + return t.IDMal +} +func (t *BaseAnime) GetSiteURL() *string { + if t == nil { + t = &BaseAnime{} + } + return t.SiteURL +} +func (t *BaseAnime) GetStatus() *MediaStatus { + if t == nil { + t = &BaseAnime{} + } + return t.Status +} +func (t *BaseAnime) GetSeason() *MediaSeason { + if t == nil { + t = &BaseAnime{} + } + return t.Season +} +func (t *BaseAnime) GetType() *MediaType { + if t == nil { + t = &BaseAnime{} + } + return t.Type +} +func (t *BaseAnime) GetFormat() *MediaFormat { + if t == nil { + t = &BaseAnime{} + } + return t.Format +} +func (t *BaseAnime) GetSeasonYear() *int { + if t == nil { + t = &BaseAnime{} + } + return t.SeasonYear +} +func (t *BaseAnime) GetBannerImage() *string { + if t == nil { + t = &BaseAnime{} + } + return t.BannerImage +} +func (t *BaseAnime) GetEpisodes() *int { + if t == nil { + t = &BaseAnime{} + } + return t.Episodes +} +func (t *BaseAnime) GetSynonyms() []*string { + if t == nil { + t = &BaseAnime{} + } + return t.Synonyms +} +func (t *BaseAnime) GetIsAdult() *bool { + if t == nil { + t = &BaseAnime{} + } + return t.IsAdult +} +func (t *BaseAnime) GetCountryOfOrigin() *string { + if t == nil { + t = &BaseAnime{} + } + return t.CountryOfOrigin +} +func (t *BaseAnime) GetMeanScore() *int { + if t == nil { + t = &BaseAnime{} + } + return t.MeanScore +} +func (t *BaseAnime) GetDescription() *string { + if t == nil { + t = &BaseAnime{} + } + return t.Description +} +func (t *BaseAnime) GetGenres() []*string { + if t == nil { + t = &BaseAnime{} + } + return t.Genres +} +func (t *BaseAnime) GetDuration() *int { + if t == nil { + t = &BaseAnime{} + } + return t.Duration +} +func (t *BaseAnime) GetTrailer() *BaseAnime_Trailer { + if t == nil { + t = &BaseAnime{} + } + return t.Trailer +} +func (t *BaseAnime) GetTitle() *BaseAnime_Title { + if t == nil { + t = &BaseAnime{} + } + return t.Title +} +func (t *BaseAnime) GetCoverImage() *BaseAnime_CoverImage { + if t == nil { + t = &BaseAnime{} + } + return t.CoverImage +} +func (t *BaseAnime) GetStartDate() *BaseAnime_StartDate { + if t == nil { + t = &BaseAnime{} + } + return t.StartDate +} +func (t *BaseAnime) GetEndDate() *BaseAnime_EndDate { + if t == nil { + t = &BaseAnime{} + } + return t.EndDate +} +func (t *BaseAnime) GetNextAiringEpisode() *BaseAnime_NextAiringEpisode { + if t == nil { + t = &BaseAnime{} + } + return t.NextAiringEpisode +} + +type CompleteAnime struct { + ID int "json:\"id\" graphql:\"id\"" + IDMal *int "json:\"idMal,omitempty\" graphql:\"idMal\"" + SiteURL *string "json:\"siteUrl,omitempty\" graphql:\"siteUrl\"" + Status *MediaStatus "json:\"status,omitempty\" graphql:\"status\"" + Season *MediaSeason "json:\"season,omitempty\" graphql:\"season\"" + SeasonYear *int "json:\"seasonYear,omitempty\" graphql:\"seasonYear\"" + Type *MediaType "json:\"type,omitempty\" graphql:\"type\"" + Format *MediaFormat "json:\"format,omitempty\" graphql:\"format\"" + BannerImage *string "json:\"bannerImage,omitempty\" graphql:\"bannerImage\"" + Episodes *int "json:\"episodes,omitempty\" graphql:\"episodes\"" + Synonyms []*string "json:\"synonyms,omitempty\" graphql:\"synonyms\"" + IsAdult *bool "json:\"isAdult,omitempty\" graphql:\"isAdult\"" + CountryOfOrigin *string "json:\"countryOfOrigin,omitempty\" graphql:\"countryOfOrigin\"" + MeanScore *int "json:\"meanScore,omitempty\" graphql:\"meanScore\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + Genres []*string "json:\"genres,omitempty\" graphql:\"genres\"" + Duration *int "json:\"duration,omitempty\" graphql:\"duration\"" + Trailer *CompleteAnime_Trailer "json:\"trailer,omitempty\" graphql:\"trailer\"" + Title *CompleteAnime_Title "json:\"title,omitempty\" graphql:\"title\"" + CoverImage *CompleteAnime_CoverImage "json:\"coverImage,omitempty\" graphql:\"coverImage\"" + StartDate *CompleteAnime_StartDate "json:\"startDate,omitempty\" graphql:\"startDate\"" + EndDate *CompleteAnime_EndDate "json:\"endDate,omitempty\" graphql:\"endDate\"" + NextAiringEpisode *CompleteAnime_NextAiringEpisode "json:\"nextAiringEpisode,omitempty\" graphql:\"nextAiringEpisode\"" + Relations *CompleteAnime_Relations "json:\"relations,omitempty\" graphql:\"relations\"" +} + +func (t *CompleteAnime) GetID() int { + if t == nil { + t = &CompleteAnime{} + } + return t.ID +} +func (t *CompleteAnime) GetIDMal() *int { + if t == nil { + t = &CompleteAnime{} + } + return t.IDMal +} +func (t *CompleteAnime) GetSiteURL() *string { + if t == nil { + t = &CompleteAnime{} + } + return t.SiteURL +} +func (t *CompleteAnime) GetStatus() *MediaStatus { + if t == nil { + t = &CompleteAnime{} + } + return t.Status +} +func (t *CompleteAnime) GetSeason() *MediaSeason { + if t == nil { + t = &CompleteAnime{} + } + return t.Season +} +func (t *CompleteAnime) GetSeasonYear() *int { + if t == nil { + t = &CompleteAnime{} + } + return t.SeasonYear +} +func (t *CompleteAnime) GetType() *MediaType { + if t == nil { + t = &CompleteAnime{} + } + return t.Type +} +func (t *CompleteAnime) GetFormat() *MediaFormat { + if t == nil { + t = &CompleteAnime{} + } + return t.Format +} +func (t *CompleteAnime) GetBannerImage() *string { + if t == nil { + t = &CompleteAnime{} + } + return t.BannerImage +} +func (t *CompleteAnime) GetEpisodes() *int { + if t == nil { + t = &CompleteAnime{} + } + return t.Episodes +} +func (t *CompleteAnime) GetSynonyms() []*string { + if t == nil { + t = &CompleteAnime{} + } + return t.Synonyms +} +func (t *CompleteAnime) GetIsAdult() *bool { + if t == nil { + t = &CompleteAnime{} + } + return t.IsAdult +} +func (t *CompleteAnime) GetCountryOfOrigin() *string { + if t == nil { + t = &CompleteAnime{} + } + return t.CountryOfOrigin +} +func (t *CompleteAnime) GetMeanScore() *int { + if t == nil { + t = &CompleteAnime{} + } + return t.MeanScore +} +func (t *CompleteAnime) GetDescription() *string { + if t == nil { + t = &CompleteAnime{} + } + return t.Description +} +func (t *CompleteAnime) GetGenres() []*string { + if t == nil { + t = &CompleteAnime{} + } + return t.Genres +} +func (t *CompleteAnime) GetDuration() *int { + if t == nil { + t = &CompleteAnime{} + } + return t.Duration +} +func (t *CompleteAnime) GetTrailer() *CompleteAnime_Trailer { + if t == nil { + t = &CompleteAnime{} + } + return t.Trailer +} +func (t *CompleteAnime) GetTitle() *CompleteAnime_Title { + if t == nil { + t = &CompleteAnime{} + } + return t.Title +} +func (t *CompleteAnime) GetCoverImage() *CompleteAnime_CoverImage { + if t == nil { + t = &CompleteAnime{} + } + return t.CoverImage +} +func (t *CompleteAnime) GetStartDate() *CompleteAnime_StartDate { + if t == nil { + t = &CompleteAnime{} + } + return t.StartDate +} +func (t *CompleteAnime) GetEndDate() *CompleteAnime_EndDate { + if t == nil { + t = &CompleteAnime{} + } + return t.EndDate +} +func (t *CompleteAnime) GetNextAiringEpisode() *CompleteAnime_NextAiringEpisode { + if t == nil { + t = &CompleteAnime{} + } + return t.NextAiringEpisode +} +func (t *CompleteAnime) GetRelations() *CompleteAnime_Relations { + if t == nil { + t = &CompleteAnime{} + } + return t.Relations +} + +type BaseCharacter struct { + ID int "json:\"id\" graphql:\"id\"" + IsFavourite bool "json:\"isFavourite\" graphql:\"isFavourite\"" + Gender *string "json:\"gender,omitempty\" graphql:\"gender\"" + Age *string "json:\"age,omitempty\" graphql:\"age\"" + DateOfBirth *BaseCharacter_DateOfBirth "json:\"dateOfBirth,omitempty\" graphql:\"dateOfBirth\"" + Name *BaseCharacter_Name "json:\"name,omitempty\" graphql:\"name\"" + Image *BaseCharacter_Image "json:\"image,omitempty\" graphql:\"image\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + SiteURL *string "json:\"siteUrl,omitempty\" graphql:\"siteUrl\"" +} + +func (t *BaseCharacter) GetID() int { + if t == nil { + t = &BaseCharacter{} + } + return t.ID +} +func (t *BaseCharacter) GetIsFavourite() bool { + if t == nil { + t = &BaseCharacter{} + } + return t.IsFavourite +} +func (t *BaseCharacter) GetGender() *string { + if t == nil { + t = &BaseCharacter{} + } + return t.Gender +} +func (t *BaseCharacter) GetAge() *string { + if t == nil { + t = &BaseCharacter{} + } + return t.Age +} +func (t *BaseCharacter) GetDateOfBirth() *BaseCharacter_DateOfBirth { + if t == nil { + t = &BaseCharacter{} + } + return t.DateOfBirth +} +func (t *BaseCharacter) GetName() *BaseCharacter_Name { + if t == nil { + t = &BaseCharacter{} + } + return t.Name +} +func (t *BaseCharacter) GetImage() *BaseCharacter_Image { + if t == nil { + t = &BaseCharacter{} + } + return t.Image +} +func (t *BaseCharacter) GetDescription() *string { + if t == nil { + t = &BaseCharacter{} + } + return t.Description +} +func (t *BaseCharacter) GetSiteURL() *string { + if t == nil { + t = &BaseCharacter{} + } + return t.SiteURL +} + +type AnimeSchedule struct { + ID int "json:\"id\" graphql:\"id\"" + IDMal *int "json:\"idMal,omitempty\" graphql:\"idMal\"" + Previous *AnimeSchedule_Previous "json:\"previous,omitempty\" graphql:\"previous\"" + Upcoming *AnimeSchedule_Upcoming "json:\"upcoming,omitempty\" graphql:\"upcoming\"" +} + +func (t *AnimeSchedule) GetID() int { + if t == nil { + t = &AnimeSchedule{} + } + return t.ID +} +func (t *AnimeSchedule) GetIDMal() *int { + if t == nil { + t = &AnimeSchedule{} + } + return t.IDMal +} +func (t *AnimeSchedule) GetPrevious() *AnimeSchedule_Previous { + if t == nil { + t = &AnimeSchedule{} + } + return t.Previous +} +func (t *AnimeSchedule) GetUpcoming() *AnimeSchedule_Upcoming { + if t == nil { + t = &AnimeSchedule{} + } + return t.Upcoming +} + +type BaseManga struct { + ID int "json:\"id\" graphql:\"id\"" + IDMal *int "json:\"idMal,omitempty\" graphql:\"idMal\"" + SiteURL *string "json:\"siteUrl,omitempty\" graphql:\"siteUrl\"" + Status *MediaStatus "json:\"status,omitempty\" graphql:\"status\"" + Season *MediaSeason "json:\"season,omitempty\" graphql:\"season\"" + Type *MediaType "json:\"type,omitempty\" graphql:\"type\"" + Format *MediaFormat "json:\"format,omitempty\" graphql:\"format\"" + BannerImage *string "json:\"bannerImage,omitempty\" graphql:\"bannerImage\"" + Chapters *int "json:\"chapters,omitempty\" graphql:\"chapters\"" + Volumes *int "json:\"volumes,omitempty\" graphql:\"volumes\"" + Synonyms []*string "json:\"synonyms,omitempty\" graphql:\"synonyms\"" + IsAdult *bool "json:\"isAdult,omitempty\" graphql:\"isAdult\"" + CountryOfOrigin *string "json:\"countryOfOrigin,omitempty\" graphql:\"countryOfOrigin\"" + MeanScore *int "json:\"meanScore,omitempty\" graphql:\"meanScore\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + Genres []*string "json:\"genres,omitempty\" graphql:\"genres\"" + Title *BaseManga_Title "json:\"title,omitempty\" graphql:\"title\"" + CoverImage *BaseManga_CoverImage "json:\"coverImage,omitempty\" graphql:\"coverImage\"" + StartDate *BaseManga_StartDate "json:\"startDate,omitempty\" graphql:\"startDate\"" + EndDate *BaseManga_EndDate "json:\"endDate,omitempty\" graphql:\"endDate\"" +} + +func (t *BaseManga) GetID() int { + if t == nil { + t = &BaseManga{} + } + return t.ID +} +func (t *BaseManga) GetIDMal() *int { + if t == nil { + t = &BaseManga{} + } + return t.IDMal +} +func (t *BaseManga) GetSiteURL() *string { + if t == nil { + t = &BaseManga{} + } + return t.SiteURL +} +func (t *BaseManga) GetStatus() *MediaStatus { + if t == nil { + t = &BaseManga{} + } + return t.Status +} +func (t *BaseManga) GetSeason() *MediaSeason { + if t == nil { + t = &BaseManga{} + } + return t.Season +} +func (t *BaseManga) GetType() *MediaType { + if t == nil { + t = &BaseManga{} + } + return t.Type +} +func (t *BaseManga) GetFormat() *MediaFormat { + if t == nil { + t = &BaseManga{} + } + return t.Format +} +func (t *BaseManga) GetBannerImage() *string { + if t == nil { + t = &BaseManga{} + } + return t.BannerImage +} +func (t *BaseManga) GetChapters() *int { + if t == nil { + t = &BaseManga{} + } + return t.Chapters +} +func (t *BaseManga) GetVolumes() *int { + if t == nil { + t = &BaseManga{} + } + return t.Volumes +} +func (t *BaseManga) GetSynonyms() []*string { + if t == nil { + t = &BaseManga{} + } + return t.Synonyms +} +func (t *BaseManga) GetIsAdult() *bool { + if t == nil { + t = &BaseManga{} + } + return t.IsAdult +} +func (t *BaseManga) GetCountryOfOrigin() *string { + if t == nil { + t = &BaseManga{} + } + return t.CountryOfOrigin +} +func (t *BaseManga) GetMeanScore() *int { + if t == nil { + t = &BaseManga{} + } + return t.MeanScore +} +func (t *BaseManga) GetDescription() *string { + if t == nil { + t = &BaseManga{} + } + return t.Description +} +func (t *BaseManga) GetGenres() []*string { + if t == nil { + t = &BaseManga{} + } + return t.Genres +} +func (t *BaseManga) GetTitle() *BaseManga_Title { + if t == nil { + t = &BaseManga{} + } + return t.Title +} +func (t *BaseManga) GetCoverImage() *BaseManga_CoverImage { + if t == nil { + t = &BaseManga{} + } + return t.CoverImage +} +func (t *BaseManga) GetStartDate() *BaseManga_StartDate { + if t == nil { + t = &BaseManga{} + } + return t.StartDate +} +func (t *BaseManga) GetEndDate() *BaseManga_EndDate { + if t == nil { + t = &BaseManga{} + } + return t.EndDate +} + +type UserFormatStats struct { + Format *MediaFormat "json:\"format,omitempty\" graphql:\"format\"" + MeanScore float64 "json:\"meanScore\" graphql:\"meanScore\"" + Count int "json:\"count\" graphql:\"count\"" + MinutesWatched int "json:\"minutesWatched\" graphql:\"minutesWatched\"" + MediaIds []*int "json:\"mediaIds\" graphql:\"mediaIds\"" + ChaptersRead int "json:\"chaptersRead\" graphql:\"chaptersRead\"" +} + +func (t *UserFormatStats) GetFormat() *MediaFormat { + if t == nil { + t = &UserFormatStats{} + } + return t.Format +} +func (t *UserFormatStats) GetMeanScore() float64 { + if t == nil { + t = &UserFormatStats{} + } + return t.MeanScore +} +func (t *UserFormatStats) GetCount() int { + if t == nil { + t = &UserFormatStats{} + } + return t.Count +} +func (t *UserFormatStats) GetMinutesWatched() int { + if t == nil { + t = &UserFormatStats{} + } + return t.MinutesWatched +} +func (t *UserFormatStats) GetMediaIds() []*int { + if t == nil { + t = &UserFormatStats{} + } + return t.MediaIds +} +func (t *UserFormatStats) GetChaptersRead() int { + if t == nil { + t = &UserFormatStats{} + } + return t.ChaptersRead +} + +type UserGenreStats struct { + Genre *string "json:\"genre,omitempty\" graphql:\"genre\"" + MeanScore float64 "json:\"meanScore\" graphql:\"meanScore\"" + Count int "json:\"count\" graphql:\"count\"" + MinutesWatched int "json:\"minutesWatched\" graphql:\"minutesWatched\"" + MediaIds []*int "json:\"mediaIds\" graphql:\"mediaIds\"" + ChaptersRead int "json:\"chaptersRead\" graphql:\"chaptersRead\"" +} + +func (t *UserGenreStats) GetGenre() *string { + if t == nil { + t = &UserGenreStats{} + } + return t.Genre +} +func (t *UserGenreStats) GetMeanScore() float64 { + if t == nil { + t = &UserGenreStats{} + } + return t.MeanScore +} +func (t *UserGenreStats) GetCount() int { + if t == nil { + t = &UserGenreStats{} + } + return t.Count +} +func (t *UserGenreStats) GetMinutesWatched() int { + if t == nil { + t = &UserGenreStats{} + } + return t.MinutesWatched +} +func (t *UserGenreStats) GetMediaIds() []*int { + if t == nil { + t = &UserGenreStats{} + } + return t.MediaIds +} +func (t *UserGenreStats) GetChaptersRead() int { + if t == nil { + t = &UserGenreStats{} + } + return t.ChaptersRead +} + +type UserStatusStats struct { + Status *MediaListStatus "json:\"status,omitempty\" graphql:\"status\"" + MeanScore float64 "json:\"meanScore\" graphql:\"meanScore\"" + Count int "json:\"count\" graphql:\"count\"" + MinutesWatched int "json:\"minutesWatched\" graphql:\"minutesWatched\"" + MediaIds []*int "json:\"mediaIds\" graphql:\"mediaIds\"" + ChaptersRead int "json:\"chaptersRead\" graphql:\"chaptersRead\"" +} + +func (t *UserStatusStats) GetStatus() *MediaListStatus { + if t == nil { + t = &UserStatusStats{} + } + return t.Status +} +func (t *UserStatusStats) GetMeanScore() float64 { + if t == nil { + t = &UserStatusStats{} + } + return t.MeanScore +} +func (t *UserStatusStats) GetCount() int { + if t == nil { + t = &UserStatusStats{} + } + return t.Count +} +func (t *UserStatusStats) GetMinutesWatched() int { + if t == nil { + t = &UserStatusStats{} + } + return t.MinutesWatched +} +func (t *UserStatusStats) GetMediaIds() []*int { + if t == nil { + t = &UserStatusStats{} + } + return t.MediaIds +} +func (t *UserStatusStats) GetChaptersRead() int { + if t == nil { + t = &UserStatusStats{} + } + return t.ChaptersRead +} + +type UserScoreStats struct { + Score *int "json:\"score,omitempty\" graphql:\"score\"" + MeanScore float64 "json:\"meanScore\" graphql:\"meanScore\"" + Count int "json:\"count\" graphql:\"count\"" + MinutesWatched int "json:\"minutesWatched\" graphql:\"minutesWatched\"" + MediaIds []*int "json:\"mediaIds\" graphql:\"mediaIds\"" + ChaptersRead int "json:\"chaptersRead\" graphql:\"chaptersRead\"" +} + +func (t *UserScoreStats) GetScore() *int { + if t == nil { + t = &UserScoreStats{} + } + return t.Score +} +func (t *UserScoreStats) GetMeanScore() float64 { + if t == nil { + t = &UserScoreStats{} + } + return t.MeanScore +} +func (t *UserScoreStats) GetCount() int { + if t == nil { + t = &UserScoreStats{} + } + return t.Count +} +func (t *UserScoreStats) GetMinutesWatched() int { + if t == nil { + t = &UserScoreStats{} + } + return t.MinutesWatched +} +func (t *UserScoreStats) GetMediaIds() []*int { + if t == nil { + t = &UserScoreStats{} + } + return t.MediaIds +} +func (t *UserScoreStats) GetChaptersRead() int { + if t == nil { + t = &UserScoreStats{} + } + return t.ChaptersRead +} + +type UserStudioStats struct { + Studio *UserStudioStats_Studio "json:\"studio,omitempty\" graphql:\"studio\"" + MeanScore float64 "json:\"meanScore\" graphql:\"meanScore\"" + Count int "json:\"count\" graphql:\"count\"" + MinutesWatched int "json:\"minutesWatched\" graphql:\"minutesWatched\"" + MediaIds []*int "json:\"mediaIds\" graphql:\"mediaIds\"" + ChaptersRead int "json:\"chaptersRead\" graphql:\"chaptersRead\"" +} + +func (t *UserStudioStats) GetStudio() *UserStudioStats_Studio { + if t == nil { + t = &UserStudioStats{} + } + return t.Studio +} +func (t *UserStudioStats) GetMeanScore() float64 { + if t == nil { + t = &UserStudioStats{} + } + return t.MeanScore +} +func (t *UserStudioStats) GetCount() int { + if t == nil { + t = &UserStudioStats{} + } + return t.Count +} +func (t *UserStudioStats) GetMinutesWatched() int { + if t == nil { + t = &UserStudioStats{} + } + return t.MinutesWatched +} +func (t *UserStudioStats) GetMediaIds() []*int { + if t == nil { + t = &UserStudioStats{} + } + return t.MediaIds +} +func (t *UserStudioStats) GetChaptersRead() int { + if t == nil { + t = &UserStudioStats{} + } + return t.ChaptersRead +} + +type UserStartYearStats struct { + StartYear *int "json:\"startYear,omitempty\" graphql:\"startYear\"" + MeanScore float64 "json:\"meanScore\" graphql:\"meanScore\"" + Count int "json:\"count\" graphql:\"count\"" + MinutesWatched int "json:\"minutesWatched\" graphql:\"minutesWatched\"" + MediaIds []*int "json:\"mediaIds\" graphql:\"mediaIds\"" + ChaptersRead int "json:\"chaptersRead\" graphql:\"chaptersRead\"" +} + +func (t *UserStartYearStats) GetStartYear() *int { + if t == nil { + t = &UserStartYearStats{} + } + return t.StartYear +} +func (t *UserStartYearStats) GetMeanScore() float64 { + if t == nil { + t = &UserStartYearStats{} + } + return t.MeanScore +} +func (t *UserStartYearStats) GetCount() int { + if t == nil { + t = &UserStartYearStats{} + } + return t.Count +} +func (t *UserStartYearStats) GetMinutesWatched() int { + if t == nil { + t = &UserStartYearStats{} + } + return t.MinutesWatched +} +func (t *UserStartYearStats) GetMediaIds() []*int { + if t == nil { + t = &UserStartYearStats{} + } + return t.MediaIds +} +func (t *UserStartYearStats) GetChaptersRead() int { + if t == nil { + t = &UserStartYearStats{} + } + return t.ChaptersRead +} + +type UserReleaseYearStats struct { + ReleaseYear *int "json:\"releaseYear,omitempty\" graphql:\"releaseYear\"" + MeanScore float64 "json:\"meanScore\" graphql:\"meanScore\"" + Count int "json:\"count\" graphql:\"count\"" + MinutesWatched int "json:\"minutesWatched\" graphql:\"minutesWatched\"" + MediaIds []*int "json:\"mediaIds\" graphql:\"mediaIds\"" + ChaptersRead int "json:\"chaptersRead\" graphql:\"chaptersRead\"" +} + +func (t *UserReleaseYearStats) GetReleaseYear() *int { + if t == nil { + t = &UserReleaseYearStats{} + } + return t.ReleaseYear +} +func (t *UserReleaseYearStats) GetMeanScore() float64 { + if t == nil { + t = &UserReleaseYearStats{} + } + return t.MeanScore +} +func (t *UserReleaseYearStats) GetCount() int { + if t == nil { + t = &UserReleaseYearStats{} + } + return t.Count +} +func (t *UserReleaseYearStats) GetMinutesWatched() int { + if t == nil { + t = &UserReleaseYearStats{} + } + return t.MinutesWatched +} +func (t *UserReleaseYearStats) GetMediaIds() []*int { + if t == nil { + t = &UserReleaseYearStats{} + } + return t.MediaIds +} +func (t *UserReleaseYearStats) GetChaptersRead() int { + if t == nil { + t = &UserReleaseYearStats{} + } + return t.ChaptersRead +} + +type BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &BaseAnime_Trailer{} + } + return t.ID +} +func (t *BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &BaseAnime_Trailer{} + } + return t.Site +} +func (t *BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &BaseAnime_Title{} + } + return t.Romaji +} +func (t *BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &BaseAnime_Title{} + } + return t.English +} +func (t *BaseAnime_Title) GetNative() *string { + if t == nil { + t = &BaseAnime_Title{} + } + return t.Native +} + +type BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &BaseAnime_CoverImage{} + } + return t.Large +} +func (t *BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &BaseAnime_CoverImage{} + } + return t.Color +} + +type BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &BaseAnime_StartDate{} + } + return t.Year +} +func (t *BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &BaseAnime_StartDate{} + } + return t.Month +} +func (t *BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &BaseAnime_StartDate{} + } + return t.Day +} + +type BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &BaseAnime_EndDate{} + } + return t.Year +} +func (t *BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &BaseAnime_EndDate{} + } + return t.Month +} +func (t *BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &BaseAnime_EndDate{} + } + return t.Day +} + +type BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type CompleteAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *CompleteAnime_Trailer) GetID() *string { + if t == nil { + t = &CompleteAnime_Trailer{} + } + return t.ID +} +func (t *CompleteAnime_Trailer) GetSite() *string { + if t == nil { + t = &CompleteAnime_Trailer{} + } + return t.Site +} +func (t *CompleteAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &CompleteAnime_Trailer{} + } + return t.Thumbnail +} + +type CompleteAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *CompleteAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &CompleteAnime_Title{} + } + return t.UserPreferred +} +func (t *CompleteAnime_Title) GetRomaji() *string { + if t == nil { + t = &CompleteAnime_Title{} + } + return t.Romaji +} +func (t *CompleteAnime_Title) GetEnglish() *string { + if t == nil { + t = &CompleteAnime_Title{} + } + return t.English +} +func (t *CompleteAnime_Title) GetNative() *string { + if t == nil { + t = &CompleteAnime_Title{} + } + return t.Native +} + +type CompleteAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *CompleteAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &CompleteAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *CompleteAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &CompleteAnime_CoverImage{} + } + return t.Large +} +func (t *CompleteAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &CompleteAnime_CoverImage{} + } + return t.Medium +} +func (t *CompleteAnime_CoverImage) GetColor() *string { + if t == nil { + t = &CompleteAnime_CoverImage{} + } + return t.Color +} + +type CompleteAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *CompleteAnime_StartDate) GetYear() *int { + if t == nil { + t = &CompleteAnime_StartDate{} + } + return t.Year +} +func (t *CompleteAnime_StartDate) GetMonth() *int { + if t == nil { + t = &CompleteAnime_StartDate{} + } + return t.Month +} +func (t *CompleteAnime_StartDate) GetDay() *int { + if t == nil { + t = &CompleteAnime_StartDate{} + } + return t.Day +} + +type CompleteAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *CompleteAnime_EndDate) GetYear() *int { + if t == nil { + t = &CompleteAnime_EndDate{} + } + return t.Year +} +func (t *CompleteAnime_EndDate) GetMonth() *int { + if t == nil { + t = &CompleteAnime_EndDate{} + } + return t.Month +} +func (t *CompleteAnime_EndDate) GetDay() *int { + if t == nil { + t = &CompleteAnime_EndDate{} + } + return t.Day +} + +type CompleteAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *CompleteAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &CompleteAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *CompleteAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &CompleteAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *CompleteAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &CompleteAnime_NextAiringEpisode{} + } + return t.Episode +} + +type CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.ID +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.Site +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type CompleteAnime_Relations_Edges_Node_BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.Romaji +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.English +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetNative() *string { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.Native +} + +type CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Large +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Color +} + +type CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Year +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Month +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Day +} + +type CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Year +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Month +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Day +} + +type CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type CompleteAnime_Relations_Edges struct { + RelationType *MediaRelation "json:\"relationType,omitempty\" graphql:\"relationType\"" + Node *BaseAnime "json:\"node,omitempty\" graphql:\"node\"" +} + +func (t *CompleteAnime_Relations_Edges) GetRelationType() *MediaRelation { + if t == nil { + t = &CompleteAnime_Relations_Edges{} + } + return t.RelationType +} +func (t *CompleteAnime_Relations_Edges) GetNode() *BaseAnime { + if t == nil { + t = &CompleteAnime_Relations_Edges{} + } + return t.Node +} + +type CompleteAnime_Relations struct { + Edges []*CompleteAnime_Relations_Edges "json:\"edges,omitempty\" graphql:\"edges\"" +} + +func (t *CompleteAnime_Relations) GetEdges() []*CompleteAnime_Relations_Edges { + if t == nil { + t = &CompleteAnime_Relations{} + } + return t.Edges +} + +type BaseCharacter_DateOfBirth struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *BaseCharacter_DateOfBirth) GetYear() *int { + if t == nil { + t = &BaseCharacter_DateOfBirth{} + } + return t.Year +} +func (t *BaseCharacter_DateOfBirth) GetMonth() *int { + if t == nil { + t = &BaseCharacter_DateOfBirth{} + } + return t.Month +} +func (t *BaseCharacter_DateOfBirth) GetDay() *int { + if t == nil { + t = &BaseCharacter_DateOfBirth{} + } + return t.Day +} + +type BaseCharacter_Name struct { + Full *string "json:\"full,omitempty\" graphql:\"full\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" + Alternative []*string "json:\"alternative,omitempty\" graphql:\"alternative\"" +} + +func (t *BaseCharacter_Name) GetFull() *string { + if t == nil { + t = &BaseCharacter_Name{} + } + return t.Full +} +func (t *BaseCharacter_Name) GetNative() *string { + if t == nil { + t = &BaseCharacter_Name{} + } + return t.Native +} +func (t *BaseCharacter_Name) GetAlternative() []*string { + if t == nil { + t = &BaseCharacter_Name{} + } + return t.Alternative +} + +type BaseCharacter_Image struct { + Large *string "json:\"large,omitempty\" graphql:\"large\"" +} + +func (t *BaseCharacter_Image) GetLarge() *string { + if t == nil { + t = &BaseCharacter_Image{} + } + return t.Large +} + +type AnimeSchedule_Previous_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeSchedule_Previous_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeSchedule_Previous_Nodes{} + } + return t.AiringAt +} +func (t *AnimeSchedule_Previous_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeSchedule_Previous_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeSchedule_Previous_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeSchedule_Previous_Nodes{} + } + return t.Episode +} + +type AnimeSchedule_Previous struct { + Nodes []*AnimeSchedule_Previous_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeSchedule_Previous) GetNodes() []*AnimeSchedule_Previous_Nodes { + if t == nil { + t = &AnimeSchedule_Previous{} + } + return t.Nodes +} + +type AnimeSchedule_Upcoming_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeSchedule_Upcoming_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeSchedule_Upcoming_Nodes{} + } + return t.AiringAt +} +func (t *AnimeSchedule_Upcoming_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeSchedule_Upcoming_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeSchedule_Upcoming_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeSchedule_Upcoming_Nodes{} + } + return t.Episode +} + +type AnimeSchedule_Upcoming struct { + Nodes []*AnimeSchedule_Upcoming_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeSchedule_Upcoming) GetNodes() []*AnimeSchedule_Upcoming_Nodes { + if t == nil { + t = &AnimeSchedule_Upcoming{} + } + return t.Nodes +} + +type BaseManga_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *BaseManga_Title) GetUserPreferred() *string { + if t == nil { + t = &BaseManga_Title{} + } + return t.UserPreferred +} +func (t *BaseManga_Title) GetRomaji() *string { + if t == nil { + t = &BaseManga_Title{} + } + return t.Romaji +} +func (t *BaseManga_Title) GetEnglish() *string { + if t == nil { + t = &BaseManga_Title{} + } + return t.English +} +func (t *BaseManga_Title) GetNative() *string { + if t == nil { + t = &BaseManga_Title{} + } + return t.Native +} + +type BaseManga_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *BaseManga_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &BaseManga_CoverImage{} + } + return t.ExtraLarge +} +func (t *BaseManga_CoverImage) GetLarge() *string { + if t == nil { + t = &BaseManga_CoverImage{} + } + return t.Large +} +func (t *BaseManga_CoverImage) GetMedium() *string { + if t == nil { + t = &BaseManga_CoverImage{} + } + return t.Medium +} +func (t *BaseManga_CoverImage) GetColor() *string { + if t == nil { + t = &BaseManga_CoverImage{} + } + return t.Color +} + +type BaseManga_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *BaseManga_StartDate) GetYear() *int { + if t == nil { + t = &BaseManga_StartDate{} + } + return t.Year +} +func (t *BaseManga_StartDate) GetMonth() *int { + if t == nil { + t = &BaseManga_StartDate{} + } + return t.Month +} +func (t *BaseManga_StartDate) GetDay() *int { + if t == nil { + t = &BaseManga_StartDate{} + } + return t.Day +} + +type BaseManga_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *BaseManga_EndDate) GetYear() *int { + if t == nil { + t = &BaseManga_EndDate{} + } + return t.Year +} +func (t *BaseManga_EndDate) GetMonth() *int { + if t == nil { + t = &BaseManga_EndDate{} + } + return t.Month +} +func (t *BaseManga_EndDate) GetDay() *int { + if t == nil { + t = &BaseManga_EndDate{} + } + return t.Day +} + +type UserStudioStats_Studio struct { + ID int "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" + IsAnimationStudio bool "json:\"isAnimationStudio\" graphql:\"isAnimationStudio\"" +} + +func (t *UserStudioStats_Studio) GetID() int { + if t == nil { + t = &UserStudioStats_Studio{} + } + return t.ID +} +func (t *UserStudioStats_Studio) GetName() string { + if t == nil { + t = &UserStudioStats_Studio{} + } + return t.Name +} +func (t *UserStudioStats_Studio) GetIsAnimationStudio() bool { + if t == nil { + t = &UserStudioStats_Studio{} + } + return t.IsAnimationStudio +} + +type AnimeCollection_MediaListCollection_Lists_Entries_StartedAt struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeCollection_MediaListCollection_Lists_Entries_StartedAt) GetYear() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_StartedAt{} + } + return t.Year +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_StartedAt) GetMonth() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_StartedAt{} + } + return t.Month +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_StartedAt) GetDay() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_StartedAt{} + } + return t.Day +} + +type AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt) GetYear() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt{} + } + return t.Year +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt) GetMonth() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt{} + } + return t.Month +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt) GetDay() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt{} + } + return t.Day +} + +type AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Trailer{} + } + return t.ID +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Trailer{} + } + return t.Site +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Title{} + } + return t.Romaji +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Title{} + } + return t.English +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Title) GetNative() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_Title{} + } + return t.Native +} + +type AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_CoverImage{} + } + return t.Large +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_CoverImage{} + } + return t.Color +} + +type AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_StartDate{} + } + return t.Year +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_StartDate{} + } + return t.Month +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_StartDate{} + } + return t.Day +} + +type AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_EndDate{} + } + return t.Year +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_EndDate{} + } + return t.Month +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_EndDate{} + } + return t.Day +} + +type AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries_Media_BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type AnimeCollection_MediaListCollection_Lists_Entries struct { + ID int "json:\"id\" graphql:\"id\"" + Score *float64 "json:\"score,omitempty\" graphql:\"score\"" + Progress *int "json:\"progress,omitempty\" graphql:\"progress\"" + Status *MediaListStatus "json:\"status,omitempty\" graphql:\"status\"" + Notes *string "json:\"notes,omitempty\" graphql:\"notes\"" + Repeat *int "json:\"repeat,omitempty\" graphql:\"repeat\"" + Private *bool "json:\"private,omitempty\" graphql:\"private\"" + StartedAt *AnimeCollection_MediaListCollection_Lists_Entries_StartedAt "json:\"startedAt,omitempty\" graphql:\"startedAt\"" + CompletedAt *AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt "json:\"completedAt,omitempty\" graphql:\"completedAt\"" + Media *BaseAnime "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *AnimeCollection_MediaListCollection_Lists_Entries) GetID() int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries{} + } + return t.ID +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries) GetScore() *float64 { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries{} + } + return t.Score +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries) GetProgress() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries{} + } + return t.Progress +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries) GetStatus() *MediaListStatus { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries{} + } + return t.Status +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries) GetNotes() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries{} + } + return t.Notes +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries) GetRepeat() *int { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries{} + } + return t.Repeat +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries) GetPrivate() *bool { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries{} + } + return t.Private +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries) GetStartedAt() *AnimeCollection_MediaListCollection_Lists_Entries_StartedAt { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries{} + } + return t.StartedAt +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries) GetCompletedAt() *AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries{} + } + return t.CompletedAt +} +func (t *AnimeCollection_MediaListCollection_Lists_Entries) GetMedia() *BaseAnime { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists_Entries{} + } + return t.Media +} + +type AnimeCollection_MediaListCollection_Lists struct { + Status *MediaListStatus "json:\"status,omitempty\" graphql:\"status\"" + Name *string "json:\"name,omitempty\" graphql:\"name\"" + IsCustomList *bool "json:\"isCustomList,omitempty\" graphql:\"isCustomList\"" + Entries []*AnimeCollection_MediaListCollection_Lists_Entries "json:\"entries,omitempty\" graphql:\"entries\"" +} + +func (t *AnimeCollection_MediaListCollection_Lists) GetStatus() *MediaListStatus { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists{} + } + return t.Status +} +func (t *AnimeCollection_MediaListCollection_Lists) GetName() *string { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists{} + } + return t.Name +} +func (t *AnimeCollection_MediaListCollection_Lists) GetIsCustomList() *bool { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists{} + } + return t.IsCustomList +} +func (t *AnimeCollection_MediaListCollection_Lists) GetEntries() []*AnimeCollection_MediaListCollection_Lists_Entries { + if t == nil { + t = &AnimeCollection_MediaListCollection_Lists{} + } + return t.Entries +} + +type AnimeCollection_MediaListCollection struct { + Lists []*AnimeCollection_MediaListCollection_Lists "json:\"lists,omitempty\" graphql:\"lists\"" +} + +func (t *AnimeCollection_MediaListCollection) GetLists() []*AnimeCollection_MediaListCollection_Lists { + if t == nil { + t = &AnimeCollection_MediaListCollection{} + } + return t.Lists +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt) GetYear() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt{} + } + return t.Year +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt) GetMonth() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt{} + } + return t.Month +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt) GetDay() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt{} + } + return t.Day +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt) GetYear() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt{} + } + return t.Year +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt) GetMonth() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt{} + } + return t.Month +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt) GetDay() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt{} + } + return t.Day +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Trailer) GetID() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Trailer{} + } + return t.ID +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Trailer) GetSite() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Trailer{} + } + return t.Site +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Trailer{} + } + return t.Thumbnail +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Title{} + } + return t.UserPreferred +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Title) GetRomaji() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Title{} + } + return t.Romaji +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Title) GetEnglish() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Title{} + } + return t.English +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Title) GetNative() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Title{} + } + return t.Native +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_CoverImage{} + } + return t.Large +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_CoverImage{} + } + return t.Medium +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_CoverImage) GetColor() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_CoverImage{} + } + return t.Color +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_StartDate) GetYear() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_StartDate{} + } + return t.Year +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_StartDate) GetMonth() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_StartDate{} + } + return t.Month +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_StartDate) GetDay() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_StartDate{} + } + return t.Day +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_EndDate) GetYear() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_EndDate{} + } + return t.Year +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_EndDate) GetMonth() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_EndDate{} + } + return t.Month +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_EndDate) GetDay() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_EndDate{} + } + return t.Day +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_NextAiringEpisode{} + } + return t.Episode +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.ID +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.Site +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.Romaji +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.English +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetNative() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.Native +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Large +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Color +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Year +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Month +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Day +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Year +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Month +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Day +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges struct { + RelationType *MediaRelation "json:\"relationType,omitempty\" graphql:\"relationType\"" + Node *BaseAnime "json:\"node,omitempty\" graphql:\"node\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges) GetRelationType() *MediaRelation { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges{} + } + return t.RelationType +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges) GetNode() *BaseAnime { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges{} + } + return t.Node +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations struct { + Edges []*AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges "json:\"edges,omitempty\" graphql:\"edges\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations) GetEdges() []*AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations_Edges { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_Media_CompleteAnime_Relations{} + } + return t.Edges +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists_Entries struct { + ID int "json:\"id\" graphql:\"id\"" + Score *float64 "json:\"score,omitempty\" graphql:\"score\"" + Progress *int "json:\"progress,omitempty\" graphql:\"progress\"" + Status *MediaListStatus "json:\"status,omitempty\" graphql:\"status\"" + Notes *string "json:\"notes,omitempty\" graphql:\"notes\"" + Repeat *int "json:\"repeat,omitempty\" graphql:\"repeat\"" + Private *bool "json:\"private,omitempty\" graphql:\"private\"" + StartedAt *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt "json:\"startedAt,omitempty\" graphql:\"startedAt\"" + CompletedAt *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt "json:\"completedAt,omitempty\" graphql:\"completedAt\"" + Media *CompleteAnime "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries) GetID() int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{} + } + return t.ID +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries) GetScore() *float64 { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{} + } + return t.Score +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries) GetProgress() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{} + } + return t.Progress +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries) GetStatus() *MediaListStatus { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{} + } + return t.Status +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries) GetNotes() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{} + } + return t.Notes +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries) GetRepeat() *int { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{} + } + return t.Repeat +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries) GetPrivate() *bool { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{} + } + return t.Private +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries) GetStartedAt() *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{} + } + return t.StartedAt +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries) GetCompletedAt() *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{} + } + return t.CompletedAt +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries) GetMedia() *CompleteAnime { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{} + } + return t.Media +} + +type AnimeCollectionWithRelations_MediaListCollection_Lists struct { + Status *MediaListStatus "json:\"status,omitempty\" graphql:\"status\"" + Name *string "json:\"name,omitempty\" graphql:\"name\"" + IsCustomList *bool "json:\"isCustomList,omitempty\" graphql:\"isCustomList\"" + Entries []*AnimeCollectionWithRelations_MediaListCollection_Lists_Entries "json:\"entries,omitempty\" graphql:\"entries\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists) GetStatus() *MediaListStatus { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists{} + } + return t.Status +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists) GetName() *string { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists{} + } + return t.Name +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists) GetIsCustomList() *bool { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists{} + } + return t.IsCustomList +} +func (t *AnimeCollectionWithRelations_MediaListCollection_Lists) GetEntries() []*AnimeCollectionWithRelations_MediaListCollection_Lists_Entries { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection_Lists{} + } + return t.Entries +} + +type AnimeCollectionWithRelations_MediaListCollection struct { + Lists []*AnimeCollectionWithRelations_MediaListCollection_Lists "json:\"lists,omitempty\" graphql:\"lists\"" +} + +func (t *AnimeCollectionWithRelations_MediaListCollection) GetLists() []*AnimeCollectionWithRelations_MediaListCollection_Lists { + if t == nil { + t = &AnimeCollectionWithRelations_MediaListCollection{} + } + return t.Lists +} + +type BaseAnimeByMalId_Media_BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *BaseAnimeByMalId_Media_BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_Trailer{} + } + return t.ID +} +func (t *BaseAnimeByMalId_Media_BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_Trailer{} + } + return t.Site +} +func (t *BaseAnimeByMalId_Media_BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type BaseAnimeByMalId_Media_BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *BaseAnimeByMalId_Media_BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *BaseAnimeByMalId_Media_BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_Title{} + } + return t.Romaji +} +func (t *BaseAnimeByMalId_Media_BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_Title{} + } + return t.English +} +func (t *BaseAnimeByMalId_Media_BaseAnime_Title) GetNative() *string { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_Title{} + } + return t.Native +} + +type BaseAnimeByMalId_Media_BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *BaseAnimeByMalId_Media_BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *BaseAnimeByMalId_Media_BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_CoverImage{} + } + return t.Large +} +func (t *BaseAnimeByMalId_Media_BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *BaseAnimeByMalId_Media_BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_CoverImage{} + } + return t.Color +} + +type BaseAnimeByMalId_Media_BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *BaseAnimeByMalId_Media_BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_StartDate{} + } + return t.Year +} +func (t *BaseAnimeByMalId_Media_BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_StartDate{} + } + return t.Month +} +func (t *BaseAnimeByMalId_Media_BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_StartDate{} + } + return t.Day +} + +type BaseAnimeByMalId_Media_BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *BaseAnimeByMalId_Media_BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_EndDate{} + } + return t.Year +} +func (t *BaseAnimeByMalId_Media_BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_EndDate{} + } + return t.Month +} +func (t *BaseAnimeByMalId_Media_BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_EndDate{} + } + return t.Day +} + +type BaseAnimeByMalId_Media_BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *BaseAnimeByMalId_Media_BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *BaseAnimeByMalId_Media_BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *BaseAnimeByMalId_Media_BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &BaseAnimeByMalId_Media_BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type BaseAnimeById_Media_BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *BaseAnimeById_Media_BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_Trailer{} + } + return t.ID +} +func (t *BaseAnimeById_Media_BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_Trailer{} + } + return t.Site +} +func (t *BaseAnimeById_Media_BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type BaseAnimeById_Media_BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *BaseAnimeById_Media_BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *BaseAnimeById_Media_BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_Title{} + } + return t.Romaji +} +func (t *BaseAnimeById_Media_BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_Title{} + } + return t.English +} +func (t *BaseAnimeById_Media_BaseAnime_Title) GetNative() *string { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_Title{} + } + return t.Native +} + +type BaseAnimeById_Media_BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *BaseAnimeById_Media_BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *BaseAnimeById_Media_BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_CoverImage{} + } + return t.Large +} +func (t *BaseAnimeById_Media_BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *BaseAnimeById_Media_BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_CoverImage{} + } + return t.Color +} + +type BaseAnimeById_Media_BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *BaseAnimeById_Media_BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_StartDate{} + } + return t.Year +} +func (t *BaseAnimeById_Media_BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_StartDate{} + } + return t.Month +} +func (t *BaseAnimeById_Media_BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_StartDate{} + } + return t.Day +} + +type BaseAnimeById_Media_BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *BaseAnimeById_Media_BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_EndDate{} + } + return t.Year +} +func (t *BaseAnimeById_Media_BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_EndDate{} + } + return t.Month +} +func (t *BaseAnimeById_Media_BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_EndDate{} + } + return t.Day +} + +type BaseAnimeById_Media_BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *BaseAnimeById_Media_BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *BaseAnimeById_Media_BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *BaseAnimeById_Media_BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &BaseAnimeById_Media_BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type SearchBaseAnimeByIds_Page_PageInfo struct { + HasNextPage *bool "json:\"hasNextPage,omitempty\" graphql:\"hasNextPage\"" +} + +func (t *SearchBaseAnimeByIds_Page_PageInfo) GetHasNextPage() *bool { + if t == nil { + t = &SearchBaseAnimeByIds_Page_PageInfo{} + } + return t.HasNextPage +} + +type SearchBaseAnimeByIds_Page_Media_BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_Trailer{} + } + return t.ID +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_Trailer{} + } + return t.Site +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type SearchBaseAnimeByIds_Page_Media_BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_Title{} + } + return t.Romaji +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_Title{} + } + return t.English +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_Title) GetNative() *string { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_Title{} + } + return t.Native +} + +type SearchBaseAnimeByIds_Page_Media_BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_CoverImage{} + } + return t.Large +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_CoverImage{} + } + return t.Color +} + +type SearchBaseAnimeByIds_Page_Media_BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_StartDate{} + } + return t.Year +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_StartDate{} + } + return t.Month +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_StartDate{} + } + return t.Day +} + +type SearchBaseAnimeByIds_Page_Media_BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_EndDate{} + } + return t.Year +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_EndDate{} + } + return t.Month +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_EndDate{} + } + return t.Day +} + +type SearchBaseAnimeByIds_Page_Media_BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *SearchBaseAnimeByIds_Page_Media_BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &SearchBaseAnimeByIds_Page_Media_BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type SearchBaseAnimeByIds_Page struct { + PageInfo *SearchBaseAnimeByIds_Page_PageInfo "json:\"pageInfo,omitempty\" graphql:\"pageInfo\"" + Media []*BaseAnime "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *SearchBaseAnimeByIds_Page) GetPageInfo() *SearchBaseAnimeByIds_Page_PageInfo { + if t == nil { + t = &SearchBaseAnimeByIds_Page{} + } + return t.PageInfo +} +func (t *SearchBaseAnimeByIds_Page) GetMedia() []*BaseAnime { + if t == nil { + t = &SearchBaseAnimeByIds_Page{} + } + return t.Media +} + +type CompleteAnimeById_Media_CompleteAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_Trailer) GetID() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Trailer{} + } + return t.ID +} +func (t *CompleteAnimeById_Media_CompleteAnime_Trailer) GetSite() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Trailer{} + } + return t.Site +} +func (t *CompleteAnimeById_Media_CompleteAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Trailer{} + } + return t.Thumbnail +} + +type CompleteAnimeById_Media_CompleteAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Title{} + } + return t.UserPreferred +} +func (t *CompleteAnimeById_Media_CompleteAnime_Title) GetRomaji() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Title{} + } + return t.Romaji +} +func (t *CompleteAnimeById_Media_CompleteAnime_Title) GetEnglish() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Title{} + } + return t.English +} +func (t *CompleteAnimeById_Media_CompleteAnime_Title) GetNative() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Title{} + } + return t.Native +} + +type CompleteAnimeById_Media_CompleteAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *CompleteAnimeById_Media_CompleteAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_CoverImage{} + } + return t.Large +} +func (t *CompleteAnimeById_Media_CompleteAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_CoverImage{} + } + return t.Medium +} +func (t *CompleteAnimeById_Media_CompleteAnime_CoverImage) GetColor() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_CoverImage{} + } + return t.Color +} + +type CompleteAnimeById_Media_CompleteAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_StartDate) GetYear() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_StartDate{} + } + return t.Year +} +func (t *CompleteAnimeById_Media_CompleteAnime_StartDate) GetMonth() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_StartDate{} + } + return t.Month +} +func (t *CompleteAnimeById_Media_CompleteAnime_StartDate) GetDay() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_StartDate{} + } + return t.Day +} + +type CompleteAnimeById_Media_CompleteAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_EndDate) GetYear() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_EndDate{} + } + return t.Year +} +func (t *CompleteAnimeById_Media_CompleteAnime_EndDate) GetMonth() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_EndDate{} + } + return t.Month +} +func (t *CompleteAnimeById_Media_CompleteAnime_EndDate) GetDay() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_EndDate{} + } + return t.Day +} + +type CompleteAnimeById_Media_CompleteAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *CompleteAnimeById_Media_CompleteAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *CompleteAnimeById_Media_CompleteAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_NextAiringEpisode{} + } + return t.Episode +} + +type CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.ID +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.Site +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.Romaji +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.English +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title) GetNative() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_Title{} + } + return t.Native +} + +type CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Large +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Color +} + +type CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Year +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Month +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Day +} + +type CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Year +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Month +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Day +} + +type CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type CompleteAnimeById_Media_CompleteAnime_Relations_Edges struct { + RelationType *MediaRelation "json:\"relationType,omitempty\" graphql:\"relationType\"" + Node *BaseAnime "json:\"node,omitempty\" graphql:\"node\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges) GetRelationType() *MediaRelation { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges{} + } + return t.RelationType +} +func (t *CompleteAnimeById_Media_CompleteAnime_Relations_Edges) GetNode() *BaseAnime { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations_Edges{} + } + return t.Node +} + +type CompleteAnimeById_Media_CompleteAnime_Relations struct { + Edges []*CompleteAnimeById_Media_CompleteAnime_Relations_Edges "json:\"edges,omitempty\" graphql:\"edges\"" +} + +func (t *CompleteAnimeById_Media_CompleteAnime_Relations) GetEdges() []*CompleteAnimeById_Media_CompleteAnime_Relations_Edges { + if t == nil { + t = &CompleteAnimeById_Media_CompleteAnime_Relations{} + } + return t.Edges +} + +type AnimeDetailsById_Media_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *AnimeDetailsById_Media_Trailer) GetID() *string { + if t == nil { + t = &AnimeDetailsById_Media_Trailer{} + } + return t.ID +} +func (t *AnimeDetailsById_Media_Trailer) GetSite() *string { + if t == nil { + t = &AnimeDetailsById_Media_Trailer{} + } + return t.Site +} +func (t *AnimeDetailsById_Media_Trailer) GetThumbnail() *string { + if t == nil { + t = &AnimeDetailsById_Media_Trailer{} + } + return t.Thumbnail +} + +type AnimeDetailsById_Media_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeDetailsById_Media_StartDate) GetYear() *int { + if t == nil { + t = &AnimeDetailsById_Media_StartDate{} + } + return t.Year +} +func (t *AnimeDetailsById_Media_StartDate) GetMonth() *int { + if t == nil { + t = &AnimeDetailsById_Media_StartDate{} + } + return t.Month +} +func (t *AnimeDetailsById_Media_StartDate) GetDay() *int { + if t == nil { + t = &AnimeDetailsById_Media_StartDate{} + } + return t.Day +} + +type AnimeDetailsById_Media_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeDetailsById_Media_EndDate) GetYear() *int { + if t == nil { + t = &AnimeDetailsById_Media_EndDate{} + } + return t.Year +} +func (t *AnimeDetailsById_Media_EndDate) GetMonth() *int { + if t == nil { + t = &AnimeDetailsById_Media_EndDate{} + } + return t.Month +} +func (t *AnimeDetailsById_Media_EndDate) GetDay() *int { + if t == nil { + t = &AnimeDetailsById_Media_EndDate{} + } + return t.Day +} + +type AnimeDetailsById_Media_Studios_Nodes struct { + Name string "json:\"name\" graphql:\"name\"" + ID int "json:\"id\" graphql:\"id\"" +} + +func (t *AnimeDetailsById_Media_Studios_Nodes) GetName() string { + if t == nil { + t = &AnimeDetailsById_Media_Studios_Nodes{} + } + return t.Name +} +func (t *AnimeDetailsById_Media_Studios_Nodes) GetID() int { + if t == nil { + t = &AnimeDetailsById_Media_Studios_Nodes{} + } + return t.ID +} + +type AnimeDetailsById_Media_Studios struct { + Nodes []*AnimeDetailsById_Media_Studios_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeDetailsById_Media_Studios) GetNodes() []*AnimeDetailsById_Media_Studios_Nodes { + if t == nil { + t = &AnimeDetailsById_Media_Studios{} + } + return t.Nodes +} + +type AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth) GetYear() *int { + if t == nil { + t = &AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth{} + } + return t.Year +} +func (t *AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth) GetMonth() *int { + if t == nil { + t = &AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth{} + } + return t.Month +} +func (t *AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth) GetDay() *int { + if t == nil { + t = &AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth{} + } + return t.Day +} + +type AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name struct { + Full *string "json:\"full,omitempty\" graphql:\"full\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" + Alternative []*string "json:\"alternative,omitempty\" graphql:\"alternative\"" +} + +func (t *AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name) GetFull() *string { + if t == nil { + t = &AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name{} + } + return t.Full +} +func (t *AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name) GetNative() *string { + if t == nil { + t = &AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name{} + } + return t.Native +} +func (t *AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name) GetAlternative() []*string { + if t == nil { + t = &AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name{} + } + return t.Alternative +} + +type AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Image struct { + Large *string "json:\"large,omitempty\" graphql:\"large\"" +} + +func (t *AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Image) GetLarge() *string { + if t == nil { + t = &AnimeDetailsById_Media_Characters_Edges_Node_BaseCharacter_Image{} + } + return t.Large +} + +type AnimeDetailsById_Media_Characters_Edges struct { + ID *int "json:\"id,omitempty\" graphql:\"id\"" + Role *CharacterRole "json:\"role,omitempty\" graphql:\"role\"" + Name *string "json:\"name,omitempty\" graphql:\"name\"" + Node *BaseCharacter "json:\"node,omitempty\" graphql:\"node\"" +} + +func (t *AnimeDetailsById_Media_Characters_Edges) GetID() *int { + if t == nil { + t = &AnimeDetailsById_Media_Characters_Edges{} + } + return t.ID +} +func (t *AnimeDetailsById_Media_Characters_Edges) GetRole() *CharacterRole { + if t == nil { + t = &AnimeDetailsById_Media_Characters_Edges{} + } + return t.Role +} +func (t *AnimeDetailsById_Media_Characters_Edges) GetName() *string { + if t == nil { + t = &AnimeDetailsById_Media_Characters_Edges{} + } + return t.Name +} +func (t *AnimeDetailsById_Media_Characters_Edges) GetNode() *BaseCharacter { + if t == nil { + t = &AnimeDetailsById_Media_Characters_Edges{} + } + return t.Node +} + +type AnimeDetailsById_Media_Characters struct { + Edges []*AnimeDetailsById_Media_Characters_Edges "json:\"edges,omitempty\" graphql:\"edges\"" +} + +func (t *AnimeDetailsById_Media_Characters) GetEdges() []*AnimeDetailsById_Media_Characters_Edges { + if t == nil { + t = &AnimeDetailsById_Media_Characters{} + } + return t.Edges +} + +type AnimeDetailsById_Media_Staff_Edges_Node_Name struct { + Full *string "json:\"full,omitempty\" graphql:\"full\"" +} + +func (t *AnimeDetailsById_Media_Staff_Edges_Node_Name) GetFull() *string { + if t == nil { + t = &AnimeDetailsById_Media_Staff_Edges_Node_Name{} + } + return t.Full +} + +type AnimeDetailsById_Media_Staff_Edges_Node struct { + Name *AnimeDetailsById_Media_Staff_Edges_Node_Name "json:\"name,omitempty\" graphql:\"name\"" + ID int "json:\"id\" graphql:\"id\"" +} + +func (t *AnimeDetailsById_Media_Staff_Edges_Node) GetName() *AnimeDetailsById_Media_Staff_Edges_Node_Name { + if t == nil { + t = &AnimeDetailsById_Media_Staff_Edges_Node{} + } + return t.Name +} +func (t *AnimeDetailsById_Media_Staff_Edges_Node) GetID() int { + if t == nil { + t = &AnimeDetailsById_Media_Staff_Edges_Node{} + } + return t.ID +} + +type AnimeDetailsById_Media_Staff_Edges struct { + Role *string "json:\"role,omitempty\" graphql:\"role\"" + Node *AnimeDetailsById_Media_Staff_Edges_Node "json:\"node,omitempty\" graphql:\"node\"" +} + +func (t *AnimeDetailsById_Media_Staff_Edges) GetRole() *string { + if t == nil { + t = &AnimeDetailsById_Media_Staff_Edges{} + } + return t.Role +} +func (t *AnimeDetailsById_Media_Staff_Edges) GetNode() *AnimeDetailsById_Media_Staff_Edges_Node { + if t == nil { + t = &AnimeDetailsById_Media_Staff_Edges{} + } + return t.Node +} + +type AnimeDetailsById_Media_Staff struct { + Edges []*AnimeDetailsById_Media_Staff_Edges "json:\"edges,omitempty\" graphql:\"edges\"" +} + +func (t *AnimeDetailsById_Media_Staff) GetEdges() []*AnimeDetailsById_Media_Staff_Edges { + if t == nil { + t = &AnimeDetailsById_Media_Staff{} + } + return t.Edges +} + +type AnimeDetailsById_Media_Rankings struct { + Context string "json:\"context\" graphql:\"context\"" + Type MediaRankType "json:\"type\" graphql:\"type\"" + Rank int "json:\"rank\" graphql:\"rank\"" + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Format MediaFormat "json:\"format\" graphql:\"format\"" + AllTime *bool "json:\"allTime,omitempty\" graphql:\"allTime\"" + Season *MediaSeason "json:\"season,omitempty\" graphql:\"season\"" +} + +func (t *AnimeDetailsById_Media_Rankings) GetContext() string { + if t == nil { + t = &AnimeDetailsById_Media_Rankings{} + } + return t.Context +} +func (t *AnimeDetailsById_Media_Rankings) GetType() *MediaRankType { + if t == nil { + t = &AnimeDetailsById_Media_Rankings{} + } + return &t.Type +} +func (t *AnimeDetailsById_Media_Rankings) GetRank() int { + if t == nil { + t = &AnimeDetailsById_Media_Rankings{} + } + return t.Rank +} +func (t *AnimeDetailsById_Media_Rankings) GetYear() *int { + if t == nil { + t = &AnimeDetailsById_Media_Rankings{} + } + return t.Year +} +func (t *AnimeDetailsById_Media_Rankings) GetFormat() *MediaFormat { + if t == nil { + t = &AnimeDetailsById_Media_Rankings{} + } + return &t.Format +} +func (t *AnimeDetailsById_Media_Rankings) GetAllTime() *bool { + if t == nil { + t = &AnimeDetailsById_Media_Rankings{} + } + return t.AllTime +} +func (t *AnimeDetailsById_Media_Rankings) GetSeason() *MediaSeason { + if t == nil { + t = &AnimeDetailsById_Media_Rankings{} + } + return t.Season +} + +type AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer) GetID() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer{} + } + return t.ID +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer) GetSite() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer{} + } + return t.Site +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer) GetThumbnail() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer{} + } + return t.Thumbnail +} + +type AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate) GetYear() *int { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate{} + } + return t.Year +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate) GetMonth() *int { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate{} + } + return t.Month +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate) GetDay() *int { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate{} + } + return t.Day +} + +type AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage{} + } + return t.ExtraLarge +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage) GetLarge() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage{} + } + return t.Large +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage) GetMedium() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage{} + } + return t.Medium +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage) GetColor() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage{} + } + return t.Color +} + +type AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title struct { + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" +} + +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title) GetRomaji() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title{} + } + return t.Romaji +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title) GetEnglish() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title{} + } + return t.English +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title) GetNative() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title{} + } + return t.Native +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title) GetUserPreferred() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title{} + } + return t.UserPreferred +} + +type AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation struct { + ID int "json:\"id\" graphql:\"id\"" + IDMal *int "json:\"idMal,omitempty\" graphql:\"idMal\"" + SiteURL *string "json:\"siteUrl,omitempty\" graphql:\"siteUrl\"" + Status *MediaStatus "json:\"status,omitempty\" graphql:\"status\"" + IsAdult *bool "json:\"isAdult,omitempty\" graphql:\"isAdult\"" + Season *MediaSeason "json:\"season,omitempty\" graphql:\"season\"" + Type *MediaType "json:\"type,omitempty\" graphql:\"type\"" + Format *MediaFormat "json:\"format,omitempty\" graphql:\"format\"" + MeanScore *int "json:\"meanScore,omitempty\" graphql:\"meanScore\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + Episodes *int "json:\"episodes,omitempty\" graphql:\"episodes\"" + Trailer *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer "json:\"trailer,omitempty\" graphql:\"trailer\"" + StartDate *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate "json:\"startDate,omitempty\" graphql:\"startDate\"" + CoverImage *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage "json:\"coverImage,omitempty\" graphql:\"coverImage\"" + BannerImage *string "json:\"bannerImage,omitempty\" graphql:\"bannerImage\"" + Title *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title "json:\"title,omitempty\" graphql:\"title\"" +} + +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetID() int { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.ID +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetIDMal() *int { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.IDMal +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetSiteURL() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.SiteURL +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetStatus() *MediaStatus { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Status +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetIsAdult() *bool { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.IsAdult +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetSeason() *MediaSeason { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Season +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetType() *MediaType { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Type +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetFormat() *MediaFormat { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Format +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetMeanScore() *int { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.MeanScore +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetDescription() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Description +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetEpisodes() *int { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Episodes +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetTrailer() *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Trailer +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetStartDate() *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.StartDate +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetCoverImage() *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.CoverImage +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetBannerImage() *string { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.BannerImage +} +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetTitle() *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Title +} + +type AnimeDetailsById_Media_Recommendations_Edges_Node struct { + MediaRecommendation *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation "json:\"mediaRecommendation,omitempty\" graphql:\"mediaRecommendation\"" +} + +func (t *AnimeDetailsById_Media_Recommendations_Edges_Node) GetMediaRecommendation() *AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges_Node{} + } + return t.MediaRecommendation +} + +type AnimeDetailsById_Media_Recommendations_Edges struct { + Node *AnimeDetailsById_Media_Recommendations_Edges_Node "json:\"node,omitempty\" graphql:\"node\"" +} + +func (t *AnimeDetailsById_Media_Recommendations_Edges) GetNode() *AnimeDetailsById_Media_Recommendations_Edges_Node { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations_Edges{} + } + return t.Node +} + +type AnimeDetailsById_Media_Recommendations struct { + Edges []*AnimeDetailsById_Media_Recommendations_Edges "json:\"edges,omitempty\" graphql:\"edges\"" +} + +func (t *AnimeDetailsById_Media_Recommendations) GetEdges() []*AnimeDetailsById_Media_Recommendations_Edges { + if t == nil { + t = &AnimeDetailsById_Media_Recommendations{} + } + return t.Edges +} + +type AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.ID +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.Site +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Title{} + } + return t.Romaji +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Title{} + } + return t.English +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Title) GetNative() *string { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_Title{} + } + return t.Native +} + +type AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Large +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_CoverImage{} + } + return t.Color +} + +type AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Year +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Month +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_StartDate{} + } + return t.Day +} + +type AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Year +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Month +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_EndDate{} + } + return t.Day +} + +type AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges_Node_BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type AnimeDetailsById_Media_Relations_Edges struct { + RelationType *MediaRelation "json:\"relationType,omitempty\" graphql:\"relationType\"" + Node *BaseAnime "json:\"node,omitempty\" graphql:\"node\"" +} + +func (t *AnimeDetailsById_Media_Relations_Edges) GetRelationType() *MediaRelation { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges{} + } + return t.RelationType +} +func (t *AnimeDetailsById_Media_Relations_Edges) GetNode() *BaseAnime { + if t == nil { + t = &AnimeDetailsById_Media_Relations_Edges{} + } + return t.Node +} + +type AnimeDetailsById_Media_Relations struct { + Edges []*AnimeDetailsById_Media_Relations_Edges "json:\"edges,omitempty\" graphql:\"edges\"" +} + +func (t *AnimeDetailsById_Media_Relations) GetEdges() []*AnimeDetailsById_Media_Relations_Edges { + if t == nil { + t = &AnimeDetailsById_Media_Relations{} + } + return t.Edges +} + +type AnimeDetailsById_Media struct { + SiteURL *string "json:\"siteUrl,omitempty\" graphql:\"siteUrl\"" + ID int "json:\"id\" graphql:\"id\"" + Duration *int "json:\"duration,omitempty\" graphql:\"duration\"" + Genres []*string "json:\"genres,omitempty\" graphql:\"genres\"" + AverageScore *int "json:\"averageScore,omitempty\" graphql:\"averageScore\"" + Popularity *int "json:\"popularity,omitempty\" graphql:\"popularity\"" + MeanScore *int "json:\"meanScore,omitempty\" graphql:\"meanScore\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + Trailer *AnimeDetailsById_Media_Trailer "json:\"trailer,omitempty\" graphql:\"trailer\"" + StartDate *AnimeDetailsById_Media_StartDate "json:\"startDate,omitempty\" graphql:\"startDate\"" + EndDate *AnimeDetailsById_Media_EndDate "json:\"endDate,omitempty\" graphql:\"endDate\"" + Studios *AnimeDetailsById_Media_Studios "json:\"studios,omitempty\" graphql:\"studios\"" + Characters *AnimeDetailsById_Media_Characters "json:\"characters,omitempty\" graphql:\"characters\"" + Staff *AnimeDetailsById_Media_Staff "json:\"staff,omitempty\" graphql:\"staff\"" + Rankings []*AnimeDetailsById_Media_Rankings "json:\"rankings,omitempty\" graphql:\"rankings\"" + Recommendations *AnimeDetailsById_Media_Recommendations "json:\"recommendations,omitempty\" graphql:\"recommendations\"" + Relations *AnimeDetailsById_Media_Relations "json:\"relations,omitempty\" graphql:\"relations\"" +} + +func (t *AnimeDetailsById_Media) GetSiteURL() *string { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.SiteURL +} +func (t *AnimeDetailsById_Media) GetID() int { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.ID +} +func (t *AnimeDetailsById_Media) GetDuration() *int { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.Duration +} +func (t *AnimeDetailsById_Media) GetGenres() []*string { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.Genres +} +func (t *AnimeDetailsById_Media) GetAverageScore() *int { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.AverageScore +} +func (t *AnimeDetailsById_Media) GetPopularity() *int { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.Popularity +} +func (t *AnimeDetailsById_Media) GetMeanScore() *int { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.MeanScore +} +func (t *AnimeDetailsById_Media) GetDescription() *string { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.Description +} +func (t *AnimeDetailsById_Media) GetTrailer() *AnimeDetailsById_Media_Trailer { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.Trailer +} +func (t *AnimeDetailsById_Media) GetStartDate() *AnimeDetailsById_Media_StartDate { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.StartDate +} +func (t *AnimeDetailsById_Media) GetEndDate() *AnimeDetailsById_Media_EndDate { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.EndDate +} +func (t *AnimeDetailsById_Media) GetStudios() *AnimeDetailsById_Media_Studios { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.Studios +} +func (t *AnimeDetailsById_Media) GetCharacters() *AnimeDetailsById_Media_Characters { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.Characters +} +func (t *AnimeDetailsById_Media) GetStaff() *AnimeDetailsById_Media_Staff { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.Staff +} +func (t *AnimeDetailsById_Media) GetRankings() []*AnimeDetailsById_Media_Rankings { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.Rankings +} +func (t *AnimeDetailsById_Media) GetRecommendations() *AnimeDetailsById_Media_Recommendations { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.Recommendations +} +func (t *AnimeDetailsById_Media) GetRelations() *AnimeDetailsById_Media_Relations { + if t == nil { + t = &AnimeDetailsById_Media{} + } + return t.Relations +} + +type ListAnime_Page_PageInfo struct { + HasNextPage *bool "json:\"hasNextPage,omitempty\" graphql:\"hasNextPage\"" + Total *int "json:\"total,omitempty\" graphql:\"total\"" + PerPage *int "json:\"perPage,omitempty\" graphql:\"perPage\"" + CurrentPage *int "json:\"currentPage,omitempty\" graphql:\"currentPage\"" + LastPage *int "json:\"lastPage,omitempty\" graphql:\"lastPage\"" +} + +func (t *ListAnime_Page_PageInfo) GetHasNextPage() *bool { + if t == nil { + t = &ListAnime_Page_PageInfo{} + } + return t.HasNextPage +} +func (t *ListAnime_Page_PageInfo) GetTotal() *int { + if t == nil { + t = &ListAnime_Page_PageInfo{} + } + return t.Total +} +func (t *ListAnime_Page_PageInfo) GetPerPage() *int { + if t == nil { + t = &ListAnime_Page_PageInfo{} + } + return t.PerPage +} +func (t *ListAnime_Page_PageInfo) GetCurrentPage() *int { + if t == nil { + t = &ListAnime_Page_PageInfo{} + } + return t.CurrentPage +} +func (t *ListAnime_Page_PageInfo) GetLastPage() *int { + if t == nil { + t = &ListAnime_Page_PageInfo{} + } + return t.LastPage +} + +type ListAnime_Page_Media_BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *ListAnime_Page_Media_BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_Trailer{} + } + return t.ID +} +func (t *ListAnime_Page_Media_BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_Trailer{} + } + return t.Site +} +func (t *ListAnime_Page_Media_BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type ListAnime_Page_Media_BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *ListAnime_Page_Media_BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *ListAnime_Page_Media_BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_Title{} + } + return t.Romaji +} +func (t *ListAnime_Page_Media_BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_Title{} + } + return t.English +} +func (t *ListAnime_Page_Media_BaseAnime_Title) GetNative() *string { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_Title{} + } + return t.Native +} + +type ListAnime_Page_Media_BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *ListAnime_Page_Media_BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *ListAnime_Page_Media_BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_CoverImage{} + } + return t.Large +} +func (t *ListAnime_Page_Media_BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *ListAnime_Page_Media_BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_CoverImage{} + } + return t.Color +} + +type ListAnime_Page_Media_BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *ListAnime_Page_Media_BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_StartDate{} + } + return t.Year +} +func (t *ListAnime_Page_Media_BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_StartDate{} + } + return t.Month +} +func (t *ListAnime_Page_Media_BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_StartDate{} + } + return t.Day +} + +type ListAnime_Page_Media_BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *ListAnime_Page_Media_BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_EndDate{} + } + return t.Year +} +func (t *ListAnime_Page_Media_BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_EndDate{} + } + return t.Month +} +func (t *ListAnime_Page_Media_BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_EndDate{} + } + return t.Day +} + +type ListAnime_Page_Media_BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *ListAnime_Page_Media_BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *ListAnime_Page_Media_BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *ListAnime_Page_Media_BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &ListAnime_Page_Media_BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type ListAnime_Page struct { + PageInfo *ListAnime_Page_PageInfo "json:\"pageInfo,omitempty\" graphql:\"pageInfo\"" + Media []*BaseAnime "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *ListAnime_Page) GetPageInfo() *ListAnime_Page_PageInfo { + if t == nil { + t = &ListAnime_Page{} + } + return t.PageInfo +} +func (t *ListAnime_Page) GetMedia() []*BaseAnime { + if t == nil { + t = &ListAnime_Page{} + } + return t.Media +} + +type ListRecentAnime_Page_PageInfo struct { + HasNextPage *bool "json:\"hasNextPage,omitempty\" graphql:\"hasNextPage\"" + Total *int "json:\"total,omitempty\" graphql:\"total\"" + PerPage *int "json:\"perPage,omitempty\" graphql:\"perPage\"" + CurrentPage *int "json:\"currentPage,omitempty\" graphql:\"currentPage\"" + LastPage *int "json:\"lastPage,omitempty\" graphql:\"lastPage\"" +} + +func (t *ListRecentAnime_Page_PageInfo) GetHasNextPage() *bool { + if t == nil { + t = &ListRecentAnime_Page_PageInfo{} + } + return t.HasNextPage +} +func (t *ListRecentAnime_Page_PageInfo) GetTotal() *int { + if t == nil { + t = &ListRecentAnime_Page_PageInfo{} + } + return t.Total +} +func (t *ListRecentAnime_Page_PageInfo) GetPerPage() *int { + if t == nil { + t = &ListRecentAnime_Page_PageInfo{} + } + return t.PerPage +} +func (t *ListRecentAnime_Page_PageInfo) GetCurrentPage() *int { + if t == nil { + t = &ListRecentAnime_Page_PageInfo{} + } + return t.CurrentPage +} +func (t *ListRecentAnime_Page_PageInfo) GetLastPage() *int { + if t == nil { + t = &ListRecentAnime_Page_PageInfo{} + } + return t.LastPage +} + +type ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Trailer{} + } + return t.ID +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Trailer{} + } + return t.Site +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Title{} + } + return t.Romaji +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Title{} + } + return t.English +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Title) GetNative() *string { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_Title{} + } + return t.Native +} + +type ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_CoverImage{} + } + return t.Large +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_CoverImage{} + } + return t.Color +} + +type ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_StartDate{} + } + return t.Year +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_StartDate{} + } + return t.Month +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_StartDate{} + } + return t.Day +} + +type ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_EndDate{} + } + return t.Year +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_EndDate{} + } + return t.Month +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_EndDate{} + } + return t.Day +} + +type ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules_Media_BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type ListRecentAnime_Page_AiringSchedules struct { + ID int "json:\"id\" graphql:\"id\"" + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + Episode int "json:\"episode\" graphql:\"episode\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Media *BaseAnime "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *ListRecentAnime_Page_AiringSchedules) GetID() int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules{} + } + return t.ID +} +func (t *ListRecentAnime_Page_AiringSchedules) GetAiringAt() int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules{} + } + return t.AiringAt +} +func (t *ListRecentAnime_Page_AiringSchedules) GetEpisode() int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules{} + } + return t.Episode +} +func (t *ListRecentAnime_Page_AiringSchedules) GetTimeUntilAiring() int { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules{} + } + return t.TimeUntilAiring +} +func (t *ListRecentAnime_Page_AiringSchedules) GetMedia() *BaseAnime { + if t == nil { + t = &ListRecentAnime_Page_AiringSchedules{} + } + return t.Media +} + +type ListRecentAnime_Page struct { + PageInfo *ListRecentAnime_Page_PageInfo "json:\"pageInfo,omitempty\" graphql:\"pageInfo\"" + AiringSchedules []*ListRecentAnime_Page_AiringSchedules "json:\"airingSchedules,omitempty\" graphql:\"airingSchedules\"" +} + +func (t *ListRecentAnime_Page) GetPageInfo() *ListRecentAnime_Page_PageInfo { + if t == nil { + t = &ListRecentAnime_Page{} + } + return t.PageInfo +} +func (t *ListRecentAnime_Page) GetAiringSchedules() []*ListRecentAnime_Page_AiringSchedules { + if t == nil { + t = &ListRecentAnime_Page{} + } + return t.AiringSchedules +} + +type AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes{} + } + return t.Episode +} + +type AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous struct { + Nodes []*AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous) GetNodes() []*AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous_Nodes { + if t == nil { + t = &AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Previous{} + } + return t.Nodes +} + +type AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.Episode +} + +type AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming struct { + Nodes []*AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming) GetNodes() []*AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming_Nodes { + if t == nil { + t = &AnimeAiringSchedule_Ongoing_Media_AnimeSchedule_Upcoming{} + } + return t.Nodes +} + +type AnimeAiringSchedule_Ongoing struct { + Media []*AnimeSchedule "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *AnimeAiringSchedule_Ongoing) GetMedia() []*AnimeSchedule { + if t == nil { + t = &AnimeAiringSchedule_Ongoing{} + } + return t.Media +} + +type AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes{} + } + return t.Episode +} + +type AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous struct { + Nodes []*AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous) GetNodes() []*AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous_Nodes { + if t == nil { + t = &AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Previous{} + } + return t.Nodes +} + +type AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.Episode +} + +type AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming struct { + Nodes []*AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming) GetNodes() []*AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming_Nodes { + if t == nil { + t = &AnimeAiringSchedule_OngoingNext_Media_AnimeSchedule_Upcoming{} + } + return t.Nodes +} + +type AnimeAiringSchedule_OngoingNext struct { + Media []*AnimeSchedule "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *AnimeAiringSchedule_OngoingNext) GetMedia() []*AnimeSchedule { + if t == nil { + t = &AnimeAiringSchedule_OngoingNext{} + } + return t.Media +} + +type AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes{} + } + return t.Episode +} + +type AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous struct { + Nodes []*AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous) GetNodes() []*AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous_Nodes { + if t == nil { + t = &AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Previous{} + } + return t.Nodes +} + +type AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.Episode +} + +type AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming struct { + Nodes []*AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming) GetNodes() []*AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming_Nodes { + if t == nil { + t = &AnimeAiringSchedule_Upcoming_Media_AnimeSchedule_Upcoming{} + } + return t.Nodes +} + +type AnimeAiringSchedule_Upcoming struct { + Media []*AnimeSchedule "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *AnimeAiringSchedule_Upcoming) GetMedia() []*AnimeSchedule { + if t == nil { + t = &AnimeAiringSchedule_Upcoming{} + } + return t.Media +} + +type AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes{} + } + return t.Episode +} + +type AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous struct { + Nodes []*AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous) GetNodes() []*AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous_Nodes { + if t == nil { + t = &AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Previous{} + } + return t.Nodes +} + +type AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.Episode +} + +type AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming struct { + Nodes []*AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming) GetNodes() []*AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming_Nodes { + if t == nil { + t = &AnimeAiringSchedule_UpcomingNext_Media_AnimeSchedule_Upcoming{} + } + return t.Nodes +} + +type AnimeAiringSchedule_UpcomingNext struct { + Media []*AnimeSchedule "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *AnimeAiringSchedule_UpcomingNext) GetMedia() []*AnimeSchedule { + if t == nil { + t = &AnimeAiringSchedule_UpcomingNext{} + } + return t.Media +} + +type AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes{} + } + return t.Episode +} + +type AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous struct { + Nodes []*AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous) GetNodes() []*AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous_Nodes { + if t == nil { + t = &AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Previous{} + } + return t.Nodes +} + +type AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.Episode +} + +type AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming struct { + Nodes []*AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming) GetNodes() []*AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming_Nodes { + if t == nil { + t = &AnimeAiringSchedule_Preceding_Media_AnimeSchedule_Upcoming{} + } + return t.Nodes +} + +type AnimeAiringSchedule_Preceding struct { + Media []*AnimeSchedule "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *AnimeAiringSchedule_Preceding) GetMedia() []*AnimeSchedule { + if t == nil { + t = &AnimeAiringSchedule_Preceding{} + } + return t.Media +} + +type AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes{} + } + return t.Episode +} + +type AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous struct { + Nodes []*AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous) GetNodes() []*AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous_Nodes { + if t == nil { + t = &AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Previous{} + } + return t.Nodes +} + +type AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes) GetAiringAt() int { + if t == nil { + t = &AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.AiringAt +} +func (t *AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes) GetTimeUntilAiring() int { + if t == nil { + t = &AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.TimeUntilAiring +} +func (t *AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes) GetEpisode() int { + if t == nil { + t = &AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes{} + } + return t.Episode +} + +type AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming struct { + Nodes []*AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming) GetNodes() []*AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming_Nodes { + if t == nil { + t = &AnimeAiringScheduleRaw_Page_Media_AnimeSchedule_Upcoming{} + } + return t.Nodes +} + +type AnimeAiringScheduleRaw_Page struct { + Media []*AnimeSchedule "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *AnimeAiringScheduleRaw_Page) GetMedia() []*AnimeSchedule { + if t == nil { + t = &AnimeAiringScheduleRaw_Page{} + } + return t.Media +} + +type UpdateMediaListEntry_SaveMediaListEntry struct { + ID int "json:\"id\" graphql:\"id\"" +} + +func (t *UpdateMediaListEntry_SaveMediaListEntry) GetID() int { + if t == nil { + t = &UpdateMediaListEntry_SaveMediaListEntry{} + } + return t.ID +} + +type UpdateMediaListEntryProgress_SaveMediaListEntry struct { + ID int "json:\"id\" graphql:\"id\"" +} + +func (t *UpdateMediaListEntryProgress_SaveMediaListEntry) GetID() int { + if t == nil { + t = &UpdateMediaListEntryProgress_SaveMediaListEntry{} + } + return t.ID +} + +type DeleteEntry_DeleteMediaListEntry struct { + Deleted *bool "json:\"deleted,omitempty\" graphql:\"deleted\"" +} + +func (t *DeleteEntry_DeleteMediaListEntry) GetDeleted() *bool { + if t == nil { + t = &DeleteEntry_DeleteMediaListEntry{} + } + return t.Deleted +} + +type UpdateMediaListEntryRepeat_SaveMediaListEntry struct { + ID int "json:\"id\" graphql:\"id\"" +} + +func (t *UpdateMediaListEntryRepeat_SaveMediaListEntry) GetID() int { + if t == nil { + t = &UpdateMediaListEntryRepeat_SaveMediaListEntry{} + } + return t.ID +} + +type MangaCollection_MediaListCollection_Lists_Entries_StartedAt struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *MangaCollection_MediaListCollection_Lists_Entries_StartedAt) GetYear() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_StartedAt{} + } + return t.Year +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_StartedAt) GetMonth() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_StartedAt{} + } + return t.Month +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_StartedAt) GetDay() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_StartedAt{} + } + return t.Day +} + +type MangaCollection_MediaListCollection_Lists_Entries_CompletedAt struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *MangaCollection_MediaListCollection_Lists_Entries_CompletedAt) GetYear() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_CompletedAt{} + } + return t.Year +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_CompletedAt) GetMonth() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_CompletedAt{} + } + return t.Month +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_CompletedAt) GetDay() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_CompletedAt{} + } + return t.Day +} + +type MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_Title) GetUserPreferred() *string { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_Title{} + } + return t.UserPreferred +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_Title) GetRomaji() *string { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_Title{} + } + return t.Romaji +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_Title) GetEnglish() *string { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_Title{} + } + return t.English +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_Title) GetNative() *string { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_Title{} + } + return t.Native +} + +type MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_CoverImage{} + } + return t.ExtraLarge +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_CoverImage) GetLarge() *string { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_CoverImage{} + } + return t.Large +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_CoverImage) GetMedium() *string { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_CoverImage{} + } + return t.Medium +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_CoverImage) GetColor() *string { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_CoverImage{} + } + return t.Color +} + +type MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_StartDate) GetYear() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_StartDate{} + } + return t.Year +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_StartDate) GetMonth() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_StartDate{} + } + return t.Month +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_StartDate) GetDay() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_StartDate{} + } + return t.Day +} + +type MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_EndDate) GetYear() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_EndDate{} + } + return t.Year +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_EndDate) GetMonth() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_EndDate{} + } + return t.Month +} +func (t *MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_EndDate) GetDay() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries_Media_BaseManga_EndDate{} + } + return t.Day +} + +type MangaCollection_MediaListCollection_Lists_Entries struct { + ID int "json:\"id\" graphql:\"id\"" + Score *float64 "json:\"score,omitempty\" graphql:\"score\"" + Progress *int "json:\"progress,omitempty\" graphql:\"progress\"" + Status *MediaListStatus "json:\"status,omitempty\" graphql:\"status\"" + Notes *string "json:\"notes,omitempty\" graphql:\"notes\"" + Repeat *int "json:\"repeat,omitempty\" graphql:\"repeat\"" + Private *bool "json:\"private,omitempty\" graphql:\"private\"" + StartedAt *MangaCollection_MediaListCollection_Lists_Entries_StartedAt "json:\"startedAt,omitempty\" graphql:\"startedAt\"" + CompletedAt *MangaCollection_MediaListCollection_Lists_Entries_CompletedAt "json:\"completedAt,omitempty\" graphql:\"completedAt\"" + Media *BaseManga "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *MangaCollection_MediaListCollection_Lists_Entries) GetID() int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries{} + } + return t.ID +} +func (t *MangaCollection_MediaListCollection_Lists_Entries) GetScore() *float64 { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries{} + } + return t.Score +} +func (t *MangaCollection_MediaListCollection_Lists_Entries) GetProgress() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries{} + } + return t.Progress +} +func (t *MangaCollection_MediaListCollection_Lists_Entries) GetStatus() *MediaListStatus { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries{} + } + return t.Status +} +func (t *MangaCollection_MediaListCollection_Lists_Entries) GetNotes() *string { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries{} + } + return t.Notes +} +func (t *MangaCollection_MediaListCollection_Lists_Entries) GetRepeat() *int { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries{} + } + return t.Repeat +} +func (t *MangaCollection_MediaListCollection_Lists_Entries) GetPrivate() *bool { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries{} + } + return t.Private +} +func (t *MangaCollection_MediaListCollection_Lists_Entries) GetStartedAt() *MangaCollection_MediaListCollection_Lists_Entries_StartedAt { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries{} + } + return t.StartedAt +} +func (t *MangaCollection_MediaListCollection_Lists_Entries) GetCompletedAt() *MangaCollection_MediaListCollection_Lists_Entries_CompletedAt { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries{} + } + return t.CompletedAt +} +func (t *MangaCollection_MediaListCollection_Lists_Entries) GetMedia() *BaseManga { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists_Entries{} + } + return t.Media +} + +type MangaCollection_MediaListCollection_Lists struct { + Status *MediaListStatus "json:\"status,omitempty\" graphql:\"status\"" + Name *string "json:\"name,omitempty\" graphql:\"name\"" + IsCustomList *bool "json:\"isCustomList,omitempty\" graphql:\"isCustomList\"" + Entries []*MangaCollection_MediaListCollection_Lists_Entries "json:\"entries,omitempty\" graphql:\"entries\"" +} + +func (t *MangaCollection_MediaListCollection_Lists) GetStatus() *MediaListStatus { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists{} + } + return t.Status +} +func (t *MangaCollection_MediaListCollection_Lists) GetName() *string { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists{} + } + return t.Name +} +func (t *MangaCollection_MediaListCollection_Lists) GetIsCustomList() *bool { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists{} + } + return t.IsCustomList +} +func (t *MangaCollection_MediaListCollection_Lists) GetEntries() []*MangaCollection_MediaListCollection_Lists_Entries { + if t == nil { + t = &MangaCollection_MediaListCollection_Lists{} + } + return t.Entries +} + +type MangaCollection_MediaListCollection struct { + Lists []*MangaCollection_MediaListCollection_Lists "json:\"lists,omitempty\" graphql:\"lists\"" +} + +func (t *MangaCollection_MediaListCollection) GetLists() []*MangaCollection_MediaListCollection_Lists { + if t == nil { + t = &MangaCollection_MediaListCollection{} + } + return t.Lists +} + +type SearchBaseManga_Page_PageInfo struct { + HasNextPage *bool "json:\"hasNextPage,omitempty\" graphql:\"hasNextPage\"" +} + +func (t *SearchBaseManga_Page_PageInfo) GetHasNextPage() *bool { + if t == nil { + t = &SearchBaseManga_Page_PageInfo{} + } + return t.HasNextPage +} + +type SearchBaseManga_Page_Media_BaseManga_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *SearchBaseManga_Page_Media_BaseManga_Title) GetUserPreferred() *string { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_Title{} + } + return t.UserPreferred +} +func (t *SearchBaseManga_Page_Media_BaseManga_Title) GetRomaji() *string { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_Title{} + } + return t.Romaji +} +func (t *SearchBaseManga_Page_Media_BaseManga_Title) GetEnglish() *string { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_Title{} + } + return t.English +} +func (t *SearchBaseManga_Page_Media_BaseManga_Title) GetNative() *string { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_Title{} + } + return t.Native +} + +type SearchBaseManga_Page_Media_BaseManga_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *SearchBaseManga_Page_Media_BaseManga_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_CoverImage{} + } + return t.ExtraLarge +} +func (t *SearchBaseManga_Page_Media_BaseManga_CoverImage) GetLarge() *string { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_CoverImage{} + } + return t.Large +} +func (t *SearchBaseManga_Page_Media_BaseManga_CoverImage) GetMedium() *string { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_CoverImage{} + } + return t.Medium +} +func (t *SearchBaseManga_Page_Media_BaseManga_CoverImage) GetColor() *string { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_CoverImage{} + } + return t.Color +} + +type SearchBaseManga_Page_Media_BaseManga_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *SearchBaseManga_Page_Media_BaseManga_StartDate) GetYear() *int { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_StartDate{} + } + return t.Year +} +func (t *SearchBaseManga_Page_Media_BaseManga_StartDate) GetMonth() *int { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_StartDate{} + } + return t.Month +} +func (t *SearchBaseManga_Page_Media_BaseManga_StartDate) GetDay() *int { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_StartDate{} + } + return t.Day +} + +type SearchBaseManga_Page_Media_BaseManga_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *SearchBaseManga_Page_Media_BaseManga_EndDate) GetYear() *int { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_EndDate{} + } + return t.Year +} +func (t *SearchBaseManga_Page_Media_BaseManga_EndDate) GetMonth() *int { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_EndDate{} + } + return t.Month +} +func (t *SearchBaseManga_Page_Media_BaseManga_EndDate) GetDay() *int { + if t == nil { + t = &SearchBaseManga_Page_Media_BaseManga_EndDate{} + } + return t.Day +} + +type SearchBaseManga_Page struct { + PageInfo *SearchBaseManga_Page_PageInfo "json:\"pageInfo,omitempty\" graphql:\"pageInfo\"" + Media []*BaseManga "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *SearchBaseManga_Page) GetPageInfo() *SearchBaseManga_Page_PageInfo { + if t == nil { + t = &SearchBaseManga_Page{} + } + return t.PageInfo +} +func (t *SearchBaseManga_Page) GetMedia() []*BaseManga { + if t == nil { + t = &SearchBaseManga_Page{} + } + return t.Media +} + +type BaseMangaById_Media_BaseManga_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *BaseMangaById_Media_BaseManga_Title) GetUserPreferred() *string { + if t == nil { + t = &BaseMangaById_Media_BaseManga_Title{} + } + return t.UserPreferred +} +func (t *BaseMangaById_Media_BaseManga_Title) GetRomaji() *string { + if t == nil { + t = &BaseMangaById_Media_BaseManga_Title{} + } + return t.Romaji +} +func (t *BaseMangaById_Media_BaseManga_Title) GetEnglish() *string { + if t == nil { + t = &BaseMangaById_Media_BaseManga_Title{} + } + return t.English +} +func (t *BaseMangaById_Media_BaseManga_Title) GetNative() *string { + if t == nil { + t = &BaseMangaById_Media_BaseManga_Title{} + } + return t.Native +} + +type BaseMangaById_Media_BaseManga_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *BaseMangaById_Media_BaseManga_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &BaseMangaById_Media_BaseManga_CoverImage{} + } + return t.ExtraLarge +} +func (t *BaseMangaById_Media_BaseManga_CoverImage) GetLarge() *string { + if t == nil { + t = &BaseMangaById_Media_BaseManga_CoverImage{} + } + return t.Large +} +func (t *BaseMangaById_Media_BaseManga_CoverImage) GetMedium() *string { + if t == nil { + t = &BaseMangaById_Media_BaseManga_CoverImage{} + } + return t.Medium +} +func (t *BaseMangaById_Media_BaseManga_CoverImage) GetColor() *string { + if t == nil { + t = &BaseMangaById_Media_BaseManga_CoverImage{} + } + return t.Color +} + +type BaseMangaById_Media_BaseManga_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *BaseMangaById_Media_BaseManga_StartDate) GetYear() *int { + if t == nil { + t = &BaseMangaById_Media_BaseManga_StartDate{} + } + return t.Year +} +func (t *BaseMangaById_Media_BaseManga_StartDate) GetMonth() *int { + if t == nil { + t = &BaseMangaById_Media_BaseManga_StartDate{} + } + return t.Month +} +func (t *BaseMangaById_Media_BaseManga_StartDate) GetDay() *int { + if t == nil { + t = &BaseMangaById_Media_BaseManga_StartDate{} + } + return t.Day +} + +type BaseMangaById_Media_BaseManga_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *BaseMangaById_Media_BaseManga_EndDate) GetYear() *int { + if t == nil { + t = &BaseMangaById_Media_BaseManga_EndDate{} + } + return t.Year +} +func (t *BaseMangaById_Media_BaseManga_EndDate) GetMonth() *int { + if t == nil { + t = &BaseMangaById_Media_BaseManga_EndDate{} + } + return t.Month +} +func (t *BaseMangaById_Media_BaseManga_EndDate) GetDay() *int { + if t == nil { + t = &BaseMangaById_Media_BaseManga_EndDate{} + } + return t.Day +} + +type MangaDetailsById_Media_Rankings struct { + Context string "json:\"context\" graphql:\"context\"" + Type MediaRankType "json:\"type\" graphql:\"type\"" + Rank int "json:\"rank\" graphql:\"rank\"" + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Format MediaFormat "json:\"format\" graphql:\"format\"" + AllTime *bool "json:\"allTime,omitempty\" graphql:\"allTime\"" + Season *MediaSeason "json:\"season,omitempty\" graphql:\"season\"" +} + +func (t *MangaDetailsById_Media_Rankings) GetContext() string { + if t == nil { + t = &MangaDetailsById_Media_Rankings{} + } + return t.Context +} +func (t *MangaDetailsById_Media_Rankings) GetType() *MediaRankType { + if t == nil { + t = &MangaDetailsById_Media_Rankings{} + } + return &t.Type +} +func (t *MangaDetailsById_Media_Rankings) GetRank() int { + if t == nil { + t = &MangaDetailsById_Media_Rankings{} + } + return t.Rank +} +func (t *MangaDetailsById_Media_Rankings) GetYear() *int { + if t == nil { + t = &MangaDetailsById_Media_Rankings{} + } + return t.Year +} +func (t *MangaDetailsById_Media_Rankings) GetFormat() *MediaFormat { + if t == nil { + t = &MangaDetailsById_Media_Rankings{} + } + return &t.Format +} +func (t *MangaDetailsById_Media_Rankings) GetAllTime() *bool { + if t == nil { + t = &MangaDetailsById_Media_Rankings{} + } + return t.AllTime +} +func (t *MangaDetailsById_Media_Rankings) GetSeason() *MediaSeason { + if t == nil { + t = &MangaDetailsById_Media_Rankings{} + } + return t.Season +} + +type MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth) GetYear() *int { + if t == nil { + t = &MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth{} + } + return t.Year +} +func (t *MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth) GetMonth() *int { + if t == nil { + t = &MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth{} + } + return t.Month +} +func (t *MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth) GetDay() *int { + if t == nil { + t = &MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_DateOfBirth{} + } + return t.Day +} + +type MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name struct { + Full *string "json:\"full,omitempty\" graphql:\"full\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" + Alternative []*string "json:\"alternative,omitempty\" graphql:\"alternative\"" +} + +func (t *MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name) GetFull() *string { + if t == nil { + t = &MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name{} + } + return t.Full +} +func (t *MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name) GetNative() *string { + if t == nil { + t = &MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name{} + } + return t.Native +} +func (t *MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name) GetAlternative() []*string { + if t == nil { + t = &MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Name{} + } + return t.Alternative +} + +type MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Image struct { + Large *string "json:\"large,omitempty\" graphql:\"large\"" +} + +func (t *MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Image) GetLarge() *string { + if t == nil { + t = &MangaDetailsById_Media_Characters_Edges_Node_BaseCharacter_Image{} + } + return t.Large +} + +type MangaDetailsById_Media_Characters_Edges struct { + ID *int "json:\"id,omitempty\" graphql:\"id\"" + Role *CharacterRole "json:\"role,omitempty\" graphql:\"role\"" + Name *string "json:\"name,omitempty\" graphql:\"name\"" + Node *BaseCharacter "json:\"node,omitempty\" graphql:\"node\"" +} + +func (t *MangaDetailsById_Media_Characters_Edges) GetID() *int { + if t == nil { + t = &MangaDetailsById_Media_Characters_Edges{} + } + return t.ID +} +func (t *MangaDetailsById_Media_Characters_Edges) GetRole() *CharacterRole { + if t == nil { + t = &MangaDetailsById_Media_Characters_Edges{} + } + return t.Role +} +func (t *MangaDetailsById_Media_Characters_Edges) GetName() *string { + if t == nil { + t = &MangaDetailsById_Media_Characters_Edges{} + } + return t.Name +} +func (t *MangaDetailsById_Media_Characters_Edges) GetNode() *BaseCharacter { + if t == nil { + t = &MangaDetailsById_Media_Characters_Edges{} + } + return t.Node +} + +type MangaDetailsById_Media_Characters struct { + Edges []*MangaDetailsById_Media_Characters_Edges "json:\"edges,omitempty\" graphql:\"edges\"" +} + +func (t *MangaDetailsById_Media_Characters) GetEdges() []*MangaDetailsById_Media_Characters_Edges { + if t == nil { + t = &MangaDetailsById_Media_Characters{} + } + return t.Edges +} + +type MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title) GetUserPreferred() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title{} + } + return t.UserPreferred +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title) GetRomaji() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title{} + } + return t.Romaji +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title) GetEnglish() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title{} + } + return t.English +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title) GetNative() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title{} + } + return t.Native +} + +type MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage{} + } + return t.ExtraLarge +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage) GetLarge() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage{} + } + return t.Large +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage) GetMedium() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage{} + } + return t.Medium +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage) GetColor() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage{} + } + return t.Color +} + +type MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate) GetYear() *int { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate{} + } + return t.Year +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate) GetMonth() *int { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate{} + } + return t.Month +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate) GetDay() *int { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate{} + } + return t.Day +} + +type MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate) GetYear() *int { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate{} + } + return t.Year +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate) GetMonth() *int { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate{} + } + return t.Month +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate) GetDay() *int { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate{} + } + return t.Day +} + +type MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation struct { + ID int "json:\"id\" graphql:\"id\"" + IDMal *int "json:\"idMal,omitempty\" graphql:\"idMal\"" + SiteURL *string "json:\"siteUrl,omitempty\" graphql:\"siteUrl\"" + Status *MediaStatus "json:\"status,omitempty\" graphql:\"status\"" + Season *MediaSeason "json:\"season,omitempty\" graphql:\"season\"" + Type *MediaType "json:\"type,omitempty\" graphql:\"type\"" + Format *MediaFormat "json:\"format,omitempty\" graphql:\"format\"" + BannerImage *string "json:\"bannerImage,omitempty\" graphql:\"bannerImage\"" + Chapters *int "json:\"chapters,omitempty\" graphql:\"chapters\"" + Volumes *int "json:\"volumes,omitempty\" graphql:\"volumes\"" + Synonyms []*string "json:\"synonyms,omitempty\" graphql:\"synonyms\"" + IsAdult *bool "json:\"isAdult,omitempty\" graphql:\"isAdult\"" + CountryOfOrigin *string "json:\"countryOfOrigin,omitempty\" graphql:\"countryOfOrigin\"" + MeanScore *int "json:\"meanScore,omitempty\" graphql:\"meanScore\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + Title *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title "json:\"title,omitempty\" graphql:\"title\"" + CoverImage *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage "json:\"coverImage,omitempty\" graphql:\"coverImage\"" + StartDate *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate "json:\"startDate,omitempty\" graphql:\"startDate\"" + EndDate *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate "json:\"endDate,omitempty\" graphql:\"endDate\"" +} + +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetID() int { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.ID +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetIDMal() *int { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.IDMal +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetSiteURL() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.SiteURL +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetStatus() *MediaStatus { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Status +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetSeason() *MediaSeason { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Season +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetType() *MediaType { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Type +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetFormat() *MediaFormat { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Format +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetBannerImage() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.BannerImage +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetChapters() *int { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Chapters +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetVolumes() *int { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Volumes +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetSynonyms() []*string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Synonyms +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetIsAdult() *bool { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.IsAdult +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetCountryOfOrigin() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.CountryOfOrigin +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetMeanScore() *int { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.MeanScore +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetDescription() *string { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Description +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetTitle() *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.Title +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetCoverImage() *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.CoverImage +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetStartDate() *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.StartDate +} +func (t *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation) GetEndDate() *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation{} + } + return t.EndDate +} + +type MangaDetailsById_Media_Recommendations_Edges_Node struct { + MediaRecommendation *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation "json:\"mediaRecommendation,omitempty\" graphql:\"mediaRecommendation\"" +} + +func (t *MangaDetailsById_Media_Recommendations_Edges_Node) GetMediaRecommendation() *MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges_Node{} + } + return t.MediaRecommendation +} + +type MangaDetailsById_Media_Recommendations_Edges struct { + Node *MangaDetailsById_Media_Recommendations_Edges_Node "json:\"node,omitempty\" graphql:\"node\"" +} + +func (t *MangaDetailsById_Media_Recommendations_Edges) GetNode() *MangaDetailsById_Media_Recommendations_Edges_Node { + if t == nil { + t = &MangaDetailsById_Media_Recommendations_Edges{} + } + return t.Node +} + +type MangaDetailsById_Media_Recommendations struct { + Edges []*MangaDetailsById_Media_Recommendations_Edges "json:\"edges,omitempty\" graphql:\"edges\"" +} + +func (t *MangaDetailsById_Media_Recommendations) GetEdges() []*MangaDetailsById_Media_Recommendations_Edges { + if t == nil { + t = &MangaDetailsById_Media_Recommendations{} + } + return t.Edges +} + +type MangaDetailsById_Media_Relations_Edges_Node_BaseManga_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_Title) GetUserPreferred() *string { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_Title{} + } + return t.UserPreferred +} +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_Title) GetRomaji() *string { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_Title{} + } + return t.Romaji +} +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_Title) GetEnglish() *string { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_Title{} + } + return t.English +} +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_Title) GetNative() *string { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_Title{} + } + return t.Native +} + +type MangaDetailsById_Media_Relations_Edges_Node_BaseManga_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_CoverImage{} + } + return t.ExtraLarge +} +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_CoverImage) GetLarge() *string { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_CoverImage{} + } + return t.Large +} +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_CoverImage) GetMedium() *string { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_CoverImage{} + } + return t.Medium +} +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_CoverImage) GetColor() *string { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_CoverImage{} + } + return t.Color +} + +type MangaDetailsById_Media_Relations_Edges_Node_BaseManga_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_StartDate) GetYear() *int { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_StartDate{} + } + return t.Year +} +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_StartDate) GetMonth() *int { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_StartDate{} + } + return t.Month +} +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_StartDate) GetDay() *int { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_StartDate{} + } + return t.Day +} + +type MangaDetailsById_Media_Relations_Edges_Node_BaseManga_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_EndDate) GetYear() *int { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_EndDate{} + } + return t.Year +} +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_EndDate) GetMonth() *int { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_EndDate{} + } + return t.Month +} +func (t *MangaDetailsById_Media_Relations_Edges_Node_BaseManga_EndDate) GetDay() *int { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges_Node_BaseManga_EndDate{} + } + return t.Day +} + +type MangaDetailsById_Media_Relations_Edges struct { + RelationType *MediaRelation "json:\"relationType,omitempty\" graphql:\"relationType\"" + Node *BaseManga "json:\"node,omitempty\" graphql:\"node\"" +} + +func (t *MangaDetailsById_Media_Relations_Edges) GetRelationType() *MediaRelation { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges{} + } + return t.RelationType +} +func (t *MangaDetailsById_Media_Relations_Edges) GetNode() *BaseManga { + if t == nil { + t = &MangaDetailsById_Media_Relations_Edges{} + } + return t.Node +} + +type MangaDetailsById_Media_Relations struct { + Edges []*MangaDetailsById_Media_Relations_Edges "json:\"edges,omitempty\" graphql:\"edges\"" +} + +func (t *MangaDetailsById_Media_Relations) GetEdges() []*MangaDetailsById_Media_Relations_Edges { + if t == nil { + t = &MangaDetailsById_Media_Relations{} + } + return t.Edges +} + +type MangaDetailsById_Media struct { + SiteURL *string "json:\"siteUrl,omitempty\" graphql:\"siteUrl\"" + ID int "json:\"id\" graphql:\"id\"" + Duration *int "json:\"duration,omitempty\" graphql:\"duration\"" + Genres []*string "json:\"genres,omitempty\" graphql:\"genres\"" + Rankings []*MangaDetailsById_Media_Rankings "json:\"rankings,omitempty\" graphql:\"rankings\"" + Characters *MangaDetailsById_Media_Characters "json:\"characters,omitempty\" graphql:\"characters\"" + Recommendations *MangaDetailsById_Media_Recommendations "json:\"recommendations,omitempty\" graphql:\"recommendations\"" + Relations *MangaDetailsById_Media_Relations "json:\"relations,omitempty\" graphql:\"relations\"" +} + +func (t *MangaDetailsById_Media) GetSiteURL() *string { + if t == nil { + t = &MangaDetailsById_Media{} + } + return t.SiteURL +} +func (t *MangaDetailsById_Media) GetID() int { + if t == nil { + t = &MangaDetailsById_Media{} + } + return t.ID +} +func (t *MangaDetailsById_Media) GetDuration() *int { + if t == nil { + t = &MangaDetailsById_Media{} + } + return t.Duration +} +func (t *MangaDetailsById_Media) GetGenres() []*string { + if t == nil { + t = &MangaDetailsById_Media{} + } + return t.Genres +} +func (t *MangaDetailsById_Media) GetRankings() []*MangaDetailsById_Media_Rankings { + if t == nil { + t = &MangaDetailsById_Media{} + } + return t.Rankings +} +func (t *MangaDetailsById_Media) GetCharacters() *MangaDetailsById_Media_Characters { + if t == nil { + t = &MangaDetailsById_Media{} + } + return t.Characters +} +func (t *MangaDetailsById_Media) GetRecommendations() *MangaDetailsById_Media_Recommendations { + if t == nil { + t = &MangaDetailsById_Media{} + } + return t.Recommendations +} +func (t *MangaDetailsById_Media) GetRelations() *MangaDetailsById_Media_Relations { + if t == nil { + t = &MangaDetailsById_Media{} + } + return t.Relations +} + +type ListManga_Page_PageInfo struct { + HasNextPage *bool "json:\"hasNextPage,omitempty\" graphql:\"hasNextPage\"" + Total *int "json:\"total,omitempty\" graphql:\"total\"" + PerPage *int "json:\"perPage,omitempty\" graphql:\"perPage\"" + CurrentPage *int "json:\"currentPage,omitempty\" graphql:\"currentPage\"" + LastPage *int "json:\"lastPage,omitempty\" graphql:\"lastPage\"" +} + +func (t *ListManga_Page_PageInfo) GetHasNextPage() *bool { + if t == nil { + t = &ListManga_Page_PageInfo{} + } + return t.HasNextPage +} +func (t *ListManga_Page_PageInfo) GetTotal() *int { + if t == nil { + t = &ListManga_Page_PageInfo{} + } + return t.Total +} +func (t *ListManga_Page_PageInfo) GetPerPage() *int { + if t == nil { + t = &ListManga_Page_PageInfo{} + } + return t.PerPage +} +func (t *ListManga_Page_PageInfo) GetCurrentPage() *int { + if t == nil { + t = &ListManga_Page_PageInfo{} + } + return t.CurrentPage +} +func (t *ListManga_Page_PageInfo) GetLastPage() *int { + if t == nil { + t = &ListManga_Page_PageInfo{} + } + return t.LastPage +} + +type ListManga_Page_Media_BaseManga_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *ListManga_Page_Media_BaseManga_Title) GetUserPreferred() *string { + if t == nil { + t = &ListManga_Page_Media_BaseManga_Title{} + } + return t.UserPreferred +} +func (t *ListManga_Page_Media_BaseManga_Title) GetRomaji() *string { + if t == nil { + t = &ListManga_Page_Media_BaseManga_Title{} + } + return t.Romaji +} +func (t *ListManga_Page_Media_BaseManga_Title) GetEnglish() *string { + if t == nil { + t = &ListManga_Page_Media_BaseManga_Title{} + } + return t.English +} +func (t *ListManga_Page_Media_BaseManga_Title) GetNative() *string { + if t == nil { + t = &ListManga_Page_Media_BaseManga_Title{} + } + return t.Native +} + +type ListManga_Page_Media_BaseManga_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *ListManga_Page_Media_BaseManga_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &ListManga_Page_Media_BaseManga_CoverImage{} + } + return t.ExtraLarge +} +func (t *ListManga_Page_Media_BaseManga_CoverImage) GetLarge() *string { + if t == nil { + t = &ListManga_Page_Media_BaseManga_CoverImage{} + } + return t.Large +} +func (t *ListManga_Page_Media_BaseManga_CoverImage) GetMedium() *string { + if t == nil { + t = &ListManga_Page_Media_BaseManga_CoverImage{} + } + return t.Medium +} +func (t *ListManga_Page_Media_BaseManga_CoverImage) GetColor() *string { + if t == nil { + t = &ListManga_Page_Media_BaseManga_CoverImage{} + } + return t.Color +} + +type ListManga_Page_Media_BaseManga_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *ListManga_Page_Media_BaseManga_StartDate) GetYear() *int { + if t == nil { + t = &ListManga_Page_Media_BaseManga_StartDate{} + } + return t.Year +} +func (t *ListManga_Page_Media_BaseManga_StartDate) GetMonth() *int { + if t == nil { + t = &ListManga_Page_Media_BaseManga_StartDate{} + } + return t.Month +} +func (t *ListManga_Page_Media_BaseManga_StartDate) GetDay() *int { + if t == nil { + t = &ListManga_Page_Media_BaseManga_StartDate{} + } + return t.Day +} + +type ListManga_Page_Media_BaseManga_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *ListManga_Page_Media_BaseManga_EndDate) GetYear() *int { + if t == nil { + t = &ListManga_Page_Media_BaseManga_EndDate{} + } + return t.Year +} +func (t *ListManga_Page_Media_BaseManga_EndDate) GetMonth() *int { + if t == nil { + t = &ListManga_Page_Media_BaseManga_EndDate{} + } + return t.Month +} +func (t *ListManga_Page_Media_BaseManga_EndDate) GetDay() *int { + if t == nil { + t = &ListManga_Page_Media_BaseManga_EndDate{} + } + return t.Day +} + +type ListManga_Page struct { + PageInfo *ListManga_Page_PageInfo "json:\"pageInfo,omitempty\" graphql:\"pageInfo\"" + Media []*BaseManga "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *ListManga_Page) GetPageInfo() *ListManga_Page_PageInfo { + if t == nil { + t = &ListManga_Page{} + } + return t.PageInfo +} +func (t *ListManga_Page) GetMedia() []*BaseManga { + if t == nil { + t = &ListManga_Page{} + } + return t.Media +} + +type ViewerStats_Viewer_Statistics_Anime_Studios_UserStudioStats_Studio struct { + ID int "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" + IsAnimationStudio bool "json:\"isAnimationStudio\" graphql:\"isAnimationStudio\"" +} + +func (t *ViewerStats_Viewer_Statistics_Anime_Studios_UserStudioStats_Studio) GetID() int { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime_Studios_UserStudioStats_Studio{} + } + return t.ID +} +func (t *ViewerStats_Viewer_Statistics_Anime_Studios_UserStudioStats_Studio) GetName() string { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime_Studios_UserStudioStats_Studio{} + } + return t.Name +} +func (t *ViewerStats_Viewer_Statistics_Anime_Studios_UserStudioStats_Studio) GetIsAnimationStudio() bool { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime_Studios_UserStudioStats_Studio{} + } + return t.IsAnimationStudio +} + +type ViewerStats_Viewer_Statistics_Anime struct { + Count int "json:\"count\" graphql:\"count\"" + MinutesWatched int "json:\"minutesWatched\" graphql:\"minutesWatched\"" + EpisodesWatched int "json:\"episodesWatched\" graphql:\"episodesWatched\"" + MeanScore float64 "json:\"meanScore\" graphql:\"meanScore\"" + Formats []*UserFormatStats "json:\"formats,omitempty\" graphql:\"formats\"" + Genres []*UserGenreStats "json:\"genres,omitempty\" graphql:\"genres\"" + Statuses []*UserStatusStats "json:\"statuses,omitempty\" graphql:\"statuses\"" + Studios []*UserStudioStats "json:\"studios,omitempty\" graphql:\"studios\"" + Scores []*UserScoreStats "json:\"scores,omitempty\" graphql:\"scores\"" + StartYears []*UserStartYearStats "json:\"startYears,omitempty\" graphql:\"startYears\"" + ReleaseYears []*UserReleaseYearStats "json:\"releaseYears,omitempty\" graphql:\"releaseYears\"" +} + +func (t *ViewerStats_Viewer_Statistics_Anime) GetCount() int { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime{} + } + return t.Count +} +func (t *ViewerStats_Viewer_Statistics_Anime) GetMinutesWatched() int { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime{} + } + return t.MinutesWatched +} +func (t *ViewerStats_Viewer_Statistics_Anime) GetEpisodesWatched() int { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime{} + } + return t.EpisodesWatched +} +func (t *ViewerStats_Viewer_Statistics_Anime) GetMeanScore() float64 { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime{} + } + return t.MeanScore +} +func (t *ViewerStats_Viewer_Statistics_Anime) GetFormats() []*UserFormatStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime{} + } + return t.Formats +} +func (t *ViewerStats_Viewer_Statistics_Anime) GetGenres() []*UserGenreStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime{} + } + return t.Genres +} +func (t *ViewerStats_Viewer_Statistics_Anime) GetStatuses() []*UserStatusStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime{} + } + return t.Statuses +} +func (t *ViewerStats_Viewer_Statistics_Anime) GetStudios() []*UserStudioStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime{} + } + return t.Studios +} +func (t *ViewerStats_Viewer_Statistics_Anime) GetScores() []*UserScoreStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime{} + } + return t.Scores +} +func (t *ViewerStats_Viewer_Statistics_Anime) GetStartYears() []*UserStartYearStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime{} + } + return t.StartYears +} +func (t *ViewerStats_Viewer_Statistics_Anime) GetReleaseYears() []*UserReleaseYearStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Anime{} + } + return t.ReleaseYears +} + +type ViewerStats_Viewer_Statistics_Manga_Studios_UserStudioStats_Studio struct { + ID int "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" + IsAnimationStudio bool "json:\"isAnimationStudio\" graphql:\"isAnimationStudio\"" +} + +func (t *ViewerStats_Viewer_Statistics_Manga_Studios_UserStudioStats_Studio) GetID() int { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga_Studios_UserStudioStats_Studio{} + } + return t.ID +} +func (t *ViewerStats_Viewer_Statistics_Manga_Studios_UserStudioStats_Studio) GetName() string { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga_Studios_UserStudioStats_Studio{} + } + return t.Name +} +func (t *ViewerStats_Viewer_Statistics_Manga_Studios_UserStudioStats_Studio) GetIsAnimationStudio() bool { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga_Studios_UserStudioStats_Studio{} + } + return t.IsAnimationStudio +} + +type ViewerStats_Viewer_Statistics_Manga struct { + Count int "json:\"count\" graphql:\"count\"" + ChaptersRead int "json:\"chaptersRead\" graphql:\"chaptersRead\"" + MeanScore float64 "json:\"meanScore\" graphql:\"meanScore\"" + Formats []*UserFormatStats "json:\"formats,omitempty\" graphql:\"formats\"" + Genres []*UserGenreStats "json:\"genres,omitempty\" graphql:\"genres\"" + Statuses []*UserStatusStats "json:\"statuses,omitempty\" graphql:\"statuses\"" + Studios []*UserStudioStats "json:\"studios,omitempty\" graphql:\"studios\"" + Scores []*UserScoreStats "json:\"scores,omitempty\" graphql:\"scores\"" + StartYears []*UserStartYearStats "json:\"startYears,omitempty\" graphql:\"startYears\"" + ReleaseYears []*UserReleaseYearStats "json:\"releaseYears,omitempty\" graphql:\"releaseYears\"" +} + +func (t *ViewerStats_Viewer_Statistics_Manga) GetCount() int { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga{} + } + return t.Count +} +func (t *ViewerStats_Viewer_Statistics_Manga) GetChaptersRead() int { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga{} + } + return t.ChaptersRead +} +func (t *ViewerStats_Viewer_Statistics_Manga) GetMeanScore() float64 { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga{} + } + return t.MeanScore +} +func (t *ViewerStats_Viewer_Statistics_Manga) GetFormats() []*UserFormatStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga{} + } + return t.Formats +} +func (t *ViewerStats_Viewer_Statistics_Manga) GetGenres() []*UserGenreStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga{} + } + return t.Genres +} +func (t *ViewerStats_Viewer_Statistics_Manga) GetStatuses() []*UserStatusStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga{} + } + return t.Statuses +} +func (t *ViewerStats_Viewer_Statistics_Manga) GetStudios() []*UserStudioStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga{} + } + return t.Studios +} +func (t *ViewerStats_Viewer_Statistics_Manga) GetScores() []*UserScoreStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga{} + } + return t.Scores +} +func (t *ViewerStats_Viewer_Statistics_Manga) GetStartYears() []*UserStartYearStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga{} + } + return t.StartYears +} +func (t *ViewerStats_Viewer_Statistics_Manga) GetReleaseYears() []*UserReleaseYearStats { + if t == nil { + t = &ViewerStats_Viewer_Statistics_Manga{} + } + return t.ReleaseYears +} + +type ViewerStats_Viewer_Statistics struct { + Anime *ViewerStats_Viewer_Statistics_Anime "json:\"anime,omitempty\" graphql:\"anime\"" + Manga *ViewerStats_Viewer_Statistics_Manga "json:\"manga,omitempty\" graphql:\"manga\"" +} + +func (t *ViewerStats_Viewer_Statistics) GetAnime() *ViewerStats_Viewer_Statistics_Anime { + if t == nil { + t = &ViewerStats_Viewer_Statistics{} + } + return t.Anime +} +func (t *ViewerStats_Viewer_Statistics) GetManga() *ViewerStats_Viewer_Statistics_Manga { + if t == nil { + t = &ViewerStats_Viewer_Statistics{} + } + return t.Manga +} + +type ViewerStats_Viewer struct { + Statistics *ViewerStats_Viewer_Statistics "json:\"statistics,omitempty\" graphql:\"statistics\"" +} + +func (t *ViewerStats_Viewer) GetStatistics() *ViewerStats_Viewer_Statistics { + if t == nil { + t = &ViewerStats_Viewer{} + } + return t.Statistics +} + +type StudioDetails_Studio_Media_Nodes_BaseAnime_Trailer struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" + Site *string "json:\"site,omitempty\" graphql:\"site\"" + Thumbnail *string "json:\"thumbnail,omitempty\" graphql:\"thumbnail\"" +} + +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_Trailer) GetID() *string { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_Trailer{} + } + return t.ID +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_Trailer) GetSite() *string { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_Trailer{} + } + return t.Site +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_Trailer) GetThumbnail() *string { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_Trailer{} + } + return t.Thumbnail +} + +type StudioDetails_Studio_Media_Nodes_BaseAnime_Title struct { + UserPreferred *string "json:\"userPreferred,omitempty\" graphql:\"userPreferred\"" + Romaji *string "json:\"romaji,omitempty\" graphql:\"romaji\"" + English *string "json:\"english,omitempty\" graphql:\"english\"" + Native *string "json:\"native,omitempty\" graphql:\"native\"" +} + +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_Title) GetUserPreferred() *string { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_Title{} + } + return t.UserPreferred +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_Title) GetRomaji() *string { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_Title{} + } + return t.Romaji +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_Title) GetEnglish() *string { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_Title{} + } + return t.English +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_Title) GetNative() *string { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_Title{} + } + return t.Native +} + +type StudioDetails_Studio_Media_Nodes_BaseAnime_CoverImage struct { + ExtraLarge *string "json:\"extraLarge,omitempty\" graphql:\"extraLarge\"" + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" + Color *string "json:\"color,omitempty\" graphql:\"color\"" +} + +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_CoverImage) GetExtraLarge() *string { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_CoverImage{} + } + return t.ExtraLarge +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_CoverImage) GetLarge() *string { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_CoverImage{} + } + return t.Large +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_CoverImage) GetMedium() *string { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_CoverImage{} + } + return t.Medium +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_CoverImage) GetColor() *string { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_CoverImage{} + } + return t.Color +} + +type StudioDetails_Studio_Media_Nodes_BaseAnime_StartDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_StartDate) GetYear() *int { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_StartDate{} + } + return t.Year +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_StartDate) GetMonth() *int { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_StartDate{} + } + return t.Month +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_StartDate) GetDay() *int { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_StartDate{} + } + return t.Day +} + +type StudioDetails_Studio_Media_Nodes_BaseAnime_EndDate struct { + Year *int "json:\"year,omitempty\" graphql:\"year\"" + Month *int "json:\"month,omitempty\" graphql:\"month\"" + Day *int "json:\"day,omitempty\" graphql:\"day\"" +} + +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_EndDate) GetYear() *int { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_EndDate{} + } + return t.Year +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_EndDate) GetMonth() *int { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_EndDate{} + } + return t.Month +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_EndDate) GetDay() *int { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_EndDate{} + } + return t.Day +} + +type StudioDetails_Studio_Media_Nodes_BaseAnime_NextAiringEpisode struct { + AiringAt int "json:\"airingAt\" graphql:\"airingAt\"" + TimeUntilAiring int "json:\"timeUntilAiring\" graphql:\"timeUntilAiring\"" + Episode int "json:\"episode\" graphql:\"episode\"" +} + +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_NextAiringEpisode) GetAiringAt() int { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_NextAiringEpisode{} + } + return t.AiringAt +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_NextAiringEpisode) GetTimeUntilAiring() int { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_NextAiringEpisode{} + } + return t.TimeUntilAiring +} +func (t *StudioDetails_Studio_Media_Nodes_BaseAnime_NextAiringEpisode) GetEpisode() int { + if t == nil { + t = &StudioDetails_Studio_Media_Nodes_BaseAnime_NextAiringEpisode{} + } + return t.Episode +} + +type StudioDetails_Studio_Media struct { + Nodes []*BaseAnime "json:\"nodes,omitempty\" graphql:\"nodes\"" +} + +func (t *StudioDetails_Studio_Media) GetNodes() []*BaseAnime { + if t == nil { + t = &StudioDetails_Studio_Media{} + } + return t.Nodes +} + +type StudioDetails_Studio struct { + ID int "json:\"id\" graphql:\"id\"" + IsAnimationStudio bool "json:\"isAnimationStudio\" graphql:\"isAnimationStudio\"" + Name string "json:\"name\" graphql:\"name\"" + Media *StudioDetails_Studio_Media "json:\"media,omitempty\" graphql:\"media\"" +} + +func (t *StudioDetails_Studio) GetID() int { + if t == nil { + t = &StudioDetails_Studio{} + } + return t.ID +} +func (t *StudioDetails_Studio) GetIsAnimationStudio() bool { + if t == nil { + t = &StudioDetails_Studio{} + } + return t.IsAnimationStudio +} +func (t *StudioDetails_Studio) GetName() string { + if t == nil { + t = &StudioDetails_Studio{} + } + return t.Name +} +func (t *StudioDetails_Studio) GetMedia() *StudioDetails_Studio_Media { + if t == nil { + t = &StudioDetails_Studio{} + } + return t.Media +} + +type GetViewer_Viewer_Avatar struct { + Large *string "json:\"large,omitempty\" graphql:\"large\"" + Medium *string "json:\"medium,omitempty\" graphql:\"medium\"" +} + +func (t *GetViewer_Viewer_Avatar) GetLarge() *string { + if t == nil { + t = &GetViewer_Viewer_Avatar{} + } + return t.Large +} +func (t *GetViewer_Viewer_Avatar) GetMedium() *string { + if t == nil { + t = &GetViewer_Viewer_Avatar{} + } + return t.Medium +} + +type GetViewer_Viewer_Options struct { + DisplayAdultContent *bool "json:\"displayAdultContent,omitempty\" graphql:\"displayAdultContent\"" + AiringNotifications *bool "json:\"airingNotifications,omitempty\" graphql:\"airingNotifications\"" + ProfileColor *string "json:\"profileColor,omitempty\" graphql:\"profileColor\"" +} + +func (t *GetViewer_Viewer_Options) GetDisplayAdultContent() *bool { + if t == nil { + t = &GetViewer_Viewer_Options{} + } + return t.DisplayAdultContent +} +func (t *GetViewer_Viewer_Options) GetAiringNotifications() *bool { + if t == nil { + t = &GetViewer_Viewer_Options{} + } + return t.AiringNotifications +} +func (t *GetViewer_Viewer_Options) GetProfileColor() *string { + if t == nil { + t = &GetViewer_Viewer_Options{} + } + return t.ProfileColor +} + +type GetViewer_Viewer struct { + Name string "json:\"name\" graphql:\"name\"" + Avatar *GetViewer_Viewer_Avatar "json:\"avatar,omitempty\" graphql:\"avatar\"" + BannerImage *string "json:\"bannerImage,omitempty\" graphql:\"bannerImage\"" + IsBlocked *bool "json:\"isBlocked,omitempty\" graphql:\"isBlocked\"" + Options *GetViewer_Viewer_Options "json:\"options,omitempty\" graphql:\"options\"" +} + +func (t *GetViewer_Viewer) GetName() string { + if t == nil { + t = &GetViewer_Viewer{} + } + return t.Name +} +func (t *GetViewer_Viewer) GetAvatar() *GetViewer_Viewer_Avatar { + if t == nil { + t = &GetViewer_Viewer{} + } + return t.Avatar +} +func (t *GetViewer_Viewer) GetBannerImage() *string { + if t == nil { + t = &GetViewer_Viewer{} + } + return t.BannerImage +} +func (t *GetViewer_Viewer) GetIsBlocked() *bool { + if t == nil { + t = &GetViewer_Viewer{} + } + return t.IsBlocked +} +func (t *GetViewer_Viewer) GetOptions() *GetViewer_Viewer_Options { + if t == nil { + t = &GetViewer_Viewer{} + } + return t.Options +} + +type AnimeCollection struct { + MediaListCollection *AnimeCollection_MediaListCollection "json:\"MediaListCollection,omitempty\" graphql:\"MediaListCollection\"" +} + +func (t *AnimeCollection) GetMediaListCollection() *AnimeCollection_MediaListCollection { + if t == nil { + t = &AnimeCollection{} + } + return t.MediaListCollection +} + +type AnimeCollectionWithRelations struct { + MediaListCollection *AnimeCollectionWithRelations_MediaListCollection "json:\"MediaListCollection,omitempty\" graphql:\"MediaListCollection\"" +} + +func (t *AnimeCollectionWithRelations) GetMediaListCollection() *AnimeCollectionWithRelations_MediaListCollection { + if t == nil { + t = &AnimeCollectionWithRelations{} + } + return t.MediaListCollection +} + +type BaseAnimeByMalID struct { + Media *BaseAnime "json:\"Media,omitempty\" graphql:\"Media\"" +} + +func (t *BaseAnimeByMalID) GetMedia() *BaseAnime { + if t == nil { + t = &BaseAnimeByMalID{} + } + return t.Media +} + +type BaseAnimeByID struct { + Media *BaseAnime "json:\"Media,omitempty\" graphql:\"Media\"" +} + +func (t *BaseAnimeByID) GetMedia() *BaseAnime { + if t == nil { + t = &BaseAnimeByID{} + } + return t.Media +} + +type SearchBaseAnimeByIds struct { + Page *SearchBaseAnimeByIds_Page "json:\"Page,omitempty\" graphql:\"Page\"" +} + +func (t *SearchBaseAnimeByIds) GetPage() *SearchBaseAnimeByIds_Page { + if t == nil { + t = &SearchBaseAnimeByIds{} + } + return t.Page +} + +type CompleteAnimeByID struct { + Media *CompleteAnime "json:\"Media,omitempty\" graphql:\"Media\"" +} + +func (t *CompleteAnimeByID) GetMedia() *CompleteAnime { + if t == nil { + t = &CompleteAnimeByID{} + } + return t.Media +} + +type AnimeDetailsByID struct { + Media *AnimeDetailsById_Media "json:\"Media,omitempty\" graphql:\"Media\"" +} + +func (t *AnimeDetailsByID) GetMedia() *AnimeDetailsById_Media { + if t == nil { + t = &AnimeDetailsByID{} + } + return t.Media +} + +type ListAnime struct { + Page *ListAnime_Page "json:\"Page,omitempty\" graphql:\"Page\"" +} + +func (t *ListAnime) GetPage() *ListAnime_Page { + if t == nil { + t = &ListAnime{} + } + return t.Page +} + +type ListRecentAnime struct { + Page *ListRecentAnime_Page "json:\"Page,omitempty\" graphql:\"Page\"" +} + +func (t *ListRecentAnime) GetPage() *ListRecentAnime_Page { + if t == nil { + t = &ListRecentAnime{} + } + return t.Page +} + +type AnimeAiringSchedule struct { + Ongoing *AnimeAiringSchedule_Ongoing "json:\"ongoing,omitempty\" graphql:\"ongoing\"" + OngoingNext *AnimeAiringSchedule_OngoingNext "json:\"ongoingNext,omitempty\" graphql:\"ongoingNext\"" + Upcoming *AnimeAiringSchedule_Upcoming "json:\"upcoming,omitempty\" graphql:\"upcoming\"" + UpcomingNext *AnimeAiringSchedule_UpcomingNext "json:\"upcomingNext,omitempty\" graphql:\"upcomingNext\"" + Preceding *AnimeAiringSchedule_Preceding "json:\"preceding,omitempty\" graphql:\"preceding\"" +} + +func (t *AnimeAiringSchedule) GetOngoing() *AnimeAiringSchedule_Ongoing { + if t == nil { + t = &AnimeAiringSchedule{} + } + return t.Ongoing +} +func (t *AnimeAiringSchedule) GetOngoingNext() *AnimeAiringSchedule_OngoingNext { + if t == nil { + t = &AnimeAiringSchedule{} + } + return t.OngoingNext +} +func (t *AnimeAiringSchedule) GetUpcoming() *AnimeAiringSchedule_Upcoming { + if t == nil { + t = &AnimeAiringSchedule{} + } + return t.Upcoming +} +func (t *AnimeAiringSchedule) GetUpcomingNext() *AnimeAiringSchedule_UpcomingNext { + if t == nil { + t = &AnimeAiringSchedule{} + } + return t.UpcomingNext +} +func (t *AnimeAiringSchedule) GetPreceding() *AnimeAiringSchedule_Preceding { + if t == nil { + t = &AnimeAiringSchedule{} + } + return t.Preceding +} + +type AnimeAiringScheduleRaw struct { + Page *AnimeAiringScheduleRaw_Page "json:\"Page,omitempty\" graphql:\"Page\"" +} + +func (t *AnimeAiringScheduleRaw) GetPage() *AnimeAiringScheduleRaw_Page { + if t == nil { + t = &AnimeAiringScheduleRaw{} + } + return t.Page +} + +type UpdateMediaListEntry struct { + SaveMediaListEntry *UpdateMediaListEntry_SaveMediaListEntry "json:\"SaveMediaListEntry,omitempty\" graphql:\"SaveMediaListEntry\"" +} + +func (t *UpdateMediaListEntry) GetSaveMediaListEntry() *UpdateMediaListEntry_SaveMediaListEntry { + if t == nil { + t = &UpdateMediaListEntry{} + } + return t.SaveMediaListEntry +} + +type UpdateMediaListEntryProgress struct { + SaveMediaListEntry *UpdateMediaListEntryProgress_SaveMediaListEntry "json:\"SaveMediaListEntry,omitempty\" graphql:\"SaveMediaListEntry\"" +} + +func (t *UpdateMediaListEntryProgress) GetSaveMediaListEntry() *UpdateMediaListEntryProgress_SaveMediaListEntry { + if t == nil { + t = &UpdateMediaListEntryProgress{} + } + return t.SaveMediaListEntry +} + +type DeleteEntry struct { + DeleteMediaListEntry *DeleteEntry_DeleteMediaListEntry "json:\"DeleteMediaListEntry,omitempty\" graphql:\"DeleteMediaListEntry\"" +} + +func (t *DeleteEntry) GetDeleteMediaListEntry() *DeleteEntry_DeleteMediaListEntry { + if t == nil { + t = &DeleteEntry{} + } + return t.DeleteMediaListEntry +} + +type UpdateMediaListEntryRepeat struct { + SaveMediaListEntry *UpdateMediaListEntryRepeat_SaveMediaListEntry "json:\"SaveMediaListEntry,omitempty\" graphql:\"SaveMediaListEntry\"" +} + +func (t *UpdateMediaListEntryRepeat) GetSaveMediaListEntry() *UpdateMediaListEntryRepeat_SaveMediaListEntry { + if t == nil { + t = &UpdateMediaListEntryRepeat{} + } + return t.SaveMediaListEntry +} + +type MangaCollection struct { + MediaListCollection *MangaCollection_MediaListCollection "json:\"MediaListCollection,omitempty\" graphql:\"MediaListCollection\"" +} + +func (t *MangaCollection) GetMediaListCollection() *MangaCollection_MediaListCollection { + if t == nil { + t = &MangaCollection{} + } + return t.MediaListCollection +} + +type SearchBaseManga struct { + Page *SearchBaseManga_Page "json:\"Page,omitempty\" graphql:\"Page\"" +} + +func (t *SearchBaseManga) GetPage() *SearchBaseManga_Page { + if t == nil { + t = &SearchBaseManga{} + } + return t.Page +} + +type BaseMangaByID struct { + Media *BaseManga "json:\"Media,omitempty\" graphql:\"Media\"" +} + +func (t *BaseMangaByID) GetMedia() *BaseManga { + if t == nil { + t = &BaseMangaByID{} + } + return t.Media +} + +type MangaDetailsByID struct { + Media *MangaDetailsById_Media "json:\"Media,omitempty\" graphql:\"Media\"" +} + +func (t *MangaDetailsByID) GetMedia() *MangaDetailsById_Media { + if t == nil { + t = &MangaDetailsByID{} + } + return t.Media +} + +type ListManga struct { + Page *ListManga_Page "json:\"Page,omitempty\" graphql:\"Page\"" +} + +func (t *ListManga) GetPage() *ListManga_Page { + if t == nil { + t = &ListManga{} + } + return t.Page +} + +type ViewerStats struct { + Viewer *ViewerStats_Viewer "json:\"Viewer,omitempty\" graphql:\"Viewer\"" +} + +func (t *ViewerStats) GetViewer() *ViewerStats_Viewer { + if t == nil { + t = &ViewerStats{} + } + return t.Viewer +} + +type StudioDetails struct { + Studio *StudioDetails_Studio "json:\"Studio,omitempty\" graphql:\"Studio\"" +} + +func (t *StudioDetails) GetStudio() *StudioDetails_Studio { + if t == nil { + t = &StudioDetails{} + } + return t.Studio +} + +type GetViewer struct { + Viewer *GetViewer_Viewer "json:\"Viewer,omitempty\" graphql:\"Viewer\"" +} + +func (t *GetViewer) GetViewer() *GetViewer_Viewer { + if t == nil { + t = &GetViewer{} + } + return t.Viewer +} + +const AnimeCollectionDocument = `query AnimeCollection ($userName: String) { + MediaListCollection(userName: $userName, forceSingleCompletedList: true, type: ANIME) { + lists { + status + name + isCustomList + entries { + id + score(format: POINT_100) + progress + status + notes + repeat + private + startedAt { + year + month + day + } + completedAt { + year + month + day + } + media { + ... baseAnime + } + } + } + } +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + seasonYear + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} +` + +func (c *Client) AnimeCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollection, error) { + vars := map[string]any{ + "userName": userName, + } + + var res AnimeCollection + if err := c.Client.Post(ctx, "AnimeCollection", AnimeCollectionDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const AnimeCollectionWithRelationsDocument = `query AnimeCollectionWithRelations ($userName: String) { + MediaListCollection(userName: $userName, forceSingleCompletedList: true, type: ANIME) { + lists { + status + name + isCustomList + entries { + id + score(format: POINT_100) + progress + status + notes + repeat + private + startedAt { + year + month + day + } + completedAt { + year + month + day + } + media { + ... completeAnime + } + } + } + } +} +fragment completeAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + seasonYear + type + format + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } + relations { + edges { + relationType(version: 2) + node { + ... baseAnime + } + } + } +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + seasonYear + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} +` + +func (c *Client) AnimeCollectionWithRelations(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollectionWithRelations, error) { + vars := map[string]any{ + "userName": userName, + } + + var res AnimeCollectionWithRelations + if err := c.Client.Post(ctx, "AnimeCollectionWithRelations", AnimeCollectionWithRelationsDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const BaseAnimeByMalIDDocument = `query BaseAnimeByMalId ($id: Int) { + Media(idMal: $id, type: ANIME) { + ... baseAnime + } +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + seasonYear + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} +` + +func (c *Client) BaseAnimeByMalID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByMalID, error) { + vars := map[string]any{ + "id": id, + } + + var res BaseAnimeByMalID + if err := c.Client.Post(ctx, "BaseAnimeByMalId", BaseAnimeByMalIDDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const BaseAnimeByIDDocument = `query BaseAnimeById ($id: Int) { + Media(id: $id, type: ANIME) { + ... baseAnime + } +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + seasonYear + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} +` + +func (c *Client) BaseAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByID, error) { + vars := map[string]any{ + "id": id, + } + + var res BaseAnimeByID + if err := c.Client.Post(ctx, "BaseAnimeById", BaseAnimeByIDDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const SearchBaseAnimeByIdsDocument = `query SearchBaseAnimeByIds ($ids: [Int], $page: Int, $perPage: Int, $status: [MediaStatus], $inCollection: Boolean, $sort: [MediaSort], $season: MediaSeason, $year: Int, $genre: String, $format: MediaFormat) { + Page(page: $page, perPage: $perPage) { + pageInfo { + hasNextPage + } + media(id_in: $ids, type: ANIME, status_in: $status, onList: $inCollection, sort: $sort, season: $season, seasonYear: $year, genre: $genre, format: $format) { + ... baseAnime + } + } +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + seasonYear + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} +` + +func (c *Client) SearchBaseAnimeByIds(ctx context.Context, ids []*int, page *int, perPage *int, status []*MediaStatus, inCollection *bool, sort []*MediaSort, season *MediaSeason, year *int, genre *string, format *MediaFormat, interceptors ...clientv2.RequestInterceptor) (*SearchBaseAnimeByIds, error) { + vars := map[string]any{ + "ids": ids, + "page": page, + "perPage": perPage, + "status": status, + "inCollection": inCollection, + "sort": sort, + "season": season, + "year": year, + "genre": genre, + "format": format, + } + + var res SearchBaseAnimeByIds + if err := c.Client.Post(ctx, "SearchBaseAnimeByIds", SearchBaseAnimeByIdsDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const CompleteAnimeByIDDocument = `query CompleteAnimeById ($id: Int) { + Media(id: $id, type: ANIME) { + ... completeAnime + } +} +fragment completeAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + seasonYear + type + format + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } + relations { + edges { + relationType(version: 2) + node { + ... baseAnime + } + } + } +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + seasonYear + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} +` + +func (c *Client) CompleteAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*CompleteAnimeByID, error) { + vars := map[string]any{ + "id": id, + } + + var res CompleteAnimeByID + if err := c.Client.Post(ctx, "CompleteAnimeById", CompleteAnimeByIDDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const AnimeDetailsByIDDocument = `query AnimeDetailsById ($id: Int) { + Media(id: $id, type: ANIME) { + siteUrl + id + duration + genres + averageScore + popularity + meanScore + description + trailer { + id + site + thumbnail + } + startDate { + year + month + day + } + endDate { + year + month + day + } + studios(isMain: true) { + nodes { + name + id + } + } + characters(sort: [ROLE]) { + edges { + id + role + name + node { + ... baseCharacter + } + } + } + staff(sort: [RELEVANCE]) { + edges { + role + node { + name { + full + } + id + } + } + } + rankings { + context + type + rank + year + format + allTime + season + } + recommendations(page: 1, perPage: 8, sort: RATING_DESC) { + edges { + node { + mediaRecommendation { + id + idMal + siteUrl + status(version: 2) + isAdult + season + type + format + meanScore + description + episodes + trailer { + id + site + thumbnail + } + startDate { + year + month + day + } + coverImage { + extraLarge + large + medium + color + } + bannerImage + title { + romaji + english + native + userPreferred + } + } + } + } + } + relations { + edges { + relationType(version: 2) + node { + ... baseAnime + } + } + } + } +} +fragment baseCharacter on Character { + id + isFavourite + gender + age + dateOfBirth { + year + month + day + } + name { + full + native + alternative + } + image { + large + } + description + siteUrl +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + seasonYear + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} +` + +func (c *Client) AnimeDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*AnimeDetailsByID, error) { + vars := map[string]any{ + "id": id, + } + + var res AnimeDetailsByID + if err := c.Client.Post(ctx, "AnimeDetailsById", AnimeDetailsByIDDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const ListAnimeDocument = `query ListAnime ($page: Int, $search: String, $perPage: Int, $sort: [MediaSort], $status: [MediaStatus], $genres: [String], $averageScore_greater: Int, $season: MediaSeason, $seasonYear: Int, $format: MediaFormat, $isAdult: Boolean) { + Page(page: $page, perPage: $perPage) { + pageInfo { + hasNextPage + total + perPage + currentPage + lastPage + } + media(type: ANIME, search: $search, sort: $sort, status_in: $status, isAdult: $isAdult, format: $format, genre_in: $genres, averageScore_greater: $averageScore_greater, season: $season, seasonYear: $seasonYear, format_not: MUSIC) { + ... baseAnime + } + } +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + seasonYear + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} +` + +func (c *Client) ListAnime(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, season *MediaSeason, seasonYear *int, format *MediaFormat, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListAnime, error) { + vars := map[string]any{ + "page": page, + "search": search, + "perPage": perPage, + "sort": sort, + "status": status, + "genres": genres, + "averageScore_greater": averageScoreGreater, + "season": season, + "seasonYear": seasonYear, + "format": format, + "isAdult": isAdult, + } + + var res ListAnime + if err := c.Client.Post(ctx, "ListAnime", ListAnimeDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const ListRecentAnimeDocument = `query ListRecentAnime ($page: Int, $perPage: Int, $airingAt_greater: Int, $airingAt_lesser: Int, $notYetAired: Boolean = false) { + Page(page: $page, perPage: $perPage) { + pageInfo { + hasNextPage + total + perPage + currentPage + lastPage + } + airingSchedules(notYetAired: $notYetAired, sort: TIME_DESC, airingAt_greater: $airingAt_greater, airingAt_lesser: $airingAt_lesser) { + id + airingAt + episode + timeUntilAiring + media { + ... baseAnime + } + } + } +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + seasonYear + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} +` + +func (c *Client) ListRecentAnime(ctx context.Context, page *int, perPage *int, airingAtGreater *int, airingAtLesser *int, notYetAired *bool, interceptors ...clientv2.RequestInterceptor) (*ListRecentAnime, error) { + vars := map[string]any{ + "page": page, + "perPage": perPage, + "airingAt_greater": airingAtGreater, + "airingAt_lesser": airingAtLesser, + "notYetAired": notYetAired, + } + + var res ListRecentAnime + if err := c.Client.Post(ctx, "ListRecentAnime", ListRecentAnimeDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const AnimeAiringScheduleDocument = `query AnimeAiringSchedule ($ids: [Int], $season: MediaSeason, $seasonYear: Int, $previousSeason: MediaSeason, $previousSeasonYear: Int, $nextSeason: MediaSeason, $nextSeasonYear: Int) { + ongoing: Page { + media(id_in: $ids, type: ANIME, season: $season, seasonYear: $seasonYear, onList: true) { + ... animeSchedule + } + } + ongoingNext: Page(page: 2) { + media(id_in: $ids, type: ANIME, season: $season, seasonYear: $seasonYear, onList: true) { + ... animeSchedule + } + } + upcoming: Page { + media(id_in: $ids, type: ANIME, season: $nextSeason, seasonYear: $nextSeasonYear, sort: [START_DATE], onList: true) { + ... animeSchedule + } + } + upcomingNext: Page(page: 2) { + media(id_in: $ids, type: ANIME, season: $nextSeason, seasonYear: $nextSeasonYear, sort: [START_DATE], onList: true) { + ... animeSchedule + } + } + preceding: Page { + media(id_in: $ids, type: ANIME, season: $previousSeason, seasonYear: $previousSeasonYear, onList: true) { + ... animeSchedule + } + } +} +fragment animeSchedule on Media { + id + idMal + previous: airingSchedule(notYetAired: false, perPage: 30) { + nodes { + airingAt + timeUntilAiring + episode + } + } + upcoming: airingSchedule(notYetAired: true, perPage: 30) { + nodes { + airingAt + timeUntilAiring + episode + } + } +} +` + +func (c *Client) AnimeAiringSchedule(ctx context.Context, ids []*int, season *MediaSeason, seasonYear *int, previousSeason *MediaSeason, previousSeasonYear *int, nextSeason *MediaSeason, nextSeasonYear *int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringSchedule, error) { + vars := map[string]any{ + "ids": ids, + "season": season, + "seasonYear": seasonYear, + "previousSeason": previousSeason, + "previousSeasonYear": previousSeasonYear, + "nextSeason": nextSeason, + "nextSeasonYear": nextSeasonYear, + } + + var res AnimeAiringSchedule + if err := c.Client.Post(ctx, "AnimeAiringSchedule", AnimeAiringScheduleDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const AnimeAiringScheduleRawDocument = `query AnimeAiringScheduleRaw ($ids: [Int]) { + Page { + media(id_in: $ids, type: ANIME, onList: true) { + ... animeSchedule + } + } +} +fragment animeSchedule on Media { + id + idMal + previous: airingSchedule(notYetAired: false, perPage: 30) { + nodes { + airingAt + timeUntilAiring + episode + } + } + upcoming: airingSchedule(notYetAired: true, perPage: 30) { + nodes { + airingAt + timeUntilAiring + episode + } + } +} +` + +func (c *Client) AnimeAiringScheduleRaw(ctx context.Context, ids []*int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringScheduleRaw, error) { + vars := map[string]any{ + "ids": ids, + } + + var res AnimeAiringScheduleRaw + if err := c.Client.Post(ctx, "AnimeAiringScheduleRaw", AnimeAiringScheduleRawDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const UpdateMediaListEntryDocument = `mutation UpdateMediaListEntry ($mediaId: Int, $status: MediaListStatus, $scoreRaw: Int, $progress: Int, $startedAt: FuzzyDateInput, $completedAt: FuzzyDateInput) { + SaveMediaListEntry(mediaId: $mediaId, status: $status, scoreRaw: $scoreRaw, progress: $progress, startedAt: $startedAt, completedAt: $completedAt) { + id + } +} +` + +func (c *Client) UpdateMediaListEntry(ctx context.Context, mediaID *int, status *MediaListStatus, scoreRaw *int, progress *int, startedAt *FuzzyDateInput, completedAt *FuzzyDateInput, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntry, error) { + vars := map[string]any{ + "mediaId": mediaID, + "status": status, + "scoreRaw": scoreRaw, + "progress": progress, + "startedAt": startedAt, + "completedAt": completedAt, + } + + var res UpdateMediaListEntry + if err := c.Client.Post(ctx, "UpdateMediaListEntry", UpdateMediaListEntryDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const UpdateMediaListEntryProgressDocument = `mutation UpdateMediaListEntryProgress ($mediaId: Int, $progress: Int, $status: MediaListStatus) { + SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status) { + id + } +} +` + +func (c *Client) UpdateMediaListEntryProgress(ctx context.Context, mediaID *int, progress *int, status *MediaListStatus, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryProgress, error) { + vars := map[string]any{ + "mediaId": mediaID, + "progress": progress, + "status": status, + } + + var res UpdateMediaListEntryProgress + if err := c.Client.Post(ctx, "UpdateMediaListEntryProgress", UpdateMediaListEntryProgressDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const DeleteEntryDocument = `mutation DeleteEntry ($mediaListEntryId: Int) { + DeleteMediaListEntry(id: $mediaListEntryId) { + deleted + } +} +` + +func (c *Client) DeleteEntry(ctx context.Context, mediaListEntryID *int, interceptors ...clientv2.RequestInterceptor) (*DeleteEntry, error) { + vars := map[string]any{ + "mediaListEntryId": mediaListEntryID, + } + + var res DeleteEntry + if err := c.Client.Post(ctx, "DeleteEntry", DeleteEntryDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const UpdateMediaListEntryRepeatDocument = `mutation UpdateMediaListEntryRepeat ($mediaId: Int, $repeat: Int) { + SaveMediaListEntry(mediaId: $mediaId, repeat: $repeat) { + id + } +} +` + +func (c *Client) UpdateMediaListEntryRepeat(ctx context.Context, mediaID *int, repeat *int, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryRepeat, error) { + vars := map[string]any{ + "mediaId": mediaID, + "repeat": repeat, + } + + var res UpdateMediaListEntryRepeat + if err := c.Client.Post(ctx, "UpdateMediaListEntryRepeat", UpdateMediaListEntryRepeatDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const MangaCollectionDocument = `query MangaCollection ($userName: String) { + MediaListCollection(userName: $userName, forceSingleCompletedList: true, type: MANGA) { + lists { + status + name + isCustomList + entries { + id + score(format: POINT_100) + progress + status + notes + repeat + private + startedAt { + year + month + day + } + completedAt { + year + month + day + } + media { + ... baseManga + } + } + } + } +} +fragment baseManga on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + bannerImage + chapters + volumes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } +} +` + +func (c *Client) MangaCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*MangaCollection, error) { + vars := map[string]any{ + "userName": userName, + } + + var res MangaCollection + if err := c.Client.Post(ctx, "MangaCollection", MangaCollectionDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const SearchBaseMangaDocument = `query SearchBaseManga ($page: Int, $perPage: Int, $sort: [MediaSort], $search: String, $status: [MediaStatus]) { + Page(page: $page, perPage: $perPage) { + pageInfo { + hasNextPage + } + media(type: MANGA, search: $search, sort: $sort, status_in: $status, format_not: NOVEL) { + ... baseManga + } + } +} +fragment baseManga on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + bannerImage + chapters + volumes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } +} +` + +func (c *Client) SearchBaseManga(ctx context.Context, page *int, perPage *int, sort []*MediaSort, search *string, status []*MediaStatus, interceptors ...clientv2.RequestInterceptor) (*SearchBaseManga, error) { + vars := map[string]any{ + "page": page, + "perPage": perPage, + "sort": sort, + "search": search, + "status": status, + } + + var res SearchBaseManga + if err := c.Client.Post(ctx, "SearchBaseManga", SearchBaseMangaDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const BaseMangaByIDDocument = `query BaseMangaById ($id: Int) { + Media(id: $id, type: MANGA) { + ... baseManga + } +} +fragment baseManga on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + bannerImage + chapters + volumes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } +} +` + +func (c *Client) BaseMangaByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseMangaByID, error) { + vars := map[string]any{ + "id": id, + } + + var res BaseMangaByID + if err := c.Client.Post(ctx, "BaseMangaById", BaseMangaByIDDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const MangaDetailsByIDDocument = `query MangaDetailsById ($id: Int) { + Media(id: $id, type: MANGA) { + siteUrl + id + duration + genres + rankings { + context + type + rank + year + format + allTime + season + } + characters(sort: [ROLE]) { + edges { + id + role + name + node { + ... baseCharacter + } + } + } + recommendations(page: 1, perPage: 8, sort: RATING_DESC) { + edges { + node { + mediaRecommendation { + id + idMal + siteUrl + status(version: 2) + season + type + format + bannerImage + chapters + volumes + synonyms + isAdult + countryOfOrigin + meanScore + description + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + } + } + } + } + relations { + edges { + relationType(version: 2) + node { + ... baseManga + } + } + } + } +} +fragment baseCharacter on Character { + id + isFavourite + gender + age + dateOfBirth { + year + month + day + } + name { + full + native + alternative + } + image { + large + } + description + siteUrl +} +fragment baseManga on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + bannerImage + chapters + volumes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } +} +` + +func (c *Client) MangaDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*MangaDetailsByID, error) { + vars := map[string]any{ + "id": id, + } + + var res MangaDetailsByID + if err := c.Client.Post(ctx, "MangaDetailsById", MangaDetailsByIDDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const ListMangaDocument = `query ListManga ($page: Int, $search: String, $perPage: Int, $sort: [MediaSort], $status: [MediaStatus], $genres: [String], $averageScore_greater: Int, $startDate_greater: FuzzyDateInt, $startDate_lesser: FuzzyDateInt, $format: MediaFormat, $countryOfOrigin: CountryCode, $isAdult: Boolean) { + Page(page: $page, perPage: $perPage) { + pageInfo { + hasNextPage + total + perPage + currentPage + lastPage + } + media(type: MANGA, isAdult: $isAdult, countryOfOrigin: $countryOfOrigin, search: $search, sort: $sort, status_in: $status, format: $format, genre_in: $genres, averageScore_greater: $averageScore_greater, startDate_greater: $startDate_greater, startDate_lesser: $startDate_lesser, format_not: NOVEL) { + ... baseManga + } + } +} +fragment baseManga on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + bannerImage + chapters + volumes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } +} +` + +func (c *Client) ListManga(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, startDateGreater *string, startDateLesser *string, format *MediaFormat, countryOfOrigin *string, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListManga, error) { + vars := map[string]any{ + "page": page, + "search": search, + "perPage": perPage, + "sort": sort, + "status": status, + "genres": genres, + "averageScore_greater": averageScoreGreater, + "startDate_greater": startDateGreater, + "startDate_lesser": startDateLesser, + "format": format, + "countryOfOrigin": countryOfOrigin, + "isAdult": isAdult, + } + + var res ListManga + if err := c.Client.Post(ctx, "ListManga", ListMangaDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const ViewerStatsDocument = `query ViewerStats { + Viewer { + statistics { + anime { + count + minutesWatched + episodesWatched + meanScore + formats { + ... UserFormatStats + } + genres { + ... UserGenreStats + } + statuses { + ... UserStatusStats + } + studios { + ... UserStudioStats + } + scores { + ... UserScoreStats + } + startYears { + ... UserStartYearStats + } + releaseYears { + ... UserReleaseYearStats + } + } + manga { + count + chaptersRead + meanScore + formats { + ... UserFormatStats + } + genres { + ... UserGenreStats + } + statuses { + ... UserStatusStats + } + studios { + ... UserStudioStats + } + scores { + ... UserScoreStats + } + startYears { + ... UserStartYearStats + } + releaseYears { + ... UserReleaseYearStats + } + } + } + } +} +fragment UserFormatStats on UserFormatStatistic { + format + meanScore + count + minutesWatched + mediaIds + chaptersRead +} +fragment UserGenreStats on UserGenreStatistic { + genre + meanScore + count + minutesWatched + mediaIds + chaptersRead +} +fragment UserStatusStats on UserStatusStatistic { + status + meanScore + count + minutesWatched + mediaIds + chaptersRead +} +fragment UserStudioStats on UserStudioStatistic { + studio { + id + name + isAnimationStudio + } + meanScore + count + minutesWatched + mediaIds + chaptersRead +} +fragment UserScoreStats on UserScoreStatistic { + score + meanScore + count + minutesWatched + mediaIds + chaptersRead +} +fragment UserStartYearStats on UserStartYearStatistic { + startYear + meanScore + count + minutesWatched + mediaIds + chaptersRead +} +fragment UserReleaseYearStats on UserReleaseYearStatistic { + releaseYear + meanScore + count + minutesWatched + mediaIds + chaptersRead +} +` + +func (c *Client) ViewerStats(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*ViewerStats, error) { + vars := map[string]any{} + + var res ViewerStats + if err := c.Client.Post(ctx, "ViewerStats", ViewerStatsDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const StudioDetailsDocument = `query StudioDetails ($id: Int) { + Studio(id: $id) { + id + isAnimationStudio + name + media(perPage: 80, sort: TRENDING_DESC, isMain: true) { + nodes { + ... baseAnime + } + } + } +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + seasonYear + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} +` + +func (c *Client) StudioDetails(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*StudioDetails, error) { + vars := map[string]any{ + "id": id, + } + + var res StudioDetails + if err := c.Client.Post(ctx, "StudioDetails", StudioDetailsDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const GetViewerDocument = `query GetViewer { + Viewer { + name + avatar { + large + medium + } + bannerImage + isBlocked + options { + displayAdultContent + airingNotifications + profileColor + } + } +} +` + +func (c *Client) GetViewer(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*GetViewer, error) { + vars := map[string]any{} + + var res GetViewer + if err := c.Client.Post(ctx, "GetViewer", GetViewerDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +var DocumentOperationNames = map[string]string{ + AnimeCollectionDocument: "AnimeCollection", + AnimeCollectionWithRelationsDocument: "AnimeCollectionWithRelations", + BaseAnimeByMalIDDocument: "BaseAnimeByMalId", + BaseAnimeByIDDocument: "BaseAnimeById", + SearchBaseAnimeByIdsDocument: "SearchBaseAnimeByIds", + CompleteAnimeByIDDocument: "CompleteAnimeById", + AnimeDetailsByIDDocument: "AnimeDetailsById", + ListAnimeDocument: "ListAnime", + ListRecentAnimeDocument: "ListRecentAnime", + AnimeAiringScheduleDocument: "AnimeAiringSchedule", + AnimeAiringScheduleRawDocument: "AnimeAiringScheduleRaw", + UpdateMediaListEntryDocument: "UpdateMediaListEntry", + UpdateMediaListEntryProgressDocument: "UpdateMediaListEntryProgress", + DeleteEntryDocument: "DeleteEntry", + UpdateMediaListEntryRepeatDocument: "UpdateMediaListEntryRepeat", + MangaCollectionDocument: "MangaCollection", + SearchBaseMangaDocument: "SearchBaseManga", + BaseMangaByIDDocument: "BaseMangaById", + MangaDetailsByIDDocument: "MangaDetailsById", + ListMangaDocument: "ListManga", + ViewerStatsDocument: "ViewerStats", + StudioDetailsDocument: "StudioDetails", + GetViewerDocument: "GetViewer", +} diff --git a/seanime-2.9.10/internal/api/anilist/client_mock.go b/seanime-2.9.10/internal/api/anilist/client_mock.go new file mode 100644 index 0000000..a273524 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/client_mock.go @@ -0,0 +1,569 @@ +package anilist + +import ( + "context" + "log" + "os" + "seanime/internal/test_utils" + "seanime/internal/util" + + "github.com/Yamashou/gqlgenc/clientv2" + "github.com/goccy/go-json" + "github.com/rs/zerolog" +) + +// This file contains helper functions for testing the anilist package + +func TestGetMockAnilistClient() AnilistClient { + return NewMockAnilistClient() +} + +// MockAnilistClientImpl is a mock implementation of the AnilistClient, used for tests. +// It uses the real implementation of the AnilistClient to make requests then populates a cache with the results. +// This is to avoid making repeated requests to the AniList API during tests but still have realistic data. +type MockAnilistClientImpl struct { + realAnilistClient AnilistClient + logger *zerolog.Logger +} + +func NewMockAnilistClient() *MockAnilistClientImpl { + return &MockAnilistClientImpl{ + realAnilistClient: NewAnilistClient(test_utils.ConfigData.Provider.AnilistJwt), + logger: util.NewLogger(), + } +} + +func (ac *MockAnilistClientImpl) IsAuthenticated() bool { + return ac.realAnilistClient.IsAuthenticated() +} + +func (ac *MockAnilistClientImpl) BaseAnimeByMalID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByMalID, error) { + file, err := os.Open(test_utils.GetTestDataPath("BaseAnimeByMalID")) + defer file.Close() + if err != nil { + if os.IsNotExist(err) { + ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [BaseAnimeByMalID]: %d", *id) + ret, err := ac.realAnilistClient.BaseAnimeByMalID(context.Background(), id) + if err != nil { + return nil, err + } + data, err := json.Marshal([]*BaseAnimeByMalID{ret}) + if err != nil { + log.Fatal(err) + } + err = os.WriteFile(test_utils.GetTestDataPath("BaseAnimeByMalID"), data, 0644) + if err != nil { + log.Fatal(err) + } + return ret, nil + } + } + + var media []*BaseAnimeByMalID + err = json.NewDecoder(file).Decode(&media) + if err != nil { + log.Fatal(err) + } + var ret *BaseAnimeByMalID + for _, m := range media { + if m.GetMedia().ID == *id { + ret = m + break + } + } + + if ret == nil { + ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [BaseAnimeByMalID]: %d", *id) + ret, err := ac.realAnilistClient.BaseAnimeByMalID(context.Background(), id) + if err != nil { + return nil, err + } + media = append(media, ret) + data, err := json.Marshal(media) + if err != nil { + log.Fatal(err) + } + err = os.WriteFile(test_utils.GetTestDataPath("BaseAnimeByMalID"), data, 0644) + if err != nil { + log.Fatal(err) + } + return ret, nil + } + + ac.logger.Trace().Msgf("MockAnilistClientImpl: CACHE HIT [BaseAnimeByMalID]: %d", *id) + return ret, nil +} + +func (ac *MockAnilistClientImpl) BaseAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByID, error) { + file, err := os.Open(test_utils.GetTestDataPath("BaseAnimeByID")) + defer file.Close() + if err != nil { + if os.IsNotExist(err) { + ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [BaseAnimeByID]: %d", *id) + baseAnime, err := ac.realAnilistClient.BaseAnimeByID(context.Background(), id) + if err != nil { + return nil, err + } + data, err := json.Marshal([]*BaseAnimeByID{baseAnime}) + if err != nil { + log.Fatal(err) + } + err = os.WriteFile(test_utils.GetTestDataPath("BaseAnimeByID"), data, 0644) + if err != nil { + log.Fatal(err) + } + return baseAnime, nil + } + } + + var media []*BaseAnimeByID + err = json.NewDecoder(file).Decode(&media) + if err != nil { + log.Fatal(err) + } + var baseAnime *BaseAnimeByID + for _, m := range media { + if m.GetMedia().ID == *id { + baseAnime = m + break + } + } + + if baseAnime == nil { + ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [BaseAnimeByID]: %d", *id) + baseAnime, err := ac.realAnilistClient.BaseAnimeByID(context.Background(), id) + if err != nil { + return nil, err + } + media = append(media, baseAnime) + data, err := json.Marshal(media) + if err != nil { + log.Fatal(err) + } + err = os.WriteFile(test_utils.GetTestDataPath("BaseAnimeByID"), data, 0644) + if err != nil { + log.Fatal(err) + } + return baseAnime, nil + } + + ac.logger.Trace().Msgf("MockAnilistClientImpl: CACHE HIT [BaseAnimeByID]: %d", *id) + return baseAnime, nil +} + +// AnimeCollection +// - Set userName to nil to use the boilerplate AnimeCollection +// - Set userName to a specific username to fetch and cache +func (ac *MockAnilistClientImpl) AnimeCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollection, error) { + + if userName == nil { + file, err := os.Open(test_utils.GetDataPath("BoilerplateAnimeCollection")) + defer file.Close() + + var ret *AnimeCollection + err = json.NewDecoder(file).Decode(&ret) + if err != nil { + log.Fatal(err) + } + + ac.logger.Trace().Msgf("MockAnilistClientImpl: Using [BoilerplateAnimeCollection]") + return ret, nil + } + + file, err := os.Open(test_utils.GetTestDataPath("AnimeCollection")) + defer file.Close() + if err != nil { + if os.IsNotExist(err) { + ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [AnimeCollection]: %s", *userName) + ret, err := ac.realAnilistClient.AnimeCollection(context.Background(), userName) + if err != nil { + return nil, err + } + data, err := json.Marshal(ret) + if err != nil { + log.Fatal(err) + } + err = os.WriteFile(test_utils.GetTestDataPath("AnimeCollection"), data, 0644) + if err != nil { + log.Fatal(err) + } + return ret, nil + } + } + + var ret *AnimeCollection + err = json.NewDecoder(file).Decode(&ret) + if err != nil { + log.Fatal(err) + } + + if ret == nil { + ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [AnimeCollection]: %s", *userName) + ret, err := ac.realAnilistClient.AnimeCollection(context.Background(), userName) + if err != nil { + return nil, err + } + data, err := json.Marshal(ret) + if err != nil { + log.Fatal(err) + } + err = os.WriteFile(test_utils.GetTestDataPath("AnimeCollection"), data, 0644) + if err != nil { + log.Fatal(err) + } + return ret, nil + } + + ac.logger.Trace().Msgf("MockAnilistClientImpl: CACHE HIT [AnimeCollection]: %s", *userName) + return ret, nil + +} + +func (ac *MockAnilistClientImpl) AnimeCollectionWithRelations(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollectionWithRelations, error) { + + if userName == nil { + file, err := os.Open(test_utils.GetDataPath("BoilerplateAnimeCollectionWithRelations")) + defer file.Close() + + var ret *AnimeCollectionWithRelations + err = json.NewDecoder(file).Decode(&ret) + if err != nil { + log.Fatal(err) + } + + ac.logger.Trace().Msgf("MockAnilistClientImpl: Using [BoilerplateAnimeCollectionWithRelations]") + return ret, nil + } + + file, err := os.Open(test_utils.GetTestDataPath("AnimeCollectionWithRelations")) + defer file.Close() + if err != nil { + if os.IsNotExist(err) { + ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [AnimeCollectionWithRelations]: %s", *userName) + ret, err := ac.realAnilistClient.AnimeCollectionWithRelations(context.Background(), userName) + if err != nil { + return nil, err + } + data, err := json.Marshal(ret) + if err != nil { + log.Fatal(err) + } + err = os.WriteFile(test_utils.GetTestDataPath("AnimeCollectionWithRelations"), data, 0644) + if err != nil { + log.Fatal(err) + } + return ret, nil + } + } + + var ret *AnimeCollectionWithRelations + err = json.NewDecoder(file).Decode(&ret) + if err != nil { + log.Fatal(err) + } + + if ret == nil { + ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [AnimeCollectionWithRelations]: %s", *userName) + ret, err := ac.realAnilistClient.AnimeCollectionWithRelations(context.Background(), userName) + if err != nil { + return nil, err + } + data, err := json.Marshal(ret) + if err != nil { + log.Fatal(err) + } + err = os.WriteFile(test_utils.GetTestDataPath("AnimeCollectionWithRelations"), data, 0644) + if err != nil { + log.Fatal(err) + } + return ret, nil + } + + ac.logger.Trace().Msgf("MockAnilistClientImpl: CACHE HIT [AnimeCollectionWithRelations]: %s", *userName) + return ret, nil + +} + +type TestModifyAnimeCollectionEntryInput struct { + Status *MediaListStatus + Progress *int + Score *float64 + AiredEpisodes *int + NextAiringEpisode *BaseAnime_NextAiringEpisode +} + +// TestModifyAnimeCollectionEntry will modify an entry in the fetched anime collection. +// This is used to fine-tune the anime collection for testing purposes. +// +// Example: Setting a specific progress in case the origin anime collection has no progress +func TestModifyAnimeCollectionEntry(ac *AnimeCollection, mId int, input TestModifyAnimeCollectionEntryInput) *AnimeCollection { + if ac == nil { + panic("AnimeCollection is nil") + } + + lists := ac.GetMediaListCollection().GetLists() + + removedFromList := false + var rEntry *AnimeCollection_MediaListCollection_Lists_Entries + + // Move the entry to the correct list + if input.Status != nil { + for _, list := range lists { + if list.Status == nil || list.Entries == nil { + continue + } + entries := list.GetEntries() + for idx, entry := range entries { + if entry.GetMedia().ID == mId { + // Remove from current list if status differs + if *list.Status != *input.Status { + removedFromList = true + rEntry = entry + // Ensure we're not going out of bounds + if idx >= 0 && idx < len(entries) { + // Safely remove the entry by re-slicing + list.Entries = append(entries[:idx], entries[idx+1:]...) + } + break + } + } + } + } + + // Add the entry to the correct list if it was removed + if removedFromList && rEntry != nil { + for _, list := range lists { + if list.Status == nil { + continue + } + if *list.Status == *input.Status { + if list.Entries == nil { + list.Entries = make([]*AnimeCollection_MediaListCollection_Lists_Entries, 0) + } + // Add the removed entry to the new list + list.Entries = append(list.Entries, rEntry) + break + } + } + } + } + + // Update the entry details +out: + for _, list := range lists { + entries := list.GetEntries() + for _, entry := range entries { + if entry.GetMedia().ID == mId { + if input.Status != nil { + entry.Status = input.Status + } + if input.Progress != nil { + entry.Progress = input.Progress + } + if input.Score != nil { + entry.Score = input.Score + } + if input.AiredEpisodes != nil { + entry.Media.Episodes = input.AiredEpisodes + } + if input.NextAiringEpisode != nil { + entry.Media.NextAiringEpisode = input.NextAiringEpisode + } + break out + } + } + } + + return ac +} + +func TestAddAnimeCollectionEntry(ac *AnimeCollection, mId int, input TestModifyAnimeCollectionEntryInput, realClient AnilistClient) *AnimeCollection { + if ac == nil { + panic("AnimeCollection is nil") + } + + // Fetch the anime details + baseAnime, err := realClient.BaseAnimeByID(context.Background(), &mId) + if err != nil { + log.Fatal(err) + } + anime := baseAnime.GetMedia() + + if input.NextAiringEpisode != nil { + anime.NextAiringEpisode = input.NextAiringEpisode + } + + if input.AiredEpisodes != nil { + anime.Episodes = input.AiredEpisodes + } + + lists := ac.GetMediaListCollection().GetLists() + + // Add the entry to the correct list + if input.Status != nil { + for _, list := range lists { + if list.Status == nil { + continue + } + if *list.Status == *input.Status { + if list.Entries == nil { + list.Entries = make([]*AnimeCollection_MediaListCollection_Lists_Entries, 0) + } + list.Entries = append(list.Entries, &AnimeCollection_MediaListCollection_Lists_Entries{ + Media: baseAnime.GetMedia(), + Status: input.Status, + Progress: input.Progress, + Score: input.Score, + }) + break + } + } + } + + return ac +} + +func TestAddAnimeCollectionWithRelationsEntry(ac *AnimeCollectionWithRelations, mId int, input TestModifyAnimeCollectionEntryInput, realClient AnilistClient) *AnimeCollectionWithRelations { + if ac == nil { + panic("AnimeCollection is nil") + } + + // Fetch the anime details + baseAnime, err := realClient.CompleteAnimeByID(context.Background(), &mId) + if err != nil { + log.Fatal(err) + } + anime := baseAnime.GetMedia() + + //if input.NextAiringEpisode != nil { + // anime.NextAiringEpisode = input.NextAiringEpisode + //} + + if input.AiredEpisodes != nil { + anime.Episodes = input.AiredEpisodes + } + + lists := ac.GetMediaListCollection().GetLists() + + // Add the entry to the correct list + if input.Status != nil { + for _, list := range lists { + if list.Status == nil { + continue + } + if *list.Status == *input.Status { + if list.Entries == nil { + list.Entries = make([]*AnimeCollectionWithRelations_MediaListCollection_Lists_Entries, 0) + } + list.Entries = append(list.Entries, &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{ + Media: baseAnime.GetMedia(), + Status: input.Status, + Progress: input.Progress, + Score: input.Score, + }) + break + } + } + } + + return ac +} + +// +// WILL NOT IMPLEMENT +// + +func (ac *MockAnilistClientImpl) UpdateMediaListEntry(ctx context.Context, mediaID *int, status *MediaListStatus, scoreRaw *int, progress *int, startedAt *FuzzyDateInput, completedAt *FuzzyDateInput, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntry, error) { + ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry") + return &UpdateMediaListEntry{}, nil +} + +func (ac *MockAnilistClientImpl) UpdateMediaListEntryProgress(ctx context.Context, mediaID *int, progress *int, status *MediaListStatus, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryProgress, error) { + ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry progress") + return &UpdateMediaListEntryProgress{}, nil +} + +func (ac *MockAnilistClientImpl) UpdateMediaListEntryRepeat(ctx context.Context, mediaID *int, repeat *int, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryRepeat, error) { + ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry repeat") + return &UpdateMediaListEntryRepeat{}, nil +} + +func (ac *MockAnilistClientImpl) DeleteEntry(ctx context.Context, mediaListEntryID *int, interceptors ...clientv2.RequestInterceptor) (*DeleteEntry, error) { + ac.logger.Debug().Int("entryId", *mediaListEntryID).Msg("anilist: Deleting media list entry") + return &DeleteEntry{}, nil +} + +func (ac *MockAnilistClientImpl) AnimeDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*AnimeDetailsByID, error) { + ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching anime details") + return ac.realAnilistClient.AnimeDetailsByID(ctx, id, interceptors...) +} + +func (ac *MockAnilistClientImpl) CompleteAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*CompleteAnimeByID, error) { + ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching complete media") + return ac.realAnilistClient.CompleteAnimeByID(ctx, id, interceptors...) +} + +func (ac *MockAnilistClientImpl) ListAnime(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, season *MediaSeason, seasonYear *int, format *MediaFormat, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListAnime, error) { + ac.logger.Debug().Msg("anilist: Fetching media list") + return ac.realAnilistClient.ListAnime(ctx, page, search, perPage, sort, status, genres, averageScoreGreater, season, seasonYear, format, isAdult, interceptors...) +} + +func (ac *MockAnilistClientImpl) ListRecentAnime(ctx context.Context, page *int, perPage *int, airingAtGreater *int, airingAtLesser *int, notYetAired *bool, interceptors ...clientv2.RequestInterceptor) (*ListRecentAnime, error) { + ac.logger.Debug().Msg("anilist: Fetching recent media list") + return ac.realAnilistClient.ListRecentAnime(ctx, page, perPage, airingAtGreater, airingAtLesser, notYetAired, interceptors...) +} + +func (ac *MockAnilistClientImpl) GetViewer(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*GetViewer, error) { + ac.logger.Debug().Msg("anilist: Fetching viewer") + return ac.realAnilistClient.GetViewer(ctx, interceptors...) +} + +func (ac *MockAnilistClientImpl) MangaCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*MangaCollection, error) { + ac.logger.Debug().Msg("anilist: Fetching manga collection") + return ac.realAnilistClient.MangaCollection(ctx, userName, interceptors...) +} + +func (ac *MockAnilistClientImpl) SearchBaseManga(ctx context.Context, page *int, perPage *int, sort []*MediaSort, search *string, status []*MediaStatus, interceptors ...clientv2.RequestInterceptor) (*SearchBaseManga, error) { + ac.logger.Debug().Msg("anilist: Searching manga") + return ac.realAnilistClient.SearchBaseManga(ctx, page, perPage, sort, search, status, interceptors...) +} + +func (ac *MockAnilistClientImpl) BaseMangaByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseMangaByID, error) { + ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching manga") + return ac.realAnilistClient.BaseMangaByID(ctx, id, interceptors...) +} + +func (ac *MockAnilistClientImpl) MangaDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*MangaDetailsByID, error) { + ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching manga details") + return ac.realAnilistClient.MangaDetailsByID(ctx, id, interceptors...) +} + +func (ac *MockAnilistClientImpl) ListManga(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, startDateGreater *string, startDateLesser *string, format *MediaFormat, countryOfOrigin *string, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListManga, error) { + ac.logger.Debug().Msg("anilist: Fetching manga list") + return ac.realAnilistClient.ListManga(ctx, page, search, perPage, sort, status, genres, averageScoreGreater, startDateGreater, startDateLesser, format, countryOfOrigin, isAdult, interceptors...) +} + +func (ac *MockAnilistClientImpl) StudioDetails(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*StudioDetails, error) { + ac.logger.Debug().Int("studioId", *id).Msg("anilist: Fetching studio details") + return ac.realAnilistClient.StudioDetails(ctx, id, interceptors...) +} + +func (ac *MockAnilistClientImpl) ViewerStats(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*ViewerStats, error) { + ac.logger.Debug().Msg("anilist: Fetching stats") + return ac.realAnilistClient.ViewerStats(ctx, interceptors...) +} + +func (ac *MockAnilistClientImpl) SearchBaseAnimeByIds(ctx context.Context, ids []*int, page *int, perPage *int, status []*MediaStatus, inCollection *bool, sort []*MediaSort, season *MediaSeason, year *int, genre *string, format *MediaFormat, interceptors ...clientv2.RequestInterceptor) (*SearchBaseAnimeByIds, error) { + ac.logger.Debug().Msg("anilist: Searching anime by ids") + return ac.realAnilistClient.SearchBaseAnimeByIds(ctx, ids, page, perPage, status, inCollection, sort, season, year, genre, format, interceptors...) +} + +func (ac *MockAnilistClientImpl) AnimeAiringSchedule(ctx context.Context, ids []*int, season *MediaSeason, seasonYear *int, previousSeason *MediaSeason, previousSeasonYear *int, nextSeason *MediaSeason, nextSeasonYear *int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringSchedule, error) { + ac.logger.Debug().Msg("anilist: Fetching schedule") + return ac.realAnilistClient.AnimeAiringSchedule(ctx, ids, season, seasonYear, previousSeason, previousSeasonYear, nextSeason, nextSeasonYear, interceptors...) +} + +func (ac *MockAnilistClientImpl) AnimeAiringScheduleRaw(ctx context.Context, ids []*int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringScheduleRaw, error) { + ac.logger.Debug().Msg("anilist: Fetching schedule") + return ac.realAnilistClient.AnimeAiringScheduleRaw(ctx, ids, interceptors...) +} diff --git a/seanime-2.9.10/internal/api/anilist/client_mock_test.go b/seanime-2.9.10/internal/api/anilist/client_mock_test.go new file mode 100644 index 0000000..6f5abef --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/client_mock_test.go @@ -0,0 +1,73 @@ +package anilist + +import ( + "context" + "github.com/goccy/go-json" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "os" + "seanime/internal/test_utils" + "testing" +) + +// USE CASE: Generate a boilerplate Anilist AnimeCollection for testing purposes and save it to 'test/data/BoilerplateAnimeCollection'. +// The generated AnimeCollection will have all entries in the 'Planning' status. +// The generated AnimeCollection will be used to test various Anilist API methods. +// You can use TestModifyAnimeCollectionEntry to modify the generated AnimeCollection before using it in a test. +// - DO NOT RUN IF YOU DON'T PLAN TO GENERATE A NEW 'test/data/BoilerplateAnimeCollection' +func TestGenerateBoilerplateAnimeCollection(t *testing.T) { + t.Skip("This test is not meant to be run") + test_utils.InitTestProvider(t, test_utils.Anilist()) + + anilistClient := TestGetMockAnilistClient() + + ac, err := anilistClient.AnimeCollection(context.Background(), &test_utils.ConfigData.Provider.AnilistUsername) + + if assert.NoError(t, err) { + + lists := ac.GetMediaListCollection().GetLists() + + entriesToAddToPlanning := make([]*AnimeListEntry, 0) + + if assert.NoError(t, err) { + + for _, list := range lists { + if list.Status != nil { + if list.GetStatus().String() != string(MediaListStatusPlanning) { + entries := list.GetEntries() + for _, entry := range entries { + entry.Progress = lo.ToPtr(0) + entry.Score = lo.ToPtr(0.0) + entry.Status = lo.ToPtr(MediaListStatusPlanning) + entriesToAddToPlanning = append(entriesToAddToPlanning, entry) + } + list.Entries = make([]*AnimeListEntry, 0) + } + } + } + + newLists := make([]*AnimeCollection_MediaListCollection_Lists, 0) + for _, list := range lists { + if list.Status == nil { + continue + } + if *list.GetStatus() == MediaListStatusPlanning { + list.Entries = append(list.Entries, entriesToAddToPlanning...) + newLists = append(newLists, list) + } else { + newLists = append(newLists, list) + } + } + + ac.MediaListCollection.Lists = newLists + + data, err := json.Marshal(ac) + if assert.NoError(t, err) { + err = os.WriteFile(test_utils.GetDataPath("BoilerplateAnimeCollection"), data, 0644) + assert.NoError(t, err) + } + } + + } + +} diff --git a/seanime-2.9.10/internal/api/anilist/client_test.go b/seanime-2.9.10/internal/api/anilist/client_test.go new file mode 100644 index 0000000..c5c7ada --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/client_test.go @@ -0,0 +1,237 @@ +package anilist + +import ( + "context" + "github.com/davecgh/go-spew/spew" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" +) + +//func TestHiddenFromStatus(t *testing.T) { +// test_utils.InitTestProvider(t, test_utils.Anilist()) +// +// token := test_utils.ConfigData.Provider.AnilistJwt +// logger := util.NewLogger() +// //anilistClient := NewAnilistClient(test_utils.ConfigData.Provider.AnilistJwt) +// +// variables := map[string]interface{}{} +// +// variables["userName"] = test_utils.ConfigData.Provider.AnilistUsername +// variables["type"] = "ANIME" +// +// requestBody, err := json.Marshal(map[string]interface{}{ +// "query": testQuery, +// "variables": variables, +// }) +// require.NoError(t, err) +// +// data, err := customQuery(requestBody, logger, token) +// require.NoError(t, err) +// +// var mediaLists []*MediaList +// +// type retData struct { +// Page Page +// PageInfo PageInfo +// } +// +// var ret retData +// m, err := json.Marshal(data) +// require.NoError(t, err) +// if err := json.Unmarshal(m, &ret); err != nil { +// t.Fatalf("Failed to unmarshal data: %v", err) +// } +// +// mediaLists = append(mediaLists, ret.Page.MediaList...) +// +// util.Spew(ret.Page.PageInfo) +// +// var currentPage = 1 +// var hasNextPage = false +// if ret.Page.PageInfo != nil && ret.Page.PageInfo.HasNextPage != nil { +// hasNextPage = *ret.Page.PageInfo.HasNextPage +// } +// for hasNextPage { +// currentPage++ +// variables["page"] = currentPage +// requestBody, err = json.Marshal(map[string]interface{}{ +// "query": testQuery, +// "variables": variables, +// }) +// require.NoError(t, err) +// data, err = customQuery(requestBody, logger, token) +// require.NoError(t, err) +// m, err = json.Marshal(data) +// require.NoError(t, err) +// if err := json.Unmarshal(m, &ret); err != nil { +// t.Fatalf("Failed to unmarshal data: %v", err) +// } +// util.Spew(ret.Page.PageInfo) +// if ret.Page.PageInfo != nil && ret.Page.PageInfo.HasNextPage != nil { +// hasNextPage = *ret.Page.PageInfo.HasNextPage +// } +// mediaLists = append(mediaLists, ret.Page.MediaList...) +// } +// +// //res, err := anilistClient.AnimeCollection(context.Background(), &test_utils.ConfigData.Provider.AnilistUsername) +// //assert.NoError(t, err) +// +// for _, mediaList := range mediaLists { +// util.Spew(mediaList.Media.ID) +// if mediaList.Media.ID == 151514 { +// util.Spew(mediaList) +// } +// } +// +//} +// +//const testQuery = `query ($page: Int, $userName: String, $type: MediaType) { +// Page (page: $page, perPage: 100) { +// pageInfo { +// hasNextPage +// total +// perPage +// currentPage +// lastPage +// } +// mediaList (type: $type, userName: $userName) { +// status +// startedAt { +// year +// month +// day +// } +// completedAt { +// year +// month +// day +// } +// repeat +// score(format: POINT_100) +// progress +// progressVolumes +// notes +// media { +// siteUrl +// id +// idMal +// episodes +// chapters +// volumes +// status +// averageScore +// coverImage{ +// large +// extraLarge +// } +// bannerImage +// title { +// userPreferred +// } +// } +// } +// } +// }` + +func TestGetAnimeById(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + anilistClient := TestGetMockAnilistClient() + + tests := []struct { + name string + mediaId int + }{ + { + name: "Cowboy Bebop", + mediaId: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := anilistClient.BaseAnimeByID(context.Background(), &tt.mediaId) + assert.NoError(t, err) + assert.NotNil(t, res) + }) + } +} + +func TestListAnime(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + tests := []struct { + name string + Page *int + Search *string + PerPage *int + Sort []*MediaSort + Status []*MediaStatus + Genres []*string + AverageScoreGreater *int + Season *MediaSeason + SeasonYear *int + Format *MediaFormat + IsAdult *bool + }{ + { + name: "Popular", + Page: lo.ToPtr(1), + Search: nil, + PerPage: lo.ToPtr(20), + Sort: []*MediaSort{lo.ToPtr(MediaSortTrendingDesc)}, + Status: nil, + Genres: nil, + AverageScoreGreater: nil, + Season: nil, + SeasonYear: nil, + Format: nil, + IsAdult: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + cacheKey := ListAnimeCacheKey( + tt.Page, + tt.Search, + tt.PerPage, + tt.Sort, + tt.Status, + tt.Genres, + tt.AverageScoreGreater, + tt.Season, + tt.SeasonYear, + tt.Format, + tt.IsAdult, + ) + + t.Log(cacheKey) + + res, err := ListAnimeM( + tt.Page, + tt.Search, + tt.PerPage, + tt.Sort, + tt.Status, + tt.Genres, + tt.AverageScoreGreater, + tt.Season, + tt.SeasonYear, + tt.Format, + tt.IsAdult, + util.NewLogger(), + "", + ) + assert.NoError(t, err) + + assert.Equal(t, *tt.PerPage, len(res.GetPage().GetMedia())) + + spew.Dump(res) + }) + } +} diff --git a/seanime-2.9.10/internal/api/anilist/collection_helper.go b/seanime-2.9.10/internal/api/anilist/collection_helper.go new file mode 100644 index 0000000..992fb57 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/collection_helper.go @@ -0,0 +1,248 @@ +package anilist + +import ( + "time" + + "github.com/goccy/go-json" +) + +type ( + AnimeListEntry = AnimeCollection_MediaListCollection_Lists_Entries + AnimeList = AnimeCollection_MediaListCollection_Lists + + EntryDate struct { + Year *int `json:"year,omitempty"` + Month *int `json:"month,omitempty"` + Day *int `json:"day,omitempty"` + } +) + +func (ac *AnimeCollection) GetListEntryFromAnimeId(id int) (*AnimeListEntry, bool) { + if ac == nil || ac.MediaListCollection == nil { + return nil, false + } + + var entry *AnimeCollection_MediaListCollection_Lists_Entries + for _, l := range ac.MediaListCollection.Lists { + if l.Entries == nil || len(l.Entries) == 0 { + continue + } + for _, e := range l.Entries { + if e.Media.ID == id { + entry = e + break + } + } + } + if entry == nil { + return nil, false + } + + return entry, true +} + +func (ac *AnimeCollection) GetAllAnime() []*BaseAnime { + if ac == nil { + return make([]*BaseAnime, 0) + } + + var ret []*BaseAnime + addedId := make(map[int]bool) + for _, l := range ac.MediaListCollection.Lists { + if l.Entries == nil || len(l.Entries) == 0 { + continue + } + for _, e := range l.Entries { + if _, ok := addedId[e.Media.ID]; !ok { + ret = append(ret, e.Media) + addedId[e.Media.ID] = true + } + } + } + return ret +} + +func (ac *AnimeCollection) FindAnime(mediaId int) (*BaseAnime, bool) { + if ac == nil { + return nil, false + } + + for _, l := range ac.MediaListCollection.Lists { + if l.Entries == nil || len(l.Entries) == 0 { + continue + } + for _, e := range l.Entries { + if e.Media.ID == mediaId { + return e.Media, true + } + } + } + return nil, false +} + +func (ac *AnimeCollectionWithRelations) GetListEntryFromMediaId(id int) (*AnimeCollectionWithRelations_MediaListCollection_Lists_Entries, bool) { + + if ac == nil || ac.MediaListCollection == nil { + return nil, false + } + + var entry *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries + for _, l := range ac.MediaListCollection.Lists { + if l.Entries == nil || len(l.Entries) == 0 { + continue + } + for _, e := range l.Entries { + if e.Media.ID == id { + entry = e + break + } + } + } + if entry == nil { + return nil, false + } + + return entry, true +} + +func (ac *AnimeCollectionWithRelations) GetAllAnime() []*CompleteAnime { + + var ret []*CompleteAnime + addedId := make(map[int]bool) + for _, l := range ac.MediaListCollection.Lists { + if l.Entries == nil || len(l.Entries) == 0 { + continue + } + for _, e := range l.Entries { + if _, ok := addedId[e.Media.ID]; !ok { + ret = append(ret, e.Media) + addedId[e.Media.ID] = true + } + } + } + return ret +} + +func (ac *AnimeCollectionWithRelations) FindAnime(mediaId int) (*CompleteAnime, bool) { + for _, l := range ac.MediaListCollection.Lists { + if l.Entries == nil || len(l.Entries) == 0 { + continue + } + for _, e := range l.Entries { + if e.Media.ID == mediaId { + return e.Media, true + } + } + } + return nil, false +} + +type IFuzzyDate interface { + GetYear() *int + GetMonth() *int + GetDay() *int +} + +func FuzzyDateToString(d IFuzzyDate) string { + if d == nil { + return "" + } + return fuzzyDateToString(d.GetYear(), d.GetMonth(), d.GetDay()) +} + +func ToEntryStartDate(d *AnimeCollection_MediaListCollection_Lists_Entries_StartedAt) string { + if d == nil { + return "" + } + return fuzzyDateToString(d.GetYear(), d.GetMonth(), d.GetDay()) +} + +func ToEntryCompletionDate(d *AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt) string { + if d == nil { + return "" + } + return fuzzyDateToString(d.GetYear(), d.GetMonth(), d.GetDay()) +} + +func fuzzyDateToString(year *int, month *int, day *int) string { + _year := 0 + if year != nil { + _year = *year + } + if _year == 0 { + return "" + } + _month := 0 + if month != nil { + _month = *month + } + _day := 0 + if day != nil { + _day = *day + } + return time.Date(_year, time.Month(_month), _day, 0, 0, 0, 0, time.UTC).Format(time.RFC3339) +} + +// AddEntryToList adds an entry to the appropriate list based on the provided status. +// If no list exists with the given status, a new list is created. +func (mc *AnimeCollection_MediaListCollection) AddEntryToList(entry *AnimeCollection_MediaListCollection_Lists_Entries, status MediaListStatus) { + if mc == nil || entry == nil { + return + } + + // Initialize Lists slice if nil + if mc.Lists == nil { + mc.Lists = make([]*AnimeCollection_MediaListCollection_Lists, 0) + } + + // Find existing list with the target status + for _, list := range mc.Lists { + if list.Status != nil && *list.Status == status { + // Found the list, add the entry + if list.Entries == nil { + list.Entries = make([]*AnimeCollection_MediaListCollection_Lists_Entries, 0) + } + list.Entries = append(list.Entries, entry) + return + } + } + + // No list found with the target status, create a new one + newList := &AnimeCollection_MediaListCollection_Lists{ + Status: &status, + Entries: []*AnimeCollection_MediaListCollection_Lists_Entries{entry}, + } + mc.Lists = append(mc.Lists, newList) +} + +func (ac *AnimeCollection) Copy() *AnimeCollection { + if ac == nil { + return nil + } + marshaled, err := json.Marshal(ac) + if err != nil { + return nil + } + var copy AnimeCollection + err = json.Unmarshal(marshaled, ©) + if err != nil { + return nil + } + return © +} + +func (ac *AnimeList) CopyT() *AnimeCollection_MediaListCollection_Lists { + if ac == nil { + return nil + } + marshaled, err := json.Marshal(ac) + if err != nil { + return nil + } + var copy AnimeCollection_MediaListCollection_Lists + err = json.Unmarshal(marshaled, ©) + if err != nil { + return nil + } + return © +} diff --git a/seanime-2.9.10/internal/api/anilist/compound_query.go b/seanime-2.9.10/internal/api/anilist/compound_query.go new file mode 100644 index 0000000..dfe4207 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/compound_query.go @@ -0,0 +1,115 @@ +package anilist + +import ( + "fmt" + "github.com/goccy/go-json" + "seanime/internal/util" + "strconv" +) + +func FetchBaseAnimeMap(ids []int) (ret map[int]*BaseAnime, err error) { + + query := fmt.Sprintf(CompoundBaseAnimeDocument, newCompoundQuery(ids)) + + requestBody, err := json.Marshal(map[string]interface{}{ + "query": query, + "variables": nil, + }) + if err != nil { + return nil, err + } + + data, err := customQuery(requestBody, util.NewLogger()) + if err != nil { + return nil, err + } + + var res map[string]*BaseAnime + + dataB, err := json.Marshal(data) + if err != nil { + return nil, err + } + + err = json.Unmarshal(dataB, &res) + if err != nil { + return nil, err + } + + ret = make(map[int]*BaseAnime) + for k, v := range res { + id, err := strconv.Atoi(k[1:]) + if err != nil { + return nil, err + } + ret[id] = v + } + + return ret, nil +} + +func newCompoundQuery(ids []int) string { + var query string + for _, id := range ids { + query += fmt.Sprintf(` + t%d: Media(id: %d) { + ...baseAnime + } + `, id, id) + } + return query +} + +const CompoundBaseAnimeDocument = `query CompoundQueryTest { +%s +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +}` diff --git a/seanime-2.9.10/internal/api/anilist/compound_query_test.go b/seanime-2.9.10/internal/api/anilist/compound_query_test.go new file mode 100644 index 0000000..bf4245d --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/compound_query_test.go @@ -0,0 +1,95 @@ +package anilist + +import ( + "fmt" + "github.com/davecgh/go-spew/spew" + "github.com/goccy/go-json" + "github.com/stretchr/testify/require" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" +) + +func TestCompoundQuery(t *testing.T) { + test_utils.InitTestProvider(t) + + var ids = []int{171457, 21} + + query := fmt.Sprintf(compoundQueryFormatTest, newCompoundQuery(ids)) + + t.Log(query) + + requestBody, err := json.Marshal(map[string]interface{}{ + "query": query, + "variables": nil, + }) + require.NoError(t, err) + + data, err := customQuery(requestBody, util.NewLogger()) + require.NoError(t, err) + + var res map[string]*BaseAnime + + dataB, err := json.Marshal(data) + require.NoError(t, err) + + err = json.Unmarshal(dataB, &res) + require.NoError(t, err) + + spew.Dump(res) + +} + +const compoundQueryFormatTest = `query CompoundQueryTest { +%s +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +}` diff --git a/seanime-2.9.10/internal/api/anilist/custom_query.go b/seanime-2.9.10/internal/api/anilist/custom_query.go new file mode 100644 index 0000000..9946ec3 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/custom_query.go @@ -0,0 +1,140 @@ +package anilist + +import ( + "bytes" + "compress/gzip" + "errors" + "fmt" + "net/http" + "seanime/internal/util" + "strconv" + "time" + + "github.com/goccy/go-json" + "github.com/rs/zerolog" +) + +func CustomQuery(body map[string]interface{}, logger *zerolog.Logger, token string) (data interface{}, err error) { + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, err + } + return customQuery(bodyBytes, logger, token) +} + +func customQuery(body []byte, logger *zerolog.Logger, token ...string) (data interface{}, err error) { + + var rlRemainingStr string + + reqTime := time.Now() + defer func() { + timeSince := time.Since(reqTime) + formattedDur := timeSince.Truncate(time.Millisecond).String() + if err != nil { + logger.Error().Str("duration", formattedDur).Str("rlr", rlRemainingStr).Err(err).Msg("anilist: Failed Request") + } else { + if timeSince > 600*time.Millisecond { + logger.Warn().Str("rtt", formattedDur).Str("rlr", rlRemainingStr).Msg("anilist: Long Request") + } else { + logger.Trace().Str("rtt", formattedDur).Str("rlr", rlRemainingStr).Msg("anilist: Successful Request") + } + } + }() + + defer util.HandlePanicInModuleThen("api/anilist/custom_query", func() { + err = errors.New("panic in customQuery") + }) + + client := http.DefaultClient + + var req *http.Request + req, err = http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if len(token) > 0 && token[0] != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token[0])) + } + + // Send request + retryCount := 2 + + var resp *http.Response + for i := 0; i < retryCount; i++ { + + // Reset response body for retry + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + + // Recreate the request body if it was read in a previous attempt + if req.GetBody != nil { + newBody, err := req.GetBody() + if err != nil { + return nil, fmt.Errorf("failed to get request body: %w", err) + } + req.Body = newBody + } + + resp, err = client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + rlRemainingStr = resp.Header.Get("X-Ratelimit-Remaining") + rlRetryAfterStr := resp.Header.Get("Retry-After") + rlRetryAfter, err := strconv.Atoi(rlRetryAfterStr) + if err == nil { + logger.Warn().Msgf("anilist: Rate limited, retrying in %d seconds", rlRetryAfter+1) + select { + case <-time.After(time.Duration(rlRetryAfter+1) * time.Second): + continue + } + } + + if rlRemainingStr == "" { + select { + case <-time.After(5 * time.Second): + continue + } + } + + break + } + + defer resp.Body.Close() + + if resp.Header.Get("Content-Encoding") == "gzip" { + resp.Body, err = gzip.NewReader(resp.Body) + if err != nil { + return nil, fmt.Errorf("gzip decode failed: %w", err) + } + } + + var res interface{} + err = json.NewDecoder(resp.Body).Decode(&res) + if err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + var ok bool + + reqErrors, ok := res.(map[string]interface{})["errors"].([]interface{}) + + if ok && len(reqErrors) > 0 { + firstError, foundErr := reqErrors[0].(map[string]interface{}) + if foundErr { + return nil, errors.New(firstError["message"].(string)) + } + } + + data, ok = res.(map[string]interface{})["data"] + if !ok { + return nil, errors.New("failed to parse data") + } + + return data, nil +} diff --git a/seanime-2.9.10/internal/api/anilist/date_test.go b/seanime-2.9.10/internal/api/anilist/date_test.go new file mode 100644 index 0000000..f4ea5a1 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/date_test.go @@ -0,0 +1,27 @@ +package anilist + +//import ( + +//) +// +//func TestFuzzyDate(t *testing.T) { +// +// date := "2006-01-02T15:04:05Z" +// +// parsedDate, err := time.Parse(time.RFC3339, date) +// if err != nil { +// t.Fatal(err) +// } +// +// year := parsedDate.Year() +// month := int(parsedDate.Month()) +// day := parsedDate.Day() +// t.Logf("Year: %d, Month: %d, Day: %d", year, month, day) +// +//} +// +//func TestDateTransformation(t *testing.T) { +// +// t.Logf(time.Date(2024, time.Month(1), 1, 0, 0, 0, 0, time.Local).UTC().Format(time.RFC3339)) +// +//} diff --git a/seanime-2.9.10/internal/api/anilist/entries.go b/seanime-2.9.10/internal/api/anilist/entries.go new file mode 100644 index 0000000..a888651 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/entries.go @@ -0,0 +1,50 @@ +package anilist + +import ( + "context" + "errors" + "github.com/rs/zerolog" + "seanime/internal/util/limiter" + "sync" +) + +func (c *Client) AddMediaToPlanning(mIds []int, rateLimiter *limiter.Limiter, logger *zerolog.Logger) error { + if len(mIds) == 0 { + logger.Debug().Msg("anilist: No media added to planning list") + return nil + } + if rateLimiter == nil { + return errors.New("anilist: no rate limiter provided") + } + + status := MediaListStatusPlanning + + scoreRaw := 0 + progress := 0 + + wg := sync.WaitGroup{} + for _, _id := range mIds { + wg.Add(1) + go func(id int) { + rateLimiter.Wait() + defer wg.Done() + _, err := c.UpdateMediaListEntry( + context.Background(), + &id, + &status, + &scoreRaw, + &progress, + nil, + nil, + ) + if err != nil { + logger.Error().Msg("anilist: An error occurred while adding media to planning list: " + err.Error()) + } + }(_id) + } + wg.Wait() + + logger.Debug().Any("count", len(mIds)).Msg("anilist: Media added to planning list") + + return nil +} diff --git a/seanime-2.9.10/internal/api/anilist/hook_events.go b/seanime-2.9.10/internal/api/anilist/hook_events.go new file mode 100644 index 0000000..0cd0a21 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/hook_events.go @@ -0,0 +1,19 @@ +package anilist + +import "seanime/internal/hook_resolver" + +// ListMissedSequelsRequestedEvent is triggered when the list missed sequels request is requested. +// Prevent default to skip the default behavior and return your own data. +type ListMissedSequelsRequestedEvent struct { + hook_resolver.Event + AnimeCollectionWithRelations *AnimeCollectionWithRelations `json:"animeCollectionWithRelations"` + Variables map[string]interface{} `json:"variables"` + Query string `json:"query"` + // Empty data object, will be used if the hook prevents the default behavior + List []*BaseAnime `json:"list"` +} + +type ListMissedSequelsEvent struct { + hook_resolver.Event + List []*BaseAnime `json:"list"` +} diff --git a/seanime-2.9.10/internal/api/anilist/list.go b/seanime-2.9.10/internal/api/anilist/list.go new file mode 100644 index 0000000..6b24b04 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/list.go @@ -0,0 +1,529 @@ +package anilist + +import ( + "fmt" + "seanime/internal/hook" + + "github.com/goccy/go-json" + "github.com/rs/zerolog" + "github.com/samber/lo" +) + +func ListMissedSequels( + animeCollectionWithRelations *AnimeCollectionWithRelations, + logger *zerolog.Logger, + token string, +) (ret []*BaseAnime, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic: %v", r) + } + }() + + variables := map[string]interface{}{} + variables["page"] = 1 + variables["perPage"] = 50 + + ids := make(map[int]struct{}) + for _, list := range animeCollectionWithRelations.GetMediaListCollection().GetLists() { + if list.Status == nil || !(*list.Status == MediaListStatusCompleted || *list.Status == MediaListStatusRepeating || *list.Status == MediaListStatusPaused) || list.Entries == nil { + continue + } + for _, entry := range list.Entries { + if _, ok := ids[entry.GetMedia().GetID()]; !ok { + edges := entry.GetMedia().GetRelations().GetEdges() + var sequel *BaseAnime + for _, edge := range edges { + if edge.GetRelationType() != nil && *edge.GetRelationType() == MediaRelationSequel { + sequel = edge.GetNode() + break + } + } + + if sequel == nil { + continue + } + + // Check if sequel is already in the list + _, found := animeCollectionWithRelations.FindAnime(sequel.GetID()) + if found { + continue + } + + if *sequel.GetStatus() == MediaStatusFinished || *sequel.GetStatus() == MediaStatusReleasing { + ids[sequel.GetID()] = struct{}{} + } + } + + } + } + + idsSlice := make([]int, 0, len(ids)) + for id := range ids { + idsSlice = append(idsSlice, id) + } + + if len(idsSlice) == 0 { + return []*BaseAnime{}, nil + } + + if len(idsSlice) > 10 { + idsSlice = idsSlice[:10] + } + + variables["ids"] = idsSlice + variables["inCollection"] = false + variables["sort"] = MediaSortStartDateDesc + + // Event + reqEvent := &ListMissedSequelsRequestedEvent{ + AnimeCollectionWithRelations: animeCollectionWithRelations, + Variables: variables, + List: make([]*BaseAnime, 0), + Query: SearchBaseAnimeByIdsDocument, + } + err = hook.GlobalHookManager.OnListMissedSequelsRequested().Trigger(reqEvent) + if err != nil { + return nil, err + } + + // If the hook prevented the default behavior, return the data + if reqEvent.DefaultPrevented { + return reqEvent.List, nil + } + + requestBody, err := json.Marshal(map[string]interface{}{ + "query": reqEvent.Query, + "variables": reqEvent.Variables, + }) + if err != nil { + return nil, err + } + + data, err := customQuery(requestBody, logger, token) + if err != nil { + return nil, err + } + + m, err := json.Marshal(data) + if err != nil { + return nil, err + } + var searchRes *SearchBaseAnimeByIds + if err := json.Unmarshal(m, &searchRes); err != nil { + return nil, err + } + + if searchRes == nil || searchRes.Page == nil || searchRes.Page.Media == nil { + return nil, fmt.Errorf("no data found") + } + + // Event + event := &ListMissedSequelsEvent{ + List: searchRes.Page.Media, + } + err = hook.GlobalHookManager.OnListMissedSequels().Trigger(event) + if err != nil { + return nil, err + } + + return event.List, nil +} + +func ListAnimeM( + Page *int, + Search *string, + PerPage *int, + Sort []*MediaSort, + Status []*MediaStatus, + Genres []*string, + AverageScoreGreater *int, + Season *MediaSeason, + SeasonYear *int, + Format *MediaFormat, + IsAdult *bool, + logger *zerolog.Logger, + token string, +) (*ListAnime, error) { + + variables := map[string]interface{}{} + if Page != nil { + variables["page"] = *Page + } + if Search != nil { + variables["search"] = *Search + } + if PerPage != nil { + variables["perPage"] = *PerPage + } + if Sort != nil { + variables["sort"] = Sort + } + if Status != nil { + variables["status"] = Status + } + if Genres != nil { + variables["genres"] = Genres + } + if AverageScoreGreater != nil { + variables["averageScore_greater"] = *AverageScoreGreater + } + if Season != nil { + variables["season"] = *Season + } + if SeasonYear != nil { + variables["seasonYear"] = *SeasonYear + } + if Format != nil { + variables["format"] = *Format + } + if IsAdult != nil { + variables["isAdult"] = *IsAdult + } + + requestBody, err := json.Marshal(map[string]interface{}{ + "query": ListAnimeDocument, + "variables": variables, + }) + if err != nil { + return nil, err + } + + data, err := customQuery(requestBody, logger, token) + if err != nil { + return nil, err + } + + var listMediaF ListAnime + m, err := json.Marshal(data) + if err != nil { + return nil, err + } + if err := json.Unmarshal(m, &listMediaF); err != nil { + return nil, err + } + + return &listMediaF, nil +} + +func ListMangaM( + Page *int, + Search *string, + PerPage *int, + Sort []*MediaSort, + Status []*MediaStatus, + Genres []*string, + AverageScoreGreater *int, + Year *int, + Format *MediaFormat, + CountryOfOrigin *string, + IsAdult *bool, + logger *zerolog.Logger, + token string, +) (*ListManga, error) { + + variables := map[string]interface{}{} + if Page != nil { + variables["page"] = *Page + } + if Search != nil { + variables["search"] = *Search + } + if PerPage != nil { + variables["perPage"] = *PerPage + } + if Sort != nil { + variables["sort"] = Sort + } + if Status != nil { + variables["status"] = Status + } + if Genres != nil { + variables["genres"] = Genres + } + if AverageScoreGreater != nil { + variables["averageScore_greater"] = *AverageScoreGreater * 10 + } + if Year != nil { + variables["startDate_greater"] = lo.ToPtr(fmt.Sprintf("%d0000", *Year)) + variables["startDate_lesser"] = lo.ToPtr(fmt.Sprintf("%d0000", *Year+1)) + } + if Format != nil { + variables["format"] = *Format + } + if CountryOfOrigin != nil { + variables["countryOfOrigin"] = *CountryOfOrigin + } + if IsAdult != nil { + variables["isAdult"] = *IsAdult + } + + requestBody, err := json.Marshal(map[string]interface{}{ + "query": ListMangaDocument, + "variables": variables, + }) + if err != nil { + return nil, err + } + + data, err := customQuery(requestBody, logger, token) + if err != nil { + return nil, err + } + + var listMediaF ListManga + m, err := json.Marshal(data) + if err != nil { + return nil, err + } + if err := json.Unmarshal(m, &listMediaF); err != nil { + return nil, err + } + + return &listMediaF, nil +} + +func ListRecentAiringAnimeM( + Page *int, + Search *string, + PerPage *int, + AiringAtGreater *int, + AiringAtLesser *int, + NotYetAired *bool, + Sort []*AiringSort, + logger *zerolog.Logger, + token string, +) (*ListRecentAnime, error) { + + variables := map[string]interface{}{} + if Page != nil { + variables["page"] = *Page + } + if Search != nil { + variables["search"] = *Search + } + if PerPage != nil { + variables["perPage"] = *PerPage + } + if AiringAtGreater != nil { + variables["airingAt_greater"] = *AiringAtGreater + } + if AiringAtLesser != nil { + variables["airingAt_lesser"] = *AiringAtLesser + } + if NotYetAired != nil { + variables["notYetAired"] = *NotYetAired + } + if Sort != nil { + variables["sort"] = Sort + } else { + variables["sort"] = []*AiringSort{lo.ToPtr(AiringSortTimeDesc)} + } + + requestBody, err := json.Marshal(map[string]interface{}{ + "query": ListRecentAiringAnimeQuery, + "variables": variables, + }) + if err != nil { + return nil, err + } + + data, err := customQuery(requestBody, logger, token) + if err != nil { + return nil, err + } + + var listMediaF ListRecentAnime + m, err := json.Marshal(data) + if err != nil { + return nil, err + } + if err := json.Unmarshal(m, &listMediaF); err != nil { + return nil, err + } + + return &listMediaF, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func ListAnimeCacheKey( + Page *int, + Search *string, + PerPage *int, + Sort []*MediaSort, + Status []*MediaStatus, + Genres []*string, + AverageScoreGreater *int, + Season *MediaSeason, + SeasonYear *int, + Format *MediaFormat, + IsAdult *bool, +) string { + + key := "ListAnime" + if Page != nil { + key += fmt.Sprintf("_%d", *Page) + } + if Search != nil { + key += fmt.Sprintf("_%s", *Search) + } + if PerPage != nil { + key += fmt.Sprintf("_%d", *PerPage) + } + if Sort != nil { + key += fmt.Sprintf("_%v", Sort) + } + if Status != nil { + key += fmt.Sprintf("_%v", Status) + } + if Genres != nil { + key += fmt.Sprintf("_%v", Genres) + } + if AverageScoreGreater != nil { + key += fmt.Sprintf("_%d", *AverageScoreGreater) + } + if Season != nil { + key += fmt.Sprintf("_%s", *Season) + } + if SeasonYear != nil { + key += fmt.Sprintf("_%d", *SeasonYear) + } + if Format != nil { + key += fmt.Sprintf("_%s", *Format) + } + if IsAdult != nil { + key += fmt.Sprintf("_%t", *IsAdult) + } + + return key + +} +func ListMangaCacheKey( + Page *int, + Search *string, + PerPage *int, + Sort []*MediaSort, + Status []*MediaStatus, + Genres []*string, + AverageScoreGreater *int, + Season *MediaSeason, + SeasonYear *int, + Format *MediaFormat, + CountryOfOrigin *string, + IsAdult *bool, +) string { + + key := "ListAnime" + if Page != nil { + key += fmt.Sprintf("_%d", *Page) + } + if Search != nil { + key += fmt.Sprintf("_%s", *Search) + } + if PerPage != nil { + key += fmt.Sprintf("_%d", *PerPage) + } + if Sort != nil { + key += fmt.Sprintf("_%v", Sort) + } + if Status != nil { + key += fmt.Sprintf("_%v", Status) + } + if Genres != nil { + key += fmt.Sprintf("_%v", Genres) + } + if AverageScoreGreater != nil { + key += fmt.Sprintf("_%d", *AverageScoreGreater) + } + if Season != nil { + key += fmt.Sprintf("_%s", *Season) + } + if SeasonYear != nil { + key += fmt.Sprintf("_%d", *SeasonYear) + } + if Format != nil { + key += fmt.Sprintf("_%s", *Format) + } + if CountryOfOrigin != nil { + key += fmt.Sprintf("_%s", *CountryOfOrigin) + } + if IsAdult != nil { + key += fmt.Sprintf("_%t", *IsAdult) + } + + return key + +} + +const ListRecentAiringAnimeQuery = `query ListRecentAnime ($page: Int, $perPage: Int, $airingAt_greater: Int, $airingAt_lesser: Int, $sort: [AiringSort], $notYetAired: Boolean = false) { + Page(page: $page, perPage: $perPage) { + pageInfo { + hasNextPage + total + perPage + currentPage + lastPage + } + airingSchedules(notYetAired: $notYetAired, sort: $sort, airingAt_greater: $airingAt_greater, airingAt_lesser: $airingAt_lesser) { + id + airingAt + episode + timeUntilAiring + media { + ... baseAnime + } + } + } +} +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} + ` diff --git a/seanime-2.9.10/internal/api/anilist/manga.go b/seanime-2.9.10/internal/api/anilist/manga.go new file mode 100644 index 0000000..547dc19 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/manga.go @@ -0,0 +1,123 @@ +package anilist + +type MangaList = MangaCollection_MediaListCollection_Lists +type MangaListEntry = MangaCollection_MediaListCollection_Lists_Entries + +func (ac *MangaCollection) GetListEntryFromMangaId(id int) (*MangaListEntry, bool) { + + if ac == nil || ac.MediaListCollection == nil { + return nil, false + } + + var entry *MangaCollection_MediaListCollection_Lists_Entries + for _, l := range ac.MediaListCollection.Lists { + if l.Entries == nil || len(l.Entries) == 0 { + continue + } + for _, e := range l.Entries { + if e.Media.ID == id { + entry = e + break + } + } + } + if entry == nil { + return nil, false + } + + return entry, true +} + +func (m *BaseManga) GetTitleSafe() string { + if m.GetTitle().GetEnglish() != nil { + return *m.GetTitle().GetEnglish() + } + if m.GetTitle().GetRomaji() != nil { + return *m.GetTitle().GetRomaji() + } + return "N/A" +} +func (m *BaseManga) GetRomajiTitleSafe() string { + if m.GetTitle().GetRomaji() != nil { + return *m.GetTitle().GetRomaji() + } + if m.GetTitle().GetEnglish() != nil { + return *m.GetTitle().GetEnglish() + } + return "N/A" +} + +func (m *BaseManga) GetPreferredTitle() string { + if m.GetTitle().GetUserPreferred() != nil { + return *m.GetTitle().GetUserPreferred() + } + return m.GetTitleSafe() +} + +func (m *BaseManga) GetCoverImageSafe() string { + if m.GetCoverImage().GetExtraLarge() != nil { + return *m.GetCoverImage().GetExtraLarge() + } + if m.GetCoverImage().GetLarge() != nil { + return *m.GetCoverImage().GetLarge() + } + if m.GetBannerImage() != nil { + return *m.GetBannerImage() + } + return "" +} +func (m *BaseManga) GetBannerImageSafe() string { + if m.GetBannerImage() != nil { + return *m.GetBannerImage() + } + return m.GetCoverImageSafe() +} + +func (m *BaseManga) GetAllTitles() []*string { + titles := make([]*string, 0) + if m.HasRomajiTitle() { + titles = append(titles, m.Title.Romaji) + } + if m.HasEnglishTitle() { + titles = append(titles, m.Title.English) + } + if m.HasSynonyms() && len(m.Synonyms) > 1 { + titles = append(titles, m.Synonyms...) + } + return titles +} + +func (m *BaseManga) GetMainTitlesDeref() []string { + titles := make([]string, 0) + if m.HasRomajiTitle() { + titles = append(titles, *m.Title.Romaji) + } + if m.HasEnglishTitle() { + titles = append(titles, *m.Title.English) + } + return titles +} + +func (m *BaseManga) HasEnglishTitle() bool { + return m.Title.English != nil +} +func (m *BaseManga) HasRomajiTitle() bool { + return m.Title.Romaji != nil +} +func (m *BaseManga) HasSynonyms() bool { + return m.Synonyms != nil +} + +func (m *BaseManga) GetStartYearSafe() int { + if m.GetStartDate() != nil && m.GetStartDate().GetYear() != nil { + return *m.GetStartDate().GetYear() + } + return 0 +} + +func (m *MangaListEntry) GetRepeatSafe() int { + if m.Repeat == nil { + return 0 + } + return *m.Repeat +} diff --git a/seanime-2.9.10/internal/api/anilist/media.go b/seanime-2.9.10/internal/api/anilist/media.go new file mode 100644 index 0000000..0de478a --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/media.go @@ -0,0 +1,25 @@ +package anilist + +import ( + "seanime/internal/util/result" +) + +type BaseAnimeCache struct { + *result.Cache[int, *BaseAnime] +} + +// NewBaseAnimeCache returns a new result.Cache[int, *BaseAnime]. +// It is used to temporarily store the results of FetchMediaTree calls. +func NewBaseAnimeCache() *BaseAnimeCache { + return &BaseAnimeCache{result.NewCache[int, *BaseAnime]()} +} + +type CompleteAnimeCache struct { + *result.Cache[int, *CompleteAnime] +} + +// NewCompleteAnimeCache returns a new result.Cache[int, *CompleteAnime]. +// It is used to temporarily store the results of FetchMediaTree calls. +func NewCompleteAnimeCache() *CompleteAnimeCache { + return &CompleteAnimeCache{result.NewCache[int, *CompleteAnime]()} +} diff --git a/seanime-2.9.10/internal/api/anilist/media_helper.go b/seanime-2.9.10/internal/api/anilist/media_helper.go new file mode 100644 index 0000000..72c798d --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/media_helper.go @@ -0,0 +1,574 @@ +package anilist + +import ( + "seanime/internal/util/comparison" + + "github.com/samber/lo" +) + +func (m *BaseAnime) GetTitleSafe() string { + if m.GetTitle().GetEnglish() != nil { + return *m.GetTitle().GetEnglish() + } + if m.GetTitle().GetRomaji() != nil { + return *m.GetTitle().GetRomaji() + } + return "" +} + +func (m *BaseAnime) GetEnglishTitleSafe() string { + if m.GetTitle().GetEnglish() != nil { + return *m.GetTitle().GetEnglish() + } + return "" +} + +func (m *BaseAnime) GetRomajiTitleSafe() string { + if m.GetTitle().GetRomaji() != nil { + return *m.GetTitle().GetRomaji() + } + if m.GetTitle().GetEnglish() != nil { + return *m.GetTitle().GetEnglish() + } + return "" +} + +func (m *BaseAnime) GetPreferredTitle() string { + if m.GetTitle().GetUserPreferred() != nil { + return *m.GetTitle().GetUserPreferred() + } + return m.GetTitleSafe() +} + +func (m *BaseAnime) GetCoverImageSafe() string { + if m.GetCoverImage().GetExtraLarge() != nil { + return *m.GetCoverImage().GetExtraLarge() + } + if m.GetCoverImage().GetLarge() != nil { + return *m.GetCoverImage().GetLarge() + } + if m.GetBannerImage() != nil { + return *m.GetBannerImage() + } + return "" +} + +func (m *BaseAnime) GetBannerImageSafe() string { + if m.GetBannerImage() != nil { + return *m.GetBannerImage() + } + return m.GetCoverImageSafe() +} + +func (m *BaseAnime) IsMovieOrSingleEpisode() bool { + if m == nil { + return false + } + if m.GetTotalEpisodeCount() == 1 { + return true + } + return false +} + +func (m *BaseAnime) GetSynonymsDeref() []string { + if m.Synonyms == nil { + return nil + } + return lo.Map(m.Synonyms, func(s *string, i int) string { return *s }) +} + +func (m *BaseAnime) GetSynonymsContainingSeason() []string { + if m.Synonyms == nil { + return nil + } + return lo.Filter(lo.Map(m.Synonyms, func(s *string, i int) string { return *s }), func(s string, i int) bool { return comparison.ValueContainsSeason(s) }) +} + +func (m *BaseAnime) GetStartYearSafe() int { + if m == nil || m.StartDate == nil || m.StartDate.Year == nil { + return 0 + } + return *m.StartDate.Year +} + +func (m *BaseAnime) IsMovie() bool { + if m == nil { + return false + } + if m.Format == nil { + return false + } + + return *m.Format == MediaFormatMovie +} + +func (m *BaseAnime) IsFinished() bool { + if m == nil { + return false + } + if m.Status == nil { + return false + } + + return *m.Status == MediaStatusFinished +} + +func (m *BaseAnime) GetAllTitles() []*string { + titles := make([]*string, 0) + if m.HasRomajiTitle() { + titles = append(titles, m.Title.Romaji) + } + if m.HasEnglishTitle() { + titles = append(titles, m.Title.English) + } + if m.HasSynonyms() && len(m.Synonyms) > 1 { + titles = append(titles, lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) })...) + } + return titles +} + +func (m *BaseAnime) GetAllTitlesDeref() []string { + titles := make([]string, 0) + if m.HasRomajiTitle() { + titles = append(titles, *m.Title.Romaji) + } + if m.HasEnglishTitle() { + titles = append(titles, *m.Title.English) + } + if m.HasSynonyms() && len(m.Synonyms) > 1 { + syn := lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) }) + for _, s := range syn { + titles = append(titles, *s) + } + } + return titles +} + +func (m *BaseAnime) GetMainTitles() []*string { + titles := make([]*string, 0) + if m.HasRomajiTitle() { + titles = append(titles, m.Title.Romaji) + } + if m.HasEnglishTitle() { + titles = append(titles, m.Title.English) + } + return titles +} + +func (m *BaseAnime) GetMainTitlesDeref() []string { + titles := make([]string, 0) + if m.HasRomajiTitle() { + titles = append(titles, *m.Title.Romaji) + } + if m.HasEnglishTitle() { + titles = append(titles, *m.Title.English) + } + return titles +} + +// GetCurrentEpisodeCount returns the current episode number for that media and -1 if it doesn't have one. +// i.e. -1 is returned if the media has no episodes AND the next airing episode is not set. +func (m *BaseAnime) GetCurrentEpisodeCount() int { + ceil := -1 + if m.Episodes != nil { + ceil = *m.Episodes + } + if m.NextAiringEpisode != nil { + if m.NextAiringEpisode.Episode > 0 { + ceil = m.NextAiringEpisode.Episode - 1 + } + } + + return ceil +} +func (m *BaseAnime) GetCurrentEpisodeCountOrNil() *int { + n := m.GetCurrentEpisodeCount() + if n == -1 { + return nil + } + return &n +} + +// GetTotalEpisodeCount returns the total episode number for that media and -1 if it doesn't have one +func (m *BaseAnime) GetTotalEpisodeCount() int { + ceil := -1 + if m.Episodes != nil { + ceil = *m.Episodes + } + return ceil +} + +// GetTotalEpisodeCount returns the total episode number for that media and -1 if it doesn't have one +func (m *BaseAnime) GetTotalEpisodeCountOrNil() *int { + return m.Episodes +} + +// GetPossibleSeasonNumber returns the possible season number for that media and -1 if it doesn't have one. +// It looks at the synonyms and returns the highest season number found. +func (m *BaseAnime) GetPossibleSeasonNumber() int { + if m == nil || m.Synonyms == nil || len(m.Synonyms) == 0 { + return -1 + } + titles := lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) }) + if m.HasEnglishTitle() { + titles = append(titles, m.Title.English) + } + if m.HasRomajiTitle() { + titles = append(titles, m.Title.Romaji) + } + seasons := lo.Map(titles, func(s *string, i int) int { return comparison.ExtractSeasonNumber(*s) }) + return lo.Max(seasons) +} + +func (m *BaseAnime) HasEnglishTitle() bool { + return m.Title.English != nil +} + +func (m *BaseAnime) HasRomajiTitle() bool { + return m.Title.Romaji != nil +} + +func (m *BaseAnime) HasSynonyms() bool { + return m.Synonyms != nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *CompleteAnime) GetTitleSafe() string { + if m.GetTitle().GetEnglish() != nil { + return *m.GetTitle().GetEnglish() + } + if m.GetTitle().GetRomaji() != nil { + return *m.GetTitle().GetRomaji() + } + return "N/A" +} +func (m *CompleteAnime) GetRomajiTitleSafe() string { + if m.GetTitle().GetRomaji() != nil { + return *m.GetTitle().GetRomaji() + } + if m.GetTitle().GetEnglish() != nil { + return *m.GetTitle().GetEnglish() + } + return "N/A" +} + +func (m *CompleteAnime) GetPreferredTitle() string { + if m.GetTitle().GetUserPreferred() != nil { + return *m.GetTitle().GetUserPreferred() + } + return m.GetTitleSafe() +} + +func (m *CompleteAnime) GetCoverImageSafe() string { + if m.GetCoverImage().GetExtraLarge() != nil { + return *m.GetCoverImage().GetExtraLarge() + } + if m.GetCoverImage().GetLarge() != nil { + return *m.GetCoverImage().GetLarge() + } + if m.GetBannerImage() != nil { + return *m.GetBannerImage() + } + return "" +} + +func (m *CompleteAnime) GetBannerImageSafe() string { + if m.GetBannerImage() != nil { + return *m.GetBannerImage() + } + return m.GetCoverImageSafe() +} + +func (m *CompleteAnime) IsMovieOrSingleEpisode() bool { + if m == nil { + return false + } + if m.GetTotalEpisodeCount() == 1 { + return true + } + return false +} + +func (m *CompleteAnime) IsMovie() bool { + if m == nil { + return false + } + if m.Format == nil { + return false + } + + return *m.Format == MediaFormatMovie +} + +func (m *CompleteAnime) IsFinished() bool { + if m == nil { + return false + } + if m.Status == nil { + return false + } + + return *m.Status == MediaStatusFinished +} + +func (m *CompleteAnime) GetAllTitles() []*string { + titles := make([]*string, 0) + if m.HasRomajiTitle() { + titles = append(titles, m.Title.Romaji) + } + if m.HasEnglishTitle() { + titles = append(titles, m.Title.English) + } + if m.HasSynonyms() && len(m.Synonyms) > 1 { + titles = append(titles, lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) })...) + } + return titles +} + +func (m *CompleteAnime) GetAllTitlesDeref() []string { + titles := make([]string, 0) + if m.HasRomajiTitle() { + titles = append(titles, *m.Title.Romaji) + } + if m.HasEnglishTitle() { + titles = append(titles, *m.Title.English) + } + if m.HasSynonyms() && len(m.Synonyms) > 1 { + syn := lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) }) + for _, s := range syn { + titles = append(titles, *s) + } + } + return titles +} + +// GetCurrentEpisodeCount returns the current episode number for that media and -1 if it doesn't have one. +// i.e. -1 is returned if the media has no episodes AND the next airing episode is not set. +func (m *CompleteAnime) GetCurrentEpisodeCount() int { + ceil := -1 + if m.Episodes != nil { + ceil = *m.Episodes + } + if m.NextAiringEpisode != nil { + if m.NextAiringEpisode.Episode > 0 { + ceil = m.NextAiringEpisode.Episode - 1 + } + } + return ceil +} + +// GetTotalEpisodeCount returns the total episode number for that media and -1 if it doesn't have one +func (m *CompleteAnime) GetTotalEpisodeCount() int { + ceil := -1 + if m.Episodes != nil { + ceil = *m.Episodes + } + return ceil +} + +// GetPossibleSeasonNumber returns the possible season number for that media and -1 if it doesn't have one. +// It looks at the synonyms and returns the highest season number found. +func (m *CompleteAnime) GetPossibleSeasonNumber() int { + if m == nil || m.Synonyms == nil || len(m.Synonyms) == 0 { + return -1 + } + titles := lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) }) + if m.HasEnglishTitle() { + titles = append(titles, m.Title.English) + } + if m.HasRomajiTitle() { + titles = append(titles, m.Title.Romaji) + } + seasons := lo.Map(titles, func(s *string, i int) int { return comparison.ExtractSeasonNumber(*s) }) + return lo.Max(seasons) +} + +func (m *CompleteAnime) HasEnglishTitle() bool { + return m.Title.English != nil +} + +func (m *CompleteAnime) HasRomajiTitle() bool { + return m.Title.Romaji != nil +} + +func (m *CompleteAnime) HasSynonyms() bool { + return m.Synonyms != nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var EdgeNarrowFormats = []MediaFormat{MediaFormatTv, MediaFormatTvShort} +var EdgeBroaderFormats = []MediaFormat{MediaFormatTv, MediaFormatTvShort, MediaFormatOna, MediaFormatOva, MediaFormatMovie, MediaFormatSpecial} + +func (m *CompleteAnime) FindEdge(relation string, formats []MediaFormat) (*BaseAnime, bool) { + if m.GetRelations() == nil { + return nil, false + } + + edges := m.GetRelations().GetEdges() + + for _, edge := range edges { + + if edge.GetRelationType().String() == relation { + for _, fm := range formats { + if fm.String() == edge.GetNode().GetFormat().String() { + return edge.GetNode(), true + } + } + } + + } + return nil, false +} + +func (e *CompleteAnime_Relations_Edges) IsBroadRelationFormat() bool { + if e.GetNode() == nil { + return false + } + if e.GetNode().GetFormat() == nil { + return false + } + for _, fm := range EdgeBroaderFormats { + if fm.String() == e.GetNode().GetFormat().String() { + return true + } + } + return false +} +func (e *CompleteAnime_Relations_Edges) IsNarrowRelationFormat() bool { + if e.GetNode() == nil { + return false + } + if e.GetNode().GetFormat() == nil { + return false + } + for _, fm := range EdgeNarrowFormats { + if fm.String() == e.GetNode().GetFormat().String() { + return true + } + } + return false +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *CompleteAnime) ToBaseAnime() *BaseAnime { + if m == nil { + return nil + } + + var trailer *BaseAnime_Trailer + if m.GetTrailer() != nil { + trailer = &BaseAnime_Trailer{ + ID: m.GetTrailer().GetID(), + Site: m.GetTrailer().GetSite(), + Thumbnail: m.GetTrailer().GetThumbnail(), + } + } + + var nextAiringEpisode *BaseAnime_NextAiringEpisode + if m.GetNextAiringEpisode() != nil { + nextAiringEpisode = &BaseAnime_NextAiringEpisode{ + AiringAt: m.GetNextAiringEpisode().GetAiringAt(), + TimeUntilAiring: m.GetNextAiringEpisode().GetTimeUntilAiring(), + Episode: m.GetNextAiringEpisode().GetEpisode(), + } + } + + var startDate *BaseAnime_StartDate + if m.GetStartDate() != nil { + startDate = &BaseAnime_StartDate{ + Year: m.GetStartDate().GetYear(), + Month: m.GetStartDate().GetMonth(), + Day: m.GetStartDate().GetDay(), + } + } + + var endDate *BaseAnime_EndDate + if m.GetEndDate() != nil { + endDate = &BaseAnime_EndDate{ + Year: m.GetEndDate().GetYear(), + Month: m.GetEndDate().GetMonth(), + Day: m.GetEndDate().GetDay(), + } + } + + return &BaseAnime{ + ID: m.GetID(), + IDMal: m.GetIDMal(), + SiteURL: m.GetSiteURL(), + Format: m.GetFormat(), + Episodes: m.GetEpisodes(), + Status: m.GetStatus(), + Synonyms: m.GetSynonyms(), + BannerImage: m.GetBannerImage(), + Season: m.GetSeason(), + SeasonYear: m.GetSeasonYear(), + Type: m.GetType(), + IsAdult: m.GetIsAdult(), + CountryOfOrigin: m.GetCountryOfOrigin(), + Genres: m.GetGenres(), + Duration: m.GetDuration(), + Description: m.GetDescription(), + MeanScore: m.GetMeanScore(), + Trailer: trailer, + Title: &BaseAnime_Title{ + UserPreferred: m.GetTitle().GetUserPreferred(), + Romaji: m.GetTitle().GetRomaji(), + English: m.GetTitle().GetEnglish(), + Native: m.GetTitle().GetNative(), + }, + CoverImage: &BaseAnime_CoverImage{ + ExtraLarge: m.GetCoverImage().GetExtraLarge(), + Large: m.GetCoverImage().GetLarge(), + Medium: m.GetCoverImage().GetMedium(), + Color: m.GetCoverImage().GetColor(), + }, + StartDate: startDate, + EndDate: endDate, + NextAiringEpisode: nextAiringEpisode, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *AnimeListEntry) GetProgressSafe() int { + if m == nil { + return 0 + } + if m.Progress == nil { + return 0 + } + return *m.Progress +} + +func (m *AnimeListEntry) GetScoreSafe() float64 { + if m == nil { + return 0 + } + if m.Score == nil { + return 0 + } + return *m.Score +} + +func (m *AnimeListEntry) GetRepeatSafe() int { + if m == nil { + return 0 + } + if m.Repeat == nil { + return 0 + } + return *m.Repeat +} + +func (m *AnimeListEntry) GetStatusSafe() MediaListStatus { + if m == nil { + return "" + } + if m.Status == nil { + return "" + } + return *m.Status +} diff --git a/seanime-2.9.10/internal/api/anilist/media_tree.go b/seanime-2.9.10/internal/api/anilist/media_tree.go new file mode 100644 index 0000000..9e9c154 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/media_tree.go @@ -0,0 +1,155 @@ +package anilist + +import ( + "context" + "github.com/samber/lo" + "seanime/internal/util" + "seanime/internal/util/limiter" + "seanime/internal/util/result" + "sync" +) + +type ( + CompleteAnimeRelationTree struct { + *result.Map[int, *CompleteAnime] + } + + FetchMediaTreeRelation = string +) + +const ( + FetchMediaTreeSequels FetchMediaTreeRelation = "sequels" + FetchMediaTreePrequels FetchMediaTreeRelation = "prequels" + FetchMediaTreeAll FetchMediaTreeRelation = "all" +) + +// NewCompleteAnimeRelationTree returns a new result.Map[int, *CompleteAnime]. +// It is used to store the results of FetchMediaTree or FetchMediaTree calls. +func NewCompleteAnimeRelationTree() *CompleteAnimeRelationTree { + return &CompleteAnimeRelationTree{result.NewResultMap[int, *CompleteAnime]()} +} + +func (m *BaseAnime) FetchMediaTree(rel FetchMediaTreeRelation, anilistClient AnilistClient, rl *limiter.Limiter, tree *CompleteAnimeRelationTree, cache *CompleteAnimeCache) (err error) { + if m == nil { + return nil + } + + defer util.HandlePanicInModuleWithError("anilist/BaseAnime.FetchMediaTree", &err) + + rl.Wait() + res, err := anilistClient.CompleteAnimeByID(context.Background(), &m.ID) + if err != nil { + return err + } + return res.GetMedia().FetchMediaTree(rel, anilistClient, rl, tree, cache) +} + +// FetchMediaTree populates the CompleteAnimeRelationTree with the given media's sequels and prequels. +// It also takes a CompleteAnimeCache to store the fetched media in and avoid duplicate fetches. +// It also takes a limiter.Limiter to limit the number of requests made to the AniList API. +func (m *CompleteAnime) FetchMediaTree(rel FetchMediaTreeRelation, anilistClient AnilistClient, rl *limiter.Limiter, tree *CompleteAnimeRelationTree, cache *CompleteAnimeCache) (err error) { + if m == nil { + return nil + } + + defer util.HandlePanicInModuleWithError("anilist/CompleteAnime.FetchMediaTree", &err) + + if tree.Has(m.ID) { + cache.Set(m.ID, m) + return nil + } + cache.Set(m.ID, m) + tree.Set(m.ID, m) + + if m.Relations == nil { + return nil + } + + // Get all edges + edges := m.GetRelations().GetEdges() + // Filter edges + edges = lo.Filter(edges, func(_edge *CompleteAnime_Relations_Edges, _ int) bool { + return (*_edge.RelationType == MediaRelationSequel || *_edge.RelationType == MediaRelationPrequel) && + *_edge.GetNode().Status != MediaStatusNotYetReleased && + _edge.IsBroadRelationFormat() && !tree.Has(_edge.GetNode().ID) + }) + + if len(edges) == 0 { + return nil + } + + doneCh := make(chan struct{}) + processEdges(edges, rel, anilistClient, rl, tree, cache, doneCh) + + for { + select { + case <-doneCh: + return nil + default: + } + } +} + +// processEdges fetches the next node(s) for each edge in parallel. +func processEdges(edges []*CompleteAnime_Relations_Edges, rel FetchMediaTreeRelation, anilistClient AnilistClient, rl *limiter.Limiter, tree *CompleteAnimeRelationTree, cache *CompleteAnimeCache, doneCh chan struct{}) { + var wg sync.WaitGroup + wg.Add(len(edges)) + + for i, item := range edges { + go func(edge *CompleteAnime_Relations_Edges, _ int) { + defer wg.Done() + if edge == nil { + return + } + processEdge(edge, rel, anilistClient, rl, tree, cache) + }(item, i) + } + + wg.Wait() + + go func() { + close(doneCh) + }() +} + +func processEdge(edge *CompleteAnime_Relations_Edges, rel FetchMediaTreeRelation, anilistClient AnilistClient, rl *limiter.Limiter, tree *CompleteAnimeRelationTree, cache *CompleteAnimeCache) { + defer util.HandlePanicInModuleThen("anilist/processEdge", func() {}) + cacheV, ok := cache.Get(edge.GetNode().ID) + edgeCompleteAnime := cacheV + if !ok { + rl.Wait() + // Fetch the next node + res, err := anilistClient.CompleteAnimeByID(context.Background(), &edge.GetNode().ID) + if err == nil { + edgeCompleteAnime = res.GetMedia() + cache.Set(edgeCompleteAnime.ID, edgeCompleteAnime) + } + } + if edgeCompleteAnime == nil { + return + } + // Get the relation type to fetch for the next node + edgeRel := getEdgeRelation(edge, rel) + // Fetch the next node(s) + err := edgeCompleteAnime.FetchMediaTree(edgeRel, anilistClient, rl, tree, cache) + if err != nil { + return + } +} + +// getEdgeRelation returns the relation to fetch for the next node based on the current edge and the relation to fetch. +// If the relation to fetch is FetchMediaTreeAll, it will return FetchMediaTreePrequels for prequels and FetchMediaTreeSequels for sequels. +// +// For example, if the current node is a sequel and the relation to fetch is FetchMediaTreeAll, it will return FetchMediaTreeSequels so that +// only sequels are fetched for the next node. +func getEdgeRelation(edge *CompleteAnime_Relations_Edges, rel FetchMediaTreeRelation) FetchMediaTreeRelation { + if rel == FetchMediaTreeAll { + if *edge.RelationType == MediaRelationPrequel { + return FetchMediaTreePrequels + } + if *edge.RelationType == MediaRelationSequel { + return FetchMediaTreeSequels + } + } + return rel +} diff --git a/seanime-2.9.10/internal/api/anilist/media_tree_test.go b/seanime-2.9.10/internal/api/anilist/media_tree_test.go new file mode 100644 index 0000000..7dceaec --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/media_tree_test.go @@ -0,0 +1,82 @@ +package anilist + +import ( + "context" + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + "seanime/internal/test_utils" + "seanime/internal/util/limiter" + "testing" +) + +func TestBaseAnime_FetchMediaTree_BaseAnime(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + anilistClient := TestGetMockAnilistClient() + lim := limiter.NewAnilistLimiter() + completeAnimeCache := NewCompleteAnimeCache() + + tests := []struct { + name string + mediaId int + edgeIds []int + }{ + { + name: "Bungo Stray Dogs", + mediaId: 103223, + edgeIds: []int{ + 21311, // BSD1 + 21679, // BSD2 + 103223, // BSD3 + 141249, // BSD4 + 163263, // BSD5 + }, + }, + { + name: "Re:Zero", + mediaId: 21355, + edgeIds: []int{ + 21355, // Re:Zero 1 + 108632, // Re:Zero 2 + 119661, // Re:Zero 2 Part 2 + 163134, // Re:Zero 3 + }, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + mediaF, err := anilistClient.CompleteAnimeByID(context.Background(), &tt.mediaId) + + if assert.NoError(t, err) { + + media := mediaF.GetMedia() + + tree := NewCompleteAnimeRelationTree() + + err = media.FetchMediaTree( + FetchMediaTreeAll, + anilistClient, + lim, + tree, + completeAnimeCache, + ) + + if assert.NoError(t, err) { + + for _, treeId := range tt.edgeIds { + a, found := tree.Get(treeId) + assert.Truef(t, found, "expected tree to contain %d", treeId) + spew.Dump(a.GetTitleSafe()) + } + + } + + } + }) + + } + +} diff --git a/seanime-2.9.10/internal/api/anilist/models_gen.go b/seanime-2.9.10/internal/api/anilist/models_gen.go new file mode 100644 index 0000000..049a5a0 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/models_gen.go @@ -0,0 +1,4440 @@ +// Code generated by github.com/99designs/gqlgen, DO NOT EDIT. + +package anilist + +import ( + "fmt" + "io" + "strconv" +) + +// Activity union type +type ActivityUnion interface { + IsActivityUnion() +} + +// Likeable union type +type LikeableUnion interface { + IsLikeableUnion() +} + +// Notification union type +type NotificationUnion interface { + IsNotificationUnion() +} + +// Notification for when a activity is liked +type ActivityLikeNotification struct { + // The id of the Notification + ID int `json:"id"` + // The id of the user who liked to the activity + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the activity which was liked + ActivityID int `json:"activityId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The liked activity + Activity ActivityUnion `json:"activity,omitempty"` + // The user who liked the activity + User *User `json:"user,omitempty"` +} + +func (ActivityLikeNotification) IsNotificationUnion() {} + +// Notification for when authenticated user is @ mentioned in activity or reply +type ActivityMentionNotification struct { + // The id of the Notification + ID int `json:"id"` + // The id of the user who mentioned the authenticated user + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the activity where mentioned + ActivityID int `json:"activityId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The liked activity + Activity ActivityUnion `json:"activity,omitempty"` + // The user who mentioned the authenticated user + User *User `json:"user,omitempty"` +} + +func (ActivityMentionNotification) IsNotificationUnion() {} + +// Notification for when a user is send an activity message +type ActivityMessageNotification struct { + // The id of the Notification + ID int `json:"id"` + // The if of the user who send the message + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the activity message + ActivityID int `json:"activityId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The message activity + Message *MessageActivity `json:"message,omitempty"` + // The user who sent the message + User *User `json:"user,omitempty"` +} + +func (ActivityMessageNotification) IsNotificationUnion() {} + +// Replay to an activity item +type ActivityReply struct { + // The id of the reply + ID int `json:"id"` + // The id of the replies creator + UserID *int `json:"userId,omitempty"` + // The id of the parent activity + ActivityID *int `json:"activityId,omitempty"` + // The reply text + Text *string `json:"text,omitempty"` + // The amount of likes the reply has + LikeCount int `json:"likeCount"` + // If the currently authenticated user liked the reply + IsLiked *bool `json:"isLiked,omitempty"` + // The time the reply was created at + CreatedAt int `json:"createdAt"` + // The user who created reply + User *User `json:"user,omitempty"` + // The users who liked the reply + Likes []*User `json:"likes,omitempty"` +} + +func (ActivityReply) IsLikeableUnion() {} + +// Notification for when a activity reply is liked +type ActivityReplyLikeNotification struct { + // The id of the Notification + ID int `json:"id"` + // The id of the user who liked to the activity reply + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the activity where the reply which was liked + ActivityID int `json:"activityId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The liked activity + Activity ActivityUnion `json:"activity,omitempty"` + // The user who liked the activity reply + User *User `json:"user,omitempty"` +} + +func (ActivityReplyLikeNotification) IsNotificationUnion() {} + +// Notification for when a user replies to the authenticated users activity +type ActivityReplyNotification struct { + // The id of the Notification + ID int `json:"id"` + // The id of the user who replied to the activity + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the activity which was replied too + ActivityID int `json:"activityId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The liked activity + Activity ActivityUnion `json:"activity,omitempty"` + // The user who replied to the activity + User *User `json:"user,omitempty"` +} + +func (ActivityReplyNotification) IsNotificationUnion() {} + +// Notification for when a user replies to activity the authenticated user has replied to +type ActivityReplySubscribedNotification struct { + // The id of the Notification + ID int `json:"id"` + // The id of the user who replied to the activity + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the activity which was replied too + ActivityID int `json:"activityId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The liked activity + Activity ActivityUnion `json:"activity,omitempty"` + // The user who replied to the activity + User *User `json:"user,omitempty"` +} + +func (ActivityReplySubscribedNotification) IsNotificationUnion() {} + +// Notification for when an episode of anime airs +type AiringNotification struct { + // The id of the Notification + ID int `json:"id"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the aired anime + AnimeID int `json:"animeId"` + // The episode number that just aired + Episode int `json:"episode"` + // The notification context text + Contexts []*string `json:"contexts,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The associated media of the airing schedule + Media *Media `json:"media,omitempty"` +} + +func (AiringNotification) IsNotificationUnion() {} + +// Score & Watcher stats for airing anime by episode and mid-week +type AiringProgression struct { + // The episode the stats were recorded at. .5 is the mid point between 2 episodes airing dates. + Episode *float64 `json:"episode,omitempty"` + // The average score for the media + Score *float64 `json:"score,omitempty"` + // The amount of users watching the anime + Watching *int `json:"watching,omitempty"` +} + +// Media Airing Schedule. NOTE: We only aim to guarantee that FUTURE airing data is present and accurate. +type AiringSchedule struct { + // The id of the airing schedule item + ID int `json:"id"` + // The time the episode airs at + AiringAt int `json:"airingAt"` + // Seconds until episode starts airing + TimeUntilAiring int `json:"timeUntilAiring"` + // The airing episode number + Episode int `json:"episode"` + // The associate media id of the airing episode + MediaID int `json:"mediaId"` + // The associate media of the airing episode + Media *Media `json:"media,omitempty"` +} + +type AiringScheduleConnection struct { + Edges []*AiringScheduleEdge `json:"edges,omitempty"` + Nodes []*AiringSchedule `json:"nodes,omitempty"` + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` +} + +// AiringSchedule connection edge +type AiringScheduleEdge struct { + Node *AiringSchedule `json:"node,omitempty"` + // The id of the connection + ID *int `json:"id,omitempty"` +} + +type AiringScheduleInput struct { + AiringAt *int `json:"airingAt,omitempty"` + Episode *int `json:"episode,omitempty"` + TimeUntilAiring *int `json:"timeUntilAiring,omitempty"` +} + +type AniChartHighlightInput struct { + MediaID *int `json:"mediaId,omitempty"` + Highlight *string `json:"highlight,omitempty"` +} + +type AniChartUser struct { + User *User `json:"user,omitempty"` + Settings *string `json:"settings,omitempty"` + Highlights *string `json:"highlights,omitempty"` +} + +// A character that features in an anime or manga +type Character struct { + // The id of the character + ID int `json:"id"` + // The names of the character + Name *CharacterName `json:"name,omitempty"` + // Character images + Image *CharacterImage `json:"image,omitempty"` + // A general description of the character + Description *string `json:"description,omitempty"` + // The character's gender. Usually Male, Female, or Non-binary but can be any string. + Gender *string `json:"gender,omitempty"` + // The character's birth date + DateOfBirth *FuzzyDate `json:"dateOfBirth,omitempty"` + // The character's age. Note this is a string, not an int, it may contain further text and additional ages. + Age *string `json:"age,omitempty"` + // The characters blood type + BloodType *string `json:"bloodType,omitempty"` + // If the character is marked as favourite by the currently authenticated user + IsFavourite bool `json:"isFavourite"` + // If the character is blocked from being added to favourites + IsFavouriteBlocked bool `json:"isFavouriteBlocked"` + // The url for the character page on the AniList website + SiteURL *string `json:"siteUrl,omitempty"` + // Media that includes the character + Media *MediaConnection `json:"media,omitempty"` + UpdatedAt *int `json:"updatedAt,omitempty"` + // The amount of user's who have favourited the character + Favourites *int `json:"favourites,omitempty"` + // Notes for site moderators + ModNotes *string `json:"modNotes,omitempty"` +} + +type CharacterConnection struct { + Edges []*CharacterEdge `json:"edges,omitempty"` + Nodes []*Character `json:"nodes,omitempty"` + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` +} + +// Character connection edge +type CharacterEdge struct { + Node *Character `json:"node,omitempty"` + // The id of the connection + ID *int `json:"id,omitempty"` + // The characters role in the media + Role *CharacterRole `json:"role,omitempty"` + // Media specific character name + Name *string `json:"name,omitempty"` + // The voice actors of the character + VoiceActors []*Staff `json:"voiceActors,omitempty"` + // The voice actors of the character with role date + VoiceActorRoles []*StaffRoleType `json:"voiceActorRoles,omitempty"` + // The media the character is in + Media []*Media `json:"media,omitempty"` + // The order the character should be displayed from the users favourites + FavouriteOrder *int `json:"favouriteOrder,omitempty"` +} + +type CharacterImage struct { + // The character's image of media at its largest size + Large *string `json:"large,omitempty"` + // The character's image of media at medium size + Medium *string `json:"medium,omitempty"` +} + +// The names of the character +type CharacterName struct { + // The character's given name + First *string `json:"first,omitempty"` + // The character's middle name + Middle *string `json:"middle,omitempty"` + // The character's surname + Last *string `json:"last,omitempty"` + // The character's first and last name + Full *string `json:"full,omitempty"` + // The character's full name in their native language + Native *string `json:"native,omitempty"` + // Other names the character might be referred to as + Alternative []*string `json:"alternative,omitempty"` + // Other names the character might be referred to as but are spoilers + AlternativeSpoiler []*string `json:"alternativeSpoiler,omitempty"` + // The currently authenticated users preferred name language. Default romaji for non-authenticated + UserPreferred *string `json:"userPreferred,omitempty"` +} + +// The names of the character +type CharacterNameInput struct { + // The character's given name + First *string `json:"first,omitempty"` + // The character's middle name + Middle *string `json:"middle,omitempty"` + // The character's surname + Last *string `json:"last,omitempty"` + // The character's full name in their native language + Native *string `json:"native,omitempty"` + // Other names the character might be referred by + Alternative []*string `json:"alternative,omitempty"` + // Other names the character might be referred to as but are spoilers + AlternativeSpoiler []*string `json:"alternativeSpoiler,omitempty"` +} + +// A submission for a character that features in an anime or manga +type CharacterSubmission struct { + // The id of the submission + ID int `json:"id"` + // Character that the submission is referencing + Character *Character `json:"character,omitempty"` + // The character submission changes + Submission *Character `json:"submission,omitempty"` + // Submitter for the submission + Submitter *User `json:"submitter,omitempty"` + // Data Mod assigned to handle the submission + Assignee *User `json:"assignee,omitempty"` + // Status of the submission + Status *SubmissionStatus `json:"status,omitempty"` + // Inner details of submission status + Notes *string `json:"notes,omitempty"` + Source *string `json:"source,omitempty"` + // Whether the submission is locked + Locked *bool `json:"locked,omitempty"` + CreatedAt *int `json:"createdAt,omitempty"` +} + +type CharacterSubmissionConnection struct { + Edges []*CharacterSubmissionEdge `json:"edges,omitempty"` + Nodes []*CharacterSubmission `json:"nodes,omitempty"` + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` +} + +// CharacterSubmission connection edge +type CharacterSubmissionEdge struct { + Node *CharacterSubmission `json:"node,omitempty"` + // The characters role in the media + Role *CharacterRole `json:"role,omitempty"` + // The voice actors of the character + VoiceActors []*Staff `json:"voiceActors,omitempty"` + // The submitted voice actors of the character + SubmittedVoiceActors []*StaffSubmission `json:"submittedVoiceActors,omitempty"` +} + +// Deleted data type +type Deleted struct { + // If an item has been successfully deleted + Deleted *bool `json:"deleted,omitempty"` +} + +// User's favourite anime, manga, characters, staff & studios +type Favourites struct { + // Favourite anime + Anime *MediaConnection `json:"anime,omitempty"` + // Favourite manga + Manga *MediaConnection `json:"manga,omitempty"` + // Favourite characters + Characters *CharacterConnection `json:"characters,omitempty"` + // Favourite staff + Staff *StaffConnection `json:"staff,omitempty"` + // Favourite studios + Studios *StudioConnection `json:"studios,omitempty"` +} + +// Notification for when the authenticated user is followed by another user +type FollowingNotification struct { + // The id of the Notification + ID int `json:"id"` + // The id of the user who followed the authenticated user + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The liked activity + User *User `json:"user,omitempty"` +} + +func (FollowingNotification) IsNotificationUnion() {} + +// User's format statistics +type FormatStats struct { + Format *MediaFormat `json:"format,omitempty"` + Amount *int `json:"amount,omitempty"` +} + +// Date object that allows for incomplete date values (fuzzy) +type FuzzyDate struct { + // Numeric Year (2017) + Year *int `json:"year,omitempty"` + // Numeric Month (3) + Month *int `json:"month,omitempty"` + // Numeric Day (24) + Day *int `json:"day,omitempty"` +} + +// Date object that allows for incomplete date values (fuzzy) +type FuzzyDateInput struct { + // Numeric Year (2017) + Year *int `json:"year,omitempty"` + // Numeric Month (3) + Month *int `json:"month,omitempty"` + // Numeric Day (24) + Day *int `json:"day,omitempty"` +} + +// User's genre statistics +type GenreStats struct { + Genre *string `json:"genre,omitempty"` + Amount *int `json:"amount,omitempty"` + MeanScore *int `json:"meanScore,omitempty"` + // The amount of time in minutes the genre has been watched by the user + TimeWatched *int `json:"timeWatched,omitempty"` +} + +// Page of data (Used for internal use only) +type InternalPage struct { + MediaSubmissions []*MediaSubmission `json:"mediaSubmissions,omitempty"` + CharacterSubmissions []*CharacterSubmission `json:"characterSubmissions,omitempty"` + StaffSubmissions []*StaffSubmission `json:"staffSubmissions,omitempty"` + RevisionHistory []*RevisionHistory `json:"revisionHistory,omitempty"` + Reports []*Report `json:"reports,omitempty"` + ModActions []*ModAction `json:"modActions,omitempty"` + UserBlockSearch []*User `json:"userBlockSearch,omitempty"` + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` + Users []*User `json:"users,omitempty"` + Media []*Media `json:"media,omitempty"` + Characters []*Character `json:"characters,omitempty"` + Staff []*Staff `json:"staff,omitempty"` + Studios []*Studio `json:"studios,omitempty"` + MediaList []*MediaList `json:"mediaList,omitempty"` + AiringSchedules []*AiringSchedule `json:"airingSchedules,omitempty"` + MediaTrends []*MediaTrend `json:"mediaTrends,omitempty"` + Notifications []NotificationUnion `json:"notifications,omitempty"` + Followers []*User `json:"followers,omitempty"` + Following []*User `json:"following,omitempty"` + Activities []ActivityUnion `json:"activities,omitempty"` + ActivityReplies []*ActivityReply `json:"activityReplies,omitempty"` + Threads []*Thread `json:"threads,omitempty"` + ThreadComments []*ThreadComment `json:"threadComments,omitempty"` + Reviews []*Review `json:"reviews,omitempty"` + Recommendations []*Recommendation `json:"recommendations,omitempty"` + Likes []*User `json:"likes,omitempty"` +} + +// User list activity (anime & manga updates) +type ListActivity struct { + // The id of the activity + ID int `json:"id"` + // The user id of the activity's creator + UserID *int `json:"userId,omitempty"` + // The type of activity + Type *ActivityType `json:"type,omitempty"` + // The number of activity replies + ReplyCount int `json:"replyCount"` + // The list item's textual status + Status *string `json:"status,omitempty"` + // The list progress made + Progress *string `json:"progress,omitempty"` + // If the activity is locked and can receive replies + IsLocked *bool `json:"isLocked,omitempty"` + // If the currently authenticated user is subscribed to the activity + IsSubscribed *bool `json:"isSubscribed,omitempty"` + // The amount of likes the activity has + LikeCount int `json:"likeCount"` + // If the currently authenticated user liked the activity + IsLiked *bool `json:"isLiked,omitempty"` + // If the activity is pinned to the top of the users activity feed + IsPinned *bool `json:"isPinned,omitempty"` + // The url for the activity page on the AniList website + SiteURL *string `json:"siteUrl,omitempty"` + // The time the activity was created at + CreatedAt int `json:"createdAt"` + // The owner of the activity + User *User `json:"user,omitempty"` + // The associated media to the activity update + Media *Media `json:"media,omitempty"` + // The written replies to the activity + Replies []*ActivityReply `json:"replies,omitempty"` + // The users who liked the activity + Likes []*User `json:"likes,omitempty"` +} + +func (ListActivity) IsActivityUnion() {} + +func (ListActivity) IsLikeableUnion() {} + +type ListActivityOption struct { + Disabled *bool `json:"disabled,omitempty"` + Type *MediaListStatus `json:"type,omitempty"` +} + +type ListActivityOptionInput struct { + Disabled *bool `json:"disabled,omitempty"` + Type *MediaListStatus `json:"type,omitempty"` +} + +// User's list score statistics +type ListScoreStats struct { + MeanScore *int `json:"meanScore,omitempty"` + StandardDeviation *int `json:"standardDeviation,omitempty"` +} + +// Anime or Manga +type Media struct { + // The id of the media + ID int `json:"id"` + // The mal id of the media + IDMal *int `json:"idMal,omitempty"` + // The official titles of the media in various languages + Title *MediaTitle `json:"title,omitempty"` + // The type of the media; anime or manga + Type *MediaType `json:"type,omitempty"` + // The format the media was released in + Format *MediaFormat `json:"format,omitempty"` + // The current releasing status of the media + Status *MediaStatus `json:"status,omitempty"` + // Short description of the media's story and characters + Description *string `json:"description,omitempty"` + // The first official release date of the media + StartDate *FuzzyDate `json:"startDate,omitempty"` + // The last official release date of the media + EndDate *FuzzyDate `json:"endDate,omitempty"` + // The season the media was initially released in + Season *MediaSeason `json:"season,omitempty"` + // The season year the media was initially released in + SeasonYear *int `json:"seasonYear,omitempty"` + // The year & season the media was initially released in + SeasonInt *int `json:"seasonInt,omitempty"` + // The amount of episodes the anime has when complete + Episodes *int `json:"episodes,omitempty"` + // The general length of each anime episode in minutes + Duration *int `json:"duration,omitempty"` + // The amount of chapters the manga has when complete + Chapters *int `json:"chapters,omitempty"` + // The amount of volumes the manga has when complete + Volumes *int `json:"volumes,omitempty"` + // Where the media was created. (ISO 3166-1 alpha-2) + CountryOfOrigin *string `json:"countryOfOrigin,omitempty"` + // If the media is officially licensed or a self-published doujin release + IsLicensed *bool `json:"isLicensed,omitempty"` + // Source type the media was adapted from. + Source *MediaSource `json:"source,omitempty"` + // Official Twitter hashtags for the media + Hashtag *string `json:"hashtag,omitempty"` + // Media trailer or advertisement + Trailer *MediaTrailer `json:"trailer,omitempty"` + // When the media's data was last updated + UpdatedAt *int `json:"updatedAt,omitempty"` + // The cover images of the media + CoverImage *MediaCoverImage `json:"coverImage,omitempty"` + // The banner image of the media + BannerImage *string `json:"bannerImage,omitempty"` + // The genres of the media + Genres []*string `json:"genres,omitempty"` + // Alternative titles of the media + Synonyms []*string `json:"synonyms,omitempty"` + // A weighted average score of all the user's scores of the media + AverageScore *int `json:"averageScore,omitempty"` + // Mean score of all the user's scores of the media + MeanScore *int `json:"meanScore,omitempty"` + // The number of users with the media on their list + Popularity *int `json:"popularity,omitempty"` + // Locked media may not be added to lists our favorited. This may be due to the entry pending for deletion or other reasons. + IsLocked *bool `json:"isLocked,omitempty"` + // The amount of related activity in the past hour + Trending *int `json:"trending,omitempty"` + // The amount of user's who have favourited the media + Favourites *int `json:"favourites,omitempty"` + // List of tags that describes elements and themes of the media + Tags []*MediaTag `json:"tags,omitempty"` + // Other media in the same or connecting franchise + Relations *MediaConnection `json:"relations,omitempty"` + // The characters in the media + Characters *CharacterConnection `json:"characters,omitempty"` + // The staff who produced the media + Staff *StaffConnection `json:"staff,omitempty"` + // The companies who produced the media + Studios *StudioConnection `json:"studios,omitempty"` + // If the media is marked as favourite by the current authenticated user + IsFavourite bool `json:"isFavourite"` + // If the media is blocked from being added to favourites + IsFavouriteBlocked bool `json:"isFavouriteBlocked"` + // If the media is intended only for 18+ adult audiences + IsAdult *bool `json:"isAdult,omitempty"` + // The media's next episode airing schedule + NextAiringEpisode *AiringSchedule `json:"nextAiringEpisode,omitempty"` + // The media's entire airing schedule + AiringSchedule *AiringScheduleConnection `json:"airingSchedule,omitempty"` + // The media's daily trend stats + Trends *MediaTrendConnection `json:"trends,omitempty"` + // External links to another site related to the media + ExternalLinks []*MediaExternalLink `json:"externalLinks,omitempty"` + // Data and links to legal streaming episodes on external sites + StreamingEpisodes []*MediaStreamingEpisode `json:"streamingEpisodes,omitempty"` + // The ranking of the media in a particular time span and format compared to other media + Rankings []*MediaRank `json:"rankings,omitempty"` + // The authenticated user's media list entry for the media + MediaListEntry *MediaList `json:"mediaListEntry,omitempty"` + // User reviews of the media + Reviews *ReviewConnection `json:"reviews,omitempty"` + // User recommendations for similar media + Recommendations *RecommendationConnection `json:"recommendations,omitempty"` + Stats *MediaStats `json:"stats,omitempty"` + // The url for the media page on the AniList website + SiteURL *string `json:"siteUrl,omitempty"` + // If the media should have forum thread automatically created for it on airing episode release + AutoCreateForumThread *bool `json:"autoCreateForumThread,omitempty"` + // If the media is blocked from being recommended to/from + IsRecommendationBlocked *bool `json:"isRecommendationBlocked,omitempty"` + // If the media is blocked from being reviewed + IsReviewBlocked *bool `json:"isReviewBlocked,omitempty"` + // Notes for site moderators + ModNotes *string `json:"modNotes,omitempty"` +} + +// Internal - Media characters separated +type MediaCharacter struct { + // The id of the connection + ID *int `json:"id,omitempty"` + // The characters role in the media + Role *CharacterRole `json:"role,omitempty"` + RoleNotes *string `json:"roleNotes,omitempty"` + DubGroup *string `json:"dubGroup,omitempty"` + // Media specific character name + CharacterName *string `json:"characterName,omitempty"` + // The characters in the media voiced by the parent actor + Character *Character `json:"character,omitempty"` + // The voice actor of the character + VoiceActor *Staff `json:"voiceActor,omitempty"` +} + +type MediaConnection struct { + Edges []*MediaEdge `json:"edges,omitempty"` + Nodes []*Media `json:"nodes,omitempty"` + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` +} + +type MediaCoverImage struct { + // The cover image url of the media at its largest size. If this size isn't available, large will be provided instead. + ExtraLarge *string `json:"extraLarge,omitempty"` + // The cover image url of the media at a large size + Large *string `json:"large,omitempty"` + // The cover image url of the media at medium size + Medium *string `json:"medium,omitempty"` + // Average #hex color of cover image + Color *string `json:"color,omitempty"` +} + +// Notification for when a media entry's data was changed in a significant way impacting users' list tracking +type MediaDataChangeNotification struct { + // The id of the Notification + ID int `json:"id"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the media that received data changes + MediaID int `json:"mediaId"` + // The reason for the media data change + Context *string `json:"context,omitempty"` + // The reason for the media data change + Reason *string `json:"reason,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The media that received data changes + Media *Media `json:"media,omitempty"` +} + +func (MediaDataChangeNotification) IsNotificationUnion() {} + +// Notification for when a media tracked in a user's list is deleted from the site +type MediaDeletionNotification struct { + // The id of the Notification + ID int `json:"id"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The title of the deleted media + DeletedMediaTitle *string `json:"deletedMediaTitle,omitempty"` + // The reason for the media deletion + Context *string `json:"context,omitempty"` + // The reason for the media deletion + Reason *string `json:"reason,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` +} + +func (MediaDeletionNotification) IsNotificationUnion() {} + +// Media connection edge +type MediaEdge struct { + Node *Media `json:"node,omitempty"` + // The id of the connection + ID *int `json:"id,omitempty"` + // The type of relation to the parent model + RelationType *MediaRelation `json:"relationType,omitempty"` + // If the studio is the main animation studio of the media (For Studio->MediaConnection field only) + IsMainStudio bool `json:"isMainStudio"` + // The characters in the media voiced by the parent actor + Characters []*Character `json:"characters,omitempty"` + // The characters role in the media + CharacterRole *CharacterRole `json:"characterRole,omitempty"` + // Media specific character name + CharacterName *string `json:"characterName,omitempty"` + // Notes regarding the VA's role for the character + RoleNotes *string `json:"roleNotes,omitempty"` + // Used for grouping roles where multiple dubs exist for the same language. Either dubbing company name or language variant. + DubGroup *string `json:"dubGroup,omitempty"` + // The role of the staff member in the production of the media + StaffRole *string `json:"staffRole,omitempty"` + // The voice actors of the character + VoiceActors []*Staff `json:"voiceActors,omitempty"` + // The voice actors of the character with role date + VoiceActorRoles []*StaffRoleType `json:"voiceActorRoles,omitempty"` + // The order the media should be displayed from the users favourites + FavouriteOrder *int `json:"favouriteOrder,omitempty"` +} + +// An external link to another site related to the media or staff member +type MediaExternalLink struct { + // The id of the external link + ID int `json:"id"` + // The url of the external link or base url of link source + URL *string `json:"url,omitempty"` + // The links website site name + Site string `json:"site"` + // The links website site id + SiteID *int `json:"siteId,omitempty"` + Type *ExternalLinkType `json:"type,omitempty"` + // Language the site content is in. See Staff language field for values. + Language *string `json:"language,omitempty"` + Color *string `json:"color,omitempty"` + // The icon image url of the site. Not available for all links. Transparent PNG 64x64 + Icon *string `json:"icon,omitempty"` + Notes *string `json:"notes,omitempty"` + IsDisabled *bool `json:"isDisabled,omitempty"` +} + +// An external link to another site related to the media +type MediaExternalLinkInput struct { + // The id of the external link + ID int `json:"id"` + // The url of the external link + URL string `json:"url"` + // The site location of the external link + Site string `json:"site"` +} + +// List of anime or manga +type MediaList struct { + // The id of the list entry + ID int `json:"id"` + // The id of the user owner of the list entry + UserID int `json:"userId"` + // The id of the media + MediaID int `json:"mediaId"` + // The watching/reading status + Status *MediaListStatus `json:"status,omitempty"` + // The score of the entry + Score *float64 `json:"score,omitempty"` + // The amount of episodes/chapters consumed by the user + Progress *int `json:"progress,omitempty"` + // The amount of volumes read by the user + ProgressVolumes *int `json:"progressVolumes,omitempty"` + // The amount of times the user has rewatched/read the media + Repeat *int `json:"repeat,omitempty"` + // Priority of planning + Priority *int `json:"priority,omitempty"` + // If the entry should only be visible to authenticated user + Private *bool `json:"private,omitempty"` + // Text notes + Notes *string `json:"notes,omitempty"` + // If the entry shown be hidden from non-custom lists + HiddenFromStatusLists *bool `json:"hiddenFromStatusLists,omitempty"` + // Map of booleans for which custom lists the entry are in + CustomLists *string `json:"customLists,omitempty"` + // Map of advanced scores with name keys + AdvancedScores *string `json:"advancedScores,omitempty"` + // When the entry was started by the user + StartedAt *FuzzyDate `json:"startedAt,omitempty"` + // When the entry was completed by the user + CompletedAt *FuzzyDate `json:"completedAt,omitempty"` + // When the entry data was last updated + UpdatedAt *int `json:"updatedAt,omitempty"` + // When the entry data was created + CreatedAt *int `json:"createdAt,omitempty"` + Media *Media `json:"media,omitempty"` + User *User `json:"user,omitempty"` +} + +// List of anime or manga +type MediaListCollection struct { + // Grouped media list entries + Lists []*MediaListGroup `json:"lists,omitempty"` + // The owner of the list + User *User `json:"user,omitempty"` + // If there is another chunk + HasNextChunk *bool `json:"hasNextChunk,omitempty"` + // A map of media list entry arrays grouped by status + StatusLists [][]*MediaList `json:"statusLists,omitempty"` + // A map of media list entry arrays grouped by custom lists + CustomLists [][]*MediaList `json:"customLists,omitempty"` +} + +// List group of anime or manga entries +type MediaListGroup struct { + // Media list entries + Entries []*MediaList `json:"entries,omitempty"` + Name *string `json:"name,omitempty"` + IsCustomList *bool `json:"isCustomList,omitempty"` + IsSplitCompletedList *bool `json:"isSplitCompletedList,omitempty"` + Status *MediaListStatus `json:"status,omitempty"` +} + +// A user's list options +type MediaListOptions struct { + // The score format the user is using for media lists + ScoreFormat *ScoreFormat `json:"scoreFormat,omitempty"` + // The default order list rows should be displayed in + RowOrder *string `json:"rowOrder,omitempty"` + UseLegacyLists *bool `json:"useLegacyLists,omitempty"` + // The user's anime list options + AnimeList *MediaListTypeOptions `json:"animeList,omitempty"` + // The user's manga list options + MangaList *MediaListTypeOptions `json:"mangaList,omitempty"` + // The list theme options for both lists + SharedTheme *string `json:"sharedTheme,omitempty"` + // If the shared theme should be used instead of the individual list themes + SharedThemeEnabled *bool `json:"sharedThemeEnabled,omitempty"` +} + +// A user's list options for anime or manga lists +type MediaListOptionsInput struct { + // The order each list should be displayed in + SectionOrder []*string `json:"sectionOrder,omitempty"` + // If the completed sections of the list should be separated by format + SplitCompletedSectionByFormat *bool `json:"splitCompletedSectionByFormat,omitempty"` + // The names of the user's custom lists + CustomLists []*string `json:"customLists,omitempty"` + // The names of the user's advanced scoring sections + AdvancedScoring []*string `json:"advancedScoring,omitempty"` + // If advanced scoring is enabled + AdvancedScoringEnabled *bool `json:"advancedScoringEnabled,omitempty"` + // list theme + Theme *string `json:"theme,omitempty"` +} + +// A user's list options for anime or manga lists +type MediaListTypeOptions struct { + // The order each list should be displayed in + SectionOrder []*string `json:"sectionOrder,omitempty"` + // If the completed sections of the list should be separated by format + SplitCompletedSectionByFormat *bool `json:"splitCompletedSectionByFormat,omitempty"` + // The list theme options + Theme *string `json:"theme,omitempty"` + // The names of the user's custom lists + CustomLists []*string `json:"customLists,omitempty"` + // The names of the user's advanced scoring sections + AdvancedScoring []*string `json:"advancedScoring,omitempty"` + // If advanced scoring is enabled + AdvancedScoringEnabled *bool `json:"advancedScoringEnabled,omitempty"` +} + +// Notification for when a media entry is merged into another for a user who had it on their list +type MediaMergeNotification struct { + // The id of the Notification + ID int `json:"id"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the media that was merged into + MediaID int `json:"mediaId"` + // The title of the deleted media + DeletedMediaTitles []*string `json:"deletedMediaTitles,omitempty"` + // The reason for the media data change + Context *string `json:"context,omitempty"` + // The reason for the media merge + Reason *string `json:"reason,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The media that was merged into + Media *Media `json:"media,omitempty"` +} + +func (MediaMergeNotification) IsNotificationUnion() {} + +// The ranking of a media in a particular time span and format compared to other media +type MediaRank struct { + // The id of the rank + ID int `json:"id"` + // The numerical rank of the media + Rank int `json:"rank"` + // The type of ranking + Type MediaRankType `json:"type"` + // The format the media is ranked within + Format MediaFormat `json:"format"` + // The year the media is ranked within + Year *int `json:"year,omitempty"` + // The season the media is ranked within + Season *MediaSeason `json:"season,omitempty"` + // If the ranking is based on all time instead of a season/year + AllTime *bool `json:"allTime,omitempty"` + // String that gives context to the ranking type and time span + Context string `json:"context"` +} + +// A media's statistics +type MediaStats struct { + ScoreDistribution []*ScoreDistribution `json:"scoreDistribution,omitempty"` + StatusDistribution []*StatusDistribution `json:"statusDistribution,omitempty"` + AiringProgression []*AiringProgression `json:"airingProgression,omitempty"` +} + +// Data and links to legal streaming episodes on external sites +type MediaStreamingEpisode struct { + // Title of the episode + Title *string `json:"title,omitempty"` + // Url of episode image thumbnail + Thumbnail *string `json:"thumbnail,omitempty"` + // The url of the episode + URL *string `json:"url,omitempty"` + // The site location of the streaming episodes + Site *string `json:"site,omitempty"` +} + +// Media submission +type MediaSubmission struct { + // The id of the submission + ID int `json:"id"` + // User submitter of the submission + Submitter *User `json:"submitter,omitempty"` + // Data Mod assigned to handle the submission + Assignee *User `json:"assignee,omitempty"` + // Status of the submission + Status *SubmissionStatus `json:"status,omitempty"` + SubmitterStats *string `json:"submitterStats,omitempty"` + Notes *string `json:"notes,omitempty"` + Source *string `json:"source,omitempty"` + Changes []*string `json:"changes,omitempty"` + // Whether the submission is locked + Locked *bool `json:"locked,omitempty"` + Media *Media `json:"media,omitempty"` + Submission *Media `json:"submission,omitempty"` + Characters []*MediaSubmissionComparison `json:"characters,omitempty"` + Staff []*MediaSubmissionComparison `json:"staff,omitempty"` + Studios []*MediaSubmissionComparison `json:"studios,omitempty"` + Relations []*MediaEdge `json:"relations,omitempty"` + ExternalLinks []*MediaSubmissionComparison `json:"externalLinks,omitempty"` + CreatedAt *int `json:"createdAt,omitempty"` +} + +// Media submission with comparison to current data +type MediaSubmissionComparison struct { + Submission *MediaSubmissionEdge `json:"submission,omitempty"` + Character *MediaCharacter `json:"character,omitempty"` + Staff *StaffEdge `json:"staff,omitempty"` + Studio *StudioEdge `json:"studio,omitempty"` + ExternalLink *MediaExternalLink `json:"externalLink,omitempty"` +} + +type MediaSubmissionEdge struct { + // The id of the direct submission + ID *int `json:"id,omitempty"` + CharacterRole *CharacterRole `json:"characterRole,omitempty"` + StaffRole *string `json:"staffRole,omitempty"` + RoleNotes *string `json:"roleNotes,omitempty"` + DubGroup *string `json:"dubGroup,omitempty"` + CharacterName *string `json:"characterName,omitempty"` + IsMain *bool `json:"isMain,omitempty"` + Character *Character `json:"character,omitempty"` + CharacterSubmission *Character `json:"characterSubmission,omitempty"` + VoiceActor *Staff `json:"voiceActor,omitempty"` + VoiceActorSubmission *Staff `json:"voiceActorSubmission,omitempty"` + Staff *Staff `json:"staff,omitempty"` + StaffSubmission *Staff `json:"staffSubmission,omitempty"` + Studio *Studio `json:"studio,omitempty"` + ExternalLink *MediaExternalLink `json:"externalLink,omitempty"` + Media *Media `json:"media,omitempty"` +} + +// A tag that describes a theme or element of the media +type MediaTag struct { + // The id of the tag + ID int `json:"id"` + // The name of the tag + Name string `json:"name"` + // A general description of the tag + Description *string `json:"description,omitempty"` + // The categories of tags this tag belongs to + Category *string `json:"category,omitempty"` + // The relevance ranking of the tag out of the 100 for this media + Rank *int `json:"rank,omitempty"` + // If the tag could be a spoiler for any media + IsGeneralSpoiler *bool `json:"isGeneralSpoiler,omitempty"` + // If the tag is a spoiler for this media + IsMediaSpoiler *bool `json:"isMediaSpoiler,omitempty"` + // If the tag is only for adult 18+ media + IsAdult *bool `json:"isAdult,omitempty"` + // The user who submitted the tag + UserID *int `json:"userId,omitempty"` +} + +// The official titles of the media in various languages +type MediaTitle struct { + // The romanization of the native language title + Romaji *string `json:"romaji,omitempty"` + // The official english title + English *string `json:"english,omitempty"` + // Official title in it's native language + Native *string `json:"native,omitempty"` + // The currently authenticated users preferred title language. Default romaji for non-authenticated + UserPreferred *string `json:"userPreferred,omitempty"` +} + +// The official titles of the media in various languages +type MediaTitleInput struct { + // The romanization of the native language title + Romaji *string `json:"romaji,omitempty"` + // The official english title + English *string `json:"english,omitempty"` + // Official title in it's native language + Native *string `json:"native,omitempty"` +} + +// Media trailer or advertisement +type MediaTrailer struct { + // The trailer video id + ID *string `json:"id,omitempty"` + // The site the video is hosted by (Currently either youtube or dailymotion) + Site *string `json:"site,omitempty"` + // The url for the thumbnail image of the video + Thumbnail *string `json:"thumbnail,omitempty"` +} + +// Daily media statistics +type MediaTrend struct { + // The id of the tag + MediaID int `json:"mediaId"` + // The day the data was recorded (timestamp) + Date int `json:"date"` + // The amount of media activity on the day + Trending int `json:"trending"` + // A weighted average score of all the user's scores of the media + AverageScore *int `json:"averageScore,omitempty"` + // The number of users with the media on their list + Popularity *int `json:"popularity,omitempty"` + // The number of users with watching/reading the media + InProgress *int `json:"inProgress,omitempty"` + // If the media was being released at this time + Releasing bool `json:"releasing"` + // The episode number of the anime released on this day + Episode *int `json:"episode,omitempty"` + // The related media + Media *Media `json:"media,omitempty"` +} + +type MediaTrendConnection struct { + Edges []*MediaTrendEdge `json:"edges,omitempty"` + Nodes []*MediaTrend `json:"nodes,omitempty"` + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` +} + +// Media trend connection edge +type MediaTrendEdge struct { + Node *MediaTrend `json:"node,omitempty"` +} + +// User message activity +type MessageActivity struct { + // The id of the activity + ID int `json:"id"` + // The user id of the activity's recipient + RecipientID *int `json:"recipientId,omitempty"` + // The user id of the activity's sender + MessengerID *int `json:"messengerId,omitempty"` + // The type of the activity + Type *ActivityType `json:"type,omitempty"` + // The number of activity replies + ReplyCount int `json:"replyCount"` + // The message text (Markdown) + Message *string `json:"message,omitempty"` + // If the activity is locked and can receive replies + IsLocked *bool `json:"isLocked,omitempty"` + // If the currently authenticated user is subscribed to the activity + IsSubscribed *bool `json:"isSubscribed,omitempty"` + // The amount of likes the activity has + LikeCount int `json:"likeCount"` + // If the currently authenticated user liked the activity + IsLiked *bool `json:"isLiked,omitempty"` + // If the message is private and only viewable to the sender and recipients + IsPrivate *bool `json:"isPrivate,omitempty"` + // The url for the activity page on the AniList website + SiteURL *string `json:"siteUrl,omitempty"` + // The time the activity was created at + CreatedAt int `json:"createdAt"` + // The user who the activity message was sent to + Recipient *User `json:"recipient,omitempty"` + // The user who sent the activity message + Messenger *User `json:"messenger,omitempty"` + // The written replies to the activity + Replies []*ActivityReply `json:"replies,omitempty"` + // The users who liked the activity + Likes []*User `json:"likes,omitempty"` +} + +func (MessageActivity) IsActivityUnion() {} + +func (MessageActivity) IsLikeableUnion() {} + +type ModAction struct { + // The id of the action + ID int `json:"id"` + User *User `json:"user,omitempty"` + Mod *User `json:"mod,omitempty"` + Type *ModActionType `json:"type,omitempty"` + ObjectID *int `json:"objectId,omitempty"` + ObjectType *string `json:"objectType,omitempty"` + Data *string `json:"data,omitempty"` + CreatedAt int `json:"createdAt"` +} + +type Mutation struct { +} + +// Notification option +type NotificationOption struct { + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // Whether this type of notification is enabled + Enabled *bool `json:"enabled,omitempty"` +} + +// Notification option input +type NotificationOptionInput struct { + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // Whether this type of notification is enabled + Enabled *bool `json:"enabled,omitempty"` +} + +// Page of data +type Page struct { + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` + Users []*User `json:"users,omitempty"` + Media []*Media `json:"media,omitempty"` + Characters []*Character `json:"characters,omitempty"` + Staff []*Staff `json:"staff,omitempty"` + Studios []*Studio `json:"studios,omitempty"` + MediaList []*MediaList `json:"mediaList,omitempty"` + AiringSchedules []*AiringSchedule `json:"airingSchedules,omitempty"` + MediaTrends []*MediaTrend `json:"mediaTrends,omitempty"` + Notifications []NotificationUnion `json:"notifications,omitempty"` + Followers []*User `json:"followers,omitempty"` + Following []*User `json:"following,omitempty"` + Activities []ActivityUnion `json:"activities,omitempty"` + ActivityReplies []*ActivityReply `json:"activityReplies,omitempty"` + Threads []*Thread `json:"threads,omitempty"` + ThreadComments []*ThreadComment `json:"threadComments,omitempty"` + Reviews []*Review `json:"reviews,omitempty"` + Recommendations []*Recommendation `json:"recommendations,omitempty"` + Likes []*User `json:"likes,omitempty"` +} + +type PageInfo struct { + // The total number of items. Note: This value is not guaranteed to be accurate, do not rely on this for logic + Total *int `json:"total,omitempty"` + // The count on a page + PerPage *int `json:"perPage,omitempty"` + // The current page + CurrentPage *int `json:"currentPage,omitempty"` + // The last page + LastPage *int `json:"lastPage,omitempty"` + // If there is another page + HasNextPage *bool `json:"hasNextPage,omitempty"` +} + +// Provides the parsed markdown as html +type ParsedMarkdown struct { + // The parsed markdown as html + HTML *string `json:"html,omitempty"` +} + +type Query struct { +} + +// Media recommendation +type Recommendation struct { + // The id of the recommendation + ID int `json:"id"` + // Users rating of the recommendation + Rating *int `json:"rating,omitempty"` + // The rating of the recommendation by currently authenticated user + UserRating *RecommendationRating `json:"userRating,omitempty"` + // The media the recommendation is from + Media *Media `json:"media,omitempty"` + // The recommended media + MediaRecommendation *Media `json:"mediaRecommendation,omitempty"` + // The user that first created the recommendation + User *User `json:"user,omitempty"` +} + +type RecommendationConnection struct { + Edges []*RecommendationEdge `json:"edges,omitempty"` + Nodes []*Recommendation `json:"nodes,omitempty"` + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` +} + +// Recommendation connection edge +type RecommendationEdge struct { + Node *Recommendation `json:"node,omitempty"` +} + +// Notification for when new media is added to the site +type RelatedMediaAdditionNotification struct { + // The id of the Notification + ID int `json:"id"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the new media + MediaID int `json:"mediaId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The associated media of the airing schedule + Media *Media `json:"media,omitempty"` +} + +func (RelatedMediaAdditionNotification) IsNotificationUnion() {} + +type Report struct { + ID int `json:"id"` + Reporter *User `json:"reporter,omitempty"` + Reported *User `json:"reported,omitempty"` + Reason *string `json:"reason,omitempty"` + // When the entry data was created + CreatedAt *int `json:"createdAt,omitempty"` + Cleared *bool `json:"cleared,omitempty"` +} + +// A Review that features in an anime or manga +type Review struct { + // The id of the review + ID int `json:"id"` + // The id of the review's creator + UserID int `json:"userId"` + // The id of the review's media + MediaID int `json:"mediaId"` + // For which type of media the review is for + MediaType *MediaType `json:"mediaType,omitempty"` + // A short summary of the review + Summary *string `json:"summary,omitempty"` + // The main review body text + Body *string `json:"body,omitempty"` + // The total user rating of the review + Rating *int `json:"rating,omitempty"` + // The amount of user ratings of the review + RatingAmount *int `json:"ratingAmount,omitempty"` + // The rating of the review by currently authenticated user + UserRating *ReviewRating `json:"userRating,omitempty"` + // The review score of the media + Score *int `json:"score,omitempty"` + // If the review is not yet publicly published and is only viewable by creator + Private *bool `json:"private,omitempty"` + // The url for the review page on the AniList website + SiteURL *string `json:"siteUrl,omitempty"` + // The time of the thread creation + CreatedAt int `json:"createdAt"` + // The time of the thread last update + UpdatedAt int `json:"updatedAt"` + // The creator of the review + User *User `json:"user,omitempty"` + // The media the review is of + Media *Media `json:"media,omitempty"` +} + +type ReviewConnection struct { + Edges []*ReviewEdge `json:"edges,omitempty"` + Nodes []*Review `json:"nodes,omitempty"` + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` +} + +// Review connection edge +type ReviewEdge struct { + Node *Review `json:"node,omitempty"` +} + +// Feed of mod edit activity +type RevisionHistory struct { + // The id of the media + ID int `json:"id"` + // The action taken on the objects + Action *RevisionHistoryAction `json:"action,omitempty"` + // A JSON object of the fields that changed + Changes *string `json:"changes,omitempty"` + // The user who made the edit to the object + User *User `json:"user,omitempty"` + // The media the mod feed entry references + Media *Media `json:"media,omitempty"` + // The character the mod feed entry references + Character *Character `json:"character,omitempty"` + // The staff member the mod feed entry references + Staff *Staff `json:"staff,omitempty"` + // The studio the mod feed entry references + Studio *Studio `json:"studio,omitempty"` + // The external link source the mod feed entry references + ExternalLink *MediaExternalLink `json:"externalLink,omitempty"` + // When the mod feed entry was created + CreatedAt *int `json:"createdAt,omitempty"` +} + +// A user's list score distribution. +type ScoreDistribution struct { + Score *int `json:"score,omitempty"` + // The amount of list entries with this score + Amount *int `json:"amount,omitempty"` +} + +type SiteStatistics struct { + Users *SiteTrendConnection `json:"users,omitempty"` + Anime *SiteTrendConnection `json:"anime,omitempty"` + Manga *SiteTrendConnection `json:"manga,omitempty"` + Characters *SiteTrendConnection `json:"characters,omitempty"` + Staff *SiteTrendConnection `json:"staff,omitempty"` + Studios *SiteTrendConnection `json:"studios,omitempty"` + Reviews *SiteTrendConnection `json:"reviews,omitempty"` +} + +// Daily site statistics +type SiteTrend struct { + // The day the data was recorded (timestamp) + Date int `json:"date"` + Count int `json:"count"` + // The change from yesterday + Change int `json:"change"` +} + +type SiteTrendConnection struct { + Edges []*SiteTrendEdge `json:"edges,omitempty"` + Nodes []*SiteTrend `json:"nodes,omitempty"` + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` +} + +// Site trend connection edge +type SiteTrendEdge struct { + Node *SiteTrend `json:"node,omitempty"` +} + +// Voice actors or production staff +type Staff struct { + // The id of the staff member + ID int `json:"id"` + // The names of the staff member + Name *StaffName `json:"name,omitempty"` + // The primary language the staff member dub's in + Language *StaffLanguage `json:"language,omitempty"` + // The primary language of the staff member. Current values: Japanese, English, Korean, Italian, Spanish, Portuguese, French, German, Hebrew, Hungarian, Chinese, Arabic, Filipino, Catalan, Finnish, Turkish, Dutch, Swedish, Thai, Tagalog, Malaysian, Indonesian, Vietnamese, Nepali, Hindi, Urdu + LanguageV2 *string `json:"languageV2,omitempty"` + // The staff images + Image *StaffImage `json:"image,omitempty"` + // A general description of the staff member + Description *string `json:"description,omitempty"` + // The person's primary occupations + PrimaryOccupations []*string `json:"primaryOccupations,omitempty"` + // The staff's gender. Usually Male, Female, or Non-binary but can be any string. + Gender *string `json:"gender,omitempty"` + DateOfBirth *FuzzyDate `json:"dateOfBirth,omitempty"` + DateOfDeath *FuzzyDate `json:"dateOfDeath,omitempty"` + // The person's age in years + Age *int `json:"age,omitempty"` + // [startYear, endYear] (If the 2nd value is not present staff is still active) + YearsActive []*int `json:"yearsActive,omitempty"` + // The persons birthplace or hometown + HomeTown *string `json:"homeTown,omitempty"` + // The persons blood type + BloodType *string `json:"bloodType,omitempty"` + // If the staff member is marked as favourite by the currently authenticated user + IsFavourite bool `json:"isFavourite"` + // If the staff member is blocked from being added to favourites + IsFavouriteBlocked bool `json:"isFavouriteBlocked"` + // The url for the staff page on the AniList website + SiteURL *string `json:"siteUrl,omitempty"` + // Media where the staff member has a production role + StaffMedia *MediaConnection `json:"staffMedia,omitempty"` + // Characters voiced by the actor + Characters *CharacterConnection `json:"characters,omitempty"` + // Media the actor voiced characters in. (Same data as characters with media as node instead of characters) + CharacterMedia *MediaConnection `json:"characterMedia,omitempty"` + UpdatedAt *int `json:"updatedAt,omitempty"` + // Staff member that the submission is referencing + Staff *Staff `json:"staff,omitempty"` + // Submitter for the submission + Submitter *User `json:"submitter,omitempty"` + // Status of the submission + SubmissionStatus *int `json:"submissionStatus,omitempty"` + // Inner details of submission status + SubmissionNotes *string `json:"submissionNotes,omitempty"` + // The amount of user's who have favourited the staff member + Favourites *int `json:"favourites,omitempty"` + // Notes for site moderators + ModNotes *string `json:"modNotes,omitempty"` +} + +type StaffConnection struct { + Edges []*StaffEdge `json:"edges,omitempty"` + Nodes []*Staff `json:"nodes,omitempty"` + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` +} + +// Staff connection edge +type StaffEdge struct { + Node *Staff `json:"node,omitempty"` + // The id of the connection + ID *int `json:"id,omitempty"` + // The role of the staff member in the production of the media + Role *string `json:"role,omitempty"` + // The order the staff should be displayed from the users favourites + FavouriteOrder *int `json:"favouriteOrder,omitempty"` +} + +type StaffImage struct { + // The person's image of media at its largest size + Large *string `json:"large,omitempty"` + // The person's image of media at medium size + Medium *string `json:"medium,omitempty"` +} + +// The names of the staff member +type StaffName struct { + // The person's given name + First *string `json:"first,omitempty"` + // The person's middle name + Middle *string `json:"middle,omitempty"` + // The person's surname + Last *string `json:"last,omitempty"` + // The person's first and last name + Full *string `json:"full,omitempty"` + // The person's full name in their native language + Native *string `json:"native,omitempty"` + // Other names the staff member might be referred to as (pen names) + Alternative []*string `json:"alternative,omitempty"` + // The currently authenticated users preferred name language. Default romaji for non-authenticated + UserPreferred *string `json:"userPreferred,omitempty"` +} + +// The names of the staff member +type StaffNameInput struct { + // The person's given name + First *string `json:"first,omitempty"` + // The person's middle name + Middle *string `json:"middle,omitempty"` + // The person's surname + Last *string `json:"last,omitempty"` + // The person's full name in their native language + Native *string `json:"native,omitempty"` + // Other names the character might be referred by + Alternative []*string `json:"alternative,omitempty"` +} + +// Voice actor role for a character +type StaffRoleType struct { + // The voice actors of the character + VoiceActor *Staff `json:"voiceActor,omitempty"` + // Notes regarding the VA's role for the character + RoleNotes *string `json:"roleNotes,omitempty"` + // Used for grouping roles where multiple dubs exist for the same language. Either dubbing company name or language variant. + DubGroup *string `json:"dubGroup,omitempty"` +} + +// User's staff statistics +type StaffStats struct { + Staff *Staff `json:"staff,omitempty"` + Amount *int `json:"amount,omitempty"` + MeanScore *int `json:"meanScore,omitempty"` + // The amount of time in minutes the staff member has been watched by the user + TimeWatched *int `json:"timeWatched,omitempty"` +} + +// A submission for a staff that features in an anime or manga +type StaffSubmission struct { + // The id of the submission + ID int `json:"id"` + // Staff that the submission is referencing + Staff *Staff `json:"staff,omitempty"` + // The staff submission changes + Submission *Staff `json:"submission,omitempty"` + // Submitter for the submission + Submitter *User `json:"submitter,omitempty"` + // Data Mod assigned to handle the submission + Assignee *User `json:"assignee,omitempty"` + // Status of the submission + Status *SubmissionStatus `json:"status,omitempty"` + // Inner details of submission status + Notes *string `json:"notes,omitempty"` + Source *string `json:"source,omitempty"` + // Whether the submission is locked + Locked *bool `json:"locked,omitempty"` + CreatedAt *int `json:"createdAt,omitempty"` +} + +// The distribution of the watching/reading status of media or a user's list +type StatusDistribution struct { + // The day the activity took place (Unix timestamp) + Status *MediaListStatus `json:"status,omitempty"` + // The amount of entries with this status + Amount *int `json:"amount,omitempty"` +} + +// Animation or production company +type Studio struct { + // The id of the studio + ID int `json:"id"` + // The name of the studio + Name string `json:"name"` + // If the studio is an animation studio or a different kind of company + IsAnimationStudio bool `json:"isAnimationStudio"` + // The media the studio has worked on + Media *MediaConnection `json:"media,omitempty"` + // The url for the studio page on the AniList website + SiteURL *string `json:"siteUrl,omitempty"` + // If the studio is marked as favourite by the currently authenticated user + IsFavourite bool `json:"isFavourite"` + // The amount of user's who have favourited the studio + Favourites *int `json:"favourites,omitempty"` +} + +type StudioConnection struct { + Edges []*StudioEdge `json:"edges,omitempty"` + Nodes []*Studio `json:"nodes,omitempty"` + // The pagination information + PageInfo *PageInfo `json:"pageInfo,omitempty"` +} + +// Studio connection edge +type StudioEdge struct { + Node *Studio `json:"node,omitempty"` + // The id of the connection + ID *int `json:"id,omitempty"` + // If the studio is the main animation studio of the anime + IsMain bool `json:"isMain"` + // The order the character should be displayed from the users favourites + FavouriteOrder *int `json:"favouriteOrder,omitempty"` +} + +// User's studio statistics +type StudioStats struct { + Studio *Studio `json:"studio,omitempty"` + Amount *int `json:"amount,omitempty"` + MeanScore *int `json:"meanScore,omitempty"` + // The amount of time in minutes the studio's works have been watched by the user + TimeWatched *int `json:"timeWatched,omitempty"` +} + +// User's tag statistics +type TagStats struct { + Tag *MediaTag `json:"tag,omitempty"` + Amount *int `json:"amount,omitempty"` + MeanScore *int `json:"meanScore,omitempty"` + // The amount of time in minutes the tag has been watched by the user + TimeWatched *int `json:"timeWatched,omitempty"` +} + +// User text activity +type TextActivity struct { + // The id of the activity + ID int `json:"id"` + // The user id of the activity's creator + UserID *int `json:"userId,omitempty"` + // The type of activity + Type *ActivityType `json:"type,omitempty"` + // The number of activity replies + ReplyCount int `json:"replyCount"` + // The status text (Markdown) + Text *string `json:"text,omitempty"` + // The url for the activity page on the AniList website + SiteURL *string `json:"siteUrl,omitempty"` + // If the activity is locked and can receive replies + IsLocked *bool `json:"isLocked,omitempty"` + // If the currently authenticated user is subscribed to the activity + IsSubscribed *bool `json:"isSubscribed,omitempty"` + // The amount of likes the activity has + LikeCount int `json:"likeCount"` + // If the currently authenticated user liked the activity + IsLiked *bool `json:"isLiked,omitempty"` + // If the activity is pinned to the top of the users activity feed + IsPinned *bool `json:"isPinned,omitempty"` + // The time the activity was created at + CreatedAt int `json:"createdAt"` + // The user who created the activity + User *User `json:"user,omitempty"` + // The written replies to the activity + Replies []*ActivityReply `json:"replies,omitempty"` + // The users who liked the activity + Likes []*User `json:"likes,omitempty"` +} + +func (TextActivity) IsActivityUnion() {} + +func (TextActivity) IsLikeableUnion() {} + +// Forum Thread +type Thread struct { + // The id of the thread + ID int `json:"id"` + // The title of the thread + Title *string `json:"title,omitempty"` + // The text body of the thread (Markdown) + Body *string `json:"body,omitempty"` + // The id of the thread owner user + UserID int `json:"userId"` + // The id of the user who most recently commented on the thread + ReplyUserID *int `json:"replyUserId,omitempty"` + // The id of the most recent comment on the thread + ReplyCommentID *int `json:"replyCommentId,omitempty"` + // The number of comments on the thread + ReplyCount *int `json:"replyCount,omitempty"` + // The number of times users have viewed the thread + ViewCount *int `json:"viewCount,omitempty"` + // If the thread is locked and can receive comments + IsLocked *bool `json:"isLocked,omitempty"` + // If the thread is stickied and should be displayed at the top of the page + IsSticky *bool `json:"isSticky,omitempty"` + // If the currently authenticated user is subscribed to the thread + IsSubscribed *bool `json:"isSubscribed,omitempty"` + // The amount of likes the thread has + LikeCount int `json:"likeCount"` + // If the currently authenticated user liked the thread + IsLiked *bool `json:"isLiked,omitempty"` + // The time of the last reply + RepliedAt *int `json:"repliedAt,omitempty"` + // The time of the thread creation + CreatedAt int `json:"createdAt"` + // The time of the thread last update + UpdatedAt int `json:"updatedAt"` + // The owner of the thread + User *User `json:"user,omitempty"` + // The user to last reply to the thread + ReplyUser *User `json:"replyUser,omitempty"` + // The users who liked the thread + Likes []*User `json:"likes,omitempty"` + // The url for the thread page on the AniList website + SiteURL *string `json:"siteUrl,omitempty"` + // The categories of the thread + Categories []*ThreadCategory `json:"categories,omitempty"` + // The media categories of the thread + MediaCategories []*Media `json:"mediaCategories,omitempty"` +} + +func (Thread) IsLikeableUnion() {} + +// A forum thread category +type ThreadCategory struct { + // The id of the category + ID int `json:"id"` + // The name of the category + Name string `json:"name"` +} + +// Forum Thread Comment +type ThreadComment struct { + // The id of the comment + ID int `json:"id"` + // The user id of the comment's owner + UserID *int `json:"userId,omitempty"` + // The id of thread the comment belongs to + ThreadID *int `json:"threadId,omitempty"` + // The text content of the comment (Markdown) + Comment *string `json:"comment,omitempty"` + // The amount of likes the comment has + LikeCount int `json:"likeCount"` + // If the currently authenticated user liked the comment + IsLiked *bool `json:"isLiked,omitempty"` + // The url for the comment page on the AniList website + SiteURL *string `json:"siteUrl,omitempty"` + // The time of the comments creation + CreatedAt int `json:"createdAt"` + // The time of the comments last update + UpdatedAt int `json:"updatedAt"` + // The thread the comment belongs to + Thread *Thread `json:"thread,omitempty"` + // The user who created the comment + User *User `json:"user,omitempty"` + // The users who liked the comment + Likes []*User `json:"likes,omitempty"` + // The comment's child reply comments + ChildComments *string `json:"childComments,omitempty"` + // If the comment tree is locked and may not receive replies or edits + IsLocked *bool `json:"isLocked,omitempty"` +} + +func (ThreadComment) IsLikeableUnion() {} + +// Notification for when a thread comment is liked +type ThreadCommentLikeNotification struct { + // The id of the Notification + ID int `json:"id"` + // The id of the user who liked to the activity + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the activity which was liked + CommentID int `json:"commentId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The thread that the relevant comment belongs to + Thread *Thread `json:"thread,omitempty"` + // The thread comment that was liked + Comment *ThreadComment `json:"comment,omitempty"` + // The user who liked the activity + User *User `json:"user,omitempty"` +} + +func (ThreadCommentLikeNotification) IsNotificationUnion() {} + +// Notification for when authenticated user is @ mentioned in a forum thread comment +type ThreadCommentMentionNotification struct { + // The id of the Notification + ID int `json:"id"` + // The id of the user who mentioned the authenticated user + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the comment where mentioned + CommentID int `json:"commentId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The thread that the relevant comment belongs to + Thread *Thread `json:"thread,omitempty"` + // The thread comment that included the @ mention + Comment *ThreadComment `json:"comment,omitempty"` + // The user who mentioned the authenticated user + User *User `json:"user,omitempty"` +} + +func (ThreadCommentMentionNotification) IsNotificationUnion() {} + +// Notification for when a user replies to your forum thread comment +type ThreadCommentReplyNotification struct { + // The id of the Notification + ID int `json:"id"` + // The id of the user who create the comment reply + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the reply comment + CommentID int `json:"commentId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The thread that the relevant comment belongs to + Thread *Thread `json:"thread,omitempty"` + // The reply thread comment + Comment *ThreadComment `json:"comment,omitempty"` + // The user who replied to the activity + User *User `json:"user,omitempty"` +} + +func (ThreadCommentReplyNotification) IsNotificationUnion() {} + +// Notification for when a user replies to a subscribed forum thread +type ThreadCommentSubscribedNotification struct { + // The id of the Notification + ID int `json:"id"` + // The id of the user who commented on the thread + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the new comment in the subscribed thread + CommentID int `json:"commentId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The thread that the relevant comment belongs to + Thread *Thread `json:"thread,omitempty"` + // The reply thread comment + Comment *ThreadComment `json:"comment,omitempty"` + // The user who replied to the subscribed thread + User *User `json:"user,omitempty"` +} + +func (ThreadCommentSubscribedNotification) IsNotificationUnion() {} + +// Notification for when a thread is liked +type ThreadLikeNotification struct { + // The id of the Notification + ID int `json:"id"` + // The id of the user who liked to the activity + UserID int `json:"userId"` + // The type of notification + Type *NotificationType `json:"type,omitempty"` + // The id of the thread which was liked + ThreadID int `json:"threadId"` + // The notification context text + Context *string `json:"context,omitempty"` + // The time the notification was created at + CreatedAt *int `json:"createdAt,omitempty"` + // The thread that the relevant comment belongs to + Thread *Thread `json:"thread,omitempty"` + // The liked thread comment + Comment *ThreadComment `json:"comment,omitempty"` + // The user who liked the activity + User *User `json:"user,omitempty"` +} + +func (ThreadLikeNotification) IsNotificationUnion() {} + +// A user +type User struct { + // The id of the user + ID int `json:"id"` + // The name of the user + Name string `json:"name"` + // The bio written by user (Markdown) + About *string `json:"about,omitempty"` + // The user's avatar images + Avatar *UserAvatar `json:"avatar,omitempty"` + // The user's banner images + BannerImage *string `json:"bannerImage,omitempty"` + // If the authenticated user if following this user + IsFollowing *bool `json:"isFollowing,omitempty"` + // If this user if following the authenticated user + IsFollower *bool `json:"isFollower,omitempty"` + // If the user is blocked by the authenticated user + IsBlocked *bool `json:"isBlocked,omitempty"` + Bans *string `json:"bans,omitempty"` + // The user's general options + Options *UserOptions `json:"options,omitempty"` + // The user's media list options + MediaListOptions *MediaListOptions `json:"mediaListOptions,omitempty"` + // The users favourites + Favourites *Favourites `json:"favourites,omitempty"` + // The users anime & manga list statistics + Statistics *UserStatisticTypes `json:"statistics,omitempty"` + // The number of unread notifications the user has + UnreadNotificationCount *int `json:"unreadNotificationCount,omitempty"` + // The url for the user page on the AniList website + SiteURL *string `json:"siteUrl,omitempty"` + // The donation tier of the user + DonatorTier *int `json:"donatorTier,omitempty"` + // Custom donation badge text + DonatorBadge *string `json:"donatorBadge,omitempty"` + // The user's moderator roles if they are a site moderator + ModeratorRoles []*ModRole `json:"moderatorRoles,omitempty"` + // When the user's account was created. (Does not exist for accounts created before 2020) + CreatedAt *int `json:"createdAt,omitempty"` + // When the user's data was last updated + UpdatedAt *int `json:"updatedAt,omitempty"` + // The user's statistics + Stats *UserStats `json:"stats,omitempty"` + // If the user is a moderator or data moderator + ModeratorStatus *string `json:"moderatorStatus,omitempty"` + // The user's previously used names. + PreviousNames []*UserPreviousName `json:"previousNames,omitempty"` +} + +// A user's activity history stats. +type UserActivityHistory struct { + // The day the activity took place (Unix timestamp) + Date *int `json:"date,omitempty"` + // The amount of activity on the day + Amount *int `json:"amount,omitempty"` + // The level of activity represented on a 1-10 scale + Level *int `json:"level,omitempty"` +} + +// A user's avatars +type UserAvatar struct { + // The avatar of user at its largest size + Large *string `json:"large,omitempty"` + // The avatar of user at medium size + Medium *string `json:"medium,omitempty"` +} + +type UserCountryStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + Country *string `json:"country,omitempty"` +} + +type UserFormatStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + Format *MediaFormat `json:"format,omitempty"` +} + +type UserGenreStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + Genre *string `json:"genre,omitempty"` +} + +type UserLengthStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + Length *string `json:"length,omitempty"` +} + +// User data for moderators +type UserModData struct { + Alts []*User `json:"alts,omitempty"` + Bans *string `json:"bans,omitempty"` + IP *string `json:"ip,omitempty"` + Counts *string `json:"counts,omitempty"` + Privacy *int `json:"privacy,omitempty"` + Email *string `json:"email,omitempty"` +} + +// A user's general options +type UserOptions struct { + // The language the user wants to see media titles in + TitleLanguage *UserTitleLanguage `json:"titleLanguage,omitempty"` + // Whether the user has enabled viewing of 18+ content + DisplayAdultContent *bool `json:"displayAdultContent,omitempty"` + // Whether the user receives notifications when a show they are watching aires + AiringNotifications *bool `json:"airingNotifications,omitempty"` + // Profile highlight color (blue, purple, pink, orange, red, green, gray) + ProfileColor *string `json:"profileColor,omitempty"` + // Notification options + NotificationOptions []*NotificationOption `json:"notificationOptions,omitempty"` + // The user's timezone offset (Auth user only) + Timezone *string `json:"timezone,omitempty"` + // Minutes between activity for them to be merged together. 0 is Never, Above 2 weeks (20160 mins) is Always. + ActivityMergeTime *int `json:"activityMergeTime,omitempty"` + // The language the user wants to see staff and character names in + StaffNameLanguage *UserStaffNameLanguage `json:"staffNameLanguage,omitempty"` + // Whether the user only allow messages from users they follow + RestrictMessagesToFollowing *bool `json:"restrictMessagesToFollowing,omitempty"` + // The list activity types the user has disabled from being created from list updates + DisabledListActivity []*ListActivityOption `json:"disabledListActivity,omitempty"` +} + +// A user's previous name +type UserPreviousName struct { + // A previous name of the user. + Name *string `json:"name,omitempty"` + // When the user first changed from this name. + CreatedAt *int `json:"createdAt,omitempty"` + // When the user most recently changed from this name. + UpdatedAt *int `json:"updatedAt,omitempty"` +} + +type UserReleaseYearStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + ReleaseYear *int `json:"releaseYear,omitempty"` +} + +type UserScoreStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + Score *int `json:"score,omitempty"` +} + +type UserStaffStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + Staff *Staff `json:"staff,omitempty"` +} + +type UserStartYearStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + StartYear *int `json:"startYear,omitempty"` +} + +type UserStatisticTypes struct { + Anime *UserStatistics `json:"anime,omitempty"` + Manga *UserStatistics `json:"manga,omitempty"` +} + +type UserStatistics struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + StandardDeviation float64 `json:"standardDeviation"` + MinutesWatched int `json:"minutesWatched"` + EpisodesWatched int `json:"episodesWatched"` + ChaptersRead int `json:"chaptersRead"` + VolumesRead int `json:"volumesRead"` + Formats []*UserFormatStatistic `json:"formats,omitempty"` + Statuses []*UserStatusStatistic `json:"statuses,omitempty"` + Scores []*UserScoreStatistic `json:"scores,omitempty"` + Lengths []*UserLengthStatistic `json:"lengths,omitempty"` + ReleaseYears []*UserReleaseYearStatistic `json:"releaseYears,omitempty"` + StartYears []*UserStartYearStatistic `json:"startYears,omitempty"` + Genres []*UserGenreStatistic `json:"genres,omitempty"` + Tags []*UserTagStatistic `json:"tags,omitempty"` + Countries []*UserCountryStatistic `json:"countries,omitempty"` + VoiceActors []*UserVoiceActorStatistic `json:"voiceActors,omitempty"` + Staff []*UserStaffStatistic `json:"staff,omitempty"` + Studios []*UserStudioStatistic `json:"studios,omitempty"` +} + +// A user's statistics +type UserStats struct { + // The amount of anime the user has watched in minutes + WatchedTime *int `json:"watchedTime,omitempty"` + // The amount of manga chapters the user has read + ChaptersRead *int `json:"chaptersRead,omitempty"` + ActivityHistory []*UserActivityHistory `json:"activityHistory,omitempty"` + AnimeStatusDistribution []*StatusDistribution `json:"animeStatusDistribution,omitempty"` + MangaStatusDistribution []*StatusDistribution `json:"mangaStatusDistribution,omitempty"` + AnimeScoreDistribution []*ScoreDistribution `json:"animeScoreDistribution,omitempty"` + MangaScoreDistribution []*ScoreDistribution `json:"mangaScoreDistribution,omitempty"` + AnimeListScores *ListScoreStats `json:"animeListScores,omitempty"` + MangaListScores *ListScoreStats `json:"mangaListScores,omitempty"` + FavouredGenresOverview []*GenreStats `json:"favouredGenresOverview,omitempty"` + FavouredGenres []*GenreStats `json:"favouredGenres,omitempty"` + FavouredTags []*TagStats `json:"favouredTags,omitempty"` + FavouredActors []*StaffStats `json:"favouredActors,omitempty"` + FavouredStaff []*StaffStats `json:"favouredStaff,omitempty"` + FavouredStudios []*StudioStats `json:"favouredStudios,omitempty"` + FavouredYears []*YearStats `json:"favouredYears,omitempty"` + FavouredFormats []*FormatStats `json:"favouredFormats,omitempty"` +} + +type UserStatusStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + Status *MediaListStatus `json:"status,omitempty"` +} + +type UserStudioStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + Studio *Studio `json:"studio,omitempty"` +} + +type UserTagStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + Tag *MediaTag `json:"tag,omitempty"` +} + +type UserVoiceActorStatistic struct { + Count int `json:"count"` + MeanScore float64 `json:"meanScore"` + MinutesWatched int `json:"minutesWatched"` + ChaptersRead int `json:"chaptersRead"` + MediaIds []*int `json:"mediaIds"` + VoiceActor *Staff `json:"voiceActor,omitempty"` + CharacterIds []*int `json:"characterIds"` +} + +// User's year statistics +type YearStats struct { + Year *int `json:"year,omitempty"` + Amount *int `json:"amount,omitempty"` + MeanScore *int `json:"meanScore,omitempty"` +} + +// Activity sort enums +type ActivitySort string + +const ( + ActivitySortID ActivitySort = "ID" + ActivitySortIDDesc ActivitySort = "ID_DESC" + ActivitySortPinned ActivitySort = "PINNED" +) + +var AllActivitySort = []ActivitySort{ + ActivitySortID, + ActivitySortIDDesc, + ActivitySortPinned, +} + +func (e ActivitySort) IsValid() bool { + switch e { + case ActivitySortID, ActivitySortIDDesc, ActivitySortPinned: + return true + } + return false +} + +func (e ActivitySort) String() string { + return string(e) +} + +func (e *ActivitySort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ActivitySort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ActivitySort", str) + } + return nil +} + +func (e ActivitySort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Activity type enum. +type ActivityType string + +const ( + // A text activity + ActivityTypeText ActivityType = "TEXT" + // A anime list update activity + ActivityTypeAnimeList ActivityType = "ANIME_LIST" + // A manga list update activity + ActivityTypeMangaList ActivityType = "MANGA_LIST" + // A text message activity sent to another user + ActivityTypeMessage ActivityType = "MESSAGE" + // Anime & Manga list update, only used in query arguments + ActivityTypeMediaList ActivityType = "MEDIA_LIST" +) + +var AllActivityType = []ActivityType{ + ActivityTypeText, + ActivityTypeAnimeList, + ActivityTypeMangaList, + ActivityTypeMessage, + ActivityTypeMediaList, +} + +func (e ActivityType) IsValid() bool { + switch e { + case ActivityTypeText, ActivityTypeAnimeList, ActivityTypeMangaList, ActivityTypeMessage, ActivityTypeMediaList: + return true + } + return false +} + +func (e ActivityType) String() string { + return string(e) +} + +func (e *ActivityType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ActivityType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ActivityType", str) + } + return nil +} + +func (e ActivityType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Airing schedule sort enums +type AiringSort string + +const ( + AiringSortID AiringSort = "ID" + AiringSortIDDesc AiringSort = "ID_DESC" + AiringSortMediaID AiringSort = "MEDIA_ID" + AiringSortMediaIDDesc AiringSort = "MEDIA_ID_DESC" + AiringSortTime AiringSort = "TIME" + AiringSortTimeDesc AiringSort = "TIME_DESC" + AiringSortEpisode AiringSort = "EPISODE" + AiringSortEpisodeDesc AiringSort = "EPISODE_DESC" +) + +var AllAiringSort = []AiringSort{ + AiringSortID, + AiringSortIDDesc, + AiringSortMediaID, + AiringSortMediaIDDesc, + AiringSortTime, + AiringSortTimeDesc, + AiringSortEpisode, + AiringSortEpisodeDesc, +} + +func (e AiringSort) IsValid() bool { + switch e { + case AiringSortID, AiringSortIDDesc, AiringSortMediaID, AiringSortMediaIDDesc, AiringSortTime, AiringSortTimeDesc, AiringSortEpisode, AiringSortEpisodeDesc: + return true + } + return false +} + +func (e AiringSort) String() string { + return string(e) +} + +func (e *AiringSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = AiringSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid AiringSort", str) + } + return nil +} + +func (e AiringSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// The role the character plays in the media +type CharacterRole string + +const ( + // A primary character role in the media + CharacterRoleMain CharacterRole = "MAIN" + // A supporting character role in the media + CharacterRoleSupporting CharacterRole = "SUPPORTING" + // A background character in the media + CharacterRoleBackground CharacterRole = "BACKGROUND" +) + +var AllCharacterRole = []CharacterRole{ + CharacterRoleMain, + CharacterRoleSupporting, + CharacterRoleBackground, +} + +func (e CharacterRole) IsValid() bool { + switch e { + case CharacterRoleMain, CharacterRoleSupporting, CharacterRoleBackground: + return true + } + return false +} + +func (e CharacterRole) String() string { + return string(e) +} + +func (e *CharacterRole) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = CharacterRole(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid CharacterRole", str) + } + return nil +} + +func (e CharacterRole) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Character sort enums +type CharacterSort string + +const ( + CharacterSortID CharacterSort = "ID" + CharacterSortIDDesc CharacterSort = "ID_DESC" + CharacterSortRole CharacterSort = "ROLE" + CharacterSortRoleDesc CharacterSort = "ROLE_DESC" + CharacterSortSearchMatch CharacterSort = "SEARCH_MATCH" + CharacterSortFavourites CharacterSort = "FAVOURITES" + CharacterSortFavouritesDesc CharacterSort = "FAVOURITES_DESC" + // Order manually decided by moderators + CharacterSortRelevance CharacterSort = "RELEVANCE" +) + +var AllCharacterSort = []CharacterSort{ + CharacterSortID, + CharacterSortIDDesc, + CharacterSortRole, + CharacterSortRoleDesc, + CharacterSortSearchMatch, + CharacterSortFavourites, + CharacterSortFavouritesDesc, + CharacterSortRelevance, +} + +func (e CharacterSort) IsValid() bool { + switch e { + case CharacterSortID, CharacterSortIDDesc, CharacterSortRole, CharacterSortRoleDesc, CharacterSortSearchMatch, CharacterSortFavourites, CharacterSortFavouritesDesc, CharacterSortRelevance: + return true + } + return false +} + +func (e CharacterSort) String() string { + return string(e) +} + +func (e *CharacterSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = CharacterSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid CharacterSort", str) + } + return nil +} + +func (e CharacterSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type ExternalLinkMediaType string + +const ( + ExternalLinkMediaTypeAnime ExternalLinkMediaType = "ANIME" + ExternalLinkMediaTypeManga ExternalLinkMediaType = "MANGA" + ExternalLinkMediaTypeStaff ExternalLinkMediaType = "STAFF" +) + +var AllExternalLinkMediaType = []ExternalLinkMediaType{ + ExternalLinkMediaTypeAnime, + ExternalLinkMediaTypeManga, + ExternalLinkMediaTypeStaff, +} + +func (e ExternalLinkMediaType) IsValid() bool { + switch e { + case ExternalLinkMediaTypeAnime, ExternalLinkMediaTypeManga, ExternalLinkMediaTypeStaff: + return true + } + return false +} + +func (e ExternalLinkMediaType) String() string { + return string(e) +} + +func (e *ExternalLinkMediaType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ExternalLinkMediaType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ExternalLinkMediaType", str) + } + return nil +} + +func (e ExternalLinkMediaType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type ExternalLinkType string + +const ( + ExternalLinkTypeInfo ExternalLinkType = "INFO" + ExternalLinkTypeStreaming ExternalLinkType = "STREAMING" + ExternalLinkTypeSocial ExternalLinkType = "SOCIAL" +) + +var AllExternalLinkType = []ExternalLinkType{ + ExternalLinkTypeInfo, + ExternalLinkTypeStreaming, + ExternalLinkTypeSocial, +} + +func (e ExternalLinkType) IsValid() bool { + switch e { + case ExternalLinkTypeInfo, ExternalLinkTypeStreaming, ExternalLinkTypeSocial: + return true + } + return false +} + +func (e ExternalLinkType) String() string { + return string(e) +} + +func (e *ExternalLinkType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ExternalLinkType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ExternalLinkType", str) + } + return nil +} + +func (e ExternalLinkType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Types that can be liked +type LikeableType string + +const ( + LikeableTypeThread LikeableType = "THREAD" + LikeableTypeThreadComment LikeableType = "THREAD_COMMENT" + LikeableTypeActivity LikeableType = "ACTIVITY" + LikeableTypeActivityReply LikeableType = "ACTIVITY_REPLY" +) + +var AllLikeableType = []LikeableType{ + LikeableTypeThread, + LikeableTypeThreadComment, + LikeableTypeActivity, + LikeableTypeActivityReply, +} + +func (e LikeableType) IsValid() bool { + switch e { + case LikeableTypeThread, LikeableTypeThreadComment, LikeableTypeActivity, LikeableTypeActivityReply: + return true + } + return false +} + +func (e LikeableType) String() string { + return string(e) +} + +func (e *LikeableType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = LikeableType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid LikeableType", str) + } + return nil +} + +func (e LikeableType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// The format the media was released in +type MediaFormat string + +const ( + // Anime broadcast on television + MediaFormatTv MediaFormat = "TV" + // Anime which are under 15 minutes in length and broadcast on television + MediaFormatTvShort MediaFormat = "TV_SHORT" + // Anime movies with a theatrical release + MediaFormatMovie MediaFormat = "MOVIE" + // Special episodes that have been included in DVD/Blu-ray releases, picture dramas, pilots, etc + MediaFormatSpecial MediaFormat = "SPECIAL" + // (Original Video Animation) Anime that have been released directly on DVD/Blu-ray without originally going through a theatrical release or television broadcast + MediaFormatOva MediaFormat = "OVA" + // (Original Net Animation) Anime that have been originally released online or are only available through streaming services. + MediaFormatOna MediaFormat = "ONA" + // Short anime released as a music video + MediaFormatMusic MediaFormat = "MUSIC" + // Professionally published manga with more than one chapter + MediaFormatManga MediaFormat = "MANGA" + // Written books released as a series of light novels + MediaFormatNovel MediaFormat = "NOVEL" + // Manga with just one chapter + MediaFormatOneShot MediaFormat = "ONE_SHOT" +) + +var AllMediaFormat = []MediaFormat{ + MediaFormatTv, + MediaFormatTvShort, + MediaFormatMovie, + MediaFormatSpecial, + MediaFormatOva, + MediaFormatOna, + MediaFormatMusic, + MediaFormatManga, + MediaFormatNovel, + MediaFormatOneShot, +} + +func (e MediaFormat) IsValid() bool { + switch e { + case MediaFormatTv, MediaFormatTvShort, MediaFormatMovie, MediaFormatSpecial, MediaFormatOva, MediaFormatOna, MediaFormatMusic, MediaFormatManga, MediaFormatNovel, MediaFormatOneShot: + return true + } + return false +} + +func (e MediaFormat) String() string { + return string(e) +} + +func (e *MediaFormat) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = MediaFormat(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid MediaFormat", str) + } + return nil +} + +func (e MediaFormat) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Media list sort enums +type MediaListSort string + +const ( + MediaListSortMediaID MediaListSort = "MEDIA_ID" + MediaListSortMediaIDDesc MediaListSort = "MEDIA_ID_DESC" + MediaListSortScore MediaListSort = "SCORE" + MediaListSortScoreDesc MediaListSort = "SCORE_DESC" + MediaListSortStatus MediaListSort = "STATUS" + MediaListSortStatusDesc MediaListSort = "STATUS_DESC" + MediaListSortProgress MediaListSort = "PROGRESS" + MediaListSortProgressDesc MediaListSort = "PROGRESS_DESC" + MediaListSortProgressVolumes MediaListSort = "PROGRESS_VOLUMES" + MediaListSortProgressVolumesDesc MediaListSort = "PROGRESS_VOLUMES_DESC" + MediaListSortRepeat MediaListSort = "REPEAT" + MediaListSortRepeatDesc MediaListSort = "REPEAT_DESC" + MediaListSortPriority MediaListSort = "PRIORITY" + MediaListSortPriorityDesc MediaListSort = "PRIORITY_DESC" + MediaListSortStartedOn MediaListSort = "STARTED_ON" + MediaListSortStartedOnDesc MediaListSort = "STARTED_ON_DESC" + MediaListSortFinishedOn MediaListSort = "FINISHED_ON" + MediaListSortFinishedOnDesc MediaListSort = "FINISHED_ON_DESC" + MediaListSortAddedTime MediaListSort = "ADDED_TIME" + MediaListSortAddedTimeDesc MediaListSort = "ADDED_TIME_DESC" + MediaListSortUpdatedTime MediaListSort = "UPDATED_TIME" + MediaListSortUpdatedTimeDesc MediaListSort = "UPDATED_TIME_DESC" + MediaListSortMediaTitleRomaji MediaListSort = "MEDIA_TITLE_ROMAJI" + MediaListSortMediaTitleRomajiDesc MediaListSort = "MEDIA_TITLE_ROMAJI_DESC" + MediaListSortMediaTitleEnglish MediaListSort = "MEDIA_TITLE_ENGLISH" + MediaListSortMediaTitleEnglishDesc MediaListSort = "MEDIA_TITLE_ENGLISH_DESC" + MediaListSortMediaTitleNative MediaListSort = "MEDIA_TITLE_NATIVE" + MediaListSortMediaTitleNativeDesc MediaListSort = "MEDIA_TITLE_NATIVE_DESC" + MediaListSortMediaPopularity MediaListSort = "MEDIA_POPULARITY" + MediaListSortMediaPopularityDesc MediaListSort = "MEDIA_POPULARITY_DESC" +) + +var AllMediaListSort = []MediaListSort{ + MediaListSortMediaID, + MediaListSortMediaIDDesc, + MediaListSortScore, + MediaListSortScoreDesc, + MediaListSortStatus, + MediaListSortStatusDesc, + MediaListSortProgress, + MediaListSortProgressDesc, + MediaListSortProgressVolumes, + MediaListSortProgressVolumesDesc, + MediaListSortRepeat, + MediaListSortRepeatDesc, + MediaListSortPriority, + MediaListSortPriorityDesc, + MediaListSortStartedOn, + MediaListSortStartedOnDesc, + MediaListSortFinishedOn, + MediaListSortFinishedOnDesc, + MediaListSortAddedTime, + MediaListSortAddedTimeDesc, + MediaListSortUpdatedTime, + MediaListSortUpdatedTimeDesc, + MediaListSortMediaTitleRomaji, + MediaListSortMediaTitleRomajiDesc, + MediaListSortMediaTitleEnglish, + MediaListSortMediaTitleEnglishDesc, + MediaListSortMediaTitleNative, + MediaListSortMediaTitleNativeDesc, + MediaListSortMediaPopularity, + MediaListSortMediaPopularityDesc, +} + +func (e MediaListSort) IsValid() bool { + switch e { + case MediaListSortMediaID, MediaListSortMediaIDDesc, MediaListSortScore, MediaListSortScoreDesc, MediaListSortStatus, MediaListSortStatusDesc, MediaListSortProgress, MediaListSortProgressDesc, MediaListSortProgressVolumes, MediaListSortProgressVolumesDesc, MediaListSortRepeat, MediaListSortRepeatDesc, MediaListSortPriority, MediaListSortPriorityDesc, MediaListSortStartedOn, MediaListSortStartedOnDesc, MediaListSortFinishedOn, MediaListSortFinishedOnDesc, MediaListSortAddedTime, MediaListSortAddedTimeDesc, MediaListSortUpdatedTime, MediaListSortUpdatedTimeDesc, MediaListSortMediaTitleRomaji, MediaListSortMediaTitleRomajiDesc, MediaListSortMediaTitleEnglish, MediaListSortMediaTitleEnglishDesc, MediaListSortMediaTitleNative, MediaListSortMediaTitleNativeDesc, MediaListSortMediaPopularity, MediaListSortMediaPopularityDesc: + return true + } + return false +} + +func (e MediaListSort) String() string { + return string(e) +} + +func (e *MediaListSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = MediaListSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid MediaListSort", str) + } + return nil +} + +func (e MediaListSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Media list watching/reading status enum. +type MediaListStatus string + +const ( + // Currently watching/reading + MediaListStatusCurrent MediaListStatus = "CURRENT" + // Planning to watch/read + MediaListStatusPlanning MediaListStatus = "PLANNING" + // Finished watching/reading + MediaListStatusCompleted MediaListStatus = "COMPLETED" + // Stopped watching/reading before completing + MediaListStatusDropped MediaListStatus = "DROPPED" + // Paused watching/reading + MediaListStatusPaused MediaListStatus = "PAUSED" + // Re-watching/reading + MediaListStatusRepeating MediaListStatus = "REPEATING" +) + +var AllMediaListStatus = []MediaListStatus{ + MediaListStatusCurrent, + MediaListStatusPlanning, + MediaListStatusCompleted, + MediaListStatusDropped, + MediaListStatusPaused, + MediaListStatusRepeating, +} + +func (e MediaListStatus) IsValid() bool { + switch e { + case MediaListStatusCurrent, MediaListStatusPlanning, MediaListStatusCompleted, MediaListStatusDropped, MediaListStatusPaused, MediaListStatusRepeating: + return true + } + return false +} + +func (e MediaListStatus) String() string { + return string(e) +} + +func (e *MediaListStatus) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = MediaListStatus(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid MediaListStatus", str) + } + return nil +} + +func (e MediaListStatus) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// The type of ranking +type MediaRankType string + +const ( + // Ranking is based on the media's ratings/score + MediaRankTypeRated MediaRankType = "RATED" + // Ranking is based on the media's popularity + MediaRankTypePopular MediaRankType = "POPULAR" +) + +var AllMediaRankType = []MediaRankType{ + MediaRankTypeRated, + MediaRankTypePopular, +} + +func (e MediaRankType) IsValid() bool { + switch e { + case MediaRankTypeRated, MediaRankTypePopular: + return true + } + return false +} + +func (e MediaRankType) String() string { + return string(e) +} + +func (e *MediaRankType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = MediaRankType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid MediaRankType", str) + } + return nil +} + +func (e MediaRankType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Type of relation media has to its parent. +type MediaRelation string + +const ( + // An adaption of this media into a different format + MediaRelationAdaptation MediaRelation = "ADAPTATION" + // Released before the relation + MediaRelationPrequel MediaRelation = "PREQUEL" + // Released after the relation + MediaRelationSequel MediaRelation = "SEQUEL" + // The media a side story is from + MediaRelationParent MediaRelation = "PARENT" + // A side story of the parent media + MediaRelationSideStory MediaRelation = "SIDE_STORY" + // Shares at least 1 character + MediaRelationCharacter MediaRelation = "CHARACTER" + // A shortened and summarized version + MediaRelationSummary MediaRelation = "SUMMARY" + // An alternative version of the same media + MediaRelationAlternative MediaRelation = "ALTERNATIVE" + // An alternative version of the media with a different primary focus + MediaRelationSpinOff MediaRelation = "SPIN_OFF" + // Other + MediaRelationOther MediaRelation = "OTHER" + // Version 2 only. The source material the media was adapted from + MediaRelationSource MediaRelation = "SOURCE" + // Version 2 only. + MediaRelationCompilation MediaRelation = "COMPILATION" + // Version 2 only. + MediaRelationContains MediaRelation = "CONTAINS" +) + +var AllMediaRelation = []MediaRelation{ + MediaRelationAdaptation, + MediaRelationPrequel, + MediaRelationSequel, + MediaRelationParent, + MediaRelationSideStory, + MediaRelationCharacter, + MediaRelationSummary, + MediaRelationAlternative, + MediaRelationSpinOff, + MediaRelationOther, + MediaRelationSource, + MediaRelationCompilation, + MediaRelationContains, +} + +func (e MediaRelation) IsValid() bool { + switch e { + case MediaRelationAdaptation, MediaRelationPrequel, MediaRelationSequel, MediaRelationParent, MediaRelationSideStory, MediaRelationCharacter, MediaRelationSummary, MediaRelationAlternative, MediaRelationSpinOff, MediaRelationOther, MediaRelationSource, MediaRelationCompilation, MediaRelationContains: + return true + } + return false +} + +func (e MediaRelation) String() string { + return string(e) +} + +func (e *MediaRelation) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = MediaRelation(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid MediaRelation", str) + } + return nil +} + +func (e MediaRelation) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type MediaSeason string + +const ( + // Months December to February + MediaSeasonWinter MediaSeason = "WINTER" + // Months March to May + MediaSeasonSpring MediaSeason = "SPRING" + // Months June to August + MediaSeasonSummer MediaSeason = "SUMMER" + // Months September to November + MediaSeasonFall MediaSeason = "FALL" +) + +var AllMediaSeason = []MediaSeason{ + MediaSeasonWinter, + MediaSeasonSpring, + MediaSeasonSummer, + MediaSeasonFall, +} + +func (e MediaSeason) IsValid() bool { + switch e { + case MediaSeasonWinter, MediaSeasonSpring, MediaSeasonSummer, MediaSeasonFall: + return true + } + return false +} + +func (e MediaSeason) String() string { + return string(e) +} + +func (e *MediaSeason) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = MediaSeason(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid MediaSeason", str) + } + return nil +} + +func (e MediaSeason) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Media sort enums +type MediaSort string + +const ( + MediaSortID MediaSort = "ID" + MediaSortIDDesc MediaSort = "ID_DESC" + MediaSortTitleRomaji MediaSort = "TITLE_ROMAJI" + MediaSortTitleRomajiDesc MediaSort = "TITLE_ROMAJI_DESC" + MediaSortTitleEnglish MediaSort = "TITLE_ENGLISH" + MediaSortTitleEnglishDesc MediaSort = "TITLE_ENGLISH_DESC" + MediaSortTitleNative MediaSort = "TITLE_NATIVE" + MediaSortTitleNativeDesc MediaSort = "TITLE_NATIVE_DESC" + MediaSortType MediaSort = "TYPE" + MediaSortTypeDesc MediaSort = "TYPE_DESC" + MediaSortFormat MediaSort = "FORMAT" + MediaSortFormatDesc MediaSort = "FORMAT_DESC" + MediaSortStartDate MediaSort = "START_DATE" + MediaSortStartDateDesc MediaSort = "START_DATE_DESC" + MediaSortEndDate MediaSort = "END_DATE" + MediaSortEndDateDesc MediaSort = "END_DATE_DESC" + MediaSortScore MediaSort = "SCORE" + MediaSortScoreDesc MediaSort = "SCORE_DESC" + MediaSortPopularity MediaSort = "POPULARITY" + MediaSortPopularityDesc MediaSort = "POPULARITY_DESC" + MediaSortTrending MediaSort = "TRENDING" + MediaSortTrendingDesc MediaSort = "TRENDING_DESC" + MediaSortEpisodes MediaSort = "EPISODES" + MediaSortEpisodesDesc MediaSort = "EPISODES_DESC" + MediaSortDuration MediaSort = "DURATION" + MediaSortDurationDesc MediaSort = "DURATION_DESC" + MediaSortStatus MediaSort = "STATUS" + MediaSortStatusDesc MediaSort = "STATUS_DESC" + MediaSortChapters MediaSort = "CHAPTERS" + MediaSortChaptersDesc MediaSort = "CHAPTERS_DESC" + MediaSortVolumes MediaSort = "VOLUMES" + MediaSortVolumesDesc MediaSort = "VOLUMES_DESC" + MediaSortUpdatedAt MediaSort = "UPDATED_AT" + MediaSortUpdatedAtDesc MediaSort = "UPDATED_AT_DESC" + MediaSortSearchMatch MediaSort = "SEARCH_MATCH" + MediaSortFavourites MediaSort = "FAVOURITES" + MediaSortFavouritesDesc MediaSort = "FAVOURITES_DESC" +) + +var AllMediaSort = []MediaSort{ + MediaSortID, + MediaSortIDDesc, + MediaSortTitleRomaji, + MediaSortTitleRomajiDesc, + MediaSortTitleEnglish, + MediaSortTitleEnglishDesc, + MediaSortTitleNative, + MediaSortTitleNativeDesc, + MediaSortType, + MediaSortTypeDesc, + MediaSortFormat, + MediaSortFormatDesc, + MediaSortStartDate, + MediaSortStartDateDesc, + MediaSortEndDate, + MediaSortEndDateDesc, + MediaSortScore, + MediaSortScoreDesc, + MediaSortPopularity, + MediaSortPopularityDesc, + MediaSortTrending, + MediaSortTrendingDesc, + MediaSortEpisodes, + MediaSortEpisodesDesc, + MediaSortDuration, + MediaSortDurationDesc, + MediaSortStatus, + MediaSortStatusDesc, + MediaSortChapters, + MediaSortChaptersDesc, + MediaSortVolumes, + MediaSortVolumesDesc, + MediaSortUpdatedAt, + MediaSortUpdatedAtDesc, + MediaSortSearchMatch, + MediaSortFavourites, + MediaSortFavouritesDesc, +} + +func (e MediaSort) IsValid() bool { + switch e { + case MediaSortID, MediaSortIDDesc, MediaSortTitleRomaji, MediaSortTitleRomajiDesc, MediaSortTitleEnglish, MediaSortTitleEnglishDesc, MediaSortTitleNative, MediaSortTitleNativeDesc, MediaSortType, MediaSortTypeDesc, MediaSortFormat, MediaSortFormatDesc, MediaSortStartDate, MediaSortStartDateDesc, MediaSortEndDate, MediaSortEndDateDesc, MediaSortScore, MediaSortScoreDesc, MediaSortPopularity, MediaSortPopularityDesc, MediaSortTrending, MediaSortTrendingDesc, MediaSortEpisodes, MediaSortEpisodesDesc, MediaSortDuration, MediaSortDurationDesc, MediaSortStatus, MediaSortStatusDesc, MediaSortChapters, MediaSortChaptersDesc, MediaSortVolumes, MediaSortVolumesDesc, MediaSortUpdatedAt, MediaSortUpdatedAtDesc, MediaSortSearchMatch, MediaSortFavourites, MediaSortFavouritesDesc: + return true + } + return false +} + +func (e MediaSort) String() string { + return string(e) +} + +func (e *MediaSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = MediaSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid MediaSort", str) + } + return nil +} + +func (e MediaSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Source type the media was adapted from +type MediaSource string + +const ( + // An original production not based of another work + MediaSourceOriginal MediaSource = "ORIGINAL" + // Asian comic book + MediaSourceManga MediaSource = "MANGA" + // Written work published in volumes + MediaSourceLightNovel MediaSource = "LIGHT_NOVEL" + // Video game driven primary by text and narrative + MediaSourceVisualNovel MediaSource = "VISUAL_NOVEL" + // Video game + MediaSourceVideoGame MediaSource = "VIDEO_GAME" + // Other + MediaSourceOther MediaSource = "OTHER" + // Version 2+ only. Written works not published in volumes + MediaSourceNovel MediaSource = "NOVEL" + // Version 2+ only. Self-published works + MediaSourceDoujinshi MediaSource = "DOUJINSHI" + // Version 2+ only. Japanese Anime + MediaSourceAnime MediaSource = "ANIME" + // Version 3 only. Written works published online + MediaSourceWebNovel MediaSource = "WEB_NOVEL" + // Version 3 only. Live action media such as movies or TV show + MediaSourceLiveAction MediaSource = "LIVE_ACTION" + // Version 3 only. Games excluding video games + MediaSourceGame MediaSource = "GAME" + // Version 3 only. Comics excluding manga + MediaSourceComic MediaSource = "COMIC" + // Version 3 only. Multimedia project + MediaSourceMultimediaProject MediaSource = "MULTIMEDIA_PROJECT" + // Version 3 only. Picture book + MediaSourcePictureBook MediaSource = "PICTURE_BOOK" +) + +var AllMediaSource = []MediaSource{ + MediaSourceOriginal, + MediaSourceManga, + MediaSourceLightNovel, + MediaSourceVisualNovel, + MediaSourceVideoGame, + MediaSourceOther, + MediaSourceNovel, + MediaSourceDoujinshi, + MediaSourceAnime, + MediaSourceWebNovel, + MediaSourceLiveAction, + MediaSourceGame, + MediaSourceComic, + MediaSourceMultimediaProject, + MediaSourcePictureBook, +} + +func (e MediaSource) IsValid() bool { + switch e { + case MediaSourceOriginal, MediaSourceManga, MediaSourceLightNovel, MediaSourceVisualNovel, MediaSourceVideoGame, MediaSourceOther, MediaSourceNovel, MediaSourceDoujinshi, MediaSourceAnime, MediaSourceWebNovel, MediaSourceLiveAction, MediaSourceGame, MediaSourceComic, MediaSourceMultimediaProject, MediaSourcePictureBook: + return true + } + return false +} + +func (e MediaSource) String() string { + return string(e) +} + +func (e *MediaSource) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = MediaSource(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid MediaSource", str) + } + return nil +} + +func (e MediaSource) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// The current releasing status of the media +type MediaStatus string + +const ( + // Has completed and is no longer being released + MediaStatusFinished MediaStatus = "FINISHED" + // Currently releasing + MediaStatusReleasing MediaStatus = "RELEASING" + // To be released at a later date + MediaStatusNotYetReleased MediaStatus = "NOT_YET_RELEASED" + // Ended before the work could be finished + MediaStatusCancelled MediaStatus = "CANCELLED" + // Version 2 only. Is currently paused from releasing and will resume at a later date + MediaStatusHiatus MediaStatus = "HIATUS" +) + +var AllMediaStatus = []MediaStatus{ + MediaStatusFinished, + MediaStatusReleasing, + MediaStatusNotYetReleased, + MediaStatusCancelled, + MediaStatusHiatus, +} + +func (e MediaStatus) IsValid() bool { + switch e { + case MediaStatusFinished, MediaStatusReleasing, MediaStatusNotYetReleased, MediaStatusCancelled, MediaStatusHiatus: + return true + } + return false +} + +func (e MediaStatus) String() string { + return string(e) +} + +func (e *MediaStatus) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = MediaStatus(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid MediaStatus", str) + } + return nil +} + +func (e MediaStatus) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Media trend sort enums +type MediaTrendSort string + +const ( + MediaTrendSortID MediaTrendSort = "ID" + MediaTrendSortIDDesc MediaTrendSort = "ID_DESC" + MediaTrendSortMediaID MediaTrendSort = "MEDIA_ID" + MediaTrendSortMediaIDDesc MediaTrendSort = "MEDIA_ID_DESC" + MediaTrendSortDate MediaTrendSort = "DATE" + MediaTrendSortDateDesc MediaTrendSort = "DATE_DESC" + MediaTrendSortScore MediaTrendSort = "SCORE" + MediaTrendSortScoreDesc MediaTrendSort = "SCORE_DESC" + MediaTrendSortPopularity MediaTrendSort = "POPULARITY" + MediaTrendSortPopularityDesc MediaTrendSort = "POPULARITY_DESC" + MediaTrendSortTrending MediaTrendSort = "TRENDING" + MediaTrendSortTrendingDesc MediaTrendSort = "TRENDING_DESC" + MediaTrendSortEpisode MediaTrendSort = "EPISODE" + MediaTrendSortEpisodeDesc MediaTrendSort = "EPISODE_DESC" +) + +var AllMediaTrendSort = []MediaTrendSort{ + MediaTrendSortID, + MediaTrendSortIDDesc, + MediaTrendSortMediaID, + MediaTrendSortMediaIDDesc, + MediaTrendSortDate, + MediaTrendSortDateDesc, + MediaTrendSortScore, + MediaTrendSortScoreDesc, + MediaTrendSortPopularity, + MediaTrendSortPopularityDesc, + MediaTrendSortTrending, + MediaTrendSortTrendingDesc, + MediaTrendSortEpisode, + MediaTrendSortEpisodeDesc, +} + +func (e MediaTrendSort) IsValid() bool { + switch e { + case MediaTrendSortID, MediaTrendSortIDDesc, MediaTrendSortMediaID, MediaTrendSortMediaIDDesc, MediaTrendSortDate, MediaTrendSortDateDesc, MediaTrendSortScore, MediaTrendSortScoreDesc, MediaTrendSortPopularity, MediaTrendSortPopularityDesc, MediaTrendSortTrending, MediaTrendSortTrendingDesc, MediaTrendSortEpisode, MediaTrendSortEpisodeDesc: + return true + } + return false +} + +func (e MediaTrendSort) String() string { + return string(e) +} + +func (e *MediaTrendSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = MediaTrendSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid MediaTrendSort", str) + } + return nil +} + +func (e MediaTrendSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Media type enum, anime or manga. +type MediaType string + +const ( + // Japanese Anime + MediaTypeAnime MediaType = "ANIME" + // Asian comic + MediaTypeManga MediaType = "MANGA" +) + +var AllMediaType = []MediaType{ + MediaTypeAnime, + MediaTypeManga, +} + +func (e MediaType) IsValid() bool { + switch e { + case MediaTypeAnime, MediaTypeManga: + return true + } + return false +} + +func (e MediaType) String() string { + return string(e) +} + +func (e *MediaType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = MediaType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid MediaType", str) + } + return nil +} + +func (e MediaType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type ModActionType string + +const ( + ModActionTypeNote ModActionType = "NOTE" + ModActionTypeBan ModActionType = "BAN" + ModActionTypeDelete ModActionType = "DELETE" + ModActionTypeEdit ModActionType = "EDIT" + ModActionTypeExpire ModActionType = "EXPIRE" + ModActionTypeReport ModActionType = "REPORT" + ModActionTypeReset ModActionType = "RESET" + ModActionTypeAnon ModActionType = "ANON" +) + +var AllModActionType = []ModActionType{ + ModActionTypeNote, + ModActionTypeBan, + ModActionTypeDelete, + ModActionTypeEdit, + ModActionTypeExpire, + ModActionTypeReport, + ModActionTypeReset, + ModActionTypeAnon, +} + +func (e ModActionType) IsValid() bool { + switch e { + case ModActionTypeNote, ModActionTypeBan, ModActionTypeDelete, ModActionTypeEdit, ModActionTypeExpire, ModActionTypeReport, ModActionTypeReset, ModActionTypeAnon: + return true + } + return false +} + +func (e ModActionType) String() string { + return string(e) +} + +func (e *ModActionType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ModActionType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ModActionType", str) + } + return nil +} + +func (e ModActionType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Mod role enums +type ModRole string + +const ( + // An AniList administrator + ModRoleAdmin ModRole = "ADMIN" + // A head developer of AniList + ModRoleLeadDeveloper ModRole = "LEAD_DEVELOPER" + // An AniList developer + ModRoleDeveloper ModRole = "DEVELOPER" + // A lead community moderator + ModRoleLeadCommunity ModRole = "LEAD_COMMUNITY" + // A community moderator + ModRoleCommunity ModRole = "COMMUNITY" + // A discord community moderator + ModRoleDiscordCommunity ModRole = "DISCORD_COMMUNITY" + // A lead anime data moderator + ModRoleLeadAnimeData ModRole = "LEAD_ANIME_DATA" + // An anime data moderator + ModRoleAnimeData ModRole = "ANIME_DATA" + // A lead manga data moderator + ModRoleLeadMangaData ModRole = "LEAD_MANGA_DATA" + // A manga data moderator + ModRoleMangaData ModRole = "MANGA_DATA" + // A lead social media moderator + ModRoleLeadSocialMedia ModRole = "LEAD_SOCIAL_MEDIA" + // A social media moderator + ModRoleSocialMedia ModRole = "SOCIAL_MEDIA" + // A retired moderator + ModRoleRetired ModRole = "RETIRED" + // A character data moderator + ModRoleCharacterData ModRole = "CHARACTER_DATA" + // A staff data moderator + ModRoleStaffData ModRole = "STAFF_DATA" +) + +var AllModRole = []ModRole{ + ModRoleAdmin, + ModRoleLeadDeveloper, + ModRoleDeveloper, + ModRoleLeadCommunity, + ModRoleCommunity, + ModRoleDiscordCommunity, + ModRoleLeadAnimeData, + ModRoleAnimeData, + ModRoleLeadMangaData, + ModRoleMangaData, + ModRoleLeadSocialMedia, + ModRoleSocialMedia, + ModRoleRetired, + ModRoleCharacterData, + ModRoleStaffData, +} + +func (e ModRole) IsValid() bool { + switch e { + case ModRoleAdmin, ModRoleLeadDeveloper, ModRoleDeveloper, ModRoleLeadCommunity, ModRoleCommunity, ModRoleDiscordCommunity, ModRoleLeadAnimeData, ModRoleAnimeData, ModRoleLeadMangaData, ModRoleMangaData, ModRoleLeadSocialMedia, ModRoleSocialMedia, ModRoleRetired, ModRoleCharacterData, ModRoleStaffData: + return true + } + return false +} + +func (e ModRole) String() string { + return string(e) +} + +func (e *ModRole) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ModRole(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ModRole", str) + } + return nil +} + +func (e ModRole) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Notification type enum +type NotificationType string + +const ( + // A user has sent you message + NotificationTypeActivityMessage NotificationType = "ACTIVITY_MESSAGE" + // A user has replied to your activity + NotificationTypeActivityReply NotificationType = "ACTIVITY_REPLY" + // A user has followed you + NotificationTypeFollowing NotificationType = "FOLLOWING" + // A user has mentioned you in their activity + NotificationTypeActivityMention NotificationType = "ACTIVITY_MENTION" + // A user has mentioned you in a forum comment + NotificationTypeThreadCommentMention NotificationType = "THREAD_COMMENT_MENTION" + // A user has commented in one of your subscribed forum threads + NotificationTypeThreadSubscribed NotificationType = "THREAD_SUBSCRIBED" + // A user has replied to your forum comment + NotificationTypeThreadCommentReply NotificationType = "THREAD_COMMENT_REPLY" + // An anime you are currently watching has aired + NotificationTypeAiring NotificationType = "AIRING" + // A user has liked your activity + NotificationTypeActivityLike NotificationType = "ACTIVITY_LIKE" + // A user has liked your activity reply + NotificationTypeActivityReplyLike NotificationType = "ACTIVITY_REPLY_LIKE" + // A user has liked your forum thread + NotificationTypeThreadLike NotificationType = "THREAD_LIKE" + // A user has liked your forum comment + NotificationTypeThreadCommentLike NotificationType = "THREAD_COMMENT_LIKE" + // A user has replied to activity you have also replied to + NotificationTypeActivityReplySubscribed NotificationType = "ACTIVITY_REPLY_SUBSCRIBED" + // A new anime or manga has been added to the site where its related media is on the user's list + NotificationTypeRelatedMediaAddition NotificationType = "RELATED_MEDIA_ADDITION" + // An anime or manga has had a data change that affects how a user may track it in their lists + NotificationTypeMediaDataChange NotificationType = "MEDIA_DATA_CHANGE" + // Anime or manga entries on the user's list have been merged into a single entry + NotificationTypeMediaMerge NotificationType = "MEDIA_MERGE" + // An anime or manga on the user's list has been deleted from the site + NotificationTypeMediaDeletion NotificationType = "MEDIA_DELETION" +) + +var AllNotificationType = []NotificationType{ + NotificationTypeActivityMessage, + NotificationTypeActivityReply, + NotificationTypeFollowing, + NotificationTypeActivityMention, + NotificationTypeThreadCommentMention, + NotificationTypeThreadSubscribed, + NotificationTypeThreadCommentReply, + NotificationTypeAiring, + NotificationTypeActivityLike, + NotificationTypeActivityReplyLike, + NotificationTypeThreadLike, + NotificationTypeThreadCommentLike, + NotificationTypeActivityReplySubscribed, + NotificationTypeRelatedMediaAddition, + NotificationTypeMediaDataChange, + NotificationTypeMediaMerge, + NotificationTypeMediaDeletion, +} + +func (e NotificationType) IsValid() bool { + switch e { + case NotificationTypeActivityMessage, NotificationTypeActivityReply, NotificationTypeFollowing, NotificationTypeActivityMention, NotificationTypeThreadCommentMention, NotificationTypeThreadSubscribed, NotificationTypeThreadCommentReply, NotificationTypeAiring, NotificationTypeActivityLike, NotificationTypeActivityReplyLike, NotificationTypeThreadLike, NotificationTypeThreadCommentLike, NotificationTypeActivityReplySubscribed, NotificationTypeRelatedMediaAddition, NotificationTypeMediaDataChange, NotificationTypeMediaMerge, NotificationTypeMediaDeletion: + return true + } + return false +} + +func (e NotificationType) String() string { + return string(e) +} + +func (e *NotificationType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = NotificationType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid NotificationType", str) + } + return nil +} + +func (e NotificationType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Recommendation rating enums +type RecommendationRating string + +const ( + RecommendationRatingNoRating RecommendationRating = "NO_RATING" + RecommendationRatingRateUp RecommendationRating = "RATE_UP" + RecommendationRatingRateDown RecommendationRating = "RATE_DOWN" +) + +var AllRecommendationRating = []RecommendationRating{ + RecommendationRatingNoRating, + RecommendationRatingRateUp, + RecommendationRatingRateDown, +} + +func (e RecommendationRating) IsValid() bool { + switch e { + case RecommendationRatingNoRating, RecommendationRatingRateUp, RecommendationRatingRateDown: + return true + } + return false +} + +func (e RecommendationRating) String() string { + return string(e) +} + +func (e *RecommendationRating) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = RecommendationRating(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid RecommendationRating", str) + } + return nil +} + +func (e RecommendationRating) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Recommendation sort enums +type RecommendationSort string + +const ( + RecommendationSortID RecommendationSort = "ID" + RecommendationSortIDDesc RecommendationSort = "ID_DESC" + RecommendationSortRating RecommendationSort = "RATING" + RecommendationSortRatingDesc RecommendationSort = "RATING_DESC" +) + +var AllRecommendationSort = []RecommendationSort{ + RecommendationSortID, + RecommendationSortIDDesc, + RecommendationSortRating, + RecommendationSortRatingDesc, +} + +func (e RecommendationSort) IsValid() bool { + switch e { + case RecommendationSortID, RecommendationSortIDDesc, RecommendationSortRating, RecommendationSortRatingDesc: + return true + } + return false +} + +func (e RecommendationSort) String() string { + return string(e) +} + +func (e *RecommendationSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = RecommendationSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid RecommendationSort", str) + } + return nil +} + +func (e RecommendationSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Review rating enums +type ReviewRating string + +const ( + ReviewRatingNoVote ReviewRating = "NO_VOTE" + ReviewRatingUpVote ReviewRating = "UP_VOTE" + ReviewRatingDownVote ReviewRating = "DOWN_VOTE" +) + +var AllReviewRating = []ReviewRating{ + ReviewRatingNoVote, + ReviewRatingUpVote, + ReviewRatingDownVote, +} + +func (e ReviewRating) IsValid() bool { + switch e { + case ReviewRatingNoVote, ReviewRatingUpVote, ReviewRatingDownVote: + return true + } + return false +} + +func (e ReviewRating) String() string { + return string(e) +} + +func (e *ReviewRating) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ReviewRating(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ReviewRating", str) + } + return nil +} + +func (e ReviewRating) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Review sort enums +type ReviewSort string + +const ( + ReviewSortID ReviewSort = "ID" + ReviewSortIDDesc ReviewSort = "ID_DESC" + ReviewSortScore ReviewSort = "SCORE" + ReviewSortScoreDesc ReviewSort = "SCORE_DESC" + ReviewSortRating ReviewSort = "RATING" + ReviewSortRatingDesc ReviewSort = "RATING_DESC" + ReviewSortCreatedAt ReviewSort = "CREATED_AT" + ReviewSortCreatedAtDesc ReviewSort = "CREATED_AT_DESC" + ReviewSortUpdatedAt ReviewSort = "UPDATED_AT" + ReviewSortUpdatedAtDesc ReviewSort = "UPDATED_AT_DESC" +) + +var AllReviewSort = []ReviewSort{ + ReviewSortID, + ReviewSortIDDesc, + ReviewSortScore, + ReviewSortScoreDesc, + ReviewSortRating, + ReviewSortRatingDesc, + ReviewSortCreatedAt, + ReviewSortCreatedAtDesc, + ReviewSortUpdatedAt, + ReviewSortUpdatedAtDesc, +} + +func (e ReviewSort) IsValid() bool { + switch e { + case ReviewSortID, ReviewSortIDDesc, ReviewSortScore, ReviewSortScoreDesc, ReviewSortRating, ReviewSortRatingDesc, ReviewSortCreatedAt, ReviewSortCreatedAtDesc, ReviewSortUpdatedAt, ReviewSortUpdatedAtDesc: + return true + } + return false +} + +func (e ReviewSort) String() string { + return string(e) +} + +func (e *ReviewSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ReviewSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ReviewSort", str) + } + return nil +} + +func (e ReviewSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Revision history actions +type RevisionHistoryAction string + +const ( + RevisionHistoryActionCreate RevisionHistoryAction = "CREATE" + RevisionHistoryActionEdit RevisionHistoryAction = "EDIT" +) + +var AllRevisionHistoryAction = []RevisionHistoryAction{ + RevisionHistoryActionCreate, + RevisionHistoryActionEdit, +} + +func (e RevisionHistoryAction) IsValid() bool { + switch e { + case RevisionHistoryActionCreate, RevisionHistoryActionEdit: + return true + } + return false +} + +func (e RevisionHistoryAction) String() string { + return string(e) +} + +func (e *RevisionHistoryAction) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = RevisionHistoryAction(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid RevisionHistoryAction", str) + } + return nil +} + +func (e RevisionHistoryAction) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Media list scoring type +type ScoreFormat string + +const ( + // An integer from 0-100 + ScoreFormatPoint100 ScoreFormat = "POINT_100" + // A float from 0-10 with 1 decimal place + ScoreFormatPoint10Decimal ScoreFormat = "POINT_10_DECIMAL" + // An integer from 0-10 + ScoreFormatPoint10 ScoreFormat = "POINT_10" + // An integer from 0-5. Should be represented in Stars + ScoreFormatPoint5 ScoreFormat = "POINT_5" + // An integer from 0-3. Should be represented in Smileys. 0 => No Score, 1 => :(, 2 => :|, 3 => :) + ScoreFormatPoint3 ScoreFormat = "POINT_3" +) + +var AllScoreFormat = []ScoreFormat{ + ScoreFormatPoint100, + ScoreFormatPoint10Decimal, + ScoreFormatPoint10, + ScoreFormatPoint5, + ScoreFormatPoint3, +} + +func (e ScoreFormat) IsValid() bool { + switch e { + case ScoreFormatPoint100, ScoreFormatPoint10Decimal, ScoreFormatPoint10, ScoreFormatPoint5, ScoreFormatPoint3: + return true + } + return false +} + +func (e ScoreFormat) String() string { + return string(e) +} + +func (e *ScoreFormat) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ScoreFormat(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ScoreFormat", str) + } + return nil +} + +func (e ScoreFormat) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Site trend sort enums +type SiteTrendSort string + +const ( + SiteTrendSortDate SiteTrendSort = "DATE" + SiteTrendSortDateDesc SiteTrendSort = "DATE_DESC" + SiteTrendSortCount SiteTrendSort = "COUNT" + SiteTrendSortCountDesc SiteTrendSort = "COUNT_DESC" + SiteTrendSortChange SiteTrendSort = "CHANGE" + SiteTrendSortChangeDesc SiteTrendSort = "CHANGE_DESC" +) + +var AllSiteTrendSort = []SiteTrendSort{ + SiteTrendSortDate, + SiteTrendSortDateDesc, + SiteTrendSortCount, + SiteTrendSortCountDesc, + SiteTrendSortChange, + SiteTrendSortChangeDesc, +} + +func (e SiteTrendSort) IsValid() bool { + switch e { + case SiteTrendSortDate, SiteTrendSortDateDesc, SiteTrendSortCount, SiteTrendSortCountDesc, SiteTrendSortChange, SiteTrendSortChangeDesc: + return true + } + return false +} + +func (e SiteTrendSort) String() string { + return string(e) +} + +func (e *SiteTrendSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = SiteTrendSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid SiteTrendSort", str) + } + return nil +} + +func (e SiteTrendSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// The primary language of the voice actor +type StaffLanguage string + +const ( + // Japanese + StaffLanguageJapanese StaffLanguage = "JAPANESE" + // English + StaffLanguageEnglish StaffLanguage = "ENGLISH" + // Korean + StaffLanguageKorean StaffLanguage = "KOREAN" + // Italian + StaffLanguageItalian StaffLanguage = "ITALIAN" + // Spanish + StaffLanguageSpanish StaffLanguage = "SPANISH" + // Portuguese + StaffLanguagePortuguese StaffLanguage = "PORTUGUESE" + // French + StaffLanguageFrench StaffLanguage = "FRENCH" + // German + StaffLanguageGerman StaffLanguage = "GERMAN" + // Hebrew + StaffLanguageHebrew StaffLanguage = "HEBREW" + // Hungarian + StaffLanguageHungarian StaffLanguage = "HUNGARIAN" +) + +var AllStaffLanguage = []StaffLanguage{ + StaffLanguageJapanese, + StaffLanguageEnglish, + StaffLanguageKorean, + StaffLanguageItalian, + StaffLanguageSpanish, + StaffLanguagePortuguese, + StaffLanguageFrench, + StaffLanguageGerman, + StaffLanguageHebrew, + StaffLanguageHungarian, +} + +func (e StaffLanguage) IsValid() bool { + switch e { + case StaffLanguageJapanese, StaffLanguageEnglish, StaffLanguageKorean, StaffLanguageItalian, StaffLanguageSpanish, StaffLanguagePortuguese, StaffLanguageFrench, StaffLanguageGerman, StaffLanguageHebrew, StaffLanguageHungarian: + return true + } + return false +} + +func (e StaffLanguage) String() string { + return string(e) +} + +func (e *StaffLanguage) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = StaffLanguage(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid StaffLanguage", str) + } + return nil +} + +func (e StaffLanguage) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Staff sort enums +type StaffSort string + +const ( + StaffSortID StaffSort = "ID" + StaffSortIDDesc StaffSort = "ID_DESC" + StaffSortRole StaffSort = "ROLE" + StaffSortRoleDesc StaffSort = "ROLE_DESC" + StaffSortLanguage StaffSort = "LANGUAGE" + StaffSortLanguageDesc StaffSort = "LANGUAGE_DESC" + StaffSortSearchMatch StaffSort = "SEARCH_MATCH" + StaffSortFavourites StaffSort = "FAVOURITES" + StaffSortFavouritesDesc StaffSort = "FAVOURITES_DESC" + // Order manually decided by moderators + StaffSortRelevance StaffSort = "RELEVANCE" +) + +var AllStaffSort = []StaffSort{ + StaffSortID, + StaffSortIDDesc, + StaffSortRole, + StaffSortRoleDesc, + StaffSortLanguage, + StaffSortLanguageDesc, + StaffSortSearchMatch, + StaffSortFavourites, + StaffSortFavouritesDesc, + StaffSortRelevance, +} + +func (e StaffSort) IsValid() bool { + switch e { + case StaffSortID, StaffSortIDDesc, StaffSortRole, StaffSortRoleDesc, StaffSortLanguage, StaffSortLanguageDesc, StaffSortSearchMatch, StaffSortFavourites, StaffSortFavouritesDesc, StaffSortRelevance: + return true + } + return false +} + +func (e StaffSort) String() string { + return string(e) +} + +func (e *StaffSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = StaffSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid StaffSort", str) + } + return nil +} + +func (e StaffSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Studio sort enums +type StudioSort string + +const ( + StudioSortID StudioSort = "ID" + StudioSortIDDesc StudioSort = "ID_DESC" + StudioSortName StudioSort = "NAME" + StudioSortNameDesc StudioSort = "NAME_DESC" + StudioSortSearchMatch StudioSort = "SEARCH_MATCH" + StudioSortFavourites StudioSort = "FAVOURITES" + StudioSortFavouritesDesc StudioSort = "FAVOURITES_DESC" +) + +var AllStudioSort = []StudioSort{ + StudioSortID, + StudioSortIDDesc, + StudioSortName, + StudioSortNameDesc, + StudioSortSearchMatch, + StudioSortFavourites, + StudioSortFavouritesDesc, +} + +func (e StudioSort) IsValid() bool { + switch e { + case StudioSortID, StudioSortIDDesc, StudioSortName, StudioSortNameDesc, StudioSortSearchMatch, StudioSortFavourites, StudioSortFavouritesDesc: + return true + } + return false +} + +func (e StudioSort) String() string { + return string(e) +} + +func (e *StudioSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = StudioSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid StudioSort", str) + } + return nil +} + +func (e StudioSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Submission sort enums +type SubmissionSort string + +const ( + SubmissionSortID SubmissionSort = "ID" + SubmissionSortIDDesc SubmissionSort = "ID_DESC" +) + +var AllSubmissionSort = []SubmissionSort{ + SubmissionSortID, + SubmissionSortIDDesc, +} + +func (e SubmissionSort) IsValid() bool { + switch e { + case SubmissionSortID, SubmissionSortIDDesc: + return true + } + return false +} + +func (e SubmissionSort) String() string { + return string(e) +} + +func (e *SubmissionSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = SubmissionSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid SubmissionSort", str) + } + return nil +} + +func (e SubmissionSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Submission status +type SubmissionStatus string + +const ( + SubmissionStatusPending SubmissionStatus = "PENDING" + SubmissionStatusRejected SubmissionStatus = "REJECTED" + SubmissionStatusPartiallyAccepted SubmissionStatus = "PARTIALLY_ACCEPTED" + SubmissionStatusAccepted SubmissionStatus = "ACCEPTED" +) + +var AllSubmissionStatus = []SubmissionStatus{ + SubmissionStatusPending, + SubmissionStatusRejected, + SubmissionStatusPartiallyAccepted, + SubmissionStatusAccepted, +} + +func (e SubmissionStatus) IsValid() bool { + switch e { + case SubmissionStatusPending, SubmissionStatusRejected, SubmissionStatusPartiallyAccepted, SubmissionStatusAccepted: + return true + } + return false +} + +func (e SubmissionStatus) String() string { + return string(e) +} + +func (e *SubmissionStatus) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = SubmissionStatus(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid SubmissionStatus", str) + } + return nil +} + +func (e SubmissionStatus) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Thread comments sort enums +type ThreadCommentSort string + +const ( + ThreadCommentSortID ThreadCommentSort = "ID" + ThreadCommentSortIDDesc ThreadCommentSort = "ID_DESC" +) + +var AllThreadCommentSort = []ThreadCommentSort{ + ThreadCommentSortID, + ThreadCommentSortIDDesc, +} + +func (e ThreadCommentSort) IsValid() bool { + switch e { + case ThreadCommentSortID, ThreadCommentSortIDDesc: + return true + } + return false +} + +func (e ThreadCommentSort) String() string { + return string(e) +} + +func (e *ThreadCommentSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ThreadCommentSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ThreadCommentSort", str) + } + return nil +} + +func (e ThreadCommentSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// Thread sort enums +type ThreadSort string + +const ( + ThreadSortID ThreadSort = "ID" + ThreadSortIDDesc ThreadSort = "ID_DESC" + ThreadSortTitle ThreadSort = "TITLE" + ThreadSortTitleDesc ThreadSort = "TITLE_DESC" + ThreadSortCreatedAt ThreadSort = "CREATED_AT" + ThreadSortCreatedAtDesc ThreadSort = "CREATED_AT_DESC" + ThreadSortUpdatedAt ThreadSort = "UPDATED_AT" + ThreadSortUpdatedAtDesc ThreadSort = "UPDATED_AT_DESC" + ThreadSortRepliedAt ThreadSort = "REPLIED_AT" + ThreadSortRepliedAtDesc ThreadSort = "REPLIED_AT_DESC" + ThreadSortReplyCount ThreadSort = "REPLY_COUNT" + ThreadSortReplyCountDesc ThreadSort = "REPLY_COUNT_DESC" + ThreadSortViewCount ThreadSort = "VIEW_COUNT" + ThreadSortViewCountDesc ThreadSort = "VIEW_COUNT_DESC" + ThreadSortIsSticky ThreadSort = "IS_STICKY" + ThreadSortSearchMatch ThreadSort = "SEARCH_MATCH" +) + +var AllThreadSort = []ThreadSort{ + ThreadSortID, + ThreadSortIDDesc, + ThreadSortTitle, + ThreadSortTitleDesc, + ThreadSortCreatedAt, + ThreadSortCreatedAtDesc, + ThreadSortUpdatedAt, + ThreadSortUpdatedAtDesc, + ThreadSortRepliedAt, + ThreadSortRepliedAtDesc, + ThreadSortReplyCount, + ThreadSortReplyCountDesc, + ThreadSortViewCount, + ThreadSortViewCountDesc, + ThreadSortIsSticky, + ThreadSortSearchMatch, +} + +func (e ThreadSort) IsValid() bool { + switch e { + case ThreadSortID, ThreadSortIDDesc, ThreadSortTitle, ThreadSortTitleDesc, ThreadSortCreatedAt, ThreadSortCreatedAtDesc, ThreadSortUpdatedAt, ThreadSortUpdatedAtDesc, ThreadSortRepliedAt, ThreadSortRepliedAtDesc, ThreadSortReplyCount, ThreadSortReplyCountDesc, ThreadSortViewCount, ThreadSortViewCountDesc, ThreadSortIsSticky, ThreadSortSearchMatch: + return true + } + return false +} + +func (e ThreadSort) String() string { + return string(e) +} + +func (e *ThreadSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ThreadSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ThreadSort", str) + } + return nil +} + +func (e ThreadSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// User sort enums +type UserSort string + +const ( + UserSortID UserSort = "ID" + UserSortIDDesc UserSort = "ID_DESC" + UserSortUsername UserSort = "USERNAME" + UserSortUsernameDesc UserSort = "USERNAME_DESC" + UserSortWatchedTime UserSort = "WATCHED_TIME" + UserSortWatchedTimeDesc UserSort = "WATCHED_TIME_DESC" + UserSortChaptersRead UserSort = "CHAPTERS_READ" + UserSortChaptersReadDesc UserSort = "CHAPTERS_READ_DESC" + UserSortSearchMatch UserSort = "SEARCH_MATCH" +) + +var AllUserSort = []UserSort{ + UserSortID, + UserSortIDDesc, + UserSortUsername, + UserSortUsernameDesc, + UserSortWatchedTime, + UserSortWatchedTimeDesc, + UserSortChaptersRead, + UserSortChaptersReadDesc, + UserSortSearchMatch, +} + +func (e UserSort) IsValid() bool { + switch e { + case UserSortID, UserSortIDDesc, UserSortUsername, UserSortUsernameDesc, UserSortWatchedTime, UserSortWatchedTimeDesc, UserSortChaptersRead, UserSortChaptersReadDesc, UserSortSearchMatch: + return true + } + return false +} + +func (e UserSort) String() string { + return string(e) +} + +func (e *UserSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = UserSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid UserSort", str) + } + return nil +} + +func (e UserSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// The language the user wants to see staff and character names in +type UserStaffNameLanguage string + +const ( + // The romanization of the staff or character's native name, with western name ordering + UserStaffNameLanguageRomajiWestern UserStaffNameLanguage = "ROMAJI_WESTERN" + // The romanization of the staff or character's native name + UserStaffNameLanguageRomaji UserStaffNameLanguage = "ROMAJI" + // The staff or character's name in their native language + UserStaffNameLanguageNative UserStaffNameLanguage = "NATIVE" +) + +var AllUserStaffNameLanguage = []UserStaffNameLanguage{ + UserStaffNameLanguageRomajiWestern, + UserStaffNameLanguageRomaji, + UserStaffNameLanguageNative, +} + +func (e UserStaffNameLanguage) IsValid() bool { + switch e { + case UserStaffNameLanguageRomajiWestern, UserStaffNameLanguageRomaji, UserStaffNameLanguageNative: + return true + } + return false +} + +func (e UserStaffNameLanguage) String() string { + return string(e) +} + +func (e *UserStaffNameLanguage) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = UserStaffNameLanguage(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid UserStaffNameLanguage", str) + } + return nil +} + +func (e UserStaffNameLanguage) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// User statistics sort enum +type UserStatisticsSort string + +const ( + UserStatisticsSortID UserStatisticsSort = "ID" + UserStatisticsSortIDDesc UserStatisticsSort = "ID_DESC" + UserStatisticsSortCount UserStatisticsSort = "COUNT" + UserStatisticsSortCountDesc UserStatisticsSort = "COUNT_DESC" + UserStatisticsSortProgress UserStatisticsSort = "PROGRESS" + UserStatisticsSortProgressDesc UserStatisticsSort = "PROGRESS_DESC" + UserStatisticsSortMeanScore UserStatisticsSort = "MEAN_SCORE" + UserStatisticsSortMeanScoreDesc UserStatisticsSort = "MEAN_SCORE_DESC" +) + +var AllUserStatisticsSort = []UserStatisticsSort{ + UserStatisticsSortID, + UserStatisticsSortIDDesc, + UserStatisticsSortCount, + UserStatisticsSortCountDesc, + UserStatisticsSortProgress, + UserStatisticsSortProgressDesc, + UserStatisticsSortMeanScore, + UserStatisticsSortMeanScoreDesc, +} + +func (e UserStatisticsSort) IsValid() bool { + switch e { + case UserStatisticsSortID, UserStatisticsSortIDDesc, UserStatisticsSortCount, UserStatisticsSortCountDesc, UserStatisticsSortProgress, UserStatisticsSortProgressDesc, UserStatisticsSortMeanScore, UserStatisticsSortMeanScoreDesc: + return true + } + return false +} + +func (e UserStatisticsSort) String() string { + return string(e) +} + +func (e *UserStatisticsSort) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = UserStatisticsSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid UserStatisticsSort", str) + } + return nil +} + +func (e UserStatisticsSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +// The language the user wants to see media titles in +type UserTitleLanguage string + +const ( + // The romanization of the native language title + UserTitleLanguageRomaji UserTitleLanguage = "ROMAJI" + // The official english title + UserTitleLanguageEnglish UserTitleLanguage = "ENGLISH" + // Official title in it's native language + UserTitleLanguageNative UserTitleLanguage = "NATIVE" + // The romanization of the native language title, stylised by media creator + UserTitleLanguageRomajiStylised UserTitleLanguage = "ROMAJI_STYLISED" + // The official english title, stylised by media creator + UserTitleLanguageEnglishStylised UserTitleLanguage = "ENGLISH_STYLISED" + // Official title in it's native language, stylised by media creator + UserTitleLanguageNativeStylised UserTitleLanguage = "NATIVE_STYLISED" +) + +var AllUserTitleLanguage = []UserTitleLanguage{ + UserTitleLanguageRomaji, + UserTitleLanguageEnglish, + UserTitleLanguageNative, + UserTitleLanguageRomajiStylised, + UserTitleLanguageEnglishStylised, + UserTitleLanguageNativeStylised, +} + +func (e UserTitleLanguage) IsValid() bool { + switch e { + case UserTitleLanguageRomaji, UserTitleLanguageEnglish, UserTitleLanguageNative, UserTitleLanguageRomajiStylised, UserTitleLanguageEnglishStylised, UserTitleLanguageNativeStylised: + return true + } + return false +} + +func (e UserTitleLanguage) String() string { + return string(e) +} + +func (e *UserTitleLanguage) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = UserTitleLanguage(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid UserTitleLanguage", str) + } + return nil +} + +func (e UserTitleLanguage) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} diff --git a/seanime-2.9.10/internal/api/anilist/queries/anime.graphql b/seanime-2.9.10/internal/api/anilist/queries/anime.graphql new file mode 100644 index 0000000..4746fc4 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/queries/anime.graphql @@ -0,0 +1,456 @@ +query AnimeCollection ($userName: String) { + MediaListCollection(userName: $userName, forceSingleCompletedList: true, type: ANIME) { + lists { + status + name + isCustomList + entries { + id + score(format: POINT_100) + progress + status + notes + repeat + private + startedAt { + year + month + day + } + completedAt { + year + month + day + } + media { + ...baseAnime + } + } + } + } +} + +query AnimeCollectionWithRelations ($userName: String) { + MediaListCollection(userName: $userName, forceSingleCompletedList: true, type: ANIME) { + lists { + status + name + isCustomList + entries { + id + score(format: POINT_100) + progress + status + notes + repeat + private + startedAt { + year + month + day + } + completedAt { + year + month + day + } + media { + ...completeAnime + } + } + } + } +} + +query BaseAnimeByMalId ($id: Int) { + Media(idMal: $id, type: ANIME) { + ...baseAnime + } +} + +query BaseAnimeById ($id: Int) { + Media(id: $id, type: ANIME) { + ...baseAnime + } +} + +query SearchBaseAnimeByIds ($ids: [Int], $page: Int, $perPage: Int, $status: [MediaStatus], $inCollection: Boolean, $sort: [MediaSort], $season: MediaSeason, $year: Int, $genre: String, $format: MediaFormat) { + Page(page: $page, perPage: $perPage) { + pageInfo { + hasNextPage + }, + media(id_in: $ids, type: ANIME, status_in: $status, onList: $inCollection, sort: $sort, season: $season, seasonYear: $year, genre: $genre, format: $format) { + ...baseAnime + } + } +} + +query CompleteAnimeById ($id: Int) { + Media(id: $id, type: ANIME) { + ...completeAnime + } +} + +# For view (will be cached) +query AnimeDetailsById ($id: Int) { + Media(id: $id, type: ANIME) { + siteUrl + id + duration + genres + averageScore + popularity + meanScore + description + trailer { + id + site + thumbnail + } + startDate { + year + month + day + } + endDate { + year + month + day + } + studios(isMain: true) { + nodes { + name + id + } + } + characters(sort: [ROLE]) { + edges { + id + role + name + node { + ...baseCharacter + } + } + } + staff(sort: [RELEVANCE]) { + edges { + role + node { + name { + full + } + id + } + } + } + rankings { + context + type + rank + year + format + allTime + season + } + recommendations(page: 1, perPage: 8, sort: RATING_DESC) { + edges { + node { + mediaRecommendation { + id + idMal + siteUrl + status(version: 2) + isAdult + season + type + format + meanScore + description + episodes + trailer { + id + site + thumbnail + } + startDate { + year + month + day + } + coverImage { + extraLarge + large + medium + color + } + bannerImage + title { + romaji + english + native + userPreferred + } + } + } + } + } + relations { + edges { + relationType(version: 2) + node { + ...baseAnime + } + } + } + } +} + +query ListAnime( + $page: Int + $search: String + $perPage: Int + $sort: [MediaSort] + $status: [MediaStatus] + $genres: [String] + $averageScore_greater: Int + $season: MediaSeason + $seasonYear: Int + $format: MediaFormat + $isAdult: Boolean +) { + Page(page: $page, perPage: $perPage) { + pageInfo { + hasNextPage + total + perPage + currentPage + lastPage + } + media( + type: ANIME + search: $search + sort: $sort + status_in: $status + isAdult: $isAdult + format: $format + genre_in: $genres + averageScore_greater: $averageScore_greater + season: $season + seasonYear: $seasonYear + format_not: MUSIC + ) { + ...baseAnime + } + } +} + +query ListRecentAnime ($page: Int, $perPage: Int, $airingAt_greater: Int, $airingAt_lesser: Int, $notYetAired: Boolean = false) { + Page(page: $page, perPage: $perPage) { + pageInfo { + hasNextPage + total + perPage + currentPage + lastPage + } + airingSchedules(notYetAired: $notYetAired, sort: TIME_DESC, airingAt_greater: $airingAt_greater, airingAt_lesser: $airingAt_lesser) { + id + airingAt + episode + timeUntilAiring + media { + ... baseAnime + } + } + } +} + +fragment baseAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + seasonYear + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } +} + +fragment completeAnime on Media { + id + idMal + siteUrl + status(version: 2) + season + seasonYear + type + format + bannerImage + episodes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + duration + trailer { + id + site + thumbnail + } + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } + relations { + edges { + relationType(version: 2) + node { + ...baseAnime + } + } + } +} + +fragment baseCharacter on Character { + id + isFavourite + gender + age + dateOfBirth { + year + month + day + } + name { + full + native + alternative + } + image { + large + } + description + siteUrl +} + +query AnimeAiringSchedule($ids: [Int],$season: MediaSeason, $seasonYear: Int, $previousSeason: MediaSeason, $previousSeasonYear: Int, $nextSeason: MediaSeason, $nextSeasonYear: Int) { + ongoing: Page { + media(id_in: $ids, type: ANIME, season: $season, seasonYear: $seasonYear, onList: true) { + ...animeSchedule + } + } + ongoingNext: Page(page: 2) { + media(id_in: $ids, type: ANIME, season: $season, seasonYear: $seasonYear, onList: true) { + ...animeSchedule + } + } + upcoming: Page { + media(id_in: $ids, type: ANIME, season: $nextSeason, seasonYear: $nextSeasonYear, sort: [START_DATE], onList: true) { + ...animeSchedule + } + } + upcomingNext: Page(page: 2) { + media(id_in: $ids, type: ANIME, season: $nextSeason, seasonYear: $nextSeasonYear, sort: [START_DATE], onList: true) { + ...animeSchedule + } + } + preceding: Page { + media(id_in: $ids, type: ANIME, season: $previousSeason, seasonYear: $previousSeasonYear, onList: true) { + ...animeSchedule + } + } +} + +query AnimeAiringScheduleRaw($ids: [Int]) { + Page { + media(id_in: $ids, type: ANIME, onList: true) { + ...animeSchedule + } + } +} + +fragment animeSchedule on Media { + id, + idMal + previous: airingSchedule(notYetAired: false, perPage: 30) { + nodes { + airingAt + timeUntilAiring + episode + } + }, + upcoming: airingSchedule(notYetAired: true, perPage: 30) { + nodes { + airingAt + timeUntilAiring + episode + } + } +} diff --git a/seanime-2.9.10/internal/api/anilist/queries/entry.graphql b/seanime-2.9.10/internal/api/anilist/queries/entry.graphql new file mode 100644 index 0000000..dd736d3 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/queries/entry.graphql @@ -0,0 +1,56 @@ +mutation UpdateMediaListEntry ( + $mediaId: Int + $status: MediaListStatus + $scoreRaw: Int + $progress: Int + $startedAt: FuzzyDateInput + $completedAt: FuzzyDateInput +) { + SaveMediaListEntry( + mediaId: $mediaId + status: $status + scoreRaw: $scoreRaw + progress: $progress + startedAt: $startedAt + completedAt: $completedAt + ) { + id + } +} + +mutation UpdateMediaListEntryProgress ( + $mediaId: Int + $progress: Int + $status: MediaListStatus +) { + SaveMediaListEntry( + mediaId: $mediaId + progress: $progress + status: $status + ) { + id + } +} + +mutation DeleteEntry ( + $mediaListEntryId: Int +) { + DeleteMediaListEntry( + id: $mediaListEntryId + ) { + deleted + } +} + + +mutation UpdateMediaListEntryRepeat ( + $mediaId: Int + $repeat: Int +) { + SaveMediaListEntry( + mediaId: $mediaId + repeat: $repeat + ) { + id + } +} diff --git a/seanime-2.9.10/internal/api/anilist/queries/manga.graphql b/seanime-2.9.10/internal/api/anilist/queries/manga.graphql new file mode 100644 index 0000000..8188003 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/queries/manga.graphql @@ -0,0 +1,200 @@ +query MangaCollection ($userName: String) { + MediaListCollection(userName: $userName, forceSingleCompletedList: true, type: MANGA) { + lists { + status + name + isCustomList + entries { + id + score(format: POINT_100) + progress + status + notes + repeat + private + startedAt { + year + month + day + } + completedAt { + year + month + day + } + media { + ...baseManga + } + } + } + } +} + + +query SearchBaseManga($page: Int, $perPage: Int, $sort: [MediaSort], $search: String, $status: [MediaStatus]){ + Page(page: $page, perPage: $perPage){ + pageInfo{ + hasNextPage + }, + media(type: MANGA, search: $search, sort: $sort, status_in: $status, format_not: NOVEL){ + ...baseManga + } + } +} + +query BaseMangaById ($id: Int) { + Media(id: $id, type: MANGA) { + ...baseManga + } +} + +# For view (will be cached) +query MangaDetailsById ($id: Int) { + Media(id: $id, type: MANGA) { + siteUrl + id + duration + genres + rankings { + context + type + rank + year + format + allTime + season + } + characters(sort: [ROLE]) { + edges { + id + role + name + node { + ...baseCharacter + } + } + } + recommendations(page: 1, perPage: 8, sort: RATING_DESC) { + edges { + node { + mediaRecommendation { + id + idMal + siteUrl + status(version: 2) + season + type + format + bannerImage + chapters + volumes + synonyms + isAdult + countryOfOrigin + meanScore + description + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } + } + } + } + } + relations { + edges { + relationType(version: 2) + node { + ...baseManga + } + } + } + } +} + +query ListManga( + $page: Int + $search: String + $perPage: Int + $sort: [MediaSort] + $status: [MediaStatus] + $genres: [String] + $averageScore_greater: Int + $startDate_greater: FuzzyDateInt + $startDate_lesser: FuzzyDateInt + $format: MediaFormat + $countryOfOrigin: CountryCode + $isAdult: Boolean +) { + Page(page: $page, perPage: $perPage){ + pageInfo{ + hasNextPage + total + perPage + currentPage + lastPage + }, + media(type: MANGA, isAdult: $isAdult, countryOfOrigin: $countryOfOrigin, search: $search, sort: $sort, status_in: $status, format: $format, genre_in: $genres, averageScore_greater: $averageScore_greater, startDate_greater: $startDate_greater, startDate_lesser: $startDate_lesser, format_not: NOVEL){ + ...baseManga + } + } +} + +fragment baseManga on Media { + id + idMal + siteUrl + status(version: 2) + season + type + format + bannerImage + chapters + volumes + synonyms + isAdult + countryOfOrigin + meanScore + description + genres + title { + userPreferred + romaji + english + native + } + coverImage { + extraLarge + large + medium + color + } + startDate { + year + month + day + } + endDate { + year + month + day + } +} diff --git a/seanime-2.9.10/internal/api/anilist/queries/stats.graphql b/seanime-2.9.10/internal/api/anilist/queries/stats.graphql new file mode 100644 index 0000000..8c00ce5 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/queries/stats.graphql @@ -0,0 +1,126 @@ +query ViewerStats { + Viewer { + statistics { + anime { + count + minutesWatched + episodesWatched + meanScore + formats { + ...UserFormatStats + } + genres { + ...UserGenreStats + } + statuses { + ...UserStatusStats + } + studios { + ...UserStudioStats + } + scores { + ...UserScoreStats + } + startYears { + ...UserStartYearStats + } + releaseYears { + ...UserReleaseYearStats + } + } + manga { + count + chaptersRead + meanScore + formats { + ...UserFormatStats + } + genres { + ...UserGenreStats + } + statuses { + ...UserStatusStats + } + studios { + ...UserStudioStats + } + scores { + ...UserScoreStats + } + startYears { + ...UserStartYearStats + } + releaseYears { + ...UserReleaseYearStats + } + } + } + } +} + +fragment UserFormatStats on UserFormatStatistic { + format + meanScore + count + minutesWatched + mediaIds + chaptersRead +} + +fragment UserGenreStats on UserGenreStatistic { + genre + meanScore + count + minutesWatched + mediaIds + chaptersRead +} + +fragment UserStatusStats on UserStatusStatistic { + status + meanScore + count + minutesWatched + mediaIds + chaptersRead +} + +fragment UserScoreStats on UserScoreStatistic { + score + meanScore + count + minutesWatched + mediaIds + chaptersRead +} + +fragment UserStudioStats on UserStudioStatistic { + studio { + id + name + isAnimationStudio + } + meanScore + count + minutesWatched + mediaIds + chaptersRead +} + +fragment UserStartYearStats on UserStartYearStatistic { + startYear + meanScore + count + minutesWatched + mediaIds + chaptersRead +} + +fragment UserReleaseYearStats on UserReleaseYearStatistic { + releaseYear + meanScore + count + minutesWatched + mediaIds + chaptersRead +} diff --git a/seanime-2.9.10/internal/api/anilist/queries/studio.graphql b/seanime-2.9.10/internal/api/anilist/queries/studio.graphql new file mode 100644 index 0000000..6866f26 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/queries/studio.graphql @@ -0,0 +1,12 @@ +query StudioDetails($id: Int) { + Studio(id: $id) { + id + isAnimationStudio + name + media (perPage: 80, sort: TRENDING_DESC, isMain: true) { + nodes { + ...baseAnime + } + } + } +} diff --git a/seanime-2.9.10/internal/api/anilist/queries/viewer.graphql b/seanime-2.9.10/internal/api/anilist/queries/viewer.graphql new file mode 100644 index 0000000..f2dd1b5 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/queries/viewer.graphql @@ -0,0 +1,16 @@ +query GetViewer { + Viewer { + name + avatar { + large + medium + } + bannerImage + isBlocked + options { + displayAdultContent + airingNotifications + profileColor + } + } +} \ No newline at end of file diff --git a/seanime-2.9.10/internal/api/anilist/stats.go b/seanime-2.9.10/internal/api/anilist/stats.go new file mode 100644 index 0000000..34ded5e --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/stats.go @@ -0,0 +1,72 @@ +package anilist + +import ( + "context" + "seanime/internal/util" +) + +type ( + Stats struct { + AnimeStats *AnimeStats `json:"animeStats"` + MangaStats *MangaStats `json:"mangaStats"` + } + + AnimeStats struct { + Count int `json:"count"` + MinutesWatched int `json:"minutesWatched"` + EpisodesWatched int `json:"episodesWatched"` + MeanScore float64 `json:"meanScore"` + Genres []*UserGenreStats `json:"genres"` + Formats []*UserFormatStats `json:"formats"` + Statuses []*UserStatusStats `json:"statuses"` + Studios []*UserStudioStats `json:"studios"` + Scores []*UserScoreStats `json:"scores"` + StartYears []*UserStartYearStats `json:"startYears"` + ReleaseYears []*UserReleaseYearStats `json:"releaseYears"` + } + + MangaStats struct { + Count int `json:"count"` + ChaptersRead int `json:"chaptersRead"` + MeanScore float64 `json:"meanScore"` + Genres []*UserGenreStats `json:"genres"` + Statuses []*UserStatusStats `json:"statuses"` + Scores []*UserScoreStats `json:"scores"` + StartYears []*UserStartYearStats `json:"startYears"` + ReleaseYears []*UserReleaseYearStats `json:"releaseYears"` + } +) + +func GetStats(ctx context.Context, stats *ViewerStats) (ret *Stats, err error) { + defer util.HandlePanicInModuleWithError("api/anilist/GetStats", &err) + + allStats := stats.GetViewer().GetStatistics() + + ret = &Stats{ + AnimeStats: &AnimeStats{ + Count: allStats.GetAnime().GetCount(), + MinutesWatched: allStats.GetAnime().GetMinutesWatched(), + EpisodesWatched: allStats.GetAnime().GetEpisodesWatched(), + MeanScore: allStats.GetAnime().GetMeanScore(), + Genres: allStats.GetAnime().GetGenres(), + Formats: allStats.GetAnime().GetFormats(), + Statuses: allStats.GetAnime().GetStatuses(), + Studios: allStats.GetAnime().GetStudios(), + Scores: allStats.GetAnime().GetScores(), + StartYears: allStats.GetAnime().GetStartYears(), + ReleaseYears: allStats.GetAnime().GetReleaseYears(), + }, + MangaStats: &MangaStats{ + Count: allStats.GetManga().GetCount(), + ChaptersRead: allStats.GetManga().GetChaptersRead(), + MeanScore: allStats.GetManga().GetMeanScore(), + Genres: allStats.GetManga().GetGenres(), + Statuses: allStats.GetManga().GetStatuses(), + Scores: allStats.GetManga().GetScores(), + StartYears: allStats.GetManga().GetStartYears(), + ReleaseYears: allStats.GetManga().GetReleaseYears(), + }, + } + + return ret, nil +} diff --git a/seanime-2.9.10/internal/api/anilist/utils.go b/seanime-2.9.10/internal/api/anilist/utils.go new file mode 100644 index 0000000..a9ffe93 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/utils.go @@ -0,0 +1,60 @@ +package anilist + +import ( + "time" +) + +type GetSeasonKind int + +const ( + GetSeasonKindCurrent GetSeasonKind = iota + GetSeasonKindNext + GetSeasonKindPrevious +) + +func GetSeasonInfo(now time.Time, kind GetSeasonKind) (MediaSeason, int) { + month, year := now.Month(), now.Year() + + getSeasonIndex := func(m time.Month) int { + switch { + case m >= 3 && m <= 5: // spring: 3, 4, 5 + return 1 + case m >= 6 && m <= 8: // summer: 6, 7, 8 + return 2 + case m >= 9 && m <= 11: // fall: 9, 10, 11 + return 3 + default: // winter: 12, 1, 2 + return 0 + } + } + + seasons := []MediaSeason{MediaSeasonWinter, MediaSeasonSpring, MediaSeasonSummer, MediaSeasonFall} + var index int + + switch kind { + case GetSeasonKindCurrent: + index = getSeasonIndex(month) + + case GetSeasonKindNext: + nextMonth := month + 3 + nextYear := year + if nextMonth > 12 { + nextMonth -= 12 + nextYear++ + } + index = getSeasonIndex(nextMonth) + year = nextYear + + case GetSeasonKindPrevious: + prevMonth := month - 3 + prevYear := year + if prevMonth <= 0 { + prevMonth += 12 + prevYear-- + } + index = getSeasonIndex(prevMonth) + year = prevYear + } + + return seasons[index], year +} diff --git a/seanime-2.9.10/internal/api/anilist/utils_test.go b/seanime-2.9.10/internal/api/anilist/utils_test.go new file mode 100644 index 0000000..c62fc34 --- /dev/null +++ b/seanime-2.9.10/internal/api/anilist/utils_test.go @@ -0,0 +1,34 @@ +package anilist + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGetSeason(t *testing.T) { + tests := []struct { + now time.Time + kind GetSeasonKind + expectedSeason MediaSeason + expectedYear int + }{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindCurrent, MediaSeasonWinter, 2025}, + {time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindCurrent, MediaSeasonSpring, 2025}, + {time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindCurrent, MediaSeasonSummer, 2025}, + {time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindCurrent, MediaSeasonFall, 2025}, + {time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindNext, MediaSeasonWinter, 2026}, + {time.Date(2025, 12, 31, 23, 59, 59, 999999999, time.UTC), GetSeasonKindCurrent, MediaSeasonWinter, 2025}, + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindNext, MediaSeasonSpring, 2025}, + } + + for _, tt := range tests { + t.Run(tt.now.Format(time.RFC3339), func(t *testing.T) { + t.Logf("%s", tt.now.Format(time.RFC3339)) + season, year := GetSeasonInfo(tt.now, tt.kind) + require.Equal(t, tt.expectedSeason, season, "Expected season %v, got %v", tt.expectedSeason, season) + require.Equal(t, tt.expectedYear, year, "Expected year %d, got %d", tt.expectedYear, year) + }) + } +} diff --git a/seanime-2.9.10/internal/api/animap/animap.go b/seanime-2.9.10/internal/api/animap/animap.go new file mode 100644 index 0000000..454ee44 --- /dev/null +++ b/seanime-2.9.10/internal/api/animap/animap.go @@ -0,0 +1,137 @@ +package animap + +import ( + "errors" + "io" + "net/http" + "seanime/internal/constants" + "seanime/internal/hook" + "seanime/internal/util/result" + "strconv" + + "github.com/goccy/go-json" +) + +type ( + Anime struct { + Title string `json:"title"` + Titles map[string]string `json:"titles,omitempty"` + StartDate string `json:"startDate,omitempty"` // YYYY-MM-DD + EndDate string `json:"endDate,omitempty"` // YYYY-MM-DD + Status string `json:"status"` // Finished, Airing, Upcoming, etc. + Type string `json:"type"` // TV, OVA, Movie, etc. + Episodes map[string]*Episode `json:"episodes,omitzero"` // Indexed by AniDB episode number, "1", "S1", etc. + Mappings *AnimeMapping `json:"mappings,omitzero"` + } + + AnimeMapping struct { + AnidbID int `json:"anidb_id,omitempty"` + AnilistID int `json:"anilist_id,omitempty"` + KitsuID int `json:"kitsu_id,omitempty"` + TheTvdbID int `json:"thetvdb_id,omitempty"` + TheMovieDbID string `json:"themoviedb_id,omitempty"` // Can be int or string, forced to string + MalID int `json:"mal_id,omitempty"` + LivechartID int `json:"livechart_id,omitempty"` + AnimePlanetID string `json:"animeplanet_id,omitempty"` // Can be int or string, forced to string + AnisearchID int `json:"anisearch_id,omitempty"` + SimklID int `json:"simkl_id,omitempty"` + NotifyMoeID string `json:"notifymoe_id,omitempty"` + AnimecountdownID int `json:"animecountdown_id,omitempty"` + Type string `json:"type,omitempty"` + } + + Episode struct { + AnidbEpisode string `json:"anidbEpisode"` + AnidbId int `json:"anidbEid"` + TvdbId int `json:"tvdbEid,omitempty"` + TvdbShowId int `json:"tvdbShowId,omitempty"` + AirDate string `json:"airDate,omitempty"` // YYYY-MM-DD + AnidbTitle string `json:"anidbTitle,omitempty"` // Title of the episode from AniDB + TvdbTitle string `json:"tvdbTitle,omitempty"` // Title of the episode from TVDB + Overview string `json:"overview,omitempty"` + Image string `json:"image,omitempty"` + Runtime int `json:"runtime,omitempty"` // minutes + Length string `json:"length,omitempty"` // Xm + SeasonNumber int `json:"seasonNumber,omitempty"` + SeasonName string `json:"seasonName,omitempty"` + Number int `json:"number"` + AbsoluteNumber int `json:"absoluteNumber,omitempty"` + } +) + +//---------------------------------------------------------------------------------------------------------------------- + +type Cache struct { + *result.Cache[string, *Anime] +} + +// FetchAnimapMedia fetches animap.Anime from the Animap API. +func FetchAnimapMedia(from string, id int) (*Anime, error) { + + // Event + reqEvent := &AnimapMediaRequestedEvent{ + From: from, + Id: id, + Media: &Anime{}, + } + err := hook.GlobalHookManager.OnAnimapMediaRequested().Trigger(reqEvent) + if err != nil { + return nil, err + } + + // If the hook prevented the default behavior, return the data + if reqEvent.DefaultPrevented { + return reqEvent.Media, nil + } + + from = reqEvent.From + id = reqEvent.Id + + apiUrl := constants.InternalMetadataURL + "/entry?" + from + "_id=" + strconv.Itoa(id) + + request, err := http.NewRequest("GET", apiUrl, nil) + if err != nil { + return nil, err + } + + request.Header.Set("X-Seanime-Version", "Seanime/"+constants.Version) + + // Send an HTTP GET request + response, err := http.DefaultClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + return nil, errors.New("not found on Animap") + } + + // Read the response body + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + // Unmarshal the JSON data into AnimapData + var media Anime + if err := json.Unmarshal(responseBody, &media); err != nil { + return nil, err + } + + // Event + event := &AnimapMediaEvent{ + Media: &media, + } + err = hook.GlobalHookManager.OnAnimapMedia().Trigger(event) + if err != nil { + return nil, err + } + + // If the hook prevented the default behavior, return the data + if event.DefaultPrevented { + return event.Media, nil + } + + return event.Media, nil +} diff --git a/seanime-2.9.10/internal/api/animap/hook_events.go b/seanime-2.9.10/internal/api/animap/hook_events.go new file mode 100644 index 0000000..68a83ad --- /dev/null +++ b/seanime-2.9.10/internal/api/animap/hook_events.go @@ -0,0 +1,19 @@ +package animap + +import "seanime/internal/hook_resolver" + +// AnimapMediaRequestedEvent is triggered when the Animap media is requested. +// Prevent default to skip the default behavior and return your own data. +type AnimapMediaRequestedEvent struct { + hook_resolver.Event + From string `json:"from"` + Id int `json:"id"` + // Empty data object, will be used if the hook prevents the default behavior + Media *Anime `json:"media"` +} + +// AnimapMediaEvent is triggered after processing AnimapMedia. +type AnimapMediaEvent struct { + hook_resolver.Event + Media *Anime `json:"media"` +} diff --git a/seanime-2.9.10/internal/api/anizip/anizip.go b/seanime-2.9.10/internal/api/anizip/anizip.go new file mode 100644 index 0000000..1067c6f --- /dev/null +++ b/seanime-2.9.10/internal/api/anizip/anizip.go @@ -0,0 +1,156 @@ +package anizip + +import ( + "errors" + "io" + "net/http" + "seanime/internal/hook" + "seanime/internal/util/result" + "strconv" + + "github.com/goccy/go-json" +) + +// AniZip is the API used for fetching anime metadata and mappings. + +type ( + Episode struct { + TvdbEid int `json:"tvdbEid,omitempty"` + AirDate string `json:"airdate,omitempty"` + SeasonNumber int `json:"seasonNumber,omitempty"` + EpisodeNumber int `json:"episodeNumber,omitempty"` + AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber,omitempty"` + Title map[string]string `json:"title,omitempty"` + Image string `json:"image,omitempty"` + Summary string `json:"summary,omitempty"` + Overview string `json:"overview,omitempty"` + Runtime int `json:"runtime,omitempty"` + Length int `json:"length,omitempty"` + Episode string `json:"episode,omitempty"` + AnidbEid int `json:"anidbEid,omitempty"` + Rating string `json:"rating,omitempty"` + } + + Mappings struct { + AnimeplanetID string `json:"animeplanet_id,omitempty"` + KitsuID int `json:"kitsu_id,omitempty"` + MalID int `json:"mal_id,omitempty"` + Type string `json:"type,omitempty"` + AnilistID int `json:"anilist_id,omitempty"` + AnisearchID int `json:"anisearch_id,omitempty"` + AnidbID int `json:"anidb_id,omitempty"` + NotifymoeID string `json:"notifymoe_id,omitempty"` + LivechartID int `json:"livechart_id,omitempty"` + ThetvdbID int `json:"thetvdb_id,omitempty"` + ImdbID string `json:"imdb_id,omitempty"` + ThemoviedbID string `json:"themoviedb_id,omitempty"` + } + + Media struct { + Titles map[string]string `json:"titles"` + Episodes map[string]Episode `json:"episodes"` + EpisodeCount int `json:"episodeCount"` + SpecialCount int `json:"specialCount"` + Mappings *Mappings `json:"mappings"` + } +) + +//---------------------------------------------------------------------------------------------------------------------- + +type Cache struct { + *result.Cache[string, *Media] +} + +func NewCache() *Cache { + return &Cache{result.NewCache[string, *Media]()} +} + +func GetCacheKey(from string, id int) string { + return from + strconv.Itoa(id) +} + +//---------------------------------------------------------------------------------------------------------------------- + +// FetchAniZipMedia fetches anizip.Media from the AniZip API. +func FetchAniZipMedia(from string, id int) (*Media, error) { + + // Event + reqEvent := &AnizipMediaRequestedEvent{ + From: from, + Id: id, + Media: &Media{}, + } + err := hook.GlobalHookManager.OnAnizipMediaRequested().Trigger(reqEvent) + if err != nil { + return nil, err + } + + // If the hook prevented the default behavior, return the data + if reqEvent.DefaultPrevented { + return reqEvent.Media, nil + } + + from = reqEvent.From + id = reqEvent.Id + + apiUrl := "https://api.ani.zip/v1/episodes?" + from + "_id=" + strconv.Itoa(id) + + // Send an HTTP GET request + response, err := http.Get(apiUrl) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + return nil, errors.New("not found on AniZip") + } + + // Read the response body + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + // Unmarshal the JSON data into AniZipData + var media Media + if err := json.Unmarshal(responseBody, &media); err != nil { + return nil, err + } + + // Event + event := &AnizipMediaEvent{ + Media: &media, + } + err = hook.GlobalHookManager.OnAnizipMedia().Trigger(event) + if err != nil { + return nil, err + } + + // If the hook prevented the default behavior, return the data + if event.DefaultPrevented { + return event.Media, nil + } + + return event.Media, nil +} + +// FetchAniZipMediaC is the same as FetchAniZipMedia but uses a cache. +// If the media is found in the cache, it will be returned. +// If the media is not found in the cache, it will be fetched and then added to the cache. +func FetchAniZipMediaC(from string, id int, cache *Cache) (*Media, error) { + + cacheV, ok := cache.Get(GetCacheKey(from, id)) + if ok { + return cacheV, nil + } + + media, err := FetchAniZipMedia(from, id) + if err != nil { + return nil, err + } + + cache.Set(GetCacheKey(from, id), media) + + return media, nil +} diff --git a/seanime-2.9.10/internal/api/anizip/anizip_helper.go b/seanime-2.9.10/internal/api/anizip/anizip_helper.go new file mode 100644 index 0000000..106eec2 --- /dev/null +++ b/seanime-2.9.10/internal/api/anizip/anizip_helper.go @@ -0,0 +1,65 @@ +package anizip + +func (m *Media) GetTitle() string { + if m == nil { + return "" + } + if len(m.Titles["en"]) > 0 { + return m.Titles["en"] + } + return m.Titles["ro"] +} + +func (m *Media) GetMappings() *Mappings { + if m == nil { + return &Mappings{} + } + return m.Mappings +} + +func (m *Media) FindEpisode(ep string) (*Episode, bool) { + if m.Episodes == nil { + return nil, false + } + episode, found := m.Episodes[ep] + if !found { + return nil, false + } + + return &episode, true +} + +func (m *Media) GetMainEpisodeCount() int { + if m == nil { + return 0 + } + return m.EpisodeCount +} + +// GetOffset returns the offset of the first episode relative to the absolute episode number. +// e.g, if the first episode's absolute number is 13, then the offset is 12. +func (m *Media) GetOffset() int { + if m == nil { + return 0 + } + firstEp, found := m.FindEpisode("1") + if !found { + return 0 + } + if firstEp.AbsoluteEpisodeNumber == 0 { + return 0 + } + return firstEp.AbsoluteEpisodeNumber - 1 +} + +func (e *Episode) GetTitle() string { + eng, ok := e.Title["en"] + if ok { + return eng + } + rom, ok := e.Title["x-jat"] + if ok { + return rom + } + return "" +} diff --git a/seanime-2.9.10/internal/api/anizip/anizip_test.go b/seanime-2.9.10/internal/api/anizip/anizip_test.go new file mode 100644 index 0000000..5e4a583 --- /dev/null +++ b/seanime-2.9.10/internal/api/anizip/anizip_test.go @@ -0,0 +1,37 @@ +package anizip + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestFetchAniZipMedia(t *testing.T) { + + tests := []struct { + name string + provider string + id int + expectedTitle string + }{ + { + name: "Cowboy Bebop", + provider: "anilist", + id: 1, + expectedTitle: "Cowboy Bebop", + }, + } + + for _, test := range tests { + + t.Run(test.name, func(t *testing.T) { + media, err := FetchAniZipMedia(test.provider, test.id) + if assert.NoError(t, err) { + if assert.NotNil(t, media) { + assert.Equal(t, media.GetTitle(), test.expectedTitle) + } + } + }) + + } + +} diff --git a/seanime-2.9.10/internal/api/anizip/hook_events.go b/seanime-2.9.10/internal/api/anizip/hook_events.go new file mode 100644 index 0000000..8fa8ee3 --- /dev/null +++ b/seanime-2.9.10/internal/api/anizip/hook_events.go @@ -0,0 +1,19 @@ +package anizip + +import "seanime/internal/hook_resolver" + +// AnizipMediaRequestedEvent is triggered when the AniZip media is requested. +// Prevent default to skip the default behavior and return your own data. +type AnizipMediaRequestedEvent struct { + hook_resolver.Event + From string `json:"from"` + Id int `json:"id"` + // Empty data object, will be used if the hook prevents the default behavior + Media *Media `json:"media"` +} + +// AnizipMediaEvent is triggered after processing AnizipMedia. +type AnizipMediaEvent struct { + hook_resolver.Event + Media *Media `json:"media"` +} diff --git a/seanime-2.9.10/internal/api/filler/filler.go b/seanime-2.9.10/internal/api/filler/filler.go new file mode 100644 index 0000000..b3679ff --- /dev/null +++ b/seanime-2.9.10/internal/api/filler/filler.go @@ -0,0 +1,185 @@ +package filler + +import ( + "fmt" + "seanime/internal/util" + "strings" + + "github.com/adrg/strutil/metrics" + "github.com/gocolly/colly" + "github.com/rs/zerolog" +) + +type ( + SearchOptions struct { + Titles []string + } + + SearchResult struct { + Slug string + Title string + } + + API interface { + Search(opts SearchOptions) (*SearchResult, error) + FindFillerData(slug string) (*Data, error) + } + + Data struct { + FillerEpisodes []string `json:"fillerEpisodes"` + } +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type ( + AnimeFillerList struct { + baseUrl string + userAgent string + logger *zerolog.Logger + } +) + +func NewAnimeFillerList(logger *zerolog.Logger) *AnimeFillerList { + return &AnimeFillerList{ + baseUrl: "https://www.animefillerlist.com", + userAgent: util.GetRandomUserAgent(), + logger: logger, + } +} + +func (af *AnimeFillerList) Search(opts SearchOptions) (result *SearchResult, err error) { + + defer util.HandlePanicInModuleWithError("api/metadata/filler/Search", &err) + + c := colly.NewCollector( + colly.UserAgent(af.userAgent), + ) + + ret := make([]*SearchResult, 0) + + c.OnHTML("div.Group > ul > li > a", func(e *colly.HTMLElement) { + ret = append(ret, &SearchResult{ + Slug: e.Attr("href"), + Title: e.Text, + }) + }) + + err = c.Visit(fmt.Sprintf("%s/shows", af.baseUrl)) + if err != nil { + return nil, err + } + + if len(ret) == 0 { + return nil, fmt.Errorf("no results found") + } + + lev := metrics.NewLevenshtein() + lev.CaseSensitive = false + + compResults := make([]struct { + OriginalValue string + Value string + Distance int + }, 0) + + for _, result := range ret { + firstTitle := result.Title + secondTitle := "" + + // Check if a second title exists between parentheses + if strings.LastIndex(firstTitle, " (") != -1 && strings.LastIndex(firstTitle, ")") != -1 { + secondTitle = firstTitle[strings.LastIndex(firstTitle, " (")+2 : strings.LastIndex(firstTitle, ")")] + if !util.IsMostlyLatinString(secondTitle) { + secondTitle = "" + } + } + + if secondTitle != "" { + firstTitle = firstTitle[:strings.LastIndex(firstTitle, " (")] + } + + for _, mediaTitle := range opts.Titles { + compResults = append(compResults, struct { + OriginalValue string + Value string + Distance int + }{ + OriginalValue: result.Title, + Value: firstTitle, + Distance: lev.Distance(mediaTitle, firstTitle), + }) + if secondTitle != "" { + compResults = append(compResults, struct { + OriginalValue string + Value string + Distance int + }{ + OriginalValue: result.Title, + Value: secondTitle, + Distance: lev.Distance(mediaTitle, secondTitle), + }) + } + } + } + + // Find the best match + bestResult := struct { + OriginalValue string + Value string + Distance int + }{} + + for _, result := range compResults { + if bestResult.OriginalValue == "" || result.Distance <= bestResult.Distance { + if bestResult.OriginalValue != "" && result.Distance == bestResult.Distance && len(result.OriginalValue) > len(bestResult.OriginalValue) { + continue + } + bestResult = result + } + } + + if bestResult.OriginalValue == "" { + return nil, fmt.Errorf("no results found") + } + + if bestResult.Distance > 10 { + return nil, fmt.Errorf("no results found") + } + + // Get the result + for _, r := range ret { + if r.Title == bestResult.OriginalValue { + return r, nil + } + } + + return +} + +func (af *AnimeFillerList) FindFillerData(slug string) (ret *Data, err error) { + + defer util.HandlePanicInModuleWithError("api/metadata/filler/FindFillerEpisodes", &err) + + c := colly.NewCollector( + colly.UserAgent(af.userAgent), + ) + + ret = &Data{ + FillerEpisodes: make([]string, 0), + } + + fillerEps := make([]string, 0) + c.OnHTML("tr.filler", func(e *colly.HTMLElement) { + fillerEps = append(fillerEps, e.ChildText("td.Number")) + }) + + err = c.Visit(fmt.Sprintf("%s%s", af.baseUrl, slug)) + if err != nil { + return nil, err + } + + ret.FillerEpisodes = fillerEps + + return +} diff --git a/seanime-2.9.10/internal/api/filler/filler_test.go b/seanime-2.9.10/internal/api/filler/filler_test.go new file mode 100644 index 0000000..9a3f87a --- /dev/null +++ b/seanime-2.9.10/internal/api/filler/filler_test.go @@ -0,0 +1,24 @@ +package filler + +import ( + "seanime/internal/util" + "testing" + + "github.com/davecgh/go-spew/spew" +) + +func TestAnimeFillerList_Search(t *testing.T) { + + af := NewAnimeFillerList(util.NewLogger()) + + opts := SearchOptions{ + Titles: []string{"Hunter x Hunter (2011)"}, + } + + ret, err := af.Search(opts) + if err != nil { + t.Error(err) + } + + spew.Dump(ret) +} diff --git a/seanime-2.9.10/internal/api/mal/anime.go b/seanime-2.9.10/internal/api/mal/anime.go new file mode 100644 index 0000000..bdeca6d --- /dev/null +++ b/seanime-2.9.10/internal/api/mal/anime.go @@ -0,0 +1,186 @@ +package mal + +import ( + "fmt" + "net/url" +) + +const ( + BaseAnimeFields string = "id,title,main_picture,alternative_titles,start_date,end_date,start_season,nsfw,synopsis,num_episodes,mean,rank,popularity,media_type,status" +) + +type ( + BasicAnime struct { + ID int `json:"id"` + Title string `json:"title"` + MainPicture struct { + Medium string `json:"medium"` + Large string `json:"large"` + } `json:"main_picture"` + AlternativeTitles struct { + Synonyms []string `json:"synonyms"` + En string `json:"en"` + Ja string `json:"ja"` + } `json:"alternative_titles"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + StartSeason struct { + Year int `json:"year"` + Season string `json:"season"` + } `json:"start_season"` + Synopsis string `json:"synopsis"` + NSFW string `json:"nsfw"` + NumEpisodes int `json:"num_episodes"` + Mean float32 `json:"mean"` + Rank int `json:"rank"` + Popularity int `json:"popularity"` + MediaType MediaType `json:"media_type"` + Status MediaStatus `json:"status"` + } + AnimeListEntry struct { + Node struct { + ID int `json:"id"` + Title string `json:"title"` + MainPicture struct { + Medium string `json:"medium"` + Large string `json:"large"` + } `json:"main_picture"` + } `json:"node"` + ListStatus struct { + Status MediaListStatus `json:"status"` + IsRewatching bool `json:"is_rewatching"` + NumEpisodesWatched int `json:"num_episodes_watched"` + Score int `json:"score"` + UpdatedAt string `json:"updated_at"` + } `json:"list_status"` + } +) + +func (w *Wrapper) GetAnimeDetails(mId int) (*BasicAnime, error) { + w.logger.Debug().Int("mId", mId).Msg("mal: Getting anime details") + + reqUrl := fmt.Sprintf("%s/anime/%d?fields=%s", ApiBaseURL, mId, BaseAnimeFields) + + if w.AccessToken == "" { + return nil, fmt.Errorf("access token is empty") + } + + var anime BasicAnime + err := w.doQuery("GET", reqUrl, nil, "application/json", &anime) + if err != nil { + w.logger.Error().Err(err).Int("mId", mId).Msg("mal: Failed to get anime details") + return nil, err + } + + w.logger.Info().Int("mId", mId).Msg("mal: Fetched anime details") + + return &anime, nil +} + +func (w *Wrapper) GetAnimeCollection() ([]*AnimeListEntry, error) { + w.logger.Debug().Msg("mal: Getting anime collection") + + reqUrl := fmt.Sprintf("%s/users/@me/animelist?fields=list_status&limit=1000", ApiBaseURL) + + type response struct { + Data []*AnimeListEntry `json:"data"` + } + + var data response + err := w.doQuery("GET", reqUrl, nil, "application/json", &data) + if err != nil { + w.logger.Error().Err(err).Msg("mal: Failed to get anime collection") + return nil, err + } + + w.logger.Info().Msg("mal: Fetched anime collection") + + return data.Data, nil +} + +type AnimeListProgressParams struct { + NumEpisodesWatched *int +} + +func (w *Wrapper) UpdateAnimeProgress(opts *AnimeListProgressParams, mId int) error { + w.logger.Debug().Int("mId", mId).Msg("mal: Updating anime progress") + + // Get anime details + anime, err := w.GetAnimeDetails(mId) + if err != nil { + return err + } + + status := MediaListStatusWatching + if anime.Status == MediaStatusFinishedAiring && anime.NumEpisodes > 0 && anime.NumEpisodes <= *opts.NumEpisodesWatched { + status = MediaListStatusCompleted + } + + if anime.NumEpisodes > 0 && *opts.NumEpisodesWatched > anime.NumEpisodes { + *opts.NumEpisodesWatched = anime.NumEpisodes + } + + // Update MAL list entry + err = w.UpdateAnimeListStatus(&AnimeListStatusParams{ + Status: &status, + NumEpisodesWatched: opts.NumEpisodesWatched, + }, mId) + + if err == nil { + w.logger.Info().Int("mId", mId).Msg("mal: Updated anime progress") + } + + return err +} + +type AnimeListStatusParams struct { + Status *MediaListStatus + IsRewatching *bool + NumEpisodesWatched *int + Score *int +} + +func (w *Wrapper) UpdateAnimeListStatus(opts *AnimeListStatusParams, mId int) error { + w.logger.Debug().Int("mId", mId).Msg("mal: Updating anime list status") + + reqUrl := fmt.Sprintf("%s/anime/%d/my_list_status", ApiBaseURL, mId) + + // Build URL + urlData := url.Values{} + if opts.Status != nil { + urlData.Set("status", string(*opts.Status)) + } + if opts.IsRewatching != nil { + urlData.Set("is_rewatching", fmt.Sprintf("%t", *opts.IsRewatching)) + } + if opts.NumEpisodesWatched != nil { + urlData.Set("num_watched_episodes", fmt.Sprintf("%d", *opts.NumEpisodesWatched)) + } + if opts.Score != nil { + urlData.Set("score", fmt.Sprintf("%d", *opts.Score)) + } + encodedData := urlData.Encode() + + err := w.doMutation("PATCH", reqUrl, encodedData) + if err != nil { + w.logger.Error().Err(err).Int("mId", mId).Msg("mal: Failed to update anime list status") + return err + } + return nil +} + +func (w *Wrapper) DeleteAnimeListItem(mId int) error { + w.logger.Debug().Int("mId", mId).Msg("mal: Deleting anime list item") + + reqUrl := fmt.Sprintf("%s/anime/%d/my_list_status", ApiBaseURL, mId) + + err := w.doMutation("DELETE", reqUrl, "") + if err != nil { + w.logger.Error().Err(err).Int("mId", mId).Msg("mal: Failed to delete anime list item") + return err + } + + w.logger.Info().Int("mId", mId).Msg("mal: Deleted anime list item") + + return nil +} diff --git a/seanime-2.9.10/internal/api/mal/anime_test.go b/seanime-2.9.10/internal/api/mal/anime_test.go new file mode 100644 index 0000000..f494bfd --- /dev/null +++ b/seanime-2.9.10/internal/api/mal/anime_test.go @@ -0,0 +1,62 @@ +package mal + +import ( + "github.com/davecgh/go-spew/spew" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" +) + +func TestGetAnimeDetails(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MyAnimeList()) + + malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger()) + + res, err := malWrapper.GetAnimeDetails(51179) + + spew.Dump(res) + + if err != nil { + t.Fatalf("error while fetching media, %v", err) + } + + t.Log(res.Title) +} + +func TestGetAnimeCollection(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MyAnimeList()) + + malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger()) + + res, err := malWrapper.GetAnimeCollection() + + if err != nil { + t.Fatalf("error while fetching anime collection, %v", err) + } + + for _, entry := range res { + t.Log(entry.Node.Title) + if entry.Node.ID == 51179 { + spew.Dump(entry) + } + } +} + +func TestUpdateAnimeListStatus(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MyAnimeList(), test_utils.MyAnimeListMutation()) + + malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger()) + + mId := 51179 + progress := 2 + status := MediaListStatusWatching + + err := malWrapper.UpdateAnimeListStatus(&AnimeListStatusParams{ + Status: &status, + NumEpisodesWatched: &progress, + }, mId) + + if err != nil { + t.Fatalf("error while fetching media, %v", err) + } +} diff --git a/seanime-2.9.10/internal/api/mal/manga.go b/seanime-2.9.10/internal/api/mal/manga.go new file mode 100644 index 0000000..a012415 --- /dev/null +++ b/seanime-2.9.10/internal/api/mal/manga.go @@ -0,0 +1,185 @@ +package mal + +import ( + "fmt" + "net/url" +) + +const ( + BaseMangaFields string = "id,title,main_picture,alternative_titles,start_date,end_date,nsfw,synopsis,num_volumes,num_chapters,mean,rank,popularity,media_type,status" +) + +type ( + BasicManga struct { + ID int `json:"id"` + Title string `json:"title"` + MainPicture struct { + Medium string `json:"medium"` + Large string `json:"large"` + } `json:"main_picture"` + AlternativeTitles struct { + Synonyms []string `json:"synonyms"` + En string `json:"en"` + Ja string `json:"ja"` + } `json:"alternative_titles"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + Synopsis string `json:"synopsis"` + NSFW string `json:"nsfw"` + NumVolumes int `json:"num_volumes"` + NumChapters int `json:"num_chapters"` + Mean float32 `json:"mean"` + Rank int `json:"rank"` + Popularity int `json:"popularity"` + MediaType MediaType `json:"media_type"` + Status MediaStatus `json:"status"` + } + + MangaListEntry struct { + Node struct { + ID int `json:"id"` + Title string `json:"title"` + MainPicture struct { + Medium string `json:"medium"` + Large string `json:"large"` + } `json:"main_picture"` + } `json:"node"` + ListStatus struct { + Status MediaListStatus `json:"status"` + IsRereading bool `json:"is_rereading"` + NumVolumesRead int `json:"num_volumes_read"` + NumChaptersRead int `json:"num_chapters_read"` + Score int `json:"score"` + UpdatedAt string `json:"updated_at"` + } `json:"list_status"` + } +) + +func (w *Wrapper) GetMangaDetails(mId int) (*BasicManga, error) { + w.logger.Debug().Int("mId", mId).Msg("mal: Getting manga details") + + reqUrl := fmt.Sprintf("%s/manga/%d?fields=%s", ApiBaseURL, mId, BaseMangaFields) + + if w.AccessToken == "" { + return nil, fmt.Errorf("access token is empty") + } + + var manga BasicManga + err := w.doQuery("GET", reqUrl, nil, "application/json", &manga) + if err != nil { + w.logger.Error().Err(err).Msg("mal: Failed to get manga details") + return nil, err + } + + w.logger.Info().Int("mId", mId).Msg("mal: Fetched manga details") + + return &manga, nil +} + +func (w *Wrapper) GetMangaCollection() ([]*MangaListEntry, error) { + w.logger.Debug().Msg("mal: Getting manga collection") + + reqUrl := fmt.Sprintf("%s/users/@me/mangalist?fields=list_status&limit=1000", ApiBaseURL) + + type response struct { + Data []*MangaListEntry `json:"data"` + } + + var data response + err := w.doQuery("GET", reqUrl, nil, "application/json", &data) + if err != nil { + w.logger.Error().Err(err).Msg("mal: Failed to get manga collection") + return nil, err + } + + w.logger.Info().Msg("mal: Fetched manga collection") + + return data.Data, nil +} + +type MangaListProgressParams struct { + NumChaptersRead *int +} + +func (w *Wrapper) UpdateMangaProgress(opts *MangaListProgressParams, mId int) error { + w.logger.Debug().Int("mId", mId).Msg("mal: Updating manga progress") + + // Get manga details + manga, err := w.GetMangaDetails(mId) + if err != nil { + return err + } + + status := MediaListStatusReading + if manga.Status == MediaStatusFinished && manga.NumChapters > 0 && manga.NumChapters <= *opts.NumChaptersRead { + status = MediaListStatusCompleted + } + + if manga.NumChapters > 0 && *opts.NumChaptersRead > manga.NumChapters { + *opts.NumChaptersRead = manga.NumChapters + } + + // Update MAL list entry + err = w.UpdateMangaListStatus(&MangaListStatusParams{ + Status: &status, + NumChaptersRead: opts.NumChaptersRead, + }, mId) + + if err == nil { + w.logger.Info().Int("mId", mId).Msg("mal: Updated manga progress") + } + + return err +} + +type MangaListStatusParams struct { + Status *MediaListStatus + IsRereading *bool + NumChaptersRead *int + Score *int +} + +func (w *Wrapper) UpdateMangaListStatus(opts *MangaListStatusParams, mId int) error { + w.logger.Debug().Int("mId", mId).Msg("mal: Updating manga list status") + + reqUrl := fmt.Sprintf("%s/manga/%d/my_list_status", ApiBaseURL, mId) + + // Build URL + urlData := url.Values{} + if opts.Status != nil { + urlData.Set("status", string(*opts.Status)) + } + if opts.IsRereading != nil { + urlData.Set("is_rereading", fmt.Sprintf("%t", *opts.IsRereading)) + } + if opts.NumChaptersRead != nil { + urlData.Set("num_chapters_read", fmt.Sprintf("%d", *opts.NumChaptersRead)) + } + if opts.Score != nil { + urlData.Set("score", fmt.Sprintf("%d", *opts.Score)) + } + encodedData := urlData.Encode() + + err := w.doMutation("PATCH", reqUrl, encodedData) + if err != nil { + w.logger.Error().Err(err).Msg("mal: Failed to update manga list status") + return err + } + return nil +} + +func (w *Wrapper) DeleteMangaListItem(mId int) error { + w.logger.Debug().Int("mId", mId).Msg("mal: Deleting manga list item") + + reqUrl := fmt.Sprintf("%s/manga/%d/my_list_status", ApiBaseURL, mId) + + err := w.doMutation("DELETE", reqUrl, "") + if err != nil { + w.logger.Error().Err(err).Msg("mal: Failed to delete manga list item") + return err + } + + w.logger.Info().Int("mId", mId).Msg("mal: Deleted manga list item") + + return nil +} diff --git a/seanime-2.9.10/internal/api/mal/manga_test.go b/seanime-2.9.10/internal/api/mal/manga_test.go new file mode 100644 index 0000000..70f4214 --- /dev/null +++ b/seanime-2.9.10/internal/api/mal/manga_test.go @@ -0,0 +1,62 @@ +package mal + +import ( + "github.com/davecgh/go-spew/spew" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" +) + +func TestGetMangaDetails(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MyAnimeList()) + + malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger()) + + res, err := malWrapper.GetMangaDetails(13) + + spew.Dump(res) + + if err != nil { + t.Fatalf("error while fetching media, %v", err) + } + + t.Log(res.Title) +} + +func TestGetMangaCollection(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MyAnimeList()) + + malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger()) + + res, err := malWrapper.GetMangaCollection() + + if err != nil { + t.Fatalf("error while fetching anime collection, %v", err) + } + + for _, entry := range res { + t.Log(entry.Node.Title) + if entry.Node.ID == 13 { + spew.Dump(entry) + } + } +} + +func TestUpdateMangaListStatus(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MyAnimeList(), test_utils.MyAnimeListMutation()) + + malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger()) + + mId := 13 + progress := 1000 + status := MediaListStatusReading + + err := malWrapper.UpdateMangaListStatus(&MangaListStatusParams{ + Status: &status, + NumChaptersRead: &progress, + }, mId) + + if err != nil { + t.Fatalf("error while fetching media, %v", err) + } +} diff --git a/seanime-2.9.10/internal/api/mal/search.go b/seanime-2.9.10/internal/api/mal/search.go new file mode 100644 index 0000000..053279c --- /dev/null +++ b/seanime-2.9.10/internal/api/mal/search.go @@ -0,0 +1,232 @@ +package mal + +import ( + "errors" + "fmt" + "github.com/goccy/go-json" + "github.com/samber/lo" + "io" + "math" + "net/http" + "net/url" + "regexp" + "seanime/internal/util/comparison" + "seanime/internal/util/result" + "sort" + "strings" +) + +type ( + SearchResultPayload struct { + MediaType string `json:"media_type"` + StartYear int `json:"start_year"` + Aired string `json:"aired,omitempty"` + Score string `json:"score"` + Status string `json:"status"` + } + + SearchResultAnime struct { + ID int `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + URL string `json:"url"` + ImageURL string `json:"image_url"` + ThumbnailURL string `json:"thumbnail_url"` + Payload *SearchResultPayload `json:"payload"` + ESScore float64 `json:"es_score"` + } + + SearchResult struct { + Categories []*struct { + Type string `json:"type"` + Items []*SearchResultAnime `json:"items"` + } `json:"categories"` + } + + SearchCache struct { + *result.Cache[int, *SearchResultAnime] + } +) + +//---------------------------------------------------------------------------------------------------------------------- + +// SearchWithMAL uses MAL's search API to find suggestions that match the title provided. +func SearchWithMAL(title string, slice int) ([]*SearchResultAnime, error) { + + url := "https://myanimelist.net/search/prefix.json?type=anime&v=1&keyword=" + url.QueryEscape(title) + + res, err := http.Get(url) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request failed with status code: %d", res.StatusCode) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var bodyMap SearchResult + err = json.Unmarshal(body, &bodyMap) + if err != nil { + return nil, fmt.Errorf("unmarshaling error: %v", err) + } + + if bodyMap.Categories == nil { + return nil, fmt.Errorf("missing 'categories' in response") + } + + items := make([]*SearchResultAnime, 0) + for _, cat := range bodyMap.Categories { + if cat.Type == "anime" { + items = append(items, cat.Items...) + } + } + + if len(items) > slice { + return items[:slice], nil + } + return items, nil +} + +// AdvancedSearchWithMAL is like SearchWithMAL, but it uses additional algorithms to find the best match. +func AdvancedSearchWithMAL(title string) (*SearchResultAnime, error) { + + if len(title) == 0 { + return nil, fmt.Errorf("title is empty") + } + + // trim the title + title = strings.ToLower(strings.TrimSpace(title)) + + // MAL typically doesn't use "cour" + re := regexp.MustCompile(`\bcour\b`) + title = re.ReplaceAllString(title, "part") + + // fetch suggestions from MAL + suggestions, err := SearchWithMAL(title, 8) + if err != nil { + return nil, err + } + + // sort the suggestions by score + sort.Slice(suggestions, func(i, j int) bool { + return suggestions[i].ESScore > suggestions[j].ESScore + }) + + // keep anime that have aired + suggestions = lo.Filter(suggestions, func(n *SearchResultAnime, index int) bool { + return n.ESScore >= 0.1 && n.Payload.Status != "Not yet aired" + }) + // reduce score if anime is older than 2006 + suggestions = lo.Map(suggestions, func(n *SearchResultAnime, index int) *SearchResultAnime { + if n.Payload.StartYear < 2006 { + n.ESScore -= 0.1 + } + return n + }) + + tparts := strings.Fields(title) + tsub := tparts[0] + if len(tparts) > 1 { + tsub += " " + tparts[1] + } + tsub = strings.TrimSpace(tsub) + + // + t1, foundT1 := lo.Find(suggestions, func(n *SearchResultAnime) bool { + nTitle := strings.ToLower(n.Name) + + _tsub := tparts[0] + if len(tparts) > 1 { + _tsub += " " + tparts[1] + } + _tsub = strings.TrimSpace(_tsub) + + re := regexp.MustCompile(`\b(film|movie|season|part|(s\d{2}e?))\b`) + + return strings.HasPrefix(nTitle, tsub) && n.Payload.MediaType == "TV" && !re.MatchString(nTitle) + }) + + // very generous + t2, foundT2 := lo.Find(suggestions, func(n *SearchResultAnime) bool { + nTitle := strings.ToLower(n.Name) + + _tsub := tparts[0] + + re := regexp.MustCompile(`\b(film|movie|season|part|(s\d{2}e?))\b`) + + return strings.HasPrefix(nTitle, _tsub) && n.Payload.MediaType == "TV" && !re.MatchString(nTitle) + }) + + levResult, found := comparison.FindBestMatchWithLevenshtein(&title, lo.Map(suggestions, func(n *SearchResultAnime, index int) *string { return &n.Name })) + + if !found { + return nil, errors.New("couldn't find a suggestion from levenshtein") + } + + levSuggestion, found := lo.Find(suggestions, func(n *SearchResultAnime) bool { + return strings.ToLower(n.Name) == strings.ToLower(*levResult.Value) + }) + + if !found { + return nil, errors.New("couldn't locate lenshtein result") + } + + if foundT1 { + d, found := comparison.FindBestMatchWithLevenshtein(&tsub, []*string{&title, new(string)}) + if found && len(*d.Value) > 0 { + if d.Distance <= 1 { + return t1, nil + } + } + } + + // Strong correlation using MAL + if suggestions[0].ESScore >= 4.5 { + return suggestions[0], nil + } + + // Very Likely match using distance + if levResult.Distance <= 4 { + return levSuggestion, nil + } + + if suggestions[0].ESScore < 5 { + + // Likely match using [startsWith] + if foundT1 { + dev := math.Abs(t1.ESScore-suggestions[0].ESScore) < 2.0 + if len(tsub) > 6 && dev { + return t1, nil + } + } + // Likely match using [startsWith] + if foundT2 { + dev := math.Abs(t2.ESScore-suggestions[0].ESScore) < 2.0 + if len(tparts[0]) > 6 && dev { + return t2, nil + } + } + + // Likely match using distance + if levSuggestion.ESScore >= 1 && !(suggestions[0].ESScore > 3) { + return suggestions[0], nil + } + + // Less than likely match using MAL + return suggestions[0], nil + + } + + // Distance above threshold, falling back to first MAL suggestion above + if levResult.Distance >= 5 && suggestions[0].ESScore >= 1 { + return suggestions[0], nil + } + + return nil, nil +} diff --git a/seanime-2.9.10/internal/api/mal/search_test.go b/seanime-2.9.10/internal/api/mal/search_test.go new file mode 100644 index 0000000..9c28a0a --- /dev/null +++ b/seanime-2.9.10/internal/api/mal/search_test.go @@ -0,0 +1,34 @@ +package mal + +import ( + "seanime/internal/test_utils" + "testing" +) + +func TestSearchWithMAL(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MyAnimeList()) + + res, err := SearchWithMAL("bungo stray dogs", 4) + + if err != nil { + t.Fatalf("error while fetching media, %v", err) + } + + for _, m := range res { + t.Log(m.Name) + } + +} + +func TestAdvancedSearchWithMal(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MyAnimeList()) + + res, err := AdvancedSearchWithMAL("sousou no frieren") + + if err != nil { + t.Fatal("expected result, got error: ", err) + } + + t.Log(res.Name) + +} diff --git a/seanime-2.9.10/internal/api/mal/types.go b/seanime-2.9.10/internal/api/mal/types.go new file mode 100644 index 0000000..dfb77f1 --- /dev/null +++ b/seanime-2.9.10/internal/api/mal/types.go @@ -0,0 +1,40 @@ +package mal + +import "time" + +type ( + RequestOptions struct { + AccessToken string + RefreshToken string + ExpiresAt time.Time + } + + MediaType string + MediaStatus string + MediaListStatus string +) + +const ( + MediaTypeTV MediaType = "tv" // Anime + MediaTypeOVA MediaType = "ova" // Anime + MediaTypeMovie MediaType = "movie" // Anime + MediaTypeSpecial MediaType = "special" // Anime + MediaTypeONA MediaType = "ona" // Anime + MediaTypeMusic MediaType = "music" + MediaTypeManga MediaType = "manga" // Manga + MediaTypeNovel MediaType = "novel" // Manga + MediaTypeOneShot MediaType = "oneshot" // Manga + MediaStatusFinishedAiring MediaStatus = "finished_airing" // Anime + MediaStatusCurrentlyAiring MediaStatus = "currently_airing" // Anime + MediaStatusNotYetAired MediaStatus = "not_yet_aired" // Anime + MediaStatusFinished MediaStatus = "finished" // Manga + MediaStatusCurrentlyPublishing MediaStatus = "currently_publishing" // Manga + MediaStatusNotYetPublished MediaStatus = "not_yet_published" // Manga + MediaListStatusReading MediaListStatus = "reading" // Manga + MediaListStatusWatching MediaListStatus = "watching" // Anime + MediaListStatusCompleted MediaListStatus = "completed" + MediaListStatusOnHold MediaListStatus = "on_hold" + MediaListStatusDropped MediaListStatus = "dropped" + MediaListStatusPlanToWatch MediaListStatus = "plan_to_watch" // Anime + MediaListStatusPlanToRead MediaListStatus = "plan_to_read" // Manga +) diff --git a/seanime-2.9.10/internal/api/mal/wrapper.go b/seanime-2.9.10/internal/api/mal/wrapper.go new file mode 100644 index 0000000..c7a845c --- /dev/null +++ b/seanime-2.9.10/internal/api/mal/wrapper.go @@ -0,0 +1,160 @@ +package mal + +import ( + "fmt" + "github.com/goccy/go-json" + "github.com/rs/zerolog" + "io" + "net/http" + "net/url" + "seanime/internal/database/db" + "seanime/internal/database/models" + "strings" + "time" +) + +const ( + ApiBaseURL string = "https://api.myanimelist.net/v2" +) + +type ( + Wrapper struct { + AccessToken string + client *http.Client + logger *zerolog.Logger + } +) + +func NewWrapper(accessToken string, logger *zerolog.Logger) *Wrapper { + return &Wrapper{ + AccessToken: accessToken, + client: &http.Client{}, + logger: logger, + } +} + +func (w *Wrapper) doQuery(method, uri string, body io.Reader, contentType string, data interface{}) error { + req, err := http.NewRequest(method, uri, body) + if err != nil { + return err + } + req.Header.Add("Content-Type", contentType) + req.Header.Add("Authorization", "Bearer "+w.AccessToken) + + // Make the HTTP request + resp, err := w.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if !((resp.StatusCode >= 200) && (resp.StatusCode <= 299)) { + return fmt.Errorf("invalid response status %s", resp.Status) + } + + if err := json.NewDecoder(resp.Body).Decode(data); err != nil { + return err + } + return nil +} + +func (w *Wrapper) doMutation(method, uri, encodedParams string) error { + var reader io.Reader + reader = nil + if encodedParams != "" { + reader = strings.NewReader(encodedParams) + } + + req, err := http.NewRequest(method, uri, reader) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Authorization", "Bearer "+w.AccessToken) + + // Make the HTTP request + resp, err := w.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if !((resp.StatusCode >= 200) && (resp.StatusCode <= 299)) { + return fmt.Errorf("invalid response status %s", resp.Status) + } + + return nil +} + +func VerifyMALAuth(malInfo *models.Mal, db *db.Database, logger *zerolog.Logger) (*models.Mal, error) { + + // Token has not expired + if malInfo.TokenExpiresAt.After(time.Now()) { + logger.Debug().Msg("mal: Token is still valid") + return malInfo, nil + } + + // Token is expired, refresh it + client := &http.Client{} + + // Build URL + urlData := url.Values{} + urlData.Set("grant_type", "refresh_token") + urlData.Set("refresh_token", malInfo.RefreshToken) + encodedData := urlData.Encode() + + req, err := http.NewRequest("POST", "https://myanimelist.net/v1/oauth2/token", strings.NewReader(encodedData)) + if err != nil { + logger.Error().Err(err).Msg("mal: Failed to create request") + return malInfo, err + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Authorization", "Basic "+malInfo.AccessToken) + + // Response + res, err := client.Do(req) + if err != nil { + logger.Error().Err(err).Msg("mal: Failed to refresh token") + return malInfo, err + } + defer res.Body.Close() + + type malAuthResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int32 `json:"expires_in"` + TokenType string `json:"token_type"` + } + + ret := malAuthResponse{} + if err := json.NewDecoder(res.Body).Decode(&ret); err != nil { + return malInfo, err + } + + if ret.AccessToken == "" { + logger.Error().Msgf("mal: Failed to refresh token %s", res.Status) + return malInfo, fmt.Errorf("mal: Failed to refresh token %s", res.Status) + } + + // Save + updatedMalInfo := models.Mal{ + BaseModel: models.BaseModel{ + ID: 1, + UpdatedAt: time.Now(), + }, + Username: "", + AccessToken: ret.AccessToken, + RefreshToken: ret.RefreshToken, + TokenExpiresAt: time.Now().Add(time.Duration(ret.ExpiresIn) * time.Second), + } + + _, err = db.UpsertMalInfo(&updatedMalInfo) + if err != nil { + logger.Error().Err(err).Msg("mal: Failed to save updated MAL info") + return malInfo, err + } + + logger.Info().Msg("mal: Refreshed token") + + return &updatedMalInfo, nil +} diff --git a/seanime-2.9.10/internal/api/mangaupdates/mangaupdates_test.go b/seanime-2.9.10/internal/api/mangaupdates/mangaupdates_test.go new file mode 100644 index 0000000..723c0dc --- /dev/null +++ b/seanime-2.9.10/internal/api/mangaupdates/mangaupdates_test.go @@ -0,0 +1,65 @@ +package mangaupdates + +import ( + "bytes" + "github.com/davecgh/go-spew/spew" + "github.com/goccy/go-json" + "github.com/stretchr/testify/require" + "net/http" + "strings" + "testing" + "time" +) + +func TestApi(t *testing.T) { + + tests := []struct { + title string + startDate string + }{ + { + title: "Dandadan", + startDate: "2021-04-06", + }, + } + + type searchReleaseBody struct { + Search string `json:"search"` + StartDate string `json:"start_date,omitempty"` + } + + var apiUrl = "https://api.mangaupdates.com/v1/releases/search" + + for _, test := range tests { + t.Run(test.title, func(t *testing.T) { + + client := http.Client{Timeout: 10 * time.Second} + + body := searchReleaseBody{ + Search: strings.ToLower(test.title), + StartDate: test.startDate, + } + + bodyB, err := json.Marshal(body) + require.NoError(t, err) + + req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(bodyB)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + var result interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + + spew.Dump(result) + + }) + } + +} diff --git a/seanime-2.9.10/internal/api/metadata/anime.go b/seanime-2.9.10/internal/api/metadata/anime.go new file mode 100644 index 0000000..c6b4f1f --- /dev/null +++ b/seanime-2.9.10/internal/api/metadata/anime.go @@ -0,0 +1,144 @@ +package metadata + +import ( + "regexp" + "seanime/internal/api/anilist" + "seanime/internal/hook" + "seanime/internal/util" + "seanime/internal/util/filecache" + "strconv" + + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +type ( + AnimeWrapperImpl struct { + metadata mo.Option[*AnimeMetadata] + baseAnime *anilist.BaseAnime + fileCacher *filecache.Cacher + logger *zerolog.Logger + } +) + +func (aw *AnimeWrapperImpl) GetEpisodeMetadata(epNum int) (ret EpisodeMetadata) { + if aw == nil || aw.baseAnime == nil { + return + } + + ret = EpisodeMetadata{ + AnidbId: 0, + TvdbId: 0, + Title: "", + Image: "", + AirDate: "", + Length: 0, + Summary: "", + Overview: "", + EpisodeNumber: epNum, + Episode: strconv.Itoa(epNum), + SeasonNumber: 0, + AbsoluteEpisodeNumber: 0, + AnidbEid: 0, + } + + defer util.HandlePanicInModuleThen("api/metadata/GetEpisodeMetadata", func() {}) + + reqEvent := &AnimeEpisodeMetadataRequestedEvent{} + reqEvent.MediaId = aw.baseAnime.GetID() + reqEvent.EpisodeNumber = epNum + reqEvent.EpisodeMetadata = &ret + _ = hook.GlobalHookManager.OnAnimeEpisodeMetadataRequested().Trigger(reqEvent) + epNum = reqEvent.EpisodeNumber + + // Default prevented by hook, return the metadata + if reqEvent.DefaultPrevented { + if reqEvent.EpisodeMetadata == nil { + return ret + } + return *reqEvent.EpisodeMetadata + } + + // + // Process + // + + episode := mo.None[*EpisodeMetadata]() + if aw.metadata.IsAbsent() { + ret.Image = aw.baseAnime.GetBannerImageSafe() + } else { + episodeF, found := aw.metadata.MustGet().FindEpisode(strconv.Itoa(epNum)) + if found { + episode = mo.Some(episodeF) + } + } + + // If we don't have Animap metadata, just return the metadata containing the image + if episode.IsAbsent() { + return ret + } + + ret = *episode.MustGet() + + // If TVDB image is not set, use Animap image, if that is not set, use the AniList banner image + if ret.Image == "" { + // Set Animap image if TVDB image is not set + if episode.MustGet().Image != "" { + ret.Image = episode.MustGet().Image + } else { + // If Animap image is not set, use the base media image + ret.Image = aw.baseAnime.GetBannerImageSafe() + } + } + + // Event + event := &AnimeEpisodeMetadataEvent{ + EpisodeMetadata: &ret, + EpisodeNumber: epNum, + MediaId: aw.baseAnime.GetID(), + } + _ = hook.GlobalHookManager.OnAnimeEpisodeMetadata().Trigger(event) + if event.EpisodeMetadata == nil { + return ret + } + ret = *event.EpisodeMetadata + + return ret +} + +func ExtractEpisodeInteger(s string) (int, bool) { + pattern := "[0-9]+" + regex := regexp.MustCompile(pattern) + + // Find the first match in the input string. + match := regex.FindString(s) + + if match != "" { + // Convert the matched string to an integer. + num, err := strconv.Atoi(match) + if err != nil { + return 0, false + } + return num, true + } + + return 0, false +} + +func OffsetAnidbEpisode(s string, offset int) string { + pattern := "([0-9]+)" + regex := regexp.MustCompile(pattern) + + // Replace the first matched integer with the incremented value. + result := regex.ReplaceAllStringFunc(s, func(matched string) string { + num, err := strconv.Atoi(matched) + if err == nil { + num = num + offset + return strconv.Itoa(num) + } else { + return matched + } + }) + + return result +} diff --git a/seanime-2.9.10/internal/api/metadata/anime_test.go b/seanime-2.9.10/internal/api/metadata/anime_test.go new file mode 100644 index 0000000..ce98aee --- /dev/null +++ b/seanime-2.9.10/internal/api/metadata/anime_test.go @@ -0,0 +1,26 @@ +package metadata + +import ( + "testing" +) + +func TestOffsetEpisode(t *testing.T) { + + cases := []struct { + input string + expected string + }{ + {"S1", "S2"}, + {"OP1", "OP2"}, + {"1", "2"}, + {"OP", "OP"}, + } + + for _, c := range cases { + actual := OffsetAnidbEpisode(c.input, 1) + if actual != c.expected { + t.Errorf("OffsetAnidbEpisode(%s, 1) == %s, expected %s", c.input, actual, c.expected) + } + } + +} diff --git a/seanime-2.9.10/internal/api/metadata/hook_events.go b/seanime-2.9.10/internal/api/metadata/hook_events.go new file mode 100644 index 0000000..95e6e02 --- /dev/null +++ b/seanime-2.9.10/internal/api/metadata/hook_events.go @@ -0,0 +1,47 @@ +package metadata + +import "seanime/internal/hook_resolver" + +// AnimeMetadataRequestedEvent is triggered when anime metadata is requested and right before the metadata is processed. +// This event is followed by [AnimeMetadataEvent] which is triggered when the metadata is available. +// Prevent default to skip the default behavior and return the modified metadata. +// If the modified metadata is nil, an error will be returned. +type AnimeMetadataRequestedEvent struct { + hook_resolver.Event + MediaId int `json:"mediaId"` + // Empty metadata object, will be used if the hook prevents the default behavior + AnimeMetadata *AnimeMetadata `json:"animeMetadata"` +} + +// AnimeMetadataEvent is triggered when anime metadata is available and is about to be returned. +// Anime metadata can be requested in many places, ranging from displaying the anime entry to starting a torrent stream. +// This event is triggered after [AnimeMetadataRequestedEvent]. +// If the modified metadata is nil, an error will be returned. +type AnimeMetadataEvent struct { + hook_resolver.Event + MediaId int `json:"mediaId"` + AnimeMetadata *AnimeMetadata `json:"animeMetadata"` +} + +// AnimeEpisodeMetadataRequestedEvent is triggered when anime episode metadata is requested. +// Prevent default to skip the default behavior and return the overridden metadata. +// This event is triggered before [AnimeEpisodeMetadataEvent]. +// If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned. +type AnimeEpisodeMetadataRequestedEvent struct { + hook_resolver.Event + // Empty metadata object, will be used if the hook prevents the default behavior + EpisodeMetadata *EpisodeMetadata `json:"animeEpisodeMetadata"` + EpisodeNumber int `json:"episodeNumber"` + MediaId int `json:"mediaId"` +} + +// AnimeEpisodeMetadataEvent is triggered when anime episode metadata is available and is about to be returned. +// In the current implementation, episode metadata is requested for display purposes. It is used to get a more complete metadata object since the original AnimeMetadata object is not complete. +// This event is triggered after [AnimeEpisodeMetadataRequestedEvent]. +// If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned. +type AnimeEpisodeMetadataEvent struct { + hook_resolver.Event + EpisodeMetadata *EpisodeMetadata `json:"animeEpisodeMetadata"` + EpisodeNumber int `json:"episodeNumber"` + MediaId int `json:"mediaId"` +} diff --git a/seanime-2.9.10/internal/api/metadata/mock.go b/seanime-2.9.10/internal/api/metadata/mock.go new file mode 100644 index 0000000..bf8686d --- /dev/null +++ b/seanime-2.9.10/internal/api/metadata/mock.go @@ -0,0 +1,18 @@ +package metadata + +import ( + "seanime/internal/util" + "seanime/internal/util/filecache" + "testing" + + "github.com/stretchr/testify/require" +) + +func GetMockProvider(t *testing.T) Provider { + filecacher, err := filecache.NewCacher(t.TempDir()) + require.NoError(t, err) + return NewProvider(&NewProviderImplOptions{ + Logger: util.NewLogger(), + FileCacher: filecacher, + }) +} diff --git a/seanime-2.9.10/internal/api/metadata/provider.go b/seanime-2.9.10/internal/api/metadata/provider.go new file mode 100644 index 0000000..82c3465 --- /dev/null +++ b/seanime-2.9.10/internal/api/metadata/provider.go @@ -0,0 +1,212 @@ +package metadata + +import ( + "errors" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/animap" + "seanime/internal/hook" + "seanime/internal/util/filecache" + "seanime/internal/util/result" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/samber/mo" + "golang.org/x/sync/singleflight" +) + +type ( + ProviderImpl struct { + logger *zerolog.Logger + fileCacher *filecache.Cacher + animeMetadataCache *result.BoundedCache[string, *AnimeMetadata] + singleflight *singleflight.Group + } + + NewProviderImplOptions struct { + Logger *zerolog.Logger + FileCacher *filecache.Cacher + } +) + +func GetAnimeMetadataCacheKey(platform Platform, mId int) string { + return fmt.Sprintf("%s$%d", platform, mId) +} + +// NewProvider creates a new metadata provider. +func NewProvider(options *NewProviderImplOptions) Provider { + return &ProviderImpl{ + logger: options.Logger, + fileCacher: options.FileCacher, + animeMetadataCache: result.NewBoundedCache[string, *AnimeMetadata](100), + singleflight: &singleflight.Group{}, + } +} + +// GetCache returns the anime metadata cache. +func (p *ProviderImpl) GetCache() *result.BoundedCache[string, *AnimeMetadata] { + return p.animeMetadataCache +} + +// GetAnimeMetadata fetches anime metadata from api.ani.zip. +func (p *ProviderImpl) GetAnimeMetadata(platform Platform, mId int) (ret *AnimeMetadata, err error) { + cacheKey := GetAnimeMetadataCacheKey(platform, mId) + if cached, ok := p.animeMetadataCache.Get(cacheKey); ok { + return cached, nil + } + + res, err, _ := p.singleflight.Do(cacheKey, func() (interface{}, error) { + return p.fetchAnimeMetadata(platform, mId) + }) + if err != nil { + return nil, err + } + + return res.(*AnimeMetadata), nil +} + +func (p *ProviderImpl) fetchAnimeMetadata(platform Platform, mId int) (*AnimeMetadata, error) { + ret := &AnimeMetadata{ + Titles: make(map[string]string), + Episodes: make(map[string]*EpisodeMetadata), + EpisodeCount: 0, + SpecialCount: 0, + Mappings: &AnimeMappings{}, + } + + // Invoke AnimeMetadataRequested hook + reqEvent := &AnimeMetadataRequestedEvent{ + MediaId: mId, + AnimeMetadata: ret, + } + err := hook.GlobalHookManager.OnAnimeMetadataRequested().Trigger(reqEvent) + if err != nil { + return nil, err + } + mId = reqEvent.MediaId + + // Default prevented by hook, return the metadata + if reqEvent.DefaultPrevented { + // Override the metadata + ret = reqEvent.AnimeMetadata + + // Trigger the event + event := &AnimeMetadataEvent{ + MediaId: mId, + AnimeMetadata: ret, + } + err = hook.GlobalHookManager.OnAnimeMetadata().Trigger(event) + if err != nil { + return nil, err + } + ret = event.AnimeMetadata + mId = event.MediaId + + if ret == nil { + return nil, errors.New("no metadata was returned") + } + p.animeMetadataCache.SetT(GetAnimeMetadataCacheKey(platform, mId), ret, 1*time.Hour) + return ret, nil + } + + m, err := animap.FetchAnimapMedia(string(platform), mId) + if err != nil || m == nil { + //return p.AnizipFallback(platform, mId) + return nil, err + } + + ret.Titles = m.Titles + ret.EpisodeCount = 0 + ret.SpecialCount = 0 + ret.Mappings.AnimeplanetId = m.Mappings.AnimePlanetID + ret.Mappings.KitsuId = m.Mappings.KitsuID + ret.Mappings.MalId = m.Mappings.MalID + ret.Mappings.Type = m.Mappings.Type + ret.Mappings.AnilistId = m.Mappings.AnilistID + ret.Mappings.AnisearchId = m.Mappings.AnisearchID + ret.Mappings.AnidbId = m.Mappings.AnidbID + ret.Mappings.NotifymoeId = m.Mappings.NotifyMoeID + ret.Mappings.LivechartId = m.Mappings.LivechartID + ret.Mappings.ThetvdbId = m.Mappings.TheTvdbID + ret.Mappings.ImdbId = "" + ret.Mappings.ThemoviedbId = m.Mappings.TheMovieDbID + + for key, ep := range m.Episodes { + firstChar := key[0] + if firstChar == 'S' { + ret.SpecialCount++ + } else { + if firstChar >= '0' && firstChar <= '9' { + ret.EpisodeCount++ + } + } + em := &EpisodeMetadata{ + AnidbId: ep.AnidbId, + TvdbId: ep.TvdbId, + Title: ep.AnidbTitle, + Image: ep.Image, + AirDate: ep.AirDate, + Length: ep.Runtime, + Summary: strings.ReplaceAll(ep.Overview, "`", "'"), + Overview: strings.ReplaceAll(ep.Overview, "`", "'"), + EpisodeNumber: ep.Number, + Episode: key, + SeasonNumber: ep.SeasonNumber, + AbsoluteEpisodeNumber: ep.AbsoluteNumber, + AnidbEid: ep.AnidbId, + HasImage: ep.Image != "", + } + if em.Length == 0 && ep.Runtime > 0 { + em.Length = ep.Runtime + } + if em.Summary == "" && ep.Overview != "" { + em.Summary = ep.Overview + } + if em.Overview == "" && ep.Overview != "" { + em.Overview = ep.Overview + } + if ep.TvdbTitle != "" && ep.AnidbTitle == "Episode "+ep.AnidbEpisode { + em.Title = ep.TvdbTitle + + } + ret.Episodes[key] = em + } + + // Event + event := &AnimeMetadataEvent{ + MediaId: mId, + AnimeMetadata: ret, + } + err = hook.GlobalHookManager.OnAnimeMetadata().Trigger(event) + if err != nil { + return nil, err + } + ret = event.AnimeMetadata + mId = event.MediaId + + p.animeMetadataCache.SetT(GetAnimeMetadataCacheKey(platform, mId), ret, 1*time.Hour) + + return ret, nil +} + +// GetAnimeMetadataWrapper creates a new anime wrapper. +// +// Example: +// +// metadataProvider.GetAnimeMetadataWrapper(media, metadata) +// metadataProvider.GetAnimeMetadataWrapper(media, nil) +func (p *ProviderImpl) GetAnimeMetadataWrapper(media *anilist.BaseAnime, metadata *AnimeMetadata) AnimeMetadataWrapper { + aw := &AnimeWrapperImpl{ + metadata: mo.None[*AnimeMetadata](), + baseAnime: media, + fileCacher: p.fileCacher, + logger: p.logger, + } + + if metadata != nil { + aw.metadata = mo.Some(metadata) + } + + return aw +} diff --git a/seanime-2.9.10/internal/api/metadata/types.go b/seanime-2.9.10/internal/api/metadata/types.go new file mode 100644 index 0000000..d0ca980 --- /dev/null +++ b/seanime-2.9.10/internal/api/metadata/types.go @@ -0,0 +1,165 @@ +package metadata + +import ( + "seanime/internal/api/anilist" + "seanime/internal/util/result" + "strings" + "time" +) + +const ( + AnilistPlatform Platform = "anilist" + MalPlatform Platform = "mal" +) + +type ( + Platform string + + Provider interface { + // GetAnimeMetadata fetches anime metadata for the given platform from a source. + // In this case, the source is api.ani.zip. + GetAnimeMetadata(platform Platform, mId int) (*AnimeMetadata, error) + GetCache() *result.BoundedCache[string, *AnimeMetadata] + // GetAnimeMetadataWrapper creates a wrapper for anime metadata. + GetAnimeMetadataWrapper(anime *anilist.BaseAnime, metadata *AnimeMetadata) AnimeMetadataWrapper + } + + // AnimeMetadataWrapper is a container for anime metadata. + // This wrapper is used to get a more complete metadata object by getting data from multiple sources in the Provider. + // The user can request metadata to be fetched from TVDB as well, which will be stored in the cache. + AnimeMetadataWrapper interface { + // GetEpisodeMetadata combines metadata from multiple sources to create a single EpisodeMetadata object. + GetEpisodeMetadata(episodeNumber int) EpisodeMetadata + } +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type ( + AnimeMetadata struct { + Titles map[string]string `json:"titles"` + Episodes map[string]*EpisodeMetadata `json:"episodes"` + EpisodeCount int `json:"episodeCount"` + SpecialCount int `json:"specialCount"` + Mappings *AnimeMappings `json:"mappings"` + + currentEpisodeCount int `json:"-"` + } + + AnimeMappings struct { + AnimeplanetId string `json:"animeplanetId"` + KitsuId int `json:"kitsuId"` + MalId int `json:"malId"` + Type string `json:"type"` + AnilistId int `json:"anilistId"` + AnisearchId int `json:"anisearchId"` + AnidbId int `json:"anidbId"` + NotifymoeId string `json:"notifymoeId"` + LivechartId int `json:"livechartId"` + ThetvdbId int `json:"thetvdbId"` + ImdbId string `json:"imdbId"` + ThemoviedbId string `json:"themoviedbId"` + } + + EpisodeMetadata struct { + AnidbId int `json:"anidbId"` + TvdbId int `json:"tvdbId"` + Title string `json:"title"` + Image string `json:"image"` + AirDate string `json:"airDate"` + Length int `json:"length"` + Summary string `json:"summary"` + Overview string `json:"overview"` + EpisodeNumber int `json:"episodeNumber"` + Episode string `json:"episode"` + SeasonNumber int `json:"seasonNumber"` + AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber"` + AnidbEid int `json:"anidbEid"` + HasImage bool `json:"hasImage"` // Indicates if the episode has a real image + } +) + +func (m *AnimeMetadata) GetTitle() string { + if m == nil { + return "" + } + if len(m.Titles["en"]) > 0 { + return m.Titles["en"] + } + return m.Titles["ro"] +} + +func (m *AnimeMetadata) GetMappings() *AnimeMappings { + if m == nil { + return &AnimeMappings{} + } + return m.Mappings +} + +func (m *AnimeMetadata) FindEpisode(ep string) (*EpisodeMetadata, bool) { + if m.Episodes == nil { + return nil, false + } + episode, found := m.Episodes[ep] + if !found { + return nil, false + } + + return episode, true +} + +func (m *AnimeMetadata) GetMainEpisodeCount() int { + if m == nil { + return 0 + } + return m.EpisodeCount +} + +func (m *AnimeMetadata) GetCurrentEpisodeCount() int { + if m == nil { + return 0 + } + if m.currentEpisodeCount > 0 { + return m.currentEpisodeCount + } + count := 0 + for _, ep := range m.Episodes { + firstChar := ep.Episode[0] + if firstChar >= '0' && firstChar <= '9' { + // Check if aired + if ep.AirDate != "" { + date, err := time.Parse("2006-01-02", ep.AirDate) + if err == nil { + if date.Before(time.Now()) || date.Equal(time.Now()) { + count++ + } + } + } + } + } + m.currentEpisodeCount = count + return count +} + +// GetOffset returns the offset of the first episode relative to the absolute episode number. +// e.g, if the first episode's absolute number is 13, then the offset is 12. +func (m *AnimeMetadata) GetOffset() int { + if m == nil { + return 0 + } + firstEp, found := m.FindEpisode("1") + if !found { + return 0 + } + if firstEp.AbsoluteEpisodeNumber == 0 { + return 0 + } + return firstEp.AbsoluteEpisodeNumber - 1 +} + +func (e *EpisodeMetadata) GetTitle() string { + if e == nil { + return "" + } + return strings.ReplaceAll(e.Title, "`", "'") +} diff --git a/seanime-2.9.10/internal/constants/constants.go b/seanime-2.9.10/internal/constants/constants.go new file mode 100644 index 0000000..2ea708b --- /dev/null +++ b/seanime-2.9.10/internal/constants/constants.go @@ -0,0 +1,19 @@ +package constants + +import ( + "seanime/internal/util" + "time" +) + +const ( + Version = "2.9.10" + VersionName = "Natsu" + GcTime = time.Minute * 30 + ConfigFileName = "config.toml" + MalClientId = "51cb4294feb400f3ddc66a30f9b9a00f" + DiscordApplicationId = "1224777421941899285" +) + +var DefaultExtensionMarketplaceURL = util.Decode("aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tLzVyYWhpbS9zZWFuaW1lLWV4dGVuc2lvbnMvcmVmcy9oZWFkcy9tYWluL21hcmtldHBsYWNlLmpzb24=") +var AnnouncementURL = util.Decode("aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tLzVyYWhpbS9oaWJpa2UvcmVmcy9oZWFkcy9tYWluL3B1YmxpYy9hbm5vdW5jZW1lbnRzLmpzb24=") +var InternalMetadataURL = util.Decode("aHR0cHM6Ly9hbmltZS5jbGFwLmluZw==") diff --git a/seanime-2.9.10/internal/continuity/history.go b/seanime-2.9.10/internal/continuity/history.go new file mode 100644 index 0000000..8c4ccde --- /dev/null +++ b/seanime-2.9.10/internal/continuity/history.go @@ -0,0 +1,418 @@ +package continuity + +import ( + "fmt" + "seanime/internal/database/db_bridge" + "seanime/internal/hook" + "seanime/internal/library/anime" + "seanime/internal/util" + "seanime/internal/util/filecache" + "strconv" + "strings" + "time" +) + +const ( + MaxWatchHistoryItems = 100 + IgnoreRatioThreshold = 0.9 + WatchHistoryBucketName = "watch_history" +) + +type ( + // WatchHistory is a map of WatchHistoryItem. + // The key is the WatchHistoryItem.MediaId. + WatchHistory map[int]*WatchHistoryItem + + // WatchHistoryItem are stored in the file cache. + // The history is used to resume playback from the last known position. + // Item.MediaId and Item.EpisodeNumber are used to identify the media and episode. + // Only one Item per MediaId should exist in the history. + WatchHistoryItem struct { + Kind Kind `json:"kind"` + // Used for MediastreamKind and ExternalPlayerKind. + Filepath string `json:"filepath"` + MediaId int `json:"mediaId"` + EpisodeNumber int `json:"episodeNumber"` + // The current playback time in seconds. + // Used to determine when to remove the item from the history. + CurrentTime float64 `json:"currentTime"` + // The duration of the media in seconds. + Duration float64 `json:"duration"` + // Timestamp of when the item was added to the history. + TimeAdded time.Time `json:"timeAdded"` + // TimeAdded is used in conjunction with TimeUpdated + // Timestamp of when the item was last updated. + // Used to determine when to remove the item from the history (First in, first out). + TimeUpdated time.Time `json:"timeUpdated"` + } + + WatchHistoryItemResponse struct { + Item *WatchHistoryItem `json:"item"` + Found bool `json:"found"` + } + + UpdateWatchHistoryItemOptions struct { + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + MediaId int `json:"mediaId"` + EpisodeNumber int `json:"episodeNumber"` + Filepath string `json:"filepath,omitempty"` + Kind Kind `json:"kind"` + } +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *Manager) GetWatchHistory() WatchHistory { + defer util.HandlePanicInModuleThen("continuity/GetWatchHistory", func() {}) + + m.mu.RLock() + defer m.mu.RUnlock() + + items, err := filecache.GetAll[*WatchHistoryItem](m.fileCacher, *m.watchHistoryFileCacheBucket) + if err != nil { + m.logger.Error().Err(err).Msg("continuity: Failed to get watch history") + return nil + } + + ret := make(WatchHistory) + for _, item := range items { + ret[item.MediaId] = item + } + + return ret +} + +func (m *Manager) GetWatchHistoryItem(mediaId int) *WatchHistoryItemResponse { + defer util.HandlePanicInModuleThen("continuity/GetWatchHistoryItem", func() {}) + + m.mu.RLock() + defer m.mu.RUnlock() + + i, found := m.getWatchHistory(mediaId) + return &WatchHistoryItemResponse{ + Item: i, + Found: found, + } +} + +// UpdateWatchHistoryItem updates the WatchHistoryItem in the file cache. +func (m *Manager) UpdateWatchHistoryItem(opts *UpdateWatchHistoryItemOptions) (err error) { + defer util.HandlePanicInModuleWithError("continuity/UpdateWatchHistoryItem", &err) + + m.mu.Lock() + defer m.mu.Unlock() + + added := false + + // Get the current history + i, found := m.getWatchHistory(opts.MediaId) + if !found { + added = true + i = &WatchHistoryItem{ + Kind: opts.Kind, + Filepath: opts.Filepath, + MediaId: opts.MediaId, + EpisodeNumber: opts.EpisodeNumber, + CurrentTime: opts.CurrentTime, + Duration: opts.Duration, + TimeAdded: time.Now(), + TimeUpdated: time.Now(), + } + } else { + i.Kind = opts.Kind + i.EpisodeNumber = opts.EpisodeNumber + i.CurrentTime = opts.CurrentTime + i.Duration = opts.Duration + i.TimeUpdated = time.Now() + } + + // Save the i + err = m.fileCacher.Set(*m.watchHistoryFileCacheBucket, strconv.Itoa(opts.MediaId), i) + if err != nil { + return fmt.Errorf("continuity: Failed to save watch history item: %w", err) + } + + _ = hook.GlobalHookManager.OnWatchHistoryItemUpdated().Trigger(&WatchHistoryItemUpdatedEvent{ + WatchHistoryItem: i, + }) + + // If the item was added, check if we need to remove the oldest item + if added { + _ = m.trimWatchHistoryItems() + } + + return nil +} + +func (m *Manager) DeleteWatchHistoryItem(mediaId int) (err error) { + defer util.HandlePanicInModuleWithError("continuity/DeleteWatchHistoryItem", &err) + + m.mu.Lock() + defer m.mu.Unlock() + + err = m.fileCacher.Delete(*m.watchHistoryFileCacheBucket, strconv.Itoa(mediaId)) + if err != nil { + return fmt.Errorf("continuity: Failed to delete watch history item: %w", err) + } + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// GetExternalPlayerEpisodeWatchHistoryItem is called before launching the external player to get the last known position. +// Unlike GetWatchHistoryItem, this checks if the episode numbers match. +func (m *Manager) GetExternalPlayerEpisodeWatchHistoryItem(path string, isStream bool, episode, mediaId int) (ret *WatchHistoryItemResponse) { + defer util.HandlePanicInModuleThen("continuity/GetExternalPlayerEpisodeWatchHistoryItem", func() {}) + + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.settings.WatchContinuityEnabled { + return &WatchHistoryItemResponse{ + Item: nil, + Found: false, + } + } + + ret = &WatchHistoryItemResponse{ + Item: nil, + Found: false, + } + + m.logger.Debug(). + Str("path", path). + Bool("isStream", isStream). + Int("episode", episode). + Int("mediaId", mediaId). + Msg("continuity: Retrieving watch history item") + + // Normalize path + path = util.NormalizePath(path) + + if isStream { + + event := &WatchHistoryStreamEpisodeItemRequestedEvent{ + WatchHistoryItem: &WatchHistoryItem{}, + } + + hook.GlobalHookManager.OnWatchHistoryStreamEpisodeItemRequested().Trigger(event) + if event.DefaultPrevented { + return &WatchHistoryItemResponse{ + Item: event.WatchHistoryItem, + Found: event.WatchHistoryItem != nil, + } + } + + if episode == 0 || mediaId == 0 { + m.logger.Debug(). + Int("episode", episode). + Int("mediaId", mediaId). + Msg("continuity: No episode or media provided") + return + } + + i, found := m.getWatchHistory(mediaId) + if !found || i.EpisodeNumber != episode { + m.logger.Trace(). + Interface("item", i). + Msg("continuity: No watch history item found or episode number does not match") + return + } + + m.logger.Debug(). + Interface("item", i). + Msg("continuity: Watch history item found") + + return &WatchHistoryItemResponse{ + Item: i, + Found: found, + } + + } else { + // Find the local file from the path + lfs, _, err := db_bridge.GetLocalFiles(m.db) + if err != nil { + return ret + } + + event := &WatchHistoryLocalFileEpisodeItemRequestedEvent{ + Path: path, + LocalFiles: lfs, + WatchHistoryItem: &WatchHistoryItem{}, + } + hook.GlobalHookManager.OnWatchHistoryLocalFileEpisodeItemRequested().Trigger(event) + if event.DefaultPrevented { + return &WatchHistoryItemResponse{ + Item: event.WatchHistoryItem, + Found: event.WatchHistoryItem != nil, + } + } + + var lf *anime.LocalFile + // Find the local file from the path + for _, l := range lfs { + if l.GetNormalizedPath() == path { + lf = l + m.logger.Trace().Msg("continuity: Local file found from path") + break + } + } + // If the local file is not found, the path might be a filename (in the case of VLC) + if lf == nil { + for _, l := range lfs { + if strings.ToLower(l.Name) == path { + lf = l + m.logger.Trace().Msg("continuity: Local file found from filename") + break + } + } + } + + if lf == nil || lf.MediaId == 0 || !lf.IsMain() { + m.logger.Trace().Msg("continuity: Local file not found or not main") + return + } + + i, found := m.getWatchHistory(lf.MediaId) + if !found || i.EpisodeNumber != lf.GetEpisodeNumber() { + m.logger.Trace(). + Interface("item", i). + Msg("continuity: No watch history item found or episode number does not match") + return + } + + m.logger.Debug(). + Interface("item", i). + Msg("continuity: Watch history item found") + + return &WatchHistoryItemResponse{ + Item: i, + Found: found, + } + } +} + +func (m *Manager) UpdateExternalPlayerEpisodeWatchHistoryItem(currentTime, duration float64) { + defer util.HandlePanicInModuleThen("continuity/UpdateWatchHistoryItem", func() {}) + + m.mu.Lock() + defer m.mu.Unlock() + + if !m.settings.WatchContinuityEnabled { + return + } + + if m.externalPlayerEpisodeDetails.IsAbsent() { + return + } + + added := false + + opts, ok := m.externalPlayerEpisodeDetails.Get() + if !ok { + return + } + + // Get the current history + i, found := m.getWatchHistory(opts.MediaId) + if !found { + added = true + i = &WatchHistoryItem{ + Kind: ExternalPlayerKind, + Filepath: opts.Filepath, + MediaId: opts.MediaId, + EpisodeNumber: opts.EpisodeNumber, + CurrentTime: currentTime, + Duration: duration, + TimeAdded: time.Now(), + TimeUpdated: time.Now(), + } + } else { + i.Kind = ExternalPlayerKind + i.EpisodeNumber = opts.EpisodeNumber + i.CurrentTime = currentTime + i.Duration = duration + i.TimeUpdated = time.Now() + } + + // Save the i + _ = m.fileCacher.Set(*m.watchHistoryFileCacheBucket, strconv.Itoa(opts.MediaId), i) + + // If the item was added, check if we need to remove the oldest item + if added { + _ = m.trimWatchHistoryItems() + } + + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *Manager) getWatchHistory(mediaId int) (ret *WatchHistoryItem, exists bool) { + defer util.HandlePanicInModuleThen("continuity/getWatchHistory", func() { + ret = nil + exists = false + }) + + reqEvent := &WatchHistoryItemRequestedEvent{ + MediaId: mediaId, + WatchHistoryItem: ret, + } + hook.GlobalHookManager.OnWatchHistoryItemRequested().Trigger(reqEvent) + ret = reqEvent.WatchHistoryItem + + if reqEvent.DefaultPrevented { + return reqEvent.WatchHistoryItem, reqEvent.WatchHistoryItem != nil + } + + exists, _ = m.fileCacher.Get(*m.watchHistoryFileCacheBucket, strconv.Itoa(mediaId), &ret) + + if exists && ret != nil && ret.Duration > 0 { + // If the item completion ratio is equal or above IgnoreRatioThreshold, don't return anything + ratio := ret.CurrentTime / ret.Duration + if ratio >= IgnoreRatioThreshold { + // Delete the item + go func() { + defer util.HandlePanicInModuleThen("continuity/getWatchHistory", func() {}) + _ = m.fileCacher.Delete(*m.watchHistoryFileCacheBucket, strconv.Itoa(mediaId)) + }() + return nil, false + } + if ratio < 0.05 { + return nil, false + } + } + + return +} + +// removes the oldest WatchHistoryItem from the file cache. +func (m *Manager) trimWatchHistoryItems() error { + defer util.HandlePanicInModuleThen("continuity/TrimWatchHistoryItems", func() {}) + + // Get all the items + items, err := filecache.GetAll[*WatchHistoryItem](m.fileCacher, *m.watchHistoryFileCacheBucket) + if err != nil { + return fmt.Errorf("continuity: Failed to get watch history items: %w", err) + } + + // If there are too many items, remove the oldest one + if len(items) > MaxWatchHistoryItems { + var oldestKey string + for key := range items { + if oldestKey == "" || items[key].TimeUpdated.Before(items[oldestKey].TimeUpdated) { + oldestKey = key + } + } + err = m.fileCacher.Delete(*m.watchHistoryFileCacheBucket, oldestKey) + if err != nil { + return fmt.Errorf("continuity: Failed to remove oldest watch history item: %w", err) + } + } + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/seanime-2.9.10/internal/continuity/history_test.go b/seanime-2.9.10/internal/continuity/history_test.go new file mode 100644 index 0000000..2b1d76d --- /dev/null +++ b/seanime-2.9.10/internal/continuity/history_test.go @@ -0,0 +1,79 @@ +package continuity + +import ( + "github.com/stretchr/testify/require" + "path/filepath" + "seanime/internal/database/db" + "seanime/internal/test_utils" + "seanime/internal/util" + "seanime/internal/util/filecache" + "testing" +) + +func TestHistoryItems(t *testing.T) { + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t) + + logger := util.NewLogger() + + tempDir := t.TempDir() + t.Log(tempDir) + + database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger) + require.NoError(t, err) + + cacher, err := filecache.NewCacher(filepath.Join(tempDir, "cache")) + require.NoError(t, err) + + manager := NewManager(&NewManagerOptions{ + FileCacher: cacher, + Logger: logger, + Database: database, + }) + require.NotNil(t, manager) + + var mediaIds = make([]int, MaxWatchHistoryItems+1) + for i := 0; i < MaxWatchHistoryItems+1; i++ { + mediaIds[i] = i + 1 + } + + // Add items to the history + for _, mediaId := range mediaIds { + err = manager.UpdateWatchHistoryItem(&UpdateWatchHistoryItemOptions{ + MediaId: mediaId, + EpisodeNumber: 1, + CurrentTime: 10, + Duration: 100, + }) + require.NoError(t, err) + } + + // Check if the oldest item was removed + items, err := filecache.GetAll[WatchHistoryItem](cacher, *manager.watchHistoryFileCacheBucket) + require.NoError(t, err) + + require.Len(t, items, MaxWatchHistoryItems) + + // Update an item + err = manager.UpdateWatchHistoryItem(&UpdateWatchHistoryItemOptions{ + MediaId: mediaIds[0], // 1 + EpisodeNumber: 2, + CurrentTime: 30, + Duration: 100, + }) + require.NoError(t, err) + + // Check if the item was updated + items, err = filecache.GetAll[WatchHistoryItem](cacher, *manager.watchHistoryFileCacheBucket) + require.NoError(t, err) + + require.Len(t, items, MaxWatchHistoryItems) + + item, found := items["1"] + require.True(t, found) + + require.Equal(t, 2, item.EpisodeNumber) + require.Equal(t, 30., item.CurrentTime) + require.Equal(t, 100., item.Duration) + +} diff --git a/seanime-2.9.10/internal/continuity/hook_events.go b/seanime-2.9.10/internal/continuity/hook_events.go new file mode 100644 index 0000000..16a622c --- /dev/null +++ b/seanime-2.9.10/internal/continuity/hook_events.go @@ -0,0 +1,38 @@ +package continuity + +import ( + "seanime/internal/hook_resolver" + "seanime/internal/library/anime" +) + +// WatchHistoryItemRequestedEvent is triggered when a watch history item is requested. +// Prevent default to skip getting the watch history item from the file cache, in this case the event should have a valid WatchHistoryItem object or set it to nil to indicate that the watch history item was not found. +type WatchHistoryItemRequestedEvent struct { + hook_resolver.Event + MediaId int `json:"mediaId"` + // Empty WatchHistoryItem object, will be used if the hook prevents the default behavior + WatchHistoryItem *WatchHistoryItem `json:"watchHistoryItem"` +} + +// WatchHistoryItemUpdatedEvent is triggered when a watch history item is updated. +type WatchHistoryItemUpdatedEvent struct { + hook_resolver.Event + WatchHistoryItem *WatchHistoryItem `json:"watchHistoryItem"` +} + +type WatchHistoryLocalFileEpisodeItemRequestedEvent struct { + hook_resolver.Event + Path string + // All scanned local files + LocalFiles []*anime.LocalFile + // Empty WatchHistoryItem object, will be used if the hook prevents the default behavior + WatchHistoryItem *WatchHistoryItem `json:"watchHistoryItem"` +} + +type WatchHistoryStreamEpisodeItemRequestedEvent struct { + hook_resolver.Event + Episode int + MediaId int + // Empty WatchHistoryItem object, will be used if the hook prevents the default behavior + WatchHistoryItem *WatchHistoryItem `json:"watchHistoryItem"` +} diff --git a/seanime-2.9.10/internal/continuity/manager.go b/seanime-2.9.10/internal/continuity/manager.go new file mode 100644 index 0000000..82ae305 --- /dev/null +++ b/seanime-2.9.10/internal/continuity/manager.go @@ -0,0 +1,106 @@ +package continuity + +import ( + "github.com/rs/zerolog" + "github.com/samber/mo" + "seanime/internal/database/db" + "seanime/internal/util/filecache" + "sync" + "time" +) + +const ( + OnlinestreamKind Kind = "onlinestream" + MediastreamKind Kind = "mediastream" + ExternalPlayerKind Kind = "external_player" +) + +type ( + // Manager is used to manage the user's viewing history across different media types. + Manager struct { + fileCacher *filecache.Cacher + db *db.Database + watchHistoryFileCacheBucket *filecache.Bucket + + externalPlayerEpisodeDetails mo.Option[*ExternalPlayerEpisodeDetails] + + logger *zerolog.Logger + settings *Settings + mu sync.RWMutex + } + + // ExternalPlayerEpisodeDetails is used to store the episode details when using an external player. + // Since the media player module only cares about the filepath, the PlaybackManager will store the episode number and media id here when playback starts. + ExternalPlayerEpisodeDetails struct { + EpisodeNumber int `json:"episodeNumber"` + MediaId int `json:"mediaId"` + Filepath string `json:"filepath"` + } + + Settings struct { + WatchContinuityEnabled bool + } + + Kind string +) + +type ( + NewManagerOptions struct { + FileCacher *filecache.Cacher + Logger *zerolog.Logger + Database *db.Database + } +) + +// NewManager creates a new Manager, it should be initialized once. +func NewManager(opts *NewManagerOptions) *Manager { + watchHistoryFileCacheBucket := filecache.NewBucket(WatchHistoryBucketName, time.Hour*24*99999) + + ret := &Manager{ + fileCacher: opts.FileCacher, + logger: opts.Logger, + db: opts.Database, + watchHistoryFileCacheBucket: &watchHistoryFileCacheBucket, + settings: &Settings{ + WatchContinuityEnabled: false, + }, + externalPlayerEpisodeDetails: mo.None[*ExternalPlayerEpisodeDetails](), + } + + ret.logger.Info().Msg("continuity: Initialized manager") + + return ret +} + +// SetSettings should be called after initializing the Manager. +func (m *Manager) SetSettings(settings *Settings) { + if m == nil || settings == nil { + return + } + + m.mu.Lock() + defer m.mu.Unlock() + m.settings = settings +} + +// GetSettings returns the current settings. +func (m *Manager) GetSettings() *Settings { + if m == nil { + return nil + } + + m.mu.RLock() + defer m.mu.RUnlock() + return m.settings +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *Manager) SetExternalPlayerEpisodeDetails(details *ExternalPlayerEpisodeDetails) { + if m == nil || details == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.externalPlayerEpisodeDetails = mo.Some(details) +} diff --git a/seanime-2.9.10/internal/continuity/mock.go b/seanime-2.9.10/internal/continuity/mock.go new file mode 100644 index 0000000..1e5f7f8 --- /dev/null +++ b/seanime-2.9.10/internal/continuity/mock.go @@ -0,0 +1,24 @@ +package continuity + +import ( + "github.com/stretchr/testify/require" + "path/filepath" + "seanime/internal/database/db" + "seanime/internal/util" + "seanime/internal/util/filecache" + "testing" +) + +func GetMockManager(t *testing.T, db *db.Database) *Manager { + logger := util.NewLogger() + cacher, err := filecache.NewCacher(filepath.Join(t.TempDir(), "cache")) + require.NoError(t, err) + + manager := NewManager(&NewManagerOptions{ + FileCacher: cacher, + Logger: logger, + Database: db, + }) + + return manager +} diff --git a/seanime-2.9.10/internal/core/anilist.go b/seanime-2.9.10/internal/core/anilist.go new file mode 100644 index 0000000..0c7b72a --- /dev/null +++ b/seanime-2.9.10/internal/core/anilist.go @@ -0,0 +1,109 @@ +package core + +import ( + "context" + "seanime/internal/api/anilist" + "seanime/internal/events" + "seanime/internal/platforms/platform" + "seanime/internal/user" +) + +// GetUser returns the currently logged-in user or a simulated one. +func (a *App) GetUser() *user.User { + if a.user == nil { + return user.NewSimulatedUser() + } + return a.user +} + +func (a *App) GetUserAnilistToken() string { + if a.user == nil || a.user.Token == user.SimulatedUserToken { + return "" + } + + return a.user.Token +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// UpdatePlatform changes the current platform to the provided one. +func (a *App) UpdatePlatform(platform platform.Platform) { + a.AnilistPlatform = platform +} + +// UpdateAnilistClientToken will update the Anilist Client Wrapper token. +// This function should be called when a user logs in +func (a *App) UpdateAnilistClientToken(token string) { + a.AnilistClient = anilist.NewAnilistClient(token) + a.AnilistPlatform.SetAnilistClient(a.AnilistClient) // Update Anilist Client Wrapper in Platform +} + +// GetAnimeCollection returns the user's Anilist collection if it in the cache, otherwise it queries Anilist for the user's collection. +// When bypassCache is true, it will always query Anilist for the user's collection +func (a *App) GetAnimeCollection(bypassCache bool) (*anilist.AnimeCollection, error) { + return a.AnilistPlatform.GetAnimeCollection(context.Background(), bypassCache) +} + +// GetRawAnimeCollection is the same as GetAnimeCollection but returns the raw collection that includes custom lists +func (a *App) GetRawAnimeCollection(bypassCache bool) (*anilist.AnimeCollection, error) { + return a.AnilistPlatform.GetRawAnimeCollection(context.Background(), bypassCache) +} + +// RefreshAnimeCollection queries Anilist for the user's collection +func (a *App) RefreshAnimeCollection() (*anilist.AnimeCollection, error) { + go func() { + a.OnRefreshAnilistCollectionFuncs.Range(func(key string, f func()) bool { + go f() + return true + }) + }() + + ret, err := a.AnilistPlatform.RefreshAnimeCollection(context.Background()) + + if err != nil { + return nil, err + } + + // Save the collection to PlaybackManager + a.PlaybackManager.SetAnimeCollection(ret) + + // Save the collection to AutoDownloader + a.AutoDownloader.SetAnimeCollection(ret) + + // Save the collection to LocalManager + a.LocalManager.SetAnimeCollection(ret) + + // Save the collection to DirectStreamManager + a.DirectStreamManager.SetAnimeCollection(ret) + + a.WSEventManager.SendEvent(events.RefreshedAnilistAnimeCollection, nil) + + return ret, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// GetMangaCollection is the same as GetAnimeCollection but for manga +func (a *App) GetMangaCollection(bypassCache bool) (*anilist.MangaCollection, error) { + return a.AnilistPlatform.GetMangaCollection(context.Background(), bypassCache) +} + +// GetRawMangaCollection does not exclude custom lists +func (a *App) GetRawMangaCollection(bypassCache bool) (*anilist.MangaCollection, error) { + return a.AnilistPlatform.GetRawMangaCollection(context.Background(), bypassCache) +} + +// RefreshMangaCollection queries Anilist for the user's manga collection +func (a *App) RefreshMangaCollection() (*anilist.MangaCollection, error) { + mc, err := a.AnilistPlatform.RefreshMangaCollection(context.Background()) + + if err != nil { + return nil, err + } + + a.LocalManager.SetMangaCollection(mc) + + a.WSEventManager.SendEvent(events.RefreshedAnilistMangaCollection, nil) + + return mc, nil +} diff --git a/seanime-2.9.10/internal/core/app.go b/seanime-2.9.10/internal/core/app.go new file mode 100644 index 0000000..7f08a9f --- /dev/null +++ b/seanime-2.9.10/internal/core/app.go @@ -0,0 +1,440 @@ +package core + +import ( + "os" + "runtime" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/constants" + "seanime/internal/continuity" + "seanime/internal/database/db" + "seanime/internal/database/models" + debrid_client "seanime/internal/debrid/client" + "seanime/internal/directstream" + discordrpc_presence "seanime/internal/discordrpc/presence" + "seanime/internal/doh" + "seanime/internal/events" + "seanime/internal/extension_playground" + "seanime/internal/extension_repo" + "seanime/internal/hook" + "seanime/internal/library/autodownloader" + "seanime/internal/library/autoscanner" + "seanime/internal/library/fillermanager" + "seanime/internal/library/playbackmanager" + "seanime/internal/library/scanner" + "seanime/internal/local" + "seanime/internal/manga" + "seanime/internal/mediaplayers/iina" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/mediaplayers/mpchc" + "seanime/internal/mediaplayers/mpv" + "seanime/internal/mediaplayers/vlc" + "seanime/internal/mediastream" + "seanime/internal/nakama" + "seanime/internal/nativeplayer" + "seanime/internal/onlinestream" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/platforms/offline_platform" + "seanime/internal/platforms/platform" + "seanime/internal/platforms/simulated_platform" + "seanime/internal/plugin" + "seanime/internal/report" + "seanime/internal/torrent_clients/torrent_client" + "seanime/internal/torrents/torrent" + "seanime/internal/torrentstream" + "seanime/internal/updater" + "seanime/internal/user" + "seanime/internal/util" + "seanime/internal/util/filecache" + "seanime/internal/util/result" + "sync" + + "github.com/rs/zerolog" +) + +type ( + App struct { + Config *Config + Database *db.Database + Logger *zerolog.Logger + TorrentClientRepository *torrent_client.Repository + TorrentRepository *torrent.Repository + DebridClientRepository *debrid_client.Repository + Watcher *scanner.Watcher + AnilistClient anilist.AnilistClient + AnilistPlatform platform.Platform + OfflinePlatform platform.Platform + LocalManager local.Manager + FillerManager *fillermanager.FillerManager + WSEventManager *events.WSEventManager + AutoDownloader *autodownloader.AutoDownloader + ExtensionRepository *extension_repo.Repository + ExtensionPlaygroundRepository *extension_playground.PlaygroundRepository + DirectStreamManager *directstream.Manager + NativePlayer *nativeplayer.NativePlayer + MediaPlayer struct { + VLC *vlc.VLC + MpcHc *mpchc.MpcHc + Mpv *mpv.Mpv + Iina *iina.Iina + } + MediaPlayerRepository *mediaplayer.Repository + Version string + Updater *updater.Updater + AutoScanner *autoscanner.AutoScanner + PlaybackManager *playbackmanager.PlaybackManager + FileCacher *filecache.Cacher + OnlinestreamRepository *onlinestream.Repository + MangaRepository *manga.Repository + MetadataProvider metadata.Provider + DiscordPresence *discordrpc_presence.Presence + MangaDownloader *manga.Downloader + ContinuityManager *continuity.Manager + Cleanups []func() + OnRefreshAnilistCollectionFuncs *result.Map[string, func()] + OnFlushLogs func() + MediastreamRepository *mediastream.Repository + TorrentstreamRepository *torrentstream.Repository + FeatureFlags FeatureFlags + Settings *models.Settings + SecondarySettings struct { + Mediastream *models.MediastreamSettings + Torrentstream *models.TorrentstreamSettings + Debrid *models.DebridSettings + } // Struct for other settings sent to clientN + SelfUpdater *updater.SelfUpdater + ReportRepository *report.Repository + TotalLibrarySize uint64 // Initialized in modules.go + LibraryDir string + IsDesktopSidecar bool + animeCollection *anilist.AnimeCollection + rawAnimeCollection *anilist.AnimeCollection // (retains custom lists) + mangaCollection *anilist.MangaCollection + rawMangaCollection *anilist.MangaCollection // (retains custom lists) + user *user.User + previousVersion string + moduleMu sync.Mutex + HookManager hook.Manager + ServerReady bool // Whether the Anilist data from the first request has been fetched + isOffline *bool + NakamaManager *nakama.Manager + ServerPasswordHash string // SHA-256 hash of the server password + } +) + +// NewApp creates a new server instance +func NewApp(configOpts *ConfigOptions, selfupdater *updater.SelfUpdater) *App { + + // Initialize logger with predefined format + logger := util.NewLogger() + + // Log application version, OS, architecture and system info + logger.Info().Msgf("app: Seanime %s-%s", constants.Version, constants.VersionName) + logger.Info().Msgf("app: OS: %s", runtime.GOOS) + logger.Info().Msgf("app: Arch: %s", runtime.GOARCH) + logger.Info().Msgf("app: Processor count: %d", runtime.NumCPU()) + + // Initialize hook manager for plugin event system + hookManager := hook.NewHookManager(hook.NewHookManagerOptions{Logger: logger}) + hook.SetGlobalHookManager(hookManager) + plugin.GlobalAppContext.SetLogger(logger) + + // Store current version to detect version changes + previousVersion := constants.Version + + // Add callback to track version changes + configOpts.OnVersionChange = append(configOpts.OnVersionChange, func(oldVersion string, newVersion string) { + logger.Info().Str("prev", oldVersion).Str("current", newVersion).Msg("app: Version change detected") + previousVersion = oldVersion + }) + + // Initialize configuration with provided options + // Creates config directory if it doesn't exist + cfg, err := NewConfig(configOpts, logger) + if err != nil { + logger.Fatal().Err(err).Msgf("app: Failed to initialize config") + } + + // Compute SHA-256 hash of the server password + serverPasswordHash := "" + if cfg.Server.Password != "" { + serverPasswordHash = util.HashSHA256Hex(cfg.Server.Password) + } + + // Create logs directory if it doesn't exist + _ = os.MkdirAll(cfg.Logs.Dir, 0755) + + // Start background process to trim log files + go TrimLogEntries(cfg.Logs.Dir, logger) + + logger.Info().Msgf("app: Data directory: %s", cfg.Data.AppDataDir) + logger.Info().Msgf("app: Working directory: %s", cfg.Data.WorkingDir) + + // Log if running in desktop sidecar mode + if configOpts.IsDesktopSidecar { + logger.Info().Msg("app: Desktop sidecar mode enabled") + } + + // Initialize database connection + database, err := db.NewDatabase(cfg.Data.AppDataDir, cfg.Database.Name, logger) + if err != nil { + logger.Fatal().Err(err).Msgf("app: Failed to initialize database") + } + + HandleNewDatabaseEntries(database, logger) + + // Clean up old database entries in background goroutines + database.TrimLocalFileEntries() // Remove old local file entries + database.TrimScanSummaryEntries() // Remove old scan summaries + database.TrimTorrentstreamHistory() // Remove old torrent stream history + + // Get anime library paths for plugin context + animeLibraryPaths, _ := database.GetAllLibraryPathsFromSettings() + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + Database: database, + AnimeLibraryPaths: &animeLibraryPaths, + }) + + // Get Anilist token from database if available + anilistToken := database.GetAnilistToken() + + // Initialize Anilist API client with the token + // If the token is empty, the client will not be authenticated + anilistCW := anilist.NewAnilistClient(anilistToken) + + // Initialize WebSocket event manager for real-time communication + wsEventManager := events.NewWSEventManager(logger) + + // Exit if no WebSocket connections in desktop sidecar mode + if configOpts.IsDesktopSidecar { + wsEventManager.ExitIfNoConnsAsDesktopSidecar() + } + + // Initialize DNS-over-HTTPS service in background + go doh.HandleDoH(cfg.Server.DoHUrl, logger) + + // Initialize file cache system for media and metadata + fileCacher, err := filecache.NewCacher(cfg.Cache.Dir) + if err != nil { + logger.Fatal().Err(err).Msgf("app: Failed to initialize file cacher") + } + + // Initialize extension repository + extensionRepository := extension_repo.NewRepository(&extension_repo.NewRepositoryOptions{ + Logger: logger, + ExtensionDir: cfg.Extensions.Dir, + WSEventManager: wsEventManager, + FileCacher: fileCacher, + HookManager: hookManager, + }) + // Load extensions in background + go LoadExtensions(extensionRepository, logger, cfg) + + // Initialize metadata provider for media information + metadataProvider := metadata.NewProvider(&metadata.NewProviderImplOptions{ + Logger: logger, + FileCacher: fileCacher, + }) + + // Set initial metadata provider (will change if offline mode is enabled) + activeMetadataProvider := metadataProvider + + // Initialize manga repository + mangaRepository := manga.NewRepository(&manga.NewRepositoryOptions{ + Logger: logger, + FileCacher: fileCacher, + CacheDir: cfg.Cache.Dir, + ServerURI: cfg.GetServerURI(), + WsEventManager: wsEventManager, + DownloadDir: cfg.Manga.DownloadDir, + Database: database, + }) + + // Initialize Anilist platform + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistCW, logger) + + // Update plugin context with new modules + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + AnilistPlatform: anilistPlatform, + WSEventManager: wsEventManager, + MetadataProvider: metadataProvider, + }) + + // Initialize sync manager for offline/online synchronization + localManager, err := local.NewManager(&local.NewManagerOptions{ + LocalDir: cfg.Offline.Dir, + AssetDir: cfg.Offline.AssetDir, + Logger: logger, + MetadataProvider: metadataProvider, + MangaRepository: mangaRepository, + Database: database, + WSEventManager: wsEventManager, + IsOffline: cfg.Server.Offline, + AnilistPlatform: anilistPlatform, + }) + if err != nil { + logger.Fatal().Err(err).Msgf("app: Failed to initialize sync manager") + } + + // Use local metadata provider if in offline mode + if cfg.Server.Offline { + activeMetadataProvider = localManager.GetOfflineMetadataProvider() + } + + // Initialize local platform for offline operations + offlinePlatform, err := offline_platform.NewOfflinePlatform(localManager, anilistCW, logger) + if err != nil { + logger.Fatal().Err(err).Msgf("app: Failed to initialize local platform") + } + + // Initialize simulated platform for unauthenticated operations + simulatedPlatform, err := simulated_platform.NewSimulatedPlatform(localManager, anilistCW, logger) + if err != nil { + logger.Fatal().Err(err).Msgf("app: Failed to initialize simulated platform") + } + + // Change active platform if offline mode is enabled + activePlatform := anilistPlatform + if cfg.Server.Offline { + activePlatform = offlinePlatform + } else if !anilistCW.IsAuthenticated() { + logger.Warn().Msg("app: Anilist client is not authenticated, using simulated platform") + activePlatform = simulatedPlatform + } + + // Initialize online streaming repository + onlinestreamRepository := onlinestream.NewRepository(&onlinestream.NewRepositoryOptions{ + Logger: logger, + FileCacher: fileCacher, + MetadataProvider: activeMetadataProvider, + Platform: activePlatform, + Database: database, + }) + + // Initialize extension playground for testing extensions + extensionPlaygroundRepository := extension_playground.NewPlaygroundRepository(logger, activePlatform, activeMetadataProvider) + + isOffline := cfg.Server.Offline + + // Create the main app instance with initialized components + app := &App{ + Config: cfg, + Database: database, + AnilistClient: anilistCW, + AnilistPlatform: activePlatform, + OfflinePlatform: offlinePlatform, + LocalManager: localManager, + WSEventManager: wsEventManager, + Logger: logger, + Version: constants.Version, + Updater: updater.New(constants.Version, logger, wsEventManager), + FileCacher: fileCacher, + OnlinestreamRepository: onlinestreamRepository, + MetadataProvider: activeMetadataProvider, + MangaRepository: mangaRepository, + ExtensionRepository: extensionRepository, + ExtensionPlaygroundRepository: extensionPlaygroundRepository, + ReportRepository: report.NewRepository(logger), + TorrentRepository: nil, // Initialized in App.initModulesOnce + FillerManager: nil, // Initialized in App.initModulesOnce + MangaDownloader: nil, // Initialized in App.initModulesOnce + PlaybackManager: nil, // Initialized in App.initModulesOnce + AutoDownloader: nil, // Initialized in App.initModulesOnce + AutoScanner: nil, // Initialized in App.initModulesOnce + MediastreamRepository: nil, // Initialized in App.initModulesOnce + TorrentstreamRepository: nil, // Initialized in App.initModulesOnce + ContinuityManager: nil, // Initialized in App.initModulesOnce + DebridClientRepository: nil, // Initialized in App.initModulesOnce + DirectStreamManager: nil, // Initialized in App.initModulesOnce + NativePlayer: nil, // Initialized in App.initModulesOnce + NakamaManager: nil, // Initialized in App.initModulesOnce + TorrentClientRepository: nil, // Initialized in App.InitOrRefreshModules + MediaPlayerRepository: nil, // Initialized in App.InitOrRefreshModules + DiscordPresence: nil, // Initialized in App.InitOrRefreshModules + previousVersion: previousVersion, + FeatureFlags: NewFeatureFlags(cfg, logger), + IsDesktopSidecar: configOpts.IsDesktopSidecar, + SecondarySettings: struct { + Mediastream *models.MediastreamSettings + Torrentstream *models.TorrentstreamSettings + Debrid *models.DebridSettings + }{Mediastream: nil, Torrentstream: nil}, + SelfUpdater: selfupdater, + moduleMu: sync.Mutex{}, + OnRefreshAnilistCollectionFuncs: result.NewResultMap[string, func()](), + HookManager: hookManager, + isOffline: &isOffline, + ServerPasswordHash: serverPasswordHash, + } + + // Run database migrations if version has changed + app.runMigrations() + + // Initialize modules that only need to be initialized once + app.initModulesOnce() + + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + IsOffline: app.IsOffline(), + ContinuityManager: app.ContinuityManager, + AutoScanner: app.AutoScanner, + AutoDownloader: app.AutoDownloader, + FileCacher: app.FileCacher, + OnlinestreamRepository: app.OnlinestreamRepository, + MediastreamRepository: app.MediastreamRepository, + TorrentstreamRepository: app.TorrentstreamRepository, + }) + + if !*app.IsOffline() { + go app.Updater.FetchAnnouncements() + } + + // Initialize all modules that depend on settings + app.InitOrRefreshModules() + + // Load built-in extensions into extension consumers + app.AddExtensionBankToConsumers() + + // Initialize Anilist data if not in offline mode + if !*app.IsOffline() { + app.InitOrRefreshAnilistData() + } else { + app.ServerReady = true + } + + // Initialize mediastream settings (for streaming media) + app.InitOrRefreshMediastreamSettings() + + // Initialize torrentstream settings (for torrent streaming) + app.InitOrRefreshTorrentstreamSettings() + + // Initialize debrid settings (for debrid services) + app.InitOrRefreshDebridSettings() + + // Register Nakama manager cleanup + app.AddCleanupFunction(app.NakamaManager.Cleanup) + + // Run one-time initialization actions + app.performActionsOnce() + + return app +} + +func (a *App) IsOffline() *bool { + return a.isOffline +} + +func (a *App) AddCleanupFunction(f func()) { + a.Cleanups = append(a.Cleanups, f) +} +func (a *App) AddOnRefreshAnilistCollectionFunc(key string, f func()) { + if key == "" { + return + } + a.OnRefreshAnilistCollectionFuncs.Set(key, f) +} + +func (a *App) Cleanup() { + for _, f := range a.Cleanups { + f() + } +} diff --git a/seanime-2.9.10/internal/core/config.go b/seanime-2.9.10/internal/core/config.go new file mode 100644 index 0000000..f6fcf25 --- /dev/null +++ b/seanime-2.9.10/internal/core/config.go @@ -0,0 +1,439 @@ +package core + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "seanime/internal/constants" + "seanime/internal/util" + "strconv" + + "github.com/rs/zerolog" + "github.com/spf13/viper" +) + +type Config struct { + Version string + Server struct { + Host string + Port int + Offline bool + UseBinaryPath bool // Makes $SEANIME_WORKING_DIR point to the binary's directory + Systray bool + DoHUrl string + Password string + } + Database struct { + Name string + } + Web struct { + AssetDir string + } + Logs struct { + Dir string + } + Cache struct { + Dir string + TranscodeDir string + } + Offline struct { + Dir string + AssetDir string + } + Manga struct { + DownloadDir string + LocalDir string + } + Data struct { // Hydrated after config is loaded + AppDataDir string + WorkingDir string + } + Extensions struct { + Dir string + } + Anilist struct { + ClientID string + } + Experimental struct { + MainServerTorrentStreaming bool + } +} + +type ConfigOptions struct { + DataDir string // The path to the Seanime data directory, if any + OnVersionChange []func(oldVersion string, newVersion string) + EmbeddedLogo []byte // The embedded logo + IsDesktopSidecar bool // Run as the desktop sidecar +} + +// NewConfig initializes the config +func NewConfig(options *ConfigOptions, logger *zerolog.Logger) (*Config, error) { + + logger.Debug().Msg("app: Initializing config") + + // Set Seanime's environment variables + if os.Getenv("SEANIME_DATA_DIR") != "" { + options.DataDir = os.Getenv("SEANIME_DATA_DIR") + } + + defaultHost := "127.0.0.1" + defaultPort := 43211 + + if os.Getenv("SEANIME_SERVER_HOST") != "" { + defaultHost = os.Getenv("SEANIME_SERVER_HOST") + } + if os.Getenv("SEANIME_SERVER_PORT") != "" { + var err error + defaultPort, err = strconv.Atoi(os.Getenv("SEANIME_SERVER_PORT")) + if err != nil { + return nil, fmt.Errorf("invalid SEANIME_SERVER_PORT environment variable: %s", os.Getenv("SEANIME_SERVER_PORT")) + } + } + + // Initialize the app data directory + dataDir, configPath, err := initAppDataDir(options.DataDir, logger) + if err != nil { + return nil, err + } + + // Set Seanime's default custom environment variables + if err = setDataDirEnv(dataDir); err != nil { + return nil, err + } + + // Configure viper + viper.SetConfigName(constants.ConfigFileName) + viper.SetConfigType("toml") + viper.SetConfigFile(configPath) + + // Set default values + viper.SetDefault("version", constants.Version) + viper.SetDefault("server.host", defaultHost) + viper.SetDefault("server.port", defaultPort) + viper.SetDefault("server.offline", false) + // Use the binary's directory as the working directory environment variable on macOS + viper.SetDefault("server.useBinaryPath", true) + //viper.SetDefault("server.systray", true) + viper.SetDefault("database.name", "seanime") + viper.SetDefault("web.assetDir", "$SEANIME_DATA_DIR/assets") + viper.SetDefault("cache.dir", "$SEANIME_DATA_DIR/cache") + viper.SetDefault("cache.transcodeDir", "$SEANIME_DATA_DIR/cache/transcode") + viper.SetDefault("manga.downloadDir", "$SEANIME_DATA_DIR/manga") + viper.SetDefault("manga.localDir", "$SEANIME_DATA_DIR/manga-local") + viper.SetDefault("logs.dir", "$SEANIME_DATA_DIR/logs") + viper.SetDefault("offline.dir", "$SEANIME_DATA_DIR/offline") + viper.SetDefault("offline.assetDir", "$SEANIME_DATA_DIR/offline/assets") + viper.SetDefault("extensions.dir", "$SEANIME_DATA_DIR/extensions") + + // Create and populate the config file if it doesn't exist + if err = createConfigFile(configPath); err != nil { + return nil, err + } + + // Read the config file + if err := viper.ReadInConfig(); err != nil { + return nil, err + } + + // Unmarshal the config values + cfg := &Config{} + if err := viper.Unmarshal(cfg); err != nil { + return nil, err + } + + // Update the config if the version has changed + if err := updateVersion(cfg, options); err != nil { + return nil, err + } + + // Before expanding the values, check if we need to override the working directory + if err = setWorkingDirEnv(cfg.Server.UseBinaryPath); err != nil { + return nil, err + } + + // Expand the values, replacing environment variables + expandEnvironmentValues(cfg) + cfg.Data.AppDataDir = dataDir + cfg.Data.WorkingDir = os.Getenv("SEANIME_WORKING_DIR") + + // Check validity of the config + if err := validateConfig(cfg, logger); err != nil { + return nil, err + } + + go loadLogo(options.EmbeddedLogo, dataDir) + + return cfg, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (cfg *Config) GetServerAddr(df ...string) string { + return fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) +} + +func (cfg *Config) GetServerURI(df ...string) string { + pAddr := fmt.Sprintf("http://%s", cfg.GetServerAddr(df...)) + if cfg.Server.Host == "" || cfg.Server.Host == "0.0.0.0" { + pAddr = fmt.Sprintf(":%d", cfg.Server.Port) + if len(df) > 0 { + pAddr = fmt.Sprintf("http://%s:%d", df[0], cfg.Server.Port) + } + } + return pAddr +} + +func getWorkingDir(useBinaryPath bool) (string, error) { + // Get the working directory + wd, err := os.Getwd() + if err != nil { + return "", err + } + + binaryDir := "" + if exe, err := os.Executable(); err == nil { + if p, err := filepath.EvalSymlinks(exe); err == nil { + binaryDir = filepath.Dir(p) + binaryDir = filepath.FromSlash(binaryDir) + } + } + + if useBinaryPath && binaryDir != "" { + return binaryDir, nil + } + + //// Use the binary's directory as the working directory if needed + //if useBinaryPath { + // exe, err := os.Executable() + // if err != nil { + // return wd, nil // Fallback to working dir + // } + // p, err := filepath.EvalSymlinks(exe) + // if err != nil { + // return wd, nil // Fallback to working dir + // } + // wd = filepath.Dir(p) // Set the binary's directory as the working directory + // return wd, nil + //} + return wd, nil +} + +func setDataDirEnv(dataDir string) error { + // Set the data directory environment variable + if os.Getenv("SEANIME_DATA_DIR") == "" { + if err := os.Setenv("SEANIME_DATA_DIR", dataDir); err != nil { + return err + } + } + + return nil +} + +func setWorkingDirEnv(useBinaryPath bool) error { + // Set the working directory environment variable + wd, err := getWorkingDir(useBinaryPath) + if err != nil { + return err + } + if err = os.Setenv("SEANIME_WORKING_DIR", filepath.FromSlash(wd)); err != nil { + return err + } + + return nil +} + +// validateConfig checks if the config values are valid +func validateConfig(cfg *Config, logger *zerolog.Logger) error { + if cfg.Server.Host == "" { + return errInvalidConfigValue("server.host", "cannot be empty") + } + if cfg.Server.Port == 0 { + return errInvalidConfigValue("server.port", "cannot be 0") + } + if cfg.Database.Name == "" { + return errInvalidConfigValue("database.name", "cannot be empty") + } + if cfg.Web.AssetDir == "" { + return errInvalidConfigValue("web.assetDir", "cannot be empty") + } + if err := checkIsValidPath(cfg.Web.AssetDir); err != nil { + return wrapInvalidConfigValue("web.assetDir", err) + } + + if cfg.Cache.Dir == "" { + return errInvalidConfigValue("cache.dir", "cannot be empty") + } + if err := checkIsValidPath(cfg.Cache.Dir); err != nil { + return wrapInvalidConfigValue("cache.dir", err) + } + + if cfg.Cache.TranscodeDir == "" { + return errInvalidConfigValue("cache.transcodeDir", "cannot be empty") + } + if err := checkIsValidPath(cfg.Cache.TranscodeDir); err != nil { + return wrapInvalidConfigValue("cache.transcodeDir", err) + } + + if cfg.Logs.Dir == "" { + return errInvalidConfigValue("logs.dir", "cannot be empty") + } + if err := checkIsValidPath(cfg.Logs.Dir); err != nil { + return wrapInvalidConfigValue("logs.dir", err) + } + + if cfg.Manga.DownloadDir == "" { + return errInvalidConfigValue("manga.downloadDir", "cannot be empty") + } + if err := checkIsValidPath(cfg.Manga.DownloadDir); err != nil { + return wrapInvalidConfigValue("manga.downloadDir", err) + } + + if cfg.Manga.LocalDir == "" { + return errInvalidConfigValue("manga.localDir", "cannot be empty") + } + if err := checkIsValidPath(cfg.Manga.LocalDir); err != nil { + return wrapInvalidConfigValue("manga.localDir", err) + } + + if cfg.Extensions.Dir == "" { + return errInvalidConfigValue("extensions.dir", "cannot be empty") + } + if err := checkIsValidPath(cfg.Extensions.Dir); err != nil { + return wrapInvalidConfigValue("extensions.dir", err) + } + + // Uncomment if "MainServerTorrentStreaming" is no longer an experimental feature + if cfg.Experimental.MainServerTorrentStreaming { + logger.Warn().Msgf("app: 'Main Server Torrent Streaming' feature is no longer experimental, remove the flag from your config file") + } + + return nil +} + +func checkIsValidPath(path string) error { + ok := filepath.IsAbs(path) + if !ok { + return errors.New("path is not an absolute path") + } + return nil +} + +// errInvalidConfigValue returns an error for an invalid config value +func errInvalidConfigValue(s string, s2 string) error { + return fmt.Errorf("invalid config value: \"%s\" %s", s, s2) +} +func wrapInvalidConfigValue(s string, err error) error { + return fmt.Errorf("invalid config value: \"%s\" %w", s, err) +} + +func updateVersion(cfg *Config, opts *ConfigOptions) error { + defer func() { + if r := recover(); r != nil { + // Do nothing + } + }() + + if cfg.Version != constants.Version { + for _, f := range opts.OnVersionChange { + f(cfg.Version, constants.Version) + } + cfg.Version = constants.Version + } + + viper.Set("version", constants.Version) + + return viper.WriteConfig() +} + +func expandEnvironmentValues(cfg *Config) { + defer func() { + if r := recover(); r != nil { + // Do nothing + } + }() + cfg.Web.AssetDir = filepath.FromSlash(os.ExpandEnv(cfg.Web.AssetDir)) + cfg.Cache.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Cache.Dir)) + cfg.Cache.TranscodeDir = filepath.FromSlash(os.ExpandEnv(cfg.Cache.TranscodeDir)) + cfg.Logs.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Logs.Dir)) + cfg.Manga.DownloadDir = filepath.FromSlash(os.ExpandEnv(cfg.Manga.DownloadDir)) + cfg.Manga.LocalDir = filepath.FromSlash(os.ExpandEnv(cfg.Manga.LocalDir)) + cfg.Offline.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Offline.Dir)) + cfg.Offline.AssetDir = filepath.FromSlash(os.ExpandEnv(cfg.Offline.AssetDir)) + cfg.Extensions.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Extensions.Dir)) +} + +// createConfigFile creates a default config file if it doesn't exist +func createConfigFile(configPath string) error { + _, err := os.Stat(configPath) + if os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { + return err + } + if err := viper.WriteConfig(); err != nil { + return err + } + } + return nil +} + +func initAppDataDir(definedDataDir string, logger *zerolog.Logger) (dataDir string, configPath string, err error) { + + // User defined data directory + if definedDataDir != "" { + + // Expand environment variables + definedDataDir = filepath.FromSlash(os.ExpandEnv(definedDataDir)) + + if !filepath.IsAbs(definedDataDir) { + return "", "", errors.New("app: Data directory path must be absolute") + } + + // Replace the default data directory + dataDir = definedDataDir + + logger.Trace().Str("dataDir", dataDir).Msg("app: Overriding default data directory") + } else { + // Default OS data directory + // windows: %APPDATA% + // unix: $XDG_CONFIG_HOME or $HOME + // darwin: $HOME/Library/Application Support + dataDir, err = os.UserConfigDir() + if err != nil { + return "", "", err + } + // Get the app directory + dataDir = filepath.Join(dataDir, "Seanime") + } + + // Create data dir if it doesn't exist + if err := os.MkdirAll(dataDir, 0700); err != nil { + return "", "", err + } + + // Get the config file path + // Normalize the config file path + configPath = filepath.FromSlash(filepath.Join(dataDir, constants.ConfigFileName)) + // Normalize the data directory path + dataDir = filepath.FromSlash(dataDir) + + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func loadLogo(embeddedLogo []byte, dataDir string) (err error) { + defer util.HandlePanicInModuleWithError("core/loadLogo", &err) + + if len(embeddedLogo) == 0 { + return nil + } + + logoPath := filepath.Join(dataDir, "logo.png") + if _, err = os.Stat(logoPath); os.IsNotExist(err) { + if err = os.WriteFile(logoPath, embeddedLogo, 0644); err != nil { + return err + } + } + return nil +} diff --git a/seanime-2.9.10/internal/core/echo.go b/seanime-2.9.10/internal/core/echo.go new file mode 100644 index 0000000..2d52d1f --- /dev/null +++ b/seanime-2.9.10/internal/core/echo.go @@ -0,0 +1,93 @@ +package core + +import ( + "embed" + "io/fs" + "log" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/goccy/go-json" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +func NewEchoApp(app *App, webFS *embed.FS) *echo.Echo { + e := echo.New() + e.HideBanner = true + e.HidePort = true + e.Debug = false + e.JSONSerializer = &CustomJSONSerializer{} + + distFS, err := fs.Sub(webFS, "web") + if err != nil { + log.Fatal(err) + } + + e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ + Filesystem: http.FS(distFS), + Browse: true, + HTML5: true, + Skipper: func(c echo.Context) bool { + cUrl := c.Request().URL + if strings.HasPrefix(cUrl.RequestURI(), "/api") || + strings.HasPrefix(cUrl.RequestURI(), "/events") || + strings.HasPrefix(cUrl.RequestURI(), "/assets") || + strings.HasPrefix(cUrl.RequestURI(), "/manga-downloads") || + strings.HasPrefix(cUrl.RequestURI(), "/offline-assets") { + return true // Continue to the next handler + } + if !strings.HasSuffix(cUrl.Path, ".html") && filepath.Ext(cUrl.Path) == "" { + cUrl.Path = cUrl.Path + ".html" + } + if cUrl.Path == "/.html" { + cUrl.Path = "/index.html" + } + return false // Continue to the filesystem handler + }, + })) + + app.Logger.Info().Msgf("app: Serving embedded web interface") + + // Serve web assets + app.Logger.Info().Msgf("app: Web assets path: %s", app.Config.Web.AssetDir) + e.Static("/assets", app.Config.Web.AssetDir) + + // Serve manga downloads + if app.Config.Manga.DownloadDir != "" { + app.Logger.Info().Msgf("app: Manga downloads path: %s", app.Config.Manga.DownloadDir) + e.Static("/manga-downloads", app.Config.Manga.DownloadDir) + } + + // Serve offline assets + app.Logger.Info().Msgf("app: Offline assets path: %s", app.Config.Offline.AssetDir) + e.Static("/offline-assets", app.Config.Offline.AssetDir) + + return e +} + +type CustomJSONSerializer struct{} + +func (j *CustomJSONSerializer) Serialize(c echo.Context, i interface{}, indent string) error { + enc := json.NewEncoder(c.Response()) + return enc.Encode(i) +} + +func (j *CustomJSONSerializer) Deserialize(c echo.Context, i interface{}) error { + dec := json.NewDecoder(c.Request().Body) + return dec.Decode(i) +} + +func RunEchoServer(app *App, e *echo.Echo) { + app.Logger.Info().Msgf("app: Server Address: %s", app.Config.GetServerAddr()) + + // Start the server + go func() { + log.Fatal(e.Start(app.Config.GetServerAddr())) + }() + + time.Sleep(100 * time.Millisecond) + app.Logger.Info().Msg("app: Seanime started at " + app.Config.GetServerURI()) +} diff --git a/seanime-2.9.10/internal/core/extensions.go b/seanime-2.9.10/internal/core/extensions.go new file mode 100644 index 0000000..70db020 --- /dev/null +++ b/seanime-2.9.10/internal/core/extensions.go @@ -0,0 +1,263 @@ +package core + +import ( + "seanime/internal/extension" + "seanime/internal/extension_repo" + manga_providers "seanime/internal/manga/providers" + onlinestream_providers "seanime/internal/onlinestream/providers" + "seanime/internal/torrents/animetosho" + "seanime/internal/torrents/nyaa" + "seanime/internal/torrents/seadex" + "seanime/internal/util" + + "github.com/rs/zerolog" +) + +func LoadExtensions(extensionRepository *extension_repo.Repository, logger *zerolog.Logger, config *Config) { + + // + // Built-in manga providers + // + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "comick", + Name: "ComicK", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeMangaProvider, + Author: "Seanime", + Description: "", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/comick.webp", + }, manga_providers.NewComicK(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "comick-multi", + Name: "ComicK (Multi)", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeMangaProvider, + Author: "Seanime", + Description: "", + Lang: "multi", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/comick.webp", + }, manga_providers.NewComicKMulti(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "mangapill", + Name: "Mangapill", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeMangaProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/mangapill.png", + }, manga_providers.NewMangapill(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "weebcentral", + Name: "WeebCentral", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeMangaProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/weebcentral.png", + }, manga_providers.NewWeebCentral(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "mangadex", + Name: "Mangadex", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeMangaProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/mangadex.png", + }, manga_providers.NewMangadex(logger)) + + //extensionRepository.ReloadBuiltInExtension(extension.Extension{ + // ID: "manganato", + // Name: "Manganato", + // Version: "", + // ManifestURI: "builtin", + // Language: extension.LanguageGo, + // Type: extension.TypeMangaProvider, + // Author: "Seanime", + // Lang: "en", + // Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/manganato.png", + //}, manga_providers.NewManganato(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: manga_providers.LocalProvider, + Name: "Local", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeMangaProvider, + Author: "Seanime", + Lang: "multi", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/local-manga.png", + }, manga_providers.NewLocal(config.Manga.LocalDir, logger)) + + // + // Built-in online stream providers + // + + //extensionRepository.LoadBuiltInOnlinestreamProviderExtension(extension.Extension{ + // ID: "gogoanime", + // Name: "Gogoanime", + // Version: "", + // ManifestURI: "builtin", + // Language: extension.LanguageGo, + // Type: extension.TypeOnlinestreamProvider, + // Author: "Seanime", + // Lang: "en", + // Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/gogoanime.png", + //}, onlinestream_providers.NewGogoanime(logger)) + + //extensionRepository.LoadBuiltInOnlinestreamProviderExtension(extension.Extension{ + // ID: "zoro", + // Name: "Hianime", + // Version: "", + // ManifestURI: "builtin", + // Language: extension.LanguageGo, + // Type: extension.TypeOnlinestreamProvider, + // Author: "Seanime", + // Lang: "en", + // Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/hianime.png", + //}, onlinestream_providers.NewZoro(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "animepahe", + Name: "Animepahe", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageTypescript, + Type: extension.TypeOnlinestreamProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/animepahe.png", + Payload: onlinestream_providers.AnimepahePayload, + }, nil) + + // + // Built-in torrent providers + // + + nyaaUserConfig := extension.UserConfig{ + Version: 1, + Fields: []extension.ConfigField{ + { + Name: "apiUrl", + Label: "API URL", + Type: extension.ConfigFieldTypeText, + Default: util.Decode("aHR0cHM6Ly9ueWFhLnNpLz9wYWdlPXJzcyZxPSs="), + }, + }, + } + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "nyaa", + Name: "Nyaa", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/nyaa.png", + UserConfig: &nyaaUserConfig, + }, nyaa.NewProvider(logger, "anime-eng")) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "nyaa-non-eng", + Name: "Nyaa (Non-English)", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + Lang: "multi", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/nyaa.png", + UserConfig: &nyaaUserConfig, + }, nyaa.NewProvider(logger, "anime-non-eng")) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "nyaa-sukebei", + Name: "Nyaa Sukebei", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/nyaa.png", + UserConfig: &extension.UserConfig{ + Version: 1, + Fields: []extension.ConfigField{ + { + Name: "apiUrl", + Label: "API URL", + Type: extension.ConfigFieldTypeText, + Default: util.Decode("aHR0cHM6Ly9zdWtlYmVpLm55YWEuc2kvP3BhZ2U9cnNzJnE9Kw=="), + }, + }, + }, + }, nyaa.NewSukebeiProvider(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "animetosho", + Name: "AnimeTosho", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/animetosho.png", + }, animetosho.NewProvider(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "seadex", + Name: "SeaDex", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/seadex.png", + UserConfig: &extension.UserConfig{ + Version: 1, + Fields: []extension.ConfigField{ + { + Name: "apiUrl", + Label: "API URL", + Type: extension.ConfigFieldTypeText, + Default: util.Decode("aHR0cHM6Ly9yZWxlYXNlcy5tb2UvYXBpL2NvbGxlY3Rpb25zL2VudHJpZXMvcmVjb3Jkcw=="), + }, + }, + }, + }, seadex.NewProvider(logger)) + + extensionRepository.ReloadExternalExtensions() +} + +func (a *App) AddExtensionBankToConsumers() { + + var consumers = []extension.Consumer{ + a.MangaRepository, + a.OnlinestreamRepository, + a.TorrentRepository, + } + + for _, consumer := range consumers { + consumer.InitExtensionBank(a.ExtensionRepository.GetExtensionBank()) + } +} diff --git a/seanime-2.9.10/internal/core/feature_flags.go b/seanime-2.9.10/internal/core/feature_flags.go new file mode 100644 index 0000000..63b8d62 --- /dev/null +++ b/seanime-2.9.10/internal/core/feature_flags.go @@ -0,0 +1,36 @@ +package core + +import ( + "github.com/rs/zerolog" + "github.com/spf13/viper" +) + +type ( + FeatureFlags struct { + MainServerTorrentStreaming bool + } + + ExperimentalFeatureFlags struct { + } +) + +// NewFeatureFlags initializes the feature flags +func NewFeatureFlags(cfg *Config, logger *zerolog.Logger) FeatureFlags { + ff := FeatureFlags{ + MainServerTorrentStreaming: viper.GetBool("experimental.mainServerTorrentStreaming"), + } + + checkExperimentalFeatureFlags(&ff, cfg, logger) + + return ff +} + +func checkExperimentalFeatureFlags(ff *FeatureFlags, cfg *Config, logger *zerolog.Logger) { + if ff.MainServerTorrentStreaming { + logger.Warn().Msg("app: [Feature flag] 'Main Server Torrent Streaming' experimental feature is enabled") + } +} + +func (ff *FeatureFlags) IsMainServerTorrentStreamingEnabled() bool { + return ff.MainServerTorrentStreaming +} diff --git a/seanime-2.9.10/internal/core/flags.go b/seanime-2.9.10/internal/core/flags.go new file mode 100644 index 0000000..99b4386 --- /dev/null +++ b/seanime-2.9.10/internal/core/flags.go @@ -0,0 +1,43 @@ +package core + +import ( + "flag" + "fmt" + "strings" +) + +type ( + SeanimeFlags struct { + DataDir string + Update bool + IsDesktopSidecar bool + } +) + +func GetSeanimeFlags() SeanimeFlags { + // Help flag + flag.Usage = func() { + fmt.Printf("Self-hosted, user-friendly, media server for anime and manga enthusiasts.\n\n") + fmt.Printf("Usage:\n seanime [flags]\n\n") + fmt.Printf("Flags:\n") + fmt.Printf(" -datadir, --datadir string") + fmt.Printf(" directory that contains all Seanime data\n") + fmt.Printf(" -update") + fmt.Printf(" update the application\n") + fmt.Printf(" -h show this help message\n") + } + // Parse flags + var dataDir string + flag.StringVar(&dataDir, "datadir", "", "Directory that contains all Seanime data") + var update bool + flag.BoolVar(&update, "update", false, "Update the application") + var isDesktopSidecar bool + flag.BoolVar(&isDesktopSidecar, "desktop-sidecar", false, "Run as the desktop sidecar") + flag.Parse() + + return SeanimeFlags{ + DataDir: strings.TrimSpace(dataDir), + Update: update, + IsDesktopSidecar: isDesktopSidecar, + } +} diff --git a/seanime-2.9.10/internal/core/hmac_auth.go b/seanime-2.9.10/internal/core/hmac_auth.go new file mode 100644 index 0000000..8222de6 --- /dev/null +++ b/seanime-2.9.10/internal/core/hmac_auth.go @@ -0,0 +1,19 @@ +package core + +import ( + "seanime/internal/util" + "time" +) + +// GetServerPasswordHMACAuth returns an HMAC authenticator using the hashed server password as the base secret +// This is used for server endpoints that don't use Nakama +func (a *App) GetServerPasswordHMACAuth() *util.HMACAuth { + var secret string + if a.Config != nil && a.Config.Server.Password != "" { + secret = a.ServerPasswordHash + } else { + secret = "seanime-default-secret" + } + + return util.NewHMACAuth(secret, 24*time.Hour) +} diff --git a/seanime-2.9.10/internal/core/logging.go b/seanime-2.9.10/internal/core/logging.go new file mode 100644 index 0000000..e7cfb8f --- /dev/null +++ b/seanime-2.9.10/internal/core/logging.go @@ -0,0 +1,83 @@ +package core + +import ( + "github.com/rs/zerolog" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func TrimLogEntries(dir string, logger *zerolog.Logger) { + // Get all log files in the directory + entries, err := os.ReadDir(dir) + if err != nil { + logger.Error().Err(err).Msg("core: Failed to read log directory") + return + } + + // Get the total size of all log entries + var totalSize int64 + for _, file := range entries { + if file.IsDir() { + continue + } + info, err := file.Info() + if err != nil { + continue + } + totalSize += info.Size() + } + + var files []os.FileInfo + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, info) + } + + var serverLogFiles []os.FileInfo + var scanLogFiles []os.FileInfo + + for _, file := range files { + if strings.HasPrefix(file.Name(), "seanime-") { + serverLogFiles = append(serverLogFiles, file) + } else if strings.Contains(file.Name(), "-scan") { + scanLogFiles = append(scanLogFiles, file) + } + } + + for _, _files := range [][]os.FileInfo{serverLogFiles, scanLogFiles} { + files := _files + if len(files) <= 1 { + continue + } + + // Sort from newest to oldest + sort.Slice(files, func(i, j int) bool { + return files[i].ModTime().After(files[j].ModTime()) + }) + + // Delete all log files older than 14 days + deleted := 0 + for i := 1; i < len(files); i++ { + if time.Since(files[i].ModTime()) > 14*24*time.Hour { + err := os.Remove(filepath.Join(dir, files[i].Name())) + if err != nil { + continue + } + deleted++ + } + } + if deleted > 0 { + logger.Info().Msgf("app: Deleted %d log files older than 14 days", deleted) + } + } + +} diff --git a/seanime-2.9.10/internal/core/migrations.go b/seanime-2.9.10/internal/core/migrations.go new file mode 100644 index 0000000..3008370 --- /dev/null +++ b/seanime-2.9.10/internal/core/migrations.go @@ -0,0 +1,117 @@ +package core + +import ( + "seanime/internal/constants" + "seanime/internal/util" + "strings" + + "github.com/Masterminds/semver/v3" +) + +func (a *App) runMigrations() { + + go func() { + done := false + defer func() { + if done { + a.Logger.Info().Msg("app: Version migration complete") + } + }() + defer util.HandlePanicThen(func() { + a.Logger.Error().Msg("app: runMigrations failed") + }) + + previousVersion, err := semver.NewVersion(a.previousVersion) + if err != nil { + a.Logger.Error().Err(err).Msg("app: Failed to parse previous version") + return + } + + if a.previousVersion != constants.Version { + + hasUpdated := util.VersionIsOlderThan(a.previousVersion, constants.Version) + + //----------------------------------------------------------------------------------------- + // DEVNOTE: 1.2.0 uses an incorrect manga cache format for MangaSee pages + // This migration will remove all manga cache files that start with "manga_" + if a.previousVersion == "1.2.0" && hasUpdated { + a.Logger.Debug().Msg("app: Executing version migration task") + err := a.FileCacher.RemoveAllBy(func(filename string) bool { + return strings.HasPrefix(filename, "manga_") + }) + if err != nil { + a.Logger.Error().Err(err).Msg("app: MIGRATION FAILED; READ THIS") + a.Logger.Error().Msg("app: Failed to remove 'manga' cache files, please clear them manually by going to the settings. Ignore this message if you have no manga cache files.") + } + done = true + } + + //----------------------------------------------------------------------------------------- + + c1, _ := semver.NewConstraint("<= 1.3.0, >= 1.2.0") + if c1.Check(previousVersion) { + a.Logger.Debug().Msg("app: Executing version migration task") + err := a.FileCacher.RemoveAllBy(func(filename string) bool { + return strings.HasPrefix(filename, "manga_") + }) + if err != nil { + a.Logger.Error().Err(err).Msg("app: MIGRATION FAILED; READ THIS") + a.Logger.Error().Msg("app: Failed to remove 'manga' cache files, please clear them manually by going to the settings. Ignore this message if you have no manga cache files.") + } + done = true + } + + //----------------------------------------------------------------------------------------- + + // DEVNOTE: 1.5.6 uses a different cache format for media streaming info + // -> Delete the cache files when updated from any version between 1.5.0 and 1.5.5 + c2, _ := semver.NewConstraint("<= 1.5.5, >= 1.5.0") + if c2.Check(previousVersion) { + a.Logger.Debug().Msg("app: Executing version migration task") + err := a.FileCacher.RemoveAllBy(func(filename string) bool { + return strings.HasPrefix(filename, "mediastream_mediainfo_") + }) + if err != nil { + a.Logger.Error().Err(err).Msg("app: MIGRATION FAILED; READ THIS") + a.Logger.Error().Msg("app: Failed to remove transcoding cache files, please clear them manually by going to the settings. Ignore this message if you have no transcoding cache files.") + } + done = true + } + + //----------------------------------------------------------------------------------------- + + // DEVNOTE: 2.0.0 uses a different cache format for online streaming + // -> Delete the cache files when updated from a version older than 2.0.0 and newer than 1.5.0 + c3, _ := semver.NewConstraint("< 2.0.0, >= 1.5.0") + if c3.Check(previousVersion) { + a.Logger.Debug().Msg("app: Executing version migration task") + err := a.FileCacher.RemoveAllBy(func(filename string) bool { + return strings.HasPrefix(filename, "onlinestream_") + }) + if err != nil { + a.Logger.Error().Err(err).Msg("app: MIGRATION FAILED; READ THIS") + a.Logger.Error().Msg("app: Failed to remove online streaming cache files, please clear them manually by going to the settings. Ignore this message if you have no online streaming cache files.") + } + done = true + } + + //----------------------------------------------------------------------------------------- + + // DEVNOTE: 2.1.0 refactored the manga cache format + // -> Delete the cache files when updated from a version older than 2.1.0 + c4, _ := semver.NewConstraint("< 2.1.0") + if c4.Check(previousVersion) { + a.Logger.Debug().Msg("app: Executing version migration task") + err := a.FileCacher.RemoveAllBy(func(filename string) bool { + return strings.HasPrefix(filename, "manga_") + }) + if err != nil { + a.Logger.Error().Err(err).Msg("app: MIGRATION FAILED; READ THIS") + a.Logger.Error().Msg("app: Failed to remove 'manga' cache files, please clear them manually by going to the settings. Ignore this message if you have no manga cache files.") + } + done = true + } + } + }() + +} diff --git a/seanime-2.9.10/internal/core/modules.go b/seanime-2.9.10/internal/core/modules.go new file mode 100644 index 0000000..846b9ea --- /dev/null +++ b/seanime-2.9.10/internal/core/modules.go @@ -0,0 +1,727 @@ +package core + +import ( + "runtime" + "seanime/internal/api/anilist" + "seanime/internal/continuity" + "seanime/internal/database/db" + "seanime/internal/database/db_bridge" + "seanime/internal/database/models" + debrid_client "seanime/internal/debrid/client" + "seanime/internal/directstream" + discordrpc_presence "seanime/internal/discordrpc/presence" + "seanime/internal/events" + "seanime/internal/library/anime" + "seanime/internal/library/autodownloader" + "seanime/internal/library/autoscanner" + "seanime/internal/library/fillermanager" + "seanime/internal/library/playbackmanager" + "seanime/internal/manga" + "seanime/internal/mediaplayers/iina" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/mediaplayers/mpchc" + "seanime/internal/mediaplayers/mpv" + "seanime/internal/mediaplayers/vlc" + "seanime/internal/mediastream" + "seanime/internal/nakama" + "seanime/internal/nativeplayer" + "seanime/internal/notifier" + "seanime/internal/plugin" + "seanime/internal/torrent_clients/qbittorrent" + "seanime/internal/torrent_clients/torrent_client" + "seanime/internal/torrent_clients/transmission" + "seanime/internal/torrents/torrent" + "seanime/internal/torrentstream" + "seanime/internal/user" + + "github.com/cli/browser" + "github.com/rs/zerolog" +) + +// initModulesOnce will initialize modules that need to persist. +// This function is called once after the App instance is created. +// The settings of these modules will be set/refreshed in InitOrRefreshModules. +func (a *App) initModulesOnce() { + + a.LocalManager.SetRefreshAnilistCollectionsFunc(func() { + _, _ = a.RefreshAnimeCollection() + _, _ = a.RefreshMangaCollection() + }) + + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + OnRefreshAnilistAnimeCollection: func() { + _, _ = a.RefreshAnimeCollection() + }, + OnRefreshAnilistMangaCollection: func() { + _, _ = a.RefreshMangaCollection() + }, + }) + + // +---------------------+ + // | Discord RPC | + // +---------------------+ + + a.DiscordPresence = discordrpc_presence.New(nil, a.Logger) + a.AddCleanupFunction(func() { + a.DiscordPresence.Close() + }) + + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + DiscordPresence: a.DiscordPresence, + }) + + // +---------------------+ + // | Filler | + // +---------------------+ + + a.FillerManager = fillermanager.New(&fillermanager.NewFillerManagerOptions{ + DB: a.Database, + Logger: a.Logger, + }) + + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + FillerManager: a.FillerManager, + }) + + // +---------------------+ + // | Continuity | + // +---------------------+ + + a.ContinuityManager = continuity.NewManager(&continuity.NewManagerOptions{ + FileCacher: a.FileCacher, + Logger: a.Logger, + Database: a.Database, + }) + + // +---------------------+ + // | Playback Manager | + // +---------------------+ + + // Playback Manager + a.PlaybackManager = playbackmanager.New(&playbackmanager.NewPlaybackManagerOptions{ + Logger: a.Logger, + WSEventManager: a.WSEventManager, + Platform: a.AnilistPlatform, + MetadataProvider: a.MetadataProvider, + Database: a.Database, + DiscordPresence: a.DiscordPresence, + IsOffline: a.IsOffline(), + ContinuityManager: a.ContinuityManager, + RefreshAnimeCollectionFunc: func() { + _, _ = a.RefreshAnimeCollection() + }, + }) + + // +---------------------+ + // | Torrent Repository | + // +---------------------+ + + a.TorrentRepository = torrent.NewRepository(&torrent.NewRepositoryOptions{ + Logger: a.Logger, + MetadataProvider: a.MetadataProvider, + }) + + // +---------------------+ + // | Manga Downloader | + // +---------------------+ + + a.MangaDownloader = manga.NewDownloader(&manga.NewDownloaderOptions{ + Database: a.Database, + Logger: a.Logger, + WSEventManager: a.WSEventManager, + DownloadDir: a.Config.Manga.DownloadDir, + Repository: a.MangaRepository, + IsOffline: a.IsOffline(), + }) + + a.MangaDownloader.Start() + + // +---------------------+ + // | Media Stream | + // +---------------------+ + + a.MediastreamRepository = mediastream.NewRepository(&mediastream.NewRepositoryOptions{ + Logger: a.Logger, + WSEventManager: a.WSEventManager, + FileCacher: a.FileCacher, + }) + + a.AddCleanupFunction(func() { + a.MediastreamRepository.OnCleanup() + }) + + // +---------------------+ + // | Native Player | + // +---------------------+ + + a.NativePlayer = nativeplayer.New(nativeplayer.NewNativePlayerOptions{ + WsEventManager: a.WSEventManager, + Logger: a.Logger, + }) + + // +---------------------+ + // | Direct Stream | + // +---------------------+ + + a.DirectStreamManager = directstream.NewManager(directstream.NewManagerOptions{ + Logger: a.Logger, + WSEventManager: a.WSEventManager, + ContinuityManager: a.ContinuityManager, + MetadataProvider: a.MetadataProvider, + DiscordPresence: a.DiscordPresence, + Platform: a.AnilistPlatform, + RefreshAnimeCollectionFunc: func() { + _, _ = a.RefreshAnimeCollection() + }, + IsOffline: a.IsOffline(), + NativePlayer: a.NativePlayer, + }) + + // +---------------------+ + // | Torrent Stream | + // +---------------------+ + + a.TorrentstreamRepository = torrentstream.NewRepository(&torrentstream.NewRepositoryOptions{ + Logger: a.Logger, + BaseAnimeCache: anilist.NewBaseAnimeCache(), + CompleteAnimeCache: anilist.NewCompleteAnimeCache(), + MetadataProvider: a.MetadataProvider, + TorrentRepository: a.TorrentRepository, + Platform: a.AnilistPlatform, + PlaybackManager: a.PlaybackManager, + WSEventManager: a.WSEventManager, + Database: a.Database, + DirectStreamManager: a.DirectStreamManager, + NativePlayer: a.NativePlayer, + }) + + // +---------------------+ + // | Debrid Client Repo | + // +---------------------+ + + a.DebridClientRepository = debrid_client.NewRepository(&debrid_client.NewRepositoryOptions{ + Logger: a.Logger, + WSEventManager: a.WSEventManager, + Database: a.Database, + MetadataProvider: a.MetadataProvider, + Platform: a.AnilistPlatform, + PlaybackManager: a.PlaybackManager, + TorrentRepository: a.TorrentRepository, + DirectStreamManager: a.DirectStreamManager, + }) + + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + PlaybackManager: a.PlaybackManager, + MangaRepository: a.MangaRepository, + }) + + // +---------------------+ + // | Auto Downloader | + // +---------------------+ + + a.AutoDownloader = autodownloader.New(&autodownloader.NewAutoDownloaderOptions{ + Logger: a.Logger, + TorrentClientRepository: a.TorrentClientRepository, + TorrentRepository: a.TorrentRepository, + Database: a.Database, + WSEventManager: a.WSEventManager, + MetadataProvider: a.MetadataProvider, + DebridClientRepository: a.DebridClientRepository, + IsOffline: a.IsOffline(), + }) + + // This is run in a goroutine + a.AutoDownloader.Start() + + // +---------------------+ + // | Auto Scanner | + // +---------------------+ + + a.AutoScanner = autoscanner.New(&autoscanner.NewAutoScannerOptions{ + Database: a.Database, + Platform: a.AnilistPlatform, + Logger: a.Logger, + WSEventManager: a.WSEventManager, + Enabled: false, // Will be set in InitOrRefreshModules + AutoDownloader: a.AutoDownloader, + MetadataProvider: a.MetadataProvider, + LogsDir: a.Config.Logs.Dir, + }) + + // This is run in a goroutine + a.AutoScanner.Start() + + // +---------------------+ + // | Nakama | + // +---------------------+ + + a.NakamaManager = nakama.NewManager(&nakama.NewManagerOptions{ + Logger: a.Logger, + WSEventManager: a.WSEventManager, + PlaybackManager: a.PlaybackManager, + TorrentstreamRepository: a.TorrentstreamRepository, + DebridClientRepository: a.DebridClientRepository, + Platform: a.AnilistPlatform, + ServerHost: a.Config.Server.Host, + ServerPort: a.Config.Server.Port, + }) + +} + +// HandleNewDatabaseEntries initializes essential database collections. +// It creates an empty local files collection if one does not already exist. +func HandleNewDatabaseEntries(database *db.Database, logger *zerolog.Logger) { + + // Create initial empty local files collection if none exists + if _, _, err := db_bridge.GetLocalFiles(database); err != nil { + _, err := db_bridge.InsertLocalFiles(database, make([]*anime.LocalFile, 0)) + if err != nil { + logger.Fatal().Err(err).Msgf("app: Failed to initialize local files in the database") + } + } + +} + +// InitOrRefreshModules will initialize or refresh modules that depend on settings. +// This function is called: +// - After the App instance is created +// - After settings are updated. +func (a *App) InitOrRefreshModules() { + a.moduleMu.Lock() + defer a.moduleMu.Unlock() + + a.Logger.Debug().Msgf("app: Refreshing modules") + + // Stop watching if already watching + if a.Watcher != nil { + a.Watcher.StopWatching() + } + + // If Discord presence is already initialized, close it + if a.DiscordPresence != nil { + a.DiscordPresence.Close() + } + + // Get settings from database + settings, err := a.Database.GetSettings() + if err != nil || settings == nil { + a.Logger.Warn().Msg("app: Did not initialize modules, no settings found") + return + } + + a.Settings = settings // Store settings instance in app + if settings.Library != nil { + a.LibraryDir = settings.GetLibrary().LibraryPath + } + + // +---------------------+ + // | Module settings | + // +---------------------+ + // Refresh settings of modules that were initialized in initModulesOnce + + notifier.GlobalNotifier.SetSettings(a.Config.Data.AppDataDir, a.Settings.GetNotifications(), a.Logger) + + // Refresh updater settings + if settings.Library != nil { + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + AnimeLibraryPaths: a.Database.AllLibraryPathsFromSettings(settings), + }) + + if a.Updater != nil { + a.Updater.SetEnabled(!settings.Library.DisableUpdateCheck) + } + + // Refresh auto scanner settings + if a.AutoScanner != nil { + a.AutoScanner.SetSettings(*settings.Library) + } + + // Torrent Repository + a.TorrentRepository.SetSettings(&torrent.RepositorySettings{ + DefaultAnimeProvider: settings.Library.TorrentProvider, + }) + } + + if settings.MediaPlayer != nil { + a.MediaPlayer.VLC = &vlc.VLC{ + Host: settings.MediaPlayer.Host, + Port: settings.MediaPlayer.VlcPort, + Password: settings.MediaPlayer.VlcPassword, + Path: settings.MediaPlayer.VlcPath, + Logger: a.Logger, + } + a.MediaPlayer.MpcHc = &mpchc.MpcHc{ + Host: settings.MediaPlayer.Host, + Port: settings.MediaPlayer.MpcPort, + Path: settings.MediaPlayer.MpcPath, + Logger: a.Logger, + } + a.MediaPlayer.Mpv = mpv.New(a.Logger, settings.MediaPlayer.MpvSocket, settings.MediaPlayer.MpvPath, settings.MediaPlayer.MpvArgs) + a.MediaPlayer.Iina = iina.New(a.Logger, settings.MediaPlayer.IinaSocket, settings.MediaPlayer.IinaPath, settings.MediaPlayer.IinaArgs) + + // Set media player repository + a.MediaPlayerRepository = mediaplayer.NewRepository(&mediaplayer.NewRepositoryOptions{ + Logger: a.Logger, + Default: settings.MediaPlayer.Default, + VLC: a.MediaPlayer.VLC, + MpcHc: a.MediaPlayer.MpcHc, + Mpv: a.MediaPlayer.Mpv, // Socket + Iina: a.MediaPlayer.Iina, + WSEventManager: a.WSEventManager, + ContinuityManager: a.ContinuityManager, + }) + + a.PlaybackManager.SetMediaPlayerRepository(a.MediaPlayerRepository) + a.PlaybackManager.SetSettings(&playbackmanager.Settings{ + AutoPlayNextEpisode: a.Settings.GetLibrary().AutoPlayNextEpisode, + }) + + a.DirectStreamManager.SetSettings(&directstream.Settings{ + AutoPlayNextEpisode: a.Settings.GetLibrary().AutoPlayNextEpisode, + AutoUpdateProgress: a.Settings.GetLibrary().AutoUpdateProgress, + }) + + a.TorrentstreamRepository.SetMediaPlayerRepository(a.MediaPlayerRepository) + + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + MediaPlayerRepository: a.MediaPlayerRepository, + }) + } else { + a.Logger.Warn().Msg("app: Did not initialize media player module, no settings found") + } + + // +---------------------+ + // | Torrents | + // +---------------------+ + + if settings.Torrent != nil { + // Init qBittorrent + qbit := qbittorrent.NewClient(&qbittorrent.NewClientOptions{ + Logger: a.Logger, + Username: settings.Torrent.QBittorrentUsername, + Password: settings.Torrent.QBittorrentPassword, + Port: settings.Torrent.QBittorrentPort, + Host: settings.Torrent.QBittorrentHost, + Path: settings.Torrent.QBittorrentPath, + Tags: settings.Torrent.QBittorrentTags, + }) + // Login to qBittorrent + go func() { + if settings.Torrent.Default == "qbittorrent" { + err = qbit.Login() + if err != nil { + a.Logger.Error().Err(err).Msg("app: Failed to login to qBittorrent") + } else { + a.Logger.Info().Msg("app: Logged in to qBittorrent") + } + } + }() + // Init Transmission + trans, err := transmission.New(&transmission.NewTransmissionOptions{ + Logger: a.Logger, + Username: settings.Torrent.TransmissionUsername, + Password: settings.Torrent.TransmissionPassword, + Port: settings.Torrent.TransmissionPort, + Host: settings.Torrent.TransmissionHost, + Path: settings.Torrent.TransmissionPath, + }) + if err != nil && settings.Torrent.TransmissionUsername != "" && settings.Torrent.TransmissionPassword != "" { // Only log error if username and password are set + a.Logger.Error().Err(err).Msg("app: Failed to initialize transmission client") + } + + // Shutdown torrent client first + if a.TorrentClientRepository != nil { + a.TorrentClientRepository.Shutdown() + } + + // Torrent Client Repository + a.TorrentClientRepository = torrent_client.NewRepository(&torrent_client.NewRepositoryOptions{ + Logger: a.Logger, + QbittorrentClient: qbit, + Transmission: trans, + TorrentRepository: a.TorrentRepository, + Provider: settings.Torrent.Default, + MetadataProvider: a.MetadataProvider, + }) + + a.TorrentClientRepository.InitActiveTorrentCount(settings.Torrent.ShowActiveTorrentCount, a.WSEventManager) + + // Set AutoDownloader qBittorrent client + a.AutoDownloader.SetTorrentClientRepository(a.TorrentClientRepository) + + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + TorrentClientRepository: a.TorrentClientRepository, + AutoDownloader: a.AutoDownloader, + }) + } else { + a.Logger.Warn().Msg("app: Did not initialize torrent client module, no settings found") + } + + // +---------------------+ + // | AutoDownloader | + // +---------------------+ + + // Update Auto Downloader - This runs in a goroutine + if settings.AutoDownloader != nil { + a.AutoDownloader.SetSettings(settings.AutoDownloader, settings.Library.TorrentProvider) + } + + // +---------------------+ + // | Library Watcher | + // +---------------------+ + + // Initialize library watcher + if settings.Library != nil && len(settings.Library.LibraryPath) > 0 { + go func() { + a.initLibraryWatcher(settings.Library.GetLibraryPaths()) + }() + } + + // +---------------------+ + // | Discord | + // +---------------------+ + + if settings.Discord != nil && a.DiscordPresence != nil { + a.DiscordPresence.SetSettings(settings.Discord) + } + + // +---------------------+ + // | Continuity | + // +---------------------+ + + if settings.Library != nil { + a.ContinuityManager.SetSettings(&continuity.Settings{ + WatchContinuityEnabled: settings.Library.EnableWatchContinuity, + }) + } + + if settings.Manga != nil { + a.MangaRepository.SetSettings(settings) + } + + // +---------------------+ + // | Nakama | + // +---------------------+ + + if settings.Nakama != nil { + a.NakamaManager.SetSettings(settings.Nakama) + } + + runtime.GC() + + a.Logger.Info().Msg("app: Refreshed modules") + +} + +// InitOrRefreshMediastreamSettings will initialize or refresh the mediastream settings. +// It is called after the App instance is created and after settings are updated. +func (a *App) InitOrRefreshMediastreamSettings() { + + var settings *models.MediastreamSettings + var found bool + settings, found = a.Database.GetMediastreamSettings() + if !found { + + var err error + settings, err = a.Database.UpsertMediastreamSettings(&models.MediastreamSettings{ + BaseModel: models.BaseModel{ + ID: 1, + }, + TranscodeEnabled: false, + TranscodeHwAccel: "cpu", + TranscodePreset: "fast", + PreTranscodeEnabled: false, + }) + if err != nil { + a.Logger.Error().Err(err).Msg("app: Failed to initialize mediastream module") + return + } + } + + a.MediastreamRepository.InitializeModules(settings, a.Config.Cache.Dir, a.Config.Cache.TranscodeDir) + + // Cleanup cache + go func() { + if settings.TranscodeEnabled { + // If transcoding is enabled, trim files + _ = a.FileCacher.TrimMediastreamVideoFiles() + } else { + // If transcoding is disabled, clear all files + _ = a.FileCacher.ClearMediastreamVideoFiles() + } + }() + + a.SecondarySettings.Mediastream = settings +} + +// InitOrRefreshTorrentstreamSettings will initialize or refresh the mediastream settings. +// It is called after the App instance is created and after settings are updated. +func (a *App) InitOrRefreshTorrentstreamSettings() { + + var settings *models.TorrentstreamSettings + var found bool + settings, found = a.Database.GetTorrentstreamSettings() + if !found { + + var err error + settings, err = a.Database.UpsertTorrentstreamSettings(&models.TorrentstreamSettings{ + BaseModel: models.BaseModel{ + ID: 1, + }, + Enabled: false, + AutoSelect: true, + PreferredResolution: "", + DisableIPV6: false, + DownloadDir: "", + AddToLibrary: false, + TorrentClientHost: "", + TorrentClientPort: 43213, + StreamingServerHost: "0.0.0.0", + StreamingServerPort: 43214, + IncludeInLibrary: false, + StreamUrlAddress: "", + SlowSeeding: false, + }) + if err != nil { + a.Logger.Error().Err(err).Msg("app: Failed to initialize mediastream module") + return + } + } + + err := a.TorrentstreamRepository.InitModules(settings, a.Config.Server.Host, a.Config.Server.Port) + if err != nil && settings.Enabled { + a.Logger.Error().Err(err).Msg("app: Failed to initialize Torrent streaming module") + //_, _ = a.Database.UpsertTorrentstreamSettings(&models.TorrentstreamSettings{ + // BaseModel: models.BaseModel{ + // ID: 1, + // }, + // Enabled: false, + //}) + } + + a.Cleanups = append(a.Cleanups, func() { + a.TorrentstreamRepository.Shutdown() + }) + + // Set torrent streaming settings in secondary settings + // so the client can use them + a.SecondarySettings.Torrentstream = settings +} + +func (a *App) InitOrRefreshDebridSettings() { + + settings, found := a.Database.GetDebridSettings() + if !found { + + var err error + settings, err = a.Database.UpsertDebridSettings(&models.DebridSettings{ + BaseModel: models.BaseModel{ + ID: 1, + }, + Enabled: false, + Provider: "", + ApiKey: "", + IncludeDebridStreamInLibrary: false, + StreamAutoSelect: false, + StreamPreferredResolution: "", + }) + if err != nil { + a.Logger.Error().Err(err).Msg("app: Failed to initialize debrid module") + return + } + } + + a.SecondarySettings.Debrid = settings + + err := a.DebridClientRepository.InitializeProvider(settings) + if err != nil { + a.Logger.Error().Err(err).Msg("app: Failed to initialize debrid provider") + return + } +} + +// InitOrRefreshAnilistData will initialize the Anilist anime collection and the account. +// This function should be called after App.Database is initialized and after settings are updated. +func (a *App) InitOrRefreshAnilistData() { + a.Logger.Debug().Msg("app: Fetching Anilist data") + + var currUser *user.User + acc, err := a.Database.GetAccount() + if err != nil || acc.Username == "" { + a.ServerReady = true + currUser = user.NewSimulatedUser() // Create a simulated user if no account is found + } else { + currUser, err = user.NewUser(acc) + if err != nil { + a.Logger.Error().Err(err).Msg("app: Failed to create user from account") + return + } + } + + a.user = currUser + + // Set username to Anilist platform + a.AnilistPlatform.SetUsername(currUser.Viewer.Name) + + a.Logger.Info().Msg("app: Authenticated to AniList") + + go func() { + _, err = a.RefreshAnimeCollection() + if err != nil { + a.Logger.Error().Err(err).Msg("app: Failed to fetch Anilist anime collection") + } + + a.ServerReady = true + a.WSEventManager.SendEvent(events.ServerReady, nil) + + _, err = a.RefreshMangaCollection() + if err != nil { + a.Logger.Error().Err(err).Msg("app: Failed to fetch Anilist manga collection") + } + }() + + go func(username string) { + a.DiscordPresence.SetUsername(username) + }(currUser.Viewer.Name) + + a.Logger.Info().Msg("app: Fetched Anilist data") +} + +func (a *App) performActionsOnce() { + + go func() { + if a.Settings == nil || a.Settings.Library == nil { + return + } + + if a.Settings.GetLibrary().OpenWebURLOnStart { + // Open the web URL + err := browser.OpenURL(a.Config.GetServerURI("127.0.0.1")) + if err != nil { + a.Logger.Warn().Err(err).Msg("app: Failed to open web URL, please open it manually in your browser") + } else { + a.Logger.Info().Msg("app: Opened web URL") + } + } + + if a.Settings.GetLibrary().RefreshLibraryOnStart { + go func() { + a.Logger.Debug().Msg("app: Refreshing library") + a.AutoScanner.RunNow() + a.Logger.Info().Msg("app: Refreshed library") + }() + } + + if a.Settings.GetLibrary().OpenTorrentClientOnStart && a.TorrentClientRepository != nil { + // Start the torrent client + ok := a.TorrentClientRepository.Start() + if !ok { + a.Logger.Warn().Msg("app: Failed to open torrent client") + } else { + a.Logger.Info().Msg("app: Started torrent client") + } + + } + }() + +} diff --git a/seanime-2.9.10/internal/core/offline.go b/seanime-2.9.10/internal/core/offline.go new file mode 100644 index 0000000..fd70c11 --- /dev/null +++ b/seanime-2.9.10/internal/core/offline.go @@ -0,0 +1,39 @@ +package core + +import ( + "seanime/internal/api/metadata" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/platforms/offline_platform" + + "github.com/spf13/viper" +) + +// SetOfflineMode changes the offline mode. +// It updates the config and active AniList platform. +func (a *App) SetOfflineMode(enabled bool) { + // Update the config + a.Config.Server.Offline = enabled + viper.Set("server.offline", enabled) + err := viper.WriteConfig() + if err != nil { + a.Logger.Err(err).Msg("app: Failed to write config after setting offline mode") + } + a.Logger.Info().Bool("enabled", enabled).Msg("app: Offline mode set") + a.isOffline = &enabled + + // Update the platform and metadata provider + if enabled { + a.AnilistPlatform, _ = offline_platform.NewOfflinePlatform(a.LocalManager, a.AnilistClient, a.Logger) + a.MetadataProvider = a.LocalManager.GetOfflineMetadataProvider() + } else { + // DEVNOTE: We don't handle local platform since the feature doesn't allow offline mode + a.AnilistPlatform = anilist_platform.NewAnilistPlatform(a.AnilistClient, a.Logger) + a.MetadataProvider = metadata.NewProvider(&metadata.NewProviderImplOptions{ + Logger: a.Logger, + FileCacher: a.FileCacher, + }) + a.InitOrRefreshAnilistData() + } + + a.InitOrRefreshModules() +} diff --git a/seanime-2.9.10/internal/core/tui.go b/seanime-2.9.10/internal/core/tui.go new file mode 100644 index 0000000..8f4297a --- /dev/null +++ b/seanime-2.9.10/internal/core/tui.go @@ -0,0 +1,122 @@ +package core + +import ( + "fmt" + "os" + "seanime/internal/constants" + "strings" + + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" +) + +func PrintHeader() { + // Get terminal width + physicalWidth, _, _ := term.GetSize(int(os.Stdout.Fd())) + + // Color scheme + // primary := lipgloss.Color("#7B61FF") + // secondary := lipgloss.Color("#5243CB") + // highlight := lipgloss.Color("#14F9D5") + // versionBgColor := lipgloss.Color("#8A2BE2") + subtle := lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} + + // Base styles + docStyle := lipgloss.NewStyle().Padding(1, 2) + if physicalWidth > 0 { + docStyle = docStyle.MaxWidth(physicalWidth) + } + + // Build the header + doc := strings.Builder{} + + // Logo with gradient effect + logoStyle := lipgloss.NewStyle().Bold(true) + logoLines := strings.Split(asciiLogo(), "\n") + + // Create a gradient effect for the logo + gradientColors := []string{"#9370DB", "#8A2BE2", "#7B68EE", "#6A5ACD", "#5243CB"} + for i, line := range logoLines { + colorIdx := i % len(gradientColors) + coloredLine := logoStyle.Foreground(lipgloss.Color(gradientColors[colorIdx])).Render(line) + doc.WriteString(coloredLine + "\n") + } + + // App name and version with box + titleBox := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(subtle). + Foreground(lipgloss.Color("#FFF7DB")). + // Background(secondary). + Padding(0, 1). + Bold(true). + Render("Seanime") + + versionBox := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(subtle). + Foreground(lipgloss.Color("#ed4760")). + // Background(versionBgColor). + Padding(0, 1). + Bold(true). + Render(constants.Version) + + // Version name with different style + versionName := lipgloss.NewStyle(). + Italic(true). + Border(lipgloss.NormalBorder()). + BorderForeground(subtle). + Foreground(lipgloss.Color("#FFF7DB")). + // Background(versionBgColor). + Padding(0, 1). + Render(constants.VersionName) + + // Combine title elements + titleRow := lipgloss.JoinHorizontal(lipgloss.Center, titleBox, versionBox, versionName) + + // Add a decorative line + // lineWidth := min(80, physicalWidth-4) + // line := lipgloss.NewStyle(). + // Foreground(subtle). + // Render(strings.Repeat("─", lineWidth)) + + // Put it all together + doc.WriteString("\n" + + lipgloss.NewStyle().Align(lipgloss.Center).Render(titleRow)) + + // Print the result + fmt.Println(docStyle.Render(doc.String())) +} + +// func asciiLogo() string { +// return `⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⣿⠀⠀⠀⢠⣾⣧⣤⡖⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⢀⣼⠋⠀⠉⠀⢄⣸⣿⣿⣿⣿⣿⣥⡤⢶⣿⣦⣀⡀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⡆⠀⠀⠀⣙⣛⣿⣿⣿⣿⡏⠀⠀⣀⣿⣿⣿⡟ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠙⠻⠷⣦⣤⣤⣬⣽⣿⣿⣿⣿⣿⣿⣿⣟⠛⠿⠋⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠋⣿⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⡆⠀⠀ +// ⠀⠀⠀⠀⣠⣶⣶⣶⣿⣦⡀⠘⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋⠈⢹⡏⠁⠀⠀ +// ⠀⠀⠀⢀⣿⡏⠉⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡆⠀⢀⣿⡇⠀⠀⠀ +// ⠀⠀⠀⢸⣿⠀⠀⠀⠀⠀⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡘⣿⣿⣃⠀⠀⠀ +// ⣴⣷⣀⣸⣿⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿⠹⣿⣯⣤⣾⠏⠉⠉⠉⠙⠢⠀ +// ⠈⠙⢿⣿⡟⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣄⠛⠉⢩⣷⣴⡆⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠋⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⣿⣿⣿⣀⡠⠋⠈⢿⣇⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⠿⠛⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀` +// } + +func asciiLogo() string { + return `⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⣴⡇⠀⠀⠀ +⠀⢸⣿⣿⣶⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣴⣶⣿⣿⣿⣿⣿⡇⠀⠀⠀ +⠀⠘⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⠀⠀⠀ +⠀⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⠀⠀ +⠀⠀⠀⠘⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠏⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠉⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⡻⣿⣿⣿⠟⠋⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⣿⣿⣿⣿⣿⡌⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⢀⣠⣤⣴⣶⣶⣶⣦⣤⣤⣄⣉⡉⠛⠷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⢀⣴⣾⣿⣿⣿⣿⡿⠿⠿⠿⣿⣿⣿⣿⣿⣿⣶⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀ +⠀⠀ ⠉⠉⠀⠀⠉⠉⠀⠀ ⠉ ⠉⠉⠉⠉⠉⠉⠉⠛⠛⠛⠲⠦⠄` +} diff --git a/seanime-2.9.10/internal/core/watcher.go b/seanime-2.9.10/internal/core/watcher.go new file mode 100644 index 0000000..1af2fba --- /dev/null +++ b/seanime-2.9.10/internal/core/watcher.go @@ -0,0 +1,59 @@ +package core + +import ( + "seanime/internal/library/scanner" + "seanime/internal/util" + "sync" +) + +// initLibraryWatcher will initialize the library watcher. +// - Used by AutoScanner +func (a *App) initLibraryWatcher(paths []string) { + // Create a new watcher + watcher, err := scanner.NewWatcher(&scanner.NewWatcherOptions{ + Logger: a.Logger, + WSEventManager: a.WSEventManager, + }) + if err != nil { + a.Logger.Error().Err(err).Msg("app: Failed to initialize watcher") + return + } + + // Initialize library file watcher + err = watcher.InitLibraryFileWatcher(&scanner.WatchLibraryFilesOptions{ + LibraryPaths: paths, + }) + if err != nil { + a.Logger.Error().Err(err).Msg("app: Failed to watch library files") + return + } + + var dirSize uint64 = 0 + mu := sync.Mutex{} + wg := sync.WaitGroup{} + for _, path := range paths { + wg.Add(1) + go func(path string) { + defer wg.Done() + ds, _ := util.DirSize(path) + mu.Lock() + dirSize += ds + mu.Unlock() + }(path) + } + wg.Wait() + a.TotalLibrarySize = dirSize + + a.Logger.Info().Msgf("watcher: Library size: %s", util.Bytes(dirSize)) + + // Set the watcher + a.Watcher = watcher + + // Start watching + a.Watcher.StartWatching( + func() { + // Notify the auto scanner when a file action occurs + a.AutoScanner.Notify() + }) + +} diff --git a/seanime-2.9.10/internal/cron/cron.go b/seanime-2.9.10/internal/cron/cron.go new file mode 100644 index 0000000..39380a7 --- /dev/null +++ b/seanime-2.9.10/internal/cron/cron.go @@ -0,0 +1,79 @@ +package cron + +import ( + "seanime/internal/core" + "time" +) + +type JobCtx struct { + App *core.App +} + +func RunJobs(app *core.App) { + + // Run the jobs only if the server is online + ctx := &JobCtx{ + App: app, + } + + refreshAnilistTicker := time.NewTicker(10 * time.Minute) + refreshLocalDataTicker := time.NewTicker(30 * time.Minute) + refetchReleaseTicker := time.NewTicker(1 * time.Hour) + refetchAnnouncementsTicker := time.NewTicker(10 * time.Minute) + + go func() { + for { + select { + case <-refreshAnilistTicker.C: + if *app.IsOffline() { + continue + } + RefreshAnilistDataJob(ctx) + if app.LocalManager != nil && + !app.GetUser().IsSimulated && + app.Settings != nil && + app.Settings.Library != nil && + app.Settings.Library.AutoSyncToLocalAccount { + _ = app.LocalManager.SynchronizeAnilistToSimulatedCollection() + } + } + } + }() + + go func() { + for { + select { + case <-refreshLocalDataTicker.C: + if *app.IsOffline() { + continue + } + SyncLocalDataJob(ctx) + } + } + }() + + go func() { + for { + select { + case <-refetchReleaseTicker.C: + if *app.IsOffline() { + continue + } + app.Updater.ShouldRefetchReleases() + } + } + }() + + go func() { + for { + select { + case <-refetchAnnouncementsTicker.C: + if *app.IsOffline() { + continue + } + app.Updater.FetchAnnouncements() + } + } + }() + +} diff --git a/seanime-2.9.10/internal/cron/refresh_anilist.go b/seanime-2.9.10/internal/cron/refresh_anilist.go new file mode 100644 index 0000000..8c234e9 --- /dev/null +++ b/seanime-2.9.10/internal/cron/refresh_anilist.go @@ -0,0 +1,50 @@ +package cron + +import ( + "seanime/internal/events" +) + +func RefreshAnilistDataJob(c *JobCtx) { + defer func() { + if r := recover(); r != nil { + } + }() + + if c.App.Settings == nil || c.App.Settings.Library == nil { + return + } + + // Refresh the Anilist Collection + animeCollection, _ := c.App.RefreshAnimeCollection() + + if c.App.Settings.GetLibrary().EnableManga { + mangaCollection, _ := c.App.RefreshMangaCollection() + c.App.WSEventManager.SendEvent(events.RefreshedAnilistMangaCollection, mangaCollection) + } + + c.App.WSEventManager.SendEvent(events.RefreshedAnilistAnimeCollection, animeCollection) +} + +func SyncLocalDataJob(c *JobCtx) { + defer func() { + if r := recover(); r != nil { + } + }() + + if c.App.Settings == nil || c.App.Settings.Library == nil { + return + } + + // Only synchronize local data if the user is not simulated + if c.App.Settings.Library.AutoSyncOfflineLocalData && !c.App.GetUser().IsSimulated { + c.App.LocalManager.SynchronizeLocal() + } + + // Only synchronize local data if the user is not simulated + if c.App.Settings.Library.AutoSaveCurrentMediaOffline && !c.App.GetUser().IsSimulated { + added, _ := c.App.LocalManager.AutoTrackCurrentMedia() + if added && c.App.Settings.Library.AutoSyncOfflineLocalData { + go c.App.LocalManager.SynchronizeLocal() + } + } +} diff --git a/seanime-2.9.10/internal/database/db/README.md b/seanime-2.9.10/internal/database/db/README.md new file mode 100644 index 0000000..93e9089 --- /dev/null +++ b/seanime-2.9.10/internal/database/db/README.md @@ -0,0 +1,7 @@ +# db + +Should only import `models` internal package. + +### 🚫 Do not + +- Do not define **models** here. diff --git a/seanime-2.9.10/internal/database/db/account.go b/seanime-2.9.10/internal/database/db/account.go new file mode 100644 index 0000000..0f6fd74 --- /dev/null +++ b/seanime-2.9.10/internal/database/db/account.go @@ -0,0 +1,59 @@ +package db + +import ( + "errors" + "seanime/internal/database/models" + + "gorm.io/gorm/clause" +) + +var accountCache *models.Account + +func (db *Database) UpsertAccount(acc *models.Account) (*models.Account, error) { + err := db.gormdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(acc).Error + + if err != nil { + db.Logger.Error().Err(err).Msg("Failed to save account in the database") + return nil, err + } + + if acc.Username != "" { + accountCache = acc + } else { + accountCache = nil + } + + return acc, nil +} + +func (db *Database) GetAccount() (*models.Account, error) { + + if accountCache != nil { + return accountCache, nil + } + + var acc models.Account + err := db.gormdb.Last(&acc).Error + if err != nil { + return nil, err + } + if acc.Username == "" || acc.Token == "" || acc.Viewer == nil { + return nil, errors.New("account not found") + } + + accountCache = &acc + + return &acc, err +} + +// GetAnilistToken retrieves the AniList token from the account or returns an empty string +func (db *Database) GetAnilistToken() string { + acc, err := db.GetAccount() + if err != nil { + return "" + } + return acc.Token +} diff --git a/seanime-2.9.10/internal/database/db/autodownloader_item.go b/seanime-2.9.10/internal/database/db/autodownloader_item.go new file mode 100644 index 0000000..6d1ce3b --- /dev/null +++ b/seanime-2.9.10/internal/database/db/autodownloader_item.go @@ -0,0 +1,57 @@ +package db + +import ( + "seanime/internal/database/models" +) + +func (db *Database) GetAutoDownloaderItems() ([]*models.AutoDownloaderItem, error) { + var res []*models.AutoDownloaderItem + err := db.gormdb.Find(&res).Error + if err != nil { + return nil, err + } + + return res, nil +} + +func (db *Database) GetAutoDownloaderItem(id uint) (*models.AutoDownloaderItem, error) { + var res models.AutoDownloaderItem + err := db.gormdb.First(&res, id).Error + if err != nil { + return nil, err + } + + return &res, nil +} + +func (db *Database) GetAutoDownloaderItemByMediaId(mId int) ([]*models.AutoDownloaderItem, error) { + var res []*models.AutoDownloaderItem + err := db.gormdb.Where("media_id = ?", mId).Find(&res).Error + if err != nil { + return nil, err + } + + return res, nil +} + +func (db *Database) InsertAutoDownloaderItem(item *models.AutoDownloaderItem) error { + err := db.gormdb.Create(item).Error + if err != nil { + return err + } + return nil +} + +func (db *Database) DeleteAutoDownloaderItem(id uint) error { + return db.gormdb.Delete(&models.AutoDownloaderItem{}, id).Error +} + +// DeleteDownloadedAutoDownloaderItems will delete all the downloaded queued items from the database. +func (db *Database) DeleteDownloadedAutoDownloaderItems() error { + return db.gormdb.Where("downloaded = ?", true).Delete(&models.AutoDownloaderItem{}).Error +} + +func (db *Database) UpdateAutoDownloaderItem(id uint, item *models.AutoDownloaderItem) error { + // Save the data + return db.gormdb.Model(&models.AutoDownloaderItem{}).Where("id = ?", id).Updates(item).Error +} diff --git a/seanime-2.9.10/internal/database/db/chapter_downloader_queue.go b/seanime-2.9.10/internal/database/db/chapter_downloader_queue.go new file mode 100644 index 0000000..1ba57f3 --- /dev/null +++ b/seanime-2.9.10/internal/database/db/chapter_downloader_queue.go @@ -0,0 +1,135 @@ +package db + +import ( + "errors" + "gorm.io/gorm" + "seanime/internal/database/models" +) + +func (db *Database) GetChapterDownloadQueue() ([]*models.ChapterDownloadQueueItem, error) { + var res []*models.ChapterDownloadQueueItem + err := db.gormdb.Find(&res).Error + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to get chapter download queue") + return nil, err + } + + return res, nil +} + +func (db *Database) GetNextChapterDownloadQueueItem() (*models.ChapterDownloadQueueItem, error) { + var res models.ChapterDownloadQueueItem + err := db.gormdb.Where("status = ?", "not_started").First(&res).Error + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + db.Logger.Error().Err(err).Msg("db: Failed to get next chapter download queue item") + } + return nil, nil + } + + return &res, nil +} + +func (db *Database) DequeueChapterDownloadQueueItem() (*models.ChapterDownloadQueueItem, error) { + // Pop the first item from the queue + var res models.ChapterDownloadQueueItem + err := db.gormdb.Where("status = ?", "downloading").First(&res).Error + if err != nil { + return nil, err + } + + err = db.gormdb.Delete(&res).Error + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to delete chapter download queue item") + return nil, err + } + + return &res, nil +} + +func (db *Database) InsertChapterDownloadQueueItem(item *models.ChapterDownloadQueueItem) error { + + // Check if the item already exists + var existingItem models.ChapterDownloadQueueItem + err := db.gormdb.Where("provider = ? AND media_id = ? AND chapter_id = ?", item.Provider, item.MediaID, item.ChapterID).First(&existingItem).Error + if err == nil { + db.Logger.Debug().Msg("db: Chapter download queue item already exists") + return errors.New("chapter is already in the download queue") + } + + if item.ChapterID == "" { + return errors.New("chapter ID is empty") + } + if item.Provider == "" { + return errors.New("provider is empty") + } + if item.MediaID == 0 { + return errors.New("media ID is empty") + } + if item.ChapterNumber == "" { + return errors.New("chapter number is empty") + } + + err = db.gormdb.Create(item).Error + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to insert chapter download queue item") + return err + } + return nil +} + +func (db *Database) UpdateChapterDownloadQueueItemStatus(provider string, mId int, chapterId string, status string) error { + err := db.gormdb.Model(&models.ChapterDownloadQueueItem{}). + Where("provider = ? AND media_id = ? AND chapter_id = ?", provider, mId, chapterId). + Update("status", status).Error + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to update chapter download queue item status") + return err + } + return nil +} + +func (db *Database) GetMediaQueuedChapters(mediaId int) ([]*models.ChapterDownloadQueueItem, error) { + var res []*models.ChapterDownloadQueueItem + err := db.gormdb.Where("media_id = ?", mediaId).Find(&res).Error + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to get media queued chapters") + return nil, err + } + + return res, nil +} + +func (db *Database) ClearAllChapterDownloadQueueItems() error { + err := db.gormdb. + Where("status = ? OR status = ? OR status = ?", "not_started", "downloading", "errored"). + Delete(&models.ChapterDownloadQueueItem{}). + Error + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to clear all chapter download queue items") + return err + } + return nil +} + +func (db *Database) ResetErroredChapterDownloadQueueItems() error { + err := db.gormdb.Model(&models.ChapterDownloadQueueItem{}). + Where("status = ?", "errored"). + Update("status", "not_started").Error + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to reset errored chapter download queue items") + return err + } + return nil +} + +func (db *Database) ResetDownloadingChapterDownloadQueueItems() error { + err := db.gormdb.Model(&models.ChapterDownloadQueueItem{}). + Where("status = ?", "downloading"). + Update("status", "not_started").Error + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to reset downloading chapter download queue items") + return err + } + return nil +} diff --git a/seanime-2.9.10/internal/database/db/db.go b/seanime-2.9.10/internal/database/db/db.go new file mode 100644 index 0000000..bab34b1 --- /dev/null +++ b/seanime-2.9.10/internal/database/db/db.go @@ -0,0 +1,102 @@ +package db + +import ( + "fmt" + "log" + "os" + "path/filepath" + "seanime/internal/database/models" + "time" + + "github.com/glebarez/sqlite" + "github.com/rs/zerolog" + "github.com/samber/mo" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +type Database struct { + gormdb *gorm.DB + Logger *zerolog.Logger + CurrMediaFillers mo.Option[map[int]*MediaFillerItem] +} + +func (db *Database) Gorm() *gorm.DB { + return db.gormdb +} + +func NewDatabase(appDataDir, dbName string, logger *zerolog.Logger) (*Database, error) { + + // Set the SQLite database path + var sqlitePath string + if os.Getenv("TEST_ENV") == "true" { + sqlitePath = ":memory:" + } else { + sqlitePath = filepath.Join(appDataDir, dbName+".db") + } + + // Connect to the SQLite database + db, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{ + Logger: gormlogger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + gormlogger.Config{ + SlowThreshold: time.Second, + LogLevel: gormlogger.Error, + IgnoreRecordNotFoundError: true, + ParameterizedQueries: false, + Colorful: true, + }, + ), + }) + if err != nil { + return nil, err + } + + // Migrate tables + err = migrateTables(db) + if err != nil { + logger.Fatal().Err(err).Msg("db: Failed to perform auto migration") + return nil, err + } + + logger.Info().Str("name", fmt.Sprintf("%s.db", dbName)).Msg("db: Database instantiated") + + return &Database{ + gormdb: db, + Logger: logger, + CurrMediaFillers: mo.None[map[int]*MediaFillerItem](), + }, nil +} + +// MigrateTables performs auto migration on the database +func migrateTables(db *gorm.DB) error { + err := db.AutoMigrate( + &models.LocalFiles{}, + &models.Settings{}, + &models.Account{}, + &models.Mal{}, + &models.ScanSummary{}, + &models.AutoDownloaderRule{}, + &models.AutoDownloaderItem{}, + &models.SilencedMediaEntry{}, + &models.Theme{}, + &models.PlaylistEntry{}, + &models.ChapterDownloadQueueItem{}, + &models.TorrentstreamSettings{}, + &models.TorrentstreamHistory{}, + &models.MediastreamSettings{}, + &models.MediaFiller{}, + &models.MangaMapping{}, + &models.OnlinestreamMapping{}, + &models.DebridSettings{}, + &models.DebridTorrentItem{}, + &models.PluginData{}, + //&models.MangaChapterContainer{}, + ) + if err != nil { + + return err + } + + return nil +} diff --git a/seanime-2.9.10/internal/database/db/debrid_torrent_item.go b/seanime-2.9.10/internal/database/db/debrid_torrent_item.go new file mode 100644 index 0000000..6220ea2 --- /dev/null +++ b/seanime-2.9.10/internal/database/db/debrid_torrent_item.go @@ -0,0 +1,56 @@ +package db + +import ( + "seanime/internal/database/models" +) + +func (db *Database) GetDebridTorrentItems() ([]*models.DebridTorrentItem, error) { + var res []*models.DebridTorrentItem + err := db.gormdb.Find(&res).Error + if err != nil { + return nil, err + } + + return res, nil +} + +func (db *Database) GetDebridTorrentItemByDbId(dbId uint) (*models.DebridTorrentItem, error) { + var res models.DebridTorrentItem + err := db.gormdb.First(&res, dbId).Error + if err != nil { + return nil, err + } + + return &res, nil +} + +func (db *Database) GetDebridTorrentItemByTorrentItemId(tId string) (*models.DebridTorrentItem, error) { + var res *models.DebridTorrentItem + err := db.gormdb.Where("torrent_item_id = ?", tId).First(&res).Error + if err != nil { + return nil, err + } + + return res, nil +} + +func (db *Database) InsertDebridTorrentItem(item *models.DebridTorrentItem) error { + err := db.gormdb.Create(item).Error + if err != nil { + return err + } + return nil +} + +func (db *Database) DeleteDebridTorrentItemByDbId(dbId uint) error { + return db.gormdb.Delete(&models.DebridTorrentItem{}, dbId).Error +} + +func (db *Database) DeleteDebridTorrentItemByTorrentItemId(tId string) error { + return db.gormdb.Where("torrent_item_id = ?", tId).Delete(&models.DebridTorrentItem{}).Error +} + +func (db *Database) UpdateDebridTorrentItemByDbId(dbId uint, item *models.DebridTorrentItem) error { + // Save the data + return db.gormdb.Model(&models.DebridTorrentItem{}).Where("id = ?", dbId).Updates(item).Error +} diff --git a/seanime-2.9.10/internal/database/db/localfiles.go b/seanime-2.9.10/internal/database/db/localfiles.go new file mode 100644 index 0000000..2b22bc8 --- /dev/null +++ b/seanime-2.9.10/internal/database/db/localfiles.go @@ -0,0 +1,51 @@ +package db + +import ( + "seanime/internal/database/models" + + "gorm.io/gorm/clause" +) + +// TrimLocalFileEntries will trim the local file entries if there are more than 10 entries. +// This is run in a goroutine. +func (db *Database) TrimLocalFileEntries() { + go func() { + var count int64 + err := db.gormdb.Model(&models.LocalFiles{}).Count(&count).Error + if err != nil { + db.Logger.Error().Err(err).Msg("database: Failed to count local file entries") + return + } + if count > 10 { + // Leave 5 entries + err = db.gormdb.Delete(&models.LocalFiles{}, "id IN (SELECT id FROM local_files ORDER BY id ASC LIMIT ?)", count-5).Error + if err != nil { + db.Logger.Error().Err(err).Msg("database: Failed to delete old local file entries") + return + } + } + }() +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (db *Database) UpsertLocalFiles(lfs *models.LocalFiles) (*models.LocalFiles, error) { + err := db.gormdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(lfs).Error + + if err != nil { + return nil, err + } + return lfs, nil +} + +func (db *Database) InsertLocalFiles(lfs *models.LocalFiles) (*models.LocalFiles, error) { + err := db.gormdb.Create(lfs).Error + + if err != nil { + return nil, err + } + return lfs, nil +} diff --git a/seanime-2.9.10/internal/database/db/mal.go b/seanime-2.9.10/internal/database/db/mal.go new file mode 100644 index 0000000..c4fe3e1 --- /dev/null +++ b/seanime-2.9.10/internal/database/db/mal.go @@ -0,0 +1,50 @@ +package db + +import ( + "errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "seanime/internal/database/models" +) + +func (db *Database) GetMalInfo() (*models.Mal, error) { + // Get the first entry + var res models.Mal + err := db.gormdb.First(&res, 1).Error + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("MAL not connected") + } else if err != nil { + return nil, err + } + return &res, nil +} + +func (db *Database) UpsertMalInfo(info *models.Mal) (*models.Mal, error) { + err := db.gormdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(info).Error + + if err != nil { + return nil, err + } + return info, nil +} + +func (db *Database) InsertMalInfo(info *models.Mal) (*models.Mal, error) { + err := db.gormdb.Create(info).Error + + if err != nil { + return nil, err + } + return info, nil +} + +func (db *Database) DeleteMalInfo() error { + err := db.gormdb.Delete(&models.Mal{}, 1).Error + + if err != nil { + return err + } + return nil +} diff --git a/seanime-2.9.10/internal/database/db/manga.go b/seanime-2.9.10/internal/database/db/manga.go new file mode 100644 index 0000000..9167021 --- /dev/null +++ b/seanime-2.9.10/internal/database/db/manga.go @@ -0,0 +1,102 @@ +package db + +import ( + "fmt" + "seanime/internal/database/models" + "seanime/internal/util/result" +) + +var mangaMappingCache = result.NewResultMap[string, *models.MangaMapping]() + +func formatMangaMappingCacheKey(provider string, mediaId int) string { + return fmt.Sprintf("%s$%d", provider, mediaId) +} + +func (db *Database) GetMangaMapping(provider string, mediaId int) (*models.MangaMapping, bool) { + + if res, ok := mangaMappingCache.Get(formatMangaMappingCacheKey(provider, mediaId)); ok { + return res, true + } + + var res models.MangaMapping + err := db.gormdb.Where("provider = ? AND media_id = ?", provider, mediaId).First(&res).Error + if err != nil { + return nil, false + } + + mangaMappingCache.Set(formatMangaMappingCacheKey(provider, mediaId), &res) + + return &res, true +} + +func (db *Database) InsertMangaMapping(provider string, mediaId int, mangaId string) error { + mapping := models.MangaMapping{ + Provider: provider, + MediaID: mediaId, + MangaID: mangaId, + } + + mangaMappingCache.Set(formatMangaMappingCacheKey(provider, mediaId), &mapping) + + return db.gormdb.Save(&mapping).Error +} + +func (db *Database) DeleteMangaMapping(provider string, mediaId int) error { + err := db.gormdb.Where("provider = ? AND media_id = ?", provider, mediaId).Delete(&models.MangaMapping{}).Error + if err != nil { + return err + } + + mangaMappingCache.Delete(formatMangaMappingCacheKey(provider, mediaId)) + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var mangaChapterContainerCache = result.NewResultMap[string, *models.MangaChapterContainer]() + +func formatMangaChapterContainerCacheKey(provider string, mediaId int, chapterId string) string { + return fmt.Sprintf("%s$%d$%s", provider, mediaId, chapterId) +} + +func (db *Database) GetMangaChapterContainer(provider string, mediaId int, chapterId string) (*models.MangaChapterContainer, bool) { + + if res, ok := mangaChapterContainerCache.Get(formatMangaChapterContainerCacheKey(provider, mediaId, chapterId)); ok { + return res, true + } + + var res models.MangaChapterContainer + err := db.gormdb.Where("provider = ? AND media_id = ? AND chapter_id = ?", provider, mediaId, chapterId).First(&res).Error + if err != nil { + return nil, false + } + + mangaChapterContainerCache.Set(formatMangaChapterContainerCacheKey(provider, mediaId, chapterId), &res) + + return &res, true +} + +func (db *Database) InsertMangaChapterContainer(provider string, mediaId int, chapterId string, chapterContainer []byte) error { + container := models.MangaChapterContainer{ + Provider: provider, + MediaID: mediaId, + ChapterID: chapterId, + Data: chapterContainer, + } + + mangaChapterContainerCache.Set(formatMangaChapterContainerCacheKey(provider, mediaId, chapterId), &container) + + return db.gormdb.Save(&container).Error +} + +func (db *Database) DeleteMangaChapterContainer(provider string, mediaId int, chapterId string) error { + err := db.gormdb.Where("provider = ? AND media_id = ? AND chapter_id = ?", provider, mediaId, chapterId).Delete(&models.MangaChapterContainer{}).Error + if err != nil { + return err + } + + mangaChapterContainerCache.Delete(formatMangaChapterContainerCacheKey(provider, mediaId, chapterId)) + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/seanime-2.9.10/internal/database/db/media_filler.go b/seanime-2.9.10/internal/database/db/media_filler.go new file mode 100644 index 0000000..c0109fc --- /dev/null +++ b/seanime-2.9.10/internal/database/db/media_filler.go @@ -0,0 +1,182 @@ +package db + +import ( + "github.com/goccy/go-json" + "github.com/samber/mo" + "seanime/internal/api/filler" + "seanime/internal/database/models" + "time" +) + +type MediaFillerItem struct { + DbId uint `json:"dbId"` + Provider string `json:"provider"` + Slug string `json:"slug"` + MediaId int `json:"mediaId"` + LastFetchedAt time.Time `json:"lastFetchedAt"` + FillerEpisodes []string `json:"fillerEpisodes"` +} + +// GetCachedMediaFillers will return all the media fillers (cache-first). +// If the cache is empty, it will fetch the media fillers from the database. +func (db *Database) GetCachedMediaFillers() (map[int]*MediaFillerItem, error) { + + if db.CurrMediaFillers.IsPresent() { + return db.CurrMediaFillers.MustGet(), nil + } + + var res []*models.MediaFiller + err := db.gormdb.Find(&res).Error + if err != nil { + return nil, err + } + + // Unmarshal the media fillers + mediaFillers := make(map[int]*MediaFillerItem) + for _, mf := range res { + + var fillerData filler.Data + if err := json.Unmarshal(mf.Data, &fillerData); err != nil { + return nil, err + } + + // Get the filler episodes + var fillerEpisodes []string + if fillerData.FillerEpisodes != nil || len(fillerData.FillerEpisodes) > 0 { + fillerEpisodes = fillerData.FillerEpisodes + } + + mediaFillers[mf.MediaID] = &MediaFillerItem{ + DbId: mf.ID, + Provider: mf.Provider, + MediaId: mf.MediaID, + Slug: mf.Slug, + LastFetchedAt: mf.LastFetchedAt, + FillerEpisodes: fillerEpisodes, + } + } + + // Cache the media fillers + db.CurrMediaFillers = mo.Some(mediaFillers) + + return db.CurrMediaFillers.MustGet(), nil +} + +func (db *Database) GetMediaFillerItem(mediaId int) (*MediaFillerItem, bool) { + + mediaFillers, err := db.GetCachedMediaFillers() + if err != nil { + return nil, false + } + + item, ok := mediaFillers[mediaId] + + return item, ok +} + +func (db *Database) InsertMediaFiller( + provider string, + mediaId int, + slug string, + lastFetchedAt time.Time, + fillerEpisodes []string, +) error { + + // Marshal the filler data + fillerData := filler.Data{ + FillerEpisodes: fillerEpisodes, + } + + fillerDataBytes, err := json.Marshal(fillerData) + if err != nil { + return err + } + + // Delete the existing media filler + _ = db.DeleteMediaFiller(mediaId) + + // Save the media filler + err = db.gormdb.Create(&models.MediaFiller{ + Provider: provider, + MediaID: mediaId, + Slug: slug, + LastFetchedAt: lastFetchedAt, + Data: fillerDataBytes, + }).Error + if err != nil { + return err + } + + // Update the cache + db.CurrMediaFillers = mo.None[map[int]*MediaFillerItem]() + + return nil +} + +// SaveCachedMediaFillerItems will save the cached media filler items in the database. +// Call this function after editing the cached media filler items. +func (db *Database) SaveCachedMediaFillerItems() error { + + if db.CurrMediaFillers.IsAbsent() { + return nil + } + + mediaFillers, err := db.GetCachedMediaFillers() + if err != nil { + return err + } + + for _, mf := range mediaFillers { + if len(mf.FillerEpisodes) == 0 { + continue + } + // Marshal the filler data + fillerData := filler.Data{ + FillerEpisodes: mf.FillerEpisodes, + } + + fillerDataBytes, err := json.Marshal(fillerData) + if err != nil { + return err + } + + // Save the media filler + err = db.gormdb.Model(&models.MediaFiller{}). + Where("id = ?", mf.DbId). + Updates(map[string]interface{}{ + "last_fetched_at": mf.LastFetchedAt, + "data": fillerDataBytes, + }).Error + if err != nil { + return err + } + } + + // Update the cache + db.CurrMediaFillers = mo.None[map[int]*MediaFillerItem]() + + return nil +} + +func (db *Database) DeleteMediaFiller(mediaId int) error { + + mediaFillers, err := db.GetCachedMediaFillers() + if err != nil { + return err + } + + item, ok := mediaFillers[mediaId] + if !ok { + return nil + } + + err = db.gormdb.Delete(&models.MediaFiller{}, item.DbId).Error + if err != nil { + return err + } + + // Update the cache + db.CurrMediaFillers = mo.None[map[int]*MediaFillerItem]() + + return nil +} diff --git a/seanime-2.9.10/internal/database/db/nakama.go b/seanime-2.9.10/internal/database/db/nakama.go new file mode 100644 index 0000000..b7ee5a7 --- /dev/null +++ b/seanime-2.9.10/internal/database/db/nakama.go @@ -0,0 +1,24 @@ +package db + +import ( + "seanime/internal/database/models" +) + +func (db *Database) UpsertNakamaSettings(nakamaSettings *models.NakamaSettings) (*models.NakamaSettings, error) { + + // Get current settings + currentSettings, err := db.GetSettings() + if err != nil { + return nil, err + } + + // Update the settings + *(currentSettings.Nakama) = *nakamaSettings + + _, err = db.UpsertSettings(currentSettings) + if err != nil { + return nil, err + } + + return nakamaSettings, nil +} diff --git a/seanime-2.9.10/internal/database/db/onlinestream.go b/seanime-2.9.10/internal/database/db/onlinestream.go new file mode 100644 index 0000000..8c6fcda --- /dev/null +++ b/seanime-2.9.10/internal/database/db/onlinestream.go @@ -0,0 +1,52 @@ +package db + +import ( + "fmt" + "seanime/internal/database/models" + "seanime/internal/util/result" +) + +var onlinestreamMappingCache = result.NewResultMap[string, *models.OnlinestreamMapping]() + +func formatOnlinestreamMappingCacheKey(provider string, mediaId int) string { + return fmt.Sprintf("%s$%d", provider, mediaId) +} + +func (db *Database) GetOnlinestreamMapping(provider string, mediaId int) (*models.OnlinestreamMapping, bool) { + + if res, ok := onlinestreamMappingCache.Get(formatOnlinestreamMappingCacheKey(provider, mediaId)); ok { + return res, true + } + + var res models.OnlinestreamMapping + err := db.gormdb.Where("provider = ? AND media_id = ?", provider, mediaId).First(&res).Error + if err != nil { + return nil, false + } + + onlinestreamMappingCache.Set(formatOnlinestreamMappingCacheKey(provider, mediaId), &res) + + return &res, true +} + +func (db *Database) InsertOnlinestreamMapping(provider string, mediaId int, animeId string) error { + mapping := models.OnlinestreamMapping{ + Provider: provider, + MediaID: mediaId, + AnimeID: animeId, + } + + onlinestreamMappingCache.Set(formatOnlinestreamMappingCacheKey(provider, mediaId), &mapping) + + return db.gormdb.Save(&mapping).Error +} + +func (db *Database) DeleteOnlinestreamMapping(provider string, mediaId int) error { + err := db.gormdb.Where("provider = ? AND media_id = ?", provider, mediaId).Delete(&models.OnlinestreamMapping{}).Error + if err != nil { + return err + } + + onlinestreamMappingCache.Delete(formatOnlinestreamMappingCacheKey(provider, mediaId)) + return nil +} diff --git a/seanime-2.9.10/internal/database/db/scan_summary.go b/seanime-2.9.10/internal/database/db/scan_summary.go new file mode 100644 index 0000000..1a60047 --- /dev/null +++ b/seanime-2.9.10/internal/database/db/scan_summary.go @@ -0,0 +1,24 @@ +package db + +import ( + "seanime/internal/database/models" +) + +func (db *Database) TrimScanSummaryEntries() { + go func() { + var count int64 + err := db.gormdb.Model(&models.ScanSummary{}).Count(&count).Error + if err != nil { + db.Logger.Error().Err(err).Msg("Failed to count scan summary entries") + return + } + if count > 10 { + // Leave 5 entries + err = db.gormdb.Delete(&models.ScanSummary{}, "id IN (SELECT id FROM scan_summaries ORDER BY id ASC LIMIT ?)", count-5).Error + if err != nil { + db.Logger.Error().Err(err).Msg("Failed to delete old scan summary entries") + return + } + } + }() +} diff --git a/seanime-2.9.10/internal/database/db/settings.go b/seanime-2.9.10/internal/database/db/settings.go new file mode 100644 index 0000000..cb1dbdc --- /dev/null +++ b/seanime-2.9.10/internal/database/db/settings.go @@ -0,0 +1,200 @@ +package db + +import ( + "seanime/internal/database/models" + + "gorm.io/gorm/clause" +) + +var CurrSettings *models.Settings + +func (db *Database) UpsertSettings(settings *models.Settings) (*models.Settings, error) { + + err := db.gormdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(settings).Error + + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to save settings in the database") + return nil, err + } + + CurrSettings = settings + + db.Logger.Debug().Msg("db: Settings saved") + return settings, nil + +} + +func (db *Database) GetSettings() (*models.Settings, error) { + + if CurrSettings != nil { + return CurrSettings, nil + } + + var settings models.Settings + err := db.gormdb.Where("id = ?", 1).Find(&settings).Error + + if err != nil { + return nil, err + } + return &settings, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (db *Database) GetLibraryPathFromSettings() (string, error) { + settings, err := db.GetSettings() + if err != nil { + return "", err + } + return settings.Library.LibraryPath, nil +} + +func (db *Database) GetAdditionalLibraryPathsFromSettings() ([]string, error) { + settings, err := db.GetSettings() + if err != nil { + return []string{}, err + } + return settings.Library.LibraryPaths, nil +} + +func (db *Database) GetAllLibraryPathsFromSettings() ([]string, error) { + settings, err := db.GetSettings() + if err != nil { + return []string{}, err + } + if settings.Library == nil { + return []string{}, nil + } + return append([]string{settings.Library.LibraryPath}, settings.Library.LibraryPaths...), nil +} + +func (db *Database) AllLibraryPathsFromSettings(settings *models.Settings) *[]string { + if settings.Library == nil { + return &[]string{} + } + r := append([]string{settings.Library.LibraryPath}, settings.Library.LibraryPaths...) + return &r +} + +func (db *Database) AutoUpdateProgressIsEnabled() (bool, error) { + settings, err := db.GetSettings() + if err != nil { + return false, err + } + return settings.Library.AutoUpdateProgress, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var CurrMediastreamSettings *models.MediastreamSettings + +func (db *Database) UpsertMediastreamSettings(settings *models.MediastreamSettings) (*models.MediastreamSettings, error) { + + err := db.gormdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(settings).Error + + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to save media streaming settings in the database") + return nil, err + } + + CurrMediastreamSettings = settings + + db.Logger.Debug().Msg("db: Media streaming settings saved") + return settings, nil + +} + +func (db *Database) GetMediastreamSettings() (*models.MediastreamSettings, bool) { + + if CurrMediastreamSettings != nil { + return CurrMediastreamSettings, true + } + + var settings models.MediastreamSettings + err := db.gormdb.Where("id = ?", 1).First(&settings).Error + + if err != nil { + return nil, false + } + return &settings, true +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var CurrTorrentstreamSettings *models.TorrentstreamSettings + +func (db *Database) UpsertTorrentstreamSettings(settings *models.TorrentstreamSettings) (*models.TorrentstreamSettings, error) { + + err := db.gormdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(settings).Error + + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to save torrent streaming settings in the database") + return nil, err + } + + CurrTorrentstreamSettings = settings + + db.Logger.Debug().Msg("db: Torrent streaming settings saved") + return settings, nil +} + +func (db *Database) GetTorrentstreamSettings() (*models.TorrentstreamSettings, bool) { + + if CurrTorrentstreamSettings != nil { + return CurrTorrentstreamSettings, true + } + + var settings models.TorrentstreamSettings + err := db.gormdb.Where("id = ?", 1).First(&settings).Error + + if err != nil { + return nil, false + } + return &settings, true +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var CurrentDebridSettings *models.DebridSettings + +func (db *Database) UpsertDebridSettings(settings *models.DebridSettings) (*models.DebridSettings, error) { + err := db.gormdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(settings).Error + + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to save debrid settings in the database") + return nil, err + } + + CurrentDebridSettings = settings + + db.Logger.Debug().Msg("db: Debrid settings saved") + return settings, nil +} + +func (db *Database) GetDebridSettings() (*models.DebridSettings, bool) { + + if CurrentDebridSettings != nil { + return CurrentDebridSettings, true + } + + var settings models.DebridSettings + err := db.gormdb.Where("id = ?", 1).First(&settings).Error + if err != nil { + return nil, false + } + return &settings, true +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/seanime-2.9.10/internal/database/db/silenced_media_entry.go b/seanime-2.9.10/internal/database/db/silenced_media_entry.go new file mode 100644 index 0000000..669937b --- /dev/null +++ b/seanime-2.9.10/internal/database/db/silenced_media_entry.go @@ -0,0 +1,70 @@ +package db + +import ( + "gorm.io/gorm/clause" + "seanime/internal/database/models" +) + +func (db *Database) GetSilencedMediaEntries() ([]*models.SilencedMediaEntry, error) { + var res []*models.SilencedMediaEntry + err := db.gormdb.Find(&res).Error + if err != nil { + return nil, err + } + + return res, nil +} + +// GetSilencedMediaEntryIds returns the ids of all silenced media entries. +// It returns an empty slice if there is an error. +func (db *Database) GetSilencedMediaEntryIds() ([]int, error) { + var res []*models.SilencedMediaEntry + err := db.gormdb.Find(&res).Error + if err != nil { + return make([]int, 0), err + } + + if len(res) == 0 { + return make([]int, 0), nil + } + + mIds := make([]int, len(res)) + for i, v := range res { + mIds[i] = int(v.ID) + } + + return mIds, nil +} + +func (db *Database) GetSilencedMediaEntry(mId uint) (*models.SilencedMediaEntry, error) { + var res models.SilencedMediaEntry + err := db.gormdb.First(&res, mId).Error + if err != nil { + return nil, err + } + + return &res, nil +} + +func (db *Database) InsertSilencedMediaEntry(mId uint) error { + err := db.gormdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(&models.SilencedMediaEntry{ + BaseModel: models.BaseModel{ + ID: mId, + }, + }).Error + if err != nil { + return err + } + return nil +} + +func (db *Database) DeleteSilencedMediaEntry(id uint) error { + err := db.gormdb.Delete(&models.SilencedMediaEntry{}, id).Error + if err != nil { + return err + } + return nil +} diff --git a/seanime-2.9.10/internal/database/db/theme.go b/seanime-2.9.10/internal/database/db/theme.go new file mode 100644 index 0000000..38b10cd --- /dev/null +++ b/seanime-2.9.10/internal/database/db/theme.go @@ -0,0 +1,47 @@ +package db + +import ( + "gorm.io/gorm/clause" + "seanime/internal/database/models" +) + +var themeCache *models.Theme + +func (db *Database) GetTheme() (*models.Theme, error) { + + if themeCache != nil { + return themeCache, nil + } + + var theme models.Theme + err := db.gormdb.Where("id = ?", 1).Find(&theme).Error + + if err != nil { + return nil, err + } + + themeCache = &theme + + return &theme, nil +} + +// UpsertTheme updates the theme settings. +func (db *Database) UpsertTheme(settings *models.Theme) (*models.Theme, error) { + + err := db.gormdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(settings).Error + + if err != nil { + db.Logger.Error().Err(err).Msg("db: Failed to save theme in the database") + return nil, err + } + + db.Logger.Debug().Msg("db: Theme saved") + + themeCache = settings + + return settings, nil + +} diff --git a/seanime-2.9.10/internal/database/db/token.go b/seanime-2.9.10/internal/database/db/token.go new file mode 100644 index 0000000..01f776b --- /dev/null +++ b/seanime-2.9.10/internal/database/db/token.go @@ -0,0 +1,21 @@ +package db + +import ( + "gorm.io/gorm/clause" + "seanime/internal/database/models" +) + +func (db *Database) UpsertToken(token *models.Token) (*models.Token, error) { + + err := db.gormdb.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"}), + }).Create(token).Error + + if err != nil { + db.Logger.Error().Err(err).Msg("Failed to save token in the database") + return nil, err + } + return token, nil + +} diff --git a/seanime-2.9.10/internal/database/db/torrentstream_history.go b/seanime-2.9.10/internal/database/db/torrentstream_history.go new file mode 100644 index 0000000..8d869ff --- /dev/null +++ b/seanime-2.9.10/internal/database/db/torrentstream_history.go @@ -0,0 +1,24 @@ +package db + +import ( + "seanime/internal/database/models" +) + +func (db *Database) TrimTorrentstreamHistory() { + go func() { + var count int64 + err := db.gormdb.Model(&models.TorrentstreamHistory{}).Count(&count).Error + if err != nil { + db.Logger.Error().Err(err).Msg("database: Failed to count torrent stream history entries") + return + } + if count > 50 { + // Leave 40 entries + err = db.gormdb.Delete(&models.TorrentstreamHistory{}, "id IN (SELECT id FROM torrentstream_histories ORDER BY updated_at ASC LIMIT ?)", 10).Error + if err != nil { + db.Logger.Error().Err(err).Msg("database: Failed to delete old torrent stream history entries") + return + } + } + }() +} diff --git a/seanime-2.9.10/internal/database/db_bridge/README.md b/seanime-2.9.10/internal/database/db_bridge/README.md new file mode 100644 index 0000000..b328e6d --- /dev/null +++ b/seanime-2.9.10/internal/database/db_bridge/README.md @@ -0,0 +1,2 @@ +The database may store some structs defined outside as `[]byte` inside `models`. +To avoid circular dependencies, we define methods that directly convert `[]byte` to the corresponding struct using the database to store/retrieve them. diff --git a/seanime-2.9.10/internal/database/db_bridge/autodownloader_rule.go b/seanime-2.9.10/internal/database/db_bridge/autodownloader_rule.go new file mode 100644 index 0000000..05da3b9 --- /dev/null +++ b/seanime-2.9.10/internal/database/db_bridge/autodownloader_rule.go @@ -0,0 +1,109 @@ +package db_bridge + +import ( + "github.com/goccy/go-json" + "seanime/internal/database/db" + "seanime/internal/database/models" + "seanime/internal/library/anime" +) + +var CurrAutoDownloaderRules []*anime.AutoDownloaderRule + +func GetAutoDownloaderRules(db *db.Database) ([]*anime.AutoDownloaderRule, error) { + + //if CurrAutoDownloaderRules != nil { + // return CurrAutoDownloaderRules, nil + //} + + var res []*models.AutoDownloaderRule + err := db.Gorm().Find(&res).Error + if err != nil { + return nil, err + } + + // Unmarshal the data + var rules []*anime.AutoDownloaderRule + for _, r := range res { + smBytes := r.Value + var sm anime.AutoDownloaderRule + if err := json.Unmarshal(smBytes, &sm); err != nil { + return nil, err + } + sm.DbID = r.ID + rules = append(rules, &sm) + } + + //CurrAutoDownloaderRules = rules + + return rules, nil +} + +func GetAutoDownloaderRule(db *db.Database, id uint) (*anime.AutoDownloaderRule, error) { + var res models.AutoDownloaderRule + err := db.Gorm().First(&res, id).Error + if err != nil { + return nil, err + } + + // Unmarshal the data + smBytes := res.Value + var sm anime.AutoDownloaderRule + if err := json.Unmarshal(smBytes, &sm); err != nil { + return nil, err + } + sm.DbID = res.ID + + return &sm, nil +} + +func GetAutoDownloaderRulesByMediaId(db *db.Database, mediaId int) (ret []*anime.AutoDownloaderRule) { + rules, err := GetAutoDownloaderRules(db) + if err != nil { + return + } + + for _, rule := range rules { + if rule.MediaId == mediaId { + ret = append(ret, rule) + } + } + + return +} + +func InsertAutoDownloaderRule(db *db.Database, sm *anime.AutoDownloaderRule) error { + + CurrAutoDownloaderRules = nil + + // Marshal the data + bytes, err := json.Marshal(sm) + if err != nil { + return err + } + + // Save the data + return db.Gorm().Create(&models.AutoDownloaderRule{ + Value: bytes, + }).Error +} + +func DeleteAutoDownloaderRule(db *db.Database, id uint) error { + + CurrAutoDownloaderRules = nil + + return db.Gorm().Delete(&models.AutoDownloaderRule{}, id).Error +} + +func UpdateAutoDownloaderRule(db *db.Database, id uint, sm *anime.AutoDownloaderRule) error { + + CurrAutoDownloaderRules = nil + + // Marshal the data + bytes, err := json.Marshal(sm) + if err != nil { + return err + } + + // Save the data + return db.Gorm().Model(&models.AutoDownloaderRule{}).Where("id = ?", id).Update("value", bytes).Error +} diff --git a/seanime-2.9.10/internal/database/db_bridge/localfiles.go b/seanime-2.9.10/internal/database/db_bridge/localfiles.go new file mode 100644 index 0000000..3dc93c1 --- /dev/null +++ b/seanime-2.9.10/internal/database/db_bridge/localfiles.go @@ -0,0 +1,97 @@ +package db_bridge + +import ( + "github.com/goccy/go-json" + "github.com/samber/mo" + "seanime/internal/database/db" + "seanime/internal/database/models" + "seanime/internal/library/anime" +) + +var CurrLocalFilesDbId uint +var CurrLocalFiles mo.Option[[]*anime.LocalFile] + +// GetLocalFiles will return the latest local files and the id of the entry. +func GetLocalFiles(db *db.Database) ([]*anime.LocalFile, uint, error) { + + if CurrLocalFiles.IsPresent() { + return CurrLocalFiles.MustGet(), CurrLocalFilesDbId, nil + } + + // Get the latest entry + var res models.LocalFiles + err := db.Gorm().Last(&res).Error + if err != nil { + return nil, 0, err + } + + // Unmarshal the local files + lfsBytes := res.Value + var lfs []*anime.LocalFile + if err := json.Unmarshal(lfsBytes, &lfs); err != nil { + return nil, 0, err + } + + db.Logger.Debug().Msg("db: Local files retrieved") + + CurrLocalFiles = mo.Some(lfs) + CurrLocalFilesDbId = res.ID + + return lfs, res.ID, nil +} + +// SaveLocalFiles will save the local files in the database at the given id. +func SaveLocalFiles(db *db.Database, lfsId uint, lfs []*anime.LocalFile) ([]*anime.LocalFile, error) { + // Marshal the local files + marshaledLfs, err := json.Marshal(lfs) + if err != nil { + return nil, err + } + + // Save the local files + ret, err := db.UpsertLocalFiles(&models.LocalFiles{ + BaseModel: models.BaseModel{ + ID: lfsId, + }, + Value: marshaledLfs, + }) + if err != nil { + return nil, err + } + + // Unmarshal the saved local files + var retLfs []*anime.LocalFile + if err := json.Unmarshal(ret.Value, &retLfs); err != nil { + return lfs, nil + } + + CurrLocalFiles = mo.Some(retLfs) + CurrLocalFilesDbId = ret.ID + + return retLfs, nil +} + +// InsertLocalFiles will insert the local files in the database at a new entry. +func InsertLocalFiles(db *db.Database, lfs []*anime.LocalFile) ([]*anime.LocalFile, error) { + + // Marshal the local files + bytes, err := json.Marshal(lfs) + if err != nil { + return nil, err + } + + // Save the local files to the database + ret, err := db.InsertLocalFiles(&models.LocalFiles{ + Value: bytes, + }) + + if err != nil { + return nil, err + } + + CurrLocalFiles = mo.Some(lfs) + CurrLocalFilesDbId = ret.ID + + return lfs, nil + +} diff --git a/seanime-2.9.10/internal/database/db_bridge/playlist.go b/seanime-2.9.10/internal/database/db_bridge/playlist.go new file mode 100644 index 0000000..152a65b --- /dev/null +++ b/seanime-2.9.10/internal/database/db_bridge/playlist.go @@ -0,0 +1,82 @@ +package db_bridge + +import ( + "github.com/goccy/go-json" + "seanime/internal/database/db" + "seanime/internal/database/models" + "seanime/internal/library/anime" +) + +func GetPlaylists(db *db.Database) ([]*anime.Playlist, error) { + var res []*models.PlaylistEntry + err := db.Gorm().Find(&res).Error + if err != nil { + return nil, err + } + + playlists := make([]*anime.Playlist, 0) + for _, p := range res { + var localFiles []*anime.LocalFile + if err := json.Unmarshal(p.Value, &localFiles); err == nil { + playlist := anime.NewPlaylist(p.Name) + playlist.SetLocalFiles(localFiles) + playlist.DbId = p.ID + playlists = append(playlists, playlist) + } + } + return playlists, nil +} + +func SavePlaylist(db *db.Database, playlist *anime.Playlist) error { + data, err := json.Marshal(playlist.LocalFiles) + if err != nil { + return err + } + playlistEntry := &models.PlaylistEntry{ + Name: playlist.Name, + Value: data, + } + + return db.Gorm().Save(playlistEntry).Error +} + +func DeletePlaylist(db *db.Database, id uint) error { + return db.Gorm().Where("id = ?", id).Delete(&models.PlaylistEntry{}).Error +} + +func UpdatePlaylist(db *db.Database, playlist *anime.Playlist) error { + data, err := json.Marshal(playlist.LocalFiles) + if err != nil { + return err + } + + // Get the playlist entry + playlistEntry := &models.PlaylistEntry{} + if err := db.Gorm().Where("id = ?", playlist.DbId).First(playlistEntry).Error; err != nil { + return err + } + + // Update the playlist entry + playlistEntry.Name = playlist.Name + playlistEntry.Value = data + + return db.Gorm().Save(playlistEntry).Error +} + +func GetPlaylist(db *db.Database, id uint) (*anime.Playlist, error) { + playlistEntry := &models.PlaylistEntry{} + if err := db.Gorm().Where("id = ?", id).First(playlistEntry).Error; err != nil { + return nil, err + } + + var localFiles []*anime.LocalFile + if err := json.Unmarshal(playlistEntry.Value, &localFiles); err != nil { + return nil, err + } + + playlist := anime.NewPlaylist(playlistEntry.Name) + playlist.SetLocalFiles(localFiles) + playlist.DbId = playlistEntry.ID + + return playlist, nil +} diff --git a/seanime-2.9.10/internal/database/db_bridge/scan_summary.go b/seanime-2.9.10/internal/database/db_bridge/scan_summary.go new file mode 100644 index 0000000..bd8dfb9 --- /dev/null +++ b/seanime-2.9.10/internal/database/db_bridge/scan_summary.go @@ -0,0 +1,50 @@ +package db_bridge + +import ( + "seanime/internal/database/db" + "seanime/internal/database/models" + "seanime/internal/library/summary" + + "github.com/goccy/go-json" +) + +func GetScanSummaries(database *db.Database) ([]*summary.ScanSummaryItem, error) { + var res []*models.ScanSummary + err := database.Gorm().Find(&res).Error + if err != nil { + return nil, err + } + + // Unmarshal the data + var items []*summary.ScanSummaryItem + for _, r := range res { + smBytes := r.Value + var sm summary.ScanSummary + if err := json.Unmarshal(smBytes, &sm); err != nil { + return nil, err + } + items = append(items, &summary.ScanSummaryItem{ + CreatedAt: r.CreatedAt, + ScanSummary: &sm, + }) + } + + return items, nil +} + +func InsertScanSummary(db *db.Database, sm *summary.ScanSummary) error { + if sm == nil { + return nil + } + + // Marshal the data + bytes, err := json.Marshal(sm) + if err != nil { + return err + } + + // Save the data + return db.Gorm().Create(&models.ScanSummary{ + Value: bytes, + }).Error +} diff --git a/seanime-2.9.10/internal/database/db_bridge/torrentstream_history.go b/seanime-2.9.10/internal/database/db_bridge/torrentstream_history.go new file mode 100644 index 0000000..bec8d99 --- /dev/null +++ b/seanime-2.9.10/internal/database/db_bridge/torrentstream_history.go @@ -0,0 +1,46 @@ +package db_bridge + +import ( + "github.com/goccy/go-json" + "seanime/internal/database/db" + "seanime/internal/database/models" + hibiketorrent "seanime/internal/extension/hibike/torrent" +) + +func GetTorrentstreamHistory(db *db.Database, mId int) (*hibiketorrent.AnimeTorrent, error) { + var history models.TorrentstreamHistory + if err := db.Gorm().Where("media_id = ?", mId).First(&history).Error; err != nil { + return nil, err + } + + var torrent hibiketorrent.AnimeTorrent + if err := json.Unmarshal(history.Torrent, &torrent); err != nil { + return nil, err + } + return &torrent, nil +} + +func InsertTorrentstreamHistory(db *db.Database, mId int, torrent *hibiketorrent.AnimeTorrent) error { + if torrent == nil { + return nil + } + + // Marshal the data + bytes, err := json.Marshal(torrent) + if err != nil { + return err + } + + // Get current history + var history models.TorrentstreamHistory + if err := db.Gorm().Where("media_id = ?", mId).First(&history).Error; err == nil { + // Update the history + history.Torrent = bytes + return db.Gorm().Save(&history).Error + } + + return db.Gorm().Create(&models.TorrentstreamHistory{ + MediaId: mId, + Torrent: bytes, + }).Error +} diff --git a/seanime-2.9.10/internal/database/models/models.go b/seanime-2.9.10/internal/database/models/models.go new file mode 100644 index 0000000..30ed3b8 --- /dev/null +++ b/seanime-2.9.10/internal/database/models/models.go @@ -0,0 +1,511 @@ +package models + +import ( + "database/sql/driver" + "errors" + "strconv" + "strings" + "time" +) + +type BaseModel struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Token struct { + BaseModel + Value string `json:"value"` +} + +type Account struct { + BaseModel + Username string `gorm:"column:username" json:"username"` + Token string `gorm:"column:token" json:"token"` + Viewer []byte `gorm:"column:viewer" json:"viewer"` +} + +// +---------------------+ +// | LocalFiles | +// +---------------------+ + +type LocalFiles struct { + BaseModel + Value []byte `gorm:"column:value" json:"value"` +} + +// +---------------------+ +// | Settings | +// +---------------------+ + +type Settings struct { + BaseModel + Library *LibrarySettings `gorm:"embedded" json:"library"` + MediaPlayer *MediaPlayerSettings `gorm:"embedded" json:"mediaPlayer"` + Torrent *TorrentSettings `gorm:"embedded" json:"torrent"` + Manga *MangaSettings `gorm:"embedded" json:"manga"` + Anilist *AnilistSettings `gorm:"embedded" json:"anilist"` + ListSync *ListSyncSettings `gorm:"embedded" json:"listSync"` + AutoDownloader *AutoDownloaderSettings `gorm:"embedded" json:"autoDownloader"` + Discord *DiscordSettings `gorm:"embedded" json:"discord"` + Notifications *NotificationSettings `gorm:"embedded" json:"notifications"` + Nakama *NakamaSettings `gorm:"embedded;embeddedPrefix:nakama_" json:"nakama"` +} + +type AnilistSettings struct { + //AnilistClientId string `gorm:"column:anilist_client_id" json:"anilistClientId"` + HideAudienceScore bool `gorm:"column:hide_audience_score" json:"hideAudienceScore"` + EnableAdultContent bool `gorm:"column:enable_adult_content" json:"enableAdultContent"` + BlurAdultContent bool `gorm:"column:blur_adult_content" json:"blurAdultContent"` +} + +type LibrarySettings struct { + LibraryPath string `gorm:"column:library_path" json:"libraryPath"` + AutoUpdateProgress bool `gorm:"column:auto_update_progress" json:"autoUpdateProgress"` + DisableUpdateCheck bool `gorm:"column:disable_update_check" json:"disableUpdateCheck"` + TorrentProvider string `gorm:"column:torrent_provider" json:"torrentProvider"` + AutoScan bool `gorm:"column:auto_scan" json:"autoScan"` + EnableOnlinestream bool `gorm:"column:enable_onlinestream" json:"enableOnlinestream"` + IncludeOnlineStreamingInLibrary bool `gorm:"column:include_online_streaming_in_library" json:"includeOnlineStreamingInLibrary"` + DisableAnimeCardTrailers bool `gorm:"column:disable_anime_card_trailers" json:"disableAnimeCardTrailers"` + EnableManga bool `gorm:"column:enable_manga" json:"enableManga"` + DOHProvider string `gorm:"column:doh_provider" json:"dohProvider"` + OpenTorrentClientOnStart bool `gorm:"column:open_torrent_client_on_start" json:"openTorrentClientOnStart"` + OpenWebURLOnStart bool `gorm:"column:open_web_url_on_start" json:"openWebURLOnStart"` + RefreshLibraryOnStart bool `gorm:"column:refresh_library_on_start" json:"refreshLibraryOnStart"` + // v2.1+ + AutoPlayNextEpisode bool `gorm:"column:auto_play_next_episode" json:"autoPlayNextEpisode"` + // v2.2+ + EnableWatchContinuity bool `gorm:"column:enable_watch_continuity" json:"enableWatchContinuity"` + LibraryPaths LibraryPaths `gorm:"column:library_paths;type:text" json:"libraryPaths"` + AutoSyncOfflineLocalData bool `gorm:"column:auto_sync_offline_local_data" json:"autoSyncOfflineLocalData"` + // v2.6+ + ScannerMatchingThreshold float64 `gorm:"column:scanner_matching_threshold" json:"scannerMatchingThreshold"` + ScannerMatchingAlgorithm string `gorm:"column:scanner_matching_algorithm" json:"scannerMatchingAlgorithm"` + // v2.9+ + AutoSyncToLocalAccount bool `gorm:"column:auto_sync_to_local_account" json:"autoSyncToLocalAccount"` + AutoSaveCurrentMediaOffline bool `gorm:"column:auto_save_current_media_offline" json:"autoSaveCurrentMediaOffline"` +} + +func (o *LibrarySettings) GetLibraryPaths() (ret []string) { + ret = make([]string, len(o.LibraryPaths)+1) + ret[0] = o.LibraryPath + if len(o.LibraryPaths) > 0 { + copy(ret[1:], o.LibraryPaths) + } + return +} + +type LibraryPaths []string + +func (o *LibraryPaths) Scan(src interface{}) error { + str, ok := src.(string) + if !ok { + return errors.New("src value cannot cast to string") + } + *o = strings.Split(str, ",") + return nil +} +func (o LibraryPaths) Value() (driver.Value, error) { + if len(o) == 0 { + return nil, nil + } + return strings.Join(o, ","), nil +} + +type NakamaSettings struct { + Enabled bool `gorm:"column:enabled" json:"enabled"` + // Username is the name used to identify a peer or host. + Username string `gorm:"column:username" json:"username"` + // IsHost allows the server to act as a host for other clients. This requires a password to be set. + IsHost bool `gorm:"column:is_host" json:"isHost"` + HostPassword string `gorm:"column:host_password" json:"hostPassword"` + RemoteServerURL string `gorm:"column:remote_server_url" json:"remoteServerURL"` + RemoteServerPassword string `gorm:"column:remote_server_password" json:"remoteServerPassword"` + // IncludeNakamaAnimeLibrary adds the local anime library of the host to the connected clients. + IncludeNakamaAnimeLibrary bool `gorm:"column:include_nakama_anime_library" json:"includeNakamaAnimeLibrary"` + // HostShareLocalAnimeLibrary shares the local anime library to connected clients + HostShareLocalAnimeLibrary bool `gorm:"column:host_share_local_anime_library" json:"hostShareLocalAnimeLibrary"` + // HostUnsharedAnimeIds is a list of anime IDs that should not be shared with connected clients. + HostUnsharedAnimeIds IntSlice `gorm:"column:host_unshared_anime_ids;type:text" json:"hostUnsharedAnimeIds"` + // HostEnablePortForwarding enables port forwarding. + HostEnablePortForwarding bool `gorm:"column:host_enable_port_forwarding" json:"hostEnablePortForwarding"` +} + +type IntSlice []int + +func (o *IntSlice) Scan(src interface{}) error { + str, ok := src.(string) + if !ok { + return errors.New("src value cannot cast to string") + } + ids := strings.Split(str, ",") + *o = make(IntSlice, len(ids)) + for i, id := range ids { + (*o)[i], _ = strconv.Atoi(id) + } + return nil +} +func (o IntSlice) Value() (driver.Value, error) { + if len(o) == 0 { + return nil, nil + } + strs := make([]string, len(o)) + for i, id := range o { + strs[i] = strconv.Itoa(id) + } + return strings.Join(strs, ","), nil +} + +type MangaSettings struct { + DefaultProvider string `gorm:"column:default_manga_provider" json:"defaultMangaProvider"` + AutoUpdateProgress bool `gorm:"column:manga_auto_update_progress" json:"mangaAutoUpdateProgress"` + LocalSourceDirectory string `gorm:"column:manga_local_source_directory" json:"mangaLocalSourceDirectory"` +} + +type MediaPlayerSettings struct { + Default string `gorm:"column:default_player" json:"defaultPlayer"` // "vlc" or "mpc-hc" + Host string `gorm:"column:player_host" json:"host"` + VlcUsername string `gorm:"column:vlc_username" json:"vlcUsername"` + VlcPassword string `gorm:"column:vlc_password" json:"vlcPassword"` + VlcPort int `gorm:"column:vlc_port" json:"vlcPort"` + VlcPath string `gorm:"column:vlc_path" json:"vlcPath"` + MpcPort int `gorm:"column:mpc_port" json:"mpcPort"` + MpcPath string `gorm:"column:mpc_path" json:"mpcPath"` + MpvSocket string `gorm:"column:mpv_socket" json:"mpvSocket"` + MpvPath string `gorm:"column:mpv_path" json:"mpvPath"` + MpvArgs string `gorm:"column:mpv_args" json:"mpvArgs"` + IinaSocket string `gorm:"column:iina_socket" json:"iinaSocket"` + IinaPath string `gorm:"column:iina_path" json:"iinaPath"` + IinaArgs string `gorm:"column:iina_args" json:"iinaArgs"` +} + +type TorrentSettings struct { + Default string `gorm:"column:default_torrent_client" json:"defaultTorrentClient"` + QBittorrentPath string `gorm:"column:qbittorrent_path" json:"qbittorrentPath"` + QBittorrentHost string `gorm:"column:qbittorrent_host" json:"qbittorrentHost"` + QBittorrentPort int `gorm:"column:qbittorrent_port" json:"qbittorrentPort"` + QBittorrentUsername string `gorm:"column:qbittorrent_username" json:"qbittorrentUsername"` + QBittorrentPassword string `gorm:"column:qbittorrent_password" json:"qbittorrentPassword"` + QBittorrentTags string `gorm:"column:qbittorrent_tags" json:"qbittorrentTags"` + TransmissionPath string `gorm:"column:transmission_path" json:"transmissionPath"` + TransmissionHost string `gorm:"column:transmission_host" json:"transmissionHost"` + TransmissionPort int `gorm:"column:transmission_port" json:"transmissionPort"` + TransmissionUsername string `gorm:"column:transmission_username" json:"transmissionUsername"` + TransmissionPassword string `gorm:"column:transmission_password" json:"transmissionPassword"` + // v2.1+ + ShowActiveTorrentCount bool `gorm:"column:show_active_torrent_count" json:"showActiveTorrentCount"` + // v2.2+ + HideTorrentList bool `gorm:"column:hide_torrent_list" json:"hideTorrentList"` +} + +type ListSyncSettings struct { + Automatic bool `gorm:"column:automatic_sync" json:"automatic"` + Origin string `gorm:"column:sync_origin" json:"origin"` +} + +type DiscordSettings struct { + EnableRichPresence bool `gorm:"column:enable_rich_presence" json:"enableRichPresence"` + EnableAnimeRichPresence bool `gorm:"column:enable_anime_rich_presence" json:"enableAnimeRichPresence"` + EnableMangaRichPresence bool `gorm:"column:enable_manga_rich_presence" json:"enableMangaRichPresence"` + RichPresenceHideSeanimeRepositoryButton bool `gorm:"column:rich_presence_hide_seanime_repository_button" json:"richPresenceHideSeanimeRepositoryButton"` + RichPresenceShowAniListMediaButton bool `gorm:"column:rich_presence_show_anilist_media_button" json:"richPresenceShowAniListMediaButton"` + RichPresenceShowAniListProfileButton bool `gorm:"column:rich_presence_show_anilist_profile_button" json:"richPresenceShowAniListProfileButton"` + RichPresenceUseMediaTitleStatus bool `gorm:"column:rich_presence_use_media_title_status;default:true" json:"richPresenceUseMediaTitleStatus"` +} + +type NotificationSettings struct { + DisableNotifications bool `gorm:"column:disable_notifications" json:"disableNotifications"` + DisableAutoDownloaderNotifications bool `gorm:"column:disable_auto_downloader_notifications" json:"disableAutoDownloaderNotifications"` + DisableAutoScannerNotifications bool `gorm:"column:disable_auto_scanner_notifications" json:"disableAutoScannerNotifications"` +} + +// +---------------------+ +// | MAL | +// +---------------------+ + +type Mal struct { + BaseModel + Username string `gorm:"column:username" json:"username"` + AccessToken string `gorm:"column:access_token" json:"accessToken"` + RefreshToken string `gorm:"column:refresh_token" json:"refreshToken"` + TokenExpiresAt time.Time `gorm:"column:token_expires_at" json:"tokenExpiresAt"` +} + +// +---------------------+ +// | Scan Summary | +// +---------------------+ + +type ScanSummary struct { + BaseModel + Value []byte `gorm:"column:value" json:"value"` +} + +// +---------------------+ +// | Auto downloader | +// +---------------------+ + +type AutoDownloaderRule struct { + BaseModel + Value []byte `gorm:"column:value" json:"value"` +} + +type AutoDownloaderItem struct { + BaseModel + RuleID uint `gorm:"column:rule_id" json:"ruleId"` + MediaID int `gorm:"column:media_id" json:"mediaId"` + Episode int `gorm:"column:episode" json:"episode"` + Link string `gorm:"column:link" json:"link"` + Hash string `gorm:"column:hash" json:"hash"` + Magnet string `gorm:"column:magnet" json:"magnet"` + TorrentName string `gorm:"column:torrent_name" json:"torrentName"` + Downloaded bool `gorm:"column:downloaded" json:"downloaded"` +} + +type AutoDownloaderSettings struct { + Provider string `gorm:"column:auto_downloader_provider" json:"provider"` + Interval int `gorm:"column:auto_downloader_interval" json:"interval"` + Enabled bool `gorm:"column:auto_downloader_enabled" json:"enabled"` + DownloadAutomatically bool `gorm:"column:auto_downloader_download_automatically" json:"downloadAutomatically"` + EnableEnhancedQueries bool `gorm:"column:auto_downloader_enable_enhanced_queries" json:"enableEnhancedQueries"` + EnableSeasonCheck bool `gorm:"column:auto_downloader_enable_season_check" json:"enableSeasonCheck"` + UseDebrid bool `gorm:"column:auto_downloader_use_debrid" json:"useDebrid"` +} + +// +---------------------+ +// | Media Entry | +// +---------------------+ + +type SilencedMediaEntry struct { + BaseModel +} + +// +---------------------+ +// | Theme | +// +---------------------+ + +type Theme struct { + BaseModel + // Main + EnableColorSettings bool `gorm:"column:enable_color_settings" json:"enableColorSettings"` + BackgroundColor string `gorm:"column:background_color" json:"backgroundColor"` + AccentColor string `gorm:"column:accent_color" json:"accentColor"` + SidebarBackgroundColor string `gorm:"column:sidebar_background_color" json:"sidebarBackgroundColor"` // DEPRECATED + AnimeEntryScreenLayout string `gorm:"column:anime_entry_screen_layout" json:"animeEntryScreenLayout"` // DEPRECATED + ExpandSidebarOnHover bool `gorm:"column:expand_sidebar_on_hover" json:"expandSidebarOnHover"` + HideTopNavbar bool `gorm:"column:hide_top_navbar" json:"hideTopNavbar"` + EnableMediaCardBlurredBackground bool `gorm:"column:enable_media_card_blurred_background" json:"enableMediaCardBlurredBackground"` + // Note: These are named "libraryScreen" but are used on all pages + LibraryScreenCustomBackgroundImage string `gorm:"column:library_screen_custom_background_image" json:"libraryScreenCustomBackgroundImage"` + LibraryScreenCustomBackgroundOpacity int `gorm:"column:library_screen_custom_background_opacity" json:"libraryScreenCustomBackgroundOpacity"` + // Anime + SmallerEpisodeCarouselSize bool `gorm:"column:smaller_episode_carousel_size" json:"smallerEpisodeCarouselSize"` + // Library Screen (Anime & Manga) + // LibraryScreenBannerType: "dynamic", "custom" + LibraryScreenBannerType string `gorm:"column:library_screen_banner_type" json:"libraryScreenBannerType"` + LibraryScreenCustomBannerImage string `gorm:"column:library_screen_custom_banner_image" json:"libraryScreenCustomBannerImage"` + LibraryScreenCustomBannerPosition string `gorm:"column:library_screen_custom_banner_position" json:"libraryScreenCustomBannerPosition"` + LibraryScreenCustomBannerOpacity int `gorm:"column:library_screen_custom_banner_opacity" json:"libraryScreenCustomBannerOpacity"` + DisableLibraryScreenGenreSelector bool `gorm:"column:disable_library_screen_genre_selector" json:"disableLibraryScreenGenreSelector"` + + LibraryScreenCustomBackgroundBlur string `gorm:"column:library_screen_custom_background_blur" json:"libraryScreenCustomBackgroundBlur"` + EnableMediaPageBlurredBackground bool `gorm:"column:enable_media_page_blurred_background" json:"enableMediaPageBlurredBackground"` + DisableSidebarTransparency bool `gorm:"column:disable_sidebar_transparency" json:"disableSidebarTransparency"` + UseLegacyEpisodeCard bool `gorm:"column:use_legacy_episode_card" json:"useLegacyEpisodeCard"` // DEPRECATED + DisableCarouselAutoScroll bool `gorm:"column:disable_carousel_auto_scroll" json:"disableCarouselAutoScroll"` + + // v2.6+ + MediaPageBannerType string `gorm:"column:media_page_banner_type" json:"mediaPageBannerType"` + MediaPageBannerSize string `gorm:"column:media_page_banner_size" json:"mediaPageBannerSize"` + MediaPageBannerInfoBoxSize string `gorm:"column:media_page_banner_info_box_size" json:"mediaPageBannerInfoBoxSize"` + + // v2.7+ + ShowEpisodeCardAnimeInfo bool `gorm:"column:show_episode_card_anime_info" json:"showEpisodeCardAnimeInfo"` + ContinueWatchingDefaultSorting string `gorm:"column:continue_watching_default_sorting" json:"continueWatchingDefaultSorting"` + AnimeLibraryCollectionDefaultSorting string `gorm:"column:anime_library_collection_default_sorting" json:"animeLibraryCollectionDefaultSorting"` + MangaLibraryCollectionDefaultSorting string `gorm:"column:manga_library_collection_default_sorting" json:"mangaLibraryCollectionDefaultSorting"` + ShowAnimeUnwatchedCount bool `gorm:"column:show_anime_unwatched_count" json:"showAnimeUnwatchedCount"` + ShowMangaUnreadCount bool `gorm:"column:show_manga_unread_count" json:"showMangaUnreadCount"` + + // v2.8+ + HideEpisodeCardDescription bool `gorm:"column:hide_episode_card_description" json:"hideEpisodeCardDescription"` + HideDownloadedEpisodeCardFilename bool `gorm:"column:hide_downloaded_episode_card_filename" json:"hideDownloadedEpisodeCardFilename"` + CustomCSS string `gorm:"column:custom_css" json:"customCSS"` + MobileCustomCSS string `gorm:"column:mobile_custom_css" json:"mobileCustomCSS"` + + // v2.9+ + UnpinnedMenuItems StringSlice `gorm:"column:unpinned_menu_items;type:text" json:"unpinnedMenuItems"` +} + +// +---------------------+ +// | Playlist | +// +---------------------+ + +type PlaylistEntry struct { + BaseModel + Name string `gorm:"column:name" json:"name"` + Value []byte `gorm:"column:value" json:"value"` +} + +// +------------------------+ +// | Chapter Download Queue | +// +------------------------+ + +type ChapterDownloadQueueItem struct { + BaseModel + Provider string `gorm:"column:provider" json:"provider"` + MediaID int `gorm:"column:media_id" json:"mediaId"` + ChapterID string `gorm:"column:chapter_id" json:"chapterId"` + ChapterNumber string `gorm:"column:chapter_number" json:"chapterNumber"` + PageData []byte `gorm:"column:page_data" json:"pageData"` // Contains map of page index to page details + Status string `gorm:"column:status" json:"status"` +} + +// +---------------------+ +// | MediaStream | +// +---------------------+ + +type MediastreamSettings struct { + BaseModel + // DEVNOTE: Should really be "Enabled" + TranscodeEnabled bool `gorm:"column:transcode_enabled" json:"transcodeEnabled"` + TranscodeHwAccel string `gorm:"column:transcode_hw_accel" json:"transcodeHwAccel"` + TranscodeThreads int `gorm:"column:transcode_threads" json:"transcodeThreads"` + TranscodePreset string `gorm:"column:transcode_preset" json:"transcodePreset"` + DisableAutoSwitchToDirectPlay bool `gorm:"column:disable_auto_switch_to_direct_play" json:"disableAutoSwitchToDirectPlay"` + DirectPlayOnly bool `gorm:"column:direct_play_only" json:"directPlayOnly"` + PreTranscodeEnabled bool `gorm:"column:pre_transcode_enabled" json:"preTranscodeEnabled"` + PreTranscodeLibraryDir string `gorm:"column:pre_transcode_library_dir" json:"preTranscodeLibraryDir"` + FfmpegPath string `gorm:"column:ffmpeg_path" json:"ffmpegPath"` + FfprobePath string `gorm:"column:ffprobe_path" json:"ffprobePath"` + // v2.2+ + TranscodeHwAccelCustomSettings string `gorm:"column:transcode_hw_accel_custom_settings" json:"transcodeHwAccelCustomSettings"` + + //TranscodeTempDir string `gorm:"column:transcode_temp_dir" json:"transcodeTempDir"` // DEPRECATED +} + +// +---------------------+ +// | TorrentStream | +// +---------------------+ + +type TorrentstreamSettings struct { + BaseModel + Enabled bool `gorm:"column:enabled" json:"enabled"` + AutoSelect bool `gorm:"column:auto_select" json:"autoSelect"` + PreferredResolution string `gorm:"column:preferred_resolution" json:"preferredResolution"` + DisableIPV6 bool `gorm:"column:disable_ipv6" json:"disableIPV6"` + DownloadDir string `gorm:"column:download_dir" json:"downloadDir"` + AddToLibrary bool `gorm:"column:add_to_library" json:"addToLibrary"` + TorrentClientHost string `gorm:"column:torrent_client_host" json:"torrentClientHost"` + TorrentClientPort int `gorm:"column:torrent_client_port" json:"torrentClientPort"` + StreamingServerHost string `gorm:"column:streaming_server_host" json:"streamingServerHost"` + StreamingServerPort int `gorm:"column:streaming_server_port" json:"streamingServerPort"` + //FallbackToTorrentStreamingView bool `gorm:"column:fallback_to_torrent_streaming_view" json:"fallbackToTorrentStreamingView"` // DEPRECATED + IncludeInLibrary bool `gorm:"column:include_in_library" json:"includeInLibrary"` + // v2.6+ + StreamUrlAddress string `gorm:"column:stream_url_address" json:"streamUrlAddress"` + // v2.7+ + SlowSeeding bool `gorm:"column:slow_seeding" json:"slowSeeding"` +} + +type TorrentstreamHistory struct { + BaseModel + MediaId int `gorm:"column:media_id" json:"mediaId"` + Torrent []byte `gorm:"column:torrent" json:"torrent"` +} + +// +---------------------+ +// | Filler | +// +---------------------+ + +type MediaFiller struct { + BaseModel + Provider string `gorm:"column:provider" json:"provider"` + Slug string `gorm:"column:slug" json:"slug"` + MediaID int `gorm:"column:media_id" json:"mediaId"` + LastFetchedAt time.Time `gorm:"column:last_fetched_at" json:"lastFetchedAt"` + Data []byte `gorm:"column:data" json:"data"` +} + +// +---------------------+ +// | Manga | +// +---------------------+ + +type MangaMapping struct { + BaseModel + Provider string `gorm:"column:provider" json:"provider"` + MediaID int `gorm:"column:media_id" json:"mediaId"` + MangaID string `gorm:"column:manga_id" json:"mangaId"` // ID from search result, used to fetch chapters +} + +type MangaChapterContainer struct { + BaseModel + Provider string `gorm:"column:provider" json:"provider"` + MediaID int `gorm:"column:media_id" json:"mediaId"` + ChapterID string `gorm:"column:chapter_id" json:"chapterId"` + Data []byte `gorm:"column:data" json:"data"` +} + +// +---------------------+ +// | Online streaming | +// +---------------------+ + +type OnlinestreamMapping struct { + BaseModel + Provider string `gorm:"column:provider" json:"provider"` + MediaID int `gorm:"column:media_id" json:"mediaId"` + AnimeID string `gorm:"column:anime_id" json:"anime_id"` // ID from search result, used to fetch episodes +} + +// +---------------------+ +// | Debrid | +// +---------------------+ + +type DebridSettings struct { + BaseModel + Enabled bool `gorm:"column:enabled" json:"enabled"` + Provider string `gorm:"column:provider" json:"provider"` + ApiKey string `gorm:"column:api_key" json:"apiKey"` + //FallbackToDebridStreamingView bool `gorm:"column:fallback_to_debrid_streaming_view" json:"fallbackToDebridStreamingView"` // DEPRECATED + IncludeDebridStreamInLibrary bool `gorm:"column:include_debrid_stream_in_library" json:"includeDebridStreamInLibrary"` + StreamAutoSelect bool `gorm:"column:stream_auto_select" json:"streamAutoSelect"` + StreamPreferredResolution string `gorm:"column:stream_preferred_resolution" json:"streamPreferredResolution"` +} + +type DebridTorrentItem struct { + BaseModel + TorrentItemID string `gorm:"column:torrent_item_id" json:"torrentItemId"` + Destination string `gorm:"column:destination" json:"destination"` + Provider string `gorm:"column:provider" json:"provider"` + MediaId int `gorm:"column:media_id" json:"mediaId"` +} + +// +---------------------+ +// | Plugin | +// +---------------------+ + +type PluginData struct { + BaseModel + PluginID string `gorm:"column:plugin_id;index" json:"pluginId"` + Data []byte `gorm:"column:data" json:"data"` +} + +/////////////////////////////////////////////////////////////////////////// + +type StringSlice []string + +func (o *StringSlice) Scan(src interface{}) error { + str, ok := src.(string) + if !ok { + return errors.New("src value cannot cast to string") + } + *o = strings.Split(str, ",") + return nil +} +func (o StringSlice) Value() (driver.Value, error) { + if len(o) == 0 { + return nil, nil + } + return strings.Join(o, ","), nil +} diff --git a/seanime-2.9.10/internal/database/models/models_helper.go b/seanime-2.9.10/internal/database/models/models_helper.go new file mode 100644 index 0000000..6ef3ffe --- /dev/null +++ b/seanime-2.9.10/internal/database/models/models_helper.go @@ -0,0 +1,93 @@ +package models + +func (s *Settings) GetMediaPlayer() *MediaPlayerSettings { + if s == nil || s.MediaPlayer == nil { + return &MediaPlayerSettings{} + } + return s.MediaPlayer +} + +func (s *Settings) GetTorrent() *TorrentSettings { + if s == nil || s.Torrent == nil { + return &TorrentSettings{} + } + return s.Torrent +} + +func (s *Settings) GetAnilist() *AnilistSettings { + if s == nil || s.Anilist == nil { + return &AnilistSettings{} + } + return s.Anilist +} + +func (s *Settings) GetManga() *MangaSettings { + if s == nil || s.Manga == nil { + return &MangaSettings{} + } + return s.Manga +} + +func (s *Settings) GetLibrary() *LibrarySettings { + if s == nil || s.Library == nil { + return &LibrarySettings{} + } + return s.Library +} + +func (s *Settings) GetListSync() *ListSyncSettings { + if s == nil || s.ListSync == nil { + return &ListSyncSettings{} + } + return s.ListSync +} + +func (s *Settings) GetAutoDownloader() *AutoDownloaderSettings { + if s == nil || s.AutoDownloader == nil { + return &AutoDownloaderSettings{} + } + return s.AutoDownloader +} + +func (s *Settings) GetDiscord() *DiscordSettings { + if s == nil || s.Discord == nil { + return &DiscordSettings{} + } + return s.Discord +} + +func (s *Settings) GetNotifications() *NotificationSettings { + if s == nil || s.Notifications == nil { + return &NotificationSettings{} + } + return s.Notifications +} + +func (s *Settings) GetNakama() *NakamaSettings { + if s == nil || s.Nakama == nil { + return &NakamaSettings{} + } + return s.Nakama +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (s *Settings) GetSensitiveValues() []string { + if s == nil { + return []string{} + } + return []string{ + s.GetMediaPlayer().VlcPassword, + s.GetTorrent().QBittorrentPassword, + s.GetTorrent().TransmissionPassword, + } +} + +func (s *DebridSettings) GetSensitiveValues() []string { + if s == nil { + return []string{} + } + return []string{ + s.ApiKey, + } +} diff --git a/seanime-2.9.10/internal/debrid/client/download.go b/seanime-2.9.10/internal/debrid/client/download.go new file mode 100644 index 0000000..fe7acde --- /dev/null +++ b/seanime-2.9.10/internal/debrid/client/download.go @@ -0,0 +1,443 @@ +package debrid_client + +import ( + "context" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "runtime" + "seanime/internal/debrid/debrid" + "seanime/internal/events" + "seanime/internal/hook" + "seanime/internal/notifier" + "seanime/internal/util" + "seanime/internal/util/result" + "strings" + "sync" + "time" +) + +func (r *Repository) launchDownloadLoop(ctx context.Context) { + r.logger.Trace().Msg("debrid: Starting download loop") + go func() { + for { + select { + case <-ctx.Done(): + r.logger.Trace().Msg("debrid: Download loop destroy request received") + // Destroy the loop + return + case <-time.After(time.Minute * 1): + // Every minute, check if there are any completed downloads + provider, found := r.provider.Get() + if !found { + continue + } + + // Get the list of completed downloads + items, err := provider.GetTorrents() + if err != nil { + r.logger.Err(err).Msg("debrid: Failed to get torrents") + continue + } + + readyItems := make([]*debrid.TorrentItem, 0) + for _, item := range items { + if item.IsReady { + readyItems = append(readyItems, item) + } + } + + dbItems, err := r.db.GetDebridTorrentItems() + if err != nil { + r.logger.Err(err).Msg("debrid: Failed to get debrid torrent items") + continue + } + + for _, dbItem := range dbItems { + // Check if the item is ready for download + for _, readyItem := range readyItems { + if dbItem.TorrentItemID == readyItem.ID { + r.logger.Debug().Str("torrentItemId", dbItem.TorrentItemID).Msg("debrid: Torrent is ready for download") + // Remove the item from the database + err = r.db.DeleteDebridTorrentItemByDbId(dbItem.ID) + if err != nil { + r.logger.Err(err).Msg("debrid: Failed to remove debrid torrent item") + continue + } + time.Sleep(1 * time.Second) + // Download the torrent locally + err = r.downloadTorrentItem(readyItem.ID, readyItem.Name, dbItem.Destination) + if err != nil { + r.logger.Err(err).Msg("debrid: Failed to download torrent") + continue + } + } + } + } + + } + } + }() +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) DownloadTorrent(item debrid.TorrentItem, destination string) error { + return r.downloadTorrentItem(item.ID, item.Name, destination) +} + +type downloadStatus struct { + TotalBytes int64 + TotalSize int64 +} + +func (r *Repository) downloadTorrentItem(tId string, torrentName string, destination string) (err error) { + defer util.HandlePanicInModuleWithError("debrid/client/downloadTorrentItem", &err) + + provider, err := r.GetProvider() + if err != nil { + return err + } + + r.logger.Debug().Str("torrentName", torrentName).Str("destination", destination).Msg("debrid: Downloading torrent") + + // Get the download URL + downloadUrl, err := provider.GetTorrentDownloadUrl(debrid.DownloadTorrentOptions{ + ID: tId, + }) + if err != nil { + return err + } + + event := &DebridLocalDownloadRequestedEvent{ + TorrentName: torrentName, + Destination: destination, + DownloadUrl: downloadUrl, + } + err = hook.GlobalHookManager.OnDebridLocalDownloadRequested().Trigger(event) + if err != nil { + return err + } + + if event.DefaultPrevented { + r.logger.Debug().Msg("debrid: Download prevented by hook") + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + r.ctxMap.Set(tId, cancel) + + go func(ctx context.Context) { + defer func() { + cancel() + r.ctxMap.Delete(tId) + }() + + wg := sync.WaitGroup{} + downloadUrls := strings.Split(downloadUrl, ",") + downloadMap := result.NewResultMap[string, downloadStatus]() + + for _, url := range downloadUrls { + wg.Add(1) + go func(ctx context.Context, url string) { + defer wg.Done() + + // Download the file + ok := r.downloadFile(ctx, tId, url, destination, downloadMap) + if !ok { + return + } + }(ctx, url) + } + wg.Wait() + + r.sendDownloadCompletedEvent(tId) + notifier.GlobalNotifier.Notify(notifier.Debrid, fmt.Sprintf("Downloaded %q", torrentName)) + }(ctx) + + // Send a starting event + r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{ + "status": "downloading", + "itemID": tId, + "totalBytes": "0 B", + "totalSize": "-", + "speed": "", + }) + + return nil +} + +func (r *Repository) downloadFile(ctx context.Context, tId string, downloadUrl string, destination string, downloadMap *result.Map[string, downloadStatus]) (ok bool) { + defer util.HandlePanicInModuleThen("debrid/client/downloadFile", func() { + ok = false + }) + + // Create a cancellable HTTP request + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil) + if err != nil { + r.logger.Err(err).Str("downloadUrl", downloadUrl).Msg("debrid: Failed to create request") + return false + } + + _ = os.MkdirAll(destination, os.ModePerm) + + // Download the files to a temporary folder + tmpDirPath, err := os.MkdirTemp(destination, ".tmp-") + if err != nil { + r.logger.Err(err).Str("destination", destination).Msg("debrid: Failed to create temp folder") + r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to create temp folder: %v", err)) + return false + } + defer os.RemoveAll(tmpDirPath) // Clean up temp folder on exit + + if runtime.GOOS == "windows" { + r.logger.Debug().Str("tmpDirPath", tmpDirPath).Msg("debrid: Hiding temp folder") + util.HideFile(tmpDirPath) + time.Sleep(time.Millisecond * 500) + } + + // Execute the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + r.logger.Err(err).Str("downloadUrl", downloadUrl).Msg("debrid: Failed to execute request") + r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to execute download request: %v", err)) + return false + } + defer resp.Body.Close() + + // e.g. "my-torrent.zip", "downloaded_torrent" + filename := "downloaded_torrent" + ext := "" + + // Try to get the file name from the Content-Disposition header + hFilename, err := getFilenameFromHeaders(downloadUrl) + if err == nil { + r.logger.Warn().Str("newFilename", hFilename).Str("defaultFilename", filename).Msg("debrid: Filename found in headers, overriding default") + filename = hFilename + } + + if ct := resp.Header.Get("Content-Type"); ct != "" { + mediaType, _, err := mime.ParseMediaType(ct) + if err == nil { + switch mediaType { + case "application/zip": + ext = ".zip" + case "application/x-rar-compressed": + ext = ".rar" + default: + } + r.logger.Debug().Str("mediaType", mediaType).Str("ext", ext).Msg("debrid: Detected media type and extension") + } + } + + if filename == "downloaded_torrent" && ext != "" { + filename = fmt.Sprintf("%s%s", filename, ext) + } + + // Check if the download URL has the extension + urlExt := filepath.Ext(downloadUrl) + if filename == "downloaded_torrent" && urlExt != "" { + filename = filepath.Base(downloadUrl) + filename, _ = url.PathUnescape(filename) + ext = urlExt + r.logger.Warn().Str("urlExt", urlExt).Str("filename", filename).Str("downloadUrl", downloadUrl).Msg("debrid: Extension found in URL, using it as file extension and file name") + } + + r.logger.Debug().Str("filename", filename).Str("ext", ext).Msg("debrid: Starting download") + + // Create a file in the temporary folder to store the download + // e.g. "/tmp/torrent-123456789/my-torrent.zip" + tmpDownloadedFilePath := filepath.Join(tmpDirPath, filename) + file, err := os.Create(tmpDownloadedFilePath) + if err != nil { + r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to create temp file") + r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to create temp file: %v", err)) + return false + } + + totalSize := resp.ContentLength + speed := 0 + + lastSent := time.Now() + + // Copy response body to the temporary file + buffer := make([]byte, 32*1024) + var totalBytes int64 + var lastBytes int64 + for { + n, err := resp.Body.Read(buffer) + if n > 0 { + _, writeErr := file.Write(buffer[:n]) + if writeErr != nil { + _ = file.Close() + r.logger.Err(writeErr).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to write to temp file") + r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Download failed / Failed to write to temp file: %v", writeErr)) + r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap) + return false + } + totalBytes += int64(n) + if totalSize > 0 { + speed = int((totalBytes - lastBytes) / 1024) // KB/s + lastBytes = totalBytes + } + + downloadMap.Set(downloadUrl, downloadStatus{ + TotalBytes: totalBytes, + TotalSize: totalSize, + }) + + if time.Since(lastSent) > time.Second*2 { + _totalBytes := uint64(0) + _totalSize := uint64(0) + downloadMap.Range(func(key string, value downloadStatus) bool { + _totalBytes += uint64(value.TotalBytes) + _totalSize += uint64(value.TotalSize) + return true + }) + // Notify progress + r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{ + "status": "downloading", + "itemID": tId, + "totalBytes": util.Bytes(_totalBytes), + "totalSize": util.Bytes(_totalSize), + "speed": speed, + }) + lastSent = time.Now() + } + } + if err != nil { + if err == io.EOF { + break + } + if errors.Is(err, context.Canceled) { + _ = file.Close() + r.logger.Debug().Msg("debrid: Download cancelled") + r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap) + return false + } + _ = file.Close() + r.logger.Err(err).Str("downloadUrl", downloadUrl).Msg("debrid: Failed to read from response body") + r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Download failed / Failed to read from response body: %v", err)) + r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap) + return false + } + } + + _ = file.Close() + + downloadMap.Delete(downloadUrl) + + if len(downloadMap.Values()) == 0 { + r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{ + "status": "downloading", + "itemID": tId, + "totalBytes": "Extracting...", + "totalSize": "-", + "speed": "", + }) + } + + r.logger.Debug().Msg("debrid: Download completed") + + switch runtime.GOOS { + case "windows": + time.Sleep(time.Second * 1) + } + + // Extract the downloaded file + var extractedDir string + switch ext { + case ".zip": + extractedDir, err = unzipFile(tmpDownloadedFilePath, tmpDirPath) + r.logger.Debug().Str("extractedDir", extractedDir).Msg("debrid: Extracted zip file") + case ".rar": + extractedDir, err = unrarFile(tmpDownloadedFilePath, tmpDirPath) + r.logger.Debug().Str("extractedDir", extractedDir).Msg("debrid: Extracted rar file") + default: + r.logger.Debug().Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Str("destination", destination).Msg("debrid: No extraction needed, moving file directly") + // Move the file directly to the destination + err = moveFolderOrFileTo(tmpDownloadedFilePath, destination) + if err != nil { + r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Str("destination", destination).Msg("debrid: Failed to move downloaded file") + r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to move downloaded file: %v", err)) + r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap) + return false + } + return true + } + if err != nil { + r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to extract downloaded file") + r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to extract downloaded file: %v", err)) + r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap) + return false + } + + r.logger.Debug().Msg("debrid: Extraction completed, deleting temporary files") + + // Delete the downloaded file + err = os.Remove(tmpDownloadedFilePath) + if err != nil { + r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to delete downloaded file") + // Do not stop here, continue with the extracted files + } + + r.logger.Debug().Str("extractedDir", extractedDir).Str("destination", destination).Msg("debrid: Moving extracted files to destination") + + // Move the extracted files to the destination + err = moveContentsTo(extractedDir, destination) + if err != nil { + r.logger.Err(err).Str("extractedDir", extractedDir).Str("destination", destination).Msg("debrid: Failed to move downloaded files") + r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to move downloaded files: %v", err)) + r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap) + return false + } + + return true +} + +func (r *Repository) sendDownloadCancelledEvent(tId string, url string, downloadMap *result.Map[string, downloadStatus]) { + downloadMap.Delete(url) + + if len(downloadMap.Values()) == 0 { + r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{ + "status": "cancelled", + "itemID": tId, + }) + } +} + +func (r *Repository) sendDownloadCompletedEvent(tId string) { + r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{ + "status": "completed", + "itemID": tId, + }) +} + +func getFilenameFromHeaders(url string) (string, error) { + resp, err := http.Head(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + // Get the Content-Disposition header + contentDisposition := resp.Header.Get("Content-Disposition") + if contentDisposition == "" { + return "", fmt.Errorf("no Content-Disposition header found") + } + + // Use a regex to extract the filename from Content-Disposition + re := regexp.MustCompile(`filename="(.+)"`) + matches := re.FindStringSubmatch(contentDisposition) + if len(matches) > 1 { + return matches[1], nil + } + return "", fmt.Errorf("filename not found in Content-Disposition header") +} diff --git a/seanime-2.9.10/internal/debrid/client/download_test.go b/seanime-2.9.10/internal/debrid/client/download_test.go new file mode 100644 index 0000000..dc4ddfa --- /dev/null +++ b/seanime-2.9.10/internal/debrid/client/download_test.go @@ -0,0 +1,110 @@ +package debrid_client + +import ( + "context" + "fmt" + "github.com/stretchr/testify/require" + "os" + "seanime/internal/database/db" + "seanime/internal/database/models" + "seanime/internal/debrid/debrid" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + "time" +) + +func TestTorBoxDownload(t *testing.T) { + test_utils.InitTestProvider(t) + + logger := util.NewLogger() + database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger) + require.NoError(t, err) + + repo := GetMockRepository(t, database) + + err = repo.InitializeProvider(&models.DebridSettings{ + Enabled: true, + Provider: "torbox", + ApiKey: test_utils.ConfigData.Provider.TorBoxApiKey, + }) + require.NoError(t, err) + + tempDestinationDir := t.TempDir() + + fmt.Println(tempDestinationDir) + + // + // Test download + // + torrentItemId := "116389" + + err = database.InsertDebridTorrentItem(&models.DebridTorrentItem{ + TorrentItemID: torrentItemId, + Destination: tempDestinationDir, + Provider: "torbox", + MediaId: 0, // Not yet used + }) + require.NoError(t, err) + + // Get the provider + provider, err := repo.GetProvider() + require.NoError(t, err) + + // Get the torrents from the provider + torrentItems, err := provider.GetTorrents() + require.NoError(t, err) + + // Get the torrent item from the database + dbTorrentItem, err := database.GetDebridTorrentItemByTorrentItemId(torrentItemId) + + // Select the torrent item from the provider + var torrentItem *debrid.TorrentItem + for _, item := range torrentItems { + if item.ID == dbTorrentItem.TorrentItemID { + torrentItem = item + } + } + require.NotNil(t, torrentItem) + + // Check if the torrent is ready + require.Truef(t, torrentItem.IsReady, "Torrent is not ready") + + // Remove the item from the database + err = database.DeleteDebridTorrentItemByDbId(dbTorrentItem.ID) + require.NoError(t, err) + + // Download the torrent + err = repo.downloadTorrentItem(dbTorrentItem.TorrentItemID, torrentItem.Name, dbTorrentItem.Destination) + require.NoError(t, err) + + time.Sleep(time.Millisecond * 500) + + // Wait for the download to finish +loop: + for { + select { + case <-time.After(time.Second * 1): + isEmpty := true + repo.ctxMap.Range(func(key string, value context.CancelFunc) bool { + isEmpty = false + return true + }) + if isEmpty { + break loop + } + } + } + + // Check if the file exists + entries, err := os.ReadDir(tempDestinationDir) + require.NoError(t, err) + + fmt.Println("=== Downloaded files ===") + + for _, entry := range entries { + util.Spew(entry.Name()) + } + + require.NotEmpty(t, entries) +} diff --git a/seanime-2.9.10/internal/debrid/client/finder.go b/seanime-2.9.10/internal/debrid/client/finder.go new file mode 100644 index 0000000..91705dc --- /dev/null +++ b/seanime-2.9.10/internal/debrid/client/finder.go @@ -0,0 +1,391 @@ +package debrid_client + +import ( + "cmp" + "context" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/debrid/debrid" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/hook" + torrentanalyzer "seanime/internal/torrents/analyzer" + itorrent "seanime/internal/torrents/torrent" + "seanime/internal/util" + "slices" + "strconv" + + "github.com/samber/lo" +) + +func (r *Repository) findBestTorrent(provider debrid.Provider, media *anilist.CompleteAnime, episodeNumber int) (selectedTorrent *hibiketorrent.AnimeTorrent, fileId string, err error) { + + defer util.HandlePanicInModuleWithError("debridstream/findBestTorrent", &err) + + r.logger.Debug().Msgf("debridstream: Finding best torrent for %s, Episode %d", media.GetTitleSafe(), episodeNumber) + + providerId := itorrent.ProviderAnimeTosho + fallbackProviderId := itorrent.ProviderNyaa + + // Get AnimeTosho provider extension + providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(providerId) + if !ok { + r.logger.Error().Str("provider", itorrent.ProviderAnimeTosho).Msg("debridstream: AnimeTosho provider extension not found") + return nil, "", fmt.Errorf("provider extension not found") + } + + searchBatch := false + canSearchBatch := !media.IsMovie() && media.IsFinished() + if canSearchBatch { + searchBatch = true + } + + loopCount := 0 + var currentProvider = providerId + + var data *itorrent.SearchData +searchLoop: + for { + data, err = r.torrentRepository.SearchAnime(context.Background(), itorrent.AnimeSearchOptions{ + Provider: currentProvider, + Type: itorrent.AnimeSearchTypeSmart, + Media: media.ToBaseAnime(), + Query: "", + Batch: searchBatch, + EpisodeNumber: episodeNumber, + BestReleases: false, + Resolution: r.settings.StreamPreferredResolution, + }) + // If we are searching for batches, we don't want to return an error if no torrents are found + // We will just search again without the batch flag + if err != nil { + if !searchBatch { + r.logger.Error().Err(err).Msg("debridstream: Error searching torrents") + + // Try fallback provider if we're still on primary provider + if currentProvider == providerId { + r.logger.Debug().Msgf("debridstream: Primary provider failed, trying fallback provider %s", fallbackProviderId) + currentProvider = fallbackProviderId + // Get fallback provider extension + providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider) + if !ok { + r.logger.Error().Str("provider", fallbackProviderId).Msg("debridstream: Fallback provider extension not found") + return nil, "", fmt.Errorf("fallback provider extension not found") + } + continue + } + + return nil, "", err + } + searchBatch = false + continue + } + + // Get cached + hashes := make([]string, 0) + for _, t := range data.Torrents { + if t.InfoHash == "" { + continue + } + hashes = append(hashes, t.InfoHash) + } + instantAvail := provider.GetInstantAvailability(hashes) + data.DebridInstantAvailability = instantAvail + + // If we are searching for batches, we want to filter out torrents that are not cached + if searchBatch { + // Nothing found, search again without the batch flag + if len(data.Torrents) == 0 { + searchBatch = false + loopCount++ + continue + } + if len(data.DebridInstantAvailability) > 0 { + r.logger.Debug().Msg("debridstream: Found cached instant availability") + data.Torrents = lo.Filter(data.Torrents, func(t *hibiketorrent.AnimeTorrent, i int) bool { + _, isCached := data.DebridInstantAvailability[t.InfoHash] + return isCached + }) + break searchLoop + } + // If we didn't find any cached batches, we will search again without the batch flag + searchBatch = false + loopCount++ + continue + } + + // If on the first try were looking for file torrents but found no cached ones, we will search again for batches + if loopCount == 0 && canSearchBatch && len(data.DebridInstantAvailability) == 0 { + searchBatch = true + loopCount++ + continue + } + + // Stop looking if either we found cached torrents or no cached batches were found + break searchLoop + } + + if data == nil || len(data.Torrents) == 0 { + // Try fallback provider if we're still on primary provider + if currentProvider == providerId { + r.logger.Debug().Msgf("debridstream: No torrents found with primary provider, trying fallback provider %s", fallbackProviderId) + currentProvider = fallbackProviderId + // Get fallback provider extension + providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider) + if !ok { + r.logger.Error().Str("provider", fallbackProviderId).Msg("debridstream: Fallback provider extension not found") + return nil, "", fmt.Errorf("fallback provider extension not found") + } + + // Try searching with fallback provider (reset searchBatch based on canSearchBatch) + searchBatch = false + if canSearchBatch { + searchBatch = true + } + loopCount = 0 + + // Restart the search with fallback provider + goto searchLoop + } + + r.logger.Error().Msg("debridstream: No torrents found") + return nil, "", fmt.Errorf("no torrents found") + } + + // Sort by seeders from highest to lowest + slices.SortStableFunc(data.Torrents, func(a, b *hibiketorrent.AnimeTorrent) int { + return cmp.Compare(b.Seeders, a.Seeders) + }) + + // Trigger hook + fetchedEvent := &DebridAutoSelectTorrentsFetchedEvent{ + Torrents: data.Torrents, + } + _ = hook.GlobalHookManager.OnDebridAutoSelectTorrentsFetched().Trigger(fetchedEvent) + data.Torrents = fetchedEvent.Torrents + + r.logger.Debug().Msgf("debridstream: Found %d torrents", len(data.Torrents)) + + hashes := make([]string, 0) + for _, t := range data.Torrents { + if t.InfoHash == "" { + continue + } + hashes = append(hashes, t.InfoHash) + } + + // Find cached torrent + instantAvail := provider.GetInstantAvailability(hashes) + data.DebridInstantAvailability = instantAvail + + // Filter out torrents that are not cached if we have cached instant availability + if len(data.DebridInstantAvailability) > 0 { + r.logger.Debug().Msg("debridstream: Found cached instant availability") + data.Torrents = lo.Filter(data.Torrents, func(t *hibiketorrent.AnimeTorrent, i int) bool { + _, isCached := data.DebridInstantAvailability[t.InfoHash] + return isCached + }) + } + + tries := 0 + + for _, searchT := range data.Torrents { + if tries >= 2 { + break + } + + r.logger.Trace().Msgf("debridstream: Getting torrent magnet for %s", searchT.Name) + magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(searchT) + if err != nil { + r.logger.Warn().Err(err).Msgf("debridstream: Error scraping magnet link for %s", searchT.Link) + tries++ + continue + } + + // Set the magnet link + searchT.MagnetLink = magnet + + r.logger.Debug().Msgf("debridstream: Adding torrent %s from magnet", searchT.Link) + + // Get the torrent info + // On Real-Debrid, this will add the torrent + info, err := provider.GetTorrentInfo(debrid.GetTorrentInfoOptions{ + MagnetLink: searchT.MagnetLink, + InfoHash: searchT.InfoHash, + }) + if err != nil { + r.logger.Warn().Err(err).Msgf("debridstream: Error adding torrent %s", searchT.Link) + tries++ + continue + } + + filepaths := lo.Map(info.Files, func(f *debrid.TorrentItemFile, _ int) string { + return f.Path + }) + + if len(filepaths) == 0 { + r.logger.Error().Msg("debridstream: No files found in the torrent") + return nil, "", fmt.Errorf("no files found in the torrent") + } + + // Create a new Torrent Analyzer + analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{ + Logger: r.logger, + Filepaths: filepaths, + Media: media, + Platform: r.platform, + MetadataProvider: r.metadataProvider, + ForceMatch: true, + }) + + r.logger.Debug().Msgf("debridstream: Analyzing torrent %s", searchT.Link) + + // Analyze torrent files + analysis, err := analyzer.AnalyzeTorrentFiles() + if err != nil { + r.logger.Warn().Err(err).Msg("debridstream: Error analyzing torrent files") + // Remove torrent on failure (if it was added) + //if info.ID != nil { + // go func() { + // _ = provider.DeleteTorrent(*info.ID) + // }() + //} + tries++ + continue + } + + r.logger.Debug().Int("count", len(analysis.GetFiles())).Msgf("debridstream: Analyzed torrent %s", searchT.Link) + + r.logger.Debug().Msgf("debridstream: Finding corresponding file for episode %s", strconv.Itoa(episodeNumber)) + + analysisFile, found := analysis.GetFileByAniDBEpisode(strconv.Itoa(episodeNumber)) + // Check if analyzer found the episode + if !found { + r.logger.Error().Msgf("debridstream: Failed to auto-select episode from torrent %s", searchT.Link) + // Remove torrent on failure + //if info.ID != nil { + // go func() { + // _ = provider.DeleteTorrent(*info.ID) + // }() + //} + tries++ + continue + } + + r.logger.Debug().Msgf("debridstream: Found corresponding file for episode %s: %s", strconv.Itoa(episodeNumber), analysisFile.GetLocalFile().Name) + + tFile := info.Files[analysisFile.GetIndex()] + r.logger.Debug().Str("file", util.SpewT(tFile)).Msgf("debridstream: Selected file %s", tFile.Name) + r.logger.Debug().Msgf("debridstream: Selected torrent %s", searchT.Name) + selectedTorrent = searchT + fileId = tFile.ID + + //go func() { + // _ = provider.DeleteTorrent(*info.ID) + //}() + break + } + + if selectedTorrent == nil { + return nil, "", fmt.Errorf("failed to find torrent") + } + + return +} + +// findBestTorrentFromManualSelection is like findBestTorrent but for a pre-selected torrent +func (r *Repository) findBestTorrentFromManualSelection(provider debrid.Provider, t *hibiketorrent.AnimeTorrent, media *anilist.CompleteAnime, episodeNumber int, chosenFileIndex *int) (selectedTorrent *hibiketorrent.AnimeTorrent, fileId string, err error) { + + r.logger.Debug().Msgf("debridstream: Analyzing torrent from %s for %s", t.Link, media.GetTitleSafe()) + + // Get the torrent's provider extension + providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(t.Provider) + if !ok { + r.logger.Error().Str("provider", t.Provider).Msg("debridstream: provider extension not found") + return nil, "", fmt.Errorf("provider extension not found") + } + + // Check if the torrent is cached + if t.InfoHash != "" { + instantAvail := provider.GetInstantAvailability([]string{t.InfoHash}) + if len(instantAvail) == 0 { + r.logger.Warn().Msg("debridstream: Torrent is not cached") + // We'll still continue since the user specifically selected this torrent + } + } + + // Get the magnet link + magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(t) + if err != nil { + r.logger.Error().Err(err).Msgf("debridstream: Error scraping magnet link for %s", t.Link) + return nil, "", fmt.Errorf("could not get magnet link from %s", t.Link) + } + + // Set the magnet link + t.MagnetLink = magnet + + // Get the torrent info from the debrid provider + info, err := provider.GetTorrentInfo(debrid.GetTorrentInfoOptions{ + MagnetLink: t.MagnetLink, + InfoHash: t.InfoHash, + }) + if err != nil { + r.logger.Error().Err(err).Msgf("debridstream: Error adding torrent %s", t.Link) + return nil, "", err + } + + // If the torrent has only one file, return it + if len(info.Files) == 1 { + return t, info.Files[0].ID, nil + } + + var fileIndex int + + // If the file index is already selected + if chosenFileIndex != nil { + fileIndex = *chosenFileIndex + } else { + // We know the torrent has multiple files, so we'll need to analyze it + filepaths := lo.Map(info.Files, func(f *debrid.TorrentItemFile, _ int) string { + return f.Path + }) + + if len(filepaths) == 0 { + r.logger.Error().Msg("debridstream: No files found in the torrent") + return nil, "", fmt.Errorf("no files found in the torrent") + } + + // Create a new Torrent Analyzer + analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{ + Logger: r.logger, + Filepaths: filepaths, + Media: media, + Platform: r.platform, + MetadataProvider: r.metadataProvider, + ForceMatch: true, + }) + + // Analyze torrent files + analysis, err := analyzer.AnalyzeTorrentFiles() + if err != nil { + r.logger.Warn().Err(err).Msg("debridstream: Error analyzing torrent files") + return nil, "", err + } + + analysisFile, found := analysis.GetFileByAniDBEpisode(strconv.Itoa(episodeNumber)) + // Check if analyzer found the episode + if !found { + r.logger.Error().Msgf("debridstream: Failed to auto-select episode from torrent %s", t.Name) + return nil, "", fmt.Errorf("could not find episode %d in torrent", episodeNumber) + } + + r.logger.Debug().Msgf("debridstream: Found corresponding file for episode %s: %s", strconv.Itoa(episodeNumber), analysisFile.GetLocalFile().Name) + + fileIndex = analysisFile.GetIndex() + } + + tFile := info.Files[fileIndex] + r.logger.Debug().Str("file", util.SpewT(tFile)).Msgf("debridstream: Selected file %s", tFile.Name) + r.logger.Debug().Msgf("debridstream: Selected torrent %s", t.Name) + + return t, tFile.ID, nil +} diff --git a/seanime-2.9.10/internal/debrid/client/hook_events.go b/seanime-2.9.10/internal/debrid/client/hook_events.go new file mode 100644 index 0000000..377bedb --- /dev/null +++ b/seanime-2.9.10/internal/debrid/client/hook_events.go @@ -0,0 +1,44 @@ +package debrid_client + +import ( + "seanime/internal/api/anilist" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/hook_resolver" +) + +// DebridAutoSelectTorrentsFetchedEvent is triggered when the torrents are fetched for auto select. +// The torrents are sorted by seeders from highest to lowest. +// This event is triggered before the top 3 torrents are analyzed. +type DebridAutoSelectTorrentsFetchedEvent struct { + hook_resolver.Event + Torrents []*hibiketorrent.AnimeTorrent +} + +// DebridSkipStreamCheckEvent is triggered when the debrid client is about to skip the stream check. +// Prevent default to enable the stream check. +type DebridSkipStreamCheckEvent struct { + hook_resolver.Event + StreamURL string `json:"streamURL"` + Retries int `json:"retries"` + RetryDelay int `json:"retryDelay"` // in seconds +} + +// DebridSendStreamToMediaPlayerEvent is triggered when the debrid client is about to send a stream to the media player. +// Prevent default to skip the playback. +type DebridSendStreamToMediaPlayerEvent struct { + hook_resolver.Event + WindowTitle string `json:"windowTitle"` + StreamURL string `json:"streamURL"` + Media *anilist.BaseAnime `json:"media"` + AniDbEpisode string `json:"aniDbEpisode"` + PlaybackType string `json:"playbackType"` +} + +// DebridLocalDownloadRequestedEvent is triggered when Seanime is about to download a debrid torrent locally. +// Prevent default to skip the default download and override the download. +type DebridLocalDownloadRequestedEvent struct { + hook_resolver.Event + TorrentName string `json:"torrentName"` + Destination string `json:"destination"` + DownloadUrl string `json:"downloadUrl"` +} diff --git a/seanime-2.9.10/internal/debrid/client/mock.go b/seanime-2.9.10/internal/debrid/client/mock.go new file mode 100644 index 0000000..5e54696 --- /dev/null +++ b/seanime-2.9.10/internal/debrid/client/mock.go @@ -0,0 +1,45 @@ +package debrid_client + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/continuity" + "seanime/internal/database/db" + "seanime/internal/events" + "seanime/internal/library/playbackmanager" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/util" + "testing" +) + +func GetMockRepository(t *testing.T, db *db.Database) *Repository { + logger := util.NewLogger() + wsEventManager := events.NewWSEventManager(logger) + anilistClient := anilist.TestGetMockAnilistClient() + platform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + metadataProvider := metadata.GetMockProvider(t) + playbackManager := playbackmanager.New(&playbackmanager.NewPlaybackManagerOptions{ + WSEventManager: wsEventManager, + Logger: logger, + Platform: platform, + MetadataProvider: metadataProvider, + Database: db, + RefreshAnimeCollectionFunc: func() { + // Do nothing + }, + DiscordPresence: nil, + IsOffline: &[]bool{false}[0], + ContinuityManager: continuity.GetMockManager(t, db), + }) + + r := NewRepository(&NewRepositoryOptions{ + Logger: logger, + WSEventManager: wsEventManager, + Database: db, + MetadataProvider: metadataProvider, + Platform: platform, + PlaybackManager: playbackManager, + }) + + return r +} diff --git a/seanime-2.9.10/internal/debrid/client/previews.go b/seanime-2.9.10/internal/debrid/client/previews.go new file mode 100644 index 0000000..1a63b37 --- /dev/null +++ b/seanime-2.9.10/internal/debrid/client/previews.go @@ -0,0 +1,132 @@ +package debrid_client + +import ( + "fmt" + "github.com/5rahim/habari" + "seanime/internal/api/anilist" + "seanime/internal/debrid/debrid" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/util" + "seanime/internal/util/comparison" + "sync" +) + +type ( + FilePreview struct { + Path string `json:"path"` + DisplayPath string `json:"displayPath"` + DisplayTitle string `json:"displayTitle"` + EpisodeNumber int `json:"episodeNumber"` + RelativeEpisodeNumber int `json:"relativeEpisodeNumber"` + IsLikely bool `json:"isLikely"` + Index int `json:"index"` + FileId string `json:"fileId"` + } + + GetTorrentFilePreviewsOptions struct { + Torrent *hibiketorrent.AnimeTorrent + Magnet string + EpisodeNumber int + AbsoluteOffset int + Media *anilist.BaseAnime + } +) + +func (r *Repository) GetTorrentFilePreviewsFromManualSelection(opts *GetTorrentFilePreviewsOptions) (ret []*FilePreview, err error) { + defer util.HandlePanicInModuleWithError("debrid_client/GetTorrentFilePreviewsFromManualSelection", &err) + + if opts.Torrent == nil || opts.Magnet == "" || opts.Media == nil { + return nil, fmt.Errorf("torrentstream: Invalid options") + } + + r.logger.Trace().Str("hash", opts.Torrent.InfoHash).Msg("debridstream: Getting file previews for torrent selection") + + torrentInfo, err := r.GetTorrentInfo(debrid.GetTorrentInfoOptions{ + MagnetLink: opts.Magnet, + InfoHash: opts.Torrent.InfoHash, + }) + if err != nil { + r.logger.Error().Err(err).Msgf("debridstream: Error adding torrent %s", opts.Magnet) + return nil, err + } + + fileMetadataMap := make(map[string]*habari.Metadata) + wg := sync.WaitGroup{} + mu := sync.RWMutex{} + wg.Add(len(torrentInfo.Files)) + for _, file := range torrentInfo.Files { + go func(file *debrid.TorrentItemFile) { + defer wg.Done() + defer util.HandlePanicInModuleThen("debridstream/GetTorrentFilePreviewsFromManualSelection", func() {}) + + metadata := habari.Parse(file.Path) + mu.Lock() + fileMetadataMap[file.Path] = metadata + mu.Unlock() + }(file) + } + wg.Wait() + + containsAbsoluteEps := false + for _, metadata := range fileMetadataMap { + if len(metadata.EpisodeNumber) == 1 { + ep := util.StringToIntMust(metadata.EpisodeNumber[0]) + if ep > opts.Media.GetTotalEpisodeCount() { + containsAbsoluteEps = true + break + } + } + } + + wg = sync.WaitGroup{} + mu2 := sync.Mutex{} + + for i, file := range torrentInfo.Files { + wg.Add(1) + go func(i int, file *debrid.TorrentItemFile) { + defer wg.Done() + defer util.HandlePanicInModuleThen("debridstream/GetTorrentFilePreviewsFromManualSelection", func() {}) + + mu.RLock() + metadata, found := fileMetadataMap[file.Path] + mu.RUnlock() + + displayTitle := file.Path + + isLikely := false + parsedEpisodeNumber := -1 + + if found && !comparison.ValueContainsSpecial(file.Name) && !comparison.ValueContainsNC(file.Name) { + if len(metadata.EpisodeNumber) == 1 { + ep := util.StringToIntMust(metadata.EpisodeNumber[0]) + parsedEpisodeNumber = ep + displayTitle = fmt.Sprintf("Episode %d", ep) + if metadata.EpisodeTitle != "" { + displayTitle = fmt.Sprintf("%s - %s", displayTitle, metadata.EpisodeTitle) + } + } + } + + if !containsAbsoluteEps { + isLikely = parsedEpisodeNumber == opts.EpisodeNumber + } + + mu2.Lock() + // Get the file preview + ret = append(ret, &FilePreview{ + Path: file.Path, + DisplayPath: file.Path, + DisplayTitle: displayTitle, + EpisodeNumber: parsedEpisodeNumber, + IsLikely: isLikely, + FileId: file.ID, + Index: i, + }) + mu2.Unlock() + }(i, file) + } + + wg.Wait() + + return +} diff --git a/seanime-2.9.10/internal/debrid/client/repository.go b/seanime-2.9.10/internal/debrid/client/repository.go new file mode 100644 index 0000000..bc80bdb --- /dev/null +++ b/seanime-2.9.10/internal/debrid/client/repository.go @@ -0,0 +1,255 @@ +package debrid_client + +import ( + "context" + "fmt" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/database/db" + "seanime/internal/database/models" + "seanime/internal/debrid/debrid" + "seanime/internal/debrid/realdebrid" + "seanime/internal/debrid/torbox" + "seanime/internal/directstream" + "seanime/internal/events" + "seanime/internal/library/playbackmanager" + "seanime/internal/platforms/platform" + "seanime/internal/torrents/torrent" + "seanime/internal/util/result" + + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +var ( + ErrProviderNotSet = fmt.Errorf("debrid: Provider not set") +) + +type ( + Repository struct { + provider mo.Option[debrid.Provider] + logger *zerolog.Logger + db *db.Database + settings *models.DebridSettings + wsEventManager events.WSEventManagerInterface + ctxMap *result.Map[string, context.CancelFunc] + downloadLoopCancelFunc context.CancelFunc + torrentRepository *torrent.Repository + directStreamManager *directstream.Manager + + playbackManager *playbackmanager.PlaybackManager + streamManager *StreamManager + completeAnimeCache *anilist.CompleteAnimeCache + metadataProvider metadata.Provider + platform platform.Platform + + previousStreamOptions mo.Option[*StartStreamOptions] + } + + NewRepositoryOptions struct { + Logger *zerolog.Logger + WSEventManager events.WSEventManagerInterface + Database *db.Database + + TorrentRepository *torrent.Repository + PlaybackManager *playbackmanager.PlaybackManager + DirectStreamManager *directstream.Manager + MetadataProvider metadata.Provider + Platform platform.Platform + } +) + +func NewRepository(opts *NewRepositoryOptions) (ret *Repository) { + ret = &Repository{ + provider: mo.None[debrid.Provider](), + logger: opts.Logger, + wsEventManager: opts.WSEventManager, + db: opts.Database, + settings: &models.DebridSettings{ + Enabled: false, + }, + torrentRepository: opts.TorrentRepository, + platform: opts.Platform, + playbackManager: opts.PlaybackManager, + metadataProvider: opts.MetadataProvider, + completeAnimeCache: anilist.NewCompleteAnimeCache(), + ctxMap: result.NewResultMap[string, context.CancelFunc](), + previousStreamOptions: mo.None[*StartStreamOptions](), + directStreamManager: opts.DirectStreamManager, + } + + ret.streamManager = NewStreamManager(ret) + + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) startOrStopDownloadLoop() { + // Cancel the previous download loop if it's running + if r.downloadLoopCancelFunc != nil { + r.downloadLoopCancelFunc() + } + + // Start the download loop if the provider is set and enabled + if r.settings.Enabled && r.provider.IsPresent() { + ctx, cancel := context.WithCancel(context.Background()) + r.downloadLoopCancelFunc = cancel + r.launchDownloadLoop(ctx) + } +} + +// InitializeProvider is called each time the settings change +func (r *Repository) InitializeProvider(settings *models.DebridSettings) error { + r.settings = settings + + if !settings.Enabled { + r.provider = mo.None[debrid.Provider]() + // Stop the download loop if it's running + r.startOrStopDownloadLoop() + return nil + } + + switch settings.Provider { + case "torbox": + r.provider = mo.Some(torbox.NewTorBox(r.logger)) + case "realdebrid": + r.provider = mo.Some(realdebrid.NewRealDebrid(r.logger)) + default: + r.provider = mo.None[debrid.Provider]() + } + + if r.provider.IsAbsent() { + r.logger.Warn().Str("provider", settings.Provider).Msg("debrid: No provider set") + // Stop the download loop if it's running + r.startOrStopDownloadLoop() + return nil + } + + // Authenticate the provider + err := r.provider.MustGet().Authenticate(r.settings.ApiKey) + if err != nil { + r.logger.Err(err).Msg("debrid: Failed to authenticate") + r.provider = mo.None[debrid.Provider]() + // Cancel the download loop if it's running + if r.downloadLoopCancelFunc != nil { + r.downloadLoopCancelFunc() + } + return err + } + + // Start the download loop + r.startOrStopDownloadLoop() + + return nil +} + +func (r *Repository) GetProvider() (debrid.Provider, error) { + p, found := r.provider.Get() + if !found { + return nil, ErrProviderNotSet + } + + return p, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// AddAndQueueTorrent adds a torrent to the debrid service and queues it for automatic download +func (r *Repository) AddAndQueueTorrent(opts debrid.AddTorrentOptions, destination string, mId int) (string, error) { + provider, err := r.GetProvider() + if err != nil { + return "", err + } + + if !filepath.IsAbs(destination) { + return "", fmt.Errorf("debrid: Failed to add torrent, destination must be an absolute path") + } + + // Add the torrent to the debrid service + torrentItemId, err := provider.AddTorrent(opts) + if err != nil { + return "", err + } + + // Add the torrent item to the database (so it can be downloaded automatically once it's ready) + // We ignore the error since it's non-critical + _ = r.db.InsertDebridTorrentItem(&models.DebridTorrentItem{ + TorrentItemID: torrentItemId, + Destination: destination, + Provider: provider.GetSettings().ID, + MediaId: mId, + }) + + return torrentItemId, nil +} + +// GetTorrentInfo retrieves information about a torrent. +// This is used for file section for debrid streaming. +// On Real Debrid, this adds the torrent to the user's account. +func (r *Repository) GetTorrentInfo(opts debrid.GetTorrentInfoOptions) (*debrid.TorrentInfo, error) { + provider, err := r.GetProvider() + if err != nil { + return nil, err + } + + torrentInfo, err := provider.GetTorrentInfo(opts) + if err != nil { + return nil, err + } + + // Remove non-video files + torrentInfo.Files = debrid.FilterVideoFiles(torrentInfo.Files) + + return torrentInfo, nil +} + +func (r *Repository) HasProvider() bool { + return r.provider.IsPresent() +} + +func (r *Repository) GetSettings() *models.DebridSettings { + return r.settings +} + +// CancelDownload cancels the download for the given item ID +func (r *Repository) CancelDownload(itemID string) error { + cancelFunc, found := r.ctxMap.Get(itemID) + if !found { + return fmt.Errorf("no download found for item ID: %s", itemID) + } + + // Call the cancel function to cancel the download + if cancelFunc != nil { + cancelFunc() + } + + r.ctxMap.Delete(itemID) + + // Notify that the download has been cancelled + r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{ + "status": "cancelled", + "itemID": itemID, + }) + + return nil +} + +func (r *Repository) StartStream(ctx context.Context, opts *StartStreamOptions) error { + return r.streamManager.startStream(ctx, opts) +} + +func (r *Repository) GetStreamURL() (string, bool) { + return r.streamManager.currentStreamUrl, r.streamManager.currentStreamUrl != "" +} + +func (r *Repository) CancelStream(opts *CancelStreamOptions) { + r.streamManager.cancelStream(opts) +} + +func (r *Repository) GetPreviousStreamOptions() (*StartStreamOptions, bool) { + return r.previousStreamOptions.Get() +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/seanime-2.9.10/internal/debrid/client/stream.go b/seanime-2.9.10/internal/debrid/client/stream.go new file mode 100644 index 0000000..3294d3e --- /dev/null +++ b/seanime-2.9.10/internal/debrid/client/stream.go @@ -0,0 +1,567 @@ +package debrid_client + +import ( + "context" + "errors" + "fmt" + "seanime/internal/database/db_bridge" + "seanime/internal/debrid/debrid" + "seanime/internal/directstream" + "seanime/internal/events" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/hook" + "seanime/internal/library/playbackmanager" + "seanime/internal/util" + "strconv" + "sync" + "time" + + "github.com/samber/mo" +) + +type ( + StreamManager struct { + repository *Repository + currentTorrentItemId string + downloadCtxCancelFunc context.CancelFunc + + currentStreamUrl string + + playbackSubscriberCtxCancelFunc context.CancelFunc + } + + StreamPlaybackType string + + StreamStatus string + + StreamState struct { + Status StreamStatus `json:"status"` + TorrentName string `json:"torrentName"` + Message string `json:"message"` + } + + StartStreamOptions struct { + MediaId int + EpisodeNumber int // RELATIVE Episode number to identify the file + AniDBEpisode string // Anizip episode + Torrent *hibiketorrent.AnimeTorrent // Selected torrent + FileId string // File ID or index + FileIndex *int // Index of the file to stream (Manual selection) + UserAgent string + ClientId string + PlaybackType StreamPlaybackType + AutoSelect bool + } + + CancelStreamOptions struct { + // Whether to remove the torrent from the debrid service + RemoveTorrent bool `json:"removeTorrent"` + } +) + +const ( + StreamStatusDownloading StreamStatus = "downloading" + StreamStatusReady StreamStatus = "ready" + StreamStatusFailed StreamStatus = "failed" + StreamStatusStarted StreamStatus = "started" +) + +func NewStreamManager(repository *Repository) *StreamManager { + return &StreamManager{ + repository: repository, + currentTorrentItemId: "", + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +const ( + PlaybackTypeNone StreamPlaybackType = "none" + PlaybackTypeNoneAndAwait StreamPlaybackType = "noneAndAwait" + PlaybackTypeDefault StreamPlaybackType = "default" + PlaybackTypeNativePlayer StreamPlaybackType = "nativeplayer" + PlaybackTypeExternalPlayer StreamPlaybackType = "externalPlayerLink" +) + +// startStream is called by the client to start streaming a torrent +func (s *StreamManager) startStream(ctx context.Context, opts *StartStreamOptions) (err error) { + defer util.HandlePanicInModuleWithError("debrid/client/StartStream", &err) + + s.repository.previousStreamOptions = mo.Some(opts) + + s.repository.logger.Info(). + Str("clientId", opts.ClientId). + Any("playbackType", opts.PlaybackType). + Int("mediaId", opts.MediaId).Msgf("debridstream: Starting stream for episode %s", opts.AniDBEpisode) + + // Cancel the download context if it's running + if s.downloadCtxCancelFunc != nil { + s.downloadCtxCancelFunc() + s.downloadCtxCancelFunc = nil + } + + if s.playbackSubscriberCtxCancelFunc != nil { + s.playbackSubscriberCtxCancelFunc() + s.playbackSubscriberCtxCancelFunc = nil + } + + provider, err := s.repository.GetProvider() + if err != nil { + return fmt.Errorf("debridstream: Failed to start stream: %w", err) + } + + s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream") + //defer func() { + // s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream") + //}() + + if opts.PlaybackType == PlaybackTypeNativePlayer { + s.repository.directStreamManager.PrepareNewStream(opts.ClientId, "Selecting torrent...") + } + + // + // Get the media info + // + media, _, err := s.getMediaInfo(ctx, opts.MediaId) + if err != nil { + s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream") + return err + } + + episodeNumber := opts.EpisodeNumber + aniDbEpisode := strconv.Itoa(episodeNumber) + + selectedTorrent := opts.Torrent + fileId := opts.FileId + + if opts.AutoSelect { + + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusDownloading, + TorrentName: "-", + Message: "Selecting best torrent...", + }) + + st, fi, err := s.repository.findBestTorrent(provider, media, opts.EpisodeNumber) + if err != nil { + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusFailed, + TorrentName: "-", + Message: fmt.Sprintf("Failed to select best torrent, %v", err), + }) + s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream") + return fmt.Errorf("debridstream: Failed to start stream: %w", err) + } + selectedTorrent = st + fileId = fi + } else { + // Manual selection + if selectedTorrent == nil { + s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream") + return fmt.Errorf("debridstream: Failed to start stream, no torrent provided") + } + + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusDownloading, + TorrentName: selectedTorrent.Name, + Message: "Analyzing selected torrent...", + }) + + // If no fileId is provided, we need to analyze the torrent to find the correct file + if fileId == "" { + var chosenFileIndex *int + if opts.FileIndex != nil { + chosenFileIndex = opts.FileIndex + } + st, fi, err := s.repository.findBestTorrentFromManualSelection(provider, selectedTorrent, media, opts.EpisodeNumber, chosenFileIndex) + if err != nil { + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusFailed, + TorrentName: selectedTorrent.Name, + Message: fmt.Sprintf("Failed to analyze torrent, %v", err), + }) + s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream") + return fmt.Errorf("debridstream: Failed to analyze torrent: %w", err) + } + selectedTorrent = st + fileId = fi + } + } + + if selectedTorrent == nil { + s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream") + return fmt.Errorf("debridstream: Failed to start stream, no torrent provided") + } + + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusDownloading, + TorrentName: selectedTorrent.Name, + Message: "Adding torrent...", + }) + + // Add the torrent to the debrid service + torrentItemId, err := provider.AddTorrent(debrid.AddTorrentOptions{ + MagnetLink: selectedTorrent.MagnetLink, + InfoHash: selectedTorrent.InfoHash, + SelectFileId: fileId, // RD-only, download only the selected file + }) + if err != nil { + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusFailed, + TorrentName: selectedTorrent.Name, + Message: fmt.Sprintf("Failed to add torrent, %v", err), + }) + s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream") + return fmt.Errorf("debridstream: Failed to add torrent: %w", err) + } + + time.Sleep(1 * time.Second) + + // Save the current torrent item id + s.currentTorrentItemId = torrentItemId + ctx, cancelCtx := context.WithCancel(context.Background()) + s.downloadCtxCancelFunc = cancelCtx + + readyCh := make(chan struct{}) + readyOnce := sync.Once{} + ready := func() { + readyOnce.Do(func() { + close(readyCh) + }) + } + + // Launch a goroutine that will listen to the added torrent's status + go func(ctx context.Context) { + defer util.HandlePanicInModuleThen("debrid/client/StartStream", func() {}) + defer func() { + s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream") + }() + + defer func() { + // Cancel the context + if s.downloadCtxCancelFunc != nil { + s.downloadCtxCancelFunc() + s.downloadCtxCancelFunc = nil + } + }() + + s.repository.logger.Debug().Msg("debridstream: Listening to torrent status") + + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusDownloading, + TorrentName: selectedTorrent.Name, + Message: fmt.Sprintf("Downloading torrent..."), + }) + + itemCh := make(chan debrid.TorrentItem, 1) + + go func() { + for item := range itemCh { + if opts.PlaybackType == PlaybackTypeNativePlayer { + s.repository.directStreamManager.PrepareNewStream(opts.ClientId, fmt.Sprintf("Awaiting stream: %d%%", item.CompletionPercentage)) + } + + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusDownloading, + TorrentName: item.Name, + Message: fmt.Sprintf("Downloading torrent: %d%%", item.CompletionPercentage), + }) + } + }() + + // Await the stream URL + // For Torbox, this will wait until the entire torrent is downloaded + streamUrl, err := provider.GetTorrentStreamUrl(ctx, debrid.StreamTorrentOptions{ + ID: torrentItemId, + FileId: fileId, + }, itemCh) + + go func() { + close(itemCh) + }() + + if ctx.Err() != nil { + s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream") + ready() + return + } + + if err != nil { + s.repository.logger.Err(err).Msg("debridstream: Failed to get stream URL") + if !errors.Is(err, context.Canceled) { + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusFailed, + TorrentName: selectedTorrent.Name, + Message: fmt.Sprintf("Failed to get stream URL, %v", err), + }) + } + ready() + return + } + + skipCheckEvent := &DebridSkipStreamCheckEvent{ + StreamURL: streamUrl, + Retries: 4, + RetryDelay: 8, + } + _ = hook.GlobalHookManager.OnDebridSkipStreamCheck().Trigger(skipCheckEvent) + streamUrl = skipCheckEvent.StreamURL + + // Default prevented, we check if we can stream the file + if skipCheckEvent.DefaultPrevented { + s.repository.logger.Debug().Msg("debridstream: Stream URL received, checking stream file") + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusDownloading, + TorrentName: selectedTorrent.Name, + Message: "Checking stream file...", + }) + + retries := 0 + + streamUrlCheckLoop: + for { // Retry loop for a total of 4 times (32 seconds) + select { + case <-ctx.Done(): + s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream") + return + default: + // Check if we can stream the URL + if canStream, reason := CanStream(streamUrl); !canStream { + if retries >= skipCheckEvent.Retries { + s.repository.logger.Error().Msg("debridstream: Cannot stream the file") + + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusFailed, + TorrentName: selectedTorrent.Name, + Message: fmt.Sprintf("Cannot stream this file: %s", reason), + }) + return + } + s.repository.logger.Warn().Msg("debridstream: Rechecking stream file in 8 seconds") + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusDownloading, + TorrentName: selectedTorrent.Name, + Message: "Checking stream file...", + }) + retries++ + time.Sleep(time.Duration(skipCheckEvent.RetryDelay) * time.Second) + continue + } + break streamUrlCheckLoop + } + } + } + + s.repository.logger.Debug().Msg("debridstream: Stream is ready") + + // Signal to the client that the torrent is ready to stream + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusReady, + TorrentName: selectedTorrent.Name, + Message: "Ready to stream the file", + }) + + if ctx.Err() != nil { + s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream") + ready() + return + } + + windowTitle := media.GetPreferredTitle() + if !media.IsMovieOrSingleEpisode() { + windowTitle += fmt.Sprintf(" - Episode %s", aniDbEpisode) + } + + event := &DebridSendStreamToMediaPlayerEvent{ + WindowTitle: windowTitle, + StreamURL: streamUrl, + Media: media.ToBaseAnime(), + AniDbEpisode: aniDbEpisode, + PlaybackType: string(opts.PlaybackType), + } + err = hook.GlobalHookManager.OnDebridSendStreamToMediaPlayer().Trigger(event) + if err != nil { + s.repository.logger.Err(err).Msg("debridstream: Failed to send stream to media player") + } + windowTitle = event.WindowTitle + streamUrl = event.StreamURL + media := event.Media + aniDbEpisode := event.AniDbEpisode + playbackType := StreamPlaybackType(event.PlaybackType) + + if event.DefaultPrevented { + s.repository.logger.Debug().Msg("debridstream: Stream prevented by hook") + ready() + return + } + + s.currentStreamUrl = streamUrl + + switch playbackType { + case PlaybackTypeNone: + // No playback type selected, just signal to the client that the stream is ready + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusReady, + TorrentName: selectedTorrent.Name, + Message: "External player link sent", + }) + case PlaybackTypeNoneAndAwait: + // No playback type selected, just signal to the client that the stream is ready + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusReady, + TorrentName: selectedTorrent.Name, + Message: "External player link sent", + }) + ready() + + case PlaybackTypeDefault: + // + // Start the stream + // + s.repository.logger.Debug().Msg("debridstream: Starting the media player") + + s.repository.wsEventManager.SendEvent(events.InfoToast, "Sending stream to media player...") + s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream") + + var playbackSubscriberCtx context.Context + playbackSubscriberCtx, s.playbackSubscriberCtxCancelFunc = context.WithCancel(context.Background()) + playbackSubscriber := s.repository.playbackManager.SubscribeToPlaybackStatus("debridstream") + + // Sends the stream to the media player + // DEVNOTE: Events are handled by the torrentstream.Repository module + err = s.repository.playbackManager.StartStreamingUsingMediaPlayer(windowTitle, &playbackmanager.StartPlayingOptions{ + Payload: streamUrl, + UserAgent: opts.UserAgent, + ClientId: opts.ClientId, + }, media, aniDbEpisode) + if err != nil { + go s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream") + if s.playbackSubscriberCtxCancelFunc != nil { + s.playbackSubscriberCtxCancelFunc() + s.playbackSubscriberCtxCancelFunc = nil + } + // Failed to start the stream, we'll drop the torrents and stop the server + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusFailed, + TorrentName: selectedTorrent.Name, + Message: fmt.Sprintf("Failed to send the stream to the media player, %v", err), + }) + return + } + + // Listen to the playback status + // Reset the current stream url when playback is stopped + go func() { + defer util.HandlePanicInModuleThen("debridstream/PlaybackSubscriber", func() {}) + defer func() { + if s.playbackSubscriberCtxCancelFunc != nil { + s.playbackSubscriberCtxCancelFunc() + s.playbackSubscriberCtxCancelFunc = nil + } + }() + select { + case <-playbackSubscriberCtx.Done(): + s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream") + s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream") + s.currentStreamUrl = "" + case event := <-playbackSubscriber.EventCh: + switch event.(type) { + case playbackmanager.StreamStartedEvent: + s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream") + case playbackmanager.StreamStoppedEvent: + go s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream") + s.currentStreamUrl = "" + } + } + }() + + case PlaybackTypeExternalPlayer: + // Send the external player link + s.repository.wsEventManager.SendEventTo(opts.ClientId, events.ExternalPlayerOpenURL, struct { + Url string `json:"url"` + MediaId int `json:"mediaId"` + EpisodeNumber int `json:"episodeNumber"` + MediaTitle string `json:"mediaTitle"` + }{ + Url: streamUrl, + MediaId: opts.MediaId, + EpisodeNumber: opts.EpisodeNumber, + MediaTitle: media.GetPreferredTitle(), + }) + + // Signal to the client that the torrent has started playing (remove loading status) + // We can't know for sure + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusReady, + TorrentName: selectedTorrent.Name, + Message: "External player link sent", + }) + case PlaybackTypeNativePlayer: + err := s.repository.directStreamManager.PlayDebridStream(ctx, directstream.PlayDebridStreamOptions{ + StreamUrl: streamUrl, + MediaId: media.ID, + EpisodeNumber: opts.EpisodeNumber, + AnidbEpisode: opts.AniDBEpisode, + Media: media, + Torrent: selectedTorrent, + FileId: fileId, + UserAgent: opts.UserAgent, + ClientId: opts.ClientId, + AutoSelect: false, + }) + if err != nil { + s.repository.logger.Error().Err(err).Msg("directstream: Failed to prepare new stream") + return + } + } + + go func() { + defer util.HandlePanicInModuleThen("debridstream/AddBatchHistory", func() {}) + + _ = db_bridge.InsertTorrentstreamHistory(s.repository.db, media.GetID(), selectedTorrent) + }() + }(ctx) + + s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{ + Status: StreamStatusStarted, + TorrentName: selectedTorrent.Name, + Message: "Stream started", + }) + s.repository.logger.Info().Msg("debridstream: Stream started") + + if opts.PlaybackType == PlaybackTypeNoneAndAwait { + s.repository.logger.Debug().Msg("debridstream: Waiting for stream to be ready") + <-readyCh + s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream") + } + + return nil +} + +func (s *StreamManager) cancelStream(opts *CancelStreamOptions) { + if s.downloadCtxCancelFunc != nil { + s.downloadCtxCancelFunc() + s.downloadCtxCancelFunc = nil + } + + s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream") + + s.currentStreamUrl = "" + + if opts.RemoveTorrent && s.currentTorrentItemId != "" { + // Remove the torrent from the debrid service + provider, err := s.repository.GetProvider() + if err != nil { + s.repository.logger.Err(err).Msg("debridstream: Failed to remove torrent") + return + } + + // Remove the torrent from the debrid service + err = provider.DeleteTorrent(s.currentTorrentItemId) + if err != nil { + s.repository.logger.Err(err).Msg("debridstream: Failed to remove torrent") + } + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/seanime-2.9.10/internal/debrid/client/stream_helpers.go b/seanime-2.9.10/internal/debrid/client/stream_helpers.go new file mode 100644 index 0000000..f8d185e --- /dev/null +++ b/seanime-2.9.10/internal/debrid/client/stream_helpers.go @@ -0,0 +1,136 @@ +package debrid_client + +import ( + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/util" + "strings" +) + +func (s *StreamManager) getMediaInfo(ctx context.Context, mediaId int) (media *anilist.CompleteAnime, animeMetadata *metadata.AnimeMetadata, err error) { + // Get the media + var found bool + media, found = s.repository.completeAnimeCache.Get(mediaId) + if !found { + // Fetch the media + media, err = s.repository.platform.GetAnimeWithRelations(ctx, mediaId) + if err != nil { + return nil, nil, fmt.Errorf("torrentstream: Failed to fetch media: %w", err) + } + } + + // Get the media + animeMetadata, err = s.repository.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mediaId) + if err != nil { + //return nil, nil, fmt.Errorf("torrentstream: Could not fetch AniDB media: %w", err) + animeMetadata = &metadata.AnimeMetadata{ + Titles: make(map[string]string), + Episodes: make(map[string]*metadata.EpisodeMetadata), + EpisodeCount: 0, + SpecialCount: 0, + Mappings: &metadata.AnimeMappings{ + AnilistId: media.GetID(), + }, + } + animeMetadata.Titles["en"] = media.GetTitleSafe() + animeMetadata.Titles["x-jat"] = media.GetRomajiTitleSafe() + err = nil + } + + return +} + +func CanStream(streamUrl string) (bool, string) { + hasExtension, isArchive := IsArchive(streamUrl) + + // If we were able to verify that the stream URL is an archive, we can't stream it + if isArchive { + return false, "Stream URL is an archive" + } + + // If the stream URL has an extension, we can stream it + if hasExtension { + ext := filepath.Ext(streamUrl) + if util.IsValidVideoExtension(ext) { + return true, "" + } + // If the extension is not a valid video extension, we can't stream it + return false, "Stream URL is not a valid video extension" + } + + // If the stream URL doesn't have an extension, we'll get the headers to check if it's a video + // If the headers are not available, we can't stream it + + contentType, err := GetContentType(streamUrl) + if err != nil { + return false, "Failed to get content type" + } + + if strings.HasPrefix(contentType, "video/") { + return true, "" + } + + return false, fmt.Sprintf("Stream URL of type %q is not a video", contentType) +} + +func IsArchive(streamUrl string) (hasExtension bool, isArchive bool) { + ext := filepath.Ext(streamUrl) + if ext == ".zip" || ext == ".rar" { + return true, true + } + + if ext != "" { + return true, false + } + + return false, false +} + +func GetContentTypeHead(url string) string { + resp, err := http.Head(url) + if err != nil { + return "" + } + + defer resp.Body.Close() + + return resp.Header.Get("Content-Type") +} + +func GetContentType(url string) (string, error) { + // Try using HEAD request + if cType := GetContentTypeHead(url); cType != "" { + return cType, nil + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + + // Only read a small amount of data to determine the content type. + req.Header.Set("Range", "bytes=0-511") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + // Read the first 512 bytes + buf := make([]byte, 512) + n, err := resp.Body.Read(buf) + if err != nil && err != io.EOF { + return "", err + } + + // Detect content type based on the read bytes + contentType := http.DetectContentType(buf[:n]) + + return contentType, nil +} diff --git a/seanime-2.9.10/internal/debrid/client/utils.go b/seanime-2.9.10/internal/debrid/client/utils.go new file mode 100644 index 0000000..0dff0c6 --- /dev/null +++ b/seanime-2.9.10/internal/debrid/client/utils.go @@ -0,0 +1,282 @@ +package debrid_client + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/nwaples/rardecode/v2" +) + +// Unzips a file to the destination +// +// Example: +// If "file.zip" contains `folder>file.text`, the file will be extracted to "/path/to/dest/{TMP}/folder/file.txt" +// unzipFile("file.zip", "/path/to/dest") +func unzipFile(src, dest string) (string, error) { + r, err := zip.OpenReader(src) + if err != nil { + return "", fmt.Errorf("failed to open zip file: %w", err) + } + defer r.Close() + + // Create a temporary folder to extract the files + extractedDir, err := os.MkdirTemp(dest, "extracted-") + if err != nil { + return "", fmt.Errorf("failed to create temp folder: %w", err) + } + + // Iterate through the files in the archive + for _, f := range r.File { + // Get the full path of the file in the destination + fpath := filepath.Join(extractedDir, f.Name) + // If the file is a directory, create it in the destination + if f.FileInfo().IsDir() { + _ = os.MkdirAll(fpath, os.ModePerm) + continue + } + // Make sure the parent directory exists (will not return an error if it already exists) + if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { + return "", err + } + + // Open the file in the destination + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return "", err + } + // Open the file in the archive + rc, err := f.Open() + if err != nil { + _ = outFile.Close() + return "", err + } + + // Copy the file from the archive to the destination + _, err = io.Copy(outFile, rc) + _ = outFile.Close() + _ = rc.Close() + + if err != nil { + return "", err + } + } + return extractedDir, nil +} + +// Unrars a file to the destination +// +// Example: +// If "file.rar" contains a folder "folder" with a file "file.txt", the file will be extracted to "/path/to/dest/{TM}/folder/file.txt" +// unrarFile("file.rar", "/path/to/dest") +func unrarFile(src, dest string) (string, error) { + r, err := rardecode.OpenReader(src) + if err != nil { + return "", fmt.Errorf("failed to open rar file: %w", err) + } + defer r.Close() + + // Create a temporary folder to extract the files + extractedDir, err := os.MkdirTemp(dest, "extracted-") + if err != nil { + return "", fmt.Errorf("failed to create temp folder: %w", err) + } + + // Iterate through the files in the archive + for { + header, err := r.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + + // Get the full path of the file in the destination + fpath := filepath.Join(extractedDir, header.Name) + // If the file is a directory, create it in the destination + if header.IsDir { + _ = os.MkdirAll(fpath, os.ModePerm) + continue + } + + // Make sure the parent directory exists (will not return an error if it already exists) + if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { + return "", err + } + + // Open the file in the destination + outFile, err := os.Create(fpath) + if err != nil { + return "", err + } + + // Copy the file from the archive to the destination + _, err = io.Copy(outFile, r) + outFile.Close() + + if err != nil { + return "", err + } + } + return extractedDir, nil +} + +// Moves a folder or file to the destination +// +// Example: +// moveFolderOrFileTo("/path/to/src/folder", "/path/to/dest") -> "/path/to/dest/folder" +func moveFolderOrFileTo(src, dest string) error { + // Ensure the destination folder exists + if _, err := os.Stat(dest); os.IsNotExist(err) { + err := os.MkdirAll(dest, os.ModePerm) + if err != nil { + return fmt.Errorf("failed to create destination folder: %v", err) + } + } + + destFolder := filepath.Join(dest, filepath.Base(src)) + + // Move the folder by renaming it + err := os.Rename(src, destFolder) + if err != nil { + return fmt.Errorf("failed to move folder: %v", err) + } + + return nil +} + +// Moves the contents of a folder to the destination +// It will move ONLY the folder containing multiple files or folders OR a single deeply nested file +// +// Example: +// +// Case 1: +// src/ +// - Anime/ +// - Ep1.mkv +// - Ep2.mkv +// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Anime" +// +// Case 2: +// src/ +// - {HASH}/ +// - Anime/ +// - Ep1.mkv +// - Ep2.mkv +// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Anime" +// +// Case 3: +// src/ +// - {HASH}/ +// - Anime/ +// - Ep1.mkv +// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Ep1.mkv" +// +// Case 4: +// src/ +// - {HASH}/ +// - Anime/ +// - Anime 1/ +// - Ep1.mkv +// - Ep2.mkv +// - Anime 2/ +// - Ep1.mkv +// - Ep2.mkv +// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Anime" +func moveContentsTo(src, dest string) error { + // Ensure the source and destination directories exist + if _, err := os.Stat(src); os.IsNotExist(err) { + return fmt.Errorf("source directory does not exist: %s", src) + } + _ = os.MkdirAll(dest, os.ModePerm) + + srcEntries, err := os.ReadDir(src) + if err != nil { + return err + } + + // If the source folder contains multiple files or folders, move its contents to the destination + if len(srcEntries) > 1 { + for _, srcEntry := range srcEntries { + err := moveFolderOrFileTo(filepath.Join(src, srcEntry.Name()), dest) + if err != nil { + return err + } + } + return nil + } + + folderMap := make(map[string]int) + err = findFolderChildCount(src, folderMap) + if err != nil { + return err + } + + var folderToMove string + for folder, count := range folderMap { + if count > 1 { + if folderToMove == "" || len(folder) < len(folderToMove) { + folderToMove = folder + } + continue + } + } + + //util.Spew(folderToMove) + + // It's a single file, move that file only + if folderToMove == "" { + fp := getDeeplyNestedFile(src) + if fp == "" { + return fmt.Errorf("no files found in the source directory") + } + return moveFolderOrFileTo(fp, dest) + } + + // Move the folder containing multiple files or folders + err = moveFolderOrFileTo(folderToMove, dest) + if err != nil { + return err + } + + return nil +} + +// Finds the folder to move to the destination +func findFolderChildCount(src string, folderMap map[string]int) error { + srcEntries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, srcEntry := range srcEntries { + folderMap[src]++ + if srcEntry.IsDir() { + err = findFolderChildCount(filepath.Join(src, srcEntry.Name()), folderMap) + if err != nil { + return err + } + } + } + + return nil +} + +func getDeeplyNestedFile(src string) (fp string) { + srcEntries, err := os.ReadDir(src) + if err != nil { + return "" + } + + for _, srcEntry := range srcEntries { + if srcEntry.IsDir() { + return getDeeplyNestedFile(filepath.Join(src, srcEntry.Name())) + } + return filepath.Join(src, srcEntry.Name()) + } + + return "" +} diff --git a/seanime-2.9.10/internal/debrid/client/utils_test.go b/seanime-2.9.10/internal/debrid/client/utils_test.go new file mode 100644 index 0000000..09b658a --- /dev/null +++ b/seanime-2.9.10/internal/debrid/client/utils_test.go @@ -0,0 +1,200 @@ +package debrid_client + +import ( + "fmt" + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "testing" +) + +func PrintPathStructure(path string, indent string) error { + entries, err := os.ReadDir(path) + if err != nil { + return fmt.Errorf("failed to read directory %s: %w", path, err) + } + + for _, entry := range entries { + fmt.Println(indent + entry.Name()) + + if entry.IsDir() { + newIndent := indent + " " + newPath := filepath.Join(path, entry.Name()) + if err := PrintPathStructure(newPath, newIndent); err != nil { + return err + } + } else { + } + } + return nil +} + +func TestCreateTempDir(t *testing.T) { + + files := []string{ + "/12345/Anime/Ep1.mkv", + "/12345/Anime/Ep2.mkv", + } + + root := "./root" + for _, file := range files { + path := filepath.Join(root, file) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create directory: %v", err) + } + if err := os.WriteFile(path, []byte("dummy content"), 0644); err != nil { + t.Fatalf("failed to create file %s: %v", path, err) + } + } + defer os.RemoveAll(root) + + err := PrintPathStructure(root, "") + require.NoError(t, err) + +} + +func TestMoveContentsTo(t *testing.T) { + tests := []struct { + name string + files []string + dest string + expected string + expectErr bool + }{ + { + name: "Case 1: Move folder with files", + files: []string{ + "/Anime/Ep1.mkv", + "/Anime/Ep2.mkv", + }, + dest: "./dest", + expected: "./dest/Anime", + expectErr: false, + }, + { + name: "Case 2: Move folder with single hash directory", + files: []string{ + "/12345/Anime/Ep1.mkv", + "/12345/Anime/Ep2.mkv", + }, + dest: "./dest", + expected: "./dest/Anime", + expectErr: false, + }, + { + name: "Case 3: Move single file", + files: []string{ + "/12345/Anime/Ep1.mkv", + }, + dest: "./dest", + expected: "./dest/Ep1.mkv", + expectErr: false, + }, + { + name: "Case 5: Source directory does not exist", + files: []string{}, + dest: "./dest", + expected: "", + expectErr: true, + }, + { + name: "Case 6: Move single file with hash directory", + files: []string{ + "/12345/Anime/Ep1.mkv", + }, + dest: "./dest", + expected: "./dest/Ep1.mkv", + }, + { + name: "Case 7", + files: []string{ + "Ep1.mkv", + }, + dest: "./dest", + expected: "./dest/Ep1.mkv", + }, + { + name: "Case 8", + files: []string{ + "Ep1.mkv", + "Ep2.mkv", + }, + dest: "./dest", + expected: "./dest/Ep2.mkv", + }, + { + name: "Case 9", + files: []string{ + "/12345/Anime/Anime 1/Ep1.mkv", + "/12345/Anime/Anime 1/Ep2.mkv", + "/12345/Anime/Anime 2/Ep1.mkv", + "/12345/Anime/Anime 2/Ep2.mkv", + "/12345/Anime 2/Anime 3/Ep1.mkv", + "/12345/Anime 2/Anime 3/Ep2.mkv", + }, + dest: "./dest", + expected: "./dest/12345", + expectErr: false, + }, + { + name: "Case 10", + files: []string{ + "/Users/r/Downloads/b6aa416f662a2df83c6f5f79da95004ced59b8ef/Tsue to Tsurugi no Wistoria S01 1080p WEBRip DD+ x265-EMBER/[EMBER] Tsue to Tsurugi no Wistoria - 01.mkv", + "/Users/r/Downloads/b6aa416f662a2df83c6f5f79da95004ced59b8ef/Tsue to Tsurugi no Wistoria S01 1080p WEBRip DD+ x265-EMBER/[EMBER] Tsue to Tsurugi no Wistoria - 02.mkv", + }, + dest: "./dest", + expected: "./dest/Tsue to Tsurugi no Wistoria S01 1080p WEBRip DD+ x265-EMBER", + expectErr: false, + }, + { + name: "Case 11", + files: []string{ + "/Users/rahim/Downloads/80431b4f9a12f4e06616062d3d3973b9ef99b5e6/[SubsPlease] Bocchi the Rock! - 01 (1080p) [E04F4EFB]/[SubsPlease] Bocchi the Rock! - 01 (1080p) [E04F4EFB].mkv", + }, + dest: "./dest", + expected: "./dest/[SubsPlease] Bocchi the Rock! - 01 (1080p) [E04F4EFB].mkv", + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create the source directory structure + root := "./root" + for _, file := range tt.files { + path := filepath.Join(root, file) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create directory: %v", err) + } + if err := os.WriteFile(path, []byte("dummy content"), 0644); err != nil { + t.Fatalf("failed to create file %s: %v", path, err) + } + } + defer os.RemoveAll(root) // Cleanup temp dir after test + + PrintPathStructure(root, "") + println("-----------------------------") + + // Create the destination directory + if err := os.MkdirAll(tt.dest, 0755); err != nil { + t.Fatalf("failed to create dest directory: %v", err) + } + defer os.RemoveAll(tt.dest) // Cleanup dest after test + + // Move the contents + err := moveContentsTo(root, tt.dest) + + if (err != nil) != tt.expectErr { + t.Errorf("unexpected error: %v", err) + } + + if !tt.expectErr { + if _, err := os.Stat(tt.expected); os.IsNotExist(err) { + t.Errorf("expected directory or file does not exist: %s", tt.expected) + } + + PrintPathStructure(tt.dest, "") + } + }) + } +} diff --git a/seanime-2.9.10/internal/debrid/debrid/debrid.go b/seanime-2.9.10/internal/debrid/debrid/debrid.go new file mode 100644 index 0000000..9376376 --- /dev/null +++ b/seanime-2.9.10/internal/debrid/debrid/debrid.go @@ -0,0 +1,126 @@ +package debrid + +import ( + "context" + "fmt" + "path/filepath" + "seanime/internal/util" +) + +var ( + ErrNotAuthenticated = fmt.Errorf("not authenticated") + ErrFailedToAuthenticate = fmt.Errorf("failed to authenticate") + ErrStreamInterrupted = fmt.Errorf("stream interrupted") +) + +type ( + Provider interface { + GetSettings() Settings + Authenticate(apiKey string) error + AddTorrent(opts AddTorrentOptions) (string, error) + // GetTorrentStreamUrl returns the stream URL for the torrent file. It should block until the stream URL is available. + GetTorrentStreamUrl(ctx context.Context, opts StreamTorrentOptions, itemCh chan TorrentItem) (streamUrl string, err error) + // GetTorrentDownloadUrl returns the download URL for the torrent. It should return an error if the torrent is not ready. + GetTorrentDownloadUrl(opts DownloadTorrentOptions) (downloadUrl string, err error) + // GetInstantAvailability returns a map where the key is the torrent's info hash + GetInstantAvailability(hashes []string) map[string]TorrentItemInstantAvailability + GetTorrent(id string) (*TorrentItem, error) + GetTorrentInfo(opts GetTorrentInfoOptions) (*TorrentInfo, error) + GetTorrents() ([]*TorrentItem, error) + DeleteTorrent(id string) error + } + + AddTorrentOptions struct { + MagnetLink string `json:"magnetLink"` + InfoHash string `json:"infoHash"` + SelectFileId string `json:"selectFileId"` // Real-Debrid only, ID, IDs, or "all" + } + + StreamTorrentOptions struct { + ID string `json:"id"` + FileId string `json:"fileId"` // ID or index of the file to stream + } + + GetTorrentInfoOptions struct { + MagnetLink string `json:"magnetLink"` + InfoHash string `json:"infoHash"` + } + + DownloadTorrentOptions struct { + ID string `json:"id"` + FileId string `json:"fileId"` // ID or index of the file to download + } + + // TorrentItem represents a torrent added to a Debrid service + TorrentItem struct { + ID string `json:"id"` + Name string `json:"name"` // Name of the torrent or file + Hash string `json:"hash"` // SHA1 hash of the torrent + Size int64 `json:"size"` // Size of the selected files (size in bytes) + FormattedSize string `json:"formattedSize"` // Formatted size of the selected files + CompletionPercentage int `json:"completionPercentage"` // Progress percentage (0 to 100) + ETA string `json:"eta"` // Formatted estimated time remaining + Status TorrentItemStatus `json:"status"` // Current download status + AddedAt string `json:"added"` // Date when the torrent was added, RFC3339 format + Speed string `json:"speed,omitempty"` // Current download speed (optional, present in downloading state) + Seeders int `json:"seeders,omitempty"` // Number of seeders (optional, present in downloading state) + IsReady bool `json:"isReady"` // Whether the torrent is ready to be downloaded + Files []*TorrentItemFile `json:"files,omitempty"` // List of files in the torrent (optional) + } + + TorrentItemFile struct { + ID string `json:"id"` // ID of the file, usually the index + Index int `json:"index"` + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + } + + TorrentItemStatus string + + TorrentItemInstantAvailability struct { + CachedFiles map[string]*CachedFile `json:"cachedFiles"` // Key is the file ID (or index) + } + + //------------------------------------------------------------------ + + TorrentInfo struct { + ID *string `json:"id"` // ID of the torrent if added to the debrid service + Name string `json:"name"` + Hash string `json:"hash"` + Size int64 `json:"size"` + Files []*TorrentItemFile `json:"files"` + } + + CachedFile struct { + Size int64 `json:"size"` + Name string `json:"name"` + } + //////////////////////////////////////////////////////////////////// + + Settings struct { + ID string `json:"id"` + Name string `json:"name"` + } +) + +const ( + TorrentItemStatusDownloading TorrentItemStatus = "downloading" + TorrentItemStatusCompleted TorrentItemStatus = "completed" + TorrentItemStatusSeeding TorrentItemStatus = "seeding" + TorrentItemStatusError TorrentItemStatus = "error" + TorrentItemStatusStalled TorrentItemStatus = "stalled" + TorrentItemStatusPaused TorrentItemStatus = "paused" + TorrentItemStatusOther TorrentItemStatus = "other" +) + +func FilterVideoFiles(files []*TorrentItemFile) []*TorrentItemFile { + var filtered []*TorrentItemFile + for _, file := range files { + ext := filepath.Ext(file.Name) + if util.IsValidVideoExtension(ext) { + filtered = append(filtered, file) + } + } + return filtered +} diff --git a/seanime-2.9.10/internal/debrid/realdebrid/realdebrid.go b/seanime-2.9.10/internal/debrid/realdebrid/realdebrid.go new file mode 100644 index 0000000..f3afb02 --- /dev/null +++ b/seanime-2.9.10/internal/debrid/realdebrid/realdebrid.go @@ -0,0 +1,811 @@ +package realdebrid + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "math" + "mime/multipart" + "net/http" + "net/url" + "path/filepath" + "seanime/internal/debrid/debrid" + "seanime/internal/util" + "slices" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +type ( + RealDebrid struct { + baseUrl string + apiKey mo.Option[string] + client *http.Client + logger *zerolog.Logger + } + + ErrorResponse struct { + Error string `json:"error"` + ErrorDetails string `json:"error_details"` + ErrorCode int `json:"error_code"` + } + + Torrent struct { + ID string `json:"id"` + Filename string `json:"filename"` + Hash string `json:"hash"` + Bytes int64 `json:"bytes"` + Host string `json:"host"` + Split int `json:"split"` + Progress float64 `json:"progress"` + Status string `json:"status"` + Added string `json:"added"` + Links []string `json:"links"` + Ended string `json:"ended"` + Speed int64 `json:"speed"` + Seeders int `json:"seeders"` + } + + TorrentInfo struct { + ID string `json:"id"` + Filename string `json:"filename"` + OriginalFilename string `json:"original_filename"` + Hash string `json:"hash"` + Bytes int64 `json:"bytes"` // Size of selected files + OriginalBytes int64 `json:"original_bytes"` // Size of the torrent + Host string `json:"host"` + Split int `json:"split"` + Progress float64 `json:"progress"` + Status string `json:"status"` + Added string `json:"added"` + Files []*TorrentInfoFile `json:"files"` + Links []string `json:"links"` + Ended string `json:"ended"` + Speed int64 `json:"speed"` + Seeders int `json:"seeders"` + } + + TorrentInfoFile struct { + ID int `json:"id"` + Path string `json:"path"` // e.g. "/Big Buck Bunny/Big Buck Bunny.mp4" + Bytes int64 `json:"bytes"` + Selected int `json:"selected"` // 1 if selected, 0 if not + } + + InstantAvailabilityItem struct { + Hash string `json:"hash"` + Files []struct { + Filename string `json:"filename"` + Filesize int `json:"filesize"` + } `json:"files"` + } +) + +func NewRealDebrid(logger *zerolog.Logger) debrid.Provider { + return &RealDebrid{ + baseUrl: "https://api.real-debrid.com/rest/1.0", + apiKey: mo.None[string](), + client: &http.Client{ + Timeout: time.Second * 10, + }, + logger: logger, + } +} + +func NewRealDebridT(logger *zerolog.Logger) *RealDebrid { + return &RealDebrid{ + baseUrl: "https://api.real-debrid.com/rest/1.0", + apiKey: mo.None[string](), + client: &http.Client{ + Timeout: time.Second * 30, + }, + logger: logger, + } +} + +func (t *RealDebrid) GetSettings() debrid.Settings { + return debrid.Settings{ + ID: "realdebrid", + Name: "RealDebrid", + } +} + +func (t *RealDebrid) doQuery(method, uri string, body io.Reader, contentType string) (ret []byte, err error) { + apiKey, found := t.apiKey.Get() + if !found { + return nil, debrid.ErrNotAuthenticated + } + + req, err := http.NewRequest(method, uri, body) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", contentType) + req.Header.Add("Authorization", "Bearer "+apiKey) + + resp, err := t.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var errResp ErrorResponse + + if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to decode response") + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + // If the error details are empty, we'll just return the response body + if errResp.ErrorDetails == "" && errResp.ErrorCode == 0 { + content, _ := io.ReadAll(resp.Body) + return content, nil + } + + return nil, fmt.Errorf("failed to query API: %s, %s", resp.Status, errResp.ErrorDetails) + } + + content, _ := io.ReadAll(resp.Body) + //fmt.Println(string(content)) + + return content, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (t *RealDebrid) Authenticate(apiKey string) error { + t.apiKey = mo.Some(apiKey) + return nil +} + +// { +// "string": { // First hash +// "string": [ // hoster, ex: "rd" +// // All file IDs variants +// { +// "int": { // file ID, you must ask all file IDs from this array on /selectFiles to get instant downloading +// "filename": "string", +// "filesize": int +// }, +// }, +type instantAvailabilityResponse map[string]map[string][]map[int]instantAvailabilityFile +type instantAvailabilityFile struct { + Filename string `json:"filename"` + Filesize int64 `json:"filesize"` +} + +func (t *RealDebrid) GetInstantAvailability(hashes []string) map[string]debrid.TorrentItemInstantAvailability { + + t.logger.Trace().Strs("hashes", hashes).Msg("realdebrid: Checking instant availability") + + availability := make(map[string]debrid.TorrentItemInstantAvailability) + + if len(hashes) == 0 { + return availability + } + + return t.getInstantAvailabilityT(hashes, 3, 100) +} + +func (t *RealDebrid) getInstantAvailabilityT(hashes []string, retries int, limit int) (ret map[string]debrid.TorrentItemInstantAvailability) { + + ret = make(map[string]debrid.TorrentItemInstantAvailability) + + var hashBatches [][]string + + for i := 0; i < len(hashes); i += limit { + end := i + limit + if end > len(hashes) { + end = len(hashes) + } + hashBatches = append(hashBatches, hashes[i:end]) + } + + for _, batch := range hashBatches { + + hashParams := "" + for _, hash := range batch { + hashParams += "/" + hash + } + + resp, err := t.doQuery("GET", t.baseUrl+"/torrents/instantAvailability"+hashParams, nil, "application/json") + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to get instant availability") + return + } + + //fmt.Println(string(resp)) + + var instantAvailability instantAvailabilityResponse + err = json.Unmarshal(resp, &instantAvailability) + if err != nil { + if limit != 1 && retries > 0 { + t.logger.Warn().Msg("realdebrid: Retrying instant availability request") + return t.getInstantAvailabilityT(hashes, retries-1, int(math.Ceil(float64(limit)/10))) + } else { + t.logger.Error().Err(err).Msg("realdebrid: Failed to parse instant availability") + return + } + } + + for hash, hosters := range instantAvailability { + currentHash := "" + for _, _hash := range hashes { + if strings.EqualFold(hash, _hash) { + currentHash = _hash + break + } + } + + if currentHash == "" { + continue + } + + avail := debrid.TorrentItemInstantAvailability{ + CachedFiles: make(map[string]*debrid.CachedFile), + } + + for hoster, hosterI := range hosters { + if hoster != "rd" { + continue + } + + for _, hosterFiles := range hosterI { + for fileId, file := range hosterFiles { + avail.CachedFiles[strconv.Itoa(fileId)] = &debrid.CachedFile{ + Name: file.Filename, + Size: file.Filesize, + } + } + } + } + + if len(avail.CachedFiles) > 0 { + ret[currentHash] = avail + } + } + + } + + return +} + +func (t *RealDebrid) AddTorrent(opts debrid.AddTorrentOptions) (string, error) { + + // Check if the torrent is already added + // If it is, return the torrent ID + torrentId := "" + if opts.InfoHash != "" { + torrents, err := t.getTorrents(false) + if err == nil { + for _, torrent := range torrents { + if torrent.Hash == opts.InfoHash { + t.logger.Debug().Str("torrentId", torrent.ID).Msg("realdebrid: Torrent already added") + torrentId = torrent.ID + break + } + } + } + time.Sleep(1 * time.Second) + } + + // If the torrent wasn't already added, add it + if torrentId == "" { + resp, err := t.addMagnet(opts.MagnetLink) + if err != nil { + return "", err + } + torrentId = resp.ID + } + + // If a file ID is provided, select the file to start downloading it + if opts.SelectFileId != "" { + // Select the file to download + err := t.selectCachedFiles(torrentId, opts.SelectFileId) + if err != nil { + return "", err + } + } + + return torrentId, nil +} + +// GetTorrentStreamUrl blocks until the torrent is downloaded and returns the stream URL for the torrent file by calling GetTorrentDownloadUrl. +func (t *RealDebrid) GetTorrentStreamUrl(ctx context.Context, opts debrid.StreamTorrentOptions, itemCh chan debrid.TorrentItem) (streamUrl string, err error) { + + t.logger.Trace().Str("torrentId", opts.ID).Str("fileId", opts.FileId).Msg("realdebrid: Retrieving stream link") + + doneCh := make(chan struct{}) + + go func(ctx context.Context) { + defer func() { + close(doneCh) + }() + for { + select { + case <-ctx.Done(): + err = ctx.Err() + return + case <-time.After(4 * time.Second): + ti, _err := t.getTorrentInfo(opts.ID) + if _err != nil { + t.logger.Error().Err(_err).Msg("realdebrid: Failed to get torrent") + err = fmt.Errorf("realdebrid: Failed to get torrent: %w", _err) + return + } + + dt := toDebridTorrent(&Torrent{ + ID: ti.ID, + Filename: ti.Filename, + Hash: ti.Hash, + Bytes: ti.Bytes, + Host: ti.Host, + Split: ti.Split, + Progress: ti.Progress, + Status: ti.Status, + Added: ti.Added, + Links: ti.Links, + Ended: ti.Ended, + Speed: ti.Speed, + Seeders: ti.Seeders, + }) + itemCh <- *dt + + // Check if the torrent is ready + if dt.IsReady { + time.Sleep(1 * time.Second) + + files := make([]*TorrentInfoFile, 0) + for _, f := range ti.Files { + if f.Selected == 1 { + files = append(files, f) + } + } + + if len(files) == 0 { + err = fmt.Errorf("realdebrid: No files downloaded") + return + } + + for idx, f := range files { + if strconv.Itoa(f.ID) == opts.FileId { + resp, err := t.unrestrictLink(ti.Links[idx]) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to get download URL") + return + } + + streamUrl = resp.Download + return + } + } + err = fmt.Errorf("realdebrid: File not found") + return + } + } + } + }(ctx) + + <-doneCh + + return +} + +type unrestrictLinkResponse struct { + ID string `json:"id"` + Filename string `json:"filename"` + MimeType string `json:"mimeType"` + Filesize int64 `json:"filesize"` + Link string `json:"link"` + Host string `json:"host"` + Chunks int `json:"chunks"` + Crc int `json:"crc"` + Download string `json:"download"` // Generated download link + Streamable int `json:"streamable"` +} + +// GetTorrentDownloadUrl returns the download URL for the torrent file. +// If no opts.FileId is provided, it will return a comma-separated list of download URLs for all selected files in the torrent. +func (t *RealDebrid) GetTorrentDownloadUrl(opts debrid.DownloadTorrentOptions) (downloadUrl string, err error) { + + t.logger.Trace().Str("torrentId", opts.ID).Msg("realdebrid: Retrieving download link") + + torrentInfo, err := t.getTorrentInfo(opts.ID) + if err != nil { + return "", fmt.Errorf("realdebrid: Failed to get download URL: %w", err) + } + + files := make([]*TorrentInfoFile, 0) + for _, f := range torrentInfo.Files { + if f.Selected == 1 { + files = append(files, f) + } + } + + downloadUrl = "" + + if opts.FileId != "" { + var file *TorrentInfoFile + var link string + for idx, f := range files { + if strconv.Itoa(f.ID) == opts.FileId { + file = f + link = torrentInfo.Links[idx] + break + } + } + + if file == nil || link == "" { + return "", fmt.Errorf("realdebrid: File not found") + } + + unrestrictLink, err := t.unrestrictLink(link) + if err != nil { + return "", fmt.Errorf("realdebrid: Failed to get download URL: %w", err) + } + + return unrestrictLink.Download, nil + } + + for idx := range files { + link := torrentInfo.Links[idx] + unrestrictLink, err := t.unrestrictLink(link) + if err != nil { + return "", fmt.Errorf("realdebrid: Failed to get download URL: %w", err) + } + if downloadUrl != "" { + downloadUrl += "," + } + downloadUrl += unrestrictLink.Download + } + + return downloadUrl, nil +} + +func (t *RealDebrid) GetTorrent(id string) (ret *debrid.TorrentItem, err error) { + torrent, err := t.getTorrent(id) + if err != nil { + return nil, err + } + + ret = toDebridTorrent(torrent) + + return ret, nil +} + +// GetTorrentInfo uses the info hash to return the torrent's data. +// This adds the torrent to the user's account without downloading it and removes it after getting the info. +func (t *RealDebrid) GetTorrentInfo(opts debrid.GetTorrentInfoOptions) (ret *debrid.TorrentInfo, err error) { + + if opts.MagnetLink == "" { + return nil, fmt.Errorf("realdebrid: Magnet link is required") + } + + // Add the torrent to the user's account without downloading it + resp, err := t.addMagnet(opts.MagnetLink) + if err != nil { + return nil, fmt.Errorf("realdebrid: Failed to get info: %w", err) + } + + torrent, err := t.getTorrentInfo(resp.ID) + if err != nil { + return nil, err + } + + go func() { + // Remove the torrent + err = t.DeleteTorrent(torrent.ID) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to delete torrent") + } + }() + + ret = toDebridTorrentInfo(torrent) + + return ret, nil +} + +func (t *RealDebrid) GetTorrents() (ret []*debrid.TorrentItem, err error) { + + torrents, err := t.getTorrents(true) + if err != nil { + return nil, fmt.Errorf("realdebrid: Failed to get torrents: %w", err) + } + + for _, t := range torrents { + ret = append(ret, toDebridTorrent(t)) + } + + slices.SortFunc(ret, func(i, j *debrid.TorrentItem) int { + return cmp.Compare(j.AddedAt, i.AddedAt) + }) + + return ret, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// selectCachedFiles +// Real Debrid will re-download cached torrent if we select only a few files from the torrent. +// To avoid this, we'll select all *cached* files in the torrent if the file we want to download is cached. +func (t *RealDebrid) selectCachedFiles(id string, idStr string) (err error) { + + t.logger.Trace().Str("torrentId", id).Str("fileId", "all").Msg("realdebrid: Selecting all files") + + return t._selectFiles(id, "all") + + //t.logger.Trace().Str("torrentId", id).Str("fileId", idStr).Msg("realdebrid: Selecting cached files") + //// If the file ID is "all" or a list of IDs, just call selectFiles + //if idStr == "all" || strings.Contains(idStr, ",") { + // return t._selectFiles(id, idStr) + //} + // + //// Get the torrent info + //torrent, err := t.getTorrent(id) + //if err != nil { + // return err + //} + // + //// Get the instant availability + //avail := t.GetInstantAvailability([]string{torrent.Hash}) + //if _, ok := avail[torrent.Hash]; !ok { + // return t._selectFiles(id, idStr) + //} + // + //// Get all cached file IDs + //ids := make([]string, 0) + //for fileIdStr := range avail[torrent.Hash].CachedFiles { + // if fileIdStr != "" { + // ids = append(ids, fileIdStr) + // } + //} + // + //// If the selected file isn't cached, we'll just download it alone + //if !slices.Contains(ids, idStr) { + // return t._selectFiles(id, idStr) + //} + // + //// Download all cached files + //return t._selectFiles(id, strings.Join(ids, ",")) +} + +func (t *RealDebrid) _selectFiles(id string, idStr string) (err error) { + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + t.logger.Trace().Str("torrentId", id).Str("fileId", idStr).Msg("realdebrid: Selecting files") + + err = writer.WriteField("files", idStr) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to write field 'files'") + return fmt.Errorf("realdebrid: Failed to select files: %w", err) + } + + _, err = t.doQuery("POST", t.baseUrl+fmt.Sprintf("/torrents/selectFiles/%s", id), &body, writer.FormDataContentType()) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to select files") + return fmt.Errorf("realdebrid: Failed to select files: %w", err) + } + + return nil +} + +type addMagnetResponse struct { + ID string `json:"id"` + URI string `json:"uri"` +} + +func (t *RealDebrid) addMagnet(magnet string) (ret *addMagnetResponse, err error) { + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + t.logger.Trace().Str("magnetLink", magnet).Msg("realdebrid: Adding torrent") + + err = writer.WriteField("magnet", magnet) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to write field 'magnet'") + return nil, fmt.Errorf("torbox: Failed to add torrent: %w", err) + } + + resp, err := t.doQuery("POST", t.baseUrl+"/torrents/addMagnet", &body, writer.FormDataContentType()) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to add torrent") + return nil, fmt.Errorf("realdebrid: Failed to add torrent: %w", err) + } + + err = json.Unmarshal(resp, &ret) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to parse torrent") + return nil, fmt.Errorf("realdebrid: Failed to parse torrent: %w", err) + } + + return ret, nil +} + +func (t *RealDebrid) unrestrictLink(link string) (ret *unrestrictLinkResponse, err error) { + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + err = writer.WriteField("link", link) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to write field 'link'") + return nil, fmt.Errorf("realdebrid: Failed to unrestrict link: %w", err) + } + + resp, err := t.doQuery("POST", t.baseUrl+"/unrestrict/link", &body, writer.FormDataContentType()) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to unrestrict link") + return nil, fmt.Errorf("realdebrid: Failed to unrestrict link: %w", err) + } + + err = json.Unmarshal(resp, &ret) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to parse unrestrict link") + return nil, fmt.Errorf("realdebrid: Failed to parse unrestrict link: %w", err) + } + + return ret, nil +} + +func (t *RealDebrid) getTorrents(activeOnly bool) (ret []*Torrent, err error) { + _url, _ := url.Parse(t.baseUrl + "/torrents") + q := _url.Query() + if activeOnly { + q.Set("filter", "active") + } else { + q.Set("limit", "500") + } + + resp, err := t.doQuery("GET", _url.String(), nil, "application/json") + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to get torrents") + return nil, fmt.Errorf("realdebrid: Failed to get torrents: %w", err) + } + + err = json.Unmarshal(resp, &ret) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to parse torrents") + return nil, fmt.Errorf("realdebrid: Failed to parse torrents: %w", err) + } + + return ret, nil +} + +func (t *RealDebrid) getTorrent(id string) (ret *Torrent, err error) { + + resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/info/%s", id), nil, "application/json") + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to get torrent") + return nil, fmt.Errorf("realdebrid: Failed to get torrent: %w", err) + } + + var ti TorrentInfo + + err = json.Unmarshal(resp, &ti) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to parse torrent") + return nil, fmt.Errorf("realdebrid: Failed to parse torrent: %w", err) + } + + ret = &Torrent{ + ID: ti.ID, + Filename: ti.Filename, + Hash: ti.Hash, + Bytes: ti.Bytes, + Host: ti.Host, + Split: ti.Split, + Progress: ti.Progress, + Status: ti.Status, + Added: ti.Added, + Links: ti.Links, + Ended: ti.Ended, + Speed: ti.Speed, + Seeders: ti.Seeders, + } + + return ret, nil +} + +func (t *RealDebrid) getTorrentInfo(id string) (ret *TorrentInfo, err error) { + + resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/info/%s", id), nil, "application/json") + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to get torrent") + return nil, fmt.Errorf("realdebrid: Failed to get torrent: %w", err) + } + + err = json.Unmarshal(resp, &ret) + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to parse torrent") + return nil, fmt.Errorf("realdebrid: Failed to parse torrent: %w", err) + } + + return ret, nil +} + +func toDebridTorrent(t *Torrent) (ret *debrid.TorrentItem) { + + status := toDebridTorrentStatus(t) + + ret = &debrid.TorrentItem{ + ID: t.ID, + Name: t.Filename, + Hash: t.Hash, + Size: t.Bytes, + FormattedSize: util.Bytes(uint64(t.Bytes)), + CompletionPercentage: int(t.Progress), + ETA: "", + Status: status, + AddedAt: t.Added, + Speed: util.ToHumanReadableSpeed(int(t.Speed)), + Seeders: t.Seeders, + IsReady: status == debrid.TorrentItemStatusCompleted, + } + + return +} + +func toDebridTorrentInfo(t *TorrentInfo) (ret *debrid.TorrentInfo) { + + var files []*debrid.TorrentItemFile + for _, f := range t.Files { + name := filepath.Base(f.Path) + + files = append(files, &debrid.TorrentItemFile{ + ID: strconv.Itoa(f.ID), + Index: f.ID, + Name: name, // e.g. "Big Buck Bunny.mp4" + Path: f.Path, // e.g. "/Big Buck Bunny/Big Buck Bunny.mp4" + Size: f.Bytes, + }) + } + + ret = &debrid.TorrentInfo{ + ID: &t.ID, + Name: t.Filename, + Hash: t.Hash, + Size: t.OriginalBytes, + Files: files, + } + + return +} + +func toDebridTorrentStatus(t *Torrent) debrid.TorrentItemStatus { + switch t.Status { + case "downloading", "queued": + return debrid.TorrentItemStatusDownloading + case "waiting_files_selection", "magnet_conversion": + return debrid.TorrentItemStatusStalled + case "downloaded", "dead": + return debrid.TorrentItemStatusCompleted + case "uploading": + return debrid.TorrentItemStatusSeeding + case "paused": + return debrid.TorrentItemStatusPaused + default: + return debrid.TorrentItemStatusOther + } +} + +func (t *RealDebrid) DeleteTorrent(id string) error { + + _, err := t.doQuery("DELETE", t.baseUrl+fmt.Sprintf("/torrents/delete/%s", id), nil, "application/json") + if err != nil { + t.logger.Error().Err(err).Msg("realdebrid: Failed to delete torrent") + return fmt.Errorf("realdebrid: Failed to delete torrent: %w", err) + } + + return nil +} diff --git a/seanime-2.9.10/internal/debrid/realdebrid/realdebrid_test.go b/seanime-2.9.10/internal/debrid/realdebrid/realdebrid_test.go new file mode 100644 index 0000000..e657ded --- /dev/null +++ b/seanime-2.9.10/internal/debrid/realdebrid/realdebrid_test.go @@ -0,0 +1,150 @@ +package realdebrid + +import ( + "fmt" + "github.com/stretchr/testify/require" + "seanime/internal/debrid/debrid" + "seanime/internal/test_utils" + "seanime/internal/util" + "strings" + "testing" +) + +func TestTorBox_GetTorrents(t *testing.T) { + test_utils.InitTestProvider(t) + logger := util.NewLogger() + + rd := NewRealDebrid(logger) + + err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey) + require.NoError(t, err) + + fmt.Println("=== All torrents ===") + + torrents, err := rd.GetTorrents() + require.NoError(t, err) + + util.Spew(torrents) +} + +func TestTorBox_AddTorrent(t *testing.T) { + t.Skip("Skipping test that adds a torrent to RealDebrid") + + test_utils.InitTestProvider(t) + + // Already added + magnet := "magnet:?xt=urn:btih:80431b4f9a12f4e06616062d3d3973b9ef99b5e6&dn=%5BSubsPlease%5D%20Bocchi%20the%20Rock%21%20-%2001%20%281080p%29%20%5BE04F4EFB%5D.mkv&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Fexodus.desync.com%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce" + + logger := util.NewLogger() + + rd := NewRealDebrid(logger) + + err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey) + require.NoError(t, err) + + torrentId, err := rd.AddTorrent(debrid.AddTorrentOptions{ + MagnetLink: magnet, + InfoHash: "80431b4f9a12f4e06616062d3d3973b9ef99b5e6", + }) + require.NoError(t, err) + + torrentId2, err := rd.AddTorrent(debrid.AddTorrentOptions{ + MagnetLink: magnet, + InfoHash: "80431b4f9a12f4e06616062d3d3973b9ef99b5e6", + }) + require.NoError(t, err) + + require.Equal(t, torrentId, torrentId2) + + fmt.Println(torrentId) +} + +func TestTorBox_getTorrentInfo(t *testing.T) { + + test_utils.InitTestProvider(t) + + logger := util.NewLogger() + + rd := NewRealDebridT(logger) + + err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey) + require.NoError(t, err) + + ti, err := rd.getTorrentInfo("W3IWF5TX3AE6G") + require.NoError(t, err) + + util.Spew(ti) +} + +func TestTorBox_GetDownloadUrl(t *testing.T) { + + test_utils.InitTestProvider(t) + + logger := util.NewLogger() + + rd := NewRealDebridT(logger) + + err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey) + require.NoError(t, err) + + urls, err := rd.GetTorrentDownloadUrl(debrid.DownloadTorrentOptions{ + ID: "W3IWF5TX3AE6G", + FileId: "11", + }) + require.NoError(t, err) + + util.Spew(strings.Split(urls, ",")) +} + +func TestTorBox_InstantAvailability(t *testing.T) { + + test_utils.InitTestProvider(t) + + logger := util.NewLogger() + + rd := NewRealDebridT(logger) + + err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey) + require.NoError(t, err) + avail := rd.GetInstantAvailability([]string{"9f4961a9c71eeb53abce2ef2afc587b452dee5eb"}) + require.NoError(t, err) + + util.Spew(avail) +} + +func TestTorBox_ChooseFileAndDownload(t *testing.T) { + //t.Skip("Skipping test that adds a torrent to RealDebrid") + + test_utils.InitTestProvider(t) + + magnet := "magnet:?xt=urn:btih:80431b4f9a12f4e06616062d3d3973b9ef99b5e6&dn=%5BSubsPlease%5D%20Bocchi%20the%20Rock%21%20-%2001%20%281080p%29%20%5BE04F4EFB%5D.mkv&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Fexodus.desync.com%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce" + + logger := util.NewLogger() + + rd := NewRealDebrid(logger) + + err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey) + require.NoError(t, err) + + // Should add the torrent and get the torrent info + torrentInfo, err := rd.GetTorrentInfo(debrid.GetTorrentInfoOptions{ + MagnetLink: magnet, + InfoHash: "80431b4f9a12f4e06616062d3d3973b9ef99b5e6", + }) + require.NoError(t, err) + + // The torrent should have one file + require.Len(t, torrentInfo.Files, 1) + + file := torrentInfo.Files[0] + + // Download the file + resp, err := rd.AddTorrent(debrid.AddTorrentOptions{ + MagnetLink: magnet, + InfoHash: "80431b4f9a12f4e06616062d3d3973b9ef99b5e6", + SelectFileId: file.ID, + }) + require.NoError(t, err) + + util.Spew(resp) +} diff --git a/seanime-2.9.10/internal/debrid/torbox/torbox.go b/seanime-2.9.10/internal/debrid/torbox/torbox.go new file mode 100644 index 0000000..dd023f7 --- /dev/null +++ b/seanime-2.9.10/internal/debrid/torbox/torbox.go @@ -0,0 +1,627 @@ +package torbox + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "seanime/internal/constants" + "seanime/internal/debrid/debrid" + "seanime/internal/util" + "slices" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +type ( + TorBox struct { + baseUrl string + apiKey mo.Option[string] + client *http.Client + logger *zerolog.Logger + } + + Response struct { + Success bool `json:"success"` + Detail string `json:"detail"` + Data interface{} `json:"data"` + } + + File struct { + ID int `json:"id"` + MD5 string `json:"md5"` + S3Path string `json:"s3_path"` + Name string `json:"name"` + Size int `json:"size"` + MimeType string `json:"mimetype"` + ShortName string `json:"short_name"` + } + + Torrent struct { + ID int `json:"id"` + Hash string `json:"hash"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Magnet string `json:"magnet"` + Size int64 `json:"size"` + Active bool `json:"active"` + AuthID string `json:"auth_id"` + DownloadState string `json:"download_state"` + Seeds int `json:"seeds"` + Peers int `json:"peers"` + Ratio float64 `json:"ratio"` + Progress float64 `json:"progress"` + DownloadSpeed float64 `json:"download_speed"` + UploadSpeed float64 `json:"upload_speed"` + Name string `json:"name"` + ETA int64 `json:"eta"` + Server float64 `json:"server"` + TorrentFile bool `json:"torrent_file"` + ExpiresAt string `json:"expires_at"` + DownloadPresent bool `json:"download_present"` + DownloadFinished bool `json:"download_finished"` + Files []*File `json:"files"` + InactiveCheck int `json:"inactive_check"` + Availability float64 `json:"availability"` + } + + TorrentInfo struct { + Name string `json:"name"` + Hash string `json:"hash"` + Size int64 `json:"size"` + Files []*TorrentInfoFile `json:"files"` + } + + TorrentInfoFile struct { + Name string `json:"name"` // e.g. "Big Buck Bunny/Big Buck Bunny.mp4" + Size int64 `json:"size"` + } + + InstantAvailabilityItem struct { + Name string `json:"name"` + Hash string `json:"hash"` + Size int64 `json:"size"` + Files []struct { + Name string `json:"name"` + Size int64 `json:"size"` + } `json:"files"` + } +) + +func NewTorBox(logger *zerolog.Logger) debrid.Provider { + return &TorBox{ + baseUrl: "https://api.torbox.app/v1/api", + apiKey: mo.None[string](), + client: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 5, + IdleConnTimeout: 90 * time.Second, + }, + }, + logger: logger, + } +} + +func (t *TorBox) GetSettings() debrid.Settings { + return debrid.Settings{ + ID: "torbox", + Name: "TorBox", + } +} + +func (t *TorBox) doQuery(method, uri string, body io.Reader, contentType string) (*Response, error) { + return t.doQueryCtx(context.Background(), method, uri, body, contentType) +} + +func (t *TorBox) doQueryCtx(ctx context.Context, method, uri string, body io.Reader, contentType string) (*Response, error) { + apiKey, found := t.apiKey.Get() + if !found { + return nil, debrid.ErrNotAuthenticated + } + + req, err := http.NewRequestWithContext(ctx, method, uri, body) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", contentType) + req.Header.Add("Authorization", "Bearer "+apiKey) + req.Header.Add("User-Agent", "Seanime/"+constants.Version) + + resp, err := t.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + bodyB, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("request failed: code %d, body: %s", resp.StatusCode, string(bodyB)) + } + + bodyB, err := io.ReadAll(resp.Body) + if err != nil { + t.logger.Error().Err(err).Msg("torbox: Failed to read response body") + return nil, err + } + + var ret Response + if err := json.Unmarshal(bodyB, &ret); err != nil { + trimmedBody := string(bodyB) + if len(trimmedBody) > 2000 { + trimmedBody = trimmedBody[:2000] + "..." + } + t.logger.Error().Err(err).Msg("torbox: Failed to decode response, response body: " + trimmedBody) + return nil, err + } + + if !ret.Success { + return nil, fmt.Errorf("request failed: %s", ret.Detail) + } + + return &ret, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (t *TorBox) Authenticate(apiKey string) error { + t.apiKey = mo.Some(apiKey) + return nil +} + +func (t *TorBox) GetInstantAvailability(hashes []string) map[string]debrid.TorrentItemInstantAvailability { + + t.logger.Trace().Strs("hashes", hashes).Msg("torbox: Checking instant availability") + + availability := make(map[string]debrid.TorrentItemInstantAvailability) + + if len(hashes) == 0 { + return availability + } + + var hashBatches [][]string + + for i := 0; i < len(hashes); i += 100 { + end := i + 100 + if end > len(hashes) { + end = len(hashes) + } + hashBatches = append(hashBatches, hashes[i:end]) + } + + for _, batch := range hashBatches { + resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/checkcached?hash=%s&format=list&list_files=true", strings.Join(batch, ",")), nil, "application/json") + if err != nil { + return availability + } + + marshaledData, _ := json.Marshal(resp.Data) + + var items []*InstantAvailabilityItem + err = json.Unmarshal(marshaledData, &items) + if err != nil { + return availability + } + + for _, item := range items { + availability[item.Hash] = debrid.TorrentItemInstantAvailability{ + CachedFiles: make(map[string]*debrid.CachedFile), + } + + for idx, file := range item.Files { + availability[item.Hash].CachedFiles[strconv.Itoa(idx)] = &debrid.CachedFile{ + Name: file.Name, + Size: file.Size, + } + } + } + + } + + return availability +} + +func (t *TorBox) AddTorrent(opts debrid.AddTorrentOptions) (string, error) { + + // Check if the torrent is already added by checking existing torrents + if opts.InfoHash != "" { + // First check if it's already in our account using a more efficient approach + torrents, err := t.getTorrents() + if err == nil { + for _, torrent := range torrents { + if torrent.Hash == opts.InfoHash { + return strconv.Itoa(torrent.ID), nil + } + } + } + // Small delay to avoid rate limiting + time.Sleep(500 * time.Millisecond) + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + t.logger.Trace().Str("magnetLink", opts.MagnetLink).Msg("torbox: Adding torrent") + + err := writer.WriteField("magnet", opts.MagnetLink) + if err != nil { + return "", fmt.Errorf("torbox: Failed to add torrent: %w", err) + } + + err = writer.WriteField("seed", "1") + if err != nil { + return "", fmt.Errorf("torbox: Failed to add torrent: %w", err) + } + + err = writer.Close() + if err != nil { + return "", fmt.Errorf("torbox: Failed to add torrent: %w", err) + } + + resp, err := t.doQuery("POST", t.baseUrl+"/torrents/createtorrent", &body, writer.FormDataContentType()) + if err != nil { + return "", fmt.Errorf("torbox: Failed to add torrent: %w", err) + } + + type data struct { + ID int `json:"torrent_id"` + Name string `json:"name"` + Hash string `json:"hash"` + } + + marshaledData, _ := json.Marshal(resp.Data) + + var d data + err = json.Unmarshal(marshaledData, &d) + if err != nil { + return "", fmt.Errorf("torbox: Failed to add torrent: %w", err) + } + + t.logger.Debug().Str("torrentId", strconv.Itoa(d.ID)).Str("torrentName", d.Name).Str("torrentHash", d.Hash).Msg("torbox: Torrent added") + + return strconv.Itoa(d.ID), nil +} + +// GetTorrentStreamUrl blocks until the torrent is downloaded and returns the stream URL for the torrent file by calling GetTorrentDownloadUrl. +func (t *TorBox) GetTorrentStreamUrl(ctx context.Context, opts debrid.StreamTorrentOptions, itemCh chan debrid.TorrentItem) (streamUrl string, err error) { + + t.logger.Trace().Str("torrentId", opts.ID).Str("fileId", opts.FileId).Msg("torbox: Retrieving stream link") + + doneCh := make(chan struct{}) + + go func(ctx context.Context) { + defer func() { + close(doneCh) + }() + + for { + select { + case <-ctx.Done(): + err = ctx.Err() + return + case <-time.After(4 * time.Second): + torrent, _err := t.GetTorrent(opts.ID) + if _err != nil { + t.logger.Error().Err(_err).Msg("torbox: Failed to get torrent") + err = fmt.Errorf("torbox: Failed to get torrent: %w", _err) + return + } + + itemCh <- *torrent + + // Check if the torrent is ready + if torrent.IsReady { + time.Sleep(1 * time.Second) + downloadUrl, err := t.GetTorrentDownloadUrl(debrid.DownloadTorrentOptions{ + ID: opts.ID, + FileId: opts.FileId, // Filename + }) + if err != nil { + t.logger.Error().Err(err).Msg("torbox: Failed to get download URL") + return + } + + streamUrl = downloadUrl + return + } + } + } + }(ctx) + + <-doneCh + + return +} + +func (t *TorBox) GetTorrentDownloadUrl(opts debrid.DownloadTorrentOptions) (downloadUrl string, err error) { + + t.logger.Trace().Str("torrentId", opts.ID).Msg("torbox: Retrieving download link") + + apiKey, found := t.apiKey.Get() + if !found { + return "", fmt.Errorf("torbox: Failed to get download URL: %w", debrid.ErrNotAuthenticated) + } + + url := t.baseUrl + fmt.Sprintf("/torrents/requestdl?token=%s&torrent_id=%s&zip_link=true", apiKey, opts.ID) + if opts.FileId != "" { + // Get the actual file ID + torrent, err := t.getTorrent(opts.ID) + if err != nil { + return "", fmt.Errorf("torbox: Failed to get download URL: %w", err) + } + var fId string + for _, f := range torrent.Files { + if f.ShortName == opts.FileId { + fId = strconv.Itoa(f.ID) + break + } + } + if fId == "" { + return "", fmt.Errorf("torbox: Failed to get download URL, file not found") + } + url = t.baseUrl + fmt.Sprintf("/torrents/requestdl?token=%s&torrent_id=%s&file_id=%s", apiKey, opts.ID, fId) + } + + resp, err := t.doQuery("GET", url, nil, "application/json") + if err != nil { + return "", fmt.Errorf("torbox: Failed to get download URL: %w", err) + } + + marshaledData, _ := json.Marshal(resp.Data) + + var d string + err = json.Unmarshal(marshaledData, &d) + if err != nil { + return "", fmt.Errorf("torbox: Failed to get download URL: %w", err) + } + + t.logger.Debug().Str("downloadUrl", d).Msg("torbox: Download link retrieved") + + return d, nil +} + +func (t *TorBox) GetTorrent(id string) (ret *debrid.TorrentItem, err error) { + torrent, err := t.getTorrent(id) + if err != nil { + return nil, err + } + + ret = toDebridTorrent(torrent) + + return ret, nil +} + +func (t *TorBox) getTorrent(id string) (ret *Torrent, err error) { + + resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/mylist?bypass_cache=true&id=%s", id), nil, "application/json") + if err != nil { + return nil, fmt.Errorf("torbox: Failed to get torrent: %w", err) + } + + marshaledData, _ := json.Marshal(resp.Data) + + err = json.Unmarshal(marshaledData, &ret) + if err != nil { + return nil, fmt.Errorf("torbox: Failed to parse torrent: %w", err) + } + + return ret, nil +} + +// GetTorrentInfo uses the info hash to return the torrent's data. +// For cached torrents, it uses the /checkcached endpoint for faster response. +// For uncached torrents, it falls back to /torrentinfo endpoint. +func (t *TorBox) GetTorrentInfo(opts debrid.GetTorrentInfoOptions) (ret *debrid.TorrentInfo, err error) { + + if opts.InfoHash == "" { + return nil, fmt.Errorf("torbox: No info hash provided") + } + + resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/checkcached?hash=%s&format=object&list_files=true", opts.InfoHash), nil, "application/json") + if err != nil { + return nil, fmt.Errorf("torbox: Failed to check cached torrent: %w", err) + } + + // If the torrent is cached + if resp.Data != nil { + data, ok := resp.Data.(map[string]interface{}) + if ok { + if torrentData, exists := data[opts.InfoHash]; exists { + marshaledData, _ := json.Marshal(torrentData) + + var torrent TorrentInfo + err = json.Unmarshal(marshaledData, &torrent) + if err != nil { + return nil, fmt.Errorf("torbox: Failed to parse cached torrent: %w", err) + } + + ret = toDebridTorrentInfo(&torrent) + return ret, nil + } + } + } + + // If not cached, fall back + resp, err = t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/torrentinfo?hash=%s&timeout=15", opts.InfoHash), nil, "application/json") + if err != nil { + return nil, fmt.Errorf("torbox: Failed to get torrent info: %w", err) + } + + // DEVNOTE: Handle incorrect TorBox API response + data, ok := resp.Data.(map[string]interface{}) + if ok { + if _, ok := data["data"]; ok { + if _, ok := data["data"].(map[string]interface{}); ok { + data = data["data"].(map[string]interface{}) + } else { + return nil, fmt.Errorf("torbox: Failed to parse response") + } + } + } + + marshaledData, _ := json.Marshal(data) + + var torrent TorrentInfo + err = json.Unmarshal(marshaledData, &torrent) + if err != nil { + return nil, fmt.Errorf("torbox: Failed to parse torrent: %w", err) + } + + ret = toDebridTorrentInfo(&torrent) + + return ret, nil +} + +func (t *TorBox) GetTorrents() (ret []*debrid.TorrentItem, err error) { + + torrents, err := t.getTorrents() + if err != nil { + return nil, fmt.Errorf("torbox: Failed to get torrents: %w", err) + } + + // Limit the number of torrents to 500 + if len(torrents) > 500 { + torrents = torrents[:500] + } + + for _, t := range torrents { + ret = append(ret, toDebridTorrent(t)) + } + + slices.SortFunc(ret, func(i, j *debrid.TorrentItem) int { + return cmp.Compare(j.AddedAt, i.AddedAt) + }) + + return ret, nil +} + +func (t *TorBox) getTorrents() (ret []*Torrent, err error) { + + resp, err := t.doQuery("GET", t.baseUrl+"/torrents/mylist?bypass_cache=true", nil, "application/json") + if err != nil { + return nil, fmt.Errorf("torbox: Failed to get torrents: %w", err) + } + + marshaledData, _ := json.Marshal(resp.Data) + + err = json.Unmarshal(marshaledData, &ret) + if err != nil { + t.logger.Error().Err(err).Msg("Failed to parse torrents") + return nil, fmt.Errorf("torbox: Failed to parse torrents: %w", err) + } + + return ret, nil +} + +func toDebridTorrent(t *Torrent) (ret *debrid.TorrentItem) { + + addedAt, _ := time.Parse(time.RFC3339Nano, t.CreatedAt) + + completionPercentage := int(t.Progress * 100) + + ret = &debrid.TorrentItem{ + ID: strconv.Itoa(t.ID), + Name: t.Name, + Hash: t.Hash, + Size: t.Size, + FormattedSize: util.Bytes(uint64(t.Size)), + CompletionPercentage: completionPercentage, + ETA: util.FormatETA(int(t.ETA)), + Status: toDebridTorrentStatus(t), + AddedAt: addedAt.Format(time.RFC3339), + Speed: util.ToHumanReadableSpeed(int(t.DownloadSpeed)), + Seeders: t.Seeds, + IsReady: t.DownloadPresent, + } + + return +} + +func toDebridTorrentInfo(t *TorrentInfo) (ret *debrid.TorrentInfo) { + + var files []*debrid.TorrentItemFile + for idx, f := range t.Files { + nameParts := strings.Split(f.Name, "/") + var name string + + if len(nameParts) == 1 { + name = nameParts[0] + } else { + name = nameParts[len(nameParts)-1] + } + + files = append(files, &debrid.TorrentItemFile{ + ID: name, // Set the ID to the og name so GetStreamUrl can use that to get the real file ID + Index: idx, + Name: name, // e.g. "Big Buck Bunny.mp4" + Path: fmt.Sprintf("/%s", f.Name), // e.g. "/Big Buck Bunny/Big Buck Bunny.mp4" + Size: f.Size, + }) + } + + ret = &debrid.TorrentInfo{ + Name: t.Name, + Hash: t.Hash, + Size: t.Size, + Files: files, + } + + return +} + +func toDebridTorrentStatus(t *Torrent) debrid.TorrentItemStatus { + if t.DownloadFinished && t.DownloadPresent { + switch t.DownloadState { + case "uploading": + return debrid.TorrentItemStatusSeeding + default: + return debrid.TorrentItemStatusCompleted + } + } + + switch t.DownloadState { + case "downloading", "metaDL": + return debrid.TorrentItemStatusDownloading + case "stalled", "stalled (no seeds)": + return debrid.TorrentItemStatusStalled + case "completed", "cached": + return debrid.TorrentItemStatusCompleted + case "uploading": + return debrid.TorrentItemStatusSeeding + case "paused": + return debrid.TorrentItemStatusPaused + default: + return debrid.TorrentItemStatusOther + } +} + +func (t *TorBox) DeleteTorrent(id string) error { + + type body = struct { + ID int `json:"torrent_id"` + Operation string `json:"operation"` + } + + b := body{ + ID: util.StringToIntMust(id), + Operation: "delete", + } + + marshaledData, _ := json.Marshal(b) + + _, err := t.doQuery("POST", t.baseUrl+fmt.Sprintf("/torrents/controltorrent"), bytes.NewReader(marshaledData), "application/json") + if err != nil { + return fmt.Errorf("torbox: Failed to delete torrent: %w", err) + } + + return nil +} diff --git a/seanime-2.9.10/internal/debrid/torbox/torbox_test.go b/seanime-2.9.10/internal/debrid/torbox/torbox_test.go new file mode 100644 index 0000000..455d632 --- /dev/null +++ b/seanime-2.9.10/internal/debrid/torbox/torbox_test.go @@ -0,0 +1,67 @@ +package torbox + +import ( + "fmt" + "github.com/stretchr/testify/require" + "seanime/internal/debrid/debrid" + "seanime/internal/test_utils" + "seanime/internal/util" + "strconv" + "testing" +) + +func TestTorBox_GetTorrents(t *testing.T) { + test_utils.InitTestProvider(t) + logger := util.NewLogger() + + tb := NewTorBox(logger) + + err := tb.Authenticate(test_utils.ConfigData.Provider.TorBoxApiKey) + require.NoError(t, err) + + fmt.Println("=== All torrents ===") + + torrents, err := tb.GetTorrents() + require.NoError(t, err) + + util.Spew(torrents) + + fmt.Println("=== Selecting torrent ===") + + torrent, err := tb.GetTorrent(strconv.Itoa(98926)) + require.NoError(t, err) + + util.Spew(torrent) + + fmt.Println("=== Download link ===") + + downloadUrl, err := tb.GetTorrentDownloadUrl(debrid.DownloadTorrentOptions{ + ID: strconv.Itoa(98926), + }) + require.NoError(t, err) + + fmt.Println(downloadUrl) +} + +func TestTorBox_AddTorrent(t *testing.T) { + t.Skip("Skipping test that adds a torrent to TorBox") + + test_utils.InitTestProvider(t) + + // Already added + magnet := "magnet:?xt=urn:btih:80431b4f9a12f4e06616062d3d3973b9ef99b5e6&dn=%5BSubsPlease%5D%20Bocchi%20the%20Rock%21%20-%2001%20%281080p%29%20%5BE04F4EFB%5D.mkv&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Fexodus.desync.com%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce" + + logger := util.NewLogger() + + tb := NewTorBox(logger) + + err := tb.Authenticate(test_utils.ConfigData.Provider.TorBoxApiKey) + require.NoError(t, err) + + torrentId, err := tb.AddTorrent(debrid.AddTorrentOptions{ + MagnetLink: magnet, + }) + require.NoError(t, err) + + fmt.Println(torrentId) +} diff --git a/seanime-2.9.10/internal/directstream/debridstream.go b/seanime-2.9.10/internal/directstream/debridstream.go new file mode 100644 index 0000000..1736c11 --- /dev/null +++ b/seanime-2.9.10/internal/directstream/debridstream.go @@ -0,0 +1,723 @@ +package directstream + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "seanime/internal/api/anilist" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/library/anime" + "seanime/internal/mkvparser" + "seanime/internal/nativeplayer" + "seanime/internal/util" + httputil "seanime/internal/util/http" + "seanime/internal/util/result" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" +) + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Torrent +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var _ Stream = (*DebridStream)(nil) + +// DebridStream is a stream that is a torrent. +type DebridStream struct { + BaseStream + streamUrl string + contentLength int64 + torrent *hibiketorrent.AnimeTorrent + streamReadyCh chan struct{} // Closed by the initiator when the stream is ready + httpStream *httputil.FileStream // Shared file-backed cache for multiple readers + cacheMu sync.RWMutex // Protects httpStream access +} + +func (s *DebridStream) Type() nativeplayer.StreamType { + return nativeplayer.StreamTypeDebrid +} + +func (s *DebridStream) LoadContentType() string { + s.contentTypeOnce.Do(func() { + s.cacheMu.RLock() + if s.httpStream == nil { + s.cacheMu.RUnlock() + _ = s.initializeStream() + } else { + s.cacheMu.RUnlock() + } + + info, ok := s.FetchStreamInfo(s.streamUrl) + if !ok { + s.logger.Warn().Str("url", s.streamUrl).Msg("directstream(debrid): Failed to fetch stream info for content type") + return + } + s.logger.Debug().Str("url", s.streamUrl).Str("contentType", info.ContentType).Int64("contentLength", info.ContentLength).Msg("directstream(debrid): Fetched content type and length") + s.contentType = info.ContentType + if s.contentType == "application/force-download" { + s.contentType = "application/octet-stream" + } + s.contentLength = info.ContentLength + }) + + return s.contentType +} + +// Close cleanup the HTTP cache and other resources +func (s *DebridStream) Close() error { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + s.logger.Debug().Msg("directstream(debrid): Closing HTTP cache") + + if s.httpStream != nil { + if err := s.httpStream.Close(); err != nil { + s.logger.Error().Err(err).Msg("directstream(debrid): Failed to close HTTP cache") + return err + } + s.httpStream = nil + } + + s.logger.Debug().Msg("directstream(debrid): HTTP cache closed successfully") + + return nil +} + +// Terminate overrides BaseStream.Terminate to also clean up the HTTP cache +func (s *DebridStream) Terminate() { + // Clean up HTTP cache first + if err := s.Close(); err != nil { + s.logger.Error().Err(err).Msg("directstream(debrid): Failed to clean up HTTP cache during termination") + } + + // Call the base implementation + s.BaseStream.Terminate() +} + +func (s *DebridStream) LoadPlaybackInfo() (ret *nativeplayer.PlaybackInfo, err error) { + s.playbackInfoOnce.Do(func() { + if s.streamUrl == "" { + ret = &nativeplayer.PlaybackInfo{} + err = fmt.Errorf("stream url is not set") + s.playbackInfoErr = err + return + } + + id := uuid.New().String() + + var entryListData *anime.EntryListData + if animeCollection, ok := s.manager.animeCollection.Get(); ok { + if listEntry, ok := animeCollection.GetListEntryFromAnimeId(s.media.ID); ok { + entryListData = anime.NewEntryListData(listEntry) + } + } + + contentType := s.LoadContentType() + + playbackInfo := nativeplayer.PlaybackInfo{ + ID: id, + StreamType: s.Type(), + MimeType: contentType, + StreamUrl: "{{SERVER_URL}}/api/v1/directstream/stream?id=" + id, + ContentLength: s.contentLength, // loaded by LoadContentType + MkvMetadata: nil, + MkvMetadataParser: mo.None[*mkvparser.MetadataParser](), + Episode: s.episode, + Media: s.media, + EntryListData: entryListData, + } + + // If the content type is an EBML content type, we can create a metadata parser + if isEbmlContent(s.LoadContentType()) { + reader, err := httputil.NewHttpReadSeekerFromURL(s.streamUrl) + //reader, err := s.getPriorityReader() + if err != nil { + err = fmt.Errorf("failed to create reader for stream url: %w", err) + s.logger.Error().Err(err).Msg("directstream(debrid): Failed to create reader for stream url") + s.playbackInfoErr = err + return + } + defer reader.Close() // Close this specific reader instance + + _, _ = reader.Seek(0, io.SeekStart) + s.logger.Trace().Msgf( + "directstream(debrid): Loading metadata for stream url: %s", + s.streamUrl, + ) + + parser := mkvparser.NewMetadataParser(reader, s.logger) + metadata := parser.GetMetadata(context.Background()) + if metadata.Error != nil { + err = fmt.Errorf("failed to get metadata: %w", metadata.Error) + s.logger.Error().Err(metadata.Error).Msg("directstream(debrid): Failed to get metadata") + s.playbackInfoErr = err + return + } + + playbackInfo.MkvMetadata = metadata + playbackInfo.MkvMetadataParser = mo.Some(parser) + } + + s.playbackInfo = &playbackInfo + }) + + return s.playbackInfo, s.playbackInfoErr +} + +func (s *DebridStream) GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool) { + return getAttachmentByName(s.manager.playbackCtx, s, filename) +} + +var videoProxyClient = &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + ForceAttemptHTTP2: false, // Fixes issues on Linux + }, + Timeout: 60 * time.Second, +} + +func (s *DebridStream) GetStreamHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.logger.Trace().Str("range", r.Header.Get("Range")).Str("method", r.Method).Msg("directstream(debrid): Stream endpoint hit") + + if s.streamUrl == "" { + s.logger.Error().Msg("directstream(debrid): No URL to stream") + http.Error(w, "No URL to stream", http.StatusNotFound) + return + } + + if r.Method == http.MethodHead { + s.logger.Trace().Msg("directstream(debrid): Handling HEAD request") + + fileSize := s.contentLength + w.Header().Set("Content-Length", fmt.Sprintf("%d", fileSize)) + w.Header().Set("Content-Type", s.LoadContentType()) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusOK) + return + } + + rangeHeader := r.Header.Get("Range") + + if err := s.initializeStream(); err != nil { + s.logger.Error().Err(err).Msg("directstream(debrid): Failed to initialize FileStream") + http.Error(w, "Failed to initialize FileStream", http.StatusInternalServerError) + return + } + + reader, err := s.getReader() + if err != nil { + s.logger.Error().Err(err).Msg("directstream(debrid): Failed to create reader for stream url") + http.Error(w, "Failed to create reader for stream url", http.StatusInternalServerError) + } + + if isThumbnailRequest(r) { + ra, ok := handleRange(w, r, reader, s.filename, s.contentLength) + if !ok { + return + } + serveContentRange(w, r, r.Context(), reader, s.filename, s.contentLength, s.contentType, ra) + return + } + + ra, ok := handleRange(w, r, reader, s.filename, s.contentLength) + if !ok { + return + } + + if _, ok := s.playbackInfo.MkvMetadataParser.Get(); ok { + subReader, err := s.getReader() + if err != nil { + s.logger.Error().Err(err).Msg("directstream(debrid): Failed to create subtitle reader for stream url") + http.Error(w, "Failed to create subtitle reader for stream url", http.StatusInternalServerError) + return + } + if ra.Start < s.contentLength-1024*1024 { + go s.StartSubtitleStreamP(s, s.manager.playbackCtx, subReader, ra.Start, 0) + } + } + + req, err := http.NewRequest(http.MethodGet, s.streamUrl, nil) + if err != nil { + http.Error(w, "Failed to create request", http.StatusInternalServerError) + return + } + + req.Header.Set("Accept", "*/*") + req.Header.Set("Range", rangeHeader) + + // Copy original request headers to the proxied request + for key, values := range r.Header { + for _, value := range values { + req.Header.Add(key, value) + } + } + + resp, err := videoProxyClient.Do(req) + if err != nil { + http.Error(w, "Failed to proxy request", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + w.Header().Set(key, value) + } + } + + w.Header().Set("Content-Type", s.LoadContentType()) // overwrite the type + w.WriteHeader(resp.StatusCode) + + _ = s.httpStream.WriteAndFlush(resp.Body, w, ra.Start) + }) +} + +//func (s *DebridStream) GetStreamHandler() http.Handler { +// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// s.logger.Trace().Str("range", r.Header.Get("Range")).Str("method", r.Method).Msg("directstream(debrid): Stream endpoint hit") +// +// if s.streamUrl == "" { +// s.logger.Error().Msg("directstream(debrid): No URL to stream") +// http.Error(w, "No URL to stream", http.StatusNotFound) +// return +// } +// +// // Handle HEAD requests explicitly to provide file size information +// if r.Method == http.MethodHead { +// s.logger.Trace().Msg("directstream(debrid): Handling HEAD request") +// +// // Set the content length from torrent file +// fileSize := s.contentLength +// w.Header().Set("Content-Length", fmt.Sprintf("%d", fileSize)) +// w.Header().Set("Content-Type", s.LoadContentType()) +// w.Header().Set("Accept-Ranges", "bytes") +// w.WriteHeader(http.StatusOK) +// return +// } +// +// rangeHeader := r.Header.Get("Range") +// +// // Parse the range header +// ranges, err := httputil.ParseRange(rangeHeader, s.contentLength) +// if err != nil && !errors.Is(err, httputil.ErrNoOverlap) { +// w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", s.contentLength)) +// http.Error(w, "Invalid Range", http.StatusRequestedRangeNotSatisfiable) +// return +// } else if err != nil && errors.Is(err, httputil.ErrNoOverlap) { +// // Let Go handle overlap +// w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", s.contentLength)) +// } +// +// // Initialize the FileStream +// if err := s.initializeStream(); err != nil { +// s.logger.Error().Err(err).Msg("directstream(debrid): Failed to initialize FileStream") +// http.Error(w, "Failed to initialize FileStream", http.StatusInternalServerError) +// return +// } +// +// // Determine the offset for the HTTP request and FileStream +// var httpRequestOffset int64 = 0 +// var fileWriteOffset int64 = 0 +// var httpResponseOffset int64 = 0 +// +// if len(ranges) > 0 { +// originalOffset := ranges[0].Start +// // Start HTTP request 1MB earlier to ensure subtitle clusters are available +// const bufferSize = 1024 * 1024 // 1MB +// httpRequestOffset = originalOffset - bufferSize +// if httpRequestOffset < 0 { +// httpRequestOffset = 0 +// } +// fileWriteOffset = httpRequestOffset +// httpResponseOffset = originalOffset - httpRequestOffset +// } +// +// // Update the range header for the actual HTTP request +// var actualRangeHeader string +// if len(ranges) > 0 { +// if httpRequestOffset != ranges[0].Start { +// // Create a new range header starting from the earlier offset +// endOffset := ranges[0].Start + ranges[0].Length - 1 +// if endOffset >= s.contentLength { +// endOffset = s.contentLength - 1 +// } +// actualRangeHeader = fmt.Sprintf("bytes=%d-%d", httpRequestOffset, endOffset) +// } else { +// actualRangeHeader = rangeHeader +// } +// } +// +// // Create HTTP request for the range +// req, err := http.NewRequest(http.MethodGet, s.streamUrl, nil) +// if err != nil { +// http.Error(w, "Failed to create request", http.StatusInternalServerError) +// return +// } +// +// w.Header().Set("Content-Type", s.LoadContentType()) +// w.Header().Set("Accept-Ranges", "bytes") +// w.Header().Set("Connection", "keep-alive") +// w.Header().Set("Cache-Control", "no-store") +// +// // Copy original request headers to the proxied request +// for key, values := range r.Header { +// for _, value := range values { +// req.Header.Add(key, value) +// } +// } +// +// req.Header.Set("Accept", "*/*") +// req.Header.Set("Range", actualRangeHeader) +// +// // Make the HTTP request +// resp, err := videoProxyClient.Do(req) +// if err != nil { +// http.Error(w, "Failed to proxy request", http.StatusInternalServerError) +// return +// } +// defer resp.Body.Close() +// +// if _, ok := s.playbackInfo.MkvMetadataParser.Get(); ok { +// // Start a subtitle stream from the current position using normal reader (no prefetching) +// subReader, err := s.getReader() +// if err != nil { +// s.logger.Error().Err(err).Msg("directstream(debrid): Failed to create subtitle reader for stream url") +// http.Error(w, "Failed to create subtitle reader for stream url", http.StatusInternalServerError) +// return +// } +// // Do not start stream if start if 1MB from the end +// if len(ranges) > 0 && ranges[0].Start < s.contentLength-1024*1024 { +// go s.StartSubtitleStream(s, s.manager.playbackCtx, subReader, ranges[0].Start) +// } +// } +// +// // Copy response headers but adjust Content-Range if we modified the range +// for key, values := range resp.Header { +// if key == "Content-Type" { +// continue +// } +// if key == "Content-Range" && httpResponseOffset > 0 { +// // Adjust the Content-Range header to reflect the original request +// continue // We'll set this manually below +// } +// if key == "Content-Length" && httpResponseOffset > 0 { +// continue +// } +// for _, value := range values { +// w.Header().Set(key, value) +// } +// } +// +// // Set the correct Content-Range header for the original request +// if len(ranges) > 0 && httpResponseOffset > 0 { +// originalRange := ranges[0] +// w.Header().Set("Content-Range", originalRange.ContentRange(s.contentLength)) +// w.Header().Set("Content-Length", fmt.Sprintf("%d", s.contentLength)) +// } +// +// // Set the status code +// w.WriteHeader(resp.StatusCode) +// +// // Create a custom writer that skips the buffer bytes for HTTP response +// httpWriter := &offsetWriter{ +// writer: w, +// skipBytes: httpResponseOffset, +// skipped: 0, +// } +// +// // Use FileStream's WriteAndFlush to write all data to file but only desired range to HTTP response +// err = s.httpStream.WriteAndFlush(resp.Body, httpWriter, fileWriteOffset) +// if err != nil { +// s.logger.Error().Err(err).Msg("directstream(debrid): Failed to stream response body") +// http.Error(w, "Failed to stream response body", http.StatusInternalServerError) +// return +// } +// }) +//} + +type PlayDebridStreamOptions struct { + StreamUrl string + MediaId int + EpisodeNumber int // RELATIVE Episode number to identify the file + AnidbEpisode string // Anizip episode + Media *anilist.BaseAnime + Torrent *hibiketorrent.AnimeTorrent // Selected torrent + FileId string // File ID or index + UserAgent string + ClientId string + AutoSelect bool +} + +// PlayDebridStream is used by a module to load a new torrent stream. +func (m *Manager) PlayDebridStream(ctx context.Context, opts PlayDebridStreamOptions) error { + m.playbackMu.Lock() + defer m.playbackMu.Unlock() + + episodeCollection, err := anime.NewEpisodeCollection(anime.NewEpisodeCollectionOptions{ + AnimeMetadata: nil, + Media: opts.Media, + MetadataProvider: m.metadataProvider, + Logger: m.Logger, + }) + if err != nil { + return fmt.Errorf("cannot play local file, could not create episode collection: %w", err) + } + + episode, ok := episodeCollection.FindEpisodeByAniDB(opts.AnidbEpisode) + if !ok { + return fmt.Errorf("cannot play torrent stream, could not find episode: %s", opts.AnidbEpisode) + } + + stream := &DebridStream{ + streamUrl: opts.StreamUrl, + torrent: opts.Torrent, + BaseStream: BaseStream{ + manager: m, + logger: m.Logger, + clientId: opts.ClientId, + media: opts.Media, + filename: "", + episode: episode, + episodeCollection: episodeCollection, + subtitleEventCache: result.NewResultMap[string, *mkvparser.SubtitleEvent](), + activeSubtitleStreams: result.NewResultMap[string, *SubtitleStream](), + }, + streamReadyCh: make(chan struct{}), + } + + go func() { + m.loadStream(stream) + }() + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// initializeStream creates the HTTP cache for this stream if it doesn't exist +func (s *DebridStream) initializeStream() error { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + if s.httpStream != nil { + return nil // Already initialized + } + + if s.streamUrl == "" { + return fmt.Errorf("stream URL is not set") + } + + // Get content length first + if s.contentLength == 0 { + info, ok := s.FetchStreamInfo(s.streamUrl) + if !ok { + return fmt.Errorf("failed to fetch stream info") + } + s.contentLength = info.ContentLength + } + + s.logger.Debug().Msgf("directstream(debrid): Initializing FileStream for stream URL: %s", s.streamUrl) + + // Create a file-backed stream with the known content length + cache, err := httputil.NewFileStream(s.manager.playbackCtx, s.logger, s.contentLength) + if err != nil { + return fmt.Errorf("failed to create FileStream: %w", err) + } + + s.httpStream = cache + + s.logger.Debug().Msgf("directstream(debrid): FileStream initialized") + + return nil +} + +func (s *DebridStream) getReader() (io.ReadSeekCloser, error) { + if err := s.initializeStream(); err != nil { + return nil, err + } + + s.cacheMu.RLock() + defer s.cacheMu.RUnlock() + + if s.httpStream == nil { + return nil, fmt.Errorf("FileStream not initialized") + } + + return s.httpStream.NewReader() +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// offsetWriter is a wrapper that skips a specified number of bytes before writing to the underlying writer +type offsetWriter struct { + writer io.Writer + skipBytes int64 + skipped int64 +} + +func (ow *offsetWriter) Write(p []byte) (n int, err error) { + if ow.skipped < ow.skipBytes { + // We still need to skip some bytes + remaining := ow.skipBytes - ow.skipped + if int64(len(p)) <= remaining { + // Skip all of this write + ow.skipped += int64(len(p)) + return len(p), nil + } else { + // Skip part of this write and write the rest + skipCount := remaining + ow.skipped = ow.skipBytes + return ow.writer.Write(p[skipCount:]) + } + } + // No more skipping needed, write everything + return ow.writer.Write(p) +} + +func fetchContentLength(ctx context.Context, url string) (int64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return 0, fmt.Errorf("failed to create HEAD request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, fmt.Errorf("failed to fetch content length: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + contentLength := resp.ContentLength + if contentLength < 0 { + return 0, errors.New("content length not provided") + } + + return contentLength, nil +} + +type StreamInfo struct { + ContentType string + ContentLength int64 +} + +func (s *DebridStream) FetchStreamInfo(streamUrl string) (info *StreamInfo, canStream bool) { + hasExtension, isArchive := IsArchive(streamUrl) + + // If we were able to verify that the stream URL is an archive, we can't stream it + if isArchive { + s.logger.Warn().Str("url", streamUrl).Msg("directstream(debrid): Stream URL is an archive, cannot stream") + return nil, false + } + + // If the stream URL has an extension, we can stream it + if hasExtension { + ext := filepath.Ext(streamUrl) + // If not a valid video extension, we can't stream it + if !util.IsValidVideoExtension(ext) { + s.logger.Warn().Str("url", streamUrl).Str("ext", ext).Msg("directstream(debrid): Stream URL has an invalid video extension, cannot stream") + return nil, false + } + } + + // We'll fetch headers to get the info + // If the headers are not available, we can't stream it + + contentType, contentLength, err := s.GetContentTypeAndLength(streamUrl) + if err != nil { + s.logger.Error().Err(err).Str("url", streamUrl).Msg("directstream(debrid): Failed to fetch content type and length") + return nil, false + } + + // If not a video content type, we can't stream it + if !strings.HasPrefix(contentType, "video/") && contentType != "application/octet-stream" && contentType != "application/force-download" { + s.logger.Warn().Str("url", streamUrl).Str("contentType", contentType).Msg("directstream(debrid): Stream URL has an invalid content type, cannot stream") + return nil, false + } + + return &StreamInfo{ + ContentType: contentType, + ContentLength: contentLength, + }, true +} + +func IsArchive(streamUrl string) (hasExtension bool, isArchive bool) { + ext := filepath.Ext(streamUrl) + if ext == ".zip" || ext == ".rar" { + return true, true + } + + if ext != "" { + return true, false + } + + return false, false +} + +func GetContentTypeAndLengthHead(url string) (string, string) { + resp, err := http.Head(url) + if err != nil { + return "", "" + } + + defer resp.Body.Close() + + return resp.Header.Get("Content-Type"), resp.Header.Get("Content-Length") +} + +func (s *DebridStream) GetContentTypeAndLength(url string) (string, int64, error) { + // Try using HEAD request + cType, cLength := GetContentTypeAndLengthHead(url) + + length, err := strconv.ParseInt(cLength, 10, 64) + if err != nil && cLength != "" { + s.logger.Error().Err(err).Str("contentType", cType).Str("contentLength", cLength).Msg("directstream(debrid): Failed to parse content length from header") + return "", 0, fmt.Errorf("failed to parse content length: %w", err) + } + + if cType != "" { + return cType, length, nil + } + + s.logger.Trace().Msg("directstream(debrid): Content type not found in headers, falling back to GET request") + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", 0, err + } + + // Only read a small amount of data to determine the content type. + req.Header.Set("Range", "bytes=0-511") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + + // Read the first 512 bytes + buf := make([]byte, 512) + n, err := resp.Body.Read(buf) + if err != nil && err != io.EOF { + return "", 0, err + } + + // Detect content type based on the read bytes + contentType := http.DetectContentType(buf[:n]) + + return contentType, length, nil +} diff --git a/seanime-2.9.10/internal/directstream/localfile.go b/seanime-2.9.10/internal/directstream/localfile.go new file mode 100644 index 0000000..3b8981d --- /dev/null +++ b/seanime-2.9.10/internal/directstream/localfile.go @@ -0,0 +1,319 @@ +package directstream + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/library/anime" + "seanime/internal/mkvparser" + "seanime/internal/nativeplayer" + "seanime/internal/util" + "seanime/internal/util/result" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" +) + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Local File +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var _ Stream = (*LocalFileStream)(nil) + +// LocalFileStream is a stream that is a local file. +type LocalFileStream struct { + BaseStream + localFile *anime.LocalFile +} + +func (s *LocalFileStream) newReader() (io.ReadSeekCloser, error) { + r, err := os.OpenFile(s.localFile.Path, os.O_RDONLY, 0) + if err != nil { + return nil, err + } + + return r, nil +} + +func (s *LocalFileStream) Type() nativeplayer.StreamType { + return nativeplayer.StreamTypeFile +} + +func (s *LocalFileStream) LoadContentType() string { + s.contentTypeOnce.Do(func() { + // No need to pass a reader because we are not going to read the file + // Get the mime type from the file extension + s.contentType = loadContentType(s.localFile.Path) + }) + + return s.contentType +} + +func (s *LocalFileStream) LoadPlaybackInfo() (ret *nativeplayer.PlaybackInfo, err error) { + s.playbackInfoOnce.Do(func() { + if s.localFile == nil { + s.playbackInfo = &nativeplayer.PlaybackInfo{} + err = fmt.Errorf("local file is not set") + s.playbackInfoErr = err + return + } + + // Open the file + fr, err := s.newReader() + if err != nil { + s.logger.Error().Err(err).Msg("directstream(file): Failed to open local file") + s.manager.preStreamError(s, fmt.Errorf("cannot stream local file: %w", err)) + return + } + + // Close the file when done + defer func() { + if closer, ok := fr.(io.Closer); ok { + s.logger.Trace().Msg("directstream(file): Closing local file reader") + _ = closer.Close() + } else { + s.logger.Trace().Msg("directstream(file): Local file reader does not implement io.Closer") + } + }() + + // Get the file size + size, err := fr.Seek(0, io.SeekEnd) + if err != nil { + s.logger.Error().Err(err).Msg("directstream(file): Failed to get file size") + s.manager.preStreamError(s, fmt.Errorf("failed to get file size: %w", err)) + return + } + _, _ = fr.Seek(0, io.SeekStart) + + id := uuid.New().String() + + var entryListData *anime.EntryListData + if animeCollection, ok := s.manager.animeCollection.Get(); ok { + if listEntry, ok := animeCollection.GetListEntryFromAnimeId(s.media.ID); ok { + entryListData = anime.NewEntryListData(listEntry) + } + } + + playbackInfo := nativeplayer.PlaybackInfo{ + ID: id, + StreamType: s.Type(), + MimeType: s.LoadContentType(), + StreamUrl: "{{SERVER_URL}}/api/v1/directstream/stream?id=" + id, + ContentLength: size, + MkvMetadata: nil, + MkvMetadataParser: mo.None[*mkvparser.MetadataParser](), + Episode: s.episode, + Media: s.media, + EntryListData: entryListData, + } + + // If the content type is an EBML content type, we can create a metadata parser + if isEbmlContent(s.LoadContentType()) { + + parserKey := util.Base64EncodeStr(s.localFile.Path) + + parser, ok := s.manager.parserCache.Get(parserKey) + if !ok { + parser = mkvparser.NewMetadataParser(fr, s.logger) + s.manager.parserCache.SetT(parserKey, parser, 2*time.Hour) + } + + metadata := parser.GetMetadata(context.Background()) + if metadata.Error != nil { + s.logger.Error().Err(metadata.Error).Msg("directstream(torrent): Failed to get metadata") + s.manager.preStreamError(s, fmt.Errorf("failed to get metadata: %w", metadata.Error)) + s.playbackInfoErr = fmt.Errorf("failed to get metadata: %w", metadata.Error) + return + } + + playbackInfo.MkvMetadata = metadata + playbackInfo.MkvMetadataParser = mo.Some(parser) + } + + s.playbackInfo = &playbackInfo + }) + + return s.playbackInfo, s.playbackInfoErr +} + +func (s *LocalFileStream) GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool) { + return getAttachmentByName(s.manager.playbackCtx, s, filename) +} + +func (s *LocalFileStream) GetStreamHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.logger.Trace().Str("method", r.Method).Msg("directstream: Received request") + + defer func() { + s.logger.Trace().Msg("directstream: Request finished") + }() + + if r.Method == http.MethodHead { + // Get the file size + fileInfo, err := os.Stat(s.localFile.Path) + if err != nil { + s.logger.Error().Msg("directstream: Failed to get file info") + http.Error(w, "Failed to get file info", http.StatusInternalServerError) + return + } + + // Set the content length + w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size())) + w.Header().Set("Content-Type", s.LoadContentType()) + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", s.localFile.Path)) + w.WriteHeader(http.StatusOK) + } else { + ServeLocalFile(w, r, s) + } + }) +} + +func ServeLocalFile(w http.ResponseWriter, r *http.Request, lfStream *LocalFileStream) { + playbackInfo, err := lfStream.LoadPlaybackInfo() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + size := playbackInfo.ContentLength + + if isThumbnailRequest(r) { + reader, err := lfStream.newReader() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + ra, ok := handleRange(w, r, reader, lfStream.localFile.Path, size) + if !ok { + return + } + serveContentRange(w, r, r.Context(), reader, lfStream.localFile.Path, size, playbackInfo.MimeType, ra) + return + } + + if lfStream.serveContentCancelFunc != nil { + lfStream.serveContentCancelFunc() + } + + ct, cancel := context.WithCancel(lfStream.manager.playbackCtx) + lfStream.serveContentCancelFunc = cancel + + reader, err := lfStream.newReader() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer reader.Close() + + ra, ok := handleRange(w, r, reader, lfStream.localFile.Path, size) + if !ok { + return + } + + if _, ok := playbackInfo.MkvMetadataParser.Get(); ok { + // Start a subtitle stream from the current position + subReader, err := lfStream.newReader() + if err != nil { + lfStream.logger.Error().Err(err).Msg("directstream: Failed to create subtitle reader") + http.Error(w, "Failed to create subtitle reader", http.StatusInternalServerError) + return + } + go lfStream.StartSubtitleStream(lfStream, lfStream.manager.playbackCtx, subReader, ra.Start) + } + + serveContentRange(w, r, ct, reader, lfStream.localFile.Path, size, playbackInfo.MimeType, ra) +} + +type PlayLocalFileOptions struct { + ClientId string + Path string + LocalFiles []*anime.LocalFile +} + +// PlayLocalFile is used by a module to load a new torrent stream. +func (m *Manager) PlayLocalFile(ctx context.Context, opts PlayLocalFileOptions) error { + m.playbackMu.Lock() + defer m.playbackMu.Unlock() + + animeCollection, ok := m.animeCollection.Get() + if !ok { + return fmt.Errorf("cannot play local file, anime collection is not set") + } + + // Get the local file + var lf *anime.LocalFile + for _, l := range opts.LocalFiles { + if util.NormalizePath(l.Path) == util.NormalizePath(opts.Path) { + lf = l + break + } + } + + if lf == nil { + return fmt.Errorf("cannot play local file, could not find local file: %s", opts.Path) + } + + if lf.MediaId == 0 { + return fmt.Errorf("local file has not been matched to a media: %s", opts.Path) + } + + mId := lf.MediaId + var media *anilist.BaseAnime + listEntry, ok := animeCollection.GetListEntryFromAnimeId(mId) + if ok { + media = listEntry.Media + } + + if media == nil { + return fmt.Errorf("media not found in anime collection: %d", mId) + } + + episodeCollection, err := anime.NewEpisodeCollectionFromLocalFiles(ctx, anime.NewEpisodeCollectionFromLocalFilesOptions{ + LocalFiles: opts.LocalFiles, + Media: media, + AnimeCollection: animeCollection, + Platform: m.platform, + MetadataProvider: m.metadataProvider, + Logger: m.Logger, + }) + if err != nil { + return fmt.Errorf("cannot play local file, could not create episode collection: %w", err) + } + + var episode *anime.Episode + for _, e := range episodeCollection.Episodes { + if e.LocalFile != nil && util.NormalizePath(e.LocalFile.Path) == util.NormalizePath(lf.Path) { + episode = e + break + } + } + + if episode == nil { + return fmt.Errorf("cannot play local file, could not find episode for local file: %s", opts.Path) + } + + stream := &LocalFileStream{ + localFile: lf, + BaseStream: BaseStream{ + manager: m, + logger: m.Logger, + clientId: opts.ClientId, + filename: filepath.Base(lf.Path), + media: media, + episode: episode, + episodeCollection: episodeCollection, + subtitleEventCache: result.NewResultMap[string, *mkvparser.SubtitleEvent](), + activeSubtitleStreams: result.NewResultMap[string, *SubtitleStream](), + }, + } + + m.loadStream(stream) + + return nil +} diff --git a/seanime-2.9.10/internal/directstream/manager.go b/seanime-2.9.10/internal/directstream/manager.go new file mode 100644 index 0000000..337592e --- /dev/null +++ b/seanime-2.9.10/internal/directstream/manager.go @@ -0,0 +1,139 @@ +package directstream + +import ( + "context" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/continuity" + discordrpc_presence "seanime/internal/discordrpc/presence" + "seanime/internal/events" + "seanime/internal/library/anime" + "seanime/internal/mkvparser" + "seanime/internal/nativeplayer" + "seanime/internal/platforms/platform" + "seanime/internal/util/result" + "sync" + "time" + + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +// Manager handles direct stream playback and progress tracking for the built-in video player. +// It is similar to [playbackmanager.PlaybackManager]. +type ( + Manager struct { + Logger *zerolog.Logger + + // ------------ Modules ------------- // + + wsEventManager events.WSEventManagerInterface + continuityManager *continuity.Manager + metadataProvider metadata.Provider + discordPresence *discordrpc_presence.Presence + platform platform.Platform + refreshAnimeCollectionFunc func() // This function is called to refresh the AniList collection + + nativePlayer *nativeplayer.NativePlayer + nativePlayerSubscriber *nativeplayer.Subscriber + + // --------- Playback Context -------- // + + playbackMu sync.Mutex + playbackCtx context.Context + playbackCtxCancelFunc context.CancelFunc + + // ---------- Playback State ---------- // + + currentStream mo.Option[Stream] // The current stream being played + + // \/ Stream playback + // This is set by [SetStreamEpisodeCollection] + currentStreamEpisodeCollection mo.Option[*anime.EpisodeCollection] + + settings *Settings + + isOffline *bool + animeCollection mo.Option[*anilist.AnimeCollection] + animeCache *result.Cache[int, *anilist.BaseAnime] + + parserCache *result.Cache[string, *mkvparser.MetadataParser] + //playbackStatusSubscribers *result.Map[string, *PlaybackStatusSubscriber] + } + + Settings struct { + AutoPlayNextEpisode bool + AutoUpdateProgress bool + } + + NewManagerOptions struct { + Logger *zerolog.Logger + WSEventManager events.WSEventManagerInterface + MetadataProvider metadata.Provider + ContinuityManager *continuity.Manager + DiscordPresence *discordrpc_presence.Presence + Platform platform.Platform + RefreshAnimeCollectionFunc func() + IsOffline *bool + NativePlayer *nativeplayer.NativePlayer + } +) + +func NewManager(options NewManagerOptions) *Manager { + ret := &Manager{ + Logger: options.Logger, + wsEventManager: options.WSEventManager, + metadataProvider: options.MetadataProvider, + continuityManager: options.ContinuityManager, + discordPresence: options.DiscordPresence, + platform: options.Platform, + refreshAnimeCollectionFunc: options.RefreshAnimeCollectionFunc, + isOffline: options.IsOffline, + currentStream: mo.None[Stream](), + nativePlayer: options.NativePlayer, + parserCache: result.NewCache[string, *mkvparser.MetadataParser](), + } + + ret.nativePlayerSubscriber = ret.nativePlayer.Subscribe("directstream") + + ret.listenToNativePlayerEvents() + + return ret +} + +func (m *Manager) SetAnimeCollection(ac *anilist.AnimeCollection) { + m.animeCollection = mo.Some(ac) +} + +func (m *Manager) SetSettings(s *Settings) { + m.settings = s +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *Manager) getAnime(ctx context.Context, mediaId int) (*anilist.BaseAnime, error) { + media, ok := m.animeCache.Get(mediaId) + if ok { + return media, nil + } + + // Find in anime collection + animeCollection, ok := m.animeCollection.Get() + if ok { + media, ok := animeCollection.FindAnime(mediaId) + if ok { + return media, nil + } + } + + // Find in platform + media, err := m.platform.GetAnime(ctx, mediaId) + if err != nil { + return nil, err + } + + // Cache + m.animeCache.SetT(mediaId, media, 1*time.Hour) + + return media, nil +} diff --git a/seanime-2.9.10/internal/directstream/serve.go b/seanime-2.9.10/internal/directstream/serve.go new file mode 100644 index 0000000..936da81 --- /dev/null +++ b/seanime-2.9.10/internal/directstream/serve.go @@ -0,0 +1,39 @@ +package directstream + +import ( + "errors" + "net/http" + "net/url" + + "github.com/labstack/echo/v4" +) + +// ServeEchoStream is a proxy to the current stream. +// It sits in between the player and the real stream (whether it's a local file, torrent, or http stream). +// +// If this is an EBML stream, it gets the range request from the player, processes it to stream the correct subtitles, and serves the video. +// Otherwise, it just serves the video. +func (m *Manager) ServeEchoStream() http.Handler { + return m.getStreamHandler() +} + +// ServeEchoAttachments serves the attachments loaded into memory from the current stream. +func (m *Manager) ServeEchoAttachments(c echo.Context) error { + // Get the current stream + stream, ok := m.currentStream.Get() + if !ok { + return errors.New("no stream") + } + + filename := c.Param("*") + + filename, _ = url.PathUnescape(filename) + + // Get the attachment + attachment, ok := stream.GetAttachmentByName(filename) + if !ok { + return errors.New("attachment not found") + } + + return c.Blob(200, attachment.Mimetype, attachment.Data) +} diff --git a/seanime-2.9.10/internal/directstream/stream.go b/seanime-2.9.10/internal/directstream/stream.go new file mode 100644 index 0000000..444e244 --- /dev/null +++ b/seanime-2.9.10/internal/directstream/stream.go @@ -0,0 +1,426 @@ +package directstream + +import ( + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/continuity" + discordrpc_presence "seanime/internal/discordrpc/presence" + "seanime/internal/library/anime" + "seanime/internal/mkvparser" + "seanime/internal/nativeplayer" + "seanime/internal/util/result" + "sync" + + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +// Stream is the common interface for all stream types. +type Stream interface { + // Type returns the type of the stream. + Type() nativeplayer.StreamType + // LoadContentType loads and returns the content type of the stream. + // e.g. "video/mp4", "video/webm", "video/x-matroska" + LoadContentType() string + // ClientId returns the client ID of the current stream. + ClientId() string + // Media returns the media of the current stream. + Media() *anilist.BaseAnime + // Episode returns the episode of the current stream. + Episode() *anime.Episode + // ListEntryData returns the list entry data for the current stream. + ListEntryData() *anime.EntryListData + // EpisodeCollection returns the episode collection for the media of the current stream. + EpisodeCollection() *anime.EpisodeCollection + // LoadPlaybackInfo loads and returns the playback info. + LoadPlaybackInfo() (*nativeplayer.PlaybackInfo, error) + // GetAttachmentByName returns the attachment by name for the stream. + // It is used to serve fonts and other attachments. + GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool) + // GetStreamHandler returns the stream handler. + GetStreamHandler() http.Handler + // StreamError is called when an error occurs while streaming. + // This is used to notify the native player that an error occurred. + // It will close the stream. + StreamError(err error) + // Terminate ends the stream. + // Once this is called, the stream should not be used anymore. + Terminate() + // GetSubtitleEventCache accesses the subtitle event cache. + GetSubtitleEventCache() *result.Map[string, *mkvparser.SubtitleEvent] + // OnSubtitleFileUploaded is called when a subtitle file is uploaded. + OnSubtitleFileUploaded(filename string, content string) +} + +func (m *Manager) getStreamHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stream, ok := m.currentStream.Get() + if !ok { + http.Error(w, "no stream", http.StatusInternalServerError) + return + } + stream.GetStreamHandler().ServeHTTP(w, r) + }) +} + +func (m *Manager) PrepareNewStream(clientId string, step string) { + m.prepareNewStream(clientId, step) +} + +func (m *Manager) prepareNewStream(clientId string, step string) { + // Cancel the previous playback + if m.playbackCtxCancelFunc != nil { + m.Logger.Trace().Msgf("directstream: Cancelling previous playback") + m.playbackCtxCancelFunc() + m.playbackCtxCancelFunc = nil + } + + // Clear the current stream if it exists + if stream, ok := m.currentStream.Get(); ok { + m.Logger.Debug().Msgf("directstream: Terminating previous stream before preparing new stream") + stream.Terminate() + m.currentStream = mo.None[Stream]() + } + + m.Logger.Debug().Msgf("directstream: Signaling native player that a new stream is starting") + // Signal the native player that a new stream is starting + m.nativePlayer.OpenAndAwait(clientId, step) +} + +// loadStream loads a new stream and cancels the previous one. +// Caller should use mutex to lock the manager. +func (m *Manager) loadStream(stream Stream) { + m.prepareNewStream(stream.ClientId(), "Loading stream...") + + m.Logger.Debug().Msgf("directstream: Loading stream") + m.currentStream = mo.Some(stream) + + // Create a new context + ctx, cancel := context.WithCancel(context.Background()) + m.playbackCtx = ctx + m.playbackCtxCancelFunc = cancel + + m.Logger.Debug().Msgf("directstream: Loading content type") + m.nativePlayer.OpenAndAwait(stream.ClientId(), "Loading metadata...") + // Load the content type + contentType := stream.LoadContentType() + if contentType == "" { + m.Logger.Error().Msg("directstream: Failed to load content type") + m.preStreamError(stream, fmt.Errorf("failed to load content type")) + return + } + + m.Logger.Debug().Msgf("directstream: Signaling native player that metadata is being loaded") + + // Load the playback info + // If EBML, it will block until the metadata is parsed + playbackInfo, err := stream.LoadPlaybackInfo() + if err != nil { + m.Logger.Error().Err(err).Msg("directstream: Failed to load playback info") + m.preStreamError(stream, fmt.Errorf("failed to load playback info: %w", err)) + return + } + + // Shut the mkv parser logger + //parser, ok := playbackInfo.MkvMetadataParser.Get() + //if ok { + // parser.SetLoggerEnabled(false) + //} + + m.Logger.Debug().Msgf("directstream: Signaling native player that stream is ready") + m.nativePlayer.Watch(stream.ClientId(), playbackInfo) +} + +func (m *Manager) listenToNativePlayerEvents() { + go func() { + defer func() { + m.Logger.Trace().Msg("directstream: Stream loop goroutine exited") + }() + + for { + select { + case event := <-m.nativePlayerSubscriber.Events(): + cs, ok := m.currentStream.Get() + if !ok { + continue + } + + if event.GetClientId() != "" && event.GetClientId() != cs.ClientId() { + continue + } + switch event := event.(type) { + case *nativeplayer.VideoPausedEvent: + m.Logger.Debug().Msgf("directstream: Video paused") + + // Discord + if m.discordPresence != nil && !*m.isOffline { + go m.discordPresence.UpdateAnimeActivity(int(event.CurrentTime), int(event.Duration), true) + } + case *nativeplayer.VideoResumedEvent: + m.Logger.Debug().Msgf("directstream: Video resumed") + + // Discord + if m.discordPresence != nil && !*m.isOffline { + go m.discordPresence.UpdateAnimeActivity(int(event.CurrentTime), int(event.Duration), false) + } + case *nativeplayer.VideoEndedEvent: + m.Logger.Debug().Msgf("directstream: Video ended") + + // Discord + if m.discordPresence != nil && !*m.isOffline { + go m.discordPresence.Close() + } + case *nativeplayer.VideoSeekedEvent: + m.Logger.Debug().Msgf("directstream: Video seeked, CurrentTime: %f", event.CurrentTime) + // Convert video timestamp to byte offset for subtitle extraction + // if event.CurrentTime > 0 { + // cs.ServeSubtitlesFromTime(event.CurrentTime) + // } + case *nativeplayer.VideoLoadedMetadataEvent: + m.Logger.Debug().Msgf("directstream: Video loaded metadata") + // Start subtitle extraction from the beginning + // cs.ServeSubtitlesFromTime(0.0) + if lfStream, ok := cs.(*LocalFileStream); ok { + subReader, err := lfStream.newReader() + if err != nil { + m.Logger.Error().Err(err).Msg("directstream: Failed to create subtitle reader") + cs.StreamError(fmt.Errorf("failed to create subtitle reader: %w", err)) + return + } + lfStream.StartSubtitleStream(lfStream, m.playbackCtx, subReader, 0) + } else if ts, ok := cs.(*TorrentStream); ok { + subReader := ts.file.NewReader() + subReader.SetResponsive() + ts.StartSubtitleStream(ts, m.playbackCtx, subReader, 0) + } + + // Discord + if m.discordPresence != nil && !*m.isOffline { + go m.discordPresence.SetAnimeActivity(&discordrpc_presence.AnimeActivity{ + ID: cs.Media().GetID(), + Title: cs.Media().GetPreferredTitle(), + Image: cs.Media().GetCoverImageSafe(), + IsMovie: cs.Media().IsMovie(), + EpisodeNumber: cs.Episode().ProgressNumber, + Progress: int(event.CurrentTime), + Duration: int(event.Duration), + }) + } + case *nativeplayer.VideoErrorEvent: + m.Logger.Debug().Msgf("directstream: Video error, Error: %s", event.Error) + cs.StreamError(fmt.Errorf(event.Error)) + + // Discord + if m.discordPresence != nil && !*m.isOffline { + go m.discordPresence.Close() + } + case *nativeplayer.SubtitleFileUploadedEvent: + m.Logger.Debug().Msgf("directstream: Subtitle file uploaded, Filename: %s", event.Filename) + cs.OnSubtitleFileUploaded(event.Filename, event.Content) + case *nativeplayer.VideoTerminatedEvent: + m.Logger.Debug().Msgf("directstream: Video terminated") + cs.Terminate() + + // Discord + if m.discordPresence != nil && !*m.isOffline { + go m.discordPresence.Close() + } + case *nativeplayer.VideoStatusEvent: + _ = m.continuityManager.UpdateWatchHistoryItem(&continuity.UpdateWatchHistoryItemOptions{ + CurrentTime: event.Status.CurrentTime, + Duration: event.Status.Duration, + MediaId: cs.Media().GetID(), + EpisodeNumber: cs.Episode().GetEpisodeNumber(), + Kind: continuity.MediastreamKind, + }) + + // Discord + if m.discordPresence != nil && !*m.isOffline { + go m.discordPresence.UpdateAnimeActivity(int(event.Status.CurrentTime), int(event.Status.Duration), event.Status.Paused) + } + case *nativeplayer.VideoCompletedEvent: + m.Logger.Debug().Msgf("directstream: Video completed") + + if baseStream, ok := cs.(*BaseStream); ok { + baseStream.updateProgress.Do(func() { + mediaId := baseStream.media.GetID() + epNum := baseStream.episode.GetProgressNumber() + totalEpisodes := baseStream.media.GetTotalEpisodeCount() // total episode count or -1 + + _ = baseStream.manager.platform.UpdateEntryProgress(context.Background(), mediaId, epNum, &totalEpisodes) + }) + } + } + } + } + }() +} + +func (m *Manager) unloadStream() { + m.playbackMu.Lock() + defer m.playbackMu.Unlock() + + m.Logger.Debug().Msg("directstream: Unloading current stream") + + // Cancel any existing playback context first + if m.playbackCtxCancelFunc != nil { + m.Logger.Trace().Msg("directstream: Cancelling playback context") + m.playbackCtxCancelFunc() + m.playbackCtxCancelFunc = nil + } + + // Clear the current stream + if stream, ok := m.currentStream.Get(); ok { + m.Logger.Debug().Msg("directstream: Terminating current stream") + stream.Terminate() + } + + m.currentStream = mo.None[Stream]() + m.Logger.Debug().Msg("directstream: Stream unloaded successfully") +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type BaseStream struct { + logger *zerolog.Logger + clientId string + contentType string + contentTypeOnce sync.Once + episode *anime.Episode + media *anilist.BaseAnime + listEntryData *anime.EntryListData + episodeCollection *anime.EpisodeCollection + playbackInfo *nativeplayer.PlaybackInfo + playbackInfoErr error + playbackInfoOnce sync.Once + subtitleEventCache *result.Map[string, *mkvparser.SubtitleEvent] + terminateOnce sync.Once + serveContentCancelFunc context.CancelFunc + filename string // Name of the file being streamed, if applicable + + // Subtitle stream management + activeSubtitleStreams *result.Map[string, *SubtitleStream] + + manager *Manager + updateProgress sync.Once +} + +var _ Stream = (*BaseStream)(nil) + +func (s *BaseStream) GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool) { + return nil, false +} + +func (s *BaseStream) GetStreamHandler() http.Handler { + return nil +} + +func (s *BaseStream) LoadContentType() string { + return s.contentType +} + +func (s *BaseStream) LoadPlaybackInfo() (*nativeplayer.PlaybackInfo, error) { + return s.playbackInfo, s.playbackInfoErr +} + +func (s *BaseStream) Type() nativeplayer.StreamType { + return "" +} + +func (s *BaseStream) Media() *anilist.BaseAnime { + return s.media +} + +func (s *BaseStream) Episode() *anime.Episode { + return s.episode +} + +func (s *BaseStream) ListEntryData() *anime.EntryListData { + return s.listEntryData +} + +func (s *BaseStream) EpisodeCollection() *anime.EpisodeCollection { + return s.episodeCollection +} + +func (s *BaseStream) ClientId() string { + return s.clientId +} + +func (s *BaseStream) Terminate() { + s.terminateOnce.Do(func() { + // Cancel the playback context + // This will snowball and cancel other stuff + if s.manager.playbackCtxCancelFunc != nil { + s.manager.playbackCtxCancelFunc() + } + + // Cancel all active subtitle streams + s.activeSubtitleStreams.Range(func(_ string, s *SubtitleStream) bool { + s.cleanupFunc() + return true + }) + s.activeSubtitleStreams.Clear() + + s.subtitleEventCache.Clear() + }) +} + +func (s *BaseStream) StreamError(err error) { + s.logger.Error().Err(err).Msg("directstream: Stream error occurred") + s.manager.nativePlayer.Error(s.clientId, err) + s.Terminate() + s.manager.unloadStream() +} + +func (s *BaseStream) GetSubtitleEventCache() *result.Map[string, *mkvparser.SubtitleEvent] { + return s.subtitleEventCache +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Helpers +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// loadContentType loads the content type of the file. +// If the content type cannot be determined from the file extension, +// the first reader will be used to determine the content type. +func loadContentType(path string, reader ...io.ReadSeekCloser) string { + ext := filepath.Ext(path) + + switch ext { + case ".mp4": + return "video/mp4" + case ".mkv": + //return "video/x-matroska" + return "video/webm" + case ".webm", ".m4v": + return "video/webm" + case ".avi": + return "video/x-msvideo" + case ".mov": + return "video/quicktime" + case ".flv": + return "video/x-flv" + default: + } + + // No extension found + // Read the first 1KB to determine the content type + if len(reader) > 0 { + if mimeType, ok := mkvparser.ReadIsMkvOrWebm(reader[0]); ok { + return mimeType + } + } + + return "" +} + +func (m *Manager) preStreamError(stream Stream, err error) { + stream.Terminate() + m.nativePlayer.Error(stream.ClientId(), err) + m.unloadStream() +} diff --git a/seanime-2.9.10/internal/directstream/stream_helpers.go b/seanime-2.9.10/internal/directstream/stream_helpers.go new file mode 100644 index 0000000..f631164 --- /dev/null +++ b/seanime-2.9.10/internal/directstream/stream_helpers.go @@ -0,0 +1,233 @@ +package directstream + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + httputil "seanime/internal/util/http" + "time" + + "github.com/neilotoole/streamcache" +) + +func handleRange(w http.ResponseWriter, r *http.Request, reader io.ReadSeekCloser, name string, size int64) (httputil.Range, bool) { + // No Range header → let Go handle it + rangeHdr := r.Header.Get("Range") + if rangeHdr == "" { + http.ServeContent(w, r, name, time.Now(), reader) + return httputil.Range{}, false + } + + // Parse the range header + ranges, err := httputil.ParseRange(rangeHdr, size) + if err != nil && !errors.Is(err, httputil.ErrNoOverlap) { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size)) + http.Error(w, "Invalid Range", http.StatusRequestedRangeNotSatisfiable) + return httputil.Range{}, false + } else if err != nil && errors.Is(err, httputil.ErrNoOverlap) { + // Let Go handle overlap + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size)) + http.ServeContent(w, r, name, time.Now(), reader) + return httputil.Range{}, false + } + + return ranges[0], true +} + +func serveContentRange(w http.ResponseWriter, r *http.Request, ctx context.Context, reader io.ReadSeekCloser, name string, size int64, contentType string, ra httputil.Range) { + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Type", contentType) + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Cache-Control", "no-store") + + // Validate range + if ra.Start >= size || ra.Start < 0 || ra.Length <= 0 { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size)) + http.Error(w, "Range Not Satisfiable", http.StatusRequestedRangeNotSatisfiable) + return + } + + // Set response headers for partial content + w.Header().Set("Content-Range", ra.ContentRange(size)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", ra.Length)) + w.WriteHeader(http.StatusPartialContent) + + // Seek to the requested position + _, err := reader.Seek(ra.Start, io.SeekStart) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _, _ = copyWithContext(ctx, w, reader, ra.Length) +} + +func serveTorrent(w http.ResponseWriter, r *http.Request, ctx context.Context, reader io.ReadSeekCloser, name string, size int64, contentType string, ra httputil.Range) { + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Type", contentType) + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Cache-Control", "no-store") + + // Validate range + if ra.Start >= size || ra.Start < 0 || ra.Length <= 0 { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size)) + http.Error(w, "Range Not Satisfiable", http.StatusRequestedRangeNotSatisfiable) + return + } + + // Set response headers for partial content + w.Header().Set("Content-Range", ra.ContentRange(size)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", ra.Length)) + w.WriteHeader(http.StatusPartialContent) + + // Seek to the requested position + _, err := reader.Seek(ra.Start, io.SeekStart) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _, _ = copyWithContext(ctx, w, reader, ra.Length) +} + +// copyWithContext copies n bytes from src to dst, respecting context cancellation +func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader, n int64) (int64, error) { + // Use a reasonably sized buffer + buf := make([]byte, 32*1024) // 32KB buffer + + var flusher http.Flusher + if f, ok := dst.(http.Flusher); ok { + flusher = f + } + + var written int64 + for written < n { + // Check if context is done before each read + select { + case <-ctx.Done(): + return written, ctx.Err() + default: + } + + // Calculate how much to read this iteration + toRead := int64(len(buf)) + if n-written < toRead { + toRead = n - written + } + + // Read from source + nr, readErr := io.LimitReader(src, toRead).Read(buf) + if nr > 0 { + // Write to destination + nw, writeErr := dst.Write(buf[:nr]) + if nw < nr { + return written + int64(nw), writeErr + } + written += int64(nr) + + if flusher != nil { + flusher.Flush() + } + + // Handle write error + if writeErr != nil { + return written, writeErr + } + } + + // Handle read error or EOF + if readErr != nil { + if readErr == io.EOF { + if written >= n { + return written, nil // Successfully read everything requested + } + } + return written, readErr + } + } + + return written, nil +} + +func isThumbnailRequest(r *http.Request) bool { + return r.URL.Query().Get("thumbnail") == "true" +} + +func copyWithFlush(ctx context.Context, w http.ResponseWriter, rdr io.Reader, totalBytes int64) { + const flushThreshold = 1 * 1024 * 1024 // 1MiB + buf := make([]byte, 32*1024) // 32KiB buffer + var written int64 + var sinceLastFlush int64 + flusher, _ := w.(http.Flusher) + + for written < totalBytes { + select { + case <-ctx.Done(): + return + default: + } + + // B) Decide how many bytes to read this iteration + toRead := int64(len(buf)) + if totalBytes-written < toRead { + toRead = totalBytes - written + } + + lr := io.LimitReader(rdr, toRead) + nr, readErr := lr.Read(buf) + if nr > 0 { + nw, writeErr := w.Write(buf[:nr]) + written += int64(nw) + sinceLastFlush += int64(nw) + + if flusher != nil && sinceLastFlush >= flushThreshold { + flusher.Flush() + sinceLastFlush = 0 + } + if writeErr != nil { + return + } + if nw < nr { + // Client closed or truncated write + return + } + } + if readErr != nil { + // EOF or any other read error → stop streaming + return + } + } +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////// + +type StreamCacheReadSeekCloser struct { + stream *streamcache.Stream + streamReader *streamcache.Reader + originalReader io.ReadSeekCloser +} + +var _ io.ReadSeekCloser = (*StreamCacheReadSeekCloser)(nil) + +func NewStreamCacheReadSeekCloser(ctx context.Context, reader io.ReadSeekCloser) StreamCacheReadSeekCloser { + stream := streamcache.New(reader) + return StreamCacheReadSeekCloser{ + stream: stream, + streamReader: stream.NewReader(ctx), + originalReader: reader, + } +} + +func (s StreamCacheReadSeekCloser) Read(p []byte) (n int, err error) { + return s.streamReader.Read(p) +} + +func (s StreamCacheReadSeekCloser) Seek(offset int64, whence int) (int64, error) { + return s.originalReader.Seek(offset, whence) +} + +func (s StreamCacheReadSeekCloser) Close() error { + return s.originalReader.Close() +} diff --git a/seanime-2.9.10/internal/directstream/subtitles.go b/seanime-2.9.10/internal/directstream/subtitles.go new file mode 100644 index 0000000..c3f40a7 --- /dev/null +++ b/seanime-2.9.10/internal/directstream/subtitles.go @@ -0,0 +1,522 @@ +package directstream + +import ( + "cmp" + "context" + "errors" + "fmt" + "io" + "seanime/internal/events" + "seanime/internal/mkvparser" + "seanime/internal/util" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" +) + +type SubtitleStream struct { + stream Stream + logger *zerolog.Logger + parser *mkvparser.MetadataParser + reader io.ReadSeekCloser + offset int64 + completed bool // ran until the EOF + + cleanupFunc func() + stopOnce sync.Once +} + +func (s *SubtitleStream) Stop(completed bool) { + s.stopOnce.Do(func() { + s.logger.Debug().Int64("offset", s.offset).Msg("directstream: Stopping subtitle stream") + s.completed = completed + s.cleanupFunc() + }) +} + +// StartSubtitleStreamP starts a subtitle stream for the given stream at the given offset with a specified backoff bytes. +func (s *BaseStream) StartSubtitleStreamP(stream Stream, playbackCtx context.Context, newReader io.ReadSeekCloser, offset int64, backoffBytes int64) { + mkvMetadataParser, ok := s.playbackInfo.MkvMetadataParser.Get() + if !ok { + return + } + + s.logger.Trace().Int64("offset", offset).Msg("directstream: Starting new subtitle stream") + subtitleStream := &SubtitleStream{ + stream: stream, + logger: s.logger, + parser: mkvMetadataParser, + reader: newReader, + offset: offset, + } + + // Check if we have a completed subtitle stream for this offset + shouldContinue := true + s.activeSubtitleStreams.Range(func(key string, value *SubtitleStream) bool { + // If a stream is completed and its offset comes before this one, we don't need to start a new stream + // |------------------------------->| other stream + // | this stream + // ^^^ starting in an area the other stream has already completed + if offset > 0 && value.offset <= offset && value.completed { + shouldContinue = false + return false + } + return true + }) + + if !shouldContinue { + s.logger.Debug().Int64("offset", offset).Msg("directstream: Skipping subtitle stream, range already fulfilled") + return + } + + ctx, subtitleCtxCancel := context.WithCancel(playbackCtx) + subtitleStream.cleanupFunc = subtitleCtxCancel + + subtitleStreamId := uuid.New().String() + s.activeSubtitleStreams.Set(subtitleStreamId, subtitleStream) + + subtitleCh, errCh, _ := subtitleStream.parser.ExtractSubtitles(ctx, newReader, offset, backoffBytes) + + firstEventSentCh := make(chan struct{}) + closeFirstEventSentOnce := sync.Once{} + + onFirstEventSent := func() { + closeFirstEventSentOnce.Do(func() { + s.logger.Debug().Int64("offset", offset).Msg("directstream: First subtitle event sent") + close(firstEventSentCh) // Notify that the first subtitle event has been sent + }) + } + + var lastSubtitleEvent *mkvparser.SubtitleEvent + lastSubtitleEventRWMutex := sync.RWMutex{} + + // Check every second if we need to end this stream + go func() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + subtitleStream.Stop(false) + return + case <-ticker.C: + if lastSubtitleEvent == nil { + continue + } + shouldEnd := false + lastSubtitleEventRWMutex.RLock() + s.activeSubtitleStreams.Range(func(key string, value *SubtitleStream) bool { + if key != subtitleStreamId { + // If the other stream is ahead of this stream + // and the last subtitle event is after the other stream's offset + // |---------------> this stream + // |-------------> other stream + // ^^^ stop this stream where it reached the tail of the other stream + if offset > 0 && offset < value.offset && lastSubtitleEvent.HeadPos >= value.offset { + shouldEnd = true + } + } + return true + }) + lastSubtitleEventRWMutex.RUnlock() + if shouldEnd { + subtitleStream.Stop(false) + return + } + } + } + }() + + go func() { + defer func(reader io.ReadSeekCloser) { + _ = reader.Close() + s.logger.Trace().Int64("offset", offset).Msg("directstream: Closing subtitle stream goroutine") + }(newReader) + defer func() { + onFirstEventSent() + subtitleStream.cleanupFunc() + }() + + // Keep track if channels are active to manage loop termination + subtitleChannelActive := true + errorChannelActive := true + + for subtitleChannelActive || errorChannelActive { // Loop as long as at least one channel might still produce data or a final status + select { + case <-ctx.Done(): + s.logger.Debug().Int64("offset", offset).Msg("directstream: Subtitle streaming cancelled by context") + return + + case subtitle, ok := <-subtitleCh: + if !ok { + subtitleCh = nil // Mark as exhausted + subtitleChannelActive = false + if !errorChannelActive { // If both channels are exhausted, exit + return + } + continue // Continue to wait for errorChannel or ctx.Done() + } + if subtitle != nil { + onFirstEventSent() + s.manager.nativePlayer.SubtitleEvent(stream.ClientId(), subtitle) + lastSubtitleEventRWMutex.Lock() + lastSubtitleEvent = subtitle + lastSubtitleEventRWMutex.Unlock() + } + + case err, ok := <-errCh: + if !ok { + errCh = nil // Mark as exhausted + errorChannelActive = false + if !subtitleChannelActive { // If both channels are exhausted, exit + return + } + continue // Continue to wait for subtitleChannel or ctx.Done() + } + // A value (error or nil) was received from errCh. + // This is the terminal signal from the mkvparser's subtitle streaming process. + if err != nil { + s.logger.Warn().Err(err).Int64("offset", offset).Msg("directstream: Error streaming subtitles") + } else { + s.logger.Info().Int64("offset", offset).Msg("directstream: Subtitle streaming completed by parser.") + subtitleStream.Stop(true) + } + return // Terminate goroutine + } + } + }() + + //// Then wait for first subtitle event or timeout to prevent indefinite stalling + //if offset > 0 { + // // Wait for cluster to be found first + // <-startedCh + // + // select { + // case <-firstEventSentCh: + // s.logger.Debug().Int64("offset", offset).Msg("directstream: First subtitle event received, continuing") + // case <-time.After(3 * time.Second): + // s.logger.Debug().Int64("offset", offset).Msg("directstream: Subtitle timeout reached (3s), continuing without waiting") + // case <-ctx.Done(): + // s.logger.Debug().Int64("offset", offset).Msg("directstream: Context cancelled while waiting for first subtitle") + // return + // } + //} +} + +// StartSubtitleStream starts a subtitle stream for the given stream at the given offset. +// +// If the media has no MKV metadata, this function will do nothing. +func (s *BaseStream) StartSubtitleStream(stream Stream, playbackCtx context.Context, newReader io.ReadSeekCloser, offset int64) { + // use 1MB as the cluster padding for subtitle streams + s.StartSubtitleStreamP(stream, playbackCtx, newReader, offset, 1024*1024) +} + +//// StartSubtitleStream is similar to BaseStream.StartSubtitleStream, but rate limits the requests to the external debrid server. +//// - There will only be one subtitle stream at a time. +//func (s *DebridStream) StartSubtitleStream(stream Stream, playbackCtx context.Context, newReader io.ReadSeekCloser, offset int64, end int64) { +// mkvMetadataParser, ok := s.playbackInfo.MkvMetadataParser.Get() +// if !ok { +// return +// } +// +// s.logger.Trace().Int64("offset", offset).Msg("directstream(debrid): Starting new subtitle stream") +// subtitleStream := &SubtitleStream{ +// stream: stream, +// logger: s.logger, +// parser: mkvMetadataParser, +// reader: newReader, +// offset: offset, +// } +// +// s.activeSubtitleStreams.Range(func(key string, value *SubtitleStream) bool { +// value.Stop(true) +// return true +// }) +// +// ctx, subtitleCtxCancel := context.WithCancel(playbackCtx) +// subtitleStream.cleanupFunc = subtitleCtxCancel +// +// subtitleStreamId := uuid.New().String() +// s.activeSubtitleStreams.Set(subtitleStreamId, subtitleStream) +// +// subtitleCh, errCh, _ := subtitleStream.parser.ExtractSubtitles(ctx, newReader, offset) +// +// firstEventSentCh := make(chan struct{}) +// closeFirstEventSentOnce := sync.Once{} +// +// onFirstEventSent := func() { +// closeFirstEventSentOnce.Do(func() { +// s.logger.Debug().Int64("offset", offset).Msg("directstream: First subtitle event sent") +// close(firstEventSentCh) // Notify that the first subtitle event has been sent +// }) +// } +// +// var lastSubtitleEvent *mkvparser.SubtitleEvent +// lastSubtitleEventRWMutex := sync.RWMutex{} +// +// // Check every second if we need to end this stream +// go func() { +// ticker := time.NewTicker(1 * time.Second) +// defer ticker.Stop() +// for { +// select { +// case <-ctx.Done(): +// subtitleStream.Stop(false) +// return +// case <-ticker.C: +// if lastSubtitleEvent == nil { +// continue +// } +// shouldEnd := false +// lastSubtitleEventRWMutex.RLock() +// s.activeSubtitleStreams.Range(func(key string, value *SubtitleStream) bool { +// if key != subtitleStreamId { +// // If the other stream is ahead of this stream +// // and the last subtitle event is after the other stream's offset +// // |---------------> this stream +// // |-------------> other stream +// // ^^^ stop this stream where it reached the tail of the other stream +// if offset > 0 && offset < value.offset && lastSubtitleEvent.HeadPos >= value.offset { +// shouldEnd = true +// } +// } +// return true +// }) +// lastSubtitleEventRWMutex.RUnlock() +// if shouldEnd { +// subtitleStream.Stop(false) +// return +// } +// } +// } +// }() +// +// go func() { +// defer func(reader io.ReadSeekCloser) { +// _ = reader.Close() +// s.logger.Trace().Int64("offset", offset).Msg("directstream: Closing subtitle stream goroutine") +// }(newReader) +// defer func() { +// onFirstEventSent() +// subtitleStream.cleanupFunc() +// }() +// +// // Keep track if channels are active to manage loop termination +// subtitleChannelActive := true +// errorChannelActive := true +// +// for subtitleChannelActive || errorChannelActive { // Loop as long as at least one channel might still produce data or a final status +// select { +// case <-ctx.Done(): +// s.logger.Debug().Int64("offset", offset).Msg("directstream: Subtitle streaming cancelled by context") +// return +// +// case subtitle, ok := <-subtitleCh: +// if !ok { +// subtitleCh = nil // Mark as exhausted +// subtitleChannelActive = false +// if !errorChannelActive { // If both channels are exhausted, exit +// return +// } +// continue // Continue to wait for errorChannel or ctx.Done() +// } +// if subtitle != nil { +// onFirstEventSent() +// s.manager.nativePlayer.SubtitleEvent(stream.ClientId(), subtitle) +// lastSubtitleEventRWMutex.Lock() +// lastSubtitleEvent = subtitle +// lastSubtitleEventRWMutex.Unlock() +// } +// +// case err, ok := <-errCh: +// if !ok { +// errCh = nil // Mark as exhausted +// errorChannelActive = false +// if !subtitleChannelActive { // If both channels are exhausted, exit +// return +// } +// continue // Continue to wait for subtitleChannel or ctx.Done() +// } +// // A value (error or nil) was received from errCh. +// // This is the terminal signal from the mkvparser's subtitle streaming process. +// if err != nil { +// s.logger.Warn().Err(err).Int64("offset", offset).Msg("directstream: Error streaming subtitles") +// } else { +// s.logger.Info().Int64("offset", offset).Msg("directstream: Subtitle streaming completed by parser.") +// subtitleStream.Stop(true) +// } +// return // Terminate goroutine +// } +// } +// }() +//} + +//// streamSubtitles starts the subtitle stream. +//// It will stream the subtitles from all tracks to the client. The client should load the subtitles in an array. +//func (m *Manager) streamSubtitles(ctx context.Context, stream Stream, parser *mkvparser.MetadataParser, newReader io.ReadSeekCloser, offset int64, cleanupFunc func()) (firstEventSentCh chan struct{}) { +// m.Logger.Debug().Int64("offset", offset).Str("clientId", stream.ClientId()).Msg("directstream: Starting subtitle extraction") +// +// subtitleCh, errCh, _ := parser.ExtractSubtitles(ctx, newReader, offset) +// +// firstEventSentCh = make(chan struct{}) +// closeFirstEventSentOnce := sync.Once{} +// +// onFirstEventSent := func() { +// closeFirstEventSentOnce.Do(func() { +// m.Logger.Debug().Int64("offset", offset).Msg("directstream: First subtitle event sent") +// close(firstEventSentCh) // Notify that the first subtitle event has been sent +// }) +// } +// +// go func() { +// defer func(reader io.ReadSeekCloser) { +// _ = reader.Close() +// m.Logger.Trace().Int64("offset", offset).Msg("directstream: Closing subtitle stream goroutine") +// }(newReader) +// defer func() { +// onFirstEventSent() +// if cleanupFunc != nil { +// cleanupFunc() +// } +// }() +// +// // Keep track if channels are active to manage loop termination +// subtitleChannelActive := true +// errorChannelActive := true +// +// for subtitleChannelActive || errorChannelActive { // Loop as long as at least one channel might still produce data or a final status +// select { +// case <-ctx.Done(): +// m.Logger.Debug().Int64("offset", offset).Msg("directstream: Subtitle streaming cancelled by context") +// return +// +// case subtitle, ok := <-subtitleCh: +// if !ok { +// subtitleCh = nil // Mark as exhausted +// subtitleChannelActive = false +// if !errorChannelActive { // If both channels are exhausted, exit +// return +// } +// continue // Continue to wait for errorChannel or ctx.Done() +// } +// if subtitle != nil { +// onFirstEventSent() +// m.nativePlayer.SubtitleEvent(stream.ClientId(), subtitle) +// } +// +// case err, ok := <-errCh: +// if !ok { +// errCh = nil // Mark as exhausted +// errorChannelActive = false +// if !subtitleChannelActive { // If both channels are exhausted, exit +// return +// } +// continue // Continue to wait for subtitleChannel or ctx.Done() +// } +// // A value (error or nil) was received from errCh. +// // This is the terminal signal from the mkvparser's subtitle streaming process. +// if err != nil { +// m.Logger.Warn().Err(err).Int64("offset", offset).Msg("directstream: Error streaming subtitles") +// } else { +// m.Logger.Info().Int64("offset", offset).Msg("directstream: Subtitle streaming completed by parser.") +// } +// return // Terminate goroutine +// } +// } +// }() +// +// return +//} + +// OnSubtitleFileUploaded adds a subtitle track, converts it to ASS if needed. +func (s *BaseStream) OnSubtitleFileUploaded(filename string, content string) { + parser, ok := s.playbackInfo.MkvMetadataParser.Get() + if !ok { + s.logger.Error().Msg("directstream:A Failed to load playback info") + return + } + + ext := util.FileExt(filename) + + newContent := content + if ext != ".ass" && ext != ".ssa" { + var err error + var from int + switch ext { + case ".srt": + from = mkvparser.SubtitleTypeSRT + case ".vtt": + from = mkvparser.SubtitleTypeWEBVTT + case ".ttml": + from = mkvparser.SubtitleTypeTTML + case ".stl": + from = mkvparser.SubtitleTypeSTL + case ".txt": + from = mkvparser.SubtitleTypeUnknown + default: + err = errors.New("unsupported subtitle format") + } + s.logger.Debug(). + Str("filename", filename). + Str("ext", ext). + Int("detected", from). + Msg("directstream: Converting uploaded subtitle file") + newContent, err = mkvparser.ConvertToASS(content, from) + if err != nil { + s.manager.wsEventManager.SendEventTo(s.clientId, events.ErrorToast, "Failed to convert subtitle file: "+err.Error()) + return + } + } + + metadata := parser.GetMetadata(context.Background()) + num := int64(len(metadata.Tracks)) + 1 + subtitleNum := int64(len(metadata.SubtitleTracks)) + + // e.g. filename = "title.eng.srt" -> name = "title.eng" + name := strings.TrimSuffix(filename, ext) + // e.g. "title.eng" -> ".eng" or "title.eng" + name = strings.Replace(name, strings.Replace(s.filename, util.FileExt(s.filename), "", -1), "", 1) // remove the filename from the subtitle name + name = strings.TrimSpace(name) + + // e.g. name = "title.eng" -> probableLangExt = ".eng" + probableLangExt := util.FileExt(name) + + // if probableLangExt is not empty, use it as the language + lang := cmp.Or(strings.TrimPrefix(probableLangExt, "."), name) + // cleanup lang + lang = strings.ReplaceAll(lang, "-", " ") + lang = strings.ReplaceAll(lang, "_", " ") + lang = strings.ReplaceAll(lang, ".", " ") + lang = strings.ReplaceAll(lang, ",", " ") + lang = cmp.Or(lang, fmt.Sprintf("Added track %d", num+1)) + + if name == "PLACEHOLDER" { + name = fmt.Sprintf("External (#%d)", subtitleNum+1) + lang = "und" + } + + track := &mkvparser.TrackInfo{ + Number: num, + UID: num + 900, + Type: mkvparser.TrackTypeSubtitle, + CodecID: "S_TEXT/ASS", + Name: name, + Language: lang, + LanguageIETF: lang, + Default: false, + Forced: false, + Enabled: true, + CodecPrivate: newContent, + } + + metadata.Tracks = append(metadata.Tracks, track) + metadata.SubtitleTracks = append(metadata.SubtitleTracks, track) + + s.logger.Debug(). + Msg("directstream: Sending subtitle file to the client") + + s.manager.nativePlayer.AddSubtitleTrack(s.clientId, track) +} diff --git a/seanime-2.9.10/internal/directstream/torrent.go b/seanime-2.9.10/internal/directstream/torrent.go new file mode 100644 index 0000000..56bc4a1 --- /dev/null +++ b/seanime-2.9.10/internal/directstream/torrent.go @@ -0,0 +1 @@ +package directstream diff --git a/seanime-2.9.10/internal/directstream/torrentstream.go b/seanime-2.9.10/internal/directstream/torrentstream.go new file mode 100644 index 0000000..361fa9e --- /dev/null +++ b/seanime-2.9.10/internal/directstream/torrentstream.go @@ -0,0 +1,229 @@ +package directstream + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/library/anime" + "seanime/internal/mkvparser" + "seanime/internal/nativeplayer" + "seanime/internal/util/result" + "seanime/internal/util/torrentutil" + + "github.com/anacrolix/torrent" + "github.com/google/uuid" + "github.com/samber/mo" +) + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Torrent +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var _ Stream = (*TorrentStream)(nil) + +// TorrentStream is a stream that is a torrent. +type TorrentStream struct { + BaseStream + torrent *torrent.Torrent + file *torrent.File + streamReadyCh chan struct{} // Closed by the initiator when the stream is ready +} + +func (s *TorrentStream) Type() nativeplayer.StreamType { + return nativeplayer.StreamTypeTorrent +} + +func (s *TorrentStream) LoadContentType() string { + s.contentTypeOnce.Do(func() { + r := s.file.NewReader() + defer r.Close() + s.contentType = loadContentType(s.file.DisplayPath(), r) + }) + + return s.contentType +} + +func (s *TorrentStream) LoadPlaybackInfo() (ret *nativeplayer.PlaybackInfo, err error) { + s.playbackInfoOnce.Do(func() { + if s.file == nil || s.torrent == nil { + ret = &nativeplayer.PlaybackInfo{} + err = fmt.Errorf("torrent is not set") + s.playbackInfoErr = err + return + } + + id := uuid.New().String() + + var entryListData *anime.EntryListData + if animeCollection, ok := s.manager.animeCollection.Get(); ok { + if listEntry, ok := animeCollection.GetListEntryFromAnimeId(s.media.ID); ok { + entryListData = anime.NewEntryListData(listEntry) + } + } + + playbackInfo := nativeplayer.PlaybackInfo{ + ID: id, + StreamType: s.Type(), + MimeType: s.LoadContentType(), + StreamUrl: "{{SERVER_URL}}/api/v1/directstream/stream?id=" + id, + ContentLength: s.file.Length(), + MkvMetadata: nil, + MkvMetadataParser: mo.None[*mkvparser.MetadataParser](), + Episode: s.episode, + Media: s.media, + EntryListData: entryListData, + } + + // If the content type is an EBML content type, we can create a metadata parser + if isEbmlContent(s.LoadContentType()) { + reader := torrentutil.NewReadSeeker(s.torrent, s.file, s.logger) + parser := mkvparser.NewMetadataParser(reader, s.logger) + metadata := parser.GetMetadata(context.Background()) + if metadata.Error != nil { + err = fmt.Errorf("failed to get metadata: %w", metadata.Error) + s.logger.Error().Err(metadata.Error).Msg("directstream(torrent): Failed to get metadata") + s.playbackInfoErr = err + return + } + + // Add subtitle tracks from subtitle files in the torrent + s.AppendSubtitleFile(s.torrent, s.file, metadata) + + playbackInfo.MkvMetadata = metadata + playbackInfo.MkvMetadataParser = mo.Some(parser) + } + + s.playbackInfo = &playbackInfo + }) + + return s.playbackInfo, s.playbackInfoErr +} + +func (s *TorrentStream) GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool) { + return getAttachmentByName(s.manager.playbackCtx, s, filename) +} + +func (s *TorrentStream) GetStreamHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.logger.Trace().Str("range", r.Header.Get("Range")).Str("method", r.Method).Msg("directstream(torrent): Stream endpoint hit") + + if s.file == nil || s.torrent == nil { + s.logger.Error().Msg("directstream(torrent): No torrent to stream") + http.Error(w, "No torrent to stream", http.StatusNotFound) + return + } + + size := s.file.Length() + contentType := s.LoadContentType() + name := s.file.DisplayPath() + + // Handle HEAD requests explicitly to provide file size information + if r.Method == http.MethodHead { + s.logger.Trace().Msg("directstream(torrent): Handling HEAD request") + // Set the content length from torrent file + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", name)) + w.WriteHeader(http.StatusOK) + return + } + + if isThumbnailRequest(r) { + reader := s.file.NewReader() + ra, ok := handleRange(w, r, reader, name, size) + if !ok { + return + } + serveContentRange(w, r, r.Context(), reader, name, size, contentType, ra) + return + } + + s.logger.Trace().Str("file", name).Msg("directstream(torrent): New reader") + tr := torrentutil.NewReadSeeker(s.torrent, s.file, s.logger) + defer func() { + s.logger.Trace().Msg("directstream(torrent): Closing reader") + _ = tr.Close() + }() + + ra, ok := handleRange(w, r, tr, name, size) + if !ok { + return + } + + if _, ok := s.playbackInfo.MkvMetadataParser.Get(); ok { + // Start a subtitle stream from the current position + subReader := s.file.NewReader() + subReader.SetResponsive() + s.StartSubtitleStream(s, s.manager.playbackCtx, subReader, ra.Start) + } + + serveContentRange(w, r, s.manager.playbackCtx, tr, name, size, s.LoadContentType(), ra) + }) +} + +type PlayTorrentStreamOptions struct { + ClientId string + EpisodeNumber int + AnidbEpisode string + Media *anilist.BaseAnime + Torrent *torrent.Torrent + File *torrent.File +} + +// PlayTorrentStream is used by a module to load a new torrent stream. +func (m *Manager) PlayTorrentStream(ctx context.Context, opts PlayTorrentStreamOptions) (chan struct{}, error) { + m.playbackMu.Lock() + defer m.playbackMu.Unlock() + + episodeCollection, err := anime.NewEpisodeCollection(anime.NewEpisodeCollectionOptions{ + AnimeMetadata: nil, + Media: opts.Media, + MetadataProvider: m.metadataProvider, + Logger: m.Logger, + }) + if err != nil { + return nil, fmt.Errorf("cannot play local file, could not create episode collection: %w", err) + } + + episode, ok := episodeCollection.FindEpisodeByAniDB(opts.AnidbEpisode) + if !ok { + return nil, fmt.Errorf("cannot play torrent stream, could not find episode: %s", opts.AnidbEpisode) + } + + stream := &TorrentStream{ + torrent: opts.Torrent, + file: opts.File, + BaseStream: BaseStream{ + manager: m, + logger: m.Logger, + clientId: opts.ClientId, + media: opts.Media, + filename: filepath.Base(opts.File.DisplayPath()), + episode: episode, + episodeCollection: episodeCollection, + subtitleEventCache: result.NewResultMap[string, *mkvparser.SubtitleEvent](), + activeSubtitleStreams: result.NewResultMap[string, *SubtitleStream](), + }, + streamReadyCh: make(chan struct{}), + } + + go func() { + <-stream.streamReadyCh + m.loadStream(stream) + }() + + return stream.streamReadyCh, nil +} + +// AppendSubtitleFile finds the subtitle file for the torrent and appends it as a track to the metadata +// - If there's only one subtitle file, use it +// - If there are multiple subtitle files, use the one that matches the name of the selected torrent file +// - If there are no subtitle files, do nothing +// +// If the subtitle file is not ASS/SSA, it will be converted to ASS/SSA. +func (s *TorrentStream) AppendSubtitleFile(t *torrent.Torrent, file *torrent.File, metadata *mkvparser.Metadata) { + +} diff --git a/seanime-2.9.10/internal/directstream/utils.go b/seanime-2.9.10/internal/directstream/utils.go new file mode 100644 index 0000000..dd6e55c --- /dev/null +++ b/seanime-2.9.10/internal/directstream/utils.go @@ -0,0 +1,38 @@ +package directstream + +import ( + "context" + "net/url" + "path/filepath" + "seanime/internal/mkvparser" +) + +func getAttachmentByName(ctx context.Context, stream Stream, filename string) (*mkvparser.AttachmentInfo, bool) { + filename, _ = url.PathUnescape(filename) + + container, err := stream.LoadPlaybackInfo() + if err != nil { + return nil, false + } + + parser, ok := container.MkvMetadataParser.Get() + if !ok { + return nil, false + } + + attachment, ok := parser.GetMetadata(ctx).GetAttachmentByName(filename) + if !ok { + return nil, false + } + + return attachment, true +} + +func isEbmlExtension(filename string) bool { + ext := filepath.Ext(filename) + return ext == ".mkv" || ext == ".m4v" || ext == ".mp4" +} + +func isEbmlContent(mimeType string) bool { + return mimeType == "video/x-matroska" || mimeType == "video/webm" +} diff --git a/seanime-2.9.10/internal/discordrpc/client/README.md b/seanime-2.9.10/internal/discordrpc/client/README.md new file mode 100644 index 0000000..11bcdd9 --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/client/README.md @@ -0,0 +1 @@ +Full credit to [thelennylord/discord-rpc](https://github.com/thelennylord/discord-rpc/) diff --git a/seanime-2.9.10/internal/discordrpc/client/activity.go b/seanime-2.9.10/internal/discordrpc/client/activity.go new file mode 100644 index 0000000..3c149b1 --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/client/activity.go @@ -0,0 +1,101 @@ +package discordrpc_client + +import ( + "os" + "strconv" + "time" + + "github.com/google/uuid" +) + +// Activity holds the data for discord rich presence +// +// See https://discord.com/developers/docs/game-sdk/activities#data-models-activity-struct +type Activity struct { + Name string `json:"name,omitempty"` + Details string `json:"details,omitempty"` + DetailsURL string `json:"details_url,omitempty"` // URL to details + State string `json:"state,omitempty"` + StateURL string `json:"state_url,omitempty"` // URL to state + + Timestamps *Timestamps `json:"timestamps,omitempty"` + Assets *Assets `json:"assets,omitempty"` + Party *Party `json:"party,omitempty"` + Secrets *Secrets `json:"secrets,omitempty"` + Buttons []*Button `json:"buttons,omitempty"` + + Instance bool `json:"instance"` + Type int `json:"type"` + StatusDisplayType int `json:"status_display_type,omitempty"` // 1 = name, 2 = details, 3 = state +} + +// Timestamps holds unix timestamps for start and/or end of the game +// +// See https://discord.com/developers/docs/game-sdk/activities#data-models-activitytimestamps-struct +type Timestamps struct { + Start *Epoch `json:"start,omitempty"` + End *Epoch `json:"end,omitempty"` +} + +// Epoch wrapper around time.Time to ensure times are sent as a unix epoch int +type Epoch struct{ time.Time } + +// MarshalJSON converts time.Time to unix time int +func (t Epoch) MarshalJSON() ([]byte, error) { + return []byte(strconv.FormatInt(t.Unix(), 10)), nil +} + +// Assets passes image references for inclusion in rich presence +// +// See https://discord.com/developers/docs/game-sdk/activities#data-models-activityassets-struct +type Assets struct { + LargeImage string `json:"large_image,omitempty"` + LargeText string `json:"large_text,omitempty"` + LargeURL string `json:"large_url,omitempty"` // URL to large image, if any + SmallImage string `json:"small_image,omitempty"` + SmallText string `json:"small_text,omitempty"` + SmallURL string `json:"small_url,omitempty"` // URL to small image, if any +} + +// Party holds information for the current party of the player +type Party struct { + ID string `json:"id"` + Size []int `json:"size"` // seems to be element [0] is count and [1] is max +} + +// Secrets holds secrets for Rich Presence joining and spectating +type Secrets struct { + Join string `json:"join,omitempty"` + Spectate string `json:"spectate,omitempty"` + Match string `json:"match,omitempty"` +} + +type Button struct { + Label string `json:"label,omitempty"` + Url string `json:"url,omitempty"` +} + +// SetActivity sets the Rich Presence activity for the running application +func (c *Client) SetActivity(activity Activity) error { + payload := Payload{ + Cmd: SetActivityCommand, + Args: Args{ + Pid: os.Getpid(), + Activity: &activity, + }, + Nonce: uuid.New(), + } + return c.SendPayload(payload) +} + +func (c *Client) CancelActivity() error { + payload := Payload{ + Cmd: SetActivityCommand, + Args: Args{ + Pid: os.Getpid(), + Activity: nil, + }, + Nonce: uuid.New(), + } + return c.SendPayload(payload) +} diff --git a/seanime-2.9.10/internal/discordrpc/client/client.go b/seanime-2.9.10/internal/discordrpc/client/client.go new file mode 100644 index 0000000..eef5b32 --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/client/client.go @@ -0,0 +1,55 @@ +package discordrpc_client + +import ( + "fmt" + "github.com/goccy/go-json" + "seanime/internal/discordrpc/ipc" +) + +// Client wrapper for the Discord RPC client +type Client struct { + ClientID string + Socket *discordrpc_ipc.Socket +} + +func (c *Client) Close() { + if c == nil { + return + } + c.Socket.Close() +} + +// New sends a handshake in the socket and returns an error or nil and an instance of Client +func New(clientId string) (*Client, error) { + if clientId == "" { + return nil, fmt.Errorf("no clientId set") + } + + payload, err := json.Marshal(handshake{"1", clientId}) + if err != nil { + return nil, err + } + + sock, err := discordrpc_ipc.NewConnection() + if err != nil { + return nil, err + } + + c := &Client{Socket: sock, ClientID: clientId} + + r, err := c.Socket.Send(0, string(payload)) + if err != nil { + return nil, err + } + + var responseBody Data + if err := json.Unmarshal([]byte(r), &responseBody); err != nil { + return nil, err + } + + if responseBody.Code > 1000 { + return nil, fmt.Errorf(responseBody.Message) + } + + return c, nil +} diff --git a/seanime-2.9.10/internal/discordrpc/client/client_test.go b/seanime-2.9.10/internal/discordrpc/client/client_test.go new file mode 100644 index 0000000..d437849 --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/client/client_test.go @@ -0,0 +1,49 @@ +package discordrpc_client + +import ( + "seanime/internal/constants" + "testing" + "time" +) + +func TestClient(t *testing.T) { + drpc, err := New(constants.DiscordApplicationId) + if err != nil { + t.Fatalf("failed to connect to discord ipc: %v", err) + } + defer drpc.Close() + + mangaActivity := Activity{ + Details: "Boku no Kokoro no Yabai Yatsu", + State: "Reading Chapter 30", + Assets: &Assets{ + LargeImage: "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx101557-bEJu54cmVYxx.jpg", + LargeText: "Boku no Kokoro no Yabai Yatsu", + SmallImage: "logo", + SmallText: "Seanime", + }, + Timestamps: &Timestamps{ + Start: &Epoch{ + Time: time.Now(), + }, + }, + Instance: true, + Type: 3, + } + + go func() { + _ = drpc.SetActivity(mangaActivity) + time.Sleep(10 * time.Second) + mangaActivity2 := mangaActivity + mangaActivity2.Timestamps.Start.Time = time.Now() + mangaActivity2.State = "Reading Chapter 31" + _ = drpc.SetActivity(mangaActivity2) + return + }() + + //if err != nil { + // t.Fatalf("failed to set activity: %v", err) + //} + + time.Sleep(30 * time.Second) +} diff --git a/seanime-2.9.10/internal/discordrpc/client/command.go b/seanime-2.9.10/internal/discordrpc/client/command.go new file mode 100644 index 0000000..2d3a51a --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/client/command.go @@ -0,0 +1,114 @@ +package discordrpc_client + +import ( + "fmt" + "github.com/goccy/go-json" + "github.com/google/uuid" +) + +type command string + +const ( + // DispatchCommand event dispatch + DispatchCommand command = "DISPATCH" + + // AuthorizeCommand used to authorize a new client with your app + AuthorizeCommand command = "AUTHORIZE" + + // AuthenticateCommand used to authenticate an existing client with your app + AuthenticateCommand command = "AUTHENTICATE" + + // GetGuildCommand used to retrieve guild information from the client + GetGuildCommand command = "GET_GUILD" + + // GetGuildsCommand used to retrieve a list of guilds from the client + GetGuildsCommand command = "GET_GUILDS" + + // GetChannelCommand used to retrieve channel information from the client + GetChannelCommand command = "GET_CHANNEL" + + // GetChannelsCommand used to retrieve a list of channels for a guild from the client + GetChannelsCommand command = "GET_CHANNELS" + + // SubscribeCommand used to subscribe to an RPC event + SubscribeCommand command = "SUBSCRIBE" + + // UnSubscribeCommand used to unsubscribe from an RPC event + UnSubscribeCommand command = "UNSUBSCRIBE" + + // SetUserVoiceSettingsCommand used to change voice settings of users in voice channels + SetUserVoiceSettingsCommand command = "SET_USER_VOICE_SETTINGS" + + // SelectVoiceChannelCommand used to join or leave a voice channel, group dm, or dm + SelectVoiceChannelCommand command = "SELECT_VOICE_CHANNEL" + + // GetSelectedVoiceChannelCommand used to get the current voice channel the client is in + GetSelectedVoiceChannelCommand command = "GET_SELECTED_VOICE_CHANNEL" + + // SelectTextChannelCommand used to join or leave a text channel, group dm, or dm + SelectTextChannelCommand command = "SELECT_TEXT_CHANNEL" + + // GetVoiceSettingsCommand used to retrieve the client's voice settings + GetVoiceSettingsCommand command = "GET_VOICE_SETTINGS" + + // SetVoiceSettingsCommand used to set the client's voice settings + SetVoiceSettingsCommand command = "SET_VOICE_SETTINGS" + + // CaptureShortcutCommand used to capture a keyboard shortcut entered by the user + CaptureShortcutCommand command = "CAPTURE_SHORTCUT" + + // SetCertifiedDevicesCommand used to send info about certified hardware devices + SetCertifiedDevicesCommand command = "SET_CERTIFIED_DEVICES" + + // SetActivityCommand used to update a user's Rich Presence + SetActivityCommand command = "SET_ACTIVITY" + + // SendActivityJoinInviteCommand used to consent to a Rich Presence Ask to Join request + SendActivityJoinInviteCommand command = "SEND_ACTIVITY_JOIN_INVITE" + + // CloseActivityRequestCommand used to reject a Rich Presence Ask to Join request + CloseActivityRequestCommand command = "CLOSE_ACTIVITY_REQUEST" +) + +type Payload struct { + Cmd command `json:"cmd"` + Args Args `json:"args"` + Event event `json:"evt,omitempty"` + Data *Data `json:"data,omitempty"` + Nonce uuid.UUID `json:"nonce"` +} + +// SendPayload sends payload to the Discord RPC server +func (c *Client) SendPayload(payload Payload) error { + if c == nil { + return nil + } + + // Marshal the payload into JSON + rb, err := json.Marshal(payload) + if err != nil { + return err + } + // Send over the socket + r, err := c.Socket.Send(1, string(rb)) + if err != nil { + return err + } + + // Response usually matches the outgoing request, also a payload + var responseBody Payload + if err := json.Unmarshal([]byte(r), &responseBody); err != nil { + return err + } + + // TODO: Convert op codes to enums? Either way seems that 1000 is good, everything else is bad + if responseBody.Data.Code > 1000 { + return fmt.Errorf(responseBody.Data.Message) + } + + if responseBody.Nonce != payload.Nonce { + return fmt.Errorf("invalid nonce") + } + + return nil +} diff --git a/seanime-2.9.10/internal/discordrpc/client/events.go b/seanime-2.9.10/internal/discordrpc/client/events.go new file mode 100644 index 0000000..342306b --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/client/events.go @@ -0,0 +1,66 @@ +package discordrpc_client + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type ActivityEventData struct { + Secret string `json:"secret"` + User *User `json:"user"` +} + +type event string + +var ( + ActivityJoinEvent event = "ACTIVITY_JOIN" + ActivitySpectateEvent event = "ACTIVITY_SPECTATE" + ActivityJoinRequestEvent event = "ACTIVITY_JOIN_REQUEST" +) + +func (c *Client) RegisterEvent(ch chan ActivityEventData, evt event) error { + if c == nil { + return nil + } + + payload := Payload{ + Cmd: SubscribeCommand, + Event: evt, + Nonce: uuid.New(), + } + + err := c.SendPayload(payload) + if err != nil { + return nil + } + + go func() { + for { + r, err := c.Socket.Read() + if err != nil { + continue + } + + var response struct { + Event event `json:"event"` + Data *ActivityEventData `json:"data"` + } + + if err := json.Unmarshal([]byte(r), &response); err != nil { + continue + } + + if response.Event == evt { + continue + } + + ch <- *response.Data + + time.Sleep(10 * time.Millisecond) + } + }() + + return nil +} diff --git a/seanime-2.9.10/internal/discordrpc/client/types.go b/seanime-2.9.10/internal/discordrpc/client/types.go new file mode 100644 index 0000000..b6cef4f --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/client/types.go @@ -0,0 +1,25 @@ +package discordrpc_client + +type handshake struct { + V string `json:"v"` + ClientID string `json:"client_id"` +} + +// Data section of the RPC response +type Data struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Args seems to contain the most data, Pid here is mandatory +type Args struct { + Pid int `json:"pid"` + Activity *Activity `json:"activity,omitempty"` +} + +type User struct { + Id string `json:"id"` + Username string `json:"username"` + Discriminator string `json:"discriminator"` + Avatar string `json:"avatar"` +} diff --git a/seanime-2.9.10/internal/discordrpc/ipc/ipc.go b/seanime-2.9.10/internal/discordrpc/ipc/ipc.go new file mode 100644 index 0000000..104858e --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/ipc/ipc.go @@ -0,0 +1,73 @@ +package discordrpc_ipc + +import ( + "bytes" + "encoding/binary" + "fmt" + "net" + "os" +) + +// GetIpcPath chooses the correct directory to the ipc socket and returns it +func GetIpcPath() string { + vn := []string{"XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"} + + for _, name := range vn { + path, exists := os.LookupEnv(name) + + if exists { + return path + } + } + + return "/tmp" +} + +// Socket extends net.Conn methods +type Socket struct { + net.Conn +} + +// Read the socket response +func (socket *Socket) Read() (string, error) { + buf := make([]byte, 512) + payloadLength, err := socket.Conn.Read(buf) + if err != nil { + return "", err + } + + buffer := new(bytes.Buffer) + for i := 8; i < payloadLength; i++ { + buffer.WriteByte(buf[i]) + } + + r := buffer.String() + if r == "" { + return "", fmt.Errorf("empty response") + } + + return r, nil +} + +// Send opcode and payload to the unix socket +func (socket *Socket) Send(opcode int, payload string) (string, error) { + buf := new(bytes.Buffer) + + err := binary.Write(buf, binary.LittleEndian, int32(opcode)) + if err != nil { + return "", err + } + + err = binary.Write(buf, binary.LittleEndian, int32(len(payload))) + if err != nil { + return "", err + } + + buf.Write([]byte(payload)) + _, err = socket.Write(buf.Bytes()) + if err != nil { + return "", err + } + + return socket.Read() +} diff --git a/seanime-2.9.10/internal/discordrpc/ipc/ipc_notwin.go b/seanime-2.9.10/internal/discordrpc/ipc/ipc_notwin.go new file mode 100644 index 0000000..5c099d7 --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/ipc/ipc_notwin.go @@ -0,0 +1,19 @@ +//go:build !windows +// +build !windows + +package discordrpc_ipc + +import ( + "net" + "time" +) + +// NewConnection opens the discord-ipc-0 unix socket +func NewConnection() (*Socket, error) { + sock, err := net.DialTimeout("unix", GetIpcPath()+"/discord-ipc-0", time.Second*2) + if err != nil { + return nil, err + } + + return &Socket{sock}, nil +} diff --git a/seanime-2.9.10/internal/discordrpc/ipc/ipc_windows.go b/seanime-2.9.10/internal/discordrpc/ipc/ipc_windows.go new file mode 100644 index 0000000..0481c24 --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/ipc/ipc_windows.go @@ -0,0 +1,24 @@ +//go:build windows +// +build windows + +package discordrpc_ipc + +import ( + "time" + + "github.com/Microsoft/go-winio" +) + +// NewConnection opens the discord-ipc-0 named pipe +func NewConnection() (*Socket, error) { + // Connect to the Windows named pipe, this is a well known name + // We use DialTimeout since it will block forever (or very, very long) on Windows + // if the pipe is not available (Discord not running) + t := 2 * time.Second + sock, err := winio.DialPipe(`\\.\pipe\discord-ipc-0`, &t) + if err != nil { + return nil, err + } + + return &Socket{sock}, nil +} diff --git a/seanime-2.9.10/internal/discordrpc/presence/hook_events.go b/seanime-2.9.10/internal/discordrpc/presence/hook_events.go new file mode 100644 index 0000000..6bdd43b --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/presence/hook_events.go @@ -0,0 +1,89 @@ +package discordrpc_presence + +import ( + discordrpc_client "seanime/internal/discordrpc/client" + "seanime/internal/hook_resolver" +) + +// DiscordPresenceAnimeActivityRequestedEvent is triggered when anime activity is requested, after the [animeActivity] is processed, and right before the activity is sent to queue. +// There is no guarantee as to when or if the activity will be successfully sent to discord. +// Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. +// Prevent default to stop the activity from being sent to discord. +type DiscordPresenceAnimeActivityRequestedEvent struct { + hook_resolver.Event + // Anime activity object used to generate the activity + AnimeActivity *AnimeActivity `json:"animeActivity"` + + // Name of the activity + Name string `json:"name"` + // Details of the activity + Details string `json:"details"` + DetailsURL string `json:"detailsUrl"` + // State of the activity + State string `json:"state"` + // Timestamps of the activity + StartTimestamp *int64 `json:"startTimestamp"` + EndTimestamp *int64 `json:"endTimestamp"` + + // Assets of the activity + LargeImage string `json:"largeImage"` + LargeText string `json:"largeText"` + LargeURL string `json:"largeUrl,omitempty"` // URL to large image, if any + SmallImage string `json:"smallImage"` + SmallText string `json:"smallText"` + SmallURL string `json:"smallUrl,omitempty"` // URL to small image, if any + + // Buttons of the activity + Buttons []*discordrpc_client.Button `json:"buttons"` + + // Whether the activity is an instance + Instance bool `json:"instance"` + // Type of the activity + Type int `json:"type"` + // StatusDisplayType controls formatting + StatusDisplayType int `json:"statusDisplayType,omitempty"` +} + +// DiscordPresenceMangaActivityRequestedEvent is triggered when manga activity is requested, after the [mangaActivity] is processed, and right before the activity is sent to queue. +// There is no guarantee as to when or if the activity will be successfully sent to discord. +// Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. +// Prevent default to stop the activity from being sent to discord. +type DiscordPresenceMangaActivityRequestedEvent struct { + hook_resolver.Event + // Manga activity object used to generate the activity + MangaActivity *MangaActivity `json:"mangaActivity"` + + // Name of the activity + Name string `json:"name"` + // Details of the activity + Details string `json:"details"` + DetailsURL string `json:"detailsUrl"` + // State of the activity + State string `json:"state"` + // Timestamps of the activity + StartTimestamp *int64 `json:"startTimestamp"` + EndTimestamp *int64 `json:"endTimestamp"` + + // Assets of the activity + LargeImage string `json:"largeImage"` + LargeText string `json:"largeText"` + LargeURL string `json:"largeUrl,omitempty"` // URL to large image, if any + SmallImage string `json:"smallImage"` + SmallText string `json:"smallText"` + SmallURL string `json:"smallUrl,omitempty"` // URL to small image, if any + + // Buttons of the activity + Buttons []*discordrpc_client.Button `json:"buttons"` + + // Whether the activity is an instance + Instance bool `json:"instance"` + // Type of the activity + Type int `json:"type"` + // StatusDisplayType controls formatting + StatusDisplayType int `json:"statusDisplayType,omitempty"` +} + +// DiscordPresenceClientClosedEvent is triggered when the discord rpc client is closed. +type DiscordPresenceClientClosedEvent struct { + hook_resolver.Event +} diff --git a/seanime-2.9.10/internal/discordrpc/presence/presence.go b/seanime-2.9.10/internal/discordrpc/presence/presence.go new file mode 100644 index 0000000..79244ba --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/presence/presence.go @@ -0,0 +1,667 @@ +package discordrpc_presence + +import ( + "context" + "fmt" + "seanime/internal/constants" + "seanime/internal/database/models" + discordrpc_client "seanime/internal/discordrpc/client" + "seanime/internal/hook" + "seanime/internal/util" + "sync" + "time" + + "github.com/rs/zerolog" + "github.com/samber/lo" +) + +type Presence struct { + client *discordrpc_client.Client + settings *models.DiscordSettings + logger *zerolog.Logger + hasSent bool + username string + mu sync.RWMutex + + animeActivity *AnimeActivity + lastAnimeActivityUpdateSent time.Time + + lastSent time.Time + eventQueue chan func() + cancelFunc context.CancelFunc // Cancel function for the event loop context +} + +// New creates a new Presence instance. +// If rich presence is enabled, it sets up a new discord rpc client. +func New(settings *models.DiscordSettings, logger *zerolog.Logger) *Presence { + var client *discordrpc_client.Client + + if settings != nil && settings.EnableRichPresence { + var err error + client, err = discordrpc_client.New(constants.DiscordApplicationId) + if err != nil { + logger.Error().Err(err).Msg("discordrpc: rich presence enabled but failed to create discord rpc client") + } + } + + p := &Presence{ + client: client, + settings: settings, + logger: logger, + lastAnimeActivityUpdateSent: time.Now().Add(5 * time.Second), + lastSent: time.Now().Add(-5 * time.Second), + hasSent: false, + eventQueue: make(chan func(), 100), + } + + if settings != nil && settings.EnableRichPresence { + p.startEventLoop() + } + + return p +} + +func (p *Presence) startEventLoop() { + // Cancel any existing goroutine + if p.cancelFunc != nil { + p.cancelFunc() + } + + // Create new context with cancel + ctx, cancel := context.WithCancel(context.Background()) + p.cancelFunc = cancel + + ticker := time.NewTicker(5 * time.Second) + + go func() { + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + p.logger.Debug().Msg("discordrpc: Event loop stopped") + return + case <-ticker.C: + select { + case job := <-p.eventQueue: + p.mu.RLock() + if p.client == nil { + p.mu.RUnlock() + continue + } + job() + p.lastSent = time.Now() + p.mu.RUnlock() + default: + } + } + } + }() +} + +// Close closes the discord rpc client. +// If the client is nil, it does nothing. +func (p *Presence) Close() { + p.close() + p.animeActivity = nil +} + +func (p *Presence) close() { + defer util.HandlePanicInModuleThen("discordrpc/presence/Close", func() {}) + p.clearEventQueue() + + // Cancel the event loop goroutine + if p.cancelFunc != nil { + p.cancelFunc() + p.cancelFunc = nil + } + + if p.client == nil { + return + } + p.client.Close() + p.client = nil + + _ = hook.GlobalHookManager.OnDiscordPresenceClientClosed().Trigger(&DiscordPresenceClientClosedEvent{}) +} + +func (p *Presence) SetSettings(settings *models.DiscordSettings) { + p.mu.Lock() + defer p.mu.Unlock() + + defer util.HandlePanicInModuleThen("discordrpc/presence/SetSettings", func() {}) + + // Close the current client and stop event loop + p.Close() + + settings.RichPresenceUseMediaTitleStatus = false // Devnote: Not used anymore, disable + settings.RichPresenceShowAniListMediaButton = false // Devnote: Not used anymore, disable + p.settings = settings + + // Create a new client if rich presence is enabled + if settings.EnableRichPresence { + p.logger.Info().Msg("discordrpc: Discord Rich Presence enabled") + p.setClient() + } else { + p.client = nil + } +} + +func (p *Presence) SetUsername(username string) { + p.mu.Lock() + defer p.mu.Unlock() + + p.username = username +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (p *Presence) setClient() { + defer util.HandlePanicInModuleThen("discordrpc/presence/setClient", func() {}) + + if p.client == nil { + client, err := discordrpc_client.New(constants.DiscordApplicationId) + if err != nil { + p.logger.Error().Err(err).Msg("discordrpc: Rich presence enabled but failed to create discord rpc client") + return + } + p.client = client + p.startEventLoop() + p.logger.Debug().Msg("discordrpc: RPC client initialized and event loop started") + } +} + +var isChecking bool + +// check executes multiple checks to determine if the presence should be set. +// It returns true if the presence should be set. +func (p *Presence) check() (proceed bool) { + defer util.HandlePanicInModuleThen("discordrpc/presence/check", func() { + proceed = false + }) + + if isChecking { + return false + } + isChecking = true + defer func() { + isChecking = false + }() + + // If the client is nil, return false + if p.settings == nil { + return false + } + + // If rich presence is disabled, return false + if !p.settings.EnableRichPresence { + return false + } + + // If the client is nil, create a new client + if p.client == nil { + p.setClient() + } + + // If the client is still nil, return false + if p.client == nil { + return false + } + + // If this is the first time setting the presence, return true + if !p.hasSent { + p.hasSent = true + return true + } + + // // If the last sent time is less than 5 seconds ago, return false + // if time.Since(p.lastSent) < 5*time.Second { + // rest := 5*time.Second - time.Since(p.lastSent) + // time.Sleep(rest) + // } + + return true +} + +var ( + defaultActivity = discordrpc_client.Activity{ + Name: "Seanime", + Details: "", + State: "", + Assets: &discordrpc_client.Assets{ + LargeImage: "", + LargeText: "", + SmallImage: "https://seanime.app/images/circular-logo.png", + SmallText: "Seanime v" + constants.Version, + SmallURL: "https://seanime.app", + }, + Timestamps: &discordrpc_client.Timestamps{ + Start: &discordrpc_client.Epoch{ + Time: time.Now(), + }, + }, + Buttons: []*discordrpc_client.Button{ + { + Label: "Seanime", + Url: "https://seanime.app", + }, + }, + Instance: true, + Type: 3, + StatusDisplayType: 2, + } +) + +func isSeanimeButtonPresent(activity *discordrpc_client.Activity) bool { + if activity == nil || activity.Buttons == nil { + return false + } + for _, button := range activity.Buttons { + if button.Label == "Seanime" && button.Url == "https://seanime.app" { + return true + } + } + return false +} + +type AnimeActivity struct { + ID int `json:"id"` + Title string `json:"title"` + Image string `json:"image"` + IsMovie bool `json:"isMovie"` + EpisodeNumber int `json:"episodeNumber"` + Paused bool `json:"paused"` + Progress int `json:"progress"` + Duration int `json:"duration"` + TotalEpisodes *int `json:"totalEpisodes,omitempty"` + CurrentEpisodeCount *int `json:"currentEpisodeCount,omitempty"` + EpisodeTitle *string `json:"episodeTitle,omitempty"` +} + +func animeActivityKey(a *AnimeActivity) string { + return fmt.Sprintf("%d:%d", a.ID, a.EpisodeNumber) +} + +func (p *Presence) SetAnimeActivity(a *AnimeActivity) { + p.mu.Lock() + defer p.mu.Unlock() + + defer util.HandlePanicInModuleThen("discordrpc/presence/SetAnimeActivity", func() {}) + + if !p.check() { + return + } + + if !p.settings.EnableAnimeRichPresence { + return + } + + // Clear the queue if the anime activity is different + if p.animeActivity != nil && animeActivityKey(a) != animeActivityKey(p.animeActivity) { + p.clearEventQueue() + } + + event := &DiscordPresenceAnimeActivityRequestedEvent{} + + state := fmt.Sprintf("Watching Episode %d", a.EpisodeNumber) + //if a.TotalEpisodes != nil { + // state += fmt.Sprintf(" of %d", *a.TotalEpisodes) + //} + if a.IsMovie { + state = "Watching Movie" + } + + activity := defaultActivity + activity.Details = a.Title + activity.DetailsURL = fmt.Sprintf("https://anilist.co/anime/%d", a.ID) + activity.State = state + activity.Assets.LargeImage = a.Image + activity.Assets.LargeText = a.Title + activity.Assets.LargeURL = fmt.Sprintf("https://anilist.co/anime/%d", a.ID) + + // Calculate the start time + startTime := time.Now() + if a.Progress > 0 { + startTime = startTime.Add(-time.Duration(a.Progress) * time.Second) + } + + activity.Timestamps.Start.Time = startTime + event.StartTimestamp = lo.ToPtr(startTime.Unix()) + endTime := startTime.Add(time.Duration(a.Duration) * time.Second) + activity.Timestamps.End = &discordrpc_client.Epoch{ + Time: endTime, + } + event.EndTimestamp = lo.ToPtr(endTime.Unix()) + + // Hide the end timestamp if the anime is paused + if a.Paused { + activity.Timestamps.End = nil + event.EndTimestamp = nil + } + + activity.Buttons = make([]*discordrpc_client.Button, 0) + + if p.settings.RichPresenceShowAniListProfileButton { + activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{ + Label: "View Profile", + Url: fmt.Sprintf("https://anilist.co/user/%s", p.username), + }) + } + + if !(p.settings.RichPresenceHideSeanimeRepositoryButton || len(activity.Buttons) > 1) { + activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{ + Label: "Seanime", + Url: "https://seanime.app", + }) + } + + // p.logger.Debug().Msgf("discordrpc: Setting anime activity: %s", a.Title) + + p.animeActivity = a + + event.AnimeActivity = a + event.Name = activity.Name + event.Details = activity.Details + event.DetailsURL = activity.DetailsURL + event.State = state + event.LargeImage = activity.Assets.LargeImage + event.LargeText = activity.Assets.LargeText + event.LargeURL = activity.Assets.LargeURL + event.SmallImage = activity.Assets.SmallImage + event.SmallText = activity.Assets.SmallText + event.SmallURL = activity.Assets.SmallURL + event.Buttons = activity.Buttons + event.Instance = defaultActivity.Instance + event.Type = defaultActivity.Type + + _ = hook.GlobalHookManager.OnDiscordPresenceAnimeActivityRequested().Trigger(event) + + if event.DefaultPrevented { + return + } + + // Update the activity + activity.Name = event.Name + activity.Details = event.Details + activity.DetailsURL = event.DetailsURL + activity.State = event.State + activity.Assets.LargeImage = event.LargeImage + activity.Assets.LargeText = event.LargeText + activity.Assets.LargeURL = event.LargeURL + activity.Buttons = event.Buttons + // Only allow changing small image and text if Seanime button is present + if isSeanimeButtonPresent(&activity) { + activity.Assets.SmallImage = event.SmallImage + activity.Assets.SmallText = event.SmallText + activity.Assets.SmallURL = event.SmallURL + } + // Update start timestamp + if event.StartTimestamp != nil { + activity.Timestamps.Start.Time = time.Unix(*event.StartTimestamp, 0) + } else { + activity.Timestamps.Start = nil + } + // Update end timestamp + if event.EndTimestamp != nil { + activity.Timestamps.End = &discordrpc_client.Epoch{ + Time: time.Unix(*event.EndTimestamp, 0), + } + } else { + activity.Timestamps.End = nil + } + // Reset timestamps if both are nil + if event.StartTimestamp == nil && event.EndTimestamp == nil { + activity.Timestamps = nil + } + activity.Instance = event.Instance + activity.Type = event.Type + + select { + case p.eventQueue <- func() { + _ = p.client.SetActivity(activity) + // p.logger.Debug().Int("progress", a.Progress).Int("duration", a.Duration).Msgf("discordrpc: Anime activity set for %s", a.Title) + }: + default: + //p.logger.Error().Msgf("discordrpc: event queue is full for %s", a.Title) + } +} + +// clearEventQueue drains the event queue channel +func (p *Presence) clearEventQueue() { + //p.logger.Debug().Msg("discordrpc: Clearing event queue") + for { + select { + case <-p.eventQueue: + default: + return + } + } +} + +func (p *Presence) UpdateAnimeActivity(progress int, duration int, paused bool) { + // do not lock, we call SetAnimeActivity + + defer util.HandlePanicInModuleThen("discordrpc/presence/UpdateWatching", func() {}) + + if p.animeActivity == nil { + return + } + + p.animeActivity.Progress = progress + p.animeActivity.Duration = duration + + // Pause status changed + if p.animeActivity.Paused != paused { + // p.logger.Debug().Msgf("discordrpc: Pause status changed to %t for %s", paused, p.animeActivity.Title) + p.animeActivity.Paused = paused + p.lastAnimeActivityUpdateSent = time.Now() + + // Clear the event queue to ensure pause/unpause takes precedence + p.clearEventQueue() + + if paused { + // p.logger.Debug().Msgf("discordrpc: Stopping activity for %s", p.animeActivity.Title) + // Stop the current activity if paused + // but do not erase the current activity + // p.close() + + // edit: just switch to default timestamp + p.SetAnimeActivity(p.animeActivity) + } else { + // p.logger.Debug().Msgf("discordrpc: Restarting activity for %s", p.animeActivity.Title) + // Restart the current activity if unpaused + p.SetAnimeActivity(p.animeActivity) + } + return + } + + // Handles seeking + if !p.animeActivity.Paused { + // If the last update was more than 5 seconds ago, update the activity + if time.Since(p.lastAnimeActivityUpdateSent) > 6*time.Second { + // p.logger.Debug().Msgf("discordrpc: Updating activity for %s", p.animeActivity.Title) + p.lastAnimeActivityUpdateSent = time.Now() + p.SetAnimeActivity(p.animeActivity) + } + } +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type LegacyAnimeActivity struct { + ID int `json:"id"` + Title string `json:"title"` + Image string `json:"image"` + IsMovie bool `json:"isMovie"` + EpisodeNumber int `json:"episodeNumber"` +} + +// LegacySetAnimeActivity sets the presence to watching anime. +func (p *Presence) LegacySetAnimeActivity(a *LegacyAnimeActivity) { + p.mu.Lock() + defer p.mu.Unlock() + + defer util.HandlePanicInModuleThen("discordrpc/presence/SetAnimeActivity", func() {}) + + if !p.check() { + return + } + + if !p.settings.EnableAnimeRichPresence { + return + } + + state := fmt.Sprintf("Watching Episode %d", a.EpisodeNumber) + if a.IsMovie { + state = "Watching Movie" + } + + activity := defaultActivity + activity.Details = a.Title + activity.DetailsURL = fmt.Sprintf("https://anilist.co/anime/%d", a.ID) + activity.State = state + activity.Assets.LargeImage = a.Image + activity.Assets.LargeText = a.Title + activity.Assets.LargeURL = fmt.Sprintf("https://anilist.co/anime/%d", a.ID) + activity.Timestamps.Start.Time = time.Now() + activity.Timestamps.End = nil + activity.Buttons = make([]*discordrpc_client.Button, 0) + + if p.settings.RichPresenceShowAniListProfileButton { + activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{ + Label: "View Profile", + Url: fmt.Sprintf("https://anilist.co/user/%s", p.username), + }) + } + + if !(p.settings.RichPresenceHideSeanimeRepositoryButton || len(activity.Buttons) > 1) { + activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{ + Label: "Seanime", + Url: "https://seanime.app", + }) + } + + // p.logger.Debug().Msgf("discordrpc: Setting anime activity: %s", a.Title) + + p.eventQueue <- func() { + _ = p.client.SetActivity(activity) + } +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type MangaActivity struct { + ID int `json:"id"` + Title string `json:"title"` + Image string `json:"image"` + Chapter string `json:"chapter"` +} + +// SetMangaActivity sets the presence to watching anime. +func (p *Presence) SetMangaActivity(a *MangaActivity) { + p.mu.Lock() + defer p.mu.Unlock() + + defer util.HandlePanicInModuleThen("discordrpc/presence/SetMangaActivity", func() {}) + + if !p.check() { + return + } + + if !p.settings.EnableMangaRichPresence { + return + } + + event := &DiscordPresenceMangaActivityRequestedEvent{} + + activity := defaultActivity + activity.Details = a.Title + activity.DetailsURL = fmt.Sprintf("https://anilist.co/manga/%d", a.ID) + activity.State = fmt.Sprintf("Reading Chapter %s", a.Chapter) + activity.Assets.LargeImage = a.Image + activity.Assets.LargeText = a.Title + activity.Assets.LargeURL = fmt.Sprintf("https://anilist.co/manga/%d", a.ID) + + now := time.Now() + activity.Timestamps.Start.Time = now + event.StartTimestamp = lo.ToPtr(now.Unix()) + activity.Timestamps.End = nil + event.EndTimestamp = nil + activity.Buttons = make([]*discordrpc_client.Button, 0) + + if p.settings.RichPresenceShowAniListProfileButton && p.username != "" { + activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{ + Label: "View Profile", + Url: fmt.Sprintf("https://anilist.co/user/%s", p.username), + }) + } + + if !(p.settings.RichPresenceHideSeanimeRepositoryButton || len(activity.Buttons) > 1) { + activity.Buttons = append(activity.Buttons, &discordrpc_client.Button{ + Label: "Seanime", + Url: "https://seanime.app", + }) + } + + event.MangaActivity = a + event.Name = activity.Name + event.Details = activity.Details + event.DetailsURL = activity.DetailsURL + event.State = activity.State + event.LargeImage = activity.Assets.LargeImage + event.LargeText = activity.Assets.LargeText + event.LargeURL = activity.Assets.LargeURL + event.SmallImage = activity.Assets.SmallImage + event.SmallText = activity.Assets.SmallText + event.SmallURL = activity.Assets.SmallURL + event.Buttons = activity.Buttons + event.Instance = activity.Instance + event.Type = activity.Type + + _ = hook.GlobalHookManager.OnDiscordPresenceMangaActivityRequested().Trigger(event) + + if event.DefaultPrevented { + return + } + + // Update the activity + activity.Name = event.Name + activity.Details = event.Details + activity.DetailsURL = event.DetailsURL + activity.State = event.State + activity.Assets.LargeImage = event.LargeImage + activity.Assets.LargeText = event.LargeText + activity.Assets.LargeURL = event.LargeURL + activity.Buttons = event.Buttons + // Only allow changing small image and text if Seanime button is present + if isSeanimeButtonPresent(&activity) { + activity.Assets.SmallImage = event.SmallImage + activity.Assets.SmallText = event.SmallText + activity.Assets.SmallURL = event.SmallURL + } + activity.Instance = event.Instance + activity.Type = event.Type + // Update start timestamp + if event.StartTimestamp != nil { + activity.Timestamps.Start.Time = time.Unix(*event.StartTimestamp, 0) + } else { + activity.Timestamps.Start = nil + } + // Update end timestamp + if event.EndTimestamp != nil { + activity.Timestamps.End = &discordrpc_client.Epoch{ + Time: time.Unix(*event.EndTimestamp, 0), + } + } else { + activity.Timestamps.End = nil + } + // Reset timestamps if both are nil + if event.StartTimestamp == nil && event.EndTimestamp == nil { + activity.Timestamps = nil + } + + p.logger.Debug().Msgf("discordrpc: Setting manga activity: %s", a.Title) + + p.eventQueue <- func() { + _ = p.client.SetActivity(activity) + } +} diff --git a/seanime-2.9.10/internal/discordrpc/presence/presence_test.go b/seanime-2.9.10/internal/discordrpc/presence/presence_test.go new file mode 100644 index 0000000..0e8bc04 --- /dev/null +++ b/seanime-2.9.10/internal/discordrpc/presence/presence_test.go @@ -0,0 +1,60 @@ +package discordrpc_presence + +import ( + "seanime/internal/database/models" + "seanime/internal/util" + "testing" + "time" +) + +func TestPresence(t *testing.T) { + + settings := &models.DiscordSettings{ + EnableRichPresence: true, + EnableAnimeRichPresence: true, + EnableMangaRichPresence: true, + } + + presence := New(nil, util.NewLogger()) + presence.SetSettings(settings) + presence.SetUsername("test") + defer presence.Close() + + presence.SetMangaActivity(&MangaActivity{ + Title: "Boku no Kokoro no Yabai Yatsu", + Image: "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx101557-bEJu54cmVYxx.jpg", + Chapter: "30", + }) + + time.Sleep(10 * time.Second) + + // Simulate settings being updated + + settings.EnableMangaRichPresence = false + presence.SetSettings(settings) + presence.SetUsername("test") + + time.Sleep(5 * time.Second) + + presence.SetMangaActivity(&MangaActivity{ + Title: "Boku no Kokoro no Yabai Yatsu", + Image: "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx101557-bEJu54cmVYxx.jpg", + Chapter: "31", + }) + + // Simulate settings being updated + + settings.EnableMangaRichPresence = true + presence.SetSettings(settings) + presence.SetUsername("test") + + time.Sleep(5 * time.Second) + + presence.SetMangaActivity(&MangaActivity{ + Title: "Boku no Kokoro no Yabai Yatsu", + Image: "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx101557-bEJu54cmVYxx.jpg", + Chapter: "31", + }) + + time.Sleep(10 * time.Second) +} diff --git a/seanime-2.9.10/internal/doh/doh.go b/seanime-2.9.10/internal/doh/doh.go new file mode 100644 index 0000000..8f071f6 --- /dev/null +++ b/seanime-2.9.10/internal/doh/doh.go @@ -0,0 +1,37 @@ +package doh + +import ( + "context" + "net" + "seanime/internal/util" + + "github.com/ncruces/go-dns" + "github.com/rs/zerolog" +) + +func HandleDoH(dohUrl string, logger *zerolog.Logger) { + defer util.HandlePanicInModuleThen("doh/HandleDoH", func() {}) + + if dohUrl == "" { + return + } + + logger.Info().Msgf("doh: Using DoH resolver: %s", dohUrl) + + // Set up the DoH resolver + resolver, err := dns.NewDoHResolver(dohUrl, dns.DoHCache()) + if err != nil { + logger.Error().Err(err).Msgf("doh: Failed to create DoH resolver: %s", dohUrl) + return + } + + // Override the default resolver + net.DefaultResolver = resolver + + // Test the resolver + _, err = resolver.LookupIPAddr(context.Background(), "ipv4.google.com") + if err != nil { + logger.Error().Err(err).Msgf("doh: DoH resolver failed lookup: %s", dohUrl) + return + } +} diff --git a/seanime-2.9.10/internal/doh/doh_test.go b/seanime-2.9.10/internal/doh/doh_test.go new file mode 100644 index 0000000..005c6d9 --- /dev/null +++ b/seanime-2.9.10/internal/doh/doh_test.go @@ -0,0 +1,86 @@ +package doh + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ncruces/go-dns" +) + +func TestDoHResolver(t *testing.T) { + // Start a temporary HTTP test server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("hello via DoH")) + })) + defer ts.Close() + + // Extract hostname and port from the test server URL + host, port, err := net.SplitHostPort(ts.Listener.Addr().String()) + if err != nil { + t.Fatalf("failed to parse test server address: %v", err) + } + + // Mock a "DNS record" by pointing a custom hostname to the test server's IP + fakeHostname := "test.local" + + dohURL := "https://cloudflare-dns.com/dns-query{?dns}" + + // Set up the DoH resolver + resolver, err := dns.NewDoHResolver(dohURL, dns.DoHCache()) + if err != nil { + t.Fatalf("failed to create DoH resolver: %v", err) + } + + // Override the default resolver + net.DefaultResolver = resolver + + // Use a custom DialContext to redirect fakeHostname to test server IP + dialer := &net.Dialer{ + Timeout: 3 * time.Second, + Resolver: &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + // Shortcut: Always return test server's IP for "test.local" + d := net.Dialer{} + if network == "udp" || network == "tcp" { + return d.Dial(network, net.JoinHostPort(host, "53")) + } + return d.Dial(network, address) + }, + }, + } + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // Intercept DNS for fakeHostname only + if addr[:len(fakeHostname)] == fakeHostname { + addr = net.JoinHostPort(host, port) + } + return dialer.DialContext(ctx, network, addr) + }, + }, + } + + // Make a request to the fake hostname (which we route to the test server) + resp, err := client.Get("http://" + fakeHostname + ":" + port) + if err != nil { + t.Fatalf("failed to GET: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200 OK, got %v", resp.Status) + } + + // Read the response body + bodyR, err := io.ReadAll(resp.Body) + + t.Log(string(bodyR)) +} diff --git a/seanime-2.9.10/internal/events/endpoints.go b/seanime-2.9.10/internal/events/endpoints.go new file mode 100644 index 0000000..0c53f6e --- /dev/null +++ b/seanime-2.9.10/internal/events/endpoints.go @@ -0,0 +1,218 @@ +// This code was generated by codegen/main.go. DO NOT EDIT. +package events + +const ( + AddUnknownMediaEndpoint = "ANIME-COLLECTION-add-unknown-media" + AnilistListAnimeEndpoint = "ANILIST-anilist-list-anime" + AnilistListMangaEndpoint = "MANGA-anilist-list-manga" + AnilistListMissedSequelsEndpoint = "ANILIST-anilist-list-missed-sequels" + AnilistListRecentAiringAnimeEndpoint = "ANILIST-anilist-list-recent-airing-anime" + AnimeEntryBulkActionEndpoint = "ANIME-ENTRIES-anime-entry-bulk-action" + AnimeEntryManualMatchEndpoint = "ANIME-ENTRIES-anime-entry-manual-match" + CancelDiscordActivityEndpoint = "DISCORD-cancel-discord-activity" + ClearAllChapterDownloadQueueEndpoint = "MANGA-DOWNLOAD-clear-all-chapter-download-queue" + ClearFileCacheMediastreamVideoFilesEndpoint = "FILECACHE-clear-file-cache-mediastream-video-files" + CreateAutoDownloaderRuleEndpoint = "AUTO-DOWNLOADER-create-auto-downloader-rule" + CreatePlaylistEndpoint = "PLAYLIST-create-playlist" + DebridAddTorrentsEndpoint = "DEBRID-debrid-add-torrents" + DebridCancelDownloadEndpoint = "DEBRID-debrid-cancel-download" + DebridCancelStreamEndpoint = "DEBRID-debrid-cancel-stream" + DebridDeleteTorrentEndpoint = "DEBRID-debrid-delete-torrent" + DebridDownloadTorrentEndpoint = "DEBRID-debrid-download-torrent" + DebridGetTorrentFilePreviewsEndpoint = "DEBRID-debrid-get-torrent-file-previews" + DebridGetTorrentInfoEndpoint = "DEBRID-debrid-get-torrent-info" + DebridGetTorrentsEndpoint = "DEBRID-debrid-get-torrents" + DebridStartStreamEndpoint = "DEBRID-debrid-start-stream" + DeleteAnilistListEntryEndpoint = "ANILIST-delete-anilist-list-entry" + DeleteAutoDownloaderItemEndpoint = "AUTO-DOWNLOADER-delete-auto-downloader-item" + DeleteAutoDownloaderRuleEndpoint = "AUTO-DOWNLOADER-delete-auto-downloader-rule" + DeleteLocalFilesEndpoint = "LOCALFILES-delete-local-files" + DeleteLogsEndpoint = "STATUS-delete-logs" + DeleteMangaDownloadedChaptersEndpoint = "MANGA-DOWNLOAD-delete-manga-downloaded-chapters" + DeletePlaylistEndpoint = "PLAYLIST-delete-playlist" + DirectorySelectorEndpoint = "DIRECTORY-SELECTOR-directory-selector" + DirectstreamPlayLocalFileEndpoint = "DIRECTSTREAM-directstream-play-local-file" + DownloadIssueReportEndpoint = "REPORT-download-issue-report" + DownloadMangaChaptersEndpoint = "MANGA-DOWNLOAD-download-manga-chapters" + DownloadReleaseEndpoint = "DOWNLOAD-download-release" + DownloadTorrentFileEndpoint = "DOWNLOAD-download-torrent-file" + EditAnilistListEntryEndpoint = "ANILIST-edit-anilist-list-entry" + EditMALListEntryProgressEndpoint = "MAL-edit-mal-list-entry-progress" + EmptyMangaEntryCacheEndpoint = "MANGA-empty-manga-entry-cache" + FetchAnimeEntrySuggestionsEndpoint = "ANIME-ENTRIES-fetch-anime-entry-suggestions" + FetchExternalExtensionDataEndpoint = "EXTENSIONS-fetch-external-extension-data" + ForceGCEndpoint = "STATUS-force-g-c" + GetActiveTorrentListEndpoint = "TORRENT-CLIENT-get-active-torrent-list" + GetAllExtensionsEndpoint = "EXTENSIONS-get-all-extensions" + GetAniListStatsEndpoint = "ANILIST-get-ani-list-stats" + GetAnilistAnimeDetailsEndpoint = "ANILIST-get-anilist-anime-details" + GetAnilistMangaCollectionEndpoint = "MANGA-get-anilist-manga-collection" + GetAnilistStudioDetailsEndpoint = "ANILIST-get-anilist-studio-details" + GetAnimeCollectionEndpoint = "ANILIST-get-anime-collection" + GetAnimeCollectionScheduleEndpoint = "ANIME-COLLECTION-get-anime-collection-schedule" + GetAnimeEntryEndpoint = "ANIME-ENTRIES-get-anime-entry" + GetAnimeEntrySilenceStatusEndpoint = "ANIME-ENTRIES-get-anime-entry-silence-status" + GetAnimeEpisodeCollectionEndpoint = "ANIME-get-anime-episode-collection" + GetAnnouncementsEndpoint = "STATUS-get-announcements" + GetAutoDownloaderItemsEndpoint = "AUTO-DOWNLOADER-get-auto-downloader-items" + GetAutoDownloaderRuleEndpoint = "AUTO-DOWNLOADER-get-auto-downloader-rule" + GetAutoDownloaderRulesEndpoint = "AUTO-DOWNLOADER-get-auto-downloader-rules" + GetAutoDownloaderRulesByAnimeEndpoint = "AUTO-DOWNLOADER-get-auto-downloader-rules-by-anime" + GetCPUProfileEndpoint = "STATUS-get-c-p-u-profile" + GetChangelogEndpoint = "RELEASES-get-changelog" + GetContinuityWatchHistoryEndpoint = "CONTINUITY-get-continuity-watch-history" + GetContinuityWatchHistoryItemEndpoint = "CONTINUITY-get-continuity-watch-history-item" + GetDebridSettingsEndpoint = "DEBRID-get-debrid-settings" + GetDocsEndpoint = "DOCS-get-docs" + GetExtensionPayloadEndpoint = "EXTENSIONS-get-extension-payload" + GetExtensionUpdateDataEndpoint = "EXTENSIONS-get-extension-update-data" + GetExtensionUserConfigEndpoint = "EXTENSIONS-get-extension-user-config" + GetFileCacheMediastreamVideoFilesTotalSizeEndpoint = "FILECACHE-get-file-cache-mediastream-video-files-total-size" + GetFileCacheTotalSizeEndpoint = "FILECACHE-get-file-cache-total-size" + GetGoRoutineProfileEndpoint = "STATUS-get-go-routine-profile" + GetLatestLogContentEndpoint = "STATUS-get-latest-log-content" + GetLatestUpdateEndpoint = "RELEASES-get-latest-update" + GetLibraryCollectionEndpoint = "ANIME-COLLECTION-get-library-collection" + GetLocalFilesEndpoint = "LOCALFILES-get-local-files" + GetLocalMangaPageEndpoint = "MANGA-get-local-manga-page" + GetLogFilenamesEndpoint = "STATUS-get-log-filenames" + GetMangaCollectionEndpoint = "MANGA-get-manga-collection" + GetMangaDownloadDataEndpoint = "MANGA-DOWNLOAD-get-manga-download-data" + GetMangaDownloadQueueEndpoint = "MANGA-DOWNLOAD-get-manga-download-queue" + GetMangaDownloadsListEndpoint = "MANGA-DOWNLOAD-get-manga-downloads-list" + GetMangaEntryEndpoint = "MANGA-get-manga-entry" + GetMangaEntryChaptersEndpoint = "MANGA-get-manga-entry-chapters" + GetMangaEntryDetailsEndpoint = "MANGA-get-manga-entry-details" + GetMangaEntryDownloadedChaptersEndpoint = "MANGA-get-manga-entry-downloaded-chapters" + GetMangaEntryPagesEndpoint = "MANGA-get-manga-entry-pages" + GetMangaLatestChapterNumbersMapEndpoint = "MANGA-get-manga-latest-chapter-numbers-map" + GetMangaMappingEndpoint = "MANGA-get-manga-mapping" + GetMarketplaceExtensionsEndpoint = "EXTENSIONS-get-marketplace-extensions" + GetMediastreamSettingsEndpoint = "MEDIASTREAM-get-mediastream-settings" + GetMemoryProfileEndpoint = "STATUS-get-memory-profile" + GetMemoryStatsEndpoint = "STATUS-get-memory-stats" + GetMissingEpisodesEndpoint = "ANIME-ENTRIES-get-missing-episodes" + GetNakamaAnimeAllLibraryFilesEndpoint = "NAKAMA-get-nakama-anime-all-library-files" + GetNakamaAnimeLibraryEndpoint = "NAKAMA-get-nakama-anime-library" + GetNakamaAnimeLibraryCollectionEndpoint = "NAKAMA-get-nakama-anime-library-collection" + GetNakamaAnimeLibraryFilesEndpoint = "NAKAMA-get-nakama-anime-library-files" + GetOnlineStreamEpisodeListEndpoint = "ONLINESTREAM-get-online-stream-episode-list" + GetOnlineStreamEpisodeSourceEndpoint = "ONLINESTREAM-get-online-stream-episode-source" + GetOnlinestreamMappingEndpoint = "ONLINESTREAM-get-onlinestream-mapping" + GetPlaylistEpisodesEndpoint = "PLAYLIST-get-playlist-episodes" + GetPlaylistsEndpoint = "PLAYLIST-get-playlists" + GetPluginSettingsEndpoint = "EXTENSIONS-get-plugin-settings" + GetRawAnilistMangaCollectionEndpoint = "MANGA-get-raw-anilist-manga-collection" + GetRawAnimeCollectionEndpoint = "ANILIST-get-raw-anime-collection" + GetScanSummariesEndpoint = "SCAN-SUMMARY-get-scan-summaries" + GetSettingsEndpoint = "SETTINGS-get-settings" + GetStatusEndpoint = "STATUS-get-status" + GetThemeEndpoint = "THEME-get-theme" + GetTorrentstreamBatchHistoryEndpoint = "TORRENTSTREAM-get-torrentstream-batch-history" + GetTorrentstreamSettingsEndpoint = "TORRENTSTREAM-get-torrentstream-settings" + GetTorrentstreamTorrentFilePreviewsEndpoint = "TORRENTSTREAM-get-torrentstream-torrent-file-previews" + GettingStartedEndpoint = "SETTINGS-getting-started" + GrantPluginPermissionsEndpoint = "EXTENSIONS-grant-plugin-permissions" + ImportLocalFilesEndpoint = "LOCALFILES-import-local-files" + InstallExternalExtensionEndpoint = "EXTENSIONS-install-external-extension" + InstallLatestUpdateEndpoint = "RELEASES-install-latest-update" + ListAnimeTorrentProviderExtensionsEndpoint = "EXTENSIONS-list-anime-torrent-provider-extensions" + ListDevelopmentModeExtensionsEndpoint = "EXTENSIONS-list-development-mode-extensions" + ListExtensionDataEndpoint = "EXTENSIONS-list-extension-data" + ListMangaProviderExtensionsEndpoint = "EXTENSIONS-list-manga-provider-extensions" + ListOnlinestreamProviderExtensionsEndpoint = "EXTENSIONS-list-onlinestream-provider-extensions" + LocalAddTrackedMediaEndpoint = "LOCAL-local-add-tracked-media" + LocalFileBulkActionEndpoint = "LOCALFILES-local-file-bulk-action" + LocalGetHasLocalChangesEndpoint = "LOCAL-local-get-has-local-changes" + LocalGetIsMediaTrackedEndpoint = "LOCAL-local-get-is-media-tracked" + LocalGetLocalStorageSizeEndpoint = "LOCAL-local-get-local-storage-size" + LocalGetSyncQueueStateEndpoint = "LOCAL-local-get-sync-queue-state" + LocalGetTrackedMediaItemsEndpoint = "LOCAL-local-get-tracked-media-items" + LocalRemoveTrackedMediaEndpoint = "LOCAL-local-remove-tracked-media" + LocalSetHasLocalChangesEndpoint = "LOCAL-local-set-has-local-changes" + LocalSyncAnilistDataEndpoint = "LOCAL-local-sync-anilist-data" + LocalSyncDataEndpoint = "LOCAL-local-sync-data" + LocalSyncSimulatedDataToAnilistEndpoint = "LOCAL-local-sync-simulated-data-to-anilist" + LoginEndpoint = "AUTH-login" + LogoutEndpoint = "AUTH-logout" + MALAuthEndpoint = "MAL-mal-auth" + MALLogoutEndpoint = "MAL-mal-logout" + MangaManualMappingEndpoint = "MANGA-manga-manual-mapping" + MangaManualSearchEndpoint = "MANGA-manga-manual-search" + MediastreamShutdownTranscodeStreamEndpoint = "MEDIASTREAM-mediastream-shutdown-transcode-stream" + NakamaCreateWatchPartyEndpoint = "NAKAMA-nakama-create-watch-party" + NakamaJoinWatchPartyEndpoint = "NAKAMA-nakama-join-watch-party" + NakamaLeaveWatchPartyEndpoint = "NAKAMA-nakama-leave-watch-party" + NakamaPlayVideoEndpoint = "NAKAMA-nakama-play-video" + NakamaReconnectToHostEndpoint = "NAKAMA-nakama-reconnect-to-host" + NakamaRemoveStaleConnectionsEndpoint = "NAKAMA-nakama-remove-stale-connections" + NakamaWebSocketEndpoint = "NAKAMA-nakama-web-socket" + OnlineStreamEmptyCacheEndpoint = "ONLINESTREAM-online-stream-empty-cache" + OnlinestreamManualMappingEndpoint = "ONLINESTREAM-onlinestream-manual-mapping" + OnlinestreamManualSearchEndpoint = "ONLINESTREAM-onlinestream-manual-search" + OpenAnimeEntryInExplorerEndpoint = "ANIME-ENTRIES-open-anime-entry-in-explorer" + OpenInExplorerEndpoint = "EXPLORER-open-in-explorer" + PlaybackAutoPlayNextEpisodeEndpoint = "PLAYBACK-MANAGER-playback-auto-play-next-episode" + PlaybackCancelCurrentPlaylistEndpoint = "PLAYBACK-MANAGER-playback-cancel-current-playlist" + PlaybackCancelManualTrackingEndpoint = "PLAYBACK-MANAGER-playback-cancel-manual-tracking" + PlaybackGetNextEpisodeEndpoint = "PLAYBACK-MANAGER-playback-get-next-episode" + PlaybackPlayNextEpisodeEndpoint = "PLAYBACK-MANAGER-playback-play-next-episode" + PlaybackPlayRandomVideoEndpoint = "PLAYBACK-MANAGER-playback-play-random-video" + PlaybackPlayVideoEndpoint = "PLAYBACK-MANAGER-playback-play-video" + PlaybackPlaylistNextEndpoint = "PLAYBACK-MANAGER-playback-playlist-next" + PlaybackStartManualTrackingEndpoint = "PLAYBACK-MANAGER-playback-start-manual-tracking" + PlaybackStartPlaylistEndpoint = "PLAYBACK-MANAGER-playback-start-playlist" + PlaybackSyncCurrentProgressEndpoint = "PLAYBACK-MANAGER-playback-sync-current-progress" + PopulateFillerDataEndpoint = "METADATA-populate-filler-data" + PreloadMediastreamMediaContainerEndpoint = "MEDIASTREAM-preload-mediastream-media-container" + RefetchMangaChapterContainersEndpoint = "MANGA-refetch-manga-chapter-containers" + ReloadExternalExtensionEndpoint = "EXTENSIONS-reload-external-extension" + ReloadExternalExtensionsEndpoint = "EXTENSIONS-reload-external-extensions" + RemoveEmptyDirectoriesEndpoint = "LOCALFILES-remove-empty-directories" + RemoveFileCacheBucketEndpoint = "FILECACHE-remove-file-cache-bucket" + RemoveFillerDataEndpoint = "METADATA-remove-filler-data" + RemoveMangaMappingEndpoint = "MANGA-remove-manga-mapping" + RemoveOnlinestreamMappingEndpoint = "ONLINESTREAM-remove-onlinestream-mapping" + RequestMediastreamMediaContainerEndpoint = "MEDIASTREAM-request-mediastream-media-container" + ResetErroredChapterDownloadQueueEndpoint = "MANGA-DOWNLOAD-reset-errored-chapter-download-queue" + RunAutoDownloaderEndpoint = "AUTO-DOWNLOADER-run-auto-downloader" + RunExtensionPlaygroundCodeEndpoint = "EXTENSIONS-run-extension-playground-code" + SaveAutoDownloaderSettingsEndpoint = "SETTINGS-save-auto-downloader-settings" + SaveDebridSettingsEndpoint = "DEBRID-save-debrid-settings" + SaveExtensionUserConfigEndpoint = "EXTENSIONS-save-extension-user-config" + SaveIssueReportEndpoint = "REPORT-save-issue-report" + SaveMediastreamSettingsEndpoint = "MEDIASTREAM-save-mediastream-settings" + SaveSettingsEndpoint = "SETTINGS-save-settings" + SaveTorrentstreamSettingsEndpoint = "TORRENTSTREAM-save-torrentstream-settings" + ScanLocalFilesEndpoint = "SCAN-scan-local-files" + SearchTorrentEndpoint = "TORRENT-SEARCH-search-torrent" + SendNakamaMessageEndpoint = "NAKAMA-send-nakama-message" + SetDiscordAnimeActivityWithProgressEndpoint = "DISCORD-set-discord-anime-activity-with-progress" + SetDiscordLegacyAnimeActivityEndpoint = "DISCORD-set-discord-legacy-anime-activity" + SetDiscordMangaActivityEndpoint = "DISCORD-set-discord-manga-activity" + SetOfflineModeEndpoint = "LOCAL-set-offline-mode" + SetPluginSettingsPinnedTraysEndpoint = "EXTENSIONS-set-plugin-settings-pinned-trays" + StartDefaultMediaPlayerEndpoint = "MEDIAPLAYER-start-default-media-player" + StartMangaDownloadQueueEndpoint = "MANGA-DOWNLOAD-start-manga-download-queue" + StopMangaDownloadQueueEndpoint = "MANGA-DOWNLOAD-stop-manga-download-queue" + TestDumpEndpoint = "MANUAL-DUMP-test-dump" + ToggleAnimeEntrySilenceStatusEndpoint = "ANIME-ENTRIES-toggle-anime-entry-silence-status" + TorrentClientActionEndpoint = "TORRENT-CLIENT-torrent-client-action" + TorrentClientAddMagnetFromRuleEndpoint = "TORRENT-CLIENT-torrent-client-add-magnet-from-rule" + TorrentClientDownloadEndpoint = "TORRENT-CLIENT-torrent-client-download" + TorrentstreamDropTorrentEndpoint = "TORRENTSTREAM-torrentstream-drop-torrent" + TorrentstreamStartStreamEndpoint = "TORRENTSTREAM-torrentstream-start-stream" + TorrentstreamStopStreamEndpoint = "TORRENTSTREAM-torrentstream-stop-stream" + UninstallExternalExtensionEndpoint = "EXTENSIONS-uninstall-external-extension" + UpdateAnimeEntryProgressEndpoint = "ANIME-ENTRIES-update-anime-entry-progress" + UpdateAnimeEntryRepeatEndpoint = "ANIME-ENTRIES-update-anime-entry-repeat" + UpdateAutoDownloaderRuleEndpoint = "AUTO-DOWNLOADER-update-auto-downloader-rule" + UpdateContinuityWatchHistoryItemEndpoint = "CONTINUITY-update-continuity-watch-history-item" + UpdateDiscordAnimeActivityWithProgressEndpoint = "DISCORD-update-discord-anime-activity-with-progress" + UpdateExtensionCodeEndpoint = "EXTENSIONS-update-extension-code" + UpdateLocalFileDataEndpoint = "LOCALFILES-update-local-file-data" + UpdateLocalFilesEndpoint = "LOCALFILES-update-local-files" + UpdateMangaProgressEndpoint = "MANGA-update-manga-progress" + UpdatePlaylistEndpoint = "PLAYLIST-update-playlist" + UpdateThemeEndpoint = "THEME-update-theme" +) diff --git a/seanime-2.9.10/internal/events/events.go b/seanime-2.9.10/internal/events/events.go new file mode 100644 index 0000000..2045448 --- /dev/null +++ b/seanime-2.9.10/internal/events/events.go @@ -0,0 +1,98 @@ +package events + +type WebsocketClientEventType string + +const ( + NativePlayerEventType WebsocketClientEventType = "native-player" + NakamaEventType WebsocketClientEventType = "nakama" + PluginEvent WebsocketClientEventType = "plugin" +) + +type WebsocketClientEvent struct { + ClientID string `json:"clientId"` + Type WebsocketClientEventType `json:"type"` + Payload interface{} `json:"payload"` +} + +const ( + ServerReady = "server-ready" // The anilist data has been loaded + + EventScanProgress = "scan-progress" // Progress of the scan + EventScanStatus = "scan-status" // Status text of the scan + RefreshedAnilistAnimeCollection = "refreshed-anilist-anime-collection" // The anilist collection has been refreshed + RefreshedAnilistMangaCollection = "refreshed-anilist-manga-collection" // The manga collection has been refreshed + LibraryWatcherFileAdded = "library-watcher-file-added" // A new file has been added to the library + LibraryWatcherFileRemoved = "library-watcher-file-removed" // A file has been removed from the library + AutoDownloaderItemAdded = "auto-downloader-item-added" // An item has been added to the auto downloader queue + + AutoScanStarted = "auto-scan-started" // The auto scan has started + AutoScanCompleted = "auto-scan-completed" // The auto scan has stopped + + PlaybackManagerProgressTrackingStarted = "playback-manager-progress-tracking-started" // The video progress tracking has started + PlaybackManagerProgressTrackingStopped = "playback-manager-progress-tracking-stopped" // The video progress tracking has stopped + PlaybackManagerProgressVideoCompleted = "playback-manager-progress-video-completed" // The video progress has been completed + PlaybackManagerProgressPlaybackState = "playback-manager-progress-playback-state" // Dispatches the current playback state + PlaybackManagerProgressUpdated = "playback-manager-progress-updated" // Signals that the progress has been updated + PlaybackManagerPlaylistState = "playback-manager-playlist-state" // Dispatches the current playlist state + PlaybackManagerManualTrackingPlaybackState = "playback-manager-manual-tracking-playback-state" // Dispatches the current playback state + PlaybackManagerManualTrackingStopped = "playback-manager-manual-tracking-stopped" // The manual tracking has been stopped + + ExternalPlayerOpenURL = "external-player-open-url" // Open a URL to send media to an external media player + + InfoToast = "info-toast" + ErrorToast = "error-toast" + WarningToast = "warning-toast" + SuccessToast = "success-toast" + + CheckForUpdates = "check-for-updates" + CheckForAnnouncements = "check-for-announcements" + + RefreshedMangaDownloadData = "refreshed-manga-download-data" + ChapterDownloadQueueUpdated = "chapter-download-queue-updated" + OfflineSnapshotCreated = "offline-snapshot-created" + + MediastreamShutdownStream = "mediastream-shutdown-stream" + + ExtensionsReloaded = "extensions-reloaded" + ExtensionUpdatesFound = "extension-updates-found" + PluginUnloaded = "plugin-unloaded" + PluginLoaded = "plugin-loaded" + + ActiveTorrentCountUpdated = "active-torrent-count-updated" + + SyncLocalQueueState = "sync-local-queue-state" + SyncLocalFinished = "sync-local-finished" + SyncAnilistFinished = "sync-anilist-finished" + + TorrentStreamState = "torrentstream-state" + + DebridDownloadProgress = "debrid-download-progress" + DebridStreamState = "debrid-stream-state" + + InvalidateQueries = "invalidate-queries" + ConsoleLog = "console-log" + ConsoleWarn = "console-warn" + + ShowIndefiniteLoader = "show-indefinite-loader" + HideIndefiniteLoader = "hide-indefinite-loader" + + // Nakama events + NakamaHostStarted = "nakama-host-started" + NakamaHostStopped = "nakama-host-stopped" + NakamaPeerConnected = "nakama-peer-connected" + NakamaPeerDisconnected = "nakama-peer-disconnected" + NakamaHostConnected = "nakama-host-connected" + NakamaHostDisconnected = "nakama-host-disconnected" + NakamaError = "nakama-error" + NakamaAnimeLibraryReceived = "nakama-anime-library-received" + NakamaCustomMessage = "nakama-custom-message" + NakamaStatusRequested = "nakama-status-requested" + NakamaStatus = "nakama-status" + + NakamaOnlineStreamEvent = "nakama-online-stream-event" + + // Nakama Watch Party events + NakamaWatchPartyState = "nakama-watch-party-state" + NakamaWatchPartyEnableRelayMode = "nakama-watch-party-enable-relay-mode" + NakamaWatchPartyRelayModeToggleShareLibraryWithOrigin = "nakama-watch-party-relay-mode-toggle-share-library-with-origin" +) diff --git a/seanime-2.9.10/internal/events/websocket.go b/seanime-2.9.10/internal/events/websocket.go new file mode 100644 index 0000000..254c994 --- /dev/null +++ b/seanime-2.9.10/internal/events/websocket.go @@ -0,0 +1,289 @@ +package events + +import ( + "os" + "seanime/internal/util" + "seanime/internal/util/result" + "sync" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/gorilla/websocket" + "github.com/rs/zerolog" +) + +type WSEventManagerInterface interface { + SendEvent(t string, payload interface{}) + SendEventTo(clientId string, t string, payload interface{}, noLog ...bool) + SubscribeToClientEvents(id string) *ClientEventSubscriber + SubscribeToClientNativePlayerEvents(id string) *ClientEventSubscriber + SubscribeToClientNakamaEvents(id string) *ClientEventSubscriber + UnsubscribeFromClientEvents(id string) +} + +type GlobalWSEventManagerWrapper struct { + WSEventManager WSEventManagerInterface +} + +var GlobalWSEventManager *GlobalWSEventManagerWrapper + +func (w *GlobalWSEventManagerWrapper) SendEvent(t string, payload interface{}) { + if w.WSEventManager == nil { + return + } + w.WSEventManager.SendEvent(t, payload) +} + +func (w *GlobalWSEventManagerWrapper) SendEventTo(clientId string, t string, payload interface{}, noLog ...bool) { + if w.WSEventManager == nil { + return + } + w.WSEventManager.SendEventTo(clientId, t, payload, noLog...) +} + +type ( + // WSEventManager holds the websocket connection instance. + // It is attached to the App instance, so it is available to other handlers. + WSEventManager struct { + Conns []*WSConn + Logger *zerolog.Logger + hasHadConnection bool + mu sync.Mutex + eventMu sync.RWMutex + clientEventSubscribers *result.Map[string, *ClientEventSubscriber] + clientNativePlayerEventSubscribers *result.Map[string, *ClientEventSubscriber] + nakamaEventSubscribers *result.Map[string, *ClientEventSubscriber] + } + + ClientEventSubscriber struct { + Channel chan *WebsocketClientEvent + mu sync.RWMutex + closed bool + } + + WSConn struct { + ID string + Conn *websocket.Conn + } + + WSEvent struct { + Type string `json:"type"` + Payload interface{} `json:"payload"` + } +) + +// NewWSEventManager creates a new WSEventManager instance for App. +func NewWSEventManager(logger *zerolog.Logger) *WSEventManager { + ret := &WSEventManager{ + Logger: logger, + Conns: make([]*WSConn, 0), + clientEventSubscribers: result.NewResultMap[string, *ClientEventSubscriber](), + clientNativePlayerEventSubscribers: result.NewResultMap[string, *ClientEventSubscriber](), + nakamaEventSubscribers: result.NewResultMap[string, *ClientEventSubscriber](), + } + GlobalWSEventManager = &GlobalWSEventManagerWrapper{ + WSEventManager: ret, + } + return ret +} + +// ExitIfNoConnsAsDesktopSidecar monitors the websocket connection as a desktop sidecar. +// It checks for a connection every 5 seconds. If a connection is lost, it starts a countdown a waits for 15 seconds. +// If a connection is not established within 15 seconds, it will exit the app. +func (m *WSEventManager) ExitIfNoConnsAsDesktopSidecar() { + go func() { + defer util.HandlePanicInModuleThen("events/ExitIfNoConnsAsDesktopSidecar", func() {}) + + m.Logger.Info().Msg("ws: Monitoring connection as desktop sidecar") + // Create a ticker to check connection every 5 seconds + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + // Track connection loss time + var connectionLostTime time.Time + exitTimeout := 10 * time.Second + + for range ticker.C { + // Check WebSocket connection status + if len(m.Conns) == 0 && m.hasHadConnection { + // If not connected and first detection of connection loss + if connectionLostTime.IsZero() { + m.Logger.Warn().Msg("ws: No connection detected. Starting countdown...") + connectionLostTime = time.Now() + } + + // Check if connection has been lost for more than 15 seconds + if time.Since(connectionLostTime) > exitTimeout { + m.Logger.Warn().Msg("ws: No connection detected for 10 seconds. Exiting...") + os.Exit(1) + } + } else { + // Connection is active, reset connection lost time + connectionLostTime = time.Time{} + } + } + }() +} + +func (m *WSEventManager) AddConn(id string, conn *websocket.Conn) { + m.hasHadConnection = true + m.Conns = append(m.Conns, &WSConn{ + ID: id, + Conn: conn, + }) +} + +func (m *WSEventManager) RemoveConn(id string) { + for i, conn := range m.Conns { + if conn.ID == id { + m.Conns = append(m.Conns[:i], m.Conns[i+1:]...) + break + } + } +} + +// SendEvent sends a websocket event to the client. +func (m *WSEventManager) SendEvent(t string, payload interface{}) { + m.mu.Lock() + defer m.mu.Unlock() + // If there's no connection, do nothing + //if m.Conn == nil { + // return + //} + + if t != PlaybackManagerProgressPlaybackState && payload == nil { + m.Logger.Trace().Str("type", t).Msg("ws: Sending message") + } + + for _, conn := range m.Conns { + err := conn.Conn.WriteJSON(WSEvent{ + Type: t, + Payload: payload, + }) + if err != nil { + // Note: NaN error coming from [progress_tracking.go] + //m.Logger.Err(err).Msg("ws: Failed to send message") + } + //m.Logger.Trace().Str("type", t).Msg("ws: Sent message") + } + + //err := m.Conn.WriteJSON(WSEvent{ + // Type: t, + // Payload: payload, + //}) + //if err != nil { + // m.Logger.Err(err).Msg("ws: Failed to send message") + //} + //m.Logger.Trace().Str("type", t).Msg("ws: Sent message") +} + +// SendEventTo sends a websocket event to the specified client. +func (m *WSEventManager) SendEventTo(clientId string, t string, payload interface{}, noLog ...bool) { + m.mu.Lock() + defer m.mu.Unlock() + + for _, conn := range m.Conns { + if conn.ID == clientId { + if t != "pong" { + if len(noLog) == 0 || !noLog[0] { + truncated := spew.Sprint(payload) + if len(truncated) > 500 { + truncated = truncated[:500] + "..." + } + m.Logger.Trace().Str("to", clientId).Str("type", t).Str("payload", truncated).Msg("ws: Sending message") + } + } + _ = conn.Conn.WriteJSON(WSEvent{ + Type: t, + Payload: payload, + }) + } + } +} + +func (m *WSEventManager) SendStringTo(clientId string, s string) { + m.mu.Lock() + defer m.mu.Unlock() + + for _, conn := range m.Conns { + if conn.ID == clientId { + _ = conn.Conn.WriteMessage(websocket.TextMessage, []byte(s)) + } + } +} + +func (m *WSEventManager) OnClientEvent(event *WebsocketClientEvent) { + m.eventMu.RLock() + defer m.eventMu.RUnlock() + + onEvent := func(key string, subscriber *ClientEventSubscriber) bool { + go func() { + defer util.HandlePanicInModuleThen("events/OnClientEvent/clientNativePlayerEventSubscribers", func() {}) + subscriber.mu.RLock() + defer subscriber.mu.RUnlock() + if !subscriber.closed { + select { + case subscriber.Channel <- event: + default: + // Channel is blocked, skip sending + m.Logger.Warn().Msg("ws: Client event channel is blocked, event dropped") + } + } + }() + return true + } + + switch event.Type { + case NativePlayerEventType: + m.clientNativePlayerEventSubscribers.Range(onEvent) + case NakamaEventType: + m.nakamaEventSubscribers.Range(onEvent) + default: + m.clientEventSubscribers.Range(onEvent) + } +} + +func (m *WSEventManager) SubscribeToClientEvents(id string) *ClientEventSubscriber { + subscriber := &ClientEventSubscriber{ + Channel: make(chan *WebsocketClientEvent, 900), + } + m.clientEventSubscribers.Set(id, subscriber) + return subscriber +} + +func (m *WSEventManager) SubscribeToClientNativePlayerEvents(id string) *ClientEventSubscriber { + subscriber := &ClientEventSubscriber{ + Channel: make(chan *WebsocketClientEvent, 100), + } + m.clientNativePlayerEventSubscribers.Set(id, subscriber) + return subscriber +} + +func (m *WSEventManager) SubscribeToClientNakamaEvents(id string) *ClientEventSubscriber { + subscriber := &ClientEventSubscriber{ + Channel: make(chan *WebsocketClientEvent, 100), + } + m.nakamaEventSubscribers.Set(id, subscriber) + return subscriber +} + +func (m *WSEventManager) UnsubscribeFromClientEvents(id string) { + m.eventMu.Lock() + defer m.eventMu.Unlock() + defer func() { + if r := recover(); r != nil { + m.Logger.Warn().Msg("ws: Failed to unsubscribe from client events") + } + }() + subscriber, ok := m.clientEventSubscribers.Get(id) + if !ok { + subscriber, ok = m.clientNativePlayerEventSubscribers.Get(id) + } + if ok { + subscriber.mu.Lock() + defer subscriber.mu.Unlock() + subscriber.closed = true + m.clientEventSubscribers.Delete(id) + close(subscriber.Channel) + } +} diff --git a/seanime-2.9.10/internal/events/websocket_mock.go b/seanime-2.9.10/internal/events/websocket_mock.go new file mode 100644 index 0000000..b4b5584 --- /dev/null +++ b/seanime-2.9.10/internal/events/websocket_mock.go @@ -0,0 +1,75 @@ +package events + +import ( + "seanime/internal/util/result" + + "github.com/rs/zerolog" +) + +type ( + MockWSEventManager struct { + Conn interface{} + Logger *zerolog.Logger + ClientEventSubscribers *result.Map[string, *ClientEventSubscriber] + } + + MockWSEvent struct { + Type string `json:"type"` + Payload interface{} `json:"payload"` + } +) + +func NewMockWSEventManager(logger *zerolog.Logger) *MockWSEventManager { + return &MockWSEventManager{ + Logger: logger, + ClientEventSubscribers: result.NewResultMap[string, *ClientEventSubscriber](), + } +} + +// SendEvent sends a websocket event to the client. +func (m *MockWSEventManager) SendEvent(t string, payload interface{}) { + m.Logger.Trace().Any("payload", payload).Str("type", t).Msg("ws: Sent message") +} + +func (m *MockWSEventManager) SendEventTo(clientId string, t string, payload interface{}, noLog ...bool) { + if len(noLog) == 0 || !noLog[0] { + m.Logger.Trace().Any("payload", payload).Str("type", t).Str("clientId", clientId).Msg("ws: Sent message to client") + } +} + +func (m *MockWSEventManager) SubscribeToClientEvents(id string) *ClientEventSubscriber { + subscriber := &ClientEventSubscriber{ + Channel: make(chan *WebsocketClientEvent), + } + m.ClientEventSubscribers.Set(id, subscriber) + return subscriber +} + +func (m *MockWSEventManager) SubscribeToClientNativePlayerEvents(id string) *ClientEventSubscriber { + subscriber := &ClientEventSubscriber{ + Channel: make(chan *WebsocketClientEvent), + } + m.ClientEventSubscribers.Set(id, subscriber) + return subscriber +} + +func (m *MockWSEventManager) SubscribeToClientNakamaEvents(id string) *ClientEventSubscriber { + subscriber := &ClientEventSubscriber{ + Channel: make(chan *WebsocketClientEvent), + } + m.ClientEventSubscribers.Set(id, subscriber) + return subscriber +} + +func (m *MockWSEventManager) UnsubscribeFromClientEvents(id string) { + m.ClientEventSubscribers.Delete(id) +} + +//// + +func (m *MockWSEventManager) MockSendClientEvent(event *WebsocketClientEvent) { + m.ClientEventSubscribers.Range(func(key string, subscriber *ClientEventSubscriber) bool { + subscriber.Channel <- event + return true + }) +} diff --git a/seanime-2.9.10/internal/extension/bank.go b/seanime-2.9.10/internal/extension/bank.go new file mode 100644 index 0000000..9607d13 --- /dev/null +++ b/seanime-2.9.10/internal/extension/bank.go @@ -0,0 +1,116 @@ +package extension + +import ( + "seanime/internal/util/result" + "sync" +) + +type UnifiedBank struct { + extensions *result.Map[string, BaseExtension] + extensionAddedCh chan struct{} + extensionRemovedCh chan struct{} + mu sync.RWMutex +} + +func NewUnifiedBank() *UnifiedBank { + return &UnifiedBank{ + extensions: result.NewResultMap[string, BaseExtension](), + extensionAddedCh: make(chan struct{}, 100), + extensionRemovedCh: make(chan struct{}, 100), + mu: sync.RWMutex{}, + } +} + +func (b *UnifiedBank) Lock() { + b.mu.Lock() +} + +func (b *UnifiedBank) Unlock() { + b.mu.Unlock() +} + +func (b *UnifiedBank) Reset() { + b.mu.Lock() + defer b.mu.Unlock() + b.extensions = result.NewResultMap[string, BaseExtension]() +} + +func (b *UnifiedBank) RemoveExternalExtensions() { + b.mu.Lock() + defer b.mu.Unlock() + + b.extensions.Range(func(id string, ext BaseExtension) bool { + if ext.GetManifestURI() != "builtin" { + b.extensions.Delete(id) + } + return true + }) +} + +func (b *UnifiedBank) Set(id string, ext BaseExtension) { + // Add the extension to the map + b.extensions.Set(id, ext) + + // Notify listeners that an extension has been added + b.mu.Lock() + defer b.mu.Unlock() + + b.extensionAddedCh <- struct{}{} +} + +func (b *UnifiedBank) Get(id string) (BaseExtension, bool) { + //b.mu.RLock() + //defer b.mu.RUnlock() + return b.extensions.Get(id) +} + +func (b *UnifiedBank) Delete(id string) { + // Delete the extension from the map + b.extensions.Delete(id) + + // Notify listeners that an extension has been removed + b.mu.Lock() + defer b.mu.Unlock() + + b.extensionRemovedCh <- struct{}{} +} + +func (b *UnifiedBank) GetExtensionMap() *result.Map[string, BaseExtension] { + b.mu.RLock() + defer b.mu.RUnlock() + return b.extensions +} + +func (b *UnifiedBank) Range(f func(id string, ext BaseExtension) bool) { + // No need to lock + b.extensions.Range(f) +} + +func (b *UnifiedBank) OnExtensionAdded() <-chan struct{} { + return b.extensionAddedCh +} + +func (b *UnifiedBank) OnExtensionRemoved() <-chan struct{} { + return b.extensionRemovedCh +} + +func GetExtension[T BaseExtension](bank *UnifiedBank, id string) (ret T, ok bool) { + // No need to lock + ext, ok := bank.extensions.Get(id) + if !ok { + return + } + + ret, ok = ext.(T) + return +} + +func RangeExtensions[T BaseExtension](bank *UnifiedBank, f func(id string, ext T) bool) { + // No need to lock + bank.extensions.Range(func(id string, ext BaseExtension) bool { + if typedExt, ok := ext.(T); ok { + return f(id, typedExt) + } + return true + }) +} \ No newline at end of file diff --git a/seanime-2.9.10/internal/extension/extension.go b/seanime-2.9.10/internal/extension/extension.go new file mode 100644 index 0000000..dc512f0 --- /dev/null +++ b/seanime-2.9.10/internal/extension/extension.go @@ -0,0 +1,226 @@ +package extension + +import ( + "strings" +) + +type Consumer interface { + InitExtensionBank(bank *UnifiedBank) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type Type string + +type Language string + +type PluginPermissionScope string + +const ( + TypeAnimeTorrentProvider Type = "anime-torrent-provider" + TypeMangaProvider Type = "manga-provider" + TypeOnlinestreamProvider Type = "onlinestream-provider" + TypePlugin Type = "plugin" +) + +const ( + LanguageJavascript Language = "javascript" + LanguageTypescript Language = "typescript" + LanguageGo Language = "go" +) + +type Extension struct { + // ID is the unique identifier of the extension + // It must be unique across all extensions + // It must start with a letter and contain only alphanumeric characters + ID string `json:"id"` // e.g. "extension-example" + Name string `json:"name"` // e.g. "Extension" + Version string `json:"version"` // e.g. "1.0.0" + SemverConstraint string `json:"semverConstraint,omitempty"` + // The URI to the extension manifest file. + // This is "builtin" if the extension is built-in and "" if the extension is local. + ManifestURI string `json:"manifestURI"` // e.g. "http://cdn.something.app/extensions/extension-example/manifest.json" + // The programming language of the extension + // It is used to determine how to interpret the extension + Language Language `json:"language"` // e.g. "go" + // Type is the area of the application the extension is targeting + Type Type `json:"type"` // e.g. "anime-torrent-provider" + Description string `json:"description"` // e.g. "This extension provides torrents" + Author string `json:"author"` // e.g. "Seanime" + // Icon is the URL to the extension icon + Icon string `json:"icon"` + // Website is the URL to the extension website + Website string `json:"website"` + // ISO 639-1 language code. + // Set this to "multi" if the extension supports multiple languages. + // Defaults to "en". + Lang string `json:"lang"` + // List of permissions asked by the extension. + // The user must grant these permissions before the extension can be loaded. + Permissions []string `json:"permissions,omitempty"` // NOT IMPLEMENTED + UserConfig *UserConfig `json:"userConfig,omitempty"` + // Payload is the content of the extension. + Payload string `json:"payload"` + // PayloadURI is the URI to the extension payload. + // It can be used as an alternative to the Payload field to load the payload from a remote source. + // If the extension is in debug mode, this can be a file path to the local payload. + PayloadURI string `json:"payloadURI,omitempty"` + // Plugin is the manifest of the extension if it is a plugin. + Plugin *PluginManifest `json:"plugin,omitempty"` + + // IsDevelopment is true if the extension is in development mode. + // If true, the extension code will be loaded from PayloadURI and allow you to edit the code from an editor and reload the extension without restarting the application. + IsDevelopment bool `json:"isDevelopment,omitempty"` + + SavedUserConfig *SavedUserConfig `json:"-"` // Contains the saved user config for the extension +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// BaseExtension is the base interface for all extensions +// An extension is a JS file that is loaded by HTTP request +type BaseExtension interface { + GetID() string + GetName() string + GetVersion() string + GetManifestURI() string + GetLanguage() Language + GetType() Type + GetDescription() string + GetAuthor() string + GetPayload() string + GetPayloadURI() string + GetLang() string + GetIcon() string + GetWebsite() string + GetPermissions() []string + GetUserConfig() *UserConfig + GetSavedUserConfig() *SavedUserConfig + GetIsDevelopment() bool +} + +type Configurable interface { + SetSavedUserConfig(config SavedUserConfig) +} + +func ToExtensionData(ext BaseExtension) *Extension { + return &Extension{ + ID: ext.GetID(), + Name: ext.GetName(), + Version: ext.GetVersion(), + ManifestURI: ext.GetManifestURI(), + Language: ext.GetLanguage(), + Lang: GetExtensionLang(ext.GetLang()), + Type: ext.GetType(), + Description: ext.GetDescription(), + Author: ext.GetAuthor(), + Permissions: ext.GetPermissions(), + UserConfig: ext.GetUserConfig(), + Icon: ext.GetIcon(), + Website: ext.GetWebsite(), + Payload: ext.GetPayload(), + PayloadURI: ext.GetPayloadURI(), + IsDevelopment: ext.GetIsDevelopment(), + SavedUserConfig: ext.GetSavedUserConfig(), + } +} + +func GetExtensionLang(lang string) string { + if lang == "" { + return "en" + } + if lang == "all" { + return "multi" + } + return lang +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type InvalidExtensionErrorCode string + +const ( + // InvalidExtensionManifestError is returned when the extension manifest is invalid + InvalidExtensionManifestError InvalidExtensionErrorCode = "invalid_manifest" + // InvalidExtensionPayloadError is returned when the extension code is invalid / obsolete + InvalidExtensionPayloadError InvalidExtensionErrorCode = "invalid_payload" + InvalidExtensionUserConfigError InvalidExtensionErrorCode = "user_config_error" + // InvalidExtensionAuthorizationError is returned when some authorization scopes have not been granted + InvalidExtensionAuthorizationError InvalidExtensionErrorCode = "invalid_authorization" + // InvalidExtensionPluginPermissionsNotGranted is returned when the plugin permissions have not been granted + InvalidExtensionPluginPermissionsNotGranted InvalidExtensionErrorCode = "plugin_permissions_not_granted" + // InvalidExtensionSemverConstraintError is returned when the semver constraint is invalid + InvalidExtensionSemverConstraintError InvalidExtensionErrorCode = "invalid_semver_constraint" +) + +type InvalidExtension struct { + // Auto-generated ID + ID string `json:"id"` + Path string `json:"path"` + Extension Extension `json:"extension"` + Reason string `json:"reason"` + Code InvalidExtensionErrorCode `json:"code"` + PluginPermissionDescription string `json:"pluginPermissionDescription,omitempty"` +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type UserConfig struct { + // The version of the extension configuration. + // This is used to determine if the configuration has changed. + Version int `json:"version"` + // Whether the extension requires user configuration. + RequiresConfig bool `json:"requiresConfig"` + // This will be used to generate the user configuration form, and the values will be passed to the extension. + Fields []ConfigField `json:"fields"` +} + +type Preferences struct { + // This will be used to generate the preference form, and the values will be passed to the extension. + Fields []ConfigField `json:"fields"` +} + +type SavedUserConfig struct { + // The version of the extension configuration. + Version int `json:"version"` + // The values of the user configuration fields. + Values map[string]string `json:"values"` +} + +const ( + ConfigFieldTypeText ConfigFieldType = "text" + ConfigFieldTypeSwitch ConfigFieldType = "switch" + ConfigFieldTypeSelect ConfigFieldType = "select" +) + +type ( + + // ConfigField represents a field in an extension's configuration. + // The fields are defined in the manifest file. + ConfigField struct { + Type ConfigFieldType `json:"type"` + Name string `json:"name"` + Label string `json:"label"` + Options []ConfigFieldSelectOption `json:"options,omitempty"` + Default string `json:"default,omitempty"` + } + + ConfigFieldType string + + ConfigFieldSelectOption struct { + Value string `json:"value"` + Label string `json:"label"` + } + + ConfigFieldValueValidator func(value string) error +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (p *PluginPermissionScope) String() string { + return string(*p) +} + +func (p *PluginPermissionScope) Is(str string) bool { + return strings.EqualFold(string(*p), str) +} diff --git a/seanime-2.9.10/internal/extension/hibike/manga/types.go b/seanime-2.9.10/internal/extension/hibike/manga/types.go new file mode 100644 index 0000000..200885e --- /dev/null +++ b/seanime-2.9.10/internal/extension/hibike/manga/types.go @@ -0,0 +1,97 @@ +package hibikemanga + +type ( + Provider interface { + // Search returns the search results for the given query. + Search(opts SearchOptions) ([]*SearchResult, error) + // FindChapters returns the chapter details for the given manga ID. + FindChapters(id string) ([]*ChapterDetails, error) + // FindChapterPages returns the chapter pages for the given chapter ID. + FindChapterPages(id string) ([]*ChapterPage, error) + // GetSettings returns the provider settings. + GetSettings() Settings + } + + Settings struct { + SupportsMultiScanlator bool `json:"supportsMultiScanlator"` + SupportsMultiLanguage bool `json:"supportsMultiLanguage"` + } + + SearchOptions struct { + Query string `json:"query"` + // Year is the year the manga was released. + // It will be 0 if the year is not available. + Year int `json:"year"` + } + + SearchResult struct { + // "ID" of the extension. + Provider string `json:"provider"` + // ID of the manga, used to fetch the chapter details. + // It can be a combination of keys separated by a delimiter. (Delimiters should not be slashes). + ID string `json:"id"` + // The title of the manga. + Title string `json:"title"` + // Synonyms are alternative titles for the manga. + Synonyms []string `json:"synonyms,omitempty"` + // Year the manga was released. + Year int `json:"year,omitempty"` + // URL of the manga cover image. + Image string `json:"image,omitempty"` + // Indicates how well the chapter title matches the search query. + // It is a number from 0 to 1. + // Leave it empty if the comparison should be done by Seanime. + SearchRating float64 `json:"searchRating,omitempty"` + } + + ChapterDetails struct { + // "ID" of the extension. + // This should be the same as the extension ID and follow the same format. + Provider string `json:"provider"` + // ID of the chapter, used to fetch the chapter pages. + // It can be a combination of keys separated by a delimiter. (Delimiters should not be slashes). + // If the same ID has multiple languages, the language key should be included. (e.g., "one-piece-001$chapter-1$en"). + // If the same ID has multiple scanlators, the group key should be included. (e.g., "one-piece-001$chapter-1$group-1"). + ID string `json:"id"` + // The chapter page URL. + URL string `json:"url"` + // The chapter title. + // It should be in this format: "Chapter X.Y - {title}" where X is the chapter number and Y is the subchapter number. + Title string `json:"title"` + // e.g., "1", "1.5", "2", "3" + Chapter string `json:"chapter"` + // From 0 to n + Index uint `json:"index"` + // The scanlator that translated the chapter. + // Leave it empty if your extension does not support multiple scanlators. + Scanlator string `json:"scanlator,omitempty"` + // The language of the chapter. + // Leave it empty if your extension does not support multiple languages. + Language string `json:"language,omitempty"` + // The rating of the chapter. It is a number from 0 to 100. + // Leave it empty if the rating is not available. + Rating int `json:"rating,omitempty"` + // UpdatedAt is the date when the chapter was last updated. + // It should be in the format "YYYY-MM-DD". + // Leave it empty if the date is not available. + UpdatedAt string `json:"updatedAt,omitempty"` + + // LocalIsPDF is true if the chapter is a single, readable PDF file. + LocalIsPDF bool `json:"localIsPDF,omitempty"` + } + + ChapterPage struct { + // ID of the provider. + // This should be the same as the extension ID and follow the same format. + Provider string `json:"provider"` + // URL of the chapter page. + URL string `json:"url"` + // Index of the page in the chapter. + // From 0 to n. + Index int `json:"index"` + // Request headers for the page if proxying is required. + Headers map[string]string `json:"headers"` + + Buf []byte `json:"-"` + } +) diff --git a/seanime-2.9.10/internal/extension/hibike/onlinestream/types.go b/seanime-2.9.10/internal/extension/hibike/onlinestream/types.go new file mode 100644 index 0000000..2a3688e --- /dev/null +++ b/seanime-2.9.10/internal/extension/hibike/onlinestream/types.go @@ -0,0 +1,146 @@ +package hibikeonlinestream + +type ( + Provider interface { + Search(opts SearchOptions) ([]*SearchResult, error) + // FindEpisodes returns the episodes for the given anime ID. + FindEpisodes(id string) ([]*EpisodeDetails, error) + // FindEpisodeServer returns the episode server for the given episode. + // The "server" argument can be "default" + FindEpisodeServer(episode *EpisodeDetails, server string) (*EpisodeServer, error) + // GetSettings returns the provider settings. + GetSettings() Settings + } + + SearchOptions struct { + // The media object provided by Seanime. + Media Media `json:"media"` + // The search query. + Query string `json:"query"` + // Whether to search for subbed or dubbed anime. + Dub bool `json:"dub"` + // The year the anime was released. + // Will be 0 if the year is not available. + Year int `json:"year"` + } + + Media struct { + // AniList ID of the media. + ID int `json:"id"` + // MyAnimeList ID of the media. + IDMal *int `json:"idMal,omitempty"` + // e.g. "FINISHED", "RELEASING", "NOT_YET_RELEASED", "CANCELLED", "HIATUS" + // This will be set to "NOT_YET_RELEASED" if the status is unknown. + Status string `json:"status,omitempty"` + // e.g. "TV", "TV_SHORT", "MOVIE", "SPECIAL", "OVA", "ONA", "MUSIC" + // This will be set to "TV" if the format is unknown. + Format string `json:"format,omitempty"` + // e.g. "Attack on Titan" + // This will be undefined if the english title is unknown. + EnglishTitle *string `json:"englishTitle,omitempty"` + // e.g. "Shingeki no Kyojin" + RomajiTitle string `json:"romajiTitle,omitempty"` + // TotalEpisodes is total number of episodes of the media. + // This will be -1 if the total number of episodes is unknown / not applicable. + EpisodeCount int `json:"episodeCount,omitempty"` + // All alternative titles of the media. + Synonyms []string `json:"synonyms"` + // Whether the media is NSFW. + IsAdult bool `json:"isAdult"` + // Start date of the media. + // This will be undefined if it has no start date. + StartDate *FuzzyDate `json:"startDate,omitempty"` + } + + FuzzyDate struct { + Year int `json:"year"` + Month *int `json:"month"` + Day *int `json:"day"` + } + + Settings struct { + EpisodeServers []string `json:"episodeServers"` + SupportsDub bool `json:"supportsDub"` + } + + SearchResult struct { + // ID is the anime slug. + // It is used to fetch the episode details. + ID string `json:"id"` + // Title is the anime title. + Title string `json:"title"` + // URL is the anime page URL. + URL string `json:"url"` + SubOrDub SubOrDub `json:"subOrDub"` + } + + // EpisodeDetails contains the episode information from a provider. + // It is obtained by scraping the list of episodes. + EpisodeDetails struct { + // "ID" of the extension. + Provider string `json:"provider"` + // ID is the episode slug. + // e.g. "the-apothecary-diaries-18578". + ID string `json:"id"` + // Episode number. + // From 0 to n. + Number int `json:"number"` + // Episode page URL. + URL string `json:"url"` + // Episode title. + // Leave it empty if the episode title is not available. + Title string `json:"title,omitempty"` + } + + // EpisodeServer contains the server, headers and video sources for an episode. + EpisodeServer struct { + // "ID" of the extension. + Provider string `json:"provider"` + // Episode server name. + // e.g. "vidcloud". + Server string `json:"server"` + // HTTP headers for the video request. + Headers map[string]string `json:"headers"` + // Video sources for the episode. + VideoSources []*VideoSource `json:"videoSources"` + } + + SubOrDub string + + VideoSourceType string + + VideoSource struct { + // URL of the video source. + URL string `json:"url"` + // Type of the video source. + Type VideoSourceType `json:"type"` + // Quality of the video source. + // e.g. "default", "auto", "1080p". + Quality string `json:"quality"` + // Subtitles for the video source. + Subtitles []*VideoSubtitle `json:"subtitles"` + } + + VideoSubtitle struct { + ID string `json:"id"` + URL string `json:"url"` + // e.g. "en", "fr" + Language string `json:"language"` + IsDefault bool `json:"isDefault"` + } + + VideoExtractor interface { + Extract(uri string) ([]*VideoSource, error) + } +) + +const ( + Sub SubOrDub = "sub" + Dub SubOrDub = "dub" + SubAndDub SubOrDub = "both" +) + +const ( + VideoSourceMP4 VideoSourceType = "mp4" + VideoSourceM3U8 VideoSourceType = "m3u8" +) diff --git a/seanime-2.9.10/internal/extension/hibike/torrent/types.go b/seanime-2.9.10/internal/extension/hibike/torrent/types.go new file mode 100644 index 0000000..c247604 --- /dev/null +++ b/seanime-2.9.10/internal/extension/hibike/torrent/types.go @@ -0,0 +1,170 @@ +package hibiketorrent + +// Resolutions represent resolution filters available to the user. +var Resolutions = []string{"1080", "720", "540", "480"} + +const ( + // AnimeProviderTypeMain providers can be used as default providers. + AnimeProviderTypeMain AnimeProviderType = "main" + // AnimeProviderTypeSpecial providers cannot be set as default provider. + // Providers that return only specific content (e.g. adult content). + // These providers should not return anything from "GetLatest". + AnimeProviderTypeSpecial AnimeProviderType = "special" +) + +const ( + AnimeProviderSmartSearchFilterBatch AnimeProviderSmartSearchFilter = "batch" + AnimeProviderSmartSearchFilterEpisodeNumber AnimeProviderSmartSearchFilter = "episodeNumber" + AnimeProviderSmartSearchFilterResolution AnimeProviderSmartSearchFilter = "resolution" + AnimeProviderSmartSearchFilterQuery AnimeProviderSmartSearchFilter = "query" + AnimeProviderSmartSearchFilterBestReleases AnimeProviderSmartSearchFilter = "bestReleases" +) + +type ( + AnimeProviderType string + + AnimeProviderSmartSearchFilter string + + AnimeProviderSettings struct { + CanSmartSearch bool `json:"canSmartSearch"` + SmartSearchFilters []AnimeProviderSmartSearchFilter `json:"smartSearchFilters"` + SupportsAdult bool `json:"supportsAdult"` + Type AnimeProviderType `json:"type"` + } + + AnimeProvider interface { + // Search for torrents. + Search(opts AnimeSearchOptions) ([]*AnimeTorrent, error) + // SmartSearch for torrents. + SmartSearch(opts AnimeSmartSearchOptions) ([]*AnimeTorrent, error) + // GetTorrentInfoHash returns the info hash of the torrent. + // This should just return the info hash without scraping the torrent page if already available. + GetTorrentInfoHash(torrent *AnimeTorrent) (string, error) + // GetTorrentMagnetLink returns the magnet link of the torrent. + // This should just return the magnet link without scraping the torrent page if already available. + GetTorrentMagnetLink(torrent *AnimeTorrent) (string, error) + // GetLatest returns the latest torrents. + GetLatest() ([]*AnimeTorrent, error) + // GetSettings returns the provider settings. + GetSettings() AnimeProviderSettings + } + + Media struct { + // AniList ID of the media. + ID int `json:"id"` + // MyAnimeList ID of the media. + IDMal *int `json:"idMal,omitempty"` + // e.g. "FINISHED", "RELEASING", "NOT_YET_RELEASED", "CANCELLED", "HIATUS" + // This will be set to "NOT_YET_RELEASED" if the status is unknown. + Status string `json:"status,omitempty"` + // e.g. "TV", "TV_SHORT", "MOVIE", "SPECIAL", "OVA", "ONA", "MUSIC" + // This will be set to "TV" if the format is unknown. + Format string `json:"format,omitempty"` + // e.g. "Attack on Titan" + // This will be undefined if the english title is unknown. + EnglishTitle *string `json:"englishTitle,omitempty"` + // e.g. "Shingeki no Kyojin" + RomajiTitle string `json:"romajiTitle,omitempty"` + // TotalEpisodes is total number of episodes of the media. + // This will be -1 if the total number of episodes is unknown / not applicable. + EpisodeCount int `json:"episodeCount,omitempty"` + // Absolute offset of the media's season. + // This will be 0 if the media is not seasonal or the offset is unknown. + AbsoluteSeasonOffset int `json:"absoluteSeasonOffset,omitempty"` + // All alternative titles of the media. + Synonyms []string `json:"synonyms"` + // Whether the media is NSFW. + IsAdult bool `json:"isAdult"` + // Start date of the media. + // This will be undefined if it has no start date. + StartDate *FuzzyDate `json:"startDate,omitempty"` + } + + FuzzyDate struct { + Year int `json:"year"` + Month *int `json:"month"` + Day *int `json:"day"` + } + + // AnimeSearchOptions represents the options to search for torrents without filters. + AnimeSearchOptions struct { + // The media object provided by Seanime. + Media Media `json:"media"` + // The user search query. + Query string `json:"query"` + } + + AnimeSmartSearchOptions struct { + // The media object provided by Seanime. + Media Media `json:"media"` + // The user search query. + // This will be empty if your extension does not support custom queries. + Query string `json:"query"` + // Indicates whether the user wants to search for batch torrents. + // This will be false if your extension does not support batch torrents. + Batch bool `json:"batch"` + // The episode number the user wants to search for. + // This will be 0 if your extension does not support episode number filtering. + EpisodeNumber int `json:"episodeNumber"` + // The resolution the user wants to search for. + // This will be empty if your extension does not support resolution filtering. + // e.g. "1080", "720" + Resolution string `json:"resolution"` + // AniDB Anime ID of the media. + AnidbAID int `json:"anidbAID"` + // AniDB Episode ID of the media. + AnidbEID int `json:"anidbEID"` + // Indicates whether the user wants to search for the best releases. + // This will be false if your extension does not support filtering by best releases. + BestReleases bool `json:"bestReleases"` + } + + AnimeTorrent struct { + // "ID" of the extension. + Provider string `json:"provider,omitempty"` + // Title of the torrent. + Name string `json:"name"` + // Date of the torrent. + // The date should have RFC3339 format. e.g. "2006-01-02T15:04:05Z07:00" + Date string `json:"date"` + // Size of the torrent in bytes. + Size int64 `json:"size"` + // Formatted size of the torrent. e.g. "1.2 GB" + // Leave this empty if you want Seanime to format the size. + FormattedSize string `json:"formattedSize"` + // Number of seeders. + Seeders int `json:"seeders"` + // Number of leechers. + Leechers int `json:"leechers"` + // Number of downloads. + DownloadCount int `json:"downloadCount"` + // Link to the torrent page. + Link string `json:"link"` + // Download URL of the torrent. + // Leave this empty if you cannot provide a direct download URL. + DownloadUrl string `json:"downloadUrl"` + // Magnet link of the torrent. + // Leave this empty if you cannot provide a magnet link without scraping. + MagnetLink string `json:"magnetLink,omitempty"` + // InfoHash of the torrent. + // Leave empty if it should be scraped later. + InfoHash string `json:"infoHash,omitempty"` + // Resolution of the video. + // e.g. "1080p", "720p" + Resolution string `json:"resolution,omitempty"` + // Set this to true if you can confirm that the torrent is a batch. + // Else, Seanime will parse the torrent name to determine if it's a batch. + IsBatch bool `json:"isBatch,omitempty"` + // Episode number of the torrent. + // Return -1 if unknown / unable to determine and Seanime will parse the torrent name. + EpisodeNumber int `json:"episodeNumber,omitempty"` + // Release group of the torrent. + // Leave this empty if you want Seanime to parse the release group from the name. + ReleaseGroup string `json:"releaseGroup,omitempty"` + // Set this to true if you can confirm that the torrent is the best release. + IsBestRelease bool `json:"isBestRelease"` + // Set this to true if you can confirm that the torrent matches the anime the user is searching for. + // e.g. If the torrent was found using the AniDB anime or episode ID + Confirmed bool `json:"confirmed"` + } +) diff --git a/seanime-2.9.10/internal/extension/hibike/vendor_extension.go b/seanime-2.9.10/internal/extension/hibike/vendor_extension.go new file mode 100644 index 0000000..bf4855c --- /dev/null +++ b/seanime-2.9.10/internal/extension/hibike/vendor_extension.go @@ -0,0 +1,8 @@ +package hibikextension + +type ( + SelectOption struct { + Value string `json:"value"` + Label string `json:"label"` + } +) diff --git a/seanime-2.9.10/internal/extension/manga_provider.go b/seanime-2.9.10/internal/extension/manga_provider.go new file mode 100644 index 0000000..772ee2b --- /dev/null +++ b/seanime-2.9.10/internal/extension/manga_provider.go @@ -0,0 +1,98 @@ +package extension + +import ( + hibikemanga "seanime/internal/extension/hibike/manga" +) + +type MangaProviderExtension interface { + BaseExtension + GetProvider() hibikemanga.Provider +} + +type MangaProviderExtensionImpl struct { + ext *Extension + provider hibikemanga.Provider +} + +func NewMangaProviderExtension(ext *Extension, provider hibikemanga.Provider) MangaProviderExtension { + return &MangaProviderExtensionImpl{ + ext: ext, + provider: provider, + } +} + +func (m *MangaProviderExtensionImpl) GetProvider() hibikemanga.Provider { + return m.provider +} + +func (m *MangaProviderExtensionImpl) GetExtension() *Extension { + return m.ext +} + +func (m *MangaProviderExtensionImpl) GetType() Type { + return m.ext.Type +} + +func (m *MangaProviderExtensionImpl) GetID() string { + return m.ext.ID +} + +func (m *MangaProviderExtensionImpl) GetName() string { + return m.ext.Name +} + +func (m *MangaProviderExtensionImpl) GetVersion() string { + return m.ext.Version +} + +func (m *MangaProviderExtensionImpl) GetManifestURI() string { + return m.ext.ManifestURI +} + +func (m *MangaProviderExtensionImpl) GetLanguage() Language { + return m.ext.Language +} + +func (m *MangaProviderExtensionImpl) GetLang() string { + return GetExtensionLang(m.ext.Lang) +} + +func (m *MangaProviderExtensionImpl) GetDescription() string { + return m.ext.Description +} + +func (m *MangaProviderExtensionImpl) GetAuthor() string { + return m.ext.Author +} + +func (m *MangaProviderExtensionImpl) GetPayload() string { + return m.ext.Payload +} + +func (m *MangaProviderExtensionImpl) GetWebsite() string { + return m.ext.Website +} + +func (m *MangaProviderExtensionImpl) GetIcon() string { + return m.ext.Icon +} + +func (m *MangaProviderExtensionImpl) GetPermissions() []string { + return m.ext.Permissions +} + +func (m *MangaProviderExtensionImpl) GetUserConfig() *UserConfig { + return m.ext.UserConfig +} + +func (m *MangaProviderExtensionImpl) GetSavedUserConfig() *SavedUserConfig { + return m.ext.SavedUserConfig +} + +func (m *MangaProviderExtensionImpl) GetPayloadURI() string { + return m.ext.PayloadURI +} + +func (m *MangaProviderExtensionImpl) GetIsDevelopment() bool { + return m.ext.IsDevelopment +} diff --git a/seanime-2.9.10/internal/extension/onlinestream_provider.go b/seanime-2.9.10/internal/extension/onlinestream_provider.go new file mode 100644 index 0000000..ea5da56 --- /dev/null +++ b/seanime-2.9.10/internal/extension/onlinestream_provider.go @@ -0,0 +1,98 @@ +package extension + +import ( + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" +) + +type OnlinestreamProviderExtension interface { + BaseExtension + GetProvider() hibikeonlinestream.Provider +} + +type OnlinestreamProviderExtensionImpl struct { + ext *Extension + provider hibikeonlinestream.Provider +} + +func NewOnlinestreamProviderExtension(ext *Extension, provider hibikeonlinestream.Provider) OnlinestreamProviderExtension { + return &OnlinestreamProviderExtensionImpl{ + ext: ext, + provider: provider, + } +} + +func (m *OnlinestreamProviderExtensionImpl) GetProvider() hibikeonlinestream.Provider { + return m.provider +} + +func (m *OnlinestreamProviderExtensionImpl) GetExtension() *Extension { + return m.ext +} + +func (m *OnlinestreamProviderExtensionImpl) GetType() Type { + return m.ext.Type +} + +func (m *OnlinestreamProviderExtensionImpl) GetID() string { + return m.ext.ID +} + +func (m *OnlinestreamProviderExtensionImpl) GetName() string { + return m.ext.Name +} + +func (m *OnlinestreamProviderExtensionImpl) GetVersion() string { + return m.ext.Version +} + +func (m *OnlinestreamProviderExtensionImpl) GetManifestURI() string { + return m.ext.ManifestURI +} + +func (m *OnlinestreamProviderExtensionImpl) GetLanguage() Language { + return m.ext.Language +} + +func (m *OnlinestreamProviderExtensionImpl) GetLang() string { + return GetExtensionLang(m.ext.Lang) +} + +func (m *OnlinestreamProviderExtensionImpl) GetDescription() string { + return m.ext.Description +} + +func (m *OnlinestreamProviderExtensionImpl) GetAuthor() string { + return m.ext.Author +} + +func (m *OnlinestreamProviderExtensionImpl) GetPayload() string { + return m.ext.Payload +} + +func (m *OnlinestreamProviderExtensionImpl) GetWebsite() string { + return m.ext.Website +} + +func (m *OnlinestreamProviderExtensionImpl) GetIcon() string { + return m.ext.Icon +} + +func (m *OnlinestreamProviderExtensionImpl) GetPermissions() []string { + return m.ext.Permissions +} + +func (m *OnlinestreamProviderExtensionImpl) GetUserConfig() *UserConfig { + return m.ext.UserConfig +} + +func (m *OnlinestreamProviderExtensionImpl) GetSavedUserConfig() *SavedUserConfig { + return m.ext.SavedUserConfig +} + +func (m *OnlinestreamProviderExtensionImpl) GetPayloadURI() string { + return m.ext.PayloadURI +} + +func (m *OnlinestreamProviderExtensionImpl) GetIsDevelopment() bool { + return m.ext.IsDevelopment +} diff --git a/seanime-2.9.10/internal/extension/plugin.go b/seanime-2.9.10/internal/extension/plugin.go new file mode 100644 index 0000000..87fb2cf --- /dev/null +++ b/seanime-2.9.10/internal/extension/plugin.go @@ -0,0 +1,399 @@ +package extension + +import ( + "crypto/sha256" + "fmt" + "strings" +) + +const ( + PluginManifestVersion = "1" +) + +var ( + PluginPermissionStorage PluginPermissionScope = "storage" // Allows the plugin to store its own data + PluginPermissionDatabase PluginPermissionScope = "database" // Allows the plugin to read non-auth data from the database and write to it + PluginPermissionPlayback PluginPermissionScope = "playback" // Allows the plugin to use the playback manager + PluginPermissionAnilist PluginPermissionScope = "anilist" // Allows the plugin to use the Anilist client + PluginPermissionAnilistToken PluginPermissionScope = "anilist-token" // Allows the plugin to see and use the Anilist token + PluginPermissionSystem PluginPermissionScope = "system" // Allows the plugin to use the OS/Filesystem/Filepath functions. SystemPermissions must be granted additionally. + PluginPermissionCron PluginPermissionScope = "cron" // Allows the plugin to use the cron manager + PluginPermissionNotification PluginPermissionScope = "notification" // Allows the plugin to use the notification manager + PluginPermissionDiscord PluginPermissionScope = "discord" // Allows the plugin to use the discord rpc + PluginPermissionTorrentClient PluginPermissionScope = "torrent-client" // Allows the plugin to use the torrent client +) + +type PluginManifest struct { + Version string `json:"version"` + // Permissions is a list of permissions that the plugin is asking for. + // The user must acknowledge these permissions before the plugin can be loaded. + Permissions PluginPermissions `json:"permissions,omitempty"` +} + +type PluginPermissions struct { + Scopes []PluginPermissionScope `json:"scopes,omitempty"` + Allow PluginAllowlist `json:"allow,omitempty"` +} + +// PluginAllowlist is a list of system permissions that the plugin is asking for. +// +// The user must acknowledge these permissions before the plugin can be loaded. +type PluginAllowlist struct { + // ReadPaths is a list of paths that the plugin is allowed to read from. + ReadPaths []string `json:"readPaths,omitempty"` + // WritePaths is a list of paths that the plugin is allowed to write to. + WritePaths []string `json:"writePaths,omitempty"` + // CommandScopes defines the commands that the plugin is allowed to execute. + // Each command scope has a unique identifier and configuration. + CommandScopes []CommandScope `json:"commandScopes,omitempty"` +} + +// CommandScope defines a specific command or set of commands that can be executed +// with specific arguments and validation rules. +type CommandScope struct { + // Description explains why this command scope is needed + Description string `json:"description,omitempty"` + // Command is the executable program + Command string `json:"command"` + // Args defines the allowed arguments for this command + // If nil or empty, no arguments are allowed + // If contains "$ARGS", any arguments are allowed at that position + Args []CommandArg `json:"args,omitempty"` +} + +// CommandArg represents an argument for a command +type CommandArg struct { + // Value is the fixed argument value + // If empty, Validator must be set + Value string `json:"value,omitempty"` + // Validator is a Perl compatible regex pattern to validate dynamic argument values + // Special values: + // - "$ARGS" allows any arguments at this position + // - "$PATH" allows any valid file path + Validator string `json:"validator,omitempty"` +} + +// ReadAllowCommands returns a human-readable representation of the commands +// that the plugin is allowed to execute. +func (p *PluginAllowlist) ReadAllowCommands() []string { + if p == nil { + return []string{} + } + + result := make([]string, 0) + + // Add commands from CommandScopes + if len(p.CommandScopes) > 0 { + for _, scope := range p.CommandScopes { + cmd := scope.Command + + // Build argument string + args := "" + for i, arg := range scope.Args { + if i > 0 { + args += " " + } + + if arg.Value != "" { + args += arg.Value + } else if arg.Validator == "$ARGS" { + args += "[any arguments]" + } else if arg.Validator == "$PATH" { + args += "[any path]" + } else if arg.Validator != "" { + args += "[matching: " + arg.Validator + "]" + } + } + + if args != "" { + cmd += " " + args + } + + // Add description if available + if scope.Description != "" { + cmd += " - " + scope.Description + } + + result = append(result, cmd) + } + } + + return result +} + +func (p *PluginPermissions) GetHash() string { + if p == nil { + return "" + } + + if len(p.Scopes) == 0 && + len(p.Allow.ReadPaths) == 0 && + len(p.Allow.WritePaths) == 0 && + len(p.Allow.CommandScopes) == 0 { + return "" + } + + h := sha256.New() + + // Hash scopes + for _, scope := range p.Scopes { + h.Write([]byte(scope)) + } + + // Hash allowlist read paths + for _, path := range p.Allow.ReadPaths { + h.Write([]byte("read:" + path)) + } + + // Hash allowlist write paths + for _, path := range p.Allow.WritePaths { + h.Write([]byte("write:" + path)) + } + + // Hash command scopes + for _, cmd := range p.Allow.CommandScopes { + h.Write([]byte("cmd:" + cmd.Command + ":" + cmd.Description)) + for _, arg := range cmd.Args { + h.Write([]byte("arg:" + arg.Value + ":" + arg.Validator)) + } + } + + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func (p *PluginPermissions) GetDescription() string { + if p == nil { + return "" + } + + // Check if any permissions exist + if len(p.Scopes) == 0 && + len(p.Allow.ReadPaths) == 0 && + len(p.Allow.WritePaths) == 0 && + len(p.Allow.CommandScopes) == 0 { + return "No permissions requested." + } + + var desc strings.Builder + + // Add scopes section if any exist + if len(p.Scopes) > 0 { + desc.WriteString("Application:\n") + for _, scope := range p.Scopes { + desc.WriteString("• ") + switch scope { + case PluginPermissionStorage: + desc.WriteString("Storage: Store plugin data locally\n") + case PluginPermissionDatabase: + desc.WriteString("Database: Read and write non-auth data\n") + case PluginPermissionPlayback: + desc.WriteString("Playback: Control media playback and media players\n") + case PluginPermissionAnilist: + desc.WriteString("AniList: View and edit your AniList lists\n") + case PluginPermissionAnilistToken: + desc.WriteString("AniList Token: View and use your AniList token\n") + case PluginPermissionSystem: + desc.WriteString("System: Access OS functions (accessing files, running commands, etc.)\n") + case PluginPermissionCron: + desc.WriteString("Cron: Schedule automated tasks\n") + case PluginPermissionNotification: + desc.WriteString("Notification: Send system notifications\n") + case PluginPermissionDiscord: + desc.WriteString("Discord: Set Discord Rich Presence\n") + case PluginPermissionTorrentClient: + desc.WriteString("Torrent Client: Control torrent clients\n") + default: + desc.WriteString(string(scope) + "\n") + } + } + desc.WriteString("\n") + } + + // Add file permissions if any exist + hasFilePaths := len(p.Allow.ReadPaths) > 0 || len(p.Allow.WritePaths) > 0 + if hasFilePaths { + desc.WriteString("File System:\n") + + if len(p.Allow.ReadPaths) > 0 { + desc.WriteString("• Read from:\n") + for _, path := range p.Allow.ReadPaths { + desc.WriteString("\t - " + explainPath(path) + "\n") + } + } + + if len(p.Allow.WritePaths) > 0 { + desc.WriteString("• Write to:\n") + for _, path := range p.Allow.WritePaths { + desc.WriteString("\t - " + explainPath(path) + "\n") + } + } + desc.WriteString("\n") + } + + // Add command permissions if any exist + if len(p.Allow.CommandScopes) > 0 { + desc.WriteString("Commands:\n") + for _, cmd := range p.Allow.CommandScopes { + cmdDesc := "• " + cmd.Command + + // Format arguments + if len(cmd.Args) > 0 { + argsDesc := "" + for _, arg := range cmd.Args { + if arg.Value != "" { + argsDesc += " " + arg.Value + } else if arg.Validator == "$ARGS" { + argsDesc += " [any arguments]" + } else if arg.Validator == "$PATH" { + argsDesc += " [any file path]" + } else if arg.Validator != "" { + argsDesc += " [pattern: " + arg.Validator + "]" + } + } + cmdDesc += argsDesc + } + + // Add command description if available + if cmd.Description != "" { + cmdDesc += "\n\t Purpose: " + cmd.Description + } + + desc.WriteString(cmdDesc + "\n") + } + } + + return strings.TrimSpace(desc.String()) +} + +// explainPath adds human-readable descriptions to paths containing environment variables +func explainPath(path string) string { + environmentVars := map[string]string{ + "$SEANIME_ANIME_LIBRARY": "Your anime library directories", + "$HOME": "Your system's Home directory", + "$CACHE": "Your system's Cache directory", + "$TEMP": "Your system's Temporary directory", + "$CONFIG": "Your system's Config directory", + "$DOWNLOAD": "Your system's Downloads directory", + "$DESKTOP": "Your system's Desktop directory", + "$DOCUMENT": "Your system's Documents directory", + } + + result := path + + // Check if we need to add an explanation + needsExplanation := false + explanation := "" + + for envVar, description := range environmentVars { + if strings.Contains(path, envVar) { + if explanation != "" { + explanation += ", " + } + explanation += fmt.Sprintf("%s = %s", envVar, description) + needsExplanation = true + } + } + + if needsExplanation { + result += " (" + explanation + ")" + } + + return result +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////// + +type PluginExtension interface { + BaseExtension + GetPermissionHash() string +} + +type PluginExtensionImpl struct { + ext *Extension +} + +func NewPluginExtension(ext *Extension) PluginExtension { + return &PluginExtensionImpl{ + ext: ext, + } +} + +func (m *PluginExtensionImpl) GetPermissionHash() string { + if m.ext.Plugin == nil { + return "" + } + + return m.ext.Plugin.Permissions.GetHash() +} + +func (m *PluginExtensionImpl) GetExtension() *Extension { + return m.ext +} + +func (m *PluginExtensionImpl) GetType() Type { + return m.ext.Type +} + +func (m *PluginExtensionImpl) GetID() string { + return m.ext.ID +} + +func (m *PluginExtensionImpl) GetName() string { + return m.ext.Name +} + +func (m *PluginExtensionImpl) GetVersion() string { + return m.ext.Version +} + +func (m *PluginExtensionImpl) GetManifestURI() string { + return m.ext.ManifestURI +} + +func (m *PluginExtensionImpl) GetLanguage() Language { + return m.ext.Language +} + +func (m *PluginExtensionImpl) GetLang() string { + return GetExtensionLang(m.ext.Lang) +} + +func (m *PluginExtensionImpl) GetDescription() string { + return m.ext.Description +} + +func (m *PluginExtensionImpl) GetAuthor() string { + return m.ext.Author +} + +func (m *PluginExtensionImpl) GetPayload() string { + return m.ext.Payload +} + +func (m *PluginExtensionImpl) GetWebsite() string { + return m.ext.Website +} + +func (m *PluginExtensionImpl) GetIcon() string { + return m.ext.Icon +} + +func (m *PluginExtensionImpl) GetPermissions() []string { + return m.ext.Permissions +} + +func (m *PluginExtensionImpl) GetUserConfig() *UserConfig { + return m.ext.UserConfig +} + +func (m *PluginExtensionImpl) GetSavedUserConfig() *SavedUserConfig { + return m.ext.SavedUserConfig +} + +func (m *PluginExtensionImpl) GetPayloadURI() string { + return m.ext.PayloadURI +} + +func (m *PluginExtensionImpl) GetIsDevelopment() bool { + return m.ext.IsDevelopment +} diff --git a/seanime-2.9.10/internal/extension/torrent_provider.go b/seanime-2.9.10/internal/extension/torrent_provider.go new file mode 100644 index 0000000..115f57e --- /dev/null +++ b/seanime-2.9.10/internal/extension/torrent_provider.go @@ -0,0 +1,98 @@ +package extension + +import ( + hibiketorrent "seanime/internal/extension/hibike/torrent" +) + +type AnimeTorrentProviderExtension interface { + BaseExtension + GetProvider() hibiketorrent.AnimeProvider +} + +type AnimeTorrentProviderExtensionImpl struct { + ext *Extension + provider hibiketorrent.AnimeProvider +} + +func NewAnimeTorrentProviderExtension(ext *Extension, provider hibiketorrent.AnimeProvider) AnimeTorrentProviderExtension { + return &AnimeTorrentProviderExtensionImpl{ + ext: ext, + provider: provider, + } +} + +func (m *AnimeTorrentProviderExtensionImpl) GetProvider() hibiketorrent.AnimeProvider { + return m.provider +} + +func (m *AnimeTorrentProviderExtensionImpl) GetExtension() *Extension { + return m.ext +} + +func (m *AnimeTorrentProviderExtensionImpl) GetType() Type { + return m.ext.Type +} + +func (m *AnimeTorrentProviderExtensionImpl) GetID() string { + return m.ext.ID +} + +func (m *AnimeTorrentProviderExtensionImpl) GetName() string { + return m.ext.Name +} + +func (m *AnimeTorrentProviderExtensionImpl) GetVersion() string { + return m.ext.Version +} + +func (m *AnimeTorrentProviderExtensionImpl) GetManifestURI() string { + return m.ext.ManifestURI +} + +func (m *AnimeTorrentProviderExtensionImpl) GetLanguage() Language { + return m.ext.Language +} + +func (m *AnimeTorrentProviderExtensionImpl) GetLang() string { + return GetExtensionLang(m.ext.Lang) +} + +func (m *AnimeTorrentProviderExtensionImpl) GetDescription() string { + return m.ext.Description +} + +func (m *AnimeTorrentProviderExtensionImpl) GetAuthor() string { + return m.ext.Author +} + +func (m *AnimeTorrentProviderExtensionImpl) GetPayload() string { + return m.ext.Payload +} + +func (m *AnimeTorrentProviderExtensionImpl) GetWebsite() string { + return m.ext.Website +} + +func (m *AnimeTorrentProviderExtensionImpl) GetIcon() string { + return m.ext.Icon +} + +func (m *AnimeTorrentProviderExtensionImpl) GetPermissions() []string { + return m.ext.Permissions +} + +func (m *AnimeTorrentProviderExtensionImpl) GetUserConfig() *UserConfig { + return m.ext.UserConfig +} + +func (m *AnimeTorrentProviderExtensionImpl) GetSavedUserConfig() *SavedUserConfig { + return m.ext.SavedUserConfig +} + +func (m *AnimeTorrentProviderExtensionImpl) GetPayloadURI() string { + return m.ext.PayloadURI +} + +func (m *AnimeTorrentProviderExtensionImpl) GetIsDevelopment() bool { + return m.ext.IsDevelopment +} diff --git a/seanime-2.9.10/internal/extension_playground/playground.go b/seanime-2.9.10/internal/extension_playground/playground.go new file mode 100644 index 0000000..e3b8b9c --- /dev/null +++ b/seanime-2.9.10/internal/extension_playground/playground.go @@ -0,0 +1,491 @@ +package extension_playground + +import ( + "bytes" + "context" + "fmt" + "runtime" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/extension" + hibikemanga "seanime/internal/extension/hibike/manga" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/extension_repo" + goja_runtime "seanime/internal/goja/goja_runtime" + "seanime/internal/manga" + "seanime/internal/onlinestream" + "seanime/internal/platforms/platform" + "seanime/internal/util" + "seanime/internal/util/result" + "strconv" + "strings" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/goccy/go-json" + "github.com/rs/zerolog" +) + +type ( + PlaygroundRepository struct { + logger *zerolog.Logger + platform platform.Platform + baseAnimeCache *result.Cache[int, *anilist.BaseAnime] + baseMangaCache *result.Cache[int, *anilist.BaseManga] + metadataProvider metadata.Provider + gojaRuntimeManager *goja_runtime.Manager + } + + RunPlaygroundCodeResponse struct { + Logs string `json:"logs"` + Value string `json:"value"` + } + + RunPlaygroundCodeParams struct { + Type extension.Type `json:"type"` + Language extension.Language `json:"language"` + Code string `json:"code"` + Inputs map[string]interface{} `json:"inputs"` + Function string `json:"function"` + } +) + +func NewPlaygroundRepository(logger *zerolog.Logger, platform platform.Platform, metadataProvider metadata.Provider) *PlaygroundRepository { + return &PlaygroundRepository{ + logger: logger, + platform: platform, + metadataProvider: metadataProvider, + baseAnimeCache: result.NewCache[int, *anilist.BaseAnime](), + baseMangaCache: result.NewCache[int, *anilist.BaseManga](), + gojaRuntimeManager: goja_runtime.NewManager(logger), + } +} + +func (r *PlaygroundRepository) RunPlaygroundCode(params *RunPlaygroundCodeParams) (resp *RunPlaygroundCodeResponse, err error) { + defer util.HandlePanicInModuleWithError("extension_playground/RunPlaygroundCode", &err) + + if params == nil { + return nil, fmt.Errorf("no parameters provided") + } + + ext := &extension.Extension{ + ID: "playground-extension", + Name: "Playground", + Version: "0.0.0", + ManifestURI: "", + Language: params.Language, + Type: params.Type, + Description: "", + Author: "", + Icon: "", + Website: "", + Payload: params.Code, + } + + r.logger.Debug().Msgf("playground: Inputs: %s", strings.ReplaceAll(spew.Sprint(params.Inputs), "\n", "")) + + switch params.Type { + case extension.TypeMangaProvider: + return r.runPlaygroundCodeMangaProvider(ext, params) + case extension.TypeOnlinestreamProvider: + return r.runPlaygroundCodeOnlinestreamProvider(ext, params) + case extension.TypeAnimeTorrentProvider: + return r.runPlaygroundCodeAnimeTorrentProvider(ext, params) + default: + } + + runtime.GC() + + return nil, fmt.Errorf("invalid extension type") +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type PlaygroundDebugLogger struct { + logger *zerolog.Logger + buff *bytes.Buffer +} + +func (r *PlaygroundRepository) newPlaygroundDebugLogger() *PlaygroundDebugLogger { + buff := bytes.NewBuffer(nil) + consoleWritier := zerolog.ConsoleWriter{ + Out: buff, + TimeFormat: time.DateTime, + FormatMessage: util.ZerologFormatMessageSimple, + FormatLevel: util.ZerologFormatLevelSimple, + NoColor: true, // Needed to prevent color codes from being written to the file + } + + logger := zerolog.New(consoleWritier).With().Timestamp().Logger() + + return &PlaygroundDebugLogger{ + logger: &logger, + buff: buff, + } +} + +func newPlaygroundResponse(playgroundLogger *PlaygroundDebugLogger, value interface{}) *RunPlaygroundCodeResponse { + v := "" + + switch value.(type) { + case error: + v = fmt.Sprintf("ERROR: %+v", value) + case string: + v = value.(string) + default: + // Pretty print the value to json + prettyJSON, err := json.MarshalIndent(value, "", " ") + if err != nil { + v = fmt.Sprintf("ERROR: Failed to marshal value to JSON: %+v", err) + } else { + v = string(prettyJSON) + } + } + + logs := playgroundLogger.buff.String() + + return &RunPlaygroundCodeResponse{ + Logs: logs, + Value: v, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *PlaygroundRepository) getAnime(mediaId int) (anime *anilist.BaseAnime, am *metadata.AnimeMetadata, err error) { + var ok bool + anime, ok = r.baseAnimeCache.Get(mediaId) + if !ok { + anime, err = r.platform.GetAnime(context.Background(), mediaId) + if err != nil { + return nil, nil, err + } + r.baseAnimeCache.SetT(mediaId, anime, 24*time.Hour) + } + + am, _ = r.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mediaId) + return anime, am, nil +} + +func (r *PlaygroundRepository) getManga(mediaId int) (manga *anilist.BaseManga, err error) { + var ok bool + manga, ok = r.baseMangaCache.Get(mediaId) + if !ok { + manga, err = r.platform.GetManga(context.Background(), mediaId) + if err != nil { + return nil, err + } + r.baseMangaCache.SetT(mediaId, manga, 24*time.Hour) + } + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *PlaygroundRepository) runPlaygroundCodeAnimeTorrentProvider(ext *extension.Extension, params *RunPlaygroundCodeParams) (resp *RunPlaygroundCodeResponse, err error) { + + playgroundLogger := r.newPlaygroundDebugLogger() + + // Inputs + // - mediaId int + // - options struct + + mediaId, ok := params.Inputs["mediaId"].(float64) + if !ok || mediaId <= 0 { + return nil, fmt.Errorf("invalid mediaId") + } + + // Fetch the anime + anime, animeMetadata, err := r.getAnime(int(mediaId)) + if err != nil { + return nil, err + } + + queryMedia := hibiketorrent.Media{ + ID: anime.GetID(), + IDMal: anime.GetIDMal(), + Status: string(*anime.GetStatus()), + Format: string(*anime.GetFormat()), + EnglishTitle: anime.GetTitle().GetEnglish(), + RomajiTitle: anime.GetRomajiTitleSafe(), + EpisodeCount: anime.GetTotalEpisodeCount(), + AbsoluteSeasonOffset: 0, + Synonyms: anime.GetSynonymsContainingSeason(), + IsAdult: *anime.GetIsAdult(), + StartDate: &hibiketorrent.FuzzyDate{ + Year: *anime.GetStartDate().GetYear(), + Month: anime.GetStartDate().GetMonth(), + Day: anime.GetStartDate().GetDay(), + }, + } + + switch params.Language { + case extension.LanguageGo: + //... + case extension.LanguageJavascript, extension.LanguageTypescript: + _, provider, err := extension_repo.NewGojaAnimeTorrentProvider(ext, params.Language, playgroundLogger.logger, r.gojaRuntimeManager) + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + defer r.gojaRuntimeManager.DeletePluginPool(ext.ID) + + // Run the code + switch params.Function { + case "search": + res, err := provider.Search(hibiketorrent.AnimeSearchOptions{ + Media: queryMedia, + Query: params.Inputs["query"].(string), + }) + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + return newPlaygroundResponse(playgroundLogger, res), nil + case "smartSearch": + type p struct { + Query string `json:"query"` + Batch bool `json:"batch"` + EpisodeNumber int `json:"episodeNumber"` + Resolution string `json:"resolution"` + BestReleases bool `json:"bestReleases"` + } + m, _ := json.Marshal(params.Inputs["options"]) + var options p + _ = json.Unmarshal(m, &options) + + anidbAID := 0 + anidbEID := 0 + + // Get the AniDB Anime ID and Episode ID + if animeMetadata != nil { + // Override absolute offset value of queryMedia + queryMedia.AbsoluteSeasonOffset = animeMetadata.GetOffset() + + if animeMetadata.GetMappings() != nil { + + anidbAID = animeMetadata.GetMappings().AnidbId + // Find Animap Episode based on inputted episode number + anizipEpisode, found := animeMetadata.FindEpisode(strconv.Itoa(options.EpisodeNumber)) + if found { + anidbEID = anizipEpisode.AnidbEid + } + } + } + + res, err := provider.SmartSearch(hibiketorrent.AnimeSmartSearchOptions{ + Media: queryMedia, + Query: options.Query, + Batch: options.Batch, + EpisodeNumber: options.EpisodeNumber, + Resolution: options.Resolution, + BestReleases: options.BestReleases, + AnidbAID: anidbAID, + AnidbEID: anidbEID, + }) + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + return newPlaygroundResponse(playgroundLogger, res), nil + case "getTorrentInfoHash": + var torrent hibiketorrent.AnimeTorrent + _ = json.Unmarshal([]byte(params.Inputs["torrent"].(string)), &torrent) + + res, err := provider.GetTorrentInfoHash(&torrent) + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + return newPlaygroundResponse(playgroundLogger, res), nil + case "getTorrentMagnetLink": + var torrent hibiketorrent.AnimeTorrent + _ = json.Unmarshal([]byte(params.Inputs["torrent"].(string)), &torrent) + + res, err := provider.GetTorrentMagnetLink(&torrent) + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + return newPlaygroundResponse(playgroundLogger, res), nil + case "getLatest": + res, err := provider.GetLatest() + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + return newPlaygroundResponse(playgroundLogger, res), nil + case "getSettings": + res := provider.GetSettings() + return newPlaygroundResponse(playgroundLogger, res), nil + } + } + + return nil, fmt.Errorf("unknown call") +} + +func (r *PlaygroundRepository) runPlaygroundCodeMangaProvider(ext *extension.Extension, params *RunPlaygroundCodeParams) (resp *RunPlaygroundCodeResponse, err error) { + + playgroundLogger := r.newPlaygroundDebugLogger() + + mediaId, ok := params.Inputs["mediaId"].(float64) + if !ok || mediaId <= 0 { + return nil, fmt.Errorf("invalid mediaId") + } + + media, err := r.getManga(int(mediaId)) + if err != nil { + return nil, err + } + + titles := media.GetAllTitles() + + switch params.Language { + case extension.LanguageGo: + //... + case extension.LanguageJavascript, extension.LanguageTypescript: + _, provider, err := extension_repo.NewGojaMangaProvider(ext, params.Language, playgroundLogger.logger, r.gojaRuntimeManager) + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + defer r.gojaRuntimeManager.DeletePluginPool(ext.ID) + + // Run the code + switch params.Function { + case "search": + // Search + y := 0 + if media.GetStartDate().GetYear() != nil { + y = *media.GetStartDate().GetYear() + } + + ret := make([]*hibikemanga.SearchResult, 0) + for _, title := range titles { + res, err := provider.Search(hibikemanga.SearchOptions{ + Query: *title, + Year: y, + }) + if err != nil { + playgroundLogger.logger.Error().Err(err).Msgf("playground: Search failed for title \"%s\"", *title) + } + manga.HydrateSearchResultSearchRating(res, title) + ret = append(ret, res...) + } + + var selected *hibikemanga.SearchResult + if len(ret) > 0 { + selected = manga.GetBestSearchResult(ret) + } + + return newPlaygroundResponse(playgroundLogger, selected), nil + + case "findChapters": + res, err := provider.FindChapters(params.Inputs["id"].(string)) + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + return newPlaygroundResponse(playgroundLogger, res), nil + + case "findChapterPages": + res, err := provider.FindChapterPages(params.Inputs["id"].(string)) + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + return newPlaygroundResponse(playgroundLogger, res), nil + } + } + + return nil, fmt.Errorf("unknown call") +} + +func (r *PlaygroundRepository) runPlaygroundCodeOnlinestreamProvider(ext *extension.Extension, params *RunPlaygroundCodeParams) (resp *RunPlaygroundCodeResponse, err error) { + + playgroundLogger := r.newPlaygroundDebugLogger() + + mediaId, ok := params.Inputs["mediaId"].(float64) + if !ok || mediaId <= 0 { + return nil, fmt.Errorf("invalid mediaId") + } + + // Fetch the anime + anime, _, err := r.getAnime(int(mediaId)) + if err != nil { + return nil, err + } + + titles := anime.GetAllTitles() + + queryMedia := hibikeonlinestream.Media{ + ID: anime.GetID(), + IDMal: anime.GetIDMal(), + Status: string(*anime.GetStatus()), + Format: string(*anime.GetFormat()), + EnglishTitle: anime.GetTitle().GetEnglish(), + RomajiTitle: anime.GetRomajiTitleSafe(), + EpisodeCount: anime.GetTotalEpisodeCount(), + Synonyms: anime.GetSynonymsContainingSeason(), + IsAdult: *anime.GetIsAdult(), + StartDate: &hibikeonlinestream.FuzzyDate{ + Year: *anime.GetStartDate().GetYear(), + Month: anime.GetStartDate().GetMonth(), + Day: anime.GetStartDate().GetDay(), + }, + } + + switch params.Language { + case extension.LanguageGo: + //... + case extension.LanguageJavascript, extension.LanguageTypescript: + _, provider, err := extension_repo.NewGojaOnlinestreamProvider(ext, params.Language, playgroundLogger.logger, r.gojaRuntimeManager) + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + defer r.gojaRuntimeManager.DeletePluginPool(ext.ID) + + // Run the code + switch params.Function { + case "search": + // Search - params: dub: boolean + ret := make([]*hibikeonlinestream.SearchResult, 0) + for _, title := range titles { + res, err := provider.Search(hibikeonlinestream.SearchOptions{ + Media: queryMedia, + Query: *title, + Dub: params.Inputs["dub"].(bool), + Year: anime.GetStartYearSafe(), + }) + if err != nil { + playgroundLogger.logger.Error().Err(err).Msgf("playground: Search failed for title \"%s\"", *title) + } + ret = append(ret, res...) + } + + if len(ret) == 0 { + return newPlaygroundResponse(playgroundLogger, onlinestream.ErrNoAnimeFound), nil + } + + bestRes, found := onlinestream.GetBestSearchResult(ret, titles) + if !found { + return newPlaygroundResponse(playgroundLogger, onlinestream.ErrNoAnimeFound), nil + } + + return newPlaygroundResponse(playgroundLogger, bestRes), nil + + case "findEpisodes": + // FindEpisodes - params: id: string + res, err := provider.FindEpisodes(params.Inputs["id"].(string)) + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + return newPlaygroundResponse(playgroundLogger, res), nil + + case "findEpisodeServer": + // FindEpisodeServer - params: episode: EpisodeDetails, server: string + var episode hibikeonlinestream.EpisodeDetails + _ = json.Unmarshal([]byte(params.Inputs["episode"].(string)), &episode) + + res, err := provider.FindEpisodeServer(&episode, params.Inputs["server"].(string)) + if err != nil { + return newPlaygroundResponse(playgroundLogger, err), nil + } + return newPlaygroundResponse(playgroundLogger, res), nil + } + } + + return nil, fmt.Errorf("unknown call") +} diff --git a/seanime-2.9.10/internal/extension_playground/playground_test.go b/seanime-2.9.10/internal/extension_playground/playground_test.go new file mode 100644 index 0000000..5f71621 --- /dev/null +++ b/seanime-2.9.10/internal/extension_playground/playground_test.go @@ -0,0 +1,76 @@ +package extension_playground + +import ( + "os" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/extension" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGojaAnimeTorrentProvider(t *testing.T) { + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t, test_utils.Anilist()) + + logger := util.NewLogger() + + anilistClient := anilist.TestGetMockAnilistClient() + platform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + metadataProvider := metadata.GetMockProvider(t) + + repo := NewPlaygroundRepository(logger, platform, metadataProvider) + + // Get the script + filepath := "../extension_repo/goja_torrent_test/my-torrent-provider.ts" + fileB, err := os.ReadFile(filepath) + if err != nil { + t.Fatal(err) + } + + params := RunPlaygroundCodeParams{ + Type: extension.TypeAnimeTorrentProvider, + Language: extension.LanguageTypescript, + Code: string(fileB), + Inputs: nil, + Function: "", + } + + tests := []struct { + name string + inputs map[string]interface{} + function string + }{ + { + name: "Search", + function: "search", + inputs: map[string]interface{}{ + "query": "One Piece", + "mediaId": 21, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params.Function = tt.function + params.Inputs = tt.inputs + + resp, err := repo.RunPlaygroundCode(¶ms) + require.NoError(t, err) + + t.Log("Logs:") + + t.Log(resp.Logs) + + t.Log("\n\nValue:") + + t.Log(resp.Value) + }) + } + +} diff --git a/seanime-2.9.10/internal/extension_repo/README.md b/seanime-2.9.10/internal/extension_repo/README.md new file mode 100644 index 0000000..dbc5389 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/README.md @@ -0,0 +1,12 @@ +## Add packages to Go interpreter + +1. Extract package symbols using `yaegi` CLI tool + + ```bash + cd internal/yaegi_interp + ``` + + ```bash + yaegi extract "github.com/5rahim/hibike/a/b" + yaegi extract "github.com/a/b/c" + ``` diff --git a/seanime-2.9.10/internal/extension_repo/builtin.go b/seanime-2.9.10/internal/extension_repo/builtin.go new file mode 100644 index 0000000..4724a03 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/builtin.go @@ -0,0 +1,149 @@ +package extension_repo + +import ( + "seanime/internal/events" + "seanime/internal/extension" + hibikemanga "seanime/internal/extension/hibike/manga" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" + hibiketorrent "seanime/internal/extension/hibike/torrent" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Built-in extensions +// - Built-in extensions are loaded once, on application startup +// - The "manifestURI" field is set to "builtin" to indicate that the extension is not external +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) ReloadBuiltInExtension(ext extension.Extension, provider interface{}) { + r.reloadBuiltInExtension(ext, provider) +} + +func (r *Repository) reloadBuiltInExtension(ext extension.Extension, provider interface{}) { + + // Unload the extension + // Remove extension from bank + r.extensionBank.Delete(ext.ID) + + // Kill Goja VM if it exists + gojaExtension, ok := r.gojaExtensions.Get(ext.ID) + if ok { + // Interrupt the extension's runtime and running processed before unloading + gojaExtension.ClearInterrupt() + r.logger.Trace().Str("id", ext.ID).Msg("extensions: Killed built-in extension's runtime") + r.gojaExtensions.Delete(ext.ID) + } + // Remove from invalid extensions + r.invalidExtensions.Delete(ext.ID) + + // Load the extension + r.loadBuiltInExtension(ext, provider) +} + +func saveUserConfigInProvider(ext *extension.Extension, provider interface{}) { + if provider == nil { + return + } + + if ext.SavedUserConfig == nil { + return + } + + if configurableProvider, ok := provider.(extension.Configurable); ok { + configurableProvider.SetSavedUserConfig(*ext.SavedUserConfig) + } +} + +func (r *Repository) loadBuiltInExtension(ext extension.Extension, provider interface{}) { + + r.builtinExtensions.Set(ext.ID, &builtinExtension{ + Extension: ext, + provider: provider, + }) + + // Load user config in the struct + configErr := r.loadUserConfig(&ext) + if configErr != nil { + r.invalidExtensions.Set(ext.ID, &extension.InvalidExtension{ + ID: ext.ID, + Reason: configErr.Error(), + Path: "", + Code: extension.InvalidExtensionUserConfigError, + Extension: ext, + }) + r.logger.Warn().Err(configErr).Str("id", ext.ID).Msg("extensions: Failed to load user config") + } + + switch ext.Type { + case extension.TypeMangaProvider: + switch ext.Language { + // Go + case extension.LanguageGo: + if provider == nil { + r.logger.Error().Str("id", ext.ID).Msg("extensions: Built-in manga provider extension requires a provider") + return + } + saveUserConfigInProvider(&ext, provider) + if mangaProvider, ok := provider.(hibikemanga.Provider); ok { + r.loadBuiltInMangaProviderExtension(ext, mangaProvider) + } + } + case extension.TypeAnimeTorrentProvider: + switch ext.Language { + // Go + case extension.LanguageGo: + if provider == nil { + r.logger.Error().Str("id", ext.ID).Msg("extensions: Built-in anime torrent provider extension requires a provider") + return + } + saveUserConfigInProvider(&ext, provider) + if animeProvider, ok := provider.(hibiketorrent.AnimeProvider); ok { + r.loadBuiltInAnimeTorrentProviderExtension(ext, animeProvider) + } + } + case extension.TypeOnlinestreamProvider: + switch ext.Language { + // Go + case extension.LanguageGo: + if provider == nil { + r.logger.Error().Str("id", ext.ID).Msg("extensions: Built-in onlinestream provider extension requires a provider") + return + } + saveUserConfigInProvider(&ext, provider) + if onlinestreamProvider, ok := provider.(hibikeonlinestream.Provider); ok { + r.loadBuiltInOnlinestreamProviderExtension(ext, onlinestreamProvider) + } + case extension.LanguageJavascript, extension.LanguageTypescript: + r.loadBuiltInOnlinestreamProviderExtensionJS(ext) + } + case extension.TypePlugin: + // TODO: Implement + } + + r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded built-in extension") + r.wsEventManager.SendEvent(events.ExtensionsReloaded, nil) +} + +func (r *Repository) loadBuiltInMangaProviderExtension(ext extension.Extension, provider hibikemanga.Provider) { + r.extensionBank.Set(ext.ID, extension.NewMangaProviderExtension(&ext, provider)) + r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded built-in manga provider extension") +} + +func (r *Repository) loadBuiltInAnimeTorrentProviderExtension(ext extension.Extension, provider hibiketorrent.AnimeProvider) { + r.extensionBank.Set(ext.ID, extension.NewAnimeTorrentProviderExtension(&ext, provider)) + r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded built-in anime torrent provider extension") +} + +func (r *Repository) loadBuiltInOnlinestreamProviderExtension(ext extension.Extension, provider hibikeonlinestream.Provider) { + r.extensionBank.Set(ext.ID, extension.NewOnlinestreamProviderExtension(&ext, provider)) + r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded built-in onlinestream provider extension") +} + +func (r *Repository) loadBuiltInOnlinestreamProviderExtensionJS(ext extension.Extension) { + // Load the extension as if it was an external extension + err := r.loadExternalOnlinestreamExtensionJS(&ext, ext.Language) + if err != nil { + r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to load built-in JS onlinestream provider extension") + return + } + r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded built-in onlinestream provider extension") +} diff --git a/seanime-2.9.10/internal/extension_repo/external.go b/seanime-2.9.10/internal/extension_repo/external.go new file mode 100644 index 0000000..cd009e3 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/external.go @@ -0,0 +1,672 @@ +package extension_repo + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "runtime" + "seanime/internal/constants" + "seanime/internal/events" + "seanime/internal/extension" + "seanime/internal/util" + "sync" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/google/uuid" + "github.com/samber/lo" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// External extensions +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) FetchExternalExtensionData(manifestURI string) (*extension.Extension, error) { + return r.fetchExternalExtensionData(manifestURI) +} + +// fetchExternalExtensionData fetches the extension data from the manifest URI. +// noPayloadDownload is an optional argument to skip downloading the payload from the payload URI if it exists (e.g when checking for updates) +func (r *Repository) fetchExternalExtensionData(manifestURI string, noPayloadDownload ...bool) (*extension.Extension, error) { + + // Fetch the manifest file + client := &http.Client{} + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURI, nil) + if err != nil { + r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to create HTTP request") + return nil, fmt.Errorf("failed to create HTTP request, %w", err) + } + + resp, err := client.Do(req) + if err != nil { + r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to fetch extension manifest") + return nil, fmt.Errorf("failed to fetch extension manifest, %w", err) + } + defer resp.Body.Close() + + // Parse the response + var ext extension.Extension + err = json.NewDecoder(resp.Body).Decode(&ext) + if err != nil { + r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to parse extension manifest") + return nil, fmt.Errorf("failed to parse extension manifest, %w", err) + } + + // Before sanity check, fetch the payload if needed + if ext.PayloadURI != "" && !lo.Contains(noPayloadDownload, true) { + r.logger.Debug().Str("id", ext.ID).Msg("extensions: Downloading payload") + payloadFromURI, err := r.downloadPayload(ext.PayloadURI) + if err != nil { + r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to download payload") + return nil, fmt.Errorf("failed to download payload, %w", err) + } + if payloadFromURI == "" { + r.logger.Error().Str("id", ext.ID).Msg("extensions: Downloaded payload is empty") + return nil, fmt.Errorf("downloaded payload is empty") + } + ext.Payload = payloadFromURI + } + + // Check manifest + if err = manifestSanityCheck(&ext); err != nil { + r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed sanity check") + return nil, fmt.Errorf("failed sanity check, %w", err) + } + + // Check if the extension is development mode + if ext.IsDevelopment { + r.logger.Error().Str("id", ext.ID).Msg("extensions: Development mode enabled, cannot install development mode extensions for security reasons") + return nil, fmt.Errorf("cannot install development mode extensions for security reasons") + } + + return &ext, nil +} + +func (r *Repository) downloadPayload(uri string) (string, error) { + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + client := &http.Client{} + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return "", fmt.Errorf("failed to create HTTP request, %w", err) + } + + // Download the payload + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to download payload, %w", err) + } + defer resp.Body.Close() + + // Read the payload + payload, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read payload, %w", err) + } + + return string(payload), nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Install external extension +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type ExtensionInstallResponse struct { + Message string `json:"message"` +} + +func (r *Repository) InstallExternalExtension(manifestURI string) (*ExtensionInstallResponse, error) { + + ext, err := r.fetchExternalExtensionData(manifestURI) + if err != nil { + r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to fetch extension data") + return nil, fmt.Errorf("failed to fetch extension data, %w", err) + } + + filename := filepath.Join(r.extensionDir, ext.ID+".json") + + update := false + + // Check if the extension is already installed + // i.e. a file with the same ID exists + if _, err := os.Stat(filename); err == nil { + r.logger.Debug().Str("id", ext.ID).Msg("extensions: Updating extension") + // Delete the old extension + err := os.Remove(filename) + if err != nil { + r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to remove old extension") + return nil, fmt.Errorf("failed to remove old extension, %w", err) + } + update = true + } + + // Add the extension as a json file + file, err := os.Create(filename) + if err != nil { + r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to create extension file") + return nil, fmt.Errorf("failed to create extension file, %w", err) + } + defer file.Close() + + // Write the extension to the file + enc := json.NewEncoder(file) + err = enc.Encode(ext) + if err != nil { + r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to write extension to file") + return nil, fmt.Errorf("failed to write extension to file, %w", err) + } + + // Reload the extensions + //r.loadExternalExtensions() + + r.reloadExtension(ext.ID) + + if update { + r.updateDataMu.Lock() + r.updateData = lo.Filter(r.updateData, func(item UpdateData, _ int) bool { + return item.ExtensionID != ext.ID + }) + r.updateDataMu.Unlock() + return &ExtensionInstallResponse{ + Message: fmt.Sprintf("Successfully updated %s", ext.Name), + }, nil + } + + return &ExtensionInstallResponse{ + Message: fmt.Sprintf("Successfully installed %s", ext.Name), + }, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Uninstall external extension +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) UninstallExternalExtension(id string) error { + + // Check if the extension exists + // Parse the ext + ext, err := extractExtensionFromFile(filepath.Join(r.extensionDir, id+".json")) + if err != nil { + r.logger.Error().Err(err).Str("filepath", filepath.Join(r.extensionDir, id+".json")).Msg("extensions: Failed to read extension file") + return fmt.Errorf("failed to read extension file, %w", err) + } + + // Uninstall the extension + err = os.Remove(filepath.Join(r.extensionDir, id+".json")) + if err != nil { + r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to uninstall extension") + return fmt.Errorf("failed to uninstall extension, %w", err) + } + + // Reload the extensions + //r.loadExternalExtensions() + + go func() { + _ = r.deleteExtensionUserConfig(id) + + // Delete the plugin data if it was a plugin + if ext.Type == extension.TypePlugin { + r.deletePluginData(id) + r.removePluginFromStoredSettings(id) + } + }() + + r.reloadExtension(id) + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Check for updates +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// checkForUpdates checks all extensions for updates by querying their respective repositories. +// It returns a list of extension update data containing IDs and versions. +func (r *Repository) checkForUpdates() (ret []UpdateData) { + + wg := sync.WaitGroup{} + mu := sync.Mutex{} + + r.logger.Trace().Msg("extensions: Checking for updates") + + // Check for updates for all extensions + r.extensionBank.Range(func(key string, ext extension.BaseExtension) bool { + wg.Add(1) + go func(ext extension.BaseExtension) { + defer wg.Done() + + // Skip built-in extensions + if ext.GetManifestURI() == "builtin" || ext.GetManifestURI() == "" { + return + } + + // Get the extension data from the repository + extFromRepo, err := r.fetchExternalExtensionData(ext.GetManifestURI(), true) + if err != nil { + r.logger.Error().Err(err).Str("id", ext.GetID()).Str("url", ext.GetManifestURI()).Msg("extensions: Failed to fetch extension data while checking for update") + return + } + + // Sanity check, this checks for the version too + if err = manifestSanityCheck(extFromRepo); err != nil { + r.logger.Error().Err(err).Str("id", ext.GetID()).Str("url", ext.GetManifestURI()).Msg("extensions: Failed sanity check while checking for update") + return + } + + if extFromRepo.ID != ext.GetID() { + r.logger.Warn().Str("id", ext.GetID()).Str("newID", extFromRepo.ID).Str("url", ext.GetManifestURI()).Msg("extensions: Extension ID changed while checking for update") + return + } + + // If there's an update, send the update data to the channel + if extFromRepo.Version != ext.GetVersion() { + mu.Lock() + ret = append(ret, UpdateData{ + ExtensionID: extFromRepo.ID, + Version: extFromRepo.Version, + ManifestURI: extFromRepo.ManifestURI, + }) + mu.Unlock() + } + }(ext) + return true + }) + + wg.Wait() + + r.logger.Debug().Int("haveUpdates", len(ret)).Msg("extensions: Retrieved update info") + + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Update extension code +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// UpdateExtensionCode updates the code of an external application +func (r *Repository) UpdateExtensionCode(id string, payload string) error { + + if id == "" { + r.logger.Error().Msg("extensions: ID is empty") + return fmt.Errorf("id is empty") + } + + if payload == "" { + r.logger.Error().Msg("extensions: Payload is empty") + return fmt.Errorf("payload is empty") + } + + // We don't check if the extension existed in "loaded" extensions since the extension might be invalid + // We check if the file exists + + filename := id + ".json" + extensionFilepath := filepath.Join(r.extensionDir, filename) + + if _, err := os.Stat(extensionFilepath); err != nil { + r.logger.Error().Err(err).Str("id", id).Msg("extensions: Extension not found") + return fmt.Errorf("extension not found") + } + + ext, err := extractExtensionFromFile(extensionFilepath) + if err != nil { + r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to read extension file") + return fmt.Errorf("failed to read extension file, %w", err) + } + + // Update the payload + ext.Payload = payload + + // Write the extension to the file + file, err := os.Create(extensionFilepath) + if err != nil { + r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to create extension file") + return fmt.Errorf("failed to create extension file, %w", err) + } + defer file.Close() + + enc := json.NewEncoder(file) + err = enc.Encode(ext) + + if err != nil { + r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to write extension to file") + return fmt.Errorf("failed to write extension to file, %w", err) + } + + // Call reload extension to unload it + r.reloadExtension(id) + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Loading/Reloading external extensions +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) ReloadExternalExtensions() { + r.loadExternalExtensions() +} + +func (r *Repository) ReloadExternalExtension(id string) { + r.reloadExtension(id) + + runtime.GC() +} + +// interruptExternalGojaExtensionVMs kills all VMs from currently loaded external Goja extensions & clears the Goja extensions map. +func (r *Repository) interruptExternalGojaExtensionVMs() { + defer util.HandlePanicInModuleThen("extension_repo/interruptExternalGojaExtensionVMs", func() {}) + + r.logger.Trace().Msg("extensions: Interrupting Goja VMs") + + count := 0 + // Remove external extensions from the Goja extensions map + //r.gojaExtensions.Clear() + for _, key := range r.gojaExtensions.Keys() { + if gojaExt, ok := r.gojaExtensions.Get(key); ok { + if gojaExt.GetExtension().ManifestURI != "builtin" { + gojaExt.ClearInterrupt() + r.gojaExtensions.Delete(key) + count++ + } + } + } + + r.logger.Debug().Int("count", count).Msg("extensions: Killed Goja VMs") +} + +// unloadExternalExtensions unloads all external extensions from the extension banks. +func (r *Repository) unloadExternalExtensions() { + r.logger.Trace().Msg("extensions: Unloading external extensions") + // We also clear the invalid extensions list, assuming the extensions are reloaded + //r.invalidExtensions.Clear() + + count := 0 + + for _, key := range r.invalidExtensions.Keys() { + if invalidExt, ok := r.invalidExtensions.Get(key); ok { + if invalidExt.Extension.ManifestURI != "builtin" { + r.invalidExtensions.Delete(key) + count++ + } + } + } + r.extensionBank.RemoveExternalExtensions() + + r.logger.Debug().Int("count", count).Msg("extensions: Unloaded external extensions") +} + +// loadExternalExtensions loads all external extensions from the extension directory. +// This should be called after the built-in extensions are loaded. +func (r *Repository) loadExternalExtensions() { + r.logger.Trace().Msg("extensions: Loading external extensions") + + // Interrupt all Goja VMs + r.interruptExternalGojaExtensionVMs() + + // Unload all external extensions + r.unloadExternalExtensions() + + // + // Load external extensions + // + + err := filepath.WalkDir(r.extensionDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + r.logger.Error().Err(err).Msg("extensions: Failed to walk directory") + return err + } + + if d.IsDir() { + return nil + } + + // Check if the file is a .json file + // If it is, parse the json and install the extension + if filepath.Ext(path) != ".json" { + return nil + } + + r.loadExternalExtension(path) + + return nil + + }) + if err != nil { + r.logger.Error().Err(err).Msg("extensions: Failed to load extensions") + return + } + + r.logger.Debug().Msg("extensions: Loaded external extensions") + + if r.firstExternalExtensionLoadedFunc != nil { + r.firstExternalExtensionLoadedFunc() + } + + r.wsEventManager.SendEvent(events.ExtensionsReloaded, nil) +} + +// Loads an external extension from a file path +func (r *Repository) loadExternalExtension(filePath string) { + // Parse the ext + ext, err := extractExtensionFromFile(filePath) + if err != nil { + r.logger.Error().Err(err).Str("filepath", filePath).Msg("extensions: Failed to read extension file") + return + } + + ext.Lang = extension.GetExtensionLang(ext.Lang) + + var manifestError error + + // + + // | Manifest sanity check + // + + + // Sanity check + if err = r.extensionSanityCheck(ext); err != nil { + r.logger.Error().Err(err).Str("filepath", filePath).Msg("extensions: Failed sanity check") + manifestError = err + } + + invalidExtensionID := ext.ID + if invalidExtensionID == "" { + invalidExtensionID = uuid.NewString() + } + + // If there was an error with the manifest, skip loading the extension, + // add the extension to the InvalidExtensions list and return + // The extension should be added to the InvalidExtensions list with an auto-generated ID. + if manifestError != nil { + r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{ + ID: invalidExtensionID, + Reason: manifestError.Error(), + Path: filePath, + Code: extension.InvalidExtensionManifestError, + Extension: *ext, + }) + r.logger.Error().Err(manifestError).Str("filepath", filePath).Msg("extensions: Failed to load extension, manifest error") + return + } + + if ext.SemverConstraint != "" { + c, err := semver.NewConstraint(ext.SemverConstraint) + v, _ := semver.NewVersion(constants.Version) + if err == nil { + if !c.Check(v) { + r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{ + ID: invalidExtensionID, + Reason: fmt.Sprintf("Incompatible with this version of Seanime (%s): %s", constants.Version, ext.SemverConstraint), + Path: filePath, + Code: extension.InvalidExtensionSemverConstraintError, + Extension: *ext, + }) + r.logger.Error().Str("id", ext.ID).Msg("extensions: Failed to load extension, semver constraint error") + return + } + } + } + + var loadingErr error + + // + + // | Load payload + // + + + // Load the payload URI if the extension is development mode. + // The payload URI is a path to the payload file. + if ext.IsDevelopment && ext.PayloadURI != "" { + if _, err := os.Stat(ext.PayloadURI); errors.Is(err, os.ErrNotExist) { + r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to read payload file") + return + } + payload, err := os.ReadFile(ext.PayloadURI) + if err != nil { + r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to read payload file") + return + } + ext.Payload = string(payload) + r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded payload from file") + } + + // + + // | Check plugin permissions + // + + + if ext.Type == extension.TypePlugin && !ext.IsDevelopment { + if ext.Plugin == nil { // Shouldn't happen because of sanity check, but just in case + r.logger.Error().Str("id", ext.ID).Msg("extensions: Plugin manifest is missing plugin object") + return + } + permissionErr := r.checkPluginPermissions(ext) + if permissionErr != nil { + r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{ + ID: invalidExtensionID, + Reason: permissionErr.Error(), + Path: filePath, + Code: extension.InvalidExtensionPluginPermissionsNotGranted, + Extension: *ext, + PluginPermissionDescription: ext.Plugin.Permissions.GetDescription(), + }) + r.logger.Warn().Err(permissionErr).Str("id", ext.ID).Msg("extensions: Plugin permissions not granted. Please grant the permissions in the extension page.") + return + } + } + + // + + // | Load user config + // + + + // Load user config + configErr := r.loadUserConfig(ext) + + // If there was an error loading the user config, we add it to the InvalidExtensions list + // BUT we still load the extension + // DEVNOTE: Failure to load the user config is not a critical error + if configErr != nil { + r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{ + ID: invalidExtensionID, + Reason: configErr.Error(), + Path: filePath, + Code: extension.InvalidExtensionUserConfigError, + Extension: *ext, + }) + r.logger.Warn().Err(configErr).Str("id", invalidExtensionID).Msg("extensions: Failed to load user config") + } + + // + + // | Load extension + // + + + // Load extension + switch ext.Type { + case extension.TypeMangaProvider: + // Load manga provider + loadingErr = r.loadExternalMangaExtension(ext) + case extension.TypeOnlinestreamProvider: + // Load online streaming provider + loadingErr = r.loadExternalOnlinestreamProviderExtension(ext) + case extension.TypeAnimeTorrentProvider: + // Load torrent provider + loadingErr = r.loadExternalAnimeTorrentProviderExtension(ext) + case extension.TypePlugin: + // Load plugin + loadingErr = r.loadPlugin(ext) + default: + r.logger.Error().Str("type", string(ext.Type)).Msg("extensions: Extension type not supported") + loadingErr = fmt.Errorf("extension type not supported") + } + + // If there was an error loading the extension, skip adding it to the extension bank + // and add the extension to the InvalidExtensions list + if loadingErr != nil { + r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{ + ID: invalidExtensionID, + Reason: loadingErr.Error(), + Path: filePath, + Code: extension.InvalidExtensionPayloadError, + Extension: *ext, + }) + r.logger.Error().Err(loadingErr).Str("filepath", filePath).Msg("extensions: Failed to load extension") + return + } + + r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded external extension") +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Reload specific extension +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) reloadExtension(id string) { + r.logger.Trace().Str("id", id).Msg("extensions: Reloading extension") + + // 1. Unload the extension + + // Remove extension from bank + r.extensionBank.Delete(id) + + // Kill Goja VM if it exists + gojaExtension, ok := r.gojaExtensions.Get(id) + if ok { + // Interrupt the extension's runtime and running processed before unloading + gojaExtension.ClearInterrupt() + r.logger.Trace().Str("id", id).Msg("extensions: Killed extension's runtime") + r.gojaExtensions.Delete(id) + } + // Remove from invalid extensions + r.invalidExtensions.Delete(id) + + time.Sleep(200 * time.Millisecond) + + // 2. Load the extension back + + // Load the extension from the file + extensionFilepath := filepath.Join(r.extensionDir, id+".json") + + // Check if the extension still exists + if _, err := os.Stat(extensionFilepath); err != nil { + // If the extension doesn't exist anymore, return silently - it was uninstalled + r.wsEventManager.SendEvent(events.ExtensionsReloaded, nil) + r.logger.Debug().Str("id", id).Msg("extensions: Extension removed") + return + } + + // If the extension still exist, load it back + r.loadExternalExtension(extensionFilepath) + + r.logger.Debug().Str("id", id).Msg("extensions: Reloaded extension") + r.wsEventManager.SendEvent(events.ExtensionsReloaded, nil) +} diff --git a/seanime-2.9.10/internal/extension_repo/external_anime_torrent_provider.go b/seanime-2.9.10/internal/extension_repo/external_anime_torrent_provider.go new file mode 100644 index 0000000..a0a6f5f --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/external_anime_torrent_provider.go @@ -0,0 +1,41 @@ +package extension_repo + +import ( + "fmt" + "seanime/internal/extension" + "seanime/internal/util" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Anime Torrent provider +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) loadExternalAnimeTorrentProviderExtension(ext *extension.Extension) (err error) { + defer util.HandlePanicInModuleWithError("extension_repo/loadExternalAnimeTorrentProviderExtension", &err) + + switch ext.Language { + case extension.LanguageJavascript, extension.LanguageTypescript: + err = r.loadExternalAnimeTorrentProviderExtensionJS(ext, ext.Language) + default: + err = fmt.Errorf("unsupported language: %v", ext.Language) + } + + if err != nil { + return + } + + return +} + +func (r *Repository) loadExternalAnimeTorrentProviderExtensionJS(ext *extension.Extension, language extension.Language) error { + provider, gojaExt, err := NewGojaAnimeTorrentProvider(ext, language, r.logger, r.gojaRuntimeManager) + if err != nil { + return err + } + + // Add the extension to the map + retExt := extension.NewAnimeTorrentProviderExtension(ext, provider) + r.extensionBank.Set(ext.ID, retExt) + r.gojaExtensions.Set(ext.ID, gojaExt) + return nil +} diff --git a/seanime-2.9.10/internal/extension_repo/external_fs.go b/seanime-2.9.10/internal/extension_repo/external_fs.go new file mode 100644 index 0000000..a576bfa --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/external_fs.go @@ -0,0 +1,24 @@ +package extension_repo + +import ( + "github.com/goccy/go-json" + "os" + "seanime/internal/extension" +) + +func extractExtensionFromFile(filepath string) (ext *extension.Extension, err error) { + // Get the content of the file + fileContent, err := os.ReadFile(filepath) + if err != nil { + return + } + + err = json.Unmarshal(fileContent, &ext) + if err != nil { + // If the manifest data is corrupted or not a valid manifest, skip loading the extension. + // We don't add it to the InvalidExtensions list because there's not enough information to + return + } + + return +} diff --git a/seanime-2.9.10/internal/extension_repo/external_manga_provider.go b/seanime-2.9.10/internal/extension_repo/external_manga_provider.go new file mode 100644 index 0000000..4b8d255 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/external_manga_provider.go @@ -0,0 +1,38 @@ +package extension_repo + +import ( + "seanime/internal/extension" + "seanime/internal/util" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Manga +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) loadExternalMangaExtension(ext *extension.Extension) (err error) { + defer util.HandlePanicInModuleWithError("extension_repo/loadExternalMangaExtension", &err) + + switch ext.Language { + case extension.LanguageJavascript, extension.LanguageTypescript: + err = r.loadExternalMangaExtensionJS(ext, ext.Language) + } + + if err != nil { + return + } + + return +} + +func (r *Repository) loadExternalMangaExtensionJS(ext *extension.Extension, language extension.Language) error { + provider, gojaExt, err := NewGojaMangaProvider(ext, language, r.logger, r.gojaRuntimeManager) + if err != nil { + return err + } + + // Add the extension to the map + retExt := extension.NewMangaProviderExtension(ext, provider) + r.extensionBank.Set(ext.ID, retExt) + r.gojaExtensions.Set(ext.ID, gojaExt) + return nil +} diff --git a/seanime-2.9.10/internal/extension_repo/external_onlinestream_provider.go b/seanime-2.9.10/internal/extension_repo/external_onlinestream_provider.go new file mode 100644 index 0000000..767b560 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/external_onlinestream_provider.go @@ -0,0 +1,41 @@ +package extension_repo + +import ( + "fmt" + "seanime/internal/extension" + "seanime/internal/util" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Online streaming +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) loadExternalOnlinestreamProviderExtension(ext *extension.Extension) (err error) { + defer util.HandlePanicInModuleWithError("extension_repo/loadExternalOnlinestreamProviderExtension", &err) + + switch ext.Language { + case extension.LanguageJavascript, extension.LanguageTypescript: + err = r.loadExternalOnlinestreamExtensionJS(ext, ext.Language) + default: + err = fmt.Errorf("unsupported language: %v", ext.Language) + } + + if err != nil { + return + } + + return +} + +func (r *Repository) loadExternalOnlinestreamExtensionJS(ext *extension.Extension, language extension.Language) error { + provider, gojaExt, err := NewGojaOnlinestreamProvider(ext, language, r.logger, r.gojaRuntimeManager) + if err != nil { + return err + } + + // Add the extension to the map + retExt := extension.NewOnlinestreamProviderExtension(ext, provider) + r.extensionBank.Set(ext.ID, retExt) + r.gojaExtensions.Set(ext.ID, gojaExt) + return nil +} diff --git a/seanime-2.9.10/internal/extension_repo/external_plugin.go b/seanime-2.9.10/internal/extension_repo/external_plugin.go new file mode 100644 index 0000000..be42674 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/external_plugin.go @@ -0,0 +1,148 @@ +package extension_repo + +import ( + "fmt" + "path/filepath" + "slices" + + "seanime/internal/extension" + "seanime/internal/util" + "seanime/internal/util/filecache" + + "github.com/samber/lo" +) + +const PluginSettingsKey = "1" +const PluginSettingsBucket = "plugin-settings" + +var ( + ErrPluginPermissionsNotGranted = fmt.Errorf("plugin: permissions not granted") +) + +type ( + StoredPluginSettingsData struct { + PinnedTrayPluginIds []string `json:"pinnedTrayPluginIds"` + PluginGrantedPermissions map[string]string `json:"pluginGrantedPermissions"` // Extension ID -> Permission Hash + } +) + +var DefaultStoredPluginSettingsData = StoredPluginSettingsData{ + PinnedTrayPluginIds: []string{}, + PluginGrantedPermissions: map[string]string{}, +} + +// GetPluginSettings returns the stored plugin settings. +// If no settings are found, it will return the default settings. +func (r *Repository) GetPluginSettings() *StoredPluginSettingsData { + bucket := filecache.NewPermanentBucket(PluginSettingsBucket) + + var settings StoredPluginSettingsData + found, _ := r.fileCacher.GetPerm(bucket, PluginSettingsKey, &settings) + if !found { + r.fileCacher.SetPerm(bucket, PluginSettingsKey, DefaultStoredPluginSettingsData) + return &DefaultStoredPluginSettingsData + } + + return &settings +} + +// SetPluginSettingsPinnedTrays sets the pinned tray plugin IDs. +func (r *Repository) SetPluginSettingsPinnedTrays(pinnedTrayPluginIds []string) { + bucket := filecache.NewPermanentBucket(PluginSettingsBucket) + + settings := r.GetPluginSettings() + settings.PinnedTrayPluginIds = pinnedTrayPluginIds + + r.fileCacher.SetPerm(bucket, PluginSettingsKey, settings) +} + +func (r *Repository) GrantPluginPermissions(pluginId string) { + // Parse the ext + ext, err := extractExtensionFromFile(filepath.Join(r.extensionDir, pluginId+".json")) + if err != nil { + r.logger.Error().Err(err).Str("filepath", filepath.Join(r.extensionDir, pluginId+".json")).Msg("extensions: Failed to read extension file") + return + } + + // Check if the extension is a plugin + if ext.Type != extension.TypePlugin { + r.logger.Error().Str("id", pluginId).Msg("extensions: Extension is not a plugin") + return + } + + // Grant the plugin permissions + permissionHash := ext.Plugin.Permissions.GetHash() + + r.setPluginGrantedPermissions(pluginId, permissionHash) + + r.logger.Debug().Str("id", pluginId).Msg("extensions: Granted plugin permissions") + + // Reload the extension + r.reloadExtension(pluginId) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// setPluginGrantedPermissions sets the granted permissions for a plugin. +func (r *Repository) setPluginGrantedPermissions(pluginId string, permissionHash string) { + bucket := filecache.NewPermanentBucket(PluginSettingsBucket) + + settings := r.GetPluginSettings() + if settings.PluginGrantedPermissions == nil { + settings.PluginGrantedPermissions = make(map[string]string) + } + settings.PluginGrantedPermissions[pluginId] = permissionHash + + r.fileCacher.SetPerm(bucket, PluginSettingsKey, settings) +} + +// removePluginFromStoredSettings removes a plugin from the stored settings. +func (r *Repository) removePluginFromStoredSettings(pluginId string) { + bucket := filecache.NewPermanentBucket(PluginSettingsBucket) + + settings := r.GetPluginSettings() + delete(settings.PluginGrantedPermissions, pluginId) + + if slices.Contains(settings.PinnedTrayPluginIds, pluginId) { + settings.PinnedTrayPluginIds = lo.Filter(settings.PinnedTrayPluginIds, func(id string, _ int) bool { + return id != pluginId + }) + } + + r.fileCacher.SetPerm(bucket, PluginSettingsKey, settings) +} + +func (r *Repository) checkPluginPermissions(ext *extension.Extension) (err error) { + defer util.HandlePanicInModuleWithError("extension_repo/checkPluginPermissions", &err) + + if ext.Type != extension.TypePlugin { + return nil + } + + if ext.Plugin == nil { + return nil + } + + // Get current plugin permission hash + pluginPermissionHash := ext.Plugin.Permissions.GetHash() + + // If the plugin has no permissions, skip the check + if pluginPermissionHash == "" { + return nil + } + + // Get stored plugin permission hash + permissionMap := r.GetPluginSettings().PluginGrantedPermissions + + // Check if the plugin has been granted the required permissions + granted, found := permissionMap[ext.ID] + if !found { + return ErrPluginPermissionsNotGranted + } + + if granted != pluginPermissionHash { + return ErrPluginPermissionsNotGranted + } + + return nil +} diff --git a/seanime-2.9.10/internal/extension_repo/goja.go b/seanime-2.9.10/internal/extension_repo/goja.go new file mode 100644 index 0000000..8916400 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja.go @@ -0,0 +1,276 @@ +package extension_repo + +import ( + "context" + "encoding/json" + "fmt" + "io" + "reflect" + "seanime/internal/extension" + goja_bindings "seanime/internal/goja/goja_bindings" + "seanime/internal/library/anime" + "seanime/internal/plugin" + "sync" + "time" + + "github.com/5rahim/habari" + "github.com/dop251/goja" + gojabuffer "github.com/dop251/goja_nodejs/buffer" + gojarequire "github.com/dop251/goja_nodejs/require" + gojaurl "github.com/dop251/goja_nodejs/url" + "github.com/evanw/esbuild/pkg/api" + "github.com/rs/zerolog" + "github.com/spf13/cast" +) + +// GojaExtension is stored in the repository extension map, giving access to the VMs. +// Current use: Kill the VM when the extension is unloaded. +type GojaExtension interface { + PutVM(*goja.Runtime) + ClearInterrupt() + GetExtension() *extension.Extension +} + +var cachedArrayOfTypes = plugin.NewStore[reflect.Type, reflect.Type](nil) + +func BindUserConfig(vm *goja.Runtime, ext *extension.Extension, logger *zerolog.Logger) { + vm.Set("$getUserPreference", func(call goja.FunctionCall) goja.Value { + if ext.SavedUserConfig == nil { + return goja.Undefined() + } + + key := call.Argument(0).String() + value, ok := ext.SavedUserConfig.Values[key] + if !ok { + // Check if the field has a default value + for _, field := range ext.UserConfig.Fields { + if field.Name == key && field.Default != "" { + return vm.ToValue(field.Default) + } + } + + return goja.Undefined() + } + + return vm.ToValue(value) + }) +} + +// ShareBinds binds the shared bindings to the VM +// This is called once per VM +func ShareBinds(vm *goja.Runtime, logger *zerolog.Logger) { + registry := new(gojarequire.Registry) + registry.Enable(vm) + + fm := goja_bindings.DefaultFieldMapper{} + vm.SetFieldNameMapper(fm) + // goja.TagFieldNameMapper("json", true) + + bindings := []struct { + name string + fn func(*goja.Runtime) error + }{ + {"url", func(vm *goja.Runtime) error { gojaurl.Enable(vm); return nil }}, + {"buffer", func(vm *goja.Runtime) error { gojabuffer.Enable(vm); return nil }}, + {"fetch", func(vm *goja.Runtime) error { goja_bindings.BindFetch(vm); return nil }}, + {"console", func(vm *goja.Runtime) error { goja_bindings.BindConsole(vm, logger); return nil }}, + {"formData", func(vm *goja.Runtime) error { goja_bindings.BindFormData(vm); return nil }}, + {"document", func(vm *goja.Runtime) error { goja_bindings.BindDocument(vm); return nil }}, + {"crypto", func(vm *goja.Runtime) error { goja_bindings.BindCrypto(vm); return nil }}, + {"torrentUtils", func(vm *goja.Runtime) error { goja_bindings.BindTorrentUtils(vm); return nil }}, + } + + for _, binding := range bindings { + if err := binding.fn(vm); err != nil { + logger.Error().Err(err).Str("name", binding.name).Msg("failed to bind") + } + } + + vm.Set("__isOffline__", plugin.GlobalAppContext.IsOffline()) + + vm.Set("$toString", func(raw any, maxReaderBytes int) (string, error) { + switch v := raw.(type) { + case io.Reader: + if maxReaderBytes == 0 { + maxReaderBytes = 32 << 20 // 32 MB + } + + limitReader := io.LimitReader(v, int64(maxReaderBytes)) + + bodyBytes, readErr := io.ReadAll(limitReader) + if readErr != nil { + return "", readErr + } + + return string(bodyBytes), nil + default: + str, err := cast.ToStringE(v) + if err == nil { + return str, nil + } + + // as a last attempt try to json encode the value + rawBytes, _ := json.Marshal(raw) + + return string(rawBytes), nil + } + }) + + vm.Set("$toBytes", func(raw any) ([]byte, error) { + switch v := raw.(type) { + case io.Reader: + bodyBytes, readErr := io.ReadAll(v) + if readErr != nil { + return nil, readErr + } + + return bodyBytes, nil + case string: + return []byte(v), nil + case []byte: + return v, nil + case []rune: + return []byte(string(v)), nil + default: + // as a last attempt try to json encode the value + rawBytes, _ := json.Marshal(raw) + return rawBytes, nil + } + }) + + vm.Set("$toError", func(raw any) error { + if err, ok := raw.(error); ok { + return err + } + + return fmt.Errorf("%v", raw) + }) + + vm.Set("$sleep", func(milliseconds int64) { + time.Sleep(time.Duration(milliseconds) * time.Millisecond) + }) + + vm.Set("$arrayOf", func(model any) any { + mt := reflect.TypeOf(model) + st := cachedArrayOfTypes.GetOrSet(mt, func() reflect.Type { + return reflect.SliceOf(mt) + }) + + return reflect.New(st).Elem().Addr().Interface() + }) + + vm.Set("$unmarshal", func(data, dst any) error { + raw, err := json.Marshal(data) + if err != nil { + return err + } + + return json.Unmarshal(raw, &dst) + }) + + vm.Set("$toPointer", func(data interface{}) interface{} { + if data == nil { + return nil + } + v := data + return &v + }) + + vm.Set("$Context", func(call goja.ConstructorCall) *goja.Object { + var instance context.Context + + oldCtx, ok := call.Argument(0).Export().(context.Context) + if ok { + instance = oldCtx + } else { + instance = context.Background() + } + + key := call.Argument(1).Export() + if key != nil { + instance = context.WithValue(instance, key, call.Argument(2).Export()) + } + + instanceValue := vm.ToValue(instance).(*goja.Object) + instanceValue.SetPrototype(call.This.Prototype()) + + return instanceValue + }) + + // + // Habari + // + habariObj := vm.NewObject() + _ = habariObj.Set("parse", func(filename string) *habari.Metadata { + return habari.Parse(filename) + }) + vm.Set("$habari", habariObj) + + // + // Anime Utils + // + animeUtilsObj := vm.NewObject() + _ = animeUtilsObj.Set("newLocalFileWrapper", func(lfs []*anime.LocalFile) *anime.LocalFileWrapper { + return anime.NewLocalFileWrapper(lfs) + }) + vm.Set("$animeUtils", animeUtilsObj) + + vm.Set("$waitGroup", func() *sync.WaitGroup { + return &sync.WaitGroup{} + }) + + // Run a function in a new goroutine + // The Goja runtime is not thread safe, so nothing related to the VM should be done in this goroutine + // You can use the $waitGroup to wait for multiple goroutines to finish + // You can use $store to communicate with the main thread + vm.Set("$unsafeGoroutine", func(fn func()) { + go func() { + defer func() { + if r := recover(); r != nil { + logger.Error().Err(fmt.Errorf("%v", r)).Msg("goroutine panic") + } + }() + fn() + }() + }) +} + +// JSVMTypescriptToJS converts typescript to javascript +func JSVMTypescriptToJS(ts string) (string, error) { + result := api.Transform(ts, api.TransformOptions{ + Target: api.ES2018, + Loader: api.LoaderTS, + Format: api.FormatDefault, + MinifyWhitespace: true, + MinifySyntax: true, + Sourcemap: api.SourceMapNone, + }) + + if len(result.Errors) > 0 { + var errMsgs []string + for _, err := range result.Errors { + errMsgs = append(errMsgs, err.Text) + } + return "", fmt.Errorf("typescript compilation errors: %v", errMsgs) + } + + return string(result.Code), nil +} + +// structToMap converts a struct to "JSON-like" map for Goja extensions +// This is used to pass structs to Goja extensions +func structToMap(obj interface{}) map[string]interface{} { + // Convert the struct to a map + jsonData, err := json.Marshal(obj) + if err != nil { + return nil + } + + var data map[string]interface{} + err = json.Unmarshal(jsonData, &data) + if err != nil { + return nil + } + + return data +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_anime_torrent_provider.go b/seanime-2.9.10/internal/extension_repo/goja_anime_torrent_provider.go new file mode 100644 index 0000000..c4ef72f --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_anime_torrent_provider.go @@ -0,0 +1,152 @@ +package extension_repo + +import ( + "context" + "seanime/internal/extension" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/goja/goja_runtime" + "seanime/internal/util" + + "github.com/rs/zerolog" +) + +type GojaAnimeTorrentProvider struct { + *gojaProviderBase +} + +func NewGojaAnimeTorrentProvider(ext *extension.Extension, language extension.Language, logger *zerolog.Logger, runtimeManager *goja_runtime.Manager) (hibiketorrent.AnimeProvider, *GojaAnimeTorrentProvider, error) { + base, err := initializeProviderBase(ext, language, logger, runtimeManager) + if err != nil { + return nil, nil, err + } + + provider := &GojaAnimeTorrentProvider{ + gojaProviderBase: base, + } + return provider, provider, nil +} + +func (g *GojaAnimeTorrentProvider) Search(opts hibiketorrent.AnimeSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) { + defer util.HandlePanicInModuleWithError(g.ext.ID+".Search", &err) + + method, err := g.callClassMethod(context.Background(), "search", structToMap(opts)) + + promiseRes, err := g.waitForPromise(method) + if err != nil { + return nil, err + } + + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return nil, err + } + + for i := range ret { + ret[i].Provider = g.ext.ID + } + + return +} + +func (g *GojaAnimeTorrentProvider) SmartSearch(opts hibiketorrent.AnimeSmartSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) { + defer util.HandlePanicInModuleWithError(g.ext.ID+".SmartSearch", &err) + + method, err := g.callClassMethod(context.Background(), "smartSearch", structToMap(opts)) + + promiseRes, err := g.waitForPromise(method) + if err != nil { + return nil, err + } + + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return nil, err + } + + for i := range ret { + ret[i].Provider = g.ext.ID + } + + return +} + +func (g *GojaAnimeTorrentProvider) GetTorrentInfoHash(torrent *hibiketorrent.AnimeTorrent) (ret string, err error) { + defer util.HandlePanicInModuleWithError(g.ext.ID+".GetTorrentInfoHash", &err) + + res, err := g.callClassMethod(context.Background(), "getTorrentInfoHash", structToMap(torrent)) + if err != nil { + return "", err + } + + promiseRes, err := g.waitForPromise(res) + if err != nil { + return "", err + } + + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return "", err + } + + return +} + +func (g *GojaAnimeTorrentProvider) GetTorrentMagnetLink(torrent *hibiketorrent.AnimeTorrent) (ret string, err error) { + defer util.HandlePanicInModuleWithError(g.ext.ID+".GetTorrentMagnetLink", &err) + + res, err := g.callClassMethod(context.Background(), "getTorrentMagnetLink", structToMap(torrent)) + if err != nil { + return "", err + } + + promiseRes, err := g.waitForPromise(res) + if err != nil { + return "", err + } + + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return "", err + } + + return +} + +func (g *GojaAnimeTorrentProvider) GetLatest() (ret []*hibiketorrent.AnimeTorrent, err error) { + defer util.HandlePanicInModuleWithError(g.ext.ID+".GetLatest", &err) + + method, err := g.callClassMethod(context.Background(), "getLatest") + if err != nil { + return nil, err + } + + promiseRes, err := g.waitForPromise(method) + if err != nil { + return nil, err + } + + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return nil, err + } + + return +} + +func (g *GojaAnimeTorrentProvider) GetSettings() (ret hibiketorrent.AnimeProviderSettings) { + defer util.HandlePanicInModuleThen(g.ext.ID+".GetSettings", func() { + ret = hibiketorrent.AnimeProviderSettings{} + }) + + res, err := g.callClassMethod(context.Background(), "getSettings") + if err != nil { + return + } + + err = g.unmarshalValue(res, &ret) + if err != nil { + return + } + + return +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_animepahe/animepahe.ts b/seanime-2.9.10/internal/extension_repo/goja_animepahe/animepahe.ts new file mode 100644 index 0000000..573555b --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_animepahe/animepahe.ts @@ -0,0 +1,258 @@ +/// +/// + +type EpisodeData = { + id: number; episode: number; title: string; snapshot: string; filler: number; session: string; created_at?: string +} + +type AnimeData = { + id: number; title: string; type: string; year: number; poster: string; session: string +} + +class Provider { + + api = "https://animepahe.ru" + headers = { Referer: "https://kwik.si" } + + getSettings(): Settings { + return { + episodeServers: ["kwik"], + supportsDub: false, + } + } + + async search(opts: SearchOptions): Promise { + const req = await fetch(`${this.api}/api?m=search&q=${encodeURIComponent(opts.query)}`, { + headers: { + Cookie: "__ddg1_=;__ddg2_=;", + }, + }) + + if (!req.ok) { + return [] + } + const data = (await req.json()) as { data: AnimeData[] } + const results: SearchResult[] = [] + + if (!data?.data) { + return [] + } + + data.data.map((item: AnimeData) => { + results.push({ + subOrDub: "sub", + id: item.session, + title: item.title, + url: "", + }) + }) + + return results + } + + async findEpisodes(id: string): Promise { + let episodes: EpisodeDetails[] = [] + + const req = + await fetch( + `${this.api}${id.includes("-") ? `/anime/${id}` : `/a/${id}`}`, + { + headers: { + Cookie: "__ddg1_=;__ddg2_=;", + }, + }, + ) + + const html = await req.text() + + + function pushData(data: EpisodeData[]) { + for (const item of data) { + episodes.push({ + id: item.session + "$" + id, + number: item.episode, + title: item.title && item.title.length > 0 ? item.title : "Episode " + item.episode, + url: req.url, + }) + } + } + + const $ = LoadDoc(html) + + const tempId = $("head > meta[property='og:url']").attr("content")!.split("/").pop()! + + const { last_page, data } = (await ( + await fetch(`${this.api}/api?m=release&id=${tempId}&sort=episode_asc&page=1`, { + headers: { + Cookie: "__ddg1_=;__ddg2_=;", + }, + }) + ).json()) as { + last_page: number; + data: EpisodeData[] + } + + pushData(data) + + const pageNumbers = Array.from({ length: last_page - 1 }, (_, i) => i + 2) + + const promises = pageNumbers.map((pageNumber) => + fetch(`${this.api}/api?m=release&id=${tempId}&sort=episode_asc&page=${pageNumber}`, { + headers: { + Cookie: "__ddg1_=;__ddg2_=;", + }, + }).then((res) => res.json()), + ) + const results = (await Promise.all(promises)) as { + data: EpisodeData[] + }[] + + results.forEach((showData) => { + for (const data of showData.data) { + if (data) { + pushData([data]) + } + } + }); + (data as any[]).sort((a, b) => a.number - b.number) + + if (episodes.length === 0) { + throw new Error("No episodes found.") + } + + + const lowest = episodes[0].number + if (lowest > 1) { + for (let i = 0; i < episodes.length; i++) { + episodes[i].number = episodes[i].number - lowest + 1 + } + } + + // Remove decimal episode numbers + episodes = episodes.filter((episode) => Number.isInteger(episode.number)) + + // for (let i = 0; i < episodes.length; i++) { + // // If an episode number is a decimal, round it up to the nearest whole number + // if (Number.isInteger(episodes[i].number)) { + // continue + // } + // const original = episodes[i].number + // episodes[i].number = Math.floor(episodes[i].number) + // episodes[i].title = `Episode ${episodes[i].number} [{${original}}]` + // } + + return episodes + } + + async findEpisodeServer(episode: EpisodeDetails, _server: string): Promise { + const episodeId = episode.id.split("$")[0] + const animeId = episode.id.split("$")[1] + + console.log(`${this.api}/play/${animeId}/${episodeId}`) + + const req = await fetch( + `${this.api}/play/${animeId}/${episodeId}`, + { + headers: { + Cookie: "__ddg1_=;__ddg2_=;", + }, + }, + ) + + const html = await req.text() + + const regex = /https:\/\/kwik\.si\/e\/\w+/g + const matches = html.match(regex) + + if (matches === null) { + throw new Error("Failed to fetch episode server.") + } + + const $ = LoadDoc(html) + + const result: EpisodeServer = { + videoSources: [], + headers: this.headers ?? {}, + server: "kwik", + } + + $("button[data-src]").each(async (_, el) => { + let videoSource: VideoSource = { + url: "", + type: "m3u8", + quality: "", + subtitles: [], + } + + videoSource.url = el.data("src")! + if (!videoSource.url) { + return + } + + const fansub = el.data("fansub")! + const quality = el.data("resolution")! + + videoSource.quality = `${quality}p - ${fansub}` + + if (el.data("audio") === "eng") { + videoSource.quality += " (Eng)" + } + + if (videoSource.url === matches[0]) { + videoSource.quality += " (default)" + } + + result.videoSources.push(videoSource) + }) + + const queries = result.videoSources.map(async (videoSource) => { + try { + const src_req = await fetch(videoSource.url, { + headers: { + Referer: this.headers.Referer, + "user-agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.56", + }, + }) + + const src_html = await src_req.text() + + const scripts = src_html.match(/eval\(f.+?\}\)\)/g) + if (!scripts) { + return + } + + for (const _script of scripts) { + const scriptMatch = _script.match(/eval(.+)/) + if (!scriptMatch || !scriptMatch[1]) { + continue + } + + try { + const decoded = eval(scriptMatch[1]) + const link = decoded.match(/source='(.+?)'/) + if (!link || !link[1]) { + continue + } + + videoSource.url = link[1] + + } + catch (e) { + console.error("Failed to extract kwik link", e) + } + + } + + } + catch (e) { + console.error("Failed to fetch kwik link", e) + } + }) + + await Promise.all(queries) + + return result + } +} + diff --git a/seanime-2.9.10/internal/extension_repo/goja_animepahe/tsconfig.json b/seanime-2.9.10/internal/extension_repo/goja_animepahe/tsconfig.json new file mode 100644 index 0000000..44bbd4f --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_animepahe/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "esnext", + "dom" + ], + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "downlevelIteration": true + } +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_base.go b/seanime-2.9.10/internal/extension_repo/goja_base.go new file mode 100644 index 0000000..7cc775e --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_base.go @@ -0,0 +1,201 @@ +package extension_repo + +import ( + "context" + "encoding/json" + "fmt" + "seanime/internal/extension" + "seanime/internal/goja/goja_runtime" + "time" + + "github.com/dop251/goja" + "github.com/dop251/goja/parser" + "github.com/rs/zerolog" +) + +type gojaProviderBase struct { + ext *extension.Extension + logger *zerolog.Logger + pool *goja_runtime.Pool + program *goja.Program + source string + runtimeManager *goja_runtime.Manager +} + +func initializeProviderBase(ext *extension.Extension, language extension.Language, logger *zerolog.Logger, runtimeManager *goja_runtime.Manager) (*gojaProviderBase, error) { + // initFn, pr, err := SetupGojaExtensionVM(ext, language, logger) + // if err != nil { + // return nil, err + // } + source := ext.Payload + if language == extension.LanguageTypescript { + var err error + source, err = JSVMTypescriptToJS(ext.Payload) + if err != nil { + logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to convert typescript") + return nil, err + } + } + + // Compile the program once, to be reused by all VMs + program, err := goja.Compile("", source, false) + if err != nil { + logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to compile program") + return nil, fmt.Errorf("compilation failed: %w", err) + } + + initFn := func() *goja.Runtime { + vm := goja.New() + vm.SetParserOptions(parser.WithDisableSourceMaps) + // Bind the shared bindings + ShareBinds(vm, logger) + BindUserConfig(vm, ext, logger) + return vm + } + + pool, err := runtimeManager.GetOrCreatePrivatePool(ext.ID, initFn) + if err != nil { + return nil, err + } + + return &gojaProviderBase{ + ext: ext, + logger: logger, + pool: pool, + program: program, + source: source, + runtimeManager: runtimeManager, + }, nil +} + +func (g *gojaProviderBase) GetExtension() *extension.Extension { + return g.ext +} + +func (g *gojaProviderBase) callClassMethod(ctx context.Context, methodName string, args ...interface{}) (goja.Value, error) { + if ctx == nil { + ctx = context.Background() + } + + vm, err := g.pool.Get(ctx) + if err != nil { + g.logger.Error().Err(err).Str("id", g.ext.ID).Msg("extension: Failed to get VM") + return nil, fmt.Errorf("failed to get VM: %w", err) + } + defer func() { + g.pool.Put(vm) + }() + + // Ensure the Provider class is defined only once per VM + providerType, err := vm.RunString("typeof Provider") + if err != nil { + g.logger.Error().Err(err).Str("id", g.ext.ID).Msg("extension: Failed to check Provider existence") + return nil, fmt.Errorf("failed to check Provider existence: %w", err) + } + if providerType.String() == "undefined" { + _, err = vm.RunProgram(g.program) + if err != nil { + g.logger.Error().Err(err).Str("id", g.ext.ID).Msg("extension: Failed to run program") + return nil, fmt.Errorf("failed to run program: %w", err) + } + } + + // Create a new instance of the Provider class + providerInstance, err := vm.RunString("new Provider()") + if err != nil { + g.logger.Error().Err(err).Str("id", g.ext.ID).Msg("extension: Failed to create Provider instance") + return nil, fmt.Errorf("failed to create Provider instance: %w", err) + } + + if providerInstance == nil { + g.logger.Error().Str("id", g.ext.ID).Msg("extension: Provider constructor returned nil") + return nil, fmt.Errorf("provider constructor returned nil") + } + + // Get the method from the instance + method, ok := goja.AssertFunction(providerInstance.ToObject(vm).Get(methodName)) + if !ok { + g.logger.Error().Str("id", g.ext.ID).Str("method", methodName).Msg("extension: Method not found or not a function") + return nil, fmt.Errorf("method %s not found or not a function", methodName) + } + + // Convert arguments to Goja values + gojaArgs := make([]goja.Value, len(args)) + for i, arg := range args { + gojaArgs[i] = vm.ToValue(arg) + } + + // Call the method + result, err := method(providerInstance, gojaArgs...) + if err != nil { + g.logger.Error().Err(err).Str("id", g.ext.ID).Str("method", methodName).Msg("extension: Method execution failed") + return nil, fmt.Errorf("method %s execution failed: %w", methodName, err) + } + + // g.runtimeManager.PrintBasePoolMetrics() + + return result, nil +} + +// unmarshalValue unmarshals a Goja value to a target interface +// This is used to convert the result of a method call to a struct +func (g *gojaProviderBase) unmarshalValue(value goja.Value, target interface{}) error { + if value == nil { + return fmt.Errorf("cannot unmarshal nil value") + } + + exported := value.Export() + if exported == nil { + return fmt.Errorf("exported value is nil") + } + + data, err := json.Marshal(exported) + if err != nil { + return fmt.Errorf("failed to marshal value: %w", err) + } + return json.Unmarshal(data, target) +} + +// waitForPromise waits for a promise to resolve and returns the result +func (g *gojaProviderBase) waitForPromise(value goja.Value) (goja.Value, error) { + if value == nil { + return nil, fmt.Errorf("cannot wait for nil promise") + } + + // If the value is a promise, wait for it to resolve + if promise, ok := value.Export().(*goja.Promise); ok { + doneCh := make(chan struct{}) + + // Wait for the promise to resolve + go func() { + for promise.State() == goja.PromiseStatePending { + time.Sleep(10 * time.Millisecond) + } + close(doneCh) + }() + + <-doneCh + + // If the promise is rejected, return the error + if promise.State() == goja.PromiseStateRejected { + err := promise.Result() + return nil, fmt.Errorf("promise rejected: %v", err) + } + + // If the promise is fulfilled, return the result + res := promise.Result() + + return res, nil + } + + // If the value is not a promise, return it as is + return value, nil +} + +func (g *gojaProviderBase) PutVM(vm *goja.Runtime) { + g.pool.Put(vm) +} + +func (g *gojaProviderBase) ClearInterrupt() { + // no-op +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_benchmark_test.go b/seanime-2.9.10/internal/extension_repo/goja_benchmark_test.go new file mode 100644 index 0000000..c3559df --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_benchmark_test.go @@ -0,0 +1,400 @@ +package extension_repo + +import ( + "seanime/internal/api/anilist" + "seanime/internal/hook" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/util" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewGojaPlugin(t *testing.T) { + payload := ` + function init() { + + $app.onGetAnime((e) => { + + if(e.anime.id === 178022) { + e.anime.id = 21; + e.anime.idMal = 21; + $replace(e.anime.id, 22) + $replace(e.anime.title, { "english": "The One Piece is Real" }) + // e.anime.title = { "english": "The One Piece is Real" } + // $replace(e.anime.synonyms, ["The One Piece is Real"]) + e.anime.synonyms = ["The One Piece is Real"] + // e.anime.synonyms[0] = "The One Piece is Real" + // $replace(e.anime.synonyms[0], "The One Piece is Real") + } + + e.next(); + }); + + $app.onGetAnime((e) => { + console.log("$app.onGetAnime(2) fired") + console.log(e.anime.id) + console.log(e.anime.idMal) + console.log(e.anime.synonyms[0]) + console.log(e.anime.title) + }); + } + ` + + opts := DefaultTestPluginOptions() + opts.Payload = payload + + _, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + m, err := anilistPlatform.GetAnime(t.Context(), 178022) + if err != nil { + t.Fatalf("GetAnime returned error: %v", err) + } + + util.Spew(m.Title) + util.Spew(m.Synonyms) + + // m, err = anilistPlatform.GetAnime(177709) + // if err != nil { + // t.Fatalf("GetAnime returned error: %v", err) + // } + + // util.Spew(m.Title) + + manager.PrintPluginPoolMetrics(opts.ID) +} + +func BenchmarkAllHooks(b *testing.B) { + b.Run("BaselineNoHook", BenchmarkBaselineNoHook) + b.Run("HookInvocation", BenchmarkHookInvocation) + b.Run("HookInvocationParallel", BenchmarkHookInvocationParallel) + b.Run("HookInvocationWithWork", BenchmarkHookInvocationWithWork) + b.Run("HookInvocationWithWorkParallel", BenchmarkHookInvocationWithWorkParallel) + b.Run("NoHookInvocation", BenchmarkNoHookInvocation) + b.Run("NoHookInvocationParallel", BenchmarkNoHookInvocationParallel) + b.Run("NoHookInvocationWithWork", BenchmarkNoHookInvocationWithWork) +} + +func BenchmarkHookInvocation(b *testing.B) { + b.ReportAllocs() + + // Dummy extension payload that registers a hook + payload := ` + function init() { + $app.onGetAnime(function(e) { + e.next(); + }); + } + ` + + opts := DefaultTestPluginOptions() + opts.ID = "dummy-hook-benchmark" + opts.Payload = payload + opts.SetupHooks = true + + _, _, runtimeManager, _, _, err := InitTestPlugin(b, opts) + require.NoError(b, err) + + // Create a dummy anime event that we'll reuse + title := "Test Anime" + dummyEvent := &anilist_platform.GetAnimeEvent{ + Anime: &anilist.BaseAnime{ + ID: 1234, + Title: &anilist.BaseAnime_Title{ + English: &title, + }, + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := hook.GlobalHookManager.OnGetAnime().Trigger(dummyEvent); err != nil { + b.Fatal(err) + } + } + + runtimeManager.PrintPluginPoolMetrics(opts.ID) +} + +func BenchmarkNoHookInvocation(b *testing.B) { + b.ReportAllocs() + + // Dummy extension payload that registers a hook + payload := ` + function init() { + $app.onMissingEpisodes(function(e) { + e.next(); + }); + } + ` + + opts := DefaultTestPluginOptions() + opts.ID = "dummy-hook-benchmark" + opts.Payload = payload + opts.SetupHooks = true + + _, _, runtimeManager, _, _, err := InitTestPlugin(b, opts) + require.NoError(b, err) + + // Create a dummy anime event that we'll reuse + title := "Test Anime" + dummyEvent := &anilist_platform.GetAnimeEvent{ + Anime: &anilist.BaseAnime{ + ID: 1234, + Title: &anilist.BaseAnime_Title{ + English: &title, + }, + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := hook.GlobalHookManager.OnGetAnime().Trigger(dummyEvent); err != nil { + b.Fatal(err) + } + } + + runtimeManager.PrintPluginPoolMetrics(opts.ID) +} + +// Add a parallel version to see how it performs under concurrent load +func BenchmarkHookInvocationParallel(b *testing.B) { + b.ReportAllocs() + + payload := ` + function init() { + $app.onGetAnime(function(e) { + e.next(); + }); + } + ` + + opts := DefaultTestPluginOptions() + opts.ID = "dummy-hook-benchmark" + opts.Payload = payload + opts.SetupHooks = true + + _, _, runtimeManager, _, _, err := InitTestPlugin(b, opts) + require.NoError(b, err) + + title := "Test Anime" + event := &anilist_platform.GetAnimeEvent{ + Anime: &anilist.BaseAnime{ + ID: 1234, + Title: &anilist.BaseAnime_Title{ + English: &title, + }, + }, + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if err := hook.GlobalHookManager.OnGetAnime().Trigger(event); err != nil { + b.Fatal(err) + } + } + }) + + runtimeManager.PrintPluginPoolMetrics(opts.ID) +} + +func BenchmarkNoHookInvocationParallel(b *testing.B) { + b.ReportAllocs() + + payload := ` + function init() { + $app.onMissingEpisodes(function(e) { + e.next(); + }); + } + ` + + opts := DefaultTestPluginOptions() + opts.ID = "dummy-hook-benchmark" + opts.Payload = payload + opts.SetupHooks = true + + _, _, runtimeManager, _, _, err := InitTestPlugin(b, opts) + require.NoError(b, err) + + title := "Test Anime" + event := &anilist_platform.GetAnimeEvent{ + Anime: &anilist.BaseAnime{ + ID: 1234, + Title: &anilist.BaseAnime_Title{ + English: &title, + }, + }, + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if err := hook.GlobalHookManager.OnGetAnime().Trigger(event); err != nil { + b.Fatal(err) + } + } + }) + + runtimeManager.PrintPluginPoolMetrics(opts.ID) +} + +// BenchmarkBaselineNoHook measures the baseline performance without any hooks +func BenchmarkBaselineNoHook(b *testing.B) { + b.ReportAllocs() + title := "Test Anime" + dummyEvent := &anilist_platform.GetAnimeEvent{ + Anime: &anilist.BaseAnime{ + ID: 1234, + Title: &anilist.BaseAnime_Title{ + English: &title, + }, + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = dummyEvent.Next() + } +} + +// BenchmarkHookInvocationWithWork measures performance with a hook that does some actual work +func BenchmarkHookInvocationWithWork(b *testing.B) { + b.ReportAllocs() + + payload := ` + function init() { + $app.onGetAnime(function(e) { + // Do some work + if (e.anime.id === 1234) { + e.anime.id = 5678; + e.anime.title.english = "Modified Title"; + e.anime.idMal = 9012; + } + e.next(); + }); + } + ` + + opts := DefaultTestPluginOptions() + opts.ID = "dummy-hook-benchmark" + opts.Payload = payload + opts.SetupHooks = true + + _, _, runtimeManager, _, _, err := InitTestPlugin(b, opts) + require.NoError(b, err) + + title := "Test Anime" + dummyEvent := &anilist_platform.GetAnimeEvent{ + Anime: &anilist.BaseAnime{ + ID: 1234, + Title: &anilist.BaseAnime_Title{ + English: &title, + }, + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := hook.GlobalHookManager.OnGetAnime().Trigger(dummyEvent); err != nil { + b.Fatal(err) + } + } + + runtimeManager.PrintPluginPoolMetrics(opts.ID) +} + +// BenchmarkHookParallel measures parallel performance with a hook that does some work +func BenchmarkHookInvocationWithWorkParallel(b *testing.B) { + b.ReportAllocs() + + payload := ` + function init() { + $app.onGetAnime(function(e) { + // Do some work + if (e.anime.id === 1234) { + e.anime.id = 5678; + e.anime.title.english = "Modified Title"; + e.anime.idMal = 9012; + } + e.next(); + }); + } + ` + + opts := DefaultTestPluginOptions() + opts.ID = "dummy-hook-benchmark" + opts.Payload = payload + opts.SetupHooks = true + + _, _, runtimeManager, _, _, err := InitTestPlugin(b, opts) + require.NoError(b, err) + + title := "Test Anime" + dummyEvent := &anilist_platform.GetAnimeEvent{ + Anime: &anilist.BaseAnime{ + ID: 1234, + Title: &anilist.BaseAnime_Title{ + English: &title, + }, + }, + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if err := hook.GlobalHookManager.OnGetAnime().Trigger(dummyEvent); err != nil { + b.Fatal(err) + } + } + }) + + runtimeManager.PrintPluginPoolMetrics(opts.ID) +} + +func BenchmarkNoHookInvocationWithWork(b *testing.B) { + b.ReportAllocs() + + payload := ` + function init() { + $app.onMissingEpisodes(function(e) { + // Do some work + if (e.anime.id === 1234) { + e.anime.id = 5678; + e.anime.title.english = "Modified Title"; + e.anime.idMal = 9012; + } + e.next(); + }); + } + ` + + opts := DefaultTestPluginOptions() + opts.ID = "dummy-hook-benchmark" + opts.Payload = payload + opts.SetupHooks = true + + _, _, runtimeManager, _, _, err := InitTestPlugin(b, opts) + require.NoError(b, err) + + title := "Test Anime" + dummyEvent := &anilist_platform.GetAnimeEvent{ + Anime: &anilist.BaseAnime{ + ID: 1234, + Title: &anilist.BaseAnime_Title{ + English: &title, + }, + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := hook.GlobalHookManager.OnGetAnime().Trigger(dummyEvent); err != nil { + b.Fatal(err) + } + } + + runtimeManager.PrintPluginPoolMetrics(opts.ID) +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_extension_test.go b/seanime-2.9.10/internal/extension_repo/goja_extension_test.go new file mode 100644 index 0000000..2a1a4ce --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_extension_test.go @@ -0,0 +1,162 @@ +package extension_repo_test + +import ( + "os" + "seanime/internal/extension" + hibikemanga "seanime/internal/extension/hibike/manga" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" + "seanime/internal/extension_repo" + "seanime/internal/goja/goja_runtime" + "seanime/internal/util" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/require" +) + +func TestGojaWithExtension(t *testing.T) { + runtimeManager := goja_runtime.NewManager(util.NewLogger()) + // Get the script + filepath := "./goja_manga_test/my-manga-provider.ts" + fileB, err := os.ReadFile(filepath) + if err != nil { + t.Fatal(err) + } + + ext := &extension.Extension{ + ID: "my-manga-provider", + Name: "MyMangaProvider", + Version: "0.1.0", + ManifestURI: "", + Language: extension.LanguageTypescript, + Type: extension.TypeMangaProvider, + Description: "", + Author: "", + Payload: string(fileB), + } + + // Create the provider + provider, _, err := extension_repo.NewGojaMangaProvider(ext, ext.Language, util.NewLogger(), runtimeManager) + require.NoError(t, err) + + // Test the search function + searchResult, err := provider.Search(hibikemanga.SearchOptions{Query: "dandadan"}) + require.NoError(t, err) + + spew.Dump(searchResult) + + // Should have a result with rating of 1 + var dandadanRes *hibikemanga.SearchResult + for _, res := range searchResult { + if res.SearchRating == 1 { + dandadanRes = res + break + } + } + require.NotNil(t, dandadanRes) + spew.Dump(dandadanRes) + + // Test the search function again + searchResult, err = provider.Search(hibikemanga.SearchOptions{Query: "boku no kokoro no yaibai"}) + require.NoError(t, err) + require.GreaterOrEqual(t, len(searchResult), 1) + + t.Logf("Search results: %d", len(searchResult)) + + // Test the findChapters function + chapters, err := provider.FindChapters("pYN47sZm") // Boku no Kokoro no Yabai Yatsu + require.NoError(t, err) + require.GreaterOrEqual(t, len(chapters), 100) + + t.Logf("Chapters: %d", len(chapters)) + + // Test the findChapterPages function + pages, err := provider.FindChapterPages("WLxnx") // Boku no Kokoro no Yabai Yatsu - Chapter 1 + require.NoError(t, err) + require.GreaterOrEqual(t, len(pages), 10) + + for _, page := range pages { + t.Logf("Page: %s, Index: %d\n", page.URL, page.Index) + } +} + +func TestGojaOnlinestreamExtension(t *testing.T) { + runtimeManager := goja_runtime.NewManager(util.NewLogger()) + // Get the script + filepath := "./goja_animepahe/animepahe.ts" + fileB, err := os.ReadFile(filepath) + if err != nil { + t.Fatal(err) + } + + ext := &extension.Extension{ + ID: "animepahe", + Name: "Animepahe", + Version: "0.1.0", + ManifestURI: "", + Language: extension.LanguageTypescript, + Type: extension.TypeOnlinestreamProvider, + Description: "", + Author: "", + Payload: string(fileB), + } + + // Create the provider + provider, _, err := extension_repo.NewGojaOnlinestreamProvider(ext, ext.Language, util.NewLogger(), runtimeManager) + require.NoError(t, err) + + // Test the search function + searchResult, err := provider.Search(hibikeonlinestream.SearchOptions{Query: "dandadan"}) + require.NoError(t, err) + + spew.Dump(searchResult) + + // Should have a result with rating of 1 + var dandadanRes *hibikeonlinestream.SearchResult + dandadanRes = searchResult[0] + require.NotNil(t, dandadanRes) + + // Test find episodes + episodes, err := provider.FindEpisodes(dandadanRes.ID) + require.NoError(t, err) + + util.Spew(episodes) + +} + +func TestGojaOnlinestreamExtension2(t *testing.T) { + runtimeManager := goja_runtime.NewManager(util.NewLogger()) + // Get the script + filepath := "./goja_animepahe/animepahe.ts" + fileB, err := os.ReadFile(filepath) + if err != nil { + t.Fatal(err) + } + + ext := &extension.Extension{ + ID: "animepahe", + Name: "Animepahe", + Version: "0.1.0", + ManifestURI: "", + Language: extension.LanguageTypescript, + Type: extension.TypeOnlinestreamProvider, + Description: "", + Author: "", + Payload: string(fileB), + } + + // Create the provider + provider, _, err := extension_repo.NewGojaOnlinestreamProvider(ext, ext.Language, util.NewLogger(), runtimeManager) + require.NoError(t, err) + // Find first episode server + server, err := provider.FindEpisodeServer(&hibikeonlinestream.EpisodeDetails{ + Provider: "animepahe", + ID: "0ba8e30b98b1be6d19c8ac73ae11372911e62424ef454f05052ef6af8f01f13b$269b021d-a893-4471-04e7-b8933d81bda1", + Number: 1, + URL: "", + Title: "", + }, "kwik") + require.NoError(t, err) + + spew.Dump(server) +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_manga_provider.go b/seanime-2.9.10/internal/extension_repo/goja_manga_provider.go new file mode 100644 index 0000000..75d28e6 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_manga_provider.go @@ -0,0 +1,130 @@ +package extension_repo + +import ( + "context" + "seanime/internal/extension" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/goja/goja_runtime" + "seanime/internal/util" + "seanime/internal/util/comparison" + + "github.com/rs/zerolog" +) + +type GojaMangaProvider struct { + *gojaProviderBase +} + +func NewGojaMangaProvider(ext *extension.Extension, language extension.Language, logger *zerolog.Logger, runtimeManager *goja_runtime.Manager) (hibikemanga.Provider, *GojaMangaProvider, error) { + base, err := initializeProviderBase(ext, language, logger, runtimeManager) + if err != nil { + return nil, nil, err + } + + provider := &GojaMangaProvider{ + gojaProviderBase: base, + } + return provider, provider, nil +} + +func (g *GojaMangaProvider) GetSettings() (ret hibikemanga.Settings) { + defer util.HandlePanicInModuleThen(g.ext.ID+".GetSettings", func() { + ret = hibikemanga.Settings{} + }) + + method, err := g.callClassMethod(context.Background(), "getSettings") + if err != nil { + return + } + + err = g.unmarshalValue(method, &ret) + if err != nil { + return + } + + return +} + +func (g *GojaMangaProvider) Search(opts hibikemanga.SearchOptions) (ret []*hibikemanga.SearchResult, err error) { + defer util.HandlePanicInModuleWithError(g.ext.ID+".Search", &err) + + method, err := g.callClassMethod(context.Background(), "search", structToMap(opts)) + + promiseRes, err := g.waitForPromise(method) + if err != nil { + return nil, err + } + + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return nil, err + } + + // Set the provider & search rating + for i := range ret { + ret[i].Provider = g.ext.ID + + synonyms := ret[i].Synonyms + if synonyms == nil { + continue + } + + compTitles := []*string{&ret[i].Title} + for _, syn := range synonyms { + compTitles = append(compTitles, &syn) + } + + compRes, ok := comparison.FindBestMatchWithSorensenDice(&opts.Query, compTitles) + if ok { + ret[i].SearchRating = compRes.Rating + } + } + + return ret, nil +} + +func (g *GojaMangaProvider) FindChapters(id string) (ret []*hibikemanga.ChapterDetails, err error) { + defer util.HandlePanicInModuleWithError(g.ext.ID+".FindChapters", &err) + + method, err := g.callClassMethod(context.Background(), "findChapters", id) + + promiseRes, err := g.waitForPromise(method) + if err != nil { + return nil, err + } + + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return nil, err + } + + // Set the provider + for i := range ret { + ret[i].Provider = g.ext.ID + } + + return ret, nil +} + +func (g *GojaMangaProvider) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) { + defer util.HandlePanicInModuleWithError(g.ext.ID+".FindChapterPages", &err) + + method, err := g.callClassMethod(context.Background(), "findChapterPages", id) + + promiseRes, err := g.waitForPromise(method) + if err != nil { + return nil, err + } + + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return nil, err + } + + // Set the provider + for i := range ret { + ret[i].Provider = g.ext.ID + } + + return ret, nil +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_manga_test/manga-provider.d.ts b/seanime-2.9.10/internal/extension_repo/goja_manga_test/manga-provider.d.ts new file mode 100644 index 0000000..a6a681d --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_manga_test/manga-provider.d.ts @@ -0,0 +1,43 @@ +declare type SearchResult = { + id: string + title: string + synonyms?: string[] + year?: number + image?: string +} + +declare type ChapterDetails = { + id: string + url: string + title: string + chapter: string + index: number + scanlator?: string + language?: string + rating?: number + updatedAt?: string +} + +declare type ChapterPage = { + url: string + index: number + headers: { [key: string]: string } +} + +declare type QueryOptions = { + query: string + year?: number +} + +declare type Settings = { + supportsMultiLanguage?: boolean + supportsMultiScanlator?: boolean +} + +declare abstract class MangaProvider { + search(opts: QueryOptions): Promise + findChapters(id: string): Promise + findChapterPages(id: string): Promise + + getSettings(): Settings +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_manga_test/my-manga-provider.ts b/seanime-2.9.10/internal/extension_repo/goja_manga_test/my-manga-provider.ts new file mode 100644 index 0000000..92df976 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_manga_test/my-manga-provider.ts @@ -0,0 +1,222 @@ +/// + +class Provider { + + private api = "https://api.comick.fun" + + getSettings(): Settings { + return { + supportsMultiLanguage: true, + supportsMultiScanlator: false, + } + } + + async search(opts: QueryOptions): Promise { + console.log(this.api, opts.query) + + const requestRes = await fetch(`${this.api}/v1.0/search?q=${encodeURIComponent(opts.query)}&limit=25&page=1`, { + method: "get", + }) + const comickRes = await requestRes.json() as ComickSearchResult[] + + const ret: SearchResult[] = [] + + for (const res of comickRes) { + + let cover: any = res.md_covers ? res.md_covers[0] : null + if (cover && cover.b2key != undefined) { + cover = "https://meo.comick.pictures/" + cover.b2key + } + + ret.push({ + id: res.hid, + title: res.title ?? res.slug, + synonyms: res.md_titles?.map(t => t.title) ?? {}, + year: res.year ?? 0, + image: cover, + }) + } + + console.log(ret[0]) + + console.error("test", ret[0].id) + + return ret + } + + async findChapters(id: string): Promise { + + console.log("Fetching chapters", id) + + const chapterList: ChapterDetails[] = [] + + const data = (await (await fetch(`${this.api}/comic/${id}/chapters?lang=en&page=0&limit=1000000`))?.json()) as { chapters: ComickChapter[] } + + const chapters: ChapterDetails[] = [] + + for (const chapter of data.chapters) { + + if (!chapter.chap) { + continue + } + + let title = "Chapter " + this.padNum(chapter.chap, 2) + " " + + if (title.length === 0) { + if (!chapter.title) { + title = "Oneshot" + } else { + title = chapter.title + } + } + + let canPush = true + for (let i = 0; i < chapters.length; i++) { + if (chapters[i].title?.trim() === title?.trim()) { + canPush = false + } + } + + if (canPush) { + if (chapter.lang === "en") { + chapters.push({ + url: `${this.api}/comic/${id}/chapter/${chapter.hid}`, + index: 0, + id: chapter.hid, + title: title?.trim(), + chapter: chapter.chap, + rating: chapter.up_count - chapter.down_count, + updatedAt: chapter.updated_at, + }) + } + } + } + + chapters.reverse() + + for (let i = 0; i < chapters.length; i++) { + chapters[i].index = i + } + + console.log(chapters.map(c => c.chapter)) + + return chapters + } + + async findChapterPages(id: string): Promise { + + const data = (await (await fetch(`${this.api}/chapter/${id}`))?.json()) as { + chapter: { md_images: { vol: any; w: number; h: number; b2key: string }[] } + } + + const pages: ChapterPage[] = [] + + data.chapter.md_images.map((image, index: number) => { + pages.push({ + url: `https://meo.comick.pictures/${image.b2key}?width=${image.w}`, + index: index, + headers: {}, + }) + }) + + return pages + } + + padNum(number: string, places: number): string { + let range = number.split("-") + range = range.map((chapter) => { + chapter = chapter.trim() + const digits = chapter.split(".")[0].length + return "0".repeat(Math.max(0, places - digits)) + chapter + }) + return range.join("-") + } + +} + +interface ComickSearchResult { + title: string; + id: number; + hid: string; + slug: string; + year?: number; + rating: string; + rating_count: number; + follow_count: number; + user_follow_count: number; + content_rating: string; + created_at: string; + demographic: number; + md_titles: { title: string }[]; + md_covers: { vol: any; w: number; h: number; b2key: string }[]; + highlight: string; +} + +interface Comic { + id: number; + hid: string; + title: string; + country: string; + status: number; + links: { + al: string; + ap: string; + bw: string; + kt: string; + mu: string; + amz: string; + cdj: string; + ebj: string; + mal: string; + raw: string; + }; + last_chapter: any; + chapter_count: number; + demographic: number; + hentai: boolean; + user_follow_count: number; + follow_rank: number; + comment_count: number; + follow_count: number; + desc: string; + parsed: string; + slug: string; + mismatch: any; + year: number; + bayesian_rating: any; + rating_count: number; + content_rating: string; + translation_completed: boolean; + relate_from: Array; + mies: any; + md_titles: { title: string }[]; + md_comic_md_genres: { md_genres: { name: string; type: string | null; slug: string; group: string } }[]; + mu_comics: { + licensed_in_english: any; + mu_comic_categories: { + mu_categories: { title: string; slug: string }; + positive_vote: number; + negative_vote: number; + }[]; + }; + md_covers: { vol: any; w: number; h: number; b2key: string }[]; + iso639_1: string; + lang_name: string; + lang_native: string; +} + +interface ComickChapter { + id: number; + chap: string; + title: string; + vol: string | null; + lang: string; + created_at: string; + updated_at: string; + up_count: number; + down_count: number; + group_name: any; + hid: string; + identities: any; + md_chapter_groups: { md_groups: { title: string; slug: string } }[]; +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_manga_test/tsconfig.json b/seanime-2.9.10/internal/extension_repo/goja_manga_test/tsconfig.json new file mode 100644 index 0000000..babea55 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_manga_test/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "es2015", + "dom" + ], + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_onlinestream_provider.go b/seanime-2.9.10/internal/extension_repo/goja_onlinestream_provider.go new file mode 100644 index 0000000..2f766b4 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_onlinestream_provider.go @@ -0,0 +1,128 @@ +package extension_repo + +import ( + "context" + "fmt" + "seanime/internal/extension" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" + "seanime/internal/goja/goja_runtime" + "seanime/internal/util" + + "github.com/rs/zerolog" +) + +type GojaOnlinestreamProvider struct { + *gojaProviderBase +} + +func NewGojaOnlinestreamProvider(ext *extension.Extension, language extension.Language, logger *zerolog.Logger, runtimeManager *goja_runtime.Manager) (hibikeonlinestream.Provider, *GojaOnlinestreamProvider, error) { + base, err := initializeProviderBase(ext, language, logger, runtimeManager) + if err != nil { + return nil, nil, err + } + + provider := &GojaOnlinestreamProvider{ + gojaProviderBase: base, + } + return provider, provider, nil +} + +func (g *GojaOnlinestreamProvider) GetEpisodeServers() (ret []string) { + ret = make([]string, 0) + + method, err := g.callClassMethod(context.Background(), "getEpisodeServers") + + promiseRes, err := g.waitForPromise(method) + if err != nil { + return + } + + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return + } + + return +} + +func (g *GojaOnlinestreamProvider) Search(opts hibikeonlinestream.SearchOptions) (ret []*hibikeonlinestream.SearchResult, err error) { + defer util.HandlePanicInModuleWithError(g.ext.ID+".Search", &err) + + method, err := g.callClassMethod(context.Background(), "search", structToMap(opts)) + if err != nil { + return nil, fmt.Errorf("failed to call search method: %w", err) + } + + promiseRes, err := g.waitForPromise(method) + if err != nil { + return nil, fmt.Errorf("failed to wait for promise: %w", err) + } + + ret = make([]*hibikeonlinestream.SearchResult, 0) + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal search results: %w", err) + } + + return ret, nil +} + +func (g *GojaOnlinestreamProvider) FindEpisodes(id string) (ret []*hibikeonlinestream.EpisodeDetails, err error) { + defer util.HandlePanicInModuleWithError(g.ext.ID+".FindEpisodes", &err) + + method, err := g.callClassMethod(context.Background(), "findEpisodes", id) + + promiseRes, err := g.waitForPromise(method) + if err != nil { + return nil, err + } + + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return nil, err + } + + for _, episode := range ret { + episode.Provider = g.ext.ID + } + + return +} + +func (g *GojaOnlinestreamProvider) FindEpisodeServer(episode *hibikeonlinestream.EpisodeDetails, server string) (ret *hibikeonlinestream.EpisodeServer, err error) { + defer util.HandlePanicInModuleWithError(g.ext.ID+".FindEpisodeServer", &err) + + method, err := g.callClassMethod(context.Background(), "findEpisodeServer", structToMap(episode), server) + + promiseRes, err := g.waitForPromise(method) + if err != nil { + return nil, err + } + + err = g.unmarshalValue(promiseRes, &ret) + if err != nil { + return nil, err + } + + ret.Provider = g.ext.ID + + return +} + +func (g *GojaOnlinestreamProvider) GetSettings() (ret hibikeonlinestream.Settings) { + defer util.HandlePanicInModuleThen(g.ext.ID+".GetSettings", func() { + ret = hibikeonlinestream.Settings{} + }) + + method, err := g.callClassMethod(context.Background(), "getSettings") + if err != nil { + return + } + + err = g.unmarshalValue(method, &ret) + if err != nil { + return + } + + return +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_onlinestream_test/my-onlinestream-provider.ts b/seanime-2.9.10/internal/extension_repo/goja_onlinestream_test/my-onlinestream-provider.ts new file mode 100644 index 0000000..fcddd88 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_onlinestream_test/my-onlinestream-provider.ts @@ -0,0 +1,250 @@ +/// +/// + +class ProviderN { + + api = "https://anitaku.to" + ajaxURL = "https://ajax.gogocdn.net" + + getSettings(): Settings { + return { + episodeServers: ["gogocdn", "vidstreaming", "streamsb"], + supportsDub: true, + } + } + + async search(opts: SearchOptions): Promise { + const request = await fetch(`${this.api}/search.html?keyword=${encodeURIComponent(opts.query)}`) + if (!request.ok) { + return [] + } + const data = await request.text() + const results: SearchResult[] = [] + + const $ = LoadDoc(data) + + $("ul.items > li").each((_, el) => { + const title = el.find("p.name a").text().trim() + const id = el.find("div.img a").attr("href") + if (!id) { + return + } + + results.push({ + id: id, + title: title, + url: id, + subOrDub: "sub", + }) + }) + + return results + } + + async findEpisodes(id: string): Promise { + const episodes: EpisodeDetails[] = [] + + const data = await (await fetch(`${this.api}${id}`)).text() + + const $ = LoadDoc(data) + + const epStart = $("#episode_page > li").first().find("a").attr("ep_start") + const epEnd = $("#episode_page > li").last().find("a").attr("ep_end") + const movieId = $("#movie_id").attr("value") + const alias = $("#alias_anime").attr("value") + + const req = await (await fetch(`${this.ajaxURL}/ajax/load-list-episode?ep_start=${epStart}&ep_end=${epEnd}&id=${movieId}&default_ep=${0}&alias=${alias}`)).text() + + const $$ = LoadDoc(req) + + $$("#episode_related > li").each((i, el) => { + episodes?.push({ + id: el.find("a").attr("href")?.trim() ?? "", + url: el.find("a").attr("href")?.trim() ?? "", + number: parseFloat(el.find(`div.name`).text().replace("EP ", "")), + title: el.find(`div.name`).text(), + }) + }) + + return episodes.reverse() + } + + async findEpisodeServer(episode: EpisodeDetails, _server: string): Promise { + let server = "gogocdn" + if (_server !== "default") { + server = _server + } + + const episodeServer: EpisodeServer = { + server: server, + headers: {}, + videoSources: [], + } + + if (episode.id.startsWith("http")) { + const serverURL = episode.id + try { + const es = await new Extractor(serverURL, episodeServer).extract(server) + if (es) { + return es + } + } + catch (e) { + console.error(e) + return episodeServer + } + return episodeServer + } + + const data = await (await fetch(`${this.api}${episode.id}`)).text() + + const $ = LoadDoc(data) + + let serverURL: string + + switch (server) { + case "gogocdn": + serverURL = `${$("#load_anime > div > div > iframe").attr("src")}` + break + case "vidstreaming": + serverURL = `${$("div.anime_video_body > div.anime_muti_link > ul > li.vidcdn > a").attr("data-video")}` + break + case "streamsb": + serverURL = $("div.anime_video_body > div.anime_muti_link > ul > li.streamsb > a").attr("data-video")! + break + default: + serverURL = `${$("#load_anime > div > div > iframe").attr("src")}` + break + } + + episode.id = serverURL + return await this.findEpisodeServer(episode, server) + } + +} + + +class Extractor { + private url: string + private result: EpisodeServer + + constructor(url: string, result: EpisodeServer) { + this.url = url + this.result = result + } + + async extract(server: string): Promise { + try { + switch (server) { + case "gogocdn": + console.log("GogoCDN extraction") + return await this.extractGogoCDN(this.url, this.result) + case "vidstreaming": + return await this.extractGogoCDN(this.url, this.result) + default: + return undefined + } + } + catch (e) { + console.error(e) + return undefined + } + } + + + public async extractGogoCDN(url: string, result: EpisodeServer): Promise { + const keys = { + key: CryptoJS.enc.Utf8.parse("37911490979715163134003223491201"), + secondKey: CryptoJS.enc.Utf8.parse("54674138327930866480207815084989"), + iv: CryptoJS.enc.Utf8.parse("3134003223491201"), + } + + function generateEncryptedAjaxParams(id: string) { + const encryptedKey = CryptoJS.AES.encrypt(id, keys.key, { + iv: keys.iv, + }) + + const scriptValue = $("script[data-name='episode']").data("value")! + + const decryptedToken = CryptoJS.AES.decrypt(scriptValue, keys.key, { + iv: keys.iv, + }).toString(CryptoJS.enc.Utf8) + + return `id=${encryptedKey.toString(CryptoJS.enc.Base64)}&alias=${id}&${decryptedToken}` + } + + function decryptAjaxData(encryptedData: string) { + + const decryptedData = CryptoJS.AES.decrypt(encryptedData, keys.secondKey, { + iv: keys.iv, + }).toString(CryptoJS.enc.Utf8) + + return JSON.parse(decryptedData) + } + + const req = await fetch(url) + + const $ = LoadDoc(await req.text()) + + const encryptedParams = generateEncryptedAjaxParams(new URL(url).searchParams.get("id") ?? "") + + const xmlHttpUrl = `${new URL(url).protocol}//${new URL(url).hostname}/encrypt-ajax.php?${encryptedParams}` + + const encryptedData = await fetch(xmlHttpUrl, { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }) + + + const decryptedData = await decryptAjaxData(((await encryptedData.json()) as { data: any })?.data) + if (!decryptedData.source) throw new Error("No source found. Try a different server.") + + if (decryptedData.source[0].file.includes(".m3u8")) { + const resResult = await fetch(decryptedData.source[0].file.toString()) + const resolutions = (await resResult.text()).match(/(RESOLUTION=)(.*)(\s*?)(\s*.*)/g) + + resolutions?.forEach((res: string) => { + const index = decryptedData.source[0].file.lastIndexOf("/") + const quality = res.split("\n")[0].split("x")[1].split(",")[0] + const url = decryptedData.source[0].file.slice(0, index) + + result.videoSources.push({ + url: url + "/" + res.split("\n")[1], + quality: quality + "p", + subtitles: [], + type: "m3u8", + }) + }) + + decryptedData.source.forEach((source: any) => { + result.videoSources.push({ + url: source.file, + quality: "default", + subtitles: [], + type: "m3u8", + }) + }) + } else { + decryptedData.source.forEach((source: any) => { + result.videoSources.push({ + url: source.file, + quality: source.label.split(" ")[0] + "p", + subtitles: [], + type: "m3u8", + }) + }) + + decryptedData.source_bk.forEach((source: any) => { + result.videoSources.push({ + url: source.file, + quality: "backup", + subtitles: [], + type: "m3u8", + }) + }) + } + + return result + } +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_onlinestream_test/onlinestream-provider.d.ts b/seanime-2.9.10/internal/extension_repo/goja_onlinestream_test/onlinestream-provider.d.ts new file mode 100644 index 0000000..758e88c --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_onlinestream_test/onlinestream-provider.d.ts @@ -0,0 +1,79 @@ +declare type SearchResult = { + id: string + title: string + url: string + subOrDub: SubOrDub +} + +declare type SubOrDub = "sub" | "dub" | "both" + +declare type EpisodeDetails = { + id: string + number: number + url: string + title?: string +} + +declare type EpisodeServer = { + server: string + headers: { [key: string]: string } + videoSources: VideoSource[] +} + +declare type VideoSourceType = "mp4" | "m3u8" + +declare type VideoSource = { + url: string + type: VideoSourceType + quality: string + subtitles: VideoSubtitle[] +} + +declare type VideoSubtitle = { + id: string + url: string + language: string + isDefault: boolean +} + +declare interface Media { + id: number + idMal?: number + status?: string + format?: string + englishTitle?: string + romajiTitle?: string + episodeCount?: number + absoluteSeasonOffset?: number + synonyms: string[] + isAdult: boolean + startDate?: FuzzyDate +} + +declare interface FuzzyDate { + year: number + month?: number + day?: number +} + +declare type SearchOptions = { + media: Media + query: string + dub: boolean + year?: number +} + +declare type Settings = { + episodeServers: string[] + supportsDub: boolean +} + +declare abstract class AnimeProvider { + search(opts: SearchOptions): Promise + + findEpisodes(id: string): Promise + + findEpisodeServer(episode: EpisodeDetails, server: string): Promise + + getSettings(): Settings +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_onlinestream_test/tsconfig.json b/seanime-2.9.10/internal/extension_repo/goja_onlinestream_test/tsconfig.json new file mode 100644 index 0000000..44bbd4f --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_onlinestream_test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "esnext", + "dom" + ], + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "downlevelIteration": true + } +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_plugin.go b/seanime-2.9.10/internal/extension_repo/goja_plugin.go new file mode 100644 index 0000000..fddcec4 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_plugin.go @@ -0,0 +1,419 @@ +package extension_repo + +import ( + "context" + "fmt" + "reflect" + "seanime/internal/events" + "seanime/internal/extension" + goja_bindings "seanime/internal/goja/goja_bindings" + "seanime/internal/goja/goja_runtime" + "seanime/internal/hook" + "seanime/internal/plugin" + plugin_ui "seanime/internal/plugin/ui" + "seanime/internal/util" + goja_util "seanime/internal/util/goja" + "slices" + "strings" + + "github.com/dop251/goja" + "github.com/dop251/goja/parser" + "github.com/rs/zerolog" + "github.com/samber/lo" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Load Plugin +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) loadPluginExtension(ext *extension.Extension) (err error) { + defer util.HandlePanicInModuleWithError("extension_repo/loadPluginExtension", &err) + + _, gojaExt, err := NewGojaPlugin(ext, ext.Language, r.logger, r.gojaRuntimeManager, r.wsEventManager) + if err != nil { + return err + } + + // Add the extension to the map + retExt := extension.NewPluginExtension(ext) + r.extensionBank.Set(ext.ID, retExt) + r.gojaExtensions.Set(ext.ID, gojaExt) + + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Plugin +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type GojaPlugin struct { + ext *extension.Extension + logger *zerolog.Logger + pool *goja_runtime.Pool + runtimeManager *goja_runtime.Manager + store *plugin.Store[string, any] + storage *plugin.Storage + ui *plugin_ui.UI + scheduler *goja_util.Scheduler + loader *goja.Runtime + unbindHookFuncs []func() + interrupted bool + wsEventManager events.WSEventManagerInterface +} + +func (p *GojaPlugin) GetExtension() *extension.Extension { + return p.ext +} + +func (p *GojaPlugin) PutVM(vm *goja.Runtime) { + p.pool.Put(vm) +} + +// ClearInterrupt stops the UI VM and other modules. +// It is called when the extension is unloaded. +func (p *GojaPlugin) ClearInterrupt() { + if p.interrupted { + return + } + + p.interrupted = true + + p.logger.Debug().Msg("plugin: Interrupting plugin") + // Unload the UI + if p.ui != nil { + p.ui.Unload(false) + } + // Clear the interrupt + if p.loader != nil { + p.loader.ClearInterrupt() + } + // Stop the store + if p.store != nil { + p.store.Stop() + } + // Stop the storage + if p.storage != nil { + p.storage.Stop() + } + // Delete the plugin pool + if p.runtimeManager != nil { + p.runtimeManager.DeletePluginPool(p.ext.ID) + } + p.logger.Debug().Msgf("plugin: Unbinding hooks (%d)", len(p.unbindHookFuncs)) + // Unbind all hooks + for _, unbindHookFunc := range p.unbindHookFuncs { + unbindHookFunc() + } + // Run garbage collection + // runtime.GC() + p.logger.Debug().Msg("plugin: Interrupted plugin") +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func NewGojaPlugin( + ext *extension.Extension, + language extension.Language, + mLogger *zerolog.Logger, + runtimeManager *goja_runtime.Manager, + wsEventManager events.WSEventManagerInterface, +) (*GojaPlugin, GojaExtension, error) { + logger := lo.ToPtr(mLogger.With().Str("id", ext.ID).Logger()) + defer util.HandlePanicInModuleThen("extension_repo/NewGojaPlugin", func() { + logger.Error().Msg("extensions: Failed to create Goja plugin") + }) + + logger.Trace().Msg("extensions: Loading plugin") + + // 1. Create a new plugin instance + p := &GojaPlugin{ + ext: ext, + logger: logger, + runtimeManager: runtimeManager, + store: plugin.NewStore[string, any](nil), // Create a store (must be stopped when unloading) + scheduler: goja_util.NewScheduler(), // Create a scheduler (must be stopped when unloading) + ui: nil, // To be initialized + loader: goja.New(), // To be initialized + unbindHookFuncs: []func(){}, + wsEventManager: wsEventManager, + } + + // 2. Create a new loader for the plugin + // Bind shared APIs to the loader + ShareBinds(p.loader, logger) + BindUserConfig(p.loader, ext, logger) + // Bind hooks to the loader + p.bindHooks() + + // 3. Convert the payload to JavaScript if necessary + source := ext.Payload + if language == extension.LanguageTypescript { + var err error + source, err = JSVMTypescriptToJS(ext.Payload) + if err != nil { + logger.Error().Err(err).Msg("extensions: Failed to convert typescript") + return nil, nil, err + } + } + + // 4. Create a new pool for the plugin hooks (must be deleted when unloading) + var err error + p.pool, err = runtimeManager.GetOrCreatePrivatePool(ext.ID, func() *goja.Runtime { + runtime := goja.New() + ShareBinds(runtime, logger) + BindUserConfig(runtime, ext, logger) + p.BindPluginAPIs(runtime, logger) + return runtime + }) + if err != nil { + return nil, nil, err + } + + //////// UI + + // 5. Create a new VM for the UI (The UI uses a single VM instead of a pool in order to share state) + // (must be interrupted when unloading) + uiVM := goja.New() + uiVM.SetParserOptions(parser.WithDisableSourceMaps) + // Bind shared APIs + ShareBinds(uiVM, logger) + BindUserConfig(uiVM, ext, logger) + // Bind the store to the UI VM + p.BindPluginAPIs(uiVM, logger) + // Create a new UI instance + p.ui = plugin_ui.NewUI(plugin_ui.NewUIOptions{ + Extension: ext, + Logger: logger, + VM: uiVM, + WSManager: wsEventManager, + Scheduler: p.scheduler, + }) + + go func() { + <-p.ui.Destroyed() + p.logger.Warn().Msg("plugin: UI interrupted, interrupting plugin") + p.ClearInterrupt() + }() + + // 6. Bind the UI API to the loader so the plugin can register a new UI + // $ui.register(callback) + uiObj := p.loader.NewObject() + _ = uiObj.Set("register", p.ui.Register) + _ = p.loader.Set("$ui", uiObj) + + // 7. Load the plugin source code in the VM (nothing will execute) + _, err = p.loader.RunString(source) + if err != nil { + logger.Error().Err(err).Msg("extensions: Failed to load plugin") + return nil, nil, err + } + + // 8. Get and call the init function to actually run the plugin + if initFunc := p.loader.Get("init"); initFunc != nil && initFunc != goja.Undefined() { + _, err = p.loader.RunString("init();") + if err != nil { + logger.Error().Err(err).Msg("extensions: Failed to run plugin") + return nil, nil, fmt.Errorf("failed to run plugin: %w", err) + } + logger.Debug().Msg("extensions: Plugin initialized") + } + + return p, p, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// BindPluginAPIs adds plugin-specific APIs +func (p *GojaPlugin) BindPluginAPIs(vm *goja.Runtime, logger *zerolog.Logger) { + // Bind the app context + //_ = vm.Set("$ctx", hook.GlobalHookManager.AppContext()) + + fm := FieldMapper{} + vm.SetFieldNameMapper(fm) + + // Bind the store + p.store.Bind(vm, p.scheduler) + // Bind mutable bindings + goja_util.BindMutable(vm) + // Bind await bindings + goja_util.BindAwait(vm) + // Bind console bindings + _ = goja_bindings.BindConsoleWithWS(p.ext, vm, logger, p.wsEventManager) + + // Bind the app context + plugin.GlobalAppContext.BindApp(vm, logger, p.ext) + + // Bind permission-specific APIs + if p.ext.Plugin != nil { + for _, permission := range p.ext.Plugin.Permissions.Scopes { + switch permission { + case extension.PluginPermissionStorage: // Storage + p.storage = plugin.GlobalAppContext.BindStorage(vm, logger, p.ext, p.scheduler) + + case extension.PluginPermissionAnilist: // Anilist + plugin.GlobalAppContext.BindAnilist(vm, logger, p.ext) + + case extension.PluginPermissionDatabase: // Database + plugin.GlobalAppContext.BindDatabase(vm, logger, p.ext) + + case extension.PluginPermissionSystem: // System + plugin.GlobalAppContext.BindSystem(vm, logger, p.ext, p.scheduler) + } + } + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// bindHooks sets up hooks for the Goja runtime +func (p *GojaPlugin) bindHooks() { + // Create a FieldMapper instance for method name mapping + fm := FieldMapper{} + + // Get the type of the global hook manager + appType := reflect.TypeOf(hook.GlobalHookManager) + // Get the value of the global hook manager + appValue := reflect.ValueOf(hook.GlobalHookManager) + // Get the total number of methods in the global hook manager + // i.e. OnGetAnime, OnGetAnimeDetails, etc. + totalMethods := appType.NumMethod() + // Define methods to exclude from binding + excludeHooks := []string{"OnServe", ""} + + // Create a new JavaScript object to hold the hooks ($app) + appObj := p.loader.NewObject() + + // Iterate through all methods of the global hook manager + // i.e. OnGetAnime, OnGetAnimeDetails, etc. + for i := 0; i < totalMethods; i++ { + // Get the method at the current index + method := appType.Method(i) + + // Check that the method name starts with "On" and is not excluded + if !strings.HasPrefix(method.Name, "On") || slices.Contains(excludeHooks, method.Name) { + continue // Skip to the next method if not a hook or excluded + } + + // Map the method name to a JavaScript-friendly name + // e.g. OnGetAnime -> onGetAnime + jsName := fm.MethodName(appType, method) + + // Set the method on the app object with a callback function + // e.g. $app.onGetAnime(callback, "tag1", "tag2") + appObj.Set(jsName, func(callback string, tags ...string) { + // Create a wrapper JavaScript function that calls the provided callback + // This is necessary because the callback will be called with the provided args + callback = `function(e) { return (` + callback + `).call(undefined, e); }` + // Compile the callback into a Goja program + pr := goja.MustCompile("", "{("+callback+").apply(undefined, __args)}", true) + + // Prepare the tags as reflect.Values for method invocation + tagsAsValues := make([]reflect.Value, len(tags)) + for i, tag := range tags { + tagsAsValues[i] = reflect.ValueOf(tag) + } + + // Get the hook function from the global hook manager and invokes it with the provided tags + // The invokation returns a hook instance + // i.e. OnTaggedHook(tags...) -> TaggedHook / OnHook() -> Hook + hookInstance := appValue.MethodByName(method.Name).Call(tagsAsValues)[0] + + // Get the BindFunc method from the hook instance + hookBindFunc := hookInstance.MethodByName("BindFunc") + unbindHookFunc := hookInstance.MethodByName("Unbind") + + // Get the expected handler type for the hook + // i.e. func(e *hook_resolver.Resolver) error + handlerType := hookBindFunc.Type().In(0) + + // Create a new handler function for the hook + // - returns a new handler of the given handlerType that wraps the function + handler := reflect.MakeFunc(handlerType, func(args []reflect.Value) (results []reflect.Value) { + // Prepare arguments for the handler + handlerArgs := make([]any, len(args)) + + // var err error + // if p.interrupted { + // return []reflect.Value{reflect.ValueOf(&err).Elem()} + // } + + // Run the handler in an isolated "executor" runtime for concurrency + err := p.runtimeManager.Run(context.Background(), p.ext.ID, func(executor *goja.Runtime) error { + // Set the field name mapper for the executor + executor.SetFieldNameMapper(fm) + // Convert each argument (event property) to the appropriate type + for i, arg := range args { + handlerArgs[i] = arg.Interface() + } + // Set the global variable $ctx in the executor + // executor.Set("$$app", plugin.GlobalAppContext) + executor.Set("__args", handlerArgs) + // Execute the handler program + res, err := executor.RunProgram(pr) + // Clear the __args variable for this executor + executor.Set("__args", goja.Undefined()) + // executor.Set("$ctx", goja.Undefined()) + + // Check for returned Go error value + if res != nil { + if resErr, ok := res.Export().(error); ok { + return resErr + } + } + + return normalizeException(err) + }) + + // Return the error as a reflect.Value + return []reflect.Value{reflect.ValueOf(&err).Elem()} + }) + + // Bind the hook if the plugin is not interrupted + if p.interrupted { + return + } + + // Register the wrapped hook handler + callRet := hookBindFunc.Call([]reflect.Value{handler}) + // Get the ID from the return value + id, ok := callRet[0].Interface().(string) + if ok { + p.unbindHookFuncs = append(p.unbindHookFuncs, func() { + p.logger.Trace().Str("id", p.ext.ID).Msgf("plugin: Unbinding hook %s", id) + unbindHookFunc.Call([]reflect.Value{reflect.ValueOf(id)}) + }) + } + }) + } + + // Set the $app object in the loader for JavaScript access + p.loader.Set("$app", appObj) +} + +// normalizeException checks if the provided error is a goja.Exception +// and attempts to return its underlying Go error. +// +// note: using just goja.Exception.Unwrap() is insufficient and may falsely result in nil. +func normalizeException(err error) error { + if err == nil { + return nil + } + + jsException, ok := err.(*goja.Exception) + if !ok { + return err // no exception + } + + switch v := jsException.Value().Export().(type) { + case error: + err = v + case map[string]any: // goja.GoError + if vErr, ok := v["value"].(error); ok { + err = vErr + } + } + + return err +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/seanime-2.9.10/internal/extension_repo/goja_plugin_system_test.go b/seanime-2.9.10/internal/extension_repo/goja_plugin_system_test.go new file mode 100644 index 0000000..6acd356 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_plugin_system_test.go @@ -0,0 +1,2036 @@ +package extension_repo + +import ( + "archive/zip" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "seanime/internal/extension" + "seanime/internal/plugin" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGojaPluginSystemOS tests the $os bindings in the Goja plugin system +func TestGojaPluginSystemOS(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create test files and directories + testFilePath := filepath.Join(tempDir, "test.txt") + testDirPath := filepath.Join(tempDir, "testdir") + testContent := []byte("Hello, world!") + + err := os.WriteFile(testFilePath, testContent, 0644) + require.NoError(t, err) + + err = os.Mkdir(testDirPath, 0755) + require.NoError(t, err) + + // Test $os.platform and $os.arch + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing $os bindings"); + + // Test platform and arch + console.log("Platform:", $os.platform); + console.log("Arch:", $os.arch); + + // Test tempDir + const tempDirPath = $os.tempDir(); + console.log("Temp dir:", tempDirPath); + + // Test readFile + const content = $os.readFile("${TEST_FILE_PATH}"); + console.log("File content:", $toString(content)); + $store.set("fileContent", $toString(content)); + + // Test writeFile + $os.writeFile("${TEST_FILE_PATH}.new", $toBytes("New content"), 0644); + const newContent = $os.readFile("${TEST_FILE_PATH}.new"); + console.log("New file content:", $toString(newContent)); + $store.set("newFileContent", $toString(newContent)); + + // Test readDir + const entries = $os.readDir("${TEST_DIR}"); + console.log("Directory entries:"); + for (const entry of entries) { + console.log(" Entry:", entry.name()); + } + $store.set("dirEntries", entries.length); + + // Test mkdir + $os.mkdir("${TEST_DIR}/newdir", 0755); + const newEntries = $os.readDir("${TEST_DIR}"); + console.log("New directory entries:"); + for (const entry of newEntries) { + console.log(" Entry:", entry.name()); + } + $store.set("newDirEntries", newEntries.length); + + // Test stat + const stats = $os.stat("${TEST_FILE_PATH}"); + console.log("File stats:", stats); + $store.set("fileSize", stats.size()); + + // Test rename + $os.rename("${TEST_FILE_PATH}.new", "${TEST_FILE_PATH}.renamed"); + const renamedExists = $os.stat("${TEST_FILE_PATH}.renamed") !== null; + console.log("Renamed file exists:", renamedExists); + $store.set("renamedExists", renamedExists); + + // Test remove + $os.remove("${TEST_FILE_PATH}.renamed"); + let removeSuccess = true; + try { + $os.stat("${TEST_FILE_PATH}.renamed"); + removeSuccess = false; + } catch (e) { + // File should not exist + removeSuccess = true; + } + console.log("Remove success:", removeSuccess); + $store.set("removeSuccess", removeSuccess); + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${TEST_FILE_PATH}", testFilePath) + payload = strings.ReplaceAll(payload, "${TEST_DIR}", tempDir) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check the store values + fileContent, ok := plugin.store.GetOk("fileContent") + require.True(t, ok, "fileContent should be set in store") + assert.Equal(t, "Hello, world!", fileContent) + + newFileContent, ok := plugin.store.GetOk("newFileContent") + require.True(t, ok, "newFileContent should be set in store") + assert.Equal(t, "New content", newFileContent) + + dirEntries, ok := plugin.store.GetOk("dirEntries") + require.True(t, ok, "dirEntries should be set in store") + assert.Equal(t, int64(3), dirEntries) // test.txt, test.txt.new and testdir + + newDirEntries, ok := plugin.store.GetOk("newDirEntries") + require.True(t, ok, "newDirEntries should be set in store") + assert.Equal(t, int64(4), newDirEntries) // test.txt, test.txt.new, testdir, and newdir + + fileSize, ok := plugin.store.GetOk("fileSize") + require.True(t, ok, "fileSize should be set in store") + assert.Equal(t, int64(13), fileSize) // "Hello, world!" is 13 bytes + + renamedExists, ok := plugin.store.GetOk("renamedExists") + require.True(t, ok, "renamedExists should be set in store") + assert.True(t, renamedExists.(bool)) + + removeSuccess, ok := plugin.store.GetOk("removeSuccess") + require.True(t, ok, "removeSuccess should be set in store") + assert.True(t, removeSuccess.(bool)) + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemOSUnauthorized tests that unauthorized paths are rejected +func TestGojaPluginSystemOSUnauthorized(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + unauthorizedDir := filepath.Join(os.TempDir(), "unauthorized") + + // Ensure the unauthorized directory exists + _ = os.MkdirAll(unauthorizedDir, 0755) + defer os.RemoveAll(unauthorizedDir) + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing unauthorized $os operations"); + + // Try to read from unauthorized path + try { + const content = $os.readFile("${UNAUTHORIZED_PATH}/test.txt"); + $store.set("unauthorizedRead", false); + } catch (e) { + console.log("Unauthorized read error:", e.message); + $store.set("unauthorizedRead", true); + $store.set("unauthorizedReadError", e.message); + } + + // Try to write to unauthorized path + try { + $os.writeFile("${UNAUTHORIZED_PATH}/test.txt", $toBytes("Unauthorized"), 0644); + $store.set("unauthorizedWrite", false); + } catch (e) { + console.log("Unauthorized write error:", e.message); + $store.set("unauthorizedWrite", true); + $store.set("unauthorizedWriteError", e.message); + } + + // Try to read directory from unauthorized path + try { + const entries = $os.readDir("${UNAUTHORIZED_PATH}"); + $store.set("unauthorizedReadDir", false); + } catch (e) { + console.log("Unauthorized readDir error:", e.message); + $store.set("unauthorizedReadDir", true); + $store.set("unauthorizedReadDirError", e.message); + } + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${UNAUTHORIZED_PATH}", unauthorizedDir) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/*"}, + WritePaths: []string{tempDir + "/*"}, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check that unauthorized operations were rejected + unauthorizedRead, ok := plugin.store.GetOk("unauthorizedRead") + require.True(t, ok, "unauthorizedRead should be set in store") + assert.True(t, unauthorizedRead.(bool)) + + unauthorizedReadError, ok := plugin.store.GetOk("unauthorizedReadError") + require.True(t, ok, "unauthorizedReadError should be set in store") + assert.Contains(t, unauthorizedReadError.(string), "not authorized for read") + + unauthorizedWrite, ok := plugin.store.GetOk("unauthorizedWrite") + require.True(t, ok, "unauthorizedWrite should be set in store") + assert.True(t, unauthorizedWrite.(bool)) + + unauthorizedWriteError, ok := plugin.store.GetOk("unauthorizedWriteError") + require.True(t, ok, "unauthorizedWriteError should be set in store") + assert.Contains(t, unauthorizedWriteError.(string), "not authorized for write") + + unauthorizedReadDir, ok := plugin.store.GetOk("unauthorizedReadDir") + require.True(t, ok, "unauthorizedReadDir should be set in store") + assert.True(t, unauthorizedReadDir.(bool)) + + unauthorizedReadDirError, ok := plugin.store.GetOk("unauthorizedReadDirError") + require.True(t, ok, "unauthorizedReadDirError should be set in store") + assert.Contains(t, unauthorizedReadDirError.(string), "not authorized for read") + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemOSOpenFile tests the $os.openFile, $os.create functions +func TestGojaPluginSystemOSOpenFile(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing $os.openFile and $os.create"); + + // Test create + const file = $os.create("${TEMP_DIR}/created.txt"); + file.writeString("Created file content"); + file.close(); + + const createdContent = $os.readFile("${TEMP_DIR}/created.txt"); + console.log("Created file content:", $toString(createdContent)); + $store.set("createdContent", $toString(createdContent)); + + // Test openFile for reading and writing + const fileRW = $os.openFile("${TEMP_DIR}/rw.txt", $os.O_RDWR | $os.O_CREATE, 0644); + fileRW.writeString("Read-write file content"); + fileRW.close(); + + const fileRead = $os.openFile("${TEMP_DIR}/rw.txt", $os.O_RDONLY, 0644); + const buffer = new Uint8Array(100); + const bytesRead = fileRead.read(buffer); + const content = $toString(buffer.subarray(0, bytesRead)); + console.log("Read-write file content:", content); + $store.set("rwContent", content); + fileRead.close(); + + // Test openFile with append + const fileAppend = $os.openFile("${TEMP_DIR}/rw.txt", $os.O_WRONLY | $os.O_APPEND, 0644); + fileAppend.writeString(" - Appended content"); + fileAppend.close(); + + const appendedContent = $os.readFile("${TEMP_DIR}/rw.txt"); + console.log("Appended file content:", $toString(appendedContent)); + $store.set("appendedContent", $toString(appendedContent)); + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${TEMP_DIR}", tempDir) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/*"}, + WritePaths: []string{tempDir + "/*"}, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check the store values + createdContent, ok := plugin.store.GetOk("createdContent") + require.True(t, ok, "createdContent should be set in store") + assert.Equal(t, "Created file content", createdContent) + + rwContent, ok := plugin.store.GetOk("rwContent") + require.True(t, ok, "rwContent should be set in store") + assert.Equal(t, "Read-write file content", rwContent) + + appendedContent, ok := plugin.store.GetOk("appendedContent") + require.True(t, ok, "appendedContent should be set in store") + assert.Equal(t, "Read-write file content - Appended content", appendedContent) + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemOSMkdirAll tests the $os.mkdirAll function +func TestGojaPluginSystemOSMkdirAll(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing $os.mkdirAll"); + + // Test mkdirAll with nested directories + $os.mkdirAll("${TEMP_DIR}/nested/dirs/structure", 0755); + + // Check if the directories were created + const nestedExists = $os.stat("${TEMP_DIR}/nested") !== null; + const dirsExists = $os.stat("${TEMP_DIR}/nested/dirs") !== null; + const structureExists = $os.stat("${TEMP_DIR}/nested/dirs/structure") !== null; + + console.log("Nested directories exist:", nestedExists, dirsExists, structureExists); + $store.set("nestedExists", nestedExists); + $store.set("dirsExists", dirsExists); + $store.set("structureExists", structureExists); + + // Create a file in the nested directory + $os.writeFile("${TEMP_DIR}/nested/dirs/structure/test.txt", $toBytes("Nested file"), 0644); + const nestedContent = $os.readFile("${TEMP_DIR}/nested/dirs/structure/test.txt"); + console.log("Nested file content:", $toString(nestedContent)); + $store.set("nestedContent", $toString(nestedContent)); + + // Test removeAll + $os.removeAll("${TEMP_DIR}/nested"); + let removeAllSuccess = true; + try { + $os.stat("${TEMP_DIR}/nested"); + removeAllSuccess = false; + } catch (e) { + // Directory should not exist + removeAllSuccess = true; + } + console.log("RemoveAll success:", removeAllSuccess); + $store.set("removeAllSuccess", removeAllSuccess); + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${TEMP_DIR}", tempDir) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check the store values + nestedExists, ok := plugin.store.GetOk("nestedExists") + require.True(t, ok, "nestedExists should be set in store") + assert.True(t, nestedExists.(bool)) + + dirsExists, ok := plugin.store.GetOk("dirsExists") + require.True(t, ok, "dirsExists should be set in store") + assert.True(t, dirsExists.(bool)) + + structureExists, ok := plugin.store.GetOk("structureExists") + require.True(t, ok, "structureExists should be set in store") + assert.True(t, structureExists.(bool)) + + nestedContent, ok := plugin.store.GetOk("nestedContent") + require.True(t, ok, "nestedContent should be set in store") + assert.Equal(t, "Nested file", nestedContent) + + removeAllSuccess, ok := plugin.store.GetOk("removeAllSuccess") + require.True(t, ok, "removeAllSuccess should be set in store") + assert.True(t, removeAllSuccess.(bool)) + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemOSPermissions tests that the plugin system enforces permissions correctly +func TestGojaPluginSystemOSPermissions(t *testing.T) { + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing $os permissions"); + + // Try to use $os without system permission + try { + const tempDirPath = $os.tempDir(); + $store.set("noPermissionAccess", true); + } catch (e) { + console.log("No permission error:", e.message); + $store.set("noPermissionAccess", false); + $store.set("noPermissionError", e.message); + } + }); +} + ` + + opts := DefaultTestPluginOptions() + opts.Payload = payload + // Deliberately NOT including the system permission + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{}, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check that operations were rejected due to missing permissions + noPermissionAccess, ok := plugin.store.GetOk("noPermissionAccess") + require.True(t, ok, "noPermissionAccess should be set in store") + assert.False(t, noPermissionAccess.(bool)) + + noPermissionError, ok := plugin.store.GetOk("noPermissionError") + require.True(t, ok, "noPermissionError should be set in store") + assert.Contains(t, noPermissionError.(string), "$os is not defined") + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemFilepath tests the $filepath bindings in the Goja plugin system +func TestGojaPluginSystemFilepath(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create test files and directories + testFilePath := filepath.Join(tempDir, "test.txt") + nestedDir := filepath.Join(tempDir, "nested", "dir") + err := os.MkdirAll(nestedDir, 0755) + require.NoError(t, err) + + err = os.WriteFile(testFilePath, []byte("Hello, world!"), 0644) + require.NoError(t, err) + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing $filepath bindings"); + + // Test base + const baseName = $filepath.base("${TEST_FILE_PATH}"); + console.log("Base name:", baseName); + $store.set("baseName", baseName); + + // Test dir + const dirName = $filepath.dir("${TEST_FILE_PATH}"); + console.log("Dir name:", dirName); + $store.set("dirName", dirName); + + // Test ext + const extName = $filepath.ext("${TEST_FILE_PATH}"); + console.log("Ext name:", extName); + $store.set("extName", extName); + + // Test join + const joinedPath = $filepath.join("${TEMP_DIR}", "subdir", "file.txt"); + console.log("Joined path:", joinedPath); + $store.set("joinedPath", joinedPath); + + // Test split + const [dir, file] = $filepath.split("${TEST_FILE_PATH}"); + console.log("Split path:", dir, file); + $store.set("splitDir", dir); + $store.set("splitFile", file); + + // Test glob + const globResults = $filepath.glob("${TEMP_DIR}", "*.txt"); + console.log("Glob results:", globResults); + $store.set("globResults", globResults.length); + + // Test match + const isMatch = $filepath.match("*.txt", "test.txt"); + console.log("Match result:", isMatch); + $store.set("isMatch", isMatch); + + // Test isAbs + const isAbsPath = $filepath.isAbs("${TEST_FILE_PATH}"); + console.log("Is absolute path:", isAbsPath); + $store.set("isAbsPath", isAbsPath); + + // Test toSlash and fromSlash + const slashPath = $filepath.toSlash("${TEST_FILE_PATH}"); + console.log("To slash:", slashPath); + $store.set("slashPath", slashPath); + + const fromSlashPath = $filepath.fromSlash(slashPath); + console.log("From slash:", fromSlashPath); + $store.set("fromSlashPath", fromSlashPath); + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${TEST_FILE_PATH}", testFilePath) + payload = strings.ReplaceAll(payload, "${TEMP_DIR}", tempDir) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check the store values + baseName, ok := plugin.store.GetOk("baseName") + require.True(t, ok, "baseName should be set in store") + assert.Equal(t, "test.txt", baseName) + + dirName, ok := plugin.store.GetOk("dirName") + require.True(t, ok, "dirName should be set in store") + assert.Equal(t, tempDir, dirName) + + extName, ok := plugin.store.GetOk("extName") + require.True(t, ok, "extName should be set in store") + assert.Equal(t, ".txt", extName) + + joinedPath, ok := plugin.store.GetOk("joinedPath") + require.True(t, ok, "joinedPath should be set in store") + assert.Equal(t, filepath.Join(tempDir, "subdir", "file.txt"), joinedPath) + + splitDir, ok := plugin.store.GetOk("splitDir") + require.True(t, ok, "splitDir should be set in store") + assert.Equal(t, tempDir+string(filepath.Separator), splitDir) + + splitFile, ok := plugin.store.GetOk("splitFile") + require.True(t, ok, "splitFile should be set in store") + assert.Equal(t, "test.txt", splitFile) + + globResults, ok := plugin.store.GetOk("globResults") + require.True(t, ok, "globResults should be set in store") + assert.Equal(t, int64(1), globResults) // test.txt + + isMatch, ok := plugin.store.GetOk("isMatch") + require.True(t, ok, "isMatch should be set in store") + assert.True(t, isMatch.(bool)) + + isAbsPath, ok := plugin.store.GetOk("isAbsPath") + require.True(t, ok, "isAbsPath should be set in store") + assert.True(t, isAbsPath.(bool)) + + slashPath, ok := plugin.store.GetOk("slashPath") + require.True(t, ok, "slashPath should be set in store") + assert.Contains(t, slashPath.(string), "/") + + fromSlashPath, ok := plugin.store.GetOk("fromSlashPath") + require.True(t, ok, "fromSlashPath should be set in store") + assert.Equal(t, testFilePath, fromSlashPath) + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemIO tests the $io bindings in the Goja plugin system +func TestGojaPluginSystemIO(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create test file + testFilePath := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFilePath, []byte("Hello, world!"), 0644) + require.NoError(t, err) + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing $io bindings"); + + // Test readAll + const file = $os.openFile("${TEST_FILE_PATH}", $os.O_RDONLY, 0); + const content = $io.readAll(file); + file.close(); + console.log("Read content:", $toString(content)); + $store.set("readAllContent", $toString(content)); + + // Test copy + const srcFile = $os.openFile("${TEST_FILE_PATH}", $os.O_RDONLY, 0); + const destFile = $os.create("${TEMP_DIR}/copy.txt"); + const bytesCopied = $io.copy(destFile, srcFile); + srcFile.close(); + destFile.close(); + console.log("Bytes copied:", bytesCopied); + $store.set("bytesCopied", bytesCopied); + + // Test writeString + const stringFile = $os.create("${TEMP_DIR}/string.txt"); + const bytesWritten = $io.writeString(stringFile, "Written with writeString"); + stringFile.close(); + console.log("Bytes written:", bytesWritten); + $store.set("bytesWritten", bytesWritten); + + // Read the file back to verify + const stringContent = $os.readFile("${TEMP_DIR}/string.txt"); + console.log("String content:", $toString(stringContent)); + $store.set("stringContent", $toString(stringContent)); + + // Test copyN + const srcFileN = $os.openFile("${TEST_FILE_PATH}", $os.O_RDONLY, 0); + const destFileN = $os.create("${TEMP_DIR}/copyN.txt"); + const bytesCopiedN = $io.copyN(destFileN, srcFileN, 5); // Copy only 5 bytes + srcFileN.close(); + destFileN.close(); + console.log("Bytes copied with copyN:", bytesCopiedN); + $store.set("bytesCopiedN", bytesCopiedN); + + // Read the file back to verify + const copyNContent = $os.readFile("${TEMP_DIR}/copyN.txt"); + console.log("CopyN content:", $toString(copyNContent)); + $store.set("copyNContent", $toString(copyNContent)); + + // Test limitReader + const bigFile = $os.openFile("${TEST_FILE_PATH}", $os.O_RDONLY, 0); + const limitedReader = $io.limitReader(bigFile, 5); // Limit to 5 bytes + const limitBuffer = new Uint8Array(100); + const limitBytesRead = limitedReader.read(limitBuffer); + bigFile.close(); + console.log("Limited bytes read:", limitBytesRead); + $store.set("limitBytesRead", limitBytesRead); + $store.set("limitContent", $toString(limitBuffer.subarray(0, limitBytesRead))); + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${TEST_FILE_PATH}", testFilePath) + payload = strings.ReplaceAll(payload, "${TEMP_DIR}", tempDir) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check the store values + readAllContent, ok := plugin.store.GetOk("readAllContent") + require.True(t, ok, "readAllContent should be set in store") + assert.Equal(t, "Hello, world!", readAllContent) + + bytesCopied, ok := plugin.store.GetOk("bytesCopied") + require.True(t, ok, "bytesCopied should be set in store") + assert.Equal(t, int64(13), bytesCopied) // "Hello, world!" is 13 bytes + + bytesWritten, ok := plugin.store.GetOk("bytesWritten") + require.True(t, ok, "bytesWritten should be set in store") + assert.Equal(t, int64(24), bytesWritten) // "Written with writeString" is 24 bytes + + stringContent, ok := plugin.store.GetOk("stringContent") + require.True(t, ok, "stringContent should be set in store") + assert.Equal(t, "Written with writeString", stringContent) + + bytesCopiedN, ok := plugin.store.GetOk("bytesCopiedN") + require.True(t, ok, "bytesCopiedN should be set in store") + assert.Equal(t, int64(5), bytesCopiedN) + + copyNContent, ok := plugin.store.GetOk("copyNContent") + require.True(t, ok, "copyNContent should be set in store") + assert.Equal(t, "Hello", copyNContent) + + limitBytesRead, ok := plugin.store.GetOk("limitBytesRead") + require.True(t, ok, "limitBytesRead should be set in store") + assert.Equal(t, int64(5), limitBytesRead) + + limitContent, ok := plugin.store.GetOk("limitContent") + require.True(t, ok, "limitContent should be set in store") + assert.Equal(t, "Hello", limitContent) + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemBufio tests the $bufio bindings in the Goja plugin system +func TestGojaPluginSystemBufio(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create test file with multiple lines + testFilePath := filepath.Join(tempDir, "multiline.txt") + err := os.WriteFile(testFilePath, []byte("Line 1\nLine 2\nLine 3\nLine 4\n"), 0644) + require.NoError(t, err) + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing $bufio bindings"); + + // Test NewReader and ReadString + const file = $os.openFile("${TEST_FILE_PATH}", $os.O_RDONLY, 0); + const reader = $bufio.newReader(file); + + // Read lines one by one with try/catch to handle EOF + const lines = []; + for (let i = 0; i < 10; i++) { // Try to read more lines than exist + try { + const line = reader.readString($toBytes('\n')); + console.log("Read line:", line); + lines.push(line.trim()); + } catch (e) { + console.log("Caught expected EOF:", e.message); + $store.set("eofCaught", true); + } + } + file.close(); + + console.log("Read lines:", lines); + $store.set("lines", lines); + + // Test NewWriter + const writeFile = $os.create("${TEMP_DIR}/bufio_write.txt"); + const writer = $bufio.newWriter(writeFile); + + // Write multiple strings + writer.writeString("Buffered "); + writer.writeString("write "); + writer.writeString("test"); + + // Flush to ensure data is written + writer.flush(); + writeFile.close(); + + // Read back the file to verify + const writtenContent = $os.readFile("${TEMP_DIR}/bufio_write.txt"); + console.log("Written content:", $toString(writtenContent)); + $store.set("writtenContent", $toString(writtenContent)); + + // Test Scanner + const scanFile = $os.openFile("${TEST_FILE_PATH}", $os.O_RDONLY, 0); + const scanner = $bufio.newScanner(scanFile); + + // Scan lines + const scannedLines = []; + while (scanner.scan()) { + scannedLines.push(scanner.text()); + } + scanFile.close(); + + console.log("Scanned lines:", scannedLines); + $store.set("scannedLines", scannedLines); + + // Test ReadBytes + try { + const bytesFile = $os.openFile("${TEST_FILE_PATH}", $os.O_RDONLY, 0); + const bytesReader = $bufio.newReader(bytesFile); + + const bytesLines = []; + try { + for (let i = 0; i < 10; i++) { + const lineBytes = bytesReader.readBytes('\n'.charCodeAt(0)); + bytesLines.push($toString(lineBytes).trim()); + } + } catch (e) { + console.log("Caught expected EOF in readBytes:", e.message); + $store.set("eofCaughtBytes", true); + } + bytesFile.close(); + + console.log("Read bytes lines:", bytesLines); + $store.set("bytesLines", bytesLines); + } catch (e) { + console.log("Error in ReadBytes test:", e.message); + } + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${TEST_FILE_PATH}", testFilePath) + payload = strings.ReplaceAll(payload, "${TEMP_DIR}", tempDir) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check the store values + lines, ok := plugin.store.GetOk("lines") + require.True(t, ok, "lines should be set in store") + assert.Equal(t, []interface{}{"Line 1", "Line 2", "Line 3", "Line 4"}, lines) + + eofCaught, ok := plugin.store.GetOk("eofCaught") + require.True(t, ok, "eofCaught should be set in store") + assert.True(t, eofCaught.(bool)) + + writtenContent, ok := plugin.store.GetOk("writtenContent") + require.True(t, ok, "writtenContent should be set in store") + assert.Equal(t, "Buffered write test", writtenContent) + + scannedLines, ok := plugin.store.GetOk("scannedLines") + require.True(t, ok, "scannedLines should be set in store") + assert.Equal(t, []interface{}{"Line 1", "Line 2", "Line 3", "Line 4"}, scannedLines) + + bytesLines, ok := plugin.store.GetOk("bytesLines") + if ok { + assert.Equal(t, []interface{}{"Line 1", "Line 2", "Line 3", "Line 4"}, bytesLines) + } + + eofCaughtBytes, ok := plugin.store.GetOk("eofCaughtBytes") + if ok { + assert.True(t, eofCaughtBytes.(bool)) + } + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemBytes tests the $bytes bindings in the Goja plugin system +func TestGojaPluginSystemBytes(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing $bytes bindings"); + + // Test NewBuffer + const buffer = $bytes.newBuffer($toBytes("Hello")); + buffer.writeString(", world!"); + + const bufferContent = $toString(buffer.bytes()); + console.log("Buffer content:", bufferContent); + $store.set("bufferContent", bufferContent); + + // Test NewBufferString + const strBuffer = $bytes.newBufferString("String buffer"); + strBuffer.writeString(" test"); + + const strBufferContent = strBuffer.string(); + console.log("String buffer content:", strBufferContent); + $store.set("strBufferContent", strBufferContent); + + // Test NewReader + const reader = $bytes.newReader($toBytes("Bytes reader test")); + const readerBuffer = new Uint8Array(100); + const bytesRead = reader.read(readerBuffer); + + const readerContent = $toString(readerBuffer.subarray(0, bytesRead)); + console.log("Reader content:", readerContent); + $store.set("readerContent", readerContent); + + // Test buffer methods + const testBuffer = $bytes.newBuffer($toBytes("")); + testBuffer.writeString("Test"); + testBuffer.writeByte(32); // Space + testBuffer.writeString("methods"); + + const testBufferContent = testBuffer.string(); + console.log("Test buffer content:", testBufferContent); + $store.set("testBufferContent", testBufferContent); + + // Test read methods + const readBuffer = $bytes.newBuffer($toBytes("Read test")); + const readByte = readBuffer.readByte(); + console.log("Read byte:", String.fromCharCode(readByte)); + $store.set("readByte", readByte); + + const nextBytes = new Uint8Array(4); + readBuffer.read(nextBytes); + console.log("Next bytes:", $toString(nextBytes)); + $store.set("nextBytes", $toString(nextBytes)); + }); +} + ` + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check the store values + bufferContent, ok := plugin.store.GetOk("bufferContent") + require.True(t, ok, "bufferContent should be set in store") + assert.Equal(t, "Hello, world!", bufferContent) + + strBufferContent, ok := plugin.store.GetOk("strBufferContent") + require.True(t, ok, "strBufferContent should be set in store") + assert.Equal(t, "String buffer test", strBufferContent) + + readerContent, ok := plugin.store.GetOk("readerContent") + require.True(t, ok, "readerContent should be set in store") + assert.Equal(t, "Bytes reader test", readerContent) + + testBufferContent, ok := plugin.store.GetOk("testBufferContent") + require.True(t, ok, "testBufferContent should be set in store") + assert.Equal(t, "Test methods", testBufferContent) + + readByte, ok := plugin.store.GetOk("readByte") + require.True(t, ok, "readByte should be set in store") + assert.Equal(t, int64('R'), readByte) + + nextBytes, ok := plugin.store.GetOk("nextBytes") + require.True(t, ok, "nextBytes should be set in store") + assert.Equal(t, "ead ", nextBytes) + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemDownloader tests the ctx.downloader bindings in the Goja plugin system +func TestGojaPluginSystemDownloader(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create a test HTTP server that serves a large file in chunks to simulate download progress + const totalSize = 1024 * 1024 // 1MB + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set content length for proper progress calculation + w.Header().Set("Content-Length", fmt.Sprintf("%d", totalSize)) + + // Flush headers to client + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + // Send data in chunks with delays to simulate download progress + chunkSize := 32 * 1024 // 32KB chunks + chunk := make([]byte, chunkSize) + for i := 0; i < len(chunk); i++ { + chunk[i] = byte(i % 256) + } + + for sent := 0; sent < totalSize; sent += chunkSize { + // Sleep to simulate network delay + time.Sleep(100 * time.Millisecond) + + // Calculate remaining bytes + remaining := totalSize - sent + if remaining < chunkSize { + chunkSize = remaining + } + + // Write chunk + w.Write(chunk[:chunkSize]) + + // Flush to ensure client receives data immediately + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + })) + defer server.Close() + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing ctx.downloader bindings with large file"); + + // Test download + const downloadPath = "${TEMP_DIR}/large_download.bin"; + try { + const downloadID = ctx.downloader.download("${SERVER_URL}", downloadPath, { + timeout: 60 // 60 second timeout + }); + console.log("Download started with ID:", downloadID); + $store.set("downloadID", downloadID); + + // Track progress updates + const progressUpdates = []; + + // Wait for download to complete + let downloadComplete = ctx.state(false); + const cancelWatch = ctx.downloader.watch(downloadID, (progress) => { + // Store progress update + progressUpdates.push({ + percentage: progress.percentage, + totalBytes: progress.totalBytes, + speed: progress.speed, + status: progress.status + }); + + console.log("Download progress:", + progress.percentage.toFixed(2), "%, ", + "Speed:", (progress.speed / 1024).toFixed(2), "KB/s, ", + "Downloaded:", (progress.totalBytes / 1024).toFixed(2), "KB" + , progress); + + if (progress.status === "completed") { + downloadComplete.set(true); + $store.set("downloadComplete", true); + $store.set("downloadProgress", progress); + $store.set("progressUpdates", progressUpdates); + } else if (progress.status === "error") { + console.log("Download error:", progress.error); + $store.set("downloadError", progress.error); + } + }); + + // Wait for download to complete + ctx.effect(() => { + if (!downloadComplete.get()) { + return + } + // Cancel watch + cancelWatch(); + + // Check downloaded file + try { + if (downloadComplete) { + const stats = $os.stat(downloadPath); + console.log("Downloaded file size:", stats.size(), "bytes"); + $store.set("downloadedSize", stats.size()); + } + + // List downloads + const downloads = ctx.downloader.listDownloads(); + console.log("Active downloads:", downloads.length); + $store.set("downloadsCount", downloads.length); + + // Get progress + const progress = ctx.downloader.getProgress(downloadID); + if (progress) { + console.log("Final download progress:", progress); + $store.set("finalProgress", progress); + } else { + console.log("Progress not found for ID:", downloadID); + $store.set("progressNotFound", true); + } + } catch (e) { + console.log("Error in download check:", e.message); + $store.set("checkError", e.message); + } + }, [downloadComplete]); + } catch (e) { + console.log("Error starting download:", e.message); + $store.set("startError", e.message); + } + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${TEMP_DIR}", tempDir) + payload = strings.ReplaceAll(payload, "${SERVER_URL}", server.URL) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + }, + } + + p, logger, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute and download to complete + time.Sleep(12 * time.Second) + + // Check the store values + downloadID, ok := p.store.GetOk("downloadID") + require.True(t, ok, "downloadID should be set in store") + assert.NotEmpty(t, downloadID) + + // Check if download completed or if there was an error + downloadComplete, ok := p.store.GetOk("downloadComplete") + if ok && downloadComplete.(bool) { + // If download completed, check file size + downloadedSize, ok := p.store.GetOk("downloadedSize") + require.True(t, ok, "downloadedSize should be set in store") + assert.Equal(t, int64(totalSize), downloadedSize) + + // Check progress updates + progressUpdates, ok := p.store.GetOk("progressUpdates") + require.True(t, ok, "progressUpdates should be set in store") + updates, ok := progressUpdates.([]interface{}) + require.True(t, ok, "progressUpdates should be a slice") + + // Should have multiple progress updates + assert.Greater(t, len(updates), 1, "Should have multiple progress updates") + + // Print progress updates for debugging + logger.Info().Msgf("Received %d progress updates", len(updates)) + for i, update := range updates { + if i < 5 || i >= len(updates)-5 { + logger.Info().Interface("update", update).Msgf("Progress update %d", i) + } else if i == 5 { + logger.Info().Msg("... more updates ...") + } + } + + finalProgress, ok := p.store.GetOk("finalProgress") + if ok { + progressMap, ok := finalProgress.(*plugin.DownloadProgress) + require.Truef(t, ok, "finalProgress should be a map, got %T", finalProgress) + assert.Equal(t, "completed", progressMap.Status) + assert.InDelta(t, 100.0, progressMap.Percentage, 0.1) + } + } else { + // If download failed, check error + downloadError, _ := p.store.GetOk("downloadError") + t.Logf("Download error: %v", downloadError) + // Don't fail the test if there was an error, just log it + } + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemFilepathWalk tests the walk and walkDir functions in the filepath module +func TestGojaPluginSystemFilepathWalk(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create a directory structure for testing walk and walkDir + dirs := []string{ + filepath.Join(tempDir, "dir1"), + filepath.Join(tempDir, "dir1", "subdir1"), + filepath.Join(tempDir, "dir1", "subdir2"), + filepath.Join(tempDir, "dir2"), + filepath.Join(tempDir, "dir2", "subdir1"), + } + + files := []string{ + filepath.Join(tempDir, "file1.txt"), + filepath.Join(tempDir, "file2.txt"), + filepath.Join(tempDir, "dir1", "file3.txt"), + filepath.Join(tempDir, "dir1", "subdir1", "file4.txt"), + filepath.Join(tempDir, "dir1", "subdir2", "file5.txt"), + filepath.Join(tempDir, "dir2", "file6.txt"), + filepath.Join(tempDir, "dir2", "subdir1", "file7.txt"), + } + + // Create directories + for _, dir := range dirs { + err := os.MkdirAll(dir, 0755) + require.NoError(t, err) + } + + // Create files with content + for i, file := range files { + content := fmt.Sprintf("Content of file %d", i+1) + err := os.WriteFile(file, []byte(content), 0644) + require.NoError(t, err) + } + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing $filepath walk and walkDir"); + + // Test walk + const walkPaths = []; + const walkErrors = []; + + $filepath.walk("${TEMP_DIR}", (path, info, err) => { + if (err) { + console.log("Walk error:", path, err); + walkErrors.push({ path, error: err.message }); + return; // Continue walking + } + + console.log("Walk path:", path, "isDir:", info.isDir()); + walkPaths.push({ + path: path, + isDir: info.isDir(), + name: info.name() + }); + return; // Continue walking + }); + + console.log("Walk found", walkPaths.length, "paths"); + $store.set("walkPaths", walkPaths); + $store.set("walkErrors", walkErrors); + + // Test walkDir + const walkDirPaths = []; + const walkDirErrors = []; + + $filepath.walkDir("${TEMP_DIR}", (path, d, err) => { + if (err) { + console.log("WalkDir error:", path, err); + walkDirErrors.push({ path, error: err.message }); + return; // Continue walking + } + + console.log("WalkDir path:", path, "isDir:", d.isDir()); + walkDirPaths.push({ + path: path, + isDir: d.isDir(), + name: d.name() + }); + return; // Continue walking + }); + + console.log("WalkDir found", walkDirPaths.length, "paths"); + $store.set("walkDirPaths", walkDirPaths); + $store.set("walkDirErrors", walkDirErrors); + + // Count files and directories found + const walkFileCount = walkPaths.filter(p => !p.isDir).length; + const walkDirCount = walkPaths.filter(p => p.isDir).length; + const walkDirFileCount = walkDirPaths.filter(p => !p.isDir).length; + const walkDirDirCount = walkDirPaths.filter(p => p.isDir).length; + + console.log("Walk found", walkFileCount, "files and", walkDirCount, "directories"); + console.log("WalkDir found", walkDirFileCount, "files and", walkDirDirCount, "directories"); + + $store.set("walkFileCount", walkFileCount); + $store.set("walkDirCount", walkDirCount); + $store.set("walkDirFileCount", walkDirFileCount); + $store.set("walkDirDirCount", walkDirDirCount); + + // Test skipping a directory + const skipDirWalkPaths = []; + + $filepath.walk("${TEMP_DIR}", (path, info, err) => { + if (err) { + return; // Continue walking + } + + // Skip dir1 and its subdirectories + if (info.isDir() && info.name() === "dir1") { + console.log("Skipping directory:", path); + return $filepath.skipDir; // Skip this directory + } + + skipDirWalkPaths.push(path); + return; // Continue walking + }); + + console.log("Skip dir walk found", skipDirWalkPaths.length, "paths"); + $store.set("skipDirWalkPaths", skipDirWalkPaths); + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${TEMP_DIR}", tempDir) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check the store values + walkPaths, ok := plugin.store.GetOk("walkPaths") + require.True(t, ok, "walkPaths should be set in store") + walkPathsSlice, ok := walkPaths.([]interface{}) + require.True(t, ok, "walkPaths should be a slice") + + // Total number of paths should be dirs + files + root dir + assert.Equal(t, len(dirs)+len(files)+1, len(walkPathsSlice), "walkPaths should contain all directories and files") + + walkDirPaths, ok := plugin.store.GetOk("walkDirPaths") + require.True(t, ok, "walkDirPaths should be set in store") + walkDirPathsSlice, ok := walkDirPaths.([]interface{}) + require.True(t, ok, "walkDirPaths should be a slice") + + // Total number of paths should be dirs + files + root dir + assert.Equal(t, len(dirs)+len(files)+1, len(walkDirPathsSlice), "walkDirPaths should contain all directories and files") + + // Check file and directory counts + walkFileCount, ok := plugin.store.GetOk("walkFileCount") + require.True(t, ok, "walkFileCount should be set in store") + assert.Equal(t, int64(len(files)), walkFileCount, "walkFileCount should match the number of files") + + walkDirCount, ok := plugin.store.GetOk("walkDirCount") + require.True(t, ok, "walkDirCount should be set in store") + assert.Equal(t, int64(len(dirs)+1), walkDirCount, "walkDirCount should match the number of directories plus root") + + walkDirFileCount, ok := plugin.store.GetOk("walkDirFileCount") + require.True(t, ok, "walkDirFileCount should be set in store") + assert.Equal(t, int64(len(files)), walkDirFileCount, "walkDirFileCount should match the number of files") + + walkDirDirCount, ok := plugin.store.GetOk("walkDirDirCount") + require.True(t, ok, "walkDirDirCount should be set in store") + assert.Equal(t, int64(len(dirs)+1), walkDirDirCount, "walkDirDirCount should match the number of directories plus root") + + // Check skipping directories + skipDirWalkPaths, ok := plugin.store.GetOk("skipDirWalkPaths") + require.True(t, ok, "skipDirWalkPaths should be set in store") + skipDirWalkPathsSlice, ok := skipDirWalkPaths.([]interface{}) + require.True(t, ok, "skipDirWalkPaths should be a slice") + + // Count how many paths should be left after skipping dir1 and its subdirectories + // We should have tempDir, dir2, dir2/subdir1, and their files (file1.txt, file2.txt, file6.txt, file7.txt) + expectedPathsAfterSkip := 1 + 2 + 4 // root + dir2 dirs + files in root and dir2 + assert.Equal(t, expectedPathsAfterSkip, len(skipDirWalkPathsSlice), "skipDirWalkPaths should not contain dir1 and its subdirectories") + + // Check for errors + walkErrors, ok := plugin.store.GetOk("walkErrors") + require.True(t, ok, "walkErrors should be set in store") + walkErrorsSlice, ok := walkErrors.([]interface{}) + require.True(t, ok, "walkErrors should be a slice") + assert.Empty(t, walkErrorsSlice, "There should be no walk errors") + + walkDirErrors, ok := plugin.store.GetOk("walkDirErrors") + require.True(t, ok, "walkDirErrors should be set in store") + walkDirErrorsSlice, ok := walkDirErrors.([]interface{}) + require.True(t, ok, "walkDirErrors should be a slice") + assert.Empty(t, walkDirErrorsSlice, "There should be no walkDir errors") + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemCommands tests the command execution functionality in the system module +func TestGojaPluginSystemCommands(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create a test file to use with commands + testFilePath := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFilePath, []byte("Hello, world!"), 0644) + require.NoError(t, err) + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing command execution"); + + // Test executing a simple command + try { + // Create a command to list files + const cmd = $os.cmd("ls", "-la", "${TEMP_DIR}"); + + // Set up stdout capture + const stdoutPipe = cmd.stdoutPipe(); + + // Start the command + cmd.start(); + + // Read the output + const output = $io.readAll(stdoutPipe); + console.log("Command output:", $toString(output)); + $store.set("commandOutput", $toString(output)); + + // Wait for the command to complete + cmd.wait(); + + // Check exit code + const exitCode = cmd.processState.exitCode(); + console.log("Command exit code:", exitCode); + $store.set("commandExitCode", exitCode); + } catch (e) { + console.log("Command execution error:", e.message); + $store.set("commandError", e.message); + } + + // Test executing an async command + //try { + // // Create a command to list files + // const asyncCmd = $osExtra.asyncCmd("ls", "-la", "${TEMP_DIR}"); + // + // + // asyncCmd.run((data, err, exitCode, signal) => { + // // console.log(data, err, exitCode, signal) + // if (data) { + // console.log("Async command data:", $toString(data)); + // } + // if (err) { + // console.log("Async command error:", $toString(err)); + // } + // if (exitCode) { + // console.log("Async command exit code:", exitCode); + // } + // if (signal) { + // console.log("Async command signal:", signal); + // } + // }); + //} catch (e) { + // console.log("Command execution error:", e.message); + // $store.set("asyncCommandError", e.message); + //} + + // // Try unsafe goroutine + // try { + // // Create a command to list files + // const cmd = $os.cmd("ls", "-la", "${TEMP_DIR}"); + + // $store.watch("unsafeGoroutineOutput", (output) => { + // console.log("Unsafe goroutine output:", output); + // }); + + // // Read the output using scanner + // $unsafeGoroutine(function() { + // // Set up stdout capture + // const stdoutPipe = cmd.stdoutPipe(); + + // console.log("Starting unsafe goroutine"); + // const output = $io.readAll(stdoutPipe); + // $store.set("unsafeGoroutineOutput", $toString(output)); + // console.log("Unsafe goroutine output set", $toString(output)); + + // cmd.wait(); + // }); + + // // Start the command + // cmd.start(); + + // // Check exit code + // const exitCode = cmd.processState.exitCode(); + // console.log("Command exit code:", exitCode); + + // } catch (e) { + // console.log("Command execution error:", e.message); + // $store.set("unsafeGoroutineError", e.message); + // } + + // Test executing a command with combined output + try { + // Create a command to find a string in a file + const cmd = $os.cmd("grep", "Hello", "${TEST_FILE_PATH}"); + + // Run the command and capture output + const output = cmd.combinedOutput(); + console.log("Grep output:", $toString(output)); + $store.set("grepOutput", $toString(output)); + + // Check if the command found the string + const foundString = $toString(output).includes("Hello"); + console.log("Found string:", foundString); + $store.set("foundString", foundString); + } catch (e) { + console.log("Grep execution error:", e.message); + $store.set("grepError", e.message); + } + + // Test executing a command with input + try { + // Create a command to sort lines + const cmd = $os.cmd("sort"); + + // Set up stdin and stdout pipes + const stdinPipe = cmd.stdinPipe(); + const stdoutPipe = cmd.stdoutPipe(); + + // Start the command + cmd.start(); + + // Write to stdin + $io.writeString(stdinPipe, "c\nb\na\n"); + stdinPipe.close(); + + // Read sorted output + const sortedOutput = $io.readAll(stdoutPipe); + console.log("Sorted output:", $toString(sortedOutput)); + $store.set("sortedOutput", $toString(sortedOutput)); + + // Wait for the command to complete + cmd.wait(); + } catch (e) { + console.log("Sort execution error:", e.message); + $store.set("sortError", e.message); + } + + // Test unauthorized command + try { + // Try to execute an unauthorized command + const cmd = $os.cmd("open", "https://google.com"); + cmd.run(); + $store.set("unauthorizedCommandRan", true); + } catch (e) { + console.log("Unauthorized command error:", e.message); + $store.set("unauthorizedCommandError", e.message); + $store.set("unauthorizedCommandRan", false); + } + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${TEMP_DIR}", tempDir) + payload = strings.ReplaceAll(payload, "${TEST_FILE_PATH}", testFilePath) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + CommandScopes: []extension.CommandScope{ + { + Command: "ls", + Args: []extension.CommandArg{ + {Value: "-la"}, + {Validator: "$PATH"}, + }, + }, + { + Command: "grep", + Args: []extension.CommandArg{ + {Value: "Hello"}, + {Validator: "$PATH"}, + }, + }, + { + Command: "sort", + Args: []extension.CommandArg{}, + }, + }, + }, + } + + fmt.Println(opts.Permissions.GetDescription()) + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check the store values for the ls command + commandOutput, ok := plugin.store.GetOk("commandOutput") + require.True(t, ok, "commandOutput should be set in store") + assert.Contains(t, commandOutput.(string), "test.txt", "Command output should contain the test file") + + commandExitCode, ok := plugin.store.GetOk("commandExitCode") + require.True(t, ok, "commandExitCode should be set in store") + assert.Equal(t, int64(0), commandExitCode, "Command exit code should be 0") + + // Check the store values for the grep command + grepOutput, ok := plugin.store.GetOk("grepOutput") + if ok { + assert.Contains(t, grepOutput.(string), "Hello", "Grep output should contain 'Hello'") + } + + foundString, ok := plugin.store.GetOk("foundString") + if ok { + assert.True(t, foundString.(bool), "Should have found the string in the file") + } + + // Check the store values for the sort command + sortedOutput, ok := plugin.store.GetOk("sortedOutput") + if ok { + // Expected output: "a\nb\nc\n" (sorted) + assert.Contains(t, sortedOutput.(string), "a", "Sorted output should contain 'a'") + assert.Contains(t, sortedOutput.(string), "b", "Sorted output should contain 'b'") + assert.Contains(t, sortedOutput.(string), "c", "Sorted output should contain 'c'") + + // Check if the lines are in the correct order + lines := strings.Split(strings.TrimSpace(sortedOutput.(string)), "\n") + if len(lines) >= 3 { + assert.Equal(t, "a", lines[0], "First line should be 'a'") + assert.Equal(t, "b", lines[1], "Second line should be 'b'") + assert.Equal(t, "c", lines[2], "Third line should be 'c'") + } + } + + // Check that unauthorized command was rejected + unauthorizedCommandRan, ok := plugin.store.GetOk("unauthorizedCommandRan") + require.True(t, ok, "unauthorizedCommandRan should be set in store") + assert.False(t, unauthorizedCommandRan.(bool), "Unauthorized command should not have run") + + unauthorizedCommandError, ok := plugin.store.GetOk("unauthorizedCommandError") + require.True(t, ok, "unauthorizedCommandError should be set in store") + assert.Contains(t, unauthorizedCommandError.(string), "not authorized", "Error should indicate command was not authorized") + + manager.PrintPluginPoolMetrics(opts.ID) +} + +func TestGojaPluginSystemAsyncCommand(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create a test file to use with commands + testFilePath := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFilePath, []byte("Hello, world!"), 0644) + require.NoError(t, err) + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing async command execution"); + + // Test executing an async command + try { + // Create a command to list files + let asyncCmd = $osExtra.asyncCmd("ls", "-la", "${TEMP_DIR}"); + + let output = ""; + asyncCmd.run((data, err, exitCode, signal) => { + // console.log(data, err, exitCode, signal) + if (data) { + // console.log("Async command data:", $toString(data)); + output += $toString(data) + "\n"; + $store.set("asyncCommandData", $toString(output)); + } + if (err) { + console.log("Async command error:", $toString(err)); + $store.set("asyncCommandError", $toString(err)); + } + if (exitCode !== undefined) { + console.log("output 1", output) + console.log("Async command exit code:", exitCode); + $store.set("asyncCommandExitCode", exitCode); + console.log("Async command signal:", signal); + $store.set("asyncCommandSignal", signal); + } + }); + + console.log("Running second command") + + let asyncCmd2 = $osExtra.asyncCmd("ls", "-la", "${TEMP_DIR}"); + + let output2 = ""; + asyncCmd2.run((data, err, exitCode, signal) => { + // console.log(data, err, exitCode, signal) + if (data) { + // console.log("Async command data:", $toString(data)); + output2 += $toString(data) + "\n"; + $store.set("asyncCommandData", $toString(output2)); + } + if (err) { + console.log("Async command error:", $toString(err)); + $store.set("asyncCommandError", $toString(err)); + } + if (exitCode !== undefined) { + console.log("output 2", output2) + console.log("Async command exit code:", exitCode); + $store.set("asyncCommandExitCode", exitCode); + console.log("Async command signal:", signal); + $store.set("asyncCommandSignal", signal); + } + }); + + } catch (e) { + console.log("Command execution error:", e.message); + $store.set("asyncCommandError", e.message); + } + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${TEMP_DIR}", tempDir) + payload = strings.ReplaceAll(payload, "${TEST_FILE_PATH}", testFilePath) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + CommandScopes: []extension.CommandScope{ + { + Command: "ls", + Args: []extension.CommandArg{ + {Value: "-la"}, + {Validator: "$PATH"}, + }, + }, + }, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(2 * time.Second) + + // Check the store values for the ls command + asyncCommandData, ok := plugin.store.GetOk("asyncCommandData") + require.True(t, ok, "asyncCommandData should be set in store") + assert.Contains(t, asyncCommandData.(string), "test.txt", "Command output should contain the test file") + + asyncCommandExitCode, ok := plugin.store.GetOk("asyncCommandExitCode") + require.True(t, ok, "asyncCommandExitCode should be set in store") + assert.Equal(t, int64(0), asyncCommandExitCode, "Command exit code should be 0") + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemUnzip tests the unzip functionality in the osExtra module +func TestGojaPluginSystemUnzip(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create a test zip file + zipPath := filepath.Join(tempDir, "test.zip") + extractPath := filepath.Join(tempDir, "extracted") + + // Create a zip file with test content + err := createTestZipFile(zipPath) + require.NoError(t, err, "Failed to create test zip file") + + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing $osExtra unzip functionality"); + + try { + // Create extraction directory + $os.mkdirAll("${EXTRACT_PATH}", 0755); + + // Unzip the file + $osExtra.unzip("${ZIP_PATH}", "${EXTRACT_PATH}"); + console.log("Unzip successful"); + $store.set("unzipSuccess", true); + + // Check if files were extracted + const entries = $os.readDir("${EXTRACT_PATH}"); + const fileNames = entries.map(entry => entry.name()); + console.log("Extracted files:", fileNames); + $store.set("extractedFiles", fileNames); + + // Read content of extracted file + const content = $os.readFile("${EXTRACT_PATH}/test.txt"); + console.log("Extracted content:", $toString(content)); + $store.set("extractedContent", $toString(content)); + + // Try to unzip to an unauthorized location + try { + $osExtra.unzip("${ZIP_PATH}", "/tmp/unauthorized"); + $store.set("unauthorizedUnzipSuccess", true); + } catch (e) { + console.log("Unauthorized unzip error:", e.message); + $store.set("unauthorizedUnzipError", e.message); + $store.set("unauthorizedUnzipSuccess", false); + } + + } catch (e) { + console.log("Unzip error:", e.message); + $store.set("unzipError", e.message); + } + }); +} + ` + + // Replace placeholders with actual paths + payload = strings.ReplaceAll(payload, "${ZIP_PATH}", zipPath) + payload = strings.ReplaceAll(payload, "${EXTRACT_PATH}", extractPath) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{tempDir + "/**/*"}, + WritePaths: []string{tempDir + "/**/*"}, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check the store values + unzipSuccess, ok := plugin.store.GetOk("unzipSuccess") + require.True(t, ok, "unzipSuccess should be set in store") + assert.True(t, unzipSuccess.(bool), "Unzip should have succeeded") + + extractedFiles, ok := plugin.store.GetOk("extractedFiles") + require.True(t, ok, "extractedFiles should be set in store") + filesSlice, ok := extractedFiles.([]interface{}) + require.True(t, ok, "extractedFiles should be a slice") + assert.Contains(t, filesSlice, "test.txt", "Extracted files should include test.txt") + + extractedContent, ok := plugin.store.GetOk("extractedContent") + require.True(t, ok, "extractedContent should be set in store") + assert.Equal(t, "Test content for zip file", extractedContent, "Extracted content should match original") + + // Check unauthorized unzip attempt + unauthorizedUnzipSuccess, ok := plugin.store.GetOk("unauthorizedUnzipSuccess") + require.True(t, ok, "unauthorizedUnzipSuccess should be set in store") + assert.False(t, unauthorizedUnzipSuccess.(bool), "Unauthorized unzip should have failed") + + unauthorizedUnzipError, ok := plugin.store.GetOk("unauthorizedUnzipError") + require.True(t, ok, "unauthorizedUnzipError should be set in store") + assert.Contains(t, unauthorizedUnzipError.(string), "not authorized", "Error should indicate path not authorized") + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// TestGojaPluginSystemMime tests the mime module functionality +func TestGojaPluginSystemMime(t *testing.T) { + payload := ` +function init() { + $ui.register((ctx) => { + console.log("Testing $mime functionality"); + + try { + // Test parsing content type + const contentType = "text/html; charset=utf-8"; + const parsed = $mime.parse(contentType); + console.log("Parsed content type:", parsed); + $store.set("parsedContentType", parsed); + + // Test parsing content type with multiple parameters + const contentTypeWithParams = "application/json; charset=utf-8; boundary=something"; + const parsedWithParams = $mime.parse(contentTypeWithParams); + console.log("Parsed content type with params:", parsedWithParams); + $store.set("parsedContentTypeWithParams", parsedWithParams); + + // Test formatting content type + const formatted = $mime.format("text/plain", { charset: "utf-8", boundary: "boundary" }); + console.log("Formatted content type:", formatted); + $store.set("formattedContentType", formatted); + + // Test parsing invalid content type + try { + const invalidContentType = "invalid content type"; + const parsedInvalid = $mime.parse(invalidContentType); + console.log("Parsed invalid content type:", parsedInvalid); + $store.set("parsedInvalidContentType", parsedInvalid); + } catch (e) { + console.log("Invalid content type error:", e.message); + $store.set("invalidContentTypeError", e.message); + } + } catch (e) { + console.log("Mime test error:", e.message); + $store.set("mimeTestError", e.message); + } + }); +} + ` + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + // Wait for the plugin to execute + time.Sleep(1 * time.Second) + + // Check the store values for parsed content type + parsedContentType, ok := plugin.store.GetOk("parsedContentType") + require.True(t, ok, "parsedContentType should be set in store") + parsedMap, ok := parsedContentType.(map[string]interface{}) + require.True(t, ok, "parsedContentType should be a map") + + // Check media type + mediaType, ok := parsedMap["mediaType"] + require.True(t, ok, "mediaType should be in parsed result") + assert.Equal(t, "text/html", mediaType, "Media type should be text/html") + + // Check parameters + parameters, ok := parsedMap["parameters"] + require.True(t, ok, "parameters should be in parsed result") + paramsMap, ok := parameters.(map[string]string) + require.Truef(t, ok, "parameters should be a map but got %T", parameters) + assert.Equal(t, "utf-8", paramsMap["charset"], "charset parameter should be utf-8") + + // Check parsed content type with multiple parameters + parsedWithParams, ok := plugin.store.GetOk("parsedContentTypeWithParams") + require.True(t, ok, "parsedContentTypeWithParams should be set in store") + parsedWithParamsMap, ok := parsedWithParams.(map[string]interface{}) + require.Truef(t, ok, "parsedContentTypeWithParams should be a map but got %T", parsedWithParams) + + // Check media type + mediaTypeWithParams, ok := parsedWithParamsMap["mediaType"] + require.True(t, ok, "mediaType should be in parsed result") + assert.Equal(t, "application/json", mediaTypeWithParams, "Media type should be application/json") + + // Check parameters + parametersWithParams, ok := parsedWithParamsMap["parameters"] + require.True(t, ok, "parameters should be in parsed result") + require.Truef(t, ok, "parameters should be a map but got %T", parametersWithParams) + + // Check formatted content type + formattedContentType, ok := plugin.store.GetOk("formattedContentType") + require.True(t, ok, "formattedContentType should be set in store") + assert.Contains(t, formattedContentType.(string), "text/plain", "Formatted content type should contain text/plain") + assert.Contains(t, formattedContentType.(string), "charset=utf-8", "Formatted content type should contain charset=utf-8") + assert.Contains(t, formattedContentType.(string), "boundary=boundary", "Formatted content type should contain boundary=boundary") + + // Check invalid content type error + invalidContentTypeError, ok := plugin.store.GetOk("invalidContentTypeError") + if ok { + assert.NotEmpty(t, invalidContentTypeError, "Invalid content type should have produced an error") + } + + manager.PrintPluginPoolMetrics(opts.ID) +} + +// Helper function to create a test zip file +func createTestZipFile(zipPath string) error { + // Create a buffer to write our zip to + zipFile, err := os.Create(zipPath) + if err != nil { + return err + } + defer zipFile.Close() + + // Create a new zip archive + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Add a file to the archive + fileWriter, err := zipWriter.Create("test.txt") + if err != nil { + return err + } + + // Write content to the file + _, err = fileWriter.Write([]byte("Test content for zip file")) + if err != nil { + return err + } + + // Add a directory to the archive + _, err = zipWriter.Create("testdir/") + if err != nil { + return err + } + + // Add a file in the directory + dirFileWriter, err := zipWriter.Create("testdir/nested.txt") + if err != nil { + return err + } + + // Write content to the nested file + _, err = dirFileWriter.Write([]byte("Nested file content")) + if err != nil { + return err + } + + return nil +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_plugin_test.go b/seanime-2.9.10/internal/extension_repo/goja_plugin_test.go new file mode 100644 index 0000000..f836bec --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_plugin_test.go @@ -0,0 +1,798 @@ +package extension_repo + +import ( + "fmt" + "net/http" + "net/http/httptest" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/continuity" + "seanime/internal/database/db" + "seanime/internal/events" + "seanime/internal/extension" + "seanime/internal/goja/goja_runtime" + "seanime/internal/hook" + "seanime/internal/library/fillermanager" + "seanime/internal/library/playbackmanager" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/mediaplayers/mpv" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/plugin" + "seanime/internal/test_utils" + "seanime/internal/util" + "seanime/internal/util/filecache" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/samber/lo" + "github.com/stretchr/testify/require" +) + +var ( + testDocumentsDir = "/Users/rahim/Documents" + testDocumentCollectionDir = "/Users/rahim/Documents/collection" + testVideoPath = "/Users/rahim/Documents/collection/Bocchi the Rock/[ASW] Bocchi the Rock! - 01 [1080p HEVC][EDC91675].mkv" + + tempTestDir = "$TEMP/test" +) + +// TestPluginOptions contains options for initializing a test plugin +type TestPluginOptions struct { + ID string + Payload string + Language extension.Language + Permissions extension.PluginPermissions + PoolSize int + SetupHooks bool +} + +// DefaultTestPluginOptions returns default options for a test plugin +func DefaultTestPluginOptions() TestPluginOptions { + return TestPluginOptions{ + ID: "dummy-plugin", + Payload: "", + Language: extension.LanguageJavascript, + Permissions: extension.PluginPermissions{}, + PoolSize: 15, + SetupHooks: true, + } +} + +// InitTestPlugin initializes a test plugin with the given options +func InitTestPlugin(t testing.TB, opts TestPluginOptions) (*GojaPlugin, *zerolog.Logger, *goja_runtime.Manager, *anilist_platform.AnilistPlatform, events.WSEventManagerInterface, error) { + if opts.SetupHooks { + test_utils.SetTwoLevelDeep() + if tPtr, ok := t.(*testing.T); ok { + test_utils.InitTestProvider(tPtr, test_utils.Anilist()) + } + } + + ext := &extension.Extension{ + ID: opts.ID, + Payload: opts.Payload, + Language: opts.Language, + Plugin: &extension.PluginManifest{}, + } + + if len(opts.Permissions.Scopes) > 0 { + ext.Plugin = &extension.PluginManifest{ + Permissions: opts.Permissions, + } + } + + ext.Plugin.Permissions.Allow = opts.Permissions.Allow + + logger := util.NewLogger() + wsEventManager := events.NewMockWSEventManager(logger) + anilistClient := anilist.NewMockAnilistClient() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger).(*anilist_platform.AnilistPlatform) + + // Initialize hook manager if needed + if opts.SetupHooks { + hm := hook.NewHookManager(hook.NewHookManagerOptions{Logger: logger}) + hook.SetGlobalHookManager(hm) + } + + manager := goja_runtime.NewManager(logger) + + database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger) + require.NoError(t, err) + + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + Database: database, + AnilistPlatform: anilistPlatform, + WSEventManager: wsEventManager, + AnimeLibraryPaths: &[]string{}, + PlaybackManager: &playbackmanager.PlaybackManager{}, + }) + + plugin, _, err := NewGojaPlugin(ext, opts.Language, logger, manager, wsEventManager) + return plugin, logger, manager, anilistPlatform, wsEventManager, err +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +func TestGojaPluginAnime(t *testing.T) { + payload := ` + function init() { + + $ui.register(async (ctx) => { + try { + console.log("Fetching anime entry"); + console.log(typeof ctx.anime.getAnimeEntry) +ctx.anime.getAnimeEntry(21) + //ctx.anime.getAnimeEntry(21).then((anime) => { + // console.log("Anime", anime) + //}).catch((e) => { + // console.error("Error fetching anime entry", e) + //}) + } catch (e) { + console.error("Error fetching anime entry", e) + } + }) + } + ` + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionAnilist, + extension.PluginPermissionDatabase, + }, + } + logger := util.NewLogger() + database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger) + require.NoError(t, err) + + metadataProvider := metadata.NewProvider(&metadata.NewProviderImplOptions{ + FileCacher: lo.Must(filecache.NewCacher(t.TempDir())), + Logger: logger, + }) + + fillerManager := fillermanager.New(&fillermanager.NewFillerManagerOptions{ + Logger: logger, + DB: database, + }) + + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + Database: database, + MetadataProvider: metadataProvider, + FillerManager: fillerManager, + }) + + _, logger, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + manager.PrintPluginPoolMetrics(opts.ID) + + time.Sleep(3 * time.Second) +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +func TestGojaPluginMpv(t *testing.T) { + payload := fmt.Sprintf(` +function init() { + + $ui.register(async (ctx) => { + + console.log("Testing MPV"); + + await ctx.mpv.openAndPlay("%s") + + const cancel = ctx.mpv.onEvent((event) => { + console.log("Event received", event) + }) + + ctx.setTimeout(() => { + const conn = ctx.mpv.getConnection() + if (conn) { + conn.call("set_property", "pause", true) + } + }, 3000) + + ctx.setTimeout(async () => { + console.log("Cancelling event listener") + cancel() + await ctx.mpv.stop() + }, 5000) + }); + +} + `, testVideoPath) + + playbackManager, _, err := getPlaybackManager(t) + require.NoError(t, err) + + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + PlaybackManager: playbackManager, + }) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionPlayback, + }, + } + + _, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + manager.PrintPluginPoolMetrics(opts.ID) + + time.Sleep(8 * time.Second) +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +// Test that the plugin cannot access paths that are not allowed +// $os.readDir should throw an error +func TestGojaPluginPathNotAllowed(t *testing.T) { + payload := fmt.Sprintf(` +function init() { + $ui.register((ctx) => { + + const tempDir = $os.tempDir(); + console.log("Temp dir", tempDir); + + const dirPath = "%s"; + const entries = $os.readDir(dirPath); + }); +} + `, testDocumentCollectionDir) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{"$TEMP/*", testDocumentsDir}, + WritePaths: []string{"$TEMP/*"}, + }, + } + + _, _, manager, _, _, err := InitTestPlugin(t, opts) + require.Error(t, err) + + manager.PrintPluginPoolMetrics(opts.ID) + +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +// Test that the plugin can play a video and listen to events +func TestGojaPluginPlaybackEvents(t *testing.T) { + payload := fmt.Sprintf(` +function init() { + + $ui.register((ctx) => { + console.log("Testing Playback"); + + const cancel = ctx.playback.registerEventListener("mySubscriber", (event) => { + console.log("Event received", event) + }) + + ctx.playback.playUsingMediaPlayer("%s") + + ctx.setTimeout(() => { + console.log("Cancelling event listener") + cancel() + }, 15000) + }); + +} + `, testVideoPath) + + playbackManager, _, err := getPlaybackManager(t) + require.NoError(t, err) + + plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{ + PlaybackManager: playbackManager, + }) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionPlayback, + }, + } + + _, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + manager.PrintPluginPoolMetrics(opts.ID) + + time.Sleep(16 * time.Second) +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +// Tests that we can register hooks and the UI handler. +// Tests that the state updates correctly and effects run as expected. +// Tests that we can fetch data from an external source. +func TestGojaPluginUIAndHooks(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + time.Sleep(1000 * time.Millisecond) + fmt.Fprint(w, `{"test": "data"}`) + })) + defer server.Close() + + payload := fmt.Sprintf(` + function init() { + + $app.onGetAnime(async (e) => { + const url = "%s" + + // const res = $await(fetch(url)) + const res = await fetch(url) + const data = res.json() + console.log("fetched results in hook", data) + $store.set("data", data) + + console.log("first hook fired"); + + e.next(); + }); + + $app.onGetAnime(async (e) => { + console.log("results from first hook", $store.get("data")); + + e.next(); + }); + + $ui.register((ctx) => { + const url = "%s" + console.log("this is the start"); + + const count = ctx.state(0) + + ctx.effect(async () => { + console.log("running effect that takes 1s") + ctx.setTimeout(() => { + console.log("1s elapsed since first effect called") + }, 1000) + const [a, b, c, d, e, f] = await Promise.all([ + ctx.fetch("https://jsonplaceholder.typicode.com/todos/1"), + ctx.fetch("https://jsonplaceholder.typicode.com/todos/2"), + ctx.fetch("https://jsonplaceholder.typicode.com/todos/3"), + ctx.fetch("https://jsonplaceholder.typicode.com/todos/3"), + ctx.fetch("https://jsonplaceholder.typicode.com/todos/3"), + ctx.fetch(url), + ]) + console.log("fetch results", a.json(), b.json(), c.json(), d.json(), e.json(), f.json()) + }, [count]) + + ctx.effect(() => { + console.log("running effect that runs fast ran second") + }, [count]) + + count.set(p => p+1) + + console.log("this is the end"); + }); + + } + `, server.URL, server.URL) + + opts := DefaultTestPluginOptions() + opts.Payload = payload + + _, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + go func() { + time.Sleep(time.Second) + _, err := anilistPlatform.GetAnime(t.Context(), 178022) + if err != nil { + t.Errorf("GetAnime returned error: %v", err) + } + + // _, err = anilistPlatform.GetAnime(177709) + // if err != nil { + // t.Errorf("GetAnime returned error: %v", err) + // } + }() + + manager.PrintPluginPoolMetrics(opts.ID) + + time.Sleep(3 * time.Second) +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +func TestGojaPluginStore(t *testing.T) { + payload := ` + function init() { + + $app.onGetAnime((e) => { + + $store.set("anime", e.anime); + $store.set("value", 42); + + e.next(); + }); + + $app.onGetAnime((e) => { + + console.log("Hook 2, value", $store.get("value")); + console.log("Hook 2, value 2", $store.get("value2")); + + e.next(); + }); + + } + ` + + opts := DefaultTestPluginOptions() + opts.Payload = payload + + plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + m, err := anilistPlatform.GetAnime(t.Context(), 178022) + if err != nil { + t.Fatalf("GetAnime returned error: %v", err) + } + + util.Spew(m.Title) + util.Spew(m.Synonyms) + + m, err = anilistPlatform.GetAnime(t.Context(), 177709) + if err != nil { + t.Fatalf("GetAnime returned error: %v", err) + } + + util.Spew(m.Title) + + value := plugin.store.Get("value") + require.NotNil(t, value) + + manager.PrintPluginPoolMetrics(opts.ID) +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +func TestGojaPluginJsonFieldNames(t *testing.T) { + payload := ` + function init() { + + $app.onPreUpdateEntryProgress((e) => { + console.log("pre update entry progress", e) + + $store.set("mediaId", e.mediaId); + + e.next(); + }); + + } + ` + + opts := DefaultTestPluginOptions() + opts.Payload = payload + + plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + err = anilistPlatform.UpdateEntryProgress(t.Context(), 178022, 1, lo.ToPtr(1)) + if err != nil { + t.Fatalf("GetAnime returned error: %v", err) + } + + mediaId := plugin.store.Get("mediaId") + require.NotNil(t, mediaId) + + manager.PrintPluginPoolMetrics(opts.ID) +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +func TestGojaPluginAnilistCustomQuery(t *testing.T) { + payload := ` + function init() { + $ui.register((ctx) => { + const token = $database.anilist.getToken() + try { + const res = $anilist.customQuery({ query:` + "`" + ` + query GetOnePiece { + Media(id: 21) { + title { + romaji + english + native + userPreferred + } + airingSchedule(perPage: 1, page: 1) { + nodes { + episode + airingAt + } + } + id + } + } + ` + "`" + `, variables: {}}, token); + + console.log("res", res) + } catch (e) { + console.error("Error fetching anime list", e); + } + }); + } + ` + + opts := DefaultTestPluginOptions() + opts.Payload = payload + + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionAnilist, + extension.PluginPermissionAnilistToken, + extension.PluginPermissionDatabase, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + _ = plugin + + manager.PrintPluginPoolMetrics(opts.ID) +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +func TestGojaPluginAnilistListAnime(t *testing.T) { + payload := ` + function init() { + + $ui.register((ctx) => { + + try { + const res = $anilist.listRecentAnime(1, 15, undefined, undefined, undefined) + console.log("res", res) + } catch (e) { + console.error("Error fetching anime list", e) + } + + }) + } + ` + opts := DefaultTestPluginOptions() + opts.Payload = payload + + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionAnilist, + }, + } + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + _ = plugin + + manager.PrintPluginPoolMetrics(opts.ID) +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +func TestGojaPluginStorage(t *testing.T) { + payload := ` + function init() { + + $app.onGetAnime((e) => { + + if ($storage.get("foo") !== "qux") { + throw new Error("foo should be qux") + } + + $storage.set("foo", "anime") + console.log("foo", $storage.get("foo")) + $store.set("expectedValue4", "anime") + + e.next(); + }); + + $ui.register((ctx) => { + + $storage.set("foo", "bar") + console.log("foo", $storage.get("foo")) + + $store.set("expectedValue1", "bar") + + ctx.setTimeout(() => { + console.log("foo", $storage.get("foo")) + $storage.set("foo", "baz") + console.log("foo", $storage.get("foo")) + $store.set("expectedValue2", "baz") + }, 1000) + + ctx.setTimeout(() => { + console.log("foo", $storage.get("foo")) + $storage.set("foo", "qux") + console.log("foo", $storage.get("foo")) + $store.set("expectedValue3", "qux") + }, 1500) + + }) + + } + ` + + opts := DefaultTestPluginOptions() + opts.Payload = payload + opts.Permissions = extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionDatabase, + extension.PluginPermissionStorage, + }, + } + + plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + _ = plugin + + manager.PrintPluginPoolMetrics(opts.ID) + + time.Sleep(2 * time.Second) + + _, err = anilistPlatform.GetAnime(t.Context(), 178022) + require.NoError(t, err) + + expectedValue1 := plugin.store.Get("expectedValue1") + require.Equal(t, "bar", expectedValue1) + + expectedValue2 := plugin.store.Get("expectedValue2") + require.Equal(t, "baz", expectedValue2) + + expectedValue3 := plugin.store.Get("expectedValue3") + require.Equal(t, "qux", expectedValue3) + + expectedValue4 := plugin.store.Get("expectedValue4") + require.Equal(t, "anime", expectedValue4) + +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +func TestGojaPluginTryCatch(t *testing.T) { + payload := ` + function init() { + + $ui.register((ctx) => { + try { + throw new Error("test error") + } catch (e) { + console.log("catch", e) + $store.set("error", e) + } + + try { + undefined.f() + } catch (e) { + console.log("catch 2", e) + $store.set("error2", e) + } + + }) + + } + ` + + opts := DefaultTestPluginOptions() + opts.Payload = payload + + plugin, _, manager, _, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + + manager.PrintPluginPoolMetrics(opts.ID) + + err1 := plugin.store.Get("error") + require.NotNil(t, err1) + + err2 := plugin.store.Get("error2") + require.NotNil(t, err2) +} + +///////////////////////////////////////////////////////////////////////////////////////////// + +func TestGojaSharedMemory(t *testing.T) { + payload := ` + function init() { + + $ui.register((ctx) => { + const state = ctx.state("test") + + $store.set("state", state) + + }) + + $app.onGetAnime((e) => { + const state = $store.get("state") + console.log("state", state) + console.log("state value", state.get()) + e.next(); + }) + + } + ` + + opts := DefaultTestPluginOptions() + opts.Payload = payload + + plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts) + require.NoError(t, err) + _ = plugin + + manager.PrintPluginPoolMetrics(opts.ID) + + _, err = anilistPlatform.GetAnime(t.Context(), 178022) + require.NoError(t, err) + + time.Sleep(2 * time.Second) +} + +/////////////////////////////////////////////////////////////////////////////////////////////s + +func getPlaybackManager(t *testing.T) (*playbackmanager.PlaybackManager, *anilist.AnimeCollection, error) { + + logger := util.NewLogger() + + wsEventManager := events.NewMockWSEventManager(logger) + + database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger) + + if err != nil { + t.Fatalf("error while creating database, %v", err) + } + + filecacher, err := filecache.NewCacher(t.TempDir()) + require.NoError(t, err) + anilistClient := anilist.TestGetMockAnilistClient() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), true) + metadataProvider := metadata.GetMockProvider(t) + require.NoError(t, err) + continuityManager := continuity.NewManager(&continuity.NewManagerOptions{ + FileCacher: filecacher, + Logger: logger, + Database: database, + }) + + playbackManager := playbackmanager.New(&playbackmanager.NewPlaybackManagerOptions{ + WSEventManager: wsEventManager, + Logger: logger, + Platform: anilistPlatform, + MetadataProvider: metadataProvider, + Database: database, + RefreshAnimeCollectionFunc: func() { + // Do nothing + }, + DiscordPresence: nil, + IsOffline: lo.ToPtr(false), + ContinuityManager: continuityManager, + }) + + playbackManager.SetAnimeCollection(animeCollection) + playbackManager.SetSettings(&playbackmanager.Settings{ + AutoPlayNextEpisode: false, + }) + + playbackManager.SetMediaPlayerRepository(mediaplayer.NewRepository(&mediaplayer.NewRepositoryOptions{ + Mpv: mpv.New(logger, "", ""), + Logger: logger, + Default: "mpv", + ContinuityManager: continuityManager, + })) + + return playbackManager, animeCollection, nil +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_plugin_types/app.d.ts b/seanime-2.9.10/internal/extension_repo/goja_plugin_types/app.d.ts new file mode 100644 index 0000000..c7f7c4f --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_plugin_types/app.d.ts @@ -0,0 +1,3678 @@ +declare namespace $app { + + /** + * @package anilist + */ + + /** + * @event ListMissedSequelsRequestedEvent + * @file internal/api/anilist/hook_events.go + * @description + * ListMissedSequelsRequestedEvent is triggered when the list missed sequels request is requested. + * Prevent default to skip the default behavior and return your own data. + */ + function onListMissedSequelsRequested(cb: (event: ListMissedSequelsRequestedEvent) => void): void; + + interface ListMissedSequelsRequestedEvent { + next(): void; + + preventDefault(): void; + + animeCollectionWithRelations?: AL_AnimeCollectionWithRelations; + variables?: Record; + query: string; + list?: Array; + } + + /** + * @event ListMissedSequelsEvent + * @file internal/api/anilist/hook_events.go + */ + function onListMissedSequels(cb: (event: ListMissedSequelsEvent) => void): void; + + interface ListMissedSequelsEvent { + next(): void; + + list?: Array; + } + + + /** + * @package anilist_platform + */ + + /** + * @event GetAnimeEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetAnime(cb: (event: GetAnimeEvent) => void): void; + + interface GetAnimeEvent { + next(): void; + + anime?: AL_BaseAnime; + } + + /** + * @event GetAnimeDetailsEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetAnimeDetails(cb: (event: GetAnimeDetailsEvent) => void): void; + + interface GetAnimeDetailsEvent { + next(): void; + + anime?: AL_AnimeDetailsById_Media; + } + + /** + * @event GetMangaEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetManga(cb: (event: GetMangaEvent) => void): void; + + interface GetMangaEvent { + next(): void; + + manga?: AL_BaseManga; + } + + /** + * @event GetMangaDetailsEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetMangaDetails(cb: (event: GetMangaDetailsEvent) => void): void; + + interface GetMangaDetailsEvent { + next(): void; + + manga?: AL_MangaDetailsById_Media; + } + + /** + * @event GetCachedAnimeCollectionEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetCachedAnimeCollection(cb: (event: GetCachedAnimeCollectionEvent) => void): void; + + interface GetCachedAnimeCollectionEvent { + next(): void; + + animeCollection?: AL_AnimeCollection; + } + + /** + * @event GetCachedMangaCollectionEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetCachedMangaCollection(cb: (event: GetCachedMangaCollectionEvent) => void): void; + + interface GetCachedMangaCollectionEvent { + next(): void; + + mangaCollection?: AL_MangaCollection; + } + + /** + * @event GetAnimeCollectionEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetAnimeCollection(cb: (event: GetAnimeCollectionEvent) => void): void; + + interface GetAnimeCollectionEvent { + next(): void; + + animeCollection?: AL_AnimeCollection; + } + + /** + * @event GetMangaCollectionEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetMangaCollection(cb: (event: GetMangaCollectionEvent) => void): void; + + interface GetMangaCollectionEvent { + next(): void; + + mangaCollection?: AL_MangaCollection; + } + + /** + * @event GetCachedRawAnimeCollectionEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetCachedRawAnimeCollection(cb: (event: GetCachedRawAnimeCollectionEvent) => void): void; + + interface GetCachedRawAnimeCollectionEvent { + next(): void; + + animeCollection?: AL_AnimeCollection; + } + + /** + * @event GetCachedRawMangaCollectionEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetCachedRawMangaCollection(cb: (event: GetCachedRawMangaCollectionEvent) => void): void; + + interface GetCachedRawMangaCollectionEvent { + next(): void; + + mangaCollection?: AL_MangaCollection; + } + + /** + * @event GetRawAnimeCollectionEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetRawAnimeCollection(cb: (event: GetRawAnimeCollectionEvent) => void): void; + + interface GetRawAnimeCollectionEvent { + next(): void; + + animeCollection?: AL_AnimeCollection; + } + + /** + * @event GetRawMangaCollectionEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetRawMangaCollection(cb: (event: GetRawMangaCollectionEvent) => void): void; + + interface GetRawMangaCollectionEvent { + next(): void; + + mangaCollection?: AL_MangaCollection; + } + + /** + * @event GetStudioDetailsEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onGetStudioDetails(cb: (event: GetStudioDetailsEvent) => void): void; + + interface GetStudioDetailsEvent { + next(): void; + + studio?: AL_StudioDetails; + } + + /** + * @event PreUpdateEntryEvent + * @file internal/platforms/anilist_platform/hook_events.go + * @description + * PreUpdateEntryEvent is triggered when an entry is about to be updated. + * Prevent default to skip the default update and override the update. + */ + function onPreUpdateEntry(cb: (event: PreUpdateEntryEvent) => void): void; + + interface PreUpdateEntryEvent { + next(): void; + + preventDefault(): void; + + mediaId?: number; + status?: AL_MediaListStatus; + scoreRaw?: number; + progress?: number; + startedAt?: AL_FuzzyDateInput; + completedAt?: AL_FuzzyDateInput; + } + + /** + * @event PostUpdateEntryEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onPostUpdateEntry(cb: (event: PostUpdateEntryEvent) => void): void; + + interface PostUpdateEntryEvent { + next(): void; + + mediaId?: number; + } + + /** + * @event PreUpdateEntryProgressEvent + * @file internal/platforms/anilist_platform/hook_events.go + * @description + * PreUpdateEntryProgressEvent is triggered when an entry's progress is about to be updated. + * Prevent default to skip the default update and override the update. + */ + function onPreUpdateEntryProgress(cb: (event: PreUpdateEntryProgressEvent) => void): void; + + interface PreUpdateEntryProgressEvent { + next(): void; + + preventDefault(): void; + + mediaId?: number; + progress?: number; + totalCount?: number; + status?: AL_MediaListStatus; + } + + /** + * @event PostUpdateEntryProgressEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onPostUpdateEntryProgress(cb: (event: PostUpdateEntryProgressEvent) => void): void; + + interface PostUpdateEntryProgressEvent { + next(): void; + + mediaId?: number; + } + + /** + * @event PreUpdateEntryRepeatEvent + * @file internal/platforms/anilist_platform/hook_events.go + * @description + * PreUpdateEntryRepeatEvent is triggered when an entry's repeat is about to be updated. + * Prevent default to skip the default update and override the update. + */ + function onPreUpdateEntryRepeat(cb: (event: PreUpdateEntryRepeatEvent) => void): void; + + interface PreUpdateEntryRepeatEvent { + next(): void; + + preventDefault(): void; + + mediaId?: number; + repeat?: number; + } + + /** + * @event PostUpdateEntryRepeatEvent + * @file internal/platforms/anilist_platform/hook_events.go + */ + function onPostUpdateEntryRepeat(cb: (event: PostUpdateEntryRepeatEvent) => void): void; + + interface PostUpdateEntryRepeatEvent { + next(): void; + + mediaId?: number; + } + + + /** + * @package animap + */ + + /** + * @event AnimapMediaRequestedEvent + * @file internal/api/animap/hook_events.go + * @description + * AnimapMediaRequestedEvent is triggered when the Animap media is requested. + * Prevent default to skip the default behavior and return your own data. + */ + function onAnimapMediaRequested(cb: (event: AnimapMediaRequestedEvent) => void): void; + + interface AnimapMediaRequestedEvent { + next(): void; + + preventDefault(): void; + + from: string; + id: number; + media?: Animap_Anime; + } + + /** + * @event AnimapMediaEvent + * @file internal/api/animap/hook_events.go + * @description + * AnimapMediaEvent is triggered after processing AnimapMedia. + */ + function onAnimapMedia(cb: (event: AnimapMediaEvent) => void): void; + + interface AnimapMediaEvent { + next(): void; + + media?: Animap_Anime; + } + + + /** + * @package anime + */ + + /** + * @event AnimeEntryRequestedEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeEntryRequestedEvent is triggered when an anime entry is requested. + * Prevent default to skip the default behavior and return the modified entry. + * This event is triggered before [AnimeEntryEvent]. + * If the modified entry is nil, an error will be returned. + */ + function onAnimeEntryRequested(cb: (event: AnimeEntryRequestedEvent) => void): void; + + interface AnimeEntryRequestedEvent { + next(): void; + + preventDefault(): void; + + mediaId: number; + localFiles?: Array; + animeCollection?: AL_AnimeCollection; + entry?: Anime_Entry; + } + + /** + * @event AnimeEntryEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeEntryEvent is triggered when the media entry is being returned. + * This event is triggered after [AnimeEntryRequestedEvent]. + */ + function onAnimeEntry(cb: (event: AnimeEntryEvent) => void): void; + + interface AnimeEntryEvent { + next(): void; + + entry?: Anime_Entry; + } + + /** + * @event AnimeEntryFillerHydrationEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeEntryFillerHydrationEvent is triggered when the filler data is being added to the media entry. + * This event is triggered after [AnimeEntryEvent]. + * Prevent default to skip the filler data. + */ + function onAnimeEntryFillerHydration(cb: (event: AnimeEntryFillerHydrationEvent) => void): void; + + interface AnimeEntryFillerHydrationEvent { + next(): void; + + preventDefault(): void; + + entry?: Anime_Entry; + } + + /** + * @event AnimeEntryLibraryDataRequestedEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeEntryLibraryDataRequestedEvent is triggered when the app requests the library data for a media entry. + * This is triggered before [AnimeEntryLibraryDataEvent]. + */ + function onAnimeEntryLibraryDataRequested(cb: (event: AnimeEntryLibraryDataRequestedEvent) => void): void; + + interface AnimeEntryLibraryDataRequestedEvent { + next(): void; + + entryLocalFiles?: Array; + mediaId: number; + currentProgress: number; + } + + /** + * @event AnimeEntryLibraryDataEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeEntryLibraryDataEvent is triggered when the library data is being added to the media entry. + * This is triggered after [AnimeEntryLibraryDataRequestedEvent]. + */ + function onAnimeEntryLibraryData(cb: (event: AnimeEntryLibraryDataEvent) => void): void; + + interface AnimeEntryLibraryDataEvent { + next(): void; + + entryLibraryData?: Anime_EntryLibraryData; + } + + /** + * @event AnimeEntryManualMatchBeforeSaveEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeEntryManualMatchBeforeSaveEvent is triggered when the user manually matches local files to a media entry. + * Prevent default to skip saving the local files. + */ + function onAnimeEntryManualMatchBeforeSave(cb: (event: AnimeEntryManualMatchBeforeSaveEvent) => void): void; + + interface AnimeEntryManualMatchBeforeSaveEvent { + next(): void; + + preventDefault(): void; + + mediaId: number; + paths?: Array; + matchedLocalFiles?: Array; + } + + /** + * @event MissingEpisodesRequestedEvent + * @file internal/library/anime/hook_events.go + * @description + * MissingEpisodesRequestedEvent is triggered when the user requests the missing episodes for the entire library. + * Prevent default to skip the default process and return the modified missing episodes. + */ + function onMissingEpisodesRequested(cb: (event: MissingEpisodesRequestedEvent) => void): void; + + interface MissingEpisodesRequestedEvent { + next(): void; + + preventDefault(): void; + + animeCollection?: AL_AnimeCollection; + localFiles?: Array; + silencedMediaIds?: Array; + missingEpisodes?: Anime_MissingEpisodes; + } + + /** + * @event MissingEpisodesEvent + * @file internal/library/anime/hook_events.go + * @description + * MissingEpisodesEvent is triggered when the missing episodes are being returned. + */ + function onMissingEpisodes(cb: (event: MissingEpisodesEvent) => void): void; + + interface MissingEpisodesEvent { + next(): void; + + missingEpisodes?: Anime_MissingEpisodes; + } + + /** + * @event AnimeLibraryCollectionRequestedEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeLibraryCollectionRequestedEvent is triggered when the user requests the library collection. + * Prevent default to skip the default process and return the modified library collection. + * If the modified library collection is nil, an error will be returned. + */ + function onAnimeLibraryCollectionRequested(cb: (event: AnimeLibraryCollectionRequestedEvent) => void): void; + + interface AnimeLibraryCollectionRequestedEvent { + next(): void; + + preventDefault(): void; + + animeCollection?: AL_AnimeCollection; + localFiles?: Array; + libraryCollection?: Anime_LibraryCollection; + } + + /** + * @event AnimeLibraryCollectionEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeLibraryCollectionEvent is triggered when the user requests the library collection. + */ + function onAnimeLibraryCollection(cb: (event: AnimeLibraryCollectionEvent) => void): void; + + interface AnimeLibraryCollectionEvent { + next(): void; + + libraryCollection?: Anime_LibraryCollection; + } + + /** + * @event AnimeLibraryStreamCollectionRequestedEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeLibraryStreamCollectionRequestedEvent is triggered when the user requests the library stream collection. + * This is called when the user enables "Include in library" for either debrid/online/torrent streamings. + */ + function onAnimeLibraryStreamCollectionRequested(cb: (event: AnimeLibraryStreamCollectionRequestedEvent) => void): void; + + interface AnimeLibraryStreamCollectionRequestedEvent { + next(): void; + + animeCollection?: AL_AnimeCollection; + libraryCollection?: Anime_LibraryCollection; + } + + /** + * @event AnimeLibraryStreamCollectionEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeLibraryStreamCollectionEvent is triggered when the library stream collection is being returned. + */ + function onAnimeLibraryStreamCollection(cb: (event: AnimeLibraryStreamCollectionEvent) => void): void; + + interface AnimeLibraryStreamCollectionEvent { + next(): void; + + streamCollection?: Anime_StreamCollection; + } + + /** + * @event AnimeEntryDownloadInfoRequestedEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeEntryDownloadInfoRequestedEvent is triggered when the app requests the download info for a media entry. + * This is triggered before [AnimeEntryDownloadInfoEvent]. + */ + function onAnimeEntryDownloadInfoRequested(cb: (event: AnimeEntryDownloadInfoRequestedEvent) => void): void; + + interface AnimeEntryDownloadInfoRequestedEvent { + next(): void; + + localFiles?: Array; + AnimeMetadata?: Metadata_AnimeMetadata; + Media?: AL_BaseAnime; + Progress?: number; + Status?: AL_MediaListStatus; + entryDownloadInfo?: Anime_EntryDownloadInfo; + } + + /** + * @event AnimeEntryDownloadInfoEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeEntryDownloadInfoEvent is triggered when the download info is being returned. + */ + function onAnimeEntryDownloadInfo(cb: (event: AnimeEntryDownloadInfoEvent) => void): void; + + interface AnimeEntryDownloadInfoEvent { + next(): void; + + entryDownloadInfo?: Anime_EntryDownloadInfo; + } + + /** + * @event AnimeEpisodeCollectionRequestedEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeEpisodeCollectionRequestedEvent is triggered when the episode collection is being requested. + * Prevent default to skip the default behavior and return your own data. + */ + function onAnimeEpisodeCollectionRequested(cb: (event: AnimeEpisodeCollectionRequestedEvent) => void): void; + + interface AnimeEpisodeCollectionRequestedEvent { + next(): void; + + preventDefault(): void; + + media?: AL_BaseAnime; + metadata?: Metadata_AnimeMetadata; + episodeCollection?: Anime_EpisodeCollection; + } + + /** + * @event AnimeEpisodeCollectionEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeEpisodeCollectionEvent is triggered when the episode collection is being returned. + */ + function onAnimeEpisodeCollection(cb: (event: AnimeEpisodeCollectionEvent) => void): void; + + interface AnimeEpisodeCollectionEvent { + next(): void; + + episodeCollection?: Anime_EpisodeCollection; + } + + /** + * @event AnimeScheduleItemsEvent + * @file internal/library/anime/hook_events.go + * @description + * AnimeScheduleItemsEvent is triggered when the schedule items are being returned. + */ + function onAnimeScheduleItems(cb: (event: AnimeScheduleItemsEvent) => void): void; + + interface AnimeScheduleItemsEvent { + animeCollection?: AL_AnimeCollection; + items?: Array; + + next(): void; + } + + + /** + * @package anizip + */ + + /** + * @event AnizipMediaRequestedEvent + * @file internal/api/anizip/hook_events.go + * @description + * AnizipMediaRequestedEvent is triggered when the AniZip media is requested. + * Prevent default to skip the default behavior and return your own data. + */ + function onAnizipMediaRequested(cb: (event: AnizipMediaRequestedEvent) => void): void; + + interface AnizipMediaRequestedEvent { + next(): void; + + preventDefault(): void; + + from: string; + id: number; + media?: Anizip_Media; + } + + /** + * @event AnizipMediaEvent + * @file internal/api/anizip/hook_events.go + * @description + * AnizipMediaEvent is triggered after processing AnizipMedia. + */ + function onAnizipMedia(cb: (event: AnizipMediaEvent) => void): void; + + interface AnizipMediaEvent { + next(): void; + + media?: Anizip_Media; + } + + + /** + * @package autodownloader + */ + + /** + * @event AutoDownloaderRunStartedEvent + * @file internal/library/autodownloader/hook_events.go + * @description + * AutoDownloaderRunStartedEvent is triggered when the autodownloader starts checking for new episodes. + * Prevent default to abort the run. + */ + function onAutoDownloaderRunStarted(cb: (event: AutoDownloaderRunStartedEvent) => void): void; + + interface AutoDownloaderRunStartedEvent { + next(): void; + + preventDefault(): void; + + rules?: Array; + } + + /** + * @event AutoDownloaderTorrentsFetchedEvent + * @file internal/library/autodownloader/hook_events.go + * @description + * AutoDownloaderTorrentsFetchedEvent is triggered at the beginning of a run, when the autodownloader fetches torrents from the provider. + */ + function onAutoDownloaderTorrentsFetched(cb: (event: AutoDownloaderTorrentsFetchedEvent) => void): void; + + interface AutoDownloaderTorrentsFetchedEvent { + next(): void; + + torrents?: Array; + } + + /** + * @event AutoDownloaderMatchVerifiedEvent + * @file internal/library/autodownloader/hook_events.go + * @description + * AutoDownloaderMatchVerifiedEvent is triggered when a torrent is verified to follow a rule. + * Prevent default to abort the download if the match is found. + */ + function onAutoDownloaderMatchVerified(cb: (event: AutoDownloaderMatchVerifiedEvent) => void): void; + + interface AutoDownloaderMatchVerifiedEvent { + next(): void; + + preventDefault(): void; + + torrent?: AutoDownloader_NormalizedTorrent; + rule?: Anime_AutoDownloaderRule; + listEntry?: AL_AnimeListEntry; + localEntry?: Anime_LocalFileWrapperEntry; + episode: number; + matchFound: boolean; + } + + /** + * @event AutoDownloaderSettingsUpdatedEvent + * @file internal/library/autodownloader/hook_events.go + * @description + * AutoDownloaderSettingsUpdatedEvent is triggered when the autodownloader settings are updated + */ + function onAutoDownloaderSettingsUpdated(cb: (event: AutoDownloaderSettingsUpdatedEvent) => void): void; + + interface AutoDownloaderSettingsUpdatedEvent { + next(): void; + + settings?: Models_AutoDownloaderSettings; + } + + /** + * @event AutoDownloaderBeforeDownloadTorrentEvent + * @file internal/library/autodownloader/hook_events.go + * @description + * AutoDownloaderBeforeDownloadTorrentEvent is triggered when the autodownloader is about to download a torrent. + * Prevent default to abort the download. + */ + function onAutoDownloaderBeforeDownloadTorrent(cb: (event: AutoDownloaderBeforeDownloadTorrentEvent) => void): void; + + interface AutoDownloaderBeforeDownloadTorrentEvent { + next(): void; + + preventDefault(): void; + + torrent?: AutoDownloader_NormalizedTorrent; + rule?: Anime_AutoDownloaderRule; + items?: Array; + } + + /** + * @event AutoDownloaderAfterDownloadTorrentEvent + * @file internal/library/autodownloader/hook_events.go + * @description + * AutoDownloaderAfterDownloadTorrentEvent is triggered when the autodownloader has downloaded a torrent. + */ + function onAutoDownloaderAfterDownloadTorrent(cb: (event: AutoDownloaderAfterDownloadTorrentEvent) => void): void; + + interface AutoDownloaderAfterDownloadTorrentEvent { + next(): void; + + torrent?: AutoDownloader_NormalizedTorrent; + rule?: Anime_AutoDownloaderRule; + } + + + /** + * @package continuity + */ + + /** + * @event WatchHistoryItemRequestedEvent + * @file internal/continuity/hook_events.go + * @description + * WatchHistoryItemRequestedEvent is triggered when a watch history item is requested. + * Prevent default to skip getting the watch history item from the file cache, in this case the event should have a valid WatchHistoryItem object + * or set it to nil to indicate that the watch history item was not found. + */ + function onWatchHistoryItemRequested(cb: (event: WatchHistoryItemRequestedEvent) => void): void; + + interface WatchHistoryItemRequestedEvent { + next(): void; + + preventDefault(): void; + + mediaId: number; + watchHistoryItem?: Continuity_WatchHistoryItem; + } + + /** + * @event WatchHistoryItemUpdatedEvent + * @file internal/continuity/hook_events.go + * @description + * WatchHistoryItemUpdatedEvent is triggered when a watch history item is updated. + */ + function onWatchHistoryItemUpdated(cb: (event: WatchHistoryItemUpdatedEvent) => void): void; + + interface WatchHistoryItemUpdatedEvent { + next(): void; + + watchHistoryItem?: Continuity_WatchHistoryItem; + } + + /** + * @event WatchHistoryLocalFileEpisodeItemRequestedEvent + * @file internal/continuity/hook_events.go + */ + function onWatchHistoryLocalFileEpisodeItemRequested(cb: (event: WatchHistoryLocalFileEpisodeItemRequestedEvent) => void): void; + + interface WatchHistoryLocalFileEpisodeItemRequestedEvent { + next(): void; + + Path: string; + LocalFiles?: Array; + watchHistoryItem?: Continuity_WatchHistoryItem; + } + + /** + * @event WatchHistoryStreamEpisodeItemRequestedEvent + * @file internal/continuity/hook_events.go + */ + function onWatchHistoryStreamEpisodeItemRequested(cb: (event: WatchHistoryStreamEpisodeItemRequestedEvent) => void): void; + + interface WatchHistoryStreamEpisodeItemRequestedEvent { + next(): void; + + Episode: number; + MediaId: number; + watchHistoryItem?: Continuity_WatchHistoryItem; + } + + + /** + * @package debrid_client + */ + + /** + * @event DebridAutoSelectTorrentsFetchedEvent + * @file internal/debrid/client/hook_events.go + * @description + * DebridAutoSelectTorrentsFetchedEvent is triggered when the torrents are fetched for auto select. + * The torrents are sorted by seeders from highest to lowest. + * This event is triggered before the top 3 torrents are analyzed. + */ + function onDebridAutoSelectTorrentsFetched(cb: (event: DebridAutoSelectTorrentsFetchedEvent) => void): void; + + interface DebridAutoSelectTorrentsFetchedEvent { + next(): void; + + Torrents?: Array; + } + + /** + * @event DebridSkipStreamCheckEvent + * @file internal/debrid/client/hook_events.go + * @description + * DebridSkipStreamCheckEvent is triggered when the debrid client is about to skip the stream check. + * Prevent default to enable the stream check. + */ + function onDebridSkipStreamCheck(cb: (event: DebridSkipStreamCheckEvent) => void): void; + + interface DebridSkipStreamCheckEvent { + next(): void; + + preventDefault(): void; + + streamURL: string; + retries: number; + /** + * in seconds + */ + retryDelay: number; + } + + /** + * @event DebridSendStreamToMediaPlayerEvent + * @file internal/debrid/client/hook_events.go + * @description + * DebridSendStreamToMediaPlayerEvent is triggered when the debrid client is about to send a stream to the media player. + * Prevent default to skip the playback. + */ + function onDebridSendStreamToMediaPlayer(cb: (event: DebridSendStreamToMediaPlayerEvent) => void): void; + + interface DebridSendStreamToMediaPlayerEvent { + next(): void; + + preventDefault(): void; + + windowTitle: string; + streamURL: string; + media?: AL_BaseAnime; + aniDbEpisode: string; + playbackType: string; + } + + /** + * @event DebridLocalDownloadRequestedEvent + * @file internal/debrid/client/hook_events.go + * @description + * DebridLocalDownloadRequestedEvent is triggered when Seanime is about to download a debrid torrent locally. + * Prevent default to skip the default download and override the download. + */ + function onDebridLocalDownloadRequested(cb: (event: DebridLocalDownloadRequestedEvent) => void): void; + + interface DebridLocalDownloadRequestedEvent { + next(): void; + + preventDefault(): void; + + torrentName: string; + destination: string; + downloadUrl: string; + } + + + /** + * @package discordrpc_presence + */ + + /** + * @event DiscordPresenceAnimeActivityRequestedEvent + * @file internal/discordrpc/presence/hook_events.go + * @description + * DiscordPresenceAnimeActivityRequestedEvent is triggered when anime activity is requested, after the [animeActivity] is processed, and right + * before the activity is sent to queue. There is no guarantee as to when or if the activity will be successfully sent to discord. Note that + * this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. Prevent default to + * stop the activity from being sent to discord. + */ + function onDiscordPresenceAnimeActivityRequested(cb: (event: DiscordPresenceAnimeActivityRequestedEvent) => void): void; + + interface DiscordPresenceAnimeActivityRequestedEvent { + next(): void; + + preventDefault(): void; + + animeActivity?: DiscordRPC_AnimeActivity; + name: string; + details: string; + detailsUrl: string; + state: string; + startTimestamp?: number; + endTimestamp?: number; + largeImage: string; + largeText: string; + /** + * URL to large image, if any + */ + largeUrl?: string; + smallImage: string; + smallText: string; + /** + * URL to small image, if any + */ + smallUrl?: string; + buttons?: Array; + instance: boolean; + type: number; + statusDisplayType?: number; + } + + /** + * @event DiscordPresenceMangaActivityRequestedEvent + * @file internal/discordrpc/presence/hook_events.go + * @description + * DiscordPresenceMangaActivityRequestedEvent is triggered when manga activity is requested, after the [mangaActivity] is processed, and right + * before the activity is sent to queue. There is no guarantee as to when or if the activity will be successfully sent to discord. Note that + * this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. Prevent default to + * stop the activity from being sent to discord. + */ + function onDiscordPresenceMangaActivityRequested(cb: (event: DiscordPresenceMangaActivityRequestedEvent) => void): void; + + interface DiscordPresenceMangaActivityRequestedEvent { + next(): void; + + preventDefault(): void; + + mangaActivity?: DiscordRPC_MangaActivity; + name: string; + details: string; + detailsUrl: string; + state: string; + startTimestamp?: number; + endTimestamp?: number; + largeImage: string; + largeText: string; + /** + * URL to large image, if any + */ + largeUrl?: string; + smallImage: string; + smallText: string; + /** + * URL to small image, if any + */ + smallUrl?: string; + buttons?: Array; + instance: boolean; + type: number; + statusDisplayType?: number; + } + + /** + * @event DiscordPresenceClientClosedEvent + * @file internal/discordrpc/presence/hook_events.go + * @description + * DiscordPresenceClientClosedEvent is triggered when the discord rpc client is closed. + */ + function onDiscordPresenceClientClosed(cb: (event: DiscordPresenceClientClosedEvent) => void): void; + + interface DiscordPresenceClientClosedEvent { + next(): void; + + } + + + /** + * @package fillermanager + */ + + /** + * @event HydrateFillerDataRequestedEvent + * @file internal/library/fillermanager/hook_events.go + * @description + * HydrateFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for an entry. + * This is used by the local file episode list. + * Prevent default to skip the default behavior and return your own data. + */ + function onHydrateFillerDataRequested(cb: (event: HydrateFillerDataRequestedEvent) => void): void; + + interface HydrateFillerDataRequestedEvent { + entry?: Anime_Entry; + + next(): void; + + preventDefault(): void; + } + + /** + * @event HydrateOnlinestreamFillerDataRequestedEvent + * @file internal/library/fillermanager/hook_events.go + * @description + * HydrateOnlinestreamFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for online streaming + * episodes. This is used by the online streaming episode list. Prevent default to skip the default behavior and return your own data. + */ + function onHydrateOnlinestreamFillerDataRequested(cb: (event: HydrateOnlinestreamFillerDataRequestedEvent) => void): void; + + interface HydrateOnlinestreamFillerDataRequestedEvent { + episodes?: Array; + + next(): void; + + preventDefault(): void; + } + + /** + * @event HydrateEpisodeFillerDataRequestedEvent + * @file internal/library/fillermanager/hook_events.go + * @description + * HydrateEpisodeFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for specific episodes. + * This is used by the torrent and debrid streaming episode list. + * Prevent default to skip the default behavior and return your own data. + */ + function onHydrateEpisodeFillerDataRequested(cb: (event: HydrateEpisodeFillerDataRequestedEvent) => void): void; + + interface HydrateEpisodeFillerDataRequestedEvent { + episodes?: Array; + + next(): void; + + preventDefault(): void; + } + + + /** + * @package manga + */ + + /** + * @event MangaEntryRequestedEvent + * @file internal/manga/hook_events.go + * @description + * MangaEntryRequestedEvent is triggered when a manga entry is requested. + * Prevent default to skip the default behavior and return the modified entry. + * If the modified entry is nil, an error will be returned. + */ + function onMangaEntryRequested(cb: (event: MangaEntryRequestedEvent) => void): void; + + interface MangaEntryRequestedEvent { + next(): void; + + preventDefault(): void; + + mediaId: number; + mangaCollection?: AL_MangaCollection; + entry?: Manga_Entry; + } + + /** + * @event MangaEntryEvent + * @file internal/manga/hook_events.go + * @description + * MangaEntryEvent is triggered when the manga entry is being returned. + */ + function onMangaEntry(cb: (event: MangaEntryEvent) => void): void; + + interface MangaEntryEvent { + next(): void; + + entry?: Manga_Entry; + } + + /** + * @event MangaLibraryCollectionRequestedEvent + * @file internal/manga/hook_events.go + * @description + * MangaLibraryCollectionRequestedEvent is triggered when the manga library collection is being requested. + */ + function onMangaLibraryCollectionRequested(cb: (event: MangaLibraryCollectionRequestedEvent) => void): void; + + interface MangaLibraryCollectionRequestedEvent { + next(): void; + + mangaCollection?: AL_MangaCollection; + } + + /** + * @event MangaLibraryCollectionEvent + * @file internal/manga/hook_events.go + * @description + * MangaLibraryCollectionEvent is triggered when the manga library collection is being returned. + */ + function onMangaLibraryCollection(cb: (event: MangaLibraryCollectionEvent) => void): void; + + interface MangaLibraryCollectionEvent { + next(): void; + + libraryCollection?: Manga_Collection; + } + + /** + * @event MangaDownloadedChapterContainersRequestedEvent + * @file internal/manga/hook_events.go + * @description + * MangaDownloadedChapterContainersRequestedEvent is triggered when the manga downloaded chapter containers are being requested. + * Prevent default to skip the default behavior and return the modified chapter containers. + * If the modified chapter containers are nil, an error will be returned. + */ + function onMangaDownloadedChapterContainersRequested(cb: (event: MangaDownloadedChapterContainersRequestedEvent) => void): void; + + interface MangaDownloadedChapterContainersRequestedEvent { + next(): void; + + preventDefault(): void; + + mangaCollection?: AL_MangaCollection; + chapterContainers?: Array; + } + + /** + * @event MangaDownloadedChapterContainersEvent + * @file internal/manga/hook_events.go + * @description + * MangaDownloadedChapterContainersEvent is triggered when the manga downloaded chapter containers are being returned. + */ + function onMangaDownloadedChapterContainers(cb: (event: MangaDownloadedChapterContainersEvent) => void): void; + + interface MangaDownloadedChapterContainersEvent { + next(): void; + + chapterContainers?: Array; + } + + /** + * @event MangaLatestChapterNumbersMapEvent + * @file internal/manga/hook_events.go + * @description + * MangaLatestChapterNumbersMapEvent is triggered when the manga latest chapter numbers map is being returned. + */ + function onMangaLatestChapterNumbersMap(cb: (event: MangaLatestChapterNumbersMapEvent) => void): void; + + interface MangaLatestChapterNumbersMapEvent { + next(): void; + + latestChapterNumbersMap?: Record>; + } + + /** + * @event MangaDownloadMapEvent + * @file internal/manga/hook_events.go + * @description + * MangaDownloadMapEvent is triggered when the manga download map has been updated. + * This map is used to tell the client which chapters have been downloaded. + */ + function onMangaDownloadMap(cb: (event: MangaDownloadMapEvent) => void): void; + + interface MangaDownloadMapEvent { + next(): void; + + mediaMap?: Manga_MediaMap; + } + + /** + * @event MangaChapterContainerRequestedEvent + * @file internal/manga/hook_events.go + * @description + * MangaChapterContainerRequestedEvent is triggered when the manga chapter container is being requested. + * This event happens before the chapter container is fetched from the cache or provider. + * Prevent default to skip the default behavior and return the modified chapter container. + * If the modified chapter container is nil, an error will be returned. + */ + function onMangaChapterContainerRequested(cb: (event: MangaChapterContainerRequestedEvent) => void): void; + + interface MangaChapterContainerRequestedEvent { + next(): void; + + preventDefault(): void; + + provider: string; + mediaId: number; + titles?: Array; + year: number; + chapterContainer?: Manga_ChapterContainer; + } + + /** + * @event MangaChapterContainerEvent + * @file internal/manga/hook_events.go + * @description + * MangaChapterContainerEvent is triggered when the manga chapter container is being returned. + * This event happens after the chapter container is fetched from the cache or provider. + */ + function onMangaChapterContainer(cb: (event: MangaChapterContainerEvent) => void): void; + + interface MangaChapterContainerEvent { + next(): void; + + chapterContainer?: Manga_ChapterContainer; + } + + + /** + * @package mediaplayer + */ + + /** + * @event MediaPlayerLocalFileTrackingRequestedEvent + * @file internal/mediaplayers/mediaplayer/hook_events.go + * @description + * MediaPlayerLocalFileTrackingRequestedEvent is triggered when the playback manager wants to track the progress of a local file. + * Prevent default to stop tracking. + */ + function onMediaPlayerLocalFileTrackingRequested(cb: (event: MediaPlayerLocalFileTrackingRequestedEvent) => void): void; + + interface MediaPlayerLocalFileTrackingRequestedEvent { + next(): void; + + preventDefault(): void; + + startRefreshDelay: number; + refreshDelay: number; + maxRetries: number; + } + + /** + * @event MediaPlayerStreamTrackingRequestedEvent + * @file internal/mediaplayers/mediaplayer/hook_events.go + * @description + * MediaPlayerStreamTrackingRequestedEvent is triggered when the playback manager wants to track the progress of a stream. + * Prevent default to stop tracking. + */ + function onMediaPlayerStreamTrackingRequested(cb: (event: MediaPlayerStreamTrackingRequestedEvent) => void): void; + + interface MediaPlayerStreamTrackingRequestedEvent { + next(): void; + + preventDefault(): void; + + startRefreshDelay: number; + refreshDelay: number; + maxRetries: number; + maxRetriesAfterStart: number; + } + + + /** + * @package metadata + */ + + /** + * @event AnimeMetadataRequestedEvent + * @file internal/api/metadata/hook_events.go + * @description + * AnimeMetadataRequestedEvent is triggered when anime metadata is requested and right before the metadata is processed. + * This event is followed by [AnimeMetadataEvent] which is triggered when the metadata is available. + * Prevent default to skip the default behavior and return the modified metadata. + * If the modified metadata is nil, an error will be returned. + */ + function onAnimeMetadataRequested(cb: (event: AnimeMetadataRequestedEvent) => void): void; + + interface AnimeMetadataRequestedEvent { + next(): void; + + preventDefault(): void; + + mediaId: number; + animeMetadata?: Metadata_AnimeMetadata; + } + + /** + * @event AnimeMetadataEvent + * @file internal/api/metadata/hook_events.go + * @description + * AnimeMetadataEvent is triggered when anime metadata is available and is about to be returned. + * Anime metadata can be requested in many places, ranging from displaying the anime entry to starting a torrent stream. + * This event is triggered after [AnimeMetadataRequestedEvent]. + * If the modified metadata is nil, an error will be returned. + */ + function onAnimeMetadata(cb: (event: AnimeMetadataEvent) => void): void; + + interface AnimeMetadataEvent { + next(): void; + + mediaId: number; + animeMetadata?: Metadata_AnimeMetadata; + } + + /** + * @event AnimeEpisodeMetadataRequestedEvent + * @file internal/api/metadata/hook_events.go + * @description + * AnimeEpisodeMetadataRequestedEvent is triggered when anime episode metadata is requested. + * Prevent default to skip the default behavior and return the overridden metadata. + * This event is triggered before [AnimeEpisodeMetadataEvent]. + * If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned. + */ + function onAnimeEpisodeMetadataRequested(cb: (event: AnimeEpisodeMetadataRequestedEvent) => void): void; + + interface AnimeEpisodeMetadataRequestedEvent { + next(): void; + + preventDefault(): void; + + animeEpisodeMetadata?: Metadata_EpisodeMetadata; + episodeNumber: number; + mediaId: number; + } + + /** + * @event AnimeEpisodeMetadataEvent + * @file internal/api/metadata/hook_events.go + * @description + * AnimeEpisodeMetadataEvent is triggered when anime episode metadata is available and is about to be returned. + * In the current implementation, episode metadata is requested for display purposes. It is used to get a more complete metadata object since the + * original AnimeMetadata object is not complete. This event is triggered after [AnimeEpisodeMetadataRequestedEvent]. If the modified episode + * metadata is nil, an empty EpisodeMetadata object will be returned. + */ + function onAnimeEpisodeMetadata(cb: (event: AnimeEpisodeMetadataEvent) => void): void; + + interface AnimeEpisodeMetadataEvent { + next(): void; + + animeEpisodeMetadata?: Metadata_EpisodeMetadata; + episodeNumber: number; + mediaId: number; + } + + + /** + * @package playbackmanager + */ + + /** + * @event LocalFilePlaybackRequestedEvent + * @file internal/library/playbackmanager/hook_events.go + * @description + * LocalFilePlaybackRequestedEvent is triggered when a local file is requested to be played. + * Prevent default to skip the default playback and override the playback. + */ + function onLocalFilePlaybackRequested(cb: (event: LocalFilePlaybackRequestedEvent) => void): void; + + interface LocalFilePlaybackRequestedEvent { + next(): void; + + preventDefault(): void; + + path: string; + } + + /** + * @event StreamPlaybackRequestedEvent + * @file internal/library/playbackmanager/hook_events.go + * @description + * StreamPlaybackRequestedEvent is triggered when a stream is requested to be played. + * Prevent default to skip the default playback and override the playback. + */ + function onStreamPlaybackRequested(cb: (event: StreamPlaybackRequestedEvent) => void): void; + + interface StreamPlaybackRequestedEvent { + next(): void; + + preventDefault(): void; + + windowTitle: string; + payload: string; + media?: AL_BaseAnime; + aniDbEpisode: string; + } + + /** + * @event PlaybackBeforeTrackingEvent + * @file internal/library/playbackmanager/hook_events.go + * @description + * PlaybackBeforeTrackingEvent is triggered just before the playback tracking starts. + * Prevent default to skip playback tracking. + */ + function onPlaybackBeforeTracking(cb: (event: PlaybackBeforeTrackingEvent) => void): void; + + interface PlaybackBeforeTrackingEvent { + next(): void; + + preventDefault(): void; + + isStream: boolean; + } + + /** + * @event PlaybackLocalFileDetailsRequestedEvent + * @file internal/library/playbackmanager/hook_events.go + * @description + * PlaybackLocalFileDetailsRequestedEvent is triggered when the local files details for a specific path are requested. + * This event is triggered right after the media player loads an episode. + * The playback manager uses the local files details to track the progress, propose next episodes, etc. + * In the current implementation, the details are fetched by selecting the local file from the database and making requests to retrieve the media + * and anime list entry. Prevent default to skip the default fetching and override the details. + */ + function onPlaybackLocalFileDetailsRequested(cb: (event: PlaybackLocalFileDetailsRequestedEvent) => void): void; + + interface PlaybackLocalFileDetailsRequestedEvent { + next(): void; + + preventDefault(): void; + + path: string; + localFiles?: Array; + animeListEntry?: AL_AnimeListEntry; + localFile?: Anime_LocalFile; + localFileWrapperEntry?: Anime_LocalFileWrapperEntry; + } + + /** + * @event PlaybackStreamDetailsRequestedEvent + * @file internal/library/playbackmanager/hook_events.go + * @description + * PlaybackStreamDetailsRequestedEvent is triggered when the stream details are requested. + * Prevent default to skip the default fetching and override the details. + * In the current implementation, the details are fetched by selecting the anime from the anime collection. If nothing is found, the stream is + * still tracked. + */ + function onPlaybackStreamDetailsRequested(cb: (event: PlaybackStreamDetailsRequestedEvent) => void): void; + + interface PlaybackStreamDetailsRequestedEvent { + next(): void; + + preventDefault(): void; + + animeCollection?: AL_AnimeCollection; + mediaId: number; + animeListEntry?: AL_AnimeListEntry; + } + + + /** + * @package scanner + */ + + /** + * @event ScanStartedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanStartedEvent is triggered when the scanning process begins. + * Prevent default to skip the rest of the scanning process and return the local files. + */ + function onScanStarted(cb: (event: ScanStartedEvent) => void): void; + + interface ScanStartedEvent { + next(): void; + + preventDefault(): void; + + libraryPath: string; + otherLibraryPaths?: Array; + enhanced: boolean; + skipLocked: boolean; + skipIgnored: boolean; + localFiles?: Array; + } + + /** + * @event ScanFilePathsRetrievedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanFilePathsRetrievedEvent is triggered when the file paths to scan are retrieved. + * The event includes file paths from all directories to scan. + * The event includes file paths of local files that will be skipped. + */ + function onScanFilePathsRetrieved(cb: (event: ScanFilePathsRetrievedEvent) => void): void; + + interface ScanFilePathsRetrievedEvent { + next(): void; + + filePaths?: Array; + } + + /** + * @event ScanLocalFilesParsedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanLocalFilesParsedEvent is triggered right after the file paths are parsed into local file objects. + * The event does not include local files that are skipped. + */ + function onScanLocalFilesParsed(cb: (event: ScanLocalFilesParsedEvent) => void): void; + + interface ScanLocalFilesParsedEvent { + next(): void; + + localFiles?: Array; + } + + /** + * @event ScanCompletedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanCompletedEvent is triggered when the scanning process finishes. + * The event includes all the local files (skipped and scanned) to be inserted as a new entry. + * Right after this event, the local files will be inserted as a new entry. + */ + function onScanCompleted(cb: (event: ScanCompletedEvent) => void): void; + + interface ScanCompletedEvent { + next(): void; + + localFiles?: Array; + /** + * in milliseconds + */ + duration: number; + } + + /** + * @event ScanMediaFetcherStartedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanMediaFetcherStartedEvent is triggered right before Seanime starts fetching media to be matched against the local files. + */ + function onScanMediaFetcherStarted(cb: (event: ScanMediaFetcherStartedEvent) => void): void; + + interface ScanMediaFetcherStartedEvent { + next(): void; + + enhanced: boolean; + } + + /** + * @event ScanMediaFetcherCompletedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanMediaFetcherCompletedEvent is triggered when the media fetcher completes. + * The event includes all the media fetched from AniList. + * The event includes the media IDs that are not in the user's collection. + */ + function onScanMediaFetcherCompleted(cb: (event: ScanMediaFetcherCompletedEvent) => void): void; + + interface ScanMediaFetcherCompletedEvent { + next(): void; + + allMedia?: Array; + unknownMediaIds?: Array; + } + + /** + * @event ScanMatchingStartedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanMatchingStartedEvent is triggered when the matching process begins. + * Prevent default to skip the default matching, in which case modified local files will be used. + */ + function onScanMatchingStarted(cb: (event: ScanMatchingStartedEvent) => void): void; + + interface ScanMatchingStartedEvent { + next(): void; + + preventDefault(): void; + + localFiles?: Array; + normalizedMedia?: Array; + algorithm: string; + threshold: number; + } + + /** + * @event ScanLocalFileMatchedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanLocalFileMatchedEvent is triggered when a local file is matched with media and before the match is analyzed. + * Prevent default to skip the default analysis and override the match. + */ + function onScanLocalFileMatched(cb: (event: ScanLocalFileMatchedEvent) => void): void; + + interface ScanLocalFileMatchedEvent { + next(): void; + + preventDefault(): void; + + match?: Anime_NormalizedMedia; + found: boolean; + localFile?: Anime_LocalFile; + score: number; + } + + /** + * @event ScanMatchingCompletedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanMatchingCompletedEvent is triggered when the matching process completes. + */ + function onScanMatchingCompleted(cb: (event: ScanMatchingCompletedEvent) => void): void; + + interface ScanMatchingCompletedEvent { + next(): void; + + localFiles?: Array; + } + + /** + * @event ScanHydrationStartedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanHydrationStartedEvent is triggered when the file hydration process begins. + * Prevent default to skip the rest of the hydration process, in which case the event's local files will be used. + */ + function onScanHydrationStarted(cb: (event: ScanHydrationStartedEvent) => void): void; + + interface ScanHydrationStartedEvent { + next(): void; + + preventDefault(): void; + + localFiles?: Array; + allMedia?: Array; + } + + /** + * @event ScanLocalFileHydrationStartedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanLocalFileHydrationStartedEvent is triggered when a local file's metadata is about to be hydrated. + * Prevent default to skip the default hydration and override the hydration. + */ + function onScanLocalFileHydrationStarted(cb: (event: ScanLocalFileHydrationStartedEvent) => void): void; + + interface ScanLocalFileHydrationStartedEvent { + next(): void; + + preventDefault(): void; + + localFile?: Anime_LocalFile; + media?: Anime_NormalizedMedia; + } + + /** + * @event ScanLocalFileHydratedEvent + * @file internal/library/scanner/hook_events.go + * @description + * ScanLocalFileHydratedEvent is triggered when a local file's metadata is hydrated + */ + function onScanLocalFileHydrated(cb: (event: ScanLocalFileHydratedEvent) => void): void; + + interface ScanLocalFileHydratedEvent { + next(): void; + + localFile?: Anime_LocalFile; + mediaId: number; + episode: number; + } + + + /** + * @package torrentstream + */ + + /** + * @event TorrentStreamAutoSelectTorrentsFetchedEvent + * @file internal/torrentstream/hook_events.go + * @description + * TorrentStreamAutoSelectTorrentsFetchedEvent is triggered when the torrents are fetched for auto select. + * The torrents are sorted by seeders from highest to lowest. + * This event is triggered before the top 3 torrents are analyzed. + */ + function onTorrentStreamAutoSelectTorrentsFetched(cb: (event: TorrentStreamAutoSelectTorrentsFetchedEvent) => void): void; + + interface TorrentStreamAutoSelectTorrentsFetchedEvent { + next(): void; + + Torrents?: Array; + } + + /** + * @event TorrentStreamSendStreamToMediaPlayerEvent + * @file internal/torrentstream/hook_events.go + * @description + * TorrentStreamSendStreamToMediaPlayerEvent is triggered when the torrent stream is about to send a stream to the media player. + * Prevent default to skip the default playback and override the playback. + */ + function onTorrentStreamSendStreamToMediaPlayer(cb: (event: TorrentStreamSendStreamToMediaPlayerEvent) => void): void; + + interface TorrentStreamSendStreamToMediaPlayerEvent { + next(): void; + + preventDefault(): void; + + windowTitle: string; + streamURL: string; + media?: AL_BaseAnime; + aniDbEpisode: string; + playbackType: string; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollection { + MediaListCollection?: AL_AnimeCollection_MediaListCollection; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollectionWithRelations { + MediaListCollection?: AL_AnimeCollectionWithRelations_MediaListCollection; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollectionWithRelations_MediaListCollection { + lists?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollectionWithRelations_MediaListCollection_Lists { + status?: AL_MediaListStatus; + name?: string; + isCustomList?: boolean; + entries?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries { + id: number; + score?: number; + progress?: number; + status?: AL_MediaListStatus; + notes?: string; + repeat?: number; + private?: boolean; + startedAt?: AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt; + completedAt?: AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt; + media?: AL_CompleteAnime; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_CompletedAt { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollectionWithRelations_MediaListCollection_Lists_Entries_StartedAt { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollection_MediaListCollection { + lists?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollection_MediaListCollection_Lists { + status?: AL_MediaListStatus; + name?: string; + isCustomList?: boolean; + entries?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollection_MediaListCollection_Lists_Entries { + id: number; + score?: number; + progress?: number; + status?: AL_MediaListStatus; + notes?: string; + repeat?: number; + private?: boolean; + startedAt?: AL_AnimeCollection_MediaListCollection_Lists_Entries_StartedAt; + completedAt?: AL_AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt; + media?: AL_BaseAnime; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeCollection_MediaListCollection_Lists_Entries_StartedAt { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media { + siteUrl?: string; + id: number; + duration?: number; + genres?: Array; + averageScore?: number; + popularity?: number; + meanScore?: number; + description?: string; + trailer?: AL_AnimeDetailsById_Media_Trailer; + startDate?: AL_AnimeDetailsById_Media_StartDate; + endDate?: AL_AnimeDetailsById_Media_EndDate; + studios?: AL_AnimeDetailsById_Media_Studios; + characters?: AL_AnimeDetailsById_Media_Characters; + staff?: AL_AnimeDetailsById_Media_Staff; + rankings?: Array; + recommendations?: AL_AnimeDetailsById_Media_Recommendations; + relations?: AL_AnimeDetailsById_Media_Relations; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Characters { + edges?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Characters_Edges { + id?: number; + role?: AL_CharacterRole; + name?: string; + node?: AL_BaseCharacter; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_EndDate { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Rankings { + context: string; + type: AL_MediaRankType; + rank: number; + year?: number; + format: AL_MediaFormat; + allTime?: boolean; + season?: AL_MediaSeason; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Recommendations { + edges?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Recommendations_Edges { + node?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Recommendations_Edges_Node { + mediaRecommendation?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation { + id: number; + idMal?: number; + siteUrl?: string; + status?: AL_MediaStatus; + isAdult?: boolean; + season?: AL_MediaSeason; + type?: AL_MediaType; + format?: AL_MediaFormat; + meanScore?: number; + description?: string; + episodes?: number; + trailer?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer; + startDate?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate; + coverImage?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage; + bannerImage?: string; + title?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage { + extraLarge?: string; + large?: string; + medium?: string; + color?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title { + romaji?: string; + english?: string; + native?: string; + userPreferred?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer { + id?: string; + site?: string; + thumbnail?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Relations { + edges?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Relations_Edges { + relationType?: AL_MediaRelation; + node?: AL_BaseAnime; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Staff { + edges?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Staff_Edges { + role?: string; + node?: AL_AnimeDetailsById_Media_Staff_Edges_Node; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Staff_Edges_Node { + name?: AL_AnimeDetailsById_Media_Staff_Edges_Node_Name; + id: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Staff_Edges_Node_Name { + full?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_StartDate { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Studios { + nodes?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Studios_Nodes { + name: string; + id: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_AnimeDetailsById_Media_Trailer { + id?: string; + site?: string; + thumbnail?: string; + } + + /** + * - Filepath: internal/api/anilist/collection_helper.go + */ + export type AL_AnimeListEntry = AL_AnimeCollection_MediaListCollection_Lists_Entries; + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseAnime { + id: number; + idMal?: number; + siteUrl?: string; + status?: AL_MediaStatus; + season?: AL_MediaSeason; + type?: AL_MediaType; + format?: AL_MediaFormat; + seasonYear?: number; + bannerImage?: string; + episodes?: number; + synonyms?: Array; + isAdult?: boolean; + countryOfOrigin?: string; + meanScore?: number; + description?: string; + genres?: Array; + duration?: number; + trailer?: AL_BaseAnime_Trailer; + title?: AL_BaseAnime_Title; + coverImage?: AL_BaseAnime_CoverImage; + startDate?: AL_BaseAnime_StartDate; + endDate?: AL_BaseAnime_EndDate; + nextAiringEpisode?: AL_BaseAnime_NextAiringEpisode; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseAnime_CoverImage { + extraLarge?: string; + large?: string; + medium?: string; + color?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseAnime_EndDate { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseAnime_NextAiringEpisode { + airingAt: number; + timeUntilAiring: number; + episode: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseAnime_StartDate { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseAnime_Title { + userPreferred?: string; + romaji?: string; + english?: string; + native?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseAnime_Trailer { + id?: string; + site?: string; + thumbnail?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseCharacter { + id: number; + isFavourite: boolean; + gender?: string; + age?: string; + dateOfBirth?: AL_BaseCharacter_DateOfBirth; + name?: AL_BaseCharacter_Name; + image?: AL_BaseCharacter_Image; + description?: string; + siteUrl?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseCharacter_DateOfBirth { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseCharacter_Image { + large?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseCharacter_Name { + full?: string; + native?: string; + alternative?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseManga { + id: number; + idMal?: number; + siteUrl?: string; + status?: AL_MediaStatus; + season?: AL_MediaSeason; + type?: AL_MediaType; + format?: AL_MediaFormat; + bannerImage?: string; + chapters?: number; + volumes?: number; + synonyms?: Array; + isAdult?: boolean; + countryOfOrigin?: string; + meanScore?: number; + description?: string; + genres?: Array; + title?: AL_BaseManga_Title; + coverImage?: AL_BaseManga_CoverImage; + startDate?: AL_BaseManga_StartDate; + endDate?: AL_BaseManga_EndDate; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseManga_CoverImage { + extraLarge?: string; + large?: string; + medium?: string; + color?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseManga_EndDate { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseManga_StartDate { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_BaseManga_Title { + userPreferred?: string; + romaji?: string; + english?: string; + native?: string; + } + + /** + * - Filepath: internal/api/anilist/models_gen.go + * @description + * The role the character plays in the media + */ + export type AL_CharacterRole = "MAIN" | "SUPPORTING" | "BACKGROUND"; + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_CompleteAnime { + id: number; + idMal?: number; + siteUrl?: string; + status?: AL_MediaStatus; + season?: AL_MediaSeason; + seasonYear?: number; + type?: AL_MediaType; + format?: AL_MediaFormat; + bannerImage?: string; + episodes?: number; + synonyms?: Array; + isAdult?: boolean; + countryOfOrigin?: string; + meanScore?: number; + description?: string; + genres?: Array; + duration?: number; + trailer?: AL_CompleteAnime_Trailer; + title?: AL_CompleteAnime_Title; + coverImage?: AL_CompleteAnime_CoverImage; + startDate?: AL_CompleteAnime_StartDate; + endDate?: AL_CompleteAnime_EndDate; + nextAiringEpisode?: AL_CompleteAnime_NextAiringEpisode; + relations?: AL_CompleteAnime_Relations; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_CompleteAnime_CoverImage { + extraLarge?: string; + large?: string; + medium?: string; + color?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_CompleteAnime_EndDate { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_CompleteAnime_NextAiringEpisode { + airingAt: number; + timeUntilAiring: number; + episode: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_CompleteAnime_Relations { + edges?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_CompleteAnime_Relations_Edges { + relationType?: AL_MediaRelation; + node?: AL_BaseAnime; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_CompleteAnime_StartDate { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_CompleteAnime_Title { + userPreferred?: string; + romaji?: string; + english?: string; + native?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_CompleteAnime_Trailer { + id?: string; + site?: string; + thumbnail?: string; + } + + /** + * - Filepath: internal/api/anilist/models_gen.go + * @description + * Date object that allows for incomplete date values (fuzzy) + */ + interface AL_FuzzyDateInput { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_ListAnime { + Page?: AL_ListAnime_Page; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_ListAnime_Page { + pageInfo?: AL_ListAnime_Page_PageInfo; + media?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_ListAnime_Page_PageInfo { + hasNextPage?: boolean; + total?: number; + perPage?: number; + currentPage?: number; + lastPage?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_ListManga { + Page?: AL_ListManga_Page; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_ListManga_Page { + pageInfo?: AL_ListManga_Page_PageInfo; + media?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_ListManga_Page_PageInfo { + hasNextPage?: boolean; + total?: number; + perPage?: number; + currentPage?: number; + lastPage?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_ListRecentAnime { + Page?: AL_ListRecentAnime_Page; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_ListRecentAnime_Page { + pageInfo?: AL_ListRecentAnime_Page_PageInfo; + airingSchedules?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_ListRecentAnime_Page_AiringSchedules { + id: number; + airingAt: number; + episode: number; + timeUntilAiring: number; + media?: AL_BaseAnime; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_ListRecentAnime_Page_PageInfo { + hasNextPage?: boolean; + total?: number; + perPage?: number; + currentPage?: number; + lastPage?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaCollection { + MediaListCollection?: AL_MangaCollection_MediaListCollection; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaCollection_MediaListCollection { + lists?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaCollection_MediaListCollection_Lists { + status?: AL_MediaListStatus; + name?: string; + isCustomList?: boolean; + entries?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaCollection_MediaListCollection_Lists_Entries { + id: number; + score?: number; + progress?: number; + status?: AL_MediaListStatus; + notes?: string; + repeat?: number; + private?: boolean; + startedAt?: AL_MangaCollection_MediaListCollection_Lists_Entries_StartedAt; + completedAt?: AL_MangaCollection_MediaListCollection_Lists_Entries_CompletedAt; + media?: AL_BaseManga; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaCollection_MediaListCollection_Lists_Entries_CompletedAt { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaCollection_MediaListCollection_Lists_Entries_StartedAt { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media { + siteUrl?: string; + id: number; + duration?: number; + genres?: Array; + rankings?: Array; + characters?: AL_MangaDetailsById_Media_Characters; + recommendations?: AL_MangaDetailsById_Media_Recommendations; + relations?: AL_MangaDetailsById_Media_Relations; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Characters { + edges?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Characters_Edges { + id?: number; + role?: AL_CharacterRole; + name?: string; + node?: AL_BaseCharacter; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Rankings { + context: string; + type: AL_MediaRankType; + rank: number; + year?: number; + format: AL_MediaFormat; + allTime?: boolean; + season?: AL_MediaSeason; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Recommendations { + edges?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Recommendations_Edges { + node?: AL_MangaDetailsById_Media_Recommendations_Edges_Node; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Recommendations_Edges_Node { + mediaRecommendation?: AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation { + id: number; + idMal?: number; + siteUrl?: string; + status?: AL_MediaStatus; + season?: AL_MediaSeason; + type?: AL_MediaType; + format?: AL_MediaFormat; + bannerImage?: string; + chapters?: number; + volumes?: number; + synonyms?: Array; + isAdult?: boolean; + countryOfOrigin?: string; + meanScore?: number; + description?: string; + title?: AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title; + coverImage?: AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage; + startDate?: AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate; + endDate?: AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage { + extraLarge?: string; + large?: string; + medium?: string; + color?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate { + year?: number; + month?: number; + day?: number; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title { + userPreferred?: string; + romaji?: string; + english?: string; + native?: string; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Relations { + edges?: Array; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_MangaDetailsById_Media_Relations_Edges { + relationType?: AL_MediaRelation; + node?: AL_BaseManga; + } + + /** + * - Filepath: internal/api/anilist/models_gen.go + * @description + * The format the media was released in + */ + export type AL_MediaFormat = "TV" | + "TV_SHORT" | + "MOVIE" | + "SPECIAL" | + "OVA" | + "ONA" | + "MUSIC" | + "MANGA" | + "NOVEL" | + "ONE_SHOT"; + + /** + * - Filepath: internal/api/anilist/models_gen.go + * @description + * Media list watching/reading status enum. + */ + export type AL_MediaListStatus = "CURRENT" | + "PLANNING" | + "COMPLETED" | + "DROPPED" | + "PAUSED" | + "REPEATING"; + + /** + * - Filepath: internal/api/anilist/models_gen.go + * @description + * The type of ranking + */ + export type AL_MediaRankType = "RATED" | "POPULAR"; + + /** + * - Filepath: internal/api/anilist/models_gen.go + * @description + * Type of relation media has to its parent. + */ + export type AL_MediaRelation = "ADAPTATION" | + "PREQUEL" | + "SEQUEL" | + "PARENT" | + "SIDE_STORY" | + "CHARACTER" | + "SUMMARY" | + "ALTERNATIVE" | + "SPIN_OFF" | + "OTHER" | + "SOURCE" | + "COMPILATION" | + "CONTAINS"; + + /** + * - Filepath: internal/api/anilist/models_gen.go + */ + export type AL_MediaSeason = "WINTER" | "SPRING" | "SUMMER" | "FALL"; + + /** + * - Filepath: internal/api/anilist/models_gen.go + * @description + * Media sort enums + */ + export type AL_MediaSort = "ID" | + "ID_DESC" | + "TITLE_ROMAJI" | + "TITLE_ROMAJI_DESC" | + "TITLE_ENGLISH" | + "TITLE_ENGLISH_DESC" | + "TITLE_NATIVE" | + "TITLE_NATIVE_DESC" | + "TYPE" | + "TYPE_DESC" | + "FORMAT" | + "FORMAT_DESC" | + "START_DATE" | + "START_DATE_DESC" | + "END_DATE" | + "END_DATE_DESC" | + "SCORE" | + "SCORE_DESC" | + "POPULARITY" | + "POPULARITY_DESC" | + "TRENDING" | + "TRENDING_DESC" | + "EPISODES" | + "EPISODES_DESC" | + "DURATION" | + "DURATION_DESC" | + "STATUS" | + "STATUS_DESC" | + "CHAPTERS" | + "CHAPTERS_DESC" | + "VOLUMES" | + "VOLUMES_DESC" | + "UPDATED_AT" | + "UPDATED_AT_DESC" | + "SEARCH_MATCH" | + "FAVOURITES" | + "FAVOURITES_DESC"; + + /** + * - Filepath: internal/api/anilist/models_gen.go + * @description + * The current releasing status of the media + */ + export type AL_MediaStatus = "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS"; + + /** + * - Filepath: internal/api/anilist/models_gen.go + * @description + * Media type enum, anime or manga. + */ + export type AL_MediaType = "ANIME" | "MANGA"; + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_StudioDetails { + Studio?: AL_StudioDetails_Studio; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_StudioDetails_Studio { + id: number; + isAnimationStudio: boolean; + name: string; + media?: AL_StudioDetails_Studio_Media; + } + + /** + * - Filepath: internal/api/anilist/client_gen.go + */ + interface AL_StudioDetails_Studio_Media { + nodes?: Array; + } + + /** + * - Filepath: internal/api/animap/animap.go + */ + interface Animap_Anime { + title: string; + titles?: Record; + /** + * YYYY-MM-DD + */ + startDate?: string; + /** + * YYYY-MM-DD + */ + endDate?: string; + /** + * Finished, Airing, Upcoming, etc. + */ + status: string; + /** + * TV, OVA, Movie, etc. + */ + type: string; + /** + * Indexed by AniDB episode number, "1", "S1", etc. + */ + episodes?: Record; + mappings?: Animap_AnimeMapping; + } + + /** + * - Filepath: internal/api/animap/animap.go + */ + interface Animap_AnimeMapping { + anidb_id?: number; + anilist_id?: number; + kitsu_id?: number; + thetvdb_id?: number; + /** + * Can be int or string, forced to string + */ + themoviedb_id?: string; + mal_id?: number; + livechart_id?: number; + /** + * Can be int or string, forced to string + */ + animeplanet_id?: string; + anisearch_id?: number; + simkl_id?: number; + notifymoe_id?: string; + animecountdown_id?: number; + type?: string; + } + + /** + * - Filepath: internal/api/animap/animap.go + */ + interface Animap_Episode { + anidbEpisode: string; + anidbEid: number; + tvdbEid?: number; + tvdbShowId?: number; + /** + * YYYY-MM-DD + */ + airDate?: string; + /** + * Title of the episode from AniDB + */ + anidbTitle?: string; + /** + * Title of the episode from TVDB + */ + tvdbTitle?: string; + overview?: string; + image?: string; + /** + * minutes + */ + runtime?: number; + /** + * Xm + */ + length?: string; + seasonNumber?: number; + seasonName?: string; + number: number; + absoluteNumber?: number; + } + + /** + * - Filepath: internal/library/anime/autodownloader_rule.go + */ + interface Anime_AutoDownloaderRule { + /** + * Will be set when fetched from the database + */ + dbId: number; + enabled: boolean; + mediaId: number; + releaseGroups?: Array; + resolutions?: Array; + comparisonTitle: string; + titleComparisonType: Anime_AutoDownloaderRuleTitleComparisonType; + episodeType: Anime_AutoDownloaderRuleEpisodeType; + episodeNumbers?: Array; + destination: string; + additionalTerms?: Array; + } + + /** + * - Filepath: internal/library/anime/autodownloader_rule.go + */ + export type Anime_AutoDownloaderRuleEpisodeType = "recent" | "selected"; + + /** + * - Filepath: internal/library/anime/autodownloader_rule.go + */ + export type Anime_AutoDownloaderRuleTitleComparisonType = "contains" | "likely"; + + /** + * - Filepath: internal/library/anime/entry.go + */ + interface Anime_Entry { + mediaId: number; + media?: AL_BaseAnime; + listData?: Anime_EntryListData; + libraryData?: Anime_EntryLibraryData; + downloadInfo?: Anime_EntryDownloadInfo; + episodes?: Array; + nextEpisode?: Anime_Episode; + localFiles?: Array; + anidbId: number; + currentEpisodeCount: number; + _isNakamaEntry: boolean; + nakamaLibraryData?: Anime_NakamaEntryLibraryData; + } + + /** + * - Filepath: internal/library/anime/entry_download_info.go + */ + interface Anime_EntryDownloadEpisode { + episodeNumber: number; + aniDBEpisode: string; + episode?: Anime_Episode; + } + + /** + * - Filepath: internal/library/anime/entry_download_info.go + */ + interface Anime_EntryDownloadInfo { + episodesToDownload?: Array; + canBatch: boolean; + batchAll: boolean; + hasInaccurateSchedule: boolean; + rewatch: boolean; + absoluteOffset: number; + } + + /** + * - Filepath: internal/library/anime/entry_library_data.go + */ + interface Anime_EntryLibraryData { + allFilesLocked: boolean; + sharedPath: string; + unwatchedCount: number; + mainFileCount: number; + } + + /** + * - Filepath: internal/library/anime/entry.go + */ + interface Anime_EntryListData { + progress?: number; + score?: number; + status?: AL_MediaListStatus; + repeat?: number; + startedAt?: string; + completedAt?: string; + } + + /** + * - Filepath: internal/library/anime/episode.go + */ + interface Anime_Episode { + type: Anime_LocalFileType; + /** + * e.g, Show: "Episode 1", Movie: "Violet Evergarden The Movie" + */ + displayTitle: string; + /** + * e.g, "Shibuya Incident - Gate, Open" + */ + episodeTitle: string; + episodeNumber: number; + /** + * AniDB episode number + */ + aniDBEpisode?: string; + absoluteEpisodeNumber: number; + /** + * Usually the same as EpisodeNumber, unless there is a discrepancy between AniList and AniDB + */ + progressNumber: number; + localFile?: Anime_LocalFile; + /** + * Is in the local files + */ + isDownloaded: boolean; + /** + * (image, airDate, length, summary, overview) + */ + episodeMetadata?: Anime_EpisodeMetadata; + /** + * (episode, aniDBEpisode, type...) + */ + fileMetadata?: Anime_LocalFileMetadata; + /** + * No AniDB data + */ + isInvalid: boolean; + /** + * Alerts the user that there is a discrepancy between AniList and AniDB + */ + metadataIssue?: string; + baseAnime?: AL_BaseAnime; + _isNakamaEpisode: boolean; + } + + /** + * - Filepath: internal/library/anime/episode_collection.go + */ + interface Anime_EpisodeCollection { + hasMappingError: boolean; + episodes?: Array; + metadata?: Metadata_AnimeMetadata; + } + + /** + * - Filepath: internal/library/anime/episode.go + */ + interface Anime_EpisodeMetadata { + anidbId?: number; + image?: string; + airDate?: string; + length?: number; + summary?: string; + overview?: string; + isFiller?: boolean; + /** + * Indicates if the episode has a real image + */ + hasImage?: boolean; + } + + /** + * - Filepath: internal/library/anime/collection.go + */ + interface Anime_LibraryCollection { + continueWatchingList?: Array; + lists?: Array; + unmatchedLocalFiles?: Array; + unmatchedGroups?: Array; + ignoredLocalFiles?: Array; + unknownGroups?: Array; + stats?: Anime_LibraryCollectionStats; + /** + * Hydrated by the route handler + */ + stream?: Anime_StreamCollection; + } + + /** + * - Filepath: internal/library/anime/collection.go + */ + interface Anime_LibraryCollectionEntry { + media?: AL_BaseAnime; + mediaId: number; + /** + * Library data + */ + libraryData?: Anime_EntryLibraryData; + /** + * Library data from Nakama + */ + nakamaLibraryData?: Anime_NakamaEntryLibraryData; + /** + * AniList list data + */ + listData?: Anime_EntryListData; + } + + /** + * - Filepath: internal/library/anime/collection.go + */ + interface Anime_LibraryCollectionList { + type?: AL_MediaListStatus; + status?: AL_MediaListStatus; + entries?: Array; + } + + /** + * - Filepath: internal/library/anime/collection.go + */ + interface Anime_LibraryCollectionStats { + totalEntries: number; + totalFiles: number; + totalShows: number; + totalMovies: number; + totalSpecials: number; + totalSize: string; + } + + /** + * - Filepath: internal/library/anime/localfile.go + */ + interface Anime_LocalFile { + path: string; + name: string; + parsedInfo?: Anime_LocalFileParsedData; + parsedFolderInfo?: Array; + metadata?: Anime_LocalFileMetadata; + locked: boolean; + /** + * Unused for now + */ + ignored: boolean; + mediaId: number; + } + + /** + * - Filepath: internal/library/anime/localfile.go + */ + interface Anime_LocalFileMetadata { + episode: number; + aniDBEpisode: string; + type: Anime_LocalFileType; + } + + /** + * - Filepath: internal/library/anime/localfile.go + */ + interface Anime_LocalFileParsedData { + original: string; + title?: string; + releaseGroup?: string; + season?: string; + seasonRange?: Array; + part?: string; + partRange?: Array; + episode?: string; + episodeRange?: Array; + episodeTitle?: string; + year?: string; + } + + /** + * - Filepath: internal/library/anime/localfile.go + */ + export type Anime_LocalFileType = "main" | "special" | "nc"; + + /** + * - Filepath: internal/library/anime/localfile_wrapper.go + */ + interface Anime_LocalFileWrapperEntry { + mediaId: number; + localFiles?: Array; + } + + /** + * - Filepath: internal/library/anime/missing_episodes.go + */ + interface Anime_MissingEpisodes { + episodes?: Array; + silencedEpisodes?: Array; + } + + /** + * - Filepath: internal/library/anime/entry_library_data.go + */ + interface Anime_NakamaEntryLibraryData { + unwatchedCount: number; + mainFileCount: number; + } + + /** + * - Filepath: internal/library/anime/normalized_media.go + */ + interface Anime_NormalizedMedia { + id: number; + idMal?: number; + siteUrl?: string; + status?: AL_MediaStatus; + season?: AL_MediaSeason; + type?: AL_MediaType; + format?: AL_MediaFormat; + seasonYear?: number; + bannerImage?: string; + episodes?: number; + synonyms?: Array; + isAdult?: boolean; + countryOfOrigin?: string; + meanScore?: number; + description?: string; + genres?: Array; + duration?: number; + trailer?: AL_BaseAnime_Trailer; + title?: AL_BaseAnime_Title; + coverImage?: AL_BaseAnime_CoverImage; + startDate?: AL_BaseAnime_StartDate; + endDate?: AL_BaseAnime_EndDate; + nextAiringEpisode?: AL_BaseAnime_NextAiringEpisode; + } + + /** + * - Filepath: internal/library/anime/schedule.go + */ + interface Anime_ScheduleItem { + mediaId: number; + title: string; + time: string; + dateTime?: string; + image: string; + episodeNumber: number; + isMovie: boolean; + isSeasonFinale: boolean; + } + + /** + * - Filepath: internal/library/anime/collection.go + */ + interface Anime_StreamCollection { + continueWatchingList?: Array; + anime?: Array; + listData?: Record; + } + + /** + * - Filepath: internal/library/anime/collection.go + */ + interface Anime_UnknownGroup { + mediaId: number; + localFiles?: Array; + } + + /** + * - Filepath: internal/library/anime/collection.go + */ + interface Anime_UnmatchedGroup { + dir: string; + localFiles?: Array; + suggestions?: Array; + } + + /** + * - Filepath: internal/api/anizip/anizip.go + */ + interface Anizip_Episode { + tvdbEid?: number; + airdate?: string; + seasonNumber?: number; + episodeNumber?: number; + absoluteEpisodeNumber?: number; + title?: Record; + image?: string; + summary?: string; + overview?: string; + runtime?: number; + length?: number; + episode?: string; + anidbEid?: number; + rating?: string; + } + + /** + * - Filepath: internal/api/anizip/anizip.go + */ + interface Anizip_Mappings { + animeplanet_id?: string; + kitsu_id?: number; + mal_id?: number; + type?: string; + anilist_id?: number; + anisearch_id?: number; + anidb_id?: number; + notifymoe_id?: string; + livechart_id?: number; + thetvdb_id?: number; + imdb_id?: string; + themoviedb_id?: string; + } + + /** + * - Filepath: internal/api/anizip/anizip.go + */ + interface Anizip_Media { + titles?: Record; + episodes?: Record; + episodeCount: number; + specialCount: number; + mappings?: Anizip_Mappings; + } + + /** + * - Filepath: internal/library/autodownloader/autodownloader_torrent.go + */ + interface AutoDownloader_NormalizedTorrent { + parsedData?: $habari.Metadata; + /** + * Access using GetMagnet() + */ + magnet: string; + provider?: string; + name: string; + date: string; + size: number; + formattedSize: string; + seeders: number; + leechers: number; + downloadCount: number; + link: string; + downloadUrl: string; + magnetLink?: string; + infoHash?: string; + resolution?: string; + isBatch?: boolean; + episodeNumber?: number; + releaseGroup?: string; + isBestRelease: boolean; + confirmed: boolean; + } + + /** + * - Filepath: internal/continuity/manager.go + */ + export type Continuity_Kind = "onlinestream" | "mediastream" | "external_player"; + + /** + * - Filepath: internal/continuity/history.go + */ + interface Continuity_UpdateWatchHistoryItemOptions { + currentTime: number; + duration: number; + mediaId: number; + episodeNumber: number; + filepath?: string; + kind: Continuity_Kind; + } + + /** + * - Filepath: internal/continuity/history.go + */ + export type Continuity_WatchHistory = Record; + + /** + * - Filepath: internal/continuity/history.go + */ + interface Continuity_WatchHistoryItem { + kind: Continuity_Kind; + filepath: string; + mediaId: number; + episodeNumber: number; + currentTime: number; + duration: number; + timeAdded?: string; + timeUpdated?: string; + } + + /** + * - Filepath: internal/continuity/history.go + */ + interface Continuity_WatchHistoryItemResponse { + item?: Continuity_WatchHistoryItem; + found: boolean; + } + + /** + * - Filepath: internal/discordrpc/presence/presence.go + */ + interface DiscordRPC_AnimeActivity { + id: number; + title: string; + image: string; + isMovie: boolean; + episodeNumber: number; + paused: boolean; + progress: number; + duration: number; + totalEpisodes?: number; + currentEpisodeCount?: number; + episodeTitle?: string; + } + + /** + * - Filepath: internal/discordrpc/client/activity.go + */ + interface DiscordRPC_Button { + label?: string; + url?: string; + } + + /** + * - Filepath: internal/discordrpc/presence/presence.go + */ + interface DiscordRPC_LegacyAnimeActivity { + id: number; + title: string; + image: string; + isMovie: boolean; + episodeNumber: number; + } + + /** + * - Filepath: internal/discordrpc/presence/presence.go + */ + interface DiscordRPC_MangaActivity { + id: number; + title: string; + image: string; + chapter: string; + } + + /** + * - Filepath: internal/extension/hibike/manga/types.go + */ + interface HibikeManga_ChapterDetails { + provider: string; + id: string; + url: string; + title: string; + chapter: string; + index: number; + scanlator?: string; + language?: string; + rating?: number; + updatedAt?: string; + localIsPDF?: boolean; + } + + /** + * - Filepath: internal/extension/hibike/torrent/types.go + */ + interface HibikeTorrent_AnimeTorrent { + provider?: string; + name: string; + date: string; + size: number; + formattedSize: string; + seeders: number; + leechers: number; + downloadCount: number; + link: string; + downloadUrl: string; + magnetLink?: string; + infoHash?: string; + resolution?: string; + isBatch?: boolean; + episodeNumber?: number; + releaseGroup?: string; + isBestRelease: boolean; + confirmed: boolean; + } + + /** + * - Filepath: internal/manga/chapter_container.go + */ + interface Manga_ChapterContainer { + mediaId: number; + provider: string; + chapters?: Array; + } + + /** + * - Filepath: internal/manga/collection.go + */ + interface Manga_Collection { + lists?: Array; + } + + /** + * - Filepath: internal/manga/collection.go + */ + interface Manga_CollectionEntry { + media?: AL_BaseManga; + mediaId: number; + /** + * AniList list data + */ + listData?: Manga_EntryListData; + } + + /** + * - Filepath: internal/manga/collection.go + */ + interface Manga_CollectionList { + type?: AL_MediaListStatus; + status?: AL_MediaListStatus; + entries?: Array; + } + + /** + * - Filepath: internal/manga/manga_entry.go + */ + interface Manga_Entry { + mediaId: number; + media?: AL_BaseManga; + listData?: Manga_EntryListData; + } + + /** + * - Filepath: internal/manga/manga_entry.go + */ + interface Manga_EntryListData { + progress?: number; + score?: number; + status?: AL_MediaListStatus; + repeat?: number; + startedAt?: string; + completedAt?: string; + } + + /** + * - Filepath: internal/manga/chapter_container.go + */ + interface Manga_MangaLatestChapterNumberItem { + provider: string; + scanlator: string; + language: string; + number: number; + } + + /** + * - Filepath: internal/manga/download.go + */ + export type Manga_MediaMap = Record; + + /** + * - Filepath: internal/manga/download.go + */ + export type Manga_ProviderDownloadMap = Record>; + + /** + * - Filepath: internal/manga/download.go + */ + interface Manga_ProviderDownloadMapChapterInfo { + chapterId: string; + chapterNumber: string; + } + + /** + * - Filepath: internal/api/metadata/types.go + */ + interface Metadata_AnimeMappings { + animeplanetId: string; + kitsuId: number; + malId: number; + type: string; + anilistId: number; + anisearchId: number; + anidbId: number; + notifymoeId: string; + livechartId: number; + thetvdbId: number; + imdbId: string; + themoviedbId: string; + } + + /** + * - Filepath: internal/api/metadata/types.go + */ + interface Metadata_AnimeMetadata { + titles?: Record; + episodes?: Record; + episodeCount: number; + specialCount: number; + mappings?: Metadata_AnimeMappings; + } + + /** + * - Filepath: internal/api/metadata/types.go + */ + interface Metadata_EpisodeMetadata { + anidbId: number; + tvdbId: number; + title: string; + image: string; + airDate: string; + length: number; + summary: string; + overview: string; + episodeNumber: number; + episode: string; + seasonNumber: number; + absoluteEpisodeNumber: number; + anidbEid: number; + /** + * Indicates if the episode has a real image + */ + hasImage: boolean; + } + + /** + * - Filepath: internal/database/models/models.go + */ + interface Models_AutoDownloaderItem { + ruleId: number; + mediaId: number; + episode: number; + link: string; + hash: string; + magnet: string; + torrentName: string; + downloaded: boolean; + id: number; + createdAt?: string; + updatedAt?: string; + } + + /** + * - Filepath: internal/database/models/models.go + */ + interface Models_AutoDownloaderSettings { + provider: string; + interval: number; + enabled: boolean; + downloadAutomatically: boolean; + enableEnhancedQueries: boolean; + enableSeasonCheck: boolean; + useDebrid: boolean; + } + + /** + * - Filepath: internal/onlinestream/repository.go + */ + interface Onlinestream_Episode { + number: number; + title?: string; + image?: string; + description?: string; + isFiller?: boolean; + } + + /** + * - Filepath: internal/torrent_clients/torrent_client/torrent.go + */ + interface TorrentClient_Torrent { + name: string; + hash: string; + seeds: number; + upSpeed: string; + downSpeed: string; + progress: number; + size: string; + eta: string; + status: TorrentClient_TorrentStatus; + contentPath: string; + } + + /** + * - Filepath: internal/torrent_clients/torrent_client/torrent.go + */ + export type TorrentClient_TorrentStatus = "downloading" | "seeding" | "paused" | "other" | "stopped"; + +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_plugin_types/core.d.ts b/seanime-2.9.10/internal/extension_repo/goja_plugin_types/core.d.ts new file mode 100644 index 0000000..c4ecc51 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_plugin_types/core.d.ts @@ -0,0 +1,332 @@ +/** + * Is offline + */ +declare const __isOffline__: boolean + +/** + * Fetch + */ +declare function fetch(url: string, options?: FetchOptions): Promise + +interface FetchOptions { + /** HTTP method, defaults to GET */ + method?: string + /** Request headers */ + headers?: Record + /** Request body */ + body?: any + /** Whether to bypass cloudflare */ + noCloudflareBypass?: boolean + /** Timeout in seconds, defaults to 35 */ + timeout?: number +} + +interface FetchResponse { + /** Response status code */ + status: number + /** Response status text */ + statusText: string + /** Request method used */ + method: string + /** Raw response headers */ + rawHeaders: Record + /** Whether the response was successful (status in range 200-299) */ + ok: boolean + /** Request URL */ + url: string + /** Response headers */ + headers: Record + /** Response cookies */ + cookies: Record + /** Whether the response was redirected */ + redirected: boolean + /** Response content type */ + contentType: string + /** Response content length */ + contentLength: number + + /** Get response text */ + text(): string + + /** Parse response as JSON */ + json(): T +} + +/** + * Replaces the reference of the value with the new value. + * @param value - The value to replace + * @param newValue - The new value + */ +declare function $replace(value: T, newValue: T): void + +/** + * Creates a deep copy of the value. + * @param value - The value to copy + * @returns A deep copy of the value + */ +declare function $clone(value: T): T + +/** + * Converts a value to a string + * @param value - The value to convert + * @returns The string representation of the value + */ +declare function $toString(value: any): string + +/** + * Converts a value to a bytes array + * @param value - The value to convert + * @returns The bytes array + */ +declare function $toBytes(value: any): Uint8Array + +/** + * Sleeps for a specified amount of time + * @param milliseconds - The amount of time to sleep in milliseconds + */ +declare function $sleep(milliseconds: number): void + +/** + * + * @param model + */ +declare function $arrayOf(model: T): T[] + +/** + * Marshals and unmarshals a value to a JSON string + * @param data - The value to marshal + * @param dst - The destination to unmarshal the value to. Must be a reference. + * @throws If unmarshalling fails + */ +declare function $unmarshalJSON(data: any, dst: any): void + +/** + * Get a user preference + * @param key The key of the preference + * @returns The value of the preference set by the user, the default value if it is not set, or undefined. + */ +declare function $getUserPreference(key: string): string | undefined; + +/** + * Habari + */ + +declare namespace $habari { + + interface Metadata { + season_number?: string[] + part_number?: string[] + title?: string + formatted_title?: string + anime_type?: string[] + year?: string + audio_term?: string[] + device_compatibility?: string[] + episode_number?: string[] + other_episode_number?: string[] + episode_number_alt?: string[] + episode_title?: string + file_checksum?: string + file_extension?: string + file_name?: string + language?: string[] + release_group?: string + release_information?: string[] + release_version?: string[] + source?: string[] + subtitles?: string[] + video_resolution?: string + video_term?: string[] + volume_number?: string[] + } + + /** + * Parses a filename and returns the metadata + * @param filename - The filename to parse + * @returns The metadata + */ + function parse(filename: string): Metadata +} + +/** + * Buffer + */ + +declare class Buffer extends ArrayBuffer { + static poolSize: number + + constructor(arg?: string | ArrayBuffer | ArrayLike, encoding?: string) + + static from(arrayBuffer: ArrayBuffer): Buffer + static from(array: ArrayLike): Buffer + static from(string: string, encoding?: string): Buffer + + static alloc(size: number, fill?: string | number, encoding?: string): Buffer + + equals(other: Buffer | Uint8Array): boolean + + toString(encoding?: string): string +} + + +/** + * Crypto + */ + +declare class WordArray { + toString(encoder?: CryptoJSEncoder): string; +} + +// CryptoJS supports AES-128, AES-192, and AES-256. It will pick the variant by the size of the key you pass in. If you use a passphrase, +// then it will generate a 256-bit key. +declare class CryptoJS { + static AES: { + encrypt: (message: string, key: string | Uint8Array, cfg?: AESConfig) => WordArray; + decrypt: (message: string | WordArray, key: string | Uint8Array, cfg?: AESConfig) => WordArray; + } + static enc: { + Utf8: CryptoJSEncoder; + Base64: CryptoJSEncoder; + Hex: CryptoJSEncoder; + Latin1: CryptoJSEncoder; + Utf16: CryptoJSEncoder; + Utf16LE: CryptoJSEncoder; + } +} + +declare interface AESConfig { + iv?: Uint8Array; +} + +declare class CryptoJSEncoder { + stringify(input: Uint8Array): string; + + parse(input: string): Uint8Array; +} + + +/** + * Doc + */ + +declare class DocSelection { + // Retrieves the value of the specified attribute for the first element in the DocSelection. + // To get the value for each element individually, use a looping construct such as each or map. + attr(name: string): string | undefined; + + // Returns an object containing the attributes of the first element in the DocSelection. + attrs(): { [key: string]: string }; + + // Gets the child elements of each element in the DocSelection, optionally filtered by a selector. + children(selector?: string): DocSelection; + + // For each element in the DocSelection, gets the first ancestor that matches the selector by testing the element itself + // and traversing up through its ancestors in the DOM tree. + closest(selector?: string): DocSelection; + + // Gets the children of each element in the DocSelection, including text and comment nodes. + contents(): DocSelection; + + // Gets the children of each element in the DocSelection, filtered by the specified selector. + contentsFiltered(selector: string): DocSelection; + + // Gets the value of a data attribute for the first element in the DocSelection. + // If no name is provided, returns an object containing all data attributes. + data(name?: T): T extends string ? (string | undefined) : { [key: string]: string }; + + // Iterates over each element in the DocSelection, executing a function for each matched element. + each(callback: (index: number, element: DocSelection) => void): DocSelection; + + // Ends the most recent filtering operation in the current chain and returns the set of matched elements to its previous state. + end(): DocSelection; + + // Reduces the set of matched elements to the one at the specified index. If a negative index is given, it counts backwards starting at the end + // of the set. + eq(index: number): DocSelection; + + // Filters the set of matched elements to those that match the selector. + filter(selector: string | ((index: number, element: DocSelection) => boolean)): DocSelection; + + // Gets the descendants of each element in the DocSelection, filtered by a selector. + find(selector: string): DocSelection; + + // Reduces the set of matched elements to the first element in the DocSelection. + first(): DocSelection; + + // Reduces the set of matched elements to those that have a descendant that matches the selector. + has(selector: string): DocSelection; + + // Gets the combined text contents of each element in the DocSelection, including their descendants. + text(): string; + + // Gets the HTML contents of the first element in the DocSelection. + html(): string | null; + + // Checks the set of matched elements against a selector and returns true if at least one of these elements matches. + is(selector: string | ((index: number, element: DocSelection) => boolean)): boolean; + + // Reduces the set of matched elements to the last element in the DocSelection. + last(): DocSelection; + + // Gets the number of elements in the DocSelection. + length(): number; + + // Passes each element in the current matched set through a function, producing an array of the return values. + map(callback: (index: number, element: DocSelection) => T): T[]; + + // Gets the next sibling of each element in the DocSelection, optionally filtered by a selector. + next(selector?: string): DocSelection; + + // Gets all following siblings of each element in the DocSelection, optionally filtered by a selector. + nextAll(selector?: string): DocSelection; + + // Gets the next siblings of each element in the DocSelection, up to but not including the element matched by the selector. + nextUntil(selector: string, until?: string): DocSelection; + + // Removes elements from the DocSelection that match the selector. + not(selector: string | ((index: number, element: DocSelection) => boolean)): DocSelection; + + // Gets the parent of each element in the DocSelection, optionally filtered by a selector. + parent(selector?: string): DocSelection; + + // Gets the ancestors of each element in the DocSelection, optionally filtered by a selector. + parents(selector?: string): DocSelection; + + // Gets the ancestors of each element in the DocSelection, up to but not including the element matched by the selector. + parentsUntil(selector: string, until?: string): DocSelection; + + // Gets the previous sibling of each element in the DocSelection, optionally filtered by a selector. + prev(selector?: string): DocSelection; + + // Gets all preceding siblings of each element in the DocSelection, optionally filtered by a selector. + prevAll(selector?: string): DocSelection; + + // Gets the previous siblings of each element in the DocSelection, up to but not including the element matched by the selector. + prevUntil(selector: string, until?: string): DocSelection; + + // Gets the siblings of each element in the DocSelection, optionally filtered by a selector. + siblings(selector?: string): DocSelection; +} + +declare class Doc extends DocSelection { + constructor(html: string); +} + +declare function LoadDoc(html: string): DocSelectionFunction; + +declare interface DocSelectionFunction { + (selector: string): DocSelection; +} + +/** + * Torrent utils + */ + +declare interface $torrentUtils { + /** + * Get a magnet link from a base64 encoded torrent data + * @param b64 - The base64 encoded torrent data + * @returns The magnet link + */ + getMagnetLinkFromTorrentData(b64: string): string +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_plugin_types/plugin.d.ts b/seanime-2.9.10/internal/extension_repo/goja_plugin_types/plugin.d.ts new file mode 100644 index 0000000..da42b27 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_plugin_types/plugin.d.ts @@ -0,0 +1,1852 @@ +/// + +declare namespace $ui { + /** + * Registers the plugin as UI plugin. + * @param fn - The setup function for the plugin. + */ + function register(fn: (ctx: Context) => void): void + + interface Context { + /** + * Screen navigation and management + */ + screen: Screen + /** + * Toast notifications + */ + toast: Toast + /** + * Actions + */ + action: Action + + /** + * DOM + */ + dom: DOM + + /** + * Playback + */ + playback: Playback + + /** + * MPV + */ + mpv: MPV + + /** + * Notifications + */ + notification: Notification + + /** + * Manga + */ + manga: Manga + + /** + * Anime + */ + anime: Anime + + /** + * Discord + */ + discord: Discord + + /** + * Continuity + */ + continuity: Continuity + + /** + * Auto Scanner + */ + autoScanner: AutoScanner + + /** + * External Player Link + */ + externalPlayerLink: ExternalPlayerLink + + /** + * Auto Downloader + */ + autoDownloader: AutoDownloader + + /** + * Filler Manager + */ + fillerManager: FillerManager + + /** + * Torrent Client + */ + torrentClient: TorrentClient + + /** + * Creates a new state object with an initial value. + * @param initialValue - The initial value for the state + * @returns A state object that can be used to get and set values + */ + state(initialValue?: T): State + + /** + * Sets a timeout to execute a function after a delay. + * @param fn - The function to execute + * @param delay - The delay in milliseconds + * @returns A function to cancel the timeout + */ + setTimeout(fn: () => void, delay: number): () => void + + /** + * Sets an interval to execute a function repeatedly. + * @param fn - The function to execute + * @param delay - The delay in milliseconds between executions + * @returns A function to cancel the interval + */ + setInterval(fn: () => void, delay: number): () => void + + /** + * Creates an effect that runs when dependencies change. + * @param fn - The effect function to run + * @param deps - Array of dependencies that trigger the effect + * @returns A function to clean up the effect + */ + effect(fn: () => void, deps: State[]): () => void + + /** + * Makes a fetch request. + * @param url - The URL to fetch + * @param options - Fetch options + * @returns A promise that resolves to the fetch response + */ + fetch(url: string, options?: FetchOptions): Promise + + /** + * Registers an event handler for the plugin. + * @param eventName - The unique event identifier to register the handler for. + * @param handler - The handler to register. + * @returns A function to unregister the handler. + */ + registerEventHandler(eventName: string, handler: (event: any) => void): () => void + + /** + * Registers an event handler for the plugin. + * @param uniqueKey - A unique key to identify the handler. This is to avoid memory leaks caused by re-rendering the same component. + * @param handler - The handler to register. + * @returns The event handler id. + */ + eventHandler(uniqueKey: string, handler: (event: any) => void): string + + /** + * Registers a field reference for field components. + * @param fieldName - The name of the field + * @returns A field reference object + */ + fieldRef(defaultValue?: T): FieldRef + + /** + * Creates a new tray icon. + * @param options - The options for the tray icon. + * @returns A tray icon object. + */ + newTray(options: TrayOptions): Tray + + /** + * Creates a new command palette. + * @param options - The options for the command palette + * @returns A command palette object + */ + newCommandPalette(options: CommandPaletteOptions): CommandPalette + } + + interface State { + /** The current value */ + value: T + /** Length of the value if it's a string */ + length?: number + + /** Gets the current value */ + get(): T + + /** Sets a new value */ + set(value: T | ((prev: T) => T)): void + } + + interface FetchOptions { + /** HTTP method, defaults to GET */ + method?: string + /** Request headers */ + headers?: Record + /** Request body */ + body?: any + /** Whether to bypass cloudflare */ + noCloudflareBypass?: boolean + /** Timeout in seconds, defaults to 35 */ + timeout?: number + } + + interface FetchResponse { + /** Response status code */ + status: number + /** Response status text */ + statusText: string + /** Request method used */ + method: string + /** Raw response headers */ + rawHeaders: Record + /** Whether the response was successful (status in range 200-299) */ + ok: boolean + /** Request URL */ + url: string + /** Response headers */ + headers: Record + /** Response cookies */ + cookies: Record + /** Whether the response was redirected */ + redirected: boolean + /** Response content type */ + contentType: string + /** Response content length */ + contentLength: number + /** Get response text */ + text(): string + + /** Parse response as JSON */ + json(): T + } + + interface FieldRef { + /** The current value of the field */ + current: T + + /** Sets the value of the field */ + setValue(value: T): void + + /** Sets the callback to be called when the value changes */ + onValueChange(callback: (value: T) => void): void + } + + interface TrayOptions { + /** URL of the tray icon */ + iconUrl: string + /** Whether the tray has content */ + withContent: boolean + /** Width of the tray */ + width?: string + /** Minimum height of the tray */ + minHeight?: string + } + + interface Tray { + /** UI components for building tray content */ + div: DivComponentFunction + flex: FlexComponentFunction + stack: StackComponentFunction + text: TextComponentFunction + button: ButtonComponentFunction + anchor: AnchorComponentFunction + input: InputComponentFunction + select: SelectComponentFunction + checkbox: CheckboxComponentFunction + radioGroup: RadioGroupComponentFunction + switch: SwitchComponentFunction + + /** Invoked when the tray icon is clicked */ + onClick(cb: () => void): void + + /** Invoked when the tray icon is opened */ + onOpen(cb: () => void): void + + /** Invoked when the tray icon is closed */ + onClose(cb: () => void): void + + /** Registers the render function for the tray content */ + render(fn: () => void): void + + /** Schedules a re-render of the tray content */ + update(): void + + /** Opens the tray */ + open(): void + + /** Closes the tray */ + close(): void + + /** Updates the badge number of the tray icon. 0 = no badge. Default intent is "info". */ + updateBadge(options: { number: number, intent?: "success" | "error" | "warning" | "info" }): void + } + + interface Playback { + /** + * Plays a file using the media player + * @param filePath - The path to the file to play + * @returns A promise that resolves when the file has started playing + */ + playUsingMediaPlayer(filePath: string): Promise + + /** + * Streams a file using the media player + * @param windowTitle - The title of the window + * @param streamUrl - The URL of the stream to play + * @param anime - The anime object + * @param aniDbEpisode - The AniDB episode number + * @throws Error if an error occurs + */ + streamUsingMediaPlayer(windowTitle: string, streamUrl: string, anime: $app.AL_BaseAnime, aniDbEpisode: string): Promise + + /** + * Registers an event listener for the playback instance + * @param callback - The callback to call when the event occurs + * @returns A function to remove the event listener + */ + registerEventListener(callback: (event: PlaybackEvent) => void): () => void + + /** + * Cancels the tracking of the current media being played. + * Note that this does not stop/close the media player. + * @throws Error if an error occurs, or if the playback is not running + */ + cancel(): void + + /** + * Pauses the playback + * @throws Error if an error occurs, or if the playback is not running + */ + pause(): void + + /** + * Resumes the playback + * @throws Error if an error occurs, or if the playback is not paused + */ + resume(): void + + /** + * Seeks to a specific position in the playback + * @param seconds - The position to seek to + * @throws Error if an error occurs, or if the playback is not running + */ + seek(seconds: number): void + + /** + * Gets the next episode to play for the current media being played + * @returns The next episode to play + */ + getNextEpisode(): Promise<$app.Anime_LocalFile | undefined> + + /** + * Plays the next episode for the current media being played + * @throws Error if an error occurs, or if the playback is not running + */ + playNextEpisode(): Promise + + } + + interface PlaybackEvent { + /** Whether the video has started */ + isVideoStarted: boolean + /** Whether the video has stopped */ + isVideoStopped: boolean + /** Whether the video has completed */ + isVideoCompleted: boolean + /** Whether the stream has started */ + isStreamStarted: boolean + /** Whether the stream has stopped */ + isStreamStopped: boolean + /** Whether the stream has completed */ + isStreamCompleted: boolean + /** The event that occurred when the video started */ + startedEvent: { filename: string } + /** The event that occurred when the video stopped */ + stoppedEvent: { reason: string } + /** The event that occurred when the video completed */ + completedEvent: { filename: string } + /** The state of the playback */ + state: PlaybackState + /** The status of the playback */ + status: PlaybackStatus + } + + interface PlaybackState { + /** The episode number */ + episodeNumber: number + /** The title of the media */ + mediaTitle: string + /** The cover image of the media */ + mediaCoverImage: string + /** The total number of episodes */ + mediaTotalEpisodes: number + /** The filename */ + filename: string + /** The completion percentage */ + completionPercentage: number + /** Whether the next episode can be played */ + canPlayNext: boolean + /** Whether the progress has been updated */ + progressUpdated: boolean + /** The media ID */ + mediaId: number + } + + interface PlaybackStatus { + /** The completion percentage of the playback */ + completionPercentage: number + /** Whether the playback is playing */ + playing: boolean + /** The filename */ + filename: string + /** The path */ + path: string + /** The duration of the playback, in milliseconds */ + duration: number + /** The filepath */ + filepath: string + /** The current time in seconds */ + currentTimeInSeconds: number + /** The duration in seconds */ + durationInSeconds: number + } + + interface MPV { + /** + * Opens and plays a file + * @throws Error if an error occurs + * @returns A promise that resolves when the file has started playing + */ + openAndPlay(filePath: string): Promise + + /** + * Stops the playback + * @returns A promise that resolves when the playback has stopped + */ + stop(): Promise + + /** + * Returns the connection object + * @returns The connection object or undefined if the connection is not open + */ + getConnection(): MpvConnection | undefined + + /** + * Registers an event listener for the MPV instance + * + * Some properties are already observed by default with the following IDs: + * - 42 = time-pos + * - 43 = pause + * - 44 = duration + * - 45 = filename + * - 46 = path + * + * You can observe other properties by getting the connection object and calling conn.call("observe_property", id, property) + * + * @param event - The event to listen for + * @param callback - The callback to call when the event occurs + */ + onEvent(callback: (event: MpvEvent) => void): void + } + + interface MpvConnection { + /** + * Calls a command on the MPV instance + * @param command - The command to call + * @param args - The arguments to pass to the command + */ + call(...args: any[]): void + + /** + * Sets a property on the MPV instance + * @param property - The property to set + * @param value - The value to set the property to + */ + set(property: string, value: any): void + + /** + * Gets a property from the MPV instance + * @param property - The property to get + * @returns The value of the property + */ + get(property: string): any + + /** + * Closes the connection to the MPV instance + */ + close(): void + + /** + * Whether the connection is closed + */ + isClosed(): boolean + } + + interface MpvEvent { + /** The name of the event */ + event: string + /** The data associated with the event */ + data: any + /** The reason for the event */ + reason: string + /** The prefix for the event */ + prefix: string + /** The level of the event */ + level: string + /** The text of the event */ + text: string + /** The ID of the event */ + id: number + } + + interface Action { + /** + * Creates a new button for the anime page + * @param props - Button properties + */ + newAnimePageButton(props: { label: string, intent?: Intent, style?: Record }): ActionObject<{ media: $app.AL_BaseAnime }> + + /** + * Creates a new dropdown menu item for the anime page + * @param props - Dropdown item properties + */ + newAnimePageDropdownItem(props: { label: string, style?: Record }): ActionObject<{ media: $app.AL_BaseAnime }> + + /** + * Creates a new dropdown menu item for the anime library + * @param props - Dropdown item properties + */ + newAnimeLibraryDropdownItem(props: { label: string, style?: Record }): ActionObject + + /** + * Creates a new context menu item for media cards + * @param props - Context menu item properties + */ + newMediaCardContextMenuItem(props: { + label: string, + for?: F, + style?: Record + }): ActionObject<{ + media: F extends "anime" ? $app.AL_BaseAnime : F extends "manga" ? $app.AL_BaseManga : $app.AL_BaseAnime | $app.AL_BaseManga + }> & { + /** Sets the 'for' property of the action */ + setFor(forMedia: "anime" | "manga" | "both"): void + } + + /** + * Creates a new button for the manga page + * @param props - Button properties + */ + newMangaPageButton(props: { label: string, intent?: Intent, style?: Record }): ActionObject<{ media: $app.AL_BaseManga }> + + /** + * Creates a new context menu item for the episode card + * @param props - Context menu item properties + */ + newEpisodeCardContextMenuItem(props: { label: string, style?: Record }): ActionObject<{ episode: $app.Anime_Episode }> + + /** + * Creates a new menu item for the episode grid item + * @param props - Menu item properties + */ + newEpisodeGridItemMenuItem(props: { + label: string, + style?: Record, + type: "library" | "torrentstream" | "debridstream" | "onlinestream" | "undownloaded" | "medialinks" | "mediastream" + }): ActionObject<{ + episode: $app.Anime_Episode | $app.Onlinestream_Episode, + type: "library" | "torrentstream" | "debridstream" | "onlinestream" | "undownloaded" | "medialinks" | "mediastream" + }> + } + + interface ActionObject { + /** Mounts the action to make it visible */ + mount(): void + + /** Unmounts the action to hide it */ + unmount(): void + + /** Sets the label of the action */ + setLabel(label: string): void + + /** Sets the style of the action */ + setStyle(style: Record): void + + /** Sets the click handler for the action */ + onClick(handler: (event: E) => void): void + + /** Sets the intent of the action */ + setIntent(intent: Intent): void + } + + interface CommandPaletteOptions { + /** Placeholder text for the command palette input */ + placeholder?: string + /** Keyboard shortcut to open the command palette */ + keyboardShortcut?: string + } + + interface CommandPalette { + /** UI components for building command palette items */ + div: DivComponentFunction + flex: FlexComponentFunction + stack: StackComponentFunction + text: TextComponentFunction + button: ButtonComponentFunction + anchor: AnchorComponentFunction + + /** Sets the items in the command palette */ + setItems(items: CommandPaletteItem[]): void + + /** Refreshes the command palette items */ + refresh(): void + + /** Sets the placeholder text */ + setPlaceholder(placeholder: string): void + + /** Opens the command palette */ + open(): void + + /** Closes the command palette */ + close(): void + + /** Sets the input value */ + setInput(input: string): void + + /** Gets the current input value */ + getInput(): string + + /** Called when the command palette is opened */ + onOpen(cb: () => void): void + + /** Called when the command palette is closed */ + onClose(cb: () => void): void + } + + interface CommandPaletteItem { + /** Label for the item */ + label?: string + /** Value associated with the item */ + value: string + /** + * Type of filtering to apply when the input changes. + * If not provided, the item will not be filtered. + */ + filterType?: "includes" | "startsWith" + /** Heading for the item group */ + heading?: string + /** Custom render function for the item */ + render?: () => void + /** Called when the item is selected */ + onSelect: () => void + } + + interface Screen { + /** Navigates to a specific path */ + navigateTo(path: string, searchParams?: Record): void + + /** Reloads the current screen */ + reload(): void + + /** Calls onNavigate with the current screen data */ + loadCurrent(): void + + /** Called when navigation occurs */ + onNavigate(cb: (event: { pathname: string, searchParams: Record }) => void): void + } + + interface Toast { + /** Shows a success toast */ + success(message: string): void + + /** Shows an error toast */ + error(message: string): void + + /** Shows an info toast */ + info(message: string): void + + /** Shows a warning toast */ + warning(message: string): void + } + + type ComponentFunction = (props: any) => void + type ComponentProps = { + style?: Record, + className?: string, + } + type FieldComponentProps = { + fieldRef?: FieldRef, + value?: V, + onChange?: string, + disabled?: boolean, + size?: "sm" | "md" | "lg", + + } & ComponentProps + + type DivComponentFunction = { + (props: { items: any[] } & ComponentProps): void + (items: any[], props?: ComponentProps): void + } + type FlexComponentFunction = { + (props: { items: any[], gap?: number, direction?: "row" | "column" } & ComponentProps): void + (items: any[], props?: { gap?: number, direction?: "row" | "column" } & ComponentProps): void + } + type StackComponentFunction = { + (props: { items: any[], gap?: number } & ComponentProps): void + (items: any[], props?: { gap?: number } & ComponentProps): void + } + type TextComponentFunction = { + (props: { text: string } & ComponentProps): void + (text: string, props?: ComponentProps): void + } + + /** + * @default size="sm" + */ + type ButtonComponentFunction = { + (props: { + label?: string, + onClick?: string, + intent?: Intent, + disabled?: boolean, + loading?: boolean, + size?: "xs" | "sm" | "md" | "lg" + } & ComponentProps): void + (label: string, + props?: { onClick?: string, intent?: Intent, disabled?: boolean, loading?: boolean, size?: "xs" | "sm" | "md" | "lg" } & ComponentProps, + ): void + } + /** + * @default target="_blank" + */ + type AnchorComponentFunction = { + (props: { + text: string, + href: string, + target?: string, + onClick?: string + } & ComponentProps): void + (text: string, + props: { href: string, target?: string, onClick?: string } & ComponentProps, + ): void + } + /** + * @default size="md" + */ + type InputComponentFunction = { + (props: { label?: string, placeholder?: string, textarea?: boolean, onSelect?: string } & FieldComponentProps): void + (label: string, props?: { placeholder?: string, textarea?: boolean, onSelect?: string } & FieldComponentProps): void + } + /** + * @default size="md" + */ + type SelectComponentFunction = { + (props: { label: string, placeholder?: string, options: { label: string, value: string }[] } & FieldComponentProps): void + (label: string, options: { placeholder?: string, value?: string, options: { label: string, value: string }[] } & FieldComponentProps): void + } + /** + * @default size="md" + */ + type CheckboxComponentFunction = { + (props: { label: string } & FieldComponentProps): void + (label: string, props?: FieldComponentProps): void + } + /** + * @default size="md" + */ + type RadioGroupComponentFunction = { + (props: { label: string, options: { label: string, value: string }[] } & FieldComponentProps): void + (label: string, options: { value?: string, options: { label: string, value: string }[] } & FieldComponentProps): void + } + /** + * @default side="right" + * @default size="sm" + */ + type SwitchComponentFunction = { + (props: { label: string, side?: "left" | "right" } & FieldComponentProps): void + (label: string, props?: { side?: "left" | "right" } & FieldComponentProps): void + } + + // DOM Element interface + interface DOMElement { + id: string + tagName: string + attributes: Record + textContent?: string + // Only available if withInnerHTML is true + innerHTML?: string + // Only available if withOuterHTML is true + outerHTML?: string + + /** + * Gets the text content of the element + * @returns A promise that resolves to the text content of the element + */ + getText(): Promise + + /** + * Sets the text content of the element + * @param text - The text content to set + */ + setText(text: string): void + + /** + * Gets the value of an attribute + * @param name - The name of the attribute + * @returns A promise that resolves to the value of the attribute + */ + getAttribute(name: string): Promise + + /** + * Gets all attributes of the element + * @returns A promise that resolves to a record of all attributes + */ + getAttributes(): Promise> + + /** + * Sets the value of an attribute + * @param name - The name of the attribute + * @param value - The value to set + */ + setAttribute(name: string, value: string): void + + /** + * Removes an attribute + * @param name - The name of the attribute + */ + removeAttribute(name: string): void + + /** + * Checks if the element has an attribute + * @param name - The name of the attribute + * @returns A promise that resolves to true if the attribute exists + */ + hasAttribute(name: string): Promise + + /** + * Gets a property of the element + * @param name - The name of the property + * @returns A promise that resolves to the value of the property + */ + getProperty(name: string): Promise + + /** + * Sets a property of the element + * @param name - The name of the property + * @param value - The value to set + */ + setProperty(name: string, value: any): void + + /** + * Adds a class to the element + * @param className - The class to add + */ + addClass(className: string): void + + /** + * Checks if the element has a class + * @param className - The class to check + * @returns A promise that resolves to true if the class exists + */ + hasClass(className: string): Promise + + /** + * Sets the CSS text of the element + * @param cssText - The CSS text to set + */ + setCssText(cssText: string): void + + /** + * Sets the style of the element + * @param property - The property to set + * @param value - The value to set + */ + setStyle(property: string, value: string): void + + /** + * Gets the style of the element + * @param property - Optional property to get. If omitted, returns all styles. + * @returns A promise that resolves to the value of the property or record of all styles + */ + getStyle(property?: string): Promise> + + /** + * Checks if the element has a style property set + * @param property - The property to check + * @returns A promise that resolves to true if the property is set + */ + hasStyle(property: string): Promise + + /** + * Removes a style property + * @param property - The property to remove + */ + removeStyle(property: string): void + + /** + * Gets the computed style of the element + * @param property - The property to get + * @returns A promise that resolves to the computed value of the property + */ + getComputedStyle(property: string): Promise + + /** + * Gets a data attribute (data-* attribute) + * @param key - The data attribute key (without the data- prefix) + * @returns A promise that resolves to the data attribute value + */ + getDataAttribute(key: string): Promise + + /** + * Gets all data attributes (data-* attributes) + * @returns A promise that resolves to a record of all data attributes + */ + getDataAttributes(): Promise> + + /** + * Sets a data attribute (data-* attribute) + * @param key - The data attribute key (without the data- prefix) + * @param value - The value to set + */ + setDataAttribute(key: string, value: string): void + + /** + * Removes a data attribute (data-* attribute) + * @param key - The data attribute key (without the data- prefix) + */ + removeDataAttribute(key: string): void + + /** + * Checks if the element has a data attribute + * @param key - The data attribute key (without the data- prefix) + * @returns A promise that resolves to true if the data attribute exists + */ + hasDataAttribute(key: string): Promise + + // DOM manipulation + /** + * Appends a child to the element + * @param child - The child to append + */ + append(child: DOMElement): void + + /** + * Inserts a sibling before the element + * @param sibling - The sibling to insert + */ + before(sibling: DOMElement): void + + /** + * Inserts a sibling after the element + * @param sibling - The sibling to insert + */ + after(sibling: DOMElement): void + + /** + * Removes the element + */ + remove(): void + + /** + * Gets the parent of the element + * @returns The parent of the element + */ + getParent(opts?: DOMQueryElementOptions): Promise + + /** + * Gets the children of the element + * @returns The children of the element + */ + getChildren(opts?: DOMQueryElementOptions): Promise + + // Events + addEventListener(event: string, callback: (event: any) => void): () => void + + /** + * Queries the DOM for elements that are descendants of this element and match the selector + * @param selector - The selector to query + * @returns A promise that resolves to an array of DOM elements + */ + query(selector: string): Promise + + /** + * Queries the DOM for a single element that is a descendant of this element and matches the selector + * @param selector - The selector to query + * @returns A promise that resolves to a DOM element or null if no element is found + */ + queryOne(selector: string): Promise + } + + interface DOMQueryElementOptions { + /** + * Whether to include the innerHTML of the element + */ + withInnerHTML?: boolean + + /** + * Whether to include the outerHTML of the element + */ + withOuterHTML?: boolean + + /** + * Whether to assign plugin-element IDs to all child elements + * This is useful when you need to interact with child elements directly + */ + identifyChildren?: boolean + } + + // DOM interface + interface DOM { + /** + * Queries the DOM for elements matching the selector + * @param selector - The selector to query + * @returns A promise that resolves to an array of DOM elements + */ + query(selector: string, opts?: DOMQueryElementOptions): Promise + + /** + * Queries the DOM for a single element matching the selector + * @param selector - The selector to query + * @returns A promise that resolves to a DOM element or null if no element is found + */ + queryOne(selector: string, opts?: DOMQueryElementOptions): Promise + + /** + * Observes changes to the DOM + * @param selector - The selector to observe + * @param callback - The callback to call when the DOM changes + * @returns A tuple containing a function to stop observing the DOM and a function to refetch observed elements + */ + observe(selector: string, callback: (elements: DOMElement[]) => void, opts?: DOMQueryElementOptions): [() => void, () => void] + + /** + * Observes changes to the DOM in the viewport + * @param selector - The selector to observe + * @param callback - The callback to call when the DOM changes + * @returns A tuple containing a function to stop observing the DOM and a function to refetch observed elements + */ + observeInView(selector: string, + callback: (elements: DOMElement[]) => void, + opts?: DOMQueryElementOptions & { margin?: string }, + ): [() => void, () => void] + + /** + * Creates a new DOM element + * @param tagName - The tag name of the element + * @returns A promise that resolves to a DOM element + */ + createElement(tagName: string): Promise + + /** + * Returns the DOM element from an element ID + * Note: No properties are available on this element, only methods, and there is no guarantee that the element exists + * @param elementId - The ID of the element + * @returns A DOM element + */ + asElement(elementId: string): Omit + + /** + * Called when the DOM is ready + * @param callback - The callback to call when the DOM is ready + */ + onReady(callback: () => void): void + } + + interface Notification { + /** + * Sends a system notification + * @param message - The message to send + */ + send(message: string): void + } + + interface Anime { + /** + * Get an anime entry + * @param mediaId - The ID of the anime + * @returns A promise that resolves to an anime entry + * @throws Error if a needed repository is not found + */ + getAnimeEntry(mediaId: number): Promise<$app.Anime_Entry> + } + + interface Manga { + /** + * Get a chapter container for a manga. + * This caches the chapter container if it exists. + * @param opts - The options for the chapter container + * @returns A promise that resolves to a chapter container + * @throws Error if the chapter container is not found or if the manga repository is not found + */ + getChapterContainer(opts: { + mediaId: number; + provider: string; + titles?: string[]; + year?: number; + }): Promise<$app.Manga_ChapterContainer | null> + + /** + * Get the downloaded chapters + * @returns A promise that resolves to an array of chapters grouped by provider and manga ID + */ + getDownloadedChapters(): Promise<$app.Manga_ChapterContainer[]> + + /** + * Get the manga collection + * @returns A promise that resolves to a manga collection + */ + getCollection(): Promise<$app.Manga_Collection> + + /** + * Deletes all cached chapters and refetches them based on the selected provider for that manga. + * + * @param selectedProviderMap - A map of manga IDs to provider IDs. Previously cached chapters for providers not in the map will be + * deleted. + * @returns A promise that resolves to void + */ + refreshChapters(selectedProviderMap: Record): Promise + + /** + * Empties cached chapters for a manga + * @param mediaId - The ID of the manga + * @returns A promise that resolves to void + */ + emptyCache(mediaId: number): Promise + + /** + * Get the manga providers + * @returns A map of provider IDs to provider names + */ + getProviders(): Record + } + + interface Discord { + /** + * Set the manga activity + * @param activity - The manga activity to set + */ + setMangaActivity(activity: $app.DiscordRPC_MangaActivity): void + + /** + * Set the anime activity with progress tracking. + * @param activity - The anime activity to set + */ + setAnimeActivity(activity: $app.DiscordRPC_AnimeActivity): void + + /** + * Update the current anime activity progress. + * Pausing the activity will cancel the activity on discord but retain the it in memory. + * @param progress - The progress of the anime in seconds + * @param duration - The duration of the anime in seconds + * @param paused - Whether the anime is paused + */ + updateAnimeActivity(progress: number, duration: number, paused: boolean): void + + /** + * Set the anime activity (no progress tracking) + * @param activity - The anime activity to set + */ + setLegacyAnimeActivity(activity: $app.DiscordRPC_LegacyAnimeActivity): void + + /** + * Cancels the current activity by closing the discord RPC client + */ + cancelActivity(): void + } + + interface Continuity { + /** + * Get the watch history. + * The returned object is not in any particular order. + * @returns A record of media IDs to watch history items + * @throws Error if something goes wrong + */ + getWatchHistory(): Record + + /** + * Delete a watch history item + * @param mediaId - The ID of the media + * @throws Error if something goes wrong + */ + deleteWatchHistoryItem(mediaId: number): void + + /** + * Update a watch history item + * @param mediaId - The ID of the media + * @param watchHistoryItem - The watch history item to update + * @throws Error if something goes wrong + */ + updateWatchHistoryItem(mediaId: number, watchHistoryItem: $app.Continuity_WatchHistoryItem): void + + /** + * Get a watch history item + * @param mediaId - The ID of the media + * @returns The watch history item + * @throws Error if something goes wrong + */ + getWatchHistoryItem(mediaId: number): $app.Continuity_WatchHistoryItem | undefined + } + + interface AutoScanner { + /** + * Notify the auto scanner to scan the libraries if it is enabled. + * This is a non-blocking call that simply schedules a scan if one is not already running planned. + */ + notify(): void + } + + interface ExternalPlayerLink { + /** + * Open a URL in the external player. + * @param url - The URL to open + * @param mediaId - The ID of the media (used for the modal) + * @param episodeNumber - The episode number (used for the modal) + */ + open(url: string, mediaId: number, episodeNumber: number): void + } + + interface AutoDownloader { + /** + * Run the auto downloader if it is enabled. + * This is a non-blocking call. + */ + run(): void + } + + interface FillerManager { + /** + * Get the filler episodes for a media ID + * @param mediaId - The media ID + * @returns The filler episodes + */ + getFillerEpisodes(mediaId: number): string[] + + /** + * Set the filler episodes for a media ID + * @param mediaId - The media ID + * @param fillerEpisodes - The filler episodes + */ + setFillerEpisodes(mediaId: number, fillerEpisodes: string[]): void + + /** + * Check if an episode is a filler + * @param mediaId - The media ID + * @param episodeNumber - The episode number + */ + isEpisodeFiller(mediaId: number, episodeNumber: number): boolean + + /** + * Hydrate the filler data for an anime entry + * @param e - The anime entry + */ + hydrateFillerData(e: $app.Anime_Entry): void + + /** + * Hydrate the filler data for an onlinestream episode + * @param mId - The media ID + * @param episodes - The episodes + */ + hydrateOnlinestreamFillerData(mId: number, episodes: $app.Onlinestream_Episode[]): void + + /** + * Remove the filler data for a media ID + * @param mediaId - The media ID + */ + removeFillerData(mediaId: number): void + } + + interface TorrentClient { + /** + * Get all torrents + * @returns A promise that resolves to an array of torrents + */ + getTorrents(): Promise<$app.TorrentClient_Torrent[]> + + /** + * Get the active torrents + * @returns A promise that resolves to an array of active torrents + */ + getActiveTorrents(): Promise<$app.TorrentClient_Torrent[]> + + /** + * Pause some torrents + * @param hashes - The hashes of the torrents to pause + */ + pauseTorrents(hashes: string[]): Promise + + /** + * Resume some torrents + * @param hashes - The hashes of the torrents to resume + */ + resumeTorrents(hashes: string[]): Promise + + /** + * Deselect some files from a torrent + * @param hash - The hash of the torrent + * @param indices - The indices of the files to deselect + */ + deselectFiles(hash: string, indices: number[]): Promise + + /** + * Get the files of a torrent + * @param hash - The hash of the torrent + * @returns A promise that resolves to an array of files + */ + getFiles(hash: string): Promise + } + + type Intent = + "primary" + | "primary-subtle" + | "alert" + | "alert-subtle" + | "warning" + | "warning-subtle" + | "success" + | "success-subtle" + | "white" + | "white-subtle" + | "gray" + | "gray-subtle" +} + +declare namespace $storage { + /** + * Sets a value in the storage. + * @param key - The key to set + * @param value - The value to set + * @throws Error if something goes wrong + */ + function set(key: string, value: any): void + + /** + * Gets a value from the storage. + * @param key - The key to get + * @returns The value associated with the key + * @throws Error if something goes wrong + */ + function get(key: string): T | undefined + + /** + * Removes a value from the storage. + * @param key - The key to remove + * @throws Error if something goes wrong + */ + function remove(key: string): void + + /** + * Drops the database. + * @throws Error if something goes wrong + */ + function drop(): void + + /** + * Clears all values from the storage. + * @throws Error if something goes wrong + */ + function clear(): void + + /** + * Returns all keys in the storage. + * @returns An array of all keys in the storage + * @throws Error if something goes wrong + */ + function keys(): string[] + + /** + * Checks if a key exists in the storage. + * @param key - The key to check + * @returns True if the key exists, false otherwise + * @throws Error if something goes wrong + */ + function has(key: string): boolean +} + +declare namespace $anilist { + /** + * Refresh the anime collection. + * This will cause the frontend to refetch queries that depend on the anime collection. + */ + function refreshAnimeCollection(): void + + /** + * Refresh the manga collection. + * This will cause the frontend to refetch queries that depend on the manga collection. + */ + function refreshMangaCollection(): void + + /** + * Update a media list entry. + * The anime/manga collection should be refreshed after updating the entry. + */ + function updateEntry( + mediaId: number, + status: $app.AL_MediaListStatus | undefined, + scoreRaw: number | undefined, + progress: number | undefined, + startedAt: $app.AL_FuzzyDateInput | undefined, + completedAt: $app.AL_FuzzyDateInput | undefined, + ): void + + /** + * Update a media list entry's progress. + * The anime/manga collection should be refreshed after updating the entry. + */ + function updateEntryProgress(mediaId: number, progress: number, totalCount: number | undefined): void + + /** + * Update a media list entry's repeat count. + * The anime/manga collection should be refreshed after updating the entry. + */ + function updateEntryRepeat(mediaId: number, repeat: number): void + + /** + * Delete a media list entry. + * The anime/manga collection should be refreshed after deleting the entry. + */ + function deleteEntry(mediaId: number): void + + /** + * Get the user's anime collection. + * This collection does not include lists with no status (custom lists). + */ + function getAnimeCollection(bypassCache: boolean): $app.AL_AnimeCollection + + /** + * Same as [$anilist.getAnimeCollection] but includes lists with no status (custom lists). + */ + function getRawAnimeCollection(bypassCache: boolean): $app.AL_AnimeCollection + + /** + * Get the user's manga collection. + * This collection does not include lists with no status (custom lists). + */ + function getMangaCollection(bypassCache: boolean): $app.AL_MangaCollection + + /** + * Same as [$anilist.getMangaCollection] but includes lists with no status (custom lists). + */ + function getRawMangaCollection(bypassCache: boolean): $app.AL_MangaCollection + + /** + * Get anime by ID + */ + function getAnime(id: number): $app.AL_BaseAnime + + /** + * Get manga by ID + */ + function getManga(id: number): $app.AL_BaseManga + + /** + * Get detailed anime info by ID + */ + function getAnimeDetails(id: number): $app.AL_AnimeDetailsById_Media + + /** + * Get detailed manga info by ID + */ + function getMangaDetails(id: number): $app.AL_MangaDetailsById_Media + + /** + * Get anime collection with relations + */ + function getAnimeCollectionWithRelations(): $app.AL_AnimeCollectionWithRelations + + /** + * Add media to collection. + * + * This will add the media to the collection with the status "PLANNING". + * + * The anime/manga collection should be refreshed after adding the media. + */ + function addMediaToCollection(mediaIds: number[]): void + + /** + * Get studio details + */ + function getStudioDetails(studioId: number): $app.AL_StudioDetails + + /** + * List anime based on search criteria + */ + function listAnime( + page: number | undefined, + search: string | undefined, + perPage: number | undefined, + sort: $app.AL_MediaSort[] | undefined, + status: $app.AL_MediaStatus[] | undefined, + genres: string[] | undefined, + averageScoreGreater: number | undefined, + season: $app.AL_MediaSeason | undefined, + seasonYear: number | undefined, + format: $app.AL_MediaFormat | undefined, + isAdult: boolean | undefined, + ): $app.AL_ListAnime + + /** + * List manga based on search criteria + */ + function listManga( + page: number | undefined, + search: string | undefined, + perPage: number | undefined, + sort: $app.AL_MediaSort[] | undefined, + status: $app.AL_MediaStatus[] | undefined, + genres: string[] | undefined, + averageScoreGreater: number | undefined, + startDateGreater: string | undefined, + startDateLesser: string | undefined, + format: $app.AL_MediaFormat | undefined, + countryOfOrigin: string | undefined, + isAdult: boolean | undefined, + ): $app.AL_ListManga + + /** + * List recent anime + */ + function listRecentAnime( + page: number | undefined, + perPage: number | undefined, + airingAtGreater: number | undefined, + airingAtLesser: number | undefined, + notYetAired: boolean | undefined, + ): $app.AL_ListRecentAnime + + /** + * Make a custom GraphQL query + */ + function customQuery(body: Record, token: string): T +} + +declare namespace $store { + /** + * Sets a value in the store. + * @param key - The key to set + * @param value - The value to set + */ + function set(key: string, value: any): void + + /** + * Gets a value from the store. + * @param key - The key to get + * @returns The value associated with the key + */ + function get(key: string): T + + /** + * Checks if a key exists in the store. + * @param key - The key to check + * @returns True if the key exists, false otherwise + */ + function has(key: string): boolean + + /** + * Gets a value from the store or sets it if it doesn't exist. + * @param key - The key to get or set + * @param setFunc - The function to set the value + * @returns The value associated with the key + */ + function getOrSet(key: string, setFunc: () => T): T + + /** + * Sets a value in the store if it's less than the limit. + * @param key - The key to set + * @param value - The value to set + * @param maxAllowedElements - The maximum allowed elements + */ + function setIfLessThanLimit(key: string, value: T, maxAllowedElements: number): boolean + + /** + * Unmarshals a JSON string. + * @param data - The JSON string to unmarshal + */ + function unmarshalJSON(data: string): void + + /** + * Marshals a value to a JSON string. + * @param value - The value to marshal + * @returns The JSON string + */ + function marshalJSON(value: any): string + + /** + * Resets the store. + */ + function reset(): void + + /** + * Gets all values from the store. + * @returns An array of all values in the store + */ + function values(): any[] + + /** + * Watches a key in the store. + * @param key - The key to watch + * @param callback - The callback to call when the key changes + */ + function watch(key: string, callback: (value: T) => void): void +} + +/** + * Cron + */ + +declare namespace $cron { + /** + * Adds a cron job + * @param id - The id of the cron job + * @param cronExpr - The cron expression + * @param fn - The function to call + */ + function add(id: string, cronExpr: string, fn: () => void): void + + /** + * Removes a cron job + * @param id - The id of the cron job + */ + function remove(id: string): void + + /** + * Removes all cron jobs + */ + function removeAll(): void + + /** + * Gets the total number of cron jobs + * @returns The total number of cron jobs + */ + function total(): number + + /** + * Starts the cron jobs, can be paused by calling stop() + */ + function start(): void + + /** + * Stops the cron jobs, can be resumed by calling start() + */ + function stop(): void + + /** + * Checks if the cron jobs have started + * @returns True if the cron jobs have started, false otherwise + */ + function hasStarted(): boolean +} + +/** + * Database + */ + +declare namespace $database { + + namespace localFiles { + /** + * Gets the local files + * @returns The local files + */ + function getAll(): $app.Anime_LocalFile[] + + /** + * Finds the local files by a filter function + * @param filterFn - The filter function + * @returns The local files + */ + function findBy(filterFn: (file: $app.Anime_LocalFile) => boolean): $app.Anime_LocalFile[] + + /** + * Saves the modified local files. This only works if the local files are already in the database. + * @param files - The local files to save + */ + function save(files: $app.Anime_LocalFile[]): $app.Anime_LocalFile[] + + /** + * Inserts the local files as a new entry + * @param files - The local files to insert + */ + function insert(files: $app.Anime_LocalFile[]): $app.Anime_LocalFile[] + } + + namespace anilist { + /** + * Get the Anilist token + * + * Permissions needed: anilist-token + * + * @returns The Anilist token + */ + function getToken(): string + + /** + * Get the Anilist username + */ + function getUsername(): string + } + + namespace autoDownloaderRules { + /** + * Gets all auto downloader rules + */ + function getAll(): $app.Anime_AutoDownloaderRule[] + + /** + * Gets an auto downloader rule by the database id + * @param id - The id of the auto downloader rule in the database + * @returns The auto downloader rule + */ + function get(id: number): $app.Anime_AutoDownloaderRule | undefined + + /** + * Gets all auto downloader rules by media id + * @param mediaId - The id of the media + * @returns The auto downloader rules + */ + function getByMediaId(mediaId: number): $app.Anime_AutoDownloaderRule[] + + /** + * Inserts an auto downloader rule + * @param rule - The auto downloader rule to insert + */ + function insert(rule: Omit<$app.Anime_AutoDownloaderRule, "dbId">): void + + /** + * Updates an auto downloader rule + * @param id - The id of the auto downloader rule in the database + * @param rule - The auto downloader rule to update + */ + function update(id: number, rule: Omit<$app.Anime_AutoDownloaderRule, "dbId">): void + + /** + * Deletes an auto downloader rule + * @param id - The id of the auto downloader rule in the database + */ + function remove(id: number): void + } + + namespace autoDownloaderItems { + /** + * Gets all auto downloader items + */ + function getAll(): $app.Models_AutoDownloaderItem[] + + /** + * Gets an auto downloader item by id + * @param id - The id of the auto downloader item in the database + */ + function get(id: number): $app.Models_AutoDownloaderItem | undefined + + /** + * Gets all auto downloader items by media id + * @param mediaId - The id of the media + */ + function getByMediaId(mediaId: number): $app.Models_AutoDownloaderItem[] + + /** + * Inserts an auto downloader item + * @param item - The auto downloader item to insert + */ + function insert(item: $app.Models_AutoDownloaderItem): void + + /** + * Deletes an auto downloader item + * @param id - The id of the auto downloader item in the database + */ + function remove(id: number): void + } + + namespace silencedMediaEntries { + /** + * Gets all silenced media entry ids + */ + function getAllIds(): number[] + + /** + * Checks if a media entry is silenced + * @param mediaId - The id of the media + * @returns True if the media entry is silenced, false otherwise + */ + function isSilenced(mediaId: number): boolean + + /** + * Sets a media entry as silenced + * @param mediaId - The id of the media + */ + function setSilenced(mediaId: number, silenced: boolean): void + } + + namespace mediaFillers { + /** + * Gets all media fillers + */ + function getAll(): Record + + /** + * Gets a media filler by media id + * @param mediaId - The id of the media + */ + function get(mediaId: number): MediaFillerItem | undefined + + /** + * Inserts a media filler + * @param provider - The provider of the media filler + * @param mediaId - The id of the media + * @param slug - The slug of the media filler + * @param fillerEpisodes - The filler episodes + */ + function insert(provider: string, mediaId: number, slug: string, fillerEpisodes: string[]): void + + /** + * Deletes a media filler + * @param mediaId - The id of the media + */ + function remove(mediaId: number): void + } + + interface MediaFillerItem { + /** + * The id of the media filler in the database + */ + dbId: number + /** + * The provider of the media filler + */ + provider: string + /** + * The id of the media + */ + mediaId: number + /** + * The slug of the media filler + */ + slug: string + /** + * The filler episodes + */ + fillerEpisodes: string[] + /** + * Date and time the filler data was last fetched + */ + lastFetchedAt: string + } +} + +declare namespace $app { + /** + * Gets the version of the app + * @returns The version of the app + */ + function getVersion(): string + + /** + * Gets the version name of the app + * @returns The version name of the app + */ + function getVersionName(): string + + /** + * Invalidates the queries on the client + * @param queryKeys - Keys of the queries to invalidate + */ + function invalidateClientQuery(queryKeys: string[]): void +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_plugin_types/system.d.ts b/seanime-2.9.10/internal/extension_repo/goja_plugin_types/system.d.ts new file mode 100644 index 0000000..edfc1d8 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_plugin_types/system.d.ts @@ -0,0 +1,822 @@ +/** + * OS module provides a platform-independent interface to operating system functionality. + * This is a restricted subset of Go's os package with permission checks. + */ +declare namespace $os { + /** The operating system (e.g., "darwin", "linux", "windows") */ + const platform: string + + /** The system architecture (e.g., "amd64", "arm64") */ + const arch: string + + /** + * Creates and executes a new command with the given arguments. + * Command execution is subject to permission checks. + * @param name The name of the command to run + * @param args The arguments to pass to the command + * @returns A command object or an error if the command is not authorized + */ + function cmd(name: string, ...args: string[]): $os.Cmd + + /** + * Reads the entire file specified by path. + * @param path The path to the file to read + * @returns The file contents as a byte array + * @throws Error if the path is not authorized for reading + */ + function readFile(path: string): Uint8Array + + /** + * Writes data to the named file, creating it if necessary. + * If the file exists, it is truncated. + * @param path The path to the file to write + * @param data The data to write to the file + * @param perm The file mode (permissions) + * @throws Error if the path is not authorized for writing + */ + function writeFile(path: string, data: Uint8Array, perm: number): void + + /** + * Reads a directory, returning a list of directory entries. + * @param path The path to the directory to read + * @returns An array of directory entries + * @throws Error if the path is not authorized for reading + */ + function readDir(path: string): $os.DirEntry[] + + /** + * Returns the default directory to use for temporary files. + * @returns The temporary directory path or empty string if not authorized + * @throws Error if the path is not authorized for reading + */ + function tempDir(): string + + /** + * Returns the user's configuration directory. + * @returns The configuration directory path or empty string if not authorized + * @throws Error if the path is not authorized for reading + */ + function configDir(): string + + /** + * Returns the user's home directory. + * @returns The home directory path or empty string if not authorized + * @throws Error if the path is not authorized for reading + */ + function homeDir(): string + + /** + * Returns the user's cache directory. + * @returns The cache directory path or empty string if not authorized + * @throws Error if the path is not authorized for reading + */ + function cacheDir(): string + + /** + * Changes the size of the named file. + * @param path The path to the file to truncate + * @param size The new size of the file + * @throws Error if the path is not authorized for writing + */ + function truncate(path: string, size: number): void + + /** + * Creates a new directory with the specified name and permission bits. + * @param path The path of the directory to create + * @param perm The permission bits + * @throws Error if the path is not authorized for writing + */ + function mkdir(path: string, perm: number): void + + /** + * Creates a directory named path, along with any necessary parents. + * @param path The path of the directory to create + * @param perm The permission bits + * @throws Error if the path is not authorized for writing + */ + function mkdirAll(path: string, perm: number): void + + /** + * Renames (moves) oldpath to newpath. + * @param oldpath The source path + * @param newpath The destination path + * @throws Error if either path is not authorized for writing + */ + function rename(oldpath: string, newpath: string): void + + /** + * Removes the named file or (empty) directory. + * @param path The path to remove + * @throws Error if the path is not authorized for writing + */ + function remove(path: string): void + + /** + * Removes path and any children it contains. + * @param path The path to remove recursively + * @throws Error if the path is not authorized for writing + */ + function removeAll(path: string): void + + /** + * Returns a FileInfo describing the named file. + * @param path The path to get information about + * @returns Information about the file + * @throws Error if the path is not authorized for reading + */ + function stat(path: string): $os.FileInfo + + /** + * Opens a file for reading and writing. + * @param path The path to the file to open + * @param flag The flags to open the file with + * @param perm The file mode (permissions) + * @returns A file object or an error if the file is not authorized for writing + */ + function openFile(path: string, flag: number, perm: number): $os.File + + + interface File { + chmod(mode: number): void + + chown(uid: number, gid: number): void + + close(): void + + fd(): number + + name(): string + + read(b: Uint8Array): number + + readAt(b: Uint8Array, off: number): number + + readDir(n: number): $os.DirEntry[] + + readFrom(r: $io.Reader): number + + readdir(n: number): $os.FileInfo[] + + readdirnames(n: number): string[] + + seek(offset: number, whence: number): number + + setDeadline(t: Date): void + + setReadDeadline(t: Date): void + + setWriteDeadline(t: Date): void + + stat(): $os.FileInfo + + sync(): void + + syscallConn(): any /* Not documented */ + truncate(size: number): void + + write(b: Uint8Array): number + + writeAt(b: Uint8Array, off: number): number + + writeString(s: string): number + + writeTo(w: $io.Writer): number + } + + /** + * Cmd represents an external command being prepared or run. + * A Cmd cannot be reused after calling its Run, Output or CombinedOutput methods. + */ + interface Cmd { + /** + * Args holds command line arguments, including the command as Args[0]. + * If the Args field is empty or nil, Run uses {Path}. + * In typical use, both Path and Args are set by calling Command. + */ + args: string[] + + /** + * If Cancel is non-nil, the command must have been created with CommandContext + * and Cancel will be called when the command's Context is done. + */ + cancel: () => void + + /** + * Dir specifies the working directory of the command. + * If Dir is the empty string, Run runs the command in the calling process's current directory. + */ + dir: string + + /** + * Env specifies the environment of the process. + * Each entry is of the form "key=value". + * If Env is nil, the new process uses the current process's environment. + */ + env: string[] + + /** Error information if the command failed */ + err: Error + + /** + * ExtraFiles specifies additional open files to be inherited by the new process. + * It does not include standard input, standard output, or standard error. + */ + extraFiles: $os.File[] + + /** + * Path is the path of the command to run. + * This is the only field that must be set to a non-zero value. + */ + path: string + + /** Process is the underlying process, once started. */ + process?: $os.Process + + /** ProcessState contains information about an exited process. */ + processState?: $os.ProcessState + + /** Standard error of the command */ + stderr: any + + /** Standard input of the command */ + stdin: any + + /** Standard output of the command */ + stdout: any + + /** SysProcAttr holds optional, operating system-specific attributes. */ + sysProcAttr?: any + + /** + * If WaitDelay is non-zero, it bounds the time spent waiting on two sources of + * unexpected delay in Wait: a child process that fails to exit after the associated + * Context is canceled, and a child process that exits but leaves its I/O pipes unclosed. + */ + waitDelay: number + + /** + * CombinedOutput runs the command and returns its combined standard output and standard error. + * @returns The combined output as a string or byte array + */ + combinedOutput(): string | number[] + + /** + * Environ returns a copy of the environment in which the command would be run as it is currently configured. + * @returns The environment variables as an array of strings + */ + environ(): string[] + + /** + * Output runs the command and returns its standard output. + * @returns The standard output as a string or byte array + */ + output(): string | number[] + + /** + * Run starts the specified command and waits for it to complete. + * The returned error is nil if the command runs, has no problems copying stdin, stdout, + * and stderr, and exits with a zero exit status. + */ + run(): void + + /** + * Start starts the specified command but does not wait for it to complete. + * If Start returns successfully, the c.Process field will be set. + */ + start(): void + + /** + * StderrPipe returns a pipe that will be connected to the command's standard error when the command starts. + * @returns A readable stream for the command's standard error + */ + stderrPipe(): any + + /** + * StdinPipe returns a pipe that will be connected to the command's standard input when the command starts. + * @returns A writable stream for the command's standard input + */ + stdinPipe(): any + + /** + * StdoutPipe returns a pipe that will be connected to the command's standard output when the command starts. + * @returns A readable stream for the command's standard output + */ + stdoutPipe(): any + + /** + * String returns a human-readable description of the command. + * It is intended only for debugging. + * @returns A string representation of the command + */ + string(): string + + /** + * Wait waits for the command to exit and waits for any copying to stdin or copying from stdout or stderr to complete. + * The command must have been started by Start. + */ + wait(): void + } + + interface Process { + kill(): void + + wait(): void + + signal(sig: Signal): void + } + + interface Signal { + string(): string + + signal(): void + } + + const Kill: Signal + const Interrupt: Signal + + interface ProcessState { + pid(): number + + string(): string + + exitCode(): number + } + + /** + * AsyncCmd represents an external command being prepared or run asynchronously. + */ + interface AsyncCmd { + /** + * Get the underlying $os.Cmd. + * To start the command, call start() on the underlying command, not run(). + * @returns The underlying $os.Cmd + */ + getCommand(): $os.Cmd + + /** + * Run the command + * @param callback The callback to call each time data is available from the command's stdout or stderr + * @param data The data from the command's stdout + * @param err The data from the command's stderr + * @param exitCode The exit code of the command + * @param signal The signal that terminated the command + */ + run(callback: (data: Uint8Array | undefined, + err: Uint8Array | undefined, + exitCode: number | undefined, + signal: string | undefined, + ) => void): void + } + + /** + * FileInfo describes a file and is returned by stat. + */ + interface FileInfo { + /** Base name of the file */ + name(): string + + /** Length in bytes for regular files system-dependent for others */ + size(): number + + /** File mode bits */ + mode(): number + + /** Modification time */ + modTime(): Date + + /** Abbreviation for mode().isDir() */ + isDir(): boolean + + /** Underlying data source (can return null) */ + sys(): any + } + + /** + * DirEntry is an entry read from a directory. + */ + interface DirEntry { + /** Returns the name of the file (or subdirectory) described by the entry */ + name(): string + + /** Reports whether the entry describes a directory */ + isDir(): boolean + + /** Returns the type bits for the entry */ + type(): number + + /** Returns the FileInfo for the file or subdirectory described by the entry */ + info(): $os.FileInfo + } + + /** + * Constants for file mode bits + */ + namespace FileMode { + /** Is a directory */ + const ModeDir: number + + /** Append-only */ + const ModeAppend: number + + /** Exclusive use */ + const ModeExclusive: number + + /** Temporary file */ + const ModeTemporary: number + + /** Symbolic link */ + const ModeSymlink: number + + /** Device file */ + const ModeDevice: number + + /** Named pipe (FIFO) */ + const ModeNamedPipe: number + + /** Unix domain socket */ + const ModeSocket: number + + /** Setuid */ + const ModeSetuid: number + + /** Setgid */ + const ModeSetgid: number + + /** Unix character device, when ModeDevice is set */ + const ModeCharDevice: number + + /** Sticky */ + const ModeSticky: number + + /** Non-regular file */ + const ModeIrregular: number + + /** Mask for the type bits. For regular files, none will be set */ + const ModeType: number + + /** Unix permission bits, 0o777 */ + const ModePerm: number + } +} + +/** + * Filepath module provides functions to manipulate file paths in a way compatible with the target operating system. + */ +declare namespace $filepath { + + const skipDir: GoError + + /** + * Returns the last element of path. + * @param path The path to get the base name from + * @returns The base name of the path + */ + function base(path: string): string + + /** + * Cleans the path by applying a series of rules. + * @param path The path to clean + * @returns The cleaned path + */ + function clean(path: string): string + + /** + * Returns all but the last element of path. + * @param path The path to get the directory from + * @returns The directory containing the file + */ + function dir(path: string): string + + /** + * Returns the file extension of path. + * @param path The path to get the extension from + * @returns The file extension (including the dot) + */ + function ext(path: string): string + + /** + * Converts path from slash-separated to OS-specific separator. + * @param path The path to convert + * @returns The path with OS-specific separators + */ + function fromSlash(path: string): string + + /** + * Returns a list of files matching the pattern in the base directory. + * @param basePath The base directory to search in + * @param pattern The glob pattern to match + * @returns An array of matching file paths + * @throws Error if the base path is not authorized for reading + */ + function glob(basePath: string, pattern: string): string[] + + /** + * Reports whether the path is absolute. + * @param path The path to check + * @returns True if the path is absolute + */ + function isAbs(path: string): boolean + + /** + * Joins any number of path elements into a single path. + * @param paths The path elements to join + * @returns The joined path + */ + function join(...paths: string[]): string + + /** + * Reports whether name matches the shell pattern. + * @param pattern The pattern to match against + * @param name The string to check + * @returns True if name matches pattern + */ + function match(pattern: string, name: string): boolean + + /** + * Returns the relative path from basepath to targpath. + * @param basepath The base path + * @param targpath The target path + * @returns The relative path + */ + function rel(basepath: string, targpath: string): string + + /** + * Splits path into directory and file components. + * @param path The path to split + * @returns An array with [directory, file] + */ + function split(path: string): [string, string] + + /** + * Splits a list of paths joined by the OS-specific ListSeparator. + * @param path The path list to split + * @returns An array of paths + */ + function splitList(path: string): string[] + + /** + * Converts path from OS-specific separator to slash-separated. + * @param path The path to convert + * @returns The path with forward slashes + */ + function toSlash(path: string): string + + /** + * Walks the file tree rooted at the specificed path, calling walkFn for each file or directory. + * It reads entire directories into memory before proceeding. + * @param root The root directory to start walking from + * @param walkFn The function to call for each file or directory + * @throws Error if the root path is not authorized for reading + */ + function walk(root: string, walkFn: (path: string, info: $os.FileInfo, err: GoError) => GoError): void + + /** + * Walks the file tree rooted at the specificed path, calling walkDirFn for each file or directory. + * @param root The root directory to start walking from + * @param walkDirFn The function to call for each file or directory + * @throws Error if the root path is not authorized for reading + */ + function walkDir(root: string, walkDirFn: (path: string, d: $os.DirEntry, err: GoError) => GoError): void +} + +type GoError = string | undefined + +/** + * Extra OS utilities not in the standard library. + */ +declare namespace $osExtra { + /** + * Unwraps an archive and moves its contents to the destination. + * @param src The source archive path + * @param dest The destination directory + * @throws Error if either path is not authorized for writing + */ + function unwrapAndMove(src: string, dest: string): void + + /** + * Extracts a ZIP archive to the destination directory. + * @param src The source ZIP file path + * @param dest The destination directory + * @throws Error if either path is not authorized for writing + */ + function unzip(src: string, dest: string): void + + /** + * Extracts a RAR archive to the destination directory. + * @param src The source RAR file path + * @param dest The destination directory + * @throws Error if either path is not authorized for writing + */ + function unrar(src: string, dest: string): void + + /** + * Returns the user's desktop directory. + * @returns The desktop directory path or empty string if not authorized + * @throws Error if the path is not authorized for reading + */ + function desktopDir(): string + + /** + * Returns the user's documents directory. + * @returns The documents directory path or empty string if not authorized + * @throws Error if the path is not authorized for reading + */ + function documentsDir(): string + + /** + * Returns the user's downloads directory. + * @returns The downloads directory path or empty string if not authorized + * @throws Error if the path is not authorized for reading + */ + function downloadDir(): string + + /** + * Creates a new AsyncCmd instance. + * Get the underlying $os.Cmd with getCommand(). + * @param name The name of the command to execute + * @param arg The arguments to pass to the command + * @returns A new AsyncCmd instance + */ + function asyncCmd(name: string, ...args: string[]): $os.AsyncCmd +} + +/** + * Downloader module for downloading files with progress tracking. + */ +declare namespace $downloader { + /** + * Download status constants + */ + type DownloadStatus = "downloading" | "completed" | "cancelled" | "error" + + /** + * Download progress information + */ + interface DownloadProgress { + /** Unique download identifier */ + id: string + /** Source URL */ + url: string + /** Destination file path */ + destination: string + /** Number of bytes downloaded so far */ + totalBytes: number + /** Total file size in bytes (if known) */ + totalSize: number + /** Download speed in bytes per second */ + speed: number + /** Download completion percentage (0-100) */ + percentage: number + /** Current download status */ + status: DownloadStatus + /** Error message if status is ERROR */ + error?: string + /** Time of the last progress update */ + lastUpdate: Date + /** Time when the download started */ + startTime: Date + } + + /** + * Download options + */ + interface DownloadOptions { + /** Timeout in seconds */ + timeout?: number + /** HTTP headers to send with the request */ + headers?: Record + } + + /** + * Starts a file download. + * @param url The URL to download from + * @param destination The path to save the file to + * @param options Download options + * @returns A unique download ID + * @throws Error if the destination path is not authorized for writing + */ + function download(url: string, destination: string, options?: DownloadOptions): string + + /** + * Watches a download for progress updates. + * @param downloadId The download ID to watch + * @param callback Function to call with progress updates + * @returns A function to cancel the watch + */ + function watch(downloadId: string, callback: (progress: DownloadProgress | undefined) => void): () => void + + /** + * Gets the current progress of a download. + * @param downloadId The download ID to check + * @returns The current download progress + */ + function getProgress(downloadId: string): DownloadProgress | undefined + + /** + * Lists all active downloads. + * @returns An array of download progress objects + */ + function listDownloads(): DownloadProgress[] + + /** + * Cancels a specific download. + * @param downloadId The download ID to cancel + * @returns True if the download was cancelled + */ + function cancel(downloadId: string): boolean + + /** + * Cancels all active downloads. + * @returns The number of downloads cancelled + */ + function cancelAll(): number +} + +/** + * MIME type utilities. + */ +declare namespace $mime { + /** + * Parses a MIME type string and returns the media type and parameters. + * @param contentType The MIME type string to parse + * @returns An object containing the media type and parameters + * @throws Error if parsing fails + */ + function parse(contentType: string): { mediaType: string; parameters: Record } +} + +/** + * IO module provides basic interfaces to I/O primitives. + * This is a restricted subset of Go's io package with permission checks. + */ +declare namespace $io { + /** + * Reader is the interface that wraps the basic Read method. + * Read reads up to len(p) bytes into p. It returns the number of bytes + * read (0 <= n <= len(p)) and any error encountered. + */ + interface Reader { + read(p: Uint8Array): number + } + + /** + * Writer is the interface that wraps the basic Write method. + * Write writes len(p) bytes from p to the underlying data stream. + * It returns the number of bytes written from p (0 <= n <= len(p)) + * and any error encountered that caused the write to stop early. + */ + interface Writer { + write(p: Uint8Array): number + } + + /** + * Closer is the interface that wraps the basic Close method. + * The behavior of Close after already being called is undefined. + */ + interface Closer { + close(): void + } + + /** + * ReadWriter is the interface that groups the basic Read and Write methods. + */ + interface ReadWriter extends Reader, Writer { + } + + /** + * ReadCloser is the interface that groups the basic Read and Close methods. + */ + interface ReadCloser extends Reader, Closer { + } + + /** + * WriteCloser is the interface that groups the basic Write and Close methods. + */ + interface WriteCloser extends Writer, Closer { + } + + /** + * ReadWriteCloser is the interface that groups the basic Read, Write and Close methods. + */ + interface ReadWriteCloser extends Reader, Writer, Closer { + } + + /** + * ReaderFrom is the interface that wraps the ReadFrom method. + * ReadFrom reads data from r until EOF or error. + * The return value n is the number of bytes read. + */ + interface ReaderFrom { + readFrom(r: Reader): number + } + + /** + * WriterTo is the interface that wraps the WriteTo method. + * WriteTo writes data to w until there's no more data to write or + * when an error occurs. The return value n is the number of bytes + * written. + */ + interface WriterTo { + writeTo(w: Writer): number + } +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_plugin_types/tsconfig.json b/seanime-2.9.10/internal/extension_repo/goja_plugin_types/tsconfig.json new file mode 100644 index 0000000..c6472f1 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_plugin_types/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "es2015", + "dom" + ], + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": false, + "forceConsistentCasingInFileNames": true + } +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_torrent_test/anime-torrent-provider.d.ts b/seanime-2.9.10/internal/extension_repo/goja_torrent_test/anime-torrent-provider.d.ts new file mode 100644 index 0000000..7d3df0b --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_torrent_test/anime-torrent-provider.d.ts @@ -0,0 +1,88 @@ +declare type AnimeProviderSmartSearchFilter = "batch" | "episodeNumber" | "resolution" | "query" | "bestReleases" + +declare type AnimeProviderType = "main" | "special" + +declare interface AnimeProviderSettings { + canSmartSearch: boolean + smartSearchFilters: AnimeProviderSmartSearchFilter[] + supportsAdult: boolean + type: AnimeProviderType +} + +declare interface Media { + id: number + idMal?: number + status?: string + format?: string + englishTitle?: string + romajiTitle?: string + episodeCount?: number + absoluteSeasonOffset?: number + synonyms: string[] + isAdult: boolean + startDate?: FuzzyDate +} + +declare interface FuzzyDate { + year: number + month?: number + day?: number +} + +declare interface AnimeSearchOptions { + media: Media + query: string +} + +declare interface AnimeSmartSearchOptions { + media: Media + query: string + batch: boolean + episodeNumber: number + resolution: string + anidbAID: number + anidbEID: number + bestReleases: boolean +} + +declare interface AnimeTorrent { + name: string + date: string + size: number + formattedSize: string + seeders: number + leechers: number + downloadCount: number + link: string + downloadUrl: string + magnetLink?: string + infoHash?: string + resolution?: string + isBatch?: boolean + episodeNumber?: number + releaseGroup?: string + isBestRelease: boolean + confirmed: boolean +} + +declare interface AnimeTorrentProvider { + // Returns the search results depending on the query. + search(opts: AnimeSearchOptions): Promise + + // Returns the search results depending on the search options. + smartSearch(opts: AnimeSmartSearchOptions): Promise + + // Returns the info hash of the torrent. + // This should just return the info hash without scraping the torrent page if already available. + getTorrentInfoHash(torrent: AnimeTorrent): Promise + + // Returns the magnet link of the torrent. + // This should just return the magnet link without scraping the torrent page if already available. + getTorrentMagnetLink(torrent: AnimeTorrent): Promise + + // Returns the latest torrents. + getLatest(): Promise + + // Returns the provider settings. + getSettings(): AnimeProviderSettings +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_torrent_test/my-torrent-provider.ts b/seanime-2.9.10/internal/extension_repo/goja_torrent_test/my-torrent-provider.ts new file mode 100644 index 0000000..3d4379d --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_torrent_test/my-torrent-provider.ts @@ -0,0 +1,176 @@ +/// + +class Provider { + + api = "https://nyaa.si/?page=rss" + + getSettings(): AnimeProviderSettings { + return { + canSmartSearch: false, + smartSearchFilters: [], + supportsAdult: false, + type: "main", + } + } + + async fetchTorrents(url: string): Promise { + + const furl = `${this.api}&q=+${encodeURIComponent(url)}&c=1_3` + + try { + console.log(furl) + const response = await fetch(furl) + + if (!response.ok) { + throw new Error(`Failed to fetch torrents, ${response.statusText}`) + } + + const xmlText = await response.text() + const torrents = this.parseXML(xmlText) + console.log(torrents) + + return torrents + } + catch (error) { + throw new Error(`Error fetching torrents: ${error}`) + } + } + + async search(opts: AnimeSearchOptions): Promise { + console.log(opts) + const torrents = await this.fetchTorrents(opts.query) + return torrents.map(t => this.toAnimeTorrent(t)) + } + + toAnimeTorrent(torrent: NyaaTorrent): AnimeTorrent { + return { + name: torrent.title, + date: new Date(torrent.timestamp * 1000).toISOString(), + size: torrent.total_size, + formattedSize: torrent.size, + seeders: torrent.seeders, + leechers: torrent.leechers, + downloadCount: torrent.torrent_download_count, + link: torrent.link, + downloadUrl: torrent.torrent_url, + magnetLink: torrent.magnet_uri, + infoHash: torrent.info_hash, + resolution: "", + isBatch: false, + isBestRelease: false, + confirmed: false, + } + } + + async smartSearch(opts: AnimeSmartSearchOptions): Promise { + const ret: AnimeTorrent[] = [] + return ret + } + + private parseXML(xmlText: string): NyaaTorrent[] { + const torrents: NyaaTorrent[] = [] + + // Helper to extract content between XML tags + const getTagContent = (xml: string, tag: string): string => { + const regex = new RegExp(`<${tag}[^>]*>([^<]*)`) + const match = xml.match(regex) + return match ? match[1].trim() : "" + } + + // Helper to extract content from nyaa namespace tags + const getNyaaTagContent = (xml: string, tag: string): string => { + const regex = new RegExp(`]*>([^<]*)`) + const match = xml.match(regex) + return match ? match[1].trim() : "" + } + + // Split XML into items + const itemRegex = /([\s\S]*?)<\/item>/g + let match + + let id = 1 + while ((match = itemRegex.exec(xmlText)) !== null) { + const itemXml = match[1] + + const title = getTagContent(itemXml, "title") + const link = getTagContent(itemXml, "link") + const pubDate = getTagContent(itemXml, "pubDate") + const seeders = parseInt(getNyaaTagContent(itemXml, "seeders")) || 0 + const leechers = parseInt(getNyaaTagContent(itemXml, "leechers")) || 0 + const downloads = parseInt(getNyaaTagContent(itemXml, "downloads")) || 0 + const infoHash = getNyaaTagContent(itemXml, "infoHash") + const size = getNyaaTagContent(itemXml, "size") + + // Convert size string (e.g., "571.3 MiB") to bytes + const sizeInBytes = (() => { + const match = size.match(/^([\d.]+)\s*([KMGT]iB)$/) + if (!match) return 0 + const [, num, unit] = match + const multipliers: { [key: string]: number } = { + "KiB": 1024, + "MiB": 1024 * 1024, + "GiB": 1024 * 1024 * 1024, + "TiB": 1024 * 1024 * 1024 * 1024, + } + return Math.round(parseFloat(num) * multipliers[unit]) + })() + + const torrent: NyaaTorrent = { + id: id++, + title, + link, + timestamp: Math.floor(new Date(pubDate).getTime() / 1000), + status: "success", + torrent_url: link, + info_hash: infoHash, + magnet_uri: `magnet:?xt=urn:btih:${infoHash}`, + seeders, + leechers, + torrent_download_count: downloads, + total_size: sizeInBytes, + size, + num_files: 1, + anidb_aid: 0, + anidb_eid: 0, + anidb_fid: 0, + article_url: link, + article_title: title, + website_url: "https://nyaa.si", + } + + torrents.push(torrent) + } + + return torrents + } +} + +type NyaaTorrent = { + id: number + title: string + link: string + timestamp: number + status: string + size: string + tosho_id?: number + nyaa_id?: number + nyaa_subdom?: any + anidex_id?: number + torrent_url: string + info_hash: string + info_hash_v2?: string + magnet_uri: string + seeders: number + leechers: number + torrent_download_count: number + tracker_updated?: any + nzb_url?: string + total_size: number + num_files: number + anidb_aid: number + anidb_eid: number + anidb_fid: number + article_url: string + article_title: string + website_url: string +} diff --git a/seanime-2.9.10/internal/extension_repo/goja_torrent_test/tsconfig.json b/seanime-2.9.10/internal/extension_repo/goja_torrent_test/tsconfig.json new file mode 100644 index 0000000..babea55 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/goja_torrent_test/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "es2015", + "dom" + ], + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/seanime-2.9.10/internal/extension_repo/manga_provider_test.go b/seanime-2.9.10/internal/extension_repo/manga_provider_test.go new file mode 100644 index 0000000..d7a681e --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/manga_provider_test.go @@ -0,0 +1,80 @@ +package extension_repo_test + +import ( + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/require" + "seanime/internal/extension" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/manga/providers" + "seanime/internal/util" + "testing" +) + +// Tests the external manga provider extension loaded from the extension directory. +// This will load the extensions from ./testdir +func TestExternalGoMangaExtension(t *testing.T) { + + repo := getRepo(t) + + // Load all extensions + // This should load all the extensions in the directory + repo.ReloadExternalExtensions() + + ext, found := repo.GetMangaProviderExtensionByID("mangapill-external") + require.True(t, found) + + t.Logf("\nExtension:\n\tID: %s \n\tName: %s", ext.GetID(), ext.GetName()) + + // Test the extension + so := hibikemanga.SearchOptions{ + Query: "Dandadan", + } + + searchResults, err := ext.GetProvider().Search(so) + require.NoError(t, err) + require.GreaterOrEqual(t, len(searchResults), 1) + + chapters, err := ext.GetProvider().FindChapters(searchResults[0].ID) + require.NoError(t, err) + require.GreaterOrEqual(t, len(chapters), 1) + + spew.Dump(chapters[0]) + +} + +// Tests the built-in manga provider extension +func TestBuiltinMangaExtension(t *testing.T) { + + logger := util.NewLogger() + repo := getRepo(t) + + // Load all extensions + // This should load all the extensions in the directory + repo.ReloadBuiltInExtension(extension.Extension{ + ID: "seanime-builtin-mangapill", + Type: "manga-provider", + Name: "Mangapill", + Version: "0.0.0", + Language: "go", + ManifestURI: "", + Description: "", + Author: "", + Payload: "", + }, manga_providers.NewMangapill(logger)) + + ext, found := repo.GetMangaProviderExtensionByID("seanime-builtin-mangapill") + require.True(t, found) + + t.Logf("\nExtension:\n\tID: %s \n\tName: %s", ext.GetID(), ext.GetName()) + + // Test the extension + so := hibikemanga.SearchOptions{ + Query: "Dandadan", + } + + searchResults, err := ext.GetProvider().Search(so) + require.NoError(t, err) + + spew.Dump(searchResults) + +} diff --git a/seanime-2.9.10/internal/extension_repo/mapper.go b/seanime-2.9.10/internal/extension_repo/mapper.go new file mode 100644 index 0000000..d30f793 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/mapper.go @@ -0,0 +1,78 @@ +package extension_repo + +import ( + "reflect" + "strings" + "unicode" + + "github.com/dop251/goja" +) + +var ( + _ goja.FieldNameMapper = (*FieldMapper)(nil) +) + +// FieldMapper provides custom mapping between Go and JavaScript property names. +// +// It is similar to the builtin "uncapFieldNameMapper" but also converts +// all uppercase identifiers to their lowercase equivalent (eg. "GET" -> "get"). +// It also checks for JSON tags and uses them if they exist. +type FieldMapper struct { +} + +// FieldName implements the [FieldNameMapper.FieldName] interface method. +func (u FieldMapper) FieldName(t reflect.Type, f reflect.StructField) string { + // First check for a JSON tag + if jsonTag := f.Tag.Get("json"); jsonTag != "" { + // Split by comma to handle cases like `json:"name,omitempty"` + parts := strings.Split(jsonTag, ",") + // If the JSON tag isn't "-" (which means don't include this field) + if parts[0] != "-" && parts[0] != "" { + return parts[0] + } + } + // Fall back to default conversion + return convertGoToJSName(f.Name) +} + +// MethodName implements the [FieldNameMapper.MethodName] interface method. +func (u FieldMapper) MethodName(_ reflect.Type, m reflect.Method) string { + return convertGoToJSName(m.Name) +} + +var nameExceptions = map[string]string{"OAuth2": "oauth2"} + +func convertGoToJSName(name string) string { + if v, ok := nameExceptions[name]; ok { + return v + } + + startUppercase := make([]rune, 0, len(name)) + + for _, c := range name { + if c != '_' && !unicode.IsUpper(c) && !unicode.IsDigit(c) { + break + } + + startUppercase = append(startUppercase, c) + } + + totalStartUppercase := len(startUppercase) + + // all uppercase eg. "JSON" -> "json" + if len(name) == totalStartUppercase { + return strings.ToLower(name) + } + + // eg. "JSONField" -> "jsonField" + if totalStartUppercase > 1 { + return strings.ToLower(name[0:totalStartUppercase-1]) + name[totalStartUppercase-1:] + } + + // eg. "GetField" -> "getField" + if totalStartUppercase == 1 { + return strings.ToLower(name[0:1]) + name[1:] + } + + return name +} diff --git a/seanime-2.9.10/internal/extension_repo/marketplace.go b/seanime-2.9.10/internal/extension_repo/marketplace.go new file mode 100644 index 0000000..ccaa1c1 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/marketplace.go @@ -0,0 +1,50 @@ +package extension_repo + +import ( + "fmt" + "io" + "seanime/internal/constants" + "seanime/internal/extension" + "seanime/internal/util" + + "github.com/goccy/go-json" + "github.com/samber/lo" +) + +func (r *Repository) GetMarketplaceExtensions(url string) (extensions []*extension.Extension, err error) { + defer util.HandlePanicInModuleWithError("extension_repo/GetMarketplaceExtensions", &err) + + marketplaceUrl := constants.DefaultExtensionMarketplaceURL + if url != "" { + marketplaceUrl = url + } + + return r.getMarketplaceExtensions(marketplaceUrl) +} + +func (r *Repository) getMarketplaceExtensions(url string) (extensions []*extension.Extension, err error) { + resp, err := r.client.Get(url) + if err != nil { + r.logger.Error().Err(err).Msgf("marketplace: Failed to get marketplace extension: %s", url) + return nil, fmt.Errorf("failed to get marketplace extension: %s", url) + } + defer resp.Body.Close() + + bodyR, err := io.ReadAll(resp.Body) + if err != nil { + r.logger.Error().Err(err).Msgf("marketplace: Failed to read marketplace extension: %s", url) + return nil, fmt.Errorf("failed to read marketplace extension: %s", url) + } + + err = json.Unmarshal(bodyR, &extensions) + if err != nil { + r.logger.Error().Err(err).Msgf("marketplace: Failed to unmarshal marketplace extension: %s", url) + return nil, fmt.Errorf("failed to unmarshal marketplace extension: %s", url) + } + + extensions = lo.Filter(extensions, func(item *extension.Extension, _ int) bool { + return item.ID != "" && item.ManifestURI != "" + }) + + return +} diff --git a/seanime-2.9.10/internal/extension_repo/mediaplayer_testdir/mobileplayer.go b/seanime-2.9.10/internal/extension_repo/mediaplayer_testdir/mobileplayer.go new file mode 100644 index 0000000..fe5b795 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/mediaplayer_testdir/mobileplayer.go @@ -0,0 +1,85 @@ +package mediaplayer_testdir + +//import ( +// "fmt" +// "strings" +// +// hibikemediaplayer "seanime/internal/extension/hibike/mediaplayer" +//) +// +//type ( +// // MobilePlayer is an extension that sends media links the mobile device's media player. +// MobilePlayer struct { +// config mobilePlayerConfig +// } +// +// mobilePlayerConfig struct { +// iosPlayer string +// androidPlayer string +// } +//) +// +//func NewMediaPlayer() hibikemediaplayer.MediaPlayer { +// return &MobilePlayer{} +//} +// +//func (m *MobilePlayer) InitConfig(config map[string]interface{}) { +// iosPlayer, _ := config["iosPlayer"].(string) +// androidPlayer, _ := config["androidPlayer"].(string) +// +// m.config = mobilePlayerConfig{ +// iosPlayer: iosPlayer, +// androidPlayer: androidPlayer, +// } +//} +// +//func (m *MobilePlayer) GetSettings() hibikemediaplayer.Settings { +// return hibikemediaplayer.Settings{ +// CanTrackProgress: false, +// } +//} +// +//func (m *MobilePlayer) Play(req hibikemediaplayer.PlayRequest) (*hibikemediaplayer.PlayResponse, error) { +// return m.getPlayResponse(req) +//} +// +//func (m *MobilePlayer) Stream(req hibikemediaplayer.PlayRequest) (*hibikemediaplayer.PlayResponse, error) { +// return m.getPlayResponse(req) +//} +// +//func (m *MobilePlayer) getPlayResponse(req hibikemediaplayer.PlayRequest) (*hibikemediaplayer.PlayResponse, error) { +// var url string +// if req.ClientInfo.Platform == "ios" { +// // Play on iOS +// switch m.config.iosPlayer { +// case "outplayer": +// url = getOutplayerUrl(req.Path) +// } +// } +// +// if url == "" { +// return nil, fmt.Errorf("no player found for platform %s", req.ClientInfo.Platform) +// } +// +// return &hibikemediaplayer.PlayResponse{ +// OpenURL: url, +// }, nil +//} +// +//func getOutplayerUrl(url string) (ret string) { +// ret = strings.Replace(url, "http://", "outplayer://", 1) +// ret = strings.Replace(ret, "https://", "outplayer://", 1) +// return +//} +// +//func (m *MobilePlayer) GetPlaybackStatus() (*hibikemediaplayer.PlaybackStatus, error) { +// return nil, fmt.Errorf("not implemented") +//} +// +//func (m *MobilePlayer) Start() error { +// return nil +//} +// +//func (m *MobilePlayer) Stop() error { +// return nil +//} diff --git a/seanime-2.9.10/internal/extension_repo/mediaplayer_testdir/mobileplayer.json b/seanime-2.9.10/internal/extension_repo/mediaplayer_testdir/mobileplayer.json new file mode 100644 index 0000000..fbf829d --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/mediaplayer_testdir/mobileplayer.json @@ -0,0 +1,46 @@ +{ + "id": "mobileplayer", + "name": "MobilePlayer", + "description": "", + "version": "0.0.1", + "type": "mediaplayer", + "manifestURI": "", + "language": "go", + "author": "Seanime", + "config": { + "requiresConfig": true, + "fields": [ + { + "type": "select", + "label": "iOS Player", + "name": "iosPlayer", + "options": [ + { + "label": "Outplayer", + "value": "outplayer" + }, + { + "label": "VLC", + "value": "vlc" + } + ] + }, + { + "type": "select", + "label": "Android Player", + "name": "androidPlayer", + "options": [ + { + "label": "VLC", + "value": "vlc" + }, + { + "label": "MX Player", + "value": "mxplayer" + } + ] + } + ] + }, + "payload": "" +} diff --git a/seanime-2.9.10/internal/extension_repo/mock.go b/seanime-2.9.10/internal/extension_repo/mock.go new file mode 100644 index 0000000..f0fb3dc --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/mock.go @@ -0,0 +1,169 @@ +package extension_repo + +import ( + "seanime/internal/events" + "seanime/internal/extension" + "seanime/internal/manga/providers" + "seanime/internal/onlinestream/providers" + "seanime/internal/torrents/animetosho" + "seanime/internal/torrents/nyaa" + "seanime/internal/torrents/seadex" + "seanime/internal/util" + "seanime/internal/util/filecache" + "testing" +) + +func GetMockExtensionRepository(t *testing.T) *Repository { + logger := util.NewLogger() + filecacher, _ := filecache.NewCacher(t.TempDir()) + extensionRepository := NewRepository(&NewRepositoryOptions{ + Logger: logger, + ExtensionDir: t.TempDir(), + WSEventManager: events.NewMockWSEventManager(logger), + FileCacher: filecacher, + }) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "comick", + Name: "ComicK", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeMangaProvider, + Author: "Seanime", + Description: "", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/comick.webp", + }, manga_providers.NewComicK(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "comick-multi", + Name: "ComicK (Multi)", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeMangaProvider, + Author: "Seanime", + Description: "", + Lang: "multi", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/comick.webp", + }, manga_providers.NewComicKMulti(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "mangapill", + Name: "Mangapill", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeMangaProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/mangapill.png", + }, manga_providers.NewMangapill(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "mangadex", + Name: "Mangadex", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeMangaProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/mangadex.png", + }, manga_providers.NewMangadex(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "manganato", + Name: "Manganato", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeMangaProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/manganato.png", + }, manga_providers.NewManganato(logger)) + + // + // Built-in online stream providers + // + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "gogoanime", + Name: "Gogoanime", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeOnlinestreamProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/gogoanime.png", + }, onlinestream_providers.NewGogoanime(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "zoro", + Name: "Hianime", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeOnlinestreamProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/hianime.png", + }, onlinestream_providers.NewZoro(logger)) + + // + // Built-in torrent providers + // + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "nyaa", + Name: "Nyaa", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/nyaa.png", + }, nyaa.NewProvider(logger, nyaa.CategoryAnimeEng)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "nyaa-sukebei", + Name: "Nyaa Sukebei", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/nyaa.png", + }, nyaa.NewSukebeiProvider(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "animetosho", + Name: "AnimeTosho", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/animetosho.png", + }, animetosho.NewProvider(logger)) + + extensionRepository.ReloadBuiltInExtension(extension.Extension{ + ID: "seadex", + Name: "SeaDex", + Version: "", + ManifestURI: "builtin", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + Lang: "en", + Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/seadex.png", + }, seadex.NewProvider(logger)) + + return extensionRepository +} diff --git a/seanime-2.9.10/internal/extension_repo/onlinestream_provider_test.go b/seanime-2.9.10/internal/extension_repo/onlinestream_provider_test.go new file mode 100644 index 0000000..1dcce81 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/onlinestream_provider_test.go @@ -0,0 +1,39 @@ +package extension_repo_test + +import ( + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/require" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" + "testing" +) + +func TestExternalGoOnlinestreamProviderExtension(t *testing.T) { + + repo := getRepo(t) + + // Load all extensions + // This should load all the extensions in the directory + repo.ReloadExternalExtensions() + + ext, found := repo.GetOnlinestreamProviderExtensionByID("gogoanime-external") + require.True(t, found) + + t.Logf("\nExtension:\n\tID: %s \n\tName: %s", ext.GetID(), ext.GetName()) + + searchResults, err := ext.GetProvider().Search(hibikeonlinestream.SearchOptions{ + Query: "Blue Lock", + Dub: false, + }) + require.NoError(t, err) + require.GreaterOrEqual(t, len(searchResults), 1) + + episodes, err := ext.GetProvider().FindEpisodes(searchResults[0].ID) + require.NoError(t, err) + require.GreaterOrEqual(t, len(episodes), 1) + + server, err := ext.GetProvider().FindEpisodeServer(episodes[0], ext.GetProvider().GetSettings().EpisodeServers[0]) + require.NoError(t, err) + + spew.Dump(server) + +} diff --git a/seanime-2.9.10/internal/extension_repo/repository.go b/seanime-2.9.10/internal/extension_repo/repository.go new file mode 100644 index 0000000..b2c7fb3 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/repository.go @@ -0,0 +1,343 @@ +package extension_repo + +import ( + "context" + "net/http" + "os" + "seanime/internal/events" + "seanime/internal/extension" + hibikemanga "seanime/internal/extension/hibike/manga" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/goja/goja_runtime" + "seanime/internal/hook" + "seanime/internal/util" + "seanime/internal/util/filecache" + "seanime/internal/util/result" + "sync" + "time" + + "github.com/rs/zerolog" + "github.com/samber/lo" +) + +type ( + // Repository manages all extensions + Repository struct { + logger *zerolog.Logger + fileCacher *filecache.Cacher + wsEventManager events.WSEventManagerInterface + // Absolute path to the directory containing all extensions + extensionDir string + // Store all active Goja VMs + // - When reloading extensions, all VMs are interrupted + gojaExtensions *result.Map[string, GojaExtension] + + gojaRuntimeManager *goja_runtime.Manager + // Extension bank + // - When reloading extensions, external extensions are removed & re-added + extensionBank *extension.UnifiedBank + + invalidExtensions *result.Map[string, *extension.InvalidExtension] + + hookManager hook.Manager + + client *http.Client + + // Cache the of all built-in extensions when they're first loaded + // This is used to quickly determine if an extension is built-in or not and to reload them + builtinExtensions *result.Map[string, *builtinExtension] + + updateData []UpdateData + updateDataMu sync.Mutex + + // Called when the external extensions are loaded for the first time + firstExternalExtensionLoadedFunc context.CancelFunc + } + + builtinExtension struct { + extension.Extension + provider interface{} + } + + AllExtensions struct { + Extensions []*extension.Extension `json:"extensions"` + InvalidExtensions []*extension.InvalidExtension `json:"invalidExtensions"` + // List of extensions with invalid user config extensions, these extensions are still loaded + InvalidUserConfigExtensions []*extension.InvalidExtension `json:"invalidUserConfigExtensions"` + // List of extension IDs that have an update available + // This is only populated when the user clicks on "Check for updates" + HasUpdate []UpdateData `json:"hasUpdate"` + } + + UpdateData struct { + ExtensionID string `json:"extensionID"` + ManifestURI string `json:"manifestURI"` + Version string `json:"version"` + } + + MangaProviderExtensionItem struct { + ID string `json:"id"` + Name string `json:"name"` + Lang string `json:"lang"` // ISO 639-1 language code + Settings hibikemanga.Settings `json:"settings"` + } + + OnlinestreamProviderExtensionItem struct { + ID string `json:"id"` + Name string `json:"name"` + Lang string `json:"lang"` // ISO 639-1 language code + EpisodeServers []string `json:"episodeServers"` + SupportsDub bool `json:"supportsDub"` + } + + AnimeTorrentProviderExtensionItem struct { + ID string `json:"id"` + Name string `json:"name"` + Lang string `json:"lang"` // ISO 639-1 language code + Settings hibiketorrent.AnimeProviderSettings `json:"settings"` + } +) + +type NewRepositoryOptions struct { + Logger *zerolog.Logger + ExtensionDir string + WSEventManager events.WSEventManagerInterface + FileCacher *filecache.Cacher + HookManager hook.Manager +} + +func NewRepository(opts *NewRepositoryOptions) *Repository { + + // Make sure the extension directory exists + _ = os.MkdirAll(opts.ExtensionDir, os.ModePerm) + + ret := &Repository{ + logger: opts.Logger, + extensionDir: opts.ExtensionDir, + wsEventManager: opts.WSEventManager, + gojaExtensions: result.NewResultMap[string, GojaExtension](), + gojaRuntimeManager: goja_runtime.NewManager(opts.Logger), + extensionBank: extension.NewUnifiedBank(), + invalidExtensions: result.NewResultMap[string, *extension.InvalidExtension](), + fileCacher: opts.FileCacher, + hookManager: opts.HookManager, + client: http.DefaultClient, + builtinExtensions: result.NewResultMap[string, *builtinExtension](), + updateData: make([]UpdateData, 0), + } + + firstExtensionLoadedCtx, firstExtensionLoadedCancel := context.WithCancel(context.Background()) + ret.firstExternalExtensionLoadedFunc = firstExtensionLoadedCancel + + // Fetch extension updates at launch and every 12 hours + go func(firstExtensionLoadedCtx context.Context) { + defer util.HandlePanicInModuleThen("extension_repo/fetchExtensionUpdates", func() { + ret.firstExternalExtensionLoadedFunc = nil + }) + for { + if ret.firstExternalExtensionLoadedFunc != nil { + // Block until the first external extensions are loaded + select { + case <-firstExtensionLoadedCtx.Done(): + } + } + + ret.firstExternalExtensionLoadedFunc = nil + + ret.updateData = ret.checkForUpdates() + if len(ret.updateData) > 0 { + // Signal the frontend that there are updates available + ret.wsEventManager.SendEvent(events.ExtensionUpdatesFound, ret.updateData) + } + time.Sleep(12 * time.Hour) + } + }(firstExtensionLoadedCtx) + + return ret +} + +func (r *Repository) GetAllExtensions(withUpdates bool) (ret *AllExtensions) { + invalidExtensions := r.ListInvalidExtensions() + + fatalInvalidExtensions := lo.Filter(invalidExtensions, func(ext *extension.InvalidExtension, _ int) bool { + return ext.Code != extension.InvalidExtensionUserConfigError + }) + + userConfigInvalidExtensions := lo.Filter(invalidExtensions, func(ext *extension.InvalidExtension, _ int) bool { + return ext.Code == extension.InvalidExtensionUserConfigError + }) + + ret = &AllExtensions{ + Extensions: r.ListExtensionData(), + InvalidExtensions: fatalInvalidExtensions, + InvalidUserConfigExtensions: userConfigInvalidExtensions, + } + + // Send the update data to the frontend if there are any updates + if len(r.updateData) > 0 { + ret.HasUpdate = r.updateData + } + + if withUpdates { + ret.HasUpdate = r.checkForUpdates() + r.updateData = ret.HasUpdate + } + return +} + +func (r *Repository) GetUpdateData() (ret []UpdateData) { + return r.updateData +} + +func (r *Repository) ListExtensionData() (ret []*extension.Extension) { + r.extensionBank.Range(func(key string, ext extension.BaseExtension) bool { + retExt := extension.ToExtensionData(ext) + retExt.Payload = "" + ret = append(ret, retExt) + return true + }) + + return ret +} + +func (r *Repository) ListDevelopmentModeExtensions() (ret []*extension.Extension) { + r.extensionBank.Range(func(key string, ext extension.BaseExtension) bool { + if ext.GetIsDevelopment() { + retExt := extension.ToExtensionData(ext) + retExt.Payload = "" + ret = append(ret, retExt) + } + return true + }) + + return ret +} + +func (r *Repository) ListInvalidExtensions() (ret []*extension.InvalidExtension) { + r.invalidExtensions.Range(func(key string, ext *extension.InvalidExtension) bool { + ext.Extension.Payload = "" + ret = append(ret, ext) + return true + }) + + return ret +} + +func (r *Repository) GetExtensionPayload(id string) (ret string) { + ext, found := r.extensionBank.Get(id) + if !found { + return "" + } + return ext.GetPayload() +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Lists +// - Lists are used to display available options to the user based on the extensions installed +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) ListMangaProviderExtensions() []*MangaProviderExtensionItem { + ret := make([]*MangaProviderExtensionItem, 0) + + extension.RangeExtensions(r.extensionBank, func(key string, ext extension.MangaProviderExtension) bool { + settings := ext.GetProvider().GetSettings() + ret = append(ret, &MangaProviderExtensionItem{ + ID: ext.GetID(), + Name: ext.GetName(), + Lang: extension.GetExtensionLang(ext.GetLang()), + Settings: settings, + }) + return true + }) + + return ret +} + +func (r *Repository) ListOnlinestreamProviderExtensions() []*OnlinestreamProviderExtensionItem { + ret := make([]*OnlinestreamProviderExtensionItem, 0) + + extension.RangeExtensions(r.extensionBank, func(key string, ext extension.OnlinestreamProviderExtension) bool { + settings := ext.GetProvider().GetSettings() + ret = append(ret, &OnlinestreamProviderExtensionItem{ + ID: ext.GetID(), + Name: ext.GetName(), + Lang: extension.GetExtensionLang(ext.GetLang()), + EpisodeServers: settings.EpisodeServers, + SupportsDub: settings.SupportsDub, + }) + return true + }) + + return ret +} + +func (r *Repository) ListAnimeTorrentProviderExtensions() []*AnimeTorrentProviderExtensionItem { + ret := make([]*AnimeTorrentProviderExtensionItem, 0) + + extension.RangeExtensions(r.extensionBank, func(key string, ext extension.AnimeTorrentProviderExtension) bool { + settings := ext.GetProvider().GetSettings() + ret = append(ret, &AnimeTorrentProviderExtensionItem{ + ID: ext.GetID(), + Name: ext.GetName(), + Lang: extension.GetExtensionLang(ext.GetLang()), + Settings: hibiketorrent.AnimeProviderSettings{ + Type: settings.Type, + CanSmartSearch: settings.CanSmartSearch, + SupportsAdult: settings.SupportsAdult, + SmartSearchFilters: lo.Map(settings.SmartSearchFilters, func(value hibiketorrent.AnimeProviderSmartSearchFilter, _ int) hibiketorrent.AnimeProviderSmartSearchFilter { + return value + }), + }, + }) + + return true + }) + + return ret +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// GetLoadedExtension returns the loaded extension by ID. +// It returns an extension.BaseExtension interface, so it can be used to get the extension's details. +func (r *Repository) GetLoadedExtension(id string) (extension.BaseExtension, bool) { + var ext extension.BaseExtension + ext, found := r.extensionBank.Get(id) + if found { + return ext, true + } + + return nil, false +} + +func (r *Repository) GetExtensionBank() *extension.UnifiedBank { + return r.extensionBank +} + +func (r *Repository) GetMangaProviderExtensionByID(id string) (extension.MangaProviderExtension, bool) { + ext, found := extension.GetExtension[extension.MangaProviderExtension](r.extensionBank, id) + return ext, found +} + +func (r *Repository) GetOnlinestreamProviderExtensionByID(id string) (extension.OnlinestreamProviderExtension, bool) { + ext, found := extension.GetExtension[extension.OnlinestreamProviderExtension](r.extensionBank, id) + return ext, found +} + +func (r *Repository) GetAnimeTorrentProviderExtensionByID(id string) (extension.AnimeTorrentProviderExtension, bool) { + ext, found := extension.GetExtension[extension.AnimeTorrentProviderExtension](r.extensionBank, id) + return ext, found +} + +func (r *Repository) loadPlugin(ext *extension.Extension) (err error) { + defer util.HandlePanicInModuleWithError("extension_repo/loadPlugin", &err) + + err = r.loadPluginExtension(ext) + if err != nil { + r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to load plugin") + return err + } + + r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded plugin") + return +} diff --git a/seanime-2.9.10/internal/extension_repo/repository_test.go b/seanime-2.9.10/internal/extension_repo/repository_test.go new file mode 100644 index 0000000..a844d03 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/repository_test.go @@ -0,0 +1,19 @@ +package extension_repo_test + +import ( + "seanime/internal/events" + "seanime/internal/extension_repo" + "seanime/internal/util" + "testing" +) + +func getRepo(t *testing.T) *extension_repo.Repository { + logger := util.NewLogger() + wsEventManager := events.NewMockWSEventManager(logger) + + return extension_repo.NewRepository(&extension_repo.NewRepositoryOptions{ + Logger: logger, + ExtensionDir: "testdir", + WSEventManager: wsEventManager, + }) +} diff --git a/seanime-2.9.10/internal/extension_repo/testdir/_gogoanime_external.go b/seanime-2.9.10/internal/extension_repo/testdir/_gogoanime_external.go new file mode 100644 index 0000000..f18238b --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/testdir/_gogoanime_external.go @@ -0,0 +1,614 @@ +package main + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "github.com/goccy/go-json" + "github.com/gocolly/colly" + "github.com/rs/zerolog" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" +) + +const ( + DefaultServer = "default" + GogoanimeProvider = "gogoanime-external" + GogocdnServer = "gogocdn" + VidstreamingServer = "vidstreaming" + StreamSBServer = "streamsb" +) + +type Gogoanime struct { + BaseURL string + AjaxURL string + Client http.Client + UserAgent string + logger *zerolog.Logger +} + +func NewProvider(logger *zerolog.Logger) hibikeonlinestream.Provider { + return &Gogoanime{ + BaseURL: "https://anitaku.to", + AjaxURL: "https://ajax.gogocdn.net", + Client: http.Client{}, + UserAgent: util.GetRandomUserAgent(), + logger: logger, + } +} + +func (g *Gogoanime) GetEpisodeServers() []string { + return []string{GogocdnServer, VidstreamingServer} +} + +func (g *Gogoanime) Search(query string, dubbed bool) ([]*hibikeonlinestream.SearchResult, error) { + var results []*hibikeonlinestream.SearchResult + + g.logger.Debug().Str("query", query).Bool("dubbed", dubbed).Msg("gogoanime: Searching anime") + + c := colly.NewCollector( + colly.UserAgent(g.UserAgent), + ) + + c.OnHTML(".last_episodes > ul > li", func(e *colly.HTMLElement) { + id := "" + idParts := strings.Split(e.ChildAttr("p.name > a", "href"), "/") + if len(idParts) > 2 { + id = idParts[2] + } + title := e.ChildText("p.name > a") + url := g.BaseURL + e.ChildAttr("p.name > a", "href") + subOrDub := hibikeonlinestream.Sub + if strings.Contains(strings.ToLower(e.ChildText("p.name > a")), "dub") { + subOrDub = hibikeonlinestream.Dub + } + results = append(results, &hibikeonlinestream.SearchResult{ + ID: id, + Title: title, + URL: url, + SubOrDub: subOrDub, + }) + }) + + searchURL := g.BaseURL + "/search.html?keyword=" + url.QueryEscape(query) + if dubbed { + searchURL += "%20(Dub)" + } + + err := c.Visit(searchURL) + if err != nil { + return nil, err + } + + g.logger.Debug().Int("count", len(results)).Msg("gogoanime: Fetched anime") + + return results, nil +} + +func (g *Gogoanime) FindEpisode(id string) ([]*hibikeonlinestream.EpisodeDetails, error) { + var episodes []*hibikeonlinestream.EpisodeDetails + + g.logger.Debug().Str("id", id).Msg("gogoanime: Fetching episodes") + + if !strings.Contains(id, "gogoanime") { + id = fmt.Sprintf("%s/category/%s", g.BaseURL, id) + } + + c := colly.NewCollector( + colly.UserAgent(g.UserAgent), + ) + + var epStart, epEnd, movieID, alias string + + c.OnHTML("#episode_page > li > a", func(e *colly.HTMLElement) { + if epStart == "" { + epStart = e.Attr("ep_start") + } + epEnd = e.Attr("ep_end") + }) + + c.OnHTML("#movie_id", func(e *colly.HTMLElement) { + movieID = e.Attr("value") + }) + + c.OnHTML("#alias", func(e *colly.HTMLElement) { + alias = e.Attr("value") + }) + + err := c.Visit(id) + if err != nil { + g.logger.Error().Err(err).Msg("gogoanime: Failed to fetch episodes") + return nil, err + } + + c2 := colly.NewCollector( + colly.UserAgent(g.UserAgent), + ) + + c2.OnHTML("#episode_related > li", func(e *colly.HTMLElement) { + episodeIDParts := strings.Split(e.ChildAttr("a", "href"), "/") + if len(episodeIDParts) < 2 { + return + } + episodeID := strings.TrimSpace(episodeIDParts[1]) + episodeNumberStr := strings.TrimPrefix(e.ChildText("div.name"), "EP ") + episodeNumber, err := strconv.Atoi(episodeNumberStr) + if err != nil { + g.logger.Error().Err(err).Str("episodeID", episodeID).Msg("failed to parse episode number") + return + } + episodes = append(episodes, &hibikeonlinestream.EpisodeDetails{ + Provider: GogoanimeProvider, + ID: episodeID, + Number: episodeNumber, + URL: g.BaseURL + "/" + episodeID, + }) + }) + + ajaxURL := fmt.Sprintf("%s/ajax/load-list-episode", g.AjaxURL) + ajaxParams := url.Values{ + "ep_start": {epStart}, + "ep_end": {epEnd}, + "id": {movieID}, + "alias": {alias}, + "default_ep": {"0"}, + } + ajaxURLWithParams := fmt.Sprintf("%s?%s", ajaxURL, ajaxParams.Encode()) + + err = c2.Visit(ajaxURLWithParams) + if err != nil { + g.logger.Error().Err(err).Msg("gogoanime: Failed to fetch episodes") + return nil, err + } + + g.logger.Debug().Int("count", len(episodes)).Msg("gogoanime: Fetched episodes") + + return episodes, nil +} + +func (g *Gogoanime) FindEpisodeServer(episodeInfo *hibikeonlinestream.EpisodeDetails, server string) (*hibikeonlinestream.EpisodeServer, error) { + var source *hibikeonlinestream.EpisodeServer + + if server == DefaultServer { + server = GogocdnServer + } + g.logger.Debug().Str("server", string(server)).Str("episodeID", episodeInfo.ID).Msg("gogoanime: Fetching server sources") + + c := colly.NewCollector() + + switch server { + case VidstreamingServer: + c.OnHTML(".anime_muti_link > ul > li.vidcdn > a", func(e *colly.HTMLElement) { + src := e.Attr("data-video") + gogocdn := NewGogoCDN() + videoSources, err := gogocdn.Extract(src) + if err == nil { + source = &hibikeonlinestream.EpisodeServer{ + Provider: GogoanimeProvider, + Server: server, + Headers: map[string]string{ + "Referer": g.BaseURL + "/" + episodeInfo.ID, + }, + VideoSources: videoSources, + } + } + }) + case GogocdnServer, "": + c.OnHTML("#load_anime > div > div > iframe", func(e *colly.HTMLElement) { + src := e.Attr("src") + gogocdn := NewGogoCDN() + videoSources, err := gogocdn.Extract(src) + if err == nil { + source = &hibikeonlinestream.EpisodeServer{ + Provider: GogoanimeProvider, + Server: server, + Headers: map[string]string{ + "Referer": g.BaseURL + "/" + episodeInfo.ID, + }, + VideoSources: videoSources, + } + } + }) + case StreamSBServer: + c.OnHTML(".anime_muti_link > ul > li.streamsb > a", func(e *colly.HTMLElement) { + src := e.Attr("data-video") + streamsb := NewStreamSB() + videoSources, err := streamsb.Extract(src) + if err == nil { + source = &hibikeonlinestream.EpisodeServer{ + Provider: GogoanimeProvider, + Server: server, + Headers: map[string]string{ + "Referer": g.BaseURL + "/" + episodeInfo.ID, + "watchsb": "streamsb", + "User-Agent": g.UserAgent, + }, + VideoSources: videoSources, + } + } + }) + } + + err := c.Visit(g.BaseURL + "/" + episodeInfo.ID) + if err != nil { + return nil, err + } + + if source == nil { + g.logger.Warn().Str("server", string(server)).Msg("gogoanime: No sources found") + return nil, fmt.Errorf("no sources found") + } + + g.logger.Debug().Str("server", string(server)).Int("videoSources", len(source.VideoSources)).Msg("gogoanime: Fetched server sources") + + return source, nil + +} + +type cdnKeys struct { + key []byte + secondKey []byte + iv []byte +} + +type GogoCDN struct { + client *http.Client + serverName string + keys cdnKeys + referrer string +} + +func NewGogoCDN() *GogoCDN { + return &GogoCDN{ + client: &http.Client{}, + serverName: "goload", + keys: cdnKeys{ + key: []byte("37911490979715163134003223491201"), + secondKey: []byte("54674138327930866480207815084989"), + iv: []byte("3134003223491201"), + }, + } +} + +// Extract fetches and extracts video sources from the provided URI. +func (g *GogoCDN) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("failed to extract video sources") + } + }() + + // Instantiate a new collector + c := colly.NewCollector( + // Allow visiting the same page multiple times + colly.AllowURLRevisit(), + ) + ur, err := url.Parse(uri) + if err != nil { + return nil, err + } + + // Variables to hold extracted values + var scriptValue, id string + + id = ur.Query().Get("id") + + // Find and extract the script value and id + c.OnHTML("script[data-name='episode']", func(e *colly.HTMLElement) { + scriptValue = e.Attr("data-value") + + }) + + // Start scraping + err = c.Visit(uri) + if err != nil { + return nil, err + } + + // Check if scriptValue and id are found + if scriptValue == "" || id == "" { + return nil, errors.New("script value or id not found") + } + + // Extract video sources + ajaxUrl := fmt.Sprintf("%s://%s/encrypt-ajax.php?%s", ur.Scheme, ur.Host, g.generateEncryptedAjaxParams(id, scriptValue)) + + req, err := http.NewRequest("GET", ajaxUrl, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") + req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01") + + encryptedData, err := g.client.Do(req) + if err != nil { + return nil, err + } + + defer encryptedData.Body.Close() + + encryptedDataBytesRes, err := io.ReadAll(encryptedData.Body) + if err != nil { + return nil, err + } + + var encryptedDataBytes map[string]string + err = json.Unmarshal(encryptedDataBytesRes, &encryptedDataBytes) + if err != nil { + return nil, err + } + + data, err := g.decryptAjaxData(encryptedDataBytes["data"]) + + source, ok := data["source"].([]interface{}) + + // Check if source is found + if !ok { + return nil, errors.New("source not found") + } + + var results []*hibikeonlinestream.VideoSource + + urls := make([]string, 0) + for _, src := range source { + s := src.(map[string]interface{}) + urls = append(urls, s["file"].(string)) + } + + sourceBK, ok := data["source_bk"].([]interface{}) + if ok { + for _, src := range sourceBK { + s := src.(map[string]interface{}) + urls = append(urls, s["file"].(string)) + } + } + + for _, url := range urls { + + vs, ok := g.urlToVideoSource(url, source, sourceBK) + if ok { + results = append(results, vs...) + } + + } + + return results, nil +} + +func (g *GogoCDN) urlToVideoSource(url string, source []interface{}, sourceBK []interface{}) (vs []*hibikeonlinestream.VideoSource, ok bool) { + defer func() { + if r := recover(); r != nil { + ok = false + } + }() + ret := make([]*hibikeonlinestream.VideoSource, 0) + if strings.Contains(url, ".m3u8") { + resResult, err := http.Get(url) + if err != nil { + return nil, false + } + defer resResult.Body.Close() + + bodyBytes, err := io.ReadAll(resResult.Body) + if err != nil { + return nil, false + } + bodyString := string(bodyBytes) + + resolutions := regexp.MustCompile(`(RESOLUTION=)(.*)(\s*?)(\s.*)`).FindAllStringSubmatch(bodyString, -1) + baseURL := url[:strings.LastIndex(url, "/")] + + for _, res := range resolutions { + quality := strings.Split(strings.Split(res[2], "x")[1], ",")[0] + url := fmt.Sprintf("%s/%s", baseURL, strings.TrimSpace(res[4])) + ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceM3U8, Quality: quality + "p"}) + } + + ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceM3U8, Quality: "default"}) + } else { + for _, src := range source { + s := src.(map[string]interface{}) + if s["file"].(string) == url { + quality := strings.Split(s["label"].(string), " ")[0] + "p" + ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceMP4, Quality: quality}) + } + } + if sourceBK != nil { + for _, src := range sourceBK { + s := src.(map[string]interface{}) + if s["file"].(string) == url { + ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceMP4, Quality: "backup"}) + } + } + } + } + + return ret, true +} + +// generateEncryptedAjaxParams generates encrypted AJAX parameters. +func (g *GogoCDN) generateEncryptedAjaxParams(id, scriptValue string) string { + encryptedKey := g.encrypt(id, g.keys.iv, g.keys.key) + decryptedToken := g.decrypt(scriptValue, g.keys.iv, g.keys.key) + return fmt.Sprintf("id=%s&alias=%s", encryptedKey, decryptedToken) +} + +// encrypt encrypts the given text using AES CBC mode. +func (g *GogoCDN) encrypt(text string, iv []byte, key []byte) string { + block, _ := aes.NewCipher(key) + textBytes := []byte(text) + textBytes = pkcs7Padding(textBytes, aes.BlockSize) + cipherText := make([]byte, len(textBytes)) + + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(cipherText, textBytes) + + return base64.StdEncoding.EncodeToString(cipherText) +} + +// decrypt decrypts the given text using AES CBC mode. +func (g *GogoCDN) decrypt(text string, iv []byte, key []byte) string { + block, _ := aes.NewCipher(key) + cipherText, _ := base64.StdEncoding.DecodeString(text) + plainText := make([]byte, len(cipherText)) + + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(plainText, cipherText) + plainText = pkcs7Trimming(plainText) + + return string(plainText) +} + +func (g *GogoCDN) decryptAjaxData(encryptedData string) (map[string]interface{}, error) { + decodedData, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(g.keys.secondKey) + if err != nil { + return nil, err + } + + if len(decodedData) < aes.BlockSize { + return nil, fmt.Errorf("cipher text too short") + } + + iv := g.keys.iv + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(decodedData, decodedData) + + // Remove padding + decodedData = pkcs7Trimming(decodedData) + + var data map[string]interface{} + err = json.Unmarshal(decodedData, &data) + if err != nil { + return nil, err + } + + return data, nil +} + +// pkcs7Padding pads the text to be a multiple of blockSize using Pkcs7 padding. +func pkcs7Padding(text []byte, blockSize int) []byte { + padding := blockSize - len(text)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(text, padText...) +} + +// pkcs7Trimming removes Pkcs7 padding from the text. +func pkcs7Trimming(text []byte) []byte { + length := len(text) + unpadding := int(text[length-1]) + return text[:(length - unpadding)] +} + +type StreamSB struct { + Host string + Host2 string + UserAgent string +} + +func NewStreamSB() *StreamSB { + return &StreamSB{ + Host: "https://streamsss.net/sources50", + Host2: "https://watchsb.com/sources50", + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36", + } +} + +func (s *StreamSB) Payload(hex string) string { + return "566d337678566f743674494a7c7c" + hex + "7c7c346b6767586d6934774855537c7c73747265616d7362/6565417268755339773461447c7c346133383438333436313335376136323337373433383634376337633465366534393338373136643732373736343735373237613763376334363733353737303533366236333463353333363534366137633763373337343732363536313664373336327c7c6b586c3163614468645a47617c7c73747265616d7362" +} + +func (s *StreamSB) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) { + defer func() { + if r := recover(); r != nil { + err = errors.New("failed to extract video sources") + } + }() + + var ret []*hibikeonlinestream.VideoSource + + id := strings.Split(uri, "/e/")[1] + if strings.Contains(id, "html") { + id = strings.Split(id, ".html")[0] + } + + if id == "" { + return nil, errors.New("cannot find ID") + } + + client := &http.Client{} + req, _ := http.NewRequest("GET", fmt.Sprintf("%s/%s", s.Host, s.Payload(hex.EncodeToString([]byte(id)))), nil) + req.Header.Add("watchsb", "sbstream") + req.Header.Add("User-Agent", s.UserAgent) + req.Header.Add("Referer", uri) + + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + body, _ := io.ReadAll(res.Body) + + var jsonResponse map[string]interface{} + err = json.Unmarshal(body, &jsonResponse) + if err != nil { + return nil, err + } + + streamData, ok := jsonResponse["stream_data"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("stream data not found") + } + + m3u8Urls, err := client.Get(streamData["file"].(string)) + if err != nil { + return nil, err + } + defer m3u8Urls.Body.Close() + + m3u8Body, err := io.ReadAll(m3u8Urls.Body) + if err != nil { + return nil, err + } + videoList := strings.Split(string(m3u8Body), "#EXT-X-STREAM-INF:") + + for _, video := range videoList { + if !strings.Contains(video, "m3u8") { + continue + } + + url := strings.Split(video, "\n")[1] + quality := strings.Split(strings.Split(video, "RESOLUTION=")[1], ",")[0] + quality = strings.Split(quality, "x")[1] + + ret = append(ret, &hibikeonlinestream.VideoSource{ + URL: url, + Quality: quality + "p", + Type: hibikeonlinestream.VideoSourceM3U8, + }) + } + + ret = append(ret, &hibikeonlinestream.VideoSource{ + URL: streamData["file"].(string), + Quality: "auto", + Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(streamData["file"].(string), ".m3u8")], + }) + + return ret, nil +} diff --git a/seanime-2.9.10/internal/extension_repo/testdir/_mangapill_external.go b/seanime-2.9.10/internal/extension_repo/testdir/_mangapill_external.go new file mode 100644 index 0000000..35dc767 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/testdir/_mangapill_external.go @@ -0,0 +1,226 @@ +package main + +import ( + "fmt" + "github.com/5rahim/hibike/pkg/util/bypass" + "github.com/5rahim/hibike/pkg/util/common" + "github.com/5rahim/hibike/pkg/util/similarity" + "github.com/gocolly/colly" + "github.com/rs/zerolog" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const MangapillProvider = "mangapill-external" + +type ( + Mangapill struct { + Url string + Client *http.Client + UserAgent string + logger *zerolog.Logger + } +) + +func NewProvider(logger *zerolog.Logger) manga.Provider { + c := &http.Client{ + Timeout: 60 * time.Second, + } + c.Transport = bypass.AddCloudFlareByPass(c.Transport) + return &Mangapill{ + Url: "https://mangapill.com", + Client: c, + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3", + logger: logger, + } +} + +// DEVNOTE: Unique ID +// Each chapter ID has this format: {number}${slug} -- e.g. 6502-10004000$gokurakugai-chapter-4 +// The chapter ID is split by the $ character to reconstruct the chapter URL for subsequent requests + +func (mp *Mangapill) Search(opts manga.SearchOptions) (ret []*manga.SearchResult, err error) { + ret = make([]*manga.SearchResult, 0) + + mp.logger.Debug().Str("query", opts.Query).Msg("mangapill: Searching manga") + + uri := fmt.Sprintf("%s/search?q=%s", mp.Url, url.QueryEscape(opts.Query)) + + c := colly.NewCollector( + colly.UserAgent(mp.UserAgent), + ) + + c.WithTransport(mp.Client.Transport) + + c.OnHTML("div.container div.my-3.justify-end > div", func(e *colly.HTMLElement) { + defer func() { + if r := recover(); r != nil { + } + }() + result := &manga.SearchResult{ + Provider: "mangapill", + } + + result.ID = strings.Split(e.ChildAttr("a", "href"), "/manga/")[1] + result.ID = strings.Replace(result.ID, "/", "$", -1) + + title := e.DOM.Find("div > a > div.mt-3").Text() + result.Title = strings.TrimSpace(title) + + altTitles := e.DOM.Find("div > a > div.text-xs.text-secondary").Text() + if altTitles != "" { + result.Synonyms = []string{strings.TrimSpace(altTitles)} + } + + compTitles := []string{result.Title} + if len(result.Synonyms) > 0 { + compTitles = append(compTitles, result.Synonyms[0]) + } + compRes, _ := similarity.FindBestMatchWithSorensenDice(opts.Query, compTitles) + result.SearchRating = compRes.Rating + + result.Image = e.ChildAttr("a img", "data-src") + + yearStr := e.DOM.Find("div > div.flex > div").Eq(1).Text() + year, err := strconv.Atoi(strings.TrimSpace(yearStr)) + if err != nil { + result.Year = 0 + } else { + result.Year = year + } + + ret = append(ret, result) + }) + + err = c.Visit(uri) + if err != nil { + mp.logger.Error().Err(err).Msg("mangapill: Failed to visit") + return nil, err + } + + // code + + if len(ret) == 0 { + mp.logger.Error().Str("query", opts.Query).Msg("mangapill: No results found") + return nil, fmt.Errorf("no results found") + } + + mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found results") + + return ret, nil +} + +func (mp *Mangapill) FindChapters(id string) (ret []*manga.ChapterDetails, err error) { + ret = make([]*manga.ChapterDetails, 0) + + mp.logger.Debug().Str("mangaId", id).Msg("mangapill: Finding chapters") + + uriId := strings.Replace(id, "$", "/", -1) + uri := fmt.Sprintf("%s/manga/%s", mp.Url, uriId) + + c := colly.NewCollector( + colly.UserAgent(mp.UserAgent), + ) + + c.WithTransport(mp.Client.Transport) + + c.OnHTML("div.container div.border-border div#chapters div.grid-cols-1 a", func(e *colly.HTMLElement) { + defer func() { + if r := recover(); r != nil { + } + }() + chapter := &manga.ChapterDetails{ + Provider: MangapillProvider, + } + + chapter.ID = strings.Split(e.Attr("href"), "/chapters/")[1] + chapter.ID = strings.Replace(chapter.ID, "/", "$", -1) + + chapter.Title = strings.TrimSpace(e.Text) + + splitTitle := strings.Split(chapter.Title, "Chapter ") + if len(splitTitle) < 2 { + return + } + chapter.Chapter = splitTitle[1] + + ret = append(ret, chapter) + }) + + err = c.Visit(uri) + if err != nil { + mp.logger.Error().Err(err).Msg("mangapill: Failed to visit") + return nil, err + } + + if len(ret) == 0 { + mp.logger.Error().Str("mangaId", id).Msg("mangapill: No chapters found") + return nil, fmt.Errorf("no chapters found") + } + + common.Reverse(ret) + + for i, chapter := range ret { + chapter.Index = uint(i) + } + + mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found chapters") + + return ret, nil +} + +func (mp *Mangapill) FindChapterPages(id string) (ret []*manga.ChapterPage, err error) { + ret = make([]*manga.ChapterPage, 0) + + mp.logger.Debug().Str("chapterId", id).Msg("mangapill: Finding chapter pages") + + uriId := strings.Replace(id, "$", "/", -1) + uri := fmt.Sprintf("%s/chapters/%s", mp.Url, uriId) + + c := colly.NewCollector( + colly.UserAgent(mp.UserAgent), + ) + + c.WithTransport(mp.Client.Transport) + + c.OnHTML("chapter-page", func(e *colly.HTMLElement) { + defer func() { + if r := recover(); r != nil { + } + }() + page := &manga.ChapterPage{} + + page.URL = e.DOM.Find("div picture img").AttrOr("data-src", "") + if page.URL == "" { + return + } + indexStr := e.DOM.Find("div[data-summary] > div").Text() + index, _ := strconv.Atoi(strings.Split(strings.Split(indexStr, "page ")[1], "/")[0]) + page.Index = index - 1 + + page.Headers = map[string]string{ + "Referer": mp.Url, + } + + ret = append(ret, page) + }) + + err = c.Visit(uri) + if err != nil { + mp.logger.Error().Err(err).Msg("mangapill: Failed to visit") + return nil, err + } + + if len(ret) == 0 { + mp.logger.Error().Str("chapterId", id).Msg("mangapill: No pages found") + return nil, fmt.Errorf("no pages found") + } + + mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found pages") + + return ret, nil + +} diff --git a/seanime-2.9.10/internal/extension_repo/testdir/_my_anime_torrent_provider.go b/seanime-2.9.10/internal/extension_repo/testdir/_my_anime_torrent_provider.go new file mode 100644 index 0000000..2527ab4 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/testdir/_my_anime_torrent_provider.go @@ -0,0 +1,69 @@ +package main + +import ( + "net/http" + "time" + + bypass "github.com/5rahim/hibike/pkg/util/bypass" + "github.com/rs/zerolog" + torrent "seanime/internal/extension/hibike/torrent" +) + +type ( + MyAnimeTorrentProvider struct { + url string + client *http.Client + logger *zerolog.Logger + } +) + +func NewProvider(logger *zerolog.Logger) torrent.AnimeProvider { + c := &http.Client{ + Timeout: 60 * time.Second, + } + c.Transport = bypass.AddCloudFlareByPass(c.Transport) + return &MyAnimeTorrentProvider{ + url: "https://example.com", + client: c, + logger: logger, + } +} + +func (m *MyAnimeTorrentProvider) Search(opts torrent.AnimeSearchOptions) ([]*torrent.AnimeTorrent, error) { + //TODO implement me + panic("implement me") +} + +func (m *MyAnimeTorrentProvider) SmartSearch(opts torrent.AnimeSmartSearchOptions) ([]*torrent.AnimeTorrent, error) { + //TODO implement me + panic("implement me") +} + +func (m *MyAnimeTorrentProvider) GetTorrentInfoHash(torrent *torrent.AnimeTorrent) (string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MyAnimeTorrentProvider) GetTorrentMagnetLink(torrent *torrent.AnimeTorrent) (string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MyAnimeTorrentProvider) GetLatest() ([]*torrent.AnimeTorrent, error) { + //TODO implement me + panic("implement me") +} + +func (m *MyAnimeTorrentProvider) GetSettings() torrent.AnimeProviderSettings { + return torrent.AnimeProviderSettings{ + CanSmartSearch: true, + SmartSearchFilters: []torrent.AnimeProviderSmartSearchFilter{ + torrent.AnimeProviderSmartSearchFilterEpisodeNumber, + torrent.AnimeProviderSmartSearchFilterResolution, + torrent.AnimeProviderSmartSearchFilterQuery, + torrent.AnimeProviderSmartSearchFilterBatch, + }, + SupportsAdult: false, + Type: "main", + } +} diff --git a/seanime-2.9.10/internal/extension_repo/testdir/_my_online_streaming_provider.go b/seanime-2.9.10/internal/extension_repo/testdir/_my_online_streaming_provider.go new file mode 100644 index 0000000..0b892ab --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/testdir/_my_online_streaming_provider.go @@ -0,0 +1,49 @@ +package main + +import ( + "net/http" + "time" + + bypass "github.com/5rahim/hibike/pkg/util/bypass" + "github.com/rs/zerolog" + onlinestream "seanime/internal/extension/hibike/onlinestream" +) + +type ( + Provider struct { + url string + client *http.Client + logger *zerolog.Logger + } +) + +func NewProvider(logger *zerolog.Logger) onlinestream.Provider { + c := &http.Client{ + Timeout: 60 * time.Second, + } + c.Transport = bypass.AddCloudFlareByPass(c.Transport) + return &Provider{ + url: "https://example.com", + client: c, + logger: logger, + } +} + +func (p *Provider) Search(query string, dub bool) ([]*onlinestream.SearchResult, error) { + //TODO implement me + panic("implement me") +} + +func (p *Provider) FindEpisode(id string) ([]*onlinestream.EpisodeDetails, error) { + //TODO implement me + panic("implement me") +} + +func (p *Provider) FindEpisodeServer(episode *onlinestream.EpisodeDetails, server string) (*onlinestream.EpisodeServer, error) { + //TODO implement me + panic("implement me") +} + +func (p *Provider) GetEpisodeServers() []string { + return []string{"server1", "server2"} +} diff --git a/seanime-2.9.10/internal/extension_repo/testdir/gogoanime.json b/seanime-2.9.10/internal/extension_repo/testdir/gogoanime.json new file mode 100644 index 0000000..87a0148 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/testdir/gogoanime.json @@ -0,0 +1,11 @@ +{ + "id": "gogoanime-external", + "name": "Gogoanime (External)", + "description": "", + "version": "0.0.1", + "type": "onlinestream-provider", + "manifestURI": "", + "language": "go", + "author": "Seanime", + "payload": "package main\n\nimport (\n\t\"bytes\"\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"fmt\"\n\tbrowser \"github.com/EDDYCJY/fake-useragent\"\n\t\"github.com/goccy/go-json\"\n\t\"github.com/gocolly/colly\"\n\t\"github.com/rs/zerolog\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\thibikeonlinestream \"github.com/5rahim/hibike/pkg/extension/onlinestream\"\n)\n\nconst (\n\tDefaultServer = \"default\"\n\tGogoanimeProvider = \"gogoanime-external\"\n\tGogocdnServer = \"gogocdn\"\n\tVidstreamingServer = \"vidstreaming\"\n\tStreamSBServer = \"streamsb\"\n)\n\ntype Gogoanime struct {\n\tBaseURL string\n\tAjaxURL string\n\tClient http.Client\n\tUserAgent string\n\tlogger *zerolog.Logger\n}\n\nfunc NewProvider(logger *zerolog.Logger) hibikeonlinestream.Provider {\n\treturn &Gogoanime{\n\t\tBaseURL: \"https://anitaku.to\",\n\t\tAjaxURL: \"https://ajax.gogocdn.net\",\n\t\tClient: http.Client{},\n\t\tUserAgent: browser.Firefox(),\n\t\tlogger: logger,\n\t}\n}\n\nfunc (g *Gogoanime) GetEpisodeServers() []string {\n\treturn []string{GogocdnServer, VidstreamingServer}\n}\n\nfunc (g *Gogoanime) Search(query string, dubbed bool) ([]*hibikeonlinestream.SearchResult, error) {\n\tvar results []*hibikeonlinestream.SearchResult\n\n\tg.logger.Debug().Str(\"query\", query).Bool(\"dubbed\", dubbed).Msg(\"gogoanime: Searching anime\")\n\n\tc := colly.NewCollector(\n\t\tcolly.UserAgent(g.UserAgent),\n\t)\n\n\tc.OnHTML(\".last_episodes > ul > li\", func(e *colly.HTMLElement) {\n\t\tid := \"\"\n\t\tidParts := strings.Split(e.ChildAttr(\"p.name > a\", \"href\"), \"/\")\n\t\tif len(idParts) > 2 {\n\t\t\tid = idParts[2]\n\t\t}\n\t\ttitle := e.ChildText(\"p.name > a\")\n\t\turl := g.BaseURL + e.ChildAttr(\"p.name > a\", \"href\")\n\t\tsubOrDub := hibikeonlinestream.Sub\n\t\tif strings.Contains(strings.ToLower(e.ChildText(\"p.name > a\")), \"dub\") {\n\t\t\tsubOrDub = hibikeonlinestream.Dub\n\t\t}\n\t\tresults = append(results, &hibikeonlinestream.SearchResult{\n\t\t\tID: id,\n\t\t\tTitle: title,\n\t\t\tURL: url,\n\t\t\tSubOrDub: subOrDub,\n\t\t})\n\t})\n\n\tsearchURL := g.BaseURL + \"/search.html?keyword=\" + url.QueryEscape(query)\n\tif dubbed {\n\t\tsearchURL += \"%20(Dub)\"\n\t}\n\n\terr := c.Visit(searchURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tg.logger.Debug().Int(\"count\", len(results)).Msg(\"gogoanime: Fetched anime\")\n\n\treturn results, nil\n}\n\nfunc (g *Gogoanime) FindEpisode(id string) ([]*hibikeonlinestream.EpisodeDetails, error) {\n\tvar episodes []*hibikeonlinestream.EpisodeDetails\n\n\tg.logger.Debug().Str(\"id\", id).Msg(\"gogoanime: Fetching episodes\")\n\n\tif !strings.Contains(id, \"gogoanime\") {\n\t\tid = fmt.Sprintf(\"%s/category/%s\", g.BaseURL, id)\n\t}\n\n\tc := colly.NewCollector(\n\t\tcolly.UserAgent(g.UserAgent),\n\t)\n\n\tvar epStart, epEnd, movieID, alias string\n\n\tc.OnHTML(\"#episode_page > li > a\", func(e *colly.HTMLElement) {\n\t\tif epStart == \"\" {\n\t\t\tepStart = e.Attr(\"ep_start\")\n\t\t}\n\t\tepEnd = e.Attr(\"ep_end\")\n\t})\n\n\tc.OnHTML(\"#movie_id\", func(e *colly.HTMLElement) {\n\t\tmovieID = e.Attr(\"value\")\n\t})\n\n\tc.OnHTML(\"#alias\", func(e *colly.HTMLElement) {\n\t\talias = e.Attr(\"value\")\n\t})\n\n\terr := c.Visit(id)\n\tif err != nil {\n\t\tg.logger.Error().Err(err).Msg(\"gogoanime: Failed to fetch episodes\")\n\t\treturn nil, err\n\t}\n\n\tc2 := colly.NewCollector(\n\t\tcolly.UserAgent(g.UserAgent),\n\t)\n\n\tc2.OnHTML(\"#episode_related > li\", func(e *colly.HTMLElement) {\n\t\tepisodeIDParts := strings.Split(e.ChildAttr(\"a\", \"href\"), \"/\")\n\t\tif len(episodeIDParts) < 2 {\n\t\t\treturn\n\t\t}\n\t\tepisodeID := strings.TrimSpace(episodeIDParts[1])\n\t\tepisodeNumberStr := strings.TrimPrefix(e.ChildText(\"div.name\"), \"EP \")\n\t\tepisodeNumber, err := strconv.Atoi(episodeNumberStr)\n\t\tif err != nil {\n\t\t\tg.logger.Error().Err(err).Str(\"episodeID\", episodeID).Msg(\"failed to parse episode number\")\n\t\t\treturn\n\t\t}\n\t\tepisodes = append(episodes, &hibikeonlinestream.EpisodeDetails{\n\t\t\tProvider: GogoanimeProvider,\n\t\t\tID: episodeID,\n\t\t\tNumber: episodeNumber,\n\t\t\tURL: g.BaseURL + \"/\" + episodeID,\n\t\t})\n\t})\n\n\tajaxURL := fmt.Sprintf(\"%s/ajax/load-list-episode\", g.AjaxURL)\n\tajaxParams := url.Values{\n\t\t\"ep_start\": {epStart},\n\t\t\"ep_end\": {epEnd},\n\t\t\"id\": {movieID},\n\t\t\"alias\": {alias},\n\t\t\"default_ep\": {\"0\"},\n\t}\n\tajaxURLWithParams := fmt.Sprintf(\"%s?%s\", ajaxURL, ajaxParams.Encode())\n\n\terr = c2.Visit(ajaxURLWithParams)\n\tif err != nil {\n\t\tg.logger.Error().Err(err).Msg(\"gogoanime: Failed to fetch episodes\")\n\t\treturn nil, err\n\t}\n\n\tg.logger.Debug().Int(\"count\", len(episodes)).Msg(\"gogoanime: Fetched episodes\")\n\n\treturn episodes, nil\n}\n\nfunc (g *Gogoanime) FindEpisodeServer(episodeInfo *hibikeonlinestream.EpisodeDetails, server string) (*hibikeonlinestream.EpisodeServer, error) {\n\tvar source *hibikeonlinestream.EpisodeServer\n\n\tif server == DefaultServer {\n\t\tserver = GogocdnServer\n\t}\n\tg.logger.Debug().Str(\"server\", string(server)).Str(\"episodeID\", episodeInfo.ID).Msg(\"gogoanime: Fetching server sources\")\n\n\tc := colly.NewCollector()\n\n\tswitch server {\n\tcase VidstreamingServer:\n\t\tc.OnHTML(\".anime_muti_link > ul > li.vidcdn > a\", func(e *colly.HTMLElement) {\n\t\t\tsrc := e.Attr(\"data-video\")\n\t\t\tgogocdn := NewGogoCDN()\n\t\t\tvideoSources, err := gogocdn.Extract(src)\n\t\t\tif err == nil {\n\t\t\t\tsource = &hibikeonlinestream.EpisodeServer{\n\t\t\t\t\tProvider: GogoanimeProvider,\n\t\t\t\t\tServer: server,\n\t\t\t\t\tHeaders: map[string]string{\n\t\t\t\t\t\t\"Referer\": g.BaseURL + \"/\" + episodeInfo.ID,\n\t\t\t\t\t},\n\t\t\t\t\tVideoSources: videoSources,\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\tcase GogocdnServer, \"\":\n\t\tc.OnHTML(\"#load_anime > div > div > iframe\", func(e *colly.HTMLElement) {\n\t\t\tsrc := e.Attr(\"src\")\n\t\t\tgogocdn := NewGogoCDN()\n\t\t\tvideoSources, err := gogocdn.Extract(src)\n\t\t\tif err == nil {\n\t\t\t\tsource = &hibikeonlinestream.EpisodeServer{\n\t\t\t\t\tProvider: GogoanimeProvider,\n\t\t\t\t\tServer: server,\n\t\t\t\t\tHeaders: map[string]string{\n\t\t\t\t\t\t\"Referer\": g.BaseURL + \"/\" + episodeInfo.ID,\n\t\t\t\t\t},\n\t\t\t\t\tVideoSources: videoSources,\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\tcase StreamSBServer:\n\t\tc.OnHTML(\".anime_muti_link > ul > li.streamsb > a\", func(e *colly.HTMLElement) {\n\t\t\tsrc := e.Attr(\"data-video\")\n\t\t\tstreamsb := NewStreamSB()\n\t\t\tvideoSources, err := streamsb.Extract(src)\n\t\t\tif err == nil {\n\t\t\t\tsource = &hibikeonlinestream.EpisodeServer{\n\t\t\t\t\tProvider: GogoanimeProvider,\n\t\t\t\t\tServer: server,\n\t\t\t\t\tHeaders: map[string]string{\n\t\t\t\t\t\t\"Referer\": g.BaseURL + \"/\" + episodeInfo.ID,\n\t\t\t\t\t\t\"watchsb\": \"streamsb\",\n\t\t\t\t\t\t\"User-Agent\": g.UserAgent,\n\t\t\t\t\t},\n\t\t\t\t\tVideoSources: videoSources,\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n\n\terr := c.Visit(g.BaseURL + \"/\" + episodeInfo.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif source == nil {\n\t\tg.logger.Warn().Str(\"server\", string(server)).Msg(\"gogoanime: No sources found\")\n\t\treturn nil, fmt.Errorf(\"no sources found\")\n\t}\n\n\tg.logger.Debug().Str(\"server\", string(server)).Int(\"videoSources\", len(source.VideoSources)).Msg(\"gogoanime: Fetched server sources\")\n\n\treturn source, nil\n\n}\n\ntype cdnKeys struct {\n\tkey []byte\n\tsecondKey []byte\n\tiv []byte\n}\n\ntype GogoCDN struct {\n\tclient *http.Client\n\tserverName string\n\tkeys cdnKeys\n\treferrer string\n}\n\nfunc NewGogoCDN() *GogoCDN {\n\treturn &GogoCDN{\n\t\tclient: &http.Client{},\n\t\tserverName: \"goload\",\n\t\tkeys: cdnKeys{\n\t\t\tkey: []byte(\"37911490979715163134003223491201\"),\n\t\t\tsecondKey: []byte(\"54674138327930866480207815084989\"),\n\t\t\tiv: []byte(\"3134003223491201\"),\n\t\t},\n\t}\n}\n\n// Extract fetches and extracts video sources from the provided URI.\nfunc (g *GogoCDN) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\terr = fmt.Errorf(\"failed to extract video sources\")\n\t\t}\n\t}()\n\n\t// Instantiate a new collector\n\tc := colly.NewCollector(\n\t\t// Allow visiting the same page multiple times\n\t\tcolly.AllowURLRevisit(),\n\t)\n\tur, err := url.Parse(uri)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Variables to hold extracted values\n\tvar scriptValue, id string\n\n\tid = ur.Query().Get(\"id\")\n\n\t// Find and extract the script value and id\n\tc.OnHTML(\"script[data-name='episode']\", func(e *colly.HTMLElement) {\n\t\tscriptValue = e.Attr(\"data-value\")\n\n\t})\n\n\t// Start scraping\n\terr = c.Visit(uri)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Check if scriptValue and id are found\n\tif scriptValue == \"\" || id == \"\" {\n\t\treturn nil, errors.New(\"script value or id not found\")\n\t}\n\n\t// Extract video sources\n\tajaxUrl := fmt.Sprintf(\"%s://%s/encrypt-ajax.php?%s\", ur.Scheme, ur.Host, g.generateEncryptedAjaxParams(id, scriptValue))\n\n\treq, err := http.NewRequest(\"GET\", ajaxUrl, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"X-Requested-With\", \"XMLHttpRequest\")\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36\")\n\treq.Header.Set(\"Accept\", \"application/json, text/javascript, */*; q=0.01\")\n\n\tencryptedData, err := g.client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer encryptedData.Body.Close()\n\n\tencryptedDataBytesRes, err := io.ReadAll(encryptedData.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar encryptedDataBytes map[string]string\n\terr = json.Unmarshal(encryptedDataBytesRes, &encryptedDataBytes)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdata, err := g.decryptAjaxData(encryptedDataBytes[\"data\"])\n\n\tsource, ok := data[\"source\"].([]interface{})\n\n\t// Check if source is found\n\tif !ok {\n\t\treturn nil, errors.New(\"source not found\")\n\t}\n\n\tvar results []*hibikeonlinestream.VideoSource\n\n\turls := make([]string, 0)\n\tfor _, src := range source {\n\t\ts := src.(map[string]interface{})\n\t\turls = append(urls, s[\"file\"].(string))\n\t}\n\n\tsourceBK, ok := data[\"source_bk\"].([]interface{})\n\tif ok {\n\t\tfor _, src := range sourceBK {\n\t\t\ts := src.(map[string]interface{})\n\t\t\turls = append(urls, s[\"file\"].(string))\n\t\t}\n\t}\n\n\tfor _, url := range urls {\n\n\t\tvs, ok := g.urlToVideoSource(url, source, sourceBK)\n\t\tif ok {\n\t\t\tresults = append(results, vs...)\n\t\t}\n\n\t}\n\n\treturn results, nil\n}\n\nfunc (g *GogoCDN) urlToVideoSource(url string, source []interface{}, sourceBK []interface{}) (vs []*hibikeonlinestream.VideoSource, ok bool) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tok = false\n\t\t}\n\t}()\n\tret := make([]*hibikeonlinestream.VideoSource, 0)\n\tif strings.Contains(url, \".m3u8\") {\n\t\tresResult, err := http.Get(url)\n\t\tif err != nil {\n\t\t\treturn nil, false\n\t\t}\n\t\tdefer resResult.Body.Close()\n\n\t\tbodyBytes, err := io.ReadAll(resResult.Body)\n\t\tif err != nil {\n\t\t\treturn nil, false\n\t\t}\n\t\tbodyString := string(bodyBytes)\n\n\t\tresolutions := regexp.MustCompile(`(RESOLUTION=)(.*)(\\s*?)(\\s.*)`).FindAllStringSubmatch(bodyString, -1)\n\t\tbaseURL := url[:strings.LastIndex(url, \"/\")]\n\n\t\tfor _, res := range resolutions {\n\t\t\tquality := strings.Split(strings.Split(res[2], \"x\")[1], \",\")[0]\n\t\t\turl := fmt.Sprintf(\"%s/%s\", baseURL, strings.TrimSpace(res[4]))\n\t\t\tret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceM3U8, Quality: quality + \"p\"})\n\t\t}\n\n\t\tret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceM3U8, Quality: \"default\"})\n\t} else {\n\t\tfor _, src := range source {\n\t\t\ts := src.(map[string]interface{})\n\t\t\tif s[\"file\"].(string) == url {\n\t\t\t\tquality := strings.Split(s[\"label\"].(string), \" \")[0] + \"p\"\n\t\t\t\tret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceMP4, Quality: quality})\n\t\t\t}\n\t\t}\n\t\tif sourceBK != nil {\n\t\t\tfor _, src := range sourceBK {\n\t\t\t\ts := src.(map[string]interface{})\n\t\t\t\tif s[\"file\"].(string) == url {\n\t\t\t\t\tret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceMP4, Quality: \"backup\"})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ret, true\n}\n\n// generateEncryptedAjaxParams generates encrypted AJAX parameters.\nfunc (g *GogoCDN) generateEncryptedAjaxParams(id, scriptValue string) string {\n\tencryptedKey := g.encrypt(id, g.keys.iv, g.keys.key)\n\tdecryptedToken := g.decrypt(scriptValue, g.keys.iv, g.keys.key)\n\treturn fmt.Sprintf(\"id=%s&alias=%s\", encryptedKey, decryptedToken)\n}\n\n// encrypt encrypts the given text using AES CBC mode.\nfunc (g *GogoCDN) encrypt(text string, iv []byte, key []byte) string {\n\tblock, _ := aes.NewCipher(key)\n\ttextBytes := []byte(text)\n\ttextBytes = pkcs7Padding(textBytes, aes.BlockSize)\n\tcipherText := make([]byte, len(textBytes))\n\n\tmode := cipher.NewCBCEncrypter(block, iv)\n\tmode.CryptBlocks(cipherText, textBytes)\n\n\treturn base64.StdEncoding.EncodeToString(cipherText)\n}\n\n// decrypt decrypts the given text using AES CBC mode.\nfunc (g *GogoCDN) decrypt(text string, iv []byte, key []byte) string {\n\tblock, _ := aes.NewCipher(key)\n\tcipherText, _ := base64.StdEncoding.DecodeString(text)\n\tplainText := make([]byte, len(cipherText))\n\n\tmode := cipher.NewCBCDecrypter(block, iv)\n\tmode.CryptBlocks(plainText, cipherText)\n\tplainText = pkcs7Trimming(plainText)\n\n\treturn string(plainText)\n}\n\nfunc (g *GogoCDN) decryptAjaxData(encryptedData string) (map[string]interface{}, error) {\n\tdecodedData, err := base64.StdEncoding.DecodeString(encryptedData)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tblock, err := aes.NewCipher(g.keys.secondKey)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(decodedData) < aes.BlockSize {\n\t\treturn nil, fmt.Errorf(\"cipher text too short\")\n\t}\n\n\tiv := g.keys.iv\n\tmode := cipher.NewCBCDecrypter(block, iv)\n\tmode.CryptBlocks(decodedData, decodedData)\n\n\t// Remove padding\n\tdecodedData = pkcs7Trimming(decodedData)\n\n\tvar data map[string]interface{}\n\terr = json.Unmarshal(decodedData, &data)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn data, nil\n}\n\n// pkcs7Padding pads the text to be a multiple of blockSize using Pkcs7 padding.\nfunc pkcs7Padding(text []byte, blockSize int) []byte {\n\tpadding := blockSize - len(text)%blockSize\n\tpadText := bytes.Repeat([]byte{byte(padding)}, padding)\n\treturn append(text, padText...)\n}\n\n// pkcs7Trimming removes Pkcs7 padding from the text.\nfunc pkcs7Trimming(text []byte) []byte {\n\tlength := len(text)\n\tunpadding := int(text[length-1])\n\treturn text[:(length - unpadding)]\n}\n\ntype StreamSB struct {\n\tHost string\n\tHost2 string\n\tUserAgent string\n}\n\nfunc NewStreamSB() *StreamSB {\n\treturn &StreamSB{\n\t\tHost: \"https://streamsss.net/sources50\",\n\t\tHost2: \"https://watchsb.com/sources50\",\n\t\tUserAgent: \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36\",\n\t}\n}\n\nfunc (s *StreamSB) Payload(hex string) string {\n\treturn \"566d337678566f743674494a7c7c\" + hex + \"7c7c346b6767586d6934774855537c7c73747265616d7362/6565417268755339773461447c7c346133383438333436313335376136323337373433383634376337633465366534393338373136643732373736343735373237613763376334363733353737303533366236333463353333363534366137633763373337343732363536313664373336327c7c6b586c3163614468645a47617c7c73747265616d7362\"\n}\n\nfunc (s *StreamSB) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\terr = errors.New(\"failed to extract video sources\")\n\t\t}\n\t}()\n\n\tvar ret []*hibikeonlinestream.VideoSource\n\n\tid := strings.Split(uri, \"/e/\")[1]\n\tif strings.Contains(id, \"html\") {\n\t\tid = strings.Split(id, \".html\")[0]\n\t}\n\n\tif id == \"\" {\n\t\treturn nil, errors.New(\"cannot find ID\")\n\t}\n\n\tclient := &http.Client{}\n\treq, _ := http.NewRequest(\"GET\", fmt.Sprintf(\"%s/%s\", s.Host, s.Payload(hex.EncodeToString([]byte(id)))), nil)\n\treq.Header.Add(\"watchsb\", \"sbstream\")\n\treq.Header.Add(\"User-Agent\", s.UserAgent)\n\treq.Header.Add(\"Referer\", uri)\n\n\tres, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer res.Body.Close()\n\n\tbody, _ := io.ReadAll(res.Body)\n\n\tvar jsonResponse map[string]interface{}\n\terr = json.Unmarshal(body, &jsonResponse)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tstreamData, ok := jsonResponse[\"stream_data\"].(map[string]interface{})\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"stream data not found\")\n\t}\n\n\tm3u8Urls, err := client.Get(streamData[\"file\"].(string))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer m3u8Urls.Body.Close()\n\n\tm3u8Body, err := io.ReadAll(m3u8Urls.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvideoList := strings.Split(string(m3u8Body), \"#EXT-X-STREAM-INF:\")\n\n\tfor _, video := range videoList {\n\t\tif !strings.Contains(video, \"m3u8\") {\n\t\t\tcontinue\n\t\t}\n\n\t\turl := strings.Split(video, \"\\n\")[1]\n\t\tquality := strings.Split(strings.Split(video, \"RESOLUTION=\")[1], \",\")[0]\n\t\tquality = strings.Split(quality, \"x\")[1]\n\n\t\tret = append(ret, &hibikeonlinestream.VideoSource{\n\t\t\tURL: url,\n\t\t\tQuality: quality + \"p\",\n\t\t\tType: hibikeonlinestream.VideoSourceM3U8,\n\t\t})\n\t}\n\n\tret = append(ret, &hibikeonlinestream.VideoSource{\n\t\tURL: streamData[\"file\"].(string),\n\t\tQuality: \"auto\",\n\t\tType: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(streamData[\"file\"].(string), \".m3u8\")],\n\t})\n\n\treturn ret, nil\n}\n" +} diff --git a/seanime-2.9.10/internal/extension_repo/testdir/mangapill.json b/seanime-2.9.10/internal/extension_repo/testdir/mangapill.json new file mode 100644 index 0000000..7a4b22d --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/testdir/mangapill.json @@ -0,0 +1,11 @@ +{ + "id": "mangapill-external", + "name": "Mangapill (External)", + "description": "", + "version": "0.0.1", + "type": "manga-provider", + "manifestURI": "", + "language": "go", + "author": "Seanime", + "payload": "package main\n\nimport (\n\t\"fmt\"\n\t\"github.com/5rahim/hibike/pkg/extension/manga\"\n\t\"github.com/5rahim/hibike/pkg/util/bypass\"\n\t\"github.com/5rahim/hibike/pkg/util/common\"\n\t\"github.com/5rahim/hibike/pkg/util/similarity\"\n\t\"github.com/gocolly/colly\"\n\t\"github.com/rs/zerolog\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst MangapillProvider = \"mangapill-external\"\n\ntype (\n\tMangapill struct {\n\t\tUrl string\n\t\tClient *http.Client\n\t\tUserAgent string\n\t\tlogger *zerolog.Logger\n\t}\n)\n\nfunc NewProvider(logger *zerolog.Logger) manga.Provider {\n\tc := &http.Client{\n\t\tTimeout: 60 * time.Second,\n\t}\n\tc.Transport = bypass.AddCloudFlareByPass(c.Transport)\n\treturn &Mangapill{\n\t\tUrl: \"https://mangapill.com\",\n\t\tClient: c,\n\t\tUserAgent: \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3\",\n\t\tlogger: logger,\n\t}\n}\n\n// DEVNOTE: Unique ID\n// Each chapter ID has this format: {number}${slug} -- e.g. 6502-10004000$gokurakugai-chapter-4\n// The chapter ID is split by the $ character to reconstruct the chapter URL for subsequent requests\n\nfunc (mp *Mangapill) Search(opts manga.SearchOptions) (ret []*manga.SearchResult, err error) {\n\tret = make([]*manga.SearchResult, 0)\n\n\tmp.logger.Debug().Str(\"query\", opts.Query).Msg(\"mangapill: Searching manga\")\n\n\turi := fmt.Sprintf(\"%s/search?q=%s\", mp.Url, url.QueryEscape(opts.Query))\n\n\tc := colly.NewCollector(\n\t\tcolly.UserAgent(mp.UserAgent),\n\t)\n\n\tc.WithTransport(mp.Client.Transport)\n\n\tc.OnHTML(\"div.container div.my-3.justify-end > div\", func(e *colly.HTMLElement) {\n\t\tdefer func() {\n\t\t\tif r := recover(); r != nil {\n\t\t\t}\n\t\t}()\n\t\tresult := &manga.SearchResult{\n\t\t\tProvider: \"mangapill\",\n\t\t}\n\n\t\tresult.ID = strings.Split(e.ChildAttr(\"a\", \"href\"), \"/manga/\")[1]\n\t\tresult.ID = strings.Replace(result.ID, \"/\", \"$\", -1)\n\n\t\ttitle := e.DOM.Find(\"div > a > div.mt-3\").Text()\n\t\tresult.Title = strings.TrimSpace(title)\n\n\t\taltTitles := e.DOM.Find(\"div > a > div.text-xs.text-secondary\").Text()\n\t\tif altTitles != \"\" {\n\t\t\tresult.Synonyms = []string{strings.TrimSpace(altTitles)}\n\t\t}\n\n\t\tcompTitles := []string{result.Title}\n\t\tif len(result.Synonyms) > 0 {\n\t\t\tcompTitles = append(compTitles, result.Synonyms[0])\n\t\t}\n\t\tcompRes, _ := similarity.FindBestMatchWithSorensenDice(opts.Query, compTitles)\n\t\tresult.SearchRating = compRes.Rating\n\n\t\tresult.Image = e.ChildAttr(\"a img\", \"data-src\")\n\n\t\tyearStr := e.DOM.Find(\"div > div.flex > div\").Eq(1).Text()\n\t\tyear, err := strconv.Atoi(strings.TrimSpace(yearStr))\n\t\tif err != nil {\n\t\t\tresult.Year = 0\n\t\t} else {\n\t\t\tresult.Year = year\n\t\t}\n\n\t\tret = append(ret, result)\n\t})\n\n\terr = c.Visit(uri)\n\tif err != nil {\n\t\tmp.logger.Error().Err(err).Msg(\"mangapill: Failed to visit\")\n\t\treturn nil, err\n\t}\n\n\t// code\n\n\tif len(ret) == 0 {\n\t\tmp.logger.Error().Str(\"query\", opts.Query).Msg(\"mangapill: No results found\")\n\t\treturn nil, fmt.Errorf(\"no results found\")\n\t}\n\n\tmp.logger.Info().Int(\"count\", len(ret)).Msg(\"mangapill: Found results\")\n\n\treturn ret, nil\n}\n\nfunc (mp *Mangapill) FindChapters(id string) (ret []*manga.ChapterDetails, err error) {\n\tret = make([]*manga.ChapterDetails, 0)\n\n\tmp.logger.Debug().Str(\"mangaId\", id).Msg(\"mangapill: Finding chapters\")\n\n\turiId := strings.Replace(id, \"$\", \"/\", -1)\n\turi := fmt.Sprintf(\"%s/manga/%s\", mp.Url, uriId)\n\n\tc := colly.NewCollector(\n\t\tcolly.UserAgent(mp.UserAgent),\n\t)\n\n\tc.WithTransport(mp.Client.Transport)\n\n\tc.OnHTML(\"div.container div.border-border div#chapters div.grid-cols-1 a\", func(e *colly.HTMLElement) {\n\t\tdefer func() {\n\t\t\tif r := recover(); r != nil {\n\t\t\t}\n\t\t}()\n\t\tchapter := &manga.ChapterDetails{\n\t\t\tProvider: MangapillProvider,\n\t\t}\n\n\t\tchapter.ID = strings.Split(e.Attr(\"href\"), \"/chapters/\")[1]\n\t\tchapter.ID = strings.Replace(chapter.ID, \"/\", \"$\", -1)\n\n\t\tchapter.Title = strings.TrimSpace(e.Text)\n\n\t\tsplitTitle := strings.Split(chapter.Title, \"Chapter \")\n\t\tif len(splitTitle) < 2 {\n\t\t\treturn\n\t\t}\n\t\tchapter.Chapter = splitTitle[1]\n\n\t\tret = append(ret, chapter)\n\t})\n\n\terr = c.Visit(uri)\n\tif err != nil {\n\t\tmp.logger.Error().Err(err).Msg(\"mangapill: Failed to visit\")\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\tmp.logger.Error().Str(\"mangaId\", id).Msg(\"mangapill: No chapters found\")\n\t\treturn nil, fmt.Errorf(\"no chapters found\")\n\t}\n\n\tcommon.Reverse(ret)\n\n\tfor i, chapter := range ret {\n\t\tchapter.Index = uint(i)\n\t}\n\n\tmp.logger.Info().Int(\"count\", len(ret)).Msg(\"mangapill: Found chapters\")\n\n\treturn ret, nil\n}\n\nfunc (mp *Mangapill) FindChapterPages(id string) (ret []*manga.ChapterPage, err error) {\n\tret = make([]*manga.ChapterPage, 0)\n\n\tmp.logger.Debug().Str(\"chapterId\", id).Msg(\"mangapill: Finding chapter pages\")\n\n\turiId := strings.Replace(id, \"$\", \"/\", -1)\n\turi := fmt.Sprintf(\"%s/chapters/%s\", mp.Url, uriId)\n\n\tc := colly.NewCollector(\n\t\tcolly.UserAgent(mp.UserAgent),\n\t)\n\n\tc.WithTransport(mp.Client.Transport)\n\n\tc.OnHTML(\"chapter-page\", func(e *colly.HTMLElement) {\n\t\tdefer func() {\n\t\t\tif r := recover(); r != nil {\n\t\t\t}\n\t\t}()\n\t\tpage := &manga.ChapterPage{}\n\n\t\tpage.URL = e.DOM.Find(\"div picture img\").AttrOr(\"data-src\", \"\")\n\t\tif page.URL == \"\" {\n\t\t\treturn\n\t\t}\n\t\tindexStr := e.DOM.Find(\"div[data-summary] > div\").Text()\n\t\tindex, _ := strconv.Atoi(strings.Split(strings.Split(indexStr, \"page \")[1], \"/\")[0])\n\t\tpage.Index = index - 1\n\n\t\tpage.Headers = map[string]string{\n\t\t\t\"Referer\": mp.Url,\n\t\t}\n\n\t\tret = append(ret, page)\n\t})\n\n\terr = c.Visit(uri)\n\tif err != nil {\n\t\tmp.logger.Error().Err(err).Msg(\"mangapill: Failed to visit\")\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\tmp.logger.Error().Str(\"chapterId\", id).Msg(\"mangapill: No pages found\")\n\t\treturn nil, fmt.Errorf(\"no pages found\")\n\t}\n\n\tmp.logger.Info().Int(\"count\", len(ret)).Msg(\"mangapill: Found pages\")\n\n\treturn ret, nil\n\n}\n" +} diff --git a/seanime-2.9.10/internal/extension_repo/testdir/noop.go b/seanime-2.9.10/internal/extension_repo/testdir/noop.go new file mode 100644 index 0000000..454b38f --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/testdir/noop.go @@ -0,0 +1 @@ +package extension_repo_test diff --git a/seanime-2.9.10/internal/extension_repo/userconfig.go b/seanime-2.9.10/internal/extension_repo/userconfig.go new file mode 100644 index 0000000..9eef07c --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/userconfig.go @@ -0,0 +1,159 @@ +package extension_repo + +import ( + "fmt" + "seanime/internal/extension" + "seanime/internal/plugin" + "seanime/internal/util" + "seanime/internal/util/filecache" + "strings" +) + +func getExtensionUserConfigBucketKey(extId string) string { + return fmt.Sprintf("ext_user_config_%s", extId) +} + +var ( + ErrMissingUserConfig = fmt.Errorf("extension: user config is missing") + ErrIncompatibleUserConfig = fmt.Errorf("extension: user config is incompatible") +) + +// loadUserConfig loads the user config for the given extension by getting it from the cache and modifying the payload. +// This should be called before loading the extension. +// If the user config is absent OR the current user config is outdated, it will return an error. +// When an error is returned, the extension will not be loaded and the user will be prompted to update the extension on the frontend. +func (r *Repository) loadUserConfig(ext *extension.Extension) (err error) { + defer util.HandlePanicInModuleThen("extension_repo/loadUserConfig", func() { + err = nil + }) + + // If the extension doesn't define a user config, skip this step + if ext.UserConfig == nil { + return nil + } + + bucket := filecache.NewPermanentBucket(getExtensionUserConfigBucketKey(ext.ID)) + + // Get the user config from the cache + var savedConfig extension.SavedUserConfig + found, _ := r.fileCacher.GetPerm(bucket, ext.ID, &savedConfig) + + // No user config found but the extension requires it + if !found && ext.UserConfig.RequiresConfig { + return ErrMissingUserConfig + } + + // If the user config is outdated, return an error + if found && savedConfig.Version != ext.UserConfig.Version { + return ErrIncompatibleUserConfig + } + + // Store the user config so it's accessible inside the VMs + ext.SavedUserConfig = &savedConfig + if ext.SavedUserConfig.Values == nil { + ext.SavedUserConfig.Values = make(map[string]string) + } + ext.SavedUserConfig.Version = ext.UserConfig.Version + + if found { + // Replace the placeholders in the payload with the saved values + for _, field := range ext.UserConfig.Fields { + savedValue, found := savedConfig.Values[field.Name] + if !found { + ext.Payload = strings.ReplaceAll(ext.Payload, fmt.Sprintf("{{%s}}", field.Name), field.Default) + ext.SavedUserConfig.Values[field.Name] = field.Default // Update saved config + } else { + ext.Payload = strings.ReplaceAll(ext.Payload, fmt.Sprintf("{{%s}}", field.Name), savedValue) + ext.SavedUserConfig.Values[field.Name] = savedValue // Update saved config + } + } + return nil + } else { + // If the user config is missing but isn't required, replace the placeholders with the default values + for _, field := range ext.UserConfig.Fields { + ext.Payload = strings.ReplaceAll(ext.Payload, fmt.Sprintf("{{%s}}", field.Name), field.Default) + ext.SavedUserConfig.Values[field.Name] = field.Default // Update saved config + } + } + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type ExtensionUserConfig struct { + UserConfig *extension.UserConfig `json:"userConfig"` + SavedUserConfig *extension.SavedUserConfig `json:"savedUserConfig"` +} + +func (r *Repository) GetExtensionUserConfig(id string) (ret *ExtensionUserConfig) { + ret = &ExtensionUserConfig{ + UserConfig: nil, + SavedUserConfig: nil, + } + + defer util.HandlePanicInModuleThen("extension_repo/GetExtensionUserConfig", func() {}) + + ext, found := r.extensionBank.Get(id) + if !found { + return + } + + ret.UserConfig = ext.GetUserConfig() + + bucket := filecache.NewPermanentBucket(getExtensionUserConfigBucketKey(id)) + + var savedConfig extension.SavedUserConfig + found, _ = r.fileCacher.GetPerm(bucket, id, &savedConfig) + + if found { + ret.SavedUserConfig = &savedConfig + } + + return +} + +func (r *Repository) SaveExtensionUserConfig(id string, savedConfig *extension.SavedUserConfig) (err error) { + defer util.HandlePanicInModuleWithError("extension_repo/SaveExtensionUserConfig", &err) + + // Save the config + bucket := filecache.NewPermanentBucket(getExtensionUserConfigBucketKey(id)) + err = r.fileCacher.SetPerm(bucket, id, savedConfig) + if err != nil { + return err + } + + // If the extension is built-in, reload it + builtinExt, isBuiltIn := r.builtinExtensions.Get(id) + if isBuiltIn { + r.reloadBuiltInExtension(builtinExt.Extension, builtinExt.provider) + return nil + } + + // Reload the extension + r.reloadExtension(id) + + return nil +} + +// This should be called when the extension is uninstalled +func (r *Repository) deleteExtensionUserConfig(id string) (err error) { + defer util.HandlePanicInModuleWithError("extension_repo/deleteExtensionUserConfig", &err) + + // Delete the config + bucket := filecache.NewPermanentBucket(getExtensionUserConfigBucketKey(id)) + err = r.fileCacher.RemovePerm(bucket.Name()) + if err != nil { + return err + } + + return nil +} + +// This should be called when the extension is uninstalled +func (r *Repository) deletePluginData(id string) { + defer util.HandlePanicInModuleThen("extension_repo/deletePluginData", func() { + }) + + plugin.GlobalAppContext.DropPluginData(id) +} diff --git a/seanime-2.9.10/internal/extension_repo/utils.go b/seanime-2.9.10/internal/extension_repo/utils.go new file mode 100644 index 0000000..fabeceb --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/utils.go @@ -0,0 +1,159 @@ +package extension_repo + +import ( + "errors" + "fmt" + "regexp" + "seanime/internal/extension" + "seanime/internal/util" + "strings" +) + +func pluginManifestSanityCheck(ext *extension.Extension) error { + // Not a plugin, so no need to check + if ext.Type != extension.TypePlugin { + return nil + } + + // Check plugin manifest + if ext.Plugin == nil { + return fmt.Errorf("plugin manifest is missing") + } + + // Check plugin manifest version + if ext.Plugin.Version == "" { + return fmt.Errorf("plugin manifest version is missing") + } + + // Check plugin permissions version + if ext.Plugin.Version != extension.PluginManifestVersion { + return fmt.Errorf("unsupported plugin manifest version: %v", ext.Plugin.Version) + } + + return nil +} + +func manifestSanityCheck(ext *extension.Extension) error { + if ext.ID == "" || ext.Name == "" || ext.Version == "" || ext.Language == "" || ext.Type == "" || ext.Author == "" { + return fmt.Errorf("extension is missing required fields, ID: %v, Name: %v, Version: %v, Language: %v, Type: %v, Author: %v, Payload: %v", + ext.ID, ext.Name, ext.Version, ext.Language, ext.Type, ext.Author, len(ext.Payload)) + } + + if ext.Payload == "" && ext.PayloadURI == "" { + return fmt.Errorf("extension is missing payload and payload URI") + } + + // Check the ID + if err := isValidExtensionID(ext.ID); err != nil { + return err + } + + // Check name length + if len(ext.Name) > 50 { + return fmt.Errorf("extension name is too long") + } + + // Check author length + if len(ext.Author) > 25 { + return fmt.Errorf("extension author is too long") + } + + if !util.IsValidVersion(ext.Version) { + return fmt.Errorf("invalid version: %v", ext.Version) + } + + // Check language + if ext.Language != extension.LanguageGo && + ext.Language != extension.LanguageJavascript && + ext.Language != extension.LanguageTypescript { + return fmt.Errorf("unsupported language: %v", ext.Language) + } + + // Check type + if ext.Type != extension.TypeMangaProvider && + ext.Type != extension.TypeOnlinestreamProvider && + ext.Type != extension.TypeAnimeTorrentProvider && + ext.Type != extension.TypePlugin { + return fmt.Errorf("unsupported extension type: %v", ext.Type) + } + + if ext.Type == extension.TypePlugin { + if err := pluginManifestSanityCheck(ext); err != nil { + return err + } + } + + ext.Lang = strings.ToLower(ext.Lang) + + return nil +} + +// extensionSanityCheck checks if the extension has all the required fields in the manifest. +func (r *Repository) extensionSanityCheck(ext *extension.Extension) error { + + if err := manifestSanityCheck(ext); err != nil { + return err + } + + // Check that the ID is unique + if err := r.isUniqueExtensionID(ext.ID); err != nil { + return err + } + + return nil +} + +// checks if the extension ID is valid +// Note: The ID must start with a letter and contain only alphanumeric characters +// because it can either be used as a package name or appear in a filename +func isValidExtensionID(id string) error { + if id == "" { + return errors.New("extension ID is empty") + } + if len(id) > 40 { + return errors.New("extension ID is too long") + } + if len(id) < 3 { + return errors.New("extension ID is too short") + } + + if !isValidExtensionIDString(id) { + return errors.New("extension ID contains invalid characters") + } + + return nil +} + +func isValidExtensionIDString(id string) bool { + // Check if the ID starts with a letter and contains only alphanumeric characters + re := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9\-]*[a-zA-Z0-9]$`) + ok := re.MatchString(id) + + if !ok { + return false + } + return true +} + +func (r *Repository) isUniqueExtensionID(id string) error { + // Check if the ID is not a reserved built-in extension ID + _, found := r.extensionBank.Get(id) + if found { + return errors.New("extension ID is already in use") + } + return nil +} + +func ReplacePackageName(src string, newPkgName string) string { + rgxp, err := regexp.Compile(`package \w+`) + if err != nil { + return "" + } + + ogPkg := rgxp.FindString(src) + if ogPkg == "" { + return src + } + + return strings.Replace(src, ogPkg, "package "+newPkgName, 1) +} diff --git a/seanime-2.9.10/internal/extension_repo/utils_test.go b/seanime-2.9.10/internal/extension_repo/utils_test.go new file mode 100644 index 0000000..e482a59 --- /dev/null +++ b/seanime-2.9.10/internal/extension_repo/utils_test.go @@ -0,0 +1,52 @@ +package extension_repo + +import ( + "seanime/internal/util" + "strings" + "testing" +) + +func TestExtensionID(t *testing.T) { + + tests := []struct { + id string + expected bool + }{ + {"my-extension", true}, + {"my-extension-", false}, + {"-my-extension", false}, + {"my-extension-1", true}, + {"my.extension", false}, + {"my_extension", false}, + } + + for _, test := range tests { + if isValidExtensionIDString(test.id) != test.expected { + t.Errorf("isValidExtensionID(%v) != %v", test.id, test.expected) + } + } + +} + +func TestReplacePackageName(t *testing.T) { + extensionPackageName := "ext_" + util.GenerateCryptoID() + + payload := `package main + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" + "errors" + "fmt"` + + newPayload := ReplacePackageName(payload, extensionPackageName) + + if strings.Contains(newPayload, "package main") { + t.Errorf("ReplacePackageName failed") + } + + t.Log(newPayload) +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/bindings.go b/seanime-2.9.10/internal/goja/goja_bindings/bindings.go new file mode 100644 index 0000000..12a4255 --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/bindings.go @@ -0,0 +1,33 @@ +package goja_bindings + +import ( + "errors" + + "github.com/dop251/goja" +) + +func gojaValueIsDefined(v goja.Value) bool { + return v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) +} + +func NewErrorString(vm *goja.Runtime, err string) goja.Value { + return vm.ToValue(vm.NewGoError(errors.New(err))) +} + +func NewError(vm *goja.Runtime, err error) goja.Value { + return vm.ToValue(vm.NewGoError(err)) +} + +func PanicThrowError(vm *goja.Runtime, err error) { + goError := vm.NewGoError(err) + panic(vm.ToValue(goError)) +} + +func PanicThrowErrorString(vm *goja.Runtime, err string) { + goError := vm.NewGoError(errors.New(err)) + panic(vm.ToValue(goError)) +} + +func PanicThrowTypeError(vm *goja.Runtime, err string) { + panic(vm.NewTypeError(err)) +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/common_test.go b/seanime-2.9.10/internal/goja/goja_bindings/common_test.go new file mode 100644 index 0000000..eec03ca --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/common_test.go @@ -0,0 +1,229 @@ +package goja_bindings_test + +import ( + "errors" + "fmt" + "os" + "seanime/internal/extension" + "seanime/internal/extension_repo" + "seanime/internal/util" + "testing" + "time" + + "github.com/dop251/goja" + "github.com/dop251/goja/parser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestVM(t *testing.T) *goja.Runtime { + vm := goja.New() + vm.SetParserOptions(parser.WithDisableSourceMaps) + // Bind the shared bindings + extension_repo.ShareBinds(vm, util.NewLogger()) + fm := extension_repo.FieldMapper{} + vm.SetFieldNameMapper(fm) + return vm +} + +func divide(a, b float64) (float64, error) { + if b == 0 { + return 0, errors.New("division by zero") + } + return a / b, nil +} + +func TestDivideFunction(t *testing.T) { + vm := goja.New() + vm.Set("divide", divide) + + // Case 1: Successful division + result, err := vm.RunString("divide(10, 3);") + assert.NoError(t, err) + assert.Equal(t, 3.3333333333333335, result.Export()) + + // Case 2: Division by zero should throw an exception + _, err = vm.RunString("divide(10, 0);") + assert.Error(t, err) + assert.Contains(t, err.Error(), "division by zero") + + // Case 3: Handling error with try-catch in JS + result, err = vm.RunString(` + try { + divide(10, 0); + } catch (e) { + e.toString(); + } + `) + assert.NoError(t, err) + assert.Equal(t, "GoError: division by zero", result.Export()) +} + +func multipleReturns() (int, string, float64) { + return 42, "hello", 3.14 +} + +func TestMultipleReturns(t *testing.T) { + vm := goja.New() + vm.Set("multiReturn", multipleReturns) + + v, err := vm.RunString("multiReturn();") + assert.NoError(t, err) + util.Spew(v.Export()) +} + +func TestUserConfig(t *testing.T) { + vm := setupTestVM(t) + defer vm.ClearInterrupt() + + ext := &extension.Extension{ + UserConfig: &extension.UserConfig{ + Fields: []extension.ConfigField{ + { + Name: "test", + }, + { + Name: "test2", + Default: "Default value", + }, + }, + }, + SavedUserConfig: &extension.SavedUserConfig{ + Values: map[string]string{ + "test": "Hello World!", + }, + }, + } + extension_repo.ShareBinds(vm, util.NewLogger()) + extension_repo.BindUserConfig(vm, ext, util.NewLogger()) + + vm.RunString(` + const result = $getUserPreference("test"); + console.log(result); + + const result2 = $getUserPreference("test2"); + console.log(result2); + `) +} + +func TestByteSliceToUint8Array(t *testing.T) { + // Initialize a new Goja VM + vm := goja.New() + + // Create a Go byte slice + data := []byte("hello") + + // Set the byte slice in the Goja VM + vm.Set("data", data) + + extension_repo.ShareBinds(vm, util.NewLogger()) + + // JavaScript code to verify the type and contents of 'data' + jsCode := ` + console.log(typeof data, data); + + const dataArrayBuffer = new ArrayBuffer(5); + const uint8Array = new Uint8Array(dataArrayBuffer); + uint8Array[0] = 104; + uint8Array[1] = 101; + uint8Array[2] = 108; + uint8Array[3] = 108; + uint8Array[4] = 111; + console.log(typeof uint8Array, uint8Array); + + console.log("toString", $toString(uint8Array)); + console.log("toString", uint8Array.toString()); + + + true; // Return true if all checks pass + ` + + // Run the JavaScript code in the Goja VM + result, err := vm.RunString(jsCode) + if err != nil { + t.Fatalf("JavaScript error: %v", err) + } + + // Assert that the result is true + assert.Equal(t, true, result.Export()) +} + +func TestGojaDocument(t *testing.T) { + vm := setupTestVM(t) + defer vm.ClearInterrupt() + + tests := []struct { + entry string + }{ + {entry: "./js/test/doc-example.ts"}, + {entry: "./js/test/doc-example-2.ts"}, + } + + for _, tt := range tests { + t.Run(tt.entry, func(t *testing.T) { + fileB, err := os.ReadFile(tt.entry) + require.NoError(t, err) + + now := time.Now() + + source, err := extension_repo.JSVMTypescriptToJS(string(fileB)) + require.NoError(t, err) + + _, err = vm.RunString(source) + require.NoError(t, err) + + _, err = vm.RunString(`function NewProvider() { return new Provider() }`) + require.NoError(t, err) + + newProviderFunc, ok := goja.AssertFunction(vm.Get("NewProvider")) + require.True(t, ok) + + classObjVal, err := newProviderFunc(goja.Undefined()) + require.NoError(t, err) + + classObj := classObjVal.ToObject(vm) + + testFunc, ok := goja.AssertFunction(classObj.Get("test")) + require.True(t, ok) + + ret, err := testFunc(classObj) + require.NoError(t, err) + + promise := ret.Export().(*goja.Promise) + + for promise.State() == goja.PromiseStatePending { + time.Sleep(10 * time.Millisecond) + } + + if promise.State() == goja.PromiseStateFulfilled { + t.Logf("Fulfilled: %v", promise.Result()) + } else { + t.Fatalf("Rejected: %v", promise.Result()) + } + + fmt.Println(time.Since(now).Seconds()) + }) + } +} + +func TestOptionalParams(t *testing.T) { + vm := setupTestVM(t) + defer vm.ClearInterrupt() + + type Options struct { + Add int `json:"add"` + } + + vm.Set("test", func(a int, opts Options) int { + fmt.Println("opts", opts) + return a + opts.Add + }) + + vm.RunString(` + const result = test(1); + console.log(result); + + const result2 = test(1, { add: 10 }); + console.log(result2); + `) +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/console.go b/seanime-2.9.10/internal/goja/goja_bindings/console.go new file mode 100644 index 0000000..9072544 --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/console.go @@ -0,0 +1,200 @@ +package goja_bindings + +import ( + "encoding/json" + "fmt" + "seanime/internal/events" + "seanime/internal/extension" + "strings" + + "github.com/dop251/goja" + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Console +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type console struct { + logger *zerolog.Logger + vm *goja.Runtime + wsEventManager mo.Option[events.WSEventManagerInterface] + ext *extension.Extension +} + +// BindConsole binds the console to the VM +func BindConsole(vm *goja.Runtime, logger *zerolog.Logger) error { + return BindConsoleWithWS(nil, vm, logger, nil) +} + +// BindConsoleWithWS binds the console to the VM and sends logs messages to the websocket manager +// in order to be printed in the browser console +func BindConsoleWithWS(ext *extension.Extension, vm *goja.Runtime, logger *zerolog.Logger, wsEventManager events.WSEventManagerInterface) error { + c := &console{ + logger: logger, + vm: vm, + wsEventManager: mo.None[events.WSEventManagerInterface](), + ext: ext, + } + if wsEventManager != nil { + c.wsEventManager = mo.Some(wsEventManager) + } + + consoleObj := vm.NewObject() + consoleObj.Set("log", c.logFunc("log")) + consoleObj.Set("error", c.logFunc("error")) + consoleObj.Set("warn", c.logFunc("warn")) + consoleObj.Set("info", c.logFunc("info")) + consoleObj.Set("debug", c.logFunc("debug")) + + vm.Set("console", consoleObj) + + return nil +} + +func (c *console) logFunc(t string) (ret func(c goja.FunctionCall) goja.Value) { + defer func() { + if r := recover(); r != nil { + c.logger.Error().Msgf("extension: Panic from console: %v", r) + ret = func(call goja.FunctionCall) goja.Value { + return goja.Undefined() + } + } + }() + + return func(call goja.FunctionCall) goja.Value { + var ret []string + for _, arg := range call.Arguments { + // Check if the argument is a goja.Object + if obj, ok := arg.(*goja.Object); ok { + // First check if it's a Go error + if _, ok := obj.Export().(error); ok { + ret = append(ret, fmt.Sprintf("%+v", obj.Export())) + continue + } + + // Then check if it's a JavaScript Error object by checking its constructor name + constructor := obj.Get("constructor") + if constructor != nil && !goja.IsUndefined(constructor) && !goja.IsNull(constructor) { + if constructorObj, ok := constructor.(*goja.Object); ok { + if name := constructorObj.Get("name"); name != nil && !goja.IsUndefined(name) && !goja.IsNull(name) { + if name.String() == "Error" || strings.HasSuffix(name.String(), "Error") { + message := obj.Get("message") + stack := obj.Get("stack") + errStr := name.String() + if message != nil && !goja.IsUndefined(message) && !goja.IsNull(message) { + errStr += ": " + fmt.Sprintf("%+v", message.Export()) + } + if stack != nil && !goja.IsUndefined(stack) && !goja.IsNull(stack) { + errStr += "\nStack: " + fmt.Sprintf("%+v", stack.Export()) + } + ret = append(ret, errStr) + continue + } + } + } + } + + // Fallback for other objects: Try calling toString() if available + if hasOwnPropFn, ok := goja.AssertFunction(obj.Get("hasOwnProperty")); ok { + if retVal, err := hasOwnPropFn(obj, c.vm.ToValue("toString")); err == nil && retVal.ToBoolean() { + tsVal := obj.Get("toString") + if fn, ok := goja.AssertFunction(tsVal); ok { + strVal, err := fn(obj) + if err == nil { + // Avoid double logging if toString() is just "[object Object]" + if strVal.String() != "[object Object]" { + ret = append(ret, strVal.String()) + continue // Skip default handling if toString() worked + } + } + } + } + } + } + + // Original default handling + switch v := arg.Export().(type) { + case nil: + ret = append(ret, "undefined") + case bool: + ret = append(ret, fmt.Sprintf("%t", v)) + case int64, float64: + ret = append(ret, fmt.Sprintf("%v", v)) + case string: + if v == "" { + ret = append(ret, fmt.Sprintf("%q", v)) + break + } + ret = append(ret, fmt.Sprintf("%s", v)) + case []byte: + ret = append(ret, fmt.Sprintf("Uint8Array %s", fmt.Sprint(v))) + case map[string]interface{}: + // Try to marshal the value to JSON + bs, err := json.Marshal(v) + if err != nil { + ret = append(ret, fmt.Sprintf("%+v", v)) + } else { + ret = append(ret, fmt.Sprintf("%s", string(bs))) + } + default: + // Try to marshal the value to JSON + bs, err := json.Marshal(v) + if err != nil { + ret = append(ret, fmt.Sprintf("%+v", v)) + } else { + ret = append(ret, fmt.Sprintf("%s", string(bs))) + } + } + } + switch t { + case "log", "warn", "info", "debug": + c.logger.Debug().Msgf("extension: (console.%s): %s", t, strings.Join(ret, " ")) + case "error": + c.logger.Error().Msgf("extension: (console.error): %s", strings.Join(ret, " ")) + } + if wsEventManager, found := c.wsEventManager.Get(); found && c.ext != nil { + wsEventManager.SendEvent(events.ConsoleLog, fmt.Sprintf("%s (console.%s): %s", c.ext.ID, t, strings.Join(ret, " "))) + } + return goja.Undefined() + } +} + +//func (c *console) logFunc(t string) (ret func(c goja.FunctionCall) goja.Value) { +// defer func() { +// if r := recover(); r != nil { +// c.logger.Error().Msgf("extension: Panic from console: %v", r) +// ret = func(call goja.FunctionCall) goja.Value { +// return goja.Undefined() +// } +// } +// }() +// +// return func(call goja.FunctionCall) goja.Value { +// var ret []string +// for _, arg := range call.Arguments { +// if arg == nil || arg.Export() == nil || arg.ExportType() == nil { +// ret = append(ret, "undefined") +// continue +// } +// if bytes, ok := arg.Export().([]byte); ok { +// ret = append(ret, fmt.Sprintf("%s", string(bytes))) +// continue +// } +// if arg.ExportType().Kind() == reflect.Struct || arg.ExportType().Kind() == reflect.Map || arg.ExportType().Kind() == reflect.Slice { +// ret = append(ret, strings.ReplaceAll(spew.Sprint(arg.Export()), "\n", "")) +// } else { +// ret = append(ret, fmt.Sprintf("%+v", arg.Export())) +// } +// } +// +// switch t { +// case "log", "warn", "info", "debug": +// c.logger.Debug().Msgf("extension: [console.%s] %s", t, strings.Join(ret, " ")) +// case "error": +// c.logger.Error().Msgf("extension: [console.error] %s", strings.Join(ret, " ")) +// } +// return goja.Undefined() +// } +//} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/crypto.go b/seanime-2.9.10/internal/goja/goja_bindings/crypto.go new file mode 100644 index 0000000..95df879 --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/crypto.go @@ -0,0 +1,330 @@ +package goja_bindings + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + + "github.com/dop251/goja" +) + +type wordArray struct { + vm *goja.Runtime + iv []byte + data []byte +} + +func BindCrypto(vm *goja.Runtime) error { + + err := vm.Set("CryptoJS", map[string]interface{}{ + "AES": map[string]interface{}{ + "encrypt": cryptoAESEncryptFunc(vm), + "decrypt": cryptoAESDecryptFunc(vm), + }, + "enc": map[string]interface{}{ + "Utf8": map[string]interface{}{ + "parse": cryptoEncUtf8ParseFunc(vm), + "stringify": cryptoEncUtf8StringifyFunc(vm), + }, + "Base64": map[string]interface{}{ + "parse": cryptoEncBase64ParseFunc(vm), + "stringify": cryptoEncBase64StringifyFunc(vm), + }, + "Hex": map[string]interface{}{ + "parse": cryptoEncHexParseFunc(vm), + "stringify": cryptoEncHexStringifyFunc(vm), + }, + "Latin1": map[string]interface{}{ + "parse": cryptoEncLatin1ParseFunc(vm), + "stringify": cryptoEncLatin1StringifyFunc(vm), + }, + "Utf16": map[string]interface{}{ + "parse": cryptoEncUtf16ParseFunc(vm), + "stringify": cryptoEncUtf16StringifyFunc(vm), + }, + "Utf16LE": map[string]interface{}{ + "parse": cryptoEncUtf16LEParseFunc(vm), + "stringify": cryptoEncUtf16LEStringifyFunc(vm), + }, + }, + }) + if err != nil { + return err + } + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func newWordArrayGojaValue(vm *goja.Runtime, data []byte, iv []byte) goja.Value { + wa := &wordArray{ + vm: vm, + iv: iv, + data: data, + } + // WordArray // Utf8 + // WordArray.toString(): string // Uses Base64.stringify + // WordArray.toString(encoder: Encoder): string + obj := vm.NewObject() + obj.Prototype().Set("toString", wa.toStringFunc) + obj.Set("toString", wa.toStringFunc) + obj.Set("iv", iv) + return obj +} + +func (wa *wordArray) toStringFunc(call goja.FunctionCall) goja.Value { + if len(call.Arguments) == 0 { + return wa.vm.ToValue(base64Stringify(wa.data)) + } + + encoder, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + panic(wa.vm.ToValue("TypeError: encoder parameter must be a CryptoJS.enc object")) + } + + var ret string + if f, ok := encoder["stringify"]; ok { + if stringify, ok := f.(func(functionCall goja.FunctionCall) goja.Value); ok { + ret = stringify(goja.FunctionCall{Arguments: []goja.Value{wa.vm.ToValue(wa.data)}}).String() + } else { + panic(wa.vm.ToValue("TypeError: encoder.stringify must be a function")) + } + } else { + ret = string(wa.data) + } + + return wa.vm.ToValue(ret) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// CryptoJS.AES.encrypt(message: string, key: string, cfg?: { iv: ArrayBuffer }): WordArray +func cryptoAESEncryptFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) (ret goja.Value) { + if len(call.Arguments) < 2 { + panic(vm.ToValue("TypeError: AES.encrypt requires at least 2 arguments")) + } + + message := call.Argument(0).String() + + var keyBytes []byte + switch call.Argument(1).Export().(type) { + case string: + key := call.Argument(1).String() + keyBytes = adjustKeyLength([]byte(key)) + case []byte: + keyBytes = call.Argument(1).Export().([]byte) + keyBytes = adjustKeyLength(keyBytes) + default: + panic(vm.ToValue("TypeError: key parameter must be a string or an ArrayBuffer")) + } + + usedRandomIV := false + // Check if IV is provided + var ivBytes []byte + if len(call.Arguments) > 2 { + cfg := call.Argument(2).Export().(map[string]interface{}) + var ok bool + iv, ok := cfg["iv"].([]byte) + if !ok { + panic(vm.ToValue("TypeError: iv parameter must be an ArrayBuffer")) + } + ivBytes = iv + if len(ivBytes) != aes.BlockSize { + panic(vm.ToValue("TypeError: IV length must be equal to block size (16 bytes for AES)")) + } + } else { + // Generate a random IV + ivBytes = make([]byte, aes.BlockSize) + if _, err := io.ReadFull(rand.Reader, ivBytes); err != nil { + panic(vm.ToValue(fmt.Sprintf("Failed to generate IV: %v", err))) + } + usedRandomIV = true + } + + defer func() { + if r := recover(); r != nil { + ret = vm.ToValue(fmt.Sprintf("Encryption failed: %v", r)) + } + }() + + // Encrypt the message + encryptedMessage := encryptAES(vm, message, keyBytes, ivBytes) + + if usedRandomIV { + // Prepend the IV to the encrypted message + encryptedMessage = append(ivBytes, encryptedMessage...) + } + + return newWordArrayGojaValue(vm, encryptedMessage, ivBytes) + } +} + +// CryptoJS.AES.decrypt(encryptedMessage: string | WordArray, key: string, cfg?: { iv: ArrayBuffer }): WordArray +func cryptoAESDecryptFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) (ret goja.Value) { + if len(call.Arguments) < 2 { + panic(vm.ToValue("TypeError: AES.decrypt requires at least 2 arguments")) + } + + // Can be string or WordArray + // If WordArray, String() will call WordArray.toString() which will return the base64 encoded string + encryptedMessage := call.Argument(0).String() + + var keyBytes []byte + var originalPassword []byte + switch call.Argument(1).Export().(type) { + case string: + key := call.Argument(1).String() + originalPassword = []byte(key) + keyBytes = adjustKeyLength([]byte(key)) + case []byte: + keyBytes = call.Argument(1).Export().([]byte) + originalPassword = keyBytes + keyBytes = adjustKeyLength(keyBytes) + default: + panic(vm.ToValue("TypeError: key parameter must be a string or an ArrayBuffer")) + } + + var ivBytes []byte + var cipherText []byte + + // If IV is provided in the third argument + if len(call.Arguments) > 2 { + cfg := call.Argument(2).Export().(map[string]interface{}) + var ok bool + iv, ok := cfg["iv"].([]byte) + if !ok { + panic(vm.ToValue("TypeError: iv parameter must be an ArrayBuffer")) + } + ivBytes = iv + if len(ivBytes) != aes.BlockSize { + panic(vm.ToValue("TypeError: IV length must be equal to block size (16 bytes for AES)")) + } + var err error + decodedMessage, err := base64.StdEncoding.DecodeString(encryptedMessage) + if err != nil { + panic(vm.ToValue(fmt.Sprintf("Failed to decode ciphertext: %v", err))) + } + cipherText = decodedMessage + } else { + // Decode the base64 encoded string + decodedMessage, err := base64.StdEncoding.DecodeString(encryptedMessage) + if err != nil { + panic(vm.ToValue(fmt.Sprintf("Failed to decode ciphertext: %v", err))) + } + + // Check if openssl + if len(decodedMessage) >= 16 && string(decodedMessage[:8]) == "Salted__" { + salt := decodedMessage[8:16] + cipherText = decodedMessage[16:] + derivedKey, derivedIV := evpBytesToKey(originalPassword, salt, 32, aes.BlockSize) + keyBytes = derivedKey + ivBytes = derivedIV + } else { + // Extract the IV from the beginning of the message + ivBytes = decodedMessage[:aes.BlockSize] + cipherText = decodedMessage[aes.BlockSize:] + } + } + + // Decrypt the message + decrypted := decryptAES(vm, cipherText, keyBytes, ivBytes) + + return newWordArrayGojaValue(vm, decrypted, ivBytes) + } +} + +// Adjusts the key length to match AES key length requirements (16, 24, or 32 bytes). +// If the key length is not 16, 24, or 32 bytes, it is hashed using SHA-256 and truncated to 32 bytes (AES-256). +func adjustKeyLength(keyBytes []byte) []byte { + switch len(keyBytes) { + case 16, 24, 32: + // Valid AES key lengths: 16 bytes (AES-128), 24 bytes (AES-192), 32 bytes (AES-256) + return keyBytes + default: + // Hash the key to 32 bytes (AES-256) + hash := sha256.Sum256(keyBytes) + return hash[:] + } +} + +func encryptAES(vm *goja.Runtime, message string, key []byte, iv []byte) (ret []byte) { + defer func() { + if r := recover(); r != nil { + ret = nil + } + }() + + block, err := aes.NewCipher(key) + if err != nil { + panic(vm.ToValue(fmt.Sprintf("%v", err))) + } + + messageBytes := []byte(message) + messageBytes = pkcs7Padding(messageBytes, aes.BlockSize) + + cipherText := make([]byte, len(messageBytes)) + + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(cipherText, messageBytes) + + return cipherText +} + +func decryptAES(vm *goja.Runtime, cipherText []byte, key []byte, iv []byte) (ret []byte) { + defer func() { + if r := recover(); r != nil { + ret = nil + } + }() + + block, err := aes.NewCipher(key) + if err != nil { + panic(vm.ToValue(fmt.Sprintf("%v", err))) + } + + plainText := make([]byte, len(cipherText)) + + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(plainText, cipherText) + + plainText = pkcs7Trimming(plainText) + + return plainText +} + +func pkcs7Padding(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + +func pkcs7Trimming(data []byte) []byte { + length := len(data) + up := int(data[length-1]) + return data[:(length - up)] +} + +func evpBytesToKey(password []byte, salt []byte, keyLen, ivLen int) ([]byte, []byte) { + d := make([]byte, 0) + dI := make([]byte, 0) + + for len(d) < (keyLen + ivLen) { + h := md5.New() + h.Write(dI) + h.Write(password) + h.Write(salt) + dI = h.Sum(nil) + d = append(d, dI...) + } + + return d[:keyLen], d[keyLen : keyLen+ivLen] +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/crypto_encoders.go b/seanime-2.9.10/internal/goja/goja_bindings/crypto_encoders.go new file mode 100644 index 0000000..a87ce9a --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/crypto_encoders.go @@ -0,0 +1,296 @@ +package goja_bindings + +import ( + "encoding/base64" + "encoding/hex" + "github.com/dop251/goja" + "golang.org/x/text/encoding/charmap" + "unicode/utf16" +) + +// UTF-8 Encode +func utf8Parse(input string) []byte { + return []byte(input) +} + +// UTF-8 Decode +func utf8Stringify(input []byte) string { + return string(input) +} + +// Base64 Encode +func base64Parse(input string) []byte { + data, err := base64.StdEncoding.DecodeString(input) + if err != nil { + return nil + } + return data +} + +// Base64 Decode +func base64Stringify(input []byte) string { + return base64.StdEncoding.EncodeToString(input) +} + +// Hex Encode +func hexParse(input string) []byte { + data, err := hex.DecodeString(input) + if err != nil { + return nil + } + return data +} + +// Hex Decode +func hexStringify(input []byte) string { + return hex.EncodeToString(input) +} + +// Latin1 Encode +func latin1Parse(input string) []byte { + encoder := charmap.ISO8859_1.NewEncoder() + data, _ := encoder.Bytes([]byte(input)) + return data +} + +// Latin1 Decode +func latin1Stringify(input []byte) string { + decoder := charmap.ISO8859_1.NewDecoder() + data, _ := decoder.Bytes(input) + return string(data) +} + +// UTF-16 Encode +func utf16Parse(input string) []byte { + encoded := utf16.Encode([]rune(input)) + result := make([]byte, len(encoded)*2) + for i, val := range encoded { + result[i*2] = byte(val >> 8) + result[i*2+1] = byte(val) + } + return result +} + +// UTF-16 Decode +func utf16Stringify(input []byte) string { + if len(input)%2 != 0 { + return "" + } + decoded := make([]uint16, len(input)/2) + for i := 0; i < len(decoded); i++ { + decoded[i] = uint16(input[i*2])<<8 | uint16(input[i*2+1]) + } + return string(utf16.Decode(decoded)) +} + +// UTF-16LE Encode +func utf16LEParse(input string) []byte { + encoded := utf16.Encode([]rune(input)) + result := make([]byte, len(encoded)*2) + for i, val := range encoded { + result[i*2] = byte(val) + result[i*2+1] = byte(val >> 8) + } + return result +} + +// UTF-16LE Decode +func utf16LEStringify(input []byte) string { + if len(input)%2 != 0 { + return "" + } + decoded := make([]uint16, len(input)/2) + for i := 0; i < len(decoded); i++ { + decoded[i] = uint16(input[i*2]) | uint16(input[i*2+1])<<8 + } + return string(utf16.Decode(decoded)) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// CryptoJS.enc.Utf8.parse(input: string): WordArray +func cryptoEncUtf8ParseFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Utf8.parse requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val := call.Argument(0).String() + return vm.ToValue(utf8Parse(val)) + } +} + +// CryptoJS.enc.Utf8.stringify(wordArray: WordArray): string +func cryptoEncUtf8StringifyFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Utf8.stringify requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().([]byte) + if !ok { + return vm.ToValue("") + } + return vm.ToValue(utf8Stringify(val)) + } +} + +// CryptoJS.enc.Base64.parse(input: string): WordArray +// e.g. +func cryptoEncBase64ParseFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Base64.parse requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val := call.Argument(0).String() + return vm.ToValue(base64Parse(val)) + } +} + +// CryptoJS.enc.Base64.stringify(wordArray: WordArray): string +func cryptoEncBase64StringifyFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Base64.stringify requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().([]byte) + if !ok { + return vm.ToValue("") + } + return vm.ToValue(base64Stringify(val)) + } +} + +// CryptoJS.enc.Hex.parse(input: string): WordArray +func cryptoEncHexParseFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Hex.parse requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val := call.Argument(0).String() + return vm.ToValue(hexParse(val)) + } +} + +// CryptoJS.enc.Hex.stringify(wordArray: WordArray): string +func cryptoEncHexStringifyFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Hex.stringify requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().([]byte) + if !ok { + return vm.ToValue("") + } + return vm.ToValue(hexStringify(val)) + } +} + +// CryptoJS.enc.Latin1.parse(input: string): WordArray +func cryptoEncLatin1ParseFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Latin1.parse requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val := call.Argument(0).String() + return vm.ToValue(latin1Parse(val)) + } +} + +// CryptoJS.enc.Latin1.stringify(wordArray: WordArray): string +func cryptoEncLatin1StringifyFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Latin1.stringify requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().([]byte) + if !ok { + return vm.ToValue("") + } + return vm.ToValue(latin1Stringify(val)) + } +} + +// CryptoJS.enc.Utf16.parse(input: string): WordArray +func cryptoEncUtf16ParseFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Utf16.parse requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val := call.Argument(0).String() + return vm.ToValue(utf16Parse(val)) + } +} + +// CryptoJS.enc.Utf16.stringify(wordArray: WordArray): string +func cryptoEncUtf16StringifyFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Utf16.stringify requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().([]byte) + if !ok { + return vm.ToValue("") + } + return vm.ToValue(utf16Stringify(val)) + } +} + +// CryptoJS.enc.Utf16LE.parse(input: string): WordArray +func cryptoEncUtf16LEParseFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Utf16LE.parse requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val := call.Argument(0).String() + return vm.ToValue(utf16LEParse(val)) + } +} + +// CryptoJS.enc.Utf16LE.stringify(wordArray: WordArray): string +func cryptoEncUtf16LEStringifyFunc(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.enc.Utf16LE.stringify requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().([]byte) + if !ok { + return vm.ToValue("") + } + return vm.ToValue(utf16LEStringify(val)) + } +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/crypto_hash.go b/seanime-2.9.10/internal/goja/goja_bindings/crypto_hash.go new file mode 100644 index 0000000..62a7b18 --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/crypto_hash.go @@ -0,0 +1,120 @@ +package goja_bindings + +import ( + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "github.com/dop251/goja" + "golang.org/x/crypto/ripemd160" + "golang.org/x/crypto/sha3" +) + +// MD5 Hash +func cryptoMD5Func(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.MD5 requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().(string) + if !ok { + panic(vm.ToValue("TypeError: argument is not a string")) + } + hash := md5.Sum([]byte(val)) + return vm.ToValue(hash[:]) + } +} + +// SHA1 Hash +func cryptoSHA1Func(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.SHA1 requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().(string) + if !ok { + panic(vm.ToValue("TypeError: argument is not a string")) + } + hash := sha1.Sum([]byte(val)) + return vm.ToValue(hash[:]) + } +} + +// SHA256 Hash +func cryptoSHA256Func(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.SHA256 requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().(string) + if !ok { + panic(vm.ToValue("TypeError: argument is not a string")) + } + hash := sha256.Sum256([]byte(val)) + return vm.ToValue(hash[:]) + } +} + +// SHA512 Hash +func cryptoSHA512Func(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.SHA512 requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().(string) + if !ok { + panic(vm.ToValue("TypeError: argument is not a string")) + } + hash := sha512.Sum512([]byte(val)) + return vm.ToValue(hash[:]) + } +} + +// SHA3 Hash +func cryptoSHA3Func(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.SHA3 requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().(string) + if !ok { + panic(vm.ToValue("TypeError: argument is not a string")) + } + hash := sha3.Sum256([]byte(val)) + return vm.ToValue(hash[:]) + } +} + +// RIPEMD-160 Hash +func cryptoRIPEMD160Func(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: CryptoJS.RIPEMD160 requires at least 1 argument")) + } + if !gojaValueIsDefined(call.Arguments[0]) { + return vm.ToValue("") + } + val, ok := call.Argument(0).Export().(string) + if !ok { + panic(vm.ToValue("TypeError: argument is not a string")) + } + hasher := ripemd160.New() + hasher.Write([]byte(val)) + return vm.ToValue(hasher.Sum(nil)) + } +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/crypto_test.go b/seanime-2.9.10/internal/goja/goja_bindings/crypto_test.go new file mode 100644 index 0000000..3164bd5 --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/crypto_test.go @@ -0,0 +1,194 @@ +package goja_bindings + +import ( + "seanime/internal/util" + "testing" + "time" + + "github.com/dop251/goja" + gojabuffer "github.com/dop251/goja_nodejs/buffer" + gojarequire "github.com/dop251/goja_nodejs/require" + "github.com/stretchr/testify/require" +) + +func TestGojaCrypto(t *testing.T) { + vm := goja.New() + defer vm.ClearInterrupt() + + registry := new(gojarequire.Registry) + registry.Enable(vm) + gojabuffer.Enable(vm) + BindCrypto(vm) + BindConsole(vm, util.NewLogger()) + + _, err := vm.RunString(` +async function run() { + + try { + + console.log("\nTesting Buffer encoding/decoding") + + const originalString = "Hello, this is a string to encode!" + const base64String = Buffer.from(originalString).toString("base64") + + console.log("Original String:", originalString) + console.log("Base64 Encoded:", base64String) + + const decodedString = Buffer.from(base64String, "base64").toString("utf-8") + + console.log("Base64 Decoded:", decodedString) + + } + catch (e) { + console.error(e) + } + + try { + + console.log("\nTesting AES") + + let message = "seanime" + let key = CryptoJS.enc.Utf8.parse("secret key") + + + console.log("Message:", message) + + let encrypted = CryptoJS.AES.encrypt(message, key) + console.log("Encrypted without IV:", encrypted) // map[iv toString] + console.log("Encrypted.toString():", encrypted.toString()) // AoHrnhJfbRht2idLHM82WdkIEpRbXufnA6+ozty9fbk= + console.log("Encrypted.toString(CryptoJS.enc.Base64):", encrypted.toString(CryptoJS.enc.Base64)) // AoHrnhJfbRht2idLHM82WdkIEpRbXufnA6+ozty9fbk= + + let decrypted = CryptoJS.AES.decrypt(encrypted, key) + console.log("Decrypted:", decrypted.toString(CryptoJS.enc.Utf8)) + + let iv = CryptoJS.enc.Utf8.parse("3134003223491201") + encrypted = CryptoJS.AES.encrypt(message, key, { iv: iv }) + console.log("Encrypted with IV:", encrypted) // map[iv toString] + + decrypted = CryptoJS.AES.decrypt(encrypted, key) + console.log("Decrypted without IV:", decrypted.toString(CryptoJS.enc.Utf8)) + + decrypted = CryptoJS.AES.decrypt(encrypted, key, { iv: iv }) + console.log("Decrypted with IV:", decrypted.toString(CryptoJS.enc.Utf8)) // seanime + + } + catch (e) { + console.error(e) + } + + try { + + console.log("\nTesting encoders") + + console.log("") + let a = CryptoJS.enc.Utf8.parse("Hello, World!") + console.log("Base64 Parsed:", a) + let b = CryptoJS.enc.Base64.stringify(a) + console.log("Base64 Stringified:", b) + let c = CryptoJS.enc.Base64.parse(b) + console.log("Base64 Parsed:", c) + let d = CryptoJS.enc.Utf8.stringify(c) + console.log("Base64 Stringified:", d) + console.log("") + + let words = CryptoJS.enc.Latin1.parse("Hello, World!") + console.log("Latin1 Parsed:", words) + let latin1 = CryptoJS.enc.Latin1.stringify(words) + console.log("Latin1 Stringified", latin1) + + words = CryptoJS.enc.Hex.parse("48656c6c6f2c20576f726c6421") + console.log("Hex Parsed:", words) + let hex = CryptoJS.enc.Hex.stringify(words) + console.log("Hex Stringified", hex) + + words = CryptoJS.enc.Utf8.parse("𔭢") + console.log("Utf8 Parsed:", words) + let utf8 = CryptoJS.enc.Utf8.stringify(words) + console.log("Utf8 Stringified", utf8) + + words = CryptoJS.enc.Utf16.parse("Hello, World!") + console.log("Utf16 Parsed:", words) + let utf16 = CryptoJS.enc.Utf16.stringify(words) + console.log("Utf16 Stringified", utf16) + + words = CryptoJS.enc.Utf16LE.parse("Hello, World!") + console.log("Utf16LE Parsed:", words) + utf16 = CryptoJS.enc.Utf16LE.stringify(words) + console.log("Utf16LE Stringified", utf16) + } + catch (e) { + console.error("Error:", e) + } +} +`) + require.NoError(t, err) + + runFunc, ok := goja.AssertFunction(vm.Get("run")) + require.True(t, ok) + + ret, err := runFunc(goja.Undefined()) + require.NoError(t, err) + + promise := ret.Export().(*goja.Promise) + + for promise.State() == goja.PromiseStatePending { + time.Sleep(10 * time.Millisecond) + } + + if promise.State() == goja.PromiseStateRejected { + err := promise.Result() + t.Fatal(err) + } +} + +func TestGojaCryptoOpenSSL(t *testing.T) { + vm := goja.New() + defer vm.ClearInterrupt() + + registry := new(gojarequire.Registry) + registry.Enable(vm) + gojabuffer.Enable(vm) + BindCrypto(vm) + BindConsole(vm, util.NewLogger()) + + _, err := vm.RunString(` +async function run() { + + try { + + console.log("\nTesting Buffer encoding/decoding") + + const payload = "U2FsdGVkX19ZanX9W5jQGgNGOIOBGxhY6gxa1EHnRi3yHL8Ml4cMmQeryf9p04N12VuOjiBas21AcU0Ypc4dB4AWOdc9Cn1wdA2DuQhryUonKYHwV/XXJ53DBn1OIqAvrIAxrN8S2j9Rk5z/F/peu1Kk/d3m82jiKvhTWQcxDeDW8UzCMZbbFnm4qJC3k19+PD5Pal5sBcVTGRXNCpvSSpYb56FcP9Xs+3DyBWhNUqJuO+Wwm3G1J5HhklxCWZ7tcn7TE5Y8d5ORND7t51Padrw4LgEOootqHtfHuBVX6EqlvJslXt0kFgcXJUIO+hw0q5SJ+tiS7o/2OShJ7BCk4XzfQmhFJdBJYGjQ8WPMHYzLuMzDkf6zk2+m7YQtUTXx8SVoLXFOt8gNZeD942snGrWA5+CdYveOfJ8Yv7owoOueMzzYqr5rzG7GVapVI0HzrA24LR4AjRDICqTsJEy6Yg==" + const key = "6315b93606d60f48c964b67b14701f3848ef25af01296cf7e6a98c9460e1d2ac" + console.log("Original String:", payload) + + const decrypted = CryptoJS.AES.decrypt(payload, key) + + console.log("Decrypted:", decrypted.toString(CryptoJS.enc.Utf8)) + + } + catch (e) { + console.error(e) + } + +} +`) + require.NoError(t, err) + + runFunc, ok := goja.AssertFunction(vm.Get("run")) + require.True(t, ok) + + ret, err := runFunc(goja.Undefined()) + require.NoError(t, err) + + promise := ret.Export().(*goja.Promise) + + for promise.State() == goja.PromiseStatePending { + time.Sleep(10 * time.Millisecond) + } + + if promise.State() == goja.PromiseStateRejected { + err := promise.Result() + t.Fatal(err) + } +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/document.go b/seanime-2.9.10/internal/goja/goja_bindings/document.go new file mode 100644 index 0000000..58979f8 --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/document.go @@ -0,0 +1,679 @@ +package goja_bindings + +import ( + "fmt" + "github.com/PuerkitoBio/goquery" + "github.com/dop251/goja" + "strings" +) + +type doc struct { + vm *goja.Runtime + doc *goquery.Document + docSelection *docSelection +} + +type docSelection struct { + doc *doc + selection *goquery.Selection +} + +func setSelectionObjectProperties(obj *goja.Object, docS *docSelection) { + _ = obj.Set("length", docS.Length) + _ = obj.Set("html", docS.Html) + _ = obj.Set("text", docS.Text) + _ = obj.Set("attr", docS.Attr) + _ = obj.Set("find", docS.Find) + _ = obj.Set("children", docS.Children) + _ = obj.Set("each", docS.Each) + _ = obj.Set("text", docS.Text) + _ = obj.Set("parent", docS.Parent) + _ = obj.Set("parentsUntil", docS.ParentsUntil) + _ = obj.Set("parents", docS.Parents) + _ = obj.Set("end", docS.End) + _ = obj.Set("closest", docS.Closest) + _ = obj.Set("map", docS.Map) + _ = obj.Set("first", docS.First) + _ = obj.Set("last", docS.Last) + _ = obj.Set("eq", docS.Eq) + _ = obj.Set("contents", docS.Contents) + _ = obj.Set("contentsFiltered", docS.ContentsFiltered) + _ = obj.Set("filter", docS.Filter) + _ = obj.Set("not", docS.Not) + _ = obj.Set("is", docS.Is) + _ = obj.Set("has", docS.Has) + _ = obj.Set("next", docS.Next) + _ = obj.Set("nextAll", docS.NextAll) + _ = obj.Set("nextUntil", docS.NextUntil) + _ = obj.Set("prev", docS.Prev) + _ = obj.Set("prevAll", docS.PrevAll) + _ = obj.Set("prevUntil", docS.PrevUntil) + _ = obj.Set("siblings", docS.Siblings) + _ = obj.Set("data", docS.Data) + _ = obj.Set("attrs", docS.Attrs) +} + +func BindDocument(vm *goja.Runtime) error { + // Set Doc "class" + err := vm.Set("Doc", func(call goja.ConstructorCall) *goja.Object { + obj := call.This + if len(call.Arguments) != 1 { + return goja.Undefined().ToObject(vm) + } + html := call.Arguments[0].String() + + goqueryDoc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return goja.Undefined().ToObject(vm) + } + d := &doc{ + vm: vm, + doc: goqueryDoc, + docSelection: &docSelection{ + doc: nil, + selection: goqueryDoc.Selection, + }, + } + d.docSelection.doc = d + + setSelectionObjectProperties(obj, d.docSelection) + return obj + }) + if err != nil { + return err + } + + // Set "LoadDoc" function + err = vm.Set("LoadDoc", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) != 1 { + panic(vm.ToValue("missing argument")) + } + + html := call.Arguments[0].String() + goqueryDoc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return goja.Null() + } + + d := &doc{ + vm: vm, + doc: goqueryDoc, + docSelection: &docSelection{ + doc: nil, + selection: goqueryDoc.Selection, + }, + } + d.docSelection.doc = d + + docSelectionFunction := func(call goja.FunctionCall) goja.Value { + selectorStr, ok := call.Argument(0).Export().(string) + if !ok { + panic(vm.NewTypeError("argument is not a string").ToString()) + } + return newDocSelectionGojaValue(d, d.doc.Find(selectorStr)) + } + + return vm.ToValue(docSelectionFunction) + }) + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Document +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func newDocSelectionGojaValue(d *doc, selection *goquery.Selection) goja.Value { + ds := &docSelection{ + doc: d, + selection: selection, + } + + obj := d.vm.NewObject() + setSelectionObjectProperties(obj, ds) + + return obj +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Selection +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (s *docSelection) getFirstStringArg(call goja.FunctionCall) string { + selectorStr, ok := call.Argument(0).Export().(string) + if !ok { + panic(s.doc.vm.NewTypeError("argument is not a string").ToString()) + } + return selectorStr +} + +func (s *docSelection) Length(call goja.FunctionCall) goja.Value { + if s.selection == nil { + return s.doc.vm.ToValue(0) + } + return s.doc.vm.ToValue(s.selection.Length()) +} + +// Find gets the descendants of each element in the current set of matched elements, filtered by a selector. +// +// find(selector: string): DocSelection; +func (s *docSelection) Find(call goja.FunctionCall) (ret goja.Value) { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.Find(selectorStr)) +} + +func (s *docSelection) Html(call goja.FunctionCall) goja.Value { + if s.selection == nil { + return goja.Null() + } + htmlStr, err := s.selection.Html() + if err != nil { + return goja.Null() + } + return s.doc.vm.ToValue(htmlStr) +} + +func (s *docSelection) Text(call goja.FunctionCall) goja.Value { + if s.selection == nil { + return s.doc.vm.ToValue("") + } + return s.doc.vm.ToValue(s.selection.Text()) +} + +// Attr gets the specified attribute's value for the first element in the Selection. To get the value for each element individually, use a +// looping construct such as Each or Map method. +// +// attr(name: string): string | undefined; +func (s *docSelection) Attr(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + attr, found := s.selection.Attr(s.getFirstStringArg(call)) + if !found { + return goja.Undefined() + } + return s.doc.vm.ToValue(attr) +} + +// Attrs gets all attributes for the first element in the Selection. +// +// attrs(): { [key: string]: string }; +func (s *docSelection) Attrs(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + attrs := make(map[string]string) + for _, v := range s.selection.Get(0).Attr { + attrs[v.Key] = v.Val + } + return s.doc.vm.ToValue(attrs) +} + +// Data gets data associated with the matched elements or return the value at the named data store for the first element in the set of matched elements. +// +// data(name?: string): { [key: string]: string } | string | undefined; +func (s *docSelection) Data(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + var data map[string]string + n := s.selection.Get(0) + if n == nil { + return goja.Undefined() + } + for _, v := range n.Attr { + if strings.HasPrefix(v.Key, "data-") { + if data == nil { + data = make(map[string]string) + } + data[v.Key] = v.Val + } + } + return s.doc.vm.ToValue(data) + } + + name := call.Argument(0).String() + n := s.selection.Get(0) + if n == nil { + return goja.Undefined() + } + + data, found := s.selection.Attr(fmt.Sprintf("data-%s", name)) + if !found { + return goja.Undefined() + } + + return s.doc.vm.ToValue(data) +} + +// Parent gets the parent of each element in the Selection. It returns a new Selection object containing the matched elements. +// +// parent(selector?: string): DocSelection; +func (s *docSelection) Parent(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + return newDocSelectionGojaValue(s.doc, s.selection.Parent()) + } + + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.ParentFiltered(selectorStr)) +} + +// Parents gets the ancestors of each element in the current Selection. It returns a new Selection object with the matched elements. +// +// parents(selector?: string): DocSelection; +func (s *docSelection) Parents(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + return newDocSelectionGojaValue(s.doc, s.selection.Parents()) + } + + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.ParentsFiltered(selectorStr)) +} + +// ParentsUntil gets the ancestors of each element in the Selection, up to but not including the element matched by the selector. It returns a +// new Selection object containing the matched elements. +// +// parentsUntil(selector?: string, until?: string): DocSelection; +func (s *docSelection) ParentsUntil(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + selectorStr := s.getFirstStringArg(call) + if len(call.Arguments) < 2 { + return newDocSelectionGojaValue(s.doc, s.selection.ParentsUntil(selectorStr)) + } + untilStr := call.Argument(1).String() + return newDocSelectionGojaValue(s.doc, s.selection.ParentsFilteredUntil(selectorStr, untilStr)) +} + +// End ends the most recent filtering operation in the current chain and returns the set of matched elements to its previous state. +// +// end(): DocSelection; +func (s *docSelection) End(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + return newDocSelectionGojaValue(s.doc, s.selection.End()) +} + +// Closest gets the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree. +// +// closest(selector?: string): DocSelection; +func (s *docSelection) Closest(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + return newDocSelectionGojaValue(s.doc, s.selection.Closest("")) + } + + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.Closest(selectorStr)) +} + +// Next gets the next sibling of each selected element, optionally filtered by a selector. +// +// next(selector?: string): DocSelection; +func (s *docSelection) Next(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + return newDocSelectionGojaValue(s.doc, s.selection.Next()) + } + + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.NextFiltered(selectorStr)) +} + +// NextAll gets all following siblings of each element in the Selection, optionally filtered by a selector. +// +// nextAll(selector?: string): DocSelection; +func (s *docSelection) NextAll(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + return newDocSelectionGojaValue(s.doc, s.selection.NextAll()) + } + + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.NextAllFiltered(selectorStr)) +} + +// NextUntil gets all following siblings of each element up to but not including the element matched by the selector. +// +// nextUntil(selector: string, until?: string): DocSelection; +func (s *docSelection) NextUntil(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + selectorStr := s.getFirstStringArg(call) + if len(call.Arguments) < 2 { + return newDocSelectionGojaValue(s.doc, s.selection.NextUntil(selectorStr)) + } + untilStr := call.Argument(1).String() + return newDocSelectionGojaValue(s.doc, s.selection.NextFilteredUntil(selectorStr, untilStr)) +} + +// Prev gets the previous sibling of each selected element optionally filtered by a selector. +// +// prev(selector?: string): DocSelection; +func (s *docSelection) Prev(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + return newDocSelectionGojaValue(s.doc, s.selection.Prev()) + } + + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.PrevFiltered(selectorStr)) +} + +// PrevAll gets all preceding siblings of each element in the Selection, optionally filtered by a selector. +// +// prevAll(selector?: string): DocSelection; +func (s *docSelection) PrevAll(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + return newDocSelectionGojaValue(s.doc, s.selection.PrevAll()) + } + + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.PrevAllFiltered(selectorStr)) +} + +// PrevUntil gets all preceding siblings of each element up to but not including the element matched by the selector. +// +// prevUntil(selector: string, until?: string): DocSelection; +func (s *docSelection) PrevUntil(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + selectorStr := s.getFirstStringArg(call) + if len(call.Arguments) < 2 { + return newDocSelectionGojaValue(s.doc, s.selection.PrevUntil(selectorStr)) + } + untilStr := call.Argument(1).String() + return newDocSelectionGojaValue(s.doc, s.selection.PrevFilteredUntil(selectorStr, untilStr)) +} + +// Siblings gets the siblings of each element (excluding the element) in the set of matched elements, optionally filtered by a selector. +// +// siblings(selector?: string): DocSelection; +func (s *docSelection) Siblings(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + return newDocSelectionGojaValue(s.doc, s.selection.Siblings()) + } + + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.SiblingsFiltered(selectorStr)) +} + +// Children gets the element children of each element in the set of matched elements. +// +// children(selector?: string): DocSelection; +func (s *docSelection) Children(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + return newDocSelectionGojaValue(s.doc, s.selection.Children()) + } + + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.ChildrenFiltered(selectorStr)) +} + +// Contents gets the children of each element in the Selection, including text and comment nodes. It returns a new Selection object containing +// these elements. +// +// contents(): DocSelection; +func (s *docSelection) Contents(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + return newDocSelectionGojaValue(s.doc, s.selection.Contents()) +} + +// ContentsFiltered gets the children of each element in the Selection, filtered by the specified selector. It returns a new Selection object +// containing these elements. Since selectors only act on Element nodes, this function is an alias to ChildrenFiltered unless the selector is +// empty, in which case it is an alias to Contents. +// +// contentsFiltered(selector: string): DocSelection; +func (s *docSelection) ContentsFiltered(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.ContentsFiltered(selectorStr)) +} + +// Filter reduces the set of matched elements to those that match the selector string. It returns a new Selection object for this subset of +// matching elements. +// +// filter(selector: string | (index: number, element: DocSelection) => boolean): DocSelection; +func (s *docSelection) Filter(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + panic(s.doc.vm.ToValue("missing argument")) + } + + switch call.Argument(0).Export().(type) { + case string: + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.Filter(selectorStr)) + + case func(call goja.FunctionCall) goja.Value: + callback := call.Argument(0).Export().(func(call goja.FunctionCall) goja.Value) + return newDocSelectionGojaValue(s.doc, s.selection.FilterFunction(func(i int, selection *goquery.Selection) bool { + ret, ok := callback(goja.FunctionCall{Arguments: []goja.Value{ + s.doc.vm.ToValue(i), + newDocSelectionGojaValue(s.doc, selection), + }}).Export().(bool) + if !ok { + panic(s.doc.vm.NewTypeError("callback did not return a boolean").ToString()) + } + return ret + })) + default: + panic(s.doc.vm.NewTypeError("argument is not a string or function").ToString()) + } +} + +// Not removes elements from the Selection that match the selector string. It returns a new Selection object with the matching elements removed. +// +// not(selector: string | (index: number, element: DocSelection) => boolean): DocSelection; +func (s *docSelection) Not(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + panic(s.doc.vm.ToValue("missing argument")) + } + + switch call.Argument(0).Export().(type) { + case string: + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.Not(selectorStr)) + case func(call goja.FunctionCall) goja.Value: + callback := call.Argument(0).Export().(func(call goja.FunctionCall) goja.Value) + return newDocSelectionGojaValue(s.doc, s.selection.NotFunction(func(i int, selection *goquery.Selection) bool { + ret, ok := callback(goja.FunctionCall{Arguments: []goja.Value{ + s.doc.vm.ToValue(i), + newDocSelectionGojaValue(s.doc, selection), + }}).Export().(bool) + if !ok { + panic(s.doc.vm.NewTypeError("callback did not return a boolean").ToString()) + } + return ret + })) + default: + panic(s.doc.vm.NewTypeError("argument is not a string or function").ToString()) + } +} + +// Is checks the current matched set of elements against a selector and returns true if at least one of these elements matches. +// +// is(selector: string | (index: number, element: DocSelection) => boolean): boolean; +func (s *docSelection) Is(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + + if len(call.Arguments) == 0 || !gojaValueIsDefined(call.Argument(0)) { + panic(s.doc.vm.ToValue("missing argument")) + } + + switch call.Argument(0).Export().(type) { + case string: + selectorStr := s.getFirstStringArg(call) + return s.doc.vm.ToValue(s.selection.Is(selectorStr)) + case func(call goja.FunctionCall) goja.Value: + callback := call.Argument(0).Export().(func(call goja.FunctionCall) goja.Value) + return s.doc.vm.ToValue(s.selection.IsFunction(func(i int, selection *goquery.Selection) bool { + ret, ok := callback(goja.FunctionCall{Arguments: []goja.Value{ + s.doc.vm.ToValue(i), + newDocSelectionGojaValue(s.doc, selection), + }}).Export().(bool) + if !ok { + panic(s.doc.vm.NewTypeError("callback did not return a boolean").ToString()) + } + return ret + })) + default: + panic(s.doc.vm.NewTypeError("argument is not a string or function").ToString()) + } +} + +// Has reduces the set of matched elements to those that have a descendant that matches the selector. It returns a new Selection object with the +// matching elements. +// +// has(selector: string): DocSelection; +func (s *docSelection) Has(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + selectorStr := s.getFirstStringArg(call) + return newDocSelectionGojaValue(s.doc, s.selection.Has(selectorStr)) +} + +// Each iterates over a Selection object, executing a function for each matched element. It returns the current Selection object. The function f +// is called for each element in the selection with the index of the element in that selection starting at 0, and a *Selection that contains only +// that element. +// +// each(callback: (index: number, element: DocSelection) => void): DocSelection; +func (s *docSelection) Each(call goja.FunctionCall) (ret goja.Value) { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + callback, ok := call.Argument(0).Export().(func(call goja.FunctionCall) goja.Value) + if !ok { + panic(s.doc.vm.NewTypeError("argument is not a function").ToString()) + } + s.selection.Each(func(i int, selection *goquery.Selection) { + callback(goja.FunctionCall{Arguments: []goja.Value{ + s.doc.vm.ToValue(i), + newDocSelectionGojaValue(s.doc, selection), + }}) + }) + return goja.Undefined() +} + +// Map passes each element in the current matched set through a function, producing a slice of string holding the returned values. The function f +// is called for each element in the selection with the index of the element in that selection starting at 0, and a *Selection that contains only +// that element. +// +// map(callback: (index: number, element: DocSelection) => DocSelection): DocSelection[]; +func (s *docSelection) Map(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + callback, ok := call.Argument(0).Export().(func(call goja.FunctionCall) goja.Value) + if !ok { + panic(s.doc.vm.NewTypeError("argument is not a function").ToString()) + } + var retStr []interface{} + var retDocSelection map[string]interface{} + s.selection.Each(func(i int, selection *goquery.Selection) { + val := callback(goja.FunctionCall{Arguments: []goja.Value{ + s.doc.vm.ToValue(i), + newDocSelectionGojaValue(s.doc, selection), + }}) + + if valExport, ok := val.Export().(map[string]interface{}); ok { + retDocSelection = valExport + } + retStr = append(retStr, val.Export()) + + }) + if len(retStr) > 0 { + return s.doc.vm.ToValue(retStr) + } + return s.doc.vm.ToValue(retDocSelection) +} + +// First reduces the set of matched elements to the first in the set. It returns a new Selection object, and an empty Selection object if the +// selection is empty. +// +// first(): DocSelection; +func (s *docSelection) First(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + return newDocSelectionGojaValue(s.doc, s.selection.First()) +} + +// Last reduces the set of matched elements to the last in the set. It returns a new Selection object, and an empty Selection object if the +// selection is empty. +// +// last(): DocSelection; +func (s *docSelection) Last(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + return newDocSelectionGojaValue(s.doc, s.selection.Last()) +} + +// Eq reduces the set of matched elements to the one at the specified index. If a negative index is given, it counts backwards starting at the +// end of the set. It returns a new Selection object, and an empty Selection object if the index is invalid. +// +// eq(index: number): DocSelection; +func (s *docSelection) Eq(call goja.FunctionCall) goja.Value { + if s.selection == nil { + panic(s.doc.vm.ToValue("selection is nil")) + } + index, ok := call.Argument(0).Export().(int64) + if !ok { + panic(s.doc.vm.NewTypeError("argument is not a number").String()) + } + return newDocSelectionGojaValue(s.doc, s.selection.Eq(int(index))) +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/fetch.go b/seanime-2.9.10/internal/goja/goja_bindings/fetch.go new file mode 100644 index 0000000..588d74f --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/fetch.go @@ -0,0 +1,307 @@ +package goja_bindings + +import ( + "encoding/json" + "io" + "strings" + "time" + + "github.com/dop251/goja" + "github.com/imroc/req/v3" + "github.com/rs/zerolog/log" +) + +const ( + maxConcurrentRequests = 50 + defaultTimeout = 35 * time.Second +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Fetch +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var ( + clientWithCloudFlareBypass = req.C(). + SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"). + SetTimeout(defaultTimeout). + EnableInsecureSkipVerify(). + ImpersonateChrome() + + clientWithoutBypass = req.C(). + SetTimeout(defaultTimeout) +) + +type Fetch struct { + vm *goja.Runtime + fetchSem chan struct{} + vmResponseCh chan func() +} + +func NewFetch(vm *goja.Runtime) *Fetch { + return &Fetch{ + vm: vm, + fetchSem: make(chan struct{}, maxConcurrentRequests), + vmResponseCh: make(chan func(), maxConcurrentRequests), + } +} + +func (f *Fetch) ResponseChannel() <-chan func() { + return f.vmResponseCh +} + +func (f *Fetch) Close() { + defer func() { + if r := recover(); r != nil { + } + }() + close(f.vmResponseCh) +} + +type fetchOptions struct { + Method string + Body goja.Value + Headers map[string]string + Timeout int // seconds + NoCloudFlareBypass bool +} + +type fetchResult struct { + body []byte + request *req.Request + response *req.Response + json interface{} +} + +// BindFetch binds the fetch function to the VM +func BindFetch(vm *goja.Runtime) *Fetch { + // Create a new Fetch instance + f := NewFetch(vm) + _ = vm.Set("fetch", f.Fetch) + + go func() { + for fn := range f.ResponseChannel() { + defer func() { + if r := recover(); r != nil { + log.Warn().Msgf("extension: response channel panic: %v", r) + } + }() + fn() + } + }() + + return f +} + +func (f *Fetch) Fetch(call goja.FunctionCall) goja.Value { + defer func() { + if r := recover(); r != nil { + log.Warn().Msgf("extension: fetch panic: %v", r) + } + }() + + promise, resolve, reject := f.vm.NewPromise() + + // Input validation + if len(call.Arguments) == 0 { + PanicThrowTypeError(f.vm, "fetch requires at least 1 argument") + } + + url, ok := call.Argument(0).Export().(string) + if !ok { + PanicThrowTypeError(f.vm, "URL parameter must be a string") + } + + // Parse options + options := fetchOptions{ + Method: "GET", + Timeout: int(defaultTimeout.Seconds()), + NoCloudFlareBypass: false, + } + + var reqBody interface{} + var reqContentType string + + if len(call.Arguments) > 1 { + rawOpts := call.Argument(1).ToObject(f.vm) + if rawOpts != nil && !goja.IsUndefined(rawOpts) { + + if o := rawOpts.Get("method"); o != nil && !goja.IsUndefined(o) { + if v, ok := o.Export().(string); ok { + options.Method = strings.ToUpper(v) + } + } + if o := rawOpts.Get("timeout"); o != nil && !goja.IsUndefined(o) { + if v, ok := o.Export().(int); ok { + options.Timeout = v + } + } + if o := rawOpts.Get("headers"); o != nil && !goja.IsUndefined(o) { + if v, ok := o.Export().(map[string]interface{}); ok { + for k, interf := range v { + if str, ok := interf.(string); ok { + if options.Headers == nil { + options.Headers = make(map[string]string) + } + options.Headers[k] = str + } + } + } + } + + options.Body = rawOpts.Get("body") + + if o := rawOpts.Get("noCloudflareBypass"); o != nil && !goja.IsUndefined(o) { + if v, ok := o.Export().(bool); ok { + options.NoCloudFlareBypass = v + } + } + } + } + + if options.Body != nil && !goja.IsUndefined(options.Body) { + switch v := options.Body.Export().(type) { + case string: + reqBody = v + case io.Reader: + reqBody = v + case []byte: + reqBody = v + case *goja.ArrayBuffer: + reqBody = v.Bytes() + case goja.ArrayBuffer: + reqBody = v.Bytes() + case *formData: + body, mp := v.GetBuffer() + reqBody = body + reqContentType = mp.FormDataContentType() + case map[string]interface{}: + reqBody = v + reqContentType = "application/json" + default: + reqBody = options.Body.String() + } + } + + go func() { + // Acquire semaphore + f.fetchSem <- struct{}{} + defer func() { <-f.fetchSem }() + + log.Trace().Str("url", url).Str("method", options.Method).Msgf("extension: Network request") + + var client *req.Client + if options.NoCloudFlareBypass { + client = clientWithoutBypass + } else { + client = clientWithCloudFlareBypass + } + + // Create request with timeout + reqClient := client.Clone().SetTimeout(time.Duration(options.Timeout) * time.Second) + + request := reqClient.R() + + // Set headers + for k, v := range options.Headers { + request.SetHeader(k, v) + } + + if reqContentType != "" { + request.SetContentType(reqContentType) + } + + // Set body if present + if reqBody != nil { + request.SetBody(reqBody) + } + + var result fetchResult + var resp *req.Response + var err error + + switch options.Method { + case "GET": + resp, err = request.Get(url) + case "POST": + resp, err = request.Post(url) + case "PUT": + resp, err = request.Put(url) + case "DELETE": + resp, err = request.Delete(url) + case "PATCH": + resp, err = request.Patch(url) + case "HEAD": + resp, err = request.Head(url) + case "OPTIONS": + resp, err = request.Options(url) + default: + resp, err = request.Send(options.Method, url) + } + + if err != nil { + f.vmResponseCh <- func() { + _ = reject(NewError(f.vm, err)) + } + return + } + + rawBody := resp.Bytes() + result.body = rawBody + result.response = resp + result.request = request + + if len(rawBody) > 0 { + var data interface{} + if err := json.Unmarshal(rawBody, &data); err != nil { + result.json = nil + } else { + result.json = data + } + } + + f.vmResponseCh <- func() { + _ = resolve(result.toGojaObject(f.vm)) + return + } + }() + + return f.vm.ToValue(promise) +} + +func (f *fetchResult) toGojaObject(vm *goja.Runtime) *goja.Object { + obj := vm.NewObject() + _ = obj.Set("status", f.response.StatusCode) + _ = obj.Set("statusText", f.response.Status) + _ = obj.Set("method", f.request.Method) + _ = obj.Set("rawHeaders", f.response.Header) + _ = obj.Set("ok", f.response.IsSuccessState()) + _ = obj.Set("url", f.response.Request.URL.String()) + _ = obj.Set("body", f.body) + + headers := make(map[string]string) + for k, v := range f.response.Header { + if len(v) > 0 { + headers[k] = v[0] + } + } + _ = obj.Set("headers", headers) + + cookies := make(map[string]string) + for _, cookie := range f.response.Cookies() { + cookies[cookie.Name] = cookie.Value + } + _ = obj.Set("cookies", cookies) + _ = obj.Set("redirected", f.response.Request.URL != f.response.Request.URL) // req handles redirects automatically + _ = obj.Set("contentType", f.response.Header.Get("Content-Type")) + _ = obj.Set("contentLength", f.response.ContentLength) + + _ = obj.Set("text", func() string { + return string(f.body) + }) + + _ = obj.Set("json", func(call goja.FunctionCall) (ret goja.Value) { + return vm.ToValue(f.json) + }) + + return obj +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/fetch_test.go b/seanime-2.9.10/internal/goja/goja_bindings/fetch_test.go new file mode 100644 index 0000000..6c79e75 --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/fetch_test.go @@ -0,0 +1,324 @@ +package goja_bindings + +import ( + "fmt" + "net/http" + "net/http/httptest" + "seanime/internal/util" + "sync" + "testing" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/dop251/goja" + gojabuffer "github.com/dop251/goja_nodejs/buffer" + gojarequire "github.com/dop251/goja_nodejs/require" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFetch_ThreadSafety(t *testing.T) { + // Create a test server that simulates different response times + var serverRequestCount int + var serverMu sync.Mutex + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverMu.Lock() + serverRequestCount++ + currentRequest := serverRequestCount + serverMu.Unlock() + + // Simulate varying response times to increase chance of race conditions + time.Sleep(time.Duration(currentRequest%3) * 50 * time.Millisecond) + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"request": %d}`, currentRequest) + })) + defer server.Close() + + // Create JavaScript test code that makes concurrent fetch calls + jsCode := fmt.Sprintf(` + const url = %q; + const promises = []; + + // Function to make a fetch request and verify response + async function makeFetch(i) { + const response = await fetch(url); + const data = await response.json(); + return { index: i, data }; + } + + // Create multiple concurrent requests + for (let i = 0; i < 50; i++) { + promises.push(makeFetch(i)); + } + + // Wait for all requests to complete + Promise.all(promises) + `, server.URL) + + // Run the code multiple times to increase chance of catching race conditions + for i := 0; i < 5; i++ { + t.Run(fmt.Sprintf("Iteration_%d", i), func(t *testing.T) { + // Create a new VM for each iteration + vm := goja.New() + BindFetch(vm) + + // Execute the JavaScript code + v, err := vm.RunString(jsCode) + assert.NoError(t, err) + + // Get the Promise + promise, ok := v.Export().(*goja.Promise) + assert.True(t, ok) + + // Wait for the Promise to resolve + for promise.State() == goja.PromiseStatePending { + time.Sleep(10 * time.Millisecond) + } + + // Verify the Promise resolved successfully + assert.Equal(t, goja.PromiseStateFulfilled, promise.State()) + + // Verify we got an array of results + results, ok := promise.Result().Export().([]interface{}) + assert.True(t, ok) + assert.Len(t, results, 50) + + // Verify each result has the expected structure + for _, result := range results { + resultMap, ok := result.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, resultMap, "index") + assert.Contains(t, resultMap, "data") + + data, ok := resultMap["data"].(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, data, "request") + } + }) + } +} + +func TestFetch_VMIsolation(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"test": "data"}`) + })) + defer server.Close() + + // Create multiple VMs and make concurrent requests + const numVMs = 5 + const requestsPerVM = 40 + + var wg sync.WaitGroup + for i := 0; i < numVMs; i++ { + wg.Add(1) + go func(vmIndex int) { + defer wg.Done() + + // Create a new VM for this goroutine + vm := goja.New() + BindFetch(vm) + + // Create JavaScript code that makes multiple requests + jsCode := fmt.Sprintf(` + const url = %q; + const promises = []; + + for (let i = 0; i < %d; i++) { + promises.push(fetch(url).then(r => r.json())); + } + + Promise.all(promises) + `, server.URL, requestsPerVM) + + // Execute the code + v, err := vm.RunString(jsCode) + assert.NoError(t, err) + + // Get and wait for the Promise + promise := v.Export().(*goja.Promise) + for promise.State() == goja.PromiseStatePending { + time.Sleep(10 * time.Millisecond) + } + + // Verify the Promise resolved successfully + assert.Equal(t, goja.PromiseStateFulfilled, promise.State()) + + // Verify we got the expected number of results + results := promise.Result().Export().([]interface{}) + assert.Len(t, results, requestsPerVM) + }(i) + } + + wg.Wait() +} + +func TestGojaPromiseAll(t *testing.T) { + vm := goja.New() + + BindFetch(vm) + + registry := new(gojarequire.Registry) + registry.Enable(vm) + gojabuffer.Enable(vm) + BindConsole(vm, util.NewLogger()) + + _, err := vm.RunString(` + async function run() { + const [a, b, c] = await Promise.all([ + fetch("https://jsonplaceholder.typicode.com/todos/1"), + fetch("https://jsonplaceholder.typicode.com/todos/2"), + fetch("https://jsonplaceholder.typicode.com/todos/3"), + fetch("https://jsonplaceholder.typicode.com/todos/4"), + fetch("https://jsonplaceholder.typicode.com/todos/5"), + fetch("https://jsonplaceholder.typicode.com/todos/6"), + fetch("https://jsonplaceholder.typicode.com/todos/7"), + fetch("https://jsonplaceholder.typicode.com/todos/8"), + ]) + + const dataA = await a.json(); + const dataB = await b.json(); + const dataC = await c.json(); + + console.log("Data A:", dataA.title); + console.log("Data B:", dataB); + console.log("Data C:", dataC); + } + `) + require.NoError(t, err) + + runFunc, ok := goja.AssertFunction(vm.Get("run")) + require.True(t, ok) + + ret, err := runFunc(goja.Undefined()) + require.NoError(t, err) + + promise := ret.Export().(*goja.Promise) + + for promise.State() == goja.PromiseStatePending { + time.Sleep(10 * time.Millisecond) + } +} + +func TestGojaFormDataAndFetch(t *testing.T) { + vm := goja.New() + BindFetch(vm) + + registry := new(gojarequire.Registry) + registry.Enable(vm) + gojabuffer.Enable(vm) + BindConsole(vm, util.NewLogger()) + + _, err := vm.RunString(` +async function run() { + const formData = new FormData(); + formData.append("username", "John"); + formData.append("accountnum", 123456); + + console.log(formData.get("username")); // John + + const fData = new URLSearchParams(); + for (const pair of formData.entries()) { + fData.append(pair[0], pair[1]); + } + + const response = await fetch('https://httpbin.org/post', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData + }); + + const data = await response.json(); + console.log(data); + + console.log("Echoed GojaFormData content:"); + if (data.form) { + for (const key in data.form) { + console.log(key, data.form[key]); + } + } else { + console.log("No form data echoed in the response."); + } + + return data; +} + `) + require.NoError(t, err) + + runFunc, ok := goja.AssertFunction(vm.Get("run")) + require.True(t, ok) + + ret, err := runFunc(goja.Undefined()) + require.NoError(t, err) + + promise := ret.Export().(*goja.Promise) + + for promise.State() == goja.PromiseStatePending { + time.Sleep(10 * time.Millisecond) + } + + if promise.State() == goja.PromiseStateFulfilled { + spew.Dump(promise.Result()) + } else { + err := promise.Result() + spew.Dump(err) + } +} + +func TestGojaFetchPostJSON(t *testing.T) { + vm := goja.New() + + BindFetch(vm) + + registry := new(gojarequire.Registry) + registry.Enable(vm) + gojabuffer.Enable(vm) + BindConsole(vm, util.NewLogger()) + + _, err := vm.RunString(` +async function run() { + const response = await fetch('https://httpbin.org/post', { + method: 'POST', + body: { name: "John Doe", age: 30 }, + }); + + const data = await response.json(); + console.log(data); + + console.log("Echoed content:"); + if (data.json) { + for (const key in data.json) { + console.log(key, data.json[key]); + } + } else { + console.log("No form data echoed in the response."); + } + + return data; +} + `) + require.NoError(t, err) + + runFunc, ok := goja.AssertFunction(vm.Get("run")) + require.True(t, ok) + + ret, err := runFunc(goja.Undefined()) + require.NoError(t, err) + + promise := ret.Export().(*goja.Promise) + + for promise.State() == goja.PromiseStatePending { + time.Sleep(10 * time.Millisecond) + } + + if promise.State() == goja.PromiseStateFulfilled { + spew.Dump(promise.Result()) + } else { + err := promise.Result() + spew.Dump(err) + } +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/fieldmapper.go b/seanime-2.9.10/internal/goja/goja_bindings/fieldmapper.go new file mode 100644 index 0000000..c5ba10b --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/fieldmapper.go @@ -0,0 +1,67 @@ +package goja_bindings + +import ( + "reflect" + "strings" + "unicode" + + "github.com/dop251/goja" +) + +var ( + _ goja.FieldNameMapper = (*DefaultFieldMapper)(nil) +) + +// DefaultFieldMapper provides custom mapping between Go and JavaScript methods names. +// +// It is similar to the builtin "uncapFieldNameMapper" but also converts +// all uppercase identifiers to their lowercase equivalent (eg. "GET" -> "get"). +type DefaultFieldMapper struct { +} + +// FieldName implements the [FieldNameMapper.FieldName] interface method. +func (u DefaultFieldMapper) FieldName(_ reflect.Type, f reflect.StructField) string { + return f.Name +} + +// MethodName implements the [FieldNameMapper.MethodName] interface method. +func (u DefaultFieldMapper) MethodName(_ reflect.Type, m reflect.Method) string { + return convertGoToJSName(m.Name) +} + +var nameExceptions = map[string]string{"OAuth2": "oauth2"} + +func convertGoToJSName(name string) string { + if v, ok := nameExceptions[name]; ok { + return v + } + + startUppercase := make([]rune, 0, len(name)) + + for _, c := range name { + if c != '_' && !unicode.IsUpper(c) && !unicode.IsDigit(c) { + break + } + + startUppercase = append(startUppercase, c) + } + + totalStartUppercase := len(startUppercase) + + // all uppercase eg. "JSON" -> "json" + if len(name) == totalStartUppercase { + return strings.ToLower(name) + } + + // eg. "JSONField" -> "jsonField" + if totalStartUppercase > 1 { + return strings.ToLower(name[0:totalStartUppercase-1]) + name[totalStartUppercase-1:] + } + + // eg. "GetField" -> "getField" + if totalStartUppercase == 1 { + return strings.ToLower(name[0:1]) + name[1:] + } + + return name +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/formdata.go b/seanime-2.9.10/internal/goja/goja_bindings/formdata.go new file mode 100644 index 0000000..493517d --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/formdata.go @@ -0,0 +1,231 @@ +package goja_bindings + +import ( + "bytes" + "io" + "mime/multipart" + "strconv" + "strings" + + "github.com/dop251/goja" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// formData +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func BindFormData(vm *goja.Runtime) error { + err := vm.Set("FormData", func(call goja.ConstructorCall) *goja.Object { + fd := newFormData(vm) + + instanceValue := vm.ToValue(fd).(*goja.Object) + instanceValue.SetPrototype(call.This.Prototype()) + + return instanceValue + }) + if err != nil { + return err + } + return nil +} + +type formData struct { + runtime *goja.Runtime + buf *bytes.Buffer + writer *multipart.Writer + fieldNames map[string]struct{} + values map[string][]string + closed bool +} + +func newFormData(runtime *goja.Runtime) *formData { + buf := &bytes.Buffer{} + writer := multipart.NewWriter(buf) + return &formData{ + runtime: runtime, + buf: buf, + writer: writer, + fieldNames: make(map[string]struct{}), + values: make(map[string][]string), + closed: false, + } +} + +func (fd *formData) Append(call goja.FunctionCall) goja.Value { + if fd.closed { + return fd.runtime.ToValue("cannot append to closed FormData") + } + + fieldName := call.Argument(0).String() + value := call.Argument(1).String() + + fieldName = strings.TrimSpace(fieldName) + fd.values[fieldName] = append(fd.values[fieldName], value) + + if _, exists := fd.fieldNames[fieldName]; !exists { + fd.fieldNames[fieldName] = struct{}{} + writer, err := fd.writer.CreateFormField(fieldName) + if err != nil { + return fd.runtime.ToValue(err.Error()) + } + _, err = writer.Write([]byte(value)) + if err != nil { + return fd.runtime.ToValue(err.Error()) + } + } + + return goja.Undefined() +} + +func (fd *formData) Delete(call goja.FunctionCall) goja.Value { + if fd.closed { + return fd.runtime.ToValue("cannot delete from closed FormData") + } + + fieldName := call.Argument(0).String() + fieldName = strings.TrimSpace(fieldName) + + delete(fd.fieldNames, fieldName) + delete(fd.values, fieldName) + + return goja.Undefined() +} + +func (fd *formData) Entries(call goja.FunctionCall) goja.Value { + if fd.closed { + return fd.runtime.ToValue("cannot get entries from closed FormData") + } + + iter := fd.runtime.NewArray() + index := 0 + for key, values := range fd.values { + for _, value := range values { + entry := fd.runtime.NewObject() + entry.Set("0", key) + entry.Set("1", value) + iter.Set(strconv.Itoa(index), entry) + index++ + } + } + + return iter +} + +func (fd *formData) Get(call goja.FunctionCall) goja.Value { + if fd.closed { + return fd.runtime.ToValue("cannot get value from closed FormData") + } + + fieldName := call.Argument(0).String() + fieldName = strings.TrimSpace(fieldName) + + if values, exists := fd.values[fieldName]; exists && len(values) > 0 { + return fd.runtime.ToValue(values[0]) + } + + return goja.Undefined() +} + +func (fd *formData) GetAll(call goja.FunctionCall) goja.Value { + if fd.closed { + return fd.runtime.ToValue("cannot get all values from closed FormData") + } + + fieldName := call.Argument(0).String() + fieldName = strings.TrimSpace(fieldName) + + iter := fd.runtime.NewArray() + if values, exists := fd.values[fieldName]; exists { + for i, value := range values { + iter.Set(strconv.Itoa(i), value) + } + } + + return iter +} + +func (fd *formData) Has(call goja.FunctionCall) goja.Value { + if fd.closed { + return fd.runtime.ToValue("cannot check key in closed FormData") + } + + fieldName := call.Argument(0).String() + fieldName = strings.TrimSpace(fieldName) + + _, exists := fd.fieldNames[fieldName] + return fd.runtime.ToValue(exists) +} + +func (fd *formData) Keys(call goja.FunctionCall) goja.Value { + if fd.closed { + return fd.runtime.ToValue("cannot get keys from closed FormData") + } + + iter := fd.runtime.NewArray() + index := 0 + for key := range fd.fieldNames { + iter.Set(strconv.Itoa(index), key) + index++ + } + + return iter +} + +func (fd *formData) Set(call goja.FunctionCall) goja.Value { + if fd.closed { + return fd.runtime.ToValue("cannot set value in closed FormData") + } + + fieldName := call.Argument(0).String() + value := call.Argument(1).String() + + fieldName = strings.TrimSpace(fieldName) + fd.values[fieldName] = []string{value} + + if _, exists := fd.fieldNames[fieldName]; !exists { + fd.fieldNames[fieldName] = struct{}{} + writer, err := fd.writer.CreateFormField(fieldName) + if err != nil { + return fd.runtime.ToValue(err.Error()) + } + _, err = writer.Write([]byte(value)) + if err != nil { + return fd.runtime.ToValue(err.Error()) + } + } + + return goja.Undefined() +} + +func (fd *formData) Values(call goja.FunctionCall) goja.Value { + if fd.closed { + return fd.runtime.ToValue("cannot get values from closed FormData") + } + + iter := fd.runtime.NewArray() + index := 0 + for _, values := range fd.values { + for _, value := range values { + iter.Set(strconv.Itoa(index), value) + index++ + } + } + + return iter +} + +func (fd *formData) GetContentType() goja.Value { + if !fd.closed { + fd.writer.Close() + fd.closed = true + } + return fd.runtime.ToValue(fd.writer.FormDataContentType()) +} + +func (fd *formData) GetBuffer() (io.Reader, *multipart.Writer) { + if !fd.closed { + fd.writer.Close() + fd.closed = true + } + return bytes.NewReader(fd.buf.Bytes()), fd.writer +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/formdata_test.go b/seanime-2.9.10/internal/goja/goja_bindings/formdata_test.go new file mode 100644 index 0000000..021e19a --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/formdata_test.go @@ -0,0 +1,49 @@ +package goja_bindings + +import ( + "seanime/internal/util" + "testing" + + "github.com/dop251/goja" + gojabuffer "github.com/dop251/goja_nodejs/buffer" + gojarequire "github.com/dop251/goja_nodejs/require" + "github.com/stretchr/testify/require" +) + +func TestGojaFormData(t *testing.T) { + vm := goja.New() + defer vm.ClearInterrupt() + + BindFormData(vm) + + registry := new(gojarequire.Registry) + registry.Enable(vm) + gojabuffer.Enable(vm) + BindConsole(vm, util.NewLogger()) + + _, err := vm.RunString(` +var fd = new FormData(); +fd.append("name", "John Doe"); +fd.append("age", 30); + +console.log("Has 'name':", fd.has("name")); // true +console.log("Get 'name':", fd.get("name")); // John Doe +console.log("GetAll 'name':", fd.getAll("name")); // ["John Doe"] +console.log("Keys:", Array.from(fd.keys())); // ["name", "age"] +console.log("Values:", Array.from(fd.values())); // ["John Doe", 30] + +fd.delete("name"); +console.log("Has 'name' after delete:", fd.has("name")); // false + +console.log("Entries:"); +for (let entry of fd.entries()) { + console.log(entry[0], entry[1]); +} + +var contentType = fd.getContentType(); +var buffer = fd.getBuffer(); +console.log("Content-Type:", contentType); +console.log("Buffer:", buffer); + `) + require.NoError(t, err) +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/js/test/doc-example-2.ts b/seanime-2.9.10/internal/goja/goja_bindings/js/test/doc-example-2.ts new file mode 100644 index 0000000..a5d7f52 --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/js/test/doc-example-2.ts @@ -0,0 +1,16 @@ +/// + +class Provider { + async test() { + try { + const data = await fetch("https://cryptojs.gitbook.io/docs") + + const $ = LoadDoc(await data.text()) + + console.log($("header h1").text()) + } + catch (e) { + console.error(e) + } + } +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/js/test/doc-example.ts b/seanime-2.9.10/internal/goja/goja_bindings/js/test/doc-example.ts new file mode 100644 index 0000000..e0ecbfc --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/js/test/doc-example.ts @@ -0,0 +1,82 @@ +/// + +class Provider { + async test() { + + const html = ` + + + + + + Test Document + + + +
+

Main Title

+ +
+
+
+

First Post

+

This is the first post.

+ Read more +
+
+

Second Post

+

This is the second post.

+ Read more +
+
+

Third Post

+

This is the third post.

+ Read more +
+
+ +
+

© 2024 Example Company. All rights reserved.

+

Contact Us

+
+ +` + + const $ = new Doc(html) + + console.log("Document created") + + console.log(">>> Last post by string selector") + const article = $.find("article:last-child") + console.log(article.html()) + + console.log(">>> Post titles (map to string)") + + const titles = $.find("section") + .children("article.post") + .filter((i, e) => { + return e.attr("data-id") !== "1" + }) + .map((i, e) => { + return e.children("h2").text() + }) + + console.log(titles) + + console.log(">>> END") + + } +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/torrent.go b/seanime-2.9.10/internal/goja/goja_bindings/torrent.go new file mode 100644 index 0000000..46d03e1 --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/torrent.go @@ -0,0 +1,46 @@ +package goja_bindings + +import ( + "seanime/internal/torrents/torrent" + + "github.com/dop251/goja" +) + +func BindTorrentUtils(vm *goja.Runtime) error { + torrentUtils := vm.NewObject() + torrentUtils.Set("getMagnetLinkFromTorrentData", getMagnetLinkFromTorrentDataFunc(vm)) + vm.Set("$torrentUtils", torrentUtils) + + return nil +} + +func getMagnetLinkFromTorrentDataFunc(vm *goja.Runtime) (ret func(c goja.FunctionCall) goja.Value) { + defer func() { + if r := recover(); r != nil { + } + }() + + return func(call goja.FunctionCall) goja.Value { + defer func() { + if r := recover(); r != nil { + panic(vm.ToValue("selection is nil")) + } + }() + + if len(call.Arguments) < 1 { + panic(vm.ToValue("TypeError: getMagnetLinkFromTorrentData requires at least 1 argument")) + } + + str, ok := call.Argument(0).Export().(string) + if !ok { + panic(vm.ToValue(vm.NewTypeError("argument is not a string"))) + } + + magnet, err := torrent.StrDataToMagnetLink(str) + if err != nil { + return vm.ToValue("") + } + + return vm.ToValue(magnet) + } +} diff --git a/seanime-2.9.10/internal/goja/goja_bindings/torrent_test.go b/seanime-2.9.10/internal/goja/goja_bindings/torrent_test.go new file mode 100644 index 0000000..257440c --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_bindings/torrent_test.go @@ -0,0 +1,61 @@ +package goja_bindings + +import ( + "seanime/internal/util" + "testing" + "time" + + "github.com/dop251/goja" + gojabuffer "github.com/dop251/goja_nodejs/buffer" + gojarequire "github.com/dop251/goja_nodejs/require" + "github.com/stretchr/testify/require" +) + +func TestGojaTorrentUtils(t *testing.T) { + vm := goja.New() + + registry := new(gojarequire.Registry) + registry.Enable(vm) + gojabuffer.Enable(vm) + BindTorrentUtils(vm) + BindConsole(vm, util.NewLogger()) + BindFetch(vm) + + _, err := vm.RunString(` +async function run() { + try { + + console.log("\nTesting torrent file to magnet link") + + const url = "https://animetosho.org/storage/torrent/da9aad67b6f8bb82757bb3ef95235b42624c34f7/%5BSubsPlease%5D%20Make%20Heroine%20ga%20Oosugiru%21%20-%2011%20%281080p%29%20%5B58B3496A%5D.torrent" + + const data = await (await fetch(url)).text() + + const magnetLink = getMagnetLinkFromTorrentData(data) + + console.log("Magnet link:", magnetLink) + } + catch (e) { + console.error(e) + } +} +`) + require.NoError(t, err) + + runFunc, ok := goja.AssertFunction(vm.Get("run")) + require.True(t, ok) + + ret, err := runFunc(goja.Undefined()) + require.NoError(t, err) + + promise := ret.Export().(*goja.Promise) + + for promise.State() == goja.PromiseStatePending { + time.Sleep(10 * time.Millisecond) + } + + if promise.State() == goja.PromiseStateRejected { + err := promise.Result() + t.Fatal(err) + } +} diff --git a/seanime-2.9.10/internal/goja/goja_runtime/goja_runtime_manager.go b/seanime-2.9.10/internal/goja/goja_runtime/goja_runtime_manager.go new file mode 100644 index 0000000..2c96824 --- /dev/null +++ b/seanime-2.9.10/internal/goja/goja_runtime/goja_runtime_manager.go @@ -0,0 +1,229 @@ +package goja_runtime + +import ( + "context" + "fmt" + "runtime" + "seanime/internal/util/result" + "sync" + "sync/atomic" + + "github.com/dop251/goja" + "github.com/rs/zerolog" +) + +// Manager manages a shared pool of Goja runtimes for all extensions. +type Manager struct { + pluginPools *result.Map[string, *Pool] + basePool *Pool + logger *zerolog.Logger +} + +type Pool struct { + sp sync.Pool + factory func() *goja.Runtime + logger *zerolog.Logger + size int32 + metrics metrics +} + +// metrics holds counters for pool stats. +type metrics struct { + prewarmed atomic.Int64 + created atomic.Int64 + reused atomic.Int64 + timeouts atomic.Int64 + invocations atomic.Int64 +} + +func NewManager(logger *zerolog.Logger) *Manager { + return &Manager{ + logger: logger, + } +} + +// GetOrCreatePrivatePool returns the pool for the given extension. +func (m *Manager) GetOrCreatePrivatePool(extID string, initFn func() *goja.Runtime) (*Pool, error) { + if m.pluginPools == nil { + m.pluginPools = result.NewResultMap[string, *Pool]() + } + + pool, ok := m.pluginPools.Get(extID) + if !ok { + pool = newPool(5, initFn, m.logger) + m.pluginPools.Set(extID, pool) + } + return pool, nil +} + +func (m *Manager) DeletePluginPool(extID string) { + m.logger.Trace().Msgf("plugin: Deleting pool for extension %s", extID) + if m.pluginPools == nil { + return + } + + // Get the pool first to interrupt all runtimes + if pool, ok := m.pluginPools.Get(extID); ok { + // Drain the pool and interrupt all runtimes + m.logger.Debug().Msgf("plugin: Interrupting all runtimes in pool for extension %s", extID) + + interruptedCount := 0 + for { + // Get a runtime without using a context to avoid blocking + runtimeV := pool.sp.Get() + if runtimeV == nil { + break // No more runtimes in the pool or error occurred + } + + runtime, ok := runtimeV.(*goja.Runtime) + if !ok { + break + } + + // Interrupt the runtime + runtime.ClearInterrupt() + interruptedCount++ + } + + m.logger.Debug().Msgf("plugin: Interrupted %d runtimes in pool for extension %s", interruptedCount, extID) + } + + // Delete the pool + m.pluginPools.Delete(extID) + runtime.GC() +} + +// GetOrCreateSharedPool returns the shared pool. +func (m *Manager) GetOrCreateSharedPool(initFn func() *goja.Runtime) (*Pool, error) { + if m.basePool == nil { + m.basePool = newPool(15, initFn, m.logger) + } + return m.basePool, nil +} + +func (m *Manager) Run(ctx context.Context, extID string, fn func(*goja.Runtime) error) error { + pool, ok := m.pluginPools.Get(extID) + if !ok { + return fmt.Errorf("plugin pool not found for extension ID: %s", extID) + } + runtime, err := pool.Get(ctx) + pool.metrics.invocations.Add(1) + if err != nil { + return err + } + defer pool.Put(runtime) + return fn(runtime) +} + +func (m *Manager) RunShared(ctx context.Context, fn func(*goja.Runtime) error) error { + runtime, err := m.basePool.Get(ctx) + if err != nil { + return err + } + defer m.basePool.Put(runtime) + return fn(runtime) +} + +func (m *Manager) GetLogger() *zerolog.Logger { + return m.logger +} + +func (m *Manager) PrintPluginPoolMetrics(extID string) { + if m.pluginPools == nil { + return + } + pool, ok := m.pluginPools.Get(extID) + if !ok { + return + } + stats := pool.Stats() + m.logger.Trace(). + Int64("prewarmed", stats["prewarmed"]). + Int64("created", stats["created"]). + Int64("reused", stats["reused"]). + Int64("timeouts", stats["timeouts"]). + Int64("invocations", stats["invocations"]). + Msg("goja runtime: VM Pool Metrics") +} + +func (m *Manager) PrintBasePoolMetrics() { + if m.basePool == nil { + return + } + stats := m.basePool.Stats() + m.logger.Trace(). + Int64("prewarmed", stats["prewarmed"]). + Int64("created", stats["created"]). + Int64("reused", stats["reused"]). + Int64("invocations", stats["invocations"]). + Int64("timeouts", stats["timeouts"]). + Msg("goja runtime: Base VM Pool Metrics") +} + +// newPool creates a new Pool using sync.Pool, pre-warming it with size items. +func newPool(size int32, initFn func() *goja.Runtime, logger *zerolog.Logger) *Pool { + p := &Pool{ + factory: initFn, + logger: logger, + size: size, + } + + // p.sp.New = func() interface{} { + // runtime := initFn() + // p.metrics.created.Add(1) + // return runtime + // } + + p.sp.New = func() any { + return nil + } + + // Pre-warm the pool + logger.Trace().Int32("size", size).Msg("goja runtime: Pre-warming pool") + for i := int32(0); i < size; i++ { + r := initFn() + p.sp.Put(r) + p.metrics.prewarmed.Add(1) + } + + return p +} + +// Get retrieves a runtime from the pool or creates a new one. It respects the context for cancellation. +func (p *Pool) Get(ctx context.Context) (*goja.Runtime, error) { + v := p.sp.Get() + if v == nil { + // If sync.Pool.New returned nil or context canceled, try factory manually. + select { + case <-ctx.Done(): + p.metrics.timeouts.Add(1) + return nil, ctx.Err() + default: + } + runtime := p.factory() + p.metrics.created.Add(1) + return runtime, nil + } + p.metrics.reused.Add(1) + return v.(*goja.Runtime), nil +} + +// Put returns a runtime to the pool after clearing its interrupt. +func (p *Pool) Put(runtime *goja.Runtime) { + if runtime == nil { + return + } + runtime.ClearInterrupt() + p.sp.Put(runtime) +} + +// Stats returns pool metrics as a map. +func (p *Pool) Stats() map[string]int64 { + return map[string]int64{ + "prewarmed": p.metrics.prewarmed.Load(), + "invocations": p.metrics.invocations.Load(), + "created": p.metrics.created.Load(), + "reused": p.metrics.reused.Load(), + "timeouts": p.metrics.timeouts.Load(), + } +} diff --git a/seanime-2.9.10/internal/handlers/anilist.go b/seanime-2.9.10/internal/handlers/anilist.go new file mode 100644 index 0000000..7026def --- /dev/null +++ b/seanime-2.9.10/internal/handlers/anilist.go @@ -0,0 +1,470 @@ +package handlers + +import ( + "errors" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/util/result" + "strconv" + "time" + + "github.com/labstack/echo/v4" +) + +// HandleGetAnimeCollection +// +// @summary returns the user's AniList anime collection. +// @desc Calling GET will return the cached anime collection. +// @desc The manga collection is also refreshed in the background, and upon completion, a WebSocket event is sent. +// @desc Calling POST will refetch both the anime and manga collections. +// @returns anilist.AnimeCollection +// @route /api/v1/anilist/collection [GET,POST] +func (h *Handler) HandleGetAnimeCollection(c echo.Context) error { + + bypassCache := c.Request().Method == "POST" + + if !bypassCache { + // Get the user's anilist collection + animeCollection, err := h.App.GetAnimeCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + return h.RespondWithData(c, animeCollection) + } + + animeCollection, err := h.App.RefreshAnimeCollection() + if err != nil { + return h.RespondWithError(c, err) + } + + go func() { + if h.App.Settings != nil && h.App.Settings.GetLibrary().EnableManga { + _, _ = h.App.RefreshMangaCollection() + } + }() + + return h.RespondWithData(c, animeCollection) +} + +// HandleGetRawAnimeCollection +// +// @summary returns the user's AniList anime collection without filtering out custom lists. +// @desc Calling GET will return the cached anime collection. +// @returns anilist.AnimeCollection +// @route /api/v1/anilist/collection/raw [GET,POST] +func (h *Handler) HandleGetRawAnimeCollection(c echo.Context) error { + + bypassCache := c.Request().Method == "POST" + + // Get the user's anilist collection + animeCollection, err := h.App.GetRawAnimeCollection(bypassCache) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, animeCollection) +} + +// HandleEditAnilistListEntry +// +// @summary updates the user's list entry on Anilist. +// @desc This is used to edit an entry on AniList. +// @desc The "type" field is used to determine if the entry is an anime or manga and refreshes the collection accordingly. +// @desc The client should refetch collection-dependent queries after this mutation. +// @returns true +// @route /api/v1/anilist/list-entry [POST] +func (h *Handler) HandleEditAnilistListEntry(c echo.Context) error { + + type body struct { + MediaId *int `json:"mediaId"` + Status *anilist.MediaListStatus `json:"status"` + Score *int `json:"score"` + Progress *int `json:"progress"` + StartDate *anilist.FuzzyDateInput `json:"startedAt"` + EndDate *anilist.FuzzyDateInput `json:"completedAt"` + Type string `json:"type"` + } + + p := new(body) + if err := c.Bind(p); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.AnilistPlatform.UpdateEntry( + c.Request().Context(), + *p.MediaId, + p.Status, + p.Score, + p.Progress, + p.StartDate, + p.EndDate, + ) + if err != nil { + return h.RespondWithError(c, err) + } + + switch p.Type { + case "anime": + _, _ = h.App.RefreshAnimeCollection() + case "manga": + _, _ = h.App.RefreshMangaCollection() + default: + _, _ = h.App.RefreshAnimeCollection() + _, _ = h.App.RefreshMangaCollection() + } + + return h.RespondWithData(c, true) +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +var ( + detailsCache = result.NewCache[int, *anilist.AnimeDetailsById_Media]() +) + +// HandleGetAnilistAnimeDetails +// +// @summary returns more details about an AniList anime entry. +// @desc This fetches more fields omitted from the base queries. +// @param id - int - true - "The AniList anime ID" +// @returns anilist.AnimeDetailsById_Media +// @route /api/v1/anilist/media-details/{id} [GET] +func (h *Handler) HandleGetAnilistAnimeDetails(c echo.Context) error { + + mId, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, err) + } + + if details, ok := detailsCache.Get(mId); ok { + return h.RespondWithData(c, details) + } + details, err := h.App.AnilistPlatform.GetAnimeDetails(c.Request().Context(), mId) + if err != nil { + return h.RespondWithError(c, err) + } + detailsCache.Set(mId, details) + + return h.RespondWithData(c, details) +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +var studioDetailsMap = result.NewResultMap[int, *anilist.StudioDetails]() + +// HandleGetAnilistStudioDetails +// +// @summary returns details about a studio. +// @desc This fetches media produced by the studio. +// @param id - int - true - "The AniList studio ID" +// @returns anilist.StudioDetails +// @route /api/v1/anilist/studio-details/{id} [GET] +func (h *Handler) HandleGetAnilistStudioDetails(c echo.Context) error { + + mId, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, err) + } + + if details, ok := studioDetailsMap.Get(mId); ok { + return h.RespondWithData(c, details) + } + details, err := h.App.AnilistPlatform.GetStudioDetails(c.Request().Context(), mId) + if err != nil { + return h.RespondWithError(c, err) + } + + go func() { + if details != nil { + studioDetailsMap.Set(mId, details) + } + }() + + return h.RespondWithData(c, details) +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +// HandleDeleteAnilistListEntry +// +// @summary deletes an entry from the user's AniList list. +// @desc This is used to delete an entry on AniList. +// @desc The "type" field is used to determine if the entry is an anime or manga and refreshes the collection accordingly. +// @desc The client should refetch collection-dependent queries after this mutation. +// @route /api/v1/anilist/list-entry [DELETE] +// @returns bool +func (h *Handler) HandleDeleteAnilistListEntry(c echo.Context) error { + + type body struct { + MediaId *int `json:"mediaId"` + Type *string `json:"type"` + } + + p := new(body) + if err := c.Bind(p); err != nil { + return h.RespondWithError(c, err) + } + + if p.Type == nil || p.MediaId == nil { + return h.RespondWithError(c, errors.New("missing parameters")) + } + + var listEntryID int + + switch *p.Type { + case "anime": + // Get the list entry ID + animeCollection, err := h.App.GetAnimeCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + listEntry, found := animeCollection.GetListEntryFromAnimeId(*p.MediaId) + if !found { + return h.RespondWithError(c, errors.New("list entry not found")) + } + listEntryID = listEntry.ID + case "manga": + // Get the list entry ID + mangaCollection, err := h.App.GetMangaCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + listEntry, found := mangaCollection.GetListEntryFromMangaId(*p.MediaId) + if !found { + return h.RespondWithError(c, errors.New("list entry not found")) + } + listEntryID = listEntry.ID + } + + // Delete the list entry + err := h.App.AnilistPlatform.DeleteEntry(c.Request().Context(), listEntryID) + if err != nil { + return h.RespondWithError(c, err) + } + + switch *p.Type { + case "anime": + _, _ = h.App.RefreshAnimeCollection() + case "manga": + _, _ = h.App.RefreshMangaCollection() + } + + return h.RespondWithData(c, true) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var ( + anilistListAnimeCache = result.NewCache[string, *anilist.ListAnime]() + anilistListRecentAnimeCache = result.NewCache[string, *anilist.ListRecentAnime]() // holds 1 value +) + +// HandleAnilistListAnime +// +// @summary returns a list of anime based on the search parameters. +// @desc This is used by the "Discover" and "Advanced Search". +// @route /api/v1/anilist/list-anime [POST] +// @returns anilist.ListAnime +func (h *Handler) HandleAnilistListAnime(c echo.Context) error { + + type body struct { + Page *int `json:"page,omitempty"` + Search *string `json:"search,omitempty"` + PerPage *int `json:"perPage,omitempty"` + Sort []*anilist.MediaSort `json:"sort,omitempty"` + Status []*anilist.MediaStatus `json:"status,omitempty"` + Genres []*string `json:"genres,omitempty"` + AverageScoreGreater *int `json:"averageScore_greater,omitempty"` + Season *anilist.MediaSeason `json:"season,omitempty"` + SeasonYear *int `json:"seasonYear,omitempty"` + Format *anilist.MediaFormat `json:"format,omitempty"` + IsAdult *bool `json:"isAdult,omitempty"` + } + + p := new(body) + if err := c.Bind(p); err != nil { + return h.RespondWithError(c, err) + } + + if p.Page == nil || p.PerPage == nil { + *p.Page = 1 + *p.PerPage = 20 + } + + isAdult := false + if p.IsAdult != nil { + isAdult = *p.IsAdult && h.App.Settings.GetAnilist().EnableAdultContent + } + + cacheKey := anilist.ListAnimeCacheKey( + p.Page, + p.Search, + p.PerPage, + p.Sort, + p.Status, + p.Genres, + p.AverageScoreGreater, + p.Season, + p.SeasonYear, + p.Format, + &isAdult, + ) + + cached, ok := anilistListAnimeCache.Get(cacheKey) + if ok { + return h.RespondWithData(c, cached) + } + + ret, err := anilist.ListAnimeM( + p.Page, + p.Search, + p.PerPage, + p.Sort, + p.Status, + p.Genres, + p.AverageScoreGreater, + p.Season, + p.SeasonYear, + p.Format, + &isAdult, + h.App.Logger, + h.App.GetUserAnilistToken(), + ) + if err != nil { + return h.RespondWithError(c, err) + } + + if ret != nil { + anilistListAnimeCache.SetT(cacheKey, ret, time.Minute*10) + } + + return h.RespondWithData(c, ret) +} + +// HandleAnilistListRecentAiringAnime +// +// @summary returns a list of recently aired anime. +// @desc This is used by the "Schedule" page to display recently aired anime. +// @route /api/v1/anilist/list-recent-anime [POST] +// @returns anilist.ListRecentAnime +func (h *Handler) HandleAnilistListRecentAiringAnime(c echo.Context) error { + + type body struct { + Page *int `json:"page,omitempty"` + Search *string `json:"search,omitempty"` + PerPage *int `json:"perPage,omitempty"` + AiringAtGreater *int `json:"airingAt_greater,omitempty"` + AiringAtLesser *int `json:"airingAt_lesser,omitempty"` + NotYetAired *bool `json:"notYetAired,omitempty"` + Sort []*anilist.AiringSort `json:"sort,omitempty"` + } + + p := new(body) + if err := c.Bind(p); err != nil { + return h.RespondWithError(c, err) + } + + if p.Page == nil || p.PerPage == nil { + *p.Page = 1 + *p.PerPage = 50 + } + + cacheKey := fmt.Sprintf("%v-%v-%v-%v-%v-%v", p.Page, p.Search, p.PerPage, p.AiringAtGreater, p.AiringAtLesser, p.NotYetAired) + + cached, ok := anilistListRecentAnimeCache.Get(cacheKey) + if ok { + return h.RespondWithData(c, cached) + } + + ret, err := anilist.ListRecentAiringAnimeM( + p.Page, + p.Search, + p.PerPage, + p.AiringAtGreater, + p.AiringAtLesser, + p.NotYetAired, + p.Sort, + h.App.Logger, + h.App.GetUserAnilistToken(), + ) + if err != nil { + return h.RespondWithError(c, err) + } + + anilistListRecentAnimeCache.SetT(cacheKey, ret, time.Hour*1) + + return h.RespondWithData(c, ret) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var anilistMissedSequelsCache = result.NewCache[int, []*anilist.BaseAnime]() + +// HandleAnilistListMissedSequels +// +// @summary returns a list of sequels not in the user's list. +// @desc This is used by the "Discover" page to display sequels the user may have missed. +// @route /api/v1/anilist/list-missed-sequels [GET] +// @returns []anilist.BaseAnime +func (h *Handler) HandleAnilistListMissedSequels(c echo.Context) error { + + cached, ok := anilistMissedSequelsCache.Get(1) + if ok { + return h.RespondWithData(c, cached) + } + + // Get complete anime collection + animeCollection, err := h.App.AnilistPlatform.GetAnimeCollectionWithRelations(c.Request().Context()) + if err != nil { + return h.RespondWithError(c, err) + } + + ret, err := anilist.ListMissedSequels( + animeCollection, + h.App.Logger, + h.App.GetUserAnilistToken(), + ) + if err != nil { + return h.RespondWithError(c, err) + } + + anilistMissedSequelsCache.SetT(1, ret, time.Hour*4) + + return h.RespondWithData(c, ret) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var anilistStatsCache = result.NewCache[int, *anilist.Stats]() + +// HandleGetAniListStats +// +// @summary returns the anilist stats. +// @desc This returns the AniList stats for the user. +// @route /api/v1/anilist/stats [GET] +// @returns anilist.Stats +func (h *Handler) HandleGetAniListStats(c echo.Context) error { + cached, ok := anilistStatsCache.Get(0) + if ok { + return h.RespondWithData(c, cached) + } + + stats, err := h.App.AnilistPlatform.GetViewerStats(c.Request().Context()) + if err != nil { + return h.RespondWithError(c, err) + } + + ret, err := anilist.GetStats( + c.Request().Context(), + stats, + ) + if err != nil { + return h.RespondWithError(c, err) + } + + anilistStatsCache.SetT(0, ret, time.Hour*1) + + return h.RespondWithData(c, ret) +} diff --git a/seanime-2.9.10/internal/handlers/anime.go b/seanime-2.9.10/internal/handlers/anime.go new file mode 100644 index 0000000..f642036 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/anime.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "seanime/internal/library/anime" + "strconv" + + "github.com/labstack/echo/v4" +) + +// HandleGetAnimeEpisodeCollection +// +// @summary gets list of main episodes +// @desc This returns a list of main episodes for the given AniList anime media id. +// @desc It also loads the episode list into the different modules. +// @returns anime.EpisodeCollection +// @param id - int - true - "AniList anime media ID" +// @route /api/v1/anime/episode-collection/{id} [GET] +func (h *Handler) HandleGetAnimeEpisodeCollection(c echo.Context) error { + mId, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.AddOnRefreshAnilistCollectionFunc("HandleGetAnimeEpisodeCollection", func() { + anime.ClearEpisodeCollectionCache() + }) + + completeAnime, animeMetadata, err := h.App.TorrentstreamRepository.GetMediaInfo(c.Request().Context(), mId) + if err != nil { + return h.RespondWithError(c, err) + } + + ec, err := anime.NewEpisodeCollection(anime.NewEpisodeCollectionOptions{ + AnimeMetadata: animeMetadata, + Media: completeAnime.ToBaseAnime(), + MetadataProvider: h.App.MetadataProvider, + Logger: h.App.Logger, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.FillerManager.HydrateEpisodeFillerData(mId, ec.Episodes) + + return h.RespondWithData(c, ec) +} diff --git a/seanime-2.9.10/internal/handlers/anime_collection.go b/seanime-2.9.10/internal/handlers/anime_collection.go new file mode 100644 index 0000000..637ab40 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/anime_collection.go @@ -0,0 +1,212 @@ +package handlers + +import ( + "errors" + "seanime/internal/api/anilist" + "seanime/internal/database/db_bridge" + "seanime/internal/library/anime" + "seanime/internal/torrentstream" + "seanime/internal/util" + "seanime/internal/util/result" + "time" + + "github.com/labstack/echo/v4" +) + +// HandleGetLibraryCollection +// +// @summary returns the main local anime collection. +// @desc This creates a new LibraryCollection struct and returns it. +// @desc This is used to get the main anime collection of the user. +// @desc It uses the cached Anilist anime collection for the GET method. +// @desc It refreshes the AniList anime collection if the POST method is used. +// @route /api/v1/library/collection [GET,POST] +// @returns anime.LibraryCollection +func (h *Handler) HandleGetLibraryCollection(c echo.Context) error { + + animeCollection, err := h.App.GetAnimeCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + if animeCollection == nil { + return h.RespondWithData(c, &anime.LibraryCollection{}) + } + + originalAnimeCollection := animeCollection + + var lfs []*anime.LocalFile + nakamaLibrary, fromNakama := h.App.NakamaManager.GetHostAnimeLibrary() + if fromNakama { + // Save the original anime collection to restore it later + originalAnimeCollection = animeCollection.Copy() + lfs = nakamaLibrary.LocalFiles + // Merge missing media entries into the collection + currentMediaIds := make(map[int]struct{}) + for _, list := range animeCollection.MediaListCollection.GetLists() { + for _, entry := range list.GetEntries() { + currentMediaIds[entry.GetMedia().GetID()] = struct{}{} + } + } + + nakamaMediaIds := make(map[int]struct{}) + for _, lf := range lfs { + if lf.MediaId > 0 { + nakamaMediaIds[lf.MediaId] = struct{}{} + } + } + + missingMediaIds := make(map[int]struct{}) + for _, lf := range lfs { + if lf.MediaId > 0 { + if _, ok := currentMediaIds[lf.MediaId]; !ok { + missingMediaIds[lf.MediaId] = struct{}{} + } + } + } + + for _, list := range nakamaLibrary.AnimeCollection.MediaListCollection.GetLists() { + for _, entry := range list.GetEntries() { + if _, ok := missingMediaIds[entry.GetMedia().GetID()]; ok { + // create a new entry with blank list data + newEntry := &anilist.AnimeListEntry{ + ID: entry.GetID(), + Media: entry.GetMedia(), + Status: &[]anilist.MediaListStatus{anilist.MediaListStatusPlanning}[0], + } + animeCollection.MediaListCollection.AddEntryToList(newEntry, anilist.MediaListStatusPlanning) + } + } + } + + } else { + lfs, _, err = db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + } + + libraryCollection, err := anime.NewLibraryCollection(c.Request().Context(), &anime.NewLibraryCollectionOptions{ + AnimeCollection: animeCollection, + Platform: h.App.AnilistPlatform, + LocalFiles: lfs, + MetadataProvider: h.App.MetadataProvider, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + // Restore the original anime collection if it was modified + if fromNakama { + *animeCollection = *originalAnimeCollection + } + + if !fromNakama { + if (h.App.SecondarySettings.Torrentstream != nil && h.App.SecondarySettings.Torrentstream.Enabled && h.App.SecondarySettings.Torrentstream.IncludeInLibrary) || + (h.App.Settings.GetLibrary() != nil && h.App.Settings.GetLibrary().EnableOnlinestream && h.App.Settings.GetLibrary().IncludeOnlineStreamingInLibrary) || + (h.App.SecondarySettings.Debrid != nil && h.App.SecondarySettings.Debrid.Enabled && h.App.SecondarySettings.Debrid.IncludeDebridStreamInLibrary) { + h.App.TorrentstreamRepository.HydrateStreamCollection(&torrentstream.HydrateStreamCollectionOptions{ + AnimeCollection: animeCollection, + LibraryCollection: libraryCollection, + MetadataProvider: h.App.MetadataProvider, + }) + } + } + + // Add and remove necessary metadata when hydrating from Nakama + if fromNakama { + for _, ep := range libraryCollection.ContinueWatchingList { + ep.IsNakamaEpisode = true + } + for _, list := range libraryCollection.Lists { + for _, entry := range list.Entries { + if entry.EntryLibraryData == nil { + continue + } + entry.NakamaEntryLibraryData = &anime.NakamaEntryLibraryData{ + UnwatchedCount: entry.EntryLibraryData.UnwatchedCount, + MainFileCount: entry.EntryLibraryData.MainFileCount, + } + entry.EntryLibraryData = nil + } + } + } + + // Hydrate total library size + if libraryCollection != nil && libraryCollection.Stats != nil { + libraryCollection.Stats.TotalSize = util.Bytes(h.App.TotalLibrarySize) + } + + return h.RespondWithData(c, libraryCollection) +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +var animeScheduleCache = result.NewCache[int, []*anime.ScheduleItem]() + +// HandleGetAnimeCollectionSchedule +// +// @summary returns anime collection schedule +// @desc This is used by the "Schedule" page to display the anime schedule. +// @route /api/v1/library/schedule [GET] +// @returns []anime.ScheduleItem +func (h *Handler) HandleGetAnimeCollectionSchedule(c echo.Context) error { + + // Invalidate the cache when the Anilist collection is refreshed + h.App.AddOnRefreshAnilistCollectionFunc("HandleGetAnimeCollectionSchedule", func() { + animeScheduleCache.Clear() + }) + + if ret, ok := animeScheduleCache.Get(1); ok { + return h.RespondWithData(c, ret) + } + + animeSchedule, err := h.App.AnilistPlatform.GetAnimeAiringSchedule(c.Request().Context()) + if err != nil { + return h.RespondWithError(c, err) + } + + animeCollection, err := h.App.GetAnimeCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + ret := anime.GetScheduleItems(animeSchedule, animeCollection) + + animeScheduleCache.SetT(1, ret, 1*time.Hour) + + return h.RespondWithData(c, ret) +} + +// HandleAddUnknownMedia +// +// @summary adds the given media to the user's AniList planning collections +// @desc Since media not found in the user's AniList collection are not displayed in the library, this route is used to add them. +// @desc The response is ignored in the frontend, the client should just refetch the entire library collection. +// @route /api/v1/library/unknown-media [POST] +// @returns anilist.AnimeCollection +func (h *Handler) HandleAddUnknownMedia(c echo.Context) error { + + type body struct { + MediaIds []int `json:"mediaIds"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + // Add non-added media entries to AniList collection + if err := h.App.AnilistPlatform.AddMediaToCollection(c.Request().Context(), b.MediaIds); err != nil { + return h.RespondWithError(c, errors.New("error: Anilist responded with an error, this is most likely a rate limit issue")) + } + + // Bypass the cache + animeCollection, err := h.App.GetAnimeCollection(true) + if err != nil { + return h.RespondWithError(c, errors.New("error: Anilist responded with an error, wait one minute before refreshing")) + } + + return h.RespondWithData(c, animeCollection) + +} diff --git a/seanime-2.9.10/internal/handlers/anime_entries.go b/seanime-2.9.10/internal/handlers/anime_entries.go new file mode 100644 index 0000000..d9fa678 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/anime_entries.go @@ -0,0 +1,630 @@ +package handlers + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "seanime/internal/api/anilist" + "seanime/internal/database/db_bridge" + "seanime/internal/hook" + "seanime/internal/library/anime" + "seanime/internal/library/scanner" + "seanime/internal/library/summary" + "seanime/internal/util" + "seanime/internal/util/limiter" + "seanime/internal/util/result" + "slices" + "strconv" + "strings" + + "github.com/labstack/echo/v4" + "github.com/samber/lo" + lop "github.com/samber/lo/parallel" + "gorm.io/gorm" +) + +// HandleGetAnimeEntry +// +// @summary return a media entry for the given AniList anime media id. +// @desc This is used by the anime media entry pages to get all the data about the anime. +// @desc This includes episodes and metadata (if any), AniList list data, download info... +// @route /api/v1/library/anime-entry/{id} [GET] +// @param id - int - true - "AniList anime media ID" +// @returns anime.Entry +func (h *Handler) HandleGetAnimeEntry(c echo.Context) error { + + mId, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, err) + } + + // Get all the local files + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + // Get the host anime library files + nakamaLfs, hydratedFromNakama := h.App.NakamaManager.GetHostAnimeLibraryFiles(mId) + if hydratedFromNakama && nakamaLfs != nil { + lfs = nakamaLfs + } + + // Get the user's anilist collection + animeCollection, err := h.App.GetAnimeCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + if animeCollection == nil { + return h.RespondWithError(c, errors.New("anime collection not found")) + } + + // Create a new media entry + entry, err := anime.NewEntry(c.Request().Context(), &anime.NewEntryOptions{ + MediaId: mId, + LocalFiles: lfs, + AnimeCollection: animeCollection, + Platform: h.App.AnilistPlatform, + MetadataProvider: h.App.MetadataProvider, + IsSimulated: h.App.GetUser().IsSimulated, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + fillerEvent := new(anime.AnimeEntryFillerHydrationEvent) + fillerEvent.Entry = entry + err = hook.GlobalHookManager.OnAnimeEntryFillerHydration().Trigger(fillerEvent) + if err != nil { + return h.RespondWithError(c, err) + } + entry = fillerEvent.Entry + + if !fillerEvent.DefaultPrevented { + h.App.FillerManager.HydrateFillerData(fillerEvent.Entry) + } + + if hydratedFromNakama { + entry.IsNakamaEntry = true + for _, ep := range entry.Episodes { + ep.IsNakamaEpisode = true + } + } + + return h.RespondWithData(c, entry) +} + +//---------------------------------------------------------------------------------------------------------------------- + +// HandleAnimeEntryBulkAction +// +// @summary perform given action on all the local files for the given media id. +// @desc This is used to unmatch or toggle the lock status of all the local files for a specific media entry +// @desc The response is not used in the frontend. The client should just refetch the entire media entry data. +// @route /api/v1/library/anime-entry/bulk-action [PATCH] +// @returns []anime.LocalFile +func (h *Handler) HandleAnimeEntryBulkAction(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + Action string `json:"action"` // "unmatch" or "toggle-lock" + } + + p := new(body) + if err := c.Bind(p); err != nil { + return h.RespondWithError(c, err) + } + + // Get all the local files + lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + // Group local files by media id + groupedLfs := anime.GroupLocalFilesByMediaID(lfs) + + selectLfs, ok := groupedLfs[p.MediaId] + if !ok { + return h.RespondWithError(c, errors.New("no local files found for media id")) + } + + switch p.Action { + case "unmatch": + lfs = lop.Map(lfs, func(item *anime.LocalFile, _ int) *anime.LocalFile { + if item.MediaId == p.MediaId && p.MediaId != 0 { + item.MediaId = 0 + item.Locked = false + item.Ignored = false + } + return item + }) + case "toggle-lock": + // Flip the locked status of all the local files for the given media + allLocked := lo.EveryBy(selectLfs, func(item *anime.LocalFile) bool { return item.Locked }) + lfs = lop.Map(lfs, func(item *anime.LocalFile, _ int) *anime.LocalFile { + if item.MediaId == p.MediaId && p.MediaId != 0 { + item.Locked = !allLocked + } + return item + }) + } + + // Save the local files + retLfs, err := db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, retLfs) + +} + +//---------------------------------------------------------------------------------------------------------------------- + +// HandleOpenAnimeEntryInExplorer +// +// @summary opens the directory of a media entry in the file explorer. +// @desc This finds a common directory for all media entry local files and opens it in the file explorer. +// @desc Returns 'true' whether the operation was successful or not, errors are ignored. +// @route /api/v1/library/anime-entry/open-in-explorer [POST] +// @returns bool +func (h *Handler) HandleOpenAnimeEntryInExplorer(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + } + + p := new(body) + if err := c.Bind(p); err != nil { + return h.RespondWithError(c, err) + } + + // Get all the local files + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + lf, found := lo.Find(lfs, func(i *anime.LocalFile) bool { + return i.MediaId == p.MediaId + }) + if !found { + return h.RespondWithError(c, errors.New("local file not found")) + } + + dir := filepath.Dir(lf.GetNormalizedPath()) + cmd := "" + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "explorer" + wPath := strings.ReplaceAll(strings.ToLower(dir), "/", "\\") + args = []string{wPath} + case "darwin": + cmd = "open" + args = []string{dir} + case "linux": + cmd = "xdg-open" + args = []string{dir} + default: + return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + cmdObj := util.NewCmd(cmd, args...) + cmdObj.Stdout = os.Stdout + cmdObj.Stderr = os.Stderr + _ = cmdObj.Run() + + return h.RespondWithData(c, true) + +} + +//---------------------------------------------------------------------------------------------------------------------- + +var ( + entriesSuggestionsCache = result.NewCache[string, []*anilist.BaseAnime]() +) + +// HandleFetchAnimeEntrySuggestions +// +// @summary returns a list of media suggestions for files in the given directory. +// @desc This is used by the "Resolve unmatched media" feature to suggest media entries for the local files in the given directory. +// @desc If some matches files are found in the directory, it will ignore them and base the suggestions on the remaining files. +// @route /api/v1/library/anime-entry/suggestions [POST] +// @returns []anilist.BaseAnime +func (h *Handler) HandleFetchAnimeEntrySuggestions(c echo.Context) error { + + type body struct { + Dir string `json:"dir"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + b.Dir = strings.ToLower(b.Dir) + + suggestions, found := entriesSuggestionsCache.Get(b.Dir) + if found { + return h.RespondWithData(c, suggestions) + } + + // Retrieve local files + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + // Group local files by dir + groupedLfs := lop.GroupBy(lfs, func(item *anime.LocalFile) string { + return filepath.Dir(item.GetNormalizedPath()) + }) + + selectedLfs, found := groupedLfs[b.Dir] + if !found { + return h.RespondWithError(c, errors.New("no local files found for selected directory")) + } + + // Filter out local files that are already matched + selectedLfs = lo.Filter(selectedLfs, func(item *anime.LocalFile, _ int) bool { + return item.MediaId == 0 + }) + + title := selectedLfs[0].GetParsedTitle() + + h.App.Logger.Info().Str("title", title).Msg("handlers: Fetching anime suggestions") + + res, err := anilist.ListAnimeM( + lo.ToPtr(1), + &title, + lo.ToPtr(8), + nil, + []*anilist.MediaStatus{lo.ToPtr(anilist.MediaStatusFinished), lo.ToPtr(anilist.MediaStatusReleasing), lo.ToPtr(anilist.MediaStatusCancelled), lo.ToPtr(anilist.MediaStatusHiatus)}, + nil, + nil, + nil, + nil, + nil, + nil, + h.App.Logger, + h.App.GetUserAnilistToken(), + ) + if err != nil { + return h.RespondWithError(c, err) + } + + // Cache the results + entriesSuggestionsCache.Set(b.Dir, res.GetPage().GetMedia()) + + return h.RespondWithData(c, res.GetPage().GetMedia()) + +} + +//---------------------------------------------------------------------------------------------------------------------- + +// HandleAnimeEntryManualMatch +// +// @summary matches un-matched local files in the given directory to the given media. +// @desc It is used by the "Resolve unmatched media" feature to manually match local files to a specific media entry. +// @desc Matching involves the use of scanner.FileHydrator. It will also lock the files. +// @desc The response is not used in the frontend. The client should just refetch the entire library collection. +// @route /api/v1/library/anime-entry/manual-match [POST] +// @returns []anime.LocalFile +func (h *Handler) HandleAnimeEntryManualMatch(c echo.Context) error { + + type body struct { + Paths []string `json:"paths"` + MediaId int `json:"mediaId"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + animeCollectionWithRelations, err := h.App.AnilistPlatform.GetAnimeCollectionWithRelations(c.Request().Context()) + if err != nil { + return h.RespondWithError(c, err) + } + + // Retrieve local files + lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + compPaths := make(map[string]struct{}) + for _, p := range b.Paths { + compPaths[util.NormalizePath(p)] = struct{}{} + } + + selectedLfs := lo.Filter(lfs, func(item *anime.LocalFile, _ int) bool { + _, found := compPaths[item.GetNormalizedPath()] + return found && item.MediaId == 0 + }) + + // Add the media id to the selected local files + // Also, lock the files + selectedLfs = lop.Map(selectedLfs, func(item *anime.LocalFile, _ int) *anime.LocalFile { + item.MediaId = b.MediaId + item.Locked = true + item.Ignored = false + return item + }) + + // Get the media + media, err := h.App.AnilistPlatform.GetAnime(c.Request().Context(), b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + + // Create a slice of normalized media + normalizedMedia := []*anime.NormalizedMedia{ + anime.NewNormalizedMedia(media), + } + + scanLogger, err := scanner.NewScanLogger(h.App.Config.Logs.Dir) + if err != nil { + return h.RespondWithError(c, err) + } + + // Create scan summary logger + scanSummaryLogger := summary.NewScanSummaryLogger() + + fh := scanner.FileHydrator{ + LocalFiles: selectedLfs, + CompleteAnimeCache: anilist.NewCompleteAnimeCache(), + Platform: h.App.AnilistPlatform, + MetadataProvider: h.App.MetadataProvider, + AnilistRateLimiter: limiter.NewAnilistLimiter(), + Logger: h.App.Logger, + ScanLogger: scanLogger, + ScanSummaryLogger: scanSummaryLogger, + AllMedia: normalizedMedia, + ForceMediaId: media.GetID(), + } + + fh.HydrateMetadata() + + // Hydrate the summary logger before merging files + fh.ScanSummaryLogger.HydrateData(selectedLfs, normalizedMedia, animeCollectionWithRelations) + + // Save the scan summary + go func() { + err = db_bridge.InsertScanSummary(h.App.Database, scanSummaryLogger.GenerateSummary()) + }() + + // Remove select local files from the database slice, we will add them (hydrated) later + selectedPaths := lop.Map(selectedLfs, func(item *anime.LocalFile, _ int) string { return item.GetNormalizedPath() }) + lfs = lo.Filter(lfs, func(item *anime.LocalFile, _ int) bool { + if slices.Contains(selectedPaths, item.GetNormalizedPath()) { + return false + } + return true + }) + + // Event + event := new(anime.AnimeEntryManualMatchBeforeSaveEvent) + event.MediaId = b.MediaId + event.Paths = b.Paths + event.MatchedLocalFiles = selectedLfs + err = hook.GlobalHookManager.OnAnimeEntryManualMatchBeforeSave().Trigger(event) + if err != nil { + return h.RespondWithError(c, fmt.Errorf("OnAnimeEntryManualMatchBeforeSave: %w", err)) + } + + // Default prevented, do not save the local files + if event.DefaultPrevented { + return h.RespondWithData(c, lfs) + } + + // Add the hydrated local files to the slice + lfs = append(lfs, event.MatchedLocalFiles...) + + // Update the local files + retLfs, err := db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, retLfs) +} + +//---------------------------------------------------------------------------------------------------------------------- + +var missingEpisodesCache *anime.MissingEpisodes + +// HandleGetMissingEpisodes +// +// @summary returns a list of episodes missing from the user's library collection +// @desc It detects missing episodes by comparing the user's AniList collection 'next airing' data with the local files. +// @desc This route can be called multiple times, as it does not bypass the cache. +// @route /api/v1/library/missing-episodes [GET] +// @returns anime.MissingEpisodes +func (h *Handler) HandleGetMissingEpisodes(c echo.Context) error { + h.App.AddOnRefreshAnilistCollectionFunc("HandleGetMissingEpisodes", func() { + missingEpisodesCache = nil + }) + + if missingEpisodesCache != nil { + return h.RespondWithData(c, missingEpisodesCache) + } + + // Get the user's anilist collection + // Do not bypass the cache, since this handler might be called multiple times, and we don't want to spam the API + // A cron job will refresh the cache every 10 minutes + animeCollection, err := h.App.GetAnimeCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + // Get the silenced media ids + silencedMediaIds, _ := h.App.Database.GetSilencedMediaEntryIds() + + missingEps := anime.NewMissingEpisodes(&anime.NewMissingEpisodesOptions{ + AnimeCollection: animeCollection, + LocalFiles: lfs, + SilencedMediaIds: silencedMediaIds, + MetadataProvider: h.App.MetadataProvider, + }) + + event := new(anime.MissingEpisodesEvent) + event.MissingEpisodes = missingEps + err = hook.GlobalHookManager.OnMissingEpisodes().Trigger(event) + if err != nil { + return h.RespondWithError(c, err) + } + + missingEpisodesCache = event.MissingEpisodes + + return h.RespondWithData(c, event.MissingEpisodes) +} + +//---------------------------------------------------------------------------------------------------------------------- + +// HandleGetAnimeEntrySilenceStatus +// +// @summary returns the silence status of a media entry. +// @param id - int - true - "The ID of the media entry." +// @route /api/v1/library/anime-entry/silence/{id} [GET] +// @returns models.SilencedMediaEntry +func (h *Handler) HandleGetAnimeEntrySilenceStatus(c echo.Context) error { + mId, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, errors.New("invalid id")) + } + + animeEntry, err := h.App.Database.GetSilencedMediaEntry(uint(mId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return h.RespondWithData(c, false) + } else { + return h.RespondWithError(c, err) + } + } + + return h.RespondWithData(c, animeEntry) +} + +// HandleToggleAnimeEntrySilenceStatus +// +// @summary toggles the silence status of a media entry. +// @desc The missing episodes should be re-fetched after this. +// @route /api/v1/library/anime-entry/silence [POST] +// @returns bool +func (h *Handler) HandleToggleAnimeEntrySilenceStatus(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + animeEntry, err := h.App.Database.GetSilencedMediaEntry(uint(b.MediaId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + err = h.App.Database.InsertSilencedMediaEntry(uint(b.MediaId)) + if err != nil { + return h.RespondWithError(c, err) + } + return h.RespondWithData(c, true) + } else { + return h.RespondWithError(c, err) + } + } + + err = h.App.Database.DeleteSilencedMediaEntry(animeEntry.ID) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +//----------------------------------------------------------------------------------------------------------------------------- + +// HandleUpdateAnimeEntryProgress +// +// @summary update the progress of the given anime media entry. +// @desc This is used to update the progress of the given anime media entry on AniList. +// @desc The response is not used in the frontend, the client should just refetch the entire media entry data. +// @desc NOTE: This is currently only used by the 'Online streaming' feature since anime progress updates are handled by the Playback Manager. +// @route /api/v1/library/anime-entry/update-progress [POST] +// @returns bool +func (h *Handler) HandleUpdateAnimeEntryProgress(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + MalId int `json:"malId,omitempty"` + EpisodeNumber int `json:"episodeNumber"` + TotalEpisodes int `json:"totalEpisodes"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + // Update the progress on AniList + err := h.App.AnilistPlatform.UpdateEntryProgress( + c.Request().Context(), + b.MediaId, + b.EpisodeNumber, + &b.TotalEpisodes, + ) + if err != nil { + return h.RespondWithError(c, err) + } + + _, _ = h.App.RefreshAnimeCollection() // Refresh the AniList collection + + return h.RespondWithData(c, true) +} + +//----------------------------------------------------------------------------------------------------------------------------- + +// HandleUpdateAnimeEntryRepeat +// +// @summary update the repeat value of the given anime media entry. +// @desc This is used to update the repeat value of the given anime media entry on AniList. +// @desc The response is not used in the frontend, the client should just refetch the entire media entry data. +// @route /api/v1/library/anime-entry/update-repeat [POST] +// @returns bool +func (h *Handler) HandleUpdateAnimeEntryRepeat(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + Repeat int `json:"repeat"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.AnilistPlatform.UpdateEntryRepeat( + c.Request().Context(), + b.MediaId, + b.Repeat, + ) + if err != nil { + return h.RespondWithError(c, err) + } + + //_, _ = h.App.RefreshAnimeCollection() // Refresh the AniList collection + + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/auth.go b/seanime-2.9.10/internal/handlers/auth.go new file mode 100644 index 0000000..c070f98 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/auth.go @@ -0,0 +1,138 @@ +package handlers + +import ( + "context" + "errors" + "seanime/internal/database/models" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/platforms/simulated_platform" + "seanime/internal/util" + "time" + + "github.com/goccy/go-json" + "github.com/labstack/echo/v4" +) + +// HandleLogin +// +// @summary logs in the user by saving the JWT token in the database. +// @desc This is called when the JWT token is obtained from AniList after logging in with redirection on the client. +// @desc It also fetches the Viewer data from AniList and saves it in the database. +// @desc It creates a new handlers.Status and refreshes App modules. +// @route /api/v1/auth/login [POST] +// @returns handlers.Status +func (h *Handler) HandleLogin(c echo.Context) error { + + type body struct { + Token string `json:"token"` + } + + var b body + + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + // Set a new AniList client by passing to JWT token + h.App.UpdateAnilistClientToken(b.Token) + + // Get viewer data from AniList + getViewer, err := h.App.AnilistClient.GetViewer(context.Background()) + if err != nil { + h.App.Logger.Error().Msg("Could not authenticate to AniList") + return h.RespondWithError(c, err) + } + + if len(getViewer.Viewer.Name) == 0 { + return h.RespondWithError(c, errors.New("could not find user")) + } + + // Marshal viewer data + bytes, err := json.Marshal(getViewer.Viewer) + if err != nil { + h.App.Logger.Err(err).Msg("scan: could not save local files") + } + + // Save account data in database + _, err = h.App.Database.UpsertAccount(&models.Account{ + BaseModel: models.BaseModel{ + ID: 1, + UpdatedAt: time.Now(), + }, + Username: getViewer.Viewer.Name, + Token: b.Token, + Viewer: bytes, + }) + + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.Logger.Info().Msg("app: Authenticated to AniList") + + // Update the platform + anilistPlatform := anilist_platform.NewAnilistPlatform(h.App.AnilistClient, h.App.Logger) + h.App.UpdatePlatform(anilistPlatform) + + // Create a new status + status := h.NewStatus(c) + + h.App.InitOrRefreshAnilistData() + + h.App.InitOrRefreshModules() + + go func() { + defer util.HandlePanicThen(func() {}) + h.App.InitOrRefreshTorrentstreamSettings() + h.App.InitOrRefreshMediastreamSettings() + h.App.InitOrRefreshDebridSettings() + }() + + // Return new status + return h.RespondWithData(c, status) + +} + +// HandleLogout +// +// @summary logs out the user by removing JWT token from the database. +// @desc It removes JWT token and Viewer data from the database. +// @desc It creates a new handlers.Status and refreshes App modules. +// @route /api/v1/auth/logout [POST] +// @returns handlers.Status +func (h *Handler) HandleLogout(c echo.Context) error { + + // Update the anilist client + h.App.UpdateAnilistClientToken("") + + // Update the platform + simulatedPlatform, err := simulated_platform.NewSimulatedPlatform(h.App.LocalManager, h.App.AnilistClient, h.App.Logger) + if err != nil { + return h.RespondWithError(c, err) + } + h.App.UpdatePlatform(simulatedPlatform) + + _, err = h.App.Database.UpsertAccount(&models.Account{ + BaseModel: models.BaseModel{ + ID: 1, + UpdatedAt: time.Now(), + }, + Username: "", + Token: "", + Viewer: nil, + }) + + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.Logger.Info().Msg("Logged out of AniList") + + status := h.NewStatus(c) + + h.App.InitOrRefreshModules() + + h.App.InitOrRefreshAnilistData() + + return h.RespondWithData(c, status) +} diff --git a/seanime-2.9.10/internal/handlers/auto_downloader.go b/seanime-2.9.10/internal/handlers/auto_downloader.go new file mode 100644 index 0000000..4f522a0 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/auto_downloader.go @@ -0,0 +1,233 @@ +package handlers + +import ( + "errors" + "path/filepath" + "seanime/internal/database/db_bridge" + "seanime/internal/library/anime" + "strconv" + + "github.com/labstack/echo/v4" +) + +// HandleRunAutoDownloader +// +// @summary tells the AutoDownloader to check for new episodes if enabled. +// @desc This will run the AutoDownloader if it is enabled. +// @desc It does nothing if the AutoDownloader is disabled. +// @route /api/v1/auto-downloader/run [POST] +// @returns bool +func (h *Handler) HandleRunAutoDownloader(c echo.Context) error { + + h.App.AutoDownloader.Run() + + return h.RespondWithData(c, true) +} + +// HandleGetAutoDownloaderRule +// +// @summary returns the rule with the given DB id. +// @desc This is used to get a specific rule, useful for editing. +// @route /api/v1/auto-downloader/rule/{id} [GET] +// @param id - int - true - "The DB id of the rule" +// @returns anime.AutoDownloaderRule +func (h *Handler) HandleGetAutoDownloaderRule(c echo.Context) error { + + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, errors.New("invalid id")) + } + + rule, err := db_bridge.GetAutoDownloaderRule(h.App.Database, uint(id)) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, rule) +} + +// HandleGetAutoDownloaderRulesByAnime +// +// @summary returns the rules with the given media id. +// @route /api/v1/auto-downloader/rule/anime/{id} [GET] +// @param id - int - true - "The AniList anime id of the rules" +// @returns []anime.AutoDownloaderRule +func (h *Handler) HandleGetAutoDownloaderRulesByAnime(c echo.Context) error { + + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, errors.New("invalid id")) + } + + rules := db_bridge.GetAutoDownloaderRulesByMediaId(h.App.Database, id) + return h.RespondWithData(c, rules) +} + +// HandleGetAutoDownloaderRules +// +// @summary returns all rules. +// @desc This is used to list all rules. It returns an empty slice if there are no rules. +// @route /api/v1/auto-downloader/rules [GET] +// @returns []anime.AutoDownloaderRule +func (h *Handler) HandleGetAutoDownloaderRules(c echo.Context) error { + rules, err := db_bridge.GetAutoDownloaderRules(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, rules) +} + +// HandleCreateAutoDownloaderRule +// +// @summary creates a new rule. +// @desc The body should contain the same fields as entities.AutoDownloaderRule. +// @desc It returns the created rule. +// @route /api/v1/auto-downloader/rule [POST] +// @returns anime.AutoDownloaderRule +func (h *Handler) HandleCreateAutoDownloaderRule(c echo.Context) error { + type body struct { + Enabled bool `json:"enabled"` + MediaId int `json:"mediaId"` + ReleaseGroups []string `json:"releaseGroups"` + Resolutions []string `json:"resolutions"` + AdditionalTerms []string `json:"additionalTerms"` + ComparisonTitle string `json:"comparisonTitle"` + TitleComparisonType anime.AutoDownloaderRuleTitleComparisonType `json:"titleComparisonType"` + EpisodeType anime.AutoDownloaderRuleEpisodeType `json:"episodeType"` + EpisodeNumbers []int `json:"episodeNumbers,omitempty"` + Destination string `json:"destination"` + } + + var b body + + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + if b.Destination == "" { + return h.RespondWithError(c, errors.New("destination is required")) + } + + if !filepath.IsAbs(b.Destination) { + return h.RespondWithError(c, errors.New("destination must be an absolute path")) + } + + rule := &anime.AutoDownloaderRule{ + Enabled: b.Enabled, + MediaId: b.MediaId, + ReleaseGroups: b.ReleaseGroups, + Resolutions: b.Resolutions, + ComparisonTitle: b.ComparisonTitle, + TitleComparisonType: b.TitleComparisonType, + EpisodeType: b.EpisodeType, + EpisodeNumbers: b.EpisodeNumbers, + Destination: b.Destination, + AdditionalTerms: b.AdditionalTerms, + } + + if err := db_bridge.InsertAutoDownloaderRule(h.App.Database, rule); err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, rule) +} + +// HandleUpdateAutoDownloaderRule +// +// @summary updates a rule. +// @desc The body should contain the same fields as entities.AutoDownloaderRule. +// @desc It returns the updated rule. +// @route /api/v1/auto-downloader/rule [PATCH] +// @returns anime.AutoDownloaderRule +func (h *Handler) HandleUpdateAutoDownloaderRule(c echo.Context) error { + + type body struct { + Rule *anime.AutoDownloaderRule `json:"rule"` + } + + var b body + + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + if b.Rule == nil { + return h.RespondWithError(c, errors.New("invalid rule")) + } + + if b.Rule.DbID == 0 { + return h.RespondWithError(c, errors.New("invalid id")) + } + + // Update the rule based on its DbID (primary key) + if err := db_bridge.UpdateAutoDownloaderRule(h.App.Database, b.Rule.DbID, b.Rule); err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, b.Rule) +} + +// HandleDeleteAutoDownloaderRule +// +// @summary deletes a rule. +// @desc It returns 'true' if the rule was deleted. +// @route /api/v1/auto-downloader/rule/{id} [DELETE] +// @param id - int - true - "The DB id of the rule" +// @returns bool +func (h *Handler) HandleDeleteAutoDownloaderRule(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, errors.New("invalid id")) + } + + if err := db_bridge.DeleteAutoDownloaderRule(h.App.Database, uint(id)); err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// HandleGetAutoDownloaderItems +// +// @summary returns all queued items. +// @desc Queued items are episodes that are downloaded but not scanned or not yet downloaded. +// @desc The AutoDownloader uses these items in order to not download the same episode twice. +// @route /api/v1/auto-downloader/items [GET] +// @returns []models.AutoDownloaderItem +func (h *Handler) HandleGetAutoDownloaderItems(c echo.Context) error { + rules, err := h.App.Database.GetAutoDownloaderItems() + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, rules) +} + +// HandleDeleteAutoDownloaderItem +// +// @summary delete a queued item. +// @desc This is used to remove a queued item from the list. +// @desc Returns 'true' if the item was deleted. +// @route /api/v1/auto-downloader/item [DELETE] +// @param id - int - true - "The DB id of the item" +// @returns bool +func (h *Handler) HandleDeleteAutoDownloaderItem(c echo.Context) error { + + type body struct { + ID uint `json:"id"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + if err := h.App.Database.DeleteAutoDownloaderItem(b.ID); err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/continuity.go b/seanime-2.9.10/internal/handlers/continuity.go new file mode 100644 index 0000000..3d2a2e1 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/continuity.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "seanime/internal/continuity" + "strconv" + + "github.com/labstack/echo/v4" +) + +// HandleUpdateContinuityWatchHistoryItem +// +// @summary Updates watch history item. +// @desc This endpoint is used to update a watch history item. +// @desc Since this is low priority, we ignore any errors. +// @route /api/v1/continuity/item [PATCH] +// @returns bool +func (h *Handler) HandleUpdateContinuityWatchHistoryItem(c echo.Context) error { + type body struct { + Options continuity.UpdateWatchHistoryItemOptions `json:"options"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.ContinuityManager.UpdateWatchHistoryItem(&b.Options) + if err != nil { + // Ignore the error + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleGetContinuityWatchHistoryItem +// +// @summary Returns a watch history item. +// @desc This endpoint is used to retrieve a watch history item. +// @route /api/v1/continuity/item/{id} [GET] +// @param id - int - true - "AniList anime media ID" +// @returns continuity.WatchHistoryItemResponse +func (h *Handler) HandleGetContinuityWatchHistoryItem(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, err) + } + + if !h.App.ContinuityManager.GetSettings().WatchContinuityEnabled { + return h.RespondWithData(c, &continuity.WatchHistoryItemResponse{ + Item: nil, + Found: false, + }) + } + + resp := h.App.ContinuityManager.GetWatchHistoryItem(id) + return h.RespondWithData(c, resp) +} + +// HandleGetContinuityWatchHistory +// +// @summary Returns the continuity watch history +// @desc This endpoint is used to retrieve all watch history items. +// @route /api/v1/continuity/history [GET] +// @returns continuity.WatchHistory +func (h *Handler) HandleGetContinuityWatchHistory(c echo.Context) error { + if !h.App.ContinuityManager.GetSettings().WatchContinuityEnabled { + ret := make(map[int]*continuity.WatchHistoryItem) + return h.RespondWithData(c, ret) + } + + resp := h.App.ContinuityManager.GetWatchHistory() + return h.RespondWithData(c, resp) +} diff --git a/seanime-2.9.10/internal/handlers/debrid.go b/seanime-2.9.10/internal/handlers/debrid.go new file mode 100644 index 0000000..3e26d4b --- /dev/null +++ b/seanime-2.9.10/internal/handlers/debrid.go @@ -0,0 +1,404 @@ +package handlers + +import ( + "errors" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/database/models" + debrid_client "seanime/internal/debrid/client" + "seanime/internal/debrid/debrid" + "seanime/internal/events" + hibiketorrent "seanime/internal/extension/hibike/torrent" + + "github.com/labstack/echo/v4" +) + +// HandleGetDebridSettings +// +// @summary get debrid settings. +// @desc This returns the debrid settings. +// @returns models.DebridSettings +// @route /api/v1/debrid/settings [GET] +func (h *Handler) HandleGetDebridSettings(c echo.Context) error { + debridSettings, found := h.App.Database.GetDebridSettings() + if !found { + return h.RespondWithError(c, errors.New("debrid settings not found")) + } + + return h.RespondWithData(c, debridSettings) +} + +// HandleSaveDebridSettings +// +// @summary save debrid settings. +// @desc This saves the debrid settings. +// @desc The client should refetch the server status. +// @returns models.DebridSettings +// @route /api/v1/debrid/settings [PATCH] +func (h *Handler) HandleSaveDebridSettings(c echo.Context) error { + + type body struct { + Settings models.DebridSettings `json:"settings"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + settings, err := h.App.Database.UpsertDebridSettings(&b.Settings) + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.InitOrRefreshDebridSettings() + + return h.RespondWithData(c, settings) +} + +// HandleDebridAddTorrents +// +// @summary add torrent to debrid. +// @desc This adds a torrent to the debrid service. +// @returns bool +// @route /api/v1/debrid/torrents [POST] +func (h *Handler) HandleDebridAddTorrents(c echo.Context) error { + + type body struct { + Torrents []hibiketorrent.AnimeTorrent `json:"torrents"` + Media *anilist.BaseAnime `json:"media"` + Destination string `json:"destination"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + if !h.App.DebridClientRepository.HasProvider() { + return h.RespondWithError(c, errors.New("debrid provider not set")) + } + + for _, torrent := range b.Torrents { + // Get the torrent's provider extension + animeTorrentProviderExtension, ok := h.App.TorrentRepository.GetAnimeProviderExtension(torrent.Provider) + if !ok { + return h.RespondWithError(c, errors.New("provider extension not found for torrent")) + } + + magnet, err := animeTorrentProviderExtension.GetProvider().GetTorrentMagnetLink(&torrent) + if err != nil { + if len(b.Torrents) == 1 { + return h.RespondWithError(c, err) + } else { + h.App.Logger.Err(err).Msg("debrid: Failed to get magnet link") + h.App.WSEventManager.SendEvent(events.ErrorToast, err.Error()) + continue + } + } + + torrent.MagnetLink = magnet + + // Add the torrent to the debrid service + _, err = h.App.DebridClientRepository.AddAndQueueTorrent(debrid.AddTorrentOptions{ + MagnetLink: magnet, + SelectFileId: "all", + }, b.Destination, b.Media.ID) + if err != nil { + // If there is only one torrent, return the error + if len(b.Torrents) == 1 { + return h.RespondWithError(c, err) + } else { + // If there are multiple torrents, send an error toast and continue to the next torrent + h.App.Logger.Err(err).Msg("debrid: Failed to add torrent to debrid") + h.App.WSEventManager.SendEvent(events.ErrorToast, err.Error()) + continue + } + } + } + + return h.RespondWithData(c, true) +} + +// HandleDebridDownloadTorrent +// +// @summary download torrent from debrid. +// @desc Manually downloads a torrent from the debrid service locally. +// @returns bool +// @route /api/v1/debrid/torrents/download [POST] +func (h *Handler) HandleDebridDownloadTorrent(c echo.Context) error { + + type body struct { + TorrentItem debrid.TorrentItem `json:"torrentItem"` + Destination string `json:"destination"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + if !filepath.IsAbs(b.Destination) { + return h.RespondWithError(c, errors.New("destination must be an absolute path")) + } + + // Remove the torrent from the database + // This is done so that the torrent is not downloaded automatically + // We ignore the error here because the torrent might not be in the database + _ = h.App.Database.DeleteDebridTorrentItemByTorrentItemId(b.TorrentItem.ID) + + // Download the torrent locally + err := h.App.DebridClientRepository.DownloadTorrent(b.TorrentItem, b.Destination) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleDebridCancelDownload +// +// @summary cancel download from debrid. +// @desc This cancels a download from the debrid service. +// @returns bool +// @route /api/v1/debrid/torrents/cancel [POST] +func (h *Handler) HandleDebridCancelDownload(c echo.Context) error { + + type body struct { + ItemID string `json:"itemID"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.DebridClientRepository.CancelDownload(b.ItemID) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleDebridDeleteTorrent +// +// @summary remove torrent from debrid. +// @desc This removes a torrent from the debrid service. +// @returns bool +// @route /api/v1/debrid/torrent [DELETE] +func (h *Handler) HandleDebridDeleteTorrent(c echo.Context) error { + + type body struct { + TorrentItem debrid.TorrentItem `json:"torrentItem"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + provider, err := h.App.DebridClientRepository.GetProvider() + if err != nil { + return h.RespondWithError(c, err) + } + + err = provider.DeleteTorrent(b.TorrentItem.ID) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleDebridGetTorrents +// +// @summary get torrents from debrid. +// @desc This gets the torrents from the debrid service. +// @returns []debrid.TorrentItem +// @route /api/v1/debrid/torrents [GET] +func (h *Handler) HandleDebridGetTorrents(c echo.Context) error { + + provider, err := h.App.DebridClientRepository.GetProvider() + if err != nil { + return h.RespondWithError(c, err) + } + + torrents, err := provider.GetTorrents() + if err != nil { + h.App.Logger.Err(err).Msg("debrid: Failed to get torrents") + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, torrents) +} + +// HandleDebridGetTorrentInfo +// +// @summary get torrent info from debrid. +// @desc This gets the torrent info from the debrid service. +// @returns debrid.TorrentInfo +// @route /api/v1/debrid/torrents/info [POST] +func (h *Handler) HandleDebridGetTorrentInfo(c echo.Context) error { + type body struct { + Torrent hibiketorrent.AnimeTorrent `json:"torrent"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + animeTorrentProviderExtension, ok := h.App.TorrentRepository.GetAnimeProviderExtension(b.Torrent.Provider) + if !ok { + return h.RespondWithError(c, errors.New("provider extension not found for torrent")) + } + + magnet, err := animeTorrentProviderExtension.GetProvider().GetTorrentMagnetLink(&b.Torrent) + if err != nil { + return h.RespondWithError(c, err) + } + + b.Torrent.MagnetLink = magnet + + torrentInfo, err := h.App.DebridClientRepository.GetTorrentInfo(debrid.GetTorrentInfoOptions{ + MagnetLink: b.Torrent.MagnetLink, + InfoHash: b.Torrent.InfoHash, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, torrentInfo) +} + +// HandleDebridGetTorrentFilePreviews +// +// @summary get list of torrent files +// @returns []debrid_client.FilePreview +// @route /api/v1/debrid/torrents/file-previews [POST] +func (h *Handler) HandleDebridGetTorrentFilePreviews(c echo.Context) error { + type body struct { + Torrent *hibiketorrent.AnimeTorrent `json:"torrent"` + EpisodeNumber int `json:"episodeNumber"` + Media *anilist.BaseAnime `json:"media"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + animeTorrentProviderExtension, ok := h.App.TorrentRepository.GetAnimeProviderExtension(b.Torrent.Provider) + if !ok { + return h.RespondWithError(c, errors.New("provider extension not found for torrent")) + } + + magnet, err := animeTorrentProviderExtension.GetProvider().GetTorrentMagnetLink(b.Torrent) + if err != nil { + return h.RespondWithError(c, err) + } + + b.Torrent.MagnetLink = magnet + + // Get the media + animeMetadata, _ := h.App.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, b.Media.ID) + absoluteOffset := 0 + if animeMetadata != nil { + absoluteOffset = animeMetadata.GetOffset() + } + + torrentInfo, err := h.App.DebridClientRepository.GetTorrentFilePreviewsFromManualSelection(&debrid_client.GetTorrentFilePreviewsOptions{ + Torrent: b.Torrent, + Magnet: magnet, + EpisodeNumber: b.EpisodeNumber, + Media: b.Media, + AbsoluteOffset: absoluteOffset, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, torrentInfo) +} + +// HandleDebridStartStream +// +// @summary start stream from debrid. +// @desc This starts streaming a torrent from the debrid service. +// @returns bool +// @route /api/v1/debrid/stream/start [POST] +func (h *Handler) HandleDebridStartStream(c echo.Context) error { + type body struct { + MediaId int `json:"mediaId"` + EpisodeNumber int `json:"episodeNumber"` + AniDBEpisode string `json:"aniDBEpisode"` + AutoSelect bool `json:"autoSelect"` + Torrent *hibiketorrent.AnimeTorrent `json:"torrent"` + FileId string `json:"fileId"` + FileIndex *int `json:"fileIndex"` + PlaybackType debrid_client.StreamPlaybackType `json:"playbackType"` // "default" or "externalPlayerLink" + ClientId string `json:"clientId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + userAgent := c.Request().Header.Get("User-Agent") + + if b.Torrent != nil { + animeTorrentProviderExtension, ok := h.App.TorrentRepository.GetAnimeProviderExtension(b.Torrent.Provider) + if !ok { + return h.RespondWithError(c, errors.New("provider extension not found for torrent")) + } + + magnet, err := animeTorrentProviderExtension.GetProvider().GetTorrentMagnetLink(b.Torrent) + if err != nil { + return h.RespondWithError(c, err) + } + + b.Torrent.MagnetLink = magnet + } + + err := h.App.DebridClientRepository.StartStream(c.Request().Context(), &debrid_client.StartStreamOptions{ + MediaId: b.MediaId, + EpisodeNumber: b.EpisodeNumber, + AniDBEpisode: b.AniDBEpisode, + Torrent: b.Torrent, + FileId: b.FileId, + FileIndex: b.FileIndex, + UserAgent: userAgent, + ClientId: b.ClientId, + PlaybackType: b.PlaybackType, + AutoSelect: b.AutoSelect, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleDebridCancelStream +// +// @summary cancel stream from debrid. +// @desc This cancels a stream from the debrid service. +// @returns bool +// @route /api/v1/debrid/stream/cancel [POST] +func (h *Handler) HandleDebridCancelStream(c echo.Context) error { + type body struct { + Options *debrid_client.CancelStreamOptions `json:"options"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + h.App.DebridClientRepository.CancelStream(b.Options) + + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/directory_selector.go b/seanime-2.9.10/internal/handlers/directory_selector.go new file mode 100644 index 0000000..6c930aa --- /dev/null +++ b/seanime-2.9.10/internal/handlers/directory_selector.go @@ -0,0 +1,133 @@ +package handlers + +import ( + "os" + "path/filepath" + "strings" + + "github.com/labstack/echo/v4" +) + +type DirectoryInfo struct { + FullPath string `json:"fullPath"` + FolderName string `json:"folderName"` +} + +type DirectorySelectorResponse struct { + FullPath string `json:"fullPath"` + Exists bool `json:"exists"` + BasePath string `json:"basePath"` + Suggestions []DirectoryInfo `json:"suggestions"` + Content []DirectoryInfo `json:"content"` +} + +// HandleDirectorySelector +// +// @summary returns directory content based on the input path. +// @desc This used by the directory selector component to get directory validation and suggestions. +// @desc It returns subdirectories based on the input path. +// @desc It returns 500 error if the directory does not exist (or cannot be accessed). +// @route /api/v1/directory-selector [POST] +// @returns handlers.DirectorySelectorResponse +func (h *Handler) HandleDirectorySelector(c echo.Context) error { + + type body struct { + Input string `json:"input"` + } + var request body + + if err := c.Bind(&request); err != nil { + return h.RespondWithError(c, err) + } + + input := filepath.ToSlash(filepath.Clean(request.Input)) + directoryExists, err := checkDirectoryExists(input) + if err != nil { + return h.RespondWithError(c, err) + } + + if directoryExists { + suggestions, err := getAutocompletionSuggestions(input) + if err != nil { + return h.RespondWithError(c, err) + } + + content, err := getDirectoryContent(input) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, DirectorySelectorResponse{ + FullPath: input, + BasePath: filepath.ToSlash(filepath.Dir(input)), + Exists: true, + Suggestions: suggestions, + Content: content, + }) + } + + suggestions, err := getAutocompletionSuggestions(input) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, DirectorySelectorResponse{ + FullPath: input, + BasePath: filepath.ToSlash(filepath.Dir(input)), + Exists: false, + Suggestions: suggestions, + }) +} + +func checkDirectoryExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func getAutocompletionSuggestions(input string) ([]DirectoryInfo, error) { + var suggestions []DirectoryInfo + baseDir := filepath.Dir(input) + prefix := filepath.Base(input) + + entries, err := os.ReadDir(baseDir) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if strings.HasPrefix(strings.ToLower(entry.Name()), strings.ToLower(prefix)) { + suggestions = append(suggestions, DirectoryInfo{ + FullPath: filepath.Join(baseDir, entry.Name()), + FolderName: entry.Name(), + }) + } + } + + return suggestions, nil +} + +func getDirectoryContent(path string) ([]DirectoryInfo, error) { + var content []DirectoryInfo + + entries, err := os.ReadDir(path) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if entry.IsDir() { + content = append(content, DirectoryInfo{ + FullPath: filepath.Join(path, entry.Name()), + FolderName: entry.Name(), + }) + } + } + + return content, nil +} diff --git a/seanime-2.9.10/internal/handlers/directstream.go b/seanime-2.9.10/internal/handlers/directstream.go new file mode 100644 index 0000000..2c45805 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/directstream.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "net/http" + "seanime/internal/database/db_bridge" + "seanime/internal/directstream" + + "github.com/labstack/echo/v4" +) + +// HandleDirectstreamPlayLocalFile +// +// @summary request local file stream. +// @desc This requests a local file stream and returns the media container to start the playback. +// @returns mediastream.MediaContainer +// @route /api/v1/directstream/play/localfile [POST] +func (h *Handler) HandleDirectstreamPlayLocalFile(c echo.Context) error { + type body struct { + Path string `json:"path"` // The path of the file. + ClientId string `json:"clientId"` // The session id + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.App.DirectStreamManager.PlayLocalFile(c.Request().Context(), directstream.PlayLocalFileOptions{ + ClientId: b.ClientId, + Path: b.Path, + LocalFiles: lfs, + }) +} + +func (h *Handler) HandleDirectstreamGetStream() http.Handler { + return h.App.DirectStreamManager.ServeEchoStream() +} + +func (h *Handler) HandleDirectstreamGetAttachments(c echo.Context) error { + return h.App.DirectStreamManager.ServeEchoAttachments(c) +} diff --git a/seanime-2.9.10/internal/handlers/discord.go b/seanime-2.9.10/internal/handlers/discord.go new file mode 100644 index 0000000..01cbdf0 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/discord.go @@ -0,0 +1,144 @@ +package handlers + +import ( + discordrpc_presence "seanime/internal/discordrpc/presence" + + "github.com/labstack/echo/v4" +) + +// HandleSetDiscordMangaActivity +// +// @summary sets manga activity for discord rich presence. +// @route /api/v1/discord/presence/manga [POST] +// @returns bool +func (h *Handler) HandleSetDiscordMangaActivity(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + Title string `json:"title"` + Image string `json:"image"` + Chapter string `json:"chapter"` + } + + var b body + if err := c.Bind(&b); err != nil { + h.App.Logger.Error().Err(err).Msg("discord rpc handler: failed to parse request body") + return h.RespondWithData(c, false) + } + + h.App.DiscordPresence.SetMangaActivity(&discordrpc_presence.MangaActivity{ + ID: b.MediaId, + Title: b.Title, + Image: b.Image, + Chapter: b.Chapter, + }) + + return h.RespondWithData(c, true) +} + +// HandleSetDiscordLegacyAnimeActivity +// +// @summary sets anime activity for discord rich presence. +// @route /api/v1/discord/presence/legacy-anime [POST] +// @returns bool +func (h *Handler) HandleSetDiscordLegacyAnimeActivity(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + Title string `json:"title"` + Image string `json:"image"` + IsMovie bool `json:"isMovie"` + EpisodeNumber int `json:"episodeNumber"` + } + + var b body + if err := c.Bind(&b); err != nil { + h.App.Logger.Error().Err(err).Msg("discord rpc handler: failed to parse request body") + return h.RespondWithData(c, false) + } + + h.App.DiscordPresence.LegacySetAnimeActivity(&discordrpc_presence.LegacyAnimeActivity{ + ID: b.MediaId, + Title: b.Title, + Image: b.Image, + IsMovie: b.IsMovie, + EpisodeNumber: b.EpisodeNumber, + }) + + return h.RespondWithData(c, true) +} + +// HandleSetDiscordAnimeActivityWithProgress +// +// @summary sets anime activity for discord rich presence with progress. +// @route /api/v1/discord/presence/anime [POST] +// @returns bool +func (h *Handler) HandleSetDiscordAnimeActivityWithProgress(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + Title string `json:"title"` + Image string `json:"image"` + IsMovie bool `json:"isMovie"` + EpisodeNumber int `json:"episodeNumber"` + Progress int `json:"progress"` + Duration int `json:"duration"` + TotalEpisodes *int `json:"totalEpisodes,omitempty"` + CurrentEpisodeCount *int `json:"currentEpisodeCount,omitempty"` + EpisodeTitle *string `json:"episodeTitle,omitempty"` + } + + var b body + if err := c.Bind(&b); err != nil { + h.App.Logger.Error().Err(err).Msg("discord rpc handler: failed to parse request body") + return h.RespondWithData(c, false) + } + + h.App.DiscordPresence.SetAnimeActivity(&discordrpc_presence.AnimeActivity{ + ID: b.MediaId, + Title: b.Title, + Image: b.Image, + IsMovie: b.IsMovie, + EpisodeNumber: b.EpisodeNumber, + Progress: b.Progress, + Duration: b.Duration, + TotalEpisodes: b.TotalEpisodes, + CurrentEpisodeCount: b.CurrentEpisodeCount, + EpisodeTitle: b.EpisodeTitle, + }) + + return h.RespondWithData(c, true) +} + +// HandleUpdateDiscordAnimeActivityWithProgress +// +// @summary updates the anime activity for discord rich presence with progress. +// @route /api/v1/discord/presence/anime-update [POST] +// @returns bool +func (h *Handler) HandleUpdateDiscordAnimeActivityWithProgress(c echo.Context) error { + + type body struct { + Progress int `json:"progress"` + Duration int `json:"duration"` + Paused bool `json:"paused"` + } + + var b body + if err := c.Bind(&b); err != nil { + h.App.Logger.Error().Err(err).Msg("discord rpc handler: failed to parse request body") + return h.RespondWithData(c, false) + } + + h.App.DiscordPresence.UpdateAnimeActivity(b.Progress, b.Duration, b.Paused) + return h.RespondWithData(c, true) +} + +// HandleCancelDiscordActivity +// +// @summary cancels the current discord rich presence activity. +// @route /api/v1/discord/presence/cancel [POST] +// @returns bool +func (h *Handler) HandleCancelDiscordActivity(c echo.Context) error { + h.App.DiscordPresence.Close() + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/docs.go b/seanime-2.9.10/internal/handlers/docs.go new file mode 100644 index 0000000..0c3fa12 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/docs.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "os" + "path/filepath" + "strings" + + "github.com/goccy/go-json" + "github.com/labstack/echo/v4" +) + +type ( + ApiDocsGroup struct { + Filename string `json:"filename"` + Name string `json:"name"` + Handlers []*RouteHandler `json:"handlers"` + } + + RouteHandler struct { + Name string `json:"name"` + TrimmedName string `json:"trimmedName"` + Comments []string `json:"comments"` + Filepath string `json:"filepath"` + Filename string `json:"filename"` + Api *RouteHandlerApi `json:"api"` + } + + RouteHandlerApi struct { + Summary string `json:"summary"` + Descriptions []string `json:"descriptions"` + Endpoint string `json:"endpoint"` + Methods []string `json:"methods"` + Params []*RouteHandlerParam `json:"params"` + BodyFields []*RouteHandlerParam `json:"bodyFields"` + Returns string `json:"returns"` + ReturnGoType string `json:"returnGoType"` + ReturnTypescriptType string `json:"returnTypescriptType"` + } + + RouteHandlerParam struct { + Name string `json:"name"` + JsonName string `json:"jsonName"` + GoType string `json:"goType"` // e.g., []models.User + UsedStructType string `json:"usedStructType"` // e.g., models.User + TypescriptType string `json:"typescriptType"` // e.g., Array + Required bool `json:"required"` + Descriptions []string `json:"descriptions"` + } +) + +var cachedDocs []*ApiDocsGroup + +// HandleGetDocs +// +// @summary returns the API documentation +// @route /api/v1/internal/docs [GET] +// @returns []handlers.ApiDocsGroup +func (h *Handler) HandleGetDocs(c echo.Context) error { + + if len(cachedDocs) > 0 { + return h.RespondWithData(c, cachedDocs) + } + + // Read the file + wd, _ := os.Getwd() + buf, err := os.ReadFile(filepath.Join(wd, "codegen/generated/handlers.json")) + if err != nil { + return h.RespondWithError(c, err) + } + + var data []*RouteHandler + // Unmarshal the data + err = json.Unmarshal(buf, &data) + if err != nil { + return h.RespondWithError(c, err) + } + + // Group the data + groups := make(map[string]*ApiDocsGroup) + for _, handler := range data { + group, ok := groups[handler.Filename] + if !ok { + group = &ApiDocsGroup{ + Filename: handler.Filename, + Name: strings.TrimPrefix(handler.Filename, ".go"), + } + groups[handler.Filename] = group + } + group.Handlers = append(group.Handlers, handler) + } + + cachedDocs = make([]*ApiDocsGroup, 0, len(groups)) + for _, group := range groups { + cachedDocs = append(cachedDocs, group) + } + + return h.RespondWithData(c, groups) +} diff --git a/seanime-2.9.10/internal/handlers/download.go b/seanime-2.9.10/internal/handlers/download.go new file mode 100644 index 0000000..2d6473e --- /dev/null +++ b/seanime-2.9.10/internal/handlers/download.go @@ -0,0 +1,130 @@ +package handlers + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/updater" + "seanime/internal/util" + + "github.com/labstack/echo/v4" +) + +// HandleDownloadTorrentFile +// +// @summary downloads torrent files to the destination folder +// @route /api/v1/download-torrent-file [POST] +// @returns bool +func (h *Handler) HandleDownloadTorrentFile(c echo.Context) error { + + type body struct { + DownloadUrls []string `json:"download_urls"` + Destination string `json:"destination"` + Media *anilist.BaseAnime `json:"media"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + errs := make([]error, 0) + for _, url := range b.DownloadUrls { + err := downloadTorrentFile(url, b.Destination) + if err != nil { + errs = append(errs, err) + } + } + + if len(errs) == 1 { + return h.RespondWithError(c, errs[0]) + } else if len(errs) > 1 { + return h.RespondWithError(c, errors.New("failed to download multiple files")) + } + + return h.RespondWithData(c, true) +} + +func downloadTorrentFile(url string, dest string) (err error) { + + defer util.HandlePanicInModuleWithError("handlers/download/downloadTorrentFile", &err) + + // Get the file name from the URL + fileName := filepath.Base(url) + filePath := filepath.Join(dest, fileName) + + // Get the data + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + // Check if the request was successful (status code 200) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download file, %s", resp.Status) + } + + // Create the destination folder if it doesn't exist + err = os.MkdirAll(dest, 0755) + if err != nil { + return err + } + + // Create the file + out, err := os.Create(filePath) + if err != nil { + return err + } + defer out.Close() + + // Write the body to file + _, err = io.Copy(out, resp.Body) + if err != nil { + return err + } + + return nil +} + +type DownloadReleaseResponse struct { + Destination string `json:"destination"` + Error string `json:"error,omitempty"` +} + +// HandleDownloadRelease +// +// @summary downloads selected release asset to the destination folder. +// @desc Downloads the selected release asset to the destination folder and extracts it if possible. +// @desc If the extraction fails, the error message will be returned in the successful response. +// @desc The successful response will contain the destination path of the extracted files. +// @desc It only returns an error if the download fails. +// @route /api/v1/download-release [POST] +// @returns handlers.DownloadReleaseResponse +func (h *Handler) HandleDownloadRelease(c echo.Context) error { + + type body struct { + DownloadUrl string `json:"download_url"` + Destination string `json:"destination"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + path, err := h.App.Updater.DownloadLatestRelease(b.DownloadUrl, b.Destination) + + if err != nil { + if errors.Is(err, updater.ErrExtractionFailed) { + return h.RespondWithData(c, DownloadReleaseResponse{Destination: path, Error: err.Error()}) + } + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, DownloadReleaseResponse{Destination: path}) +} diff --git a/seanime-2.9.10/internal/handlers/events_webview.go b/seanime-2.9.10/internal/handlers/events_webview.go new file mode 100644 index 0000000..07cf356 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/events_webview.go @@ -0,0 +1,12 @@ +package handlers + +import "seanime/internal/events" + +func (h *Handler) HandleClientEvents(event *events.WebsocketClientEvent) { + + //h.App.Logger.Debug().Msgf("ws: message received: %+v", event) + + if h.App.WSEventManager != nil { + h.App.WSEventManager.OnClientEvent(event) + } +} diff --git a/seanime-2.9.10/internal/handlers/explorer.go b/seanime-2.9.10/internal/handlers/explorer.go new file mode 100644 index 0000000..4ac250e --- /dev/null +++ b/seanime-2.9.10/internal/handlers/explorer.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "os" + "runtime" + "seanime/internal/util" + "strings" + + "github.com/labstack/echo/v4" +) + +// HandleOpenInExplorer +// +// @summary opens the given directory in the file explorer. +// @desc It returns 'true' whether the operation was successful or not. +// @route /api/v1/open-in-explorer [POST] +// @returns bool +func (h *Handler) HandleOpenInExplorer(c echo.Context) error { + + type body struct { + Path string `json:"path"` + } + + p := new(body) + if err := c.Bind(p); err != nil { + return h.RespondWithError(c, err) + } + + OpenDirInExplorer(p.Path) + + return h.RespondWithData(c, true) +} + +func OpenDirInExplorer(dir string) { + if dir == "" { + return + } + + cmd := "" + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "explorer" + args = []string{strings.ReplaceAll(strings.ToLower(dir), "/", "\\")} + case "darwin": + cmd = "open" + args = []string{dir} + case "linux": + cmd = "xdg-open" + args = []string{dir} + default: + return + } + cmdObj := util.NewCmd(cmd, args...) + cmdObj.Stdout = os.Stdout + cmdObj.Stderr = os.Stderr + _ = cmdObj.Run() +} diff --git a/seanime-2.9.10/internal/handlers/extensions.go b/seanime-2.9.10/internal/handlers/extensions.go new file mode 100644 index 0000000..d9cb1ba --- /dev/null +++ b/seanime-2.9.10/internal/handlers/extensions.go @@ -0,0 +1,362 @@ +package handlers + +import ( + "fmt" + "net/url" + "seanime/internal/extension" + "seanime/internal/extension_playground" + + "github.com/labstack/echo/v4" +) + +// HandleFetchExternalExtensionData +// +// @summary returns the extension data from the given manifest uri. +// @route /api/v1/extensions/external/fetch [POST] +// @returns extension.Extension +func (h *Handler) HandleFetchExternalExtensionData(c echo.Context) error { + type body struct { + ManifestURI string `json:"manifestUri"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + extension, err := h.App.ExtensionRepository.FetchExternalExtensionData(b.ManifestURI) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, extension) +} + +// HandleInstallExternalExtension +// +// @summary installs the extension from the given manifest uri. +// @route /api/v1/extensions/external/install [POST] +// @returns extension_repo.ExtensionInstallResponse +func (h *Handler) HandleInstallExternalExtension(c echo.Context) error { + type body struct { + ManifestURI string `json:"manifestUri"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + res, err := h.App.ExtensionRepository.InstallExternalExtension(b.ManifestURI) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, res) +} + +// HandleUninstallExternalExtension +// +// @summary uninstalls the extension with the given ID. +// @route /api/v1/extensions/external/uninstall [POST] +// @returns bool +func (h *Handler) HandleUninstallExternalExtension(c echo.Context) error { + type body struct { + ID string `json:"id"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.ExtensionRepository.UninstallExternalExtension(b.ID) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleUpdateExtensionCode +// +// @summary updates the extension code with the given ID and reloads the extensions. +// @route /api/v1/extensions/external/edit-payload [POST] +// @returns bool +func (h *Handler) HandleUpdateExtensionCode(c echo.Context) error { + type body struct { + ID string `json:"id"` + Payload string `json:"payload"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.ExtensionRepository.UpdateExtensionCode(b.ID, b.Payload) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleReloadExternalExtensions +// +// @summary reloads the external extensions. +// @route /api/v1/extensions/external/reload [POST] +// @returns bool +func (h *Handler) HandleReloadExternalExtensions(c echo.Context) error { + h.App.ExtensionRepository.ReloadExternalExtensions() + return h.RespondWithData(c, true) +} + +// HandleReloadExternalExtension +// +// @summary reloads the external extension with the given ID. +// @route /api/v1/extensions/external/reload [POST] +// @returns bool +func (h *Handler) HandleReloadExternalExtension(c echo.Context) error { + type body struct { + ID string `json:"id"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + h.App.ExtensionRepository.ReloadExternalExtension(b.ID) + return h.RespondWithData(c, true) +} + +// HandleListExtensionData +// +// @summary returns the loaded extensions +// @route /api/v1/extensions/list [GET] +// @returns []extension.Extension +func (h *Handler) HandleListExtensionData(c echo.Context) error { + extensions := h.App.ExtensionRepository.ListExtensionData() + return h.RespondWithData(c, extensions) +} + +// HandleGetExtensionPayload +// +// @summary returns the payload of the extension with the given ID. +// @route /api/v1/extensions/payload/{id} [GET] +// @returns string +func (h *Handler) HandleGetExtensionPayload(c echo.Context) error { + payload := h.App.ExtensionRepository.GetExtensionPayload(c.Param("id")) + return h.RespondWithData(c, payload) +} + +// HandleListDevelopmentModeExtensions +// +// @summary returns the development mode extensions +// @route /api/v1/extensions/list/development [GET] +// @returns []extension.Extension +func (h *Handler) HandleListDevelopmentModeExtensions(c echo.Context) error { + extensions := h.App.ExtensionRepository.ListDevelopmentModeExtensions() + return h.RespondWithData(c, extensions) +} + +// HandleGetAllExtensions +// +// @summary returns all loaded and invalid extensions. +// @route /api/v1/extensions/all [POST] +// @returns extension_repo.AllExtensions +func (h *Handler) HandleGetAllExtensions(c echo.Context) error { + type body struct { + WithUpdates bool `json:"withUpdates"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + extensions := h.App.ExtensionRepository.GetAllExtensions(b.WithUpdates) + return h.RespondWithData(c, extensions) +} + +// HandleGetExtensionUpdateData +// +// @summary returns the update data that were found for the extensions. +// @route /api/v1/extensions/updates [GET] +// @returns []extension_repo.UpdateData +func (h *Handler) HandleGetExtensionUpdateData(c echo.Context) error { + return h.RespondWithData(c, h.App.ExtensionRepository.GetUpdateData()) +} + +// HandleListMangaProviderExtensions +// +// @summary returns the installed manga providers. +// @route /api/v1/extensions/list/manga-provider [GET] +// @returns []extension_repo.MangaProviderExtensionItem +func (h *Handler) HandleListMangaProviderExtensions(c echo.Context) error { + extensions := h.App.ExtensionRepository.ListMangaProviderExtensions() + return h.RespondWithData(c, extensions) +} + +// HandleListOnlinestreamProviderExtensions +// +// @summary returns the installed online streaming providers. +// @route /api/v1/extensions/list/onlinestream-provider [GET] +// @returns []extension_repo.OnlinestreamProviderExtensionItem +func (h *Handler) HandleListOnlinestreamProviderExtensions(c echo.Context) error { + extensions := h.App.ExtensionRepository.ListOnlinestreamProviderExtensions() + return h.RespondWithData(c, extensions) +} + +// HandleListAnimeTorrentProviderExtensions +// +// @summary returns the installed torrent providers. +// @route /api/v1/extensions/list/anime-torrent-provider [GET] +// @returns []extension_repo.AnimeTorrentProviderExtensionItem +func (h *Handler) HandleListAnimeTorrentProviderExtensions(c echo.Context) error { + extensions := h.App.ExtensionRepository.ListAnimeTorrentProviderExtensions() + return h.RespondWithData(c, extensions) +} + +// HandleGetPluginSettings +// +// @summary returns the plugin settings. +// @route /api/v1/extensions/plugin-settings [GET] +// @returns extension_repo.StoredPluginSettingsData +func (h *Handler) HandleGetPluginSettings(c echo.Context) error { + settings := h.App.ExtensionRepository.GetPluginSettings() + return h.RespondWithData(c, settings) +} + +// HandleSetPluginSettingsPinnedTrays +// +// @summary sets the pinned trays in the plugin settings. +// @route /api/v1/extensions/plugin-settings/pinned-trays [POST] +// @returns bool +func (h *Handler) HandleSetPluginSettingsPinnedTrays(c echo.Context) error { + type body struct { + PinnedTrayPluginIds []string `json:"pinnedTrayPluginIds"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + h.App.ExtensionRepository.SetPluginSettingsPinnedTrays(b.PinnedTrayPluginIds) + return h.RespondWithData(c, true) +} + +// HandleGrantPluginPermissions +// +// @summary grants the plugin permissions to the extension with the given ID. +// @route /api/v1/extensions/plugin-permissions/grant [POST] +// @returns bool +func (h *Handler) HandleGrantPluginPermissions(c echo.Context) error { + type body struct { + ID string `json:"id"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + h.App.ExtensionRepository.GrantPluginPermissions(b.ID) + return h.RespondWithData(c, true) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// HandleRunExtensionPlaygroundCode +// +// @summary runs the code in the extension playground. +// @desc Returns the logs +// @route /api/v1/extensions/playground/run [POST] +// @returns extension_playground.RunPlaygroundCodeResponse +func (h *Handler) HandleRunExtensionPlaygroundCode(c echo.Context) error { + type body struct { + Params *extension_playground.RunPlaygroundCodeParams `json:"params"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + res, err := h.App.ExtensionPlaygroundRepository.RunPlaygroundCode(b.Params) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, res) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// HandleGetExtensionUserConfig +// +// @summary returns the user config definition and current values for the extension with the given ID. +// @route /api/v1/extensions/user-config/{id} [GET] +// @returns extension_repo.ExtensionUserConfig +func (h *Handler) HandleGetExtensionUserConfig(c echo.Context) error { + id := c.Param("id") + if id == "" { + return h.RespondWithError(c, fmt.Errorf("id is required")) + } + config := h.App.ExtensionRepository.GetExtensionUserConfig(id) + return h.RespondWithData(c, config) +} + +// HandleSaveExtensionUserConfig +// +// @summary saves the user config for the extension with the given ID and reloads it. +// @route /api/v1/extensions/user-config [POST] +// @returns bool +func (h *Handler) HandleSaveExtensionUserConfig(c echo.Context) error { + type body struct { + ID string `json:"id"` // The extension ID + Version int `json:"version"` // The current extension user config definition version + Values map[string]string `json:"values"` // The values + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + config := &extension.SavedUserConfig{ + Version: b.Version, + Values: b.Values, + } + + err := h.App.ExtensionRepository.SaveExtensionUserConfig(b.ID, config) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// HandleGetMarketplaceExtensions +// +// @summary returns the marketplace extensions. +// @route /api/v1/extensions/marketplace [GET] +// @returns []extension.Extension +func (h *Handler) HandleGetMarketplaceExtensions(c echo.Context) error { + encodedMarketplaceUrl := c.QueryParam("marketplace") + marketplaceUrl := "" + + if encodedMarketplaceUrl != "" { + marketplaceUrl, _ = url.PathUnescape(encodedMarketplaceUrl) + } + + extensions, err := h.App.ExtensionRepository.GetMarketplaceExtensions(marketplaceUrl) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, extensions) +} diff --git a/seanime-2.9.10/internal/handlers/filecache.go b/seanime-2.9.10/internal/handlers/filecache.go new file mode 100644 index 0000000..751a106 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/filecache.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "seanime/internal/util" + "strings" + + "github.com/labstack/echo/v4" +) + +// HandleGetFileCacheTotalSize +// +// @summary returns the total size of cache files. +// @desc The total size of the cache files is returned in human-readable format. +// @route /api/v1/filecache/total-size [GET] +// @returns string +func (h *Handler) HandleGetFileCacheTotalSize(c echo.Context) error { + // Get the cache size + size, err := h.App.FileCacher.GetTotalSize() + if err != nil { + return h.RespondWithError(c, err) + } + + // Return the cache size + return h.RespondWithData(c, util.Bytes(uint64(size))) +} + +// HandleRemoveFileCacheBucket +// +// @summary deletes all buckets with the given prefix. +// @desc The bucket value is the prefix of the cache files that should be deleted. +// @desc Returns 'true' if the operation was successful. +// @route /api/v1/filecache/bucket [DELETE] +// @returns bool +func (h *Handler) HandleRemoveFileCacheBucket(c echo.Context) error { + + type body struct { + Bucket string `json:"bucket"` // e.g. "onlinestream_" + } + + // Parse the request body + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + // Remove all files in the cache directory that match the given filter + err := h.App.FileCacher.RemoveAllBy(func(filename string) bool { + return strings.HasPrefix(filename, b.Bucket) + }) + + if err != nil { + return h.RespondWithError(c, err) + } + + // Return a success response + return h.RespondWithData(c, true) +} + +// HandleGetFileCacheMediastreamVideoFilesTotalSize +// +// @summary returns the total size of cached video file data. +// @desc The total size of the cache video file data is returned in human-readable format. +// @route /api/v1/filecache/mediastream/videofiles/total-size [GET] +// @returns string +func (h *Handler) HandleGetFileCacheMediastreamVideoFilesTotalSize(c echo.Context) error { + // Get the cache size + size, err := h.App.FileCacher.GetMediastreamVideoFilesTotalSize() + if err != nil { + return h.RespondWithError(c, err) + } + + // Return the cache size + return h.RespondWithData(c, util.Bytes(uint64(size))) +} + +// HandleClearFileCacheMediastreamVideoFiles +// +// @summary deletes the contents of the mediastream video file cache directory. +// @desc Returns 'true' if the operation was successful. +// @route /api/v1/filecache/mediastream/videofiles [DELETE] +// @returns bool +func (h *Handler) HandleClearFileCacheMediastreamVideoFiles(c echo.Context) error { + + // Clear the attachments + err := h.App.FileCacher.ClearMediastreamVideoFiles() + + if err != nil { + return h.RespondWithError(c, err) + } + + // Clear the transcode dir + h.App.MediastreamRepository.ClearTranscodeDir() + + if h.App.MediastreamRepository != nil { + go h.App.MediastreamRepository.CacheWasCleared() + } + + // Return a success response + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/local.go b/seanime-2.9.10/internal/handlers/local.go new file mode 100644 index 0000000..db2d6b7 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/local.go @@ -0,0 +1,226 @@ +package handlers + +import ( + "seanime/internal/util" + "strconv" + + "github.com/labstack/echo/v4" +) + +// HandleSetOfflineMode +// +// @summary sets the offline mode. +// @desc Returns true if the offline mode is active, false otherwise. +// @route /api/v1/local/offline [POST] +// @returns bool +func (h *Handler) HandleSetOfflineMode(c echo.Context) error { + type body struct { + Enabled bool `json:"enabled"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + h.App.SetOfflineMode(b.Enabled) + return h.RespondWithData(c, b.Enabled) +} + +// HandleLocalGetTrackedMediaItems +// +// @summary gets all tracked media. +// @route /api/v1/local/track [GET] +// @returns []local.TrackedMediaItem +func (h *Handler) HandleLocalGetTrackedMediaItems(c echo.Context) error { + tracked := h.App.LocalManager.GetTrackedMediaItems() + return h.RespondWithData(c, tracked) +} + +// HandleLocalAddTrackedMedia +// +// @summary adds one or multiple media to be tracked for offline sync. +// @route /api/v1/local/track [POST] +// @returns bool +func (h *Handler) HandleLocalAddTrackedMedia(c echo.Context) error { + type body struct { + Media []struct { + MediaId int `json:"mediaId"` + Type string `json:"type"` + } `json:"media"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + var err error + for _, m := range b.Media { + switch m.Type { + case "anime": + err = h.App.LocalManager.TrackAnime(m.MediaId) + case "manga": + err = h.App.LocalManager.TrackManga(m.MediaId) + } + } + + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleLocalRemoveTrackedMedia +// +// @summary remove media from being tracked for offline sync. +// @desc This will remove anime from being tracked for offline sync and delete any associated data. +// @route /api/v1/local/track [DELETE] +// @returns bool +func (h *Handler) HandleLocalRemoveTrackedMedia(c echo.Context) error { + type body struct { + MediaId int `json:"mediaId"` + Type string `json:"type"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + var err error + switch b.Type { + case "anime": + err = h.App.LocalManager.UntrackAnime(b.MediaId) + case "manga": + err = h.App.LocalManager.UntrackManga(b.MediaId) + } + + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleLocalGetIsMediaTracked +// +// @summary checks if media is being tracked for offline sync. +// @route /api/v1/local/track/{id}/{type} [GET] +// @param id - int - true - "AniList anime media ID" +// @param type - string - true - "Type of media (anime/manga)" +// @returns bool +func (h *Handler) HandleLocalGetIsMediaTracked(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, err) + } + + kind := c.Param("type") + tracked := h.App.LocalManager.IsMediaTracked(id, kind) + + return h.RespondWithData(c, tracked) +} + +// HandleLocalSyncData +// +// @summary syncs local data with AniList. +// @route /api/v1/local/local [POST] +// @returns bool +func (h *Handler) HandleLocalSyncData(c echo.Context) error { + // Do not allow syncing if the user is simulated + if h.App.GetUser().IsSimulated { + return h.RespondWithData(c, true) + } + err := h.App.LocalManager.SynchronizeLocal() + if err != nil { + return h.RespondWithError(c, err) + } + + if h.App.Settings.GetLibrary().AutoSaveCurrentMediaOffline { + go func() { + added, _ := h.App.LocalManager.AutoTrackCurrentMedia() + if added { + _ = h.App.LocalManager.SynchronizeLocal() + } + }() + } + + return h.RespondWithData(c, true) +} + +// HandleLocalGetSyncQueueState +// +// @summary gets the current sync queue state. +// @desc This will return the list of media that are currently queued for syncing. +// @route /api/v1/local/queue [GET] +// @returns local.QueueState +func (h *Handler) HandleLocalGetSyncQueueState(c echo.Context) error { + return h.RespondWithData(c, h.App.LocalManager.GetSyncer().GetQueueState()) +} + +// HandleLocalSyncAnilistData +// +// @summary syncs AniList data with local. +// @route /api/v1/local/anilist [POST] +// @returns bool +func (h *Handler) HandleLocalSyncAnilistData(c echo.Context) error { + err := h.App.LocalManager.SynchronizeAnilist() + if err != nil { + return h.RespondWithError(c, err) + } + return h.RespondWithData(c, true) +} + +// HandleLocalSetHasLocalChanges +// +// @summary sets the flag to determine if there are local changes that need to be synced with AniList. +// @route /api/v1/local/updated [POST] +// @returns bool +func (h *Handler) HandleLocalSetHasLocalChanges(c echo.Context) error { + type body struct { + Updated bool `json:"updated"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + h.App.LocalManager.SetHasLocalChanges(b.Updated) + return h.RespondWithData(c, true) +} + +// HandleLocalGetHasLocalChanges +// +// @summary gets the flag to determine if there are local changes that need to be synced with AniList. +// @route /api/v1/local/updated [GET] +// @returns bool +func (h *Handler) HandleLocalGetHasLocalChanges(c echo.Context) error { + updated := h.App.LocalManager.HasLocalChanges() + return h.RespondWithData(c, updated) +} + +// HandleLocalGetLocalStorageSize +// +// @summary gets the size of the local storage in a human-readable format. +// @route /api/v1/local/storage/size [GET] +// @returns string +func (h *Handler) HandleLocalGetLocalStorageSize(c echo.Context) error { + size := h.App.LocalManager.GetLocalStorageSize() + return h.RespondWithData(c, util.Bytes(uint64(size))) +} + +// HandleLocalSyncSimulatedDataToAnilist +// +// @summary syncs the simulated data to AniList. +// @route /api/v1/local/sync-simulated-to-anilist [POST] +// @returns bool +func (h *Handler) HandleLocalSyncSimulatedDataToAnilist(c echo.Context) error { + err := h.App.LocalManager.SynchronizeSimulatedCollectionToAnilist() + if err != nil { + return h.RespondWithError(c, err) + } + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/localfiles.go b/seanime-2.9.10/internal/handlers/localfiles.go new file mode 100644 index 0000000..7627310 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/localfiles.go @@ -0,0 +1,327 @@ +package handlers + +import ( + "errors" + "fmt" + "os" + "seanime/internal/database/db_bridge" + "seanime/internal/library/anime" + "seanime/internal/library/filesystem" + "time" + + "github.com/goccy/go-json" + "github.com/labstack/echo/v4" + "github.com/samber/lo" + "github.com/sourcegraph/conc/pool" +) + +// HandleGetLocalFiles +// +// @summary returns all local files. +// @desc Reminder that local files are scanned from the library path. +// @route /api/v1/library/local-files [GET] +// @returns []anime.LocalFile +func (h *Handler) HandleGetLocalFiles(c echo.Context) error { + + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, lfs) +} + +func (h *Handler) HandleDumpLocalFilesToFile(c echo.Context) error { + + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + filename := fmt.Sprintf("seanime-localfiles-%s.json", time.Now().Format("2006-01-02_15-04-05")) + + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + c.Response().Header().Set("Content-Type", "application/json") + + jsonData, err := json.MarshalIndent(lfs, "", " ") + if err != nil { + return h.RespondWithError(c, err) + } + + return c.Blob(200, "application/json", jsonData) +} + +// HandleImportLocalFiles +// +// @summary imports local files from the given path. +// @desc This will import local files from the given path. +// @desc The response is ignored, the client should refetch the entire library collection and media entry. +// @route /api/v1/library/local-files/import [POST] +func (h *Handler) HandleImportLocalFiles(c echo.Context) error { + type body struct { + DataFilePath string `json:"dataFilePath"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + contentB, err := os.ReadFile(b.DataFilePath) + if err != nil { + return h.RespondWithError(c, err) + } + + var lfs []*anime.LocalFile + if err := json.Unmarshal(contentB, &lfs); err != nil { + return h.RespondWithError(c, err) + } + + if len(lfs) == 0 { + return h.RespondWithError(c, errors.New("no local files found")) + } + + _, err = db_bridge.InsertLocalFiles(h.App.Database, lfs) + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.Database.TrimLocalFileEntries() + + return h.RespondWithData(c, true) +} + +// HandleLocalFileBulkAction +// +// @summary performs an action on all local files. +// @desc This will perform the given action on all local files. +// @desc The response is ignored, the client should refetch the entire library collection and media entry. +// @route /api/v1/library/local-files [POST] +// @returns []anime.LocalFile +func (h *Handler) HandleLocalFileBulkAction(c echo.Context) error { + + type body struct { + Action string `json:"action"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + // Get all the local files + lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + switch b.Action { + case "lock": + for _, lf := range lfs { + // Note: Don't lock local files that are not associated with a media. + // Else refreshing the library will ignore them. + if lf.MediaId != 0 { + lf.Locked = true + } + } + case "unlock": + for _, lf := range lfs { + lf.Locked = false + } + } + + // Save the local files + retLfs, err := db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, retLfs) +} + +// HandleUpdateLocalFileData +// +// @summary updates the local file with the given path. +// @desc This will update the local file with the given path. +// @desc The response is ignored, the client should refetch the entire library collection and media entry. +// @route /api/v1/library/local-file [PATCH] +// @returns []anime.LocalFile +func (h *Handler) HandleUpdateLocalFileData(c echo.Context) error { + + type body struct { + Path string `json:"path"` + Metadata *anime.LocalFileMetadata `json:"metadata"` + Locked bool `json:"locked"` + Ignored bool `json:"ignored"` + MediaId int `json:"mediaId"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + // Get all the local files + lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + lf, found := lo.Find(lfs, func(i *anime.LocalFile) bool { + return i.HasSamePath(b.Path) + }) + if !found { + return h.RespondWithError(c, errors.New("local file not found")) + } + lf.Metadata = b.Metadata + lf.Locked = b.Locked + lf.Ignored = b.Ignored + lf.MediaId = b.MediaId + + // Save the local files + retLfs, err := db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, retLfs) +} + +// HandleUpdateLocalFiles +// +// @summary updates local files with the given paths. +// @desc The client should refetch the entire library collection and media entry. +// @route /api/v1/library/local-files [PATCH] +// @returns bool +func (h *Handler) HandleUpdateLocalFiles(c echo.Context) error { + + type body struct { + Paths []string `json:"paths"` + Action string `json:"action"` + MediaId int `json:"mediaId,omitempty"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + // Get all the local files + lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + // Update the files + for _, path := range b.Paths { + lf, found := lo.Find(lfs, func(i *anime.LocalFile) bool { + return i.HasSamePath(path) + }) + if !found { + continue + } + switch b.Action { + case "lock": + lf.Locked = true + case "unlock": + lf.Locked = false + case "ignore": + lf.MediaId = 0 + lf.Ignored = true + lf.Locked = false + case "unignore": + lf.Ignored = false + lf.Locked = false + case "unmatch": + lf.MediaId = 0 + lf.Locked = false + lf.Ignored = false + case "match": + lf.MediaId = b.MediaId + lf.Locked = true + lf.Ignored = false + } + } + + // Save the local files + _, err = db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleDeleteLocalFiles +// +// @summary deletes local files with the given paths. +// @desc This will delete the local files with the given paths. +// @desc The client should refetch the entire library collection and media entry. +// @route /api/v1/library/local-files [DELETE] +// @returns bool +func (h *Handler) HandleDeleteLocalFiles(c echo.Context) error { + + type body struct { + Paths []string `json:"paths"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + // Get all the local files + lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + // Delete the files + p := pool.New().WithErrors() + for _, path := range b.Paths { + path := path + p.Go(func() error { + err := os.Remove(path) + if err != nil { + return err + } + return nil + }) + } + if err := p.Wait(); err != nil { + return h.RespondWithError(c, err) + } + + // Remove the files from the list + lfs = lo.Filter(lfs, func(i *anime.LocalFile, _ int) bool { + return !lo.Contains(b.Paths, i.Path) + }) + + // Save the local files + _, err = db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleRemoveEmptyDirectories +// +// @summary removes empty directories. +// @desc This will remove empty directories in the library path. +// @route /api/v1/library/empty-directories [DELETE] +// @returns bool +func (h *Handler) HandleRemoveEmptyDirectories(c echo.Context) error { + + libraryPaths, err := h.App.Database.GetAllLibraryPathsFromSettings() + if err != nil { + return h.RespondWithError(c, err) + } + + for _, path := range libraryPaths { + filesystem.RemoveEmptyDirectories(path, h.App.Logger) + } + + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/mal.go b/seanime-2.9.10/internal/handlers/mal.go new file mode 100644 index 0000000..d5f4169 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/mal.go @@ -0,0 +1,159 @@ +package handlers + +import ( + "errors" + "net/http" + "net/url" + "seanime/internal/api/mal" + "seanime/internal/constants" + "seanime/internal/database/models" + "strconv" + "strings" + "time" + + "github.com/goccy/go-json" + "github.com/labstack/echo/v4" +) + +type MalAuthResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int32 `json:"expires_in"` + TokenType string `json:"token_type"` +} + +// HandleMALAuth +// +// @summary fetches the access and refresh tokens for the given code. +// @desc This is used to authenticate the user with MyAnimeList. +// @desc It will save the info in the database, effectively logging the user in. +// @desc The client should re-fetch the server status after this. +// @route /api/v1/mal/auth [POST] +// @returns handlers.MalAuthResponse +func (h *Handler) HandleMALAuth(c echo.Context) error { + + type body struct { + Code string `json:"code"` + State string `json:"state"` + CodeVerifier string `json:"code_verifier"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + client := &http.Client{} + + // Build URL + urlData := url.Values{} + urlData.Set("client_id", constants.MalClientId) + urlData.Set("grant_type", "authorization_code") + urlData.Set("code", b.Code) + urlData.Set("code_verifier", b.CodeVerifier) + encodedData := urlData.Encode() + + req, err := http.NewRequest("POST", "https://myanimelist.net/v1/oauth2/token", strings.NewReader(encodedData)) + if err != nil { + return h.RespondWithError(c, err) + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Content-Length", strconv.Itoa(len(urlData.Encode()))) + + // Response + res, err := client.Do(req) + if err != nil { + return h.RespondWithError(c, err) + } + defer res.Body.Close() + + ret := MalAuthResponse{} + if err := json.NewDecoder(res.Body).Decode(&ret); err != nil { + return h.RespondWithError(c, err) + } + + // Save + malInfo := models.Mal{ + BaseModel: models.BaseModel{ + ID: 1, + UpdatedAt: time.Now(), + }, + Username: "", + AccessToken: ret.AccessToken, + RefreshToken: ret.RefreshToken, + TokenExpiresAt: time.Now().Add(time.Duration(ret.ExpiresIn) * time.Second), + } + + _, err = h.App.Database.UpsertMalInfo(&malInfo) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, ret) +} + +// HandleEditMALListEntryProgress +// +// @summary updates the progress of a MAL list entry. +// @route /api/v1/mal/list-entry/progress [POST] +// @returns bool +func (h *Handler) HandleEditMALListEntryProgress(c echo.Context) error { + + type body struct { + MediaId *int `json:"mediaId"` + Progress *int `json:"progress"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + if b.MediaId == nil || b.Progress == nil { + return h.RespondWithError(c, errors.New("mediaId and progress is required")) + } + + // Get MAL info + _malInfo, err := h.App.Database.GetMalInfo() + if err != nil { + return h.RespondWithError(c, err) + } + + // Verify MAL auth + malInfo, err := mal.VerifyMALAuth(_malInfo, h.App.Database, h.App.Logger) + if err != nil { + return h.RespondWithError(c, err) + } + + // Get MAL Wrapper + malWrapper := mal.NewWrapper(malInfo.AccessToken, h.App.Logger) + + // Update MAL list entry + err = malWrapper.UpdateAnimeProgress(&mal.AnimeListProgressParams{ + NumEpisodesWatched: b.Progress, + }, *b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.Logger.Debug().Msgf("mal: Updated MAL list entry for mediaId %d", *b.MediaId) + + return h.RespondWithData(c, true) +} + +// HandleMALLogout +// +// @summary logs the user out of MyAnimeList. +// @desc This will delete the MAL info from the database, effectively logging the user out. +// @desc The client should re-fetch the server status after this. +// @route /api/v1/mal/logout [POST] +// @returns bool +func (h *Handler) HandleMALLogout(c echo.Context) error { + + err := h.App.Database.DeleteMalInfo() + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/manga.go b/seanime-2.9.10/internal/handlers/manga.go new file mode 100644 index 0000000..2fcd132 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/manga.go @@ -0,0 +1,596 @@ +package handlers + +import ( + "errors" + "net/http" + "net/url" + "seanime/internal/api/anilist" + "seanime/internal/extension" + "seanime/internal/manga" + manga_providers "seanime/internal/manga/providers" + "seanime/internal/util/result" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v4" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var ( + baseMangaCache = result.NewCache[int, *anilist.BaseManga]() + mangaDetailsCache = result.NewCache[int, *anilist.MangaDetailsById_Media]() +) + +// HandleGetAnilistMangaCollection +// +// @summary returns the user's AniList manga collection. +// @route /api/v1/manga/anilist/collection [GET] +// @returns anilist.MangaCollection +func (h *Handler) HandleGetAnilistMangaCollection(c echo.Context) error { + + type body struct { + BypassCache bool `json:"bypassCache"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + collection, err := h.App.GetMangaCollection(b.BypassCache) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, collection) +} + +// HandleGetRawAnilistMangaCollection +// +// @summary returns the user's AniList manga collection. +// @route /api/v1/manga/anilist/collection/raw [GET,POST] +// @returns anilist.MangaCollection +func (h *Handler) HandleGetRawAnilistMangaCollection(c echo.Context) error { + + bypassCache := c.Request().Method == "POST" + + // Get the user's anilist collection + mangaCollection, err := h.App.GetRawMangaCollection(bypassCache) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, mangaCollection) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// HandleGetMangaCollection +// +// @summary returns the user's main manga collection. +// @desc This is an object that contains all the user's manga entries in a structured format. +// @route /api/v1/manga/collection [GET] +// @returns manga.Collection +func (h *Handler) HandleGetMangaCollection(c echo.Context) error { + + animeCollection, err := h.App.GetMangaCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + collection, err := manga.NewCollection(&manga.NewCollectionOptions{ + MangaCollection: animeCollection, + Platform: h.App.AnilistPlatform, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, collection) +} + +// HandleGetMangaEntry +// +// @summary returns a manga entry for the given AniList manga id. +// @desc This is used by the manga media entry pages to get all the data about the anime. It includes metadata and AniList list data. +// @route /api/v1/manga/entry/{id} [GET] +// @param id - int - true - "AniList manga media ID" +// @returns manga.Entry +func (h *Handler) HandleGetMangaEntry(c echo.Context) error { + + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, err) + } + + animeCollection, err := h.App.GetMangaCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + entry, err := manga.NewEntry(c.Request().Context(), &manga.NewEntryOptions{ + MediaId: id, + Logger: h.App.Logger, + FileCacher: h.App.FileCacher, + Platform: h.App.AnilistPlatform, + MangaCollection: animeCollection, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + if entry != nil { + baseMangaCache.SetT(entry.MediaId, entry.Media, 1*time.Hour) + } + + return h.RespondWithData(c, entry) +} + +// HandleGetMangaEntryDetails +// +// @summary returns more details about an AniList manga entry. +// @desc This fetches more fields omitted from the base queries. +// @route /api/v1/manga/entry/{id}/details [GET] +// @param id - int - true - "AniList manga media ID" +// @returns anilist.MangaDetailsById_Media +func (h *Handler) HandleGetMangaEntryDetails(c echo.Context) error { + + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, err) + } + + if detailsMedia, found := mangaDetailsCache.Get(id); found { + return h.RespondWithData(c, detailsMedia) + } + + details, err := h.App.AnilistPlatform.GetMangaDetails(c.Request().Context(), id) + if err != nil { + return h.RespondWithError(c, err) + } + + mangaDetailsCache.SetT(id, details, 1*time.Hour) + + return h.RespondWithData(c, details) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// HandleGetMangaLatestChapterNumbersMap +// +// @summary returns the latest chapter number for all manga entries. +// @route /api/v1/manga/latest-chapter-numbers [GET] +// @returns map[int][]manga.MangaLatestChapterNumberItem +func (h *Handler) HandleGetMangaLatestChapterNumbersMap(c echo.Context) error { + ret, err := h.App.MangaRepository.GetMangaLatestChapterNumbersMap() + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, ret) +} + +// HandleRefetchMangaChapterContainers +// +// @summary refetches the chapter containers for all manga entries previously cached. +// @route /api/v1/manga/refetch-chapter-containers [POST] +// @returns bool +func (h *Handler) HandleRefetchMangaChapterContainers(c echo.Context) error { + + type body struct { + SelectedProviderMap map[int]string `json:"selectedProviderMap"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + mangaCollection, err := h.App.GetMangaCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + err = h.App.MangaRepository.RefreshChapterContainers(mangaCollection, b.SelectedProviderMap) + if err != nil { + return h.RespondWithError(c, err) + } + + return nil +} + +// HandleEmptyMangaEntryCache +// +// @summary empties the cache for a manga entry. +// @desc This will empty the cache for a manga entry (chapter lists and pages), allowing the client to fetch fresh data. +// @desc HandleGetMangaEntryChapters should be called after this to fetch the new chapter list. +// @desc Returns 'true' if the operation was successful. +// @route /api/v1/manga/entry/cache [DELETE] +// @returns bool +func (h *Handler) HandleEmptyMangaEntryCache(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.MangaRepository.EmptyMangaCache(b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleGetMangaEntryChapters +// +// @summary returns the chapters for a manga entry based on the provider. +// @route /api/v1/manga/chapters [POST] +// @returns manga.ChapterContainer +func (h *Handler) HandleGetMangaEntryChapters(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + Provider string `json:"provider"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + var titles []*string + baseManga, found := baseMangaCache.Get(b.MediaId) + if !found { + var err error + baseManga, err = h.App.AnilistPlatform.GetManga(c.Request().Context(), b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + titles = baseManga.GetAllTitles() + baseMangaCache.SetT(b.MediaId, baseManga, 24*time.Hour) + } else { + titles = baseManga.GetAllTitles() + } + + container, err := h.App.MangaRepository.GetMangaChapterContainer(&manga.GetMangaChapterContainerOptions{ + Provider: b.Provider, + MediaId: b.MediaId, + Titles: titles, + Year: baseManga.GetStartYearSafe(), + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, container) +} + +// HandleGetMangaEntryPages +// +// @summary returns the pages for a manga entry based on the provider and chapter id. +// @desc This will return the pages for a manga chapter. +// @desc If the app is offline and the chapter is not downloaded, it will return an error. +// @desc If the app is online and the chapter is not downloaded, it will return the pages from the provider. +// @desc If the chapter is downloaded, it will return the appropriate struct. +// @desc If 'double page' is requested, it will fetch image sizes and include the dimensions in the response. +// @route /api/v1/manga/pages [POST] +// @returns manga.PageContainer +func (h *Handler) HandleGetMangaEntryPages(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + Provider string `json:"provider"` + ChapterId string `json:"chapterId"` + DoublePage bool `json:"doublePage"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + container, err := h.App.MangaRepository.GetMangaPageContainer(b.Provider, b.MediaId, b.ChapterId, b.DoublePage, h.App.IsOffline()) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, container) +} + +// HandleGetMangaEntryDownloadedChapters +// +// @summary returns all download chapters for a manga entry, +// @route /api/v1/manga/downloaded-chapters/{id} [GET] +// @param id - int - true - "AniList manga media ID" +// @returns []manga.ChapterContainer +func (h *Handler) HandleGetMangaEntryDownloadedChapters(c echo.Context) error { + + mId, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, err) + } + + mangaCollection, err := h.App.GetMangaCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + container, err := h.App.MangaRepository.GetDownloadedMangaChapterContainers(mId, mangaCollection) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, container) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +var ( + anilistListMangaCache = result.NewCache[string, *anilist.ListManga]() +) + +// HandleAnilistListManga +// +// @summary returns a list of manga based on the search parameters. +// @desc This is used by "Advanced Search" and search function. +// @route /api/v1/manga/anilist/list [POST] +// @returns anilist.ListManga +func (h *Handler) HandleAnilistListManga(c echo.Context) error { + + type body struct { + Page *int `json:"page,omitempty"` + Search *string `json:"search,omitempty"` + PerPage *int `json:"perPage,omitempty"` + Sort []*anilist.MediaSort `json:"sort,omitempty"` + Status []*anilist.MediaStatus `json:"status,omitempty"` + Genres []*string `json:"genres,omitempty"` + AverageScoreGreater *int `json:"averageScore_greater,omitempty"` + Year *int `json:"year,omitempty"` + CountryOfOrigin *string `json:"countryOfOrigin,omitempty"` + IsAdult *bool `json:"isAdult,omitempty"` + Format *anilist.MediaFormat `json:"format,omitempty"` + } + + p := new(body) + if err := c.Bind(p); err != nil { + return h.RespondWithError(c, err) + } + + if p.Page == nil || p.PerPage == nil { + *p.Page = 1 + *p.PerPage = 20 + } + + isAdult := false + if p.IsAdult != nil { + isAdult = *p.IsAdult && h.App.Settings.GetAnilist().EnableAdultContent + } + + cacheKey := anilist.ListMangaCacheKey( + p.Page, + p.Search, + p.PerPage, + p.Sort, + p.Status, + p.Genres, + p.AverageScoreGreater, + nil, + p.Year, + p.Format, + p.CountryOfOrigin, + &isAdult, + ) + + cached, ok := anilistListMangaCache.Get(cacheKey) + if ok { + return h.RespondWithData(c, cached) + } + + ret, err := anilist.ListMangaM( + p.Page, + p.Search, + p.PerPage, + p.Sort, + p.Status, + p.Genres, + p.AverageScoreGreater, + p.Year, + p.Format, + p.CountryOfOrigin, + &isAdult, + h.App.Logger, + h.App.GetUserAnilistToken(), + ) + if err != nil { + return h.RespondWithError(c, err) + } + + if ret != nil { + anilistListMangaCache.SetT(cacheKey, ret, time.Minute*10) + } + + return h.RespondWithData(c, ret) +} + +// HandleUpdateMangaProgress +// +// @summary updates the progress of a manga entry. +// @desc Note: MyAnimeList is not supported +// @route /api/v1/manga/update-progress [POST] +// @returns bool +func (h *Handler) HandleUpdateMangaProgress(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + MalId int `json:"malId,omitempty"` + ChapterNumber int `json:"chapterNumber"` + TotalChapters int `json:"totalChapters"` + } + + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + // Update the progress on AniList + err := h.App.AnilistPlatform.UpdateEntryProgress( + c.Request().Context(), + b.MediaId, + b.ChapterNumber, + &b.TotalChapters, + ) + if err != nil { + return h.RespondWithError(c, err) + } + + _, _ = h.App.RefreshMangaCollection() // Refresh the AniList collection + + return h.RespondWithData(c, true) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// HandleMangaManualSearch +// +// @summary returns search results for a manual search. +// @desc Returns search results for a manual search. +// @route /api/v1/manga/search [POST] +// @returns []hibikemanga.SearchResult +func (h *Handler) HandleMangaManualSearch(c echo.Context) error { + + type body struct { + Provider string `json:"provider"` + Query string `json:"query"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + ret, err := h.App.MangaRepository.ManualSearch(b.Provider, b.Query) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, ret) +} + +// HandleMangaManualMapping +// +// @summary manually maps a manga entry to a manga ID from the provider. +// @desc This is used to manually map a manga entry to a manga ID from the provider. +// @desc The client should re-fetch the chapter container after this. +// @route /api/v1/manga/manual-mapping [POST] +// @returns bool +func (h *Handler) HandleMangaManualMapping(c echo.Context) error { + + type body struct { + Provider string `json:"provider"` + MediaId int `json:"mediaId"` + MangaId string `json:"mangaId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.MangaRepository.ManualMapping(b.Provider, b.MediaId, b.MangaId) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleGetMangaMapping +// +// @summary returns the mapping for a manga entry. +// @desc This is used to get the mapping for a manga entry. +// @desc An empty string is returned if there's no manual mapping. If there is, the manga ID will be returned. +// @route /api/v1/manga/get-mapping [POST] +// @returns manga.MappingResponse +func (h *Handler) HandleGetMangaMapping(c echo.Context) error { + + type body struct { + Provider string `json:"provider"` + MediaId int `json:"mediaId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + mapping := h.App.MangaRepository.GetMapping(b.Provider, b.MediaId) + return h.RespondWithData(c, mapping) +} + +// HandleRemoveMangaMapping +// +// @summary removes the mapping for a manga entry. +// @desc This is used to remove the mapping for a manga entry. +// @desc The client should re-fetch the chapter container after this. +// @route /api/v1/manga/remove-mapping [POST] +// @returns bool +func (h *Handler) HandleRemoveMangaMapping(c echo.Context) error { + + type body struct { + Provider string `json:"provider"` + MediaId int `json:"mediaId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.MangaRepository.RemoveMapping(b.Provider, b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// HandleGetLocalMangaPage +// +// @summary returns a local manga page. +// @route /api/v1/manga/local-page/{path} [GET] +// @returns manga.PageContainer +func (h *Handler) HandleGetLocalMangaPage(c echo.Context) error { + + path := c.Param("path") + path, err := url.PathUnescape(path) + if err != nil { + return h.RespondWithError(c, err) + } + + path = strings.TrimPrefix(path, manga_providers.LocalServePath) + + providerExtension, ok := extension.GetExtension[extension.MangaProviderExtension](h.App.ExtensionRepository.GetExtensionBank(), manga_providers.LocalProvider) + if !ok { + return h.RespondWithError(c, errors.New("manga: Local provider not found")) + } + + localProvider, ok := providerExtension.GetProvider().(*manga_providers.Local) + if !ok { + return h.RespondWithError(c, errors.New("manga: Local provider not found")) + } + + reader, err := localProvider.ReadPage(path) + if err != nil { + return h.RespondWithError(c, err) + } + + return c.Stream(http.StatusOK, "image/jpeg", reader) +} diff --git a/seanime-2.9.10/internal/handlers/manga_download.go b/seanime-2.9.10/internal/handlers/manga_download.go new file mode 100644 index 0000000..b6fa53d --- /dev/null +++ b/seanime-2.9.10/internal/handlers/manga_download.go @@ -0,0 +1,210 @@ +package handlers + +import ( + "seanime/internal/events" + "seanime/internal/manga" + chapter_downloader "seanime/internal/manga/downloader" + "time" + + "github.com/labstack/echo/v4" +) + +// HandleDownloadMangaChapters +// +// @summary adds chapters to the download queue. +// @route /api/v1/manga/download-chapters [POST] +// @returns bool +func (h *Handler) HandleDownloadMangaChapters(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + Provider string `json:"provider"` + ChapterIds []string `json:"chapterIds"` + StartNow bool `json:"startNow"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + h.App.WSEventManager.SendEvent(events.InfoToast, "Adding chapters to download queue...") + + // Add chapters to the download queue + for _, chapterId := range b.ChapterIds { + err := h.App.MangaDownloader.DownloadChapter(manga.DownloadChapterOptions{ + Provider: b.Provider, + MediaId: b.MediaId, + ChapterId: chapterId, + StartNow: b.StartNow, + }) + if err != nil { + return h.RespondWithError(c, err) + } + time.Sleep(400 * time.Millisecond) // Sleep to avoid rate limiting + } + + return h.RespondWithData(c, true) +} + +// HandleGetMangaDownloadData +// +// @summary returns the download data for a specific media. +// @desc This is used to display information about the downloaded and queued chapters in the UI. +// @desc If the 'cached' parameter is false, it will refresh the data by rescanning the download folder. +// @route /api/v1/manga/download-data [POST] +// @returns manga.MediaDownloadData +func (h *Handler) HandleGetMangaDownloadData(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + Cached bool `json:"cached"` // If false, it will refresh the data + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + data, err := h.App.MangaDownloader.GetMediaDownloads(b.MediaId, b.Cached) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, data) +} + +// HandleGetMangaDownloadQueue +// +// @summary returns the items in the download queue. +// @route /api/v1/manga/download-queue [GET] +// @returns []models.ChapterDownloadQueueItem +func (h *Handler) HandleGetMangaDownloadQueue(c echo.Context) error { + + data, err := h.App.Database.GetChapterDownloadQueue() + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, data) +} + +// HandleStartMangaDownloadQueue +// +// @summary starts the download queue if it's not already running. +// @desc This will start the download queue if it's not already running. +// @desc Returns 'true' whether the queue was started or not. +// @route /api/v1/manga/download-queue/start [POST] +// @returns bool +func (h *Handler) HandleStartMangaDownloadQueue(c echo.Context) error { + + h.App.MangaDownloader.RunChapterDownloadQueue() + + return h.RespondWithData(c, true) +} + +// HandleStopMangaDownloadQueue +// +// @summary stops the manga download queue. +// @desc This will stop the manga download queue. +// @desc Returns 'true' whether the queue was stopped or not. +// @route /api/v1/manga/download-queue/stop [POST] +// @returns bool +func (h *Handler) HandleStopMangaDownloadQueue(c echo.Context) error { + + h.App.MangaDownloader.StopChapterDownloadQueue() + + return h.RespondWithData(c, true) + +} + +// HandleClearAllChapterDownloadQueue +// +// @summary clears all chapters from the download queue. +// @desc This will clear all chapters from the download queue. +// @desc Returns 'true' whether the queue was cleared or not. +// @desc This will also send a websocket event telling the client to refetch the download queue. +// @route /api/v1/manga/download-queue [DELETE] +// @returns bool +func (h *Handler) HandleClearAllChapterDownloadQueue(c echo.Context) error { + + err := h.App.Database.ClearAllChapterDownloadQueueItems() + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.WSEventManager.SendEvent(events.ChapterDownloadQueueUpdated, nil) + + return h.RespondWithData(c, true) +} + +// HandleResetErroredChapterDownloadQueue +// +// @summary resets the errored chapters in the download queue. +// @desc This will reset the errored chapters in the download queue, so they can be re-downloaded. +// @desc Returns 'true' whether the queue was reset or not. +// @desc This will also send a websocket event telling the client to refetch the download queue. +// @route /api/v1/manga/download-queue/reset-errored [POST] +// @returns bool +func (h *Handler) HandleResetErroredChapterDownloadQueue(c echo.Context) error { + + err := h.App.Database.ResetErroredChapterDownloadQueueItems() + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.WSEventManager.SendEvent(events.ChapterDownloadQueueUpdated, nil) + + return h.RespondWithData(c, true) +} + +// HandleDeleteMangaDownloadedChapters +// +// @summary deletes downloaded chapters. +// @desc This will delete downloaded chapters from the filesystem. +// @desc Returns 'true' whether the chapters were deleted or not. +// @desc The client should refetch the download data after this. +// @route /api/v1/manga/download-chapter [DELETE] +// @returns bool +func (h *Handler) HandleDeleteMangaDownloadedChapters(c echo.Context) error { + + type body struct { + DownloadIds []chapter_downloader.DownloadID `json:"downloadIds"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.MangaDownloader.DeleteChapters(b.DownloadIds) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleGetMangaDownloadsList +// +// @summary displays the list of downloaded manga. +// @desc This analyzes the download folder and returns a well-formatted structure for displaying downloaded manga. +// @desc It returns a list of manga.DownloadListItem where the media data might be nil if it's not in the AniList collection. +// @route /api/v1/manga/downloads [GET] +// @returns []manga.DownloadListItem +func (h *Handler) HandleGetMangaDownloadsList(c echo.Context) error { + + mangaCollection, err := h.App.GetMangaCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + res, err := h.App.MangaDownloader.NewDownloadList(&manga.NewDownloadListOptions{ + MangaCollection: mangaCollection, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, res) +} diff --git a/seanime-2.9.10/internal/handlers/manual_dump.go b/seanime-2.9.10/internal/handlers/manual_dump.go new file mode 100644 index 0000000..01a3f8b --- /dev/null +++ b/seanime-2.9.10/internal/handlers/manual_dump.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "seanime/internal/api/anilist" + "seanime/internal/library/scanner" + "seanime/internal/util/limiter" + + "github.com/labstack/echo/v4" +) + +// DUMMY HANDLER + +type RequestBody struct { + Dir string `json:"dir"` + Username string `json:"userName"` +} + +// HandleTestDump +// +// @summary this is a dummy handler for testing purposes. +// @route /api/v1/test-dump [POST] +func (h *Handler) HandleTestDump(c echo.Context) error { + + body := new(RequestBody) + if err := c.Bind(body); err != nil { + return h.RespondWithError(c, err) + } + + localFiles, err := scanner.GetLocalFilesFromDir(body.Dir, h.App.Logger) + if err != nil { + return h.RespondWithError(c, err) + } + + completeAnimeCache := anilist.NewCompleteAnimeCache() + + mc, err := scanner.NewMediaFetcher(c.Request().Context(), &scanner.MediaFetcherOptions{ + Enhanced: false, + Platform: h.App.AnilistPlatform, + MetadataProvider: h.App.MetadataProvider, + LocalFiles: localFiles, + CompleteAnimeCache: completeAnimeCache, + Logger: h.App.Logger, + AnilistRateLimiter: limiter.NewAnilistLimiter(), + DisableAnimeCollection: false, + ScanLogger: nil, + }) + + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, mc.AllMedia) +} diff --git a/seanime-2.9.10/internal/handlers/mediaplayer.go b/seanime-2.9.10/internal/handlers/mediaplayer.go new file mode 100644 index 0000000..69b1e00 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/mediaplayer.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "github.com/labstack/echo/v4" +) + +// HandleStartDefaultMediaPlayer +// +// @summary launches the default media player (vlc or mpc-hc). +// @route /api/v1/media-player/start [POST] +// @returns bool +func (h *Handler) HandleStartDefaultMediaPlayer(c echo.Context) error { + + // Retrieve settings + settings, err := h.App.Database.GetSettings() + if err != nil { + return h.RespondWithError(c, err) + } + + switch settings.MediaPlayer.Default { + case "vlc": + err = h.App.MediaPlayer.VLC.Start() + if err != nil { + return h.RespondWithError(c, err) + } + case "mpc-hc": + err = h.App.MediaPlayer.MpcHc.Start() + if err != nil { + return h.RespondWithError(c, err) + } + } + + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/mediastream.go b/seanime-2.9.10/internal/handlers/mediastream.go new file mode 100644 index 0000000..4bc0b67 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/mediastream.go @@ -0,0 +1,179 @@ +package handlers + +import ( + "errors" + "fmt" + "seanime/internal/database/models" + "seanime/internal/mediastream" + + "github.com/labstack/echo/v4" +) + +// HandleGetMediastreamSettings +// +// @summary get mediastream settings. +// @desc This returns the mediastream settings. +// @returns models.MediastreamSettings +// @route /api/v1/mediastream/settings [GET] +func (h *Handler) HandleGetMediastreamSettings(c echo.Context) error { + mediastreamSettings, found := h.App.Database.GetMediastreamSettings() + if !found { + return h.RespondWithError(c, errors.New("media streaming settings not found")) + } + + return h.RespondWithData(c, mediastreamSettings) +} + +// HandleSaveMediastreamSettings +// +// @summary save mediastream settings. +// @desc This saves the mediastream settings. +// @returns models.MediastreamSettings +// @route /api/v1/mediastream/settings [PATCH] +func (h *Handler) HandleSaveMediastreamSettings(c echo.Context) error { + type body struct { + Settings models.MediastreamSettings `json:"settings"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + settings, err := h.App.Database.UpsertMediastreamSettings(&b.Settings) + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.InitOrRefreshMediastreamSettings() + + return h.RespondWithData(c, settings) +} + +// HandleRequestMediastreamMediaContainer +// +// @summary request media stream. +// @desc This requests a media stream and returns the media container to start the playback. +// @returns mediastream.MediaContainer +// @route /api/v1/mediastream/request [POST] +func (h *Handler) HandleRequestMediastreamMediaContainer(c echo.Context) error { + + type body struct { + Path string `json:"path"` // The path of the file. + StreamType mediastream.StreamType `json:"streamType"` // The type of stream to request. + AudioStreamIndex int `json:"audioStreamIndex"` // The audio stream index to use. (unused) + ClientId string `json:"clientId"` // The session id + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + var mediaContainer *mediastream.MediaContainer + var err error + + switch b.StreamType { + case mediastream.StreamTypeDirect: + mediaContainer, err = h.App.MediastreamRepository.RequestDirectPlay(b.Path, b.ClientId) + case mediastream.StreamTypeTranscode: + mediaContainer, err = h.App.MediastreamRepository.RequestTranscodeStream(b.Path, b.ClientId) + case mediastream.StreamTypeOptimized: + err = fmt.Errorf("stream type %s not implemented", b.StreamType) + //mediaContainer, err = h.App.MediastreamRepository.RequestOptimizedStream(b.Path) + default: + err = fmt.Errorf("stream type %s not implemented", b.StreamType) + } + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, mediaContainer) +} + +// HandlePreloadMediastreamMediaContainer +// +// @summary preloads media stream for playback. +// @desc This preloads a media stream by extracting the media information and attachments. +// @returns bool +// @route /api/v1/mediastream/preload [POST] +func (h *Handler) HandlePreloadMediastreamMediaContainer(c echo.Context) error { + + type body struct { + Path string `json:"path"` // The path of the file. + StreamType mediastream.StreamType `json:"streamType"` // The type of stream to request. + AudioStreamIndex int `json:"audioStreamIndex"` // The audio stream index to use. + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + var err error + + switch b.StreamType { + case mediastream.StreamTypeTranscode: + err = h.App.MediastreamRepository.RequestPreloadTranscodeStream(b.Path) + case mediastream.StreamTypeDirect: + err = h.App.MediastreamRepository.RequestPreloadDirectPlay(b.Path) + default: + err = fmt.Errorf("stream type %s not implemented", b.StreamType) + } + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +func (h *Handler) HandleMediastreamGetSubtitles(c echo.Context) error { + return h.App.MediastreamRepository.ServeEchoExtractedSubtitles(c) +} + +func (h *Handler) HandleMediastreamGetAttachments(c echo.Context) error { + return h.App.MediastreamRepository.ServeEchoExtractedAttachments(c) +} + +// +// Direct +// + +func (h *Handler) HandleMediastreamDirectPlay(c echo.Context) error { + client := "1" + return h.App.MediastreamRepository.ServeEchoDirectPlay(c, client) +} + +// +// Transcode +// + +func (h *Handler) HandleMediastreamTranscode(c echo.Context) error { + client := "1" + return h.App.MediastreamRepository.ServeEchoTranscodeStream(c, client) +} + +// HandleMediastreamShutdownTranscodeStream +// +// @summary shuts down the transcode stream +// @desc This requests the transcoder to shut down. It should be called when unmounting the player (playback is no longer needed). +// @desc This will also send an events.MediastreamShutdownStream event. +// @desc It will not return any error and is safe to call multiple times. +// @returns bool +// @route /api/v1/mediastream/shutdown-transcode [POST] +func (h *Handler) HandleMediastreamShutdownTranscodeStream(c echo.Context) error { + client := "1" + h.App.MediastreamRepository.ShutdownTranscodeStream(client) + return h.RespondWithData(c, true) +} + +// +// Serve file +// + +func (h *Handler) HandleMediastreamFile(c echo.Context) error { + client := "1" + fp := c.QueryParam("path") + libraryPaths := h.App.Settings.GetLibrary().GetLibraryPaths() + return h.App.MediastreamRepository.ServeEchoFile(c, fp, client, libraryPaths) +} diff --git a/seanime-2.9.10/internal/handlers/metadata.go b/seanime-2.9.10/internal/handlers/metadata.go new file mode 100644 index 0000000..8d834e0 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/metadata.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "github.com/labstack/echo/v4" +) + +// HandlePopulateFillerData +// +// @summary fetches and caches filler data for the given media. +// @desc This will fetch and cache filler data for the given media. +// @returns true +// @route /api/v1/metadata-provider/filler [POST] +func (h *Handler) HandlePopulateFillerData(c echo.Context) error { + type body struct { + MediaId int `json:"mediaId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + animeCollection, err := h.App.GetAnimeCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + media, found := animeCollection.FindAnime(b.MediaId) + if !found { + // Fetch media + media, err = h.App.AnilistPlatform.GetAnime(c.Request().Context(), b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + } + + // Fetch filler data + err = h.App.FillerManager.FetchAndStoreFillerData(b.MediaId, media.GetAllTitlesDeref()) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleRemoveFillerData +// +// @summary removes filler data cache. +// @desc This will remove the filler data cache for the given media. +// @returns bool +// @route /api/v1/metadata-provider/filler [DELETE] +func (h *Handler) HandleRemoveFillerData(c echo.Context) error { + type body struct { + MediaId int `json:"mediaId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.FillerManager.RemoveFillerData(b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/nakama.go b/seanime-2.9.10/internal/handlers/nakama.go new file mode 100644 index 0000000..8ed769c --- /dev/null +++ b/seanime-2.9.10/internal/handlers/nakama.go @@ -0,0 +1,783 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "seanime/internal/database/db_bridge" + "seanime/internal/library/anime" + "seanime/internal/nakama" + "seanime/internal/util" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v4" + "github.com/samber/lo" +) + +// HandleNakamaWebSocket handles WebSocket connections for Nakama peers +// +// @summary handles WebSocket connections for Nakama peers. +// @desc This endpoint handles WebSocket connections from Nakama peers when this instance is acting as a host. +// @route /api/v1/nakama/ws [GET] +func (h *Handler) HandleNakamaWebSocket(c echo.Context) error { + // Use the standard library HTTP ResponseWriter and Request + w := c.Response().Writer + r := c.Request() + + // Let the Nakama manager handle the WebSocket connection + h.App.NakamaManager.HandlePeerConnection(w, r) + return nil +} + +// HandleSendNakamaMessage +// +// @summary sends a custom message through Nakama. +// @desc This allows sending custom messages to connected peers or the host. +// @route /api/v1/nakama/message [POST] +// @returns nakama.MessageResponse +func (h *Handler) HandleSendNakamaMessage(c echo.Context) error { + type body struct { + MessageType string `json:"messageType"` + Payload interface{} `json:"payload"` + PeerID string `json:"peerId,omitempty"` // If specified, send to specific peer (host only) + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + var err error + if b.PeerID != "" && h.App.Settings.GetNakama().IsHost { + // Send to specific peer + err = h.App.NakamaManager.SendMessageToPeer(b.PeerID, nakama.MessageType(b.MessageType), b.Payload) + } else if h.App.Settings.GetNakama().IsHost { + // Send to all peers + err = h.App.NakamaManager.SendMessage(nakama.MessageType(b.MessageType), b.Payload) + } else { + // Send to host + err = h.App.NakamaManager.SendMessageToHost(nakama.MessageType(b.MessageType), b.Payload) + } + + if err != nil { + return h.RespondWithError(c, err) + } + + response := &nakama.MessageResponse{ + Success: true, + Message: "Message sent successfully", + } + + return h.RespondWithData(c, response) +} + +// HandleGetNakamaAnimeLibrary +// +// @summary shares the local anime collection with Nakama clients. +// @desc This creates a new LibraryCollection struct and returns it. +// @desc This is used to share the local anime collection with Nakama clients. +// @route /api/v1/nakama/host/anime/library/collection [GET] +// @returns nakama.NakamaAnimeLibrary +func (h *Handler) HandleGetNakamaAnimeLibrary(c echo.Context) error { + if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary { + return h.RespondWithError(c, errors.New("host is not sharing its anime library")) + } + + animeCollection, err := h.App.GetAnimeCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + if animeCollection == nil { + return h.RespondWithData(c, &anime.LibraryCollection{}) + } + + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds + unsharedAnimeIdsMap := make(map[int]struct{}) + unsharedAnimeIdsMap[0] = struct{}{} // Do not share unmatched files + for _, id := range unsharedAnimeIds { + unsharedAnimeIdsMap[id] = struct{}{} + } + lfs = lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool { + _, ok := unsharedAnimeIdsMap[lf.MediaId] + return !ok + }) + + libraryCollection, err := anime.NewLibraryCollection(c.Request().Context(), &anime.NewLibraryCollectionOptions{ + AnimeCollection: animeCollection, + Platform: h.App.AnilistPlatform, + LocalFiles: lfs, + MetadataProvider: h.App.MetadataProvider, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + // Hydrate total library size + if libraryCollection != nil && libraryCollection.Stats != nil { + libraryCollection.Stats.TotalSize = util.Bytes(h.App.TotalLibrarySize) + } + + return h.RespondWithData(c, &nakama.NakamaAnimeLibrary{ + LocalFiles: lfs, + AnimeCollection: animeCollection, + }) +} + +// HandleGetNakamaAnimeLibraryCollection +// +// @summary shares the local anime collection with Nakama clients. +// @desc This creates a new LibraryCollection struct and returns it. +// @desc This is used to share the local anime collection with Nakama clients. +// @route /api/v1/nakama/host/anime/library/collection [GET] +// @returns anime.LibraryCollection +func (h *Handler) HandleGetNakamaAnimeLibraryCollection(c echo.Context) error { + if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary { + return h.RespondWithError(c, errors.New("host is not sharing its anime library")) + } + + animeCollection, err := h.App.GetAnimeCollection(false) + if err != nil { + return h.RespondWithError(c, err) + } + + if animeCollection == nil { + return h.RespondWithData(c, &anime.LibraryCollection{}) + } + + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds + unsharedAnimeIdsMap := make(map[int]struct{}) + unsharedAnimeIdsMap[0] = struct{}{} + for _, id := range unsharedAnimeIds { + unsharedAnimeIdsMap[id] = struct{}{} + } + lfs = lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool { + _, ok := unsharedAnimeIdsMap[lf.MediaId] + return !ok + }) + + libraryCollection, err := anime.NewLibraryCollection(c.Request().Context(), &anime.NewLibraryCollectionOptions{ + AnimeCollection: animeCollection, + Platform: h.App.AnilistPlatform, + LocalFiles: lfs, + MetadataProvider: h.App.MetadataProvider, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + // Hydrate total library size + if libraryCollection != nil && libraryCollection.Stats != nil { + libraryCollection.Stats.TotalSize = util.Bytes(h.App.TotalLibrarySize) + } + + return h.RespondWithData(c, libraryCollection) +} + +// HandleGetNakamaAnimeLibraryFiles +// +// @summary return the local files for the given AniList anime media id. +// @desc This is used by the anime media entry pages to get all the data about the anime. +// @route /api/v1/nakama/host/anime/library/files/{id} [POST] +// @param id - int - true - "AniList anime media ID" +// @returns []anime.LocalFile +func (h *Handler) HandleGetNakamaAnimeLibraryFiles(c echo.Context) error { + if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary { + return h.RespondWithError(c, errors.New("host is not sharing its anime library")) + } + + mId, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, err) + } + + // Get all the local files + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds + unsharedAnimeIdsMap := make(map[int]struct{}) + unsharedAnimeIdsMap[0] = struct{}{} + for _, id := range unsharedAnimeIds { + unsharedAnimeIdsMap[id] = struct{}{} + } + + retLfs := lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool { + if _, ok := unsharedAnimeIdsMap[lf.MediaId]; ok { + return false + } + return lf.MediaId == mId + }) + + return h.RespondWithData(c, retLfs) +} + +// HandleGetNakamaAnimeAllLibraryFiles +// +// @summary return all the local files for the host. +// @desc This is used to share the local anime collection with Nakama clients. +// @route /api/v1/nakama/host/anime/library/files [POST] +// @returns []anime.LocalFile +func (h *Handler) HandleGetNakamaAnimeAllLibraryFiles(c echo.Context) error { + if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary { + return h.RespondWithError(c, errors.New("host is not sharing its anime library")) + } + + // Get all the local files + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds + unsharedAnimeIdsMap := make(map[int]struct{}) + unsharedAnimeIdsMap[0] = struct{}{} + for _, id := range unsharedAnimeIds { + unsharedAnimeIdsMap[id] = struct{}{} + } + lfs = lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool { + _, ok := unsharedAnimeIdsMap[lf.MediaId] + return !ok + }) + + return h.RespondWithData(c, lfs) +} + +// HandleNakamaPlayVideo +// +// @summary plays the media from the host. +// @route /api/v1/nakama/play [POST] +// @returns bool +func (h *Handler) HandleNakamaPlayVideo(c echo.Context) error { + type body struct { + Path string `json:"path"` + MediaId int `json:"mediaId"` + AniDBEpisode string `json:"anidbEpisode"` + } + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + if !h.App.NakamaManager.IsConnectedToHost() { + return h.RespondWithError(c, errors.New("not connected to host")) + } + + media, err := h.App.AnilistPlatform.GetAnime(c.Request().Context(), b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + + err = h.App.NakamaManager.PlayHostAnimeLibraryFile(b.Path, c.Request().Header.Get("User-Agent"), media, b.AniDBEpisode) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// Note: This is not used anymore. Each peer will independently stream the torrent. +// route /api/v1/nakama/host/torrentstream/stream +// Allows peers to stream the currently playing torrent. +func (h *Handler) HandleNakamaHostTorrentstreamServeStream(c echo.Context) error { + h.App.TorrentstreamRepository.HTTPStreamHandler().ServeHTTP(c.Response().Writer, c.Request()) + return nil +} + +var videoProxyClient = &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + ForceAttemptHTTP2: false, // Fixes issues on Linux + }, + Timeout: 60 * time.Second, +} + +// route /api/v1/nakama/host/debridstream/stream +// Allows peers to stream the currently playing torrent. +func (h *Handler) HandleNakamaHostDebridstreamServeStream(c echo.Context) error { + streamUrl, ok := h.App.DebridClientRepository.GetStreamURL() + if !ok { + return echo.NewHTTPError(http.StatusNotFound, "no stream url") + } + + // Proxy the stream to the peer + // The debrid stream URL directly comes from the debrid service + req, err := http.NewRequest(c.Request().Method, streamUrl, c.Request().Body) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request") + } + + // Copy original request headers to the proxied request + for key, values := range c.Request().Header { + for _, value := range values { + req.Header.Add(key, value) + } + } + + resp, err := videoProxyClient.Do(req) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to proxy request") + } + defer resp.Body.Close() + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + c.Response().Header().Add(key, value) + } + } + + // Set the status code + c.Response().WriteHeader(resp.StatusCode) + + // Stream the response body + _, err = io.Copy(c.Response().Writer, resp.Body) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to stream response body") + } + return nil +} + +// route /api/v1/nakama/host/debridstream/url +// Returns the debrid stream URL for direct access by peers to avoid host bandwidth usage +func (h *Handler) HandleNakamaHostGetDebridstreamURL(c echo.Context) error { + streamUrl, ok := h.App.DebridClientRepository.GetStreamURL() + if !ok { + return echo.NewHTTPError(http.StatusNotFound, "no stream url") + } + + return h.RespondWithData(c, map[string]string{ + "streamUrl": streamUrl, + }) +} + +// route /api/v1/nakama/host/anime/library/stream?path={base64_encoded_path} +func (h *Handler) HandleNakamaHostAnimeLibraryServeStream(c echo.Context) error { + filepath := c.QueryParam("path") + decodedPath, err := base64.StdEncoding.DecodeString(filepath) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid path") + } + + h.App.Logger.Info().Msgf("nakama: Serving anime library file: %s", string(decodedPath)) + + // Make sure file is in library + isInLibrary := false + libraryPaths := h.App.Settings.GetLibrary().GetLibraryPaths() + for _, libraryPath := range libraryPaths { + if util.IsFileUnderDir(string(decodedPath), libraryPath) { + isInLibrary = true + break + } + } + + if !isInLibrary { + return echo.NewHTTPError(http.StatusNotFound, "file not in library") + } + + return c.File(string(decodedPath)) +} + +// route /api/v1/nakama/stream +// Proxies stream requests to the host. It inserts the Nakama password in the headers. +// It checks if the password is valid. +// For debrid streams, it redirects directly to the debrid service to avoid host bandwidth usage. +func (h *Handler) HandleNakamaProxyStream(c echo.Context) error { + + streamType := c.QueryParam("type") // "file", "torrent", "debrid" + if streamType == "" { + return echo.NewHTTPError(http.StatusBadRequest, "type is required") + } + + hostServerUrl := h.App.Settings.GetNakama().RemoteServerURL + hostServerUrl = strings.TrimSuffix(hostServerUrl, "/") + + if streamType == "debrid" { + // Get the debrid stream URL from the host + urlEndpoint := hostServerUrl + "/api/v1/nakama/host/debridstream/url" + + req, err := http.NewRequest(http.MethodGet, urlEndpoint, nil) + if err != nil { + h.App.Logger.Error().Err(err).Str("url", urlEndpoint).Msg("nakama: Failed to create debrid URL request") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request") + } + + // Add Nakama password for authentication + req.Header.Set("X-Seanime-Nakama-Token", h.App.Settings.GetNakama().RemoteServerPassword) + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + h.App.Logger.Error().Err(err).Str("url", urlEndpoint).Msg("nakama: Failed to get debrid stream URL") + return echo.NewHTTPError(http.StatusBadGateway, "failed to get stream URL") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", urlEndpoint).Msg("nakama: Failed to get debrid stream URL") + return echo.NewHTTPError(resp.StatusCode, "failed to get stream URL") + } + + // Parse the response to get the stream URL + type urlResponse struct { + Data struct { + StreamUrl string `json:"streamUrl"` + } `json:"data"` + } + + var urlResp urlResponse + body, err := io.ReadAll(resp.Body) + if err != nil { + h.App.Logger.Error().Err(err).Msg("nakama: Failed to read debrid URL response") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to read response") + } + + if err := json.Unmarshal(body, &urlResp); err != nil { + h.App.Logger.Error().Err(err).Msg("nakama: Failed to parse debrid URL response") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse response") + } + + if urlResp.Data.StreamUrl == "" { + h.App.Logger.Error().Msg("nakama: Empty debrid stream URL") + return echo.NewHTTPError(http.StatusNotFound, "no stream URL available") + } + + req, err = http.NewRequest(c.Request().Method, urlResp.Data.StreamUrl, c.Request().Body) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request") + } + + // Copy original request headers to the proxied request + for key, values := range c.Request().Header { + for _, value := range values { + req.Header.Add(key, value) + } + } + + resp, err = videoProxyClient.Do(req) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to proxy request") + } + defer resp.Body.Close() + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + c.Response().Header().Add(key, value) + } + } + + // Set the status code + c.Response().WriteHeader(resp.StatusCode) + + // Stream the response body + _, err = io.Copy(c.Response().Writer, resp.Body) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to stream response body") + } + return nil + } + + requestUrl := "" + switch streamType { + case "file": + // Path should be base64 encoded + filepath := c.QueryParam("path") + if filepath == "" { + return echo.NewHTTPError(http.StatusBadRequest, "path is required") + } + requestUrl = hostServerUrl + "/api/v1/nakama/host/anime/library/stream?path=" + filepath + case "torrent": + requestUrl = hostServerUrl + "/api/v1/nakama/host/torrentstream/stream" + default: + return echo.NewHTTPError(http.StatusBadRequest, "invalid type") + } + + client := &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 2, + IdleConnTimeout: 30 * time.Second, + DisableKeepAlives: true, // Disable keep-alive to prevent connection reuse issues + ForceAttemptHTTP2: false, + }, + Timeout: 120 * time.Second, + } + + if c.Request().Method == http.MethodHead { + req, err := http.NewRequest(http.MethodHead, requestUrl, nil) + if err != nil { + h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: Failed to create HEAD request") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request") + } + + // Add Nakama password for authentication + req.Header.Set("X-Seanime-Nakama-Token", h.App.Settings.GetNakama().RemoteServerPassword) + + // Add User-Agent from original request + if userAgent := c.Request().Header.Get("User-Agent"); userAgent != "" { + req.Header.Set("User-Agent", userAgent) + } + + resp, err := client.Do(req) + if err != nil { + h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: Failed to proxy HEAD request") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to proxy request") + } + defer resp.Body.Close() + + // Log authentication failures + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", requestUrl).Msg("nakama: Authentication failed - check password configuration") + } + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + c.Response().Header().Add(key, value) + } + } + + return c.NoContent(resp.StatusCode) + } + + // Create request with timeout context + ctx := c.Request().Context() + req, err := http.NewRequestWithContext(ctx, c.Request().Method, requestUrl, c.Request().Body) + if err != nil { + h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: Failed to create request") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request") + } + + // Copy request headers but skip problematic ones + for key, values := range c.Request().Header { + // Skip headers that should not be forwarded or might cause errors + if key == "Host" || key == "Content-Length" || key == "Connection" || + key == "Transfer-Encoding" || key == "Accept-Encoding" || + key == "Upgrade" || key == "Proxy-Connection" || + strings.HasPrefix(key, "Sec-") { // Skip WebSocket and security headers + continue + } + for _, value := range values { + req.Header.Add(key, value) + } + } + + req.Header.Set("Accept", "*/*") + // req.Header.Set("Accept-Encoding", "identity") // Disable compression to avoid issues + + // Add Nakama password for authentication + req.Header.Set("X-Seanime-Nakama-Token", h.App.Settings.GetNakama().RemoteServerPassword) + + h.App.Logger.Debug().Str("url", requestUrl).Str("method", c.Request().Method).Msg("nakama: Proxying request") + + // Add retry mechanism for intermittent network issues + var resp *http.Response + maxRetries := 3 + for attempt := 0; attempt < maxRetries; attempt++ { + resp, err = client.Do(req) + if err == nil { + break + } + + if attempt < maxRetries-1 { + h.App.Logger.Warn().Err(err).Int("attempt", attempt+1).Str("url", requestUrl).Msg("nakama: request failed, retrying") + time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) // Exponential backoff + continue + } + + h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: failed to proxy request after retries") + return echo.NewHTTPError(http.StatusBadGateway, "failed to proxy request after retries") + } + defer resp.Body.Close() + + // Log authentication failures with more detail + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", requestUrl).Msg("nakama: authentication failed - verify RemoteServerPassword matches host's HostPassword") + } + + // Log and handle 406 Not Acceptable errors + if resp.StatusCode == http.StatusNotAcceptable { + h.App.Logger.Error().Int("status", resp.StatusCode).Str("url", requestUrl).Str("content-type", resp.Header.Get("Content-Type")).Msg("nakama: 406 Not Acceptable - content negotiation failed") + } + + // Handle range request errors + if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable { + h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", requestUrl).Str("range", c.Request().Header.Get("Range")).Msg("nakama: range request not satisfiable") + } + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + c.Response().Header().Add(key, value) + } + } + + // Set the status code + c.Response().WriteHeader(resp.StatusCode) + + // Stream the response body with better error handling + bytesWritten, err := io.Copy(c.Response().Writer, resp.Body) + if err != nil { + // Check if it's a network-related error + if strings.Contains(err.Error(), "connection") || strings.Contains(err.Error(), "broken pipe") || + strings.Contains(err.Error(), "wsasend") || strings.Contains(err.Error(), "reset by peer") { + h.App.Logger.Warn().Err(err).Int64("bytes_written", bytesWritten).Str("url", requestUrl).Msg("nakama: network connection error during streaming") + } else { + h.App.Logger.Error().Err(err).Int64("bytes_written", bytesWritten).Str("url", requestUrl).Msg("nakama: error streaming response body") + } + // Don't return error here as response has already started + } else { + h.App.Logger.Debug().Int64("bytes_written", bytesWritten).Str("url", requestUrl).Msg("nakama: successfully streamed response") + } + return nil +} + +// HandleNakamaReconnectToHost +// +// @summary reconnects to the Nakama host. +// @desc This attempts to reconnect to the configured Nakama host if the connection was lost. +// @route /api/v1/nakama/reconnect [POST] +// @returns nakama.MessageResponse +func (h *Handler) HandleNakamaReconnectToHost(c echo.Context) error { + err := h.App.NakamaManager.ReconnectToHost() + if err != nil { + return h.RespondWithError(c, err) + } + + response := &nakama.MessageResponse{ + Success: true, + Message: "Reconnection initiated", + } + + return h.RespondWithData(c, response) +} + +// HandleNakamaRemoveStaleConnections +// +// @summary removes stale peer connections. +// @desc This removes peer connections that haven't responded to ping messages for a while. +// @route /api/v1/nakama/cleanup [POST] +// @returns nakama.MessageResponse +func (h *Handler) HandleNakamaRemoveStaleConnections(c echo.Context) error { + if !h.App.Settings.GetNakama().IsHost { + return h.RespondWithError(c, errors.New("not acting as host")) + } + + h.App.NakamaManager.RemoveStaleConnections() + + response := &nakama.MessageResponse{ + Success: true, + Message: "Stale connections cleaned up", + } + + return h.RespondWithData(c, response) +} + +// HandleNakamaCreateWatchParty +// +// @summary creates a new watch party session. +// @desc This creates a new watch party that peers can join to watch content together in sync. +// @route /api/v1/nakama/watch-party/create [POST] +// @returns bool +func (h *Handler) HandleNakamaCreateWatchParty(c echo.Context) error { + type body struct { + Settings *nakama.WatchPartySessionSettings `json:"settings"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + if !h.App.Settings.GetNakama().IsHost { + return h.RespondWithError(c, errors.New("only hosts can create watch parties")) + } + + // Set default settings if not provided + if b.Settings == nil { + b.Settings = &nakama.WatchPartySessionSettings{ + SyncThreshold: 2.0, + MaxBufferWaitTime: 10, + } + } + + _, err := h.App.NakamaManager.GetWatchPartyManager().CreateWatchParty(&nakama.CreateWatchOptions{ + Settings: b.Settings, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleNakamaJoinWatchParty +// +// @summary joins an existing watch party. +// @desc This allows a peer to join an active watch party session. +// @route /api/v1/nakama/watch-party/join [POST] +// @returns bool +func (h *Handler) HandleNakamaJoinWatchParty(c echo.Context) error { + if h.App.Settings.GetNakama().IsHost { + return h.RespondWithError(c, errors.New("hosts cannot join watch parties")) + } + + if !h.App.NakamaManager.IsConnectedToHost() { + return h.RespondWithError(c, errors.New("not connected to host")) + } + + // Send join request to host + err := h.App.NakamaManager.GetWatchPartyManager().JoinWatchParty() + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleNakamaLeaveWatchParty +// +// @summary leaves the current watch party. +// @desc This removes the user from the active watch party session. +// @route /api/v1/nakama/watch-party/leave [POST] +// @returns bool +func (h *Handler) HandleNakamaLeaveWatchParty(c echo.Context) error { + if h.App.Settings.GetNakama().IsHost { + // Host stopping the watch party + h.App.NakamaManager.GetWatchPartyManager().StopWatchParty() + } else { + // Peer leaving the watch party + if !h.App.NakamaManager.IsConnectedToHost() { + return h.RespondWithError(c, errors.New("not connected to host")) + } + + err := h.App.NakamaManager.GetWatchPartyManager().LeaveWatchParty() + if err != nil { + return h.RespondWithError(c, err) + } + } + + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/onlinestream.go b/seanime-2.9.10/internal/handlers/onlinestream.go new file mode 100644 index 0000000..adfa23b --- /dev/null +++ b/seanime-2.9.10/internal/handlers/onlinestream.go @@ -0,0 +1,228 @@ +package handlers + +import ( + "errors" + "seanime/internal/api/anilist" + "seanime/internal/onlinestream" + + "github.com/labstack/echo/v4" +) + +// HandleGetOnlineStreamEpisodeList +// +// @summary returns the episode list for the given media and provider. +// @desc It returns the episode list for the given media and provider. +// @desc The episodes are cached using a file cache. +// @desc The episode list is just a list of episodes with no video sources, it's what the client uses to display the episodes and subsequently fetch the sources. +// @desc The episode list might be nil or empty if nothing could be found, but the media will always be returned. +// @route /api/v1/onlinestream/episode-list [POST] +// @returns onlinestream.EpisodeListResponse +func (h *Handler) HandleGetOnlineStreamEpisodeList(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + Dubbed bool `json:"dubbed"` + Provider string `json:"provider,omitempty"` // Can be empty since we still have the media id + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + if h.App.Settings == nil || !h.App.Settings.GetLibrary().EnableOnlinestream { + return h.RespondWithError(c, errors.New("enable online streaming in the settings")) + } + + // Get media + // This is cached + media, err := h.App.OnlinestreamRepository.GetMedia(c.Request().Context(), b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + + if media.Status == nil || *media.Status == anilist.MediaStatusNotYetReleased { + return h.RespondWithError(c, errors.New("unavailable")) + } + + // Get episode list + // This is cached using file cache + episodes, err := h.App.OnlinestreamRepository.GetMediaEpisodes(b.Provider, media, b.Dubbed) + //if err != nil { + // return h.RespondWithError(c, err) + //} + + ret := onlinestream.EpisodeListResponse{ + Episodes: episodes, + Media: media, + } + + h.App.FillerManager.HydrateOnlinestreamFillerData(b.MediaId, ret.Episodes) + + return h.RespondWithData(c, ret) +} + +// HandleGetOnlineStreamEpisodeSource +// +// @summary returns the video sources for the given media, episode number and provider. +// @route /api/v1/onlinestream/episode-source [POST] +// @returns onlinestream.EpisodeSource +func (h *Handler) HandleGetOnlineStreamEpisodeSource(c echo.Context) error { + + type body struct { + EpisodeNumber int `json:"episodeNumber"` + MediaId int `json:"mediaId"` + Provider string `json:"provider"` + Dubbed bool `json:"dubbed"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + // Get media + // This is cached + media, err := h.App.OnlinestreamRepository.GetMedia(c.Request().Context(), b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + + sources, err := h.App.OnlinestreamRepository.GetEpisodeSources(c.Request().Context(), b.Provider, b.MediaId, b.EpisodeNumber, b.Dubbed, media.GetStartYearSafe()) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, sources) +} + +// HandleOnlineStreamEmptyCache +// +// @summary empties the cache for the given media. +// @route /api/v1/onlinestream/cache [DELETE] +// @returns bool +func (h *Handler) HandleOnlineStreamEmptyCache(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.OnlinestreamRepository.EmptyCache(b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// HandleOnlinestreamManualSearch +// +// @summary returns search results for a manual search. +// @desc Returns search results for a manual search. +// @route /api/v1/onlinestream/search [POST] +// @returns []hibikeonlinestream.SearchResult +func (h *Handler) HandleOnlinestreamManualSearch(c echo.Context) error { + + type body struct { + Provider string `json:"provider"` + Query string `json:"query"` + Dubbed bool `json:"dubbed"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + ret, err := h.App.OnlinestreamRepository.ManualSearch(b.Provider, b.Query, b.Dubbed) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, ret) +} + +// HandleOnlinestreamManualMapping +// +// @summary manually maps an anime entry to an anime ID from the provider. +// @desc This is used to manually map an anime entry to an anime ID from the provider. +// @desc The client should re-fetch the chapter container after this. +// @route /api/v1/onlinestream/manual-mapping [POST] +// @returns bool +func (h *Handler) HandleOnlinestreamManualMapping(c echo.Context) error { + + type body struct { + Provider string `json:"provider"` + MediaId int `json:"mediaId"` + AnimeId string `json:"animeId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.OnlinestreamRepository.ManualMapping(b.Provider, b.MediaId, b.AnimeId) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleGetOnlinestreamMapping +// +// @summary returns the mapping for an anime entry. +// @desc This is used to get the mapping for an anime entry. +// @desc An empty string is returned if there's no manual mapping. If there is, the anime ID will be returned. +// @route /api/v1/onlinestream/get-mapping [POST] +// @returns onlinestream.MappingResponse +func (h *Handler) HandleGetOnlinestreamMapping(c echo.Context) error { + + type body struct { + Provider string `json:"provider"` + MediaId int `json:"mediaId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + mapping := h.App.OnlinestreamRepository.GetMapping(b.Provider, b.MediaId) + return h.RespondWithData(c, mapping) +} + +// HandleRemoveOnlinestreamMapping +// +// @summary removes the mapping for an anime entry. +// @desc This is used to remove the mapping for an anime entry. +// @desc The client should re-fetch the chapter container after this. +// @route /api/v1/onlinestream/remove-mapping [POST] +// @returns bool +func (h *Handler) HandleRemoveOnlinestreamMapping(c echo.Context) error { + + type body struct { + Provider string `json:"provider"` + MediaId int `json:"mediaId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.OnlinestreamRepository.RemoveMapping(b.Provider, b.MediaId) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/playback_manager.go b/seanime-2.9.10/internal/handlers/playback_manager.go new file mode 100644 index 0000000..5ba373b --- /dev/null +++ b/seanime-2.9.10/internal/handlers/playback_manager.go @@ -0,0 +1,232 @@ +package handlers + +import ( + "seanime/internal/database/db_bridge" + "seanime/internal/library/playbackmanager" + + "github.com/labstack/echo/v4" +) + +// HandlePlaybackPlayVideo +// +// @summary plays the video with the given path using the default media player. +// @desc This tells the Playback Manager to play the video using the default media player and start tracking progress. +// @desc This returns 'true' if the video was successfully played. +// @route /api/v1/playback-manager/play [POST] +// @returns bool +func (h *Handler) HandlePlaybackPlayVideo(c echo.Context) error { + type body struct { + Path string `json:"path"` + } + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.PlaybackManager.StartPlayingUsingMediaPlayer(&playbackmanager.StartPlayingOptions{ + Payload: b.Path, + UserAgent: c.Request().Header.Get("User-Agent"), + ClientId: "", + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandlePlaybackPlayRandomVideo +// +// @summary plays a random, unwatched video using the default media player. +// @desc This tells the Playback Manager to play a random, unwatched video using the media player and start tracking progress. +// @desc It respects the user's progress data and will prioritize "current" and "repeating" media if they are many of them. +// @desc This returns 'true' if the video was successfully played. +// @route /api/v1/playback-manager/play-random [POST] +// @returns bool +func (h *Handler) HandlePlaybackPlayRandomVideo(c echo.Context) error { + + err := h.App.PlaybackManager.StartRandomVideo(&playbackmanager.StartRandomVideoOptions{ + UserAgent: c.Request().Header.Get("User-Agent"), + ClientId: "", + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandlePlaybackSyncCurrentProgress +// +// @summary updates the AniList progress of the currently playing media. +// @desc This is called after 'Update progress' is clicked when watching a media. +// @desc This route returns the media ID of the currently playing media, so the client can refetch the media entry data. +// @route /api/v1/playback-manager/sync-current-progress [POST] +// @returns int +func (h *Handler) HandlePlaybackSyncCurrentProgress(c echo.Context) error { + + err := h.App.PlaybackManager.SyncCurrentProgress() + if err != nil { + return h.RespondWithError(c, err) + } + + mId, _ := h.App.PlaybackManager.GetCurrentMediaID() + + return h.RespondWithData(c, mId) +} + +// HandlePlaybackPlayNextEpisode +// +// @summary plays the next episode of the currently playing media. +// @desc This will play the next episode of the currently playing media. +// @desc This is non-blocking so the client should prevent multiple calls until the next status is received. +// @route /api/v1/playback-manager/next-episode [POST] +// @returns bool +func (h *Handler) HandlePlaybackPlayNextEpisode(c echo.Context) error { + + err := h.App.PlaybackManager.PlayNextEpisode() + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandlePlaybackGetNextEpisode +// +// @summary gets the next episode of the currently playing media. +// @desc This is used by the client's autoplay feature +// @route /api/v1/playback-manager/next-episode [GET] +// @returns *anime.LocalFile +func (h *Handler) HandlePlaybackGetNextEpisode(c echo.Context) error { + + lf := h.App.PlaybackManager.GetNextEpisode() + return h.RespondWithData(c, lf) +} + +// HandlePlaybackAutoPlayNextEpisode +// +// @summary plays the next episode of the currently playing media. +// @desc This will play the next episode of the currently playing media. +// @route /api/v1/playback-manager/autoplay-next-episode [POST] +// @returns bool +func (h *Handler) HandlePlaybackAutoPlayNextEpisode(c echo.Context) error { + + err := h.App.PlaybackManager.AutoPlayNextEpisode() + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// HandlePlaybackStartPlaylist +// +// @summary starts playing a playlist. +// @desc The client should refetch playlists. +// @route /api/v1/playback-manager/start-playlist [POST] +// @returns bool +func (h *Handler) HandlePlaybackStartPlaylist(c echo.Context) error { + + type body struct { + DbId uint `json:"dbId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + // Get playlist + playlist, err := db_bridge.GetPlaylist(h.App.Database, b.DbId) + if err != nil { + return h.RespondWithError(c, err) + } + + err = h.App.PlaybackManager.StartPlaylist(playlist) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandlePlaybackCancelCurrentPlaylist +// +// @summary ends the current playlist. +// @desc This will stop the current playlist. This is non-blocking. +// @route /api/v1/playback-manager/cancel-playlist [POST] +// @returns bool +func (h *Handler) HandlePlaybackCancelCurrentPlaylist(c echo.Context) error { + + err := h.App.PlaybackManager.CancelCurrentPlaylist() + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandlePlaybackPlaylistNext +// +// @summary moves to the next item in the current playlist. +// @desc This is non-blocking so the client should prevent multiple calls until the next status is received. +// @route /api/v1/playback-manager/playlist-next [POST] +// @returns bool +func (h *Handler) HandlePlaybackPlaylistNext(c echo.Context) error { + + err := h.App.PlaybackManager.RequestNextPlaylistFile() + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// HandlePlaybackStartManualTracking +// +// @summary starts manual tracking of a media. +// @desc Used for tracking progress of media that is not played through any integrated media player. +// @desc This should only be used for trackable episodes (episodes that count towards progress). +// @desc This returns 'true' if the tracking was successfully started. +// @route /api/v1/playback-manager/manual-tracking/start [POST] +// @returns bool +func (h *Handler) HandlePlaybackStartManualTracking(c echo.Context) error { + type body struct { + MediaId int `json:"mediaId"` + EpisodeNumber int `json:"episodeNumber"` + ClientId string `json:"clientId"` + } + b := new(body) + if err := c.Bind(b); err != nil { + return h.RespondWithError(c, err) + } + + err := h.App.PlaybackManager.StartManualProgressTracking(&playbackmanager.StartManualProgressTrackingOptions{ + ClientId: b.ClientId, + MediaId: b.MediaId, + EpisodeNumber: b.EpisodeNumber, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandlePlaybackCancelManualTracking +// +// @summary cancels manual tracking of a media. +// @desc This will stop the server from expecting progress updates for the media. +// @route /api/v1/playback-manager/manual-tracking/cancel [POST] +// @returns bool +func (h *Handler) HandlePlaybackCancelManualTracking(c echo.Context) error { + + h.App.PlaybackManager.CancelManualProgressTracking() + + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/playlist.go b/seanime-2.9.10/internal/handlers/playlist.go new file mode 100644 index 0000000..828d61c --- /dev/null +++ b/seanime-2.9.10/internal/handlers/playlist.go @@ -0,0 +1,185 @@ +package handlers + +import ( + "errors" + "github.com/labstack/echo/v4" + "seanime/internal/database/db_bridge" + "seanime/internal/library/anime" + "seanime/internal/util" + "strconv" +) + +// HandleCreatePlaylist +// +// @summary creates a new playlist. +// @desc This will create a new playlist with the given name and local file paths. +// @desc The response is ignored, the client should re-fetch the playlists after this. +// @route /api/v1/playlist [POST] +// @returns anime.Playlist +func (h *Handler) HandleCreatePlaylist(c echo.Context) error { + + type body struct { + Name string `json:"name"` + Paths []string `json:"paths"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + // Get the local files + dbLfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + // Filter the local files + lfs := make([]*anime.LocalFile, 0) + for _, path := range b.Paths { + for _, lf := range dbLfs { + if lf.GetNormalizedPath() == util.NormalizePath(path) { + lfs = append(lfs, lf) + break + } + } + } + + // Create the playlist + playlist := anime.NewPlaylist(b.Name) + playlist.SetLocalFiles(lfs) + + // Save the playlist + if err := db_bridge.SavePlaylist(h.App.Database, playlist); err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, playlist) +} + +// HandleGetPlaylists +// +// @summary returns all playlists. +// @route /api/v1/playlists [GET] +// @returns []anime.Playlist +func (h *Handler) HandleGetPlaylists(c echo.Context) error { + + playlists, err := db_bridge.GetPlaylists(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, playlists) +} + +// HandleUpdatePlaylist +// +// @summary updates a playlist. +// @returns the updated playlist +// @desc The response is ignored, the client should re-fetch the playlists after this. +// @route /api/v1/playlist [PATCH] +// @param id - int - true - "The ID of the playlist to update." +// @returns anime.Playlist +func (h *Handler) HandleUpdatePlaylist(c echo.Context) error { + + type body struct { + DbId uint `json:"dbId"` + Name string `json:"name"` + Paths []string `json:"paths"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + // Get the local files + dbLfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + // Filter the local files + lfs := make([]*anime.LocalFile, 0) + for _, path := range b.Paths { + for _, lf := range dbLfs { + if lf.GetNormalizedPath() == util.NormalizePath(path) { + lfs = append(lfs, lf) + break + } + } + } + + // Recreate playlist + playlist := anime.NewPlaylist(b.Name) + playlist.DbId = b.DbId + playlist.Name = b.Name + playlist.SetLocalFiles(lfs) + + // Save the playlist + if err := db_bridge.UpdatePlaylist(h.App.Database, playlist); err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, playlist) +} + +// HandleDeletePlaylist +// +// @summary deletes a playlist. +// @route /api/v1/playlist [DELETE] +// @returns bool +func (h *Handler) HandleDeletePlaylist(c echo.Context) error { + + type body struct { + DbId uint `json:"dbId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + + } + + if err := db_bridge.DeletePlaylist(h.App.Database, b.DbId); err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleGetPlaylistEpisodes +// +// @summary returns all the local files of a playlist media entry that have not been watched. +// @route /api/v1/playlist/episodes/{id}/{progress} [GET] +// @param id - int - true - "The ID of the media entry." +// @param progress - int - true - "The progress of the media entry." +// @returns []anime.LocalFile +func (h *Handler) HandleGetPlaylistEpisodes(c echo.Context) error { + + lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + lfw := anime.NewLocalFileWrapper(lfs) + + // Params + mId, err := strconv.Atoi(c.Param("id")) + if err != nil { + return h.RespondWithError(c, err) + } + progress, err := strconv.Atoi(c.Param("progress")) + if err != nil { + return h.RespondWithError(c, err) + } + + group, found := lfw.GetLocalEntryById(mId) + if !found { + return h.RespondWithError(c, errors.New("media entry not found")) + } + + toWatch := group.GetUnwatchedLocalFiles(progress) + + return h.RespondWithData(c, toWatch) +} diff --git a/seanime-2.9.10/internal/handlers/releases.go b/seanime-2.9.10/internal/handlers/releases.go new file mode 100644 index 0000000..7d99de2 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/releases.go @@ -0,0 +1,167 @@ +package handlers + +import ( + "bufio" + "fmt" + "net/http" + "seanime/internal/updater" + "seanime/internal/util/result" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/labstack/echo/v4" + "github.com/samber/lo" +) + +// HandleInstallLatestUpdate +// +// @summary installs the latest update. +// @desc This will install the latest update and launch the new version. +// @route /api/v1/install-update [POST] +// @returns handlers.Status +func (h *Handler) HandleInstallLatestUpdate(c echo.Context) error { + type body struct { + FallbackDestination string `json:"fallback_destination"` + } + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + go func() { + time.Sleep(2 * time.Second) + h.App.SelfUpdater.StartSelfUpdate(b.FallbackDestination) + }() + + status := h.NewStatus(c) + status.Updating = true + + time.Sleep(1 * time.Second) + + return h.RespondWithData(c, status) +} + +// HandleGetLatestUpdate +// +// @summary returns the latest update. +// @desc This will return the latest update. +// @desc If an error occurs, it will return an empty update. +// @route /api/v1/latest-update [GET] +// @returns updater.Update +func (h *Handler) HandleGetLatestUpdate(c echo.Context) error { + update, err := h.App.Updater.GetLatestUpdate() + if err != nil { + return h.RespondWithData(c, &updater.Update{}) + } + + return h.RespondWithData(c, update) +} + +type changelogItem struct { + Version string `json:"version"` + Lines []string `json:"lines"` +} + +var changelogCache = result.NewCache[string, []*changelogItem]() + +// HandleGetChangelog +// +// @summary returns the changelog for versions greater than or equal to the given version. +// @route /api/v1/changelog [GET] +// @param before query string true "The version to get the changelog for." +// @returns string +func (h *Handler) HandleGetChangelog(c echo.Context) error { + before := c.QueryParam("before") + after := c.QueryParam("after") + + key := fmt.Sprintf("%s-%s", before, after) + + cached, ok := changelogCache.Get(key) + if ok { + return h.RespondWithData(c, cached) + } + + changelogBody, err := http.Get("https://raw.githubusercontent.com/5rahim/seanime/main/CHANGELOG.md") + if err != nil { + return h.RespondWithData(c, []*changelogItem{}) + } + defer changelogBody.Body.Close() + + changelog := []*changelogItem{} + + scanner := bufio.NewScanner(changelogBody.Body) + + var version string + var body []string + var blockOpen bool + + for scanner.Scan() { + line := scanner.Text() + + if strings.HasPrefix(line, "## ") { + if blockOpen { + changelog = append(changelog, &changelogItem{ + Version: version, + Lines: body, + }) + } + + version = strings.TrimPrefix(line, "## ") + version = strings.TrimLeft(version, "v") + body = []string{} + blockOpen = true + } else if blockOpen { + if strings.TrimSpace(line) == "" { + continue + } + + body = append(body, line) + } + } + + if blockOpen { + changelog = append(changelog, &changelogItem{ + Version: version, + Lines: body, + }) + } + + // e.g. get changelog after 2.7.0 + if after != "" { + changelog = lo.Filter(changelog, func(item *changelogItem, index int) bool { + afterVersion, err := semver.NewVersion(after) + if err != nil { + return false + } + + version, err := semver.NewVersion(item.Version) + if err != nil { + return false + } + + return version.GreaterThan(afterVersion) + }) + } + + // e.g. get changelog before 2.7.0 + if before != "" { + changelog = lo.Filter(changelog, func(item *changelogItem, index int) bool { + beforeVersion, err := semver.NewVersion(before) + if err != nil { + return false + } + + version, err := semver.NewVersion(item.Version) + if err != nil { + return false + } + + return version.LessThan(beforeVersion) + }) + } + + changelogCache.Set(key, changelog) + + return h.RespondWithData(c, changelog) +} diff --git a/seanime-2.9.10/internal/handlers/report.go b/seanime-2.9.10/internal/handlers/report.go new file mode 100644 index 0000000..134c1fd --- /dev/null +++ b/seanime-2.9.10/internal/handlers/report.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "seanime/internal/database/db_bridge" + "seanime/internal/library/anime" + "seanime/internal/report" + "time" + + "github.com/labstack/echo/v4" +) + +// HandleSaveIssueReport +// +// @summary saves the issue report in memory. +// @route /api/v1/report/issue [POST] +// @returns bool +func (h *Handler) HandleSaveIssueReport(c echo.Context) error { + + type body struct { + ClickLogs []*report.ClickLog `json:"clickLogs"` + NetworkLogs []*report.NetworkLog `json:"networkLogs"` + ReactQueryLogs []*report.ReactQueryLog `json:"reactQueryLogs"` + ConsoleLogs []*report.ConsoleLog `json:"consoleLogs"` + IsAnimeLibraryIssue bool `json:"isAnimeLibraryIssue"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + var localFiles []*anime.LocalFile + if b.IsAnimeLibraryIssue { + // Get local files + var err error + localFiles, _, err = db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + } + + status := h.NewStatus(c) + + if err := h.App.ReportRepository.SaveIssueReport(report.SaveIssueReportOptions{ + LogsDir: h.App.Config.Logs.Dir, + UserAgent: c.Request().Header.Get("User-Agent"), + ClickLogs: b.ClickLogs, + NetworkLogs: b.NetworkLogs, + ReactQueryLogs: b.ReactQueryLogs, + ConsoleLogs: b.ConsoleLogs, + Settings: h.App.Settings, + DebridSettings: h.App.SecondarySettings.Debrid, + IsAnimeLibraryIssue: b.IsAnimeLibraryIssue, + LocalFiles: localFiles, + ServerStatus: status, + }); err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleDownloadIssueReport +// +// @summary generates and downloads the issue report file. +// @route /api/v1/report/issue/download [GET] +// @returns report.IssueReport +func (h *Handler) HandleDownloadIssueReport(c echo.Context) error { + + issueReport, ok := h.App.ReportRepository.GetSavedIssueReport() + if !ok { + return h.RespondWithError(c, fmt.Errorf("no issue report found")) + } + + marshaledIssueReport, err := json.Marshal(issueReport) + if err != nil { + return h.RespondWithError(c, fmt.Errorf("failed to marshal issue report: %w", err)) + } + + buffer := bytes.Buffer{} + buffer.Write(marshaledIssueReport) + + // Generate filename with current timestamp + filename := fmt.Sprintf("issue_report_%s.json", time.Now().Format("2006-01-02_15-04-05")) + + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + c.Response().Header().Set("Content-Type", "application/json") + + return c.Stream(200, "application/json", &buffer) +} diff --git a/seanime-2.9.10/internal/handlers/response.go b/seanime-2.9.10/internal/handlers/response.go new file mode 100644 index 0000000..6aef5e4 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/response.go @@ -0,0 +1,27 @@ +package handlers + +// SeaResponse is a generic response type for the API. +// It is used to return data or errors. +type SeaResponse[R any] struct { + Error string `json:"error,omitempty"` + Data R `json:"data,omitempty"` +} + +func NewDataResponse[R any](data R) SeaResponse[R] { + res := SeaResponse[R]{ + Data: data, + } + return res +} + +func NewErrorResponse(err error) SeaResponse[any] { + if err == nil { + return SeaResponse[any]{ + Error: "Unknown error", + } + } + res := SeaResponse[any]{ + Error: err.Error(), + } + return res +} diff --git a/seanime-2.9.10/internal/handlers/routes.go b/seanime-2.9.10/internal/handlers/routes.go new file mode 100644 index 0000000..8c28deb --- /dev/null +++ b/seanime-2.9.10/internal/handlers/routes.go @@ -0,0 +1,560 @@ +package handlers + +import ( + "net/http" + "path/filepath" + "seanime/internal/core" + util "seanime/internal/util/proxies" + "strings" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/rs/zerolog" + "github.com/ziflex/lecho/v3" +) + +type Handler struct { + App *core.App +} + +func InitRoutes(app *core.App, e *echo.Echo) { + // CORS middleware + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: []string{"*"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Cookie", "Authorization", + "X-Seanime-Token", "X-Seanime-Nakama-Token", "X-Seanime-Nakama-Username", "X-Seanime-Nakama-Server-Version", "X-Seanime-Nakama-Peer-Id"}, + AllowCredentials: true, + })) + + lechoLogger := lecho.From(*app.Logger) + + urisToSkip := []string{ + "/internal/metrics", + "/_next", + "/icons", + "/events", + "/api/v1/image-proxy", + "/api/v1/mediastream/transcode/", + "/api/v1/torrent-client/list", + "/api/v1/proxy", + } + + // Logging middleware + e.Use(lecho.Middleware(lecho.Config{ + Logger: lechoLogger, + Skipper: func(c echo.Context) bool { + path := c.Request().URL.RequestURI() + if filepath.Ext(c.Request().URL.Path) == ".txt" || + filepath.Ext(c.Request().URL.Path) == ".png" || + filepath.Ext(c.Request().URL.Path) == ".ico" { + return true + } + for _, uri := range urisToSkip { + if uri == path || strings.HasPrefix(path, uri) { + return true + } + } + return false + }, + Enricher: func(c echo.Context, logger zerolog.Context) zerolog.Context { + // Add which file the request came from + return logger.Str("file", c.Path()) + }, + })) + + // Recovery middleware + e.Use(middleware.Recover()) + + // Client ID middleware + e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Check if the client has a UUID cookie + cookie, err := c.Cookie("Seanime-Client-Id") + + if err != nil || cookie.Value == "" { + // Generate a new UUID for the client + u := uuid.New().String() + + // Create a cookie with the UUID + newCookie := new(http.Cookie) + newCookie.Name = "Seanime-Client-Id" + newCookie.Value = u + newCookie.HttpOnly = false // Make the cookie accessible via JS + newCookie.Expires = time.Now().Add(24 * time.Hour) + newCookie.Path = "/" + newCookie.Domain = "" + newCookie.SameSite = http.SameSiteDefaultMode + newCookie.Secure = false + + // Set the cookie + c.SetCookie(newCookie) + + // Store the UUID in the context for use in the request + c.Set("Seanime-Client-Id", u) + } else { + // Store the existing UUID in the context for use in the request + c.Set("Seanime-Client-Id", cookie.Value) + } + + return next(c) + } + }) + + e.Use(headMethodMiddleware) + + h := &Handler{App: app} + + e.GET("/events", h.webSocketEventHandler) + + v1 := e.Group("/api").Group("/v1") // Commented out for now, will be used later + + // + // Auth middleware + // + v1.Use(h.OptionalAuthMiddleware) + + imageProxy := &util.ImageProxy{} + v1.GET("/image-proxy", imageProxy.ProxyImage) + + v1.GET("/proxy", util.VideoProxy) + v1.HEAD("/proxy", util.VideoProxy) + + v1.GET("/status", h.HandleGetStatus) + v1.GET("/log/*", h.HandleGetLogContent) + v1.GET("/logs/filenames", h.HandleGetLogFilenames) + v1.DELETE("/logs", h.HandleDeleteLogs) + v1.GET("/logs/latest", h.HandleGetLatestLogContent) + + v1.GET("/memory/stats", h.HandleGetMemoryStats) + v1.GET("/memory/profile", h.HandleGetMemoryProfile) + v1.GET("/memory/goroutine", h.HandleGetGoRoutineProfile) + v1.GET("/memory/cpu", h.HandleGetCPUProfile) + v1.POST("/memory/gc", h.HandleForceGC) + + v1.POST("/announcements", h.HandleGetAnnouncements) + + // Auth + v1.POST("/auth/login", h.HandleLogin) + v1.POST("/auth/logout", h.HandleLogout) + + // Settings + v1.GET("/settings", h.HandleGetSettings) + v1.PATCH("/settings", h.HandleSaveSettings) + v1.POST("/start", h.HandleGettingStarted) + v1.PATCH("/settings/auto-downloader", h.HandleSaveAutoDownloaderSettings) + + // Auto Downloader + v1.POST("/auto-downloader/run", h.HandleRunAutoDownloader) + v1.GET("/auto-downloader/rule/:id", h.HandleGetAutoDownloaderRule) + v1.GET("/auto-downloader/rule/anime/:id", h.HandleGetAutoDownloaderRulesByAnime) + v1.GET("/auto-downloader/rules", h.HandleGetAutoDownloaderRules) + v1.POST("/auto-downloader/rule", h.HandleCreateAutoDownloaderRule) + v1.PATCH("/auto-downloader/rule", h.HandleUpdateAutoDownloaderRule) + v1.DELETE("/auto-downloader/rule/:id", h.HandleDeleteAutoDownloaderRule) + + v1.GET("/auto-downloader/items", h.HandleGetAutoDownloaderItems) + v1.DELETE("/auto-downloader/item", h.HandleDeleteAutoDownloaderItem) + + // Other + v1.POST("/test-dump", h.HandleTestDump) + + v1.POST("/directory-selector", h.HandleDirectorySelector) + + v1.POST("/open-in-explorer", h.HandleOpenInExplorer) + + v1.POST("/media-player/start", h.HandleStartDefaultMediaPlayer) + + // + // AniList + // + + v1Anilist := v1.Group("/anilist") + + v1Anilist.GET("/collection", h.HandleGetAnimeCollection) + v1Anilist.POST("/collection", h.HandleGetAnimeCollection) + + v1Anilist.GET("/collection/raw", h.HandleGetRawAnimeCollection) + v1Anilist.POST("/collection/raw", h.HandleGetRawAnimeCollection) + + v1Anilist.GET("/media-details/:id", h.HandleGetAnilistAnimeDetails) + + v1Anilist.GET("/studio-details/:id", h.HandleGetAnilistStudioDetails) + + v1Anilist.POST("/list-entry", h.HandleEditAnilistListEntry) + + v1Anilist.DELETE("/list-entry", h.HandleDeleteAnilistListEntry) + + v1Anilist.POST("/list-anime", h.HandleAnilistListAnime) + + v1Anilist.POST("/list-recent-anime", h.HandleAnilistListRecentAiringAnime) + + v1Anilist.GET("/list-missed-sequels", h.HandleAnilistListMissedSequels) + + v1Anilist.GET("/stats", h.HandleGetAniListStats) + + // + // MAL + // + + v1.POST("/mal/auth", h.HandleMALAuth) + + v1.POST("/mal/logout", h.HandleMALLogout) + + // + // Library + // + + v1Library := v1.Group("/library") + + v1Library.POST("/scan", h.HandleScanLocalFiles) + + v1Library.DELETE("/empty-directories", h.HandleRemoveEmptyDirectories) + + v1Library.GET("/local-files", h.HandleGetLocalFiles) + v1Library.POST("/local-files", h.HandleLocalFileBulkAction) + v1Library.PATCH("/local-files", h.HandleUpdateLocalFiles) + v1Library.DELETE("/local-files", h.HandleDeleteLocalFiles) + v1Library.GET("/local-files/dump", h.HandleDumpLocalFilesToFile) + v1Library.POST("/local-files/import", h.HandleImportLocalFiles) + v1Library.PATCH("/local-file", h.HandleUpdateLocalFileData) + + v1Library.GET("/collection", h.HandleGetLibraryCollection) + v1Library.GET("/schedule", h.HandleGetAnimeCollectionSchedule) + + v1Library.GET("/scan-summaries", h.HandleGetScanSummaries) + + v1Library.GET("/missing-episodes", h.HandleGetMissingEpisodes) + + v1Library.GET("/anime-entry/:id", h.HandleGetAnimeEntry) + v1Library.POST("/anime-entry/suggestions", h.HandleFetchAnimeEntrySuggestions) + v1Library.POST("/anime-entry/manual-match", h.HandleAnimeEntryManualMatch) + v1Library.PATCH("/anime-entry/bulk-action", h.HandleAnimeEntryBulkAction) + v1Library.POST("/anime-entry/open-in-explorer", h.HandleOpenAnimeEntryInExplorer) + v1Library.POST("/anime-entry/update-progress", h.HandleUpdateAnimeEntryProgress) + v1Library.POST("/anime-entry/update-repeat", h.HandleUpdateAnimeEntryRepeat) + v1Library.GET("/anime-entry/silence/:id", h.HandleGetAnimeEntrySilenceStatus) + v1Library.POST("/anime-entry/silence", h.HandleToggleAnimeEntrySilenceStatus) + + v1Library.POST("/unknown-media", h.HandleAddUnknownMedia) + + // + // Anime + // + v1.GET("/anime/episode-collection/:id", h.HandleGetAnimeEpisodeCollection) + + // + // Torrent / Torrent Client + // + + v1.POST("/torrent/search", h.HandleSearchTorrent) + v1.POST("/torrent-client/download", h.HandleTorrentClientDownload) + v1.GET("/torrent-client/list", h.HandleGetActiveTorrentList) + v1.POST("/torrent-client/action", h.HandleTorrentClientAction) + v1.POST("/torrent-client/rule-magnet", h.HandleTorrentClientAddMagnetFromRule) + + // + // Download + // + + v1.POST("/download-torrent-file", h.HandleDownloadTorrentFile) + + // + // Updates + // + + v1.GET("/latest-update", h.HandleGetLatestUpdate) + v1.GET("/changelog", h.HandleGetChangelog) + v1.POST("/install-update", h.HandleInstallLatestUpdate) + v1.POST("/download-release", h.HandleDownloadRelease) + + // + // Theme + // + + v1.GET("/theme", h.HandleGetTheme) + v1.PATCH("/theme", h.HandleUpdateTheme) + + // + // Playback Manager + // + + v1.POST("/playback-manager/sync-current-progress", h.HandlePlaybackSyncCurrentProgress) + v1.POST("/playback-manager/start-playlist", h.HandlePlaybackStartPlaylist) + v1.POST("/playback-manager/playlist-next", h.HandlePlaybackPlaylistNext) + v1.POST("/playback-manager/cancel-playlist", h.HandlePlaybackCancelCurrentPlaylist) + v1.POST("/playback-manager/next-episode", h.HandlePlaybackPlayNextEpisode) + v1.GET("/playback-manager/next-episode", h.HandlePlaybackGetNextEpisode) + v1.POST("/playback-manager/autoplay-next-episode", h.HandlePlaybackAutoPlayNextEpisode) + v1.POST("/playback-manager/play", h.HandlePlaybackPlayVideo) + v1.POST("/playback-manager/play-random", h.HandlePlaybackPlayRandomVideo) + //------------ + v1.POST("/playback-manager/manual-tracking/start", h.HandlePlaybackStartManualTracking) + v1.POST("/playback-manager/manual-tracking/cancel", h.HandlePlaybackCancelManualTracking) + + // + // Playlists + // + + v1.GET("/playlists", h.HandleGetPlaylists) + v1.POST("/playlist", h.HandleCreatePlaylist) + v1.PATCH("/playlist", h.HandleUpdatePlaylist) + v1.DELETE("/playlist", h.HandleDeletePlaylist) + v1.GET("/playlist/episodes/:id/:progress", h.HandleGetPlaylistEpisodes) + + // + // Onlinestream + // + + v1.POST("/onlinestream/episode-source", h.HandleGetOnlineStreamEpisodeSource) + v1.POST("/onlinestream/episode-list", h.HandleGetOnlineStreamEpisodeList) + v1.DELETE("/onlinestream/cache", h.HandleOnlineStreamEmptyCache) + + v1.POST("/onlinestream/search", h.HandleOnlinestreamManualSearch) + v1.POST("/onlinestream/manual-mapping", h.HandleOnlinestreamManualMapping) + v1.POST("/onlinestream/get-mapping", h.HandleGetOnlinestreamMapping) + v1.POST("/onlinestream/remove-mapping", h.HandleRemoveOnlinestreamMapping) + + // + // Metadata Provider + // + + v1.POST("/metadata-provider/filler", h.HandlePopulateFillerData) + v1.DELETE("/metadata-provider/filler", h.HandleRemoveFillerData) + + // + // Manga + // + + v1Manga := v1.Group("/manga") + v1Manga.POST("/anilist/collection", h.HandleGetAnilistMangaCollection) + v1Manga.GET("/anilist/collection/raw", h.HandleGetRawAnilistMangaCollection) + v1Manga.POST("/anilist/collection/raw", h.HandleGetRawAnilistMangaCollection) + v1Manga.POST("/anilist/list", h.HandleAnilistListManga) + v1Manga.GET("/collection", h.HandleGetMangaCollection) + v1Manga.GET("/latest-chapter-numbers", h.HandleGetMangaLatestChapterNumbersMap) + v1Manga.POST("/refetch-chapter-containers", h.HandleRefetchMangaChapterContainers) + v1Manga.GET("/entry/:id", h.HandleGetMangaEntry) + v1Manga.GET("/entry/:id/details", h.HandleGetMangaEntryDetails) + v1Manga.DELETE("/entry/cache", h.HandleEmptyMangaEntryCache) + v1Manga.POST("/chapters", h.HandleGetMangaEntryChapters) + v1Manga.POST("/pages", h.HandleGetMangaEntryPages) + v1Manga.POST("/update-progress", h.HandleUpdateMangaProgress) + + v1Manga.GET("/downloaded-chapters/:id", h.HandleGetMangaEntryDownloadedChapters) + v1Manga.GET("/downloads", h.HandleGetMangaDownloadsList) + v1Manga.POST("/download-chapters", h.HandleDownloadMangaChapters) + v1Manga.POST("/download-data", h.HandleGetMangaDownloadData) + v1Manga.DELETE("/download-chapter", h.HandleDeleteMangaDownloadedChapters) + v1Manga.GET("/download-queue", h.HandleGetMangaDownloadQueue) + v1Manga.POST("/download-queue/start", h.HandleStartMangaDownloadQueue) + v1Manga.POST("/download-queue/stop", h.HandleStopMangaDownloadQueue) + v1Manga.DELETE("/download-queue", h.HandleClearAllChapterDownloadQueue) + v1Manga.POST("/download-queue/reset-errored", h.HandleResetErroredChapterDownloadQueue) + + v1Manga.POST("/search", h.HandleMangaManualSearch) + v1Manga.POST("/manual-mapping", h.HandleMangaManualMapping) + v1Manga.POST("/get-mapping", h.HandleGetMangaMapping) + v1Manga.POST("/remove-mapping", h.HandleRemoveMangaMapping) + + v1Manga.GET("/local-page/:path", h.HandleGetLocalMangaPage) + + // + // File Cache + // + + v1FileCache := v1.Group("/filecache") + v1FileCache.GET("/total-size", h.HandleGetFileCacheTotalSize) + v1FileCache.DELETE("/bucket", h.HandleRemoveFileCacheBucket) + v1FileCache.GET("/mediastream/videofiles/total-size", h.HandleGetFileCacheMediastreamVideoFilesTotalSize) + v1FileCache.DELETE("/mediastream/videofiles", h.HandleClearFileCacheMediastreamVideoFiles) + + // + // Discord + // + + v1Discord := v1.Group("/discord") + v1Discord.POST("/presence/manga", h.HandleSetDiscordMangaActivity) + v1Discord.POST("/presence/legacy-anime", h.HandleSetDiscordLegacyAnimeActivity) + v1Discord.POST("/presence/anime", h.HandleSetDiscordAnimeActivityWithProgress) + v1Discord.POST("/presence/anime-update", h.HandleUpdateDiscordAnimeActivityWithProgress) + v1Discord.POST("/presence/cancel", h.HandleCancelDiscordActivity) + + // + // Media Stream + // + v1.GET("/mediastream/settings", h.HandleGetMediastreamSettings) + v1.PATCH("/mediastream/settings", h.HandleSaveMediastreamSettings) + v1.POST("/mediastream/request", h.HandleRequestMediastreamMediaContainer) + v1.POST("/mediastream/preload", h.HandlePreloadMediastreamMediaContainer) + // Transcode + v1.POST("/mediastream/shutdown-transcode", h.HandleMediastreamShutdownTranscodeStream) + v1.GET("/mediastream/transcode/*", h.HandleMediastreamTranscode) + v1.GET("/mediastream/subs/*", h.HandleMediastreamGetSubtitles) + v1.GET("/mediastream/att/*", h.HandleMediastreamGetAttachments) + v1.GET("/mediastream/direct", h.HandleMediastreamDirectPlay) + v1.HEAD("/mediastream/direct", h.HandleMediastreamDirectPlay) + v1.GET("/mediastream/file", h.HandleMediastreamFile) + + // + // Direct Stream + // + v1.POST("/directstream/play/localfile", h.HandleDirectstreamPlayLocalFile) + v1.GET("/directstream/stream", echo.WrapHandler(h.HandleDirectstreamGetStream())) + v1.HEAD("/directstream/stream", echo.WrapHandler(h.HandleDirectstreamGetStream())) + v1.GET("/directstream/att/*", h.HandleDirectstreamGetAttachments) + + // + // Torrent stream + // + v1.GET("/torrentstream/settings", h.HandleGetTorrentstreamSettings) + v1.PATCH("/torrentstream/settings", h.HandleSaveTorrentstreamSettings) + v1.POST("/torrentstream/start", h.HandleTorrentstreamStartStream) + v1.POST("/torrentstream/stop", h.HandleTorrentstreamStopStream) + v1.POST("/torrentstream/drop", h.HandleTorrentstreamDropTorrent) + v1.POST("/torrentstream/torrent-file-previews", h.HandleGetTorrentstreamTorrentFilePreviews) + v1.POST("/torrentstream/batch-history", h.HandleGetTorrentstreamBatchHistory) + v1.GET("/torrentstream/stream/*", h.HandleTorrentstreamServeStream) + + // + // Extensions + // + + v1Extensions := v1.Group("/extensions") + v1Extensions.POST("/playground/run", h.HandleRunExtensionPlaygroundCode) + v1Extensions.POST("/external/fetch", h.HandleFetchExternalExtensionData) + v1Extensions.POST("/external/install", h.HandleInstallExternalExtension) + v1Extensions.POST("/external/uninstall", h.HandleUninstallExternalExtension) + v1Extensions.POST("/external/edit-payload", h.HandleUpdateExtensionCode) + v1Extensions.POST("/external/reload", h.HandleReloadExternalExtensions) + v1Extensions.POST("/external/reload", h.HandleReloadExternalExtension) + v1Extensions.POST("/all", h.HandleGetAllExtensions) + v1Extensions.GET("/updates", h.HandleGetExtensionUpdateData) + v1Extensions.GET("/list", h.HandleListExtensionData) + v1Extensions.GET("/payload/:id", h.HandleGetExtensionPayload) + v1Extensions.GET("/list/development", h.HandleListDevelopmentModeExtensions) + v1Extensions.GET("/list/manga-provider", h.HandleListMangaProviderExtensions) + v1Extensions.GET("/list/onlinestream-provider", h.HandleListOnlinestreamProviderExtensions) + v1Extensions.GET("/list/anime-torrent-provider", h.HandleListAnimeTorrentProviderExtensions) + v1Extensions.GET("/user-config/:id", h.HandleGetExtensionUserConfig) + v1Extensions.POST("/user-config", h.HandleSaveExtensionUserConfig) + v1Extensions.GET("/marketplace", h.HandleGetMarketplaceExtensions) + v1Extensions.GET("/plugin-settings", h.HandleGetPluginSettings) + v1Extensions.POST("/plugin-settings/pinned-trays", h.HandleSetPluginSettingsPinnedTrays) + v1Extensions.POST("/plugin-permissions/grant", h.HandleGrantPluginPermissions) + + // + // Continuity + // + v1Continuity := v1.Group("/continuity") + v1Continuity.PATCH("/item", h.HandleUpdateContinuityWatchHistoryItem) + v1Continuity.GET("/item/:id", h.HandleGetContinuityWatchHistoryItem) + v1Continuity.GET("/history", h.HandleGetContinuityWatchHistory) + + // + // Sync + // + v1Local := v1.Group("/local") + v1Local.GET("/track", h.HandleLocalGetTrackedMediaItems) + v1Local.POST("/track", h.HandleLocalAddTrackedMedia) + v1Local.DELETE("/track", h.HandleLocalRemoveTrackedMedia) + v1Local.GET("/track/:id/:type", h.HandleLocalGetIsMediaTracked) + v1Local.POST("/local", h.HandleLocalSyncData) + v1Local.GET("/queue", h.HandleLocalGetSyncQueueState) + v1Local.POST("/anilist", h.HandleLocalSyncAnilistData) + v1Local.POST("/updated", h.HandleLocalSetHasLocalChanges) + v1Local.GET("/updated", h.HandleLocalGetHasLocalChanges) + v1Local.GET("/storage/size", h.HandleLocalGetLocalStorageSize) + v1Local.POST("/sync-simulated-to-anilist", h.HandleLocalSyncSimulatedDataToAnilist) + + v1Local.POST("/offline", h.HandleSetOfflineMode) + + // + // Debrid + // + + v1.GET("/debrid/settings", h.HandleGetDebridSettings) + v1.PATCH("/debrid/settings", h.HandleSaveDebridSettings) + v1.POST("/debrid/torrents", h.HandleDebridAddTorrents) + v1.POST("/debrid/torrents/download", h.HandleDebridDownloadTorrent) + v1.POST("/debrid/torrents/cancel", h.HandleDebridCancelDownload) + v1.DELETE("/debrid/torrent", h.HandleDebridDeleteTorrent) + v1.GET("/debrid/torrents", h.HandleDebridGetTorrents) + v1.POST("/debrid/torrents/info", h.HandleDebridGetTorrentInfo) + v1.POST("/debrid/torrents/file-previews", h.HandleDebridGetTorrentFilePreviews) + v1.POST("/debrid/stream/start", h.HandleDebridStartStream) + v1.POST("/debrid/stream/cancel", h.HandleDebridCancelStream) + + // + // Report + // + + v1.POST("/report/issue", h.HandleSaveIssueReport) + v1.GET("/report/issue/download", h.HandleDownloadIssueReport) + + // + // Nakama + // + + v1Nakama := v1.Group("/nakama") + v1Nakama.GET("/ws", h.HandleNakamaWebSocket) + v1Nakama.POST("/message", h.HandleSendNakamaMessage) + v1Nakama.POST("/reconnect", h.HandleNakamaReconnectToHost) + v1Nakama.POST("/cleanup", h.HandleNakamaRemoveStaleConnections) + v1Nakama.GET("/host/anime/library", h.HandleGetNakamaAnimeLibrary) + v1Nakama.GET("/host/anime/library/collection", h.HandleGetNakamaAnimeLibraryCollection) + v1Nakama.GET("/host/anime/library/files/:id", h.HandleGetNakamaAnimeLibraryFiles) + v1Nakama.GET("/host/anime/library/files", h.HandleGetNakamaAnimeAllLibraryFiles) + v1Nakama.POST("/play", h.HandleNakamaPlayVideo) + v1Nakama.GET("/host/torrentstream/stream", h.HandleNakamaHostTorrentstreamServeStream) + v1Nakama.GET("/host/anime/library/stream", h.HandleNakamaHostAnimeLibraryServeStream) + v1Nakama.GET("/host/debridstream/stream", h.HandleNakamaHostDebridstreamServeStream) + v1Nakama.GET("/host/debridstream/url", h.HandleNakamaHostGetDebridstreamURL) + v1Nakama.GET("/stream", h.HandleNakamaProxyStream) + v1Nakama.POST("/watch-party/create", h.HandleNakamaCreateWatchParty) + v1Nakama.POST("/watch-party/join", h.HandleNakamaJoinWatchParty) + v1Nakama.POST("/watch-party/leave", h.HandleNakamaLeaveWatchParty) + +} + +func (h *Handler) JSON(c echo.Context, code int, i interface{}) error { + return c.JSON(code, i) +} + +func (h *Handler) RespondWithData(c echo.Context, data interface{}) error { + return c.JSON(200, NewDataResponse(data)) +} + +func (h *Handler) RespondWithError(c echo.Context, err error) error { + return c.JSON(500, NewErrorResponse(err)) +} + +func headMethodMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Skip directstream route + if strings.Contains(c.Request().URL.Path, "/directstream/stream") { + return next(c) + } + + if c.Request().Method == http.MethodHead { + // Set the method to GET temporarily to reuse the handler + c.Request().Method = http.MethodGet + + defer func() { + c.Request().Method = http.MethodHead + }() // Restore method after + + // Call the next handler and then clear the response body + if err := next(c); err != nil { + if err.Error() == echo.ErrMethodNotAllowed.Error() { + return c.NoContent(http.StatusOK) + } + + return err + } + } + + return next(c) + } +} diff --git a/seanime-2.9.10/internal/handlers/scan.go b/seanime-2.9.10/internal/handlers/scan.go new file mode 100644 index 0000000..b2b8471 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/scan.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "errors" + "seanime/internal/database/db_bridge" + "seanime/internal/library/scanner" + "seanime/internal/library/summary" + + "github.com/labstack/echo/v4" +) + +// HandleScanLocalFiles +// +// @summary scans the user's library. +// @desc This will scan the user's library. +// @desc The response is ignored, the client should re-fetch the library after this. +// @route /api/v1/library/scan [POST] +// @returns []anime.LocalFile +func (h *Handler) HandleScanLocalFiles(c echo.Context) error { + + type body struct { + Enhanced bool `json:"enhanced"` + SkipLockedFiles bool `json:"skipLockedFiles"` + SkipIgnoredFiles bool `json:"skipIgnoredFiles"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + // Retrieve the user's library path + libraryPath, err := h.App.Database.GetLibraryPathFromSettings() + if err != nil { + return h.RespondWithError(c, err) + } + additionalLibraryPaths, err := h.App.Database.GetAdditionalLibraryPathsFromSettings() + if err != nil { + return h.RespondWithError(c, err) + } + + // Get the latest local files + existingLfs, _, err := db_bridge.GetLocalFiles(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + // +---------------------+ + // | Scanner | + // +---------------------+ + + // Create scan summary logger + scanSummaryLogger := summary.NewScanSummaryLogger() + + // Create a new scan logger + scanLogger, err := scanner.NewScanLogger(h.App.Config.Logs.Dir) + if err != nil { + return h.RespondWithError(c, err) + } + defer scanLogger.Done() + + // Create a new scanner + sc := scanner.Scanner{ + DirPath: libraryPath, + OtherDirPaths: additionalLibraryPaths, + Enhanced: b.Enhanced, + Platform: h.App.AnilistPlatform, + Logger: h.App.Logger, + WSEventManager: h.App.WSEventManager, + ExistingLocalFiles: existingLfs, + SkipLockedFiles: b.SkipLockedFiles, + SkipIgnoredFiles: b.SkipIgnoredFiles, + ScanSummaryLogger: scanSummaryLogger, + ScanLogger: scanLogger, + MetadataProvider: h.App.MetadataProvider, + MatchingAlgorithm: h.App.Settings.GetLibrary().ScannerMatchingAlgorithm, + MatchingThreshold: h.App.Settings.GetLibrary().ScannerMatchingThreshold, + } + + // Scan the library + allLfs, err := sc.Scan(c.Request().Context()) + if err != nil { + if errors.Is(err, scanner.ErrNoLocalFiles) { + return h.RespondWithData(c, []interface{}{}) + } else { + return h.RespondWithError(c, err) + } + } + + // Insert the local files + lfs, err := db_bridge.InsertLocalFiles(h.App.Database, allLfs) + if err != nil { + return h.RespondWithError(c, err) + } + + // Save the scan summary + _ = db_bridge.InsertScanSummary(h.App.Database, scanSummaryLogger.GenerateSummary()) + + go h.App.AutoDownloader.CleanUpDownloadedItems() + + return h.RespondWithData(c, lfs) + +} diff --git a/seanime-2.9.10/internal/handlers/scan_summary.go b/seanime-2.9.10/internal/handlers/scan_summary.go new file mode 100644 index 0000000..79a2d47 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/scan_summary.go @@ -0,0 +1,22 @@ +package handlers + +import ( + "seanime/internal/database/db_bridge" + + "github.com/labstack/echo/v4" +) + +// HandleGetScanSummaries +// +// @summary returns the latest scan summaries. +// @route /api/v1/library/scan-summaries [GET] +// @returns []summary.ScanSummaryItem +func (h *Handler) HandleGetScanSummaries(c echo.Context) error { + + sm, err := db_bridge.GetScanSummaries(h.App.Database) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, sm) +} diff --git a/seanime-2.9.10/internal/handlers/server_auth.go b/seanime-2.9.10/internal/handlers/server_auth.go new file mode 100644 index 0000000..b17ab30 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/server_auth.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "errors" + "strings" + + "github.com/labstack/echo/v4" +) + +func (h *Handler) OptionalAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if h.App.Config.Server.Password == "" { + return next(c) + } + + path := c.Request().URL.Path + passwordHash := c.Request().Header.Get("X-Seanime-Token") + + // Allow the following paths to be accessed by anyone + if path == "/api/v1/auth/login" || // for auth + path == "/api/v1/auth/logout" || // for auth + path == "/api/v1/status" || // for interface + path == "/events" || // for server events + strings.HasPrefix(path, "/api/v1/directstream") || // used by media players + // strings.HasPrefix(path, "/api/v1/mediastream") || // used by media players // NODE: DO NOT + strings.HasPrefix(path, "/api/v1/mediastream/att/") || // used by media players + strings.HasPrefix(path, "/api/v1/mediastream/direct") || // used by media players + strings.HasPrefix(path, "/api/v1/mediastream/transcode/") || // used by media players + strings.HasPrefix(path, "/api/v1/mediastream/subs/") || // used by media players + strings.HasPrefix(path, "/api/v1/image-proxy") || // used by img tag + strings.HasPrefix(path, "/api/v1/proxy") || // used by video players + strings.HasPrefix(path, "/api/v1/manga/local-page") || // used by img tag + strings.HasPrefix(path, "/api/v1/torrentstream/stream/") || // accessible by media players + strings.HasPrefix(path, "/api/v1/nakama/stream") { // accessible by media players + + if path == "/api/v1/status" { + // allow status requests by anyone but mark as unauthenticated + // so we can filter out critical info like settings + if passwordHash != h.App.ServerPasswordHash { + c.Set("unauthenticated", true) + } + } + + return next(c) + } + + if passwordHash == h.App.ServerPasswordHash { + return next(c) + } + + // Check HMAC token in query parameter + token := c.Request().URL.Query().Get("token") + if token != "" { + hmacAuth := h.App.GetServerPasswordHMACAuth() + _, err := hmacAuth.ValidateToken(token, path) + if err == nil { + return next(c) + } else { + h.App.Logger.Debug().Err(err).Str("path", path).Msg("server auth: HMAC token validation failed") + } + } + + // Handle Nakama client connections + if h.App.Settings.GetNakama().Enabled && h.App.Settings.GetNakama().IsHost { + // Verify the Nakama host password in the client request + nakamaPasswordHeader := c.Request().Header.Get("X-Seanime-Nakama-Token") + + // Allow WebSocket connections for peer-to-host communication + if path == "/api/v1/nakama/ws" { + if nakamaPasswordHeader == h.App.Settings.GetNakama().HostPassword { + c.Response().Header().Set("X-Seanime-Nakama-Is-Client", "true") + return next(c) + } + } + + // Only allow the following paths to be accessed by Nakama clients + if strings.HasPrefix(path, "/api/v1/nakama/host/") { + if nakamaPasswordHeader == h.App.Settings.GetNakama().HostPassword { + c.Response().Header().Set("X-Seanime-Nakama-Is-Client", "true") + return next(c) + } + } + } + + return h.RespondWithError(c, errors.New("UNAUTHENTICATED")) + } +} diff --git a/seanime-2.9.10/internal/handlers/settings.go b/seanime-2.9.10/internal/handlers/settings.go new file mode 100644 index 0000000..dc7271a --- /dev/null +++ b/seanime-2.9.10/internal/handlers/settings.go @@ -0,0 +1,301 @@ +package handlers + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "seanime/internal/database/models" + "seanime/internal/torrents/torrent" + "seanime/internal/util" + "time" + + "github.com/labstack/echo/v4" + "github.com/samber/lo" +) + +// HandleGetSettings +// +// @summary returns the app settings. +// @route /api/v1/settings [GET] +// @returns models.Settings +func (h *Handler) HandleGetSettings(c echo.Context) error { + + settings, err := h.App.Database.GetSettings() + if err != nil { + return h.RespondWithError(c, err) + } + if settings.ID == 0 { + return h.RespondWithError(c, errors.New(runtime.GOOS)) + } + + return h.RespondWithData(c, settings) +} + +// HandleGettingStarted +// +// @summary updates the app settings. +// @desc This will update the app settings. +// @desc The client should re-fetch the server status after this. +// @route /api/v1/start [POST] +// @returns handlers.Status +func (h *Handler) HandleGettingStarted(c echo.Context) error { + + type body struct { + Library models.LibrarySettings `json:"library"` + MediaPlayer models.MediaPlayerSettings `json:"mediaPlayer"` + Torrent models.TorrentSettings `json:"torrent"` + Anilist models.AnilistSettings `json:"anilist"` + Discord models.DiscordSettings `json:"discord"` + Manga models.MangaSettings `json:"manga"` + Notifications models.NotificationSettings `json:"notifications"` + Nakama models.NakamaSettings `json:"nakama"` + EnableTranscode bool `json:"enableTranscode"` + EnableTorrentStreaming bool `json:"enableTorrentStreaming"` + DebridProvider string `json:"debridProvider"` + DebridApiKey string `json:"debridApiKey"` + } + var b body + + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + // Check settings + if b.Library.LibraryPaths == nil { + b.Library.LibraryPaths = []string{} + } + b.Library.LibraryPath = filepath.ToSlash(b.Library.LibraryPath) + + settings, err := h.App.Database.UpsertSettings(&models.Settings{ + BaseModel: models.BaseModel{ + ID: 1, + UpdatedAt: time.Now(), + }, + Library: &b.Library, + MediaPlayer: &b.MediaPlayer, + Torrent: &b.Torrent, + Anilist: &b.Anilist, + Discord: &b.Discord, + Manga: &b.Manga, + Notifications: &b.Notifications, + Nakama: &b.Nakama, + AutoDownloader: &models.AutoDownloaderSettings{ + Provider: b.Library.TorrentProvider, + Interval: 20, + Enabled: false, + DownloadAutomatically: true, + EnableEnhancedQueries: true, + }, + }) + + if err != nil { + return h.RespondWithError(c, err) + } + + if b.EnableTorrentStreaming { + go func() { + defer util.HandlePanicThen(func() {}) + prev, found := h.App.Database.GetTorrentstreamSettings() + if found { + prev.Enabled = true + //prev.IncludeInLibrary = true + _, _ = h.App.Database.UpsertTorrentstreamSettings(prev) + } + }() + } + + if b.EnableTranscode { + go func() { + defer util.HandlePanicThen(func() {}) + prev, found := h.App.Database.GetMediastreamSettings() + if found { + prev.TranscodeEnabled = true + _, _ = h.App.Database.UpsertMediastreamSettings(prev) + } + }() + } + + if b.DebridProvider != "" && b.DebridProvider != "none" { + go func() { + defer util.HandlePanicThen(func() {}) + prev, found := h.App.Database.GetDebridSettings() + if found { + prev.Enabled = true + prev.Provider = b.DebridProvider + prev.ApiKey = b.DebridApiKey + //prev.IncludeDebridStreamInLibrary = true + _, _ = h.App.Database.UpsertDebridSettings(prev) + } + }() + } + + h.App.WSEventManager.SendEvent("settings", settings) + + status := h.NewStatus(c) + + // Refresh modules that depend on the settings + h.App.InitOrRefreshModules() + + return h.RespondWithData(c, status) +} + +// HandleSaveSettings +// +// @summary updates the app settings. +// @desc This will update the app settings. +// @desc The client should re-fetch the server status after this. +// @route /api/v1/settings [PATCH] +// @returns handlers.Status +func (h *Handler) HandleSaveSettings(c echo.Context) error { + + type body struct { + Library models.LibrarySettings `json:"library"` + MediaPlayer models.MediaPlayerSettings `json:"mediaPlayer"` + Torrent models.TorrentSettings `json:"torrent"` + Anilist models.AnilistSettings `json:"anilist"` + Discord models.DiscordSettings `json:"discord"` + Manga models.MangaSettings `json:"manga"` + Notifications models.NotificationSettings `json:"notifications"` + Nakama models.NakamaSettings `json:"nakama"` + } + var b body + + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + if b.Library.LibraryPath != "" { + b.Library.LibraryPath = filepath.ToSlash(filepath.Clean(b.Library.LibraryPath)) + } + + if b.Library.LibraryPaths == nil || b.Library.LibraryPath == "" { + b.Library.LibraryPaths = []string{} + } + + for i, path := range b.Library.LibraryPaths { + b.Library.LibraryPaths[i] = filepath.ToSlash(filepath.Clean(path)) + } + + b.Library.LibraryPaths = lo.Filter(b.Library.LibraryPaths, func(s string, _ int) bool { + if s == "" || util.IsSameDir(s, b.Library.LibraryPath) { + return false + } + info, err := os.Stat(s) + if err != nil { + return false + } + return info.IsDir() + }) + + // Check that any library paths are not subdirectories of each other + for i, path1 := range b.Library.LibraryPaths { + if util.IsSubdirectory(b.Library.LibraryPath, path1) || util.IsSubdirectory(path1, b.Library.LibraryPath) { + return h.RespondWithError(c, errors.New("library paths cannot be subdirectories of each other")) + } + for j, path2 := range b.Library.LibraryPaths { + if i != j && util.IsSubdirectory(path1, path2) { + return h.RespondWithError(c, errors.New("library paths cannot be subdirectories of each other")) + } + } + } + + autoDownloaderSettings := models.AutoDownloaderSettings{} + prevSettings, err := h.App.Database.GetSettings() + if err == nil && prevSettings.AutoDownloader != nil { + autoDownloaderSettings = *prevSettings.AutoDownloader + } + // Disable auto-downloader if the torrent provider is set to none + if b.Library.TorrentProvider == torrent.ProviderNone && autoDownloaderSettings.Enabled { + h.App.Logger.Debug().Msg("app: Disabling auto-downloader because the torrent provider is set to none") + autoDownloaderSettings.Enabled = false + } + + settings, err := h.App.Database.UpsertSettings(&models.Settings{ + BaseModel: models.BaseModel{ + ID: 1, + UpdatedAt: time.Now(), + }, + Library: &b.Library, + MediaPlayer: &b.MediaPlayer, + Torrent: &b.Torrent, + Anilist: &b.Anilist, + Manga: &b.Manga, + Discord: &b.Discord, + Notifications: &b.Notifications, + Nakama: &b.Nakama, + AutoDownloader: &autoDownloaderSettings, + }) + + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.WSEventManager.SendEvent("settings", settings) + + status := h.NewStatus(c) + + // Refresh modules that depend on the settings + h.App.InitOrRefreshModules() + + return h.RespondWithData(c, status) +} + +// HandleSaveAutoDownloaderSettings +// +// @summary updates the auto-downloader settings. +// @route /api/v1/settings/auto-downloader [PATCH] +// @returns bool +func (h *Handler) HandleSaveAutoDownloaderSettings(c echo.Context) error { + + type body struct { + Interval int `json:"interval"` + Enabled bool `json:"enabled"` + DownloadAutomatically bool `json:"downloadAutomatically"` + EnableEnhancedQueries bool `json:"enableEnhancedQueries"` + EnableSeasonCheck bool `json:"enableSeasonCheck"` + UseDebrid bool `json:"useDebrid"` + } + + var b body + + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + currSettings, err := h.App.Database.GetSettings() + if err != nil { + return h.RespondWithError(c, err) + } + + // Validation + if b.Interval < 15 { + return h.RespondWithError(c, errors.New("interval must be at least 15 minutes")) + } + + autoDownloaderSettings := &models.AutoDownloaderSettings{ + Provider: currSettings.Library.TorrentProvider, + Interval: b.Interval, + Enabled: b.Enabled, + DownloadAutomatically: b.DownloadAutomatically, + EnableEnhancedQueries: b.EnableEnhancedQueries, + EnableSeasonCheck: b.EnableSeasonCheck, + UseDebrid: b.UseDebrid, + } + + currSettings.AutoDownloader = autoDownloaderSettings + currSettings.BaseModel = models.BaseModel{ + ID: 1, + UpdatedAt: time.Now(), + } + + _, err = h.App.Database.UpsertSettings(currSettings) + if err != nil { + return h.RespondWithError(c, err) + } + + // Update Auto Downloader - This runs in a goroutine + h.App.AutoDownloader.SetSettings(autoDownloaderSettings, currSettings.Library.TorrentProvider) + + return h.RespondWithData(c, true) +} diff --git a/seanime-2.9.10/internal/handlers/status.go b/seanime-2.9.10/internal/handlers/status.go new file mode 100644 index 0000000..fa67fa1 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/status.go @@ -0,0 +1,599 @@ +package handlers + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "runtime/pprof" + "seanime/internal/constants" + "seanime/internal/core" + "seanime/internal/database/models" + "seanime/internal/user" + "seanime/internal/util" + "seanime/internal/util/result" + "slices" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v4" +) + +// Status is a struct containing the user data, settings, and OS. +// It is used by the client in various places to access necessary information. +type Status struct { + OS string `json:"os"` + ClientDevice string `json:"clientDevice"` + ClientPlatform string `json:"clientPlatform"` + ClientUserAgent string `json:"clientUserAgent"` + DataDir string `json:"dataDir"` + User *user.User `json:"user"` + Settings *models.Settings `json:"settings"` + Version string `json:"version"` + VersionName string `json:"versionName"` + ThemeSettings *models.Theme `json:"themeSettings"` + IsOffline bool `json:"isOffline"` + MediastreamSettings *models.MediastreamSettings `json:"mediastreamSettings"` + TorrentstreamSettings *models.TorrentstreamSettings `json:"torrentstreamSettings"` + DebridSettings *models.DebridSettings `json:"debridSettings"` + AnilistClientID string `json:"anilistClientId"` + Updating bool `json:"updating"` // If true, a new screen will be displayed + IsDesktopSidecar bool `json:"isDesktopSidecar"` // The server is running as a desktop sidecar + FeatureFlags core.FeatureFlags `json:"featureFlags"` + ServerReady bool `json:"serverReady"` + ServerHasPassword bool `json:"serverHasPassword"` +} + +var clientInfoCache = result.NewResultMap[string, util.ClientInfo]() + +// NewStatus returns a new Status struct. +// It uses the RouteCtx to get the App instance containing the Database instance. +func (h *Handler) NewStatus(c echo.Context) *Status { + var dbAcc *models.Account + var currentUser *user.User + var settings *models.Settings + var theme *models.Theme + //var mal *models.Mal + + // Get the user from the database (if logged in) + if dbAcc, _ = h.App.Database.GetAccount(); dbAcc != nil { + currentUser, _ = user.NewUser(dbAcc) + if currentUser != nil { + currentUser.Token = "HIDDEN" + } + } else { + // If the user is not logged in, create a simulated user + currentUser = user.NewSimulatedUser() + } + + if settings, _ = h.App.Database.GetSettings(); settings != nil { + if settings.ID == 0 || settings.Library == nil || settings.Torrent == nil || settings.MediaPlayer == nil { + settings = nil + } + } + + clientInfo, found := clientInfoCache.Get(c.Request().UserAgent()) + if !found { + clientInfo = util.GetClientInfo(c.Request().UserAgent()) + clientInfoCache.Set(c.Request().UserAgent(), clientInfo) + } + + theme, _ = h.App.Database.GetTheme() + + status := &Status{ + OS: runtime.GOOS, + ClientDevice: clientInfo.Device, + ClientPlatform: clientInfo.Platform, + DataDir: h.App.Config.Data.AppDataDir, + ClientUserAgent: c.Request().UserAgent(), + User: currentUser, + Settings: settings, + Version: h.App.Version, + VersionName: constants.VersionName, + ThemeSettings: theme, + IsOffline: h.App.Config.Server.Offline, + MediastreamSettings: h.App.SecondarySettings.Mediastream, + TorrentstreamSettings: h.App.SecondarySettings.Torrentstream, + DebridSettings: h.App.SecondarySettings.Debrid, + AnilistClientID: h.App.Config.Anilist.ClientID, + Updating: false, + IsDesktopSidecar: h.App.IsDesktopSidecar, + FeatureFlags: h.App.FeatureFlags, + ServerReady: h.App.ServerReady, + ServerHasPassword: h.App.Config.Server.Password != "", + } + + if c.Get("unauthenticated") != nil && c.Get("unauthenticated").(bool) { + // If the user is unauthenticated, return a status with no user data + status.OS = "" + status.DataDir = "" + status.User = user.NewSimulatedUser() + status.ThemeSettings = nil + status.MediastreamSettings = nil + status.TorrentstreamSettings = nil + status.Settings = &models.Settings{} + status.DebridSettings = nil + status.FeatureFlags = core.FeatureFlags{} + } + + return status +} + +// HandleGetStatus +// +// @summary returns the server status. +// @desc The server status includes app info, auth info and settings. +// @desc The client uses this to set the UI. +// @desc It is called on every page load to get the most up-to-date data. +// @desc It should be called right after updating the settings. +// @route /api/v1/status [GET] +// @returns handlers.Status +func (h *Handler) HandleGetStatus(c echo.Context) error { + + status := h.NewStatus(c) + + return h.RespondWithData(c, status) + +} + +func (h *Handler) HandleGetLogContent(c echo.Context) error { + if h.App.Config == nil || h.App.Config.Logs.Dir == "" { + return h.RespondWithData(c, "") + } + + filename := c.Param("*") + if filepath.Base(filename) != filename { + h.App.Logger.Error().Msg("handlers: Invalid filename") + return h.RespondWithError(c, fmt.Errorf("invalid filename")) + } + + fp := filepath.Join(h.App.Config.Logs.Dir, filename) + + if filepath.Ext(fp) != ".log" { + h.App.Logger.Error().Msg("handlers: Unsupported file extension") + return h.RespondWithError(c, fmt.Errorf("unsupported file extension")) + } + + if _, err := os.Stat(fp); err != nil { + h.App.Logger.Error().Err(err).Msg("handlers: Stat error") + return h.RespondWithError(c, err) + } + + contentB, err := os.ReadFile(fp) + if err != nil { + h.App.Logger.Error().Err(err).Msg("handlers: Failed to read log file") + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, string(contentB)) +} + +var newestLogFilename = "" + +// HandleGetLogFilenames +// +// @summary returns the log filenames. +// @desc This returns the filenames of all log files in the logs directory. +// @route /api/v1/logs/filenames [GET] +// @returns []string +func (h *Handler) HandleGetLogFilenames(c echo.Context) error { + if h.App.Config == nil || h.App.Config.Logs.Dir == "" { + return h.RespondWithData(c, []string{}) + } + + var filenames []string + filepath.WalkDir(h.App.Config.Logs.Dir, func(path string, d fs.DirEntry, err error) error { + if d.IsDir() { + return nil + } + + if filepath.Ext(path) != ".log" { + return nil + } + + filenames = append(filenames, filepath.Base(path)) + return nil + }) + + // Sort from newest to oldest & store the newest log filename + if len(filenames) > 0 { + slices.SortStableFunc(filenames, func(i, j string) int { + return strings.Compare(j, i) + }) + for _, filename := range filenames { + if strings.HasPrefix(strings.ToLower(filename), "seanime-") { + newestLogFilename = filename + break + } + } + } + + return h.RespondWithData(c, filenames) +} + +// HandleDeleteLogs +// +// @summary deletes certain log files. +// @desc This deletes the log files with the given filenames. +// @route /api/v1/logs [DELETE] +// @returns bool +func (h *Handler) HandleDeleteLogs(c echo.Context) error { + type body struct { + Filenames []string `json:"filenames"` + } + + if h.App.Config == nil || h.App.Config.Logs.Dir == "" { + return h.RespondWithData(c, false) + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + filepath.WalkDir(h.App.Config.Logs.Dir, func(path string, d fs.DirEntry, err error) error { + if d.IsDir() { + return nil + } + + if filepath.Ext(path) != ".log" { + return nil + } + + for _, filename := range b.Filenames { + if util.NormalizePath(filepath.Base(path)) == util.NormalizePath(filename) { + if util.NormalizePath(newestLogFilename) == util.NormalizePath(filename) { + return fmt.Errorf("cannot delete the newest log file") + } + if err := os.Remove(path); err != nil { + return err + } + } + } + return nil + }) + + return h.RespondWithData(c, true) +} + +// HandleGetLatestLogContent +// +// @summary returns the content of the latest server log file. +// @desc This returns the content of the most recent seanime- log file after flushing logs. +// @route /api/v1/logs/latest [GET] +// @returns string +func (h *Handler) HandleGetLatestLogContent(c echo.Context) error { + if h.App.Config == nil || h.App.Config.Logs.Dir == "" { + return h.RespondWithData(c, "") + } + + // Flush logs first + if h.App.OnFlushLogs != nil { + h.App.OnFlushLogs() + // Small delay to ensure logs are written + time.Sleep(100 * time.Millisecond) + } + + dirEntries, err := os.ReadDir(h.App.Config.Logs.Dir) + if err != nil { + h.App.Logger.Error().Err(err).Msg("handlers: Failed to read log directory") + return h.RespondWithError(c, err) + } + + var logFiles []string + for _, entry := range dirEntries { + if entry.IsDir() { + continue + } + name := entry.Name() + if filepath.Ext(name) != ".log" || !strings.HasPrefix(strings.ToLower(name), "seanime-") { + continue + } + logFiles = append(logFiles, filepath.Join(h.App.Config.Logs.Dir, name)) + } + + if len(logFiles) == 0 { + h.App.Logger.Warn().Msg("handlers: No log files found") + return h.RespondWithData(c, "") + } + + // Sort files in descending order based on filename + slices.SortFunc(logFiles, func(a, b string) int { + return strings.Compare(filepath.Base(b), filepath.Base(a)) + }) + + latestLogFile := logFiles[0] + + contentB, err := os.ReadFile(latestLogFile) + if err != nil { + h.App.Logger.Error().Err(err).Msg("handlers: Failed to read latest log file") + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, string(contentB)) +} + +// HandleGetAnnouncements +// +// @summary returns the server announcements. +// @desc This returns the announcements for the server. +// @route /api/v1/announcements [POST] +// @returns []updater.Announcement +func (h *Handler) HandleGetAnnouncements(c echo.Context) error { + type body struct { + Platform string `json:"platform"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + settings, _ := h.App.Database.GetSettings() + + announcements := h.App.Updater.GetAnnouncements(h.App.Version, b.Platform, settings) + + return h.RespondWithData(c, announcements) + +} + +type MemoryStatsResponse struct { + Alloc uint64 `json:"alloc"` // bytes allocated and not yet freed + TotalAlloc uint64 `json:"totalAlloc"` // bytes allocated (even if freed) + Sys uint64 `json:"sys"` // bytes obtained from system + Lookups uint64 `json:"lookups"` // number of pointer lookups + Mallocs uint64 `json:"mallocs"` // number of mallocs + Frees uint64 `json:"frees"` // number of frees + HeapAlloc uint64 `json:"heapAlloc"` // bytes allocated and not yet freed + HeapSys uint64 `json:"heapSys"` // bytes obtained from system + HeapIdle uint64 `json:"heapIdle"` // bytes in idle spans + HeapInuse uint64 `json:"heapInuse"` // bytes in non-idle span + HeapReleased uint64 `json:"heapReleased"` // bytes released to OS + HeapObjects uint64 `json:"heapObjects"` // total number of allocated objects + StackInuse uint64 `json:"stackInuse"` // bytes used by stack allocator + StackSys uint64 `json:"stackSys"` // bytes obtained from system for stack allocator + MSpanInuse uint64 `json:"mSpanInuse"` // bytes used by mspan structures + MSpanSys uint64 `json:"mSpanSys"` // bytes obtained from system for mspan structures + MCacheInuse uint64 `json:"mCacheInuse"` // bytes used by mcache structures + MCacheSys uint64 `json:"mCacheSys"` // bytes obtained from system for mcache structures + BuckHashSys uint64 `json:"buckHashSys"` // bytes used by the profiling bucket hash table + GCSys uint64 `json:"gcSys"` // bytes used for garbage collection system metadata + OtherSys uint64 `json:"otherSys"` // bytes used for other system allocations + NextGC uint64 `json:"nextGC"` // next collection will happen when HeapAlloc ≥ this amount + LastGC uint64 `json:"lastGC"` // time the last garbage collection finished + PauseTotalNs uint64 `json:"pauseTotalNs"` // cumulative nanoseconds in GC stop-the-world pauses + PauseNs uint64 `json:"pauseNs"` // nanoseconds in recent GC stop-the-world pause + NumGC uint32 `json:"numGC"` // number of completed GC cycles + NumForcedGC uint32 `json:"numForcedGC"` // number of GC cycles that were forced by the application calling the GC function + GCCPUFraction float64 `json:"gcCPUFraction"` // fraction of this program's available CPU time used by the GC since the program started + EnableGC bool `json:"enableGC"` // boolean that indicates GC is enabled + DebugGC bool `json:"debugGC"` // boolean that indicates GC debug mode is enabled + NumGoroutine int `json:"numGoroutine"` // number of goroutines +} + +// HandleGetMemoryStats +// +// @summary returns current memory statistics. +// @desc This returns real-time memory usage statistics from the Go runtime. +// @route /api/v1/memory/stats [GET] +// @returns handlers.MemoryStatsResponse +func (h *Handler) HandleGetMemoryStats(c echo.Context) error { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + // Force garbage collection to get accurate memory stats + // runtime.GC() + runtime.ReadMemStats(&m) + + response := MemoryStatsResponse{ + Alloc: m.Alloc, + TotalAlloc: m.TotalAlloc, + Sys: m.Sys, + Lookups: m.Lookups, + Mallocs: m.Mallocs, + Frees: m.Frees, + HeapAlloc: m.HeapAlloc, + HeapSys: m.HeapSys, + HeapIdle: m.HeapIdle, + HeapInuse: m.HeapInuse, + HeapReleased: m.HeapReleased, + HeapObjects: m.HeapObjects, + StackInuse: m.StackInuse, + StackSys: m.StackSys, + MSpanInuse: m.MSpanInuse, + MSpanSys: m.MSpanSys, + MCacheInuse: m.MCacheInuse, + MCacheSys: m.MCacheSys, + BuckHashSys: m.BuckHashSys, + GCSys: m.GCSys, + OtherSys: m.OtherSys, + NextGC: m.NextGC, + LastGC: m.LastGC, + PauseTotalNs: m.PauseTotalNs, + PauseNs: m.PauseNs[0], // Most recent pause + NumGC: m.NumGC, + NumForcedGC: m.NumForcedGC, + GCCPUFraction: m.GCCPUFraction, + EnableGC: m.EnableGC, + DebugGC: m.DebugGC, + NumGoroutine: runtime.NumGoroutine(), + } + + return h.RespondWithData(c, response) +} + +// HandleGetMemoryProfile +// +// @summary generates and returns a memory profile. +// @desc This generates a memory profile that can be analyzed with go tool pprof. +// @desc Query parameters: heap=true for heap profile, allocs=true for alloc profile. +// @route /api/v1/memory/profile [GET] +// @returns nil +func (h *Handler) HandleGetMemoryProfile(c echo.Context) error { + // Parse query parameters + heap := c.QueryParam("heap") == "true" + allocs := c.QueryParam("allocs") == "true" + + // Default to heap profile if no specific type requested + if !heap && !allocs { + heap = true + } + + // Set response headers for file download + timestamp := time.Now().Format("2006-01-02_15-04-05") + var filename string + var profile *pprof.Profile + var err error + + if heap { + filename = fmt.Sprintf("seanime-heap-profile-%s.pprof", timestamp) + profile = pprof.Lookup("heap") + } else if allocs { + filename = fmt.Sprintf("seanime-allocs-profile-%s.pprof", timestamp) + profile = pprof.Lookup("allocs") + } + + if profile == nil { + h.App.Logger.Error().Msg("handlers: Failed to lookup memory profile") + return h.RespondWithError(c, fmt.Errorf("failed to lookup memory profile")) + } + + c.Response().Header().Set("Content-Type", "application/octet-stream") + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + // // Force garbage collection before profiling for more accurate results + // runtime.GC() + + // Write profile to response + if err = profile.WriteTo(c.Response().Writer, 0); err != nil { + h.App.Logger.Error().Err(err).Msg("handlers: Failed to write memory profile") + return h.RespondWithError(c, err) + } + + return nil +} + +// HandleGetGoRoutineProfile +// +// @summary generates and returns a goroutine profile. +// @desc This generates a goroutine profile showing all running goroutines and their stack traces. +// @route /api/v1/memory/goroutine [GET] +// @returns nil +func (h *Handler) HandleGetGoRoutineProfile(c echo.Context) error { + timestamp := time.Now().Format("2006-01-02_15-04-05") + filename := fmt.Sprintf("seanime-goroutine-profile-%s.pprof", timestamp) + + profile := pprof.Lookup("goroutine") + if profile == nil { + h.App.Logger.Error().Msg("handlers: Failed to lookup goroutine profile") + return h.RespondWithError(c, fmt.Errorf("failed to lookup goroutine profile")) + } + + c.Response().Header().Set("Content-Type", "application/octet-stream") + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + if err := profile.WriteTo(c.Response().Writer, 0); err != nil { + h.App.Logger.Error().Err(err).Msg("handlers: Failed to write goroutine profile") + return h.RespondWithError(c, err) + } + + return nil +} + +// HandleGetCPUProfile +// +// @summary generates and returns a CPU profile. +// @desc This generates a CPU profile for the specified duration (default 30 seconds). +// @desc Query parameter: duration=30 for duration in seconds. +// @route /api/v1/memory/cpu [GET] +// @returns nil +func (h *Handler) HandleGetCPUProfile(c echo.Context) error { + // Parse duration from query parameter (default to 30 seconds) + durationStr := c.QueryParam("duration") + duration := 30 * time.Second + if durationStr != "" { + if d, err := strconv.Atoi(durationStr); err == nil && d > 0 && d <= 300 { // Max 5 minutes + duration = time.Duration(d) * time.Second + } + } + + timestamp := time.Now().Format("2006-01-02_15-04-05") + filename := fmt.Sprintf("seanime-cpu-profile-%s.pprof", timestamp) + + c.Response().Header().Set("Content-Type", "application/octet-stream") + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + // Start CPU profiling + if err := pprof.StartCPUProfile(c.Response().Writer); err != nil { + h.App.Logger.Error().Err(err).Msg("handlers: Failed to start CPU profile") + return h.RespondWithError(c, err) + } + + // Profile for the specified duration + h.App.Logger.Info().Msgf("handlers: Starting CPU profile for %v", duration) + time.Sleep(duration) + + // Stop CPU profiling + pprof.StopCPUProfile() + h.App.Logger.Info().Msg("handlers: CPU profile completed") + + return nil +} + +// HandleForceGC +// +// @summary forces garbage collection and returns memory stats. +// @desc This forces a garbage collection cycle and returns the updated memory statistics. +// @route /api/v1/memory/gc [POST] +// @returns handlers.MemoryStatsResponse +func (h *Handler) HandleForceGC(c echo.Context) error { + h.App.Logger.Info().Msg("handlers: Forcing garbage collection") + + // Force garbage collection + runtime.GC() + runtime.GC() // Run twice to ensure cleanup + + // Get updated memory stats + var m runtime.MemStats + runtime.ReadMemStats(&m) + + response := MemoryStatsResponse{ + Alloc: m.Alloc, + TotalAlloc: m.TotalAlloc, + Sys: m.Sys, + Lookups: m.Lookups, + Mallocs: m.Mallocs, + Frees: m.Frees, + HeapAlloc: m.HeapAlloc, + HeapSys: m.HeapSys, + HeapIdle: m.HeapIdle, + HeapInuse: m.HeapInuse, + HeapReleased: m.HeapReleased, + HeapObjects: m.HeapObjects, + StackInuse: m.StackInuse, + StackSys: m.StackSys, + MSpanInuse: m.MSpanInuse, + MSpanSys: m.MSpanSys, + MCacheInuse: m.MCacheInuse, + MCacheSys: m.MCacheSys, + BuckHashSys: m.BuckHashSys, + GCSys: m.GCSys, + OtherSys: m.OtherSys, + NextGC: m.NextGC, + LastGC: m.LastGC, + PauseTotalNs: m.PauseTotalNs, + PauseNs: m.PauseNs[0], + NumGC: m.NumGC, + NumForcedGC: m.NumForcedGC, + GCCPUFraction: m.GCCPUFraction, + EnableGC: m.EnableGC, + DebugGC: m.DebugGC, + NumGoroutine: runtime.NumGoroutine(), + } + + h.App.Logger.Info().Msgf("handlers: GC completed, heap size: %d bytes", response.HeapAlloc) + + return h.RespondWithData(c, response) +} diff --git a/seanime-2.9.10/internal/handlers/theme.go b/seanime-2.9.10/internal/handlers/theme.go new file mode 100644 index 0000000..50b151d --- /dev/null +++ b/seanime-2.9.10/internal/handlers/theme.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "seanime/internal/database/models" + + "github.com/labstack/echo/v4" +) + +// HandleGetTheme +// +// @summary returns the theme settings. +// @route /api/v1/theme [GET] +// @returns models.Theme +func (h *Handler) HandleGetTheme(c echo.Context) error { + theme, err := h.App.Database.GetTheme() + if err != nil { + return h.RespondWithError(c, err) + } + return h.RespondWithData(c, theme) +} + +// HandleUpdateTheme +// +// @summary updates the theme settings. +// @desc The server status should be re-fetched after this on the client. +// @route /api/v1/theme [PATCH] +// @returns models.Theme +func (h *Handler) HandleUpdateTheme(c echo.Context) error { + type body struct { + Theme models.Theme `json:"theme"` + } + + var b body + + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + // Set the theme ID to 1, so we overwrite the previous settings + b.Theme.BaseModel = models.BaseModel{ + ID: 1, + } + + // Update the theme settings + if _, err := h.App.Database.UpsertTheme(&b.Theme); err != nil { + return h.RespondWithError(c, err) + } + + // Send the new theme to the client + return h.RespondWithData(c, b.Theme) +} diff --git a/seanime-2.9.10/internal/handlers/torrent_client.go b/seanime-2.9.10/internal/handlers/torrent_client.go new file mode 100644 index 0000000..c777a6a --- /dev/null +++ b/seanime-2.9.10/internal/handlers/torrent_client.go @@ -0,0 +1,264 @@ +package handlers + +import ( + "errors" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/database/db_bridge" + "seanime/internal/events" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/torrent_clients/torrent_client" + "seanime/internal/util" + + "github.com/labstack/echo/v4" +) + +// HandleGetActiveTorrentList +// +// @summary returns all active torrents. +// @desc This handler is used by the client to display the active torrents. +// +// @route /api/v1/torrent-client/list [GET] +// @returns []torrent_client.Torrent +func (h *Handler) HandleGetActiveTorrentList(c echo.Context) error { + + // Get torrent list + res, err := h.App.TorrentClientRepository.GetActiveTorrents() + // If an error occurred, try to start the torrent client and get the list again + // DEVNOTE: We try to get the list first because this route is called repeatedly by the client. + if err != nil { + ok := h.App.TorrentClientRepository.Start() + if !ok { + return h.RespondWithError(c, errors.New("could not start torrent client, verify your settings")) + } + res, err = h.App.TorrentClientRepository.GetActiveTorrents() + } + + return h.RespondWithData(c, res) + +} + +// HandleTorrentClientAction +// +// @summary performs an action on a torrent. +// @desc This handler is used to pause, resume or remove a torrent. +// @route /api/v1/torrent-client/action [POST] +// @returns bool +func (h *Handler) HandleTorrentClientAction(c echo.Context) error { + + type body struct { + Hash string `json:"hash"` + Action string `json:"action"` + Dir string `json:"dir"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + if b.Hash == "" || b.Action == "" { + return h.RespondWithError(c, errors.New("missing arguments")) + } + + switch b.Action { + case "pause": + err := h.App.TorrentClientRepository.PauseTorrents([]string{b.Hash}) + if err != nil { + return h.RespondWithError(c, err) + } + case "resume": + err := h.App.TorrentClientRepository.ResumeTorrents([]string{b.Hash}) + if err != nil { + return h.RespondWithError(c, err) + } + case "remove": + err := h.App.TorrentClientRepository.RemoveTorrents([]string{b.Hash}) + if err != nil { + return h.RespondWithError(c, err) + } + case "open": + if b.Dir == "" { + return h.RespondWithError(c, errors.New("directory not found")) + } + OpenDirInExplorer(b.Dir) + } + + return h.RespondWithData(c, true) + +} + +// HandleTorrentClientDownload +// +// @summary adds torrents to the torrent client. +// @desc It fetches the magnets from the provided URLs and adds them to the torrent client. +// @desc If smart select is enabled, it will try to select the best torrent based on the missing episodes. +// @route /api/v1/torrent-client/download [POST] +// @returns bool +func (h *Handler) HandleTorrentClientDownload(c echo.Context) error { + + type body struct { + Torrents []hibiketorrent.AnimeTorrent `json:"torrents"` + Destination string `json:"destination"` + SmartSelect struct { + Enabled bool `json:"enabled"` + MissingEpisodeNumbers []int `json:"missingEpisodeNumbers"` + } `json:"smartSelect"` + Media *anilist.BaseAnime `json:"media"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + if b.Destination == "" { + return h.RespondWithError(c, errors.New("destination not found")) + } + + if !filepath.IsAbs(b.Destination) { + return h.RespondWithError(c, errors.New("destination path must be absolute")) + } + + // Check that the destination path is a library path + //libraryPaths, err := h.App.Database.GetAllLibraryPathsFromSettings() + //if err != nil { + // return h.RespondWithError(c, err) + //} + //isInLibrary := util.IsSubdirectoryOfAny(libraryPaths, b.Destination) + //if !isInLibrary { + // return h.RespondWithError(c, errors.New("destination path is not a library path")) + //} + + // try to start torrent client if it's not running + ok := h.App.TorrentClientRepository.Start() + if !ok { + return h.RespondWithError(c, errors.New("could not contact torrent client, verify your settings or make sure it's running")) + } + + completeAnime, err := h.App.AnilistPlatform.GetAnimeWithRelations(c.Request().Context(), b.Media.ID) + if err != nil { + return h.RespondWithError(c, err) + } + + if b.SmartSelect.Enabled { + if len(b.Torrents) > 1 { + return h.RespondWithError(c, errors.New("smart select is not supported for multiple torrents")) + } + + // smart select + err = h.App.TorrentClientRepository.SmartSelect(&torrent_client.SmartSelectParams{ + Torrent: &b.Torrents[0], + EpisodeNumbers: b.SmartSelect.MissingEpisodeNumbers, + Media: completeAnime, + Destination: b.Destination, + Platform: h.App.AnilistPlatform, + ShouldAddTorrent: true, + }) + if err != nil { + return h.RespondWithError(c, err) + } + } else { + + // Get magnets + magnets := make([]string, 0) + for _, t := range b.Torrents { + // Get the torrent's provider extension + providerExtension, ok := h.App.TorrentRepository.GetAnimeProviderExtension(t.Provider) + if !ok { + return h.RespondWithError(c, errors.New("provider extension not found for torrent")) + } + // Get the torrent magnet link + magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(&t) + if err != nil { + return h.RespondWithError(c, err) + } + + magnets = append(magnets, magnet) + } + + // try to add torrents to client, on error return error + err = h.App.TorrentClientRepository.AddMagnets(magnets, b.Destination) + if err != nil { + return h.RespondWithError(c, err) + } + } + + // Add the media to the collection (if it wasn't already) + go func() { + defer util.HandlePanicInModuleThen("handlers/HandleTorrentClientDownload", func() {}) + if b.Media != nil { + // Check if the media is already in the collection + animeCollection, err := h.App.GetAnimeCollection(false) + if err != nil { + return + } + _, found := animeCollection.FindAnime(b.Media.ID) + if found { + return + } + // Add the media to the collection + err = h.App.AnilistPlatform.AddMediaToCollection(c.Request().Context(), []int{b.Media.ID}) + if err != nil { + h.App.Logger.Error().Err(err).Msg("anilist: Failed to add media to collection") + } + ac, _ := h.App.RefreshAnimeCollection() + h.App.WSEventManager.SendEvent(events.RefreshedAnilistAnimeCollection, ac) + } + }() + + return h.RespondWithData(c, true) + +} + +// HandleTorrentClientAddMagnetFromRule +// +// @summary adds magnets to the torrent client based on the AutoDownloader item. +// @desc This is used to download torrents that were queued by the AutoDownloader. +// @desc The item will be removed from the queue if the magnet was added successfully. +// @desc The AutoDownloader items should be re-fetched after this. +// @route /api/v1/torrent-client/rule-magnet [POST] +// @returns bool +func (h *Handler) HandleTorrentClientAddMagnetFromRule(c echo.Context) error { + + type body struct { + MagnetUrl string `json:"magnetUrl"` + RuleId uint `json:"ruleId"` + QueuedItemId uint `json:"queuedItemId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + if b.MagnetUrl == "" || b.RuleId == 0 { + return h.RespondWithError(c, errors.New("missing parameters")) + } + + // Get rule from database + rule, err := db_bridge.GetAutoDownloaderRule(h.App.Database, b.RuleId) + if err != nil { + return h.RespondWithError(c, err) + } + + // try to start torrent client if it's not running + ok := h.App.TorrentClientRepository.Start() + if !ok { + return h.RespondWithError(c, errors.New("could not start torrent client, verify your settings")) + } + + // try to add torrents to client, on error return error + err = h.App.TorrentClientRepository.AddMagnets([]string{b.MagnetUrl}, rule.Destination) + if err != nil { + return h.RespondWithError(c, err) + } + + if b.QueuedItemId > 0 { + // the magnet was added successfully, remove the item from the queue + err = h.App.Database.DeleteAutoDownloaderItem(b.QueuedItemId) + } + + return h.RespondWithData(c, true) + +} diff --git a/seanime-2.9.10/internal/handlers/torrent_search.go b/seanime-2.9.10/internal/handlers/torrent_search.go new file mode 100644 index 0000000..8d5127d --- /dev/null +++ b/seanime-2.9.10/internal/handlers/torrent_search.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "seanime/internal/api/anilist" + "seanime/internal/debrid/debrid" + "seanime/internal/torrents/torrent" + "seanime/internal/util/result" + "strings" + + "github.com/labstack/echo/v4" +) + +var debridInstantAvailabilityCache = result.NewCache[string, map[string]debrid.TorrentItemInstantAvailability]() + +// HandleSearchTorrent +// +// @summary searches torrents and returns a list of torrents and their previews. +// @desc This will search for torrents and return a list of torrents with previews. +// @desc If smart search is enabled, it will filter the torrents based on search parameters. +// @route /api/v1/torrent/search [POST] +// @returns torrent.SearchData +func (h *Handler) HandleSearchTorrent(c echo.Context) error { + + type body struct { + // "smart" or "simple" + Type string `json:"type,omitempty"` + Provider string `json:"provider,omitempty"` + Query string `json:"query,omitempty"` + EpisodeNumber int `json:"episodeNumber,omitempty"` + Batch bool `json:"batch,omitempty"` + Media anilist.BaseAnime `json:"media,omitempty"` + AbsoluteOffset int `json:"absoluteOffset,omitempty"` + Resolution string `json:"resolution,omitempty"` + BestRelease bool `json:"bestRelease,omitempty"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + data, err := h.App.TorrentRepository.SearchAnime(c.Request().Context(), torrent.AnimeSearchOptions{ + Provider: b.Provider, + Type: torrent.AnimeSearchType(b.Type), + Media: &b.Media, + Query: b.Query, + Batch: b.Batch, + EpisodeNumber: b.EpisodeNumber, + BestReleases: b.BestRelease, + Resolution: b.Resolution, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + // + // Debrid torrent instant availability + // + if h.App.SecondarySettings.Debrid.Enabled { + hashes := make([]string, 0) + for _, t := range data.Torrents { + if t.InfoHash == "" { + continue + } + hashes = append(hashes, t.InfoHash) + } + hashesKey := strings.Join(hashes, ",") + var found bool + data.DebridInstantAvailability, found = debridInstantAvailabilityCache.Get(hashesKey) + if !found { + provider, err := h.App.DebridClientRepository.GetProvider() + if err == nil { + instantAvail := provider.GetInstantAvailability(hashes) + data.DebridInstantAvailability = instantAvail + debridInstantAvailabilityCache.Set(hashesKey, instantAvail) + } + } + } + + return h.RespondWithData(c, data) +} diff --git a/seanime-2.9.10/internal/handlers/torrentstream.go b/seanime-2.9.10/internal/handlers/torrentstream.go new file mode 100644 index 0000000..6437227 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/torrentstream.go @@ -0,0 +1,223 @@ +package handlers + +import ( + "errors" + "os" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/database/models" + "seanime/internal/events" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/torrentstream" + + "github.com/labstack/echo/v4" +) + +// HandleGetTorrentstreamSettings +// +// @summary get torrentstream settings. +// @desc This returns the torrentstream settings. +// @returns models.TorrentstreamSettings +// @route /api/v1/torrentstream/settings [GET] +func (h *Handler) HandleGetTorrentstreamSettings(c echo.Context) error { + torrentstreamSettings, found := h.App.Database.GetTorrentstreamSettings() + if !found { + return h.RespondWithError(c, errors.New("torrent streaming settings not found")) + } + + return h.RespondWithData(c, torrentstreamSettings) +} + +// HandleSaveTorrentstreamSettings +// +// @summary save torrentstream settings. +// @desc This saves the torrentstream settings. +// @desc The client should refetch the server status. +// @returns models.TorrentstreamSettings +// @route /api/v1/torrentstream/settings [PATCH] +func (h *Handler) HandleSaveTorrentstreamSettings(c echo.Context) error { + + type body struct { + Settings models.TorrentstreamSettings `json:"settings"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + // Validate the download directory + if b.Settings.DownloadDir != "" { + dir, err := os.Stat(b.Settings.DownloadDir) + if err != nil { + h.App.Logger.Error().Err(err).Msgf("torrentstream: Download directory %s does not exist", b.Settings.DownloadDir) + h.App.WSEventManager.SendEvent(events.ErrorToast, "Download directory does not exist") + b.Settings.DownloadDir = "" + } + if !dir.IsDir() { + h.App.Logger.Error().Msgf("torrentstream: Download directory %s is not a directory", b.Settings.DownloadDir) + h.App.WSEventManager.SendEvent(events.ErrorToast, "Download directory is not a directory") + b.Settings.DownloadDir = "" + } + } + + settings, err := h.App.Database.UpsertTorrentstreamSettings(&b.Settings) + if err != nil { + return h.RespondWithError(c, err) + } + + h.App.InitOrRefreshTorrentstreamSettings() + + return h.RespondWithData(c, settings) +} + +// HandleGetTorrentstreamTorrentFilePreviews +// +// @summary get list of torrent files from a batch +// @desc This returns a list of file previews from the torrent +// @returns []torrentstream.FilePreview +// @route /api/v1/torrentstream/torrent-file-previews [POST] +func (h *Handler) HandleGetTorrentstreamTorrentFilePreviews(c echo.Context) error { + type body struct { + Torrent *hibiketorrent.AnimeTorrent `json:"torrent"` + EpisodeNumber int `json:"episodeNumber"` + Media *anilist.BaseAnime `json:"media"` + } + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + providerExtension, ok := h.App.ExtensionRepository.GetAnimeTorrentProviderExtensionByID(b.Torrent.Provider) + if !ok { + return h.RespondWithError(c, errors.New("torrentstream: Torrent provider extension not found")) + } + + magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(b.Torrent) + if err != nil { + return h.RespondWithError(c, err) + } + + // Get the media metadata + animeMetadata, _ := h.App.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, b.Media.ID) + absoluteOffset := 0 + if animeMetadata != nil { + absoluteOffset = animeMetadata.GetOffset() + } + + files, err := h.App.TorrentstreamRepository.GetTorrentFilePreviewsFromManualSelection(&torrentstream.GetTorrentFilePreviewsOptions{ + Torrent: b.Torrent, + Magnet: magnet, + EpisodeNumber: b.EpisodeNumber, + AbsoluteOffset: absoluteOffset, + Media: b.Media, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, files) +} + +// HandleTorrentstreamStartStream +// +// @summary starts a torrent stream. +// @desc This starts the entire streaming process. +// @returns bool +// @route /api/v1/torrentstream/start [POST] +func (h *Handler) HandleTorrentstreamStartStream(c echo.Context) error { + + type body struct { + MediaId int `json:"mediaId"` + EpisodeNumber int `json:"episodeNumber"` + AniDBEpisode string `json:"aniDBEpisode"` + AutoSelect bool `json:"autoSelect"` + Torrent *hibiketorrent.AnimeTorrent `json:"torrent,omitempty"` // Nil if autoSelect is true + FileIndex *int `json:"fileIndex,omitempty"` + PlaybackType torrentstream.PlaybackType `json:"playbackType"` // "default" or "externalPlayerLink" + ClientId string `json:"clientId"` + } + + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + userAgent := c.Request().Header.Get("User-Agent") + + err := h.App.TorrentstreamRepository.StartStream(c.Request().Context(), &torrentstream.StartStreamOptions{ + MediaId: b.MediaId, + EpisodeNumber: b.EpisodeNumber, + AniDBEpisode: b.AniDBEpisode, + AutoSelect: b.AutoSelect, + Torrent: b.Torrent, + FileIndex: b.FileIndex, + UserAgent: userAgent, + ClientId: b.ClientId, + PlaybackType: b.PlaybackType, + }) + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleTorrentstreamStopStream +// +// @summary stop a torrent stream. +// @desc This stops the entire streaming process and drops the torrent if it's below a threshold. +// @desc This is made to be used while the stream is running. +// @returns bool +// @route /api/v1/torrentstream/stop [POST] +func (h *Handler) HandleTorrentstreamStopStream(c echo.Context) error { + + err := h.App.TorrentstreamRepository.StopStream() + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleTorrentstreamDropTorrent +// +// @summary drops a torrent stream. +// @desc This stops the entire streaming process and drops the torrent completely. +// @desc This is made to be used to force drop a torrent. +// @returns bool +// @route /api/v1/torrentstream/drop [POST] +func (h *Handler) HandleTorrentstreamDropTorrent(c echo.Context) error { + + err := h.App.TorrentstreamRepository.DropTorrent() + if err != nil { + return h.RespondWithError(c, err) + } + + return h.RespondWithData(c, true) +} + +// HandleGetTorrentstreamBatchHistory +// +// @summary returns the most recent batch selected. +// @desc This returns the most recent batch selected. +// @returns torrentstream.BatchHistoryResponse +// @route /api/v1/torrentstream/batch-history [POST] +func (h *Handler) HandleGetTorrentstreamBatchHistory(c echo.Context) error { + type body struct { + MediaID int `json:"mediaId"` + } + var b body + if err := c.Bind(&b); err != nil { + return h.RespondWithError(c, err) + } + + ret := h.App.TorrentstreamRepository.GetBatchHistory(b.MediaID) + return h.RespondWithData(c, ret) +} + +// route /api/v1/torrentstream/stream/* +func (h *Handler) HandleTorrentstreamServeStream(c echo.Context) error { + h.App.TorrentstreamRepository.HTTPStreamHandler().ServeHTTP(c.Response().Writer, c.Request()) + return nil +} diff --git a/seanime-2.9.10/internal/handlers/websocket.go b/seanime-2.9.10/internal/handlers/websocket.go new file mode 100644 index 0000000..a2bdf99 --- /dev/null +++ b/seanime-2.9.10/internal/handlers/websocket.go @@ -0,0 +1,94 @@ +package handlers + +import ( + "net/http" + "seanime/internal/events" + + "github.com/goccy/go-json" + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" +) + +var ( + upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + } +) + +// webSocketEventHandler creates a new websocket handler for real-time event communication +func (h *Handler) webSocketEventHandler(c echo.Context) error { + ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer ws.Close() + + // Get connection ID from query parameter + id := c.QueryParam("id") + if id == "" { + id = "0" + } + + // Add connection to manager + h.App.WSEventManager.AddConn(id, ws) + h.App.Logger.Debug().Str("id", id).Msg("ws: Client connected") + + for { + _, msg, err := ws.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + h.App.Logger.Debug().Str("id", id).Msg("ws: Client disconnected") + } else { + h.App.Logger.Debug().Str("id", id).Msg("ws: Client disconnection") + } + h.App.WSEventManager.RemoveConn(id) + break + } + + event, err := UnmarshalWebsocketClientEvent(msg) + if err != nil { + h.App.Logger.Error().Err(err).Msg("ws: Failed to unmarshal message sent from webview") + continue + } + + // Handle ping messages + if event.Type == "ping" { + timestamp := int64(0) + if payload, ok := event.Payload.(map[string]interface{}); ok { + if ts, ok := payload["timestamp"]; ok { + if tsFloat, ok := ts.(float64); ok { + timestamp = int64(tsFloat) + } else if tsInt, ok := ts.(int64); ok { + timestamp = tsInt + } + } + } + + // Send pong response back to the same client + h.App.WSEventManager.SendEventTo(event.ClientID, "pong", map[string]int64{"timestamp": timestamp}) + continue // Skip further processing for ping messages + } + + h.HandleClientEvents(event) + + // h.App.Logger.Debug().Msgf("ws: message received: %+v", msg) + + // // Echo the message back + // if err = ws.WriteMessage(messageType, msg); err != nil { + // h.App.Logger.Err(err).Msg("ws: Failed to send message") + // break + // } + } + + return nil +} + +func UnmarshalWebsocketClientEvent(msg []byte) (*events.WebsocketClientEvent, error) { + var event events.WebsocketClientEvent + if err := json.Unmarshal(msg, &event); err != nil { + return nil, err + } + return &event, nil +} diff --git a/seanime-2.9.10/internal/hook/README.md b/seanime-2.9.10/internal/hook/README.md new file mode 100644 index 0000000..7948471 --- /dev/null +++ b/seanime-2.9.10/internal/hook/README.md @@ -0,0 +1,35 @@ +### `Request` events + +- Route scoped +- A handler that does the native job is called last and can be interrupted if `e.next()` isn't called + + +### `Requested` events + +- Example: `onAnimeEntryRequested` +- Called before creation of a struct +- Native job cannot be interrupted even if `e.next()` isn't called +- Followed by event containing the struct, e.g. `onAnimeEntry` + + +### TODO + +- [ ] Scanning +- [ ] Torrent client +- [ ] Torrent search +- [ ] AutoDownloader +- [ ] Torrent streaming +- [ ] Debrid / Debrid streaming +- [ ] PlaybackManager +- [ ] Media Player +- [ ] Sync / Offline +- [ ] Online streaming +- [ ] Metadata provider +- [ ] Manga +- [ ] Media streaming + +--- + +- [ ] Command palette +- [ ] Database +- [ ] App Context \ No newline at end of file diff --git a/seanime-2.9.10/internal/hook/hook.go b/seanime-2.9.10/internal/hook/hook.go new file mode 100644 index 0000000..81f73b0 --- /dev/null +++ b/seanime-2.9.10/internal/hook/hook.go @@ -0,0 +1,200 @@ +package hook + +import ( + "seanime/internal/hook_resolver" + "sort" + "sync" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +// Handler defines a single Hook handler. +// Multiple handlers can share the same id. +// If Id is not explicitly set it will be autogenerated by Hook.Add and Hook.AddHandler. +type Handler[T hook_resolver.Resolver] struct { + // Func defines the handler function to execute. + // + // Note that users need to call e.Next() in order to proceed with + // the execution of the hook chain. + Func func(T) error + + // Id is the unique identifier of the handler. + // + // It could be used later to remove the handler from a hook via [Hook.Remove]. + // + // If missing, an autogenerated value will be assigned when adding + // the handler to a hook. + Id string + + // Priority allows changing the default exec priority of the handler within a hook. + // + // If 0, the handler will be executed in the same order it was registered. + Priority int +} + +// Hook defines a generic concurrent safe structure for managing event hooks. +// When using custom event it must embed the base [hook.Event]. +// +// Example: +// +// type CustomEvent struct { +// hook.Event +// SomeField int +// } +// +// h := Hook[*CustomEvent]{} +// +// h.BindFunc(func(e *CustomEvent) error { +// println(e.SomeField) +// +// return e.Next() +// }) +// +// h.Trigger(&CustomEvent{ SomeField: 123 }) +type Hook[T hook_resolver.Resolver] struct { + handlers []*Handler[T] + mu sync.RWMutex +} + +// Bind registers the provided handler to the current hooks queue. +// +// If handler.Id is empty it is updated with autogenerated value. +// +// If a handler from the current hook list has Id matching handler.Id +// then the old handler is replaced with the new one. +func (h *Hook[T]) Bind(handler *Handler[T]) string { + if h == nil { + return "" + } + h.mu.Lock() + defer h.mu.Unlock() + + var exists bool + + if handler.Id == "" { + handler.Id = generateHookId() + + // ensure that it doesn't exist + DuplicateCheck: + for _, existing := range h.handlers { + if existing.Id == handler.Id { + handler.Id = generateHookId() + goto DuplicateCheck + } + } + } else { + // replace existing + for i, existing := range h.handlers { + if existing.Id == handler.Id { + h.handlers[i] = handler + exists = true + break + } + } + } + + // append new + if !exists { + h.handlers = append(h.handlers, handler) + } + + // sort handlers by Priority, preserving the original order of equal items + sort.SliceStable(h.handlers, func(i, j int) bool { + return h.handlers[i].Priority < h.handlers[j].Priority + }) + + return handler.Id +} + +// BindFunc is similar to Bind but registers a new handler from just the provided function. +// +// The registered handler is added with a default 0 priority and the id will be autogenerated. +// +// If you want to register a handler with custom priority or id use the [Hook.Bind] method. +func (h *Hook[T]) BindFunc(fn func(e T) error) string { + return h.Bind(&Handler[T]{Func: fn}) +} + +// Unbind removes one or many hook handler by their id. +func (h *Hook[T]) Unbind(idsToRemove ...string) { + if h == nil { + return + } + h.mu.Lock() + defer h.mu.Unlock() + + for _, id := range idsToRemove { + for i := len(h.handlers) - 1; i >= 0; i-- { + if h.handlers[i].Id == id { + h.handlers = append(h.handlers[:i], h.handlers[i+1:]...) + break // for now stop on the first occurrence since we don't allow handlers with duplicated ids + } + } + } +} + +// UnbindAll removes all registered handlers. +func (h *Hook[T]) UnbindAll() { + if h == nil { + return + } + h.mu.Lock() + defer h.mu.Unlock() + + h.handlers = nil +} + +// Length returns to total number of registered hook handlers. +func (h *Hook[T]) Length() int { + if h == nil { + return 0 + } + h.mu.RLock() + defer h.mu.RUnlock() + + return len(h.handlers) +} + +// Trigger executes all registered hook handlers one by one +// with the specified event as an argument. +// +// Optionally, this method allows also to register additional one off +// handler funcs that will be temporary appended to the handlers queue. +// +// NB! Each hook handler must call event.Next() in order the hook chain to proceed. +func (h *Hook[T]) Trigger(event T, oneOffHandlerFuncs ...func(T) error) error { + if h == nil { + event.SetNextFunc(nil) + return event.Next() + } + h.mu.RLock() + handlers := make([]func(T) error, 0, len(h.handlers)+len(oneOffHandlerFuncs)) + for _, handler := range h.handlers { + handlers = append(handlers, handler.Func) + } + handlers = append(handlers, oneOffHandlerFuncs...) + h.mu.RUnlock() + + event.SetNextFunc(nil) // reset in case the event is being reused + + for i := len(handlers) - 1; i >= 0; i-- { + i := i + old := event.NextFunc() + event.SetNextFunc(func() error { + event.SetNextFunc(old) + handlerErr := handlers[i](event) + if handlerErr != nil { + log.Error().Err(handlerErr).Msg("hook: Error in handler") + return handlerErr + } + return nil + }) + } + + return event.Next() +} + +func generateHookId() string { + return uuid.New().String() +} diff --git a/seanime-2.9.10/internal/hook/hook_test.go b/seanime-2.9.10/internal/hook/hook_test.go new file mode 100644 index 0000000..1080b34 --- /dev/null +++ b/seanime-2.9.10/internal/hook/hook_test.go @@ -0,0 +1,52 @@ +package hook + +import ( + "errors" + "seanime/internal/hook_resolver" + "testing" +) + +func TestHookAddHandlerAndAdd(t *testing.T) { + calls := "" + + h := Hook[*hook_resolver.Event]{} + + h.BindFunc(func(e *hook_resolver.Event) error { calls += "1"; return e.Next() }) + h.BindFunc(func(e *hook_resolver.Event) error { calls += "2"; return e.Next() }) + h3Id := h.BindFunc(func(e *hook_resolver.Event) error { calls += "3"; return e.Next() }) + h.Bind(&Handler[*hook_resolver.Event]{ + Id: h3Id, // should replace 3 + Func: func(e *hook_resolver.Event) error { calls += "3'"; return e.Next() }, + }) + h.Bind(&Handler[*hook_resolver.Event]{ + Func: func(e *hook_resolver.Event) error { calls += "4"; return e.Next() }, + Priority: -2, + }) + h.Bind(&Handler[*hook_resolver.Event]{ + Func: func(e *hook_resolver.Event) error { calls += "5"; return e.Next() }, + Priority: -1, + }) + h.Bind(&Handler[*hook_resolver.Event]{ + Func: func(e *hook_resolver.Event) error { calls += "6"; return e.Next() }, + }) + h.Bind(&Handler[*hook_resolver.Event]{ + Func: func(e *hook_resolver.Event) error { calls += "7"; e.Next(); return errors.New("test") }, // error shouldn't stop the chain + }) + + h.Trigger( + &hook_resolver.Event{}, + func(e *hook_resolver.Event) error { calls += "8"; return e.Next() }, + func(e *hook_resolver.Event) error { calls += "9"; return nil }, // skip next + func(e *hook_resolver.Event) error { calls += "10"; return e.Next() }, + ) + + if total := len(h.handlers); total != 7 { + t.Fatalf("Expected %d handlers, found %d", 7, total) + } + + expectedCalls := "45123'6789" + + if calls != expectedCalls { + t.Fatalf("Expected calls sequence %q, got %q", expectedCalls, calls) + } +} diff --git a/seanime-2.9.10/internal/hook/hooks.go b/seanime-2.9.10/internal/hook/hooks.go new file mode 100644 index 0000000..e44ecfa --- /dev/null +++ b/seanime-2.9.10/internal/hook/hooks.go @@ -0,0 +1,1119 @@ +package hook + +import ( + "seanime/internal/hook_resolver" + "seanime/internal/util" + + "github.com/rs/zerolog" +) + +// Manager manages all hooks in the application +type Manager interface { + // AniList events + OnGetAnime() *Hook[hook_resolver.Resolver] + OnGetAnimeDetails() *Hook[hook_resolver.Resolver] + OnGetManga() *Hook[hook_resolver.Resolver] + OnGetMangaDetails() *Hook[hook_resolver.Resolver] + OnGetAnimeCollection() *Hook[hook_resolver.Resolver] + OnGetMangaCollection() *Hook[hook_resolver.Resolver] + OnGetCachedAnimeCollection() *Hook[hook_resolver.Resolver] + OnGetCachedMangaCollection() *Hook[hook_resolver.Resolver] + OnGetRawAnimeCollection() *Hook[hook_resolver.Resolver] + OnGetRawMangaCollection() *Hook[hook_resolver.Resolver] + OnGetCachedRawAnimeCollection() *Hook[hook_resolver.Resolver] + OnGetCachedRawMangaCollection() *Hook[hook_resolver.Resolver] + OnGetStudioDetails() *Hook[hook_resolver.Resolver] + OnPreUpdateEntry() *Hook[hook_resolver.Resolver] + OnPostUpdateEntry() *Hook[hook_resolver.Resolver] + OnPreUpdateEntryProgress() *Hook[hook_resolver.Resolver] + OnPostUpdateEntryProgress() *Hook[hook_resolver.Resolver] + OnPreUpdateEntryRepeat() *Hook[hook_resolver.Resolver] + OnPostUpdateEntryRepeat() *Hook[hook_resolver.Resolver] + + // Anime library events + OnAnimeEntryRequested() *Hook[hook_resolver.Resolver] + OnAnimeEntry() *Hook[hook_resolver.Resolver] + + OnAnimeEntryFillerHydration() *Hook[hook_resolver.Resolver] + + OnAnimeEntryLibraryDataRequested() *Hook[hook_resolver.Resolver] + OnAnimeEntryLibraryData() *Hook[hook_resolver.Resolver] + + OnAnimeEntryManualMatchBeforeSave() *Hook[hook_resolver.Resolver] + + OnMissingEpisodesRequested() *Hook[hook_resolver.Resolver] + OnMissingEpisodes() *Hook[hook_resolver.Resolver] + + OnAnimeEntryDownloadInfoRequested() *Hook[hook_resolver.Resolver] + OnAnimeEntryDownloadInfo() *Hook[hook_resolver.Resolver] + + OnAnimEpisodeCollectionRequested() *Hook[hook_resolver.Resolver] + OnAnimeEpisodeCollection() *Hook[hook_resolver.Resolver] + + // Anime library collection events + OnAnimeLibraryCollectionRequested() *Hook[hook_resolver.Resolver] + OnAnimeLibraryCollection() *Hook[hook_resolver.Resolver] + + OnAnimeLibraryStreamCollectionRequested() *Hook[hook_resolver.Resolver] + OnAnimeLibraryStreamCollection() *Hook[hook_resolver.Resolver] + + OnAnimeScheduleItems() *Hook[hook_resolver.Resolver] + + // Auto Downloader events + OnAutoDownloaderRunStarted() *Hook[hook_resolver.Resolver] + OnAutoDownloaderMatchVerified() *Hook[hook_resolver.Resolver] + OnAutoDownloaderSettingsUpdated() *Hook[hook_resolver.Resolver] + OnAutoDownloaderTorrentsFetched() *Hook[hook_resolver.Resolver] + OnAutoDownloaderBeforeDownloadTorrent() *Hook[hook_resolver.Resolver] + OnAutoDownloaderAfterDownloadTorrent() *Hook[hook_resolver.Resolver] + + // Scanner events + OnScanStarted() *Hook[hook_resolver.Resolver] + OnScanFilePathsRetrieved() *Hook[hook_resolver.Resolver] + OnScanLocalFilesParsed() *Hook[hook_resolver.Resolver] + OnScanCompleted() *Hook[hook_resolver.Resolver] + OnScanMediaFetcherStarted() *Hook[hook_resolver.Resolver] + OnScanMediaFetcherCompleted() *Hook[hook_resolver.Resolver] + OnScanMatchingStarted() *Hook[hook_resolver.Resolver] + OnScanLocalFileMatched() *Hook[hook_resolver.Resolver] + OnScanMatchingCompleted() *Hook[hook_resolver.Resolver] + OnScanHydrationStarted() *Hook[hook_resolver.Resolver] + OnScanLocalFileHydrationStarted() *Hook[hook_resolver.Resolver] + OnScanLocalFileHydrated() *Hook[hook_resolver.Resolver] + + // Anime metadata events + OnAnimeMetadataRequested() *Hook[hook_resolver.Resolver] + OnAnimeMetadata() *Hook[hook_resolver.Resolver] + OnAnimeEpisodeMetadataRequested() *Hook[hook_resolver.Resolver] + OnAnimeEpisodeMetadata() *Hook[hook_resolver.Resolver] + + // Manga events + OnMangaEntryRequested() *Hook[hook_resolver.Resolver] + OnMangaEntry() *Hook[hook_resolver.Resolver] + OnMangaLibraryCollectionRequested() *Hook[hook_resolver.Resolver] + OnMangaLibraryCollection() *Hook[hook_resolver.Resolver] + OnMangaDownloadedChapterContainersRequested() *Hook[hook_resolver.Resolver] + OnMangaDownloadedChapterContainers() *Hook[hook_resolver.Resolver] + OnMangaLatestChapterNumbersMap() *Hook[hook_resolver.Resolver] + OnMangaDownloadMap() *Hook[hook_resolver.Resolver] + OnMangaChapterContainerRequested() *Hook[hook_resolver.Resolver] + OnMangaChapterContainer() *Hook[hook_resolver.Resolver] + + // Playback events + OnLocalFilePlaybackRequested() *Hook[hook_resolver.Resolver] + OnPlaybackBeforeTracking() *Hook[hook_resolver.Resolver] + OnStreamPlaybackRequested() *Hook[hook_resolver.Resolver] + OnPlaybackLocalFileDetailsRequested() *Hook[hook_resolver.Resolver] + OnPlaybackStreamDetailsRequested() *Hook[hook_resolver.Resolver] + + // Media player events + OnMediaPlayerLocalFileTrackingRequested() *Hook[hook_resolver.Resolver] + OnMediaPlayerStreamTrackingRequested() *Hook[hook_resolver.Resolver] + + // Debrid events + OnDebridAutoSelectTorrentsFetched() *Hook[hook_resolver.Resolver] + OnDebridSendStreamToMediaPlayer() *Hook[hook_resolver.Resolver] + OnDebridLocalDownloadRequested() *Hook[hook_resolver.Resolver] + OnDebridSkipStreamCheck() *Hook[hook_resolver.Resolver] + + // Torrent stream events + OnTorrentStreamAutoSelectTorrentsFetched() *Hook[hook_resolver.Resolver] + OnTorrentStreamSendStreamToMediaPlayer() *Hook[hook_resolver.Resolver] + + // Continuity events + OnWatchHistoryItemRequested() *Hook[hook_resolver.Resolver] + OnWatchHistoryItemUpdated() *Hook[hook_resolver.Resolver] + OnWatchHistoryLocalFileEpisodeItemRequested() *Hook[hook_resolver.Resolver] + OnWatchHistoryStreamEpisodeItemRequested() *Hook[hook_resolver.Resolver] + + // Discord RPC events + OnDiscordPresenceAnimeActivityRequested() *Hook[hook_resolver.Resolver] + OnDiscordPresenceMangaActivityRequested() *Hook[hook_resolver.Resolver] + OnDiscordPresenceClientClosed() *Hook[hook_resolver.Resolver] + + // Anilist events + OnListMissedSequelsRequested() *Hook[hook_resolver.Resolver] + OnListMissedSequels() *Hook[hook_resolver.Resolver] + + // Anizip events + OnAnizipMediaRequested() *Hook[hook_resolver.Resolver] + OnAnizipMedia() *Hook[hook_resolver.Resolver] + + // Animap events + OnAnimapMediaRequested() *Hook[hook_resolver.Resolver] + OnAnimapMedia() *Hook[hook_resolver.Resolver] + + // Filler manager + OnHydrateFillerDataRequested() *Hook[hook_resolver.Resolver] + OnHydrateOnlinestreamFillerDataRequested() *Hook[hook_resolver.Resolver] + OnHydrateEpisodeFillerDataRequested() *Hook[hook_resolver.Resolver] +} + +type ManagerImpl struct { + logger *zerolog.Logger + // AniList events + onGetAnime *Hook[hook_resolver.Resolver] + onGetAnimeDetails *Hook[hook_resolver.Resolver] + onGetManga *Hook[hook_resolver.Resolver] + onGetMangaDetails *Hook[hook_resolver.Resolver] + onGetAnimeCollection *Hook[hook_resolver.Resolver] + onGetMangaCollection *Hook[hook_resolver.Resolver] + onGetCachedAnimeCollection *Hook[hook_resolver.Resolver] + onGetCachedMangaCollection *Hook[hook_resolver.Resolver] + onGetRawAnimeCollection *Hook[hook_resolver.Resolver] + onGetRawMangaCollection *Hook[hook_resolver.Resolver] + onGetCachedRawAnimeCollection *Hook[hook_resolver.Resolver] + onGetCachedRawMangaCollection *Hook[hook_resolver.Resolver] + onGetStudioDetails *Hook[hook_resolver.Resolver] + onPreUpdateEntry *Hook[hook_resolver.Resolver] + onPostUpdateEntry *Hook[hook_resolver.Resolver] + onPreUpdateEntryProgress *Hook[hook_resolver.Resolver] + onPostUpdateEntryProgress *Hook[hook_resolver.Resolver] + onPreUpdateEntryRepeat *Hook[hook_resolver.Resolver] + onPostUpdateEntryRepeat *Hook[hook_resolver.Resolver] + // Anime library events + onAnimeEntryRequested *Hook[hook_resolver.Resolver] + onAnimeEntry *Hook[hook_resolver.Resolver] + onAnimeEntryFillerHydration *Hook[hook_resolver.Resolver] + onAnimeEntryLibraryDataRequested *Hook[hook_resolver.Resolver] + onAnimeEntryLibraryData *Hook[hook_resolver.Resolver] + onAnimeEntryManualMatchBeforeSave *Hook[hook_resolver.Resolver] + onMissingEpisodesRequested *Hook[hook_resolver.Resolver] + onMissingEpisodes *Hook[hook_resolver.Resolver] + onAnimeEntryDownloadInfoRequested *Hook[hook_resolver.Resolver] + onAnimeEntryDownloadInfo *Hook[hook_resolver.Resolver] + onAnimeEpisodeCollectionRequested *Hook[hook_resolver.Resolver] + onAnimeEpisodeCollection *Hook[hook_resolver.Resolver] + // Anime library collection events + onAnimeLibraryCollectionRequested *Hook[hook_resolver.Resolver] + onAnimeLibraryCollection *Hook[hook_resolver.Resolver] + onAnimeLibraryStreamCollectionRequested *Hook[hook_resolver.Resolver] + onAnimeLibraryStreamCollection *Hook[hook_resolver.Resolver] + onAnimeScheduleItems *Hook[hook_resolver.Resolver] + // Auto Downloader events + onAutoDownloaderMatchVerified *Hook[hook_resolver.Resolver] + onAutoDownloaderRunStarted *Hook[hook_resolver.Resolver] + onAutoDownloaderRunCompleted *Hook[hook_resolver.Resolver] + onAutoDownloaderSettingsUpdated *Hook[hook_resolver.Resolver] + onAutoDownloaderTorrentsFetched *Hook[hook_resolver.Resolver] + onAutoDownloaderBeforeDownloadTorrent *Hook[hook_resolver.Resolver] + onAutoDownloaderAfterDownloadTorrent *Hook[hook_resolver.Resolver] + // Scanner events + onScanStarted *Hook[hook_resolver.Resolver] + onScanFilePathsRetrieved *Hook[hook_resolver.Resolver] + onScanLocalFilesParsed *Hook[hook_resolver.Resolver] + onScanCompleted *Hook[hook_resolver.Resolver] + onScanMediaFetcherStarted *Hook[hook_resolver.Resolver] + onScanMediaFetcherCompleted *Hook[hook_resolver.Resolver] + onScanMatchingStarted *Hook[hook_resolver.Resolver] + onScanLocalFileMatched *Hook[hook_resolver.Resolver] + onScanMatchingCompleted *Hook[hook_resolver.Resolver] + onScanHydrationStarted *Hook[hook_resolver.Resolver] + onScanLocalFileHydrationStarted *Hook[hook_resolver.Resolver] + onScanLocalFileHydrated *Hook[hook_resolver.Resolver] + // Anime metadata events + onAnimeMetadataRequested *Hook[hook_resolver.Resolver] + onAnimeMetadata *Hook[hook_resolver.Resolver] + onAnimeEpisodeMetadataRequested *Hook[hook_resolver.Resolver] + onAnimeEpisodeMetadata *Hook[hook_resolver.Resolver] + // Manga events + onMangaEntryRequested *Hook[hook_resolver.Resolver] + onMangaEntry *Hook[hook_resolver.Resolver] + onMangaLibraryCollectionRequested *Hook[hook_resolver.Resolver] + onMangaLibraryCollection *Hook[hook_resolver.Resolver] + onMangaDownloadedChapterContainersRequested *Hook[hook_resolver.Resolver] + onMangaDownloadedChapterContainers *Hook[hook_resolver.Resolver] + onMangaLatestChapterNumbersMap *Hook[hook_resolver.Resolver] + onMangaDownloadMap *Hook[hook_resolver.Resolver] + onMangaChapterContainerRequested *Hook[hook_resolver.Resolver] + onMangaChapterContainer *Hook[hook_resolver.Resolver] + // Playback events + onLocalFilePlaybackRequested *Hook[hook_resolver.Resolver] + onPlaybackBeforeTracking *Hook[hook_resolver.Resolver] + onStreamPlaybackRequested *Hook[hook_resolver.Resolver] + onPlaybackLocalFileDetailsRequested *Hook[hook_resolver.Resolver] + onPlaybackStreamDetailsRequested *Hook[hook_resolver.Resolver] + // Media player events + onMediaPlayerLocalFileTrackingRequested *Hook[hook_resolver.Resolver] + onMediaPlayerStreamTrackingRequested *Hook[hook_resolver.Resolver] + // Debrid events + onDebridAutoSelectTorrentsFetched *Hook[hook_resolver.Resolver] + onDebridSendStreamToMediaPlayer *Hook[hook_resolver.Resolver] + onDebridLocalDownloadRequested *Hook[hook_resolver.Resolver] + onDebridSkipStreamCheck *Hook[hook_resolver.Resolver] + // Torrent stream events + onTorrentStreamAutoSelectTorrentsFetched *Hook[hook_resolver.Resolver] + onTorrentStreamSendStreamToMediaPlayer *Hook[hook_resolver.Resolver] + // Continuity events + onWatchHistoryItemRequested *Hook[hook_resolver.Resolver] + onWatchHistoryItemUpdated *Hook[hook_resolver.Resolver] + onWatchHistoryLocalFileEpisodeItemRequested *Hook[hook_resolver.Resolver] + onWatchHistoryStreamEpisodeItemRequested *Hook[hook_resolver.Resolver] + // Discord RPC events + onDiscordPresenceAnimeActivityRequested *Hook[hook_resolver.Resolver] + onDiscordPresenceMangaActivityRequested *Hook[hook_resolver.Resolver] + onDiscordPresenceClientClosed *Hook[hook_resolver.Resolver] + // Anilist events + onListMissedSequelsRequested *Hook[hook_resolver.Resolver] + onListMissedSequels *Hook[hook_resolver.Resolver] + // Anizip events + onAnizipMediaRequested *Hook[hook_resolver.Resolver] + onAnizipMedia *Hook[hook_resolver.Resolver] + // Animap events + onAnimapMediaRequested *Hook[hook_resolver.Resolver] + onAnimapMedia *Hook[hook_resolver.Resolver] + // Filler manager events + onHydrateFillerDataRequested *Hook[hook_resolver.Resolver] + onHydrateOnlinestreamFillerDataRequested *Hook[hook_resolver.Resolver] + onHydrateEpisodeFillerDataRequested *Hook[hook_resolver.Resolver] +} + +type NewHookManagerOptions struct { + Logger *zerolog.Logger +} + +var GlobalHookManager = NewHookManager(NewHookManagerOptions{ + Logger: util.NewLogger(), +}) + +func SetGlobalHookManager(manager Manager) { + GlobalHookManager = manager +} + +func NewHookManager(opts NewHookManagerOptions) Manager { + ret := &ManagerImpl{ + logger: opts.Logger, + } + + ret.initHooks() + + return ret +} + +func (m *ManagerImpl) initHooks() { + // AniList events + m.onGetAnime = &Hook[hook_resolver.Resolver]{} + m.onGetAnimeDetails = &Hook[hook_resolver.Resolver]{} + m.onGetManga = &Hook[hook_resolver.Resolver]{} + m.onGetMangaDetails = &Hook[hook_resolver.Resolver]{} + m.onGetAnimeCollection = &Hook[hook_resolver.Resolver]{} + m.onGetMangaCollection = &Hook[hook_resolver.Resolver]{} + m.onGetCachedAnimeCollection = &Hook[hook_resolver.Resolver]{} + m.onGetCachedMangaCollection = &Hook[hook_resolver.Resolver]{} + m.onGetRawAnimeCollection = &Hook[hook_resolver.Resolver]{} + m.onGetRawMangaCollection = &Hook[hook_resolver.Resolver]{} + m.onGetCachedRawAnimeCollection = &Hook[hook_resolver.Resolver]{} + m.onGetCachedRawMangaCollection = &Hook[hook_resolver.Resolver]{} + m.onGetStudioDetails = &Hook[hook_resolver.Resolver]{} + m.onPreUpdateEntry = &Hook[hook_resolver.Resolver]{} + m.onPostUpdateEntry = &Hook[hook_resolver.Resolver]{} + m.onPreUpdateEntryProgress = &Hook[hook_resolver.Resolver]{} + m.onPostUpdateEntryProgress = &Hook[hook_resolver.Resolver]{} + m.onPreUpdateEntryRepeat = &Hook[hook_resolver.Resolver]{} + m.onPostUpdateEntryRepeat = &Hook[hook_resolver.Resolver]{} + // Anime library events + m.onAnimeEntryRequested = &Hook[hook_resolver.Resolver]{} + m.onAnimeEntry = &Hook[hook_resolver.Resolver]{} + m.onAnimeEntryFillerHydration = &Hook[hook_resolver.Resolver]{} + m.onAnimeEntryLibraryDataRequested = &Hook[hook_resolver.Resolver]{} + m.onAnimeEntryLibraryData = &Hook[hook_resolver.Resolver]{} + m.onAnimeEntryManualMatchBeforeSave = &Hook[hook_resolver.Resolver]{} + m.onMissingEpisodesRequested = &Hook[hook_resolver.Resolver]{} + m.onMissingEpisodes = &Hook[hook_resolver.Resolver]{} + m.onAnimeEntryDownloadInfoRequested = &Hook[hook_resolver.Resolver]{} + m.onAnimeEntryDownloadInfo = &Hook[hook_resolver.Resolver]{} + m.onAnimeEpisodeCollectionRequested = &Hook[hook_resolver.Resolver]{} + m.onAnimeEpisodeCollection = &Hook[hook_resolver.Resolver]{} + // Anime library collection events + m.onAnimeLibraryCollectionRequested = &Hook[hook_resolver.Resolver]{} + m.onAnimeLibraryCollection = &Hook[hook_resolver.Resolver]{} + m.onAnimeLibraryStreamCollectionRequested = &Hook[hook_resolver.Resolver]{} + m.onAnimeLibraryStreamCollection = &Hook[hook_resolver.Resolver]{} + m.onAnimeScheduleItems = &Hook[hook_resolver.Resolver]{} + // Auto Downloader events + m.onAutoDownloaderMatchVerified = &Hook[hook_resolver.Resolver]{} + m.onAutoDownloaderRunStarted = &Hook[hook_resolver.Resolver]{} + m.onAutoDownloaderRunCompleted = &Hook[hook_resolver.Resolver]{} + m.onAutoDownloaderSettingsUpdated = &Hook[hook_resolver.Resolver]{} + m.onAutoDownloaderTorrentsFetched = &Hook[hook_resolver.Resolver]{} + m.onAutoDownloaderBeforeDownloadTorrent = &Hook[hook_resolver.Resolver]{} + m.onAutoDownloaderAfterDownloadTorrent = &Hook[hook_resolver.Resolver]{} + // Scanner events + m.onScanStarted = &Hook[hook_resolver.Resolver]{} + m.onScanFilePathsRetrieved = &Hook[hook_resolver.Resolver]{} + m.onScanLocalFilesParsed = &Hook[hook_resolver.Resolver]{} + m.onScanCompleted = &Hook[hook_resolver.Resolver]{} + m.onScanMediaFetcherStarted = &Hook[hook_resolver.Resolver]{} + m.onScanMediaFetcherCompleted = &Hook[hook_resolver.Resolver]{} + m.onScanMatchingStarted = &Hook[hook_resolver.Resolver]{} + m.onScanLocalFileMatched = &Hook[hook_resolver.Resolver]{} + m.onScanMatchingCompleted = &Hook[hook_resolver.Resolver]{} + m.onScanHydrationStarted = &Hook[hook_resolver.Resolver]{} + m.onScanLocalFileHydrationStarted = &Hook[hook_resolver.Resolver]{} + m.onScanLocalFileHydrated = &Hook[hook_resolver.Resolver]{} + // Anime metadata events + m.onAnimeMetadataRequested = &Hook[hook_resolver.Resolver]{} + m.onAnimeMetadata = &Hook[hook_resolver.Resolver]{} + m.onAnimeEpisodeMetadataRequested = &Hook[hook_resolver.Resolver]{} + m.onAnimeEpisodeMetadata = &Hook[hook_resolver.Resolver]{} + // Manga events + m.onMangaEntryRequested = &Hook[hook_resolver.Resolver]{} + m.onMangaEntry = &Hook[hook_resolver.Resolver]{} + m.onMangaLibraryCollectionRequested = &Hook[hook_resolver.Resolver]{} + m.onMangaLibraryCollection = &Hook[hook_resolver.Resolver]{} + m.onMangaDownloadedChapterContainersRequested = &Hook[hook_resolver.Resolver]{} + m.onMangaDownloadedChapterContainers = &Hook[hook_resolver.Resolver]{} + m.onMangaLatestChapterNumbersMap = &Hook[hook_resolver.Resolver]{} + m.onMangaDownloadMap = &Hook[hook_resolver.Resolver]{} + m.onMangaChapterContainerRequested = &Hook[hook_resolver.Resolver]{} + m.onMangaChapterContainer = &Hook[hook_resolver.Resolver]{} + // Playback events + m.onLocalFilePlaybackRequested = &Hook[hook_resolver.Resolver]{} + m.onPlaybackBeforeTracking = &Hook[hook_resolver.Resolver]{} + m.onStreamPlaybackRequested = &Hook[hook_resolver.Resolver]{} + m.onPlaybackLocalFileDetailsRequested = &Hook[hook_resolver.Resolver]{} + m.onPlaybackStreamDetailsRequested = &Hook[hook_resolver.Resolver]{} + // Media player events + m.onMediaPlayerLocalFileTrackingRequested = &Hook[hook_resolver.Resolver]{} + m.onMediaPlayerStreamTrackingRequested = &Hook[hook_resolver.Resolver]{} + // Debrid events + m.onDebridAutoSelectTorrentsFetched = &Hook[hook_resolver.Resolver]{} + m.onDebridSendStreamToMediaPlayer = &Hook[hook_resolver.Resolver]{} + m.onDebridLocalDownloadRequested = &Hook[hook_resolver.Resolver]{} + m.onDebridSkipStreamCheck = &Hook[hook_resolver.Resolver]{} + // Torrent stream events + m.onTorrentStreamAutoSelectTorrentsFetched = &Hook[hook_resolver.Resolver]{} + m.onTorrentStreamSendStreamToMediaPlayer = &Hook[hook_resolver.Resolver]{} + // Continuity events + m.onWatchHistoryItemRequested = &Hook[hook_resolver.Resolver]{} + m.onWatchHistoryItemUpdated = &Hook[hook_resolver.Resolver]{} + m.onWatchHistoryLocalFileEpisodeItemRequested = &Hook[hook_resolver.Resolver]{} + m.onWatchHistoryStreamEpisodeItemRequested = &Hook[hook_resolver.Resolver]{} + // Discord RPC events + m.onDiscordPresenceAnimeActivityRequested = &Hook[hook_resolver.Resolver]{} + m.onDiscordPresenceMangaActivityRequested = &Hook[hook_resolver.Resolver]{} + m.onDiscordPresenceClientClosed = &Hook[hook_resolver.Resolver]{} + // Anilist events + m.onListMissedSequelsRequested = &Hook[hook_resolver.Resolver]{} + m.onListMissedSequels = &Hook[hook_resolver.Resolver]{} + // Anizip events + m.onAnizipMediaRequested = &Hook[hook_resolver.Resolver]{} + m.onAnizipMedia = &Hook[hook_resolver.Resolver]{} + // Animap events + m.onAnimapMediaRequested = &Hook[hook_resolver.Resolver]{} + m.onAnimapMedia = &Hook[hook_resolver.Resolver]{} + // Filler manager events + m.onHydrateFillerDataRequested = &Hook[hook_resolver.Resolver]{} + m.onHydrateOnlinestreamFillerDataRequested = &Hook[hook_resolver.Resolver]{} + m.onHydrateEpisodeFillerDataRequested = &Hook[hook_resolver.Resolver]{} +} + +func (m *ManagerImpl) OnGetAnime() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetAnime +} + +func (m *ManagerImpl) OnGetAnimeDetails() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetAnimeDetails +} + +func (m *ManagerImpl) OnGetManga() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetManga +} + +func (m *ManagerImpl) OnGetMangaDetails() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetMangaDetails +} + +func (m *ManagerImpl) OnGetAnimeCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetAnimeCollection +} + +func (m *ManagerImpl) OnGetMangaCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetMangaCollection +} + +func (m *ManagerImpl) OnGetCachedAnimeCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetCachedAnimeCollection +} + +func (m *ManagerImpl) OnGetCachedMangaCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetCachedMangaCollection +} + +func (m *ManagerImpl) OnGetRawAnimeCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetRawAnimeCollection +} + +func (m *ManagerImpl) OnGetRawMangaCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetRawMangaCollection +} + +func (m *ManagerImpl) OnGetCachedRawAnimeCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetCachedRawAnimeCollection +} + +func (m *ManagerImpl) OnGetCachedRawMangaCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetCachedRawMangaCollection +} + +func (m *ManagerImpl) OnGetStudioDetails() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onGetStudioDetails +} + +func (m *ManagerImpl) OnPreUpdateEntry() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onPreUpdateEntry +} + +func (m *ManagerImpl) OnPostUpdateEntry() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onPostUpdateEntry +} + +func (m *ManagerImpl) OnPreUpdateEntryProgress() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onPreUpdateEntryProgress +} + +func (m *ManagerImpl) OnPostUpdateEntryProgress() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onPostUpdateEntryProgress +} + +func (m *ManagerImpl) OnPreUpdateEntryRepeat() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onPreUpdateEntryRepeat +} + +func (m *ManagerImpl) OnPostUpdateEntryRepeat() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onPostUpdateEntryRepeat +} + +// Anime entry events + +func (m *ManagerImpl) OnAnimeEntryRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEntryRequested +} + +func (m *ManagerImpl) OnAnimeEntry() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEntry +} + +func (m *ManagerImpl) OnAnimeEntryFillerHydration() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEntryFillerHydration +} + +func (m *ManagerImpl) OnAnimeEntryLibraryDataRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEntryLibraryDataRequested +} + +func (m *ManagerImpl) OnAnimeEntryLibraryData() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEntryLibraryData +} + +func (m *ManagerImpl) OnAnimeEntryManualMatchBeforeSave() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEntryManualMatchBeforeSave +} + +func (m *ManagerImpl) OnMissingEpisodesRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMissingEpisodesRequested +} + +func (m *ManagerImpl) OnMissingEpisodes() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMissingEpisodes +} + +func (m *ManagerImpl) OnAnimeEntryDownloadInfoRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEntryDownloadInfoRequested +} + +func (m *ManagerImpl) OnAnimeEntryDownloadInfo() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEntryDownloadInfo +} + +func (m *ManagerImpl) OnAnimEpisodeCollectionRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEpisodeCollectionRequested +} + +func (m *ManagerImpl) OnAnimeEpisodeCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEpisodeCollection +} + +// Anime library collection events + +func (m *ManagerImpl) OnAnimeLibraryCollectionRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeLibraryCollectionRequested +} + +func (m *ManagerImpl) OnAnimeLibraryStreamCollectionRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeLibraryStreamCollectionRequested +} + +func (m *ManagerImpl) OnAnimeLibraryCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeLibraryCollection +} + +func (m *ManagerImpl) OnAnimeLibraryStreamCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeLibraryStreamCollection +} + +func (m *ManagerImpl) OnAnimeScheduleItems() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeScheduleItems +} + +// Auto Downloader events + +func (m *ManagerImpl) OnAutoDownloaderMatchVerified() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAutoDownloaderMatchVerified +} + +func (m *ManagerImpl) OnAutoDownloaderRunStarted() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAutoDownloaderRunStarted +} + +func (m *ManagerImpl) OnAutoDownloaderSettingsUpdated() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAutoDownloaderSettingsUpdated +} + +func (m *ManagerImpl) OnAutoDownloaderTorrentsFetched() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAutoDownloaderTorrentsFetched +} + +func (m *ManagerImpl) OnAutoDownloaderBeforeDownloadTorrent() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAutoDownloaderBeforeDownloadTorrent +} + +func (m *ManagerImpl) OnAutoDownloaderAfterDownloadTorrent() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAutoDownloaderAfterDownloadTorrent +} + +// Scanner events +func (m *ManagerImpl) OnScanStarted() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanStarted +} + +func (m *ManagerImpl) OnScanFilePathsRetrieved() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanFilePathsRetrieved +} + +func (m *ManagerImpl) OnScanLocalFilesParsed() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanLocalFilesParsed +} + +func (m *ManagerImpl) OnScanCompleted() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanCompleted +} + +func (m *ManagerImpl) OnScanMediaFetcherStarted() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanMediaFetcherStarted +} + +func (m *ManagerImpl) OnScanMediaFetcherCompleted() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanMediaFetcherCompleted +} + +func (m *ManagerImpl) OnScanMatchingStarted() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanMatchingStarted +} + +func (m *ManagerImpl) OnScanLocalFileMatched() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanLocalFileMatched +} + +func (m *ManagerImpl) OnScanMatchingCompleted() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanMatchingCompleted +} + +func (m *ManagerImpl) OnScanHydrationStarted() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanHydrationStarted +} + +func (m *ManagerImpl) OnScanLocalFileHydrationStarted() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanLocalFileHydrationStarted +} + +func (m *ManagerImpl) OnScanLocalFileHydrated() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onScanLocalFileHydrated +} + +// Anime metadata events + +func (m *ManagerImpl) OnAnimeMetadataRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeMetadataRequested +} + +func (m *ManagerImpl) OnAnimeMetadata() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeMetadata +} + +func (m *ManagerImpl) OnAnimeEpisodeMetadataRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEpisodeMetadataRequested +} + +func (m *ManagerImpl) OnAnimeEpisodeMetadata() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimeEpisodeMetadata +} + +// Manga events + +func (m *ManagerImpl) OnMangaEntryRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMangaEntryRequested +} + +func (m *ManagerImpl) OnMangaEntry() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMangaEntry +} + +func (m *ManagerImpl) OnMangaLibraryCollectionRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMangaLibraryCollectionRequested +} + +func (m *ManagerImpl) OnMangaLibraryCollection() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMangaLibraryCollection +} + +func (m *ManagerImpl) OnMangaDownloadedChapterContainersRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMangaDownloadedChapterContainersRequested +} + +func (m *ManagerImpl) OnMangaDownloadedChapterContainers() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMangaDownloadedChapterContainers +} + +func (m *ManagerImpl) OnMangaLatestChapterNumbersMap() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMangaLatestChapterNumbersMap +} + +func (m *ManagerImpl) OnMangaDownloadMap() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMangaDownloadMap +} + +func (m *ManagerImpl) OnMangaChapterContainerRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMangaChapterContainerRequested +} + +func (m *ManagerImpl) OnMangaChapterContainer() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMangaChapterContainer +} + +// Playback events + +func (m *ManagerImpl) OnLocalFilePlaybackRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onLocalFilePlaybackRequested +} + +func (m *ManagerImpl) OnPlaybackBeforeTracking() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onPlaybackBeforeTracking +} + +func (m *ManagerImpl) OnStreamPlaybackRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onStreamPlaybackRequested +} + +func (m *ManagerImpl) OnPlaybackLocalFileDetailsRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onPlaybackLocalFileDetailsRequested +} + +func (m *ManagerImpl) OnPlaybackStreamDetailsRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onPlaybackStreamDetailsRequested +} + +// Media player events + +func (m *ManagerImpl) OnMediaPlayerLocalFileTrackingRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMediaPlayerLocalFileTrackingRequested +} + +func (m *ManagerImpl) OnMediaPlayerStreamTrackingRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onMediaPlayerStreamTrackingRequested +} + +// Debrid events + +func (m *ManagerImpl) OnDebridAutoSelectTorrentsFetched() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onDebridAutoSelectTorrentsFetched +} + +func (m *ManagerImpl) OnDebridSendStreamToMediaPlayer() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onDebridSendStreamToMediaPlayer +} + +func (m *ManagerImpl) OnDebridLocalDownloadRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onDebridLocalDownloadRequested +} + +func (m *ManagerImpl) OnDebridSkipStreamCheck() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onDebridSkipStreamCheck +} + +// Torrent stream events + +func (m *ManagerImpl) OnTorrentStreamAutoSelectTorrentsFetched() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onTorrentStreamAutoSelectTorrentsFetched +} + +func (m *ManagerImpl) OnTorrentStreamSendStreamToMediaPlayer() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onTorrentStreamSendStreamToMediaPlayer +} + +// Continuity events + +func (m *ManagerImpl) OnWatchHistoryItemRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onWatchHistoryItemRequested +} + +func (m *ManagerImpl) OnWatchHistoryItemUpdated() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onWatchHistoryItemUpdated +} + +func (m *ManagerImpl) OnWatchHistoryLocalFileEpisodeItemRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onWatchHistoryLocalFileEpisodeItemRequested +} + +func (m *ManagerImpl) OnWatchHistoryStreamEpisodeItemRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onWatchHistoryStreamEpisodeItemRequested +} + +// Discord RPC events + +func (m *ManagerImpl) OnDiscordPresenceAnimeActivityRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onDiscordPresenceAnimeActivityRequested +} + +func (m *ManagerImpl) OnDiscordPresenceMangaActivityRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onDiscordPresenceMangaActivityRequested +} + +func (m *ManagerImpl) OnDiscordPresenceClientClosed() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onDiscordPresenceClientClosed +} + +// Anilist events + +func (m *ManagerImpl) OnListMissedSequelsRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onListMissedSequelsRequested +} + +func (m *ManagerImpl) OnListMissedSequels() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onListMissedSequels +} + +// Anizip events + +func (m *ManagerImpl) OnAnizipMediaRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnizipMediaRequested +} + +func (m *ManagerImpl) OnAnizipMedia() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnizipMedia +} + +// Animap events + +func (m *ManagerImpl) OnAnimapMediaRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimapMediaRequested +} + +func (m *ManagerImpl) OnAnimapMedia() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onAnimapMedia +} + +// Filler manager events + +func (m *ManagerImpl) OnHydrateFillerDataRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onHydrateFillerDataRequested +} + +func (m *ManagerImpl) OnHydrateOnlinestreamFillerDataRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onHydrateOnlinestreamFillerDataRequested +} + +func (m *ManagerImpl) OnHydrateEpisodeFillerDataRequested() *Hook[hook_resolver.Resolver] { + if m == nil { + return &Hook[hook_resolver.Resolver]{} + } + return m.onHydrateEpisodeFillerDataRequested +} diff --git a/seanime-2.9.10/internal/hook/tagged.go b/seanime-2.9.10/internal/hook/tagged.go new file mode 100644 index 0000000..33875a5 --- /dev/null +++ b/seanime-2.9.10/internal/hook/tagged.go @@ -0,0 +1,81 @@ +package hook + +// +//// Tagger defines an interface for event data structs that support tags/groups/categories/etc. +//// Usually used together with TaggedHook. +//type Tagger interface { +// hook_events.Resolver +// +// Tags() []string +//} +// +//// wrapped local Hook embedded struct to limit the public API surface. +//type mainHook[T Tagger] struct { +// *Hook[T] +//} +// +//// NewTaggedHook creates a new TaggedHook with the provided main hook and optional tags. +//func NewTaggedHook[T Tagger](hook *Hook[T], tags ...string) *TaggedHook[T] { +// return &TaggedHook[T]{ +// mainHook[T]{hook}, +// tags, +// } +//} +// +//// TaggedHook defines a proxy hook which register handlers that are triggered only +//// if the TaggedHook.tags are empty or includes at least one of the event data tag(s). +//type TaggedHook[T Tagger] struct { +// mainHook[T] +// +// tags []string +//} +// +//// CanTriggerOn checks if the current TaggedHook can be triggered with +//// the provided event data tags. +//// +//// It returns always true if the hook doens't have any tags. +//func (h *TaggedHook[T]) CanTriggerOn(tagsToCheck []string) bool { +// if len(h.tags) == 0 { +// return true // match all +// } +// +// for _, t := range tagsToCheck { +// if util.Contains(h.tags, t) { +// return true +// } +// } +// +// return false +//} +// +//// Bind registers the provided handler to the current hooks queue. +//// +//// It is similar to [Hook.Bind] with the difference that the handler +//// function is invoked only if the event data tags satisfy h.CanTriggerOn. +//func (h *TaggedHook[T]) Bind(handler *Handler[T]) string { +// fn := handler.Func +// +// handler.Func = func(e T) error { +// if h.CanTriggerOn(e.Tags()) { +// return fn(e) +// } +// +// return e.Next() +// } +// +// return h.mainHook.Bind(handler) +//} +// +//// BindFunc registers a new handler with the specified function. +//// +//// It is similar to [Hook.Bind] with the difference that the handler +//// function is invoked only if the event data tags satisfy h.CanTriggerOn. +//func (h *TaggedHook[T]) BindFunc(fn func(e T) error) string { +// return h.mainHook.BindFunc(func(e T) error { +// if h.CanTriggerOn(e.Tags()) { +// return fn(e) +// } +// +// return e.Next() +// }) +//} diff --git a/seanime-2.9.10/internal/hook/tagged_test.go b/seanime-2.9.10/internal/hook/tagged_test.go new file mode 100644 index 0000000..d26b9a0 --- /dev/null +++ b/seanime-2.9.10/internal/hook/tagged_test.go @@ -0,0 +1,79 @@ +package hook + +//type mockTagsEvent struct { +// Event +// tags []string +//} +// +//func (m mockTagsEvent) Tags() []string { +// return m.tags +//} +// +//func TestTaggedHook(t *testing.T) { +// calls := "" +// +// base := &Hook[*mockTagsEvent]{} +// base.BindFunc(func(e *mockTagsEvent) error { calls += "f0"; return e.Next() }) +// +// hA := NewTaggedHook(base) +// hA.BindFunc(func(e *mockTagsEvent) error { calls += "a1"; return e.Next() }) +// hA.Bind(&Handler[*mockTagsEvent]{ +// Func: func(e *mockTagsEvent) error { calls += "a2"; return e.Next() }, +// Priority: -1, +// }) +// +// hB := NewTaggedHook(base, "b1", "b2") +// hB.BindFunc(func(e *mockTagsEvent) error { calls += "b1"; return e.Next() }) +// hB.Bind(&Handler[*mockTagsEvent]{ +// Func: func(e *mockTagsEvent) error { calls += "b2"; return e.Next() }, +// Priority: -2, +// }) +// +// hC := NewTaggedHook(base, "c1", "c2") +// hC.BindFunc(func(e *mockTagsEvent) error { calls += "c1"; return e.Next() }) +// hC.Bind(&Handler[*mockTagsEvent]{ +// Func: func(e *mockTagsEvent) error { calls += "c2"; return e.Next() }, +// Priority: -3, +// }) +// +// scenarios := []struct { +// event *mockTagsEvent +// expectedCalls string +// }{ +// { +// &mockTagsEvent{}, +// "a2f0a1", +// }, +// { +// &mockTagsEvent{tags: []string{"missing"}}, +// "a2f0a1", +// }, +// { +// &mockTagsEvent{tags: []string{"b2"}}, +// "b2a2f0a1b1", +// }, +// { +// &mockTagsEvent{tags: []string{"c1"}}, +// "c2a2f0a1c1", +// }, +// { +// &mockTagsEvent{tags: []string{"b1", "c2"}}, +// "c2b2a2f0a1b1c1", +// }, +// } +// +// for _, s := range scenarios { +// t.Run(strings.Join(s.event.tags, "_"), func(t *testing.T) { +// calls = "" // reset +// +// err := base.Trigger(s.event) +// if err != nil { +// t.Fatalf("Unexpected trigger error: %v", err) +// } +// +// if calls != s.expectedCalls { +// t.Fatalf("Expected calls sequence %q, got %q", s.expectedCalls, calls) +// } +// }) +// } +//} diff --git a/seanime-2.9.10/internal/hook_resolver/hook_resolver.go b/seanime-2.9.10/internal/hook_resolver/hook_resolver.go new file mode 100644 index 0000000..7944c0c --- /dev/null +++ b/seanime-2.9.10/internal/hook_resolver/hook_resolver.go @@ -0,0 +1,55 @@ +package hook_resolver + +// Resolver defines a common interface for a Hook event (see [Event]). +type Resolver interface { + // Next triggers the next handler in the hook's chain (if any). + Next() error + + NextFunc() func() error + + // PreventDefault prevents the native handler from being called. + PreventDefault() + + SetNextFunc(f func() error) +} + +var _ Resolver = (*Event)(nil) + +// Event implements [Resolver] and it is intended to be used as a base +// Hook event that you can embed in your custom typed event structs. +// +// Example: +// +// type CustomEvent struct { +// hook.Event +// +// SomeField int +// } +type Event struct { + next func() error + preventDefault func() + + DefaultPrevented bool `json:"defaultPrevented"` +} + +// Next calls the next hook handler. +func (e *Event) Next() error { + if e.next != nil { + return e.next() + } + return nil +} + +func (e *Event) PreventDefault() { + e.DefaultPrevented = true +} + +// NextFunc returns the function that Next calls. +func (e *Event) NextFunc() func() error { + return e.next +} + +// SetNextFunc sets the function that Next calls. +func (e *Event) SetNextFunc(f func() error) { + e.next = f +} diff --git a/seanime-2.9.10/internal/icon/README.md b/seanime-2.9.10/internal/icon/README.md new file mode 100644 index 0000000..b6b71f7 --- /dev/null +++ b/seanime-2.9.10/internal/icon/README.md @@ -0,0 +1,11 @@ +## Windows + +```bash +make_icon.bat iconwin.ico +``` + +## UNIX + +```bash +sh make_icon.sh icon.png +``` diff --git a/seanime-2.9.10/internal/icon/icon.png b/seanime-2.9.10/internal/icon/icon.png new file mode 100644 index 0000000..edfb165 Binary files /dev/null and b/seanime-2.9.10/internal/icon/icon.png differ diff --git a/seanime-2.9.10/internal/icon/iconunix.go b/seanime-2.9.10/internal/icon/iconunix.go new file mode 100644 index 0000000..891830f --- /dev/null +++ b/seanime-2.9.10/internal/icon/iconunix.go @@ -0,0 +1,432 @@ +//go:build linux || darwin +// +build linux darwin + +// File generated by 2goarray v0.1.0 (http://github.com/cratonica/2goarray) + +package icon + +var Data []byte = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x73, 0x7a, 0x7a, 0xf4, 0x00, 0x00, 0x00, + 0x04, 0x67, 0x41, 0x4d, 0x41, 0x00, 0x00, 0xb1, 0x8f, 0x0b, 0xfc, 0x61, + 0x05, 0x00, 0x00, 0x0a, 0x49, 0x69, 0x43, 0x43, 0x50, 0x73, 0x52, 0x47, + 0x42, 0x20, 0x49, 0x45, 0x43, 0x36, 0x31, 0x39, 0x36, 0x36, 0x2d, 0x32, + 0x2e, 0x31, 0x00, 0x00, 0x48, 0x89, 0x9d, 0x53, 0x77, 0x58, 0x93, 0xf7, + 0x16, 0x3e, 0xdf, 0xf7, 0x65, 0x0f, 0x56, 0x42, 0xd8, 0xf0, 0xb1, 0x97, + 0x6c, 0x81, 0x00, 0x22, 0x23, 0xac, 0x08, 0xc8, 0x10, 0x59, 0xa2, 0x10, + 0x92, 0x00, 0x61, 0x84, 0x10, 0x12, 0x40, 0xc5, 0x85, 0x88, 0x0a, 0x56, + 0x14, 0x15, 0x11, 0x9c, 0x48, 0x55, 0xc4, 0x82, 0xd5, 0x0a, 0x48, 0x9d, + 0x88, 0xe2, 0xa0, 0x28, 0xb8, 0x67, 0x41, 0x8a, 0x88, 0x5a, 0x8b, 0x55, + 0x5c, 0x38, 0xee, 0x1f, 0xdc, 0xa7, 0xb5, 0x7d, 0x7a, 0xef, 0xed, 0xed, + 0xfb, 0xd7, 0xfb, 0xbc, 0xe7, 0x9c, 0xe7, 0xfc, 0xce, 0x79, 0xcf, 0x0f, + 0x80, 0x11, 0x12, 0x26, 0x91, 0xe6, 0xa2, 0x6a, 0x00, 0x39, 0x52, 0x85, + 0x3c, 0x3a, 0xd8, 0x1f, 0x8f, 0x4f, 0x48, 0xc4, 0xc9, 0xbd, 0x80, 0x02, + 0x15, 0x48, 0xe0, 0x04, 0x20, 0x10, 0xe6, 0xcb, 0xc2, 0x67, 0x05, 0xc5, + 0x00, 0x00, 0xf0, 0x03, 0x79, 0x78, 0x7e, 0x74, 0xb0, 0x3f, 0xfc, 0x01, + 0xaf, 0x6f, 0x00, 0x02, 0x00, 0x70, 0xd5, 0x2e, 0x24, 0x12, 0xc7, 0xe1, + 0xff, 0x83, 0xba, 0x50, 0x26, 0x57, 0x00, 0x20, 0x91, 0x00, 0xe0, 0x22, + 0x12, 0xe7, 0x0b, 0x01, 0x90, 0x52, 0x00, 0xc8, 0x2e, 0x54, 0xc8, 0x14, + 0x00, 0xc8, 0x18, 0x00, 0xb0, 0x53, 0xb3, 0x64, 0x0a, 0x00, 0x94, 0x00, + 0x00, 0x6c, 0x79, 0x7c, 0x42, 0x22, 0x00, 0xaa, 0x0d, 0x00, 0xec, 0xf4, + 0x49, 0x3e, 0x05, 0x00, 0xd8, 0xa9, 0x93, 0xdc, 0x17, 0x00, 0xd8, 0xa2, + 0x1c, 0xa9, 0x08, 0x00, 0x8d, 0x01, 0x00, 0x99, 0x28, 0x47, 0x24, 0x02, + 0x40, 0xbb, 0x00, 0x60, 0x55, 0x81, 0x52, 0x2c, 0x02, 0xc0, 0xc2, 0x00, + 0xa0, 0xac, 0x40, 0x22, 0x2e, 0x04, 0xc0, 0xae, 0x01, 0x80, 0x59, 0xb6, + 0x32, 0x47, 0x02, 0x80, 0xbd, 0x05, 0x00, 0x76, 0x8e, 0x58, 0x90, 0x0f, + 0x40, 0x60, 0x00, 0x80, 0x99, 0x42, 0x2c, 0xcc, 0x00, 0x20, 0x38, 0x02, + 0x00, 0x43, 0x1e, 0x13, 0xcd, 0x03, 0x20, 0x4c, 0x03, 0xa0, 0x30, 0xd2, + 0xbf, 0xe0, 0xa9, 0x5f, 0x70, 0x85, 0xb8, 0x48, 0x01, 0x00, 0xc0, 0xcb, + 0x95, 0xcd, 0x97, 0x4b, 0xd2, 0x33, 0x14, 0xb8, 0x95, 0xd0, 0x1a, 0x77, + 0xf2, 0xf0, 0xe0, 0xe2, 0x21, 0xe2, 0xc2, 0x6c, 0xb1, 0x42, 0x61, 0x17, + 0x29, 0x10, 0x66, 0x09, 0xe4, 0x22, 0x9c, 0x97, 0x9b, 0x23, 0x13, 0x48, + 0xe7, 0x03, 0x4c, 0xce, 0x0c, 0x00, 0x00, 0x1a, 0xf9, 0xd1, 0xc1, 0xfe, + 0x38, 0x3f, 0x90, 0xe7, 0xe6, 0xe4, 0xe1, 0xe6, 0x66, 0xe7, 0x6c, 0xef, + 0xf4, 0xc5, 0xa2, 0xfe, 0x6b, 0xf0, 0x6f, 0x22, 0x3e, 0x21, 0xf1, 0xdf, + 0xfe, 0xbc, 0x8c, 0x02, 0x04, 0x00, 0x10, 0x4e, 0xcf, 0xef, 0xda, 0x5f, + 0xe5, 0xe5, 0xd6, 0x03, 0x70, 0xc7, 0x01, 0xb0, 0x75, 0xbf, 0x6b, 0xa9, + 0x5b, 0x00, 0xda, 0x56, 0x00, 0x68, 0xdf, 0xf9, 0x5d, 0x33, 0xdb, 0x09, + 0xa0, 0x5a, 0x0a, 0xd0, 0x7a, 0xf9, 0x8b, 0x79, 0x38, 0xfc, 0x40, 0x1e, + 0x9e, 0xa1, 0x50, 0xc8, 0x3c, 0x1d, 0x1c, 0x0a, 0x0b, 0x0b, 0xed, 0x25, + 0x62, 0xa1, 0xbd, 0x30, 0xe3, 0x8b, 0x3e, 0xff, 0x33, 0xe1, 0x6f, 0xe0, + 0x8b, 0x7e, 0xf6, 0xfc, 0x40, 0x1e, 0xfe, 0xdb, 0x7a, 0xf0, 0x00, 0x71, + 0x9a, 0x40, 0x99, 0xad, 0xc0, 0xa3, 0x83, 0xfd, 0x71, 0x61, 0x6e, 0x76, + 0xae, 0x52, 0x8e, 0xe7, 0xcb, 0x04, 0x42, 0x31, 0x6e, 0xf7, 0xe7, 0x23, + 0xfe, 0xc7, 0x85, 0x7f, 0xfd, 0x8e, 0x29, 0xd1, 0xe2, 0x34, 0xb1, 0x5c, + 0x2c, 0x15, 0x8a, 0xf1, 0x58, 0x89, 0xb8, 0x50, 0x22, 0x4d, 0xc7, 0x79, + 0xb9, 0x52, 0x91, 0x44, 0x21, 0xc9, 0x95, 0xe2, 0x12, 0xe9, 0x7f, 0x32, + 0xf1, 0x1f, 0x96, 0xfd, 0x09, 0x93, 0x77, 0x0d, 0x00, 0xac, 0x86, 0x4f, + 0xc0, 0x4e, 0xb6, 0x07, 0xb5, 0xcb, 0x6c, 0xc0, 0x7e, 0xee, 0x01, 0x02, + 0x8b, 0x0e, 0x58, 0xd2, 0x76, 0x00, 0x40, 0x7e, 0xf3, 0x2d, 0x8c, 0x1a, + 0x0b, 0x91, 0x00, 0x10, 0x67, 0x34, 0x32, 0x79, 0xf7, 0x00, 0x00, 0x93, + 0xbf, 0xf9, 0x8f, 0x40, 0x2b, 0x01, 0x00, 0xcd, 0x97, 0xa4, 0xe3, 0x00, + 0x00, 0xbc, 0xe8, 0x18, 0x5c, 0xa8, 0x94, 0x17, 0x4c, 0xc6, 0x08, 0x00, + 0x00, 0x44, 0xa0, 0x81, 0x2a, 0xb0, 0x41, 0x07, 0x0c, 0xc1, 0x14, 0xac, + 0xc0, 0x0e, 0x9c, 0xc1, 0x1d, 0xbc, 0xc0, 0x17, 0x02, 0x61, 0x06, 0x44, + 0x40, 0x0c, 0x24, 0xc0, 0x3c, 0x10, 0x42, 0x06, 0xe4, 0x80, 0x1c, 0x0a, + 0xa1, 0x18, 0x96, 0x41, 0x19, 0x54, 0xc0, 0x3a, 0xd8, 0x04, 0xb5, 0xb0, + 0x03, 0x1a, 0xa0, 0x11, 0x9a, 0xe1, 0x10, 0xb4, 0xc1, 0x31, 0x38, 0x0d, + 0xe7, 0xe0, 0x12, 0x5c, 0x81, 0xeb, 0x70, 0x17, 0x06, 0x60, 0x18, 0x9e, + 0xc2, 0x18, 0xbc, 0x86, 0x09, 0x04, 0x41, 0xc8, 0x08, 0x13, 0x61, 0x21, + 0x3a, 0x88, 0x11, 0x62, 0x8e, 0xd8, 0x22, 0xce, 0x08, 0x17, 0x99, 0x8e, + 0x04, 0x22, 0x61, 0x48, 0x34, 0x92, 0x80, 0xa4, 0x20, 0xe9, 0x88, 0x14, + 0x51, 0x22, 0xc5, 0xc8, 0x72, 0xa4, 0x02, 0xa9, 0x42, 0x6a, 0x91, 0x5d, + 0x48, 0x23, 0xf2, 0x2d, 0x72, 0x14, 0x39, 0x8d, 0x5c, 0x40, 0xfa, 0x90, + 0xdb, 0xc8, 0x20, 0x32, 0x8a, 0xfc, 0x8a, 0xbc, 0x47, 0x31, 0x94, 0x81, + 0xb2, 0x51, 0x03, 0xd4, 0x02, 0x75, 0x40, 0xb9, 0xa8, 0x1f, 0x1a, 0x8a, + 0xc6, 0xa0, 0x73, 0xd1, 0x74, 0x34, 0x0f, 0x5d, 0x80, 0x96, 0xa2, 0x6b, + 0xd1, 0x1a, 0xb4, 0x1e, 0x3d, 0x80, 0xb6, 0xa2, 0xa7, 0xd1, 0x4b, 0xe8, + 0x75, 0x74, 0x00, 0x7d, 0x8a, 0x8e, 0x63, 0x80, 0xd1, 0x31, 0x0e, 0x66, + 0x8c, 0xd9, 0x61, 0x5c, 0x8c, 0x87, 0x45, 0x60, 0x89, 0x58, 0x1a, 0x26, + 0xc7, 0x16, 0x63, 0xe5, 0x58, 0x35, 0x56, 0x8f, 0x35, 0x63, 0x1d, 0x58, + 0x37, 0x76, 0x15, 0x1b, 0xc0, 0x9e, 0x61, 0xef, 0x08, 0x24, 0x02, 0x8b, + 0x80, 0x13, 0xec, 0x08, 0x5e, 0x84, 0x10, 0xc2, 0x6c, 0x82, 0x90, 0x90, + 0x47, 0x58, 0x4c, 0x58, 0x43, 0xa8, 0x25, 0xec, 0x23, 0xb4, 0x12, 0xba, + 0x08, 0x57, 0x09, 0x83, 0x84, 0x31, 0xc2, 0x27, 0x22, 0x93, 0xa8, 0x4f, + 0xb4, 0x25, 0x7a, 0x12, 0xf9, 0xc4, 0x78, 0x62, 0x3a, 0xb1, 0x90, 0x58, + 0x46, 0xac, 0x26, 0xee, 0x21, 0x1e, 0x21, 0x9e, 0x25, 0x5e, 0x27, 0x0e, + 0x13, 0x5f, 0x93, 0x48, 0x24, 0x0e, 0xc9, 0x92, 0xe4, 0x4e, 0x0a, 0x21, + 0x25, 0x90, 0x32, 0x49, 0x0b, 0x49, 0x6b, 0x48, 0xdb, 0x48, 0x2d, 0xa4, + 0x53, 0xa4, 0x3e, 0xd2, 0x10, 0x69, 0x9c, 0x4c, 0x26, 0xeb, 0x90, 0x6d, + 0xc9, 0xde, 0xe4, 0x08, 0xb2, 0x80, 0xac, 0x20, 0x97, 0x91, 0xb7, 0x90, + 0x0f, 0x90, 0x4f, 0x92, 0xfb, 0xc9, 0xc3, 0xe4, 0xb7, 0x14, 0x3a, 0xc5, + 0x88, 0xe2, 0x4c, 0x09, 0xa2, 0x24, 0x52, 0xa4, 0x94, 0x12, 0x4a, 0x35, + 0x65, 0x3f, 0xe5, 0x04, 0xa5, 0x9f, 0x32, 0x42, 0x99, 0xa0, 0xaa, 0x51, + 0xcd, 0xa9, 0x9e, 0xd4, 0x08, 0xaa, 0x88, 0x3a, 0x9f, 0x5a, 0x49, 0x6d, + 0xa0, 0x76, 0x50, 0x2f, 0x53, 0x87, 0xa9, 0x13, 0x34, 0x75, 0x9a, 0x25, + 0xcd, 0x9b, 0x16, 0x43, 0xcb, 0xa4, 0x2d, 0xa3, 0xd5, 0xd0, 0x9a, 0x69, + 0x67, 0x69, 0xf7, 0x68, 0x2f, 0xe9, 0x74, 0xba, 0x09, 0xdd, 0x83, 0x1e, + 0x45, 0x97, 0xd0, 0x97, 0xd2, 0x6b, 0xe8, 0x07, 0xe9, 0xe7, 0xe9, 0x83, + 0xf4, 0x77, 0x0c, 0x0d, 0x86, 0x0d, 0x83, 0xc7, 0x48, 0x62, 0x28, 0x19, + 0x6b, 0x19, 0x7b, 0x19, 0xa7, 0x18, 0xb7, 0x19, 0x2f, 0x99, 0x4c, 0xa6, + 0x05, 0xd3, 0x97, 0x99, 0xc8, 0x54, 0x30, 0xd7, 0x32, 0x1b, 0x99, 0x67, + 0x98, 0x0f, 0x98, 0x6f, 0x55, 0x58, 0x2a, 0xf6, 0x2a, 0x7c, 0x15, 0x91, + 0xca, 0x12, 0x95, 0x3a, 0x95, 0x56, 0x95, 0x7e, 0x95, 0xe7, 0xaa, 0x54, + 0x55, 0x73, 0x55, 0x3f, 0xd5, 0x79, 0xaa, 0x0b, 0x54, 0xab, 0x55, 0x0f, + 0xab, 0x5e, 0x56, 0x7d, 0xa6, 0x46, 0x55, 0xb3, 0x50, 0xe3, 0xa9, 0x09, + 0xd4, 0x16, 0xab, 0xd5, 0xa9, 0x1d, 0x55, 0xbb, 0xa9, 0x36, 0xae, 0xce, + 0x52, 0x77, 0x52, 0x8f, 0x50, 0xcf, 0x51, 0x5f, 0xa3, 0xbe, 0x5f, 0xfd, + 0x82, 0xfa, 0x63, 0x0d, 0xb2, 0x86, 0x85, 0x46, 0xa0, 0x86, 0x48, 0xa3, + 0x54, 0x63, 0xb7, 0xc6, 0x19, 0x8d, 0x21, 0x16, 0xc6, 0x32, 0x65, 0xf1, + 0x58, 0x42, 0xd6, 0x72, 0x56, 0x03, 0xeb, 0x2c, 0x6b, 0x98, 0x4d, 0x62, + 0x5b, 0xb2, 0xf9, 0xec, 0x4c, 0x76, 0x05, 0xfb, 0x1b, 0x76, 0x2f, 0x7b, + 0x4c, 0x53, 0x43, 0x73, 0xaa, 0x66, 0xac, 0x66, 0x91, 0x66, 0x9d, 0xe6, + 0x71, 0xcd, 0x01, 0x0e, 0xc6, 0xb1, 0xe0, 0xf0, 0x39, 0xd9, 0x9c, 0x4a, + 0xce, 0x21, 0xce, 0x0d, 0xce, 0x7b, 0x2d, 0x03, 0x2d, 0x3f, 0x2d, 0xb1, + 0xd6, 0x6a, 0xad, 0x66, 0xad, 0x7e, 0xad, 0x37, 0xda, 0x7a, 0xda, 0xbe, + 0xda, 0x62, 0xed, 0x72, 0xed, 0x16, 0xed, 0xeb, 0xda, 0xef, 0x75, 0x70, + 0x9d, 0x40, 0x9d, 0x2c, 0x9d, 0xf5, 0x3a, 0x6d, 0x3a, 0xf7, 0x75, 0x09, + 0xba, 0x36, 0xba, 0x51, 0xba, 0x85, 0xba, 0xdb, 0x75, 0xcf, 0xea, 0x3e, + 0xd3, 0x63, 0xeb, 0x79, 0xe9, 0x09, 0xf5, 0xca, 0xf5, 0x0e, 0xe9, 0xdd, + 0xd1, 0x47, 0xf5, 0x6d, 0xf4, 0xa3, 0xf5, 0x17, 0xea, 0xef, 0xd6, 0xef, + 0xd1, 0x1f, 0x37, 0x30, 0x34, 0x08, 0x36, 0x90, 0x19, 0x6c, 0x31, 0x38, + 0x63, 0xf0, 0xcc, 0x90, 0x63, 0xe8, 0x6b, 0x98, 0x69, 0xb8, 0xd1, 0xf0, + 0x84, 0xe1, 0xa8, 0x11, 0xcb, 0x68, 0xba, 0x91, 0xc4, 0x68, 0xa3, 0xd1, + 0x49, 0xa3, 0x27, 0xb8, 0x26, 0xee, 0x87, 0x67, 0xe3, 0x35, 0x78, 0x17, + 0x3e, 0x66, 0xac, 0x6f, 0x1c, 0x62, 0xac, 0x34, 0xde, 0x65, 0xdc, 0x6b, + 0x3c, 0x61, 0x62, 0x69, 0x32, 0xdb, 0xa4, 0xc4, 0xa4, 0xc5, 0xe4, 0xbe, + 0x29, 0xcd, 0x94, 0x6b, 0x9a, 0x66, 0xba, 0xd1, 0xb4, 0xd3, 0x74, 0xcc, + 0xcc, 0xc8, 0x2c, 0xdc, 0xac, 0xd8, 0xac, 0xc9, 0xec, 0x8e, 0x39, 0xd5, + 0x9c, 0x6b, 0x9e, 0x61, 0xbe, 0xd9, 0xbc, 0xdb, 0xfc, 0x8d, 0x85, 0xa5, + 0x45, 0x9c, 0xc5, 0x4a, 0x8b, 0x36, 0x8b, 0xc7, 0x96, 0xda, 0x96, 0x7c, + 0xcb, 0x05, 0x96, 0x4d, 0x96, 0xf7, 0xac, 0x98, 0x56, 0x3e, 0x56, 0x79, + 0x56, 0xf5, 0x56, 0xd7, 0xac, 0x49, 0xd6, 0x5c, 0xeb, 0x2c, 0xeb, 0x6d, + 0xd6, 0x57, 0x6c, 0x50, 0x1b, 0x57, 0x9b, 0x0c, 0x9b, 0x3a, 0x9b, 0xcb, + 0xb6, 0xa8, 0xad, 0x9b, 0xad, 0xc4, 0x76, 0x9b, 0x6d, 0xdf, 0x14, 0xe2, + 0x14, 0x8f, 0x29, 0xd2, 0x29, 0xf5, 0x53, 0x6e, 0xda, 0x31, 0xec, 0xfc, + 0xec, 0x0a, 0xec, 0x9a, 0xec, 0x06, 0xed, 0x39, 0xf6, 0x61, 0xf6, 0x25, + 0xf6, 0x6d, 0xf6, 0xcf, 0x1d, 0xcc, 0x1c, 0x12, 0x1d, 0xd6, 0x3b, 0x74, + 0x3b, 0x7c, 0x72, 0x74, 0x75, 0xcc, 0x76, 0x6c, 0x70, 0xbc, 0xeb, 0xa4, + 0xe1, 0x34, 0xc3, 0xa9, 0xc4, 0xa9, 0xc3, 0xe9, 0x57, 0x67, 0x1b, 0x67, + 0xa1, 0x73, 0x9d, 0xf3, 0x35, 0x17, 0xa6, 0x4b, 0x90, 0xcb, 0x12, 0x97, + 0x76, 0x97, 0x17, 0x53, 0x6d, 0xa7, 0x8a, 0xa7, 0x6e, 0x9f, 0x7a, 0xcb, + 0x95, 0xe5, 0x1a, 0xee, 0xba, 0xd2, 0xb5, 0xd3, 0xf5, 0xa3, 0x9b, 0xbb, + 0x9b, 0xdc, 0xad, 0xd9, 0x6d, 0xd4, 0xdd, 0xcc, 0x3d, 0xc5, 0x7d, 0xab, + 0xfb, 0x4d, 0x2e, 0x9b, 0x1b, 0xc9, 0x5d, 0xc3, 0x3d, 0xef, 0x41, 0xf4, + 0xf0, 0xf7, 0x58, 0xe2, 0x71, 0xcc, 0xe3, 0x9d, 0xa7, 0x9b, 0xa7, 0xc2, + 0xf3, 0x90, 0xe7, 0x2f, 0x5e, 0x76, 0x5e, 0x59, 0x5e, 0xfb, 0xbd, 0x1e, + 0x4f, 0xb3, 0x9c, 0x26, 0x9e, 0xd6, 0x30, 0x6d, 0xc8, 0xdb, 0xc4, 0x5b, + 0xe0, 0xbd, 0xcb, 0x7b, 0x60, 0x3a, 0x3e, 0x3d, 0x65, 0xfa, 0xce, 0xe9, + 0x03, 0x3e, 0xc6, 0x3e, 0x02, 0x9f, 0x7a, 0x9f, 0x87, 0xbe, 0xa6, 0xbe, + 0x22, 0xdf, 0x3d, 0xbe, 0x23, 0x7e, 0xd6, 0x7e, 0x99, 0x7e, 0x07, 0xfc, + 0x9e, 0xfb, 0x3b, 0xfa, 0xcb, 0xfd, 0x8f, 0xf8, 0xbf, 0xe1, 0x79, 0xf2, + 0x16, 0xf1, 0x4e, 0x05, 0x60, 0x01, 0xc1, 0x01, 0xe5, 0x01, 0xbd, 0x81, + 0x1a, 0x81, 0xb3, 0x03, 0x6b, 0x03, 0x1f, 0x04, 0x99, 0x04, 0xa5, 0x07, + 0x35, 0x05, 0x8d, 0x05, 0xbb, 0x06, 0x2f, 0x0c, 0x3e, 0x15, 0x42, 0x0c, + 0x09, 0x0d, 0x59, 0x1f, 0x72, 0x93, 0x6f, 0xc0, 0x17, 0xf2, 0x1b, 0xf9, + 0x63, 0x33, 0xdc, 0x67, 0x2c, 0x9a, 0xd1, 0x15, 0xca, 0x08, 0x9d, 0x15, + 0x5a, 0x1b, 0xfa, 0x30, 0xcc, 0x26, 0x4c, 0x1e, 0xd6, 0x11, 0x8e, 0x86, + 0xcf, 0x08, 0xdf, 0x10, 0x7e, 0x6f, 0xa6, 0xf9, 0x4c, 0xe9, 0xcc, 0xb6, + 0x08, 0x88, 0xe0, 0x47, 0x6c, 0x88, 0xb8, 0x1f, 0x69, 0x19, 0x99, 0x17, + 0xf9, 0x7d, 0x14, 0x29, 0x2a, 0x32, 0xaa, 0x2e, 0xea, 0x51, 0xb4, 0x53, + 0x74, 0x71, 0x74, 0xf7, 0x2c, 0xd6, 0xac, 0xe4, 0x59, 0xfb, 0x67, 0xbd, + 0x8e, 0xf1, 0x8f, 0xa9, 0x8c, 0xb9, 0x3b, 0xdb, 0x6a, 0xb6, 0x72, 0x76, + 0x67, 0xac, 0x6a, 0x6c, 0x52, 0x6c, 0x63, 0xec, 0x9b, 0xb8, 0x80, 0xb8, + 0xaa, 0xb8, 0x81, 0x78, 0x87, 0xf8, 0x45, 0xf1, 0x97, 0x12, 0x74, 0x13, + 0x24, 0x09, 0xed, 0x89, 0xe4, 0xc4, 0xd8, 0xc4, 0x3d, 0x89, 0xe3, 0x73, + 0x02, 0xe7, 0x6c, 0x9a, 0x33, 0x9c, 0xe4, 0x9a, 0x54, 0x96, 0x74, 0x63, + 0xae, 0xe5, 0xdc, 0xa2, 0xb9, 0x17, 0xe6, 0xe9, 0xce, 0xcb, 0x9e, 0x77, + 0x3c, 0x59, 0x35, 0x59, 0x90, 0x7c, 0x38, 0x85, 0x98, 0x12, 0x97, 0xb2, + 0x3f, 0xe5, 0x83, 0x20, 0x42, 0x50, 0x2f, 0x18, 0x4f, 0xe5, 0xa7, 0x6e, + 0x4d, 0x1d, 0x13, 0xf2, 0x84, 0x9b, 0x85, 0x4f, 0x45, 0xbe, 0xa2, 0x8d, + 0xa2, 0x51, 0xb1, 0xb7, 0xb8, 0x4a, 0x3c, 0x92, 0xe6, 0x9d, 0x56, 0x95, + 0xf6, 0x38, 0xdd, 0x3b, 0x7d, 0x43, 0xfa, 0x68, 0x86, 0x4f, 0x46, 0x75, + 0xc6, 0x33, 0x09, 0x4f, 0x52, 0x2b, 0x79, 0x91, 0x19, 0x92, 0xb9, 0x23, + 0xf3, 0x4d, 0x56, 0x44, 0xd6, 0xde, 0xac, 0xcf, 0xd9, 0x71, 0xd9, 0x2d, + 0x39, 0x94, 0x9c, 0x94, 0x9c, 0xa3, 0x52, 0x0d, 0x69, 0x96, 0xb4, 0x2b, + 0xd7, 0x30, 0xb7, 0x28, 0xb7, 0x4f, 0x66, 0x2b, 0x2b, 0x93, 0x0d, 0xe4, + 0x79, 0xe6, 0x6d, 0xca, 0x1b, 0x93, 0x87, 0xca, 0xf7, 0xe4, 0x23, 0xf9, + 0x73, 0xf3, 0xdb, 0x15, 0x6c, 0x85, 0x4c, 0xd1, 0xa3, 0xb4, 0x52, 0xae, + 0x50, 0x0e, 0x16, 0x4c, 0x2f, 0xa8, 0x2b, 0x78, 0x5b, 0x18, 0x5b, 0x78, + 0xb8, 0x48, 0xbd, 0x48, 0x5a, 0xd4, 0x33, 0xdf, 0x66, 0xfe, 0xea, 0xf9, + 0x23, 0x0b, 0x82, 0x16, 0x7c, 0xbd, 0x90, 0xb0, 0x50, 0xb8, 0xb0, 0xb3, + 0xd8, 0xb8, 0x78, 0x59, 0xf1, 0xe0, 0x22, 0xbf, 0x45, 0xbb, 0x16, 0x23, + 0x8b, 0x53, 0x17, 0x77, 0x2e, 0x31, 0x5d, 0x52, 0xba, 0x64, 0x78, 0x69, + 0xf0, 0xd2, 0x7d, 0xcb, 0x68, 0xcb, 0xb2, 0x96, 0xfd, 0x50, 0xe2, 0x58, + 0x52, 0x55, 0xf2, 0x6a, 0x79, 0xdc, 0xf2, 0x8e, 0x52, 0x83, 0xd2, 0xa5, + 0xa5, 0x43, 0x2b, 0x82, 0x57, 0x34, 0x95, 0xa9, 0x94, 0xc9, 0xcb, 0x6e, + 0xae, 0xf4, 0x5a, 0xb9, 0x63, 0x15, 0x61, 0x95, 0x64, 0x55, 0xef, 0x6a, + 0x97, 0xd5, 0x5b, 0x56, 0x7f, 0x2a, 0x17, 0x95, 0x5f, 0xac, 0x70, 0xac, + 0xa8, 0xae, 0xf8, 0xb0, 0x46, 0xb8, 0xe6, 0xe2, 0x57, 0x4e, 0x5f, 0xd5, + 0x7c, 0xf5, 0x79, 0x6d, 0xda, 0xda, 0xde, 0x4a, 0xb7, 0xca, 0xed, 0xeb, + 0x48, 0xeb, 0xa4, 0xeb, 0x6e, 0xac, 0xf7, 0x59, 0xbf, 0xaf, 0x4a, 0xbd, + 0x6a, 0x41, 0xd5, 0xd0, 0x86, 0xf0, 0x0d, 0xad, 0x1b, 0xf1, 0x8d, 0xe5, + 0x1b, 0x5f, 0x6d, 0x4a, 0xde, 0x74, 0xa1, 0x7a, 0x6a, 0xf5, 0x8e, 0xcd, + 0xb4, 0xcd, 0xca, 0xcd, 0x03, 0x35, 0x61, 0x35, 0xed, 0x5b, 0xcc, 0xb6, + 0xac, 0xdb, 0xf2, 0xa1, 0x36, 0xa3, 0xf6, 0x7a, 0x9d, 0x7f, 0x5d, 0xcb, + 0x56, 0xfd, 0xad, 0xab, 0xb7, 0xbe, 0xd9, 0x26, 0xda, 0xd6, 0xbf, 0xdd, + 0x77, 0x7b, 0xf3, 0x0e, 0x83, 0x1d, 0x15, 0x3b, 0xde, 0xef, 0x94, 0xec, + 0xbc, 0xb5, 0x2b, 0x78, 0x57, 0x6b, 0xbd, 0x45, 0x7d, 0xf5, 0x6e, 0xd2, + 0xee, 0x82, 0xdd, 0x8f, 0x1a, 0x62, 0x1b, 0xba, 0xbf, 0xe6, 0x7e, 0xdd, + 0xb8, 0x47, 0x77, 0x4f, 0xc5, 0x9e, 0x8f, 0x7b, 0xa5, 0x7b, 0x07, 0xf6, + 0x45, 0xef, 0xeb, 0x6a, 0x74, 0x6f, 0x6c, 0xdc, 0xaf, 0xbf, 0xbf, 0xb2, + 0x09, 0x6d, 0x52, 0x36, 0x8d, 0x1e, 0x48, 0x3a, 0x70, 0xe5, 0x9b, 0x80, + 0x6f, 0xda, 0x9b, 0xed, 0x9a, 0x77, 0xb5, 0x70, 0x5a, 0x2a, 0x0e, 0xc2, + 0x41, 0xe5, 0xc1, 0x27, 0xdf, 0xa6, 0x7c, 0x7b, 0xe3, 0x50, 0xe8, 0xa1, + 0xce, 0xc3, 0xdc, 0xc3, 0xcd, 0xdf, 0x99, 0x7f, 0xb7, 0xf5, 0x08, 0xeb, + 0x48, 0x79, 0x2b, 0xd2, 0x3a, 0xbf, 0x75, 0xac, 0x2d, 0xa3, 0x6d, 0xa0, + 0x3d, 0xa1, 0xbd, 0xef, 0xe8, 0x8c, 0xa3, 0x9d, 0x1d, 0x5e, 0x1d, 0x47, + 0xbe, 0xb7, 0xff, 0x7e, 0xef, 0x31, 0xe3, 0x63, 0x75, 0xc7, 0x35, 0x8f, + 0x57, 0x9e, 0xa0, 0x9d, 0x28, 0x3d, 0xf1, 0xf9, 0xe4, 0x82, 0x93, 0xe3, + 0xa7, 0x64, 0xa7, 0x9e, 0x9d, 0x4e, 0x3f, 0x3d, 0xd4, 0x99, 0xdc, 0x79, + 0xf7, 0x4c, 0xfc, 0x99, 0x6b, 0x5d, 0x51, 0x5d, 0xbd, 0x67, 0x43, 0xcf, + 0x9e, 0x3f, 0x17, 0x74, 0xee, 0x4c, 0xb7, 0x5f, 0xf7, 0xc9, 0xf3, 0xde, + 0xe7, 0x8f, 0x5d, 0xf0, 0xbc, 0x70, 0xf4, 0x22, 0xf7, 0x62, 0xdb, 0x25, + 0xb7, 0x4b, 0xad, 0x3d, 0xae, 0x3d, 0x47, 0x7e, 0x70, 0xfd, 0xe1, 0x48, + 0xaf, 0x5b, 0x6f, 0xeb, 0x65, 0xf7, 0xcb, 0xed, 0x57, 0x3c, 0xae, 0x74, + 0xf4, 0x4d, 0xeb, 0x3b, 0xd1, 0xef, 0xd3, 0x7f, 0xfa, 0x6a, 0xc0, 0xd5, + 0x73, 0xd7, 0xf8, 0xd7, 0x2e, 0x5d, 0x9f, 0x79, 0xbd, 0xef, 0xc6, 0xec, + 0x1b, 0xb7, 0x6e, 0x26, 0xdd, 0x1c, 0xb8, 0x25, 0xba, 0xf5, 0xf8, 0x76, + 0xf6, 0xed, 0x17, 0x77, 0x0a, 0xee, 0x4c, 0xdc, 0x5d, 0x7a, 0x8f, 0x78, + 0xaf, 0xfc, 0xbe, 0xda, 0xfd, 0xea, 0x07, 0xfa, 0x0f, 0xea, 0x7f, 0xb4, + 0xfe, 0xb1, 0x65, 0xc0, 0x6d, 0xe0, 0xf8, 0x60, 0xc0, 0x60, 0xcf, 0xc3, + 0x59, 0x0f, 0xef, 0x0e, 0x09, 0x87, 0x9e, 0xfe, 0x94, 0xff, 0xd3, 0x87, + 0xe1, 0xd2, 0x47, 0xcc, 0x47, 0xd5, 0x23, 0x46, 0x23, 0x8d, 0x8f, 0x9d, + 0x1f, 0x1f, 0x1b, 0x0d, 0x1a, 0xbd, 0xf2, 0x64, 0xce, 0x93, 0xe1, 0xa7, + 0xb2, 0xa7, 0x13, 0xcf, 0xca, 0x7e, 0x56, 0xff, 0x79, 0xeb, 0x73, 0xab, + 0xe7, 0xdf, 0xfd, 0xe2, 0xfb, 0x4b, 0xcf, 0x58, 0xfc, 0xd8, 0xf0, 0x0b, + 0xf9, 0x8b, 0xcf, 0xbf, 0xae, 0x79, 0xa9, 0xf3, 0x72, 0xef, 0xab, 0xa9, + 0xaf, 0x3a, 0xc7, 0x23, 0xc7, 0x1f, 0xbc, 0xce, 0x79, 0x3d, 0xf1, 0xa6, + 0xfc, 0xad, 0xce, 0xdb, 0x7d, 0xef, 0xb8, 0xef, 0xba, 0xdf, 0xc7, 0xbd, + 0x1f, 0x99, 0x28, 0xfc, 0x40, 0xfe, 0x50, 0xf3, 0xd1, 0xfa, 0x63, 0xc7, + 0xa7, 0xd0, 0x4f, 0xf7, 0x3e, 0xe7, 0x7c, 0xfe, 0xfc, 0x2f, 0xf7, 0x84, + 0xf3, 0xfb, 0x2d, 0x47, 0x38, 0xcf, 0x00, 0x00, 0x00, 0x20, 0x63, 0x48, + 0x52, 0x4d, 0x00, 0x00, 0x7a, 0x26, 0x00, 0x00, 0x80, 0x84, 0x00, 0x00, + 0xfa, 0x00, 0x00, 0x00, 0x80, 0xe8, 0x00, 0x00, 0x75, 0x30, 0x00, 0x00, + 0xea, 0x60, 0x00, 0x00, 0x3a, 0x98, 0x00, 0x00, 0x17, 0x70, 0x9c, 0xba, + 0x51, 0x3c, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, + 0x0b, 0x13, 0x00, 0x00, 0x0b, 0x13, 0x01, 0x00, 0x9a, 0x9c, 0x18, 0x00, + 0x00, 0x08, 0xed, 0x49, 0x44, 0x41, 0x54, 0x58, 0x85, 0xad, 0x97, 0x6b, + 0x8c, 0x65, 0x45, 0xb5, 0xc7, 0x7f, 0xab, 0xaa, 0xf6, 0xe3, 0x3c, 0xfa, + 0xf4, 0x39, 0xdd, 0xd3, 0xa7, 0x9b, 0x19, 0x60, 0xc0, 0x1e, 0x44, 0x50, + 0x1c, 0x83, 0x17, 0x24, 0xf2, 0xf6, 0x31, 0x1a, 0x47, 0x23, 0xf8, 0xc5, + 0x57, 0x7c, 0xa0, 0x06, 0xe4, 0x83, 0xe2, 0x23, 0x26, 0x97, 0xa8, 0x97, + 0x44, 0x13, 0x8c, 0xc6, 0x2f, 0x2a, 0x26, 0x1a, 0xa3, 0xd1, 0xc4, 0x44, + 0x8d, 0x06, 0xae, 0xf7, 0x5e, 0xa2, 0x18, 0x45, 0x24, 0x8a, 0x46, 0x50, + 0x06, 0x05, 0x71, 0x98, 0xa1, 0xe7, 0xfd, 0xe8, 0x9e, 0xe9, 0x3e, 0xe7, + 0x74, 0x9f, 0xd3, 0xe7, 0xb1, 0x77, 0xd5, 0xf2, 0xc3, 0xee, 0xee, 0x99, + 0x4b, 0x64, 0xc6, 0x44, 0x2a, 0xd9, 0x49, 0x65, 0xa7, 0xf6, 0xfe, 0xff, + 0x6b, 0xd5, 0x7f, 0xfd, 0xd7, 0x2a, 0x79, 0xdb, 0x8e, 0xfb, 0x51, 0x55, + 0x4a, 0xe9, 0xc4, 0x47, 0x45, 0xe4, 0xed, 0xfd, 0x41, 0xeb, 0x12, 0x50, + 0x07, 0xc2, 0x0b, 0x3b, 0x14, 0xc0, 0xa7, 0x49, 0xe3, 0x19, 0x11, 0xb9, + 0xb7, 0xdf, 0x5f, 0xfa, 0x22, 0x02, 0x4e, 0x55, 0xa9, 0x94, 0x37, 0xdd, + 0xbb, 0xd8, 0xde, 0x77, 0xf3, 0x33, 0x73, 0x3f, 0xa7, 0xdf, 0x6f, 0x21, + 0x62, 0x40, 0xfe, 0x5d, 0x02, 0xb2, 0x0e, 0x5a, 0xcc, 0x55, 0x51, 0xf5, + 0xa4, 0x49, 0xed, 0xca, 0x8b, 0x2e, 0x7c, 0xfd, 0x95, 0x53, 0x93, 0x2f, + 0xd9, 0xb1, 0xda, 0x3f, 0xf9, 0x5a, 0x57, 0x4a, 0x27, 0x6e, 0x5f, 0x6a, + 0xcd, 0xdd, 0xfc, 0xbb, 0x47, 0xbf, 0x42, 0x7f, 0xd8, 0x26, 0x4d, 0xea, + 0x88, 0x14, 0x1f, 0xbc, 0x70, 0x43, 0x36, 0x08, 0x9d, 0x6c, 0xed, 0xe1, + 0xf8, 0x89, 0xa7, 0xb8, 0xe6, 0x8a, 0x3b, 0x5e, 0xd3, 0x9c, 0xbc, 0xf8, + 0xb3, 0x4e, 0x44, 0x3e, 0xb2, 0x7b, 0xee, 0x01, 0xfa, 0xc3, 0x36, 0xf5, + 0xda, 0xf9, 0xa8, 0x86, 0x17, 0x10, 0xf8, 0x74, 0x02, 0xc5, 0x48, 0x92, + 0x1a, 0x9d, 0x95, 0x23, 0xfc, 0xfd, 0xd9, 0x9f, 0x31, 0xd9, 0x98, 0xfd, + 0xa0, 0xeb, 0x0f, 0x5a, 0x17, 0xf4, 0x07, 0x2d, 0xd2, 0xa4, 0xfe, 0x2f, + 0x83, 0xe7, 0xb9, 0xe0, 0x3d, 0x8c, 0x46, 0xc2, 0x30, 0x38, 0x52, 0x9b, + 0x93, 0xa6, 0x45, 0xc4, 0xac, 0x05, 0x91, 0xe7, 0x46, 0x4f, 0x37, 0x48, + 0xa8, 0x06, 0x4a, 0x69, 0x9d, 0xfe, 0x60, 0x89, 0xde, 0xea, 0xc9, 0xa6, + 0x53, 0x54, 0x45, 0xcc, 0x59, 0x25, 0x67, 0x0c, 0xf4, 0xfb, 0xc2, 0x60, + 0x60, 0x18, 0x1f, 0xf7, 0x8c, 0x8d, 0x85, 0x27, 0x1b, 0x0d, 0x7f, 0x70, + 0xeb, 0xf9, 0x2b, 0x83, 0x7d, 0xfb, 0x93, 0x74, 0x69, 0xc9, 0xce, 0xe4, + 0xb9, 0x5c, 0xda, 0xeb, 0xd9, 0x34, 0xcb, 0xa0, 0x5a, 0xf5, 0x58, 0xfb, + 0x7c, 0x27, 0x29, 0x88, 0x58, 0x44, 0x08, 0x4e, 0x10, 0x2f, 0x67, 0x11, + 0x9c, 0x31, 0xca, 0x89, 0x56, 0x44, 0xad, 0xec, 0xdb, 0xd7, 0x5c, 0xdd, + 0xfb, 0xc2, 0xf4, 0x4c, 0xfe, 0x93, 0x76, 0xcb, 0xce, 0x5d, 0xf4, 0xe2, + 0x21, 0x77, 0x7f, 0xfe, 0x08, 0x9f, 0xb9, 0x6b, 0x0b, 0x0f, 0x3f, 0x5c, + 0x65, 0xeb, 0xd6, 0x51, 0x73, 0x7e, 0xde, 0x5d, 0x7f, 0x62, 0xc1, 0xbd, + 0x63, 0x79, 0xc5, 0xbc, 0x2e, 0xcf, 0xa5, 0x66, 0xcc, 0xa9, 0xdd, 0x9f, + 0x1e, 0x11, 0x11, 0x03, 0x48, 0x70, 0xeb, 0x8c, 0x4e, 0x3d, 0xff, 0x7f, + 0xa1, 0x31, 0xb0, 0xd8, 0x8a, 0xb8, 0xf8, 0x45, 0x83, 0xbf, 0x5f, 0x77, + 0x7d, 0xef, 0x06, 0x11, 0x99, 0xef, 0xb4, 0x2d, 0xe5, 0xb2, 0xd2, 0x6a, + 0x39, 0xbe, 0xf6, 0xf5, 0x26, 0x07, 0x0e, 0xc4, 0x54, 0x2a, 0x81, 0x72, + 0x59, 0x17, 0x5e, 0x7d, 0x55, 0xef, 0xc7, 0xcd, 0xa6, 0xff, 0xf1, 0xb7, + 0xbe, 0x3d, 0xb9, 0xe7, 0xf0, 0x61, 0x5b, 0xab, 0x56, 0x9f, 0xef, 0x58, + 0x0b, 0x2c, 0x73, 0xc6, 0xad, 0x23, 0xf4, 0x56, 0x0d, 0xe5, 0x52, 0x18, + 0x7c, 0xec, 0x8e, 0x93, 0xd7, 0x6d, 0x9b, 0xcd, 0xe6, 0x17, 0x17, 0x2d, + 0x6f, 0xd8, 0xb1, 0xc4, 0x4b, 0x2f, 0xed, 0xb3, 0xba, 0xea, 0xb8, 0xfb, + 0x0b, 0x33, 0x1c, 0x3b, 0x16, 0xb1, 0x65, 0x73, 0x46, 0x9a, 0x06, 0x2e, + 0xbf, 0xbc, 0x4f, 0x50, 0xbd, 0xe2, 0xf0, 0x51, 0xb7, 0x6d, 0x5d, 0x17, + 0x67, 0x8c, 0xee, 0x3f, 0x63, 0x75, 0xfa, 0xe8, 0x0d, 0x2d, 0x3b, 0xdf, + 0xb8, 0x7c, 0xcf, 0xec, 0xb6, 0xd1, 0x89, 0x46, 0x3d, 0xe3, 0xb6, 0x0f, + 0x1d, 0xe3, 0xad, 0x6f, 0x59, 0x64, 0x34, 0x92, 0xda, 0xee, 0x3d, 0xc9, + 0x3b, 0xc7, 0xc6, 0xfc, 0x1d, 0xad, 0x96, 0xfd, 0xf0, 0xe1, 0x23, 0xd1, + 0x1b, 0xbb, 0x5d, 0x93, 0x84, 0x20, 0xfc, 0xf1, 0xd1, 0xf2, 0x2d, 0x83, + 0xcc, 0x12, 0x45, 0x67, 0x27, 0xe0, 0xce, 0x04, 0x1e, 0x02, 0x94, 0xe3, + 0x40, 0x6d, 0x3c, 0xdc, 0xb7, 0xeb, 0xf1, 0x12, 0x0a, 0xd4, 0xc6, 0x4b, + 0x7c, 0xe7, 0xbb, 0xe3, 0xe7, 0xfd, 0xf0, 0x47, 0x8d, 0x47, 0x0f, 0x1e, + 0x8b, 0xa7, 0xeb, 0xd5, 0x9c, 0x5e, 0xcf, 0x71, 0xf4, 0xa8, 0x63, 0x6e, + 0x2e, 0x3e, 0x3e, 0x7f, 0xdc, 0xfd, 0xe0, 0xc0, 0xc1, 0xf8, 0x4d, 0x63, + 0xa5, 0xfc, 0x79, 0x05, 0x78, 0x3a, 0x96, 0xfb, 0xe7, 0x0b, 0xd6, 0x66, + 0x02, 0xd5, 0x4a, 0xe0, 0xa7, 0xff, 0x33, 0xde, 0x9a, 0x5f, 0x8a, 0xb8, + 0xe9, 0x4d, 0x1d, 0xb6, 0x6c, 0xe9, 0xf0, 0xd7, 0x27, 0xcb, 0x2f, 0x3a, + 0x70, 0x2c, 0x99, 0x1e, 0x2f, 0x65, 0x38, 0x07, 0xd6, 0x16, 0x48, 0xbd, + 0x9e, 0x99, 0x79, 0xf8, 0xf7, 0xd5, 0x8f, 0x57, 0xd3, 0x40, 0xb9, 0x14, + 0xf0, 0xe1, 0xec, 0x6e, 0x6a, 0x00, 0x34, 0x08, 0x1a, 0xc0, 0xe7, 0x8a, + 0x06, 0x36, 0x6c, 0x58, 0x15, 0x86, 0x23, 0xc3, 0x65, 0x2f, 0x1b, 0xc6, + 0x3b, 0x5e, 0xdb, 0xa5, 0x39, 0x9d, 0x63, 0x4d, 0xe0, 0xea, 0x57, 0xf7, + 0x1e, 0xf9, 0x8f, 0xed, 0xfd, 0xef, 0x75, 0xfa, 0x8e, 0x76, 0xdb, 0x92, + 0x65, 0xc5, 0xfa, 0x38, 0x86, 0xc9, 0x71, 0x4f, 0x92, 0xe8, 0x19, 0xc0, + 0x75, 0xe3, 0xdf, 0x28, 0x38, 0x11, 0x41, 0xac, 0x41, 0xac, 0xa1, 0x54, + 0x8d, 0xc9, 0x47, 0x1e, 0xef, 0x15, 0x11, 0x41, 0x44, 0xe9, 0xf4, 0x1c, + 0xdb, 0xb6, 0x0d, 0xaf, 0x7f, 0xdf, 0x7b, 0x4f, 0x3e, 0xf1, 0xf4, 0xd3, + 0x29, 0xfb, 0xf6, 0xa7, 0xd4, 0xeb, 0x21, 0xbb, 0xf2, 0xca, 0xfe, 0xfb, + 0x4b, 0xa5, 0xf0, 0xfd, 0x76, 0xdb, 0x7e, 0xe0, 0xf8, 0x71, 0xf7, 0x96, + 0x4e, 0xc7, 0x55, 0x9d, 0x53, 0x2a, 0x95, 0x70, 0x16, 0x17, 0x17, 0x54, + 0xc1, 0x5a, 0x83, 0x58, 0x29, 0x22, 0x50, 0xaf, 0x27, 0x34, 0x26, 0x53, + 0x26, 0x27, 0x53, 0xaa, 0x63, 0x11, 0x3e, 0x0f, 0x6b, 0x47, 0x20, 0x24, + 0x4e, 0xf9, 0xd9, 0xcf, 0xc7, 0x3e, 0xdd, 0x6e, 0x5b, 0xe3, 0x1c, 0x3c, + 0xfd, 0x74, 0xca, 0x9e, 0xbd, 0x31, 0x83, 0xbe, 0x70, 0xde, 0x79, 0xd9, + 0x2f, 0xaf, 0x7a, 0xd5, 0xea, 0xbb, 0xae, 0xbd, 0xa6, 0x77, 0xe1, 0x55, + 0x57, 0xad, 0x7e, 0x64, 0x6a, 0x2a, 0x7f, 0xb2, 0xdd, 0x31, 0x0c, 0x87, + 0x72, 0xc6, 0x5a, 0xa6, 0x2a, 0x60, 0x0d, 0x62, 0x04, 0x13, 0x42, 0x41, + 0xd7, 0x59, 0x8b, 0xcf, 0x15, 0x63, 0x0d, 0xc6, 0x15, 0x47, 0x02, 0x30, + 0x5e, 0xcd, 0x39, 0x70, 0x34, 0x69, 0x7e, 0xec, 0x13, 0xe7, 0xff, 0xea, + 0x0f, 0x7f, 0x18, 0x4b, 0x9a, 0xcd, 0x9c, 0x85, 0x05, 0x07, 0x28, 0xe5, + 0x52, 0xa0, 0xdb, 0x33, 0x38, 0x1b, 0x4e, 0x5e, 0xf6, 0xb2, 0xfe, 0x3d, + 0xd7, 0x5e, 0xbb, 0x7a, 0xd9, 0x2b, 0x2f, 0x1f, 0xdc, 0x9a, 0x65, 0x26, + 0x5f, 0x5e, 0xb6, 0x58, 0x73, 0x5a, 0x35, 0x5c, 0x7b, 0x7c, 0x0e, 0x2e, + 0x71, 0xa4, 0x25, 0x87, 0x7a, 0xc5, 0x18, 0x03, 0x41, 0x95, 0x3c, 0x04, + 0x7c, 0x50, 0xd2, 0xd8, 0xd2, 0x9c, 0xa9, 0x60, 0x22, 0x83, 0xf7, 0x10, + 0xd4, 0x30, 0x55, 0xcf, 0xd9, 0x3d, 0x97, 0xdc, 0x70, 0xdf, 0x7f, 0xd7, + 0x76, 0xff, 0xed, 0x6f, 0xc9, 0x2d, 0xc6, 0x60, 0xa6, 0x36, 0x79, 0x26, + 0x26, 0x02, 0xd5, 0xaa, 0x12, 0xa7, 0x86, 0x5e, 0xcf, 0xb1, 0xb4, 0x28, + 0xcc, 0xce, 0x66, 0xdf, 0xba, 0xed, 0xd6, 0xc5, 0xcb, 0x27, 0x27, 0xfd, + 0x68, 0x65, 0xd5, 0x21, 0x08, 0xc1, 0x83, 0xcf, 0x14, 0x15, 0x4b, 0x5c, + 0x4b, 0x88, 0x53, 0x47, 0xe4, 0x8a, 0x10, 0xd9, 0x4b, 0x66, 0xdf, 0x7d, + 0x67, 0xa7, 0xfb, 0x54, 0xe2, 0x7d, 0x0f, 0x67, 0x62, 0x8c, 0x31, 0xf8, + 0xa0, 0x74, 0xbb, 0x23, 0x54, 0xb5, 0x28, 0x23, 0x02, 0xd5, 0xb2, 0xb2, + 0xb2, 0x62, 0xea, 0xbb, 0xf7, 0x26, 0x6f, 0x5d, 0x98, 0x77, 0xb7, 0x74, + 0x96, 0xed, 0xe6, 0x2c, 0x93, 0xe5, 0x89, 0xfa, 0xe8, 0x48, 0x5a, 0xb6, + 0xcc, 0xcc, 0xa4, 0x64, 0x59, 0x20, 0x4d, 0x33, 0xde, 0xbc, 0x73, 0x79, + 0xa1, 0xdf, 0x97, 0x93, 0x8f, 0xed, 0x2a, 0xbf, 0x39, 0x8e, 0x20, 0x29, + 0x59, 0xac, 0x13, 0x02, 0x82, 0x8d, 0x2c, 0xce, 0x05, 0xac, 0xc4, 0x4c, + 0x4f, 0xbc, 0x7c, 0x64, 0x2f, 0x99, 0x7d, 0xf7, 0x9d, 0x4b, 0x9d, 0xbf, + 0x26, 0xde, 0xaf, 0xe2, 0x6c, 0x84, 0xb5, 0xc2, 0x6a, 0x3f, 0x07, 0x60, + 0x62, 0x22, 0x25, 0x72, 0x86, 0xfe, 0xc0, 0x23, 0x2a, 0xc4, 0x89, 0x92, + 0x26, 0x4a, 0xaf, 0x67, 0xc7, 0xf7, 0x1d, 0x8a, 0xaf, 0xde, 0x3f, 0x97, + 0x7c, 0x68, 0xa9, 0x1d, 0x5f, 0x9f, 0xa4, 0xe6, 0xe0, 0x05, 0xe7, 0xb3, + 0x7f, 0x66, 0x5a, 0x28, 0x95, 0x3d, 0x8b, 0x4b, 0x96, 0x52, 0x59, 0x77, + 0x1d, 0xdc, 0x5f, 0xf9, 0x94, 0x9a, 0x24, 0x9a, 0xa8, 0x47, 0x54, 0x2a, + 0x11, 0xe5, 0xd4, 0x90, 0x65, 0x9e, 0x3c, 0xe4, 0x24, 0x51, 0x42, 0xb3, + 0xf1, 0x8a, 0x91, 0x01, 0xf0, 0x5e, 0x29, 0xb4, 0x20, 0x78, 0x1f, 0x28, + 0xa7, 0x96, 0x89, 0x46, 0x4a, 0x1a, 0x59, 0x6a, 0xb5, 0x98, 0x38, 0xb1, + 0x0c, 0x87, 0xeb, 0xea, 0x16, 0x92, 0x58, 0x99, 0xde, 0x04, 0xd3, 0x9b, + 0x23, 0xda, 0xed, 0xfa, 0x8d, 0xbf, 0x7f, 0x64, 0xea, 0xc1, 0xf9, 0x79, + 0x76, 0x9e, 0x7b, 0x6e, 0x9f, 0x72, 0xd9, 0x71, 0xf8, 0x68, 0x95, 0xfe, + 0xa0, 0x94, 0x4f, 0x9f, 0x63, 0x9e, 0x72, 0xd6, 0xe2, 0xbd, 0xe2, 0xbd, + 0x12, 0x45, 0x96, 0x5a, 0x25, 0xc6, 0x9e, 0x26, 0x50, 0x03, 0x60, 0x44, + 0x30, 0x46, 0x36, 0x1a, 0x28, 0x1f, 0x94, 0x51, 0x16, 0xc8, 0x7d, 0x40, + 0x50, 0x6a, 0xb5, 0x14, 0x13, 0xa5, 0x74, 0x7b, 0x86, 0x3c, 0x17, 0x54, + 0x15, 0xaf, 0xe0, 0x22, 0x43, 0xa3, 0x91, 0xd3, 0xed, 0x5a, 0xf6, 0xec, + 0x29, 0xdd, 0x6a, 0x6d, 0x86, 0xb5, 0x9e, 0xee, 0x72, 0xcc, 0x4a, 0x27, + 0x46, 0xa0, 0x2d, 0x5a, 0xf4, 0x08, 0x00, 0x59, 0xae, 0x18, 0xa0, 0x94, + 0x58, 0x22, 0x6b, 0x4e, 0x11, 0x10, 0x59, 0xb3, 0x07, 0x2d, 0xf2, 0xdf, + 0x48, 0x21, 0x4c, 0x05, 0xba, 0x5d, 0x61, 0x66, 0xca, 0x1f, 0xbd, 0xf9, + 0xa6, 0xe1, 0xed, 0xe7, 0x9e, 0x1b, 0x1e, 0x12, 0x94, 0xde, 0xaa, 0xc5, + 0x07, 0x87, 0x11, 0x58, 0x6a, 0x59, 0x1a, 0x8d, 0x3c, 0xbb, 0xe1, 0x86, + 0xa5, 0x7b, 0xb6, 0xbf, 0xbc, 0x43, 0xbd, 0x3e, 0x22, 0xcb, 0x21, 0x49, + 0x3d, 0x59, 0xce, 0x66, 0x31, 0x8a, 0x00, 0xd6, 0x16, 0xff, 0x55, 0x94, + 0x3c, 0xf7, 0x45, 0x4e, 0xc8, 0xba, 0x15, 0x8b, 0x90, 0xfb, 0x80, 0x95, + 0xe2, 0xa5, 0x88, 0xc1, 0x8a, 0xa2, 0x0a, 0xed, 0x96, 0xe5, 0x9a, 0xab, + 0x97, 0x7f, 0xf1, 0x86, 0xd7, 0xb7, 0xbf, 0x61, 0x24, 0xf9, 0xc6, 0xfe, + 0x7d, 0x32, 0x1b, 0x5c, 0xb4, 0x3d, 0x76, 0x69, 0xf3, 0xf0, 0xbe, 0x28, + 0xb9, 0xe2, 0x95, 0xbd, 0xa5, 0xa9, 0xa9, 0xfc, 0xa1, 0x1d, 0xaf, 0x5b, + 0x3c, 0x94, 0x7b, 0xa8, 0x94, 0x03, 0xef, 0x7f, 0xcf, 0x71, 0xf6, 0xce, + 0x95, 0x36, 0xfd, 0xf4, 0x7f, 0x27, 0x2e, 0x35, 0x56, 0xc9, 0x03, 0x58, + 0xc0, 0x19, 0x61, 0x98, 0x07, 0xfa, 0xa3, 0x80, 0xb1, 0x1e, 0x65, 0x8d, + 0x40, 0xee, 0x03, 0x68, 0xc1, 0xb2, 0x68, 0x16, 0x20, 0x72, 0xc2, 0x28, + 0x83, 0xb1, 0x9a, 0x67, 0xdb, 0xec, 0xe0, 0xab, 0xdd, 0x9e, 0x25, 0x8a, + 0xa0, 0x5c, 0xe6, 0xd9, 0xad, 0x17, 0x0d, 0x9e, 0xed, 0x75, 0x84, 0xbf, + 0xfc, 0x69, 0x8c, 0x9d, 0x17, 0xf7, 0x89, 0x63, 0xe5, 0xc1, 0xdf, 0xd4, + 0x79, 0xe2, 0x89, 0x31, 0xaa, 0x15, 0xb8, 0xfd, 0xb6, 0x43, 0xdc, 0x7b, + 0x5f, 0xf3, 0x4b, 0xdd, 0xe5, 0x98, 0xe9, 0xe6, 0x10, 0x11, 0x08, 0x41, + 0xc9, 0x15, 0x7c, 0x08, 0x88, 0x80, 0xf7, 0x01, 0x55, 0x3d, 0xa5, 0x01, + 0x67, 0xcd, 0x86, 0x3f, 0xaf, 0x5b, 0xa9, 0xcf, 0x85, 0x4d, 0x9b, 0xf2, + 0x93, 0x79, 0x2e, 0x8f, 0x3f, 0xbb, 0x37, 0x46, 0x88, 0xd9, 0xbc, 0x25, + 0x62, 0x71, 0xbe, 0xc2, 0x64, 0xc3, 0x73, 0xd7, 0x7f, 0xed, 0x63, 0x30, + 0x84, 0xfd, 0x07, 0x23, 0x7e, 0xfb, 0xc8, 0x38, 0x3e, 0x18, 0x32, 0x2f, + 0xdc, 0xf5, 0xb9, 0x0b, 0x3f, 0xf9, 0xc0, 0x03, 0x13, 0xb7, 0x34, 0xa7, + 0x32, 0x8c, 0x14, 0x8e, 0x6a, 0xad, 0xe0, 0x55, 0x19, 0xe4, 0x8a, 0xaa, + 0x6e, 0x38, 0xa5, 0x01, 0xb0, 0xa6, 0xd8, 0xb9, 0xaa, 0x12, 0xb4, 0x70, + 0x2c, 0x45, 0x49, 0x53, 0x65, 0xb5, 0x67, 0xc7, 0x7f, 0x72, 0x5f, 0x63, + 0xd7, 0x1f, 0x1f, 0x2b, 0xdd, 0xd9, 0x6a, 0xbb, 0x57, 0x58, 0x2b, 0xc9, + 0xb0, 0x1f, 0xb1, 0xe5, 0x9c, 0x11, 0xef, 0x7d, 0xcf, 0x71, 0x4a, 0x25, + 0xcf, 0x4a, 0xd7, 0x90, 0xa4, 0xda, 0xe8, 0x76, 0xcd, 0xce, 0x3f, 0x3f, + 0x5e, 0xfe, 0xbf, 0x87, 0x1e, 0x1e, 0xff, 0xb2, 0x18, 0x25, 0x8a, 0xc2, + 0x86, 0xc0, 0xac, 0x11, 0xb2, 0xdc, 0x93, 0xe5, 0xeb, 0x75, 0xa6, 0xc0, + 0x71, 0xa0, 0xc6, 0x87, 0x22, 0xc5, 0x44, 0x8a, 0x42, 0xa1, 0x28, 0x06, + 0x41, 0x0c, 0xa8, 0x12, 0xb5, 0x3a, 0xb2, 0xfd, 0xe8, 0x42, 0x69, 0x7b, + 0x7d, 0x8c, 0xbb, 0x6b, 0x55, 0x16, 0xac, 0x63, 0x6f, 0xf7, 0x17, 0x8d, + 0xa5, 0x5d, 0x4f, 0x54, 0x46, 0x73, 0x07, 0xe3, 0x74, 0xb1, 0x25, 0xcd, + 0x2c, 0x93, 0x97, 0xf4, 0x7a, 0xb6, 0xaa, 0x6a, 0x68, 0x34, 0x32, 0xac, + 0x81, 0x3c, 0x37, 0x20, 0x45, 0xd8, 0x47, 0x79, 0x91, 0xea, 0xd6, 0x40, + 0xd0, 0x22, 0x93, 0x40, 0x8d, 0x53, 0xc5, 0x86, 0xe0, 0x11, 0x64, 0x4d, + 0x80, 0x85, 0xf8, 0x0a, 0x22, 0xc5, 0x91, 0x54, 0x2b, 0x10, 0xc5, 0x9e, + 0xa0, 0x42, 0x67, 0xd9, 0x36, 0x9d, 0x35, 0xcd, 0x13, 0xf3, 0x11, 0x8f, + 0x3d, 0x56, 0xa3, 0x3a, 0x96, 0x11, 0x24, 0x47, 0x51, 0xa2, 0x38, 0x10, + 0x3b, 0x25, 0x2a, 0x7a, 0x73, 0x54, 0x03, 0xc3, 0x2c, 0x30, 0xca, 0x03, + 0x79, 0x08, 0x58, 0x31, 0x38, 0x11, 0xb2, 0x00, 0x21, 0x78, 0x50, 0x35, + 0x26, 0x89, 0xc7, 0x0f, 0x39, 0x37, 0xc6, 0x20, 0x5b, 0x59, 0xbb, 0x92, + 0xad, 0x67, 0x42, 0x01, 0x8e, 0x16, 0x69, 0x64, 0xd6, 0xca, 0xb3, 0x8d, + 0x3c, 0xce, 0x05, 0x6a, 0x35, 0xcf, 0x39, 0x9b, 0x07, 0xd4, 0xc6, 0x3c, + 0xb5, 0xb2, 0xa1, 0x56, 0x32, 0x38, 0x0b, 0x41, 0x8b, 0x14, 0x16, 0x8a, + 0x8d, 0xf8, 0x50, 0x54, 0x35, 0x67, 0x0c, 0x8a, 0x12, 0x10, 0x86, 0xd9, + 0x32, 0x69, 0x52, 0x23, 0x4d, 0x1a, 0x8b, 0x0e, 0xe4, 0x9b, 0x5b, 0x9a, + 0xd7, 0x7d, 0x79, 0xb9, 0xf7, 0x0c, 0x2b, 0xdd, 0x63, 0x24, 0xc9, 0x78, + 0x41, 0xa0, 0xc0, 0x26, 0x0f, 0x81, 0x61, 0x1e, 0x08, 0x41, 0x8b, 0x3c, + 0x5e, 0x2b, 0xa5, 0xaa, 0xeb, 0x25, 0x77, 0x3d, 0x72, 0x82, 0x95, 0x02, + 0xcc, 0x7b, 0x21, 0xcf, 0x43, 0xe1, 0x25, 0xa1, 0x08, 0x7d, 0x50, 0x25, + 0x28, 0x0c, 0xb3, 0x15, 0x04, 0xb8, 0xe0, 0x9c, 0x1b, 0xb1, 0x36, 0xf9, + 0xbe, 0xbc, 0x6d, 0xc7, 0xfd, 0x24, 0xf1, 0xc4, 0x83, 0x9d, 0x95, 0x3d, + 0x37, 0x1e, 0x59, 0xf8, 0x35, 0x59, 0xb6, 0x5c, 0x84, 0x6f, 0xed, 0x2e, + 0x97, 0x07, 0x25, 0xf7, 0x45, 0x7a, 0x18, 0x04, 0x63, 0xc0, 0x18, 0xc1, + 0x89, 0xd9, 0x70, 0x4f, 0x5d, 0x33, 0xb0, 0x75, 0x87, 0x55, 0x40, 0xd6, + 0x00, 0x07, 0x59, 0x20, 0xf3, 0x81, 0xa0, 0x01, 0x08, 0xa4, 0x71, 0x8d, + 0x0b, 0x37, 0xdf, 0x48, 0xb3, 0xf1, 0xd2, 0x3f, 0xf5, 0x87, 0xad, 0x57, + 0x39, 0x80, 0xe1, 0x68, 0xe9, 0x35, 0xb5, 0xea, 0xb6, 0xbb, 0xc6, 0x2a, + 0x5b, 0x6f, 0x1a, 0x8e, 0x5a, 0x17, 0x68, 0xe1, 0x1b, 0xa7, 0x0d, 0x79, + 0xee, 0x5d, 0x77, 0x63, 0x52, 0x88, 0xe9, 0x14, 0xf8, 0x73, 0x3e, 0x2b, + 0x2a, 0xea, 0x46, 0x87, 0xa4, 0xa1, 0x94, 0x34, 0x0e, 0x39, 0x9b, 0xdc, + 0xdf, 0x1f, 0xb6, 0xfe, 0x13, 0x94, 0x7f, 0x00, 0x7d, 0x1d, 0x47, 0x53, + 0x6e, 0xa2, 0x3e, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, + 0xae, 0x42, 0x60, 0x82, +} diff --git a/seanime-2.9.10/internal/icon/iconwin.go b/seanime-2.9.10/internal/icon/iconwin.go new file mode 100644 index 0000000..dd12c89 --- /dev/null +++ b/seanime-2.9.10/internal/icon/iconwin.go @@ -0,0 +1,367 @@ +//go:build windows +// +build windows + +// File generated by 2goarray v0.1.0 (http://github.com/cratonica/2goarray) + +package icon + +var Data []byte = []byte{ + 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x20, 0x20, 0x00, 0x00, 0x01, 0x00, + 0x20, 0x00, 0xa8, 0x10, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00, 0x28, 0x00, + 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x13, 0x0b, + 0x00, 0x00, 0x13, 0x0b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x4b, 0x1d, 0x1e, 0x00, 0x4b, 0x1d, 0x1e, 0x00, 0xb6, 0x46, + 0x52, 0x26, 0xb6, 0x46, 0x52, 0x8f, 0xb6, 0x46, 0x52, 0xc7, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xc7, 0xb6, 0x46, 0x52, 0x8f, 0xb6, 0x46, + 0x52, 0x26, 0x4f, 0x22, 0x25, 0x00, 0x4f, 0x23, 0x27, 0x00, 0x4f, 0x22, + 0x25, 0x00, 0xb6, 0x46, 0x52, 0x79, 0xad, 0x43, 0x4e, 0xff, 0x7d, 0x33, + 0x3b, 0xff, 0x67, 0x2b, 0x32, 0xff, 0x64, 0x2b, 0x31, 0xff, 0x64, 0x2a, + 0x2f, 0xff, 0x64, 0x29, 0x2f, 0xff, 0x63, 0x28, 0x2c, 0xff, 0x62, 0x26, + 0x28, 0xff, 0x61, 0x26, 0x28, 0xff, 0x62, 0x26, 0x2a, 0xff, 0x62, 0x27, + 0x2c, 0xff, 0x63, 0x29, 0x2e, 0xff, 0x64, 0x2b, 0x31, 0xff, 0x65, 0x2c, + 0x33, 0xff, 0x64, 0x2c, 0x31, 0xff, 0x64, 0x2b, 0x30, 0xff, 0x65, 0x2c, + 0x32, 0xff, 0x64, 0x2c, 0x32, 0xff, 0x65, 0x2c, 0x32, 0xff, 0x65, 0x2c, + 0x33, 0xff, 0x65, 0x2c, 0x33, 0xff, 0x64, 0x2b, 0x31, 0xff, 0x62, 0x26, + 0x28, 0xff, 0x60, 0x24, 0x26, 0xff, 0x60, 0x24, 0x25, 0xff, 0x62, 0x24, + 0x27, 0xff, 0x7b, 0x2e, 0x33, 0xff, 0xac, 0x42, 0x4d, 0xff, 0xb6, 0x46, + 0x52, 0x78, 0x4d, 0x1f, 0x21, 0x00, 0xb6, 0x46, 0x52, 0x27, 0xac, 0x42, + 0x4d, 0xff, 0x5b, 0x28, 0x2d, 0xff, 0x50, 0x23, 0x27, 0xff, 0x50, 0x24, + 0x29, 0xff, 0x50, 0x25, 0x2a, 0xff, 0x51, 0x25, 0x2b, 0xff, 0x51, 0x25, + 0x2b, 0xff, 0x4f, 0x23, 0x26, 0xff, 0x4d, 0x1f, 0x20, 0xff, 0x4c, 0x1d, + 0x1e, 0xff, 0x4c, 0x1e, 0x1f, 0xff, 0x4d, 0x20, 0x22, 0xff, 0x4f, 0x22, + 0x26, 0xff, 0x50, 0x24, 0x2a, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x51, 0x26, + 0x2b, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x51, 0x26, 0x2c, 0xff, 0x50, 0x25, + 0x29, 0xff, 0x50, 0x24, 0x29, 0xff, 0x50, 0x23, 0x28, 0xff, 0x4f, 0x23, + 0x27, 0xff, 0x4e, 0x21, 0x25, 0xff, 0x4e, 0x1f, 0x21, 0xff, 0x4c, 0x1d, + 0x1e, 0xff, 0x4b, 0x1c, 0x1c, 0xff, 0x4b, 0x1b, 0x1a, 0xff, 0x4c, 0x1b, + 0x1a, 0xff, 0x58, 0x21, 0x21, 0xff, 0xaf, 0x43, 0x4f, 0xff, 0xb6, 0x46, + 0x52, 0x25, 0xb6, 0x46, 0x52, 0x90, 0x7b, 0x30, 0x36, 0xff, 0x50, 0x23, + 0x27, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x50, 0x26, 0x2b, 0xff, 0x51, 0x27, + 0x2b, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x51, 0x25, 0x2a, 0xff, 0x50, 0x24, + 0x2a, 0xff, 0x4f, 0x23, 0x27, 0xff, 0x4d, 0x1f, 0x21, 0xff, 0x4c, 0x1e, + 0x1f, 0xff, 0x4d, 0x20, 0x23, 0xff, 0x4f, 0x23, 0x28, 0xff, 0x51, 0x25, + 0x2b, 0xff, 0x51, 0x26, 0x2a, 0xff, 0x50, 0x26, 0x2b, 0xff, 0x51, 0x25, + 0x2a, 0xff, 0x50, 0x24, 0x28, 0xff, 0x4e, 0x21, 0x23, 0xff, 0x4e, 0x21, + 0x23, 0xff, 0x50, 0x23, 0x28, 0xff, 0x51, 0x25, 0x2b, 0xff, 0x4f, 0x23, + 0x27, 0xff, 0x4c, 0x1e, 0x1f, 0xff, 0x4c, 0x1d, 0x1c, 0xff, 0x4c, 0x1c, + 0x1c, 0xff, 0x4c, 0x1b, 0x1a, 0xff, 0x4b, 0x1b, 0x1a, 0xff, 0x4c, 0x1b, + 0x1b, 0xff, 0x7b, 0x2f, 0x34, 0xff, 0xb6, 0x46, 0x52, 0x8e, 0xb6, 0x46, + 0x52, 0xc9, 0x65, 0x29, 0x2f, 0xff, 0x51, 0x24, 0x29, 0xff, 0x51, 0x26, + 0x2b, 0xff, 0x50, 0x26, 0x2b, 0xff, 0x50, 0x25, 0x2a, 0xff, 0x51, 0x25, + 0x2a, 0xff, 0x51, 0x26, 0x2a, 0xff, 0x51, 0x25, 0x2a, 0xff, 0x50, 0x23, + 0x27, 0xff, 0x4d, 0x1e, 0x20, 0xff, 0x4d, 0x1f, 0x21, 0xff, 0x48, 0x1f, + 0x23, 0xff, 0x46, 0x1f, 0x24, 0xff, 0x43, 0x1f, 0x24, 0xff, 0x43, 0x1f, + 0x24, 0xff, 0x42, 0x1f, 0x22, 0xff, 0x44, 0x1d, 0x1e, 0xff, 0x46, 0x1c, + 0x1d, 0xff, 0x4d, 0x1f, 0x1f, 0xff, 0x4f, 0x21, 0x24, 0xff, 0x51, 0x25, + 0x2a, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x50, 0x24, 0x29, 0xff, 0x4d, 0x1f, + 0x21, 0xff, 0x4b, 0x1c, 0x1b, 0xff, 0x4b, 0x1a, 0x18, 0xff, 0x4c, 0x1b, + 0x1b, 0xff, 0x4b, 0x1b, 0x1d, 0xff, 0x4b, 0x1b, 0x1c, 0xff, 0x63, 0x24, + 0x27, 0xff, 0xb6, 0x46, 0x52, 0xc8, 0xb6, 0x46, 0x52, 0xd2, 0x62, 0x2a, + 0x30, 0xff, 0x50, 0x24, 0x28, 0xff, 0x51, 0x25, 0x2a, 0xff, 0x50, 0x24, + 0x29, 0xff, 0x50, 0x24, 0x29, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x51, 0x26, + 0x2b, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x47, 0x1d, 0x1f, 0xff, 0x3a, 0x16, + 0x16, 0xff, 0x33, 0x16, 0x18, 0xff, 0x7b, 0x6f, 0x6f, 0xff, 0x96, 0x8d, + 0x8e, 0xff, 0xae, 0xa8, 0xa8, 0xff, 0xae, 0xa8, 0xa8, 0xff, 0xae, 0xa7, + 0xa8, 0xff, 0x95, 0x8c, 0x8c, 0xff, 0x7a, 0x6d, 0x6e, 0xff, 0x35, 0x16, + 0x17, 0xff, 0x41, 0x1c, 0x1d, 0xff, 0x4d, 0x22, 0x26, 0xff, 0x50, 0x23, + 0x28, 0xff, 0x50, 0x24, 0x29, 0xff, 0x50, 0x24, 0x28, 0xff, 0x4f, 0x22, + 0x25, 0xff, 0x4e, 0x21, 0x24, 0xff, 0x4c, 0x1c, 0x1e, 0xff, 0x4b, 0x1a, + 0x19, 0xff, 0x4b, 0x1b, 0x1b, 0xff, 0x5e, 0x23, 0x26, 0xff, 0xb6, 0x46, + 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x63, 0x2b, 0x32, 0xff, 0x50, 0x24, + 0x29, 0xff, 0x50, 0x24, 0x29, 0xff, 0x50, 0x24, 0x29, 0xff, 0x51, 0x25, + 0x2a, 0xff, 0x50, 0x26, 0x2b, 0xff, 0x50, 0x26, 0x2b, 0xff, 0x51, 0x26, + 0x2c, 0xff, 0x7c, 0x6f, 0x70, 0xff, 0xce, 0xca, 0xca, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf6, 0xf5, 0xf5, 0xff, 0xae, 0xa7, + 0xa8, 0xff, 0x4d, 0x35, 0x37, 0xff, 0x4e, 0x23, 0x29, 0xff, 0x51, 0x25, + 0x2a, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x51, 0x25, 0x2b, 0xff, 0x51, 0x27, + 0x2c, 0xff, 0x4c, 0x1e, 0x1e, 0xff, 0x4b, 0x18, 0x16, 0xff, 0x4b, 0x1c, + 0x1c, 0xff, 0x5e, 0x24, 0x26, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, + 0x52, 0xd2, 0x61, 0x29, 0x2f, 0xff, 0x50, 0x24, 0x29, 0xff, 0x50, 0x25, + 0x2a, 0xff, 0x50, 0x26, 0x2b, 0xff, 0x50, 0x25, 0x2a, 0xff, 0x50, 0x25, + 0x2b, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x51, 0x26, 0x2a, 0xff, 0xed, 0xeb, + 0xeb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe2, 0xe0, + 0xe1, 0xff, 0x53, 0x39, 0x3d, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x50, 0x25, + 0x2a, 0xff, 0x50, 0x26, 0x2b, 0xff, 0x51, 0x26, 0x2c, 0xff, 0x4f, 0x22, + 0x25, 0xff, 0x4b, 0x1b, 0x1a, 0xff, 0x4a, 0x1b, 0x1b, 0xff, 0x5e, 0x23, + 0x26, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x60, 0x28, + 0x2c, 0xff, 0x4f, 0x22, 0x26, 0xff, 0x51, 0x25, 0x2b, 0xff, 0x51, 0x26, + 0x2b, 0xff, 0x50, 0x26, 0x2b, 0xff, 0x50, 0x26, 0x2b, 0xff, 0x50, 0x25, + 0x29, 0xff, 0x50, 0x24, 0x29, 0xff, 0xbe, 0xb5, 0xb6, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xb9, 0xb3, + 0xb4, 0xff, 0x50, 0x25, 0x2a, 0xff, 0x50, 0x24, 0x28, 0xff, 0x50, 0x25, + 0x29, 0xff, 0x50, 0x24, 0x28, 0xff, 0x51, 0x24, 0x29, 0xff, 0x4d, 0x1f, + 0x21, 0xff, 0x4b, 0x1b, 0x1a, 0xff, 0x5e, 0x23, 0x24, 0xff, 0xb6, 0x46, + 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x62, 0x2b, 0x31, 0xff, 0x4f, 0x22, + 0x26, 0xff, 0x50, 0x23, 0x28, 0xff, 0x51, 0x26, 0x2a, 0xff, 0x50, 0x26, + 0x2a, 0xff, 0x50, 0x23, 0x26, 0xff, 0x4f, 0x23, 0x27, 0xff, 0x50, 0x24, + 0x29, 0xff, 0x6f, 0x52, 0x55, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xed, 0xeb, 0xeb, 0xff, 0xd2, 0xcb, 0xca, 0xff, 0xb5, 0xaa, + 0xaa, 0xff, 0xb6, 0xaa, 0xac, 0xff, 0xb5, 0xaa, 0xac, 0xff, 0xe5, 0xe1, + 0xe2, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x48, 0x1f, + 0x22, 0xff, 0x4f, 0x23, 0x26, 0xff, 0x50, 0x23, 0x27, 0xff, 0x51, 0x25, + 0x2a, 0xff, 0x51, 0x27, 0x2c, 0xff, 0x4f, 0x22, 0x24, 0xff, 0x4b, 0x1a, + 0x1a, 0xff, 0x5f, 0x24, 0x27, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, + 0x52, 0xd2, 0x62, 0x2a, 0x30, 0xff, 0x50, 0x25, 0x2a, 0xff, 0x50, 0x25, + 0x2a, 0xff, 0x51, 0x27, 0x2c, 0xff, 0x50, 0x24, 0x29, 0xff, 0x4e, 0x21, + 0x23, 0xff, 0x50, 0x24, 0x28, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x51, 0x27, + 0x2c, 0xff, 0xb6, 0xab, 0xac, 0xff, 0x77, 0x5c, 0x5c, 0xff, 0x46, 0x0f, + 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x4b, 0x19, 0x16, 0xff, 0x50, 0x25, + 0x28, 0xff, 0x50, 0x23, 0x26, 0xff, 0x50, 0x25, 0x2a, 0xff, 0xb3, 0xaa, + 0xab, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x84, 0x71, 0x72, 0xff, 0x4f, 0x23, + 0x27, 0xff, 0x50, 0x25, 0x2a, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x51, 0x26, + 0x2b, 0xff, 0x4f, 0x25, 0x29, 0xff, 0x4d, 0x20, 0x22, 0xff, 0x62, 0x2a, + 0x2f, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x62, 0x2a, + 0x2f, 0xff, 0x50, 0x25, 0x2a, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x50, 0x26, + 0x2b, 0xff, 0x4f, 0x23, 0x28, 0xff, 0x4f, 0x22, 0x24, 0xff, 0x50, 0x25, + 0x2a, 0xff, 0x51, 0x27, 0x2c, 0xff, 0x50, 0x26, 0x2a, 0xff, 0x4b, 0x1b, + 0x1c, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x47, 0x0f, 0x14, 0xff, 0x46, 0x0f, + 0x14, 0xff, 0x49, 0x13, 0x14, 0xff, 0x50, 0x23, 0x27, 0xff, 0x50, 0x23, + 0x28, 0xff, 0x51, 0x26, 0x2b, 0xff, 0x80, 0x71, 0x73, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x85, 0x72, 0x73, 0xff, 0x50, 0x23, 0x27, 0xff, 0x50, 0x25, + 0x29, 0xff, 0x50, 0x24, 0x28, 0xff, 0x51, 0x25, 0x2a, 0xff, 0x51, 0x25, + 0x2a, 0xff, 0x4f, 0x23, 0x26, 0xff, 0x62, 0x2a, 0x2f, 0xff, 0xb6, 0x46, + 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x61, 0x29, 0x2f, 0xff, 0x4f, 0x23, + 0x27, 0xff, 0x51, 0x26, 0x2a, 0xff, 0x50, 0x26, 0x2a, 0xff, 0x50, 0x25, + 0x2b, 0xff, 0x51, 0x27, 0x2c, 0xff, 0x51, 0x27, 0x2c, 0xff, 0x51, 0x27, + 0x2c, 0xff, 0x49, 0x16, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x46, 0x0f, + 0x14, 0xff, 0x47, 0x0f, 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x49, 0x13, + 0x14, 0xff, 0x4e, 0x21, 0x25, 0xff, 0x49, 0x24, 0x28, 0xff, 0x3c, 0x1d, + 0x21, 0xff, 0xc3, 0xbf, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7c, 0x64, + 0x67, 0xff, 0x50, 0x24, 0x28, 0xff, 0x50, 0x23, 0x26, 0xff, 0x50, 0x24, + 0x29, 0xff, 0x51, 0x27, 0x2c, 0xff, 0x50, 0x24, 0x27, 0xff, 0x4d, 0x1d, + 0x1d, 0xff, 0x60, 0x27, 0x2a, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, + 0x52, 0xd2, 0x62, 0x2b, 0x30, 0xff, 0x50, 0x26, 0x2a, 0xff, 0x51, 0x26, + 0x2b, 0xff, 0x51, 0x27, 0x2c, 0xff, 0x4d, 0x1e, 0x20, 0xff, 0x49, 0x15, + 0x14, 0xff, 0x4f, 0x23, 0x26, 0xff, 0x4c, 0x1e, 0x20, 0xff, 0x46, 0x0f, + 0x14, 0xff, 0x47, 0x0f, 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x46, 0x0f, + 0x14, 0xff, 0x44, 0x0e, 0x13, 0xff, 0x3c, 0x0d, 0x11, 0xff, 0x32, 0x0b, + 0x0e, 0xff, 0x78, 0x6b, 0x6b, 0xff, 0xd8, 0xd5, 0xd5, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xf6, 0xf5, 0xf5, 0xff, 0x4b, 0x18, 0x16, 0xff, 0x4e, 0x1e, + 0x20, 0xff, 0x51, 0x26, 0x2c, 0xff, 0x51, 0x27, 0x2c, 0xff, 0x4f, 0x24, + 0x27, 0xff, 0x4b, 0x19, 0x16, 0xff, 0x4d, 0x1d, 0x1d, 0xff, 0x60, 0x26, + 0x29, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x63, 0x2c, + 0x33, 0xff, 0x50, 0x25, 0x28, 0xff, 0x50, 0x24, 0x29, 0xff, 0x51, 0x26, + 0x2c, 0xff, 0x50, 0x26, 0x2b, 0xff, 0x47, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x47, 0x0f, 0x14, 0xff, 0x47, 0x0f, + 0x14, 0xff, 0x43, 0x0e, 0x13, 0xff, 0x37, 0x0c, 0x10, 0xff, 0x57, 0x45, + 0x46, 0xff, 0xa0, 0x98, 0x98, 0xff, 0xec, 0xea, 0xeb, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xb2, 0xa6, + 0xa7, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x47, 0x0f, 0x14, 0xff, 0x49, 0x14, + 0x14, 0xff, 0x49, 0x13, 0x14, 0xff, 0x49, 0x12, 0x14, 0xff, 0x4e, 0x1b, + 0x19, 0xff, 0x4f, 0x21, 0x21, 0xff, 0x60, 0x26, 0x29, 0xff, 0xb6, 0x46, + 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x61, 0x28, 0x2d, 0xff, 0x4e, 0x21, + 0x24, 0xff, 0x50, 0x23, 0x27, 0xff, 0x51, 0x26, 0x2c, 0xff, 0x50, 0x27, + 0x2c, 0xff, 0x48, 0x13, 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x47, 0x0f, + 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x42, 0x0e, 0x13, 0xff, 0x58, 0x45, + 0x46, 0xff, 0xc2, 0xbe, 0xbe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xc7, 0xbe, 0xbf, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x46, 0x0f, + 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x49, 0x10, 0x14, 0xff, 0x4c, 0x19, 0x17, 0xff, 0x4d, 0x1c, + 0x1b, 0xff, 0x61, 0x28, 0x2c, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, + 0x52, 0xd2, 0x61, 0x28, 0x2e, 0xff, 0x50, 0x24, 0x29, 0xff, 0x51, 0x25, + 0x2b, 0xff, 0x4e, 0x21, 0x25, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x46, 0x0f, + 0x14, 0xff, 0x47, 0x0f, 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x69, 0x59, 0x5a, 0xff, 0xf6, 0xf5, 0xf5, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xf6, 0xf5, 0xf5, 0xff, 0xa7, 0x99, 0x9a, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x46, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x4a, 0x14, 0x14, 0xff, 0x60, 0x26, + 0x2a, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x62, 0x2b, + 0x31, 0xff, 0x4e, 0x20, 0x23, 0xff, 0x51, 0x27, 0x2c, 0xff, 0x4c, 0x20, + 0x23, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x47, 0x0f, 0x14, 0xff, 0x46, 0x0f, + 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x4f, 0x2e, 0x30, 0xff, 0xf6, 0xf5, + 0xf5, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xe4, 0xe0, 0xe0, 0xff, 0x9b, 0x8b, 0x8c, 0xff, 0x57, 0x2f, + 0x32, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x5e, 0x20, 0x21, 0xff, 0xb6, 0x46, + 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x5b, 0x1a, 0x1f, 0xff, 0x46, 0x0f, + 0x14, 0xff, 0x4a, 0x16, 0x14, 0xff, 0x47, 0x11, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0xa2, 0x98, 0x99, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf6, 0xf5, + 0xf5, 0xff, 0xbc, 0xb2, 0xb3, 0xff, 0x67, 0x47, 0x49, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x5b, 0x1a, 0x1f, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, + 0x52, 0xd2, 0x59, 0x19, 0x1f, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x46, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0xd8, 0xd5, + 0xd5, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xf6, 0xf5, 0xf5, 0xff, 0x74, 0x5a, 0x5c, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x59, 0x19, + 0x1f, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x59, 0x19, + 0x1f, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0xd9, 0xd5, 0xd5, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xd8, 0xd5, + 0xd5, 0xff, 0x40, 0x0e, 0x13, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x40, 0x0e, + 0x13, 0xff, 0x36, 0x0c, 0x10, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x59, 0x19, 0x1f, 0xff, 0xb6, 0x46, + 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x58, 0x19, 0x1f, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0xc5, 0xbe, 0xbe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf6, 0xf5, 0xf5, 0xff, 0x68, 0x59, + 0x5a, 0xff, 0x31, 0x0b, 0x0e, 0xff, 0x33, 0x0b, 0x0f, 0xff, 0x33, 0x0b, + 0x0f, 0xff, 0x2e, 0x0a, 0x0e, 0xff, 0x68, 0x59, 0x5a, 0xff, 0xc2, 0xbe, + 0xbe, 0xff, 0x4e, 0x2e, 0x30, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x45, 0x0f, 0x14, 0xff, 0x45, 0x0f, + 0x14, 0xff, 0x59, 0x19, 0x1f, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, + 0x52, 0xd2, 0x58, 0x19, 0x1f, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x9b, 0x8b, + 0x8c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe2, 0xe0, + 0xe0, 0xff, 0xd7, 0xd5, 0xd5, 0xff, 0xd8, 0xd5, 0xd5, 0xff, 0xf6, 0xf5, + 0xf5, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xa1, 0x98, + 0x99, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x59, 0x19, + 0x1f, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x58, 0x19, + 0x1f, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0xe4, 0xe0, + 0xe0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe2, 0xe0, 0xe0, 0xff, 0x40, 0x0e, + 0x13, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x59, 0x19, 0x1f, 0xff, 0xb6, 0x46, + 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x58, 0x19, 0x1f, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x56, 0x2f, 0x32, 0xff, 0xe4, 0xe0, + 0xe0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x66, 0x47, 0x49, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x59, 0x19, 0x1f, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, + 0x52, 0xd2, 0x57, 0x19, 0x1f, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x55, 0x2f, 0x32, 0xff, 0xb2, 0xa6, + 0xa7, 0xff, 0xed, 0xeb, 0xeb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xf6, 0xf5, 0xf5, 0xff, 0xc6, 0xbe, 0xbf, 0xff, 0x82, 0x6c, + 0x6d, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x58, 0x19, + 0x1f, 0xff, 0xb6, 0x46, 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xd2, 0x57, 0x19, + 0x1f, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x55, 0x2f, 0x32, 0xff, 0x82, 0x6c, 0x6d, 0xff, 0x82, 0x6c, + 0x6d, 0xff, 0x82, 0x6c, 0x6d, 0xff, 0x65, 0x47, 0x49, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x57, 0x19, 0x1f, 0xff, 0xb6, 0x46, + 0x52, 0xd2, 0xb6, 0x46, 0x52, 0xc9, 0x5b, 0x1b, 0x21, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x5c, 0x1b, 0x21, 0xff, 0xb6, 0x46, 0x52, 0xc8, 0xb6, 0x46, + 0x52, 0x90, 0x75, 0x27, 0x2f, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x76, 0x27, + 0x2f, 0xff, 0xb6, 0x46, 0x52, 0x8e, 0xb6, 0x46, 0x52, 0x27, 0xab, 0x41, + 0x4c, 0xff, 0x50, 0x15, 0x1b, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x43, 0x0f, 0x14, 0xff, 0x44, 0x0f, + 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x44, 0x0f, 0x14, 0xff, 0x43, 0x0f, + 0x14, 0xff, 0x51, 0x15, 0x1b, 0xff, 0xae, 0x42, 0x4e, 0xff, 0xb6, 0x46, + 0x52, 0x25, 0x43, 0x0f, 0x14, 0x00, 0xb6, 0x46, 0x52, 0x79, 0xac, 0x41, + 0x4c, 0xff, 0x76, 0x27, 0x2f, 0xff, 0x5d, 0x1b, 0x22, 0xff, 0x5a, 0x1a, + 0x20, 0xff, 0x5a, 0x1a, 0x20, 0xff, 0x5a, 0x1a, 0x20, 0xff, 0x5a, 0x1a, + 0x20, 0xff, 0x5a, 0x1a, 0x20, 0xff, 0x5a, 0x1a, 0x20, 0xff, 0x5a, 0x1a, + 0x20, 0xff, 0x5a, 0x1a, 0x20, 0xff, 0x5a, 0x1a, 0x20, 0xff, 0x5a, 0x1a, + 0x20, 0xff, 0x5a, 0x1a, 0x20, 0xff, 0x5a, 0x1a, 0x20, 0xff, 0x5a, 0x1a, + 0x20, 0xff, 0x5a, 0x1a, 0x20, 0xff, 0x5a, 0x1a, 0x20, 0xff, 0x5a, 0x1a, + 0x20, 0xff, 0x5b, 0x1a, 0x20, 0xff, 0x5b, 0x1a, 0x20, 0xff, 0x5a, 0x1a, + 0x20, 0xff, 0x5a, 0x1a, 0x20, 0xff, 0x5b, 0x1a, 0x20, 0xff, 0x5b, 0x1a, + 0x20, 0xff, 0x5d, 0x1b, 0x22, 0xff, 0x76, 0x27, 0x2f, 0xff, 0xac, 0x41, + 0x4c, 0xff, 0xb6, 0x46, 0x52, 0x78, 0x44, 0x0f, 0x14, 0x00, 0x42, 0x0f, + 0x14, 0x00, 0x42, 0x0f, 0x14, 0x00, 0xb6, 0x46, 0x52, 0x26, 0xb6, 0x46, + 0x52, 0x8f, 0xb6, 0x46, 0x52, 0xc6, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, 0x52, 0xcc, 0xb6, 0x46, + 0x52, 0xc6, 0xb6, 0x46, 0x52, 0x8f, 0xb6, 0x46, 0x52, 0x26, 0x42, 0x0f, + 0x14, 0x00, 0x43, 0x0f, 0x14, 0x00, 0xc0, 0x00, 0x00, 0x03, 0x80, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x01, 0xc0, 0x00, + 0x00, 0x03, +} diff --git a/seanime-2.9.10/internal/icon/iconwin.ico b/seanime-2.9.10/internal/icon/iconwin.ico new file mode 100644 index 0000000..2026c9b Binary files /dev/null and b/seanime-2.9.10/internal/icon/iconwin.ico differ diff --git a/seanime-2.9.10/internal/icon/logo.png b/seanime-2.9.10/internal/icon/logo.png new file mode 100644 index 0000000..ef5321b Binary files /dev/null and b/seanime-2.9.10/internal/icon/logo.png differ diff --git a/seanime-2.9.10/internal/icon/make_icon.bat b/seanime-2.9.10/internal/icon/make_icon.bat new file mode 100644 index 0000000..fa28113 --- /dev/null +++ b/seanime-2.9.10/internal/icon/make_icon.bat @@ -0,0 +1,41 @@ +@ECHO OFF + +IF "%GOPATH%"=="" GOTO NOGO +IF NOT EXIST %GOPATH%\bin\2goarray.exe GOTO INSTALL +:POSTINSTALL +IF "%1"=="" GOTO NOICO +IF NOT EXIST %1 GOTO BADFILE +ECHO Creating iconwin.go +ECHO //+build windows > iconwin.go +ECHO. >> iconwin.go +TYPE %1 | %GOPATH%\bin\2goarray Data icon >> iconwin.go +GOTO DONE + +:CREATEFAIL +ECHO Unable to create output file +GOTO DONE + +:INSTALL +ECHO Installing 2goarray... +go get github.com/cratonica/2goarray +IF ERRORLEVEL 1 GOTO GETFAIL +GOTO POSTINSTALL + +:GETFAIL +ECHO Failure running go get github.com/cratonica/2goarray. Ensure that go and git are in PATH +GOTO DONE + +:NOGO +ECHO GOPATH environment variable not set +GOTO DONE + +:NOICO +ECHO Please specify a .ico file +GOTO DONE + +:BADFILE +ECHO %1 is not a valid file +GOTO DONE + +:DONE + diff --git a/seanime-2.9.10/internal/library/anime/README.md b/seanime-2.9.10/internal/library/anime/README.md new file mode 100644 index 0000000..8711b5b --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/README.md @@ -0,0 +1,8 @@ +# anime + +This package contains structs that represent the main data structures of the local anime library. +Such as `LocalFile` and `LibraryEntry`. + +### 🚫 Do not + +- Do not import **database**. diff --git a/seanime-2.9.10/internal/library/anime/autodownloader_rule.go b/seanime-2.9.10/internal/library/anime/autodownloader_rule.go new file mode 100644 index 0000000..2b5c04e --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/autodownloader_rule.go @@ -0,0 +1,35 @@ +package anime + +// DEVNOTE: The structs are defined in this file because they are imported by both the autodownloader package and the db package. +// Defining them in the autodownloader package would create a circular dependency because the db package imports these structs. + +const ( + AutoDownloaderRuleTitleComparisonContains AutoDownloaderRuleTitleComparisonType = "contains" + AutoDownloaderRuleTitleComparisonLikely AutoDownloaderRuleTitleComparisonType = "likely" +) + +const ( + AutoDownloaderRuleEpisodeRecent AutoDownloaderRuleEpisodeType = "recent" + AutoDownloaderRuleEpisodeSelected AutoDownloaderRuleEpisodeType = "selected" +) + +type ( + AutoDownloaderRuleTitleComparisonType string + AutoDownloaderRuleEpisodeType string + + // AutoDownloaderRule is a rule that is used to automatically download media. + // The structs are sent to the client, thus adding `dbId` to facilitate mutations. + AutoDownloaderRule struct { + DbID uint `json:"dbId"` // Will be set when fetched from the database + Enabled bool `json:"enabled"` + MediaId int `json:"mediaId"` + ReleaseGroups []string `json:"releaseGroups"` + Resolutions []string `json:"resolutions"` + ComparisonTitle string `json:"comparisonTitle"` + TitleComparisonType AutoDownloaderRuleTitleComparisonType `json:"titleComparisonType"` + EpisodeType AutoDownloaderRuleEpisodeType `json:"episodeType"` + EpisodeNumbers []int `json:"episodeNumbers,omitempty"` + Destination string `json:"destination"` + AdditionalTerms []string `json:"additionalTerms"` + } +) diff --git a/seanime-2.9.10/internal/library/anime/collection.go b/seanime-2.9.10/internal/library/anime/collection.go new file mode 100644 index 0000000..ed5d42b --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/collection.go @@ -0,0 +1,467 @@ +package anime + +import ( + "cmp" + "context" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/hook" + "seanime/internal/platforms/platform" + "seanime/internal/util" + "slices" + "sort" + + "github.com/samber/lo" + lop "github.com/samber/lo/parallel" + "github.com/sourcegraph/conc/pool" +) + +type ( + // LibraryCollection holds the main data for the library collection. + // It consists of: + // - ContinueWatchingList: a list of Episode for the "continue watching" feature. + // - Lists: a list of LibraryCollectionList (one for each status). + // - UnmatchedLocalFiles: a list of unmatched local files (Media id == 0). "Resolve unmatched" feature. + // - UnmatchedGroups: a list of UnmatchedGroup instances. Like UnmatchedLocalFiles, but grouped by directory. "Resolve unmatched" feature. + // - IgnoredLocalFiles: a list of ignored local files. (DEVNOTE: Unused for now) + // - UnknownGroups: a list of UnknownGroup instances. Group of files whose media is not in the user's AniList "Resolve unknown media" feature. + LibraryCollection struct { + ContinueWatchingList []*Episode `json:"continueWatchingList"` + Lists []*LibraryCollectionList `json:"lists"` + UnmatchedLocalFiles []*LocalFile `json:"unmatchedLocalFiles"` + UnmatchedGroups []*UnmatchedGroup `json:"unmatchedGroups"` + IgnoredLocalFiles []*LocalFile `json:"ignoredLocalFiles"` + UnknownGroups []*UnknownGroup `json:"unknownGroups"` + Stats *LibraryCollectionStats `json:"stats"` + Stream *StreamCollection `json:"stream,omitempty"` // Hydrated by the route handler + } + + StreamCollection struct { + ContinueWatchingList []*Episode `json:"continueWatchingList"` + Anime []*anilist.BaseAnime `json:"anime"` + ListData map[int]*EntryListData `json:"listData"` + } + + LibraryCollectionListType string + + LibraryCollectionStats struct { + TotalEntries int `json:"totalEntries"` + TotalFiles int `json:"totalFiles"` + TotalShows int `json:"totalShows"` + TotalMovies int `json:"totalMovies"` + TotalSpecials int `json:"totalSpecials"` + TotalSize string `json:"totalSize"` + } + + LibraryCollectionList struct { + Type anilist.MediaListStatus `json:"type"` + Status anilist.MediaListStatus `json:"status"` + Entries []*LibraryCollectionEntry `json:"entries"` + } + + // LibraryCollectionEntry holds the data for a single entry in a LibraryCollectionList. + // It is a slimmed down version of Entry. It holds the media, media id, library data, and list data. + LibraryCollectionEntry struct { + Media *anilist.BaseAnime `json:"media"` + MediaId int `json:"mediaId"` + EntryLibraryData *EntryLibraryData `json:"libraryData"` // Library data + NakamaEntryLibraryData *NakamaEntryLibraryData `json:"nakamaLibraryData,omitempty"` // Library data from Nakama + EntryListData *EntryListData `json:"listData"` // AniList list data + } + + // UnmatchedGroup holds the data for a group of unmatched local files. + UnmatchedGroup struct { + Dir string `json:"dir"` + LocalFiles []*LocalFile `json:"localFiles"` + Suggestions []*anilist.BaseAnime `json:"suggestions"` + } + // UnknownGroup holds the data for a group of local files whose media is not in the user's AniList. + // The client will use this data to suggest media to the user, so they can add it to their AniList. + UnknownGroup struct { + MediaId int `json:"mediaId"` + LocalFiles []*LocalFile `json:"localFiles"` + } +) + +type ( + // NewLibraryCollectionOptions is a struct that holds the data needed for creating a new LibraryCollection. + NewLibraryCollectionOptions struct { + AnimeCollection *anilist.AnimeCollection + LocalFiles []*LocalFile + Platform platform.Platform + MetadataProvider metadata.Provider + } +) + +// NewLibraryCollection creates a new LibraryCollection. +func NewLibraryCollection(ctx context.Context, opts *NewLibraryCollectionOptions) (lc *LibraryCollection, err error) { + defer util.HandlePanicInModuleWithError("entities/collection/NewLibraryCollection", &err) + lc = new(LibraryCollection) + + reqEvent := &AnimeLibraryCollectionRequestedEvent{ + AnimeCollection: opts.AnimeCollection, + LocalFiles: opts.LocalFiles, + LibraryCollection: lc, + } + err = hook.GlobalHookManager.OnAnimeLibraryCollectionRequested().Trigger(reqEvent) + if err != nil { + return nil, err + } + opts.AnimeCollection = reqEvent.AnimeCollection // Override the anime collection + opts.LocalFiles = reqEvent.LocalFiles // Override the local files + lc = reqEvent.LibraryCollection // Override the library collection + + if reqEvent.DefaultPrevented { + event := &AnimeLibraryCollectionEvent{ + LibraryCollection: lc, + } + err = hook.GlobalHookManager.OnAnimeLibraryCollection().Trigger(event) + if err != nil { + return nil, err + } + + return event.LibraryCollection, nil + } + + // Get lists from collection + aniLists := opts.AnimeCollection.GetMediaListCollection().GetLists() + + // Create lists + lc.hydrateCollectionLists( + opts.LocalFiles, + aniLists, + ) + + lc.hydrateStats(opts.LocalFiles) + + // Add Continue Watching list + lc.hydrateContinueWatchingList( + ctx, + opts.LocalFiles, + opts.AnimeCollection, + opts.Platform, + opts.MetadataProvider, + ) + + lc.UnmatchedLocalFiles = lo.Filter(opts.LocalFiles, func(lf *LocalFile, index int) bool { + return lf.MediaId == 0 && !lf.Ignored + }) + + lc.IgnoredLocalFiles = lo.Filter(opts.LocalFiles, func(lf *LocalFile, index int) bool { + return lf.Ignored == true + }) + + slices.SortStableFunc(lc.IgnoredLocalFiles, func(i, j *LocalFile) int { + return cmp.Compare(i.GetPath(), j.GetPath()) + }) + + lc.hydrateUnmatchedGroups() + + // Event + event := &AnimeLibraryCollectionEvent{ + LibraryCollection: lc, + } + hook.GlobalHookManager.OnAnimeLibraryCollection().Trigger(event) + lc = event.LibraryCollection + + return +} + +//---------------------------------------------------------------------------------------------------------------------- + +func (lc *LibraryCollection) hydrateCollectionLists( + localFiles []*LocalFile, + aniLists []*anilist.AnimeCollection_MediaListCollection_Lists, +) { + + // Group local files by media id + groupedLfs := GroupLocalFilesByMediaID(localFiles) + // Get slice of media ids from local files + mIds := GetMediaIdsFromLocalFiles(localFiles) + foundIds := make([]int, 0) + + for _, list := range aniLists { + entries := list.GetEntries() + for _, entry := range entries { + foundIds = append(foundIds, entry.Media.ID) + } + } + + // Create a new LibraryCollectionList for each list + // This is done in parallel + p := pool.NewWithResults[*LibraryCollectionList]() + for _, list := range aniLists { + p.Go(func() *LibraryCollectionList { + // If the list has no status, return nil + // This occurs when there are custom lists (DEVNOTE: This shouldn't occur because we remove custom lists when the collection is fetched) + if list.Status == nil { + return nil + } + + // For each list, get the entries + entries := list.GetEntries() + + // For each entry, check if the media id is in the local files + // If it is, create a new LibraryCollectionEntry with the associated local files + p2 := pool.NewWithResults[*LibraryCollectionEntry]() + for _, entry := range entries { + p2.Go(func() *LibraryCollectionEntry { + if slices.Contains(mIds, entry.Media.ID) { + + entryLfs, _ := groupedLfs[entry.Media.ID] + libraryData, _ := NewEntryLibraryData(&NewEntryLibraryDataOptions{ + EntryLocalFiles: entryLfs, + MediaId: entry.Media.ID, + CurrentProgress: entry.GetProgressSafe(), + }) + + return &LibraryCollectionEntry{ + MediaId: entry.Media.ID, + Media: entry.Media, + EntryLibraryData: libraryData, + EntryListData: &EntryListData{ + Progress: entry.GetProgressSafe(), + Score: entry.GetScoreSafe(), + Status: entry.Status, + Repeat: entry.GetRepeatSafe(), + StartedAt: anilist.ToEntryStartDate(entry.StartedAt), + CompletedAt: anilist.ToEntryCompletionDate(entry.CompletedAt), + }, + } + } else { + return nil + } + }) + } + + r := p2.Wait() + // Filter out nil entries + r = lo.Filter(r, func(item *LibraryCollectionEntry, index int) bool { + return item != nil + }) + // Sort by title + sort.Slice(r, func(i, j int) bool { + return r[i].Media.GetTitleSafe() < r[j].Media.GetTitleSafe() + }) + + // Return a new LibraryEntries struct + return &LibraryCollectionList{ + Type: getLibraryCollectionEntryFromListStatus(*list.Status), + Status: *list.Status, + Entries: r, + } + + }) + } + + // Get the lists from the pool + lists := p.Wait() + // Filter out nil entries + lists = lo.Filter(lists, func(item *LibraryCollectionList, index int) bool { + return item != nil + }) + + // Merge repeating to current (no need to show repeating as a separate list) + repeatingList, ok := lo.Find(lists, func(item *LibraryCollectionList) bool { + return item.Status == anilist.MediaListStatusRepeating + }) + if ok { + currentList, ok := lo.Find(lists, func(item *LibraryCollectionList) bool { + return item.Status == anilist.MediaListStatusCurrent + }) + if len(repeatingList.Entries) > 0 && ok { + currentList.Entries = append(currentList.Entries, repeatingList.Entries...) + } else if len(repeatingList.Entries) > 0 { + newCurrentList := repeatingList + newCurrentList.Type = anilist.MediaListStatusCurrent + lists = append(lists, newCurrentList) + } + // Remove repeating from lists + lists = lo.Filter(lists, func(item *LibraryCollectionList, index int) bool { + return item.Status != anilist.MediaListStatusRepeating + }) + } + + // Lists + lc.Lists = lists + + if lc.Lists == nil { + lc.Lists = make([]*LibraryCollectionList, 0) + } + + // +---------------------+ + // | Unknown media ids | + // +---------------------+ + + unknownIds := make([]int, 0) + for _, id := range mIds { + if id != 0 && !slices.Contains(foundIds, id) { + unknownIds = append(unknownIds, id) + } + } + + lc.UnknownGroups = make([]*UnknownGroup, 0) + for _, id := range unknownIds { + lc.UnknownGroups = append(lc.UnknownGroups, &UnknownGroup{ + MediaId: id, + LocalFiles: groupedLfs[id], + }) + } + + return +} + +//---------------------------------------------------------------------------------------------------------------------- + +func (lc *LibraryCollection) hydrateStats(lfs []*LocalFile) { + stats := &LibraryCollectionStats{ + TotalFiles: len(lfs), + TotalEntries: 0, + TotalShows: 0, + TotalMovies: 0, + TotalSpecials: 0, + TotalSize: "", // Will be set by the route handler + } + + for _, list := range lc.Lists { + for _, entry := range list.Entries { + stats.TotalEntries++ + if entry.Media.Format != nil { + if *entry.Media.Format == anilist.MediaFormatMovie { + stats.TotalMovies++ + } else if *entry.Media.Format == anilist.MediaFormatSpecial || *entry.Media.Format == anilist.MediaFormatOva { + stats.TotalSpecials++ + } else { + stats.TotalShows++ + } + } + } + } + + lc.Stats = stats +} + +//---------------------------------------------------------------------------------------------------------------------- + +// hydrateContinueWatchingList creates a list of Episode for the "continue watching" feature. +// This should be called after the LibraryCollectionList's have been created. +func (lc *LibraryCollection) hydrateContinueWatchingList( + ctx context.Context, + localFiles []*LocalFile, + animeCollection *anilist.AnimeCollection, + platform platform.Platform, + metadataProvider metadata.Provider, +) { + + // Get currently watching list + current, found := lo.Find(lc.Lists, func(item *LibraryCollectionList) bool { + return item.Status == anilist.MediaListStatusCurrent + }) + + // If no currently watching list is found, return an empty slice + if !found { + lc.ContinueWatchingList = make([]*Episode, 0) // Set empty slice + return + } + // Get media ids from current list + mIds := make([]int, len(current.Entries)) + for i, entry := range current.Entries { + mIds[i] = entry.MediaId + } + + // Create a new Entry for each media id + mEntryPool := pool.NewWithResults[*Entry]() + for _, mId := range mIds { + mEntryPool.Go(func() *Entry { + me, _ := NewEntry(ctx, &NewEntryOptions{ + MediaId: mId, + LocalFiles: localFiles, + AnimeCollection: animeCollection, + Platform: platform, + MetadataProvider: metadataProvider, + }) + return me + }) + } + mEntries := mEntryPool.Wait() + mEntries = lo.Filter(mEntries, func(item *Entry, index int) bool { + return item != nil + }) // Filter out nil entries + + // If there are no entries, return an empty slice + if len(mEntries) == 0 { + lc.ContinueWatchingList = make([]*Episode, 0) // Return empty slice + return + } + + // Sort by progress + sort.Slice(mEntries, func(i, j int) bool { + return mEntries[i].EntryListData.Progress > mEntries[j].EntryListData.Progress + }) + + // Remove entries the user has watched all episodes of + mEntries = lop.Map(mEntries, func(mEntry *Entry, index int) *Entry { + if !mEntry.HasWatchedAll() { + return mEntry + } + return nil + }) + mEntries = lo.Filter(mEntries, func(item *Entry, index int) bool { + return item != nil + }) + + // Get the next episode for each media entry + mEpisodes := lop.Map(mEntries, func(mEntry *Entry, index int) *Episode { + ep, ok := mEntry.FindNextEpisode() + if ok { + return ep + } + return nil + }) + mEpisodes = lo.Filter(mEpisodes, func(item *Episode, index int) bool { + return item != nil + }) + + lc.ContinueWatchingList = mEpisodes +} + +//---------------------------------------------------------------------------------------------------------------------- + +// hydrateUnmatchedGroups is a method of the LibraryCollection struct. +// It is responsible for grouping unmatched local files by their directory and creating UnmatchedGroup instances for each group. +func (lc *LibraryCollection) hydrateUnmatchedGroups() { + + groups := make([]*UnmatchedGroup, 0) + + // Group by directory + groupedLfs := lop.GroupBy(lc.UnmatchedLocalFiles, func(lf *LocalFile) string { + return filepath.Dir(lf.GetPath()) + }) + + for key, value := range groupedLfs { + groups = append(groups, &UnmatchedGroup{ + Dir: key, + LocalFiles: value, + Suggestions: make([]*anilist.BaseAnime, 0), + }) + } + + slices.SortStableFunc(groups, func(i, j *UnmatchedGroup) int { + return cmp.Compare(i.Dir, j.Dir) + }) + + // Assign the created groups + lc.UnmatchedGroups = groups +} + +//---------------------------------------------------------------------------------------------------------------------- + +// getLibraryCollectionEntryFromListStatus maps anilist.MediaListStatus to LibraryCollectionListType. +func getLibraryCollectionEntryFromListStatus(st anilist.MediaListStatus) anilist.MediaListStatus { + if st == anilist.MediaListStatusRepeating { + return anilist.MediaListStatusCurrent + } + + return st +} diff --git a/seanime-2.9.10/internal/library/anime/collection_test.go b/seanime-2.9.10/internal/library/anime/collection_test.go new file mode 100644 index 0000000..fad5562 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/collection_test.go @@ -0,0 +1,95 @@ +package anime_test + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/library/anime" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +func TestNewLibraryCollection(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + logger := util.NewLogger() + metadataProvider := metadata.GetMockProvider(t) + + anilistClient := anilist.TestGetMockAnilistClient() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + + animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), false) + + if assert.NoError(t, err) { + + // Mock Anilist collection and local files + // User is currently watching Sousou no Frieren and One Piece + lfs := make([]*anime.LocalFile, 0) + + // Sousou no Frieren + // 7 episodes downloaded, 4 watched + mediaId := 154587 + lfs = append(lfs, anime.MockHydratedLocalFiles( + anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - %ep (1080p) [F02B9CEE].mkv", mediaId, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 5, MetadataAniDbEpisode: "5", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 6, MetadataAniDbEpisode: "6", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 7, MetadataAniDbEpisode: "7", MetadataType: anime.LocalFileTypeMain}, + }), + )...) + anilist.TestModifyAnimeCollectionEntry(animeCollection, mediaId, anilist.TestModifyAnimeCollectionEntryInput{ + Status: lo.ToPtr(anilist.MediaListStatusCurrent), + Progress: lo.ToPtr(4), // Mock progress + }) + + // One Piece + // Downloaded 1070-1075 but only watched up until 1060 + mediaId = 21 + lfs = append(lfs, anime.MockHydratedLocalFiles( + anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\One Piece\\[SubsPlease] One Piece - %ep (1080p) [F02B9CEE].mkv", mediaId, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 1070, MetadataAniDbEpisode: "1070", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 1071, MetadataAniDbEpisode: "1071", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 1072, MetadataAniDbEpisode: "1072", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 1073, MetadataAniDbEpisode: "1073", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 1074, MetadataAniDbEpisode: "1074", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 1075, MetadataAniDbEpisode: "1075", MetadataType: anime.LocalFileTypeMain}, + }), + )...) + anilist.TestModifyAnimeCollectionEntry(animeCollection, mediaId, anilist.TestModifyAnimeCollectionEntryInput{ + Status: lo.ToPtr(anilist.MediaListStatusCurrent), + Progress: lo.ToPtr(1060), // Mock progress + }) + + // Add unmatched local files + mediaId = 0 + lfs = append(lfs, anime.MockHydratedLocalFiles( + anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\Unmatched\\[SubsPlease] Unmatched - %ep (1080p) [F02B9CEE].mkv", mediaId, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain}, + }), + )...) + + libraryCollection, err := anime.NewLibraryCollection(t.Context(), &anime.NewLibraryCollectionOptions{ + AnimeCollection: animeCollection, + LocalFiles: lfs, + Platform: anilistPlatform, + MetadataProvider: metadataProvider, + }) + + if assert.NoError(t, err) { + + assert.Equal(t, 1, len(libraryCollection.ContinueWatchingList)) // Only Sousou no Frieren is in the continue watching list + assert.Equal(t, 4, len(libraryCollection.UnmatchedLocalFiles)) // 4 unmatched local files + + } + } + +} diff --git a/seanime-2.9.10/internal/library/anime/entry.go b/seanime-2.9.10/internal/library/anime/entry.go new file mode 100644 index 0000000..6d008c9 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/entry.go @@ -0,0 +1,377 @@ +package anime + +import ( + "context" + "errors" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/hook" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/platforms/platform" + "sort" + + "github.com/samber/lo" + "github.com/sourcegraph/conc/pool" +) + +type ( + // Entry is a container for all data related to a media. + // It is the primary data structure used by the frontend. + Entry struct { + MediaId int `json:"mediaId"` + Media *anilist.BaseAnime `json:"media"` + EntryListData *EntryListData `json:"listData"` + EntryLibraryData *EntryLibraryData `json:"libraryData"` + EntryDownloadInfo *EntryDownloadInfo `json:"downloadInfo,omitempty"` + Episodes []*Episode `json:"episodes"` + NextEpisode *Episode `json:"nextEpisode"` + LocalFiles []*LocalFile `json:"localFiles"` + AnidbId int `json:"anidbId"` + CurrentEpisodeCount int `json:"currentEpisodeCount"` + + IsNakamaEntry bool `json:"_isNakamaEntry"` + NakamaLibraryData *NakamaEntryLibraryData `json:"nakamaLibraryData,omitempty"` + } + + // EntryListData holds the details of the AniList entry. + EntryListData struct { + Progress int `json:"progress,omitempty"` + Score float64 `json:"score,omitempty"` + Status *anilist.MediaListStatus `json:"status,omitempty"` + Repeat int `json:"repeat,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + CompletedAt string `json:"completedAt,omitempty"` + } +) + +type ( + // NewEntryOptions is a constructor for Entry. + NewEntryOptions struct { + MediaId int + LocalFiles []*LocalFile // All local files + AnimeCollection *anilist.AnimeCollection + Platform platform.Platform + MetadataProvider metadata.Provider + IsSimulated bool // If the account is simulated + } +) + +// NewEntry creates a new Entry based on the media id and a list of local files. +// A Entry is a container for all data related to a media. +// It is the primary data structure used by the frontend. +// +// It has the following properties: +// - EntryListData: Details of the AniList entry (if any) +// - EntryLibraryData: Details of the local files (if any) +// - EntryDownloadInfo: Details of the download status +// - Episodes: List of episodes (if any) +// - NextEpisode: Next episode to watch (if any) +// - LocalFiles: List of local files (if any) +// - AnidbId: AniDB id +// - CurrentEpisodeCount: Current episode count +func NewEntry(ctx context.Context, opts *NewEntryOptions) (*Entry, error) { + // Create new Entry + entry := new(Entry) + entry.MediaId = opts.MediaId + + reqEvent := new(AnimeEntryRequestedEvent) + reqEvent.MediaId = opts.MediaId + reqEvent.LocalFiles = opts.LocalFiles + reqEvent.AnimeCollection = opts.AnimeCollection + reqEvent.Entry = entry + + err := hook.GlobalHookManager.OnAnimeEntryRequested().Trigger(reqEvent) + if err != nil { + return nil, err + } + opts.MediaId = reqEvent.MediaId // Override the media ID + opts.LocalFiles = reqEvent.LocalFiles // Override the local files + opts.AnimeCollection = reqEvent.AnimeCollection // Override the anime collection + entry = reqEvent.Entry // Override the entry + + // Default prevented, return the modified entry + if reqEvent.DefaultPrevented { + event := new(AnimeEntryEvent) + event.Entry = reqEvent.Entry + err = hook.GlobalHookManager.OnAnimeEntry().Trigger(event) + if err != nil { + return nil, err + } + + if event.Entry == nil { + return nil, errors.New("no entry was returned") + } + return event.Entry, nil + } + + if opts.AnimeCollection == nil || + opts.Platform == nil { + return nil, errors.New("missing arguments when creating media entry") + } + + // +---------------------+ + // | AniList entry | + // +---------------------+ + + // Get the Anilist List entry + anilistEntry, found := opts.AnimeCollection.GetListEntryFromAnimeId(opts.MediaId) + + // Set the media + // If the Anilist List entry does not exist, fetch the media from AniList + if !found { + // If the Anilist entry does not exist, instantiate one with zero values + anilistEntry = &anilist.AnimeListEntry{} + + // Fetch the media + fetchedMedia, err := opts.Platform.GetAnime(ctx, opts.MediaId) // DEVNOTE: Maybe cache it? + if err != nil { + return nil, err + } + entry.Media = fetchedMedia + } else { + animeEvent := new(anilist_platform.GetAnimeEvent) + animeEvent.Anime = anilistEntry.Media + err := hook.GlobalHookManager.OnGetAnime().Trigger(animeEvent) + if err != nil { + return nil, err + } + entry.Media = animeEvent.Anime + } + + // If the account is simulated and the media was in the library, we will still fetch + // the media from AniList to ensure we have the latest data + if opts.IsSimulated && found { + // Fetch the media + fetchedMedia, err := opts.Platform.GetAnime(ctx, opts.MediaId) // DEVNOTE: Maybe cache it? + if err != nil { + return nil, err + } + entry.Media = fetchedMedia + } + + entry.CurrentEpisodeCount = entry.Media.GetCurrentEpisodeCount() + + // +---------------------+ + // | Local files | + // +---------------------+ + + // Get the entry's local files + lfs := GetLocalFilesFromMediaId(opts.LocalFiles, opts.MediaId) + entry.LocalFiles = lfs // Returns empty slice if no local files are found + + libraryData, _ := NewEntryLibraryData(&NewEntryLibraryDataOptions{ + EntryLocalFiles: lfs, + MediaId: entry.Media.ID, + CurrentProgress: anilistEntry.GetProgressSafe(), + }) + entry.EntryLibraryData = libraryData + + // +---------------------+ + // | Animap | + // +---------------------+ + + // Fetch AniDB data and cache it for 30 minutes + animeMetadata, err := opts.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, opts.MediaId) + if err != nil { + + // +---------------- Start + // +---------------------+ + // | Without Animap | + // +---------------------+ + + // If Animap data is not found, we will still create the Entry without it + simpleAnimeEntry, err := NewSimpleEntry(ctx, &NewSimpleAnimeEntryOptions{ + MediaId: opts.MediaId, + LocalFiles: opts.LocalFiles, + AnimeCollection: opts.AnimeCollection, + Platform: opts.Platform, + }) + if err != nil { + return nil, err + } + + event := &AnimeEntryEvent{ + Entry: &Entry{ + MediaId: simpleAnimeEntry.MediaId, + Media: simpleAnimeEntry.Media, + EntryListData: simpleAnimeEntry.EntryListData, + EntryLibraryData: simpleAnimeEntry.EntryLibraryData, + EntryDownloadInfo: nil, + Episodes: simpleAnimeEntry.Episodes, + NextEpisode: simpleAnimeEntry.NextEpisode, + LocalFiles: simpleAnimeEntry.LocalFiles, + AnidbId: 0, + CurrentEpisodeCount: simpleAnimeEntry.CurrentEpisodeCount, + }, + } + err = hook.GlobalHookManager.OnAnimeEntry().Trigger(event) + if err != nil { + return nil, err + } + + return event.Entry, nil + // +--------------- End + + } + + entry.AnidbId = animeMetadata.GetMappings().AnidbId + + // Instantiate EntryListData + // If the media exist in the user's anime list, add the details + if found { + entry.EntryListData = NewEntryListData(anilistEntry) + } + + // +---------------------+ + // | Episodes | + // +---------------------+ + + // Create episode entities + entry.hydrateEntryEpisodeData(anilistEntry, animeMetadata, opts.MetadataProvider) + + event := &AnimeEntryEvent{ + Entry: entry, + } + err = hook.GlobalHookManager.OnAnimeEntry().Trigger(event) + if err != nil { + return nil, err + } + + return event.Entry, nil +} + +//---------------------------------------------------------------------------------------------------------------------- + +// hydrateEntryEpisodeData +// AniZipData, Media and LocalFiles should be defined +func (e *Entry) hydrateEntryEpisodeData( + anilistEntry *anilist.AnimeListEntry, + animeMetadata *metadata.AnimeMetadata, + metadataProvider metadata.Provider, +) { + + if animeMetadata.Episodes == nil && len(animeMetadata.Episodes) == 0 { + return + } + + // +---------------------+ + // | Discrepancy | + // +---------------------+ + + // We offset the progress number by 1 if there is a discrepancy + progressOffset := 0 + if FindDiscrepancy(e.Media, animeMetadata) == DiscrepancyAniListCountsEpisodeZero { + progressOffset = 1 + + _, ok := lo.Find(e.LocalFiles, func(lf *LocalFile) bool { + return lf.Metadata.Episode == 0 + }) + // Remove the offset if episode 0 is not found + if !ok { + progressOffset = 0 + } + } + + // +---------------------+ + // | Episodes | + // +---------------------+ + + p := pool.NewWithResults[*Episode]() + for _, lf := range e.LocalFiles { + p.Go(func() *Episode { + return NewEpisode(&NewEpisodeOptions{ + LocalFile: lf, + OptionalAniDBEpisode: "", + AnimeMetadata: animeMetadata, + Media: e.Media, + ProgressOffset: progressOffset, + IsDownloaded: true, + MetadataProvider: metadataProvider, + }) + }) + } + episodes := p.Wait() + // Sort by progress number + sort.Slice(episodes, func(i, j int) bool { + return episodes[i].EpisodeNumber < episodes[j].EpisodeNumber + }) + e.Episodes = episodes + + // +---------------------+ + // | Download Info | + // +---------------------+ + + info, err := NewEntryDownloadInfo(&NewEntryDownloadInfoOptions{ + LocalFiles: e.LocalFiles, + AnimeMetadata: animeMetadata, + Progress: anilistEntry.Progress, + Status: anilistEntry.Status, + Media: e.Media, + MetadataProvider: metadataProvider, + }) + if err == nil { + e.EntryDownloadInfo = info + } + + nextEp, found := e.FindNextEpisode() + if found { + e.NextEpisode = nextEp + } + +} + +func NewEntryListData(anilistEntry *anilist.AnimeListEntry) *EntryListData { + return &EntryListData{ + Progress: anilistEntry.GetProgressSafe(), + Score: anilistEntry.GetScoreSafe(), + Status: anilistEntry.Status, + Repeat: anilistEntry.GetRepeatSafe(), + StartedAt: anilist.FuzzyDateToString(anilistEntry.StartedAt), + CompletedAt: anilist.FuzzyDateToString(anilistEntry.CompletedAt), + } +} + +//---------------------------------------------------------------------------------------------------------------------- + +type Discrepancy int + +const ( + DiscrepancyAniListCountsEpisodeZero Discrepancy = iota + DiscrepancyAniListCountsSpecials + DiscrepancyAniDBHasMore + DiscrepancyNone +) + +// FindDiscrepancy returns the discrepancy between the AniList and AniDB episode counts. +// It returns DiscrepancyAniListCountsEpisodeZero if AniList most likely has episode 0 as part of the main count. +// It returns DiscrepancyAniListCountsSpecials if there is a discrepancy between the AniList and AniDB episode counts and specials are included in the AniList count. +// It returns DiscrepancyAniDBHasMore if the AniDB episode count is greater than the AniList episode count. +// It returns DiscrepancyNone if there is no discrepancy. +func FindDiscrepancy(media *anilist.BaseAnime, animeMetadata *metadata.AnimeMetadata) Discrepancy { + if media == nil || animeMetadata == nil || animeMetadata.Episodes == nil { + return DiscrepancyNone + } + + _, aniDBHasS1 := animeMetadata.Episodes["S1"] + _, aniDBHasS2 := animeMetadata.Episodes["S2"] + + difference := media.GetCurrentEpisodeCount() - animeMetadata.GetMainEpisodeCount() + + if difference == 0 { + return DiscrepancyNone + } + + if difference < 0 { + return DiscrepancyAniDBHasMore + } + + if difference == 1 && aniDBHasS1 { + return DiscrepancyAniListCountsEpisodeZero + } + + if difference > 1 && aniDBHasS1 && aniDBHasS2 { + return DiscrepancyAniListCountsSpecials + } + + return DiscrepancyNone +} diff --git a/seanime-2.9.10/internal/library/anime/entry_download_info.go b/seanime-2.9.10/internal/library/anime/entry_download_info.go new file mode 100644 index 0000000..d041f31 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/entry_download_info.go @@ -0,0 +1,350 @@ +package anime + +import ( + "errors" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/hook" + "strconv" + + "github.com/samber/lo" + "github.com/sourcegraph/conc/pool" +) + +type ( + // EntryDownloadInfo is instantiated by the Entry + EntryDownloadInfo struct { + EpisodesToDownload []*EntryDownloadEpisode `json:"episodesToDownload"` + CanBatch bool `json:"canBatch"` + BatchAll bool `json:"batchAll"` + HasInaccurateSchedule bool `json:"hasInaccurateSchedule"` + Rewatch bool `json:"rewatch"` + AbsoluteOffset int `json:"absoluteOffset"` + } + + EntryDownloadEpisode struct { + EpisodeNumber int `json:"episodeNumber"` + AniDBEpisode string `json:"aniDBEpisode"` + Episode *Episode `json:"episode"` + } +) + +type ( + NewEntryDownloadInfoOptions struct { + // Media's local files + LocalFiles []*LocalFile + AnimeMetadata *metadata.AnimeMetadata + Media *anilist.BaseAnime + Progress *int + Status *anilist.MediaListStatus + MetadataProvider metadata.Provider + } +) + +// NewEntryDownloadInfo returns a list of episodes to download or episodes for the torrent/debrid streaming views +// based on the options provided. +func NewEntryDownloadInfo(opts *NewEntryDownloadInfoOptions) (*EntryDownloadInfo, error) { + + reqEvent := &AnimeEntryDownloadInfoRequestedEvent{ + LocalFiles: opts.LocalFiles, + AnimeMetadata: opts.AnimeMetadata, + Media: opts.Media, + Progress: opts.Progress, + Status: opts.Status, + EntryDownloadInfo: &EntryDownloadInfo{}, + } + + err := hook.GlobalHookManager.OnAnimeEntryDownloadInfoRequested().Trigger(reqEvent) + if err != nil { + return nil, err + } + + if reqEvent.DefaultPrevented { + return reqEvent.EntryDownloadInfo, nil + } + + opts.LocalFiles = reqEvent.LocalFiles + opts.AnimeMetadata = reqEvent.AnimeMetadata + opts.Media = reqEvent.Media + opts.Progress = reqEvent.Progress + opts.Status = reqEvent.Status + + if *opts.Media.Status == anilist.MediaStatusNotYetReleased { + return &EntryDownloadInfo{}, nil + } + if opts.AnimeMetadata == nil { + return nil, errors.New("could not get anime metadata") + } + currentEpisodeCount := opts.Media.GetCurrentEpisodeCount() + if currentEpisodeCount == -1 && opts.AnimeMetadata != nil { + currentEpisodeCount = opts.AnimeMetadata.GetCurrentEpisodeCount() + } + if currentEpisodeCount == -1 { + return nil, errors.New("could not get current media episode count") + } + + // +---------------------+ + // | Discrepancy | + // +---------------------+ + + // Whether AniList includes episode 0 as part of main episodes, but AniDB does not, however AniDB has "S1" + discrepancy := FindDiscrepancy(opts.Media, opts.AnimeMetadata) + + // AniList is the source of truth for episode numbers + epSlice := newEpisodeSlice(currentEpisodeCount) + + // Handle discrepancies + if discrepancy != DiscrepancyNone { + + // If AniList includes episode 0 as part of main episodes, but AniDB does not, however AniDB has "S1" + if discrepancy == DiscrepancyAniListCountsEpisodeZero { + // Add "S1" to the beginning of the episode slice + epSlice.trimEnd(1) + epSlice.prepend(0, "S1") + } + + // If AniList includes specials, but AniDB does not + if discrepancy == DiscrepancyAniListCountsSpecials { + diff := currentEpisodeCount - opts.AnimeMetadata.GetMainEpisodeCount() + epSlice.trimEnd(diff) + for i := 0; i < diff; i++ { + epSlice.add(currentEpisodeCount-i, "S"+strconv.Itoa(i+1)) + } + } + + // If AniDB has more episodes than AniList + if discrepancy == DiscrepancyAniDBHasMore { + // Do nothing + } + + } + + // Filter out episodes not aired + if opts.Media.NextAiringEpisode != nil { + epSlice.filter(func(item *episodeSliceItem, index int) bool { + // e.g. if the next airing episode is 13, then filter out episodes 14 and above + return index+1 < opts.Media.NextAiringEpisode.Episode + }) + } + + // Get progress, if the media isn't in the user's list, progress is 0 + // If the media is completed, set progress is 0 + progress := 0 + if opts.Progress != nil { + progress = *opts.Progress + } + if opts.Status != nil { + if *opts.Status == anilist.MediaListStatusCompleted { + progress = 0 + } + } + + hasInaccurateSchedule := false + if opts.Media.NextAiringEpisode == nil && *opts.Media.Status == anilist.MediaStatusReleasing { + hasInaccurateSchedule = true + } + + // Filter out episodes already watched (index+1 is the progress number) + toDownloadSlice := epSlice.filterNew(func(item *episodeSliceItem, index int) bool { + return index+1 > progress + }) + + // This slice contains episode numbers that are not downloaded + // The source of truth is AniDB, but we will handle discrepancies + lfsEpSlice := newEpisodeSlice(0) + if opts.LocalFiles != nil { + // Get all episode numbers of main local files + for _, lf := range opts.LocalFiles { + if lf.Metadata.Type == LocalFileTypeMain { + lfsEpSlice.add(lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + } + } + } + + // Filter out downloaded episodes + toDownloadSlice.filter(func(item *episodeSliceItem, index int) bool { + isDownloaded := false + for _, lf := range opts.LocalFiles { + if lf.Metadata.Type != LocalFileTypeMain { + continue + } + // If the file episode number matches that of the episode slice item + if lf.Metadata.Episode == item.episodeNumber { + isDownloaded = true + } + // If the slice episode number is 0 and the file is a main S1 + if discrepancy == DiscrepancyAniListCountsEpisodeZero && item.episodeNumber == 0 && lf.Metadata.AniDBEpisode == "S1" { + isDownloaded = true + } + } + + return !isDownloaded + }) + + // +---------------------+ + // | EntryEpisode | + // +---------------------+ + + // Generate `episodesToDownload` based on `toDownloadSlice` + + // DEVNOTE: The EntryEpisode generated has inaccurate progress numbers since not local files are passed in + + progressOffset := 0 + if discrepancy == DiscrepancyAniListCountsEpisodeZero { + progressOffset = 1 + } + + p := pool.NewWithResults[*EntryDownloadEpisode]() + for _, ep := range toDownloadSlice.getSlice() { + p.Go(func() *EntryDownloadEpisode { + str := new(EntryDownloadEpisode) + str.EpisodeNumber = ep.episodeNumber + str.AniDBEpisode = ep.aniDBEpisode + // Create a new episode with a placeholder local file + // We pass that placeholder local file so that all episodes are hydrated as main episodes for consistency + str.Episode = NewEpisode(&NewEpisodeOptions{ + LocalFile: &LocalFile{ + ParsedData: &LocalFileParsedData{}, + ParsedFolderData: []*LocalFileParsedData{}, + Metadata: &LocalFileMetadata{ + Episode: ep.episodeNumber, + Type: LocalFileTypeMain, + AniDBEpisode: ep.aniDBEpisode, + }, + }, + OptionalAniDBEpisode: str.AniDBEpisode, + AnimeMetadata: opts.AnimeMetadata, + Media: opts.Media, + ProgressOffset: progressOffset, + IsDownloaded: false, + MetadataProvider: opts.MetadataProvider, + }) + str.Episode.AniDBEpisode = ep.aniDBEpisode + // Reset the local file to nil, since it's a placeholder + str.Episode.LocalFile = nil + return str + }) + } + episodesToDownload := p.Wait() + + //-------------- + + canBatch := false + if *opts.Media.GetStatus() == anilist.MediaStatusFinished && opts.Media.GetTotalEpisodeCount() > 0 { + canBatch = true + } + batchAll := false + if canBatch && lfsEpSlice.len() == 0 && progress == 0 { + batchAll = true + } + rewatch := false + if opts.Status != nil && *opts.Status == anilist.MediaListStatusCompleted { + rewatch = true + } + + downloadInfo := &EntryDownloadInfo{ + EpisodesToDownload: episodesToDownload, + CanBatch: canBatch, + BatchAll: batchAll, + Rewatch: rewatch, + HasInaccurateSchedule: hasInaccurateSchedule, + AbsoluteOffset: opts.AnimeMetadata.GetOffset(), + } + + event := &AnimeEntryDownloadInfoEvent{ + EntryDownloadInfo: downloadInfo, + } + err = hook.GlobalHookManager.OnAnimeEntryDownloadInfo().Trigger(event) + if err != nil { + return nil, err + } + + return event.EntryDownloadInfo, nil +} + +type episodeSliceItem struct { + episodeNumber int + aniDBEpisode string +} + +type episodeSlice []*episodeSliceItem + +func newEpisodeSlice(episodeCount int) *episodeSlice { + s := make([]*episodeSliceItem, 0) + for i := 0; i < episodeCount; i++ { + s = append(s, &episodeSliceItem{episodeNumber: i + 1, aniDBEpisode: strconv.Itoa(i + 1)}) + } + ret := &episodeSlice{} + ret.set(s) + return ret +} + +func (s *episodeSlice) set(eps []*episodeSliceItem) { + *s = eps +} + +func (s *episodeSlice) add(episodeNumber int, aniDBEpisode string) { + *s = append(*s, &episodeSliceItem{episodeNumber: episodeNumber, aniDBEpisode: aniDBEpisode}) +} + +func (s *episodeSlice) prepend(episodeNumber int, aniDBEpisode string) { + *s = append([]*episodeSliceItem{{episodeNumber: episodeNumber, aniDBEpisode: aniDBEpisode}}, *s...) +} + +func (s *episodeSlice) trimEnd(n int) { + *s = (*s)[:len(*s)-n] +} + +func (s *episodeSlice) trimStart(n int) { + *s = (*s)[n:] +} + +func (s *episodeSlice) len() int { + return len(*s) +} + +func (s *episodeSlice) get(index int) *episodeSliceItem { + return (*s)[index] +} + +func (s *episodeSlice) getEpisodeNumber(episodeNumber int) *episodeSliceItem { + for _, item := range *s { + if item.episodeNumber == episodeNumber { + return item + } + } + return nil +} + +func (s *episodeSlice) filter(filter func(*episodeSliceItem, int) bool) { + *s = lo.Filter(*s, filter) +} + +func (s *episodeSlice) filterNew(filter func(*episodeSliceItem, int) bool) *episodeSlice { + s2 := make(episodeSlice, 0) + for i, item := range *s { + if filter(item, i) { + s2 = append(s2, item) + } + } + return &s2 +} + +func (s *episodeSlice) copy() *episodeSlice { + s2 := make(episodeSlice, len(*s), cap(*s)) + for i, item := range *s { + s2[i] = item + } + return &s2 +} + +func (s *episodeSlice) getSlice() []*episodeSliceItem { + return *s +} + +func (s *episodeSlice) print() { + for i, item := range *s { + fmt.Printf("(%d) %d -> %s\n", i, item.episodeNumber, item.aniDBEpisode) + } +} diff --git a/seanime-2.9.10/internal/library/anime/entry_download_info_test.go b/seanime-2.9.10/internal/library/anime/entry_download_info_test.go new file mode 100644 index 0000000..0dcaf89 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/entry_download_info_test.go @@ -0,0 +1,168 @@ +package anime_test + +import ( + "context" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/library/anime" + "seanime/internal/test_utils" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewEntryDownloadInfo(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + metadataProvider := metadata.GetMockProvider(t) + + anilistClient := anilist.TestGetMockAnilistClient() + animeCollection, err := anilistClient.AnimeCollection(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + localFiles []*anime.LocalFile + mediaId int + currentProgress int + status anilist.MediaListStatus + expectedEpisodeNumbersToDownload []struct { + episodeNumber int + aniDbEpisode string + } + }{ + { + // AniList includes episode 0 as a main episode but AniDB lists it as a special S1 + // So we should expect to see episode 0 (S1) in the list of episodes to download + name: "Mushoku Tensei: Jobless Reincarnation Season 2", + localFiles: nil, + mediaId: 146065, + currentProgress: 0, + status: anilist.MediaListStatusCurrent, + expectedEpisodeNumbersToDownload: []struct { + episodeNumber int + aniDbEpisode string + }{ + {episodeNumber: 0, aniDbEpisode: "S1"}, + {episodeNumber: 1, aniDbEpisode: "1"}, + {episodeNumber: 2, aniDbEpisode: "2"}, + {episodeNumber: 3, aniDbEpisode: "3"}, + {episodeNumber: 4, aniDbEpisode: "4"}, + {episodeNumber: 5, aniDbEpisode: "5"}, + {episodeNumber: 6, aniDbEpisode: "6"}, + {episodeNumber: 7, aniDbEpisode: "7"}, + {episodeNumber: 8, aniDbEpisode: "8"}, + {episodeNumber: 9, aniDbEpisode: "9"}, + {episodeNumber: 10, aniDbEpisode: "10"}, + {episodeNumber: 11, aniDbEpisode: "11"}, + {episodeNumber: 12, aniDbEpisode: "12"}, + }, + }, + { + // Same as above but progress of 1 should just eliminate episode 0 from the list and not episode 1 + name: "Mushoku Tensei: Jobless Reincarnation Season 2 - 2", + localFiles: nil, + mediaId: 146065, + currentProgress: 1, + status: anilist.MediaListStatusCurrent, + expectedEpisodeNumbersToDownload: []struct { + episodeNumber int + aniDbEpisode string + }{ + {episodeNumber: 1, aniDbEpisode: "1"}, + {episodeNumber: 2, aniDbEpisode: "2"}, + {episodeNumber: 3, aniDbEpisode: "3"}, + {episodeNumber: 4, aniDbEpisode: "4"}, + {episodeNumber: 5, aniDbEpisode: "5"}, + {episodeNumber: 6, aniDbEpisode: "6"}, + {episodeNumber: 7, aniDbEpisode: "7"}, + {episodeNumber: 8, aniDbEpisode: "8"}, + {episodeNumber: 9, aniDbEpisode: "9"}, + {episodeNumber: 10, aniDbEpisode: "10"}, + {episodeNumber: 11, aniDbEpisode: "11"}, + {episodeNumber: 12, aniDbEpisode: "12"}, + }, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + anilistEntry, _ := animeCollection.GetListEntryFromAnimeId(tt.mediaId) + + animeMetadata, err := metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, tt.mediaId) + require.NoError(t, err) + + info, err := anime.NewEntryDownloadInfo(&anime.NewEntryDownloadInfoOptions{ + LocalFiles: tt.localFiles, + Progress: &tt.currentProgress, + Status: &tt.status, + Media: anilistEntry.Media, + MetadataProvider: metadataProvider, + AnimeMetadata: animeMetadata, + }) + + if assert.NoError(t, err) && assert.NotNil(t, info) { + + foundEpToDownload := make([]struct { + episodeNumber int + aniDbEpisode string + }, 0) + for _, ep := range info.EpisodesToDownload { + foundEpToDownload = append(foundEpToDownload, struct { + episodeNumber int + aniDbEpisode string + }{ + episodeNumber: ep.EpisodeNumber, + aniDbEpisode: ep.AniDBEpisode, + }) + } + + assert.ElementsMatch(t, tt.expectedEpisodeNumbersToDownload, foundEpToDownload) + + } + + }) + + } + +} + +func TestNewEntryDownloadInfo2(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + mediaId := 21 + + metadataProvider := metadata.GetMockProvider(t) + + anilistClient := anilist.TestGetMockAnilistClient() + animeCollection, err := anilistClient.AnimeCollection(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + + anilistEntry, _ := animeCollection.GetListEntryFromAnimeId(mediaId) + + animeMetadata, err := metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mediaId) + require.NoError(t, err) + + info, err := anime.NewEntryDownloadInfo(&anime.NewEntryDownloadInfoOptions{ + LocalFiles: nil, + Progress: lo.ToPtr(0), + Status: lo.ToPtr(anilist.MediaListStatusCurrent), + Media: anilistEntry.Media, + MetadataProvider: metadataProvider, + AnimeMetadata: animeMetadata, + }) + require.NoError(t, err) + + require.NotNil(t, info) + + t.Log(len(info.EpisodesToDownload)) + assert.GreaterOrEqual(t, len(info.EpisodesToDownload), 1096) +} diff --git a/seanime-2.9.10/internal/library/anime/entry_helper.go b/seanime-2.9.10/internal/library/anime/entry_helper.go new file mode 100644 index 0000000..f4672d1 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/entry_helper.go @@ -0,0 +1,251 @@ +package anime + +import "github.com/samber/lo" + +// HasWatchedAll returns true if all episodes have been watched. +// Returns false if there are no downloaded episodes. +func (e *Entry) HasWatchedAll() bool { + // If there are no episodes, return nil + latestEp, ok := e.FindLatestEpisode() + if !ok { + return false + } + + return e.GetCurrentProgress() >= latestEp.GetProgressNumber() + +} + +// FindNextEpisode returns the episode whose episode number is the same as the progress number + 1. +// Returns false if there are no episodes or if there is no next episode. +func (e *Entry) FindNextEpisode() (*Episode, bool) { + eps, ok := e.FindMainEpisodes() + if !ok { + return nil, false + } + ep, ok := lo.Find(eps, func(ep *Episode) bool { + return ep.GetProgressNumber() == e.GetCurrentProgress()+1 + }) + if !ok { + return nil, false + } + return ep, true +} + +// FindLatestEpisode returns the *main* episode with the highest episode number. +// Returns false if there are no episodes. +func (e *Entry) FindLatestEpisode() (*Episode, bool) { + // If there are no episodes, return nil + eps, ok := e.FindMainEpisodes() + if !ok { + return nil, false + } + // Get the episode with the highest progress number + latest := eps[0] + for _, ep := range eps { + if ep.GetProgressNumber() > latest.GetProgressNumber() { + latest = ep + } + } + return latest, true +} + +// FindLatestLocalFile returns the *main* local file with the highest episode number. +// Returns false if there are no local files. +func (e *Entry) FindLatestLocalFile() (*LocalFile, bool) { + lfs, ok := e.FindMainLocalFiles() + // If there are no local files, return nil + if !ok { + return nil, false + } + // Get the local file with the highest episode number + latest := lfs[0] + for _, lf := range lfs { + if lf.GetEpisodeNumber() > latest.GetEpisodeNumber() { + latest = lf + } + } + return latest, true +} + +//---------------------------------------------------------------------------------------------------------------------- + +// GetCurrentProgress returns the progress number. +// If the media entry is not in any AniList list, returns 0. +func (e *Entry) GetCurrentProgress() int { + listData, ok := e.FindListData() + if !ok { + return 0 + } + return listData.Progress +} + +// FindEpisodes returns the episodes. +// Returns false if there are no episodes. +func (e *Entry) FindEpisodes() ([]*Episode, bool) { + if e.Episodes == nil { + return nil, false + } + return e.Episodes, true +} + +// FindMainEpisodes returns the main episodes. +// Returns false if there are no main episodes. +func (e *Entry) FindMainEpisodes() ([]*Episode, bool) { + if e.Episodes == nil { + return nil, false + } + eps := make([]*Episode, 0) + for _, ep := range e.Episodes { + if ep.IsMain() { + eps = append(eps, ep) + } + } + return e.Episodes, true +} + +// FindLocalFiles returns the local files. +// Returns false if there are no local files. +func (e *Entry) FindLocalFiles() ([]*LocalFile, bool) { + if !e.IsDownloaded() { + return nil, false + } + return e.LocalFiles, true +} + +// FindMainLocalFiles returns *main* local files. +// Returns false if there are no local files. +func (e *Entry) FindMainLocalFiles() ([]*LocalFile, bool) { + if !e.IsDownloaded() { + return nil, false + } + lfs := make([]*LocalFile, 0) + for _, lf := range e.LocalFiles { + if lf.IsMain() { + lfs = append(lfs, lf) + } + } + if len(lfs) == 0 { + return nil, false + } + return lfs, true +} + +// IsDownloaded returns true if there are local files. +func (e *Entry) IsDownloaded() bool { + if e.LocalFiles == nil { + return false + } + return len(e.LocalFiles) > 0 +} + +func (e *Entry) FindListData() (*EntryListData, bool) { + if e.EntryListData == nil { + return nil, false + } + return e.EntryListData, true +} + +func (e *Entry) IsInAnimeCollection() bool { + _, ok := e.FindListData() + return ok +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (e *SimpleEntry) GetCurrentProgress() int { + listData, ok := e.FindListData() + if !ok { + return 0 + } + return listData.Progress +} + +func (e *SimpleEntry) FindMainEpisodes() ([]*Episode, bool) { + if e.Episodes == nil { + return nil, false + } + eps := make([]*Episode, 0) + for _, ep := range e.Episodes { + if ep.IsMain() { + eps = append(eps, ep) + } + } + return e.Episodes, true +} + +func (e *SimpleEntry) FindNextEpisode() (*Episode, bool) { + eps, ok := e.FindMainEpisodes() + if !ok { + return nil, false + } + ep, ok := lo.Find(eps, func(ep *Episode) bool { + return ep.GetProgressNumber() == e.GetCurrentProgress()+1 + }) + if !ok { + return nil, false + } + return ep, true +} + +func (e *SimpleEntry) FindLatestEpisode() (*Episode, bool) { + // If there are no episodes, return nil + eps, ok := e.FindMainEpisodes() + if !ok { + return nil, false + } + // Get the episode with the highest progress number + latest := eps[0] + for _, ep := range eps { + if ep.GetProgressNumber() > latest.GetProgressNumber() { + latest = ep + } + } + return latest, true +} + +func (e *SimpleEntry) FindLatestLocalFile() (*LocalFile, bool) { + lfs, ok := e.FindMainLocalFiles() + // If there are no local files, return nil + if !ok { + return nil, false + } + // Get the local file with the highest episode number + latest := lfs[0] + for _, lf := range lfs { + if lf.GetEpisodeNumber() > latest.GetEpisodeNumber() { + latest = lf + } + } + return latest, true +} + +func (e *SimpleEntry) FindMainLocalFiles() ([]*LocalFile, bool) { + if e.LocalFiles == nil { + return nil, false + } + if len(e.LocalFiles) == 0 { + return nil, false + } + lfs := make([]*LocalFile, 0) + for _, lf := range e.LocalFiles { + if lf.IsMain() { + lfs = append(lfs, lf) + } + } + if len(lfs) == 0 { + return nil, false + } + return lfs, true +} + +func (e *SimpleEntry) FindListData() (*EntryListData, bool) { + if e.EntryListData == nil { + return nil, false + } + return e.EntryListData, true +} + +func (e *SimpleEntry) IsInAnimeCollection() bool { + _, ok := e.FindListData() + return ok +} diff --git a/seanime-2.9.10/internal/library/anime/entry_library_data.go b/seanime-2.9.10/internal/library/anime/entry_library_data.go new file mode 100644 index 0000000..1739cca --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/entry_library_data.go @@ -0,0 +1,77 @@ +package anime + +import ( + "seanime/internal/hook" + "strings" + + "github.com/samber/lo" +) + +type ( + EntryLibraryData struct { + AllFilesLocked bool `json:"allFilesLocked"` + SharedPath string `json:"sharedPath"` + UnwatchedCount int `json:"unwatchedCount"` + MainFileCount int `json:"mainFileCount"` + } + + NakamaEntryLibraryData struct { + UnwatchedCount int `json:"unwatchedCount"` + MainFileCount int `json:"mainFileCount"` + } + + NewEntryLibraryDataOptions struct { + EntryLocalFiles []*LocalFile + MediaId int + CurrentProgress int + } +) + +// NewEntryLibraryData creates a new EntryLibraryData based on the media id and a list of local files related to the media. +// It will return false if the list of local files is empty. +func NewEntryLibraryData(opts *NewEntryLibraryDataOptions) (ret *EntryLibraryData, ok bool) { + + reqEvent := new(AnimeEntryLibraryDataRequestedEvent) + reqEvent.EntryLocalFiles = opts.EntryLocalFiles + reqEvent.MediaId = opts.MediaId + reqEvent.CurrentProgress = opts.CurrentProgress + + err := hook.GlobalHookManager.OnAnimeEntryLibraryDataRequested().Trigger(reqEvent) + if err != nil { + return nil, false + } + + if reqEvent.EntryLocalFiles == nil || len(reqEvent.EntryLocalFiles) == 0 { + return nil, false + } + sharedPath := strings.Replace(reqEvent.EntryLocalFiles[0].Path, reqEvent.EntryLocalFiles[0].Name, "", 1) + sharedPath = strings.TrimSuffix(strings.TrimSuffix(sharedPath, "\\"), "/") + + ret = &EntryLibraryData{ + AllFilesLocked: lo.EveryBy(reqEvent.EntryLocalFiles, func(item *LocalFile) bool { return item.Locked }), + SharedPath: sharedPath, + } + ok = true + + lfw := NewLocalFileWrapper(reqEvent.EntryLocalFiles) + lfwe, ok := lfw.GetLocalEntryById(reqEvent.MediaId) + if !ok { + return ret, true + } + + ret.UnwatchedCount = len(lfwe.GetUnwatchedLocalFiles(reqEvent.CurrentProgress)) + + mainLfs, ok := lfwe.GetMainLocalFiles() + if !ok { + return ret, true + } + ret.MainFileCount = len(mainLfs) + + event := new(AnimeEntryLibraryDataEvent) + event.EntryLibraryData = ret + err = hook.GlobalHookManager.OnAnimeEntryLibraryData().Trigger(event) + if err != nil { + return nil, false + } + return event.EntryLibraryData, true +} diff --git a/seanime-2.9.10/internal/library/anime/entry_simple.go b/seanime-2.9.10/internal/library/anime/entry_simple.go new file mode 100644 index 0000000..e7094ce --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/entry_simple.go @@ -0,0 +1,148 @@ +package anime + +import ( + "context" + "errors" + "seanime/internal/api/anilist" + "seanime/internal/platforms/platform" + "sort" + + "github.com/sourcegraph/conc/pool" +) + +type ( + SimpleEntry struct { + MediaId int `json:"mediaId"` + Media *anilist.BaseAnime `json:"media"` + EntryListData *EntryListData `json:"listData"` + EntryLibraryData *EntryLibraryData `json:"libraryData"` + Episodes []*Episode `json:"episodes"` + NextEpisode *Episode `json:"nextEpisode"` + LocalFiles []*LocalFile `json:"localFiles"` + CurrentEpisodeCount int `json:"currentEpisodeCount"` + } + + SimpleEntryListData struct { + Progress int `json:"progress,omitempty"` + Score float64 `json:"score,omitempty"` + Status *anilist.MediaListStatus `json:"status,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + CompletedAt string `json:"completedAt,omitempty"` + } + + NewSimpleAnimeEntryOptions struct { + MediaId int + LocalFiles []*LocalFile // All local files + AnimeCollection *anilist.AnimeCollection + Platform platform.Platform + } +) + +func NewSimpleEntry(ctx context.Context, opts *NewSimpleAnimeEntryOptions) (*SimpleEntry, error) { + + if opts.AnimeCollection == nil || + opts.Platform == nil { + return nil, errors.New("missing arguments when creating simple media entry") + } + // Create new Entry + entry := new(SimpleEntry) + entry.MediaId = opts.MediaId + + // +---------------------+ + // | AniList entry | + // +---------------------+ + + // Get the Anilist List entry + anilistEntry, found := opts.AnimeCollection.GetListEntryFromAnimeId(opts.MediaId) + + // Set the media + // If the Anilist List entry does not exist, fetch the media from AniList + if !found { + // If the Anilist entry does not exist, instantiate one with zero values + anilistEntry = &anilist.AnimeListEntry{} + + // Fetch the media + fetchedMedia, err := opts.Platform.GetAnime(ctx, opts.MediaId) // DEVNOTE: Maybe cache it? + if err != nil { + return nil, err + } + entry.Media = fetchedMedia + } else { + entry.Media = anilistEntry.Media + } + + entry.CurrentEpisodeCount = entry.Media.GetCurrentEpisodeCount() + + // +---------------------+ + // | Local files | + // +---------------------+ + + // Get the entry's local files + lfs := GetLocalFilesFromMediaId(opts.LocalFiles, opts.MediaId) + entry.LocalFiles = lfs // Returns empty slice if no local files are found + + libraryData, _ := NewEntryLibraryData(&NewEntryLibraryDataOptions{ + EntryLocalFiles: lfs, + MediaId: entry.Media.ID, + CurrentProgress: anilistEntry.GetProgressSafe(), + }) + entry.EntryLibraryData = libraryData + + // Instantiate EntryListData + // If the media exist in the user's anime list, add the details + if found { + entry.EntryListData = &EntryListData{ + Progress: anilistEntry.GetProgressSafe(), + Score: anilistEntry.GetScoreSafe(), + Status: anilistEntry.Status, + Repeat: anilistEntry.GetRepeatSafe(), + StartedAt: anilist.ToEntryStartDate(anilistEntry.StartedAt), + CompletedAt: anilist.ToEntryCompletionDate(anilistEntry.CompletedAt), + } + } + + // +---------------------+ + // | Episodes | + // +---------------------+ + + // Create episode entities + entry.hydrateEntryEpisodeData() + + return entry, nil + +} + +//---------------------------------------------------------------------------------------------------------------------- + +// hydrateEntryEpisodeData +// AniZipData, Media and LocalFiles should be defined +func (e *SimpleEntry) hydrateEntryEpisodeData() { + + // +---------------------+ + // | Episodes | + // +---------------------+ + + p := pool.NewWithResults[*Episode]() + for _, lf := range e.LocalFiles { + lf := lf + p.Go(func() *Episode { + return NewSimpleEpisode(&NewSimpleEpisodeOptions{ + LocalFile: lf, + Media: e.Media, + IsDownloaded: true, + }) + }) + } + episodes := p.Wait() + // Sort by progress number + sort.Slice(episodes, func(i, j int) bool { + return episodes[i].EpisodeNumber < episodes[j].EpisodeNumber + }) + e.Episodes = episodes + + nextEp, found := e.FindNextEpisode() + if found { + e.NextEpisode = nextEp + } + +} diff --git a/seanime-2.9.10/internal/library/anime/entry_test.go b/seanime-2.9.10/internal/library/anime/entry_test.go new file mode 100644 index 0000000..1990794 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/entry_test.go @@ -0,0 +1,116 @@ +package anime_test + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/library/anime" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +// TestNewAnimeEntry tests /library/entry endpoint. +// /!\ MAKE SURE TO HAVE THE MEDIA ADDED TO YOUR LIST TEST ACCOUNT LISTS +func TestNewAnimeEntry(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + logger := util.NewLogger() + metadataProvider := metadata.GetMockProvider(t) + + tests := []struct { + name string + mediaId int + localFiles []*anime.LocalFile + currentProgress int + expectedNextEpisodeNumber int + expectedNextEpisodeProgressNumber int + }{ + { + name: "Sousou no Frieren", + mediaId: 154587, + localFiles: anime.MockHydratedLocalFiles( + anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - %ep (1080p) [F02B9CEE].mkv", 154587, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 5, MetadataAniDbEpisode: "5", MetadataType: anime.LocalFileTypeMain}, + }), + ), + currentProgress: 4, + expectedNextEpisodeNumber: 5, + expectedNextEpisodeProgressNumber: 5, + }, + { + name: "Mushoku Tensei II Isekai Ittara Honki Dasu", + mediaId: 146065, + localFiles: anime.MockHydratedLocalFiles( + anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:/Anime/Mushoku Tensei II Isekai Ittara Honki Dasu/[SubsPlease] Mushoku Tensei S2 - 00 (1080p) [9C362DC3].mkv", 146065, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 0, MetadataAniDbEpisode: "S1", MetadataType: anime.LocalFileTypeMain}, // Special episode + {MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 5, MetadataAniDbEpisode: "5", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 6, MetadataAniDbEpisode: "6", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 7, MetadataAniDbEpisode: "7", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 8, MetadataAniDbEpisode: "8", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 9, MetadataAniDbEpisode: "9", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 10, MetadataAniDbEpisode: "10", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 11, MetadataAniDbEpisode: "11", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 12, MetadataAniDbEpisode: "12", MetadataType: anime.LocalFileTypeMain}, + }), + ), + currentProgress: 0, + expectedNextEpisodeNumber: 0, + expectedNextEpisodeProgressNumber: 1, + }, + } + + anilistClient := anilist.TestGetMockAnilistClient() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), false) + if err != nil { + t.Fatal(err) + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + anilist.TestModifyAnimeCollectionEntry(animeCollection, tt.mediaId, anilist.TestModifyAnimeCollectionEntryInput{ + Progress: lo.ToPtr(tt.currentProgress), // Mock progress + }) + + entry, err := anime.NewEntry(t.Context(), &anime.NewEntryOptions{ + MediaId: tt.mediaId, + LocalFiles: tt.localFiles, + AnimeCollection: animeCollection, + Platform: anilistPlatform, + MetadataProvider: metadataProvider, + }) + + if assert.NoErrorf(t, err, "Failed to get mock data") { + + if assert.NoError(t, err) { + + // Mock progress is 4 + nextEp, found := entry.FindNextEpisode() + if assert.True(t, found, "did not find next episode") { + assert.Equal(t, tt.expectedNextEpisodeNumber, nextEp.EpisodeNumber, "next episode number mismatch") + assert.Equal(t, tt.expectedNextEpisodeProgressNumber, nextEp.ProgressNumber, "next episode progress number mismatch") + } + + t.Logf("Found %v episodes", len(entry.Episodes)) + + } + + } + + }) + + } +} diff --git a/seanime-2.9.10/internal/library/anime/episode.go b/seanime-2.9.10/internal/library/anime/episode.go new file mode 100644 index 0000000..5178e2f --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/episode.go @@ -0,0 +1,361 @@ +package anime + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "strconv" + "strings" +) + +type ( + // Episode represents a single episode of a media entry. + Episode struct { + Type LocalFileType `json:"type"` + DisplayTitle string `json:"displayTitle"` // e.g, Show: "Episode 1", Movie: "Violet Evergarden The Movie" + EpisodeTitle string `json:"episodeTitle"` // e.g, "Shibuya Incident - Gate, Open" + EpisodeNumber int `json:"episodeNumber"` + AniDBEpisode string `json:"aniDBEpisode,omitempty"` // AniDB episode number + AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber"` + ProgressNumber int `json:"progressNumber"` // Usually the same as EpisodeNumber, unless there is a discrepancy between AniList and AniDB + LocalFile *LocalFile `json:"localFile"` + IsDownloaded bool `json:"isDownloaded"` // Is in the local files + EpisodeMetadata *EpisodeMetadata `json:"episodeMetadata"` // (image, airDate, length, summary, overview) + FileMetadata *LocalFileMetadata `json:"fileMetadata"` // (episode, aniDBEpisode, type...) + IsInvalid bool `json:"isInvalid"` // No AniDB data + MetadataIssue string `json:"metadataIssue,omitempty"` // Alerts the user that there is a discrepancy between AniList and AniDB + BaseAnime *anilist.BaseAnime `json:"baseAnime,omitempty"` + // IsNakamaEpisode indicates that this episode is from the Nakama host's anime library. + IsNakamaEpisode bool `json:"_isNakamaEpisode"` + } + + // EpisodeMetadata represents the metadata of an Episode. + // Metadata is fetched from Animap (AniDB) and, optionally, AniList (if Animap is not available). + EpisodeMetadata struct { + AnidbId int `json:"anidbId,omitempty"` + Image string `json:"image,omitempty"` + AirDate string `json:"airDate,omitempty"` + Length int `json:"length,omitempty"` + Summary string `json:"summary,omitempty"` + Overview string `json:"overview,omitempty"` + IsFiller bool `json:"isFiller,omitempty"` + HasImage bool `json:"hasImage,omitempty"` // Indicates if the episode has a real image + } +) + +type ( + // NewEpisodeOptions hold data used to create a new Episode. + NewEpisodeOptions struct { + LocalFile *LocalFile + AnimeMetadata *metadata.AnimeMetadata // optional + Media *anilist.BaseAnime + OptionalAniDBEpisode string + // ProgressOffset will offset the ProgressNumber for a specific MAIN file + // This is used when there is a discrepancy between AniList and AniDB + // When this is -1, it means that a re-mapping of AniDB Episode is needed + ProgressOffset int + IsDownloaded bool + MetadataProvider metadata.Provider // optional + } + + // NewSimpleEpisodeOptions hold data used to create a new Episode. + // Unlike NewEpisodeOptions, this struct does not require Animap data. It is used to list episodes without AniDB metadata. + NewSimpleEpisodeOptions struct { + LocalFile *LocalFile + Media *anilist.BaseAnime + IsDownloaded bool + } +) + +// NewEpisode creates a new episode entity. +// +// It is used to list existing local files as episodes +// OR list non-downloaded episodes by passing the `OptionalAniDBEpisode` parameter. +// +// `AnimeMetadata` should be defined, but this is not always the case. +// `LocalFile` is optional. +func NewEpisode(opts *NewEpisodeOptions) *Episode { + entryEp := new(Episode) + entryEp.BaseAnime = opts.Media + entryEp.DisplayTitle = "" + entryEp.EpisodeTitle = "" + + hydrated := false + + // LocalFile exists + if opts.LocalFile != nil { + + aniDBEp := opts.LocalFile.Metadata.AniDBEpisode + + // ProgressOffset is -1, meaning the hydrator mistakenly set AniDB episode to "S1" (due to torrent name) because the episode number is 0 + // The hydrator ASSUMES that AniDB will not include episode 0 as part of main episodes. + // We will remap "S1" to "1" and offset other AniDB episodes by 1 + // e.g, ["S1", "1", "2", "3",...,"12"] -> ["1", "2", "3", "4",...,"13"] + if opts.ProgressOffset == -1 && opts.LocalFile.GetType() == LocalFileTypeMain { + if aniDBEp == "S1" { + aniDBEp = "1" + opts.ProgressOffset = 0 + } else { + // e.g, "1" -> "2" etc... + aniDBEp = metadata.OffsetAnidbEpisode(aniDBEp, opts.ProgressOffset) + } + entryEp.MetadataIssue = "forced_remapping" + } + + // Get the Animap episode + foundAnimapEpisode := false + var episodeMetadata *metadata.EpisodeMetadata + if opts.AnimeMetadata != nil { + episodeMetadata, foundAnimapEpisode = opts.AnimeMetadata.FindEpisode(aniDBEp) + } + + entryEp.IsDownloaded = true + entryEp.FileMetadata = opts.LocalFile.GetMetadata() + entryEp.Type = opts.LocalFile.GetType() + entryEp.LocalFile = opts.LocalFile + + // Set episode number and progress number + switch opts.LocalFile.Metadata.Type { + case LocalFileTypeMain: + entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber() + entryEp.ProgressNumber = opts.LocalFile.GetEpisodeNumber() + opts.ProgressOffset + if foundAnimapEpisode { + entryEp.AniDBEpisode = aniDBEp + entryEp.AbsoluteEpisodeNumber = entryEp.EpisodeNumber + opts.AnimeMetadata.GetOffset() + } + case LocalFileTypeSpecial: + entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber() + entryEp.ProgressNumber = 0 + case LocalFileTypeNC: + entryEp.EpisodeNumber = 0 + entryEp.ProgressNumber = 0 + } + + // Set titles + if len(entryEp.DisplayTitle) == 0 { + switch opts.LocalFile.Metadata.Type { + case LocalFileTypeMain: + if foundAnimapEpisode { + entryEp.AniDBEpisode = aniDBEp + if *opts.Media.GetFormat() == anilist.MediaFormatMovie { + entryEp.DisplayTitle = opts.Media.GetPreferredTitle() + entryEp.EpisodeTitle = "Complete Movie" + } else { + entryEp.DisplayTitle = "Episode " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber()) + entryEp.EpisodeTitle = episodeMetadata.GetTitle() + } + } else { + if *opts.Media.GetFormat() == anilist.MediaFormatMovie { + entryEp.DisplayTitle = opts.Media.GetPreferredTitle() + entryEp.EpisodeTitle = "Complete Movie" + } else { + entryEp.DisplayTitle = "Episode " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber()) + entryEp.EpisodeTitle = opts.LocalFile.GetParsedEpisodeTitle() + } + } + hydrated = true // Hydrated + case LocalFileTypeSpecial: + if foundAnimapEpisode { + entryEp.AniDBEpisode = aniDBEp + episodeInt, found := metadata.ExtractEpisodeInteger(aniDBEp) + if found { + entryEp.DisplayTitle = "Special " + strconv.Itoa(episodeInt) + } else { + entryEp.DisplayTitle = "Special " + aniDBEp + } + entryEp.EpisodeTitle = episodeMetadata.GetTitle() + } else { + entryEp.DisplayTitle = "Special " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber()) + } + hydrated = true // Hydrated + case LocalFileTypeNC: + if foundAnimapEpisode { + entryEp.AniDBEpisode = aniDBEp + entryEp.DisplayTitle = episodeMetadata.GetTitle() + entryEp.EpisodeTitle = "" + } else { + entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle() + entryEp.EpisodeTitle = "" + } + hydrated = true // Hydrated + } + } else { + hydrated = true // Hydrated + } + + // Set episode metadata + entryEp.EpisodeMetadata = NewEpisodeMetadata(opts.AnimeMetadata, episodeMetadata, opts.Media, opts.MetadataProvider) + + } else if len(opts.OptionalAniDBEpisode) > 0 && opts.AnimeMetadata != nil { + // No LocalFile, but AniDB episode is provided + + // Get the Animap episode + if episodeMetadata, foundAnimapEpisode := opts.AnimeMetadata.FindEpisode(opts.OptionalAniDBEpisode); foundAnimapEpisode { + + entryEp.IsDownloaded = false + entryEp.Type = LocalFileTypeMain + if strings.HasPrefix(opts.OptionalAniDBEpisode, "S") { + entryEp.Type = LocalFileTypeSpecial + } else if strings.HasPrefix(opts.OptionalAniDBEpisode, "OP") || strings.HasPrefix(opts.OptionalAniDBEpisode, "ED") { + entryEp.Type = LocalFileTypeNC + } + entryEp.EpisodeNumber = 0 + entryEp.ProgressNumber = 0 + + if episodeInt, ok := metadata.ExtractEpisodeInteger(opts.OptionalAniDBEpisode); ok { + entryEp.EpisodeNumber = episodeInt + entryEp.ProgressNumber = episodeInt + opts.ProgressOffset + entryEp.AniDBEpisode = opts.OptionalAniDBEpisode + entryEp.AbsoluteEpisodeNumber = entryEp.EpisodeNumber + opts.AnimeMetadata.GetOffset() + switch entryEp.Type { + case LocalFileTypeMain: + if *opts.Media.GetFormat() == anilist.MediaFormatMovie { + entryEp.DisplayTitle = opts.Media.GetPreferredTitle() + entryEp.EpisodeTitle = "Complete Movie" + } else { + entryEp.DisplayTitle = "Episode " + strconv.Itoa(episodeInt) + entryEp.EpisodeTitle = episodeMetadata.GetTitle() + } + case LocalFileTypeSpecial: + entryEp.DisplayTitle = "Special " + strconv.Itoa(episodeInt) + entryEp.EpisodeTitle = episodeMetadata.GetTitle() + case LocalFileTypeNC: + entryEp.DisplayTitle = opts.OptionalAniDBEpisode + entryEp.EpisodeTitle = "" + } + hydrated = true + } + + // Set episode metadata + entryEp.EpisodeMetadata = NewEpisodeMetadata(opts.AnimeMetadata, episodeMetadata, opts.Media, opts.MetadataProvider) + } else { + // No Local file, no Animap data + // DEVNOTE: Non-downloaded, without any AniDB data. Don't handle this case. + // Non-downloaded episodes are determined from AniDB data either way. + } + + } + + // If for some reason the episode is not hydrated, set it as invalid + if !hydrated { + if opts.LocalFile != nil { + entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle() + } + entryEp.EpisodeTitle = "" + entryEp.IsInvalid = true + return entryEp + } + + return entryEp +} + +// NewEpisodeMetadata creates a new EpisodeMetadata from an Animap episode and AniList media. +// If the Animap episode is nil, it will just set the image from the media. +func NewEpisodeMetadata( + animeMetadata *metadata.AnimeMetadata, + episode *metadata.EpisodeMetadata, + media *anilist.BaseAnime, + metadataProvider metadata.Provider, +) *EpisodeMetadata { + md := new(EpisodeMetadata) + + // No Animap data + if episode == nil { + md.Image = media.GetCoverImageSafe() + return md + } + epInt, err := strconv.Atoi(episode.Episode) + + if err == nil { + aw := metadataProvider.GetAnimeMetadataWrapper(media, animeMetadata) + epMetadata := aw.GetEpisodeMetadata(epInt) + md.AnidbId = epMetadata.AnidbId + md.Image = epMetadata.Image + md.AirDate = epMetadata.AirDate + md.Length = epMetadata.Length + md.Summary = epMetadata.Summary + md.Overview = epMetadata.Overview + md.HasImage = epMetadata.HasImage + md.IsFiller = false + } else { + md.Image = media.GetBannerImageSafe() + } + + return md +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// NewSimpleEpisode creates a Episode without AniDB metadata. +func NewSimpleEpisode(opts *NewSimpleEpisodeOptions) *Episode { + entryEp := new(Episode) + entryEp.BaseAnime = opts.Media + entryEp.DisplayTitle = "" + entryEp.EpisodeTitle = "" + entryEp.EpisodeMetadata = new(EpisodeMetadata) + + hydrated := false + + // LocalFile exists + if opts.LocalFile != nil { + + entryEp.IsDownloaded = true + entryEp.FileMetadata = opts.LocalFile.GetMetadata() + entryEp.Type = opts.LocalFile.GetType() + entryEp.LocalFile = opts.LocalFile + + // Set episode number and progress number + switch opts.LocalFile.Metadata.Type { + case LocalFileTypeMain: + entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber() + entryEp.ProgressNumber = opts.LocalFile.GetEpisodeNumber() + hydrated = true // Hydrated + case LocalFileTypeSpecial: + entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber() + entryEp.ProgressNumber = 0 + hydrated = true // Hydrated + case LocalFileTypeNC: + entryEp.EpisodeNumber = 0 + entryEp.ProgressNumber = 0 + hydrated = true // Hydrated + } + + // Set titles + if len(entryEp.DisplayTitle) == 0 { + switch opts.LocalFile.Metadata.Type { + case LocalFileTypeMain: + if *opts.Media.GetFormat() == anilist.MediaFormatMovie { + entryEp.DisplayTitle = opts.Media.GetPreferredTitle() + entryEp.EpisodeTitle = "Complete Movie" + } else { + entryEp.DisplayTitle = "Episode " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber()) + entryEp.EpisodeTitle = opts.LocalFile.GetParsedEpisodeTitle() + } + + hydrated = true // Hydrated + case LocalFileTypeSpecial: + entryEp.DisplayTitle = "Special " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber()) + hydrated = true // Hydrated + case LocalFileTypeNC: + entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle() + entryEp.EpisodeTitle = "" + hydrated = true // Hydrated + } + } + + entryEp.EpisodeMetadata.Image = opts.Media.GetCoverImageSafe() + + } + + if !hydrated { + if opts.LocalFile != nil { + entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle() + } + entryEp.EpisodeTitle = "" + entryEp.IsInvalid = true + entryEp.MetadataIssue = "no_anidb_data" + return entryEp + } + + entryEp.MetadataIssue = "no_anidb_data" + return entryEp +} diff --git a/seanime-2.9.10/internal/library/anime/episode_collection.go b/seanime-2.9.10/internal/library/anime/episode_collection.go new file mode 100644 index 0000000..ee28440 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/episode_collection.go @@ -0,0 +1,309 @@ +package anime + +import ( + "cmp" + "context" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/hook" + "seanime/internal/platforms/platform" + "seanime/internal/util/result" + "slices" + "time" + + "github.com/rs/zerolog" + "github.com/samber/lo" +) + +var episodeCollectionCache = result.NewBoundedCache[int, *EpisodeCollection](10) +var EpisodeCollectionFromLocalFilesCache = result.NewBoundedCache[int, *EpisodeCollection](10) + +type ( + // EpisodeCollection represents a collection of episodes. + EpisodeCollection struct { + HasMappingError bool `json:"hasMappingError"` + Episodes []*Episode `json:"episodes"` + Metadata *metadata.AnimeMetadata `json:"metadata"` + } +) + +type NewEpisodeCollectionOptions struct { + // AnimeMetadata can be nil, if not provided, it will be fetched from the metadata provider. + AnimeMetadata *metadata.AnimeMetadata + Media *anilist.BaseAnime + MetadataProvider metadata.Provider + Logger *zerolog.Logger +} + +// NewEpisodeCollection creates a new episode collection by leveraging EntryDownloadInfo. +// The returned EpisodeCollection is cached for 6 hours. +// +// AnimeMetadata is optional, if not provided, it will be fetched from the metadata provider. +// +// Note: This is used by Torrent and Debrid streaming +func NewEpisodeCollection(opts NewEpisodeCollectionOptions) (ec *EpisodeCollection, err error) { + if opts.Logger == nil { + opts.Logger = lo.ToPtr(zerolog.Nop()) + } + + if opts.Media == nil { + return nil, fmt.Errorf("cannont create episode collectiom, media is nil") + } + + if opts.MetadataProvider == nil { + return nil, fmt.Errorf("cannot create episode collection, metadata provider is nil") + } + + if ec, ok := episodeCollectionCache.Get(opts.Media.ID); ok { + opts.Logger.Debug().Msg("torrentstream: Using cached episode collection") + return ec, nil + } + + if opts.AnimeMetadata == nil { + // Fetch the metadata + opts.AnimeMetadata, err = opts.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, opts.Media.ID) + if err != nil { + opts.AnimeMetadata = &metadata.AnimeMetadata{ + Titles: make(map[string]string), + Episodes: make(map[string]*metadata.EpisodeMetadata), + EpisodeCount: 0, + SpecialCount: 0, + Mappings: &metadata.AnimeMappings{ + AnilistId: opts.Media.GetID(), + }, + } + opts.AnimeMetadata.Titles["en"] = opts.Media.GetTitleSafe() + opts.AnimeMetadata.Titles["x-jat"] = opts.Media.GetRomajiTitleSafe() + err = nil + } + } + + reqEvent := &AnimeEpisodeCollectionRequestedEvent{ + Media: opts.Media, + Metadata: opts.AnimeMetadata, + EpisodeCollection: &EpisodeCollection{}, + } + err = hook.GlobalHookManager.OnAnimEpisodeCollectionRequested().Trigger(reqEvent) + if err != nil { + return nil, err + } + opts.Media = reqEvent.Media + opts.AnimeMetadata = reqEvent.Metadata + + if reqEvent.DefaultPrevented { + return reqEvent.EpisodeCollection, nil + } + + ec = &EpisodeCollection{ + HasMappingError: false, + Episodes: make([]*Episode, 0), + Metadata: opts.AnimeMetadata, + } + + // +---------------------+ + // | Download Info | + // +---------------------+ + + info, err := NewEntryDownloadInfo(&NewEntryDownloadInfoOptions{ + LocalFiles: nil, + AnimeMetadata: opts.AnimeMetadata, + Progress: lo.ToPtr(0), // Progress is 0 because we want the entire list + Status: lo.ToPtr(anilist.MediaListStatusCurrent), + Media: opts.Media, + MetadataProvider: opts.MetadataProvider, + }) + if err != nil { + opts.Logger.Error().Err(err).Msg("torrentstream: could not get media entry info") + return nil, err + } + + // As of v2.8.0, this should never happen, getMediaInfo always returns an anime metadata struct, even if it's not found + // causing NewEntryDownloadInfo to return a valid list of episodes to download + if info == nil || info.EpisodesToDownload == nil { + opts.Logger.Debug().Msg("torrentstream: no episodes found from AniDB, using AniList") + for epIdx := range opts.Media.GetCurrentEpisodeCount() { + episodeNumber := epIdx + 1 + + mediaWrapper := opts.MetadataProvider.GetAnimeMetadataWrapper(opts.Media, nil) + episodeMetadata := mediaWrapper.GetEpisodeMetadata(episodeNumber) + + episode := &Episode{ + Type: LocalFileTypeMain, + DisplayTitle: fmt.Sprintf("Episode %d", episodeNumber), + EpisodeTitle: opts.Media.GetPreferredTitle(), + EpisodeNumber: episodeNumber, + AniDBEpisode: fmt.Sprintf("%d", episodeNumber), + AbsoluteEpisodeNumber: episodeNumber, + ProgressNumber: episodeNumber, + LocalFile: nil, + IsDownloaded: false, + EpisodeMetadata: &EpisodeMetadata{ + AnidbId: 0, + Image: episodeMetadata.Image, + AirDate: "", + Length: 0, + Summary: "", + Overview: "", + IsFiller: false, + }, + FileMetadata: nil, + IsInvalid: false, + MetadataIssue: "", + BaseAnime: opts.Media, + } + ec.Episodes = append(ec.Episodes, episode) + } + ec.HasMappingError = true + return + } + + if len(info.EpisodesToDownload) == 0 { + opts.Logger.Error().Msg("torrentstream: no episodes found") + return nil, fmt.Errorf("no episodes found") + } + + ec.Episodes = lo.Map(info.EpisodesToDownload, func(episode *EntryDownloadEpisode, i int) *Episode { + return episode.Episode + }) + + slices.SortStableFunc(ec.Episodes, func(i, j *Episode) int { + return cmp.Compare(i.EpisodeNumber, j.EpisodeNumber) + }) + + event := &AnimeEpisodeCollectionEvent{ + EpisodeCollection: ec, + } + err = hook.GlobalHookManager.OnAnimeEpisodeCollection().Trigger(event) + if err != nil { + return nil, err + } + ec = event.EpisodeCollection + + episodeCollectionCache.SetT(opts.Media.ID, ec, time.Minute*10) + + return +} + +func ClearEpisodeCollectionCache() { + episodeCollectionCache.Clear() +} + +///////// + +type NewEpisodeCollectionFromLocalFilesOptions struct { + LocalFiles []*LocalFile + Media *anilist.BaseAnime + AnimeCollection *anilist.AnimeCollection + Platform platform.Platform + MetadataProvider metadata.Provider + Logger *zerolog.Logger +} + +func NewEpisodeCollectionFromLocalFiles(ctx context.Context, opts NewEpisodeCollectionFromLocalFilesOptions) (*EpisodeCollection, error) { + if opts.Logger == nil { + opts.Logger = lo.ToPtr(zerolog.Nop()) + } + + if ec, ok := EpisodeCollectionFromLocalFilesCache.Get(opts.Media.GetID()); ok { + return ec, nil + } + + // Make sure to keep the local files from the media only + opts.LocalFiles = lo.Filter(opts.LocalFiles, func(lf *LocalFile, i int) bool { + return lf.MediaId == opts.Media.GetID() + }) + + // Create a new media entry + entry, err := NewEntry(ctx, &NewEntryOptions{ + MediaId: opts.Media.GetID(), + LocalFiles: opts.LocalFiles, + AnimeCollection: opts.AnimeCollection, + Platform: opts.Platform, + MetadataProvider: opts.MetadataProvider, + }) + if err != nil { + return nil, fmt.Errorf("cannot play local file, could not create entry: %w", err) + } + + // Should be cached if it exists + animeMetadata, err := opts.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, opts.Media.ID) + if err != nil { + animeMetadata = &metadata.AnimeMetadata{ + Titles: make(map[string]string), + Episodes: make(map[string]*metadata.EpisodeMetadata), + EpisodeCount: 0, + SpecialCount: 0, + Mappings: &metadata.AnimeMappings{ + AnilistId: opts.Media.GetID(), + }, + } + animeMetadata.Titles["en"] = opts.Media.GetTitleSafe() + animeMetadata.Titles["x-jat"] = opts.Media.GetRomajiTitleSafe() + err = nil + } + + ec := &EpisodeCollection{ + HasMappingError: false, + Episodes: entry.Episodes, + Metadata: animeMetadata, + } + + EpisodeCollectionFromLocalFilesCache.SetT(opts.Media.GetID(), ec, time.Hour*6) + + return ec, nil +} + +///////// + +func (ec *EpisodeCollection) FindEpisodeByNumber(episodeNumber int) (*Episode, bool) { + for _, episode := range ec.Episodes { + if episode.EpisodeNumber == episodeNumber { + return episode, true + } + } + return nil, false +} + +func (ec *EpisodeCollection) FindEpisodeByAniDB(anidbEpisode string) (*Episode, bool) { + for _, episode := range ec.Episodes { + if episode.AniDBEpisode == anidbEpisode { + return episode, true + } + } + return nil, false +} + +// GetMainLocalFiles returns the *main* local files. +func (ec *EpisodeCollection) GetMainLocalFiles() ([]*Episode, bool) { + ret := make([]*Episode, 0) + for _, episode := range ec.Episodes { + if episode.LocalFile == nil || episode.LocalFile.IsMain() { + ret = append(ret, episode) + } + } + if len(ret) == 0 { + return nil, false + } + return ret, true +} + +// FindNextEpisode returns the *main* local file whose episode number is after the given local file. +func (ec *EpisodeCollection) FindNextEpisode(current *Episode) (*Episode, bool) { + episodes, ok := ec.GetMainLocalFiles() + if !ok { + return nil, false + } + // Get the local file whose episode number is after the given local file + var next *Episode + for _, e := range episodes { + if e.GetEpisodeNumber() == current.GetEpisodeNumber()+1 { + next = e + break + } + } + if next == nil { + return nil, false + } + return next, true +} diff --git a/seanime-2.9.10/internal/library/anime/episode_helper.go b/seanime-2.9.10/internal/library/anime/episode_helper.go new file mode 100644 index 0000000..7a72c86 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/episode_helper.go @@ -0,0 +1,28 @@ +package anime + +func (e *Episode) GetEpisodeNumber() int { + if e == nil { + return -1 + } + return e.EpisodeNumber +} +func (e *Episode) GetProgressNumber() int { + if e == nil { + return -1 + } + return e.ProgressNumber +} + +func (e *Episode) IsMain() bool { + if e == nil || e.LocalFile == nil { + return false + } + return e.LocalFile.IsMain() +} + +func (e *Episode) GetLocalFile() *LocalFile { + if e == nil { + return nil + } + return e.LocalFile +} diff --git a/seanime-2.9.10/internal/library/anime/hook_events.go b/seanime-2.9.10/internal/library/anime/hook_events.go new file mode 100644 index 0000000..eee8b03 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/hook_events.go @@ -0,0 +1,167 @@ +package anime + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/hook_resolver" +) + +///////////////////////////// +// Anime Library Events +///////////////////////////// + +// AnimeEntryRequestedEvent is triggered when an anime entry is requested. +// Prevent default to skip the default behavior and return the modified entry. +// This event is triggered before [AnimeEntryEvent]. +// If the modified entry is nil, an error will be returned. +type AnimeEntryRequestedEvent struct { + hook_resolver.Event + MediaId int `json:"mediaId"` + LocalFiles []*LocalFile `json:"localFiles"` + AnimeCollection *anilist.AnimeCollection `json:"animeCollection"` + // Empty entry object, will be used if the hook prevents the default behavior + Entry *Entry `json:"entry"` +} + +// AnimeEntryEvent is triggered when the media entry is being returned. +// This event is triggered after [AnimeEntryRequestedEvent]. +type AnimeEntryEvent struct { + hook_resolver.Event + Entry *Entry `json:"entry"` +} + +// AnimeEntryFillerHydrationEvent is triggered when the filler data is being added to the media entry. +// This event is triggered after [AnimeEntryEvent]. +// Prevent default to skip the filler data. +type AnimeEntryFillerHydrationEvent struct { + hook_resolver.Event + Entry *Entry `json:"entry"` +} + +// AnimeEntryLibraryDataRequestedEvent is triggered when the app requests the library data for a media entry. +// This is triggered before [AnimeEntryLibraryDataEvent]. +type AnimeEntryLibraryDataRequestedEvent struct { + hook_resolver.Event + EntryLocalFiles []*LocalFile `json:"entryLocalFiles"` + MediaId int `json:"mediaId"` + CurrentProgress int `json:"currentProgress"` +} + +// AnimeEntryLibraryDataEvent is triggered when the library data is being added to the media entry. +// This is triggered after [AnimeEntryLibraryDataRequestedEvent]. +type AnimeEntryLibraryDataEvent struct { + hook_resolver.Event + EntryLibraryData *EntryLibraryData `json:"entryLibraryData"` +} + +// AnimeEntryManualMatchBeforeSaveEvent is triggered when the user manually matches local files to a media entry. +// Prevent default to skip saving the local files. +type AnimeEntryManualMatchBeforeSaveEvent struct { + hook_resolver.Event + // The media ID chosen by the user + MediaId int `json:"mediaId"` + // The paths of the local files that are being matched + Paths []string `json:"paths"` + // The local files that are being matched + MatchedLocalFiles []*LocalFile `json:"matchedLocalFiles"` +} + +// MissingEpisodesRequestedEvent is triggered when the user requests the missing episodes for the entire library. +// Prevent default to skip the default process and return the modified missing episodes. +type MissingEpisodesRequestedEvent struct { + hook_resolver.Event + AnimeCollection *anilist.AnimeCollection `json:"animeCollection"` + LocalFiles []*LocalFile `json:"localFiles"` + SilencedMediaIds []int `json:"silencedMediaIds"` + // Empty missing episodes object, will be used if the hook prevents the default behavior + MissingEpisodes *MissingEpisodes `json:"missingEpisodes"` +} + +// MissingEpisodesEvent is triggered when the missing episodes are being returned. +type MissingEpisodesEvent struct { + hook_resolver.Event + MissingEpisodes *MissingEpisodes `json:"missingEpisodes"` +} + +///////////////////////////// +// Anime Collection Events +///////////////////////////// + +// AnimeLibraryCollectionRequestedEvent is triggered when the user requests the library collection. +// Prevent default to skip the default process and return the modified library collection. +// If the modified library collection is nil, an error will be returned. +type AnimeLibraryCollectionRequestedEvent struct { + hook_resolver.Event + AnimeCollection *anilist.AnimeCollection `json:"animeCollection"` + LocalFiles []*LocalFile `json:"localFiles"` + // Empty library collection object, will be used if the hook prevents the default behavior + LibraryCollection *LibraryCollection `json:"libraryCollection"` +} + +// AnimeLibraryCollectionEvent is triggered when the user requests the library collection. +type AnimeLibraryCollectionEvent struct { + hook_resolver.Event + LibraryCollection *LibraryCollection `json:"libraryCollection"` +} + +// AnimeLibraryStreamCollectionRequestedEvent is triggered when the user requests the library stream collection. +// This is called when the user enables "Include in library" for either debrid/online/torrent streamings. +type AnimeLibraryStreamCollectionRequestedEvent struct { + hook_resolver.Event + AnimeCollection *anilist.AnimeCollection `json:"animeCollection"` + LibraryCollection *LibraryCollection `json:"libraryCollection"` +} + +// AnimeLibraryStreamCollectionEvent is triggered when the library stream collection is being returned. +type AnimeLibraryStreamCollectionEvent struct { + hook_resolver.Event + StreamCollection *StreamCollection `json:"streamCollection"` +} + +//////////////////////////////////////// + +// AnimeEntryDownloadInfoRequestedEvent is triggered when the app requests the download info for a media entry. +// This is triggered before [AnimeEntryDownloadInfoEvent]. +type AnimeEntryDownloadInfoRequestedEvent struct { + hook_resolver.Event + LocalFiles []*LocalFile `json:"localFiles"` + AnimeMetadata *metadata.AnimeMetadata + Media *anilist.BaseAnime + Progress *int + Status *anilist.MediaListStatus + // Empty download info object, will be used if the hook prevents the default behavior + EntryDownloadInfo *EntryDownloadInfo `json:"entryDownloadInfo"` +} + +// AnimeEntryDownloadInfoEvent is triggered when the download info is being returned. +type AnimeEntryDownloadInfoEvent struct { + hook_resolver.Event + EntryDownloadInfo *EntryDownloadInfo `json:"entryDownloadInfo"` +} + +///////////////////////////////////// + +// AnimeEpisodeCollectionRequestedEvent is triggered when the episode collection is being requested. +// Prevent default to skip the default behavior and return your own data. +type AnimeEpisodeCollectionRequestedEvent struct { + hook_resolver.Event + Media *anilist.BaseAnime `json:"media"` + Metadata *metadata.AnimeMetadata `json:"metadata"` + // Empty episode collection object, will be used if the hook prevents the default behavior + EpisodeCollection *EpisodeCollection `json:"episodeCollection"` +} + +// AnimeEpisodeCollectionEvent is triggered when the episode collection is being returned. +type AnimeEpisodeCollectionEvent struct { + hook_resolver.Event + EpisodeCollection *EpisodeCollection `json:"episodeCollection"` +} + +///////////////////////////////////// + +// AnimeScheduleItemsEvent is triggered when the schedule items are being returned. +type AnimeScheduleItemsEvent struct { + hook_resolver.Event + AnimeCollection *anilist.AnimeCollection `json:"animeCollection"` + Items []*ScheduleItem `json:"items"` +} diff --git a/seanime-2.9.10/internal/library/anime/localfile.go b/seanime-2.9.10/internal/library/anime/localfile.go new file mode 100644 index 0000000..2fc9086 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/localfile.go @@ -0,0 +1,139 @@ +package anime + +import ( + "seanime/internal/library/filesystem" + + "github.com/5rahim/habari" +) + +const ( + LocalFileTypeMain LocalFileType = "main" // Main episodes that are trackable + LocalFileTypeSpecial LocalFileType = "special" // OVA, ONA, etc. + LocalFileTypeNC LocalFileType = "nc" // Opening, ending, etc. +) + +type ( + LocalFileType string + // LocalFile represents a media file on the local filesystem. + // It is used to store information about and state of the file, such as its path, name, and parsed data. + LocalFile struct { + Path string `json:"path"` + Name string `json:"name"` + ParsedData *LocalFileParsedData `json:"parsedInfo"` + ParsedFolderData []*LocalFileParsedData `json:"parsedFolderInfo"` + Metadata *LocalFileMetadata `json:"metadata"` + Locked bool `json:"locked"` + Ignored bool `json:"ignored"` // Unused for now + MediaId int `json:"mediaId"` + } + + // LocalFileMetadata holds metadata related to a media episode. + LocalFileMetadata struct { + Episode int `json:"episode"` + AniDBEpisode string `json:"aniDBEpisode"` + Type LocalFileType `json:"type"` + } + + // LocalFileParsedData holds parsed data from a media file's name. + // This data is used to identify the media file during the scanning process. + LocalFileParsedData struct { + Original string `json:"original"` + Title string `json:"title,omitempty"` + ReleaseGroup string `json:"releaseGroup,omitempty"` + Season string `json:"season,omitempty"` + SeasonRange []string `json:"seasonRange,omitempty"` + Part string `json:"part,omitempty"` + PartRange []string `json:"partRange,omitempty"` + Episode string `json:"episode,omitempty"` + EpisodeRange []string `json:"episodeRange,omitempty"` + EpisodeTitle string `json:"episodeTitle,omitempty"` + Year string `json:"year,omitempty"` + } +) + +// NewLocalFileS creates and returns a reference to a new LocalFile struct. +// It will parse the file's name and its directory names to extract necessary information. +// - opath: The full path to the file. +// - dirPaths: The full paths to the directories that may contain the file. (Library root paths) +func NewLocalFileS(opath string, dirPaths []string) *LocalFile { + info := filesystem.SeparateFilePathS(opath, dirPaths) + return newLocalFile(opath, info) +} + +// NewLocalFile creates and returns a reference to a new LocalFile struct. +// It will parse the file's name and its directory names to extract necessary information. +// - opath: The full path to the file. +// - dirPath: The full path to the directory containing the file. (The library root path) +func NewLocalFile(opath, dirPath string) *LocalFile { + info := filesystem.SeparateFilePath(opath, dirPath) + return newLocalFile(opath, info) +} + +func newLocalFile(opath string, info *filesystem.SeparatedFilePath) *LocalFile { + // Parse filename + fElements := habari.Parse(info.Filename) + parsedInfo := NewLocalFileParsedData(info.Filename, fElements) + + // Parse dir names + parsedFolderInfo := make([]*LocalFileParsedData, 0) + for _, dirname := range info.Dirnames { + if len(dirname) > 0 { + pElements := habari.Parse(dirname) + parsed := NewLocalFileParsedData(dirname, pElements) + parsedFolderInfo = append(parsedFolderInfo, parsed) + } + } + + localFile := &LocalFile{ + Path: opath, + Name: info.Filename, + ParsedData: parsedInfo, + ParsedFolderData: parsedFolderInfo, + Metadata: &LocalFileMetadata{ + Episode: 0, + AniDBEpisode: "", + Type: "", + }, + Locked: false, + Ignored: false, + MediaId: 0, + } + + return localFile +} + +// NewLocalFileParsedData Converts habari.Metadata into LocalFileParsedData, which is more suitable. +func NewLocalFileParsedData(original string, elements *habari.Metadata) *LocalFileParsedData { + i := new(LocalFileParsedData) + i.Original = original + i.Title = elements.FormattedTitle + i.ReleaseGroup = elements.ReleaseGroup + i.EpisodeTitle = elements.EpisodeTitle + i.Year = elements.Year + + if len(elements.SeasonNumber) > 0 { + if len(elements.SeasonNumber) == 1 { + i.Season = elements.SeasonNumber[0] + } else { + i.SeasonRange = elements.SeasonNumber + } + } + + if len(elements.EpisodeNumber) > 0 { + if len(elements.EpisodeNumber) == 1 { + i.Episode = elements.EpisodeNumber[0] + } else { + i.EpisodeRange = elements.EpisodeNumber + } + } + + if len(elements.PartNumber) > 0 { + if len(elements.PartNumber) == 1 { + i.Part = elements.PartNumber[0] + } else { + i.PartRange = elements.PartNumber + } + } + + return i +} diff --git a/seanime-2.9.10/internal/library/anime/localfile_helper.go b/seanime-2.9.10/internal/library/anime/localfile_helper.go new file mode 100644 index 0000000..ae98e5f --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/localfile_helper.go @@ -0,0 +1,448 @@ +package anime + +import ( + "bytes" + "fmt" + "path/filepath" + "seanime/internal/util" + "seanime/internal/util/comparison" + "slices" + "strconv" + "strings" + + "github.com/samber/lo" + lop "github.com/samber/lo/parallel" +) + +//---------------------------------------------------------------------------------------------------------------------- + +func (f *LocalFile) IsParsedEpisodeValid() bool { + if f == nil || f.ParsedData == nil { + return false + } + return len(f.ParsedData.Episode) > 0 +} + +// GetEpisodeNumber returns the metadata episode number. +// This requires the LocalFile to be hydrated. +func (f *LocalFile) GetEpisodeNumber() int { + if f.Metadata == nil { + return -1 + } + return f.Metadata.Episode +} + +func (f *LocalFile) GetParsedEpisodeTitle() string { + if f.ParsedData == nil { + return "" + } + return f.ParsedData.EpisodeTitle +} + +// HasBeenWatched returns whether the episode has been watched. +// This only applies to main episodes. +func (f *LocalFile) HasBeenWatched(progress int) bool { + if f.Metadata == nil { + return false + } + if f.GetEpisodeNumber() == 0 && progress == 0 { + return false + } + return progress >= f.GetEpisodeNumber() +} + +// GetType returns the metadata type. +// This requires the LocalFile to be hydrated. +func (f *LocalFile) GetType() LocalFileType { + return f.Metadata.Type +} + +// IsMain returns true if the metadata type is LocalFileTypeMain +func (f *LocalFile) IsMain() bool { + return f.Metadata.Type == LocalFileTypeMain +} + +// GetMetadata returns the file metadata. +// This requires the LocalFile to be hydrated. +func (f *LocalFile) GetMetadata() *LocalFileMetadata { + return f.Metadata +} + +// GetAniDBEpisode returns the metadata AniDB episode number. +// This requires the LocalFile to be hydrated. +func (f *LocalFile) GetAniDBEpisode() string { + return f.Metadata.AniDBEpisode +} + +func (f *LocalFile) IsLocked() bool { + return f.Locked +} + +func (f *LocalFile) IsIgnored() bool { + return f.Ignored +} + +// GetNormalizedPath returns the lowercase path of the LocalFile. +// Use this for comparison. +func (f *LocalFile) GetNormalizedPath() string { + return util.NormalizePath(f.Path) +} + +func (f *LocalFile) GetPath() string { + return f.Path +} + +func (f *LocalFile) HasSamePath(path string) bool { + return f.GetNormalizedPath() == util.NormalizePath(path) +} + +// IsInDir returns true if the LocalFile is in the given directory. +func (f *LocalFile) IsInDir(dirPath string) bool { + dirPath = util.NormalizePath(dirPath) + if !filepath.IsAbs(dirPath) { + return false + } + return strings.HasPrefix(f.GetNormalizedPath(), dirPath) +} + +// IsAtRootOf returns true if the LocalFile is at the root of the given directory. +func (f *LocalFile) IsAtRootOf(dirPath string) bool { + dirPath = strings.TrimSuffix(util.NormalizePath(dirPath), "/") + return filepath.ToSlash(filepath.Dir(f.GetNormalizedPath())) == dirPath +} + +func (f *LocalFile) Equals(lf *LocalFile) bool { + return util.NormalizePath(f.Path) == util.NormalizePath(lf.Path) +} + +func (f *LocalFile) IsIncluded(lfs []*LocalFile) bool { + for _, lf := range lfs { + if f.Equals(lf) { + return true + } + } + return false +} + +//---------------------------------------------------------------------------------------------------------------------- + +// buildTitle concatenates the given strings into a single string. +func buildTitle(vals ...string) string { + buf := bytes.NewBuffer([]byte{}) + for i, v := range vals { + buf.WriteString(v) + if i != len(vals)-1 { + buf.WriteString(" ") + } + } + return buf.String() +} + +// GetUniqueAnimeTitlesFromLocalFiles returns all parsed anime titles without duplicates, from a slice of LocalFile's. +func GetUniqueAnimeTitlesFromLocalFiles(lfs []*LocalFile) []string { + // Concurrently get title from each local file + titles := lop.Map(lfs, func(file *LocalFile, index int) string { + title := file.GetParsedTitle() + // Some rudimentary exclusions + for _, i := range []string{"SPECIALS", "SPECIAL", "EXTRA", "NC", "OP", "MOVIE", "MOVIES"} { + if strings.ToUpper(title) == i { + return "" + } + } + return title + }) + // Keep unique title and filter out empty ones + titles = lo.Filter(lo.Uniq(titles), func(item string, index int) bool { + return len(item) > 0 + }) + return titles +} + +// GetMediaIdsFromLocalFiles returns all media ids from a slice of LocalFile's. +func GetMediaIdsFromLocalFiles(lfs []*LocalFile) []int { + + // Group local files by media id + groupedLfs := GroupLocalFilesByMediaID(lfs) + + // Get slice of media ids from local files + mIds := make([]int, len(groupedLfs)) + for key := range groupedLfs { + if !slices.Contains(mIds, key) { + mIds = append(mIds, key) + } + } + + return mIds + +} + +// GetLocalFilesFromMediaId returns all local files with the given media id. +func GetLocalFilesFromMediaId(lfs []*LocalFile, mId int) []*LocalFile { + + return lo.Filter(lfs, func(item *LocalFile, _ int) bool { + return item.MediaId == mId + }) + +} + +// GroupLocalFilesByMediaID returns a map of media id to local files. +func GroupLocalFilesByMediaID(lfs []*LocalFile) (groupedLfs map[int][]*LocalFile) { + groupedLfs = lop.GroupBy(lfs, func(item *LocalFile) int { + return item.MediaId + }) + + return +} + +// IsLocalFileGroupValidEntry checks if there are any main episodes with valid episodes +func IsLocalFileGroupValidEntry(lfs []*LocalFile) bool { + // Check if there are any main episodes with valid parsed data + flag := false + for _, lf := range lfs { + if lf.GetType() == LocalFileTypeMain && lf.IsParsedEpisodeValid() { + flag = true + break + } + } + return flag +} + +// FindLatestLocalFileFromGroup returns the "main" episode with the highest episode number. +// Returns false if there are no episodes. +func FindLatestLocalFileFromGroup(lfs []*LocalFile) (*LocalFile, bool) { + // Check if there are any main episodes with valid parsed data + if !IsLocalFileGroupValidEntry(lfs) { + return nil, false + } + if lfs == nil || len(lfs) == 0 { + return nil, false + } + // Get the episode with the highest progress number + latest, found := lo.Find(lfs, func(lf *LocalFile) bool { + return lf.GetType() == LocalFileTypeMain && lf.IsParsedEpisodeValid() + }) + if !found { + return nil, false + } + for _, lf := range lfs { + if lf.GetType() == LocalFileTypeMain && lf.GetEpisodeNumber() > latest.GetEpisodeNumber() { + latest = lf + } + } + if latest == nil || latest.GetType() != LocalFileTypeMain { + return nil, false + } + return latest, true +} + +func (f *LocalFile) GetParsedData() *LocalFileParsedData { + return f.ParsedData +} + +// GetParsedTitle returns the parsed title of the LocalFile. Falls back to the folder title if the file title is empty. +func (f *LocalFile) GetParsedTitle() string { + if len(f.ParsedData.Title) > 0 { + return f.ParsedData.Title + } + if len(f.GetFolderTitle()) > 0 { + return f.GetFolderTitle() + } + return "" +} + +func (f *LocalFile) GetFolderTitle() string { + folderTitles := make([]string, 0) + if f.ParsedFolderData != nil && len(f.ParsedFolderData) > 0 { + // Go through each folder data and keep the ones with a title + data := lo.Filter(f.ParsedFolderData, func(fpd *LocalFileParsedData, _ int) bool { + return len(fpd.Title) > 0 + }) + if len(data) == 0 { + return "" + } + // Get the titles + for _, v := range data { + folderTitles = append(folderTitles, v.Title) + } + // If there are multiple titles, return the one closest to the end + return folderTitles[len(folderTitles)-1] + } + + return "" +} + +// GetTitleVariations is used for matching. +func (f *LocalFile) GetTitleVariations() []*string { + + folderSeason := 0 + + // Get the season from the folder data + if f.ParsedFolderData != nil && len(f.ParsedFolderData) > 0 { + v, found := lo.Find(f.ParsedFolderData, func(fpd *LocalFileParsedData) bool { + return len(fpd.Season) > 0 + }) + if found { + if res, ok := util.StringToInt(v.Season); ok { + folderSeason = res + } + } + } + + // Get the season from the filename + season := 0 + if len(f.ParsedData.Season) > 0 { + if res, ok := util.StringToInt(f.ParsedData.Season); ok { + season = res + } + } + + part := 0 + + // Get the part from the folder data + if f.ParsedFolderData != nil && len(f.ParsedFolderData) > 0 { + v, found := lo.Find(f.ParsedFolderData, func(fpd *LocalFileParsedData) bool { + return len(fpd.Part) > 0 + }) + if found { + if res, ok := util.StringToInt(v.Season); ok { + part = res + } + } + } + + // Devnote: This causes issues when an episode title contains "Part" + //// Get the part from the filename + //if len(f.ParsedData.Part) > 0 { + // if res, ok := util.StringToInt(f.ParsedData.Part); ok { + // part = res + // } + //} + + folderTitle := f.GetFolderTitle() + + if comparison.ValueContainsIgnoredKeywords(folderTitle) { + folderTitle = "" + } + + if len(f.ParsedData.Title) == 0 && len(folderTitle) == 0 { + return make([]*string, 0) + } + + titleVariations := make([]string, 0) + + bothTitles := len(f.ParsedData.Title) > 0 && len(folderTitle) > 0 // Both titles are present (filename and folder) + noSeasonsOrParts := folderSeason == 0 && season == 0 && part == 0 // No seasons or parts are present + bothTitlesSimilar := bothTitles && strings.Contains(folderTitle, f.ParsedData.Title) // The folder title contains the filename title + eitherSeason := folderSeason > 0 || season > 0 // Either season is present + eitherSeasonFirst := folderSeason == 1 || season == 1 // Either season is 1 + + // Part + if part > 0 { + if len(folderTitle) > 0 { + titleVariations = append(titleVariations, + buildTitle(folderTitle, "Part", strconv.Itoa(part)), + buildTitle(folderTitle, "Part", util.IntegerToOrdinal(part)), + buildTitle(folderTitle, "Cour", strconv.Itoa(part)), + buildTitle(folderTitle, "Cour", util.IntegerToOrdinal(part)), + ) + } + if len(f.ParsedData.Title) > 0 { + titleVariations = append(titleVariations, + buildTitle(f.ParsedData.Title, "Part", strconv.Itoa(part)), + buildTitle(f.ParsedData.Title, "Part", util.IntegerToOrdinal(part)), + buildTitle(f.ParsedData.Title, "Cour", strconv.Itoa(part)), + buildTitle(f.ParsedData.Title, "Cour", util.IntegerToOrdinal(part)), + ) + } + } + + // Title, no seasons, no parts, or season 1 + // e.g. "Bungou Stray Dogs" + // e.g. "Bungou Stray Dogs Season 1" + if noSeasonsOrParts || eitherSeasonFirst { + if len(f.ParsedData.Title) > 0 { // Add filename title + titleVariations = append(titleVariations, f.ParsedData.Title) + } + if len(folderTitle) > 0 { // Both titles are present and similar, add folder title + titleVariations = append(titleVariations, folderTitle) + } + } + + // Part & Season + // e.g. "Spy x Family Season 1 Part 2" + if part > 0 && eitherSeason { + if len(folderTitle) > 0 { + if season > 0 { + titleVariations = append(titleVariations, + buildTitle(folderTitle, "Season", strconv.Itoa(season), "Part", strconv.Itoa(part)), + ) + } else if folderSeason > 0 { + titleVariations = append(titleVariations, + buildTitle(folderTitle, "Season", strconv.Itoa(folderSeason), "Part", strconv.Itoa(part)), + ) + } + } + if len(f.ParsedData.Title) > 0 { + if season > 0 { + titleVariations = append(titleVariations, + buildTitle(f.ParsedData.Title, "Season", strconv.Itoa(season), "Part", strconv.Itoa(part)), + ) + } else if folderSeason > 0 { + titleVariations = append(titleVariations, + buildTitle(f.ParsedData.Title, "Season", strconv.Itoa(folderSeason), "Part", strconv.Itoa(part)), + ) + } + } + } + + // Season is present + if eitherSeason { + arr := make([]string, 0) + + seas := folderSeason // Default to folder parsed season + if season > 0 { // Use filename parsed season if present + seas = season + } + + // Both titles are present + if bothTitles { + // Add both titles + arr = append(arr, f.ParsedData.Title) + arr = append(arr, folderTitle) + if !bothTitlesSimilar { // Combine both titles if they are not similar + arr = append(arr, fmt.Sprintf("%s %s", folderTitle, f.ParsedData.Title)) + } + } else if len(folderTitle) > 0 { // Only folder title is present + + arr = append(arr, folderTitle) + + } else if len(f.ParsedData.Title) > 0 { // Only filename title is present + + arr = append(arr, f.ParsedData.Title) + + } + + for _, t := range arr { + titleVariations = append(titleVariations, + buildTitle(t, "Season", strconv.Itoa(seas)), + buildTitle(t, "S"+strconv.Itoa(seas)), + buildTitle(t, util.IntegerToOrdinal(seas), "Season"), + ) + } + } + + titleVariations = lo.Uniq(titleVariations) + + // If there are no title variations, use the folder title or the parsed title + if len(titleVariations) == 0 { + if len(folderTitle) > 0 { + titleVariations = append(titleVariations, folderTitle) + } + if len(f.ParsedData.Title) > 0 { + titleVariations = append(titleVariations, f.ParsedData.Title) + } + } + + return lo.ToSlicePtr(titleVariations) + +} diff --git a/seanime-2.9.10/internal/library/anime/localfile_helper_test.go b/seanime-2.9.10/internal/library/anime/localfile_helper_test.go new file mode 100644 index 0000000..6a2bac1 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/localfile_helper_test.go @@ -0,0 +1,329 @@ +package anime_test + +import ( + "github.com/davecgh/go-spew/spew" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "path/filepath" + "seanime/internal/library/anime" + "seanime/internal/util" + "strings" + "testing" +) + +func TestLocalFile_GetNormalizedPath(t *testing.T) { + + tests := []struct { + filePath string + libraryPath string + expectedResult string + }{ + { + filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + libraryPath: "E:/ANIME", + expectedResult: "e:/anime/bungou stray dogs 5th season/bungou stray dogs/[subsplease] bungou stray dogs - 61 (1080p) [f609b947].mkv", + }, + { + filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv", + libraryPath: "E:/ANIME", + expectedResult: "e:/anime/shakugan no shana/shakugan no shana i/opening/op01.mkv", + }, + } + + for _, tt := range tests { + t.Run(tt.filePath, func(t *testing.T) { + lf := anime.NewLocalFile(tt.filePath, tt.libraryPath) + + if assert.NotNil(t, lf) { + + if assert.Equal(t, tt.expectedResult, lf.GetNormalizedPath()) { + spew.Dump(lf.GetNormalizedPath()) + } + } + + }) + } + +} + +func TestLocalFile_IsInDir(t *testing.T) { + + tests := []struct { + filePath string + libraryPath string + dir string + expectedResult bool + }{ + { + filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + libraryPath: "E:/ANIME", + dir: "E:/ANIME/Bungou Stray Dogs 5th Season", + expectedResult: true, + }, + { + filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv", + libraryPath: "E:/ANIME", + dir: "E:/ANIME/Shakugan No Shana", + expectedResult: true, + }, + { + filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv", + libraryPath: "E:/ANIME", + dir: "E:/ANIME/Shakugan No Shana I", + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.filePath, func(t *testing.T) { + lf := anime.NewLocalFile(tt.filePath, tt.libraryPath) + + if assert.NotNil(t, lf) { + + if assert.Equal(t, tt.expectedResult, lf.IsInDir(tt.dir)) { + spew.Dump(lf.IsInDir(tt.dir)) + } + } + + }) + } + +} + +func TestLocalFile_IsAtRootOf(t *testing.T) { + + tests := []struct { + filePath string + libraryPath string + dir string + expectedResult bool + }{ + { + filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + libraryPath: "E:/ANIME", + dir: "E:/ANIME/Bungou Stray Dogs 5th Season", + expectedResult: false, + }, + { + filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv", + libraryPath: "E:/ANIME", + dir: "E:/ANIME/Shakugan No Shana", + expectedResult: false, + }, + { + filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv", + libraryPath: "E:/ANIME", + dir: "E:/ANIME/Shakugan No Shana/Shakugan No Shana I/Opening", + expectedResult: true, + }, + } + + for _, tt := range tests { + t.Run(tt.filePath, func(t *testing.T) { + lf := anime.NewLocalFile(tt.filePath, tt.libraryPath) + + if assert.NotNil(t, lf) { + + if !assert.Equal(t, tt.expectedResult, lf.IsAtRootOf(tt.dir)) { + t.Log(filepath.Dir(lf.GetNormalizedPath())) + t.Log(strings.TrimSuffix(util.NormalizePath(tt.dir), "/")) + } + } + + }) + + } + +} + +func TestLocalFile_Equals(t *testing.T) { + + tests := []struct { + filePath1 string + filePath2 string + libraryPath string + expectedResult bool + }{ + { + filePath1: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + filePath2: "E:/ANIME/Bungou Stray Dogs 5th Season/Bungou Stray Dogs/[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + libraryPath: "E:/Anime", + expectedResult: true, + }, + { + filePath1: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + filePath2: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 62 (1080p) [F609B947].mkv", + libraryPath: "E:/ANIME", + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.filePath1, func(t *testing.T) { + lf1 := anime.NewLocalFile(tt.filePath1, tt.libraryPath) + lf2 := anime.NewLocalFile(tt.filePath2, tt.libraryPath) + + if assert.NotNil(t, lf1) && assert.NotNil(t, lf2) { + assert.Equal(t, tt.expectedResult, lf1.Equals(lf2)) + } + + }) + + } + +} + +func TestLocalFile_GetTitleVariations(t *testing.T) { + + tests := []struct { + filePath string + libraryPath string + expectedTitles []string + }{ + { + filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + libraryPath: "E:/ANIME", + expectedTitles: []string{ + "Bungou Stray Dogs 5th Season", + "Bungou Stray Dogs Season 5", + "Bungou Stray Dogs S5", + }, + }, + { + filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv", + libraryPath: "E:/ANIME", + expectedTitles: []string{ + "Shakugan No Shana I", + }, + }, + { + filePath: "E:\\ANIME\\Neon Genesis Evangelion Death & Rebirth\\[Anime Time] Neon Genesis Evangelion - Rebirth.mkv", + libraryPath: "E:/ANIME", + expectedTitles: []string{ + "Neon Genesis Evangelion - Rebirth", + "Neon Genesis Evangelion Death & Rebirth", + }, + }, + { + filePath: "E:\\ANIME\\Omoi, Omoware, Furi, Furare\\[GJM] Love Me, Love Me Not (BD 1080p) [841C23CD].mkv", + libraryPath: "E:/ANIME", + expectedTitles: []string{ + "Love Me, Love Me Not", + "Omoi, Omoware, Furi, Furare", + }, + }, + { + filePath: "E:\\ANIME\\Violet Evergarden Gaiden Eien to Jidou Shuki Ningyou\\Violet.Evergarden.Gaiden.2019.1080..Dual.Audio.BDRip.10.bits.DD.x265-EMBER.mkv", + libraryPath: "E:/ANIME", + expectedTitles: []string{ + "Violet Evergarden Gaiden Eien to Jidou Shuki Ningyou", + "Violet Evergarden Gaiden 2019", + }, + }, + { + filePath: "E:\\ANIME\\Violet Evergarden S01+Movies+OVA 1080p Dual Audio BDRip 10 bits DD x265-EMBER\\01. Season 1 + OVA\\S01E01-'I Love You' and Auto Memory Dolls [F03E1F7A].mkv", + libraryPath: "E:/ANIME", + expectedTitles: []string{ + "Violet Evergarden", + "Violet Evergarden S1", + "Violet Evergarden Season 1", + "Violet Evergarden 1st Season", + }, + }, + { + filePath: "E:\\ANIME\\Golden Kamuy 4th Season\\[Judas] Golden Kamuy (Season 4) [1080p][HEVC x265 10bit][Multi-Subs]\\[Judas] Golden Kamuy - S04E01.mkv", + libraryPath: "E:/ANIME", + expectedTitles: []string{ + "Golden Kamuy S4", + "Golden Kamuy Season 4", + "Golden Kamuy 4th Season", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.filePath, func(t *testing.T) { + lf := anime.NewLocalFile(tt.filePath, tt.libraryPath) + + if assert.NotNil(t, lf) { + tv := lo.Map(lf.GetTitleVariations(), func(item *string, _ int) string { return *item }) + + if assert.ElementsMatch(t, tt.expectedTitles, tv) { + spew.Dump(lf.GetTitleVariations()) + } + } + + }) + } + +} + +func TestLocalFile_GetParsedTitle(t *testing.T) { + + tests := []struct { + filePath string + libraryPath string + expectedParsedTitle string + }{ + { + filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\Bungou Stray Dogs\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + libraryPath: "E:/ANIME", + expectedParsedTitle: "Bungou Stray Dogs", + }, + { + filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv", + libraryPath: "E:/ANIME", + expectedParsedTitle: "Shakugan No Shana I", + }, + } + + for _, tt := range tests { + t.Run(tt.filePath, func(t *testing.T) { + lf := anime.NewLocalFile(tt.filePath, tt.libraryPath) + + if assert.NotNil(t, lf) { + + if assert.Equal(t, tt.expectedParsedTitle, lf.GetParsedTitle()) { + spew.Dump(lf.GetParsedTitle()) + } + } + + }) + } + +} + +func TestLocalFile_GetFolderTitle(t *testing.T) { + + tests := []struct { + filePath string + libraryPath string + expectedFolderTitle string + }{ + { + filePath: "E:\\Anime\\Bungou Stray Dogs 5th Season\\S05E11 - Episode Title.mkv", + libraryPath: "E:/ANIME", + expectedFolderTitle: "Bungou Stray Dogs", + }, + { + filePath: "E:\\Anime\\Shakugan No Shana\\Shakugan No Shana I\\Opening\\OP01.mkv", + libraryPath: "E:/ANIME", + expectedFolderTitle: "Shakugan No Shana I", + }, + } + + for _, tt := range tests { + t.Run(tt.filePath, func(t *testing.T) { + lf := anime.NewLocalFile(tt.filePath, tt.libraryPath) + + if assert.NotNil(t, lf) { + + if assert.Equal(t, tt.expectedFolderTitle, lf.GetFolderTitle()) { + spew.Dump(lf.GetFolderTitle()) + } + } + + }) + } + +} diff --git a/seanime-2.9.10/internal/library/anime/localfile_test.go b/seanime-2.9.10/internal/library/anime/localfile_test.go new file mode 100644 index 0000000..9c0ce7f --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/localfile_test.go @@ -0,0 +1,59 @@ +package anime_test + +import ( + "runtime" + "seanime/internal/library/anime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewLocalFile(t *testing.T) { + + tests := []struct { + path string + libraryPath string + expectedNbFolders int + expectedFilename string + os string + }{ + { + path: "E:\\Anime\\Bungou Stray Dogs 5th Season\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + libraryPath: "E:\\Anime", + expectedFilename: "[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + expectedNbFolders: 1, + os: "windows", + }, + { + path: "E:\\Anime\\Bungou Stray Dogs 5th Season\\[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + libraryPath: "E:/ANIME", + expectedFilename: "[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + expectedNbFolders: 1, + os: "windows", + }, + { + path: "/mnt/Anime/Bungou Stray Dogs/Bungou Stray Dogs 5th Season/[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + libraryPath: "/mnt/Anime", + expectedFilename: "[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + expectedNbFolders: 2, + os: "", + }, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + if tt.os != "" { + if tt.os != runtime.GOOS { + t.Skipf("skipping test for %s", tt.path) + } + } + + lf := anime.NewLocalFile(tt.path, tt.libraryPath) + + if assert.NotNil(t, lf) { + assert.Equal(t, tt.expectedNbFolders, len(lf.ParsedFolderData)) + assert.Equal(t, tt.expectedFilename, lf.Name) + } + }) + } +} diff --git a/seanime-2.9.10/internal/library/anime/localfile_wrapper.go b/seanime-2.9.10/internal/library/anime/localfile_wrapper.go new file mode 100644 index 0000000..ab3d7e3 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/localfile_wrapper.go @@ -0,0 +1,207 @@ +package anime + +import ( + "cmp" + "slices" +) + +type ( + // LocalFileWrapper takes a slice of LocalFiles and provides helper methods. + LocalFileWrapper struct { + LocalFiles []*LocalFile `json:"localFiles"` + LocalEntries []*LocalFileWrapperEntry `json:"localEntries"` + UnmatchedLocalFiles []*LocalFile `json:"unmatchedLocalFiles"` + } + + LocalFileWrapperEntry struct { + MediaId int `json:"mediaId"` + LocalFiles []*LocalFile `json:"localFiles"` + } +) + +// NewLocalFileWrapper creates and returns a reference to a new LocalFileWrapper +func NewLocalFileWrapper(lfs []*LocalFile) *LocalFileWrapper { + lfw := &LocalFileWrapper{ + LocalFiles: lfs, + LocalEntries: make([]*LocalFileWrapperEntry, 0), + UnmatchedLocalFiles: make([]*LocalFile, 0), + } + + // Group local files by media id + groupedLfs := GroupLocalFilesByMediaID(lfs) + for mId, gLfs := range groupedLfs { + if mId == 0 { + lfw.UnmatchedLocalFiles = gLfs + continue + } + lfw.LocalEntries = append(lfw.LocalEntries, &LocalFileWrapperEntry{ + MediaId: mId, + LocalFiles: gLfs, + }) + } + + return lfw +} + +func (lfw *LocalFileWrapper) GetLocalEntryById(mId int) (*LocalFileWrapperEntry, bool) { + for _, me := range lfw.LocalEntries { + if me.MediaId == mId { + return me, true + } + } + return nil, false +} + +// GetMainLocalFiles returns the *main* local files. +func (e *LocalFileWrapperEntry) GetMainLocalFiles() ([]*LocalFile, bool) { + lfs := make([]*LocalFile, 0) + for _, lf := range e.LocalFiles { + if lf.IsMain() { + lfs = append(lfs, lf) + } + } + if len(lfs) == 0 { + return nil, false + } + return lfs, true +} + +// GetUnwatchedLocalFiles returns the *main* local files that have not been watched. +// It returns an empty slice if all local files have been watched. +// +// /!\ IF Episode 0 is present, progress will be decremented by 1. This is because we assume AniList includes the episode 0 in the total count. +func (e *LocalFileWrapperEntry) GetUnwatchedLocalFiles(progress int) []*LocalFile { + ret := make([]*LocalFile, 0) + lfs, ok := e.GetMainLocalFiles() + if !ok { + return ret + } + + for _, lf := range lfs { + if lf.GetEpisodeNumber() == 0 { + progress = progress - 1 + break + } + } + + for _, lf := range lfs { + if lf.GetEpisodeNumber() > progress { + ret = append(ret, lf) + } + } + + return ret +} + +// GetFirstUnwatchedLocalFiles is like GetUnwatchedLocalFiles but returns local file with the lowest episode number. +func (e *LocalFileWrapperEntry) GetFirstUnwatchedLocalFiles(progress int) (*LocalFile, bool) { + lfs := e.GetUnwatchedLocalFiles(progress) + if len(lfs) == 0 { + return nil, false + } + // Sort local files by episode number + slices.SortStableFunc(lfs, func(a, b *LocalFile) int { + return cmp.Compare(a.GetEpisodeNumber(), b.GetEpisodeNumber()) + }) + return lfs[0], true +} + +// HasMainLocalFiles returns true if there are any *main* local files. +func (e *LocalFileWrapperEntry) HasMainLocalFiles() bool { + for _, lf := range e.LocalFiles { + if lf.IsMain() { + return true + } + } + return false +} + +// FindLocalFileWithEpisodeNumber returns the *main* local file with the given episode number. +func (e *LocalFileWrapperEntry) FindLocalFileWithEpisodeNumber(ep int) (*LocalFile, bool) { + for _, lf := range e.LocalFiles { + if !lf.IsMain() { + continue + } + if lf.GetEpisodeNumber() == ep { + return lf, true + } + } + return nil, false +} + +// FindLatestLocalFile returns the *main* local file with the highest episode number. +func (e *LocalFileWrapperEntry) FindLatestLocalFile() (*LocalFile, bool) { + lfs, ok := e.GetMainLocalFiles() + if !ok { + return nil, false + } + // Get the local file with the highest episode number + latest := lfs[0] + for _, lf := range lfs { + if lf.GetEpisodeNumber() > latest.GetEpisodeNumber() { + latest = lf + } + } + return latest, true +} + +// FindNextEpisode returns the *main* local file whose episode number is after the given local file. +func (e *LocalFileWrapperEntry) FindNextEpisode(lf *LocalFile) (*LocalFile, bool) { + lfs, ok := e.GetMainLocalFiles() + if !ok { + return nil, false + } + // Get the local file whose episode number is after the given local file + var next *LocalFile + for _, l := range lfs { + if l.GetEpisodeNumber() == lf.GetEpisodeNumber()+1 { + next = l + break + } + } + if next == nil { + return nil, false + } + return next, true +} + +// GetProgressNumber returns the progress number of a **main** local file. +func (e *LocalFileWrapperEntry) GetProgressNumber(lf *LocalFile) int { + lfs, ok := e.GetMainLocalFiles() + if !ok { + return 0 + } + var hasEpZero bool + for _, l := range lfs { + if l.GetEpisodeNumber() == 0 { + hasEpZero = true + break + } + } + + if hasEpZero { + return lf.GetEpisodeNumber() + 1 + } + + return lf.GetEpisodeNumber() +} + +func (lfw *LocalFileWrapper) GetUnmatchedLocalFiles() []*LocalFile { + return lfw.UnmatchedLocalFiles +} + +func (lfw *LocalFileWrapper) GetLocalEntries() []*LocalFileWrapperEntry { + return lfw.LocalEntries +} + +func (lfw *LocalFileWrapper) GetLocalFiles() []*LocalFile { + return lfw.LocalFiles +} + +func (e *LocalFileWrapperEntry) GetLocalFiles() []*LocalFile { + return e.LocalFiles +} + +func (e *LocalFileWrapperEntry) GetMediaId() int { + return e.MediaId +} diff --git a/seanime-2.9.10/internal/library/anime/localfile_wrapper_test.go b/seanime-2.9.10/internal/library/anime/localfile_wrapper_test.go new file mode 100644 index 0000000..21d0d07 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/localfile_wrapper_test.go @@ -0,0 +1,194 @@ +package anime_test + +import ( + "cmp" + "github.com/stretchr/testify/assert" + "seanime/internal/library/anime" + "slices" + "testing" +) + +func TestLocalFileWrapperEntry(t *testing.T) { + + lfs := anime.MockHydratedLocalFiles( + anime.MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "/mnt/anime/One Piece/One Piece - %ep.mkv", 21, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 1070, MetadataAniDbEpisode: "1070", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 1071, MetadataAniDbEpisode: "1071", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 1072, MetadataAniDbEpisode: "1072", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 1073, MetadataAniDbEpisode: "1073", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 1074, MetadataAniDbEpisode: "1074", MetadataType: anime.LocalFileTypeMain}, + }), + anime.MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "/mnt/anime/Blue Lock/Blue Lock - %ep.mkv", 22222, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain}, + }), + anime.MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "/mnt/anime/Kimi ni Todoke/Kimi ni Todoke - %ep.mkv", 9656, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 0, MetadataAniDbEpisode: "S1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain}, + }), + ) + + tests := []struct { + name string + mediaId int + expectedNbMainLocalFiles int + expectedLatestEpisode int + expectedEpisodeNumberAfterEpisode []int + }{ + { + name: "One Piece", + mediaId: 21, + expectedNbMainLocalFiles: 5, + expectedLatestEpisode: 1074, + expectedEpisodeNumberAfterEpisode: []int{1071, 1072}, + }, + { + name: "Blue Lock", + mediaId: 22222, + expectedNbMainLocalFiles: 3, + expectedLatestEpisode: 3, + expectedEpisodeNumberAfterEpisode: []int{2, 3}, + }, + } + + lfw := anime.NewLocalFileWrapper(lfs) + + // Not empty + if assert.Greater(t, len(lfw.GetLocalEntries()), 0) { + + for _, tt := range tests { + + // Can get by id + entry, ok := lfw.GetLocalEntryById(tt.mediaId) + if assert.Truef(t, ok, "could not find entry for %s", tt.name) { + + assert.Equalf(t, tt.mediaId, entry.GetMediaId(), "media id does not match for %s", tt.name) + + // Can get main local files + mainLfs, ok := entry.GetMainLocalFiles() + if assert.Truef(t, ok, "could not find main local files for %s", tt.name) { + + // Number of main local files matches + assert.Equalf(t, tt.expectedNbMainLocalFiles, len(mainLfs), "number of main local files does not match for %s", tt.name) + + // Can find latest episode + latest, ok := entry.FindLatestLocalFile() + if assert.Truef(t, ok, "could not find latest local file for %s", tt.name) { + assert.Equalf(t, tt.expectedLatestEpisode, latest.GetEpisodeNumber(), "latest episode does not match for %s", tt.name) + } + + // Can find successive episodes + firstEp, ok := entry.FindLocalFileWithEpisodeNumber(tt.expectedEpisodeNumberAfterEpisode[0]) + if assert.True(t, ok) { + secondEp, ok := entry.FindNextEpisode(firstEp) + if assert.True(t, ok) { + assert.Equal(t, tt.expectedEpisodeNumberAfterEpisode[1], secondEp.GetEpisodeNumber(), "second episode does not match for %s", tt.name) + } + } + + } + + } + + } + + } + +} + +func TestLocalFileWrapperEntryProgressNumber(t *testing.T) { + + lfs := anime.MockHydratedLocalFiles( + anime.MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "/mnt/anime/Kimi ni Todoke/Kimi ni Todoke - %ep.mkv", 9656, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 0, MetadataAniDbEpisode: "S1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain}, + }), + anime.MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "/mnt/anime/Kimi ni Todoke/Kimi ni Todoke - %ep.mkv", 9656_2, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 1, MetadataAniDbEpisode: "S1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 2, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 3, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain}, + }), + ) + + tests := []struct { + name string + mediaId int + expectedNbMainLocalFiles int + expectedLatestEpisode int + expectedEpisodeNumberAfterEpisode []int + expectedProgressNumbers []int + }{ + { + name: "Kimi ni Todoke", + mediaId: 9656, + expectedNbMainLocalFiles: 3, + expectedLatestEpisode: 2, + expectedEpisodeNumberAfterEpisode: []int{1, 2}, + expectedProgressNumbers: []int{1, 2, 3}, // S1 -> 1, 1 -> 2, 2 -> 3 + }, + { + name: "Kimi ni Todoke 2", + mediaId: 9656_2, + expectedNbMainLocalFiles: 3, + expectedLatestEpisode: 3, + expectedEpisodeNumberAfterEpisode: []int{2, 3}, + expectedProgressNumbers: []int{1, 2, 3}, // S1 -> 1, 1 -> 2, 2 -> 3 + }, + } + + lfw := anime.NewLocalFileWrapper(lfs) + + // Not empty + if assert.Greater(t, len(lfw.GetLocalEntries()), 0) { + + for _, tt := range tests { + + // Can get by id + entry, ok := lfw.GetLocalEntryById(tt.mediaId) + if assert.Truef(t, ok, "could not find entry for %s", tt.name) { + + assert.Equalf(t, tt.mediaId, entry.GetMediaId(), "media id does not match for %s", tt.name) + + // Can get main local files + mainLfs, ok := entry.GetMainLocalFiles() + if assert.Truef(t, ok, "could not find main local files for %s", tt.name) { + + // Number of main local files matches + assert.Equalf(t, tt.expectedNbMainLocalFiles, len(mainLfs), "number of main local files does not match for %s", tt.name) + + // Can find latest episode + latest, ok := entry.FindLatestLocalFile() + if assert.Truef(t, ok, "could not find latest local file for %s", tt.name) { + assert.Equalf(t, tt.expectedLatestEpisode, latest.GetEpisodeNumber(), "latest episode does not match for %s", tt.name) + } + + // Can find successive episodes + firstEp, ok := entry.FindLocalFileWithEpisodeNumber(tt.expectedEpisodeNumberAfterEpisode[0]) + if assert.True(t, ok) { + secondEp, ok := entry.FindNextEpisode(firstEp) + if assert.True(t, ok) { + assert.Equal(t, tt.expectedEpisodeNumberAfterEpisode[1], secondEp.GetEpisodeNumber(), "second episode does not match for %s", tt.name) + } + } + + slices.SortStableFunc(mainLfs, func(i *anime.LocalFile, j *anime.LocalFile) int { + return cmp.Compare(i.GetEpisodeNumber(), j.GetEpisodeNumber()) + }) + for idx, lf := range mainLfs { + progressNum := entry.GetProgressNumber(lf) + + assert.Equalf(t, tt.expectedProgressNumbers[idx], progressNum, "progress number does not match for %s", tt.name) + } + + } + + } + + } + + } + +} diff --git a/seanime-2.9.10/internal/library/anime/media_entry_test_mock_data.json b/seanime-2.9.10/internal/library/anime/media_entry_test_mock_data.json new file mode 100644 index 0000000..2631a4b --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/media_entry_test_mock_data.json @@ -0,0 +1,591 @@ +{ + "154587": { + "localFiles": [ + { + "path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 01 (1080p) [F02B9CEE].mkv", + "name": "[SubsPlease] Sousou no Frieren - 01 (1080p) [F02B9CEE].mkv", + "parsedInfo": { + "original": "[SubsPlease] Sousou no Frieren - 01 (1080p) [F02B9CEE].mkv", + "title": "Sousou no Frieren", + "releaseGroup": "SubsPlease", + "episode": "01" + }, + "parsedFolderInfo": [ + { + "original": "Sousou no Frieren", + "title": "Sousou no Frieren" + } + ], + "metadata": { + "episode": 1, + "aniDBEpisode": "1", + "type": "main" + }, + "locked": false, + "ignored": false, + "mediaId": 154587 + }, + { + "path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 02 (1080p) [E5A85899].mkv", + "name": "[SubsPlease] Sousou no Frieren - 02 (1080p) [E5A85899].mkv", + "parsedInfo": { + "original": "[SubsPlease] Sousou no Frieren - 02 (1080p) [E5A85899].mkv", + "title": "Sousou no Frieren", + "releaseGroup": "SubsPlease", + "episode": "02" + }, + "parsedFolderInfo": [ + { + "original": "Sousou no Frieren", + "title": "Sousou no Frieren" + } + ], + "metadata": { + "episode": 2, + "aniDBEpisode": "2", + "type": "main" + }, + "locked": false, + "ignored": false, + "mediaId": 154587 + }, + { + "path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 03 (1080p) [7EF3F175].mkv", + "name": "[SubsPlease] Sousou no Frieren - 03 (1080p) [7EF3F175].mkv", + "parsedInfo": { + "original": "[SubsPlease] Sousou no Frieren - 03 (1080p) [7EF3F175].mkv", + "title": "Sousou no Frieren", + "releaseGroup": "SubsPlease", + "episode": "03" + }, + "parsedFolderInfo": [ + { + "original": "Sousou no Frieren", + "title": "Sousou no Frieren" + } + ], + "metadata": { + "episode": 3, + "aniDBEpisode": "3", + "type": "main" + }, + "locked": false, + "ignored": false, + "mediaId": 154587 + }, + { + "path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 04 (1080p) [5ED46803].mkv", + "name": "[SubsPlease] Sousou no Frieren - 04 (1080p) [5ED46803].mkv", + "parsedInfo": { + "original": "[SubsPlease] Sousou no Frieren - 04 (1080p) [5ED46803].mkv", + "title": "Sousou no Frieren", + "releaseGroup": "SubsPlease", + "episode": "04" + }, + "parsedFolderInfo": [ + { + "original": "Sousou no Frieren", + "title": "Sousou no Frieren" + } + ], + "metadata": { + "episode": 4, + "aniDBEpisode": "4", + "type": "main" + }, + "locked": false, + "ignored": false, + "mediaId": 154587 + }, + { + "path": "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 05 (1080p) [8E3F8FA5].mkv", + "name": "[SubsPlease] Sousou no Frieren - 05 (1080p) [8E3F8FA5].mkv", + "parsedInfo": { + "original": "[SubsPlease] Sousou no Frieren - 05 (1080p) [8E3F8FA5].mkv", + "title": "Sousou no Frieren", + "releaseGroup": "SubsPlease", + "episode": "05" + }, + "parsedFolderInfo": [ + { + "original": "Sousou no Frieren", + "title": "Sousou no Frieren" + } + ], + "metadata": { + "episode": 5, + "aniDBEpisode": "5", + "type": "main" + }, + "locked": false, + "ignored": false, + "mediaId": 154587 + } + ], + "animeCollection": { + "MediaListCollection": { + "lists": [ + { + "status": "CURRENT", + "entries": [ + { + "id": 366875178, + "score": 9, + "progress": 4, + "status": "CURRENT", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2023, + "month": 10 + }, + "completedAt": {}, + "media": { + "id": 154587, + "idMal": 52991, + "siteUrl": "https://anilist.co/anime/154587", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/154587-ivXNJ23SM1xB.jpg", + "episodes": 28, + "synonyms": [ + "Frieren at the Funeral", + "장송의 프리렌", + "Frieren: Oltre la Fine del Viaggio", + "คำอธิษฐานในวันที่จากลา Frieren", + "Frieren e a Jornada para o Além", + "Frieren – Nach dem Ende der Reise", + "葬送的芙莉蓮", + "Frieren: Más allá del final del viaje", + "Frieren en el funeral", + "Sōsō no Furīren", + "Frieren. U kresu drogi", + "Frieren - Pháp sư tiễn táng", + "Фрирен, провожающая в последний путь" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Sousou no Frieren", + "romaji": "Sousou no Frieren", + "english": "Frieren: Beyond Journey’s End", + "native": "葬送のフリーレン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154587-n1fmjRv4JQUd.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154587-n1fmjRv4JQUd.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154587-n1fmjRv4JQUd.jpg", + "color": "#d6f1c9" + }, + "startDate": { + "year": 2023, + "month": 9, + "day": 29 + }, + "endDate": {}, + "nextAiringEpisode": { + "airingAt": 1700229600, + "timeUntilAiring": 223940, + "episode": 11 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 118586, + "idMal": 126287, + "siteUrl": "https://anilist.co/manga/118586", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/118586-1JLJiwaIlnBp.jpg", + "synonyms": [ + "Frieren at the Funeral", + "장송의 프리렌", + "Frieren: Oltre la Fine del Viaggio", + "คำอธิษฐานในวันที่จากลา Frieren", + "Frieren e a Jornada para o Além", + "Frieren – Nach dem Ende der Reise", + "葬送的芙莉蓮", + "Frieren After \"The End\"", + "Frieren: Remnants of the Departed", + "Frieren. U kresu drogi", + "Frieren", + "FRIEREN: Más allá del fin del viaje" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Sousou no Frieren", + "romaji": "Sousou no Frieren", + "english": "Frieren: Beyond Journey’s End", + "native": "葬送のフリーレン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx118586-F0Lp86XQV7du.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx118586-F0Lp86XQV7du.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx118586-F0Lp86XQV7du.jpg", + "color": "#e4ae5d" + }, + "startDate": { + "year": 2020, + "month": 4, + "day": 28 + }, + "endDate": {} + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 169811, + "idMal": 56805, + "siteUrl": "https://anilist.co/anime/169811", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/169811-jgMVZlIdH19a.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Yuusha", + "romaji": "Yuusha", + "native": "勇者" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx169811-H0RW7WHkRlbH.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx169811-H0RW7WHkRlbH.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx169811-H0RW7WHkRlbH.png" + }, + "startDate": { + "year": 2023, + "month": 9, + "day": 29 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 29 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 170068, + "idMal": 56885, + "siteUrl": "https://anilist.co/anime/170068", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "ONA", + "synonyms": [ + "Sousou no Frieren Mini Anime", + "Frieren: Beyond Journey’s End Mini Anime", + "葬送のフリーレン ミニアニメ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Sousou no Frieren: ●● no Mahou", + "romaji": "Sousou no Frieren: ●● no Mahou", + "native": "葬送のフリーレン ~●●の魔法~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx170068-ijY3tCP8KoWP.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx170068-ijY3tCP8KoWP.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx170068-ijY3tCP8KoWP.jpg", + "color": "#bbd678" + }, + "startDate": { + "year": 2023, + "month": 10, + "day": 11 + }, + "endDate": {} + } + } + ] + } + } + } + ] + } + ] + } + } + }, + "146065": { + "localFiles": [], + "animeCollection": { + "MediaListCollection": { + "lists": [ + { + "status": "CURRENT", + "entries": [ + { + "id": 366466419, + "score": 0, + "progress": 0, + "status": "CURRENT", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2023, + "month": 10, + "day": 4 + }, + "completedAt": { + "year": 2023, + "month": 10, + "day": 9 + }, + "media": { + "id": 146065, + "idMal": 51179, + "siteUrl": "https://anilist.co/anime/146065", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/146065-33RDijfuxLLk.jpg", + "episodes": 13, + "synonyms": [ + "ชาตินี้พี่ต้องเทพ ภาค 2", + "Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season", + "Mushoku Tensei II: Jobless Reincarnation", + "Mushoku Tensei II: Reencarnación desde cero", + "无职转生~到了异世界就拿出真本事~第2季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu", + "romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu", + "english": "Mushoku Tensei: Jobless Reincarnation Season 2", + "native": "無職転生 Ⅱ ~異世界行ったら本気だす~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx146065-IjirxRK26O03.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx146065-IjirxRK26O03.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx146065-IjirxRK26O03.png", + "color": "#35aee4" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 3 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 25 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 85470, + "idMal": 70261, + "siteUrl": "https://anilist.co/manga/85470", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85470-akkFSKH9aacB.jpg", + "synonyms": [ + "เกิดชาตินี้พี่ต้องเทพ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "english": "Mushoku Tensei: Jobless Reincarnation", + "native": "無職転生 ~異世界行ったら本気だす~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85470-jt6BF9tDWB2X.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85470-jt6BF9tDWB2X.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85470-jt6BF9tDWB2X.jpg", + "color": "#f1bb1a" + }, + "startDate": { + "year": 2014, + "month": 1, + "day": 23 + }, + "endDate": { + "year": 2022, + "month": 11, + "day": 25 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 85564, + "idMal": 70259, + "siteUrl": "https://anilist.co/manga/85564", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85564-Wy8IQU3Km61c.jpg", + "synonyms": [ + "Mushoku Tensei: Uma segunda chance" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "english": "Mushoku Tensei: Jobless Reincarnation", + "native": "無職転生 ~異世界行ったら本気だす~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx85564-egXRASF0x9B9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx85564-egXRASF0x9B9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx85564-egXRASF0x9B9.jpg", + "color": "#e4ae0d" + }, + "startDate": { + "year": 2014, + "month": 5, + "day": 2 + }, + "endDate": {} + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 127720, + "idMal": 45576, + "siteUrl": "https://anilist.co/anime/127720", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/127720-oBpHiMWQhFVN.jpg", + "episodes": 12, + "synonyms": [ + "Mushoku Tensei: Jobless Reincarnation Part 2", + "ชาตินี้พี่ต้องเทพ พาร์ท 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2", + "english": "Mushoku Tensei: Jobless Reincarnation Cour 2", + "native": "無職転生 ~異世界行ったら本気だす~ 第2クール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx127720-ADJgIrUVMdU9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx127720-ADJgIrUVMdU9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx127720-ADJgIrUVMdU9.jpg", + "color": "#d6bb1a" + }, + "startDate": { + "year": 2021, + "month": 10, + "day": 4 + }, + "endDate": { + "year": 2021, + "month": 12, + "day": 20 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 142989, + "idMal": 142765, + "siteUrl": "https://anilist.co/manga/142989", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "Mushoku Tensei - Depressed Magician" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen", + "native": "無職転生 ~異世界行ったら本気だす~ 失意の魔術師編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx142989-jYDNHLwdER70.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx142989-jYDNHLwdER70.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx142989-jYDNHLwdER70.png", + "color": "#e4bb28" + }, + "startDate": { + "year": 2021, + "month": 12, + "day": 20 + }, + "endDate": {} + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 166873, + "idMal": 55888, + "siteUrl": "https://anilist.co/anime/166873", + "status": "NOT_YET_RELEASED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "episodes": 12, + "synonyms": [ + "Mushoku Tensei: Jobless Reincarnation Season 2 Part 2", + "ชาตินี้พี่ต้องเทพ ภาค 2", + "Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season Part 2", + "Mushoku Tensei II: Jobless Reincarnation Part 2", + "Mushoku Tensei II: Reencarnación desde cero", + "无职转生~到了异世界就拿出真本事~第2季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2", + "romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2", + "native": "無職転生 Ⅱ ~異世界行ったら本気だす~ 第2クール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx166873-cqMLPB00KcEI.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx166873-cqMLPB00KcEI.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx166873-cqMLPB00KcEI.jpg", + "color": "#6b501a" + }, + "startDate": { + "year": 2024, + "month": 4 + }, + "endDate": { + "year": 2024, + "month": 6 + } + } + } + ] + } + } + } + ] + } + ] + } + } + } +} diff --git a/seanime-2.9.10/internal/library/anime/missing_episodes.go b/seanime-2.9.10/internal/library/anime/missing_episodes.go new file mode 100644 index 0000000..c9e435a --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/missing_episodes.go @@ -0,0 +1,154 @@ +package anime + +import ( + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/hook" + "seanime/internal/util/limiter" + "sort" + "time" + + "github.com/samber/lo" + lop "github.com/samber/lo/parallel" + "github.com/sourcegraph/conc/pool" +) + +type ( + MissingEpisodes struct { + Episodes []*Episode `json:"episodes"` + SilencedEpisodes []*Episode `json:"silencedEpisodes"` + } + + NewMissingEpisodesOptions struct { + AnimeCollection *anilist.AnimeCollection + LocalFiles []*LocalFile + SilencedMediaIds []int + MetadataProvider metadata.Provider + } +) + +func NewMissingEpisodes(opts *NewMissingEpisodesOptions) *MissingEpisodes { + missing := new(MissingEpisodes) + + reqEvent := new(MissingEpisodesRequestedEvent) + reqEvent.AnimeCollection = opts.AnimeCollection + reqEvent.LocalFiles = opts.LocalFiles + reqEvent.SilencedMediaIds = opts.SilencedMediaIds + reqEvent.MissingEpisodes = missing + err := hook.GlobalHookManager.OnMissingEpisodesRequested().Trigger(reqEvent) + if err != nil { + return nil + } + opts.AnimeCollection = reqEvent.AnimeCollection // Override the anime collection + opts.LocalFiles = reqEvent.LocalFiles // Override the local files + opts.SilencedMediaIds = reqEvent.SilencedMediaIds // Override the silenced media IDs + missing = reqEvent.MissingEpisodes + + // Default prevented by hook, return the missing episodes + if reqEvent.DefaultPrevented { + event := new(MissingEpisodesEvent) + event.MissingEpisodes = missing + err = hook.GlobalHookManager.OnMissingEpisodes().Trigger(event) + if err != nil { + return nil + } + return event.MissingEpisodes + } + + groupedLfs := GroupLocalFilesByMediaID(opts.LocalFiles) + + rateLimiter := limiter.NewLimiter(time.Second, 20) + p := pool.NewWithResults[[]*EntryDownloadEpisode]() + for mId, lfs := range groupedLfs { + p.Go(func() []*EntryDownloadEpisode { + entry, found := opts.AnimeCollection.GetListEntryFromAnimeId(mId) + if !found { + return nil + } + + // Skip if the status is nil, dropped or completed + if entry.Status == nil || *entry.Status == anilist.MediaListStatusDropped || *entry.Status == anilist.MediaListStatusCompleted { + return nil + } + + latestLf, found := FindLatestLocalFileFromGroup(lfs) + if !found { + return nil + } + //If the latest local file is the same or higher than the current episode count, skip + if entry.Media.GetCurrentEpisodeCount() == -1 || entry.Media.GetCurrentEpisodeCount() <= latestLf.GetEpisodeNumber() { + return nil + } + rateLimiter.Wait() + // Fetch anime metadata + animeMetadata, err := opts.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, entry.Media.ID) + if err != nil { + return nil + } + + // Get download info + downloadInfo, err := NewEntryDownloadInfo(&NewEntryDownloadInfoOptions{ + LocalFiles: lfs, + AnimeMetadata: animeMetadata, + Media: entry.Media, + Progress: entry.Progress, + Status: entry.Status, + MetadataProvider: opts.MetadataProvider, + }) + if err != nil { + return nil + } + + episodes := downloadInfo.EpisodesToDownload + + sort.Slice(episodes, func(i, j int) bool { + return episodes[i].Episode.GetEpisodeNumber() < episodes[j].Episode.GetEpisodeNumber() + }) + + // If there are more than 1 episode to download, modify the name of the first episode + if len(episodes) > 1 { + episodes = episodes[:1] // keep the first episode + if episodes[0].Episode != nil { + episodes[0].Episode.DisplayTitle = episodes[0].Episode.DisplayTitle + fmt.Sprintf(" & %d more", len(downloadInfo.EpisodesToDownload)-1) + } + } + return episodes + }) + } + epsToDownload := p.Wait() + epsToDownload = lo.Filter(epsToDownload, func(item []*EntryDownloadEpisode, _ int) bool { + return item != nil + }) + + // Flatten + flattenedEpsToDownload := lo.Flatten(epsToDownload) + eps := lop.Map(flattenedEpsToDownload, func(item *EntryDownloadEpisode, _ int) *Episode { + return item.Episode + }) + // Sort + sort.Slice(eps, func(i, j int) bool { + return eps[i].GetEpisodeNumber() < eps[j].GetEpisodeNumber() + }) + sort.Slice(eps, func(i, j int) bool { + return eps[i].BaseAnime.ID < eps[j].BaseAnime.ID + }) + + missing.Episodes = lo.Filter(eps, func(item *Episode, _ int) bool { + return !lo.Contains(opts.SilencedMediaIds, item.BaseAnime.ID) + }) + + missing.SilencedEpisodes = lo.Filter(eps, func(item *Episode, _ int) bool { + return lo.Contains(opts.SilencedMediaIds, item.BaseAnime.ID) + }) + + // Event + event := new(MissingEpisodesEvent) + event.MissingEpisodes = missing + err = hook.GlobalHookManager.OnMissingEpisodes().Trigger(event) + if err != nil { + return nil + } + + return event.MissingEpisodes +} diff --git a/seanime-2.9.10/internal/library/anime/missing_episodes_test.go b/seanime-2.9.10/internal/library/anime/missing_episodes_test.go new file mode 100644 index 0000000..30af077 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/missing_episodes_test.go @@ -0,0 +1,85 @@ +package anime_test + +import ( + "context" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/library/anime" + "seanime/internal/test_utils" + "testing" +) + +// Test to retrieve accurate missing episodes +// DEPRECATED +func TestNewMissingEpisodes(t *testing.T) { + t.Skip("Outdated test") + test_utils.InitTestProvider(t, test_utils.Anilist()) + + metadataProvider := metadata.GetMockProvider(t) + + anilistClient := anilist.TestGetMockAnilistClient() + animeCollection, err := anilistClient.AnimeCollection(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + mediaId int + localFiles []*anime.LocalFile + mediaAiredEpisodes int + currentProgress int + expectedMissingEpisodes int + }{ + { + // Sousou no Frieren - 10 currently aired episodes + // User has 5 local files from ep 1 to 5, but only watched 4 episodes + // So we should expect to see 5 missing episodes + name: "Sousou no Frieren, missing 5 episodes", + mediaId: 154587, + localFiles: anime.MockHydratedLocalFiles( + anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - %ep (1080p) [F02B9CEE].mkv", 154587, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 5, MetadataAniDbEpisode: "5", MetadataType: anime.LocalFileTypeMain}, + }), + ), + mediaAiredEpisodes: 10, + currentProgress: 4, + //expectedMissingEpisodes: 5, + expectedMissingEpisodes: 1, // DEVNOTE: Now the value is 1 at most because everything else is merged + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + // Mock Anilist collection + anilist.TestModifyAnimeCollectionEntry(animeCollection, tt.mediaId, anilist.TestModifyAnimeCollectionEntryInput{ + Progress: lo.ToPtr(tt.currentProgress), // Mock progress + AiredEpisodes: lo.ToPtr(tt.mediaAiredEpisodes), + NextAiringEpisode: &anilist.BaseAnime_NextAiringEpisode{ + Episode: tt.mediaAiredEpisodes + 1, + }, + }) + + }) + + if assert.NoError(t, err) { + missingData := anime.NewMissingEpisodes(&anime.NewMissingEpisodesOptions{ + AnimeCollection: animeCollection, + LocalFiles: tt.localFiles, + MetadataProvider: metadataProvider, + }) + + assert.Equal(t, tt.expectedMissingEpisodes, len(missingData.Episodes)) + } + + } + +} diff --git a/seanime-2.9.10/internal/library/anime/normalized_media.go b/seanime-2.9.10/internal/library/anime/normalized_media.go new file mode 100644 index 0000000..ad1994e --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/normalized_media.go @@ -0,0 +1,24 @@ +package anime + +import ( + "seanime/internal/api/anilist" + "seanime/internal/util/result" +) + +type NormalizedMedia struct { + *anilist.BaseAnime +} + +type NormalizedMediaCache struct { + *result.Cache[int, *NormalizedMedia] +} + +func NewNormalizedMedia(m *anilist.BaseAnime) *NormalizedMedia { + return &NormalizedMedia{ + BaseAnime: m, + } +} + +func NewNormalizedMediaCache() *NormalizedMediaCache { + return &NormalizedMediaCache{result.NewCache[int, *NormalizedMedia]()} +} diff --git a/seanime-2.9.10/internal/library/anime/playlist.go b/seanime-2.9.10/internal/library/anime/playlist.go new file mode 100644 index 0000000..8429bae --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/playlist.go @@ -0,0 +1,50 @@ +package anime + +import ( + "seanime/internal/util" +) + +type ( + // Playlist holds the data from models.PlaylistEntry + Playlist struct { + DbId uint `json:"dbId"` // DbId is the database ID of the models.PlaylistEntry + Name string `json:"name"` // Name is the name of the playlist + LocalFiles []*LocalFile `json:"localFiles"` // LocalFiles is a list of local files in the playlist, in order + } +) + +// NewPlaylist creates a new Playlist instance +func NewPlaylist(name string) *Playlist { + return &Playlist{ + Name: name, + LocalFiles: make([]*LocalFile, 0), + } +} + +func (pd *Playlist) SetLocalFiles(lfs []*LocalFile) { + pd.LocalFiles = lfs +} + +// AddLocalFile adds a local file to the playlist +func (pd *Playlist) AddLocalFile(localFile *LocalFile) { + pd.LocalFiles = append(pd.LocalFiles, localFile) +} + +// RemoveLocalFile removes a local file from the playlist +func (pd *Playlist) RemoveLocalFile(path string) { + for i, lf := range pd.LocalFiles { + if lf.GetNormalizedPath() == util.NormalizePath(path) { + pd.LocalFiles = append(pd.LocalFiles[:i], pd.LocalFiles[i+1:]...) + return + } + } +} + +func (pd *Playlist) LocalFileExists(path string, lfs []*LocalFile) bool { + for _, lf := range lfs { + if lf.GetNormalizedPath() == util.NormalizePath(path) { + return true + } + } + return false +} diff --git a/seanime-2.9.10/internal/library/anime/schedule.go b/seanime-2.9.10/internal/library/anime/schedule.go new file mode 100644 index 0000000..f5827f6 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/schedule.go @@ -0,0 +1,111 @@ +package anime + +import ( + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/hook" + "time" + + "github.com/samber/lo" +) + +type ScheduleItem struct { + MediaId int `json:"mediaId"` + Title string `json:"title"` + // Time is in 15:04 format + Time string `json:"time"` + // DateTime is in UTC + DateTime time.Time `json:"dateTime"` + Image string `json:"image"` + EpisodeNumber int `json:"episodeNumber"` + IsMovie bool `json:"isMovie"` + IsSeasonFinale bool `json:"isSeasonFinale"` +} + +func GetScheduleItems(animeSchedule *anilist.AnimeAiringSchedule, animeCollection *anilist.AnimeCollection) []*ScheduleItem { + animeEntryMap := make(map[int]*anilist.AnimeListEntry) + for _, list := range animeCollection.MediaListCollection.GetLists() { + for _, entry := range list.GetEntries() { + animeEntryMap[entry.GetMedia().GetID()] = entry + } + } + + type animeScheduleNode interface { + GetAiringAt() int + GetTimeUntilAiring() int + GetEpisode() int + } + + type animeScheduleMedia interface { + GetMedia() []*anilist.AnimeSchedule + } + + formatNodeItem := func(node animeScheduleNode, entry *anilist.AnimeListEntry) *ScheduleItem { + t := time.Unix(int64(node.GetAiringAt()), 0) + item := &ScheduleItem{ + MediaId: entry.GetMedia().GetID(), + Title: *entry.GetMedia().GetTitle().GetUserPreferred(), + Time: t.UTC().Format("15:04"), + DateTime: t.UTC(), + Image: entry.GetMedia().GetCoverImageSafe(), + EpisodeNumber: node.GetEpisode(), + IsMovie: entry.GetMedia().IsMovie(), + IsSeasonFinale: false, + } + if entry.GetMedia().GetTotalEpisodeCount() > 0 && node.GetEpisode() == entry.GetMedia().GetTotalEpisodeCount() { + item.IsSeasonFinale = true + } + return item + } + + formatPart := func(m animeScheduleMedia) ([]*ScheduleItem, bool) { + if m == nil { + return nil, false + } + ret := make([]*ScheduleItem, 0) + for _, m := range m.GetMedia() { + entry, ok := animeEntryMap[m.GetID()] + if !ok || entry.Status == nil || *entry.Status == anilist.MediaListStatusDropped { + continue + } + for _, n := range m.GetPrevious().GetNodes() { + ret = append(ret, formatNodeItem(n, entry)) + } + for _, n := range m.GetUpcoming().GetNodes() { + ret = append(ret, formatNodeItem(n, entry)) + } + } + return ret, true + } + + ongoingItems, _ := formatPart(animeSchedule.GetOngoing()) + ongoingNextItems, _ := formatPart(animeSchedule.GetOngoingNext()) + precedingItems, _ := formatPart(animeSchedule.GetPreceding()) + upcomingItems, _ := formatPart(animeSchedule.GetUpcoming()) + upcomingNextItems, _ := formatPart(animeSchedule.GetUpcomingNext()) + + allItems := make([]*ScheduleItem, 0) + allItems = append(allItems, ongoingItems...) + allItems = append(allItems, ongoingNextItems...) + allItems = append(allItems, precedingItems...) + allItems = append(allItems, upcomingItems...) + allItems = append(allItems, upcomingNextItems...) + + ret := lo.UniqBy(allItems, func(item *ScheduleItem) string { + if item == nil { + return "" + } + return fmt.Sprintf("%d-%d-%d", item.MediaId, item.EpisodeNumber, item.DateTime.Unix()) + }) + + event := &AnimeScheduleItemsEvent{ + AnimeCollection: animeCollection, + Items: ret, + } + err := hook.GlobalHookManager.OnAnimeScheduleItems().Trigger(event) + if err != nil { + return ret + } + + return event.Items +} diff --git a/seanime-2.9.10/internal/library/anime/test_helpers.go b/seanime-2.9.10/internal/library/anime/test_helpers.go new file mode 100644 index 0000000..964e5d9 --- /dev/null +++ b/seanime-2.9.10/internal/library/anime/test_helpers.go @@ -0,0 +1,80 @@ +package anime + +import ( + "strconv" + "strings" +) + +type MockHydratedLocalFileOptions struct { + FilePath string + LibraryPath string + MediaId int + MetadataEpisode int + MetadataAniDbEpisode string + MetadataType LocalFileType +} + +func MockHydratedLocalFile(opts MockHydratedLocalFileOptions) *LocalFile { + lf := NewLocalFile(opts.FilePath, opts.LibraryPath) + lf.MediaId = opts.MediaId + lf.Metadata = &LocalFileMetadata{ + AniDBEpisode: opts.MetadataAniDbEpisode, + Episode: opts.MetadataEpisode, + Type: opts.MetadataType, + } + return lf +} + +// MockHydratedLocalFiles creates a slice of LocalFiles based on the provided options +// +// Example: +// +// MockHydratedLocalFiles( +// MockHydratedLocalFileOptions{ +// FilePath: "/mnt/anime/One Piece/One Piece - 1070.mkv", +// LibraryPath: "/mnt/anime/", +// MetadataEpisode: 1070, +// MetadataAniDbEpisode: "1070", +// MetadataType: LocalFileTypeMain, +// }, +// MockHydratedLocalFileOptions{ +// ... +// }, +// ) +func MockHydratedLocalFiles(opts ...[]MockHydratedLocalFileOptions) []*LocalFile { + lfs := make([]*LocalFile, 0, len(opts)) + for _, opt := range opts { + for _, o := range opt { + lfs = append(lfs, MockHydratedLocalFile(o)) + } + } + return lfs +} + +type MockHydratedLocalFileWrapperOptionsMetadata struct { + MetadataEpisode int + MetadataAniDbEpisode string + MetadataType LocalFileType +} + +// MockGenerateHydratedLocalFileGroupOptions generates a slice of MockHydratedLocalFileOptions based on a template string and metadata +// +// Example: +// +// MockGenerateHydratedLocalFileGroupOptions("/mnt/anime/", "One Piece/One Piece - %ep.mkv", 21, []MockHydratedLocalFileWrapperOptionsMetadata{ +// {MetadataEpisode: 1070, MetadataAniDbEpisode: "1070", MetadataType: LocalFileTypeMain}, +// }) +func MockGenerateHydratedLocalFileGroupOptions(libraryPath string, template string, mId int, m []MockHydratedLocalFileWrapperOptionsMetadata) []MockHydratedLocalFileOptions { + opts := make([]MockHydratedLocalFileOptions, 0, len(m)) + for _, metadata := range m { + opts = append(opts, MockHydratedLocalFileOptions{ + FilePath: strings.ReplaceAll(template, "%ep", strconv.Itoa(metadata.MetadataEpisode)), + LibraryPath: libraryPath, + MediaId: mId, + MetadataEpisode: metadata.MetadataEpisode, + MetadataAniDbEpisode: metadata.MetadataAniDbEpisode, + MetadataType: metadata.MetadataType, + }) + } + return opts +} diff --git a/seanime-2.9.10/internal/library/autodownloader/autodownloader.go b/seanime-2.9.10/internal/library/autodownloader/autodownloader.go new file mode 100644 index 0000000..ad0725b --- /dev/null +++ b/seanime-2.9.10/internal/library/autodownloader/autodownloader.go @@ -0,0 +1,1014 @@ +package autodownloader + +import ( + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/database/db" + "seanime/internal/database/db_bridge" + "seanime/internal/database/models" + debrid_client "seanime/internal/debrid/client" + "seanime/internal/debrid/debrid" + "seanime/internal/events" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/hook" + "seanime/internal/library/anime" + "seanime/internal/notifier" + "seanime/internal/torrent_clients/torrent_client" + "seanime/internal/torrents/torrent" + "seanime/internal/util" + "seanime/internal/util/comparison" + "sort" + "strings" + "sync" + "time" + + "github.com/5rahim/habari" + "github.com/adrg/strutil/metrics" + "github.com/rs/zerolog" + "github.com/samber/lo" + "github.com/samber/mo" + "github.com/sourcegraph/conc/pool" +) + +const ( + ComparisonThreshold = 0.8 +) + +type ( + AutoDownloader struct { + logger *zerolog.Logger + torrentClientRepository *torrent_client.Repository + torrentRepository *torrent.Repository + debridClientRepository *debrid_client.Repository + database *db.Database + animeCollection mo.Option[*anilist.AnimeCollection] + wsEventManager events.WSEventManagerInterface + settings *models.AutoDownloaderSettings + metadataProvider metadata.Provider + settingsUpdatedCh chan struct{} + stopCh chan struct{} + startCh chan struct{} + debugTrace bool + mu sync.Mutex + isOffline *bool + } + + NewAutoDownloaderOptions struct { + Logger *zerolog.Logger + TorrentClientRepository *torrent_client.Repository + TorrentRepository *torrent.Repository + WSEventManager events.WSEventManagerInterface + Database *db.Database + MetadataProvider metadata.Provider + DebridClientRepository *debrid_client.Repository + IsOffline *bool + } + + tmpTorrentToDownload struct { + torrent *NormalizedTorrent + episode int + } +) + +func New(opts *NewAutoDownloaderOptions) *AutoDownloader { + return &AutoDownloader{ + logger: opts.Logger, + torrentClientRepository: opts.TorrentClientRepository, + torrentRepository: opts.TorrentRepository, + database: opts.Database, + wsEventManager: opts.WSEventManager, + animeCollection: mo.None[*anilist.AnimeCollection](), + metadataProvider: opts.MetadataProvider, + debridClientRepository: opts.DebridClientRepository, + settings: &models.AutoDownloaderSettings{ + Provider: torrent.ProviderAnimeTosho, // Default provider, will be updated after the settings are fetched + Interval: 20, + Enabled: false, + DownloadAutomatically: false, + EnableEnhancedQueries: false, + }, + settingsUpdatedCh: make(chan struct{}, 1), + stopCh: make(chan struct{}, 1), + startCh: make(chan struct{}, 1), + debugTrace: true, + mu: sync.Mutex{}, + isOffline: opts.IsOffline, + } +} + +// SetSettings should be called after the settings are fetched and updated from the database. +// If the AutoDownloader is not active, it will start it if the settings are enabled. +// If the AutoDownloader is active, it will stop it if the settings are disabled. +func (ad *AutoDownloader) SetSettings(settings *models.AutoDownloaderSettings, provider string) { + defer util.HandlePanicInModuleThen("autodownloader/SetSettings", func() {}) + + event := &AutoDownloaderSettingsUpdatedEvent{ + Settings: settings, + } + _ = hook.GlobalHookManager.OnAutoDownloaderSettingsUpdated().Trigger(event) + settings = event.Settings + + if ad == nil { + return + } + go func() { + ad.mu.Lock() + defer ad.mu.Unlock() + ad.settings = settings + // Update the provider if it's provided + if provider != "" { + ad.settings.Provider = provider + } + ad.settingsUpdatedCh <- struct{}{} // Notify that the settings have been updated + if ad.settings.Enabled { + ad.startCh <- struct{}{} // Start the auto downloader + } else if !ad.settings.Enabled { + ad.stopCh <- struct{}{} // Stop the auto downloader + } + }() +} + +func (ad *AutoDownloader) SetAnimeCollection(ac *anilist.AnimeCollection) { + ad.animeCollection = mo.Some(ac) +} + +func (ad *AutoDownloader) SetTorrentClientRepository(repo *torrent_client.Repository) { + defer util.HandlePanicInModuleThen("autodownloader/SetTorrentClientRepository", func() {}) + + if ad == nil { + return + } + ad.torrentClientRepository = repo +} + +// Start will start the auto downloader in a goroutine +func (ad *AutoDownloader) Start() { + defer util.HandlePanicInModuleThen("autodownloader/Start", func() {}) + + if ad == nil { + return + } + go func() { + ad.mu.Lock() + if ad.settings.Enabled { + started := ad.torrentClientRepository.Start() // Start torrent client if it's not running + if !started { + ad.logger.Warn().Msg("autodownloader: Failed to start torrent client. Make sure it's running for the Auto Downloader to work.") + ad.mu.Unlock() + return + } + } + ad.mu.Unlock() + + // Start the auto downloader + ad.start() + }() +} + +func (ad *AutoDownloader) Run() { + defer util.HandlePanicInModuleThen("autodownloader/Run", func() {}) + + if ad == nil { + return + } + go func() { + ad.mu.Lock() + defer ad.mu.Unlock() + ad.startCh <- struct{}{} + ad.logger.Trace().Msg("autodownloader: Received start signal") + }() +} + +// CleanUpDownloadedItems will clean up downloaded items from the database. +// This should be run after a scan is completed. +func (ad *AutoDownloader) CleanUpDownloadedItems() { + defer util.HandlePanicInModuleThen("autodownloader/CleanUpDownloadedItems", func() {}) + + if ad == nil { + return + } + ad.mu.Lock() + defer ad.mu.Unlock() + err := ad.database.DeleteDownloadedAutoDownloaderItems() + if err != nil { + return + } +} + +func (ad *AutoDownloader) start() { + defer util.HandlePanicInModuleThen("autodownloader/start", func() {}) + + if ad.settings.Enabled { + ad.logger.Info().Msg("autodownloader: Module started") + } + + for { + interval := 20 + // Use the user-defined interval if it's greater or equal to 15 + if ad.settings != nil && ad.settings.Interval > 0 && ad.settings.Interval >= 15 { + interval = ad.settings.Interval + } + ticker := time.NewTicker(time.Duration(interval) * time.Minute) + select { + case <-ad.settingsUpdatedCh: + break // Restart the loop + case <-ad.stopCh: + + case <-ad.startCh: + if ad.settings.Enabled { + ad.logger.Debug().Msg("autodownloader: Auto Downloader started") + ad.checkForNewEpisodes() + } + case <-ticker.C: + if ad.settings.Enabled { + ad.checkForNewEpisodes() + } + } + ticker.Stop() + } + +} + +func (ad *AutoDownloader) checkForNewEpisodes() { + defer util.HandlePanicInModuleThen("autodownloader/checkForNewEpisodes", func() {}) + + if ad.isOffline != nil && *ad.isOffline { + ad.logger.Debug().Msg("autodownloader: Skipping check for new episodes. AutoDownloader is in offline mode.") + return + } + + ad.mu.Lock() + if ad == nil || ad.torrentRepository == nil || !ad.settings.Enabled || ad.settings.Provider == "" || ad.settings.Provider == torrent.ProviderNone { + ad.logger.Warn().Msg("autodownloader: Could not check for new episodes. AutoDownloader is not enabled or provider is not set.") + ad.mu.Unlock() + return + } + + // DEVNOTE: [checkForNewEpisodes] is called on startup, when the default anime provider extension has not yet been loaded. + providerExt, found := ad.torrentRepository.GetDefaultAnimeProviderExtension() + if !found { + //ad.logger.Warn().Msg("autodownloader: Could not check for new episodes. Default provider not found.") + ad.mu.Unlock() + return + } + if providerExt.GetProvider().GetSettings().Type != hibiketorrent.AnimeProviderTypeMain { + ad.logger.Warn().Msgf("autodownloader: Could not check for new episodes. Provider '%s' cannot be used for auto downloading.", providerExt.GetName()) + ad.mu.Unlock() + return + } + ad.mu.Unlock() + + torrents := make([]*NormalizedTorrent, 0) + + // Get rules from the database + rules, err := db_bridge.GetAutoDownloaderRules(ad.database) + if err != nil { + ad.logger.Error().Err(err).Msg("autodownloader: Failed to fetch rules from the database") + return + } + + // Filter out disabled rules + _filteredRules := make([]*anime.AutoDownloaderRule, 0) + for _, rule := range rules { + if rule.Enabled { + _filteredRules = append(_filteredRules, rule) + } + } + rules = _filteredRules + + // Event + event := &AutoDownloaderRunStartedEvent{ + Rules: rules, + } + _ = hook.GlobalHookManager.OnAutoDownloaderRunStarted().Trigger(event) + rules = event.Rules + + // Default prevented, return + if event.DefaultPrevented { + return + } + + // If there are no rules, return + if len(rules) == 0 { + ad.logger.Debug().Msg("autodownloader: No rules found") + return + } + + // Get local files from the database + lfs, _, err := db_bridge.GetLocalFiles(ad.database) + if err != nil { + ad.logger.Error().Err(err).Msg("autodownloader: Failed to fetch local files from the database") + return + } + // Create a LocalFileWrapper + lfWrapper := anime.NewLocalFileWrapper(lfs) + + // Get the latest torrents + torrents, err = ad.getLatestTorrents(rules) + if err != nil { + ad.logger.Error().Err(err).Msg("autodownloader: Failed to get latest torrents") + return + } + + // Event + fetchedEvent := &AutoDownloaderTorrentsFetchedEvent{ + Torrents: torrents, + } + _ = hook.GlobalHookManager.OnAutoDownloaderTorrentsFetched().Trigger(fetchedEvent) + torrents = fetchedEvent.Torrents + + // // Try to start the torrent client if it's not running + // if ad.torrentClientRepository != nil { + // started := ad.torrentClientRepository.Start() // Start torrent client if it's not running + // if !started { + // ad.logger.Warn().Msg("autodownloader: Failed to start torrent client. Make sure it's running.") + // } + // } + + // Get existing torrents + existingTorrents := make([]*torrent_client.Torrent, 0) + if ad.torrentClientRepository != nil { + existingTorrents, err = ad.torrentClientRepository.GetList() + if err != nil { + existingTorrents = make([]*torrent_client.Torrent, 0) + } + } + + downloaded := 0 + mu := sync.Mutex{} + + // Going through each rule + p := pool.New() + for _, rule := range rules { + rule := rule + p.Go(func() { + if !rule.Enabled { + return // Skip rule + } + listEntry, found := ad.getRuleListEntry(rule) + // If the media is not found, skip the rule + if !found { + return // Skip rule + } + + // DEVNOTE: This is bad, do not skip anime that are not releasing because dubs are delayed + // If the media is not releasing AND has more than one episode, skip the rule + // This is to avoid skipping movies and single-episode OVAs + //if *listEntry.GetMedia().GetStatus() != anilist.MediaStatusReleasing && listEntry.GetMedia().GetCurrentEpisodeCount() > 1 { + // return // Skip rule + //} + + localEntry, _ := lfWrapper.GetLocalEntryById(listEntry.GetMedia().GetID()) + + // +---------------------+ + // | Existing Item | + // +---------------------+ + items, err := ad.database.GetAutoDownloaderItemByMediaId(listEntry.GetMedia().GetID()) + if err != nil { + items = make([]*models.AutoDownloaderItem, 0) + } + + // Get all torrents that follow the rule + torrentsToDownload := make([]*tmpTorrentToDownload, 0) + outer: + for _, t := range torrents { + // If the torrent is already added, skip it + for _, et := range existingTorrents { + if et.Hash == t.InfoHash { + continue outer // Skip the torrent + } + } + + episode, ok := ad.torrentFollowsRule(t, rule, listEntry, localEntry, items) + event := &AutoDownloaderMatchVerifiedEvent{ + Torrent: t, + Rule: rule, + ListEntry: listEntry, + LocalEntry: localEntry, + Episode: episode, + MatchFound: ok, + } + _ = hook.GlobalHookManager.OnAutoDownloaderMatchVerified().Trigger(event) + t = event.Torrent + rule = event.Rule + listEntry = event.ListEntry + localEntry = event.LocalEntry + episode = event.Episode + ok = event.MatchFound + + // Default prevented, skip the torrent + if event.DefaultPrevented { + continue outer // Skip the torrent + } + + if ok { + torrentsToDownload = append(torrentsToDownload, &tmpTorrentToDownload{ + torrent: t, + episode: episode, + }) + } + } + + // Download the torrent if there's only one + if len(torrentsToDownload) == 1 { + t := torrentsToDownload[0] + ok := ad.downloadTorrent(t.torrent, rule, t.episode) + if ok { + downloaded++ + } + return + } + + // If there's more than one, we will group them by episode and sort them + // Make a map [episode]torrents + epMap := make(map[int][]*tmpTorrentToDownload) + for _, t := range torrentsToDownload { + if _, ok := epMap[t.episode]; !ok { + epMap[t.episode] = make([]*tmpTorrentToDownload, 0) + epMap[t.episode] = append(epMap[t.episode], t) + } else { + epMap[t.episode] = append(epMap[t.episode], t) + } + } + + // Go through each episode group and download the best torrent (by resolution and seeders) + for ep, torrents := range epMap { + + // If there's only one torrent for the episode, download it + if len(torrents) == 1 { + ok := ad.downloadTorrent(torrents[0].torrent, rule, ep) + if ok { + mu.Lock() + downloaded++ + mu.Unlock() + } + continue + } + + // If there are more than one + // Sort by resolution + sort.Slice(torrents, func(i, j int) bool { + qI := comparison.ExtractResolutionInt(torrents[i].torrent.ParsedData.VideoResolution) + qJ := comparison.ExtractResolutionInt(torrents[j].torrent.ParsedData.VideoResolution) + return qI > qJ + }) + // Sort by seeds + sort.Slice(torrents, func(i, j int) bool { + return torrents[i].torrent.Seeders > torrents[j].torrent.Seeders + }) + + ok := ad.downloadTorrent(torrents[0].torrent, rule, ep) + if ok { + mu.Lock() + downloaded++ + mu.Unlock() + } + } + }) + } + p.Wait() + + if downloaded > 0 { + if ad.settings.DownloadAutomatically { + notifier.GlobalNotifier.Notify( + notifier.AutoDownloader, + fmt.Sprintf("%d %s %s been downloaded.", downloaded, util.Pluralize(downloaded, "episode", "episodes"), util.Pluralize(downloaded, "has", "have")), + ) + } else { + notifier.GlobalNotifier.Notify( + notifier.AutoDownloader, + fmt.Sprintf("%d %s %s been added to the queue.", downloaded, util.Pluralize(downloaded, "episode", "episodes"), util.Pluralize(downloaded, "has", "have")), + ) + } + } + +} + +func (ad *AutoDownloader) torrentFollowsRule( + t *NormalizedTorrent, + rule *anime.AutoDownloaderRule, + listEntry *anilist.AnimeListEntry, + localEntry *anime.LocalFileWrapperEntry, + items []*models.AutoDownloaderItem, +) (int, bool) { + defer util.HandlePanicInModuleThen("autodownloader/torrentFollowsRule", func() {}) + + if ok := ad.isReleaseGroupMatch(t.ParsedData.ReleaseGroup, rule); !ok { + return -1, false + } + + if ok := ad.isResolutionMatch(t.ParsedData.VideoResolution, rule); !ok { + return -1, false + } + + if ok := ad.isTitleMatch(t.ParsedData, t.Name, rule, listEntry); !ok { + return -1, false + } + + if ok := ad.isAdditionalTermsMatch(t.Name, rule); !ok { + return -1, false + } + + episode, ok := ad.isSeasonAndEpisodeMatch(t.ParsedData, rule, listEntry, localEntry, items) + if !ok { + return -1, false + } + + return episode, true +} + +func (ad *AutoDownloader) downloadTorrent(t *NormalizedTorrent, rule *anime.AutoDownloaderRule, episode int) bool { + defer util.HandlePanicInModuleThen("autodownloader/downloadTorrent", func() {}) + + ad.mu.Lock() + defer ad.mu.Unlock() + + // Double check that the episode hasn't been added while we have the lock + items, err := ad.database.GetAutoDownloaderItemByMediaId(rule.MediaId) + if err == nil { + for _, item := range items { + if item.Episode == episode { + return false // Skip, episode was added by another goroutine + } + } + } + + // Event + beforeEvent := &AutoDownloaderBeforeDownloadTorrentEvent{ + Torrent: t, + Rule: rule, + Items: items, + } + _ = hook.GlobalHookManager.OnAutoDownloaderBeforeDownloadTorrent().Trigger(beforeEvent) + t = beforeEvent.Torrent + rule = beforeEvent.Rule + _ = beforeEvent.Items + + // Default prevented, return + if beforeEvent.DefaultPrevented { + return false + } + + providerExtension, found := ad.torrentRepository.GetDefaultAnimeProviderExtension() + if !found { + ad.logger.Warn().Msg("autodownloader: Could not download torrent. Default provider not found") + return false + } + + if ad.torrentClientRepository == nil { + ad.logger.Error().Msg("autodownloader: torrent client not found") + return false + } + + useDebrid := false + + if ad.settings.UseDebrid { + // Check if the debrid provider is enabled + if !ad.debridClientRepository.HasProvider() || !ad.debridClientRepository.GetSettings().Enabled { + ad.logger.Error().Msg("autodownloader: Debrid provider not found or not enabled") + // We return instead of falling back to torrent client + return false + } + useDebrid = true + } + + // Get torrent magnet + magnet, err := t.GetMagnet(providerExtension.GetProvider()) + if err != nil { + ad.logger.Error().Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to get magnet link for torrent") + return false + } + + downloaded := false + + if useDebrid { + // + // Debrid + // + + if ad.settings.DownloadAutomatically { + // Add the torrent to the debrid provider and queue it + _, err := ad.debridClientRepository.AddAndQueueTorrent(debrid.AddTorrentOptions{ + MagnetLink: magnet, + SelectFileId: "all", // RD-only, select all files + }, rule.Destination, rule.MediaId) + if err != nil { + ad.logger.Error().Err(err).Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to add torrent to debrid") + return false + } + } else { + debridProvider, err := ad.debridClientRepository.GetProvider() + if err != nil { + ad.logger.Error().Err(err).Msg("autodownloader: Failed to get debrid provider") + return false + } + + // Add the torrent to the debrid provider + _, err = debridProvider.AddTorrent(debrid.AddTorrentOptions{ + MagnetLink: magnet, + SelectFileId: "all", // RD-only, select all files + }) + if err != nil { + ad.logger.Error().Err(err).Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to add torrent to debrid") + return false + } + } + + } else { + // Pause the torrent when it's added + if ad.settings.DownloadAutomatically { + + // + // Torrent client + // + started := ad.torrentClientRepository.Start() // Start torrent client if it's not running + if !started { + ad.logger.Error().Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to download torrent. torrent client is not running.") + return false + } + + // Return if the torrent is already added + torrentExists := ad.torrentClientRepository.TorrentExists(t.InfoHash) + if torrentExists { + //ad.Logger.Debug().Str("name", t.Name).Msg("autodownloader: Torrent already added") + return false + } + + ad.logger.Debug().Msgf("autodownloader: Downloading torrent: %s", t.Name) + + // Add the torrent to torrent client + err := ad.torrentClientRepository.AddMagnets([]string{magnet}, rule.Destination) + if err != nil { + ad.logger.Error().Err(err).Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to add torrent to torrent client") + return false + } + + downloaded = true + } + } + + ad.logger.Info().Str("name", t.Name).Msg("autodownloader: Added torrent") + ad.wsEventManager.SendEvent(events.AutoDownloaderItemAdded, t.Name) + + // Add the torrent to the database + item := &models.AutoDownloaderItem{ + RuleID: rule.DbID, + MediaID: rule.MediaId, + Episode: episode, + Link: t.Link, + Hash: t.InfoHash, + Magnet: magnet, + TorrentName: t.Name, + Downloaded: downloaded, + } + _ = ad.database.InsertAutoDownloaderItem(item) + + // Event + afterEvent := &AutoDownloaderAfterDownloadTorrentEvent{ + Torrent: t, + Rule: rule, + } + _ = hook.GlobalHookManager.OnAutoDownloaderAfterDownloadTorrent().Trigger(afterEvent) + + return true +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (ad *AutoDownloader) isAdditionalTermsMatch(torrentName string, rule *anime.AutoDownloaderRule) (ok bool) { + defer util.HandlePanicInModuleThen("autodownloader/isAdditionalTermsMatch", func() { + ok = false + }) + + if len(rule.AdditionalTerms) == 0 { + return true + } + + // Go through each additional term + for _, optionsText := range rule.AdditionalTerms { + // Split the options by comma + options := strings.Split(strings.TrimSpace(optionsText), ",") + // Check if the torrent name contains at least one of the options + foundOption := false + for _, option := range options { + option := strings.TrimSpace(option) + if strings.Contains(strings.ToLower(torrentName), strings.ToLower(option)) { + foundOption = true + } + } + // If the torrent name doesn't contain any of the options, return false + if !foundOption { + return false + } + } + + // If all options are found, return true + return true +} +func (ad *AutoDownloader) isReleaseGroupMatch(releaseGroup string, rule *anime.AutoDownloaderRule) (ok bool) { + defer util.HandlePanicInModuleThen("autodownloader/isReleaseGroupMatch", func() { + ok = false + }) + + if len(rule.ReleaseGroups) == 0 { + return true + } + for _, rg := range rule.ReleaseGroups { + if strings.ToLower(rg) == strings.ToLower(releaseGroup) { + return true + } + } + return false +} + +// isResolutionMatch +// DEVOTE: Improve this +func (ad *AutoDownloader) isResolutionMatch(quality string, rule *anime.AutoDownloaderRule) (ok bool) { + defer util.HandlePanicInModuleThen("autodownloader/isResolutionMatch", func() { + ok = false + }) + + if len(rule.Resolutions) == 0 { + return true + } + if quality == "" { + return false + } + for _, q := range rule.Resolutions { + qualityWithoutP := strings.TrimSuffix(quality, "p") + qWithoutP := strings.TrimSuffix(q, "p") + if quality == q || qualityWithoutP == qWithoutP { + return true + } + if strings.Contains(quality, qWithoutP) { // e.g. 1080 in 1920x1080 + return true + } + } + return false +} + +func (ad *AutoDownloader) isTitleMatch(torrentParsedData *habari.Metadata, torrentName string, rule *anime.AutoDownloaderRule, listEntry *anilist.AnimeListEntry) (ok bool) { + defer util.HandlePanicInModuleThen("autodownloader/isTitleMatch", func() { + ok = false + }) + + switch rule.TitleComparisonType { + case anime.AutoDownloaderRuleTitleComparisonContains: + // +---------------------+ + // | Title "Contains" | + // +---------------------+ + + // Check if the torrent name contains the comparison title exactly + // This will fail for torrent titles that don't contain a season number if the comparison title has a season number + if strings.Contains(strings.ToLower(torrentParsedData.Title), strings.ToLower(rule.ComparisonTitle)) { + return true + } + if strings.Contains(strings.ToLower(torrentName), strings.ToLower(rule.ComparisonTitle)) { + return true + } + + case anime.AutoDownloaderRuleTitleComparisonLikely: + // +---------------------+ + // | Title "Likely" | + // +---------------------+ + + torrentTitle := torrentParsedData.Title + comparisonTitle := strings.ReplaceAll(strings.ReplaceAll(rule.ComparisonTitle, "[", ""), "]", "") + + // 1. Use comparison title (without season number - if it exists) + // Remove season number from the torrent title if it exists + parsedComparisonTitle := habari.Parse(comparisonTitle) + if parsedComparisonTitle.Title != "" && len(parsedComparisonTitle.SeasonNumber) > 0 { + _comparisonTitle := parsedComparisonTitle.Title + if len(parsedComparisonTitle.ReleaseGroup) > 0 { + _comparisonTitle = fmt.Sprintf("%s %s", parsedComparisonTitle.ReleaseGroup, _comparisonTitle) + _comparisonTitle = strings.TrimSpace(_comparisonTitle) + } + + // First, use comparison title, compare without season number + // e.g. Torrent: "[Seanime] Jujutsu Kaisen 2nd Season - 20 [...].mkv" -> "Jujutsu Kaisen" + // e.g. Comparison Title: "Jujutsu Kaisen 2nd Season" -> "Jujutsu Kaisen" + + // DEVNOTE: isSeasonAndEpisodeMatch will handle the case where the torrent has a season number + + // Make sure the distance is not too great + lev := metrics.NewLevenshtein() + lev.CaseSensitive = false + res := lev.Distance(torrentTitle, _comparisonTitle) + if res < 4 { + return true + } + } + + // 2. Use media titles + // If we're here, it means that either + // - the comparison title doesn't have a season number + // - the comparison title (w/o season number) is not similar to the torrent title + + torrentTitleVariations := []*string{&torrentTitle} + + if len(torrentParsedData.SeasonNumber) > 0 { + season := util.StringToIntMust(torrentParsedData.SeasonNumber[0]) + if season > 1 { + // If the torrent has a season number, add it to the variations + torrentTitleVariations = []*string{ + lo.ToPtr(fmt.Sprintf("%s Season %s", torrentParsedData.Title, torrentParsedData.SeasonNumber[0])), + lo.ToPtr(fmt.Sprintf("%s S%s", torrentParsedData.Title, torrentParsedData.SeasonNumber[0])), + lo.ToPtr(fmt.Sprintf("%s %s Season", torrentParsedData.Title, util.IntegerToOrdinal(util.StringToIntMust(torrentParsedData.SeasonNumber[0])))), + } + } + } + + // If the parsed comparison title doesn't match, compare the torrent title with media titles + mediaTitles := listEntry.GetMedia().GetAllTitles() + var compRes *comparison.SorensenDiceResult + for _, title := range torrentTitleVariations { + res, found := comparison.FindBestMatchWithSorensenDice(title, mediaTitles) + if found { + if compRes == nil || res.Rating > compRes.Rating { + compRes = res + } + } + } + + // If the best match is not found + // /!\ This shouldn't happen since the media titles are always present + if compRes == nil { + // Compare using rule comparison title + sd := metrics.NewSorensenDice() + sd.CaseSensitive = false + res := sd.Compare(torrentTitle, comparisonTitle) + + if res > ComparisonThreshold { + return true + } + return false + } + + // If the best match is found + if compRes.Rating > ComparisonThreshold { + return true + } + + return false + } + return false +} + +func (ad *AutoDownloader) isSeasonAndEpisodeMatch( + parsedData *habari.Metadata, + rule *anime.AutoDownloaderRule, + listEntry *anilist.AnimeListEntry, + localEntry *anime.LocalFileWrapperEntry, + items []*models.AutoDownloaderItem, +) (a int, b bool) { + defer util.HandlePanicInModuleThen("autodownloader/isSeasonAndEpisodeMatch", func() { + b = false + }) + + if listEntry == nil { + return -1, false + } + + episodes := parsedData.EpisodeNumber + + // Skip if we parsed more than one episode number (e.g. "01-02") + // We can't handle this case since it might be a batch release + if len(episodes) > 1 { + return -1, false + } + + var ok bool + episode := 1 + if len(episodes) == 1 { + _episode, _ok := util.StringToInt(episodes[0]) + if _ok { + episode = _episode + ok = true + } + } + + // +---------------------+ + // | No episode number | + // +---------------------+ + + // We can't parse the episode number + if !ok { + // Return true if the media (has only one episode or is a movie) AND (is not in the library) + if listEntry.GetMedia().GetCurrentEpisodeCount() == 1 || *listEntry.GetMedia().GetFormat() == anilist.MediaFormatMovie { + // Make sure it wasn't already added + for _, item := range items { + if item.Episode == 1 { + return -1, false // Skip, file already queued or downloaded + } + } + // Make sure it doesn't exist in the library + if localEntry != nil { + if _, found := localEntry.FindLocalFileWithEpisodeNumber(1); found { + return -1, false // Skip, file already exists + } + } + return 1, true // Good to go + } + return -1, false + } + + // +---------------------+ + // | Episode number | + // +---------------------+ + + hasAbsoluteEpisode := false + + // Handle ABSOLUTE episode numbers + if listEntry.GetMedia().GetCurrentEpisodeCount() != -1 && episode > listEntry.GetMedia().GetCurrentEpisodeCount() { + // Fetch the Animap media in order to normalize the episode number + ad.mu.Lock() + animeMetadata, err := ad.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, listEntry.GetMedia().GetID()) + // If the media is found and the offset is greater than 0 + if err == nil && animeMetadata.GetOffset() > 0 { + hasAbsoluteEpisode = true + episode = episode - animeMetadata.GetOffset() + } + ad.mu.Unlock() + } + + // Return false if the episode is already downloaded + for _, item := range items { + if item.Episode == episode { + return -1, false // Skip, file already queued or downloaded + } + } + + // Return false if the episode is already in the library + if localEntry != nil { + if _, found := localEntry.FindLocalFileWithEpisodeNumber(episode); found { + return -1, false + } + } + + // If there's no absolute episode number, check that the episode number is not greater than the current episode count + if !hasAbsoluteEpisode && episode > listEntry.GetMedia().GetCurrentEpisodeCount() { + return -1, false + } + + // As a last check, make sure the seasons match ONLY if the episode number is not absolute + // We do this check only for "likely" title comparison type since the season numbers are not compared + if ad.settings.EnableSeasonCheck { + if !hasAbsoluteEpisode { + switch rule.TitleComparisonType { + case anime.AutoDownloaderRuleTitleComparisonLikely: + // If the title comparison type is "Likely", we will compare the season numbers + if len(parsedData.SeasonNumber) > 0 { + season, ok := util.StringToInt(parsedData.SeasonNumber[0]) + if ok && season > 1 { + parsedComparisonTitle := habari.Parse(rule.ComparisonTitle) + if len(parsedComparisonTitle.SeasonNumber) == 0 { + return -1, false + } + if season != util.StringToIntMust(parsedComparisonTitle.SeasonNumber[0]) { + return -1, false + } + } + } + } + } + } + + switch rule.EpisodeType { + case anime.AutoDownloaderRuleEpisodeRecent: + // +---------------------+ + // | Episode "Recent" | + // +---------------------+ + // Return false if the user has already watched the episode + if listEntry.Progress != nil && *listEntry.GetProgress() > episode { + return -1, false + } + return episode, true // Good to go + case anime.AutoDownloaderRuleEpisodeSelected: + // +---------------------+ + // | Episode "Selected" | + // +---------------------+ + // Return true if the episode is in the list of selected episodes + for _, ep := range rule.EpisodeNumbers { + if ep == episode { + return episode, true // Good to go + } + } + return -1, false + } + return -1, false +} + +func (ad *AutoDownloader) getRuleListEntry(rule *anime.AutoDownloaderRule) (*anilist.AnimeListEntry, bool) { + if rule == nil || rule.MediaId == 0 || ad.animeCollection.IsAbsent() { + return nil, false + } + + listEntry, found := ad.animeCollection.MustGet().GetListEntryFromAnimeId(rule.MediaId) + if !found { + return nil, false + } + + return listEntry, true +} diff --git a/seanime-2.9.10/internal/library/autodownloader/autodownloader_torrent.go b/seanime-2.9.10/internal/library/autodownloader/autodownloader_torrent.go new file mode 100644 index 0000000..cfc7eff --- /dev/null +++ b/seanime-2.9.10/internal/library/autodownloader/autodownloader_torrent.go @@ -0,0 +1,93 @@ +package autodownloader + +import ( + "errors" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/library/anime" + "sync" + + "github.com/5rahim/habari" + "github.com/samber/lo" +) + +type ( + // NormalizedTorrent is a struct built from torrent from a provider. + // It is used to normalize the data from different providers so that it can be used by the AutoDownloader. + NormalizedTorrent struct { + hibiketorrent.AnimeTorrent + ParsedData *habari.Metadata `json:"parsedData"` + magnet string // Access using GetMagnet() + } +) + +func (ad *AutoDownloader) getLatestTorrents(rules []*anime.AutoDownloaderRule) (ret []*NormalizedTorrent, err error) { + ad.logger.Debug().Msg("autodownloader: Checking for new episodes") + + providerExtension, ok := ad.torrentRepository.GetDefaultAnimeProviderExtension() + if !ok { + ad.logger.Warn().Msg("autodownloader: No default torrent provider found") + return nil, errors.New("no default torrent provider found") + } + + // Get the latest torrents + torrents, err := providerExtension.GetProvider().GetLatest() + if err != nil { + ad.logger.Error().Err(err).Msg("autodownloader: Failed to get latest torrents") + return nil, err + } + + if ad.settings.EnableEnhancedQueries { + // Get unique release groups + uniqueReleaseGroups := GetUniqueReleaseGroups(rules) + // Filter the torrents + wg := sync.WaitGroup{} + mu := sync.Mutex{} + wg.Add(len(uniqueReleaseGroups)) + + for _, releaseGroup := range uniqueReleaseGroups { + go func(releaseGroup string) { + defer wg.Done() + filteredTorrents, err := providerExtension.GetProvider().Search(hibiketorrent.AnimeSearchOptions{ + Media: hibiketorrent.Media{}, + Query: releaseGroup, + }) + if err != nil { + return + } + mu.Lock() + torrents = append(torrents, filteredTorrents...) + mu.Unlock() + }(releaseGroup) + } + wg.Wait() + // Remove duplicates + torrents = lo.UniqBy(torrents, func(t *hibiketorrent.AnimeTorrent) string { + return t.Name + }) + } + + // Normalize the torrents + ret = make([]*NormalizedTorrent, 0, len(torrents)) + for _, t := range torrents { + parsedData := habari.Parse(t.Name) + ret = append(ret, &NormalizedTorrent{ + AnimeTorrent: *t, + ParsedData: parsedData, + }) + } + + return ret, nil +} + +// GetMagnet returns the magnet link for the torrent. +func (t *NormalizedTorrent) GetMagnet(providerExtension hibiketorrent.AnimeProvider) (string, error) { + if t.magnet == "" { + magnet, err := providerExtension.GetTorrentMagnetLink(&t.AnimeTorrent) + if err != nil { + return "", err + } + t.magnet = magnet + return t.magnet, nil + } + return t.magnet, nil +} diff --git a/seanime-2.9.10/internal/library/autodownloader/comparison_test.go b/seanime-2.9.10/internal/library/autodownloader/comparison_test.go new file mode 100644 index 0000000..8e3ce9c --- /dev/null +++ b/seanime-2.9.10/internal/library/autodownloader/comparison_test.go @@ -0,0 +1,327 @@ +package autodownloader + +import ( + "github.com/5rahim/habari" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/database/models" + "seanime/internal/library/anime" + "testing" +) + +func TestComparison(t *testing.T) { + ad := AutoDownloader{ + metadataProvider: metadata.GetMockProvider(t), + settings: &models.AutoDownloaderSettings{ + EnableSeasonCheck: true, + }, + } + name1 := "[Oshi no Ko] 2nd Season" + name2 := "Oshi no Ko Season 2" + aniListEntry := &anilist.AnimeListEntry{ + Media: &anilist.BaseAnime{ + ID: 166531, + Title: &anilist.BaseAnime_Title{ + Romaji: &name1, + English: &name2, + }, + Episodes: lo.ToPtr(13), + Format: lo.ToPtr(anilist.MediaFormatTv), + }, + } + + rule := &anime.AutoDownloaderRule{ + MediaId: 166531, + ReleaseGroups: []string{"SubsPlease", "Erai-raws"}, + Resolutions: []string{"1080p"}, + TitleComparisonType: "likely", + EpisodeType: "recent", + EpisodeNumbers: []int{3}, // ignored + Destination: "/data/seanime/library/[Oshi no Ko] 2nd Season", + ComparisonTitle: "[Oshi no Ko] 2nd Season", + } + + tests := []struct { + torrentName string + succeedTitleComparison bool + succeedSeasonAndEpisodeMatch bool + enableSeasonCheck bool + }{ + { + torrentName: "[Erai-raws] Oshi no Ko 2nd Season - 03 [720p][Multiple Subtitle] [ENG][FRE]", + succeedTitleComparison: true, + succeedSeasonAndEpisodeMatch: true, + enableSeasonCheck: true, + }, + { + torrentName: "[SubsPlease] Oshi no Ko - 16 (1080p)", + succeedTitleComparison: true, + succeedSeasonAndEpisodeMatch: true, + enableSeasonCheck: true, + }, + { + torrentName: "[Erai-raws] Oshi no Ko 3rd Season - 03 [720p][Multiple Subtitle] [ENG][FRE]", + succeedTitleComparison: true, + succeedSeasonAndEpisodeMatch: false, + enableSeasonCheck: true, + }, + { + torrentName: "[Erai-raws] Oshi no Ko 2nd Season - 03 [720p][Multiple Subtitle] [ENG][FRE]", + succeedTitleComparison: true, + succeedSeasonAndEpisodeMatch: true, + enableSeasonCheck: false, + }, + { + torrentName: "[SubsPlease] Oshi no Ko - 16 (1080p)", + succeedTitleComparison: true, + succeedSeasonAndEpisodeMatch: true, + enableSeasonCheck: false, + }, + { + torrentName: "[Erai-raws] Oshi no Ko 3rd Season - 03 [720p][Multiple Subtitle] [ENG][FRE]", + succeedTitleComparison: true, + succeedSeasonAndEpisodeMatch: true, + enableSeasonCheck: false, + }, + } + + lfw := anime.NewLocalFileWrapper([]*anime.LocalFile{ + { + Path: "/data/seanime/library/[Oshi no Ko] 2nd Season/[SubsPlease] Oshi no Ko - 12 (1080p).mkv", + Name: "Oshi no Ko - 12 (1080p).mkv", + ParsedData: &anime.LocalFileParsedData{ + Original: "Oshi no Ko - 12 (1080p).mkv", + Title: "Oshi no Ko", + ReleaseGroup: "SubsPlease", + }, + ParsedFolderData: []*anime.LocalFileParsedData{ + { + Original: "[Oshi no Ko] 2nd Season", + Title: "[Oshi no Ko]", + }, + }, + Metadata: &anime.LocalFileMetadata{ + Episode: 1, + AniDBEpisode: "1", + Type: "main", + }, + MediaId: 166531, + }, + }) + + for _, tt := range tests { + t.Run(tt.torrentName, func(t *testing.T) { + + ad.settings.EnableSeasonCheck = tt.enableSeasonCheck + + p := habari.Parse(tt.torrentName) + if tt.succeedTitleComparison { + require.True(t, ad.isTitleMatch(p, tt.torrentName, rule, aniListEntry)) + } else { + require.False(t, ad.isTitleMatch(p, tt.torrentName, rule, aniListEntry)) + } + lfwe, ok := lfw.GetLocalEntryById(166531) + require.True(t, ok) + _, ok = ad.isSeasonAndEpisodeMatch(p, rule, aniListEntry, lfwe, []*models.AutoDownloaderItem{}) + if tt.succeedSeasonAndEpisodeMatch { + require.True(t, ok) + } else { + require.False(t, ok) + } + }) + } + +} + +func TestComparison2(t *testing.T) { + ad := AutoDownloader{ + metadataProvider: metadata.GetMockProvider(t), + settings: &models.AutoDownloaderSettings{ + EnableSeasonCheck: true, + }, + } + name1 := "DANDADAN" + name2 := "Dandadan" + aniListEntry := &anilist.AnimeListEntry{ + Media: &anilist.BaseAnime{ + Title: &anilist.BaseAnime_Title{ + Romaji: &name1, + English: &name2, + }, + Episodes: lo.ToPtr(12), + Status: lo.ToPtr(anilist.MediaStatusFinished), + Format: lo.ToPtr(anilist.MediaFormatTv), + }, + } + + rule := &anime.AutoDownloaderRule{ + MediaId: 166531, + ReleaseGroups: []string{}, + Resolutions: []string{"1080p"}, + TitleComparisonType: "likely", + EpisodeType: "recent", + EpisodeNumbers: []int{}, + Destination: "/data/seanime/library/Dandadan", + ComparisonTitle: "Dandadan", + } + + tests := []struct { + torrentName string + succeedAdditionalTermsMatch bool + ruleAdditionalTerms []string + }{ + { + torrentName: "[Anime Time] Dandadan - 04 [Dual Audio][1080p][HEVC 10bit x265][AAC][Multi Sub] [Weekly]", + ruleAdditionalTerms: []string{}, + succeedAdditionalTermsMatch: true, + }, + { + torrentName: "[Anime Time] Dandadan - 04 [Dual Audio][1080p][HEVC 10bit x265][AAC][Multi Sub] [Weekly]", + ruleAdditionalTerms: []string{ + "H265,H.265, H 265,x265", + "10bit,10-bit,10 bit", + }, + succeedAdditionalTermsMatch: true, + }, + { + torrentName: "[Raze] Dandadan - 04 x265 10bit 1080p 143.8561fps.mkv", + ruleAdditionalTerms: []string{ + "H265,H.265, H 265,x265", + "10bit,10-bit,10 bit", + }, + succeedAdditionalTermsMatch: true, + }, + //{ // DEVNOTE: Doesn't pass because of title + // torrentName: "[Sokudo] DAN DA DAN | Dandadan - S01E03 [1080p EAC-3 AV1][Dual Audio] (weekly)", + // ruleAdditionalTerms: []string{ + // "H265,H.265, H 265,x265", + // "10bit,10-bit,10 bit", + // }, + // succeedAdditionalTermsMatch: false, + //}, + { + torrentName: "[Raze] Dandadan - 04 x265 10bit 1080p 143.8561fps.mkv", + ruleAdditionalTerms: []string{ + "H265,H.265, H 265,x265", + "10bit,10-bit,10 bit", + "AAC", + }, + succeedAdditionalTermsMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.torrentName, func(t *testing.T) { + + rule.AdditionalTerms = tt.ruleAdditionalTerms + + ok := ad.isTitleMatch(habari.Parse(tt.torrentName), tt.torrentName, rule, aniListEntry) + assert.True(t, ok) + + ok = ad.isAdditionalTermsMatch(tt.torrentName, rule) + if tt.succeedAdditionalTermsMatch { + assert.True(t, ok) + } else { + assert.False(t, ok) + } + }) + } + +} + +func TestComparison3(t *testing.T) { + ad := AutoDownloader{ + metadataProvider: metadata.GetMockProvider(t), + settings: &models.AutoDownloaderSettings{ + EnableSeasonCheck: true, + }, + } + name1 := "Dandadan" + name2 := "DAN DA DAN" + aniListEntry := &anilist.AnimeListEntry{ + Media: &anilist.BaseAnime{ + Title: &anilist.BaseAnime_Title{ + Romaji: &name1, + English: &name2, + }, + Status: lo.ToPtr(anilist.MediaStatusFinished), + Episodes: lo.ToPtr(12), + Format: lo.ToPtr(anilist.MediaFormatTv), + }, + } + + rule := &anime.AutoDownloaderRule{ + MediaId: 166531, + ReleaseGroups: []string{}, + Resolutions: []string{}, + TitleComparisonType: "likely", + EpisodeType: "recent", + EpisodeNumbers: []int{}, + Destination: "/data/seanime/library/Dandadan", + ComparisonTitle: "Dandadan", + } + + tests := []struct { + torrentName string + succeedTitleComparison bool + succeedSeasonAndEpisodeMatch bool + enableSeasonCheck bool + }{ + { + torrentName: "[Salieri] Zom 100 Bucket List of the Dead - S1 - BD (1080p) (HDR) [Dual Audio]", + succeedTitleComparison: false, + succeedSeasonAndEpisodeMatch: false, + enableSeasonCheck: false, + }, + } + + lfw := anime.NewLocalFileWrapper([]*anime.LocalFile{ + { + Path: "/data/seanime/library/Dandadan/[SubsPlease] Dandadan - 01 (1080p).mkv", + Name: "Dandadan - 01 (1080p).mkv", + ParsedData: &anime.LocalFileParsedData{ + Original: "Dandadan - 01 (1080p).mkv", + Title: "Dandadan", + ReleaseGroup: "SubsPlease", + }, + ParsedFolderData: []*anime.LocalFileParsedData{ + { + Original: "Dandadan", + Title: "Dandadan", + }, + }, + Metadata: &anime.LocalFileMetadata{ + Episode: 1, + AniDBEpisode: "1", + Type: "main", + }, + MediaId: 171018, + }, + }) + + for _, tt := range tests { + t.Run(tt.torrentName, func(t *testing.T) { + + ad.settings.EnableSeasonCheck = tt.enableSeasonCheck + + p := habari.Parse(tt.torrentName) + if tt.succeedTitleComparison { + require.True(t, ad.isTitleMatch(p, tt.torrentName, rule, aniListEntry)) + } else { + require.False(t, ad.isTitleMatch(p, tt.torrentName, rule, aniListEntry)) + } + lfwe, ok := lfw.GetLocalEntryById(171018) + require.True(t, ok) + _, ok = ad.isSeasonAndEpisodeMatch(p, rule, aniListEntry, lfwe, []*models.AutoDownloaderItem{}) + if tt.succeedSeasonAndEpisodeMatch { + assert.True(t, ok) + } else { + assert.False(t, ok) + } + }) + } + +} diff --git a/seanime-2.9.10/internal/library/autodownloader/helpers.go b/seanime-2.9.10/internal/library/autodownloader/helpers.go new file mode 100644 index 0000000..497cba4 --- /dev/null +++ b/seanime-2.9.10/internal/library/autodownloader/helpers.go @@ -0,0 +1,21 @@ +package autodownloader + +import ( + "seanime/internal/library/anime" + "strings" +) + +func GetUniqueReleaseGroups(rules []*anime.AutoDownloaderRule) []string { + uniqueReleaseGroups := make(map[string]string) + for _, rule := range rules { + for _, releaseGroup := range rule.ReleaseGroups { + // make it case-insensitive + uniqueReleaseGroups[strings.ToLower(releaseGroup)] = releaseGroup + } + } + var result []string + for k := range uniqueReleaseGroups { + result = append(result, k) + } + return result +} diff --git a/seanime-2.9.10/internal/library/autodownloader/hook_events.go b/seanime-2.9.10/internal/library/autodownloader/hook_events.go new file mode 100644 index 0000000..cd2230c --- /dev/null +++ b/seanime-2.9.10/internal/library/autodownloader/hook_events.go @@ -0,0 +1,60 @@ +package autodownloader + +import ( + "seanime/internal/api/anilist" + "seanime/internal/database/models" + "seanime/internal/hook_resolver" + "seanime/internal/library/anime" +) + +// AutoDownloaderRunStartedEvent is triggered when the autodownloader starts checking for new episodes. +// Prevent default to abort the run. +type AutoDownloaderRunStartedEvent struct { + hook_resolver.Event + Rules []*anime.AutoDownloaderRule `json:"rules"` +} + +// AutoDownloaderTorrentsFetchedEvent is triggered at the beginning of a run, when the autodownloader fetches torrents from the provider. +type AutoDownloaderTorrentsFetchedEvent struct { + hook_resolver.Event + Torrents []*NormalizedTorrent `json:"torrents"` +} + +// AutoDownloaderMatchVerifiedEvent is triggered when a torrent is verified to follow a rule. +// Prevent default to abort the download if the match is found. +type AutoDownloaderMatchVerifiedEvent struct { + hook_resolver.Event + // Fetched torrent + Torrent *NormalizedTorrent `json:"torrent"` + Rule *anime.AutoDownloaderRule `json:"rule"` + ListEntry *anilist.AnimeListEntry `json:"listEntry"` + LocalEntry *anime.LocalFileWrapperEntry `json:"localEntry"` + // The episode number found for the match + // If the match failed, this will be 0 + Episode int `json:"episode"` + // Whether the torrent matches the rule + // Changing this value to true will trigger a download even if the match failed; + MatchFound bool `json:"matchFound"` +} + +// AutoDownloaderSettingsUpdatedEvent is triggered when the autodownloader settings are updated +type AutoDownloaderSettingsUpdatedEvent struct { + hook_resolver.Event + Settings *models.AutoDownloaderSettings `json:"settings"` +} + +// AutoDownloaderBeforeDownloadTorrentEvent is triggered when the autodownloader is about to download a torrent. +// Prevent default to abort the download. +type AutoDownloaderBeforeDownloadTorrentEvent struct { + hook_resolver.Event + Torrent *NormalizedTorrent `json:"torrent"` + Rule *anime.AutoDownloaderRule `json:"rule"` + Items []*models.AutoDownloaderItem `json:"items"` +} + +// AutoDownloaderAfterDownloadTorrentEvent is triggered when the autodownloader has downloaded a torrent. +type AutoDownloaderAfterDownloadTorrentEvent struct { + hook_resolver.Event + Torrent *NormalizedTorrent `json:"torrent"` + Rule *anime.AutoDownloaderRule `json:"rule"` +} diff --git a/seanime-2.9.10/internal/library/autoscanner/autoscanner.go b/seanime-2.9.10/internal/library/autoscanner/autoscanner.go new file mode 100644 index 0000000..e44267e --- /dev/null +++ b/seanime-2.9.10/internal/library/autoscanner/autoscanner.go @@ -0,0 +1,270 @@ +package autoscanner + +import ( + "context" + "errors" + "seanime/internal/api/metadata" + "seanime/internal/database/db" + "seanime/internal/database/db_bridge" + "seanime/internal/database/models" + "seanime/internal/events" + "seanime/internal/library/autodownloader" + "seanime/internal/library/scanner" + "seanime/internal/library/summary" + "seanime/internal/notifier" + "seanime/internal/platforms/platform" + "seanime/internal/util" + "sync" + "time" + + "github.com/rs/zerolog" +) + +type ( + AutoScanner struct { + fileActionCh chan struct{} // Used to notify the scanner that a file action has occurred. + waiting bool // Used to prevent multiple scans from occurring at the same time. + missedAction bool // Used to indicate that a file action was missed while scanning. + mu sync.Mutex + scannedCh chan struct{} + waitTime time.Duration // Wait time to listen to additional changes before triggering a scan. + enabled bool + settings models.LibrarySettings + platform platform.Platform + logger *zerolog.Logger + wsEventManager events.WSEventManagerInterface + db *db.Database // Database instance is required to update the local files. + autoDownloader *autodownloader.AutoDownloader // AutoDownloader instance is required to refresh queue. + metadataProvider metadata.Provider + logsDir string + } + NewAutoScannerOptions struct { + Database *db.Database + Platform platform.Platform + Logger *zerolog.Logger + WSEventManager events.WSEventManagerInterface + Enabled bool + AutoDownloader *autodownloader.AutoDownloader + WaitTime time.Duration + MetadataProvider metadata.Provider + LogsDir string + } +) + +func New(opts *NewAutoScannerOptions) *AutoScanner { + wt := time.Second * 15 // Default wait time is 15 seconds. + if opts.WaitTime > 0 { + wt = opts.WaitTime + } + + return &AutoScanner{ + fileActionCh: make(chan struct{}, 1), + waiting: false, + missedAction: false, + mu: sync.Mutex{}, + scannedCh: make(chan struct{}, 1), + waitTime: wt, + enabled: opts.Enabled, + platform: opts.Platform, + logger: opts.Logger, + wsEventManager: opts.WSEventManager, + db: opts.Database, + autoDownloader: opts.AutoDownloader, + metadataProvider: opts.MetadataProvider, + logsDir: opts.LogsDir, + } +} + +// Notify is used to notify the AutoScanner that a file action has occurred. +func (as *AutoScanner) Notify() { + if as == nil { + return + } + + defer util.HandlePanicInModuleThen("scanner/autoscanner/Notify", func() { + as.logger.Error().Msg("autoscanner: recovered from panic") + }) + + as.mu.Lock() + defer as.mu.Unlock() + + // If we are currently scanning, we will set the missedAction flag to true. + if as.waiting { + as.missedAction = true + return + } + + if as.enabled { + go func() { + // Otherwise, we will send a signal to the fileActionCh. + as.fileActionCh <- struct{}{} + }() + } +} + +// Start starts the AutoScanner in a goroutine. +func (as *AutoScanner) Start() { + go func() { + if as.enabled { + as.logger.Info().Msg("autoscanner: Module started") + } + + as.watch() + }() +} + +// SetSettings should be called after the settings are fetched and updated from the database. +func (as *AutoScanner) SetSettings(settings models.LibrarySettings) { + as.mu.Lock() + defer as.mu.Unlock() + + as.enabled = settings.AutoScan + as.settings = settings +} + +// watch is used to watch for file actions and trigger a scan. +// When a file action occurs, it will wait 30 seconds before triggering a scan. +// If another file action occurs within that 30 seconds, it will reset the timer. +// After the 30 seconds have passed, it will trigger a scan. +// When a scan is complete, it will check the missedAction flag and trigger another scan if necessary. +func (as *AutoScanner) watch() { + defer util.HandlePanicInModuleThen("scanner/autoscanner/watch", func() { + as.logger.Error().Msg("autoscanner: recovered from panic") + }) + + for { + // Block until the file action channel is ready to receive a signal. + <-as.fileActionCh + as.waitAndScan() + } + +} + +// waitAndScan is used to wait for additional file actions before triggering a scan. +func (as *AutoScanner) waitAndScan() { + as.logger.Trace().Msgf("autoscanner: File action occurred, waiting %v seconds before triggering a scan.", as.waitTime.Seconds()) + as.mu.Lock() + as.waiting = true // Set the scanning flag to true. + as.missedAction = false // Reset the missedAction flag. + as.mu.Unlock() + + // Wait 30 seconds before triggering a scan. + // During this time, if another file action occurs, it will reset the timer after it has expired. + <-time.After(as.waitTime) + + as.mu.Lock() + // If a file action occurred while we were waiting, we will trigger another scan. + if as.missedAction { + as.logger.Trace().Msg("autoscanner: Missed file action") + as.mu.Unlock() + as.waitAndScan() + return + } + + as.waiting = false + as.mu.Unlock() + + // Trigger a scan. + as.scan() +} + +// RunNow bypasses checks and triggers a scan immediately, even if the autoscanner is disabled. +func (as *AutoScanner) RunNow() { + as.scan() +} + +// scan is used to trigger a scan. +func (as *AutoScanner) scan() { + defer util.HandlePanicInModuleThen("scanner/autoscanner/scan", func() { + as.logger.Error().Msg("autoscanner: Recovered from panic") + }) + + // Create scan summary logger + scanSummaryLogger := summary.NewScanSummaryLogger() + + as.logger.Trace().Msg("autoscanner: Starting scanner") + as.wsEventManager.SendEvent(events.AutoScanStarted, nil) + defer as.wsEventManager.SendEvent(events.AutoScanCompleted, nil) + + settings, err := as.db.GetSettings() + if err != nil || settings == nil { + as.logger.Error().Err(err).Msg("autoscanner: Failed to get settings") + return + } + + if settings.Library.LibraryPath == "" { + as.logger.Error().Msg("autoscanner: Library path is not set") + return + } + + // Get existing local files + existingLfs, _, err := db_bridge.GetLocalFiles(as.db) + if err != nil { + as.logger.Error().Err(err).Msg("autoscanner: Failed to get existing local files") + return + } + + // Create a new scan logger + var scanLogger *scanner.ScanLogger + if as.logsDir != "" { + scanLogger, err = scanner.NewScanLogger(as.logsDir) + if err != nil { + as.logger.Error().Err(err).Msg("autoscanner: Failed to create scan logger") + return + } + defer scanLogger.Done() + } + + // Create a new scanner + sc := scanner.Scanner{ + DirPath: settings.Library.LibraryPath, + OtherDirPaths: settings.Library.LibraryPaths, + Enhanced: false, // Do not use enhanced mode for auto scanner. + Platform: as.platform, + Logger: as.logger, + WSEventManager: as.wsEventManager, + ExistingLocalFiles: existingLfs, + SkipLockedFiles: true, // Skip locked files by default. + SkipIgnoredFiles: true, + ScanSummaryLogger: scanSummaryLogger, + ScanLogger: scanLogger, + MetadataProvider: as.metadataProvider, + MatchingThreshold: as.settings.ScannerMatchingThreshold, + MatchingAlgorithm: as.settings.ScannerMatchingAlgorithm, + } + + allLfs, err := sc.Scan(context.Background()) + if err != nil { + if errors.Is(err, scanner.ErrNoLocalFiles) { + return + } else { + as.logger.Error().Err(err).Msg("autoscanner: Failed to scan library") + return + } + } + + if as.db != nil && len(allLfs) > 0 { + as.logger.Trace().Msg("autoscanner: Updating local files") + + // Insert the local files + _, err = db_bridge.InsertLocalFiles(as.db, allLfs) + if err != nil { + as.logger.Error().Err(err).Msg("failed to insert local files") + return + } + + } + + // Save the scan summary + err = db_bridge.InsertScanSummary(as.db, scanSummaryLogger.GenerateSummary()) + if err != nil { + as.logger.Error().Err(err).Msg("failed to insert scan summary") + } + + // Refresh the queue + go as.autoDownloader.CleanUpDownloadedItems() + + notifier.GlobalNotifier.Notify(notifier.AutoScanner, "Your library has been scanned.") + + return +} diff --git a/seanime-2.9.10/internal/library/autoscanner/autoscanner_test.go b/seanime-2.9.10/internal/library/autoscanner/autoscanner_test.go new file mode 100644 index 0000000..ef4ff1f --- /dev/null +++ b/seanime-2.9.10/internal/library/autoscanner/autoscanner_test.go @@ -0,0 +1,9 @@ +package autoscanner + +import ( + "testing" +) + +func TestAutoScanner(t *testing.T) { + +} diff --git a/seanime-2.9.10/internal/library/filesystem/clean.go b/seanime-2.9.10/internal/library/filesystem/clean.go new file mode 100644 index 0000000..0d6a5c5 --- /dev/null +++ b/seanime-2.9.10/internal/library/filesystem/clean.go @@ -0,0 +1,67 @@ +package filesystem + +import ( + "errors" + "github.com/rs/zerolog" + "os" + "path/filepath" +) + +// RemoveEmptyDirectories deletes all empty directories in a given directory. +// It ignores errors. +func RemoveEmptyDirectories(root string, logger *zerolog.Logger) { + + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + // Skip the root directory + if path == root { + return nil + } + + if info.IsDir() { + // Check if the directory is empty + isEmpty, err := isDirectoryEmpty(path) + if err != nil { + return nil + } + + // Delete the empty directory + if isEmpty { + err := os.Remove(path) + if err != nil { + logger.Warn().Err(err).Str("path", path).Msg("filesystem: Could not delete empty directory") + } + logger.Info().Str("path", path).Msg("filesystem: Deleted empty directory") + // ignore error + } + } + + return nil + }) + +} + +func isDirectoryEmpty(path string) (bool, error) { + dir, err := os.Open(path) + if err != nil { + return false, err + } + defer dir.Close() + + _, err = dir.Readdir(1) + if err == nil { + // Directory is not empty + return false, nil + } + + if errors.Is(err, os.ErrNotExist) { + // Directory does not exist + return false, nil + } + + // Directory is empty + return true, nil +} diff --git a/seanime-2.9.10/internal/library/filesystem/clean_test.go b/seanime-2.9.10/internal/library/filesystem/clean_test.go new file mode 100644 index 0000000..176eb80 --- /dev/null +++ b/seanime-2.9.10/internal/library/filesystem/clean_test.go @@ -0,0 +1,16 @@ +package filesystem + +import ( + "seanime/internal/util" + "testing" +) + +func TestDeleteEmptyDirectories(t *testing.T) { + + path := "E:/ANIME_TEST" + + RemoveEmptyDirectories(path, util.NewLogger()) + + t.Log("All empty directories removed successfully.") + +} diff --git a/seanime-2.9.10/internal/library/filesystem/mediapath.go b/seanime-2.9.10/internal/library/filesystem/mediapath.go new file mode 100644 index 0000000..d923400 --- /dev/null +++ b/seanime-2.9.10/internal/library/filesystem/mediapath.go @@ -0,0 +1,193 @@ +package filesystem + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "seanime/internal/util" + "sort" + "strings" +) + +type SeparatedFilePath struct { + Filename string + Dirnames []string + PrefixPath string +} + +// SeparateFilePath separates a path into a filename and a slice of dirnames while ignoring the prefix. +func SeparateFilePath(path string, prefixPath string) *SeparatedFilePath { + path = filepath.ToSlash(path) + prefixPath = filepath.ToSlash(prefixPath) + cleaned := path + if strings.HasPrefix(strings.ToLower(path), strings.ToLower(prefixPath)) { + cleaned = path[len(prefixPath):] // Remove prefix + } + fp := filepath.Base(filepath.ToSlash(path)) + parentsPath := filepath.Dir(filepath.ToSlash(cleaned)) + if parentsPath == "." || parentsPath == "/" || parentsPath == ".." { + parentsPath = "" + } + + return &SeparatedFilePath{ + Filename: fp, + Dirnames: strings.Split(parentsPath, "/"), + PrefixPath: prefixPath, + } +} + +// SeparateFilePathS separates a path into a filename and a slice of dirnames while ignoring the prefix. +// Unlike [SeparateFilePath], it will check multiple prefixes. +// +// Example: +// +// path = "/path/to/file.mkv" +// potentialPrefixes = []string{"/path/to", "/path"} +// fp, dirs := SeparateFilePathS(path, potentialPrefixes) +// fmt.Println(fp) // file.mkv +// fmt.Println(dirs) // [to] +func SeparateFilePathS(path string, potentialPrefixes []string) *SeparatedFilePath { + // Sort prefix paths by length in descending order + sort.Slice(potentialPrefixes, func(i, j int) bool { + return len(potentialPrefixes[i]) > len(potentialPrefixes[j]) + }) + + // Check each prefix path, and remove the first match from the path + prefixPath := "" + for _, p := range potentialPrefixes { + // Normalize the paths for comparison only + if strings.HasPrefix(util.NormalizePath(path), util.NormalizePath(p)) { + // Remove the prefix from the path + path = path[len(p):] + prefixPath = p + break + } + } + + filename := filepath.ToSlash(filepath.Base(path)) + parentsPath := filepath.ToSlash(filepath.Dir(filepath.ToSlash(path))) + + dirs := make([]string, 0) + for _, dir := range strings.Split(parentsPath, "/") { + if dir != "" { + dirs = append(dirs, dir) + } + } + + return &SeparatedFilePath{ + Filename: filename, + Dirnames: dirs, + PrefixPath: prefixPath, + } +} + +// GetMediaFilePathsFromDir returns a slice of strings containing the paths of all the media files in a directory. +// DEPRECATED: Use GetMediaFilePathsFromDirS instead. +func GetMediaFilePathsFromDir(dirPath string) ([]string, error) { + filePaths := make([]string, 0) + + err := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + ext := strings.ToLower(filepath.Ext(path)) + + if !d.IsDir() && util.IsValidVideoExtension(ext) { + filePaths = append(filePaths, path) + } + return nil + }) + + if err != nil { + return nil, errors.New("could not traverse the local directory") + } + + return filePaths, nil +} + +// GetMediaFilePathsFromDirS returns a slice of strings containing the paths of all the video files in a directory. +// Unlike GetMediaFilePathsFromDir, it follows symlinks. +func GetMediaFilePathsFromDirS(oDirPath string) ([]string, error) { + filePaths := make([]string, 0) + visited := make(map[string]bool) + + // Normalize the initial directory path + dirPath, err := filepath.Abs(oDirPath) + if err != nil { + return nil, fmt.Errorf("could not resolve path: %w", err) + } + + var walkDir func(string) error + walkDir = func(oCurrentPath string) error { + + currentPath := oCurrentPath + + // Normalize current path + resolvedPath, err := filepath.EvalSymlinks(oCurrentPath) + if err == nil { + currentPath = resolvedPath + } + + if visited[currentPath] { + return nil + } + visited[currentPath] = true + + return filepath.WalkDir(currentPath, func(path string, d fs.DirEntry, err error) error { + + if err != nil { + return nil + } + + // If it's a symlink directory, resolve and walk the symlink + info, err := os.Lstat(path) + if err != nil { + return nil + } + + if info.Mode()&os.ModeSymlink != 0 { + linkPath, err := os.Readlink(path) + if err != nil { + return nil + } + + // Resolve the symlink to an absolute path + if !filepath.IsAbs(linkPath) { + linkPath = filepath.Join(filepath.Dir(path), linkPath) + } + + // Only follow the symlink if we can access it + if _, err := os.Stat(linkPath); err == nil { + return walkDir(linkPath) + } + return nil + } + + if d.IsDir() { + return nil + } + + ext := strings.ToLower(filepath.Ext(path)) + if util.IsValidMediaFile(path) && util.IsValidVideoExtension(ext) { + filePaths = append(filePaths, path) + } + return nil + }) + } + + if err = walkDir(dirPath); err != nil { + return nil, fmt.Errorf("could not traverse directory %s: %w", dirPath, err) + } + + return filePaths, nil +} + +//---------------------------------------------------------------------------------------------------------------------- + +func FileExists(filePath string) bool { + _, err := os.Stat(filePath) + return !errors.Is(err, os.ErrNotExist) +} diff --git a/seanime-2.9.10/internal/library/filesystem/mediapath_test.go b/seanime-2.9.10/internal/library/filesystem/mediapath_test.go new file mode 100644 index 0000000..9995a8c --- /dev/null +++ b/seanime-2.9.10/internal/library/filesystem/mediapath_test.go @@ -0,0 +1,133 @@ +package filesystem + +import ( + "fmt" + "os" + "path/filepath" + "seanime/internal/util" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSeparateFilePathS(t *testing.T) { + tests := []struct { + path string + potentialPrefixes []string + expected *SeparatedFilePath + }{ + { + path: "/path/to/file.mkv", + potentialPrefixes: []string{"/path/to", "/path"}, + expected: &SeparatedFilePath{Filename: "file.mkv", Dirnames: []string{}}, + }, + { + path: "/path/TO/to/file.mkv", + potentialPrefixes: []string{"/path"}, + expected: &SeparatedFilePath{Filename: "file.mkv", Dirnames: []string{"TO", "to"}}, + }, + { + path: "/path/to/file2.mkv", + potentialPrefixes: []string{}, + expected: &SeparatedFilePath{Filename: "file2.mkv", Dirnames: []string{"path", "to"}}, + }, + { + path: "/mnt/Anime/Bungou Stray Dogs/Bungou Stray Dogs 5th Season/[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", + potentialPrefixes: []string{"/mnt/Anime", "/mnt/Anime/", "/mnt", "/var/"}, + expected: &SeparatedFilePath{Filename: "[SubsPlease] Bungou Stray Dogs - 61 (1080p) [F609B947].mkv", Dirnames: []string{"Bungou Stray Dogs", "Bungou Stray Dogs 5th Season"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + fmt.Println("Here") + res := SeparateFilePathS(tt.path, tt.potentialPrefixes) + assert.Equal(t, tt.expected.Filename, res.Filename) + assert.Equal(t, tt.expected.Dirnames, res.Dirnames) + }) + } +} + +// Test with symlinks +func TestGetVideoFilePathsFromDir_WithSymlinks(t *testing.T) { + tmpDir := t.TempDir() + + libDir := filepath.Join(tmpDir, "library") + externalLibDir := t.TempDir() + os.Mkdir(libDir, 0755) + // Create files in the external directory + createFile(t, filepath.Join(externalLibDir, "external_video1.mkv")) + createFile(t, filepath.Join(externalLibDir, "external_video2.mp4")) + + // Create directories and files + dir1 := filepath.Join(libDir, "Anime1") + os.Mkdir(dir1, 0755) + createFile(t, filepath.Join(dir1, "Anime1_1.mkv")) + createFile(t, filepath.Join(dir1, "Anime1_2.mp4")) + + dir2 := filepath.Join(libDir, "Anime2") + os.Mkdir(dir2, 0755) + createFile(t, filepath.Join(dir2, "Anime2_1.mkv")) + + // Create a symlink to the external directory + symlinkPath := filepath.Join(libDir, "symlink_to_external") + if err := os.Symlink(externalLibDir, symlinkPath); err != nil { + t.Fatalf("Failed to create symlink: %s", err) + } + // Create a recursive symlink to the library directory + symlinkToLibPath := filepath.Join(externalLibDir, "symlink_to_library") + if err := os.Symlink(libDir, symlinkToLibPath); err != nil { + t.Fatalf("Failed to create symlink: %s", err) + } + + // Expected files + expectedPaths := []string{ + filepath.Join(dir1, "Anime1_1.mkv"), + filepath.Join(dir1, "Anime1_2.mp4"), + filepath.Join(dir2, "Anime2_1.mkv"), + filepath.Join(externalLibDir, "external_video1.mkv"), + filepath.Join(externalLibDir, "external_video2.mp4"), + } + + filePaths, err := GetMediaFilePathsFromDirS(libDir) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + util.Spew(filePaths) + + // Check results + for _, expected := range expectedPaths { + found := false + for _, path := range filePaths { + // if path == expected { + // found = true + // break + // } + // Compare the paths using stdlib + info1, err := os.Stat(path) + if err != nil { + t.Fatalf("Failed to get file info for %s: %s", path, err) + } + info2, err := os.Stat(expected) + if err != nil { + t.Fatalf("Failed to get file info for %s: %s", expected, err) + } + if os.SameFile(info1, info2) { + found = true + break + } + } + if !found { + t.Errorf("Expected file path %s not found in result", expected) + } + } +} + +func createFile(t *testing.T, path string) { + file, err := os.Create(path) + if err != nil { + t.Fatalf("Failed to create file: %s", err) + } + defer file.Close() +} diff --git a/seanime-2.9.10/internal/library/fillermanager/fillermanager.go b/seanime-2.9.10/internal/library/fillermanager/fillermanager.go new file mode 100644 index 0000000..22b5028 --- /dev/null +++ b/seanime-2.9.10/internal/library/fillermanager/fillermanager.go @@ -0,0 +1,289 @@ +package fillermanager + +import ( + "seanime/internal/api/filler" + "seanime/internal/database/db" + "seanime/internal/hook" + "seanime/internal/library/anime" + "seanime/internal/onlinestream" + "seanime/internal/util" + "strconv" + "sync" + "time" + + "github.com/rs/zerolog" + lop "github.com/samber/lo/parallel" +) + +type ( + Interface interface { + // RefetchFillerData re-fetches the fillers for the given media IDs + RefetchFillerData() error + // HasFillerFetched checks if the fillers for the given media ID have been fetched + HasFillerFetched(mediaId int) bool + // FetchAndStoreFillerData fetches the filler data for the given media ID + FetchAndStoreFillerData(mediaId int, titles []string) error + // RemoveFillerData removes the filler data for the given media ID + RemoveFillerData(mediaId int) error + // IsEpisodeFiller checks if the given episode number is a filler for the given media ID + IsEpisodeFiller(mediaId int, episodeNumber int) bool + } + + FillerManager struct { + db *db.Database + logger *zerolog.Logger + fillerApi filler.API + } + + NewFillerManagerOptions struct { + DB *db.Database + Logger *zerolog.Logger + } +) + +func New(opts *NewFillerManagerOptions) *FillerManager { + return &FillerManager{ + db: opts.DB, + logger: opts.Logger, + fillerApi: filler.NewAnimeFillerList(opts.Logger), + } +} + +func (fm *FillerManager) RefetchFillerData() error { + + defer util.HandlePanicInModuleThen("library/fillermanager/RefetchFillerData", func() { + fm.logger.Error().Msg("fillermanager: Failed to re-fetch filler data") + }) + + wg := sync.WaitGroup{} + + fm.logger.Debug().Msg("fillermanager: Re-fetching filler data") + + mediaFillers, err := fm.db.GetCachedMediaFillers() + if err != nil { + return err + } + + for _, mf := range mediaFillers { + wg.Add(1) + go func(*db.MediaFillerItem) { + defer wg.Done() + // Fetch the db data + + // Fetch the filler data + fillerData, err := fm.fillerApi.FindFillerData(mf.Slug) + if err != nil { + fm.logger.Error().Err(err).Int("mediaId", mf.MediaId).Msg("fillermanager: Failed to fetch filler data") + return + } + + // Update the filler data + mf.FillerEpisodes = fillerData.FillerEpisodes + + }(mf) + } + wg.Wait() + + err = fm.db.SaveCachedMediaFillerItems() + if err != nil { + return err + } + + fm.logger.Debug().Msg("fillermanager: Re-fetched filler data") + + return nil +} + +func (fm *FillerManager) HasFillerFetched(mediaId int) bool { + + defer util.HandlePanicInModuleThen("library/fillermanager/HasFillerFetched", func() { + }) + + _, ok := fm.db.GetMediaFillerItem(mediaId) + return ok +} + +func (fm *FillerManager) GetFillerEpisodes(mediaId int) ([]string, bool) { + + defer util.HandlePanicInModuleThen("library/fillermanager/GetFillerEpisodes", func() { + }) + + fillerItem, ok := fm.db.GetMediaFillerItem(mediaId) + if !ok { + return nil, false + } + + return fillerItem.FillerEpisodes, true +} + +func (fm *FillerManager) FetchAndStoreFillerData(mediaId int, titles []string) error { + + defer util.HandlePanicInModuleThen("library/fillermanager/FetchAndStoreFillerData", func() { + }) + + fm.logger.Debug().Int("mediaId", mediaId).Msg("fillermanager: Fetching filler data") + + res, err := fm.fillerApi.Search(filler.SearchOptions{ + Titles: titles, + }) + if err != nil { + return err + } + + fm.logger.Debug().Int("mediaId", mediaId).Str("slug", res.Slug).Msg("fillermanager: Fetched filler data") + + return fm.fetchAndStoreFillerDataFromSlug(mediaId, res.Slug) +} + +func (fm *FillerManager) fetchAndStoreFillerDataFromSlug(mediaId int, slug string) error { + + defer util.HandlePanicInModuleThen("library/fillermanager/FetchAndStoreFillerDataFromSlug", func() { + }) + + fillerData, err := fm.fillerApi.FindFillerData(slug) + if err != nil { + return err + } + + err = fm.db.InsertMediaFiller( + "animefillerlist", + mediaId, + slug, + time.Now(), + fillerData.FillerEpisodes, + ) + if err != nil { + return err + } + + return nil +} + +func (fm *FillerManager) StoreFillerData(source string, slug string, mediaId int, fillerEpisodes []string) error { + + defer util.HandlePanicInModuleThen("library/fillermanager/StoreFillerDataForMedia", func() { + }) + + return fm.db.InsertMediaFiller( + source, + mediaId, + slug, + time.Now(), + fillerEpisodes, + ) +} + +func (fm *FillerManager) RemoveFillerData(mediaId int) error { + + defer util.HandlePanicInModuleThen("library/fillermanager/RemoveFillerData", func() { + }) + + fm.logger.Debug().Int("mediaId", mediaId).Msg("fillermanager: Removing filler data") + + return fm.db.DeleteMediaFiller(mediaId) +} + +func (fm *FillerManager) IsEpisodeFiller(mediaId int, episodeNumber int) bool { + + defer util.HandlePanicInModuleThen("library/fillermanager/IsEpisodeFiller", func() { + }) + + mediaFillerData, ok := fm.db.GetMediaFillerItem(mediaId) + if !ok { + return false + } + + if len(mediaFillerData.FillerEpisodes) == 0 { + return false + } + + for _, ep := range mediaFillerData.FillerEpisodes { + if ep == strconv.Itoa(episodeNumber) { + return true + } + } + + return false +} + +func (fm *FillerManager) HydrateFillerData(e *anime.Entry) { + if fm == nil { + return + } + if e == nil || e.Media == nil || e.Episodes == nil || len(e.Episodes) == 0 { + return + } + + event := &HydrateFillerDataRequestedEvent{ + Entry: e, + } + _ = hook.GlobalHookManager.OnHydrateFillerDataRequested().Trigger(event) + if event.DefaultPrevented { + return + } + e = event.Entry + + // Check if the filler data has been fetched + if !fm.HasFillerFetched(e.Media.ID) { + return + } + + lop.ForEach(e.Episodes, func(ep *anime.Episode, _ int) { + if ep == nil || ep.EpisodeMetadata == nil { + return + } + ep.EpisodeMetadata.IsFiller = fm.IsEpisodeFiller(e.Media.ID, ep.EpisodeNumber) + }) +} + +func (fm *FillerManager) HydrateOnlinestreamFillerData(mId int, episodes []*onlinestream.Episode) { + if fm == nil { + return + } + if episodes == nil || len(episodes) == 0 { + return + } + + event := &HydrateOnlinestreamFillerDataRequestedEvent{ + Episodes: episodes, + } + _ = hook.GlobalHookManager.OnHydrateOnlinestreamFillerDataRequested().Trigger(event) + if event.DefaultPrevented { + return + } + episodes = event.Episodes + + // Check if the filler data has been fetched + if !fm.HasFillerFetched(mId) { + return + } + + for _, ep := range episodes { + ep.IsFiller = fm.IsEpisodeFiller(mId, ep.Number) + } +} + +func (fm *FillerManager) HydrateEpisodeFillerData(mId int, episodes []*anime.Episode) { + if fm == nil || len(episodes) == 0 { + return + } + + event := &HydrateEpisodeFillerDataRequestedEvent{ + Episodes: episodes, + } + _ = hook.GlobalHookManager.OnHydrateEpisodeFillerDataRequested().Trigger(event) + if event.DefaultPrevented { + return + } + episodes = event.Episodes + + // Check if the filler data has been fetched + if !fm.HasFillerFetched(mId) { + return + } + + lop.ForEach(episodes, func(e *anime.Episode, _ int) { + //h.App.FillerManager.HydrateEpisodeFillerData(mId, e) + e.EpisodeMetadata.IsFiller = fm.IsEpisodeFiller(mId, e.EpisodeNumber) + }) +} diff --git a/seanime-2.9.10/internal/library/fillermanager/hook_events.go b/seanime-2.9.10/internal/library/fillermanager/hook_events.go new file mode 100644 index 0000000..23e0a19 --- /dev/null +++ b/seanime-2.9.10/internal/library/fillermanager/hook_events.go @@ -0,0 +1,31 @@ +package fillermanager + +import ( + "seanime/internal/hook_resolver" + "seanime/internal/library/anime" + "seanime/internal/onlinestream" +) + +// HydrateFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for an entry. +// This is used by the local file episode list. +// Prevent default to skip the default behavior and return your own data. +type HydrateFillerDataRequestedEvent struct { + hook_resolver.Event + Entry *anime.Entry `json:"entry"` +} + +// HydrateOnlinestreamFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for online streaming episodes. +// This is used by the online streaming episode list. +// Prevent default to skip the default behavior and return your own data. +type HydrateOnlinestreamFillerDataRequestedEvent struct { + hook_resolver.Event + Episodes []*onlinestream.Episode `json:"episodes"` +} + +// HydrateEpisodeFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for specific episodes. +// This is used by the torrent and debrid streaming episode list. +// Prevent default to skip the default behavior and return your own data. +type HydrateEpisodeFillerDataRequestedEvent struct { + hook_resolver.Event + Episodes []*anime.Episode `json:"episodes"` +} diff --git a/seanime-2.9.10/internal/library/playbackmanager/hook_events.go b/seanime-2.9.10/internal/library/playbackmanager/hook_events.go new file mode 100644 index 0000000..8d29da0 --- /dev/null +++ b/seanime-2.9.10/internal/library/playbackmanager/hook_events.go @@ -0,0 +1,60 @@ +package playbackmanager + +import ( + "seanime/internal/api/anilist" + "seanime/internal/hook_resolver" + "seanime/internal/library/anime" +) + +// LocalFilePlaybackRequestedEvent is triggered when a local file is requested to be played. +// Prevent default to skip the default playback and override the playback. +type LocalFilePlaybackRequestedEvent struct { + hook_resolver.Event + Path string `json:"path"` +} + +// StreamPlaybackRequestedEvent is triggered when a stream is requested to be played. +// Prevent default to skip the default playback and override the playback. +type StreamPlaybackRequestedEvent struct { + hook_resolver.Event + WindowTitle string `json:"windowTitle"` + Payload string `json:"payload"` + Media *anilist.BaseAnime `json:"media"` + AniDbEpisode string `json:"aniDbEpisode"` +} + +// PlaybackBeforeTrackingEvent is triggered just before the playback tracking starts. +// Prevent default to skip playback tracking. +type PlaybackBeforeTrackingEvent struct { + hook_resolver.Event + IsStream bool `json:"isStream"` +} + +// PlaybackLocalFileDetailsRequestedEvent is triggered when the local files details for a specific path are requested. +// This event is triggered right after the media player loads an episode. +// The playback manager uses the local files details to track the progress, propose next episodes, etc. +// In the current implementation, the details are fetched by selecting the local file from the database and making requests to retrieve the media and anime list entry. +// Prevent default to skip the default fetching and override the details. +type PlaybackLocalFileDetailsRequestedEvent struct { + hook_resolver.Event + Path string `json:"path"` + // List of all local files + LocalFiles []*anime.LocalFile `json:"localFiles"` + // Empty anime list entry + AnimeListEntry *anilist.AnimeListEntry `json:"animeListEntry"` + // Empty local file + LocalFile *anime.LocalFile `json:"localFile"` + // Empty local file wrapper entry + LocalFileWrapperEntry *anime.LocalFileWrapperEntry `json:"localFileWrapperEntry"` +} + +// PlaybackStreamDetailsRequestedEvent is triggered when the stream details are requested. +// Prevent default to skip the default fetching and override the details. +// In the current implementation, the details are fetched by selecting the anime from the anime collection. If nothing is found, the stream is still tracked. +type PlaybackStreamDetailsRequestedEvent struct { + hook_resolver.Event + AnimeCollection *anilist.AnimeCollection `json:"animeCollection"` + MediaId int `json:"mediaId"` + // Empty anime list entry + AnimeListEntry *anilist.AnimeListEntry `json:"animeListEntry"` +} diff --git a/seanime-2.9.10/internal/library/playbackmanager/manual_tracking.go b/seanime-2.9.10/internal/library/playbackmanager/manual_tracking.go new file mode 100644 index 0000000..e49692a --- /dev/null +++ b/seanime-2.9.10/internal/library/playbackmanager/manual_tracking.go @@ -0,0 +1,143 @@ +package playbackmanager + +import ( + "context" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/events" + "seanime/internal/util" + "time" + + "github.com/samber/mo" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Manual progress tracking +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type ManualTrackingState struct { + EpisodeNumber int + MediaId int + CurrentProgress int + TotalEpisodes int +} + +type StartManualProgressTrackingOptions struct { + ClientId string + MediaId int + EpisodeNumber int +} + +func (pm *PlaybackManager) CancelManualProgressTracking() { + pm.mu.Lock() + defer pm.mu.Unlock() + + if pm.manualTrackingCtxCancel != nil { + pm.manualTrackingCtxCancel() + pm.currentManualTrackingState = mo.None[*ManualTrackingState]() + } +} + +func (pm *PlaybackManager) StartManualProgressTracking(opts *StartManualProgressTrackingOptions) (err error) { + defer util.HandlePanicInModuleWithError("library/playbackmanager/StartManualProgressTracking", &err) + + ctx := context.Background() + + pm.mu.Lock() + defer pm.mu.Unlock() + + pm.Logger.Trace().Msg("playback manager: Starting manual progress tracking") + + // Cancel manual tracking if active + if pm.manualTrackingCtxCancel != nil { + pm.Logger.Trace().Msg("playback manager: Cancelling previous manual tracking context") + pm.manualTrackingCtxCancel() + pm.manualTrackingWg.Wait() + } + + // Get the media + // - Find the media in the collection + animeCollection, err := pm.platform.GetAnimeCollection(ctx, false) + if err != nil { + return err + } + + var media *anilist.BaseAnime + var currentProgress int + var totalEpisodes int + + listEntry, found := animeCollection.GetListEntryFromAnimeId(opts.MediaId) + + if found { + media = listEntry.Media + } else { + // Fetch the media from AniList + media, err = pm.platform.GetAnime(ctx, opts.MediaId) + } + if media == nil { + pm.Logger.Error().Msg("playback manager: Media not found for manual tracking") + return fmt.Errorf("media not found") + } + + currentProgress = 0 + if listEntry != nil && listEntry.GetProgress() != nil { + currentProgress = *listEntry.GetProgress() + } + totalEpisodes = media.GetTotalEpisodeCount() + + // Set the current playback type (for progress update later on) + pm.currentPlaybackType = ManualTrackingPlayback + + // Set the manual tracking state (for progress update later on) + pm.currentManualTrackingState = mo.Some(&ManualTrackingState{ + EpisodeNumber: opts.EpisodeNumber, + MediaId: opts.MediaId, + CurrentProgress: currentProgress, + TotalEpisodes: totalEpisodes, + }) + + pm.Logger.Trace(). + Int("episode_number", opts.EpisodeNumber). + Int("mediaId", opts.MediaId). + Int("currentProgress", currentProgress). + Int("totalEpisodes", totalEpisodes). + Msg("playback manager: Starting manual progress tracking") + + // Start sending the manual tracking events + pm.manualTrackingWg.Add(1) + go func() { + defer pm.manualTrackingWg.Done() + // Create a new context + pm.manualTrackingCtx, pm.manualTrackingCtxCancel = context.WithCancel(context.Background()) + defer func() { + if pm.manualTrackingCtxCancel != nil { + pm.manualTrackingCtxCancel() + } + }() + + for { + select { + case <-pm.manualTrackingCtx.Done(): + pm.Logger.Debug().Msg("playback manager: Manual progress tracking canceled") + pm.wsEventManager.SendEvent(events.PlaybackManagerManualTrackingStopped, nil) + return + default: + ps := playbackStatePool.Get().(*PlaybackState) + ps.EpisodeNumber = opts.EpisodeNumber + ps.MediaTitle = *media.GetTitle().GetUserPreferred() + ps.MediaTotalEpisodes = totalEpisodes + ps.Filename = "" + ps.CompletionPercentage = 0 + ps.CanPlayNext = false + ps.ProgressUpdated = false + ps.MediaId = opts.MediaId + pm.wsEventManager.SendEvent(events.PlaybackManagerManualTrackingPlaybackState, ps) + playbackStatePool.Put(ps) + // Continuously send the progress to the client + time.Sleep(3 * time.Second) + } + } + }() + + return nil +} diff --git a/seanime-2.9.10/internal/library/playbackmanager/play_random_episode.go b/seanime-2.9.10/internal/library/playbackmanager/play_random_episode.go new file mode 100644 index 0000000..e76280b --- /dev/null +++ b/seanime-2.9.10/internal/library/playbackmanager/play_random_episode.go @@ -0,0 +1,98 @@ +package playbackmanager + +import ( + "context" + "fmt" + "seanime/internal/database/db_bridge" + "seanime/internal/library/anime" + + "github.com/samber/lo" +) + +type StartRandomVideoOptions struct { + UserAgent string + ClientId string +} + +// StartRandomVideo starts a random video from the collection. +// Note that this might now be suited if the user has multiple seasons of the same anime. +func (pm *PlaybackManager) StartRandomVideo(opts *StartRandomVideoOptions) error { + pm.playlistHub.reset() + if err := pm.checkOrLoadAnimeCollection(); err != nil { + return err + } + + animeCollection, err := pm.platform.GetAnimeCollection(context.Background(), false) + if err != nil { + return err + } + + // + // Retrieve random episode + // + + // Get lfs + lfs, _, err := db_bridge.GetLocalFiles(pm.Database) + if err != nil { + return fmt.Errorf("error getting local files: %s", err.Error()) + } + + // Create a local file wrapper + lfw := anime.NewLocalFileWrapper(lfs) + // Get entries (grouped by media id) + lfEntries := lfw.GetLocalEntries() + lfEntries = lo.Filter(lfEntries, func(e *anime.LocalFileWrapperEntry, _ int) bool { + return e.HasMainLocalFiles() + }) + if len(lfEntries) == 0 { + return fmt.Errorf("no playable media found") + } + + continueLfs := make([]*anime.LocalFile, 0) + otherLfs := make([]*anime.LocalFile, 0) + for _, e := range lfEntries { + anilistEntry, ok := animeCollection.GetListEntryFromAnimeId(e.GetMediaId()) + if !ok { + continue + } + progress := 0 + if anilistEntry.Progress != nil { + progress = *anilistEntry.Progress + } + if anilistEntry.Status == nil || *anilistEntry.Status == "COMPLETED" { + continue + } + firstUnwatchedFile, found := e.GetFirstUnwatchedLocalFiles(progress) + if !found { + continue + } + if *anilistEntry.Status == "CURRENT" || *anilistEntry.Status == "REPEATING" { + continueLfs = append(continueLfs, firstUnwatchedFile) + } else { + otherLfs = append(otherLfs, firstUnwatchedFile) + } + } + + if len(continueLfs) == 0 && len(otherLfs) == 0 { + return fmt.Errorf("no playable file found") + } + + lfs = append(continueLfs, otherLfs...) + // only choose from continueLfs if there are more than 8 episodes + if len(continueLfs) > 8 { + lfs = continueLfs + } + + lfs = lo.Shuffle(lfs) + + err = pm.StartPlayingUsingMediaPlayer(&StartPlayingOptions{ + Payload: lfs[0].GetPath(), + UserAgent: opts.UserAgent, + ClientId: opts.ClientId, + }) + if err != nil { + return err + } + + return nil +} diff --git a/seanime-2.9.10/internal/library/playbackmanager/playback_manager.go b/seanime-2.9.10/internal/library/playbackmanager/playback_manager.go new file mode 100644 index 0000000..ca24977 --- /dev/null +++ b/seanime-2.9.10/internal/library/playbackmanager/playback_manager.go @@ -0,0 +1,731 @@ +package playbackmanager + +import ( + "context" + "errors" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/continuity" + "seanime/internal/database/db" + "seanime/internal/database/db_bridge" + discordrpc_presence "seanime/internal/discordrpc/presence" + "seanime/internal/events" + "seanime/internal/hook" + "seanime/internal/library/anime" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/platforms/platform" + "seanime/internal/util" + "seanime/internal/util/result" + "sync" + "sync/atomic" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +const ( + LocalFilePlayback PlaybackType = "localfile" + StreamPlayback PlaybackType = "stream" + ManualTrackingPlayback PlaybackType = "manual" +) + +var playbackStatePool = sync.Pool{ + New: func() interface{} { + return &PlaybackState{} + }, +} + +type ( + PlaybackType string + + // PlaybackManager manages video playback (local and stream) and progress tracking for desktop media players. + // It receives and dispatch appropriate events for: + // - Syncing progress with AniList, etc. + // - Sending notifications to the client + PlaybackManager struct { + Logger *zerolog.Logger + Database *db.Database + MediaPlayerRepository *mediaplayer.Repository // MediaPlayerRepository is used to control the media player + continuityManager *continuity.Manager + + settings *Settings + + discordPresence *discordrpc_presence.Presence // DiscordPresence is used to update the user's Discord presence + mediaPlayerRepoSubscriber *mediaplayer.RepositorySubscriber // Used to listen for media player events + wsEventManager events.WSEventManagerInterface + platform platform.Platform + metadataProvider metadata.Provider + refreshAnimeCollectionFunc func() // This function is called to refresh the AniList collection + mu sync.Mutex + eventMu sync.RWMutex + cancel context.CancelFunc + + // historyMap stores a PlaybackState whose state is "completed" + // Since PlaybackState is sent to client continuously, once a PlaybackState is stored in historyMap, only IT will be sent to the client. + // This is so when the user seeks back to a video, the client can show the last known "completed" state of the video + historyMap map[string]PlaybackState + currentPlaybackType PlaybackType + currentMediaPlaybackStatus *mediaplayer.PlaybackStatus // The current video playback status (can be nil) + + autoPlayMu sync.Mutex + nextEpisodeLocalFile mo.Option[*anime.LocalFile] // The next episode's local file (for local file playback) + + // currentMediaListEntry for Local file playback & stream playback + // For Local file playback, it MUST be set + // For Stream playback, it is optional + // See [progress_tracking.go] for how it is handled + currentMediaListEntry mo.Option[*anilist.AnimeListEntry] // List Entry for the current video playback + + // \/ Local file playback + currentLocalFile mo.Option[*anime.LocalFile] // Local file for the current video playback + currentLocalFileWrapperEntry mo.Option[*anime.LocalFileWrapperEntry] // This contains the current media entry local file data + + // \/ Stream playback + // The current episode being streamed, set in [StartStreamingUsingMediaPlayer] by finding the episode in currentStreamEpisodeCollection + currentStreamEpisode mo.Option[*anime.Episode] + // The current media being streamed, set in [StartStreamingUsingMediaPlayer] + currentStreamMedia mo.Option[*anilist.BaseAnime] + currentStreamAniDbEpisode mo.Option[string] + + // \/ Manual progress tracking (non-integrated external player) + manualTrackingCtx context.Context + manualTrackingCtxCancel context.CancelFunc + manualTrackingPlaybackState PlaybackState + currentManualTrackingState mo.Option[*ManualTrackingState] + manualTrackingWg sync.WaitGroup + + // \/ Playlist + playlistHub *playlistHub // The playlist hub + + isOffline *bool + animeCollection mo.Option[*anilist.AnimeCollection] + + playbackStatusSubscribers *result.Map[string, *PlaybackStatusSubscriber] + } + + // PlaybackStatusSubscriber provides a single event channel for all playback events + PlaybackStatusSubscriber struct { + EventCh chan PlaybackEvent + canceled atomic.Bool + } + + // PlaybackEvent is the base interface for all playback events + PlaybackEvent interface { + Type() string + } + + PlaybackStartingEvent struct { + Filepath string + PlaybackType PlaybackType + Media *anilist.BaseAnime + AniDbEpisode string + EpisodeNumber int + WindowTitle string + } + + // Local file playback events + + PlaybackStatusChangedEvent struct { + Status mediaplayer.PlaybackStatus + State PlaybackState + } + + VideoStartedEvent struct { + Filename string + Filepath string + } + + VideoStoppedEvent struct { + Reason string + } + + VideoCompletedEvent struct { + Filename string + } + + // Stream playback events + StreamStateChangedEvent struct { + State PlaybackState + } + + StreamStatusChangedEvent struct { + Status mediaplayer.PlaybackStatus + } + + StreamStartedEvent struct { + Filename string + Filepath string + } + + StreamStoppedEvent struct { + Reason string + } + + StreamCompletedEvent struct { + Filename string + } + + PlaybackStateType string + + // PlaybackState is used to keep track of the user's current video playback + // It is sent to the client each time the video playback state is picked up -- this is used to update the client's UI + PlaybackState struct { + EpisodeNumber int `json:"episodeNumber"` // The episode number + AniDbEpisode string `json:"aniDbEpisode"` // The AniDB episode number + MediaTitle string `json:"mediaTitle"` // The title of the media + MediaCoverImage string `json:"mediaCoverImage"` // The cover image of the media + MediaTotalEpisodes int `json:"mediaTotalEpisodes"` // The total number of episodes + Filename string `json:"filename"` // The filename + CompletionPercentage float64 `json:"completionPercentage"` // The completion percentage + CanPlayNext bool `json:"canPlayNext"` // Whether the next episode can be played + ProgressUpdated bool `json:"progressUpdated"` // Whether the progress has been updated + MediaId int `json:"mediaId"` // The media ID + } + + NewPlaybackManagerOptions struct { + WSEventManager events.WSEventManagerInterface + Logger *zerolog.Logger + Platform platform.Platform + MetadataProvider metadata.Provider + Database *db.Database + RefreshAnimeCollectionFunc func() // This function is called to refresh the AniList collection + DiscordPresence *discordrpc_presence.Presence + IsOffline *bool + ContinuityManager *continuity.Manager + } + + Settings struct { + AutoPlayNextEpisode bool + } +) + +// Event type implementations +func (e PlaybackStatusChangedEvent) Type() string { return "playback_status_changed" } +func (e VideoStartedEvent) Type() string { return "video_started" } +func (e VideoStoppedEvent) Type() string { return "video_stopped" } +func (e VideoCompletedEvent) Type() string { return "video_completed" } +func (e StreamStateChangedEvent) Type() string { return "stream_state_changed" } +func (e StreamStatusChangedEvent) Type() string { return "stream_status_changed" } +func (e StreamStartedEvent) Type() string { return "stream_started" } +func (e StreamStoppedEvent) Type() string { return "stream_stopped" } +func (e StreamCompletedEvent) Type() string { return "stream_completed" } +func (e PlaybackStartingEvent) Type() string { return "playback_starting" } + +func New(opts *NewPlaybackManagerOptions) *PlaybackManager { + pm := &PlaybackManager{ + Logger: opts.Logger, + Database: opts.Database, + settings: &Settings{}, + discordPresence: opts.DiscordPresence, + wsEventManager: opts.WSEventManager, + platform: opts.Platform, + metadataProvider: opts.MetadataProvider, + refreshAnimeCollectionFunc: opts.RefreshAnimeCollectionFunc, + mu: sync.Mutex{}, + autoPlayMu: sync.Mutex{}, + eventMu: sync.RWMutex{}, + historyMap: make(map[string]PlaybackState), + isOffline: opts.IsOffline, + nextEpisodeLocalFile: mo.None[*anime.LocalFile](), + currentStreamEpisode: mo.None[*anime.Episode](), + currentStreamMedia: mo.None[*anilist.BaseAnime](), + currentStreamAniDbEpisode: mo.None[string](), + animeCollection: mo.None[*anilist.AnimeCollection](), + currentManualTrackingState: mo.None[*ManualTrackingState](), + currentLocalFile: mo.None[*anime.LocalFile](), + currentLocalFileWrapperEntry: mo.None[*anime.LocalFileWrapperEntry](), + currentMediaListEntry: mo.None[*anilist.AnimeListEntry](), + continuityManager: opts.ContinuityManager, + playbackStatusSubscribers: result.NewResultMap[string, *PlaybackStatusSubscriber](), + } + + pm.playlistHub = newPlaylistHub(pm) + + return pm +} + +func (pm *PlaybackManager) SetAnimeCollection(ac *anilist.AnimeCollection) { + pm.animeCollection = mo.Some(ac) +} + +func (pm *PlaybackManager) SetSettings(s *Settings) { + pm.settings = s +} + +// SetMediaPlayerRepository sets the media player repository and starts listening to media player events +// - This method is called when the media player is mounted (due to settings change or when the app starts) +func (pm *PlaybackManager) SetMediaPlayerRepository(mediaPlayerRepository *mediaplayer.Repository) { + go func() { + // If a previous context exists, cancel it + if pm.cancel != nil { + pm.cancel() + } + + pm.playlistHub.reset() + + // Create a new context for listening to the MediaPlayer instance's event + // When this is canceled above, the previous listener goroutine will stop -- this is done to prevent multiple listeners + var ctx context.Context + ctx, pm.cancel = context.WithCancel(context.Background()) + + pm.mu.Lock() + // Set the new media player repository instance + pm.MediaPlayerRepository = mediaPlayerRepository + // Set up event listeners for the media player instance + pm.mediaPlayerRepoSubscriber = pm.MediaPlayerRepository.Subscribe("playbackmanager") + pm.mu.Unlock() + + // Start listening to new media player events + pm.listenToMediaPlayerEvents(ctx) + + // DEVNOTE: pm.listenToClientPlayerEvents() + }() +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type StartPlayingOptions struct { + Payload string // url or path + UserAgent string + ClientId string +} + +func (pm *PlaybackManager) StartPlayingUsingMediaPlayer(opts *StartPlayingOptions) error { + + event := &LocalFilePlaybackRequestedEvent{ + Path: opts.Payload, + } + err := hook.GlobalHookManager.OnLocalFilePlaybackRequested().Trigger(event) + if err != nil { + return err + } + opts.Payload = event.Path + + if event.DefaultPrevented { + pm.Logger.Debug().Msg("playback manager: Local file playback prevented by hook") + return nil + } + + pm.playlistHub.reset() + if err := pm.checkOrLoadAnimeCollection(); err != nil { + return err + } + + // Cancel manual tracking if active + if pm.manualTrackingCtxCancel != nil { + pm.manualTrackingCtxCancel() + } + + // Send the media file to the media player + err = pm.MediaPlayerRepository.Play(opts.Payload) + if err != nil { + return err + } + + trackingEvent := &PlaybackBeforeTrackingEvent{ + IsStream: false, + } + err = hook.GlobalHookManager.OnPlaybackBeforeTracking().Trigger(trackingEvent) + if err != nil { + return err + } + + if trackingEvent.DefaultPrevented { + return nil + } + + // Start tracking + pm.MediaPlayerRepository.StartTracking() + + return nil +} + +// StartUntrackedStreamingUsingMediaPlayer starts a stream using the media player without any tracking. +func (pm *PlaybackManager) StartUntrackedStreamingUsingMediaPlayer(windowTitle string, opts *StartPlayingOptions) (err error) { + defer util.HandlePanicInModuleWithError("library/playbackmanager/StartUntrackedStreamingUsingMediaPlayer", &err) + + event := &StreamPlaybackRequestedEvent{ + WindowTitle: windowTitle, + Payload: opts.Payload, + Media: nil, + AniDbEpisode: "", + } + err = hook.GlobalHookManager.OnStreamPlaybackRequested().Trigger(event) + if err != nil { + return err + } + + if event.DefaultPrevented { + pm.Logger.Debug().Msg("playback manager: Stream playback prevented by hook") + return nil + } + + pm.Logger.Trace().Msg("playback manager: Starting the media player") + + pm.mu.Lock() + defer pm.mu.Unlock() + + episodeNumber := 0 + + err = pm.MediaPlayerRepository.Stream(opts.Payload, episodeNumber, 0, windowTitle) + if err != nil { + pm.Logger.Error().Err(err).Msg("playback manager: Failed to start streaming") + return err + } + + pm.Logger.Trace().Msg("playback manager: Sent stream to media player") + + return nil +} + +// StartStreamingUsingMediaPlayer starts streaming a video using the media player. +// This sets PlaybackManager.currentStreamMedia and PlaybackManager.currentStreamEpisode used for progress tracking. +// Note that PlaybackManager.currentStreamEpisodeCollection is not required to start streaming but is needed for progress tracking. +func (pm *PlaybackManager) StartStreamingUsingMediaPlayer(windowTitle string, opts *StartPlayingOptions, media *anilist.BaseAnime, aniDbEpisode string) (err error) { + defer util.HandlePanicInModuleWithError("library/playbackmanager/StartStreamingUsingMediaPlayer", &err) + + event := &StreamPlaybackRequestedEvent{ + WindowTitle: windowTitle, + Payload: opts.Payload, + Media: media, + AniDbEpisode: aniDbEpisode, + } + err = hook.GlobalHookManager.OnStreamPlaybackRequested().Trigger(event) + if err != nil { + return err + } + + aniDbEpisode = event.AniDbEpisode + windowTitle = event.WindowTitle + + if event.DefaultPrevented { + pm.Logger.Debug().Msg("playback manager: Stream playback prevented by hook") + return nil + } + + pm.playlistHub.reset() + if *pm.isOffline { + return errors.New("cannot stream when offline") + } + + if event.Media == nil || aniDbEpisode == "" { + pm.Logger.Error().Msg("playback manager: cannot start streaming, missing options [StartStreamingUsingMediaPlayer]") + return errors.New("cannot start streaming, not enough data provided") + } + + pm.Logger.Trace().Msg("playback manager: Starting the media player") + + pm.mu.Lock() + defer pm.mu.Unlock() + + // Cancel manual tracking if active + if pm.manualTrackingCtxCancel != nil { + pm.manualTrackingCtxCancel() + } + + pm.currentStreamMedia = mo.Some(event.Media) + episodeNumber := 0 + + // Find the current episode being stream + episodeCollection, err := anime.NewEpisodeCollection(anime.NewEpisodeCollectionOptions{ + AnimeMetadata: nil, + Media: event.Media, + MetadataProvider: pm.metadataProvider, + Logger: pm.Logger, + }) + + pm.currentStreamAniDbEpisode = mo.Some(aniDbEpisode) + + if episode, ok := episodeCollection.FindEpisodeByAniDB(aniDbEpisode); ok { + episodeNumber = episode.EpisodeNumber + pm.currentStreamEpisode = mo.Some(episode) + } else { + pm.Logger.Warn().Str("episode", aniDbEpisode).Msg("playback manager: Failed to find episode in episode collection") + } + + err = pm.MediaPlayerRepository.Stream(event.Payload, episodeNumber, event.Media.ID, windowTitle) + if err != nil { + pm.Logger.Error().Err(err).Msg("playback manager: Failed to start streaming") + return err + } + + pm.Logger.Trace().Msg("playback manager: Sent stream to media player") + + trackingEvent := &PlaybackBeforeTrackingEvent{ + IsStream: true, + } + err = hook.GlobalHookManager.OnPlaybackBeforeTracking().Trigger(trackingEvent) + if err != nil { + return err + } + + if trackingEvent.DefaultPrevented { + return nil + } + + pm.MediaPlayerRepository.StartTrackingTorrentStream() + + pm.Logger.Trace().Msg("playback manager: Started tracking torrent stream") + + return nil +} + +// PlayNextEpisode plays the next episode of the local media that is being watched +// - Called when the user clicks on the "Next" button in the client +// - Should not be called when the user is watching a playlist +// - Should not be called when no next episode is available +func (pm *PlaybackManager) PlayNextEpisode() (err error) { + defer util.HandlePanicInModuleWithError("library/playbackmanager/PlayNextEpisode", &err) + + switch pm.currentPlaybackType { + case LocalFilePlayback: + if pm.currentLocalFile.IsAbsent() || pm.currentMediaListEntry.IsAbsent() || pm.currentLocalFileWrapperEntry.IsAbsent() { + return errors.New("could not play next episode") + } + + nextLf, found := pm.currentLocalFileWrapperEntry.MustGet().FindNextEpisode(pm.currentLocalFile.MustGet()) + if !found { + return errors.New("could not play next episode") + } + + err = pm.MediaPlayerRepository.Play(nextLf.Path) + if err != nil { + return err + } + // Start tracking the video + pm.MediaPlayerRepository.StartTracking() + + case StreamPlayback: + // TODO: Implement it for torrentstream + // Check if torrent stream etc... + } + + return nil +} + +// GetNextEpisode gets the next [anime.LocalFile] of the local media that is being watched. +// It will return nil if there is no next episode. +// This is used by the client's "Auto Play" feature. +func (pm *PlaybackManager) GetNextEpisode() (ret *anime.LocalFile) { + defer util.HandlePanicInModuleThen("library/playbackmanager/GetNextEpisode", func() { + ret = nil + }) + + switch pm.currentPlaybackType { + case LocalFilePlayback: + if lf, found := pm.nextEpisodeLocalFile.Get(); found { + ret = lf + } + return + } + + return nil +} + +// AutoPlayNextEpisode will play the next episode of the local media that is being watched. +// This calls [PlaybackManager.PlayNextEpisode] only once if multiple clients made the request. +func (pm *PlaybackManager) AutoPlayNextEpisode() error { + pm.autoPlayMu.Lock() + defer pm.autoPlayMu.Unlock() + + pm.Logger.Trace().Msg("playback manager: Auto play request received") + + if !pm.settings.AutoPlayNextEpisode { + return nil + } + + lf := pm.GetNextEpisode() + // This shouldn't happen because the client should check if there is a next episode before sending the request. + // However, it will happen if there are multiple clients launching the request. + if lf == nil { + pm.Logger.Warn().Msg("playback manager: No next episode to play") + return nil + } + + if err := pm.PlayNextEpisode(); err != nil { + pm.Logger.Error().Err(err).Msg("playback manager: Failed to auto play next episode") + return fmt.Errorf("failed to auto play next episode: %w", err) + } + + // Remove the next episode from the queue + pm.nextEpisodeLocalFile = mo.None[*anime.LocalFile]() + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Pause pauses the current media player playback. +func (pm *PlaybackManager) Pause() error { + return pm.MediaPlayerRepository.Pause() +} + +// Resume resumes the current media player playback. +func (pm *PlaybackManager) Resume() error { + return pm.MediaPlayerRepository.Resume() +} + +// Seek seeks to the specified time in the current media. +func (pm *PlaybackManager) Seek(seconds float64) error { + return pm.MediaPlayerRepository.Seek(seconds) +} + +// PullStatus pulls the current media player playback status at the time of the call. +func (pm *PlaybackManager) PullStatus() (*mediaplayer.PlaybackStatus, bool) { + return pm.MediaPlayerRepository.PullStatus() +} + +// Cancel stops the current media player playback and publishes a "normal" event. +func (pm *PlaybackManager) Cancel() error { + pm.MediaPlayerRepository.Stop() + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Playlist +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// CancelCurrentPlaylist cancels the current playlist. +// This is an action triggered by the client. +func (pm *PlaybackManager) CancelCurrentPlaylist() error { + go pm.playlistHub.reset() + return nil +} + +// RequestNextPlaylistFile will play the next file in the playlist. +// This is an action triggered by the client. +func (pm *PlaybackManager) RequestNextPlaylistFile() error { + go pm.playlistHub.playNextFile() + return nil +} + +// StartPlaylist starts a playlist. +// This action is triggered by the client. +func (pm *PlaybackManager) StartPlaylist(playlist *anime.Playlist) (err error) { + defer util.HandlePanicInModuleWithError("library/playbackmanager/StartPlaylist", &err) + + pm.playlistHub.loadPlaylist(playlist) + + _ = pm.checkOrLoadAnimeCollection() + + // Play the first video in the playlist + firstVidPath := playlist.LocalFiles[0].Path + err = pm.MediaPlayerRepository.Play(firstVidPath) + if err != nil { + return err + } + + // Start tracking the video + pm.MediaPlayerRepository.StartTracking() + + // Create a new context for the playlist hub + var ctx context.Context + ctx, pm.playlistHub.cancel = context.WithCancel(context.Background()) + + // Listen to new play requests + go func() { + pm.Logger.Debug().Msg("playback manager: Listening for new file requests") + for { + select { + // When the playlist hub context is cancelled (No playlist is being played) + case <-ctx.Done(): + pm.Logger.Debug().Msg("playback manager: Playlist context cancelled") + // Send event to the client -- nil signals that no playlist is being played + pm.wsEventManager.SendEvent(events.PlaybackManagerPlaylistState, nil) + return + case path := <-pm.playlistHub.requestNewFileCh: + // requestNewFileCh receives the path of the next video to play + // The channel is fed when it's time to play the next video or when the client requests the next video + // see: RequestNextPlaylistFile, playlistHub code + pm.Logger.Debug().Str("path", path).Msg("playback manager: Playing next file") + // Send notification to the client + pm.wsEventManager.SendEvent(events.InfoToast, "Playing next file in playlist") + // Play the requested video + err := pm.MediaPlayerRepository.Play(path) + if err != nil { + pm.Logger.Error().Err(err).Msg("playback manager: Failed to play next file in playlist") + pm.playlistHub.cancel() + return + } + // Start tracking the video + pm.MediaPlayerRepository.StartTracking() + case <-pm.playlistHub.endOfPlaylistCh: + pm.Logger.Debug().Msg("playback manager: End of playlist") + pm.wsEventManager.SendEvent(events.InfoToast, "End of playlist") + // Send event to the client -- nil signals that no playlist is being played + pm.wsEventManager.SendEvent(events.PlaybackManagerPlaylistState, nil) + go pm.MediaPlayerRepository.Stop() + pm.playlistHub.cancel() + return + } + } + }() + + // Delete playlist in goroutine + go func() { + err := db_bridge.DeletePlaylist(pm.Database, playlist.DbId) + if err != nil { + pm.Logger.Error().Err(err).Str("name", playlist.Name).Msgf("playback manager: Failed to delete playlist") + return + } + pm.Logger.Debug().Str("name", playlist.Name).Msgf("playback manager: Deleted playlist") + }() + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (pm *PlaybackManager) checkOrLoadAnimeCollection() (err error) { + defer util.HandlePanicInModuleWithError("library/playbackmanager/checkOrLoadAnimeCollection", &err) + + if pm.animeCollection.IsAbsent() { + // If the anime collection is not present, we retrieve it from the platform + collection, err := pm.platform.GetAnimeCollection(context.Background(), false) + if err != nil { + return err + } + pm.animeCollection = mo.Some(collection) + } + return nil +} + +func (pm *PlaybackManager) SubscribeToPlaybackStatus(id string) *PlaybackStatusSubscriber { + subscriber := &PlaybackStatusSubscriber{ + EventCh: make(chan PlaybackEvent, 100), + } + pm.playbackStatusSubscribers.Set(id, subscriber) + return subscriber +} + +func (pm *PlaybackManager) RegisterMediaPlayerCallback(callback func(event PlaybackEvent, cancelFunc func())) (cancel func()) { + id := uuid.NewString() + playbackSubscriber := pm.SubscribeToPlaybackStatus(id) + cancel = func() { + pm.UnsubscribeFromPlaybackStatus(id) + } + go func(playbackSubscriber *PlaybackStatusSubscriber) { + for event := range playbackSubscriber.EventCh { + callback(event, cancel) + } + }(playbackSubscriber) + + return cancel +} + +func (pm *PlaybackManager) UnsubscribeFromPlaybackStatus(id string) { + defer func() { + if r := recover(); r != nil { + pm.Logger.Warn().Msg("playback manager: Failed to unsubscribe from playback status") + } + }() + subscriber, ok := pm.playbackStatusSubscribers.Get(id) + if !ok { + return + } + subscriber.canceled.Store(true) + pm.playbackStatusSubscribers.Delete(id) + close(subscriber.EventCh) +} diff --git a/seanime-2.9.10/internal/library/playbackmanager/playback_manager_test.go b/seanime-2.9.10/internal/library/playbackmanager/playback_manager_test.go new file mode 100644 index 0000000..3a37c2c --- /dev/null +++ b/seanime-2.9.10/internal/library/playbackmanager/playback_manager_test.go @@ -0,0 +1,57 @@ +package playbackmanager_test + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/continuity" + "seanime/internal/database/db" + "seanime/internal/events" + "seanime/internal/library/playbackmanager" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "seanime/internal/util/filecache" + "testing" + + "github.com/stretchr/testify/require" +) + +func getPlaybackManager(t *testing.T) (*playbackmanager.PlaybackManager, *anilist.AnimeCollection, error) { + + logger := util.NewLogger() + + wsEventManager := events.NewMockWSEventManager(logger) + + database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger) + + if err != nil { + t.Fatalf("error while creating database, %v", err) + } + + filecacher, err := filecache.NewCacher(t.TempDir()) + require.NoError(t, err) + anilistClient := anilist.TestGetMockAnilistClient() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), true) + metadataProvider := metadata.GetMockProvider(t) + require.NoError(t, err) + continuityManager := continuity.NewManager(&continuity.NewManagerOptions{ + FileCacher: filecacher, + Logger: logger, + Database: database, + }) + + return playbackmanager.New(&playbackmanager.NewPlaybackManagerOptions{ + WSEventManager: wsEventManager, + Logger: logger, + Platform: anilistPlatform, + MetadataProvider: metadataProvider, + Database: database, + RefreshAnimeCollectionFunc: func() { + // Do nothing + }, + DiscordPresence: nil, + IsOffline: &[]bool{false}[0], + ContinuityManager: continuityManager, + }), animeCollection, nil +} diff --git a/seanime-2.9.10/internal/library/playbackmanager/playlist.go b/seanime-2.9.10/internal/library/playbackmanager/playlist.go new file mode 100644 index 0000000..63588f7 --- /dev/null +++ b/seanime-2.9.10/internal/library/playbackmanager/playlist.go @@ -0,0 +1,235 @@ +package playbackmanager + +import ( + "context" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/events" + "seanime/internal/library/anime" + "sync" + "sync/atomic" + + "github.com/rs/zerolog" +) + +type ( + playlistHub struct { + requestNewFileCh chan string + endOfPlaylistCh chan struct{} + + wsEventManager events.WSEventManagerInterface + logger *zerolog.Logger + currentPlaylist *anime.Playlist // The current playlist that is being played (can be nil) + nextLocalFile *anime.LocalFile // The next episode that will be played (can be nil) + cancel context.CancelFunc // The cancel function for the current playlist + mu sync.Mutex // The mutex + + playingLf *anime.LocalFile // The currently playing local file + playingMediaListEntry *anilist.AnimeListEntry // The currently playing media entry + completedCurrent atomic.Bool // Whether the current episode has been completed + + currentState *PlaylistState // This is sent to the client to show the current playlist state + + playbackManager *PlaybackManager + } + + PlaylistState struct { + Current *PlaylistStateItem `json:"current"` + Next *PlaylistStateItem `json:"next"` + Remaining int `json:"remaining"` + } + + PlaylistStateItem struct { + Name string `json:"name"` + MediaImage string `json:"mediaImage"` + } +) + +func newPlaylistHub(pm *PlaybackManager) *playlistHub { + ret := &playlistHub{ + logger: pm.Logger, + wsEventManager: pm.wsEventManager, + playbackManager: pm, + requestNewFileCh: make(chan string, 1), + endOfPlaylistCh: make(chan struct{}, 1), + completedCurrent: atomic.Bool{}, + } + + ret.completedCurrent.Store(false) + + return ret +} + +func (h *playlistHub) loadPlaylist(playlist *anime.Playlist) { + if playlist == nil { + h.logger.Error().Msg("playlist hub: Playlist is nil") + return + } + h.reset() + h.currentPlaylist = playlist + h.logger.Debug().Str("name", playlist.Name).Msg("playlist hub: Playlist loaded") + return +} + +func (h *playlistHub) reset() { + if h.cancel != nil { + h.cancel() + } + h.currentPlaylist = nil + h.playingLf = nil + h.playingMediaListEntry = nil + h.currentState = nil + h.wsEventManager.SendEvent(events.PlaybackManagerPlaylistState, h.currentState) + return +} + +func (h *playlistHub) check(currListEntry *anilist.AnimeListEntry, currLf *anime.LocalFile, ps PlaybackState) bool { + if h.currentPlaylist == nil || currLf == nil || currListEntry == nil { + h.currentPlaylist = nil + h.playingLf = nil + h.playingMediaListEntry = nil + return false + } + return true +} + +func (h *playlistHub) findNextFile() (*anime.LocalFile, bool) { + if h.currentPlaylist == nil || h.playingLf == nil { + return nil, false + } + + for i, lf := range h.currentPlaylist.LocalFiles { + if lf.GetNormalizedPath() == h.playingLf.GetNormalizedPath() { + if i+1 < len(h.currentPlaylist.LocalFiles) { + return h.currentPlaylist.LocalFiles[i+1], true + } + break + } + } + + return nil, false +} + +func (h *playlistHub) playNextFile() (*anime.LocalFile, bool) { + if h.currentPlaylist == nil || h.playingLf == nil || h.nextLocalFile == nil { + return nil, false + } + + h.logger.Debug().Str("path", h.nextLocalFile.Path).Str("cmd", "playNextFile").Msg("playlist hub: Requesting next file") + h.requestNewFileCh <- h.nextLocalFile.Path + h.completedCurrent.Store(false) + + return nil, false +} + +func (h *playlistHub) onVideoStart(currListEntry *anilist.AnimeListEntry, currLf *anime.LocalFile, ps PlaybackState) { + if !h.check(currListEntry, currLf, ps) { + return + } + + h.playingLf = currLf + h.playingMediaListEntry = currListEntry + + h.nextLocalFile, _ = h.findNextFile() + + if h.playbackManager.animeCollection.IsAbsent() { + return + } + + // Refresh current playlist state + playlistState := &PlaylistState{} + playlistState.Current = &PlaylistStateItem{ + Name: fmt.Sprintf("%s - Episode %d", currListEntry.GetMedia().GetPreferredTitle(), currLf.GetEpisodeNumber()), + MediaImage: currListEntry.GetMedia().GetCoverImageSafe(), + } + if h.nextLocalFile != nil { + lfe, found := h.playbackManager.animeCollection.MustGet().GetListEntryFromAnimeId(h.nextLocalFile.MediaId) + if found { + playlistState.Next = &PlaylistStateItem{ + Name: fmt.Sprintf("%s - Episode %d", lfe.GetMedia().GetPreferredTitle(), h.nextLocalFile.GetEpisodeNumber()), + MediaImage: lfe.GetMedia().GetCoverImageSafe(), + } + } + } + remaining := 0 + for i, lf := range h.currentPlaylist.LocalFiles { + if lf.GetNormalizedPath() == currLf.GetNormalizedPath() { + remaining = len(h.currentPlaylist.LocalFiles) - 1 - i + break + } + } + playlistState.Remaining = remaining + h.currentState = playlistState + h.completedCurrent.Store(false) + + h.logger.Debug().Str("path", currLf.Path).Msgf("playlist hub: Video started") + + return +} + +func (h *playlistHub) onVideoCompleted(currListEntry *anilist.AnimeListEntry, currLf *anime.LocalFile, ps PlaybackState) { + if !h.check(currListEntry, currLf, ps) { + return + } + + h.logger.Debug().Str("path", currLf.Path).Msgf("playlist hub: Video completed") + h.completedCurrent.Store(true) + + return +} + +func (h *playlistHub) onPlaybackStatus(currListEntry *anilist.AnimeListEntry, currLf *anime.LocalFile, ps PlaybackState) { + if !h.check(currListEntry, currLf, ps) { + return + } + + h.wsEventManager.SendEvent(events.PlaybackManagerPlaylistState, h.currentState) + + return +} + +func (h *playlistHub) onTrackingStopped() { + if h.currentPlaylist == nil || h.playingLf == nil { // Return if no playlist + return + } + + // When tracking has stopped, request next file + //if h.nextLocalFile != nil { + // h.logger.Debug().Str("path", h.nextLocalFile.Path).Msg("playlist hub: Requesting next file") + // h.requestNewFileCh <- h.nextLocalFile.Path + //} else { + // h.logger.Debug().Msg("playlist hub: End of playlist") + // h.endOfPlaylistCh <- struct{}{} + //} + + h.logger.Debug().Msgf("playlist hub: Tracking stopped, completed current: %v", h.completedCurrent.Load()) + + if !h.completedCurrent.Load() { + h.reset() + } + + return +} + +func (h *playlistHub) onTrackingError() { + if h.currentPlaylist == nil { // Return if no playlist + return + } + + // When tracking has stopped, request next file + h.logger.Debug().Msgf("playlist hub: Tracking error, completed current: %v", h.completedCurrent.Load()) + if h.completedCurrent.Load() { + h.logger.Debug().Msg("playlist hub: Assuming current episode is completed") + if h.nextLocalFile != nil { + h.logger.Debug().Str("path", h.nextLocalFile.Path).Msg("playlist hub: Requesting next file") + h.requestNewFileCh <- h.nextLocalFile.Path + //h.completedCurrent.Store(false) do not reset completedCurrent here + } else { + h.logger.Debug().Msg("playlist hub: End of playlist") + h.endOfPlaylistCh <- struct{}{} + h.completedCurrent.Store(false) + } + } + + return +} diff --git a/seanime-2.9.10/internal/library/playbackmanager/playlist_test.go b/seanime-2.9.10/internal/library/playbackmanager/playlist_test.go new file mode 100644 index 0000000..58aa3fb --- /dev/null +++ b/seanime-2.9.10/internal/library/playbackmanager/playlist_test.go @@ -0,0 +1,93 @@ +package playbackmanager_test + +import ( + "seanime/internal/events" + "seanime/internal/library/anime" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/mediaplayers/mpchc" + "seanime/internal/mediaplayers/mpv" + "seanime/internal/mediaplayers/vlc" + "seanime/internal/test_utils" + "seanime/internal/util" + "strconv" + "testing" +) + +var defaultPlayer = "vlc" +var localFilePaths = []string{ + "E:/ANIME/Dungeon Meshi/[EMBER] Dungeon Meshi - 04.mkv", + "E:/ANIME/Dungeon Meshi/[EMBER] Dungeon Meshi - 05.mkv", + "E:/ANIME/Dungeon Meshi/[EMBER] Dungeon Meshi - 06.mkv", +} +var mediaId = 153518 + +func TestPlaylists(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist(), test_utils.MediaPlayer()) + + playbackManager, animeCollection, err := getPlaybackManager(t) + if err != nil { + t.Fatal(err) + } + + repo := getRepo() + + playbackManager.SetMediaPlayerRepository(repo) + playbackManager.SetAnimeCollection(animeCollection) + + // Test the playlist hub + lfs := make([]*anime.LocalFile, 0) + for _, path := range localFilePaths { + lf := anime.NewLocalFile(path, "E:/ANIME") + epNum, _ := strconv.Atoi(lf.ParsedData.Episode) + lf.MediaId = mediaId + lf.Metadata.Type = anime.LocalFileTypeMain + lf.Metadata.Episode = epNum + lf.Metadata.AniDBEpisode = lf.ParsedData.Episode + lfs = append(lfs, lf) + } + + playlist := &anime.Playlist{ + DbId: 1, + Name: "test", + LocalFiles: lfs, + } + + err = playbackManager.StartPlaylist(playlist) + if err != nil { + t.Fatal(err) + } + + select {} + +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func getRepo() *mediaplayer.Repository { + logger := util.NewLogger() + WSEventManager := events.NewMockWSEventManager(logger) + + vlcI := &vlc.VLC{ + Host: test_utils.ConfigData.Provider.VlcPath, + Port: test_utils.ConfigData.Provider.VlcPort, + Password: test_utils.ConfigData.Provider.VlcPassword, + Logger: logger, + } + + mpc := &mpchc.MpcHc{ + Host: test_utils.ConfigData.Provider.MpcHost, + Path: test_utils.ConfigData.Provider.MpcPath, + Port: test_utils.ConfigData.Provider.MpcPort, + Logger: logger, + } + + repo := mediaplayer.NewRepository(&mediaplayer.NewRepositoryOptions{ + Logger: logger, + Default: defaultPlayer, + VLC: vlcI, + MpcHc: mpc, + Mpv: mpv.New(logger, "", ""), + WSEventManager: WSEventManager, + }) + return repo +} diff --git a/seanime-2.9.10/internal/library/playbackmanager/progress_tracking.go b/seanime-2.9.10/internal/library/playbackmanager/progress_tracking.go new file mode 100644 index 0000000..cbb8546 --- /dev/null +++ b/seanime-2.9.10/internal/library/playbackmanager/progress_tracking.go @@ -0,0 +1,685 @@ +package playbackmanager + +import ( + "cmp" + "context" + "errors" + "seanime/internal/continuity" + discordrpc_presence "seanime/internal/discordrpc/presence" + "seanime/internal/events" + "seanime/internal/library/anime" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/util" + + "github.com/samber/mo" +) + +var ( + ErrProgressUpdateAnilist = errors.New("playback manager: Failed to update progress on AniList") + ErrProgressUpdateMAL = errors.New("playback manager: Failed to update progress on MyAnimeList") +) + +func (pm *PlaybackManager) listenToMediaPlayerEvents(ctx context.Context) { + // Listen for media player events + go func() { + for { + select { + // Stop listening when the context is cancelled -- meaning a new MediaPlayer instance is set + case <-ctx.Done(): + return + case event := <-pm.mediaPlayerRepoSubscriber.EventCh: + switch e := event.(type) { + // Local file events + case mediaplayer.TrackingStartedEvent: // New video has started playing + pm.handleTrackingStarted(e.Status) + case mediaplayer.VideoCompletedEvent: // Video has been watched completely but still tracking + pm.handleVideoCompleted(e.Status) + case mediaplayer.TrackingStoppedEvent: // Tracking has stopped completely + pm.handleTrackingStopped(e.Reason) + case mediaplayer.PlaybackStatusEvent: // Playback status has changed + pm.handlePlaybackStatus(e.Status) + case mediaplayer.TrackingRetryEvent: // Error occurred while starting tracking + pm.handleTrackingRetry(e.Reason) + + // Streaming events + case mediaplayer.StreamingTrackingStartedEvent: + pm.handleStreamingTrackingStarted(e.Status) + case mediaplayer.StreamingPlaybackStatusEvent: + pm.handleStreamingPlaybackStatus(e.Status) + case mediaplayer.StreamingVideoCompletedEvent: + pm.handleStreamingVideoCompleted(e.Status) + case mediaplayer.StreamingTrackingStoppedEvent: + pm.handleStreamingTrackingStopped(e.Reason) + case mediaplayer.StreamingTrackingRetryEvent: + // Do nothing + } + } + } + }() +} + +func (pm *PlaybackManager) handleTrackingStarted(status *mediaplayer.PlaybackStatus) { + pm.eventMu.Lock() + defer pm.eventMu.Unlock() + + // Set the playback type + pm.currentPlaybackType = LocalFilePlayback + + // Reset the history map + pm.historyMap = make(map[string]PlaybackState) + + // Set the current media playback status + pm.currentMediaPlaybackStatus = status + // Get the playback state + _ps := pm.getLocalFilePlaybackState(status) + // Log + pm.Logger.Debug().Msg("playback manager: Tracking started, extracting metadata...") + // Send event to the client + pm.wsEventManager.SendEvent(events.PlaybackManagerProgressTrackingStarted, _ps) + + // Notify subscribers + go func() { + pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool { + if value.canceled.Load() { + return true + } + value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps} + value.EventCh <- VideoStartedEvent{Filename: status.Filename, Filepath: status.Filepath} + return true + }) + }() + + // Retrieve data about the current video playback + // Set PlaybackManager.currentMediaListEntry to the list entry of the current video + currentMediaListEntry, currentLocalFile, currentLocalFileWrapperEntry, err := pm.getLocalFilePlaybackDetails(status.Filepath) + if err != nil { + pm.Logger.Error().Err(err).Msg("playback manager: Failed to get media data") + // Send error event to the client + pm.wsEventManager.SendEvent(events.ErrorToast, err.Error()) + // + pm.MediaPlayerRepository.Cancel() + return + } + + pm.currentMediaListEntry = mo.Some(currentMediaListEntry) + pm.currentLocalFile = mo.Some(currentLocalFile) + pm.currentLocalFileWrapperEntry = mo.Some(currentLocalFileWrapperEntry) + pm.Logger.Debug(). + Str("media", pm.currentMediaListEntry.MustGet().GetMedia().GetPreferredTitle()). + Int("episode", pm.currentLocalFile.MustGet().GetEpisodeNumber()). + Msg("playback manager: Playback started") + + pm.continuityManager.SetExternalPlayerEpisodeDetails(&continuity.ExternalPlayerEpisodeDetails{ + EpisodeNumber: pm.currentLocalFile.MustGet().GetEpisodeNumber(), + MediaId: pm.currentMediaListEntry.MustGet().GetMedia().GetID(), + Filepath: pm.currentLocalFile.MustGet().GetPath(), + }) + + // ------- Playlist ------- // + go pm.playlistHub.onVideoStart(pm.currentMediaListEntry.MustGet(), pm.currentLocalFile.MustGet(), _ps) + + // ------- Discord ------- // + if pm.discordPresence != nil && !*pm.isOffline { + go pm.discordPresence.SetAnimeActivity(&discordrpc_presence.AnimeActivity{ + ID: pm.currentMediaListEntry.MustGet().GetMedia().GetID(), + Title: pm.currentMediaListEntry.MustGet().GetMedia().GetPreferredTitle(), + Image: pm.currentMediaListEntry.MustGet().GetMedia().GetCoverImageSafe(), + IsMovie: pm.currentMediaListEntry.MustGet().GetMedia().IsMovie(), + EpisodeNumber: pm.currentLocalFileWrapperEntry.MustGet().GetProgressNumber(pm.currentLocalFile.MustGet()), + Progress: int(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds), + Duration: int(pm.currentMediaPlaybackStatus.DurationInSeconds), + TotalEpisodes: pm.currentMediaListEntry.MustGet().GetMedia().Episodes, + CurrentEpisodeCount: pm.currentMediaListEntry.MustGet().GetMedia().GetCurrentEpisodeCountOrNil(), + }) + } +} + +func (pm *PlaybackManager) handleVideoCompleted(status *mediaplayer.PlaybackStatus) { + pm.eventMu.Lock() + defer pm.eventMu.Unlock() + + // Set the current media playback status + pm.currentMediaPlaybackStatus = status + // Get the playback state + _ps := pm.getLocalFilePlaybackState(status) + // Log + pm.Logger.Debug().Msg("playback manager: Received video completed event") + + // Notify subscribers + go func() { + pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool { + if value.canceled.Load() { + return true + } + value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps} + value.EventCh <- VideoCompletedEvent{Filename: status.Filename} + return true + }) + }() + + // + // Update the progress on AniList if auto update progress is enabled + // + pm.autoSyncCurrentProgress(&_ps) + + // Send the playback state with the `ProgressUpdated` flag + // The client will use this to notify the user if the progress has been updated + pm.wsEventManager.SendEvent(events.PlaybackManagerProgressVideoCompleted, _ps) + // Push the video playback state to the history + pm.historyMap[status.Filename] = _ps + + // ------- Playlist ------- // + if pm.currentMediaListEntry.IsPresent() && pm.currentLocalFile.IsPresent() { + go pm.playlistHub.onVideoCompleted(pm.currentMediaListEntry.MustGet(), pm.currentLocalFile.MustGet(), _ps) + } +} + +func (pm *PlaybackManager) handleTrackingStopped(reason string) { + pm.eventMu.Lock() + defer pm.eventMu.Unlock() + + pm.Logger.Debug().Msg("playback manager: Received tracking stopped event") + pm.wsEventManager.SendEvent(events.PlaybackManagerProgressTrackingStopped, reason) + + // Find the next episode and set it to [PlaybackManager.nextEpisodeLocalFile] + if pm.currentMediaListEntry.IsPresent() && pm.currentLocalFile.IsPresent() && pm.currentLocalFileWrapperEntry.IsPresent() { + lf, ok := pm.currentLocalFileWrapperEntry.MustGet().FindNextEpisode(pm.currentLocalFile.MustGet()) + if ok { + pm.nextEpisodeLocalFile = mo.Some(lf) + } else { + pm.nextEpisodeLocalFile = mo.None[*anime.LocalFile]() + } + } + + // Notify subscribers + go func() { + pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool { + if value.canceled.Load() { + return true + } + value.EventCh <- VideoStoppedEvent{Reason: reason} + return true + }) + }() + + if pm.currentMediaPlaybackStatus != nil { + pm.continuityManager.UpdateExternalPlayerEpisodeWatchHistoryItem(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds, pm.currentMediaPlaybackStatus.DurationInSeconds) + } + + // ------- Playlist ------- // + go pm.playlistHub.onTrackingStopped() + + // ------- Discord ------- // + if pm.discordPresence != nil && !*pm.isOffline { + go pm.discordPresence.Close() + } +} + +func (pm *PlaybackManager) handlePlaybackStatus(status *mediaplayer.PlaybackStatus) { + pm.eventMu.Lock() + defer pm.eventMu.Unlock() + + pm.currentPlaybackType = LocalFilePlayback + + // Set the current media playback status + pm.currentMediaPlaybackStatus = status + // Get the playback state + _ps := pm.getLocalFilePlaybackState(status) + // If the same PlaybackState is in the history, update the ProgressUpdated flag + // PlaybackStatusCh has no way of knowing if the progress has been updated + if h, ok := pm.historyMap[status.Filename]; ok { + _ps.ProgressUpdated = h.ProgressUpdated + } + + // Notify subscribers + go func() { + pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool { + if value.canceled.Load() { + return true + } + value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps} + return true + }) + }() + + // Send the playback state to the client + pm.wsEventManager.SendEvent(events.PlaybackManagerProgressPlaybackState, _ps) + + // ------- Playlist ------- // + if pm.currentMediaListEntry.IsPresent() && pm.currentLocalFile.IsPresent() { + go pm.playlistHub.onPlaybackStatus(pm.currentMediaListEntry.MustGet(), pm.currentLocalFile.MustGet(), _ps) + } + + // ------- Discord ------- // + if pm.discordPresence != nil && !*pm.isOffline { + go pm.discordPresence.UpdateAnimeActivity(int(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds), int(pm.currentMediaPlaybackStatus.DurationInSeconds), !pm.currentMediaPlaybackStatus.Playing) + } +} + +func (pm *PlaybackManager) handleTrackingRetry(reason string) { + // DEVNOTE: This event is not sent to the client + // We notify the playlist hub, so it can play the next episode (it's assumed that the user closed the player) + + // ------- Playlist ------- // + go pm.playlistHub.onTrackingError() +} + +func (pm *PlaybackManager) handleStreamingTrackingStarted(status *mediaplayer.PlaybackStatus) { + pm.eventMu.Lock() + defer pm.eventMu.Unlock() + + if pm.currentStreamEpisode.IsAbsent() || pm.currentStreamMedia.IsAbsent() { + return + } + + //// Get the media list entry + //// Note that it might be absent if the user is watching a stream that is not in the library + pm.currentMediaListEntry = pm.getStreamPlaybackDetails(pm.currentStreamMedia.MustGet().GetID()) + + // Set the playback type + pm.currentPlaybackType = StreamPlayback + + // Reset the history map + pm.historyMap = make(map[string]PlaybackState) + + // Set the current media playback status + pm.currentMediaPlaybackStatus = status + // Get the playback state + _ps := pm.getStreamPlaybackState(status) + + // Notify subscribers + go func() { + pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool { + if value.canceled.Load() { + return true + } + value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps} + value.EventCh <- StreamStartedEvent{Filename: status.Filename, Filepath: status.Filepath} + return true + }) + }() + + // Log + pm.Logger.Debug().Msg("playback manager: Tracking started for stream") + // Send event to the client + pm.wsEventManager.SendEvent(events.PlaybackManagerProgressTrackingStarted, _ps) + + pm.continuityManager.SetExternalPlayerEpisodeDetails(&continuity.ExternalPlayerEpisodeDetails{ + EpisodeNumber: pm.currentStreamEpisode.MustGet().GetProgressNumber(), + MediaId: pm.currentStreamMedia.MustGet().GetID(), + Filepath: "", + }) + + // ------- Discord ------- // + if pm.discordPresence != nil && !*pm.isOffline { + go pm.discordPresence.SetAnimeActivity(&discordrpc_presence.AnimeActivity{ + ID: pm.currentStreamMedia.MustGet().GetID(), + Title: pm.currentStreamMedia.MustGet().GetPreferredTitle(), + Image: pm.currentStreamMedia.MustGet().GetCoverImageSafe(), + IsMovie: pm.currentStreamMedia.MustGet().IsMovie(), + EpisodeNumber: pm.currentStreamEpisode.MustGet().GetProgressNumber(), + Progress: int(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds), + Duration: int(pm.currentMediaPlaybackStatus.DurationInSeconds), + TotalEpisodes: pm.currentStreamMedia.MustGet().Episodes, + CurrentEpisodeCount: pm.currentStreamMedia.MustGet().GetCurrentEpisodeCountOrNil(), + }) + } +} + +func (pm *PlaybackManager) handleStreamingPlaybackStatus(status *mediaplayer.PlaybackStatus) { + pm.eventMu.Lock() + defer pm.eventMu.Unlock() + + if pm.currentStreamEpisode.IsAbsent() { + return + } + + pm.currentPlaybackType = StreamPlayback + + // Set the current media playback status + pm.currentMediaPlaybackStatus = status + // Get the playback state + _ps := pm.getStreamPlaybackState(status) + // If the same PlaybackState is in the history, update the ProgressUpdated flag + // PlaybackStatusCh has no way of knowing if the progress has been updated + if h, ok := pm.historyMap[status.Filename]; ok { + _ps.ProgressUpdated = h.ProgressUpdated + } + + // Notify subscribers + go func() { + pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool { + if value.canceled.Load() { + return true + } + value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps} + return true + }) + }() + + // Send the playback state to the client + pm.wsEventManager.SendEvent(events.PlaybackManagerProgressPlaybackState, _ps) + + // ------- Discord ------- // + if pm.discordPresence != nil && !*pm.isOffline { + go pm.discordPresence.UpdateAnimeActivity(int(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds), int(pm.currentMediaPlaybackStatus.DurationInSeconds), !pm.currentMediaPlaybackStatus.Playing) + } +} + +func (pm *PlaybackManager) handleStreamingVideoCompleted(status *mediaplayer.PlaybackStatus) { + pm.eventMu.Lock() + defer pm.eventMu.Unlock() + + if pm.currentStreamEpisode.IsAbsent() { + return + } + + // Set the current media playback status + pm.currentMediaPlaybackStatus = status + // Get the playback state + _ps := pm.getStreamPlaybackState(status) + // Log + pm.Logger.Debug().Msg("playback manager: Received video completed event") + + // Notify subscribers + go func() { + pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool { + if value.canceled.Load() { + return true + } + value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps} + value.EventCh <- StreamCompletedEvent{Filename: status.Filename} + return true + }) + }() + // + // Update the progress on AniList if auto update progress is enabled + // + pm.autoSyncCurrentProgress(&_ps) + + // Send the playback state with the `ProgressUpdated` flag + // The client will use this to notify the user if the progress has been updated + pm.wsEventManager.SendEvent(events.PlaybackManagerProgressVideoCompleted, _ps) + // Push the video playback state to the history + pm.historyMap[status.Filename] = _ps +} + +func (pm *PlaybackManager) handleStreamingTrackingStopped(reason string) { + pm.eventMu.Lock() + defer pm.eventMu.Unlock() + + if pm.currentStreamEpisode.IsAbsent() { + return + } + + if pm.currentMediaPlaybackStatus != nil { + pm.continuityManager.UpdateExternalPlayerEpisodeWatchHistoryItem(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds, pm.currentMediaPlaybackStatus.DurationInSeconds) + } + + // Notify subscribers + go func() { + pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool { + if value.canceled.Load() { + return true + } + value.EventCh <- StreamStoppedEvent{Reason: reason} + return true + }) + }() + + pm.Logger.Debug().Msg("playback manager: Received tracking stopped event") + pm.wsEventManager.SendEvent(events.PlaybackManagerProgressTrackingStopped, reason) + + // ------- Discord ------- // + if pm.discordPresence != nil && !*pm.isOffline { + go pm.discordPresence.Close() + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Local File +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// getLocalFilePlaybackState returns a new PlaybackState +func (pm *PlaybackManager) getLocalFilePlaybackState(status *mediaplayer.PlaybackStatus) PlaybackState { + pm.mu.Lock() + defer pm.mu.Unlock() + + currentLocalFileWrapperEntry, ok := pm.currentLocalFileWrapperEntry.Get() + if !ok { + return PlaybackState{} + } + + currentLocalFile, ok := pm.currentLocalFile.Get() + if !ok { + return PlaybackState{} + } + + currentMediaListEntry, ok := pm.currentMediaListEntry.Get() + if !ok { + return PlaybackState{} + } + + // Find the following episode + _, canPlayNext := currentLocalFileWrapperEntry.FindNextEpisode(currentLocalFile) + + return PlaybackState{ + EpisodeNumber: currentLocalFileWrapperEntry.GetProgressNumber(currentLocalFile), + AniDbEpisode: currentLocalFile.GetAniDBEpisode(), + MediaTitle: currentMediaListEntry.GetMedia().GetPreferredTitle(), + MediaTotalEpisodes: currentMediaListEntry.GetMedia().GetCurrentEpisodeCount(), + MediaCoverImage: currentMediaListEntry.GetMedia().GetCoverImageSafe(), + MediaId: currentMediaListEntry.GetMedia().GetID(), + Filename: status.Filename, + CompletionPercentage: status.CompletionPercentage, + CanPlayNext: canPlayNext, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Stream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// getStreamPlaybackState returns a new PlaybackState +func (pm *PlaybackManager) getStreamPlaybackState(status *mediaplayer.PlaybackStatus) PlaybackState { + pm.mu.Lock() + defer pm.mu.Unlock() + + currentStreamEpisode, ok := pm.currentStreamEpisode.Get() + if !ok { + return PlaybackState{} + } + + currentStreamMedia, ok := pm.currentStreamMedia.Get() + if !ok { + return PlaybackState{} + } + + currentStreamAniDbEpisode, ok := pm.currentStreamAniDbEpisode.Get() + if !ok { + return PlaybackState{} + } + + return PlaybackState{ + EpisodeNumber: currentStreamEpisode.GetProgressNumber(), + AniDbEpisode: currentStreamAniDbEpisode, + MediaTitle: currentStreamMedia.GetPreferredTitle(), + MediaTotalEpisodes: currentStreamMedia.GetCurrentEpisodeCount(), + MediaCoverImage: currentStreamMedia.GetCoverImageSafe(), + MediaId: currentStreamMedia.GetID(), + Filename: cmp.Or(status.Filename, "Stream"), + CompletionPercentage: status.CompletionPercentage, + CanPlayNext: false, // DEVNOTE: This is not used for streams + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// autoSyncCurrentProgress syncs the current video playback progress with providers. +// This is called once when a "video complete" event is heard. +func (pm *PlaybackManager) autoSyncCurrentProgress(_ps *PlaybackState) { + + shouldUpdate, err := pm.Database.AutoUpdateProgressIsEnabled() + if err != nil { + pm.Logger.Error().Err(err).Msg("playback manager: Failed to check if auto update progress is enabled") + return + } + + if !shouldUpdate { + return + } + + switch pm.currentPlaybackType { + case LocalFilePlayback: + // Note :currentMediaListEntry MUST be defined since we assume that the media is in the user's library + if pm.currentMediaListEntry.IsAbsent() || pm.currentLocalFileWrapperEntry.IsAbsent() || pm.currentLocalFile.IsAbsent() { + return + } + // Check if we should update the progress + // If the current progress is lower than the episode progress number + epProgressNum := pm.currentLocalFileWrapperEntry.MustGet().GetProgressNumber(pm.currentLocalFile.MustGet()) + if *pm.currentMediaListEntry.MustGet().Progress >= epProgressNum { + return + } + + case StreamPlayback: + if pm.currentStreamEpisode.IsAbsent() || pm.currentStreamMedia.IsAbsent() { + return + } + // Do not auto update progress is the media is in the library AND the progress is higher than the current episode + epProgressNum := pm.currentStreamEpisode.MustGet().GetProgressNumber() + if pm.currentMediaListEntry.IsPresent() && *pm.currentMediaListEntry.MustGet().Progress >= epProgressNum { + return + } + } + + // Update the progress on AniList + pm.Logger.Debug().Msg("playback manager: Updating progress on AniList") + err = pm.updateProgress() + + if err != nil { + _ps.ProgressUpdated = false + pm.wsEventManager.SendEvent(events.ErrorToast, "Failed to update progress on AniList") + } else { + _ps.ProgressUpdated = true + pm.wsEventManager.SendEvent(events.PlaybackManagerProgressUpdated, _ps) + } + +} + +// SyncCurrentProgress syncs the current video playback progress with providers +// This method is called when the user manually requests to sync the progress +// - This method will return an error only if the progress update fails on AniList +// - This method will refresh the anilist collection +func (pm *PlaybackManager) SyncCurrentProgress() error { + pm.eventMu.RLock() + + err := pm.updateProgress() + if err != nil { + pm.eventMu.RUnlock() + return err + } + + // Push the current playback state to the history + if pm.currentMediaPlaybackStatus != nil { + var _ps PlaybackState + switch pm.currentPlaybackType { + case LocalFilePlayback: + pm.getLocalFilePlaybackState(pm.currentMediaPlaybackStatus) + case StreamPlayback: + pm.getStreamPlaybackState(pm.currentMediaPlaybackStatus) + } + _ps.ProgressUpdated = true + pm.historyMap[pm.currentMediaPlaybackStatus.Filename] = _ps + pm.wsEventManager.SendEvent(events.PlaybackManagerProgressUpdated, _ps) + } + + pm.refreshAnimeCollectionFunc() + + pm.eventMu.RUnlock() + return nil +} + +// updateProgress updates the progress of the current video playback on AniList and MyAnimeList. +// This only returns an error if the progress update fails on AniList +// - /!\ When this is called, the PlaybackState should have been pushed to the history +func (pm *PlaybackManager) updateProgress() (err error) { + + var mediaId int + var epNum int + var totalEpisodes int + + switch pm.currentPlaybackType { + case LocalFilePlayback: + // + // Local File + // + if pm.currentLocalFileWrapperEntry.IsAbsent() || pm.currentLocalFile.IsAbsent() || pm.currentMediaListEntry.IsAbsent() { + return errors.New("no video is being watched") + } + + defer util.HandlePanicInModuleWithError("playbackmanager/updateProgress", &err) + + /// Online + mediaId = pm.currentMediaListEntry.MustGet().GetMedia().GetID() + epNum = pm.currentLocalFileWrapperEntry.MustGet().GetProgressNumber(pm.currentLocalFile.MustGet()) + totalEpisodes = pm.currentMediaListEntry.MustGet().GetMedia().GetTotalEpisodeCount() // total episode count or -1 + + case StreamPlayback: + // + // Stream + // + // Last sanity check + if pm.currentStreamEpisode.IsAbsent() || pm.currentStreamMedia.IsAbsent() { + return errors.New("no video is being watched") + } + + mediaId = pm.currentStreamMedia.MustGet().ID + epNum = pm.currentStreamEpisode.MustGet().GetProgressNumber() + totalEpisodes = pm.currentStreamMedia.MustGet().GetTotalEpisodeCount() // total episode count or -1 + + case ManualTrackingPlayback: + // + // Manual Tracking + // + if pm.currentManualTrackingState.IsAbsent() { + return errors.New("no media file is being manually tracked") + } + + defer func() { + if pm.manualTrackingCtxCancel != nil { + pm.manualTrackingCtxCancel() + } + }() + + /// Online + mediaId = pm.currentManualTrackingState.MustGet().MediaId + epNum = pm.currentManualTrackingState.MustGet().EpisodeNumber + totalEpisodes = pm.currentManualTrackingState.MustGet().TotalEpisodes + + default: + return errors.New("unknown playback type") + } + + if mediaId == 0 { // Sanity check + return errors.New("media ID not found") + } + + // Update the progress on AniList + err = pm.platform.UpdateEntryProgress( + context.Background(), + mediaId, + epNum, + &totalEpisodes, + ) + if err != nil { + pm.Logger.Error().Err(err).Msg("playback manager: Error occurred while updating progress on AniList") + return ErrProgressUpdateAnilist + } + + pm.refreshAnimeCollectionFunc() // Refresh the AniList collection + + pm.Logger.Info().Msg("playback manager: Updated progress on AniList") + + return nil +} diff --git a/seanime-2.9.10/internal/library/playbackmanager/stream_magnet.go b/seanime-2.9.10/internal/library/playbackmanager/stream_magnet.go new file mode 100644 index 0000000..3b0403e --- /dev/null +++ b/seanime-2.9.10/internal/library/playbackmanager/stream_magnet.go @@ -0,0 +1,22 @@ +package playbackmanager + +import "seanime/internal/library/anime" + +type ( + StreamMagnetRequestOptions struct { + MagnetLink string `json:"magnet_link"` // magnet link to stream + OptionalMediaId int `json:"optionalMediaId,omitempty"` // optional media ID to associate with the magnet link + Untracked bool `json:"untracked"` + } + + // TrackedStreamMagnetRequestResponse is returned after analysis of the magnet link + TrackedStreamMagnetRequestResponse struct { + EpisodeNumber int `json:"episodeNumber"` // episode number of the magnet link + EpisodeCollection *anime.EpisodeCollection `json:"episodeCollection"` + } + + TrackedStreamMagnetOptions struct { + EpisodeNumber int `json:"episodeNumber"` + AniDBEpisode string `json:"anidbEpisode"` + } +) diff --git a/seanime-2.9.10/internal/library/playbackmanager/utils.go b/seanime-2.9.10/internal/library/playbackmanager/utils.go new file mode 100644 index 0000000..5743689 --- /dev/null +++ b/seanime-2.9.10/internal/library/playbackmanager/utils.go @@ -0,0 +1,141 @@ +package playbackmanager + +import ( + "errors" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/database/db_bridge" + "seanime/internal/hook" + "seanime/internal/library/anime" + "seanime/internal/util" + "strings" + + "github.com/samber/mo" +) + +// GetCurrentMediaID returns the media id of the currently playing media +func (pm *PlaybackManager) GetCurrentMediaID() (int, error) { + if pm.currentLocalFile.IsAbsent() { + return 0, errors.New("no media is currently playing") + } + return pm.currentLocalFile.MustGet().MediaId, nil +} + +// GetLocalFilePlaybackDetails is called once everytime a new video is played. It returns the anilist entry, local file and local file wrapper entry. +func (pm *PlaybackManager) getLocalFilePlaybackDetails(path string) (*anilist.AnimeListEntry, *anime.LocalFile, *anime.LocalFileWrapperEntry, error) { + pm.mu.Lock() + defer pm.mu.Unlock() + // Normalize path + path = util.NormalizePath(path) + + pm.Logger.Debug().Str("path", path).Msg("playback manager: Getting local file playback details") + + // Find the local file from the path + lfs, _, err := db_bridge.GetLocalFiles(pm.Database) + if err != nil { + return nil, nil, nil, fmt.Errorf("error getting local files: %s", err.Error()) + } + + reqEvent := &PlaybackLocalFileDetailsRequestedEvent{ + Path: path, + LocalFiles: lfs, + AnimeListEntry: &anilist.AnimeListEntry{}, + LocalFile: &anime.LocalFile{}, + LocalFileWrapperEntry: &anime.LocalFileWrapperEntry{}, + } + err = hook.GlobalHookManager.OnPlaybackLocalFileDetailsRequested().Trigger(reqEvent) + if err != nil { + return nil, nil, nil, err + } + lfs = reqEvent.LocalFiles // Override the local files + + // Default prevented, use the hook's details + if reqEvent.DefaultPrevented { + pm.Logger.Debug().Msg("playback manager: Local file details processing prevented by hook") + if reqEvent.AnimeListEntry == nil || reqEvent.LocalFile == nil || reqEvent.LocalFileWrapperEntry == nil { + return nil, nil, nil, errors.New("local file details not found") + } + return reqEvent.AnimeListEntry, reqEvent.LocalFile, reqEvent.LocalFileWrapperEntry, nil + } + + var lf *anime.LocalFile + // Find the local file from the path + for _, l := range lfs { + if l.GetNormalizedPath() == path { + lf = l + pm.Logger.Debug().Msg("playback manager: Local file found by path") + break + } + } + + // If the local file is not found, the path might be a filename (in the case of VLC) + if lf == nil { + for _, l := range lfs { + if strings.ToLower(l.Name) == path { + pm.Logger.Debug().Msg("playback manager: Local file found by name") + lf = l + break + } + } + } + + if lf == nil { + return nil, nil, nil, errors.New("local file not found") + } + if lf.MediaId == 0 { + return nil, nil, nil, errors.New("local file has not been matched") + } + + if pm.animeCollection.IsAbsent() { + return nil, nil, nil, fmt.Errorf("error getting anime collection: %w", err) + } + + ret, ok := pm.animeCollection.MustGet().GetListEntryFromAnimeId(lf.MediaId) + if !ok { + return nil, nil, nil, errors.New("anilist list entry not found") + } + + // Create local file wrapper + lfw := anime.NewLocalFileWrapper(lfs) + lfe, ok := lfw.GetLocalEntryById(lf.MediaId) + if !ok { + return nil, nil, nil, errors.New("local file wrapper entry not found") + } + + return ret, lf, lfe, nil +} + +// GetStreamPlaybackDetails is called once everytime a new video is played. +func (pm *PlaybackManager) getStreamPlaybackDetails(mId int) mo.Option[*anilist.AnimeListEntry] { + pm.mu.Lock() + defer pm.mu.Unlock() + + if pm.animeCollection.IsAbsent() { + return mo.None[*anilist.AnimeListEntry]() + } + + reqEvent := &PlaybackStreamDetailsRequestedEvent{ + AnimeCollection: pm.animeCollection.MustGet(), + MediaId: mId, + AnimeListEntry: &anilist.AnimeListEntry{}, + } + err := hook.GlobalHookManager.OnPlaybackStreamDetailsRequested().Trigger(reqEvent) + if err != nil { + return mo.None[*anilist.AnimeListEntry]() + } + + if reqEvent.DefaultPrevented { + pm.Logger.Debug().Msg("playback manager: Stream details processing prevented by hook") + if reqEvent.AnimeListEntry == nil { + return mo.None[*anilist.AnimeListEntry]() + } + return mo.Some(reqEvent.AnimeListEntry) + } + + ret, ok := pm.animeCollection.MustGet().GetListEntryFromAnimeId(mId) + if !ok { + return mo.None[*anilist.AnimeListEntry]() + } + + return mo.Some(ret) +} diff --git a/seanime-2.9.10/internal/library/scanner/hook_events.go b/seanime-2.9.10/internal/library/scanner/hook_events.go new file mode 100644 index 0000000..f7eef5c --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/hook_events.go @@ -0,0 +1,129 @@ +package scanner + +import ( + "seanime/internal/api/anilist" + "seanime/internal/hook_resolver" + "seanime/internal/library/anime" +) + +// ScanStartedEvent is triggered when the scanning process begins. +// Prevent default to skip the rest of the scanning process and return the local files. +type ScanStartedEvent struct { + hook_resolver.Event + // The main directory to scan + LibraryPath string `json:"libraryPath"` + // Other directories to scan + OtherLibraryPaths []string `json:"otherLibraryPaths"` + // Whether to use enhanced scanning, + // Enhanced scanning will fetch media from AniList based on the local files' titles, + // and use the metadata to match the local files. + Enhanced bool `json:"enhanced"` + // Whether to skip locked files + SkipLocked bool `json:"skipLocked"` + // Whether to skip ignored files + SkipIgnored bool `json:"skipIgnored"` + // All previously scanned local files + LocalFiles []*anime.LocalFile `json:"localFiles"` +} + +// ScanFilePathsRetrievedEvent is triggered when the file paths to scan are retrieved. +// The event includes file paths from all directories to scan. +// The event includes file paths of local files that will be skipped. +type ScanFilePathsRetrievedEvent struct { + hook_resolver.Event + FilePaths []string `json:"filePaths"` +} + +// ScanLocalFilesParsedEvent is triggered right after the file paths are parsed into local file objects. +// The event does not include local files that are skipped. +type ScanLocalFilesParsedEvent struct { + hook_resolver.Event + LocalFiles []*anime.LocalFile `json:"localFiles"` +} + +// ScanCompletedEvent is triggered when the scanning process finishes. +// The event includes all the local files (skipped and scanned) to be inserted as a new entry. +// Right after this event, the local files will be inserted as a new entry. +type ScanCompletedEvent struct { + hook_resolver.Event + LocalFiles []*anime.LocalFile `json:"localFiles"` + Duration int `json:"duration"` // in milliseconds +} + +// ScanMediaFetcherStartedEvent is triggered right before Seanime starts fetching media to be matched against the local files. +type ScanMediaFetcherStartedEvent struct { + hook_resolver.Event + // Whether to use enhanced scanning. + // Enhanced scanning will fetch media from AniList based on the local files' titles, + // and use the metadata to match the local files. + Enhanced bool `json:"enhanced"` +} + +// ScanMediaFetcherCompletedEvent is triggered when the media fetcher completes. +// The event includes all the media fetched from AniList. +// The event includes the media IDs that are not in the user's collection. +type ScanMediaFetcherCompletedEvent struct { + hook_resolver.Event + // All media fetched from AniList, to be matched against the local files. + AllMedia []*anilist.CompleteAnime `json:"allMedia"` + // Media IDs that are not in the user's collection. + UnknownMediaIds []int `json:"unknownMediaIds"` +} + +// ScanMatchingStartedEvent is triggered when the matching process begins. +// Prevent default to skip the default matching, in which case modified local files will be used. +type ScanMatchingStartedEvent struct { + hook_resolver.Event + // Local files to be matched. + // If default is prevented, these local files will be used. + LocalFiles []*anime.LocalFile `json:"localFiles"` + // Media to be matched against the local files. + NormalizedMedia []*anime.NormalizedMedia `json:"normalizedMedia"` + // Matching algorithm. + Algorithm string `json:"algorithm"` + // Matching threshold. + Threshold float64 `json:"threshold"` +} + +// ScanLocalFileMatchedEvent is triggered when a local file is matched with media and before the match is analyzed. +// Prevent default to skip the default analysis and override the match. +type ScanLocalFileMatchedEvent struct { + hook_resolver.Event + // Can be nil if there's no match + Match *anime.NormalizedMedia `json:"match"` + Found bool `json:"found"` + LocalFile *anime.LocalFile `json:"localFile"` + Score float64 `json:"score"` +} + +// ScanMatchingCompletedEvent is triggered when the matching process completes. +type ScanMatchingCompletedEvent struct { + hook_resolver.Event + LocalFiles []*anime.LocalFile `json:"localFiles"` +} + +// ScanHydrationStartedEvent is triggered when the file hydration process begins. +// Prevent default to skip the rest of the hydration process, in which case the event's local files will be used. +type ScanHydrationStartedEvent struct { + hook_resolver.Event + // Local files to be hydrated. + LocalFiles []*anime.LocalFile `json:"localFiles"` + // Media to be hydrated. + AllMedia []*anime.NormalizedMedia `json:"allMedia"` +} + +// ScanLocalFileHydrationStartedEvent is triggered when a local file's metadata is about to be hydrated. +// Prevent default to skip the default hydration and override the hydration. +type ScanLocalFileHydrationStartedEvent struct { + hook_resolver.Event + LocalFile *anime.LocalFile `json:"localFile"` + Media *anime.NormalizedMedia `json:"media"` +} + +// ScanLocalFileHydratedEvent is triggered when a local file's metadata is hydrated +type ScanLocalFileHydratedEvent struct { + hook_resolver.Event + LocalFile *anime.LocalFile `json:"localFile"` + MediaId int `json:"mediaId"` + Episode int `json:"episode"` +} diff --git a/seanime-2.9.10/internal/library/scanner/hydrator.go b/seanime-2.9.10/internal/library/scanner/hydrator.go new file mode 100644 index 0000000..263f237 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/hydrator.go @@ -0,0 +1,525 @@ +package scanner + +import ( + "errors" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/hook" + "seanime/internal/library/anime" + "seanime/internal/library/summary" + "seanime/internal/platforms/platform" + "seanime/internal/util" + "seanime/internal/util/comparison" + "seanime/internal/util/limiter" + "strconv" + "time" + + "github.com/rs/zerolog" + "github.com/samber/lo" + lop "github.com/samber/lo/parallel" + "github.com/sourcegraph/conc/pool" +) + +// FileHydrator hydrates the metadata of all (matched) LocalFiles. +// LocalFiles should already have their media ID hydrated. +type FileHydrator struct { + LocalFiles []*anime.LocalFile // Local files to hydrate + AllMedia []*anime.NormalizedMedia // All media used to hydrate local files + CompleteAnimeCache *anilist.CompleteAnimeCache + Platform platform.Platform + MetadataProvider metadata.Provider + AnilistRateLimiter *limiter.Limiter + Logger *zerolog.Logger + ScanLogger *ScanLogger // optional + ScanSummaryLogger *summary.ScanSummaryLogger // optional + ForceMediaId int // optional - force all local files to have this media ID +} + +// HydrateMetadata will hydrate the metadata of each LocalFile with the metadata of the matched anilist.BaseAnime. +// It will divide the LocalFiles into groups based on their media ID and process each group in parallel. +func (fh *FileHydrator) HydrateMetadata() { + start := time.Now() + rateLimiter := limiter.NewLimiter(5*time.Second, 20) + + fh.Logger.Debug().Msg("hydrator: Starting metadata hydration") + + // Invoke ScanHydrationStarted hook + event := &ScanHydrationStartedEvent{ + LocalFiles: fh.LocalFiles, + AllMedia: fh.AllMedia, + } + _ = hook.GlobalHookManager.OnScanHydrationStarted().Trigger(event) + fh.LocalFiles = event.LocalFiles + fh.AllMedia = event.AllMedia + + // Default prevented, do not hydrate the metadata + if event.DefaultPrevented { + return + } + + // Group local files by media ID + groups := lop.GroupBy(fh.LocalFiles, func(localFile *anime.LocalFile) int { + return localFile.MediaId + }) + + // Remove the group with unmatched media + delete(groups, 0) + + if fh.ScanLogger != nil { + fh.ScanLogger.LogFileHydrator(zerolog.InfoLevel). + Int("entryCount", len(groups)). + Msg("Starting metadata hydration process") + } + + // Process each group in parallel + p := pool.New() + for mId, files := range groups { + p.Go(func() { + if len(files) > 0 { + fh.hydrateGroupMetadata(mId, files, rateLimiter) + } + }) + } + p.Wait() + + if fh.ScanLogger != nil { + fh.ScanLogger.LogFileHydrator(zerolog.InfoLevel). + Int64("ms", time.Since(start).Milliseconds()). + Msg("Finished metadata hydration") + } +} + +func (fh *FileHydrator) hydrateGroupMetadata( + mId int, + lfs []*anime.LocalFile, // Grouped local files + rateLimiter *limiter.Limiter, +) { + + // Get the media + media, found := lo.Find(fh.AllMedia, func(media *anime.NormalizedMedia) bool { + return media.ID == mId + }) + if !found { + if fh.ScanLogger != nil { + fh.ScanLogger.LogFileHydrator(zerolog.ErrorLevel). + Int("mediaId", mId). + Msg("Could not find media in FileHydrator options") + } + return + } + + // Tree contains media relations + tree := anilist.NewCompleteAnimeRelationTree() + // Tree analysis used for episode normalization + var mediaTreeAnalysis *MediaTreeAnalysis + treeFetched := false + + // Process each local file in the group sequentially + lo.ForEach(lfs, func(lf *anime.LocalFile, index int) { + + defer util.HandlePanicInModuleThenS("scanner/hydrator/hydrateGroupMetadata", func(stackTrace string) { + lf.MediaId = 0 + /*Log*/ + if fh.ScanLogger != nil { + fh.ScanLogger.LogFileHydrator(zerolog.ErrorLevel). + Str("filename", lf.Name). + Msg("Panic occurred, file un-matched") + } + fh.ScanSummaryLogger.LogPanic(lf, stackTrace) + }) + + episode := -1 + + // Invoke ScanLocalFileHydrationStarted hook + event := &ScanLocalFileHydrationStartedEvent{ + LocalFile: lf, + Media: media, + } + _ = hook.GlobalHookManager.OnScanLocalFileHydrationStarted().Trigger(event) + lf = event.LocalFile + media = event.Media + + defer func() { + // Invoke ScanLocalFileHydrated hook + event := &ScanLocalFileHydratedEvent{ + LocalFile: lf, + MediaId: mId, + Episode: episode, + } + _ = hook.GlobalHookManager.OnScanLocalFileHydrated().Trigger(event) + lf = event.LocalFile + mId = event.MediaId + episode = event.Episode + }() + + // Handle hook override + if event.DefaultPrevented { + if fh.ScanLogger != nil { + fh.ScanLogger.LogFileHydrator(zerolog.DebugLevel). + Str("filename", lf.Name). + Msg("Default hydration skipped by hook") + } + fh.ScanSummaryLogger.LogDebug(lf, "Default hydration skipped by hook") + return + } + + lf.Metadata.Type = anime.LocalFileTypeMain + + // Get episode number + if len(lf.ParsedData.Episode) > 0 { + if ep, ok := util.StringToInt(lf.ParsedData.Episode); ok { + episode = ep + } + } + + // NC metadata + if comparison.ValueContainsNC(lf.Name) { + lf.Metadata.Episode = 0 + lf.Metadata.AniDBEpisode = "" + lf.Metadata.Type = anime.LocalFileTypeNC + + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.DebugLevel, lf, mId, episode). + Msg("File has been marked as NC") + } + fh.ScanSummaryLogger.LogMetadataNC(lf) + return + } + + // Special metadata + if comparison.ValueContainsSpecial(lf.Name) { + lf.Metadata.Type = anime.LocalFileTypeSpecial + if episode > -1 { + // ep14 (13 original) -> ep1 s1 + if episode > media.GetCurrentEpisodeCount() { + lf.Metadata.Episode = episode - media.GetCurrentEpisodeCount() + lf.Metadata.AniDBEpisode = "S" + strconv.Itoa(episode-media.GetCurrentEpisodeCount()) + } else { + lf.Metadata.Episode = episode + lf.Metadata.AniDBEpisode = "S" + strconv.Itoa(episode) + } + } else { + lf.Metadata.Episode = 1 + lf.Metadata.AniDBEpisode = "S1" + } + + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.DebugLevel, lf, mId, episode). + Msg("File has been marked as special") + } + fh.ScanSummaryLogger.LogMetadataSpecial(lf, lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + return + } + // Movie metadata + if *media.Format == anilist.MediaFormatMovie { + lf.Metadata.Episode = 1 + lf.Metadata.AniDBEpisode = "1" + + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.DebugLevel, lf, mId, episode). + Msg("File has been marked as main") + } + fh.ScanSummaryLogger.LogMetadataMain(lf, lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + return + } + + // No absolute episode count + // "media.GetTotalEpisodeCount() == -1" is a fix for media with unknown episode count, we will just assume that the episode number is correct + // TODO: We might want to fetch the media when the episode count is unknown in order to get the correct episode count + if episode > -1 && (episode <= media.GetCurrentEpisodeCount() || media.GetTotalEpisodeCount() == -1) { + // Episode 0 - Might be a special + // By default, we will assume that AniDB doesn't include Episode 0 as part of the main episodes (which is often the case) + // If this proves to be wrong, media_entry.go will offset the AniDBEpisode by 1 and treat "S1" as "1" when it is a main episode + if episode == 0 { + // Leave episode number as 0, assuming that the client will handle tracking correctly + lf.Metadata.Episode = 0 + lf.Metadata.AniDBEpisode = "S1" + + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.DebugLevel, lf, mId, episode). + Msg("File has been marked as main") + } + fh.ScanSummaryLogger.LogMetadataEpisodeZero(lf, lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + return + } + + lf.Metadata.Episode = episode + lf.Metadata.AniDBEpisode = strconv.Itoa(episode) + + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.DebugLevel, lf, mId, episode). + Msg("File has been marked as main") + } + fh.ScanSummaryLogger.LogMetadataMain(lf, lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + return + } + + // Episode number is higher but media only has 1 episode + // - Might be a movie that was not correctly identified as such + // - Or, the torrent files were divided into multiple episodes from a media that is listed as a movie on AniList + if episode > media.GetCurrentEpisodeCount() && media.GetTotalEpisodeCount() == 1 { + lf.Metadata.Episode = 1 // Coerce episode number to 1 because it is used for tracking + lf.Metadata.AniDBEpisode = "1" + + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.WarnLevel, lf, mId, episode). + Str("warning", "File's episode number is higher than the media's episode count, but the media only has 1 episode"). + Msg("File has been marked as main") + } + fh.ScanSummaryLogger.LogMetadataMain(lf, lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + return + } + + // No episode number, but the media only has 1 episode + if episode == -1 && media.GetCurrentEpisodeCount() == 1 { + lf.Metadata.Episode = 1 // Coerce episode number to 1 because it is used for tracking + lf.Metadata.AniDBEpisode = "1" + + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.WarnLevel, lf, mId, episode). + Str("warning", "No episode number found, but the media only has 1 episode"). + Msg("File has been marked as main") + } + fh.ScanSummaryLogger.LogMetadataMain(lf, lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + return + } + + // Still no episode number and the media has more than 1 episode and is not a movie + // We will mark it as a special episode + if episode == -1 { + lf.Metadata.Type = anime.LocalFileTypeSpecial + lf.Metadata.Episode = 1 + lf.Metadata.AniDBEpisode = "S1" + + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.ErrorLevel, lf, mId, episode). + Msg("No episode number found, file has been marked as special") + } + fh.ScanSummaryLogger.LogMetadataEpisodeNormalizationFailed(lf, errors.New("no episode number found"), lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + return + } + + // Absolute episode count + if episode > media.GetCurrentEpisodeCount() && fh.ForceMediaId == 0 { + if !treeFetched { + + mediaTreeFetchStart := time.Now() + // Fetch media tree + // The media tree will be used to normalize episode numbers + if err := media.FetchMediaTree(anilist.FetchMediaTreeAll, fh.Platform.GetAnilistClient(), fh.AnilistRateLimiter, tree, fh.CompleteAnimeCache); err == nil { + // Create a new media tree analysis that will be used for episode normalization + mta, _ := NewMediaTreeAnalysis(&MediaTreeAnalysisOptions{ + tree: tree, + metadataProvider: fh.MetadataProvider, + rateLimiter: rateLimiter, + }) + // Hoist the media tree analysis, so it will be used by other files + // We don't care if it's nil because [normalizeEpisodeNumberAndHydrate] will handle it + mediaTreeAnalysis = mta + treeFetched = true + + /*Log */ + if mta != nil && mta.branches != nil { + if fh.ScanLogger != nil { + fh.ScanLogger.LogFileHydrator(zerolog.DebugLevel). + Int("mediaId", mId). + Int64("ms", time.Since(mediaTreeFetchStart).Milliseconds()). + Int("requests", len(mediaTreeAnalysis.branches)). + Any("branches", mediaTreeAnalysis.printBranches()). + Msg("Media tree fetched") + } + fh.ScanSummaryLogger.LogMetadataMediaTreeFetched(lf, time.Since(mediaTreeFetchStart).Milliseconds(), len(mediaTreeAnalysis.branches)) + } + } else { + if fh.ScanLogger != nil { + fh.ScanLogger.LogFileHydrator(zerolog.ErrorLevel). + Int("mediaId", mId). + Str("error", err.Error()). + Int64("ms", time.Since(mediaTreeFetchStart).Milliseconds()). + Msg("Could not fetch media tree") + } + fh.ScanSummaryLogger.LogMetadataMediaTreeFetchFailed(lf, err, time.Since(mediaTreeFetchStart).Milliseconds()) + } + } + + // Normalize episode number + if err := fh.normalizeEpisodeNumberAndHydrate(mediaTreeAnalysis, lf, episode, media.GetCurrentEpisodeCount()); err != nil { + + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.WarnLevel, lf, mId, episode). + Dict("mediaTreeAnalysis", zerolog.Dict(). + Bool("normalized", false). + Str("error", err.Error()). + Str("reason", "Episode normalization failed"), + ). + Msg("File has been marked as special") + } + fh.ScanSummaryLogger.LogMetadataEpisodeNormalizationFailed(lf, err, lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + } else { + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.DebugLevel, lf, mId, episode). + Dict("mediaTreeAnalysis", zerolog.Dict(). + Bool("normalized", true). + Bool("hasNewMediaId", lf.MediaId != mId). + Int("newMediaId", lf.MediaId), + ). + Msg("File has been marked as main") + } + fh.ScanSummaryLogger.LogMetadataEpisodeNormalized(lf, mId, episode, lf.Metadata.Episode, lf.MediaId, lf.Metadata.AniDBEpisode) + } + return + } + + // Absolute episode count with forced media ID + if fh.ForceMediaId != 0 && episode > media.GetCurrentEpisodeCount() { + + // When we encounter a file with an episode number higher than the media's episode count + // we have a forced media ID, we will fetch the media from AniList and get the offset + animeMetadata, err := fh.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, fh.ForceMediaId) + if err != nil { + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.ErrorLevel, lf, mId, episode). + Str("error", err.Error()). + Msg("Could not fetch AniDB metadata") + } + lf.Metadata.Episode = episode + lf.Metadata.AniDBEpisode = strconv.Itoa(episode) + lf.MediaId = fh.ForceMediaId + fh.ScanSummaryLogger.LogMetadataEpisodeNormalizationFailed(lf, errors.New("could not fetch AniDB metadata"), lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + return + } + + // Get the first episode to calculate the offset + firstEp, ok := animeMetadata.Episodes["1"] + if !ok { + /*Log */ + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.ErrorLevel, lf, mId, episode). + Msg("Could not find absolute episode offset") + } + lf.Metadata.Episode = episode + lf.Metadata.AniDBEpisode = strconv.Itoa(episode) + lf.MediaId = fh.ForceMediaId + fh.ScanSummaryLogger.LogMetadataEpisodeNormalizationFailed(lf, errors.New("could not find absolute episode offset"), lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + return + } + + // ref: media_tree_analysis.go + usePartEpisodeNumber := firstEp.EpisodeNumber > 1 && firstEp.AbsoluteEpisodeNumber-firstEp.EpisodeNumber > 1 + minPartAbsoluteEpisodeNumber := 0 + maxPartAbsoluteEpisodeNumber := 0 + if usePartEpisodeNumber { + minPartAbsoluteEpisodeNumber = firstEp.EpisodeNumber + maxPartAbsoluteEpisodeNumber = minPartAbsoluteEpisodeNumber + animeMetadata.GetMainEpisodeCount() - 1 + } + + absoluteEpisodeNumber := firstEp.AbsoluteEpisodeNumber + + // Calculate the relative episode number + relativeEp := episode + + // Let's say the media has 12 episodes and the file is "episode 13" + // If the [partAbsoluteEpisodeNumber] is 13, then the [relativeEp] will be 1, we can safely ignore the [absoluteEpisodeNumber] + // e.g. 13 - (13-1) = 1 + if minPartAbsoluteEpisodeNumber <= episode && maxPartAbsoluteEpisodeNumber >= episode { + relativeEp = episode - (minPartAbsoluteEpisodeNumber - 1) + } else { + // Let's say the media has 12 episodes and the file is "episode 38" + // The [absoluteEpisodeNumber] will be 38 and the [relativeEp] will be 1 + // e.g. 38 - (38-1) = 1 + relativeEp = episode - (absoluteEpisodeNumber - 1) + } + + if relativeEp < 1 { + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.WarnLevel, lf, mId, episode). + Dict("normalization", zerolog.Dict(). + Bool("normalized", false). + Str("reason", "Episode normalization failed, could not find relative episode number"), + ). + Msg("File has been marked as main") + } + lf.Metadata.Episode = episode + lf.Metadata.AniDBEpisode = strconv.Itoa(episode) + lf.MediaId = fh.ForceMediaId + fh.ScanSummaryLogger.LogMetadataEpisodeNormalizationFailed(lf, errors.New("could not find relative episode number"), lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + return + } + + if fh.ScanLogger != nil { + fh.logFileHydration(zerolog.DebugLevel, lf, mId, relativeEp). + Dict("mediaTreeAnalysis", zerolog.Dict(). + Bool("normalized", true). + Int("forcedMediaId", fh.ForceMediaId), + ). + Msg("File has been marked as main") + } + lf.Metadata.Episode = relativeEp + lf.Metadata.AniDBEpisode = strconv.Itoa(relativeEp) + lf.MediaId = fh.ForceMediaId + fh.ScanSummaryLogger.LogMetadataMain(lf, lf.Metadata.Episode, lf.Metadata.AniDBEpisode) + return + + } + + }) + +} + +func (fh *FileHydrator) logFileHydration(level zerolog.Level, lf *anime.LocalFile, mId int, episode int) *zerolog.Event { + return fh.ScanLogger.LogFileHydrator(level). + Str("filename", lf.Name). + Int("mediaId", mId). + Dict("vars", zerolog.Dict(). + Str("parsedEpisode", lf.ParsedData.Episode). + Int("episode", episode), + ). + Dict("metadata", zerolog.Dict(). + Int("episode", lf.Metadata.Episode). + Str("aniDBEpisode", lf.Metadata.AniDBEpisode)) +} + +// normalizeEpisodeNumberAndHydrate will normalize the episode number and hydrate the metadata of the LocalFile. +// If the MediaTreeAnalysis is nil, the episode number will not be normalized. +func (fh *FileHydrator) normalizeEpisodeNumberAndHydrate( + mta *MediaTreeAnalysis, + lf *anime.LocalFile, + ep int, // The absolute episode number of the media + maxEp int, // The maximum episode number of the media +) error { + // No media tree analysis + if mta == nil { + diff := ep - maxEp // e.g. 14 - 12 = 2 + // Let's consider this a special episode (it might not exist on AniDB, but it's better than setting everything to "S1") + lf.Metadata.Episode = diff // e.g. 2 + lf.Metadata.AniDBEpisode = "S" + strconv.Itoa(diff) // e.g. S2 + lf.Metadata.Type = anime.LocalFileTypeSpecial + return errors.New("[hydrator] could not find media tree") + } + + relativeEp, mediaId, ok := mta.getRelativeEpisodeNumber(ep) + if !ok { + diff := ep - maxEp // e.g. 14 - 12 = 2 + // Do the same as above + lf.Metadata.Episode = diff + lf.Metadata.AniDBEpisode = "S" + strconv.Itoa(diff) // e.g. S2 + lf.Metadata.Type = anime.LocalFileTypeSpecial + return errors.New("[hydrator] could not find relative episode number from media tree") + } + + lf.Metadata.Episode = relativeEp + lf.Metadata.AniDBEpisode = strconv.Itoa(relativeEp) + lf.MediaId = mediaId + return nil +} diff --git a/seanime-2.9.10/internal/library/scanner/hydrator_test.go b/seanime-2.9.10/internal/library/scanner/hydrator_test.go new file mode 100644 index 0000000..52777ee --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/hydrator_test.go @@ -0,0 +1,122 @@ +package scanner + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/library/anime" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/util" + "seanime/internal/util/limiter" + "testing" +) + +func TestFileHydrator_HydrateMetadata(t *testing.T) { + + completeAnimeCache := anilist.NewCompleteAnimeCache() + anilistRateLimiter := limiter.NewAnilistLimiter() + logger := util.NewLogger() + metadataProvider := metadata.GetMockProvider(t) + anilistClient := anilist.TestGetMockAnilistClient() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + animeCollection, err := anilistPlatform.GetAnimeCollectionWithRelations(t.Context()) + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + allMedia := animeCollection.GetAllAnime() + + tests := []struct { + name string + paths []string + expectedMediaId int + }{ + { + name: "should be hydrated with id 131586", + paths: []string{ + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 20v2 (1080p) [30072859].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 21v2 (1080p) [4B1616A5].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 22v2 (1080p) [58BF43B4].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 23v2 (1080p) [D94B4894].mkv", + }, + expectedMediaId: 131586, // 86 - Eighty Six Part 2 + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + scanLogger, err := NewConsoleScanLogger() + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + // +---------------------+ + // | Local Files | + // +---------------------+ + + var lfs []*anime.LocalFile + for _, path := range tt.paths { + lf := anime.NewLocalFile(path, "E:/Anime") + lfs = append(lfs, lf) + } + + // +---------------------+ + // | MediaContainer | + // +---------------------+ + + mc := NewMediaContainer(&MediaContainerOptions{ + AllMedia: allMedia, + ScanLogger: scanLogger, + }) + + for _, nm := range mc.NormalizedMedia { + t.Logf("media id: %d, title: %s", nm.ID, nm.GetTitleSafe()) + } + + // +---------------------+ + // | Matcher | + // +---------------------+ + + matcher := &Matcher{ + LocalFiles: lfs, + MediaContainer: mc, + CompleteAnimeCache: nil, + Logger: util.NewLogger(), + ScanLogger: scanLogger, + } + + err = matcher.MatchLocalFilesWithMedia() + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + // +---------------------+ + // | FileHydrator | + // +---------------------+ + + fh := &FileHydrator{ + LocalFiles: lfs, + AllMedia: mc.NormalizedMedia, + CompleteAnimeCache: completeAnimeCache, + Platform: anilistPlatform, + AnilistRateLimiter: anilistRateLimiter, + MetadataProvider: metadataProvider, + Logger: logger, + ScanLogger: scanLogger, + } + + fh.HydrateMetadata() + + for _, lf := range fh.LocalFiles { + if lf.MediaId != tt.expectedMediaId { + t.Fatalf("expected media id %d, got %d", tt.expectedMediaId, lf.MediaId) + } + + t.Logf("local file: %s,\nmedia id: %d\n", lf.Name, lf.MediaId) + } + + }) + } + +} diff --git a/seanime-2.9.10/internal/library/scanner/ignore.go b/seanime-2.9.10/internal/library/scanner/ignore.go new file mode 100644 index 0000000..f695811 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/ignore.go @@ -0,0 +1,3 @@ +package scanner + +// .seaignore diff --git a/seanime-2.9.10/internal/library/scanner/localfile.go b/seanime-2.9.10/internal/library/scanner/localfile.go new file mode 100644 index 0000000..525f631 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/localfile.go @@ -0,0 +1,28 @@ +package scanner + +import ( + "github.com/rs/zerolog" + lop "github.com/samber/lo/parallel" + "seanime/internal/library/anime" + "seanime/internal/library/filesystem" +) + +// GetLocalFilesFromDir creates a new LocalFile for each video file +func GetLocalFilesFromDir(dirPath string, logger *zerolog.Logger) ([]*anime.LocalFile, error) { + paths, err := filesystem.GetMediaFilePathsFromDirS(dirPath) + + logger.Trace(). + Any("dirPath", dirPath). + Msg("localfile: Retrieving and creating local files") + + // Concurrently populate localFiles + localFiles := lop.Map(paths, func(path string, index int) *anime.LocalFile { + return anime.NewLocalFile(path, dirPath) + }) + + logger.Trace(). + Any("count", len(localFiles)). + Msg("localfile: Retrieved local files") + + return localFiles, err +} diff --git a/seanime-2.9.10/internal/library/scanner/localfile_test.go b/seanime-2.9.10/internal/library/scanner/localfile_test.go new file mode 100644 index 0000000..6b6334f --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/localfile_test.go @@ -0,0 +1,21 @@ +package scanner + +import ( + "github.com/stretchr/testify/assert" + "seanime/internal/util" + "testing" +) + +func TestGetLocalFilesFromDir(t *testing.T) { + t.Skip("Skipping test that requires local files") + + var dir = "E:/Anime" + + logger := util.NewLogger() + + localFiles, err := GetLocalFilesFromDir(dir, logger) + + if assert.NoError(t, err) { + t.Logf("Found %d local files", len(localFiles)) + } +} diff --git a/seanime-2.9.10/internal/library/scanner/matcher.go b/seanime-2.9.10/internal/library/scanner/matcher.go new file mode 100644 index 0000000..455afc4 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/matcher.go @@ -0,0 +1,552 @@ +package scanner + +import ( + "errors" + "fmt" + "math" + "seanime/internal/api/anilist" + "seanime/internal/hook" + "seanime/internal/library/anime" + "seanime/internal/library/summary" + "seanime/internal/util" + "seanime/internal/util/comparison" + "time" + + "github.com/adrg/strutil/metrics" + "github.com/rs/zerolog" + "github.com/samber/lo" + lop "github.com/samber/lo/parallel" + "github.com/sourcegraph/conc/pool" +) + +type Matcher struct { + LocalFiles []*anime.LocalFile + MediaContainer *MediaContainer + CompleteAnimeCache *anilist.CompleteAnimeCache + Logger *zerolog.Logger + ScanLogger *ScanLogger + ScanSummaryLogger *summary.ScanSummaryLogger // optional + Algorithm string + Threshold float64 +} + +var ( + ErrNoLocalFiles = errors.New("[matcher] no local files") +) + +// MatchLocalFilesWithMedia will match each anime.LocalFile with a specific anilist.BaseAnime and modify the LocalFile's `mediaId` +func (m *Matcher) MatchLocalFilesWithMedia() error { + + if m.Threshold == 0 { + m.Threshold = 0.5 + } + + start := time.Now() + + if len(m.LocalFiles) == 0 { + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.WarnLevel).Msg("No local files") + } + return ErrNoLocalFiles + } + if len(m.MediaContainer.allMedia) == 0 { + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.WarnLevel).Msg("No media fed into the matcher") + } + return errors.New("[matcher] no media fed into the matcher") + } + + m.Logger.Debug().Msg("matcher: Starting matching process") + + // Invoke ScanMatchingStarted hook + event := &ScanMatchingStartedEvent{ + LocalFiles: m.LocalFiles, + NormalizedMedia: m.MediaContainer.NormalizedMedia, + Algorithm: m.Algorithm, + Threshold: m.Threshold, + } + _ = hook.GlobalHookManager.OnScanMatchingStarted().Trigger(event) + m.LocalFiles = event.LocalFiles + m.MediaContainer.NormalizedMedia = event.NormalizedMedia + m.Algorithm = event.Algorithm + m.Threshold = event.Threshold + + if event.DefaultPrevented { + m.Logger.Debug().Msg("matcher: Match stopped by hook") + return nil + } + + // Parallelize the matching process + lop.ForEach(m.LocalFiles, func(localFile *anime.LocalFile, _ int) { + m.matchLocalFileWithMedia(localFile) + }) + + // m.validateMatches() + + // Invoke ScanMatchingCompleted hook + completedEvent := &ScanMatchingCompletedEvent{ + LocalFiles: m.LocalFiles, + } + _ = hook.GlobalHookManager.OnScanMatchingCompleted().Trigger(completedEvent) + m.LocalFiles = completedEvent.LocalFiles + + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.InfoLevel). + Int64("ms", time.Since(start).Milliseconds()). + Int("files", len(m.LocalFiles)). + Int("unmatched", lo.CountBy(m.LocalFiles, func(localFile *anime.LocalFile) bool { + return localFile.MediaId == 0 + })). + Msg("Finished matching process") + } + + return nil +} + +// matchLocalFileWithMedia finds the best match for the local file +// If the best match is above a certain threshold, set the local file's mediaId to the best match's id +// If the best match is below a certain threshold, leave the local file's mediaId to 0 +func (m *Matcher) matchLocalFileWithMedia(lf *anime.LocalFile) { + defer util.HandlePanicInModuleThenS("scanner/matcher/matchLocalFileWithMedia", func(stackTrace string) { + lf.MediaId = 0 + /*Log*/ + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.ErrorLevel). + Str("filename", lf.Name). + Msg("Panic occurred, file un-matched") + } + m.ScanSummaryLogger.LogPanic(lf, stackTrace) + }) + + // Check if the local file has already been matched + if lf.MediaId != 0 { + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Str("filename", lf.Name). + Msg("File already matched") + } + m.ScanSummaryLogger.LogFileNotMatched(lf, "Already matched") + return + } + // Check if the local file has a title + if lf.GetParsedTitle() == "" { + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.WarnLevel). + Str("filename", lf.Name). + Msg("File has no parsed title") + } + m.ScanSummaryLogger.LogFileNotMatched(lf, "No parsed title found") + return + } + + // Create title variations + // Check cache for title variation + + titleVariations := lf.GetTitleVariations() + + if len(titleVariations) == 0 { + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.WarnLevel). + Str("filename", lf.Name). + Msg("No titles found") + } + m.ScanSummaryLogger.LogFileNotMatched(lf, "No title variations found") + return + } + + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Str("filename", lf.Name). + Interface("titleVariations", titleVariations). + Msg("Matching local file") + } + m.ScanSummaryLogger.LogDebug(lf, util.InlineSpewT(titleVariations)) + + //------------------ + + var levMatch *comparison.LevenshteinResult + var sdMatch *comparison.SorensenDiceResult + var jaccardMatch *comparison.JaccardResult + + if m.Algorithm == "jaccard" { + // Using Jaccard + // Get the matchs for each title variation + compResults := lop.Map(titleVariations, func(title *string, _ int) *comparison.JaccardResult { + comps := make([]*comparison.JaccardResult, 0) + if len(m.MediaContainer.engTitles) > 0 { + if eng, found := comparison.FindBestMatchWithJaccard(title, m.MediaContainer.engTitles); found { + comps = append(comps, eng) + } + } + if len(m.MediaContainer.romTitles) > 0 { + if rom, found := comparison.FindBestMatchWithJaccard(title, m.MediaContainer.romTitles); found { + comps = append(comps, rom) + } + } + if len(m.MediaContainer.synonyms) > 0 { + if syn, found := comparison.FindBestMatchWithJaccard(title, m.MediaContainer.synonyms); found { + comps = append(comps, syn) + } + } + var res *comparison.JaccardResult + if len(comps) > 1 { + res = lo.Reduce(comps, func(prev *comparison.JaccardResult, curr *comparison.JaccardResult, _ int) *comparison.JaccardResult { + if prev.Rating > curr.Rating { + return prev + } else { + return curr + } + }, comps[0]) + } else if len(comps) == 1 { + return comps[0] + } + return res + }) + + // Retrieve the match from all the title variations results + jaccardMatch = lo.Reduce(compResults, func(prev *comparison.JaccardResult, curr *comparison.JaccardResult, _ int) *comparison.JaccardResult { + if prev.Rating > curr.Rating { + return prev + } else { + return curr + } + }, compResults[0]) + + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Str("filename", lf.Name). + Interface("match", jaccardMatch). + Interface("results", compResults). + Msg("Jaccard match") + } + m.ScanSummaryLogger.LogComparison(lf, "Jaccard", *jaccardMatch.Value, "Rating", util.InlineSpewT(jaccardMatch.Rating)) + + } else if m.Algorithm == "sorensen-dice" { + // Using Sorensen-Dice + // Get the matchs for each title variation + compResults := lop.Map(titleVariations, func(title *string, _ int) *comparison.SorensenDiceResult { + comps := make([]*comparison.SorensenDiceResult, 0) + if len(m.MediaContainer.engTitles) > 0 { + if eng, found := comparison.FindBestMatchWithSorensenDice(title, m.MediaContainer.engTitles); found { + comps = append(comps, eng) + } + } + if len(m.MediaContainer.romTitles) > 0 { + if rom, found := comparison.FindBestMatchWithSorensenDice(title, m.MediaContainer.romTitles); found { + comps = append(comps, rom) + } + } + if len(m.MediaContainer.synonyms) > 0 { + if syn, found := comparison.FindBestMatchWithSorensenDice(title, m.MediaContainer.synonyms); found { + comps = append(comps, syn) + } + } + var res *comparison.SorensenDiceResult + if len(comps) > 1 { + res = lo.Reduce(comps, func(prev *comparison.SorensenDiceResult, curr *comparison.SorensenDiceResult, _ int) *comparison.SorensenDiceResult { + if prev.Rating > curr.Rating { + return prev + } else { + return curr + } + }, comps[0]) + } else if len(comps) == 1 { + return comps[0] + } + return res + }) + + // Retrieve the match from all the title variations results + sdMatch = lo.Reduce(compResults, func(prev *comparison.SorensenDiceResult, curr *comparison.SorensenDiceResult, _ int) *comparison.SorensenDiceResult { + if prev.Rating > curr.Rating { + return prev + } else { + return curr + } + }, compResults[0]) + + //util.Spew(compResults) + + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Str("filename", lf.Name). + Interface("match", sdMatch). + Interface("results", compResults). + Msg("Sorensen-Dice match") + } + m.ScanSummaryLogger.LogComparison(lf, "Sorensen-Dice", *sdMatch.Value, "Rating", util.InlineSpewT(sdMatch.Rating)) + + } else { + // Using Levenshtein + // Get the matches for each title variation + levCompResults := lop.Map(titleVariations, func(title *string, _ int) *comparison.LevenshteinResult { + comps := make([]*comparison.LevenshteinResult, 0) + if len(m.MediaContainer.engTitles) > 0 { + if eng, found := comparison.FindBestMatchWithLevenshtein(title, m.MediaContainer.engTitles); found { + comps = append(comps, eng) + } + } + if len(m.MediaContainer.romTitles) > 0 { + if rom, found := comparison.FindBestMatchWithLevenshtein(title, m.MediaContainer.romTitles); found { + comps = append(comps, rom) + } + } + if len(m.MediaContainer.synonyms) > 0 { + if syn, found := comparison.FindBestMatchWithLevenshtein(title, m.MediaContainer.synonyms); found { + comps = append(comps, syn) + } + } + var res *comparison.LevenshteinResult + if len(comps) > 1 { + res = lo.Reduce(comps, func(prev *comparison.LevenshteinResult, curr *comparison.LevenshteinResult, _ int) *comparison.LevenshteinResult { + if prev.Distance < curr.Distance { + return prev + } else { + return curr + } + }, comps[0]) + } else if len(comps) == 1 { + return comps[0] + } + return res + }) + + levMatch = lo.Reduce(levCompResults, func(prev *comparison.LevenshteinResult, curr *comparison.LevenshteinResult, _ int) *comparison.LevenshteinResult { + if prev.Distance < curr.Distance { + return prev + } else { + return curr + } + }, levCompResults[0]) + + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Str("filename", lf.Name). + Interface("match", levMatch). + Interface("results", levCompResults). + Int("distance", levMatch.Distance). + Msg("Levenshtein match") + } + m.ScanSummaryLogger.LogComparison(lf, "Levenshtein", *levMatch.Value, "Distance", util.InlineSpewT(levMatch.Distance)) + } + + //------------------ + + var mediaMatch *anime.NormalizedMedia + var found bool + finalRating := 0.0 + + if sdMatch != nil { + finalRating = sdMatch.Rating + mediaMatch, found = m.MediaContainer.GetMediaFromTitleOrSynonym(sdMatch.Value) + + } else if jaccardMatch != nil { + finalRating = jaccardMatch.Rating + mediaMatch, found = m.MediaContainer.GetMediaFromTitleOrSynonym(jaccardMatch.Value) + + } else { + dice := metrics.NewSorensenDice() + dice.CaseSensitive = false + dice.NgramSize = 1 + finalRating = dice.Compare(*levMatch.OriginalValue, *levMatch.Value) + m.ScanSummaryLogger.LogComparison(lf, "Sorensen-Dice", *levMatch.Value, "Final rating", util.InlineSpewT(finalRating)) + mediaMatch, found = m.MediaContainer.GetMediaFromTitleOrSynonym(levMatch.Value) + } + + // After setting the mediaId, add the hook invocation + // Invoke ScanLocalFileMatched hook + event := &ScanLocalFileMatchedEvent{ + LocalFile: lf, + Score: finalRating, + Match: mediaMatch, + Found: found, + } + hook.GlobalHookManager.OnScanLocalFileMatched().Trigger(event) + lf = event.LocalFile + mediaMatch = event.Match + found = event.Found + finalRating = event.Score + + // Check if the hook overrode the match + if event.DefaultPrevented { + if m.ScanLogger != nil { + if mediaMatch != nil { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Str("filename", lf.Name). + Int("id", mediaMatch.ID). + Msg("Hook overrode match") + } else { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Str("filename", lf.Name). + Msg("Hook overrode match, no match found") + } + } + if mediaMatch != nil { + lf.MediaId = mediaMatch.ID + } else { + lf.MediaId = 0 + } + return + } + + if !found { + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.ErrorLevel). + Str("filename", lf.Name). + Msg("No media found from comparison result") + } + m.ScanSummaryLogger.LogFileNotMatched(lf, "No media found from comparison result") + return + } + + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Str("filename", lf.Name). + Str("title", mediaMatch.GetTitleSafe()). + Int("id", mediaMatch.ID). + Msg("Best match found") + } + + if finalRating < m.Threshold { + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Str("filename", lf.Name). + Float64("rating", finalRating). + Float64("threshold", m.Threshold). + Msg("Best match Sorensen-Dice rating too low, un-matching file") + } + m.ScanSummaryLogger.LogFailedMatch(lf, "Rating too low, threshold is "+fmt.Sprintf("%f", m.Threshold)) + return + } + + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Str("filename", lf.Name). + Float64("rating", finalRating). + Float64("threshold", m.Threshold). + Msg("Best match rating high enough, matching file") + } + m.ScanSummaryLogger.LogSuccessfullyMatched(lf, mediaMatch.ID) + + lf.MediaId = mediaMatch.ID +} + +//---------------------------------------------------------------------------------------------------------------------- + +// validateMatches compares groups of local files' titles with the media titles and un-matches the local files that have a lower rating than the highest rating. +func (m *Matcher) validateMatches() { + + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.InfoLevel).Msg("Validating matches") + } + + // Group local files by media ID + groups := lop.GroupBy(m.LocalFiles, func(localFile *anime.LocalFile) int { + return localFile.MediaId + }) + + // Remove the group with unmatched media + delete(groups, 0) + + // Un-match files with lower ratings + p := pool.New() + for mId, files := range groups { + p.Go(func() { + if len(files) > 0 { + m.validateMatchGroup(mId, files) + } + }) + } + p.Wait() + +} + +// validateMatchGroup compares the local files' titles under the same media +// with the media titles and un-matches the local files that have a lower rating. +// This is done to try and filter out wrong matches. +func (m *Matcher) validateMatchGroup(mediaId int, lfs []*anime.LocalFile) { + + media, found := m.MediaContainer.GetMediaFromId(mediaId) + if !found { + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.ErrorLevel). + Int("mediaId", mediaId). + Msg("Media not found in media container") + } + return + } + + titles := media.GetAllTitles() + + // Compare all files' parsed title with the media title + // Get the highest rating that will be used to un-match lower rated files + p := pool.NewWithResults[float64]() + for _, lf := range lfs { + p.Go(func() float64 { + t := lf.GetParsedTitle() + if comparison.ValueContainsSpecial(lf.Name) || comparison.ValueContainsNC(lf.Name) { + return 0 + } + compRes, ok := comparison.FindBestMatchWithSorensenDice(&t, titles) + if ok { + return compRes.Rating + } + return 0 + }) + } + fileRatings := p.Wait() + + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Int("mediaId", mediaId). + Any("fileRatings", fileRatings). + Msg("File ratings") + } + + highestRating := lo.Reduce(fileRatings, func(prev float64, curr float64, _ int) float64 { + if prev > curr { + return prev + } else { + return curr + } + }, 0.0) + + // Un-match files that have a lower rating than the ceiling + // UNLESS they are Special or NC + lop.ForEach(lfs, func(lf *anime.LocalFile, _ int) { + if !comparison.ValueContainsSpecial(lf.Name) && !comparison.ValueContainsNC(lf.Name) { + t := lf.GetParsedTitle() + if compRes, ok := comparison.FindBestMatchWithSorensenDice(&t, titles); ok { + // If the local file's rating is lower, un-match it + // Unless the difference is less than 0.7 (very lax since a lot of anime have very long names that can be truncated) + if compRes.Rating < highestRating && math.Abs(compRes.Rating-highestRating) > 0.7 { + lf.MediaId = 0 + + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.WarnLevel). + Int("mediaId", mediaId). + Str("filename", lf.Name). + Float64("rating", compRes.Rating). + Float64("highestRating", highestRating). + Msg("Rating does not match parameters, un-matching file") + } + m.ScanSummaryLogger.LogUnmatched(lf, fmt.Sprintf("Rating does not match parameters. File rating: %f, highest rating: %f", compRes.Rating, highestRating)) + + } else { + + if m.ScanLogger != nil { + m.ScanLogger.LogMatcher(zerolog.DebugLevel). + Int("mediaId", mediaId). + Str("filename", lf.Name). + Float64("rating", compRes.Rating). + Float64("highestRating", highestRating). + Msg("Rating matches parameters, keeping file matched") + } + m.ScanSummaryLogger.LogMatchValidated(lf, mediaId) + + } + } + } + }) + +} diff --git a/seanime-2.9.10/internal/library/scanner/matcher_test.go b/seanime-2.9.10/internal/library/scanner/matcher_test.go new file mode 100644 index 0000000..9bad68d --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/matcher_test.go @@ -0,0 +1,244 @@ +package scanner + +import ( + "context" + "seanime/internal/api/anilist" + "seanime/internal/library/anime" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +// Add more media to this file if needed +// scanner_test_mock_data.json + +func TestMatcher_MatchLocalFileWithMedia(t *testing.T) { + + anilistClient := anilist.TestGetMockAnilistClient() + animeCollection, err := anilistClient.AnimeCollectionWithRelations(context.Background(), nil) + if err != nil { + t.Fatal(err.Error()) + } + allMedia := animeCollection.GetAllAnime() + + dir := "E:/Anime" + + tests := []struct { + name string + paths []string + expectedMediaId int + }{ + { + // These local files are from "86 - Eighty Six Part 2" but should be matched with "86 - Eighty Six Part 1" + // because there is no indication for the part. However, the FileHydrator will fix this issue. + name: "should match with media id 116589", + paths: []string{ + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 20v2 (1080p) [30072859].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 21v2 (1080p) [4B1616A5].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 22v2 (1080p) [58BF43B4].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 23v2 (1080p) [D94B4894].mkv", + }, + expectedMediaId: 116589, // 86 - Eighty Six Part 1 + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + scanLogger, err := NewConsoleScanLogger() + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + // +---------------------+ + // | Local Files | + // +---------------------+ + + var lfs []*anime.LocalFile + for _, path := range tt.paths { + lf := anime.NewLocalFile(path, dir) + lfs = append(lfs, lf) + } + + // +---------------------+ + // | MediaContainer | + // +---------------------+ + + mc := NewMediaContainer(&MediaContainerOptions{ + AllMedia: allMedia, + ScanLogger: scanLogger, + }) + + // +---------------------+ + // | Matcher | + // +---------------------+ + + matcher := &Matcher{ + LocalFiles: lfs, + MediaContainer: mc, + CompleteAnimeCache: nil, + Logger: util.NewLogger(), + ScanLogger: scanLogger, + ScanSummaryLogger: nil, + } + + err = matcher.MatchLocalFilesWithMedia() + + if assert.NoError(t, err, "Error while matching local files") { + for _, lf := range lfs { + if lf.MediaId != tt.expectedMediaId { + t.Fatalf("expected media id %d, got %d", tt.expectedMediaId, lf.MediaId) + } + t.Logf("local file: %s,\nmedia id: %d\n", lf.Name, lf.MediaId) + } + } + }) + } + +} + +func TestMatcher_MatchLocalFileWithMedia2(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + anilistClient := anilist.NewAnilistClient(test_utils.ConfigData.Provider.AnilistJwt) + animeCollection, err := anilistClient.AnimeCollectionWithRelations(context.Background(), &test_utils.ConfigData.Provider.AnilistUsername) + if err != nil { + t.Fatal(err.Error()) + } + + dir := "E:/Anime" + + tests := []struct { + name string + paths []string + expectedMediaId int + otherMediaIds []int + }{ + { + name: "Kono Subarashii Sekai ni Shukufuku wo! - 21202", + paths: []string{ + "E:/Anime/Kono Subarashii Sekai ni Shukufuku wo!/Kono Subarashii Sekai ni Shukufuku wo! (01-10) [1080p] (Batch)/[HorribleSubs] Kono Subarashii Sekai ni Shukufuku wo! - 01 [1080p].mkv", + "E:/Anime/Kono Subarashii Sekai ni Shukufuku wo!/Kono Subarashii Sekai ni Shukufuku wo! (01-10) [1080p] (Batch)/[HorribleSubs] Kono Subarashii Sekai ni Shukufuku wo! - 02 [1080p].mkv", + "E:/Anime/Kono Subarashii Sekai ni Shukufuku wo!/Kono Subarashii Sekai ni Shukufuku wo! (01-10) [1080p] (Batch)/[HorribleSubs] Kono Subarashii Sekai ni Shukufuku wo! - 03 [1080p].mkv", + }, + expectedMediaId: 21202, // + }, + { + name: "Kono Subarashii Sekai ni Shukufuku wo! 2 - 21699", + paths: []string{ + "E:/Anime/Kono Subarashii Sekai ni Shukufuku wo! 2/KonoSuba.God's.Blessing.On.This.Wonderful.World.S02.1080p.BluRay.10-Bit.Dual-Audio.FLAC2.0.x265-YURASUKA/KonoSuba.God's.Blessing.On.This.Wonderful.World.S02E01.1080p.BluRay.10-Bit.Dual-Audio.FLAC2.0.x265-YURASUKA.mkv", + "E:/Anime/Kono Subarashii Sekai ni Shukufuku wo! 2/KonoSuba.God's.Blessing.On.This.Wonderful.World.S02.1080p.BluRay.10-Bit.Dual-Audio.FLAC2.0.x265-YURASUKA/KonoSuba.God's.Blessing.On.This.Wonderful.World.S02E02.1080p.BluRay.10-Bit.Dual-Audio.FLAC2.0.x265-YURASUKA.mkv", + "E:/Anime/Kono Subarashii Sekai ni Shukufuku wo! 2/KonoSuba.God's.Blessing.On.This.Wonderful.World.S02.1080p.BluRay.10-Bit.Dual-Audio.FLAC2.0.x265-YURASUKA/KonoSuba.God's.Blessing.On.This.Wonderful.World.S02E03.1080p.BluRay.10-Bit.Dual-Audio.FLAC2.0.x265-YURASUKA.mkv", + }, + expectedMediaId: 21699, + }, + { + name: "Demon Slayer: Kimetsu no Yaiba Entertainment District Arc - 142329", + paths: []string{ + "E:/Anime/Kimetsu no Yaiba Yuukaku-hen/[Salieri] Demon Slayer - Kimetsu No Yaiba - S3 - Entertainment District - BD (1080P) (HDR) [Dual-Audio]/[Salieri] Demon Slayer S3 - Kimetsu No Yaiba- Entertainment District - 03 (1080P) (HDR) [Dual-Audio].mkv", + }, + expectedMediaId: 142329, + }, + { + name: "KnY 145139", + paths: []string{ + "E:/Anime/Kimetsu no Yaiba Katanakaji no Sato-hen/Demon Slayer S03 1080p Dual Audio BDRip 10 bits DD x265-EMBER/S03E07-Awful Villain [703A5C5B].mkv", + }, + expectedMediaId: 145139, + }, + { + name: "MT 108465", + paths: []string{ + "E:/Anime/Mushoku Tensei Isekai Ittara Honki Dasu/Mushoku Tensei S01+SP 1080p Dual Audio BDRip 10 bits DDP x265-EMBER/Mushoku Tensei S01P01 1080p Dual Audio BDRip 10 bits DD x265-EMBER/S01E01-Jobless Reincarnation V2 [911C3607].mkv", + }, + expectedMediaId: 108465, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + // Add media to collection if it doesn't exist + allMedia := animeCollection.GetAllAnime() + hasExpectedMediaId := false + for _, media := range allMedia { + if media.ID == tt.expectedMediaId { + hasExpectedMediaId = true + break + } + } + if !hasExpectedMediaId { + anilist.TestAddAnimeCollectionWithRelationsEntry(animeCollection, tt.expectedMediaId, anilist.TestModifyAnimeCollectionEntryInput{Status: lo.ToPtr(anilist.MediaListStatusCurrent)}, anilistClient) + allMedia = animeCollection.GetAllAnime() + } + + for _, otherMediaId := range tt.otherMediaIds { + hasOtherMediaId := false + for _, media := range allMedia { + if media.ID == otherMediaId { + hasOtherMediaId = true + break + } + } + if !hasOtherMediaId { + anilist.TestAddAnimeCollectionWithRelationsEntry(animeCollection, otherMediaId, anilist.TestModifyAnimeCollectionEntryInput{Status: lo.ToPtr(anilist.MediaListStatusCurrent)}, anilistClient) + allMedia = animeCollection.GetAllAnime() + } + } + + scanLogger, err := NewConsoleScanLogger() + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + // +---------------------+ + // | Local Files | + // +---------------------+ + + var lfs []*anime.LocalFile + for _, path := range tt.paths { + lf := anime.NewLocalFile(path, dir) + lfs = append(lfs, lf) + } + + // +---------------------+ + // | MediaContainer | + // +---------------------+ + + mc := NewMediaContainer(&MediaContainerOptions{ + AllMedia: allMedia, + ScanLogger: scanLogger, + }) + + // +---------------------+ + // | Matcher | + // +---------------------+ + + matcher := &Matcher{ + LocalFiles: lfs, + MediaContainer: mc, + CompleteAnimeCache: nil, + Logger: util.NewLogger(), + ScanLogger: scanLogger, + ScanSummaryLogger: nil, + } + + err = matcher.MatchLocalFilesWithMedia() + + if assert.NoError(t, err, "Error while matching local files") { + for _, lf := range lfs { + if lf.MediaId != tt.expectedMediaId { + t.Fatalf("expected media id %d, got %d", tt.expectedMediaId, lf.MediaId) + } + t.Logf("local file: %s,\nmedia id: %d\n", lf.Name, lf.MediaId) + } + } + }) + } + +} diff --git a/seanime-2.9.10/internal/library/scanner/media_container.go b/seanime-2.9.10/internal/library/scanner/media_container.go new file mode 100644 index 0000000..6fdeeb8 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/media_container.go @@ -0,0 +1,146 @@ +package scanner + +import ( + "github.com/rs/zerolog" + "github.com/samber/lo" + lop "github.com/samber/lo/parallel" + "seanime/internal/api/anilist" + "seanime/internal/library/anime" + "seanime/internal/util/comparison" + "strings" +) + +type ( + MediaContainerOptions struct { + AllMedia []*anilist.CompleteAnime + ScanLogger *ScanLogger + } + + MediaContainer struct { + NormalizedMedia []*anime.NormalizedMedia + ScanLogger *ScanLogger + engTitles []*string + romTitles []*string + synonyms []*string + allMedia []*anilist.CompleteAnime + } +) + +// NewMediaContainer will create a list of all English titles, Romaji titles, and synonyms from all anilist.BaseAnime (used by Matcher). +// +// The list will include all anilist.BaseAnime and their relations (prequels, sequels, spin-offs, etc...) as NormalizedMedia. +// +// It also provides helper functions to get a NormalizedMedia from a title or synonym (used by FileHydrator). +func NewMediaContainer(opts *MediaContainerOptions) *MediaContainer { + mc := new(MediaContainer) + mc.ScanLogger = opts.ScanLogger + + mc.NormalizedMedia = make([]*anime.NormalizedMedia, 0) + + normalizedMediaMap := make(map[int]*anime.NormalizedMedia) + + for _, m := range opts.AllMedia { + normalizedMediaMap[m.ID] = anime.NewNormalizedMedia(m.ToBaseAnime()) + if m.Relations != nil && m.Relations.Edges != nil && len(m.Relations.Edges) > 0 { + for _, edgeM := range m.Relations.Edges { + if edgeM.Node == nil || edgeM.Node.Format == nil || edgeM.RelationType == nil { + continue + } + if *edgeM.Node.Format != anilist.MediaFormatMovie && + *edgeM.Node.Format != anilist.MediaFormatOva && + *edgeM.Node.Format != anilist.MediaFormatSpecial && + *edgeM.Node.Format != anilist.MediaFormatTv { + continue + } + if *edgeM.RelationType != anilist.MediaRelationPrequel && + *edgeM.RelationType != anilist.MediaRelationSequel && + *edgeM.RelationType != anilist.MediaRelationSpinOff && + *edgeM.RelationType != anilist.MediaRelationAlternative && + *edgeM.RelationType != anilist.MediaRelationParent { + continue + } + // DEVNOTE: Edges fetched from the AniList AnimeCollection query do not contain NextAiringEpisode + // Make sure we don't overwrite the original media in the map that contains NextAiringEpisode + if _, found := normalizedMediaMap[edgeM.Node.ID]; !found { + normalizedMediaMap[edgeM.Node.ID] = anime.NewNormalizedMedia(edgeM.Node) + } + } + } + } + for _, m := range normalizedMediaMap { + mc.NormalizedMedia = append(mc.NormalizedMedia, m) + } + + engTitles := lop.Map(mc.NormalizedMedia, func(m *anime.NormalizedMedia, index int) *string { + if m.Title.English != nil { + return m.Title.English + } + return new(string) + }) + romTitles := lop.Map(mc.NormalizedMedia, func(m *anime.NormalizedMedia, index int) *string { + if m.Title.Romaji != nil { + return m.Title.Romaji + } + return new(string) + }) + _synonymsArr := lop.Map(mc.NormalizedMedia, func(m *anime.NormalizedMedia, index int) []*string { + if m.Synonyms != nil { + return m.Synonyms + } + return make([]*string, 0) + }) + synonyms := lo.Flatten(_synonymsArr) + engTitles = lo.Filter(engTitles, func(s *string, i int) bool { return s != nil && len(*s) > 0 }) + romTitles = lo.Filter(romTitles, func(s *string, i int) bool { return s != nil && len(*s) > 0 }) + synonyms = lo.Filter(synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) }) + + mc.engTitles = engTitles + mc.romTitles = romTitles + mc.synonyms = synonyms + mc.allMedia = opts.AllMedia + + if mc.ScanLogger != nil { + mc.ScanLogger.LogMediaContainer(zerolog.InfoLevel). + Any("inputCount", len(opts.AllMedia)). + Any("mediaCount", len(mc.NormalizedMedia)). + Any("titles", len(mc.engTitles)+len(mc.romTitles)+len(mc.synonyms)). + Msg("Created media container") + } + + return mc +} + +func (mc *MediaContainer) GetMediaFromTitleOrSynonym(title *string) (*anime.NormalizedMedia, bool) { + if title == nil { + return nil, false + } + t := strings.ToLower(*title) + res, found := lo.Find(mc.NormalizedMedia, func(m *anime.NormalizedMedia) bool { + if m.HasEnglishTitle() && t == strings.ToLower(*m.Title.English) { + return true + } + if m.HasRomajiTitle() && t == strings.ToLower(*m.Title.Romaji) { + return true + } + if m.HasSynonyms() { + for _, syn := range m.Synonyms { + if t == strings.ToLower(*syn) { + return true + } + } + } + return false + }) + + return res, found +} + +func (mc *MediaContainer) GetMediaFromId(id int) (*anime.NormalizedMedia, bool) { + res, found := lo.Find(mc.NormalizedMedia, func(m *anime.NormalizedMedia) bool { + if m.ID == id { + return true + } + return false + }) + return res, found +} diff --git a/seanime-2.9.10/internal/library/scanner/media_fetcher.go b/seanime-2.9.10/internal/library/scanner/media_fetcher.go new file mode 100644 index 0000000..9786637 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/media_fetcher.go @@ -0,0 +1,352 @@ +package scanner + +import ( + "context" + "errors" + "seanime/internal/api/anilist" + "seanime/internal/api/mal" + "seanime/internal/api/metadata" + "seanime/internal/hook" + "seanime/internal/library/anime" + "seanime/internal/platforms/platform" + "seanime/internal/util" + "seanime/internal/util/limiter" + "seanime/internal/util/parallel" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/rs/zerolog" + "github.com/samber/lo" + lop "github.com/samber/lo/parallel" +) + +// MediaFetcher holds all anilist.BaseAnime that will be used for the comparison process +type MediaFetcher struct { + AllMedia []*anilist.CompleteAnime + CollectionMediaIds []int + UnknownMediaIds []int // Media IDs that are not in the user's collection + AnimeCollectionWithRelations *anilist.AnimeCollectionWithRelations + ScanLogger *ScanLogger +} + +type MediaFetcherOptions struct { + Enhanced bool + Platform platform.Platform + MetadataProvider metadata.Provider + LocalFiles []*anime.LocalFile + CompleteAnimeCache *anilist.CompleteAnimeCache + Logger *zerolog.Logger + AnilistRateLimiter *limiter.Limiter + DisableAnimeCollection bool + ScanLogger *ScanLogger +} + +// NewMediaFetcher +// Calling this method will kickstart the fetch process +// When enhancing is false, MediaFetcher.AllMedia will be all anilist.BaseAnime from the user's AniList collection. +// When enhancing is true, MediaFetcher.AllMedia will be anilist.BaseAnime for each unique, parsed anime title and their relations. +func NewMediaFetcher(ctx context.Context, opts *MediaFetcherOptions) (ret *MediaFetcher, retErr error) { + defer util.HandlePanicInModuleWithError("library/scanner/NewMediaFetcher", &retErr) + + if opts.Platform == nil || + opts.LocalFiles == nil || + opts.CompleteAnimeCache == nil || + opts.MetadataProvider == nil || + opts.Logger == nil || + opts.AnilistRateLimiter == nil { + return nil, errors.New("missing options") + } + + mf := new(MediaFetcher) + mf.ScanLogger = opts.ScanLogger + + opts.Logger.Debug(). + Any("enhanced", opts.Enhanced). + Msg("media fetcher: Creating media fetcher") + + if mf.ScanLogger != nil { + mf.ScanLogger.LogMediaFetcher(zerolog.InfoLevel). + Msg("Creating media fetcher") + } + + // Invoke ScanMediaFetcherStarted hook + event := &ScanMediaFetcherStartedEvent{ + Enhanced: opts.Enhanced, + } + hook.GlobalHookManager.OnScanMediaFetcherStarted().Trigger(event) + opts.Enhanced = event.Enhanced + + // +---------------------+ + // | All media | + // +---------------------+ + + // Fetch latest user's AniList collection + animeCollectionWithRelations, err := opts.Platform.GetAnimeCollectionWithRelations(ctx) + if err != nil { + return nil, err + } + + mf.AnimeCollectionWithRelations = animeCollectionWithRelations + + mf.AllMedia = make([]*anilist.CompleteAnime, 0) + + if !opts.DisableAnimeCollection { + // For each collection entry, append the media to AllMedia + for _, list := range animeCollectionWithRelations.GetMediaListCollection().GetLists() { + for _, entry := range list.GetEntries() { + mf.AllMedia = append(mf.AllMedia, entry.GetMedia()) + + // +---------------------+ + // | Cache | + // +---------------------+ + // We assume the CompleteAnimeCache is empty. Add media to cache. + opts.CompleteAnimeCache.Set(entry.GetMedia().ID, entry.GetMedia()) + } + } + } + + if mf.ScanLogger != nil { + mf.ScanLogger.LogMediaFetcher(zerolog.DebugLevel). + Int("count", len(mf.AllMedia)). + Msg("Fetched media from AniList collection") + } + + //-------------------------------------------- + + // Get the media IDs from the collection + mf.CollectionMediaIds = lop.Map(mf.AllMedia, func(m *anilist.CompleteAnime, index int) int { + return m.ID + }) + + //-------------------------------------------- + + // +---------------------+ + // | Enhanced | + // +---------------------+ + + // If enhancing is on, scan media from local files and get their relations + if opts.Enhanced { + + _, ok := FetchMediaFromLocalFiles( + ctx, + opts.Platform, + opts.LocalFiles, + opts.CompleteAnimeCache, // CompleteAnimeCache will be populated on success + opts.MetadataProvider, + opts.AnilistRateLimiter, + mf.ScanLogger, + ) + if ok { + // We assume the CompleteAnimeCache is populated. We overwrite AllMedia with the cache content. + // This is because the cache will contain all media from the user's collection AND scanned ones + mf.AllMedia = make([]*anilist.CompleteAnime, 0) + opts.CompleteAnimeCache.Range(func(key int, value *anilist.CompleteAnime) bool { + mf.AllMedia = append(mf.AllMedia, value) + return true + }) + } + } + + // +---------------------+ + // | Unknown media | + // +---------------------+ + // Media that are not in the user's collection + + // Get the media that are not in the user's collection + unknownMedia := lo.Filter(mf.AllMedia, func(m *anilist.CompleteAnime, _ int) bool { + return !lo.Contains(mf.CollectionMediaIds, m.ID) + }) + // Get the media IDs that are not in the user's collection + mf.UnknownMediaIds = lop.Map(unknownMedia, func(m *anilist.CompleteAnime, _ int) int { + return m.ID + }) + + if mf.ScanLogger != nil { + mf.ScanLogger.LogMediaFetcher(zerolog.DebugLevel). + Int("unknownMediaCount", len(mf.UnknownMediaIds)). + Int("allMediaCount", len(mf.AllMedia)). + Msg("Finished creating media fetcher") + } + + // Invoke ScanMediaFetcherCompleted hook + completedEvent := &ScanMediaFetcherCompletedEvent{ + AllMedia: mf.AllMedia, + UnknownMediaIds: mf.UnknownMediaIds, + } + _ = hook.GlobalHookManager.OnScanMediaFetcherCompleted().Trigger(completedEvent) + mf.AllMedia = completedEvent.AllMedia + mf.UnknownMediaIds = completedEvent.UnknownMediaIds + + return mf, nil +} + +//---------------------------------------------------------------------------------------------------------------------- + +// FetchMediaFromLocalFiles gets media and their relations from local file titles. +// It retrieves unique titles from local files, +// fetches mal.SearchResultAnime from MAL, +// uses these search results to get AniList IDs using metadata.AnimeMetadata mappings, +// queries AniList to retrieve all anilist.BaseAnime using anilist.GetBaseAnimeById and their relations using anilist.FetchMediaTree. +// It does not return an error if one of the steps fails. +// It returns the scanned media and a boolean indicating whether the process was successful. +func FetchMediaFromLocalFiles( + ctx context.Context, + platform platform.Platform, + localFiles []*anime.LocalFile, + completeAnime *anilist.CompleteAnimeCache, + metadataProvider metadata.Provider, + anilistRateLimiter *limiter.Limiter, + scanLogger *ScanLogger, +) (ret []*anilist.CompleteAnime, ok bool) { + defer util.HandlePanicInModuleThen("library/scanner/FetchMediaFromLocalFiles", func() { + ok = false + }) + + if scanLogger != nil { + scanLogger.LogMediaFetcher(zerolog.DebugLevel). + Str("module", "Enhanced"). + Msg("Fetching media from local files") + } + + rateLimiter := limiter.NewLimiter(time.Second, 20) + rateLimiter2 := limiter.NewLimiter(time.Second, 20) + + // Get titles + titles := anime.GetUniqueAnimeTitlesFromLocalFiles(localFiles) + + if scanLogger != nil { + scanLogger.LogMediaFetcher(zerolog.DebugLevel). + Str("module", "Enhanced"). + Str("context", spew.Sprint(titles)). + Msg("Parsed titles from local files") + } + + // +---------------------+ + // | MyAnimeList | + // +---------------------+ + + // Get MAL media from titles + malSR := parallel.NewSettledResults[string, *mal.SearchResultAnime](titles) + malSR.AllSettled(func(title string, index int) (*mal.SearchResultAnime, error) { + rateLimiter.Wait() + return mal.AdvancedSearchWithMAL(title) + }) + malRes, ok := malSR.GetFulfilledResults() + if !ok { + return nil, false + } + + // Get duplicate-free version of MAL media + malMedia := lo.UniqBy(*malRes, func(res *mal.SearchResultAnime) int { return res.ID }) + // Get the MAL media IDs + malIds := lop.Map(malMedia, func(n *mal.SearchResultAnime, index int) int { return n.ID }) + + if scanLogger != nil { + scanLogger.LogMediaFetcher(zerolog.DebugLevel). + Str("module", "Enhanced"). + Str("context", spew.Sprint(lo.Map(malMedia, func(n *mal.SearchResultAnime, _ int) string { + return n.Name + }))). + Msg("Fetched MAL media from titles") + } + + // +---------------------+ + // | Animap | + // +---------------------+ + + // Get Animap mappings for each MAL ID and store them in `metadataProvider` + // This step is necessary because MAL doesn't provide AniList IDs and some MAL media don't exist on AniList + lop.ForEach(malIds, func(id int, index int) { + rateLimiter2.Wait() + //_, _ = metadataProvider.GetAnimeMetadata(metadata.MalPlatform, id) + _, _ = metadataProvider.GetCache().GetOrSet(metadata.GetAnimeMetadataCacheKey(metadata.MalPlatform, id), func() (*metadata.AnimeMetadata, error) { + res, err := metadataProvider.GetAnimeMetadata(metadata.MalPlatform, id) + return res, err + }) + }) + + // +---------------------+ + // | AniList | + // +---------------------+ + + // Retrieve the AniList IDs from the Animap mappings stored in the cache + anilistIds := make([]int, 0) + metadataProvider.GetCache().Range(func(key string, value *metadata.AnimeMetadata) bool { + if value != nil { + anilistIds = append(anilistIds, value.GetMappings().AnilistId) + } + return true + }) + + // Fetch all media from the AniList IDs + anilistMedia := make([]*anilist.CompleteAnime, 0) + lop.ForEach(anilistIds, func(id int, index int) { + anilistRateLimiter.Wait() + media, err := platform.GetAnimeWithRelations(ctx, id) + if err == nil { + anilistMedia = append(anilistMedia, media) + if scanLogger != nil { + scanLogger.LogMediaFetcher(zerolog.DebugLevel). + Str("module", "Enhanced"). + Str("title", media.GetTitleSafe()). + Msg("Fetched Anilist media from MAL id") + } + } else { + if scanLogger != nil { + scanLogger.LogMediaFetcher(zerolog.WarnLevel). + Str("module", "Enhanced"). + Int("id", id). + Msg("Failed to fetch Anilist media from MAL id") + } + } + }) + + if scanLogger != nil { + scanLogger.LogMediaFetcher(zerolog.DebugLevel). + Str("module", "Enhanced"). + Str("context", spew.Sprint(lo.Map(anilistMedia, func(n *anilist.CompleteAnime, _ int) string { + return n.GetTitleSafe() + }))). + Msg("Fetched Anilist media from MAL ids") + } + + // +---------------------+ + // | MediaTree | + // +---------------------+ + + // Create a new tree that will hold the fetched relations + // /!\ This is redundant because we already have a cache, but `FetchMediaTree` needs its + tree := anilist.NewCompleteAnimeRelationTree() + + start := time.Now() + // For each media, fetch its relations + // The relations are fetched in parallel and added to `completeAnime` + lop.ForEach(anilistMedia, func(m *anilist.CompleteAnime, index int) { + // We ignore errors because we want to continue even if one of the media fails + _ = m.FetchMediaTree(anilist.FetchMediaTreeAll, platform.GetAnilistClient(), anilistRateLimiter, tree, completeAnime) + }) + + // +---------------------+ + // | Cache | + // +---------------------+ + + // Retrieve all media from the cache + scanned := make([]*anilist.CompleteAnime, 0) + completeAnime.Range(func(key int, value *anilist.CompleteAnime) bool { + scanned = append(scanned, value) + return true + }) + + if scanLogger != nil { + scanLogger.LogMediaFetcher(zerolog.InfoLevel). + Str("module", "Enhanced"). + Int("ms", int(time.Since(start).Milliseconds())). + Int("count", len(scanned)). + Str("context", spew.Sprint(lo.Map(scanned, func(n *anilist.CompleteAnime, _ int) string { + return n.GetTitleSafe() + }))). + Msg("Finished fetching media from local files") + } + + return scanned, true +} diff --git a/seanime-2.9.10/internal/library/scanner/media_fetcher_test.go b/seanime-2.9.10/internal/library/scanner/media_fetcher_test.go new file mode 100644 index 0000000..a275b70 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/media_fetcher_test.go @@ -0,0 +1,273 @@ +package scanner + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/library/anime" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "seanime/internal/util/limiter" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +func TestNewMediaFetcher(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + anilistClient := anilist.TestGetMockAnilistClient() + logger := util.NewLogger() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + metadataProvider := metadata.GetMockProvider(t) + completeAnimeCache := anilist.NewCompleteAnimeCache() + anilistRateLimiter := limiter.NewAnilistLimiter() + + dir := "E:/Anime" + + tests := []struct { + name string + paths []string + enhanced bool + disableAnimeCollection bool + }{ + { + name: "86 - Eighty Six Part 1 & 2", + paths: []string{ + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 20v2 (1080p) [30072859].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 21v2 (1080p) [4B1616A5].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 22v2 (1080p) [58BF43B4].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 23v2 (1080p) [D94B4894].mkv", + }, + enhanced: false, + disableAnimeCollection: false, + }, + { + name: "86 - Eighty Six Part 1 & 2", + paths: []string{ + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 20v2 (1080p) [30072859].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 21v2 (1080p) [4B1616A5].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 22v2 (1080p) [58BF43B4].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 23v2 (1080p) [D94B4894].mkv", + }, + enhanced: true, + disableAnimeCollection: true, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + scanLogger, err := NewConsoleScanLogger() + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + // +---------------------+ + // | Local Files | + // +---------------------+ + + var lfs []*anime.LocalFile + for _, path := range tt.paths { + lf := anime.NewLocalFile(path, dir) + lfs = append(lfs, lf) + } + + // +---------------------+ + // | MediaFetcher | + // +---------------------+ + + mf, err := NewMediaFetcher(t.Context(), &MediaFetcherOptions{ + Enhanced: tt.enhanced, + Platform: anilistPlatform, + LocalFiles: lfs, + CompleteAnimeCache: completeAnimeCache, + MetadataProvider: metadataProvider, + Logger: util.NewLogger(), + AnilistRateLimiter: anilistRateLimiter, + ScanLogger: scanLogger, + DisableAnimeCollection: tt.disableAnimeCollection, + }) + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + mc := NewMediaContainer(&MediaContainerOptions{ + AllMedia: mf.AllMedia, + ScanLogger: scanLogger, + }) + + for _, m := range mc.NormalizedMedia { + t.Log(m.GetTitleSafe()) + } + + }) + + } + +} + +func TestNewEnhancedMediaFetcher(t *testing.T) { + + anilistClient := anilist.TestGetMockAnilistClient() + logger := util.NewLogger() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + metaProvider := metadata.GetMockProvider(t) + completeAnimeCache := anilist.NewCompleteAnimeCache() + anilistRateLimiter := limiter.NewAnilistLimiter() + + dir := "E:/Anime" + + tests := []struct { + name string + paths []string + enhanced bool + }{ + { + name: "86 - Eighty Six Part 1 & 2", + paths: []string{ + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 20v2 (1080p) [30072859].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 21v2 (1080p) [4B1616A5].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 22v2 (1080p) [58BF43B4].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 23v2 (1080p) [D94B4894].mkv", + }, + enhanced: false, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + scanLogger, err := NewScanLogger("./logs") + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + // +---------------------+ + // | Local Files | + // +---------------------+ + + var lfs []*anime.LocalFile + for _, path := range tt.paths { + lf := anime.NewLocalFile(path, dir) + lfs = append(lfs, lf) + } + + // +---------------------+ + // | MediaFetcher | + // +---------------------+ + + mf, err := NewMediaFetcher(t.Context(), &MediaFetcherOptions{ + Enhanced: tt.enhanced, + Platform: anilistPlatform, + LocalFiles: lfs, + CompleteAnimeCache: completeAnimeCache, + MetadataProvider: metaProvider, + Logger: util.NewLogger(), + AnilistRateLimiter: anilistRateLimiter, + ScanLogger: scanLogger, + }) + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + mc := NewMediaContainer(&MediaContainerOptions{ + AllMedia: mf.AllMedia, + ScanLogger: scanLogger, + }) + + for _, m := range mc.NormalizedMedia { + t.Log(m.GetTitleSafe()) + } + + }) + + } + +} + +func TestFetchMediaFromLocalFiles(t *testing.T) { + + anilistClient := anilist.TestGetMockAnilistClient() + logger := util.NewLogger() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + metaProvider := metadata.GetMockProvider(t) + completeAnimeCache := anilist.NewCompleteAnimeCache() + anilistRateLimiter := limiter.NewAnilistLimiter() + + tests := []struct { + name string + paths []string + expectedMediaId []int + }{ + { + name: "86 - Eighty Six Part 1 & 2", + paths: []string{ + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 20v2 (1080p) [30072859].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 21v2 (1080p) [4B1616A5].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 22v2 (1080p) [58BF43B4].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 23v2 (1080p) [D94B4894].mkv", + }, + expectedMediaId: []int{116589, 131586}, // 86 - Eighty Six Part 1 & 2 + }, + } + + dir := "E:/Anime" + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + scanLogger, err := NewScanLogger("./logs") + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + // +---------------------+ + // | Local Files | + // +---------------------+ + + var lfs []*anime.LocalFile + for _, path := range tt.paths { + lf := anime.NewLocalFile(path, dir) + lfs = append(lfs, lf) + } + + // +--------------------------+ + // | FetchMediaFromLocalFiles | + // +--------------------------+ + + media, ok := FetchMediaFromLocalFiles( + t.Context(), + anilistPlatform, + lfs, + completeAnimeCache, + metaProvider, + anilistRateLimiter, + scanLogger, + ) + if !ok { + t.Fatal("could not fetch media from local files") + } + + ids := lo.Map(media, func(k *anilist.CompleteAnime, _ int) int { + return k.ID + }) + + // Test if all expected media IDs are present + for _, id := range tt.expectedMediaId { + assert.Contains(t, ids, id) + } + + t.Log("Media IDs:") + for _, m := range media { + t.Log(m.GetTitleSafe()) + } + + }) + } + +} diff --git a/seanime-2.9.10/internal/library/scanner/media_tree_analysis.go b/seanime-2.9.10/internal/library/scanner/media_tree_analysis.go new file mode 100644 index 0000000..df716d4 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/media_tree_analysis.go @@ -0,0 +1,226 @@ +package scanner + +import ( + "errors" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/util/limiter" + "sort" + "time" + + "github.com/samber/lo" + "github.com/sourcegraph/conc/pool" +) + +type ( + MediaTreeAnalysisOptions struct { + tree *anilist.CompleteAnimeRelationTree + metadataProvider metadata.Provider + rateLimiter *limiter.Limiter + } + + MediaTreeAnalysis struct { + branches []*MediaTreeAnalysisBranch + } + + MediaTreeAnalysisBranch struct { + media *anilist.CompleteAnime + animeMetadata *metadata.AnimeMetadata + // The second absolute episode number of the first episode + // Sometimes, the metadata provider may have a 'true' absolute episode number and a 'part' absolute episode number + // 'part' absolute episode numbers might be used for "Part 2s" of a season + minPartAbsoluteEpisodeNumber int + maxPartAbsoluteEpisodeNumber int + minAbsoluteEpisode int + maxAbsoluteEpisode int + totalEpisodeCount int + noAbsoluteEpisodesFound bool + } +) + +// NewMediaTreeAnalysis will analyze the media tree and create and store a MediaTreeAnalysisBranch for each media in the tree. +// Each MediaTreeAnalysisBranch will contain the min and max absolute episode number for the media. +// The min and max absolute episode numbers are used to get the relative episode number from an absolute episode number. +func NewMediaTreeAnalysis(opts *MediaTreeAnalysisOptions) (*MediaTreeAnalysis, error) { + + relations := make([]*anilist.CompleteAnime, 0) + opts.tree.Range(func(key int, value *anilist.CompleteAnime) bool { + relations = append(relations, value) + return true + }) + + // Get Animap data for all related media in the tree + // With each Animap media, get the min and max absolute episode number + // Create new MediaTreeAnalysisBranch for each Animap media + p := pool.NewWithResults[*MediaTreeAnalysisBranch]().WithErrors() + for _, rel := range relations { + p.Go(func() (*MediaTreeAnalysisBranch, error) { + opts.rateLimiter.Wait() + + animeMetadata, err := opts.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, rel.ID) + if err != nil { + return nil, err + } + + // Get the first episode + firstEp, ok := animeMetadata.Episodes["1"] + if !ok { + return nil, errors.New("no first episode") + } + + // discrepancy: "seasonNumber":1,"episodeNumber":12,"absoluteEpisodeNumber":13, + // this happens when the media has a separate entry but is technically the same season + // when we detect this, we should use the "episodeNumber" as the absoluteEpisodeNumber + // this is a hacky fix, but it works for the cases I've seen so far + usePartEpisodeNumber := firstEp.EpisodeNumber > 1 && firstEp.AbsoluteEpisodeNumber-firstEp.EpisodeNumber > 1 + partAbsoluteEpisodeNumber := 0 + maxPartAbsoluteEpisodeNumber := 0 + if usePartEpisodeNumber { + partAbsoluteEpisodeNumber = firstEp.EpisodeNumber + maxPartAbsoluteEpisodeNumber = partAbsoluteEpisodeNumber + animeMetadata.GetMainEpisodeCount() - 1 + } + + // If the first episode exists and has a valid absolute episode number, create a new MediaTreeAnalysisBranch + if animeMetadata.Episodes != nil { + return &MediaTreeAnalysisBranch{ + media: rel, + animeMetadata: animeMetadata, + minPartAbsoluteEpisodeNumber: partAbsoluteEpisodeNumber, + maxPartAbsoluteEpisodeNumber: maxPartAbsoluteEpisodeNumber, + minAbsoluteEpisode: firstEp.AbsoluteEpisodeNumber, + // The max absolute episode number is the first episode's absolute episode number plus the total episode count minus 1 + // We subtract 1 because the first episode's absolute episode number is already included in the total episode count + // e.g, if the first episode's absolute episode number is 13 and the total episode count is 12, the max absolute episode number is 24 + maxAbsoluteEpisode: firstEp.AbsoluteEpisodeNumber + (animeMetadata.GetMainEpisodeCount() - 1), + totalEpisodeCount: animeMetadata.GetMainEpisodeCount(), + noAbsoluteEpisodesFound: firstEp.AbsoluteEpisodeNumber == 0, + }, nil + } + + return nil, errors.New("could not analyze media tree branch") + + }) + } + branches, _ := p.Wait() + + if branches == nil || len(branches) == 0 { + return nil, errors.New("no branches found") + } + + return &MediaTreeAnalysis{branches: branches}, nil + +} + +// getRelativeEpisodeNumber uses the MediaTreeAnalysis to get the relative episode number for an absolute episode number +func (o *MediaTreeAnalysis) getRelativeEpisodeNumber(abs int) (relativeEp int, mediaId int, ok bool) { + + isPartAbsolute := false + + // Find the MediaTreeAnalysisBranch that contains the absolute episode number + branch, ok := lo.Find(o.branches, func(n *MediaTreeAnalysisBranch) bool { + // First check if the partAbsoluteEpisodeNumber is set + if n.minPartAbsoluteEpisodeNumber > 0 && n.maxPartAbsoluteEpisodeNumber > 0 { + // If it is, check if the absolute episode number given is the same as the partAbsoluteEpisodeNumber + // If it is, return true + if n.minPartAbsoluteEpisodeNumber <= abs && n.maxPartAbsoluteEpisodeNumber >= abs { + isPartAbsolute = true + return true + } + } + + // Else, check if the absolute episode number given is within the min and max absolute episode numbers of the branch + if n.minAbsoluteEpisode <= abs && n.maxAbsoluteEpisode >= abs { + return true + } + return false + }) + if !ok { + // Sort branches manually + type branchByFirstEpDate struct { + branch *MediaTreeAnalysisBranch + firstEpDate time.Time + minAbsoluteEpisode int + maxAbsoluteEpisode int + } + branches := make([]*branchByFirstEpDate, 0) + for _, b := range o.branches { + // Get the first episode date + firstEp, ok := b.animeMetadata.Episodes["1"] + if !ok { + continue + } + // parse date + t, err := time.Parse(time.DateOnly, firstEp.AirDate) + if err != nil { + continue + } + branches = append(branches, &branchByFirstEpDate{ + branch: b, + firstEpDate: t, + }) + } + + // Sort branches by first episode date + // If the first episode date is not available, the branch will be placed at the end + sort.Slice(branches, func(i, j int) bool { + return branches[i].firstEpDate.Before(branches[j].firstEpDate) + }) + + // Hydrate branches with min and max absolute episode numbers + visited := make(map[int]*branchByFirstEpDate) + for idx, b := range branches { + visited[idx] = b + if v, ok := visited[idx-1]; ok { + b.minAbsoluteEpisode = v.maxAbsoluteEpisode + 1 + b.maxAbsoluteEpisode = b.minAbsoluteEpisode + b.branch.totalEpisodeCount - 1 + continue + } + b.minAbsoluteEpisode = 1 + b.maxAbsoluteEpisode = b.minAbsoluteEpisode + b.branch.totalEpisodeCount - 1 + } + + for _, b := range branches { + if b.minAbsoluteEpisode <= abs && b.maxAbsoluteEpisode >= abs { + b.branch.minAbsoluteEpisode = b.minAbsoluteEpisode + b.branch.maxAbsoluteEpisode = b.maxAbsoluteEpisode + branch = b.branch + relativeEp = abs - (branch.minAbsoluteEpisode - 1) + mediaId = branch.media.ID + ok = true + return + } + } + + return 0, 0, false + } + + if isPartAbsolute { + // Let's say the media has 12 episodes and the file is "episode 13" + // If the [partAbsoluteEpisodeNumber] is 13, then the [relativeEp] will be 1, we can safely ignore the [absoluteEpisodeNumber] + // e.g. 13 - (13-1) = 1 + relativeEp = abs - (branch.minPartAbsoluteEpisodeNumber - 1) + } else { + // Let's say the media has 12 episodes and the file is "episode 38" + // The [minAbsoluteEpisode] will be 38 and the [relativeEp] will be 1 + // e.g. 38 - (38-1) = 1 + relativeEp = abs - (branch.minAbsoluteEpisode - 1) + } + + mediaId = branch.media.ID + + return +} + +func (o *MediaTreeAnalysis) printBranches() (str string) { + str = "[" + for _, branch := range o.branches { + str += fmt.Sprintf("media: '%s', minAbsoluteEpisode: %d, maxAbsoluteEpisode: %d, totalEpisodeCount: %d; ", branch.media.GetTitleSafe(), branch.minAbsoluteEpisode, branch.maxAbsoluteEpisode, branch.totalEpisodeCount) + } + if len(o.branches) > 0 { + str = str[:len(str)-2] + } + str += "]" + return str + +} diff --git a/seanime-2.9.10/internal/library/scanner/media_tree_analysis_test.go b/seanime-2.9.10/internal/library/scanner/media_tree_analysis_test.go new file mode 100644 index 0000000..f95db5a --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/media_tree_analysis_test.go @@ -0,0 +1,170 @@ +package scanner + +import ( + "context" + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/test_utils" + "seanime/internal/util/limiter" + "testing" + "time" +) + +func TestMediaTreeAnalysis(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + anilistClient := anilist.TestGetMockAnilistClient() + + anilistRateLimiter := limiter.NewAnilistLimiter() + tree := anilist.NewCompleteAnimeRelationTree() + + metadataProvider := metadata.GetMockProvider(t) + + tests := []struct { + name string + mediaId int + absoluteEpisodeNumber int + expectedRelativeEpisodeNumber int + }{ + { + name: "Media Tree Analysis for 86 - Eighty Six Part 2", + mediaId: 131586, // 86 - Eighty Six Part 2 + absoluteEpisodeNumber: 23, + expectedRelativeEpisodeNumber: 12, + }, + { + name: "Oshi no Ko Season 2", + mediaId: 150672, // 86 - Eighty Six Part 2 + absoluteEpisodeNumber: 12, + expectedRelativeEpisodeNumber: 1, + }, + { + name: "Re:zero", + mediaId: 21355, // Re:Zero kara Hajimeru Isekai Seikatsu + absoluteEpisodeNumber: 51, + expectedRelativeEpisodeNumber: 1, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + mediaF, err := anilistClient.BaseAnimeByID(context.Background(), &tt.mediaId) + if err != nil { + t.Fatal("expected media, got not found") + } + media := mediaF.GetMedia() + + // +---------------------+ + // | MediaTree | + // +---------------------+ + + err = media.FetchMediaTree( + anilist.FetchMediaTreeAll, + anilistClient, + anilistRateLimiter, + tree, + anilist.NewCompleteAnimeCache(), + ) + + if err != nil { + t.Fatal("expected media tree, got error:", err.Error()) + } + + // +---------------------+ + // | MediaTreeAnalysis | + // +---------------------+ + + mta, err := NewMediaTreeAnalysis(&MediaTreeAnalysisOptions{ + tree: tree, + metadataProvider: metadataProvider, + rateLimiter: limiter.NewLimiter(time.Minute, 25), + }) + if err != nil { + t.Fatal("expected media tree analysis, got error:", err.Error()) + } + + // +---------------------+ + // | Relative Episode | + // +---------------------+ + + relEp, _, ok := mta.getRelativeEpisodeNumber(tt.absoluteEpisodeNumber) + + if assert.Truef(t, ok, "expected relative episode number %v for absolute episode number %v, nothing found", tt.expectedRelativeEpisodeNumber, tt.absoluteEpisodeNumber) { + + assert.Equal(t, tt.expectedRelativeEpisodeNumber, relEp) + + } + + }) + + } + +} + +func TestMediaTreeAnalysis2(t *testing.T) { + + anilistClient := anilist.TestGetMockAnilistClient() + anilistRateLimiter := limiter.NewAnilistLimiter() + tree := anilist.NewCompleteAnimeRelationTree() + + metadataProvider := metadata.GetMockProvider(t) + + tests := []struct { + name string + mediaId int + }{ + { + name: "Media Tree Analysis", + mediaId: 375, // Soreyuke! Uchuu Senkan Yamamoto Yohko + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + media, err := anilistClient.BaseAnimeByID(context.Background(), &tt.mediaId) + if err != nil { + t.Fatal("expected media, got error:", err.Error()) + } + + // +---------------------+ + // | MediaTree | + // +---------------------+ + + err = media.GetMedia().FetchMediaTree( + anilist.FetchMediaTreeAll, + anilistClient, + anilistRateLimiter, + tree, + anilist.NewCompleteAnimeCache(), + ) + + if err != nil { + t.Fatal("expected media tree, got error:", err.Error()) + } + + // +---------------------+ + // | MediaTreeAnalysis | + // +---------------------+ + + mta, err := NewMediaTreeAnalysis(&MediaTreeAnalysisOptions{ + tree: tree, + metadataProvider: metadataProvider, + rateLimiter: limiter.NewLimiter(time.Minute, 25), + }) + if err != nil { + t.Fatal("expected media tree analysis, got error:", err.Error()) + } + + t.Log(spew.Sdump(mta)) + + }) + + } + +} diff --git a/seanime-2.9.10/internal/library/scanner/scan.go b/seanime-2.9.10/internal/library/scanner/scan.go new file mode 100644 index 0000000..1381d62 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/scan.go @@ -0,0 +1,421 @@ +package scanner + +import ( + "context" + "errors" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/events" + "seanime/internal/hook" + "seanime/internal/library/anime" + "seanime/internal/library/filesystem" + "seanime/internal/library/summary" + "seanime/internal/platforms/platform" + "seanime/internal/util" + "seanime/internal/util/limiter" + "sync" + "time" + + "github.com/rs/zerolog" + "github.com/samber/lo" + lop "github.com/samber/lo/parallel" +) + +type Scanner struct { + DirPath string + OtherDirPaths []string + Enhanced bool + Platform platform.Platform + Logger *zerolog.Logger + WSEventManager events.WSEventManagerInterface + ExistingLocalFiles []*anime.LocalFile + SkipLockedFiles bool + SkipIgnoredFiles bool + ScanSummaryLogger *summary.ScanSummaryLogger + ScanLogger *ScanLogger + MetadataProvider metadata.Provider + MatchingThreshold float64 + MatchingAlgorithm string +} + +// Scan will scan the directory and return a list of anime.LocalFile. +func (scn *Scanner) Scan(ctx context.Context) (lfs []*anime.LocalFile, err error) { + defer util.HandlePanicWithError(&err) + + go func() { + anime.EpisodeCollectionFromLocalFilesCache.Clear() + }() + + scn.WSEventManager.SendEvent(events.EventScanProgress, 0) + scn.WSEventManager.SendEvent(events.EventScanStatus, "Retrieving local files...") + + completeAnimeCache := anilist.NewCompleteAnimeCache() + + // Create a new Anilist rate limiter + anilistRateLimiter := limiter.NewAnilistLimiter() + + if scn.ScanSummaryLogger == nil { + scn.ScanSummaryLogger = summary.NewScanSummaryLogger() + } + + scn.Logger.Debug().Msg("scanner: Starting scan") + scn.WSEventManager.SendEvent(events.EventScanProgress, 10) + scn.WSEventManager.SendEvent(events.EventScanStatus, "Retrieving local files...") + + startTime := time.Now() + + // Invoke ScanStarted hook + event := &ScanStartedEvent{ + LibraryPath: scn.DirPath, + OtherLibraryPaths: scn.OtherDirPaths, + Enhanced: scn.Enhanced, + SkipLocked: scn.SkipLockedFiles, + SkipIgnored: scn.SkipIgnoredFiles, + LocalFiles: scn.ExistingLocalFiles, + } + _ = hook.GlobalHookManager.OnScanStarted().Trigger(event) + scn.DirPath = event.LibraryPath + scn.OtherDirPaths = event.OtherLibraryPaths + scn.Enhanced = event.Enhanced + scn.SkipLockedFiles = event.SkipLocked + scn.SkipIgnoredFiles = event.SkipIgnored + + // Default prevented, return the local files + if event.DefaultPrevented { + // Invoke ScanCompleted hook + completedEvent := &ScanCompletedEvent{ + LocalFiles: event.LocalFiles, + Duration: int(time.Since(startTime).Milliseconds()), + } + hook.GlobalHookManager.OnScanCompleted().Trigger(completedEvent) + + return completedEvent.LocalFiles, nil + } + + // +---------------------+ + // | File paths | + // +---------------------+ + + libraryPaths := append([]string{scn.DirPath}, scn.OtherDirPaths...) + + // Create a map of local file paths used to avoid duplicates + retrievedPathMap := make(map[string]struct{}) + + paths := make([]string, 0) + mu := sync.Mutex{} + logMu := sync.Mutex{} + wg := sync.WaitGroup{} + + wg.Add(len(libraryPaths)) + + // Get local files from all directories + for i, dirPath := range libraryPaths { + go func(dirPath string, i int) { + defer wg.Done() + retrievedPaths, err := filesystem.GetMediaFilePathsFromDirS(dirPath) + if err != nil { + scn.Logger.Error().Msgf("scanner: An error occurred while retrieving local files from directory: %s", err) + return + } + + if scn.ScanLogger != nil { + logMu.Lock() + if i == 0 { + scn.ScanLogger.logger.Info(). + Any("count", len(paths)). + Msgf("Retrieved file paths from main directory: %s", dirPath) + } else { + scn.ScanLogger.logger.Info(). + Any("count", len(retrievedPaths)). + Msgf("Retrieved file paths from other directory: %s", dirPath) + } + logMu.Unlock() + } + + for _, path := range retrievedPaths { + if _, ok := retrievedPathMap[util.NormalizePath(path)]; !ok { + mu.Lock() + paths = append(paths, path) + mu.Unlock() + } + } + }(dirPath, i) + } + + wg.Wait() + + if scn.ScanLogger != nil { + scn.ScanLogger.logger.Info(). + Any("count", len(paths)). + Msg("Retrieved file paths from all directories") + } + + // Invoke ScanFilePathsRetrieved hook + fpEvent := &ScanFilePathsRetrievedEvent{ + FilePaths: paths, + } + _ = hook.GlobalHookManager.OnScanFilePathsRetrieved().Trigger(fpEvent) + paths = fpEvent.FilePaths + + // +---------------------+ + // | Local files | + // +---------------------+ + + localFiles := make([]*anime.LocalFile, 0) + + // Get skipped files depending on options + skippedLfs := make(map[string]*anime.LocalFile) + if (scn.SkipLockedFiles || scn.SkipIgnoredFiles) && scn.ExistingLocalFiles != nil { + // Retrieve skipped files from existing local files + for _, lf := range scn.ExistingLocalFiles { + if scn.SkipLockedFiles && lf.IsLocked() { + skippedLfs[lf.GetNormalizedPath()] = lf + } else if scn.SkipIgnoredFiles && lf.IsIgnored() { + skippedLfs[lf.GetNormalizedPath()] = lf + } + } + } + + // Create local files from paths (skipping skipped files) + localFiles = lop.Map(paths, func(path string, _ int) *anime.LocalFile { + if _, ok := skippedLfs[util.NormalizePath(path)]; !ok { + // Create a new local file + return anime.NewLocalFileS(path, libraryPaths) + } else { + return nil + } + }) + + // Remove nil values + localFiles = lo.Filter(localFiles, func(lf *anime.LocalFile, _ int) bool { + return lf != nil + }) + + // Invoke ScanLocalFilesParsed hook + parsedEvent := &ScanLocalFilesParsedEvent{ + LocalFiles: localFiles, + } + _ = hook.GlobalHookManager.OnScanLocalFilesParsed().Trigger(parsedEvent) + localFiles = parsedEvent.LocalFiles + + if scn.ScanLogger != nil { + scn.ScanLogger.logger.Debug(). + Any("count", len(localFiles)). + Msg("Local files to be scanned") + scn.ScanLogger.logger.Debug(). + Any("count", len(skippedLfs)). + Msg("Skipped files") + + scn.ScanLogger.logger.Debug(). + Msg("===========================================================================================================") + } + + for _, lf := range localFiles { + if scn.ScanLogger != nil { + scn.ScanLogger.logger.Trace(). + Str("path", lf.Path). + Str("filename", lf.Name). + Interface("parsedData", lf.ParsedData). + Interface("parsedFolderData", lf.ParsedFolderData). + Msg("Parsed local file") + } + } + + if scn.ScanLogger != nil { + scn.ScanLogger.logger.Debug(). + Msg("===========================================================================================================") + } + + // DEVNOTE: Removed library path checking because it causes some issues with symlinks + + // +---------------------+ + // | No files to scan | + // +---------------------+ + + // If there are no local files to scan (all files are skipped, or a file was deleted) + if len(localFiles) == 0 { + scn.WSEventManager.SendEvent(events.EventScanProgress, 90) + scn.WSEventManager.SendEvent(events.EventScanStatus, "Verifying file integrity...") + // Add skipped files + if len(skippedLfs) > 0 { + for _, sf := range skippedLfs { + if filesystem.FileExists(sf.Path) { // Verify that the file still exists + localFiles = append(localFiles, sf) + } + } + } + scn.Logger.Debug().Msg("scanner: Scan completed") + scn.WSEventManager.SendEvent(events.EventScanProgress, 100) + scn.WSEventManager.SendEvent(events.EventScanStatus, "Scan completed") + + // Invoke ScanCompleted hook + completedEvent := &ScanCompletedEvent{ + LocalFiles: localFiles, + Duration: int(time.Since(startTime).Milliseconds()), + } + hook.GlobalHookManager.OnScanCompleted().Trigger(completedEvent) + localFiles = completedEvent.LocalFiles + + return localFiles, nil + } + + scn.WSEventManager.SendEvent(events.EventScanProgress, 20) + if scn.Enhanced { + scn.WSEventManager.SendEvent(events.EventScanStatus, "Fetching media detected from file titles...") + } else { + scn.WSEventManager.SendEvent(events.EventScanStatus, "Fetching media...") + } + + // +---------------------+ + // | MediaFetcher | + // +---------------------+ + + // Fetch media needed for matching + mf, err := NewMediaFetcher(ctx, &MediaFetcherOptions{ + Enhanced: scn.Enhanced, + Platform: scn.Platform, + MetadataProvider: scn.MetadataProvider, + LocalFiles: localFiles, + CompleteAnimeCache: completeAnimeCache, + Logger: scn.Logger, + AnilistRateLimiter: anilistRateLimiter, + DisableAnimeCollection: false, + ScanLogger: scn.ScanLogger, + }) + if err != nil { + return nil, err + } + + scn.WSEventManager.SendEvent(events.EventScanProgress, 40) + scn.WSEventManager.SendEvent(events.EventScanStatus, "Matching local files...") + + // +---------------------+ + // | MediaContainer | + // +---------------------+ + + // Create a new container for media + mc := NewMediaContainer(&MediaContainerOptions{ + AllMedia: mf.AllMedia, + ScanLogger: scn.ScanLogger, + }) + + scn.Logger.Debug(). + Any("count", len(mc.NormalizedMedia)). + Msg("media container: Media container created") + + // +---------------------+ + // | Matcher | + // +---------------------+ + + // Create a new matcher + matcher := &Matcher{ + LocalFiles: localFiles, + MediaContainer: mc, + CompleteAnimeCache: completeAnimeCache, + Logger: scn.Logger, + ScanLogger: scn.ScanLogger, + ScanSummaryLogger: scn.ScanSummaryLogger, + Algorithm: scn.MatchingAlgorithm, + Threshold: scn.MatchingThreshold, + } + + scn.WSEventManager.SendEvent(events.EventScanProgress, 60) + + err = matcher.MatchLocalFilesWithMedia() + if err != nil { + // If the matcher received no local files, return an error + if errors.Is(err, ErrNoLocalFiles) { + scn.Logger.Debug().Msg("scanner: Scan completed") + scn.WSEventManager.SendEvent(events.EventScanProgress, 100) + scn.WSEventManager.SendEvent(events.EventScanStatus, "Scan completed") + } + return nil, err + } + + scn.WSEventManager.SendEvent(events.EventScanProgress, 70) + scn.WSEventManager.SendEvent(events.EventScanStatus, "Hydrating metadata...") + + // +---------------------+ + // | FileHydrator | + // +---------------------+ + + // Create a new hydrator + hydrator := &FileHydrator{ + AllMedia: mc.NormalizedMedia, + LocalFiles: localFiles, + MetadataProvider: scn.MetadataProvider, + Platform: scn.Platform, + CompleteAnimeCache: completeAnimeCache, + AnilistRateLimiter: anilistRateLimiter, + Logger: scn.Logger, + ScanLogger: scn.ScanLogger, + ScanSummaryLogger: scn.ScanSummaryLogger, + } + hydrator.HydrateMetadata() + + scn.WSEventManager.SendEvent(events.EventScanProgress, 80) + + // +---------------------+ + // | Add missing media | + // +---------------------+ + + // Add non-added media entries to AniList collection + // Max of 4 to avoid rate limit issues + if len(mf.UnknownMediaIds) < 5 { + scn.WSEventManager.SendEvent(events.EventScanStatus, "Adding missing media to AniList...") + + if err = scn.Platform.AddMediaToCollection(ctx, mf.UnknownMediaIds); err != nil { + scn.Logger.Warn().Msg("scanner: An error occurred while adding media to planning list: " + err.Error()) + } + } + + scn.WSEventManager.SendEvent(events.EventScanProgress, 90) + scn.WSEventManager.SendEvent(events.EventScanStatus, "Verifying file integrity...") + + // Hydrate the summary logger before merging files + scn.ScanSummaryLogger.HydrateData(localFiles, mc.NormalizedMedia, mf.AnimeCollectionWithRelations) + + // +---------------------+ + // | Merge files | + // +---------------------+ + + // Merge skipped files with scanned files + // Only files that exist (this removes deleted/moved files) + if len(skippedLfs) > 0 { + wg := sync.WaitGroup{} + mu := sync.Mutex{} + wg.Add(len(skippedLfs)) + for _, skippedLf := range skippedLfs { + go func(skippedLf *anime.LocalFile) { + defer wg.Done() + if filesystem.FileExists(skippedLf.Path) { + mu.Lock() + localFiles = append(localFiles, skippedLf) + mu.Unlock() + } + }(skippedLf) + } + wg.Wait() + } + + scn.Logger.Info().Msg("scanner: Scan completed") + scn.WSEventManager.SendEvent(events.EventScanProgress, 100) + scn.WSEventManager.SendEvent(events.EventScanStatus, "Scan completed") + + if scn.ScanLogger != nil { + scn.ScanLogger.logger.Info(). + Int("count", len(localFiles)). + Int("unknownMediaCount", len(mf.UnknownMediaIds)). + Msg("Scan completed") + } + + // Invoke ScanCompleted hook + completedEvent := &ScanCompletedEvent{ + LocalFiles: localFiles, + Duration: int(time.Since(startTime).Milliseconds()), + } + hook.GlobalHookManager.OnScanCompleted().Trigger(completedEvent) + localFiles = completedEvent.LocalFiles + + return localFiles, nil +} diff --git a/seanime-2.9.10/internal/library/scanner/scan_logger.go b/seanime-2.9.10/internal/library/scanner/scan_logger.go new file mode 100644 index 0000000..0b30bd3 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/scan_logger.go @@ -0,0 +1,107 @@ +package scanner + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/rs/zerolog" +) + +// ScanLogger is a custom logger struct for scanning operations. +type ScanLogger struct { + logger *zerolog.Logger + logFile *os.File + buffer *bytes.Buffer +} + +// NewScanLogger creates a new ScanLogger with a log file named based on the current datetime. +// - dir: The directory to save the log file in. This should come from the config. +func NewScanLogger(outputDir string) (*ScanLogger, error) { + // Generate a log file name with the current datetime + logFileName := fmt.Sprintf("%s-scan.log", time.Now().Format("2006-01-02_15-04-05")) + + // Create the logs directory if it doesn't exist + if _, err := os.Stat(outputDir); os.IsNotExist(err) { + err := os.Mkdir(outputDir, 0755) + if err != nil { + return nil, err + } + } + + // Open the log file for writing + logFile, err := os.OpenFile(filepath.Join(outputDir, logFileName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + + // Create a buffer for storing log entries + buffer := new(bytes.Buffer) + + // Create an array writer to wrap the JSON encoder + logger := zerolog.New(buffer).With().Logger() + + return &ScanLogger{&logger, logFile, buffer}, nil +} + +// NewConsoleScanLogger creates a new mock ScanLogger +func NewConsoleScanLogger() (*ScanLogger, error) { + output := zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.DateTime, + } + + // Create an array writer to wrap the JSON encoder + logger := zerolog.New(output).With().Logger() + + return &ScanLogger{logger: &logger, logFile: nil, buffer: nil}, nil +} + +func (sl *ScanLogger) LogMediaContainer(level zerolog.Level) *zerolog.Event { + return sl.logger.WithLevel(level).Str("context", "MediaContainer") +} + +func (sl *ScanLogger) LogMatcher(level zerolog.Level) *zerolog.Event { + return sl.logger.WithLevel(level).Str("context", "Matcher") +} + +func (sl *ScanLogger) LogFileHydrator(level zerolog.Level) *zerolog.Event { + return sl.logger.WithLevel(level).Str("context", "FileHydrator") +} + +func (sl *ScanLogger) LogMediaFetcher(level zerolog.Level) *zerolog.Event { + return sl.logger.WithLevel(level).Str("context", "MediaFetcher") +} + +// Done flushes the buffer to the log file and closes the file. +func (sl *ScanLogger) Done() error { + if sl.logFile == nil { + return nil + } + + // Write buffer contents to the log file + _, err := sl.logFile.Write(sl.buffer.Bytes()) + if err != nil { + return err + } + + // Sync and close the log file + err = sl.logFile.Sync() + if err != nil { + return err + } + + return sl.logFile.Close() +} + +func (sl *ScanLogger) Close() { + if sl.logFile == nil { + return + } + err := sl.logFile.Sync() + if err != nil { + return + } +} diff --git a/seanime-2.9.10/internal/library/scanner/scan_logger_test.go b/seanime-2.9.10/internal/library/scanner/scan_logger_test.go new file mode 100644 index 0000000..a4c6684 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/scan_logger_test.go @@ -0,0 +1,124 @@ +package scanner + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/library/anime" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/util" + "seanime/internal/util/limiter" + "testing" +) + +func TestScanLogger(t *testing.T) { + + anilistClient := anilist.TestGetMockAnilistClient() + logger := util.NewLogger() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + animeCollection, err := anilistPlatform.GetAnimeCollectionWithRelations(t.Context()) + if err != nil { + t.Fatal(err.Error()) + } + allMedia := animeCollection.GetAllAnime() + metadataProvider := metadata.GetMockProvider(t) + completeAnimeCache := anilist.NewCompleteAnimeCache() + anilistRateLimiter := limiter.NewAnilistLimiter() + + tests := []struct { + name string + paths []string + expectedMediaId int + }{ + { + name: "should be hydrated with id 131586", + paths: []string{ + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 20v2 (1080p) [30072859].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 21v2 (1080p) [4B1616A5].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 22v2 (1080p) [58BF43B4].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 23v2 (1080p) [D94B4894].mkv", + }, + expectedMediaId: 131586, // 86 - Eighty Six Part 2 + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + scanLogger, err := NewScanLogger("./logs") + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + // +---------------------+ + // | Local Files | + // +---------------------+ + + var lfs []*anime.LocalFile + for _, path := range tt.paths { + lf := anime.NewLocalFile(path, "E:/Anime") + lfs = append(lfs, lf) + } + + // +---------------------+ + // | MediaContainer | + // +---------------------+ + + mc := NewMediaContainer(&MediaContainerOptions{ + AllMedia: allMedia, + ScanLogger: scanLogger, + }) + + for _, nm := range mc.NormalizedMedia { + t.Logf("media id: %d, title: %s", nm.ID, nm.GetTitleSafe()) + } + + // +---------------------+ + // | Matcher | + // +---------------------+ + + matcher := &Matcher{ + LocalFiles: lfs, + MediaContainer: mc, + CompleteAnimeCache: completeAnimeCache, + Logger: util.NewLogger(), + ScanLogger: scanLogger, + ScanSummaryLogger: nil, + } + + err = matcher.MatchLocalFilesWithMedia() + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + // +---------------------+ + // | FileHydrator | + // +---------------------+ + + fh := FileHydrator{ + LocalFiles: lfs, + AllMedia: mc.NormalizedMedia, + CompleteAnimeCache: completeAnimeCache, + Platform: anilistPlatform, + MetadataProvider: metadataProvider, + AnilistRateLimiter: anilistRateLimiter, + Logger: logger, + ScanLogger: scanLogger, + ScanSummaryLogger: nil, + ForceMediaId: 0, + } + + fh.HydrateMetadata() + + for _, lf := range fh.LocalFiles { + if lf.MediaId != tt.expectedMediaId { + t.Fatalf("expected media id %d, got %d", tt.expectedMediaId, lf.MediaId) + } + + t.Logf("local file: %s,\nmedia id: %d\n", lf.Name, lf.MediaId) + } + + }) + } + +} diff --git a/seanime-2.9.10/internal/library/scanner/scan_test.go b/seanime-2.9.10/internal/library/scanner/scan_test.go new file mode 100644 index 0000000..5f584ee --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/scan_test.go @@ -0,0 +1,79 @@ +package scanner + +import ( + "seanime/internal/api/anilist" + "seanime/internal/events" + "seanime/internal/library/anime" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" +) + +//---------------------------------------------------------------------------------------------------------------------- + +func TestScanner_Scan(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + anilistClient := anilist.TestGetMockAnilistClient() + logger := util.NewLogger() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + wsEventManager := events.NewMockWSEventManager(util.NewLogger()) + dir := "E:/Anime" + + tests := []struct { + name string + paths []string + }{ + { + name: "Scan", + paths: []string{ + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 20v2 (1080p) [30072859].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 21v2 (1080p) [4B1616A5].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 22v2 (1080p) [58BF43B4].mkv", + "E:/Anime/[SubsPlease] 86 - Eighty Six (01-23) (1080p) [Batch]/[SubsPlease] 86 - Eighty Six - 23v2 (1080p) [D94B4894].mkv", + }, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + existingLfs := make([]*anime.LocalFile, 0) + for _, path := range tt.paths { + lf := anime.NewLocalFile(path, dir) + existingLfs = append(existingLfs, lf) + } + + // +---------------------+ + // | Scan | + // +---------------------+ + + scanner := &Scanner{ + DirPath: dir, + Enhanced: false, + Platform: anilistPlatform, + Logger: util.NewLogger(), + WSEventManager: wsEventManager, + ExistingLocalFiles: existingLfs, + SkipLockedFiles: false, + SkipIgnoredFiles: false, + ScanLogger: nil, + ScanSummaryLogger: nil, + } + + lfs, err := scanner.Scan(t.Context()) + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + for _, lf := range lfs { + t.Log(lf.Name) + } + + }) + + } + +} diff --git a/seanime-2.9.10/internal/library/scanner/watcher.go b/seanime-2.9.10/internal/library/scanner/watcher.go new file mode 100644 index 0000000..29fbfb0 --- /dev/null +++ b/seanime-2.9.10/internal/library/scanner/watcher.go @@ -0,0 +1,116 @@ +package scanner + +import ( + "os" + "path/filepath" + "seanime/internal/events" + "strings" + + "github.com/fsnotify/fsnotify" + "github.com/rs/zerolog" +) + +// Watcher is a custom file system event watcher +type Watcher struct { + Watcher *fsnotify.Watcher + Logger *zerolog.Logger + WSEventManager events.WSEventManagerInterface + TotalSize string +} + +type NewWatcherOptions struct { + Logger *zerolog.Logger + WSEventManager events.WSEventManagerInterface +} + +// NewWatcher creates a new Watcher instance for monitoring a directory and its subdirectories +func NewWatcher(opts *NewWatcherOptions) (*Watcher, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + + return &Watcher{ + Watcher: watcher, + Logger: opts.Logger, + WSEventManager: opts.WSEventManager, + }, nil +} + +//---------------------------------------------------------------------------------------------------------------------- + +type WatchLibraryFilesOptions struct { + LibraryPaths []string +} + +// InitLibraryFileWatcher starts watching the specified directory and its subdirectories for file system events +func (w *Watcher) InitLibraryFileWatcher(opts *WatchLibraryFilesOptions) error { + // Define a function to add directories and their subdirectories to the watcher + watchDir := func(dir string) error { + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return w.Watcher.Add(path) + } + return nil + }) + return err + } + + // Add the initial directory and its subdirectories to the watcher + for _, path := range opts.LibraryPaths { + if err := watchDir(path); err != nil { + return err + } + } + + w.Logger.Info().Msgf("watcher: Watching directories: %+v", opts.LibraryPaths) + + return nil +} + +func (w *Watcher) StartWatching( + onFileAction func(), +) { + // Start a goroutine to handle file system events + go func() { + for { + select { + case event, ok := <-w.Watcher.Events: + if !ok { + return + } + //if event.Op&fsnotify.Write == fsnotify.Write { + //} + if strings.Contains(event.Name, ".part") || strings.Contains(event.Name, ".tmp") { + continue + } + if event.Op&fsnotify.Create == fsnotify.Create { + w.Logger.Debug().Msgf("watcher: File created: %s", event.Name) + w.WSEventManager.SendEvent(events.LibraryWatcherFileAdded, event.Name) + onFileAction() + } + if event.Op&fsnotify.Remove == fsnotify.Remove { + w.Logger.Debug().Msgf("watcher: File removed: %s", event.Name) + w.WSEventManager.SendEvent(events.LibraryWatcherFileRemoved, event.Name) + onFileAction() + } + + case err, ok := <-w.Watcher.Errors: + if !ok { + return + } + w.Logger.Warn().Err(err).Msgf("watcher: Error while watching directory") + } + } + }() +} + +func (w *Watcher) StopWatching() { + err := w.Watcher.Close() + if err == nil { + w.Logger.Trace().Err(err).Msgf("watcher: Watcher stopped") + } +} diff --git a/seanime-2.9.10/internal/library/summary/scan_summary.go b/seanime-2.9.10/internal/library/summary/scan_summary.go new file mode 100644 index 0000000..62750ae --- /dev/null +++ b/seanime-2.9.10/internal/library/summary/scan_summary.go @@ -0,0 +1,363 @@ +package summary + +import ( + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/library/anime" + "time" + + "github.com/google/uuid" +) + +const ( + LogFileNotMatched LogType = iota + LogComparison + LogSuccessfullyMatched + LogFailedMatch + LogMatchValidated + LogUnmatched + LogMetadataMediaTreeFetched + LogMetadataMediaTreeFetchFailed + LogMetadataEpisodeNormalized + LogMetadataEpisodeNormalizationFailed + LogMetadataEpisodeZero + LogMetadataNC + LogMetadataSpecial + LogMetadataMain + LogMetadataHydrated + LogPanic + LogDebug +) + +type ( + LogType int + + ScanSummaryLogger struct { + Logs []*ScanSummaryLog + LocalFiles []*anime.LocalFile + AllMedia []*anime.NormalizedMedia + AnimeCollection *anilist.AnimeCollectionWithRelations + } + + ScanSummaryLog struct { // Holds a log entry. The log entry will then be used to generate a ScanSummary. + ID string `json:"id"` + FilePath string `json:"filePath"` + Level string `json:"level"` + Message string `json:"message"` + } + + ScanSummary struct { + ID string `json:"id"` + Groups []*ScanSummaryGroup `json:"groups"` + UnmatchedFiles []*ScanSummaryFile `json:"unmatchedFiles"` + } + + ScanSummaryFile struct { + ID string `json:"id"` + LocalFile *anime.LocalFile `json:"localFile"` + Logs []*ScanSummaryLog `json:"logs"` + } + + ScanSummaryGroup struct { + ID string `json:"id"` + Files []*ScanSummaryFile `json:"files"` + MediaId int `json:"mediaId"` + MediaTitle string `json:"mediaTitle"` + MediaImage string `json:"mediaImage"` + MediaIsInCollection bool `json:"mediaIsInCollection"` // Whether the media is in the user's AniList collection + } + + ScanSummaryItem struct { // Database item + CreatedAt time.Time `json:"createdAt"` + ScanSummary *ScanSummary `json:"scanSummary"` + } +) + +func NewScanSummaryLogger() *ScanSummaryLogger { + return &ScanSummaryLogger{ + Logs: make([]*ScanSummaryLog, 0), + } +} + +// HydrateData will hydrate the data needed to generate the summary. +func (l *ScanSummaryLogger) HydrateData(lfs []*anime.LocalFile, media []*anime.NormalizedMedia, animeCollection *anilist.AnimeCollectionWithRelations) { + l.LocalFiles = lfs + l.AllMedia = media + l.AnimeCollection = animeCollection +} + +func (l *ScanSummaryLogger) GenerateSummary() *ScanSummary { + if l == nil || l.LocalFiles == nil || l.AllMedia == nil || l.AnimeCollection == nil { + return nil + } + summary := &ScanSummary{ + ID: uuid.NewString(), + Groups: make([]*ScanSummaryGroup, 0), + UnmatchedFiles: make([]*ScanSummaryFile, 0), + } + + groupsMap := make(map[int][]*ScanSummaryFile) + + // Generate summary files + for _, lf := range l.LocalFiles { + + if lf.MediaId == 0 { + summary.UnmatchedFiles = append(summary.UnmatchedFiles, &ScanSummaryFile{ + ID: uuid.NewString(), + LocalFile: lf, + Logs: l.getFileLogs(lf), + }) + continue + } + + summaryFile := &ScanSummaryFile{ + ID: uuid.NewString(), + LocalFile: lf, + Logs: l.getFileLogs(lf), + } + + //summary.Files = append(summary.Files, summaryFile) + + // Add to group + if _, ok := groupsMap[lf.MediaId]; !ok { + groupsMap[lf.MediaId] = make([]*ScanSummaryFile, 0) + groupsMap[lf.MediaId] = append(groupsMap[lf.MediaId], summaryFile) + } else { + groupsMap[lf.MediaId] = append(groupsMap[lf.MediaId], summaryFile) + } + } + + // Generate summary groups + for mediaId, files := range groupsMap { + mediaTitle := "" + mediaImage := "" + mediaIsInCollection := false + for _, m := range l.AllMedia { + if m.ID == mediaId { + mediaTitle = m.GetPreferredTitle() + mediaImage = "" + if m.GetCoverImage() != nil && m.GetCoverImage().GetLarge() != nil { + mediaImage = *m.GetCoverImage().GetLarge() + } + break + } + } + if _, found := l.AnimeCollection.GetListEntryFromMediaId(mediaId); found { + mediaIsInCollection = true + } + + summary.Groups = append(summary.Groups, &ScanSummaryGroup{ + ID: uuid.NewString(), + Files: files, + MediaId: mediaId, + MediaTitle: mediaTitle, + MediaImage: mediaImage, + MediaIsInCollection: mediaIsInCollection, + }) + } + + return summary +} + +func (l *ScanSummaryLogger) LogComparison(lf *anime.LocalFile, algo string, bestTitle string, ratingType string, rating string) { + if l == nil { + return + } + + msg := fmt.Sprintf("Comparison using %s. Best title: \"%s\". %s: %s", algo, bestTitle, ratingType, rating) + l.logType(LogComparison, lf, msg) +} + +func (l *ScanSummaryLogger) LogSuccessfullyMatched(lf *anime.LocalFile, mediaId int) { + if l == nil { + return + } + msg := fmt.Sprintf("Successfully matched to media %d", mediaId) + l.logType(LogSuccessfullyMatched, lf, msg) +} + +func (l *ScanSummaryLogger) LogPanic(lf *anime.LocalFile, stackTrace string) { + if l == nil { + return + } + //msg := fmt.Sprintf("Panic occurred, please report this issue on the GitHub repository with the stack trace printed in the terminal") + l.logType(LogPanic, lf, "PANIC! "+stackTrace) +} + +func (l *ScanSummaryLogger) LogFailedMatch(lf *anime.LocalFile, reason string) { + if l == nil { + return + } + msg := fmt.Sprintf("Failed to match: %s", reason) + l.logType(LogFailedMatch, lf, msg) +} + +func (l *ScanSummaryLogger) LogMatchValidated(lf *anime.LocalFile, mediaId int) { + if l == nil { + return + } + msg := fmt.Sprintf("Match validated for media %d", mediaId) + l.logType(LogMatchValidated, lf, msg) +} + +func (l *ScanSummaryLogger) LogUnmatched(lf *anime.LocalFile, reason string) { + if l == nil { + return + } + msg := fmt.Sprintf("Unmatched: %s", reason) + l.logType(LogUnmatched, lf, msg) +} + +func (l *ScanSummaryLogger) LogFileNotMatched(lf *anime.LocalFile, reason string) { + if l == nil { + return + } + msg := fmt.Sprintf("Not matched: %s", reason) + l.logType(LogFileNotMatched, lf, msg) +} + +func (l *ScanSummaryLogger) LogMetadataMediaTreeFetched(lf *anime.LocalFile, ms int64, branches int) { + if l == nil { + return + } + msg := fmt.Sprintf("Media tree fetched in %dms. Branches: %d", ms, branches) + l.logType(LogMetadataMediaTreeFetched, lf, msg) +} + +func (l *ScanSummaryLogger) LogMetadataMediaTreeFetchFailed(lf *anime.LocalFile, err error, ms int64) { + if l == nil { + return + } + msg := fmt.Sprintf("Could not fetch media tree: %s. Took %dms", err.Error(), ms) + l.logType(LogMetadataMediaTreeFetchFailed, lf, msg) +} + +func (l *ScanSummaryLogger) LogMetadataEpisodeNormalized(lf *anime.LocalFile, mediaId int, episode int, newEpisode int, newMediaId int, aniDBEpisode string) { + if l == nil { + return + } + msg := fmt.Sprintf("Episode %d normalized to %d. New media ID: %d. AniDB episode: %s", episode, newEpisode, newMediaId, aniDBEpisode) + l.logType(LogMetadataEpisodeNormalized, lf, msg) +} + +func (l *ScanSummaryLogger) LogMetadataEpisodeNormalizationFailed(lf *anime.LocalFile, err error, episode int, aniDBEpisode string) { + if l == nil { + return + } + msg := fmt.Sprintf("Episode normalization failed. Reason \"%s\". Episode %d. AniDB episode %s", err.Error(), episode, aniDBEpisode) + l.logType(LogMetadataEpisodeNormalizationFailed, lf, msg) +} + +func (l *ScanSummaryLogger) LogMetadataNC(lf *anime.LocalFile) { + if l == nil { + return + } + msg := fmt.Sprintf("Marked as NC file") + l.logType(LogMetadataNC, lf, msg) +} + +func (l *ScanSummaryLogger) LogMetadataSpecial(lf *anime.LocalFile, episode int, aniDBEpisode string) { + if l == nil { + return + } + msg := fmt.Sprintf("Marked as Special episode. Episode %d. AniDB episode: %s", episode, aniDBEpisode) + l.logType(LogMetadataSpecial, lf, msg) +} + +func (l *ScanSummaryLogger) LogMetadataMain(lf *anime.LocalFile, episode int, aniDBEpisode string) { + if l == nil { + return + } + msg := fmt.Sprintf("Marked as main episode. Episode %d. AniDB episode: %s", episode, aniDBEpisode) + l.logType(LogMetadataMain, lf, msg) +} + +func (l *ScanSummaryLogger) LogMetadataEpisodeZero(lf *anime.LocalFile, episode int, aniDBEpisode string) { + if l == nil { + return + } + msg := fmt.Sprintf("Marked as main episode. Episode %d. AniDB episode set to %s assuming AniDB does not include episode 0 in the episode count.", episode, aniDBEpisode) + l.logType(LogMetadataEpisodeZero, lf, msg) +} + +func (l *ScanSummaryLogger) LogMetadataHydrated(lf *anime.LocalFile, mediaId int) { + if l == nil { + return + } + msg := fmt.Sprintf("Metadata hydrated for media %d", mediaId) + l.logType(LogMetadataHydrated, lf, msg) +} + +func (l *ScanSummaryLogger) LogDebug(lf *anime.LocalFile, message string) { + if l == nil { + return + } + l.log(lf, "info", message) +} + +func (l *ScanSummaryLogger) logType(logType LogType, lf *anime.LocalFile, message string) { + if l == nil { + return + } + switch logType { + case LogComparison: + l.log(lf, "info", message) + case LogSuccessfullyMatched: + l.log(lf, "info", message) + case LogFailedMatch: + l.log(lf, "warning", message) + case LogMatchValidated: + l.log(lf, "info", message) + case LogUnmatched: + l.log(lf, "warning", message) + case LogMetadataMediaTreeFetched: + l.log(lf, "info", message) + case LogMetadataMediaTreeFetchFailed: + l.log(lf, "error", message) + case LogMetadataEpisodeNormalized: + l.log(lf, "info", message) + case LogMetadataEpisodeNormalizationFailed: + l.log(lf, "error", message) + case LogMetadataHydrated: + l.log(lf, "info", message) + case LogMetadataNC: + l.log(lf, "info", message) + case LogMetadataSpecial: + l.log(lf, "info", message) + case LogMetadataMain: + l.log(lf, "info", message) + case LogMetadataEpisodeZero: + l.log(lf, "warning", message) + case LogFileNotMatched: + l.log(lf, "warning", message) + case LogPanic: + l.log(lf, "error", message) + case LogDebug: + l.log(lf, "info", message) + } +} + +func (l *ScanSummaryLogger) log(lf *anime.LocalFile, level string, message string) { + if l == nil { + return + } + l.Logs = append(l.Logs, &ScanSummaryLog{ + ID: uuid.NewString(), + FilePath: lf.Path, + Level: level, + Message: message, + }) +} + +func (l *ScanSummaryLogger) getFileLogs(lf *anime.LocalFile) []*ScanSummaryLog { + logs := make([]*ScanSummaryLog, 0) + if l == nil { + return logs + } + for _, log := range l.Logs { + if lf.HasSamePath(log.FilePath) { + logs = append(logs, log) + } + } + return logs +} diff --git a/seanime-2.9.10/internal/local/database.go b/seanime-2.9.10/internal/local/database.go new file mode 100644 index 0000000..ce309e3 --- /dev/null +++ b/seanime-2.9.10/internal/local/database.go @@ -0,0 +1,78 @@ +package local + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/glebarez/sqlite" + "github.com/rs/zerolog" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +type Database struct { + gormdb *gorm.DB + logger *zerolog.Logger +} + +func newLocalSyncDatabase(appDataDir, dbName string, logger *zerolog.Logger) (*Database, error) { + + // Set the SQLite database path + var sqlitePath string + if os.Getenv("TEST_ENV") == "true" { + sqlitePath = ":memory:" + } else { + sqlitePath = filepath.Join(appDataDir, dbName+".db") + } + + // Connect to the SQLite database + db, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{ + Logger: gormlogger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + gormlogger.Config{ + SlowThreshold: time.Second, + LogLevel: gormlogger.Error, + IgnoreRecordNotFoundError: true, + ParameterizedQueries: false, + Colorful: true, + }, + ), + }) + if err != nil { + return nil, err + } + + // Migrate tables + err = migrateTables(db) + if err != nil { + logger.Fatal().Err(err).Msg("local platform: Failed to perform auto migration") + return nil, err + } + + logger.Info().Str("name", fmt.Sprintf("%s.db", dbName)).Msg("local platform: Database instantiated") + + return &Database{ + gormdb: db, + logger: logger, + }, nil +} + +// MigrateTables performs auto migration on the database +func migrateTables(db *gorm.DB) error { + err := db.AutoMigrate( + &Settings{}, + &LocalCollection{}, + &SimulatedCollection{}, + &AnimeSnapshot{}, + &MangaSnapshot{}, + &TrackedMedia{}, + ) + if err != nil { + return err + } + + return nil +} diff --git a/seanime-2.9.10/internal/local/database_helpers.go b/seanime-2.9.10/internal/local/database_helpers.go new file mode 100644 index 0000000..eaa5a90 --- /dev/null +++ b/seanime-2.9.10/internal/local/database_helpers.go @@ -0,0 +1,242 @@ +package local + +import ( + "seanime/internal/api/anilist" + + "github.com/goccy/go-json" +) + +var CurrSettings *Settings + +func (ldb *Database) SaveSettings(s *Settings) error { + s.BaseModel.ID = 1 + CurrSettings = nil + return ldb.gormdb.Save(s).Error +} + +func (ldb *Database) GetSettings() *Settings { + if CurrSettings != nil { + return CurrSettings + } + var s Settings + err := ldb.gormdb.First(&s).Error + if err != nil { + _ = ldb.SaveSettings(&Settings{ + BaseModel: BaseModel{ + ID: 1, + }, + Updated: false, + }) + return &Settings{ + BaseModel: BaseModel{ + ID: 1, + }, + Updated: false, + } + } + return &s +} + +func (ldb *Database) SetTrackedMedia(sm *TrackedMedia) error { + return ldb.gormdb.Save(sm).Error +} + +// GetTrackedMedia returns the tracked media with the given mediaId and kind. +// This should only be used when adding/removing tracked media. +func (ldb *Database) GetTrackedMedia(mediaId int, kind string) (*TrackedMedia, bool) { + var sm TrackedMedia + err := ldb.gormdb.Where("media_id = ? AND type = ?", mediaId, kind).First(&sm).Error + return &sm, err == nil +} + +func (ldb *Database) GetAllTrackedMediaByType(kind string) ([]*TrackedMedia, bool) { + var sm []*TrackedMedia + err := ldb.gormdb.Where("type = ?", kind).Find(&sm).Error + return sm, err == nil +} + +func (ldb *Database) GetAllTrackedMedia() ([]*TrackedMedia, bool) { + var sm []*TrackedMedia + err := ldb.gormdb.Find(&sm).Error + return sm, err == nil +} + +func (ldb *Database) RemoveTrackedMedia(mediaId int, kind string) error { + return ldb.gormdb.Where("media_id = ? AND type = ?", mediaId, kind).Delete(&TrackedMedia{}).Error +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +func (ldb *Database) SaveAnimeSnapshot(as *AnimeSnapshot) error { + return ldb.gormdb.Save(as).Error +} + +func (ldb *Database) GetAnimeSnapshot(mediaId int) (*AnimeSnapshot, bool) { + var as AnimeSnapshot + err := ldb.gormdb.Where("media_id = ?", mediaId).First(&as).Error + return &as, err == nil +} + +func (ldb *Database) RemoveAnimeSnapshot(mediaId int) error { + return ldb.gormdb.Where("media_id = ?", mediaId).Delete(&AnimeSnapshot{}).Error +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +func (ldb *Database) SaveMangaSnapshot(ms *MangaSnapshot) error { + return ldb.gormdb.Save(ms).Error +} + +func (ldb *Database) GetMangaSnapshot(mediaId int) (*MangaSnapshot, bool) { + var ms MangaSnapshot + err := ldb.gormdb.Where("media_id = ?", mediaId).First(&ms).Error + return &ms, err == nil +} + +func (ldb *Database) RemoveMangaSnapshot(mediaId int) error { + return ldb.gormdb.Where("media_id = ?", mediaId).Delete(&MangaSnapshot{}).Error +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +func (ldb *Database) GetAnimeSnapshots() ([]*AnimeSnapshot, bool) { + var as []*AnimeSnapshot + err := ldb.gormdb.Find(&as).Error + return as, err == nil +} + +func (ldb *Database) GetMangaSnapshots() ([]*MangaSnapshot, bool) { + var ms []*MangaSnapshot + err := ldb.gormdb.Find(&ms).Error + return ms, err == nil +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +func (ldb *Database) SaveAnimeCollection(ac *anilist.AnimeCollection) error { + return ldb._saveLocalCollection(AnimeType, ac) +} + +func (ldb *Database) SaveMangaCollection(mc *anilist.MangaCollection) error { + return ldb._saveLocalCollection(MangaType, mc) +} + +func (ldb *Database) GetLocalAnimeCollection() (*anilist.AnimeCollection, bool) { + lc, ok := ldb._getLocalCollection(AnimeType) + if !ok { + return nil, false + } + + var ac anilist.AnimeCollection + err := json.Unmarshal(lc.Value, &ac) + + return &ac, err == nil +} + +func (ldb *Database) GetLocalMangaCollection() (*anilist.MangaCollection, bool) { + lc, ok := ldb._getLocalCollection(MangaType) + if !ok { + return nil, false + } + + var mc anilist.MangaCollection + err := json.Unmarshal(lc.Value, &mc) + + return &mc, err == nil +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +func (ldb *Database) _getLocalCollection(collectionType string) (*LocalCollection, bool) { + var lc LocalCollection + err := ldb.gormdb.Where("type = ?", collectionType).First(&lc).Error + return &lc, err == nil +} + +func (ldb *Database) _saveLocalCollection(collectionType string, value interface{}) error { + + marshalledValue, err := json.Marshal(value) + if err != nil { + return err + } + + // Check if collection already exists + lc, ok := ldb._getLocalCollection(collectionType) + if ok { + lc.Value = marshalledValue + return ldb.gormdb.Save(&lc).Error + } + + lcN := LocalCollection{ + Type: collectionType, + Value: marshalledValue, + } + + return ldb.gormdb.Save(&lcN).Error +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Simulated collections +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (ldb *Database) _getSimulatedCollection(collectionType string) (*SimulatedCollection, bool) { + var lc SimulatedCollection + err := ldb.gormdb.Where("type = ?", collectionType).First(&lc).Error + return &lc, err == nil +} + +func (ldb *Database) _saveSimulatedCollection(collectionType string, value interface{}) error { + + marshalledValue, err := json.Marshal(value) + if err != nil { + return err + } + + // Check if collection already exists + lc, ok := ldb._getSimulatedCollection(collectionType) + if ok { + lc.Value = marshalledValue + return ldb.gormdb.Save(&lc).Error + } + + lcN := SimulatedCollection{ + Type: collectionType, + Value: marshalledValue, + } + + return ldb.gormdb.Save(&lcN).Error +} + +func (ldb *Database) SaveSimulatedAnimeCollection(ac *anilist.AnimeCollection) error { + return ldb._saveSimulatedCollection(AnimeType, ac) +} + +func (ldb *Database) SaveSimulatedMangaCollection(mc *anilist.MangaCollection) error { + return ldb._saveSimulatedCollection(MangaType, mc) +} + +func (ldb *Database) GetSimulatedAnimeCollection() (*anilist.AnimeCollection, bool) { + lc, ok := ldb._getSimulatedCollection(AnimeType) + if !ok { + return nil, false + } + + var ac anilist.AnimeCollection + err := json.Unmarshal(lc.Value, &ac) + + return &ac, err == nil +} + +func (ldb *Database) GetSimulatedMangaCollection() (*anilist.MangaCollection, bool) { + lc, ok := ldb._getSimulatedCollection(MangaType) + if !ok { + return nil, false + } + + var mc anilist.MangaCollection + err := json.Unmarshal(lc.Value, &mc) + + return &mc, err == nil +} diff --git a/seanime-2.9.10/internal/local/database_models.go b/seanime-2.9.10/internal/local/database_models.go new file mode 100644 index 0000000..d1353d0 --- /dev/null +++ b/seanime-2.9.10/internal/local/database_models.go @@ -0,0 +1,198 @@ +package local + +import ( + "database/sql/driver" + "errors" + "seanime/internal/api/metadata" + "seanime/internal/manga" + "time" + + "github.com/goccy/go-json" +) + +type BaseModel struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type Settings struct { + BaseModel + // Flag to determine if there are local changes that need to be synced with AniList. + Updated bool `gorm:"column:updated" json:"updated"` +} + +// +---------------------+ +// | Offline | +// +---------------------+ + +// LocalCollection is an anilist collection that is stored locally for offline use. +// It is meant to be kept in sync with the real AniList collection when online. +type LocalCollection struct { + BaseModel + Type string `gorm:"column:type" json:"type"` // "anime" or "manga" + Value []byte `gorm:"column:value" json:"value"` // Marshalled struct +} + +// TrackedMedia tracks media that should be stored locally. +type TrackedMedia struct { + BaseModel + MediaId int `gorm:"column:media_id" json:"mediaId"` + Type string `gorm:"column:type" json:"type"` // "anime" or "manga" +} + +type AnimeSnapshot struct { + BaseModel + MediaId int `gorm:"column:media_id" json:"mediaId"` + //ListEntry LocalAnimeListEntry `gorm:"column:list_entry" json:"listEntry"` + AnimeMetadata LocalAnimeMetadata `gorm:"column:anime_metadata" json:"animeMetadata"` + BannerImagePath string `gorm:"column:banner_image_path" json:"bannerImagePath"` + CoverImagePath string `gorm:"column:cover_image_path" json:"coverImagePath"` + EpisodeImagePaths StringMap `gorm:"column:episode_image_paths" json:"episodeImagePaths"` + // ReferenceKey is used to compare the snapshot with the current data. + ReferenceKey string `gorm:"column:reference_key" json:"referenceKey"` +} + +type MangaSnapshot struct { + BaseModel + MediaId int `gorm:"column:media_id" json:"mediaId"` + //ListEntry LocalMangaListEntry `gorm:"column:list_entry" json:"listEntry"` + ChapterContainers LocalMangaChapterContainers `gorm:"column:chapter_Containers" json:"chapterContainers"` + BannerImagePath string `gorm:"column:banner_image_path" json:"bannerImagePath"` + CoverImagePath string `gorm:"column:cover_image_path" json:"coverImagePath"` + // ReferenceKey is used to compare the snapshot with the current data. + ReferenceKey string `gorm:"column:reference_key" json:"referenceKey"` +} + +// +---------------------+ +// | Simulated | +// +---------------------+ + +// SimulatedCollection is used for users without an account. +type SimulatedCollection struct { + BaseModel + Type string `gorm:"column:type" json:"type"` // "anime" or "manga" + Value []byte `gorm:"column:value" json:"value"` // Marshalled struct +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type StringMap map[string]string + +func (o *StringMap) Scan(src interface{}) error { + bytes, ok := src.([]byte) + if !ok { + return errors.New("src value cannot cast to []byte") + } + var ret map[string]string + err := json.Unmarshal(bytes, &ret) + if err != nil { + return err + } + *o = ret + return nil +} + +func (o StringMap) Value() (driver.Value, error) { + return json.Marshal(o) +} + +type LocalAnimeMetadata metadata.AnimeMetadata + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +func (o *LocalAnimeMetadata) Scan(src interface{}) error { + bytes, ok := src.([]byte) + if !ok { + return errors.New("src value cannot cast to []byte") + } + var ret metadata.AnimeMetadata + err := json.Unmarshal(bytes, &ret) + if err != nil { + return err + } + *o = LocalAnimeMetadata(ret) + return nil +} + +func (o LocalAnimeMetadata) Value() (driver.Value, error) { + return json.Marshal(o) +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +type LocalMangaChapterContainers []*manga.ChapterContainer + +func (o *LocalMangaChapterContainers) Scan(src interface{}) error { + bytes, ok := src.([]byte) + if !ok { + return errors.New("src value cannot cast to []byte") + } + var ret []*manga.ChapterContainer + err := json.Unmarshal(bytes, &ret) + if err != nil { + return err + } + *o = LocalMangaChapterContainers(ret) + return nil +} + +func (o LocalMangaChapterContainers) Value() (driver.Value, error) { + return json.Marshal(o) +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +//type LocalMangaListEntry anilist.MangaListEntry +// +//func (o *LocalMangaListEntry) Scan(src interface{}) error { +// bytes, ok := src.([]byte) +// if !ok { +// return errors.New("src value cannot cast to []byte") +// } +// var ret anilist.MangaListEntry +// err := json.Unmarshal(bytes, &ret) +// if err != nil { +// return err +// } +// *o = LocalMangaListEntry(ret) +// return nil +//} +// +//func (o LocalMangaListEntry) Value() (driver.Value, error) { +// if o.ID == 0 { +// return nil, nil +// } +// return json.Marshal(o) +//} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +//type LocalAnimeListEntry anilist.AnimeListEntry +// +//func (o *LocalAnimeListEntry) Scan(src interface{}) error { +// bytes, ok := src.([]byte) +// if !ok { +// return errors.New("src value cannot cast to []byte") +// } +// var ret anilist.AnimeListEntry +// err := json.Unmarshal(bytes, &ret) +// if err != nil { +// return err +// } +// *o = LocalAnimeListEntry(ret) +// return nil +//} +// +//func (o LocalAnimeListEntry) Value() (driver.Value, error) { +// if o.ID == 0 { +// return nil, nil +// } +// return json.Marshal(o) +//} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Local account +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/seanime-2.9.10/internal/local/diff.go b/seanime-2.9.10/internal/local/diff.go new file mode 100644 index 0000000..2b967fd --- /dev/null +++ b/seanime-2.9.10/internal/local/diff.go @@ -0,0 +1,327 @@ +package local + +import ( + "fmt" + "seanime/internal/api/anilist" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/library/anime" + "seanime/internal/manga" + "slices" + "strings" + + "github.com/rs/zerolog" + "github.com/samber/lo" + "github.com/samber/mo" +) + +// DEVNOTE: Here we compare the media data from the current up-to-date collections with the local data. +// Outdated media are added to the Syncer to be updated. +// If the media doesn't have a snapshot -> a new snapshot is created. +// If the reference key is different -> the metadata is re-fetched and the snapshot is updated. +// If the list data is different -> the list data is updated. + +const ( + DiffTypeMissing DiffType = iota // We need to add a new snapshot + DiffTypeMetadata // We need to re-fetch the snapshot metadata (episode metadata / chapter containers), list data will be updated as well + DiffTypeListData // We need to update the list data +) + +type ( + Diff struct { + Logger *zerolog.Logger + } + + DiffType int +) + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +type GetAnimeDiffOptions struct { + Collection *anilist.AnimeCollection + LocalCollection mo.Option[*anilist.AnimeCollection] + LocalFiles []*anime.LocalFile + TrackedAnime map[int]*TrackedMedia + Snapshots map[int]*AnimeSnapshot +} + +type AnimeDiffResult struct { + AnimeEntry *anilist.AnimeListEntry + AnimeSnapshot *AnimeSnapshot + DiffType DiffType +} + +// GetAnimeDiffs returns the anime that have changed. +// The anime is considered changed if: +// - It doesn't have a snapshot +// - The reference key is different (e.g. the number of local files has changed), meaning we need to update the snapshot. +func (d *Diff) GetAnimeDiffs(opts GetAnimeDiffOptions) map[int]*AnimeDiffResult { + + collection := opts.Collection + localCollection := opts.LocalCollection + trackedAnimeMap := opts.TrackedAnime + snapshotMap := opts.Snapshots + + changedMap := make(map[int]*AnimeDiffResult) + + if len(collection.MediaListCollection.Lists) == 0 || len(trackedAnimeMap) == 0 { + return changedMap + } + + for _, _list := range collection.MediaListCollection.Lists { + if _list.GetStatus() == nil || _list.GetEntries() == nil { + continue + } + for _, _entry := range _list.GetEntries() { + // Check if the anime is tracked + _, isTracked := trackedAnimeMap[_entry.GetMedia().GetID()] + if !isTracked { + continue + } + + if localCollection.IsAbsent() { + d.Logger.Trace().Msgf("local manager: Diff > Anime %d, local collection is missing", _entry.GetMedia().GetID()) + changedMap[_entry.GetMedia().GetID()] = &AnimeDiffResult{ + AnimeEntry: _entry, + DiffType: DiffTypeMissing, + } + continue // Go to the next anime + } + + // Check if the anime has a snapshot + snapshot, hasSnapshot := snapshotMap[_entry.GetMedia().GetID()] + if !hasSnapshot { + d.Logger.Trace().Msgf("local manager: Diff > Anime %d is missing a snapshot", _entry.GetMedia().GetID()) + changedMap[_entry.GetMedia().GetID()] = &AnimeDiffResult{ + AnimeEntry: _entry, + DiffType: DiffTypeMissing, + } + continue // Go to the next anime + } + + _lfs := lo.Filter(opts.LocalFiles, func(lf *anime.LocalFile, _ int) bool { + return lf.MediaId == _entry.GetMedia().GetID() + }) + + // Check if the anime has changed + _referenceKey := GetAnimeReferenceKey(_entry.Media, _lfs) + + // Check if the reference key is different + if snapshotMap[_entry.GetMedia().GetID()].ReferenceKey != _referenceKey { + d.Logger.Trace().Str("localReferenceKey", snapshotMap[_entry.GetMedia().GetID()].ReferenceKey).Str("currentReferenceKey", _referenceKey).Msgf("local manager: Diff > Anime %d has an outdated snapshot", _entry.GetMedia().GetID()) + changedMap[_entry.GetMedia().GetID()] = &AnimeDiffResult{ + AnimeEntry: _entry, + AnimeSnapshot: snapshot, + DiffType: DiffTypeMetadata, + } + continue // Go to the next anime + } + + localEntry, found := localCollection.MustGet().GetListEntryFromAnimeId(_entry.GetMedia().GetID()) + if !found { + d.Logger.Trace().Msgf("local manager: Diff > Anime %d is missing from the local collection", _entry.GetMedia().GetID()) + changedMap[_entry.GetMedia().GetID()] = &AnimeDiffResult{ + AnimeEntry: _entry, + AnimeSnapshot: snapshot, + DiffType: DiffTypeMissing, + } + continue // Go to the next anime + } + + // Check if the list data has changed + _listDataKey := GetAnimeListDataKey(_entry) + localListDataKey := GetAnimeListDataKey(localEntry) + + if _listDataKey != localListDataKey { + d.Logger.Trace().Str("localListDataKey", localListDataKey).Str("currentListDataKey", _listDataKey).Msgf("local manager: Diff > Anime %d has changed list data", _entry.GetMedia().GetID()) + changedMap[_entry.GetMedia().GetID()] = &AnimeDiffResult{ + AnimeEntry: _entry, + AnimeSnapshot: snapshot, + DiffType: DiffTypeListData, + } + continue // Go to the next anime + } + + } + } + + return changedMap +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +type GetMangaDiffOptions struct { + Collection *anilist.MangaCollection + LocalCollection mo.Option[*anilist.MangaCollection] + DownloadedChapterContainers []*manga.ChapterContainer + TrackedManga map[int]*TrackedMedia + Snapshots map[int]*MangaSnapshot +} + +type MangaDiffResult struct { + MangaEntry *anilist.MangaListEntry + MangaSnapshot *MangaSnapshot + DiffType DiffType +} + +// GetMangaDiffs returns the manga that have changed. +func (d *Diff) GetMangaDiffs(opts GetMangaDiffOptions) map[int]*MangaDiffResult { + + collection := opts.Collection + localCollection := opts.LocalCollection + trackedMangaMap := opts.TrackedManga + snapshotMap := opts.Snapshots + + changedMap := make(map[int]*MangaDiffResult) + + if len(collection.MediaListCollection.Lists) == 0 || len(trackedMangaMap) == 0 { + return changedMap + } + + for _, _list := range collection.MediaListCollection.Lists { + if _list.GetStatus() == nil || _list.GetEntries() == nil { + continue + } + for _, _entry := range _list.GetEntries() { + // Check if the manga is tracked + _, isTracked := trackedMangaMap[_entry.GetMedia().GetID()] + if !isTracked { + continue + } + + if localCollection.IsAbsent() { + d.Logger.Trace().Msgf("local manager: Diff > Manga %d, local collection is missing", _entry.GetMedia().GetID()) + changedMap[_entry.GetMedia().GetID()] = &MangaDiffResult{ + MangaEntry: _entry, + DiffType: DiffTypeMissing, + } + continue // Go to the next manga + } + + // Check if the manga has a snapshot + snapshot, hasSnapshot := snapshotMap[_entry.GetMedia().GetID()] + if !hasSnapshot { + d.Logger.Trace().Msgf("local manager: Diff > Manga %d is missing a snapshot", _entry.GetMedia().GetID()) + changedMap[_entry.GetMedia().GetID()] = &MangaDiffResult{ + MangaEntry: _entry, + DiffType: DiffTypeMissing, + } + continue // Go to the next manga + } + + // Check if the manga has changed + _referenceKey := GetMangaReferenceKey(_entry.Media, opts.DownloadedChapterContainers) + + // Check if the reference key is different + if snapshotMap[_entry.GetMedia().GetID()].ReferenceKey != _referenceKey { + d.Logger.Trace().Str("localReferenceKey", snapshotMap[_entry.GetMedia().GetID()].ReferenceKey).Str("currentReferenceKey", _referenceKey).Msgf("local manager: Diff > Manga %d has an outdated snapshot", _entry.GetMedia().GetID()) + changedMap[_entry.GetMedia().GetID()] = &MangaDiffResult{ + MangaEntry: _entry, + MangaSnapshot: snapshot, + DiffType: DiffTypeMetadata, + } + continue // Go to the next manga + } + + localEntry, found := localCollection.MustGet().GetListEntryFromMangaId(_entry.GetMedia().GetID()) + if !found { + d.Logger.Trace().Msgf("local manager: Diff > Manga %d is missing from the local collection", _entry.GetMedia().GetID()) + changedMap[_entry.GetMedia().GetID()] = &MangaDiffResult{ + MangaEntry: _entry, + MangaSnapshot: snapshot, + DiffType: DiffTypeMissing, + } + continue // Go to the next manga + } + + // Check if the list data has changed + _listDataKey := GetMangaListDataKey(_entry) + localListDataKey := GetMangaListDataKey(localEntry) + + if _listDataKey != localListDataKey { + d.Logger.Trace().Str("localListDataKey", localListDataKey).Str("currentListDataKey", _listDataKey).Msgf("local manager: Diff > Manga %d has changed list data", _entry.GetMedia().GetID()) + changedMap[_entry.GetMedia().GetID()] = &MangaDiffResult{ + MangaEntry: _entry, + MangaSnapshot: snapshot, + DiffType: DiffTypeListData, + } + continue // Go to the next manga + } + + } + } + + return changedMap +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func GetAnimeReferenceKey(bAnime *anilist.BaseAnime, lfs []*anime.LocalFile) string { + // Reference key is used to compare the snapshot with the current data. + // If the reference key is different, the snapshot is outdated. + animeLfs := lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool { + return lf.MediaId == bAnime.ID + }) + + // Extract the paths and sort them to maintain a consistent order. + paths := lo.Map(animeLfs, func(lf *anime.LocalFile, _ int) string { + return lf.Path + }) + slices.Sort(paths) + + return fmt.Sprintf("%d-%s", bAnime.ID, strings.Join(paths, ",")) +} + +func GetMangaReferenceKey(bManga *anilist.BaseManga, dcc []*manga.ChapterContainer) string { + // Reference key is used to compare the snapshot with the current data. + // If the reference key is different, the snapshot is outdated. + mangaDcc := lo.Filter(dcc, func(dc *manga.ChapterContainer, _ int) bool { + return dc.MediaId == bManga.ID + }) + + slices.SortFunc(mangaDcc, func(i, j *manga.ChapterContainer) int { + return strings.Compare(i.Provider, j.Provider) + }) + var k string + for _, dc := range mangaDcc { + l := dc.Provider + "-" + slices.SortFunc(dc.Chapters, func(i, j *hibikemanga.ChapterDetails) int { + return strings.Compare(i.ID, j.ID) + }) + for _, c := range dc.Chapters { + l += c.ID + "-" + } + k += l + } + + return fmt.Sprintf("%d-%s", bManga.ID, k) +} + +func GetAnimeListDataKey(entry *anilist.AnimeListEntry) string { + return fmt.Sprintf("%s-%d-%f-%d-%v-%v-%v-%v-%v-%v", + MediaListStatusPointerValue(entry.GetStatus()), + IntPointerValue(entry.GetProgress()), + Float64PointerValue(entry.GetScore()), + IntPointerValue(entry.GetRepeat()), + IntPointerValue(entry.GetStartedAt().GetYear()), + IntPointerValue(entry.GetStartedAt().GetMonth()), + IntPointerValue(entry.GetStartedAt().GetDay()), + IntPointerValue(entry.GetCompletedAt().GetYear()), + IntPointerValue(entry.GetCompletedAt().GetMonth()), + IntPointerValue(entry.GetCompletedAt().GetDay()), + ) +} + +func GetMangaListDataKey(entry *anilist.MangaListEntry) string { + return fmt.Sprintf("%s-%d-%f-%d-%v-%v-%v-%v-%v-%v", + MediaListStatusPointerValue(entry.GetStatus()), + IntPointerValue(entry.GetProgress()), + Float64PointerValue(entry.GetScore()), + IntPointerValue(entry.GetRepeat()), + IntPointerValue(entry.GetStartedAt().GetYear()), + IntPointerValue(entry.GetStartedAt().GetMonth()), + IntPointerValue(entry.GetStartedAt().GetDay()), + IntPointerValue(entry.GetCompletedAt().GetYear()), + IntPointerValue(entry.GetCompletedAt().GetMonth()), + IntPointerValue(entry.GetCompletedAt().GetDay()), + ) +} diff --git a/seanime-2.9.10/internal/local/manager.go b/seanime-2.9.10/internal/local/manager.go new file mode 100644 index 0000000..2ef50f1 --- /dev/null +++ b/seanime-2.9.10/internal/local/manager.go @@ -0,0 +1,1039 @@ +package local + +import ( + "context" + "fmt" + "os" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/database/db" + "seanime/internal/database/db_bridge" + "seanime/internal/events" + "seanime/internal/library/anime" + "seanime/internal/manga" + "seanime/internal/platforms/platform" + + "github.com/rs/zerolog" + "github.com/samber/lo" + "github.com/samber/mo" +) + +var ( + ErrAlreadyTracked = fmt.Errorf("local manager: Media already tracked") +) + +const ( + AnimeType = "anime" + MangaType = "manga" +) + +type Manager interface { + // SetAnimeCollection updates the online anime collection in the manager. + SetAnimeCollection(ac *anilist.AnimeCollection) + // SetMangaCollection updates the online manga collection in the manager. + SetMangaCollection(mc *anilist.MangaCollection) + // GetLocalAnimeCollection returns the local anime collection stored in the local database. + GetLocalAnimeCollection() mo.Option[*anilist.AnimeCollection] + // GetLocalMangaCollection returns the local manga collection stored in the local database. + GetLocalMangaCollection() mo.Option[*anilist.MangaCollection] + // UpdateLocalAnimeCollection updates the local anime collection using the online data. + UpdateLocalAnimeCollection(ac *anilist.AnimeCollection) + // UpdateLocalMangaCollection updates the local manga collection using the online data. + UpdateLocalMangaCollection(mc *anilist.MangaCollection) + // GetOfflineMetadataProvider returns the offline metadata provider. + GetOfflineMetadataProvider() metadata.Provider + // GetSyncer returns the syncer (used to synchronize the anime and manga snapshots in the local database). + GetSyncer() *Syncer + AutoTrackCurrentMedia() (bool, error) + // TrackAnime adds an anime to track for offline use. + // It checks that the anime is currently in the user's anime collection. + TrackAnime(mId int) error + // UntrackAnime removes the anime from tracking. + UntrackAnime(mId int) error + // TrackManga adds a manga to track for offline use. + // It checks that the manga is currently in the user's manga collection. + TrackManga(mId int) error + // UntrackManga removes a manga from tracking. + UntrackManga(mId int) error + // IsMediaTracked checks if the media is tracked in the local database. + IsMediaTracked(aId int, kind string) bool + // GetTrackedMediaItems returns all tracked media items. + GetTrackedMediaItems() []*TrackedMediaItem + // SynchronizeLocal syncs all currently tracked media. + // Compares the local database with the user's anime and manga collections and updates the local database accordingly. + SynchronizeLocal() error + // SynchronizeAnilist syncs the user's AniList data with data stored in the local database. + SynchronizeAnilist() error + // SetRefreshAnilistCollectionsFunc sets the function to call to refresh the online AniList collections. + SetRefreshAnilistCollectionsFunc(func()) + // HasLocalChanges checks if there are any local changes that need to be uploaded or ignored. + HasLocalChanges() bool + // SetHasLocalChanges sets the flag to determine if there are local changes that need to be uploaded or ignored. + SetHasLocalChanges(bool) + // GetLocalStorageSize returns the size of the local storage in bytes. + GetLocalStorageSize() int64 + // GetSimulatedAnimeCollection returns the simulated anime collection for unauthenticated users. + GetSimulatedAnimeCollection() mo.Option[*anilist.AnimeCollection] + // GetSimulatedMangaCollection returns the simulated manga collection for unauthenticated users. + GetSimulatedMangaCollection() mo.Option[*anilist.MangaCollection] + // SaveSimulatedAnimeCollection sets the simulated anime collection for unauthenticated users. + SaveSimulatedAnimeCollection(ac *anilist.AnimeCollection) + // SaveSimulatedMangaCollection sets the simulated manga collection for unauthenticated users. + SaveSimulatedMangaCollection(mc *anilist.MangaCollection) + // SynchronizeSimulatedCollectionToAnilist synchronizes the simulated anime and manga collections to the user's AniList account. + SynchronizeSimulatedCollectionToAnilist() error + // SynchronizeAnilistToSimulatedCollection synchronizes the user's AniList account to the simulated anime and manga collections. + SynchronizeAnilistToSimulatedCollection() error + + SetOffline(bool) +} + +type ( + ManagerImpl struct { + db *db.Database + localDb *Database + localDir string + localAssetsDir string + isOffline bool + + logger *zerolog.Logger + metadataProvider metadata.Provider + mangaRepository *manga.Repository + wsEventManager events.WSEventManagerInterface + offlineMetadataProvider metadata.Provider + anilistPlatform platform.Platform + + syncer *Syncer + + // Anime collection stored in the local database, without modifications + localAnimeCollection mo.Option[*anilist.AnimeCollection] + // Manga collection stored in the local database, without modifications + localMangaCollection mo.Option[*anilist.MangaCollection] + + // Anime collection from the user's AniList account, changed by ManagerImpl.SetAnimeCollection + animeCollection mo.Option[*anilist.AnimeCollection] + // Manga collection from the user's AniList account, changed by ManagerImpl.SetMangaCollection + mangaCollection mo.Option[*anilist.MangaCollection] + + // Downloaded chapter containers, set by ManagerImpl.Synchronize, accessed by the synchronization Syncer + downloadedChapterContainers []*manga.ChapterContainer + // Local files, set by ManagerImpl.Synchronize, accessed by the synchronization Syncer + localFiles []*anime.LocalFile + + RefreshAnilistCollectionsFunc func() + } + TrackedMediaItem struct { + MediaId int `json:"mediaId"` + Type string `json:"type"` + AnimeEntry *anilist.AnimeListEntry `json:"animeEntry,omitempty"` + MangaEntry *anilist.MangaListEntry `json:"mangaEntry,omitempty"` + } + + NewManagerOptions struct { + LocalDir string + AssetDir string + Logger *zerolog.Logger + MetadataProvider metadata.Provider + MangaRepository *manga.Repository + Database *db.Database + WSEventManager events.WSEventManagerInterface + AnilistPlatform platform.Platform + IsOffline bool + } +) + +func NewManager(opts *NewManagerOptions) (Manager, error) { + + _ = os.MkdirAll(opts.LocalDir, os.ModePerm) + + localDb, err := newLocalSyncDatabase(opts.LocalDir, "local", opts.Logger) + if err != nil { + return nil, err + } + + ret := &ManagerImpl{ + db: opts.Database, + localDb: localDb, + localDir: opts.LocalDir, + localAssetsDir: opts.AssetDir, + logger: opts.Logger, + animeCollection: mo.None[*anilist.AnimeCollection](), + mangaCollection: mo.None[*anilist.MangaCollection](), + localAnimeCollection: mo.None[*anilist.AnimeCollection](), + localMangaCollection: mo.None[*anilist.MangaCollection](), + metadataProvider: opts.MetadataProvider, + mangaRepository: opts.MangaRepository, + downloadedChapterContainers: make([]*manga.ChapterContainer, 0), + localFiles: make([]*anime.LocalFile, 0), + wsEventManager: opts.WSEventManager, + isOffline: opts.IsOffline, + anilistPlatform: opts.AnilistPlatform, + RefreshAnilistCollectionsFunc: func() {}, + } + + ret.syncer = NewQueue(ret) + ret.offlineMetadataProvider = NewOfflineMetadataProvider(ret) + + // Load the local collections + ret.loadLocalAnimeCollection() + ret.loadLocalMangaCollection() + + _ = ret.localDb.GetSettings() + + return ret, nil +} + +func (m *ManagerImpl) SetRefreshAnilistCollectionsFunc(f func()) { + m.RefreshAnilistCollectionsFunc = f +} + +func (m *ManagerImpl) GetSyncer() *Syncer { + return m.syncer +} + +func (m *ManagerImpl) GetOfflineMetadataProvider() metadata.Provider { + return m.offlineMetadataProvider +} + +func (m *ManagerImpl) SetOffline(enabled bool) { + m.isOffline = enabled +} + +func (m *ManagerImpl) HasLocalChanges() bool { + s := m.localDb.GetSettings() + return s.Updated +} + +func (m *ManagerImpl) SetHasLocalChanges(b bool) { + s := m.localDb.GetSettings() + if s.Updated == b { + return + } + s.Updated = b + _ = m.localDb.SaveSettings(s) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *ManagerImpl) loadLocalAnimeCollection() { + collection, ok := m.localDb.GetLocalAnimeCollection() + if !ok { + m.localAnimeCollection = mo.None[*anilist.AnimeCollection]() + } + m.localAnimeCollection = mo.Some(collection) +} + +func (m *ManagerImpl) loadLocalMangaCollection() { + collection, ok := m.localDb.GetLocalMangaCollection() + if !ok { + m.localMangaCollection = mo.None[*anilist.MangaCollection]() + } + m.localMangaCollection = mo.Some(collection) +} + +func (m *ManagerImpl) SetAnimeCollection(ac *anilist.AnimeCollection) { + if ac == nil { + m.animeCollection = mo.None[*anilist.AnimeCollection]() + } else { + m.animeCollection = mo.Some[*anilist.AnimeCollection](ac) + } +} + +func (m *ManagerImpl) SetMangaCollection(mc *anilist.MangaCollection) { + if mc == nil { + m.mangaCollection = mo.None[*anilist.MangaCollection]() + } else { + m.mangaCollection = mo.Some[*anilist.MangaCollection](mc) + } +} + +func (m *ManagerImpl) GetLocalAnimeCollection() mo.Option[*anilist.AnimeCollection] { + return m.localAnimeCollection +} + +func (m *ManagerImpl) GetLocalMangaCollection() mo.Option[*anilist.MangaCollection] { + return m.localMangaCollection +} + +func (m *ManagerImpl) UpdateLocalAnimeCollection(ac *anilist.AnimeCollection) { + _ = m.localDb.SaveAnimeCollection(ac) + m.loadLocalAnimeCollection() +} + +func (m *ManagerImpl) UpdateLocalMangaCollection(mc *anilist.MangaCollection) { + _ = m.localDb.SaveMangaCollection(mc) + m.loadLocalMangaCollection() +} + +func (m *ManagerImpl) AutoTrackCurrentMedia() (added bool, err error) { + + m.logger.Trace().Msgf("local manager: Saving all current media for offline use") + + trackedMedia := m.GetTrackedMediaItems() + trackedMediaMap := make(map[int]struct{}) + for _, item := range trackedMedia { + trackedMediaMap[item.MediaId] = struct{}{} + } + + groupedLocalFiles := lo.GroupBy(m.localFiles, func(f *anime.LocalFile) int { + return f.MediaId + }) + + animeCollection, ok := m.animeCollection.Get() + if ok { + for _, list := range animeCollection.MediaListCollection.Lists { + for _, entry := range list.GetEntries() { + if entry.Status == nil || *entry.GetStatus() != anilist.MediaListStatusCurrent { + continue + } + if _, found := trackedMediaMap[entry.Media.GetID()]; found { + continue + } + m.logger.Trace().Msgf("local manager: Adding anime %d to local database", entry.Media.GetID()) + + lfs, ok := groupedLocalFiles[entry.Media.GetID()] + if !ok || len(lfs) == 0 { + continue + } + + err := m.TrackAnime(entry.Media.GetID()) + if err != nil { + continue + } + added = true + } + } + } + + groupedDownloadedChapterContainers := lo.GroupBy(m.downloadedChapterContainers, func(c *manga.ChapterContainer) int { + return c.MediaId + }) + + mangaCollection, ok := m.mangaCollection.Get() + if ok { + for _, list := range mangaCollection.MediaListCollection.Lists { + for _, entry := range list.GetEntries() { + if entry.Status == nil || *entry.GetStatus() != anilist.MediaListStatusCurrent { + continue + } + if _, found := trackedMediaMap[entry.Media.GetID()]; found { + continue + } + m.logger.Trace().Msgf("local manager: Adding manga %d to local database", entry.Media.GetID()) + + ccs, ok := groupedDownloadedChapterContainers[entry.Media.GetID()] + if !ok || len(ccs) == 0 { + continue + } + + err := m.TrackManga(entry.Media.GetID()) + if err != nil { + continue + } + added = true + } + } + } + + return +} + +// TrackAnime adds an anime to track. +// It checks that the anime is currently in the user's anime collection. +// The anime should have local files, or else ManagerImpl.Synchronize will remove it from tracking. +func (m *ManagerImpl) TrackAnime(mId int) error { + + m.logger.Trace().Msgf("local manager: Adding anime %d to local database", mId) + + s := &TrackedMedia{ + MediaId: mId, + Type: AnimeType, + } + + // Check if the anime is in the user's anime collection + if m.animeCollection.IsAbsent() { + m.logger.Error().Msg("local manager: Anime collection not set") + return fmt.Errorf("anime collection not set") + } + + if _, found := m.animeCollection.MustGet().GetListEntryFromAnimeId(mId); !found { + m.logger.Error().Msgf("local manager: Anime %d not found in user's anime collection", mId) + return fmt.Errorf("anime is not in AniList collection") + } + + if _, found := m.localDb.GetTrackedMedia(mId, AnimeType); found { + return ErrAlreadyTracked + } + + err := m.localDb.gormdb.Create(s).Error + if err != nil { + m.logger.Error().Msgf("local manager: Failed to add anime %d to local database: %w", mId, err) + return fmt.Errorf("failed to add anime %d to local database: %w", mId, err) + } + + return nil +} + +func (m *ManagerImpl) UntrackAnime(mId int) error { + + m.logger.Trace().Msgf("local manager: Removing anime %d from local database", mId) + + if _, found := m.localDb.GetTrackedMedia(mId, AnimeType); !found { + m.logger.Error().Msgf("local manager: Anime %d not in local database", mId) + return fmt.Errorf("anime is not in local database") + } + + err := m.removeAnime(mId) + if err != nil { + return err + } + + m.GetSyncer().refreshCollections() + + return nil +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +// TrackManga adds a manga to track. +// It checks that the manga is currently in the user's manga collection. +// The manga should have downloaded chapter containers, or else ManagerImpl.Synchronize will remove it from tracking. +func (m *ManagerImpl) TrackManga(mId int) error { + + m.logger.Trace().Msgf("local manager: Adding manga %d to local database", mId) + + s := &TrackedMedia{ + MediaId: mId, + Type: MangaType, + } + + // Check if the manga is in the user's manga collection + if m.mangaCollection.IsAbsent() { + m.logger.Error().Msg("local manager: Manga collection not set") + return fmt.Errorf("manga collection not set") + } + + if _, found := m.mangaCollection.MustGet().GetListEntryFromMangaId(mId); !found { + m.logger.Error().Msgf("local manager: Manga %d not found in user's manga collection", mId) + return fmt.Errorf("manga is not in AniList collection") + } + + if _, found := m.localDb.GetTrackedMedia(mId, MangaType); found { + return ErrAlreadyTracked + } + + err := m.localDb.gormdb.Create(s).Error + if err != nil { + m.logger.Error().Msgf("local manager: Failed to add manga %d to local database: %w", mId, err) + return fmt.Errorf("failed to add manga %d to local database: %w", mId, err) + } + + return nil +} + +func (m *ManagerImpl) UntrackManga(mId int) error { + + m.logger.Trace().Msgf("local manager: Removing manga %d from local database", mId) + + if _, found := m.localDb.GetTrackedMedia(mId, MangaType); !found { + m.logger.Error().Msgf("local manager: Manga %d not in local database", mId) + return fmt.Errorf("manga is not in local database") + } + + err := m.removeManga(mId) + if err != nil { + return err + } + + m.GetSyncer().refreshCollections() + + return nil +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +func (m *ManagerImpl) IsMediaTracked(aId int, kind string) bool { + _, found := m.localDb.GetTrackedMedia(aId, kind) + return found +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +func (m *ManagerImpl) GetTrackedMediaItems() (ret []*TrackedMediaItem) { + trackedMedia, ok := m.localDb.GetAllTrackedMedia() + if !ok { + return + } + + if m.animeCollection.IsAbsent() || m.mangaCollection.IsAbsent() { + return + } + + for _, item := range trackedMedia { + if item.Type == AnimeType { + if localAnimeCollection, found := m.localAnimeCollection.Get(); found { + if e, found := localAnimeCollection.GetListEntryFromAnimeId(item.MediaId); found { + ret = append(ret, &TrackedMediaItem{ + MediaId: item.MediaId, + Type: item.Type, + AnimeEntry: e, + }) + continue + } + if e, found := m.animeCollection.MustGet().GetListEntryFromAnimeId(item.MediaId); found { + ret = append(ret, &TrackedMediaItem{ + MediaId: item.MediaId, + Type: item.Type, + AnimeEntry: e, + }) + continue + } + } + } else if item.Type == MangaType { + if localMangaCollection, found := m.localMangaCollection.Get(); found { + if e, found := localMangaCollection.GetListEntryFromMangaId(item.MediaId); found { + ret = append(ret, &TrackedMediaItem{ + MediaId: item.MediaId, + Type: item.Type, + MangaEntry: e, + }) + continue + } + } + if e, found := m.mangaCollection.MustGet().GetListEntryFromMangaId(item.MediaId); found { + ret = append(ret, &TrackedMediaItem{ + MediaId: item.MediaId, + Type: item.Type, + MangaEntry: e, + }) + continue + } + } + } + + return +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +// SynchronizeLocal should be called after updates to the user's anime and manga collections. +// +// - After adding/removing an anime or manga to track +// - After the user's anime and manga collections have been updated (e.g. after a user's anime and manga list has been updated) +// +// It will add media list entries from the user's collection to the Syncer only if the media is tracked. +// - The Syncer will then synchronize the anime & manga with the local database if needed +// +// It will remove any anime & manga from the local database that are not in the user's collection anymore. +// It will then update the ManagerImpl.localAnimeCollection and ManagerImpl.localMangaCollection +func (m *ManagerImpl) SynchronizeLocal() error { + + localStorageSizeCache = 0 + + m.loadLocalAnimeCollection() + m.loadLocalMangaCollection() + + settings := m.localDb.GetSettings() + if settings.Updated { + return fmt.Errorf("cannot sync, upload or ignore local changes before syncing") + } + + lfs, _, err := db_bridge.GetLocalFiles(m.db) + if err != nil { + return fmt.Errorf("local manager: Couldn't start syncing, failed to get local files: %w", err) + } + + // Check if the anime and manga collections are set + if m.animeCollection.IsAbsent() { + return fmt.Errorf("local manager: Couldn't start syncing, anime collection not set") + } + + if m.mangaCollection.IsAbsent() { + return fmt.Errorf("local manager: Couldn't start syncing, manga collection not set") + } + + mangaChapterContainers, err := m.mangaRepository.GetDownloadedChapterContainers(m.mangaCollection.MustGet()) + if err != nil { + return fmt.Errorf("local manager: Couldn't start syncing, failed to get downloaded chapter containers: %w", err) + } + + return m.synchronize(lfs, mangaChapterContainers) +} + +func (m *ManagerImpl) synchronize(lfs []*anime.LocalFile, mangaChapterContainers []*manga.ChapterContainer) error { + + m.logger.Trace().Msg("local manager: Synchronizing local database with user's anime and manga collections") + + m.localFiles = lfs + m.downloadedChapterContainers = mangaChapterContainers + + // Check if the anime and manga collections are set + if m.animeCollection.IsAbsent() { + return fmt.Errorf("local manager: Anime collection not set") + } + + if m.mangaCollection.IsAbsent() { + return fmt.Errorf("local manager: Manga collection not set") + } + + trackedAnimeMap, trackedMangaMap := m.loadTrackedMedia() + + // Remove anime and manga from the local database that are not in the user's anime and manga collections + for _, item := range trackedAnimeMap { + // If the anime is not in the user's anime collection, remove it from the local database + if _, found := m.animeCollection.MustGet().GetListEntryFromAnimeId(item.MediaId); !found { + err := m.removeAnime(item.MediaId) + if err != nil { + return fmt.Errorf("local manager: Failed to remove anime %d from local database: %w", item.MediaId, err) + } + } + } + for _, item := range trackedMangaMap { + // If the manga is not in the user's manga collection, remove it from the local database + if _, found := m.mangaCollection.MustGet().GetListEntryFromMangaId(item.MediaId); !found { + err := m.removeManga(item.MediaId) + if err != nil { + return fmt.Errorf("local manager: Failed to remove manga %d from local database: %w", item.MediaId, err) + } + } + } + + // Get snapshots for all tracked anime and manga + animeSnapshots, _ := m.localDb.GetAnimeSnapshots() + mangaSnapshots, _ := m.localDb.GetMangaSnapshots() + + // Create a map of the snapshots + animeSnapshotMap := make(map[int]*AnimeSnapshot) + for _, snapshot := range animeSnapshots { + animeSnapshotMap[snapshot.MediaId] = snapshot + } + + mangaSnapshotMap := make(map[int]*MangaSnapshot) + for _, snapshot := range mangaSnapshots { + mangaSnapshotMap[snapshot.MediaId] = snapshot + } + + m.syncer.runDiffs(trackedAnimeMap, animeSnapshotMap, trackedMangaMap, mangaSnapshotMap, m.localFiles, m.downloadedChapterContainers) + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *ManagerImpl) SynchronizeAnilist() error { + if m.animeCollection.IsAbsent() { + return fmt.Errorf("local manager: Anime collection not set") + } + + if m.mangaCollection.IsAbsent() { + return fmt.Errorf("local manager: Manga collection not set") + } + + m.loadLocalAnimeCollection() + m.loadLocalMangaCollection() + + if localAnimeCollection, ok := m.localAnimeCollection.Get(); ok { + for _, list := range localAnimeCollection.MediaListCollection.Lists { + if list.GetStatus() == nil || list.GetEntries() == nil { + continue + } + for _, entry := range list.GetEntries() { + if entry.GetStatus() == nil { + continue + } + + // Get the entry from AniList + var originalEntry *anilist.AnimeListEntry + if e, found := m.animeCollection.MustGet().GetListEntryFromAnimeId(entry.GetMedia().GetID()); found { + originalEntry = e + } + if originalEntry == nil { + continue + } + + key1 := GetAnimeListDataKey(entry) + key2 := GetAnimeListDataKey(originalEntry) + + // If the entry is the same, skip + if key1 == key2 { + continue + } + + var startDate *anilist.FuzzyDateInput + if entry.GetStartedAt() != nil { + startDate = &anilist.FuzzyDateInput{ + Year: entry.GetStartedAt().GetYear(), + Month: entry.GetStartedAt().GetMonth(), + Day: entry.GetStartedAt().GetDay(), + } + } + + var endDate *anilist.FuzzyDateInput + if entry.GetCompletedAt() != nil { + endDate = &anilist.FuzzyDateInput{ + Year: entry.GetCompletedAt().GetYear(), + Month: entry.GetCompletedAt().GetMonth(), + Day: entry.GetCompletedAt().GetDay(), + } + } + + var score *int + if entry.GetScore() != nil { + score = lo.ToPtr(int(*entry.GetScore())) + } + + _ = m.anilistPlatform.UpdateEntry( + context.Background(), + entry.GetMedia().GetID(), + entry.GetStatus(), + score, + entry.GetProgress(), + startDate, + endDate, + ) + } + } + } + + if localMangaCollection, ok := m.localMangaCollection.Get(); ok { + for _, list := range localMangaCollection.MediaListCollection.Lists { + if list.GetStatus() == nil || list.GetEntries() == nil { + continue + } + for _, entry := range list.GetEntries() { + if entry.GetStatus() == nil { + continue + } + + // Get the entry from AniList + var originalEntry *anilist.MangaListEntry + if e, found := m.mangaCollection.MustGet().GetListEntryFromMangaId(entry.GetMedia().GetID()); found { + originalEntry = e + } + if originalEntry == nil { + continue + } + + key1 := GetMangaListDataKey(entry) + key2 := GetMangaListDataKey(originalEntry) + + // If the entry is the same, skip + if key1 == key2 { + continue + } + + var startDate *anilist.FuzzyDateInput + if entry.GetStartedAt() != nil { + startDate = &anilist.FuzzyDateInput{ + Year: entry.GetStartedAt().GetYear(), + Month: entry.GetStartedAt().GetMonth(), + Day: entry.GetStartedAt().GetDay(), + } + } + + var endDate *anilist.FuzzyDateInput + if entry.GetCompletedAt() != nil { + endDate = &anilist.FuzzyDateInput{ + Year: entry.GetCompletedAt().GetYear(), + Month: entry.GetCompletedAt().GetMonth(), + Day: entry.GetCompletedAt().GetDay(), + } + } + + var score *int + if entry.GetScore() != nil { + score = lo.ToPtr(int(*entry.GetScore())) + } + + _ = m.anilistPlatform.UpdateEntry( + context.Background(), + entry.GetMedia().GetID(), + entry.GetStatus(), + score, + entry.GetProgress(), + startDate, + endDate, + ) + } + } + } + + m.RefreshAnilistCollectionsFunc() + + m.wsEventManager.SendEvent(events.RefreshedAnilistAnimeCollection, nil) + m.wsEventManager.SendEvent(events.RefreshedAnilistMangaCollection, nil) + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *ManagerImpl) loadTrackedMedia() (trackedAnimeMap map[int]*TrackedMedia, trackedMangaMap map[int]*TrackedMedia) { + trackedAnime, _ := m.localDb.GetAllTrackedMediaByType(AnimeType) + trackedManga, _ := m.localDb.GetAllTrackedMediaByType(MangaType) + + trackedAnimeMap = make(map[int]*TrackedMedia) + for _, item := range trackedAnime { + trackedAnimeMap[item.MediaId] = item + } + + trackedMangaMap = make(map[int]*TrackedMedia) + for _, m := range trackedManga { + trackedMangaMap[m.MediaId] = m + } + + m.GetSyncer().trackedMangaMap = trackedMangaMap + m.GetSyncer().trackedAnimeMap = trackedAnimeMap + + return trackedAnimeMap, trackedMangaMap +} + +func (m *ManagerImpl) removeAnime(aId int) error { + m.logger.Trace().Msgf("local manager: Removing anime %d from local database", aId) + // Remove the tracked anime + err := m.localDb.RemoveTrackedMedia(aId, AnimeType) + if err != nil { + return fmt.Errorf("local manager: Failed to remove anime %d from local database: %w", aId, err) + } + // Remove the anime snapshot + _ = m.localDb.RemoveAnimeSnapshot(aId) + // Remove the images + _ = m.removeMediaImages(aId) + return nil +} + +func (m *ManagerImpl) removeManga(mId int) error { + m.logger.Trace().Msgf("local manager: Removing manga %d from local database", mId) + // Remove the tracked manga + err := m.localDb.RemoveTrackedMedia(mId, MangaType) + if err != nil { + return fmt.Errorf("local manager: Failed to remove manga %d from local database: %w", mId, err) + } + // Remove the manga snapshot + _ = m.localDb.RemoveMangaSnapshot(mId) + // Remove the images + _ = m.removeMediaImages(mId) + return nil +} + +// removeMediaImages removes the images for the media with the given ID. +// - The images are stored in the local assets' directory. +// - e.g. datadir/local/assets/{mediaId}/* +func (m *ManagerImpl) removeMediaImages(mediaId int) error { + m.logger.Trace().Msgf("local manager: Removing images for media %d", mediaId) + path := filepath.Join(m.localAssetsDir, fmt.Sprintf("%d", mediaId)) + _ = os.RemoveAll(path) + //if err != nil { + // return fmt.Errorf("local manager: Failed to remove images for media %d: %w", mediaId, err) + //} + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Avoids recalculating the size of the cache directory every time it is requested +var localStorageSizeCache int64 + +func (m *ManagerImpl) GetLocalStorageSize() int64 { + + if localStorageSizeCache != 0 { + return localStorageSizeCache + } + + var size int64 + _ = filepath.Walk(m.localDir, func(path string, info os.FileInfo, err error) error { + if info != nil { + size += info.Size() + } + return nil + }) + + localStorageSizeCache = size + + return size +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *ManagerImpl) GetSimulatedAnimeCollection() mo.Option[*anilist.AnimeCollection] { + ac, ok := m.localDb.GetSimulatedAnimeCollection() + if !ok { + return mo.None[*anilist.AnimeCollection]() + } + return mo.Some(ac) +} + +func (m *ManagerImpl) GetSimulatedMangaCollection() mo.Option[*anilist.MangaCollection] { + mc, ok := m.localDb.GetSimulatedMangaCollection() + if !ok { + return mo.None[*anilist.MangaCollection]() + } + return mo.Some(mc) +} + +func (m *ManagerImpl) SaveSimulatedAnimeCollection(ac *anilist.AnimeCollection) { + //// Remove airing dates from each entry + //for _, list := range ac.MediaListCollection.Lists { + // for _, entry := range list.Entries { + // entry.GetMedia().NextAiringEpisode = nil + // } + //} + _ = m.localDb.SaveSimulatedAnimeCollection(ac) +} + +func (m *ManagerImpl) SaveSimulatedMangaCollection(mc *anilist.MangaCollection) { + _ = m.localDb.SaveSimulatedMangaCollection(mc) +} + +func (m *ManagerImpl) SynchronizeAnilistToSimulatedCollection() error { + if animeCollection, ok := m.animeCollection.Get(); ok { + m.SaveSimulatedAnimeCollection(animeCollection) + } + + if mangaCollection, ok := m.mangaCollection.Get(); ok { + m.SaveSimulatedMangaCollection(mangaCollection) + } + + return nil +} + +func (m *ManagerImpl) SynchronizeSimulatedCollectionToAnilist() error { + if localAnimeCollection, ok := m.localDb.GetSimulatedAnimeCollection(); ok { + for _, list := range localAnimeCollection.MediaListCollection.Lists { + if list.GetStatus() == nil || list.GetEntries() == nil { + continue + } + for _, entry := range list.GetEntries() { + if entry.GetStatus() == nil { + continue + } + + // Get the entry from AniList + var originalEntry *anilist.AnimeListEntry + if e, found := m.animeCollection.MustGet().GetListEntryFromAnimeId(entry.GetMedia().GetID()); found { + originalEntry = e + } + if originalEntry == nil { + continue + } + + key1 := GetAnimeListDataKey(entry) + key2 := GetAnimeListDataKey(originalEntry) + + // If the entry is the same, skip + if key1 == key2 { + continue + } + + var startDate *anilist.FuzzyDateInput + if entry.GetStartedAt() != nil { + startDate = &anilist.FuzzyDateInput{ + Year: entry.GetStartedAt().GetYear(), + Month: entry.GetStartedAt().GetMonth(), + Day: entry.GetStartedAt().GetDay(), + } + } + + var endDate *anilist.FuzzyDateInput + if entry.GetCompletedAt() != nil { + endDate = &anilist.FuzzyDateInput{ + Year: entry.GetCompletedAt().GetYear(), + Month: entry.GetCompletedAt().GetMonth(), + Day: entry.GetCompletedAt().GetDay(), + } + } + + var score *int + if entry.GetScore() != nil { + score = lo.ToPtr(int(*entry.GetScore())) + } else { + score = lo.ToPtr(0) + } + + _ = m.anilistPlatform.UpdateEntry( + context.Background(), + entry.GetMedia().GetID(), + entry.GetStatus(), + score, + entry.GetProgress(), + startDate, + endDate, + ) + } + } + } + + if localMangaCollection, ok := m.localDb.GetSimulatedMangaCollection(); ok { + for _, list := range localMangaCollection.MediaListCollection.Lists { + if list.GetStatus() == nil || list.GetEntries() == nil { + continue + } + for _, entry := range list.GetEntries() { + if entry.GetStatus() == nil { + continue + } + + // Get the entry from AniList + var originalEntry *anilist.MangaListEntry + if e, found := m.mangaCollection.MustGet().GetListEntryFromMangaId(entry.GetMedia().GetID()); found { + originalEntry = e + } + if originalEntry == nil { + continue + } + + key1 := GetMangaListDataKey(entry) + key2 := GetMangaListDataKey(originalEntry) + + // If the entry is the same, skip + if key1 == key2 { + continue + } + + var startDate *anilist.FuzzyDateInput + if entry.GetStartedAt() != nil { + startDate = &anilist.FuzzyDateInput{ + Year: entry.GetStartedAt().GetYear(), + Month: entry.GetStartedAt().GetMonth(), + Day: entry.GetStartedAt().GetDay(), + } + } + + var endDate *anilist.FuzzyDateInput + if entry.GetCompletedAt() != nil { + endDate = &anilist.FuzzyDateInput{ + Year: entry.GetCompletedAt().GetYear(), + Month: entry.GetCompletedAt().GetMonth(), + Day: entry.GetCompletedAt().GetDay(), + } + } + + var score *int + if entry.GetScore() != nil { + score = lo.ToPtr(int(*entry.GetScore())) + } else { + score = lo.ToPtr(0) + } + + _ = m.anilistPlatform.UpdateEntry( + context.Background(), + entry.GetMedia().GetID(), + entry.GetStatus(), + score, + entry.GetProgress(), + startDate, + endDate, + ) + } + } + } + + m.RefreshAnilistCollectionsFunc() + + m.wsEventManager.SendEvent(events.RefreshedAnilistAnimeCollection, nil) + m.wsEventManager.SendEvent(events.RefreshedAnilistMangaCollection, nil) + + return nil +} diff --git a/seanime-2.9.10/internal/local/metadata.go b/seanime-2.9.10/internal/local/metadata.go new file mode 100644 index 0000000..fdf3525 --- /dev/null +++ b/seanime-2.9.10/internal/local/metadata.go @@ -0,0 +1,93 @@ +package local + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/util/result" + "strconv" + + "github.com/pkg/errors" +) + +// OfflineMetadataProvider replaces the metadata provider only when offline +type OfflineMetadataProvider struct { + manager *ManagerImpl + animeSnapshots map[int]*AnimeSnapshot + animeMetadataCache *result.BoundedCache[string, *metadata.AnimeMetadata] +} + +type OfflineAnimeMetadataWrapper struct { + anime *anilist.BaseAnime + metadata *metadata.AnimeMetadata +} + +func NewOfflineMetadataProvider(manager *ManagerImpl) metadata.Provider { + ret := &OfflineMetadataProvider{ + manager: manager, + animeSnapshots: make(map[int]*AnimeSnapshot), + animeMetadataCache: result.NewBoundedCache[string, *metadata.AnimeMetadata](500), + } + + // Load the anime snapshots + // DEVNOTE: We assume that it will be loaded once since it's used only when offline + ret.loadAnimeSnapshots() + + return ret +} + +func (mp *OfflineMetadataProvider) loadAnimeSnapshots() { + animeSnapshots, ok := mp.manager.localDb.GetAnimeSnapshots() + if !ok { + return + } + + for _, snapshot := range animeSnapshots { + mp.animeSnapshots[snapshot.MediaId] = snapshot + } +} + +func (mp *OfflineMetadataProvider) GetAnimeMetadata(platform metadata.Platform, mId int) (*metadata.AnimeMetadata, error) { + if platform != metadata.AnilistPlatform { + return nil, errors.New("unsupported platform") + } + + if snapshot, ok := mp.animeSnapshots[mId]; ok { + localAnimeMetadata := snapshot.AnimeMetadata + for _, episode := range localAnimeMetadata.Episodes { + if imgUrl, ok := snapshot.EpisodeImagePaths[episode.Episode]; ok { + episode.Image = *FormatAssetUrl(mId, imgUrl) + } + } + + return &metadata.AnimeMetadata{ + Titles: localAnimeMetadata.Titles, + Episodes: localAnimeMetadata.Episodes, + EpisodeCount: localAnimeMetadata.EpisodeCount, + SpecialCount: localAnimeMetadata.SpecialCount, + Mappings: localAnimeMetadata.Mappings, + }, nil + } + + return nil, errors.New("anime metadata not found") +} + +func (mp *OfflineMetadataProvider) GetCache() *result.BoundedCache[string, *metadata.AnimeMetadata] { + return mp.animeMetadataCache +} + +func (mp *OfflineMetadataProvider) GetAnimeMetadataWrapper(anime *anilist.BaseAnime, metadata *metadata.AnimeMetadata) metadata.AnimeMetadataWrapper { + return &OfflineAnimeMetadataWrapper{ + anime: anime, + metadata: metadata, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (mw *OfflineAnimeMetadataWrapper) GetEpisodeMetadata(episodeNumber int) (ret metadata.EpisodeMetadata) { + episodeMetadata, found := mw.metadata.FindEpisode(strconv.Itoa(episodeNumber)) + if found { + ret = *episodeMetadata + } + return +} diff --git a/seanime-2.9.10/internal/local/mock.go b/seanime-2.9.10/internal/local/mock.go new file mode 100644 index 0000000..461f606 --- /dev/null +++ b/seanime-2.9.10/internal/local/mock.go @@ -0,0 +1,48 @@ +package local + +import ( + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/database/db" + "seanime/internal/events" + "seanime/internal/extension_repo" + "seanime/internal/manga" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + + "github.com/stretchr/testify/require" +) + +func GetMockManager(t *testing.T, db *db.Database) Manager { + logger := util.NewLogger() + metadataProvider := metadata.GetMockProvider(t) + extensionRepository := extension_repo.GetMockExtensionRepository(t) + mangaRepository := manga.GetMockRepository(t, db) + + mangaRepository.InitExtensionBank(extensionRepository.GetExtensionBank()) + + wsEventManager := events.NewMockWSEventManager(logger) + anilistClient := anilist.NewMockAnilistClient() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + + localDir := filepath.Join(test_utils.ConfigData.Path.DataDir, "offline") + assetsDir := filepath.Join(test_utils.ConfigData.Path.DataDir, "offline", "assets") + + m, err := NewManager(&NewManagerOptions{ + LocalDir: localDir, + AssetDir: assetsDir, + Logger: util.NewLogger(), + MetadataProvider: metadataProvider, + MangaRepository: mangaRepository, + Database: db, + WSEventManager: wsEventManager, + AnilistPlatform: anilistPlatform, + IsOffline: false, + }) + require.NoError(t, err) + + return m +} diff --git a/seanime-2.9.10/internal/local/sync.go b/seanime-2.9.10/internal/local/sync.go new file mode 100644 index 0000000..c40cf76 --- /dev/null +++ b/seanime-2.9.10/internal/local/sync.go @@ -0,0 +1,749 @@ +package local + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/events" + "seanime/internal/library/anime" + "seanime/internal/manga" + "seanime/internal/util" + "seanime/internal/util/result" + "sync" + + "github.com/samber/lo" +) + +// DEVNOTE: The synchronization process is split into 3 parts: +// 1. ManagerImpl.synchronize removes outdated tracked anime & manga, runs Syncer.runDiffs and adds changed tracked anime & manga to the queue. +// 2. The Syncer processes the queue, calling Syncer.synchronizeAnime and Syncer.synchronizeManga for each job. +// 3. Syncer.synchronizeCollections creates a local collection that mirrors the remote collection, containing only the tracked anime & manga. Only called when the queue is emptied. + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type ( + // Syncer will synchronize the anime and manga snapshots in the local database. + // Anytime Manager.Synchronize is called, tracked anime and manga will be added to the queue. + // The queue will synchronize one anime and one manga every X minutes, until it's empty. + // + // Synchronization can fail due to network issues. When it does, the anime or manga will be added to the failed queue. + Syncer struct { + animeJobQueue chan AnimeTask + mangaJobQueue chan MangaTask + + failedAnimeQueue *result.Cache[int, *anilist.AnimeListEntry] + failedMangaQueue *result.Cache[int, *anilist.MangaListEntry] + + trackedAnimeMap map[int]*TrackedMedia + trackedMangaMap map[int]*TrackedMedia + + manager *ManagerImpl + mu sync.RWMutex + + shouldUpdateLocalCollections bool + doneUpdatingLocalCollections chan struct{} + + queueState QueueState + queueStateMu sync.RWMutex + } + + QueueState struct { + AnimeTasks map[int]*QueueMediaTask `json:"animeTasks"` + MangaTasks map[int]*QueueMediaTask `json:"mangaTasks"` + } + + QueueMediaTask struct { + MediaId int `json:"mediaId"` + Image string `json:"image"` + Title string `json:"title"` + Type string `json:"type"` + } + AnimeTask struct { + Diff *AnimeDiffResult + } + MangaTask struct { + Diff *MangaDiffResult + } +) + +func NewQueue(manager *ManagerImpl) *Syncer { + ret := &Syncer{ + animeJobQueue: make(chan AnimeTask, 100), + mangaJobQueue: make(chan MangaTask, 100), + failedAnimeQueue: result.NewCache[int, *anilist.AnimeListEntry](), + failedMangaQueue: result.NewCache[int, *anilist.MangaListEntry](), + shouldUpdateLocalCollections: false, + doneUpdatingLocalCollections: make(chan struct{}, 1), + manager: manager, + mu: sync.RWMutex{}, + queueState: QueueState{ + AnimeTasks: make(map[int]*QueueMediaTask), + MangaTasks: make(map[int]*QueueMediaTask), + }, + queueStateMu: sync.RWMutex{}, + } + + go ret.processAnimeJobs() + go ret.processMangaJobs() + + return ret +} + +func (q *Syncer) processAnimeJobs() { + for job := range q.animeJobQueue { + + q.queueStateMu.Lock() + q.queueState.AnimeTasks[job.Diff.AnimeEntry.Media.ID] = &QueueMediaTask{ + MediaId: job.Diff.AnimeEntry.Media.ID, + Image: job.Diff.AnimeEntry.Media.GetCoverImageSafe(), + Title: job.Diff.AnimeEntry.Media.GetPreferredTitle(), + Type: "anime", + } + q.SendQueueStateToClient() + q.queueStateMu.Unlock() + + q.shouldUpdateLocalCollections = true + q.synchronizeAnime(job.Diff) + + q.queueStateMu.Lock() + delete(q.queueState.AnimeTasks, job.Diff.AnimeEntry.Media.ID) + q.SendQueueStateToClient() + q.queueStateMu.Unlock() + + q.checkAndUpdateLocalCollections() + } +} + +func (q *Syncer) processMangaJobs() { + for job := range q.mangaJobQueue { + + q.queueStateMu.Lock() + q.queueState.MangaTasks[job.Diff.MangaEntry.Media.ID] = &QueueMediaTask{ + MediaId: job.Diff.MangaEntry.Media.ID, + Image: job.Diff.MangaEntry.Media.GetCoverImageSafe(), + Title: job.Diff.MangaEntry.Media.GetPreferredTitle(), + Type: "manga", + } + q.SendQueueStateToClient() + q.queueStateMu.Unlock() + + q.shouldUpdateLocalCollections = true + q.synchronizeManga(job.Diff) + + q.queueStateMu.Lock() + delete(q.queueState.MangaTasks, job.Diff.MangaEntry.Media.ID) + q.SendQueueStateToClient() + q.queueStateMu.Unlock() + + q.checkAndUpdateLocalCollections() + } +} + +// checkAndUpdateLocalCollections will synchronize the local collections once the job queue is emptied. +func (q *Syncer) checkAndUpdateLocalCollections() { + q.mu.Lock() + defer q.mu.Unlock() + + // Check if we need to update the local collections + if q.shouldUpdateLocalCollections { + // Check if both queues are empty + if len(q.animeJobQueue) == 0 && len(q.mangaJobQueue) == 0 { + // Update the local collections + err := q.synchronizeCollections() + if err != nil { + q.manager.logger.Error().Err(err).Msg("local manager: Failed to synchronize collections") + } + q.SendQueueStateToClient() + q.manager.wsEventManager.SendEvent(events.SyncLocalFinished, nil) + q.shouldUpdateLocalCollections = false + select { + case q.doneUpdatingLocalCollections <- struct{}{}: + default: + } + } + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (q *Syncer) GetQueueState() QueueState { + return q.queueState +} + +func (q *Syncer) SendQueueStateToClient() { + q.manager.wsEventManager.SendEvent(events.SyncLocalQueueState, q.GetQueueState()) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// synchronizeCollections should be called after the tracked anime & manga snapshots have been updated. +// The ManagerImpl.animeCollection and ManagerImpl.mangaCollection should be set & up-to-date. +// Instead of modifying the local collections directly, we create new collections that mirror the remote collections, but with up-to-date data. +func (q *Syncer) synchronizeCollections() (err error) { + defer util.HandlePanicInModuleWithError("sync/synchronizeCollections", &err) + + q.manager.loadTrackedMedia() + + // DEVNOTE: "_" prefix = original/remote collection + // We shouldn't modify the remote collection, so making sure we get new pointers + + q.manager.logger.Trace().Msg("local manager: Synchronizing local collections") + + _animeCollection := q.manager.animeCollection.MustGet() + _mangaCollection := q.manager.mangaCollection.MustGet() + + // Get up-to-date snapshots + animeSnapshots, _ := q.manager.localDb.GetAnimeSnapshots() + mangaSnapshots, _ := q.manager.localDb.GetMangaSnapshots() + + animeSnapshotMap := make(map[int]*AnimeSnapshot) + for _, snapshot := range animeSnapshots { + animeSnapshotMap[snapshot.MediaId] = snapshot + } + + mangaSnapshotMap := make(map[int]*MangaSnapshot) + for _, snapshot := range mangaSnapshots { + mangaSnapshotMap[snapshot.MediaId] = snapshot + } + + localAnimeCollection := &anilist.AnimeCollection{ + MediaListCollection: &anilist.AnimeCollection_MediaListCollection{ + Lists: []*anilist.AnimeCollection_MediaListCollection_Lists{}, + }, + } + + localMangaCollection := &anilist.MangaCollection{ + MediaListCollection: &anilist.MangaCollection_MediaListCollection{ + Lists: []*anilist.MangaCollection_MediaListCollection_Lists{}, + }, + } + + // Re-create all anime collection lists, without entries + for _, _animeList := range _animeCollection.MediaListCollection.GetLists() { + if _animeList.GetStatus() == nil { + continue + } + list := &anilist.AnimeCollection_MediaListCollection_Lists{ + Status: ToNewPointer(_animeList.Status), + Name: ToNewPointer(_animeList.Name), + IsCustomList: ToNewPointer(_animeList.IsCustomList), + Entries: []*anilist.AnimeListEntry{}, + } + localAnimeCollection.MediaListCollection.Lists = append(localAnimeCollection.MediaListCollection.Lists, list) + } + + // Re-create all manga collection lists, without entries + for _, _mangaList := range _mangaCollection.MediaListCollection.GetLists() { + if _mangaList.GetStatus() == nil { + continue + } + list := &anilist.MangaCollection_MediaListCollection_Lists{ + Status: ToNewPointer(_mangaList.Status), + Name: ToNewPointer(_mangaList.Name), + IsCustomList: ToNewPointer(_mangaList.IsCustomList), + Entries: []*anilist.MangaListEntry{}, + } + localMangaCollection.MediaListCollection.Lists = append(localMangaCollection.MediaListCollection.Lists, list) + } + + //visited := make(map[int]struct{}) + + if len(animeSnapshots) > 0 { + // Create local anime collection + for _, _animeList := range _animeCollection.MediaListCollection.GetLists() { + if _animeList.GetStatus() == nil { + continue + } + for _, _animeEntry := range _animeList.GetEntries() { + // Check if the anime is tracked + _, found := q.trackedAnimeMap[_animeEntry.GetMedia().GetID()] + if !found { + continue + } + // Get the anime snapshot + snapshot, found := animeSnapshotMap[_animeEntry.GetMedia().GetID()] + if !found { + continue + } + + // Add the anime to the right list + for _, list := range localAnimeCollection.MediaListCollection.GetLists() { + if list.GetStatus() == nil { + continue + } + + if *list.GetStatus() != *_animeList.GetStatus() { + continue + } + + editedAnime := BaseAnimeDeepCopy(_animeEntry.GetMedia()) + editedAnime.BannerImage = FormatAssetUrl(snapshot.MediaId, snapshot.BannerImagePath) + editedAnime.CoverImage = &anilist.BaseAnime_CoverImage{ + ExtraLarge: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), + Large: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), + Medium: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), + Color: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), + } + + var startedAt *anilist.AnimeCollection_MediaListCollection_Lists_Entries_StartedAt + if _animeEntry.GetStartedAt() != nil { + startedAt = &anilist.AnimeCollection_MediaListCollection_Lists_Entries_StartedAt{ + Year: ToNewPointer(_animeEntry.GetStartedAt().GetYear()), + Month: ToNewPointer(_animeEntry.GetStartedAt().GetMonth()), + Day: ToNewPointer(_animeEntry.GetStartedAt().GetDay()), + } + } + + var completedAt *anilist.AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt + if _animeEntry.GetCompletedAt() != nil { + completedAt = &anilist.AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt{ + Year: ToNewPointer(_animeEntry.GetCompletedAt().GetYear()), + Month: ToNewPointer(_animeEntry.GetCompletedAt().GetMonth()), + Day: ToNewPointer(_animeEntry.GetCompletedAt().GetDay()), + } + } + + entry := &anilist.AnimeListEntry{ + ID: _animeEntry.GetID(), + Score: ToNewPointer(_animeEntry.GetScore()), + Progress: ToNewPointer(_animeEntry.GetProgress()), + Status: ToNewPointer(_animeEntry.GetStatus()), + Notes: ToNewPointer(_animeEntry.GetNotes()), + Repeat: ToNewPointer(_animeEntry.GetRepeat()), + Private: ToNewPointer(_animeEntry.GetPrivate()), + StartedAt: startedAt, + CompletedAt: completedAt, + Media: editedAnime, + } + list.Entries = append(list.Entries, entry) + break + } + + } + } + } + + if len(mangaSnapshots) > 0 { + // Create local manga collection + for _, _mangaList := range _mangaCollection.MediaListCollection.GetLists() { + if _mangaList.GetStatus() == nil { + continue + } + for _, _mangaEntry := range _mangaList.GetEntries() { + // Check if the manga is tracked + _, found := q.trackedMangaMap[_mangaEntry.GetMedia().GetID()] + if !found { + continue + } + // Get the manga snapshot + snapshot, found := mangaSnapshotMap[_mangaEntry.GetMedia().GetID()] + if !found { + continue + } + + // Add the manga to the right list + for _, list := range localMangaCollection.MediaListCollection.GetLists() { + if list.GetStatus() == nil { + continue + } + + if *list.GetStatus() != *_mangaList.GetStatus() { + continue + } + + editedManga := BaseMangaDeepCopy(_mangaEntry.GetMedia()) + editedManga.BannerImage = FormatAssetUrl(snapshot.MediaId, snapshot.BannerImagePath) + editedManga.CoverImage = &anilist.BaseManga_CoverImage{ + ExtraLarge: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), + Large: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), + Medium: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), + Color: FormatAssetUrl(snapshot.MediaId, snapshot.CoverImagePath), + } + + var startedAt *anilist.MangaCollection_MediaListCollection_Lists_Entries_StartedAt + if _mangaEntry.GetStartedAt() != nil { + startedAt = &anilist.MangaCollection_MediaListCollection_Lists_Entries_StartedAt{ + Year: ToNewPointer(_mangaEntry.GetStartedAt().GetYear()), + Month: ToNewPointer(_mangaEntry.GetStartedAt().GetMonth()), + Day: ToNewPointer(_mangaEntry.GetStartedAt().GetDay()), + } + } + + var completedAt *anilist.MangaCollection_MediaListCollection_Lists_Entries_CompletedAt + if _mangaEntry.GetCompletedAt() != nil { + completedAt = &anilist.MangaCollection_MediaListCollection_Lists_Entries_CompletedAt{ + Year: ToNewPointer(_mangaEntry.GetCompletedAt().GetYear()), + Month: ToNewPointer(_mangaEntry.GetCompletedAt().GetMonth()), + Day: ToNewPointer(_mangaEntry.GetCompletedAt().GetDay()), + } + } + + entry := &anilist.MangaListEntry{ + ID: _mangaEntry.GetID(), + Score: ToNewPointer(_mangaEntry.GetScore()), + Progress: ToNewPointer(_mangaEntry.GetProgress()), + Status: ToNewPointer(_mangaEntry.GetStatus()), + Notes: ToNewPointer(_mangaEntry.GetNotes()), + Repeat: ToNewPointer(_mangaEntry.GetRepeat()), + Private: ToNewPointer(_mangaEntry.GetPrivate()), + StartedAt: startedAt, + CompletedAt: completedAt, + Media: editedManga, + } + list.Entries = append(list.Entries, entry) + break + } + + } + } + } + + // Save the local collections + err = q.manager.localDb.SaveAnimeCollection(localAnimeCollection) + if err != nil { + return err + } + + err = q.manager.localDb.SaveMangaCollection(localMangaCollection) + if err != nil { + return err + } + + q.manager.loadLocalAnimeCollection() + q.manager.loadLocalMangaCollection() + + q.manager.logger.Debug().Msg("local manager: Synchronized local collections") + + return nil +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +func (q *Syncer) sendAnimeToFailedQueue(entry *anilist.AnimeListEntry) { + q.failedAnimeQueue.Set(entry.Media.ID, entry) +} + +func (q *Syncer) sendMangaToFailedQueue(entry *anilist.MangaListEntry) { + q.failedMangaQueue.Set(entry.Media.ID, entry) +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +func (q *Syncer) refreshCollections() { + + q.manager.logger.Trace().Msg("local manager: Refreshing collections") + + if len(q.animeJobQueue) > 0 || len(q.mangaJobQueue) > 0 { + q.manager.logger.Trace().Msg("local manager: Skipping refreshCollections, job queues are not empty") + return + } + + q.shouldUpdateLocalCollections = true + q.checkAndUpdateLocalCollections() +} + +// runDiffs runs the diffing process to find outdated anime & manga. +// The diffs are then added to the job queues for synchronization. +func (q *Syncer) runDiffs( + trackedAnimeMap map[int]*TrackedMedia, + trackedAnimeSnapshotMap map[int]*AnimeSnapshot, + trackedMangaMap map[int]*TrackedMedia, + trackedMangaSnapshotMap map[int]*MangaSnapshot, + localFiles []*anime.LocalFile, + downloadedChapterContainers []*manga.ChapterContainer, +) { + q.mu.Lock() + defer q.mu.Unlock() + + q.manager.logger.Trace().Msg("local manager: Running diffs") + + if q.manager.animeCollection.IsAbsent() { + q.manager.logger.Error().Msg("local manager: Cannot get diffs, anime collection is absent") + return + } + + if q.manager.mangaCollection.IsAbsent() { + q.manager.logger.Error().Msg("local manager: Cannot get diffs, manga collection is absent") + return + } + + if len(q.animeJobQueue) > 0 || len(q.mangaJobQueue) > 0 { + q.manager.logger.Trace().Msg("local manager: Skipping diffs, job queues are not empty") + return + } + + diff := &Diff{ + Logger: q.manager.logger, + } + + wg := sync.WaitGroup{} + wg.Add(2) + + var animeDiffs map[int]*AnimeDiffResult + + go func() { + animeDiffs = diff.GetAnimeDiffs(GetAnimeDiffOptions{ + Collection: q.manager.animeCollection.MustGet(), + LocalCollection: q.manager.localAnimeCollection, + LocalFiles: localFiles, + TrackedAnime: trackedAnimeMap, + Snapshots: trackedAnimeSnapshotMap, + }) + wg.Done() + //q.manager.logger.Trace().Msg("local manager: Finished getting anime diffs") + }() + + var mangaDiffs map[int]*MangaDiffResult + + go func() { + mangaDiffs = diff.GetMangaDiffs(GetMangaDiffOptions{ + Collection: q.manager.mangaCollection.MustGet(), + LocalCollection: q.manager.localMangaCollection, + DownloadedChapterContainers: downloadedChapterContainers, + TrackedManga: trackedMangaMap, + Snapshots: trackedMangaSnapshotMap, + }) + wg.Done() + //q.manager.logger.Trace().Msg("local manager: Finished getting manga diffs") + }() + + wg.Wait() + + // Add the diffs to be synced asynchronously + go func() { + q.manager.logger.Trace().Int("animeJobs", len(animeDiffs)).Int("mangaJobs", len(mangaDiffs)).Msg("local manager: Adding diffs to the job queues") + + for _, i := range animeDiffs { + q.animeJobQueue <- AnimeTask{Diff: i} + } + for _, i := range mangaDiffs { + q.mangaJobQueue <- MangaTask{Diff: i} + } + + if len(animeDiffs) == 0 && len(mangaDiffs) == 0 { + q.manager.logger.Trace().Msg("local manager: No diffs found") + //q.refreshCollections() + } + }() + + // Done + q.manager.logger.Trace().Msg("local manager: Done running diffs") +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + +// synchronizeAnime creates or updates the anime snapshot in the local database. +// The anime should be tracked. +// - If the anime has no local files, it will be removed entirely from the local database. +// - If the anime has local files, we create or update the snapshot. +func (q *Syncer) synchronizeAnime(diff *AnimeDiffResult) { + defer util.HandlePanicInModuleThen("sync/synchronizeAnime", func() {}) + + entry := diff.AnimeEntry + + if entry == nil { + return + } + + q.manager.logger.Trace().Msgf("local manager: Starting synchronization of anime %d, diff type: %+v", entry.Media.ID, diff.DiffType) + + lfs := lo.Filter(q.manager.localFiles, func(f *anime.LocalFile, _ int) bool { + return f.MediaId == entry.Media.ID + }) + + // If the anime (which is tracked) has no local files, remove it entirely from the local database + if len(lfs) == 0 { + q.manager.logger.Warn().Msgf("local manager: No local files found for anime %d, removing from the local database", entry.Media.ID) + _ = q.manager.removeAnime(entry.Media.ID) + return + } + + var animeMetadata *metadata.AnimeMetadata + var metadataWrapper metadata.AnimeMetadataWrapper + if diff.DiffType == DiffTypeMissing || diff.DiffType == DiffTypeMetadata { + // Get the anime metadata + var err error + animeMetadata, err = q.manager.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, entry.Media.ID) + if err != nil { + q.sendAnimeToFailedQueue(entry) + q.manager.logger.Error().Err(err).Msgf("local manager: Failed to get metadata for anime %d", entry.Media.ID) + return + } + + metadataWrapper = q.manager.metadataProvider.GetAnimeMetadataWrapper(diff.AnimeEntry.Media, animeMetadata) + } + + // + // The snapshot is missing + // + if diff.DiffType == DiffTypeMissing && animeMetadata != nil { + bannerImage, coverImage, episodeImagePaths, ok := DownloadAnimeImages(q.manager.logger, q.manager.localAssetsDir, entry, animeMetadata, metadataWrapper, lfs) + if !ok { + q.sendAnimeToFailedQueue(entry) + return + } + + // Create a new snapshot + snapshot := &AnimeSnapshot{ + MediaId: entry.GetMedia().GetID(), + AnimeMetadata: LocalAnimeMetadata(*animeMetadata), + BannerImagePath: bannerImage, + CoverImagePath: coverImage, + EpisodeImagePaths: episodeImagePaths, + ReferenceKey: GetAnimeReferenceKey(entry.GetMedia(), q.manager.localFiles), + } + + // Save the snapshot + err := q.manager.localDb.SaveAnimeSnapshot(snapshot) + if err != nil { + q.sendAnimeToFailedQueue(entry) + q.manager.logger.Error().Err(err).Msgf("local manager: Failed to save anime snapshot for anime %d", entry.GetMedia().GetID()) + } + return + } + + // + // The snapshot metadata is outdated (local files have changed) + // Update the anime metadata & download the new episode images if needed + // + if diff.DiffType == DiffTypeMetadata && diff.AnimeSnapshot != nil && animeMetadata != nil { + + snapshot := *diff.AnimeSnapshot + snapshot.AnimeMetadata = LocalAnimeMetadata(*animeMetadata) + snapshot.ReferenceKey = GetAnimeReferenceKey(entry.GetMedia(), q.manager.localFiles) + + // Get the current episode image URLs + currentEpisodeImageUrls := make(map[string]string) + for episodeNum, episode := range animeMetadata.Episodes { + if episode.Image == "" { + continue + } + currentEpisodeImageUrls[episodeNum] = episode.Image + } + + // Get the episode image URLs that we need to download (i.e. the ones that are not in the snapshot) + episodeImageUrlsToDownload := make(map[string]string) + // For each current episode image URL, check if the key (episode number) is in the snapshot + for episodeNum, episodeImageUrl := range currentEpisodeImageUrls { + if _, found := snapshot.EpisodeImagePaths[episodeNum]; !found { + episodeImageUrlsToDownload[episodeNum] = episodeImageUrl + } + } + + // Download the episode images if needed + if len(episodeImageUrlsToDownload) > 0 { + // Download only the episode images that we need to download + episodeImagePaths, ok := DownloadAnimeEpisodeImages(q.manager.logger, q.manager.localAssetsDir, entry.GetMedia().GetID(), episodeImageUrlsToDownload) + if !ok { + // DownloadAnimeEpisodeImages will log the error + q.sendAnimeToFailedQueue(entry) + return + } + // Update the snapshot by adding the new episode images + for episodeNum, episodeImagePath := range episodeImagePaths { + snapshot.EpisodeImagePaths[episodeNum] = episodeImagePath + } + } + + // Save the snapshot + err := q.manager.localDb.SaveAnimeSnapshot(&snapshot) + if err != nil { + q.sendAnimeToFailedQueue(entry) + q.manager.logger.Error().Err(err).Msgf("local manager: Failed to save anime snapshot for anime %d", entry.GetMedia().GetID()) + } + return + } + + // The snapshot is up-to-date + return +} + +// synchronizeManga creates or updates the manga snapshot in the local database. +// We know that the manga is tracked. +// - If the manga has no chapter containers, it will be removed entirely from the local database. +// - If the manga has chapter containers, we create or update the snapshot. +func (q *Syncer) synchronizeManga(diff *MangaDiffResult) { + defer util.HandlePanicInModuleThen("sync/synchronizeManga", func() {}) + + entry := diff.MangaEntry + + if entry == nil { + return + } + + q.manager.logger.Trace().Msgf("local manager: Starting synchronization of manga %d, diff type: %+v", entry.GetMedia().GetID(), diff.DiffType) + + if q.manager.mangaCollection.IsAbsent() { + return + } + + eContainers := make([]*manga.ChapterContainer, 0) + + // Get the manga + listEntry, ok := q.manager.mangaCollection.MustGet().GetListEntryFromMangaId(entry.GetMedia().GetID()) + if !ok { + q.manager.logger.Error().Msgf("local manager: Failed to get manga") + return + } + + if listEntry.GetStatus() == nil { + return + } + + // Get all chapter containers for this manga + // A manga entry can have multiple chapter containers due to different sources + for _, c := range q.manager.downloadedChapterContainers { + if c.MediaId == entry.GetMedia().GetID() { + eContainers = append(eContainers, c) + } + } + + // If there are no chapter containers (they may have been deleted), remove the manga from the local database + if len(eContainers) == 0 { + _ = q.manager.removeManga(entry.GetMedia().GetID()) + return + } + + if diff.DiffType == DiffTypeMissing { + bannerImage, coverImage, ok := DownloadMangaImages(q.manager.logger, q.manager.localAssetsDir, entry) + if !ok { + q.sendMangaToFailedQueue(entry) + return + } + + // Create a new snapshot + snapshot := &MangaSnapshot{ + MediaId: entry.GetMedia().GetID(), + ChapterContainers: eContainers, + BannerImagePath: bannerImage, + CoverImagePath: coverImage, + ReferenceKey: GetMangaReferenceKey(entry.GetMedia(), eContainers), + } + + // Save the snapshot + err := q.manager.localDb.SaveMangaSnapshot(snapshot) + if err != nil { + q.sendMangaToFailedQueue(entry) + q.manager.logger.Error().Err(err).Msgf("local manager: Failed to save manga snapshot for manga %d", entry.GetMedia().GetID()) + } + return + } + + if diff.DiffType == DiffTypeMetadata && diff.MangaSnapshot != nil { + snapshot := *diff.MangaSnapshot + + // Update the snapshot + snapshot.ChapterContainers = eContainers + snapshot.ReferenceKey = GetMangaReferenceKey(entry.GetMedia(), eContainers) + + // Save the snapshot + err := q.manager.localDb.SaveMangaSnapshot(&snapshot) + if err != nil { + q.sendMangaToFailedQueue(entry) + q.manager.logger.Error().Err(err).Msgf("local manager: Failed to save manga snapshot for manga %d", entry.GetMedia().GetID()) + } + return + } + + // The snapshot is up-to-date + return +} diff --git a/seanime-2.9.10/internal/local/sync_helpers.go b/seanime-2.9.10/internal/local/sync_helpers.go new file mode 100644 index 0000000..25b2c1b --- /dev/null +++ b/seanime-2.9.10/internal/local/sync_helpers.go @@ -0,0 +1,243 @@ +package local + +import ( + "fmt" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/library/anime" + "seanime/internal/util" + "seanime/internal/util/image_downloader" + + "github.com/goccy/go-json" + "github.com/rs/zerolog" +) + +// BaseAnimeDeepCopy creates a deep copy of the given base anime struct. +func BaseAnimeDeepCopy(animeCollection *anilist.BaseAnime) *anilist.BaseAnime { + bytes, err := json.Marshal(animeCollection) + if err != nil { + return nil + } + + deepCopy := &anilist.BaseAnime{} + err = json.Unmarshal(bytes, deepCopy) + if err != nil { + return nil + } + + deepCopy.NextAiringEpisode = nil + + return deepCopy +} + +// BaseMangaDeepCopy creates a deep copy of the given base manga struct. +func BaseMangaDeepCopy(animeCollection *anilist.BaseManga) *anilist.BaseManga { + bytes, err := json.Marshal(animeCollection) + if err != nil { + return nil + } + + deepCopy := &anilist.BaseManga{} + err = json.Unmarshal(bytes, deepCopy) + if err != nil { + return nil + } + + return deepCopy +} + +func ToNewPointer[A any](a *A) *A { + if a == nil { + return nil + } + t := *a + return &t +} + +func IntPointerValue[A int](a *A) A { + if a == nil { + return 0 + } + return *a +} + +func Float64PointerValue[A float64](a *A) A { + if a == nil { + return 0 + } + return *a +} + +func MediaListStatusPointerValue(a *anilist.MediaListStatus) anilist.MediaListStatus { + if a == nil { + return anilist.MediaListStatusPlanning + } + return *a +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// DownloadAnimeEpisodeImages saves the episode images for the given anime media ID. +// This should be used to update the episode images for an anime, e.g. after a new episode is released. +// +// The episodeImageUrls map should be in the format of {"1": "url1", "2": "url2", ...}, where the key is the episode number (defined in metadata.AnimeMetadata). +// It will download the images to the `/` directory and return a map of episode numbers to the downloaded image filenames. +// +// DownloadAnimeEpisodeImages(logger, "path/to/datadir/local/assets", 123, map[string]string{"1": "url1", "2": "url2"}) +// -> map[string]string{"1": "filename1.jpg", "2": "filename2.jpg"} +func DownloadAnimeEpisodeImages(logger *zerolog.Logger, assetsDir string, mId int, episodeImageUrls map[string]string) (map[string]string, bool) { + defer util.HandlePanicInModuleThen("sync/DownloadAnimeEpisodeImages", func() {}) + + logger.Trace().Msgf("local manager: Downloading episode images for anime %d", mId) + + // e.g. /path/to/datadir/local/assets/123 + mediaAssetPath := filepath.Join(assetsDir, fmt.Sprintf("%d", mId)) + imageDownloader := image_downloader.NewImageDownloader(mediaAssetPath, logger) + // Download the images + imgUrls := make([]string, 0, len(episodeImageUrls)) + for _, episodeImage := range episodeImageUrls { + if episodeImage == "" { + continue + } + imgUrls = append(imgUrls, episodeImage) + } + + err := imageDownloader.DownloadImages(imgUrls) + if err != nil { + logger.Error().Err(err).Msgf("local manager: Failed to download images for anime %d", mId) + return nil, false + } + + images, err := imageDownloader.GetImageFilenamesByUrls(imgUrls) + if err != nil { + logger.Error().Err(err).Msgf("local manager: Failed to get image filenames for anime %d", mId) + return nil, false + } + + episodeImagePaths := make(map[string]string) + for episodeNum, episodeImage := range episodeImageUrls { + episodeImagePaths[episodeNum] = images[episodeImage] + } + + return episodeImagePaths, true +} + +// DownloadAnimeImages saves the banner, cover, and episode images for the given anime entry. +// This should be used to download the images for an anime for the first time. +// +// It will download the images to the `/` directory and return the filenames of the banner, cover, and episode images. +// +// DownloadAnimeImages(logger, "path/to/datadir/local/assets", entry, animeMetadata) +// -> "banner.jpg", "cover.jpg", map[string]string{"1": "filename1.jpg", "2": "filename2.jpg"} +func DownloadAnimeImages( + logger *zerolog.Logger, + assetsDir string, + entry *anilist.AnimeListEntry, + animeMetadata *metadata.AnimeMetadata, // This is updated + metadataWrapper metadata.AnimeMetadataWrapper, + lfs []*anime.LocalFile, +) (string, string, map[string]string, bool) { + defer util.HandlePanicInModuleThen("sync/DownloadAnimeImages", func() {}) + + logger.Trace().Msgf("local manager: Downloading images for anime %d", entry.Media.ID) + // e.g. /datadir/local/assets/123 + mediaAssetPath := filepath.Join(assetsDir, fmt.Sprintf("%d", entry.Media.ID)) + imageDownloader := image_downloader.NewImageDownloader(mediaAssetPath, logger) + // Download the images + ogBannerImage := entry.GetMedia().GetBannerImageSafe() + ogCoverImage := entry.GetMedia().GetCoverImageSafe() + + imgUrls := []string{ogBannerImage, ogCoverImage} + + lfMap := make(map[string]*anime.LocalFile) + for _, lf := range lfs { + lfMap[lf.Metadata.AniDBEpisode] = lf + } + + ogEpisodeImages := make(map[string]string) + for episodeNum, episode := range animeMetadata.Episodes { + // Check if the episode is in the local files + if _, ok := lfMap[episodeNum]; !ok { + continue + } + + episodeInt, ok := util.StringToInt(episodeNum) + if !ok { + ogEpisodeImages[episodeNum] = episode.Image + imgUrls = append(imgUrls, episode.Image) + continue + } + + epMetadata := metadataWrapper.GetEpisodeMetadata(episodeInt) + episode = &epMetadata + + ogEpisodeImages[episodeNum] = episode.Image + imgUrls = append(imgUrls, episode.Image) + } + + err := imageDownloader.DownloadImages(imgUrls) + if err != nil { + logger.Error().Err(err).Msgf("local manager: Failed to download images for anime %d", entry.Media.ID) + return "", "", nil, false + } + + images, err := imageDownloader.GetImageFilenamesByUrls(imgUrls) + if err != nil { + logger.Error().Err(err).Msgf("local manager: Failed to get image filenames for anime %d", entry.Media.ID) + return "", "", nil, false + } + + bannerImage := images[ogBannerImage] + coverImage := images[ogCoverImage] + episodeImagePaths := make(map[string]string) + for episodeNum, episodeImage := range ogEpisodeImages { + if episodeImage == "" { + continue + } + episodeImagePaths[episodeNum] = images[episodeImage] + } + + logger.Debug().Msgf("local manager: Stored images for anime %d, %+v, %+v, episode images: %+v", entry.Media.ID, bannerImage, coverImage, len(episodeImagePaths)) + + return bannerImage, coverImage, episodeImagePaths, true +} + +// DownloadMangaImages saves the banner and cover images for the given manga entry. +// This should be used to download the images for a manga for the first time. +// +// It will download the images to the `/` directory and return the filenames of the banner and cover images. +// +// DownloadMangaImages(logger, "path/to/datadir/local/assets", entry) +// -> "banner.jpg", "cover.jpg" +func DownloadMangaImages(logger *zerolog.Logger, assetsDir string, entry *anilist.MangaListEntry) (string, string, bool) { + logger.Trace().Msgf("local manager: Downloading images for manga %d", entry.Media.ID) + + // e.g. /datadir/local/assets/123 + mediaAssetPath := filepath.Join(assetsDir, fmt.Sprintf("%d", entry.Media.ID)) + imageDownloader := image_downloader.NewImageDownloader(mediaAssetPath, logger) + // Download the images + ogBannerImage := entry.GetMedia().GetBannerImageSafe() + ogCoverImage := entry.GetMedia().GetCoverImageSafe() + + imgUrls := []string{ogBannerImage, ogCoverImage} + + err := imageDownloader.DownloadImages(imgUrls) + if err != nil { + logger.Error().Err(err).Msgf("local manager: Failed to download images for anime %d", entry.Media.ID) + return "", "", false + } + + images, err := imageDownloader.GetImageFilenamesByUrls(imgUrls) + if err != nil { + logger.Error().Err(err).Msgf("local manager: Failed to get image filenames for anime %d", entry.Media.ID) + return "", "", false + } + + bannerImage := images[ogBannerImage] + coverImage := images[ogCoverImage] + + logger.Debug().Msgf("local manager: Stored images for manga %d, %+v, %+v", entry.Media.ID, bannerImage, coverImage) + + return bannerImage, coverImage, true +} diff --git a/seanime-2.9.10/internal/local/sync_test.go b/seanime-2.9.10/internal/local/sync_test.go new file mode 100644 index 0000000..f18227d --- /dev/null +++ b/seanime-2.9.10/internal/local/sync_test.go @@ -0,0 +1,136 @@ +package local + +import ( + "errors" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/database/db" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + "time" + + "github.com/samber/lo" + "github.com/stretchr/testify/require" +) + +func testSetupManager(t *testing.T) (Manager, *anilist.AnimeCollection, *anilist.MangaCollection) { + + logger := util.NewLogger() + + anilistClient := anilist.NewAnilistClient(test_utils.ConfigData.Provider.AnilistJwt) + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + anilistPlatform.SetUsername(test_utils.ConfigData.Provider.AnilistUsername) + animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), true) + require.NoError(t, err) + mangaCollection, err := anilistPlatform.GetMangaCollection(t.Context(), true) + require.NoError(t, err) + + database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger) + require.NoError(t, err) + + manager := GetMockManager(t, database) + + manager.SetAnimeCollection(animeCollection) + manager.SetMangaCollection(mangaCollection) + + return manager, animeCollection, mangaCollection +} + +func TestSync2(t *testing.T) { + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t, test_utils.Anilist()) + + manager, animeCollection, _ := testSetupManager(t) + + err := manager.TrackAnime(130003) // Bocchi the rock + if err != nil && !errors.Is(err, ErrAlreadyTracked) { + require.NoError(t, err) + } + err = manager.TrackAnime(10800) // Chihayafuru + if err != nil && !errors.Is(err, ErrAlreadyTracked) { + require.NoError(t, err) + } + err = manager.TrackAnime(171457) // Make Heroine ga Oosugiru! + if err != nil && !errors.Is(err, ErrAlreadyTracked) { + require.NoError(t, err) + } + err = manager.TrackManga(101517) // JJK + if err != nil && !errors.Is(err, ErrAlreadyTracked) { + require.NoError(t, err) + } + + err = manager.SynchronizeLocal() + require.NoError(t, err) + + select { + case <-manager.GetSyncer().doneUpdatingLocalCollections: + util.Spew(manager.GetLocalAnimeCollection().MustGet()) + util.Spew(manager.GetLocalMangaCollection().MustGet()) + break + case <-time.After(10 * time.Second): + t.Log("Timeout") + break + } + + anilist.TestModifyAnimeCollectionEntry(animeCollection, 130003, anilist.TestModifyAnimeCollectionEntryInput{ + Status: lo.ToPtr(anilist.MediaListStatusCompleted), + Progress: lo.ToPtr(12), // Mock progress + }) + + fmt.Println("================================================================================================") + fmt.Println("================================================================================================") + + err = manager.SynchronizeLocal() + require.NoError(t, err) + + select { + case <-manager.GetSyncer().doneUpdatingLocalCollections: + util.Spew(manager.GetLocalAnimeCollection().MustGet()) + util.Spew(manager.GetLocalMangaCollection().MustGet()) + break + case <-time.After(10 * time.Second): + t.Log("Timeout") + break + } + +} + +func TestSync(t *testing.T) { + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t, test_utils.Anilist()) + + manager, _, _ := testSetupManager(t) + + err := manager.TrackAnime(130003) // Bocchi the rock + if err != nil && !errors.Is(err, ErrAlreadyTracked) { + require.NoError(t, err) + } + err = manager.TrackAnime(10800) // Chihayafuru + if err != nil && !errors.Is(err, ErrAlreadyTracked) { + require.NoError(t, err) + } + err = manager.TrackAnime(171457) // Make Heroine ga Oosugiru! + if err != nil && !errors.Is(err, ErrAlreadyTracked) { + require.NoError(t, err) + } + err = manager.TrackManga(101517) // JJK + if err != nil && !errors.Is(err, ErrAlreadyTracked) { + require.NoError(t, err) + } + + err = manager.SynchronizeLocal() + require.NoError(t, err) + + select { + case <-manager.GetSyncer().doneUpdatingLocalCollections: + util.Spew(manager.GetLocalAnimeCollection().MustGet()) + util.Spew(manager.GetLocalMangaCollection().MustGet()) + break + case <-time.After(10 * time.Second): + t.Log("Timeout") + break + } + +} diff --git a/seanime-2.9.10/internal/local/sync_util.go b/seanime-2.9.10/internal/local/sync_util.go new file mode 100644 index 0000000..de448a0 --- /dev/null +++ b/seanime-2.9.10/internal/local/sync_util.go @@ -0,0 +1,13 @@ +package local + +import "fmt" + +// FormatAssetUrl formats the asset URL for the given mediaId and filename. +// +// FormatAssetUrl(123, "cover.jpg") -> "{{LOCAL_ASSETS}}/123/cover.jpg" +func FormatAssetUrl(mediaId int, filename string) *string { + // {{LOCAL_ASSETS}} should be replaced in the client with the actual URL + // e.g. http:///local_assets/123/cover.jpg + a := fmt.Sprintf("{{LOCAL_ASSETS}}/%d/%s", mediaId, filename) + return &a +} diff --git a/seanime-2.9.10/internal/manga/chapter_container.go b/seanime-2.9.10/internal/manga/chapter_container.go new file mode 100644 index 0000000..03e5c88 --- /dev/null +++ b/seanime-2.9.10/internal/manga/chapter_container.go @@ -0,0 +1,497 @@ +package manga + +import ( + "cmp" + "errors" + "fmt" + "math" + "os" + "seanime/internal/api/anilist" + "seanime/internal/extension" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/hook" + manga_providers "seanime/internal/manga/providers" + "seanime/internal/util" + "seanime/internal/util/comparison" + "seanime/internal/util/result" + "slices" + "strconv" + "strings" + "sync" + + "github.com/samber/lo" +) + +type ( + // ChapterContainer is used to display the list of chapters from a provider in the client. + // It is cached in a unique file cache bucket with a key of the format: {provider}${mediaId} + ChapterContainer struct { + MediaId int `json:"mediaId"` + Provider string `json:"provider"` + Chapters []*hibikemanga.ChapterDetails `json:"chapters"` + } +) + +func getMangaChapterContainerCacheKey(provider string, mediaId int) string { + return fmt.Sprintf("%s$%d", provider, mediaId) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type GetMangaChapterContainerOptions struct { + Provider string + MediaId int + Titles []*string + Year int +} + +// GetMangaChapterContainer returns the ChapterContainer for a manga entry based on the provider. +// If it isn't cached, it will search for the manga, create a ChapterContainer and cache it. +func (r *Repository) GetMangaChapterContainer(opts *GetMangaChapterContainerOptions) (ret *ChapterContainer, err error) { + defer util.HandlePanicInModuleWithError("manga/GetMangaChapterContainer", &err) + + provider := opts.Provider + mediaId := opts.MediaId + titles := opts.Titles + + providerExtension, ok := extension.GetExtension[extension.MangaProviderExtension](r.providerExtensionBank, provider) + if !ok { + r.logger.Error().Str("provider", provider).Msg("manga: Provider not found") + return nil, errors.New("manga: Provider not found") + } + + // DEVNOTE: Local chapters can be cached + localProvider, isLocalProvider := providerExtension.GetProvider().(*manga_providers.Local) + + // Set the source directory for local provider + if isLocalProvider && r.settings.Manga.LocalSourceDirectory != "" { + localProvider.SetSourceDirectory(r.settings.Manga.LocalSourceDirectory) + } + + r.logger.Trace(). + Str("provider", provider). + Int("mediaId", mediaId). + Msgf("manga: Getting chapters") + + chapterContainerKey := getMangaChapterContainerCacheKey(provider, mediaId) + + // +---------------------+ + // | Hook event | + // +---------------------+ + + // Trigger hook event + reqEvent := &MangaChapterContainerRequestedEvent{ + Provider: provider, + MediaId: mediaId, + Titles: titles, + Year: opts.Year, + ChapterContainer: &ChapterContainer{ + MediaId: mediaId, + Provider: provider, + Chapters: []*hibikemanga.ChapterDetails{}, + }, + } + err = hook.GlobalHookManager.OnMangaChapterContainerRequested().Trigger(reqEvent) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Exception occurred while triggering hook event") + return nil, fmt.Errorf("manga: Error in hook, %w", err) + } + + // Default prevented, return the chapter container + if reqEvent.DefaultPrevented { + if reqEvent.ChapterContainer == nil { + return nil, fmt.Errorf("manga: No chapter container returned by hook event") + } + return reqEvent.ChapterContainer, nil + } + + // +---------------------+ + // | Cache | + // +---------------------+ + + var container *ChapterContainer + containerBucket := r.getFcProviderBucket(provider, mediaId, bucketTypeChapter) + + // Check if the container is in the cache + if found, _ := r.fileCacher.Get(containerBucket, chapterContainerKey, &container); found { + r.logger.Info().Str("bucket", containerBucket.Name()).Msg("manga: Chapter Container Cache HIT") + + // Trigger hook event + ev := &MangaChapterContainerEvent{ + ChapterContainer: container, + } + err = hook.GlobalHookManager.OnMangaChapterContainer().Trigger(ev) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Exception occurred while triggering hook event") + } + container = ev.ChapterContainer + + return container, nil + } + + // Delete the map cache + mangaLatestChapterNumberMap.Delete(ChapterCountMapCacheKey) + + var mangaId string + + // +---------------------+ + // | Database | + // +---------------------+ + + // Search for the mapping in the database + mapping, found := r.db.GetMangaMapping(provider, mediaId) + if found { + r.logger.Debug().Str("mangaId", mapping.MangaID).Msg("manga: Using manual mapping") + mangaId = mapping.MangaID + } + + if mangaId == "" { + // +---------------------+ + // | Search | + // +---------------------+ + + r.logger.Trace().Msg("manga: Searching for manga") + + if titles == nil { + return nil, ErrNoTitlesProvided + } + + titles = lo.Filter(titles, func(title *string, _ int) bool { + return util.IsMostlyLatinString(*title) + }) + + var searchRes []*hibikemanga.SearchResult + + var err error + for _, title := range titles { + var _searchRes []*hibikemanga.SearchResult + + _searchRes, err = providerExtension.GetProvider().Search(hibikemanga.SearchOptions{ + Query: *title, + Year: opts.Year, + }) + if err == nil { + + HydrateSearchResultSearchRating(_searchRes, title) + + searchRes = append(searchRes, _searchRes...) + } else { + r.logger.Warn().Err(err).Msg("manga: Search failed") + } + } + + if len(searchRes) == 0 { + r.logger.Error().Msg("manga: No search results found") + if err != nil { + return nil, fmt.Errorf("%w, %w", ErrNoResults, err) + } else { + return nil, ErrNoResults + } + } + + // Overwrite the provider just in case + for _, res := range searchRes { + res.Provider = provider + } + + bestRes := GetBestSearchResult(searchRes) + + mangaId = bestRes.ID + } + + // +---------------------+ + // | Get chapters | + // +---------------------+ + + chapterList, err := providerExtension.GetProvider().FindChapters(mangaId) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Failed to get chapters") + return nil, ErrNoChapters + } + + // Overwrite the provider just in case + for _, chapter := range chapterList { + chapter.Provider = provider + } + + container = &ChapterContainer{ + MediaId: mediaId, + Provider: provider, + Chapters: chapterList, + } + + // Trigger hook event + ev := &MangaChapterContainerEvent{ + ChapterContainer: container, + } + err = hook.GlobalHookManager.OnMangaChapterContainer().Trigger(ev) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Exception occurred while triggering hook event") + } + container = ev.ChapterContainer + + // Cache the container only if it has chapters + if len(container.Chapters) > 0 { + err = r.fileCacher.Set(containerBucket, chapterContainerKey, container) + if err != nil { + r.logger.Warn().Err(err).Msg("manga: Failed to populate cache") + } + } + + r.logger.Info().Str("bucket", containerBucket.Name()).Msg("manga: Retrieved chapters") + return container, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// RefreshChapterContainers deletes all cached chapter containers and refetches them based on the selected provider map. +func (r *Repository) RefreshChapterContainers(mangaCollection *anilist.MangaCollection, selectedProviderMap map[int]string) (err error) { + defer util.HandlePanicInModuleWithError("manga/RefreshChapterContainers", &err) + + // Read the cache directory + entries, err := os.ReadDir(r.cacheDir) + if err != nil { + return err + } + + removedMediaIds := make(map[int]struct{}) + mu := sync.Mutex{} + + wg := sync.WaitGroup{} + wg.Add(len(entries)) + for _, entry := range entries { + go func(entry os.DirEntry) { + defer wg.Done() + + if entry.IsDir() { + return + } + + provider, bucketType, mediaId, ok := ParseChapterContainerFileName(entry.Name()) + if !ok { + return + } + // If the bucket type is not chapter, skip + if bucketType != bucketTypeChapter { + return + } + + r.logger.Trace().Str("provider", provider).Int("mediaId", mediaId).Msg("manga: Refetching chapter container") + + mu.Lock() + // Remove the container from the cache if it hasn't been removed yet + if _, ok := removedMediaIds[mediaId]; !ok { + _ = r.EmptyMangaCache(mediaId) + removedMediaIds[mediaId] = struct{}{} + } + mu.Unlock() + + // If a selectedProviderMap is provided, check if the provider is in the map + if selectedProviderMap != nil { + // If the manga is not in the map, continue + if _, ok := selectedProviderMap[mediaId]; !ok { + return + } + + // If the provider is not the one selected, continue + if selectedProviderMap[mediaId] != provider { + return + } + } + + // Get the manga from the collection + mangaEntry, found := mangaCollection.GetListEntryFromMangaId(mediaId) + if !found { + return + } + + // If the manga is not currently reading or repeating, continue + if *mangaEntry.GetStatus() != anilist.MediaListStatusCurrent && *mangaEntry.GetStatus() != anilist.MediaListStatusRepeating { + return + } + + // Refetch the container + _, err = r.GetMangaChapterContainer(&GetMangaChapterContainerOptions{ + Provider: provider, + MediaId: mediaId, + Titles: mangaEntry.GetMedia().GetAllTitles(), + Year: mangaEntry.GetMedia().GetStartYearSafe(), + }) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Failed to refetch chapter container") + return + } + + r.logger.Trace().Str("provider", provider).Int("mediaId", mediaId).Msg("manga: Refetched chapter container") + }(entry) + } + wg.Wait() + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +const ChapterCountMapCacheKey = 1 + +var mangaLatestChapterNumberMap = result.NewResultMap[int, map[int][]MangaLatestChapterNumberItem]() + +type MangaLatestChapterNumberItem struct { + Provider string `json:"provider"` + Scanlator string `json:"scanlator"` + Language string `json:"language"` + Number int `json:"number"` +} + +// GetMangaLatestChapterNumbersMap retrieves the latest chapter number for all manga entries. +// It scans the cache directory for chapter containers and counts the number of chapters fetched from the provider for each manga. +// +// Unlike [GetMangaLatestChapterNumberMap], it will segregate the chapter numbers by scanlator and language. +func (r *Repository) GetMangaLatestChapterNumbersMap() (ret map[int][]MangaLatestChapterNumberItem, err error) { + defer util.HandlePanicInModuleThen("manga/GetMangaLatestChapterNumbersMap", func() {}) + ret = make(map[int][]MangaLatestChapterNumberItem) + + if m, ok := mangaLatestChapterNumberMap.Get(ChapterCountMapCacheKey); ok { + ret = m + return + } + + // Go through all chapter container caches + entries, err := os.ReadDir(r.cacheDir) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + // Get the provider and mediaId from the file cache name + provider, mediaId, ok := parseChapterFileName(entry.Name()) + if !ok { + continue + } + + containerBucket := r.getFcProviderBucket(provider, mediaId, bucketTypeChapter) + + // Get the container from the file cache + var container *ChapterContainer + chapterContainerKey := getMangaChapterContainerCacheKey(provider, mediaId) + if found, _ := r.fileCacher.Get(containerBucket, chapterContainerKey, &container); !found { + continue + } + + // Create groups + groupByScanlator := lo.GroupBy(container.Chapters, func(c *hibikemanga.ChapterDetails) string { + return c.Scanlator + }) + + for scanlator, chapters := range groupByScanlator { + groupByLanguage := lo.GroupBy(chapters, func(c *hibikemanga.ChapterDetails) string { + return c.Language + }) + + for language, chapters := range groupByLanguage { + lastChapter := slices.MaxFunc(chapters, func(a *hibikemanga.ChapterDetails, b *hibikemanga.ChapterDetails) int { + return cmp.Compare(a.Index, b.Index) + }) + + chapterNumFloat, _ := strconv.ParseFloat(lastChapter.Chapter, 32) + chapterCount := int(math.Floor(chapterNumFloat)) + + if _, ok := ret[mediaId]; !ok { + ret[mediaId] = []MangaLatestChapterNumberItem{} + } + + ret[mediaId] = append(ret[mediaId], MangaLatestChapterNumberItem{ + Provider: provider, + Scanlator: scanlator, + Language: language, + Number: chapterCount, + }) + } + } + } + + // Trigger hook event + ev := &MangaLatestChapterNumbersMapEvent{ + LatestChapterNumbersMap: ret, + } + err = hook.GlobalHookManager.OnMangaLatestChapterNumbersMap().Trigger(ev) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Exception occurred while triggering hook event") + } + ret = ev.LatestChapterNumbersMap + + mangaLatestChapterNumberMap.Set(ChapterCountMapCacheKey, ret) + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func parseChapterFileName(dirName string) (provider string, mId int, ok bool) { + if !strings.HasPrefix(dirName, "manga_") { + return "", 0, false + } + dirName = strings.TrimSuffix(dirName, ".cache") + parts := strings.Split(dirName, "_") + if len(parts) != 4 { + return "", 0, false + } + + provider = parts[1] + mId, err := strconv.Atoi(parts[3]) + if err != nil { + return "", 0, false + } + + return provider, mId, true +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func GetBestSearchResult(searchRes []*hibikemanga.SearchResult) *hibikemanga.SearchResult { + bestRes := searchRes[0] + for _, res := range searchRes { + if res.SearchRating > bestRes.SearchRating { + bestRes = res + } + } + return bestRes +} + +// HydrateSearchResultSearchRating rates the search results based on the provided title +// It checks if all search results have a rating of 0 and if so, it calculates ratings +// using the Sorensen-Dice +func HydrateSearchResultSearchRating(_searchRes []*hibikemanga.SearchResult, title *string) { + // Rate the search results if all ratings are 0 + if noRatings := lo.EveryBy(_searchRes, func(res *hibikemanga.SearchResult) bool { + return res.SearchRating == 0 + }); noRatings { + wg := sync.WaitGroup{} + wg.Add(len(_searchRes)) + for _, res := range _searchRes { + go func(res *hibikemanga.SearchResult) { + defer wg.Done() + + compTitles := []*string{&res.Title} + if res.Synonyms == nil || len(res.Synonyms) == 0 { + return + } + for _, syn := range res.Synonyms { + compTitles = append(compTitles, &syn) + } + + compRes, ok := comparison.FindBestMatchWithSorensenDice(title, compTitles) + if !ok { + return + } + + res.SearchRating = compRes.Rating + return + }(res) + } + wg.Wait() + } +} diff --git a/seanime-2.9.10/internal/manga/chapter_container_helpers.go b/seanime-2.9.10/internal/manga/chapter_container_helpers.go new file mode 100644 index 0000000..30a0ffe --- /dev/null +++ b/seanime-2.9.10/internal/manga/chapter_container_helpers.go @@ -0,0 +1,15 @@ +package manga + +import ( + hibikemanga "seanime/internal/extension/hibike/manga" +) + +// GetChapter returns a chapter from the container +func (cc *ChapterContainer) GetChapter(id string) (ret *hibikemanga.ChapterDetails, found bool) { + for _, c := range cc.Chapters { + if c.ID == id { + return c, true + } + } + return nil, false +} diff --git a/seanime-2.9.10/internal/manga/chapter_container_mapping.go b/seanime-2.9.10/internal/manga/chapter_container_mapping.go new file mode 100644 index 0000000..9288f55 --- /dev/null +++ b/seanime-2.9.10/internal/manga/chapter_container_mapping.go @@ -0,0 +1,120 @@ +package manga + +import ( + "errors" + "seanime/internal/extension" + "seanime/internal/util" + "seanime/internal/util/result" + "strings" + + hibikemanga "seanime/internal/extension/hibike/manga" +) + +var searchResultCache = result.NewCache[string, []*hibikemanga.SearchResult]() + +func (r *Repository) ManualSearch(provider string, query string) (ret []*hibikemanga.SearchResult, err error) { + defer util.HandlePanicInModuleWithError("manga/ManualSearch", &err) + + if query == "" { + return make([]*hibikemanga.SearchResult, 0), nil + } + + // Get the search results + providerExtension, ok := extension.GetExtension[extension.MangaProviderExtension](r.providerExtensionBank, provider) + if !ok { + r.logger.Error().Str("provider", provider).Msg("manga: Provider not found") + return nil, errors.New("manga: Provider not found") + } + + normalizedQuery := strings.ToLower(strings.TrimSpace(query)) + + searchRes, found := searchResultCache.Get(provider + normalizedQuery) + if found { + return searchRes, nil + } + + searchRes, err = providerExtension.GetProvider().Search(hibikemanga.SearchOptions{ + Query: normalizedQuery, + }) + if err != nil { + r.logger.Error().Err(err).Str("query", normalizedQuery).Msg("manga: Search failed") + return nil, err + } + + // Overwrite the provider just in case + for _, res := range searchRes { + res.Provider = provider + } + + searchResultCache.Set(provider+normalizedQuery, searchRes) + + return searchRes, nil +} + +// ManualMapping is used to manually map a manga to a provider. +// After calling this, the client should re-fetch the chapter container. +func (r *Repository) ManualMapping(provider string, mediaId int, mangaId string) (err error) { + defer util.HandlePanicInModuleWithError("manga/ManualMapping", &err) + + r.logger.Trace().Msgf("manga: Removing cached bucket for %s, media ID: %d", provider, mediaId) + + // Delete the cached chapter container if any + bucket := r.getFcProviderBucket(provider, mediaId, bucketTypeChapter) + _ = r.fileCacher.Remove(bucket.Name()) + + r.logger.Trace(). + Str("provider", provider). + Int("mediaId", mediaId). + Str("mangaId", mangaId). + Msg("manga: Manual mapping") + + // Insert the mapping into the database + err = r.db.InsertMangaMapping(provider, mediaId, mangaId) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Failed to insert mapping") + return err + } + + r.logger.Debug().Msg("manga: Manual mapping successful") + + return nil +} + +type MappingResponse struct { + MangaID *string `json:"mangaId"` +} + +func (r *Repository) GetMapping(provider string, mediaId int) (ret MappingResponse) { + defer util.HandlePanicInModuleThen("manga/GetMapping", func() { + ret = MappingResponse{} + }) + + mapping, found := r.db.GetMangaMapping(provider, mediaId) + if !found { + return MappingResponse{} + } + + return MappingResponse{ + MangaID: &mapping.MangaID, + } +} + +func (r *Repository) RemoveMapping(provider string, mediaId int) (err error) { + defer util.HandlePanicInModuleWithError("manga/RemoveMapping", &err) + + // Delete the mapping from the database + err = r.db.DeleteMangaMapping(provider, mediaId) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Failed to delete mapping") + return err + } + + r.logger.Debug().Msg("manga: Mapping removed") + + r.logger.Trace().Msgf("manga: Removing cached bucket for %s, media ID: %d", provider, mediaId) + // Delete the cached chapter container if any + bucket := r.getFcProviderBucket(provider, mediaId, bucketTypeChapter) + _ = r.fileCacher.Remove(bucket.Name()) + + return nil +} diff --git a/seanime-2.9.10/internal/manga/chapter_page_container.go b/seanime-2.9.10/internal/manga/chapter_page_container.go new file mode 100644 index 0000000..070b503 --- /dev/null +++ b/seanime-2.9.10/internal/manga/chapter_page_container.go @@ -0,0 +1,240 @@ +package manga + +import ( + "errors" + "fmt" + "seanime/internal/extension" + manga_providers "seanime/internal/manga/providers" + "seanime/internal/util" + "sync" + + hibikemanga "seanime/internal/extension/hibike/manga" +) + +type ( + // PageContainer is used to display the list of pages from a chapter in the client. + // It is cached in the file cache bucket with a key of the format: {provider}${mediaId}${chapterId} + PageContainer struct { + MediaId int `json:"mediaId"` + Provider string `json:"provider"` + ChapterId string `json:"chapterId"` + Pages []*hibikemanga.ChapterPage `json:"pages"` + PageDimensions map[int]*PageDimension `json:"pageDimensions"` // Indexed by page number + IsDownloaded bool `json:"isDownloaded"` // TODO remove + } + + // PageDimension is used to store the dimensions of a page. + // It is used by the client for 'Double Page' mode. + PageDimension struct { + Width int `json:"width"` + Height int `json:"height"` + } +) + +// GetMangaPageContainer returns the PageContainer for a manga chapter based on the provider. +func (r *Repository) GetMangaPageContainer( + provider string, + mediaId int, + chapterId string, + doublePage bool, + isOffline *bool, +) (ret *PageContainer, err error) { + defer util.HandlePanicInModuleWithError("manga/GetMangaPageContainer", &err) + + // +---------------------+ + // | Downloads | + // +---------------------+ + + providerExtension, ok := extension.GetExtension[extension.MangaProviderExtension](r.providerExtensionBank, provider) + if !ok { + r.logger.Error().Str("provider", provider).Msg("manga: Provider not found") + return nil, errors.New("manga: Provider not found") + } + + _, isLocalProvider := providerExtension.GetProvider().(*manga_providers.Local) + + if *isOffline && !isLocalProvider { + ret, err = r.getDownloadedMangaPageContainer(provider, mediaId, chapterId) + if err != nil { + return nil, err + } + return ret, nil + } + + if !isLocalProvider { + ret, _ = r.getDownloadedMangaPageContainer(provider, mediaId, chapterId) + if ret != nil { + return ret, nil + } + } + + // +---------------------+ + // | Get Pages | + // +---------------------+ + + // PageContainer key + pageContainerKey := fmt.Sprintf("%s$%d$%s", provider, mediaId, chapterId) + + r.logger.Trace(). + Str("provider", provider). + Int("mediaId", mediaId). + Str("key", pageContainerKey). + Str("chapterId", chapterId). + Msgf("manga: Getting pages") + + // +---------------------+ + // | Cache | + // +---------------------+ + + var container *PageContainer + + // PageContainer bucket + // e.g., manga_comick_pages_123 + // -> { "comick$123$10010": PageContainer }, { "comick$123$10011": PageContainer } + pageBucket := r.getFcProviderBucket(provider, mediaId, bucketTypePage) + + // Check if the container is in the cache + if found, _ := r.fileCacher.Get(pageBucket, pageContainerKey, &container); found && !isLocalProvider { + + // Hydrate page dimensions + pageDimensions, _ := r.getPageDimensions(doublePage, provider, mediaId, chapterId, container.Pages) + container.PageDimensions = pageDimensions + + r.logger.Debug().Str("key", pageContainerKey).Msg("manga: Page Container Cache HIT") + return container, nil + } + + // +---------------------+ + // | Fetch pages | + // +---------------------+ + + // Search for the chapter in the cache + containerBucket := r.getFcProviderBucket(provider, mediaId, bucketTypeChapter) + + chapterContainerKey := getMangaChapterContainerCacheKey(provider, mediaId) + + var chapterContainer *ChapterContainer + if found, _ := r.fileCacher.Get(containerBucket, chapterContainerKey, &chapterContainer); !found { + r.logger.Error().Msg("manga: Chapter Container not found") + return nil, ErrNoChapters + } + + // Get the chapter from the container + var chapter *hibikemanga.ChapterDetails + for _, c := range chapterContainer.Chapters { + if c.ID == chapterId { + chapter = c + break + } + } + + if chapter == nil { + r.logger.Error().Msg("manga: Chapter not found") + return nil, ErrChapterNotFound + } + + // Get the chapter pages + var pages []*hibikemanga.ChapterPage + + pages, err = providerExtension.GetProvider().FindChapterPages(chapter.ID) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Could not get chapter pages") + return nil, err + } + + if pages == nil || len(pages) == 0 { + r.logger.Error().Msg("manga: No pages found") + return nil, fmt.Errorf("manga: No pages found") + } + + // Overwrite provider just in case + for _, page := range pages { + page.Provider = provider + } + + pageDimensions, _ := r.getPageDimensions(doublePage, provider, mediaId, chapterId, pages) + + container = &PageContainer{ + MediaId: mediaId, + Provider: provider, + ChapterId: chapterId, + Pages: pages, + PageDimensions: pageDimensions, + IsDownloaded: false, + } + + // Set cache only if not local provider + if !isLocalProvider { + err = r.fileCacher.Set(pageBucket, pageContainerKey, container) + if err != nil { + r.logger.Warn().Err(err).Msg("manga: Failed to populate cache") + } + } + + r.logger.Debug().Str("key", pageContainerKey).Msg("manga: Retrieved pages") + + return container, nil +} + +func (r *Repository) getPageDimensions(enabled bool, provider string, mediaId int, chapterId string, pages []*hibikemanga.ChapterPage) (ret map[int]*PageDimension, err error) { + defer util.HandlePanicInModuleWithError("manga/getPageDimensions", &err) + + if !enabled { + return nil, nil + } + + // e.g. comick$123$10010 + key := fmt.Sprintf("%s$%d$%s", provider, mediaId, chapterId) + + // Page dimensions bucket + // e.g., manga_comick_page-dimensions_123 + // -> { "comick$123$10010": PageDimensions }, { "comick$123$10011": PageDimensions } + dimensionBucket := r.getFcProviderBucket(provider, mediaId, bucketTypePageDimensions) + + if found, _ := r.fileCacher.Get(dimensionBucket, key, &ret); found { + r.logger.Debug().Str("key", key).Msg("manga: Page Dimensions Cache HIT") + return + } + + r.logger.Trace().Str("key", key).Msg("manga: Getting page dimensions") + + // Get the page dimensions + pageDimensions := make(map[int]*PageDimension) + mu := sync.Mutex{} + wg := sync.WaitGroup{} + for _, page := range pages { + wg.Add(1) + go func(page *hibikemanga.ChapterPage) { + defer wg.Done() + var buf []byte + if page.Buf != nil { + buf = page.Buf + } else { + buf, err = manga_providers.GetImageByProxy(page.URL, page.Headers) + if err != nil { + return + } + } + width, height, err := getImageNaturalSizeB(buf) + if err != nil { + //r.logger.Warn().Err(err).Int("index", page.Index).Msg("manga: failed to get image size") + return + } + + mu.Lock() + // DEVNOTE: Index by page index + pageDimensions[page.Index] = &PageDimension{ + Width: width, + Height: height, + } + mu.Unlock() + }(page) + } + wg.Wait() + + _ = r.fileCacher.Set(dimensionBucket, key, pageDimensions) + + r.logger.Info().Str("bucket", dimensionBucket.Name()).Msg("manga: Retrieved page dimensions") + + return pageDimensions, nil +} diff --git a/seanime-2.9.10/internal/manga/collection.go b/seanime-2.9.10/internal/manga/collection.go new file mode 100644 index 0000000..ccd3e68 --- /dev/null +++ b/seanime-2.9.10/internal/manga/collection.go @@ -0,0 +1,147 @@ +package manga + +import ( + "cmp" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/hook" + "seanime/internal/platforms/platform" + "slices" + + "github.com/samber/lo" + "github.com/sourcegraph/conc/pool" +) + +type ( + CollectionStatusType string + + Collection struct { + Lists []*CollectionList `json:"lists"` + } + + CollectionList struct { + Type anilist.MediaListStatus `json:"type"` + Status anilist.MediaListStatus `json:"status"` + Entries []*CollectionEntry `json:"entries"` + } + + CollectionEntry struct { + Media *anilist.BaseManga `json:"media"` + MediaId int `json:"mediaId"` + EntryListData *EntryListData `json:"listData"` // AniList list data + } +) + +type ( + NewCollectionOptions struct { + MangaCollection *anilist.MangaCollection + Platform platform.Platform + } +) + +func NewCollection(opts *NewCollectionOptions) (collection *Collection, err error) { + coll := &Collection{} + if opts.MangaCollection == nil { + return nil, nil + } + if opts.Platform == nil { + return nil, fmt.Errorf("platform is nil") + } + + optsEvent := new(MangaLibraryCollectionRequestedEvent) + optsEvent.MangaCollection = opts.MangaCollection + err = hook.GlobalHookManager.OnMangaLibraryCollectionRequested().Trigger(optsEvent) + if err != nil { + return nil, err + } + opts.MangaCollection = optsEvent.MangaCollection + + aniLists := opts.MangaCollection.GetMediaListCollection().GetLists() + + aniLists = lo.Filter(aniLists, func(list *anilist.MangaList, _ int) bool { + return list.Status != nil + }) + + p := pool.NewWithResults[*CollectionList]() + for _, list := range aniLists { + p.Go(func() *CollectionList { + + if list.Status == nil { + return nil + } + + entries := list.GetEntries() + + p2 := pool.NewWithResults[*CollectionEntry]() + for _, entry := range entries { + p2.Go(func() *CollectionEntry { + + return &CollectionEntry{ + Media: entry.GetMedia(), + MediaId: entry.GetMedia().GetID(), + EntryListData: &EntryListData{ + Progress: *entry.Progress, + Score: *entry.Score, + Status: entry.Status, + Repeat: entry.GetRepeatSafe(), + StartedAt: anilist.FuzzyDateToString(entry.StartedAt), + CompletedAt: anilist.FuzzyDateToString(entry.CompletedAt), + }, + } + }) + } + + collectionEntries := p2.Wait() + + slices.SortFunc(collectionEntries, func(i, j *CollectionEntry) int { + return cmp.Compare(i.Media.GetTitleSafe(), j.Media.GetTitleSafe()) + }) + + return &CollectionList{ + Type: getCollectionEntryFromListStatus(*list.Status), + Status: *list.Status, + Entries: collectionEntries, + } + + }) + } + lists := p.Wait() + + lists = lo.Filter(lists, func(l *CollectionList, _ int) bool { + return l != nil + }) + + // Merge repeating to current (no need to show repeating as a separate list) + repeat, ok := lo.Find(lists, func(item *CollectionList) bool { + return item.Status == anilist.MediaListStatusRepeating + }) + if ok { + current, ok := lo.Find(lists, func(item *CollectionList) bool { + return item.Status == anilist.MediaListStatusCurrent + }) + if len(repeat.Entries) > 0 && ok { + current.Entries = append(current.Entries, repeat.Entries...) + } + // Remove repeating from lists + lists = lo.Filter(lists, func(item *CollectionList, index int) bool { + return item.Status != anilist.MediaListStatusRepeating + }) + } + + coll.Lists = lists + + event := new(MangaLibraryCollectionEvent) + event.LibraryCollection = coll + _ = hook.GlobalHookManager.OnMangaLibraryCollection().Trigger(event) + coll = event.LibraryCollection + + return coll, nil +} + +func getCollectionEntryFromListStatus(st anilist.MediaListStatus) anilist.MediaListStatus { + if st == anilist.MediaListStatusRepeating { + return anilist.MediaListStatusCurrent + } + + return st +} diff --git a/seanime-2.9.10/internal/manga/collection_test.go b/seanime-2.9.10/internal/manga/collection_test.go new file mode 100644 index 0000000..46a6cb8 --- /dev/null +++ b/seanime-2.9.10/internal/manga/collection_test.go @@ -0,0 +1,47 @@ +package manga + +import ( + "context" + "seanime/internal/api/anilist" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" +) + +func TestNewCollection(t *testing.T) { + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t, test_utils.Anilist()) + + anilistClient := anilist.TestGetMockAnilistClient() + logger := util.NewLogger() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + + mangaCollection, err := anilistClient.MangaCollection(context.Background(), &test_utils.ConfigData.Provider.AnilistUsername) + if err != nil { + t.Fatalf("Failed to get manga collection: %v", err) + } + + opts := &NewCollectionOptions{ + MangaCollection: mangaCollection, + Platform: anilistPlatform, + } + + collection, err := NewCollection(opts) + if err != nil { + t.Fatalf("Failed to create collection: %v", err) + } + + if len(collection.Lists) == 0 { + t.Skip("No lists found") + } + + for _, list := range collection.Lists { + t.Logf("List: %s", list.Type) + for _, entry := range list.Entries { + t.Logf("\tEntry: %s", entry.Media.GetPreferredTitle()) + t.Logf("\t\tProgress: %d", entry.EntryListData.Progress) + } + t.Log("---------------------------------------") + } +} diff --git a/seanime-2.9.10/internal/manga/download.go b/seanime-2.9.10/internal/manga/download.go new file mode 100644 index 0000000..cbd14e4 --- /dev/null +++ b/seanime-2.9.10/internal/manga/download.go @@ -0,0 +1,463 @@ +package manga + +import ( + "errors" + "fmt" + "os" + "seanime/internal/api/anilist" + "seanime/internal/database/db" + "seanime/internal/database/models" + "seanime/internal/events" + "seanime/internal/hook" + chapter_downloader "seanime/internal/manga/downloader" + manga_providers "seanime/internal/manga/providers" + "seanime/internal/util" + "seanime/internal/util/filecache" + "sync" + + "github.com/rs/zerolog" +) + +type ( + Downloader struct { + logger *zerolog.Logger + wsEventManager events.WSEventManagerInterface + database *db.Database + downloadDir string + chapterDownloader *chapter_downloader.Downloader + repository *Repository + filecacher *filecache.Cacher + + mediaMap *MediaMap // Refreshed on start and after each download + mediaMapMu sync.RWMutex + + chapterDownloadedCh chan chapter_downloader.DownloadID + readingDownloadDir bool + isOffline *bool + } + + // MediaMap is created after reading the download directory. + // It is used to store all downloaded chapters for each media. + // The key is the media ID and the value is a map of provider to a list of chapters. + // + // e.g., downloadDir/comick_1234_abc_13/ + // downloadDir/comick_1234_def_13.5/ + // -> { 1234: { "comick": [ { "chapterId": "abc", "chapterNumber": "13" }, { "chapterId": "def", "chapterNumber": "13.5" } ] } } + MediaMap map[int]ProviderDownloadMap + + // ProviderDownloadMap is used to store all downloaded chapters for a specific media and provider. + // The key is the provider and the value is a list of chapters. + ProviderDownloadMap map[string][]ProviderDownloadMapChapterInfo + + ProviderDownloadMapChapterInfo struct { + ChapterID string `json:"chapterId"` + ChapterNumber string `json:"chapterNumber"` + } + + MediaDownloadData struct { + Downloaded ProviderDownloadMap `json:"downloaded"` + Queued ProviderDownloadMap `json:"queued"` + } +) + +type ( + NewDownloaderOptions struct { + Database *db.Database + Logger *zerolog.Logger + WSEventManager events.WSEventManagerInterface + DownloadDir string + Repository *Repository + IsOffline *bool + } + + DownloadChapterOptions struct { + Provider string + MediaId int + ChapterId string + StartNow bool + } +) + +func NewDownloader(opts *NewDownloaderOptions) *Downloader { + _ = os.MkdirAll(opts.DownloadDir, os.ModePerm) + filecacher, _ := filecache.NewCacher(opts.DownloadDir) + + d := &Downloader{ + logger: opts.Logger, + wsEventManager: opts.WSEventManager, + database: opts.Database, + downloadDir: opts.DownloadDir, + repository: opts.Repository, + mediaMap: new(MediaMap), + filecacher: filecacher, + isOffline: opts.IsOffline, + } + + d.chapterDownloader = chapter_downloader.NewDownloader(&chapter_downloader.NewDownloaderOptions{ + Logger: opts.Logger, + WSEventManager: opts.WSEventManager, + Database: opts.Database, + DownloadDir: opts.DownloadDir, + }) + + go d.hydrateMediaMap() + + return d +} + +// Start is called once to start the Chapter downloader 's main goroutine. +func (d *Downloader) Start() { + d.chapterDownloader.Start() + go func() { + for { + select { + // Listen for downloaded chapters + case downloadId := <-d.chapterDownloader.ChapterDownloaded(): + if d.isOffline != nil && *d.isOffline { + continue + } + + // When a chapter is downloaded, fetch the chapter container from the file cache + // and store it in the permanent bucket. + // DEVNOTE: This will be useful to avoid re-fetching the chapter container when the cache expires. + // This is deleted when a chapter is deleted. + go func() { + chapterContainerKey := getMangaChapterContainerCacheKey(downloadId.Provider, downloadId.MediaId) + chapterContainer, found := d.repository.getChapterContainerFromFilecache(downloadId.Provider, downloadId.MediaId) + if found { + // Store the chapter container in the permanent bucket + permBucket := getPermanentChapterContainerCacheBucket(downloadId.Provider, downloadId.MediaId) + _ = d.filecacher.SetPerm(permBucket, chapterContainerKey, chapterContainer) + } + }() + + // Refresh the media map when a chapter is downloaded + d.hydrateMediaMap() + } + } + }() +} + +// The bucket for storing downloaded chapter containers. +// e.g. manga_downloaded_comick_chapters_1234 +// The key is the chapter ID. +func getPermanentChapterContainerCacheBucket(provider string, mId int) filecache.PermanentBucket { + return filecache.NewPermanentBucket(fmt.Sprintf("manga_downloaded_%s_chapters_%d", provider, mId)) +} + +// getChapterContainerFromFilecache returns the chapter container from the temporary file cache. +func (r *Repository) getChapterContainerFromFilecache(provider string, mId int) (*ChapterContainer, bool) { + // Find chapter container in the file cache + chapterBucket := r.getFcProviderBucket(provider, mId, bucketTypeChapter) + + chapterContainerKey := getMangaChapterContainerCacheKey(provider, mId) + + var chapterContainer *ChapterContainer + // Get the key-value pair in the bucket + if found, _ := r.fileCacher.Get(chapterBucket, chapterContainerKey, &chapterContainer); !found { + // If the chapter container is not found, return an error + // since it means that it wasn't fetched (for some reason) -- This shouldn't happen + return nil, false + } + + return chapterContainer, true +} + +// getChapterContainerFromPermanentFilecache returns the chapter container from the permanent file cache. +func (r *Repository) getChapterContainerFromPermanentFilecache(provider string, mId int) (*ChapterContainer, bool) { + permBucket := getPermanentChapterContainerCacheBucket(provider, mId) + + chapterContainerKey := getMangaChapterContainerCacheKey(provider, mId) + + var chapterContainer *ChapterContainer + // Get the key-value pair in the bucket + if found, _ := r.fileCacher.GetPerm(permBucket, chapterContainerKey, &chapterContainer); !found { + // If the chapter container is not found, return an error + // since it means that it wasn't fetched (for some reason) -- This shouldn't happen + return nil, false + } + + return chapterContainer, true +} + +// DownloadChapter is called by the client to download a chapter. +// It fetches the chapter pages by using Repository.GetMangaPageContainer +// and invokes the chapter_downloader.Downloader 'Download' method to add the chapter to the download queue. +func (d *Downloader) DownloadChapter(opts DownloadChapterOptions) error { + + if d.isOffline != nil && *d.isOffline { + return errors.New("manga downloader: Manga downloader is in offline mode") + } + + chapterContainer, found := d.repository.getChapterContainerFromFilecache(opts.Provider, opts.MediaId) + if !found { + return errors.New("chapters not found") + } + + // Find the chapter in the chapter container + // e.g. Wind-Breaker$0062 + chapter, ok := chapterContainer.GetChapter(opts.ChapterId) + if !ok { + return errors.New("chapter not found") + } + + // Fetch the chapter pages + pageContainer, err := d.repository.GetMangaPageContainer(opts.Provider, opts.MediaId, opts.ChapterId, false, &[]bool{false}[0]) + if err != nil { + return err + } + + // Add the chapter to the download queue + return d.chapterDownloader.AddToQueue(chapter_downloader.DownloadOptions{ + DownloadID: chapter_downloader.DownloadID{ + Provider: opts.Provider, + MediaId: opts.MediaId, + ChapterId: opts.ChapterId, + ChapterNumber: manga_providers.GetNormalizedChapter(chapter.Chapter), + }, + Pages: pageContainer.Pages, + }) +} + +// DeleteChapter is called by the client to delete a downloaded chapter. +func (d *Downloader) DeleteChapter(provider string, mediaId int, chapterId string, chapterNumber string) (err error) { + err = d.chapterDownloader.DeleteChapter(chapter_downloader.DownloadID{ + Provider: provider, + MediaId: mediaId, + ChapterId: chapterId, + ChapterNumber: chapterNumber, + }) + if err != nil { + return err + } + + permBucket := getPermanentChapterContainerCacheBucket(provider, mediaId) + _ = d.filecacher.DeletePerm(permBucket, chapterId) + + d.hydrateMediaMap() + + return nil +} + +// DeleteChapters is called by the client to delete downloaded chapters. +func (d *Downloader) DeleteChapters(ids []chapter_downloader.DownloadID) (err error) { + for _, id := range ids { + err = d.chapterDownloader.DeleteChapter(chapter_downloader.DownloadID{ + Provider: id.Provider, + MediaId: id.MediaId, + ChapterId: id.ChapterId, + ChapterNumber: id.ChapterNumber, + }) + + permBucket := getPermanentChapterContainerCacheBucket(id.Provider, id.MediaId) + _ = d.filecacher.DeletePerm(permBucket, id.ChapterId) + } + if err != nil { + return err + } + + d.hydrateMediaMap() + + return nil +} + +func (d *Downloader) GetMediaDownloads(mediaId int, cached bool) (ret MediaDownloadData, err error) { + defer util.HandlePanicInModuleWithError("manga/GetMediaDownloads", &err) + + if !cached { + d.hydrateMediaMap() + } + + return d.mediaMap.getMediaDownload(mediaId, d.database) +} + +func (d *Downloader) RunChapterDownloadQueue() { + d.chapterDownloader.Run() +} + +func (d *Downloader) StopChapterDownloadQueue() { + _ = d.database.ResetDownloadingChapterDownloadQueueItems() + d.chapterDownloader.Stop() +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type ( + NewDownloadListOptions struct { + MangaCollection *anilist.MangaCollection + } + + DownloadListItem struct { + MediaId int `json:"mediaId"` + // Media will be nil if the manga is no longer in the user's collection. + // The client should handle this case by displaying the download data without the media data. + Media *anilist.BaseManga `json:"media"` + DownloadData ProviderDownloadMap `json:"downloadData"` + } +) + +// NewDownloadList returns a list of DownloadListItem for the client to display. +func (d *Downloader) NewDownloadList(opts *NewDownloadListOptions) (ret []*DownloadListItem, err error) { + defer util.HandlePanicInModuleWithError("manga/NewDownloadList", &err) + + mm := d.mediaMap + + ret = make([]*DownloadListItem, 0) + + for mId, data := range *mm { + listEntry, ok := opts.MangaCollection.GetListEntryFromMangaId(mId) + if !ok { + ret = append(ret, &DownloadListItem{ + MediaId: mId, + Media: nil, + DownloadData: data, + }) + continue + } + + media := listEntry.GetMedia() + if media == nil { + ret = append(ret, &DownloadListItem{ + MediaId: mId, + Media: nil, + DownloadData: data, + }) + continue + } + + item := &DownloadListItem{ + MediaId: mId, + Media: media, + DownloadData: data, + } + + ret = append(ret, item) + } + + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Media map +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (mm *MediaMap) getMediaDownload(mediaId int, db *db.Database) (MediaDownloadData, error) { + + if mm == nil { + return MediaDownloadData{}, errors.New("could not check downloaded chapters") + } + + // Get all downloaded chapters for the media + downloads, ok := (*mm)[mediaId] + if !ok { + downloads = make(map[string][]ProviderDownloadMapChapterInfo) + } + + // Get all queued chapters for the media + queued, err := db.GetMediaQueuedChapters(mediaId) + if err != nil { + queued = make([]*models.ChapterDownloadQueueItem, 0) + } + + qm := make(ProviderDownloadMap) + for _, item := range queued { + if _, ok := qm[item.Provider]; !ok { + qm[item.Provider] = []ProviderDownloadMapChapterInfo{ + { + ChapterID: item.ChapterID, + ChapterNumber: item.ChapterNumber, + }, + } + } else { + qm[item.Provider] = append(qm[item.Provider], ProviderDownloadMapChapterInfo{ + ChapterID: item.ChapterID, + ChapterNumber: item.ChapterNumber, + }) + } + } + + data := MediaDownloadData{ + Downloaded: downloads, + Queued: qm, + } + + return data, nil + +} + +// hydrateMediaMap hydrates the MediaMap by reading the download directory. +func (d *Downloader) hydrateMediaMap() { + + if d.readingDownloadDir { + return + } + + d.mediaMapMu.Lock() + defer d.mediaMapMu.Unlock() + + d.readingDownloadDir = true + defer func() { + d.readingDownloadDir = false + }() + + d.logger.Debug().Msg("manga downloader: Reading download directory") + + ret := make(MediaMap) + + files, err := os.ReadDir(d.downloadDir) + if err != nil { + d.logger.Error().Err(err).Msg("manga downloader: Failed to read download directory") + } + + // Hydrate MediaMap by going through all chapter directories + mu := sync.Mutex{} + wg := sync.WaitGroup{} + for _, file := range files { + wg.Add(1) + go func(file os.DirEntry) { + defer wg.Done() + + if file.IsDir() { + // e.g. comick_1234_abc_13.5 + id, ok := chapter_downloader.ParseChapterDirName(file.Name()) + if !ok { + return + } + + mu.Lock() + newMapInfo := ProviderDownloadMapChapterInfo{ + ChapterID: id.ChapterId, + ChapterNumber: id.ChapterNumber, + } + + if _, ok := ret[id.MediaId]; !ok { + ret[id.MediaId] = make(map[string][]ProviderDownloadMapChapterInfo) + ret[id.MediaId][id.Provider] = []ProviderDownloadMapChapterInfo{newMapInfo} + } else { + if _, ok := ret[id.MediaId][id.Provider]; !ok { + ret[id.MediaId][id.Provider] = []ProviderDownloadMapChapterInfo{newMapInfo} + } else { + ret[id.MediaId][id.Provider] = append(ret[id.MediaId][id.Provider], newMapInfo) + } + } + mu.Unlock() + } + }(file) + } + wg.Wait() + + // Trigger hook event + ev := &MangaDownloadMapEvent{ + MediaMap: &ret, + } + _ = hook.GlobalHookManager.OnMangaDownloadMap().Trigger(ev) // ignore the error + // make sure the media map is not nil + if ev.MediaMap != nil { + ret = *ev.MediaMap + } + + d.mediaMap = &ret + + // When done refreshing, send a message to the client to refetch the download data + d.wsEventManager.SendEvent(events.RefreshedMangaDownloadData, nil) +} diff --git a/seanime-2.9.10/internal/manga/downloader/chapter_downloader.go b/seanime-2.9.10/internal/manga/downloader/chapter_downloader.go new file mode 100644 index 0000000..0274c10 --- /dev/null +++ b/seanime-2.9.10/internal/manga/downloader/chapter_downloader.go @@ -0,0 +1,445 @@ +package chapter_downloader + +import ( + "bytes" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "os" + "path/filepath" + "seanime/internal/database/db" + "seanime/internal/events" + hibikemanga "seanime/internal/extension/hibike/manga" + manga_providers "seanime/internal/manga/providers" + "seanime/internal/util" + "strconv" + "strings" + "sync" + + "github.com/goccy/go-json" + "github.com/rs/zerolog" + _ "golang.org/x/image/bmp" // Register BMP format + _ "golang.org/x/image/tiff" // Register Tiff format +) + +// 📁 cache/manga +// └── 📁 {provider}_{mediaId}_{chapterId}_{chapterNumber} <- Downloader generates +// ├── 📄 registry.json <- Contains Registry +// ├── 📄 1.jpg +// ├── 📄 2.jpg +// └── 📄 ... +// + +type ( + // Downloader is used to download chapters from various manga providers. + Downloader struct { + logger *zerolog.Logger + wsEventManager events.WSEventManagerInterface + database *db.Database + downloadDir string + mu sync.Mutex + downloadMu sync.Mutex + // cancelChannel is used to cancel some or all downloads. + cancelChannels map[DownloadID]chan struct{} + queue *Queue + cancelCh chan struct{} // Close to cancel the download process + runCh chan *QueueInfo // Receives a signal to download the next item + chapterDownloadedCh chan DownloadID // Sends a signal when a chapter has been downloaded + } + + //+-------------------------------------------------------------------------------------------------------------------+ + + DownloadID struct { + Provider string `json:"provider"` + MediaId int `json:"mediaId"` + ChapterId string `json:"chapterId"` + ChapterNumber string `json:"chapterNumber"` + } + + //+-------------------------------------------------------------------------------------------------------------------+ + + // Registry stored in 📄 registry.json for each chapter download. + Registry map[int]PageInfo + + PageInfo struct { + Index int `json:"index"` + Filename string `json:"filename"` + OriginalURL string `json:"original_url"` + Size int64 `json:"size"` + Width int `json:"width"` + Height int `json:"height"` + } +) + +type ( + NewDownloaderOptions struct { + Logger *zerolog.Logger + WSEventManager events.WSEventManagerInterface + DownloadDir string + Database *db.Database + } + + DownloadOptions struct { + DownloadID + Pages []*hibikemanga.ChapterPage + StartNow bool + } +) + +func NewDownloader(opts *NewDownloaderOptions) *Downloader { + runCh := make(chan *QueueInfo, 1) + + d := &Downloader{ + logger: opts.Logger, + wsEventManager: opts.WSEventManager, + downloadDir: opts.DownloadDir, + cancelChannels: make(map[DownloadID]chan struct{}), + runCh: runCh, + queue: NewQueue(opts.Database, opts.Logger, opts.WSEventManager, runCh), + chapterDownloadedCh: make(chan DownloadID, 100), + } + + return d +} + +// Start spins up a goroutine that will listen to queue events. +func (cd *Downloader) Start() { + go func() { + for { + select { + // Listen for new queue items + case queueInfo := <-cd.runCh: + cd.logger.Debug().Msgf("chapter downloader: Received queue item to download: %s", queueInfo.ChapterId) + cd.run(queueInfo) + } + } + }() +} + +func (cd *Downloader) ChapterDownloaded() <-chan DownloadID { + return cd.chapterDownloadedCh +} + +// AddToQueue adds a chapter to the download queue. +// If the chapter is already downloaded (i.e. a folder already exists), it will delete the previous data and re-download it. +func (cd *Downloader) AddToQueue(opts DownloadOptions) error { + cd.mu.Lock() + defer cd.mu.Unlock() + + downloadId := opts.DownloadID + + // Check if chapter is already downloaded + registryPath := cd.getChapterRegistryPath(downloadId) + if _, err := os.Stat(registryPath); err == nil { + cd.logger.Warn().Msg("chapter downloader: directory already exists, deleting") + // Delete folder + _ = os.RemoveAll(cd.getChapterDownloadDir(downloadId)) + } + + // Start download + cd.logger.Debug().Msgf("chapter downloader: Adding chapter to download queue: %s", opts.ChapterId) + // Add to queue + return cd.queue.Add(downloadId, opts.Pages, opts.StartNow) +} + +// DeleteChapter deletes a chapter directory from the download directory. +func (cd *Downloader) DeleteChapter(id DownloadID) error { + cd.mu.Lock() + defer cd.mu.Unlock() + + cd.logger.Debug().Msgf("chapter downloader: Deleting chapter %s", id.ChapterId) + + _ = os.RemoveAll(cd.getChapterDownloadDir(id)) + cd.logger.Debug().Msgf("chapter downloader: Removed chapter %s", id.ChapterId) + return nil +} + +// Run starts the downloader if it's not already running. +func (cd *Downloader) Run() { + cd.mu.Lock() + defer cd.mu.Unlock() + + cd.logger.Debug().Msg("chapter downloader: Starting queue") + + cd.cancelCh = make(chan struct{}) + + cd.queue.Run() +} + +// Stop cancels the download process and stops the queue from running. +func (cd *Downloader) Stop() { + cd.mu.Lock() + defer cd.mu.Unlock() + + defer func() { + if r := recover(); r != nil { + cd.logger.Error().Msgf("chapter downloader: cancelCh is already closed") + } + }() + + cd.cancelCh = make(chan struct{}) + + close(cd.cancelCh) // Cancel download process + + cd.queue.Stop() +} + +// run downloads the chapter based on the QueueInfo provided. +// This is called successively for each current item being processed. +// It invokes downloadChapterImages to download the chapter pages. +func (cd *Downloader) run(queueInfo *QueueInfo) { + + defer util.HandlePanicInModuleThen("internal/manga/downloader/runNext", func() { + cd.logger.Error().Msg("chapter downloader: Panic in 'run'") + }) + + // Download chapter images + if err := cd.downloadChapterImages(queueInfo); err != nil { + return + } + + cd.chapterDownloadedCh <- queueInfo.DownloadID +} + +// downloadChapterImages creates a directory for the chapter and downloads each image to that directory. +// It also creates a Registry file that contains information about each image. +// +// e.g., +// 📁 {provider}_{mediaId}_{chapterId}_{chapterNumber} +// ├── 📄 registry.json +// ├── 📄 1.jpg +// ├── 📄 2.jpg +// └── 📄 ... +func (cd *Downloader) downloadChapterImages(queueInfo *QueueInfo) (err error) { + + // Create download directory + // 📁 {provider}_{mediaId}_{chapterId} + destination := cd.getChapterDownloadDir(queueInfo.DownloadID) + if err = os.MkdirAll(destination, os.ModePerm); err != nil { + cd.logger.Error().Err(err).Msgf("chapter downloader: Failed to create download directory for chapter %s", queueInfo.ChapterId) + return err + } + + cd.logger.Debug().Msgf("chapter downloader: Downloading chapter %s images to %s", queueInfo.ChapterId, destination) + + registry := make(Registry) + + // calculateBatchSize calculates the batch size based on the number of URLs. + calculateBatchSize := func(numURLs int) int { + maxBatchSize := 5 + batchSize := numURLs / 10 + if batchSize < 1 { + return 1 + } else if batchSize > maxBatchSize { + return maxBatchSize + } + return batchSize + } + + // Download images + batchSize := calculateBatchSize(len(queueInfo.Pages)) + + var wg sync.WaitGroup + semaphore := make(chan struct{}, batchSize) // Semaphore to control concurrency + for _, page := range queueInfo.Pages { + semaphore <- struct{}{} // Acquire semaphore + wg.Add(1) + go func(page *hibikemanga.ChapterPage, registry *Registry) { + defer func() { + <-semaphore // Release semaphore + wg.Done() + }() + select { + case <-cd.cancelCh: + //cd.logger.Warn().Msg("chapter downloader: Download goroutine canceled") + return + default: + cd.downloadPage(page, destination, registry) + } + }(page, ®istry) + } + wg.Wait() + + // Write the registry + _ = registry.save(queueInfo, destination, cd.logger) + + cd.queue.HasCompleted(queueInfo) + + if queueInfo.Status != QueueStatusErrored { + cd.logger.Info().Msgf("chapter downloader: Finished downloading chapter %s", queueInfo.ChapterId) + } + + if queueInfo.Status == QueueStatusErrored { + return fmt.Errorf("chapter downloader: Failed to download chapter %s", queueInfo.ChapterId) + } + + return +} + +// downloadPage downloads a single page from the URL and saves it to the destination directory. +// It also updates the Registry with the page information. +func (cd *Downloader) downloadPage(page *hibikemanga.ChapterPage, destination string, registry *Registry) { + + defer util.HandlePanicInModuleThen("manga/downloader/downloadImage", func() { + }) + + // Download image from URL + + imgID := fmt.Sprintf("%02d", page.Index+1) + + buf, err := manga_providers.GetImageByProxy(page.URL, page.Headers) + if err != nil { + cd.logger.Error().Err(err).Msgf("chapter downloader: Failed to get image from URL %s", page.URL) + return + } + + // Get the image format + config, format, err := image.DecodeConfig(bytes.NewReader(buf)) + if err != nil { + cd.logger.Error().Err(err).Msgf("chapter downloader: Failed to decode image format from URL %s", page.URL) + return + } + + filename := imgID + "." + format + + // Create the file + filePath := filepath.Join(destination, filename) + file, err := os.Create(filePath) + if err != nil { + cd.logger.Error().Err(err).Msgf("chapter downloader: Failed to create file for image %s", imgID) + return + } + defer file.Close() + + // Copy the image data to the file + _, err = io.Copy(file, bytes.NewReader(buf)) + if err != nil { + cd.logger.Error().Err(err).Msgf("image downloader: Failed to write image data to file for image from %s", page.URL) + return + } + + // Update registry + cd.downloadMu.Lock() + (*registry)[page.Index] = PageInfo{ + Index: page.Index, + Width: config.Width, + Height: config.Height, + Filename: filename, + OriginalURL: page.URL, + Size: int64(len(buf)), + } + cd.downloadMu.Unlock() + + return +} + +//////////////////////// + +// save saves the Registry content to a file in the chapter directory. +func (r *Registry) save(queueInfo *QueueInfo, destination string, logger *zerolog.Logger) (err error) { + + defer util.HandlePanicInModuleThen("manga/downloader/save", func() { + err = fmt.Errorf("chapter downloader: Failed to save registry content") + }) + + // Verify all images have been downloaded + allDownloaded := true + for _, page := range queueInfo.Pages { + if _, ok := (*r)[page.Index]; !ok { + allDownloaded = false + break + } + } + + if !allDownloaded { + // Clean up downloaded images + logger.Error().Msg("chapter downloader: Not all images have been downloaded, aborting") + queueInfo.Status = QueueStatusErrored + // Delete directory + go os.RemoveAll(destination) + return fmt.Errorf("chapter downloader: Not all images have been downloaded, operation aborted") + } + + // Create registry file + var data []byte + data, err = json.Marshal(*r) + if err != nil { + return err + } + + registryFilePath := filepath.Join(destination, "registry.json") + err = os.WriteFile(registryFilePath, data, 0644) + if err != nil { + return err + } + + return +} + +func (cd *Downloader) getChapterDownloadDir(downloadId DownloadID) string { + return filepath.Join(cd.downloadDir, FormatChapterDirName(downloadId.Provider, downloadId.MediaId, downloadId.ChapterId, downloadId.ChapterNumber)) +} + +func FormatChapterDirName(provider string, mediaId int, chapterId string, chapterNumber string) string { + return fmt.Sprintf("%s_%d_%s_%s", provider, mediaId, EscapeChapterID(chapterId), chapterNumber) +} + +// ParseChapterDirName parses a chapter directory name and returns the DownloadID. +// e.g. comick_1234_chapter$UNDERSCORE$id_13.5 -> {Provider: "comick", MediaId: 1234, ChapterId: "chapter_id", ChapterNumber: "13.5"} +func ParseChapterDirName(dirName string) (id DownloadID, ok bool) { + parts := strings.Split(dirName, "_") + if len(parts) != 4 { + return id, false + } + + id.Provider = parts[0] + var err error + id.MediaId, err = strconv.Atoi(parts[1]) + if err != nil { + return id, false + } + id.ChapterId = UnescapeChapterID(parts[2]) + id.ChapterNumber = parts[3] + + ok = true + return +} + +func EscapeChapterID(id string) string { + id = strings.ReplaceAll(id, "/", "$SLASH$") + id = strings.ReplaceAll(id, "\\", "$BSLASH$") + id = strings.ReplaceAll(id, ":", "$COLON$") + id = strings.ReplaceAll(id, "*", "$ASTERISK$") + id = strings.ReplaceAll(id, "?", "$QUESTION$") + id = strings.ReplaceAll(id, "\"", "$QUOTE$") + id = strings.ReplaceAll(id, "<", "$LT$") + id = strings.ReplaceAll(id, ">", "$GT$") + id = strings.ReplaceAll(id, "|", "$PIPE$") + id = strings.ReplaceAll(id, ".", "$DOT$") + id = strings.ReplaceAll(id, " ", "$SPACE$") + id = strings.ReplaceAll(id, "_", "$UNDERSCORE$") + return id +} + +func UnescapeChapterID(id string) string { + id = strings.ReplaceAll(id, "$SLASH$", "/") + id = strings.ReplaceAll(id, "$BSLASH$", "\\") + id = strings.ReplaceAll(id, "$COLON$", ":") + id = strings.ReplaceAll(id, "$ASTERISK$", "*") + id = strings.ReplaceAll(id, "$QUESTION$", "?") + id = strings.ReplaceAll(id, "$QUOTE$", "\"") + id = strings.ReplaceAll(id, "$LT$", "<") + id = strings.ReplaceAll(id, "$GT$", ">") + id = strings.ReplaceAll(id, "$PIPE$", "|") + id = strings.ReplaceAll(id, "$DOT$", ".") + id = strings.ReplaceAll(id, "$SPACE$", " ") + id = strings.ReplaceAll(id, "$UNDERSCORE$", "_") + return id +} + +func (cd *Downloader) getChapterRegistryPath(downloadId DownloadID) string { + return filepath.Join(cd.getChapterDownloadDir(downloadId), "registry.json") +} diff --git a/seanime-2.9.10/internal/manga/downloader/chapter_downloader_test.go b/seanime-2.9.10/internal/manga/downloader/chapter_downloader_test.go new file mode 100644 index 0000000..05a4730 --- /dev/null +++ b/seanime-2.9.10/internal/manga/downloader/chapter_downloader_test.go @@ -0,0 +1,112 @@ +package chapter_downloader + +import ( + "github.com/stretchr/testify/assert" + "seanime/internal/database/db" + "seanime/internal/events" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/manga/providers" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + "time" +) + +func TestQueue(t *testing.T) { + test_utils.InitTestProvider(t) + + tempDir := t.TempDir() + + logger := util.NewLogger() + database, err := db.NewDatabase(tempDir, test_utils.ConfigData.Database.Name, logger) + if err != nil { + t.Fatalf("Failed to create database: %v", err) + } + + downloadDir := t.TempDir() + + downloader := NewDownloader(&NewDownloaderOptions{ + Logger: logger, + WSEventManager: events.NewMockWSEventManager(logger), + Database: database, + DownloadDir: downloadDir, + }) + + downloader.Start() + + tests := []struct { + name string + providerName string + provider hibikemanga.Provider + mangaId string + mediaId int + chapterIndex uint + }{ + { + providerName: manga_providers.ComickProvider, + provider: manga_providers.NewComicK(util.NewLogger()), + name: "Jujutsu Kaisen", + mangaId: "TA22I5O7", + chapterIndex: 258, + mediaId: 101517, + }, + { + providerName: manga_providers.ComickProvider, + provider: manga_providers.NewComicK(util.NewLogger()), + name: "Jujutsu Kaisen", + mangaId: "TA22I5O7", + chapterIndex: 259, + mediaId: 101517, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + // SETUP + chapters, err := tt.provider.FindChapters(tt.mangaId) + if assert.NoError(t, err, "comick.FindChapters() error") { + + assert.NotEmpty(t, chapters, "chapters is empty") + + var chapterInfo *hibikemanga.ChapterDetails + for _, chapter := range chapters { + if chapter.Index == tt.chapterIndex { + chapterInfo = chapter + break + } + } + + if assert.NotNil(t, chapterInfo, "chapter not found") { + pages, err := tt.provider.FindChapterPages(chapterInfo.ID) + if assert.NoError(t, err, "provider.FindChapterPages() error") { + assert.NotEmpty(t, pages, "pages is empty") + + // + // TEST + // + err := downloader.AddToQueue(DownloadOptions{ + DownloadID: DownloadID{ + Provider: string(tt.providerName), + MediaId: tt.mediaId, + ChapterId: chapterInfo.ID, + ChapterNumber: chapterInfo.Chapter, + }, + Pages: pages, + StartNow: true, + }) + if err != nil { + t.Fatalf("Failed to download chapter: %v", err) + } + + } + } + } + + }) + + } + + time.Sleep(10 * time.Second) +} diff --git a/seanime-2.9.10/internal/manga/downloader/queue.go b/seanime-2.9.10/internal/manga/downloader/queue.go new file mode 100644 index 0000000..d0fdf50 --- /dev/null +++ b/seanime-2.9.10/internal/manga/downloader/queue.go @@ -0,0 +1,223 @@ +package chapter_downloader + +import ( + "github.com/goccy/go-json" + "github.com/rs/zerolog" + "seanime/internal/database/db" + "seanime/internal/database/models" + "seanime/internal/events" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "sync" + "time" +) + +const ( + QueueStatusNotStarted QueueStatus = "not_started" + QueueStatusDownloading QueueStatus = "downloading" + QueueStatusErrored QueueStatus = "errored" +) + +type ( + // Queue is used to manage the download queue. + // If feeds the downloader with the next item in the queue. + Queue struct { + logger *zerolog.Logger + mu sync.Mutex + db *db.Database + current *QueueInfo + runCh chan *QueueInfo // Channel to tell downloader to run the next item + active bool + wsEventManager events.WSEventManagerInterface + } + + QueueStatus string + + // QueueInfo stores details about the download progress of a chapter. + QueueInfo struct { + DownloadID + Pages []*hibikemanga.ChapterPage + DownloadedUrls []string + Status QueueStatus + } +) + +func NewQueue(db *db.Database, logger *zerolog.Logger, wsEventManager events.WSEventManagerInterface, runCh chan *QueueInfo) *Queue { + return &Queue{ + logger: logger, + db: db, + runCh: runCh, + wsEventManager: wsEventManager, + } +} + +// Add adds a chapter to the download queue. +// It tells the queue to download the next item if possible. +func (q *Queue) Add(id DownloadID, pages []*hibikemanga.ChapterPage, runNext bool) error { + q.mu.Lock() + defer q.mu.Unlock() + + marshalled, err := json.Marshal(pages) + if err != nil { + q.logger.Error().Err(err).Msgf("Failed to marshal pages for id %v", id) + return err + } + + err = q.db.InsertChapterDownloadQueueItem(&models.ChapterDownloadQueueItem{ + BaseModel: models.BaseModel{}, + Provider: id.Provider, + MediaID: id.MediaId, + ChapterNumber: id.ChapterNumber, + ChapterID: id.ChapterId, + PageData: marshalled, + Status: string(QueueStatusNotStarted), + }) + if err != nil { + q.logger.Error().Err(err).Msgf("Failed to insert chapter download queue item for id %v", id) + return err + } + + q.logger.Info().Msgf("chapter downloader: Added chapter to download queue: %s", id.ChapterId) + + q.wsEventManager.SendEvent(events.ChapterDownloadQueueUpdated, nil) + + if runNext && q.active { + // Tells queue to run next if possible + go q.runNext() + } + + return nil +} + +func (q *Queue) HasCompleted(queueInfo *QueueInfo) { + q.mu.Lock() + defer q.mu.Unlock() + + if queueInfo.Status == QueueStatusErrored { + q.logger.Warn().Msgf("chapter downloader: Errored %s", queueInfo.DownloadID.ChapterId) + // Update the status of the current item in the database. + _ = q.db.UpdateChapterDownloadQueueItemStatus(q.current.DownloadID.Provider, q.current.DownloadID.MediaId, q.current.DownloadID.ChapterId, string(QueueStatusErrored)) + } else { + q.logger.Debug().Msgf("chapter downloader: Dequeueing %s", queueInfo.DownloadID.ChapterId) + // Dequeue the item from the database. + _, err := q.db.DequeueChapterDownloadQueueItem() + if err != nil { + q.logger.Error().Err(err).Msgf("Failed to dequeue chapter download queue item for id %v", queueInfo.DownloadID) + return + } + } + + q.wsEventManager.SendEvent(events.ChapterDownloadQueueUpdated, nil) + q.wsEventManager.SendEvent(events.RefreshedMangaDownloadData, nil) + + // Reset current item + q.current = nil + + if q.active { + // Tells queue to run next if possible + q.runNext() + } +} + +// Run activates the queue and invokes runNext +func (q *Queue) Run() { + q.mu.Lock() + defer q.mu.Unlock() + + if !q.active { + q.logger.Debug().Msg("chapter downloader: Starting queue") + } + + q.active = true + + // Tells queue to run next if possible + q.runNext() +} + +// Stop deactivates the queue +func (q *Queue) Stop() { + q.mu.Lock() + defer q.mu.Unlock() + + if q.active { + q.logger.Debug().Msg("chapter downloader: Stopping queue") + } + + q.active = false +} + +// runNext runs the next item in the queue. +// - Checks if there is a current item, if so, it returns. +// - If nothing is running, it gets the next item (QueueInfo) from the database, sets it as current and sends it to the downloader. +func (q *Queue) runNext() { + + q.logger.Debug().Msg("chapter downloader: Processing next item in queue") + + // Catch panic in runNext, so it doesn't bubble up and stop goroutines. + defer util.HandlePanicInModuleThen("internal/manga/downloader/runNext", func() { + q.logger.Error().Msg("chapter downloader: Panic in 'runNext'") + }) + + if q.current != nil { + q.logger.Debug().Msg("chapter downloader: Current item is not nil") + return + } + + q.logger.Debug().Msg("chapter downloader: Checking next item in queue") + + // Get next item from the database. + next, _ := q.db.GetNextChapterDownloadQueueItem() + if next == nil { + q.logger.Debug().Msg("chapter downloader: No next item in queue") + return + } + + id := DownloadID{ + Provider: next.Provider, + MediaId: next.MediaID, + ChapterId: next.ChapterID, + ChapterNumber: next.ChapterNumber, + } + + q.logger.Debug().Msgf("chapter downloader: Preparing next item in queue: %s", id.ChapterId) + + q.wsEventManager.SendEvent(events.ChapterDownloadQueueUpdated, nil) + // Update status + _ = q.db.UpdateChapterDownloadQueueItemStatus(id.Provider, id.MediaId, id.ChapterId, string(QueueStatusDownloading)) + + // Set the current item. + q.current = &QueueInfo{ + DownloadID: id, + DownloadedUrls: make([]string, 0), + Status: QueueStatusDownloading, + } + + // Unmarshal the page data. + err := json.Unmarshal(next.PageData, &q.current.Pages) + if err != nil { + q.logger.Error().Err(err).Msgf("Failed to unmarshal pages for id %v", id) + _ = q.db.UpdateChapterDownloadQueueItemStatus(id.Provider, id.MediaId, id.ChapterId, string(QueueStatusNotStarted)) + return + } + + // TODO: This is a temporary fix to prevent the downloader from running too fast. + time.Sleep(5 * time.Second) + + q.logger.Info().Msgf("chapter downloader: Running next item in queue: %s", id.ChapterId) + + // Tell Downloader to run + q.runCh <- q.current +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (q *Queue) GetCurrent() (qi *QueueInfo, ok bool) { + q.mu.Lock() + defer q.mu.Unlock() + + if q.current == nil { + return nil, false + } + + return q.current, true +} diff --git a/seanime-2.9.10/internal/manga/downloads.go b/seanime-2.9.10/internal/manga/downloads.go new file mode 100644 index 0000000..038acbf --- /dev/null +++ b/seanime-2.9.10/internal/manga/downloads.go @@ -0,0 +1,323 @@ +package manga + +import ( + "cmp" + "fmt" + "os" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/extension" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/hook" + chapter_downloader "seanime/internal/manga/downloader" + manga_providers "seanime/internal/manga/providers" + "slices" + + "github.com/goccy/go-json" +) + +// GetDownloadedMangaChapterContainers retrieves downloaded chapter containers for a specific manga ID. +// It filters the complete set of downloaded chapters to return only those matching the provided manga ID. +func (r *Repository) GetDownloadedMangaChapterContainers(mId int, mangaCollection *anilist.MangaCollection) (ret []*ChapterContainer, err error) { + + containers, err := r.GetDownloadedChapterContainers(mangaCollection) + if err != nil { + return nil, err + } + + for _, container := range containers { + if container.MediaId == mId { + ret = append(ret, container) + } + } + + return ret, nil +} + +// GetDownloadedChapterContainers retrieves all downloaded manga chapter containers. +// It scans the download directory for chapter folders, matches them with manga collection entries, +// and collects chapter details from file cache or provider API when necessary. +// +// Ideally, the provider API should never be called assuming the chapter details are cached. +func (r *Repository) GetDownloadedChapterContainers(mangaCollection *anilist.MangaCollection) (ret []*ChapterContainer, err error) { + ret = make([]*ChapterContainer, 0) + + // Trigger hook event + reqEvent := &MangaDownloadedChapterContainersRequestedEvent{ + MangaCollection: mangaCollection, + ChapterContainers: ret, + } + err = hook.GlobalHookManager.OnMangaDownloadedChapterContainersRequested().Trigger(reqEvent) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Exception occurred while triggering hook event") + return nil, fmt.Errorf("manga: Error in hook, %w", err) + } + mangaCollection = reqEvent.MangaCollection + + // Default prevented, return the chapter containers + if reqEvent.DefaultPrevented { + ret = reqEvent.ChapterContainers + if ret == nil { + return nil, fmt.Errorf("manga: No chapter containers returned by hook event") + } + return ret, nil + } + + // Read download directory + files, err := os.ReadDir(r.downloadDir) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Failed to read download directory") + return nil, err + } + + // Get all chapter directories + // e.g. manga_comick_123_10010_13 + chapterDirs := make([]string, 0) + for _, file := range files { + if file.IsDir() { + _, ok := chapter_downloader.ParseChapterDirName(file.Name()) + if !ok { + continue + } + chapterDirs = append(chapterDirs, file.Name()) + } + } + + if len(chapterDirs) == 0 { + return nil, nil + } + + // Now that we have all the chapter directories, we can get the chapter containers + + keys := make([]*chapter_downloader.DownloadID, 0) + for _, dir := range chapterDirs { + downloadId, ok := chapter_downloader.ParseChapterDirName(dir) + if !ok { + continue + } + keys = append(keys, &downloadId) + } + + providerAndMediaIdPairs := make(map[struct { + provider string + mediaId int + }]bool) + + for _, key := range keys { + providerAndMediaIdPairs[struct { + provider string + mediaId int + }{ + provider: key.Provider, + mediaId: key.MediaId, + }] = true + } + + // Get the chapter containers + for pair := range providerAndMediaIdPairs { + provider := pair.provider + mediaId := pair.mediaId + + // Get the manga from the collection + mangaEntry, ok := mangaCollection.GetListEntryFromMangaId(mediaId) + if !ok { + r.logger.Warn().Int("mediaId", mediaId).Msg("manga: [GetDownloadedChapterContainers] Manga not found in collection") + continue + } + + // Get the list of chapters for the manga + // Check the permanent file cache + container, found := r.getChapterContainerFromPermanentFilecache(provider, mediaId) + if !found { + // Check the temporary file cache + container, found = r.getChapterContainerFromFilecache(provider, mediaId) + if !found { + // Get the chapters from the provider + // This stays here for backwards compatibility, but ideally the method should not require an internet connection + // so this will fail if the chapters were not cached & with no internet + opts := GetMangaChapterContainerOptions{ + Provider: provider, + MediaId: mediaId, + Titles: mangaEntry.GetMedia().GetAllTitles(), + Year: mangaEntry.GetMedia().GetStartYearSafe(), + } + container, err = r.GetMangaChapterContainer(&opts) + if err != nil { + r.logger.Error().Err(err).Int("mediaId", mediaId).Msg("manga: [GetDownloadedChapterContainers] Failed to retrieve cached list of manga chapters") + continue + } + // Cache the chapter container in the permanent bucket + go func() { + chapterContainerKey := getMangaChapterContainerCacheKey(provider, mediaId) + chapterContainer, found := r.getChapterContainerFromFilecache(provider, mediaId) + if found { + // Store the chapter container in the permanent bucket + permBucket := getPermanentChapterContainerCacheBucket(provider, mediaId) + _ = r.fileCacher.SetPerm(permBucket, chapterContainerKey, chapterContainer) + } + }() + } + } else { + r.logger.Trace().Int("mediaId", mediaId).Msg("manga: Found chapter container in permanent bucket") + } + + downloadedContainer := &ChapterContainer{ + MediaId: container.MediaId, + Provider: container.Provider, + Chapters: make([]*hibikemanga.ChapterDetails, 0), + } + + // Now that we have the container, we'll filter out the chapters that are not downloaded + // Go through each chapter and check if it's downloaded + for _, chapter := range container.Chapters { + // For each chapter, check if the chapter directory exists + for _, dir := range chapterDirs { + if dir == chapter_downloader.FormatChapterDirName(provider, mediaId, chapter.ID, chapter.Chapter) { + downloadedContainer.Chapters = append(downloadedContainer.Chapters, chapter) + break + } + } + } + + if len(downloadedContainer.Chapters) == 0 { + continue + } + + ret = append(ret, downloadedContainer) + } + + // Add chapter containers from local provider + localProviderB, ok := extension.GetExtension[extension.MangaProviderExtension](r.providerExtensionBank, manga_providers.LocalProvider) + if ok { + _, ok := localProviderB.GetProvider().(*manga_providers.Local) + if ok { + for _, list := range mangaCollection.MediaListCollection.GetLists() { + for _, entry := range list.GetEntries() { + media := entry.GetMedia() + opts := GetMangaChapterContainerOptions{ + Provider: manga_providers.LocalProvider, + MediaId: media.GetID(), + Titles: media.GetAllTitles(), + Year: media.GetStartYearSafe(), + } + container, err := r.GetMangaChapterContainer(&opts) + if err != nil { + continue + } + ret = append(ret, container) + } + } + } + } + + // Event + ev := &MangaDownloadedChapterContainersEvent{ + ChapterContainers: ret, + } + err = hook.GlobalHookManager.OnMangaDownloadedChapterContainers().Trigger(ev) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Exception occurred while triggering hook event") + return nil, fmt.Errorf("manga: Error in hook, %w", err) + } + ret = ev.ChapterContainers + + return ret, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// getDownloadedMangaPageContainer retrieves page information for a downloaded manga chapter. +// It reads the chapter directory and parses the registry file to build a PageContainer +// with details about each downloaded page including dimensions and file paths. +func (r *Repository) getDownloadedMangaPageContainer( + provider string, + mediaId int, + chapterId string, +) (*PageContainer, error) { + + // Check if the chapter is downloaded + found := false + + // Read download directory + files, err := os.ReadDir(r.downloadDir) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Failed to read download directory") + return nil, err + } + + chapterDir := "" // e.g. manga_comick_123_10010_13 + for _, file := range files { + if file.IsDir() { + + downloadId, ok := chapter_downloader.ParseChapterDirName(file.Name()) + if !ok { + continue + } + + if downloadId.Provider == provider && + downloadId.MediaId == mediaId && + downloadId.ChapterId == chapterId { + found = true + chapterDir = file.Name() + break + } + } + } + + if !found { + return nil, ErrChapterNotDownloaded + } + + r.logger.Debug().Msg("manga: Found downloaded chapter directory") + + // Open registry file + registryFile, err := os.Open(filepath.Join(r.downloadDir, chapterDir, "registry.json")) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Failed to open registry file") + return nil, err + } + defer registryFile.Close() + + r.logger.Debug().Str("chapterId", chapterId).Msg("manga: Reading registry file") + + // Read registry file + var pageRegistry *chapter_downloader.Registry + err = json.NewDecoder(registryFile).Decode(&pageRegistry) + if err != nil { + r.logger.Error().Err(err).Msg("manga: Failed to decode registry file") + return nil, err + } + + pageList := make([]*hibikemanga.ChapterPage, 0) + pageDimensions := make(map[int]*PageDimension) + + // Get the downloaded pages + for pageIndex, pageInfo := range *pageRegistry { + pageList = append(pageList, &hibikemanga.ChapterPage{ + Index: pageIndex, + URL: filepath.Join(chapterDir, pageInfo.Filename), + Provider: provider, + }) + pageDimensions[pageIndex] = &PageDimension{ + Width: pageInfo.Width, + Height: pageInfo.Height, + } + } + + slices.SortStableFunc(pageList, func(i, j *hibikemanga.ChapterPage) int { + return cmp.Compare(i.Index, j.Index) + }) + + container := &PageContainer{ + MediaId: mediaId, + Provider: provider, + ChapterId: chapterId, + Pages: pageList, + PageDimensions: pageDimensions, + IsDownloaded: true, + } + + r.logger.Debug().Str("chapterId", chapterId).Msg("manga: Found downloaded chapter") + + return container, nil +} diff --git a/seanime-2.9.10/internal/manga/hook_events.go b/seanime-2.9.10/internal/manga/hook_events.go new file mode 100644 index 0000000..d47f90a --- /dev/null +++ b/seanime-2.9.10/internal/manga/hook_events.go @@ -0,0 +1,85 @@ +package manga + +import ( + "seanime/internal/api/anilist" + "seanime/internal/hook_resolver" +) + +// MangaEntryRequestedEvent is triggered when a manga entry is requested. +// Prevent default to skip the default behavior and return the modified entry. +// If the modified entry is nil, an error will be returned. +type MangaEntryRequestedEvent struct { + hook_resolver.Event + MediaId int `json:"mediaId"` + MangaCollection *anilist.MangaCollection `json:"mangaCollection"` + // Empty entry object, will be used if the hook prevents the default behavior + Entry *Entry `json:"entry"` +} + +// MangaEntryEvent is triggered when the manga entry is being returned. +type MangaEntryEvent struct { + hook_resolver.Event + Entry *Entry `json:"entry"` +} + +// MangaLibraryCollectionRequestedEvent is triggered when the manga library collection is being requested. +type MangaLibraryCollectionRequestedEvent struct { + hook_resolver.Event + MangaCollection *anilist.MangaCollection `json:"mangaCollection"` +} + +// MangaLibraryCollectionEvent is triggered when the manga library collection is being returned. +type MangaLibraryCollectionEvent struct { + hook_resolver.Event + LibraryCollection *Collection `json:"libraryCollection"` +} + +// MangaDownloadedChapterContainersRequestedEvent is triggered when the manga downloaded chapter containers are being requested. +// Prevent default to skip the default behavior and return the modified chapter containers. +// If the modified chapter containers are nil, an error will be returned. +type MangaDownloadedChapterContainersRequestedEvent struct { + hook_resolver.Event + MangaCollection *anilist.MangaCollection `json:"mangaCollection"` + // Empty chapter containers object, will be used if the hook prevents the default behavior + ChapterContainers []*ChapterContainer `json:"chapterContainers"` +} + +// MangaDownloadedChapterContainersEvent is triggered when the manga downloaded chapter containers are being returned. +type MangaDownloadedChapterContainersEvent struct { + hook_resolver.Event + ChapterContainers []*ChapterContainer `json:"chapterContainers"` +} + +// MangaLatestChapterNumbersMapEvent is triggered when the manga latest chapter numbers map is being returned. +type MangaLatestChapterNumbersMapEvent struct { + hook_resolver.Event + LatestChapterNumbersMap map[int][]MangaLatestChapterNumberItem `json:"latestChapterNumbersMap"` +} + +// MangaDownloadMapEvent is triggered when the manga download map has been updated. +// This map is used to tell the client which chapters have been downloaded. +type MangaDownloadMapEvent struct { + hook_resolver.Event + MediaMap *MediaMap `json:"mediaMap"` +} + +// MangaChapterContainerRequestedEvent is triggered when the manga chapter container is being requested. +// This event happens before the chapter container is fetched from the cache or provider. +// Prevent default to skip the default behavior and return the modified chapter container. +// If the modified chapter container is nil, an error will be returned. +type MangaChapterContainerRequestedEvent struct { + hook_resolver.Event + Provider string `json:"provider"` + MediaId int `json:"mediaId"` + Titles []*string `json:"titles"` + Year int `json:"year"` + // Empty chapter container object, will be used if the hook prevents the default behavior + ChapterContainer *ChapterContainer `json:"chapterContainer"` +} + +// MangaChapterContainerEvent is triggered when the manga chapter container is being returned. +// This event happens after the chapter container is fetched from the cache or provider. +type MangaChapterContainerEvent struct { + hook_resolver.Event + ChapterContainer *ChapterContainer `json:"chapterContainer"` +} diff --git a/seanime-2.9.10/internal/manga/image_size_test.go b/seanime-2.9.10/internal/manga/image_size_test.go new file mode 100644 index 0000000..bc98ef9 --- /dev/null +++ b/seanime-2.9.10/internal/manga/image_size_test.go @@ -0,0 +1,18 @@ +package manga + +import ( + "github.com/davecgh/go-spew/spew" + _ "image/jpeg" // Register JPEG format + _ "image/png" // Register PNG format + "testing" +) + +func TestGetImageNaturalSize(t *testing.T) { + // Test the function + width, height, err := getImageNaturalSize("https://scans-hot.leanbox.us/manga/One-Piece/1090-001.png") + if err != nil { + t.Fatal(err) + } + + spew.Dump(width, height) +} diff --git a/seanime-2.9.10/internal/manga/manga_chapter_downloads_test.go b/seanime-2.9.10/internal/manga/manga_chapter_downloads_test.go new file mode 100644 index 0000000..b1ff6ce --- /dev/null +++ b/seanime-2.9.10/internal/manga/manga_chapter_downloads_test.go @@ -0,0 +1,60 @@ +package manga + +import ( + "context" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/events" + "seanime/internal/test_utils" + "seanime/internal/util" + "seanime/internal/util/filecache" + "testing" +) + +func TestGetDownloadedChapterContainers(t *testing.T) { + t.Skip("include database") + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t, test_utils.Anilist()) + + anilistClient := anilist.TestGetMockAnilistClient() + + mangaCollection, err := anilistClient.MangaCollection(context.Background(), &test_utils.ConfigData.Provider.AnilistUsername) + if err != nil { + t.Fatal(err) + } + + logger := util.NewLogger() + cacheDir := filepath.Join(test_utils.ConfigData.Path.DataDir, "cache") + fileCacher, err := filecache.NewCacher(cacheDir) + if err != nil { + t.Fatal(err) + } + + repository := NewRepository(&NewRepositoryOptions{ + Logger: logger, + FileCacher: fileCacher, + CacheDir: cacheDir, + ServerURI: "", + WsEventManager: events.NewMockWSEventManager(logger), + DownloadDir: filepath.Join(test_utils.ConfigData.Path.DataDir, "manga"), + Database: nil, // FIX + }) + + // Test + containers, err := repository.GetDownloadedChapterContainers(mangaCollection) + if err != nil { + t.Fatal(err) + } + + for _, container := range containers { + t.Logf("MediaId: %d", container.MediaId) + t.Logf("Provider: %s", container.Provider) + t.Logf("Chapters: ") + for _, chapter := range container.Chapters { + t.Logf(" %s", chapter.Title) + } + t.Log("-----------------------------------") + t.Log("") + } + +} diff --git a/seanime-2.9.10/internal/manga/manga_entry.go b/seanime-2.9.10/internal/manga/manga_entry.go new file mode 100644 index 0000000..2476b58 --- /dev/null +++ b/seanime-2.9.10/internal/manga/manga_entry.go @@ -0,0 +1,114 @@ +package manga + +import ( + "context" + "errors" + "seanime/internal/api/anilist" + "seanime/internal/hook" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/platforms/platform" + "seanime/internal/util/filecache" + + "github.com/rs/zerolog" +) + +type ( + // Entry is fetched when the user goes to the manga entry page. + Entry struct { + MediaId int `json:"mediaId"` + Media *anilist.BaseManga `json:"media"` + EntryListData *EntryListData `json:"listData,omitempty"` + } + + EntryListData struct { + Progress int `json:"progress,omitempty"` + Score float64 `json:"score,omitempty"` + Status *anilist.MediaListStatus `json:"status,omitempty"` + Repeat int `json:"repeat,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + CompletedAt string `json:"completedAt,omitempty"` + } +) + +type ( + // NewEntryOptions is the options for creating a new manga entry. + NewEntryOptions struct { + MediaId int + Logger *zerolog.Logger + FileCacher *filecache.Cacher + MangaCollection *anilist.MangaCollection + Platform platform.Platform + } +) + +// NewEntry creates a new manga entry. +func NewEntry(ctx context.Context, opts *NewEntryOptions) (entry *Entry, err error) { + entry = &Entry{ + MediaId: opts.MediaId, + } + + reqEvent := new(MangaEntryRequestedEvent) + reqEvent.MediaId = opts.MediaId + reqEvent.MangaCollection = opts.MangaCollection + reqEvent.Entry = entry + + err = hook.GlobalHookManager.OnMangaEntryRequested().Trigger(reqEvent) + if err != nil { + return nil, err + } + opts.MediaId = reqEvent.MediaId // Override the media ID + opts.MangaCollection = reqEvent.MangaCollection // Override the manga collection + entry = reqEvent.Entry // Override the entry + + if reqEvent.DefaultPrevented { + mangaEvent := new(MangaEntryEvent) + mangaEvent.Entry = reqEvent.Entry + err = hook.GlobalHookManager.OnMangaEntry().Trigger(mangaEvent) + if err != nil { + return nil, err + } + + if mangaEvent.Entry == nil { + return nil, errors.New("no entry was returned") + } + return mangaEvent.Entry, nil + } + + anilistEntry, found := opts.MangaCollection.GetListEntryFromMangaId(opts.MediaId) + + // If the entry is not found, we fetch the manga from the Anilist API. + if !found { + media, err := opts.Platform.GetManga(ctx, opts.MediaId) + if err != nil { + return nil, err + } + entry.Media = media + + } else { + // If the entry is found, we use the entry from the collection. + mangaEvent := new(anilist_platform.GetMangaEvent) + mangaEvent.Manga = anilistEntry.GetMedia() + err := hook.GlobalHookManager.OnGetManga().Trigger(mangaEvent) + if err != nil { + return nil, err + } + entry.Media = mangaEvent.Manga + entry.EntryListData = &EntryListData{ + Progress: *anilistEntry.Progress, + Score: *anilistEntry.Score, + Status: anilistEntry.Status, + Repeat: anilistEntry.GetRepeatSafe(), + StartedAt: anilist.FuzzyDateToString(anilistEntry.StartedAt), + CompletedAt: anilist.FuzzyDateToString(anilistEntry.CompletedAt), + } + } + + mangaEvent := new(MangaEntryEvent) + mangaEvent.Entry = entry + err = hook.GlobalHookManager.OnMangaEntry().Trigger(mangaEvent) + if err != nil { + return nil, err + } + + return mangaEvent.Entry, nil +} diff --git a/seanime-2.9.10/internal/manga/mock.go b/seanime-2.9.10/internal/manga/mock.go new file mode 100644 index 0000000..48df119 --- /dev/null +++ b/seanime-2.9.10/internal/manga/mock.go @@ -0,0 +1,32 @@ +package manga + +import ( + "path/filepath" + "seanime/internal/database/db" + "seanime/internal/events" + "seanime/internal/test_utils" + "seanime/internal/util" + "seanime/internal/util/filecache" + "testing" +) + +func GetMockRepository(t *testing.T, db *db.Database) *Repository { + logger := util.NewLogger() + cacheDir := filepath.Join(test_utils.ConfigData.Path.DataDir, "cache") + fileCacher, err := filecache.NewCacher(cacheDir) + if err != nil { + t.Fatal(err) + } + + repository := NewRepository(&NewRepositoryOptions{ + Logger: logger, + FileCacher: fileCacher, + CacheDir: cacheDir, + ServerURI: "", + WsEventManager: events.NewMockWSEventManager(logger), + DownloadDir: filepath.Join(test_utils.ConfigData.Path.DataDir, "manga"), + Database: db, + }) + + return repository +} diff --git a/seanime-2.9.10/internal/manga/providers/_local_pdf_test.go b/seanime-2.9.10/internal/manga/providers/_local_pdf_test.go new file mode 100644 index 0000000..c82efbc --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/_local_pdf_test.go @@ -0,0 +1,66 @@ +package manga_providers + +import ( + "bytes" + "image/jpeg" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestConvertPDFToImages(t *testing.T) { + start := time.Now() + + doc, err := fitz.New("") + require.NoError(t, err) + defer doc.Close() + + images := make(map[int][]byte, doc.NumPage()) + + // Load images into memory + for n := 0; n < doc.NumPage(); n++ { + img, err := doc.Image(n) + if err != nil { + panic(err) + } + + var buf bytes.Buffer + err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality}) + if err != nil { + panic(err) + } + + images[n] = buf.Bytes() + } + + end := time.Now() + + t.Logf("Converted %d pages in %f seconds", len(images), end.Sub(start).Seconds()) + + for n, imgData := range images { + t.Logf("Page %d: %d bytes", n, len(imgData)) + } + + //tmpDir, err := os.MkdirTemp(os.TempDir(), "manga_test_") + //require.NoError(t, err) + //if len(images) > 0 { + // // Write the first image to a file for verification + // firstImagePath := tmpDir + "/page_0.jpg" + // err = os.WriteFile(firstImagePath, images[0], 0644) + // require.NoError(t, err) + // t.Logf("First image written to: %s", firstImagePath) + //} + // + //time.Sleep(1 * time.Minute) + // + //t.Cleanup(func() { + // // Clean up the temporary directory + // err := os.RemoveAll(tmpDir) + // if err != nil { + // t.Logf("Failed to remove temp directory: %v", err) + // } else { + // t.Logf("Temporary directory removed: %s", tmpDir) + // } + //}) +} diff --git a/seanime-2.9.10/internal/manga/providers/_template.go b/seanime-2.9.10/internal/manga/providers/_template.go new file mode 100644 index 0000000..26bf857 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/_template.go @@ -0,0 +1,82 @@ +package manga_providers + +import ( + "github.com/rs/zerolog" + "net/http" + "seanime/internal/util" + "time" +) + +type ( + Template struct { + Url string + Client *http.Client + UserAgent string + logger *zerolog.Logger + } +) + +func NewTemplate(logger *zerolog.Logger) *Template { + c := &http.Client{ + Timeout: 60 * time.Second, + } + c.Transport = util.AddCloudFlareByPass(c.Transport) + return &Template{ + Url: "https://XXXXXX.com", + Client: c, + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3", + logger: logger, + } +} + +func (mp *Template) Search(opts SearchOptions) ([]*SearchResult, error) { + results := make([]*SearchResult, 0) + + mp.logger.Debug().Str("query", opts.Query).Msg("XXXXXX: Searching manga") + + // code + + if len(results) == 0 { + mp.logger.Error().Str("query", opts.Query).Msg("XXXXXX: No results found") + return nil, ErrNoResults + } + + mp.logger.Info().Int("count", len(results)).Msg("XXXXXX: Found results") + + return results, nil +} + +func (mp *Template) FindChapters(id string) ([]*ChapterDetails, error) { + ret := make([]*ChapterDetails, 0) + + mp.logger.Debug().Str("mangaId", id).Msg("XXXXXX: Finding chapters") + + // code + + if len(ret) == 0 { + mp.logger.Error().Str("mangaId", id).Msg("XXXXXX: No chapters found") + return nil, ErrNoChapters + } + + mp.logger.Info().Int("count", len(ret)).Msg("XXXXXX: Found chapters") + + return ret, nil +} + +func (mp *Template) FindChapterPages(id string) ([]*ChapterPage, error) { + ret := make([]*ChapterPage, 0) + + mp.logger.Debug().Str("chapterId", id).Msg("XXXXXX: Finding chapter pages") + + // code + + if len(ret) == 0 { + mp.logger.Error().Str("chapterId", id).Msg("XXXXXX: No pages found") + return nil, ErrNoPages + } + + mp.logger.Info().Int("count", len(ret)).Msg("XXXXXX: Found pages") + + return ret, nil + +} diff --git a/seanime-2.9.10/internal/manga/providers/_template_test.go b/seanime-2.9.10/internal/manga/providers/_template_test.go new file mode 100644 index 0000000..c6587b7 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/_template_test.go @@ -0,0 +1,127 @@ +package manga_providers + +import ( + "github.com/stretchr/testify/assert" + "seanime/internal/util" + "testing" +) + +func TestXXXXXX_Search(t *testing.T) { + + tests := []struct { + name string + query string + }{ + { + name: "Boku no Kokoro no Yabai Yatsu", + query: "Boku no Kokoro no Yabai Yatsu", + }, + } + + provider := NewXXXXXX(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + searchRes, err := provider.Search(SearchOptions{ + Query: tt.query, + }) + if assert.NoError(t, err, "provider.Search() error") { + assert.NotEmpty(t, searchRes, "search result is empty") + + for _, res := range searchRes { + t.Logf("Title: %s", res.Title) + t.Logf("\tID: %s", res.ID) + t.Logf("\tYear: %d", res.Year) + t.Logf("\tImage: %s", res.Image) + t.Logf("\tProvider: %s", res.Provider) + t.Logf("\tSearchRating: %f", res.SearchRating) + t.Logf("\tSynonyms: %v", res.Synonyms) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestXXXXXX_FindChapters(t *testing.T) { + + tests := []struct { + name string + id string + atLeast int + }{ + { + name: "The Dangers in My Heart", + id: "", + atLeast: 141, + }, + } + + provider := NewXXXXXX(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + chapters, err := provider.FindChapters(tt.id) + if assert.NoError(t, err, "provider.FindChapters() error") { + + assert.NotEmpty(t, chapters, "chapters is empty") + + assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected") + + for _, chapter := range chapters { + t.Logf("Title: %s", chapter.Title) + t.Logf("\tSlug: %s", chapter.ID) + t.Logf("\tURL: %s", chapter.URL) + t.Logf("\tIndex: %d", chapter.Index) + t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestXXXXXX_FindChapterPages(t *testing.T) { + + tests := []struct { + name string + chapterId string + }{ + { + name: "The Dangers in My Heart", + chapterId: "", // Chapter 1 + }, + } + + provider := NewXXXXXX(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + pages, err := provider.FindChapterPages(tt.chapterId) + if assert.NoError(t, err, "provider.FindChapterPages() error") { + assert.NotEmpty(t, pages, "pages is empty") + + for _, page := range pages { + t.Logf("Index: %d", page.Index) + t.Logf("\tURL: %s", page.URL) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} diff --git a/seanime-2.9.10/internal/manga/providers/comick.go b/seanime-2.9.10/internal/manga/providers/comick.go new file mode 100644 index 0000000..7a07802 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/comick.go @@ -0,0 +1,376 @@ +package manga_providers + +import ( + "cmp" + "fmt" + "net/url" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "seanime/internal/util/comparison" + "slices" + "strings" + "time" + + "github.com/imroc/req/v3" + "github.com/rs/zerolog" +) + +type ( + ComicK struct { + Url string + Client *req.Client + logger *zerolog.Logger + } + + ComicKResultItem struct { + ID int `json:"id"` + HID string `json:"hid"` + Slug string `json:"slug"` + Title string `json:"title"` + Country string `json:"country"` + Rating string `json:"rating"` + BayesianRating string `json:"bayesian_rating"` + RatingCount int `json:"rating_count"` + FollowCount int `json:"follow_count"` + Description string `json:"desc"` + Status int `json:"status"` + LastChapter float64 `json:"last_chapter"` + TranslationCompleted bool `json:"translation_completed"` + ViewCount int `json:"view_count"` + ContentRating string `json:"content_rating"` + Demographic int `json:"demographic"` + UploadedAt string `json:"uploaded_at"` + Genres []int `json:"genres"` + CreatedAt string `json:"created_at"` + UserFollowCount int `json:"user_follow_count"` + Year int `json:"year"` + MuComics struct { + Year int `json:"year"` + } `json:"mu_comics"` + MdTitles []struct { + Title string `json:"title"` + } `json:"md_titles"` + MdCovers []struct { + W int `json:"w"` + H int `json:"h"` + B2Key string `json:"b2key"` + } `json:"md_covers"` + Highlight string `json:"highlight"` + } +) + +func NewComicK(logger *zerolog.Logger) *ComicK { + client := req.C(). + SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"). + SetTimeout(60 * time.Second). + EnableInsecureSkipVerify(). + ImpersonateSafari() + + return &ComicK{ + Url: "https://api.comick.fun", + Client: client, + logger: logger, + } +} + +// DEVNOTE: Each chapter ID is a unique string provided by ComicK + +func (c *ComicK) GetSettings() hibikemanga.Settings { + return hibikemanga.Settings{ + SupportsMultiScanlator: false, + SupportsMultiLanguage: false, + } +} + +func (c *ComicK) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) { + searchUrl := fmt.Sprintf("%s/v1.0/search?q=%s&limit=25&page=1", c.Url, url.QueryEscape(opts.Query)) + if opts.Year != 0 { + searchUrl += fmt.Sprintf("&from=%d&to=%d", opts.Year, opts.Year) + } + + c.logger.Debug().Str("searchUrl", searchUrl).Msg("comick: Searching manga") + + var data []*ComicKResultItem + resp, err := c.Client.R(). + SetSuccessResult(&data). + Get(searchUrl) + + if err != nil { + c.logger.Error().Err(err).Msg("comick: Failed to send request") + return nil, fmt.Errorf("failed to send request: %w", err) + } + + if !resp.IsSuccessState() { + c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed") + return nil, fmt.Errorf("failed to reach API: status %s", resp.Status) + } + + results := make([]*hibikemanga.SearchResult, 0) + for _, result := range data { + + // Skip fan-colored manga + if strings.Contains(result.Slug, "fan-colored") { + continue + } + + var coverURL string + if len(result.MdCovers) > 0 && result.MdCovers[0].B2Key != "" { + coverURL = "https://meo.comick.pictures/" + result.MdCovers[0].B2Key + } + + altTitles := make([]string, len(result.MdTitles)) + for j, title := range result.MdTitles { + altTitles[j] = title.Title + } + + // DEVNOTE: We don't compare to alt titles because ComicK's synonyms aren't good + compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&result.Title}) + + results = append(results, &hibikemanga.SearchResult{ + ID: result.HID, + Title: cmp.Or(result.Title, result.Slug), + Synonyms: altTitles, + Image: coverURL, + Year: result.Year, + SearchRating: compRes.Rating, + Provider: ComickProvider, + }) + } + + if len(results) == 0 { + c.logger.Warn().Msg("comick: No results found") + return nil, ErrNoChapters + } + + c.logger.Info().Int("count", len(results)).Msg("comick: Found results") + + return results, nil +} + +func (c *ComicK) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) { + ret := make([]*hibikemanga.ChapterDetails, 0) + + c.logger.Debug().Str("mangaId", id).Msg("comick: Fetching chapters") + + uri := fmt.Sprintf("%s/comic/%s/chapters?lang=en&page=0&limit=1000000&chap-order=1", c.Url, id) + + var data struct { + Chapters []*ComicChapter `json:"chapters"` + } + + resp, err := c.Client.R(). + SetSuccessResult(&data). + Get(uri) + + if err != nil { + c.logger.Error().Err(err).Msg("comick: Failed to send request") + return nil, fmt.Errorf("failed to send request: %w", err) + } + + if !resp.IsSuccessState() { + c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed") + return nil, fmt.Errorf("failed to decode response: status %s", resp.Status) + } + + chapters := make([]*hibikemanga.ChapterDetails, 0) + chaptersMap := make(map[string]*hibikemanga.ChapterDetails) + count := 0 + for _, chapter := range data.Chapters { + if chapter.Chap == "" || chapter.Lang != "en" { + continue + } + title := "Chapter " + chapter.Chap + " " + + if title == "" { + if chapter.Title == "" { + title = "Oneshot" + } else { + title = chapter.Title + } + } + title = strings.TrimSpace(title) + + prev, ok := chaptersMap[chapter.Chap] + rating := chapter.UpCount - chapter.DownCount + + if !ok || rating > prev.Rating { + if !ok { + count++ + } + chaptersMap[chapter.Chap] = &hibikemanga.ChapterDetails{ + Provider: ComickProvider, + ID: chapter.HID, + Title: title, + Index: uint(count), + URL: fmt.Sprintf("%s/chapter/%s", c.Url, chapter.HID), + Chapter: chapter.Chap, + Rating: rating, + UpdatedAt: chapter.UpdatedAt, + } + } + } + + for _, chapter := range chaptersMap { + chapters = append(chapters, chapter) + } + + // Sort chapters by index + slices.SortStableFunc(chapters, func(i, j *hibikemanga.ChapterDetails) int { + return cmp.Compare(i.Index, j.Index) + }) + + ret = append(ret, chapters...) + + if len(ret) == 0 { + c.logger.Warn().Msg("comick: No chapters found") + return nil, ErrNoChapters + } + + c.logger.Info().Int("count", len(ret)).Msg("comick: Found chapters") + + return ret, nil +} + +func (c *ComicK) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) { + ret := make([]*hibikemanga.ChapterPage, 0) + + c.logger.Debug().Str("chapterId", id).Msg("comick: Finding chapter pages") + + uri := fmt.Sprintf("%s/chapter/%s", c.Url, id) + + var data struct { + Chapter *ComicChapter `json:"chapter"` + } + + resp, err := c.Client.R(). + SetHeader("User-Agent", util.GetRandomUserAgent()). + SetSuccessResult(&data). + Get(uri) + + if err != nil { + c.logger.Error().Err(err).Msg("comick: Failed to send request") + return nil, fmt.Errorf("failed to send request: %w", err) + } + + if !resp.IsSuccessState() { + c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed") + return nil, fmt.Errorf("failed to decode response: status %s", resp.Status) + } + + if data.Chapter == nil { + c.logger.Error().Msg("comick: Chapter not found") + return nil, fmt.Errorf("chapter not found") + } + + for index, image := range data.Chapter.MdImages { + ret = append(ret, &hibikemanga.ChapterPage{ + Provider: ComickProvider, + URL: fmt.Sprintf("https://meo.comick.pictures/%s", image.B2Key), + Index: index, + Headers: make(map[string]string), + }) + } + + if len(ret) == 0 { + c.logger.Warn().Msg("comick: No pages found") + return nil, ErrNoPages + } + + c.logger.Info().Int("count", len(ret)).Msg("comick: Found pages") + + return ret, nil + +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type Comic struct { + ID int `json:"id"` + HID string `json:"hid"` + Title string `json:"title"` + Country string `json:"country"` + Status int `json:"status"` + Links struct { + AL string `json:"al"` + AP string `json:"ap"` + BW string `json:"bw"` + KT string `json:"kt"` + MU string `json:"mu"` + AMZ string `json:"amz"` + CDJ string `json:"cdj"` + EBJ string `json:"ebj"` + MAL string `json:"mal"` + RAW string `json:"raw"` + } `json:"links"` + LastChapter interface{} `json:"last_chapter"` + ChapterCount int `json:"chapter_count"` + Demographic int `json:"demographic"` + Hentai bool `json:"hentai"` + UserFollowCount int `json:"user_follow_count"` + FollowRank int `json:"follow_rank"` + CommentCount int `json:"comment_count"` + FollowCount int `json:"follow_count"` + Description string `json:"desc"` + Parsed string `json:"parsed"` + Slug string `json:"slug"` + Mismatch interface{} `json:"mismatch"` + Year int `json:"year"` + BayesianRating interface{} `json:"bayesian_rating"` + RatingCount int `json:"rating_count"` + ContentRating string `json:"content_rating"` + TranslationCompleted bool `json:"translation_completed"` + RelateFrom []interface{} `json:"relate_from"` + Mies interface{} `json:"mies"` + MdTitles []struct { + Title string `json:"title"` + } `json:"md_titles"` + MdComicMdGenres []struct { + MdGenres struct { + Name string `json:"name"` + Type interface{} `json:"type"` + Slug string `json:"slug"` + Group string `json:"group"` + } `json:"md_genres"` + } `json:"md_comic_md_genres"` + MuComics struct { + LicensedInEnglish interface{} `json:"licensed_in_english"` + MuComicCategories []struct { + MuCategories struct { + Title string `json:"title"` + Slug string `json:"slug"` + } `json:"mu_categories"` + PositiveVote int `json:"positive_vote"` + NegativeVote int `json:"negative_vote"` + } `json:"mu_comic_categories"` + } `json:"mu_comics"` + MdCovers []struct { + Vol interface{} `json:"vol"` + W int `json:"w"` + H int `json:"h"` + B2Key string `json:"b2key"` + } `json:"md_covers"` + Iso6391 string `json:"iso639_1"` + LangName string `json:"lang_name"` + LangNative string `json:"lang_native"` +} + +type ComicChapter struct { + ID int `json:"id"` + Chap string `json:"chap"` + Title string `json:"title"` + Vol string `json:"vol,omitempty"` + Lang string `json:"lang"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + UpCount int `json:"up_count"` + DownCount int `json:"down_count"` + GroupName []string `json:"group_name"` + HID string `json:"hid"` + MdImages []struct { + Name string `json:"name"` + W int `json:"w"` + H int `json:"h"` + S int `json:"s"` + B2Key string `json:"b2key"` + } `json:"md_images"` +} diff --git a/seanime-2.9.10/internal/manga/providers/comick_multi.go b/seanime-2.9.10/internal/manga/providers/comick_multi.go new file mode 100644 index 0000000..02c1a4c --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/comick_multi.go @@ -0,0 +1,249 @@ +package manga_providers + +import ( + "cmp" + "fmt" + "net/url" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "seanime/internal/util/comparison" + "slices" + "strings" + "time" + + "github.com/imroc/req/v3" + "github.com/rs/zerolog" +) + +type ( + ComicKMulti struct { + Url string + Client *req.Client + logger *zerolog.Logger + } +) + +func NewComicKMulti(logger *zerolog.Logger) *ComicKMulti { + client := req.C(). + SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"). + SetTimeout(60 * time.Second). + EnableInsecureSkipVerify(). + ImpersonateSafari() + + return &ComicKMulti{ + Url: "https://api.comick.fun", + Client: client, + logger: logger, + } +} + +// DEVNOTE: Each chapter ID is a unique string provided by ComicK + +func (c *ComicKMulti) GetSettings() hibikemanga.Settings { + return hibikemanga.Settings{ + SupportsMultiScanlator: true, + SupportsMultiLanguage: true, + } +} + +func (c *ComicKMulti) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) { + + c.logger.Debug().Str("query", opts.Query).Msg("comick: Searching manga") + + searchUrl := fmt.Sprintf("%s/v1.0/search?q=%s&limit=25&page=1", c.Url, url.QueryEscape(opts.Query)) + if opts.Year != 0 { + searchUrl += fmt.Sprintf("&from=%d&to=%d", opts.Year, opts.Year) + } + + var data []*ComicKResultItem + resp, err := c.Client.R(). + SetSuccessResult(&data). + Get(searchUrl) + + if err != nil { + c.logger.Error().Err(err).Msg("comick: Failed to send request") + return nil, fmt.Errorf("failed to send request: %w", err) + } + + if !resp.IsSuccessState() { + c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed") + return nil, fmt.Errorf("failed to reach API: status %s", resp.Status) + } + + results := make([]*hibikemanga.SearchResult, 0) + for _, result := range data { + + // Skip fan-colored manga + if strings.Contains(result.Slug, "fan-colored") { + continue + } + + var coverURL string + if len(result.MdCovers) > 0 && result.MdCovers[0].B2Key != "" { + coverURL = "https://meo.comick.pictures/" + result.MdCovers[0].B2Key + } + + altTitles := make([]string, len(result.MdTitles)) + for j, title := range result.MdTitles { + altTitles[j] = title.Title + } + + // DEVNOTE: We don't compare to alt titles because ComicK's synonyms aren't good + compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&result.Title}) + + results = append(results, &hibikemanga.SearchResult{ + ID: result.HID, + Title: cmp.Or(result.Title, result.Slug), + Synonyms: altTitles, + Image: coverURL, + Year: result.Year, + SearchRating: compRes.Rating, + Provider: ComickProvider, + }) + } + + if len(results) == 0 { + c.logger.Warn().Msg("comick: No results found") + return nil, ErrNoChapters + } + + c.logger.Info().Int("count", len(results)).Msg("comick: Found results") + + return results, nil +} + +func (c *ComicKMulti) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) { + ret := make([]*hibikemanga.ChapterDetails, 0) + + // c.logger.Debug().Str("mangaId", id).Msg("comick: Fetching chapters") + + uri := fmt.Sprintf("%s/comic/%s/chapters?page=0&limit=1000000&chap-order=1", c.Url, id) + c.logger.Debug().Str("mangaId", id).Str("uri", uri).Msg("comick: Fetching chapters") + + var data struct { + Chapters []*ComicChapter `json:"chapters"` + } + + resp, err := c.Client.R(). + SetSuccessResult(&data). + Get(uri) + + if err != nil { + c.logger.Error().Err(err).Msg("comick: Failed to send request") + return nil, fmt.Errorf("failed to send request: %w", err) + } + + if !resp.IsSuccessState() { + c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed") + return nil, fmt.Errorf("failed to decode response: status %s", resp.Status) + } + + chapters := make([]*hibikemanga.ChapterDetails, 0) + chaptersCountMap := make(map[string]int) + for _, chapter := range data.Chapters { + if chapter.Chap == "" { + continue + } + title := "Chapter " + chapter.Chap + " " + + if title == "" { + if chapter.Title == "" { + title = "Oneshot" + } else { + title = chapter.Title + } + } + title = strings.TrimSpace(title) + + groupName := "" + if len(chapter.GroupName) > 0 { + groupName = chapter.GroupName[0] + } + + count, ok := chaptersCountMap[groupName] + if !ok { + chaptersCountMap[groupName] = 0 + count = 0 + } + chapters = append(chapters, &hibikemanga.ChapterDetails{ + Provider: ComickProvider, + ID: chapter.HID, + Title: title, + Language: chapter.Lang, + Index: uint(count), + URL: fmt.Sprintf("%s/chapter/%s", c.Url, chapter.HID), + Chapter: chapter.Chap, + Scanlator: groupName, + Rating: 0, + UpdatedAt: chapter.UpdatedAt, + }) + chaptersCountMap[groupName]++ + } + + // Sort chapters by index + slices.SortStableFunc(chapters, func(i, j *hibikemanga.ChapterDetails) int { + return cmp.Compare(i.Index, j.Index) + }) + + ret = append(ret, chapters...) + + if len(ret) == 0 { + c.logger.Warn().Msg("comick: No chapters found") + return nil, ErrNoChapters + } + + c.logger.Info().Int("count", len(ret)).Msg("comick: Found chapters") + + return ret, nil +} + +func (c *ComicKMulti) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) { + ret := make([]*hibikemanga.ChapterPage, 0) + + c.logger.Debug().Str("chapterId", id).Msg("comick: Finding chapter pages") + + uri := fmt.Sprintf("%s/chapter/%s", c.Url, id) + + var data struct { + Chapter *ComicChapter `json:"chapter"` + } + + resp, err := c.Client.R(). + SetHeader("User-Agent", util.GetRandomUserAgent()). + SetSuccessResult(&data). + Get(uri) + + if err != nil { + c.logger.Error().Err(err).Msg("comick: Failed to send request") + return nil, fmt.Errorf("failed to send request: %w", err) + } + + if !resp.IsSuccessState() { + c.logger.Error().Str("status", resp.Status).Msg("comick: Request failed") + return nil, fmt.Errorf("failed to decode response: status %s", resp.Status) + } + + if data.Chapter == nil { + c.logger.Error().Msg("comick: Chapter not found") + return nil, fmt.Errorf("chapter not found") + } + + for index, image := range data.Chapter.MdImages { + ret = append(ret, &hibikemanga.ChapterPage{ + Provider: ComickProvider, + URL: fmt.Sprintf("https://meo.comick.pictures/%s", image.B2Key), + Index: index, + Headers: make(map[string]string), + }) + } + + if len(ret) == 0 { + c.logger.Warn().Msg("comick: No pages found") + return nil, ErrNoPages + } + + c.logger.Info().Int("count", len(ret)).Msg("comick: Found pages") + + return ret, nil + +} diff --git a/seanime-2.9.10/internal/manga/providers/comick_test.go b/seanime-2.9.10/internal/manga/providers/comick_test.go new file mode 100644 index 0000000..2fc0d86 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/comick_test.go @@ -0,0 +1,224 @@ +package manga_providers + +import ( + "github.com/stretchr/testify/assert" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "testing" +) + +func TestComicK_Search(t *testing.T) { + + tests := []struct { + name string + query string + }{ + { + name: "One Piece", + query: "One Piece", + }, + { + name: "Jujutsu Kaisen", + query: "Jujutsu Kaisen", + }, + { + name: "Komi-san wa, Komyushou desu", + query: "Komi-san wa, Komyushou desu", + }, + { + name: "Boku no Kokoro no Yabai Yatsu", + query: "Boku no Kokoro no Yabai Yatsu", + }, + } + + comick := NewComicK(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + searchRes, err := comick.Search(hibikemanga.SearchOptions{ + Query: tt.query, + }) + if assert.NoError(t, err, "comick.Search() error") { + assert.NotEmpty(t, searchRes, "search result is empty") + + for _, res := range searchRes { + t.Logf("Title: %s", res.Title) + t.Logf("\tID: %s", res.ID) + t.Logf("\tYear: %d", res.Year) + t.Logf("\tImage: %s", res.Image) + t.Logf("\tProvider: %s", res.Provider) + t.Logf("\tSearchRating: %f", res.SearchRating) + t.Logf("\tSynonyms: %v", res.Synonyms) + t.Log("--------------------------------------------------") + } + } + + }) + + } +} + +func TestComicK_FindChapters(t *testing.T) { + + tests := []struct { + name string + id string + atLeast int + }{ + { + name: "Jujutsu Kaisen", + id: "TA22I5O7", + atLeast: 250, + }, + { + name: "Komi-san wa, Komyushou desu", + id: "K_Dn8VW7", + atLeast: 250, + }, + { + name: "Boku no Kokoro no Yabai Yatsu", + id: "pYN47sZm", + atLeast: 141, + }, + } + + comick := NewComicK(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + chapters, err := comick.FindChapters(tt.id) + if assert.NoError(t, err, "comick.FindChapters() error") { + + assert.NotEmpty(t, chapters, "chapters is empty") + + assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected") + + for _, chapter := range chapters { + t.Logf("Title: %s", chapter.Title) + t.Logf("\tSlug: %s", chapter.ID) + t.Logf("\tURL: %s", chapter.URL) + t.Logf("\tIndex: %d", chapter.Index) + t.Logf("\tChapter: %s", chapter.Chapter) + t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestComicKMulti_FindChapters(t *testing.T) { + + tests := []struct { + name string + id string + atLeast int + }{ + { + name: "Jujutsu Kaisen", + id: "TA22I5O7", + atLeast: 250, + }, + { + name: "Komi-san wa, Komyushou desu", + id: "K_Dn8VW7", + atLeast: 250, + }, + { + name: "Boku no Kokoro no Yabai Yatsu", + id: "pYN47sZm", + atLeast: 141, + }, + } + + comick := NewComicKMulti(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + chapters, err := comick.FindChapters(tt.id) + if assert.NoError(t, err, "comick.FindChapters() error") { + + assert.NotEmpty(t, chapters, "chapters is empty") + + assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected") + + for _, chapter := range chapters { + t.Logf("Title: %s", chapter.Title) + t.Logf("\tLanguage: %s", chapter.Language) + t.Logf("\tScanlator: %s", chapter.Scanlator) + t.Logf("\tSlug: %s", chapter.ID) + t.Logf("\tURL: %s", chapter.URL) + t.Logf("\tIndex: %d", chapter.Index) + t.Logf("\tChapter: %s", chapter.Chapter) + t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestComicK_FindChapterPages(t *testing.T) { + + tests := []struct { + name string + id string + index uint + }{ + { + name: "Jujutsu Kaisen", + id: "TA22I5O7", + index: 258, + }, + } + + comick := NewComicK(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + chapters, err := comick.FindChapters(tt.id) + if assert.NoError(t, err, "comick.FindChapters() error") { + + assert.NotEmpty(t, chapters, "chapters is empty") + + var chapterInfo *hibikemanga.ChapterDetails + for _, chapter := range chapters { + if chapter.Index == tt.index { + chapterInfo = chapter + break + } + } + + if assert.NotNil(t, chapterInfo, "chapter not found") { + pages, err := comick.FindChapterPages(chapterInfo.ID) + if assert.NoError(t, err, "comick.FindChapterPages() error") { + assert.NotEmpty(t, pages, "pages is empty") + + for _, page := range pages { + t.Logf("Index: %d", page.Index) + t.Logf("\tURL: %s", page.URL) + t.Log("--------------------------------------------------") + } + } + } + } + + }) + + } + +} diff --git a/seanime-2.9.10/internal/manga/providers/helpers.go b/seanime-2.9.10/internal/manga/providers/helpers.go new file mode 100644 index 0000000..30d502e --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/helpers.go @@ -0,0 +1,14 @@ +package manga_providers + +import "strings" + +// GetNormalizedChapter returns a normalized chapter string. +// e.g. "0001" -> "1" +func GetNormalizedChapter(chapter string) string { + // Trim padding zeros + unpaddedChStr := strings.TrimLeft(chapter, "0") + if unpaddedChStr == "" { + unpaddedChStr = "0" + } + return unpaddedChStr +} diff --git a/seanime-2.9.10/internal/manga/providers/local.go b/seanime-2.9.10/internal/manga/providers/local.go new file mode 100644 index 0000000..c947b85 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/local.go @@ -0,0 +1,556 @@ +package manga_providers + +import ( + "archive/zip" + "bytes" + "fmt" + // "image/jpeg" + "io" + "os" + "path/filepath" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util/comparison" + "slices" + "strconv" + "strings" + "sync" + + // "github.com/gen2brain/go-fitz" + "github.com/rs/zerolog" + "github.com/samber/lo" +) + +const ( + LocalServePath = "{{manga-local-assets}}" +) + +type Local struct { + dir string // Directory to scan for manga + logger *zerolog.Logger + + mu sync.Mutex + currentChapterPath string + currentZipCloser io.Closer + currentPages map[string]*loadedPage +} + +type loadedPage struct { + buf []byte + page *hibikemanga.ChapterPage +} + +// chapterEntry represents a potential chapter file or directory found during scanning +type chapterEntry struct { + RelativePath string // Path relative to manga root (e.g., "mangaID/chapter1.cbz" or "mangaID/vol1/ch1.cbz") + IsDir bool // Whether this entry is a directory +} + +func NewLocal(dir string, logger *zerolog.Logger) hibikemanga.Provider { + _ = os.MkdirAll(dir, 0755) + + return &Local{ + dir: dir, + logger: logger, + currentPages: make(map[string]*loadedPage), + } +} + +func (p *Local) GetSettings() hibikemanga.Settings { + return hibikemanga.Settings{ + SupportsMultiScanlator: false, + SupportsMultiLanguage: false, + } +} + +func (p *Local) SetSourceDirectory(dir string) { + if dir != "" { + p.dir = dir + } +} + +func (p *Local) getAllManga() (res []*hibikemanga.SearchResult, err error) { + if p.dir == "" { + return make([]*hibikemanga.SearchResult, 0), nil + } + + entries, err := os.ReadDir(p.dir) + if err != nil { + return nil, err + } + + res = make([]*hibikemanga.SearchResult, 0) + for _, entry := range entries { + if entry.IsDir() { + res = append(res, &hibikemanga.SearchResult{ + ID: entry.Name(), + Title: entry.Name(), + Provider: LocalProvider, + }) + } + } + + return res, nil +} + +func (p *Local) Search(opts hibikemanga.SearchOptions) (res []*hibikemanga.SearchResult, err error) { + res = make([]*hibikemanga.SearchResult, 0) + all, err := p.getAllManga() + if err != nil { + return nil, err + } + + if opts.Query == "" { + return all, nil + } + + allTitles := make([]*string, len(all)) + for i, manga := range all { + allTitles[i] = &manga.Title + } + compRes := comparison.CompareWithLevenshteinCleanFunc(&opts.Query, allTitles, cleanMangaTitle) + + var bestMatch *comparison.LevenshteinResult + for _, res := range compRes { + if bestMatch == nil || res.Distance < bestMatch.Distance { + bestMatch = res + } + } + + if bestMatch == nil { + return res, nil + } + + if bestMatch.Distance > 3 { + // If the best match is too far away, return no results + return res, nil + } + + manga, ok := lo.Find(all, func(manga *hibikemanga.SearchResult) bool { + return manga.Title == *bestMatch.Value + }) + + if !ok { + return res, nil + } + + res = append(res, manga) + + return res, nil +} + +func cleanMangaTitle(title string) string { + title = strings.TrimSpace(title) + + // Remove some characters to make comparison easier + title = strings.Map(func(r rune) rune { + if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '!' || r == '"' || r == '<' || r == '>' || r == '|' || r == ',' { + return rune(0) + } + return r + }, title) + + return title +} + +// FindChapters scans the manga series directory and returns the chapters. +// Supports nested folder structures up to 2 levels deep. +// +// Example: +// +// Series title/ +// ├── Chapter 1/ +// │ ├── image_1.ext +// │ └── image_n.ext +// ├── Chapter 2.pdf +// └── Ch 1-10/ +// ├── Ch 1/ +// └── Ch 2/ +func (p *Local) FindChapters(mangaID string) (res []*hibikemanga.ChapterDetails, err error) { + if p.dir == "" { + return make([]*hibikemanga.ChapterDetails, 0), nil + } + + mangaPath := filepath.Join(p.dir, mangaID) + + p.logger.Trace().Str("mangaPath", mangaPath).Msg("manga: Finding local chapters") + + // Collect all potential chapter entries up to 2 levels deep + chapterEntries, err := p.collectChapterEntries(mangaPath, mangaID, 0) + if err != nil { + return nil, err + } + + res = make([]*hibikemanga.ChapterDetails, 0) + // Go through all collected entries. + for _, entry := range chapterEntries { + scannedEntry, ok := scanChapterFilename(filepath.Base(entry.RelativePath)) + if !ok { + continue + } + + if len(scannedEntry.Chapter) != 1 { + // Handle one-shots (no chapter number and only one entry) + if len(scannedEntry.Chapter) == 0 && len(chapterEntries) == 1 { + chapterTitle := "Chapter 1" + if scannedEntry.ChapterTitle != "" { + chapterTitle += " - " + scannedEntry.ChapterTitle + } + res = append(res, &hibikemanga.ChapterDetails{ + Provider: LocalProvider, + ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz" + URL: "", + Title: chapterTitle, + Chapter: "1", + Index: 0, // placeholder, will be set later + LocalIsPDF: scannedEntry.IsPDF, + }) + } else if len(scannedEntry.Chapter) == 2 { + // Handle combined chapters (e.g. "Chapter 1-2") + chapterTitle := "Chapter " + cleanChapter(scannedEntry.Chapter[0]) + "-" + cleanChapter(scannedEntry.Chapter[1]) + if scannedEntry.ChapterTitle != "" { + chapterTitle += " - " + scannedEntry.ChapterTitle + } + res = append(res, &hibikemanga.ChapterDetails{ + Provider: LocalProvider, + ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz" + URL: "", + Title: chapterTitle, + // Use the last chapter number as the chapter for progress tracking + Chapter: cleanChapter(scannedEntry.Chapter[1]), + Index: 0, // placeholder, will be set later + LocalIsPDF: scannedEntry.IsPDF, + }) + } + continue + } + + ch := cleanChapter(scannedEntry.Chapter[0]) + chapterTitle := "Chapter " + ch + if scannedEntry.ChapterTitle != "" { + chapterTitle += " - " + scannedEntry.ChapterTitle + } + + res = append(res, &hibikemanga.ChapterDetails{ + Provider: LocalProvider, + ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz" + URL: "", + Title: chapterTitle, + Chapter: ch, + Index: 0, // placeholder, will be set later + LocalIsPDF: scannedEntry.IsPDF, + }) + } + + // sort by chapter number (ascending) + slices.SortFunc(res, func(a, b *hibikemanga.ChapterDetails) int { + chA, _ := strconv.ParseFloat(a.Chapter, 64) + chB, _ := strconv.ParseFloat(b.Chapter, 64) + return int(chA - chB) + }) + + // set the indexes + for i, chapter := range res { + chapter.Index = uint(i) + } + + return res, nil +} + +// collectChapterEntries walks the directory tree up to maxDepth levels deep and collects +// all potential chapter files and directories. +func (p *Local) collectChapterEntries(currentPath, mangaID string, currentDepth int) (entries []*chapterEntry, err error) { + const maxDepth = 2 + + if currentDepth > maxDepth { + return entries, nil + } + + dirEntries, err := os.ReadDir(currentPath) + if err != nil { + return nil, err + } + + entries = make([]*chapterEntry, 0) + + for _, entry := range dirEntries { + entryPath := filepath.Join(currentPath, entry.Name()) + + // Calculate relative path from manga root + var relativePath string + if currentDepth == 0 { + // At manga root level + relativePath = filepath.Join(mangaID, entry.Name()) + } else { + // Get the relative part from current path + relativeFromManga, err := filepath.Rel(filepath.Join(p.dir, mangaID), entryPath) + if err != nil { + continue + } + relativePath = filepath.Join(mangaID, relativeFromManga) + } + + if entry.IsDir() { + // Check if this directory contains only images (making it a chapter directory) + isImageDirectory, _ := p.isImageOnlyDirectory(entryPath) + + if isImageDirectory { + // Directory contains only images, treat it as a chapter + entries = append(entries, &chapterEntry{ + RelativePath: relativePath, + IsDir: true, + }) + } else if currentDepth < maxDepth { + // Directory doesn't contain only images, recursively scan subdirectories + subEntries, err := p.collectChapterEntries(entryPath, mangaID, currentDepth+1) + if err != nil { + continue + } + + // If subdirectory contains chapters, add them + if len(subEntries) > 0 { + entries = append(entries, subEntries...) + } else { + // If no sub-chapters found, treat directory itself as potential chapter + entries = append(entries, &chapterEntry{ + RelativePath: relativePath, + IsDir: true, + }) + } + } else { + // At max depth, treat directory as potential chapter + entries = append(entries, &chapterEntry{ + RelativePath: relativePath, + IsDir: true, + }) + } + } else { + // File entry - check if it's a potential chapter file + ext := strings.ToLower(filepath.Ext(entry.Name())) + if ext == ".cbz" || ext == ".cbr" || ext == ".pdf" || ext == ".zip" { + entries = append(entries, &chapterEntry{ + RelativePath: relativePath, + IsDir: false, + }) + } + } + } + + return entries, nil +} + +// isImageOnlyDirectory checks if a directory contains only image files (no subdirectories or other files) +func (p *Local) isImageOnlyDirectory(dirPath string) (bool, error) { + entries, err := os.ReadDir(dirPath) + if err != nil { + return false, err + } + + if len(entries) == 0 { + return false, nil + } + + hasImages := false + for _, entry := range entries { + if entry.IsDir() { + return false, nil + } + + if isFileImage(entry.Name()) { + hasImages = true + } else { + return false, nil + } + } + + return hasImages, nil +} + +// "0001" -> "1", "0" -> "0" +func cleanChapter(ch string) string { + if ch == "" { + return "" + } + if ch == "0" { + return "0" + } + if strings.HasPrefix(ch, "0") { + return strings.TrimLeft(ch, "0") + } + return ch +} + +// FindChapterPages will extract the images +func (p *Local) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) { + if p.dir == "" { + return make([]*hibikemanga.ChapterPage, 0), nil + } + + // id = filepath + // e.g. "series/chapter_1.cbz" + fullpath := filepath.Join(p.dir, id) // e.g. "/collection/series/chapter_1.cbz" + + // Prefix with {{manga-local-assets}} to signal the client that this is a local file + // e.g. "{{manga-local-assets}}/series/chapter_1.cbz/image_1.jpg" + formatUrl := func(fileName string) string { + return filepath.ToSlash(filepath.Join(LocalServePath, id, fileName)) + } + + ext := filepath.Ext(fullpath) + + // Close the current pages + if p.currentZipCloser != nil { + _ = p.currentZipCloser.Close() + } + for _, loadedPage := range p.currentPages { + loadedPage.buf = nil + } + p.currentPages = make(map[string]*loadedPage) + p.currentZipCloser = nil + p.currentChapterPath = fullpath + + switch ext { + case ".zip", ".cbz": + r, err := zip.OpenReader(fullpath) + if err != nil { + return nil, err + } + defer r.Close() + + for _, f := range r.File { + if !isFileImage(f.Name) { + continue + } + + page, err := f.Open() + if err != nil { + return nil, fmt.Errorf("failed to open page: %w", err) + } + buf, err := io.ReadAll(page) + if err != nil { + return nil, fmt.Errorf("failed to read page: %w", err) + } + p.currentPages[strings.ToLower(f.Name)] = &loadedPage{ + buf: buf, + page: &hibikemanga.ChapterPage{ + Provider: LocalProvider, + URL: formatUrl(f.Name), + Index: 0, // placeholder, will be set later + Buf: buf, + }, + } + } + case ".pdf": + // doc, err := fitz.New(fullpath) + // if err != nil { + // return nil, fmt.Errorf("failed to open PDF file: %w", err) + // } + // defer doc.Close() + + // // Load images into memory + // for n := 0; n < doc.NumPage(); n++ { + // img, err := doc.Image(n) + // if err != nil { + // panic(err) + // } + + // var buf bytes.Buffer + // err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality}) + // if err != nil { + // panic(err) + // } + + // p.currentPages[fmt.Sprintf("page_%d.jpg", n)] = &loadedPage{ + // buf: buf.Bytes(), + // page: &hibikemanga.ChapterPage{ + // Provider: LocalProvider, + // URL: formatUrl(fmt.Sprintf("page_%d.jpg", n)), + // Index: n, + // }, + // } + // } + default: + // If it's a directory of images + stat, err := os.Stat(fullpath) + if err != nil { + return nil, fmt.Errorf("failed to stat file: %w", err) + } + if !stat.IsDir() { + return nil, fmt.Errorf("file is not a directory: %s", fullpath) + } + + entries, err := os.ReadDir(fullpath) + if err != nil { + return nil, fmt.Errorf("failed to read directory: %w", err) + } + + for _, entry := range entries { + if !isFileImage(entry.Name()) { + continue + } + + page, err := os.Open(filepath.Join(fullpath, entry.Name())) + if err != nil { + return nil, fmt.Errorf("failed to open page: %w", err) + } + buf, err := io.ReadAll(page) + if err != nil { + return nil, fmt.Errorf("failed to read page: %w", err) + } + p.currentPages[strings.ToLower(entry.Name())] = &loadedPage{ + buf: buf, + page: &hibikemanga.ChapterPage{ + Provider: LocalProvider, + URL: formatUrl(entry.Name()), + Index: 0, // placeholder, will be set later + Buf: buf, + }, + } + } + } + + type pageStruct struct { + Number float64 + LoadedPage *loadedPage + } + + pages := make([]*pageStruct, 0) + + // Parse and order the pages + for _, loadedPage := range p.currentPages { + scannedPage, ok := parsePageFilename(filepath.Base(loadedPage.page.URL)) + if !ok { + continue + } + pages = append(pages, &pageStruct{ + Number: scannedPage.Number, + LoadedPage: loadedPage, + }) + } + + // Sort pages + slices.SortFunc(pages, func(a, b *pageStruct) int { + return strings.Compare(filepath.Base(a.LoadedPage.page.URL), filepath.Base(b.LoadedPage.page.URL)) + }) + + ret = make([]*hibikemanga.ChapterPage, 0) + for idx, pageStruct := range pages { + pageStruct.LoadedPage.page.Index = idx + ret = append(ret, pageStruct.LoadedPage.page) + } + + return ret, nil +} + +func (p *Local) ReadPage(path string) (ret io.ReadCloser, err error) { + // e.g. path = "/series/chapter_1.cbz/image_1.jpg" + + // If the pages are already in memory, return them + if len(p.currentPages) > 0 { + page, ok := p.currentPages[strings.ToLower(filepath.Base(path))] + if ok { + return io.NopCloser(bytes.NewReader(page.buf)), nil // Return the page + } + } + + return nil, fmt.Errorf("page not found: %s", path) +} diff --git a/seanime-2.9.10/internal/manga/providers/local_parser.go b/seanime-2.9.10/internal/manga/providers/local_parser.go new file mode 100644 index 0000000..c121657 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/local_parser.go @@ -0,0 +1,823 @@ +package manga_providers + +import ( + "path/filepath" + "slices" + "strconv" + "strings" + "unicode" +) + +type ScannedChapterFile struct { + Chapter []string // can be a single chapter or a range of chapters + MangaTitle string // typically comes before the chapter number + ChapterTitle string // typically comes after the chapter number + Volume []string // typically comes after the chapter number + IsPDF bool +} + +type TokenType int + +const ( + TokenUnknown TokenType = iota + TokenText + TokenNumber + TokenKeyword + TokenSeparator + TokenEnclosed + TokenFileExtension +) + +// Token represents a parsed token from the filename +type Token struct { + Type TokenType + Value string + Position int + IsChapter bool + IsVolume bool +} + +// Lexer handles the tokenization of the filename +type Lexer struct { + input string + position int + tokens []Token + currentToken int +} + +var ChapterKeywords = []string{ + "ch", "chp", "chapter", "chap", "c", +} + +var VolumeKeywords = []string{ + "v", "vol", "volume", +} + +var SeparatorChars = []rune{ + ' ', '-', '_', '.', '[', ']', '(', ')', '{', '}', '~', +} + +var ImageExtensions = map[string]struct{}{ + ".png": {}, + ".jpg": {}, + ".jpeg": {}, + ".gif": {}, + ".webp": {}, + ".bmp": {}, + ".tiff": {}, + ".tif": {}, +} + +// NewLexer creates a new lexer instance +func NewLexer(input string) *Lexer { + return &Lexer{ + input: strings.TrimSpace(input), + tokens: make([]Token, 0), + currentToken: 0, + } +} + +// Tokenize breaks down the input into tokens +func (l *Lexer) Tokenize() []Token { + l.position = 0 + l.tokens = make([]Token, 0) + + for l.position < len(l.input) { + if l.isWhitespace(l.current()) { + l.skipWhitespace() + continue + } + + if l.isEnclosedStart(l.current()) { + l.readEnclosed() + continue + } + + if l.isSeparator(l.current()) { + l.readSeparator() + continue + } + + if l.isDigit(l.current()) { + l.readNumber() + continue + } + + if l.isLetter(l.current()) { + l.readText() + continue + } + + // Skip unknown characters + l.position++ + } + + l.classifyTokens() + return l.tokens +} + +// current returns the current character +func (l *Lexer) current() rune { + if l.position >= len(l.input) { + return 0 + } + return rune(l.input[l.position]) +} + +// peek returns the next character without advancing +func (l *Lexer) peek() rune { + if l.position+1 >= len(l.input) { + return 0 + } + return rune(l.input[l.position+1]) +} + +// advance moves to the next character +func (l *Lexer) advance() { + l.position++ +} + +// isWhitespace checks if character is whitespace +func (l *Lexer) isWhitespace(r rune) bool { + return r == ' ' || r == '\t' || r == '\n' || r == '\r' +} + +// isSeparator checks if character is a separator +func (l *Lexer) isSeparator(r rune) bool { + for _, sep := range SeparatorChars { + if r == sep { + return true + } + } + return false +} + +// isEnclosedStart checks if character starts an enclosed section +func (l *Lexer) isEnclosedStart(r rune) bool { + return r == '[' || r == '(' || r == '{' +} + +// isDigit checks if character is a digit +func (l *Lexer) isDigit(r rune) bool { + return r >= '0' && r <= '9' +} + +// isLetter checks if character is a letter +func (l *Lexer) isLetter(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') +} + +// skipWhitespace skips all whitespace characters +func (l *Lexer) skipWhitespace() { + for l.position < len(l.input) && l.isWhitespace(l.current()) { + l.advance() + } +} + +// readEnclosed reads content within brackets/parentheses +func (l *Lexer) readEnclosed() { + start := l.position + openChar := l.current() + var closeChar rune + + switch openChar { + case '[': + closeChar = ']' + case '(': + closeChar = ')' + case '{': + closeChar = '}' + default: + l.advance() + return + } + + l.advance() // Skip opening character + startContent := l.position + + for l.position < len(l.input) && l.current() != closeChar { + l.advance() + } + + if l.position < len(l.input) { + content := l.input[startContent:l.position] + l.advance() // Skip closing character + + // Only add if content is meaningful + if len(strings.TrimSpace(content)) > 0 { + l.addToken(TokenEnclosed, content, start) + } + } +} + +// readSeparator reads separator characters +func (l *Lexer) readSeparator() { + start := l.position + value := string(l.current()) + l.advance() + l.addToken(TokenSeparator, value, start) +} + +// readNumber reads numeric values (including decimals) +func (l *Lexer) readNumber() { + start := l.position + + for l.position < len(l.input) && (l.isDigit(l.current()) || l.current() == '.') { + // Stop if we hit a file extension + if l.current() == '.' && l.position+1 < len(l.input) { + // Check if this is followed by common file extensions + remaining := l.input[l.position+1:] + if strings.HasPrefix(remaining, "cbz") || strings.HasPrefix(remaining, "cbr") || + strings.HasPrefix(remaining, "pdf") || strings.HasPrefix(remaining, "epub") { + break + } + } + l.advance() + } + + value := l.input[start:l.position] + l.addToken(TokenNumber, value, start) +} + +// readText reads alphabetic text +func (l *Lexer) readText() { + start := l.position + + for l.position < len(l.input) && (l.isLetter(l.current()) || l.isDigit(l.current())) { + l.advance() + } + + value := l.input[start:l.position] + lowerValue := strings.ToLower(value) // Use lowercase for keyword checking + + // Check if this might be a concatenated keyword that continues with a decimal + if l.startsWithKeyword(lowerValue) && l.position < len(l.input) && l.current() == '.' { + // Look ahead to see if there are more digits after the decimal + tempPos := l.position + 1 + if tempPos < len(l.input) && l.isDigit(rune(l.input[tempPos])) { + // Read the decimal part + l.advance() // consume the '.' + for l.position < len(l.input) && l.isDigit(l.current()) { + l.advance() + } + // Update value to include decimal part + value = l.input[start:l.position] + lowerValue = strings.ToLower(value) + } + } + + // Check for concatenated keywords like "ch001", "c001", "chp001", "c12.5" + if l.containsKeywordPrefix(lowerValue) { + l.splitKeywordAndNumber(lowerValue, value, start) // Pass both versions + } else { + l.addToken(TokenText, value, start) // Use original case + } +} + +// startsWithKeyword checks if text starts with any known keyword +func (l *Lexer) startsWithKeyword(text string) bool { + for _, keyword := range ChapterKeywords { + if strings.HasPrefix(text, keyword) { + return true + } + } + for _, keyword := range VolumeKeywords { + if strings.HasPrefix(text, keyword) { + return true + } + } + return false +} + +// containsKeywordPrefix checks if text starts with a known keyword +func (l *Lexer) containsKeywordPrefix(text string) bool { + chKeywords := ChapterKeywords + // Sort by length descending to match longer keywords first + slices.SortFunc(chKeywords, func(a, b string) int { + return len(b) - len(a) // Sort by length descending + }) + for _, keyword := range ChapterKeywords { + if strings.HasPrefix(text, keyword) && len(text) > len(keyword) { + remaining := text[len(keyword):] + // Check if remaining part is numeric (including decimals) + if len(remaining) == 0 { + return false + } + return l.isValidNumberPart(remaining) + } + } + for _, keyword := range VolumeKeywords { + if strings.HasPrefix(text, keyword) && len(text) > len(keyword) { + remaining := text[len(keyword):] + // Check if remaining part is numeric (including decimals) + if len(remaining) == 0 { + return false + } + return l.isValidNumberPart(remaining) + } + } + return false +} + +// isValidNumberPart checks if string is valid number (including decimals) +func (l *Lexer) isValidNumberPart(s string) bool { + if len(s) == 0 { + return false + } + + // Don't allow starting with decimal + if s[0] == '.' { + return false + } + + hasDecimal := false + for _, r := range s { + if r == '.' { + if hasDecimal { + return false // Multiple decimals not allowed + } + hasDecimal = true + } else if !l.isDigit(r) { + return false + } + } + return true +} + +// splitKeywordAndNumber splits concatenated keyword and number tokens +func (l *Lexer) splitKeywordAndNumber(lowerText, originalText string, position int) { + for _, keyword := range ChapterKeywords { + if strings.HasPrefix(lowerText, keyword) && len(lowerText) > len(keyword) { + // Use original case for the keyword part + originalKeyword := originalText[:len(keyword)] + l.addKeywordToken(originalKeyword, position, true, false) + + // Extract number part (keeping original case/formatting) + numberPart := originalText[len(keyword):] + l.addToken(TokenNumber, numberPart, position+len(keyword)) + return + } + } + for _, keyword := range VolumeKeywords { + if strings.HasPrefix(lowerText, keyword) && len(lowerText) > len(keyword) { + // Use original case for the keyword part + originalKeyword := originalText[:len(keyword)] + l.addKeywordToken(originalKeyword, position, false, true) + + // Extract number part (keeping original case/formatting) + numberPart := originalText[len(keyword):] + l.addToken(TokenNumber, numberPart, position+len(keyword)) + return + } + } +} + +// addKeywordToken adds a keyword token with flags +func (l *Lexer) addKeywordToken(value string, position int, isChapter, isVolume bool) { + l.tokens = append(l.tokens, Token{ + Type: TokenKeyword, + Value: value, + Position: position, + IsChapter: isChapter, + IsVolume: isVolume, + }) +} + +// addToken adds a token to the list +func (l *Lexer) addToken(tokenType TokenType, value string, position int) { + l.tokens = append(l.tokens, Token{ + Type: tokenType, + Value: value, + Position: position, + }) +} + +// classifyTokens identifies chapter and volume keywords +func (l *Lexer) classifyTokens() { + for i := range l.tokens { + token := &l.tokens[i] + + // Check for chapter keywords (case insensitive) + lowerValue := strings.ToLower(token.Value) + for _, keyword := range ChapterKeywords { + if lowerValue == keyword { + token.Type = TokenKeyword + token.IsChapter = true + break + } + } + + // Check for volume keywords (case insensitive) + for _, keyword := range VolumeKeywords { + if lowerValue == keyword { + token.Type = TokenKeyword + token.IsVolume = true + break + } + } + + // Check for file extensions + if strings.Contains(lowerValue, "pdf") || strings.Contains(lowerValue, "cbz") || + strings.Contains(lowerValue, "cbr") || strings.Contains(lowerValue, "epub") { + token.Type = TokenFileExtension + } + } +} + +// Parser handles the semantic analysis of tokens +type Parser struct { + tokens []Token + result *ScannedChapterFile +} + +// NewParser creates a new parser instance +func NewParser(tokens []Token) *Parser { + return &Parser{ + tokens: tokens, + result: &ScannedChapterFile{ + Chapter: make([]string, 0), + Volume: make([]string, 0), + }, + } +} + +// Parse performs semantic analysis on the tokens +func (p *Parser) Parse() *ScannedChapterFile { + p.extractChapters() + p.extractVolumes() + p.extractTitles() + p.checkPDF() + + return p.result +} + +// extractChapters finds and extracts chapter numbers +func (p *Parser) extractChapters() { + for i, token := range p.tokens { + if token.IsChapter { + // Look for numbers after chapter keyword + for j := i + 1; j < len(p.tokens) && j < i+3; j++ { + nextToken := p.tokens[j] + if nextToken.Type == TokenNumber { + p.addChapterNumber(nextToken.Value) + break + } else if nextToken.Type == TokenSeparator { + continue + } else { + break + } + } + } else if token.Type == TokenNumber && !token.IsVolume { + // Standalone number might be a chapter + if p.isLikelyChapterNumber(token, i) { + p.addChapterNumber(token.Value) + } + } + } + + // Handle ranges by looking for dash-separated numbers + p.handleChapterRanges() +} + +// handleChapterRanges processes chapter ranges like "1-2" or "001-002" +func (p *Parser) handleChapterRanges() { + for i := 0; i < len(p.tokens)-2; i++ { + if p.tokens[i].Type == TokenNumber && + p.tokens[i+1].Type == TokenSeparator && p.tokens[i+1].Value == "-" && + p.tokens[i+2].Type == TokenNumber { + + // Check if first number is already a chapter + firstIsChapter := false + for _, ch := range p.result.Chapter { + if ch == p.tokens[i].Value { + firstIsChapter = true + break + } + } + + if firstIsChapter { + // Add the second number as a chapter too + p.result.Chapter = append(p.result.Chapter, p.tokens[i+2].Value) + } + } + } +} + +// extractVolumes finds and extracts volume numbers +func (p *Parser) extractVolumes() { + for i, token := range p.tokens { + if token.IsVolume { + // Look for numbers after volume keyword + for j := i + 1; j < len(p.tokens) && j < i+3; j++ { + nextToken := p.tokens[j] + if nextToken.Type == TokenNumber { + p.result.Volume = append(p.result.Volume, nextToken.Value) + break + } else if nextToken.Type == TokenSeparator { + continue + } else { + break + } + } + } + } +} + +// extractTitles finds manga title and chapter title +func (p *Parser) extractTitles() { + // Find first chapter keyword or number position + chapterPos := -1 + for i, token := range p.tokens { + if token.IsChapter || (token.Type == TokenNumber && p.isLikelyChapterNumber(token, i)) { + chapterPos = i + break + } + } + + if chapterPos > 0 { + // Everything before chapter is likely manga title + titleParts := make([]string, 0) + for i := 0; i < chapterPos; i++ { + token := p.tokens[i] + if token.Type == TokenText && !token.IsVolume && !p.isIgnoredToken(token) { + titleParts = append(titleParts, token.Value) + } else if token.Type == TokenNumber && p.isNumberInTitle(token, i, chapterPos) { + // Include numbers that are part of the title (but not volume indicators) + titleParts = append(titleParts, token.Value) + } + } + if len(titleParts) > 0 { + p.result.MangaTitle = strings.Join(titleParts, " ") + } + + // Look for chapter title after chapter number + p.extractChapterTitle(chapterPos) + } else { + // No clear chapter indicator, check if this is a "number - title" pattern + if len(p.result.Chapter) > 0 && p.hasChapterTitlePattern() { + p.extractChapterTitleFromPattern() + } else { + // Treat most text as manga title + p.extractFallbackTitle() + } + } +} + +// hasChapterTitlePattern checks for "number - title" pattern +func (p *Parser) hasChapterTitlePattern() bool { + for i := 0; i < len(p.tokens)-2; i++ { + if p.tokens[i].Type == TokenNumber && + p.tokens[i+1].Type == TokenSeparator && p.tokens[i+1].Value == "-" && + i+2 < len(p.tokens) && p.tokens[i+2].Type == TokenText { + return true + } + } + return false +} + +// extractChapterTitleFromPattern extracts title from "number - title" pattern +func (p *Parser) extractChapterTitleFromPattern() { + for i := 0; i < len(p.tokens)-2; i++ { + if p.tokens[i].Type == TokenNumber && + p.tokens[i+1].Type == TokenSeparator && p.tokens[i+1].Value == "-" { + + // Collect text after the dash + titleParts := make([]string, 0) + for j := i + 2; j < len(p.tokens); j++ { + token := p.tokens[j] + if token.Type == TokenText && !p.isIgnoredToken(token) { + titleParts = append(titleParts, token.Value) + } else if token.Type == TokenFileExtension { + break + } + } + if len(titleParts) > 0 { + p.result.ChapterTitle = strings.Join(titleParts, " ") + } + break + } + } +} + +// extractFallbackTitle extracts title when no clear chapter indicators +func (p *Parser) extractFallbackTitle() { + titleParts := make([]string, 0) + for _, token := range p.tokens { + if token.Type == TokenText && !p.isIgnoredToken(token) { + titleParts = append(titleParts, token.Value) + } + } + if len(titleParts) > 0 { + p.result.MangaTitle = strings.Join(titleParts, " ") + } +} + +// addChapterNumber adds a chapter number, handling ranges +func (p *Parser) addChapterNumber(value string) { + // Check for range indicators in the surrounding tokens + if strings.Contains(value, "-") { + parts := strings.Split(value, "-") + for _, part := range parts { + if part != "" { + p.result.Chapter = append(p.result.Chapter, strings.TrimSpace(part)) + } + } + } else { + p.result.Chapter = append(p.result.Chapter, value) + } +} + +// isLikelyChapterNumber determines if a number token is likely a chapter +func (p *Parser) isLikelyChapterNumber(token Token, position int) bool { + // If we already have chapters from keywords, be more strict + if len(p.result.Chapter) > 0 { + return false + } + + // Check context - numbers at the start of filename are likely chapters + if position < 3 { + return true + } + + // Check if preceded by common patterns + if position > 0 { + prevToken := p.tokens[position-1] + if prevToken.Type == TokenSeparator && (prevToken.Value == "-" || prevToken.Value == " ") { + return true + } + } + + return false +} + +// isNumberInTitle determines if a number token should be part of the title +func (p *Parser) isNumberInTitle(token Token, position int, chapterPos int) bool { + // Don't include numbers that are right before the chapter position + if position == chapterPos-1 { + return false + } + + // Check if this number looks like it's associated with volume + if position > 0 { + prevToken := p.tokens[position-1] + if prevToken.IsVolume { + return false // This number belongs to volume + } + } + + // Small numbers (like 05, 2) that appear early in the title are likely part of title + if position < 5 { + if val := token.Value; len(val) <= 2 { + // Check if this number looks like part of a title (e.g., "Title 05") + return true + } + } + return false +} + +// isIgnoredToken checks if token should be ignored in titles +func (p *Parser) isIgnoredToken(token Token) bool { + ignoredWords := []string{"digital", "group", "scan", "scans", "team", "raw", "raws"} + for _, word := range ignoredWords { + if token.Value == word { + return true + } + } + + // Check for version indicators that shouldn't be in volume + if strings.HasPrefix(token.Value, "v") && len(token.Value) > 1 { + remaining := token.Value[1:] + // If it's just "v" + digit, it might be version, not volume + if len(remaining) > 0 && remaining[0] >= '0' && remaining[0] <= '9' { + // Check context - if preceded by a number, it's likely a version + return true + } + } + + return false +} + +// checkPDF sets the PDF flag if file is a PDF +func (p *Parser) checkPDF() { + for _, token := range p.tokens { + if token.Type == TokenFileExtension && strings.Contains(token.Value, "pdf") { + p.result.IsPDF = true + break + } + } +} + +// scanChapterFilename scans the filename and returns a chapter entry if it is a chapter. +func scanChapterFilename(filename string) (res *ScannedChapterFile, ok bool) { + // Create lexer and tokenize + lexer := NewLexer(filename) + tokens := lexer.Tokenize() + + // Create parser and parse + parser := NewParser(tokens) + res = parser.Parse() + + return res, true +} + +func isFileImage(filename string) bool { + ext := strings.ToLower(filepath.Ext(filename)) + _, ok := ImageExtensions[ext] + return ok +} + +// extractChapterTitle finds chapter title after chapter number +func (p *Parser) extractChapterTitle(startPos int) { + // Skip to after chapter number + numberPos := -1 + for i := startPos; i < len(p.tokens); i++ { + if p.tokens[i].Type == TokenNumber { + numberPos = i + break + } + } + + if numberPos == -1 { + return + } + + // Look for dash separator followed by text + for i := numberPos + 1; i < len(p.tokens); i++ { + token := p.tokens[i] + if token.Type == TokenSeparator && token.Value == "-" { + // Found dash, collect text after it + titleParts := make([]string, 0) + for j := i + 1; j < len(p.tokens); j++ { + nextToken := p.tokens[j] + if nextToken.Type == TokenText && !p.isIgnoredToken(nextToken) { + titleParts = append(titleParts, nextToken.Value) + } else if nextToken.Type == TokenFileExtension { + break + } + } + if len(titleParts) > 0 { + p.result.ChapterTitle = strings.Join(titleParts, " ") + } + break + } + } +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type ScannedPageFile struct { + Number float64 + Filename string + Ext string +} + +func parsePageFilename(filename string) (res *ScannedPageFile, ok bool) { + res = &ScannedPageFile{ + Filename: filename, + } + + filename = strings.ToLower(filename) + res.Ext = filepath.Ext(filename) + filename = strings.TrimSuffix(filename, res.Ext) + + if len(filename) == 0 { + return res, false + } + + // Find number at the start + // check if first rune is a digit + numStr := "" + if !unicode.IsDigit(rune(filename[0])) { + // walk until non-digit + for i := 0; i < len(filename); i++ { + if !unicode.IsDigit(rune(filename[i])) && rune(filename[i]) != '.' { + break + } + numStr += string(filename[i]) + } + if len(numStr) > 0 { + res.Number, _ = strconv.ParseFloat(numStr, 64) + return res, true + } + } + + // walk until first digit + numStr = "" + firstDigitIdx := strings.IndexFunc(filename, unicode.IsDigit) + if firstDigitIdx != -1 { + numStr += string(filename[firstDigitIdx]) + // walk until first non-digit or end + for i := firstDigitIdx + 1; i < len(filename); i++ { + if !unicode.IsDigit(rune(filename[i])) && rune(filename[i]) != '.' { + break + } + numStr += string(filename[i]) + } + if len(numStr) > 0 { + res.Number, _ = strconv.ParseFloat(numStr, 64) + return res, true + } + } + + return res, false +} diff --git a/seanime-2.9.10/internal/manga/providers/local_test.go b/seanime-2.9.10/internal/manga/providers/local_test.go new file mode 100644 index 0000000..e8cafbf --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/local_test.go @@ -0,0 +1,483 @@ +package manga_providers + +import ( + "fmt" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestScanChapterFilename(t *testing.T) { + tests := []struct { + filename string + expectedChapter []string + expectedMangaTitle string + expectedChapterTitle string + expectedVolume []string + }{ + { + filename: "1.cbz", + expectedChapter: []string{"1"}, + expectedMangaTitle: "", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "2.5.pdf", + expectedChapter: []string{"2.5"}, + expectedMangaTitle: "", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Chapter 5.5.pdf", + expectedChapter: []string{"5.5"}, + expectedMangaTitle: "", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "ch 1.cbz", + expectedChapter: []string{"1"}, + expectedMangaTitle: "", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "ch 1.5-2.cbz", + expectedChapter: []string{"1.5", "2"}, + expectedMangaTitle: "", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Some title Chapter 1.cbz", + expectedChapter: []string{"1"}, + expectedMangaTitle: "Some title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Chapter 23 The Fanatics.pdf", + expectedChapter: []string{"23"}, + expectedMangaTitle: "The Fanatics", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "chapter_1.cbz", + expectedChapter: []string{"1"}, + expectedMangaTitle: "", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "1 - Some title.cbz", + expectedChapter: []string{"1"}, + expectedMangaTitle: "", + expectedChapterTitle: "Some title", + expectedVolume: []string{}, + }, + { + filename: "30 - Some title.cbz", + expectedChapter: []string{"30"}, + expectedMangaTitle: "", + expectedChapterTitle: "Some title", + expectedVolume: []string{}, + }, + { + filename: "[Group] Manga Title - c001 [123456].cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "[Group] Manga Title - c12.5 [654321].cbz", + expectedChapter: []string{"12.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "[Group] Manga Title 05 - ch10.cbz", + expectedChapter: []string{"10"}, + expectedMangaTitle: "Manga Title 05", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "[Group] Manga Title - ch10.cbz", + expectedChapter: []string{"10"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "[Group] Manga Title - ch_11.cbz", + expectedChapter: []string{"11"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "[Group] Manga Title - ch-12.cbz", + expectedChapter: []string{"12"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title v01 c001.cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{"01"}, + }, + { + filename: "Manga Title v01 c001.5.cbz", + expectedChapter: []string{"001.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{"01"}, + }, + { + filename: "Manga Title - 003.cbz", + expectedChapter: []string{"003"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 003.5.cbz", + expectedChapter: []string{"003.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 3.5 (Digital).cbz", + expectedChapter: []string{"3.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 10 (Digital) [Group].cbz", + expectedChapter: []string{"10"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - chp_15.cbz", + expectedChapter: []string{"15"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - chp-16.cbz", + expectedChapter: []string{"16"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - chp17.cbz", + expectedChapter: []string{"17"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - chp 18.cbz", + expectedChapter: []string{"18"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 001 (v2).cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 001v2.cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{"2"}, + }, + { + filename: "Manga Title - 001 [v2].cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 001 [Digital] [v2].cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 001-002.cbz", + expectedChapter: []string{"001", "002"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 001-001.5.cbz", + expectedChapter: []string{"001", "001.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 1-2.cbz", + expectedChapter: []string{"1", "2"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 1.5-2.cbz", + expectedChapter: []string{"1.5", "2"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 1 (Sample).cbz", + expectedChapter: []string{"1"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 1 (Preview).cbz", + expectedChapter: []string{"1"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 1 (Special Edition).cbz", + expectedChapter: []string{"1"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 1 (Digital) (Official).cbz", + expectedChapter: []string{"1"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 1.cbz", + expectedChapter: []string{"1"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 1.0.cbz", + expectedChapter: []string{"1.0"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 01.cbz", + expectedChapter: []string{"01"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 01.5.cbz", + expectedChapter: []string{"01.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 001.cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - 001.5.cbz", + expectedChapter: []string{"001.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - ch001.cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - ch001.5.cbz", + expectedChapter: []string{"001.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - ch_001.cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - ch_001.5.cbz", + expectedChapter: []string{"001.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - ch-001.cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - ch-001.5.cbz", + expectedChapter: []string{"001.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - chp001.cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - chp_001.cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - chp_001.5.cbz", + expectedChapter: []string{"001.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - chp-001.cbz", + expectedChapter: []string{"001"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + { + filename: "Manga Title - chp-001.5.cbz", + expectedChapter: []string{"001.5"}, + expectedMangaTitle: "Manga Title", + expectedChapterTitle: "", + expectedVolume: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + res, ok := scanChapterFilename(tt.filename) + if !ok { + t.Errorf("Failed to scan chapter filename: %s", tt.filename) + } + require.Equalf(t, tt.expectedChapter, res.Chapter, "Expected chapter '%v' for '%s' but got '%v'", tt.expectedChapter, tt.filename, res.Chapter) + require.Equalf(t, tt.expectedMangaTitle, res.MangaTitle, "Expected manga title '%v' for '%s' but got '%v'", tt.expectedMangaTitle, tt.filename, res.MangaTitle) + require.Equalf(t, tt.expectedChapterTitle, res.ChapterTitle, "Expected chapter title '%v' for '%s' but got '%v'", tt.expectedChapterTitle, tt.filename, res.ChapterTitle) + require.Equalf(t, tt.expectedVolume, res.Volume, "Expected volume '%v' for '%s' but got '%v'", tt.expectedVolume, tt.filename, res.Volume) + }) + } +} + +func TestPageSorting(t *testing.T) { + tests := []struct { + expectedOrder []string + }{ + { + expectedOrder: []string{"1149-000.jpg", "1149-001.jpg", "1149-002.jpg", "1149-019.jpg", "1149-019b.jpg", "1149-020.jpg"}, + }, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v", tt.expectedOrder), func(t *testing.T) { + newSlice := tt.expectedOrder + slices.SortFunc(newSlice, func(a, b string) int { + return strings.Compare(a, b) + }) + for i, filename := range tt.expectedOrder { + require.Equalf(t, filename, newSlice[i], "Expected order '%v' for '%s' but got '%v'", tt.expectedOrder, tt.expectedOrder[i], filename) + } + }) + } +} + +func TestParsePageFilename(t *testing.T) { + tests := []struct { + filename string + expected float64 + }{ + { + filename: "1.jpg", + expected: 1, + }, + { + filename: "1.5.jpg", + expected: 1.5, + }, + { + filename: "Page 001.jpg", + expected: 1, + }, + { + filename: "1.55.jpg", + expected: 1.55, + }, + { + filename: "2.5 -.jpg", + expected: 2.5, + }, + { + filename: "page_27.jpg", + expected: 27, + }, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + res, ok := parsePageFilename(tt.filename) + if !ok { + t.Errorf("Failed to parse page filename: %s", tt.filename) + } + require.Equalf(t, tt.expected, res.Number, "Expected number '%v' for '%s' but got '%v'", tt.expected, tt.filename, res.Number) + }) + } +} diff --git a/seanime-2.9.10/internal/manga/providers/mangadex.go b/seanime-2.9.10/internal/manga/providers/mangadex.go new file mode 100644 index 0000000..5dac561 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/mangadex.go @@ -0,0 +1,388 @@ +package manga_providers + +import ( + "cmp" + "fmt" + "net/url" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "seanime/internal/util/comparison" + "slices" + "strings" + "time" + + "github.com/imroc/req/v3" + "github.com/rs/zerolog" +) + +type ( + Mangadex struct { + Url string + BaseUrl string + UserAgent string + Client *req.Client + logger *zerolog.Logger + } + + MangadexManga struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes MangadexMangeAttributes + Relationships []MangadexMangaRelationship `json:"relationships"` + } + + MangadexMangeAttributes struct { + AltTitles []map[string]string `json:"altTitles"` + Title map[string]string `json:"title"` + Year int `json:"year"` + } + + MangadexMangaRelationship struct { + ID string `json:"id"` + Type string `json:"type"` + Related string `json:"related"` + Attributes map[string]interface{} `json:"attributes"` + } + + MangadexErrorResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Code string `json:"code"` + Title string `json:"title"` + Detail string `json:"detail"` + } + + MangadexChapterData struct { + ID string `json:"id"` + Attributes MangadexChapterAttributes `json:"attributes"` + } + + MangadexChapterAttributes struct { + Title string `json:"title"` + Volume string `json:"volume"` + Chapter string `json:"chapter"` + UpdatedAt string `json:"updatedAt"` + } +) + +// DEVNOTE: Each chapter ID is a unique string provided by Mangadex + +func NewMangadex(logger *zerolog.Logger) *Mangadex { + client := req.C(). + SetUserAgent(util.GetRandomUserAgent()). + SetTimeout(60 * time.Second). + EnableInsecureSkipVerify(). + ImpersonateChrome() + + return &Mangadex{ + Url: "https://api.mangadex.org", + BaseUrl: "https://mangadex.org", + Client: client, + UserAgent: util.GetRandomUserAgent(), + logger: logger, + } +} + +func (md *Mangadex) GetSettings() hibikemanga.Settings { + return hibikemanga.Settings{ + SupportsMultiScanlator: false, + SupportsMultiLanguage: false, + } +} + +func (md *Mangadex) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) { + ret := make([]*hibikemanga.SearchResult, 0) + + retManga := make([]*MangadexManga, 0) + + for i := range 1 { + uri := fmt.Sprintf("%s/manga?title=%s&limit=25&offset=%d&order[relevance]=desc&contentRating[]=safe&contentRating[]=suggestive&includes[]=cover_art", md.Url, url.QueryEscape(opts.Query), 25*i) + + var data struct { + Data []*MangadexManga `json:"data"` + } + + resp, err := md.Client.R(). + SetHeader("Referer", "https://google.com"). + SetSuccessResult(&data). + Get(uri) + + if err != nil { + md.logger.Error().Err(err).Msg("mangadex: Failed to send request") + return nil, err + } + + if !resp.IsSuccessState() { + md.logger.Error().Str("status", resp.Status).Msg("mangadex: Request failed") + return nil, fmt.Errorf("failed to decode response: status %s", resp.Status) + } + + retManga = append(retManga, data.Data...) + } + + for _, manga := range retManga { + var altTitles []string + for _, title := range manga.Attributes.AltTitles { + altTitle, ok := title["en"] + if ok { + altTitles = append(altTitles, altTitle) + } + altTitle, ok = title["jp"] + if ok { + altTitles = append(altTitles, altTitle) + } + altTitle, ok = title["ja"] + if ok { + altTitles = append(altTitles, altTitle) + } + } + t := getTitle(manga.Attributes) + + var img string + for _, relation := range manga.Relationships { + if relation.Type == "cover_art" { + fn, ok := relation.Attributes["fileName"].(string) + if ok { + img = fmt.Sprintf("%s/covers/%s/%s.512.jpg", md.BaseUrl, manga.ID, fn) + } else { + img = fmt.Sprintf("%s/covers/%s/%s.jpg.512.jpg", md.BaseUrl, manga.ID, relation.ID) + } + } + } + + format := strings.ToUpper(manga.Type) + if format == "ADAPTATION" { + format = "MANGA" + } + + compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&t}) + + result := &hibikemanga.SearchResult{ + ID: manga.ID, + Title: t, + Synonyms: altTitles, + Image: img, + Year: manga.Attributes.Year, + SearchRating: compRes.Rating, + Provider: string(MangadexProvider), + } + + ret = append(ret, result) + } + + if len(ret) == 0 { + md.logger.Error().Msg("mangadex: No results found") + return nil, ErrNoResults + } + + md.logger.Info().Int("count", len(ret)).Msg("mangadex: Found results") + + return ret, nil +} + +func (md *Mangadex) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) { + ret := make([]*hibikemanga.ChapterDetails, 0) + + md.logger.Debug().Str("mangaId", id).Msg("mangadex: Finding chapters") + + for page := 0; page <= 1; page++ { + uri := fmt.Sprintf("%s/manga/%s/feed?limit=500&translatedLanguage%%5B%%5D=en&includes[]=scanlation_group&includes[]=user&order[volume]=desc&order[chapter]=desc&offset=%d&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic", md.Url, id, 500*page) + + var data struct { + Result string `json:"result"` + Errors []MangadexErrorResponse `json:"errors"` + Data []MangadexChapterData `json:"data"` + } + + resp, err := md.Client.R(). + SetSuccessResult(&data). + Get(uri) + + if err != nil { + md.logger.Error().Err(err).Msg("mangadex: Failed to send request") + return nil, err + } + + if !resp.IsSuccessState() { + md.logger.Error().Str("status", resp.Status).Msg("mangadex: Request failed") + return nil, fmt.Errorf("failed to decode response: status %s", resp.Status) + } + + if data.Result == "error" { + md.logger.Error().Str("error", data.Errors[0].Title).Str("detail", data.Errors[0].Detail).Msg("mangadex: Could not find chapters") + return nil, fmt.Errorf("could not find chapters: %s", data.Errors[0].Detail) + } + + slices.Reverse(data.Data) + + chapterMap := make(map[string]*hibikemanga.ChapterDetails) + idx := uint(len(ret)) + for _, chapter := range data.Data { + + if chapter.Attributes.Chapter == "" { + continue + } + + title := "Chapter " + fmt.Sprintf("%s", chapter.Attributes.Chapter) + " " + + if _, ok := chapterMap[chapter.Attributes.Chapter]; ok { + continue + } + + chapterMap[chapter.Attributes.Chapter] = &hibikemanga.ChapterDetails{ + ID: chapter.ID, + Title: title, + Index: idx, + Chapter: chapter.Attributes.Chapter, + UpdatedAt: chapter.Attributes.UpdatedAt, + Provider: string(MangadexProvider), + } + idx++ + } + + chapters := make([]*hibikemanga.ChapterDetails, 0, len(chapterMap)) + for _, chapter := range chapterMap { + chapters = append(chapters, chapter) + } + + slices.SortStableFunc(chapters, func(i, j *hibikemanga.ChapterDetails) int { + return cmp.Compare(i.Index, j.Index) + }) + + if len(chapters) > 0 { + ret = append(ret, chapters...) + } else { + break + } + } + + if len(ret) == 0 { + md.logger.Error().Msg("mangadex: No chapters found") + return nil, ErrNoChapters + } + + md.logger.Info().Int("count", len(ret)).Msg("mangadex: Found chapters") + + return ret, nil +} + +func (md *Mangadex) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) { + ret := make([]*hibikemanga.ChapterPage, 0) + + md.logger.Debug().Str("chapterId", id).Msg("mangadex: Finding chapter pages") + + uri := fmt.Sprintf("%s/at-home/server/%s", md.Url, id) + + var data struct { + BaseUrl string `json:"baseUrl"` + Chapter struct { + Hash string `json:"hash"` + Data []string `json:"data"` + } + } + + resp, err := md.Client.R(). + SetHeader("User-Agent", util.GetRandomUserAgent()). + SetSuccessResult(&data). + Get(uri) + + if err != nil { + md.logger.Error().Err(err).Msg("mangadex: Failed to get chapter pages") + return nil, err + } + + if !resp.IsSuccessState() { + md.logger.Error().Str("status", resp.Status).Msg("mangadex: Request failed") + return nil, fmt.Errorf("failed to decode response: status %s", resp.Status) + } + + for i, page := range data.Chapter.Data { + ret = append(ret, &hibikemanga.ChapterPage{ + Provider: string(MangadexProvider), + URL: fmt.Sprintf("%s/data/%s/%s", data.BaseUrl, data.Chapter.Hash, page), + Index: i, + Headers: map[string]string{ + "Referer": "https://mangadex.org", + }, + }) + } + + if len(ret) == 0 { + md.logger.Error().Msg("mangadex: No pages found") + return nil, ErrNoPages + } + + md.logger.Info().Int("count", len(ret)).Msg("mangadex: Found pages") + + return ret, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func getTitle(attributes MangadexMangeAttributes) string { + altTitles := attributes.AltTitles + title := attributes.Title + + enTitle := title["en"] + if enTitle != "" { + return enTitle + } + + var enAltTitle string + for _, altTitle := range altTitles { + if value, ok := altTitle["en"]; ok { + enAltTitle = value + break + } + } + + if enAltTitle != "" && util.IsMostlyLatinString(enAltTitle) { + return enAltTitle + } + + // Check for other language titles + if jaRoTitle, ok := title["ja-ro"]; ok { + return jaRoTitle + } + if jpRoTitle, ok := title["jp-ro"]; ok { + return jpRoTitle + } + if jpTitle, ok := title["jp"]; ok { + return jpTitle + } + if jaTitle, ok := title["ja"]; ok { + return jaTitle + } + if koTitle, ok := title["ko"]; ok { + return koTitle + } + + // Check alt titles for other languages + for _, altTitle := range altTitles { + if value, ok := altTitle["ja-ro"]; ok { + return value + } + } + for _, altTitle := range altTitles { + if value, ok := altTitle["jp-ro"]; ok { + return value + } + } + for _, altTitle := range altTitles { + if value, ok := altTitle["jp"]; ok { + return value + } + } + for _, altTitle := range altTitles { + if value, ok := altTitle["ja"]; ok { + return value + } + } + for _, altTitle := range altTitles { + if value, ok := altTitle["ko"]; ok { + return value + } + } + + return "" +} diff --git a/seanime-2.9.10/internal/manga/providers/mangadex_test.go b/seanime-2.9.10/internal/manga/providers/mangadex_test.go new file mode 100644 index 0000000..3256af2 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/mangadex_test.go @@ -0,0 +1,153 @@ +package manga_providers + +import ( + "github.com/stretchr/testify/assert" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "testing" +) + +func TestMangadex_Search(t *testing.T) { + + tests := []struct { + name string + query string + }{ + { + name: "One Piece", + query: "One Piece", + }, + { + name: "Jujutsu Kaisen", + query: "Jujutsu Kaisen", + }, + { + name: "Boku no Kokoro no Yabai Yatsu", + query: "Boku no Kokoro no Yabai Yatsu", + }, + } + + mangadex := NewMangadex(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + searchRes, err := mangadex.Search(hibikemanga.SearchOptions{ + Query: tt.query, + }) + if assert.NoError(t, err, "mangadex.Search() error") { + assert.NotEmpty(t, searchRes, "search result is empty") + + for _, res := range searchRes { + t.Logf("Title: %s", res.Title) + t.Logf("\tID: %s", res.ID) + t.Logf("\tYear: %d", res.Year) + t.Logf("\tImage: %s", res.Image) + t.Logf("\tProvider: %s", res.Provider) + t.Logf("\tSearchRating: %f", res.SearchRating) + t.Logf("\tSynonyms: %v", res.Synonyms) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestMangadex_FindChapters(t *testing.T) { + + tests := []struct { + name string + id string + atLeast int + }{ + //{ + // name: "One Piece", + // id: "One-Piece", + // atLeast: 1100, + //}, + { + name: "Jujutsu Kaisen", + id: "c52b2ce3-7f95-469c-96b0-479524fb7a1a", + atLeast: 250, + }, + { + name: "The Dangers in My Heart", + id: "3df1a9a3-a1be-47a3-9e90-9b3e55b1d0ac", + atLeast: 141, + }, + } + + mangadex := NewMangadex(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + chapters, err := mangadex.FindChapters(tt.id) + if assert.NoError(t, err, "mangadex.FindChapters() error") { + + assert.NotEmpty(t, chapters, "chapters is empty") + + assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected") + + for _, chapter := range chapters { + t.Logf("Title: %s", chapter.Title) + t.Logf("\tSlug: %s", chapter.ID) + t.Logf("\tURL: %s", chapter.URL) + t.Logf("\tIndex: %d", chapter.Index) + t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestMangadex_FindChapterPages(t *testing.T) { + + tests := []struct { + name string + id string + chapterId string + }{ + { + name: "The Dangers in My Heart", + id: "3df1a9a3-a1be-47a3-9e90-9b3e55b1d0ac", + chapterId: "5145ea39-be4b-4bf9-81e7-4f90961db857", // Chapter 1 + }, + { + name: "Kagurabachi", + id: "", + chapterId: "9c9652fc-10d2-40b3-9382-16fb072d3068", // Chapter 1 + }, + } + + mangadex := NewMangadex(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + pages, err := mangadex.FindChapterPages(tt.chapterId) + if assert.NoError(t, err, "mangadex.FindChapterPages() error") { + assert.NotEmpty(t, pages, "pages is empty") + + for _, page := range pages { + t.Logf("Index: %d", page.Index) + t.Logf("\tURL: %s", page.URL) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} diff --git a/seanime-2.9.10/internal/manga/providers/mangafire.go b/seanime-2.9.10/internal/manga/providers/mangafire.go new file mode 100644 index 0000000..8de6b1d --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/mangafire.go @@ -0,0 +1,220 @@ +package manga_providers + +import ( + "fmt" + "net/url" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "seanime/internal/util/comparison" + "strings" + "sync" + "time" + + "github.com/gocolly/colly" + "github.com/imroc/req/v3" + "github.com/rs/zerolog" +) + +// DEVNOTE: Shelved due to WAF captcha + +type ( + Mangafire struct { + Url string + Client *req.Client + UserAgent string + logger *zerolog.Logger + } +) + +func NewMangafire(logger *zerolog.Logger) *Mangafire { + client := req.C(). + SetUserAgent(util.GetRandomUserAgent()). + SetTimeout(60 * time.Second). + EnableInsecureSkipVerify(). + ImpersonateChrome() + + return &Mangafire{ + Url: "https://mangafire.to", + Client: client, + UserAgent: util.GetRandomUserAgent(), + logger: logger, + } +} + +func (mf *Mangafire) GetSettings() hibikemanga.Settings { + return hibikemanga.Settings{ + SupportsMultiScanlator: false, + SupportsMultiLanguage: false, + } +} + +func (mf *Mangafire) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) { + results := make([]*hibikemanga.SearchResult, 0) + + mf.logger.Debug().Str("query", opts.Query).Msg("mangafire: Searching manga") + + yearStr := "" + if opts.Year > 0 { + yearStr = fmt.Sprintf("&year=%%5B%%5D=%d", opts.Year) + } + uri := fmt.Sprintf("%s/filter?keyword=%s%s&sort=recently_updated", mf.Url, url.QueryEscape(opts.Query), yearStr) + + c := colly.NewCollector( + colly.UserAgent(util.GetRandomUserAgent()), + ) + + c.WithTransport(mf.Client.Transport) + + type ToVisit struct { + ID string + Title string + Image string + } + toVisit := make([]ToVisit, 0) + + c.OnHTML("main div.container div.original div.unit", func(e *colly.HTMLElement) { + id := e.ChildAttr("a", "href") + if len(toVisit) >= 15 || id == "" { + return + } + title := "" + e.ForEachWithBreak("div.info a", func(i int, e *colly.HTMLElement) bool { + if i == 0 && e.Text != "" { + title = strings.TrimSpace(e.Text) + return false + } + return true + }) + obj := ToVisit{ + ID: id, + Title: title, + Image: e.ChildAttr("img", "src"), + } + if obj.Title != "" && obj.ID != "" { + toVisit = append(toVisit, obj) + } + }) + + err := c.Visit(uri) + if err != nil { + mf.logger.Error().Err(err).Msg("mangafire: Failed to visit") + return nil, err + } + + wg := sync.WaitGroup{} + wg.Add(len(toVisit)) + + for _, v := range toVisit { + go func(tv ToVisit) { + defer wg.Done() + + c2 := colly.NewCollector( + colly.UserAgent(mf.UserAgent), + ) + + c2.WithTransport(mf.Client.Transport) + + result := &hibikemanga.SearchResult{ + Provider: MangafireProvider, + } + + // Synonyms + c2.OnHTML("main div#manga-page div.info h6", func(e *colly.HTMLElement) { + parts := strings.Split(e.Text, "; ") + for i, v := range parts { + parts[i] = strings.TrimSpace(v) + } + syn := strings.Join(parts, "") + if syn != "" { + result.Synonyms = append(result.Synonyms, syn) + } + }) + + // Year + c2.OnHTML("main div#manga-page div.meta", func(e *colly.HTMLElement) { + if result.Year != 0 || e.Text == "" { + return + } + parts := strings.Split(e.Text, "Published: ") + if len(parts) < 2 { + return + } + parts2 := strings.Split(parts[1], " to") + if len(parts2) < 2 { + return + } + result.Year = util.StringToIntMust(strings.TrimSpace(parts2[0])) + }) + + result.ID = tv.ID + result.Title = tv.Title + result.Image = tv.Image + + err := c2.Visit(fmt.Sprintf("%s/%s", mf.Url, tv.ID)) + if err != nil { + mf.logger.Error().Err(err).Str("id", tv.ID).Msg("mangafire: Failed to visit manga page") + return + } + + // Comparison + compTitles := []*string{&result.Title} + for _, syn := range result.Synonyms { + if !util.IsMostlyLatinString(syn) { + continue + } + compTitles = append(compTitles, &syn) + } + compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, compTitles) + + result.SearchRating = compRes.Rating + + results = append(results, result) + }(v) + } + + wg.Wait() + + if len(results) == 0 { + mf.logger.Error().Str("query", opts.Query).Msg("mangafire: No results found") + return nil, ErrNoResults + } + + mf.logger.Info().Int("count", len(results)).Msg("mangafire: Found results") + + return results, nil +} + +func (mf *Mangafire) FindChapters(id string) ([]*hibikemanga.ChapterDetails, error) { + ret := make([]*hibikemanga.ChapterDetails, 0) + + mf.logger.Debug().Str("mangaId", id).Msg("mangafire: Finding chapters") + + // code + + if len(ret) == 0 { + mf.logger.Error().Str("mangaId", id).Msg("mangafire: No chapters found") + return nil, ErrNoChapters + } + + mf.logger.Info().Int("count", len(ret)).Msg("mangafire: Found chapters") + + return ret, nil +} + +func (mf *Mangafire) FindChapterPages(id string) ([]*hibikemanga.ChapterPage, error) { + ret := make([]*hibikemanga.ChapterPage, 0) + + mf.logger.Debug().Str("chapterId", id).Msg("mangafire: Finding chapter pages") + + // code + + if len(ret) == 0 { + mf.logger.Error().Str("chapterId", id).Msg("mangafire: No pages found") + return nil, ErrNoPages + } + + mf.logger.Info().Int("count", len(ret)).Msg("mangafire: Found pages") + + return ret, nil + +} diff --git a/seanime-2.9.10/internal/manga/providers/mangafire_test.go b/seanime-2.9.10/internal/manga/providers/mangafire_test.go new file mode 100644 index 0000000..9b7a159 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/mangafire_test.go @@ -0,0 +1,132 @@ +package manga_providers + +import ( + "github.com/stretchr/testify/assert" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "testing" +) + +func TestMangafire_Search(t *testing.T) { + + tests := []struct { + name string + query string + }{ + { + name: "Boku no Kokoro no Yabai Yatsu", + query: "Boku no Kokoro no Yabai Yatsu", + }, + { + name: "Dangers in My Heart", + query: "Dangers in My Heart", + }, + } + + provider := NewMangafire(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + searchRes, err := provider.Search(hibikemanga.SearchOptions{ + Query: tt.query, + }) + if assert.NoError(t, err, "provider.Search() error") { + assert.NotEmpty(t, searchRes, "search result is empty") + + for _, res := range searchRes { + t.Logf("Title: %s", res.Title) + t.Logf("\tID: %s", res.ID) + t.Logf("\tYear: %d", res.Year) + t.Logf("\tImage: %s", res.Image) + t.Logf("\tProvider: %s", res.Provider) + t.Logf("\tSearchRating: %f", res.SearchRating) + t.Logf("\tSynonyms: %v", res.Synonyms) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestMangafire_FindChapters(t *testing.T) { + + tests := []struct { + name string + id string + atLeast int + }{ + { + name: "The Dangers in My Heart", + id: "/manga/boku-no-kokoro-no-yabai-yatsu.vv882", + atLeast: 141, + }, + } + + provider := NewMangafire(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + chapters, err := provider.FindChapters(tt.id) + if assert.NoError(t, err, "provider.FindChapters() error") { + + assert.NotEmpty(t, chapters, "chapters is empty") + + assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected") + + for _, chapter := range chapters { + t.Logf("Title: %s", chapter.Title) + t.Logf("\tSlug: %s", chapter.ID) + t.Logf("\tURL: %s", chapter.URL) + t.Logf("\tIndex: %d", chapter.Index) + t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +//func TestMangafire_FindChapterPages(t *testing.T) { +// +// tests := []struct { +// name string +// chapterId string +// }{ +// { +// name: "The Dangers in My Heart", +// chapterId: "", // Chapter 1 +// }, +// } +// +// provider := NewMangafire(util.NewLogger()) +// +// for _, tt := range tests { +// +// t.Run(tt.name, func(t *testing.T) { +// +// pages, err := provider.FindChapterPages(tt.chapterId) +// if assert.NoError(t, err, "provider.FindChapterPages() error") { +// assert.NotEmpty(t, pages, "pages is empty") +// +// for _, page := range pages { +// t.Logf("Index: %d", page.Index) +// t.Logf("\tURL: %s", page.URL) +// t.Log("--------------------------------------------------") +// } +// } +// +// }) +// +// } +// +//} diff --git a/seanime-2.9.10/internal/manga/providers/manganato.go b/seanime-2.9.10/internal/manga/providers/manganato.go new file mode 100644 index 0000000..ac9e1c3 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/manganato.go @@ -0,0 +1,302 @@ +package manga_providers + +import ( + "bytes" + "fmt" + "net/url" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "seanime/internal/util/comparison" + "slices" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/imroc/req/v3" + "github.com/rs/zerolog" +) + +type ( + Manganato struct { + Url string + Client *req.Client + logger *zerolog.Logger + } + + ManganatoSearchResult struct { + ID string `json:"id"` + Name string `json:"name"` + NameUnsigned string `json:"nameunsigned"` + LastChapter string `json:"lastchapter"` + Image string `json:"image"` + Author string `json:"author"` + StoryLink string `json:"story_link"` + } +) + +func NewManganato(logger *zerolog.Logger) *Manganato { + client := req.C(). + SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"). + SetTimeout(60 * time.Second). + EnableInsecureSkipVerify(). + ImpersonateSafari() + + return &Manganato{ + Url: "https://natomanga.com", + Client: client, + logger: logger, + } +} + +func (mp *Manganato) GetSettings() hibikemanga.Settings { + return hibikemanga.Settings{ + SupportsMultiScanlator: false, + SupportsMultiLanguage: false, + } +} + +func (mp *Manganato) Search(opts hibikemanga.SearchOptions) (ret []*hibikemanga.SearchResult, err error) { + ret = make([]*hibikemanga.SearchResult, 0) + + mp.logger.Debug().Str("query", opts.Query).Msg("manganato: Searching manga") + + q := opts.Query + q = strings.ReplaceAll(q, " ", "_") + q = strings.ToLower(q) + q = strings.TrimSpace(q) + q = url.QueryEscape(q) + uri := fmt.Sprintf("https://natomanga.com/search/story/%s", q) + + resp, err := mp.Client.R(). + SetHeader("User-Agent", util.GetRandomUserAgent()). + Get(uri) + + if err != nil { + mp.logger.Error().Err(err).Str("uri", uri).Msg("manganato: Failed to send request") + return nil, err + } + + if !resp.IsSuccessState() { + mp.logger.Error().Str("status", resp.Status).Str("uri", uri).Msg("manganato: Request failed") + return nil, fmt.Errorf("failed to fetch search results: status %s", resp.Status) + } + + bodyBytes := resp.Bytes() + + //mp.logger.Debug().Str("body", string(bodyBytes)).Msg("manganato: Response body") + + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes)) + if err != nil { + mp.logger.Error().Err(err).Msg("manganato: Failed to parse HTML") + return nil, err + } + + doc.Find("div.story_item").Each(func(i int, s *goquery.Selection) { + defer func() { + if r := recover(); r != nil { + } + }() + + result := &hibikemanga.SearchResult{ + Provider: string(ManganatoProvider), + } + + href, exists := s.Find("a").Attr("href") + if !exists { + return + } + + if !strings.HasPrefix(href, "https://natomanga.com/") && + !strings.HasPrefix(href, "https://www.natomanga.com/") && + !strings.HasPrefix(href, "https://www.chapmanganato.com/") && + !strings.HasPrefix(href, "https://chapmanganato.com/") { + return + } + + result.ID = href + splitHref := strings.Split(result.ID, "/") + + if strings.Contains(href, "chapmanganato") { + result.ID = "chapmanganato$" + } else { + result.ID = "manganato$" + } + + if len(splitHref) > 4 { + result.ID += splitHref[4] + } + + result.Title = s.Find("h3.story_name").Text() + result.Title = strings.TrimSpace(result.Title) + result.Image, _ = s.Find("img").Attr("src") + + compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, []*string{&result.Title}) + result.SearchRating = compRes.Rating + ret = append(ret, result) + }) + + if len(ret) == 0 { + mp.logger.Error().Str("query", opts.Query).Msg("manganato: No results found") + return nil, ErrNoResults + } + + mp.logger.Info().Int("count", len(ret)).Msg("manganato: Found results") + + return ret, nil +} + +func (mp *Manganato) FindChapters(id string) (ret []*hibikemanga.ChapterDetails, err error) { + ret = make([]*hibikemanga.ChapterDetails, 0) + + mp.logger.Debug().Str("mangaId", id).Msg("manganato: Finding chapters") + + splitId := strings.Split(id, "$") + if len(splitId) != 2 { + mp.logger.Error().Str("mangaId", id).Msg("manganato: Invalid manga ID") + return nil, ErrNoChapters + } + + uri := "" + if splitId[0] == "manganato" { + uri = fmt.Sprintf("https://natomanga.com/manga/%s", splitId[1]) + } else if splitId[0] == "chapmanganato" { + uri = fmt.Sprintf("https://chapmanganato.com/manga/%s", splitId[1]) + } + + resp, err := mp.Client.R(). + SetHeader("User-Agent", util.GetRandomUserAgent()). + Get(uri) + + if err != nil { + mp.logger.Error().Err(err).Str("uri", uri).Msg("manganato: Failed to send request") + return nil, err + } + + if !resp.IsSuccessState() { + mp.logger.Error().Str("status", resp.Status).Str("uri", uri).Msg("manganato: Request failed") + return nil, fmt.Errorf("failed to fetch chapters: status %s", resp.Status) + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + mp.logger.Error().Err(err).Msg("manganato: Failed to parse HTML") + return nil, err + } + + doc.Find(".chapter-list .row").Each(func(i int, s *goquery.Selection) { + defer func() { + if r := recover(); r != nil { + } + }() + + name := s.Find("a").Text() + if strings.HasPrefix(name, "Vol.") { + split := strings.Split(name, " ") + name = strings.Join(split[1:], " ") + } + + chStr := strings.TrimSpace(strings.Split(name, " ")[1]) + chStr = strings.TrimSuffix(chStr, ":") + + href, exists := s.Find("a").Attr("href") + if !exists { + return + } + + hrefParts := strings.Split(href, "/") + if len(hrefParts) < 6 { + return + } + + chapterId := hrefParts[5] + chapter := &hibikemanga.ChapterDetails{ + Provider: string(ManganatoProvider), + ID: splitId[1] + "$" + chapterId, + URL: href, + Title: strings.TrimSpace(name), + Chapter: chStr, + } + ret = append(ret, chapter) + }) + + slices.Reverse(ret) + for i, chapter := range ret { + chapter.Index = uint(i) + } + + if len(ret) == 0 { + mp.logger.Error().Str("mangaId", id).Msg("manganato: No chapters found") + return nil, ErrNoChapters + } + + mp.logger.Info().Int("count", len(ret)).Msg("manganato: Found chapters") + + return ret, nil +} + +func (mp *Manganato) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) { + ret = make([]*hibikemanga.ChapterPage, 0) + + mp.logger.Debug().Str("chapterId", id).Msg("manganato: Finding chapter pages") + + splitId := strings.Split(id, "$") + if len(splitId) != 2 { + mp.logger.Error().Str("chapterId", id).Msg("manganato: Invalid chapter ID") + return nil, ErrNoPages + } + + uri := fmt.Sprintf("https://natomanga.com/manga/%s/%s", splitId[0], splitId[1]) + + resp, err := mp.Client.R(). + SetHeader("User-Agent", util.GetRandomUserAgent()). + SetHeader("Referer", "https://natomanga.com/"). + Get(uri) + + if err != nil { + mp.logger.Error().Err(err).Str("uri", uri).Msg("manganato: Failed to send request") + return nil, err + } + + if !resp.IsSuccessState() { + mp.logger.Error().Str("status", resp.Status).Str("uri", uri).Msg("manganato: Request failed") + return nil, fmt.Errorf("failed to fetch chapter pages: status %s", resp.Status) + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + mp.logger.Error().Err(err).Msg("manganato: Failed to parse HTML") + return nil, err + } + + doc.Find(".container-chapter-reader img").Each(func(i int, s *goquery.Selection) { + defer func() { + if r := recover(); r != nil { + } + }() + + src, exists := s.Attr("src") + if !exists || src == "" { + return + } + + page := &hibikemanga.ChapterPage{ + Provider: string(ManganatoProvider), + URL: src, + Index: len(ret), + Headers: map[string]string{ + "Referer": "https://natomanga.com/", + }, + } + ret = append(ret, page) + }) + + if len(ret) == 0 { + mp.logger.Error().Str("chapterId", id).Msg("manganato: No pages found") + return nil, ErrNoPages + } + + mp.logger.Info().Int("count", len(ret)).Msg("manganato: Found pages") + + return ret, nil + +} diff --git a/seanime-2.9.10/internal/manga/providers/manganato_test.go b/seanime-2.9.10/internal/manga/providers/manganato_test.go new file mode 100644 index 0000000..340ef6c --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/manganato_test.go @@ -0,0 +1,130 @@ +package manga_providers + +import ( + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestManganato_Search(t *testing.T) { + + tests := []struct { + name string + query string + }{ + { + name: "Boku no Kokoro no Yabai Yatsu", + query: "Boku no Kokoro no Yabai Yatsu", + }, + } + + provider := NewManganato(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + searchRes, err := provider.Search(hibikemanga.SearchOptions{ + Query: tt.query, + }) + if assert.NoError(t, err, "provider.Search() error") { + assert.NotEmpty(t, searchRes, "search result is empty") + + for _, res := range searchRes { + t.Logf("Title: %s", res.Title) + t.Logf("\tID: %s", res.ID) + t.Logf("\tYear: %d", res.Year) + t.Logf("\tImage: %s", res.Image) + t.Logf("\tProvider: %s", res.Provider) + t.Logf("\tSearchRating: %f", res.SearchRating) + t.Logf("\tSynonyms: %v", res.Synonyms) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestManganato_FindChapters(t *testing.T) { + + tests := []struct { + name string + id string + atLeast int + }{ + { + name: "The Dangers in My Heart", + id: "manganato$boku-no-kokoro-no-yabai-yatsu", + atLeast: 141, + }, + } + + provider := NewManganato(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + chapters, err := provider.FindChapters(tt.id) + if assert.NoError(t, err, "provider.FindChapters() error") { + + assert.NotEmpty(t, chapters, "chapters is empty") + + assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected") + + for _, chapter := range chapters { + t.Logf("Title: %s", chapter.Title) + t.Logf("\tID: %s", chapter.ID) + t.Logf("\tChapter: %s", chapter.Chapter) + t.Logf("\tURL: %s", chapter.URL) + t.Logf("\tIndex: %d", chapter.Index) + t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestManganato_FindChapterPages(t *testing.T) { + + tests := []struct { + name string + chapterId string + }{ + { + name: "The Dangers in My Heart", + chapterId: "boku-no-kokoro-no-yabai-yatsu$chapter-20", // Chapter 20 + }, + } + + provider := NewManganato(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + pages, err := provider.FindChapterPages(tt.chapterId) + if assert.NoError(t, err, "provider.FindChapterPages() error") { + assert.NotEmpty(t, pages, "pages is empty") + + for _, page := range pages { + t.Logf("Index: %d", page.Index) + t.Logf("\tURL: %s", page.URL) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} diff --git a/seanime-2.9.10/internal/manga/providers/mangapill.go b/seanime-2.9.10/internal/manga/providers/mangapill.go new file mode 100644 index 0000000..42dc265 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/mangapill.go @@ -0,0 +1,235 @@ +package manga_providers + +import ( + "fmt" + "net/url" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "seanime/internal/util/comparison" + "slices" + "strconv" + "strings" + "time" + + "github.com/gocolly/colly" + "github.com/imroc/req/v3" + "github.com/rs/zerolog" +) + +type ( + Mangapill struct { + Url string + Client *req.Client + UserAgent string + logger *zerolog.Logger + } +) + +func NewMangapill(logger *zerolog.Logger) *Mangapill { + client := req.C(). + SetUserAgent(util.GetRandomUserAgent()). + SetTimeout(60 * time.Second). + EnableInsecureSkipVerify(). + ImpersonateChrome() + + return &Mangapill{ + Url: "https://mangapill.com", + Client: client, + UserAgent: util.GetRandomUserAgent(), + logger: logger, + } +} + +// DEVNOTE: Unique ID +// Each chapter ID has this format: {number}${slug} -- e.g. 6502-10004000$gokurakugai-chapter-4 +// The chapter ID is split by the $ character to reconstruct the chapter URL for subsequent requests + +func (mp *Mangapill) GetSettings() hibikemanga.Settings { + return hibikemanga.Settings{ + SupportsMultiScanlator: false, + SupportsMultiLanguage: false, + } +} + +func (mp *Mangapill) Search(opts hibikemanga.SearchOptions) (ret []*hibikemanga.SearchResult, err error) { + ret = make([]*hibikemanga.SearchResult, 0) + + mp.logger.Debug().Str("query", opts.Query).Msg("mangapill: Searching manga") + + uri := fmt.Sprintf("%s/search?q=%s", mp.Url, url.QueryEscape(opts.Query)) + + c := colly.NewCollector( + colly.UserAgent(mp.UserAgent), + ) + + c.WithTransport(mp.Client.Transport) + + c.OnHTML("div.container div.my-3.justify-end > div", func(e *colly.HTMLElement) { + defer func() { + if r := recover(); r != nil { + } + }() + result := &hibikemanga.SearchResult{ + Provider: string(MangapillProvider), + } + + result.ID = strings.Split(e.ChildAttr("a", "href"), "/manga/")[1] + result.ID = strings.Replace(result.ID, "/", "$", -1) + + title := e.DOM.Find("div > a > div.mt-3").Text() + result.Title = strings.TrimSpace(title) + + altTitles := e.DOM.Find("div > a > div.text-xs.text-secondary").Text() + if altTitles != "" { + result.Synonyms = []string{strings.TrimSpace(altTitles)} + } + + compTitles := []*string{&result.Title} + if len(result.Synonyms) > 0 { + compTitles = append(compTitles, &result.Synonyms[0]) + } + compRes, _ := comparison.FindBestMatchWithSorensenDice(&opts.Query, compTitles) + result.SearchRating = compRes.Rating + + result.Image = e.ChildAttr("a img", "data-src") + + yearStr := e.DOM.Find("div > div.flex > div").Eq(1).Text() + year, err := strconv.Atoi(strings.TrimSpace(yearStr)) + if err != nil { + result.Year = 0 + } else { + result.Year = year + } + + ret = append(ret, result) + }) + + err = c.Visit(uri) + if err != nil { + mp.logger.Error().Err(err).Msg("mangapill: Failed to visit") + return nil, err + } + + // code + + if len(ret) == 0 { + mp.logger.Error().Str("query", opts.Query).Msg("mangapill: No results found") + return nil, ErrNoResults + } + + mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found results") + + return ret, nil +} + +func (mp *Mangapill) FindChapters(id string) (ret []*hibikemanga.ChapterDetails, err error) { + ret = make([]*hibikemanga.ChapterDetails, 0) + + mp.logger.Debug().Str("mangaId", id).Msg("mangapill: Finding chapters") + + uriId := strings.Replace(id, "$", "/", -1) + uri := fmt.Sprintf("%s/manga/%s", mp.Url, uriId) + + c := colly.NewCollector( + colly.UserAgent(mp.UserAgent), + ) + + c.WithTransport(mp.Client.Transport) + + c.OnHTML("div.container div.border-border div#chapters div.grid-cols-1 a", func(e *colly.HTMLElement) { + defer func() { + if r := recover(); r != nil { + } + }() + chapter := &hibikemanga.ChapterDetails{ + Provider: MangapillProvider, + } + + chapter.ID = strings.Split(e.Attr("href"), "/chapters/")[1] + chapter.ID = strings.Replace(chapter.ID, "/", "$", -1) + + chapter.Title = strings.TrimSpace(e.Text) + + splitTitle := strings.Split(chapter.Title, "Chapter ") + if len(splitTitle) < 2 { + return + } + chapter.Chapter = splitTitle[1] + + ret = append(ret, chapter) + }) + + err = c.Visit(uri) + if err != nil { + mp.logger.Error().Err(err).Msg("mangapill: Failed to visit") + return nil, err + } + + if len(ret) == 0 { + mp.logger.Error().Str("mangaId", id).Msg("mangapill: No chapters found") + return nil, ErrNoChapters + } + + slices.Reverse(ret) + + for i, chapter := range ret { + chapter.Index = uint(i) + } + + mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found chapters") + + return ret, nil +} + +func (mp *Mangapill) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) { + ret = make([]*hibikemanga.ChapterPage, 0) + + mp.logger.Debug().Str("chapterId", id).Msg("mangapill: Finding chapter pages") + + uriId := strings.Replace(id, "$", "/", -1) + uri := fmt.Sprintf("%s/chapters/%s", mp.Url, uriId) + + c := colly.NewCollector( + colly.UserAgent(mp.UserAgent), + ) + + c.WithTransport(mp.Client.Transport) + + c.OnHTML("chapter-page", func(e *colly.HTMLElement) { + defer func() { + if r := recover(); r != nil { + } + }() + page := &hibikemanga.ChapterPage{} + + page.URL = e.DOM.Find("div picture img").AttrOr("data-src", "") + if page.URL == "" { + return + } + indexStr := e.DOM.Find("div[data-summary] > div").Text() + index, _ := strconv.Atoi(strings.Split(strings.Split(indexStr, "page ")[1], "/")[0]) + page.Index = index - 1 + + page.Headers = map[string]string{ + "Referer": "https://mangapill.com/", + } + + ret = append(ret, page) + }) + + err = c.Visit(uri) + if err != nil { + mp.logger.Error().Err(err).Msg("mangapill: Failed to visit") + return nil, err + } + + if len(ret) == 0 { + mp.logger.Error().Str("chapterId", id).Msg("mangapill: No pages found") + return nil, ErrNoPages + } + + mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found pages") + + return ret, nil + +} diff --git a/seanime-2.9.10/internal/manga/providers/mangapill_test.go b/seanime-2.9.10/internal/manga/providers/mangapill_test.go new file mode 100644 index 0000000..7baa117 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/mangapill_test.go @@ -0,0 +1,128 @@ +package manga_providers + +import ( + "github.com/stretchr/testify/assert" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "testing" +) + +func TestMangapill_Search(t *testing.T) { + + tests := []struct { + name string + query string + }{ + { + name: "Boku no Kokoro no Yabai Yatsu", + query: "Boku no Kokoro no Yabai Yatsu", + }, + } + + provider := NewMangapill(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + searchRes, err := provider.Search(hibikemanga.SearchOptions{ + Query: tt.query, + }) + if assert.NoError(t, err, "provider.Search() error") { + assert.NotEmpty(t, searchRes, "search result is empty") + + for _, res := range searchRes { + t.Logf("Title: %s", res.Title) + t.Logf("\tID: %s", res.ID) + t.Logf("\tYear: %d", res.Year) + t.Logf("\tImage: %s", res.Image) + t.Logf("\tProvider: %s", res.Provider) + t.Logf("\tSearchRating: %f", res.SearchRating) + t.Logf("\tSynonyms: %v", res.Synonyms) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestMangapill_FindChapters(t *testing.T) { + + tests := []struct { + name string + id string + atLeast int + }{ + { + name: "The Dangers in My Heart", + id: "5232$boku-no-kokoro-no-yabai-yatsu", + atLeast: 141, + }, + } + + provider := NewMangapill(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + chapters, err := provider.FindChapters(tt.id) + if assert.NoError(t, err, "provider.FindChapters() error") { + + assert.NotEmpty(t, chapters, "chapters is empty") + + assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected") + + for _, chapter := range chapters { + t.Logf("Title: %s", chapter.Title) + t.Logf("\tSlug: %s", chapter.ID) + t.Logf("\tURL: %s", chapter.URL) + t.Logf("\tIndex: %d", chapter.Index) + t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestMangapill_FindChapterPages(t *testing.T) { + + tests := []struct { + name string + chapterId string + }{ + { + name: "The Dangers in My Heart", + chapterId: "5232-10001000$boku-no-kokoro-no-yabai-yatsu-chapter-1", // Chapter 1 + }, + } + + provider := NewMangapill(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + pages, err := provider.FindChapterPages(tt.chapterId) + if assert.NoError(t, err, "provider.FindChapterPages() error") { + assert.NotEmpty(t, pages, "pages is empty") + + for _, page := range pages { + t.Logf("Index: %d", page.Index) + t.Logf("\tURL: %s", page.URL) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} diff --git a/seanime-2.9.10/internal/manga/providers/providers.go b/seanime-2.9.10/internal/manga/providers/providers.go new file mode 100644 index 0000000..c1189a8 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/providers.go @@ -0,0 +1,19 @@ +package manga_providers + +import "errors" + +const ( + WeebCentralProvider = "weebcentral" + MangadexProvider string = "mangadex" + ComickProvider string = "comick" + MangapillProvider string = "mangapill" + ManganatoProvider string = "manganato" + MangafireProvider string = "mangafire" + LocalProvider string = "local-manga" +) + +var ( + ErrNoResults = errors.New("no results found") + ErrNoChapters = errors.New("no chapters found") + ErrNoPages = errors.New("no pages found") +) diff --git a/seanime-2.9.10/internal/manga/providers/proxy_images.go b/seanime-2.9.10/internal/manga/providers/proxy_images.go new file mode 100644 index 0000000..0129284 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/proxy_images.go @@ -0,0 +1,8 @@ +package manga_providers + +import util "seanime/internal/util/proxies" + +func GetImageByProxy(url string, headers map[string]string) ([]byte, error) { + ip := &util.ImageProxy{} + return ip.GetImage(url, headers) +} diff --git a/seanime-2.9.10/internal/manga/providers/weebcentral.go b/seanime-2.9.10/internal/manga/providers/weebcentral.go new file mode 100644 index 0000000..e225f52 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/weebcentral.go @@ -0,0 +1,309 @@ +package manga_providers + +import ( + "errors" + "fmt" + "net/url" + "regexp" + hibikemanga "seanime/internal/extension/hibike/manga" + "seanime/internal/util" + "seanime/internal/util/comparison" + "slices" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/imroc/req/v3" + "github.com/rs/zerolog" +) + +// WeebCentral implements the manga provider for WeebCentral +// It uses goquery to scrape search results, chapter lists, and chapter pages. + +type WeebCentral struct { + Url string + UserAgent string + Client *req.Client + logger *zerolog.Logger +} + +// NewWeebCentral initializes and returns a new WeebCentral provider instance. +func NewWeebCentral(logger *zerolog.Logger) *WeebCentral { + client := req.C(). + SetUserAgent(util.GetRandomUserAgent()). + SetTimeout(60 * time.Second). + EnableInsecureSkipVerify(). + ImpersonateChrome() + + return &WeebCentral{ + Url: "https://weebcentral.com", + UserAgent: util.GetRandomUserAgent(), + Client: client, + logger: logger, + } +} + +func (w *WeebCentral) GetSettings() hibikemanga.Settings { + return hibikemanga.Settings{ + SupportsMultiScanlator: false, + SupportsMultiLanguage: false, + } +} + +func (w *WeebCentral) Search(opts hibikemanga.SearchOptions) ([]*hibikemanga.SearchResult, error) { + w.logger.Debug().Str("query", opts.Query).Msg("weebcentral: Searching manga") + + searchUrl := fmt.Sprintf("%s/search/simple?location=main", w.Url) + form := url.Values{} + form.Set("text", opts.Query) + + resp, err := w.Client.R(). + SetContentType("application/x-www-form-urlencoded"). + SetHeader("HX-Request", "true"). + SetHeader("HX-Trigger", "quick-search-input"). + SetHeader("HX-Trigger-Name", "text"). + SetHeader("HX-Target", "quick-search-result"). + SetHeader("HX-Current-URL", w.Url+"/"). + SetBody(form.Encode()). + Post(searchUrl) + + if err != nil { + w.logger.Error().Err(err).Msg("weebcentral: Failed to send search request") + return nil, err + } + + if !resp.IsSuccessState() { + w.logger.Error().Str("status", resp.Status).Msg("weebcentral: Search request failed") + return nil, fmt.Errorf("search request failed: status %s", resp.Status) + } + + body := resp.String() + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(body)) + if err != nil { + w.logger.Error().Err(err).Msg("weebcentral: Failed to parse search HTML") + return nil, err + } + + var searchResults []*hibikemanga.SearchResult + doc.Find("#quick-search-result > div > a").Each(func(i int, s *goquery.Selection) { + link, exists := s.Attr("href") + if !exists { + return + } + title := strings.TrimSpace(s.Find(".flex-1").Text()) + + var image string + if s.Find("source").Length() > 0 { + image, _ = s.Find("source").Attr("srcset") + } else if s.Find("img").Length() > 0 { + image, _ = s.Find("img").Attr("src") + } + + // Extract manga id from link assuming the format contains '/series/{id}/' + idPart := "" + parts := strings.Split(link, "/series/") + if len(parts) > 1 { + subparts := strings.Split(parts[1], "/") + idPart = subparts[0] + } + if idPart == "" { + return + } + + titleCopy := title + titles := []*string{&titleCopy} + compRes, ok := comparison.FindBestMatchWithSorensenDice(&opts.Query, titles) + if !ok || compRes.Rating < 0.6 { + return + } + + searchResults = append(searchResults, &hibikemanga.SearchResult{ + ID: idPart, + Title: title, + Synonyms: []string{}, + Year: 0, + Image: image, + Provider: WeebCentralProvider, + SearchRating: compRes.Rating, + }) + }) + + if len(searchResults) == 0 { + w.logger.Error().Msg("weebcentral: No search results found") + return nil, errors.New("no results found") + } + + w.logger.Info().Int("count", len(searchResults)).Msg("weebcentral: Found search results") + return searchResults, nil +} + +func (w *WeebCentral) FindChapters(mangaId string) ([]*hibikemanga.ChapterDetails, error) { + w.logger.Debug().Str("mangaId", mangaId).Msg("weebcentral: Fetching chapters") + + chapterUrl := fmt.Sprintf("%s/series/%s/full-chapter-list", w.Url, mangaId) + + resp, err := w.Client.R(). + SetHeader("HX-Request", "true"). + SetHeader("HX-Target", "chapter-list"). + SetHeader("HX-Current-URL", fmt.Sprintf("%s/series/%s", w.Url, mangaId)). + SetHeader("Referer", fmt.Sprintf("%s/series/%s", w.Url, mangaId)). + Get(chapterUrl) + + if err != nil { + w.logger.Error().Err(err).Msg("weebcentral: Failed to fetch chapter list") + return nil, err + } + + if !resp.IsSuccessState() { + w.logger.Error().Str("status", resp.Status).Msg("weebcentral: Chapter list request failed") + return nil, fmt.Errorf("chapter list request failed: status %s", resp.Status) + } + + body := resp.String() + doc, err := goquery.NewDocumentFromReader(strings.NewReader(body)) + if err != nil { + w.logger.Error().Err(err).Msg("weebcentral: Failed to parse chapter list HTML") + return nil, err + } + + var chapters []*hibikemanga.ChapterDetails + volumeCounter := 1 + lastChapterNumber := 9999.0 + + chapterRegex := regexp.MustCompile("(\\d+(?:\\.\\d+)?)") + + doc.Find("div.flex.items-center").Each(func(i int, s *goquery.Selection) { + a := s.Find("a") + chapterUrl, exists := a.Attr("href") + if !exists { + return + } + chapterTitle := strings.TrimSpace(a.Find("span.grow > span").First().Text()) + + var chapterNumber string + var parsedChapterNumber float64 + + match := chapterRegex.FindStringSubmatch(chapterTitle) + if len(match) > 1 { + chapterNumber = w.cleanChapterNumber(match[1]) + if num, err := strconv.ParseFloat(chapterNumber, 64); err == nil { + parsedChapterNumber = num + } + } else { + chapterNumber = "" + } + + if parsedChapterNumber > lastChapterNumber { + volumeCounter++ + } + if parsedChapterNumber != 0 { + lastChapterNumber = parsedChapterNumber + } + + // Extract chapter id from the URL assuming format contains '/chapters/{id}' + chapterId := "" + parts := strings.Split(chapterUrl, "/chapters/") + if len(parts) > 1 { + chapterId = parts[1] + } + + chapters = append(chapters, &hibikemanga.ChapterDetails{ + ID: chapterId, + URL: chapterUrl, + Title: chapterTitle, + Chapter: chapterNumber, + Index: uint(i), + Provider: WeebCentralProvider, + }) + }) + + if len(chapters) == 0 { + w.logger.Error().Msg("weebcentral: No chapters found") + return nil, errors.New("no chapters found") + } + + slices.Reverse(chapters) + + for i := range chapters { + chapters[i].Index = uint(i) + } + + w.logger.Info().Int("count", len(chapters)).Msg("weebcentral: Found chapters") + return chapters, nil +} + +func (w *WeebCentral) FindChapterPages(chapterId string) ([]*hibikemanga.ChapterPage, error) { + url := fmt.Sprintf("%s/chapters/%s/images?is_prev=False&reading_style=long_strip", w.Url, chapterId) + + resp, err := w.Client.R(). + SetHeader("HX-Request", "true"). + SetHeader("HX-Current-URL", fmt.Sprintf("%s/chapters/%s", w.Url, chapterId)). + SetHeader("Referer", fmt.Sprintf("%s/chapters/%s", w.Url, chapterId)). + Get(url) + + if err != nil { + w.logger.Error().Err(err).Msg("weebcentral: Failed to fetch chapter pages") + return nil, err + } + + if !resp.IsSuccessState() { + w.logger.Error().Str("status", resp.Status).Msg("weebcentral: Chapter pages request failed") + return nil, fmt.Errorf("chapter pages request failed: status %s", resp.Status) + } + + body := resp.String() + doc, err := goquery.NewDocumentFromReader(strings.NewReader(body)) + if err != nil { + w.logger.Error().Err(err).Msg("weebcentral: Failed to parse chapter pages HTML") + return nil, err + } + + var pages []*hibikemanga.ChapterPage + totalImgs := doc.Find("img").Length() + + doc.Find("section.flex-1 img").Each(func(i int, s *goquery.Selection) { + imageUrl, exists := s.Attr("src") + if !exists || imageUrl == "" { + return + } + pages = append(pages, &hibikemanga.ChapterPage{ + URL: imageUrl, + Index: i, + Headers: map[string]string{"Referer": w.Url}, + Provider: WeebCentralProvider, + }) + }) + + if len(pages) == 0 && totalImgs > 0 { + doc.Find("img").Each(func(i int, s *goquery.Selection) { + imageUrl, exists := s.Attr("src") + if !exists || imageUrl == "" { + return + } + pages = append(pages, &hibikemanga.ChapterPage{ + URL: imageUrl, + Index: i, + Headers: map[string]string{"Referer": w.Url}, + Provider: WeebCentralProvider, + }) + }) + } + + if len(pages) == 0 { + w.logger.Error().Msg("weebcentral: No pages found") + return nil, errors.New("no pages found") + } + + w.logger.Info().Int("count", len(pages)).Msg("weebcentral: Found chapter pages") + return pages, nil +} + +func (w *WeebCentral) cleanChapterNumber(chapterStr string) string { + cleaned := strings.TrimLeft(chapterStr, "0") + if cleaned == "" { + return "0" + } + return cleaned +} diff --git a/seanime-2.9.10/internal/manga/providers/weebcentral_test.go b/seanime-2.9.10/internal/manga/providers/weebcentral_test.go new file mode 100644 index 0000000..6130365 --- /dev/null +++ b/seanime-2.9.10/internal/manga/providers/weebcentral_test.go @@ -0,0 +1,162 @@ +package manga_providers + +import ( + "seanime/internal/util" + "testing" + + "github.com/stretchr/testify/assert" + hibikemanga "seanime/internal/extension/hibike/manga" +) + +func TestWeebCentral_Search(t *testing.T) { + + tests := []struct { + name string + query string + }{ + { + name: "One Piece", + query: "One Piece", + }, + { + name: "Jujutsu Kaisen", + query: "Jujutsu Kaisen", + }, + } + + weebcentral := NewWeebCentral(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + searchRes, err := weebcentral.Search(hibikemanga.SearchOptions{ + Query: tt.query, + }) + if assert.NoError(t, err, "weebcentral.Search() error") { + assert.NotEmpty(t, searchRes, "search result is empty") + + for _, res := range searchRes { + t.Logf("Title: %s", res.Title) + t.Logf("\tID: %s", res.ID) + t.Logf("\tYear: %d", res.Year) + t.Logf("\tImage: %s", res.Image) + t.Logf("\tProvider: %s", res.Provider) + t.Logf("\tSearchRating: %f", res.SearchRating) + t.Logf("\tSynonyms: %v", res.Synonyms) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestWeebCentral_FindChapters(t *testing.T) { + + tests := []struct { + name string + id string + atLeast int + }{ + { + name: "One Piece", + id: "01J76XY7E9FNDZ1DBBM6PBJPFK", + atLeast: 1100, + }, + { + name: "Jujutsu Kaisen", + id: "01J76XYCERXE60T7FKXVCCAQ0H", + atLeast: 250, + }, + } + + weebcentral := NewWeebCentral(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + chapters, err := weebcentral.FindChapters(tt.id) + if assert.NoError(t, err, "weebcentral.FindChapters() error") { + + assert.NotEmpty(t, chapters, "chapters is empty") + + assert.GreaterOrEqual(t, len(chapters), tt.atLeast, "chapters length is less than expected") + + for _, chapter := range chapters { + t.Logf("Title: %s", chapter.Title) + t.Logf("\tSlug: %s", chapter.ID) + t.Logf("\tURL: %s", chapter.URL) + t.Logf("\tIndex: %d", chapter.Index) + t.Logf("\tChapter: %s", chapter.Chapter) + t.Logf("\tUpdatedAt: %s", chapter.UpdatedAt) + t.Log("--------------------------------------------------") + } + } + + }) + + } + +} + +func TestWeebCentral_FindChapterPages(t *testing.T) { + + tests := []struct { + name string + id string + index uint + }{ + { + name: "One Piece", + id: "01J76XY7E9FNDZ1DBBM6PBJPFK", + index: 1110, + }, + { + name: "Jujutsu Kaisen", + id: "01J76XYCERXE60T7FKXVCCAQ0H", + index: 0, + }, + } + + weebcentral := NewWeebCentral(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + chapters, err := weebcentral.FindChapters(tt.id) + if assert.NoError(t, err, "weebcentral.FindChapters() error") { + + assert.NotEmpty(t, chapters, "chapters is empty") + + var chapterInfo *hibikemanga.ChapterDetails + for _, chapter := range chapters { + if chapter.Index == tt.index { + chapterInfo = chapter + break + } + } + + if assert.NotNil(t, chapterInfo, "chapter not found") { + pages, err := weebcentral.FindChapterPages(chapterInfo.ID) + if assert.NoError(t, err, "weebcentral.FindChapterPages() error") { + assert.NotEmpty(t, pages, "pages is empty") + + for _, page := range pages { + t.Logf("Index: %d", page.Index) + t.Logf("\tURL: %s", page.URL) + t.Log("--------------------------------------------------") + } + } + } + } + + }) + + } + +} diff --git a/seanime-2.9.10/internal/manga/repository.go b/seanime-2.9.10/internal/manga/repository.go new file mode 100644 index 0000000..d6cf983 --- /dev/null +++ b/seanime-2.9.10/internal/manga/repository.go @@ -0,0 +1,186 @@ +package manga + +import ( + "bytes" + "errors" + "image" + _ "image/jpeg" // Register JPEG format + _ "image/png" // Register PNG format + "net/http" + "seanime/internal/database/db" + "seanime/internal/database/models" + "seanime/internal/events" + "seanime/internal/extension" + "seanime/internal/util/filecache" + "strconv" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" + _ "golang.org/x/image/bmp" // Register BMP format + _ "golang.org/x/image/tiff" // Register Tiff format + _ "golang.org/x/image/webp" // Register WebP format +) + +var ( + ErrNoResults = errors.New("no results found for this media") + ErrNoChapters = errors.New("no manga chapters found") + ErrChapterNotFound = errors.New("chapter not found") + ErrChapterNotDownloaded = errors.New("chapter not downloaded") + ErrNoTitlesProvided = errors.New("no titles provided") +) + +type ( + Repository struct { + logger *zerolog.Logger + fileCacher *filecache.Cacher + cacheDir string + providerExtensionBank *extension.UnifiedBank + serverUri string + wsEventManager events.WSEventManagerInterface + mu sync.Mutex + downloadDir string + db *db.Database + + settings *models.Settings + } + + NewRepositoryOptions struct { + Logger *zerolog.Logger + CacheDir string + FileCacher *filecache.Cacher + ServerURI string + WsEventManager events.WSEventManagerInterface + DownloadDir string + Database *db.Database + } +) + +func NewRepository(opts *NewRepositoryOptions) *Repository { + r := &Repository{ + logger: opts.Logger, + fileCacher: opts.FileCacher, + cacheDir: opts.CacheDir, + serverUri: opts.ServerURI, + wsEventManager: opts.WsEventManager, + downloadDir: opts.DownloadDir, + providerExtensionBank: extension.NewUnifiedBank(), + db: opts.Database, + } + return r +} + +func (r *Repository) SetSettings(settings *models.Settings) { + r.mu.Lock() + defer r.mu.Unlock() + r.settings = settings +} + +func (r *Repository) InitExtensionBank(bank *extension.UnifiedBank) { + r.mu.Lock() + defer r.mu.Unlock() + r.providerExtensionBank = bank + r.logger.Debug().Msg("manga: Initialized provider extension bank") +} + +func (r *Repository) RemoveProvider(id string) { + r.providerExtensionBank.Delete(id) +} + +func (r *Repository) GetProviderExtensionBank() *extension.UnifiedBank { + return r.providerExtensionBank +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// File Cache +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type bucketType string + +const ( + bucketTypeChapterKey = "1" + bucketTypeChapter bucketType = "chapters" + bucketTypePage bucketType = "pages" + bucketTypePageDimensions bucketType = "page-dimensions" +) + +// getFcProviderBucket returns a bucket for the provider and mediaId. +// +// e.g., manga_comick_chapters_123, manga_mangasee_pages_456 +// +// Note: Each bucket contains only 1 key-value pair. +func (r *Repository) getFcProviderBucket(provider string, mediaId int, bucketType bucketType) filecache.Bucket { + return filecache.NewBucket("manga_"+provider+"_"+string(bucketType)+"_"+strconv.Itoa(mediaId), time.Hour*24*7) +} + +// EmptyMangaCache deletes all manga buckets associated with the given mediaId. +func (r *Repository) EmptyMangaCache(mediaId int) (err error) { + // Empty the manga cache + err = r.fileCacher.RemoveAllBy(func(filename string) bool { + return strings.HasPrefix(filename, "manga_") && strings.Contains(filename, strconv.Itoa(mediaId)) + }) + return +} + +func ParseChapterContainerFileName(filename string) (provider string, bucketType bucketType, mediaId int, ok bool) { + filename = strings.TrimSuffix(filename, ".json") + filename = strings.TrimSuffix(filename, ".cache") + filename = strings.TrimSuffix(filename, ".txt") + parts := strings.Split(filename, "_") + if len(parts) != 4 { + return "", "", 0, false + } + + provider = parts[1] + var err error + mediaId, err = strconv.Atoi(parts[3]) + if err != nil { + return "", "", 0, false + } + + switch parts[2] { + case "chapters": + bucketType = bucketTypeChapter + case "pages": + bucketType = bucketTypePage + case "page-dimensions": + bucketType = bucketTypePageDimensions + default: + return "", "", 0, false + } + + ok = true + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func getImageNaturalSize(url string) (int, int, error) { + // Fetch the image + resp, err := http.Get(url) + if err != nil { + return 0, 0, err + } + defer resp.Body.Close() + + // Decode the image + img, _, err := image.DecodeConfig(resp.Body) + if err != nil { + return 0, 0, err + } + + // Return the natural size + return img.Width, img.Height, nil +} + +func getImageNaturalSizeB(data []byte) (int, int, error) { + // Decode the image + img, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return 0, 0, err + } + + // Return the natural size + return img.Width, img.Height, nil +} diff --git a/seanime-2.9.10/internal/mediaplayers/iina/iina.go b/seanime-2.9.10/internal/mediaplayers/iina/iina.go new file mode 100644 index 0000000..9004be5 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/iina/iina.go @@ -0,0 +1,588 @@ +package iina + +import ( + "context" + "errors" + "os/exec" + "seanime/internal/mediaplayers/mpvipc" + "seanime/internal/util" + "seanime/internal/util/result" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" +) + +type ( + Playback struct { + Filename string + Paused bool + Position float64 + Duration float64 + IsRunning bool + Filepath string + } + + Iina struct { + Logger *zerolog.Logger + Playback *Playback + SocketName string + AppPath string + Args string + mu sync.Mutex + playbackMu sync.RWMutex + cancel context.CancelFunc // Cancel function for the context + subscribers *result.Map[string, *Subscriber] // Subscribers to the iina events + conn *mpvipc.Connection // Reference to the mpv connection (iina uses mpv IPC) + cmd *exec.Cmd + prevSocketName string + exitedCh chan struct{} + } + + // Subscriber is a subscriber to the iina events. + // Make sure the subscriber listens to both channels, otherwise it will deadlock. + Subscriber struct { + eventCh chan *mpvipc.Event + closedCh chan struct{} + } +) + +var cmdCtx, cmdCancel = context.WithCancel(context.Background()) + +func New(logger *zerolog.Logger, socketName string, appPath string, optionalArgs ...string) *Iina { + if cmdCancel != nil { + cmdCancel() + } + + sn := socketName + if socketName == "" { + sn = getDefaultSocketName() + } + + additionalArgs := "" + if len(optionalArgs) > 0 { + additionalArgs = optionalArgs[0] + } + + return &Iina{ + Logger: logger, + Playback: &Playback{}, + mu: sync.Mutex{}, + playbackMu: sync.RWMutex{}, + SocketName: sn, + AppPath: appPath, + Args: additionalArgs, + subscribers: result.NewResultMap[string, *Subscriber](), + exitedCh: make(chan struct{}), + } +} + +func (i *Iina) GetExecutablePath() string { + if i.AppPath != "" { + return i.AppPath + } + return "iina-cli" +} + +// launchPlayer starts the iina player and plays the file. +// If the player is already running, it just loads the new file. +func (i *Iina) launchPlayer(idle bool, filePath string, args ...string) error { + var err error + + i.Logger.Trace().Msgf("iina: Launching player with args: %+v", args) + + // Cancel previous goroutine context + if i.cancel != nil { + i.Logger.Trace().Msg("iina: Cancelling previous context") + i.cancel() + } + // Cancel previous command context + if cmdCancel != nil { + i.Logger.Trace().Msg("iina: Cancelling previous command context") + cmdCancel() + } + cmdCtx, cmdCancel = context.WithCancel(context.Background()) + + i.Logger.Debug().Msg("iina: Starting player") + + iinaArgs := []string{ + "--mpv-input-ipc-server=" + i.SocketName, + "--no-stdin", + } + + if idle { + iinaArgs = append(iinaArgs, "--mpv-idle") + iinaArgs = append(iinaArgs, args...) + i.cmd, err = i.createCmd("", iinaArgs...) + } else { + iinaArgs = append(iinaArgs, args...) + i.cmd, err = i.createCmd(filePath, iinaArgs...) + } + + if err != nil { + return err + } + i.prevSocketName = i.SocketName + + err = i.cmd.Start() + if err != nil { + return err + } + + go func() { + err := i.cmd.Wait() + if err != nil { + i.Logger.Warn().Err(err).Msg("iina: Player has exited") + } + }() + + time.Sleep(2 * time.Second) + + i.Logger.Debug().Msg("iina: Player started") + + return nil +} + +func (i *Iina) replaceFile(filePath string) error { + i.Logger.Debug().Msg("iina: Replacing file") + + if i.conn != nil && !i.conn.IsClosed() { + _, err := i.conn.Call("loadfile", filePath, "replace") + if err != nil { + return err + } + } + + return nil +} + +func (i *Iina) Exited() chan struct{} { + return i.exitedCh +} + +func (i *Iina) OpenAndPlay(filePath string, args ...string) error { + i.mu.Lock() + defer i.mu.Unlock() + + i.Playback = &Playback{} + + // If the player is already running, just load the new file + var err error + if i.conn != nil && !i.conn.IsClosed() { + // Launch player or replace file + err = i.replaceFile(filePath) + } else { + // Launch player + err = i.launchPlayer(false, filePath, args...) + } + if err != nil { + return err + } + + var ctx context.Context + ctx, i.cancel = context.WithCancel(context.Background()) + + // Establish new connection, only if it doesn't exist + if i.conn != nil && !i.conn.IsClosed() { + return nil + } + + err = i.establishConnection() + if err != nil { + return err + } + + i.Playback.IsRunning = false + + // Listen for events in a goroutine + go i.listenForEvents(ctx) + + return nil +} + +func (i *Iina) Pause() error { + i.mu.Lock() + defer i.mu.Unlock() + + if i.conn == nil || i.conn.IsClosed() { + return errors.New("iina is not running") + } + + _, err := i.conn.Call("set_property", "pause", true) + if err != nil { + return err + } + + return nil +} + +func (i *Iina) Resume() error { + i.mu.Lock() + defer i.mu.Unlock() + + if i.conn == nil || i.conn.IsClosed() { + return errors.New("iina is not running") + } + + _, err := i.conn.Call("set_property", "pause", false) + if err != nil { + return err + } + + return nil +} + +// SeekTo seeks to the given position in the file. +func (i *Iina) SeekTo(position float64) error { + i.mu.Lock() + defer i.mu.Unlock() + + if i.conn == nil || i.conn.IsClosed() { + return errors.New("iina is not running") + } + + _, err := i.conn.Call("set_property", "time-pos", position) + if err != nil { + return err + } + + return nil +} + +// Seek seeks to the given position in the file. +func (i *Iina) Seek(position float64) error { + i.mu.Lock() + defer i.mu.Unlock() + + if i.conn == nil || i.conn.IsClosed() { + return errors.New("iina is not running") + } + + _, err := i.conn.Call("set_property", "time-pos", position) + if err != nil { + return err + } + + return nil +} + +func (i *Iina) GetOpenConnection() (*mpvipc.Connection, error) { + if i.conn == nil || i.conn.IsClosed() { + return nil, errors.New("iina is not running") + } + return i.conn, nil +} + +func (i *Iina) establishConnection() error { + tries := 1 + for { + i.conn = mpvipc.NewConnection(i.SocketName) + err := i.conn.Open() + if err != nil { + if tries >= 3 { + i.Logger.Error().Err(err).Msg("iina: Failed to establish connection") + return err + } + i.Logger.Error().Err(err).Msgf("iina: Failed to establish connection (%d/8), retrying...", tries) + tries++ + time.Sleep(1500 * time.Millisecond) + continue + } + i.Logger.Debug().Msg("iina: Connection established") + break + } + + return nil +} + +func (i *Iina) listenForEvents(ctx context.Context) { + // Close the connection when the goroutine ends + defer func() { + i.Logger.Debug().Msg("iina: Closing socket connection") + i.conn.Close() + i.terminate() + i.Logger.Debug().Msg("iina: Instance closed") + }() + + events, stopListening := i.conn.NewEventListener() + i.Logger.Debug().Msg("iina: Listening for events") + + _, err := i.conn.Get("path") + if err != nil { + i.Logger.Error().Err(err).Msg("iina: Failed to get path") + return + } + + _, err = i.conn.Call("observe_property", 42, "time-pos") + if err != nil { + i.Logger.Error().Err(err).Msg("iina: Failed to observe time-pos") + return + } + _, err = i.conn.Call("observe_property", 43, "pause") + if err != nil { + i.Logger.Error().Err(err).Msg("iina: Failed to observe pause") + return + } + _, err = i.conn.Call("observe_property", 44, "duration") + if err != nil { + i.Logger.Error().Err(err).Msg("iina: Failed to observe duration") + return + } + _, err = i.conn.Call("observe_property", 45, "filename") + if err != nil { + i.Logger.Error().Err(err).Msg("iina: Failed to observe filename") + return + } + _, err = i.conn.Call("observe_property", 46, "path") + if err != nil { + i.Logger.Error().Err(err).Msg("iina: Failed to observe path") + return + } + + // Listen for close event + go func() { + i.conn.WaitUntilClosed() + i.Logger.Debug().Msg("iina: Connection has been closed") + stopListening <- struct{}{} + }() + + go func() { + // When the context is cancelled, close the connection + <-ctx.Done() + i.Logger.Debug().Msg("iina: Context cancelled") + i.Playback.IsRunning = false + err := i.conn.Close() + if err != nil { + i.Logger.Error().Err(err).Msg("iina: Failed to close connection") + } + stopListening <- struct{}{} + return + }() + + // Listen for events + for event := range events { + if event.Data != nil { + i.Playback.IsRunning = true + switch event.ID { + case 43: + i.Playback.Paused = event.Data.(bool) + case 42: + i.Playback.Position = event.Data.(float64) + case 44: + i.Playback.Duration = event.Data.(float64) + case 45: + i.Playback.Filename = event.Data.(string) + case 46: + i.Playback.Filepath = event.Data.(string) + } + i.subscribers.Range(func(key string, sub *Subscriber) bool { + go func() { + sub.eventCh <- event + }() + return true + }) + } + } +} + +func (i *Iina) GetPlaybackStatus() (*Playback, error) { + i.playbackMu.RLock() + defer i.playbackMu.RUnlock() + if !i.Playback.IsRunning { + return nil, errors.New("iina is not running") + } + if i.Playback == nil { + return nil, errors.New("no playback status") + } + if i.Playback.Filename == "" { + return nil, errors.New("no media found") + } + if i.Playback.Duration == 0 { + return nil, errors.New("no duration found") + } + return i.Playback, nil +} + +func (i *Iina) CloseAll() { + i.Logger.Debug().Msg("iina: Received close request") + if i.conn != nil && !i.conn.IsClosed() { + // Send quit command to IINA before closing connection + i.Logger.Debug().Msg("iina: Sending quit command") + _, err := i.conn.Call("quit") + if err != nil { + i.Logger.Warn().Err(err).Msg("iina: Failed to send quit command") + } + time.Sleep(500 * time.Millisecond) + + err = i.conn.Close() + if err != nil { + i.Logger.Error().Err(err).Msg("iina: Failed to close connection") + } + } + i.terminate() +} + +func (i *Iina) terminate() { + defer func() { + if r := recover(); r != nil { + i.Logger.Warn().Msgf("iina: Termination panic") + } + }() + i.Logger.Trace().Msg("iina: Terminating") + i.resetPlaybackStatus() + i.publishDone() + if i.cancel != nil { + i.cancel() + } + if cmdCancel != nil { + cmdCancel() + } + i.Logger.Trace().Msg("iina: Terminated") +} + +func (i *Iina) Subscribe(id string) *Subscriber { + sub := &Subscriber{ + eventCh: make(chan *mpvipc.Event, 100), + closedCh: make(chan struct{}), + } + i.subscribers.Set(id, sub) + return sub +} + +func (i *Iina) Unsubscribe(id string) { + defer func() { + if r := recover(); r != nil { + } + }() + sub, ok := i.subscribers.Get(id) + if !ok { + return + } + close(sub.eventCh) + close(sub.closedCh) + i.subscribers.Delete(id) +} + +func (s *Subscriber) Events() <-chan *mpvipc.Event { + return s.eventCh +} + +func (s *Subscriber) Closed() <-chan struct{} { + return s.closedCh +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// parseArgs parses a command line string into individual arguments, respecting quotes +func parseArgs(s string) ([]string, error) { + args := make([]string, 0) + var current strings.Builder + var inQuotes bool + var quoteChar rune + + runes := []rune(s) + for i := 0; i < len(runes); i++ { + char := runes[i] + switch { + case char == '"' || char == '\'': + if !inQuotes { + inQuotes = true + quoteChar = char + } else if char == quoteChar { + inQuotes = false + quoteChar = 0 + // Add the current string even if it's empty (for empty quoted strings) + args = append(args, current.String()) + current.Reset() + } else { + current.WriteRune(char) + } + case char == ' ' || char == '\t': + if inQuotes { + current.WriteRune(char) + } else if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + case char == '\\' && i+1 < len(runes): + // Handle escaped characters + if inQuotes && (runes[i+1] == '"' || runes[i+1] == '\'') { + i++ + current.WriteRune(runes[i]) + } else { + current.WriteRune(char) + } + default: + current.WriteRune(char) + } + } + + if inQuotes { + return nil, errors.New("unclosed quote in arguments") + } + + if current.Len() > 0 { + args = append(args, current.String()) + } + + return args, nil +} + +func getDefaultSocketName() string { + return "/tmp/iina_socket" +} + +// createCmd returns a new exec.Cmd instance for iina-cli. +func (i *Iina) createCmd(filePath string, args ...string) (*exec.Cmd, error) { + var cmd *exec.Cmd + + // Add user-defined arguments + if i.Args != "" { + userArgs, err := parseArgs(i.Args) + if err != nil { + i.Logger.Warn().Err(err).Msg("iina: Failed to parse user arguments, using simple split") + userArgs = strings.Fields(i.Args) + } + args = append(args, userArgs...) + } + + if filePath != "" { + args = append(args, filePath) + } + + binaryPath := i.GetExecutablePath() + + cmd = util.NewCmdCtx(cmdCtx, binaryPath, args...) + + i.Logger.Trace().Msgf("iina: Command: %s", strings.Join(cmd.Args, " ")) + + return cmd, nil +} + +func (i *Iina) resetPlaybackStatus() { + i.playbackMu.Lock() + i.Logger.Trace().Msg("iina: Resetting playback status") + i.Playback.Filename = "" + i.Playback.Filepath = "" + i.Playback.Paused = false + i.Playback.Position = 0 + i.Playback.Duration = 0 + i.Playback.IsRunning = false + i.playbackMu.Unlock() + return +} + +func (i *Iina) publishDone() { + defer func() { + if r := recover(); r != nil { + i.Logger.Warn().Msgf("iina: Connection already closed") + } + }() + i.subscribers.Range(func(key string, sub *Subscriber) bool { + go func() { + sub.closedCh <- struct{}{} + }() + return true + }) +} diff --git a/seanime-2.9.10/internal/mediaplayers/iina/iina_test.go b/seanime-2.9.10/internal/mediaplayers/iina/iina_test.go new file mode 100644 index 0000000..e53ceb6 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/iina/iina_test.go @@ -0,0 +1,141 @@ +package iina + +import ( + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var testFilePath = "/Users/rahim/Documents/collection/Bocchi the Rock/[ASW] Bocchi the Rock! - 01 [1080p HEVC][EDC91675].mkv" +var testFilePath2 = "/Users/rahim/Documents/collection/One Piece/[Erai-raws] One Piece - 1072 [1080p][Multiple Subtitle][51CB925F].mkv" + +func TestIina_OpenPlayPauseSeekClose(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + i := New(util.NewLogger(), "", "") + + // Test Open and Play + t.Log("Open and Play...") + err := i.OpenAndPlay(testFilePath) + if err != nil { + t.Skipf("Skipping test: %v", err) + } + + // Subscribe to events + sub := i.Subscribe("test") + + time.Sleep(3 * time.Second) + + t.Log("Get Playback Status...") + status, err := i.GetPlaybackStatus() + if err != nil { + t.Logf("Warning: Could not get playback status: %v", err) + } else { + t.Logf("Playback Status: Duration=%.2f, Position=%.2f, Playing=%t, Filename=%s", + status.Duration, status.Position, !status.Paused, status.Filename) + assert.True(t, status.IsRunning, "Player should be running") + assert.Greater(t, status.Duration, 0.0, "Duration should be greater than 0") + } + + t.Log("Pause...") + err = i.Pause() + if err != nil { + t.Logf("Warning: Could not pause: %v", err) + } else { + time.Sleep(2 * time.Second) + status, err := i.GetPlaybackStatus() + if err == nil { + t.Logf("After pause - Paused: %t", status.Paused) + assert.True(t, status.Paused, "Player should be paused") + } + } + + t.Log("Resume...") + err = i.Resume() + if err != nil { + t.Logf("Warning: Could not resume: %v", err) + } else { + time.Sleep(2 * time.Second) + status, err := i.GetPlaybackStatus() + if err == nil { + t.Logf("After resume - Paused: %t", status.Paused) + assert.False(t, status.Paused, "Player should not be paused") + } + } + + t.Log("Seek...") + seekPosition := 30.0 // Seek to 30 seconds + err = i.Seek(seekPosition) + if err != nil { + t.Logf("Warning: Could not seek: %v", err) + } else { + time.Sleep(2 * time.Second) + status, err := i.GetPlaybackStatus() + if err == nil { + t.Logf("After seek - Position: %.2f", status.Position) + assert.InDelta(t, seekPosition, status.Position, 5.0, "Position should be close to seek position") + } + } + + t.Log("SeekTo...") + seekToPosition := 60.0 // Seek to 60 seconds + err = i.SeekTo(seekToPosition) + if err != nil { + t.Logf("Warning: Could not seek to position: %v", err) + } else { + time.Sleep(2 * time.Second) + status, err := i.GetPlaybackStatus() + if err == nil { + t.Logf("After seekTo - Position: %.2f", status.Position) + assert.InDelta(t, seekToPosition, status.Position, 5.0, "Position should be close to seekTo position") + } + } + + // Test loading another file + t.Log("Open another file...") + err = i.OpenAndPlay(testFilePath2) + if err != nil { + t.Logf("Warning: Could not open another file: %v", err) + } else { + time.Sleep(2 * time.Second) // Wait for the new file to load + status, err := i.GetPlaybackStatus() + if err != nil { + t.Logf("Warning: Could not get playback status after opening another file: %v", err) + } else { + t.Logf("New Playback Status: Duration=%.2f, Position=%.2f, Playing=%t, Filename=%s", + status.Duration, status.Position, !status.Paused, status.Filename) + assert.True(t, status.IsRunning, "Player should be running after opening another file") + assert.Greater(t, status.Duration, 0.0, "Duration should be greater than 0 after opening another file") + } + } + + // Test Close + t.Log("Close...") + go func() { + time.Sleep(2 * time.Second) + i.CloseAll() + }() + + // Wait for close event + select { + case <-sub.Closed(): + t.Log("IINA exited successfully") + case <-time.After(10 * time.Second): + t.Log("Timeout waiting for IINA to close") + i.CloseAll() // Force close + } + + // Verify player is not running + time.Sleep(1 * time.Second) + status, err = i.GetPlaybackStatus() + if err != nil { + t.Log("Confirmed: Player is no longer running") + } else if status != nil && !status.IsRunning { + t.Log("Confirmed: Player status shows not running") + } + + t.Log("Test completed successfully") +} diff --git a/seanime-2.9.10/internal/mediaplayers/mediaplayer/hook_events.go b/seanime-2.9.10/internal/mediaplayers/mediaplayer/hook_events.go new file mode 100644 index 0000000..5472776 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mediaplayer/hook_events.go @@ -0,0 +1,31 @@ +package mediaplayer + +import ( + "seanime/internal/hook_resolver" +) + +// MediaPlayerLocalFileTrackingRequestedEvent is triggered when the playback manager wants to track the progress of a local file. +// Prevent default to stop tracking. +type MediaPlayerLocalFileTrackingRequestedEvent struct { + hook_resolver.Event + // StartRefreshDelay is the number of seconds to wait before attempting to get the status + StartRefreshDelay int `json:"startRefreshDelay"` + // RefreshDelay is the number of seconds to wait before we refresh the status of the player after getting it for the first time + RefreshDelay int `json:"refreshDelay"` + // MaxRetries is the maximum number of retries + MaxRetries int `json:"maxRetries"` +} + +// MediaPlayerStreamTrackingRequestedEvent is triggered when the playback manager wants to track the progress of a stream. +// Prevent default to stop tracking. +type MediaPlayerStreamTrackingRequestedEvent struct { + hook_resolver.Event + // StartRefreshDelay is the number of seconds to wait before attempting to get the status + StartRefreshDelay int `json:"startRefreshDelay"` + // RefreshDelay is the number of seconds to wait before we refresh the status of the player after getting it for the first time + RefreshDelay int `json:"refreshDelay"` + // MaxRetries is the maximum number of retries + MaxRetries int `json:"maxRetries"` + // MaxRetriesAfterStart is the maximum number of retries after the player has started + MaxRetriesAfterStart int `json:"maxRetriesAfterStart"` +} diff --git a/seanime-2.9.10/internal/mediaplayers/mediaplayer/repository.go b/seanime-2.9.10/internal/mediaplayers/mediaplayer/repository.go new file mode 100644 index 0000000..2b3ef27 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mediaplayer/repository.go @@ -0,0 +1,1035 @@ +package mediaplayer + +import ( + "context" + "errors" + "fmt" + "seanime/internal/continuity" + "seanime/internal/events" + "seanime/internal/hook" + "seanime/internal/mediaplayers/iina" + mpchc2 "seanime/internal/mediaplayers/mpchc" + "seanime/internal/mediaplayers/mpv" + vlc2 "seanime/internal/mediaplayers/vlc" + "seanime/internal/util/result" + "sync" + "time" + + "github.com/rs/zerolog" +) + +const ( + PlayerClosedEvent = "Player closed" +) + +type PlaybackType string + +const ( + PlaybackTypeFile PlaybackType = "file" + PlaybackTypeStream PlaybackType = "stream" +) + +type ( + // Repository provides a common interface to interact with media players + Repository struct { + Logger *zerolog.Logger + Default string + VLC *vlc2.VLC + MpcHc *mpchc2.MpcHc + Mpv *mpv.Mpv + Iina *iina.Iina + wsEventManager events.WSEventManagerInterface + continuityManager *continuity.Manager + playerInUse string + completionThreshold float64 + mu sync.RWMutex + isRunning bool + currentPlaybackStatus *PlaybackStatus + subscribers *result.Map[string, *RepositorySubscriber] + cancel context.CancelFunc + exitedCh chan struct{} // Closed when the media player exits + } + + NewRepositoryOptions struct { + Logger *zerolog.Logger + Default string + VLC *vlc2.VLC + MpcHc *mpchc2.MpcHc + Mpv *mpv.Mpv + Iina *iina.Iina + WSEventManager events.WSEventManagerInterface + ContinuityManager *continuity.Manager + } + + // RepositorySubscriber provides a single event channel for all media player events + RepositorySubscriber struct { + EventCh chan MediaPlayerEvent + } + + // MediaPlayerEvent is the base interface for all media player events + MediaPlayerEvent interface { + Type() string + } + + // Local file playback events + TrackingStartedEvent struct { + Status *PlaybackStatus + } + + TrackingRetryEvent struct { + Reason string + } + + VideoCompletedEvent struct { + Status *PlaybackStatus + } + + TrackingStoppedEvent struct { + Reason string + } + + PlaybackStatusEvent struct { + Status *PlaybackStatus + } + + // Streaming playback events + StreamingTrackingStartedEvent struct { + Status *PlaybackStatus + } + + StreamingTrackingRetryEvent struct { + Reason string + } + + StreamingVideoCompletedEvent struct { + Status *PlaybackStatus + } + + StreamingTrackingStoppedEvent struct { + Reason string + } + + StreamingPlaybackStatusEvent struct { + Status *PlaybackStatus + } + + PlaybackStatus struct { + CompletionPercentage float64 `json:"completionPercentage"` + Playing bool `json:"playing"` + Filename string `json:"filename"` + Path string `json:"path"` + Duration int `json:"duration"` // in ms + Filepath string `json:"filepath"` + + CurrentTimeInSeconds float64 `json:"currentTimeInSeconds"` // in seconds + DurationInSeconds float64 `json:"durationInSeconds"` // in seconds + + PlaybackType PlaybackType `json:"playbackType"` // "file", "stream" + } +) + +func (e TrackingStartedEvent) Type() string { return "tracking_started" } +func (e TrackingRetryEvent) Type() string { return "tracking_retry" } +func (e VideoCompletedEvent) Type() string { return "video_completed" } +func (e TrackingStoppedEvent) Type() string { return "tracking_stopped" } +func (e PlaybackStatusEvent) Type() string { return "playback_status" } +func (e StreamingTrackingStartedEvent) Type() string { return "streaming_tracking_started" } +func (e StreamingTrackingRetryEvent) Type() string { return "streaming_tracking_retry" } +func (e StreamingVideoCompletedEvent) Type() string { return "streaming_video_completed" } +func (e StreamingTrackingStoppedEvent) Type() string { return "streaming_tracking_stopped" } +func (e StreamingPlaybackStatusEvent) Type() string { return "streaming_playback_status" } + +func NewRepository(opts *NewRepositoryOptions) *Repository { + + return &Repository{ + Logger: opts.Logger, + Default: opts.Default, + VLC: opts.VLC, + MpcHc: opts.MpcHc, + Mpv: opts.Mpv, + Iina: opts.Iina, + wsEventManager: opts.WSEventManager, + continuityManager: opts.ContinuityManager, + completionThreshold: 0.8, + subscribers: result.NewResultMap[string, *RepositorySubscriber](), + currentPlaybackStatus: &PlaybackStatus{}, + exitedCh: make(chan struct{}), + } +} + +func (m *Repository) Subscribe(id string) *RepositorySubscriber { + sub := &RepositorySubscriber{ + EventCh: make(chan MediaPlayerEvent, 10), // Buffered channel to avoid blocking + } + m.subscribers.Set(id, sub) + return sub +} + +func (m *Repository) Unsubscribe(id string) { + m.subscribers.Delete(id) +} + +func (m *Repository) GetStatus() *PlaybackStatus { + m.mu.Lock() + defer m.mu.Unlock() + return m.currentPlaybackStatus +} + +// PullStatus returns the current playback status directly from the media player. +func (m *Repository) PullStatus() (*PlaybackStatus, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + status, err := m.getStatus() + if err != nil { + return nil, false + } + + var ok bool + if m.currentPlaybackStatus == nil { + return nil, false + } + + if m.currentPlaybackStatus.PlaybackType == PlaybackTypeFile { + ok = m.processStatus(m.Default, status) + } else { + ok = m.processStreamStatus(m.Default, status) + } + return m.currentPlaybackStatus, ok +} + +func (m *Repository) IsRunning() bool { + return m.isRunning +} + +func (m *Repository) GetExecutablePath() string { + switch m.Default { + case "vlc": + return m.VLC.GetExecutablePath() + case "mpc-hc": + return m.MpcHc.GetExecutablePath() + case "mpv": + return m.Mpv.GetExecutablePath() + case "iina": + return m.Iina.GetExecutablePath() + } + return "" +} + +func (m *Repository) GetDefault() string { + return m.Default +} + +// Play will start the media player and load the video at the given path. +// The implementation of the specific media player is handled by the respective media player package. +// Calling it multiple *should* not open multiple instances of the media player -- subsequent calls should just load a new video if the media player is already open. +func (m *Repository) Play(path string) error { + + m.Logger.Debug().Str("path", path).Msg("media player: Media requested") + + lastWatched := m.continuityManager.GetExternalPlayerEpisodeWatchHistoryItem(path, false, 0, 0) + + switch m.Default { + case "vlc": + err := m.VLC.Start() + if err != nil { + m.Logger.Error().Err(err).Msg("media player: Could not start media player using VLC") + return fmt.Errorf("could not start VLC, %w", err) + } + + err = m.VLC.AddAndPlay(path) + if err != nil { + m.Logger.Error().Err(err).Msg("media player: Could not open and play video using VLC") + if m.VLC.Path != "" { + return fmt.Errorf("could not open and play video, %w", err) + } else { + return fmt.Errorf("could not open and play video, %w", err) + } + } + + if m.continuityManager.GetSettings().WatchContinuityEnabled { + if lastWatched.Found { + time.Sleep(400 * time.Millisecond) + _ = m.VLC.ForcePause() + time.Sleep(400 * time.Millisecond) + _ = m.VLC.Seek(fmt.Sprintf("%d", int(lastWatched.Item.CurrentTime))) + time.Sleep(400 * time.Millisecond) + _ = m.VLC.Resume() + } + } + + return nil + case "mpc-hc": + err := m.MpcHc.Start() + if err != nil { + m.Logger.Error().Err(err).Msg("media player: Could not start media player using MPC-HC") + return fmt.Errorf("could not start MPC-HC, %w", err) + } + _, err = m.MpcHc.OpenAndPlay(path) + if err != nil { + m.Logger.Error().Err(err).Msg("media player: Could not open and play video using MPC-HC") + return fmt.Errorf("could not open and play video, %w", err) + } + + if m.continuityManager.GetSettings().WatchContinuityEnabled { + if lastWatched.Found { + time.Sleep(400 * time.Millisecond) + _ = m.MpcHc.Pause() + time.Sleep(400 * time.Millisecond) + _ = m.MpcHc.Seek(int(lastWatched.Item.CurrentTime)) + time.Sleep(400 * time.Millisecond) + _ = m.MpcHc.Play() + } + } + + return nil + case "mpv": + if m.continuityManager.GetSettings().WatchContinuityEnabled { + var args []string + if lastWatched.Found { + //args = append(args, "--no-resume-playback", fmt.Sprintf("--start=+%d", int(lastWatched.Item.CurrentTime))) + args = append(args, "--no-resume-playback") + } + err := m.Mpv.OpenAndPlay(path, args...) + if err != nil { + m.Logger.Error().Err(err).Msg("media player: Could not open and play video using MPV") + return fmt.Errorf("could not open and play video, %w", err) + } + if lastWatched.Found { + _ = m.Mpv.SeekTo(lastWatched.Item.CurrentTime) + } + } else { + err := m.Mpv.OpenAndPlay(path) + if err != nil { + m.Logger.Error().Err(err).Msg("media player: Could not open and play video using MPV") + return fmt.Errorf("could not open and play video, %w", err) + } + } + + return nil + case "iina": + if m.continuityManager.GetSettings().WatchContinuityEnabled { + var args []string + if lastWatched.Found { + //args = append(args, "--mpv-no-resume-playback", fmt.Sprintf("--mpv-start=+%d", int(lastWatched.Item.CurrentTime))) + args = append(args, "--mpv-no-resume-playback") + } + err := m.Iina.OpenAndPlay(path, args...) + if err != nil { + m.Logger.Error().Err(err).Msg("media player: Could not open and play video using IINA") + return fmt.Errorf("could not open and play video, %w", err) + } + if lastWatched.Found { + _ = m.Iina.SeekTo(lastWatched.Item.CurrentTime) + } + } else { + err := m.Iina.OpenAndPlay(path) + if err != nil { + m.Logger.Error().Err(err).Msg("media player: Could not open and play video using IINA") + return fmt.Errorf("could not open and play video, %w", err) + } + } + + return nil + default: + return errors.New("no default media player set") + } + +} + +func (m *Repository) Pause() error { + switch m.Default { + case "vlc": + return m.VLC.Pause() + case "mpc-hc": + return m.MpcHc.Pause() + case "mpv": + return m.Mpv.Pause() + case "iina": + return m.Iina.Pause() + default: + return errors.New("no default media player set") + } +} + +func (m *Repository) Resume() error { + switch m.Default { + case "vlc": + return m.VLC.Resume() + case "mpc-hc": + return m.MpcHc.Play() + case "mpv": + return m.Mpv.Resume() + case "iina": + return m.Iina.Resume() + default: + return errors.New("no default media player set") + } +} + +func (m *Repository) Seek(seconds float64) error { + switch m.Default { + case "vlc": + return m.VLC.Seek(fmt.Sprintf("%d", int(seconds))) + case "mpc-hc": + return m.MpcHc.Seek(int(seconds)) + case "mpv": + return m.Mpv.Seek(seconds) + case "iina": + return m.Iina.Seek(seconds) + default: + return errors.New("no default media player set") + } +} + +func (m *Repository) Stream(streamUrl string, episode int, mediaId int, windowTitle string) error { + + m.Logger.Debug().Str("streamUrl", streamUrl).Msg("media player: Stream requested") + var err error + + switch m.Default { + case "vlc": + err = m.VLC.Start() + case "mpc-hc": + err = m.MpcHc.Start() + _, err = m.MpcHc.OpenAndPlay(streamUrl) + case "mpv": + // MPV does not need to be started + case "iina": + // IINA does not need to be started + default: + return errors.New("no default media player set") + } + + if err != nil { + m.Logger.Error().Err(err).Msg("media player: Could not start media player for stream") + return fmt.Errorf("could not open media player, %w", err) + } + + lastWatched := m.continuityManager.GetExternalPlayerEpisodeWatchHistoryItem("", true, episode, mediaId) + + switch m.Default { + case "vlc": + err = m.VLC.AddAndPlay(streamUrl) + + if m.continuityManager.GetSettings().WatchContinuityEnabled { + if lastWatched.Found { + time.Sleep(400 * time.Millisecond) + _ = m.VLC.ForcePause() + time.Sleep(400 * time.Millisecond) + _ = m.VLC.Seek(fmt.Sprintf("%d", int(lastWatched.Item.CurrentTime))) + time.Sleep(400 * time.Millisecond) + _ = m.VLC.Resume() + } + } + + case "mpc-hc": + _, err = m.MpcHc.OpenAndPlay(streamUrl) + + if m.continuityManager.GetSettings().WatchContinuityEnabled { + if lastWatched.Found { + time.Sleep(400 * time.Millisecond) + _ = m.MpcHc.Pause() + time.Sleep(400 * time.Millisecond) + _ = m.MpcHc.Seek(int(lastWatched.Item.CurrentTime)) + time.Sleep(400 * time.Millisecond) + _ = m.MpcHc.Play() + } + } + + case "mpv": + args := []string{} + if windowTitle != "" { + args = append(args, fmt.Sprintf("--title=%q", windowTitle)) + } + if m.continuityManager.GetSettings().WatchContinuityEnabled { + err = m.Mpv.OpenAndPlay(streamUrl, args...) + if lastWatched.Found { + _ = m.Mpv.SeekTo(lastWatched.Item.CurrentTime) + } + } else { + err = m.Mpv.OpenAndPlay(streamUrl, args...) + } + + case "iina": + args := []string{} + if windowTitle != "" { + args = append(args, fmt.Sprintf("--mpv-title=%q", windowTitle)) + } + if m.continuityManager.GetSettings().WatchContinuityEnabled { + err = m.Iina.OpenAndPlay(streamUrl, args...) + if lastWatched.Found { + _ = m.Iina.SeekTo(lastWatched.Item.CurrentTime) + } + } else { + err = m.Iina.OpenAndPlay(streamUrl, args...) + } + + } + + if err != nil { + m.Logger.Error().Err(err).Msg("media player: Could not open and play stream") + return fmt.Errorf("could not open and play stream, %w", err) + } + + return nil +} + +// Cancel will stop the tracking process and publish an "abnormal" event +func (m *Repository) Cancel() { + m.mu.Lock() + if m.cancel != nil { + m.Logger.Debug().Msg("media player: Cancel request received") + m.cancel() + m.trackingStopped("Something went wrong, tracking cancelled") + } else { + m.Logger.Debug().Msg("media player: Cancel request received, but no context found") + } + // Close MPV if it's the default player + if m.Default == "mpv" { + m.Mpv.CloseAll() + } + m.mu.Unlock() +} + +// Stop will stop the tracking process and publish a "normal" event +func (m *Repository) Stop() { + m.mu.Lock() + if m.cancel != nil { + m.Logger.Debug().Msg("media player: Stop request received") + m.cancel() + m.cancel = nil + m.trackingStopped("Tracking stopped") + // Close MPV if it's the default player + if m.Default == "mpv" { + go m.Mpv.CloseAll() + } + } + m.mu.Unlock() +} + +// StartTrackingTorrentStream will start tracking media player status for torrent streaming +func (m *Repository) StartTrackingTorrentStream() { + m.mu.Lock() + // If a previous context exists, cancel it + if m.cancel != nil { + m.Logger.Debug().Msg("media player: Cancelling previous context") + m.cancel() + } + + // Create a new context + var trackingCtx context.Context + trackingCtx, m.cancel = context.WithCancel(context.Background()) + + done := make(chan struct{}) + var filename string + var completed bool + var retries int + + hookEvent := &MediaPlayerStreamTrackingRequestedEvent{ + StartRefreshDelay: 3, + RefreshDelay: 1, + MaxRetries: 5, + MaxRetriesAfterStart: 5, + } + _ = hook.GlobalHookManager.OnMediaPlayerStreamTrackingRequested().Trigger(hookEvent) + startRefreshDelay := hookEvent.StartRefreshDelay + maxTries := hookEvent.MaxRetries + refreshDelay := hookEvent.RefreshDelay + maxRetriesAfterStart := hookEvent.MaxRetriesAfterStart + + // Default prevented, do not track + if hookEvent.DefaultPrevented { + m.Logger.Debug().Msg("media player: Tracking cancelled by hook") + return + } + + // Unlike normal tracking when the file is downloaded, we may need to wait a bit before we can get the status, + // so we won't count retries until it's confirmed that the file has started playing. + var trackingStarted bool + var waitInSeconds int + + m.isRunning = true + gotFirstStatus := false + + m.mu.Unlock() + + go func() { + defer func() { + m.mu.Lock() + m.isRunning = false + if m.cancel != nil { + m.cancel() + } + m.mu.Unlock() + }() + for { + select { + case <-done: + m.mu.Lock() + m.Logger.Debug().Msg("media player: Connection lost") + m.isRunning = false + m.mu.Unlock() + return + case <-trackingCtx.Done(): + m.mu.Lock() + m.Logger.Debug().Msg("media player: Context cancelled") + m.isRunning = false + m.mu.Unlock() + return + //case <-m.exitedCh: + // m.mu.Lock() + // m.Logger.Debug().Msg("media player: Player exited") + // m.isRunning = false + // m.streamingTrackingStopped(PlayerClosedEvent) + // m.mu.Unlock() + // return + default: + // Wait at least 3 seconds before we start checking the status + if !gotFirstStatus { + time.Sleep(time.Duration(startRefreshDelay) * time.Second) + } else { + time.Sleep(time.Duration(refreshDelay) * time.Second) + } + status, err := m.getStatus() + if err != nil { + if !trackingStarted { + if waitInSeconds > 60 { + m.Logger.Warn().Msg("media player: Ending goroutine, waited too long") + return + } + m.Logger.Trace().Msgf("media player: Waiting for stream, %d seconds", waitInSeconds) + waitInSeconds += refreshDelay + continue + } else { + m.streamingTrackingRetry("Failed to get player status") + m.Logger.Error().Msgf("media player: Failed to get player status, retrying (%d/%d)", retries+1, maxTries) + + // Video is completed, and we are unable to get the status + // We can safely assume that the player has been closed + if retries == 1 && (completed || m.continuityManager.GetSettings().WatchContinuityEnabled) { + m.Logger.Debug().Msg("media player: Sending player closed event") + m.streamingTrackingStopped(PlayerClosedEvent) + close(done) + break + } + + if retries >= maxTries-1 { + m.Logger.Debug().Msg("media player: Sending failed status query event") + m.streamingTrackingStopped("Failed to get player status") + close(done) + break + } + retries++ + continue + } + } + + trackingStarted = true + ok := m.processStreamStatus(m.Default, status) + + if !ok { + m.streamingTrackingRetry("Failed to get player status") + m.Logger.Error().Interface("status", status).Msgf("media player: Failed to process status, retrying (%d/%d)", retries+1, maxRetriesAfterStart) + if retries >= maxRetriesAfterStart-1 { + m.Logger.Debug().Msg("media player: Sending failed status query event") + m.streamingTrackingStopped("Failed to process status") + close(done) + break + } + retries++ + continue + } + + // New video has started playing \/ + if filename == "" || filename != m.currentPlaybackStatus.Filename { + m.Logger.Debug().Str("previousFilename", filename).Str("newFilename", m.currentPlaybackStatus.Filename).Msg("media player: Video loaded") + m.streamingTrackingStarted(m.currentPlaybackStatus) + filename = m.currentPlaybackStatus.Filename + completed = false + } + + // Video completed \/ + if m.currentPlaybackStatus.CompletionPercentage > m.completionThreshold && !completed { + m.Logger.Debug().Msg("media player: Video completed") + m.streamingVideoCompleted(m.currentPlaybackStatus) + completed = true + } + + m.streamingPlaybackStatus(m.currentPlaybackStatus) + } + } + }() +} + +// StartTracking will start tracking media player status. +// This method is safe to call multiple times -- it will cancel the previous context and start a new one. +func (m *Repository) StartTracking() { + m.mu.Lock() + // If a previous context exists, cancel it + if m.cancel != nil { + m.Logger.Debug().Msg("media player: Cancelling previous context") + m.cancel() + } + + // Create a new context + var trackingCtx context.Context + trackingCtx, m.cancel = context.WithCancel(context.Background()) + + done := make(chan struct{}) + var filename string + var completed bool + var retries int + + hookEvent := &MediaPlayerLocalFileTrackingRequestedEvent{ + StartRefreshDelay: 3, + RefreshDelay: 1, + MaxRetries: 5, + } + _ = hook.GlobalHookManager.OnMediaPlayerLocalFileTrackingRequested().Trigger(hookEvent) + startRefreshDelay := hookEvent.StartRefreshDelay + maxTries := hookEvent.MaxRetries + refreshDelay := hookEvent.RefreshDelay + + // Default prevented, do not track + if hookEvent.DefaultPrevented { + m.Logger.Debug().Msg("media player: Tracking cancelled by hook") + return + } + + m.isRunning = true + gotFirstStatus := false + + m.mu.Unlock() + + go func() { + for { + select { + case <-done: + m.mu.Lock() + m.Logger.Debug().Msg("media player: Connection lost") + m.isRunning = false + m.mu.Unlock() + if m.cancel != nil { + m.cancel() + m.cancel = nil + } + return + case <-trackingCtx.Done(): + m.mu.Lock() + m.Logger.Debug().Msg("media player: Context cancelled") + m.isRunning = false + m.cancel = nil + m.mu.Unlock() + return + //case <-m.exitedCh: + // m.mu.Lock() + // m.Logger.Debug().Msg("media player: Player exited") + // m.isRunning = false + // m.trackingStopped(PlayerClosedEvent) + // m.mu.Unlock() + // return + default: + // Wait at least X seconds before we start checking the status + if !gotFirstStatus { + time.Sleep(time.Duration(startRefreshDelay) * time.Second) + } else { + time.Sleep(time.Duration(refreshDelay) * time.Second) + } + status, err := m.getStatus() + if err != nil { + m.trackingRetry("Failed to get player status") + m.Logger.Error().Msgf("media player: Failed to get player status, retrying (%d/%d)", retries+1, maxTries) + + // Video is completed, and we are unable to get the status + // We can safely assume that the player has been closed + if retries == 1 && (completed || m.continuityManager.GetSettings().WatchContinuityEnabled) { + m.trackingStopped(PlayerClosedEvent) + close(done) + break + } + + if retries >= maxTries-1 { + m.trackingStopped("Failed to get player status") + close(done) + break + } + retries++ + continue + } + + gotFirstStatus = true + + ok := m.processStatus(m.Default, status) + + if !ok { + m.trackingRetry("Failed to get player status") + m.Logger.Error().Interface("status", status).Msgf("media player: Failed to process status, retrying (%d/%d)", retries+1, maxTries) + if retries >= maxTries-1 { + m.trackingStopped("Failed to process status") + close(done) + break + } + retries++ + continue + } + + // New video has started playing \/ + if filename == "" || filename != m.currentPlaybackStatus.Filename { + m.Logger.Debug().Str("previousFilename", filename).Str("newFilename", m.currentPlaybackStatus.Filename).Msg("media player: Video started playing") + m.Logger.Debug().Interface("currentPlaybackStatus", m.currentPlaybackStatus).Msg("media player: Playback status") + m.trackingStarted(m.currentPlaybackStatus) + filename = m.currentPlaybackStatus.Filename + completed = false + } + + // Video completed \/ + if m.currentPlaybackStatus.CompletionPercentage > m.completionThreshold && !completed { + m.Logger.Debug().Msg("media player: Video completed") + m.Logger.Debug().Interface("currentPlaybackStatus", m.currentPlaybackStatus).Msg("media player: Playback status") + m.videoCompleted(m.currentPlaybackStatus) + completed = true + } + + m.playbackStatus(m.currentPlaybackStatus) + } + } + }() +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (m *Repository) trackingStopped(reason string) { + m.subscribers.Range(func(key string, value *RepositorySubscriber) bool { + value.EventCh <- TrackingStoppedEvent{Reason: reason} + return true + }) +} + +func (m *Repository) trackingStarted(status *PlaybackStatus) { + m.subscribers.Range(func(key string, value *RepositorySubscriber) bool { + value.EventCh <- TrackingStartedEvent{Status: status} + return true + }) +} + +func (m *Repository) trackingRetry(reason string) { + m.subscribers.Range(func(key string, value *RepositorySubscriber) bool { + value.EventCh <- TrackingRetryEvent{Reason: reason} + return true + }) +} + +func (m *Repository) videoCompleted(status *PlaybackStatus) { + m.subscribers.Range(func(key string, value *RepositorySubscriber) bool { + value.EventCh <- VideoCompletedEvent{Status: status} + return true + }) +} + +func (m *Repository) playbackStatus(status *PlaybackStatus) { + m.subscribers.Range(func(key string, value *RepositorySubscriber) bool { + value.EventCh <- PlaybackStatusEvent{Status: status} + return true + }) +} + +func (m *Repository) streamingTrackingStopped(reason string) { + m.subscribers.Range(func(key string, value *RepositorySubscriber) bool { + value.EventCh <- StreamingTrackingStoppedEvent{Reason: reason} + return true + }) +} + +func (m *Repository) streamingTrackingStarted(status *PlaybackStatus) { + m.subscribers.Range(func(key string, value *RepositorySubscriber) bool { + value.EventCh <- StreamingTrackingStartedEvent{Status: status} + return true + }) +} + +func (m *Repository) streamingTrackingRetry(reason string) { + m.subscribers.Range(func(key string, value *RepositorySubscriber) bool { + value.EventCh <- StreamingTrackingRetryEvent{Reason: reason} + return true + }) +} + +func (m *Repository) streamingVideoCompleted(status *PlaybackStatus) { + m.subscribers.Range(func(key string, value *RepositorySubscriber) bool { + value.EventCh <- StreamingVideoCompletedEvent{Status: status} + return true + }) +} + +func (m *Repository) streamingPlaybackStatus(status *PlaybackStatus) { + m.subscribers.Range(func(key string, value *RepositorySubscriber) bool { + value.EventCh <- StreamingPlaybackStatusEvent{Status: status} + return true + }) +} + +func (m *Repository) getStatus() (interface{}, error) { + switch m.Default { + case "vlc": + return m.VLC.GetStatus() + case "mpc-hc": + return m.MpcHc.GetVariables() + case "mpv": + return m.Mpv.GetPlaybackStatus() + case "iina": + return m.Iina.GetPlaybackStatus() + } + return nil, errors.New("unsupported media player") +} + +func (m *Repository) processStatus(player string, status interface{}) bool { + m.currentPlaybackStatus.PlaybackType = PlaybackTypeFile + switch player { + case "vlc": + // Process VLC status + st, ok := status.(*vlc2.Status) + if !ok || st == nil { + return false + } + + m.currentPlaybackStatus.CompletionPercentage = st.Position + m.currentPlaybackStatus.Playing = st.State == "playing" + m.currentPlaybackStatus.Filename = st.Information.Category["meta"].Filename + m.currentPlaybackStatus.Duration = int(st.Length * 1000) + m.currentPlaybackStatus.Filepath = st.Information.Category["meta"].Filename + + m.currentPlaybackStatus.CurrentTimeInSeconds = float64(st.Time) + m.currentPlaybackStatus.DurationInSeconds = float64(st.Length) + return true + case "mpc-hc": + // Process MPC-HC status + st, ok := status.(*mpchc2.Variables) + if !ok || st == nil || st.Duration == 0 { + return false + } + + m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration + m.currentPlaybackStatus.Playing = st.State == 2 + m.currentPlaybackStatus.Filename = st.File + m.currentPlaybackStatus.Duration = int(st.Duration) + m.currentPlaybackStatus.Filepath = st.FilePath + + m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position / 1000 + m.currentPlaybackStatus.DurationInSeconds = st.Duration / 1000 + + return true + case "mpv": + // Process MPV status + st, ok := status.(*mpv.Playback) + if !ok || st == nil || st.Duration == 0 || st.IsRunning == false { + return false + } + + m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration + m.currentPlaybackStatus.Playing = !st.Paused + m.currentPlaybackStatus.Filename = st.Filename + m.currentPlaybackStatus.Duration = int(st.Duration) + m.currentPlaybackStatus.Filepath = st.Filepath + + m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position + m.currentPlaybackStatus.DurationInSeconds = st.Duration + + return true + case "iina": + // Process IINA status + st, ok := status.(*iina.Playback) + if !ok || st == nil || st.Duration == 0 || st.IsRunning == false { + return false + } + + m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration + m.currentPlaybackStatus.Playing = !st.Paused + m.currentPlaybackStatus.Filename = st.Filename + m.currentPlaybackStatus.Duration = int(st.Duration) + m.currentPlaybackStatus.Filepath = st.Filepath + + m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position + m.currentPlaybackStatus.DurationInSeconds = st.Duration + + return true + default: + return false + } +} + +func (m *Repository) processStreamStatus(player string, status interface{}) bool { + m.currentPlaybackStatus.PlaybackType = PlaybackTypeStream + switch player { + case "vlc": + // Process VLC status + st, ok := status.(*vlc2.Status) + if !ok || st == nil { + return false + } + + m.currentPlaybackStatus.CompletionPercentage = st.Position + m.currentPlaybackStatus.Playing = st.State == "playing" + m.currentPlaybackStatus.Filename = st.Information.Category["meta"].Filename + m.currentPlaybackStatus.Duration = int(st.Length * 1000) + m.currentPlaybackStatus.Filepath = st.Information.Category["meta"].Filename // VLC does not provide the filepath, use filename + + m.currentPlaybackStatus.CurrentTimeInSeconds = float64(st.Time) + m.currentPlaybackStatus.DurationInSeconds = float64(st.Length) + + return true + case "mpc-hc": + // Process MPC-HC status + st, ok := status.(*mpchc2.Variables) + if !ok || st == nil { + return false + } + + m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration + m.currentPlaybackStatus.Playing = st.State == 2 + m.currentPlaybackStatus.Filename = st.File + m.currentPlaybackStatus.Duration = int(st.Duration) + m.currentPlaybackStatus.Filepath = st.FilePath + + m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position / 1000 + m.currentPlaybackStatus.DurationInSeconds = st.Duration / 1000 + + return true + case "mpv": + // Process MPV status + st, ok := status.(*mpv.Playback) + if !ok || st == nil || st.Duration == 0 || st.IsRunning == false { + return false + } + + m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration + m.currentPlaybackStatus.Playing = !st.Paused + m.currentPlaybackStatus.Filename = st.Filename + m.currentPlaybackStatus.Duration = int(st.Duration) + m.currentPlaybackStatus.Filepath = st.Filepath + + m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position + m.currentPlaybackStatus.DurationInSeconds = st.Duration + + return true + case "iina": + // Process IINA status + st, ok := status.(*iina.Playback) + if !ok || st == nil || st.Duration == 0 || st.IsRunning == false { + return false + } + + m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration + m.currentPlaybackStatus.Playing = !st.Paused + m.currentPlaybackStatus.Filename = st.Filename + m.currentPlaybackStatus.Duration = int(st.Duration) + m.currentPlaybackStatus.Filepath = st.Filepath + + m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position + m.currentPlaybackStatus.DurationInSeconds = st.Duration + + return true + default: + return false + } +} diff --git a/seanime-2.9.10/internal/mediaplayers/mediaplayer/repository_test.go b/seanime-2.9.10/internal/mediaplayers/mediaplayer/repository_test.go new file mode 100644 index 0000000..217c725 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mediaplayer/repository_test.go @@ -0,0 +1,24 @@ +package mediaplayer + +import ( + "github.com/stretchr/testify/assert" + "seanime/internal/test_utils" + "testing" + "time" +) + +func TestRepository_StartTracking(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + repo := NewTestRepository(t, "mpv") + + err := repo.Play("E:\\ANIME\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 01 (1080p) [F02B9CEE].mkv") + assert.NoError(t, err) + + repo.StartTracking() + + go func() { + time.Sleep(5 * time.Second) + repo.Stop() + }() +} diff --git a/seanime-2.9.10/internal/mediaplayers/mediaplayer/test_helper.go b/seanime-2.9.10/internal/mediaplayers/mediaplayer/test_helper.go new file mode 100644 index 0000000..01d10b4 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mediaplayer/test_helper.go @@ -0,0 +1,47 @@ +package mediaplayer + +import ( + "seanime/internal/events" + "seanime/internal/mediaplayers/mpchc" + "seanime/internal/mediaplayers/mpv" + "seanime/internal/mediaplayers/vlc" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" +) + +func NewTestRepository(t *testing.T, defaultPlayer string) *Repository { + if defaultPlayer == "" { + defaultPlayer = "mpv" + } + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + logger := util.NewLogger() + WSEventManager := events.NewMockWSEventManager(logger) + + _vlc := &vlc.VLC{ + Host: test_utils.ConfigData.Provider.VlcHost, + Port: test_utils.ConfigData.Provider.VlcPort, + Password: test_utils.ConfigData.Provider.VlcPassword, + Logger: logger, + } + + _mpc := &mpchc.MpcHc{ + Host: test_utils.ConfigData.Provider.MpcHost, + Port: test_utils.ConfigData.Provider.MpcPort, + Logger: logger, + } + + _mpv := mpv.New(logger, "", "") + + repo := NewRepository(&NewRepositoryOptions{ + Logger: logger, + Default: defaultPlayer, + WSEventManager: WSEventManager, + Mpv: _mpv, + VLC: _vlc, + MpcHc: _mpc, + }) + + return repo +} diff --git a/seanime-2.9.10/internal/mediaplayers/mpchc/commands.go b/seanime-2.9.10/internal/mediaplayers/mpchc/commands.go new file mode 100644 index 0000000..2b2c2b4 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpchc/commands.go @@ -0,0 +1,188 @@ +package mpchc + +const ( + setVolumeCmd = -2 + seekCmd = -1 + quickOpenFileCmd = 969 + openFileCmd = 800 + openDVDBDCmd = 801 + openDeviceCmd = 802 + reopenFileCmd = 976 + moveToRecycleBinCmd = 24044 + saveACopyCmd = 805 + saveImageCmd = 806 + saveImageAutoCmd = 807 + saveThumbnailsCmd = 808 + loadSubtitleCmd = 809 + saveSubtitleCmd = 810 + closeCmd = 804 + propertiesCmd = 814 + exitCmd = 816 + playPauseCmd = 889 + playCmd = 887 + pauseCmd = 888 + stopCmd = 890 + framestepCmd = 891 + framestepBackCmd = 892 + goToCmd = 893 + increaseRateCmd = 895 + decreaseRateCmd = 894 + resetRateCmd = 896 + audioDelayPlus10MsCmd = 905 + audioDelayMinus10MsCmd = 906 + jumpForwardSmallCmd = 900 + jumpBackwardSmallCmd = 899 + jumpForwardMediumCmd = 902 + jumpBackwardMediumCmd = 901 + jumpForwardLargeCmd = 904 + jumpBackwardLargeCmd = 903 + jumpForwardKeyframeCmd = 898 + jumpBackwardKeyframeCmd = 897 + jumpToBeginningCmd = 996 + nextCmd = 922 + previousCmd = 921 + nextFileCmd = 920 + previousFileCmd = 919 + tunerScanCmd = 974 + quickAddFavoriteCmd = 975 + toggleCaptionAndMenuCmd = 817 + toggleSeekerCmd = 818 + toggleControlsCmd = 819 + toggleInformationCmd = 820 + toggleStatisticsCmd = 821 + toggleStatusCmd = 822 + toggleSubresyncBarCmd = 823 + togglePlaylistBarCmd = 824 + toggleCaptureBarCmd = 825 + toggleNavigationBarCmd = 33415 + toggleDebugShadersCmd = 826 + viewMinimalCmd = 827 + viewCompactCmd = 828 + viewNormalCmd = 829 + fullscreenCmd = 830 + fullscreenWithoutResChangeCmd = 831 + zoom50Cmd = 832 + zoom100Cmd = 833 + zoom200Cmd = 834 + zoomAutoFitCmd = 968 + zoomAutoFitLargerOnlyCmd = 4900 + nextARPreseCmd = 859 + vidFrmHalfCmd = 835 + vidFrmNormalCmd = 836 + vidFrmDoubleCmd = 837 + vidFrmStretchCmd = 838 + vidFrmInsideCmd = 839 + vidFrmZoom1Cmd = 841 + vidFrmZoom2Cmd = 842 + vidFrmOutsideCmd = 840 + vidFrmSwitchZoomCmd = 843 + alwaysOnTopCmd = 884 + pnsResetCmd = 861 + pnsIncSizeCmd = 862 + pnsIncWidthCmd = 864 + pnsIncHeightCmd = 866 + pnsDecSizeCmd = 863 + pnsDecWidthCmd = 865 + pnsDecHeightCmd = 867 + pnsCenterCmd = 876 + pnsLeftCmd = 868 + pnsRightCmd = 869 + pnsUpCmd = 870 + pnsDownCmd = 871 + pnsUpLeftCmd = 872 + pnsUpRightCmd = 873 + pnsDownLeftCmd = 874 + pnsDownRightCmd = 875 + pnsRotateXPlusCmd = 877 + pnsRotateXMinusCmd = 878 + pnsRotateYPlusCmd = 879 + pnsRotateYMinusCmd = 880 + pnsRotateZPlusCmd = 881 + pnsRotateZMinusCmd = 882 + volumeUpCmd = 907 + volumeDownCmd = 908 + volumeMuteCmd = 909 + volumeBoostIncreaseCmd = 970 + volumeBoostDecreaseCmd = 971 + volumeBoostMinCmd = 972 + volumeBoostMaxCmd = 973 + toggleCustomChannelMappingCmd = 993 + toggleNormalizationCmd = 994 + toggleRegainVolumeCmd = 995 + brightnessIncreaseCmd = 984 + brightnessDecreaseCmd = 985 + contrastIncreaseCmd = 986 + contrastDecreaseCmd = 987 + hueIncreaseCmd = 988 + hueDecreaseCmd = 989 + saturationIncreaseCmd = 990 + saturationDecreaseCmd = 991 + resetColorSettingsCmd = 992 + dvdTitleMenuCmd = 923 + dvdRootMenuCmd = 924 + dvdSubtitleMenuCmd = 925 + dvdAudioMenuCmd = 926 + dvdAngleMenuCmd = 927 + dvdChapterMenuCmd = 928 + dvdMenuLeftCmd = 929 + dvdMenuRightCmd = 930 + dvdMenuUpCmd = 931 + dvdMenuDownCmd = 932 + dvdMenuActivateCmd = 933 + dvdMenuBackCmd = 934 + dvdMenuLeaveCmd = 935 + bossKeyCmd = 944 + playerMenuShortCmd = 949 + playerMenuLongCmd = 950 + filtersMenuCmd = 951 + optionsCmd = 815 + nextAudioCmd = 952 + prevAudioCmd = 953 + nextSubtitleCmd = 954 + prevSubtitleCmd = 955 + onOffSubtitleCmd = 956 + reloadSubtitlesCmd = 2302 + downloadSubtitlesCmd = 812 + nextAudioOGMCmd = 957 + prevAudioOGMCmd = 958 + nextSubtitleOGMCmd = 959 + prevSubtitleOGMCmd = 960 + nextAngleDVDCmd = 961 + prevAngleDVDCmd = 962 + nextAudioDVDCmd = 963 + prevAudioDVDCmd = 964 + nextSubtitleDVDCmd = 965 + prevSubtitleDVDCmd = 966 + onOffSubtitleDVDCmd = 967 + tearingTestCmd = 32769 + remainingTimeCmd = 32778 + nextShaderPresetCmd = 57382 + prevShaderPresetCmd = 57384 + toggleDirect3DFullscreenCmd = 32779 + gotoPrevSubtitleCmd = 32780 + gotoNextSubtitleCmd = 32781 + shiftSubtitleLeftCmd = 32782 + shiftSubtitleRightCmd = 32783 + displayStatsCmd = 32784 + resetDisplayStatsCmd = 33405 + vsyncCmd = 33243 + enableFrameTimeCorrectionCmd = 33265 + accurateVsyncCmd = 33260 + decreaseVsyncOffsetCmd = 33246 + increaseVsyncOffsetCmd = 33247 + subtitleDelayMinusCmd = 24000 + subtitleDelayPlusCmd = 24001 + afterPlaybackExitCmd = 912 + afterPlaybackStandByCmd = 913 + afterPlaybackHibernateCmd = 914 + afterPlaybackShutdownCmd = 915 + afterPlaybackLogOffCmd = 916 + afterPlaybackLockCmd = 917 + afterPlaybackTurnOffTheMonitorCmd = 918 + afterPlaybackPlayNextFileInTheFolderCmd = 947 + toggleEDLWindowCmd = 846 + edlSetInCmd = 847 + edlSetOutCmd = 848 + edlNewClipCmd = 849 + edlSaveCmd = 860 +) diff --git a/seanime-2.9.10/internal/mediaplayers/mpchc/mpc_hc.go b/seanime-2.9.10/internal/mediaplayers/mpchc/mpc_hc.go new file mode 100644 index 0000000..20ab330 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpchc/mpc_hc.go @@ -0,0 +1,139 @@ +package mpchc + +import ( + "fmt" + "io" + "net/http" + neturl "net/url" + "path/filepath" + "strings" + + "github.com/rs/zerolog" +) + +type MpcHc struct { + Host string + Port int + Path string + Logger *zerolog.Logger +} + +func (api *MpcHc) url() string { + return fmt.Sprintf("http://%s:%d", api.Host, api.Port) +} + +// Execute sends a command to MPC and returns the response. +func (api *MpcHc) Execute(command int, data map[string]interface{}) (string, error) { + url := fmt.Sprintf("%s/command.html?wm_command=%d", api.url(), command) + + if data != nil { + queryParams := neturl.Values{} + for key, value := range data { + queryParams.Add(key, fmt.Sprintf("%v", value)) + } + url += "&" + queryParams.Encode() + } + + response, err := http.Get(url) + if err != nil { + api.Logger.Error().Err(err).Msg("mpc hc: Failed to execute command") + return "", err + } + defer response.Body.Close() + + // Check HTTP status code and errors + statusCode := response.StatusCode + if !((statusCode >= 200) && (statusCode <= 299)) { + err = fmt.Errorf("http error code: %d\n", statusCode) + return "", err + } + + // Get byte response and http status code + byteArr, readErr := io.ReadAll(response.Body) + if readErr != nil { + err = fmt.Errorf("error reading response: %s\n", readErr) + return "", err + } + + // Write response + res := string(byteArr) + + return res, nil +} + +func escapeInput(input string) string { + if strings.HasPrefix(input, "http") { + return neturl.QueryEscape(input) + } else { + input = filepath.FromSlash(input) + return strings.ReplaceAll(neturl.QueryEscape(input), "+", "%20") + } +} + +// OpenAndPlay opens a video file in MPC. +func (api *MpcHc) OpenAndPlay(filePath string) (string, error) { + url := fmt.Sprintf("%s/browser.html?path=%s", api.url(), escapeInput(filePath)) + api.Logger.Trace().Str("url", url).Msg("mpc hc: Opening and playing") + + response, err := http.Get(url) + if err != nil { + api.Logger.Error().Err(err).Msg("mpc hc: Failed to connect to MPC") + return "", err + } + defer response.Body.Close() + + // Check HTTP status code and errors + statusCode := response.StatusCode + if !((statusCode >= 200) && (statusCode <= 299)) { + err = fmt.Errorf("http error code: %d\n", statusCode) + api.Logger.Error().Err(err).Msg("mpc hc: Failed to open and play") + return "", err + } + + // Get byte response and http status code + byteArr, readErr := io.ReadAll(response.Body) + if readErr != nil { + err = fmt.Errorf("error reading response: %s\n", readErr) + api.Logger.Error().Err(err).Msg("mpc hc: Failed to open and play") + return "", err + } + + // Write response + res := string(byteArr) + + return res, nil +} + +// GetVariables retrieves player variables from MPC. +func (api *MpcHc) GetVariables() (*Variables, error) { + url := fmt.Sprintf("%s/variables.html", api.url()) + + response, err := http.Get(url) + if err != nil { + api.Logger.Error().Err(err).Msg("mpc hc: Failed to get variables") + return &Variables{}, err + } + defer response.Body.Close() + + // Check HTTP status code and errors + statusCode := response.StatusCode + if !((statusCode >= 200) && (statusCode <= 299)) { + err = fmt.Errorf("http error code: %d\n", statusCode) + api.Logger.Error().Err(err).Msg("mpc hc: Failed to get variables") + return &Variables{}, err + } + + // Get byte response and http status code + byteArr, readErr := io.ReadAll(response.Body) + if readErr != nil { + err = fmt.Errorf("error reading response: %s\n", readErr) + api.Logger.Error().Err(err).Msg("mpc hc: Failed to get variables") + return &Variables{}, err + } + + // Write response + res := string(byteArr) + vars := parseVariables(res) + + return vars, nil +} diff --git a/seanime-2.9.10/internal/mediaplayers/mpchc/mpc_hc_test.go b/seanime-2.9.10/internal/mediaplayers/mpchc/mpc_hc_test.go new file mode 100644 index 0000000..a17fa08 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpchc/mpc_hc_test.go @@ -0,0 +1,101 @@ +package mpchc + +import ( + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + "time" +) + +func TestMpcHc_Start(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + mpc := &MpcHc{ + Host: test_utils.ConfigData.Provider.MpcHost, + Path: test_utils.ConfigData.Provider.MpcPath, + Port: test_utils.ConfigData.Provider.MpcPort, + Logger: util.NewLogger(), + } + + err := mpc.Start() + assert.NoError(t, err) + +} + +func TestMpcHc_Play(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + mpc := &MpcHc{ + Host: test_utils.ConfigData.Provider.MpcHost, + Path: test_utils.ConfigData.Provider.MpcPath, + Port: test_utils.ConfigData.Provider.MpcPort, + Logger: util.NewLogger(), + } + + err := mpc.Start() + assert.NoError(t, err) + + res, err := mpc.OpenAndPlay("E:\\ANIME\\Violet.Evergarden.The.Movie.1080p.Dual.Audio.BDRip.10.bits.DD.x265-EMBER.mkv") + assert.NoError(t, err) + + t.Log(res) + +} + +func TestMpcHc_GetVariables(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + mpc := &MpcHc{ + Host: test_utils.ConfigData.Provider.MpcHost, + Path: test_utils.ConfigData.Provider.MpcPath, + Port: test_utils.ConfigData.Provider.MpcPort, + Logger: util.NewLogger(), + } + + err := mpc.Start() + assert.NoError(t, err) + + res, err := mpc.GetVariables() + if err != nil { + t.Fatal(err.Error()) + } + + spew.Dump(res) + +} + +func TestMpcHc_Seek(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + mpc := &MpcHc{ + Host: test_utils.ConfigData.Provider.MpcHost, + Path: test_utils.ConfigData.Provider.MpcPath, + Port: test_utils.ConfigData.Provider.MpcPort, + Logger: util.NewLogger(), + } + + err := mpc.Start() + assert.NoError(t, err) + + _, err = mpc.OpenAndPlay("E:\\ANIME\\[SubsPlease] Bocchi the Rock! (01-12) (1080p) [Batch]\\[SubsPlease] Bocchi the Rock! - 01v2 (1080p) [ABDDAE16].mkv") + assert.NoError(t, err) + + err = mpc.Pause() + + time.Sleep(400 * time.Millisecond) + + err = mpc.Seek(100000) + assert.NoError(t, err) + + time.Sleep(400 * time.Millisecond) + + err = mpc.Pause() + + vars, err := mpc.GetVariables() + assert.NoError(t, err) + + spew.Dump(vars) + +} diff --git a/seanime-2.9.10/internal/mediaplayers/mpchc/start.go b/seanime-2.9.10/internal/mediaplayers/mpchc/start.go new file mode 100644 index 0000000..1cae7a5 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpchc/start.go @@ -0,0 +1,57 @@ +package mpchc + +import ( + "fmt" + "seanime/internal/util" + "strings" + "time" +) + +func (api *MpcHc) getExecutableName() string { + if len(api.Path) > 0 { + if strings.Contains(api.Path, "64") { + return "mpc-hc64.exe" + } else { + return strings.Replace(api.Path, "C:\\Program Files\\MPC-HC\\", "", 1) + } + } + return "mpc-hc64.exe" +} + +func (api *MpcHc) GetExecutablePath() string { + + if len(api.Path) > 0 { + return api.Path + } + + return "C:\\Program Files\\MPC-HC\\mpc-hc64.exe" +} + +func (api *MpcHc) isRunning(executable string) bool { + cmd := util.NewCmd("tasklist") + output, err := cmd.Output() + if err != nil { + return false + } + + return strings.Contains(string(output), executable) +} + +func (api *MpcHc) Start() error { + name := api.getExecutableName() + exe := api.GetExecutablePath() + if api.isRunning(name) { + return nil + } + + cmd := util.NewCmd(exe) + err := cmd.Start() + if err != nil { + api.Logger.Error().Err(err).Msg("mpc-hc: Error starting MPC-HC") + return fmt.Errorf("error starting MPC-HC: %w", err) + } + + time.Sleep(1 * time.Second) + + return nil +} diff --git a/seanime-2.9.10/internal/mediaplayers/mpchc/status.go b/seanime-2.9.10/internal/mediaplayers/mpchc/status.go new file mode 100644 index 0000000..682929d --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpchc/status.go @@ -0,0 +1,58 @@ +package mpchc + +import "strconv" + +func (api *MpcHc) Play() (err error) { + _, err = api.Execute(playCmd, nil) + return +} + +func (api *MpcHc) Pause() (err error) { + _, err = api.Execute(pauseCmd, nil) + return +} + +func (api *MpcHc) TogglePlay() (err error) { + _, err = api.Execute(playPauseCmd, nil) + return +} + +func (api *MpcHc) Stop() (err error) { + _, err = api.Execute(stopCmd, nil) + return +} + +func (api *MpcHc) ToggleFullScreen() (err error) { + _, err = api.Execute(fullscreenCmd, nil) + return +} + +// Seek position in ms +func (api *MpcHc) Seek(pos int) (err error) { + _, err = api.Execute(seekCmd, map[string]interface{}{"position": millisecondsToDuration(pos)}) + return +} + +//---------------------------------------------------------------------------------------------------------------------- + +func millisecondsToDuration(ms int) string { + if ms <= 0 { + return "00:00:00" + } + + duration := ms / 1000 + hours := duration / 3600 + duration %= 3600 + + minutes := duration / 60 + duration %= 60 + + return padStart(strconv.Itoa(hours), 2, "0") + ":" + padStart(strconv.Itoa(minutes), 2, "0") + ":" + padStart(strconv.Itoa(duration), 2, "0") +} + +func padStart(s string, length int, pad string) string { + for len(s) < length { + s = pad + s + } + return s +} diff --git a/seanime-2.9.10/internal/mediaplayers/mpchc/variables.go b/seanime-2.9.10/internal/mediaplayers/mpchc/variables.go new file mode 100644 index 0000000..9d9681e --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpchc/variables.go @@ -0,0 +1,68 @@ +package mpchc + +import ( + "github.com/PuerkitoBio/goquery" + "strconv" + "strings" +) + +type Variables struct { + Version string `json:"version"` + File string `json:"file"` + FilePath string `json:"filepath"` + FileDir string `json:"filedir"` + Size string `json:"size"` + State int `json:"state"` + StateString string `json:"statestring"` + Position float64 `json:"position"` + PositionString string `json:"positionstring"` + Duration float64 `json:"duration"` + DurationString string `json:"durationstring"` + VolumeLevel float64 `json:"volumelevel"` + Muted bool `json:"muted"` +} + +func parseVariables(variablePageHtml string) *Variables { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(variablePageHtml)) + if err != nil { + // Handle error + return &Variables{} + } + + fields := make(map[string]string) + + doc.Find("p").Each(func(_ int, s *goquery.Selection) { + id, exists := s.Attr("id") + if !exists { + return + } + text := s.Text() + fields[id] = text + }) + + return &Variables{ + Version: fields["version"], + File: fields["file"], + FilePath: fields["filepath"], + FileDir: fields["filedir"], + Size: fields["size"], + State: parseInt(fields["state"]), + StateString: fields["statestring"], + Position: parseFloat(fields["position"]), + PositionString: fields["positionstring"], + Duration: parseFloat(fields["duration"]), + DurationString: fields["durationstring"], + VolumeLevel: parseFloat(fields["volumelevel"]), + Muted: fields["muted"] != "0", + } +} + +func parseInt(value string) int { + intValue, _ := strconv.Atoi(value) + return intValue +} + +func parseFloat(value string) float64 { + floatValue, _ := strconv.ParseFloat(value, 64) + return floatValue +} diff --git a/seanime-2.9.10/internal/mediaplayers/mpv/mpv.go b/seanime-2.9.10/internal/mediaplayers/mpv/mpv.go new file mode 100644 index 0000000..0b47973 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpv/mpv.go @@ -0,0 +1,657 @@ +package mpv + +import ( + "bufio" + "bytes" + "context" + "errors" + "os/exec" + "runtime" + "seanime/internal/mediaplayers/mpvipc" + "seanime/internal/util" + "seanime/internal/util/result" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" +) + +type ( + Playback struct { + Filename string + Paused bool + Position float64 + Duration float64 + IsRunning bool + Filepath string + } + + Mpv struct { + Logger *zerolog.Logger + Playback *Playback + SocketName string + AppPath string + Args string + mu sync.Mutex + playbackMu sync.RWMutex + cancel context.CancelFunc // Cancel function for the context + subscribers *result.Map[string, *Subscriber] // Subscribers to the mpv events + conn *mpvipc.Connection // Reference to the mpv connection + cmd *exec.Cmd + prevSocketName string + exitedCh chan struct{} + } + + // Subscriber is a subscriber to the mpv events. + // Make sure the subscriber listens to both channels, otherwise it will deadlock. + Subscriber struct { + eventCh chan *mpvipc.Event + closedCh chan struct{} + } +) + +var cmdCtx, cmdCancel = context.WithCancel(context.Background()) + +func New(logger *zerolog.Logger, socketName string, appPath string, optionalArgs ...string) *Mpv { + if cmdCancel != nil { + cmdCancel() + } + + sn := socketName + if socketName == "" { + sn = getDefaultSocketName() + } + + additionalArgs := "" + if len(optionalArgs) > 0 { + additionalArgs = optionalArgs[0] + } + + return &Mpv{ + Logger: logger, + Playback: &Playback{}, + mu: sync.Mutex{}, + playbackMu: sync.RWMutex{}, + SocketName: sn, + AppPath: appPath, + Args: additionalArgs, + subscribers: result.NewResultMap[string, *Subscriber](), + exitedCh: make(chan struct{}), + } +} + +func (m *Mpv) GetExecutablePath() string { + if m.AppPath != "" { + return m.AppPath + } + return "mpv" +} + +// launchPlayer starts the mpv player and plays the file. +// If the player is already running, it just loads the new file. +func (m *Mpv) launchPlayer(idle bool, filePath string, args ...string) error { + var err error + + m.Logger.Trace().Msgf("mpv: Launching player with args: %+v", args) + + // Cancel previous goroutine context + if m.cancel != nil { + m.Logger.Trace().Msg("mpv: Cancelling previous context") + m.cancel() + } + // Cancel previous command context + if cmdCancel != nil { + m.Logger.Trace().Msg("mpv: Cancelling previous command context") + cmdCancel() + } + cmdCtx, cmdCancel = context.WithCancel(context.Background()) + + m.Logger.Debug().Msg("mpv: Starting player") + if idle { + args = append(args, "--input-ipc-server="+m.SocketName, "--idle") + m.cmd, err = m.createCmd("", args...) + } else { + args = append(args, "--input-ipc-server="+m.SocketName) + m.cmd, err = m.createCmd(filePath, args...) + } + if err != nil { + return err + } + m.prevSocketName = m.SocketName + + // Create a pipe for stdout + stdoutPipe, err := m.cmd.StdoutPipe() + if err != nil { + m.Logger.Error().Err(err).Msg("mpv: Failed to create stdout pipe") + return err + } + + err = m.cmd.Start() + if err != nil { + return err + } + + wg := sync.WaitGroup{} + wg.Add(1) + + receivedLog := false + + go func() { + scanner := bufio.NewScanner(stdoutPipe) + for scanner.Scan() { + // Skip AV messages + if bytes.Contains(scanner.Bytes(), []byte("AV:")) { + continue + } + line := strings.TrimSpace(scanner.Text()) + if line != "" { + if !receivedLog { + receivedLog = true + wg.Done() + } + m.Logger.Trace().Msg("mpv cmd: " + line) // Print to logger + } + } + if err := scanner.Err(); err != nil { + if strings.Contains(err.Error(), "file already closed") { + m.Logger.Debug().Msg("mpv: File closed") + //close(m.exitedCh) + //m.exitedCh = make(chan struct{}) + } else { + m.Logger.Error().Err(err).Msg("mpv: Error reading from stdout") + } + } + }() + + go func() { + err := m.cmd.Wait() + if err != nil { + m.Logger.Warn().Err(err).Msg("mpv: Player has exited") + } + }() + + wg.Wait() + time.Sleep(1 * time.Second) + + m.Logger.Debug().Msg("mpv: Player started") + + return nil +} + +func (m *Mpv) replaceFile(filePath string) error { + m.Logger.Debug().Msg("mpv: Replacing file") + + if m.conn != nil && !m.conn.IsClosed() { + _, err := m.conn.Call("loadfile", filePath, "replace") + if err != nil { + return err + } + } + + return nil +} + +func (m *Mpv) Exited() chan struct{} { + return m.exitedCh +} + +func (m *Mpv) OpenAndPlay(filePath string, args ...string) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.Playback = &Playback{} + + // If the player is already running, just load the new file + var err error + if m.conn != nil && !m.conn.IsClosed() { + // Launch player or replace file + err = m.replaceFile(filePath) + } else { + // Launch player + err = m.launchPlayer(false, filePath, args...) + } + if err != nil { + return err + } + + // Create context for the connection + // When the cancel method is called (by launchPlayer), the previous connection will be closed + var ctx context.Context + ctx, m.cancel = context.WithCancel(context.Background()) + + // Establish new connection, only if it doesn't exist + // We don't continue past this point if the connection is already open, because it means the goroutine is already running + if m.conn != nil && !m.conn.IsClosed() { + return nil + } + + err = m.establishConnection() + if err != nil { + return err + } + + // // Reset subscriber's done channel in case it was closed + // m.subscribers.Range(func(key string, sub *Subscriber) bool { + // sub.eventCh = make(chan *mpvipc.Event) + // return true + // }) + + m.Playback.IsRunning = false + + // Listen for events in a goroutine + go m.listenForEvents(ctx) + + return nil +} + +func (m *Mpv) Pause() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.conn == nil || m.conn.IsClosed() { + return errors.New("mpv is not running") + } + + _, err := m.conn.Call("set_property", "pause", true) + if err != nil { + return err + } + + return nil +} + +func (m *Mpv) Resume() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.conn == nil || m.conn.IsClosed() { + return errors.New("mpv is not running") + } + + _, err := m.conn.Call("set_property", "pause", false) + if err != nil { + return err + } + + return nil +} + +// SeekTo seeks to the given position in the file by first pausing the player and unpausing it after seeking. +func (m *Mpv) SeekTo(position float64) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.conn == nil || m.conn.IsClosed() { + return errors.New("mpv is not running") + } + + // pause the player + _, err := m.conn.Call("set_property", "pause", true) + if err != nil { + return err + } + + time.Sleep(100 * time.Millisecond) + + _, err = m.conn.Call("set_property", "time-pos", position) + if err != nil { + return err + } + + time.Sleep(100 * time.Millisecond) + + // unpause the player + _, err = m.conn.Call("set_property", "pause", false) + if err != nil { + return err + } + + return nil +} + +// Seek seeks to the given position in the file. +func (m *Mpv) Seek(position float64) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.conn == nil || m.conn.IsClosed() { + return errors.New("mpv is not running") + } + + _, err := m.conn.Call("set_property", "time-pos", position) + if err != nil { + return err + } + + return nil +} + +func (m *Mpv) GetOpenConnection() (*mpvipc.Connection, error) { + if m.conn == nil || m.conn.IsClosed() { + return nil, errors.New("mpv is not running") + } + return m.conn, nil +} + +func (m *Mpv) establishConnection() error { + tries := 1 + for { + m.conn = mpvipc.NewConnection(m.SocketName) + err := m.conn.Open() + if err != nil { + if tries >= 5 { + m.Logger.Error().Err(err).Msg("mpv: Failed to establish connection") + return err + } + m.Logger.Error().Err(err).Msgf("mpv: Failed to establish connection (%d/4), retrying...", tries) + tries++ + time.Sleep(1 * time.Second) + continue + } + m.Logger.Debug().Msg("mpv: Connection established") + break + } + + return nil +} + +func (m *Mpv) listenForEvents(ctx context.Context) { + // Close the connection when the goroutine ends + defer func() { + m.Logger.Debug().Msg("mpv: Closing socket connection") + m.conn.Close() + m.terminate() + m.Logger.Debug().Msg("mpv: Instance closed") + }() + + events, stopListening := m.conn.NewEventListener() + m.Logger.Debug().Msg("mpv: Listening for events") + + _, err := m.conn.Get("path") + if err != nil { + m.Logger.Error().Err(err).Msg("mpv: Failed to get path") + return + } + + _, err = m.conn.Call("observe_property", 42, "time-pos") + if err != nil { + m.Logger.Error().Err(err).Msg("mpv: Failed to observe time-pos") + return + } + _, err = m.conn.Call("observe_property", 43, "pause") + if err != nil { + m.Logger.Error().Err(err).Msg("mpv: Failed to observe pause") + return + } + _, err = m.conn.Call("observe_property", 44, "duration") + if err != nil { + m.Logger.Error().Err(err).Msg("mpv: Failed to observe duration") + return + } + _, err = m.conn.Call("observe_property", 45, "filename") + if err != nil { + m.Logger.Error().Err(err).Msg("mpv: Failed to observe filename") + return + } + _, err = m.conn.Call("observe_property", 46, "path") + if err != nil { + m.Logger.Error().Err(err).Msg("mpv: Failed to observe path") + return + } + + // Listen for close event + go func() { + m.conn.WaitUntilClosed() + m.Logger.Debug().Msg("mpv: Connection has been closed") + stopListening <- struct{}{} + }() + + go func() { + // When the context is cancelled, close the connection + <-ctx.Done() + m.Logger.Debug().Msg("mpv: Context cancelled") + m.Playback.IsRunning = false + err := m.conn.Close() + if err != nil { + m.Logger.Error().Err(err).Msg("mpv: Failed to close connection") + } + stopListening <- struct{}{} + return + }() + + // Listen for events + for event := range events { + if event.Data != nil { + m.Playback.IsRunning = true + //m.Logger.Trace().Msgf("received event: %s, %v, %+v", event.Name, event.ID, event.Data) + switch event.ID { + case 43: + m.Playback.Paused = event.Data.(bool) + case 42: + m.Playback.Position = event.Data.(float64) + case 44: + m.Playback.Duration = event.Data.(float64) + case 45: + m.Playback.Filename = event.Data.(string) + case 46: + m.Playback.Filepath = event.Data.(string) + } + m.subscribers.Range(func(key string, sub *Subscriber) bool { + go func() { + sub.eventCh <- event + }() + return true + }) + } + } +} + +func (m *Mpv) GetPlaybackStatus() (*Playback, error) { + m.playbackMu.RLock() + defer m.playbackMu.RUnlock() + if !m.Playback.IsRunning { + return nil, errors.New("mpv is not running") + } + if m.Playback == nil { + return nil, errors.New("no playback status") + } + if m.Playback.Filename == "" { + return nil, errors.New("no media found") + } + if m.Playback.Duration == 0 { + return nil, errors.New("no duration found") + } + return m.Playback, nil +} + +func (m *Mpv) CloseAll() { + m.Logger.Debug().Msg("mpv: Received close request") + if m.conn != nil { + err := m.conn.Close() + if err != nil { + m.Logger.Error().Err(err).Msg("mpv: Failed to close connection") + } + } + m.terminate() +} + +func (m *Mpv) terminate() { + defer func() { + if r := recover(); r != nil { + m.Logger.Warn().Msgf("mpv: Termination panic") + } + }() + m.Logger.Trace().Msg("mpv: Terminating") + m.resetPlaybackStatus() + m.publishDone() + if m.cancel != nil { + m.cancel() + } + if cmdCancel != nil { + cmdCancel() + } + m.Logger.Trace().Msg("mpv: Terminated") +} + +func (m *Mpv) Subscribe(id string) *Subscriber { + sub := &Subscriber{ + eventCh: make(chan *mpvipc.Event, 100), + closedCh: make(chan struct{}), + } + m.subscribers.Set(id, sub) + return sub +} + +func (m *Mpv) Unsubscribe(id string) { + defer func() { + if r := recover(); r != nil { + } + }() + sub, ok := m.subscribers.Get(id) + if !ok { + return + } + close(sub.eventCh) + close(sub.closedCh) + m.subscribers.Delete(id) +} + +func (s *Subscriber) Events() <-chan *mpvipc.Event { + return s.eventCh +} + +func (s *Subscriber) Closed() <-chan struct{} { + return s.closedCh +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// parseArgs parses a command line string into individual arguments, respecting quotes +func parseArgs(s string) ([]string, error) { + args := make([]string, 0) + var current strings.Builder + var inQuotes bool + var quoteChar rune + + runes := []rune(s) + for i := 0; i < len(runes); i++ { + char := runes[i] + switch { + case char == '"' || char == '\'': + if !inQuotes { + inQuotes = true + quoteChar = char + } else if char == quoteChar { + inQuotes = false + quoteChar = 0 + // Add the current string even if it's empty (for empty quoted strings) + args = append(args, current.String()) + current.Reset() + } else { + current.WriteRune(char) + } + case char == ' ' || char == '\t': + if inQuotes { + current.WriteRune(char) + } else if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + case char == '\\' && i+1 < len(runes): + // Handle escaped characters + if inQuotes && (runes[i+1] == '"' || runes[i+1] == '\'') { + i++ + current.WriteRune(runes[i]) + } else { + current.WriteRune(char) + } + default: + current.WriteRune(char) + } + } + + if inQuotes { + return nil, errors.New("unclosed quote in arguments") + } + + if current.Len() > 0 { + args = append(args, current.String()) + } + + return args, nil +} + +// getDefaultSocketName returns the default name of the socket/pipe. +func getDefaultSocketName() string { + switch runtime.GOOS { + case "windows": + return "\\\\.\\pipe\\mpv_ipc" + case "linux": + return "/tmp/mpv_socket" + case "darwin": + return "/tmp/mpv_socket" + default: + return "/tmp/mpv_socket" + } +} + +// createCmd returns a new exec.Cmd instance. +func (m *Mpv) createCmd(filePath string, args ...string) (*exec.Cmd, error) { + var cmd *exec.Cmd + + // Add user-defined arguments + if m.Args != "" { + userArgs, err := parseArgs(m.Args) + if err != nil { + m.Logger.Warn().Err(err).Msg("mpv: Failed to parse user arguments, using simple split") + userArgs = strings.Fields(m.Args) + } + args = append(args, userArgs...) + } + + if filePath != "" { + // escapedFilePath := url.PathEscape(filePath) + args = append(args, filePath) + } + + binaryPath := "mpv" + switch m.AppPath { + case "": + default: + binaryPath = m.AppPath + } + + cmd = util.NewCmdCtx(cmdCtx, binaryPath, args...) + + m.Logger.Trace().Msgf("mpv: Command: %s", strings.Join(cmd.Args, " ")) + + return cmd, nil +} + +func (m *Mpv) resetPlaybackStatus() { + m.playbackMu.Lock() + m.Logger.Trace().Msg("mpv: Resetting playback status") + m.Playback.Filename = "" + m.Playback.Filepath = "" + m.Playback.Paused = false + m.Playback.Position = 0 + m.Playback.Duration = 0 + m.Playback.IsRunning = false + m.playbackMu.Unlock() + return +} + +func (m *Mpv) publishDone() { + defer func() { + if r := recover(); r != nil { + m.Logger.Warn().Msgf("mpv: Connection already closed") + } + }() + m.subscribers.Range(func(key string, sub *Subscriber) bool { + go func() { + sub.closedCh <- struct{}{} + }() + return true + }) +} diff --git a/seanime-2.9.10/internal/mediaplayers/mpv/mpv_test.go b/seanime-2.9.10/internal/mediaplayers/mpv/mpv_test.go new file mode 100644 index 0000000..20de0eb --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpv/mpv_test.go @@ -0,0 +1,251 @@ +package mpv + +import ( + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" +) + +var testFilePath = "E:\\ANIME\\[SubsPlease] Bocchi the Rock! (01-12) (1080p) [Batch]\\[SubsPlease] Bocchi the Rock! - 01v2 (1080p) [ABDDAE16].mkv" + +func TestMpv_OpenAndPlay(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + m := New(util.NewLogger(), "", "") + + err := m.OpenAndPlay(testFilePath) + if err != nil { + t.Fatal(err) + } + + sub := m.Subscribe("test") + + go func() { + time.Sleep(2 * time.Second) + m.CloseAll() + }() + + select { + case v, _ := <-sub.Closed(): + t.Logf("mpv exited, %+v", v) + break + } + + t.Log("Done") + +} + +func TestMpv_OpenAndPlayPath(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + m := New(util.NewLogger(), "", test_utils.ConfigData.Provider.MpvPath) + + err := m.OpenAndPlay(testFilePath) + if err != nil { + t.Fatal(err) + } + + sub := m.Subscribe("test") + + select { + case v, _ := <-sub.Closed(): + t.Logf("mpv exited, %+v", v) + break + } + + t.Log("Done") + +} + +func TestMpv_Playback(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + m := New(util.NewLogger(), "", "") + + err := m.OpenAndPlay(testFilePath) + if err != nil { + t.Fatal(err) + } + + sub := m.Subscribe("test") + +loop: + for { + select { + case v, _ := <-sub.Closed(): + t.Logf("mpv exited, %+v", v) + break loop + default: + spew.Dump(m.GetPlaybackStatus()) + time.Sleep(2 * time.Second) + } + } + + t.Log("Done") + +} + +func TestMpv_Multiple(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + m := New(util.NewLogger(), "", "") + + err := m.OpenAndPlay(testFilePath) + if err != nil { + t.Fatal(err) + } + + time.Sleep(2 * time.Second) + + err = m.OpenAndPlay(testFilePath) + if !assert.NoError(t, err) { + t.Log("error opening mpv instance twice") + } + + sub := m.Subscribe("test") + + go func() { + time.Sleep(2 * time.Second) + m.CloseAll() + }() + + select { + case v, _ := <-sub.Closed(): + t.Logf("mpv exited, %+v", v) + break + } + + t.Log("Done") + +} + +// Test parseArgs function +func TestParseArgs(t *testing.T) { + tests := []struct { + name string + input string + expected []string + hasError bool + }{ + { + name: "simple arguments", + input: "--fullscreen --volume=50", + expected: []string{"--fullscreen", "--volume=50"}, + hasError: false, + }, + { + name: "double quoted argument", + input: "--title=\"My Movie Name\"", + expected: []string{"--title=My Movie Name"}, + hasError: false, + }, + { + name: "single quoted argument", + input: "--title='My Movie Name'", + expected: []string{"--title=My Movie Name"}, + hasError: false, + }, + { + name: "space separated quoted argument", + input: "--title \"My Movie Name\"", + expected: []string{"--title", "My Movie Name"}, + hasError: false, + }, + { + name: "single space separated quoted argument", + input: "--title 'My Movie Name'", + expected: []string{"--title", "My Movie Name"}, + hasError: false, + }, + { + name: "mixed arguments", + input: "--fullscreen --title \"My Movie\" --volume=50", + expected: []string{"--fullscreen", "--title", "My Movie", "--volume=50"}, + hasError: false, + }, + { + name: "path with spaces", + input: "--subtitle-file \"C:\\Program Files\\subtitles\\movie.srt\"", + expected: []string{"--subtitle-file", "C:\\Program Files\\subtitles\\movie.srt"}, + hasError: false, + }, + { + name: "escaped quotes", + input: "--title \"Movie with \\\"quotes\\\" in title\"", + expected: []string{"--title", "Movie with \"quotes\" in title"}, + hasError: false, + }, + { + name: "empty string", + input: "", + expected: []string{}, + hasError: false, + }, + { + name: "only spaces", + input: " ", + expected: []string{}, + hasError: false, + }, + { + name: "tabs and spaces", + input: "--fullscreen\t\t--volume=50 --loop", + expected: []string{"--fullscreen", "--volume=50", "--loop"}, + hasError: false, + }, + { + name: "unclosed double quote", + input: "--title \"My Movie", + expected: nil, + hasError: true, + }, + { + name: "unclosed single quote", + input: "--title 'My Movie", + expected: nil, + hasError: true, + }, + { + name: "nested quotes", + input: "--title \"Movie 'with' nested quotes\"", + expected: []string{"--title", "Movie 'with' nested quotes"}, + hasError: false, + }, + { + name: "complex mixed case", + input: "--fullscreen --title=\"Complex Movie\" --volume 75 --subtitle-file 'path/with spaces/sub.srt' --loop", + expected: []string{"--fullscreen", "--title=Complex Movie", "--volume", "75", "--subtitle-file", "path/with spaces/sub.srt", "--loop"}, + hasError: false, + }, + { + name: "empty quoted string", + input: "--title \"\"", + expected: []string{"--title", ""}, + hasError: false, + }, + { + name: "multiple spaces between args", + input: "--fullscreen --volume=50", + expected: []string{"--fullscreen", "--volume=50"}, + hasError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseArgs(tt.input) + + if tt.hasError { + assert.Error(t, err, "Expected error for input: %q", tt.input) + assert.Nil(t, result, "Expected nil result when error occurs") + } else { + assert.NoError(t, err, "Unexpected error for input: %q", tt.input) + assert.Equal(t, tt.expected, result, "Mismatch for input: %q", tt.input) + } + }) + } +} diff --git a/seanime-2.9.10/internal/mediaplayers/mpvipc/mpvipc.go b/seanime-2.9.10/internal/mediaplayers/mpvipc/mpvipc.go new file mode 100644 index 0000000..07a1c01 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpvipc/mpvipc.go @@ -0,0 +1,324 @@ +// Package mpvipc provides an interface for communicating with the mpv media +// player via it's JSON IPC interface +package mpvipc + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "net" + "sync" + "time" +) + +var ( + ErrClientClosed = errors.New("client connection closed") +) + +// Connection represents a connection to a mpv IPC socket +type Connection struct { + client net.Conn + socketName string + + lastRequest uint + waitingRequests map[uint]chan *commandResult + + lastListener uint + eventListeners map[uint]chan<- *Event + + lastCloseWaiter uint + closeWaiters map[uint]chan struct{} + + lock *sync.Mutex +} + +// Event represents an event received from mpv. For a list of all possible +// events, see https://mpv.io/manual/master/#list-of-events +type Event struct { + // Name is the only obligatory field: the name of the event + Name string `json:"event"` + + // Reason is the reason for the event: currently used for the "end-file" + // event. When Name is "end-file", possible values of Reason are: + // "eof", "stop", "quit", "error", "redirect", "unknown" + Reason string `json:"reason"` + + // Prefix is the log-message prefix (only if Name is "log-message") + Prefix string `json:"prefix"` + + // Level is the loglevel for a log-message (only if Name is "log-message") + Level string `json:"level"` + + // Text is the text of a log-message (only if Name is "log-message") + Text string `json:"text"` + + // ID is the user-set property ID (on events triggered by observed properties) + ID uint `json:"id"` + + // Data is the property value (on events triggered by observed properties) + Data interface{} `json:"data"` +} + +// NewConnection returns a Connection associated with the given unix socket +func NewConnection(socketName string) *Connection { + return &Connection{ + socketName: socketName, + lock: &sync.Mutex{}, + waitingRequests: make(map[uint]chan *commandResult), + eventListeners: make(map[uint]chan<- *Event), + closeWaiters: make(map[uint]chan struct{}), + } +} + +// Open connects to the socket. Returns an error if already connected. +// It also starts listening to events, so ListenForEvents() can be called +// afterwards. +func (c *Connection) Open() error { + c.lock.Lock() + defer c.lock.Unlock() + + if c.client != nil { + return fmt.Errorf("already open") + } + + client, err := dial(c.socketName) + if err != nil { + return fmt.Errorf("can't connect to mpv's socket: %s", err) + } + c.client = client + go c.listen() + return nil +} + +// ListenForEvents blocks until something is received on the stop channel (or +// it's closed). +// In the meantime, events received on the socket will be sent on the events +// channel. They may not appear in the same order they happened in. +// +// The events channel is closed automatically just before this method returns. +func (c *Connection) ListenForEvents(events chan<- *Event, stop <-chan struct{}) { + c.lock.Lock() + c.lastListener++ + id := c.lastListener + c.eventListeners[id] = events + c.lock.Unlock() + + <-stop + + c.lock.Lock() + delete(c.eventListeners, id) + close(events) + c.lock.Unlock() +} + +// NewEventListener is a convenience wrapper around ListenForEvents(). It +// creates and returns the event channel and the stop channel. After calling +// NewEventListener, read events from the events channel and send an empty +// struct to the stop channel to close it. +func (c *Connection) NewEventListener() (chan *Event, chan struct{}) { + events := make(chan *Event, 16) + stop := make(chan struct{}) + go c.ListenForEvents(events, stop) + return events, stop +} + +// Call calls an arbitrary command and returns its result. For a list of +// possible functions, see https://mpv.io/manual/master/#commands and +// https://mpv.io/manual/master/#list-of-input-commands +func (c *Connection) Call(arguments ...interface{}) (interface{}, error) { + c.lock.Lock() + c.lastRequest++ + id := c.lastRequest + resultChannel := make(chan *commandResult, 1) + c.waitingRequests[id] = resultChannel + c.lock.Unlock() + + defer func() { + c.lock.Lock() + delete(c.waitingRequests, id) + c.lock.Unlock() + }() + + err := c.sendCommand(id, arguments...) + if err != nil { + return nil, err + } + + var deadline <-chan time.Time + timer := time.NewTimer(time.Second * 5) + defer timer.Stop() + deadline = timer.C + + select { + case result := <-resultChannel: + if result.Status == "success" { + return result.Data, nil + } + return nil, fmt.Errorf("mpv error: %s", result.Status) + case <-deadline: + return nil, errors.New("timeout") + } +} + +// Set is a shortcut to Call("set_property", property, value) +func (c *Connection) Set(property string, value interface{}) error { + _, err := c.Call("set_property", property, value) + return err +} + +// Get is a shortcut to Call("get_property", property) +func (c *Connection) Get(property string) (interface{}, error) { + value, err := c.Call("get_property", property) + return value, err +} + +// Close closes the socket, disconnecting from mpv. It is safe to call Close() +// on an already closed connection. +func (c *Connection) Close() error { + c.lock.Lock() + defer c.lock.Unlock() + + if c.client != nil { + err := c.client.Close() + for waiterID := range c.closeWaiters { + close(c.closeWaiters[waiterID]) + } + c.client = nil + return err + } + return nil +} + +// IsClosed returns true if the connection is closed. There are several cases +// in which a connection is closed: +// +// 1. Close() has been called +// +// 2. The connection has been initialised but Open() hasn't been called yet +// +// 3. The connection terminated because of an error, mpv exiting or crashing +// +// It's ok to use IsClosed() to check if you need to reopen the connection +// before calling a command. +func (c *Connection) IsClosed() bool { + c.lock.Lock() + defer c.lock.Unlock() + return c.client == nil +} + +// WaitUntilClosed blocks until the connection becomes closed. See IsClosed() +// for an explanation of the closed state. +func (c *Connection) WaitUntilClosed() { + c.lock.Lock() + if c.client == nil { + c.lock.Unlock() + return + } + + closed := make(chan struct{}) + c.lastCloseWaiter++ + waiterID := c.lastCloseWaiter + c.closeWaiters[waiterID] = closed + + c.lock.Unlock() + + <-closed + + c.lock.Lock() + delete(c.closeWaiters, waiterID) + c.lock.Unlock() +} + +func (c *Connection) sendCommand(id uint, arguments ...interface{}) error { + var client net.Conn + c.lock.Lock() + client = c.client + c.lock.Unlock() + if client == nil { + return ErrClientClosed + } + + message := &commandRequest{ + Arguments: arguments, + ID: id, + } + data, err := json.Marshal(&message) + if err != nil { + return fmt.Errorf("can't encode command: %s", err) + } + _, err = c.client.Write(data) + if err != nil { + return fmt.Errorf("can't write command: %s", err) + } + _, err = c.client.Write([]byte("\n")) + if err != nil { + return fmt.Errorf("can't terminate command: %s", err) + } + return err +} + +type commandRequest struct { + Arguments []interface{} `json:"command"` + ID uint `json:"request_id"` +} + +type commandResult struct { + Status string `json:"error"` + Data interface{} `json:"data"` + ID uint `json:"request_id"` +} + +func (c *Connection) checkResult(data []byte) { + result := &commandResult{} + err := json.Unmarshal(data, &result) + if err != nil { + return // skip malformed data + } + if result.Status == "" { + return // not a result + } + c.lock.Lock() + // not ok means the request is deleted + request, ok := c.waitingRequests[result.ID] + c.lock.Unlock() + if ok { + request <- result + } +} + +func (c *Connection) checkEvent(data []byte) { + event := &Event{} + err := json.Unmarshal(data, &event) + if err != nil { + return // skip malformed data + } + if event.Name == "" { + return // not an event + } + eventsCh := make([]chan<- *Event, 0, 8) + c.lock.Lock() + for listenerID := range c.eventListeners { + listener := c.eventListeners[listenerID] + eventsCh = append(eventsCh, listener) + } + c.lock.Unlock() + + for _, eventCh := range eventsCh { + select { + case eventCh <- event: + default: + // ignore the recent + } + } +} + +func (c *Connection) listen() { + scanner := bufio.NewScanner(c.client) + for scanner.Scan() { + data := scanner.Bytes() + c.checkEvent(data) + c.checkResult(data) + } + _ = c.Close() +} diff --git a/seanime-2.9.10/internal/mediaplayers/mpvipc/mpvipc_test.go b/seanime-2.9.10/internal/mediaplayers/mpvipc/mpvipc_test.go new file mode 100644 index 0000000..2aba377 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpvipc/mpvipc_test.go @@ -0,0 +1,189 @@ +package mpvipc + +import ( + "fmt" + "time" +) + +func ExampleConnection_Call() { + conn := NewConnection("/tmp/mpv_socket") + err := conn.Open() + if err != nil { + fmt.Print(err) + return + } + defer func() { + _ = conn.Close() + }() + + // toggle play/pause + _, err = conn.Call("cycle", "pause") + if err != nil { + fmt.Print(err) + } + + // increase volume by 5 + _, err = conn.Call("add", "volume", 5) + if err != nil { + fmt.Print(err) + } + + // decrease volume by 3, showing an osd message and progress bar + _, err = conn.Call("osd-msg-bar", "add", "volume", -3) + if err != nil { + fmt.Print(err) + } + + // get mpv's version + version, err := conn.Call("get_version") + if err != nil { + fmt.Print(err) + } + fmt.Printf("version: %f\n", version.(float64)) +} + +func ExampleConnection_Set() { + conn := NewConnection("/tmp/mpv_socket") + err := conn.Open() + if err != nil { + fmt.Print(err) + return + } + defer func() { + _ = conn.Close() + }() + + // pause playback + err = conn.Set("pause", true) + if err != nil { + fmt.Print(err) + } + + // seek to the middle of file + err = conn.Set("percent-pos", 50) + if err != nil { + fmt.Print(err) + } +} + +func ExampleConnection_Get() { + conn := NewConnection("/tmp/mpv_socket") + err := conn.Open() + if err != nil { + fmt.Print(err) + return + } + defer func() { + _ = conn.Close() + }() + + // see if we're paused + paused, err := conn.Get("pause") + if err != nil { + fmt.Print(err) + } else if paused.(bool) { + fmt.Printf("we're paused!\n") + } else { + fmt.Printf("we're not paused.\n") + } + + // see the current position in the file + elapsed, err := conn.Get("time-pos") + if err != nil { + fmt.Print(err) + } else { + fmt.Printf("seconds from start of video: %f\n", elapsed.(float64)) + } +} + +func ExampleConnection_ListenForEvents() { + conn := NewConnection("/tmp/mpv_socket") + err := conn.Open() + if err != nil { + fmt.Print(err) + return + } + defer func() { + _ = conn.Close() + }() + + _, err = conn.Call("observe_property", 42, "volume") + if err != nil { + fmt.Print(err) + } + + events := make(chan *Event) + stop := make(chan struct{}) + go conn.ListenForEvents(events, stop) + + // print all incoming events for 5 seconds, then exit + go func() { + time.Sleep(time.Second * 5) + stop <- struct{}{} + }() + + for event := range events { + if event.ID == 42 { + fmt.Printf("volume now is %f\n", event.Data.(float64)) + } else { + fmt.Printf("received event: %s\n", event.Name) + } + } +} + +func ExampleConnection_NewEventListener() { + conn := NewConnection("/tmp/mpv_socket") + err := conn.Open() + if err != nil { + fmt.Print(err) + return + } + defer func() { + _ = conn.Close() + }() + + _, err = conn.Call("observe_property", 42, "volume") + if err != nil { + fmt.Print(err) + } + + events, stop := conn.NewEventListener() + + // print all incoming events for 5 seconds, then exit + go func() { + time.Sleep(time.Second * 5) + stop <- struct{}{} + }() + + for event := range events { + if event.ID == 42 { + fmt.Printf("volume now is %f\n", event.Data.(float64)) + } else { + fmt.Printf("received event: %s\n", event.Name) + } + } +} + +func ExampleConnection_WaitUntilClosed() { + conn := NewConnection("/tmp/mpv_socket") + err := conn.Open() + if err != nil { + fmt.Print(err) + return + } + defer func() { + _ = conn.Close() + }() + + events, stop := conn.NewEventListener() + + // print events until mpv exits, then exit + go func() { + conn.WaitUntilClosed() + stop <- struct{}{} + }() + + for event := range events { + fmt.Printf("received event: %s\n", event.Name) + } +} diff --git a/seanime-2.9.10/internal/mediaplayers/mpvipc/pipe.go b/seanime-2.9.10/internal/mediaplayers/mpvipc/pipe.go new file mode 100644 index 0000000..7f8288d --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpvipc/pipe.go @@ -0,0 +1,10 @@ +//go:build !windows +// +build !windows + +package mpvipc + +import "net" + +func dial(path string) (net.Conn, error) { + return net.Dial("unix", path) +} diff --git a/seanime-2.9.10/internal/mediaplayers/mpvipc/pipe_windows.go b/seanime-2.9.10/internal/mediaplayers/mpvipc/pipe_windows.go new file mode 100644 index 0000000..9a0e633 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/mpvipc/pipe_windows.go @@ -0,0 +1,16 @@ +//go:build windows +// +build windows + +package mpvipc + +import ( + "net" + "time" + + winio "github.com/Microsoft/go-winio" +) + +func dial(path string) (net.Conn, error) { + timeout := time.Second * 10 + return winio.DialPipe(path, &timeout) +} diff --git a/seanime-2.9.10/internal/mediaplayers/vlc/README.md b/seanime-2.9.10/internal/mediaplayers/vlc/README.md new file mode 100644 index 0000000..1093f84 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/vlc/README.md @@ -0,0 +1 @@ +Source code from [CedArtic/go-vlc-ctrl](https://github.com/CedArctic/go-vlc-ctrl), updated and modified for the purpose of this project. diff --git a/seanime-2.9.10/internal/mediaplayers/vlc/art.go b/seanime-2.9.10/internal/mediaplayers/vlc/art.go new file mode 100644 index 0000000..fc43c9a --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/vlc/art.go @@ -0,0 +1,41 @@ +package vlc + +import ( + "errors" + "strconv" +) + +// Art fetches cover art based on a playlist item's ID. If no ID is provided, Art returns the current item's cover art. +// Cover art is returned in the form of a byte array. +func (vlc *VLC) Art(itemID ...int) (byteArr []byte, err error) { + + // Check variadic arguments + if len(itemID) > 1 { + err = errors.New("please provide only up to one ID") + return + } + + // Build request URL + urlSegment := "/art" + if len(itemID) == 1 { + urlSegment = urlSegment + "?item=" + strconv.Itoa(itemID[0]) + } + + // Make request + var response string + response, err = vlc.RequestMaker(urlSegment) + + // Error Handling + if err != nil { + return + } + if response == "Error" { + err = errors.New("no cover art available for item") + return + } + + // Convert response to byte array + byteArr = []byte(response) + + return +} diff --git a/seanime-2.9.10/internal/mediaplayers/vlc/browse.go b/seanime-2.9.10/internal/mediaplayers/vlc/browse.go new file mode 100644 index 0000000..02c2b0a --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/vlc/browse.go @@ -0,0 +1,39 @@ +package vlc + +import "github.com/goccy/go-json" + +// File struct represents a single item in the browsed directory. Can be a file or a dir +type File struct { + Type string `json:"type"` // file or dir + Path string `json:"path"` + Name string `json:"name"` + AccessTime uint `json:"access_time"` + UID uint `json:"uid"` + CreationTime uint `json:"creation_time"` + GID uint `json:"gid"` + ModificationTime uint `json:"modification_time"` + Mode uint `json:"mode"` + URI string `json:"uri"` + Size uint `json:"size"` +} + +// ParseBrowse parses Browse() responses to []File +func ParseBrowse(browseResponse string) (files []File, err error) { + var temp struct { + Files []File `json:"element"` + } + err = json.Unmarshal([]byte(browseResponse), &temp) + files = temp.Files + return +} + +// Browse returns a File array with the items of the provided directory URI +func (vlc *VLC) Browse(uri string) (files []File, err error) { + var response string + response, err = vlc.RequestMaker("/requests/browse.json?uri=" + uri) + if err != nil { + return + } + files, err = ParseBrowse(response) + return +} diff --git a/seanime-2.9.10/internal/mediaplayers/vlc/playlist.go b/seanime-2.9.10/internal/mediaplayers/vlc/playlist.go new file mode 100644 index 0000000..3162a34 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/vlc/playlist.go @@ -0,0 +1,39 @@ +package vlc + +import "github.com/goccy/go-json" + +// Node structure (node or leaf type) is the basic element of VLC's playlist tree representation. +// Leafs are playlist items. Nodes are playlists or folders inside playlists. +type Node struct { + Ro string `json:"ro"` + Type string `json:"type"` // node or leaf + Name string `json:"name"` + ID string `json:"id"` + Duration int `json:"duration,omitempty"` + URI string `json:"uri,omitempty"` + Current string `json:"current,omitempty"` + Children []Node `json:"children,omitempty"` +} + +// ParsePlaylist parses Playlist() responses to Node +func ParsePlaylist(playlistResponse string) (playlist Node, err error) { + err = json.Unmarshal([]byte(playlistResponse), &playlist) + if err != nil { + return + } + return +} + +// Playlist returns a Node object that is the root node of VLC's Playlist tree +// Playlist tree structure: Level 0 - Root Node (Type="node"), Level 1 - Playlists (Type="node"), +// Level 2+: Playlist Items (Type="leaf") or Folder (Type="node") +func (vlc *VLC) Playlist() (playlist Node, err error) { + // Make response and check for errors + response, err := vlc.RequestMaker("/requests/playlist.json") + if err != nil { + return + } + // Parse to node + playlist, err = ParsePlaylist(response) + return +} diff --git a/seanime-2.9.10/internal/mediaplayers/vlc/start.go b/seanime-2.9.10/internal/mediaplayers/vlc/start.go new file mode 100644 index 0000000..e741665 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/vlc/start.go @@ -0,0 +1,66 @@ +package vlc + +import ( + "fmt" + "runtime" + "seanime/internal/util" + "time" +) + +func (vlc *VLC) getExecutableName() string { + switch runtime.GOOS { + case "windows": + return "vlc.exe" + case "linux": + return "vlc" + case "darwin": + return "vlc" + default: + return "vlc" + } +} + +func (vlc *VLC) GetExecutablePath() string { + + if len(vlc.Path) > 0 { + return vlc.Path + } + + switch runtime.GOOS { + case "windows": + return "C:\\Program Files\\VideoLAN\\VLC\\vlc.exe" + case "linux": + return "/usr/bin/vlc" // Default path for VLC on most Linux distributions + case "darwin": + return "/Applications/VLC.app/Contents/MacOS/VLC" // Default path for VLC on macOS + default: + return "C:\\Program Files\\VideoLAN\\VLC\\vlc.exe" + } +} + +func (vlc *VLC) Start() error { + + // If the path is empty, do not check if VLC is running + if vlc.Path == "" { + return nil + } + + // Check if VLC is already running + name := vlc.getExecutableName() + if util.ProgramIsRunning(name) { + return nil + } + + // Start VLC + exe := vlc.GetExecutablePath() + cmd := util.NewCmd(exe) + err := cmd.Start() + if err != nil { + vlc.Logger.Error().Err(err).Msg("vlc: Error starting VLC") + return fmt.Errorf("error starting VLC: %w", err) + } + + time.Sleep(1 * time.Second) + + return nil +} diff --git a/seanime-2.9.10/internal/mediaplayers/vlc/status.go b/seanime-2.9.10/internal/mediaplayers/vlc/status.go new file mode 100644 index 0000000..455bc76 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/vlc/status.go @@ -0,0 +1,390 @@ +package vlc + +import ( + "errors" + "net/url" + "path/filepath" + "strconv" + "strings" + + "github.com/goccy/go-json" +) + +// Status contains information related to the VLC instance status. Use parseStatus to parse the response from a +// status.go function. +type Status struct { + // TODO: The Status structure is still a work in progress + Fullscreen bool `json:"fullscreen"` + Stats Stats `json:"stats"` + AspectRatio string `json:"aspectratio"` + AudioDelay float64 `json:"audiodelay"` + APIVersion uint `json:"apiversion"` + CurrentPlID uint `json:"currentplid"` + Time uint `json:"time"` + Volume uint `json:"volume"` + Length uint `json:"length"` + Random bool `json:"random"` + AudioFilters map[string]string `json:"audiofilters"` + Rate float64 `json:"rate"` + VideoEffects VideoEffects `json:"videoeffects"` + State string `json:"state"` + Loop bool `json:"loop"` + Version string `json:"version"` + Position float64 `json:"position"` + Information Information `json:"information"` + Repeat bool `json:"repeat"` + SubtitleDelay float64 `json:"subtitledelay"` + Equalizer []Equalizer `json:"equalizer"` +} + +// Stats contains certain statistics of a VLC instance. A Stats variable is included in Status +type Stats struct { + InputBitRate float64 `json:"inputbitrate"` + SentBytes uint `json:"sentbytes"` + LosABuffers uint `json:"lostabuffers"` + AveragedEMuxBitrate float64 `json:"averagedemuxbitrate"` + ReadPackets uint `json:"readpackets"` + DemuxReadPackets uint `json:"demuxreadpackets"` + LostPictures uint `json:"lostpictures"` + DisplayedPictures uint `json:"displayedpictures"` + SentPackets uint `json:"sentpackets"` + DemuxReadBytes uint `json:"demuxreadbytes"` + DemuxBitRate float64 `json:"demuxbitrate"` + PlayedABuffers uint `json:"playedabuffers"` + DemuxDiscontinuity uint `json:"demuxdiscontinuity"` + DecodeAudio uint `json:"decodedaudio"` + SendBitRate float64 `json:"sendbitrate"` + ReadBytes uint `json:"readbytes"` + AverageInputBitRate float64 `json:"averageinputbitrate"` + DemuxCorrupted uint `json:"demuxcorrupted"` + DecodedVideo uint `json:"decodedvideo"` +} + +// VideoEffects contains the current video effects configuration. A VideoEffects variable is included in Status +type VideoEffects struct { + Hue int `json:"hue"` + Saturation int `json:"saturation"` + Contrast int `json:"contrast"` + Brightness int `json:"brightness"` + Gamma int `json:"gamma"` +} + +// Information contains information related to the item currently being played. It is also part of Status +type Information struct { + Chapter int `json:"chapter"` + // TODO: Chapters definition might need to be changed + Chapters []interface{} `json:"chapters"` + Title int `json:"title"` + // TODO: Category definition might need to be updated/modified + Category map[string]struct { + Filename string `json:"filename"` + Codec string `json:"Codec"` + Channels string `json:"Channels"` + BitsPerSample string `json:"Bits_per_sample"` + Type string `json:"Type"` + SampleRate string `json:"Sample_rate"` + } `json:"category"` + Titles []interface{} `json:"titles"` +} + +// Equalizer contains information related to the equalizer configuration. An Equalizer variable is included in Status +type Equalizer struct { + Presets map[string]string `json:"presets"` + Bands map[string]string `json:"bands"` + Preamp int `json:"preamp"` +} + +// parseStatus parses GetStatus() responses to Status struct. +func parseStatus(statusResponse string) (status *Status, err error) { + err = json.Unmarshal([]byte(statusResponse), &status) + if err != nil { + return + } + return status, nil +} + +// GetStatus returns a Status object containing information of the instances' status +func (vlc *VLC) GetStatus() (status *Status, err error) { + // Make request + var response string + response, err = vlc.RequestMaker("/requests/status.json") + // Error handling + if err != nil { + return + } + // Parse response to Status + status, err = parseStatus(response) + return +} + +// Play playlist item with given id. If id is omitted, play last active item +func (vlc *VLC) Play(itemID ...int) (err error) { + // Check variadic arguments and form urlSegment + if len(itemID) > 1 { + err = errors.New("please provide only up to one ID") + return + } + urlSegment := "/requests/status.json?command=pl_play" + if len(itemID) == 1 { + urlSegment = urlSegment + "&id=" + strconv.Itoa(itemID[0]) + } + _, err = vlc.RequestMaker(urlSegment) + return +} + +// Pause toggles pause: If current state was 'stop', play item with given id, if no id specified, play current item. +// If no current item, play the first item in the playlist. +func (vlc *VLC) Pause(itemID ...int) (err error) { + // Check variadic arguments and form urlSegment + if len(itemID) > 1 { + err = errors.New("please provide only up to one ID") + return + } + urlSegment := "/requests/status.json?command=pl_pause" + if len(itemID) == 1 { + urlSegment = urlSegment + "&id=" + strconv.Itoa(itemID[0]) + } + _, err = vlc.RequestMaker(urlSegment) + return +} + +// Stop stops playback +func (vlc *VLC) Stop() (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=pl_stop") + return +} + +// Next skips to the next playlist item +func (vlc *VLC) Next() (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=pl_next") + return +} + +// Previous goes back to the previous playlist item +func (vlc *VLC) Previous() (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=pl_previous") + return +} + +// EmptyPlaylist empties the playlist +func (vlc *VLC) EmptyPlaylist() (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=pl_empty") + return +} + +// ToggleLoop toggles Random Playback +func (vlc *VLC) ToggleLoop() (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=pl_random") + return +} + +// ToggleRepeat toggles Playback Looping +func (vlc *VLC) ToggleRepeat() (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=pl_loop") + return +} + +// ToggleRandom toggles Repeat +func (vlc *VLC) ToggleRandom() (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=pl_repeat") + return +} + +// ToggleFullscreen toggles Fullscreen mode +func (vlc *VLC) ToggleFullscreen() (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=fullscreen") + return +} + +func escapeInput(input string) string { + if strings.HasPrefix(input, "http") { + return url.QueryEscape(input) + } else { + input = filepath.FromSlash(input) + return strings.ReplaceAll(url.QueryEscape(input), "+", "%20") + } +} + +// AddAndPlay adds a URI to the playlist and starts playback. +// The option field is optional and can have the values: noaudio, novideo +func (vlc *VLC) AddAndPlay(uri string, option ...string) error { + // Check variadic arguments and form urlSegment + if len(option) > 1 { + return errors.New("please provide only one option") + } + urlSegment := "/requests/status.json?command=in_play&input=" + escapeInput(uri) + if len(option) == 1 { + if (option[0] != "noaudio") && (option[0] != "novideo") { + return errors.New("invalid option") + } + urlSegment = urlSegment + "&option=" + option[0] + } + _, err := vlc.RequestMaker(urlSegment) + return err +} + +// Add adds a URI to the playlist +func (vlc *VLC) Add(uri string) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=in_enqueue&input=" + escapeInput(uri)) + return +} + +// AddSubtitle adds a subtitle from URI to currently playing file +func (vlc *VLC) AddSubtitle(uri string) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=addsubtitle&val=" + escapeInput(uri)) + return +} + +// Resume resumes playback if paused, else does nothing +func (vlc *VLC) Resume() (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=pl_forceresume") + return +} + +// ForcePause pauses playback, does nothing if already paused +func (vlc *VLC) ForcePause() (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=pl_forcepause") + return +} + +// Delete deletes an item with given id from playlist +func (vlc *VLC) Delete(id int) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=pl_delete&id=" + strconv.Itoa(id)) + return +} + +// AudioDelay sets Audio Delay in seconds +func (vlc *VLC) AudioDelay(delay float64) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=audiodelay&val=" + strconv.FormatFloat(delay, 'f', -1, 64)) + return +} + +// SubDelay sets Subtitle Delay in seconds +func (vlc *VLC) SubDelay(delay float64) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=subdelay&val=" + strconv.FormatFloat(delay, 'f', -1, 64)) + return +} + +// PlaybackRate sets Playback Rate. Must be > 0 +func (vlc *VLC) PlaybackRate(rate float64) (err error) { + if rate <= 0 { + err = errors.New("rate must be greater than 0") + return + } + _, err = vlc.RequestMaker("/requests/status.json?command=rate&val=" + strconv.FormatFloat(rate, 'f', -1, 64)) + return +} + +// AspectRatio sets aspect ratio. Must be one of the following values. Any other value will reset aspect ratio to default. +// Valid aspect ratio values: 1:1 , 4:3 , 5:4 , 16:9 , 16:10 , 221:100 , 235:100 , 239:100 +func (vlc *VLC) AspectRatio(ratio string) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=aspectratio&val=" + ratio) + return +} + +// Sort sorts playlist using sort mode and order . +// If id=0 then items will be sorted in normal order, if id=1 they will be sorted in reverse order. +// A non exhaustive list of sort modes: 0 Id, 1 Name, 3 Author, 5 Random, 7 Track number. +func (vlc *VLC) Sort(id int, val int) (err error) { + if (id != 0) && (id != 1) { + err = errors.New("sorting order must be 0 or 1") + return + } + _, err = vlc.RequestMaker("/requests/status.json?command=pl_sort&id=" + strconv.Itoa(id) + "&val=" + strconv.Itoa(val)) + return +} + +// ToggleSD toggle-enables service discovery module . +// Typical values are: sap shoutcast, podcast, hal +func (vlc *VLC) ToggleSD(val string) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=pl_sd&val=" + val) + return +} + +// Volume sets Volume level (can be absolute integer, or +/- relative value). +// Percentage isn't working at the moment. Allowed values are of the form: +, -, or % +func (vlc *VLC) Volume(val string) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=volume&val=" + val) + return +} + +// Seek seeks to +// +// Allowed values are of the form: +// [+ or -][:][:][] +// or [+ or -]% +// (value between [ ] are optional, value between < > are mandatory) +// examples: +// 1000 -> seek to the 1000th second +// +1H:2M -> seek 1 hour and 2 minutes forward +// -10% -> seek 10% back +func (vlc *VLC) Seek(val string) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=seek&val=" + val) + return +} + +// Preamp sets the preamp gain value, must be >=-20 and <=20 +func (vlc *VLC) Preamp(gain int) (err error) { + if (gain < -20) || (gain > 20) { + err = errors.New("preamp must be between -20 and 20") + return + } + _, err = vlc.RequestMaker("/requests/status.json?command=preamp&val=" + strconv.Itoa(gain)) + return +} + +// SetEQ sets the gain for a specific Equalizer band +func (vlc *VLC) SetEQ(band int, gain int) (err error) { + if (gain < -20) || (gain > 20) { + err = errors.New("gain must be between -20 and 20") + return + } + _, err = vlc.RequestMaker("/requests/status.json?command=equalizer&band=" + strconv.Itoa(band) + "&val=" + strconv.Itoa(gain)) + return +} + +// ToggleEQ toggles the EQ (true to enable, false to disable) +func (vlc *VLC) ToggleEQ(enable bool) (err error) { + enableStr := "0" + if enable == true { + enableStr = "1" + } + _, err = vlc.RequestMaker("/requests/status.json?command=enableeq&val=" + enableStr) + return +} + +// SetEQPreset sets the equalizer preset as per the id specified +func (vlc *VLC) SetEQPreset(id int) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=setpreset&id=" + strconv.Itoa(id)) + return +} + +// SelectTitle selects the title using the title number +func (vlc *VLC) SelectTitle(id int) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=title&val=" + strconv.Itoa(id)) + return +} + +// SelectChapter selects the chapter using the chapter number +func (vlc *VLC) SelectChapter(id int) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=chapter&val=" + strconv.Itoa(id)) + return +} + +// SelectAudioTrack selects the audio track (use the number from the stream) +func (vlc *VLC) SelectAudioTrack(id int) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=audio_track&val=" + strconv.Itoa(id)) + return +} + +// SelectVideoTrack selects the video track (use the number from the stream) +func (vlc *VLC) SelectVideoTrack(id int) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=video_track&val=" + strconv.Itoa(id)) + return +} + +// SelectSubtitleTrack selects the subtitle track (use the number from the stream) +func (vlc *VLC) SelectSubtitleTrack(id int) (err error) { + _, err = vlc.RequestMaker("/requests/status.json?command=subtitle_track&val=" + strconv.Itoa(id)) + return +} diff --git a/seanime-2.9.10/internal/mediaplayers/vlc/vlc.go b/seanime-2.9.10/internal/mediaplayers/vlc/vlc.go new file mode 100644 index 0000000..99ced38 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/vlc/vlc.go @@ -0,0 +1,66 @@ +package vlc + +// https://github.com/CedArctic/go-vlc-ctrl/tree/master + +import ( + "fmt" + "github.com/rs/zerolog" + "io" + "net/http" + "strconv" +) + +// VLC struct represents an http interface enabled VLC instance. Build using NewVLC() +type VLC struct { + Host string + Port int + Password string + Path string + Logger *zerolog.Logger +} + +func (vlc *VLC) url() string { + return fmt.Sprintf("http://%s:%s", vlc.Host, strconv.Itoa(vlc.Port)) +} + +// RequestMaker make requests to VLC using a urlSegment provided by other functions +func (vlc *VLC) RequestMaker(urlSegment string) (response string, err error) { + + // Form a GET Request + client := &http.Client{} + request, reqErr := http.NewRequest("GET", vlc.url()+urlSegment, nil) + if reqErr != nil { + err = fmt.Errorf("http request error: %s\n", reqErr) + return + } + + // Make a GET request + request.SetBasicAuth("", vlc.Password) + reqResponse, resErr := client.Do(request) + if resErr != nil { + err = fmt.Errorf("http response error: %s\n", resErr) + return + } + defer func() { + reqResponse.Body.Close() + }() + + // Check HTTP status code and errors + statusCode := reqResponse.StatusCode + if !((statusCode >= 200) && (statusCode <= 299)) { + err = fmt.Errorf("http error code: %d\n", statusCode) + return "", err + } + + // Get byte response and http status code + byteArr, readErr := io.ReadAll(reqResponse.Body) + if readErr != nil { + err = fmt.Errorf("error reading response: %s\n", readErr) + return + } + + // Write response + response = string(byteArr) + + return +} diff --git a/seanime-2.9.10/internal/mediaplayers/vlc/vlc_test.go b/seanime-2.9.10/internal/mediaplayers/vlc/vlc_test.go new file mode 100644 index 0000000..52d646b --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/vlc/vlc_test.go @@ -0,0 +1,87 @@ +package vlc + +import ( + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + "time" +) + +func TestVLC_Play(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + vlc := &VLC{ + Host: test_utils.ConfigData.Provider.VlcHost, + Port: test_utils.ConfigData.Provider.VlcPort, + Password: test_utils.ConfigData.Provider.VlcPassword, + Path: test_utils.ConfigData.Provider.VlcPath, + Logger: util.NewLogger(), + } + + err := vlc.Start() + require.NoError(t, err) + + err = vlc.AddAndPlay("E:\\Anime\\[Judas] Golden Kamuy (Seasons 1-2) [BD 1080p][HEVC x265 10bit][Eng-Subs]\\[Judas] Golden Kamuy - S2\\[Judas] Golden Kamuy S2 - 16.mkv") + + if err != nil { + t.Fatal(err) + } + + time.Sleep(400 * time.Millisecond) + + vlc.ForcePause() + + time.Sleep(400 * time.Millisecond) + + status, err := vlc.GetStatus() + require.NoError(t, err) + + assert.Equal(t, "paused", status.State) + + if err != nil { + t.Fatal(err) + } + +} + +func TestVLC_Seek(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.MediaPlayer()) + + vlc := &VLC{ + Host: test_utils.ConfigData.Provider.VlcHost, + Port: test_utils.ConfigData.Provider.VlcPort, + Password: test_utils.ConfigData.Provider.VlcPassword, + Path: test_utils.ConfigData.Provider.VlcPath, + Logger: util.NewLogger(), + } + + err := vlc.Start() + require.NoError(t, err) + + err = vlc.AddAndPlay("E:\\ANIME\\[SubsPlease] Bocchi the Rock! (01-12) (1080p) [Batch]\\[SubsPlease] Bocchi the Rock! - 01v2 (1080p) [ABDDAE16].mkv") + + time.Sleep(400 * time.Millisecond) + + vlc.ForcePause() + + time.Sleep(400 * time.Millisecond) + + vlc.Seek("100") + + time.Sleep(400 * time.Millisecond) + + status, err := vlc.GetStatus() + require.NoError(t, err) + + assert.Equal(t, "paused", status.State) + + spew.Dump(status) + + if err != nil { + t.Fatal(err) + } + +} diff --git a/seanime-2.9.10/internal/mediaplayers/vlc/vlm.go b/seanime-2.9.10/internal/mediaplayers/vlc/vlm.go new file mode 100644 index 0000000..cf92a04 --- /dev/null +++ b/seanime-2.9.10/internal/mediaplayers/vlc/vlm.go @@ -0,0 +1,21 @@ +package vlc + +import "net/url" + +// Vlm returns the full list of VLM elements +func (vlc *VLC) Vlm() (response string, err error) { + response, err = vlc.RequestMaker("/requests/vlm.xml") + return +} + +// VlmCmd executes a VLM Command and returns the response. Command is internally URL percent-encoded +func (vlc *VLC) VlmCmd(cmd string) (response string, err error) { + response, err = vlc.RequestMaker("/requests/vlm_cmd.xml?command=" + url.QueryEscape(cmd)) + return +} + +// VlmCmdErr returns the last VLM Error +func (vlc *VLC) VlmCmdErr() (response string, err error) { + response, err = vlc.RequestMaker("/requests/vlm_cmd.xml") + return +} diff --git a/seanime-2.9.10/internal/mediastream/attachments.go b/seanime-2.9.10/internal/mediastream/attachments.go new file mode 100644 index 0000000..6c21dfd --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/attachments.go @@ -0,0 +1,74 @@ +package mediastream + +import ( + "errors" + "net/url" + "path/filepath" + "seanime/internal/events" + "seanime/internal/mediastream/videofile" + + "github.com/labstack/echo/v4" +) + +func (r *Repository) ServeEchoExtractedSubtitles(c echo.Context) error { + + if !r.IsInitialized() { + r.wsEventManager.SendEvent(events.MediastreamShutdownStream, "Module not initialized") + return errors.New("module not initialized") + } + + if !r.TranscoderIsInitialized() { + r.wsEventManager.SendEvent(events.MediastreamShutdownStream, "Transcoder not initialized") + return errors.New("transcoder not initialized") + } + + // Get the parameter group + subFilePath := c.Param("*") + + // Get current media + mediaContainer, found := r.playbackManager.currentMediaContainer.Get() + if !found { + return errors.New("no file has been loaded") + } + + retPath := videofile.GetFileSubsCacheDir(r.cacheDir, mediaContainer.Hash) + + if retPath == "" { + return errors.New("could not find subtitles") + } + + r.logger.Trace().Msgf("mediastream: Serving subtitles from %s", retPath) + + return c.File(filepath.Join(retPath, subFilePath)) +} + +func (r *Repository) ServeEchoExtractedAttachments(c echo.Context) error { + if !r.IsInitialized() { + r.wsEventManager.SendEvent(events.MediastreamShutdownStream, "Module not initialized") + return errors.New("module not initialized") + } + + if !r.TranscoderIsInitialized() { + r.wsEventManager.SendEvent(events.MediastreamShutdownStream, "Transcoder not initialized") + return errors.New("transcoder not initialized") + } + + // Get the parameter group + subFilePath := c.Param("*") + + // Get current media + mediaContainer, found := r.playbackManager.currentMediaContainer.Get() + if !found { + return errors.New("no file has been loaded") + } + + retPath := videofile.GetFileAttCacheDir(r.cacheDir, mediaContainer.Hash) + + if retPath == "" { + return errors.New("could not find subtitles") + } + + subFilePath, _ = url.PathUnescape(subFilePath) + + return c.File(filepath.Join(retPath, subFilePath)) +} diff --git a/seanime-2.9.10/internal/mediastream/directplay.go b/seanime-2.9.10/internal/mediastream/directplay.go new file mode 100644 index 0000000..d853f2d --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/directplay.go @@ -0,0 +1,89 @@ +package mediastream + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "seanime/internal/events" + "seanime/internal/util" + + "github.com/labstack/echo/v4" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Direct +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) ServeEchoFile(c echo.Context, rawFilePath string, clientId string, libraryPaths []string) error { + // Unescape the file path, ignore errors + filePath, _ := url.PathUnescape(rawFilePath) + + // If the file path is base64 encoded, decode it + if util.IsBase64(rawFilePath) { + var err error + filePath, err = util.Base64DecodeStr(rawFilePath) + if err != nil { + // this shouldn't happen, but just in case IsBase64 is wrong + filePath, _ = url.PathUnescape(rawFilePath) + } + } + + // Make sure the file is in the library directories + inLibrary := false + for _, libraryPath := range libraryPaths { + if util.IsFileUnderDir(filePath, libraryPath) { + inLibrary = true + break + } + } + + if !inLibrary { + return c.NoContent(http.StatusNotFound) + } + + r.logger.Trace().Str("filepath", filePath).Str("payload", rawFilePath).Msg("mediastream: Served file") + // Content disposition + filename := filepath.Base(filePath) + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename)) + + return c.File(filePath) +} + +func (r *Repository) ServeEchoDirectPlay(c echo.Context, clientId string) error { + + if !r.IsInitialized() { + r.wsEventManager.SendEvent(events.MediastreamShutdownStream, "Module not initialized") + return errors.New("module not initialized") + } + + // Get current media + mediaContainer, found := r.playbackManager.currentMediaContainer.Get() + if !found { + r.wsEventManager.SendEvent(events.MediastreamShutdownStream, "no file has been loaded") + return errors.New("no file has been loaded") + } + + if c.Request().Method == http.MethodHead { + r.logger.Trace().Msg("mediastream: Received HEAD request for direct play") + + // Get the file size + fileInfo, err := os.Stat(mediaContainer.Filepath) + if err != nil { + r.logger.Error().Msg("mediastream: Failed to get file info") + return c.NoContent(http.StatusInternalServerError) + } + + // Set the content length + c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size())) + c.Response().Header().Set("Content-Type", "video/mp4") + c.Response().Header().Set("Accept-Ranges", "bytes") + filename := filepath.Base(mediaContainer.Filepath) + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename)) + return c.NoContent(http.StatusOK) + } + + return c.File(mediaContainer.Filepath) +} diff --git a/seanime-2.9.10/internal/mediastream/optimizer/optimizer.go b/seanime-2.9.10/internal/mediastream/optimizer/optimizer.go new file mode 100644 index 0000000..def9379 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/optimizer/optimizer.go @@ -0,0 +1,87 @@ +package optimizer + +import ( + "fmt" + "github.com/rs/zerolog" + "github.com/samber/mo" + "seanime/internal/events" + "seanime/internal/mediastream/videofile" + "seanime/internal/util" +) + +const ( + QualityLow Quality = "low" + QualityMedium Quality = "medium" + QualityHigh Quality = "high" + QualityMax Quality = "max" +) + +type ( + Quality string + + Optimizer struct { + wsEventManager events.WSEventManagerInterface + logger *zerolog.Logger + libraryDir mo.Option[string] + concurrentTasks int + } + + NewOptimizerOptions struct { + Logger *zerolog.Logger + WSEventManager events.WSEventManagerInterface + } +) + +func NewOptimizer(opts *NewOptimizerOptions) *Optimizer { + ret := &Optimizer{ + logger: opts.Logger, + wsEventManager: opts.WSEventManager, + libraryDir: mo.None[string](), + concurrentTasks: 2, + } + return ret +} + +func (o *Optimizer) SetLibraryDir(libraryDir string) { + o.libraryDir = mo.Some[string](libraryDir) +} + +///////////// + +type StartMediaOptimizationOptions struct { + Filepath string + Quality Quality + AudioChannelIndex int + MediaInfo *videofile.MediaInfo +} + +func (o *Optimizer) StartMediaOptimization(opts *StartMediaOptimizationOptions) (err error) { + defer util.HandlePanicInModuleWithError("mediastream/optimizer/StartMediaOptimization", &err) + + o.logger.Debug().Any("opts", opts).Msg("mediastream: Starting media optimization") + + if !o.libraryDir.IsPresent() { + return fmt.Errorf("library directory not set") + } + + if opts.Filepath == "" { + return fmt.Errorf("no filepath") + } + + return +} + +func qualityToPreset(quality Quality) string { + switch quality { + case QualityLow: + return "ultrafast" + case QualityMedium: + return "veryfast" + case QualityHigh: + return "fast" + case QualityMax: + return "medium" + default: + return "veryfast" + } +} diff --git a/seanime-2.9.10/internal/mediastream/playback.go b/seanime-2.9.10/internal/mediastream/playback.go new file mode 100644 index 0000000..4bc1c08 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/playback.go @@ -0,0 +1,176 @@ +package mediastream + +import ( + "errors" + "fmt" + "seanime/internal/mediastream/videofile" + "seanime/internal/util/result" + + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +const ( + StreamTypeTranscode StreamType = "transcode" // On-the-fly transcoding + StreamTypeOptimized StreamType = "optimized" // Pre-transcoded + StreamTypeDirect StreamType = "direct" // Direct streaming +) + +type ( + StreamType string + + PlaybackManager struct { + logger *zerolog.Logger + currentMediaContainer mo.Option[*MediaContainer] // The current media being played. + repository *Repository + mediaContainers *result.Map[string, *MediaContainer] // Temporary cache for the media containers. + } + + PlaybackState struct { + MediaId int `json:"mediaId"` // The media ID + } + + MediaContainer struct { + Filepath string `json:"filePath"` + Hash string `json:"hash"` + StreamType StreamType `json:"streamType"` // Tells the frontend how to play the media. + StreamUrl string `json:"streamUrl"` // The relative endpoint to stream the media. + MediaInfo *videofile.MediaInfo `json:"mediaInfo"` + //Metadata *Metadata `json:"metadata"` + // todo: add more fields (e.g. metadata) + } +) + +func NewPlaybackManager(repository *Repository) *PlaybackManager { + return &PlaybackManager{ + logger: repository.logger, + repository: repository, + mediaContainers: result.NewResultMap[string, *MediaContainer](), + } +} + +func (p *PlaybackManager) KillPlayback() { + p.logger.Debug().Msg("mediastream: Killing playback") + if p.currentMediaContainer.IsPresent() { + p.currentMediaContainer = mo.None[*MediaContainer]() + p.logger.Trace().Msg("mediastream: Removed current media container") + } +} + +// RequestPlayback is called by the frontend to stream a media file +func (p *PlaybackManager) RequestPlayback(filepath string, streamType StreamType) (ret *MediaContainer, err error) { + + p.logger.Debug().Str("filepath", filepath).Any("type", streamType).Msg("mediastream: Requesting playback") + + // Create a new media container + ret, err = p.newMediaContainer(filepath, streamType) + + if err != nil { + p.logger.Error().Err(err).Msg("mediastream: Failed to create media container") + return nil, fmt.Errorf("failed to create media container: %v", err) + } + + // Set the current media container. + p.currentMediaContainer = mo.Some(ret) + + p.logger.Info().Str("filepath", filepath).Msg("mediastream: Ready to play media") + + return +} + +// PreloadPlayback is called by the frontend to preload a media container so that the data is stored in advanced +func (p *PlaybackManager) PreloadPlayback(filepath string, streamType StreamType) (ret *MediaContainer, err error) { + + p.logger.Debug().Str("filepath", filepath).Any("type", streamType).Msg("mediastream: Preloading playback") + + // Create a new media container + ret, err = p.newMediaContainer(filepath, streamType) + + if err != nil { + p.logger.Error().Err(err).Msg("mediastream: Failed to create media container") + return nil, fmt.Errorf("failed to create media container: %v", err) + } + + p.logger.Info().Str("filepath", filepath).Msg("mediastream: Ready to play media") + + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Optimize +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (p *PlaybackManager) newMediaContainer(filepath string, streamType StreamType) (ret *MediaContainer, err error) { + p.logger.Debug().Str("filepath", filepath).Any("type", streamType).Msg("mediastream: New media container requested") + // Get the hash of the file. + hash, err := videofile.GetHashFromPath(filepath) + if err != nil { + return nil, err + } + + p.logger.Trace().Str("hash", hash).Msg("mediastream: Checking cache") + + // Check the cache ONLY if the stream type is the same. + if mc, ok := p.mediaContainers.Get(hash); ok && mc.StreamType == streamType { + p.logger.Debug().Str("hash", hash).Msg("mediastream: Media container cache HIT") + return mc, nil + } + + p.logger.Trace().Str("hash", hash).Msg("mediastream: Creating media container") + + // Get the media information of the file. + ret = &MediaContainer{ + Filepath: filepath, + Hash: hash, + StreamType: streamType, + } + + p.logger.Debug().Msg("mediastream: Extracting media info") + + ret.MediaInfo, err = p.repository.mediaInfoExtractor.GetInfo(p.repository.settings.MustGet().FfprobePath, filepath) + if err != nil { + return nil, err + } + + p.logger.Debug().Msg("mediastream: Extracted media info, extracting attachments") + + // Extract the attachments from the file. + err = videofile.ExtractAttachment(p.repository.settings.MustGet().FfmpegPath, filepath, hash, ret.MediaInfo, p.repository.cacheDir, p.logger) + if err != nil { + p.logger.Error().Err(err).Msg("mediastream: Failed to extract attachments") + return nil, err + } + + p.logger.Debug().Msg("mediastream: Extracted attachments") + + streamUrl := "" + switch streamType { + case StreamTypeDirect: + // Directly serve the file. + streamUrl = "/api/v1/mediastream/direct" + case StreamTypeTranscode: + // Live transcode the file. + streamUrl = "/api/v1/mediastream/transcode/master.m3u8" + case StreamTypeOptimized: + // TODO: Check if the file is already transcoded when the feature is implemented. + // ... + streamUrl = "/api/v1/mediastream/hls/master.m3u8" + } + + // TODO: Add metadata to the media container. + // ... + + if streamUrl == "" { + return nil, errors.New("invalid stream type") + } + + // Set the stream URL. + ret.StreamUrl = streamUrl + + // Store the media container in the map. + p.mediaContainers.Set(hash, ret) + + return +} diff --git a/seanime-2.9.10/internal/mediastream/repository.go b/seanime-2.9.10/internal/mediastream/repository.go new file mode 100644 index 0000000..1b8cc02 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/repository.go @@ -0,0 +1,284 @@ +package mediastream + +import ( + "errors" + "github.com/rs/zerolog" + "github.com/samber/mo" + "os" + "path/filepath" + "seanime/internal/database/models" + "seanime/internal/events" + "seanime/internal/mediastream/optimizer" + "seanime/internal/mediastream/transcoder" + "seanime/internal/mediastream/videofile" + "seanime/internal/util/filecache" + "sync" +) + +type ( + Repository struct { + transcoder mo.Option[*transcoder.Transcoder] + optimizer *optimizer.Optimizer + settings mo.Option[*models.MediastreamSettings] + playbackManager *PlaybackManager + mediaInfoExtractor *videofile.MediaInfoExtractor + logger *zerolog.Logger + wsEventManager events.WSEventManagerInterface + fileCacher *filecache.Cacher + reqMu sync.Mutex + cacheDir string // where attachments are stored + transcodeDir string // where stream segments are stored + } + + NewRepositoryOptions struct { + Logger *zerolog.Logger + WSEventManager events.WSEventManagerInterface + FileCacher *filecache.Cacher + } +) + +func NewRepository(opts *NewRepositoryOptions) *Repository { + ret := &Repository{ + logger: opts.Logger, + optimizer: optimizer.NewOptimizer(&optimizer.NewOptimizerOptions{ + Logger: opts.Logger, + WSEventManager: opts.WSEventManager, + }), + settings: mo.None[*models.MediastreamSettings](), + transcoder: mo.None[*transcoder.Transcoder](), + wsEventManager: opts.WSEventManager, + fileCacher: opts.FileCacher, + mediaInfoExtractor: videofile.NewMediaInfoExtractor(opts.FileCacher, opts.Logger), + } + ret.playbackManager = NewPlaybackManager(ret) + + return ret +} + +func (r *Repository) IsInitialized() bool { + return r.settings.IsPresent() +} + +func (r *Repository) OnCleanup() { + +} + +func (r *Repository) InitializeModules(settings *models.MediastreamSettings, cacheDir string, transcodeDir string) { + if settings == nil { + r.logger.Error().Msg("mediastream: Settings not present") + return + } + // Create the temp directory + err := os.MkdirAll(transcodeDir, 0755) + if err != nil { + r.logger.Error().Err(err).Msg("mediastream: Failed to create transcode directory") + } + + if settings.FfmpegPath == "" { + settings.FfmpegPath = "ffmpeg" + } + + if settings.FfprobePath == "" { + settings.FfprobePath = "ffprobe" + } + + // Set the settings + r.settings = mo.Some[*models.MediastreamSettings](settings) + + r.cacheDir = cacheDir + r.transcodeDir = transcodeDir + + // Set the optimizer settings + r.optimizer.SetLibraryDir(settings.PreTranscodeLibraryDir) + + // Initialize the transcoder + if ok := r.initializeTranscoder(r.settings); ok { + } + + r.logger.Info().Msg("mediastream: Module initialized") +} + +// CacheWasCleared should be called when the cache directory is manually cleared. +func (r *Repository) CacheWasCleared() { + r.playbackManager.mediaContainers.Clear() +} + +func (r *Repository) ClearTranscodeDir() { + r.reqMu.Lock() + defer r.reqMu.Unlock() + + r.logger.Trace().Msg("mediastream: Clearing transcode directory") + + // Empty the transcode directory + if r.transcodeDir != "" { + files, err := os.ReadDir(r.transcodeDir) + if err != nil { + r.logger.Error().Err(err).Msg("mediastream: Failed to read transcode directory") + return + } + + for _, file := range files { + err = os.RemoveAll(filepath.Join(r.transcodeDir, file.Name())) + if err != nil { + r.logger.Error().Err(err).Msg("mediastream: Failed to remove file from transcode directory") + } + } + } + + r.logger.Debug().Msg("mediastream: Transcode directory cleared") + + r.playbackManager.mediaContainers.Clear() +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Optimize +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type StartMediaOptimizationOptions struct { + Filepath string + Quality optimizer.Quality + AudioChannelIndex int +} + +func (r *Repository) StartMediaOptimization(opts *StartMediaOptimizationOptions) (err error) { + if !r.IsInitialized() { + return errors.New("module not initialized") + } + + mediaInfo, err := r.mediaInfoExtractor.GetInfo(r.settings.MustGet().FfmpegPath, opts.Filepath) + if err != nil { + return + } + + err = r.optimizer.StartMediaOptimization(&optimizer.StartMediaOptimizationOptions{ + Filepath: opts.Filepath, + Quality: opts.Quality, + MediaInfo: mediaInfo, + }) + return +} + +func (r *Repository) RequestOptimizedStream(filepath string) (ret *MediaContainer, err error) { + if !r.IsInitialized() { + return nil, errors.New("module not initialized") + } + + ret, err = r.playbackManager.RequestPlayback(filepath, StreamTypeOptimized) + + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Transcode +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) TranscoderIsInitialized() bool { + return r.IsInitialized() && r.transcoder.IsPresent() +} + +func (r *Repository) RequestTranscodeStream(filepath string, clientId string) (ret *MediaContainer, err error) { + r.reqMu.Lock() + defer r.reqMu.Unlock() + + r.logger.Debug().Str("filepath", filepath).Msg("mediastream: Transcode stream requested") + + if !r.IsInitialized() { + return nil, errors.New("module not initialized") + } + + // Reinitialize the transcoder for each new transcode request + if ok := r.initializeTranscoder(r.settings); !ok { + return nil, errors.New("real-time transcoder not initialized, check your settings") + } + + ret, err = r.playbackManager.RequestPlayback(filepath, StreamTypeTranscode) + + return +} + +func (r *Repository) RequestPreloadTranscodeStream(filepath string) (err error) { + r.logger.Debug().Str("filepath", filepath).Msg("mediastream: Transcode stream preloading requested") + + if !r.IsInitialized() { + return errors.New("module not initialized") + } + + _, err = r.playbackManager.PreloadPlayback(filepath, StreamTypeTranscode) + + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Direct Play +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) RequestDirectPlay(filepath string, clientId string) (ret *MediaContainer, err error) { + r.reqMu.Lock() + defer r.reqMu.Unlock() + + r.logger.Debug().Str("filepath", filepath).Msg("mediastream: Direct play requested") + + if !r.IsInitialized() { + return nil, errors.New("module not initialized") + } + + ret, err = r.playbackManager.RequestPlayback(filepath, StreamTypeDirect) + + return +} + +func (r *Repository) RequestPreloadDirectPlay(filepath string) (err error) { + r.logger.Debug().Str("filepath", filepath).Msg("mediastream: Direct stream preloading requested") + + if !r.IsInitialized() { + return errors.New("module not initialized") + } + + _, err = r.playbackManager.PreloadPlayback(filepath, StreamTypeDirect) + + return +} + +/////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) initializeTranscoder(settings mo.Option[*models.MediastreamSettings]) bool { + // Destroy the old transcoder if it exists + if r.transcoder.IsPresent() { + tc, _ := r.transcoder.Get() + tc.Destroy() + } + + r.transcoder = mo.None[*transcoder.Transcoder]() + + // If the transcoder is not enabled, don't initialize the transcoder + if !settings.MustGet().TranscodeEnabled { + return false + } + + // If the temp directory is not set, don't initialize the transcoder + if r.transcodeDir == "" { + r.logger.Error().Msg("mediastream: Transcode directory not set, could not initialize transcoder") + return false + } + + opts := &transcoder.NewTranscoderOptions{ + Logger: r.logger, + HwAccelKind: settings.MustGet().TranscodeHwAccel, + Preset: settings.MustGet().TranscodePreset, + FfmpegPath: settings.MustGet().FfmpegPath, + FfprobePath: settings.MustGet().FfprobePath, + HwAccelCustomSettings: settings.MustGet().TranscodeHwAccelCustomSettings, + TempOutDir: r.transcodeDir, + } + + tc, err := transcoder.NewTranscoder(opts) + if err != nil { + r.logger.Error().Err(err).Msg("mediastream: Failed to initialize transcoder") + return false + } + + r.logger.Info().Msg("mediastream: Transcoder module initialized") + r.transcoder = mo.Some[*transcoder.Transcoder](tc) + + return true +} diff --git a/seanime-2.9.10/internal/mediastream/trans_test.go b/seanime-2.9.10/internal/mediastream/trans_test.go new file mode 100644 index 0000000..3780faf --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/trans_test.go @@ -0,0 +1,46 @@ +package mediastream + +import ( + "fmt" + "github.com/xfrr/goffmpeg/transcoder" + "os" + "path/filepath" + "testing" +) + +func TestTrans(t *testing.T) { + t.Skip("Do not run") + var dest = "E:\\TRANSCODING_TEMP\\id\\index.m3u8" + var videopath = "E:\\ANIME\\Dungeon Meshi\\[EMBER] Dungeon Meshi - 15.mkv" + _ = os.MkdirAll(filepath.Dir(dest), 0755) + + trans := new(transcoder.Transcoder) + + err := trans.Initialize(videopath, dest) + if err != nil { + panic(err) + } + + trans.MediaFile().SetHardwareAcceleration("auto") + //trans.MediaFile().SetSeekTime("00:10:00") + trans.MediaFile().SetPreset("veryfast") + trans.MediaFile().SetVideoCodec("libx264") + trans.MediaFile().SetHlsPlaylistType("vod") + trans.MediaFile().SetCRF(32) + trans.MediaFile().SetHlsMasterPlaylistName("index.m3u8") + trans.MediaFile().SetHlsSegmentDuration(4) + trans.MediaFile().SetHlsSegmentFilename("segment-%03d.ts") + //trans.MediaFile().SetHlsListSize(0) + trans.MediaFile().SetPixFmt("yuv420p") + trans.MediaFile().SetAudioCodec("aac") + trans.MediaFile().SetTags(map[string]string{"-map": "0:v:0 0:a:0"}) + + done := trans.Run(true) + progress := trans.Output() + for p := range progress { + fmt.Println(p) + } + + fmt.Println(<-done) + +} diff --git a/seanime-2.9.10/internal/mediastream/transcode.go b/seanime-2.9.10/internal/mediastream/transcode.go new file mode 100644 index 0000000..ef8f3c8 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcode.go @@ -0,0 +1,176 @@ +package mediastream + +import ( + "errors" + "seanime/internal/events" + "seanime/internal/mediastream/transcoder" + "strconv" + "strings" + + "github.com/samber/mo" + + "github.com/labstack/echo/v4" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Transcode +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) ServeEchoTranscodeStream(c echo.Context, clientId string) error { + + if !r.IsInitialized() { + r.wsEventManager.SendEvent(events.MediastreamShutdownStream, "Module not initialized") + return errors.New("module not initialized") + } + + if !r.TranscoderIsInitialized() { + r.wsEventManager.SendEvent(events.MediastreamShutdownStream, "Transcoder not initialized") + return errors.New("transcoder not initialized") + } + + path := c.Param("*") + + mediaContainer, found := r.playbackManager.currentMediaContainer.Get() + if !found { + return errors.New("no file has been loaded") + } + + if path == "master.m3u8" { + ret, err := r.transcoder.MustGet().GetMaster(mediaContainer.Filepath, mediaContainer.Hash, mediaContainer.MediaInfo, clientId) + if err != nil { + return err + } + + return c.String(200, ret) + } + + // Video stream + // /:quality/index.m3u8 + if strings.HasSuffix(path, "index.m3u8") && !strings.Contains(path, "audio") { + split := strings.Split(path, "/") + if len(split) != 2 { + return errors.New("invalid index.m3u8 path") + } + + quality, err := transcoder.QualityFromString(split[0]) + if err != nil { + return err + } + + ret, err := r.transcoder.MustGet().GetVideoIndex(mediaContainer.Filepath, mediaContainer.Hash, mediaContainer.MediaInfo, quality, clientId) + if err != nil { + return err + } + + return c.String(200, ret) + } + + // Audio stream + // /audio/:audio/index.m3u8 + if strings.HasSuffix(path, "index.m3u8") && strings.Contains(path, "audio") { + split := strings.Split(path, "/") + if len(split) != 3 { + return errors.New("invalid index.m3u8 path") + } + + audio, err := strconv.ParseInt(split[1], 10, 32) + if err != nil { + return err + } + + ret, err := r.transcoder.MustGet().GetAudioIndex(mediaContainer.Filepath, mediaContainer.Hash, mediaContainer.MediaInfo, int32(audio), clientId) + if err != nil { + return err + } + + return c.String(200, ret) + } + + // Video segment + // /:quality/segments-:chunk.ts + if strings.HasSuffix(path, ".ts") && !strings.Contains(path, "audio") { + split := strings.Split(path, "/") + if len(split) != 2 { + return errors.New("invalid segments-:chunk.ts path") + } + + quality, err := transcoder.QualityFromString(split[0]) + if err != nil { + return err + } + + segment, err := transcoder.ParseSegment(split[1]) + if err != nil { + return err + } + + ret, err := r.transcoder.MustGet().GetVideoSegment(mediaContainer.Filepath, mediaContainer.Hash, mediaContainer.MediaInfo, quality, segment, clientId) + if err != nil { + return err + } + + return c.File(ret) + } + + // Audio segment + // /audio/:audio/segments-:chunk.ts + if strings.HasSuffix(path, ".ts") && strings.Contains(path, "audio") { + split := strings.Split(path, "/") + if len(split) != 3 { + return errors.New("invalid segments-:chunk.ts path") + } + + audio, err := strconv.ParseInt(split[1], 10, 32) + if err != nil { + return err + } + + segment, err := transcoder.ParseSegment(split[2]) + if err != nil { + return err + } + + ret, err := r.transcoder.MustGet().GetAudioSegment(mediaContainer.Filepath, mediaContainer.Hash, mediaContainer.MediaInfo, int32(audio), segment, clientId) + if err != nil { + return err + } + + return c.File(ret) + } + + return errors.New("invalid path") +} + +// ShutdownTranscodeStream It should be called when unmounting the player (playback is no longer needed). +// This will also send an events.MediastreamShutdownStream event. +func (r *Repository) ShutdownTranscodeStream(clientId string) { + r.reqMu.Lock() + defer r.reqMu.Unlock() + + if !r.IsInitialized() { + return + } + + if !r.TranscoderIsInitialized() { + return + } + + r.logger.Warn().Str("client_id", clientId).Msg("mediastream: Received shutdown transcode stream request") + + if !r.playbackManager.currentMediaContainer.IsPresent() { + return + } + + // Kill playback + r.playbackManager.KillPlayback() + + // Destroy the current transcoder + r.transcoder.MustGet().Destroy() + + // Load a new transcoder + r.transcoder = mo.None[*transcoder.Transcoder]() + r.initializeTranscoder(r.settings) + + // Send event + r.wsEventManager.SendEvent(events.MediastreamShutdownStream, nil) +} diff --git a/seanime-2.9.10/internal/mediastream/transcoder/README.md b/seanime-2.9.10/internal/mediastream/transcoder/README.md new file mode 100644 index 0000000..655b0eb --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/README.md @@ -0,0 +1,2 @@ +The transcoder implementation was adapted from [zoriya/Kyoo](https://github.com/zoriya/Kyoo/tree/master/transcoder), +licensed under GPL-3.0. diff --git a/seanime-2.9.10/internal/mediastream/transcoder/audiostream.go b/seanime-2.9.10/internal/mediastream/transcoder/audiostream.go new file mode 100644 index 0000000..491ff9c --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/audiostream.go @@ -0,0 +1,44 @@ +package transcoder + +import ( + "fmt" + "github.com/rs/zerolog" + "path/filepath" +) + +type AudioStream struct { + Stream + index int32 + logger *zerolog.Logger + settings *Settings +} + +// NewAudioStream creates a new AudioStream for a file, at a given audio index. +func NewAudioStream(file *FileStream, idx int32, logger *zerolog.Logger, settings *Settings) *AudioStream { + logger.Trace().Str("file", filepath.Base(file.Path)).Int32("idx", idx).Msgf("trancoder: Creating audio stream") + ret := new(AudioStream) + ret.index = idx + ret.logger = logger + ret.settings = settings + NewStream(fmt.Sprintf("audio %d", idx), file, ret, &ret.Stream, settings, logger) + return ret +} + +func (as *AudioStream) getOutPath(encoderId int) string { + return filepath.Join(as.file.Out, fmt.Sprintf("segment-a%d-%d-%%d.ts", as.index, encoderId)) +} + +func (as *AudioStream) getFlags() Flags { + return AudioF +} + +func (as *AudioStream) getTranscodeArgs(segments string) []string { + return []string{ + "-map", fmt.Sprintf("0:a:%d", as.index), + "-c:a", "aac", + // TODO: Support 5.1 audio streams. + "-ac", "2", + // TODO: Support multi audio qualities. + "-b:a", "128k", + } +} diff --git a/seanime-2.9.10/internal/mediastream/transcoder/filestream.go b/seanime-2.9.10/internal/mediastream/transcoder/filestream.go new file mode 100644 index 0000000..d72e098 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/filestream.go @@ -0,0 +1,261 @@ +package transcoder + +import ( + "context" + "fmt" + "math" + "os" + "path/filepath" + "seanime/internal/mediastream/videofile" + "seanime/internal/util/result" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" +) + +// FileStream represents a stream of file data. +// It holds the keyframes, media information, video streams, and audio streams. +type FileStream struct { + ready sync.WaitGroup // A WaitGroup to synchronize go routines. + err error // An error that might occur during processing. + Path string // The path of the file. + Out string // The output path. + Keyframes *Keyframe // The keyframes of the video. + Info *videofile.MediaInfo // The media information of the file. + videos *result.Map[Quality, *VideoStream] // A map of video streams. + audios *result.Map[int32, *AudioStream] // A map of audio streams. + logger *zerolog.Logger + settings *Settings +} + +// NewFileStream creates a new FileStream. +func NewFileStream( + path string, + sha string, + mediaInfo *videofile.MediaInfo, + settings *Settings, + logger *zerolog.Logger, +) *FileStream { + ret := &FileStream{ + Path: path, + Out: filepath.Join(settings.StreamDir, sha), + videos: result.NewResultMap[Quality, *VideoStream](), + audios: result.NewResultMap[int32, *AudioStream](), + logger: logger, + settings: settings, + Info: mediaInfo, + } + + ret.ready.Add(1) + go func() { + defer ret.ready.Done() + ret.Keyframes = GetKeyframes(path, sha, logger, settings) + }() + + return ret +} + +// Kill stops all streams. +func (fs *FileStream) Kill() { + fs.videos.Range(func(_ Quality, s *VideoStream) bool { + s.Kill() + return true + }) + fs.audios.Range(func(_ int32, s *AudioStream) bool { + s.Kill() + return true + }) +} + +// Destroy stops all streams and removes the output directory. +func (fs *FileStream) Destroy() { + fs.logger.Debug().Msg("filestream: Destroying streams") + fs.Kill() + _ = os.RemoveAll(fs.Out) +} + +// GetMaster generates the master playlist. +func (fs *FileStream) GetMaster() string { + master := "#EXTM3U\n" + if fs.Info.Video != nil { + var transmuxQuality Quality + for _, quality := range Qualities { + if quality.Height() >= fs.Info.Video.Quality.Height() || quality.AverageBitrate() >= fs.Info.Video.Bitrate { + transmuxQuality = quality + break + } + } + { + bitrate := float64(fs.Info.Video.Bitrate) + master += "#EXT-X-STREAM-INF:" + master += fmt.Sprintf("AVERAGE-BANDWIDTH=%d,", int(math.Min(bitrate*0.8, float64(transmuxQuality.AverageBitrate())))) + master += fmt.Sprintf("BANDWIDTH=%d,", int(math.Min(bitrate, float64(transmuxQuality.MaxBitrate())))) + master += fmt.Sprintf("RESOLUTION=%dx%d,", fs.Info.Video.Width, fs.Info.Video.Height) + if fs.Info.Video.MimeCodec != nil { + master += fmt.Sprintf("CODECS=\"%s\",", *fs.Info.Video.MimeCodec) + } + master += "AUDIO=\"audio\"," + master += "CLOSED-CAPTIONS=NONE\n" + master += fmt.Sprintf("./%s/index.m3u8\n", Original) + } + aspectRatio := float32(fs.Info.Video.Width) / float32(fs.Info.Video.Height) + // codec is the prefix + the level, the level is not part of the codec we want to compare for the same_codec check bellow + transmuxPrefix := "avc1.6400" + transmuxCodec := transmuxPrefix + "28" + + for _, quality := range Qualities { + sameCodec := fs.Info.Video.MimeCodec != nil && strings.HasPrefix(*fs.Info.Video.MimeCodec, transmuxPrefix) + includeLvl := quality.Height() < fs.Info.Video.Quality.Height() || (quality.Height() == fs.Info.Video.Quality.Height() && !sameCodec) + + if includeLvl { + master += "#EXT-X-STREAM-INF:" + master += fmt.Sprintf("AVERAGE-BANDWIDTH=%d,", quality.AverageBitrate()) + master += fmt.Sprintf("BANDWIDTH=%d,", quality.MaxBitrate()) + master += fmt.Sprintf("RESOLUTION=%dx%d,", int(aspectRatio*float32(quality.Height())+0.5), quality.Height()) + master += fmt.Sprintf("CODECS=\"%s\",", transmuxCodec) + master += "AUDIO=\"audio\"," + master += "CLOSED-CAPTIONS=NONE\n" + master += fmt.Sprintf("./%s/index.m3u8\n", quality) + } + } + + //for _, quality := range Qualities { + // if quality.Height() < fs.Info.Video.Quality.Height() && quality.AverageBitrate() < fs.Info.Video.Bitrate { + // master += "#EXT-X-STREAM-INF:" + // master += fmt.Sprintf("AVERAGE-BANDWIDTH=%d,", quality.AverageBitrate()) + // master += fmt.Sprintf("BANDWIDTH=%d,", quality.MaxBitrate()) + // master += fmt.Sprintf("RESOLUTION=%dx%d,", int(aspectRatio*float32(quality.Height())+0.5), quality.Height()) + // master += "CODECS=\"avc1.640028\"," + // master += "AUDIO=\"audio\"," + // master += "CLOSED-CAPTIONS=NONE\n" + // master += fmt.Sprintf("./%s/index.m3u8\n", quality) + // } + //} + } + for _, audio := range fs.Info.Audios { + master += "#EXT-X-MEDIA:TYPE=AUDIO," + master += "GROUP-ID=\"audio\"," + if audio.Language != nil { + master += fmt.Sprintf("LANGUAGE=\"%s\",", *audio.Language) + } + if audio.Title != nil { + master += fmt.Sprintf("NAME=\"%s\",", *audio.Title) + } else if audio.Language != nil { + master += fmt.Sprintf("NAME=\"%s\",", *audio.Language) + } else { + master += fmt.Sprintf("NAME=\"Audio %d\",", audio.Index) + } + if audio.IsDefault { + master += "DEFAULT=YES," + } + master += "CHANNELS=\"2\"," + master += fmt.Sprintf("URI=\"./audio/%d/index.m3u8\"\n", audio.Index) + } + return master +} + +// GetVideoIndex gets the index of a video stream of a specific quality. +func (fs *FileStream) GetVideoIndex(quality Quality) (string, error) { + stream := fs.getVideoStream(quality) + return stream.GetIndex() +} + +// getVideoStream gets a video stream of a specific quality. +// It creates a new stream if it does not exist. +func (fs *FileStream) getVideoStream(quality Quality) *VideoStream { + stream, _ := fs.videos.GetOrSet(quality, func() (*VideoStream, error) { + return NewVideoStream(fs, quality, fs.logger, fs.settings), nil + }) + return stream +} + +// GetVideoSegment gets a segment of a video stream of a specific quality. +//func (fs *FileStream) GetVideoSegment(quality Quality, segment int32) (string, error) { +// stream := fs.getVideoStream(quality) +// return stream.GetSegment(segment) +//} + +// GetVideoSegment gets a segment of a video stream of a specific quality. +func (fs *FileStream) GetVideoSegment(quality Quality, segment int32) (string, error) { + streamLogger.Debug().Msgf("filestream: Retrieving video segment %d (%s)", segment, quality) + // Debug + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + debugStreamRequest(fmt.Sprintf("video %s, segment %d", quality, segment), ctx) + + //stream := fs.getVideoStream(quality) + //return stream.GetSegment(segment) + + // Channel to signal completion + done := make(chan struct{}) + + var ret string + var err error + + // Execute the retrieval operation in a goroutine + go func() { + defer close(done) + stream := fs.getVideoStream(quality) + ret, err = stream.GetSegment(segment) + }() + + // Wait for either the operation to complete or the timeout to occur + select { + case <-done: + return ret, err + case <-ctx.Done(): + return "", fmt.Errorf("filestream: timeout while retrieving video segment %d (%s)", segment, quality) + } +} + +// GetAudioIndex gets the index of an audio stream of a specific index. +func (fs *FileStream) GetAudioIndex(audio int32) (string, error) { + stream := fs.getAudioStream(audio) + return stream.GetIndex() +} + +// GetAudioSegment gets a segment of an audio stream of a specific index. +func (fs *FileStream) GetAudioSegment(audio int32, segment int32) (string, error) { + streamLogger.Debug().Msgf("filestream: Retrieving audio %d segment %d", audio, segment) + // Debug + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + debugStreamRequest(fmt.Sprintf("audio %d, segment %d", audio, segment), ctx) + + stream := fs.getAudioStream(audio) + return stream.GetSegment(segment) +} + +// getAudioStream gets an audio stream of a specific index. +// It creates a new stream if it does not exist. +func (fs *FileStream) getAudioStream(audio int32) *AudioStream { + stream, _ := fs.audios.GetOrSet(audio, func() (*AudioStream, error) { + return NewAudioStream(fs, audio, fs.logger, fs.settings), nil + }) + return stream +} + +func debugStreamRequest(text string, ctx context.Context) { + //ctx, cancel := context.WithCancel(context.Background()) + //defer cancel() + if debugStream { + start := time.Now() + ticker := time.NewTicker(2 * time.Second) + go func() { + for { + select { + case <-ctx.Done(): + ticker.Stop() + return + case <-ticker.C: + if debugStream { + time.Sleep(2 * time.Second) + streamLogger.Debug().Msgf("t: %s has been running for %.2f", text, time.Since(start).Seconds()) + } + } + } + }() + } +} diff --git a/seanime-2.9.10/internal/mediastream/transcoder/hwaccel.go b/seanime-2.9.10/internal/mediastream/transcoder/hwaccel.go new file mode 100644 index 0000000..1642ab0 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/hwaccel.go @@ -0,0 +1,149 @@ +package transcoder + +import ( + "runtime" + + "github.com/goccy/go-json" +) + +type ( + HwAccelOptions struct { + Kind string + Preset string + CustomSettings string + } +) + +func GetHardwareAccelSettings(opts HwAccelOptions) HwAccelSettings { + name := opts.Kind + if name == "" || name == "auto" || name == "cpu" || name == "none" { + name = "disabled" + } + streamLogger.Debug().Msgf("transcoder: Hardware acceleration: %s", name) + + var customHwAccelSettings HwAccelSettings + if opts.CustomSettings != "" && name == "custom" { + err := json.Unmarshal([]byte(opts.CustomSettings), &customHwAccelSettings) + if err != nil { + streamLogger.Error().Err(err).Msg("transcoder: Failed to parse custom hardware acceleration settings, falling back to CPU") + name = "disabled" + } + customHwAccelSettings.Name = "custom" + } else if opts.CustomSettings == "" && name == "custom" { + name = "disabled" + } + + defaultOSDevice := "/dev/dri/renderD128" + switch runtime.GOOS { + case "windows": + defaultOSDevice = "auto" + } + + // superfast or ultrafast would produce heavy files, so opt for "fast" by default. + // vaapi does not have any presets so this flag is unused for vaapi hwaccel. + preset := opts.Preset + + switch name { + case "disabled": + return HwAccelSettings{ + Name: "disabled", + DecodeFlags: []string{}, + EncodeFlags: []string{ + "-c:v", "libx264", + "-preset", preset, + // sc_threshold is a scene detection mechanism used to create a keyframe when the scene changes + // this is on by default and inserts keyframes where we don't want to (it also breaks force_key_frames) + // we disable it to prevents whole scenes from being removed due to the -f segment failing to find the corresponding keyframe + "-sc_threshold", "0", + // force 8bits output (by default it keeps the same as the source but 10bits is not playable on some devices) + "-pix_fmt", "yuv420p", + }, + // we could put :force_original_aspect_ratio=decrease:force_divisible_by=2 here but we already calculate a correct width and + // aspect ratio in our code so there is no need. + ScaleFilter: "scale=%d:%d", + WithForcedIdr: true, + } + case "vaapi": + return HwAccelSettings{ + Name: name, + DecodeFlags: []string{ + "-hwaccel", "vaapi", + "-hwaccel_device", GetEnvOr("SEANIME_TRANSCODER_VAAPI_RENDERER", defaultOSDevice), + "-hwaccel_output_format", "vaapi", + }, + EncodeFlags: []string{ + // h264_vaapi does not have any preset or scenecut flags. + "-c:v", "h264_vaapi", + }, + // if the hardware decoder could not work and fallback to soft decode, we need to instruct ffmpeg to + // upload back frames to gpu space (after converting them) + // see https://trac.ffmpeg.org/wiki/Hardware/VAAPI#Encoding for more info + // we also need to force the format to be nv12 since 10bits is not supported via hwaccel. + // this filter is equivalent to this pseudocode: + // if (vaapi) { + // hwupload, passthrough, keep vaapi as is + // convert whatever to nv12 on GPU + // } else { + // convert whatever to nv12 on CPU + // hwupload to vaapi(nv12) + // convert whatever to nv12 on GPU // scale_vaapi doesn't support passthrough option, so it has to make a copy + // } + // See https://www.reddit.com/r/ffmpeg/comments/1bqn60w/hardware_accelerated_decoding_without_hwdownload/ for more info + ScaleFilter: "format=nv12|vaapi,hwupload,scale_vaapi=%d:%d:format=nv12", + WithForcedIdr: true, + } + case "qsv", "intel": + return HwAccelSettings{ + Name: name, + DecodeFlags: []string{ + "-hwaccel", "qsv", + "-qsv_device", GetEnvOr("SEANIME_TRANSCODER_QSV_RENDERER", defaultOSDevice), + "-hwaccel_output_format", "qsv", + }, + EncodeFlags: []string{ + "-c:v", "h264_qsv", + "-preset", preset, + }, + // see note on ScaleFilter of the vaapi HwAccel, this is the same filter but adapted to qsv + ScaleFilter: "format=nv12|qsv,hwupload,scale_qsv=%d:%d:format=nv12", + WithForcedIdr: true, + } + case "nvidia": + return HwAccelSettings{ + Name: "nvidia", + DecodeFlags: []string{ + "-hwaccel", "cuda", + // this flag prevents data to go from gpu space to cpu space + // it forces the whole dec/enc to be on the gpu. We want that. + "-hwaccel_output_format", "cuda", + }, + EncodeFlags: []string{ + "-c:v", "h264_nvenc", + "-preset", preset, + // the exivalent of -sc_threshold on nvidia. + "-no-scenecut", "1", + }, + // see note on ScaleFilter of the vaapi HwAccel, this is the same filter but adapted to cuda + ScaleFilter: "format=nv12|cuda,hwupload,scale_cuda=%d:%d:format=nv12", + WithForcedIdr: true, + } + case "videotoolbox": + return HwAccelSettings{ + Name: "videotoolbox", + DecodeFlags: []string{ + "-hwaccel", "videotoolbox", + }, + EncodeFlags: []string{ + "-c:v", "h264_videotoolbox", + "-profile:v", "main", + }, + ScaleFilter: "scale=%d:%d", + WithForcedIdr: true, + } + case "custom": + return customHwAccelSettings + default: + streamLogger.Fatal().Msgf("No hardware accelerator named: %s", name) + panic("unreachable") + } +} diff --git a/seanime-2.9.10/internal/mediastream/transcoder/keyframes.go b/seanime-2.9.10/internal/mediastream/transcoder/keyframes.go new file mode 100644 index 0000000..84aacbf --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/keyframes.go @@ -0,0 +1,212 @@ +package transcoder + +import ( + "bufio" + "path/filepath" + "seanime/internal/mediastream/videofile" + "seanime/internal/util" + "seanime/internal/util/result" + "strconv" + "strings" + "sync" + + "github.com/rs/zerolog" +) + +type Keyframe struct { + Sha string + Keyframes []float64 + IsDone bool + info *KeyframeInfo +} +type KeyframeInfo struct { + mutex sync.RWMutex + ready sync.WaitGroup + listeners []func(keyframes []float64) +} + +func (kf *Keyframe) Get(idx int32) float64 { + kf.info.mutex.RLock() + defer kf.info.mutex.RUnlock() + return kf.Keyframes[idx] +} + +func (kf *Keyframe) Slice(start int32, end int32) []float64 { + if end <= start { + return []float64{} + } + kf.info.mutex.RLock() + defer kf.info.mutex.RUnlock() + ref := kf.Keyframes[start:end] + ret := make([]float64, end-start) + copy(ret, ref) + return ret +} + +func (kf *Keyframe) Length() (int32, bool) { + kf.info.mutex.RLock() + defer kf.info.mutex.RUnlock() + return int32(len(kf.Keyframes)), kf.IsDone +} + +func (kf *Keyframe) add(values []float64) { + kf.info.mutex.Lock() + defer kf.info.mutex.Unlock() + kf.Keyframes = append(kf.Keyframes, values...) + for _, listener := range kf.info.listeners { + listener(kf.Keyframes) + } +} + +func (kf *Keyframe) AddListener(callback func(keyframes []float64)) { + kf.info.mutex.Lock() + defer kf.info.mutex.Unlock() + kf.info.listeners = append(kf.info.listeners, callback) +} + +var keyframes = result.NewResultMap[string, *Keyframe]() + +func GetKeyframes( + path string, + hash string, + logger *zerolog.Logger, + settings *Settings, +) *Keyframe { + ret, _ := keyframes.GetOrSet(hash, func() (*Keyframe, error) { + kf := &Keyframe{ + Sha: hash, + IsDone: false, + info: &KeyframeInfo{}, + } + kf.info.ready.Add(1) + go func() { + keyframesPath := filepath.Join(settings.StreamDir, hash, "keyframes.json") + if err := getSavedInfo(keyframesPath, kf); err == nil { + logger.Trace().Msgf("transcoder: Keyframes Cache HIT") + kf.info.ready.Done() + return + } + + err := getKeyframes(settings.FfprobePath, path, kf, hash, logger) + if err == nil { + saveInfo(keyframesPath, kf) + } + }() + return kf, nil + }) + ret.info.ready.Wait() + return ret +} + +func getKeyframes(ffprobePath string, path string, kf *Keyframe, hash string, logger *zerolog.Logger) error { + defer printExecTime(logger, "ffprobe analysis for %s", path)() + // Execute ffprobe to retrieve all IFrames. IFrames are specific points in the video we can divide it into segments. + // We instruct ffprobe to return the timestamp and flags of each frame. + // Although it's possible to request ffprobe to return only i-frames (keyframes) using the -skip_frame nokey option, this approach is highly inefficient. + // The inefficiency arises because when this option is used, ffmpeg processes every single frame, which significantly slows down the operation. + cmd := util.NewCmd( + "ffprobe", + "-loglevel", "error", + "-select_streams", "v:0", + "-show_entries", "packet=pts_time,flags", + "-of", "csv=print_section=0", + path, + ) + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + err = cmd.Start() + if err != nil { + return err + } + + scanner := bufio.NewScanner(stdout) + + ret := make([]float64, 0, 1000) + max := 100 + done := 0 + for scanner.Scan() { + frame := scanner.Text() + if frame == "" { + continue + } + + x := strings.Split(frame, ",") + pts, flags := x[0], x[1] + + // if no video track + if pts == "N/A" { + break + } + + // Only take keyframes + if flags[0] != 'K' { + continue + } + + fpts, err := strconv.ParseFloat(pts, 64) + if err != nil { + return err + } + + // Previously, the aim was to save only those keyframes that had a minimum gap of 3 seconds between them. + // This was to avoid creating segments as short as 0.2 seconds. + // However, there were instances where the -f segment muxer would ignore the specified segment time and choose a random keyframe to cut at. + // To counter this, treat every keyframe as a potential segment. + //if done == 0 && len(ret) == 0 { + // + // // There are instances where videos may not start exactly at 0:00. This needs to be considered, + // // and we should only include keyframes that occur after the video's start time. If not done so, + // // it can lead to a discrepancy in our segment count and potentially duplicate the same segment in the stream. + // + // // For simplicity in code comprehension, we designate 0 as the initial keyframe, even though it's not genuine. + // // This value is never actually passed to ffmpeg. + // ret = append(ret, 0) + // continue + //} + ret = append(ret, fpts) + + if len(ret) == max { + kf.add(ret) + if done == 0 { + kf.info.ready.Done() + } else if done >= 500 { + max = 500 + } + done += max + // clear the array without reallocing it + ret = ret[:0] + } + } + + // If there is less than 2 (i.e. equals 0 or 1 (it happens for audio files with poster)) + if len(ret) < 2 { + dummy, err := getDummyKeyframes(ffprobePath, path, hash) + if err != nil { + return err + } + ret = dummy + } + + kf.add(ret) + if done == 0 { + kf.info.ready.Done() + } + kf.IsDone = true + return nil +} + +func getDummyKeyframes(ffprobePath string, path string, sha string) ([]float64, error) { + dummyKeyframeDuration := float64(2) + info, err := videofile.FfprobeGetInfo(ffprobePath, path, sha) + if err != nil { + return nil, err + } + segmentCount := int((float64(info.Duration) / dummyKeyframeDuration) + 1) + ret := make([]float64, segmentCount) + for segmentIndex := 0; segmentIndex < segmentCount; segmentIndex += 1 { + ret[segmentIndex] = float64(segmentIndex) * dummyKeyframeDuration + } + return ret, nil +} diff --git a/seanime-2.9.10/internal/mediastream/transcoder/quality.go b/seanime-2.9.10/internal/mediastream/transcoder/quality.go new file mode 100644 index 0000000..77c212b --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/quality.go @@ -0,0 +1,121 @@ +package transcoder + +import ( + "errors" +) + +type Quality string + +const ( + P240 Quality = "240p" + P360 Quality = "360p" + P480 Quality = "480p" + P720 Quality = "720p" + P1080 Quality = "1080p" + P1440 Quality = "1440p" + P4k Quality = "4k" + P8k Quality = "8k" + Original Quality = "original" +) + +// Qualities +// Original is not included in this list because it is a special case +var Qualities = []Quality{P240, P360, P480, P720, P1080, P1440, P4k, P8k} + +func QualityFromString(str string) (Quality, error) { + if str == string(Original) { + return Original, nil + } + + qualities := Qualities + for _, quality := range qualities { + if string(quality) == str { + return quality, nil + } + } + return Original, errors.New("invalid quality string") +} + +// AverageBitrate +// Note: Not accurate +func (q Quality) AverageBitrate() uint32 { + switch q { + case P240: + return 400_000 + case P360: + return 800_000 + case P480: + return 1_200_000 + case P720: + return 2_400_000 + case P1080: + return 4_800_000 + case P1440: + return 9_600_000 + case P4k: + return 16_000_000 + case P8k: + return 28_000_000 + case Original: + panic("Original quality must be handled specially") + } + panic("Invalid quality value") +} + +func (q Quality) MaxBitrate() uint32 { + switch q { + case P240: + return 700_000 + case P360: + return 1_400_000 + case P480: + return 2_100_000 + case P720: + return 4_000_000 + case P1080: + return 8_000_000 + case P1440: + return 12_000_000 + case P4k: + return 28_000_000 + case P8k: + return 40_000_000 + case Original: + panic("Original quality must be handled specially") + } + panic("Invalid quality value") +} + +func (q Quality) Height() uint32 { + switch q { + case P240: + return 240 + case P360: + return 360 + case P480: + return 480 + case P720: + return 720 + case P1080: + return 1080 + case P1440: + return 1440 + case P4k: + return 2160 + case P8k: + return 4320 + case Original: + panic("Original quality must be handled specially") + } + panic("Invalid quality value") +} + +func QualityFromHeight(height uint32) Quality { + qualities := Qualities + for _, quality := range qualities { + if quality.Height() >= height { + return quality + } + } + return P240 +} diff --git a/seanime-2.9.10/internal/mediastream/transcoder/settings.go b/seanime-2.9.10/internal/mediastream/transcoder/settings.go new file mode 100644 index 0000000..49c88e4 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/settings.go @@ -0,0 +1,19 @@ +package transcoder + +import "os" + +func GetEnvOr(env string, def string) string { + out := os.Getenv(env) + if out == "" { + return def + } + return out +} + +type HwAccelSettings struct { + Name string `json:"name"` + DecodeFlags []string `json:"decodeFlags"` + EncodeFlags []string `json:"encodeFlags"` + ScaleFilter string `json:"scaleFilter"` + WithForcedIdr bool `json:"removeForcedIdr"` +} diff --git a/seanime-2.9.10/internal/mediastream/transcoder/stream.go b/seanime-2.9.10/internal/mediastream/transcoder/stream.go new file mode 100644 index 0000000..af5a7f7 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/stream.go @@ -0,0 +1,667 @@ +package transcoder + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "math" + "os" + "os/exec" + "path/filepath" + "seanime/internal/util" + "slices" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" + "github.com/samber/lo" + lop "github.com/samber/lo/parallel" +) + +type Flags int32 + +const ( + AudioF Flags = 1 << 0 + VideoF Flags = 1 << 1 + Transmux Flags = 1 << 3 +) + +type StreamHandle interface { + getTranscodeArgs(segments string) []string + getOutPath(encoderId int) string + getFlags() Flags +} + +type Stream struct { + kind string + handle StreamHandle + file *FileStream + segments []Segment + heads []Head + // the lock used for the heads + //lock sync.RWMutex + + segmentsLock sync.RWMutex + headsLock sync.RWMutex + + logger *zerolog.Logger + settings *Settings + killCh chan struct{} + ctx context.Context + cancel context.CancelFunc +} + +type Segment struct { + // channel open if the segment is not ready. closed if ready. + // one can check if segment 1 is open by doing: + // + // ts.isSegmentReady(1). + // + // You can also wait for it to be ready (non-blocking if already ready) by doing: + // <-ts.segments[i] + channel chan struct{} + encoder int +} + +type Head struct { + segment int32 + end int32 + command *exec.Cmd + stdin io.WriteCloser +} + +var DeletedHead = Head{ + segment: -1, + end: -1, + command: nil, +} + +var streamLogger = util.NewLogger() + +func NewStream( + kind string, + file *FileStream, + handle StreamHandle, + ret *Stream, + settings *Settings, + logger *zerolog.Logger, +) { + ret.kind = kind + ret.handle = handle + ret.file = file + ret.heads = make([]Head, 0) + ret.settings = settings + ret.logger = logger + ret.killCh = make(chan struct{}) + ret.ctx, ret.cancel = context.WithCancel(context.Background()) + + length, isDone := file.Keyframes.Length() + ret.segments = make([]Segment, length, max(length, 2000)) + for seg := range ret.segments { + ret.segments[seg].channel = make(chan struct{}) + } + + if !isDone { + file.Keyframes.AddListener(func(keyframes []float64) { + ret.segmentsLock.Lock() + defer ret.segmentsLock.Unlock() + oldLength := len(ret.segments) + if cap(ret.segments) > len(keyframes) { + ret.segments = ret.segments[:len(keyframes)] + } else { + ret.segments = append(ret.segments, make([]Segment, len(keyframes)-oldLength)...) + } + for seg := oldLength; seg < len(keyframes); seg++ { + ret.segments[seg].channel = make(chan struct{}) + } + }) + } +} + +func (ts *Stream) GetIndex() (string, error) { + // playlist type is event since we can append to the list if Keyframe.IsDone is false. + // start time offset makes the stream start at 0s instead of ~3segments from the end (requires version 6 of hls) + index := `#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-PLAYLIST-TYPE:EVENT +#EXT-X-START:TIME-OFFSET=0 +#EXT-X-TARGETDURATION:4 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-INDEPENDENT-SEGMENTS +` + length, isDone := ts.file.Keyframes.Length() + + for segment := int32(0); segment < length-1; segment++ { + index += fmt.Sprintf("#EXTINF:%.6f\n", ts.file.Keyframes.Get(segment+1)-ts.file.Keyframes.Get(segment)) + index += fmt.Sprintf("segment-%d.ts\n", segment) + } + // do not forget to add the last segment between the last keyframe and the end of the file + // if the keyframes extraction is not done, do not bother to add it, it will be retrived on the next index retrival + if isDone { + index += fmt.Sprintf("#EXTINF:%.6f\n", float64(ts.file.Info.Duration)-ts.file.Keyframes.Get(length-1)) + index += fmt.Sprintf("segment-%d.ts\n", length-1) + index += `#EXT-X-ENDLIST` + } + return index, nil +} + +// GetSegment returns the path to the segment and waits for it to be ready. +func (ts *Stream) GetSegment(segment int32) (string, error) { + // DEVNOTE: Reset the kill channel + // This is needed because when the segment is needed again, this channel should be open + ts.killCh = make(chan struct{}) + if debugStream { + streamLogger.Trace().Msgf("transcoder: Getting segment %d [GetSegment]", segment) + defer streamLogger.Trace().Msgf("transcoder: Retrieved segment %d [GetSegment]", segment) + } + + ts.segmentsLock.RLock() + ts.headsLock.RLock() + ready := ts.isSegmentReady(segment) + // we want to calculate distance in the same lock else it can be funky + distance := 0. + isScheduled := false + if !ready { + distance = ts.getMinEncoderDistance(segment) + for _, head := range ts.heads { + if head.segment <= segment && segment < head.end { + isScheduled = true + break + } + } + } + readyChan := ts.segments[segment].channel + + ts.segmentsLock.RUnlock() + ts.headsLock.RUnlock() + + if !ready { + // Only start a new encode if there is too big a distance between the current encoder and the segment. + if distance > 60 || !isScheduled { + streamLogger.Trace().Msgf("transcoder: New encoder for segment %d", segment) + err := ts.run(segment) + if err != nil { + return "", err + } + } else { + streamLogger.Trace().Msgf("transcoder: Awaiting segment %d - %.2fs gap", segment, distance) + } + + select { + // DEVNOTE: This can cause issues if the segment is called again but was "killed" beforehand + // It's used to interrupt the waiting process but might not be needed since there's a timeout + case <-ts.killCh: + return "", fmt.Errorf("transcoder: Stream killed while waiting for segment %d", segment) + case <-readyChan: + break + case <-time.After(25 * time.Second): + streamLogger.Error().Msgf("transcoder: Could not retrieve %s segment %d (timeout)", ts.kind, segment) + return "", errors.New("could not retrieve segment (timeout)") + } + } + //go ts.prepareNextSegments(segment) + ts.prepareNextSegments(segment) + return fmt.Sprintf(filepath.ToSlash(ts.handle.getOutPath(ts.segments[segment].encoder)), segment), nil +} + +// prepareNextSegments will start the next segments if they are not already started. +func (ts *Stream) prepareNextSegments(segment int32) { + //if ts.IsKilled() { + // return + //} + // Audio is way cheaper to create than video, so we don't need to run them in advance + // Running it in advance might actually slow down the video encode since less compute + // power can be used, so we simply disable that. + if ts.handle.getFlags()&VideoF == 0 { + return + } + + ts.segmentsLock.RLock() + defer ts.segmentsLock.RUnlock() + ts.headsLock.RLock() + defer ts.headsLock.RUnlock() + + for i := segment + 1; i <= min(segment+10, int32(len(ts.segments)-1)); i++ { + // If the segment is already ready, we don't need to start a new encoder. + if ts.isSegmentReady(i) { + continue + } + // only start encode for segments not planned (getMinEncoderDistance returns Inf for them) + // or if they are 60s away (assume 5s per segments) + if ts.getMinEncoderDistance(i) < 60+(5*float64(i-segment)) { + continue + } + streamLogger.Trace().Msgf("transcoder: Creating new encoder head for future segment %d", i) + go func() { + _ = ts.run(i) + }() + return + } +} + +func (ts *Stream) getMinEncoderDistance(segment int32) float64 { + t := ts.file.Keyframes.Get(segment) + distances := lop.Map(ts.heads, func(head Head, _ int) float64 { + // ignore killed heads or heads after the current time + if head.segment < 0 || ts.file.Keyframes.Get(head.segment) > t || segment >= head.end { + return math.Inf(1) + } + return t - ts.file.Keyframes.Get(head.segment) + }) + if len(distances) == 0 { + return math.Inf(1) + } + return slices.Min(distances) +} + +func (ts *Stream) Kill() { + streamLogger.Trace().Msgf("transcoder: Killing %s stream", ts.kind) + defer streamLogger.Trace().Msg("transcoder: Stream killed") + ts.lockHeads() + defer ts.unlockHeads() + + for id := range ts.heads { + ts.KillHead(id) + } +} + +func (ts *Stream) IsKilled() bool { + select { + case <-ts.killCh: + // if the channel returned, it means it was closed + return true + default: + return false + } +} + +// KillHead +// Stream is assumed to be locked +func (ts *Stream) KillHead(encoderId int) { + //streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: Killing %s encoder head", ts.kind) + defer streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: Killed %s encoder head", ts.kind) + defer func() { + if r := recover(); r != nil { + } + }() + close(ts.killCh) + ts.cancel() + if ts.heads[encoderId] == DeletedHead || ts.heads[encoderId].command == nil { + return + } + ts.heads[encoderId].command.Process.Signal(os.Interrupt) + //_, _ = ts.heads[encoderId].stdin.Write([]byte("q")) + //_ = ts.heads[encoderId].stdin.Close() + + ts.heads[encoderId] = DeletedHead +} + +func (ts *Stream) SetIsKilled() { +} + +////////////////////////////// + +// Remember to lock before calling this. +func (ts *Stream) isSegmentReady(segment int32) bool { + select { + case <-ts.segments[segment].channel: + // if the channel returned, it means it was closed + return true + default: + return false + } +} + +func (ts *Stream) isSegmentTranscoding(segment int32) bool { + for _, head := range ts.heads { + if head.segment == segment { + return true + } + } + return false +} + +func toSegmentStr(segments []float64) string { + return strings.Join(lo.Map(segments, func(seg float64, _ int) string { + return fmt.Sprintf("%.6f", seg) + }), ",") +} + +func (ts *Stream) run(start int32) error { + //if ts.IsKilled() { + // return nil + //} + ts.logger.Trace().Msgf("transcoder: Running %s encoder head from %d", ts.kind, start) + // Start the transcoder up to the 100th segment (or less) + length, isDone := ts.file.Keyframes.Length() + end := min(start+100, length) + // if keyframes analysis is not finished, always have a 1-segment padding + // for the extra segment needed for precise split (look comment before -to flag) + if !isDone { + end -= 2 + } + // Stop at the first finished segment + ts.lockSegments() + for i := start; i < end; i++ { + if ts.isSegmentReady(i) || ts.isSegmentTranscoding(i) { + end = i + break + } + } + if start >= end { + // this can happen if the start segment was finished between the check + // to call run() and the actual call. + // since most checks are done in a RLock() instead of a Lock() this can + // happens when two goroutines try to make the same segment ready + ts.unlockSegments() + return nil + } + ts.unlockSegments() + + ts.lockHeads() + encoderId := len(ts.heads) + ts.heads = append(ts.heads, Head{segment: start, end: end, command: nil}) + ts.unlockHeads() + + streamLogger.Trace().Any("eid", encoderId).Msgf( + "transcoder: Transcoding %d-%d/%d segments for %s", + start, + end, + length, + ts.kind, + ) + + // Include both the start and end delimiter because -ss and -to are not accurate + // Having an extra segment allows us to cut precisely the segments we want with the + // -f segment that does cut the beginning and the end at the keyframe like asked + startRef := float64(0) + startSeg := start + if start != 0 { + // we always take on segment before the current one, for different reasons for audio/video: + // - Audio: we need context before the starting point, without that ffmpeg doesn't know what to do and leave ~100ms of silence + // - Video: if a segment is really short (between 20 and 100ms), the padding given in the else block bellow is not enough and + // the previous segment is played another time. the -segment_times is way more precise, so it does not do the same with this one + startSeg = start - 1 + if ts.handle.getFlags()&AudioF != 0 { + startRef = ts.file.Keyframes.Get(startSeg) + } else { + // the param for the -ss takes the keyframe before the specified time + // (if the specified time is a keyframe, it either takes that keyframe or the one before) + // to prevent this weird behavior, we specify a bit after the keyframe that interest us + + // this can't be used with audio since we need to have context before the start-time + // without this context, the cut loses a bit of audio (audio gap of ~100ms) + if startSeg+1 == length { + startRef = (ts.file.Keyframes.Get(startSeg) + float64(ts.file.Info.Duration)) / 2 + } else { + startRef = (ts.file.Keyframes.Get(startSeg) + ts.file.Keyframes.Get(startSeg+1)) / 2 + } + } + } + endPadding := int32(1) + if end == length { + endPadding = 0 + } + segments := ts.file.Keyframes.Slice(start+1, end+endPadding) + if len(segments) == 0 { + // we can't leave that empty else ffmpeg errors out. + segments = []float64{9999999} + } + + outpath := ts.handle.getOutPath(encoderId) + err := os.MkdirAll(filepath.Dir(outpath), 0755) + if err != nil { + return err + } + + args := []string{ + "-nostats", "-hide_banner", "-loglevel", "warning", + } + + args = append(args, ts.settings.HwAccel.DecodeFlags...) + + if startRef != 0 { + if ts.handle.getFlags()&VideoF != 0 { + // This is the default behavior in transmux mode and needed to force pre/post segment to work + // This must be disabled when processing only audio because it creates gaps in audio + args = append(args, "-noaccurate_seek") + } + args = append(args, + "-ss", fmt.Sprintf("%.6f", startRef), + ) + } + // do not include -to if we want the file to go to the end + if end+1 < length { + // sometimes, the duration is shorter than expected (only during transcode it seems) + // always include more and use the -f segment to split the file where we want + endRef := ts.file.Keyframes.Get(end + 1) + // it seems that the -to is confused when -ss seek before the given time (because it searches for a keyframe) + // add back the time that would be lost otherwise + // this only happens when -to is before -i but having -to after -i gave a bug (not sure, don't remember) + endRef += startRef - ts.file.Keyframes.Get(startSeg) + args = append(args, + "-to", fmt.Sprintf("%.6f", endRef), + ) + } + args = append(args, + "-i", ts.file.Path, + // this makes behaviors consistent between soft and hardware decodes. + // this also means that after a -ss 50, the output video will start at 50s + "-start_at_zero", + // for hls streams, -copyts is mandatory + "-copyts", + // this makes output file start at 0s instead of a random delay + the -ss value + // this also cancel -start_at_zero weird delay. + // this is not always respected, but generally it gives better results. + // even when this is not respected, it does not result in a bugged experience but this is something + // to keep in mind when debugging + "-muxdelay", "0", + ) + args = append(args, ts.handle.getTranscodeArgs(toSegmentStr(segments))...) + args = append(args, + "-f", "segment", + // needed for rounding issues when forcing keyframes + // recommended value is 1/(2*frame_rate), which for a 24fps is ~0.021 + // we take a little bit more than that to be extra safe but too much can be harmful + // when segments are short (can make the video repeat itself) + "-segment_time_delta", "0.05", + "-segment_format", "mpegts", + "-segment_times", toSegmentStr(lop.Map(segments, func(seg float64, _ int) float64 { + // segment_times want durations, not timestamps so we must substract the -ss param + // since we give a greater value to -ss to prevent wrong seeks but -segment_times + // needs precise segments, we use the keyframe we want to seek to as a reference. + return seg - ts.file.Keyframes.Get(startSeg) + })), + "-segment_list_type", "flat", + "-segment_list", "pipe:1", + "-segment_start_number", fmt.Sprint(start), + outpath, + ) + + // Added logging for ffmpeg command and hardware transcoding state + streamLogger.Trace().Msgf("transcoder: ffmpeg command: %s %s", ts.settings.FfmpegPath, strings.Join(args, " ")) + if len(ts.settings.HwAccel.DecodeFlags) > 0 { + streamLogger.Trace().Msgf("transcoder: Hardware transcoding enabled with flags: %v", ts.settings.HwAccel.DecodeFlags) + } else { + streamLogger.Trace().Msg("transcoder: Hardware transcoding not enabled") + } + + cmd := util.NewCmdCtx(context.Background(), ts.settings.FfmpegPath, args...) + streamLogger.Trace().Msgf("transcoder: Executing ffmpeg for segments %d-%d of %s", start, end, ts.kind) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + var stderr strings.Builder + cmd.Stderr = &stderr + + err = cmd.Start() + if err != nil { + return err + } + ts.lockHeads() + ts.heads[encoderId].command = cmd + ts.heads[encoderId].stdin = stdin + ts.unlockHeads() + + go func(stdin io.WriteCloser) { + scanner := bufio.NewScanner(stdout) + format := filepath.Base(outpath) + shouldStop := false + + for scanner.Scan() { + var segment int32 + _, _ = fmt.Sscanf(scanner.Text(), format, &segment) + + // If the segment number is less than the starting segment (start), it means it's not relevant for the current processing, so we skip it + if segment < start { + // This happens because we use -f segments for accurate cutting (since -ss is not) + // check comment at beginning of function for more info + continue + } + ts.lockHeads() + ts.heads[encoderId].segment = segment + ts.unlockHeads() + if debugFfmpegOutput { + streamLogger.Debug().Int("eid", encoderId).Msgf("t: \t ffmpeg finished segment %d/%d (%d-%d) of %s", segment, end, start, end, ts.kind) + } + + ts.lockSegments() + // If the segment is already marked as done, we can stop the ffmpeg process + if ts.isSegmentReady(segment) { + // the current segment is already marked as done so another process has already gone up to here. + _, _ = stdin.Write([]byte("q")) + _ = stdin.Close() + //cmd.Process.Signal(os.Interrupt) + if debugFfmpeg { + streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: Terminated ffmpeg, segment %d is ready", segment) + } + shouldStop = true + } else { + // Mark the segment as ready + ts.segments[segment].encoder = encoderId + close(ts.segments[segment].channel) + if segment == end-1 { + // file finished, ffmpeg will finish soon on its own + shouldStop = true + } else if ts.isSegmentReady(segment + 1) { + // If the next segment is already marked as done, we can stop the ffmpeg process + _, _ = stdin.Write([]byte("q")) + _ = stdin.Close() + //cmd.Process.Signal(os.Interrupt) + if debugFfmpeg { + streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: Terminated ffmpeg, next segment %d is ready", segment) + } + shouldStop = true + } + } + ts.unlockSegments() + // we need this and not a return in the condition because we want to unlock + // the lock (and can't defer since this is a loop) + if shouldStop { + if debugFfmpeg { + streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: ffmpeg completed segments %d-%d/%d of %s", start, end, length, ts.kind) + } + return + } + } + + if err := scanner.Err(); err != nil { + streamLogger.Error().Int("eid", encoderId).Err(err).Msg("transcoder: Error scanning ffmpeg output") + return + } + }(stdin) + + // Listen for kill signal + go func(stdin io.WriteCloser) { + select { + case <-ts.ctx.Done(): + streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: Aborting ffmpeg process for %s", ts.kind) + _, _ = stdin.Write([]byte("q")) + _ = stdin.Close() + return + } + }(stdin) + + // Listen for process termination + go func() { + err := cmd.Wait() + var exitErr *exec.ExitError + // Check if hardware acceleration was attempted and if stderr indicates a failure to use it + if len(ts.settings.HwAccel.DecodeFlags) > 0 { + lowerOutput := strings.ToLower(stderr.String()) + if strings.Contains(lowerOutput, "failed") && + (strings.Contains(lowerOutput, "hwaccel") || strings.Contains(lowerOutput, "vaapi") || strings.Contains(lowerOutput, "cuvid") || strings.Contains(lowerOutput, "vdpau")) { + streamLogger.Warn().Int("eid", encoderId).Msg("transcoder: ffmpeg failed to use hardware acceleration settings; falling back to CPU") + } + } + + if errors.As(err, &exitErr) && exitErr.ExitCode() == 255 { + streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: ffmpeg process was terminated") + } else if err != nil { + streamLogger.Error().Int("eid", encoderId).Err(fmt.Errorf("%s: %s", err, stderr.String())).Msgf("transcoder: ffmpeg process failed") + } else { + streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: ffmpeg process for %s exited", ts.kind) + } + + ts.lockHeads() + defer ts.unlockHeads() + // we can't delete the head directly because it would invalidate the others encoderId + ts.heads[encoderId] = DeletedHead + }() + + return nil +} + +const debugLocks = false +const debugFfmpeg = true +const debugFfmpegOutput = false +const debugStream = false + +func (ts *Stream) lockHeads() { + if debugLocks { + streamLogger.Debug().Msg("t: Locking heads") + } + ts.headsLock.Lock() + if debugLocks { + streamLogger.Debug().Msg("t: \t\tLocked heads") + } +} + +func (ts *Stream) unlockHeads() { + if debugLocks { + streamLogger.Debug().Msg("t: Unlocking heads") + } + ts.headsLock.Unlock() + if debugLocks { + streamLogger.Debug().Msg("t: \t\tUnlocked heads") + } +} + +func (ts *Stream) lockSegments() { + if debugLocks { + streamLogger.Debug().Msg("t: Locking segments") + } + ts.segmentsLock.Lock() + if debugLocks { + streamLogger.Debug().Msg("t: \t\tLocked segments") + } +} + +func (ts *Stream) unlockSegments() { + if debugLocks { + streamLogger.Debug().Msg("t: Unlocking segments") + } + ts.segmentsLock.Unlock() + if debugLocks { + streamLogger.Debug().Msg("t: \t\tUnlocked segments") + } +} diff --git a/seanime-2.9.10/internal/mediastream/transcoder/tracker.go b/seanime-2.9.10/internal/mediastream/transcoder/tracker.go new file mode 100644 index 0000000..b04ace7 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/tracker.go @@ -0,0 +1,249 @@ +package transcoder + +import ( + "time" + + "github.com/rs/zerolog" +) + +type ClientInfo struct { + client string + path string + quality *Quality + audio int32 + head int32 +} + +type Tracker struct { + // key: client_id + clients map[string]ClientInfo + // key: client_id + visitDate map[string]time.Time + // key: path + lastUsage map[string]time.Time + transcoder *Transcoder + deletedStream chan string + logger *zerolog.Logger + killCh chan struct{} // Close channel to stop tracker +} + +func NewTracker(t *Transcoder) *Tracker { + ret := &Tracker{ + clients: make(map[string]ClientInfo), + visitDate: make(map[string]time.Time), + lastUsage: make(map[string]time.Time), + transcoder: t, + logger: t.logger, + deletedStream: make(chan string, 1000), + killCh: make(chan struct{}), + } + go ret.start() + return ret +} + +func (t *Tracker) Stop() { + close(t.killCh) +} + +func Abs(x int32) int32 { + if x < 0 { + return -x + } + return x +} + +func (t *Tracker) start() { + inactiveTime := 1 * time.Hour + timer := time.NewTicker(inactiveTime) + defer timer.Stop() + for { + select { + case <-t.killCh: + return + case info, ok := <-t.transcoder.clientChan: + if !ok { + return + } + + old, ok := t.clients[info.client] + // First fixup the info. Most routes return partial infos + if ok && old.path == info.path { + if info.quality == nil { + info.quality = old.quality + } + if info.audio == -1 { + info.audio = old.audio + } + if info.head == -1 { + info.head = old.head + } + } + + t.clients[info.client] = info + t.visitDate[info.client] = time.Now() + t.lastUsage[info.path] = time.Now() + + // now that the new info is stored and fixed, kill old streams + if ok && old.path == info.path { + if old.audio != info.audio && old.audio != -1 { + t.KillAudioIfDead(old.path, old.audio) + } + if old.quality != info.quality && old.quality != nil { + t.KillQualityIfDead(old.path, *old.quality) + } + if old.head != -1 && Abs(info.head-old.head) > 100 { + t.KillOrphanedHeads(old.path, old.quality, old.audio) + } + } else if ok { + t.KillStreamIfDead(old.path) + } + + case <-timer.C: + // Purge old clients + for client, date := range t.visitDate { + if time.Since(date) < inactiveTime { + continue + } + + info := t.clients[client] + + if !t.KillStreamIfDead(info.path) { + audioCleanup := info.audio != -1 && t.KillAudioIfDead(info.path, info.audio) + videoCleanup := info.quality != nil && t.KillQualityIfDead(info.path, *info.quality) + if !audioCleanup || !videoCleanup { + t.KillOrphanedHeads(info.path, info.quality, info.audio) + } + } + + delete(t.clients, client) + delete(t.visitDate, client) + } + case path := <-t.deletedStream: + t.DestroyStreamIfOld(path) + } + } +} + +func (t *Tracker) KillStreamIfDead(path string) bool { + for _, stream := range t.clients { + if stream.path == path { + return false + } + } + t.logger.Trace().Msgf("Killing stream %s", path) + + stream, ok := t.transcoder.streams.Get(path) + if !ok { + return false + } + stream.Kill() + go func() { + select { + case <-t.killCh: + return + case <-time.After(4 * time.Hour): + t.deletedStream <- path + } + //time.Sleep(4 * time.Hour) + //t.deletedStream <- path + }() + return true +} + +func (t *Tracker) DestroyStreamIfOld(path string) { + if time.Since(t.lastUsage[path]) < 4*time.Hour { + return + } + stream, ok := t.transcoder.streams.Get(path) + if !ok { + return + } + t.transcoder.streams.Delete(path) + stream.Destroy() +} + +func (t *Tracker) KillAudioIfDead(path string, audio int32) bool { + for _, stream := range t.clients { + if stream.path == path && stream.audio == audio { + return false + } + } + t.logger.Trace().Msgf("Killing audio %d of %s", audio, path) + + stream, ok := t.transcoder.streams.Get(path) + if !ok { + return false + } + astream, aok := stream.audios.Get(audio) + if !aok { + return false + } + astream.Kill() + return true +} + +func (t *Tracker) KillQualityIfDead(path string, quality Quality) bool { + for _, stream := range t.clients { + if stream.path == path && stream.quality != nil && *stream.quality == quality { + return false + } + } + //start := time.Now() + t.logger.Trace().Msgf("transcoder: Killing %s video stream ", quality) + + stream, ok := t.transcoder.streams.Get(path) + if !ok { + return false + } + vstream, vok := stream.videos.Get(quality) + if !vok { + return false + } + vstream.Kill() + + //t.logger.Trace().Msgf("transcoder: Killed %s video stream in %.2fs", quality, time.Since(start).Seconds()) + return true +} + +func (t *Tracker) KillOrphanedHeads(path string, quality *Quality, audio int32) { + stream, ok := t.transcoder.streams.Get(path) + if !ok { + return + } + + if quality != nil { + vstream, vok := stream.videos.Get(*quality) + if vok { + t.killOrphanedHeads(&vstream.Stream) + } + } + if audio != -1 { + astream, aok := stream.audios.Get(audio) + if aok { + t.killOrphanedHeads(&astream.Stream) + } + } +} + +func (t *Tracker) killOrphanedHeads(stream *Stream) { + stream.headsLock.RLock() + defer stream.headsLock.RUnlock() + + for encoderId, head := range stream.heads { + if head == DeletedHead { + continue + } + + distance := int32(99999) + for _, info := range t.clients { + if info.head == -1 { + continue + } + distance = min(Abs(info.head-head.segment), distance) + } + if distance > 20 { + t.logger.Trace().Msgf("transcoder: Killing orphaned head %d", encoderId) + stream.KillHead(encoderId) + } + } +} diff --git a/seanime-2.9.10/internal/mediastream/transcoder/transcoder.go b/seanime-2.9.10/internal/mediastream/transcoder/transcoder.go new file mode 100644 index 0000000..237ab8c --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/transcoder.go @@ -0,0 +1,247 @@ +package transcoder + +import ( + "fmt" + "os" + "path" + "path/filepath" + "seanime/internal/mediastream/videofile" + "seanime/internal/util/result" + "time" + + "github.com/rs/zerolog" +) + +type ( + Transcoder struct { + // All file streams currently running, index is file path + streams *result.Map[string, *FileStream] + clientChan chan ClientInfo + tracker *Tracker + logger *zerolog.Logger + settings Settings + } + + Settings struct { + StreamDir string + HwAccel HwAccelSettings + FfmpegPath string + FfprobePath string + } + + NewTranscoderOptions struct { + Logger *zerolog.Logger + HwAccelKind string + Preset string + TempOutDir string + FfmpegPath string + FfprobePath string + HwAccelCustomSettings string + } +) + +func NewTranscoder(opts *NewTranscoderOptions) (*Transcoder, error) { + + // Create a directory that'll hold the stream segments if it doesn't exist + streamDir := filepath.Join(opts.TempOutDir, "streams") + _ = os.MkdirAll(streamDir, 0755) + + // Clear the directory containing the streams + dir, err := os.ReadDir(streamDir) + if err != nil { + return nil, err + } + for _, d := range dir { + _ = os.RemoveAll(path.Join(streamDir, d.Name())) + } + + ret := &Transcoder{ + streams: result.NewResultMap[string, *FileStream](), + clientChan: make(chan ClientInfo, 1000), + logger: opts.Logger, + settings: Settings{ + StreamDir: streamDir, + HwAccel: GetHardwareAccelSettings(HwAccelOptions{ + Kind: opts.HwAccelKind, + Preset: opts.Preset, + CustomSettings: opts.HwAccelCustomSettings, + }), + FfmpegPath: opts.FfmpegPath, + FfprobePath: opts.FfprobePath, + }, + } + ret.tracker = NewTracker(ret) + + ret.logger.Info().Msg("transcoder: Initialized") + return ret, nil +} + +func (t *Transcoder) GetSettings() *Settings { + return &t.settings +} + +// Destroy stops all streams and removes the output directory. +// A new transcoder should be created after calling this function. +func (t *Transcoder) Destroy() { + defer func() { + if r := recover(); r != nil { + } + }() + t.tracker.Stop() + + t.logger.Debug().Msg("transcoder: Destroying transcoder") + for _, s := range t.streams.Values() { + s.Destroy() + } + t.streams.Clear() + //close(t.clientChan) + t.streams = result.NewResultMap[string, *FileStream]() + t.clientChan = make(chan ClientInfo, 10) + t.logger.Debug().Msg("transcoder: Transcoder destroyed") +} + +func (t *Transcoder) getFileStream(path string, hash string, mediaInfo *videofile.MediaInfo) (*FileStream, error) { + if debugStream { + start := time.Now() + t.logger.Trace().Msgf("transcoder: Getting filestream") + defer t.logger.Trace().Msgf("transcoder: Filestream retrieved in %.2fs", time.Since(start).Seconds()) + } + ret, _ := t.streams.GetOrSet(path, func() (*FileStream, error) { + return NewFileStream(path, hash, mediaInfo, &t.settings, t.logger), nil + }) + if ret == nil { + return nil, fmt.Errorf("could not get filestream, file may not exist") + } + ret.ready.Wait() + if ret.err != nil { + t.streams.Delete(path) + return nil, ret.err + } + return ret, nil +} + +func (t *Transcoder) GetMaster(path string, hash string, mediaInfo *videofile.MediaInfo, client string) (string, error) { + if debugStream { + start := time.Now() + t.logger.Trace().Msgf("transcoder: Retrieving master file") + defer t.logger.Trace().Msgf("transcoder: Master file retrieved in %.2fs", time.Since(start).Seconds()) + } + stream, err := t.getFileStream(path, hash, mediaInfo) + if err != nil { + return "", err + } + t.clientChan <- ClientInfo{ + client: client, + path: path, + quality: nil, + audio: -1, + head: -1, + } + return stream.GetMaster(), nil +} + +func (t *Transcoder) GetVideoIndex( + path string, + hash string, + mediaInfo *videofile.MediaInfo, + quality Quality, + client string, +) (string, error) { + if debugStream { + start := time.Now() + t.logger.Trace().Msgf("transcoder: Retrieving video index file (%s)", quality) + defer t.logger.Trace().Msgf("transcoder: Video index file retrieved in %.2fs", time.Since(start).Seconds()) + } + stream, err := t.getFileStream(path, hash, mediaInfo) + if err != nil { + return "", err + } + t.clientChan <- ClientInfo{ + client: client, + path: path, + quality: &quality, + audio: -1, + head: -1, + } + return stream.GetVideoIndex(quality) +} + +func (t *Transcoder) GetAudioIndex( + path string, + hash string, + mediaInfo *videofile.MediaInfo, + audio int32, + client string, +) (string, error) { + if debugStream { + start := time.Now() + t.logger.Trace().Msgf("transcoder: Retrieving audio index file (%d)", audio) + defer t.logger.Trace().Msgf("transcoder: Audio index file retrieved in %.2fs", time.Since(start).Seconds()) + } + stream, err := t.getFileStream(path, hash, mediaInfo) + if err != nil { + return "", err + } + t.clientChan <- ClientInfo{ + client: client, + path: path, + audio: audio, + head: -1, + } + return stream.GetAudioIndex(audio) +} + +func (t *Transcoder) GetVideoSegment( + path string, + hash string, + mediaInfo *videofile.MediaInfo, + quality Quality, + segment int32, + client string, +) (string, error) { + if debugStream { + start := time.Now() + t.logger.Trace().Msgf("transcoder: Retrieving video segment %d (%s) [GetVideoSegment]", segment, quality) + defer t.logger.Trace().Msgf("transcoder: Video segment retrieved in %.2fs", time.Since(start).Seconds()) + } + stream, err := t.getFileStream(path, hash, mediaInfo) + if err != nil { + return "", err + } + //t.logger.Trace().Msgf("transcoder: Sending client info, segment %d (%s) [GetVideoSegment]", segment, quality) + t.clientChan <- ClientInfo{ + client: client, + path: path, + quality: &quality, + audio: -1, + head: segment, + } + //t.logger.Trace().Msgf("transcoder: Getting video segment %d (%s) [GetVideoSegment]", segment, quality) + return stream.GetVideoSegment(quality, segment) +} + +func (t *Transcoder) GetAudioSegment( + path string, + hash string, + mediaInfo *videofile.MediaInfo, + audio int32, + segment int32, + client string, +) (string, error) { + if debugStream { + start := time.Now() + t.logger.Trace().Msgf("transcoder: Retrieving audio segment %d (%d)", segment, audio) + defer t.logger.Trace().Msgf("transcoder: Audio segment %d (%d) retrieved in %.2fs", segment, audio, time.Since(start).Seconds()) + } + stream, err := t.getFileStream(path, hash, mediaInfo) + if err != nil { + return "", err + } + t.clientChan <- ClientInfo{ + client: client, + path: path, + audio: audio, + head: segment, + } + return stream.GetAudioSegment(audio, segment) +} diff --git a/seanime-2.9.10/internal/mediastream/transcoder/utils.go b/seanime-2.9.10/internal/mediastream/transcoder/utils.go new file mode 100644 index 0000000..f41eaf5 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/utils.go @@ -0,0 +1,58 @@ +package transcoder + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/goccy/go-json" + "github.com/rs/zerolog" +) + +func ParseSegment(segment string) (int32, error) { + var ret int32 + _, err := fmt.Sscanf(segment, "segment-%d.ts", &ret) + if err != nil { + return 0, errors.New("could not parse segment") + } + return ret, nil +} + +func getSavedInfo[T any](savePath string, mi *T) error { + savedFile, err := os.Open(savePath) + if err != nil { + return err + } + saved, err := io.ReadAll(savedFile) + if err != nil { + return err + } + err = json.Unmarshal(saved, mi) + if err != nil { + return err + } + return nil +} + +func saveInfo[T any](savePath string, mi *T) error { + content, err := json.Marshal(*mi) + if err != nil { + return err + } + // create directory if it doesn't exist + _ = os.MkdirAll(filepath.Dir(savePath), 0755) + return os.WriteFile(savePath, content, 0666) +} + +func printExecTime(logger *zerolog.Logger, message string, args ...any) func() { + msg := fmt.Sprintf(message, args...) + start := time.Now() + logger.Trace().Msgf("transcoder: Running %s", msg) + + return func() { + logger.Trace().Msgf("transcoder: %s finished in %s", msg, time.Since(start)) + } +} diff --git a/seanime-2.9.10/internal/mediastream/transcoder/videostream.go b/seanime-2.9.10/internal/mediastream/transcoder/videostream.go new file mode 100644 index 0000000..590a0ed --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/transcoder/videostream.go @@ -0,0 +1,91 @@ +package transcoder + +import ( + "fmt" + "path/filepath" + + "github.com/rs/zerolog" +) + +type VideoStream struct { + Stream + quality Quality + logger *zerolog.Logger + settings *Settings +} + +func NewVideoStream(file *FileStream, quality Quality, logger *zerolog.Logger, settings *Settings) *VideoStream { + logger.Trace().Str("file", filepath.Base(file.Path)).Any("quality", quality).Msgf("transcoder: Creating video stream") + ret := new(VideoStream) + ret.quality = quality + ret.logger = logger + ret.settings = settings + NewStream(fmt.Sprintf("video (%s)", quality), file, ret, &ret.Stream, settings, logger) + return ret +} + +func (vs *VideoStream) getFlags() Flags { + if vs.quality == Original { + return VideoF | Transmux + } + return VideoF +} + +func (vs *VideoStream) getOutPath(encoderId int) string { + return filepath.Join(vs.file.Out, fmt.Sprintf("segment-%s-%d-%%d.ts", vs.quality, encoderId)) +} + +func closestMultiple(n int32, x int32) int32 { + if x > n { + return x + } + + n = n + x/2 + n = n - (n % x) + return n +} + +func (vs *VideoStream) getTranscodeArgs(segments string) []string { + args := []string{ + "-map", "0:V:0", + } + + if vs.quality == Original { + args = append(args, + "-c:v", "copy", + ) + vs.logger.Debug().Msg("videostream: Transcoding to original quality") + return args + } + + vs.logger.Debug().Interface("hwaccelArgs", vs.settings.HwAccel).Msg("videostream: Hardware Acceleration") + + args = append(args, vs.settings.HwAccel.EncodeFlags...) + width := int32(float64(vs.quality.Height()) / float64(vs.file.Info.Video.Height) * float64(vs.file.Info.Video.Width)) + // force a width that is a multiple of two else some apps behave badly. + width = closestMultiple(width, 2) + args = append(args, + "-vf", fmt.Sprintf(vs.settings.HwAccel.ScaleFilter, width, vs.quality.Height()), + // Even less sure but buf size are 5x the average bitrate since the average bitrate is only + // useful for hls segments. + "-bufsize", fmt.Sprint(vs.quality.MaxBitrate()*5), + "-b:v", fmt.Sprint(vs.quality.AverageBitrate()), + "-maxrate", fmt.Sprint(vs.quality.MaxBitrate()), + ) + if vs.settings.HwAccel.WithForcedIdr { + // Force segments to be split exactly on keyframes (only works when transcoding) + // forced-idr is needed to force keyframes to be an idr-frame (by default it can be any i frames) + // without this option, some hardware encoders uses others i-frames and the -f segment can't cut at them. + args = append(args, "-forced-idr", "1") + } + + args = append(args, + "-force_key_frames", segments, + // make ffmpeg globally less buggy + "-strict", "-2", + ) + + vs.logger.Debug().Interface("args", args).Msgf("videostream: Transcoding to %s quality", vs.quality) + + return args +} diff --git a/seanime-2.9.10/internal/mediastream/videofile/extract.go b/seanime-2.9.10/internal/mediastream/videofile/extract.go new file mode 100644 index 0000000..133dcc1 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/videofile/extract.go @@ -0,0 +1,82 @@ +package videofile + +import ( + "context" + "fmt" + "github.com/rs/zerolog" + "os" + "path/filepath" + "seanime/internal/util" + "seanime/internal/util/crashlog" +) + +func GetFileSubsCacheDir(outDir string, hash string) string { + return filepath.Join(outDir, "videofiles", hash, "/subs") +} + +func GetFileAttCacheDir(outDir string, hash string) string { + return filepath.Join(outDir, "videofiles", hash, "/att") +} + +func ExtractAttachment(ffmpegPath string, path string, hash string, mediaInfo *MediaInfo, cacheDir string, logger *zerolog.Logger) (err error) { + logger.Debug().Str("hash", hash).Msgf("videofile: Starting media attachment extraction") + + attachmentPath := GetFileAttCacheDir(cacheDir, hash) + subsPath := GetFileSubsCacheDir(cacheDir, hash) + _ = os.MkdirAll(attachmentPath, 0755) + _ = os.MkdirAll(subsPath, 0755) + + subsDir, err := os.ReadDir(subsPath) + if err == nil { + if len(subsDir) == len(mediaInfo.Subtitles) { + logger.Debug().Str("hash", hash).Msgf("videofile: Attachments already extracted") + return + } + } + + for _, sub := range mediaInfo.Subtitles { + if sub.Extension == nil || *sub.Extension == "" { + logger.Error().Msgf("videofile: Subtitle format is not supported") + return fmt.Errorf("videofile: Unsupported subtitle format") + } + } + + // Instantiate a new crash logger + crashLogger := crashlog.GlobalCrashLogger.InitArea("ffmpeg") + defer crashLogger.Close() + + crashLogger.LogInfof("Extracting attachments from %s", path) + + // DEVNOTE: All paths fed into this command should be absolute + cmd := util.NewCmdCtx( + context.Background(), + ffmpegPath, + "-dump_attachment:t", "", + // override old attachments + "-y", + "-i", path, + ) + // The working directory for the command is the attachment directory + cmd.Dir = attachmentPath + + for _, sub := range mediaInfo.Subtitles { + if ext := sub.Extension; ext != nil { + cmd.Args = append( + cmd.Args, + "-map", fmt.Sprintf("0:s:%d", sub.Index), + "-c:s", "copy", + fmt.Sprintf("%s/%d.%s", subsPath, sub.Index, *ext), + ) + } + } + + cmd.Stdout = crashLogger.Stdout() + cmd.Stderr = crashLogger.Stdout() + err = cmd.Run() + if err != nil { + logger.Error().Err(err).Msgf("videofile: Error starting FFmpeg") + crashlog.GlobalCrashLogger.WriteAreaLogToFile(crashLogger) + } + + return err +} diff --git a/seanime-2.9.10/internal/mediastream/videofile/info.go b/seanime-2.9.10/internal/mediastream/videofile/info.go new file mode 100644 index 0000000..74b149a --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/videofile/info.go @@ -0,0 +1,436 @@ +package videofile + +import ( + "cmp" + "context" + "fmt" + "mime" + "path/filepath" + "seanime/internal/util/filecache" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/samber/lo" + "golang.org/x/text/language" + "gopkg.in/vansante/go-ffprobe.v2" +) + +type MediaInfo struct { + // closed if the mediainfo is ready for read. open otherwise + ready <-chan struct{} + // The sha1 of the video file + Sha string `json:"sha"` + // The internal path of the video file + Path string `json:"path"` + // The extension currently used to store this video file + Extension string `json:"extension"` + MimeCodec *string `json:"mimeCodec"` + // The file size of the video file + Size uint64 `json:"size"` + // The length of the media in seconds + Duration float32 `json:"duration"` + // The container of the video file of this episode + Container *string `json:"container"` + // The video codec and information + Video *Video `json:"video"` + // The list of videos if there are multiples + Videos []Video `json:"videos"` + // The list of audio tracks + Audios []Audio `json:"audios"` + // The list of subtitles tracks + Subtitles []Subtitle `json:"subtitles"` + // The list of fonts that can be used to display subtitles + Fonts []string `json:"fonts"` + // The list of chapters. See Chapter for more information + Chapters []Chapter `json:"chapters"` +} + +type Video struct { + // The codec of this stream (defined as the RFC 6381) + Codec string `json:"codec"` + // RFC 6381 mime codec, e.g., "video/mp4, codecs=avc1.42E01E, mp4a.40.2" + MimeCodec *string `json:"mimeCodec"` + // The language of this stream (as a ISO-639-2 language code) + Language *string `json:"language"` + // The max quality of this video track + Quality Quality `json:"quality"` + // The width of the video stream + Width uint32 `json:"width"` + // The height of the video stream + Height uint32 `json:"height"` + // The average bitrate of the video in bytes/s + Bitrate uint32 `json:"bitrate"` +} + +type Audio struct { + // The index of this track on the media + Index uint32 `json:"index"` + // The title of the stream + Title *string `json:"title"` + // The language of this stream (as a ISO-639-2 language code) + Language *string `json:"language"` + // The codec of this stream + Codec string `json:"codec"` + MimeCodec *string `json:"mimeCodec"` + // Is this stream the default one of its type? + IsDefault bool `json:"isDefault"` + // Is this stream tagged as forced? (useful only for subtitles) + IsForced bool `json:"isForced"` + Channels uint32 `json:"channels"` +} + +type Subtitle struct { + // The index of this track on the media + Index uint32 `json:"index"` + // The title of the stream + Title *string `json:"title"` + // The language of this stream (as a ISO-639-2 language code) + Language *string `json:"language"` + // The codec of this stream + Codec string `json:"codec"` + // The extension for the codec + Extension *string `json:"extension"` + // Is this stream the default one of its type? + IsDefault bool `json:"isDefault"` + // Is this stream tagged as forced? (useful only for subtitles) + IsForced bool `json:"isForced"` + // Is this subtitle file external? + IsExternal bool `json:"isExternal"` + // The link to access this subtitle + Link *string `json:"link"` +} + +type Chapter struct { + // The start time of the chapter (in second from the start of the episode) + StartTime float32 `json:"startTime"` + // The end time of the chapter (in second from the start of the episode) + EndTime float32 `json:"endTime"` + // The name of this chapter. This should be a human-readable name that could be presented to the user + Name string `json:"name"` + // TODO: add a type field for Opening, Credits... +} + +type MediaInfoExtractor struct { + fileCacher *filecache.Cacher + logger *zerolog.Logger +} + +func NewMediaInfoExtractor(fileCacher *filecache.Cacher, logger *zerolog.Logger) *MediaInfoExtractor { + return &MediaInfoExtractor{ + fileCacher: fileCacher, + logger: logger, + } +} + +// GetInfo returns the media information of a file. +// If the information is not in the cache, it will be extracted and saved in the cache. +func (e *MediaInfoExtractor) GetInfo(ffprobePath, path string) (mi *MediaInfo, err error) { + hash, err := GetHashFromPath(path) + if err != nil { + return nil, err + } + + e.logger.Debug().Str("path", path).Str("hash", hash).Msg("mediastream: Getting media information [MediaInfoExtractor]") + + bucketName := fmt.Sprintf("mediastream_mediainfo_%s", hash) + bucket := filecache.NewBucket(bucketName, 24*7*52*time.Hour) + e.logger.Trace().Str("bucketName", bucketName).Msg("mediastream: Using cache bucket [MediaInfoExtractor]") + + e.logger.Trace().Msg("mediastream: Getting media information from cache [MediaInfoExtractor]") + + // Look in the cache + if found, _ := e.fileCacher.Get(bucket, hash, &mi); found { + e.logger.Debug().Str("hash", hash).Msg("mediastream: Media information cache HIT [MediaInfoExtractor]") + return mi, nil + } + + e.logger.Debug().Str("hash", hash).Msg("mediastream: Extracting media information using FFprobe") + + // Get the media information of the file. + mi, err = FfprobeGetInfo(ffprobePath, path, hash) + if err != nil { + e.logger.Error().Err(err).Str("path", path).Msg("mediastream: Failed to extract media information using FFprobe") + return nil, err + } + + // Save in the cache + _ = e.fileCacher.Set(bucket, hash, mi) + + e.logger.Debug().Str("hash", hash).Msg("mediastream: Extracted media information using FFprobe") + + return mi, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func FfprobeGetInfo(ffprobePath, path, hash string) (*MediaInfo, error) { + + if ffprobePath != "" { + ffprobe.SetFFProbeBinPath(ffprobePath) + } + + ffprobeCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + + data, err := ffprobe.ProbeURL(ffprobeCtx, path) + if err != nil { + return nil, err + } + + ext := filepath.Ext(path)[1:] + + sizeUint64, _ := strconv.ParseUint(data.Format.Size, 10, 64) + + mi := &MediaInfo{ + Sha: hash, + Path: path, + Extension: ext, + Size: sizeUint64, + Duration: float32(data.Format.DurationSeconds), + Container: cmp.Or(lo.ToPtr(data.Format.FormatName), nil), + } + + // Get the video streams + mi.Videos = streamToMap(data.Streams, ffprobe.StreamVideo, func(stream *ffprobe.Stream, i uint32) Video { + lang, _ := language.Parse(stream.Tags.Language) + bitrate, _ := strconv.ParseUint(cmp.Or(stream.BitRate, data.Format.BitRate), 10, 32) + return Video{ + Codec: stream.CodecName, + MimeCodec: streamToMimeCodec(stream), + Language: nullIfZero(lang.String()), + Quality: heightToQuality(uint32(stream.Height)), + Width: uint32(stream.Width), + Height: uint32(stream.Height), + // ffmpeg does not report bitrate in mkv files, fallback to bitrate of the whole container + // (bigger than the result since it contains audio and other videos but better than nothing). + Bitrate: uint32(bitrate), + } + }) + + // Get the audio streams + mi.Audios = streamToMap(data.Streams, ffprobe.StreamAudio, func(stream *ffprobe.Stream, i uint32) Audio { + lang, _ := language.Parse(stream.Tags.Language) + return Audio{ + Index: i, + Title: nullIfZero(stream.Tags.Title), + Language: nullIfZero(lang.String()), + Codec: stream.CodecName, + MimeCodec: streamToMimeCodec(stream), + IsDefault: stream.Disposition.Default != 0, + IsForced: stream.Disposition.Forced != 0, + } + }) + + // Get the subtitle streams + mi.Subtitles = streamToMap(data.Streams, ffprobe.StreamSubtitle, func(stream *ffprobe.Stream, i uint32) Subtitle { + subExtensions := map[string]string{ + "subrip": "srt", + "ass": "ass", + "vtt": "vtt", + "ssa": "ssa", + } + extension, ok := subExtensions[stream.CodecName] + var link *string + if ok { + x := fmt.Sprintf("/%d.%s", i, extension) + link = &x + } + lang, _ := language.Parse(stream.Tags.Language) + return Subtitle{ + Index: i, + Title: nullIfZero(stream.Tags.Title), + Language: nullIfZero(lang.String()), + Codec: stream.CodecName, + Extension: lo.ToPtr(extension), + IsDefault: stream.Disposition.Default != 0, + IsForced: stream.Disposition.Forced != 0, + Link: link, + } + }) + + // Remove subtitles without extensions (not supported) + mi.Subtitles = lo.Filter(mi.Subtitles, func(item Subtitle, _ int) bool { + if item.Extension == nil || *item.Extension == "" || item.Link == nil { + return false + } + return true + }) + + // Get chapters + mi.Chapters = lo.Map(data.Chapters, func(chapter *ffprobe.Chapter, _ int) Chapter { + return Chapter{ + StartTime: float32(chapter.StartTimeSeconds), + EndTime: float32(chapter.EndTimeSeconds), + Name: chapter.Title(), + } + }) + + // Get fonts + mi.Fonts = streamToMap(data.Streams, ffprobe.StreamAttachment, func(stream *ffprobe.Stream, i uint32) string { + filename, _ := stream.TagList.GetString("filename") + return filename + }) + + var codecs []string + if len(mi.Videos) > 0 && mi.Videos[0].MimeCodec != nil { + codecs = append(codecs, *mi.Videos[0].MimeCodec) + } + if len(mi.Audios) > 0 && mi.Audios[0].MimeCodec != nil { + codecs = append(codecs, *mi.Audios[0].MimeCodec) + } + container := mime.TypeByExtension(fmt.Sprintf(".%s", mi.Extension)) + if container != "" { + if len(codecs) > 0 { + codecsStr := strings.Join(codecs, ", ") + mi.MimeCodec = lo.ToPtr(fmt.Sprintf("%s; codecs=\"%s\"", container, codecsStr)) + } else { + mi.MimeCodec = &container + } + } + + if len(mi.Videos) > 0 { + mi.Video = &mi.Videos[0] + } + + return mi, nil +} + +func nullIfZero[T comparable](v T) *T { + var zero T + if v != zero { + return &v + } + return nil +} + +func streamToMap[T any](streams []*ffprobe.Stream, kind ffprobe.StreamType, mapper func(*ffprobe.Stream, uint32) T) []T { + count := 0 + for _, stream := range streams { + if stream.CodecType == string(kind) { + count++ + } + } + ret := make([]T, count) + + i := uint32(0) + for _, stream := range streams { + if stream.CodecType == string(kind) { + ret[i] = mapper(stream, i) + i++ + } + } + return ret +} + +func streamToMimeCodec(stream *ffprobe.Stream) *string { + switch stream.CodecName { + case "h264": + ret := "avc1" + + switch strings.ToLower(stream.Profile) { + case "high": + ret += ".6400" + case "main": + ret += ".4D40" + case "baseline": + ret += ".42E0" + default: + // Default to constrained baseline if profile is invalid + ret += ".4240" + } + + ret += fmt.Sprintf("%02x", stream.Level) + return &ret + + case "h265", "hevc": + // The h265 syntax is a bit of a mystery at the time this comment was written. + // This is what I've found through various sources: + // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN] + ret := "hvc1" + + if stream.Profile == "main 10" { + ret += ".2.4" + } else { + ret += ".1.4" + } + + ret += fmt.Sprintf(".L%02X.BO", stream.Level) + return &ret + + case "av1": + // https://aomedia.org/av1/specification/annex-a/ + // FORMAT: [codecTag].[profile].[level][tier].[bitDepth] + ret := "av01" + + switch strings.ToLower(stream.Profile) { + case "main": + ret += ".0" + case "high": + ret += ".1" + case "professional": + ret += ".2" + default: + } + + // not sure about this field, we want pixel bit depth + bitdepth, _ := strconv.ParseUint(stream.BitsPerRawSample, 10, 32) + if bitdepth != 8 && bitdepth != 10 && bitdepth != 12 { + // Default to 8 bits + bitdepth = 8 + } + + tierflag := 'M' + ret += fmt.Sprintf(".%02X%c.%02d", stream.Level, tierflag, bitdepth) + + return &ret + + case "aac": + ret := "mp4a" + + switch strings.ToLower(stream.Profile) { + case "he": + ret += ".40.5" + case "lc": + ret += ".40.2" + default: + ret += ".40.2" + } + + return &ret + + case "opus": + ret := "Opus" + return &ret + + case "ac3": + ret := "mp4a.a5" + return &ret + + case "eac3": + ret := "mp4a.a6" + return &ret + + case "flac": + ret := "fLaC" + return &ret + + case "alac": + ret := "alac" + return &ret + + default: + return nil + } +} + +func heightToQuality(height uint32) Quality { + qualities := Qualities + for _, quality := range qualities { + if quality.Height() >= height { + return quality + } + } + return P240 +} diff --git a/seanime-2.9.10/internal/mediastream/videofile/info_test.go b/seanime-2.9.10/internal/mediastream/videofile/info_test.go new file mode 100644 index 0000000..2c659d2 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/videofile/info_test.go @@ -0,0 +1,51 @@ +package videofile + +import ( + "os" + "path/filepath" + "seanime/internal/util" + "testing" +) + +func TestFfprobeGetInfo_1(t *testing.T) { + t.Skip() + + testFilePath := "" + + mi, err := FfprobeGetInfo("", testFilePath, "1") + if err != nil { + t.Fatalf("Error getting media info: %v", err) + } + + util.Spew(mi) +} + +func TestExtractAttachment(t *testing.T) { + t.Skip() + + testFilePath := "" + + testDir := t.TempDir() + + mi, err := FfprobeGetInfo("", testFilePath, "1") + if err != nil { + t.Fatalf("Error getting media info: %v", err) + } + + util.Spew(mi) + + err = ExtractAttachment("", testFilePath, "1", mi, testDir, util.NewLogger()) + if err != nil { + t.Fatalf("Error extracting attachment: %v", err) + } + + entries, err := os.ReadDir(filepath.Join(testDir, "videofiles", "1", "att")) + if err != nil { + t.Fatalf("Error reading directory: %v", err) + } + + for _, entry := range entries { + info, _ := entry.Info() + t.Logf("Entry: %s, Size: %d\n", entry.Name(), info.Size()) + } +} diff --git a/seanime-2.9.10/internal/mediastream/videofile/info_utils.go b/seanime-2.9.10/internal/mediastream/videofile/info_utils.go new file mode 100644 index 0000000..074208b --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/videofile/info_utils.go @@ -0,0 +1,19 @@ +package videofile + +import ( + "crypto/sha1" + "encoding/hex" + "os" +) + +func GetHashFromPath(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + h := sha1.New() + h.Write([]byte(path)) + h.Write([]byte(info.ModTime().String())) + sha := hex.EncodeToString(h.Sum(nil)) + return sha, nil +} diff --git a/seanime-2.9.10/internal/mediastream/videofile/video_quality.go b/seanime-2.9.10/internal/mediastream/videofile/video_quality.go new file mode 100644 index 0000000..26e63d7 --- /dev/null +++ b/seanime-2.9.10/internal/mediastream/videofile/video_quality.go @@ -0,0 +1,118 @@ +package videofile + +import "errors" + +type Quality string + +const ( + P240 Quality = "240p" + P360 Quality = "360p" + P480 Quality = "480p" + P720 Quality = "720p" + P1080 Quality = "1080p" + P1440 Quality = "1440p" + P4k Quality = "4k" + P8k Quality = "8k" + Original Quality = "original" +) + +// Qualities Purposefully removing Original from this list (since it require special treatments anyway) +var Qualities = []Quality{P240, P360, P480, P720, P1080, P1440, P4k, P8k} + +func QualityFromString(str string) (Quality, error) { + if str == string(Original) { + return Original, nil + } + + qualities := Qualities + for _, quality := range qualities { + if string(quality) == str { + return quality, nil + } + } + return Original, errors.New("invalid quality string") +} + +// AverageBitrate +// I'm not entirely sure about the values for bit rates. Double-checking would be nice. +func (v Quality) AverageBitrate() uint32 { + switch v { + case P240: + return 400_000 + case P360: + return 800_000 + case P480: + return 1_200_000 + case P720: + return 2_400_000 + case P1080: + return 4_800_000 + case P1440: + return 9_600_000 + case P4k: + return 16_000_000 + case P8k: + return 28_000_000 + case Original: + panic("Original quality must be handled specially") + } + panic("Invalid quality value") +} + +func (v Quality) MaxBitrate() uint32 { + switch v { + case P240: + return 700_000 + case P360: + return 1_400_000 + case P480: + return 2_100_000 + case P720: + return 4_000_000 + case P1080: + return 8_000_000 + case P1440: + return 12_000_000 + case P4k: + return 28_000_000 + case P8k: + return 40_000_000 + case Original: + panic("Original quality must be handled specially") + } + panic("Invalid quality value") +} + +func (v Quality) Height() uint32 { + switch v { + case P240: + return 240 + case P360: + return 360 + case P480: + return 480 + case P720: + return 720 + case P1080: + return 1080 + case P1440: + return 1440 + case P4k: + return 2160 + case P8k: + return 4320 + case Original: + panic("Original quality must be handled specially") + } + panic("Invalid quality value") +} + +func GetQualityFromHeight(height uint32) Quality { + qualities := Qualities + for _, quality := range qualities { + if quality.Height() >= height { + return quality + } + } + return P240 +} diff --git a/seanime-2.9.10/internal/mkvparser/metadata.go b/seanime-2.9.10/internal/mkvparser/metadata.go new file mode 100644 index 0000000..6eadbd4 --- /dev/null +++ b/seanime-2.9.10/internal/mkvparser/metadata.go @@ -0,0 +1,116 @@ +package mkvparser + +// TrackType represents the type of a Matroska track. +type TrackType string + +const ( + TrackTypeVideo TrackType = "video" + TrackTypeAudio TrackType = "audio" + TrackTypeSubtitle TrackType = "subtitle" + TrackTypeLogo TrackType = "logo" + TrackTypeButtons TrackType = "buttons" + TrackTypeComplex TrackType = "complex" + TrackTypeUnknown TrackType = "unknown" +) + +type AttachmentType string + +const ( + AttachmentTypeFont AttachmentType = "font" + AttachmentTypeSubtitle AttachmentType = "subtitle" + AttachmentTypeOther AttachmentType = "other" +) + +// TrackInfo holds extracted information about a media track. +type TrackInfo struct { + Number int64 `json:"number"` + UID int64 `json:"uid"` + Type TrackType `json:"type"` // "video", "audio", "subtitle", etc. + CodecID string `json:"codecID"` + Name string `json:"name,omitempty"` + Language string `json:"language,omitempty"` // Best effort language code + LanguageIETF string `json:"languageIETF,omitempty"` // IETF language code + Default bool `json:"default"` + Forced bool `json:"forced"` + Enabled bool `json:"enabled"` + CodecPrivate string `json:"codecPrivate,omitempty"` // Raw CodecPrivate data, often used for subtitle headers (e.g., ASS/SSA styles) + + // Video specific + Video *VideoTrack `json:"video,omitempty"` + // Audio specific + Audio *AudioTrack `json:"audio,omitempty"` + // Internal fields + contentEncodings *ContentEncodings `json:"-"` + defaultDuration uint64 `json:"-"` // in ns +} + +// ChapterInfo holds extracted information about a chapter. +type ChapterInfo struct { + UID uint64 `json:"uid"` + Start float64 `json:"start"` // Start time in seconds + End float64 `json:"end,omitempty"` // End time in seconds + Text string `json:"text,omitempty"` + Languages []string `json:"languages,omitempty"` // Legacy 3-letter language codes + LanguagesIETF []string `json:"languagesIETF,omitempty"` // IETF language tags +} + +// AttachmentInfo holds extracted information about an attachment. +type AttachmentInfo struct { + UID uint64 `json:"uid"` + Filename string `json:"filename"` + Mimetype string `json:"mimetype"` + Size int `json:"size"` + Description string `json:"description,omitempty"` + Type AttachmentType `json:"type,omitempty"` + Data []byte `json:"-"` // Data loaded into memory + IsCompressed bool `json:"-"` // Whether the data is compressed +} + +// Metadata holds all extracted metadata. +type Metadata struct { + Title string `json:"title,omitempty"` + Duration float64 `json:"duration"` // Duration in seconds + TimecodeScale float64 `json:"timecodeScale"` // Original timecode scale from Info + MuxingApp string `json:"muxingApp,omitempty"` + WritingApp string `json:"writingApp,omitempty"` + Tracks []*TrackInfo `json:"tracks"` + VideoTracks []*TrackInfo `json:"videoTracks"` + AudioTracks []*TrackInfo `json:"audioTracks"` + SubtitleTracks []*TrackInfo `json:"subtitleTracks"` + Chapters []*ChapterInfo `json:"chapters"` + Attachments []*AttachmentInfo `json:"attachments"` + MimeCodec string `json:"mimeCodec,omitempty"` // RFC 6381 codec string + Error error `json:"-"` +} + +func (m *Metadata) GetTrackByNumber(num int64) *TrackInfo { + for _, track := range m.Tracks { + if track.Number == num { + return track + } + } + return nil +} + +func (m *Metadata) GetAttachmentByName(name string) (*AttachmentInfo, bool) { + for _, attachment := range m.Attachments { + if attachment.Filename == name { + return attachment, true + } + } + return nil, false +} + +/////////////////////////////////////////////////////////////////////////////////////////// + +func (t *TrackInfo) IsAudioTrack() bool { + return t.Type == TrackTypeAudio +} + +func (t *TrackInfo) IsVideoTrack() bool { + return t.Type == TrackTypeVideo +} + +func (t *TrackInfo) IsSubtitleTrack() bool { + return t.Type == TrackTypeSubtitle +} diff --git a/seanime-2.9.10/internal/mkvparser/mkvparser.go b/seanime-2.9.10/internal/mkvparser/mkvparser.go new file mode 100644 index 0000000..1aa5458 --- /dev/null +++ b/seanime-2.9.10/internal/mkvparser/mkvparser.go @@ -0,0 +1,1203 @@ +package mkvparser + +import ( + "bytes" + "cmp" + "compress/zlib" + "context" + "errors" + "fmt" + "io" + "path/filepath" + "seanime/internal/util" + "strings" + "sync" + "time" + + "github.com/5rahim/gomkv" + "github.com/goccy/go-json" + "github.com/rs/zerolog" + "github.com/samber/lo" +) + +const ( + maxScanBytes = 35 * 1024 * 1024 // 35MB + // Default timecode scale (1ms) + defaultTimecodeScale = 1_000_000 + + clusterSearchChunkSize = 8192 // 8KB + clusterSearchDepth = 10 * 1024 * 1024 // 1MB +) + +var matroskaClusterID = []byte{0x1F, 0x43, 0xB6, 0x75} + +var subtitleExtensions = map[string]struct{}{".ass": {}, ".ssa": {}, ".srt": {}, ".vtt": {}, ".txt": {}} +var fontExtensions = map[string]struct{}{".ttf": {}, ".ttc": {}, ".woff": {}, ".woff2": {}, ".bdf": {}, ".otf": {}, ".cff": {}, ".otc": {}, ".pfa": {}, ".pfb": {}, ".pcf": {}, ".pfr": {}, ".fnt": {}, ".eot": {}} + +// SubtitleEvent holds information for a single subtitle entry. +type SubtitleEvent struct { + TrackNumber uint64 `json:"trackNumber"` + Text string `json:"text"` // Content + StartTime float64 `json:"startTime"` // Start time in seconds + Duration float64 `json:"duration"` // Duration in seconds + CodecID string `json:"codecID"` // e.g., "S_TEXT/ASS", "S_TEXT/UTF8" + ExtraData map[string]string `json:"extraData,omitempty"` + + HeadPos int64 `json:"-"` // Position in the stream +} + +// GetSubtitleEventKey stringifies the subtitle event to serve as a key +func GetSubtitleEventKey(se *SubtitleEvent) string { + marshaled, err := json.Marshal(se) + if err != nil { + return "" + } + return string(marshaled) +} + +// MetadataParser parses Matroska metadata from a file. +type MetadataParser struct { + reader io.ReadSeeker + logger *zerolog.Logger + realLogger *zerolog.Logger + parseErr error + parseOnce sync.Once + metadataOnce sync.Once + + // Internal state for parsing + timecodeScale uint64 + currentTrack *TrackInfo + tracks []*TrackInfo + info *Info + chapters []*ChapterInfo + attachments []*AttachmentInfo + + // Result + extractedMetadata *Metadata +} + +// NewMetadataParser creates a new MetadataParser. +func NewMetadataParser(reader io.ReadSeeker, logger *zerolog.Logger) *MetadataParser { + return &MetadataParser{ + reader: reader, + logger: logger, + realLogger: logger, + timecodeScale: defaultTimecodeScale, + tracks: make([]*TrackInfo, 0), + chapters: make([]*ChapterInfo, 0), + attachments: make([]*AttachmentInfo, 0), + info: &Info{}, + extractedMetadata: nil, + } +} + +func (mp *MetadataParser) SetLoggerEnabled(enabled bool) { + if !enabled { + mp.logger = lo.ToPtr(zerolog.Nop()) + } else { + mp.logger = mp.realLogger + } +} + +// convertTrackType converts Matroska track type uint to a string representation. +func convertTrackType(trackType uint64) TrackType { + switch trackType { + case 0x01: + return TrackTypeVideo + case 0x02: + return TrackTypeAudio + case 0x03: + return TrackTypeComplex + case 0x10: + return TrackTypeLogo + case 0x11: + return TrackTypeSubtitle + case 0x12: + return TrackTypeButtons + default: + return TrackTypeUnknown + } +} + +func getLanguageCode(track *TrackInfo) string { + if track.LanguageIETF != "" { + return track.LanguageIETF + } + if track.Language != "" && track.Language != "und" { + return track.Language + } + return "eng" +} + +func getSubtitleTrackType(codecID string) string { + switch codecID { + case "S_TEXT/ASS": + return "SSA" + case "S_TEXT/SSA": + return "SSA" + case "S_TEXT/UTF8": + return "TEXT" + case "S_HDMV/PGS": + return "PGS" + } + return "unknown" +} + +// parseMetadataOnce performs the actual parsing of the file stream. +func (mp *MetadataParser) parseMetadataOnce(ctx context.Context) { + mp.parseOnce.Do(func() { + mp.logger.Debug().Msg("mkvparser: Starting metadata parsing") + startTime := time.Now() + + // Create a handler for parsing + handler := &metadataHandler{ + mp: mp, + ctx: ctx, + logger: mp.logger, + } + + _, _ = mp.reader.Seek(0, io.SeekStart) + + // Devnote: Don't limit the depth anymore + //limitedReader, err := util.NewLimitedReadSeeker(mp.reader, maxScanBytes) + //if err != nil { + // mp.logger.Error().Err(err).Msg("mkvparser: Failed to create limited reader") + // mp.parseErr = fmt.Errorf("mkvparser: Failed to create limited reader: %w", err) + // return + //} + + // Parse the MKV file + err := gomkv.ParseSections(mp.reader, handler, + gomkv.InfoElement, + gomkv.AttachmentsElement, + gomkv.TracksElement, + gomkv.SegmentElement, + gomkv.ChaptersElement, + ) + if err != nil && err != io.EOF && !strings.Contains(err.Error(), "unexpected EOF") { + mp.logger.Error().Err(err).Msg("mkvparser: MKV parsing error") + mp.parseErr = fmt.Errorf("mkv parsing failed: %w", err) + } else if err != nil { + mp.logger.Debug().Err(err).Msg("mkvparser: MKV parsing finished with EOF/unexpected EOF (expected outcome).") + mp.parseErr = nil + } else { + mp.logger.Debug().Msg("mkvparser: MKV parsing completed fully within scan limit.") + mp.parseErr = nil + } + + logMsg := mp.logger.Info().Dur("parseDuration", time.Since(startTime)) + if mp.parseErr != nil { + logMsg.Err(mp.parseErr) + } + logMsg.Msg("mkvparser: Metadata parsing attempt finished") + }) +} + +// Handler for parsing metadata +type metadataHandler struct { + gomkv.DefaultHandler + mp *MetadataParser + ctx context.Context + logger *zerolog.Logger + + // Track parsing state + inTrackEntry bool + currentTrack *TrackInfo + inVideo bool + inAudio bool + + // Chapter parsing state + inEditionEntry bool + inChapterAtom bool + currentChapter *ChapterInfo + inChapterDisplay bool + currentLanguages []string // Temporary storage for chapter languages + currentIETF []string // Temporary storage for chapter IETF languages + + // Attachment parsing state + isAttachment bool + currentAttachment *AttachmentInfo +} + +func (h *metadataHandler) HandleMasterBegin(id gomkv.ElementID, info gomkv.ElementInfo) (bool, error) { + switch id { + case gomkv.SegmentElement: + return true, nil // Parse Segment and its children + case gomkv.TracksElement: + return true, nil // Parse Track metadata + case gomkv.TrackEntryElement: + h.inTrackEntry = true + h.currentTrack = &TrackInfo{ + Default: false, + Enabled: true, + } + return true, nil + case gomkv.VideoElement: + h.inVideo = true + if h.currentTrack != nil && h.currentTrack.Video == nil { + h.currentTrack.Video = &VideoTrack{} + } + return true, nil + case gomkv.AudioElement: + h.inAudio = true + if h.currentTrack != nil && h.currentTrack.Audio == nil { + h.currentTrack.Audio = &AudioTrack{} + } + return true, nil + case gomkv.InfoElement: + if h.mp.info == nil { + h.mp.info = &Info{} + } + return true, nil + case gomkv.ChaptersElement: + return true, nil + case gomkv.EditionEntryElement: + h.inEditionEntry = true + return true, nil + case gomkv.ChapterAtomElement: + h.inChapterAtom = true + h.currentChapter = &ChapterInfo{} + return true, nil + case gomkv.ChapterDisplayElement: + h.inChapterDisplay = true + h.currentLanguages = make([]string, 0) + h.currentIETF = make([]string, 0) + return true, nil + case gomkv.AttachmentsElement: + return true, nil + case gomkv.AttachedFileElement: + h.isAttachment = true + h.currentAttachment = &AttachmentInfo{} + return true, nil + case gomkv.ContentEncodingsElement: + if h.currentTrack != nil && h.currentTrack.contentEncodings == nil { + h.currentTrack.contentEncodings = &ContentEncodings{ + ContentEncoding: make([]ContentEncoding, 0), + } + } else if h.isAttachment && h.currentAttachment != nil { + // Handle content encoding for attachments + h.currentAttachment.IsCompressed = true + } + return true, nil + } + return false, nil +} + +func (h *metadataHandler) HandleMasterEnd(id gomkv.ElementID, info gomkv.ElementInfo) error { + switch id { + case gomkv.TrackEntryElement: + if h.currentTrack != nil { + h.mp.tracks = append(h.mp.tracks, h.currentTrack) + } + h.inTrackEntry = false + h.currentTrack = nil + case gomkv.VideoElement: + h.inVideo = false + case gomkv.AudioElement: + h.inAudio = false + case gomkv.EditionEntryElement: + h.inEditionEntry = false + case gomkv.ChapterAtomElement: + if h.currentChapter != nil && h.inEditionEntry { + h.mp.chapters = append(h.mp.chapters, h.currentChapter) + } + h.inChapterAtom = false + h.currentChapter = nil + case gomkv.ChapterDisplayElement: + if h.currentChapter != nil { + h.currentChapter.Languages = h.currentLanguages + h.currentChapter.LanguagesIETF = h.currentIETF + } + h.inChapterDisplay = false + h.currentLanguages = nil + h.currentIETF = nil + case gomkv.AttachedFileElement: + if h.currentAttachment != nil { + // Handle compressed attachments if needed + if h.currentAttachment.Data != nil && h.currentAttachment.IsCompressed { + zlibReader, err := zlib.NewReader(bytes.NewReader(h.currentAttachment.Data)) + if err != nil { + h.logger.Error().Err(err).Str("filename", h.currentAttachment.Filename).Msg("mkvparser: Failed to create zlib reader for attachment") + } else { + decompressedData, err := io.ReadAll(zlibReader) + _ = zlibReader.Close() + if err != nil { + h.logger.Error().Err(err).Str("filename", h.currentAttachment.Filename).Msg("mkvparser: Failed to decompress attachment") + } else { + h.currentAttachment.Data = decompressedData + h.currentAttachment.Size = len(decompressedData) + } + } + } + fileExt := strings.ToLower(filepath.Ext(h.currentAttachment.Filename)) + if _, ok := fontExtensions[fileExt]; ok { + h.currentAttachment.Type = AttachmentTypeFont + } else if _, ok := subtitleExtensions[fileExt]; ok { + h.currentAttachment.Type = AttachmentTypeSubtitle + } else { + h.currentAttachment.Type = AttachmentTypeOther + } + h.mp.attachments = append(h.mp.attachments, h.currentAttachment) + } + h.isAttachment = false + h.currentAttachment = nil + } + return nil +} + +func (h *metadataHandler) HandleString(id gomkv.ElementID, value string, info gomkv.ElementInfo) error { + switch id { + case gomkv.CodecIDElement: + if h.currentTrack != nil { + h.currentTrack.CodecID = value + } + case gomkv.LanguageElement: + if h.currentTrack != nil { + h.currentTrack.Language = value + } else if h.inChapterDisplay { + h.currentLanguages = append(h.currentLanguages, value) + } + case gomkv.LanguageIETFElement: + if h.currentTrack != nil { + h.currentTrack.LanguageIETF = value + } else if h.inChapterDisplay { + h.currentIETF = append(h.currentIETF, value) + } + case gomkv.NameElement: + if h.currentTrack != nil { + h.currentTrack.Name = value + } + case gomkv.TitleElement: + if h.mp.info != nil { + h.mp.info.Title = value + } + case gomkv.MuxingAppElement: + if h.mp.info != nil { + h.mp.info.MuxingApp = value + } + case gomkv.WritingAppElement: + if h.mp.info != nil { + h.mp.info.WritingApp = value + } + case gomkv.ChapStringElement: + if h.inChapterDisplay && h.currentChapter != nil { + h.currentChapter.Text = value + } + case gomkv.FileDescriptionElement: + if h.isAttachment && h.currentAttachment != nil { + h.currentAttachment.Description = value + } + case gomkv.FileNameElement: + if h.isAttachment && h.currentAttachment != nil { + h.currentAttachment.Filename = value + } + case gomkv.FileMimeTypeElement: + if h.isAttachment && h.currentAttachment != nil { + h.currentAttachment.Mimetype = value + } + } + return nil +} + +func (h *metadataHandler) HandleInteger(id gomkv.ElementID, value int64, info gomkv.ElementInfo) error { + switch id { + case gomkv.TimecodeScaleElement: + h.mp.timecodeScale = uint64(value) + if h.mp.info != nil { + h.mp.info.TimecodeScale = uint64(value) + } + case gomkv.TrackNumberElement: + if h.currentTrack != nil { + h.currentTrack.Number = value + } + case gomkv.TrackUIDElement: + if h.currentTrack != nil { + h.currentTrack.UID = value + } + case gomkv.TrackTypeElement: + if h.currentTrack != nil { + h.currentTrack.Type = convertTrackType(uint64(value)) + } + case gomkv.DefaultDurationElement: + if h.currentTrack != nil { + h.currentTrack.defaultDuration = uint64(value) + } + case gomkv.FlagDefaultElement: + if h.currentTrack != nil { + h.currentTrack.Default = value == 1 + } + case gomkv.FlagForcedElement: + if h.currentTrack != nil { + h.currentTrack.Forced = value == 1 + } + case gomkv.FlagEnabledElement: + if h.currentTrack != nil { + h.currentTrack.Enabled = value == 1 + } + case gomkv.PixelWidthElement: + if h.currentTrack != nil && h.currentTrack.Video != nil { + h.currentTrack.Video.PixelWidth = uint64(value) + } + case gomkv.PixelHeightElement: + if h.currentTrack != nil && h.currentTrack.Video != nil { + h.currentTrack.Video.PixelHeight = uint64(value) + } + case gomkv.ChannelsElement: + if h.currentTrack != nil && h.currentTrack.Audio != nil { + h.currentTrack.Audio.Channels = uint64(value) + } + case gomkv.BitDepthElement: + if h.currentTrack != nil && h.currentTrack.Audio != nil { + h.currentTrack.Audio.BitDepth = uint64(value) + } + case gomkv.ChapterTimeStartElement: + if h.inChapterAtom && h.currentChapter != nil { + h.currentChapter.Start = float64(value) * float64(h.mp.timecodeScale) / 1e9 + } + case gomkv.ChapterTimeEndElement: + if h.inChapterAtom && h.currentChapter != nil { + h.currentChapter.End = float64(value) * float64(h.mp.timecodeScale) / 1e9 + } + case gomkv.ChapterUIDElement: + if h.inChapterAtom && h.currentChapter != nil { + h.currentChapter.UID = uint64(value) + } + case gomkv.FileUIDElement: + if h.isAttachment && h.currentAttachment != nil { + h.currentAttachment.UID = uint64(value) + } + } + return nil +} + +func (h *metadataHandler) HandleFloat(id gomkv.ElementID, value float64, info gomkv.ElementInfo) error { + switch id { + case gomkv.DurationElement: + if h.mp.info != nil { + h.mp.info.Duration = value + } + case gomkv.SamplingFrequencyElement: + if h.currentTrack != nil && h.currentTrack.Audio != nil { + h.currentTrack.Audio.SamplingFrequency = value + } + } + return nil +} + +func (h *metadataHandler) HandleBinary(id gomkv.ElementID, value []byte, info gomkv.ElementInfo) error { + switch id { + case gomkv.CodecPrivateElement: + if h.currentTrack != nil { + h.currentTrack.CodecPrivate = string(value) + h.currentTrack.CodecPrivate = strings.ReplaceAll(h.currentTrack.CodecPrivate, "\r\n", "\n") + } + case gomkv.FileDataElement: + if h.isAttachment && h.currentAttachment != nil { + h.currentAttachment.Data = value + h.currentAttachment.Size = len(value) + } + } + return nil +} + +// GetMetadata extracts all relevant metadata from the file. +func (mp *MetadataParser) GetMetadata(ctx context.Context) *Metadata { + mp.parseMetadataOnce(ctx) + + mp.metadataOnce.Do(func() { + result := &Metadata{ + VideoTracks: make([]*TrackInfo, 0), + AudioTracks: make([]*TrackInfo, 0), + SubtitleTracks: make([]*TrackInfo, 0), + Tracks: mp.tracks, + Chapters: mp.chapters, + Attachments: mp.attachments, + Error: mp.parseErr, + } + + if mp.parseErr != nil { + if !(errors.Is(mp.parseErr, context.Canceled) || errors.Is(mp.parseErr, context.DeadlineExceeded)) { + mp.extractedMetadata = result + return + } + } + + if mp.info != nil { + result.Title = mp.info.Title + result.MuxingApp = mp.info.MuxingApp + result.WritingApp = mp.info.WritingApp + result.TimecodeScale = float64(mp.timecodeScale) + if mp.info.Duration > 0 { + result.Duration = (mp.info.Duration * float64(mp.timecodeScale)) / 1e9 + } + } + + mp.logger.Debug(). + Int("tracks", len(mp.tracks)). + Int("chapters", len(mp.chapters)). + Int("attachments", len(mp.attachments)). + Msg("mkvparser: Metadata parsing complete") + + if len(mp.chapters) == 0 { + mp.logger.Debug().Msg("mkvparser: No chapters found") + } + if len(mp.attachments) == 0 { + mp.logger.Debug().Msg("mkvparser: No attachments found") + } + + for _, track := range mp.tracks { + switch track.Type { + case TrackTypeVideo: + result.VideoTracks = append(result.VideoTracks, track) + case TrackTypeAudio: + result.AudioTracks = append(result.AudioTracks, track) + case TrackTypeSubtitle: + // Fix missing fields + track.Name = cmp.Or(track.Name, strings.ToUpper(track.Language), strings.ToUpper(track.LanguageIETF)) + track.Language = getLanguageCode(track) + result.SubtitleTracks = append(result.SubtitleTracks, track) + } + } + + // Group subtitle tracks by duplicate name + groups := lo.GroupBy(result.SubtitleTracks, func(t *TrackInfo) string { + return t.Name + }) + for _, group := range groups { + for _, track := range group { + track.Name = fmt.Sprintf("%s", track.Name) + if track.Language == "" { + track.Language = getLanguageCode(track) + } + } + } + + // Generate MimeCodec string + var codecStrings []string + seenCodecs := make(map[string]bool) + + if len(result.VideoTracks) > 0 { + firstVideoTrack := result.VideoTracks[0] + var videoCodecStr string + switch firstVideoTrack.CodecID { + case "V_MPEGH/ISO/HEVC": + videoCodecStr = "hvc1" + case "V_MPEG4/ISO/AVC": + videoCodecStr = "avc1" + case "V_AV1": + videoCodecStr = "av01" + case "V_VP9": + videoCodecStr = "vp09" + case "V_VP8": + videoCodecStr = "vp8" + default: + if firstVideoTrack.CodecID != "" { + videoCodecStr = strings.ToLower(strings.ReplaceAll(firstVideoTrack.CodecID, "/", ".")) + } + } + if videoCodecStr != "" && !seenCodecs[videoCodecStr] { + codecStrings = append(codecStrings, videoCodecStr) + seenCodecs[videoCodecStr] = true + } + } + + for _, audioTrack := range result.AudioTracks { + var audioCodecStr string + switch audioTrack.CodecID { + case "A_AAC": + audioCodecStr = "mp4a.40.2" + case "A_AC3": + audioCodecStr = "ac-3" + case "A_EAC3": + audioCodecStr = "ec-3" + case "A_OPUS": + audioCodecStr = "opus" + case "A_DTS": + audioCodecStr = "dts" + case "A_FLAC": + audioCodecStr = "flac" + case "A_TRUEHD": + audioCodecStr = "mlp" + case "A_MS/ACM": + if strings.Contains(strings.ToLower(audioTrack.Name), "vorbis") { + audioCodecStr = "vorbis" + } else if audioTrack.CodecID != "" { + audioCodecStr = strings.ToLower(strings.ReplaceAll(audioTrack.CodecID, "/", ".")) + } + case "A_VORBIS": + audioCodecStr = "vorbis" + default: + if audioTrack.CodecID != "" { + audioCodecStr = strings.ToLower(strings.ReplaceAll(audioTrack.CodecID, "/", ".")) + } + } + if audioCodecStr != "" && !seenCodecs[audioCodecStr] { + codecStrings = append(codecStrings, audioCodecStr) + seenCodecs[audioCodecStr] = true + } + } + + if len(codecStrings) > 0 { + result.MimeCodec = fmt.Sprintf("video/x-matroska; codecs=\"%s\"", strings.Join(codecStrings, ", ")) + } else { + result.MimeCodec = "video/x-matroska" + } + + mp.extractedMetadata = result + }) + + return mp.extractedMetadata +} + +// ExtractSubtitles extracts subtitles from a streaming source by reading it as a continuous flow. +// If an offset is provided, it will seek to the cluster near the offset and start parsing from there. +// +// The function returns a channel of SubtitleEvent which will be closed when: +// - The context is canceled +// - The entire stream is processed +// - An unrecoverable error occurs (which is also returned in the error channel) +func (mp *MetadataParser) ExtractSubtitles(ctx context.Context, newReader io.ReadSeekCloser, offset int64, backoffBytes int64) (<-chan *SubtitleEvent, <-chan error, <-chan struct{}) { + subtitleCh := make(chan *SubtitleEvent) + errCh := make(chan error, 1) + startedCh := make(chan struct{}) + + var closeOnce sync.Once + closeChannels := func(err error) { + closeOnce.Do(func() { + select { + case errCh <- err: + default: // Channel might be full or closed, ignore + } + close(subtitleCh) + close(errCh) + }) + } + + // coordination between extraction goroutines + extractCtx, cancel := context.WithCancel(ctx) + + if offset > 0 { + mp.logger.Debug().Int64("offset", offset).Msg("mkvparser: Attempting to find cluster near offset") + + clusterSeekOffset, err := findNextClusterOffset(newReader, offset, backoffBytes) + if err != nil { + if !errors.Is(err, io.EOF) { + mp.logger.Error().Err(err).Msg("mkvparser: Failed to seek to offset for subtitle extraction") + } + cancel() + closeChannels(err) + return subtitleCh, errCh, startedCh + } + + close(startedCh) + + mp.logger.Debug().Int64("clusterSeekOffset", clusterSeekOffset).Msg("mkvparser: Found cluster near offset") + + _, err = newReader.Seek(clusterSeekOffset, io.SeekStart) + if err != nil { + mp.logger.Error().Err(err).Msg("mkvparser: Failed to seek to cluster offset") + cancel() + closeChannels(err) + return subtitleCh, errCh, startedCh + } + } else { + close(startedCh) + } + + go func() { + defer util.HandlePanicInModuleThen("mkvparser/ExtractSubtitles", func() { + closeChannels(fmt.Errorf("subtitle extraction goroutine panic")) + }) + defer cancel() // Ensure context is cancelled when main goroutine exits + defer mp.logger.Trace().Msgf("mkvparser: Subtitle extraction goroutine finished.") + + sampler := lo.ToPtr(mp.logger.Sample(&zerolog.BasicSampler{N: 500})) + + // First, ensure metadata is parsed to get track information + mp.parseMetadataOnce(extractCtx) + + if mp.parseErr != nil && !errors.Is(mp.parseErr, io.EOF) && !strings.Contains(mp.parseErr.Error(), "unexpected EOF") { + mp.logger.Error().Err(mp.parseErr).Msg("mkvparser: ExtractSubtitles cannot proceed due to initial metadata parsing error") + closeChannels(fmt.Errorf("initial metadata parse failed: %w", mp.parseErr)) + return + } + + // Create a map of subtitle tracks for quick lookup + subtitleTracks := make(map[uint64]*TrackInfo) + for _, track := range mp.tracks { + if track.Type == TrackTypeSubtitle { + subtitleTracks[uint64(track.Number)] = track + } + } + + if len(subtitleTracks) == 0 { + mp.logger.Info().Msg("mkvparser: No subtitle tracks found for streaming") + closeChannels(nil) + return + } + + handler := &subtitleHandler{ + mp: mp, + ctx: extractCtx, // use extraction context instead of original context + logger: mp.logger, + sampler: sampler, + subtitleCh: subtitleCh, + subtitleTracks: subtitleTracks, + timecodeScale: mp.timecodeScale, + clusterTime: 0, + reader: newReader, + lastSubtitleEvents: make(map[uint64]*SubtitleEvent), + startedCh: startedCh, + } + + // Parse the stream for subtitles + err := gomkv.Parse(newReader, handler) + if err != nil && err != io.EOF && !strings.Contains(err.Error(), "unexpected EOF") { + //mp.logger.Error().Err(err).Msg("mkvparser: Unrecoverable error during subtitle stream parsing") + closeChannels(err) + } else { + mp.logger.Debug().Err(err).Msg("mkvparser: Subtitle streaming completed successfully or with expected EOF.") + closeChannels(nil) + } + }() + + return subtitleCh, errCh, startedCh +} + +// Handler for subtitle extraction +type subtitleHandler struct { + gomkv.DefaultHandler + mp *MetadataParser + ctx context.Context + logger *zerolog.Logger + sampler *zerolog.Logger + subtitleCh chan<- *SubtitleEvent + subtitleTracks map[uint64]*TrackInfo + timecodeScale uint64 + clusterTime uint64 + currentBlockDuration uint64 + reader io.ReadSeekCloser + lastSubtitleEvents map[uint64]*SubtitleEvent // Track last subtitle event per track for duration calculation + startedCh chan struct{} + // BlockGroup handling + inBlockGroup bool + pendingBlock *pendingSubtitleBlock +} + +// pendingSubtitleBlock holds block data until we have complete information +type pendingSubtitleBlock struct { + trackNum uint64 + timecode int16 + data []byte + duration uint64 + hasBlock bool + hasDuration bool + headPos int64 +} + +func (h *subtitleHandler) HandleMasterBegin(id gomkv.ElementID, info gomkv.ElementInfo) (bool, error) { + switch id { + case gomkv.SegmentElement: + return true, nil + case gomkv.ClusterElement: + return true, nil + case gomkv.BlockGroupElement: + h.inBlockGroup = true + headPos, _ := h.reader.Seek(0, io.SeekCurrent) + h.pendingBlock = &pendingSubtitleBlock{ + headPos: headPos, + } + return true, nil + } + return false, nil +} + +func (h *subtitleHandler) HandleMasterEnd(id gomkv.ElementID, info gomkv.ElementInfo) error { + switch id { + case gomkv.BlockGroupElement: + // Process the pending block if we have complete information + if h.pendingBlock != nil && h.pendingBlock.hasBlock { + // If we have duration from BlockDurationElement, use it; otherwise use track default + if h.pendingBlock.hasDuration { + h.processPendingBlock(h.pendingBlock.duration) + } else { + h.processPendingBlock(0) // Will fall back to track defaultDuration + } + } + h.inBlockGroup = false + h.pendingBlock = nil + h.currentBlockDuration = 0 + } + return nil +} + +func (h *subtitleHandler) HandleInteger(id gomkv.ElementID, value int64, info gomkv.ElementInfo) error { + if id == gomkv.TimecodeElement { + h.clusterTime = uint64(value) + } else if id == gomkv.BlockDurationElement { + if h.inBlockGroup && h.pendingBlock != nil { + h.pendingBlock.duration = uint64(value) + h.pendingBlock.hasDuration = true + } else { + h.currentBlockDuration = uint64(value) + } + } + return nil +} + +func (h *subtitleHandler) processPendingBlock(blockDuration uint64) { + if h.pendingBlock == nil || !h.pendingBlock.hasBlock { + return + } + + track, isSubtitle := h.subtitleTracks[h.pendingBlock.trackNum] + if !isSubtitle { + return + } + + // PGS subtitles are not supported + if track.CodecID == "S_HDMV/PGS" || getSubtitleTrackType(track.CodecID) == "unknown" { + return + } + + absoluteTimeScaled := h.clusterTime + uint64(h.pendingBlock.timecode) + timestampNs := absoluteTimeScaled * h.timecodeScale + milliseconds := float64(timestampNs) / 1e6 + + // Calculate duration in milliseconds + var duration float64 + if blockDuration > 0 { + duration = float64(blockDuration*h.timecodeScale) / 1e6 // ms + } else if track.defaultDuration > 0 { + duration = float64(track.defaultDuration) / 1e6 // ms + } + + h.processSubtitleData(h.pendingBlock.trackNum, track, h.pendingBlock.data, milliseconds, duration, h.pendingBlock.headPos) +} + +func (h *subtitleHandler) processSubtitleData(trackNum uint64, track *TrackInfo, subtitleData []byte, milliseconds, duration float64, headPos int64) { + if getSubtitleTrackType(track.CodecID) == "PGS" || getSubtitleTrackType(track.CodecID) == "unknown" { + return + } + + if track.contentEncodings != nil { + if zr, err := zlib.NewReader(bytes.NewReader(subtitleData)); err == nil { + if buf, err := io.ReadAll(zr); err == nil { + subtitleData = buf + } + _ = zr.Close() + } + } + + initialText := string(subtitleData) + subtitleEvent := &SubtitleEvent{ + TrackNumber: trackNum, + Text: initialText, + StartTime: milliseconds, + Duration: duration, + CodecID: track.CodecID, + ExtraData: make(map[string]string), + HeadPos: headPos, + } + + // Handling for ASS/SSA format + if track.CodecID == "S_TEXT/ASS" || track.CodecID == "S_TEXT/SSA" { + values := strings.Split(initialText, ",") + if len(values) < 9 { + //h.logger.Warn(). + // Str("text", initialText). + // Int("fields", len(values)). + // Msg("mkvparser: Invalid ASS/SSA subtitle format, not enough fields") + return + } + + // SSA_KEYS = ['readOrder', 'layer', 'style', 'name', 'marginL', 'marginR', 'marginV', 'effect', 'text'] + // For ASS: ignore readOrder (start from index 1), extract indices 1-7, text from index 8 + // For SSA: ignore readOrder and layer (start from index 2), extract indices 2-7, text from index 8 + + startIndex := 1 + if track.CodecID == "S_TEXT/SSA" { + startIndex = 2 + } + + // Map values to ExtraData based on SSA_KEYS array + ssaKeys := []string{"readorder", "layer", "style", "name", "marginl", "marginr", "marginv", "effect"} + + for i := startIndex; i < 8 && i < len(values); i++ { + if i < len(ssaKeys) { + subtitleEvent.ExtraData[ssaKeys[i]] = values[i] + } + } + + // Text is everything from index 8 onwards + if len(values) > 8 { + text := strings.Join(values[8:], ",") + subtitleEvent.Text = strings.TrimSpace(text) + } + } else if track.CodecID == "S_TEXT/UTF8" { + // Convert UTF8 to ASS format + subtitleEvent.Text = UTF8ToASSText(initialText) + + subtitleEvent.CodecID = "S_TEXT/ASS" + subtitleEvent.ExtraData = make(map[string]string) + subtitleEvent.ExtraData["readorder"] = "0" + subtitleEvent.ExtraData["layer"] = "0" + subtitleEvent.ExtraData["style"] = "Default" + subtitleEvent.ExtraData["name"] = "Default" + subtitleEvent.ExtraData["marginl"] = "0" + subtitleEvent.ExtraData["marginr"] = "0" + } + + // Update the subtitle event duration after potential ASS/SSA calculation + subtitleEvent.Duration = duration + + // Handle previous subtitle event duration according to Matroska spec: + // If a subtitle has no duration, it should be displayed until the next subtitle is encountered + if lastEvent, exists := h.lastSubtitleEvents[trackNum]; exists { + // If the previous event had no duration, calculate it based on the time difference + if lastEvent.Duration == 0 { + calculatedDuration := milliseconds - lastEvent.StartTime + if calculatedDuration > 0 { + // Create a copy of the last event with updated duration + updatedLastEvent := *lastEvent + updatedLastEvent.Duration = calculatedDuration + + h.sampler.Trace(). + Uint64("trackNum", trackNum). + Float64("previousStartTime", lastEvent.StartTime). + Float64("calculatedDuration", calculatedDuration). + Str("previousText", lastEvent.Text). + Msg("mkvparser: Updated previous subtitle event duration") + + // Send the updated previous event with calculated duration + select { + case h.subtitleCh <- &updatedLastEvent: + // Successfully sent updated previous event + case <-h.ctx.Done(): + // h.logger.Debug().Msg("mkvparser: Subtitle sending cancelled by context.") + return + } + } + } + } + + // Store current event as the last event for this track + // Create a copy to avoid potential issues with pointer references + eventCopy := *subtitleEvent + h.lastSubtitleEvents[trackNum] = &eventCopy + + h.sampler.Trace(). + Uint64("trackNum", trackNum). + Float64("startTime", milliseconds). + Float64("duration", duration). + Str("codecId", track.CodecID). + Str("text", subtitleEvent.Text). + Interface("data", subtitleEvent.ExtraData). + Msg("mkvparser: Subtitle event") + + // Only send the current subtitle event if it has a duration > 0 + // Events without duration will be held and sent when their duration is calculated by the next event + if duration > 0 { + select { + case h.subtitleCh <- subtitleEvent: + // Successfully sent + case <-h.ctx.Done(): + // h.logger.Debug().Msg("mkvparser: Subtitle sending cancelled by context.") + return + } + } +} + +func (h *subtitleHandler) HandleBinary(id gomkv.ElementID, value []byte, info gomkv.ElementInfo) error { + switch id { + case gomkv.SimpleBlockElement, gomkv.BlockElement: + if len(value) < 4 { + return nil + } + + trackNum := uint64(value[0] & 0x7F) + track, isSubtitle := h.subtitleTracks[trackNum] + if !isSubtitle { + return nil + } + + blockTimecode := int16(value[1])<<8 | int16(value[2]) + subtitleData := value[4:] + + if h.inBlockGroup && h.pendingBlock != nil { + // Store block data for later processing when BlockGroup ends + h.pendingBlock.trackNum = trackNum + h.pendingBlock.timecode = blockTimecode + h.pendingBlock.data = make([]byte, len(subtitleData)) + copy(h.pendingBlock.data, subtitleData) + h.pendingBlock.hasBlock = true + } else { + // Process immediately for SimpleBlock or standalone Block + absoluteTimeScaled := h.clusterTime + uint64(blockTimecode) + timestampNs := absoluteTimeScaled * h.timecodeScale + milliseconds := float64(timestampNs) / 1e6 + + // Calculate duration in milliseconds + var duration float64 + if h.currentBlockDuration > 0 { + duration = float64(h.currentBlockDuration*h.timecodeScale) / 1e6 // ms + } else if track.defaultDuration > 0 { + duration = float64(track.defaultDuration) / 1e6 // ms + } + + // Get current position for SimpleBlock/standalone Block + headPos, _ := h.reader.Seek(0, io.SeekCurrent) + h.processSubtitleData(trackNum, track, subtitleData, milliseconds, duration, headPos) + + // Reset the block duration for the next block + h.currentBlockDuration = 0 + } + } + return nil +} + +// findNextClusterOffset searches for the Matroska Cluster ID in the ReadSeeker rs, +// starting from seekOffset. It returns the absolute file offset of the found Cluster ID, +// or an error. If found, the ReadSeeker's position is set to the start of the Cluster ID. +func findNextClusterOffset(rs io.ReadSeeker, seekOffset, backoffBytes int64) (int64, error) { + + // DEVNOTE: findNextClusterOffset is faster than findPrecedingOrCurrentClusterOffset + // however it's not ideal so we'll offset the offset by 1MB to avoid missing a cluster + //toRemove := int64(1 * 1024 * 1024) // 1MB + if seekOffset > backoffBytes { + seekOffset -= backoffBytes + } else { + seekOffset = 0 + } + + // Seek to the starting position + absPosOfNextRead, err := rs.Seek(seekOffset, io.SeekStart) + if err != nil { + return -1, fmt.Errorf("initial seek to %d failed: %w", seekOffset, err) + } + + mainBuf := make([]byte, clusterSearchChunkSize) + searchBuf := make([]byte, (len(matroskaClusterID)-1)+clusterSearchChunkSize) + + lenOverlapCarried := 0 // Length of overlap data copied into searchBuf's start from previous iteration + + for { + n, readErr := rs.Read(mainBuf) + + if n == 0 && readErr == io.EOF { + return -1, fmt.Errorf("cluster ID not found, EOF reached before reading new data") + } + if readErr != nil && readErr != io.EOF { + return -1, fmt.Errorf("error reading file: %w", readErr) + } + + copy(searchBuf[lenOverlapCarried:], mainBuf[:n]) + currentSearchWindow := searchBuf[:lenOverlapCarried+n] + + idx := bytes.Index(currentSearchWindow, matroskaClusterID) + if idx != -1 { + foundAtAbsoluteOffset := (absPosOfNextRead - int64(lenOverlapCarried)) + int64(idx) + + _, seekErr := rs.Seek(foundAtAbsoluteOffset, io.SeekStart) + if seekErr != nil { + return -1, fmt.Errorf("failed to seek to found cluster position %d: %w", foundAtAbsoluteOffset, seekErr) + } + return foundAtAbsoluteOffset, nil + } + + if readErr == io.EOF { + return -1, io.EOF + } + + if len(currentSearchWindow) >= len(matroskaClusterID)-1 { + lenOverlapCarried = len(matroskaClusterID) - 1 + copy(searchBuf[:lenOverlapCarried], currentSearchWindow[len(currentSearchWindow)-lenOverlapCarried:]) + } else { + lenOverlapCarried = len(currentSearchWindow) + copy(searchBuf[:lenOverlapCarried], currentSearchWindow) + } + + absPosOfNextRead += int64(n) + } +} + +// findPrecedingOrCurrentClusterOffset searches for the Matroska Cluster ID in the ReadSeeker rs, +// looking for a cluster that starts at or before targetFileOffset. It searches backwards from targetFileOffset. +// It returns the absolute file offset of the found Cluster ID, or an error. +// If found, the ReadSeeker's position is set to the start of the Cluster ID. +func findPrecedingOrCurrentClusterOffset(rs io.ReadSeeker, targetFileOffset int64) (int64, error) { + mainBuf := make([]byte, clusterSearchChunkSize) + searchBuf := make([]byte, (len(matroskaClusterID)-1)+clusterSearchChunkSize) + + // Start from targetFileOffset and work backwards + currentReadEndPos := targetFileOffset + int64(len(matroskaClusterID)) + lenOverlapCarried := 0 + + for { + // Calculate read position and size + readStartPos := currentReadEndPos - clusterSearchChunkSize + if readStartPos < 0 { + readStartPos = 0 + } + bytesToRead := currentReadEndPos - readStartPos + + // Check if we have enough data to potentially find a cluster + if bytesToRead < int64(len(matroskaClusterID)) { + return -1, fmt.Errorf("cluster ID not found at or before offset %d", targetFileOffset) + } + + // Seek and read + _, err := rs.Seek(readStartPos, io.SeekStart) + if err != nil { + return -1, fmt.Errorf("seek to %d failed: %w", readStartPos, err) + } + + n, readErr := rs.Read(mainBuf[:bytesToRead]) + if readErr != nil && readErr != io.EOF { + return -1, fmt.Errorf("error reading file: %w", readErr) + } + if n == 0 { + return -1, fmt.Errorf("no data read at offset %d", readStartPos) + } + + // Copy data to search buffer + copy(searchBuf[lenOverlapCarried:], mainBuf[:n]) + currentSearchWindow := searchBuf[:lenOverlapCarried+n] + + // Search for cluster ID in current window + for i := len(currentSearchWindow) - len(matroskaClusterID); i >= 0; i-- { + if bytes.Equal(currentSearchWindow[i:i+len(matroskaClusterID)], matroskaClusterID) { + foundOffset := readStartPos + int64(i) + if foundOffset <= targetFileOffset { + _, seekErr := rs.Seek(foundOffset, io.SeekStart) + if seekErr != nil { + return -1, fmt.Errorf("failed to seek to found cluster at %d: %w", foundOffset, seekErr) + } + return foundOffset, nil + } + } + } + + // Check search depth limit + if (targetFileOffset - readStartPos) >= clusterSearchDepth { + return -1, fmt.Errorf("cluster ID not found within search depth %dMB", clusterSearchDepth/1024/1024) + } + + // If we've reached the start of the file, we're done + if readStartPos == 0 { + return -1, fmt.Errorf("cluster ID not found, reached start of file") + } + + // Prepare for next iteration + // Keep overlap from start of current window for next search + if len(currentSearchWindow) >= len(matroskaClusterID)-1 { + lenOverlapCarried = len(matroskaClusterID) - 1 + copy(searchBuf[:lenOverlapCarried], currentSearchWindow[:lenOverlapCarried]) + } else { + lenOverlapCarried = len(currentSearchWindow) + copy(searchBuf[:lenOverlapCarried], currentSearchWindow) + } + + currentReadEndPos = readStartPos + int64(lenOverlapCarried) + } +} diff --git a/seanime-2.9.10/internal/mkvparser/mkvparser_subtitles.go b/seanime-2.9.10/internal/mkvparser/mkvparser_subtitles.go new file mode 100644 index 0000000..8bb906a --- /dev/null +++ b/seanime-2.9.10/internal/mkvparser/mkvparser_subtitles.go @@ -0,0 +1,146 @@ +package mkvparser + +import ( + "bytes" + "fmt" + "strings" + + "github.com/5rahim/go-astisub" +) + +const ( + SubtitleTypeASS = iota + SubtitleTypeSRT + SubtitleTypeSTL + SubtitleTypeTTML + SubtitleTypeWEBVTT + SubtitleTypeUnknown +) + +func isProbablySrt(content string) bool { + separatorCounts := strings.Count(content, "-->") + return separatorCounts > 5 +} + +func DetectSubtitleType(content string) int { + if strings.HasPrefix(strings.TrimSpace(content), "[Script Info]") { + return SubtitleTypeASS + } else if isProbablySrt(content) { + return SubtitleTypeSRT + } else if strings.Contains(content, "") { + return SubtitleTypeTTML + } else if strings.HasPrefix(strings.TrimSpace(content), "WEBVTT") { + return SubtitleTypeWEBVTT + } else if strings.Contains(content, "{\\") || strings.Contains(content, "\\N") { + return SubtitleTypeSTL + } + return SubtitleTypeUnknown +} + +func ConvertToASS(content string, from int) (string, error) { + var o *astisub.Subtitles + var err error + + reader := bytes.NewReader([]byte(content)) + +read: + switch from { + case SubtitleTypeSRT: + o, err = astisub.ReadFromSRT(reader) + case SubtitleTypeSTL: + o, err = astisub.ReadFromSTL(reader, astisub.STLOptions{IgnoreTimecodeStartOfProgramme: true}) + case SubtitleTypeTTML: + o, err = astisub.ReadFromTTML(reader) + case SubtitleTypeWEBVTT: + o, err = astisub.ReadFromWebVTT(reader) + case SubtitleTypeUnknown: + detectedType := DetectSubtitleType(content) + if detectedType == SubtitleTypeUnknown { + return "", fmt.Errorf("failed to detect subtitle format from content") + } + from = detectedType + goto read + default: + return "", fmt.Errorf("unsupported subtitle format: %d", from) + } + + if err != nil { + return "", fmt.Errorf("failed to read subtitles: %w", err) + } + + if o == nil { + return "", fmt.Errorf("failed to read subtitles: %w", err) + } + + o.Metadata = &astisub.Metadata{ + SSAScriptType: "v4.00+", + SSAWrapStyle: "0", + SSAPlayResX: &[]int{640}[0], + SSAPlayResY: &[]int{360}[0], + SSAScaledBorderAndShadow: true, + } + + //Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding + //Style: Default, Roboto Medium,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1.3,0,2,20,20,23,0 + o.Styles["Default"] = &astisub.Style{ + ID: "Default", + InlineStyle: &astisub.StyleAttributes{ + SSAFontName: "Roboto Medium", + SSAFontSize: &[]float64{24}[0], + SSAPrimaryColour: &astisub.Color{ + Red: 255, + Green: 255, + Blue: 255, + Alpha: 0, + }, + SSASecondaryColour: &astisub.Color{ + Red: 255, + Green: 0, + Blue: 0, + Alpha: 0, + }, + SSAOutlineColour: &astisub.Color{ + Red: 0, + Green: 0, + Blue: 0, + Alpha: 0, + }, + SSABackColour: &astisub.Color{ + Red: 0, + Green: 0, + Blue: 0, + Alpha: 0, + }, + SSABold: &[]bool{false}[0], + SSAItalic: &[]bool{false}[0], + SSAUnderline: &[]bool{false}[0], + SSAStrikeout: &[]bool{false}[0], + SSAScaleX: &[]float64{100}[0], + SSAScaleY: &[]float64{100}[0], + SSASpacing: &[]float64{0}[0], + SSAAngle: &[]float64{0}[0], + SSABorderStyle: &[]int{1}[0], + SSAOutline: &[]float64{1.3}[0], + SSAShadow: &[]float64{0}[0], + SSAAlignment: &[]int{2}[0], + SSAMarginLeft: &[]int{20}[0], + SSAMarginRight: &[]int{20}[0], + SSAMarginVertical: &[]int{23}[0], + SSAEncoding: &[]int{0}[0], + }, + } + + for _, item := range o.Items { + item.Style = &astisub.Style{ + ID: "Default", + } + } + + w := &bytes.Buffer{} + err = o.WriteToSSA(w) + if err != nil { + return "", fmt.Errorf("failed to write subtitles: %w", err) + } + + return w.String(), nil +} diff --git a/seanime-2.9.10/internal/mkvparser/mkvparser_subtitles_test.go b/seanime-2.9.10/internal/mkvparser/mkvparser_subtitles_test.go new file mode 100644 index 0000000..925ef54 --- /dev/null +++ b/seanime-2.9.10/internal/mkvparser/mkvparser_subtitles_test.go @@ -0,0 +1,37 @@ +package mkvparser + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConvertSRTToASS(t *testing.T) { + srt := `1 +00:00:00,000 --> 00:00:03,000 +Hello, world! + +2 +00:00:04,000 --> 00:00:06,000 +This is a <--> test. +` + out, err := ConvertToASS(srt, SubtitleTypeSRT) + require.NoError(t, err) + + require.Equal(t, `[Script Info] +PlayResX: 640 +PlayResY: 360 +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name, Alignment, Angle, BackColour, Bold, BorderStyle, Encoding, Fontname, Fontsize, Italic, MarginL, MarginR, MarginV, Outline, OutlineColour, PrimaryColour, ScaleX, ScaleY, SecondaryColour, Shadow, Spacing, Strikeout, Underline +Style: Default,2,0.000,&H00000000,0,1,0,Roboto Medium,24.000,0,20,20,23,1.300,&H00000000,&H00ffffff,100.000,100.000,&H000000ff,0.000,0.000,0,0 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,00:00:00.00,00:00:03.00,Default,,0,0,0,,Hello, world! +Dialogue: 0,00:00:04.00,00:00:06.00,Default,,0,0,0,,This is a <--> test. +`, out) +} diff --git a/seanime-2.9.10/internal/mkvparser/mkvparser_test.go b/seanime-2.9.10/internal/mkvparser/mkvparser_test.go new file mode 100644 index 0000000..62a41c6 --- /dev/null +++ b/seanime-2.9.10/internal/mkvparser/mkvparser_test.go @@ -0,0 +1,367 @@ +package mkvparser + +import ( + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "seanime/internal/util" + httputil "seanime/internal/util/http" + "seanime/internal/util/torrentutil" + "strings" + "testing" + "time" + + "github.com/anacrolix/torrent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + //testMagnet = util.Decode("bWFnbmV0Oj94dD11cm46YnRpaDpRRVI1TFlQSkFYWlFBVVlLSE5TTE80TzZNTlY2VUQ2QSZ0cj1odHRwJTNBJTJGJTJGbnlhYS50cmFja2VyLndmJTNBNzc3NyUyRmFubm91bmNlJnRyPXVkcCUzQSUyRiUyRnRyYWNrZXIuY29wcGVyc3VyZmVyLnRrJTNBNjk2OSUyRmFubm91bmNlJnRyPXVkcCUzQSUyRiUyRnRyYWNrZXIub3BlbnRyYWNrci5vcmclM0ExMzM3JTJGYW5ub3VuY2UmdHI9dWRwJTNBJTJGJTJGOS5yYXJiZy50byUzQTI3MTAlMkZhbm5vdW5jZSZ0cj11ZHAlM0ElMkYlMkY5LnJhcmJnLm1lJTNBMjcxMCUyRmFubm91bmNlJmRuPSU1QlN1YnNQbGVhc2UlNUQlMjBTb3Vzb3UlMjBubyUyMEZyaWVyZW4lMjAtJTIwMjglMjAlMjgxMDgwcCUyOSUyMCU1QjhCQkJDMjhDJTVELm1rdg==") + //testMagnet = util.Decode("bWFnbmV0Oj94dD11cm46YnRpaDpiMDA1MmU2OWZlOWJlYWEyYTc2ODIwOGY5M2ZkMGY1YmVkNTcxNWM1JmRuPVNBS0FNT1RPJTIwREFZUyUyMFMwMUUwNCUyMEhhcmQtQm9pbGVkJTIwUkVQQUNLJTIwMTA4MHAlMjBORiUyMFdFQi1ETCUyMEREUDUuMSUyMEglMjAyNjQlMjBNVUxUaS1WQVJZRyUyMCUyOE11bHRpLUF1ZGlvJTJDJTIwTXVsdGktU3VicyUyOSZ0cj1odHRwJTNBJTJGJTJGbnlhYS50cmFja2VyLndmJTNBNzc3NyUyRmFubm91bmNlJnRyPXVkcCUzQSUyRiUyRm9wZW4uc3RlYWx0aC5zaSUzQTgwJTJGYW5ub3VuY2UmdHI9dWRwJTNBJTJGJTJGdHJhY2tlci5vcGVudHJhY2tyLm9yZyUzQTEzMzclMkZhbm5vdW5jZSZ0cj11ZHAlM0ElMkYlMkZleG9kdXMuZGVzeW5jLmNvbSUzQTY5NjklMkZhbm5vdW5jZSZ0cj11ZHAlM0ElMkYlMkZ0cmFja2VyLnRvcnJlbnQuZXUub3JnJTNBNDUxJTJGYW5ub3VuY2U=") + testMagnet = "magnet:?xt=urn:btih:TZP5JOTCMYEDJYQSWFXTKREGJ2LNYMIU&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce&tr=http%3A%2F%2Fanidex.moe%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.internetwarriors.net%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.zer0day.to%3A1337%2Fannounce&dn=%5BGJM%5D%20Love%20Me%2C%20Love%20Me%20Not%20%28Omoi%2C%20Omoware%2C%20Furi%2C%20Furare%29%20%28BD%201080p%29%20%5B841C23CD%5D.mkv" + testHttpUrl = "" + testFile = util.Decode("L1VzZXJzL3JhaGltL0RvY3VtZW50cy9jb2xsZWN0aW9uL1NvdXNvdSBubyBGcmllcmVuL0ZyaWVyZW4uQmV5b25kLkpvdXJuZXlzLkVuZC5TMDFFMDEuMTA4MHAuQ1IuV0VCLURMLkFBQzIuMC5IaW4tVGFtLUVuZy1KcG4tR2VyLVNwYS1TcGEtRnJhLVBvci5ILjI2NC5NU3Vicy1Ub29uc0h1Yi5ta3Y=") + testFile2 = util.Decode("L1VzZXJzL3JhaGltL0RvY3VtZW50cy9jb2xsZWN0aW9uL0RhbmRhZGFuL1tTdWJzUGxlYXNlXSBEYW5kYWRhbiAtIDA0ICgxMDgwcCkgWzNEMkNDN0NGXS5ta3Y=") + // Timeout for torrent operations + torrentInfoTimeout = 60 * time.Second + // Timeout for metadata parsing test + metadataTestTimeout = 90 * time.Second + // Number of initial pieces to prioritize for header metadata + initialPiecesToPrioritize = 20 +) + +// getTestTorrentClient creates a new torrent client for testing. +func getTestTorrentClient(t *testing.T, tempDir string) *torrent.Client { + t.Helper() + cfg := torrent.NewDefaultClientConfig() + // Use a subdirectory within the temp dir for torrent data + cfg.DataDir = filepath.Join(tempDir, "torrent_data") + err := os.MkdirAll(cfg.DataDir, 0755) + if err != nil { + t.Fatalf("failed to create torrent data directory: %v", err) + } + + client, err := torrent.NewClient(cfg) + if err != nil { + t.Fatalf("failed to create torrent client: %v", err) + } + return client +} + +// hasExt checks if a file path has a specific extension (case-insensitive). +func hasExt(name, ext string) bool { + if len(name) < len(ext) { + return false + } + return strings.ToLower(name[len(name)-len(ext):]) == strings.ToLower(ext) +} + +// hasVideoExt checks for common video file extensions. +func hasVideoExt(name string) bool { + return hasExt(name, ".mkv") || hasExt(name, ".mp4") || hasExt(name, ".avi") || hasExt(name, ".mov") || hasExt(name, ".webm") +} + +// getTestTorrentFile adds the torrent, waits for metadata, returns the first video file. +func getTestTorrentFile(t *testing.T, magnet string, tempDir string) (*torrent.Client, *torrent.Torrent, *torrent.File) { + t.Helper() + client := getTestTorrentClient(t, tempDir) + + tctx, cancel := context.WithTimeout(context.Background(), torrentInfoTimeout) + defer cancel() + + tor, err := client.AddMagnet(magnet) + if err != nil { + client.Close() // Close client on error + t.Fatalf("failed to add magnet: %v", err) + } + + t.Log("Waiting for torrent info...") + select { + case <-tor.GotInfo(): + t.Log("Torrent info received.") + // continue + case <-tctx.Done(): + tor.Drop() // Attempt to drop torrent + client.Close() // Close client + t.Fatalf("timeout waiting for torrent metadata (%v)", torrentInfoTimeout) + } + + // Find the first video file + for _, f := range tor.Files() { + path := f.DisplayPath() + + if hasVideoExt(path) { + t.Logf("Found video file: %s (Size: %d bytes)", path, f.Length()) + return client, tor, f + } + } + + t.Logf("No video file found in torrent info: %s", tor.Info().Name) + tor.Drop() // Drop torrent if no suitable file found + client.Close() // Close client + t.Fatalf("no video file found in torrent") + return nil, nil, nil // Should not be reached +} + +func assertTestResult(t *testing.T, result *Metadata) { + + //util.Spew(result) + + if result.Error != nil { + // If the error is context timeout/canceled, it's less severe but still worth noting + if errors.Is(result.Error, context.DeadlineExceeded) || errors.Is(result.Error, context.Canceled) { + t.Logf("Warning: GetMetadata context deadline exceeded or canceled: %v", result.Error) + } else { + t.Errorf("GetMetadata failed with unexpected error: %v", result.Error) + } + } else if result.Error != nil { + t.Logf("Note: GetMetadata stopped with expected error: %v", result.Error) + } + + // Check Duration (should be positive for this known file) + assert.True(t, result.Duration > 0, "Expected Duration to be positive, got %.2f", result.Duration) + t.Logf("Duration: %.2f seconds", result.Duration) + + // Check TimecodeScale + assert.True(t, result.TimecodeScale > 0, "Expected TimecodeScale to be positive, got %f", result.TimecodeScale) + t.Logf("TimecodeScale: %f", result.TimecodeScale) + + // Check Muxing/Writing App (often present) + if result.MuxingApp != "" { + t.Logf("MuxingApp: %s", result.MuxingApp) + } + if result.WritingApp != "" { + t.Logf("WritingApp: %s", result.WritingApp) + } + + // Check Tracks (expecting video, audio, subs for this file) + assert.NotEmpty(t, result.Tracks, "Expected to find tracks") + t.Logf("Found %d total tracks:", len(result.Tracks)) + foundVideo := false + foundAudio := false + for i, track := range result.Tracks { + t.Logf(" Track %d:\n Type=%s, Codec=%s, Lang=%s, Name='%s', Default=%v, Forced=%v, Enabled=%v", + i, track.Type, track.CodecID, track.Language, track.Name, track.Default, track.Forced, track.Enabled) + if track.Video != nil { + foundVideo = true + assert.True(t, track.Video.PixelWidth > 0, "Video track should have PixelWidth > 0") + assert.True(t, track.Video.PixelHeight > 0, "Video track should have PixelHeight > 0") + t.Logf(" Video Details: %dx%d", track.Video.PixelWidth, track.Video.PixelHeight) + } + if track.Audio != nil { + foundAudio = true + assert.True(t, track.Audio.SamplingFrequency > 0, "Audio track should have SamplingFrequency > 0") + assert.True(t, track.Audio.Channels > 0, "Audio track should have Channels > 0") + t.Logf(" Audio Details: Freq=%.1f, Channels=%d, BitDepth=%d", track.Audio.SamplingFrequency, track.Audio.Channels, track.Audio.BitDepth) + } + t.Log() + } + assert.True(t, foundVideo, "Expected to find at least one video track") + assert.True(t, foundAudio, "Expected to find at least one audio track") + + t.Logf("Found %d total chapters:", len(result.Chapters)) + for _, chapter := range result.Chapters { + t.Logf(" Chapter %d: StartTime=%.2f, EndTime=%.2f, Name='%s'", + chapter.UID, chapter.Start, chapter.End, chapter.Text) + } + + t.Logf("Found %d total attachments:", len(result.Attachments)) + for _, att := range result.Attachments { + t.Logf(" Attachment %d: Name='%s', MimeType='%s', Size=%d bytes", + att.UID, att.Filename, att.Mimetype, att.Size) + } + + // Print the JSON representation of the result + //jsonResult, err := json.MarshalIndent(result, "", " ") + //if err != nil { + // t.Fatalf("Failed to marshal result to JSON: %v", err) + //} + //t.Logf("JSON Result: %s", string(jsonResult)) +} + +func testStreamSubtitles(t *testing.T, parser *MetadataParser, reader io.ReadSeekCloser, offset int64, ctx context.Context) { + // Stream for 30 seconds + streamCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + subtitleCh, errCh, _ := parser.ExtractSubtitles(streamCtx, reader, offset, 1024*1024) + + var streamedSubtitles []*SubtitleEvent + + // Collect subtitles with a timeout + collectDone := make(chan struct{}) + go func() { + defer func() { + // Close the reader if it implements io.Closer + if closer, ok := reader.(io.Closer); ok { + _ = closer.Close() + } + }() + defer close(collectDone) + for { + select { + case subtitle, ok := <-subtitleCh: + if !ok { + return // Channel closed + } + streamedSubtitles = append(streamedSubtitles, subtitle) + case <-streamCtx.Done(): + return // Timeout + } + } + }() + + // Wait for all subtitles or timeout + select { + case <-collectDone: + // All subtitles collected + case <-streamCtx.Done(): + t.Log("StreamSubtitles collection timed out (this is expected for large files)") + } + + // Check for errors + select { + case err := <-errCh: + if err != nil { + t.Logf("StreamSubtitles returned an error: %v", err) + } + default: + // No errors yet + } + + t.Logf("Found %d streamed subtitles:", len(streamedSubtitles)) + for i, sub := range streamedSubtitles { + if i < 5 { // Log first 5 subtitles + t.Logf(" Streamed Subtitle %d: TrackNumber=%d, StartTime=%.2f, Text='%s'", + i, sub.TrackNumber, sub.StartTime, sub.Text) + } + } +} + +// TestMetadataParser_Torrent performs an integration test. +// It downloads the header of a real torrent and parses its metadata. +func TestMetadataParser_Torrent(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + tempDir := t.TempDir() + client, tor, file := getTestTorrentFile(t, testMagnet, tempDir) + + // Ensure client and torrent are closed/dropped eventually + t.Cleanup(func() { + t.Log("Dropping torrent...") + tor.Drop() + t.Log("Closing torrent client...") + client.Close() + t.Log("Cleanup finished.") + }) + + logger := util.NewLogger() + parser := NewMetadataParser(file.NewReader(), logger) + + // Create context with timeout for the metadata parsing operation itself + ctx, cancel := context.WithTimeout(context.Background(), metadataTestTimeout) + defer cancel() + + t.Log("Calling file.Download() to enable piece requests...") + file.Download() // Start download requests + + // Prioritize initial pieces to ensure metadata is fetched quickly + torInfo := tor.Info() + if torInfo != nil && torInfo.NumPieces() > 0 { + numPieces := torInfo.NumPieces() + piecesToFetch := initialPiecesToPrioritize + if numPieces < piecesToFetch { + piecesToFetch = numPieces + } + t.Logf("Prioritizing first %d pieces (out of %d) for header parsing...", piecesToFetch, numPieces) + for i := 0; i < piecesToFetch; i++ { + p := tor.Piece(i) + if p != nil { + p.SetPriority(torrent.PiecePriorityNow) + } + } + // Give a moment for prioritization to take effect and requests to start + time.Sleep(500 * time.Millisecond) + } else { + t.Log("Torrent info or pieces not available for prioritization.") + } + + t.Log("Calling GetMetadata...") + startTime := time.Now() + metadata := parser.GetMetadata(ctx) + elapsed := time.Since(startTime) + t.Logf("GetMetadata took %v", elapsed) + + assertTestResult(t, metadata) + + testStreamSubtitles(t, parser, torrentutil.NewReadSeeker(tor, file, logger), 78123456, ctx) +} + +// TestMetadataParser_HTTPStream tests parsing from an HTTP stream +func TestMetadataParser_HTTPStream(t *testing.T) { + if testHttpUrl == "" { + t.Skip("Skipping HTTP stream test") + } + + logger := util.NewLogger() + + res, err := http.Get(testHttpUrl) + if err != nil { + t.Fatalf("HTTP GET request failed: %v", err) + } + defer res.Body.Close() + + rs := httputil.NewHttpReadSeeker(res) + + if res.StatusCode != http.StatusOK { + t.Fatalf("HTTP GET request returned non-OK status: %s", res.Status) + } + + parser := NewMetadataParser(rs, logger) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // 30-second timeout for parsing + defer cancel() + + metadata := parser.GetMetadata(ctx) + + assertTestResult(t, metadata) + + _, err = rs.Seek(0, io.SeekStart) + require.NoError(t, err) + + testStreamSubtitles(t, parser, rs, 1230000000, ctx) +} + +func TestMetadataParser_File(t *testing.T) { + if testFile == "" { + t.Skip("Skipping file test") + } + + logger := util.NewLogger() + + file, err := os.Open(testFile) + if err != nil { + t.Fatalf("Could not open file: %v", err) + } + defer file.Close() + + parser := NewMetadataParser(file, logger) + + ctx, _ := context.WithTimeout(context.Background(), 30*time.Second) // 30-second timeout for parsing + //defer cancel() + + metadata := parser.GetMetadata(ctx) + + assertTestResult(t, metadata) + + _, err = file.Seek(0, io.SeekStart) + require.NoError(t, err) + + testStreamSubtitles(t, parser, file, 1230000000, ctx) +} diff --git a/seanime-2.9.10/internal/mkvparser/mkvparser_utils.go b/seanime-2.9.10/internal/mkvparser/mkvparser_utils.go new file mode 100644 index 0000000..6f47886 --- /dev/null +++ b/seanime-2.9.10/internal/mkvparser/mkvparser_utils.go @@ -0,0 +1,183 @@ +package mkvparser + +import ( + "bytes" + "io" + "strings" +) + +// ReadIsMkvOrWebm reads the first 1KB of the stream to determine if it is a Matroska or WebM file. +// It returns the mime type and a boolean indicating if it is a Matroska or WebM file. +// It seeks to the beginning of the stream before and after reading. +func ReadIsMkvOrWebm(r io.ReadSeeker) (string, bool) { + // Go to the beginning of the stream + _, err := r.Seek(0, io.SeekStart) + if err != nil { + return "", false + } + defer r.Seek(0, io.SeekStart) + + return isMkvOrWebm(r) +} + +func isMkvOrWebm(r io.Reader) (string, bool) { + header := make([]byte, 1024) // Read the first 1KB to be safe + n, err := r.Read(header) + if err != nil { + return "", false + } + + // Check for EBML magic bytes + if !bytes.HasPrefix(header, []byte{0x1A, 0x45, 0xDF, 0xA3}) { + return "", false + } + + // Look for the DocType tag (0x42 82) and check the string + docTypeTag := []byte{0x42, 0x82} + idx := bytes.Index(header, docTypeTag) + if idx == -1 || idx+3 >= n { + return "", false + } + + size := int(header[idx+2]) // Size of DocType field + if idx+3+size > n { + return "", false + } + + docType := string(header[idx+3 : idx+3+size]) + switch docType { + case "matroska": + return "video/x-matroska", true + case "webm": + return "video/webm", true + default: + return "", false + } +} + +// UTF8ToASSText +// +// note: needs testing +func UTF8ToASSText(text string) string { + // Convert HTML entities to actual characters + type tags struct { + values []string + replace string + } + t := []tags{ + {values: []string{"<"}, replace: "<"}, + {values: []string{">"}, replace: ">"}, + {values: []string{"&"}, replace: "&"}, + {values: []string{" "}, replace: "\\h"}, + {values: []string{"""}, replace: "\""}, + {values: []string{"'"}, replace: "'"}, + {values: []string{"'"}, replace: "'"}, + {values: []string{"«"}, replace: "«"}, + {values: []string{"»"}, replace: "»"}, + {values: []string{"–"}, replace: "-"}, + {values: []string{"—"}, replace: "—"}, + {values: []string{"…"}, replace: "…"}, + {values: []string{"©"}, replace: "©"}, + {values: []string{"®"}, replace: "®"}, + {values: []string{"™"}, replace: "™"}, + {values: []string{"€"}, replace: "€"}, + {values: []string{"£"}, replace: "£"}, + {values: []string{"¥"}, replace: "¥"}, + {values: []string{"$"}, replace: "$"}, + {values: []string{"¢"}, replace: "¢"}, + // + {values: []string{"\r\n", "\n", "\r", "
", "
", "
", "
", "
", "
"}, replace: "\\N"}, + {values: []string{"", "", ""}, replace: "{\\b1}"}, + {values: []string{"", "", ""}, replace: "{\\b0}"}, + {values: []string{"", "", ""}, replace: "{\\i1}"}, + {values: []string{"", "", ""}, replace: "{\\i0}"}, + {values: []string{"", ""}, replace: "{\\u1}"}, + {values: []string{"", ""}, replace: "{\\u0}"}, + {values: []string{"", "", "", ""}, replace: "{\\s1}"}, + {values: []string{"", "", "", ""}, replace: "{\\s0}"}, + {values: []string{"
", "
"}, replace: "{\\an8}"}, + {values: []string{"
", "
"}, replace: ""}, + {values: []string{"", ""}, replace: "{\\ruby1}"}, + {values: []string{"", ""}, replace: "{\\ruby0}"}, + {values: []string{"

", "

", "

", "
"}, replace: ""}, + {values: []string{"

", "

", "
", "
"}, replace: "\\N"}, + } + + for _, tag := range t { + for _, value := range tag.values { + text = strings.ReplaceAll(text, value, tag.replace) + } + } + + // Font tags with color and size + if strings.Contains(text, "") + if tagEnd == -1 { + break + } + tagEnd += tagStart + + // Extract the font tag content + fontTag := text[tagStart : tagEnd+1] + replacement := "" + + // Handle color attribute + if colorStart := strings.Index(fontTag, "color=\""); colorStart != -1 { + colorStart += 7 // length of 'color="' + if colorEnd := strings.Index(fontTag[colorStart:], "\""); colorEnd != -1 { + color := fontTag[colorStart : colorStart+colorEnd] + // Convert HTML color to ASS format + if strings.HasPrefix(color, "#") { + if len(color) == 7 { // #RRGGBB format + color = "&H" + color[5:7] + color[3:5] + color[1:3] + "&" // Convert to ASS BGR format + } + } + replacement += "{\\c" + color + "}" + } + } + + // Handle size attribute + if sizeStart := strings.Index(fontTag, "size=\""); sizeStart != -1 { + sizeStart += 6 // length of 'size="' + if sizeEnd := strings.Index(fontTag[sizeStart:], "\""); sizeEnd != -1 { + size := fontTag[sizeStart : sizeStart+sizeEnd] + replacement += "{\\fs" + size + "}" + } + } + + // Handle face/family attribute + if faceStart := strings.Index(fontTag, "face=\""); faceStart != -1 { + faceStart += 6 // length of 'face="' + if faceEnd := strings.Index(fontTag[faceStart:], "\""); faceEnd != -1 { + face := fontTag[faceStart : faceStart+faceEnd] + replacement += "{\\fn" + face + "}" + } + } + + // Replace the opening font tag + text = text[:tagStart] + replacement + text[tagEnd+1:] + + // Find and remove the corresponding closing tag + if closeStart := strings.Index(text, ""); closeStart != -1 { + text = text[:closeStart] + "{\\r}" + text[closeStart+7:] + } else if closeStart = strings.Index(text, ""); closeStart != -1 { + text = text[:closeStart] + "{\\r}" + text[closeStart+7:] + } + } + } + + return text +} diff --git a/seanime-2.9.10/internal/mkvparser/structs.go b/seanime-2.9.10/internal/mkvparser/structs.go new file mode 100644 index 0000000..64f8415 --- /dev/null +++ b/seanime-2.9.10/internal/mkvparser/structs.go @@ -0,0 +1,115 @@ +package mkvparser + +import ( + "time" +) + +// Info element and its children +type Info struct { + Title string + MuxingApp string + WritingApp string + TimecodeScale uint64 + Duration float64 + DateUTC time.Time +} + +// TrackEntry represents a track in the MKV file +type TrackEntry struct { + TrackNumber uint64 + TrackUID uint64 + TrackType uint64 + FlagEnabled uint64 + FlagDefault uint64 + FlagForced uint64 + DefaultDuration uint64 + Name string + Language string + LanguageIETF string + CodecID string + CodecPrivate []byte + Video *VideoTrack + Audio *AudioTrack + ContentEncodings *ContentEncodings +} + +// VideoTrack contains video-specific track data +type VideoTrack struct { + PixelWidth uint64 + PixelHeight uint64 +} + +// AudioTrack contains audio-specific track data +type AudioTrack struct { + SamplingFrequency float64 + Channels uint64 + BitDepth uint64 +} + +// ContentEncodings contains information about how the track data is encoded +type ContentEncodings struct { + ContentEncoding []ContentEncoding +} + +// ContentEncoding describes a single encoding applied to the track data +type ContentEncoding struct { + ContentEncodingOrder uint64 + ContentEncodingScope uint64 + ContentEncodingType uint64 + ContentCompression *ContentCompression +} + +// ContentCompression describes how the track data is compressed +type ContentCompression struct { + ContentCompAlgo uint64 + ContentCompSettings []byte +} + +// ChapterAtom represents a single chapter point +type ChapterAtom struct { + ChapterUID uint64 + ChapterTimeStart uint64 + ChapterTimeEnd uint64 + ChapterDisplay []ChapterDisplay +} + +// ChapterDisplay contains displayable chapter information +type ChapterDisplay struct { + ChapString string + ChapLanguage []string + ChapLanguageIETF []string +} + +// AttachedFile represents a file attached to the MKV container +type AttachedFile struct { + FileDescription string + FileName string + FileMimeType string + FileData []byte + FileUID uint64 +} + +// Block represents a data block in the MKV file +type Block struct { + TrackNumber uint64 + Timecode int16 + Data [][]byte +} + +// BlockGroup represents a group of blocks with additional information +type BlockGroup struct { + Block Block + BlockDuration uint64 +} + +// Cluster represents a cluster of blocks in the MKV file +type Cluster struct { + Timecode uint64 + SimpleBlock []Block + BlockGroup []BlockGroup +} + +// Tracks element and its children +type Tracks struct { + TrackEntry []TrackEntry `ebml:"TrackEntry"` +} diff --git a/seanime-2.9.10/internal/nakama/connect.go b/seanime-2.9.10/internal/nakama/connect.go new file mode 100644 index 0000000..30d841d --- /dev/null +++ b/seanime-2.9.10/internal/nakama/connect.go @@ -0,0 +1,214 @@ +package nakama + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + + "github.com/huin/goupnp/dcps/internetgateway1" + "github.com/huin/goupnp/dcps/internetgateway2" +) + +type UPnPClient interface { + GetExternalIPAddress() (string, error) + AddPortMapping(string, uint16, string, uint16, string, bool, string, uint32) error + DeletePortMapping(string, uint16, string) error +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Port forwarding +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func EnablePortForwarding(port int) (string, error) { + return enablePortForwarding(port) +} + +// enablePortForwarding enables port forwarding for a given port and returns the address. +func enablePortForwarding(port int) (string, error) { + // Try IGDv2 first, then fallback to IGDv1 + ip, err := addPortMappingIGD(func() ([]UPnPClient, error) { + clients, _, err := internetgateway2.NewWANIPConnection1Clients() + if err != nil { + return nil, err + } + upnpClients := make([]UPnPClient, len(clients)) + for i, client := range clients { + upnpClients[i] = client + } + return upnpClients, nil + }, port) + if err != nil { + ip, err = addPortMappingIGD(func() ([]UPnPClient, error) { + clients, _, err := internetgateway1.NewWANIPConnection1Clients() + if err != nil { + return nil, err + } + upnpClients := make([]UPnPClient, len(clients)) + for i, client := range clients { + upnpClients[i] = client + } + return upnpClients, nil + }, port) + if err != nil { + return "", fmt.Errorf("failed to add port mapping: %w", err) + } + } + + return fmt.Sprintf("http://%s:%d", ip, port), nil +} + +func disablePortForwarding(port int) error { + // Try to remove port mapping from both IGDv2 and IGDv1 + err1 := removePortMappingIGD(func() ([]UPnPClient, error) { + clients, _, err := internetgateway2.NewWANIPConnection1Clients() + if err != nil { + return nil, err + } + upnpClients := make([]UPnPClient, len(clients)) + for i, client := range clients { + upnpClients[i] = client + } + return upnpClients, nil + }, port) + err2 := removePortMappingIGD(func() ([]UPnPClient, error) { + clients, _, err := internetgateway1.NewWANIPConnection1Clients() + if err != nil { + return nil, err + } + upnpClients := make([]UPnPClient, len(clients)) + for i, client := range clients { + upnpClients[i] = client + } + return upnpClients, nil + }, port) + + // Return error only if both failed + if err1 != nil && err2 != nil { + return fmt.Errorf("failed to remove port mapping from IGDv2: %v, IGDv1: %v", err1, err2) + } + + return nil +} + +// addPortMappingIGD adds a port mapping using the provided client factory and returns the external IP +func addPortMappingIGD(clientFactory func() ([]UPnPClient, error), port int) (string, error) { + clients, err := clientFactory() + if err != nil { + return "", err + } + + for _, client := range clients { + // Get external IP address + externalIP, err := client.GetExternalIPAddress() + if err != nil { + continue // Try next client + } + + // Add port mapping + err = client.AddPortMapping( + "", // NewRemoteHost (empty for any) + uint16(port), // NewExternalPort + "TCP", // NewProtocol + uint16(port), // NewInternalPort + "127.0.0.1", // NewInternalClient (localhost) + true, // NewEnabled + "Seanime Nakama", // NewPortMappingDescription + uint32(3600), // NewLeaseDuration (1 hour) + ) + if err != nil { + continue // Try next client + } + + return externalIP, nil // Success + } + + return "", fmt.Errorf("no working UPnP clients found") +} + +// removePortMappingIGD removes a port mapping using the provided client factory +func removePortMappingIGD(clientFactory func() ([]UPnPClient, error), port int) error { + clients, err := clientFactory() + if err != nil { + return err + } + + for _, client := range clients { + err = client.DeletePortMapping( + "", // NewRemoteHost (empty for any) + uint16(port), // NewExternalPort + "TCP", // NewProtocol + ) + if err != nil { + continue // Try next client + } + + return nil // Success + } + + return fmt.Errorf("no working UPnP clients found") +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Join code (shelved) +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func EncryptJoinCode(ip string, port int, password string) (string, error) { + plainText := fmt.Sprintf("%s:%d", ip, port) + + // Derive 256-bit key from password + key := sha256.Sum256([]byte(password)) + + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plainText), nil) + return base64.RawURLEncoding.EncodeToString(ciphertext), nil +} + +func DecryptJoinCode(code, password string) (string, error) { + data, err := base64.RawURLEncoding.DecodeString(code) + if err != nil { + return "", err + } + + key := sha256.Sum256([]byte(password)) + + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} diff --git a/seanime-2.9.10/internal/nakama/connect_test.go b/seanime-2.9.10/internal/nakama/connect_test.go new file mode 100644 index 0000000..4046ed0 --- /dev/null +++ b/seanime-2.9.10/internal/nakama/connect_test.go @@ -0,0 +1,41 @@ +package nakama + +import ( + "testing" +) + +func TestPortForwarding(t *testing.T) { + // Test port forwarding for port 43211 + address, err := EnablePortForwarding(43211) + if err != nil { + t.Logf("Port forwarding failed (expected if no UPnP router available): %v", err) + t.Skip("No UPnP support available") + return + } + + t.Logf("Port forwarding enabled successfully: %s", address) + + // Clean up - disable the port forwarding + err = disablePortForwarding(43211) + if err != nil { + t.Logf("Warning: Failed to clean up port forwarding: %v", err) + } +} + +func TestEncryptJoinCode(t *testing.T) { + code, err := EncryptJoinCode("127.0.0.1", 4000, "password") + if err != nil { + t.Fatal(err) + } + + t.Logf("code: %s", code) + + addr, err := DecryptJoinCode(code, "password") + if err != nil { + t.Fatal(err) + } + + if addr != "127.0.0.1:4000" { + t.Fatal("invalid decrypted code") + } +} diff --git a/seanime-2.9.10/internal/nakama/handlers.go b/seanime-2.9.10/internal/nakama/handlers.go new file mode 100644 index 0000000..b14c806 --- /dev/null +++ b/seanime-2.9.10/internal/nakama/handlers.go @@ -0,0 +1,207 @@ +package nakama + +import ( + "encoding/json" + "errors" + "seanime/internal/events" + "time" +) + +// registerDefaultHandlers registers the default message handlers +func (m *Manager) registerDefaultHandlers() { + m.messageHandlers[MessageTypeAuth] = m.handleAuthMessage + m.messageHandlers[MessageTypeAuthReply] = m.handleAuthReplyMessage + m.messageHandlers[MessageTypePing] = m.handlePingMessage + m.messageHandlers[MessageTypePong] = m.handlePongMessage + m.messageHandlers[MessageTypeError] = m.handleErrorMessage + m.messageHandlers[MessageTypeCustom] = m.handleCustomMessage + + // Watch party handlers + m.messageHandlers[MessageTypeWatchPartyCreated] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyStopped] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyJoin] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyLeave] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyStateChanged] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyPlaybackStatus] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyPlaybackStopped] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyPeerStatus] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyBufferUpdate] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyRelayModeOriginStreamStarted] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyRelayModeOriginPlaybackStatus] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyRelayModePeersReady] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyRelayModePeerBuffering] = m.handleWatchPartyMessage + m.messageHandlers[MessageTypeWatchPartyRelayModeOriginPlaybackStopped] = m.handleWatchPartyMessage +} + +// handleMessage routes messages to the appropriate handler +func (m *Manager) handleMessage(message *Message, senderID string) error { + m.handlerMu.RLock() + handler, exists := m.messageHandlers[message.Type] + m.handlerMu.RUnlock() + + if !exists { + return errors.New("unknown message type: " + string(message.Type)) + } + + return handler(message, senderID) +} + +// handleAuthMessage handles authentication requests from peers +func (m *Manager) handleAuthMessage(message *Message, senderID string) error { + if !m.settings.IsHost { + return errors.New("not acting as host") + } + + // Parse auth payload + authData, err := json.Marshal(message.Payload) + if err != nil { + return err + } + + var authPayload AuthPayload + if err := json.Unmarshal(authData, &authPayload); err != nil { + return err + } + + // Get peer connection + peerConn, exists := m.peerConnections.Get(senderID) + if !exists { + return errors.New("peer connection not found") + } + + // Verify password + success := authPayload.Password == m.settings.HostPassword + var replyMessage string + if success { + // Update the peer connection with the PeerID from auth payload if not already set + if peerConn.PeerId == "" && authPayload.PeerId != "" { + peerConn.PeerId = authPayload.PeerId + } + + peerConn.Authenticated = true + replyMessage = "Authentication successful" + m.logger.Info().Str("peerID", peerConn.PeerId).Str("senderID", senderID).Msg("nakama: Peer authenticated successfully") + + // Send event to client about new peer connection + m.wsEventManager.SendEvent(events.NakamaPeerConnected, map[string]interface{}{ + "peerId": peerConn.PeerId, // Use PeerID for events + "authenticated": true, + }) + } else { + replyMessage = "Authentication failed" + m.logger.Warn().Str("peerId", peerConn.PeerId).Str("senderID", senderID).Msg("nakama: Peer authentication failed") + } + + // Send auth reply + authReply := &Message{ + Type: MessageTypeAuthReply, + Payload: AuthReplyPayload{ + Success: success, + Message: replyMessage, + Username: m.username, + PeerId: peerConn.PeerId, // Echo back the peer's UUID + }, + Timestamp: time.Now(), + } + + return peerConn.SendMessage(authReply) +} + +// handleAuthReplyMessage handles authentication replies from hosts +func (m *Manager) handleAuthReplyMessage(message *Message, senderID string) error { + // This should only be received by clients, and is handled in the client connection logic + // We can log it here for debugging purposes + m.logger.Debug().Str("senderID", senderID).Msg("nakama: Received auth reply") + return nil +} + +// handlePingMessage handles ping messages +func (m *Manager) handlePingMessage(message *Message, senderID string) error { + // Send pong response + pongMessage := &Message{ + Type: MessageTypePong, + Payload: nil, + Timestamp: time.Now(), + } + + if m.settings.IsHost { + // We're the host, send pong to peer + peerConn, exists := m.peerConnections.Get(senderID) + if !exists { + return errors.New("peer connection not found") + } + return peerConn.SendMessage(pongMessage) + } else { + // We're a client, send pong to host + m.hostMu.RLock() + defer m.hostMu.RUnlock() + if m.hostConnection == nil { + return errors.New("not connected to host") + } + return m.hostConnection.SendMessage(pongMessage) + } +} + +// handlePongMessage handles pong messages +func (m *Manager) handlePongMessage(message *Message, senderID string) error { + // Update last ping time + if m.settings.IsHost { + // Update peer's last ping time + peerConn, exists := m.peerConnections.Get(senderID) + if exists { + peerConn.LastPing = time.Now() + } + } else { + // Update host's last ping time + m.hostMu.Lock() + if m.hostConnection != nil { + m.hostConnection.LastPing = time.Now() + } + m.hostMu.Unlock() + } + return nil +} + +// handleErrorMessage handles error messages +func (m *Manager) handleErrorMessage(message *Message, senderID string) error { + // Parse error payload + errorData, err := json.Marshal(message.Payload) + if err != nil { + return err + } + + var errorPayload ErrorPayload + if err := json.Unmarshal(errorData, &errorPayload); err != nil { + return err + } + + m.logger.Error().Str("senderID", senderID).Str("errorMessage", errorPayload.Message).Str("errorCode", errorPayload.Code).Msg("nakama: Received error message") + + // Send event to client about the error + m.wsEventManager.SendEvent(events.NakamaError, map[string]interface{}{ + "senderID": senderID, + "message": errorPayload.Message, + "code": errorPayload.Code, + }) + + return nil +} + +// handleCustomMessage handles custom messages +func (m *Manager) handleCustomMessage(message *Message, senderID string) error { + m.logger.Debug().Str("senderID", senderID).Msg("nakama: Received custom message") + + // Send event to client with the custom message + m.wsEventManager.SendEvent(events.NakamaCustomMessage, map[string]interface{}{ + "senderID": senderID, + "payload": message.Payload, + "requestID": message.RequestID, + "timestamp": message.Timestamp, + }) + + return nil +} + +func (m *Manager) handleWatchPartyMessage(message *Message, senderID string) error { + return m.watchPartyManager.handleMessage(message, senderID) +} diff --git a/seanime-2.9.10/internal/nakama/host.go b/seanime-2.9.10/internal/nakama/host.go new file mode 100644 index 0000000..14a0e1d --- /dev/null +++ b/seanime-2.9.10/internal/nakama/host.go @@ -0,0 +1,238 @@ +package nakama + +import ( + "net/http" + "seanime/internal/constants" + "seanime/internal/events" + "seanime/internal/util" + "time" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow connections from any origin + }, +} + +// startHostServices initializes the host services +func (m *Manager) startHostServices() { + if m.settings == nil || !m.settings.IsHost || !m.settings.Enabled { + return + } + + m.logger.Info().Msg("nakama: Starting host services") + + // Clean up any existing watch party session + m.watchPartyManager.Cleanup() + + // Start ping routine for connected peers + go m.hostPingRoutine() + + // Start stale connection cleanup routine + go m.staleConnectionCleanupRoutine() + + // Send event to client about host mode being enabled + m.wsEventManager.SendEvent(events.NakamaHostStarted, map[string]interface{}{ + "enabled": true, + }) +} + +// stopHostServices stops the host services +func (m *Manager) stopHostServices() { + m.logger.Info().Msg("nakama: Stopping host services") + + // Disconnect all peers + m.peerConnections.Range(func(id string, conn *PeerConnection) bool { + conn.Close() + return true + }) + m.peerConnections.Clear() + + // Send event to client about host mode being disabled + m.wsEventManager.SendEvent(events.NakamaHostStopped, map[string]interface{}{ + "enabled": false, + }) +} + +// HandlePeerConnection handles incoming WebSocket connections from peers +func (m *Manager) HandlePeerConnection(w http.ResponseWriter, r *http.Request) { + if m.settings == nil || !m.settings.IsHost || !m.settings.Enabled { + http.Error(w, "Host mode not enabled", http.StatusForbidden) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + m.logger.Error().Err(err).Msg("nakama: Failed to upgrade WebSocket connection") + return + } + + username := r.Header.Get("X-Seanime-Nakama-Username") + // Generate a random username if username is not set (this shouldn't be the case because the peer will generate its own username) + if username == "" { + username = "Peer_" + util.RandomStringWithAlphabet(8, "bcdefhijklmnopqrstuvwxyz0123456789") + } + + peerID := r.Header.Get("X-Seanime-Nakama-Peer-Id") + if peerID == "" { + m.logger.Error().Msg("nakama: Peer connection missing PeerID header") + http.Error(w, "Missing PeerID header", http.StatusBadRequest) + return + } + + serverVersion := r.Header.Get("X-Seanime-Nakama-Server-Version") + if serverVersion != constants.Version { + http.Error(w, "Server version mismatch", http.StatusBadRequest) + return + } + + // Check for existing connection with the same PeerID (reconnection scenario) + var existingConnID string + m.peerConnections.Range(func(id string, existingConn *PeerConnection) bool { + if existingConn.PeerId == peerID { + existingConnID = id + return false // Stop iteration + } + return true + }) + + // Remove existing connection for this PeerID to handle reconnection + if existingConnID != "" { + if oldConn, exists := m.peerConnections.Get(existingConnID); exists { + m.logger.Info().Str("peerID", peerID).Str("oldConnID", existingConnID).Msg("nakama: Removing old connection for reconnecting peer") + m.peerConnections.Delete(existingConnID) + oldConn.Close() + } + } + + // Generate new internal connection ID + internalConnID := generateConnectionID() + peerConn := &PeerConnection{ + ID: internalConnID, + PeerId: peerID, + Username: username, + Conn: conn, + ConnectionType: ConnectionTypePeer, + Authenticated: false, + LastPing: time.Now(), + } + + m.logger.Info().Str("internalConnID", internalConnID).Str("peerID", peerID).Str("username", username).Msg("nakama: New peer connection") + + // Add to connections using internal connection ID as key + m.peerConnections.Set(internalConnID, peerConn) + + // Handle the connection in a goroutine + go m.handlePeerConnection(peerConn) +} + +// handlePeerConnection handles messages from a specific peer +func (m *Manager) handlePeerConnection(peerConn *PeerConnection) { + defer func() { + m.logger.Info().Str("peerId", peerConn.PeerId).Str("internalConnID", peerConn.ID).Msg("nakama: Peer disconnected") + + // Remove from connections (safe to call multiple times) + if _, exists := m.peerConnections.Get(peerConn.ID); exists { + m.peerConnections.Delete(peerConn.ID) + + // Remove peer from watch party if they were participating + m.watchPartyManager.HandlePeerDisconnected(peerConn.PeerId) + + // Send event to client about peer disconnection (only if we actually removed it) + m.wsEventManager.SendEvent(events.NakamaPeerDisconnected, map[string]interface{}{ + "peerId": peerConn.PeerId, + }) + } + + // Close connection (safe to call multiple times) + peerConn.Close() + }() + + // Set up ping/pong handler + peerConn.Conn.SetPongHandler(func(appData string) error { + peerConn.LastPing = time.Now() + return nil + }) + + // Set read deadline + peerConn.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + + for { + select { + case <-m.ctx.Done(): + return + default: + var message Message + err := peerConn.Conn.ReadJSON(&message) + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + m.logger.Error().Err(err).Str("peerId", peerConn.PeerId).Msg("nakama: Unexpected close error") + } + return + } + + // Handle the message using internal connection ID for message routing + if err := m.handleMessage(&message, peerConn.ID); err != nil { + m.logger.Error().Err(err).Str("peerId", peerConn.PeerId).Str("messageType", string(message.Type)).Msg("nakama: Failed to handle message") + + // Send error response + errorMsg := &Message{ + Type: MessageTypeError, + Payload: ErrorPayload{ + Message: err.Error(), + }, + Timestamp: time.Now(), + } + peerConn.SendMessage(errorMsg) + } + + // Reset read deadline + peerConn.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + } + } +} + +// hostPingRoutine sends ping messages to all connected peers +func (m *Manager) hostPingRoutine() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-m.ctx.Done(): + return + case <-ticker.C: + m.peerConnections.Range(func(id string, conn *PeerConnection) bool { + // Send ping + message := &Message{ + Type: MessageTypePing, + Payload: nil, + Timestamp: time.Now(), + } + + if err := conn.SendMessage(message); err != nil { + m.logger.Error().Err(err).Str("peerId", conn.PeerId).Msg("nakama: Failed to send ping") + // Don't close here, let the stale connection cleanup handle it + } + return true + }) + } + } +} + +// staleConnectionCleanupRoutine periodically removes stale connections +func (m *Manager) staleConnectionCleanupRoutine() { + ticker := time.NewTicker(120 * time.Second) // Run every 2 minutes + defer ticker.Stop() + + for { + select { + case <-m.ctx.Done(): + return + case <-ticker.C: + m.RemoveStaleConnections() + } + } +} diff --git a/seanime-2.9.10/internal/nakama/nakama.go b/seanime-2.9.10/internal/nakama/nakama.go new file mode 100644 index 0000000..a9d565a --- /dev/null +++ b/seanime-2.9.10/internal/nakama/nakama.go @@ -0,0 +1,646 @@ +package nakama + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "seanime/internal/database/models" + debrid_client "seanime/internal/debrid/client" + "seanime/internal/events" + "seanime/internal/library/playbackmanager" + "seanime/internal/platforms/platform" + "seanime/internal/torrentstream" + "seanime/internal/util" + "seanime/internal/util/result" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/imroc/req/v3" + "github.com/rs/zerolog" +) + +type Manager struct { + serverHost string + serverPort int + username string + logger *zerolog.Logger + settings *models.NakamaSettings + wsEventManager events.WSEventManagerInterface + platform platform.Platform + playbackManager *playbackmanager.PlaybackManager + torrentstreamRepository *torrentstream.Repository + debridClientRepository *debrid_client.Repository + peerId string + + // Host connections (when acting as host) + peerConnections *result.Map[string, *PeerConnection] + + // Host connection (when connecting to a host) + hostConnection *HostConnection + hostConnectionCtx context.Context + hostConnectionCancel context.CancelFunc + hostMu sync.RWMutex + reconnecting bool // Flag to prevent multiple concurrent reconnection attempts + + // Connection management + cancel context.CancelFunc + ctx context.Context + + // Message handlers + messageHandlers map[MessageType]func(*Message, string) error + handlerMu sync.RWMutex + + // Cleanup functions + cleanups []func() + + reqClient *req.Client + watchPartyManager *WatchPartyManager + + previousPath string // latest file streamed by the peer - real path on the host +} + +type NewManagerOptions struct { + Logger *zerolog.Logger + WSEventManager events.WSEventManagerInterface + PlaybackManager *playbackmanager.PlaybackManager + TorrentstreamRepository *torrentstream.Repository + DebridClientRepository *debrid_client.Repository + Platform platform.Platform + ServerHost string + ServerPort int +} + +type ConnectionType string + +const ( + ConnectionTypeHost ConnectionType = "host" + ConnectionTypePeer ConnectionType = "peer" +) + +// MessageType represents the type of message being sent +type MessageType string + +const ( + MessageTypeAuth MessageType = "auth" + MessageTypeAuthReply MessageType = "auth_reply" + MessageTypePing MessageType = "ping" + MessageTypePong MessageType = "pong" + MessageTypeError MessageType = "error" + + MessageTypeCustom MessageType = "custom" +) + +// Message represents a message sent between Nakama instances +type Message struct { + Type MessageType `json:"type"` + Payload interface{} `json:"payload"` + RequestID string `json:"requestId,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// PeerConnection represents a connection from a peer to this host +type PeerConnection struct { + ID string // Internal connection ID (websocket) + PeerId string // UUID generated by the peer (primary identifier) + Username string // Display name (kept for UI purposes) + Conn *websocket.Conn + ConnectionType ConnectionType + Authenticated bool + LastPing time.Time + mu sync.RWMutex +} + +// HostConnection represents this instance's connection to a host +type HostConnection struct { + URL string + PeerId string // UUID generated by this peer instance + Username string + Conn *websocket.Conn + Authenticated bool + LastPing time.Time + reconnectTimer *time.Timer + mu sync.RWMutex +} + +// NakamaEvent represents events sent to the client +type NakamaEvent struct { + Type string `json:"type"` + Payload interface{} `json:"payload"` +} + +// AuthPayload represents authentication data +type AuthPayload struct { + Password string `json:"password"` + PeerId string `json:"peerId"` // UUID generated by the peer +} + +// AuthReplyPayload represents authentication response +type AuthReplyPayload struct { + Success bool `json:"success"` + Message string `json:"message"` + Username string `json:"username"` + PeerId string `json:"peerId"` // Echo back the peer's UUID +} + +// ErrorPayload represents error messages +type ErrorPayload struct { + Message string `json:"message"` + Code string `json:"code,omitempty"` +} + +// HostConnectionStatus represents the status of the host connection +type HostConnectionStatus struct { + Connected bool `json:"connected"` + Authenticated bool `json:"authenticated"` + URL string `json:"url"` + LastPing time.Time `json:"lastPing"` + PeerId string `json:"peerId"` + Username string `json:"username"` +} + +// NakamaStatus represents the overall status of Nakama connections +type NakamaStatus struct { + IsHost bool `json:"isHost"` + ConnectedPeers []string `json:"connectedPeers"` + IsConnectedToHost bool `json:"isConnectedToHost"` + HostConnectionStatus *HostConnectionStatus `json:"hostConnectionStatus"` + CurrentWatchPartySession *WatchPartySession `json:"currentWatchPartySession"` +} + +// MessageResponse represents a response to message sending requests +type MessageResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +type ClientEvent struct { + Type string `json:"type"` + Payload interface{} `json:"payload"` +} + +func NewManager(opts *NewManagerOptions) *Manager { + ctx, cancel := context.WithCancel(context.Background()) + + m := &Manager{ + username: "", + logger: opts.Logger, + wsEventManager: opts.WSEventManager, + playbackManager: opts.PlaybackManager, + peerConnections: result.NewResultMap[string, *PeerConnection](), + platform: opts.Platform, + ctx: ctx, + cancel: cancel, + messageHandlers: make(map[MessageType]func(*Message, string) error), + cleanups: make([]func(), 0), + reqClient: req.C(), + serverHost: opts.ServerHost, + serverPort: opts.ServerPort, + settings: &models.NakamaSettings{}, + torrentstreamRepository: opts.TorrentstreamRepository, + debridClientRepository: opts.DebridClientRepository, + previousPath: "", + } + + m.watchPartyManager = NewWatchPartyManager(m) + + // Register default message handlers + m.registerDefaultHandlers() + + eventListener := m.wsEventManager.SubscribeToClientEvents("nakama") + go func() { + for event := range eventListener.Channel { + if event.Type == events.NakamaStatusRequested { + currSession, _ := m.GetWatchPartyManager().GetCurrentSession() + status := &NakamaStatus{ + IsHost: m.IsHost(), + ConnectedPeers: m.GetConnectedPeers(), + IsConnectedToHost: m.IsConnectedToHost(), + HostConnectionStatus: m.GetHostConnectionStatus(), + CurrentWatchPartySession: currSession, + } + m.wsEventManager.SendEvent(events.NakamaStatus, status) + } + + if event.Type == events.NakamaWatchPartyEnableRelayMode { + var payload WatchPartyEnableRelayModePayload + marshaledPayload, err := json.Marshal(event.Payload) + if err != nil { + m.logger.Error().Err(err).Msg("nakama: Failed to marshal watch party enable relay mode payload") + continue + } + err = json.Unmarshal(marshaledPayload, &payload) + if err != nil { + m.logger.Error().Err(err).Msg("nakama: Failed to unmarshal watch party enable relay mode payload") + continue + } + m.GetWatchPartyManager().EnableRelayMode(payload.PeerId) + } + } + }() + + return m +} + +func (m *Manager) SetSettings(settings *models.NakamaSettings) { + var previousSettings *models.NakamaSettings + if m.settings != nil { + previousSettings = &[]models.NakamaSettings{*m.settings}[0] + } + + // If the host password has changed, stop host service + // This will cause a restart of the host service + disconnectAsHost := false + if m.settings != nil && m.settings.HostPassword != settings.HostPassword { + disconnectAsHost = true + m.stopHostServices() + } + + m.settings = settings + m.username = cmp.Or(settings.Username, "Peer_"+util.RandomStringWithAlphabet(8, "bcdefhijklmnopqrstuvwxyz0123456789")) + m.logger.Debug().Bool("isHost", settings.IsHost).Str("username", m.username).Str("remoteURL", settings.RemoteServerURL).Msg("nakama: Settings updated") + + if previousSettings == nil || previousSettings.IsHost != settings.IsHost || previousSettings.Enabled != settings.Enabled || disconnectAsHost { + // Determine if we should stop host services + shouldStopHost := m.IsHost() && (!settings.Enabled || // Nakama disabled + !settings.IsHost || // Switching to peer mode + disconnectAsHost) // Password changed (requires restart) + + // Determine if we should start host services + shouldStartHost := settings.IsHost && settings.Enabled + + // Always stop first if needed, then start + if shouldStopHost { + m.stopHostServices() + } + if shouldStartHost { + m.startHostServices() + } + } + + if previousSettings == nil || previousSettings.RemoteServerURL != settings.RemoteServerURL || previousSettings.RemoteServerPassword != settings.RemoteServerPassword || previousSettings.Enabled != settings.Enabled { + // Determine if we should disconnect from current host + shouldDisconnect := m.IsConnectedToHost() && (!settings.Enabled || // Nakama disabled + settings.IsHost || // Switching to host mode + settings.RemoteServerURL == "" || // No remote URL + settings.RemoteServerPassword == "" || // No password + (previousSettings != nil && previousSettings.RemoteServerURL != settings.RemoteServerURL) || // URL changed + (previousSettings != nil && previousSettings.RemoteServerPassword != settings.RemoteServerPassword)) // Password changed + + // Determine if we should connect to a host + shouldConnect := !settings.IsHost && + settings.Enabled && + settings.RemoteServerURL != "" && + settings.RemoteServerPassword != "" + + // Always disconnect first if needed, then connect + if shouldDisconnect { + m.disconnectFromHost() + } + if shouldConnect { + m.connectToHost() + } + } + + // if previousSettings == nil || previousSettings.Username != settings.Username { + // m.SendMessage(MessageTypeCustom, map[string]interface{}{ + // "type": "nakama_username_changed", + // "username": settings.Username, + // }) + // } +} + +func (m *Manager) GetHostBaseServerURL() string { + url := m.settings.RemoteServerURL + if strings.HasSuffix(url, "/") { + url = strings.TrimSuffix(url, "/") + } + return url +} + +func (m *Manager) IsHost() bool { + return m.settings.IsHost +} + +func (m *Manager) GetHostConnection() (*HostConnection, bool) { + m.hostMu.RLock() + defer m.hostMu.RUnlock() + return m.hostConnection, m.hostConnection != nil +} + +// GetWatchPartyManager returns the watch party manager +func (m *Manager) GetWatchPartyManager() *WatchPartyManager { + return m.watchPartyManager +} + +// Cleanup stops all connections and services +func (m *Manager) Cleanup() { + m.logger.Debug().Msg("nakama: Cleaning up") + + if m.cancel != nil { + m.cancel() + } + + // Cancel any ongoing host connection attempts + m.hostMu.Lock() + if m.hostConnectionCancel != nil { + m.hostConnectionCancel() + m.hostConnectionCancel = nil + } + m.hostMu.Unlock() + + // Cleanup host connections + m.peerConnections.Range(func(id string, conn *PeerConnection) bool { + conn.Close() + return true + }) + m.peerConnections.Clear() + + // Cleanup client connection + m.hostMu.Lock() + if m.hostConnection != nil { + m.hostConnection.Close() + m.hostConnection = nil + } + m.hostMu.Unlock() + + // Run cleanup functions + for _, cleanup := range m.cleanups { + cleanup() + } +} + +// RegisterMessageHandler registers a custom message handler +func (m *Manager) RegisterMessageHandler(msgType MessageType, handler func(*Message, string) error) { + m.handlerMu.Lock() + defer m.handlerMu.Unlock() + m.messageHandlers[msgType] = handler +} + +// SendMessage sends a message to all connected peers (when acting as host) +func (m *Manager) SendMessage(msgType MessageType, payload interface{}) error { + if !m.settings.IsHost { + return errors.New("not acting as host") + } + + message := &Message{ + Type: msgType, + Payload: payload, + Timestamp: time.Now(), + } + + var lastError error + m.peerConnections.Range(func(id string, conn *PeerConnection) bool { + if err := conn.SendMessage(message); err != nil { + m.logger.Error().Err(err).Str("peerId", conn.PeerId).Msg("nakama: Failed to send message to peer") + lastError = err + } + return true + }) + + return lastError +} + +// SendMessageToPeer sends a message to a specific peer by their PeerID +func (m *Manager) SendMessageToPeer(peerID string, msgType MessageType, payload interface{}) error { + if !m.settings.IsHost { + return errors.New("only hosts can send messages to peers") + } + + // Find peer by PeerID + var targetConn *PeerConnection + m.peerConnections.Range(func(id string, conn *PeerConnection) bool { + if conn.PeerId == peerID { + targetConn = conn + return false // Stop iteration + } + return true + }) + + if targetConn == nil { + return errors.New("peer not found: " + peerID) + } + + message := &Message{ + Type: msgType, + Payload: payload, + Timestamp: time.Now(), + } + + return targetConn.SendMessage(message) +} + +// SendMessageToHost sends a message to the host (when acting as peer) +func (m *Manager) SendMessageToHost(msgType MessageType, payload interface{}) error { + m.hostMu.RLock() + defer m.hostMu.RUnlock() + + if m.hostConnection == nil || !m.hostConnection.Authenticated { + return errors.New("not connected to host") + } + + message := &Message{ + Type: msgType, + Payload: payload, + Timestamp: time.Now(), + } + + return m.hostConnection.SendMessage(message) +} + +// GetConnectedPeers returns a list of connected peer IDs +func (m *Manager) GetConnectedPeers() []string { + if !m.settings.IsHost { + return []string{} + } + + peers := make([]string, 0) + + m.peerConnections.Range(func(id string, conn *PeerConnection) bool { + if conn.Authenticated { + // Use PeerID as the primary identifier + peerDisplayName := conn.Username + if peerDisplayName == "" { + peerDisplayName = "Unknown" + } + // Format: "Username (PeerID_short)" + // peers = append(peers, fmt.Sprintf("%s (%s)", peerDisplayName, conn.PeerId[:8])) + peers = append(peers, fmt.Sprintf("%s", peerDisplayName)) + } + return true + }) + + return peers +} + +// IsConnectedToHost returns whether this instance is connected to a host +func (m *Manager) IsConnectedToHost() bool { + m.hostMu.RLock() + defer m.hostMu.RUnlock() + return m.hostConnection != nil && m.hostConnection.Authenticated +} + +// GetHostConnectionStatus returns the status of the host connection +func (m *Manager) GetHostConnectionStatus() *HostConnectionStatus { + m.hostMu.RLock() + defer m.hostMu.RUnlock() + + if m.hostConnection == nil { + return nil + } + + return &HostConnectionStatus{ + Connected: m.hostConnection != nil, + Authenticated: m.hostConnection != nil && m.hostConnection.Authenticated, + URL: m.hostConnection.URL, + LastPing: m.hostConnection.LastPing, + PeerId: m.hostConnection.PeerId, + Username: m.hostConnection.Username, + } +} + +func (pc *PeerConnection) SendMessage(message *Message) error { + pc.mu.Lock() + defer pc.mu.Unlock() + return pc.Conn.WriteJSON(message) +} + +func (pc *PeerConnection) Close() { + pc.mu.Lock() + defer pc.mu.Unlock() + _ = pc.Conn.Close() +} + +func (hc *HostConnection) SendMessage(message *Message) error { + hc.mu.Lock() + defer hc.mu.Unlock() + return hc.Conn.WriteJSON(message) +} + +func (hc *HostConnection) Close() { + hc.mu.Lock() + defer hc.mu.Unlock() + if hc.reconnectTimer != nil { + hc.reconnectTimer.Stop() + } + _ = hc.Conn.Close() +} + +// Helper function to generate connection IDs +func generateConnectionID() string { + return fmt.Sprintf("conn_%d", time.Now().UnixNano()) +} + +// ReconnectToHost attempts to reconnect to the host +func (m *Manager) ReconnectToHost() error { + if m.settings == nil || m.settings.RemoteServerURL == "" || m.settings.RemoteServerPassword == "" { + return errors.New("no host connection configured") + } + + // Check if already reconnecting + m.hostMu.Lock() + if m.reconnecting { + m.hostMu.Unlock() + return errors.New("reconnection already in progress") + } + m.hostMu.Unlock() + + m.logger.Info().Msg("nakama: Manual reconnection to host requested") + + // Disconnect current connection if exists + m.disconnectFromHost() + + // Wait a moment before reconnecting + time.Sleep(1 * time.Second) + + // Reconnect + m.connectToHost() + return nil +} + +// RemoveStaleConnections removes connections that haven't responded to ping in a while +func (m *Manager) RemoveStaleConnections() { + if !m.settings.IsHost { + return + } + + staleThreshold := 90 * time.Second // Consider connections stale after 90 seconds of no ping + now := time.Now() + + var staleConnections []string + + m.peerConnections.Range(func(id string, conn *PeerConnection) bool { + conn.mu.RLock() + lastPing := conn.LastPing + authenticated := conn.Authenticated + conn.mu.RUnlock() + + // Only check authenticated connections + if !authenticated { + return true + } + + // If LastPing is zero, use connection time as reference + if lastPing.IsZero() { + lastPing = now.Add(-staleThreshold - time.Minute) + } + + if now.Sub(lastPing) > staleThreshold { + staleConnections = append(staleConnections, id) + } + return true + }) + + // Remove stale connections + for _, id := range staleConnections { + if conn, exists := m.peerConnections.Get(id); exists { + // Double-check to avoid race conditions + conn.mu.RLock() + lastPing := conn.LastPing + if lastPing.IsZero() { + lastPing = now.Add(-staleThreshold - time.Minute) + } + isStale := now.Sub(lastPing) > staleThreshold + conn.mu.RUnlock() + + if isStale { + m.logger.Info().Str("peerId", conn.PeerId).Str("internalConnID", id).Msg("nakama: Removing stale peer connection") + + // Remove from map first to prevent re-addition + m.peerConnections.Delete(id) + + // Remove peer from watch party if they were participating + m.watchPartyManager.HandlePeerDisconnected(conn.PeerId) + + // Then close the connection (this will trigger the defer cleanup in handlePeerConnection) + conn.Close() + + // Send event about peer disconnection + m.wsEventManager.SendEvent(events.NakamaPeerDisconnected, map[string]interface{}{ + "peerId": conn.PeerId, + "reason": "stale_connection", + }) + } + } + } + + if len(staleConnections) > 0 { + m.logger.Info().Int("count", len(staleConnections)).Msg("nakama: Removed stale peer connections") + } +} + +// FindPeerByPeerID finds a peer connection by their PeerID +func (m *Manager) FindPeerByPeerID(peerID string) (*PeerConnection, bool) { + var found *PeerConnection + m.peerConnections.Range(func(id string, conn *PeerConnection) bool { + if conn.PeerId == peerID { + found = conn + return false // Stop iteration + } + return true + }) + return found, found != nil +} diff --git a/seanime-2.9.10/internal/nakama/peer.go b/seanime-2.9.10/internal/nakama/peer.go new file mode 100644 index 0000000..34671fd --- /dev/null +++ b/seanime-2.9.10/internal/nakama/peer.go @@ -0,0 +1,386 @@ +package nakama + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "seanime/internal/constants" + "seanime/internal/events" + "seanime/internal/util" + "strings" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +// connectToHost establishes a connection to the Nakama host +func (m *Manager) connectToHost() { + if m.settings == nil || !m.settings.Enabled || m.settings.RemoteServerURL == "" || m.settings.RemoteServerPassword == "" { + return + } + + m.logger.Info().Str("url", m.settings.RemoteServerURL).Msg("nakama: Connecting to host") + + // Cancel any existing connection attempts + m.hostMu.Lock() + if m.hostConnectionCancel != nil { + m.hostConnectionCancel() + } + + // Create new context for this connection attempt + m.hostConnectionCtx, m.hostConnectionCancel = context.WithCancel(m.ctx) + + // Prevent multiple concurrent connection attempts + if m.reconnecting { + m.hostMu.Unlock() + return + } + m.reconnecting = true + m.hostMu.Unlock() + + go m.connectToHostAsync() +} + +// disconnectFromHost disconnects from the Nakama host +func (m *Manager) disconnectFromHost() { + m.hostMu.Lock() + defer m.hostMu.Unlock() + + // Cancel any ongoing connection attempts + if m.hostConnectionCancel != nil { + m.hostConnectionCancel() + m.hostConnectionCancel = nil + } + + if m.hostConnection != nil { + m.logger.Info().Msg("nakama: Disconnecting from host") + + // Cancel any reconnection timer + if m.hostConnection.reconnectTimer != nil { + m.hostConnection.reconnectTimer.Stop() + } + + m.hostConnection.Close() + m.hostConnection = nil + + // Send event to client about disconnection + m.wsEventManager.SendEvent(events.NakamaHostDisconnected, map[string]interface{}{ + "connected": false, + }) + } + + // Reset reconnecting flag + m.reconnecting = false +} + +// connectToHostAsync handles the actual connection logic with retries +func (m *Manager) connectToHostAsync() { + defer func() { + m.hostMu.Lock() + m.reconnecting = false + m.hostMu.Unlock() + }() + + if m.settings == nil || !m.settings.Enabled || m.settings.RemoteServerURL == "" || m.settings.RemoteServerPassword == "" { + return + } + + // Get the connection context + m.hostMu.RLock() + connCtx := m.hostConnectionCtx + m.hostMu.RUnlock() + + if connCtx == nil { + return + } + + maxRetries := 5 + retryDelay := 5 * time.Second + + for attempt := 0; attempt < maxRetries; attempt++ { + select { + case <-connCtx.Done(): + m.logger.Info().Msg("nakama: Connection attempt cancelled") + return + case <-m.ctx.Done(): + return + default: + } + + if err := m.attemptHostConnection(connCtx); err != nil { + m.logger.Error().Err(err).Int("attempt", attempt+1).Msg("nakama: Failed to connect to host") + + if attempt < maxRetries-1 { + select { + case <-connCtx.Done(): + m.logger.Info().Msg("nakama: Connection attempt cancelled") + return + case <-m.ctx.Done(): + return + case <-time.After(retryDelay): + retryDelay *= 2 // Exponential backoff + continue + } + } + } else { + // Success + m.logger.Info().Msg("nakama: Successfully connected to host") + return + } + } + + // Only log error if not cancelled + select { + case <-connCtx.Done(): + m.logger.Info().Msg("nakama: Connection attempts cancelled") + default: + m.logger.Error().Msg("nakama: Failed to connect to host after all retries") + m.wsEventManager.SendEvent(events.ErrorToast, "Failed to connect to Nakama host after multiple attempts.") + } +} + +// attemptHostConnection makes a single connection attempt to the host +func (m *Manager) attemptHostConnection(connCtx context.Context) error { + // Parse URL + u, err := url.Parse(m.settings.RemoteServerURL) + if err != nil { + return err + } + + // Convert HTTP to WebSocket scheme + switch u.Scheme { + case "http": + u.Scheme = "ws" + case "https": + u.Scheme = "wss" + } + + // Add Nakama WebSocket path + if !strings.HasSuffix(u.Path, "/") { + u.Path += "/" + } + u.Path += "api/v1/nakama/ws" + + // Generate UUID for this peer instance + peerID := uuid.New().String() + + username := m.username + // Generate a random username if username is not set + if username == "" { + username = "Peer_" + util.RandomStringWithAlphabet(8, "bcdefhijklmnopqrstuvwxyz0123456789") + } + + // Set up headers for authentication + headers := http.Header{} + headers.Set("X-Seanime-Nakama-Token", m.settings.RemoteServerPassword) + headers.Set("X-Seanime-Nakama-Username", username) + headers.Set("X-Seanime-Nakama-Server-Version", constants.Version) + headers.Set("X-Seanime-Nakama-Peer-Id", peerID) + + // Create a dialer with the connection context + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + } + + // Connect with context + conn, _, err := dialer.DialContext(connCtx, u.String(), headers) + if err != nil { + return err + } + + hostConn := &HostConnection{ + URL: u.String(), + Conn: conn, + Authenticated: false, + LastPing: time.Now(), + PeerId: peerID, // Store our generated PeerID + } + + // Authenticate + authMessage := &Message{ + Type: MessageTypeAuth, + Payload: AuthPayload{ + Password: m.settings.RemoteServerPassword, + PeerId: peerID, // Include PeerID in auth payload + }, + Timestamp: time.Now(), + } + + if err := hostConn.SendMessage(authMessage); err != nil { + _ = conn.Close() + return err + } + + // Wait for auth response with timeout + _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + var authResponse Message + if err := conn.ReadJSON(&authResponse); err != nil { + _ = conn.Close() + return err + } + + if authResponse.Type != MessageTypeAuthReply { + _ = conn.Close() + return errors.New("unexpected auth response type") + } + + // Parse auth response + authReplyData, err := json.Marshal(authResponse.Payload) + if err != nil { + _ = conn.Close() + return err + } + + var authReply AuthReplyPayload + if err := json.Unmarshal(authReplyData, &authReply); err != nil { + _ = conn.Close() + return err + } + + if !authReply.Success { + _ = conn.Close() + return errors.New("authentication failed: " + authReply.Message) + } + + // Verify that the host echoed back our PeerID + if authReply.PeerId != peerID { + m.logger.Warn().Str("expectedPeerID", peerID).Str("receivedPeerID", authReply.PeerId).Msg("nakama: Host returned different PeerID") + } + + hostConn.Username = authReply.Username + if hostConn.Username == "" { + hostConn.Username = "Host_" + util.RandomStringWithAlphabet(8, "bcdefhijklmnopqrstuvwxyz0123456789") + } + hostConn.Authenticated = true + + // Set the connection and cancel any existing reconnection timer + m.hostMu.Lock() + if m.hostConnection != nil && m.hostConnection.reconnectTimer != nil { + m.hostConnection.reconnectTimer.Stop() + } + m.hostConnection = hostConn + m.hostMu.Unlock() + + // Send event to client about successful connection + m.wsEventManager.SendEvent(events.NakamaHostConnected, map[string]interface{}{ + "connected": true, + "authenticated": true, + "url": hostConn.URL, + "peerID": peerID, // Include our PeerID in the event + }) + + // Start handling the connection + go m.handleHostConnection(hostConn) + + // Start client ping routine + go m.clientPingRoutine() + + return nil +} + +// handleHostConnection handles messages from the host +func (m *Manager) handleHostConnection(hostConn *HostConnection) { + defer func() { + m.logger.Info().Msg("nakama: Host connection closed") + + m.hostMu.Lock() + if m.hostConnection == hostConn { + m.hostConnection = nil + } + m.hostMu.Unlock() + + // Send event to client about disconnection + m.wsEventManager.SendEvent(events.NakamaHostDisconnected, map[string]interface{}{ + "connected": false, + }) + + // Attempt reconnection after a delay if settings are still valid and not already reconnecting + m.hostMu.Lock() + shouldReconnect := m.settings != nil && m.settings.RemoteServerURL != "" && m.settings.RemoteServerPassword != "" && !m.reconnecting + if shouldReconnect { + m.reconnecting = true + hostConn.reconnectTimer = time.AfterFunc(10*time.Second, func() { + m.connectToHostAsync() + }) + } + m.hostMu.Unlock() + }() + + // Set up ping/pong handler + hostConn.Conn.SetPongHandler(func(appData string) error { + hostConn.LastPing = time.Now() + return nil + }) + + // Set read deadline + _ = hostConn.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + + for { + select { + case <-m.ctx.Done(): + return + default: + var message Message + err := hostConn.Conn.ReadJSON(&message) + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + m.logger.Error().Err(err).Msg("nakama: Unexpected close error from host") + } + return + } + + // Handle the message + if err := m.handleMessage(&message, "host"); err != nil { + m.logger.Error().Err(err).Str("messageType", string(message.Type)).Msg("nakama: Failed to handle message from host") + } + + // Reset read deadline + _ = hostConn.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + } + } +} + +// clientPingRoutine sends ping messages to the host +func (m *Manager) clientPingRoutine() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-m.ctx.Done(): + return + case <-ticker.C: + m.hostMu.RLock() + if m.hostConnection == nil || !m.hostConnection.Authenticated { + m.hostMu.RUnlock() + return + } + + // Check if host is still alive + if time.Since(m.hostConnection.LastPing) > 90*time.Second { + m.logger.Warn().Msg("nakama: Host connection timeout") + m.hostConnection.Close() + m.hostMu.RUnlock() + return + } + + // Send ping + message := &Message{ + Type: MessageTypePing, + Payload: nil, + Timestamp: time.Now(), + } + + if err := m.hostConnection.SendMessage(message); err != nil { + m.logger.Error().Err(err).Msg("nakama: Failed to send ping to host") + m.hostConnection.Close() + m.hostMu.RUnlock() + return + } + m.hostMu.RUnlock() + } + } +} diff --git a/seanime-2.9.10/internal/nakama/share.go b/seanime-2.9.10/internal/nakama/share.go new file mode 100644 index 0000000..24b928a --- /dev/null +++ b/seanime-2.9.10/internal/nakama/share.go @@ -0,0 +1,245 @@ +package nakama + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/events" + "seanime/internal/library/anime" + "seanime/internal/library/playbackmanager" + "seanime/internal/util" + "strconv" + "strings" + "time" + + "github.com/imroc/req/v3" +) + +type ( + HydrateHostAnimeLibraryOptions struct { + AnimeCollection *anilist.AnimeCollection + LibraryCollection *anime.LibraryCollection + MetadataProvider metadata.Provider + } + + NakamaAnimeLibrary struct { + LocalFiles []*anime.LocalFile `json:"localFiles"` + AnimeCollection *anilist.AnimeCollection `json:"animeCollection"` + } +) + +// generateHMACToken generates an HMAC token for stream authentication +func (m *Manager) generateHMACToken(endpoint string) (string, error) { + // Use the Nakama password as the base secret - HostPassword for hosts, RemoteServerPassword for peers + var secret string + if m.settings.IsHost { + secret = m.settings.HostPassword + } else { + secret = m.settings.RemoteServerPassword + } + + hmacAuth := util.NewHMACAuth(secret, 24*time.Hour) + return hmacAuth.GenerateToken(endpoint) +} + +func (m *Manager) GetHostAnimeLibraryFiles(mId ...int) (lfs []*anime.LocalFile, hydrated bool) { + if !m.settings.Enabled || !m.settings.IncludeNakamaAnimeLibrary || !m.IsConnectedToHost() { + return nil, false + } + + var response *req.Response + var err error + if len(mId) > 0 { + response, err = m.reqClient.R(). + SetHeader("X-Seanime-Nakama-Token", m.settings.RemoteServerPassword). + Get(m.GetHostBaseServerURL() + "/api/v1/nakama/host/anime/library/files/" + strconv.Itoa(mId[0])) + if err != nil { + return nil, false + } + } else { + response, err = m.reqClient.R(). + SetHeader("X-Seanime-Nakama-Token", m.settings.RemoteServerPassword). + Get(m.GetHostBaseServerURL() + "/api/v1/nakama/host/anime/library/files") + if err != nil { + return nil, false + } + } + + if !response.IsSuccessState() { + return nil, false + } + + body := response.Bytes() + + var entryResponse struct { + Data []*anime.LocalFile `json:"data"` + } + err = json.Unmarshal(body, &entryResponse) + if err != nil { + return nil, false + } + + return entryResponse.Data, true +} + +func (m *Manager) GetHostAnimeLibrary() (ac *NakamaAnimeLibrary, hydrated bool) { + if !m.settings.Enabled || !m.settings.IncludeNakamaAnimeLibrary || !m.IsConnectedToHost() { + return nil, false + } + + var response *req.Response + var err error + + response, err = m.reqClient.R(). + SetHeader("X-Seanime-Nakama-Token", m.settings.RemoteServerPassword). + Get(m.GetHostBaseServerURL() + "/api/v1/nakama/host/anime/library") + if err != nil { + return nil, false + } + + if !response.IsSuccessState() { + return nil, false + } + + body := response.Bytes() + + var entryResponse struct { + Data *NakamaAnimeLibrary `json:"data"` + } + err = json.Unmarshal(body, &entryResponse) + if err != nil { + return nil, false + } + + if entryResponse.Data == nil { + return nil, false + } + + return entryResponse.Data, true +} + +func (m *Manager) getBaseServerURL() string { + ret := "" + host := m.serverHost + if host == "0.0.0.0" { + host = "127.0.0.1" + } + ret = fmt.Sprintf("http://%s:%d", host, m.serverPort) + if strings.HasPrefix(ret, "http://http") { + ret = strings.Replace(ret, "http://http", "http", 1) + } + return ret +} + +func (m *Manager) PlayHostAnimeLibraryFile(path string, userAgent string, media *anilist.BaseAnime, aniDBEpisode string) error { + if !m.settings.Enabled || !m.IsConnectedToHost() { + return errors.New("not connected to host") + } + + m.previousPath = path + + m.logger.Debug().Int("mediaId", media.ID).Msg("nakama: Playing host anime library file") + m.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "nakama-file") + m.wsEventManager.SendEvent(events.InfoToast, "Sending stream to player...") + + // Send a HTTP request to the host to get the anime library + // If we can access it then the host is sharing its anime library + response, err := m.reqClient.R(). + SetHeader("X-Seanime-Nakama-Token", m.settings.RemoteServerPassword). + Get(m.GetHostBaseServerURL() + "/api/v1/nakama/host/anime/library/collection") + if err != nil { + return fmt.Errorf("cannot access host's anime library: %w", err) + } + + if !response.IsSuccessState() { + body := response.Bytes() + code := response.StatusCode + return fmt.Errorf("cannot access host's anime library: %d, %s", code, string(body)) + } + + host := m.serverHost + if host == "0.0.0.0" { + host = "127.0.0.1" + } + address := fmt.Sprintf("%s:%d", host, m.serverPort) + ret := fmt.Sprintf("http://%s/api/v1/nakama/stream?type=file&path=%s", address, base64.StdEncoding.EncodeToString([]byte(path))) + if strings.HasPrefix(ret, "http://http") { + ret = strings.Replace(ret, "http://http", "http", 1) + } + + windowTitle := media.GetPreferredTitle() + if !media.IsMovieOrSingleEpisode() { + windowTitle += " - Episode " + aniDBEpisode + } + + err = m.playbackManager.StartStreamingUsingMediaPlayer(windowTitle, &playbackmanager.StartPlayingOptions{ + Payload: ret, + UserAgent: userAgent, + ClientId: "", + }, media, aniDBEpisode) + if err != nil { + m.wsEventManager.SendEvent(events.HideIndefiniteLoader, "nakama-file") + go m.playbackManager.UnsubscribeFromPlaybackStatus("nakama-file") + return err + } + + m.playbackManager.RegisterMediaPlayerCallback(func(event playbackmanager.PlaybackEvent, cancel func()) { + switch event.(type) { + case playbackmanager.StreamStartedEvent: + m.wsEventManager.SendEvent(events.HideIndefiniteLoader, "nakama-file") + cancel() + } + }) + + return nil +} + +func (m *Manager) PlayHostAnimeStream(streamType string, userAgent string, media *anilist.BaseAnime, aniDBEpisode string) error { + if !m.settings.Enabled || !m.IsConnectedToHost() { + return errors.New("not connected to host") + } + + m.logger.Debug().Int("mediaId", media.ID).Msg("nakama: Playing host anime stream") + m.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "nakama-stream") + m.wsEventManager.SendEvent(events.InfoToast, "Sending stream to player...") + + host := m.serverHost + if host == "0.0.0.0" { + host = "127.0.0.1" + } + address := fmt.Sprintf("%s:%d", host, m.serverPort) + + ret := fmt.Sprintf("http://%s/api/v1/nakama/stream?type=%s", address, streamType) + if strings.HasPrefix(ret, "http://http") { + ret = strings.Replace(ret, "http://http", "http", 1) + } + + windowTitle := media.GetPreferredTitle() + if !media.IsMovieOrSingleEpisode() { + windowTitle += " - Episode " + aniDBEpisode + } + + err := m.playbackManager.StartStreamingUsingMediaPlayer(windowTitle, &playbackmanager.StartPlayingOptions{ + Payload: ret, + UserAgent: userAgent, + ClientId: "", + }, media, aniDBEpisode) + if err != nil { + m.wsEventManager.SendEvent(events.HideIndefiniteLoader, "nakama-stream") + go m.playbackManager.UnsubscribeFromPlaybackStatus("nakama-stream") + return err + } + + m.playbackManager.RegisterMediaPlayerCallback(func(event playbackmanager.PlaybackEvent, cancel func()) { + switch event.(type) { + case playbackmanager.StreamStartedEvent: + m.wsEventManager.SendEvent(events.HideIndefiniteLoader, "nakama-stream") + cancel() + } + }) + + return nil +} diff --git a/seanime-2.9.10/internal/nakama/watch_party.go b/seanime-2.9.10/internal/nakama/watch_party.go new file mode 100644 index 0000000..c5e89df --- /dev/null +++ b/seanime-2.9.10/internal/nakama/watch_party.go @@ -0,0 +1,421 @@ +package nakama + +import ( + "context" + "encoding/json" + debrid_client "seanime/internal/debrid/client" + "seanime/internal/library/playbackmanager" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/torrentstream" + "sync" + "time" + + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +const ( + // Host -> Peer + MessageTypeWatchPartyCreated = "watch_party_created" // Host creates a watch party + MessageTypeWatchPartyStateChanged = "watch_party_state_changed" // Host or peer changes the state of the watch party + MessageTypeWatchPartyStopped = "watch_party_stopped" // Host stops a watch party + MessageTypeWatchPartyPlaybackStatus = "watch_party_playback_status" // Host or peer sends playback status to peers (seek, play, pause, etc) + MessageTypeWatchPartyPlaybackStopped = "watch_party_playback_stopped" // Peer sends playback stopped to host + // MessageTypeWatchPartyRelayModeStreamReady = "watch_party_relay_mode_stream_ready" // Relay server signals to origin that the stream is ready + MessageTypeWatchPartyRelayModePeersReady = "watch_party_relay_mode_peers_ready" // Relay server signals to origin that all peers are ready + MessageTypeWatchPartyRelayModePeerBuffering = "watch_party_relay_mode_peer_buffering" // Relay server signals to origin the buffering status (tells origin to pause/unpause) + // Peer -> Host + MessageTypeWatchPartyJoin = "watch_party_join" // Peer joins a watch party + MessageTypeWatchPartyLeave = "watch_party_leave" // Peer leaves a watch party + MessageTypeWatchPartyPeerStatus = "watch_party_peer_status" // Peer reports their current status to host + MessageTypeWatchPartyBufferUpdate = "watch_party_buffer_update" // Peer reports buffering state to host + MessageTypeWatchPartyRelayModeOriginStreamStarted = "watch_party_relay_mode_origin_stream_started" // Relay origin sends is starting a stream, the host will start it too + MessageTypeWatchPartyRelayModeOriginPlaybackStatus = "watch_party_relay_mode_origin_playback_status" // Relay origin sends playback status to relay server + MessageTypeWatchPartyRelayModeOriginPlaybackStopped = "watch_party_relay_mode_origin_playback_stopped" // Relay origin sends playback stopped to relay server +) + +const ( + // Drift detection and sync thresholds + MinSyncThreshold = 0.8 // Minimum sync threshold to prevent excessive seeking + MaxSyncThreshold = 5.0 // Maximum sync threshold for loose synchronization + AggressiveSyncMultiplier = 0.4 // Multiplier for large drift (>3s) to sync aggressively + ModerateSyncMultiplier = 0.6 // Multiplier for medium drift (>1.5s) to sync more frequently + + // Sync timing and delays + MinSeekDelay = 200 * time.Millisecond // Minimum delay for seek operations + MaxSeekDelay = 600 * time.Millisecond // Maximum delay for seek operations + DefaultSeekCooldown = 1 * time.Second // Cooldown between consecutive seeks + + // Message staleness and processing + MaxMessageAge = 1.5 // Seconds to ignore stale sync messages + PendingSeekWaitMultiplier = 1.0 // Multiplier for pending seek wait time + + // Position and state detection + SignificantPositionJump = 3.0 // Seconds to detect seeking vs normal playback + ResumePositionDriftThreshold = 1.0 // Seconds of drift before syncing on resume + ResumeAheadTolerance = 2.0 // Seconds ahead tolerance to prevent jitter on resume + PausePositionSyncThreshold = 0.7 // Seconds of drift threshold for pause sync + + // Catch-up and buffering + CatchUpBehindThreshold = 2.0 // Seconds behind before starting catch-up + CatchUpToleranceThreshold = 0.5 // Seconds within target to stop catch-up + MaxCatchUpDuration = 4 * time.Second // Maximum duration for catch-up operations + CatchUpTickInterval = 200 * time.Millisecond // Interval for catch-up progress checks + + // Buffer detection (peer-side) + BufferDetectionMinInterval = 1.5 // Seconds between buffer health checks + BufferDetectionTolerance = 0.6 // Tolerance for playback progress detection + BufferDetectionStallThreshold = 2 // Consecutive stalls before buffering detection + BufferHealthDecrement = 0.15 // Buffer health decrease per stall + EndOfContentThreshold = 2.0 // Seconds from end to disable buffering detection + + // Network and timing compensation + MinDynamicDelay = 200 * time.Millisecond // Minimum network delay compensation + MaxDynamicDelay = 500 * time.Millisecond // Maximum network delay compensation +) + +type WatchPartyManager struct { + logger *zerolog.Logger + manager *Manager + + currentSession mo.Option[*WatchPartySession] // Current watch party session + sessionCtx context.Context // Context for the current watch party session + sessionCtxCancel context.CancelFunc // Cancel function for the current watch party session + mu sync.RWMutex // Mutex for the watch party manager + + // Seek management to prevent choppy playback + lastSeekTime time.Time // Time of last seek operation + seekCooldown time.Duration // Minimum time between seeks + + // Catch-up management + catchUpCancel context.CancelFunc // Cancel function for catch-up operations + catchUpMu sync.Mutex // Mutex for catch-up operations + + // Seek management + pendingSeekTime time.Time // When a seek was initiated + pendingSeekPosition float64 // Position we're seeking to + seekMu sync.Mutex // Mutex for seek state + + // Buffering management (host only) + bufferWaitStart time.Time // When we started waiting for peers to buffer + isWaitingForBuffers bool // Whether we're currently waiting for peers to be ready + bufferMu sync.Mutex // Mutex for buffer state changes + statusReportTicker *time.Ticker // Ticker for peer status reporting + statusReportCancel context.CancelFunc // Cancel function for status reporting + waitForPeersCancel context.CancelFunc // Cancel function for waitForPeersReady goroutine + + // Buffering detection (peer only) + bufferDetectionMu sync.Mutex // Mutex for buffering detection state + lastPosition float64 // Last known playback position + lastPositionTime time.Time // When we last updated the position + stallCount int // Number of consecutive stalls detected + + lastPlayState bool // Last known play/pause state to detect rapid changes + lastPlayStateTime time.Time // When we last changed play state + + // Sequence-based message ordering + sequenceMu sync.Mutex // Mutex for sequence number operations + sendSequence uint64 // Current sequence number for outgoing messages + lastRxSequence uint64 // Latest received sequence number + + // Peer + peerPlaybackListener *playbackmanager.PlaybackStatusSubscriber // Listener for playback status changes (can be nil) +} + +type WatchPartySession struct { + ID string `json:"id"` + Participants map[string]*WatchPartySessionParticipant `json:"participants"` + Settings *WatchPartySessionSettings `json:"settings"` + CreatedAt time.Time `json:"createdAt"` + CurrentMediaInfo *WatchPartySessionMediaInfo `json:"currentMediaInfo"` // can be nil if not set + IsRelayMode bool `json:"isRelayMode"` // Whether this session is in relay mode + mu sync.RWMutex `json:"-"` +} + +type WatchPartySessionParticipant struct { + ID string `json:"id"` // PeerID (UUID) for unique identification + Username string `json:"username"` // Display name + IsHost bool `json:"isHost"` + CanControl bool `json:"canControl"` + IsReady bool `json:"isReady"` + LastSeen time.Time `json:"lastSeen"` + Latency int64 `json:"latency"` // in milliseconds + // Buffering state + IsBuffering bool `json:"isBuffering"` + BufferHealth float64 `json:"bufferHealth"` // 0.0 to 1.0, how much buffer is available + PlaybackStatus *mediaplayer.PlaybackStatus `json:"playbackStatus,omitempty"` // Current playback status + // Relay mode + IsRelayOrigin bool `json:"isRelayOrigin"` // Whether this peer is the origin for relay mode +} + +type WatchPartySessionMediaInfo struct { + MediaId int `json:"mediaId"` + EpisodeNumber int `json:"episodeNumber"` + AniDBEpisode string `json:"aniDbEpisode"` + StreamType string `json:"streamType"` // "file", "torrent", "debrid", "online" + StreamPath string `json:"streamPath"` // URL for stream playback (e.g. /api/v1/nakama/stream?type=file&path=...) + + OnlineStreamParams *OnlineStreamParams `json:"onlineStreamParams,omitempty"` + OptionalTorrentStreamStartOptions *torrentstream.StartStreamOptions `json:"optionalTorrentStreamStartOptions,omitempty"` +} + +type OnlineStreamParams struct { + MediaId int `json:"mediaId"` + Provider string `json:"provider"` + Server string `json:"server"` + Dubbed bool `json:"dubbed"` + EpisodeNumber int `json:"episodeNumber"` + Quality string `json:"quality"` +} + +type WatchPartySessionSettings struct { + SyncThreshold float64 `json:"syncThreshold"` // Seconds of desync before forcing sync + MaxBufferWaitTime int `json:"maxBufferWaitTime"` // Max time to wait for buffering peers (seconds) +} + +// Events +type ( + WatchPartyCreatedPayload struct { + Session *WatchPartySession `json:"session"` + } + + WatchPartyJoinPayload struct { + PeerId string `json:"peerId"` + Username string `json:"username"` + } + + WatchPartyLeavePayload struct { + PeerId string `json:"peerId"` + } + + WatchPartyPlaybackStatusPayload struct { + PlaybackStatus mediaplayer.PlaybackStatus `json:"playbackStatus"` + Timestamp int64 `json:"timestamp"` // Unix nano timestamp + SequenceNumber uint64 `json:"sequenceNumber"` + EpisodeNumber int `json:"episodeNumber"` // For episode changes + } + + WatchPartyStateChangedPayload struct { + Session *WatchPartySession `json:"session"` + } + + WatchPartyPeerStatusPayload struct { + PeerId string `json:"peerId"` + PlaybackStatus mediaplayer.PlaybackStatus `json:"playbackStatus"` + IsBuffering bool `json:"isBuffering"` + BufferHealth float64 `json:"bufferHealth"` // 0.0 to 1.0 + Timestamp time.Time `json:"timestamp"` + } + + WatchPartyBufferUpdatePayload struct { + PeerId string `json:"peerId"` + IsBuffering bool `json:"isBuffering"` + BufferHealth float64 `json:"bufferHealth"` + Timestamp time.Time `json:"timestamp"` + } + + WatchPartyEnableRelayModePayload struct { + PeerId string `json:"peerId"` // PeerID of the peer to promote to origin + } + + WatchPartyRelayModeOriginStreamStartedPayload struct { + Filename string `json:"filename"` + Filepath string `json:"filepath"` + StreamType string `json:"streamType"` + OptionalLocalPath string `json:"optionalLocalPath,omitempty"` + OptionalTorrentStreamStartOptions *torrentstream.StartStreamOptions `json:"optionalTorrentStreamStartOptions,omitempty"` + OptionalDebridStreamStartOptions *debrid_client.StartStreamOptions `json:"optionalDebridStreamStartOptions,omitempty"` + Status mediaplayer.PlaybackStatus `json:"status"` + State playbackmanager.PlaybackState `json:"state"` + } + + WatchPartyRelayModeOriginPlaybackStatusPayload struct { + Status mediaplayer.PlaybackStatus `json:"status"` + State playbackmanager.PlaybackState `json:"state"` + Timestamp int64 `json:"timestamp"` + } +) + +func NewWatchPartyManager(manager *Manager) *WatchPartyManager { + return &WatchPartyManager{ + logger: manager.logger, + manager: manager, + seekCooldown: DefaultSeekCooldown, + } +} + +// Cleanup stops all goroutines and cleans up resources to prevent memory leaks +func (wpm *WatchPartyManager) Cleanup() { + wpm.mu.Lock() + defer wpm.mu.Unlock() + + if wpm.currentSession.IsPresent() { + go wpm.LeaveWatchParty() + go wpm.StopWatchParty() + } + + wpm.logger.Debug().Msg("nakama: Cleaning up watch party manager") + + // Stop status reporting (peer side) + wpm.stopStatusReporting() + + // Cancel any ongoing catch-up operations + wpm.cancelCatchUp() + + // Clean up seek management state + wpm.seekMu.Lock() + wpm.pendingSeekTime = time.Time{} + wpm.pendingSeekPosition = 0 + wpm.seekMu.Unlock() + + // Cancel waitForPeersReady goroutine (host side) + wpm.bufferMu.Lock() + if wpm.waitForPeersCancel != nil { + wpm.waitForPeersCancel() + wpm.waitForPeersCancel = nil + } + wpm.isWaitingForBuffers = false + wpm.bufferMu.Unlock() + + // Cancel session context (stops all session-related goroutines) + if wpm.sessionCtxCancel != nil { + wpm.sessionCtxCancel() + wpm.sessionCtx = nil + wpm.sessionCtxCancel = nil + } + + // Clear session + wpm.currentSession = mo.None[*WatchPartySession]() + + wpm.logger.Debug().Msg("nakama: Watch party manager cleanup completed") +} + +// GetCurrentSession returns the current watch party session if it exists +func (wpm *WatchPartyManager) GetCurrentSession() (*WatchPartySession, bool) { + wpm.mu.RLock() + defer wpm.mu.RUnlock() + + session, ok := wpm.currentSession.Get() + return session, ok +} + +func (wpm *WatchPartyManager) handleMessage(message *Message, senderID string) error { + marshaledPayload, err := json.Marshal(message.Payload) + if err != nil { + return err + } + + // wpm.logger.Debug().Str("type", string(message.Type)).Interface("payload", message.Payload).Msg("nakama: Received watch party message") + + switch message.Type { + case MessageTypeWatchPartyStateChanged: + // wpm.logger.Debug().Msg("nakama: Received watch party state changed message") + var payload WatchPartyStateChangedPayload + err := json.Unmarshal(marshaledPayload, &payload) + if err != nil { + return err + } + wpm.handleWatchPartyStateChangedEvent(&payload) + + case MessageTypeWatchPartyCreated: + wpm.logger.Debug().Msg("nakama: Received watch party created message") + var payload WatchPartyCreatedPayload + err := json.Unmarshal(marshaledPayload, &payload) + if err != nil { + return err + } + wpm.handleWatchPartyCreatedEvent(&payload) + + case MessageTypeWatchPartyStopped: + wpm.logger.Debug().Msg("nakama: Received watch party stopped message") + wpm.handleWatchPartyStoppedEvent() + + case MessageTypeWatchPartyJoin: + wpm.logger.Debug().Msg("nakama: Received watch party join message") + var payload WatchPartyJoinPayload + err := json.Unmarshal(marshaledPayload, &payload) + if err != nil { + return err + } + wpm.handleWatchPartyPeerJoinedEvent(&payload, message.Timestamp) + + case MessageTypeWatchPartyLeave: + wpm.logger.Debug().Msg("nakama: Received watch party leave message") + var payload WatchPartyLeavePayload + err := json.Unmarshal(marshaledPayload, &payload) + if err != nil { + return err + } + wpm.handleWatchPartyPeerLeftEvent(&payload) + + case MessageTypeWatchPartyPeerStatus: + //wpm.logger.Debug().Msg("nakama: Received watch party peer status message") + var payload WatchPartyPeerStatusPayload + err := json.Unmarshal(marshaledPayload, &payload) + if err != nil { + return err + } + wpm.handleWatchPartyPeerStatusEvent(&payload) + + case MessageTypeWatchPartyBufferUpdate: + //wpm.logger.Debug().Msg("nakama: Received watch party buffer update message") + var payload WatchPartyBufferUpdatePayload + err := json.Unmarshal(marshaledPayload, &payload) + if err != nil { + return err + } + wpm.handleWatchPartyBufferUpdateEvent(&payload) + + case MessageTypeWatchPartyPlaybackStatus: + // wpm.logger.Debug().Msg("nakama: Received watch party playback status message") + var payload WatchPartyPlaybackStatusPayload + err := json.Unmarshal(marshaledPayload, &payload) + if err != nil { + return err + } + wpm.handleWatchPartyPlaybackStatusEvent(&payload) + + case MessageTypeWatchPartyRelayModeOriginStreamStarted: + wpm.logger.Debug().Msg("nakama: Received relay mode stream from origin message") + var payload WatchPartyRelayModeOriginStreamStartedPayload + err := json.Unmarshal(marshaledPayload, &payload) + if err != nil { + return err + } + wpm.handleWatchPartyRelayModeOriginStreamStartedEvent(&payload) + + case MessageTypeWatchPartyRelayModePeerBuffering: + // TODO: Implement + + case MessageTypeWatchPartyRelayModePeersReady: + wpm.logger.Debug().Msg("nakama: Received relay mode peers ready message") + wpm.handleWatchPartyRelayModePeersReadyEvent() + + case MessageTypeWatchPartyRelayModeOriginPlaybackStatus: + // wpm.logger.Debug().Msg("nakama: Received relay mode origin playback status message") + var payload WatchPartyRelayModeOriginPlaybackStatusPayload + err := json.Unmarshal(marshaledPayload, &payload) + if err != nil { + return err + } + wpm.handleWatchPartyRelayModeOriginPlaybackStatusEvent(&payload) + + case MessageTypeWatchPartyRelayModeOriginPlaybackStopped: + wpm.logger.Debug().Msg("nakama: Received relay mode origin playback stopped message") + wpm.handleWatchPartyRelayModeOriginPlaybackStoppedEvent() + } + + return nil +} + +func (mi *WatchPartySessionMediaInfo) Equals(other *WatchPartySessionMediaInfo) bool { + if mi == nil || other == nil { + return false + } + + return mi.MediaId == other.MediaId && + mi.EpisodeNumber == other.EpisodeNumber && + mi.AniDBEpisode == other.AniDBEpisode && + mi.StreamType == other.StreamType && + mi.StreamPath == other.StreamPath +} diff --git a/seanime-2.9.10/internal/nakama/watch_party_host.go b/seanime-2.9.10/internal/nakama/watch_party_host.go new file mode 100644 index 0000000..044ad69 --- /dev/null +++ b/seanime-2.9.10/internal/nakama/watch_party_host.go @@ -0,0 +1,858 @@ +package nakama + +import ( + "context" + "errors" + debrid_client "seanime/internal/debrid/client" + "seanime/internal/events" + "seanime/internal/library/playbackmanager" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/torrentstream" + "seanime/internal/util" + "strings" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" +) + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Host +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type CreateWatchOptions struct { + Settings *WatchPartySessionSettings `json:"settings"` +} + +// CreateWatchParty creates a new watch party (host only) +func (wpm *WatchPartyManager) CreateWatchParty(options *CreateWatchOptions) (*WatchPartySession, error) { + wpm.mu.Lock() + defer wpm.mu.Unlock() + + if !wpm.manager.IsHost() { + return nil, errors.New("only hosts can create watch parties") + } + + if wpm.sessionCtxCancel != nil { + wpm.sessionCtxCancel() + wpm.sessionCtx = nil + wpm.sessionCtxCancel = nil + wpm.currentSession = mo.None[*WatchPartySession]() + } + + wpm.logger.Debug().Msg("nakama: Creating watch party") + + wpm.sessionCtx, wpm.sessionCtxCancel = context.WithCancel(context.Background()) + + // Generate unique ID + sessionID := uuid.New().String() + + session := &WatchPartySession{ + ID: sessionID, + Participants: make(map[string]*WatchPartySessionParticipant), + CurrentMediaInfo: nil, + Settings: options.Settings, + CreatedAt: time.Now(), + } + + // Add host as participant + session.Participants["host"] = &WatchPartySessionParticipant{ + ID: "host", + Username: wpm.manager.username, + IsHost: true, + CanControl: true, + IsReady: true, + LastSeen: time.Now(), + Latency: 0, + } + + wpm.currentSession = mo.Some(session) + + // Reset sequence numbers for new session + wpm.sequenceMu.Lock() + wpm.sendSequence = 0 + wpm.lastRxSequence = 0 + wpm.sequenceMu.Unlock() + + // Notify all peers about the new watch party + _ = wpm.manager.SendMessage(MessageTypeWatchPartyCreated, WatchPartyCreatedPayload{ + Session: session, + }) + + wpm.logger.Debug().Str("sessionId", sessionID).Msg("nakama: Watch party created") + + // Send websocket event to update the UI + wpm.manager.wsEventManager.SendEvent(events.NakamaWatchPartyState, session) + + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-wpm.sessionCtx.Done(): + wpm.logger.Debug().Msg("nakama: Watch party periodic broadcast stopped") + return + case <-ticker.C: + // Broadcast the session state to all peers every 5 seconds + // This is useful for peers that will join later + wpm.broadcastSessionStateToPeers() + } + } + }() + + go wpm.listenToPlaybackManager() + // go wpm.listenToOnlineStreaming() // TODO + + return session, nil +} + +// PromotePeerToRelayModeOrigin promotes a peer to be the origin for relay mode +func (wpm *WatchPartyManager) PromotePeerToRelayModeOrigin(peerId string) { + wpm.mu.Lock() + defer wpm.mu.Unlock() + + if !wpm.manager.IsHost() { + return + } + + wpm.logger.Debug().Str("peerId", peerId).Msg("nakama: Promoting peer to relay mode origin") + + session, ok := wpm.currentSession.Get() + if !ok { + wpm.logger.Warn().Msg("nakama: Cannot promote peer to relay mode origin, no active watch party session") + return + } + + // Check if the peer exists in the session + participant, exists := session.Participants[peerId] + if !exists { + wpm.logger.Warn().Str("peerId", peerId).Msg("nakama: Cannot promote peer to relay mode origin, peer not found in session") + return + } + + // Set the IsRelayOrigin flag to true + participant.IsRelayOrigin = true + // Broadcast the updated session state to all peers + session.mu.Lock() + session.IsRelayMode = true + session.mu.Unlock() + + wpm.logger.Debug().Str("peerId", peerId).Msg("nakama: Peer promoted to relay mode origin") + + wpm.broadcastSessionStateToPeers() + wpm.sendSessionStateToClient() +} + +func (wpm *WatchPartyManager) StopWatchParty() { + wpm.mu.Lock() + defer wpm.mu.Unlock() + + if !wpm.manager.IsHost() { + return + } + + wpm.logger.Debug().Msg("nakama: Stopping watch party") + + // Cancel any ongoing catch-up operations + wpm.cancelCatchUp() + + // Reset buffering state and cancel any waitForPeersReady goroutine + wpm.bufferMu.Lock() + wpm.isWaitingForBuffers = false + if wpm.waitForPeersCancel != nil { + wpm.waitForPeersCancel() + wpm.waitForPeersCancel = nil + } + wpm.bufferMu.Unlock() + + // Broadcast the stop event to all peers + _ = wpm.manager.SendMessage(MessageTypeWatchPartyStopped, nil) + + if wpm.sessionCtxCancel != nil { + wpm.sessionCtxCancel() + wpm.sessionCtx = nil + wpm.sessionCtxCancel = nil + wpm.currentSession = mo.None[*WatchPartySession]() + } + + wpm.broadcastSessionStateToPeers() + wpm.sendSessionStateToClient() +} + +// listenToPlaybackManager listens to the playback manager +func (wpm *WatchPartyManager) listenToPlaybackManager() { + playbackSubscriber := wpm.manager.playbackManager.SubscribeToPlaybackStatus("nakama_watch_party") + + go func() { + defer util.HandlePanicInModuleThen("nakama/listenToPlaybackManager", func() {}) + defer func() { + wpm.logger.Debug().Msg("nakama: Stopping playback manager listener") + go wpm.manager.playbackManager.UnsubscribeFromPlaybackStatus("nakama_watch_party") + }() + + for { + select { + case <-wpm.sessionCtx.Done(): + wpm.logger.Debug().Msg("nakama: Stopping playback manager listener") + return + case event := <-playbackSubscriber.EventCh: + _, ok := wpm.currentSession.Get() + if !ok { + continue + } + + switch event := event.(type) { + case playbackmanager.VideoStoppedEvent, playbackmanager.StreamStoppedEvent: + // Reset + wpm.logger.Debug().Msg("nakama: Playback stopped event received") + + wpm.bufferMu.Lock() + wpm.isWaitingForBuffers = true + wpm.bufferWaitStart = time.Now() + // Cancel existing waitForPeersReady goroutine + if wpm.waitForPeersCancel != nil { + wpm.waitForPeersCancel() + wpm.waitForPeersCancel = nil + } + wpm.bufferMu.Unlock() + + // Reset the current session media info + wpm.mu.Lock() + session, ok := wpm.currentSession.Get() + if !ok { + wpm.mu.Unlock() + return + } + session.CurrentMediaInfo = nil + wpm.mu.Unlock() + + // Broadcast the session state to all peers + go wpm.broadcastSessionStateToPeers() + + case playbackmanager.PlaybackStatusChangedEvent: + if event.State.MediaId == 0 { + continue + } + + go func(event playbackmanager.PlaybackStatusChangedEvent) { + wpm.manager.playbackManager.PullStatus() + + streamType := "file" + if event.Status.PlaybackType == mediaplayer.PlaybackTypeStream { + if strings.Contains(event.Status.Filepath, "/api/v1/torrentstream") { + streamType = "torrent" + } else { + streamType = "debrid" + } + } + + optionalTorrentStreamStartOptions, _ := wpm.manager.torrentstreamRepository.GetPreviousStreamOptions() + + streamPath := event.Status.Filepath + newCurrentMediaInfo := &WatchPartySessionMediaInfo{ + MediaId: event.State.MediaId, + EpisodeNumber: event.State.EpisodeNumber, + AniDBEpisode: event.State.AniDbEpisode, + StreamType: streamType, + StreamPath: streamPath, + OptionalTorrentStreamStartOptions: optionalTorrentStreamStartOptions, + } + + wpm.mu.Lock() + session, ok := wpm.currentSession.Get() + if !ok { + wpm.mu.Unlock() + return + } + + // If this is the same media, just send the playback status + if session.CurrentMediaInfo.Equals(newCurrentMediaInfo) && event.State.MediaId != 0 { + wpm.mu.Unlock() + + // Get next sequence number for message ordering + wpm.sequenceMu.Lock() + wpm.sendSequence++ + sequenceNum := wpm.sendSequence + wpm.sequenceMu.Unlock() + + // Send message + _ = wpm.manager.SendMessage(MessageTypeWatchPartyPlaybackStatus, WatchPartyPlaybackStatusPayload{ + PlaybackStatus: event.Status, + Timestamp: time.Now().UnixNano(), + SequenceNumber: sequenceNum, + EpisodeNumber: event.State.EpisodeNumber, + }) + + } else { + // For new playback, update the session + wpm.logger.Debug().Msgf("nakama: Playback changed or started: %s", streamPath) + session.CurrentMediaInfo = newCurrentMediaInfo + wpm.mu.Unlock() + + // Pause immediately and wait for peers to be ready + _ = wpm.manager.playbackManager.Pause() + + // Reset buffering state for new playback + wpm.bufferMu.Lock() + wpm.isWaitingForBuffers = true + wpm.bufferWaitStart = time.Now() + + // Cancel existing waitForPeersReady goroutine + if wpm.waitForPeersCancel != nil { + wpm.waitForPeersCancel() + wpm.waitForPeersCancel = nil + } + wpm.bufferMu.Unlock() + + go wpm.broadcastSessionStateToPeers() + + // Start checking peer readiness + go wpm.waitForPeersReady(func() { + if !session.IsRelayMode { + // resume playback + _ = wpm.manager.playbackManager.Resume() + } else { + // in relay mode, just signal to the origin + _ = wpm.manager.SendMessage(MessageTypeWatchPartyRelayModePeersReady, nil) + } + }) + } + }(event) + } + } + } + }() +} + +// broadcastSessionStateToPeers broadcasts the session state to all peers +func (wpm *WatchPartyManager) broadcastSessionStateToPeers() { + session, ok := wpm.currentSession.Get() + if !ok { + _ = wpm.manager.SendMessage(MessageTypeWatchPartyStateChanged, WatchPartyStateChangedPayload{ + Session: nil, + }) + return + } + + _ = wpm.manager.SendMessage(MessageTypeWatchPartyStateChanged, WatchPartyStateChangedPayload{ + Session: session, + }) +} + +func (wpm *WatchPartyManager) sendSessionStateToClient() { + session, ok := wpm.currentSession.Get() + if !ok { + wpm.manager.wsEventManager.SendEvent(events.NakamaWatchPartyState, nil) + return + } + + wpm.manager.wsEventManager.SendEvent(events.NakamaWatchPartyState, session) +} + +// handleWatchPartyPeerJoinedEvent is called when a peer joins a watch party +func (wpm *WatchPartyManager) handleWatchPartyPeerJoinedEvent(payload *WatchPartyJoinPayload, timestamp time.Time) { + if !wpm.manager.IsHost() { + return + } + + wpm.mu.Lock() + defer wpm.mu.Unlock() + + wpm.logger.Debug().Str("peerId", payload.PeerId).Msg("nakama: Peer joined watch party") + + session, ok := wpm.currentSession.Get() + if !ok { + return + } + + session.mu.Lock() + // Add the peer to the session + session.Participants[payload.PeerId] = &WatchPartySessionParticipant{ + ID: payload.PeerId, + Username: payload.Username, + IsHost: false, + CanControl: false, + IsReady: false, + LastSeen: timestamp, + Latency: 0, + // Initialize buffering state + IsBuffering: false, + BufferHealth: 1.0, + PlaybackStatus: nil, + } + session.mu.Unlock() + + // Send session state + go wpm.broadcastSessionStateToPeers() + + wpm.logger.Debug().Str("peerId", payload.PeerId).Msg("nakama: Updated watch party state after peer joined") + + wpm.sendSessionStateToClient() +} + +// handleWatchPartyPeerLeftEvent is called when a peer leaves a watch party +func (wpm *WatchPartyManager) handleWatchPartyPeerLeftEvent(payload *WatchPartyLeavePayload) { + if !wpm.manager.IsHost() { + return + } + + wpm.mu.Lock() + defer wpm.mu.Unlock() + + wpm.logger.Debug().Str("peerId", payload.PeerId).Msg("nakama: Peer left watch party") + + session, ok := wpm.currentSession.Get() + if !ok { + return + } + + // Remove the peer from the session + delete(session.Participants, payload.PeerId) + + // Send session state + go wpm.broadcastSessionStateToPeers() + + wpm.logger.Debug().Str("peerId", payload.PeerId).Msg("nakama: Updated watch party state after peer left") + + wpm.sendSessionStateToClient() +} + +// HandlePeerDisconnected handles peer disconnections and removes them from the watch party +func (wpm *WatchPartyManager) HandlePeerDisconnected(peerID string) { + if !wpm.manager.IsHost() { + return + } + + wpm.mu.Lock() + defer wpm.mu.Unlock() + + session, ok := wpm.currentSession.Get() + if !ok { + return + } + + // Check if the peer is in the watch party + if _, exists := session.Participants[peerID]; !exists { + return + } + + wpm.logger.Debug().Str("peerId", peerID).Msg("nakama: Peer disconnected, removing from watch party") + + // Remove the peer from the session + delete(session.Participants, peerID) + + // Send session state to remaining peers + go wpm.broadcastSessionStateToPeers() + + wpm.logger.Debug().Str("peerId", peerID).Msg("nakama: Updated watch party state after peer disconnected") + + // Send websocket event to update the UI + wpm.sendSessionStateToClient() +} + +// handleWatchPartyPeerStatusEvent handles regular status reports from peers +func (wpm *WatchPartyManager) handleWatchPartyPeerStatusEvent(payload *WatchPartyPeerStatusPayload) { + if !wpm.manager.IsHost() { + return + } + + wpm.mu.Lock() + session, ok := wpm.currentSession.Get() + if !ok { + wpm.mu.Unlock() + return + } + + // Update peer status + if participant, exists := session.Participants[payload.PeerId]; exists { + participant.PlaybackStatus = &payload.PlaybackStatus + participant.IsBuffering = payload.IsBuffering + participant.BufferHealth = payload.BufferHealth + participant.LastSeen = payload.Timestamp + participant.IsReady = !payload.IsBuffering && payload.BufferHealth > 0.1 // Consider ready if not buffering and has some buffer + + wpm.logger.Debug(). + Str("peerId", payload.PeerId). + Bool("isBuffering", payload.IsBuffering). + Float64("bufferHealth", payload.BufferHealth). + Bool("isReady", participant.IsReady). + Msg("nakama: Updated peer status") + } + wpm.mu.Unlock() + + // Check if we should start/resume playback based on peer states (call after releasing mutex) + // Run this asynchronously to avoid blocking the event processing + go wpm.checkAndManageBuffering() + + // Send session state to client to update the UI + wpm.sendSessionStateToClient() +} + +// handleWatchPartyBufferUpdateEvent handles buffer state changes from peers +func (wpm *WatchPartyManager) handleWatchPartyBufferUpdateEvent(payload *WatchPartyBufferUpdatePayload) { + if !wpm.manager.IsHost() { + return + } + + wpm.mu.Lock() + session, ok := wpm.currentSession.Get() + if !ok { + wpm.mu.Unlock() + return + } + + // Update peer buffer status + if participant, exists := session.Participants[payload.PeerId]; exists { + participant.IsBuffering = payload.IsBuffering + participant.BufferHealth = payload.BufferHealth + participant.LastSeen = payload.Timestamp + participant.IsReady = !payload.IsBuffering && payload.BufferHealth > 0.1 + + wpm.logger.Debug(). + Str("peerId", payload.PeerId). + Bool("isBuffering", payload.IsBuffering). + Float64("bufferHealth", payload.BufferHealth). + Bool("isReady", participant.IsReady). + Msg("nakama: Updated peer buffer status") + } + wpm.mu.Unlock() + + // Immediately check if we need to pause/resume based on buffer state (call after releasing mutex) + // Run this asynchronously to avoid blocking the event processing + go wpm.checkAndManageBuffering() + + // Broadcast updated session state + go wpm.broadcastSessionStateToPeers() + + // Send session state to client to update the UI + wpm.sendSessionStateToClient() +} + +// checkAndManageBuffering manages playback based on peer buffering states +// NOTE: This function should NOT be called while holding wpm.mu as it may need to acquire bufferMu +func (wpm *WatchPartyManager) checkAndManageBuffering() { + session, ok := wpm.currentSession.Get() + if !ok { + return + } + + // Get current playback status + playbackStatus, hasPlayback := wpm.manager.playbackManager.PullStatus() + if !hasPlayback { + return + } + + // Count peer states + var totalPeers, readyPeers, bufferingPeers int + for _, participant := range session.Participants { + if !participant.IsHost { + totalPeers++ + if participant.IsReady { + readyPeers++ + } + if participant.IsBuffering { + bufferingPeers++ + } + } + } + + // No peers means no buffering management needed + if totalPeers == 0 { + return + } + + wpm.bufferMu.Lock() + defer wpm.bufferMu.Unlock() + + maxWaitTime := time.Duration(session.Settings.MaxBufferWaitTime) * time.Second + + // If any peer is buffering and we're playing, pause and wait + if bufferingPeers > 0 && playbackStatus.Playing { + if !wpm.isWaitingForBuffers { + wpm.logger.Debug(). + Int("bufferingPeers", bufferingPeers). + Int("totalPeers", totalPeers). + Msg("nakama: Pausing playback due to peer buffering") + + _ = wpm.manager.playbackManager.Pause() + wpm.isWaitingForBuffers = true + wpm.bufferWaitStart = time.Now() + } + return + } + + // If we're waiting for buffers + if wpm.isWaitingForBuffers { + waitTime := time.Since(wpm.bufferWaitStart) + + // Resume if all peers are ready or max wait time exceeded + if bufferingPeers == 0 || waitTime > maxWaitTime { + wpm.logger.Debug(). + Int("readyPeers", readyPeers). + Int("totalPeers", totalPeers). + Int("bufferingPeers", bufferingPeers). + Float64("waitTimeSeconds", waitTime.Seconds()). + Bool("maxWaitExceeded", waitTime > maxWaitTime). + Msg("nakama: Resuming playback after buffer wait") + + _ = wpm.manager.playbackManager.Resume() + wpm.isWaitingForBuffers = false + } + } +} + +// waitForPeersReady waits for peers to be ready before resuming playback +func (wpm *WatchPartyManager) waitForPeersReady(onReady func()) { + session, ok := wpm.currentSession.Get() + if !ok { + return + } + + // Create cancellable context for this goroutine + ctx, cancel := context.WithCancel(context.Background()) + + wpm.bufferMu.Lock() + wpm.waitForPeersCancel = cancel + wpm.bufferMu.Unlock() + + defer func() { + wpm.bufferMu.Lock() + wpm.waitForPeersCancel = nil + wpm.bufferMu.Unlock() + }() + + maxWaitTime := time.Duration(session.Settings.MaxBufferWaitTime) * time.Second + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + wpm.logger.Debug().Msg("nakama: Waiting for peers to be ready") + + for { + select { + case <-ctx.Done(): + wpm.logger.Debug().Msg("nakama: waitForPeersReady cancelled") + return + case <-wpm.sessionCtx.Done(): + wpm.logger.Debug().Msg("nakama: Session ended while waiting for peers") + return + case <-ticker.C: + wpm.bufferMu.Lock() + + // Check if we've been waiting too long + waitTime := time.Since(wpm.bufferWaitStart) + if waitTime > maxWaitTime { + wpm.logger.Debug().Float64("waitTimeSeconds", waitTime.Seconds()).Msg("nakama: Max wait time exceeded, resuming playback") + + onReady() + + wpm.isWaitingForBuffers = false + wpm.bufferMu.Unlock() + return + } + + // Count ready peers + session, ok := wpm.currentSession.Get() + if !ok { + wpm.bufferMu.Unlock() + return + } + + var totalPeers, readyPeers int + for _, participant := range session.Participants { + if !participant.IsHost && !participant.IsRelayOrigin { + totalPeers++ + if participant.IsReady { + readyPeers++ + } + } + } + + // If no peers or all peers are ready, resume playback + if totalPeers == 0 || readyPeers == totalPeers { + wpm.logger.Debug(). + Int("readyPeers", readyPeers). + Int("totalPeers", totalPeers). + Msg("nakama: All peers are ready, resuming playback") + + onReady() + + wpm.isWaitingForBuffers = false + wpm.bufferMu.Unlock() + return + } + + wpm.logger.Debug(). + Int("readyPeers", readyPeers). + Int("totalPeers", totalPeers). + Float64("waitTimeSeconds", waitTime.Seconds()). + Msg("nakama: Still waiting for peers to be ready") + + wpm.bufferMu.Unlock() + } + } +} + +func (wpm *WatchPartyManager) EnableRelayMode(peerId string) { + wpm.mu.Lock() + defer wpm.mu.Unlock() + + wpm.logger.Debug().Str("peerId", peerId).Msg("nakama: Enabling relay mode") + + session, ok := wpm.currentSession.Get() + if !ok { + return + } + + session.mu.Lock() + participant, exists := session.Participants[peerId] + if !exists { + wpm.logger.Warn().Str("peerId", peerId).Msg("nakama: Cannot enable relay mode, peer not found in session") + wpm.manager.wsEventManager.SendEvent(events.ErrorToast, "Peer not found in session") + return + } + session.IsRelayMode = true + participant.IsRelayOrigin = true + session.mu.Unlock() + + wpm.logger.Debug().Str("peerId", peerId).Msg("nakama: Relay mode enabled") + + wpm.broadcastSessionStateToPeers() + wpm.sendSessionStateToClient() +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Relay mode +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// handleWatchPartyRelayModeOriginStreamStartedEvent is called when the relay origin sends us (the host) a new stream. +// It starts the same stream as the origin on the host by using the same options as the origin. +func (wpm *WatchPartyManager) handleWatchPartyRelayModeOriginStreamStartedEvent(payload *WatchPartyRelayModeOriginStreamStartedPayload) { + wpm.mu.Lock() + defer wpm.mu.Unlock() + + wpm.logger.Debug().Str("filepath", payload.Filepath).Msg("nakama: Relay mode origin stream started") + + session, ok := wpm.currentSession.Get() + if !ok { + return + } + + session.Settings.MaxBufferWaitTime = 60 // higher buffer wait time for relay mode + + event := payload + + // Load the stream on the host + // Playback won't actually be started + switch event.StreamType { + case "file": + // Do nothing, the file is already available + case "torrent": + // Start the torrent stream and wait for it to be ready + options := *event.OptionalTorrentStreamStartOptions + options.PlaybackType = torrentstream.PlaybackTypeNoneAndAwait + err := wpm.manager.torrentstreamRepository.StartStream(context.Background(), &options) + if err != nil { + wpm.logger.Error().Err(err).Msg("nakama: Failed to start torrent stream") + } + case "debrid": + // Start the debrid stream and wait for it to be ready + options := *event.OptionalDebridStreamStartOptions + options.PlaybackType = debrid_client.PlaybackTypeNoneAndAwait + err := wpm.manager.debridClientRepository.StartStream(context.Background(), &options) + if err != nil { + wpm.logger.Error().Err(err).Msg("nakama: Failed to start debrid stream") + } + } + + // Update the current media info + streamPath := event.Status.Filepath + if event.StreamType == "file" { + // For file streams, we should use the file path directly + streamPath = event.OptionalLocalPath + } + newCurrentMediaInfo := &WatchPartySessionMediaInfo{ + MediaId: event.State.MediaId, + EpisodeNumber: event.State.EpisodeNumber, + AniDBEpisode: event.State.AniDbEpisode, + StreamType: event.StreamType, + StreamPath: streamPath, + OptionalTorrentStreamStartOptions: event.OptionalTorrentStreamStartOptions, + } + + // Video playback has started, send the media info to the peers + session.CurrentMediaInfo = newCurrentMediaInfo + + // Pause immediately and wait for peers to be ready + _ = wpm.manager.playbackManager.Pause() + + // Reset buffering state for new playback + wpm.bufferMu.Lock() + wpm.isWaitingForBuffers = true + wpm.bufferWaitStart = time.Now() + + // Cancel existing waitForPeersReady goroutine + if wpm.waitForPeersCancel != nil { + wpm.waitForPeersCancel() + wpm.waitForPeersCancel = nil + } + wpm.bufferMu.Unlock() + + // broadcast the session state to the peers + // this will not include the relay origin + wpm.broadcastSessionStateToPeers() + + // Start checking peer readiness + go wpm.waitForPeersReady(func() { + if !session.IsRelayMode { + // not in relay mode, resume playback + _ = wpm.manager.playbackManager.Resume() + } else { + // in relay mode, just signal to the origin + _ = wpm.manager.SendMessage(MessageTypeWatchPartyRelayModePeersReady, nil) + } + }) + +} + +// handleWatchPartyRelayModeOriginPlaybackStatusEvent is called when the relay origin sends us (the host) a playback status update +func (wpm *WatchPartyManager) handleWatchPartyRelayModeOriginPlaybackStatusEvent(payload *WatchPartyRelayModeOriginPlaybackStatusPayload) { + wpm.mu.Lock() + defer wpm.mu.Unlock() + + // wpm.logger.Debug().Msg("nakama: Relay mode origin playback status") + + // Send the playback status immediately to the peers + // Get next sequence number for relayed message + wpm.sequenceMu.Lock() + wpm.sendSequence++ + sequenceNum := wpm.sendSequence + wpm.sequenceMu.Unlock() + + _ = wpm.manager.SendMessage(MessageTypeWatchPartyPlaybackStatus, WatchPartyPlaybackStatusPayload{ + PlaybackStatus: payload.Status, + Timestamp: payload.Timestamp, // timestamp of the origin + SequenceNumber: sequenceNum, + EpisodeNumber: payload.State.EpisodeNumber, + }) +} + +// handleWatchPartyRelayModeOriginPlaybackStoppedEvent is called when the relay origin sends us (the host) a playback stopped event +func (wpm *WatchPartyManager) handleWatchPartyRelayModeOriginPlaybackStoppedEvent() { + wpm.mu.Lock() + defer wpm.mu.Unlock() + + wpm.logger.Debug().Msg("nakama: Relay mode origin playback stopped") + + session, ok := wpm.currentSession.Get() + if !ok { + return + } + + session.mu.Lock() + session.CurrentMediaInfo = nil + session.mu.Unlock() + + wpm.broadcastSessionStateToPeers() + wpm.sendSessionStateToClient() +} diff --git a/seanime-2.9.10/internal/nakama/watch_party_onlinestream.go b/seanime-2.9.10/internal/nakama/watch_party_onlinestream.go new file mode 100644 index 0000000..33aa2d6 --- /dev/null +++ b/seanime-2.9.10/internal/nakama/watch_party_onlinestream.go @@ -0,0 +1,148 @@ +package nakama + +import ( + "seanime/internal/events" + "time" + + "github.com/goccy/go-json" +) + +const ( + OnlineStreamStartedEvent = "online-stream-started" // reported by host when onCanPlay is called + OnlineStreamPlaybackStatusEvent = "online-stream-playback-status" +) + +type OnlineStreamStartedEventPayload struct { + MediaId int `json:"mediaId"` + EpisodeNumber int `json:"episodeNumber"` + Provider string `json:"provider"` + Server string `json:"server"` + Dubbed bool `json:"dubbed"` + Quality string `json:"quality"` +} + +func (wpm *WatchPartyManager) listenToOnlineStreaming() { + go func() { + listener := wpm.manager.wsEventManager.SubscribeToClientNakamaEvents("watch_party") + + for { + select { + case <-wpm.sessionCtx.Done(): + wpm.logger.Debug().Msg("nakama: Stopping online stream listener") + return + case clientEvent := <-listener.Channel: + + marshaled, _ := json.Marshal(clientEvent.Payload) + + var event NakamaEvent + err := json.Unmarshal(marshaled, &event) + if err != nil { + return + } + + marshaledPayload, _ := json.Marshal(event.Payload) + + session, ok := wpm.currentSession.Get() + if !ok { + continue + } + + switch event.Type { + case OnlineStreamStartedEvent: + wpm.logger.Debug().Msg("nakama: Received online stream started event") + + var payload OnlineStreamStartedEventPayload + if err := json.Unmarshal(marshaledPayload, &payload); err != nil { + wpm.logger.Error().Err(err).Msg("nakama: Failed to unmarshal online stream started event") + return + } + wpm.logger.Debug().Interface("payload", payload).Msg("nakama: Received online stream started event") + + newCurrentMediaInfo := &WatchPartySessionMediaInfo{ + MediaId: payload.MediaId, + EpisodeNumber: payload.EpisodeNumber, + AniDBEpisode: "", + StreamType: "online", + StreamPath: "", + OnlineStreamParams: &OnlineStreamParams{ + MediaId: payload.MediaId, + Provider: payload.Provider, + EpisodeNumber: payload.EpisodeNumber, + Server: payload.Server, + Dubbed: payload.Dubbed, + Quality: payload.Quality, + }, + } + + session.CurrentMediaInfo = newCurrentMediaInfo + + // Pause immediately and wait for peers to be ready + //_ = wpm.manager.playbackManager.Pause() + wpm.sendCommandToOnlineStream(OnlineStreamCommandPause) + + // Reset buffering state for new playback + wpm.bufferMu.Lock() + wpm.isWaitingForBuffers = true + wpm.bufferWaitStart = time.Now() + + // Cancel existing waitForPeersReady goroutine + if wpm.waitForPeersCancel != nil { + wpm.waitForPeersCancel() + wpm.waitForPeersCancel = nil + } + wpm.bufferMu.Unlock() + + wpm.broadcastSessionStateToPeers() + + // Start checking peer readiness + go wpm.waitForPeersReady(func() { + wpm.sendCommandToOnlineStream(OnlineStreamCommandPlay) + }) + } + } + } + }() +} + +type OnlineStreamCommand string + +type OnlineStreamCommandPayload struct { + Type OnlineStreamCommand `json:"type"` // The command type + Payload interface{} `json:"payload,omitempty"` // Optional payload for the command +} + +const ( + OnlineStreamCommandStart OnlineStreamCommand = "start" // Start the online stream + OnlineStreamCommandPlay OnlineStreamCommand = "play" + OnlineStreamCommandPause OnlineStreamCommand = "pause" + OnlineStreamCommandSeek OnlineStreamCommand = "seek" + OnlineStreamCommandSeekTo OnlineStreamCommand = "seekTo" // Seek to a specific time in seconds +) + +func (wpm *WatchPartyManager) sendCommandToOnlineStream(cmd OnlineStreamCommand, payload ...interface{}) { + session, ok := wpm.currentSession.Get() + if !ok { + return + } + + if session.CurrentMediaInfo == nil || session.CurrentMediaInfo.OnlineStreamParams == nil { + wpm.logger.Warn().Msg("nakama: No online stream params available for sending command") + return + } + + commandPayload := OnlineStreamCommandPayload{ + Type: cmd, + Payload: nil, + } + + if len(payload) > 0 { + commandPayload.Payload = payload[0] + } + + event := NakamaEvent{ + Type: OnlineStreamPlaybackStatusEvent, + Payload: commandPayload, + } + + wpm.manager.wsEventManager.SendEvent(events.NakamaOnlineStreamEvent, event) +} diff --git a/seanime-2.9.10/internal/nakama/watch_party_peer.go b/seanime-2.9.10/internal/nakama/watch_party_peer.go new file mode 100644 index 0000000..c85bd40 --- /dev/null +++ b/seanime-2.9.10/internal/nakama/watch_party_peer.go @@ -0,0 +1,676 @@ +package nakama + +import ( + "context" + "errors" + "fmt" + "math" + "seanime/internal/events" + "seanime/internal/library/playbackmanager" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/util" + "strings" + "time" + + "github.com/samber/mo" +) + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Peer +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (wpm *WatchPartyManager) JoinWatchParty() error { + if wpm.manager.IsHost() { + return errors.New("only peers can join watch parties") + } + + wpm.logger.Debug().Msg("nakama: Joining watch party") + + hostConn, ok := wpm.manager.GetHostConnection() + if !ok { + return errors.New("no host connection found") + } + + _, ok = wpm.currentSession.Get() // session should exist + if !ok { + return errors.New("no watch party found") + } + + wpm.sessionCtx, wpm.sessionCtxCancel = context.WithCancel(context.Background()) + + // Reset sequence numbers for new session participation + wpm.sequenceMu.Lock() + wpm.sendSequence = 0 + wpm.lastRxSequence = 0 + wpm.sequenceMu.Unlock() + + // Send join message to host + _ = wpm.manager.SendMessageToHost(MessageTypeWatchPartyJoin, WatchPartyJoinPayload{ + PeerId: hostConn.PeerId, + Username: wpm.manager.username, + }) + + // Start status reporting to host + wpm.startStatusReporting() + + // Send websocket event to update the UI + wpm.sendSessionStateToClient() + + // Start listening to playback manager + wpm.relayModeListenToPlaybackManager() + + return nil +} + +// startStatusReporting starts sending status updates to the host every 2 seconds +func (wpm *WatchPartyManager) startStatusReporting() { + if wpm.manager.IsHost() { + return + } + + // Stop any existing status reporting + wpm.stopStatusReporting() + + wpm.mu.Lock() + defer wpm.mu.Unlock() + + // Reset buffering detection state + wpm.bufferDetectionMu.Lock() + wpm.lastPosition = 0 + wpm.lastPositionTime = time.Time{} + wpm.stallCount = 0 + wpm.bufferDetectionMu.Unlock() + + // Create context for status reporting + ctx, cancel := context.WithCancel(context.Background()) + wpm.statusReportCancel = cancel + + // Start ticker for regular status reports + wpm.statusReportTicker = time.NewTicker(2 * time.Second) + + go func() { + defer util.HandlePanicInModuleThen("nakama/startStatusReporting", func() {}) + defer wpm.statusReportTicker.Stop() + + hostConn, ok := wpm.manager.GetHostConnection() + if !ok { + return + } + + wpm.logger.Debug().Msg("nakama: Started status reporting to host") + + for { + select { + case <-ctx.Done(): + wpm.logger.Debug().Msg("nakama: Stopped status reporting") + return + case <-wpm.statusReportTicker.C: + wpm.sendStatusToHost(hostConn.PeerId) + } + } + }() +} + +// stopStatusReporting stops sending status updates to the host +func (wpm *WatchPartyManager) stopStatusReporting() { + if wpm.statusReportCancel != nil { + wpm.statusReportCancel() + wpm.statusReportCancel = nil + } + + if wpm.statusReportTicker != nil { + wpm.statusReportTicker.Stop() + wpm.statusReportTicker = nil + } +} + +// sendStatusToHost sends current playback status and buffer state to the host +func (wpm *WatchPartyManager) sendStatusToHost(peerId string) { + playbackStatus, hasPlayback := wpm.manager.playbackManager.PullStatus() + if !hasPlayback { + return + } + + // Calculate buffer health and buffering state + isBuffering, bufferHealth := wpm.calculateBufferState(playbackStatus) + + // Send peer status update + _ = wpm.manager.SendMessageToHost(MessageTypeWatchPartyPeerStatus, WatchPartyPeerStatusPayload{ + PeerId: peerId, + PlaybackStatus: *playbackStatus, + IsBuffering: isBuffering, + BufferHealth: bufferHealth, + Timestamp: time.Now(), + }) +} + +// calculateBufferState calculates buffering state and buffer health from playback status +func (wpm *WatchPartyManager) calculateBufferState(status *mediaplayer.PlaybackStatus) (bool, float64) { + if status == nil { + return true, 0.0 // No status means we're probably buffering + } + + wpm.bufferDetectionMu.Lock() + defer wpm.bufferDetectionMu.Unlock() + + now := time.Now() + currentPosition := status.CurrentTimeInSeconds + + // Initialize tracking on first call + if wpm.lastPositionTime.IsZero() { + wpm.lastPosition = currentPosition + wpm.lastPositionTime = now + wpm.stallCount = 0 + return false, 1.0 // Assume good state initially + } + + // Time since last position check + timeDelta := now.Sub(wpm.lastPositionTime).Seconds() + positionDelta := currentPosition - wpm.lastPosition + + // Update tracking + wpm.lastPosition = currentPosition + wpm.lastPositionTime = now + + // Don't check too frequently to avoid false positives + if timeDelta < BufferDetectionMinInterval { + return false, 1.0 // Return good state if checking too soon + } + + // Check if we're at the end of the content + isAtEnd := currentPosition >= (status.DurationInSeconds - EndOfContentThreshold) + if isAtEnd { + // Reset stall count when at end + wpm.stallCount = 0 + return false, 1.0 // Not buffering if we're at the end + } + + // Handle seeking, if position jumped significantly, reset tracking + if math.Abs(positionDelta) > SignificantPositionJump { // Detect seeking vs normal playback + wpm.logger.Debug(). + Float64("positionDelta", positionDelta). + Float64("currentPosition", currentPosition). + Msg("nakama: Position change detected, likely seeking, resetting stall tracking") + wpm.stallCount = 0 + return false, 1.0 // Reset state after seeking + } + + // If the player is playing but position hasn't advanced significantly + if status.Playing { + // Expected minimum position change + expectedMinChange := timeDelta * BufferDetectionTolerance + + if positionDelta < expectedMinChange { + // Position hasn't advanced as expected while playing, likely buffering + wpm.stallCount++ + + // Consider buffering after threshold consecutive stalls to avoid false positives + isBuffering := wpm.stallCount >= BufferDetectionStallThreshold + + // Buffer health decreases with consecutive stalls + bufferHealth := math.Max(0.0, 1.0-(float64(wpm.stallCount)*BufferHealthDecrement)) + + if isBuffering { + wpm.logger.Debug(). + Int("stallCount", wpm.stallCount). + Float64("positionDelta", positionDelta). + Float64("expectedMinChange", expectedMinChange). + Float64("bufferHealth", bufferHealth). + Msg("nakama: Buffering detected, position not advancing while playing") + } + + return isBuffering, bufferHealth + } else { + // Position is advancing normally, reset stall count + if wpm.stallCount > 0 { + wpm.logger.Debug(). + Int("previousStallCount", wpm.stallCount). + Float64("positionDelta", positionDelta). + Msg("nakama: Playback resumed normally, resetting stall count") + } + wpm.stallCount = 0 + return false, 0.95 // good buffer health when playing normally + } + } else { + // Player is paused, reset stall count and return good buffer state + if wpm.stallCount > 0 { + wpm.logger.Debug().Msg("nakama: Player paused, resetting stall count") + } + wpm.stallCount = 0 + return false, 1.0 + } +} + +// resetBufferingState resets the buffering detection state (useful when playback changes) +func (wpm *WatchPartyManager) resetBufferingState() { + wpm.bufferDetectionMu.Lock() + defer wpm.bufferDetectionMu.Unlock() + + wpm.lastPosition = 0 + wpm.lastPositionTime = time.Time{} + wpm.stallCount = 0 + wpm.logger.Debug().Msg("nakama: Reset buffering detection state") +} + +// LeaveWatchParty signals to the host that the peer is leaving the watch party. +// The host will remove the peer from the session and the peer will receive a new session state. +// DEVNOTE: We don't remove the session from the manager, it should still exist. +func (wpm *WatchPartyManager) LeaveWatchParty() error { + if wpm.manager.IsHost() { + return errors.New("only peers can leave watch parties") + } + + wpm.mu.Lock() + defer wpm.mu.Unlock() + + wpm.logger.Debug().Msg("nakama: Leaving watch party") + + // Stop status reporting + wpm.stopStatusReporting() + + // Cancel the session context + if wpm.sessionCtxCancel != nil { + wpm.sessionCtxCancel() + wpm.sessionCtx = nil + wpm.sessionCtxCancel = nil + } + + hostConn, ok := wpm.manager.GetHostConnection() + if !ok { + return errors.New("no host connection found") + } + + _, ok = wpm.currentSession.Get() // session should exist + if !ok { + return errors.New("no watch party found") + } + + _ = wpm.manager.SendMessageToHost(MessageTypeWatchPartyLeave, WatchPartyLeavePayload{ + PeerId: hostConn.PeerId, + }) + + // Send websocket event to update the UI (nil indicates session left) + wpm.manager.wsEventManager.SendEvent(events.NakamaWatchPartyState, nil) + + return nil +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Events +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// handleWatchPartyStateChangedEvent is called when the host updates the session state. +// It starts a stream on the peer if there's a new media info. +func (wpm *WatchPartyManager) handleWatchPartyStateChangedEvent(payload *WatchPartyStateChangedPayload) { + if wpm.manager.IsHost() { + return + } + + wpm.mu.Lock() + defer wpm.mu.Unlock() + + hostConn, ok := wpm.manager.GetHostConnection() // should always be ok + if !ok { + return + } + + // + // Session didn't exist + // + + // Immediately update the session if it doesn't exist + if _, exists := wpm.currentSession.Get(); !exists && payload.Session != nil { + wpm.currentSession = mo.Some(&WatchPartySession{}) // Add a placeholder session + } + + currentSession, exists := wpm.currentSession.Get() + if !exists { + return + } + + // + // Session destroyed + // + + if payload.Session == nil { + wpm.logger.Debug().Msg("nakama: Session destroyed") + if wpm.sessionCtxCancel != nil { + wpm.sessionCtxCancel() + wpm.sessionCtx = nil + wpm.sessionCtxCancel = nil + } + // Stop playback if it's playing + if _, ok := currentSession.Participants[hostConn.PeerId]; ok { + wpm.logger.Debug().Msg("nakama: Stopping playback due to session destroyed") + _ = wpm.manager.playbackManager.Cancel() + } + wpm.currentSession = mo.None[*WatchPartySession]() + wpm.sendSessionStateToClient() + return + } + + // \/ Below, session should exist + + participant, isParticipant := payload.Session.Participants[hostConn.PeerId] + + // + // Starting playback / Peer joined / Video changed + // + + // If the payload session has a media info but the current session doesn't, + // and the peer is a participant, we need to start playback + newPlayback := payload.Session.CurrentMediaInfo != nil && currentSession.CurrentMediaInfo == nil + playbackChanged := payload.Session.CurrentMediaInfo != nil && !payload.Session.CurrentMediaInfo.Equals(currentSession.CurrentMediaInfo) + + // Check if peer is newly a participant - they should start playback even if media info hasn't changed + wasParticipant := currentSession.Participants != nil && currentSession.Participants[hostConn.PeerId] != nil + peerJoined := isParticipant && !wasParticipant && payload.Session.CurrentMediaInfo != nil + + if (newPlayback || playbackChanged || peerJoined) && + isParticipant && + !participant.IsRelayOrigin { + wpm.logger.Debug().Bool("newPlayback", newPlayback).Bool("playbackChanged", playbackChanged).Bool("peerJoined", peerJoined).Msg("nakama: Starting playback due to new media info") + + // Reset buffering detection state for new media + wpm.resetBufferingState() + + // Fetch the media info + media, err := wpm.manager.platform.GetAnime(context.Background(), payload.Session.CurrentMediaInfo.MediaId) + if err != nil { + wpm.logger.Error().Err(err).Msg("nakama: Failed to fetch media info for watch party") + return + } + + // Play the media + wpm.logger.Debug().Int("mediaId", payload.Session.CurrentMediaInfo.MediaId).Msg("nakama: Playing watch party media") + + switch payload.Session.CurrentMediaInfo.StreamType { + case "torrent": + if payload.Session.CurrentMediaInfo.OptionalTorrentStreamStartOptions == nil { + wpm.logger.Error().Msg("nakama: No torrent stream start options found") + wpm.manager.wsEventManager.SendEvent(events.ErrorToast, "Watch party: Failed to play media: Host did not return torrent stream start options") + return + } + if !wpm.manager.torrentstreamRepository.IsEnabled() { + wpm.logger.Error().Msg("nakama: Torrent streaming is not enabled") + wpm.manager.wsEventManager.SendEvent(events.ErrorToast, "Watch party: Failed to play media: Torrent streaming is not enabled") + return + } + // Start the torrent + err = wpm.manager.torrentstreamRepository.StartStream(wpm.sessionCtx, payload.Session.CurrentMediaInfo.OptionalTorrentStreamStartOptions) + case "debrid": + err = wpm.manager.PlayHostAnimeStream(payload.Session.CurrentMediaInfo.StreamType, "seanime/nakama", media, payload.Session.CurrentMediaInfo.AniDBEpisode) + case "file": + err = wpm.manager.PlayHostAnimeLibraryFile(payload.Session.CurrentMediaInfo.StreamPath, "seanime/nakama", media, payload.Session.CurrentMediaInfo.AniDBEpisode) + case "online": + wpm.sendCommandToOnlineStream(OnlineStreamCommandStart, payload.Session.CurrentMediaInfo.OnlineStreamParams) + } + if err != nil { + wpm.logger.Error().Err(err).Msg("nakama: Failed to play watch party media") + wpm.manager.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("Watch party: Failed to play media: %s", err.Error())) + } + + // Auto-leave the watch party when playback stops + // The user will have to re-join to start the stream again + if payload.Session.CurrentMediaInfo.StreamType != "online" && !participant.IsRelayOrigin { + wpm.peerPlaybackListener = wpm.manager.playbackManager.SubscribeToPlaybackStatus("nakama_peer_playback_listener") + go func() { + defer util.HandlePanicInModuleThen("nakama/handleWatchPartyStateChangedEvent/autoLeaveWatchParty", func() {}) + + for { + select { + case <-wpm.sessionCtx.Done(): + wpm.manager.playbackManager.UnsubscribeFromPlaybackStatus("nakama_peer_playback_listener") + return + case event, ok := <-wpm.peerPlaybackListener.EventCh: + if !ok { + return + } + + switch event.(type) { + case playbackmanager.StreamStoppedEvent: + _ = wpm.LeaveWatchParty() + return + } + } + } + }() + } + } + + // + // Peer left + // + + canceledPlayback := false + + // If the peer is a participant in the current session but the new session doesn't have them, + // we need to stop playback and status reporting + if _, ok := currentSession.Participants[hostConn.PeerId]; ok && payload.Session.Participants[hostConn.PeerId] == nil { + wpm.logger.Debug().Msg("nakama: Removing peer from session due to new session state") + // Stop status reporting when removed from session + wpm.stopStatusReporting() + // Before stopping playback, unsubscribe from the playback listener + // This is to prevent the peer from auto-leaving the watch party when host stops playback + if wpm.peerPlaybackListener != nil { + wpm.manager.playbackManager.UnsubscribeFromPlaybackStatus("nakama_peer_playback_listener") + wpm.peerPlaybackListener = nil + } + _ = wpm.manager.playbackManager.Cancel() + canceledPlayback = true + } + + // + // Session stopped + // + + // If the host stopped the session, we need to cancel playback + if payload.Session.CurrentMediaInfo == nil && currentSession.CurrentMediaInfo != nil && !canceledPlayback { + wpm.logger.Debug().Msg("nakama: Canceling playback due to host stopping session") + // Before stopping playback, unsubscribe from the playback listener + // This is to prevent the peer from auto-leaving the watch party when host stops playback + if wpm.peerPlaybackListener != nil { + wpm.manager.playbackManager.UnsubscribeFromPlaybackStatus("nakama_peer_playback_listener") + wpm.peerPlaybackListener = nil + } + _ = wpm.manager.playbackManager.Cancel() + canceledPlayback = true + } + + // Update the session + wpm.currentSession = mo.Some(payload.Session) + wpm.sendSessionStateToClient() +} + +// handleWatchPartyCreatedEvent is called when a host creates a watch party +// We cancel any existing session +// We just store the session in the manager, and the peer will decide whether to join or not +func (wpm *WatchPartyManager) handleWatchPartyCreatedEvent(payload *WatchPartyCreatedPayload) { + if wpm.manager.IsHost() { + return + } + + wpm.logger.Debug().Msg("nakama: Host created watch party") + + // Cancel any existing session + if wpm.sessionCtxCancel != nil { + wpm.sessionCtxCancel() + wpm.sessionCtx = nil + wpm.sessionCtxCancel = nil + wpm.currentSession = mo.None[*WatchPartySession]() + } + + // Load the session into the manager + // even if the peer isn't a participant + wpm.currentSession = mo.Some(payload.Session) + + wpm.sendSessionStateToClient() +} + +// handleWatchPartyStoppedEvent is called when the host stops a watch party. +// +// We check if the user was a participant in an active watch party session. +// If yes, we will cancel playback. +func (wpm *WatchPartyManager) handleWatchPartyStoppedEvent() { + if wpm.manager.IsHost() { + return + } + + wpm.logger.Debug().Msg("nakama: Host stopped watch party") + + // Stop status reporting + wpm.stopStatusReporting() + + // Cancel any ongoing catch-up operations + wpm.cancelCatchUp() + + hostConn, ok := wpm.manager.GetHostConnection() // should always be ok + if !ok { + return + } + + // Cancel playback if the user was a participant in any previous session + currentSession, ok := wpm.currentSession.Get() + if ok { + if _, ok := currentSession.Participants[hostConn.PeerId]; ok { + _ = wpm.manager.playbackManager.Cancel() + } + } + + // Cancel any existing session + if wpm.sessionCtxCancel != nil { + wpm.sessionCtxCancel() + wpm.sessionCtx = nil + wpm.sessionCtxCancel = nil + wpm.currentSession = mo.None[*WatchPartySession]() + } + + wpm.manager.wsEventManager.SendEvent(events.NakamaWatchPartyState, nil) +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Relay mode +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// relayModeListenToPlaybackManager starts listening to the playback manager when in relay mode +func (wpm *WatchPartyManager) relayModeListenToPlaybackManager() { + go func() { + defer util.HandlePanicInModuleThen("nakama/relayModeListenToPlaybackManager", func() {}) + + wpm.logger.Debug().Msg("nakama: Started listening to playback manager for relay mode") + + playbackSubscriber := wpm.manager.playbackManager.SubscribeToPlaybackStatus("nakama_peer_relay_mode") + defer wpm.manager.playbackManager.UnsubscribeFromPlaybackStatus("nakama_peer_relay_mode") + + newStream := false + streamStartedPayload := WatchPartyRelayModeOriginStreamStartedPayload{} + + for { + select { + case <-wpm.sessionCtx.Done(): + wpm.logger.Debug().Msg("nakama: Stopped listening to playback manager") + return + case event := <-playbackSubscriber.EventCh: + currentSession, ok := wpm.currentSession.Get() // should always be ok + if !ok { + return + } + + hostConn, ok := wpm.manager.GetHostConnection() // should always be ok + if !ok { + return + } + + currentSession.mu.Lock() + if !currentSession.IsRelayMode { + currentSession.mu.Unlock() + continue + } + + participant, ok := currentSession.Participants[hostConn.PeerId] + if !ok { + currentSession.mu.Unlock() + continue + } + + if !participant.IsRelayOrigin { + currentSession.mu.Unlock() + continue + } + + switch event := event.(type) { + // 1. Stream started + case playbackmanager.StreamStartedEvent: + wpm.logger.Debug().Msg("nakama: Relay mode origin stream started") + + newStream = true + streamStartedPayload = WatchPartyRelayModeOriginStreamStartedPayload{} + + // immediately pause the playback + _ = wpm.manager.playbackManager.Pause() + + streamStartedPayload.Filename = event.Filename + streamStartedPayload.Filepath = event.Filepath + + if strings.Contains(streamStartedPayload.Filepath, "type=file") { + streamStartedPayload.OptionalLocalPath = wpm.manager.previousPath + streamStartedPayload.StreamType = "file" + } else if strings.Contains(streamStartedPayload.Filepath, "/api/v1/torrentstream") { + streamStartedPayload.StreamType = "torrent" + streamStartedPayload.OptionalTorrentStreamStartOptions, _ = wpm.manager.torrentstreamRepository.GetPreviousStreamOptions() + } else { + streamStartedPayload.StreamType = "debrid" + streamStartedPayload.OptionalDebridStreamStartOptions, _ = wpm.manager.debridClientRepository.GetPreviousStreamOptions() + } + + // 2. Stream status changed + case playbackmanager.PlaybackStatusChangedEvent: + wpm.logger.Debug().Msg("nakama: Relay mode origin stream status changed") + + if newStream { + newStream = false + + // this is a new stream, send the stream started payload + _ = wpm.manager.SendMessageToHost(MessageTypeWatchPartyRelayModeOriginStreamStarted, WatchPartyRelayModeOriginStreamStartedPayload{ + Filename: streamStartedPayload.Filename, + Filepath: streamStartedPayload.Filepath, + StreamType: streamStartedPayload.StreamType, + OptionalLocalPath: streamStartedPayload.OptionalLocalPath, + OptionalTorrentStreamStartOptions: streamStartedPayload.OptionalTorrentStreamStartOptions, + OptionalDebridStreamStartOptions: streamStartedPayload.OptionalDebridStreamStartOptions, + Status: event.Status, + State: event.State, + }) + currentSession.mu.Unlock() + continue + } + + // send the playback status to the relay host + _ = wpm.manager.SendMessageToHost(MessageTypeWatchPartyRelayModeOriginPlaybackStatus, WatchPartyRelayModeOriginPlaybackStatusPayload{ + Status: event.Status, + State: event.State, + Timestamp: time.Now().UnixNano(), + }) + + // 3. Stream stopped + case playbackmanager.StreamStoppedEvent: + wpm.logger.Debug().Msg("nakama: Relay mode origin stream stopped") + _ = wpm.manager.SendMessageToHost(MessageTypeWatchPartyRelayModeOriginPlaybackStopped, nil) + } + currentSession.mu.Unlock() + } + } + }() +} + +// handleWatchPartyRelayModePeersReadyEvent is called when the host signals that the peers are ready in relay mode +func (wpm *WatchPartyManager) handleWatchPartyRelayModePeersReadyEvent() { + if wpm.manager.IsHost() { + return + } + + wpm.logger.Debug().Msg("nakama: Relay mode peers ready") + + // resume playback + _ = wpm.manager.playbackManager.Resume() +} diff --git a/seanime-2.9.10/internal/nakama/watch_party_syncing.go b/seanime-2.9.10/internal/nakama/watch_party_syncing.go new file mode 100644 index 0000000..3da5d0a --- /dev/null +++ b/seanime-2.9.10/internal/nakama/watch_party_syncing.go @@ -0,0 +1,413 @@ +package nakama + +import ( + "context" + "math" + "seanime/internal/mediaplayers/mediaplayer" + "time" +) + +// handleWatchPartyPlaybackStatusEvent is called when the host sends a playback status. +// +// We check if the peer is a participant in the session. +// If yes, we will update the playback status and sync the playback position. +func (wpm *WatchPartyManager) handleWatchPartyPlaybackStatusEvent(payload *WatchPartyPlaybackStatusPayload) { + if wpm.manager.IsHost() { + return + } + + // wpm.logger.Debug().Msg("nakama: Received playback status from watch party") + + wpm.mu.Lock() + defer wpm.mu.Unlock() + + session, ok := wpm.currentSession.Get() + if !ok { + return + } + + hostConn, ok := wpm.manager.GetHostConnection() + if !ok { + return + } + + if participant, isParticipant := session.Participants[hostConn.PeerId]; !isParticipant || participant.IsRelayOrigin { + return + } + + payloadStatus := payload.PlaybackStatus + + // If the peer's session doesn't have a media info, do nothing + if session.CurrentMediaInfo == nil { + return + } + + // If the playback manager doesn't have a status, do nothing + playbackStatus, ok := wpm.manager.playbackManager.PullStatus() + if !ok { + return + } + + // Check if the message is too old to prevent acting on stale data + wpm.sequenceMu.Lock() + isStale := payload.SequenceNumber != 0 && payload.SequenceNumber <= wpm.lastRxSequence + if payload.SequenceNumber > wpm.lastRxSequence { + wpm.lastRxSequence = payload.SequenceNumber + } + wpm.sequenceMu.Unlock() + + if isStale { + wpm.logger.Debug().Uint64("messageSeq", payload.SequenceNumber).Uint64("lastSeq", wpm.lastRxSequence).Msg("nakama: Ignoring stale playback status message (old sequence)") + return + } + + now := time.Now().UnixNano() + driftNs := now - payload.Timestamp + timeSinceMessage := float64(driftNs) / 1e9 // Convert to seconds + if timeSinceMessage > 5 { // Clamp to a reasonable maximum delay + timeSinceMessage = 0 // If it's more than 5 seconds, treat it as no delay + } + + // Handle play/pause state changes + if payloadStatus.Playing != playbackStatus.Playing { + if payloadStatus.Playing { + // Cancel any ongoing catch-up operation + wpm.cancelCatchUp() + + // When host resumes, sync position before resuming if there's significant drift + // Calculate where the host should be NOW, not when they resumed + hostCurrentPosition := payloadStatus.CurrentTimeInSeconds + timeSinceMessage + positionDrift := hostCurrentPosition - playbackStatus.CurrentTimeInSeconds + + // Check if we need to seek + shouldSeek := false + if positionDrift < 0 { + // Peer is behind, always seek if beyond threshold + shouldSeek = math.Abs(positionDrift) > ResumePositionDriftThreshold + } else { + // Peer is ahead, only seek backward if significantly ahead to prevent jitter + // This prevents backward seeks when peer is slightly ahead due to pause message delay + shouldSeek = positionDrift > ResumeAheadTolerance + } + + if shouldSeek { + // Calculate dynamic seek delay based on message timing + dynamicDelay := time.Duration(timeSinceMessage*1000) * time.Millisecond + if dynamicDelay < MinSeekDelay { + dynamicDelay = MinSeekDelay + } + if dynamicDelay > MaxDynamicDelay { + dynamicDelay = MaxDynamicDelay + } + + // Predict where host will be when our seek takes effect + seekPosition := hostCurrentPosition + dynamicDelay.Seconds() + + wpm.logger.Debug(). + Float64("positionDrift", positionDrift). + Float64("hostCurrentPosition", hostCurrentPosition). + Float64("seekPosition", seekPosition). + Float64("peerPosition", playbackStatus.CurrentTimeInSeconds). + Float64("dynamicDelay", dynamicDelay.Seconds()). + Bool("peerAhead", positionDrift > 0). + Msg("nakama: Host resumed, syncing position before resume") + + // Track pending seek + now := time.Now() + wpm.seekMu.Lock() + wpm.pendingSeekTime = now + wpm.pendingSeekPosition = seekPosition + wpm.seekMu.Unlock() + + _ = wpm.manager.playbackManager.Seek(seekPosition) + } else if positionDrift > 0 && positionDrift <= ResumeAheadTolerance { + wpm.logger.Debug(). + Float64("positionDrift", positionDrift). + Float64("hostCurrentPosition", hostCurrentPosition). + Float64("peerPosition", playbackStatus.CurrentTimeInSeconds). + Msg("nakama: Host resumed, peer slightly ahead, not seeking yet") + } + + wpm.logger.Debug().Msg("nakama: Host resumed, resuming peer playback") + _ = wpm.manager.playbackManager.Resume() + } else { + wpm.logger.Debug().Msg("nakama: Host paused, handling peer pause") + wpm.handleHostPause(payloadStatus, *playbackStatus, timeSinceMessage) + } + } + + // Handle position sync for different state combinations + if payloadStatus.Playing == playbackStatus.Playing { + // Both in same state, use normal sync + wpm.syncPlaybackPosition(payloadStatus, *playbackStatus, timeSinceMessage, session) + } else if payloadStatus.Playing && !playbackStatus.Playing { + // Host playing, peer paused, sync position and resume + hostExpectedPosition := payloadStatus.CurrentTimeInSeconds + timeSinceMessage + + wpm.logger.Debug(). + Float64("hostPosition", hostExpectedPosition). + Float64("peerPosition", playbackStatus.CurrentTimeInSeconds). + Msg("nakama: Host is playing but peer is paused, syncing and resuming") + + // Resume and sync to host position + _ = wpm.manager.playbackManager.Resume() + + // Track pending seek + now := time.Now() + wpm.seekMu.Lock() + wpm.pendingSeekTime = now + wpm.pendingSeekPosition = hostExpectedPosition + wpm.seekMu.Unlock() + + _ = wpm.manager.playbackManager.Seek(hostExpectedPosition) + } else if !payloadStatus.Playing && playbackStatus.Playing { + // Host paused, peer playing, pause immediately + wpm.logger.Debug().Msg("nakama: Host is paused but peer is playing, pausing immediately") + + // Cancel catch-up and pause + wpm.cancelCatchUp() + wpm.handleHostPause(payloadStatus, *playbackStatus, timeSinceMessage) + } +} + +// handleHostPause handles when the host pauses playback +func (wpm *WatchPartyManager) handleHostPause(hostStatus mediaplayer.PlaybackStatus, peerStatus mediaplayer.PlaybackStatus, timeSinceMessage float64) { + // Cancel any ongoing catch-up operation + wpm.cancelCatchUp() + + now := time.Now() + + // Calculate where the host actually paused based on dynamic timing + hostActualPausePosition := hostStatus.CurrentTimeInSeconds + // Don't add time compensation for pause position, the host has already paused + + // Calculate time difference considering message delay + timeDifference := hostActualPausePosition - peerStatus.CurrentTimeInSeconds + + // If peer is significantly behind the host, let it catch up before pausing + if timeDifference > CatchUpBehindThreshold { + wpm.logger.Debug().Msgf("nakama: Host paused, peer behind by %.2f seconds, catching up", timeDifference) + wpm.startCatchUp(hostActualPausePosition, timeSinceMessage) + } else { + // Peer is close enough or ahead, pause immediately with position correction + // Use more aggressive sync threshold for pause operations + if math.Abs(timeDifference) > PausePositionSyncThreshold { + wpm.logger.Debug(). + Float64("hostPausePosition", hostActualPausePosition). + Float64("peerPosition", peerStatus.CurrentTimeInSeconds). + Float64("timeDifference", timeDifference). + Float64("timeSinceMessage", timeSinceMessage). + Msg("nakama: Host paused, syncing position before pause") + + // Track pending seek + wpm.seekMu.Lock() + wpm.pendingSeekTime = now + wpm.pendingSeekPosition = hostActualPausePosition + wpm.seekMu.Unlock() + + _ = wpm.manager.playbackManager.Seek(hostActualPausePosition) + } + _ = wpm.manager.playbackManager.Pause() + wpm.logger.Debug().Msgf("nakama: Host paused, peer paused immediately (diff: %.2f)", timeDifference) + } +} + +// startCatchUp starts a catch-up operation to sync with the host's pause position +func (wpm *WatchPartyManager) startCatchUp(hostPausePosition float64, timeSinceMessage float64) { + wpm.catchUpMu.Lock() + defer wpm.catchUpMu.Unlock() + + // Cancel any existing catch-up + if wpm.catchUpCancel != nil { + wpm.catchUpCancel() + } + + // Create a new context for this catch-up operation + ctx, cancel := context.WithCancel(context.Background()) + wpm.catchUpCancel = cancel + + go func() { + defer cancel() + + ticker := time.NewTicker(CatchUpTickInterval) + defer ticker.Stop() + + maxCatchUpTime := MaxCatchUpDuration + startTime := time.Now() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // If catch-up is taking too long, force sync to host position + if time.Since(startTime) > maxCatchUpTime { + wpm.logger.Debug().Msg("nakama: Catch-up timeout, seeking to host position and pausing") + + // Seek to host position and pause + now := time.Now() + wpm.seekMu.Lock() + wpm.pendingSeekTime = now + wpm.pendingSeekPosition = hostPausePosition + wpm.seekMu.Unlock() + + _ = wpm.manager.playbackManager.Seek(hostPausePosition) + _ = wpm.manager.playbackManager.Pause() + return + } + + // Get current playback status + currentStatus, ok := wpm.manager.playbackManager.PullStatus() + if !ok { + continue + } + + // Check if we've reached or passed the host's pause position (with tighter tolerance) + positionDiff := hostPausePosition - currentStatus.CurrentTimeInSeconds + if positionDiff <= CatchUpToleranceThreshold { + wpm.logger.Debug().Msgf("nakama: Caught up to host position %.2f (current: %.2f), pausing", hostPausePosition, currentStatus.CurrentTimeInSeconds) + + // Track pending seek + now := time.Now() + wpm.seekMu.Lock() + wpm.pendingSeekTime = now + wpm.pendingSeekPosition = hostPausePosition + wpm.seekMu.Unlock() + + _ = wpm.manager.playbackManager.Seek(hostPausePosition) + _ = wpm.manager.playbackManager.Pause() + return + } + + // Continue trying to catch up to host position + + wpm.logger.Debug(). + Float64("positionDiff", positionDiff). + Float64("currentPosition", currentStatus.CurrentTimeInSeconds). + Float64("hostPausePosition", hostPausePosition). + Msg("nakama: Still catching up to host pause position") + } + } + }() +} + +// cancelCatchUp cancels any ongoing catch-up operation +func (wpm *WatchPartyManager) cancelCatchUp() { + wpm.catchUpMu.Lock() + defer wpm.catchUpMu.Unlock() + + if wpm.catchUpCancel != nil { + wpm.catchUpCancel() + wpm.catchUpCancel = nil + } +} + +// syncPlaybackPosition synchronizes playback position when both host and peer are in the same play/pause state +func (wpm *WatchPartyManager) syncPlaybackPosition(hostStatus mediaplayer.PlaybackStatus, peerStatus mediaplayer.PlaybackStatus, timeSinceMessage float64, session *WatchPartySession) { + now := time.Now() + + // Ignore very old messages to prevent stale syncing + if timeSinceMessage > MaxMessageAge { + return + } + + // Check if we have a pending seek operation, use dynamic compensation + wpm.seekMu.Lock() + hasPendingSeek := !wpm.pendingSeekTime.IsZero() + timeSincePendingSeek := now.Sub(wpm.pendingSeekTime) + pendingSeekPosition := wpm.pendingSeekPosition + wpm.seekMu.Unlock() + + // Use dynamic compensation, if we have a pending seek, wait for at least the message delay time + dynamicSeekDelay := time.Duration(timeSinceMessage*1000) * time.Millisecond + if dynamicSeekDelay < MinSeekDelay { + dynamicSeekDelay = MinSeekDelay // Minimum delay + } + if dynamicSeekDelay > MaxSeekDelay { + dynamicSeekDelay = MaxSeekDelay // Maximum delay + } + + // If we have a pending seek that's still in progress, don't sync + if hasPendingSeek && timeSincePendingSeek < dynamicSeekDelay { + wpm.logger.Debug(). + Float64("timeSincePendingSeek", timeSincePendingSeek.Seconds()). + Float64("dynamicSeekDelay", dynamicSeekDelay.Seconds()). + Float64("pendingSeekPosition", pendingSeekPosition). + Msg("nakama: Ignoring sync, pending seek in progress") + return + } + + // Clear pending seek if it's been long enough + if hasPendingSeek && timeSincePendingSeek >= dynamicSeekDelay { + wpm.seekMu.Lock() + wpm.pendingSeekTime = time.Time{} + wpm.pendingSeekPosition = 0 + wpm.seekMu.Unlock() + } + + // Dynamic compensation: Calculate where the host should be NOW based on their timestamp + hostCurrentPosition := hostStatus.CurrentTimeInSeconds + if hostStatus.Playing { + // Add the exact time that has passed since the host's status was captured + hostCurrentPosition += timeSinceMessage + } + + // Calculate drift between peer and host's current position + drift := hostCurrentPosition - peerStatus.CurrentTimeInSeconds + driftAbs := drift + if driftAbs < 0 { + driftAbs = -driftAbs + } + + // Get sync threshold from session settings + syncThreshold := session.Settings.SyncThreshold + // Clamp + if syncThreshold < MinSyncThreshold { + syncThreshold = MinSyncThreshold + } else if syncThreshold > MaxSyncThreshold { + syncThreshold = MaxSyncThreshold + } + + // Check if we're in seek cooldown period + timeSinceLastSeek := now.Sub(wpm.lastSeekTime) + inCooldown := timeSinceLastSeek < wpm.seekCooldown + + // Use more aggressive thresholds for different drift ranges + effectiveThreshold := syncThreshold + if driftAbs > 3.0 { // Large drift - be very aggressive + effectiveThreshold = syncThreshold * AggressiveSyncMultiplier + } else if driftAbs > 1.5 { // Medium drift - be more aggressive + effectiveThreshold = syncThreshold * ModerateSyncMultiplier + } + + // Only sync if drift exceeds threshold and we're not in cooldown + if driftAbs > effectiveThreshold && !inCooldown { + // For the seek position, predict where the host will be when our seek takes effect + // Use the dynamic delay we calculated based on actual network conditions + seekPosition := hostCurrentPosition + if hostStatus.Playing { + // Add compensation for the time it will take for our seek to take effect + seekPosition += dynamicSeekDelay.Seconds() + } + + wpm.logger.Debug(). + Float64("drift", drift). + Float64("hostOriginalPosition", hostStatus.CurrentTimeInSeconds). + Float64("hostCurrentPosition", hostCurrentPosition). + Float64("seekPosition", seekPosition). + Float64("peerPosition", peerStatus.CurrentTimeInSeconds). + Float64("timeSinceMessage", timeSinceMessage). + Float64("dynamicSeekDelay", dynamicSeekDelay.Seconds()). + Float64("effectiveThreshold", effectiveThreshold). + Msg("nakama: Syncing playback position with dynamic compensation") + + // Track pending seek + wpm.seekMu.Lock() + wpm.pendingSeekTime = now + wpm.pendingSeekPosition = seekPosition + wpm.seekMu.Unlock() + + _ = wpm.manager.playbackManager.Seek(seekPosition) + wpm.lastSeekTime = now + } +} diff --git a/seanime-2.9.10/internal/nativeplayer/events.go b/seanime-2.9.10/internal/nativeplayer/events.go new file mode 100644 index 0000000..f10f76f --- /dev/null +++ b/seanime-2.9.10/internal/nativeplayer/events.go @@ -0,0 +1,414 @@ +package nativeplayer + +import ( + "context" + "seanime/internal/mkvparser" + "time" + + "github.com/goccy/go-json" +) + +type ServerEvent string + +const ( + ServerEventOpenAndAwait ServerEvent = "open-and-await" + ServerEventWatch ServerEvent = "watch" + ServerEventSubtitleEvent ServerEvent = "subtitle-event" + ServerEventSetTracks ServerEvent = "set-tracks" + ServerEventPause ServerEvent = "pause" + ServerEventResume ServerEvent = "resume" + ServerEventSeek ServerEvent = "seek" + ServerEventError ServerEvent = "error" + ServerEventAddSubtitleTrack ServerEvent = "add-subtitle-track" + ServerEventTerminate ServerEvent = "terminate" +) + +// OpenAndAwait opens the player and waits for the client to send the watch event. +func (p *NativePlayer) OpenAndAwait(clientId string, loadingState string) { + p.sendPlayerEventTo(clientId, string(ServerEventOpenAndAwait), loadingState) +} + +// Watch sends the watch event to the client. +func (p *NativePlayer) Watch(clientId string, playbackInfo *PlaybackInfo) { + p.sendPlayerEventTo(clientId, string(ServerEventWatch), playbackInfo, true) +} + +// SubtitleEvent sends the subtitle event to the client. +func (p *NativePlayer) SubtitleEvent(clientId string, event *mkvparser.SubtitleEvent) { + p.sendPlayerEventTo(clientId, string(ServerEventSubtitleEvent), event, true) +} + +// SetTracks sends the set tracks event to the client. +func (p *NativePlayer) SetTracks(clientId string, tracks []*mkvparser.TrackInfo) { + p.sendPlayerEventTo(clientId, string(ServerEventSetTracks), tracks) +} + +// Pause sends the pause event to the client. +func (p *NativePlayer) Pause(clientId string) { + p.sendPlayerEventTo(clientId, string(ServerEventPause), nil) +} + +// Resume sends the resume event to the client. +func (p *NativePlayer) Resume(clientId string) { + p.sendPlayerEventTo(clientId, string(ServerEventResume), nil) +} + +// Seek sends the seek event to the client. +func (p *NativePlayer) Seek(clientId string, time float64) { + p.sendPlayerEventTo(clientId, string(ServerEventSeek), time) +} + +// Error stops the playback and displays an error message. +func (p *NativePlayer) Error(clientId string, err error) { + p.sendPlayerEventTo(clientId, string(ServerEventError), struct { + Error string `json:"error"` + }{ + Error: err.Error(), + }) +} + +// AddSubtitleTrack sends the subtitle track added event to the client. +func (p *NativePlayer) AddSubtitleTrack(clientId string, track *mkvparser.TrackInfo) { + p.sendPlayerEventTo(clientId, string(ServerEventAddSubtitleTrack), track) +} + +// Stop emits a VideoTerminatedEvent to all subscribers. +// It should only be called by a module. +func (p *NativePlayer) Stop() { + p.logger.Debug().Msg("nativeplayer: Stopping playback, notifying subscribers") + p.notifySubscribers(&VideoTerminatedEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: p.playbackStatus.ClientId}, + }) + p.sendPlayerEvent(string(ServerEventTerminate), nil) +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Client Events +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type ClientEvent string + +const ( + PlayerEventVideoPaused ClientEvent = "video-paused" + PlayerEventVideoResumed ClientEvent = "video-resumed" + PlayerEventVideoCompleted ClientEvent = "video-completed" + PlayerEventVideoEnded ClientEvent = "video-ended" + PlayerEventVideoSeeked ClientEvent = "video-seeked" + PlayerEventVideoError ClientEvent = "video-error" + PlayerEventVideoLoadedMetadata ClientEvent = "loaded-metadata" + PlayerEventSubtitleFileUploaded ClientEvent = "subtitle-file-uploaded" + PlayerEventVideoTerminated ClientEvent = "video-terminated" + PlayerEventVideoTimeUpdate ClientEvent = "video-time-update" +) + +type ( + // PlayerEvent is an event coming from the client player. + PlayerEvent struct { + ClientId string `json:"clientId"` + Type ClientEvent `json:"type"` + Payload interface{} `json:"payload"` + } + VideoEvent interface { + GetClientId() string + } + BaseVideoEvent struct { + ClientId string `json:"clientId"` + } + VideoPausedEvent struct { + BaseVideoEvent + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + } + VideoResumedEvent struct { + BaseVideoEvent + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + } + VideoEndedEvent struct { + BaseVideoEvent + AutoNext bool `json:"autoNext"` + } + VideoErrorEvent struct { + BaseVideoEvent + Error string `json:"error"` + } + VideoSeekedEvent struct { + BaseVideoEvent + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + } + VideoStatusEvent struct { + BaseVideoEvent + Status PlaybackStatus `json:"status"` + } + VideoLoadedMetadataEvent struct { + BaseVideoEvent + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + } + SubtitleFileUploadedEvent struct { + BaseVideoEvent + Filename string `json:"filename"` + Content string `json:"content"` + } + VideoTerminatedEvent struct { + BaseVideoEvent + } + VideoCompletedEvent struct { + BaseVideoEvent + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + } +) + +// Client event payloads +type ( + videoStartedPayload struct { + Url string `json:"url"` + Paused bool `json:"paused"` + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + } + videoPausedPayload struct { + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + } + videoResumedPayload struct { + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + } + videoLoadedMetadataPayload struct { + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + } + videoSeekedPayload struct { + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + } + subtitleFileUploadedPayload struct { + Filename string `json:"filename"` + Content string `json:"content"` + } + videoErrorPayload struct { + Error string `json:"error"` + } + videoEndedPayload struct { + AutoNext bool `json:"autoNext"` + } + videoTerminatedPayload struct { + } + videoTimeUpdatePayload struct { + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + Paused bool `json:"paused"` + } + videoCompletedPayload struct { + CurrentTime float64 `json:"currentTime"` + Duration float64 `json:"duration"` + } +) + +// listenToPlayerEvents listens to client events and notifies subscribers. +func (p *NativePlayer) listenToPlayerEvents() { + // Start a goroutine to listen to native player events + go func() { + for { + select { + // Listen to native player events from the client + case clientEvent := <-p.clientPlayerEventSubscriber.Channel: + playerEvent := &PlayerEvent{} + marshaled, _ := json.Marshal(clientEvent.Payload) + // Unmarshal the player event + if err := json.Unmarshal(marshaled, &playerEvent); err == nil { + // Handle events + switch playerEvent.Type { + + // case PlayerEventVideoStarted: + // p.setPlaybackStatus(func() { + // event := &videoStartedPayload{} + // if err := playerEvent.UnmarshalAs(&event); err != nil { + // p.notifySubscribers(&VideoStartedEvent{ + // BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + // }) + // } + // }) + case PlayerEventVideoPaused: + payload := &videoPausedPayload{} + if err := playerEvent.UnmarshalAs(&payload); err == nil { + p.setPlaybackStatus(func() { + p.playbackStatus.ClientId = playerEvent.ClientId + p.playbackStatus.Paused = true + p.playbackStatus.CurrentTime = payload.CurrentTime + p.playbackStatus.Duration = payload.Duration + }) + p.notifySubscribers(&VideoPausedEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + CurrentTime: payload.CurrentTime, + Duration: payload.Duration, + }) + p.notifySubscribers(&VideoStatusEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + Status: *p.playbackStatus, + }) + } + case PlayerEventVideoResumed: + payload := &videoResumedPayload{} + if err := playerEvent.UnmarshalAs(&payload); err == nil { + p.setPlaybackStatus(func() { + p.playbackStatus.ClientId = playerEvent.ClientId + p.playbackStatus.Paused = false + p.playbackStatus.CurrentTime = payload.CurrentTime + p.playbackStatus.Duration = payload.Duration + }) + p.notifySubscribers(&VideoResumedEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + CurrentTime: payload.CurrentTime, + Duration: payload.Duration, + }) + p.notifySubscribers(&VideoStatusEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + Status: *p.playbackStatus, + }) + } + case PlayerEventVideoCompleted: + payload := &videoCompletedPayload{} + if err := playerEvent.UnmarshalAs(&payload); err == nil { + p.setPlaybackStatus(func() { + p.playbackStatus.ClientId = playerEvent.ClientId + p.playbackStatus.CurrentTime = payload.CurrentTime + p.playbackStatus.Duration = payload.Duration + }) + p.notifySubscribers(&VideoCompletedEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + CurrentTime: payload.CurrentTime, + Duration: payload.Duration, + }) + } + case PlayerEventVideoEnded: + payload := &videoEndedPayload{} + if err := playerEvent.UnmarshalAs(&payload); err == nil { + p.setPlaybackStatus(func() { + p.playbackStatus.ClientId = playerEvent.ClientId + }) + p.notifySubscribers(&VideoEndedEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + AutoNext: payload.AutoNext, + }) + } + case PlayerEventVideoError: + payload := &videoErrorPayload{} + if err := playerEvent.UnmarshalAs(&payload); err == nil { + p.setPlaybackStatus(func() { + p.playbackStatus.ClientId = playerEvent.ClientId + }) + p.notifySubscribers(&VideoErrorEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + Error: payload.Error, + }) + } + case PlayerEventVideoSeeked: + payload := &videoSeekedPayload{} + if err := playerEvent.UnmarshalAs(&payload); err == nil { + p.setPlaybackStatus(func() { + p.playbackStatus.ClientId = playerEvent.ClientId + }) + if p.seekedEventCancelFunc != nil { + p.seekedEventCancelFunc() + } + var ctx context.Context + ctx, p.seekedEventCancelFunc = context.WithCancel(context.Background()) + // Debounce the event + go func() { + defer func() { + if r := recover(); r != nil { + } + if p.seekedEventCancelFunc != nil { + p.seekedEventCancelFunc() + p.seekedEventCancelFunc = nil + } + }() + select { + case <-ctx.Done(): + case <-time.After(time.Millisecond * 150): + p.setPlaybackStatus(func() { + p.playbackStatus.CurrentTime = payload.CurrentTime + p.playbackStatus.Duration = payload.Duration + }) + p.notifySubscribers(&VideoSeekedEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + CurrentTime: payload.CurrentTime, + Duration: payload.Duration, + }) + return + } + }() + } else { + // Log error: util.Logger.Error().Err(err).Msg("nativeplayer: Failed to unmarshal video seeked payload") + } + case PlayerEventVideoLoadedMetadata: + payload := &videoLoadedMetadataPayload{} + if err := playerEvent.UnmarshalAs(&payload); err == nil { + p.setPlaybackStatus(func() { + p.playbackStatus.ClientId = playerEvent.ClientId + p.playbackStatus.CurrentTime = payload.CurrentTime + p.playbackStatus.Duration = payload.Duration + }) + p.notifySubscribers(&VideoLoadedMetadataEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + CurrentTime: payload.CurrentTime, + Duration: payload.Duration, + }) + } + case PlayerEventSubtitleFileUploaded: + payload := &subtitleFileUploadedPayload{} + if err := playerEvent.UnmarshalAs(&payload); err == nil { + p.setPlaybackStatus(func() { + p.playbackStatus.ClientId = playerEvent.ClientId + }) + p.notifySubscribers(&SubtitleFileUploadedEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + Filename: payload.Filename, + Content: payload.Content, + }) + } + case PlayerEventVideoTerminated: + p.setPlaybackStatus(func() { + p.playbackStatus.ClientId = playerEvent.ClientId + }) + p.notifySubscribers(&VideoTerminatedEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + }) + case PlayerEventVideoTimeUpdate: + payload := &videoTimeUpdatePayload{} + if err := playerEvent.UnmarshalAs(&payload); err == nil { + p.setPlaybackStatus(func() { + p.playbackStatus.ClientId = playerEvent.ClientId + p.playbackStatus.CurrentTime = payload.CurrentTime + p.playbackStatus.Duration = payload.Duration + p.playbackStatus.Paused = payload.Paused + }) + p.notifySubscribers(&VideoStatusEvent{ + BaseVideoEvent: BaseVideoEvent{ClientId: playerEvent.ClientId}, + Status: *p.playbackStatus, + }) + } + } + } + } + } + }() +} + +// Events returns the event channel for the subscriber. +func (s *Subscriber) Events() <-chan VideoEvent { + return s.eventCh +} + +func (e *PlayerEvent) UnmarshalAs(dest interface{}) error { + marshaled, _ := json.Marshal(e.Payload) + return json.Unmarshal(marshaled, dest) +} + +func (e *BaseVideoEvent) GetClientId() string { + return e.ClientId +} diff --git a/seanime-2.9.10/internal/nativeplayer/nativeplayer.go b/seanime-2.9.10/internal/nativeplayer/nativeplayer.go new file mode 100644 index 0000000..6d989f2 --- /dev/null +++ b/seanime-2.9.10/internal/nativeplayer/nativeplayer.go @@ -0,0 +1,167 @@ +package nativeplayer + +import ( + "context" + "seanime/internal/api/anilist" + "seanime/internal/events" + "seanime/internal/library/anime" + "seanime/internal/mkvparser" + "seanime/internal/util/result" + "sync" + + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +type StreamType string + +const ( + StreamTypeTorrent StreamType = "torrent" + StreamTypeFile StreamType = "localfile" + StreamTypeDebrid StreamType = "debrid" +) + +type ( + PlaybackInfo struct { + ID string `json:"id"` + StreamType StreamType `json:"streamType"` + MimeType string `json:"mimeType"` // e.g. "video/mp4", "video/webm" + StreamUrl string `json:"streamUrl"` // URL of the stream + ContentLength int64 `json:"contentLength"` // Size of the stream in bytes + MkvMetadata *mkvparser.Metadata `json:"mkvMetadata,omitempty"` // nil if not ebml + EntryListData *anime.EntryListData `json:"entryListData,omitempty"` // nil if not in list + Episode *anime.Episode `json:"episode"` + Media *anilist.BaseAnime `json:"media"` + + MkvMetadataParser mo.Option[*mkvparser.MetadataParser] `json:"-"` + } +) + +type ( + // NativePlayer is the built-in HTML5 video player in Seanime. + // There can only be one instance of this player at a time. + NativePlayer struct { + wsEventManager events.WSEventManagerInterface + clientPlayerEventSubscriber *events.ClientEventSubscriber + + playbackStatusMu sync.RWMutex + playbackStatus *PlaybackStatus + + seekedEventCancelFunc context.CancelFunc + + subscribers *result.Map[string, *Subscriber] + + logger *zerolog.Logger + } + + PlaybackStatus struct { + ClientId string + Url string + Paused bool + CurrentTime float64 + Duration float64 + } + + // Subscriber listens to the player events + Subscriber struct { + eventCh chan VideoEvent + } + + NewNativePlayerOptions struct { + WsEventManager events.WSEventManagerInterface + Logger *zerolog.Logger + } +) + +// New returns a new instance of NativePlayer. +func New(options NewNativePlayerOptions) *NativePlayer { + np := &NativePlayer{ + playbackStatus: &PlaybackStatus{}, + wsEventManager: options.WsEventManager, + clientPlayerEventSubscriber: options.WsEventManager.SubscribeToClientNativePlayerEvents("nativeplayer"), + subscribers: result.NewResultMap[string, *Subscriber](), + logger: options.Logger, + } + + np.listenToPlayerEvents() + + return np +} + +// sendPlayerEventTo sends an event of type events.NativePlayerEventType to the client. +func (p *NativePlayer) sendPlayerEventTo(clientId string, t string, payload interface{}, noLog ...bool) { + p.wsEventManager.SendEventTo(clientId, string(events.NativePlayerEventType), struct { + Type string `json:"type"` + Payload interface{} `json:"payload"` + }{ + Type: t, + Payload: payload, + }, noLog...) +} + +func (p *NativePlayer) sendPlayerEvent(t string, payload interface{}) { + p.wsEventManager.SendEvent(string(events.NativePlayerEventType), struct { + Type string `json:"type"` + Payload interface{} `json:"payload"` + }{ + Type: t, + Payload: payload, + }) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Subscribe lets other modules subscribe to the native player events +func (p *NativePlayer) Subscribe(id string) *Subscriber { + subscriber := &Subscriber{ + eventCh: make(chan VideoEvent, 10), + } + p.subscribers.Set(id, subscriber) + + return subscriber +} + +// Unsubscribe removes a subscriber from the player. +func (p *NativePlayer) Unsubscribe(id string) { + p.subscribers.Delete(id) +} + +func (p *NativePlayer) notifySubscribers(event VideoEvent) { + p.subscribers.Range(func(id string, subscriber *Subscriber) bool { + select { + case subscriber.eventCh <- event: + default: + // If the channel is full, skip sending the event + } + return true + }) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// GetPlaybackStatus returns the current playback status of the player. +func (p *NativePlayer) GetPlaybackStatus() *PlaybackStatus { + p.playbackStatusMu.RLock() + defer p.playbackStatusMu.RUnlock() + return p.playbackStatus +} + +func (p *NativePlayer) SetPlaybackStatus(status *PlaybackStatus) { + p.setPlaybackStatus(func() { + p.playbackStatus = status + }) +} + +// setPlaybackStatus sets the current playback status of the player +// and notifies all subscribers of the change. +func (p *NativePlayer) setPlaybackStatus(do func()) { + p.playbackStatusMu.Lock() + defer p.playbackStatusMu.Unlock() + do() + p.notifySubscribers(&VideoStatusEvent{ + BaseVideoEvent: BaseVideoEvent{ + ClientId: p.playbackStatus.ClientId, + }, + Status: *p.playbackStatus, + }) +} diff --git a/seanime-2.9.10/internal/notifier/beeep_test.go b/seanime-2.9.10/internal/notifier/beeep_test.go new file mode 100644 index 0000000..1dc5ec3 --- /dev/null +++ b/seanime-2.9.10/internal/notifier/beeep_test.go @@ -0,0 +1,18 @@ +package notifier + +import ( + "github.com/gen2brain/beeep" + "github.com/stretchr/testify/require" + "path/filepath" + "seanime/internal/test_utils" + "testing" +) + +func TestBeeep(t *testing.T) { + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t) + + err := beeep.Notify("Seanime", "Downloaded 1 episode", filepath.Join(test_utils.ConfigData.Path.DataDir, "logo.png")) + require.NoError(t, err) + +} diff --git a/seanime-2.9.10/internal/notifier/gotoast_test.go b/seanime-2.9.10/internal/notifier/gotoast_test.go new file mode 100644 index 0000000..7dfb9b8 --- /dev/null +++ b/seanime-2.9.10/internal/notifier/gotoast_test.go @@ -0,0 +1,27 @@ +//go:build windows + +package notifier + +import ( + "github.com/go-toast/toast" + "path/filepath" + "seanime/internal/test_utils" + "testing" +) + +func TestGoToast(t *testing.T) { + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t) + + notification := toast.Notification{ + AppID: "Seanime", + Title: "Seanime", + Icon: filepath.Join(test_utils.ConfigData.Path.DataDir, "logo.png"), + Message: "Auto Downloader has downloaded 1 episode", + } + err := notification.Push() + if err != nil { + t.Fatal(err) + } + +} diff --git a/seanime-2.9.10/internal/notifier/notifier.go b/seanime-2.9.10/internal/notifier/notifier.go new file mode 100644 index 0000000..0bb0ce4 --- /dev/null +++ b/seanime-2.9.10/internal/notifier/notifier.go @@ -0,0 +1,74 @@ +package notifier + +import ( + "github.com/rs/zerolog" + "github.com/samber/mo" + "path/filepath" + "seanime/internal/database/models" + "sync" +) + +type ( + Notifier struct { + dataDir mo.Option[string] + settings mo.Option[*models.NotificationSettings] + mu sync.Mutex + logoPath string + logger mo.Option[*zerolog.Logger] + } + + Notification string +) + +const ( + AutoDownloader Notification = "Auto Downloader" + AutoScanner Notification = "Auto Scanner" + Debrid Notification = "Debrid" +) + +var GlobalNotifier = NewNotifier() + +func init() { + GlobalNotifier = NewNotifier() +} + +func NewNotifier() *Notifier { + return &Notifier{ + dataDir: mo.None[string](), + settings: mo.None[*models.NotificationSettings](), + mu: sync.Mutex{}, + logger: mo.None[*zerolog.Logger](), + } +} + +func (n *Notifier) SetSettings(datadir string, settings *models.NotificationSettings, logger *zerolog.Logger) { + if datadir == "" || settings == nil { + return + } + + n.mu.Lock() + n.dataDir = mo.Some(datadir) + n.settings = mo.Some(settings) + n.logoPath = filepath.Join(datadir, "logo.png") + n.logger = mo.Some(logger) + n.mu.Unlock() +} + +func (n *Notifier) canProceed(id Notification) bool { + if !n.dataDir.IsPresent() || !n.settings.IsPresent() { + return false + } + + if n.settings.MustGet().DisableNotifications { + return false + } + + switch id { + case AutoDownloader: + return !n.settings.MustGet().DisableAutoDownloaderNotifications + case AutoScanner: + return !n.settings.MustGet().DisableAutoScannerNotifications + } + + return false +} diff --git a/seanime-2.9.10/internal/notifier/notifier_test.go b/seanime-2.9.10/internal/notifier/notifier_test.go new file mode 100644 index 0000000..4b779e6 --- /dev/null +++ b/seanime-2.9.10/internal/notifier/notifier_test.go @@ -0,0 +1,32 @@ +package notifier + +import ( + "fmt" + "seanime/internal/database/models" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + "time" +) + +func TestNotifier(t *testing.T) { + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t) + + GlobalNotifier = NewNotifier() + + GlobalNotifier.SetSettings(test_utils.ConfigData.Path.DataDir, &models.NotificationSettings{}, util.NewLogger()) + + GlobalNotifier.Notify( + AutoDownloader, + fmt.Sprintf("%d %s %s been downloaded or added to the queue.", 1, util.Pluralize(1, "episode", "episodes"), util.Pluralize(1, "has", "have")), + ) + + GlobalNotifier.Notify( + AutoScanner, + fmt.Sprintf("%d %s %s been downloaded or added to the queue.", 1, util.Pluralize(1, "episode", "episodes"), util.Pluralize(1, "has", "have")), + ) + + time.Sleep(1 * time.Second) + +} diff --git a/seanime-2.9.10/internal/notifier/notify_unix.go b/seanime-2.9.10/internal/notifier/notify_unix.go new file mode 100644 index 0000000..0769d53 --- /dev/null +++ b/seanime-2.9.10/internal/notifier/notify_unix.go @@ -0,0 +1,39 @@ +//go:build !windows + +package notifier + +import ( + "fmt" + "github.com/gen2brain/beeep" + "seanime/internal/util" +) + +// Notify sends a notification to the user. +// This is run in a goroutine. +func (n *Notifier) Notify(id Notification, message string) { + go func() { + defer util.HandlePanicThen(func() {}) + + n.mu.Lock() + defer n.mu.Unlock() + + if !n.canProceed(id) { + return + } + + err := beeep.Notify( + fmt.Sprintf("Seanime: %s", id), + message, + n.logoPath, + ) + if err != nil { + if n.logger.IsPresent() { + n.logger.MustGet().Trace().Msgf("notifier: Failed to push notification: %v", err) + } + } + + if n.logger.IsPresent() { + n.logger.MustGet().Trace().Msgf("notifier: Pushed notification: %v", id) + } + }() +} diff --git a/seanime-2.9.10/internal/notifier/notify_windows.go b/seanime-2.9.10/internal/notifier/notify_windows.go new file mode 100644 index 0000000..1f1acaf --- /dev/null +++ b/seanime-2.9.10/internal/notifier/notify_windows.go @@ -0,0 +1,40 @@ +//go:build windows + +package notifier + +import ( + "github.com/go-toast/toast" + "seanime/internal/util" +) + +// Notify sends a notification to the user. +// This is run in a goroutine. +func (n *Notifier) Notify(id Notification, message string) { + go func() { + defer util.HandlePanicInModuleThen("notifier/Notify", func() {}) + + n.mu.Lock() + defer n.mu.Unlock() + + if !n.canProceed(id) { + return + } + + notification := toast.Notification{ + AppID: "Seanime", + Title: string(id), + Message: message, + Icon: n.logoPath, + } + + err := notification.Push() + if err != nil { + if n.logger.IsPresent() { + n.logger.MustGet().Trace().Msgf("notifier: Failed to push notification: %v", err) + } + } + if n.logger.IsPresent() { + n.logger.MustGet().Trace().Msgf("notifier: Pushed notification: %v", id) + } + }() +} diff --git a/seanime-2.9.10/internal/onlinestream/manual_mapping.go b/seanime-2.9.10/internal/onlinestream/manual_mapping.go new file mode 100644 index 0000000..a093f93 --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/manual_mapping.go @@ -0,0 +1,122 @@ +package onlinestream + +import ( + "errors" + "fmt" + "seanime/internal/extension" + "seanime/internal/util" + "seanime/internal/util/result" + "strings" + + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" +) + +var searchResultCache = result.NewCache[string, []*hibikeonlinestream.SearchResult]() + +func (r *Repository) ManualSearch(provider string, query string, dub bool) (ret []*hibikeonlinestream.SearchResult, err error) { + defer util.HandlePanicInModuleWithError("onlinestream/ManualSearch", &err) + + if query == "" { + return make([]*hibikeonlinestream.SearchResult, 0), nil + } + + // Get the search results + providerExtension, ok := extension.GetExtension[extension.OnlinestreamProviderExtension](r.providerExtensionBank, provider) + if !ok { + r.logger.Error().Str("provider", provider).Msg("onlinestream: Provider not found") + return nil, errors.New("onlinestream: Provider not found") + } + + normalizedQuery := strings.ToLower(strings.TrimSpace(query)) + + searchRes, found := searchResultCache.Get(provider + normalizedQuery + fmt.Sprintf("%t", dub)) + if found { + return searchRes, nil + } + + searchRes, err = providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{ + Query: normalizedQuery, + Dub: dub, + Year: 0, + }) + if err != nil { + r.logger.Error().Err(err).Str("query", normalizedQuery).Msg("onlinestream: Search failed") + return nil, err + } + + searchResultCache.Set(provider+normalizedQuery+fmt.Sprintf("%t", dub), searchRes) + + return searchRes, nil +} + +// ManualMapping is used to manually map an anime to a provider. +// After calling this, the client should re-fetch the episode list. +func (r *Repository) ManualMapping(provider string, mediaId int, animeId string) (err error) { + defer util.HandlePanicInModuleWithError("onlinestream/ManualMapping", &err) + + r.logger.Trace().Msgf("onlinestream: Removing cached bucket for %s, media ID: %d", provider, mediaId) + + // Delete the cached data if any + epListBucket := r.getFcEpisodeListBucket(provider, mediaId) + _ = r.fileCacher.Remove(epListBucket.Name()) + epDataBucket := r.getFcEpisodeDataBucket(provider, mediaId) + _ = r.fileCacher.Remove(epDataBucket.Name()) + + r.logger.Trace(). + Str("provider", provider). + Int("mediaId", mediaId). + Str("animeId", animeId). + Msg("onlinestream: Manual mapping") + + // Insert the mapping into the database + err = r.db.InsertOnlinestreamMapping(provider, mediaId, animeId) + if err != nil { + r.logger.Error().Err(err).Msg("onlinestream: Failed to insert mapping") + return err + } + + r.logger.Debug().Msg("onlinestream: Manual mapping successful") + + return nil +} + +type MappingResponse struct { + AnimeId *string `json:"animeId"` +} + +func (r *Repository) GetMapping(provider string, mediaId int) (ret MappingResponse) { + defer util.HandlePanicInModuleThen("onlinestream/GetMapping", func() { + ret = MappingResponse{} + }) + + mapping, found := r.db.GetOnlinestreamMapping(provider, mediaId) + if !found { + return MappingResponse{} + } + + return MappingResponse{ + AnimeId: &mapping.AnimeID, + } +} + +func (r *Repository) RemoveMapping(provider string, mediaId int) (err error) { + defer util.HandlePanicInModuleWithError("onlinestream/RemoveMapping", &err) + + // Delete the mapping from the database + err = r.db.DeleteOnlinestreamMapping(provider, mediaId) + if err != nil { + r.logger.Error().Err(err).Msg("onlinestream: Failed to delete mapping") + return err + } + + r.logger.Debug().Msg("onlinestream: Mapping removed") + + r.logger.Trace().Msgf("onlinestream: Removing cached bucket for %s, media ID: %d", provider, mediaId) + // Delete the cached data if any + epListBucket := r.getFcEpisodeListBucket(provider, mediaId) + _ = r.fileCacher.Remove(epListBucket.Name()) + epDataBucket := r.getFcEpisodeDataBucket(provider, mediaId) + _ = r.fileCacher.Remove(epDataBucket.Name()) + + return nil +} diff --git a/seanime-2.9.10/internal/onlinestream/providers/_animepahe.go b/seanime-2.9.10/internal/onlinestream/providers/_animepahe.go new file mode 100644 index 0000000..76a6cbf --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/providers/_animepahe.go @@ -0,0 +1,446 @@ +package onlinestream_providers + +import ( + "cmp" + "fmt" + "github.com/PuerkitoBio/goquery" + "github.com/goccy/go-json" + "github.com/gocolly/colly" + "github.com/rs/zerolog" + "net/http" + "net/url" + "regexp" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" + onlinestream_sources "seanime/internal/onlinestream/sources" + "seanime/internal/util" + "sort" + "strings" + "sync" +) + +type ( + Animepahe struct { + BaseURL string + Client http.Client + UserAgent string + logger *zerolog.Logger + } + AnimepaheSearchResult struct { + Data []struct { + ID int `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + Poster string `json:"poster"` + Type string `json:"type"` + Session string `json:"session"` + } `json:"data"` + } +) + +func NewAnimepahe(logger *zerolog.Logger) hibikeonlinestream.Provider { + return &Animepahe{ + BaseURL: "https://animepahe.ru", + Client: http.Client{}, + UserAgent: util.GetRandomUserAgent(), + logger: logger, + } +} + +func (g *Animepahe) GetSettings() hibikeonlinestream.Settings { + return hibikeonlinestream.Settings{ + EpisodeServers: []string{"animepahe"}, + SupportsDub: false, + } +} + +func (g *Animepahe) Search(opts hibikeonlinestream.SearchOptions) ([]*hibikeonlinestream.SearchResult, error) { + var results []*hibikeonlinestream.SearchResult + + query := opts.Query + dubbed := opts.Dub + + g.logger.Debug().Str("query", query).Bool("dubbed", dubbed).Msg("animepahe: Searching anime") + + q := url.QueryEscape(query) + request, err := http.NewRequest("GET", g.BaseURL+fmt.Sprintf("/api?m=search&q=%s", q), nil) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to create request") + return nil, err + } + + request.Header.Set("User-Agent", g.UserAgent) + request.Header.Set("Cookie", "__ddg1_=;__ddg2_=;") + + response, err := g.Client.Do(request) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to send request") + return nil, err + } + defer response.Body.Close() + + var searchResult AnimepaheSearchResult + err = json.NewDecoder(response.Body).Decode(&searchResult) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to decode response") + return nil, err + } + + for _, data := range searchResult.Data { + results = append(results, &hibikeonlinestream.SearchResult{ + ID: cmp.Or(fmt.Sprintf("%d", data.ID), data.Session), + Title: data.Title, + URL: fmt.Sprintf("%s/anime/%d", g.BaseURL, data.ID), + SubOrDub: hibikeonlinestream.Sub, + }) + } + + return results, nil +} + +func (g *Animepahe) FindEpisodes(id string) ([]*hibikeonlinestream.EpisodeDetails, error) { + var episodes []*hibikeonlinestream.EpisodeDetails + + q1 := fmt.Sprintf("/anime/%s", id) + if !strings.Contains(id, "-") { + q1 = fmt.Sprintf("/a/%s", id) + } + c := colly.NewCollector( + colly.UserAgent(g.UserAgent), + ) + + c.OnRequest(func(r *colly.Request) { + r.Headers.Set("Cookie", "__ddg1_=;__ddg2_=") + }) + + var tempId string + c.OnHTML("head > meta[property='og:url']", func(e *colly.HTMLElement) { + parts := strings.Split(e.Attr("content"), "/") + tempId = parts[len(parts)-1] + }) + + err := c.Visit(g.BaseURL + q1) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to fetch episodes") + return nil, err + } + + // { last_page: number; data: { id: number; episode: number; title: string; snapshot: string; filler: number; created_at?: string }[] } + type data struct { + LastPage int `json:"last_page"` + Data []struct { + ID int `json:"id"` + Episode int `json:"episode"` + Title string `json:"title"` + Snapshot string `json:"snapshot"` + Filler int `json:"filler"` + Session string `json:"session"` + CreatedAt string `json:"created_at"` + } `json:"data"` + } + + q2 := fmt.Sprintf("/api?m=release&id=%s&sort=episode_asc&page=1", tempId) + request, err := http.NewRequest("GET", g.BaseURL+q2, nil) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to create request") + return nil, err + } + + request.Header.Set("User-Agent", g.UserAgent) + request.Header.Set("Cookie", "__ddg1_=;__ddg2_=") + + response, err := g.Client.Do(request) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to send request") + return nil, err + } + defer response.Body.Close() + + var d data + err = json.NewDecoder(response.Body).Decode(&d) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to decode response") + return nil, err + } + + for _, e := range d.Data { + episodes = append(episodes, &hibikeonlinestream.EpisodeDetails{ + Provider: "animepahe", + ID: fmt.Sprintf("%d$%s", e.ID, id), + Number: e.Episode, + URL: fmt.Sprintf("%s/anime/%s/%d", g.BaseURL, id, e.Episode), + Title: cmp.Or(e.Title, "Episode "+fmt.Sprintf("%d", e.Episode)), + }) + } + + var pageNumbers []int + + for i := 2; i <= d.LastPage; i++ { + pageNumbers = append(pageNumbers, i) + } + + wg := sync.WaitGroup{} + wg.Add(len(pageNumbers)) + mu := sync.Mutex{} + + for _, p := range pageNumbers { + go func(p int) { + defer wg.Done() + q2 := fmt.Sprintf("/api?m=release&id=%s&sort=episode_asc&page=%d", tempId, p) + request, err := http.NewRequest("GET", g.BaseURL+q2, nil) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to create request") + return + } + + request.Header.Set("User-Agent", g.UserAgent) + request.Header.Set("Cookie", "__ddg1_=;__ddg2_=") + + response, err := g.Client.Do(request) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to send request") + return + } + defer response.Body.Close() + + var d data + err = json.NewDecoder(response.Body).Decode(&d) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to decode response") + return + } + + mu.Lock() + for _, e := range d.Data { + episodes = append(episodes, &hibikeonlinestream.EpisodeDetails{ + Provider: "animepahe", + ID: fmt.Sprintf("%d$%s", e.ID, id), + Number: e.Episode, + URL: fmt.Sprintf("%s/anime/%s/%d", g.BaseURL, id, e.Episode), + Title: cmp.Or(e.Title, "Episode "+fmt.Sprintf("%d", e.Episode)), + }) + } + mu.Unlock() + }(p) + } + + wg.Wait() + + g.logger.Debug().Int("count", len(episodes)).Msg("animepahe: Fetched episodes") + + sort.Slice(episodes, func(i, j int) bool { + return episodes[i].Number < episodes[j].Number + }) + + if len(episodes) == 0 { + return nil, fmt.Errorf("no episodes found") + } + + // Normalize episode numbers + offset := episodes[0].Number + 1 + for i, e := range episodes { + episodes[i].Number = e.Number - offset + } + + return episodes, nil +} + +func (g *Animepahe) FindEpisodeServer(episodeInfo *hibikeonlinestream.EpisodeDetails, server string) (*hibikeonlinestream.EpisodeServer, error) { + var source *hibikeonlinestream.EpisodeServer + + parts := strings.Split(episodeInfo.ID, "$") + if len(parts) < 2 { + return nil, fmt.Errorf("animepahe: Invalid episode ID") + } + + episodeID := parts[0] + animeID := parts[1] + + q1 := fmt.Sprintf("/anime/%s", animeID) + if !strings.Contains(animeID, "-") { + q1 = fmt.Sprintf("/a/%s", animeID) + } + c := colly.NewCollector( + colly.UserAgent(g.UserAgent), + ) + + var reqUrl *url.URL + + c.OnRequest(func(r *colly.Request) { + r.Headers.Set("Cookie", "__ddg1_=;__ddg2_=") + }) + + c.OnResponse(func(r *colly.Response) { + reqUrl = r.Request.URL + }) + + var tempId string + c.OnHTML("head > meta[property='og:url']", func(e *colly.HTMLElement) { + parts := strings.Split(e.Attr("content"), "/") + tempId = parts[len(parts)-1] + }) + + err := c.Visit(g.BaseURL + q1) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to fetch episodes") + return nil, err + } + + var sessionId string + // retain url without query + reqUrlStr := reqUrl.Path + reqUrlStrParts := strings.Split(reqUrlStr, "/anime/") + sessionId = reqUrlStrParts[len(reqUrlStrParts)-1] + + // { last_page: number; data: { id: number; episode: number; title: string; snapshot: string; filler: number; created_at?: string }[] } + type data struct { + LastPage int `json:"last_page"` + Data []struct { + ID int `json:"id"` + Session string `json:"session"` + } `json:"data"` + } + + q2 := fmt.Sprintf("/api?m=release&id=%s&sort=episode_asc&page=1", tempId) + request, err := http.NewRequest("GET", g.BaseURL+q2, nil) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to create request") + return nil, err + } + + request.Header.Set("User-Agent", g.UserAgent) + request.Header.Set("Cookie", "__ddg1_=;__ddg2_=") + + response, err := g.Client.Do(request) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to send request") + return nil, err + } + defer response.Body.Close() + + var d data + err = json.NewDecoder(response.Body).Decode(&d) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to decode response") + return nil, err + } + + episodeSession := "" + + for _, e := range d.Data { + if fmt.Sprintf("%d", e.ID) == episodeID { + episodeSession = e.Session + break + } + } + + var pageNumbers []int + + for i := 1; i <= d.LastPage; i++ { + pageNumbers = append(pageNumbers, i) + } + + if episodeSession == "" { + wg := sync.WaitGroup{} + wg.Add(len(pageNumbers)) + mu := sync.Mutex{} + + for _, p := range pageNumbers { + go func(p int) { + defer wg.Done() + q2 := fmt.Sprintf("/api?m=release&id=%s&sort=episode_asc&page=%d", tempId, p) + request, err := http.NewRequest("GET", g.BaseURL+q2, nil) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to create request") + return + } + + request.Header.Set("User-Agent", g.UserAgent) + request.Header.Set("Cookie", "__ddg1_=;__ddg2_=") + + response, err := g.Client.Do(request) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to send request") + return + } + defer response.Body.Close() + + var d data + err = json.NewDecoder(response.Body).Decode(&d) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to decode response") + return + } + + mu.Lock() + for _, e := range d.Data { + if fmt.Sprintf("%d", e.ID) == episodeID { + episodeSession = e.Session + break + } + } + mu.Unlock() + }(p) + } + + wg.Wait() + } + + if episodeSession == "" { + return nil, fmt.Errorf("animepahe: Episode not found") + } + + q3 := fmt.Sprintf("/play/%s/%s", sessionId, episodeSession) + request2, err := http.NewRequest("GET", g.BaseURL+q3, nil) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to create request") + return nil, err + } + + request2.Header.Set("User-Agent", g.UserAgent) + request2.Header.Set("Cookie", "__ddg1_=;__ddg2_=") + + response2, err := g.Client.Do(request2) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to send request") + return nil, err + } + defer response2.Body.Close() + + htmlString := "" + + doc, err := goquery.NewDocumentFromReader(response2.Body) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to parse response") + return nil, err + } + + htmlString = doc.Text() + + //const regex = /https:\/\/kwik\.si\/e\/\w+/g; + // const matches = watchReq.match(regex); + // + // if (matches === null) return undefined; + + re := regexp.MustCompile(`https:\/\/kwik\.si\/e\/\w+`) + matches := re.FindAllString(htmlString, -1) + if len(matches) == 0 { + return nil, fmt.Errorf("animepahe: Failed to find episode source") + } + + kwik := onlinestream_sources.NewKwik() + videoSources, err := kwik.Extract(matches[0]) + if err != nil { + g.logger.Error().Err(err).Msg("animepahe: Failed to extract video sources") + return nil, fmt.Errorf("animepahe: Failed to extract video sources, %w", err) + } + + source = &hibikeonlinestream.EpisodeServer{ + Provider: "animepahe", + Server: KwikServer, + Headers: map[string]string{"Referer": "https://kwik.si/"}, + VideoSources: videoSources, + } + + return source, nil + +} diff --git a/seanime-2.9.10/internal/onlinestream/providers/_animepahe_test.go b/seanime-2.9.10/internal/onlinestream/providers/_animepahe_test.go new file mode 100644 index 0000000..6eca970 --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/providers/_animepahe_test.go @@ -0,0 +1,158 @@ +package onlinestream_providers + +import ( + "errors" + "github.com/stretchr/testify/assert" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" + "seanime/internal/util" + "testing" +) + +func TestAnimepahe_Search(t *testing.T) { + + ap := NewAnimepahe(util.NewLogger()) + + tests := []struct { + name string + query string + dubbed bool + }{ + { + name: "One Piece", + query: "One Piece", + dubbed: false, + }, + { + name: "Blue Lock Season 2", + query: "Blue Lock Season 2", + dubbed: false, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + results, err := ap.Search(hibikeonlinestream.SearchOptions{ + Query: tt.query, + Dub: tt.dubbed, + }) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.NotEmpty(t, results) + + for _, r := range results { + assert.NotEmpty(t, r.ID, "ID is empty") + assert.NotEmpty(t, r.Title, "Title is empty") + assert.NotEmpty(t, r.URL, "URL is empty") + } + + util.Spew(results) + + }) + + } + +} + +func TestAnimepahe_FetchEpisodes(t *testing.T) { + + tests := []struct { + name string + id string + }{ + { + name: "One Piece", + id: "4", + }, + { + name: "Blue Lock Season 2", + id: "5648", + }, + } + + ap := NewAnimepahe(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + episodes, err := ap.FindEpisodes(tt.id) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.NotEmpty(t, episodes) + + for _, e := range episodes { + assert.NotEmpty(t, e.ID, "ID is empty") + assert.NotEmpty(t, e.Number, "Number is empty") + assert.NotEmpty(t, e.URL, "URL is empty") + } + + util.Spew(episodes) + + }) + + } + +} + +func TestAnimepahe_FetchSources(t *testing.T) { + + tests := []struct { + name string + episode *hibikeonlinestream.EpisodeDetails + server string + }{ + { + name: "One Piece", + episode: &hibikeonlinestream.EpisodeDetails{ + ID: "63391$4", + Number: 1115, + URL: "", + }, + server: KwikServer, + }, + { + name: "Blue Lock Season 2 - Episode 1", + episode: &hibikeonlinestream.EpisodeDetails{ + ID: "64056$5648", + Number: 1, + URL: "", + }, + server: KwikServer, + }, + } + ap := NewAnimepahe(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + sources, err := ap.FindEpisodeServer(tt.episode, tt.server) + if err != nil { + if !errors.Is(err, ErrSourceNotFound) { + t.Fatal(err) + } + } + + if err != nil { + t.Skip("Source not found") + } + + assert.NotEmpty(t, sources) + + for _, s := range sources.VideoSources { + assert.NotEmpty(t, s, "Source is empty") + } + + util.Spew(sources) + + }) + + } + +} diff --git a/seanime-2.9.10/internal/onlinestream/providers/animepahe.go b/seanime-2.9.10/internal/onlinestream/providers/animepahe.go new file mode 100644 index 0000000..26461dc --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/providers/animepahe.go @@ -0,0 +1,3 @@ +package onlinestream_providers + +var AnimepahePayload = "/// \n/// \n/// \n\ntype EpisodeData = {\n id: number; episode: number; title: string; snapshot: string; filler: number; session: string; created_at?: string\n}\n\ntype AnimeData = {\n id: number; title: string; type: string; year: number; poster: string; session: string\n}\n\nclass Provider {\n\n api = \"https://animepahe.ru\"\n headers = { Referer: \"https://kwik.si\" }\n\n getSettings(): Settings {\n return {\n episodeServers: [\"kwik\"],\n supportsDub: false,\n }\n }\n\n async search(opts: SearchOptions): Promise {\n const req = await fetch(`${this.api}/api?m=search&q=${encodeURIComponent(opts.query)}`, {\n headers: {\n Cookie: \"__ddg1_=;__ddg2_=;\",\n },\n })\n\n if (!req.ok) {\n return []\n }\n const data = (await req.json()) as { data: AnimeData[] }\n const results: SearchResult[] = []\n\n if (!data?.data) {\n return []\n }\n\n data.data.map((item: AnimeData) => {\n results.push({\n subOrDub: \"sub\",\n id: item.session,\n title: item.title,\n url: \"\",\n })\n })\n\n return results\n }\n\n async findEpisodes(id: string): Promise {\n let episodes: EpisodeDetails[] = []\n\n const req =\n await fetch(\n `${this.api}${id.includes(\"-\") ? `/anime/${id}` : `/a/${id}`}`,\n {\n headers: {\n Cookie: \"__ddg1_=;__ddg2_=;\",\n },\n },\n )\n\n const html = await req.text()\n\n\n function pushData(data: EpisodeData[]) {\n for (const item of data) {\n episodes.push({\n id: item.session + \"$\" + id,\n number: item.episode,\n title: item.title && item.title.length > 0 ? item.title : \"Episode \" + item.episode,\n url: req.url,\n })\n }\n }\n\n const $ = LoadDoc(html)\n\n const tempId = $(\"head > meta[property='og:url']\").attr(\"content\")!.split(\"/\").pop()!\n\n const { last_page, data } = (await (\n await fetch(`${this.api}/api?m=release&id=${tempId}&sort=episode_asc&page=1`, {\n headers: {\n Cookie: \"__ddg1_=;__ddg2_=;\",\n },\n })\n ).json()) as {\n last_page: number;\n data: EpisodeData[]\n }\n\n pushData(data)\n\n const pageNumbers = Array.from({ length: last_page - 1 }, (_, i) => i + 2)\n\n const promises = pageNumbers.map((pageNumber) =>\n fetch(`${this.api}/api?m=release&id=${tempId}&sort=episode_asc&page=${pageNumber}`, {\n headers: {\n Cookie: \"__ddg1_=;__ddg2_=;\",\n },\n }).then((res) => res.json()),\n )\n const results = (await Promise.all(promises)) as {\n data: EpisodeData[]\n }[]\n\n results.forEach((showData) => {\n for (const data of showData.data) {\n if (data) {\n pushData([data])\n }\n }\n });\n (data as any[]).sort((a, b) => a.number - b.number)\n\n if (episodes.length === 0) {\n throw new Error(\"No episodes found.\")\n }\n\n\n const lowest = episodes[0].number\n if (lowest > 1) {\n for (let i = 0; i < episodes.length; i++) {\n episodes[i].number = episodes[i].number - lowest + 1\n }\n }\n \n // Remove decimal episode numbers\n episodes = episodes.filter((episode) => Number.isInteger(episode.number))\n\n // for (let i = 0; i < episodes.length; i++) {\n // // If an episode number is a decimal, round it up to the nearest whole number\n // if (Number.isInteger(episodes[i].number)) {\n // continue\n // }\n // const original = episodes[i].number\n // episodes[i].number = Math.floor(episodes[i].number)\n // episodes[i].title = `Episode ${episodes[i].number} [{${original}}]`\n // }\n\n return episodes\n }\n\n async findEpisodeServer(episode: EpisodeDetails, _server: string): Promise {\n const episodeId = episode.id.split(\"$\")[0]\n const animeId = episode.id.split(\"$\")[1]\n\n console.log(`${this.api}/play/${animeId}/${episodeId}`)\n\n const req = await fetch(\n `${this.api}/play/${animeId}/${episodeId}`,\n {\n headers: {\n Cookie: \"__ddg1_=;__ddg2_=;\",\n },\n },\n )\n\n const html = await req.text()\n\n const regex = /https:\\/\\/kwik\\.si\\/e\\/\\w+/g\n const matches = html.match(regex)\n\n if (matches === null) {\n throw new Error(\"Failed to fetch episode server.\")\n }\n\n const $ = LoadDoc(html)\n\n const result: EpisodeServer = {\n videoSources: [],\n headers: this.headers ?? {},\n server: \"kwik\",\n }\n\n $(\"button[data-src]\").each(async (_, el) => {\n let videoSource: VideoSource = {\n url: \"\",\n type: \"m3u8\",\n quality: \"\",\n subtitles: [],\n }\n\n videoSource.url = el.data(\"src\")!\n if (!videoSource.url) {\n return\n }\n\n const fansub = el.data(\"fansub\")!\n const quality = el.data(\"resolution\")!\n\n videoSource.quality = `${quality}p - ${fansub}`\n\n if (el.data(\"audio\") === \"eng\") {\n videoSource.quality += \" (Eng)\"\n }\n\n if (videoSource.url === matches[0]) {\n videoSource.quality += \" (default)\"\n }\n\n result.videoSources.push(videoSource)\n })\n\n await Promise.all(result.videoSources.map(async (videoSource) => {\n try {\n const src_req = await fetch(videoSource.url, {\n headers: {\n Referer: this.headers.Referer,\n \"user-agent\":\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.56\",\n },\n })\n\n const src_html = await src_req.text()\n\n const scripts = src_html.match(/eval\\(f.+?\\}\\)\\)/g)\n if (!scripts) {\n return\n }\n\n for (const _script of scripts) {\n const scriptMatch = _script.match(/eval(.+)/)\n if (!scriptMatch || !scriptMatch[1]) {\n continue\n }\n\n try {\n const decoded = eval(scriptMatch[1])\n const link = decoded.match(/source='(.+?)'/)\n if (!link || !link[1]) {\n continue\n }\n\n videoSource.url = link[1]\n\n }\n catch (e) {\n console.error(\"Failed to extract kwik link\", e)\n }\n\n }\n\n }\n catch (e) {\n console.error(\"Failed to fetch kwik link\", e)\n }\n }))\n\n return result\n }\n}\n\n" diff --git a/seanime-2.9.10/internal/onlinestream/providers/common.go b/seanime-2.9.10/internal/onlinestream/providers/common.go new file mode 100644 index 0000000..6bfef2a --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/providers/common.go @@ -0,0 +1,25 @@ +package onlinestream_providers + +import "errors" + +// Built-in +const ( + GogoanimeProvider string = "gogoanime" + ZoroProvider string = "zoro" +) + +// Built-in +const ( + DefaultServer = "default" + GogocdnServer = "gogocdn" + VidstreamingServer = "vidstreaming" + StreamSBServer = "streamsb" + VidcloudServer = "vidcloud" + StreamtapeServer = "streamtape" + KwikServer = "kwik" +) + +var ( + ErrSourceNotFound = errors.New("video source not found") + ErrServerNotFound = errors.New("server not found") +) diff --git a/seanime-2.9.10/internal/onlinestream/providers/gogoanime.go b/seanime-2.9.10/internal/onlinestream/providers/gogoanime.go new file mode 100644 index 0000000..1198cab --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/providers/gogoanime.go @@ -0,0 +1,247 @@ +package onlinestream_providers + +import ( + "fmt" + "github.com/gocolly/colly" + "github.com/rs/zerolog" + "net/http" + "net/url" + "seanime/internal/onlinestream/sources" + "seanime/internal/util" + "strconv" + "strings" + + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" +) + +type Gogoanime struct { + BaseURL string + AjaxURL string + Client http.Client + UserAgent string + logger *zerolog.Logger +} + +func NewGogoanime(logger *zerolog.Logger) hibikeonlinestream.Provider { + return &Gogoanime{ + BaseURL: "https://anitaku.to", + AjaxURL: "https://ajax.gogocdn.net", + Client: http.Client{}, + UserAgent: util.GetRandomUserAgent(), + logger: logger, + } +} + +func (g *Gogoanime) GetSettings() hibikeonlinestream.Settings { + return hibikeonlinestream.Settings{ + EpisodeServers: []string{GogocdnServer, VidstreamingServer}, + SupportsDub: true, + } +} + +func (g *Gogoanime) Search(opts hibikeonlinestream.SearchOptions) ([]*hibikeonlinestream.SearchResult, error) { + var results []*hibikeonlinestream.SearchResult + + query := opts.Query + dubbed := opts.Dub + + g.logger.Debug().Str("query", query).Bool("dubbed", dubbed).Msg("gogoanime: Searching anime") + + c := colly.NewCollector( + colly.UserAgent(g.UserAgent), + ) + + c.OnHTML(".last_episodes > ul > li", func(e *colly.HTMLElement) { + id := "" + idParts := strings.Split(e.ChildAttr("p.name > a", "href"), "/") + if len(idParts) > 2 { + id = idParts[2] + } + title := e.ChildText("p.name > a") + url := g.BaseURL + e.ChildAttr("p.name > a", "href") + subOrDub := hibikeonlinestream.Sub + if strings.Contains(strings.ToLower(e.ChildText("p.name > a")), "dub") { + subOrDub = hibikeonlinestream.Dub + } + results = append(results, &hibikeonlinestream.SearchResult{ + ID: id, + Title: title, + URL: url, + SubOrDub: subOrDub, + }) + }) + + searchURL := g.BaseURL + "/search.html?keyword=" + url.QueryEscape(query) + if dubbed { + searchURL += "%20(Dub)" + } + + err := c.Visit(searchURL) + if err != nil { + return nil, err + } + + g.logger.Debug().Int("count", len(results)).Msg("gogoanime: Fetched anime") + + return results, nil +} + +func (g *Gogoanime) FindEpisodes(id string) ([]*hibikeonlinestream.EpisodeDetails, error) { + var episodes []*hibikeonlinestream.EpisodeDetails + + g.logger.Debug().Str("id", id).Msg("gogoanime: Fetching episodes") + + if !strings.Contains(id, "gogoanime") { + id = fmt.Sprintf("%s/category/%s", g.BaseURL, id) + } + + c := colly.NewCollector( + colly.UserAgent(g.UserAgent), + ) + + var epStart, epEnd, movieID, alias string + + c.OnHTML("#episode_page > li > a", func(e *colly.HTMLElement) { + if epStart == "" { + epStart = e.Attr("ep_start") + } + epEnd = e.Attr("ep_end") + }) + + c.OnHTML("#movie_id", func(e *colly.HTMLElement) { + movieID = e.Attr("value") + }) + + c.OnHTML("#alias", func(e *colly.HTMLElement) { + alias = e.Attr("value") + }) + + err := c.Visit(id) + if err != nil { + g.logger.Error().Err(err).Msg("gogoanime: Failed to fetch episodes") + return nil, err + } + + c2 := colly.NewCollector( + colly.UserAgent(g.UserAgent), + ) + + c2.OnHTML("#episode_related > li", func(e *colly.HTMLElement) { + episodeIDParts := strings.Split(e.ChildAttr("a", "href"), "/") + if len(episodeIDParts) < 2 { + return + } + episodeID := strings.TrimSpace(episodeIDParts[1]) + episodeNumberStr := strings.TrimPrefix(e.ChildText("div.name"), "EP ") + episodeNumber, err := strconv.Atoi(episodeNumberStr) + if err != nil { + g.logger.Error().Err(err).Str("episodeID", episodeID).Msg("failed to parse episode number") + return + } + episodes = append(episodes, &hibikeonlinestream.EpisodeDetails{ + Provider: GogoanimeProvider, + ID: episodeID, + Number: episodeNumber, + URL: g.BaseURL + "/" + episodeID, + }) + }) + + ajaxURL := fmt.Sprintf("%s/ajax/load-list-episode", g.AjaxURL) + ajaxParams := url.Values{ + "ep_start": {epStart}, + "ep_end": {epEnd}, + "id": {movieID}, + "alias": {alias}, + "default_ep": {"0"}, + } + ajaxURLWithParams := fmt.Sprintf("%s?%s", ajaxURL, ajaxParams.Encode()) + + err = c2.Visit(ajaxURLWithParams) + if err != nil { + g.logger.Error().Err(err).Msg("gogoanime: Failed to fetch episodes") + return nil, err + } + + g.logger.Debug().Int("count", len(episodes)).Msg("gogoanime: Fetched episodes") + + return episodes, nil +} + +func (g *Gogoanime) FindEpisodeServer(episodeInfo *hibikeonlinestream.EpisodeDetails, server string) (*hibikeonlinestream.EpisodeServer, error) { + var source *hibikeonlinestream.EpisodeServer + + if server == DefaultServer { + server = GogocdnServer + } + g.logger.Debug().Str("server", string(server)).Str("episodeID", episodeInfo.ID).Msg("gogoanime: Fetching server sources") + + c := colly.NewCollector() + + switch server { + case VidstreamingServer: + c.OnHTML(".anime_muti_link > ul > li.vidcdn > a", func(e *colly.HTMLElement) { + src := e.Attr("data-video") + gogocdn := onlinestream_sources.NewGogoCDN() + videoSources, err := gogocdn.Extract(src) + if err == nil { + source = &hibikeonlinestream.EpisodeServer{ + Provider: GogoanimeProvider, + Server: server, + Headers: map[string]string{ + "Referer": g.BaseURL + "/" + episodeInfo.ID, + }, + VideoSources: videoSources, + } + } + }) + case GogocdnServer, "": + c.OnHTML("#load_anime > div > div > iframe", func(e *colly.HTMLElement) { + src := e.Attr("src") + gogocdn := onlinestream_sources.NewGogoCDN() + videoSources, err := gogocdn.Extract(src) + if err == nil { + source = &hibikeonlinestream.EpisodeServer{ + Provider: GogoanimeProvider, + Server: server, + Headers: map[string]string{ + "Referer": g.BaseURL + "/" + episodeInfo.ID, + }, + VideoSources: videoSources, + } + } + }) + case StreamSBServer: + c.OnHTML(".anime_muti_link > ul > li.streamsb > a", func(e *colly.HTMLElement) { + src := e.Attr("data-video") + streamsb := onlinestream_sources.NewStreamSB() + videoSources, err := streamsb.Extract(src) + if err == nil { + source = &hibikeonlinestream.EpisodeServer{ + Provider: GogoanimeProvider, + Server: server, + Headers: map[string]string{ + "Referer": g.BaseURL + "/" + episodeInfo.ID, + "watchsb": "streamsb", + "User-Agent": g.UserAgent, + }, + VideoSources: videoSources, + } + } + }) + } + + err := c.Visit(g.BaseURL + "/" + episodeInfo.ID) + if err != nil { + return nil, err + } + + if source == nil { + g.logger.Warn().Str("server", server).Msg("gogoanime: No sources found") + return nil, ErrSourceNotFound + } + + g.logger.Debug().Str("server", server).Int("videoSources", len(source.VideoSources)).Msg("gogoanime: Fetched server sources") + + return source, nil + +} diff --git a/seanime-2.9.10/internal/onlinestream/providers/gogoanime_test.go b/seanime-2.9.10/internal/onlinestream/providers/gogoanime_test.go new file mode 100644 index 0000000..8122a39 --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/providers/gogoanime_test.go @@ -0,0 +1,172 @@ +package onlinestream_providers + +import ( + "errors" + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" + "seanime/internal/util" + "testing" +) + +func TestGogoanime_Search(t *testing.T) { + + gogo := NewGogoanime(util.NewLogger()) + + tests := []struct { + name string + query string + dubbed bool + }{ + { + name: "One Piece", + query: "One Piece", + dubbed: false, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + results, err := gogo.Search(hibikeonlinestream.SearchOptions{ + Query: tt.query, + Dub: tt.dubbed, + }) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.NotEmpty(t, results) + + for _, r := range results { + assert.NotEmpty(t, r.ID, "ID is empty") + assert.NotEmpty(t, r.Title, "Title is empty") + assert.NotEmpty(t, r.URL, "URL is empty") + } + + spew.Dump(results) + + }) + + } + +} + +func TestGogoanime_FetchEpisodes(t *testing.T) { + + tests := []struct { + name string + id string + }{ + { + name: "One Piece", + id: "one-piece", + }, + { + name: "One Piece (Dub)", + id: "one-piece-dub", + }, + } + + gogo := NewGogoanime(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + episodes, err := gogo.FindEpisodes(tt.id) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.NotEmpty(t, episodes) + + for _, e := range episodes { + assert.NotEmpty(t, e.ID, "ID is empty") + assert.NotEmpty(t, e.Number, "Number is empty") + assert.NotEmpty(t, e.URL, "URL is empty") + } + + spew.Dump(episodes) + + }) + + } + +} + +func TestGogoanime_FetchSources(t *testing.T) { + + tests := []struct { + name string + episode *hibikeonlinestream.EpisodeDetails + server string + }{ + { + name: "One Piece", + episode: &hibikeonlinestream.EpisodeDetails{ + ID: "one-piece-episode-1075", + Number: 1075, + URL: "https://anitaku.to/one-piece-episode-1075", + }, + server: VidstreamingServer, + }, + { + name: "One Piece", + episode: &hibikeonlinestream.EpisodeDetails{ + ID: "one-piece-episode-1075", + Number: 1075, + URL: "https://anitaku.to/one-piece-episode-1075", + }, + server: StreamSBServer, + }, + { + name: "One Piece", + episode: &hibikeonlinestream.EpisodeDetails{ + ID: "one-piece-episode-1075", + Number: 1075, + URL: "https://anitaku.to/one-piece-episode-1075", + }, + server: GogocdnServer, + }, + { + name: "Bocchi the Rock!", + episode: &hibikeonlinestream.EpisodeDetails{ + ID: "bocchi-the-rock-episode-1", + Number: 1075, + URL: "https://anitaku.to/bocchi-the-rock-episode-1", + }, + server: GogocdnServer, + }, + } + gogo := NewGogoanime(util.NewLogger()) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + sources, err := gogo.FindEpisodeServer(tt.episode, tt.server) + if err != nil { + if !errors.Is(err, ErrSourceNotFound) { + t.Fatal(err) + } + } + + if err != nil { + t.Skip("Source not found") + } + + assert.NotEmpty(t, sources) + + for _, s := range sources.VideoSources { + assert.NotEmpty(t, s, "Source is empty") + } + + spew.Dump(sources) + + }) + + } + +} diff --git a/seanime-2.9.10/internal/onlinestream/providers/zoro.go b/seanime-2.9.10/internal/onlinestream/providers/zoro.go new file mode 100644 index 0000000..1449f7b --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/providers/zoro.go @@ -0,0 +1,342 @@ +package onlinestream_providers + +import ( + "errors" + "fmt" + "github.com/PuerkitoBio/goquery" + "github.com/goccy/go-json" + "github.com/gocolly/colly" + "github.com/rs/zerolog" + "github.com/samber/lo" + "net/http" + "net/url" + "seanime/internal/onlinestream/sources" + "seanime/internal/util" + "strconv" + "strings" + + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" +) + +type Zoro struct { + BaseURL string + Client *http.Client + UserAgent string + logger *zerolog.Logger +} + +func NewZoro(logger *zerolog.Logger) hibikeonlinestream.Provider { + return &Zoro{ + BaseURL: "https://hianime.to", + UserAgent: util.GetRandomUserAgent(), + Client: &http.Client{}, + logger: logger, + } +} + +func (z *Zoro) GetSettings() hibikeonlinestream.Settings { + return hibikeonlinestream.Settings{ + EpisodeServers: []string{VidcloudServer, VidstreamingServer}, + SupportsDub: true, + } +} + +func (z *Zoro) Search(opts hibikeonlinestream.SearchOptions) ([]*hibikeonlinestream.SearchResult, error) { + var results []*hibikeonlinestream.SearchResult + + query := opts.Query + dubbed := opts.Dub + + z.logger.Debug().Str("query", query).Bool("dubbed", dubbed).Msg("zoro: Searching anime") + + c := colly.NewCollector() + + c.OnHTML(".flw-item", func(e *colly.HTMLElement) { + id := strings.Split(strings.Split(e.ChildAttr(".film-name a", "href"), "/")[1], "?")[0] + title := e.ChildText(".film-name a") + url := strings.Split(z.BaseURL+e.ChildAttr(".film-name a", "href"), "?")[0] + subOrDub := hibikeonlinestream.Sub + foundSub := false + foundDub := false + if e.ChildText(".tick-item.tick-dub") != "" { + foundDub = true + } + if e.ChildText(".tick-item.tick-sub") != "" { + foundSub = true + } + if foundSub && foundDub { + subOrDub = hibikeonlinestream.SubAndDub + } else if foundDub { + subOrDub = hibikeonlinestream.Dub + } + results = append(results, &hibikeonlinestream.SearchResult{ + ID: id, + Title: title, + URL: url, + SubOrDub: subOrDub, + }) + }) + + searchURL := z.BaseURL + "/search?keyword=" + url.QueryEscape(query) + + err := c.Visit(searchURL) + if err != nil { + return nil, err + } + + if dubbed { + results = lo.Filter(results, func(r *hibikeonlinestream.SearchResult, _ int) bool { + return r.SubOrDub == hibikeonlinestream.Dub || r.SubOrDub == hibikeonlinestream.SubAndDub + }) + } + + z.logger.Debug().Int("count", len(results)).Msg("zoro: Fetched anime") + + return results, nil +} + +func (z *Zoro) FindEpisodes(id string) ([]*hibikeonlinestream.EpisodeDetails, error) { + var episodes []*hibikeonlinestream.EpisodeDetails + + z.logger.Debug().Str("id", id).Msg("zoro: Fetching episodes") + + c := colly.NewCollector() + + subOrDub := hibikeonlinestream.Sub + + c.OnHTML("div.film-stats > div.tick", func(e *colly.HTMLElement) { + if e.ChildText(".tick-item.tick-dub") != "" { + subOrDub = hibikeonlinestream.Dub + } + if e.ChildText(".tick-item.tick-sub") != "" { + if subOrDub == hibikeonlinestream.Dub { + subOrDub = hibikeonlinestream.SubAndDub + } + } + }) + + watchUrl := fmt.Sprintf("%s/watch/%s", z.BaseURL, id) + err := c.Visit(watchUrl) + if err != nil { + z.logger.Error().Err(err).Msg("zoro: Failed to fetch episodes") + return nil, err + } + + // Get episodes + + splitId := strings.Split(id, "-") + idNum := splitId[len(splitId)-1] + ajaxUrl := fmt.Sprintf("%s/ajax/v2/episode/list/%s", z.BaseURL, idNum) + + c2 := colly.NewCollector( + colly.UserAgent(z.UserAgent), + ) + + c2.OnRequest(func(r *colly.Request) { + r.Headers.Set("X-Requested-With", "XMLHttpRequest") + r.Headers.Set("Referer", watchUrl) + }) + + c2.OnResponse(func(r *colly.Response) { + var jsonResponse map[string]interface{} + err = json.Unmarshal(r.Body, &jsonResponse) + if err != nil { + return + } + doc, err := goquery.NewDocumentFromReader(strings.NewReader(jsonResponse["html"].(string))) + if err != nil { + return + } + content := doc.Find(".detail-infor-content") + content.Find("a").Each(func(i int, s *goquery.Selection) { + id := s.AttrOr("href", "") + if id == "" { + return + } + hrefParts := strings.Split(s.AttrOr("href", ""), "/") + if len(hrefParts) < 2 { + return + } + if subOrDub == hibikeonlinestream.SubAndDub { + subOrDub = "both" + } + id = fmt.Sprintf("%s$%s", strings.Replace(hrefParts[2], "?ep=", "$episode$", 1), subOrDub) + epNumber, _ := strconv.Atoi(s.AttrOr("data-number", "")) + url := z.BaseURL + s.AttrOr("href", "") + title := s.AttrOr("title", "") + episodes = append(episodes, &hibikeonlinestream.EpisodeDetails{ + Provider: ZoroProvider, + ID: id, + Number: epNumber, + URL: url, + Title: title, + }) + }) + }) + + err = c2.Visit(ajaxUrl) + if err != nil { + z.logger.Error().Err(err).Msg("zoro: Failed to fetch episodes") + return nil, err + } + + z.logger.Debug().Int("count", len(episodes)).Msg("zoro: Fetched episodes") + + return episodes, nil +} + +func (z *Zoro) FindEpisodeServer(episodeInfo *hibikeonlinestream.EpisodeDetails, server string) (*hibikeonlinestream.EpisodeServer, error) { + var source *hibikeonlinestream.EpisodeServer + + if server == DefaultServer { + server = VidcloudServer + } + + z.logger.Debug().Str("server", server).Str("episodeID", episodeInfo.ID).Msg("zoro: Fetching server sources") + + episodeParts := strings.Split(episodeInfo.ID, "$") + + if len(episodeParts) < 3 { + return nil, errors.New("invalid episode id") + } + + episodeID := fmt.Sprintf("%s?ep=%s", episodeParts[0], episodeParts[2]) + subOrDub := hibikeonlinestream.Sub + if episodeParts[len(episodeParts)-1] == "dub" { + subOrDub = hibikeonlinestream.Dub + } + + // Get server + + var serverId string + + c := colly.NewCollector( + colly.UserAgent(z.UserAgent), + ) + c.OnRequest(func(r *colly.Request) { + r.Headers.Set("X-Requested-With", "XMLHttpRequest") + }) + + c.OnResponse(func(r *colly.Response) { + var jsonResponse map[string]interface{} + err := json.Unmarshal(r.Body, &jsonResponse) + if err != nil { + return + } + doc, err := goquery.NewDocumentFromReader(strings.NewReader(jsonResponse["html"].(string))) + if err != nil { + return + } + + switch server { + case VidcloudServer: + serverId = z.findServerId(doc, 4, subOrDub) + case VidstreamingServer: + serverId = z.findServerId(doc, 4, subOrDub) + case StreamSBServer: + serverId = z.findServerId(doc, 4, subOrDub) + case StreamtapeServer: + serverId = z.findServerId(doc, 4, subOrDub) + } + }) + + ajaxEpisodeUrl := fmt.Sprintf("%s/ajax/v2/episode/servers?episodeId=%s", z.BaseURL, strings.Split(episodeID, "?ep=")[1]) + if err := c.Visit(ajaxEpisodeUrl); err != nil { + return nil, err + } + + if serverId == "" { + return nil, ErrServerNotFound + } + + c2 := colly.NewCollector( + colly.UserAgent(z.UserAgent), + ) + c2.OnRequest(func(r *colly.Request) { + r.Headers.Set("X-Requested-With", "XMLHttpRequest") + }) + + c2.OnResponse(func(r *colly.Response) { + var jsonResponse map[string]interface{} + err := json.Unmarshal(r.Body, &jsonResponse) + if err != nil { + return + } + if _, ok := jsonResponse["link"].(string); !ok { + return + } + switch server { + case VidcloudServer, VidstreamingServer: + megacloud := onlinestream_sources.NewMegaCloud() + sources, err := megacloud.Extract(jsonResponse["link"].(string)) + if err != nil { + return + } + source = &hibikeonlinestream.EpisodeServer{ + Provider: ZoroProvider, + Server: server, + Headers: map[string]string{}, + VideoSources: sources, + } + case StreamtapeServer: + streamtape := onlinestream_sources.NewStreamtape() + sources, err := streamtape.Extract(jsonResponse["link"].(string)) + if err != nil { + return + } + source = &hibikeonlinestream.EpisodeServer{ + Provider: ZoroProvider, + Server: server, + Headers: map[string]string{ + "Referer": jsonResponse["link"].(string), + "User-Agent": z.UserAgent, + }, + VideoSources: sources, + } + case StreamSBServer: + streamsb := onlinestream_sources.NewStreamSB() + sources, err := streamsb.Extract(jsonResponse["link"].(string)) + if err != nil { + return + } + source = &hibikeonlinestream.EpisodeServer{ + Provider: ZoroProvider, + Server: server, + Headers: map[string]string{ + "Referer": jsonResponse["link"].(string), + "watchsb": "streamsb", + "User-Agent": z.UserAgent, + }, + VideoSources: sources, + } + } + }) + + // Get sources + serverSourceUrl := fmt.Sprintf("%s/ajax/v2/episode/sources?id=%s", z.BaseURL, serverId) + if err := c2.Visit(serverSourceUrl); err != nil { + return nil, err + } + + if source == nil { + z.logger.Warn().Str("server", server).Msg("zoro: No sources found") + return nil, ErrSourceNotFound + } + + z.logger.Debug().Str("server", server).Int("videoSources", len(source.VideoSources)).Msg("zoro: Fetched server sources") + + return source, nil +} + +func (z *Zoro) findServerId(doc *goquery.Document, idx int, subOrDub hibikeonlinestream.SubOrDub) string { + var serverId string + doc.Find(fmt.Sprintf("div.ps_-block.ps_-block-sub.servers-%s > div.ps__-list > div", subOrDub)).Each(func(i int, s *goquery.Selection) { + _serverId := s.AttrOr("data-server-id", "") + if serverId == "" { + if _serverId == strconv.Itoa(idx) { + serverId = s.AttrOr("data-id", "") + } + } + }) + return serverId +} diff --git a/seanime-2.9.10/internal/onlinestream/providers/zoro_test.go b/seanime-2.9.10/internal/onlinestream/providers/zoro_test.go new file mode 100644 index 0000000..61615f7 --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/providers/zoro_test.go @@ -0,0 +1,194 @@ +package onlinestream_providers + +import ( + "errors" + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" + "seanime/internal/util" + "testing" +) + +func TestZoro_Search(t *testing.T) { + + logger := util.NewLogger() + zoro := NewZoro(logger) + + tests := []struct { + name string + query string + dubbed bool + }{ + { + name: "One Piece", + query: "One Piece", + dubbed: false, + }, + { + name: "Dungeon Meshi", + query: "Dungeon Meshi", + dubbed: false, + }, + { + name: "Omoi, Omoware, Furi, Furare", + query: "Omoi, Omoware, Furi, Furare", + dubbed: false, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + results, err := zoro.Search(hibikeonlinestream.SearchOptions{ + Query: tt.query, + Dub: tt.dubbed, + }) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.NotEmpty(t, results) + + for _, r := range results { + assert.NotEmpty(t, r.ID, "ID is empty") + assert.NotEmpty(t, r.Title, "Title is empty") + assert.NotEmpty(t, r.URL, "URL is empty") + } + + spew.Dump(results) + + }) + + } + +} + +func TestZoro_FetchEpisodes(t *testing.T) { + logger := util.NewLogger() + + tests := []struct { + name string + id string + }{ + { + name: "One Piece", + id: "one-piece-100", + }, + { + name: "The Apothecary Diaries", + id: "the-apothecary-diaries-18578", + }, + } + + zoro := NewZoro(logger) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + episodes, err := zoro.FindEpisodes(tt.id) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.NotEmpty(t, episodes) + + for _, e := range episodes { + assert.NotEmpty(t, e.ID, "ID is empty") + assert.NotEmpty(t, e.Number, "Number is empty") + assert.NotEmpty(t, e.URL, "URL is empty") + } + + spew.Dump(episodes) + + }) + + } + +} + +func TestZoro_FetchSources(t *testing.T) { + logger := util.NewLogger() + + tests := []struct { + name string + episode *hibikeonlinestream.EpisodeDetails + server string + }{ + { + name: "One Piece", + episode: &hibikeonlinestream.EpisodeDetails{ + ID: "one-piece-100$episode$120118$both", + Number: 1095, + URL: "https://hianime.to/watch/one-piece-100?ep=120118", + }, + server: VidcloudServer, + }, + { + name: "One Piece", + episode: &hibikeonlinestream.EpisodeDetails{ + ID: "one-piece-100$episode$120118$both", + Number: 1095, + URL: "https://hianime.to/watch/one-piece-100?ep=120118", + }, + server: VidstreamingServer, + }, + { + name: "One Piece", + episode: &hibikeonlinestream.EpisodeDetails{ + ID: "one-piece-100$episode$120118$both", + Number: 1095, + URL: "https://hianime.to/watch/one-piece-100?ep=120118", + }, + server: StreamtapeServer, + }, + { + name: "One Piece", + episode: &hibikeonlinestream.EpisodeDetails{ + ID: "one-piece-100$episode$120118$both", + Number: 1095, + URL: "https://hianime.to/watch/one-piece-100?ep=120118", + }, + server: StreamSBServer, + }, + { + name: "Apothecary Diaries", + episode: &hibikeonlinestream.EpisodeDetails{ + ID: "the-apothecary-diaries-18578$episode$122954$sub", + Number: 24, + URL: "https://hianime.to/watch/the-apothecary-diaries-18578?ep=122954", + }, + server: StreamSBServer, + }, + } + zoro := NewZoro(logger) + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + serverSources, err := zoro.FindEpisodeServer(tt.episode, tt.server) + if err != nil { + if !errors.Is(err, ErrSourceNotFound) && !errors.Is(err, ErrServerNotFound) { + t.Fatal(err) + } + } + + if err != nil { + t.Skip(err.Error()) + } + + assert.NotEmpty(t, serverSources) + + for _, s := range serverSources.VideoSources { + assert.NotEmpty(t, s, "Source is empty") + } + + spew.Dump(serverSources) + + }) + + } + +} diff --git a/seanime-2.9.10/internal/onlinestream/repository.go b/seanime-2.9.10/internal/onlinestream/repository.go new file mode 100644 index 0000000..272be93 --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/repository.go @@ -0,0 +1,288 @@ +package onlinestream + +import ( + "context" + "errors" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/database/db" + "seanime/internal/extension" + "seanime/internal/library/anime" + "seanime/internal/platforms/platform" + "seanime/internal/util/filecache" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/samber/lo" +) + +type ( + Repository struct { + logger *zerolog.Logger + providerExtensionBank *extension.UnifiedBank + fileCacher *filecache.Cacher + metadataProvider metadata.Provider + platform platform.Platform + anilistBaseAnimeCache *anilist.BaseAnimeCache + db *db.Database + } +) + +var ( + ErrNoVideoSourceFound = errors.New("no video source found") +) + +type ( + Episode struct { + Number int `json:"number"` + Title string `json:"title,omitempty"` + Image string `json:"image,omitempty"` + Description string `json:"description,omitempty"` + IsFiller bool `json:"isFiller,omitempty"` + } + + EpisodeSource struct { + Number int `json:"number"` + VideoSources []*VideoSource `json:"videoSources"` + Subtitles []*Subtitle `json:"subtitles,omitempty"` + } + + VideoSource struct { + Server string `json:"server"` + Headers map[string]string `json:"headers,omitempty"` + URL string `json:"url"` + Quality string `json:"quality"` + } + + EpisodeListResponse struct { + Episodes []*Episode `json:"episodes"` + Media *anilist.BaseAnime `json:"media"` + } + + Subtitle struct { + URL string `json:"url"` + Language string `json:"language"` + } +) + +type ( + NewRepositoryOptions struct { + Logger *zerolog.Logger + FileCacher *filecache.Cacher + MetadataProvider metadata.Provider + Platform platform.Platform + Database *db.Database + } +) + +func NewRepository(opts *NewRepositoryOptions) *Repository { + return &Repository{ + logger: opts.Logger, + metadataProvider: opts.MetadataProvider, + fileCacher: opts.FileCacher, + providerExtensionBank: extension.NewUnifiedBank(), + anilistBaseAnimeCache: anilist.NewBaseAnimeCache(), + platform: opts.Platform, + db: opts.Database, + } +} + +func (r *Repository) InitExtensionBank(bank *extension.UnifiedBank) { + r.providerExtensionBank = bank + + r.logger.Debug().Msg("onlinestream: Initialized provider extension bank") +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// getFcEpisodeDataBucket returns a episode data bucket for the provider and mediaId. +// "Episode data" refers to the episodeData struct +// +// e.g., onlinestream_zoro_episode-data_123 +func (r *Repository) getFcEpisodeDataBucket(provider string, mediaId int) filecache.Bucket { + return filecache.NewBucket("onlinestream_"+provider+"_episode-data_"+strconv.Itoa(mediaId), time.Hour*24*2) +} + +// getFcEpisodeListBucket returns a episode data bucket for the provider and mediaId. +// "Episode list" refers to a slice of onlinestream_providers.EpisodeDetails +// +// e.g., onlinestream_zoro_episode-list_123 +func (r *Repository) getFcEpisodeListBucket(provider string, mediaId int) filecache.Bucket { + return filecache.NewBucket("onlinestream_"+provider+"_episode-data_"+strconv.Itoa(mediaId), time.Hour*24*1) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) getMedia(ctx context.Context, mId int) (*anilist.BaseAnime, error) { + media, err := r.anilistBaseAnimeCache.GetOrSet(mId, func() (*anilist.BaseAnime, error) { + media, err := r.platform.GetAnime(ctx, mId) + if err != nil { + return nil, err + } + return media, nil + }) + if err != nil { + return nil, err + } + return media, nil +} + +func (r *Repository) GetMedia(ctx context.Context, mId int) (*anilist.BaseAnime, error) { + return r.getMedia(ctx, mId) +} + +func (r *Repository) EmptyCache(mediaId int) error { + _ = r.fileCacher.RemoveAllBy(func(filename string) bool { + return strings.HasPrefix(filename, "onlinestream_") && strings.Contains(filename, strconv.Itoa(mediaId)) + }) + return nil +} + +func (r *Repository) GetMediaEpisodes(provider string, media *anilist.BaseAnime, dubbed bool) ([]*Episode, error) { + episodes := make([]*Episode, 0) + + if provider == "" { + return episodes, nil + } + + // +---------------------+ + // | Animap | + // +---------------------+ + + //animeMetadata, err := r.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mId) + // //foundAnimeMetadata := err == nil && animeMetadata != nil + //aw := r.metadataProvider.GetAnimeMetadataWrapper(media, animeMetadata) + + episodeCollection, err := anime.NewEpisodeCollection(anime.NewEpisodeCollectionOptions{ + AnimeMetadata: nil, + Media: media, + MetadataProvider: r.metadataProvider, + Logger: r.logger, + }) + foundEpisodeCollection := err == nil && episodeCollection != nil + + // +---------------------+ + // | Episode list | + // +---------------------+ + + // Fetch the episode list from the provider + // "from" and "to" are set to 0 in order not to fetch episode servers + ec, err := r.getEpisodeContainer(provider, media, 0, 0, dubbed, media.GetStartYearSafe()) + if err != nil { + return nil, err + } + + for _, episodeDetails := range ec.ProviderEpisodeList { + + // If the title contains "[{", it means it's an episode part (e.g. "Episode 6 [{6.5}]", the episode number should be 6) + if strings.Contains(episodeDetails.Title, "[{") { + ep := strings.Split(episodeDetails.Title, "[{")[1] + ep = strings.Split(ep, "}]")[0] + episodes = append(episodes, &Episode{ + Number: episodeDetails.Number, + Title: fmt.Sprintf("Episode %s", ep), + Image: media.GetBannerImageSafe(), + Description: "", + IsFiller: false, + }) + + } else { + + if foundEpisodeCollection { + episode, found := episodeCollection.FindEpisodeByNumber(episodeDetails.Number) + if found { + episodes = append(episodes, &Episode{ + Number: episodeDetails.Number, + Title: episode.EpisodeTitle, + Image: episode.EpisodeMetadata.Image, + Description: episode.EpisodeMetadata.Summary, + IsFiller: episode.EpisodeMetadata.IsFiller, + }) + } else { + episodes = append(episodes, &Episode{ + Number: episodeDetails.Number, + Title: episodeDetails.Title, + Image: media.GetCoverImageSafe(), + }) + } + } else { + episodes = append(episodes, &Episode{ + Number: episodeDetails.Number, + Title: episodeDetails.Title, + Image: media.GetCoverImageSafe(), + }) + } + + } + } + + episodes = lo.Filter(episodes, func(item *Episode, index int) bool { + return item != nil + }) + + return episodes, nil +} + +func (r *Repository) GetEpisodeSources(ctx context.Context, provider string, mId int, number int, dubbed bool, year int) (*EpisodeSource, error) { + + // +---------------------+ + // | Media | + // +---------------------+ + + media, err := r.getMedia(ctx, mId) + if err != nil { + return nil, err + } + + // +---------------------+ + // | Episode servers | + // +---------------------+ + + ec, err := r.getEpisodeContainer(provider, media, number, number, dubbed, year) + if err != nil { + return nil, err + } + + var sources *EpisodeSource + for _, ep := range ec.Episodes { + if ep.Number == number { + s := &EpisodeSource{ + Number: ep.Number, + VideoSources: make([]*VideoSource, 0), + } + for _, es := range ep.Servers { + + for _, vs := range es.VideoSources { + s.VideoSources = append(s.VideoSources, &VideoSource{ + Server: es.Server, + Headers: es.Headers, + URL: vs.URL, + Quality: vs.Quality, + }) + // Add subtitles if available + // Subtitles are stored in each video source, but they are the same, so only add them once. + if len(vs.Subtitles) > 0 && s.Subtitles == nil { + s.Subtitles = make([]*Subtitle, 0, len(vs.Subtitles)) + for _, sub := range vs.Subtitles { + s.Subtitles = append(s.Subtitles, &Subtitle{ + URL: sub.URL, + Language: sub.Language, + }) + } + } + } + } + sources = s + break + } + } + + if sources == nil { + return nil, ErrNoVideoSourceFound + } + + return sources, nil +} diff --git a/seanime-2.9.10/internal/onlinestream/repository_actions.go b/seanime-2.9.10/internal/onlinestream/repository_actions.go new file mode 100644 index 0000000..b22b028 --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/repository_actions.go @@ -0,0 +1,357 @@ +package onlinestream + +import ( + "errors" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/extension" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" + onlinestream_providers "seanime/internal/onlinestream/providers" + "seanime/internal/util/comparison" + "strings" +) + +var ( + ErrNoAnimeFound = errors.New("anime not found, try manual matching") + ErrNoEpisodes = errors.New("no episodes found") + errNoEpisodeSourceFound = errors.New("no source found for episode") +) + +type ( + // episodeContainer contains results of fetching the episodes from the provider. + episodeContainer struct { + Provider string + // List of episode details from the provider. + // It is used to get the episode servers. + ProviderEpisodeList []*hibikeonlinestream.EpisodeDetails + // List of episodes with their servers. + Episodes []*episodeData + } + + // episodeData contains some details about a provider episode and all available servers. + episodeData struct { + Provider string + ID string + Number int + Title string + Servers []*hibikeonlinestream.EpisodeServer + } +) + +// getEpisodeContainer gets the episode details and servers from the specified provider. +// It takes the media ID, titles in order to fetch the episode details. +// - This function can be used to only get the episode details by setting 'from' and 'to' to 0. +// +// Since the episode details are cached, we can request episode servers multiple times without fetching the episode details again. +func (r *Repository) getEpisodeContainer(provider string, media *anilist.BaseAnime, from int, to int, dubbed bool, year int) (*episodeContainer, error) { + + r.logger.Debug(). + Str("provider", provider). + Int("mediaId", media.ID). + Int("from", from). + Int("to", to). + Bool("dubbed", dubbed). + Msg("onlinestream: Getting episode container") + + // Key identifying the provider episode list in the file cache. + // It includes "dubbed" because Gogoanime has a different entry for dubbed anime. + // e.g. 1$provider$true + providerEpisodeListKey := fmt.Sprintf("%d$%s$%v", media.ID, provider, dubbed) + + // Create the episode container + ec := &episodeContainer{ + Provider: provider, + Episodes: make([]*episodeData, 0), + ProviderEpisodeList: make([]*hibikeonlinestream.EpisodeDetails, 0), + } + + // Get the episode details from the provider. + r.logger.Debug(). + Str("key", providerEpisodeListKey). + Msgf("onlinestream: Fetching %s episode list", provider) + + // Buckets for caching the episode list and episode data. + fcEpisodeListBucket := r.getFcEpisodeListBucket(provider, media.ID) + fcEpisodeDataBucket := r.getFcEpisodeDataBucket(provider, media.ID) + + // Check if the episode list is cached to avoid fetching it again. + var providerEpisodeList []*hibikeonlinestream.EpisodeDetails + if found, _ := r.fileCacher.Get(fcEpisodeListBucket, providerEpisodeListKey, &providerEpisodeList); !found { + var err error + providerEpisodeList, err = r.getProviderEpisodeList(provider, media, dubbed, year) + if err != nil { + r.logger.Error().Err(err).Msg("onlinestream: Failed to get provider episodes") + return nil, err // ErrNoAnimeFound or ErrNoEpisodes + } + _ = r.fileCacher.Set(fcEpisodeListBucket, providerEpisodeListKey, providerEpisodeList) + } else { + r.logger.Debug(). + Str("key", providerEpisodeListKey). + Msg("onlinestream: Cache HIT for episode list") + } + + ec.ProviderEpisodeList = providerEpisodeList + + var lastServerError error + + for _, episodeDetails := range providerEpisodeList { + + if episodeDetails.Number >= from && episodeDetails.Number <= to { + + // Check if the episode is cached to avoid fetching the sources again. + key := fmt.Sprintf("%d$%s$%d$%v", media.ID, provider, episodeDetails.Number, dubbed) + + r.logger.Debug(). + Str("key", key). + Msgf("onlinestream: Fetching episode '%d' servers", episodeDetails.Number) + + // Check episode cache + var cached *episodeData + if found, _ := r.fileCacher.Get(fcEpisodeDataBucket, key, &cached); found { + ec.Episodes = append(ec.Episodes, cached) + + r.logger.Debug(). + Str("key", key). + Msgf("onlinestream: Cache HIT for episode '%d' servers", episodeDetails.Number) + + continue + } + + // Zoro dubs + if provider == onlinestream_providers.ZoroProvider && dubbed { + // If the episode details have both sub and dub, we need to get the dub episode. + if !strings.HasSuffix(episodeDetails.ID, string(hibikeonlinestream.SubAndDub)) { + // Skip sub-only episodes + continue + } + // Replace "both" with "dub" so that [getProviderEpisodeServers] can find the dub episode. + episodeDetails.ID = strings.Replace(episodeDetails.ID, string(hibikeonlinestream.SubAndDub), string(hibikeonlinestream.Dub), 1) + } + + // Fetch episode servers + servers, err := r.getProviderEpisodeServers(provider, episodeDetails) + if err != nil { + lastServerError = err + r.logger.Error().Err(err).Msgf("onlinestream: failed to get episode '%d' servers", episodeDetails.Number) + continue + } + + episode := &episodeData{ + ID: episodeDetails.ID, + Number: episodeDetails.Number, + Title: episodeDetails.Title, + Servers: servers, + } + ec.Episodes = append(ec.Episodes, episode) + + r.logger.Debug(). + Str("key", key). + Msgf("onlinestream: Found %d servers for episode '%d'", len(servers), episodeDetails.Number) + + _ = r.fileCacher.Set(fcEpisodeDataBucket, key, episode) + + } + + } + + if from > 0 && to > 0 && len(ec.Episodes) == 0 { + r.logger.Error().Err(lastServerError).Msg("onlinestream: No episode servers found") + return nil, fmt.Errorf("no episode servers found, provider returned: '%w'", lastServerError) + } + + if len(ec.ProviderEpisodeList) == 0 { + r.logger.Error().Msg("onlinestream: No episodes found for this anime") + return nil, fmt.Errorf("no episodes found for this anime") + } + + return ec, nil +} + +// getProviderEpisodeServers gets all the available servers for the episode. +// It returns errNoEpisodeSourceFound if no sources are found. +// +// Example: +// +// episodeDetails, _ := getProviderEpisodeListFromTitles(provider, titles, dubbed) +// episodeServers, err := getProviderEpisodeServers(provider, episodeDetails[0]) +func (r *Repository) getProviderEpisodeServers(provider string, episodeDetails *hibikeonlinestream.EpisodeDetails) ([]*hibikeonlinestream.EpisodeServer, error) { + var providerServers []*hibikeonlinestream.EpisodeServer + + providerExtension, ok := extension.GetExtension[extension.OnlinestreamProviderExtension](r.providerExtensionBank, provider) + if !ok { + return nil, fmt.Errorf("provider extension '%s' not found", provider) + } + + for _, episodeServer := range providerExtension.GetProvider().GetSettings().EpisodeServers { + res, err := providerExtension.GetProvider().FindEpisodeServer(episodeDetails, episodeServer) + if err == nil { + // Add the server to the list for the episode + providerServers = append(providerServers, res) + } + } + + if len(providerServers) == 0 { + return nil, errNoEpisodeSourceFound + } + + return providerServers, nil +} + +// getProviderEpisodeList gets all the hibikeonlinestream.EpisodeDetails from the provider based on the anime's titles. +// It returns ErrNoAnimeFound if the anime is not found or ErrNoEpisodes if no episodes are found. +func (r *Repository) getProviderEpisodeList(provider string, media *anilist.BaseAnime, dubbed bool, year int) ([]*hibikeonlinestream.EpisodeDetails, error) { + var ret []*hibikeonlinestream.EpisodeDetails + // romajiTitle := strings.ReplaceAll(media.GetEnglishTitleSafe(), ":", "") + // englishTitle := strings.ReplaceAll(media.GetRomajiTitleSafe(), ":", "") + + romajiTitle := media.GetRomajiTitleSafe() + englishTitle := media.GetEnglishTitleSafe() + + providerExtension, ok := extension.GetExtension[extension.OnlinestreamProviderExtension](r.providerExtensionBank, provider) + if !ok { + return nil, fmt.Errorf("provider extension '%s' not found", provider) + } + + mId := media.ID + + var matchId string + + // +---------------------+ + // | Database | + // +---------------------+ + + // Search for the mapping in the database + mapping, found := r.db.GetOnlinestreamMapping(provider, mId) + if found { + r.logger.Debug().Str("animeId", mapping.AnimeID).Msg("onlinestream: Using manual mapping") + matchId = mapping.AnimeID + } + + if matchId == "" { + // +---------------------+ + // | Search | + // +---------------------+ + + // Get search results. + var searchResults []*hibikeonlinestream.SearchResult + + queryMedia := hibikeonlinestream.Media{ + ID: media.ID, + IDMal: media.GetIDMal(), + Status: string(*media.GetStatus()), + Format: string(*media.GetFormat()), + EnglishTitle: media.GetTitle().GetEnglish(), + RomajiTitle: media.GetRomajiTitleSafe(), + EpisodeCount: media.GetTotalEpisodeCount(), + Synonyms: media.GetSynonymsContainingSeason(), + IsAdult: *media.GetIsAdult(), + StartDate: &hibikeonlinestream.FuzzyDate{ + Year: *media.GetStartDate().GetYear(), + Month: media.GetStartDate().GetMonth(), + Day: media.GetStartDate().GetDay(), + }, + } + + added := make(map[string]struct{}) + + if romajiTitle != "" { + // Search by romaji title + res, err := providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{ + Media: queryMedia, + Query: romajiTitle, + Dub: dubbed, + Year: year, + }) + if err == nil && len(res) > 0 { + searchResults = append(searchResults, res...) + for _, r := range res { + added[r.ID] = struct{}{} + } + } + if err != nil { + r.logger.Error().Err(err).Msg("onlinestream: Failed to search for romaji title") + } + r.logger.Debug(). + Int("romajiTitleResults", len(res)). + Msg("onlinestream: Found results for romaji title") + } + + if englishTitle != "" { + // Search by english title + res, err := providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{ + Media: queryMedia, + Query: englishTitle, + Dub: dubbed, + Year: year, + }) + if err == nil && len(res) > 0 { + for _, r := range res { + if _, ok := added[r.ID]; !ok { + searchResults = append(searchResults, r) + } + } + } + if err != nil { + r.logger.Error().Err(err).Msg("onlinestream: Failed to search for english title") + } + r.logger.Debug(). + Int("englishTitleResults", len(res)). + Msg("onlinestream: Found results for english title") + } + + if len(searchResults) == 0 { + return nil, fmt.Errorf("automatic matching returned no results") + } + + bestResult, found := GetBestSearchResult(searchResults, media.GetAllTitles()) + if !found { + return nil, ErrNoAnimeFound + } + matchId = bestResult.ID + } + + // Fetch episodes. + ret, err := providerExtension.GetProvider().FindEpisodes(matchId) + if err != nil { + return nil, fmt.Errorf("provider returned an error: %w", err) + } + + if len(ret) == 0 { + return nil, fmt.Errorf("provider returned no episodes") + } + + return ret, nil +} + +func GetBestSearchResult(searchResults []*hibikeonlinestream.SearchResult, titles []*string) (*hibikeonlinestream.SearchResult, bool) { + // Filter results to get the best match. + compBestResults := make([]*comparison.LevenshteinResult, 0, len(searchResults)) + for _, r := range searchResults { + // Compare search result title with all titles. + compBestResult, found := comparison.FindBestMatchWithLevenshtein(&r.Title, titles) + if found { + compBestResults = append(compBestResults, compBestResult) + } + } + + if len(compBestResults) == 0 { + return nil, false + } + + compBestResult := compBestResults[0] + for _, r := range compBestResults { + if r.Distance < compBestResult.Distance { + compBestResult = r + } + } + + // Get most accurate search result. + var bestResult *hibikeonlinestream.SearchResult + for _, r := range searchResults { + if r.Title == *compBestResult.OriginalValue { + bestResult = r + break + } + } + return bestResult, true +} diff --git a/seanime-2.9.10/internal/onlinestream/repository_actions_test.go b/seanime-2.9.10/internal/onlinestream/repository_actions_test.go new file mode 100644 index 0000000..0b2d0ca --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/repository_actions_test.go @@ -0,0 +1,112 @@ +package onlinestream + +import ( + "context" + "path/filepath" + "seanime/internal/api/anilist" + onlinestream_providers "seanime/internal/onlinestream/providers" + "seanime/internal/test_utils" + "seanime/internal/util" + "seanime/internal/util/filecache" + "testing" +) + +func TestOnlineStream_GetEpisodes(t *testing.T) { + t.Skip("TODO: Fix this test by loading built-in extensions") + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t, test_utils.Anilist()) + + tempDir := t.TempDir() + + anilistClient := anilist.TestGetMockAnilistClient() + + //fileCacher, _ := filecache.NewCacher(filepath.Join(test_utils.ConfigData.Path.DataDir, "cache")) + fileCacher, _ := filecache.NewCacher(filepath.Join(tempDir, "cache")) + + os := NewRepository(&NewRepositoryOptions{ + Logger: util.NewLogger(), + FileCacher: fileCacher, + }) + + tests := []struct { + name string + mediaId int + from int + to int + provider string + dubbed bool + }{ + { + name: "Cowboy Bebop", + mediaId: 1, + from: 1, + to: 2, + provider: onlinestream_providers.GogoanimeProvider, + dubbed: false, + }, + { + name: "Cowboy Bebop", + mediaId: 1, + from: 1, + to: 2, + provider: onlinestream_providers.ZoroProvider, + dubbed: false, + }, + { + name: "One Piece", + mediaId: 21, + from: 1075, + to: 1076, + provider: onlinestream_providers.ZoroProvider, + dubbed: false, + }, + { + name: "Dungeon Meshi", + mediaId: 153518, + from: 1, + to: 1, + provider: onlinestream_providers.ZoroProvider, + dubbed: false, + }, + { + name: "Omoi, Omoware, Furi, Furare", + mediaId: 109125, + from: 1, + to: 1, + provider: onlinestream_providers.ZoroProvider, + dubbed: false, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + mediaF, err := anilistClient.BaseAnimeByID(context.Background(), &tt.mediaId) + if err != nil { + t.Fatalf("couldn't get media: %s", err) + } + media := mediaF.GetMedia() + + ec, err := os.getEpisodeContainer(tt.provider, media, tt.from, tt.to, tt.dubbed, 0) + if err != nil { + t.Fatalf("couldn't find episodes, %s", err) + } + + t.Logf("Provider: %s, found %d episodes for the anime", ec.Provider, len(ec.ProviderEpisodeList)) + // Episode Data + for _, ep := range ec.Episodes { + t.Logf("\t\tEpisode %d has %d servers", ep.Number, len(ep.Servers)) + for _, s := range ep.Servers { + t.Logf("\t\t\tServer: %s", s.Server) + for _, vs := range s.VideoSources { + t.Logf("\t\t\t\tVideo Source: %s, Type: %s", vs.Quality, vs.Type) + } + } + } + + }) + + } + +} diff --git a/seanime-2.9.10/internal/onlinestream/sources/common.go b/seanime-2.9.10/internal/onlinestream/sources/common.go new file mode 100644 index 0000000..198589f --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/sources/common.go @@ -0,0 +1,24 @@ +package onlinestream_sources + +import ( + "errors" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" +) + +var ( + ErrNoVideoSourceFound = errors.New("no episode source found") + ErrVideoSourceExtraction = errors.New("error while extracting video sources") +) + +type VideoExtractor interface { + Extract(uri string) ([]*hibikeonlinestream.VideoSource, error) +} + +const ( + QualityDefault = "default" + QualityAuto = "auto" + Quality360 = "360" + Quality480 = "480" + Quality720 = "720" + Quality1080 = "1080" +) diff --git a/seanime-2.9.10/internal/onlinestream/sources/gogocdn.go b/seanime-2.9.10/internal/onlinestream/sources/gogocdn.go new file mode 100644 index 0000000..a1d1100 --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/sources/gogocdn.go @@ -0,0 +1,277 @@ +package onlinestream_sources + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "github.com/gocolly/colly" + "io" + "net/http" + "net/url" + "regexp" + "seanime/internal/util" + "strings" + + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" +) + +type cdnKeys struct { + key []byte + secondKey []byte + iv []byte +} + +type GogoCDN struct { + client *http.Client + serverName string + keys cdnKeys + referrer string +} + +func NewGogoCDN() *GogoCDN { + return &GogoCDN{ + client: &http.Client{}, + serverName: "goload", + keys: cdnKeys{ + key: []byte("37911490979715163134003223491201"), + secondKey: []byte("54674138327930866480207815084989"), + iv: []byte("3134003223491201"), + }, + } +} + +// Extract fetches and extracts video sources from the provided URI. +func (g *GogoCDN) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) { + + defer util.HandlePanicInModuleThen("onlinestream/sources/gogocdn/Extract", func() { + err = ErrVideoSourceExtraction + }) + + // Instantiate a new collector + c := colly.NewCollector( + // Allow visiting the same page multiple times + colly.AllowURLRevisit(), + ) + ur, err := url.Parse(uri) + if err != nil { + return nil, err + } + + // Variables to hold extracted values + var scriptValue, id string + + id = ur.Query().Get("id") + + // Find and extract the script value and id + c.OnHTML("script[data-name='episode']", func(e *colly.HTMLElement) { + scriptValue = e.Attr("data-value") + + }) + + // Start scraping + err = c.Visit(uri) + if err != nil { + return nil, err + } + + // Check if scriptValue and id are found + if scriptValue == "" || id == "" { + return nil, errors.New("script value or id not found") + } + + // Extract video sources + ajaxUrl := fmt.Sprintf("%s://%s/encrypt-ajax.php?%s", ur.Scheme, ur.Host, g.generateEncryptedAjaxParams(id, scriptValue)) + + req, err := http.NewRequest("GET", ajaxUrl, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") + req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01") + + encryptedData, err := g.client.Do(req) + if err != nil { + return nil, err + } + + defer encryptedData.Body.Close() + + encryptedDataBytesRes, err := io.ReadAll(encryptedData.Body) + if err != nil { + return nil, err + } + + var encryptedDataBytes map[string]string + err = json.Unmarshal(encryptedDataBytesRes, &encryptedDataBytes) + if err != nil { + return nil, err + } + + data, err := g.decryptAjaxData(encryptedDataBytes["data"]) + + source, ok := data["source"].([]interface{}) + + // Check if source is found + if !ok { + return nil, ErrNoVideoSourceFound + } + + var results []*hibikeonlinestream.VideoSource + + urls := make([]string, 0) + for _, src := range source { + s := src.(map[string]interface{}) + urls = append(urls, s["file"].(string)) + } + + sourceBK, ok := data["source_bk"].([]interface{}) + if ok { + for _, src := range sourceBK { + s := src.(map[string]interface{}) + urls = append(urls, s["file"].(string)) + } + } + + for _, url := range urls { + + vs, ok := g.urlToVideoSource(url, source, sourceBK) + if ok { + results = append(results, vs...) + } + + } + + return results, nil +} + +func (g *GogoCDN) urlToVideoSource(url string, source []interface{}, sourceBK []interface{}) (vs []*hibikeonlinestream.VideoSource, ok bool) { + defer util.HandlePanicInModuleThen("onlinestream/sources/gogocdn/urlToVideoSource", func() { + ok = false + }) + ret := make([]*hibikeonlinestream.VideoSource, 0) + if strings.Contains(url, ".m3u8") { + resResult, err := http.Get(url) + if err != nil { + return nil, false + } + defer resResult.Body.Close() + + bodyBytes, err := io.ReadAll(resResult.Body) + if err != nil { + return nil, false + } + bodyString := string(bodyBytes) + + resolutions := regexp.MustCompile(`(RESOLUTION=)(.*)(\s*?)(\s.*)`).FindAllStringSubmatch(bodyString, -1) + baseURL := url[:strings.LastIndex(url, "/")] + + for _, res := range resolutions { + quality := strings.Split(strings.Split(res[2], "x")[1], ",")[0] + url := fmt.Sprintf("%s/%s", baseURL, strings.TrimSpace(res[4])) + ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceM3U8, Quality: quality + "p"}) + } + + ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceM3U8, Quality: "default"}) + } else { + for _, src := range source { + s := src.(map[string]interface{}) + if s["file"].(string) == url { + quality := strings.Split(s["label"].(string), " ")[0] + "p" + ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceMP4, Quality: quality}) + } + } + if sourceBK != nil { + for _, src := range sourceBK { + s := src.(map[string]interface{}) + if s["file"].(string) == url { + ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceMP4, Quality: "backup"}) + } + } + } + } + + return ret, true +} + +// generateEncryptedAjaxParams generates encrypted AJAX parameters. +func (g *GogoCDN) generateEncryptedAjaxParams(id, scriptValue string) string { + encryptedKey := g.encrypt(id, g.keys.iv, g.keys.key) + decryptedToken := g.decrypt(scriptValue, g.keys.iv, g.keys.key) + return fmt.Sprintf("id=%s&alias=%s", encryptedKey, decryptedToken) +} + +// encrypt encrypts the given text using AES CBC mode. +func (g *GogoCDN) encrypt(text string, iv []byte, key []byte) string { + block, _ := aes.NewCipher(key) + textBytes := []byte(text) + textBytes = pkcs7Padding(textBytes, aes.BlockSize) + cipherText := make([]byte, len(textBytes)) + + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(cipherText, textBytes) + + return base64.StdEncoding.EncodeToString(cipherText) +} + +// decrypt decrypts the given text using AES CBC mode. +func (g *GogoCDN) decrypt(text string, iv []byte, key []byte) string { + block, _ := aes.NewCipher(key) + cipherText, _ := base64.StdEncoding.DecodeString(text) + plainText := make([]byte, len(cipherText)) + + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(plainText, cipherText) + plainText = pkcs7Trimming(plainText) + + return string(plainText) +} + +func (g *GogoCDN) decryptAjaxData(encryptedData string) (map[string]interface{}, error) { + decodedData, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(g.keys.secondKey) + if err != nil { + return nil, err + } + + if len(decodedData) < aes.BlockSize { + return nil, fmt.Errorf("cipher text too short") + } + + iv := g.keys.iv + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(decodedData, decodedData) + + // Remove padding + decodedData = pkcs7Trimming(decodedData) + + var data map[string]interface{} + err = json.Unmarshal(decodedData, &data) + if err != nil { + return nil, err + } + + return data, nil +} + +// pkcs7Padding pads the text to be a multiple of blockSize using Pkcs7 padding. +func pkcs7Padding(text []byte, blockSize int) []byte { + padding := blockSize - len(text)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(text, padText...) +} + +// pkcs7Trimming removes Pkcs7 padding from the text. +func pkcs7Trimming(text []byte) []byte { + length := len(text) + unpadding := int(text[length-1]) + return text[:(length - unpadding)] +} diff --git a/seanime-2.9.10/internal/onlinestream/sources/gogocdn_test.go b/seanime-2.9.10/internal/onlinestream/sources/gogocdn_test.go new file mode 100644 index 0000000..d7875ff --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/sources/gogocdn_test.go @@ -0,0 +1,16 @@ +package onlinestream_sources + +import ( + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGogoCDN_Extract(t *testing.T) { + gogo := NewGogoCDN() + + ret, err := gogo.Extract("https://embtaku.pro/streaming.php?id=MjExNjU5&title=One+Piece+Episode+1075") + assert.NoError(t, err) + + spew.Dump(ret) +} diff --git a/seanime-2.9.10/internal/onlinestream/sources/megacloud.go b/seanime-2.9.10/internal/onlinestream/sources/megacloud.go new file mode 100644 index 0000000..fa7f691 --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/sources/megacloud.go @@ -0,0 +1,350 @@ +package onlinestream_sources + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "regexp" + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" + "seanime/internal/util" + "strconv" + "strings" +) + +type MegaCloud struct { + Script string + Sources string + UserAgent string +} + +func NewMegaCloud() *MegaCloud { + return &MegaCloud{ + Script: "https://megacloud.tv/js/player/a/prod/e1-player.min.js", + Sources: "https://megacloud.tv/embed-2/ajax/e-1/getSources?id=", + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3", + } +} + +func (m *MegaCloud) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) { + defer util.HandlePanicInModuleThen("onlinestream/sources/megacloud/Extract", func() { + err = ErrVideoSourceExtraction + }) + + videoIdParts := strings.Split(uri, "/") + videoId := videoIdParts[len(videoIdParts)-1] + videoId = strings.Split(videoId, "?")[0] + + client := &http.Client{} + req, err := http.NewRequest("GET", m.Sources+videoId, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "*/*") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("User-Agent", m.UserAgent) + req.Header.Set("Referer", uri) + + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var srcData map[string]interface{} + err = json.NewDecoder(res.Body).Decode(&srcData) + if err != nil { + return nil, err + } + + subtitles := make([]*hibikeonlinestream.VideoSubtitle, 0) + for idx, s := range srcData["tracks"].([]interface{}) { + sub := s.(map[string]interface{}) + label, ok := sub["label"].(string) + if ok { + subtitle := &hibikeonlinestream.VideoSubtitle{ + URL: sub["file"].(string), + ID: label, + Language: label, + IsDefault: idx == 0, + } + subtitles = append(subtitles, subtitle) + } + } + if encryptedString, ok := srcData["sources"]; ok { + + switch encryptedString.(type) { + case []interface{}: + if len(encryptedString.([]interface{})) == 0 { + return nil, ErrNoVideoSourceFound + } + videoSources := make([]*hibikeonlinestream.VideoSource, 0) + if e, ok := encryptedString.([]interface{})[0].(map[string]interface{}); ok { + file, ok := e["file"].(string) + if ok { + videoSources = append(videoSources, &hibikeonlinestream.VideoSource{ + URL: file, + Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(file, ".m3u8")], + Subtitles: subtitles, + Quality: QualityAuto, + }) + } + } + + if len(videoSources) == 0 { + return nil, ErrNoVideoSourceFound + } + + return videoSources, nil + + case []map[string]interface{}: + if srcData["encrypted"].(bool) && ok { + videoSources := make([]*hibikeonlinestream.VideoSource, 0) + for _, e := range encryptedString.([]map[string]interface{}) { + videoSources = append(videoSources, &hibikeonlinestream.VideoSource{ + URL: e["file"].(string), + Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(e["file"].(string), ".m3u8")], + Subtitles: subtitles, + Quality: QualityAuto, + }) + } + if len(videoSources) == 0 { + return nil, ErrNoVideoSourceFound + } + return videoSources, nil + } + case string: + res, err = client.Get(m.Script) + if err != nil { + return nil, err + } + defer res.Body.Close() + + text, err := io.ReadAll(res.Body) + if err != nil { + return nil, errors.New("couldn't fetch script to decrypt resource") + } + + values, err := m.extractVariables(string(text)) + if err != nil { + return nil, err + } + + secret, encryptedSource := m.getSecret(encryptedString.(string), values) + //if err != nil { + // return nil, err + //} + + decrypted, err := m.decrypt(encryptedSource, secret) + if err != nil { + return nil, err + } + + var decryptedData []map[string]interface{} + err = json.Unmarshal([]byte(decrypted), &decryptedData) + if err != nil { + return nil, err + } + + sources := make([]*hibikeonlinestream.VideoSource, 0) + for _, e := range decryptedData { + sources = append(sources, &hibikeonlinestream.VideoSource{ + URL: e["file"].(string), + Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(e["file"].(string), ".m3u8")], + Subtitles: subtitles, + Quality: QualityAuto, + }) + } + + if len(sources) == 0 { + return nil, ErrNoVideoSourceFound + } + + return sources, nil + } + + } + + return nil, ErrNoVideoSourceFound +} + +func (m *MegaCloud) extractVariables(text string) ([][]int, error) { + re := regexp.MustCompile(`case\s*0x[0-9a-f]+:\s*\w+\s*=\s*(\w+)\s*,\s*\w+\s*=\s*(\w+);`) + matches := re.FindAllStringSubmatch(text, -1) + + var vars [][]int + + for _, match := range matches { + if len(match) < 3 { + continue + } + + caseLine := match[0] + if strings.Contains(caseLine, "partKey") { + continue + } + + matchKey1, err1 := m.matchingKey(match[1], text) + matchKey2, err2 := m.matchingKey(match[2], text) + + if err1 != nil || err2 != nil { + continue + } + + key1, err1 := strconv.ParseInt(matchKey1, 16, 64) + key2, err2 := strconv.ParseInt(matchKey2, 16, 64) + + if err1 != nil || err2 != nil { + continue + } + + vars = append(vars, []int{int(key1), int(key2)}) + } + + return vars, nil +} + +func (m *MegaCloud) matchingKey(value, script string) (string, error) { + regexPattern := `,` + regexp.QuoteMeta(value) + `=((?:0x)?([0-9a-fA-F]+))` + re := regexp.MustCompile(regexPattern) + + match := re.FindStringSubmatch(script) + if len(match) > 1 { + return strings.TrimPrefix(match[1], "0x"), nil + } + + return "", errors.New("failed to match the key") +} + +func (m *MegaCloud) getSecret(encryptedString string, values [][]int) (string, string) { + secret := "" + encryptedSourceArray := strings.Split(encryptedString, "") + currentIndex := 0 + + for _, index := range values { + start := index[0] + currentIndex + end := start + index[1] + + for i := start; i < end; i++ { + secret += string(encryptedString[i]) + encryptedSourceArray[i] = "" + } + + currentIndex += index[1] + } + + encryptedSource := strings.Join(encryptedSourceArray, "") + + return secret, encryptedSource +} + +//func (m *MegaCloud) getSecret(encryptedString string, values []int) (string, string, error) { +// var secret string +// var encryptedSource = encryptedString +// var totalInc int +// +// for i := 0; i < values[0]; i++ { +// var start, inc int +// +// switch i { +// case 0: +// start = values[2] +// inc = values[1] +// case 1: +// start = values[4] +// inc = values[3] +// case 2: +// start = values[6] +// inc = values[5] +// case 3: +// start = values[8] +// inc = values[7] +// case 4: +// start = values[10] +// inc = values[9] +// case 5: +// start = values[12] +// inc = values[11] +// case 6: +// start = values[14] +// inc = values[13] +// case 7: +// start = values[16] +// inc = values[15] +// case 8: +// start = values[18] +// inc = values[17] +// default: +// return "", "", errors.New("invalid index") +// } +// +// from := start + totalInc +// to := from + inc +// +// secret += encryptedString[from:to] +// encryptedSource = strings.Replace(encryptedSource, encryptedString[from:to], "", 1) +// totalInc += inc +// } +// +// return secret, encryptedSource, nil +//} + +func (m *MegaCloud) decrypt(encrypted, keyOrSecret string) (string, error) { + cypher, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil { + return "", err + } + + salt := cypher[8:16] + password := append([]byte(keyOrSecret), salt...) + + md5Hashes := make([][]byte, 3) + digest := password + for i := 0; i < 3; i++ { + hash := md5.Sum(digest) + md5Hashes[i] = hash[:] + digest = append(hash[:], password...) + } + + key := append(md5Hashes[0], md5Hashes[1]...) + iv := md5Hashes[2] + contents := cypher[16:] + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(contents, contents) + + contents, err = pkcs7Unpad(contents, block.BlockSize()) + if err != nil { + return "", err + } + + return string(contents), nil +} + +func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { + if blockSize <= 0 { + return nil, errors.New("invalid blocksize") + } + if len(data)%blockSize != 0 || len(data) == 0 { + return nil, errors.New("invalid PKCS7 data (block size must be a multiple of input length)") + } + padLen := int(data[len(data)-1]) + if padLen > blockSize || padLen == 0 { + return nil, errors.New("invalid PKCS7 padding") + } + for i := 0; i < padLen; i++ { + if data[len(data)-1-i] != byte(padLen) { + return nil, errors.New("invalid PKCS7 padding") + } + } + return data[:len(data)-padLen], nil +} diff --git a/seanime-2.9.10/internal/onlinestream/sources/streamsb.go b/seanime-2.9.10/internal/onlinestream/sources/streamsb.go new file mode 100644 index 0000000..1199e68 --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/sources/streamsb.go @@ -0,0 +1,111 @@ +package onlinestream_sources + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "seanime/internal/util" + "strings" + + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" +) + +type StreamSB struct { + Host string + Host2 string + UserAgent string +} + +func NewStreamSB() *StreamSB { + return &StreamSB{ + Host: "https://streamsss.net/sources50", + Host2: "https://watchsb.com/sources50", + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36", + } +} + +func (s *StreamSB) Payload(hex string) string { + return "566d337678566f743674494a7c7c" + hex + "7c7c346b6767586d6934774855537c7c73747265616d7362/6565417268755339773461447c7c346133383438333436313335376136323337373433383634376337633465366534393338373136643732373736343735373237613763376334363733353737303533366236333463353333363534366137633763373337343732363536313664373336327c7c6b586c3163614468645a47617c7c73747265616d7362" +} + +func (s *StreamSB) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) { + + defer util.HandlePanicInModuleThen("onlinestream/sources/streamsb/Extract", func() { + err = ErrVideoSourceExtraction + }) + + var ret []*hibikeonlinestream.VideoSource + + id := strings.Split(uri, "/e/")[1] + if strings.Contains(id, "html") { + id = strings.Split(id, ".html")[0] + } + + if id == "" { + return nil, errors.New("cannot find ID") + } + + client := &http.Client{} + req, _ := http.NewRequest("GET", fmt.Sprintf("%s/%s", s.Host, s.Payload(hex.EncodeToString([]byte(id)))), nil) + req.Header.Add("watchsb", "sbstream") + req.Header.Add("User-Agent", s.UserAgent) + req.Header.Add("Referer", uri) + + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + body, _ := io.ReadAll(res.Body) + + var jsonResponse map[string]interface{} + err = json.Unmarshal(body, &jsonResponse) + if err != nil { + return nil, err + } + + streamData, ok := jsonResponse["stream_data"].(map[string]interface{}) + if !ok { + return nil, ErrNoVideoSourceFound + } + + m3u8Urls, err := client.Get(streamData["file"].(string)) + if err != nil { + return nil, err + } + defer m3u8Urls.Body.Close() + + m3u8Body, err := io.ReadAll(m3u8Urls.Body) + if err != nil { + return nil, err + } + videoList := strings.Split(string(m3u8Body), "#EXT-X-STREAM-INF:") + + for _, video := range videoList { + if !strings.Contains(video, "m3u8") { + continue + } + + url := strings.Split(video, "\n")[1] + quality := strings.Split(strings.Split(video, "RESOLUTION=")[1], ",")[0] + quality = strings.Split(quality, "x")[1] + + ret = append(ret, &hibikeonlinestream.VideoSource{ + URL: url, + Quality: quality + "p", + Type: hibikeonlinestream.VideoSourceM3U8, + }) + } + + ret = append(ret, &hibikeonlinestream.VideoSource{ + URL: streamData["file"].(string), + Quality: "auto", + Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(streamData["file"].(string), ".m3u8")], + }) + + return ret, nil +} diff --git a/seanime-2.9.10/internal/onlinestream/sources/streamtape.go b/seanime-2.9.10/internal/onlinestream/sources/streamtape.go new file mode 100644 index 0000000..2776205 --- /dev/null +++ b/seanime-2.9.10/internal/onlinestream/sources/streamtape.go @@ -0,0 +1,64 @@ +package onlinestream_sources + +import ( + "errors" + "io" + "net/http" + "regexp" + "seanime/internal/util" + "strings" + + hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" +) + +type ( + Streamtape struct { + Client *http.Client + } +) + +func NewStreamtape() *Streamtape { + return &Streamtape{ + Client: &http.Client{}, + } +} + +func (s *Streamtape) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) { + defer util.HandlePanicInModuleThen("onlinestream/sources/streamtape/Extract", func() { + err = ErrVideoSourceExtraction + }) + + var ret []*hibikeonlinestream.VideoSource + + resp, err := s.Client.Get(uri) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + re := regexp.MustCompile(`robotlink'\).innerHTML = (.*)'`) + match := re.FindStringSubmatch(string(body)) + if len(match) == 0 { + return nil, errors.New("could not find robotlink") + } + + fhsh := strings.Split(match[1], "+ ('") + fh := fhsh[0] + sh := fhsh[1][3:] + + fh = strings.ReplaceAll(fh, "'", "") + + url := "https:" + fh + sh + + ret = append(ret, &hibikeonlinestream.VideoSource{ + URL: url, + Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(url, ".m3u8")], + Quality: QualityAuto, + }) + + return ret, nil +} diff --git a/seanime-2.9.10/internal/platforms/anilist_platform/anilist_platform.go b/seanime-2.9.10/internal/platforms/anilist_platform/anilist_platform.go new file mode 100644 index 0000000..672482c --- /dev/null +++ b/seanime-2.9.10/internal/platforms/anilist_platform/anilist_platform.go @@ -0,0 +1,768 @@ +package anilist_platform + +import ( + "context" + "errors" + "seanime/internal/api/anilist" + "seanime/internal/hook" + "seanime/internal/platforms/platform" + "seanime/internal/util/limiter" + "seanime/internal/util/result" + "sync" + "time" + + "github.com/rs/zerolog" + "github.com/samber/lo" + "github.com/samber/mo" +) + +type ( + AnilistPlatform struct { + logger *zerolog.Logger + username mo.Option[string] + anilistClient anilist.AnilistClient + animeCollection mo.Option[*anilist.AnimeCollection] + rawAnimeCollection mo.Option[*anilist.AnimeCollection] + mangaCollection mo.Option[*anilist.MangaCollection] + rawMangaCollection mo.Option[*anilist.MangaCollection] + isOffline bool + offlinePlatformEnabled bool + baseAnimeCache *result.BoundedCache[int, *anilist.BaseAnime] + } +) + +func NewAnilistPlatform(anilistClient anilist.AnilistClient, logger *zerolog.Logger) platform.Platform { + ap := &AnilistPlatform{ + anilistClient: anilistClient, + logger: logger, + username: mo.None[string](), + animeCollection: mo.None[*anilist.AnimeCollection](), + rawAnimeCollection: mo.None[*anilist.AnimeCollection](), + mangaCollection: mo.None[*anilist.MangaCollection](), + rawMangaCollection: mo.None[*anilist.MangaCollection](), + baseAnimeCache: result.NewBoundedCache[int, *anilist.BaseAnime](50), + } + + return ap +} + +func (ap *AnilistPlatform) clearCache() { + ap.baseAnimeCache.Clear() +} + +func (ap *AnilistPlatform) SetUsername(username string) { + // Set the username for the AnilistPlatform + if username == "" { + ap.username = mo.Some[string]("") + return + } + + ap.username = mo.Some(username) + return +} + +func (ap *AnilistPlatform) SetAnilistClient(client anilist.AnilistClient) { + // Set the AnilistClient for the AnilistPlatform + ap.anilistClient = client +} + +func (ap *AnilistPlatform) UpdateEntry(ctx context.Context, mediaID int, status *anilist.MediaListStatus, scoreRaw *int, progress *int, startedAt *anilist.FuzzyDateInput, completedAt *anilist.FuzzyDateInput) error { + ap.logger.Trace().Msg("anilist platform: Updating entry") + + event := new(PreUpdateEntryEvent) + event.MediaID = &mediaID + event.Status = status + event.ScoreRaw = scoreRaw + event.Progress = progress + event.StartedAt = startedAt + event.CompletedAt = completedAt + + err := hook.GlobalHookManager.OnPreUpdateEntry().Trigger(event) + if err != nil { + return err + } + + if event.DefaultPrevented { + return nil + } + + _, err = ap.anilistClient.UpdateMediaListEntry(ctx, event.MediaID, event.Status, event.ScoreRaw, event.Progress, event.StartedAt, event.CompletedAt) + if err != nil { + return err + } + + postEvent := new(PostUpdateEntryEvent) + postEvent.MediaID = &mediaID + + err = hook.GlobalHookManager.OnPostUpdateEntry().Trigger(postEvent) + + return nil +} + +func (ap *AnilistPlatform) UpdateEntryProgress(ctx context.Context, mediaID int, progress int, totalCount *int) error { + ap.logger.Trace().Msg("anilist platform: Updating entry progress") + + event := new(PreUpdateEntryProgressEvent) + event.MediaID = &mediaID + event.Progress = &progress + event.TotalCount = totalCount + event.Status = lo.ToPtr(anilist.MediaListStatusCurrent) + + err := hook.GlobalHookManager.OnPreUpdateEntryProgress().Trigger(event) + if err != nil { + return err + } + + if event.DefaultPrevented { + return nil + } + + realTotalCount := 0 + if totalCount != nil && *totalCount > 0 { + realTotalCount = *totalCount + } + + // Check if the anime is in the repeating list + // If it is, set the status to repeating + if ap.rawAnimeCollection.IsPresent() { + for _, list := range ap.rawAnimeCollection.MustGet().MediaListCollection.Lists { + if list.Status != nil && *list.Status == anilist.MediaListStatusRepeating { + if list.Entries != nil { + for _, entry := range list.Entries { + if entry.GetMedia().GetID() == mediaID { + *event.Status = anilist.MediaListStatusRepeating + break + } + } + } + } + } + } + if realTotalCount > 0 && progress >= realTotalCount { + *event.Status = anilist.MediaListStatusCompleted + } + + if realTotalCount > 0 && progress > realTotalCount { + *event.Progress = realTotalCount + } + + _, err = ap.anilistClient.UpdateMediaListEntryProgress( + ctx, + event.MediaID, + event.Progress, + event.Status, + ) + if err != nil { + return err + } + + postEvent := new(PostUpdateEntryProgressEvent) + postEvent.MediaID = &mediaID + + err = hook.GlobalHookManager.OnPostUpdateEntryProgress().Trigger(postEvent) + if err != nil { + return err + } + + return nil +} + +func (ap *AnilistPlatform) UpdateEntryRepeat(ctx context.Context, mediaID int, repeat int) error { + ap.logger.Trace().Msg("anilist platform: Updating entry repeat") + + event := new(PreUpdateEntryRepeatEvent) + event.MediaID = &mediaID + event.Repeat = &repeat + + err := hook.GlobalHookManager.OnPreUpdateEntryRepeat().Trigger(event) + if err != nil { + return err + } + + if event.DefaultPrevented { + return nil + } + + _, err = ap.anilistClient.UpdateMediaListEntryRepeat(ctx, event.MediaID, event.Repeat) + if err != nil { + return err + } + + postEvent := new(PostUpdateEntryRepeatEvent) + postEvent.MediaID = &mediaID + + err = hook.GlobalHookManager.OnPostUpdateEntryRepeat().Trigger(postEvent) + if err != nil { + return err + } + + return nil +} + +func (ap *AnilistPlatform) DeleteEntry(ctx context.Context, mediaID int) error { + ap.logger.Trace().Msg("anilist platform: Deleting entry") + _, err := ap.anilistClient.DeleteEntry(ctx, &mediaID) + if err != nil { + return err + } + return nil +} + +func (ap *AnilistPlatform) GetAnime(ctx context.Context, mediaID int) (*anilist.BaseAnime, error) { + ap.logger.Trace().Msg("anilist platform: Fetching anime") + + //if cachedAnime, ok := ap.baseAnimeCache.Get(mediaID); ok { + // ap.logger.Trace().Msg("anilist platform: Returning anime from cache") + // event := new(GetAnimeEvent) + // event.Anime = cachedAnime + // err := hook.GlobalHookManager.OnGetAnime().Trigger(event) + // if err != nil { + // return nil, err + // } + // return event.Anime, nil + //} + + ret, err := ap.anilistClient.BaseAnimeByID(ctx, &mediaID) + if err != nil { + + return nil, err + } + + media := ret.GetMedia() + + event := new(GetAnimeEvent) + event.Anime = media + + err = hook.GlobalHookManager.OnGetAnime().Trigger(event) + if err != nil { + return nil, err + } + + //ap.baseAnimeCache.SetT(mediaID, event.Anime, time.Minute*30) + + return event.Anime, nil +} + +func (ap *AnilistPlatform) GetAnimeByMalID(ctx context.Context, malID int) (*anilist.BaseAnime, error) { + ap.logger.Trace().Msg("anilist platform: Fetching anime by MAL ID") + ret, err := ap.anilistClient.BaseAnimeByMalID(ctx, &malID) + if err != nil { + return nil, err + } + + media := ret.GetMedia() + + event := new(GetAnimeEvent) + event.Anime = media + + err = hook.GlobalHookManager.OnGetAnime().Trigger(event) + if err != nil { + return nil, err + } + + return event.Anime, nil +} + +func (ap *AnilistPlatform) GetAnimeDetails(ctx context.Context, mediaID int) (*anilist.AnimeDetailsById_Media, error) { + ap.logger.Trace().Msg("anilist platform: Fetching anime details") + ret, err := ap.anilistClient.AnimeDetailsByID(ctx, &mediaID) + if err != nil { + return nil, err + } + + media := ret.GetMedia() + + event := new(GetAnimeDetailsEvent) + event.Anime = media + + err = hook.GlobalHookManager.OnGetAnimeDetails().Trigger(event) + if err != nil { + return nil, err + } + + return event.Anime, nil +} + +func (ap *AnilistPlatform) GetAnimeWithRelations(ctx context.Context, mediaID int) (*anilist.CompleteAnime, error) { + ap.logger.Trace().Msg("anilist platform: Fetching anime with relations") + ret, err := ap.anilistClient.CompleteAnimeByID(ctx, &mediaID) + if err != nil { + return nil, err + } + return ret.GetMedia(), nil +} + +func (ap *AnilistPlatform) GetManga(ctx context.Context, mediaID int) (*anilist.BaseManga, error) { + ap.logger.Trace().Msg("anilist platform: Fetching manga") + ret, err := ap.anilistClient.BaseMangaByID(ctx, &mediaID) + if err != nil { + return nil, err + } + + media := ret.GetMedia() + + event := new(GetMangaEvent) + event.Manga = media + + err = hook.GlobalHookManager.OnGetManga().Trigger(event) + if err != nil { + return nil, err + } + + return event.Manga, nil +} + +func (ap *AnilistPlatform) GetMangaDetails(ctx context.Context, mediaID int) (*anilist.MangaDetailsById_Media, error) { + ap.logger.Trace().Msg("anilist platform: Fetching manga details") + ret, err := ap.anilistClient.MangaDetailsByID(ctx, &mediaID) + if err != nil { + return nil, err + } + return ret.GetMedia(), nil +} + +func (ap *AnilistPlatform) GetAnimeCollection(ctx context.Context, bypassCache bool) (*anilist.AnimeCollection, error) { + if !bypassCache && ap.animeCollection.IsPresent() { + event := new(GetCachedAnimeCollectionEvent) + event.AnimeCollection = ap.animeCollection.MustGet() + err := hook.GlobalHookManager.OnGetCachedAnimeCollection().Trigger(event) + if err != nil { + return nil, err + } + return event.AnimeCollection, nil + } + + if ap.username.IsAbsent() { + return nil, nil + } + + err := ap.refreshAnimeCollection(ctx) + if err != nil { + return nil, err + } + + event := new(GetAnimeCollectionEvent) + event.AnimeCollection = ap.animeCollection.MustGet() + + err = hook.GlobalHookManager.OnGetAnimeCollection().Trigger(event) + if err != nil { + return nil, err + } + + return event.AnimeCollection, nil +} + +func (ap *AnilistPlatform) GetRawAnimeCollection(ctx context.Context, bypassCache bool) (*anilist.AnimeCollection, error) { + if !bypassCache && ap.rawAnimeCollection.IsPresent() { + event := new(GetCachedRawAnimeCollectionEvent) + event.AnimeCollection = ap.rawAnimeCollection.MustGet() + err := hook.GlobalHookManager.OnGetCachedRawAnimeCollection().Trigger(event) + if err != nil { + return nil, err + } + return event.AnimeCollection, nil + } + + if ap.username.IsAbsent() { + return nil, nil + } + + err := ap.refreshAnimeCollection(ctx) + if err != nil { + return nil, err + } + + event := new(GetRawAnimeCollectionEvent) + event.AnimeCollection = ap.rawAnimeCollection.MustGet() + + err = hook.GlobalHookManager.OnGetRawAnimeCollection().Trigger(event) + if err != nil { + return nil, err + } + + return event.AnimeCollection, nil +} + +func (ap *AnilistPlatform) RefreshAnimeCollection(ctx context.Context) (*anilist.AnimeCollection, error) { + if ap.username.IsAbsent() { + return nil, nil + } + + err := ap.refreshAnimeCollection(ctx) + if err != nil { + return nil, err + } + + event := new(GetAnimeCollectionEvent) + event.AnimeCollection = ap.animeCollection.MustGet() + + err = hook.GlobalHookManager.OnGetAnimeCollection().Trigger(event) + if err != nil { + return nil, err + } + + event2 := new(GetRawAnimeCollectionEvent) + event2.AnimeCollection = ap.rawAnimeCollection.MustGet() + + err = hook.GlobalHookManager.OnGetRawAnimeCollection().Trigger(event2) + if err != nil { + return nil, err + } + + return event.AnimeCollection, nil +} + +func (ap *AnilistPlatform) refreshAnimeCollection(ctx context.Context) error { + if ap.username.IsAbsent() { + return errors.New("anilist: Username is not set") + } + + // Else, get the collection from Anilist + collection, err := ap.anilistClient.AnimeCollection(ctx, ap.username.ToPointer()) + if err != nil { + return err + } + + // Save the raw collection to App (retains the lists with no status) + collectionCopy := *collection + ap.rawAnimeCollection = mo.Some(&collectionCopy) + listCollectionCopy := *collection.MediaListCollection + ap.rawAnimeCollection.MustGet().MediaListCollection = &listCollectionCopy + listsCopy := make([]*anilist.AnimeCollection_MediaListCollection_Lists, len(collection.MediaListCollection.Lists)) + copy(listsCopy, collection.MediaListCollection.Lists) + ap.rawAnimeCollection.MustGet().MediaListCollection.Lists = listsCopy + + // Remove lists with no status (custom lists) + collection.MediaListCollection.Lists = lo.Filter(collection.MediaListCollection.Lists, func(list *anilist.AnimeCollection_MediaListCollection_Lists, _ int) bool { + return list.Status != nil + }) + + // Save the collection to App + ap.animeCollection = mo.Some(collection) + + return nil +} + +func (ap *AnilistPlatform) GetAnimeCollectionWithRelations(ctx context.Context) (*anilist.AnimeCollectionWithRelations, error) { + ap.logger.Trace().Msg("anilist platform: Fetching anime collection with relations") + + if ap.username.IsAbsent() { + return nil, nil + } + + ret, err := ap.anilistClient.AnimeCollectionWithRelations(ctx, ap.username.ToPointer()) + if err != nil { + return nil, err + } + + return ret, nil +} + +func (ap *AnilistPlatform) GetMangaCollection(ctx context.Context, bypassCache bool) (*anilist.MangaCollection, error) { + + if !bypassCache && ap.mangaCollection.IsPresent() { + event := new(GetCachedMangaCollectionEvent) + event.MangaCollection = ap.mangaCollection.MustGet() + err := hook.GlobalHookManager.OnGetCachedMangaCollection().Trigger(event) + if err != nil { + return nil, err + } + return event.MangaCollection, nil + } + + if ap.username.IsAbsent() { + return nil, nil + } + + err := ap.refreshMangaCollection(ctx) + if err != nil { + return nil, err + } + + event := new(GetMangaCollectionEvent) + event.MangaCollection = ap.mangaCollection.MustGet() + + err = hook.GlobalHookManager.OnGetMangaCollection().Trigger(event) + if err != nil { + return nil, err + } + + return event.MangaCollection, nil +} + +func (ap *AnilistPlatform) GetRawMangaCollection(ctx context.Context, bypassCache bool) (*anilist.MangaCollection, error) { + ap.logger.Trace().Msg("anilist platform: Fetching raw manga collection") + + if !bypassCache && ap.rawMangaCollection.IsPresent() { + ap.logger.Trace().Msg("anilist platform: Returning raw manga collection from cache") + event := new(GetCachedRawMangaCollectionEvent) + event.MangaCollection = ap.rawMangaCollection.MustGet() + err := hook.GlobalHookManager.OnGetCachedRawMangaCollection().Trigger(event) + if err != nil { + return nil, err + } + return event.MangaCollection, nil + } + + if ap.username.IsAbsent() { + return nil, nil + } + + err := ap.refreshMangaCollection(ctx) + if err != nil { + return nil, err + } + + event := new(GetRawMangaCollectionEvent) + event.MangaCollection = ap.rawMangaCollection.MustGet() + + err = hook.GlobalHookManager.OnGetRawMangaCollection().Trigger(event) + if err != nil { + return nil, err + } + + return event.MangaCollection, nil +} + +func (ap *AnilistPlatform) RefreshMangaCollection(ctx context.Context) (*anilist.MangaCollection, error) { + if ap.username.IsAbsent() { + return nil, nil + } + + err := ap.refreshMangaCollection(ctx) + if err != nil { + return nil, err + } + + event := new(GetMangaCollectionEvent) + event.MangaCollection = ap.mangaCollection.MustGet() + + err = hook.GlobalHookManager.OnGetMangaCollection().Trigger(event) + if err != nil { + return nil, err + } + + event2 := new(GetRawMangaCollectionEvent) + event2.MangaCollection = ap.rawMangaCollection.MustGet() + + err = hook.GlobalHookManager.OnGetRawMangaCollection().Trigger(event2) + if err != nil { + return nil, err + } + + return event.MangaCollection, nil +} + +func (ap *AnilistPlatform) refreshMangaCollection(ctx context.Context) error { + if ap.username.IsAbsent() { + return errors.New("anilist: Username is not set") + } + + collection, err := ap.anilistClient.MangaCollection(ctx, ap.username.ToPointer()) + if err != nil { + return err + } + + // Save the raw collection to App (retains the lists with no status) + collectionCopy := *collection + ap.rawMangaCollection = mo.Some(&collectionCopy) + listCollectionCopy := *collection.MediaListCollection + ap.rawMangaCollection.MustGet().MediaListCollection = &listCollectionCopy + listsCopy := make([]*anilist.MangaCollection_MediaListCollection_Lists, len(collection.MediaListCollection.Lists)) + copy(listsCopy, collection.MediaListCollection.Lists) + ap.rawMangaCollection.MustGet().MediaListCollection.Lists = listsCopy + + // Remove lists with no status (custom lists) + collection.MediaListCollection.Lists = lo.Filter(collection.MediaListCollection.Lists, func(list *anilist.MangaCollection_MediaListCollection_Lists, _ int) bool { + return list.Status != nil + }) + + // Remove Novels from both collections + for _, list := range collection.MediaListCollection.Lists { + for _, entry := range list.Entries { + if entry.GetMedia().GetFormat() != nil && *entry.GetMedia().GetFormat() == anilist.MediaFormatNovel { + list.Entries = lo.Filter(list.Entries, func(e *anilist.MangaCollection_MediaListCollection_Lists_Entries, _ int) bool { + return *e.GetMedia().GetFormat() != anilist.MediaFormatNovel + }) + } + } + } + for _, list := range ap.rawMangaCollection.MustGet().MediaListCollection.Lists { + for _, entry := range list.Entries { + if entry.GetMedia().GetFormat() != nil && *entry.GetMedia().GetFormat() == anilist.MediaFormatNovel { + list.Entries = lo.Filter(list.Entries, func(e *anilist.MangaCollection_MediaListCollection_Lists_Entries, _ int) bool { + return *e.GetMedia().GetFormat() != anilist.MediaFormatNovel + }) + } + } + } + + // Save the collection to App + ap.mangaCollection = mo.Some(collection) + + return nil +} + +func (ap *AnilistPlatform) AddMediaToCollection(ctx context.Context, mIds []int) error { + ap.logger.Trace().Msg("anilist platform: Adding media to collection") + if len(mIds) == 0 { + ap.logger.Debug().Msg("anilist: No media added to planning list") + return nil + } + + rateLimiter := limiter.NewLimiter(1*time.Second, 1) // 1 request per second + + wg := sync.WaitGroup{} + for _, _id := range mIds { + wg.Add(1) + go func(id int) { + rateLimiter.Wait() + defer wg.Done() + _, err := ap.anilistClient.UpdateMediaListEntry( + ctx, + &id, + lo.ToPtr(anilist.MediaListStatusPlanning), + lo.ToPtr(0), + lo.ToPtr(0), + nil, + nil, + ) + if err != nil { + ap.logger.Error().Msg("anilist: An error occurred while adding media to planning list: " + err.Error()) + } + }(_id) + } + wg.Wait() + + ap.logger.Debug().Any("count", len(mIds)).Msg("anilist: Media added to planning list") + return nil +} + +func (ap *AnilistPlatform) GetStudioDetails(ctx context.Context, studioID int) (*anilist.StudioDetails, error) { + ap.logger.Trace().Msg("anilist platform: Fetching studio details") + ret, err := ap.anilistClient.StudioDetails(ctx, &studioID) + if err != nil { + return nil, err + } + + event := new(GetStudioDetailsEvent) + event.Studio = ret + + err = hook.GlobalHookManager.OnGetStudioDetails().Trigger(event) + if err != nil { + return nil, err + } + + return event.Studio, nil +} + +func (ap *AnilistPlatform) GetAnilistClient() anilist.AnilistClient { + return ap.anilistClient +} + +func (ap *AnilistPlatform) GetViewerStats(ctx context.Context) (*anilist.ViewerStats, error) { + if ap.username.IsAbsent() { + return nil, errors.New("anilist: Username is not set") + } + + ap.logger.Trace().Msg("anilist platform: Fetching viewer stats") + ret, err := ap.anilistClient.ViewerStats(ctx) + if err != nil { + return nil, err + } + + return ret, nil +} + +func (ap *AnilistPlatform) GetAnimeAiringSchedule(ctx context.Context) (*anilist.AnimeAiringSchedule, error) { + if ap.username.IsAbsent() { + return nil, errors.New("anilist: Username is not set") + } + + collection, err := ap.GetAnimeCollection(ctx, false) + if err != nil { + return nil, err + } + + mediaIds := make([]*int, 0) + for _, list := range collection.MediaListCollection.Lists { + for _, entry := range list.Entries { + mediaIds = append(mediaIds, &[]int{entry.GetMedia().GetID()}[0]) + } + } + + var ret *anilist.AnimeAiringSchedule + + now := time.Now() + currentSeason, currentSeasonYear := anilist.GetSeasonInfo(now, anilist.GetSeasonKindCurrent) + previousSeason, previousSeasonYear := anilist.GetSeasonInfo(now, anilist.GetSeasonKindPrevious) + nextSeason, nextSeasonYear := anilist.GetSeasonInfo(now, anilist.GetSeasonKindNext) + + ret, err = ap.anilistClient.AnimeAiringSchedule(ctx, mediaIds, ¤tSeason, ¤tSeasonYear, &previousSeason, &previousSeasonYear, &nextSeason, &nextSeasonYear) + if err != nil { + return nil, err + } + + type animeScheduleMedia interface { + GetMedia() []*anilist.AnimeSchedule + } + + foundIds := make(map[int]struct{}) + addIds := func(n animeScheduleMedia) { + for _, m := range n.GetMedia() { + if m == nil { + continue + } + foundIds[m.GetID()] = struct{}{} + } + } + addIds(ret.GetOngoing()) + addIds(ret.GetOngoingNext()) + addIds(ret.GetPreceding()) + addIds(ret.GetUpcoming()) + addIds(ret.GetUpcomingNext()) + + missingIds := make([]*int, 0) + for _, list := range collection.MediaListCollection.Lists { + for _, entry := range list.Entries { + if _, found := foundIds[entry.GetMedia().GetID()]; found { + continue + } + endDate := entry.GetMedia().GetEndDate() + // Ignore if ended more than 2 months ago + if endDate == nil || endDate.GetYear() == nil || endDate.GetMonth() == nil { + missingIds = append(missingIds, &[]int{entry.GetMedia().GetID()}[0]) + continue + } + endTime := time.Date(*endDate.GetYear(), time.Month(*endDate.GetMonth()), 1, 0, 0, 0, 0, time.UTC) + if endTime.Before(now.AddDate(0, -2, 0)) { + continue + } + missingIds = append(missingIds, &[]int{entry.GetMedia().GetID()}[0]) + } + } + + if len(missingIds) > 0 { + retB, err := ap.anilistClient.AnimeAiringScheduleRaw(ctx, missingIds) + if err != nil { + return nil, err + } + if len(retB.GetPage().GetMedia()) > 0 { + // Add to ongoing next + for _, m := range retB.Page.GetMedia() { + if ret.OngoingNext == nil { + ret.OngoingNext = &anilist.AnimeAiringSchedule_OngoingNext{ + Media: make([]*anilist.AnimeSchedule, 0), + } + } + if m == nil { + continue + } + + ret.OngoingNext.Media = append(ret.OngoingNext.Media, m) + } + } + } + + return ret, nil +} diff --git a/seanime-2.9.10/internal/platforms/anilist_platform/hook_events.go b/seanime-2.9.10/internal/platforms/anilist_platform/hook_events.go new file mode 100644 index 0000000..7dc8500 --- /dev/null +++ b/seanime-2.9.10/internal/platforms/anilist_platform/hook_events.go @@ -0,0 +1,121 @@ +package anilist_platform + +import ( + "seanime/internal/api/anilist" + "seanime/internal/hook_resolver" +) + +///////////////////////////// +// AniList Events +///////////////////////////// + +type GetAnimeEvent struct { + hook_resolver.Event + Anime *anilist.BaseAnime `json:"anime"` +} + +type GetAnimeDetailsEvent struct { + hook_resolver.Event + Anime *anilist.AnimeDetailsById_Media `json:"anime"` +} + +type GetMangaEvent struct { + hook_resolver.Event + Manga *anilist.BaseManga `json:"manga"` +} + +type GetMangaDetailsEvent struct { + hook_resolver.Event + Manga *anilist.MangaDetailsById_Media `json:"manga"` +} + +type GetCachedAnimeCollectionEvent struct { + hook_resolver.Event + AnimeCollection *anilist.AnimeCollection `json:"animeCollection"` +} + +type GetCachedMangaCollectionEvent struct { + hook_resolver.Event + MangaCollection *anilist.MangaCollection `json:"mangaCollection"` +} + +type GetAnimeCollectionEvent struct { + hook_resolver.Event + AnimeCollection *anilist.AnimeCollection `json:"animeCollection"` +} + +type GetMangaCollectionEvent struct { + hook_resolver.Event + MangaCollection *anilist.MangaCollection `json:"mangaCollection"` +} + +type GetCachedRawAnimeCollectionEvent struct { + hook_resolver.Event + AnimeCollection *anilist.AnimeCollection `json:"animeCollection"` +} + +type GetCachedRawMangaCollectionEvent struct { + hook_resolver.Event + MangaCollection *anilist.MangaCollection `json:"mangaCollection"` +} + +type GetRawAnimeCollectionEvent struct { + hook_resolver.Event + AnimeCollection *anilist.AnimeCollection `json:"animeCollection"` +} + +type GetRawMangaCollectionEvent struct { + hook_resolver.Event + MangaCollection *anilist.MangaCollection `json:"mangaCollection"` +} + +type GetStudioDetailsEvent struct { + hook_resolver.Event + Studio *anilist.StudioDetails `json:"studio"` +} + +// PreUpdateEntryEvent is triggered when an entry is about to be updated. +// Prevent default to skip the default update and override the update. +type PreUpdateEntryEvent struct { + hook_resolver.Event + MediaID *int `json:"mediaId"` + Status *anilist.MediaListStatus `json:"status"` + ScoreRaw *int `json:"scoreRaw"` + Progress *int `json:"progress"` + StartedAt *anilist.FuzzyDateInput `json:"startedAt"` + CompletedAt *anilist.FuzzyDateInput `json:"completedAt"` +} + +type PostUpdateEntryEvent struct { + hook_resolver.Event + MediaID *int `json:"mediaId"` +} + +// PreUpdateEntryProgressEvent is triggered when an entry's progress is about to be updated. +// Prevent default to skip the default update and override the update. +type PreUpdateEntryProgressEvent struct { + hook_resolver.Event + MediaID *int `json:"mediaId"` + Progress *int `json:"progress"` + TotalCount *int `json:"totalCount"` + // Defaults to anilist.MediaListStatusCurrent + Status *anilist.MediaListStatus `json:"status"` +} + +type PostUpdateEntryProgressEvent struct { + hook_resolver.Event + MediaID *int `json:"mediaId"` +} + +// PreUpdateEntryRepeatEvent is triggered when an entry's repeat is about to be updated. +// Prevent default to skip the default update and override the update. +type PreUpdateEntryRepeatEvent struct { + hook_resolver.Event + MediaID *int `json:"mediaId"` + Repeat *int `json:"repeat"` +} + +type PostUpdateEntryRepeatEvent struct { + hook_resolver.Event + MediaID *int `json:"mediaId"` +} diff --git a/seanime-2.9.10/internal/platforms/offline_platform/offline_platform.go b/seanime-2.9.10/internal/platforms/offline_platform/offline_platform.go new file mode 100644 index 0000000..a7e2ab2 --- /dev/null +++ b/seanime-2.9.10/internal/platforms/offline_platform/offline_platform.go @@ -0,0 +1,455 @@ +package offline_platform + +import ( + "context" + "errors" + "seanime/internal/api/anilist" + "seanime/internal/local" + "seanime/internal/platforms/platform" + + "github.com/rs/zerolog" + "github.com/samber/lo" +) + +var ( + ErrNoLocalAnimeCollection = errors.New("no local anime collection") + ErrorNoLocalMangaCollection = errors.New("no local manga collection") + // ErrMediaNotFound means the media wasn't found in the local collection + ErrMediaNotFound = errors.New("media not found") + // ErrActionNotSupported means the action isn't valid on the local platform + ErrActionNotSupported = errors.New("action not supported") +) + +// OfflinePlatform used when offline. +// It provides the same API as the anilist_platform.AnilistPlatform but some methods are no-op. +type OfflinePlatform struct { + logger *zerolog.Logger + localManager local.Manager + client anilist.AnilistClient +} + +func NewOfflinePlatform(localManager local.Manager, client anilist.AnilistClient, logger *zerolog.Logger) (platform.Platform, error) { + ap := &OfflinePlatform{ + logger: logger, + localManager: localManager, + client: client, + } + + return ap, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (lp *OfflinePlatform) SetUsername(username string) { + // no-op +} + +func (lp *OfflinePlatform) SetAnilistClient(client anilist.AnilistClient) { + // no-op +} + +func rearrangeAnimeCollectionLists(animeCollection *anilist.AnimeCollection) { + removedEntries := make([]*anilist.AnimeCollection_MediaListCollection_Lists_Entries, 0) + for _, list := range animeCollection.MediaListCollection.Lists { + if list.GetStatus() == nil || list.GetEntries() == nil { + continue + } + var indicesToRemove []int + for idx, entry := range list.GetEntries() { + if entry.GetStatus() == nil { + continue + } + // Mark for removal if status differs + if *list.GetStatus() != *entry.GetStatus() { + indicesToRemove = append(indicesToRemove, idx) + removedEntries = append(removedEntries, entry) + } + } + // Remove entries in reverse order to avoid re-slicing issues + for i := len(indicesToRemove) - 1; i >= 0; i-- { + idx := indicesToRemove[i] + list.Entries = append(list.Entries[:idx], list.Entries[idx+1:]...) + } + } + + // Add removed entries to the correct list + for _, entry := range removedEntries { + for _, list := range animeCollection.MediaListCollection.Lists { + if list.GetStatus() == nil { + continue + } + if *list.GetStatus() == *entry.GetStatus() { + list.Entries = append(list.Entries, entry) + } + } + } +} + +func rearrangeMangaCollectionLists(mangaCollection *anilist.MangaCollection) { + removedEntries := make([]*anilist.MangaCollection_MediaListCollection_Lists_Entries, 0) + for _, list := range mangaCollection.MediaListCollection.Lists { + if list.GetStatus() == nil || list.GetEntries() == nil { + continue + } + var indicesToRemove []int + for idx, entry := range list.GetEntries() { + if entry.GetStatus() == nil { + continue + } + // Mark for removal if status differs + if *list.GetStatus() != *entry.GetStatus() { + indicesToRemove = append(indicesToRemove, idx) + removedEntries = append(removedEntries, entry) + } + } + // Remove entries in reverse order to avoid re-slicing issues + for i := len(indicesToRemove) - 1; i >= 0; i-- { + idx := indicesToRemove[i] + list.Entries = append(list.Entries[:idx], list.Entries[idx+1:]...) + } + } + + // Add removed entries to the correct list + for _, entry := range removedEntries { + for _, list := range mangaCollection.MediaListCollection.Lists { + if list.GetStatus() == nil { + continue + } + if *list.GetStatus() == *entry.GetStatus() { + list.Entries = append(list.Entries, entry) + } + } + } +} + +// UpdateEntry updates the entry for the given media ID. +// It doesn't add the entry if it doesn't exist. +func (lp *OfflinePlatform) UpdateEntry(ctx context.Context, mediaID int, status *anilist.MediaListStatus, scoreRaw *int, progress *int, startedAt *anilist.FuzzyDateInput, completedAt *anilist.FuzzyDateInput) error { + if lp.localManager.GetLocalAnimeCollection().IsPresent() { + animeCollection := lp.localManager.GetLocalAnimeCollection().MustGet() + + // Find the entry + for _, list := range animeCollection.MediaListCollection.Lists { + for _, entry := range list.GetEntries() { + if entry.GetMedia().GetID() == mediaID { + // Update the entry + if status != nil { + entry.Status = status + } + if scoreRaw != nil { + entry.Score = lo.ToPtr(float64(*scoreRaw)) + } + if progress != nil { + entry.Progress = progress + } + if startedAt != nil { + entry.StartedAt = &anilist.AnimeCollection_MediaListCollection_Lists_Entries_StartedAt{ + Year: startedAt.Year, + Month: startedAt.Month, + Day: startedAt.Day, + } + } + if completedAt != nil { + entry.CompletedAt = &anilist.AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt{ + Year: completedAt.Year, + Month: completedAt.Month, + Day: completedAt.Day, + } + } + + // Save the collection + rearrangeAnimeCollectionLists(animeCollection) + lp.localManager.UpdateLocalAnimeCollection(animeCollection) + lp.localManager.SetHasLocalChanges(true) + return nil + } + } + } + } + + if lp.localManager.GetLocalMangaCollection().IsPresent() { + mangaCollection := lp.localManager.GetLocalMangaCollection().MustGet() + + // Find the entry + for _, list := range mangaCollection.MediaListCollection.Lists { + for _, entry := range list.Entries { + if entry.GetMedia().GetID() == mediaID { + // Update the entry + if status != nil { + entry.Status = status + } + if scoreRaw != nil { + entry.Score = lo.ToPtr(float64(*scoreRaw)) + } + if progress != nil { + entry.Progress = progress + } + if startedAt != nil { + entry.StartedAt = &anilist.MangaCollection_MediaListCollection_Lists_Entries_StartedAt{ + Year: startedAt.Year, + Month: startedAt.Month, + Day: startedAt.Day, + } + } + if completedAt != nil { + entry.CompletedAt = &anilist.MangaCollection_MediaListCollection_Lists_Entries_CompletedAt{ + Year: completedAt.Year, + Month: completedAt.Month, + Day: completedAt.Day, + } + } + + // Save the collection + rearrangeMangaCollectionLists(mangaCollection) + lp.localManager.UpdateLocalMangaCollection(mangaCollection) + lp.localManager.SetHasLocalChanges(true) + return nil + } + } + } + } + + return ErrMediaNotFound +} + +func (lp *OfflinePlatform) UpdateEntryProgress(ctx context.Context, mediaID int, progress int, totalEpisodes *int) error { + if lp.localManager.GetLocalAnimeCollection().IsPresent() { + animeCollection := lp.localManager.GetLocalAnimeCollection().MustGet() + + // Find the entry + for _, list := range animeCollection.MediaListCollection.Lists { + for _, entry := range list.GetEntries() { + if entry.GetMedia().GetID() == mediaID { + // Update the entry + entry.Progress = &progress + if totalEpisodes != nil { + entry.Media.Episodes = totalEpisodes + } + + // Save the collection + rearrangeAnimeCollectionLists(animeCollection) + lp.localManager.UpdateLocalAnimeCollection(animeCollection) + lp.localManager.SetHasLocalChanges(true) + return nil + } + } + } + } + + if lp.localManager.GetLocalMangaCollection().IsPresent() { + mangaCollection := lp.localManager.GetLocalMangaCollection().MustGet() + + // Find the entry + for _, list := range mangaCollection.MediaListCollection.Lists { + for _, entry := range list.Entries { + if entry.GetMedia().GetID() == mediaID { + // Update the entry + entry.Progress = &progress + if totalEpisodes != nil { + entry.Media.Chapters = totalEpisodes + } + + // Save the collection + rearrangeMangaCollectionLists(mangaCollection) + lp.localManager.UpdateLocalMangaCollection(mangaCollection) + lp.localManager.SetHasLocalChanges(true) + return nil + } + } + } + } + + return ErrMediaNotFound +} + +func (lp *OfflinePlatform) UpdateEntryRepeat(ctx context.Context, mediaID int, repeat int) error { + if lp.localManager.GetLocalAnimeCollection().IsPresent() { + animeCollection := lp.localManager.GetLocalAnimeCollection().MustGet() + + // Find the entry + for _, list := range animeCollection.MediaListCollection.Lists { + for _, entry := range list.GetEntries() { + if entry.GetMedia().GetID() == mediaID { + // Update the entry + entry.Repeat = &repeat + + // Save the collection + rearrangeAnimeCollectionLists(animeCollection) + lp.localManager.UpdateLocalAnimeCollection(animeCollection) + lp.localManager.SetHasLocalChanges(true) + return nil + } + } + } + } + + if lp.localManager.GetLocalMangaCollection().IsPresent() { + mangaCollection := lp.localManager.GetLocalMangaCollection().MustGet() + + // Find the entry + for _, list := range mangaCollection.MediaListCollection.Lists { + for _, entry := range list.Entries { + if entry.GetMedia().GetID() == mediaID { + // Update the entry + entry.Repeat = &repeat + + // Save the collection + rearrangeMangaCollectionLists(mangaCollection) + lp.localManager.UpdateLocalMangaCollection(mangaCollection) + lp.localManager.SetHasLocalChanges(true) + return nil + } + } + } + } + + return ErrMediaNotFound +} + +// DeleteEntry isn't supported for the local platform, always returns an error. +func (lp *OfflinePlatform) DeleteEntry(ctx context.Context, mediaID int) error { + return ErrActionNotSupported +} + +func (lp *OfflinePlatform) GetAnime(ctx context.Context, mediaID int) (*anilist.BaseAnime, error) { + if lp.localManager.GetLocalAnimeCollection().IsPresent() { + animeCollection := lp.localManager.GetLocalAnimeCollection().MustGet() + + // Find the entry + for _, list := range animeCollection.MediaListCollection.Lists { + for _, entry := range list.Entries { + if entry.GetMedia().GetID() == mediaID { + return entry.Media, nil + } + } + } + } + + return nil, ErrMediaNotFound +} + +func (lp *OfflinePlatform) GetAnimeByMalID(ctx context.Context, malID int) (*anilist.BaseAnime, error) { + if lp.localManager.GetLocalAnimeCollection().IsPresent() { + animeCollection := lp.localManager.GetLocalAnimeCollection().MustGet() + + // Find the entry + for _, list := range animeCollection.MediaListCollection.Lists { + for _, entry := range list.Entries { + if entry.GetMedia().GetIDMal() != nil && *entry.GetMedia().GetIDMal() == malID { + return entry.Media, nil + } + } + } + } + + return nil, ErrMediaNotFound +} + +// GetAnimeDetails isn't supported for the local platform, always returns an empty struct. +func (lp *OfflinePlatform) GetAnimeDetails(ctx context.Context, mediaID int) (*anilist.AnimeDetailsById_Media, error) { + return &anilist.AnimeDetailsById_Media{}, nil +} + +// GetAnimeWithRelations isn't supported for the local platform, always returns an error. +func (lp *OfflinePlatform) GetAnimeWithRelations(ctx context.Context, mediaID int) (*anilist.CompleteAnime, error) { + return nil, ErrActionNotSupported +} + +func (lp *OfflinePlatform) GetManga(ctx context.Context, mediaID int) (*anilist.BaseManga, error) { + if lp.localManager.GetLocalMangaCollection().IsPresent() { + mangaCollection := lp.localManager.GetLocalMangaCollection().MustGet() + + // Find the entry + for _, list := range mangaCollection.MediaListCollection.Lists { + for _, entry := range list.Entries { + if entry.GetMedia().GetID() == mediaID { + return entry.Media, nil + } + } + } + } + + return nil, ErrMediaNotFound +} + +// GetMangaDetails isn't supported for the local platform, always returns an empty struct. +func (lp *OfflinePlatform) GetMangaDetails(ctx context.Context, mediaID int) (*anilist.MangaDetailsById_Media, error) { + return &anilist.MangaDetailsById_Media{}, nil +} + +func (lp *OfflinePlatform) GetAnimeCollection(ctx context.Context, bypassCache bool) (*anilist.AnimeCollection, error) { + if lp.localManager.GetLocalAnimeCollection().IsPresent() { + return lp.localManager.GetLocalAnimeCollection().MustGet(), nil + } else { + return nil, ErrNoLocalAnimeCollection + } +} + +func (lp *OfflinePlatform) GetRawAnimeCollection(ctx context.Context, bypassCache bool) (*anilist.AnimeCollection, error) { + if lp.localManager.GetLocalAnimeCollection().IsPresent() { + return lp.localManager.GetLocalAnimeCollection().MustGet(), nil + } else { + return nil, ErrNoLocalAnimeCollection + } +} + +// RefreshAnimeCollection is a no-op, always returns the local anime collection. +func (lp *OfflinePlatform) RefreshAnimeCollection(ctx context.Context) (*anilist.AnimeCollection, error) { + animeCollection, ok := lp.localManager.GetLocalAnimeCollection().Get() + if !ok { + return nil, ErrNoLocalAnimeCollection + } + + return animeCollection, nil +} + +func (lp *OfflinePlatform) GetAnimeCollectionWithRelations(ctx context.Context) (*anilist.AnimeCollectionWithRelations, error) { + return nil, ErrActionNotSupported +} + +func (lp *OfflinePlatform) GetMangaCollection(ctx context.Context, bypassCache bool) (*anilist.MangaCollection, error) { + if lp.localManager.GetLocalMangaCollection().IsPresent() { + return lp.localManager.GetLocalMangaCollection().MustGet(), nil + } else { + return nil, ErrorNoLocalMangaCollection + } +} + +func (lp *OfflinePlatform) GetRawMangaCollection(ctx context.Context, bypassCache bool) (*anilist.MangaCollection, error) { + if lp.localManager.GetLocalMangaCollection().IsPresent() { + return lp.localManager.GetLocalMangaCollection().MustGet(), nil + } else { + return nil, ErrorNoLocalMangaCollection + } +} + +func (lp *OfflinePlatform) RefreshMangaCollection(ctx context.Context) (*anilist.MangaCollection, error) { + mangaCollection, ok := lp.localManager.GetLocalMangaCollection().Get() + if !ok { + return nil, ErrorNoLocalMangaCollection + } + + return mangaCollection, nil +} + +// AddMediaToCollection isn't supported for the local platform, always returns an error. +func (lp *OfflinePlatform) AddMediaToCollection(ctx context.Context, mIds []int) error { + return ErrActionNotSupported +} + +// GetStudioDetails isn't supported for the local platform, always returns an empty struct +func (lp *OfflinePlatform) GetStudioDetails(ctx context.Context, studioID int) (*anilist.StudioDetails, error) { + return &anilist.StudioDetails{}, nil +} + +func (lp *OfflinePlatform) GetAnilistClient() anilist.AnilistClient { + return lp.client +} + +func (lp *OfflinePlatform) GetViewerStats(ctx context.Context) (*anilist.ViewerStats, error) { + return nil, ErrActionNotSupported +} + +func (lp *OfflinePlatform) GetAnimeAiringSchedule(ctx context.Context) (*anilist.AnimeAiringSchedule, error) { + return nil, ErrActionNotSupported +} diff --git a/seanime-2.9.10/internal/platforms/platform/platform.go b/seanime-2.9.10/internal/platforms/platform/platform.go new file mode 100644 index 0000000..65b3c30 --- /dev/null +++ b/seanime-2.9.10/internal/platforms/platform/platform.go @@ -0,0 +1,62 @@ +package platform + +import ( + "context" + "seanime/internal/api/anilist" +) + +type Platform interface { + SetUsername(username string) + // SetAnilistClient sets the AniList client to use for the platform + SetAnilistClient(client anilist.AnilistClient) + // UpdateEntry updates the entry for the given media ID + UpdateEntry(context context.Context, mediaID int, status *anilist.MediaListStatus, scoreRaw *int, progress *int, startedAt *anilist.FuzzyDateInput, completedAt *anilist.FuzzyDateInput) error + // UpdateEntryProgress updates the entry progress for the given media ID + UpdateEntryProgress(context context.Context, mediaID int, progress int, totalEpisodes *int) error + // UpdateEntryRepeat updates the entry repeat number for the given media ID + UpdateEntryRepeat(context context.Context, mediaID int, repeat int) error + // DeleteEntry deletes the entry for the given media ID + DeleteEntry(context context.Context, mediaID int) error + // GetAnime gets the anime for the given media ID + GetAnime(context context.Context, mediaID int) (*anilist.BaseAnime, error) + // GetAnimeByMalID gets the anime by MAL ID + GetAnimeByMalID(context context.Context, malID int) (*anilist.BaseAnime, error) + // GetAnimeWithRelations gets the anime with relations for the given media ID + // This is used for scanning purposes in order to build the relation tree + GetAnimeWithRelations(context context.Context, mediaID int) (*anilist.CompleteAnime, error) + // GetAnimeDetails gets the anime details for the given media ID + // These details are only fetched by the anime page + GetAnimeDetails(context context.Context, mediaID int) (*anilist.AnimeDetailsById_Media, error) + // GetManga gets the manga for the given media ID + GetManga(context context.Context, mediaID int) (*anilist.BaseManga, error) + // GetAnimeCollection gets the anime collection without custom lists + // This should not make any API calls and instead should be based on GetRawAnimeCollection + GetAnimeCollection(context context.Context, bypassCache bool) (*anilist.AnimeCollection, error) + // GetRawAnimeCollection gets the anime collection with custom lists + GetRawAnimeCollection(context context.Context, bypassCache bool) (*anilist.AnimeCollection, error) + // GetMangaDetails gets the manga details for the given media ID + // These details are only fetched by the manga page + GetMangaDetails(context context.Context, mediaID int) (*anilist.MangaDetailsById_Media, error) + // GetAnimeCollectionWithRelations gets the anime collection with relations + // This is used for scanning purposes in order to build the relation tree + GetAnimeCollectionWithRelations(context context.Context) (*anilist.AnimeCollectionWithRelations, error) + // GetMangaCollection gets the manga collection without custom lists + // This should not make any API calls and instead should be based on GetRawMangaCollection + GetMangaCollection(context context.Context, bypassCache bool) (*anilist.MangaCollection, error) + // GetRawMangaCollection gets the manga collection with custom lists + GetRawMangaCollection(context context.Context, bypassCache bool) (*anilist.MangaCollection, error) + // AddMediaToCollection adds the media to the collection + AddMediaToCollection(context context.Context, mIds []int) error + // GetStudioDetails gets the studio details for the given studio ID + GetStudioDetails(context context.Context, studioID int) (*anilist.StudioDetails, error) + // GetAnilistClient gets the AniList client + GetAnilistClient() anilist.AnilistClient + // RefreshAnimeCollection refreshes the anime collection + RefreshAnimeCollection(context context.Context) (*anilist.AnimeCollection, error) + // RefreshMangaCollection refreshes the manga collection + RefreshMangaCollection(context context.Context) (*anilist.MangaCollection, error) + // GetViewerStats gets the viewer stats + GetViewerStats(context context.Context) (*anilist.ViewerStats, error) + // GetAnimeAiringSchedule gets the schedule for airing anime in the collection + GetAnimeAiringSchedule(context context.Context) (*anilist.AnimeAiringSchedule, error) +} diff --git a/seanime-2.9.10/internal/platforms/simulated_platform/helpers.go b/seanime-2.9.10/internal/platforms/simulated_platform/helpers.go new file mode 100644 index 0000000..fcf3789 --- /dev/null +++ b/seanime-2.9.10/internal/platforms/simulated_platform/helpers.go @@ -0,0 +1,515 @@ +package simulated_platform + +import ( + "context" + "errors" + "seanime/internal/api/anilist" + "time" + + "github.com/samber/lo" +) + +// CollectionWrapper provides an ambivalent interface for anime and manga collections +type CollectionWrapper struct { + platform *SimulatedPlatform + isAnime bool +} + +func (sp *SimulatedPlatform) GetAnimeCollectionWrapper() *CollectionWrapper { + return &CollectionWrapper{platform: sp, isAnime: true} +} + +func (sp *SimulatedPlatform) GetMangaCollectionWrapper() *CollectionWrapper { + return &CollectionWrapper{platform: sp, isAnime: false} +} + +// AddEntry adds a new entry to the collection +func (cw *CollectionWrapper) AddEntry(mediaId int, status anilist.MediaListStatus) error { + if cw.isAnime { + return cw.addAnimeEntry(mediaId, status) + } + return cw.addMangaEntry(mediaId, status) +} + +// UpdateEntry updates an existing entry in the collection +func (cw *CollectionWrapper) UpdateEntry(mediaId int, status *anilist.MediaListStatus, scoreRaw *int, progress *int, startedAt *anilist.FuzzyDateInput, completedAt *anilist.FuzzyDateInput) error { + if cw.isAnime { + return cw.updateAnimeEntry(mediaId, status, scoreRaw, progress, startedAt, completedAt) + } + return cw.updateMangaEntry(mediaId, status, scoreRaw, progress, startedAt, completedAt) +} + +// UpdateEntryProgress updates the progress of an entry +func (cw *CollectionWrapper) UpdateEntryProgress(mediaId int, progress int, totalCount *int) error { + status := anilist.MediaListStatusCurrent + if totalCount != nil && progress >= *totalCount { + status = anilist.MediaListStatusCompleted + } + + return cw.UpdateEntry(mediaId, &status, nil, &progress, nil, nil) +} + +// DeleteEntry removes an entry from the collection +func (cw *CollectionWrapper) DeleteEntry(mediaId int, isEntryId ...bool) error { + if cw.isAnime { + return cw.deleteAnimeEntry(mediaId, isEntryId...) + } + return cw.deleteMangaEntry(mediaId, isEntryId...) +} + +// FindEntry finds an entry by media ID +func (cw *CollectionWrapper) FindEntry(mediaId int, isEntryId ...bool) (interface{}, error) { + if cw.isAnime { + return cw.findAnimeEntry(mediaId, isEntryId...) + } + return cw.findMangaEntry(mediaId, isEntryId...) +} + +// UpdateMediaData updates the media data for an entry +func (cw *CollectionWrapper) UpdateMediaData(mediaId int, mediaData interface{}) error { + if cw.isAnime { + if baseAnime, ok := mediaData.(*anilist.BaseAnime); ok { + return cw.updateAnimeMediaData(mediaId, baseAnime) + } + return errors.New("invalid anime data type") + } + + if baseManga, ok := mediaData.(*anilist.BaseManga); ok { + return cw.updateMangaMediaData(mediaId, baseManga) + } + return errors.New("invalid manga data type") +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Anime Collection Helper Methods +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (cw *CollectionWrapper) addAnimeEntry(mediaId int, status anilist.MediaListStatus) error { + collection, err := cw.platform.getOrCreateAnimeCollection() + if err != nil { + return err + } + + // Check if entry already exists + if _, err := cw.findAnimeEntry(mediaId); err == nil { + return errors.New("entry already exists") + } + + // Fetch media data + mediaResp, err := cw.platform.client.BaseAnimeByID(context.Background(), &mediaId) + if err != nil { + return err + } + + // Find or create the appropriate list + var targetList *anilist.AnimeCollection_MediaListCollection_Lists + for _, list := range collection.GetMediaListCollection().GetLists() { + if list.GetStatus() != nil && *list.GetStatus() == status { + targetList = list + break + } + } + + if targetList == nil { + // Create new list + targetList = &anilist.AnimeCollection_MediaListCollection_Lists{ + Status: &status, + Name: lo.ToPtr(string(status)), + IsCustomList: lo.ToPtr(false), + Entries: []*anilist.AnimeCollection_MediaListCollection_Lists_Entries{}, + } + collection.GetMediaListCollection().Lists = append(collection.GetMediaListCollection().Lists, targetList) + } + + // Create new entry + newEntry := &anilist.AnimeCollection_MediaListCollection_Lists_Entries{ + ID: int(time.Now().UnixNano()), // Generate unique ID + Status: &status, + Progress: lo.ToPtr(0), + Media: mediaResp.GetMedia(), + Score: lo.ToPtr(0.0), + Notes: nil, + Repeat: lo.ToPtr(0), + Private: lo.ToPtr(false), + StartedAt: &anilist.AnimeCollection_MediaListCollection_Lists_Entries_StartedAt{}, + CompletedAt: &anilist.AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt{}, + } + + targetList.Entries = append(targetList.Entries, newEntry) + + // Save collection + cw.platform.localManager.SaveSimulatedAnimeCollection(collection) + return nil +} + +func (cw *CollectionWrapper) updateAnimeEntry(mediaId int, status *anilist.MediaListStatus, scoreRaw *int, progress *int, startedAt *anilist.FuzzyDateInput, completedAt *anilist.FuzzyDateInput) error { + collection, err := cw.platform.getOrCreateAnimeCollection() + if err != nil { + return err + } + + var foundEntry *anilist.AnimeCollection_MediaListCollection_Lists_Entries + var sourceList *anilist.AnimeCollection_MediaListCollection_Lists + var entryIndex int + + // Find the entry + for _, list := range collection.GetMediaListCollection().GetLists() { + for i, entry := range list.GetEntries() { + if entry.GetMedia().GetID() == mediaId { + foundEntry = entry + sourceList = list + entryIndex = i + break + } + } + if foundEntry != nil { + break + } + } + + if foundEntry == nil || sourceList == nil { + return ErrMediaNotFound + } + + // Update entry fields + if progress != nil { + foundEntry.Progress = progress + } + if scoreRaw != nil { + foundEntry.Score = lo.ToPtr(float64(*scoreRaw)) + } + if startedAt != nil { + foundEntry.StartedAt = &anilist.AnimeCollection_MediaListCollection_Lists_Entries_StartedAt{ + Year: startedAt.Year, + Month: startedAt.Month, + Day: startedAt.Day, + } + } + if completedAt != nil { + foundEntry.CompletedAt = &anilist.AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt{ + Year: completedAt.Year, + Month: completedAt.Month, + Day: completedAt.Day, + } + } + + // If status changed, move entry to different list + if status != nil && foundEntry.GetStatus() != nil && *status != *foundEntry.GetStatus() { + foundEntry.Status = status + + // Remove from current list + sourceList.Entries = append(sourceList.Entries[:entryIndex], sourceList.Entries[entryIndex+1:]...) + + // Find or create target list + var targetList *anilist.AnimeCollection_MediaListCollection_Lists + for _, list := range collection.GetMediaListCollection().GetLists() { + if list.GetStatus() != nil && *list.GetStatus() == *status { + targetList = list + break + } + } + + if targetList == nil { + targetList = &anilist.AnimeCollection_MediaListCollection_Lists{ + Status: status, + Name: lo.ToPtr(string(*status)), + IsCustomList: lo.ToPtr(false), + Entries: []*anilist.AnimeCollection_MediaListCollection_Lists_Entries{}, + } + collection.GetMediaListCollection().Lists = append(collection.GetMediaListCollection().Lists, targetList) + } + + targetList.Entries = append(targetList.Entries, foundEntry) + } + + cw.platform.localManager.SaveSimulatedAnimeCollection(collection) + return nil +} + +func (cw *CollectionWrapper) deleteAnimeEntry(mediaId int, isEntryId ...bool) error { + collection, err := cw.platform.getOrCreateAnimeCollection() + if err != nil { + return err + } + + // Find and remove entry + for _, list := range collection.GetMediaListCollection().GetLists() { + for i, entry := range list.GetEntries() { + if len(isEntryId) > 0 && isEntryId[0] { + // If isEntryId is true, we assume mediaId is actually the entry ID + if entry.GetID() == mediaId { + list.Entries = append(list.Entries[:i], list.Entries[i+1:]...) + cw.platform.localManager.SaveSimulatedAnimeCollection(collection) + return nil + } + } else { + if entry.GetMedia().GetID() == mediaId { + list.Entries = append(list.Entries[:i], list.Entries[i+1:]...) + cw.platform.localManager.SaveSimulatedAnimeCollection(collection) + return nil + } + } + + } + } + + return ErrMediaNotFound +} + +func (cw *CollectionWrapper) findAnimeEntry(mediaId int, isEntryId ...bool) (*anilist.AnimeCollection_MediaListCollection_Lists_Entries, error) { + collection, err := cw.platform.getOrCreateAnimeCollection() + if err != nil { + return nil, err + } + + for _, list := range collection.GetMediaListCollection().GetLists() { + for _, entry := range list.GetEntries() { + if len(isEntryId) > 0 && isEntryId[0] { + if entry.GetID() == mediaId { + return entry, nil + } + } else { + if entry.GetMedia().GetID() == mediaId { + return entry, nil + } + } + } + } + + return nil, ErrMediaNotFound +} + +func (cw *CollectionWrapper) updateAnimeMediaData(mediaId int, mediaData *anilist.BaseAnime) error { + collection, err := cw.platform.getOrCreateAnimeCollection() + if err != nil { + return err + } + + for _, list := range collection.GetMediaListCollection().GetLists() { + for _, entry := range list.GetEntries() { + if entry.GetMedia().GetID() == mediaId { + entry.Media = mediaData + cw.platform.localManager.SaveSimulatedAnimeCollection(collection) + return nil + } + } + } + + return ErrMediaNotFound +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Manga Collection Helper Methods +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (cw *CollectionWrapper) addMangaEntry(mediaId int, status anilist.MediaListStatus) error { + collection, err := cw.platform.getOrCreateMangaCollection() + if err != nil { + return err + } + + // Check if entry already exists + if _, err := cw.findMangaEntry(mediaId); err == nil { + return errors.New("entry already exists") + } + + // Fetch media data + mediaResp, err := cw.platform.client.BaseMangaByID(context.Background(), &mediaId) + if err != nil { + return err + } + + // Find or create the appropriate list + var targetList *anilist.MangaCollection_MediaListCollection_Lists + for _, list := range collection.GetMediaListCollection().GetLists() { + if list.GetStatus() != nil && *list.GetStatus() == status { + targetList = list + break + } + } + + if targetList == nil { + // Create new list + targetList = &anilist.MangaCollection_MediaListCollection_Lists{ + Status: &status, + Name: lo.ToPtr(string(status)), + IsCustomList: lo.ToPtr(false), + Entries: []*anilist.MangaCollection_MediaListCollection_Lists_Entries{}, + } + collection.GetMediaListCollection().Lists = append(collection.GetMediaListCollection().Lists, targetList) + } + + // Create new entry + newEntry := &anilist.MangaCollection_MediaListCollection_Lists_Entries{ + ID: int(time.Now().UnixNano()), + Status: &status, + Progress: lo.ToPtr(0), + Media: mediaResp.GetMedia(), + Score: lo.ToPtr(0.0), + Notes: nil, + Repeat: lo.ToPtr(0), + Private: lo.ToPtr(false), + StartedAt: &anilist.MangaCollection_MediaListCollection_Lists_Entries_StartedAt{}, + CompletedAt: &anilist.MangaCollection_MediaListCollection_Lists_Entries_CompletedAt{}, + } + + targetList.Entries = append(targetList.Entries, newEntry) + + // Save collection + cw.platform.localManager.SaveSimulatedMangaCollection(collection) + return nil +} + +func (cw *CollectionWrapper) updateMangaEntry(mediaId int, status *anilist.MediaListStatus, scoreRaw *int, progress *int, startedAt *anilist.FuzzyDateInput, completedAt *anilist.FuzzyDateInput) error { + collection, err := cw.platform.getOrCreateMangaCollection() + if err != nil { + return err + } + + var foundEntry *anilist.MangaCollection_MediaListCollection_Lists_Entries + var sourceList *anilist.MangaCollection_MediaListCollection_Lists + var entryIndex int + + // Find the entry + for _, list := range collection.GetMediaListCollection().GetLists() { + for i, entry := range list.GetEntries() { + if entry.GetMedia().GetID() == mediaId { + foundEntry = entry + sourceList = list + entryIndex = i + break + } + } + if foundEntry != nil { + break + } + } + + if foundEntry == nil || sourceList == nil { + return ErrMediaNotFound + } + + // Update entry fields + if progress != nil { + foundEntry.Progress = progress + } + if scoreRaw != nil { + foundEntry.Score = lo.ToPtr(float64(*scoreRaw)) + } + if startedAt != nil { + foundEntry.StartedAt = &anilist.MangaCollection_MediaListCollection_Lists_Entries_StartedAt{ + Year: startedAt.Year, + Month: startedAt.Month, + Day: startedAt.Day, + } + } + if completedAt != nil { + foundEntry.CompletedAt = &anilist.MangaCollection_MediaListCollection_Lists_Entries_CompletedAt{ + Year: completedAt.Year, + Month: completedAt.Month, + Day: completedAt.Day, + } + } + + // If status changed, move entry to different list + if status != nil && foundEntry.GetStatus() != nil && *status != *foundEntry.GetStatus() { + foundEntry.Status = status + + // Remove from current list + sourceList.Entries = append(sourceList.Entries[:entryIndex], sourceList.Entries[entryIndex+1:]...) + + // Find or create target list + var targetList *anilist.MangaCollection_MediaListCollection_Lists + for _, list := range collection.GetMediaListCollection().GetLists() { + if list.GetStatus() != nil && *list.GetStatus() == *status { + targetList = list + break + } + } + + if targetList == nil { + targetList = &anilist.MangaCollection_MediaListCollection_Lists{ + Status: status, + Name: lo.ToPtr(string(*status)), + IsCustomList: lo.ToPtr(false), + Entries: []*anilist.MangaCollection_MediaListCollection_Lists_Entries{}, + } + collection.GetMediaListCollection().Lists = append(collection.GetMediaListCollection().Lists, targetList) + } + + targetList.Entries = append(targetList.Entries, foundEntry) + } + + cw.platform.localManager.SaveSimulatedMangaCollection(collection) + return nil +} + +func (cw *CollectionWrapper) deleteMangaEntry(mediaId int, isEntryId ...bool) error { + collection, err := cw.platform.getOrCreateMangaCollection() + if err != nil { + return err + } + + // Find and remove entry + for _, list := range collection.GetMediaListCollection().GetLists() { + for i, entry := range list.GetEntries() { + if len(isEntryId) > 0 && isEntryId[0] { + if entry.GetID() == mediaId { + list.Entries = append(list.Entries[:i], list.Entries[i+1:]...) + cw.platform.localManager.SaveSimulatedMangaCollection(collection) + return nil + } + } else { + if entry.GetMedia().GetID() == mediaId { + list.Entries = append(list.Entries[:i], list.Entries[i+1:]...) + cw.platform.localManager.SaveSimulatedMangaCollection(collection) + return nil + } + } + } + } + + return ErrMediaNotFound +} + +func (cw *CollectionWrapper) findMangaEntry(mediaId int, isEntryId ...bool) (*anilist.MangaCollection_MediaListCollection_Lists_Entries, error) { + collection, err := cw.platform.getOrCreateMangaCollection() + if err != nil { + return nil, err + } + + for _, list := range collection.GetMediaListCollection().GetLists() { + for _, entry := range list.GetEntries() { + if len(isEntryId) > 0 && isEntryId[0] { + if entry.GetID() == mediaId { + return entry, nil + } + } else { + if entry.GetMedia().GetID() == mediaId { + return entry, nil + } + } + } + } + + return nil, ErrMediaNotFound +} + +func (cw *CollectionWrapper) updateMangaMediaData(mediaId int, mediaData *anilist.BaseManga) error { + collection, err := cw.platform.getOrCreateMangaCollection() + if err != nil { + return err + } + + for _, list := range collection.GetMediaListCollection().GetLists() { + for _, entry := range list.GetEntries() { + if entry.GetMedia().GetID() == mediaId { + entry.Media = mediaData + cw.platform.localManager.SaveSimulatedMangaCollection(collection) + return nil + } + } + } + + return ErrMediaNotFound +} diff --git a/seanime-2.9.10/internal/platforms/simulated_platform/simulated_platform.go b/seanime-2.9.10/internal/platforms/simulated_platform/simulated_platform.go new file mode 100644 index 0000000..c4c11e7 --- /dev/null +++ b/seanime-2.9.10/internal/platforms/simulated_platform/simulated_platform.go @@ -0,0 +1,582 @@ +package simulated_platform + +import ( + "context" + "encoding/json" + "errors" + "seanime/internal/api/anilist" + "seanime/internal/local" + "seanime/internal/platforms/platform" + "seanime/internal/util/limiter" + "sync" + "time" + + "github.com/rs/zerolog" +) + +var ( + // ErrMediaNotFound means the media wasn't found in the local collection + ErrMediaNotFound = errors.New("media not found") +) + +// SimulatedPlatform used when the user is not authenticated to AniList. +// It acts as a dummy account using simulated collections stored locally. +type SimulatedPlatform struct { + logger *zerolog.Logger + localManager local.Manager + client anilist.AnilistClient // should only receive an unauthenticated client + + // Cache for collections + animeCollection *anilist.AnimeCollection + mangaCollection *anilist.MangaCollection + mu sync.RWMutex + collectionMu sync.RWMutex // used to protect access to collections + lastAnimeCollectionRefetchTime time.Time // used to prevent refetching too many times + lastMangaCollectionRefetchTime time.Time // used to prevent refetching too many times + anilistRateLimit *limiter.Limiter +} + +func NewSimulatedPlatform(localManager local.Manager, client anilist.AnilistClient, logger *zerolog.Logger) (platform.Platform, error) { + sp := &SimulatedPlatform{ + logger: logger, + localManager: localManager, + client: client, + anilistRateLimit: limiter.NewAnilistLimiter(), + } + + return sp, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Implementation +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (sp *SimulatedPlatform) SetUsername(username string) { + // no-op +} + +func (sp *SimulatedPlatform) SetAnilistClient(client anilist.AnilistClient) { + sp.client = client // DEVNOTE: Should only be unauthenticated +} + +// UpdateEntry updates the entry for the given media ID. +// If the entry doesn't exist, it will be added automatically after determining the media type. +func (sp *SimulatedPlatform) UpdateEntry(ctx context.Context, mediaID int, status *anilist.MediaListStatus, scoreRaw *int, progress *int, startedAt *anilist.FuzzyDateInput, completedAt *anilist.FuzzyDateInput) error { + sp.logger.Trace().Int("mediaID", mediaID).Msg("simulated platform: Updating entry") + + sp.mu.Lock() + defer sp.mu.Unlock() + + // Try anime first + animeWrapper := sp.GetAnimeCollectionWrapper() + if _, err := animeWrapper.FindEntry(mediaID); err == nil { + return animeWrapper.UpdateEntry(mediaID, status, scoreRaw, progress, startedAt, completedAt) + } + + // Try manga + mangaWrapper := sp.GetMangaCollectionWrapper() + if _, err := mangaWrapper.FindEntry(mediaID); err == nil { + return mangaWrapper.UpdateEntry(mediaID, status, scoreRaw, progress, startedAt, completedAt) + } + + // Entry doesn't exist, determine media type and add it + defaultStatus := anilist.MediaListStatusPlanning + if status != nil { + defaultStatus = *status + } + + // Try to fetch as anime first + if _, err := sp.client.BaseAnimeByID(ctx, &mediaID); err == nil { + // It's an anime, add it to anime collection + sp.logger.Trace().Int("mediaID", mediaID).Msg("simulated platform: Adding new anime entry") + if err := animeWrapper.AddEntry(mediaID, defaultStatus); err != nil { + return err + } + // Update with provided values if there are additional updates needed + if status != &defaultStatus || scoreRaw != nil || progress != nil || startedAt != nil || completedAt != nil { + return animeWrapper.UpdateEntry(mediaID, status, scoreRaw, progress, startedAt, completedAt) + } + return nil + } + + // Try to fetch as manga + if _, err := sp.client.BaseMangaByID(ctx, &mediaID); err == nil { + // It's a manga, add it to manga collection + sp.logger.Trace().Int("mediaID", mediaID).Msg("simulated platform: Adding new manga entry") + if err := mangaWrapper.AddEntry(mediaID, defaultStatus); err != nil { + return err + } + // Update with provided values if there are additional updates needed + if status != &defaultStatus || scoreRaw != nil || progress != nil || startedAt != nil || completedAt != nil { + return mangaWrapper.UpdateEntry(mediaID, status, scoreRaw, progress, startedAt, completedAt) + } + return nil + } + + // Media not found in either anime or manga + return errors.New("media not found on AniList") +} + +func (sp *SimulatedPlatform) UpdateEntryProgress(ctx context.Context, mediaID int, progress int, totalEpisodes *int) error { + sp.logger.Trace().Int("mediaID", mediaID).Int("progress", progress).Msg("simulated platform: Updating entry progress") + + sp.mu.Lock() + defer sp.mu.Unlock() + + status := anilist.MediaListStatusCurrent + if totalEpisodes != nil && progress >= *totalEpisodes { + status = anilist.MediaListStatusCompleted + } + + // Try anime first + animeWrapper := sp.GetAnimeCollectionWrapper() + if _, err := animeWrapper.FindEntry(mediaID); err == nil { + return animeWrapper.UpdateEntryProgress(mediaID, progress, totalEpisodes) + } + + // Try manga + mangaWrapper := sp.GetMangaCollectionWrapper() + if _, err := mangaWrapper.FindEntry(mediaID); err == nil { + return mangaWrapper.UpdateEntryProgress(mediaID, progress, totalEpisodes) + } + + // Entry doesn't exist, determine media type and add it + // Try to fetch as anime first + if _, err := sp.client.BaseAnimeByID(ctx, &mediaID); err == nil { + // It's an anime, add it to anime collection + sp.logger.Trace().Int("mediaID", mediaID).Msg("simulated platform: Adding new anime entry for progress update") + if err := animeWrapper.AddEntry(mediaID, status); err != nil { + return err + } + return animeWrapper.UpdateEntryProgress(mediaID, progress, totalEpisodes) + } + + // Try to fetch as manga + if _, err := sp.client.BaseMangaByID(ctx, &mediaID); err == nil { + // It's a manga, add it to manga collection + sp.logger.Trace().Int("mediaID", mediaID).Msg("simulated platform: Adding new manga entry for progress update") + if err := mangaWrapper.AddEntry(mediaID, status); err != nil { + return err + } + return mangaWrapper.UpdateEntryProgress(mediaID, progress, totalEpisodes) + } + + // Media not found in either anime or manga + return errors.New("media not found on AniList") +} + +func (sp *SimulatedPlatform) UpdateEntryRepeat(ctx context.Context, mediaID int, repeat int) error { + sp.logger.Trace().Int("mediaID", mediaID).Int("repeat", repeat).Msg("simulated platform: Updating entry repeat") + + sp.mu.Lock() + defer sp.mu.Unlock() + + // Try anime first + wrapper := sp.GetAnimeCollectionWrapper() + if entry, err := wrapper.FindEntry(mediaID); err == nil { + if animeEntry, ok := entry.(*anilist.AnimeCollection_MediaListCollection_Lists_Entries); ok { + animeEntry.Repeat = &repeat + sp.localManager.SaveSimulatedAnimeCollection(sp.animeCollection) + return nil + } + } + + // Try manga + wrapper = sp.GetMangaCollectionWrapper() + if entry, err := wrapper.FindEntry(mediaID); err == nil { + if mangaEntry, ok := entry.(*anilist.MangaCollection_MediaListCollection_Lists_Entries); ok { + mangaEntry.Repeat = &repeat + sp.localManager.SaveSimulatedMangaCollection(sp.mangaCollection) + return nil + } + } + + return ErrMediaNotFound +} + +func (sp *SimulatedPlatform) DeleteEntry(ctx context.Context, entryId int) error { + sp.logger.Trace().Int("entryId", entryId).Msg("simulated platform: Deleting entry") + + sp.mu.Lock() + defer sp.mu.Unlock() + + // Try anime first + wrapper := sp.GetAnimeCollectionWrapper() + if _, err := wrapper.FindEntry(entryId, true); err == nil { + return wrapper.DeleteEntry(entryId, true) + } + + // Try manga + wrapper = sp.GetMangaCollectionWrapper() + if _, err := wrapper.FindEntry(entryId, true); err == nil { + return wrapper.DeleteEntry(entryId, true) + } + + return ErrMediaNotFound +} + +func (sp *SimulatedPlatform) GetAnime(ctx context.Context, mediaID int) (*anilist.BaseAnime, error) { + sp.logger.Trace().Int("mediaID", mediaID).Msg("simulated platform: Getting anime") + + // Get anime from anilist + resp, err := sp.client.BaseAnimeByID(ctx, &mediaID) + if err != nil { + return nil, err + } + + // Update media data in collection if it exists + sp.mu.Lock() + wrapper := sp.GetAnimeCollectionWrapper() + if _, err := wrapper.FindEntry(mediaID); err == nil { + _ = wrapper.UpdateMediaData(mediaID, resp.GetMedia()) + } + sp.mu.Unlock() + + return resp.GetMedia(), nil +} + +func (sp *SimulatedPlatform) GetAnimeByMalID(ctx context.Context, malID int) (*anilist.BaseAnime, error) { + sp.logger.Trace().Int("malID", malID).Msg("simulated platform: Getting anime by MAL ID") + + resp, err := sp.client.BaseAnimeByMalID(ctx, &malID) + if err != nil { + return nil, err + } + + // Update media data in collection if it exists + if resp.GetMedia() != nil { + sp.mu.Lock() + wrapper := sp.GetAnimeCollectionWrapper() + if _, err := wrapper.FindEntry(resp.GetMedia().GetID()); err == nil { + _ = wrapper.UpdateMediaData(resp.GetMedia().GetID(), resp.GetMedia()) + } + sp.mu.Unlock() + } + + return resp.GetMedia(), nil +} + +func (sp *SimulatedPlatform) GetAnimeDetails(ctx context.Context, mediaID int) (*anilist.AnimeDetailsById_Media, error) { + sp.logger.Trace().Int("mediaID", mediaID).Msg("simulated platform: Getting anime details") + + resp, err := sp.client.AnimeDetailsByID(ctx, &mediaID) + if err != nil { + return nil, err + } + + return resp.GetMedia(), nil +} + +func (sp *SimulatedPlatform) GetAnimeWithRelations(ctx context.Context, mediaID int) (*anilist.CompleteAnime, error) { + sp.logger.Trace().Int("mediaID", mediaID).Msg("simulated platform: Getting anime with relations") + + resp, err := sp.client.CompleteAnimeByID(ctx, &mediaID) + if err != nil { + return nil, err + } + + return resp.GetMedia(), nil +} + +func (sp *SimulatedPlatform) GetManga(ctx context.Context, mediaID int) (*anilist.BaseManga, error) { + sp.logger.Trace().Int("mediaID", mediaID).Msg("simulated platform: Getting manga") + + // Get manga from anilist + resp, err := sp.client.BaseMangaByID(ctx, &mediaID) + if err != nil { + return nil, err + } + + // Update media data in collection if it exists + sp.mu.Lock() + wrapper := sp.GetMangaCollectionWrapper() + if _, err := wrapper.FindEntry(mediaID); err == nil { + _ = wrapper.UpdateMediaData(mediaID, resp.GetMedia()) + } + sp.mu.Unlock() + + return resp.GetMedia(), nil +} + +func (sp *SimulatedPlatform) GetMangaDetails(ctx context.Context, mediaID int) (*anilist.MangaDetailsById_Media, error) { + sp.logger.Trace().Int("mediaID", mediaID).Msg("simulated platform: Getting manga details") + + resp, err := sp.client.MangaDetailsByID(ctx, &mediaID) + if err != nil { + return nil, err + } + + return resp.GetMedia(), nil +} + +func (sp *SimulatedPlatform) GetAnimeCollection(ctx context.Context, bypassCache bool) (*anilist.AnimeCollection, error) { + sp.logger.Trace().Bool("bypassCache", bypassCache).Msg("simulated platform: Getting anime collection") + + if bypassCache { + sp.invalidateAnimeCollectionCache() + return sp.getOrCreateAnimeCollection() + } + + return sp.animeCollection, nil +} + +func (sp *SimulatedPlatform) GetRawAnimeCollection(ctx context.Context, bypassCache bool) (*anilist.AnimeCollection, error) { + return sp.GetAnimeCollection(ctx, bypassCache) +} + +func (sp *SimulatedPlatform) RefreshAnimeCollection(ctx context.Context) (*anilist.AnimeCollection, error) { + sp.logger.Trace().Msg("simulated platform: Refreshing anime collection") + + sp.invalidateAnimeCollectionCache() + return sp.getOrCreateAnimeCollection() +} + +// GetAnimeCollectionWithRelations returns the anime collection (without relations) +func (sp *SimulatedPlatform) GetAnimeCollectionWithRelations(ctx context.Context) (*anilist.AnimeCollectionWithRelations, error) { + sp.logger.Trace().Msg("simulated platform: Getting anime collection with relations") + + // Use JSON to convert the collection structs + collection, err := sp.getOrCreateAnimeCollection() + if err != nil { + return nil, err + } + + collectionWithRelations := &anilist.AnimeCollectionWithRelations{} + + marshaled, err := json.Marshal(collection) + if err != nil { + return nil, err + } + err = json.Unmarshal(marshaled, collectionWithRelations) + if err != nil { + return nil, err + } + + // For simulated platform, the anime collection will not have relations + return collectionWithRelations, nil +} + +func (sp *SimulatedPlatform) GetMangaCollection(ctx context.Context, bypassCache bool) (*anilist.MangaCollection, error) { + sp.logger.Trace().Bool("bypassCache", bypassCache).Msg("simulated platform: Getting manga collection") + + if bypassCache { + sp.invalidateMangaCollectionCache() + return sp.getOrCreateMangaCollection() + } + + return sp.mangaCollection, nil +} + +func (sp *SimulatedPlatform) GetRawMangaCollection(ctx context.Context, bypassCache bool) (*anilist.MangaCollection, error) { + return sp.GetMangaCollection(ctx, bypassCache) +} + +func (sp *SimulatedPlatform) RefreshMangaCollection(ctx context.Context) (*anilist.MangaCollection, error) { + sp.logger.Trace().Msg("simulated platform: Refreshing manga collection") + + sp.invalidateMangaCollectionCache() + return sp.getOrCreateMangaCollection() +} + +func (sp *SimulatedPlatform) AddMediaToCollection(ctx context.Context, mIds []int) error { + sp.logger.Trace().Interface("mediaIDs", mIds).Msg("simulated platform: Adding media to collection") + + sp.mu.Lock() + defer sp.mu.Unlock() + + // DEVNOTE: We assume it's anime for now since it's only been used for anime + wrapper := sp.GetAnimeCollectionWrapper() + for _, mediaID := range mIds { + // Try to add as anime first, if it fails, ignore + _ = wrapper.AddEntry(mediaID, anilist.MediaListStatusPlanning) + } + + return nil +} + +func (sp *SimulatedPlatform) GetStudioDetails(ctx context.Context, studioID int) (*anilist.StudioDetails, error) { + return sp.client.StudioDetails(ctx, &studioID) +} + +func (sp *SimulatedPlatform) GetAnilistClient() anilist.AnilistClient { + return sp.client +} + +func (sp *SimulatedPlatform) GetViewerStats(ctx context.Context) (*anilist.ViewerStats, error) { + return nil, errors.New("use a real account to get stats") +} + +func (sp *SimulatedPlatform) GetAnimeAiringSchedule(ctx context.Context) (*anilist.AnimeAiringSchedule, error) { + collection, err := sp.GetAnimeCollection(ctx, false) + if err != nil { + return nil, err + } + + mediaIds := make([]*int, 0) + for _, list := range collection.MediaListCollection.Lists { + for _, entry := range list.Entries { + mediaIds = append(mediaIds, &[]int{entry.GetMedia().GetID()}[0]) + } + } + + var ret *anilist.AnimeAiringSchedule + + now := time.Now() + currentSeason, currentSeasonYear := anilist.GetSeasonInfo(now, anilist.GetSeasonKindCurrent) + previousSeason, previousSeasonYear := anilist.GetSeasonInfo(now, anilist.GetSeasonKindPrevious) + nextSeason, nextSeasonYear := anilist.GetSeasonInfo(now, anilist.GetSeasonKindNext) + + ret, err = sp.client.AnimeAiringSchedule(ctx, mediaIds, ¤tSeason, ¤tSeasonYear, &previousSeason, &previousSeasonYear, &nextSeason, &nextSeasonYear) + if err != nil { + return nil, err + } + + type animeScheduleMedia interface { + GetMedia() []*anilist.AnimeSchedule + } + + foundIds := make(map[int]struct{}) + addIds := func(n animeScheduleMedia) { + for _, m := range n.GetMedia() { + if m == nil { + continue + } + foundIds[m.GetID()] = struct{}{} + } + } + addIds(ret.GetOngoing()) + addIds(ret.GetOngoingNext()) + addIds(ret.GetPreceding()) + addIds(ret.GetUpcoming()) + addIds(ret.GetUpcomingNext()) + + missingIds := make([]*int, 0) + for _, list := range collection.MediaListCollection.Lists { + for _, entry := range list.Entries { + if _, found := foundIds[entry.GetMedia().GetID()]; found { + continue + } + endDate := entry.GetMedia().GetEndDate() + // Ignore if ended more than 2 months ago + if endDate == nil || endDate.GetYear() == nil || endDate.GetMonth() == nil { + missingIds = append(missingIds, &[]int{entry.GetMedia().GetID()}[0]) + continue + } + endTime := time.Date(*endDate.GetYear(), time.Month(*endDate.GetMonth()), 1, 0, 0, 0, 0, time.UTC) + if endTime.Before(now.AddDate(0, -2, 0)) { + continue + } + missingIds = append(missingIds, &[]int{entry.GetMedia().GetID()}[0]) + } + } + + if len(missingIds) > 0 { + retB, err := sp.client.AnimeAiringScheduleRaw(ctx, missingIds) + if err != nil { + return nil, err + } + if len(retB.GetPage().GetMedia()) > 0 { + // Add to ongoing next + for _, m := range retB.Page.GetMedia() { + if ret.OngoingNext == nil { + ret.OngoingNext = &anilist.AnimeAiringSchedule_OngoingNext{ + Media: make([]*anilist.AnimeSchedule, 0), + } + } + if m == nil { + continue + } + + ret.OngoingNext.Media = append(ret.OngoingNext.Media, m) + } + } + } + + return ret, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Helper Methods +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (sp *SimulatedPlatform) getOrCreateAnimeCollection() (*anilist.AnimeCollection, error) { + sp.collectionMu.RLock() + if sp.animeCollection != nil { + defer sp.collectionMu.RUnlock() + return sp.animeCollection, nil + } + sp.collectionMu.RUnlock() + + sp.collectionMu.Lock() + defer sp.collectionMu.Unlock() + + // Double-check after acquiring write lock + if sp.animeCollection != nil { + return sp.animeCollection, nil + } + + // Try to load from database + if collection := sp.localManager.GetSimulatedAnimeCollection(); collection.IsPresent() { + sp.animeCollection = collection.MustGet() + return sp.animeCollection, nil + } + + // Create empty collection + sp.animeCollection = &anilist.AnimeCollection{ + MediaListCollection: &anilist.AnimeCollection_MediaListCollection{ + Lists: []*anilist.AnimeCollection_MediaListCollection_Lists{}, + }, + } + + // Save empty collection + sp.localManager.SaveSimulatedAnimeCollection(sp.animeCollection) + + return sp.animeCollection, nil +} + +func (sp *SimulatedPlatform) getOrCreateMangaCollection() (*anilist.MangaCollection, error) { + sp.collectionMu.RLock() + if sp.mangaCollection != nil { + defer sp.collectionMu.RUnlock() + return sp.mangaCollection, nil + } + sp.collectionMu.RUnlock() + + sp.collectionMu.Lock() + defer sp.collectionMu.Unlock() + + // Double-check after acquiring write lock + if sp.mangaCollection != nil { + return sp.mangaCollection, nil + } + + // Try to load from database + if collection := sp.localManager.GetSimulatedMangaCollection(); collection.IsPresent() { + sp.mangaCollection = collection.MustGet() + return sp.mangaCollection, nil + } + + // Create empty collection + sp.mangaCollection = &anilist.MangaCollection{ + MediaListCollection: &anilist.MangaCollection_MediaListCollection{ + Lists: []*anilist.MangaCollection_MediaListCollection_Lists{}, + }, + } + + // Save empty collection + sp.localManager.SaveSimulatedMangaCollection(sp.mangaCollection) + + return sp.mangaCollection, nil +} + +func (sp *SimulatedPlatform) invalidateAnimeCollectionCache() { + sp.collectionMu.Lock() + defer sp.collectionMu.Unlock() + sp.animeCollection = nil +} + +func (sp *SimulatedPlatform) invalidateMangaCollectionCache() { + sp.collectionMu.Lock() + defer sp.collectionMu.Unlock() + sp.mangaCollection = nil +} diff --git a/seanime-2.9.10/internal/plugin/anilist.go b/seanime-2.9.10/internal/plugin/anilist.go new file mode 100644 index 0000000..b7cfc66 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/anilist.go @@ -0,0 +1,127 @@ +package plugin + +import ( + "context" + "seanime/internal/api/anilist" + "seanime/internal/events" + "seanime/internal/extension" + + "github.com/dop251/goja" + "github.com/rs/zerolog" +) + +type Anilist struct { + ctx *AppContextImpl + ext *extension.Extension + logger *zerolog.Logger +} + +// BindAnilist binds the anilist API to the Goja runtime. +// Permissions need to be checked by the caller. +// Permissions needed: anilist +func (a *AppContextImpl) BindAnilist(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension) { + anilistLogger := logger.With().Str("id", ext.ID).Logger() + al := &Anilist{ + ctx: a, + ext: ext, + logger: &anilistLogger, + } + anilistObj := vm.NewObject() + _ = anilistObj.Set("refreshAnimeCollection", al.RefreshAnimeCollection) + _ = anilistObj.Set("refreshMangaCollection", al.RefreshMangaCollection) + + // Bind anilist platform + anilistPlatform, ok := a.anilistPlatform.Get() + if ok { + _ = anilistObj.Set("updateEntry", func(mediaID int, status *anilist.MediaListStatus, scoreRaw *int, progress *int, startedAt *anilist.FuzzyDateInput, completedAt *anilist.FuzzyDateInput) error { + return anilistPlatform.UpdateEntry(context.Background(), mediaID, status, scoreRaw, progress, startedAt, completedAt) + }) + _ = anilistObj.Set("updateEntryProgress", func(mediaID int, progress int, totalEpisodes *int) error { + return anilistPlatform.UpdateEntryProgress(context.Background(), mediaID, progress, totalEpisodes) + }) + _ = anilistObj.Set("updateEntryRepeat", func(mediaID int, repeat int) error { + return anilistPlatform.UpdateEntryRepeat(context.Background(), mediaID, repeat) + }) + _ = anilistObj.Set("deleteEntry", func(mediaID int) error { + return anilistPlatform.DeleteEntry(context.Background(), mediaID) + }) + _ = anilistObj.Set("getAnimeCollection", func(bypassCache bool) (*anilist.AnimeCollection, error) { + return anilistPlatform.GetAnimeCollection(context.Background(), bypassCache) + }) + _ = anilistObj.Set("getRawAnimeCollection", func(bypassCache bool) (*anilist.AnimeCollection, error) { + return anilistPlatform.GetRawAnimeCollection(context.Background(), bypassCache) + }) + _ = anilistObj.Set("getMangaCollection", func(bypassCache bool) (*anilist.MangaCollection, error) { + return anilistPlatform.GetMangaCollection(context.Background(), bypassCache) + }) + _ = anilistObj.Set("getRawMangaCollection", func(bypassCache bool) (*anilist.MangaCollection, error) { + return anilistPlatform.GetRawMangaCollection(context.Background(), bypassCache) + }) + _ = anilistObj.Set("getAnime", func(mediaID int) (*anilist.BaseAnime, error) { + return anilistPlatform.GetAnime(context.Background(), mediaID) + }) + _ = anilistObj.Set("getManga", func(mediaID int) (*anilist.BaseManga, error) { + return anilistPlatform.GetManga(context.Background(), mediaID) + }) + _ = anilistObj.Set("getAnimeDetails", func(mediaID int) (*anilist.AnimeDetailsById_Media, error) { + return anilistPlatform.GetAnimeDetails(context.Background(), mediaID) + }) + _ = anilistObj.Set("getMangaDetails", func(mediaID int) (*anilist.MangaDetailsById_Media, error) { + return anilistPlatform.GetMangaDetails(context.Background(), mediaID) + }) + _ = anilistObj.Set("getAnimeCollectionWithRelations", func() (*anilist.AnimeCollectionWithRelations, error) { + return anilistPlatform.GetAnimeCollectionWithRelations(context.Background()) + }) + _ = anilistObj.Set("addMediaToCollection", func(mIds []int) error { + return anilistPlatform.AddMediaToCollection(context.Background(), mIds) + }) + _ = anilistObj.Set("getStudioDetails", func(studioID int) (*anilist.StudioDetails, error) { + return anilistPlatform.GetStudioDetails(context.Background(), studioID) + }) + + anilistClient := anilistPlatform.GetAnilistClient() + _ = anilistObj.Set("listAnime", func(page *int, search *string, perPage *int, sort []*anilist.MediaSort, status []*anilist.MediaStatus, genres []*string, averageScoreGreater *int, season *anilist.MediaSeason, seasonYear *int, format *anilist.MediaFormat, isAdult *bool) (*anilist.ListAnime, error) { + return anilistClient.ListAnime(context.Background(), page, search, perPage, sort, status, genres, averageScoreGreater, season, seasonYear, format, isAdult) + }) + _ = anilistObj.Set("listManga", func(page *int, search *string, perPage *int, sort []*anilist.MediaSort, status []*anilist.MediaStatus, genres []*string, averageScoreGreater *int, startDateGreater *string, startDateLesser *string, format *anilist.MediaFormat, countryOfOrigin *string, isAdult *bool) (*anilist.ListManga, error) { + return anilistClient.ListManga(context.Background(), page, search, perPage, sort, status, genres, averageScoreGreater, startDateGreater, startDateLesser, format, countryOfOrigin, isAdult) + }) + _ = anilistObj.Set("listRecentAnime", func(page *int, perPage *int, airingAtGreater *int, airingAtLesser *int, notYetAired *bool) (*anilist.ListRecentAnime, error) { + return anilistClient.ListRecentAnime(context.Background(), page, perPage, airingAtGreater, airingAtLesser, notYetAired) + }) + _ = anilistObj.Set("customQuery", func(body map[string]interface{}, token string) (interface{}, error) { + return anilist.CustomQuery(body, a.logger, token) + }) + + } + + _ = vm.Set("$anilist", anilistObj) +} + +func (a *Anilist) RefreshAnimeCollection() { + a.logger.Trace().Msg("plugin: Refreshing anime collection") + onRefreshAnilistAnimeCollection, ok := a.ctx.onRefreshAnilistAnimeCollection.Get() + if !ok { + return + } + + onRefreshAnilistAnimeCollection() + wsEventManager, ok := a.ctx.wsEventManager.Get() + if ok { + wsEventManager.SendEvent(events.RefreshedAnilistAnimeCollection, nil) + } +} + +func (a *Anilist) RefreshMangaCollection() { + a.logger.Trace().Msg("plugin: Refreshing manga collection") + onRefreshAnilistMangaCollection, ok := a.ctx.onRefreshAnilistMangaCollection.Get() + if !ok { + return + } + + onRefreshAnilistMangaCollection() + wsEventManager, ok := a.ctx.wsEventManager.Get() + if ok { + wsEventManager.SendEvent(events.RefreshedAnilistMangaCollection, nil) + } +} diff --git a/seanime-2.9.10/internal/plugin/anime.go b/seanime-2.9.10/internal/plugin/anime.go new file mode 100644 index 0000000..187c58f --- /dev/null +++ b/seanime-2.9.10/internal/plugin/anime.go @@ -0,0 +1,127 @@ +package plugin + +import ( + "context" + "seanime/internal/database/db_bridge" + "seanime/internal/extension" + "seanime/internal/goja/goja_bindings" + "seanime/internal/hook" + "seanime/internal/library/anime" + goja_util "seanime/internal/util/goja" + + "github.com/dop251/goja" + "github.com/rs/zerolog" +) + +type Anime struct { + ctx *AppContextImpl + vm *goja.Runtime + logger *zerolog.Logger + ext *extension.Extension + scheduler *goja_util.Scheduler +} + +func (a *AppContextImpl) BindAnimeToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + m := &Anime{ + ctx: a, + vm: vm, + logger: logger, + ext: ext, + scheduler: scheduler, + } + + animeObj := vm.NewObject() + + // Get downloaded chapter containers + _ = animeObj.Set("getAnimeEntry", m.getAnimeEntry) + _ = obj.Set("anime", animeObj) +} + +func (m *Anime) getAnimeEntry(call goja.FunctionCall) goja.Value { + promise, resolve, reject := m.vm.NewPromise() + + mediaId := call.Argument(0).ToInteger() + + if mediaId == 0 { + _ = reject(goja_bindings.NewErrorString(m.vm, "anilist platform not found")) + return m.vm.ToValue(promise) + } + + database, ok := m.ctx.database.Get() + if !ok { + _ = reject(goja_bindings.NewErrorString(m.vm, "database not found")) + return m.vm.ToValue(promise) + } + + anilistPlatform, ok := m.ctx.anilistPlatform.Get() + if !ok { + _ = reject(goja_bindings.NewErrorString(m.vm, "anilist platform not found")) + return m.vm.ToValue(promise) + } + + metadataProvider, ok := m.ctx.metadataProvider.Get() + if !ok { + _ = reject(goja_bindings.NewErrorString(m.vm, "metadata provider not found")) + return m.vm.ToValue(promise) + } + + fillerManager, ok := m.ctx.fillerManager.Get() + if !ok { + _ = reject(goja_bindings.NewErrorString(m.vm, "filler manager not found")) + return m.vm.ToValue(promise) + } + + go func() { + // Get all the local files + lfs, _, err := db_bridge.GetLocalFiles(database) + if err != nil { + _ = reject(m.vm.ToValue(err.Error())) + return + } + + // Get the user's anilist collection + animeCollection, err := anilistPlatform.GetAnimeCollection(context.Background(), false) + if err != nil { + _ = reject(m.vm.ToValue(err.Error())) + return + } + + if animeCollection == nil { + _ = reject(goja_bindings.NewErrorString(m.vm, "anilist collection not found")) + return + } + + // Create a new media entry + entry, err := anime.NewEntry(context.Background(), &anime.NewEntryOptions{ + MediaId: int(mediaId), + LocalFiles: lfs, + AnimeCollection: animeCollection, + Platform: anilistPlatform, + MetadataProvider: metadataProvider, + }) + if err != nil { + _ = reject(goja_bindings.NewError(m.vm, err)) + return + } + + fillerEvent := new(anime.AnimeEntryFillerHydrationEvent) + fillerEvent.Entry = entry + err = hook.GlobalHookManager.OnAnimeEntryFillerHydration().Trigger(fillerEvent) + if err != nil { + _ = reject(goja_bindings.NewError(m.vm, err)) + return + } + entry = fillerEvent.Entry + + if !fillerEvent.DefaultPrevented { + fillerManager.HydrateFillerData(fillerEvent.Entry) + } + + m.scheduler.ScheduleAsync(func() error { + _ = resolve(m.vm.ToValue(entry)) + return nil + }) + }() + + return m.vm.ToValue(promise) +} diff --git a/seanime-2.9.10/internal/plugin/app_context.go b/seanime-2.9.10/internal/plugin/app_context.go new file mode 100644 index 0000000..14bb378 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/app_context.go @@ -0,0 +1,316 @@ +package plugin + +import ( + "seanime/internal/api/metadata" + "seanime/internal/continuity" + "seanime/internal/database/db" + "seanime/internal/database/models" + discordrpc_presence "seanime/internal/discordrpc/presence" + "seanime/internal/events" + "seanime/internal/extension" + "seanime/internal/library/autodownloader" + "seanime/internal/library/autoscanner" + "seanime/internal/library/fillermanager" + "seanime/internal/library/playbackmanager" + "seanime/internal/manga" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/mediastream" + "seanime/internal/onlinestream" + "seanime/internal/platforms/platform" + "seanime/internal/torrent_clients/torrent_client" + "seanime/internal/torrentstream" + "seanime/internal/util/filecache" + goja_util "seanime/internal/util/goja" + + "github.com/dop251/goja" + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +type AppContextModules struct { + IsOffline *bool + Database *db.Database + AnimeLibraryPaths *[]string + AnilistPlatform platform.Platform + PlaybackManager *playbackmanager.PlaybackManager + MediaPlayerRepository *mediaplayer.Repository + MangaRepository *manga.Repository + MetadataProvider metadata.Provider + WSEventManager events.WSEventManagerInterface + DiscordPresence *discordrpc_presence.Presence + TorrentClientRepository *torrent_client.Repository + ContinuityManager *continuity.Manager + AutoScanner *autoscanner.AutoScanner + AutoDownloader *autodownloader.AutoDownloader + FileCacher *filecache.Cacher + OnlinestreamRepository *onlinestream.Repository + MediastreamRepository *mediastream.Repository + TorrentstreamRepository *torrentstream.Repository + FillerManager *fillermanager.FillerManager + OnRefreshAnilistAnimeCollection func() + OnRefreshAnilistMangaCollection func() +} + +// AppContext allows plugins to interact with core modules. +// It binds JS APIs to the Goja runtimes for that purpose. +type AppContext interface { + // SetModulesPartial sets modules if they are not nil + SetModulesPartial(AppContextModules) + // SetLogger sets the logger for the context + SetLogger(logger *zerolog.Logger) + + Database() mo.Option[*db.Database] + PlaybackManager() mo.Option[*playbackmanager.PlaybackManager] + MediaPlayerRepository() mo.Option[*mediaplayer.Repository] + AnilistPlatform() mo.Option[platform.Platform] + WSEventManager() mo.Option[events.WSEventManagerInterface] + + IsOffline() bool + + BindApp(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension) + // BindStorage binds $storage to the Goja runtime + BindStorage(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Storage + // BindAnilist binds $anilist to the Goja runtime + BindAnilist(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension) + // BindDatabase binds $database to the Goja runtime + BindDatabase(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension) + + // BindSystem binds $system to the Goja runtime + BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindPlaybackToContextObj binds 'playback' to the UI context object + BindPlaybackToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindCronToContextObj binds 'cron' to the UI context object + BindCronToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Cron + + // BindDownloaderToContextObj binds 'downloader' to the UI context object + BindDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindMangaToContextObj binds 'manga' to the UI context object + BindMangaToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindAnimeToContextObj binds 'anime' to the UI context object + BindAnimeToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindDiscordToContextObj binds 'discord' to the UI context object + BindDiscordToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindContinuityToContextObj binds 'continuity' to the UI context object + BindContinuityToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindTorrentClientToContextObj binds 'torrentClient' to the UI context object + BindTorrentClientToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindTorrentstreamToContextObj binds 'torrentstream' to the UI context object + BindTorrentstreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindMediastreamToContextObj binds 'mediastream' to the UI context object + BindMediastreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindOnlinestreamToContextObj binds 'onlinestream' to the UI context object + BindOnlinestreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindFillerManagerToContextObj binds 'fillerManager' to the UI context object + BindFillerManagerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindAutoDownloaderToContextObj binds 'autoDownloader' to the UI context object + BindAutoDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindAutoScannerToContextObj binds 'autoScanner' to the UI context object + BindAutoScannerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindFileCacherToContextObj binds 'fileCacher' to the UI context object + BindFileCacherToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + // BindExternalPlayerLinkToContextObj binds 'externalPlayerLink' to the UI context object + BindExternalPlayerLinkToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) + + DropPluginData(extId string) +} + +var GlobalAppContext = NewAppContext() + +//////////////////////////////////////////////////////////////////////////// + +type AppContextImpl struct { + logger *zerolog.Logger + + animeLibraryPaths mo.Option[[]string] + + wsEventManager mo.Option[events.WSEventManagerInterface] + database mo.Option[*db.Database] + playbackManager mo.Option[*playbackmanager.PlaybackManager] + mediaplayerRepo mo.Option[*mediaplayer.Repository] + mangaRepository mo.Option[*manga.Repository] + anilistPlatform mo.Option[platform.Platform] + discordPresence mo.Option[*discordrpc_presence.Presence] + metadataProvider mo.Option[metadata.Provider] + fillerManager mo.Option[*fillermanager.FillerManager] + torrentClientRepository mo.Option[*torrent_client.Repository] + torrentstreamRepository mo.Option[*torrentstream.Repository] + mediastreamRepository mo.Option[*mediastream.Repository] + onlinestreamRepository mo.Option[*onlinestream.Repository] + continuityManager mo.Option[*continuity.Manager] + autoScanner mo.Option[*autoscanner.AutoScanner] + autoDownloader mo.Option[*autodownloader.AutoDownloader] + fileCacher mo.Option[*filecache.Cacher] + onRefreshAnilistAnimeCollection mo.Option[func()] + onRefreshAnilistMangaCollection mo.Option[func()] + isOffline bool +} + +func NewAppContext() AppContext { + nopLogger := zerolog.Nop() + appCtx := &AppContextImpl{ + logger: &nopLogger, + database: mo.None[*db.Database](), + playbackManager: mo.None[*playbackmanager.PlaybackManager](), + mediaplayerRepo: mo.None[*mediaplayer.Repository](), + anilistPlatform: mo.None[platform.Platform](), + mangaRepository: mo.None[*manga.Repository](), + metadataProvider: mo.None[metadata.Provider](), + wsEventManager: mo.None[events.WSEventManagerInterface](), + discordPresence: mo.None[*discordrpc_presence.Presence](), + fillerManager: mo.None[*fillermanager.FillerManager](), + torrentClientRepository: mo.None[*torrent_client.Repository](), + torrentstreamRepository: mo.None[*torrentstream.Repository](), + mediastreamRepository: mo.None[*mediastream.Repository](), + onlinestreamRepository: mo.None[*onlinestream.Repository](), + continuityManager: mo.None[*continuity.Manager](), + autoScanner: mo.None[*autoscanner.AutoScanner](), + autoDownloader: mo.None[*autodownloader.AutoDownloader](), + fileCacher: mo.None[*filecache.Cacher](), + onRefreshAnilistAnimeCollection: mo.None[func()](), + onRefreshAnilistMangaCollection: mo.None[func()](), + isOffline: false, + } + + return appCtx +} + +func (a *AppContextImpl) IsOffline() bool { + return a.isOffline +} + +func (a *AppContextImpl) SetLogger(logger *zerolog.Logger) { + a.logger = logger +} + +func (a *AppContextImpl) Database() mo.Option[*db.Database] { + return a.database +} + +func (a *AppContextImpl) PlaybackManager() mo.Option[*playbackmanager.PlaybackManager] { + return a.playbackManager +} + +func (a *AppContextImpl) MediaPlayerRepository() mo.Option[*mediaplayer.Repository] { + return a.mediaplayerRepo +} + +func (a *AppContextImpl) AnilistPlatform() mo.Option[platform.Platform] { + return a.anilistPlatform +} + +func (a *AppContextImpl) WSEventManager() mo.Option[events.WSEventManagerInterface] { + return a.wsEventManager +} + +func (a *AppContextImpl) SetModulesPartial(modules AppContextModules) { + if modules.IsOffline != nil { + a.isOffline = *modules.IsOffline + } + + if modules.Database != nil { + a.database = mo.Some(modules.Database) + } + + if modules.AnimeLibraryPaths != nil { + a.animeLibraryPaths = mo.Some(*modules.AnimeLibraryPaths) + } + + if modules.MetadataProvider != nil { + a.metadataProvider = mo.Some(modules.MetadataProvider) + } + + if modules.PlaybackManager != nil { + a.playbackManager = mo.Some(modules.PlaybackManager) + } + + if modules.AnilistPlatform != nil { + a.anilistPlatform = mo.Some(modules.AnilistPlatform) + } + + if modules.MediaPlayerRepository != nil { + a.mediaplayerRepo = mo.Some(modules.MediaPlayerRepository) + } + + if modules.FillerManager != nil { + a.fillerManager = mo.Some(modules.FillerManager) + } + + if modules.OnRefreshAnilistAnimeCollection != nil { + a.onRefreshAnilistAnimeCollection = mo.Some(modules.OnRefreshAnilistAnimeCollection) + } + + if modules.OnRefreshAnilistMangaCollection != nil { + a.onRefreshAnilistMangaCollection = mo.Some(modules.OnRefreshAnilistMangaCollection) + } + + if modules.MangaRepository != nil { + a.mangaRepository = mo.Some(modules.MangaRepository) + } + + if modules.DiscordPresence != nil { + a.discordPresence = mo.Some(modules.DiscordPresence) + } + + if modules.WSEventManager != nil { + a.wsEventManager = mo.Some(modules.WSEventManager) + } + + if modules.ContinuityManager != nil { + a.continuityManager = mo.Some(modules.ContinuityManager) + } + + if modules.TorrentClientRepository != nil { + a.torrentClientRepository = mo.Some(modules.TorrentClientRepository) + } + + if modules.TorrentstreamRepository != nil { + a.torrentstreamRepository = mo.Some(modules.TorrentstreamRepository) + } + + if modules.MediastreamRepository != nil { + a.mediastreamRepository = mo.Some(modules.MediastreamRepository) + } + + if modules.OnlinestreamRepository != nil { + a.onlinestreamRepository = mo.Some(modules.OnlinestreamRepository) + } + + if modules.AutoDownloader != nil { + a.autoDownloader = mo.Some(modules.AutoDownloader) + } + + if modules.AutoScanner != nil { + a.autoScanner = mo.Some(modules.AutoScanner) + } + + if modules.FileCacher != nil { + a.fileCacher = mo.Some(modules.FileCacher) + } +} + +func (a *AppContextImpl) DropPluginData(extId string) { + db, ok := a.database.Get() + if !ok { + return + } + + err := db.Gorm().Where("plugin_id = ?", extId).Delete(&models.PluginData{}).Error + if err != nil { + a.logger.Error().Err(err).Msg("Failed to drop plugin data") + } +} diff --git a/seanime-2.9.10/internal/plugin/continuity.go b/seanime-2.9.10/internal/plugin/continuity.go new file mode 100644 index 0000000..14d6397 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/continuity.go @@ -0,0 +1,62 @@ +package plugin + +import ( + "seanime/internal/continuity" + "seanime/internal/extension" + "seanime/internal/goja/goja_bindings" + goja_util "seanime/internal/util/goja" + + "github.com/dop251/goja" + "github.com/rs/zerolog" +) + +func (a *AppContextImpl) BindContinuityToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + + continuityObj := vm.NewObject() + + _ = continuityObj.Set("updateWatchHistoryItem", func(opts continuity.UpdateWatchHistoryItemOptions) goja.Value { + manager, ok := a.continuityManager.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "continuity manager not set") + } + err := manager.UpdateWatchHistoryItem(&opts) + if err != nil { + goja_bindings.PanicThrowError(vm, err) + } + return goja.Undefined() + }) + + _ = continuityObj.Set("getWatchHistoryItem", func(mediaId int) goja.Value { + manager, ok := a.continuityManager.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "continuity manager not set") + } + resp := manager.GetWatchHistoryItem(mediaId) + if resp == nil || !resp.Found { + return goja.Undefined() + } + return vm.ToValue(resp.Item) + }) + + _ = continuityObj.Set("getWatchHistory", func() goja.Value { + manager, ok := a.continuityManager.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "continuity manager not set") + } + return vm.ToValue(manager.GetWatchHistory()) + }) + + _ = continuityObj.Set("deleteWatchHistoryItem", func(mediaId int) goja.Value { + manager, ok := a.continuityManager.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "continuity manager not set") + } + err := manager.DeleteWatchHistoryItem(mediaId) + if err != nil { + goja_bindings.PanicThrowError(vm, err) + } + return goja.Undefined() + }) + + _ = obj.Set("continuity", continuityObj) +} diff --git a/seanime-2.9.10/internal/plugin/cron.go b/seanime-2.9.10/internal/plugin/cron.go new file mode 100644 index 0000000..14cb717 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/cron.go @@ -0,0 +1,515 @@ +// Package cron implements a crontab-like service to execute and schedule +// repeative tasks/jobs. +// +// Example: +// +// c := cron.New() +// c.MustAdd("dailyReport", "0 0 * * *", func() { ... }) +// c.Start() +package plugin + +import ( + "encoding/json" + "errors" + "fmt" + "seanime/internal/extension" + goja_util "seanime/internal/util/goja" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/dop251/goja" + "github.com/rs/zerolog" +) + +// Cron is a crontab-like struct for tasks/jobs scheduling. +type Cron struct { + timezone *time.Location + ticker *time.Ticker + startTimer *time.Timer + tickerDone chan bool + jobs []*CronJob + interval time.Duration + mux sync.RWMutex + scheduler *goja_util.Scheduler +} + +// New create a new Cron struct with default tick interval of 1 minute +// and timezone in UTC. +// +// You can change the default tick interval with Cron.SetInterval(). +// You can change the default timezone with Cron.SetTimezone(). +func New(scheduler *goja_util.Scheduler) *Cron { + return &Cron{ + interval: 1 * time.Minute, + timezone: time.UTC, + jobs: []*CronJob{}, + tickerDone: make(chan bool), + scheduler: scheduler, + } +} + +func (a *AppContextImpl) BindCronToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Cron { + cron := New(scheduler) + cronObj := vm.NewObject() + _ = cronObj.Set("add", cron.Add) + _ = cronObj.Set("remove", cron.Remove) + _ = cronObj.Set("removeAll", cron.RemoveAll) + _ = cronObj.Set("total", cron.Total) + _ = cronObj.Set("stop", cron.Stop) + _ = cronObj.Set("start", cron.Start) + _ = cronObj.Set("hasStarted", cron.HasStarted) + _ = obj.Set("cron", cronObj) + + return cron +} + +//////////////////////////////////////////////////////////////////////////// + +// SetInterval changes the current cron tick interval +// (it usually should be >= 1 minute). +func (c *Cron) SetInterval(d time.Duration) { + // update interval + c.mux.Lock() + wasStarted := c.ticker != nil + c.interval = d + c.mux.Unlock() + + // restart the ticker + if wasStarted { + c.Start() + } +} + +// SetTimezone changes the current cron tick timezone. +func (c *Cron) SetTimezone(l *time.Location) { + c.mux.Lock() + defer c.mux.Unlock() + + c.timezone = l +} + +// MustAdd is similar to Add() but panic on failure. +func (c *Cron) MustAdd(jobId string, cronExpr string, run func()) { + if err := c.Add(jobId, cronExpr, run); err != nil { + panic(err) + } +} + +// Add registers a single cron job. +// +// If there is already a job with the provided id, then the old job +// will be replaced with the new one. +// +// cronExpr is a regular cron expression, eg. "0 */3 * * *" (aka. at minute 0 past every 3rd hour). +// Check cron.NewSchedule() for the supported tokens. +func (c *Cron) Add(jobId string, cronExpr string, fn func()) error { + if fn == nil { + return errors.New("failed to add new cron job: fn must be non-nil function") + } + + schedule, err := NewSchedule(cronExpr) + if err != nil { + return fmt.Errorf("failed to add new cron job: %w", err) + } + + c.mux.Lock() + defer c.mux.Unlock() + + // remove previous (if any) + c.jobs = slices.DeleteFunc(c.jobs, func(j *CronJob) bool { + return j.Id() == jobId + }) + + // add new + c.jobs = append(c.jobs, &CronJob{ + id: jobId, + fn: fn, + schedule: schedule, + scheduler: c.scheduler, + }) + + return nil +} + +// Remove removes a single cron job by its id. +func (c *Cron) Remove(jobId string) { + c.mux.Lock() + defer c.mux.Unlock() + + if c.jobs == nil { + return // nothing to remove + } + + c.jobs = slices.DeleteFunc(c.jobs, func(j *CronJob) bool { + return j.Id() == jobId + }) +} + +// RemoveAll removes all registered cron jobs. +func (c *Cron) RemoveAll() { + c.mux.Lock() + defer c.mux.Unlock() + + c.jobs = []*CronJob{} +} + +// Total returns the current total number of registered cron jobs. +func (c *Cron) Total() int { + c.mux.RLock() + defer c.mux.RUnlock() + + return len(c.jobs) +} + +// Jobs returns a shallow copy of the currently registered cron jobs. +func (c *Cron) Jobs() []*CronJob { + c.mux.RLock() + defer c.mux.RUnlock() + + copy := make([]*CronJob, len(c.jobs)) + for i, j := range c.jobs { + copy[i] = j + } + + return copy +} + +// Stop stops the current cron ticker (if not already). +// +// You can resume the ticker by calling Start(). +func (c *Cron) Stop() { + c.mux.Lock() + defer c.mux.Unlock() + + if c.startTimer != nil { + c.startTimer.Stop() + c.startTimer = nil + } + + if c.ticker == nil { + return // already stopped + } + + c.tickerDone <- true + c.ticker.Stop() + c.ticker = nil +} + +// Start starts the cron ticker. +// +// Calling Start() on already started cron will restart the ticker. +func (c *Cron) Start() { + c.Stop() + + // delay the ticker to start at 00 of 1 c.interval duration + now := time.Now() + next := now.Add(c.interval).Truncate(c.interval) + delay := next.Sub(now) + + c.mux.Lock() + c.startTimer = time.AfterFunc(delay, func() { + c.mux.Lock() + c.ticker = time.NewTicker(c.interval) + c.mux.Unlock() + + // run immediately at 00 + c.runDue(time.Now()) + + // run after each tick + go func() { + for { + select { + case <-c.tickerDone: + return + case t := <-c.ticker.C: + c.runDue(t) + } + } + }() + }) + c.mux.Unlock() +} + +// HasStarted checks whether the current Cron ticker has been started. +func (c *Cron) HasStarted() bool { + c.mux.RLock() + defer c.mux.RUnlock() + + return c.ticker != nil +} + +// runDue runs all registered jobs that are scheduled for the provided time. +func (c *Cron) runDue(t time.Time) { + c.mux.RLock() + defer c.mux.RUnlock() + + moment := NewMoment(t.In(c.timezone)) + + for _, j := range c.jobs { + if j.schedule.IsDue(moment) { + go j.Run() + } + } +} + +//////////////////////////////////////////////// + +// CronJob defines a single registered cron job. +type CronJob struct { + fn func() + schedule *Schedule + id string + scheduler *goja_util.Scheduler +} + +// Id returns the cron job id. +func (j *CronJob) Id() string { + return j.id +} + +// Expression returns the plain cron job schedule expression. +func (j *CronJob) Expression() string { + return j.schedule.rawExpr +} + +// Run runs the cron job function. +func (j *CronJob) Run() { + if j.fn != nil { + j.scheduler.ScheduleAsync(func() error { + j.fn() + return nil + }) + } +} + +// MarshalJSON implements [json.Marshaler] and export the current +// jobs data into valid JSON. +func (j CronJob) MarshalJSON() ([]byte, error) { + plain := struct { + Id string `json:"id"` + Expression string `json:"expression"` + }{ + Id: j.Id(), + Expression: j.Expression(), + } + + return json.Marshal(plain) +} + +//////////////////////////////////////////////// + +// Moment represents a parsed single time moment. +type Moment struct { + Minute int `json:"minute"` + Hour int `json:"hour"` + Day int `json:"day"` + Month int `json:"month"` + DayOfWeek int `json:"dayOfWeek"` +} + +// NewMoment creates a new Moment from the specified time. +func NewMoment(t time.Time) *Moment { + return &Moment{ + Minute: t.Minute(), + Hour: t.Hour(), + Day: t.Day(), + Month: int(t.Month()), + DayOfWeek: int(t.Weekday()), + } +} + +// Schedule stores parsed information for each time component when a cron job should run. +type Schedule struct { + Minutes map[int]struct{} `json:"minutes"` + Hours map[int]struct{} `json:"hours"` + Days map[int]struct{} `json:"days"` + Months map[int]struct{} `json:"months"` + DaysOfWeek map[int]struct{} `json:"daysOfWeek"` + + rawExpr string +} + +// IsDue checks whether the provided Moment satisfies the current Schedule. +func (s *Schedule) IsDue(m *Moment) bool { + if _, ok := s.Minutes[m.Minute]; !ok { + return false + } + + if _, ok := s.Hours[m.Hour]; !ok { + return false + } + + if _, ok := s.Days[m.Day]; !ok { + return false + } + + if _, ok := s.DaysOfWeek[m.DayOfWeek]; !ok { + return false + } + + if _, ok := s.Months[m.Month]; !ok { + return false + } + + return true +} + +var macros = map[string]string{ + "@yearly": "0 0 1 1 *", + "@annually": "0 0 1 1 *", + "@monthly": "0 0 1 * *", + "@weekly": "0 0 * * 0", + "@daily": "0 0 * * *", + "@midnight": "0 0 * * *", + "@hourly": "0 * * * *", + "@30min": "*/30 * * * *", + "@15min": "*/15 * * * *", + "@10min": "*/10 * * * *", + "@5min": "*/5 * * * *", +} + +// NewSchedule creates a new Schedule from a cron expression. +// +// A cron expression could be a macro OR 5 segments separated by space, +// representing: minute, hour, day of the month, month and day of the week. +// +// The following segment formats are supported: +// - wildcard: * +// - range: 1-30 +// - step: */n or 1-30/n +// - list: 1,2,3,10-20/n +// +// The following macros are supported: +// - @yearly (or @annually) +// - @monthly +// - @weekly +// - @daily (or @midnight) +// - @hourly +func NewSchedule(cronExpr string) (*Schedule, error) { + if v, ok := macros[cronExpr]; ok { + cronExpr = v + } + + segments := strings.Split(cronExpr, " ") + if len(segments) != 5 { + return nil, errors.New("invalid cron expression - must be a valid macro or to have exactly 5 space separated segments") + } + + minutes, err := parseCronSegment(segments[0], 0, 59) + if err != nil { + return nil, err + } + + hours, err := parseCronSegment(segments[1], 0, 23) + if err != nil { + return nil, err + } + + days, err := parseCronSegment(segments[2], 1, 31) + if err != nil { + return nil, err + } + + months, err := parseCronSegment(segments[3], 1, 12) + if err != nil { + return nil, err + } + + daysOfWeek, err := parseCronSegment(segments[4], 0, 6) + if err != nil { + return nil, err + } + + return &Schedule{ + Minutes: minutes, + Hours: hours, + Days: days, + Months: months, + DaysOfWeek: daysOfWeek, + rawExpr: cronExpr, + }, nil +} + +// parseCronSegment parses a single cron expression segment and +// returns its time schedule slots. +func parseCronSegment(segment string, min int, max int) (map[int]struct{}, error) { + slots := map[int]struct{}{} + + list := strings.Split(segment, ",") + for _, p := range list { + stepParts := strings.Split(p, "/") + + // step (*/n, 1-30/n) + var step int + switch len(stepParts) { + case 1: + step = 1 + case 2: + parsedStep, err := strconv.Atoi(stepParts[1]) + if err != nil { + return nil, err + } + if parsedStep < 1 || parsedStep > max { + return nil, fmt.Errorf("invalid segment step boundary - the step must be between 1 and the %d", max) + } + step = parsedStep + default: + return nil, errors.New("invalid segment step format - must be in the format */n or 1-30/n") + } + + // find the min and max range of the segment part + var rangeMin, rangeMax int + if stepParts[0] == "*" { + rangeMin = min + rangeMax = max + } else { + // single digit (1) or range (1-30) + rangeParts := strings.Split(stepParts[0], "-") + switch len(rangeParts) { + case 1: + if step != 1 { + return nil, errors.New("invalid segement step - step > 1 could be used only with the wildcard or range format") + } + parsed, err := strconv.Atoi(rangeParts[0]) + if err != nil { + return nil, err + } + if parsed < min || parsed > max { + return nil, errors.New("invalid segment value - must be between the min and max of the segment") + } + rangeMin = parsed + rangeMax = rangeMin + case 2: + parsedMin, err := strconv.Atoi(rangeParts[0]) + if err != nil { + return nil, err + } + if parsedMin < min || parsedMin > max { + return nil, fmt.Errorf("invalid segment range minimum - must be between %d and %d", min, max) + } + rangeMin = parsedMin + + parsedMax, err := strconv.Atoi(rangeParts[1]) + if err != nil { + return nil, err + } + if parsedMax < parsedMin || parsedMax > max { + return nil, fmt.Errorf("invalid segment range maximum - must be between %d and %d", rangeMin, max) + } + rangeMax = parsedMax + default: + return nil, errors.New("invalid segment range format - the range must have 1 or 2 parts") + } + } + + // fill the slots + for i := rangeMin; i <= rangeMax; i += step { + slots[i] = struct{}{} + } + } + + return slots, nil +} diff --git a/seanime-2.9.10/internal/plugin/database.go b/seanime-2.9.10/internal/plugin/database.go new file mode 100644 index 0000000..a9d8576 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/database.go @@ -0,0 +1,513 @@ +package plugin + +import ( + "errors" + "seanime/internal/database/db" + "seanime/internal/database/db_bridge" + "seanime/internal/database/models" + "seanime/internal/events" + "seanime/internal/extension" + "seanime/internal/library/anime" + util "seanime/internal/util" + "time" + + "github.com/dop251/goja" + "github.com/rs/zerolog" + "gorm.io/gorm" +) + +type Database struct { + ctx *AppContextImpl + logger *zerolog.Logger + ext *extension.Extension +} + +// BindDatabase binds the database module to the Goja runtime. +// Permissions needed: databases +func (a *AppContextImpl) BindDatabase(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension) { + dbLogger := logger.With().Str("id", ext.ID).Logger() + db := &Database{ + ctx: a, + logger: &dbLogger, + ext: ext, + } + dbObj := vm.NewObject() + + // Local files + localFilesObj := vm.NewObject() + _ = localFilesObj.Set("getAll", db.getAllLocalFiles) + _ = localFilesObj.Set("findBy", db.findLocalFilesBy) + _ = localFilesObj.Set("save", db.saveLocalFiles) + _ = localFilesObj.Set("insert", db.insertLocalFiles) + _ = dbObj.Set("localFiles", localFilesObj) + + // Anilist + anilistObj := vm.NewObject() + _ = anilistObj.Set("getToken", db.getAnilistToken) + _ = anilistObj.Set("getUsername", db.getAnilistUsername) + _ = dbObj.Set("anilist", anilistObj) + + // Auto downloader rules + autoDownloaderRulesObj := vm.NewObject() + _ = autoDownloaderRulesObj.Set("getAll", db.getAllAutoDownloaderRules) + _ = autoDownloaderRulesObj.Set("get", db.getAutoDownloaderRule) + _ = autoDownloaderRulesObj.Set("getByMediaId", db.getAutoDownloaderRulesByMediaId) + _ = autoDownloaderRulesObj.Set("update", db.updateAutoDownloaderRule) + _ = autoDownloaderRulesObj.Set("insert", db.insertAutoDownloaderRule) + _ = autoDownloaderRulesObj.Set("remove", db.deleteAutoDownloaderRule) + _ = dbObj.Set("autoDownloaderRules", autoDownloaderRulesObj) + + // Auto downloader items + autoDownloaderItemsObj := vm.NewObject() + _ = autoDownloaderItemsObj.Set("getAll", db.getAllAutoDownloaderItems) + _ = autoDownloaderItemsObj.Set("get", db.getAutoDownloaderItem) + _ = autoDownloaderItemsObj.Set("getByMediaId", db.getAutoDownloaderItemsByMediaId) + _ = autoDownloaderItemsObj.Set("insert", db.insertAutoDownloaderItem) + _ = autoDownloaderItemsObj.Set("remove", db.deleteAutoDownloaderItem) + _ = dbObj.Set("autoDownloaderItems", autoDownloaderItemsObj) + + // Silenced media entries + silencedMediaEntriesObj := vm.NewObject() + _ = silencedMediaEntriesObj.Set("getAllIds", db.getAllSilencedMediaEntryIds) + _ = silencedMediaEntriesObj.Set("isSilenced", db.isSilenced) + _ = silencedMediaEntriesObj.Set("setSilenced", db.setSilenced) + _ = dbObj.Set("silencedMediaEntries", silencedMediaEntriesObj) + + // Media fillers + mediaFillersObj := vm.NewObject() + _ = mediaFillersObj.Set("getAll", db.getAllMediaFillers) + _ = mediaFillersObj.Set("get", db.getMediaFiller) + _ = mediaFillersObj.Set("insert", db.insertMediaFiller) + _ = mediaFillersObj.Set("remove", db.deleteMediaFiller) + _ = dbObj.Set("mediaFillers", mediaFillersObj) + + _ = vm.Set("$database", dbObj) +} + +func (d *Database) getAllLocalFiles() ([]*anime.LocalFile, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + files, _, err := db_bridge.GetLocalFiles(db) + if err != nil { + return nil, err + } + + return files, nil +} + +func (d *Database) findLocalFilesBy(filterFn func(*anime.LocalFile) bool) ([]*anime.LocalFile, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + files, _, err := db_bridge.GetLocalFiles(db) + if err != nil { + return nil, err + } + + filteredFiles := make([]*anime.LocalFile, 0) + for _, file := range files { + if filterFn(file) { + filteredFiles = append(filteredFiles, file) + } + } + return filteredFiles, nil +} + +func (d *Database) saveLocalFiles(filesToSave []*anime.LocalFile) error { + db, ok := d.ctx.database.Get() + if !ok { + return errors.New("database not initialized") + } + + lfs, lfsId, err := db_bridge.GetLocalFiles(db) + if err != nil { + return err + } + + filesToSaveMap := make(map[string]*anime.LocalFile) + for _, file := range filesToSave { + filesToSaveMap[util.NormalizePath(file.Path)] = file + } + + for i := range lfs { + if fileToSave, ok := filesToSaveMap[util.NormalizePath(lfs[i].Path)]; !ok { + lfs[i] = fileToSave + } + } + + _, err = db_bridge.SaveLocalFiles(db, lfsId, lfs) + if err != nil { + return err + } + + ws, ok := d.ctx.wsEventManager.Get() + if ok { + ws.SendEvent(events.InvalidateQueries, []string{events.GetLocalFilesEndpoint, events.GetAnimeEntryEndpoint, events.GetLibraryCollectionEndpoint, events.GetMissingEpisodesEndpoint}) + } + + return nil +} + +func (d *Database) insertLocalFiles(files []*anime.LocalFile) ([]*anime.LocalFile, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + lfs, err := db_bridge.InsertLocalFiles(db, files) + if err != nil { + return nil, err + } + + ws, ok := d.ctx.wsEventManager.Get() + if ok { + ws.SendEvent(events.InvalidateQueries, []string{events.GetLocalFilesEndpoint, events.GetAnimeEntryEndpoint, events.GetLibraryCollectionEndpoint, events.GetMissingEpisodesEndpoint}) + } + + return lfs, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (d *Database) getAnilistToken() (string, error) { + if d.ext.Plugin == nil || len(d.ext.Plugin.Permissions.Scopes) == 0 { + return "", errors.New("permission denied") + } + if !util.Contains(d.ext.Plugin.Permissions.Scopes, extension.PluginPermissionAnilistToken) { + return "", errors.New("permission denied") + } + db, ok := d.ctx.database.Get() + if !ok { + return "", errors.New("database not initialized") + } + return db.GetAnilistToken(), nil +} + +func (d *Database) getAnilistUsername() (string, error) { + db, ok := d.ctx.database.Get() + if !ok { + return "", errors.New("database not initialized") + } + + acc, err := db.GetAccount() + if err != nil { + return "", nil + } + + return acc.Username, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (d *Database) getAllAutoDownloaderRules() ([]*anime.AutoDownloaderRule, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + rules, err := db_bridge.GetAutoDownloaderRules(db) + if err != nil { + return nil, err + } + + return rules, nil +} + +func (d *Database) getAutoDownloaderRule(id uint) (*anime.AutoDownloaderRule, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + rule, err := db_bridge.GetAutoDownloaderRule(db, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + return rule, nil +} + +func (d *Database) getAutoDownloaderRulesByMediaId(mediaId int) ([]*anime.AutoDownloaderRule, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + rules := db_bridge.GetAutoDownloaderRulesByMediaId(db, mediaId) + + return rules, nil +} + +func (d *Database) updateAutoDownloaderRule(id uint, rule *anime.AutoDownloaderRule) error { + db, ok := d.ctx.database.Get() + if !ok { + return errors.New("database not initialized") + } + + err := db_bridge.UpdateAutoDownloaderRule(db, id, rule) + if err != nil { + return err + } + + ws, ok := d.ctx.wsEventManager.Get() + if ok { + ws.SendEvent(events.InvalidateQueries, []string{events.GetAutoDownloaderRulesEndpoint, events.GetAutoDownloaderItemsEndpoint, events.GetAnimeEntryEndpoint}) + } + + return nil +} + +func (d *Database) insertAutoDownloaderRule(rule *anime.AutoDownloaderRule) error { + db, ok := d.ctx.database.Get() + if !ok { + return errors.New("database not initialized") + } + + err := db_bridge.InsertAutoDownloaderRule(db, rule) + if err != nil { + return err + } + + ws, ok := d.ctx.wsEventManager.Get() + if ok { + ws.SendEvent(events.InvalidateQueries, []string{events.GetAutoDownloaderRulesEndpoint, events.GetAutoDownloaderRulesByAnimeEndpoint, events.GetAutoDownloaderRuleEndpoint, events.GetAutoDownloaderItemsEndpoint, events.GetAnimeEntryEndpoint}) + } + + return nil +} + +func (d *Database) deleteAutoDownloaderRule(id uint) error { + db, ok := d.ctx.database.Get() + if !ok { + return errors.New("database not initialized") + } + + err := db_bridge.DeleteAutoDownloaderRule(db, id) + if err != nil { + return err + } + + ws, ok := d.ctx.wsEventManager.Get() + if ok { + ws.SendEvent(events.InvalidateQueries, []string{events.GetAutoDownloaderRulesEndpoint, events.GetAutoDownloaderRulesByAnimeEndpoint, events.GetAutoDownloaderRuleEndpoint, events.GetAutoDownloaderItemsEndpoint, events.GetAnimeEntryEndpoint}) + } + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (d *Database) getAllAutoDownloaderItems() ([]*models.AutoDownloaderItem, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + items, err := db.GetAutoDownloaderItems() + if err != nil { + return nil, err + } + return items, nil +} + +func (d *Database) getAutoDownloaderItem(id uint) (*models.AutoDownloaderItem, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + item, err := db.GetAutoDownloaderItem(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return item, nil +} + +func (d *Database) getAutoDownloaderItemsByMediaId(mediaId int) ([]*models.AutoDownloaderItem, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + items, err := db.GetAutoDownloaderItemByMediaId(mediaId) + if err != nil { + return nil, err + } + return items, nil +} + +func (d *Database) insertAutoDownloaderItem(item *models.AutoDownloaderItem) error { + db, ok := d.ctx.database.Get() + if !ok { + return errors.New("database not initialized") + } + + err := db.InsertAutoDownloaderItem(item) + if err != nil { + return err + } + + ws, ok := d.ctx.wsEventManager.Get() + if ok { + ws.SendEvent(events.InvalidateQueries, []string{events.GetAutoDownloaderRulesEndpoint, events.GetAutoDownloaderRulesByAnimeEndpoint, events.GetAutoDownloaderRuleEndpoint, events.GetAutoDownloaderItemsEndpoint, events.GetAnimeEntryEndpoint}) + } + + return nil +} + +func (d *Database) deleteAutoDownloaderItem(id uint) error { + db, ok := d.ctx.database.Get() + if !ok { + return errors.New("database not initialized") + } + + err := db.DeleteAutoDownloaderItem(id) + if err != nil { + return err + } + + ws, ok := d.ctx.wsEventManager.Get() + if ok { + ws.SendEvent(events.InvalidateQueries, []string{events.GetAutoDownloaderRulesEndpoint, events.GetAutoDownloaderRulesByAnimeEndpoint, events.GetAutoDownloaderRuleEndpoint, events.GetAutoDownloaderItemsEndpoint, events.GetAnimeEntryEndpoint}) + } + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (d *Database) getAllSilencedMediaEntryIds() ([]int, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + ids, err := db.GetSilencedMediaEntryIds() + if err != nil { + return nil, err + } + + return ids, nil +} + +func (d *Database) isSilenced(mediaId int) (bool, error) { + db, ok := d.ctx.database.Get() + if !ok { + return false, errors.New("database not initialized") + } + + entry, err := db.GetSilencedMediaEntry(uint(mediaId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + + return entry != nil, nil +} + +func (d *Database) setSilenced(mediaId int, silenced bool) error { + db, ok := d.ctx.database.Get() + if !ok { + return errors.New("database not initialized") + } + + if silenced { + err := db.InsertSilencedMediaEntry(uint(mediaId)) + if err != nil { + return nil + } + } else { + err := db.DeleteSilencedMediaEntry(uint(mediaId)) + if err != nil { + return nil + } + } + + ws, ok := d.ctx.wsEventManager.Get() + if ok { + ws.SendEvent(events.InvalidateQueries, []string{events.GetAnimeEntrySilenceStatusEndpoint}) + } + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (d *Database) getAllMediaFillers() (map[int]*db.MediaFillerItem, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + fillers, err := db.GetCachedMediaFillers() + if err != nil { + return nil, err + } + + return fillers, nil +} + +func (d *Database) getMediaFiller(mediaId int) (*db.MediaFillerItem, error) { + db, ok := d.ctx.database.Get() + if !ok { + return nil, errors.New("database not initialized") + } + + filler, ok := db.GetMediaFillerItem(mediaId) + if !ok { + return nil, nil + } + + return filler, nil +} + +func (d *Database) insertMediaFiller(provider string, mediaId int, slug string, fillerEpisodes []string) error { + db, ok := d.ctx.database.Get() + if !ok { + return errors.New("database not initialized") + } + + err := db.InsertMediaFiller(provider, mediaId, slug, time.Now(), fillerEpisodes) + if err != nil { + return err + } + + ws, ok := d.ctx.wsEventManager.Get() + if ok { + ws.SendEvent(events.InvalidateQueries, []string{events.GetAnimeEntryEndpoint}) + } + + return nil +} + +func (d *Database) deleteMediaFiller(mediaId int) error { + db, ok := d.ctx.database.Get() + if !ok { + return errors.New("database not initialized") + } + + err := db.DeleteMediaFiller(mediaId) + if err != nil { + return err + } + + ws, ok := d.ctx.wsEventManager.Get() + if ok { + ws.SendEvent(events.InvalidateQueries, []string{events.GetAnimeEntryEndpoint}) + } + + return nil +} diff --git a/seanime-2.9.10/internal/plugin/discord.go b/seanime-2.9.10/internal/plugin/discord.go new file mode 100644 index 0000000..900997e --- /dev/null +++ b/seanime-2.9.10/internal/plugin/discord.go @@ -0,0 +1,57 @@ +package plugin + +import ( + discordrpc_presence "seanime/internal/discordrpc/presence" + "seanime/internal/extension" + "seanime/internal/goja/goja_bindings" + goja_util "seanime/internal/util/goja" + + "github.com/dop251/goja" + "github.com/rs/zerolog" +) + +func (a *AppContextImpl) BindDiscordToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + + discordObj := vm.NewObject() + _ = discordObj.Set("setMangaActivity", func(opts discordrpc_presence.MangaActivity) goja.Value { + presence, ok := a.discordPresence.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "discord rpc client not set") + } + presence.SetMangaActivity(&opts) + return goja.Undefined() + }) + _ = discordObj.Set("setAnimeActivity", func(opts discordrpc_presence.AnimeActivity) goja.Value { + presence, ok := a.discordPresence.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "discord rpc client not set") + } + presence.SetAnimeActivity(&opts) + return goja.Undefined() + }) + _ = discordObj.Set("updateAnimeActivity", func(progress int, duration int, paused bool) goja.Value { + presence, ok := a.discordPresence.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "discord rpc client not set") + } + presence.UpdateAnimeActivity(progress, duration, paused) + return goja.Undefined() + }) + _ = discordObj.Set("setLegacyAnimeActivity", func(opts discordrpc_presence.LegacyAnimeActivity) goja.Value { + presence, ok := a.discordPresence.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "discord rpc client not set") + } + presence.LegacySetAnimeActivity(&opts) + return goja.Undefined() + }) + _ = discordObj.Set("cancelActivity", func() goja.Value { + presence, ok := a.discordPresence.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "discord rpc client not set") + } + presence.Close() + return goja.Undefined() + }) + _ = obj.Set("discord", discordObj) +} diff --git a/seanime-2.9.10/internal/plugin/downloader.go b/seanime-2.9.10/internal/plugin/downloader.go new file mode 100644 index 0000000..5a946f2 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/downloader.go @@ -0,0 +1,328 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "seanime/internal/extension" + goja_util "seanime/internal/util/goja" + "sync" + "time" + + "github.com/dop251/goja" + "github.com/google/uuid" + "github.com/rs/zerolog" +) + +type DownloadStatus string + +const ( + DownloadStatusDownloading DownloadStatus = "downloading" + DownloadStatusCompleted DownloadStatus = "completed" + DownloadStatusCancelled DownloadStatus = "cancelled" + DownloadStatusError DownloadStatus = "error" +) + +type DownloadProgress struct { + ID string `json:"id"` + URL string `json:"url"` + Destination string `json:"destination"` + TotalBytes int64 `json:"totalBytes"` + TotalSize int64 `json:"totalSize"` + Speed int64 `json:"speed"` + Percentage float64 `json:"percentage"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + LastUpdateTime time.Time `json:"lastUpdate"` + StartTime time.Time `json:"startTime"` + + lastBytes int64 `json:"-"` +} + +// IsFinished returns true if the download has completed, errored, or been cancelled +func (p *DownloadProgress) IsFinished() bool { + return p.Status == string(DownloadStatusCompleted) || p.Status == string(DownloadStatusCancelled) || p.Status == string(DownloadStatusError) +} + +type progressSubscriber struct { + ID string + Channel chan map[string]interface{} + Cancel context.CancelFunc + LastSent time.Time +} + +func (a *AppContextImpl) BindDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + downloadObj := vm.NewObject() + + progressMap := sync.Map{} + downloadCancels := sync.Map{} + progressSubscribers := sync.Map{} + + _ = downloadObj.Set("watch", func(downloadID string, callback goja.Callable) goja.Value { + // Create cancellable context for the subscriber + ctx, cancel := context.WithCancel(context.Background()) + + // Create a new subscriber + subscriber := &progressSubscriber{ + ID: downloadID, + Channel: make(chan map[string]interface{}, 1), + Cancel: cancel, + LastSent: time.Now(), + } + + // Store the subscriber + if existing, ok := progressSubscribers.Load(downloadID); ok { + // Cancel existing subscriber if any + existing.(*progressSubscriber).Cancel() + } + progressSubscribers.Store(downloadID, subscriber) + + // Start watching for progress updates + go func() { + defer func() { + close(subscriber.Channel) + progressSubscribers.Delete(downloadID) + }() + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + // If download is complete/cancelled/errored, send one last update and stop + if progress, ok := progressMap.Load(downloadID); ok { + p := progress.(*DownloadProgress) + scheduler.ScheduleAsync(func() error { + p.Speed = 0 + callback(goja.Undefined(), vm.ToValue(p)) + return nil + }) + } + return + case <-ticker.C: + if progress, ok := progressMap.Load(downloadID); ok { + p := progress.(*DownloadProgress) + scheduler.ScheduleAsync(func() error { + callback(goja.Undefined(), vm.ToValue(p)) + return nil + }) + // If download is complete/cancelled/errored, send one last update and stop + if p.IsFinished() { + return + } + } else { + // Download not found or already completed + return + } + } + } + }() + + // Return a function to cancel the watch + return vm.ToValue(func() { + if subscriber, ok := progressSubscribers.Load(downloadID); ok { + subscriber.(*progressSubscriber).Cancel() + } + }) + }) + + _ = downloadObj.Set("download", func(url string, destination string, options map[string]interface{}) (string, error) { + if !a.isAllowedPath(ext, destination, AllowPathWrite) { + return "", ErrPathNotAuthorized + } + + // Generate unique download ID + downloadID := uuid.New().String() + + // Create context with optional timeout + var ctx context.Context + var cancel context.CancelFunc + if timeout, ok := options["timeout"].(float64); ok { + ctx, cancel = context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + } else { + ctx, cancel = context.WithCancel(context.Background()) + } + downloadCancels.Store(downloadID, cancel) + + logger.Trace().Str("url", url).Str("destination", destination).Msg("plugin: Starting download") + + // Initialize progress tracking + now := time.Now() + progress := &DownloadProgress{ + ID: downloadID, + URL: url, + Destination: destination, + Status: string(DownloadStatusDownloading), + LastUpdateTime: now, + StartTime: now, + } + progressMap.Store(downloadID, progress) + + // Start download in a goroutine + go func() { + defer downloadCancels.Delete(downloadID) + defer func() { + // Clean up subscriber if it exists + if subscriber, ok := progressSubscribers.Load(downloadID); ok { + subscriber.(*progressSubscriber).Cancel() + } + }() + + // Create request + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + progress.Status = string(DownloadStatusError) + progress.Error = err.Error() + return + } + + // Add headers if provided + if headers, ok := options["headers"].(map[string]interface{}); ok { + for k, v := range headers { + if strVal, ok := v.(string); ok { + req.Header.Set(k, strVal) + } + } + } + + // Execute request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + progress.Status = string(DownloadStatusError) + progress.Error = err.Error() + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + progress.Status = string(DownloadStatusError) + progress.Error = fmt.Sprintf("server returned status code %d", resp.StatusCode) + return + } + + // Update progress with content length + progress.TotalSize = resp.ContentLength + + // Create destination directory if it doesn't exist + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + progress.Status = string(DownloadStatusError) + progress.Error = err.Error() + return + } + + // Create destination file + file, err := os.Create(destination) + if err != nil { + progress.Status = string(DownloadStatusError) + progress.Error = err.Error() + return + } + defer file.Close() + + // Create buffer for copying + buffer := make([]byte, 32*1024) + lastUpdateTime := now + + logger.Trace().Str("url", url).Str("destination", destination).Msg("plugin: Download started") + + for { + select { + case <-ctx.Done(): + progress.Status = string(DownloadStatusCancelled) + logger.Trace().Str("url", url).Str("destination", destination).Msg("plugin: Download cancelled") + return + default: + n, err := resp.Body.Read(buffer) + if n > 0 { + _, writeErr := file.Write(buffer[:n]) + if writeErr != nil { + progress.Status = string(DownloadStatusError) + progress.Error = writeErr.Error() + return + } + + progress.TotalBytes += int64(n) + if progress.TotalSize > 0 { + progress.Percentage = float64(progress.TotalBytes) / float64(progress.TotalSize) * 100 + } + + // Update speed every 500ms + if time.Since(lastUpdateTime) > 500*time.Millisecond { + elapsed := time.Since(lastUpdateTime).Seconds() + bytesInPeriod := progress.TotalBytes - progress.lastBytes + progress.Speed = int64(float64(bytesInPeriod) / elapsed) + progress.lastBytes = progress.TotalBytes + progress.LastUpdateTime = time.Now() + lastUpdateTime = time.Now() + } + } + + if err != nil { + if err == io.EOF { + progress.Status = string(DownloadStatusCompleted) + logger.Trace().Str("url", url).Str("destination", destination).Msg("plugin: Download completed") + return + } + if errors.Is(err, context.Canceled) { + progress.Status = string(DownloadStatusCancelled) + logger.Trace().Str("url", url).Str("destination", destination).Msg("plugin: Download cancelled") + return + } + progress.Status = string(DownloadStatusError) + progress.Error = err.Error() + logger.Error().Err(err).Str("url", url).Str("destination", destination).Msg("plugin: Download error") + return + } + } + } + }() + + return downloadID, nil + }) + + _ = downloadObj.Set("getProgress", func(downloadID string) *DownloadProgress { + if progress, ok := progressMap.Load(downloadID); ok { + return progress.(*DownloadProgress) + } + return nil + }) + + _ = downloadObj.Set("listDownloads", func() []*DownloadProgress { + downloads := make([]*DownloadProgress, 0) + progressMap.Range(func(key, value interface{}) bool { + downloads = append(downloads, value.(*DownloadProgress)) + return true + }) + return downloads + }) + + _ = downloadObj.Set("cancel", func(downloadID string) { + if cancel, ok := downloadCancels.Load(downloadID); ok { + if cancel == nil { + return + } + logger.Trace().Str("downloadID", downloadID).Msg("plugin: Cancelling download") + cancel.(context.CancelFunc)() + } + }) + + _ = downloadObj.Set("cancelAll", func() { + logger.Trace().Msg("plugin: Cancelling all downloads") + downloadCancels.Range(func(key, value interface{}) bool { + if value == nil { + return true + } + logger.Trace().Str("downloadID", key.(string)).Msg("plugin: Cancelling download") + value.(context.CancelFunc)() + return true + }) + }) + + _ = obj.Set("downloader", downloadObj) +} diff --git a/seanime-2.9.10/internal/plugin/manga.go b/seanime-2.9.10/internal/plugin/manga.go new file mode 100644 index 0000000..5918071 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/manga.go @@ -0,0 +1,187 @@ +package plugin + +import ( + "context" + "errors" + "seanime/internal/extension" + "seanime/internal/goja/goja_bindings" + "seanime/internal/manga" + goja_util "seanime/internal/util/goja" + + "github.com/dop251/goja" + "github.com/rs/zerolog" +) + +type Manga struct { + ctx *AppContextImpl + vm *goja.Runtime + logger *zerolog.Logger + ext *extension.Extension + scheduler *goja_util.Scheduler +} + +func (a *AppContextImpl) BindMangaToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + m := &Manga{ + ctx: a, + vm: vm, + logger: logger, + ext: ext, + scheduler: scheduler, + } + + mangaObj := vm.NewObject() + + // Get downloaded chapter containers + _ = mangaObj.Set("getDownloadedChapters", m.getDownloadedChapterContainers) + _ = mangaObj.Set("getCollection", m.getCollection) + _ = mangaObj.Set("refreshChapters", m.refreshChapterContainers) + _ = mangaObj.Set("emptyCache", m.emptyCache) + _ = mangaObj.Set("getChapterContainer", m.getChapterContainer) + _ = mangaObj.Set("getProviders", m.getProviders) + _ = obj.Set("manga", mangaObj) +} + +func (m *Manga) getProviders() (map[string]string, error) { + mangaRepo, ok := m.ctx.mangaRepository.Get() + if !ok { + return nil, errors.New("manga repository not found") + } + providers := make(map[string]string) + extension.RangeExtensions(mangaRepo.GetProviderExtensionBank(), func(id string, ext extension.MangaProviderExtension) bool { + providers[id] = ext.GetName() + return true + }) + return providers, nil +} + +type GetChapterContainerOptions struct { + MediaId int + Provider string + Titles []*string + Year int +} + +func (m *Manga) getChapterContainer(opts *GetChapterContainerOptions) goja.Value { + promise, resolve, reject := m.vm.NewPromise() + + mangaRepo, ok := m.ctx.mangaRepository.Get() + if !ok { + // reject(goja_bindings.NewErrorString(m.vm, "manga repository not set")) + // return m.vm.ToValue(promise) + goja_bindings.PanicThrowErrorString(m.vm, "manga repository not set") + } + + go func() { + ret, err := mangaRepo.GetMangaChapterContainer(&manga.GetMangaChapterContainerOptions{ + MediaId: opts.MediaId, + Provider: opts.Provider, + Titles: opts.Titles, + Year: opts.Year, + }) + m.scheduler.ScheduleAsync(func() error { + if err != nil { + reject(err.Error()) + } else { + resolve(ret) + } + return nil + }) + }() + + return m.vm.ToValue(promise) +} + +func (m *Manga) getDownloadedChapterContainers() ([]*manga.ChapterContainer, error) { + mangaRepo, ok := m.ctx.mangaRepository.Get() + if !ok { + return nil, errors.New("manga repository not found") + } + anilistPlatform, foundAnilistPlatform := m.ctx.anilistPlatform.Get() + if !foundAnilistPlatform { + return nil, errors.New("anilist platform not found") + } + + mangaCollection, err := anilistPlatform.GetMangaCollection(context.Background(), false) + if err != nil { + return nil, err + } + return mangaRepo.GetDownloadedChapterContainers(mangaCollection) +} + +func (m *Manga) getCollection() (*manga.Collection, error) { + anilistPlatform, foundAnilistPlatform := m.ctx.anilistPlatform.Get() + if !foundAnilistPlatform { + return nil, errors.New("anilist platform not found") + } + + mangaCollection, err := anilistPlatform.GetMangaCollection(context.Background(), false) + if err != nil { + return nil, err + } + return manga.NewCollection(&manga.NewCollectionOptions{ + MangaCollection: mangaCollection, + Platform: anilistPlatform, + }) +} + +func (m *Manga) refreshChapterContainers(selectedProviderMap map[int]string) goja.Value { + promise, resolve, reject := m.vm.NewPromise() + + mangaRepo, ok := m.ctx.mangaRepository.Get() + if !ok { + jsErr := m.vm.NewGoError(errors.New("manga repository not found")) + _ = reject(jsErr) + return m.vm.ToValue(promise) + } + anilistPlatform, foundAnilistPlatform := m.ctx.anilistPlatform.Get() + if !foundAnilistPlatform { + jsErr := m.vm.NewGoError(errors.New("anilist platform not found")) + _ = reject(jsErr) + return m.vm.ToValue(promise) + } + + mangaCollection, err := anilistPlatform.GetMangaCollection(context.Background(), false) + if err != nil { + reject(err.Error()) + return m.vm.ToValue(promise) + } + + go func() { + err := mangaRepo.RefreshChapterContainers(mangaCollection, selectedProviderMap) + m.scheduler.ScheduleAsync(func() error { + if err != nil { + reject(err.Error()) + } else { + resolve(nil) + } + return nil + }) + }() + + return m.vm.ToValue(promise) +} + +func (m *Manga) emptyCache(mediaId int) goja.Value { + promise, resolve, reject := m.vm.NewPromise() + + mangaRepo, ok := m.ctx.mangaRepository.Get() + if !ok { + // reject(goja_bindings.NewErrorString(m.vm, "manga repository not found")) + // return m.vm.ToValue(promise) + goja_bindings.PanicThrowErrorString(m.vm, "manga repository not found") + } + + go func() { + err := mangaRepo.EmptyMangaCache(mediaId) + m.scheduler.ScheduleAsync(func() error { + if err != nil { + reject(err.Error()) + } else { + resolve(nil) + } + return nil + }) + }() + + return m.vm.ToValue(promise) +} diff --git a/seanime-2.9.10/internal/plugin/other.go b/seanime-2.9.10/internal/plugin/other.go new file mode 100644 index 0000000..989a9af --- /dev/null +++ b/seanime-2.9.10/internal/plugin/other.go @@ -0,0 +1,347 @@ +package plugin + +import ( + "seanime/internal/events" + "seanime/internal/extension" + "seanime/internal/goja/goja_bindings" + "seanime/internal/library/anime" + "seanime/internal/onlinestream" + goja_util "seanime/internal/util/goja" + "strconv" + + "github.com/dop251/goja" + "github.com/rs/zerolog" +) + +// BindTorrentstreamToContextObj binds 'torrentstream' to the UI context object +func (a *AppContextImpl) BindTorrentstreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + +} + +// BindOnlinestreamToContextObj binds 'onlinestream' to the UI context object +func (a *AppContextImpl) BindOnlinestreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + +} + +// BindMediastreamToContextObj binds 'mediastream' to the UI context object +func (a *AppContextImpl) BindMediastreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + +} + +// BindTorrentClientToContextObj binds 'torrentClient' to the UI context object +func (a *AppContextImpl) BindTorrentClientToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + + torrentClientObj := vm.NewObject() + _ = torrentClientObj.Set("getTorrents", func() goja.Value { + promise, resolve, reject := vm.NewPromise() + + torrentClient, ok := a.torrentClientRepository.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "torrentClient not set") + } + + go func() { + torrents, err := torrentClient.GetList() + scheduler.ScheduleAsync(func() error { + if err != nil { + reject(goja_bindings.NewErrorString(vm, "error getting torrents: "+err.Error())) + return nil + } + resolve(vm.ToValue(torrents)) + return nil + }) + }() + + return vm.ToValue(promise) + }) + + _ = torrentClientObj.Set("getActiveTorrents", func() goja.Value { + promise, resolve, reject := vm.NewPromise() + + torrentClient, ok := a.torrentClientRepository.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "torrentClient not set") + } + + go func() { + activeTorrents, err := torrentClient.GetActiveTorrents() + scheduler.ScheduleAsync(func() error { + if err != nil { + reject(goja_bindings.NewErrorString(vm, "error getting active torrents: "+err.Error())) + return nil + } + resolve(vm.ToValue(activeTorrents)) + return nil + }) + }() + + return vm.ToValue(promise) + }) + + _ = torrentClientObj.Set("addMagnets", func(magnets []string, dest string) goja.Value { + promise, resolve, reject := vm.NewPromise() + + torrentClient, ok := a.torrentClientRepository.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "torrentClient not set") + } + + go func() { + err := torrentClient.AddMagnets(magnets, dest) + scheduler.ScheduleAsync(func() error { + if err != nil { + reject(goja_bindings.NewErrorString(vm, "error adding magnets: "+err.Error())) + return nil + } + resolve(goja.Undefined()) + return nil + }) + }() + + return vm.ToValue(promise) + }) + + _ = torrentClientObj.Set("removeTorrents", func(hashes []string) goja.Value { + promise, resolve, reject := vm.NewPromise() + + torrentClient, ok := a.torrentClientRepository.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "torrentClient not set") + } + + go func() { + err := torrentClient.RemoveTorrents(hashes) + scheduler.ScheduleAsync(func() error { + if err != nil { + reject(goja_bindings.NewErrorString(vm, "error removing torrents: "+err.Error())) + return nil + } + resolve(goja.Undefined()) + return nil + }) + }() + + return vm.ToValue(promise) + }) + + _ = torrentClientObj.Set("pauseTorrents", func(hashes []string) goja.Value { + promise, resolve, reject := vm.NewPromise() + + torrentClient, ok := a.torrentClientRepository.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "torrentClient not set") + } + + go func() { + err := torrentClient.PauseTorrents(hashes) + scheduler.ScheduleAsync(func() error { + if err != nil { + reject(goja_bindings.NewErrorString(vm, "error pausing torrents: "+err.Error())) + return nil + } + resolve(goja.Undefined()) + return nil + }) + }() + + return vm.ToValue(promise) + }) + + _ = torrentClientObj.Set("resumeTorrents", func(hashes []string) goja.Value { + promise, resolve, reject := vm.NewPromise() + + torrentClient, ok := a.torrentClientRepository.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "torrentClient not set") + } + + go func() { + err := torrentClient.ResumeTorrents(hashes) + scheduler.ScheduleAsync(func() error { + if err != nil { + reject(goja_bindings.NewErrorString(vm, "error resuming torrents: "+err.Error())) + return nil + } + resolve(goja.Undefined()) + return nil + }) + }() + + return vm.ToValue(promise) + }) + + _ = torrentClientObj.Set("deselectFiles", func(hash string, indices []int) goja.Value { + promise, resolve, reject := vm.NewPromise() + + torrentClient, ok := a.torrentClientRepository.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "torrentClient not set") + } + + go func() { + err := torrentClient.DeselectFiles(hash, indices) + scheduler.ScheduleAsync(func() error { + if err != nil { + reject(goja_bindings.NewErrorString(vm, "error deselecting files: "+err.Error())) + return nil + } + resolve(goja.Undefined()) + return nil + }) + }() + + return vm.ToValue(promise) + }) + + _ = torrentClientObj.Set("getFiles", func(hash string) goja.Value { + promise, resolve, reject := vm.NewPromise() + + torrentClient, ok := a.torrentClientRepository.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "torrentClient not set") + } + + go func() { + files, err := torrentClient.GetFiles(hash) + scheduler.ScheduleAsync(func() error { + if err != nil { + reject(goja_bindings.NewErrorString(vm, "error getting files: "+err.Error())) + return nil + } + resolve(vm.ToValue(files)) + return nil + }) + }() + + return vm.ToValue(promise) + }) + + _ = obj.Set("torrentClient", torrentClientObj) + +} + +// BindFillerManagerToContextObj binds 'fillerManager' to the UI context object +func (a *AppContextImpl) BindFillerManagerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + + fillerManagerObj := vm.NewObject() + _ = fillerManagerObj.Set("getFillerEpisodes", func(mediaId int) goja.Value { + fillerManager, ok := a.fillerManager.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "fillerManager not set") + } + fillerEpisodes, ok := fillerManager.GetFillerEpisodes(mediaId) + if !ok { + return goja.Undefined() + } + return vm.ToValue(fillerEpisodes) + }) + + _ = fillerManagerObj.Set("removeFillerData", func(mediaId int) goja.Value { + fillerManager, ok := a.fillerManager.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "fillerManager not set") + } + fillerManager.RemoveFillerData(mediaId) + return goja.Undefined() + }) + + _ = fillerManagerObj.Set("setFillerEpisodes", func(mediaId int, fillerEpisodes []string) goja.Value { + fillerManager, ok := a.fillerManager.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "fillerManager not set") + } + fillerManager.StoreFillerData("plugin", strconv.Itoa(mediaId), mediaId, fillerEpisodes) + return goja.Undefined() + }) + + _ = fillerManagerObj.Set("isEpisodeFiller", func(mediaId int, episodeNumber int) goja.Value { + fillerManager, ok := a.fillerManager.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "fillerManager not set") + } + return vm.ToValue(fillerManager.IsEpisodeFiller(mediaId, episodeNumber)) + }) + + _ = fillerManagerObj.Set("hydrateFillerData", func(e *anime.Entry) goja.Value { + fillerManager, ok := a.fillerManager.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "fillerManager not set") + } + fillerManager.HydrateFillerData(e) + return goja.Undefined() + }) + + _ = fillerManagerObj.Set("hydrateOnlinestreamFillerData", func(mId int, episodes []*onlinestream.Episode) goja.Value { + fillerManager, ok := a.fillerManager.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "fillerManager not set") + } + fillerManager.HydrateOnlinestreamFillerData(mId, episodes) + return goja.Undefined() + }) + + _ = obj.Set("fillerManager", fillerManagerObj) + +} + +// BindAutoDownloaderToContextObj binds 'autoDownloader' to the UI context object +func (a *AppContextImpl) BindAutoDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + + autoDownloaderObj := vm.NewObject() + _ = autoDownloaderObj.Set("run", func() goja.Value { + autoDownloader, ok := a.autoDownloader.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "autoDownloader not set") + } + autoDownloader.Run() + return goja.Undefined() + }) + _ = obj.Set("autoDownloader", autoDownloaderObj) +} + +// BindAutoScannerToContextObj binds 'autoScanner' to the UI context object +func (a *AppContextImpl) BindAutoScannerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + + autoScannerObj := vm.NewObject() + _ = autoScannerObj.Set("notify", func() goja.Value { + autoScanner, ok := a.autoScanner.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "autoScanner not set") + } + autoScanner.Notify() + return goja.Undefined() + }) + _ = obj.Set("autoScanner", autoScannerObj) + +} + +// BindFileCacherToContextObj binds 'fileCacher' to the UI context object +func (a *AppContextImpl) BindFileCacherToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + +} + +// BindExternalPlayerLinkToContextObj binds 'externalPlayerLink' to the UI context object +func (a *AppContextImpl) BindExternalPlayerLinkToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + + externalPlayerLinkObj := vm.NewObject() + _ = externalPlayerLinkObj.Set("open", func(url string, mediaId int, episodeNumber int, mediaTitle string) goja.Value { + wsEventManager, ok := a.wsEventManager.Get() + if !ok { + goja_bindings.PanicThrowErrorString(vm, "wsEventManager not set") + } + // Send the external player link + wsEventManager.SendEvent(events.ExternalPlayerOpenURL, struct { + Url string `json:"url"` + MediaId int `json:"mediaId"` + EpisodeNumber int `json:"episodeNumber"` + MediaTitle string `json:"mediaTitle"` + }{ + Url: url, + MediaId: mediaId, + EpisodeNumber: episodeNumber, + MediaTitle: mediaTitle, + }) + return goja.Undefined() + }) + _ = obj.Set("externalPlayerLink", externalPlayerLinkObj) +} diff --git a/seanime-2.9.10/internal/plugin/playback.go b/seanime-2.9.10/internal/plugin/playback.go new file mode 100644 index 0000000..df1ded6 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/playback.go @@ -0,0 +1,381 @@ +package plugin + +import ( + "errors" + "seanime/internal/api/anilist" + "seanime/internal/extension" + "seanime/internal/library/playbackmanager" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/mediaplayers/mpv" + "seanime/internal/mediaplayers/mpvipc" + goja_util "seanime/internal/util/goja" + + "github.com/dop251/goja" + "github.com/google/uuid" + "github.com/rs/zerolog" +) + +type Playback struct { + ctx *AppContextImpl + vm *goja.Runtime + logger *zerolog.Logger + ext *extension.Extension + scheduler *goja_util.Scheduler +} + +type PlaybackMPV struct { + mpv *mpv.Mpv + playback *Playback +} + +func (a *AppContextImpl) BindPlaybackToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + p := &Playback{ + ctx: a, + vm: vm, + logger: logger, + ext: ext, + scheduler: scheduler, + } + + playbackObj := vm.NewObject() + _ = playbackObj.Set("playUsingMediaPlayer", p.playUsingMediaPlayer) + _ = playbackObj.Set("streamUsingMediaPlayer", p.streamUsingMediaPlayer) + _ = playbackObj.Set("registerEventListener", p.registerEventListener) + _ = playbackObj.Set("pause", p.pause) + _ = playbackObj.Set("resume", p.resume) + _ = playbackObj.Set("seek", p.seek) + _ = playbackObj.Set("cancel", p.cancel) + _ = playbackObj.Set("getNextEpisode", p.getNextEpisode) + _ = playbackObj.Set("playNextEpisode", p.playNextEpisode) + _ = obj.Set("playback", playbackObj) + + // MPV + mpvObj := vm.NewObject() + mpv := mpv.New(logger, "", "") + playbackMPV := &PlaybackMPV{ + mpv: mpv, + playback: p, + } + _ = mpvObj.Set("openAndPlay", playbackMPV.openAndPlay) + _ = mpvObj.Set("onEvent", playbackMPV.onEvent) + _ = mpvObj.Set("getConnection", playbackMPV.getConnection) + _ = mpvObj.Set("stop", playbackMPV.stop) + _ = obj.Set("mpv", mpvObj) +} + +type PlaybackEvent struct { + IsVideoStarted bool `json:"isVideoStarted"` + IsVideoStopped bool `json:"isVideoStopped"` + IsVideoCompleted bool `json:"isVideoCompleted"` + IsStreamStarted bool `json:"isStreamStarted"` + IsStreamStopped bool `json:"isStreamStopped"` + IsStreamCompleted bool `json:"isStreamCompleted"` + StartedEvent *struct { + Filename string `json:"filename"` + } `json:"startedEvent"` + StoppedEvent *struct { + Reason string `json:"reason"` + } `json:"stoppedEvent"` + CompletedEvent *struct { + Filename string `json:"filename"` + } `json:"completedEvent"` + State *playbackmanager.PlaybackState `json:"state"` + Status *mediaplayer.PlaybackStatus `json:"status"` +} + +// playUsingMediaPlayer starts playback of a local file using the media player specified in the settings. +func (p *Playback) playUsingMediaPlayer(payload string) error { + playbackManager, ok := p.ctx.PlaybackManager().Get() + if !ok { + return errors.New("playback manager not found") + } + + return playbackManager.StartPlayingUsingMediaPlayer(&playbackmanager.StartPlayingOptions{ + Payload: payload, + }) +} + +// streamUsingMediaPlayer starts streaming a video using the media player specified in the settings. +func (p *Playback) streamUsingMediaPlayer(windowTitle string, payload string, media *anilist.BaseAnime, aniDbEpisode string) error { + playbackManager, ok := p.ctx.PlaybackManager().Get() + if !ok { + return errors.New("playback manager not found") + } + + return playbackManager.StartStreamingUsingMediaPlayer(windowTitle, &playbackmanager.StartPlayingOptions{ + Payload: payload, + }, media, aniDbEpisode) +} + +//////////////////////////////////// +// MPV +//////////////////////////////////// + +func (p *PlaybackMPV) openAndPlay(filePath string) goja.Value { + promise, resolve, reject := p.playback.vm.NewPromise() + + go func() { + err := p.mpv.OpenAndPlay(filePath) + p.playback.scheduler.ScheduleAsync(func() error { + if err != nil { + jsErr := p.playback.vm.NewGoError(err) + reject(jsErr) + } else { + resolve(nil) + } + return nil + }) + }() + + return p.playback.vm.ToValue(promise) +} + +func (p *PlaybackMPV) onEvent(callback func(event *mpvipc.Event, closed bool)) (func(), error) { + id := p.playback.ext.ID + "_mpv" + sub := p.mpv.Subscribe(id) + + go func() { + for event := range sub.Events() { + p.playback.scheduler.ScheduleAsync(func() error { + callback(event, false) + return nil + }) + } + }() + + go func() { + for range sub.Closed() { + p.playback.scheduler.ScheduleAsync(func() error { + callback(nil, true) + return nil + }) + } + }() + + cancelFn := func() { + p.mpv.Unsubscribe(id) + } + + return cancelFn, nil +} + +func (p *PlaybackMPV) stop() goja.Value { + promise, resolve, _ := p.playback.vm.NewPromise() + + go func() { + p.mpv.CloseAll() + p.playback.scheduler.ScheduleAsync(func() error { + resolve(goja.Undefined()) + return nil + }) + }() + + return p.playback.vm.ToValue(promise) +} + +func (p *PlaybackMPV) getConnection() goja.Value { + conn, err := p.mpv.GetOpenConnection() + if err != nil { + return goja.Undefined() + } + return p.playback.vm.ToValue(conn) +} + +// registerEventListener registers a subscriber for playback events. +// +// Example: +// $playback.registerEventListener("mySubscriber", (event) => { +// console.log(event) +// }); +func (p *Playback) registerEventListener(callback func(event *PlaybackEvent)) (func(), error) { + playbackManager, ok := p.ctx.PlaybackManager().Get() + if !ok { + return nil, errors.New("playback manager not found") + } + + id := uuid.New().String() + + subscriber := playbackManager.SubscribeToPlaybackStatus(id) + + go func() { + for event := range subscriber.EventCh { + switch e := event.(type) { + case playbackmanager.PlaybackStatusChangedEvent: + p.scheduler.ScheduleAsync(func() error { + callback(&PlaybackEvent{ + Status: &e.Status, + State: &e.State, + }) + return nil + }) + case playbackmanager.VideoStartedEvent: + p.scheduler.ScheduleAsync(func() error { + callback(&PlaybackEvent{ + IsVideoStarted: true, + StartedEvent: &struct { + Filename string `json:"filename"` + }{ + Filename: e.Filename, + }, + }) + return nil + }) + case playbackmanager.VideoStoppedEvent: + p.scheduler.ScheduleAsync(func() error { + callback(&PlaybackEvent{ + IsVideoStopped: true, + StoppedEvent: &struct { + Reason string `json:"reason"` + }{ + Reason: e.Reason, + }, + }) + return nil + }) + case playbackmanager.VideoCompletedEvent: + p.scheduler.ScheduleAsync(func() error { + callback(&PlaybackEvent{ + IsVideoCompleted: true, + CompletedEvent: &struct { + Filename string `json:"filename"` + }{ + Filename: e.Filename, + }, + }) + return nil + }) + case playbackmanager.StreamStateChangedEvent: + p.scheduler.ScheduleAsync(func() error { + callback(&PlaybackEvent{ + State: &e.State, + }) + return nil + }) + case playbackmanager.StreamStatusChangedEvent: + p.scheduler.ScheduleAsync(func() error { + callback(&PlaybackEvent{ + Status: &e.Status, + }) + return nil + }) + case playbackmanager.StreamStartedEvent: + p.scheduler.ScheduleAsync(func() error { + callback(&PlaybackEvent{ + IsStreamStarted: true, + StartedEvent: &struct { + Filename string `json:"filename"` + }{ + Filename: e.Filename, + }, + }) + return nil + }) + case playbackmanager.StreamStoppedEvent: + p.scheduler.ScheduleAsync(func() error { + callback(&PlaybackEvent{ + IsStreamStopped: true, + StoppedEvent: &struct { + Reason string `json:"reason"` + }{ + Reason: e.Reason, + }, + }) + return nil + }) + case playbackmanager.StreamCompletedEvent: + p.scheduler.ScheduleAsync(func() error { + callback(&PlaybackEvent{ + IsStreamCompleted: true, + CompletedEvent: &struct { + Filename string `json:"filename"` + }{ + Filename: e.Filename, + }, + }) + return nil + }) + } + } + }() + + cancelFn := func() { + playbackManager.UnsubscribeFromPlaybackStatus(id) + } + + return cancelFn, nil +} + +func (p *Playback) pause() error { + playbackManager, ok := p.ctx.PlaybackManager().Get() + if !ok { + return errors.New("playback manager not found") + } + return playbackManager.Pause() +} + +func (p *Playback) resume() error { + playbackManager, ok := p.ctx.PlaybackManager().Get() + if !ok { + return errors.New("playback manager not found") + } + return playbackManager.Resume() +} + +func (p *Playback) seek(seconds float64) error { + playbackManager, ok := p.ctx.PlaybackManager().Get() + if !ok { + return errors.New("playback manager not found") + } + return playbackManager.Seek(seconds) +} + +func (p *Playback) cancel() error { + playbackManager, ok := p.ctx.PlaybackManager().Get() + if !ok { + return errors.New("playback manager not found") + } + return playbackManager.Cancel() +} + +func (p *Playback) getNextEpisode() goja.Value { + promise, resolve, reject := p.vm.NewPromise() + + playbackManager, ok := p.ctx.PlaybackManager().Get() + if !ok { + reject(p.vm.NewGoError(errors.New("playback manager not found"))) + return p.vm.ToValue(promise) + } + + go func() { + nextEpisode := playbackManager.GetNextEpisode() + p.scheduler.ScheduleAsync(func() error { + resolve(p.vm.ToValue(nextEpisode)) + return nil + }) + }() + return p.vm.ToValue(promise) +} + +func (p *Playback) playNextEpisode() goja.Value { + promise, resolve, reject := p.vm.NewPromise() + + playbackManager, ok := p.ctx.PlaybackManager().Get() + if !ok { + reject(p.vm.NewGoError(errors.New("playback manager not found"))) + return p.vm.ToValue(promise) + } + + go func() { + err := playbackManager.PlayNextEpisode() + p.scheduler.ScheduleAsync(func() error { + if err != nil { + reject(p.vm.NewGoError(err)) + } else { + resolve(goja.Undefined()) + } + return nil + }) + }() + + return p.vm.ToValue(promise) +} diff --git a/seanime-2.9.10/internal/plugin/plugin_app.go b/seanime-2.9.10/internal/plugin/plugin_app.go new file mode 100644 index 0000000..b360513 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/plugin_app.go @@ -0,0 +1,26 @@ +package plugin + +import ( + "seanime/internal/constants" + "seanime/internal/events" + "seanime/internal/extension" + + "github.com/dop251/goja" + "github.com/rs/zerolog" +) + +func (a *AppContextImpl) BindApp(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension) { + appObj := vm.NewObject() + appObj.Set("getVersion", constants.Version) + appObj.Set("getVersionName", constants.VersionName) + + appObj.Set("invalidateClientQuery", func(keys []string) { + wsEventManager, ok := a.wsEventManager.Get() + if !ok { + return + } + wsEventManager.SendEvent(events.InvalidateQueries, keys) + }) + + _ = vm.Set("$app", appObj) +} diff --git a/seanime-2.9.10/internal/plugin/storage.go b/seanime-2.9.10/internal/plugin/storage.go new file mode 100644 index 0000000..073ca3e --- /dev/null +++ b/seanime-2.9.10/internal/plugin/storage.go @@ -0,0 +1,631 @@ +package plugin + +import ( + "encoding/json" + "errors" + "seanime/internal/database/models" + "seanime/internal/extension" + goja_util "seanime/internal/util/goja" + "seanime/internal/util/result" + "strings" + + "github.com/dop251/goja" + "github.com/rs/zerolog" + "gorm.io/gorm" +) + +// Storage is used to store data for an extension. +// A new instance is created for each extension. +type Storage struct { + ctx *AppContextImpl + ext *extension.Extension + logger *zerolog.Logger + runtime *goja.Runtime + pluginDataCache *result.Map[string, *models.PluginData] // Cache to avoid repeated database calls + keyDataCache *result.Map[string, interface{}] // Cache to avoid repeated database calls + keySubscribers *result.Map[string, []chan interface{}] // Subscribers for key changes + scheduler *goja_util.Scheduler +} + +var ( + ErrDatabaseNotInitialized = errors.New("database is not initialized") +) + +// BindStorage binds the storage API to the Goja runtime. +// Permissions need to be checked by the caller. +// Permissions needed: storage +func (a *AppContextImpl) BindStorage(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Storage { + storageLogger := logger.With().Str("id", ext.ID).Logger() + storage := &Storage{ + ctx: a, + ext: ext, + logger: &storageLogger, + runtime: vm, + pluginDataCache: result.NewResultMap[string, *models.PluginData](), + keyDataCache: result.NewResultMap[string, interface{}](), + keySubscribers: result.NewResultMap[string, []chan interface{}](), + scheduler: scheduler, + } + storageObj := vm.NewObject() + _ = storageObj.Set("get", storage.Get) + _ = storageObj.Set("set", storage.Set) + _ = storageObj.Set("remove", storage.Delete) + _ = storageObj.Set("drop", storage.Drop) + _ = storageObj.Set("clear", storage.Clear) + _ = storageObj.Set("keys", storage.Keys) + _ = storageObj.Set("has", storage.Has) + _ = storageObj.Set("watch", storage.Watch) + _ = vm.Set("$storage", storageObj) + + return storage +} + +// Stop closes all subscriber channels. +func (s *Storage) Stop() { + s.keySubscribers.Range(func(key string, subscribers []chan interface{}) bool { + for _, ch := range subscribers { + close(ch) + } + return true + }) + s.keySubscribers.Clear() +} + +// getDB returns the database instance or an error if not initialized +func (s *Storage) getDB() (*gorm.DB, error) { + db, ok := s.ctx.database.Get() + if !ok { + return nil, ErrDatabaseNotInitialized + } + return db.Gorm(), nil +} + +// getPluginData retrieves the plugin data from the database +// If createIfNotExists is true, it will create an empty record if none exists +func (s *Storage) getPluginData(createIfNotExists bool) (*models.PluginData, error) { + // Check cache first + if cachedData, ok := s.pluginDataCache.Get(s.ext.ID); ok { + return cachedData, nil + } + + db, err := s.getDB() + if err != nil { + return nil, err + } + + var pluginData models.PluginData + if err := db.Where("plugin_id = ?", s.ext.ID).First(&pluginData).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) && createIfNotExists { + // Create empty data structure + baseData := make(map[string]interface{}) + baseDataMarshaled, err := json.Marshal(baseData) + if err != nil { + return nil, err + } + + newPluginData := &models.PluginData{ + PluginID: s.ext.ID, + Data: baseDataMarshaled, + } + + if err := db.Create(newPluginData).Error; err != nil { + return nil, err + } + + // Cache the new plugin data + s.pluginDataCache.Set(s.ext.ID, newPluginData) + return newPluginData, nil + } + return nil, err + } + + // Cache the plugin data + s.pluginDataCache.Set(s.ext.ID, &pluginData) + return &pluginData, nil +} + +// getDataMap unmarshals the plugin data into a map +func (s *Storage) getDataMap(pluginData *models.PluginData) (map[string]interface{}, error) { + var data map[string]interface{} + if err := json.Unmarshal(pluginData.Data, &data); err != nil { + return make(map[string]interface{}), err + } + return data, nil +} + +// saveDataMap marshals and saves the data map to the database +func (s *Storage) saveDataMap(pluginData *models.PluginData, data map[string]interface{}) error { + marshaled, err := json.Marshal(data) + if err != nil { + return err + } + + pluginData.Data = marshaled + + db, err := s.getDB() + if err != nil { + return err + } + + err = db.Save(pluginData).Error + if err != nil { + return err + } + + // Update the cache + s.pluginDataCache.Set(s.ext.ID, pluginData) + + s.keyDataCache.Clear() + + return nil +} + +// getNestedValue retrieves a value from a nested map using dot notation +func getNestedValue(data map[string]interface{}, path string) interface{} { + if !strings.Contains(path, ".") { + return data[path] + } + + parts := strings.Split(path, ".") + current := data + + // Navigate through all parts except the last one + for i := 0; i < len(parts)-1; i++ { + part := parts[i] + next, ok := current[part] + if !ok { + return nil + } + + // Try to convert to map for next level + nextMap, ok := next.(map[string]interface{}) + if !ok { + // Try to convert from unmarshaled JSON + jsonMap, ok := next.(map[string]interface{}) + if !ok { + return nil + } + nextMap = jsonMap + } + + current = nextMap + } + + // Return the value at the final part + return current[parts[len(parts)-1]] +} + +// setNestedValue sets a value in a nested map using dot notation +// It creates intermediate maps as needed +func setNestedValue(data map[string]interface{}, path string, value interface{}) { + if !strings.Contains(path, ".") { + data[path] = value + return + } + + parts := strings.Split(path, ".") + current := data + + // Navigate and create intermediate maps as needed + for i := 0; i < len(parts)-1; i++ { + part := parts[i] + next, ok := current[part] + if !ok { + // Create new map if key doesn't exist + next = make(map[string]interface{}) + current[part] = next + } + + // Try to convert to map for next level + nextMap, ok := next.(map[string]interface{}) + if !ok { + // Try to convert from unmarshaled JSON + jsonMap, ok := next.(map[string]interface{}) + if !ok { + // Replace with a new map if not convertible + nextMap = make(map[string]interface{}) + current[part] = nextMap + } else { + nextMap = jsonMap + current[part] = nextMap + } + } + + current = nextMap + } + + // Set the value at the final part + current[parts[len(parts)-1]] = value +} + +// deleteNestedValue deletes a value from a nested map using dot notation +// Returns true if the key was found and deleted +func deleteNestedValue(data map[string]interface{}, path string) bool { + if !strings.Contains(path, ".") { + _, exists := data[path] + if exists { + delete(data, path) + return true + } + return false + } + + parts := strings.Split(path, ".") + current := data + + // Navigate through all parts except the last one + for i := 0; i < len(parts)-1; i++ { + part := parts[i] + next, ok := current[part] + if !ok { + return false + } + + // Try to convert to map for next level + nextMap, ok := next.(map[string]interface{}) + if !ok { + // Try to convert from unmarshaled JSON + jsonMap, ok := next.(map[string]interface{}) + if !ok { + return false + } + nextMap = jsonMap + } + + current = nextMap + } + + // Delete the value at the final part + lastPart := parts[len(parts)-1] + _, exists := current[lastPart] + if exists { + delete(current, lastPart) + return true + } + return false +} + +// hasNestedKey checks if a nested key exists using dot notation +func hasNestedKey(data map[string]interface{}, path string) bool { + if !strings.Contains(path, ".") { + _, exists := data[path] + return exists + } + + parts := strings.Split(path, ".") + current := data + + // Navigate through all parts except the last one + for i := 0; i < len(parts)-1; i++ { + part := parts[i] + next, ok := current[part] + if !ok { + return false + } + + // Try to convert to map for next level + nextMap, ok := next.(map[string]interface{}) + if !ok { + // Try to convert from unmarshaled JSON + jsonMap, ok := next.(map[string]interface{}) + if !ok { + return false + } + nextMap = jsonMap + } + + current = nextMap + } + + // Check if the final key exists + _, exists := current[parts[len(parts)-1]] + return exists +} + +// getAllKeys recursively gets all keys from a nested map using dot notation +func getAllKeys(data map[string]interface{}, prefix string) []string { + keys := make([]string, 0) + + for key, value := range data { + fullKey := key + if prefix != "" { + fullKey = prefix + "." + key + } + + keys = append(keys, fullKey) + + // If value is a map, recursively get its keys + if nestedMap, ok := value.(map[string]interface{}); ok { + nestedKeys := getAllKeys(nestedMap, fullKey) + keys = append(keys, nestedKeys...) + } + } + + return keys +} + +// notifyKeyAndParents sends notifications to subscribers of the given key and its parent keys +// If the value is nil, it indicates the key was deleted +func (s *Storage) notifyKeyAndParents(key string, value interface{}, data map[string]interface{}) { + // Notify direct subscribers of this key + if subscribers, ok := s.keySubscribers.Get(key); ok { + for _, ch := range subscribers { + // Non-blocking send to avoid deadlocks + select { + case ch <- value: + default: + // Channel is full or closed, skip + } + } + } + + // Also notify parent key subscribers if this is a nested key + if strings.Contains(key, ".") { + parts := strings.Split(key, ".") + for i := 1; i < len(parts); i++ { + parentKey := strings.Join(parts[:i], ".") + if subscribers, ok := s.keySubscribers.Get(parentKey); ok { + // Get the current parent value + parentValue := getNestedValue(data, parentKey) + for _, ch := range subscribers { + // Non-blocking send to avoid deadlocks + select { + case ch <- parentValue: + default: + // Channel is full or closed, skip + } + } + } + } + } +} + +func (s *Storage) Watch(key string, callback goja.Callable) goja.Value { + s.logger.Trace().Msgf("plugin: Watching key %s", key) + + // Create a channel to receive updates + updateCh := make(chan interface{}, 100) + + // Add this channel to the subscribers for this key + subscribers := []chan interface{}{} + if existingSubscribers, ok := s.keySubscribers.Get(key); ok { + subscribers = existingSubscribers + } + subscribers = append(subscribers, updateCh) + s.keySubscribers.Set(key, subscribers) + + // Start a goroutine to listen for updates + go func() { + for value := range updateCh { + // Call the callback with the new value + s.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), s.runtime.ToValue(value)) + if err != nil { + s.logger.Error().Err(err).Msgf("plugin: Error calling watch callback for key %s", key) + } + return nil + }) + } + }() + + // Check if the key currently exists and immediately send its value + // This allows watchers to get the current value right away + currentValue, _ := s.Get(key) + if currentValue != nil { + // Use non-blocking send + select { + case updateCh <- currentValue: + default: + // Channel is full, skip + } + } + + // Return a function that can be used to cancel the watch + cancelFn := func() { + close(updateCh) + // Remove this specific channel from subscribers + if existingSubscribers, ok := s.keySubscribers.Get(key); ok { + newSubscribers := make([]chan interface{}, 0, len(existingSubscribers)-1) + for _, ch := range existingSubscribers { + if ch != updateCh { + newSubscribers = append(newSubscribers, ch) + } + } + + if len(newSubscribers) > 0 { + s.keySubscribers.Set(key, newSubscribers) + } else { + s.keySubscribers.Delete(key) + } + } + } + + return s.runtime.ToValue(cancelFn) +} + +func (s *Storage) Delete(key string) error { + s.logger.Trace().Msgf("plugin: Deleting key %s", key) + + // Remove from key cache + s.keyDataCache.Delete(key) + + pluginData, err := s.getPluginData(false) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + + data, err := s.getDataMap(pluginData) + if err != nil { + return err + } + + // Notify subscribers that the key was deleted + s.notifyKeyAndParents(key, nil, data) + + if deleteNestedValue(data, key) { + return s.saveDataMap(pluginData, data) + } + + return nil +} + +func (s *Storage) Drop() error { + s.logger.Trace().Msg("plugin: Dropping storage") + + // // Close all subscriber channels + // s.keySubscribers.Range(func(key string, subscribers []chan interface{}) bool { + // for _, ch := range subscribers { + // close(ch) + // } + // return true + // }) + // s.keySubscribers.Clear() + + // Clear caches + s.pluginDataCache.Clear() + s.keyDataCache.Clear() + + db, err := s.getDB() + if err != nil { + return err + } + + return db.Where("plugin_id = ?", s.ext.ID).Delete(&models.PluginData{}).Error +} + +func (s *Storage) Clear() error { + s.logger.Trace().Msg("plugin: Clearing storage") + + // Clear key cache + s.keyDataCache.Clear() + + pluginData, err := s.getPluginData(true) + if err != nil { + return err + } + + // Get all keys before clearing + data, err := s.getDataMap(pluginData) + if err != nil { + return err + } + + // Get all keys to notify subscribers + keys := getAllKeys(data, "") + + // Create empty data map + cleanData := make(map[string]interface{}) + + // Save the empty data first + if err := s.saveDataMap(pluginData, cleanData); err != nil { + return err + } + + // Notify all subscribers that their keys were cleared + for _, key := range keys { + s.notifyKeyAndParents(key, nil, cleanData) + } + + return nil +} + +func (s *Storage) Keys() ([]string, error) { + pluginData, err := s.getPluginData(false) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return []string{}, nil + } + return nil, err + } + + data, err := s.getDataMap(pluginData) + if err != nil { + return nil, err + } + + return getAllKeys(data, ""), nil +} + +func (s *Storage) Has(key string) (bool, error) { + // Check key cache first + if s.keyDataCache.Has(key) { + return true, nil + } + + pluginData, err := s.getPluginData(false) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + + data, err := s.getDataMap(pluginData) + if err != nil { + return false, err + } + + exists := hasNestedKey(data, key) + + // If key exists, we can also cache its value for future Get calls + if exists { + value := getNestedValue(data, key) + if value != nil { + s.keyDataCache.Set(key, value) + } + } + + return exists, nil +} + +func (s *Storage) Get(key string) (interface{}, error) { + // Check key cache first + if cachedValue, ok := s.keyDataCache.Get(key); ok { + return cachedValue, nil + } + + pluginData, err := s.getPluginData(true) + if err != nil { + return nil, err + } + + data, err := s.getDataMap(pluginData) + if err != nil { + return nil, err + } + + value := getNestedValue(data, key) + + // Cache the value + if value != nil { + s.keyDataCache.Set(key, value) + } + + return value, nil +} + +func (s *Storage) Set(key string, value interface{}) error { + s.logger.Trace().Msgf("plugin: Setting key %s", key) + pluginData, err := s.getPluginData(true) + if err != nil { + return err + } + + data, err := s.getDataMap(pluginData) + if err != nil { + data = make(map[string]interface{}) + } + + setNestedValue(data, key, value) + + // Update key cache + s.keyDataCache.Set(key, value) + + // Notify subscribers + s.notifyKeyAndParents(key, value, data) + + return s.saveDataMap(pluginData, data) +} diff --git a/seanime-2.9.10/internal/plugin/store.go b/seanime-2.9.10/internal/plugin/store.go new file mode 100644 index 0000000..8849dc2 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/store.go @@ -0,0 +1,341 @@ +package plugin + +// Source: PocketBase + +import ( + "encoding/json" + goja_util "seanime/internal/util/goja" + "seanime/internal/util/result" + "sync" + + "github.com/dop251/goja" +) + +// @todo remove after https://github.com/golang/go/issues/20135 +const ShrinkThreshold = 200 // the number is arbitrary chosen + +// Store defines a concurrent safe in memory key-value data store. +// A new instance is created for each extension. +type Store[K comparable, T any] struct { + data map[K]T + mu sync.RWMutex + keySubscribers *result.Map[K, []*StoreKeySubscriber[K, T]] + deleted int64 +} + +type StoreKeySubscriber[K comparable, T any] struct { + Key K + Channel chan T +} + +// New creates a new Store[T] instance with a shallow copy of the provided data (if any). +func NewStore[K comparable, T any](data map[K]T) *Store[K, T] { + s := &Store[K, T]{ + data: make(map[K]T), + keySubscribers: result.NewResultMap[K, []*StoreKeySubscriber[K, T]](), + deleted: 0, + } + + s.Reset(data) + + return s +} + +// Stop closes all subscriber goroutines. +func (s *Store[K, T]) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + s.keySubscribers.Range(func(key K, subscribers []*StoreKeySubscriber[K, T]) bool { + for _, subscriber := range subscribers { + close(subscriber.Channel) + } + return true + }) + s.keySubscribers.Clear() +} + +func (s *Store[K, T]) Bind(vm *goja.Runtime, scheduler *goja_util.Scheduler) { + // Create a new object for the store + storeObj := vm.NewObject() + _ = storeObj.Set("get", s.Get) + _ = storeObj.Set("set", s.Set) + _ = storeObj.Set("length", s.Length) + _ = storeObj.Set("remove", s.Remove) + _ = storeObj.Set("removeAll", s.RemoveAll) + _ = storeObj.Set("getAll", s.GetAll) + _ = storeObj.Set("has", s.Has) + _ = storeObj.Set("getOrSet", s.GetOrSet) + _ = storeObj.Set("setIfLessThanLimit", s.SetIfLessThanLimit) + _ = storeObj.Set("unmarshalJSON", s.UnmarshalJSON) + _ = storeObj.Set("marshalJSON", s.MarshalJSON) + _ = storeObj.Set("reset", s.Reset) + _ = storeObj.Set("values", s.Values) + s.bindWatch(storeObj, vm, scheduler) + _ = vm.Set("$store", storeObj) +} + +// BindWatch binds the watch method to the store object in the runtime. +func (s *Store[K, T]) bindWatch(storeObj *goja.Object, vm *goja.Runtime, scheduler *goja_util.Scheduler) { + + // Example: + // store.watch("key", (value) => { + // console.log(value) + // }) + _ = storeObj.Set("watch", func(key K, callback goja.Callable) goja.Value { + // Create a new subscriber + subscriber := &StoreKeySubscriber[K, T]{ + Key: key, + Channel: make(chan T), + } + s.keySubscribers.Set(key, []*StoreKeySubscriber[K, T]{subscriber}) + + // Listen for changes + go func() { + for value := range subscriber.Channel { + // Schedule the callback when the value changes + scheduler.ScheduleAsync(func() error { + callback(goja.Undefined(), vm.ToValue(value)) + return nil + }) + } + }() + + cancelFn := func() { + close(subscriber.Channel) + s.keySubscribers.Delete(key) + } + return vm.ToValue(cancelFn) + }) +} + +///////////////////////////////////////////////////////////////////////////////////////////////// + +// Reset clears the store and replaces the store data with a +// shallow copy of the provided newData. +func (s *Store[K, T]) Reset(newData map[K]T) { + s.mu.Lock() + defer s.mu.Unlock() + + if len(newData) > 0 { + s.data = make(map[K]T, len(newData)) + for k, v := range newData { + s.data[k] = v + } + } else { + s.data = make(map[K]T) + } + + s.deleted = 0 +} + +// Length returns the current number of elements in the store. +func (s *Store[K, T]) Length() int { + s.mu.RLock() + defer s.mu.RUnlock() + + return len(s.data) +} + +// RemoveAll removes all the existing store entries. +func (s *Store[K, T]) RemoveAll() { + s.Reset(nil) +} + +// Remove removes a single entry from the store. +// +// Remove does nothing if key doesn't exist in the store. +func (s *Store[K, T]) Remove(key K) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.data, key) + s.deleted++ + + // reassign to a new map so that the old one can be gc-ed because it doesn't shrink + // + // @todo remove after https://github.com/golang/go/issues/20135 + if s.deleted >= ShrinkThreshold { + newData := make(map[K]T, len(s.data)) + for k, v := range s.data { + newData[k] = v + } + s.data = newData + s.deleted = 0 + } +} + +// Has checks if element with the specified key exist or not. +func (s *Store[K, T]) Has(key K) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + _, ok := s.data[key] + + return ok +} + +// Get returns a single element value from the store. +// +// If key is not set, the zero T value is returned. +func (s *Store[K, T]) Get(key K) T { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.data[key] +} + +// GetOk is similar to Get but returns also a boolean indicating whether the key exists or not. +func (s *Store[K, T]) GetOk(key K) (T, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + v, ok := s.data[key] + + return v, ok +} + +// GetAll returns a shallow copy of the current store data. +func (s *Store[K, T]) GetAll() map[K]T { + s.mu.RLock() + defer s.mu.RUnlock() + + var clone = make(map[K]T, len(s.data)) + + for k, v := range s.data { + clone[k] = v + } + + return clone +} + +// Values returns a slice with all of the current store values. +func (s *Store[K, T]) Values() []T { + s.mu.RLock() + defer s.mu.RUnlock() + + var values = make([]T, 0, len(s.data)) + + for _, v := range s.data { + values = append(values, v) + } + + return values +} + +// Set sets (or overwrite if already exist) a new value for key. +func (s *Store[K, T]) Set(key K, value T) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.data == nil { + s.data = make(map[K]T) + } + + s.data[key] = value + + // Notify subscribers + go s.notifySubscribers(key, value) +} + +// GetOrSet retrieves a single existing value for the provided key +// or stores a new one if it doesn't exist. +func (s *Store[K, T]) GetOrSet(key K, setFunc func() T) T { + // lock only reads to minimize locks contention + s.mu.RLock() + v, ok := s.data[key] + s.mu.RUnlock() + + if !ok { + s.mu.Lock() + v = setFunc() + if s.data == nil { + s.data = make(map[K]T) + } + s.data[key] = v + + // Notify subscribers + go s.notifySubscribers(key, v) + + s.mu.Unlock() + } + + return v +} + +// SetIfLessThanLimit sets (or overwrite if already exist) a new value for key. +// +// This method is similar to Set() but **it will skip adding new elements** +// to the store if the store length has reached the specified limit. +// false is returned if maxAllowedElements limit is reached. +func (s *Store[K, T]) SetIfLessThanLimit(key K, value T, maxAllowedElements int) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if s.data == nil { + s.data = make(map[K]T) + } + + // check for existing item + _, ok := s.data[key] + + if !ok && len(s.data) >= maxAllowedElements { + // cannot add more items + return false + } + + // add/overwrite item + s.data[key] = value + + // Notify subscribers + go s.notifySubscribers(key, value) + + return true +} + +// UnmarshalJSON implements [json.Unmarshaler] and imports the +// provided JSON data into the store. +// +// The store entries that match with the ones from the data will be overwritten with the new value. +func (s *Store[K, T]) UnmarshalJSON(data []byte) error { + raw := map[K]T{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.data == nil { + s.data = make(map[K]T) + } + + for k, v := range raw { + s.data[k] = v + + // Notify subscribers + go s.notifySubscribers(k, v) + } + + return nil +} + +// MarshalJSON implements [json.Marshaler] and export the current +// store data into valid JSON. +func (s *Store[K, T]) MarshalJSON() ([]byte, error) { + return json.Marshal(s.GetAll()) +} + +///////////////////////////////////////////////////////////////////////////////////////////////// + +func (s *Store[K, T]) notifySubscribers(key K, value T) { + s.keySubscribers.Range(func(subscriberKey K, subscribers []*StoreKeySubscriber[K, T]) bool { + if subscriberKey != key { + return true + } + for _, subscriber := range subscribers { + subscriber.Channel <- value + } + return true + }) +} diff --git a/seanime-2.9.10/internal/plugin/system.go b/seanime-2.9.10/internal/plugin/system.go new file mode 100644 index 0000000..240f833 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/system.go @@ -0,0 +1,1017 @@ +package plugin + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "mime" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "seanime/internal/extension" + util "seanime/internal/util" + goja_util "seanime/internal/util/goja" + "strings" + "sync" + + "github.com/bmatcuk/doublestar/v4" + "github.com/dop251/goja" + "github.com/rs/zerolog" +) + +var ( + ErrPathNotAuthorized = errors.New("path not authorized") +) + +const ( + AllowPathRead = 0 + AllowPathWrite = 1 +) + +type AsyncCmd struct { + cmd *exec.Cmd + + appContext *AppContextImpl + scheduler *goja_util.Scheduler + vm *goja.Runtime +} + +type CmdHelper struct { + cmd *exec.Cmd + + stdout io.ReadCloser + stderr io.ReadCloser + + appContext *AppContextImpl + scheduler *goja_util.Scheduler + vm *goja.Runtime +} + +// BindSystem binds the system module to the Goja runtime. +// Permissions needed: system + allowlist +func (a *AppContextImpl) BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) { + + ////////////////////////////////////// + // OS + ////////////////////////////////////// + + osObj := vm.NewObject() + + // _ = osObj.Set("args", os.Args) // NOT INCLUDED + // _ = osObj.Set("exit", os.Exit) // NOT INCLUDED + // _ = osObj.Set("getenv", os.Getenv) // NOT INCLUDED + // _ = osObj.Set("dirFS", os.DirFS) // NOT INCLUDED + // _ = osObj.Set("getwd", os.Getwd) // NOT INCLUDED + // _ = osObj.Set("chown", os.Chown) // NOT INCLUDED + + // e.g. $os.platform // "windows" + _ = osObj.Set("platform", runtime.GOOS) + + // e.g. $os.arch // "amd64" + _ = osObj.Set("arch", runtime.GOARCH) + + _ = osObj.Set("cmd", func(name string, arg ...string) (*exec.Cmd, error) { + if !a.isAllowedCommand(ext, name, arg...) { + return nil, fmt.Errorf("command (%s) not authorized", fmt.Sprintf("%s %s", name, strings.Join(arg, " "))) + } + + return util.NewCmdCtx(context.Background(), name, arg...), nil + }) + + _ = osObj.Set("Interrupt", os.Interrupt) + _ = osObj.Set("Kill", os.Kill) + + _ = osObj.Set("readFile", func(path string) ([]byte, error) { + if !a.isAllowedPath(ext, path, AllowPathRead) { + return nil, fmt.Errorf("$os.readFile: path (%s) not authorized for read", path) + } + + return os.ReadFile(path) + }) + _ = osObj.Set("writeFile", func(path string, data []byte, perm fs.FileMode) error { + if !a.isAllowedPath(ext, path, AllowPathWrite) { + return fmt.Errorf("$os.writeFile: path (%s) not authorized for write", path) + } + return os.WriteFile(path, data, perm) + }) + _ = osObj.Set("readDir", func(path string) ([]fs.DirEntry, error) { + if !a.isAllowedPath(ext, path, AllowPathRead) { + return nil, fmt.Errorf("$os.readDir: path (%s) not authorized for read", path) + } + return os.ReadDir(path) + }) + _ = osObj.Set("tempDir", func() (string, error) { + if !a.isAllowedPath(ext, os.TempDir(), AllowPathRead) { + return "", fmt.Errorf("$os.tempDir: path (%s) not authorized for read", os.TempDir()) + } + return os.TempDir(), nil + }) + _ = osObj.Set("configDir", func() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + if !a.isAllowedPath(ext, configDir, AllowPathRead) { + return "", fmt.Errorf("$os.configDir: path (%s) not authorized for read", configDir) + } + return configDir, nil + }) + _ = osObj.Set("homeDir", func() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + if !a.isAllowedPath(ext, homeDir, AllowPathRead) { + return "", fmt.Errorf("$os.homeDir: path (%s) not authorized for read", homeDir) + } + return homeDir, nil + }) + _ = osObj.Set("cacheDir", func() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + return "", err + } + if !a.isAllowedPath(ext, cacheDir, AllowPathRead) { + return "", fmt.Errorf("$os.cacheDir: path (%s) not authorized for read", cacheDir) + } + return cacheDir, nil + }) + _ = osObj.Set("truncate", func(path string, size int64) error { + if !a.isAllowedPath(ext, path, AllowPathWrite) { + return fmt.Errorf("$os.truncate: path (%s) not authorized for write", path) + } + return os.Truncate(path, size) + }) + _ = osObj.Set("mkdir", func(path string, perm fs.FileMode) error { + if !a.isAllowedPath(ext, path, AllowPathWrite) { + return fmt.Errorf("$os.mkdir: path (%s) not authorized for write", path) + } + return os.Mkdir(path, perm) + }) + _ = osObj.Set("mkdirAll", func(path string, perm fs.FileMode) error { + if !a.isAllowedPath(ext, path, AllowPathWrite) { + return fmt.Errorf("$os.mkdirAll: path (%s) not authorized for write", path) + } + return os.MkdirAll(path, perm) + }) + _ = osObj.Set("rename", func(oldpath, newpath string) error { + if !a.isAllowedPath(ext, oldpath, AllowPathWrite) || !a.isAllowedPath(ext, newpath, AllowPathWrite) { + return fmt.Errorf("$os.rename: path (%s) not authorized for write", oldpath) + } + return os.Rename(oldpath, newpath) + }) + _ = osObj.Set("remove", func(path string) error { + if !a.isAllowedPath(ext, path, AllowPathWrite) { + return fmt.Errorf("$os.remove: path (%s) not authorized for write", path) + } + return os.Remove(path) + }) + _ = osObj.Set("removeAll", func(path string) error { + if !a.isAllowedPath(ext, path, AllowPathWrite) { + return fmt.Errorf("$os.removeAll: path (%s) not authorized for write", path) + } + return os.RemoveAll(path) + }) + _ = osObj.Set("stat", func(path string) (fs.FileInfo, error) { + if !a.isAllowedPath(ext, path, AllowPathRead) { + return nil, fmt.Errorf("$os.stat: path (%s) not authorized for read", path) + } + return os.Stat(path) + }) + + _ = osObj.Set("O_RDONLY", os.O_RDONLY) + _ = osObj.Set("O_WRONLY", os.O_WRONLY) + _ = osObj.Set("O_RDWR", os.O_RDWR) + _ = osObj.Set("O_APPEND", os.O_APPEND) + _ = osObj.Set("O_CREATE", os.O_CREATE) + _ = osObj.Set("O_EXCL", os.O_EXCL) + _ = osObj.Set("O_SYNC", os.O_SYNC) + _ = osObj.Set("O_TRUNC", os.O_TRUNC) + + // Example: + // const file = $os.openFile("path/to/file.txt", $os.O_RDWR|$os.O_CREATE, 0644) + // file.writeString("Hello, world!") + // file.close() + _ = osObj.Set("openFile", func(path string, flag int, perm fs.FileMode) (*os.File, error) { + if !a.isAllowedPath(ext, path, AllowPathWrite) { + return nil, fmt.Errorf("$os.openFile: path (%s) not authorized for write", path) + } + return os.OpenFile(path, flag, perm) + }) + + // Example: + // const file = $os.create("path/to/file.txt") + // file.writeString("Hello, world!") + // file.close() + _ = osObj.Set("create", func(path string) (*os.File, error) { + if !a.isAllowedPath(ext, path, AllowPathWrite) { + return nil, fmt.Errorf("$os.create: path (%s) not authorized for write", path) + } + return os.Create(path) + }) + + fileModeObj := vm.NewObject() + + fileModeObj.Set("ModeDir", os.ModeDir) + fileModeObj.Set("ModeAppend", os.ModeAppend) + fileModeObj.Set("ModeExclusive", os.ModeExclusive) + fileModeObj.Set("ModeTemporary", os.ModeTemporary) + fileModeObj.Set("ModeSymlink", os.ModeSymlink) + fileModeObj.Set("ModeDevice", os.ModeDevice) + fileModeObj.Set("ModeNamedPipe", os.ModeNamedPipe) + fileModeObj.Set("ModeSocket", os.ModeSocket) + fileModeObj.Set("ModeSetuid", os.ModeSetuid) + fileModeObj.Set("ModeSetgid", os.ModeSetgid) + fileModeObj.Set("ModeCharDevice", os.ModeCharDevice) + fileModeObj.Set("ModeSticky", os.ModeSticky) + fileModeObj.Set("ModeIrregular", os.ModeIrregular) + fileModeObj.Set("ModeType", os.ModeType) + fileModeObj.Set("ModePerm", os.ModePerm) + _ = osObj.Set("FileMode", fileModeObj) + + _ = vm.Set("$os", osObj) + + ////////////////////////////////////// + // IO + ////////////////////////////////////// + + ioObj := vm.NewObject() + + ioObj.Set("copy", func(dst io.Writer, src io.Reader) (int64, error) { + return io.Copy(dst, src) + }) + + ioObj.Set("readAll", func(r io.Reader) ([]byte, error) { + return io.ReadAll(r) + }) + + ioObj.Set("writeString", func(w io.Writer, s string) (int, error) { + return io.WriteString(w, s) + }) + + ioObj.Set("readAtLeast", func(r io.Reader, buf []byte, min int) (int, error) { + return io.ReadAtLeast(r, buf, min) + }) + + ioObj.Set("readFull", func(r io.Reader, buf []byte) (int, error) { + return io.ReadFull(r, buf) + }) + + ioObj.Set("copyN", func(dst io.Writer, src io.Reader, n int64) (int64, error) { + return io.CopyN(dst, src, n) + }) + + ioObj.Set("copyBuffer", func(dst io.Writer, src io.Reader, buf []byte) (int64, error) { + return io.CopyBuffer(dst, src, buf) + }) + + ioObj.Set("limitReader", func(r io.Reader, n int64) io.Reader { + return io.LimitReader(r, n) + }) + + ioObj.Set("newSectionReader", func(r io.ReaderAt, off int64, n int64) io.Reader { + return io.NewSectionReader(r, off, n) + }) + + ioObj.Set("nopCloser", func(r io.Reader) io.ReadCloser { + return io.NopCloser(r) + }) + + _ = vm.Set("$io", ioObj) + + ////////////////////////////////////// + // bufio + ////////////////////////////////////// + + bufioObj := vm.NewObject() + + bufioObj.Set("newReader", func(r io.Reader) *bufio.Reader { + return bufio.NewReader(r) + }) + + bufioObj.Set("newReaderSize", func(r io.Reader, size int) *bufio.Reader { + return bufio.NewReaderSize(r, size) + }) + + bufioObj.Set("newWriter", func(w io.Writer) *bufio.Writer { + return bufio.NewWriter(w) + }) + + bufioObj.Set("newWriterSize", func(w io.Writer, size int) *bufio.Writer { + return bufio.NewWriterSize(w, size) + }) + + bufioObj.Set("newScanner", func(r io.Reader) *bufio.Scanner { + return bufio.NewScanner(r) + }) + + bufioObj.Set("scanLines", func(data []byte, atEOF bool) (advance int, token []byte, err error) { + return bufio.ScanLines(data, atEOF) + }) + + bufioObj.Set("scanWords", func(data []byte, atEOF bool) (advance int, token []byte, err error) { + return bufio.ScanWords(data, atEOF) + }) + + bufioObj.Set("scanRunes", func(data []byte, atEOF bool) (advance int, token []byte, err error) { + return bufio.ScanRunes(data, atEOF) + }) + + bufioObj.Set("scanBytes", func(data []byte, atEOF bool) (advance int, token []byte, err error) { + return bufio.ScanBytes(data, atEOF) + }) + + _ = vm.Set("$bufio", bufioObj) + + ////////////////////////////////////// + // bytes + ////////////////////////////////////// + + bytesObj := vm.NewObject() + + bytesObj.Set("newBuffer", func(buf []byte) *bytes.Buffer { + return bytes.NewBuffer(buf) + }) + + bytesObj.Set("newBufferString", func(s string) *bytes.Buffer { + return bytes.NewBufferString(s) + }) + + bytesObj.Set("newReader", func(b []byte) *bytes.Reader { + return bytes.NewReader(b) + }) + + _ = vm.Set("$bytes", bytesObj) + + ////////////////////////////////////// + // Filepath + ////////////////////////////////////// + + filepathObj := vm.NewObject() + + filepathObj.Set("base", filepath.Base) + filepathObj.Set("clean", filepath.Clean) + filepathObj.Set("dir", filepath.Dir) + filepathObj.Set("ext", filepath.Ext) + filepathObj.Set("fromSlash", filepath.FromSlash) + + filepathObj.Set("glob", func(basePath string, pattern string) ([]string, error) { + if !a.isAllowedPath(ext, basePath, AllowPathRead) { + return nil, fmt.Errorf("$filepath.glob: path (%s) not authorized for read", basePath) + } + return doublestar.Glob(os.DirFS(basePath), pattern) + }) + filepathObj.Set("isAbs", filepath.IsAbs) + filepathObj.Set("join", filepath.Join) + filepathObj.Set("match", doublestar.Match) + filepathObj.Set("rel", filepath.Rel) + filepathObj.Set("split", filepath.Split) + filepathObj.Set("splitList", filepath.SplitList) + filepathObj.Set("toSlash", filepath.ToSlash) + filepathObj.Set("walk", func(root string, walkFn filepath.WalkFunc) error { + if !a.isAllowedPath(ext, root, AllowPathRead) { + return fmt.Errorf("$filepath.walk: path (%s) not authorized for read", root) + } + return filepath.Walk(root, walkFn) + }) + filepathObj.Set("walkDir", func(root string, walkFn fs.WalkDirFunc) error { + if !a.isAllowedPath(ext, root, AllowPathRead) { + return fmt.Errorf("$filepath.walkDir: path (%s) not authorized for read", root) + } + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + return walkFn(path, d, err) + }) + }) + + skipDir := filepath.SkipDir + filepathObj.Set("skipDir", skipDir) + + _ = vm.Set("$filepath", filepathObj) + + ////////////////////////////////////// + // osextra + ////////////////////////////////////// + + osExtraObj := vm.NewObject() + + osExtraObj.Set("unwrapAndMove", func(src string, dest string) error { + if !a.isAllowedPath(ext, src, AllowPathWrite) || !a.isAllowedPath(ext, dest, AllowPathWrite) { + return fmt.Errorf("$osExtra.unwrapAndMove: path (%s) not authorized for write", src) + } + return util.UnwrapAndMove(src, dest) + }) + + osExtraObj.Set("unzip", func(src string, dest string) error { + if !a.isAllowedPath(ext, src, AllowPathWrite) || !a.isAllowedPath(ext, dest, AllowPathWrite) { + return fmt.Errorf("$osExtra.unzip: path (%s) not authorized for write", src) + } + return util.UnzipFile(src, dest) + }) + + osExtraObj.Set("unrar", func(src string, dest string) error { + if !a.isAllowedPath(ext, src, AllowPathWrite) || !a.isAllowedPath(ext, dest, AllowPathWrite) { + return fmt.Errorf("$osExtra.unrar: path (%s) not authorized for write", src) + } + return util.UnrarFile(src, dest) + }) + + osExtraObj.Set("downloadDir", func() (string, error) { + donwloadDir, err := util.DownloadDir() + if err != nil { + return "", err + } + if !a.isAllowedPath(ext, donwloadDir, AllowPathRead) { + return "", fmt.Errorf("$osExtra.downloadDir: path not authorized for read") + } + return donwloadDir, nil + }) + + osExtraObj.Set("desktopDir", func() (string, error) { + desktopDir, err := util.DesktopDir() + if err != nil { + return "", err + } + if !a.isAllowedPath(ext, desktopDir, AllowPathRead) { + return "", fmt.Errorf("$osExtra.desktopDir: path not authorized for read") + } + return desktopDir, nil + }) + + osExtraObj.Set("documentsDir", func() (string, error) { + documentsDir, err := util.DocumentsDir() + if err != nil { + return "", err + } + if !a.isAllowedPath(ext, documentsDir, AllowPathRead) { + return "", fmt.Errorf("$osExtra.documentsDir: path not authorized for read") + } + return documentsDir, nil + }) + + osExtraObj.Set("libraryDirs", func() ([]string, error) { + libraryDirs := []string{} + if animeLibraryPaths, ok := a.animeLibraryPaths.Get(); ok && len(animeLibraryPaths) > 0 { + for _, path := range animeLibraryPaths { + if !a.isAllowedPath(ext, path, AllowPathRead) { + return nil, fmt.Errorf("$osExtra.libraryDirs: path not authorized for read") + } + libraryDirs = append(libraryDirs, path) + } + } + return libraryDirs, nil + }) + + _ = osExtraObj.Set("asyncCmd", func(name string, arg ...string) *AsyncCmd { + return &AsyncCmd{ + cmd: util.NewCmdCtx(context.Background(), name, arg...), + appContext: a, + scheduler: scheduler, + vm: vm, + } + }) + + _ = vm.Set("$osExtra", osExtraObj) + + ////////////////////////////////////// + // mime + ////////////////////////////////////// + + mimeObj := vm.NewObject() + + mimeObj.Set("parse", func(contentType string) (map[string]interface{}, error) { + res, params, err := mime.ParseMediaType(contentType) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "mediaType": res, + "parameters": params, + }, nil + }) + mimeObj.Set("format", mime.FormatMediaType) + _ = vm.Set("$mime", mimeObj) + +} + +// resolveEnvironmentPaths resolves environment paths in the form of $NAME +// to the actual path. +// +// e.g. $SEANIME_LIBRARY_PATH -> /home/user/anime +func (a *AppContextImpl) resolveEnvironmentPaths(name string) []string { + + switch name { + case "SEANIME_ANIME_LIBRARY": + if animeLibraryPaths, ok := a.animeLibraryPaths.Get(); ok && len(animeLibraryPaths) > 0 { + return animeLibraryPaths + } + return []string{} + case "HOME": // %USERPROFILE% on Windows + homeDir, err := os.UserHomeDir() + if err != nil { + return []string{} + } + return []string{homeDir} + case "CACHE": // LocalAppData on Windows + cacheDir, err := os.UserCacheDir() + if err != nil { + return []string{} + } + return []string{cacheDir} + case "TEMP": // %TMP%, %TEMP% or %USERPROFILE% on Windows + tempDir := os.TempDir() + if tempDir == "" { + return []string{} + } + return []string{tempDir} + case "CONFIG": // AppData on Windows + configDir, err := os.UserConfigDir() + if err != nil { + return []string{} + } + return []string{configDir} + case "DOWNLOAD": + downloadDir, err := util.DownloadDir() + if err != nil { + return []string{} + } + return []string{downloadDir} + case "DESKTOP": + desktopDir, err := util.DesktopDir() + if err != nil { + return []string{} + } + return []string{desktopDir} + case "DOCUMENT": + documentsDir, err := util.DocumentsDir() + if err != nil { + return []string{} + } + return []string{documentsDir} + } + + return []string{} +} + +func (a *AppContextImpl) isAllowedPath(ext *extension.Extension, path string, mode int) bool { + // If the extension doesn't have a plugin manifest or system allowlist, deny access + if ext == nil || ext.Plugin == nil { + return false + } + + // Get the appropriate patterns based on the mode + var patterns []string + if mode == AllowPathRead { + patterns = ext.Plugin.Permissions.Allow.ReadPaths + } else if mode == AllowPathWrite { + patterns = ext.Plugin.Permissions.Allow.WritePaths + } else { + // Unknown mode + return false + } + + // If no patterns are defined, deny access + if len(patterns) == 0 { + return false + } + + // Normalize the path to use forward slashes and absolute path + normalizedPath := path + if !filepath.IsAbs(normalizedPath) { + absPath, err := filepath.Abs(normalizedPath) + if err != nil { + return false + } + normalizedPath = absPath + } + normalizedPath = filepath.ToSlash(normalizedPath) + + // Check if the path is a directory + isDir := false + if stat, err := os.Stat(path); err == nil && stat.IsDir() { + isDir = true + // Ensure directory paths end with a slash for proper matching + if !strings.HasSuffix(normalizedPath, "/") { + normalizedPath += "/" + } + } + + // Check if the path matches any of the allowed patterns + for _, pattern := range patterns { + // Resolve environment variables in the pattern, which may result in multiple patterns + resolvedPatterns := a.resolvePattern(pattern) + + for _, resolvedPattern := range resolvedPatterns { + // Convert to absolute path if needed + if !filepath.IsAbs(resolvedPattern) && !strings.HasPrefix(resolvedPattern, "*") { + resolvedPattern = filepath.Join(filepath.Dir(normalizedPath), resolvedPattern) + } + + // Direct match attempt + matched, err := doublestar.Match(resolvedPattern, normalizedPath) + if err == nil && matched { + return true + } + + // For directories, we need special handling + if isDir { + // Case 1: Check if this directory is explicitly allowed by a pattern ending with "/" + if !strings.HasSuffix(resolvedPattern, "/") { + dirPattern := resolvedPattern + if !strings.HasSuffix(dirPattern, "/") { + dirPattern += "/" + } + matched, err = doublestar.Match(dirPattern, normalizedPath) + if err == nil && matched { + return true + } + } + + // Case 2: Check if this directory is covered by a wildcard pattern + // Strip trailing wildcards to get the base directory pattern + basePattern := resolvedPattern + basePattern = strings.TrimSuffix(basePattern, "/**/*") + basePattern = strings.TrimSuffix(basePattern, "/**") + basePattern = strings.TrimSuffix(basePattern, "/*") + + // Ensure the base pattern ends with a slash for directory comparison + if !strings.HasSuffix(basePattern, "/") { + basePattern += "/" + } + + // If the path is exactly the base directory or a subdirectory of it + // AND the original pattern had a wildcard + if (normalizedPath == basePattern || strings.HasPrefix(normalizedPath, basePattern)) && + (strings.HasSuffix(resolvedPattern, "/**") || + strings.HasSuffix(resolvedPattern, "/**/*") || + strings.HasSuffix(resolvedPattern, "/*")) { + return true + } + + // Case 3: Check if the pattern is for a subdirectory of this directory + // This handles the case where we're checking access to a parent directory + // when a subdirectory is explicitly allowed + if strings.HasPrefix(basePattern, normalizedPath) && + (strings.HasSuffix(resolvedPattern, "/**") || + strings.HasSuffix(resolvedPattern, "/**/*") || + strings.HasSuffix(resolvedPattern, "/*")) { + return true + } + } else { + // For files, check if any parent directory is allowed with wildcards + parentDir := filepath.Dir(normalizedPath) + if !strings.HasSuffix(parentDir, "/") { + parentDir += "/" + } + + // Check if the file's parent directory matches a directory wildcard pattern + for _, suffix := range []string{"/**/*", "/**", "/*"} { + if strings.HasSuffix(resolvedPattern, suffix) { + basePattern := strings.TrimSuffix(resolvedPattern, suffix) + if !strings.HasSuffix(basePattern, "/") { + basePattern += "/" + } + + if strings.HasPrefix(parentDir, basePattern) { + return true + } + } + } + } + } + } + + // No matching pattern found, deny access + return false +} + +// resolvePattern resolves environment variables and special placeholders in a pattern +// Returns a slice of resolved patterns to account for placeholders that can expand to multiple paths +func (a *AppContextImpl) resolvePattern(pattern string) []string { + // Start with the original pattern + patterns := []string{pattern} + + // Replace special placeholders with their actual values + placeholders := []string{"$SEANIME_ANIME_LIBRARY", "$HOME", "$CACHE", "$TEMP", "$CONFIG"} + + for _, placeholder := range placeholders { + // Extract the placeholder name without the $ prefix + name := strings.TrimPrefix(placeholder, "$") + paths := a.resolveEnvironmentPaths(name) + + if len(paths) == 0 { + continue + } + + // If the placeholder exists in the pattern and expands to multiple paths, + // we need to create multiple patterns + if strings.Contains(pattern, placeholder) && len(paths) > 1 { + // Create a new set of patterns for each path + newPatterns := []string{} + + for _, existingPattern := range patterns { + for _, path := range paths { + // Ensure proper path separator handling + cleanPath := filepath.ToSlash(path) + // Replace the placeholder with the path, ensuring no double slashes + newPattern := strings.ReplaceAll(existingPattern, placeholder, cleanPath) + newPatterns = append(newPatterns, newPattern) + } + } + + // Replace the old patterns with the new ones + patterns = newPatterns + } else if len(paths) > 0 { + // If there's only one path or the placeholder doesn't exist, + // just replace it in all existing patterns + for i := range patterns { + // Ensure proper path separator handling + cleanPath := filepath.ToSlash(paths[0]) + // Replace the placeholder with the path, ensuring no double slashes + patterns[i] = strings.ReplaceAll(patterns[i], placeholder, cleanPath) + } + } + } + + // Replace environment variables in all patterns + for i := range patterns { + patterns[i] = os.ExpandEnv(patterns[i]) + } + + // Clean up any potential double slashes that might have been introduced + for i := range patterns { + // Replace any double slashes with single slashes + for strings.Contains(patterns[i], "//") { + patterns[i] = strings.ReplaceAll(patterns[i], "//", "/") + } + } + + return patterns +} + +func (a *AppContextImpl) isAllowedCommand(ext *extension.Extension, name string, arg ...string) bool { + // If the extension doesn't have a plugin manifest or system allowlist, deny access + if ext == nil || ext.Plugin == nil { + return false + } + + // Get the system allowlist + allowlist := ext.Plugin.Permissions.Allow + + // Check if the command is allowed in any of the command scopes + for _, scope := range allowlist.CommandScopes { + // Check if the command name matches + if scope.Command != name { + continue + } + + // If no args are defined in the scope but args are provided, deny access + if len(scope.Args) == 0 && len(arg) > 0 { + continue + } + + // Check if the arguments match the allowed pattern + if !a.validateCommandArgs(ext, scope.Args, arg) { + continue + } + + // Command and args match the scope, allow access + return true + } + + // No matching command scope found, deny access + return false +} + +// validateCommandArgs checks if the provided arguments match the allowed pattern +func (a *AppContextImpl) validateCommandArgs(ext *extension.Extension, allowedArgs []extension.CommandArg, providedArgs []string) bool { + // If more args are provided than allowed, deny access + if len(providedArgs) > len(allowedArgs) { + if len(allowedArgs) > 0 && allowedArgs[len(allowedArgs)-1].Validator == "$ARGS" { + return true + } + return false + } + + // Check each argument + for i, allowedArg := range allowedArgs { + // If we've reached the end of the provided args, deny access + if i >= len(providedArgs) { + return false + } + + // If the argument has a fixed value, check if it matches exactly + if allowedArg.Value != "" { + if allowedArg.Value != providedArgs[i] { + return false + } + continue + } + + // If the argument has a validator, check if it matches + if allowedArg.Validator != "" { + // Special case: $ARGS allows any value for the rest of the arguments + if allowedArg.Validator == "$ARGS" { + return true + } + + // Special case: $PATH allows any valid file path + if allowedArg.Validator == "$PATH" { + // Simple path validation - could be enhanced + if providedArgs[i] == "" { + return false + } + + // Check if the path is allowed + if !a.isAllowedPath(ext, providedArgs[i], AllowPathWrite) { + return false + } + + // Check if the path exists + if _, err := os.Stat(providedArgs[i]); os.IsNotExist(err) { + return false + } + + continue + } + + // Use regex validation for other validators + matched, err := regexp.MatchString(allowedArg.Validator, providedArgs[i]) + if err != nil || !matched { + return false + } + continue + } + + // If neither value nor validator is specified, deny access + return false + } + + return true +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// GetCommand returns the underlying exec.Cmd +func (c *AsyncCmd) GetCommand() *exec.Cmd { + return c.cmd +} + +func (c *AsyncCmd) Run(callback goja.Callable) error { + + stdout, err := c.cmd.StdoutPipe() + if err != nil { + return err + } + + stderr, err := c.cmd.StderrPipe() + if err != nil { + return err + } + + // Start the command + err = c.cmd.Start() + if err != nil { + return err + } + + // Use WaitGroup to ensure all goroutines complete before Wait() is called + var wg sync.WaitGroup + wg.Add(2) // One for stdout, one for stderr + + // Handle stdout + go func() { + defer wg.Done() + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + data := scanner.Bytes() + dataBytes := make([]byte, len(data)) + copy(dataBytes, data) + + c.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), c.vm.ToValue(dataBytes), goja.Undefined(), goja.Undefined(), goja.Undefined()) + return err + }) + } + }() + + // Handle stderr + go func() { + defer wg.Done() + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + data := scanner.Bytes() + dataBytes := make([]byte, len(data)) + copy(dataBytes, data) + + c.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), goja.Undefined(), c.vm.ToValue(dataBytes), goja.Undefined(), goja.Undefined()) + return err + }) + } + }() + + // Wait for both stdout and stderr to be fully processed in a separate goroutine + go func() { + // Wait for stdout and stderr goroutines to complete + wg.Wait() + + // Now wait for the command to finish + err := c.cmd.Wait() + + _ = stdout.Close() + _ = stderr.Close() + + // Process exit code and signal + exitCode := 0 + signal := "" + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + signal = exitErr.String() + } else if c.cmd.ProcessState != nil { + exitCode = c.cmd.ProcessState.ExitCode() + signal = c.cmd.ProcessState.String() + } + + // Call callback with exit code and signal + c.scheduler.ScheduleAsync(func() error { + _, err = callback(goja.Undefined(), goja.Undefined(), goja.Undefined(), c.vm.ToValue(exitCode), c.vm.ToValue(signal)) + return err + }) + }() + + return nil +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (c *CmdHelper) Run(callback goja.Callable) error { + return nil +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// // OnData registers a callback to be called when data is available from the command's stdout +// func (c *AsyncCmd) OnData(callback func(data []byte)) error { +// stdout, err := c.cmd.StdoutPipe() +// if err != nil { +// return err +// } + +// go func() { +// scanner := bufio.NewScanner(stdout) +// for scanner.Scan() { +// c.scheduler.ScheduleAsync(func() error { +// callback(scanner.Bytes()) +// return nil +// }) +// } +// }() + +// return nil +// } + +// // OnError registers a callback to be called when data is available from the command's stderr +// func (c *AsyncCmd) OnError(callback func(data []byte)) error { +// stderr, err := c.cmd.StderrPipe() +// if err != nil { +// return err +// } + +// go func() { +// scanner := bufio.NewScanner(stderr) +// for scanner.Scan() { +// c.scheduler.ScheduleAsync(func() error { +// callback(scanner.Bytes()) +// return nil +// }) +// } +// }() + +// return nil +// } + +// // OnExit registers a callback to be called when the command exits +// func (c *AsyncCmd) OnExit(callback func(code int, signal string)) error { +// go func() { +// err := c.cmd.Wait() +// if err != nil { +// return +// } +// c.scheduler.ScheduleAsync(func() error { +// callback(c.cmd.ProcessState.ExitCode(), c.cmd.ProcessState.String()) +// return nil +// }) +// }() +// return nil +// } diff --git a/seanime-2.9.10/internal/plugin/system_test.go b/seanime-2.9.10/internal/plugin/system_test.go new file mode 100644 index 0000000..add8f9c --- /dev/null +++ b/seanime-2.9.10/internal/plugin/system_test.go @@ -0,0 +1,412 @@ +package plugin + +import ( + "seanime/internal/extension" + "testing" + + "github.com/samber/mo" + "github.com/stretchr/testify/assert" +) + +// Mock AppContextImpl for testing +type mockAppContext struct { + AppContextImpl + mockPaths map[string][]string +} + +// Create a new mock context with initialized fields +func newMockAppContext(paths map[string][]string) *mockAppContext { + ctx := &mockAppContext{ + mockPaths: paths, + } + // Initialize the animeLibraryPaths field with mock data + if libraryPaths, ok := paths["SEANIME_ANIME_LIBRARY"]; ok { + ctx.animeLibraryPaths = mo.Some(libraryPaths) + } else { + ctx.animeLibraryPaths = mo.Some([]string{}) + } + return ctx +} + +func TestIsAllowedPath(t *testing.T) { + // Create mock context with predefined paths + mockCtx := newMockAppContext(map[string][]string{ + "SEANIME_ANIME_LIBRARY": {"/anime/lib1", "/anime/lib2"}, + "HOME": {"/home/user"}, + "TEMP": {"/tmp"}, + }) + + tests := []struct { + name string + ext *extension.Extension + path string + mode int + expected bool + }{ + { + name: "nil extension", + ext: nil, + path: "/some/path", + mode: AllowPathRead, + expected: false, + }, + { + name: "no patterns", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + }, + }, + }, + path: "/some/path", + mode: AllowPathRead, + expected: false, + }, + { + name: "simple path match", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{"/test/*.txt"}, + }, + }, + }, + }, + path: "/test/file.txt", + mode: AllowPathRead, + expected: true, + }, + { + name: "multiple library paths - first match", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{"$SEANIME_ANIME_LIBRARY/**"}, + }, + }, + }, + }, + path: "/anime/lib1/file.txt", + mode: AllowPathRead, + expected: true, + }, + { + name: "multiple library paths - second match", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{"$SEANIME_ANIME_LIBRARY/**"}, + }, + }, + }, + }, + path: "/anime/lib2/file.txt", + mode: AllowPathRead, + expected: true, + }, + { + name: "write mode with read pattern", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + }, + }, + }, + path: "/test/file.txt", + mode: AllowPathWrite, + expected: false, + }, + { + name: "multiple patterns - match one", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{"$SEANIME_ANIME_LIBRARY/**"}, + }, + }, + }, + }, + path: "/anime/lib1/file.txt", + mode: AllowPathRead, + expected: true, + }, + { + name: "no matching pattern", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + ReadPaths: []string{"$SEANIME_ANIME_LIBRARY/**"}, + }, + }, + }, + }, + path: "/anime/lib1/file.txt", + mode: AllowPathRead, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mockCtx.isAllowedPath(tt.ext, tt.path, tt.mode) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsAllowedCommand(t *testing.T) { + // Create mock context + mockCtx := newMockAppContext(map[string][]string{ + "HOME": {"/home/user"}, + "SEANIME_ANIME_LIBRARY": {}, // Empty but initialized + }) + + tests := []struct { + name string + ext *extension.Extension + cmd string + args []string + expected bool + }{ + { + name: "nil extension", + ext: nil, + cmd: "ls", + args: []string{"-l"}, + expected: false, + }, + { + name: "simple command no args", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + CommandScopes: []extension.CommandScope{ + { + Command: "ls", + }, + }, + }, + }, + }, + }, + cmd: "ls", + args: []string{}, + expected: true, + }, + { + name: "command with fixed args - match", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + CommandScopes: []extension.CommandScope{ + { + Command: "git", + Args: []extension.CommandArg{ + {Value: "pull"}, + {Value: "origin"}, + {Value: "main"}, + }, + }, + }, + }, + }, + }, + }, + cmd: "git", + args: []string{"pull", "origin", "main"}, + expected: true, + }, + { + name: "command with fixed args - no match", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + CommandScopes: []extension.CommandScope{ + { + Command: "git", + Args: []extension.CommandArg{ + {Value: "pull"}, + }, + }, + }, + }, + }, + }, + }, + cmd: "git", + args: []string{"push"}, + expected: false, + }, + { + name: "command with $ARGS validator", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + CommandScopes: []extension.CommandScope{ + { + Command: "echo", + Args: []extension.CommandArg{ + {Validator: "$ARGS"}, + }, + }, + }, + }, + }, + }, + }, + cmd: "echo", + args: []string{"hello", "world"}, + expected: true, + }, + { + name: "command with regex validator - match", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + CommandScopes: []extension.CommandScope{ + { + Command: "open", + Args: []extension.CommandArg{ + {Validator: "^https?://.*$"}, + }, + }, + }, + }, + }, + }, + }, + cmd: "open", + args: []string{"https://example.com"}, + expected: true, + }, + { + name: "command with regex validator - no match", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + CommandScopes: []extension.CommandScope{ + { + Command: "open", + Args: []extension.CommandArg{ + {Validator: "^https?://.*$"}, + }, + }, + }, + }, + }, + }, + }, + cmd: "open", + args: []string{"file://example.com"}, + expected: false, + }, + { + name: "command with $PATH validator", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + CommandScopes: []extension.CommandScope{ + { + Command: "open", + Args: []extension.CommandArg{ + {Validator: "$PATH"}, + }, + }, + }, + WritePaths: []string{"$SEANIME_ANIME_LIBRARY/**"}, + }, + }, + }, + }, + cmd: "open", + args: []string{"/anime/lib1/test.txt"}, + expected: false, // Directory does not exist on the machine + }, + { + name: "too many args", + ext: &extension.Extension{ + Plugin: &extension.PluginManifest{ + Permissions: extension.PluginPermissions{ + Scopes: []extension.PluginPermissionScope{ + extension.PluginPermissionSystem, + }, + Allow: extension.PluginAllowlist{ + CommandScopes: []extension.CommandScope{ + { + Command: "ls", + Args: []extension.CommandArg{ + {Value: "-l"}, + }, + }, + }, + }, + }, + }, + }, + cmd: "ls", + args: []string{"-l", "-a"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mockCtx.isAllowedCommand(tt.ext, tt.cmd, tt.args...) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/seanime-2.9.10/internal/plugin/ui/DOCS.md b/seanime-2.9.10/internal/plugin/ui/DOCS.md new file mode 100644 index 0000000..f88faa2 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/DOCS.md @@ -0,0 +1,87 @@ +# Code + +## Dev notes for the plugin UI + +Avoid +```go +func (d *DOMManager) getElementChildren(elementID string) []*goja.Object { + + // Listen for changes from the client + eventListener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + defer d.ctx.UnregisterEventListener(eventListener.ID) + payload := ClientDOMElementUpdatedEventPayload{} + + doneCh := make(chan []*goja.Object) + + go func(eventListener *EventListener) { + for event := range eventListener.Channel { + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + if payload.Action == "getChildren" && payload.ElementId == elementID { + if v, ok := payload.Result.([]interface{}); ok { + arr := make([]*goja.Object, 0, len(v)) + for _, elem := range v { + if elemData, ok := elem.(map[string]interface{}); ok { + arr = append(arr, d.createDOMElementObject(elemData)) + } + } + doneCh <- arr + return + } + } + } + } + }(eventListener) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementID, + Action: "getChildren", + Params: map[string]interface{}{}, + }) + timeout := time.After(4 * time.Second) + + select { + case <-timeout: + return []*goja.Object{} + case res := <-doneCh: + return res + } +} +``` + +In the above code +```go +arr = append(arr, d.createDOMElementObject(elemData)) +``` +Uses the VM so it should be scheduled. +```go +d.ctx.ScheduleAsync(func() error { + arr := make([]*goja.Object, 0, len(v)) + for _, elem := range v { + if elemData, ok := elem.(map[string]interface{}); ok { + arr = append(arr, d.createDOMElementObject(elemData)) + } + } + return nil +}) +``` + +However, getElementChildren() might be launched in a scheduled task. +```ts +ctx.registerEventHandler("test", () => { + const el = ctx.dom.queryOne("#test") + el.getChildren() +}) +``` + +And since getElementChildren() is coded "synchronously" (without promises), it will block the task +until the timeout and won't run its own task. +You'll end up with something like this: + +```txt +> event received +> timeout +> processing scheduled task +> sending task +``` + +Conclusion: Prefer promises when possible. For synchronous functions, avoid scheduling tasks inside them. diff --git a/seanime-2.9.10/internal/plugin/ui/_scheduler.go b/seanime-2.9.10/internal/plugin/ui/_scheduler.go new file mode 100644 index 0000000..c67ae0f --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/_scheduler.go @@ -0,0 +1,214 @@ +package plugin_ui + +import ( + "context" + "fmt" + "sync" + "time" +) + +// Job represents a task to be executed in the VM +type Job struct { + fn func() error + resultCh chan error + async bool // Flag to indicate if the job is async (doesn't need to wait for result) +} + +// Scheduler handles all VM operations added concurrently in a single goroutine +// Any goroutine that needs to execute a VM operation must schedule it because the UI VM isn't thread safe +type Scheduler struct { + jobQueue chan *Job + ctx context.Context + context *Context + cancel context.CancelFunc + wg sync.WaitGroup + // Track the currently executing job to detect nested scheduling + currentJob *Job + currentJobLock sync.Mutex +} + +func NewScheduler(uiCtx *Context) *Scheduler { + ctx, cancel := context.WithCancel(context.Background()) + s := &Scheduler{ + jobQueue: make(chan *Job, 9999), + ctx: ctx, + context: uiCtx, + cancel: cancel, + } + + s.start() + return s +} + +func (s *Scheduler) start() { + s.wg.Add(1) + go func() { + defer s.wg.Done() + for { + select { + case <-s.ctx.Done(): + return + case job := <-s.jobQueue: + // Set the current job before execution + s.currentJobLock.Lock() + s.currentJob = job + s.currentJobLock.Unlock() + + err := job.fn() + + // Clear the current job after execution + s.currentJobLock.Lock() + s.currentJob = nil + s.currentJobLock.Unlock() + + // Only send result if the job is not async + if !job.async { + job.resultCh <- err + } + + if err != nil { + s.context.HandleException(err) + } + } + } + }() +} + +func (s *Scheduler) Stop() { + s.cancel() + s.wg.Wait() +} + +// Schedule adds a job to the queue and waits for its completion +func (s *Scheduler) Schedule(fn func() error) error { + resultCh := make(chan error, 1) + job := &Job{ + fn: func() error { + defer func() { + if r := recover(); r != nil { + resultCh <- fmt.Errorf("panic: %v", r) + } + }() + return fn() + }, + resultCh: resultCh, + async: false, + } + + // Check if we're already in a job execution context + s.currentJobLock.Lock() + isNestedCall := s.currentJob != nil && !s.currentJob.async + s.currentJobLock.Unlock() + + // If this is a nested call from a synchronous job, we need to be careful + // We can't execute directly because the VM isn't thread-safe + // Instead, we'll queue it and use a separate goroutine to wait for the result + if isNestedCall { + // Queue the job + select { + case <-s.ctx.Done(): + return fmt.Errorf("scheduler stopped") + case s.jobQueue <- job: + // Create a separate goroutine to wait for the result + // This prevents deadlock while still ensuring the job runs in the scheduler + resultCh2 := make(chan error, 1) + go func() { + resultCh2 <- <-resultCh + }() + return <-resultCh2 + } + } + + // Otherwise, queue the job normally + select { + case <-s.ctx.Done(): + return fmt.Errorf("scheduler stopped") + case s.jobQueue <- job: + return <-resultCh + } +} + +// ScheduleAsync adds a job to the queue without waiting for completion +// This is useful for fire-and-forget operations or when a job needs to schedule another job +func (s *Scheduler) ScheduleAsync(fn func() error) { + job := &Job{ + fn: func() error { + defer func() { + if r := recover(); r != nil { + s.context.HandleException(fmt.Errorf("panic in async job: %v", r)) + } + }() + return fn() + }, + resultCh: nil, // No result channel needed + async: true, + } + + // Queue the job without blocking + select { + case <-s.ctx.Done(): + // Scheduler is stopped, just ignore + return + case s.jobQueue <- job: + // Job queued successfully + return + default: + // Queue is full, log an error + s.context.HandleException(fmt.Errorf("async job queue is full")) + } +} + +// ScheduleWithTimeout schedules a job with a timeout +func (s *Scheduler) ScheduleWithTimeout(fn func() error, timeout time.Duration) error { + resultCh := make(chan error, 1) + job := &Job{ + fn: func() error { + defer func() { + if r := recover(); r != nil { + resultCh <- fmt.Errorf("panic: %v", r) + } + }() + return fn() + }, + resultCh: resultCh, + async: false, + } + + // Check if we're already in a job execution context + s.currentJobLock.Lock() + isNestedCall := s.currentJob != nil && !s.currentJob.async + s.currentJobLock.Unlock() + + // If this is a nested call from a synchronous job, handle it specially + if isNestedCall { + // Queue the job + select { + case <-s.ctx.Done(): + return fmt.Errorf("scheduler stopped") + case s.jobQueue <- job: + // Create a separate goroutine to wait for the result with timeout + resultCh2 := make(chan error, 1) + go func() { + select { + case err := <-resultCh: + resultCh2 <- err + case <-time.After(timeout): + resultCh2 <- fmt.Errorf("operation timed out") + } + }() + return <-resultCh2 + } + } + + select { + case <-s.ctx.Done(): + return fmt.Errorf("scheduler stopped") + case s.jobQueue <- job: + select { + case err := <-resultCh: + return err + case <-time.After(timeout): + return fmt.Errorf("operation timed out") + } + } +} diff --git a/seanime-2.9.10/internal/plugin/ui/action.go b/seanime-2.9.10/internal/plugin/ui/action.go new file mode 100644 index 0000000..f8297a9 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/action.go @@ -0,0 +1,683 @@ +package plugin_ui + +import ( + "fmt" + "seanime/internal/util/result" + + "github.com/dop251/goja" + "github.com/goccy/go-json" + "github.com/google/uuid" +) + +const ( + MaxActionsPerType = 3 // A plugin can only at most X actions of a certain type +) + +// ActionManager +// +// Actions are buttons, dropdown items, and context menu items that are displayed in certain places in the UI. +// They are defined in the plugin code and are used to trigger events. +// +// The ActionManager is responsible for registering, rendering, and handling events for actions. +type ActionManager struct { + ctx *Context + + animePageButtons *result.Map[string, *AnimePageButton] + animePageDropdownItems *result.Map[string, *AnimePageDropdownMenuItem] + animeLibraryDropdownItems *result.Map[string, *AnimeLibraryDropdownMenuItem] + mangaPageButtons *result.Map[string, *MangaPageButton] + mediaCardContextMenuItems *result.Map[string, *MediaCardContextMenuItem] + episodeCardContextMenuItems *result.Map[string, *EpisodeCardContextMenuItem] + episodeGridItemMenuItems *result.Map[string, *EpisodeGridItemMenuItem] +} + +type BaseActionProps struct { + ID string `json:"id"` + Label string `json:"label"` + Style map[string]string `json:"style,omitempty"` +} + +// Base action struct that all action types embed +type BaseAction struct { + BaseActionProps +} + +// GetProps returns the base action properties +func (a *BaseAction) GetProps() BaseActionProps { + return a.BaseActionProps +} + +// SetProps sets the base action properties +func (a *BaseAction) SetProps(props BaseActionProps) { + a.BaseActionProps = props +} + +// UnmountAll unmounts all actions +// It should be called when the plugin is unloaded +func (a *ActionManager) UnmountAll() { + + if a.animePageButtons.ClearN() > 0 { + a.renderAnimePageButtons() + } + if a.animePageDropdownItems.ClearN() > 0 { + a.renderAnimePageDropdownItems() + } + if a.animeLibraryDropdownItems.ClearN() > 0 { + a.renderAnimeLibraryDropdownItems() + } + if a.mangaPageButtons.ClearN() > 0 { + a.renderMangaPageButtons() + } + if a.mediaCardContextMenuItems.ClearN() > 0 { + a.renderMediaCardContextMenuItems() + } + if a.episodeCardContextMenuItems.ClearN() > 0 { + a.renderEpisodeCardContextMenuItems() + } + if a.episodeGridItemMenuItems.ClearN() > 0 { + a.renderEpisodeGridItemMenuItems() + } +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type AnimePageButton struct { + BaseAction + Intent string `json:"intent,omitempty"` +} + +func (a *AnimePageButton) CreateObject(actionManager *ActionManager) *goja.Object { + obj := actionManager.ctx.vm.NewObject() + actionManager.bindSharedToObject(obj, a) + + _ = obj.Set("setIntent", func(intent string) { + a.Intent = intent + }) + + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type EpisodeCardContextMenuItem struct { + BaseAction +} + +func (a *EpisodeCardContextMenuItem) CreateObject(actionManager *ActionManager) *goja.Object { + obj := actionManager.ctx.vm.NewObject() + actionManager.bindSharedToObject(obj, a) + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type EpisodeGridItemMenuItem struct { + BaseAction + Type string `json:"type"` +} + +func (a *EpisodeGridItemMenuItem) CreateObject(actionManager *ActionManager) *goja.Object { + obj := actionManager.ctx.vm.NewObject() + actionManager.bindSharedToObject(obj, a) + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type MangaPageButton struct { + BaseAction + Intent string `json:"intent,omitempty"` +} + +func (a *MangaPageButton) CreateObject(actionManager *ActionManager) *goja.Object { + obj := actionManager.ctx.vm.NewObject() + actionManager.bindSharedToObject(obj, a) + + _ = obj.Set("setIntent", func(intent string) { + a.Intent = intent + }) + + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type AnimePageDropdownMenuItem struct { + BaseAction +} + +func (a *AnimePageDropdownMenuItem) CreateObject(actionManager *ActionManager) *goja.Object { + obj := actionManager.ctx.vm.NewObject() + actionManager.bindSharedToObject(obj, a) + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type AnimeLibraryDropdownMenuItem struct { + BaseAction +} + +func (a *AnimeLibraryDropdownMenuItem) CreateObject(actionManager *ActionManager) *goja.Object { + obj := actionManager.ctx.vm.NewObject() + actionManager.bindSharedToObject(obj, a) + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type MediaCardContextMenuItemFor string + +const ( + MediaCardContextMenuItemForAnime MediaCardContextMenuItemFor = "anime" + MediaCardContextMenuItemForManga MediaCardContextMenuItemFor = "manga" + MediaCardContextMenuItemForBoth MediaCardContextMenuItemFor = "both" +) + +type MediaCardContextMenuItem struct { + BaseAction + For MediaCardContextMenuItemFor `json:"for"` // anime, manga, both +} + +func (a *MediaCardContextMenuItem) CreateObject(actionManager *ActionManager) *goja.Object { + obj := actionManager.ctx.vm.NewObject() + actionManager.bindSharedToObject(obj, a) + + _ = obj.Set("setFor", func(_for MediaCardContextMenuItemFor) { + a.For = _for + }) + + return obj +} + +// /////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func NewActionManager(ctx *Context) *ActionManager { + return &ActionManager{ + ctx: ctx, + + animePageButtons: result.NewResultMap[string, *AnimePageButton](), + animeLibraryDropdownItems: result.NewResultMap[string, *AnimeLibraryDropdownMenuItem](), + animePageDropdownItems: result.NewResultMap[string, *AnimePageDropdownMenuItem](), + mangaPageButtons: result.NewResultMap[string, *MangaPageButton](), + mediaCardContextMenuItems: result.NewResultMap[string, *MediaCardContextMenuItem](), + episodeCardContextMenuItems: result.NewResultMap[string, *EpisodeCardContextMenuItem](), + episodeGridItemMenuItems: result.NewResultMap[string, *EpisodeGridItemMenuItem](), + } +} + +// renderAnimePageButtons is called when the client requests the buttons to display on the anime page. +func (a *ActionManager) renderAnimePageButtons() { + buttons := make([]*AnimePageButton, 0) + a.animePageButtons.Range(func(key string, value *AnimePageButton) bool { + buttons = append(buttons, value) + return true + }) + + a.ctx.SendEventToClient(ServerActionRenderAnimePageButtonsEvent, ServerActionRenderAnimePageButtonsEventPayload{ + Buttons: buttons, + }) +} + +func (a *ActionManager) renderAnimePageDropdownItems() { + items := make([]*AnimePageDropdownMenuItem, 0) + a.animePageDropdownItems.Range(func(key string, value *AnimePageDropdownMenuItem) bool { + items = append(items, value) + return true + }) + + a.ctx.SendEventToClient(ServerActionRenderAnimePageDropdownItemsEvent, ServerActionRenderAnimePageDropdownItemsEventPayload{ + Items: items, + }) +} + +func (a *ActionManager) renderAnimeLibraryDropdownItems() { + items := make([]*AnimeLibraryDropdownMenuItem, 0) + a.animeLibraryDropdownItems.Range(func(key string, value *AnimeLibraryDropdownMenuItem) bool { + items = append(items, value) + return true + }) + + a.ctx.SendEventToClient(ServerActionRenderAnimeLibraryDropdownItemsEvent, ServerActionRenderAnimeLibraryDropdownItemsEventPayload{ + Items: items, + }) +} + +func (a *ActionManager) renderMangaPageButtons() { + buttons := make([]*MangaPageButton, 0) + a.mangaPageButtons.Range(func(key string, value *MangaPageButton) bool { + buttons = append(buttons, value) + return true + }) + + a.ctx.SendEventToClient(ServerActionRenderMangaPageButtonsEvent, ServerActionRenderMangaPageButtonsEventPayload{ + Buttons: buttons, + }) +} + +func (a *ActionManager) renderMediaCardContextMenuItems() { + items := make([]*MediaCardContextMenuItem, 0) + a.mediaCardContextMenuItems.Range(func(key string, value *MediaCardContextMenuItem) bool { + items = append(items, value) + return true + }) + + a.ctx.SendEventToClient(ServerActionRenderMediaCardContextMenuItemsEvent, ServerActionRenderMediaCardContextMenuItemsEventPayload{ + Items: items, + }) +} + +func (a *ActionManager) renderEpisodeCardContextMenuItems() { + items := make([]*EpisodeCardContextMenuItem, 0) + a.episodeCardContextMenuItems.Range(func(key string, value *EpisodeCardContextMenuItem) bool { + items = append(items, value) + return true + }) + + a.ctx.SendEventToClient(ServerActionRenderEpisodeCardContextMenuItemsEvent, ServerActionRenderEpisodeCardContextMenuItemsEventPayload{ + Items: items, + }) +} + +func (a *ActionManager) renderEpisodeGridItemMenuItems() { + items := make([]*EpisodeGridItemMenuItem, 0) + a.episodeGridItemMenuItems.Range(func(key string, value *EpisodeGridItemMenuItem) bool { + items = append(items, value) + return true + }) + + a.ctx.SendEventToClient(ServerActionRenderEpisodeGridItemMenuItemsEvent, ServerActionRenderEpisodeGridItemMenuItemsEventPayload{ + Items: items, + }) +} + +// bind binds 'action' to the ctx object +// +// Example: +// ctx.action.newAnimePageButton(...) +func (a *ActionManager) bind(ctxObj *goja.Object) { + actionObj := a.ctx.vm.NewObject() + _ = actionObj.Set("newAnimePageButton", a.jsNewAnimePageButton) + _ = actionObj.Set("newAnimePageDropdownItem", a.jsNewAnimePageDropdownItem) + _ = actionObj.Set("newAnimeLibraryDropdownItem", a.jsNewAnimeLibraryDropdownItem) + _ = actionObj.Set("newMediaCardContextMenuItem", a.jsNewMediaCardContextMenuItem) + _ = actionObj.Set("newMangaPageButton", a.jsNewMangaPageButton) + _ = actionObj.Set("newEpisodeCardContextMenuItem", a.jsNewEpisodeCardContextMenuItem) + _ = actionObj.Set("newEpisodeGridItemMenuItem", a.jsNewEpisodeGridItemMenuItem) + _ = ctxObj.Set("action", actionObj) +} + +//////////////////////////////////////////////////////////////////////////////////////////////// +// Actions +//////////////////////////////////////////////////////////////////////////////////////////////// + +// jsNewEpisodeCardContextMenuItem +// +// Example: +// const downloadButton = ctx.newEpisodeCardContextMenuItem({ +// label: "Download", +// onClick: "download-button-clicked", +// }) +func (a *ActionManager) jsNewEpisodeCardContextMenuItem(call goja.FunctionCall) goja.Value { + // Create a new action + action := &EpisodeCardContextMenuItem{} + + // Get the props + a.unmarshalProps(call, action) + action.ID = uuid.New().String() + + // Create the object + obj := action.CreateObject(a) + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// jsNewEpisodeGridItemMenuItem +// +// Example: +// const downloadButton = ctx.newEpisodeGridItemContextMenuItem({ +// label: "Download", +// onClick: "download-button-clicked", +// type: "library", +// }) +func (a *ActionManager) jsNewEpisodeGridItemMenuItem(call goja.FunctionCall) goja.Value { + // Create a new action + action := &EpisodeGridItemMenuItem{} + + // Get the props + a.unmarshalProps(call, action) + action.ID = uuid.New().String() + + // Create the object + obj := action.CreateObject(a) + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// jsNewAnimePageButton +// +// Example: +// const downloadButton = ctx.newAnimePageButton({ +// label: "Download", +// intent: "primary", +// onClick: "download-button-clicked", +// }) +func (a *ActionManager) jsNewAnimePageButton(call goja.FunctionCall) goja.Value { + // Create a new action + action := &AnimePageButton{} + + // Get the props + a.unmarshalProps(call, action) + action.ID = uuid.New().String() + + // Create the object + obj := action.CreateObject(a) + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// jsNewAnimePageDropdownItem +// +// Example: +// const downloadButton = ctx.newAnimePageDropdownItem({ +// label: "Download", +// onClick: "download-button-clicked", +// }) +func (a *ActionManager) jsNewAnimePageDropdownItem(call goja.FunctionCall) goja.Value { + // Create a new action + action := &AnimePageDropdownMenuItem{} + + // Get the props + a.unmarshalProps(call, action) + action.ID = uuid.New().String() + + // Create the object + obj := action.CreateObject(a) + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// jsNewAnimeLibraryDropdownItem +// +// Example: +// const downloadButton = ctx.newAnimeLibraryDropdownItem({ +// label: "Download", +// onClick: "download-button-clicked", +// }) +func (a *ActionManager) jsNewAnimeLibraryDropdownItem(call goja.FunctionCall) goja.Value { + // Create a new action + action := &AnimeLibraryDropdownMenuItem{} + + // Get the props + a.unmarshalProps(call, action) + action.ID = uuid.New().String() + + // Create the object + obj := action.CreateObject(a) + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// jsNewMediaCardContextMenuItem +// +// Example: +// const downloadButton = ctx.newMediaCardContextMenuItem({ +// label: "Download", +// onClick: "download-button-clicked", +// }) +func (a *ActionManager) jsNewMediaCardContextMenuItem(call goja.FunctionCall) goja.Value { + // Create a new action + action := &MediaCardContextMenuItem{} + + // Get the props + a.unmarshalProps(call, action) + action.ID = uuid.New().String() + + // Create the object + obj := action.CreateObject(a) + return obj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// jsNewMangaPageButton +// +// Example: +// const downloadButton = ctx.newMangaPageButton({ +// label: "Download", +// onClick: "download-button-clicked", +// }) +func (a *ActionManager) jsNewMangaPageButton(call goja.FunctionCall) goja.Value { + // Create a new action + action := &MangaPageButton{} + + // Get the props + a.unmarshalProps(call, action) + action.ID = uuid.New().String() + + // Create the object + obj := action.CreateObject(a) + return obj +} + +// /////////////////////////////////////////////////////////////////////////////////// +// Shared +// /////////////////////////////////////////////////////////////////////////////////// +// bindSharedToObject binds shared methods to action objects +// +// Example: +// const downloadButton = ctx.newAnimePageButton(...) +// downloadButton.mount() +// downloadButton.unmount() +// downloadButton.setLabel("Downloading...") +func (a *ActionManager) bindSharedToObject(obj *goja.Object, action interface{}) { + var id string + var props BaseActionProps + var mapToUse interface{} + + switch act := action.(type) { + case *AnimePageButton: + id = act.ID + props = act.GetProps() + mapToUse = a.animePageButtons + case *MangaPageButton: + id = act.ID + props = act.GetProps() + mapToUse = a.mangaPageButtons + case *AnimePageDropdownMenuItem: + id = act.ID + props = act.GetProps() + mapToUse = a.animePageDropdownItems + case *AnimeLibraryDropdownMenuItem: + id = act.ID + props = act.GetProps() + mapToUse = a.animeLibraryDropdownItems + case *MediaCardContextMenuItem: + id = act.ID + props = act.GetProps() + mapToUse = a.mediaCardContextMenuItems + case *EpisodeCardContextMenuItem: + id = act.ID + props = act.GetProps() + mapToUse = a.episodeCardContextMenuItems + case *EpisodeGridItemMenuItem: + id = act.ID + props = act.GetProps() + mapToUse = a.episodeGridItemMenuItems + } + + _ = obj.Set("mount", func() { + switch m := mapToUse.(type) { + case *result.Map[string, *AnimePageButton]: + if btn, ok := action.(*AnimePageButton); ok { + m.Set(id, btn) + a.renderAnimePageButtons() + } + case *result.Map[string, *MangaPageButton]: + if btn, ok := action.(*MangaPageButton); ok { + m.Set(id, btn) + a.renderMangaPageButtons() + } + case *result.Map[string, *AnimePageDropdownMenuItem]: + if item, ok := action.(*AnimePageDropdownMenuItem); ok { + m.Set(id, item) + a.renderAnimePageDropdownItems() + } + case *result.Map[string, *AnimeLibraryDropdownMenuItem]: + if item, ok := action.(*AnimeLibraryDropdownMenuItem); ok { + m.Set(id, item) + a.renderAnimeLibraryDropdownItems() + } + case *result.Map[string, *MediaCardContextMenuItem]: + if item, ok := action.(*MediaCardContextMenuItem); ok { + if item.For == "" { + item.For = MediaCardContextMenuItemForBoth + } + m.Set(id, item) + a.renderMediaCardContextMenuItems() + } + case *result.Map[string, *EpisodeCardContextMenuItem]: + if item, ok := action.(*EpisodeCardContextMenuItem); ok { + m.Set(id, item) + a.renderEpisodeCardContextMenuItems() + } + case *result.Map[string, *EpisodeGridItemMenuItem]: + if item, ok := action.(*EpisodeGridItemMenuItem); ok { + m.Set(id, item) + a.renderEpisodeGridItemMenuItems() + } + } + }) + + _ = obj.Set("unmount", func() { + switch m := mapToUse.(type) { + case *result.Map[string, *AnimePageButton]: + m.Delete(id) + a.renderAnimePageButtons() + case *result.Map[string, *MangaPageButton]: + m.Delete(id) + a.renderMangaPageButtons() + case *result.Map[string, *AnimePageDropdownMenuItem]: + m.Delete(id) + a.renderAnimePageDropdownItems() + case *result.Map[string, *AnimeLibraryDropdownMenuItem]: + m.Delete(id) + a.renderAnimeLibraryDropdownItems() + case *result.Map[string, *MediaCardContextMenuItem]: + m.Delete(id) + a.renderMediaCardContextMenuItems() + case *result.Map[string, *EpisodeCardContextMenuItem]: + m.Delete(id) + a.renderEpisodeCardContextMenuItems() + case *result.Map[string, *EpisodeGridItemMenuItem]: + m.Delete(id) + a.renderEpisodeGridItemMenuItems() + } + }) + + _ = obj.Set("setLabel", func(label string) { + newProps := props + newProps.Label = label + + switch act := action.(type) { + case *AnimePageButton: + act.SetProps(newProps) + case *MangaPageButton: + act.SetProps(newProps) + case *AnimePageDropdownMenuItem: + act.SetProps(newProps) + case *AnimeLibraryDropdownMenuItem: + act.SetProps(newProps) + case *MediaCardContextMenuItem: + act.SetProps(newProps) + case *EpisodeCardContextMenuItem: + act.SetProps(newProps) + case *EpisodeGridItemMenuItem: + act.SetProps(newProps) + } + + }) + + _ = obj.Set("setStyle", func(style map[string]string) { + newProps := props + newProps.Style = style + + switch act := action.(type) { + case *AnimePageButton: + act.SetProps(newProps) + a.renderAnimePageButtons() + case *MangaPageButton: + act.SetProps(newProps) + a.renderMangaPageButtons() + case *AnimePageDropdownMenuItem: + act.SetProps(newProps) + a.renderAnimePageDropdownItems() + case *AnimeLibraryDropdownMenuItem: + act.SetProps(newProps) + a.renderAnimeLibraryDropdownItems() + case *MediaCardContextMenuItem: + act.SetProps(newProps) + a.renderMediaCardContextMenuItems() + case *EpisodeCardContextMenuItem: + act.SetProps(newProps) + a.renderEpisodeCardContextMenuItems() + case *EpisodeGridItemMenuItem: + act.SetProps(newProps) + } + }) + + _ = obj.Set("onClick", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + a.ctx.handleTypeError("onClick requires a callback function") + } + + callback, ok := goja.AssertFunction(call.Argument(0)) + if !ok { + a.ctx.handleTypeError("onClick requires a callback function") + } + + eventListener := a.ctx.RegisterEventListener(ClientActionClickedEvent) + + eventListener.SetCallback(func(event *ClientPluginEvent) { + payload := ClientActionClickedEventPayload{} + if event.ParsePayloadAs(ClientActionClickedEvent, &payload) && payload.ActionID == id { + a.ctx.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), a.ctx.vm.ToValue(payload.Event)) + return err + }) + } + }) + + return goja.Undefined() + }) +} + +///////////////////////////////////////////////////////////////////////////////////// +// Utils +///////////////////////////////////////////////////////////////////////////////////// + +func (a *ActionManager) unmarshalProps(call goja.FunctionCall, ret interface{}) { + if len(call.Arguments) < 1 { + a.ctx.handleException(fmt.Errorf("expected 1 argument")) + } + + props := call.Arguments[0].Export() + if props == nil { + a.ctx.handleException(fmt.Errorf("expected props object")) + } + + marshaled, err := json.Marshal(props) + if err != nil { + a.ctx.handleException(err) + } + + err = json.Unmarshal(marshaled, ret) + if err != nil { + a.ctx.handleException(err) + } +} diff --git a/seanime-2.9.10/internal/plugin/ui/command.go b/seanime-2.9.10/internal/plugin/ui/command.go new file mode 100644 index 0000000..0189b3b --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/command.go @@ -0,0 +1,380 @@ +package plugin_ui + +import ( + goja_util "seanime/internal/util/goja" + "seanime/internal/util/result" + "slices" + "sync" + "time" + + "github.com/dop251/goja" + "github.com/google/uuid" +) + +// CommandPaletteManager is a manager for the command palette. +// Unlike the Tray, command palette items are not reactive to state changes. +// They are only rendered when the setItems function is called or the refresh function is called. +type CommandPaletteManager struct { + ctx *Context + updateMutex sync.Mutex + lastUpdated time.Time + componentManager *ComponentManager + + placeholder string + keyboardShortcut string + + // registered is true if the command palette has been registered + registered bool + + items *result.Map[string, *commandItem] + renderedItems []*CommandItemJSON // Store rendered items when setItems is called +} + +type ( + commandItem struct { + index int + id string + label string + value string + filterType string // "includes" or "startsWith" or "" + heading string + renderFunc func(goja.FunctionCall) goja.Value + onSelectFunc func(goja.FunctionCall) goja.Value + } + + // CommandItemJSON is the JSON representation of a command item. + // It is used to send the command item to the client. + CommandItemJSON struct { + Index int `json:"index"` + ID string `json:"id"` + Label string `json:"label"` + Value string `json:"value"` + FilterType string `json:"filterType"` + Heading string `json:"heading"` + Components interface{} `json:"components"` + } +) + +func NewCommandPaletteManager(ctx *Context) *CommandPaletteManager { + return &CommandPaletteManager{ + ctx: ctx, + componentManager: &ComponentManager{ctx: ctx}, + items: result.NewResultMap[string, *commandItem](), + renderedItems: make([]*CommandItemJSON, 0), + } +} + +type NewCommandPaletteOptions struct { + Placeholder string `json:"placeholder,omitempty"` + KeyboardShortcut string `json:"keyboardShortcut,omitempty"` +} + +// sendInfoToClient sends the command palette info to the client after it's been requested. +func (c *CommandPaletteManager) sendInfoToClient() { + if c.registered { + c.ctx.SendEventToClient(ServerCommandPaletteInfoEvent, ServerCommandPaletteInfoEventPayload{ + Placeholder: c.placeholder, + KeyboardShortcut: c.keyboardShortcut, + }) + } +} + +func (c *CommandPaletteManager) jsNewCommandPalette(options NewCommandPaletteOptions) goja.Value { + c.registered = true + c.keyboardShortcut = options.KeyboardShortcut + c.placeholder = options.Placeholder + + cmdObj := c.ctx.vm.NewObject() + + _ = cmdObj.Set("setItems", func(items []interface{}) { + c.items.Clear() + + for idx, item := range items { + itemMap := item.(map[string]interface{}) + id := uuid.New().String() + label, _ := itemMap["label"].(string) + value, ok := itemMap["value"].(string) + if !ok { + c.ctx.handleTypeError("value must be a string") + return + } + filterType, _ := itemMap["filterType"].(string) + if filterType != "includes" && filterType != "startsWith" && filterType != "" { + c.ctx.handleTypeError("filterType must be 'includes', 'startsWith'") + return + } + heading, _ := itemMap["heading"].(string) + renderFunc, ok := itemMap["render"].(func(goja.FunctionCall) goja.Value) + if len(label) == 0 && !ok { + c.ctx.handleTypeError("label or render function must be provided") + return + } + onSelectFunc, ok := itemMap["onSelect"].(func(goja.FunctionCall) goja.Value) + if !ok { + c.ctx.handleTypeError("onSelect must be a function") + return + } + + c.items.Set(id, &commandItem{ + index: idx, + id: id, + label: label, + value: value, + filterType: filterType, + heading: heading, + renderFunc: renderFunc, + onSelectFunc: onSelectFunc, + }) + } + + // Convert the items to JSON + itemsJSON := make([]*CommandItemJSON, 0) + c.items.Range(func(key string, value *commandItem) bool { + itemsJSON = append(itemsJSON, value.ToJSON(c.ctx, c.componentManager, c.ctx.scheduler)) + return true + }) + // Store the converted items + c.renderedItems = itemsJSON + + c.renderCommandPaletteScheduled() + }) + + _ = cmdObj.Set("refresh", func() { + // Convert the items to JSON + itemsJSON := make([]*CommandItemJSON, 0) + c.items.Range(func(key string, value *commandItem) bool { + itemsJSON = append(itemsJSON, value.ToJSON(c.ctx, c.componentManager, c.ctx.scheduler)) + return true + }) + + c.renderedItems = itemsJSON + + c.renderCommandPaletteScheduled() + }) + + _ = cmdObj.Set("setPlaceholder", func(placeholder string) { + c.placeholder = placeholder + c.renderCommandPaletteScheduled() + }) + + _ = cmdObj.Set("open", func() { + c.ctx.SendEventToClient(ServerCommandPaletteOpenEvent, ServerCommandPaletteOpenEventPayload{}) + }) + + _ = cmdObj.Set("close", func() { + c.ctx.SendEventToClient(ServerCommandPaletteCloseEvent, ServerCommandPaletteCloseEventPayload{}) + }) + + _ = cmdObj.Set("setInput", func(input string) { + c.ctx.SendEventToClient(ServerCommandPaletteSetInputEvent, ServerCommandPaletteSetInputEventPayload{ + Value: input, + }) + }) + + _ = cmdObj.Set("getInput", func() string { + c.ctx.SendEventToClient(ServerCommandPaletteGetInputEvent, ServerCommandPaletteGetInputEventPayload{}) + + eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteInputEvent) + defer c.ctx.UnregisterEventListener(eventListener.ID) + + timeout := time.After(1500 * time.Millisecond) + input := make(chan string) + + eventListener.SetCallback(func(event *ClientPluginEvent) { + payload := ClientCommandPaletteInputEventPayload{} + if event.ParsePayloadAs(ClientCommandPaletteInputEvent, &payload) { + input <- payload.Value + } + }) + + // go func() { + // for event := range eventListener.Channel { + // if event.ParsePayloadAs(ClientCommandPaletteInputEvent, &payload) { + // input <- payload.Value + // } + // } + // }() + + select { + case <-timeout: + return "" + case input := <-input: + return input + } + }) + + // jsOnOpen + // + // Example: + // commandPalette.onOpen(() => { + // console.log("command palette opened by the user") + // }) + _ = cmdObj.Set("onOpen", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + c.ctx.handleTypeError("onOpen requires a callback function") + } + + callback, ok := goja.AssertFunction(call.Argument(0)) + if !ok { + c.ctx.handleTypeError("onOpen requires a callback function") + } + + eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteOpenedEvent) + + eventListener.SetCallback(func(event *ClientPluginEvent) { + payload := ClientCommandPaletteOpenedEventPayload{} + if event.ParsePayloadAs(ClientCommandPaletteOpenedEvent, &payload) { + c.ctx.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{})) + return err + }) + } + }) + + // go func() { + // for event := range eventListener.Channel { + // if event.ParsePayloadAs(ClientCommandPaletteOpenedEvent, &payload) { + // c.ctx.scheduler.ScheduleAsync(func() error { + // _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{})) + // if err != nil { + // c.ctx.logger.Error().Err(err).Msg("plugin: Error running command palette open callback") + // } + // return err + // }) + // } + // } + // }() + return goja.Undefined() + }) + + // jsOnClose + // + // Example: + // commandPalette.onClose(() => { + // console.log("command palette closed by the user") + // }) + _ = cmdObj.Set("onClose", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + c.ctx.handleTypeError("onClose requires a callback function") + } + + callback, ok := goja.AssertFunction(call.Argument(0)) + if !ok { + c.ctx.handleTypeError("onClose requires a callback function") + } + + eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteClosedEvent) + + eventListener.SetCallback(func(event *ClientPluginEvent) { + payload := ClientCommandPaletteClosedEventPayload{} + if event.ParsePayloadAs(ClientCommandPaletteClosedEvent, &payload) { + c.ctx.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{})) + return err + }) + } + }) + + // go func() { + // for event := range eventListener.Channel { + // if event.ParsePayloadAs(ClientCommandPaletteClosedEvent, &payload) { + // c.ctx.scheduler.ScheduleAsync(func() error { + // _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{})) + // if err != nil { + // c.ctx.logger.Error().Err(err).Msg("plugin: Error running command palette close callback") + // } + // return err + // }) + // } + // } + // }() + return goja.Undefined() + }) + + eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteItemSelectedEvent) + eventListener.SetCallback(func(event *ClientPluginEvent) { + payload := ClientCommandPaletteItemSelectedEventPayload{} + if event.ParsePayloadAs(ClientCommandPaletteItemSelectedEvent, &payload) { + c.ctx.scheduler.ScheduleAsync(func() error { + item, found := c.items.Get(payload.ItemID) + if found { + _ = item.onSelectFunc(goja.FunctionCall{}) + } + return nil + }) + } + }) + // go func() { + // eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteItemSelectedEvent) + // payload := ClientCommandPaletteItemSelectedEventPayload{} + + // for event := range eventListener.Channel { + // if event.ParsePayloadAs(ClientCommandPaletteItemSelectedEvent, &payload) { + // item, found := c.items.Get(payload.ItemID) + // if found { + // c.ctx.scheduler.ScheduleAsync(func() error { + // _ = item.onSelectFunc(goja.FunctionCall{}) + // return nil + // }) + // } + // } + // } + // }() + + // Register components + _ = cmdObj.Set("div", c.componentManager.jsDiv) + _ = cmdObj.Set("flex", c.componentManager.jsFlex) + _ = cmdObj.Set("stack", c.componentManager.jsStack) + _ = cmdObj.Set("text", c.componentManager.jsText) + _ = cmdObj.Set("button", c.componentManager.jsButton) + _ = cmdObj.Set("anchor", c.componentManager.jsAnchor) + + return cmdObj +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (c *commandItem) ToJSON(ctx *Context, componentManager *ComponentManager, scheduler *goja_util.Scheduler) *CommandItemJSON { + + var components interface{} + if c.renderFunc != nil { + var err error + components, err = componentManager.renderComponents(c.renderFunc) + if err != nil { + ctx.logger.Error().Err(err).Msg("plugin: Failed to render command palette item") + ctx.handleException(err) + return nil + } + } + + // Reset the last rendered components, we don't care about diffing + componentManager.lastRenderedComponents = nil + + return &CommandItemJSON{ + Index: c.index, + ID: c.id, + Label: c.label, + Value: c.value, + FilterType: c.filterType, + Heading: c.heading, + Components: components, + } +} + +func (c *CommandPaletteManager) renderCommandPaletteScheduled() { + c.updateMutex.Lock() + defer c.updateMutex.Unlock() + + if !c.registered { + return + } + + slices.SortFunc(c.renderedItems, func(a, b *CommandItemJSON) int { + return a.Index - b.Index + }) + + c.ctx.SendEventToClient(ServerCommandPaletteUpdatedEvent, ServerCommandPaletteUpdatedEventPayload{ + Placeholder: c.placeholder, + Items: c.renderedItems, + }) +} diff --git a/seanime-2.9.10/internal/plugin/ui/component_utils.go b/seanime-2.9.10/internal/plugin/ui/component_utils.go new file mode 100644 index 0000000..ab46c8a --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/component_utils.go @@ -0,0 +1,374 @@ +package plugin_ui + +import ( + "errors" + "fmt" + + "github.com/dop251/goja" + "github.com/goccy/go-json" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +func (c *ComponentManager) renderComponents(renderFunc func(goja.FunctionCall) goja.Value) (interface{}, error) { + if renderFunc == nil { + return nil, errors.New("render function is not set") + } + + // Get new components + newComponents := c.getComponentsData(renderFunc) + + // If we have previous components, perform diffing + if c.lastRenderedComponents != nil { + newComponents = c.componentDiff(c.lastRenderedComponents, newComponents) + } + + // Store the new components for next render + c.lastRenderedComponents = newComponents + + return newComponents, nil +} + +// getComponentsData calls the render function and returns the current state of the component tree +func (c *ComponentManager) getComponentsData(renderFunc func(goja.FunctionCall) goja.Value) interface{} { + // Call the render function + value := renderFunc(goja.FunctionCall{}) + + // Convert the value to a JSON string + v, err := json.Marshal(value) + if err != nil { + return nil + } + + var ret interface{} + err = json.Unmarshal(v, &ret) + if err != nil { + return nil + } + + return ret +} + +//// + +type ComponentProp struct { + Name string // e.g. "label" + Type string // e.g. "string" + Default interface{} // Is set if the prop is not provided, if not set and required is false, the prop will not be included in the component + Required bool // If true an no default value is provided, the component will throw a type error + Validate func(value interface{}) error // Optional validation function + OptionalFirstArg bool // If true, it can be the first argument to declaring the component as a shorthand (e.g. tray.button("Click me") instead of tray.button({label: "Click me"})) +} + +func defineComponent(vm *goja.Runtime, call goja.FunctionCall, t string, propDefs []ComponentProp) goja.Value { + component := Component{ + ID: uuid.New().String(), + Type: t, + Props: make(map[string]interface{}), + } + + propsList := make(map[string]interface{}) + propDefsMap := make(map[string]*ComponentProp) + + var shorthandProp *ComponentProp + for _, propDef := range propDefs { + + propDefsMap[propDef.Name] = &propDef + + if propDef.OptionalFirstArg { + shorthandProp = &propDef + } + } + + if len(call.Arguments) > 0 { + // Check if the first argument is the type of the shorthand + hasShorthand := false + if shorthandProp != nil { + switch shorthandProp.Type { + case "string": + if _, ok := call.Argument(0).Export().(string); ok { + propsList[shorthandProp.Name] = call.Argument(0).Export().(string) + hasShorthand = true + } + case "boolean": + if _, ok := call.Argument(0).Export().(bool); ok { + propsList[shorthandProp.Name] = call.Argument(0).Export().(bool) + hasShorthand = true + } + case "array": + if _, ok := call.Argument(0).Export().([]interface{}); ok { + propsList[shorthandProp.Name] = call.Argument(0).Export().([]interface{}) + hasShorthand = true + } + } + if hasShorthand { + // Get the rest of the props from the second argument + if len(call.Arguments) > 1 { + rest, ok := call.Argument(1).Export().(map[string]interface{}) + if ok { + // Only add props that are defined in the propDefs + for k, v := range rest { + if _, ok := propDefsMap[k]; ok { + propsList[k] = v + } + } + } + } + } + } + + if !hasShorthand { + propsArg, ok := call.Argument(0).Export().(map[string]interface{}) + if ok { + for k, v := range propsArg { + if _, ok := propDefsMap[k]; ok { + propsList[k] = v + } else { + // util.SpewMany(k, fmt.Sprintf("%T", v)) + } + } + } + } + } + + // Validate props + for _, propDef := range propDefs { + // If a prop is required and no value is provided, panic + if propDef.Required && len(propsList) == 0 { + panic(vm.NewTypeError(fmt.Sprintf("%s is required", propDef.Name))) + } + + // Validate the prop if the prop is defined + if propDef.Validate != nil { + if val, ok := propsList[propDef.Name]; ok { + err := propDef.Validate(val) + if err != nil { + log.Error().Msgf("Invalid prop value: %s", err.Error()) + panic(vm.NewTypeError(err.Error())) + } + } + } + + // Set a default value if the prop is not provided + if _, ok := propsList[propDef.Name]; !ok && propDef.Default != nil { + propsList[propDef.Name] = propDef.Default + } + } + + // Set the props + for k, v := range propsList { + component.Props[k] = v + } + + return vm.ToValue(component) +} + +// Helper function to create a validation function for a specific type +func validateType(expectedType string) func(interface{}) error { + return func(value interface{}) error { + if value == nil { + return fmt.Errorf("expected %s, got nil", expectedType) + } + switch expectedType { + case "string": + _, ok := value.(string) + if !ok { + if value == nil { + return nil + } + return fmt.Errorf("expected string, got %T", value) + } + return nil + case "number": + _, ok := value.(float64) + if !ok { + _, ok := value.(int64) + if !ok { + if value == nil { + return nil + } + return fmt.Errorf("expected number, got %T", value) + } + return nil + } + return nil + case "boolean": + _, ok := value.(bool) + if !ok { + if value == nil { + return nil + } + return fmt.Errorf("expected boolean, got %T", value) + } + return nil + case "array": + _, ok := value.([]interface{}) + if !ok { + if value == nil { + return nil + } + return fmt.Errorf("expected array, got %T", value) + } + return nil + case "object": + _, ok := value.(map[string]interface{}) + if !ok { + if value == nil { + return nil + } + return fmt.Errorf("expected object, got %T", value) + } + return nil + case "function": + _, ok := value.(func(goja.FunctionCall) goja.Value) + if !ok { + return fmt.Errorf("expected function, got %T", value) + } + return nil + default: + return fmt.Errorf("invalid type: %s", expectedType) + } + } +} + +// componentDiff compares two component trees and returns a new component tree that preserves the ID of old components that did not change. +// It also recursively handles props and items arrays. +// +// This is important to preserve state between renders in React. +func (c *ComponentManager) componentDiff(old, new interface{}) (ret interface{}) { + defer func() { + if r := recover(); r != nil { + // If a panic occurs, return the new component tree + ret = new + } + }() + + if old == nil || new == nil { + return new + } + + // Handle maps (components) + if oldMap, ok := old.(map[string]interface{}); ok { + if newMap, ok := new.(map[string]interface{}); ok { + // If types match and it's a component (has "type" field), preserve ID + if oldType, hasOldType := oldMap["type"]; hasOldType { + if newType, hasNewType := newMap["type"]; hasNewType && oldType == newType { + // Preserve the ID from the old component + if oldID, hasOldID := oldMap["id"]; hasOldID { + newMap["id"] = oldID + } + + // Recursively handle props + if oldProps, hasOldProps := oldMap["props"].(map[string]interface{}); hasOldProps { + if newProps, hasNewProps := newMap["props"].(map[string]interface{}); hasNewProps { + // Special handling for items array in props + if oldItems, ok := oldProps["items"].([]interface{}); ok { + if newItems, ok := newProps["items"].([]interface{}); ok { + newProps["items"] = c.componentDiff(oldItems, newItems) + } + } + // Handle other props + for k, v := range newProps { + if k != "items" { // Skip items as we already handled it + if oldV, exists := oldProps[k]; exists { + newProps[k] = c.componentDiff(oldV, v) + } + } + } + newMap["props"] = newProps + } + } + } + } + return newMap + } + } + + // Handle arrays + if oldArr, ok := old.([]interface{}); ok { + if newArr, ok := new.([]interface{}); ok { + // Create a new array to store the diffed components + result := make([]interface{}, len(newArr)) + + // First, try to match components by key if available + oldKeyMap := make(map[string]interface{}) + for _, oldComp := range oldArr { + if oldMap, ok := oldComp.(map[string]interface{}); ok { + if key, ok := oldMap["key"].(string); ok && key != "" { + oldKeyMap[key] = oldComp + } + } + } + + // Process each new component + for i, newComp := range newArr { + matched := false + + // Try to match by key first + if newMap, ok := newComp.(map[string]interface{}); ok { + if key, ok := newMap["key"].(string); ok && key != "" { + if oldComp, exists := oldKeyMap[key]; exists { + // Found a match by key + result[i] = c.componentDiff(oldComp, newComp) + matched = true + // t.ctx.logger.Debug(). + // Str("key", key). + // Str("type", fmt.Sprintf("%v", newMap["type"])). + // Msg("Component matched by key") + } + } + } + + // If no key match, try to match by position and type + if !matched && i < len(oldArr) { + oldComp := oldArr[i] + oldType, newType := "", "" + + if oldMap, ok := oldComp.(map[string]interface{}); ok { + if t, ok := oldMap["type"].(string); ok { + oldType = t + } + } + if newMap, ok := newComp.(map[string]interface{}); ok { + if t, ok := newMap["type"].(string); ok { + newType = t + } + } + + if oldType != "" && oldType == newType { + result[i] = c.componentDiff(oldComp, newComp) + matched = true + // t.ctx.logger.Debug(). + // Str("type", oldType). + // Msg("Component matched by type and position") + } + } + + // If no match found, use the new component as is + if !matched { + result[i] = newComp + // if newMap, ok := newComp.(map[string]interface{}); ok { + // t.ctx.logger.Debug(). + // Str("type", fmt.Sprintf("%v", newMap["type"])). + // Msg("New component added") + // } + } + } + + // Log removed components + // if len(oldArr) > len(newArr) { + // for i := len(newArr); i < len(oldArr); i++ { + // if oldMap, ok := oldArr[i].(map[string]interface{}); ok { + // t.ctx.logger.Debug(). + // Str("type", fmt.Sprintf("%v", oldMap["type"])). + // Msg("Component removed") + // } + // } + // } + + return result + } + } + + return new +} diff --git a/seanime-2.9.10/internal/plugin/ui/components.go b/seanime-2.9.10/internal/plugin/ui/components.go new file mode 100644 index 0000000..78e1039 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/components.go @@ -0,0 +1,280 @@ +package plugin_ui + +import ( + "errors" + + "github.com/dop251/goja" + "github.com/goccy/go-json" +) + +const ( + MAX_FIELD_REFS = 100 +) + +// ComponentManager is used to register components. +// Any higher-order UI system must use this to register components. (Tray) +type ComponentManager struct { + ctx *Context + + // Last rendered components + lastRenderedComponents interface{} +} + +// jsDiv +// +// Example: +// const div = tray.div({ +// items: [ +// tray.text("Some text"), +// ] +// }) +func (c *ComponentManager) jsDiv(call goja.FunctionCall) goja.Value { + return defineComponent(c.ctx.vm, call, "div", []ComponentProp{ + {Name: "items", Type: "array", Required: false, OptionalFirstArg: true}, + {Name: "style", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "className", Type: "string", Required: false, Validate: validateType("string")}, + }) +} + +// jsFlex +// +// Example: +// const flex = tray.flex({ +// items: [ +// tray.button({ label: "A button", onClick: "my-action" }), +// true ? tray.text("Some text") : null, +// ] +// }) +// tray.render(() => flex) +func (c *ComponentManager) jsFlex(call goja.FunctionCall) goja.Value { + return defineComponent(c.ctx.vm, call, "flex", []ComponentProp{ + {Name: "items", Type: "array", Required: false, OptionalFirstArg: true}, + {Name: "style", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "gap", Type: "number", Required: false, Default: 2, Validate: validateType("number")}, + {Name: "direction", Type: "string", Required: false, Default: "row", Validate: validateType("string")}, + {Name: "className", Type: "string", Required: false, Validate: validateType("string")}, + }) +} + +// jsStack +// +// Example: +// const stack = tray.stack({ +// items: [ +// tray.text("Some text"), +// ] +// }) +func (c *ComponentManager) jsStack(call goja.FunctionCall) goja.Value { + return defineComponent(c.ctx.vm, call, "stack", []ComponentProp{ + {Name: "items", Type: "array", Required: false, OptionalFirstArg: true}, + {Name: "style", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "gap", Type: "number", Required: false, Default: 2, Validate: validateType("number")}, + {Name: "className", Type: "string", Required: false, Validate: validateType("string")}, + }) +} + +// jsText +// +// Example: +// const text = tray.text("Some text") +// // or +// const text = tray.text({ text: "Some text" }) +func (c *ComponentManager) jsText(call goja.FunctionCall) goja.Value { + return defineComponent(c.ctx.vm, call, "text", []ComponentProp{ + {Name: "text", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")}, + {Name: "style", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "className", Type: "string", Required: false, Validate: validateType("string")}, + }) +} + +// jsButton +// +// Example: +// const button = tray.button("Click me") +// // or +// const button = tray.button({ label: "Click me", onClick: "my-action" }) +func (c *ComponentManager) jsButton(call goja.FunctionCall) goja.Value { + return defineComponent(c.ctx.vm, call, "button", []ComponentProp{ + {Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")}, + {Name: "onClick", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "style", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "intent", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")}, + {Name: "loading", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")}, + {Name: "size", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "className", Type: "string", Required: false, Validate: validateType("string")}, + }) +} + +// jsAnchor +// +// Example: +// const anchor = tray.anchor("Click here", { href: "https://example.com" }) +// // or +// const anchor = tray.anchor({ text: "Click here", href: "https://example.com" }) +func (c *ComponentManager) jsAnchor(call goja.FunctionCall) goja.Value { + return defineComponent(c.ctx.vm, call, "anchor", []ComponentProp{ + {Name: "text", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")}, + {Name: "href", Type: "string", Required: true, Validate: validateType("string")}, + {Name: "target", Type: "string", Required: false, Default: "_blank", Validate: validateType("string")}, + {Name: "onClick", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "style", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "className", Type: "string", Required: false, Validate: validateType("string")}, + }) +} + +//////////////////////////////////////////// +// Fields +//////////////////////////////////////////// + +// jsInput +// +// Example: +// const input = tray.input("Enter your name") // placeholder as shorthand +// // or +// const input = tray.input({ +// placeholder: "Enter your name", +// value: "John", +// onChange: "input-changed" +// }) +func (c *ComponentManager) jsInput(call goja.FunctionCall) goja.Value { + return defineComponent(c.ctx.vm, call, "input", []ComponentProp{ + {Name: "label", Type: "string", Required: false, OptionalFirstArg: true, Validate: validateType("string")}, + {Name: "placeholder", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")}, + {Name: "onChange", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "onSelect", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "style", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")}, + {Name: "textarea", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")}, + {Name: "size", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "className", Type: "string", Required: false, Validate: validateType("string")}, + }) +} + +func validateOptions(v interface{}) error { + if v == nil { + return errors.New("options must be an array of objects") + } + marshaled, err := json.Marshal(v) + if err != nil { + return err + } + var arr []map[string]interface{} + if err := json.Unmarshal(marshaled, &arr); err != nil { + return err + } + if len(arr) == 0 { + return nil + } + for _, option := range arr { + if _, ok := option["label"]; !ok { + return errors.New("options must be an array of objects with a label property") + } + if _, ok := option["value"]; !ok { + return errors.New("options must be an array of objects with a value property") + } + } + return nil +} + +// jsSelect +// +// Example: +// const select = tray.select("Select an item", { +// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }], +// onChange: "select-changed" +// }) +// // or +// const select = tray.select({ +// placeholder: "Select an item", +// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }], +// value: "Item 1", +// onChange: "select-changed" +// }) +func (c *ComponentManager) jsSelect(call goja.FunctionCall) goja.Value { + return defineComponent(c.ctx.vm, call, "select", []ComponentProp{ + {Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")}, + {Name: "placeholder", Type: "string", Required: false, Validate: validateType("string")}, + { + Name: "options", + Type: "array", + Required: true, + Validate: validateOptions, + }, + {Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")}, + {Name: "onChange", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "style", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")}, + {Name: "size", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "className", Type: "string", Required: false, Validate: validateType("string")}, + }) +} + +// jsCheckbox +// +// Example: +// const checkbox = tray.checkbox("I agree to the terms and conditions") +// // or +// const checkbox = tray.checkbox({ label: "I agree to the terms and conditions", value: true }) +func (c *ComponentManager) jsCheckbox(call goja.FunctionCall) goja.Value { + return defineComponent(c.ctx.vm, call, "checkbox", []ComponentProp{ + {Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")}, + {Name: "value", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")}, + {Name: "onChange", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "style", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")}, + {Name: "size", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "className", Type: "string", Required: false, Validate: validateType("string")}, + }) +} + +// jsRadioGroup +// +// Example: +// const radioGroup = tray.radioGroup({ +// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }], +// onChange: "radio-group-changed" +// }) +func (c *ComponentManager) jsRadioGroup(call goja.FunctionCall) goja.Value { + return defineComponent(c.ctx.vm, call, "radio-group", []ComponentProp{ + {Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")}, + {Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")}, + { + Name: "options", + Type: "array", + Required: true, + Validate: validateOptions, + }, + {Name: "onChange", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "style", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")}, + {Name: "size", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "className", Type: "string", Required: false, Validate: validateType("string")}, + }) +} + +// jsSwitch +// +// Example: +// const switch = tray.switch({ +// label: "Toggle me", +// value: true +// }) +func (c *ComponentManager) jsSwitch(call goja.FunctionCall) goja.Value { + return defineComponent(c.ctx.vm, call, "switch", []ComponentProp{ + {Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")}, + {Name: "value", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")}, + {Name: "onChange", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "style", Type: "object", Required: false, Validate: validateType("object")}, + {Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")}, + {Name: "size", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "side", Type: "string", Required: false, Validate: validateType("string")}, + {Name: "className", Type: "string", Required: false, Validate: validateType("string")}, + }) +} diff --git a/seanime-2.9.10/internal/plugin/ui/context.go b/seanime-2.9.10/internal/plugin/ui/context.go new file mode 100644 index 0000000..5978bd7 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/context.go @@ -0,0 +1,1243 @@ +package plugin_ui + +import ( + "context" + "fmt" + "reflect" + "seanime/internal/events" + "seanime/internal/extension" + "seanime/internal/plugin" + goja_util "seanime/internal/util/goja" + "seanime/internal/util/result" + "sync" + "sync/atomic" + "time" + + "github.com/dop251/goja" + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +// Constants for event batching +const ( + maxEventBatchSize = 20 // Maximum number of events in a batch + eventBatchFlushInterval = 10 // Flush interval in milliseconds +) + +// BatchedPluginEvents represents a collection of plugin events to be sent together +type BatchedPluginEvents struct { + Events []*ServerPluginEvent `json:"events"` +} + +// BatchedEvents represents a collection of events to be sent together +type BatchedEvents struct { + Events []events.WebsocketClientEvent `json:"events"` +} + +// Context manages the entire plugin UI during its lifecycle +type Context struct { + ui *UI + + ext *extension.Extension + logger *zerolog.Logger + wsEventManager events.WSEventManagerInterface + + mu sync.RWMutex + fetchSem chan struct{} // Semaphore for concurrent fetch requests + + vm *goja.Runtime + states *result.Map[string, *State] + stateSubscribers []chan *State + scheduler *goja_util.Scheduler // Schedule VM executions concurrently and execute them in order. + wsSubscriber *events.ClientEventSubscriber + eventBus *result.Map[ClientEventType, *result.Map[string, *EventListener]] // map[string]map[string]*EventListener (event -> listenerID -> listener) + contextObj *goja.Object + + fieldRefCount int // Number of field refs registered + exceptionCount int // Number of exceptions that have occurred + effectStack map[string]bool // Track currently executing effects to prevent infinite loops + effectCalls map[string][]time.Time // Track effect calls within time window + + // State update batching + updateBatchMu sync.Mutex + pendingStateUpdates map[string]struct{} // Set of state IDs with pending updates + updateBatchTimer *time.Timer // Timer for flushing batched updates + + // Event batching system + eventBatchMu sync.Mutex + pendingClientEvents []*ServerPluginEvent // Queue of pending events to send to client + eventBatchTimer *time.Timer // Timer for flushing batched events + eventBatchSize int // Current size of the event batch + + // UI update rate limiting + lastUIUpdateAt time.Time + uiUpdateMu sync.Mutex + + webviewManager *WebviewManager // UNUSED + screenManager *ScreenManager // Listen for screen events, send screen actions + trayManager *TrayManager // Register and manage tray + actionManager *ActionManager // Register and manage actions + formManager *FormManager // Register and manage forms + toastManager *ToastManager // Register and manage toasts + commandPaletteManager *CommandPaletteManager // Register and manage command palette + domManager *DOMManager // DOM manipulation manager + notificationManager *NotificationManager // Register and manage notifications + + atomicCleanupCounter atomic.Int64 + onCleanupFns *result.Map[int64, func()] + cron mo.Option[*plugin.Cron] + + registeredInlineEventHandlers *result.Map[string, *EventListener] +} + +type State struct { + ID string + Value goja.Value +} + +// EventListener is used by Goja methods to listen for events from the client +type EventListener struct { + ID string + ListenTo []ClientEventType // Optional event type to listen for + queue []*ClientPluginEvent // Queue for event payloads + callback func(*ClientPluginEvent) // Callback function to process events + closed bool + mu sync.Mutex +} + +func NewContext(ui *UI) *Context { + ret := &Context{ + ui: ui, + ext: ui.ext, + logger: ui.logger, + vm: ui.vm, + states: result.NewResultMap[string, *State](), + fetchSem: make(chan struct{}, MaxConcurrentFetchRequests), + stateSubscribers: make([]chan *State, 0), + eventBus: result.NewResultMap[ClientEventType, *result.Map[string, *EventListener]](), + wsEventManager: ui.wsEventManager, + effectStack: make(map[string]bool), + effectCalls: make(map[string][]time.Time), + pendingStateUpdates: make(map[string]struct{}), + lastUIUpdateAt: time.Now().Add(-time.Hour), // Initialize to a time in the past + atomicCleanupCounter: atomic.Int64{}, + onCleanupFns: result.NewResultMap[int64, func()](), + cron: mo.None[*plugin.Cron](), + registeredInlineEventHandlers: result.NewResultMap[string, *EventListener](), + pendingClientEvents: make([]*ServerPluginEvent, 0, maxEventBatchSize), + eventBatchSize: 0, + } + + ret.scheduler = ui.scheduler + ret.updateBatchTimer = time.AfterFunc(time.Duration(StateUpdateBatchInterval)*time.Millisecond, ret.flushStateUpdates) + ret.updateBatchTimer.Stop() // Start in stopped state + + ret.trayManager = NewTrayManager(ret) + ret.actionManager = NewActionManager(ret) + ret.webviewManager = NewWebviewManager(ret) + ret.screenManager = NewScreenManager(ret) + ret.formManager = NewFormManager(ret) + ret.toastManager = NewToastManager(ret) + ret.commandPaletteManager = NewCommandPaletteManager(ret) + ret.domManager = NewDOMManager(ret) + ret.notificationManager = NewNotificationManager(ret) + + // Initialize the event batch timer + ret.eventBatchTimer = time.AfterFunc(eventBatchFlushInterval*time.Millisecond, func() { + ret.flushEventBatch() + }) + ret.eventBatchTimer.Stop() + + return ret +} + +func (c *Context) createAndBindContextObject(vm *goja.Runtime) { + obj := vm.NewObject() + + _ = obj.Set("newTray", c.trayManager.jsNewTray) + _ = obj.Set("newForm", c.formManager.jsNewForm) + + _ = obj.Set("newCommandPalette", c.commandPaletteManager.jsNewCommandPalette) + + _ = obj.Set("state", c.jsState) + _ = obj.Set("setTimeout", c.jsSetTimeout) + _ = obj.Set("setInterval", c.jsSetInterval) + _ = obj.Set("effect", c.jsEffect) + _ = obj.Set("registerEventHandler", c.jsRegisterEventHandler) + _ = obj.Set("eventHandler", c.jsEventHandler) + _ = obj.Set("fieldRef", c.jsfieldRef) + + c.bindFetch(obj) + // Bind screen manager + c.screenManager.bind(obj) + // Bind action manager + c.actionManager.bind(obj) + // Bind toast manager + c.toastManager.bind(obj) + // Bind DOM manager + c.domManager.BindToObj(vm, obj) + // Bind manga + plugin.GlobalAppContext.BindMangaToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + // Bind anime + plugin.GlobalAppContext.BindAnimeToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + // Bind continuity + plugin.GlobalAppContext.BindContinuityToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + // Bind filler manager + plugin.GlobalAppContext.BindFillerManagerToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + // Bind auto downloader + plugin.GlobalAppContext.BindAutoDownloaderToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + // Bind auto scanner + plugin.GlobalAppContext.BindAutoScannerToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + // Bind external player link + plugin.GlobalAppContext.BindExternalPlayerLinkToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + // Bind onlinestream + plugin.GlobalAppContext.BindOnlinestreamToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + // Bind mediastream + plugin.GlobalAppContext.BindMediastreamToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + + if c.ext.Plugin != nil { + for _, permission := range c.ext.Plugin.Permissions.Scopes { + switch permission { + case extension.PluginPermissionPlayback: + // Bind playback to the context object + plugin.GlobalAppContext.BindPlaybackToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + case extension.PluginPermissionSystem: + plugin.GlobalAppContext.BindDownloaderToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + case extension.PluginPermissionCron: + // Bind cron to the context object + cron := plugin.GlobalAppContext.BindCronToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + c.cron = mo.Some(cron) + case extension.PluginPermissionNotification: + // Bind notification to the context object + c.notificationManager.bind(obj) + case extension.PluginPermissionDiscord: + // Bind discord to the context object + plugin.GlobalAppContext.BindDiscordToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + case extension.PluginPermissionTorrentClient: + // Bind torrent client to the context object + plugin.GlobalAppContext.BindTorrentClientToContextObj(vm, obj, c.logger, c.ext, c.scheduler) + } + } + } + + _ = vm.Set("__ctx", obj) + + c.contextObj = obj +} + +// RegisterEventListener is used to register a new event listener in a Goja function +func (c *Context) RegisterEventListener(events ...ClientEventType) *EventListener { + id := uuid.New().String() + listener := &EventListener{ + ID: id, + ListenTo: events, + queue: make([]*ClientPluginEvent, 0), + closed: false, + } + + // Register the listener for each event type + for _, event := range events { + if !c.eventBus.Has(event) { + c.eventBus.Set(event, result.NewResultMap[string, *EventListener]()) + } + listeners, _ := c.eventBus.Get(event) + listeners.Set(id, listener) + } + + return listener +} + +func (c *Context) UnregisterEventListener(id string) { + c.eventBus.Range(func(key ClientEventType, listenerMap *result.Map[string, *EventListener]) bool { + listener, ok := listenerMap.Get(id) + if !ok { + return true + } + + // Close the listener first before removing it + listener.Close() + + listenerMap.Delete(id) + + return true + }) +} + +func (c *Context) UnregisterEventListenerE(e *EventListener) { + if e == nil { + return + } + + for _, event := range e.ListenTo { + listeners, ok := c.eventBus.Get(event) + if !ok { + continue + } + + listener, ok := listeners.Get(e.ID) + if !ok { + continue + } + + // Close the listener first before removing it + listener.Close() + + listeners.Delete(e.ID) + } +} + +func (e *EventListener) Close() { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return + } + e.closed = true + e.queue = nil // Clear the queue +} + +func (e *EventListener) Send(event *ClientPluginEvent) { + defer func() { + if r := recover(); r != nil { + fmt.Printf("plugin: Error sending event %s\n", event.Type) + } + }() + + e.mu.Lock() + + if e.closed { + e.mu.Unlock() + return + } + + // Add event to queue + e.queue = append(e.queue, event) + hasCallback := e.callback != nil + + e.mu.Unlock() + + // Process immediately if callback is set - call after releasing the lock + if hasCallback { + go e.processEvents() + } +} + +// SetCallback sets a function to call when events are received +func (e *EventListener) SetCallback(callback func(*ClientPluginEvent)) { + e.mu.Lock() + + e.callback = callback + hasEvents := len(e.queue) > 0 && !e.closed + + e.mu.Unlock() + + // Process any existing events in the queue - call after releasing the lock + if hasEvents { + go e.processEvents() + } +} + +// processEvents processes all events in the queue +func (e *EventListener) processEvents() { + var _events []*ClientPluginEvent + var callback func(*ClientPluginEvent) + + e.mu.Lock() + if e.closed || e.callback == nil { + e.mu.Unlock() + return + } + + // Get all _events from the queue and the callback + _events = make([]*ClientPluginEvent, len(e.queue)) + copy(_events, e.queue) + e.queue = e.queue[:0] // Clear the queue + callback = e.callback // Make a copy of the callback + + e.mu.Unlock() + + // Process _events outside the lock with the copied callback + for _, event := range _events { + // Wrap each callback in a recover to prevent one bad event from stopping all processing + func(evt *ClientPluginEvent) { + defer func() { + if r := recover(); r != nil { + fmt.Printf("plugin: Error processing event: %v\n", r) + } + }() + callback(evt) + }(event) + } +} + +// SendEventToClient sends an event to the client +// It always passes the extension ID +func (c *Context) SendEventToClient(eventType ServerEventType, payload interface{}) { + c.queueEventToClient("", eventType, payload) +} + +// SendEventToClientWithClientID sends an event to the client with a specific client ID +func (c *Context) SendEventToClientWithClientID(clientID string, eventType ServerEventType, payload interface{}) { + c.wsEventManager.SendEventTo(clientID, string(events.PluginEvent), &ServerPluginEvent{ + ExtensionID: c.ext.ID, + Type: eventType, + Payload: payload, + }) +} + +// PrintState prints all states to the logger +func (c *Context) PrintState() { + c.states.Range(func(key string, state *State) bool { + c.logger.Info().Msgf("State %s = %+v", key, state.Value) + return true + }) +} + +func (c *Context) GetContextObj() (*goja.Object, bool) { + return c.contextObj, c.contextObj != nil +} + +// handleTypeError interrupts the UI the first time we encounter a type error. +// Interrupting early is better to catch wrong usage of the API. +func (c *Context) handleTypeError(msg string) { + c.logger.Error().Err(fmt.Errorf(msg)).Msg("plugin: Type error") + // c.fatalError(fmt.Errorf(msg)) + panic(c.vm.NewTypeError(msg)) +} + +// handleException interrupts the UI after a certain number of exceptions have occurred. +// As opposed to HandleTypeError, this is more-so for unexpected errors and not wrong usage of the API. +func (c *Context) handleException(err error) { + // c.mu.Lock() + // defer c.mu.Unlock() + + c.wsEventManager.SendEvent(events.ConsoleWarn, fmt.Sprintf("plugin(%s): Exception: %s", c.ext.ID, err.Error())) + c.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("plugin(%s): Exception: %s", c.ext.ID, err.Error())) + + c.exceptionCount++ + if c.exceptionCount >= MaxExceptions { + newErr := fmt.Errorf("plugin(%s): Encountered too many exceptions, last error: %w", c.ext.ID, err) + c.logger.Error().Err(newErr).Msg("plugin: Encountered too many exceptions, interrupting plugin") + c.fatalError(newErr) + } +} + +func (c *Context) fatalError(err error) { + c.logger.Error().Err(err).Msg("plugin: Encountered fatal error, interrupting plugin") + c.wsEventManager.SendEvent(events.ConsoleWarn, fmt.Sprintf("plugin(%s): Encountered fatal error, interrupting plugin", c.ext.ID)) + c.ui.lastException = err.Error() + + c.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("plugin(%s): Fatal error: %s", c.ext.ID, err.Error())) + c.wsEventManager.SendEvent(events.ConsoleWarn, fmt.Sprintf("plugin(%s): Fatal error: %s", c.ext.ID, err.Error())) + + // Unload the UI and signal the Plugin that it's been terminated + c.ui.Unload(true) +} + +func (c *Context) registerOnCleanup(fn func()) { + c.atomicCleanupCounter.Add(1) + c.onCleanupFns.Set(c.atomicCleanupCounter.Load(), fn) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// jsState is used to create a new state object +// +// Example: +// const text = ctx.state("Hello, world!"); +// text.set("Button clicked"); +// text.get(); // "Button clicked" +// text.length; // 15 +// text.set(p => p + "!!!!"); +// text.get(); // "Button clicked!!!!" +// text.length; // 19 +func (c *Context) jsState(call goja.FunctionCall) goja.Value { + id := uuid.New().String() + initial := goja.Undefined() + if len(call.Arguments) > 0 { + initial = call.Argument(0) + } + + state := &State{ + ID: id, + Value: initial, + } + + // Store the initial state + c.states.Set(id, state) + + // Create a new JS object to represent the state + stateObj := c.vm.NewObject() + + // Define getter and setter functions that interact with the Go-managed state + jsGetState := func(call goja.FunctionCall) goja.Value { + res, _ := c.states.Get(id) + return res.Value + } + jsSetState := func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 { + arg := call.Argument(0) + // e.g. state.set(prev => prev + "!!!") + if callback, ok := goja.AssertFunction(arg); ok { + prevState, ok := c.states.Get(id) + if ok { + newVal, _ := callback(goja.Undefined(), prevState.Value) + c.states.Set(id, &State{ + ID: id, + Value: newVal, + }) + c.queueStateUpdate(id) + } + } else { + c.states.Set(id, &State{ + ID: id, + Value: arg, + }) + c.queueStateUpdate(id) + } + } + return goja.Undefined() + } + + jsGetStateVal := c.vm.ToValue(jsGetState) + jsSetStateVal := c.vm.ToValue(jsSetState) + + // Define a dynamic state object that includes a 'value' property, get(), set(), and length + jsDynamicDefFuncValue, err := c.vm.RunString(`(function(obj, getter, setter) { + Object.defineProperty(obj, 'value', { + get: getter, + set: setter, + enumerable: true, + configurable: true + }); + obj.get = function() { return this.value; }; + obj.set = function(val) { this.value = val; return val; }; + Object.defineProperty(obj, 'length', { + get: function() { + var val = this.value; + return (typeof val === 'string' ? val.length : undefined); + }, + enumerable: true, + configurable: true + }); + return obj; +})`) + if err != nil { + c.handleTypeError(err.Error()) + } + jsDynamicDefFunc, ok := goja.AssertFunction(jsDynamicDefFuncValue) + if !ok { + c.handleTypeError("dynamic definition is not a function") + } + + jsDynamicState, err := jsDynamicDefFunc(goja.Undefined(), stateObj, jsGetStateVal, jsSetStateVal) + if err != nil { + c.handleTypeError(err.Error()) + } + + // Attach hidden state ID for subscription + if obj, ok := jsDynamicState.(*goja.Object); ok { + _ = obj.Set("__stateId", id) + } + + return jsDynamicState +} + +// jsSetTimeout +// +// Example: +// const cancel = ctx.setTimeout(() => { +// console.log("Printing after 1 second"); +// }, 1000); +// cancel(); // cancels the timeout +func (c *Context) jsSetTimeout(call goja.FunctionCall) goja.Value { + if len(call.Arguments) != 2 { + c.handleTypeError("setTimeout requires a function and a delay") + } + + fnValue := call.Argument(0) + delayValue := call.Argument(1) + + fn, ok := goja.AssertFunction(fnValue) + if !ok { + c.handleTypeError("setTimeout requires a function") + } + + delay, ok := delayValue.Export().(int64) + if !ok { + c.handleTypeError("delay must be a number") + } + + ctx, cancel := context.WithCancel(context.Background()) + + globalObj := c.vm.GlobalObject() + + go func(fn goja.Callable, globalObj goja.Value) { + select { + case <-ctx.Done(): + return + case <-time.After(time.Duration(delay) * time.Millisecond): + c.scheduler.ScheduleAsync(func() error { + _, err := fn(globalObj) + return err + }) + } + }(fn, globalObj) + + cancelFunc := func(call goja.FunctionCall) goja.Value { + cancel() + return goja.Undefined() + } + + return c.vm.ToValue(cancelFunc) +} + +// jsSetInterval +// +// Example: +// const cancel = ctx.setInterval(() => { +// console.log("Printing every second"); +// }, 1000); +// cancel(); // cancels the interval +func (c *Context) jsSetInterval(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + c.handleTypeError("setInterval requires a function and a delay") + } + + fnValue := call.Argument(0) + delayValue := call.Argument(1) + + fn, ok := goja.AssertFunction(fnValue) + if !ok { + c.handleTypeError("setInterval requires a function") + } + + delay, ok := delayValue.Export().(int64) + if !ok { + c.handleTypeError("delay must be a number") + } + + globalObj := c.vm.GlobalObject() + + ctx, cancel := context.WithCancel(context.Background()) + go func(fn goja.Callable, globalObj goja.Value) { + for { + select { + case <-ctx.Done(): + return + case <-time.After(time.Duration(delay) * time.Millisecond): + c.scheduler.ScheduleAsync(func() error { + _, err := fn(globalObj) + return err + }) + } + } + }(fn, globalObj) + + cancelFunc := func(call goja.FunctionCall) goja.Value { + cancel() + return goja.Undefined() + } + + c.registerOnCleanup(func() { + cancel() + }) + + return c.vm.ToValue(cancelFunc) +} + +// jsEffect +// +// Example: +// const text = ctx.state("Hello, world!"); +// ctx.effect(() => { +// console.log("Text changed"); +// }, [text]); +// text.set("Hello, world!"); // This will not trigger the effect +// text.set("Hello, world! 2"); // This will trigger the effect +func (c *Context) jsEffect(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + c.handleTypeError("effect requires a function and an array of dependencies") + } + + effectFn, ok := goja.AssertFunction(call.Argument(0)) + if !ok { + c.handleTypeError("first argument to effect must be a function") + } + + depsObj, ok := call.Argument(1).(*goja.Object) + // If no dependencies, execute effect once and return + if !ok { + c.scheduler.ScheduleAsync(func() error { + _, err := effectFn(goja.Undefined()) + return err + }) + return c.vm.ToValue(func(call goja.FunctionCall) goja.Value { + return goja.Undefined() + }) + } + + // Generate unique ID for this effect + effectID := uuid.New().String() + + // Prepare dependencies and their old values + lengthVal := depsObj.Get("length") + depsLen := int(lengthVal.ToInteger()) + + // If dependency array is empty, execute effect once and return + if depsLen == 0 { + c.scheduler.ScheduleAsync(func() error { + _, err := effectFn(goja.Undefined()) + return err + }) + return c.vm.ToValue(func(call goja.FunctionCall) goja.Value { + return goja.Undefined() + }) + } + + deps := make([]*goja.Object, depsLen) + oldValues := make([]goja.Value, depsLen) + dropIDs := make([]string, depsLen) // to store state IDs of dependencies + for i := 0; i < depsLen; i++ { + depVal := depsObj.Get(fmt.Sprintf("%d", i)) + depObj, ok := depVal.(*goja.Object) + if !ok { + c.handleTypeError("dependency is not an object") + } + deps[i] = depObj + oldValues[i] = depObj.Get("value") + + idVal := depObj.Get("__stateId") + exported := idVal.Export() + idStr, ok := exported.(string) + if !ok { + idStr = fmt.Sprintf("%v", exported) + } + dropIDs[i] = idStr + } + + globalObj := c.vm.GlobalObject() + + // Subscribe to state updates + subChan := c.subscribeStateUpdates() + ctxEffect, cancel := context.WithCancel(context.Background()) + go func(effectFn *goja.Callable, globalObj goja.Value) { + for { + select { + case <-ctxEffect.Done(): + return + case updatedState := <-subChan: + if effectFn != nil && updatedState != nil { + // Check if the updated state is one of our dependencies by matching __stateId + for i, depID := range dropIDs { + if depID == updatedState.ID { + newVal := deps[i].Get("value") + if !reflect.DeepEqual(oldValues[i].Export(), newVal.Export()) { + oldValues[i] = newVal + + // Check for infinite loops + c.mu.Lock() + if c.effectStack[effectID] { + c.logger.Warn().Msgf("Detected potential infinite loop in effect %s, skipping execution", effectID) + c.mu.Unlock() + continue + } + + // Clean up old calls and check rate + c.cleanupOldEffectCalls(effectID) + callsInWindow := len(c.effectCalls[effectID]) + if callsInWindow >= MaxEffectCallsPerWindow { + c.mu.Unlock() + c.fatalError(fmt.Errorf("effect %s exceeded rate limit with %d calls in %dms window", effectID, callsInWindow, EffectTimeWindow)) + return + } + + // Track this call + c.effectStack[effectID] = true + c.effectCalls[effectID] = append(c.effectCalls[effectID], time.Now()) + c.mu.Unlock() + + c.scheduler.ScheduleAsync(func() error { + _, err := (*effectFn)(globalObj) + c.mu.Lock() + c.effectStack[effectID] = false + c.mu.Unlock() + return err + }) + } + } + } + } + } + } + }(&effectFn, globalObj) + + cancelFunc := func(call goja.FunctionCall) goja.Value { + cancel() + c.mu.Lock() + delete(c.effectCalls, effectID) + delete(c.effectStack, effectID) + c.mu.Unlock() + return goja.Undefined() + } + + c.registerOnCleanup(func() { + cancel() + }) + + return c.vm.ToValue(cancelFunc) +} + +// jsRegisterEventHandler +// +// Example: +// ctx.registerEventHandler("button-clicked", (e) => { +// console.log("Button clicked", e); +// }); +func (c *Context) jsRegisterEventHandler(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + c.handleTypeError("registerEventHandler requires a handler name and a function") + } + + handlerName := call.Argument(0).String() + handlerCallback, ok := goja.AssertFunction(call.Argument(1)) + if !ok { + c.handleTypeError("second argument to registerEventHandler must be a function") + } + + eventListener := c.RegisterEventListener(ClientEventHandlerTriggeredEvent) + payload := ClientEventHandlerTriggeredEventPayload{} + + globalObj := c.vm.GlobalObject() + + eventListener.SetCallback(func(event *ClientPluginEvent) { + if event.ParsePayloadAs(ClientEventHandlerTriggeredEvent, &payload) { + // Check if the handler name matches + if payload.HandlerName == handlerName { + c.scheduler.ScheduleAsync(func() error { + // Trigger the callback with the event payload + _, err := handlerCallback(globalObj, c.vm.ToValue(payload.Event)) + return err + }) + } + } + }) + + return goja.Undefined() +} + +// jsEventHandler - inline event handler +// +// Example: +// tray.render(() => tray.button("Click me", { +// onClick: ctx.eventHandler("unique-key", (e) => { +// console.log("Button clicked", e); +// }) +// })); +func (c *Context) jsEventHandler(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + c.handleTypeError("eventHandler requires a function") + } + + uniqueKey := call.Argument(0).String() + if existingListener, ok := c.registeredInlineEventHandlers.Get(uniqueKey); ok { + c.UnregisterEventListenerE(existingListener) + } + + handlerCallback, ok := goja.AssertFunction(call.Argument(1)) + if !ok { + c.handleTypeError("second argument to eventHandler must be a function") + } + + id := "__eventHandler__" + uuid.New().String() + + eventListener := c.RegisterEventListener(ClientEventHandlerTriggeredEvent) + payload := ClientEventHandlerTriggeredEventPayload{} + + eventListener.SetCallback(func(event *ClientPluginEvent) { + if event.ParsePayloadAs(ClientEventHandlerTriggeredEvent, &payload) { + // Check if the handler name matches + if payload.HandlerName == id { + c.scheduler.ScheduleAsync(func() error { + // Trigger the callback with the event payload + _, err := handlerCallback(goja.Undefined(), c.vm.ToValue(payload.Event)) + return err + }) + } + } + }) + + c.registeredInlineEventHandlers.Set(uniqueKey, eventListener) + + return c.vm.ToValue(id) +} + +// jsfieldRef allows to dynamically handle the value of a field outside the rendering context +// +// Example: +// const fieldRef = ctx.fieldRef("defaultValue") +// fieldRef.current // "defaultValue" +// fieldRef.setValue("Hello World!") // Triggers an immediate update on the client +// fieldRef.current // "Hello World!" +// +// tray.render(() => tray.input({ fieldRef: "my-field" })) +func (c *Context) jsfieldRef(call goja.FunctionCall) goja.Value { + fieldRefObj := c.vm.NewObject() + + if c.fieldRefCount >= MAX_FIELD_REFS { + c.handleTypeError("Too many field refs registered") + return goja.Undefined() + } + + id := uuid.New().String() + fieldRefObj.Set("__ID", id) + + c.fieldRefCount++ + + var valueRef interface{} + var onChangeCallback func(value interface{}) + + // Handle default value if provided + if len(call.Arguments) > 0 { + valueRef = call.Argument(0).Export() + fieldRefObj.Set("current", valueRef) + } else { + fieldRefObj.Set("current", goja.Undefined()) + } + + fieldRefObj.Set("setValue", func(call goja.FunctionCall) goja.Value { + value := call.Argument(0).Export() + if value == nil { + c.handleTypeError("setValue requires a value") + } + + c.SendEventToClient(ServerFieldRefSetValueEvent, ServerFieldRefSetValueEventPayload{ + FieldRef: id, + Value: value, + }) + + valueRef = value + fieldRefObj.Set("current", value) + + return goja.Undefined() + }) + + fieldRefObj.Set("onValueChange", func(call goja.FunctionCall) goja.Value { + callback, ok := goja.AssertFunction(call.Argument(0)) + if !ok { + c.handleTypeError("onValueChange requires a function") + } + + onChangeCallback = func(value interface{}) { + _, err := callback(goja.Undefined(), c.vm.ToValue(value)) + if err != nil { + c.handleTypeError(err.Error()) + } + } + + return goja.Undefined() + }) + + // Listen for changes from the client + eventListener := c.RegisterEventListener(ClientFieldRefSendValueEvent, ClientRenderTrayEvent) + + eventListener.SetCallback(func(event *ClientPluginEvent) { + payload := ClientFieldRefSendValueEventPayload{} + renderPayload := ClientRenderTrayEventPayload{} + if event.ParsePayloadAs(ClientFieldRefSendValueEvent, &payload) && payload.FieldRef == id { + valueRef = payload.Value + // Schedule the update of the object + if payload.Value != nil { + c.scheduler.ScheduleAsync(func() error { + fieldRefObj.Set("current", payload.Value) + return nil + }) + if onChangeCallback != nil { + c.scheduler.ScheduleAsync(func() error { + onChangeCallback(payload.Value) + return nil + }) + } + } + } + + // Check if the client is requesting a render + // If it is, we send the current value to the client + if event.ParsePayloadAs(ClientRenderTrayEvent, &renderPayload) { + c.SendEventToClient(ServerFieldRefSetValueEvent, ServerFieldRefSetValueEventPayload{ + FieldRef: id, + Value: valueRef, + }) + } + }) + + return fieldRefObj +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (c *Context) subscribeStateUpdates() chan *State { + ch := make(chan *State, 10) + c.mu.Lock() + c.stateSubscribers = append(c.stateSubscribers, ch) + c.mu.Unlock() + return ch +} + +func (c *Context) publishStateUpdate(id string) { + state, ok := c.states.Get(id) + if !ok { + return + } + c.mu.RLock() + defer c.mu.RUnlock() + for _, sub := range c.stateSubscribers { + select { + case sub <- state: + default: + } + } +} + +func (c *Context) cleanupOldEffectCalls(effectID string) { + now := time.Now() + window := time.Duration(EffectTimeWindow) * time.Millisecond + var validCalls []time.Time + + for _, t := range c.effectCalls[effectID] { + if now.Sub(t) <= window { + validCalls = append(validCalls, t) + } + } + + c.effectCalls[effectID] = validCalls +} + +// queueStateUpdate adds a state update to the batch queue +func (c *Context) queueStateUpdate(id string) { + c.updateBatchMu.Lock() + defer c.updateBatchMu.Unlock() + + // Add to pending updates + c.pendingStateUpdates[id] = struct{}{} + + // Start the timer if it's not running + if !c.updateBatchTimer.Stop() { + select { + case <-c.updateBatchTimer.C: + // Timer already fired, drain the channel + default: + // Timer was already stopped + } + } + c.updateBatchTimer.Reset(time.Duration(StateUpdateBatchInterval) * time.Millisecond) +} + +// flushStateUpdates processes all pending state updates +func (c *Context) flushStateUpdates() { + c.updateBatchMu.Lock() + + // Get all pending updates + pendingUpdates := make([]string, 0, len(c.pendingStateUpdates)) + for id := range c.pendingStateUpdates { + pendingUpdates = append(pendingUpdates, id) + } + + // Clear the pending updates + c.pendingStateUpdates = make(map[string]struct{}) + + c.updateBatchMu.Unlock() + + // Process all updates + for _, id := range pendingUpdates { + c.publishStateUpdate(id) + } + + // Trigger UI update after state changes + c.triggerUIUpdate() +} + +// triggerUIUpdate schedules a UI update after state changes +func (c *Context) triggerUIUpdate() { + c.uiUpdateMu.Lock() + defer c.uiUpdateMu.Unlock() + + // Rate limit UI updates + if time.Since(c.lastUIUpdateAt) < time.Millisecond*time.Duration(UIUpdateRateLimit) { + return + } + + c.lastUIUpdateAt = time.Now() + + // Trigger tray update if available + if c.trayManager != nil { + c.trayManager.renderTrayScheduled() + } +} + +// Cleanup is called when the UI is being unloaded +func (c *Context) Cleanup() { + // Flush any pending state updates + c.flushStateUpdates() + + // Flush any pending events + c.flushEventBatch() +} + +// Stop is called when the UI is being unloaded +func (c *Context) Stop() { + c.logger.Debug().Msg("plugin: Stopping context") + + if c.updateBatchTimer != nil { + c.logger.Trace().Msg("plugin: Stopping update batch timer") + c.updateBatchTimer.Stop() + } + + if c.eventBatchTimer != nil { + c.logger.Trace().Msg("plugin: Stopping event batch timer") + c.eventBatchTimer.Stop() + } + + // Stop the scheduler + c.logger.Trace().Msg("plugin: Stopping scheduler") + c.scheduler.Stop() + + // Stop the cron + if cron, hasCron := c.cron.Get(); hasCron { + c.logger.Trace().Msg("plugin: Stopping cron") + cron.Stop() + } + + // Stop all event listeners + c.logger.Trace().Msg("plugin: Stopping event listeners") + eventListenersToClose := make([]*EventListener, 0) + + // First collect all listeners to avoid modification during iteration + c.eventBus.Range(func(_ ClientEventType, listenerMap *result.Map[string, *EventListener]) bool { + listenerMap.Range(func(_ string, listener *EventListener) bool { + eventListenersToClose = append(eventListenersToClose, listener) + return true + }) + return true + }) + + // Then close them all outside the locks + for _, listener := range eventListenersToClose { + func(l *EventListener) { + defer func() { + if r := recover(); r != nil { + c.logger.Error().Err(fmt.Errorf("%v", r)).Msg("plugin: Error stopping event listener") + } + }() + l.Close() + }(listener) + } + + // Finally clear the maps + c.eventBus.Range(func(_ ClientEventType, listenerMap *result.Map[string, *EventListener]) bool { + listenerMap.Clear() + return true + }) + c.eventBus.Clear() + + // Stop all state subscribers + c.logger.Trace().Msg("plugin: Stopping state subscribers") + for _, sub := range c.stateSubscribers { + go func(sub chan *State) { + defer func() { + if r := recover(); r != nil { + c.logger.Error().Err(fmt.Errorf("%v", r)).Msg("plugin: Error stopping state subscriber") + } + }() + close(sub) + }(sub) + } + + // Run all cleanup functions + c.onCleanupFns.Range(func(key int64, fn func()) bool { + fn() + return true + }) + c.onCleanupFns.Clear() + + c.actionManager.UnmountAll() + c.actionManager.renderAnimePageButtons() + + c.logger.Debug().Msg("plugin: Stopped context") +} + +// queueEventToClient adds an event to the batch queue for sending to the client +func (c *Context) queueEventToClient(clientID string, eventType ServerEventType, payload interface{}) { + c.eventBatchMu.Lock() + defer c.eventBatchMu.Unlock() + + // Create the plugin event + event := &ServerPluginEvent{ + ExtensionID: c.ext.ID, + Type: eventType, + Payload: payload, + } + + // Add to pending events + c.pendingClientEvents = append(c.pendingClientEvents, event) + c.eventBatchSize++ + + // If this is the first event, start the timer + if c.eventBatchSize == 1 { + c.eventBatchTimer.Reset(eventBatchFlushInterval * time.Millisecond) + } + + // If we've reached max batch size, flush immediately + if c.eventBatchSize >= maxEventBatchSize { + // Use goroutine to avoid deadlock since we're already holding the lock + go c.flushEventBatch() + } +} + +// flushEventBatch sends all pending events as a batch to the client +func (c *Context) flushEventBatch() { + c.eventBatchMu.Lock() + + // If there are no events, just unlock and return + if c.eventBatchSize == 0 { + c.eventBatchMu.Unlock() + return + } + + // Stop the timer + c.eventBatchTimer.Stop() + + // Create a copy of the pending events + allEvents := make([]*ServerPluginEvent, len(c.pendingClientEvents)) + copy(allEvents, c.pendingClientEvents) + + // Clear the pending events + c.pendingClientEvents = c.pendingClientEvents[:0] + c.eventBatchSize = 0 + + c.eventBatchMu.Unlock() + + // If only one event, send it directly to maintain compatibility with current system + if len(allEvents) == 1 { + // c.wsEventManager.SendEvent("plugin", allEvents[0]) + c.wsEventManager.SendEvent(string(events.PluginEvent), &ServerPluginEvent{ + ExtensionID: c.ext.ID, + Type: allEvents[0].Type, + Payload: allEvents[0].Payload, + }) + return + } + + // Send events as a batch + batchPayload := &BatchedPluginEvents{ + Events: allEvents, + } + + // Send the batch + c.wsEventManager.SendEvent(string(events.PluginEvent), &ServerPluginEvent{ + ExtensionID: c.ext.ID, + Type: "plugin:batch-events", + Payload: batchPayload, + }) +} diff --git a/seanime-2.9.10/internal/plugin/ui/dom.go b/seanime-2.9.10/internal/plugin/ui/dom.go new file mode 100644 index 0000000..cad6792 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/dom.go @@ -0,0 +1,1610 @@ +package plugin_ui + +import ( + "seanime/internal/util/result" + + "github.com/dop251/goja" + "github.com/google/uuid" +) + +// DOMManager handles DOM manipulation requests from plugins +type DOMManager struct { + ctx *Context + elementObservers *result.Map[string, *ElementObserver] + eventListeners *result.Map[string, *DOMEventListener] +} + +type ElementObserver struct { + ID string + Selector string + Callback goja.Callable +} + +type DOMEventListener struct { + ID string + ElementId string + EventType string + Callback goja.Callable +} + +// NewDOMManager creates a new DOM manager +func NewDOMManager(ctx *Context) *DOMManager { + return &DOMManager{ + ctx: ctx, + elementObservers: result.NewResultMap[string, *ElementObserver](), + eventListeners: result.NewResultMap[string, *DOMEventListener](), + } +} + +// BindToObj binds DOM manipulation methods to a context object +func (d *DOMManager) BindToObj(vm *goja.Runtime, obj *goja.Object) { + domObj := vm.NewObject() + _ = domObj.Set("query", d.jsQuery) + _ = domObj.Set("queryOne", d.jsQueryOne) + _ = domObj.Set("observe", d.jsObserve) + _ = domObj.Set("observeInView", d.jsObserveInView) + _ = domObj.Set("createElement", d.jsCreateElement) + _ = domObj.Set("asElement", d.jsAsElement) + _ = domObj.Set("onReady", d.jsOnReady) + + _ = obj.Set("dom", domObj) +} + +func (d *DOMManager) jsOnReady(call goja.FunctionCall) goja.Value { + + callback, ok := goja.AssertFunction(call.Argument(0)) + if !ok { + d.ctx.handleTypeError("onReady requires a callback function") + } + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMReadyEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + d.ctx.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), d.ctx.vm.ToValue(event.Payload)) + if err != nil { + d.ctx.handleException(err) + } + return nil + }) + }) + + return d.ctx.vm.ToValue(nil) +} + +// jsQuery handles querying for multiple DOM elements +func (d *DOMManager) jsQuery(call goja.FunctionCall) goja.Value { + selector := call.Argument(0).String() + + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + opts := d.getQueryElementOptions(call.Argument(1)) + + // Set up a one-time event listener for the response + listener := d.ctx.RegisterEventListener(ClientDOMQueryResultEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMQueryResultEventPayload + if event.ParsePayloadAs(ClientDOMQueryResultEvent, &payload) && payload.RequestID == requestId { + d.ctx.scheduler.ScheduleAsync(func() error { + elemObjs := make([]interface{}, 0, len(payload.Elements)) + for _, elem := range payload.Elements { + if elemData, ok := elem.(map[string]interface{}); ok { + elemObjs = append(elemObjs, d.createDOMElementObject(elemData)) + } + } + resolve(d.ctx.vm.ToValue(elemObjs)) + return nil + }) + d.ctx.UnregisterEventListener(listener.ID) + } + }) + + // Send the query request to the client + d.ctx.SendEventToClient(ServerDOMQueryEvent, &ServerDOMQueryEventPayload{ + Selector: selector, + RequestID: requestId, + WithInnerHTML: opts.WithInnerHTML, + WithOuterHTML: opts.WithOuterHTML, + IdentifyChildren: opts.IdentifyChildren, + }) + + return d.ctx.vm.ToValue(promise) +} + +// jsQueryOne handles querying for a single DOM element +func (d *DOMManager) jsQueryOne(call goja.FunctionCall) goja.Value { + selector := call.Argument(0).String() + + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + opts := d.getQueryElementOptions(call.Argument(1)) + + // Set up a one-time event listener for the response + listener := d.ctx.RegisterEventListener(ClientDOMQueryOneResultEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMQueryOneResultEventPayload + if event.ParsePayloadAs(ClientDOMQueryOneResultEvent, &payload) && payload.RequestID == requestId { + d.ctx.scheduler.ScheduleAsync(func() error { + if payload.Element != nil { + if elemData, ok := payload.Element.(map[string]interface{}); ok { + resolve(d.ctx.vm.ToValue(d.createDOMElementObject(elemData))) + } else { + resolve(d.ctx.vm.ToValue(goja.Null())) + } + } else { + resolve(d.ctx.vm.ToValue(goja.Null())) + } + return nil + }) + d.ctx.UnregisterEventListener(listener.ID) + } + }) + + // Send the query request to the client + d.ctx.SendEventToClient(ServerDOMQueryOneEvent, &ServerDOMQueryOneEventPayload{ + Selector: selector, + RequestID: requestId, + WithInnerHTML: opts.WithInnerHTML, + WithOuterHTML: opts.WithOuterHTML, + IdentifyChildren: opts.IdentifyChildren, + }) + + return d.ctx.vm.ToValue(promise) +} + +type QueryElementOptions struct { + WithInnerHTML bool `json:"withInnerHTML"` + WithOuterHTML bool `json:"withOuterHTML"` + IdentifyChildren bool `json:"identifyChildren"` +} + +func (d *DOMManager) getQueryElementOptions(argument goja.Value) QueryElementOptions { + options := QueryElementOptions{ + WithInnerHTML: false, + WithOuterHTML: false, + IdentifyChildren: false, + } + + if argument != goja.Undefined() && argument != goja.Null() { + optsObj, ok := argument.Export().(map[string]interface{}) + if !ok { + d.ctx.handleTypeError("third argument 'opts' must be an object") + } + + // Extract 'withInnerHTML' from 'opts' if present + if val, exists := optsObj["withInnerHTML"]; exists { + options.WithInnerHTML, ok = val.(bool) + if !ok { + d.ctx.handleTypeError("'withInnerHTML' property must be a boolean") + } + } + + // Extract 'identifyChildren' from 'opts' if present + if val, exists := optsObj["identifyChildren"]; exists { + options.IdentifyChildren, ok = val.(bool) + if !ok { + d.ctx.handleTypeError("'identifyChildren' property must be a boolean") + } + } + + // Extract 'withOuterHTML' from 'opts' if present + if val, exists := optsObj["withOuterHTML"]; exists { + options.WithOuterHTML, ok = val.(bool) + if !ok { + d.ctx.handleTypeError("'withOuterHTML' property must be a boolean") + } + } + } + + return options +} + +// jsObserve starts observing DOM elements matching a selector +func (d *DOMManager) jsObserve(call goja.FunctionCall) goja.Value { + selector := call.Argument(0).String() + callback, ok := goja.AssertFunction(call.Argument(1)) + if !ok { + d.ctx.handleTypeError("observe requires a callback function") + } + + options := d.getQueryElementOptions(call.Argument(2)) + + // Create observer ID + observerId := uuid.New().String() + + // Store the observer + observer := &ElementObserver{ + ID: observerId, + Selector: selector, + Callback: callback, + } + + d.elementObservers.Set(observerId, observer) + + // Send observe request to client + d.ctx.SendEventToClient(ServerDOMObserveEvent, &ServerDOMObserveEventPayload{ + Selector: selector, + ObserverId: observerId, + WithInnerHTML: options.WithInnerHTML, + WithOuterHTML: options.WithOuterHTML, + IdentifyChildren: options.IdentifyChildren, + }) + + // Start a goroutine to handle observer updates + listener := d.ctx.RegisterEventListener(ClientDOMObserveResultEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMObserveResultEventPayload + if event.ParsePayloadAs(ClientDOMObserveResultEvent, &payload) && payload.ObserverId == observerId { + d.ctx.scheduler.ScheduleAsync(func() error { + observer, exists := d.elementObservers.Get(observerId) + + if !exists { + return nil + } + + // Convert elements to DOM element objects directly in the VM thread + elemObjs := make([]interface{}, 0, len(payload.Elements)) + for _, elem := range payload.Elements { + if elemData, ok := elem.(map[string]interface{}); ok { + elemObjs = append(elemObjs, d.createDOMElementObject(elemData)) + } + } + + // Call the callback directly now that we have all elements + _, err := observer.Callback(goja.Undefined(), d.ctx.vm.ToValue(elemObjs)) + if err != nil { + d.ctx.handleException(err) + } + return nil + }) + } + }) + + // Listen for DOM ready events to re-observe elements after page reload + domReadyListener := d.ctx.RegisterEventListener(ClientDOMReadyEvent) + + domReadyListener.SetCallback(func(event *ClientPluginEvent) { + // Re-send the observe request when the DOM is ready + d.ctx.SendEventToClient(ServerDOMObserveEvent, &ServerDOMObserveEventPayload{ + Selector: selector, + ObserverId: observerId, + WithInnerHTML: options.WithInnerHTML, + WithOuterHTML: options.WithOuterHTML, + IdentifyChildren: options.IdentifyChildren, + }) + }) + + // Return a function to stop observing + cancelFn := func() { + d.ctx.UnregisterEventListener(listener.ID) + d.ctx.UnregisterEventListener(domReadyListener.ID) + d.elementObservers.Delete(observerId) + + d.ctx.SendEventToClient(ServerDOMStopObserveEvent, &ServerDOMStopObserveEventPayload{ + ObserverId: observerId, + }) + } + + refetchFn := func() { + d.ctx.SendEventToClient(ServerDOMObserveEvent, &ServerDOMObserveEventPayload{ + Selector: selector, + ObserverId: observerId, + WithInnerHTML: options.WithInnerHTML, + WithOuterHTML: options.WithOuterHTML, + IdentifyChildren: options.IdentifyChildren, + }) + } + + d.ctx.registerOnCleanup(func() { + cancelFn() + }) + + return d.ctx.vm.ToValue([]interface{}{cancelFn, refetchFn}) +} + +// jsCreateElement creates a new DOM element +func (d *DOMManager) jsCreateElement(call goja.FunctionCall) goja.Value { + tagName := call.Argument(0).String() + + // Create a promise that will be resolved with the created element + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Set up a one-time event listener for the response + listener := d.ctx.RegisterEventListener(ClientDOMCreateResultEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMCreateResultEventPayload + if event.ParsePayloadAs(ClientDOMCreateResultEvent, &payload) && payload.RequestID == requestId { + if elemData, ok := payload.Element.(map[string]interface{}); ok { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.createDOMElementObject(elemData)) + return nil + }) + } + d.ctx.UnregisterEventListener(listener.ID) + } + }) + + // Send the create request to the client + d.ctx.SendEventToClient(ServerDOMCreateEvent, &ServerDOMCreateEventPayload{ + TagName: tagName, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +// jsAsElement returns a DOM element from an element ID +// This is useful because we don't need to query the DOM for an element +// We can just use the element ID that we already have to send events to the element +func (d *DOMManager) jsAsElement(call goja.FunctionCall) goja.Value { + elementId := call.Argument(0).String() + + element := d.ctx.vm.NewObject() + _ = element.Set("id", elementId) + + // Assign methods to the element + d.assignDOMElementMethods(element, elementId) + + return d.ctx.vm.ToValue(element) +} + +// HandleObserverUpdate processes DOM observer updates from client +func (d *DOMManager) HandleObserverUpdate(observerId string, elements []interface{}) { + +} + +// HandleDOMEvent processes DOM events from client +func (d *DOMManager) HandleDOMEvent(elementId string, eventType string, eventData map[string]interface{}) { + // Find all event listeners for this element and event type + d.eventListeners.Range(func(key string, listener *DOMEventListener) bool { + if listener.ElementId == elementId && listener.EventType == eventType { + // Schedule callback execution in the VM + d.ctx.scheduler.ScheduleAsync(func() error { + _, err := listener.Callback(goja.Undefined(), d.ctx.vm.ToValue(eventData)) + if err != nil { + d.ctx.handleException(err) + } + return nil + }) + } + return true + }) +} + +// createDOMElementObject creates a JavaScript object representing a DOM element +func (d *DOMManager) createDOMElementObject(elemData map[string]interface{}) *goja.Object { + elementObj := d.ctx.vm.NewObject() + + // Set basic properties + elementId, _ := elemData["id"].(string) + _ = elementObj.Set("id", elementId) + + if tagName, ok := elemData["tagName"].(string); ok { + _ = elementObj.Set("tagName", tagName) + } + + if text, ok := elemData["text"].(string); ok { + _ = elementObj.Set("text", text) + } + + if attributes, ok := elemData["attributes"].(map[string]interface{}); ok { + attributesObj := d.ctx.vm.NewObject() + for key, value := range attributes { + _ = attributesObj.Set(key, value) + } + _ = elementObj.Set("attributes", attributesObj) + } + + if style, ok := elemData["style"].(map[string]interface{}); ok { + styleObj := d.ctx.vm.NewObject() + for key, value := range style { + _ = styleObj.Set(key, value) + } + _ = styleObj.Set("style", styleObj) + } + + if className, ok := elemData["className"].(string); ok { + _ = elementObj.Set("className", className) + } + + if classList, ok := elemData["classList"].([]string); ok { + _ = elementObj.Set("classList", classList) + } + + if children, ok := elemData["children"].([]interface{}); ok { + childrenObjs := make([]*goja.Object, 0, len(children)) + for _, child := range children { + if childData, ok := child.(map[string]interface{}); ok { + childrenObjs = append(childrenObjs, d.createDOMElementObject(childData)) + } + } + _ = elementObj.Set("children", childrenObjs) + } + + if parent, ok := elemData["parent"].(map[string]interface{}); ok { + elementObj.Set("parent", d.createDOMElementObject(parent)) + } + + if innerHTML, ok := elemData["innerHTML"].(string); ok { + _ = elementObj.Set("innerHTML", innerHTML) + } + + if outerHTML, ok := elemData["outerHTML"].(string); ok { + _ = elementObj.Set("outerHTML", outerHTML) + } + + d.assignDOMElementMethods(elementObj, elementId) + + return elementObj +} + +func (d *DOMManager) assignDOMElementMethods(elementObj *goja.Object, elementId string) { + // Define methods + _ = elementObj.Set("getText", func() goja.Value { + return d.getElementText(elementId) + }) + + _ = elementObj.Set("setText", func(text string) { + d.setElementText(elementId, text) + }) + + _ = elementObj.Set("setInnerHTML", func(innerHTML string) { + d.setElementInnerHTML(elementId, innerHTML) + }) + + _ = elementObj.Set("setOuterHTML", func(outerHTML string) { + d.setElementOuterHTML(elementId, outerHTML) + }) + + _ = elementObj.Set("getAttribute", func(name string) goja.Value { + return d.getElementAttribute(elementId, name) + }) + + _ = elementObj.Set("getAttributes", func() goja.Value { + return d.getElementAttributes(elementId) + }) + + _ = elementObj.Set("setAttribute", func(name, value string) { + d.setElementAttribute(elementId, name, value) + }) + + _ = elementObj.Set("removeAttribute", func(name string) { + d.removeElementAttribute(elementId, name) + }) + + _ = elementObj.Set("hasAttribute", func(name string) goja.Value { + return d.hasElementAttribute(elementId, name) + }) + + _ = elementObj.Set("getProperty", func(name string) goja.Value { + return d.getElementProperty(elementId, name) + }) + + _ = elementObj.Set("setProperty", func(name string, value interface{}) { + d.setElementProperty(elementId, name, value) + }) + + _ = elementObj.Set("addClass", func(call goja.FunctionCall) { + classNames := make([]string, 0, len(call.Arguments)) + for _, arg := range call.Arguments { + if className := arg.String(); className != "" { + classNames = append(classNames, className) + } + } + d.addElementClass(elementId, classNames) + }) + + _ = elementObj.Set("removeClass", func(call goja.FunctionCall) { + classNames := make([]string, 0, len(call.Arguments)) + for _, arg := range call.Arguments { + if className := arg.String(); className != "" { + classNames = append(classNames, className) + } + } + d.removeElementClass(elementId, classNames) + }) + + _ = elementObj.Set("hasClass", func(className string) goja.Value { + return d.hasElementClass(elementId, className) + }) + + _ = elementObj.Set("setStyle", func(property, value string) { + d.setElementStyle(elementId, property, value) + }) + + _ = elementObj.Set("setCssText", func(cssText string) { + d.setElementCssText(elementId, cssText) + }) + + _ = elementObj.Set("getStyle", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Argument(0)) { + property := call.Argument(0).String() + return d.ctx.vm.ToValue(d.getElementStyle(elementId, property)) + } + return d.ctx.vm.ToValue(d.getElementStyles(elementId)) + }) + + _ = elementObj.Set("getComputedStyle", func(property string) goja.Value { + return d.getElementComputedStyle(elementId, property) + }) + + _ = elementObj.Set("append", func(child *goja.Object) { + childId := child.Get("id").String() + d.appendElement(elementId, childId) + }) + + _ = elementObj.Set("before", func(sibling *goja.Object) { + siblingId := sibling.Get("id").String() + d.insertElementBefore(elementId, siblingId) + }) + + _ = elementObj.Set("after", func(sibling *goja.Object) { + siblingId := sibling.Get("id").String() + d.insertElementAfter(elementId, siblingId) + }) + + _ = elementObj.Set("remove", func() { + d.removeElement(elementId) + }) + + _ = elementObj.Set("getParent", func(opts QueryElementOptions) goja.Value { + return d.getElementParent(elementId, opts) + }) + + _ = elementObj.Set("getChildren", func(opts QueryElementOptions) goja.Value { + return d.getElementChildren(elementId, opts) + }) + + _ = elementObj.Set("addEventListener", func(event string, callback goja.Callable) func() { + return d.addElementEventListener(elementId, event, callback) + }) + + _ = elementObj.Set("getDataAttribute", func(key string) goja.Value { + return d.getElementDataAttribute(elementId, key) + }) + + _ = elementObj.Set("getDataAttributes", func() goja.Value { + return d.getElementDataAttributes(elementId) + }) + + _ = elementObj.Set("setDataAttribute", func(key, value string) { + d.setElementDataAttribute(elementId, key, value) + }) + + _ = elementObj.Set("removeDataAttribute", func(key string) { + d.removeElementDataAttribute(elementId, key) + }) + + _ = elementObj.Set("hasDataAttribute", func(key string) goja.Value { + return d.hasElementDataAttribute(elementId, key) + }) + + _ = elementObj.Set("hasStyle", func(property string) goja.Value { + return d.hasElementStyle(elementId, property) + }) + + _ = elementObj.Set("removeStyle", func(property string) { + d.removeElementStyle(elementId, property) + }) + + // Add element query methods + _ = elementObj.Set("query", func(selector string, opts QueryElementOptions) goja.Value { + return d.elementQuery(elementId, selector, opts) + }) + + _ = elementObj.Set("queryOne", func(selector string, opts QueryElementOptions) goja.Value { + return d.elementQueryOne(elementId, selector, opts) + }) +} + +// Element manipulation methods +// These send events to the client and handle responses + +func (d *DOMManager) getElementText(elementId string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + // Only process responses with matching element ID, action, and request ID + if payload.Action == "getText" && payload.ElementId == elementId && payload.RequestID == requestId { + if v, ok := payload.Result.(string); ok { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(v)) + return nil + }) + } else { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue("")) + return nil + }) + } + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + // Send the request to the client with the request ID + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "getText", + Params: map[string]interface{}{}, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) setElementText(elementId, text string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "setText", + Params: map[string]interface{}{ + "text": text, + }, + }) +} + +func (d *DOMManager) getElementAttribute(elementId, name string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + // Only process responses with matching element ID, action, and request ID + if payload.Action == "getAttribute" && payload.ElementId == elementId && payload.RequestID == requestId { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(payload.Result)) + return nil + }) + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "getAttribute", + Params: map[string]interface{}{ + "name": name, + }, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) setElementAttribute(elementId, name, value string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "setAttribute", + Params: map[string]interface{}{ + "name": name, + "value": value, + }, + }) +} + +func (d *DOMManager) removeElementAttribute(elementId, name string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "removeAttribute", + Params: map[string]interface{}{ + "name": name, + }, + }) +} + +func (d *DOMManager) addElementClass(elementId string, classNames []string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "addClass", + Params: map[string]interface{}{ + "classNames": classNames, + }, + }) +} + +func (d *DOMManager) removeElementClass(elementId string, classNames []string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "removeClass", + Params: map[string]interface{}{ + "classNames": classNames, + }, + }) +} + +func (d *DOMManager) hasElementClass(elementId, className string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + // Only process responses with matching element ID, action, and request ID + if payload.Action == "hasClass" && payload.ElementId == elementId && payload.RequestID == requestId { + if v, ok := payload.Result.(bool); ok { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(v)) + return nil + }) + } else { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(false)) + return nil + }) + } + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "hasClass", + Params: map[string]interface{}{ + "className": className, + }, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) setElementStyle(elementId, property, value string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "setStyle", + Params: map[string]interface{}{ + "property": property, + "value": value, + }, + }) +} + +func (d *DOMManager) setElementCssText(elementId, cssText string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "setCssText", + Params: map[string]interface{}{ + "cssText": cssText, + }, + }) +} + +func (d *DOMManager) getElementStyle(elementId, property string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) && payload.ElementId == elementId { + if payload.Action == "getStyle" && payload.RequestID == requestId { + if v, ok := payload.Result.(string); ok { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(v)) + return nil + }) + } else { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue("")) + return nil + }) + } + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "getStyle", + Params: map[string]interface{}{ + "property": property, + }, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) getElementComputedStyle(elementId, property string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) && payload.ElementId == elementId { + if payload.Action == "getComputedStyle" && payload.RequestID == requestId { + if v, ok := payload.Result.(string); ok { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(v)) + return nil + }) + } else { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue("")) + return nil + }) + } + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "getComputedStyle", + Params: map[string]interface{}{ + "property": property, + }, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) appendElement(parentID, childId string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: parentID, + Action: "append", + Params: map[string]interface{}{ + "childId": childId, + }, + }) +} + +func (d *DOMManager) insertElementBefore(elementId, siblingId string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "before", + Params: map[string]interface{}{ + "siblingId": siblingId, + }, + }) +} + +func (d *DOMManager) insertElementAfter(elementId, siblingId string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "after", + Params: map[string]interface{}{ + "siblingId": siblingId, + }, + }) +} + +func (d *DOMManager) removeElement(elementId string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "remove", + Params: map[string]interface{}{}, + }) +} + +func (d *DOMManager) getElementParent(elementId string, opts QueryElementOptions) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + if payload.Action == "getParent" && payload.ElementId == elementId && payload.RequestID == requestId { + if payload.Result != nil { + if parentData, ok := payload.Result.(map[string]interface{}); ok { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(d.createDOMElementObject(parentData))) + return nil + }) + } else { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(goja.Null())) + return nil + }) + } + } else { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(goja.Null())) + return nil + }) + } + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "getParent", + Params: map[string]interface{}{"opts": opts}, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) getElementChildren(elementId string, opts QueryElementOptions) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + + if payload.Action == "getChildren" && payload.ElementId == elementId && payload.RequestID == requestId { + if payload.Result != nil { + if childrenData, ok := payload.Result.([]interface{}); ok { + d.ctx.scheduler.ScheduleAsync(func() error { + childrenObjs := make([]interface{}, 0, len(childrenData)) + for _, child := range childrenData { + if childData, ok := child.(map[string]interface{}); ok { + childrenObjs = append(childrenObjs, d.createDOMElementObject(childData)) + } + } + resolve(d.ctx.vm.ToValue(childrenObjs)) + return nil + }) + } else { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue([]interface{}{})) + return nil + }) + } + } else { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue([]interface{}{})) + return nil + }) + } + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "getChildren", + Params: map[string]interface{}{"opts": opts}, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) addElementEventListener(elementId, event string, callback goja.Callable) func() { + // Create a unique ID for this event listener + listenerID := uuid.New().String() + + // Store the event listener + listener := &DOMEventListener{ + ID: listenerID, + ElementId: elementId, + EventType: event, + Callback: callback, + } + + d.eventListeners.Set(listenerID, listener) + + // Send the request to add the event listener to the client + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "addEventListener", + Params: map[string]interface{}{ + "event": event, + "listenerID": listenerID, + }, + }) + + // Return a function to remove the event listener + return func() { + d.eventListeners.Delete(listenerID) + + // Send the request to remove the event listener from the client + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "removeEventListener", + Params: map[string]interface{}{ + "event": event, + "listenerID": listenerID, + }, + }) + } +} + +func (d *DOMManager) getElementAttributes(elementId string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + if payload.Action == "getAttributes" && payload.ElementId == elementId && payload.RequestID == requestId { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(payload.Result)) + return nil + }) + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "getAttributes", + Params: map[string]interface{}{}, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) hasElementAttribute(elementId, name string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + if payload.Action == "hasAttribute" && payload.ElementId == elementId && payload.RequestID == requestId { + if v, ok := payload.Result.(bool); ok { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(v)) + return nil + }) + } else { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(false)) + return nil + }) + } + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "hasAttribute", + Params: map[string]interface{}{ + "name": name, + }, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) getElementProperty(elementId, name string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + if payload.Action == "getProperty" && payload.ElementId == elementId && payload.RequestID == requestId { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(payload.Result)) + return nil + }) + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "getProperty", + Params: map[string]interface{}{ + "name": name, + }, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) setElementProperty(elementId, name string, value interface{}) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "setProperty", + Params: map[string]interface{}{ + "name": name, + "value": value, + }, + }) +} + +func (d *DOMManager) getElementStyles(elementId string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + if payload.Action == "getStyle" && payload.ElementId == elementId && payload.RequestID == requestId && payload.Result != nil { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(payload.Result)) + return nil + }) + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "getStyle", + Params: map[string]interface{}{}, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) hasElementStyle(elementId, property string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + if payload.Action == "hasStyle" && payload.ElementId == elementId && payload.RequestID == requestId { + if v, ok := payload.Result.(bool); ok { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(v)) + return nil + }) + } else { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(false)) + return nil + }) + } + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "hasStyle", + Params: map[string]interface{}{ + "property": property, + }, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) getElementDataAttribute(elementId, key string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + if payload.Action == "getDataAttribute" && payload.ElementId == elementId && payload.RequestID == requestId { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(payload.Result)) + return nil + }) + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "getDataAttribute", + Params: map[string]interface{}{ + "key": key, + }, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) getElementDataAttributes(elementId string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + if payload.Action == "getDataAttributes" && payload.ElementId == elementId && payload.RequestID == requestId { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(payload.Result)) + return nil + }) + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "getDataAttributes", + Params: map[string]interface{}{}, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) setElementDataAttribute(elementId, key, value string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "setDataAttribute", + Params: map[string]interface{}{ + "key": key, + "value": value, + }, + }) +} + +func (d *DOMManager) removeElementDataAttribute(elementId, key string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "removeDataAttribute", + Params: map[string]interface{}{ + "key": key, + }, + }) +} + +func (d *DOMManager) hasElementDataAttribute(elementId, key string) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Listen for changes from the client + listener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMElementUpdatedEventPayload + if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) { + if payload.Action == "hasDataAttribute" && payload.ElementId == elementId && payload.RequestID == requestId { + if v, ok := payload.Result.(bool); ok { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(v)) + return nil + }) + } else { + d.ctx.scheduler.ScheduleAsync(func() error { + resolve(d.ctx.vm.ToValue(false)) + return nil + }) + } + d.ctx.UnregisterEventListener(listener.ID) + } + } + }) + + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "hasDataAttribute", + Params: map[string]interface{}{ + "key": key, + }, + RequestID: requestId, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) removeElementStyle(elementId, property string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "removeStyle", + Params: map[string]interface{}{ + "property": property, + }, + }) +} + +// elementQuery handles querying for multiple DOM elements from a parent element +func (d *DOMManager) elementQuery(elementId, selector string, opts QueryElementOptions) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Set up a one-time event listener for the response + listener := d.ctx.RegisterEventListener(ClientDOMQueryResultEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMQueryResultEventPayload + if event.ParsePayloadAs(ClientDOMQueryResultEvent, &payload) && payload.RequestID == requestId { + d.ctx.scheduler.ScheduleAsync(func() error { + elemObjs := make([]interface{}, 0, len(payload.Elements)) + for _, elem := range payload.Elements { + if elemData, ok := elem.(map[string]interface{}); ok { + elemObjs = append(elemObjs, d.createDOMElementObject(elemData)) + } + } + resolve(d.ctx.vm.ToValue(elemObjs)) + return nil + }) + d.ctx.UnregisterEventListener(listener.ID) + } + }) + + // Send the query request to the client + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "query", + Params: map[string]interface{}{ + "selector": selector, + "requestId": requestId, + "withInnerHTML": opts.WithInnerHTML, + "withOuterHTML": opts.WithOuterHTML, + }, + }) + + return d.ctx.vm.ToValue(promise) +} + +// elementQueryOne handles querying for a single DOM element from a parent element +func (d *DOMManager) elementQueryOne(elementId, selector string, opts QueryElementOptions) goja.Value { + promise, resolve, _ := d.ctx.vm.NewPromise() + + // Generate a unique request ID + requestId := uuid.New().String() + + // Set up a one-time event listener for the response + listener := d.ctx.RegisterEventListener(ClientDOMQueryOneResultEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMQueryOneResultEventPayload + if event.ParsePayloadAs(ClientDOMQueryOneResultEvent, &payload) && payload.RequestID == requestId { + d.ctx.scheduler.ScheduleAsync(func() error { + if payload.Element != nil { + if elemData, ok := payload.Element.(map[string]interface{}); ok { + resolve(d.ctx.vm.ToValue(d.createDOMElementObject(elemData))) + } else { + resolve(d.ctx.vm.ToValue(goja.Null())) + } + } else { + resolve(d.ctx.vm.ToValue(goja.Null())) + } + return nil + }) + d.ctx.UnregisterEventListener(listener.ID) + } + }) + + // Send the query request to the client + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "queryOne", + Params: map[string]interface{}{ + "selector": selector, + "requestId": requestId, + "withInnerHTML": opts.WithInnerHTML, + "withOuterHTML": opts.WithOuterHTML, + }, + }) + + return d.ctx.vm.ToValue(promise) +} + +func (d *DOMManager) setElementInnerHTML(elementId, innerHTML string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "setInnerHTML", + Params: map[string]interface{}{"innerHTML": innerHTML}, + }) +} + +func (d *DOMManager) setElementOuterHTML(elementId, outerHTML string) { + d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{ + ElementId: elementId, + Action: "setOuterHTML", + Params: map[string]interface{}{"outerHTML": outerHTML}, + }) +} + +// jsObserveInView starts observing DOM elements matching a selector when they are in the viewport +func (d *DOMManager) jsObserveInView(call goja.FunctionCall) goja.Value { + selector := call.Argument(0).String() + callback, ok := goja.AssertFunction(call.Argument(1)) + if !ok { + d.ctx.handleTypeError("observeInView requires a callback function") + } + + options := d.getQueryElementOptions(call.Argument(2)) + + // Get margin settings if provided + margin := "0px" + optsObj := call.Argument(2).ToObject(d.ctx.vm) + marginVal := optsObj.Get("margin") + if marginVal != nil && !goja.IsUndefined(marginVal) && !goja.IsNull(marginVal) { + margin = marginVal.String() + } + + // Create observer ID + observerId := uuid.New().String() + + // Store the observer + observer := &ElementObserver{ + ID: observerId, + Selector: selector, + Callback: callback, + } + + d.elementObservers.Set(observerId, observer) + + // Send observe request to client + d.ctx.SendEventToClient(ServerDOMObserveInViewEvent, &ServerDOMObserveInViewEventPayload{ + Selector: selector, + ObserverId: observerId, + WithInnerHTML: options.WithInnerHTML, + WithOuterHTML: options.WithOuterHTML, + IdentifyChildren: options.IdentifyChildren, + Margin: margin, + }) + + // Start a goroutine to handle observer updates + listener := d.ctx.RegisterEventListener(ClientDOMObserveResultEvent) + + listener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientDOMObserveResultEventPayload + if event.ParsePayloadAs(ClientDOMObserveResultEvent, &payload) && payload.ObserverId == observerId { + d.ctx.scheduler.ScheduleAsync(func() error { + observer, exists := d.elementObservers.Get(observerId) + + if !exists { + return nil + } + + // Convert elements to DOM element objects directly in the VM thread + elemObjs := make([]interface{}, 0, len(payload.Elements)) + for _, elem := range payload.Elements { + if elemData, ok := elem.(map[string]interface{}); ok { + elemObjs = append(elemObjs, d.createDOMElementObject(elemData)) + } + } + + // Call the callback directly now that we have all elements + _, err := observer.Callback(goja.Undefined(), d.ctx.vm.ToValue(elemObjs)) + if err != nil { + d.ctx.handleException(err) + } + return nil + }) + } + }) + + // Listen for DOM ready events to re-observe elements after page reload + domReadyListener := d.ctx.RegisterEventListener(ClientDOMReadyEvent) + + domReadyListener.SetCallback(func(event *ClientPluginEvent) { + // Re-send the observe request when the DOM is ready + d.ctx.SendEventToClient(ServerDOMObserveInViewEvent, &ServerDOMObserveInViewEventPayload{ + Selector: selector, + ObserverId: observerId, + WithInnerHTML: options.WithInnerHTML, + WithOuterHTML: options.WithOuterHTML, + IdentifyChildren: options.IdentifyChildren, + Margin: margin, + }) + }) + + // Return a function to stop observing + cancelFn := func() { + d.ctx.UnregisterEventListener(listener.ID) + d.ctx.UnregisterEventListener(domReadyListener.ID) + d.elementObservers.Delete(observerId) + + d.ctx.SendEventToClient(ServerDOMStopObserveEvent, &ServerDOMStopObserveEventPayload{ + ObserverId: observerId, + }) + } + + refetchFn := func() { + d.ctx.SendEventToClient(ServerDOMObserveInViewEvent, &ServerDOMObserveInViewEventPayload{ + Selector: selector, + ObserverId: observerId, + WithInnerHTML: options.WithInnerHTML, + WithOuterHTML: options.WithOuterHTML, + IdentifyChildren: options.IdentifyChildren, + Margin: margin, + }) + } + + d.ctx.registerOnCleanup(func() { + cancelFn() + }) + + return d.ctx.vm.ToValue([]interface{}{cancelFn, refetchFn}) +} diff --git a/seanime-2.9.10/internal/plugin/ui/events.go b/seanime-2.9.10/internal/plugin/ui/events.go new file mode 100644 index 0000000..112f289 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/events.go @@ -0,0 +1,397 @@ +package plugin_ui + +import "github.com/goccy/go-json" + +///////////////////////////////////////////////////////////////////////////////////// +// Client to server +///////////////////////////////////////////////////////////////////////////////////// + +type ClientEventType string + +// ClientPluginEvent is an event received from the client +type ClientPluginEvent struct { + // ExtensionID is the "sent to" + // If not set, the event is being sent to all plugins + ExtensionID string `json:"extensionId,omitempty"` + Type ClientEventType `json:"type"` + Payload interface{} `json:"payload"` +} + +const ( + ClientRenderTrayEvent ClientEventType = "tray:render" // Client wants to render the tray + ClientListTrayIconsEvent ClientEventType = "tray:list-icons" // Client wants to list all icons from all plugins + ClientTrayOpenedEvent ClientEventType = "tray:opened" // When the tray is opened + ClientTrayClosedEvent ClientEventType = "tray:closed" // When the tray is closed + ClientTrayClickedEvent ClientEventType = "tray:clicked" // When the tray is clicked + ClientListCommandPalettesEvent ClientEventType = "command-palette:list" // When the client wants to list all command palettes + ClientCommandPaletteOpenedEvent ClientEventType = "command-palette:opened" // When the client opens the command palette + ClientCommandPaletteClosedEvent ClientEventType = "command-palette:closed" // When the client closes the command palette + ClientRenderCommandPaletteEvent ClientEventType = "command-palette:render" // When the client requests the command palette to render + ClientCommandPaletteInputEvent ClientEventType = "command-palette:input" // The client sends the current input of the command palette + ClientCommandPaletteItemSelectedEvent ClientEventType = "command-palette:item-selected" // When the client selects an item from the command palette + ClientActionRenderAnimePageButtonsEvent ClientEventType = "action:anime-page-buttons:render" // When the client requests the buttons to display on the anime page + ClientActionRenderAnimePageDropdownItemsEvent ClientEventType = "action:anime-page-dropdown-items:render" // When the client requests the dropdown items to display on the anime page + ClientActionRenderMangaPageButtonsEvent ClientEventType = "action:manga-page-buttons:render" // When the client requests the buttons to display on the manga page + ClientActionRenderMediaCardContextMenuItemsEvent ClientEventType = "action:media-card-context-menu-items:render" // When the client requests the context menu items to display on the media card + ClientActionRenderAnimeLibraryDropdownItemsEvent ClientEventType = "action:anime-library-dropdown-items:render" // When the client requests the dropdown items to display on the anime library + ClientActionRenderEpisodeCardContextMenuItemsEvent ClientEventType = "action:episode-card-context-menu-items:render" // When the client requests the context menu items to display on the episode card + ClientActionRenderEpisodeGridItemMenuItemsEvent ClientEventType = "action:episode-grid-item-menu-items:render" // When the client requests the context menu items to display on the episode grid item + ClientActionClickedEvent ClientEventType = "action:clicked" // When the user clicks on an action + ClientFormSubmittedEvent ClientEventType = "form:submitted" // When the form registered by the tray is submitted + ClientScreenChangedEvent ClientEventType = "screen:changed" // When the current screen changes + ClientEventHandlerTriggeredEvent ClientEventType = "handler:triggered" // When a custom event registered by the plugin is triggered + ClientFieldRefSendValueEvent ClientEventType = "field-ref:send-value" // When the client sends the value of a field that has a ref + + ClientDOMQueryResultEvent ClientEventType = "dom:query-result" // Result of a DOM query + ClientDOMQueryOneResultEvent ClientEventType = "dom:query-one-result" // Result of a DOM query for one element + ClientDOMObserveResultEvent ClientEventType = "dom:observe-result" // Result of a DOM observation + ClientDOMStopObserveEvent ClientEventType = "dom:stop-observe" // Stop observing DOM elements + ClientDOMCreateResultEvent ClientEventType = "dom:create-result" // Result of creating a DOM element + ClientDOMElementUpdatedEvent ClientEventType = "dom:element-updated" // When a DOM element is updated + ClientDOMEventTriggeredEvent ClientEventType = "dom:event-triggered" // When a DOM event is triggered + ClientDOMReadyEvent ClientEventType = "dom:ready" // When a DOM element is ready +) + +type ClientRenderTrayEventPayload struct{} +type ClientListTrayIconsEventPayload struct{} +type ClientTrayOpenedEventPayload struct{} +type ClientTrayClosedEventPayload struct{} +type ClientTrayClickedEventPayload struct{} +type ClientActionRenderAnimePageButtonsEventPayload struct{} +type ClientActionRenderAnimePageDropdownItemsEventPayload struct{} +type ClientActionRenderMangaPageButtonsEventPayload struct{} +type ClientActionRenderMediaCardContextMenuItemsEventPayload struct{} +type ClientActionRenderAnimeLibraryDropdownItemsEventPayload struct{} +type ClientActionRenderEpisodeCardContextMenuItemsEventPayload struct{} +type ClientActionRenderEpisodeGridItemMenuItemsEventPayload struct{} + +type ClientListCommandPalettesEventPayload struct{} + +type ClientCommandPaletteOpenedEventPayload struct{} + +type ClientCommandPaletteClosedEventPayload struct{} + +type ClientActionClickedEventPayload struct { + ActionID string `json:"actionId"` + Event map[string]interface{} `json:"event"` +} + +type ClientEventHandlerTriggeredEventPayload struct { + HandlerName string `json:"handlerName"` + Event map[string]interface{} `json:"event"` +} + +type ClientFormSubmittedEventPayload struct { + FormName string `json:"formName"` + Data map[string]interface{} `json:"data"` +} + +type ClientScreenChangedEventPayload struct { + Pathname string `json:"pathname"` + Query string `json:"query"` +} + +type ClientFieldRefSendValueEventPayload struct { + FieldRef string `json:"fieldRef"` + Value interface{} `json:"value"` +} + +type ClientRenderCommandPaletteEventPayload struct{} + +type ClientCommandPaletteItemSelectedEventPayload struct { + ItemID string `json:"itemId"` +} + +type ClientCommandPaletteInputEventPayload struct { + Value string `json:"value"` +} + +type ClientDOMEventTriggeredEventPayload struct { + ElementId string `json:"elementId"` + EventType string `json:"eventType"` + Event map[string]interface{} `json:"event"` +} + +type ClientDOMQueryResultEventPayload struct { + RequestID string `json:"requestId"` + Elements []interface{} `json:"elements"` +} + +type ClientDOMQueryOneResultEventPayload struct { + RequestID string `json:"requestId"` + Element interface{} `json:"element"` +} + +type ClientDOMObserveResultEventPayload struct { + ObserverId string `json:"observerId"` + Elements []interface{} `json:"elements"` +} + +type ClientDOMCreateResultEventPayload struct { + RequestID string `json:"requestId"` + Element interface{} `json:"element"` +} + +type ClientDOMElementUpdatedEventPayload struct { + ElementId string `json:"elementId"` + Action string `json:"action"` + Result interface{} `json:"result"` + RequestID string `json:"requestId"` +} + +type ClientDOMStopObserveEventPayload struct { + ObserverId string `json:"observerId"` +} + +type ClientDOMReadyEventPayload struct { +} + +///////////////////////////////////////////////////////////////////////////////////// +// Server to client +///////////////////////////////////////////////////////////////////////////////////// + +type ServerEventType string + +// ServerPluginEvent is an event sent to the client +type ServerPluginEvent struct { + ExtensionID string `json:"extensionId"` // Extension ID must be set + Type ServerEventType `json:"type"` + Payload interface{} `json:"payload"` +} + +const ( + ServerTrayUpdatedEvent ServerEventType = "tray:updated" // When the trays are updated + ServerTrayIconEvent ServerEventType = "tray:icon" // When the tray sends its icon to the client + ServerTrayBadgeUpdatedEvent ServerEventType = "tray:badge-updated" // When the tray badge is updated + ServerTrayOpenEvent ServerEventType = "tray:open" // When the tray is opened + ServerTrayCloseEvent ServerEventType = "tray:close" // When the tray is closed + ServerCommandPaletteInfoEvent ServerEventType = "command-palette:info" // When the command palette sends its state to the client + ServerCommandPaletteUpdatedEvent ServerEventType = "command-palette:updated" // When the command palette is updated + ServerCommandPaletteOpenEvent ServerEventType = "command-palette:open" // When the command palette is opened + ServerCommandPaletteCloseEvent ServerEventType = "command-palette:close" // When the command palette is closed + ServerCommandPaletteGetInputEvent ServerEventType = "command-palette:get-input" // When the command palette requests the input from the client + ServerCommandPaletteSetInputEvent ServerEventType = "command-palette:set-input" // When the command palette sets the input + ServerActionRenderAnimePageButtonsEvent ServerEventType = "action:anime-page-buttons:updated" // When the server renders the anime page buttons + ServerActionRenderAnimePageDropdownItemsEvent ServerEventType = "action:anime-page-dropdown-items:updated" // When the server renders the anime page dropdown items + ServerActionRenderMangaPageButtonsEvent ServerEventType = "action:manga-page-buttons:updated" // When the server renders the manga page buttons + ServerActionRenderMediaCardContextMenuItemsEvent ServerEventType = "action:media-card-context-menu-items:updated" // When the server renders the media card context menu items + ServerActionRenderEpisodeCardContextMenuItemsEvent ServerEventType = "action:episode-card-context-menu-items:updated" // When the server renders the episode card context menu items + ServerActionRenderEpisodeGridItemMenuItemsEvent ServerEventType = "action:episode-grid-item-menu-items:updated" // When the server renders the episode grid item menu items + ServerActionRenderAnimeLibraryDropdownItemsEvent ServerEventType = "action:anime-library-dropdown-items:updated" // When the server renders the anime library dropdown items + ServerFormResetEvent ServerEventType = "form:reset" + ServerFormSetValuesEvent ServerEventType = "form:set-values" + ServerFieldRefSetValueEvent ServerEventType = "field-ref:set-value" // Set the value of a field (not in a form) + ServerFatalErrorEvent ServerEventType = "fatal-error" // When the UI encounters a fatal error + ServerScreenNavigateToEvent ServerEventType = "screen:navigate-to" // Navigate to a new screen + ServerScreenReloadEvent ServerEventType = "screen:reload" // Reload the current screen + ServerScreenGetCurrentEvent ServerEventType = "screen:get-current" // Get the current screen + + ServerDOMQueryEvent ServerEventType = "dom:query" // When the server queries for DOM elements + ServerDOMQueryOneEvent ServerEventType = "dom:query-one" // When the server queries for a single DOM element + ServerDOMObserveEvent ServerEventType = "dom:observe" // When the server starts observing DOM elements + ServerDOMStopObserveEvent ServerEventType = "dom:stop-observe" // When the server stops observing DOM elements + ServerDOMCreateEvent ServerEventType = "dom:create" // When the server creates a DOM element + ServerDOMManipulateEvent ServerEventType = "dom:manipulate" // When the server manipulates a DOM element + ServerDOMObserveInViewEvent ServerEventType = "dom:observe-in-view" +) + +type ServerTrayUpdatedEventPayload struct { + Components interface{} `json:"components"` +} + +type ServerCommandPaletteUpdatedEventPayload struct { + Placeholder string `json:"placeholder"` + Items interface{} `json:"items"` +} + +type ServerTrayOpenEventPayload struct { + ExtensionID string `json:"extensionId"` +} + +type ServerTrayCloseEventPayload struct { + ExtensionID string `json:"extensionId"` +} + +type ServerTrayIconEventPayload struct { + ExtensionID string `json:"extensionId"` + ExtensionName string `json:"extensionName"` + IconURL string `json:"iconUrl"` + WithContent bool `json:"withContent"` + TooltipText string `json:"tooltipText"` + BadgeNumber int `json:"badgeNumber"` + BadgeIntent string `json:"badgeIntent"` + Width string `json:"width,omitempty"` + MinHeight string `json:"minHeight,omitempty"` +} + +type ServerTrayBadgeUpdatedEventPayload struct { + BadgeNumber int `json:"badgeNumber"` + BadgeIntent string `json:"badgeIntent"` +} + +type ServerFormResetEventPayload struct { + FormName string `json:"formName"` + FieldToReset string `json:"fieldToReset"` // If not set, the form will be reset +} + +type ServerFormSetValuesEventPayload struct { + FormName string `json:"formName"` + Data map[string]interface{} `json:"data"` +} + +type ServerFieldRefSetValueEventPayload struct { + FieldRef string `json:"fieldRef"` + Value interface{} `json:"value"` +} + +type ServerFieldRefGetValueEventPayload struct { + FieldRef string `json:"fieldRef"` +} + +type ServerFatalErrorEventPayload struct { + Error string `json:"error"` +} + +type ServerScreenNavigateToEventPayload struct { + Path string `json:"path"` +} + +type ServerActionRenderAnimePageButtonsEventPayload struct { + Buttons interface{} `json:"buttons"` +} + +type ServerActionRenderAnimePageDropdownItemsEventPayload struct { + Items interface{} `json:"items"` +} + +type ServerActionRenderMangaPageButtonsEventPayload struct { + Buttons interface{} `json:"buttons"` +} + +type ServerActionRenderMediaCardContextMenuItemsEventPayload struct { + Items interface{} `json:"items"` +} + +type ServerActionRenderAnimeLibraryDropdownItemsEventPayload struct { + Items interface{} `json:"items"` +} + +type ServerActionRenderEpisodeCardContextMenuItemsEventPayload struct { + Items interface{} `json:"items"` +} + +type ServerActionRenderEpisodeGridItemMenuItemsEventPayload struct { + Items interface{} `json:"items"` +} + +type ServerScreenReloadEventPayload struct{} + +type ServerCommandPaletteInfoEventPayload struct { + Placeholder string `json:"placeholder"` + KeyboardShortcut string `json:"keyboardShortcut"` +} + +type ServerCommandPaletteOpenEventPayload struct{} + +type ServerCommandPaletteCloseEventPayload struct{} + +type ServerCommandPaletteGetInputEventPayload struct{} + +type ServerCommandPaletteSetInputEventPayload struct { + Value string `json:"value"` +} + +type ServerScreenGetCurrentEventPayload struct{} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func NewClientPluginEvent(data map[string]interface{}) *ClientPluginEvent { + extensionID, ok := data["extensionId"].(string) + if !ok { + extensionID = "" + } + + eventType, ok := data["type"].(string) + if !ok { + return nil + } + + payload, ok := data["payload"] + if !ok { + return nil + } + + return &ClientPluginEvent{ + ExtensionID: extensionID, + Type: ClientEventType(eventType), + Payload: payload, + } +} + +func (e *ClientPluginEvent) ParsePayload(ret interface{}) bool { + data, err := json.Marshal(e.Payload) + if err != nil { + return false + } + if err := json.Unmarshal(data, &ret); err != nil { + return false + } + return true +} + +func (e *ClientPluginEvent) ParsePayloadAs(t ClientEventType, ret interface{}) bool { + if e.Type != t { + return false + } + return e.ParsePayload(ret) +} + +// Add DOM event payloads +type ServerDOMQueryEventPayload struct { + Selector string `json:"selector"` + RequestID string `json:"requestId"` + WithInnerHTML bool `json:"withInnerHTML"` + WithOuterHTML bool `json:"withOuterHTML"` + IdentifyChildren bool `json:"identifyChildren"` +} + +type ServerDOMQueryOneEventPayload struct { + Selector string `json:"selector"` + RequestID string `json:"requestId"` + WithInnerHTML bool `json:"withInnerHTML"` + WithOuterHTML bool `json:"withOuterHTML"` + IdentifyChildren bool `json:"identifyChildren"` +} + +type ServerDOMObserveEventPayload struct { + Selector string `json:"selector"` + ObserverId string `json:"observerId"` + WithInnerHTML bool `json:"withInnerHTML"` + WithOuterHTML bool `json:"withOuterHTML"` + IdentifyChildren bool `json:"identifyChildren"` +} + +type ServerDOMStopObserveEventPayload struct { + ObserverId string `json:"observerId"` +} + +type ServerDOMCreateEventPayload struct { + TagName string `json:"tagName"` + RequestID string `json:"requestId"` +} + +type ServerDOMManipulateEventPayload struct { + ElementId string `json:"elementId"` + Action string `json:"action"` + Params map[string]interface{} `json:"params"` + RequestID string `json:"requestId"` +} + +type ServerDOMObserveInViewEventPayload struct { + Selector string `json:"selector"` + ObserverId string `json:"observerId"` + WithInnerHTML bool `json:"withInnerHTML"` + WithOuterHTML bool `json:"withOuterHTML"` + IdentifyChildren bool `json:"identifyChildren"` + Margin string `json:"margin"` +} diff --git a/seanime-2.9.10/internal/plugin/ui/fetch.go b/seanime-2.9.10/internal/plugin/ui/fetch.go new file mode 100644 index 0000000..5a97bde --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/fetch.go @@ -0,0 +1,27 @@ +package plugin_ui + +import ( + "seanime/internal/goja/goja_bindings" + + "github.com/dop251/goja" +) + +func (c *Context) bindFetch(obj *goja.Object) { + f := goja_bindings.NewFetch(c.vm) + + _ = obj.Set("fetch", f.Fetch) + + go func() { + for fn := range f.ResponseChannel() { + c.scheduler.ScheduleAsync(func() error { + fn() + return nil + }) + } + }() + + c.registerOnCleanup(func() { + c.logger.Debug().Msg("plugin: Terminating fetch") + f.Close() + }) +} diff --git a/seanime-2.9.10/internal/plugin/ui/form.go b/seanime-2.9.10/internal/plugin/ui/form.go new file mode 100644 index 0000000..5bfa006 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/form.go @@ -0,0 +1,337 @@ +package plugin_ui + +import ( + "github.com/dop251/goja" + "github.com/google/uuid" +) + +type FormManager struct { + ctx *Context +} + +func NewFormManager(ctx *Context) *FormManager { + return &FormManager{ + ctx: ctx, + } +} + +type FormField struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Label string `json:"label"` + Placeholder string `json:"placeholder,omitempty"` + Value interface{} `json:"value,omitempty"` + Options []FormFieldOption `json:"options,omitempty"` + Props map[string]interface{} `json:"props,omitempty"` +} + +type FormFieldOption struct { + Label string `json:"label"` + Value interface{} `json:"value"` +} + +type Form struct { + Name string `json:"name"` + ID string `json:"id"` + Type string `json:"type"` + Props FormProps `json:"props"` + manager *FormManager +} + +type FormProps struct { + Name string `json:"name"` + Fields []FormField `json:"fields"` +} + +// jsNewForm +// +// Example: +// const form = tray.newForm("form-1") +func (f *FormManager) jsNewForm(call goja.FunctionCall) goja.Value { + name, ok := call.Argument(0).Export().(string) + if !ok { + f.ctx.handleTypeError("newForm requires a name") + } + + form := &Form{ + Name: name, + ID: uuid.New().String(), + Type: "form", + Props: FormProps{Fields: make([]FormField, 0), Name: name}, + manager: f, + } + + formObj := f.ctx.vm.NewObject() + + // Form methods + formObj.Set("render", form.jsRender) + formObj.Set("onSubmit", form.jsOnSubmit) + + // Field creation methods + formObj.Set("inputField", form.jsInputField) + formObj.Set("numberField", form.jsNumberField) + formObj.Set("selectField", form.jsSelectField) + formObj.Set("checkboxField", form.jsCheckboxField) + formObj.Set("radioField", form.jsRadioField) + formObj.Set("dateField", form.jsDateField) + formObj.Set("switchField", form.jsSwitchField) + formObj.Set("submitButton", form.jsSubmitButton) + formObj.Set("reset", form.jsReset) + formObj.Set("setValues", form.jsSetValues) + + return formObj +} + +func (f *Form) jsRender(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + f.manager.ctx.handleTypeError("render requires a config object") + } + + config, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + f.manager.ctx.handleTypeError("render requires a config object") + } + + if fields, ok := config["fields"].([]interface{}); ok { + f.Props.Fields = make([]FormField, 0) + for _, field := range fields { + if fieldMap, ok := field.(FormField); ok { + f.Props.Fields = append(f.Props.Fields, fieldMap) + } + } + } + + return f.manager.ctx.vm.ToValue(f) +} + +func (f *Form) jsOnSubmit(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + f.manager.ctx.handleTypeError("onSubmit requires a callback function") + } + + callback, ok := goja.AssertFunction(call.Argument(0)) + if !ok { + f.manager.ctx.handleTypeError("onSubmit requires a callback function") + } + + eventListener := f.manager.ctx.RegisterEventListener(ClientFormSubmittedEvent) + + eventListener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientFormSubmittedEventPayload + if event.ParsePayloadAs(ClientFormSubmittedEvent, &payload) && payload.FormName == f.Name { + f.manager.ctx.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), f.manager.ctx.vm.ToValue(payload.Data)) + return err + }) + } + }) + + // go func() { + // for event := range eventListener.Channel { + // if event.ParsePayloadAs(ClientFormSubmittedEvent, &payload) { + // if payload.FormName == f.Name { + // f.manager.ctx.scheduler.ScheduleAsync(func() error { + // _, err := callback(goja.Undefined(), f.manager.ctx.vm.ToValue(payload.Data)) + // if err != nil { + // f.manager.ctx.logger.Error().Err(err).Msg("error running form submit callback") + // } + // return err + // }) + // } + // } + // } + // }() + + return goja.Undefined() +} + +func (f *Form) jsReset(call goja.FunctionCall) goja.Value { + fieldToReset := "" + if len(call.Arguments) > 0 { + var ok bool + fieldToReset, ok = call.Argument(0).Export().(string) + if !ok { + f.manager.ctx.handleTypeError("reset requires a field name") + } + } + + f.manager.ctx.SendEventToClient(ServerFormResetEvent, ServerFormResetEventPayload{ + FormName: f.Name, + FieldToReset: fieldToReset, + }) + + return goja.Undefined() +} + +func (f *Form) jsSetValues(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + f.manager.ctx.handleTypeError("setValues requires a config object") + } + + props, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + f.manager.ctx.handleTypeError("setValues requires a config object") + } + + f.manager.ctx.SendEventToClient(ServerFormSetValuesEvent, ServerFormSetValuesEventPayload{ + FormName: f.Name, + Data: props, + }) + + return goja.Undefined() +} + +func (f *Form) createField(fieldType string, props map[string]interface{}) goja.Value { + nameRaw, ok := props["name"] + name := "" + if ok { + name, ok = nameRaw.(string) + if !ok { + f.manager.ctx.handleTypeError("name must be a string") + } + } + label := "" + labelRaw, ok := props["label"] + if ok { + label, ok = labelRaw.(string) + if !ok { + f.manager.ctx.handleTypeError("label must be a string") + } + } + placeholder, ok := props["placeholder"] + if ok { + placeholder, ok = placeholder.(string) + if !ok { + f.manager.ctx.handleTypeError("placeholder must be a string") + } + } + field := FormField{ + ID: uuid.New().String(), + Type: fieldType, + Name: name, + Label: label, + Value: props["value"], + Options: nil, + } + + // Handle options if present + if options, ok := props["options"].([]interface{}); ok { + fieldOptions := make([]FormFieldOption, len(options)) + for i, opt := range options { + if optMap, ok := opt.(map[string]interface{}); ok { + fieldOptions[i] = FormFieldOption{ + Label: optMap["label"].(string), + Value: optMap["value"], + } + } + } + field.Options = fieldOptions + } + + return f.manager.ctx.vm.ToValue(field) +} + +func (f *Form) jsInputField(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + f.manager.ctx.handleTypeError("inputField requires a config object") + } + + props, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + f.manager.ctx.handleTypeError("inputField requires a config object") + } + + return f.createField("input", props) +} + +func (f *Form) jsNumberField(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + f.manager.ctx.handleTypeError("numberField requires a config object") + } + + props, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + f.manager.ctx.handleTypeError("numberField requires a config object") + } + + return f.createField("number", props) +} + +func (f *Form) jsSelectField(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + f.manager.ctx.handleTypeError("selectField requires a config object") + } + + props, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + f.manager.ctx.handleTypeError("selectField requires a config object") + } + + return f.createField("select", props) +} + +func (f *Form) jsCheckboxField(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + f.manager.ctx.handleTypeError("checkboxField requires a config object") + } + + props, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + f.manager.ctx.handleTypeError("checkboxField requires a config object") + } + + return f.createField("checkbox", props) +} + +func (f *Form) jsSwitchField(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + f.manager.ctx.handleTypeError("switchField requires a config object") + } + + props, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + f.manager.ctx.handleTypeError("switchField requires a config object") + } + + return f.createField("switch", props) +} + +func (f *Form) jsRadioField(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + f.manager.ctx.handleTypeError("radioField requires a config object") + } + + props, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + f.manager.ctx.handleTypeError("radioField requires a config object") + } + + return f.createField("radio", props) +} + +func (f *Form) jsDateField(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + f.manager.ctx.handleTypeError("dateField requires a config object") + } + + props, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + f.manager.ctx.handleTypeError("dateField requires a config object") + } + + return f.createField("date", props) +} + +func (f *Form) jsSubmitButton(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + f.manager.ctx.handleTypeError("submitButton requires a config object") + } + + props, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + f.manager.ctx.handleTypeError("submitButton requires a config object") + } + + return f.createField("submit", props) +} diff --git a/seanime-2.9.10/internal/plugin/ui/notification.go b/seanime-2.9.10/internal/plugin/ui/notification.go new file mode 100644 index 0000000..c9738e7 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/notification.go @@ -0,0 +1,36 @@ +package plugin_ui + +import ( + "seanime/internal/notifier" + + "github.com/dop251/goja" +) + +type NotificationManager struct { + ctx *Context +} + +func NewNotificationManager(ctx *Context) *NotificationManager { + return &NotificationManager{ + ctx: ctx, + } +} + +func (n *NotificationManager) bind(contextObj *goja.Object) { + notificationObj := n.ctx.vm.NewObject() + _ = notificationObj.Set("send", n.jsNotify) + + _ = contextObj.Set("notification", notificationObj) +} + +func (n *NotificationManager) jsNotify(call goja.FunctionCall) goja.Value { + message, ok := call.Argument(0).Export().(string) + if !ok { + n.ctx.handleTypeError("notification: notify requires a string message") + return goja.Undefined() + } + + notifier.GlobalNotifier.Notify(notifier.Notification(n.ctx.ext.Name), message) + + return goja.Undefined() +} diff --git a/seanime-2.9.10/internal/plugin/ui/screen.go b/seanime-2.9.10/internal/plugin/ui/screen.go new file mode 100644 index 0000000..6f54050 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/screen.go @@ -0,0 +1,104 @@ +package plugin_ui + +import ( + "net/url" + "strings" + "sync" + + "github.com/dop251/goja" +) + +type ScreenManager struct { + ctx *Context + mu sync.RWMutex +} + +func NewScreenManager(ctx *Context) *ScreenManager { + return &ScreenManager{ + ctx: ctx, + } +} + +// bind binds 'screen' to the ctx object +// +// Example: +// ctx.screen.navigateTo("/entry?id=21"); +func (s *ScreenManager) bind(ctxObj *goja.Object) { + screenObj := s.ctx.vm.NewObject() + _ = screenObj.Set("onNavigate", s.jsOnNavigate) + _ = screenObj.Set("navigateTo", s.jsNavigateTo) + _ = screenObj.Set("reload", s.jsReload) + _ = screenObj.Set("loadCurrent", s.jsLoadCurrent) + + _ = ctxObj.Set("screen", screenObj) +} + +// jsNavigateTo navigates to a new screen +// +// Example: +// ctx.screen.navigateTo("/entry?id=21"); +func (s *ScreenManager) jsNavigateTo(path string, searchParams map[string]string) { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + queryString := "" + if len(searchParams) > 0 { + query := url.Values{} + for key, value := range searchParams { + query.Add(key, value) + } + queryString = "?" + query.Encode() + } + + finalPath := path + queryString + + s.ctx.SendEventToClient(ServerScreenNavigateToEvent, ServerScreenNavigateToEventPayload{ + Path: finalPath, + }) +} + +// jsReload reloads the current screen +func (s *ScreenManager) jsReload() { + s.ctx.SendEventToClient(ServerScreenReloadEvent, ServerScreenReloadEventPayload{}) +} + +// jsLoadCurrent calls onNavigate with the current screen data +func (s *ScreenManager) jsLoadCurrent() { + s.ctx.SendEventToClient(ServerScreenGetCurrentEvent, ServerScreenGetCurrentEventPayload{}) +} + +// jsOnNavigate registers a callback to be called when the current screen changes +// +// Example: +// const onNavigate = (event) => { +// console.log(event.screen); +// }; +// ctx.screen.onNavigate(onNavigate); +func (s *ScreenManager) jsOnNavigate(callback goja.Callable) goja.Value { + eventListener := s.ctx.RegisterEventListener(ClientScreenChangedEvent) + + eventListener.SetCallback(func(event *ClientPluginEvent) { + var payload ClientScreenChangedEventPayload + if event.ParsePayloadAs(ClientScreenChangedEvent, &payload) { + s.ctx.scheduler.ScheduleAsync(func() error { + + parsedQuery, _ := url.ParseQuery(strings.TrimPrefix(payload.Query, "?")) + queryMap := make(map[string]string) + for key, value := range parsedQuery { + queryMap[key] = strings.Join(value, ",") + } + + ret := map[string]interface{}{ + "pathname": payload.Pathname, + "searchParams": queryMap, + } + + _, err := callback(goja.Undefined(), s.ctx.vm.ToValue(ret)) + return err + }) + } + }) + + return goja.Undefined() +} diff --git a/seanime-2.9.10/internal/plugin/ui/toast.go b/seanime-2.9.10/internal/plugin/ui/toast.go new file mode 100644 index 0000000..415d8df --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/toast.go @@ -0,0 +1,71 @@ +package plugin_ui + +import ( + "seanime/internal/events" + + "github.com/dop251/goja" +) + +type ToastManager struct { + ctx *Context +} + +func NewToastManager(ctx *Context) *ToastManager { + return &ToastManager{ + ctx: ctx, + } +} + +func (t *ToastManager) bind(contextObj *goja.Object) { + toastObj := t.ctx.vm.NewObject() + _ = toastObj.Set("success", t.jsToastSuccess) + _ = toastObj.Set("error", t.jsToastError) + _ = toastObj.Set("info", t.jsToastInfo) + _ = toastObj.Set("warning", t.jsToastWarning) + + _ = contextObj.Set("toast", toastObj) +} + +func (t *ToastManager) jsToastSuccess(call goja.FunctionCall) goja.Value { + message, ok := call.Argument(0).Export().(string) + if !ok { + t.ctx.handleTypeError("toast: success requires a string message") + return goja.Undefined() + } + + t.ctx.wsEventManager.SendEvent(events.SuccessToast, message) + return goja.Undefined() +} + +func (t *ToastManager) jsToastError(call goja.FunctionCall) goja.Value { + message, ok := call.Argument(0).Export().(string) + if !ok { + t.ctx.handleTypeError("toast: error requires a string message") + return goja.Undefined() + } + + t.ctx.wsEventManager.SendEvent(events.ErrorToast, message) + return goja.Undefined() +} + +func (t *ToastManager) jsToastInfo(call goja.FunctionCall) goja.Value { + message, ok := call.Argument(0).Export().(string) + if !ok { + t.ctx.handleTypeError("toast: info requires a string message") + return goja.Undefined() + } + + t.ctx.wsEventManager.SendEvent(events.InfoToast, message) + return goja.Undefined() +} + +func (t *ToastManager) jsToastWarning(call goja.FunctionCall) goja.Value { + message, ok := call.Argument(0).Export().(string) + if !ok { + t.ctx.handleTypeError("toast: warning requires a string message") + return goja.Undefined() + } + + t.ctx.wsEventManager.SendEvent(events.WarningToast, message) + return goja.Undefined() +} diff --git a/seanime-2.9.10/internal/plugin/ui/tray.go b/seanime-2.9.10/internal/plugin/ui/tray.go new file mode 100644 index 0000000..00aac24 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/tray.go @@ -0,0 +1,361 @@ +package plugin_ui + +import ( + "sync" + "time" + + "github.com/dop251/goja" + "github.com/samber/mo" +) + +type TrayManager struct { + ctx *Context + tray mo.Option[*Tray] + lastUpdatedAt time.Time + updateMutex sync.Mutex + + componentManager *ComponentManager +} + +func NewTrayManager(ctx *Context) *TrayManager { + return &TrayManager{ + ctx: ctx, + tray: mo.None[*Tray](), + componentManager: &ComponentManager{ctx: ctx}, + } +} + +// renderTrayScheduled renders the new component tree. +// This function is unsafe because it is not thread-safe and should be scheduled. +func (t *TrayManager) renderTrayScheduled() { + t.updateMutex.Lock() + defer t.updateMutex.Unlock() + + tray, registered := t.tray.Get() + if !registered { + return + } + + if !tray.WithContent { + return + } + + // Rate limit updates + //if time.Since(t.lastUpdatedAt) < time.Millisecond*200 { + // return + //} + + t.lastUpdatedAt = time.Now() + + t.ctx.scheduler.ScheduleAsync(func() error { + // t.ctx.logger.Trace().Msg("plugin: Rendering tray") + newComponents, err := t.componentManager.renderComponents(tray.renderFunc) + if err != nil { + t.ctx.logger.Error().Err(err).Msg("plugin: Failed to render tray") + t.ctx.handleException(err) + return nil + } + + // t.ctx.logger.Trace().Msg("plugin: Sending tray update to client") + // Send the JSON value to the client + t.ctx.SendEventToClient(ServerTrayUpdatedEvent, ServerTrayUpdatedEventPayload{ + Components: newComponents, + }) + return nil + }) +} + +// sendIconToClient sends the tray icon to the client after it's been requested. +func (t *TrayManager) sendIconToClient() { + if tray, registered := t.tray.Get(); registered { + t.ctx.SendEventToClient(ServerTrayIconEvent, ServerTrayIconEventPayload{ + ExtensionID: t.ctx.ext.ID, + ExtensionName: t.ctx.ext.Name, + IconURL: tray.IconURL, + WithContent: tray.WithContent, + TooltipText: tray.TooltipText, + BadgeNumber: tray.BadgeNumber, + BadgeIntent: tray.BadgeIntent, + Width: tray.Width, + MinHeight: tray.MinHeight, + }) + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Tray +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type Tray struct { + // WithContent is used to determine if the tray has any content + // If false, only the tray icon will be rendered and tray.render() will be ignored + WithContent bool `json:"withContent"` + + IconURL string `json:"iconUrl"` + TooltipText string `json:"tooltipText"` + BadgeNumber int `json:"badgeNumber"` + BadgeIntent string `json:"badgeIntent"` + Width string `json:"width,omitempty"` + MinHeight string `json:"minHeight,omitempty"` + + renderFunc func(goja.FunctionCall) goja.Value + trayManager *TrayManager +} + +type Component struct { + ID string `json:"id"` + Type string `json:"type"` + Props map[string]interface{} `json:"props"` + Key string `json:"key,omitempty"` +} + +// jsNewTray +// +// Example: +// const tray = ctx.newTray() +func (t *TrayManager) jsNewTray(call goja.FunctionCall) goja.Value { + tray := &Tray{ + renderFunc: nil, + trayManager: t, + WithContent: true, + } + + props := call.Arguments + if len(props) > 0 { + propsObj := props[0].Export().(map[string]interface{}) + if propsObj["withContent"] != nil { + tray.WithContent, _ = propsObj["withContent"].(bool) + } + if propsObj["iconUrl"] != nil { + tray.IconURL, _ = propsObj["iconUrl"].(string) + } + if propsObj["tooltipText"] != nil { + tray.TooltipText, _ = propsObj["tooltipText"].(string) + } + if propsObj["width"] != nil { + tray.Width, _ = propsObj["width"].(string) + } + if propsObj["minHeight"] != nil { + tray.MinHeight, _ = propsObj["minHeight"].(string) + } + } + + t.tray = mo.Some(tray) + + // Create a new tray object + trayObj := t.ctx.vm.NewObject() + _ = trayObj.Set("render", tray.jsRender) + _ = trayObj.Set("update", tray.jsUpdate) + _ = trayObj.Set("onOpen", tray.jsOnOpen) + _ = trayObj.Set("onClose", tray.jsOnClose) + _ = trayObj.Set("onClick", tray.jsOnClick) + _ = trayObj.Set("open", tray.jsOpen) + _ = trayObj.Set("close", tray.jsClose) + _ = trayObj.Set("updateBadge", tray.jsUpdateBadge) + + // Register components + _ = trayObj.Set("div", t.componentManager.jsDiv) + _ = trayObj.Set("flex", t.componentManager.jsFlex) + _ = trayObj.Set("stack", t.componentManager.jsStack) + _ = trayObj.Set("text", t.componentManager.jsText) + _ = trayObj.Set("button", t.componentManager.jsButton) + _ = trayObj.Set("anchor", t.componentManager.jsAnchor) + _ = trayObj.Set("input", t.componentManager.jsInput) + _ = trayObj.Set("radioGroup", t.componentManager.jsRadioGroup) + _ = trayObj.Set("switch", t.componentManager.jsSwitch) + _ = trayObj.Set("checkbox", t.componentManager.jsCheckbox) + _ = trayObj.Set("select", t.componentManager.jsSelect) + + return trayObj +} + +///// + +// jsRender registers a function to be called when the tray is rendered/updated +// +// Example: +// tray.render(() => flex) +func (t *Tray) jsRender(call goja.FunctionCall) goja.Value { + + funcRes, ok := call.Argument(0).Export().(func(goja.FunctionCall) goja.Value) + if !ok { + t.trayManager.ctx.handleTypeError("render requires a function") + } + + // Set the render function + t.renderFunc = funcRes + + return goja.Undefined() +} + +// jsUpdate schedules a re-render on the client +// +// Example: +// tray.update() +func (t *Tray) jsUpdate(call goja.FunctionCall) goja.Value { + // Update the context's lastUIUpdateAt to prevent duplicate updates + t.trayManager.ctx.uiUpdateMu.Lock() + t.trayManager.ctx.lastUIUpdateAt = time.Now() + t.trayManager.ctx.uiUpdateMu.Unlock() + + t.trayManager.renderTrayScheduled() + return goja.Undefined() +} + +// jsOpen +// +// Example: +// tray.open() +func (t *Tray) jsOpen(call goja.FunctionCall) goja.Value { + t.trayManager.ctx.SendEventToClient(ServerTrayOpenEvent, ServerTrayOpenEventPayload{ + ExtensionID: t.trayManager.ctx.ext.ID, + }) + return goja.Undefined() +} + +// jsClose +// +// Example: +// tray.close() +func (t *Tray) jsClose(call goja.FunctionCall) goja.Value { + t.trayManager.ctx.SendEventToClient(ServerTrayCloseEvent, ServerTrayCloseEventPayload{ + ExtensionID: t.trayManager.ctx.ext.ID, + }) + return goja.Undefined() +} + +// jsUpdateBadge +// +// Example: +// tray.updateBadge({ number: 1, intent: "success" }) +func (t *Tray) jsUpdateBadge(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + t.trayManager.ctx.handleTypeError("updateBadge requires a callback function") + } + + propsObj, ok := call.Argument(0).Export().(map[string]interface{}) + if !ok { + t.trayManager.ctx.handleTypeError("updateBadge requires a callback function") + } + + number, ok := propsObj["number"].(int64) + if !ok { + t.trayManager.ctx.handleTypeError("updateBadge: number must be an integer") + } + + intent, ok := propsObj["intent"].(string) + if !ok { + intent = "info" + } + + t.BadgeNumber = int(number) + t.BadgeIntent = intent + + t.trayManager.ctx.SendEventToClient(ServerTrayBadgeUpdatedEvent, ServerTrayBadgeUpdatedEventPayload{ + BadgeNumber: t.BadgeNumber, + BadgeIntent: t.BadgeIntent, + }) + return goja.Undefined() +} + +// jsOnOpen +// +// Example: +// tray.onOpen(() => { +// console.log("tray opened by the user") +// }) +func (t *Tray) jsOnOpen(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + t.trayManager.ctx.handleTypeError("onOpen requires a callback function") + } + + callback, ok := goja.AssertFunction(call.Argument(0)) + if !ok { + t.trayManager.ctx.handleTypeError("onOpen requires a callback function") + } + + eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayOpenedEvent) + payload := ClientTrayOpenedEventPayload{} + + eventListener.SetCallback(func(event *ClientPluginEvent) { + if event.ParsePayloadAs(ClientTrayOpenedEvent, &payload) { + t.trayManager.ctx.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{})) + if err != nil { + t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray open callback") + } + return err + }) + } + }) + + return goja.Undefined() +} + +// jsOnClick +// +// Example: +// tray.onClick(() => { +// console.log("tray clicked by the user") +// }) +func (t *Tray) jsOnClick(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + t.trayManager.ctx.handleTypeError("onClick requires a callback function") + } + + callback, ok := goja.AssertFunction(call.Argument(0)) + if !ok { + t.trayManager.ctx.handleTypeError("onClick requires a callback function") + } + + eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayClickedEvent) + payload := ClientTrayClickedEventPayload{} + + eventListener.SetCallback(func(event *ClientPluginEvent) { + if event.ParsePayloadAs(ClientTrayClickedEvent, &payload) { + t.trayManager.ctx.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{})) + if err != nil { + t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray click callback") + } + return err + }) + } + }) + + return goja.Undefined() +} + +// jsOnClose +// +// Example: +// tray.onClose(() => { +// console.log("tray closed by the user") +// }) +func (t *Tray) jsOnClose(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + t.trayManager.ctx.handleTypeError("onClose requires a callback function") + } + + callback, ok := goja.AssertFunction(call.Argument(0)) + if !ok { + t.trayManager.ctx.handleTypeError("onClose requires a callback function") + } + + eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayClosedEvent) + payload := ClientTrayClosedEventPayload{} + + eventListener.SetCallback(func(event *ClientPluginEvent) { + if event.ParsePayloadAs(ClientTrayClosedEvent, &payload) { + t.trayManager.ctx.scheduler.ScheduleAsync(func() error { + _, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{})) + if err != nil { + t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray close callback") + } + return err + }) + } + }) + + return goja.Undefined() +} diff --git a/seanime-2.9.10/internal/plugin/ui/ui.go b/seanime-2.9.10/internal/plugin/ui/ui.go new file mode 100644 index 0000000..444a545 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/ui.go @@ -0,0 +1,325 @@ +package plugin_ui + +import ( + "errors" + "fmt" + "seanime/internal/database/db" + "seanime/internal/events" + "seanime/internal/extension" + "seanime/internal/plugin" + "seanime/internal/util" + goja_util "seanime/internal/util/goja" + "sync" + + "github.com/dop251/goja" + "github.com/rs/zerolog" +) + +var ( + ErrTooManyExceptions = errors.New("plugin: Too many exceptions") + ErrFatalError = errors.New("plugin: Fatal error") +) + +const ( + MaxExceptions = 5 // Maximum number of exceptions that can be thrown before the UI is interrupted + MaxConcurrentFetchRequests = 10 // Maximum number of concurrent fetch requests + MaxEffectCallsPerWindow = 100 // Maximum number of effect calls allowed in time window + EffectTimeWindow = 1000 // Time window in milliseconds to track effect calls + StateUpdateBatchInterval = 10 // Time in milliseconds to batch state updates + UIUpdateRateLimit = 120 // Time in milliseconds to rate limit UI updates +) + +// UI registry, unique to a plugin and VM +type UI struct { + ext *extension.Extension + context *Context + mu sync.RWMutex + vm *goja.Runtime // VM executing the UI + logger *zerolog.Logger + wsEventManager events.WSEventManagerInterface + appContext plugin.AppContext + scheduler *goja_util.Scheduler + + lastException string + + // Channel to signal the UI has been unloaded + // This is used to interrupt the Plugin when the UI is stopped + destroyedCh chan struct{} + destroyed bool +} + +type NewUIOptions struct { + Logger *zerolog.Logger + VM *goja.Runtime + WSManager events.WSEventManagerInterface + Database *db.Database + Scheduler *goja_util.Scheduler + Extension *extension.Extension +} + +func NewUI(options NewUIOptions) *UI { + ui := &UI{ + ext: options.Extension, + vm: options.VM, + logger: options.Logger, + wsEventManager: options.WSManager, + appContext: plugin.GlobalAppContext, // Get the app context from the global hook manager + scheduler: options.Scheduler, + destroyedCh: make(chan struct{}), + } + ui.context = NewContext(ui) + ui.context.scheduler.SetOnException(func(err error) { + ui.logger.Error().Err(err).Msg("plugin: Encountered exception in asynchronous task") + ui.context.handleException(err) + }) + + return ui +} + +// Called by the Plugin when it's being unloaded +func (u *UI) Unload(signalDestroyed bool) { + u.logger.Debug().Msg("plugin: Stopping UI") + + u.UnloadFromInside(signalDestroyed) + + u.logger.Debug().Msg("plugin: Stopped UI") +} + +// UnloadFromInside is called by the UI module itself when it's being unloaded +func (u *UI) UnloadFromInside(signalDestroyed bool) { + u.mu.Lock() + defer u.mu.Unlock() + + if u.destroyed { + return + } + // Stop the VM + u.vm.ClearInterrupt() + // Unsubscribe from client all events + if u.context.wsSubscriber != nil { + u.wsEventManager.UnsubscribeFromClientEvents("plugin-" + u.ext.ID) + } + // Clean up the context (all modules) + if u.context != nil { + u.context.Stop() + } + + // Send the plugin unloaded event to the client + u.wsEventManager.SendEvent(events.PluginUnloaded, u.ext.ID) + + if signalDestroyed { + u.signalDestroyed() + } +} + +// Destroyed returns a channel that is closed when the UI is destroyed +func (u *UI) Destroyed() <-chan struct{} { + return u.destroyedCh +} + +// signalDestroyed tells the plugin that the UI has been destroyed. +// This is used to interrupt the Plugin when the UI is stopped +// TODO: FIX +func (u *UI) signalDestroyed() { + defer func() { + if r := recover(); r != nil { + u.logger.Error().Msgf("plugin: Panic in signalDestroyed: %v", r) + } + }() + + if u.destroyed { + return + } + u.destroyed = true + close(u.destroyedCh) +} + +// Register a UI +// This is the main entry point for the UI +// - It is called once when the plugin is loaded and registers all necessary modules +func (u *UI) Register(callback string) error { + defer util.HandlePanicInModuleThen("plugin_ui/Register", func() { + u.logger.Error().Msg("plugin: Panic in Register") + }) + + u.mu.Lock() + + // Create a wrapper JavaScript function that calls the provided callback + callback = `function(ctx) { return (` + callback + `).call(undefined, ctx); }` + // Compile the callback into a Goja program + // pr := goja.MustCompile("", "{("+callback+").apply(undefined, __ctx)}", true) + + // Subscribe the plugin to client events + u.context.wsSubscriber = u.wsEventManager.SubscribeToClientEvents("plugin-" + u.ext.ID) + + u.logger.Debug().Msg("plugin: Registering UI") + + // Listen for client events and send them to the event listeners + go func() { + for event := range u.context.wsSubscriber.Channel { + //u.logger.Trace().Msgf("Received event %s", event.Type) + u.HandleWSEvent(event) + } + u.logger.Debug().Msg("plugin: Event goroutine stopped") + }() + + u.context.createAndBindContextObject(u.vm) + + // Execute the callback + _, err := u.vm.RunString(`(` + callback + `).call(undefined, __ctx)`) + if err != nil { + u.mu.Unlock() + u.logger.Error().Err(err).Msg("plugin: Encountered exception in UI handler, unloading plugin") + u.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("plugin(%s): Encountered exception in UI handler: %s", u.ext.ID, err.Error())) + u.wsEventManager.SendEvent(events.ConsoleLog, fmt.Sprintf("plugin(%s): Encountered exception in UI handler: %s", u.ext.ID, err.Error())) + // Unload the UI and signal the Plugin that it's been terminated + u.UnloadFromInside(true) + return fmt.Errorf("plugin: Encountered exception in UI handler: %w", err) + } + + // Send events to the client + u.context.trayManager.renderTrayScheduled() + u.context.trayManager.sendIconToClient() + u.context.actionManager.renderAnimePageButtons() + u.context.actionManager.renderAnimePageDropdownItems() + u.context.actionManager.renderAnimeLibraryDropdownItems() + u.context.actionManager.renderMangaPageButtons() + u.context.actionManager.renderMediaCardContextMenuItems() + u.context.actionManager.renderEpisodeCardContextMenuItems() + u.context.actionManager.renderEpisodeGridItemMenuItems() + u.context.commandPaletteManager.renderCommandPaletteScheduled() + u.context.commandPaletteManager.sendInfoToClient() + + u.wsEventManager.SendEvent(events.PluginLoaded, u.ext.ID) + + u.mu.Unlock() + return nil +} + +// Add this new type to handle batched events from the client +type BatchedClientEvents struct { + Events []map[string]interface{} `json:"events"` +} + +// HandleWSEvent handles a websocket event from the client +func (u *UI) HandleWSEvent(event *events.WebsocketClientEvent) { + defer util.HandlePanicInModuleThen("plugin/HandleWSEvent", func() {}) + + u.mu.RLock() + defer u.mu.RUnlock() + + // Ignore if UI is destroyed + if u.destroyed { + return + } + + if event.Type == events.PluginEvent { + // Extract the event payload + payload, ok := event.Payload.(map[string]interface{}) + if !ok { + u.logger.Error().Str("payload", fmt.Sprintf("%+v", event.Payload)).Msg("plugin/ui: Failed to parse plugin event payload") + return + } + + // Check if this is a batch event + eventType, _ := payload["type"].(string) + if eventType == "client:batch-events" { + u.handleBatchedClientEvents(event.ClientID, payload) + return + } + + // Process normal event + clientEvent := NewClientPluginEvent(payload) + if clientEvent == nil { + u.logger.Error().Interface("payload", payload).Msg("plugin/ui: Failed to create client plugin event") + return + } + + // If the event is for this plugin + if clientEvent.ExtensionID == u.ext.ID || clientEvent.ExtensionID == "" { + // Process the event based on type + u.dispatchClientEvent(clientEvent) + } + } +} + +// dispatchClientEvent handles a client event based on its type +func (u *UI) dispatchClientEvent(clientEvent *ClientPluginEvent) { + switch clientEvent.Type { + case ClientRenderTrayEvent: // Client wants to render the tray + u.context.trayManager.renderTrayScheduled() + + case ClientListTrayIconsEvent: // Client wants to list all tray icons from all plugins + u.context.trayManager.sendIconToClient() + + case ClientActionRenderAnimePageButtonsEvent: // Client wants to update the anime page buttons + u.context.actionManager.renderAnimePageButtons() + + case ClientActionRenderAnimePageDropdownItemsEvent: // Client wants to update the anime page dropdown items + u.context.actionManager.renderAnimePageDropdownItems() + + case ClientActionRenderAnimeLibraryDropdownItemsEvent: // Client wants to update the anime library dropdown items + u.context.actionManager.renderAnimeLibraryDropdownItems() + + case ClientActionRenderMangaPageButtonsEvent: // Client wants to update the manga page buttons + u.context.actionManager.renderMangaPageButtons() + + case ClientActionRenderMediaCardContextMenuItemsEvent: // Client wants to update the media card context menu items + u.context.actionManager.renderMediaCardContextMenuItems() + + case ClientActionRenderEpisodeCardContextMenuItemsEvent: // Client wants to update the episode card context menu items + u.context.actionManager.renderEpisodeCardContextMenuItems() + + case ClientActionRenderEpisodeGridItemMenuItemsEvent: // Client wants to update the episode grid item menu items + u.context.actionManager.renderEpisodeGridItemMenuItems() + + case ClientRenderCommandPaletteEvent: // Client wants to render the command palette + u.context.commandPaletteManager.renderCommandPaletteScheduled() + + case ClientListCommandPalettesEvent: // Client wants to list all command palettes + u.context.commandPaletteManager.sendInfoToClient() + + default: + // Send to registered event listeners + eventListeners, ok := u.context.eventBus.Get(clientEvent.Type) + if !ok { + return + } + eventListeners.Range(func(key string, listener *EventListener) bool { + listener.Send(clientEvent) + return true + }) + } +} + +// handleBatchedClientEvents processes a batch of client events +func (u *UI) handleBatchedClientEvents(clientID string, payload map[string]interface{}) { + if eventPayload, ok := payload["payload"].(map[string]interface{}); ok { + if eventsRaw, ok := eventPayload["events"].([]interface{}); ok { + // Process each event in the batch + for _, eventRaw := range eventsRaw { + if eventMap, ok := eventRaw.(map[string]interface{}); ok { + // Create a synthetic event object + syntheticPayload := map[string]interface{}{ + "type": eventMap["type"], + "extensionId": eventMap["extensionId"], + "payload": eventMap["payload"], + } + + // Create and dispatch the event + clientEvent := NewClientPluginEvent(syntheticPayload) + if clientEvent == nil { + u.logger.Error().Interface("payload", syntheticPayload).Msg("plugin/ui: Failed to create client plugin event from batch") + continue + } + + // If the event is for this plugin + if clientEvent.ExtensionID == u.ext.ID || clientEvent.ExtensionID == "" { + // Process the event + u.dispatchClientEvent(clientEvent) + } + } + } + } + } +} diff --git a/seanime-2.9.10/internal/plugin/ui/webview.go b/seanime-2.9.10/internal/plugin/ui/webview.go new file mode 100644 index 0000000..03b1543 --- /dev/null +++ b/seanime-2.9.10/internal/plugin/ui/webview.go @@ -0,0 +1,11 @@ +package plugin_ui + +type WebviewManager struct { + ctx *Context +} + +func NewWebviewManager(ctx *Context) *WebviewManager { + return &WebviewManager{ + ctx: ctx, + } +} diff --git a/seanime-2.9.10/internal/report/report.go b/seanime-2.9.10/internal/report/report.go new file mode 100644 index 0000000..2457eb5 --- /dev/null +++ b/seanime-2.9.10/internal/report/report.go @@ -0,0 +1,197 @@ +package report + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +type ClickLog struct { + Timestamp time.Time `json:"timestamp"` + Element string `json:"element"` + PageURL string `json:"pageUrl"` + Text *string `json:"text"` + ClassName *string `json:"className"` +} + +type NetworkLog struct { + Type string `json:"type"` + Method string `json:"method"` + URL string `json:"url"` + PageURL string `json:"pageUrl"` + Status int `json:"status"` + Duration int `json:"duration"` + DataPreview string `json:"dataPreview"` + Body string `json:"body"` + Timestamp time.Time `json:"timestamp"` +} + +type ReactQueryLog struct { + Type string `json:"type"` + PageURL string `json:"pageUrl"` + Status string `json:"status"` + Hash string `json:"hash"` + Error interface{} `json:"error"` + DataPreview string `json:"dataPreview"` + DataType string `json:"dataType"` + Timestamp time.Time `json:"timestamp"` +} + +type ConsoleLog struct { + Type string `json:"type"` + Content string `json:"content"` + PageURL string `json:"pageUrl"` + Timestamp time.Time `json:"timestamp"` +} + +type UnlockedLocalFile struct { + Path string `json:"path"` + MediaId int `json:"mediaId"` +} + +type IssueReport struct { + CreatedAt time.Time `json:"createdAt"` + UserAgent string `json:"userAgent"` + AppVersion string `json:"appVersion"` + OS string `json:"os"` + Arch string `json:"arch"` + ClickLogs []*ClickLog `json:"clickLogs,omitempty"` + NetworkLogs []*NetworkLog `json:"networkLogs,omitempty"` + ReactQueryLogs []*ReactQueryLog `json:"reactQueryLogs,omitempty"` + ConsoleLogs []*ConsoleLog `json:"consoleLogs,omitempty"` + UnlockedLocalFiles []*UnlockedLocalFile `json:"unlockedLocalFiles,omitempty"` + ScanLogs []string `json:"scanLogs,omitempty"` + ServerLogs string `json:"serverLogs,omitempty"` + ServerStatus string `json:"status,omitempty"` +} + +func NewIssueReport(userAgent, appVersion, _os, arch string, logsDir string, isAnimeLibraryIssue bool, serverStatus interface{}, toRedact []string) (ret *IssueReport, err error) { + ret = &IssueReport{ + CreatedAt: time.Now(), + UserAgent: userAgent, + AppVersion: appVersion, + OS: _os, + Arch: arch, + ClickLogs: make([]*ClickLog, 0), + NetworkLogs: make([]*NetworkLog, 0), + ReactQueryLogs: make([]*ReactQueryLog, 0), + ConsoleLogs: make([]*ConsoleLog, 0), + UnlockedLocalFiles: make([]*UnlockedLocalFile, 0), + ScanLogs: make([]string, 0), + ServerLogs: "", + ServerStatus: "", + } + + // Get all log files in the directory + entries, err := os.ReadDir(logsDir) + if err != nil { + return nil, fmt.Errorf("failed to read log directory: %w", err) + } + var serverLogFiles []os.FileInfo + var scanLogFiles []os.FileInfo + + for _, file := range entries { + if strings.HasPrefix(file.Name(), "seanime-") { + info, err := file.Info() + if err != nil { + continue + } + serverLogFiles = append(serverLogFiles, info) + } + if strings.Contains(file.Name(), "-scan") { + info, err := file.Info() + if err != nil { + continue + } + // Check if file is newer than 1 day + if time.Since(info.ModTime()).Hours() < 24 { + scanLogFiles = append(scanLogFiles, info) + } + } + } + + userPathPattern := regexp.MustCompile(`(?i)(/home/|/Users/|C:\\Users\\)([^/\\]+)`) + + if serverStatus != nil { + serverStatusMarshaled, err := json.Marshal(serverStatus) + if err == nil { + // pretty print the json + var prettyJSON bytes.Buffer + err = json.Indent(&prettyJSON, serverStatusMarshaled, "", " ") + if err == nil { + ret.ServerStatus = prettyJSON.String() + + for _, redact := range toRedact { + ret.ServerStatus = strings.ReplaceAll(ret.ServerStatus, redact, "[REDACTED]") + } + + ret.ServerStatus = userPathPattern.ReplaceAllString(ret.ServerStatus, "${1}[REDACTED]") + } + } + } + + if len(serverLogFiles) > 0 { + sort.Slice(serverLogFiles, func(i, j int) bool { + return serverLogFiles[i].ModTime().After(serverLogFiles[j].ModTime()) + }) + // Get the most recent log file + latestLog := serverLogFiles[0] + latestLogPath := filepath.Join(logsDir, latestLog.Name()) + latestLogContent, err := os.ReadFile(latestLogPath) + if err != nil { + return nil, fmt.Errorf("failed to read log file: %w", err) + } + + latestLogContent = userPathPattern.ReplaceAll(latestLogContent, []byte("${1}[REDACTED]")) + + for _, redact := range toRedact { + latestLogContent = bytes.ReplaceAll(latestLogContent, []byte(redact), []byte("[REDACTED]")) + } + ret.ServerLogs = string(latestLogContent) + } + + if isAnimeLibraryIssue { + if len(scanLogFiles) > 0 { + for _, file := range scanLogFiles { + scanLogPath := filepath.Join(logsDir, file.Name()) + scanLogContent, err := os.ReadFile(scanLogPath) + if err != nil { + continue + } + + scanLogContent = userPathPattern.ReplaceAll(scanLogContent, []byte("${1}[REDACTED]")) + + if len(scanLogContent) == 0 { + ret.ScanLogs = append(ret.ScanLogs, "EMPTY") + } else { + ret.ScanLogs = append(ret.ScanLogs, string(scanLogContent)) + + } + } + } + } + + return +} + +func (ir *IssueReport) AddClickLogs(clickLogs []*ClickLog) { + ir.ClickLogs = append(ir.ClickLogs, clickLogs...) +} + +func (ir *IssueReport) AddNetworkLogs(networkLogs []*NetworkLog) { + ir.NetworkLogs = append(ir.NetworkLogs, networkLogs...) +} + +func (ir *IssueReport) AddReactQueryLogs(reactQueryLogs []*ReactQueryLog) { + ir.ReactQueryLogs = append(ir.ReactQueryLogs, reactQueryLogs...) +} + +func (ir *IssueReport) AddConsoleLogs(consoleLogs []*ConsoleLog) { + ir.ConsoleLogs = append(ir.ConsoleLogs, consoleLogs...) +} diff --git a/seanime-2.9.10/internal/report/repository.go b/seanime-2.9.10/internal/report/repository.go new file mode 100644 index 0000000..cc74f1b --- /dev/null +++ b/seanime-2.9.10/internal/report/repository.go @@ -0,0 +1,95 @@ +package report + +import ( + "fmt" + "github.com/rs/zerolog" + "github.com/samber/lo" + "github.com/samber/mo" + "runtime" + "seanime/internal/constants" + "seanime/internal/database/models" + "seanime/internal/library/anime" +) + +type Repository struct { + logger *zerolog.Logger + + savedIssueReport mo.Option[*IssueReport] +} + +func NewRepository(logger *zerolog.Logger) *Repository { + return &Repository{ + logger: logger, + savedIssueReport: mo.None[*IssueReport](), + } +} + +type SaveIssueReportOptions struct { + LogsDir string `json:"logsDir"` + UserAgent string `json:"userAgent"` + ClickLogs []*ClickLog `json:"clickLogs"` + NetworkLogs []*NetworkLog `json:"networkLogs"` + ReactQueryLogs []*ReactQueryLog `json:"reactQueryLogs"` + ConsoleLogs []*ConsoleLog `json:"consoleLogs"` + LocalFiles []*anime.LocalFile `json:"localFiles"` + Settings *models.Settings `json:"settings"` + DebridSettings *models.DebridSettings `json:"debridSettings"` + IsAnimeLibraryIssue bool `json:"isAnimeLibraryIssue"` + ServerStatus interface{} `json:"serverStatus"` +} + +func (r *Repository) SaveIssueReport(opts SaveIssueReportOptions) error { + + var toRedact []string + if opts.Settings != nil { + toRedact = opts.Settings.GetSensitiveValues() + } + if opts.DebridSettings != nil { + toRedact = append(toRedact, opts.DebridSettings.GetSensitiveValues()...) + } + toRedact = lo.Filter(toRedact, func(s string, _ int) bool { + return s != "" + }) + + issueReport, err := NewIssueReport( + opts.UserAgent, + constants.Version, + runtime.GOOS, + runtime.GOARCH, + opts.LogsDir, + opts.IsAnimeLibraryIssue, + opts.ServerStatus, + toRedact, + ) + if err != nil { + return fmt.Errorf("failed to create issue report: %w", err) + } + + issueReport.ClickLogs = opts.ClickLogs + issueReport.NetworkLogs = opts.NetworkLogs + issueReport.ReactQueryLogs = opts.ReactQueryLogs + issueReport.ConsoleLogs = opts.ConsoleLogs + if opts.IsAnimeLibraryIssue { + for _, localFile := range opts.LocalFiles { + if localFile.Locked { + continue + } + issueReport.UnlockedLocalFiles = append(issueReport.UnlockedLocalFiles, &UnlockedLocalFile{ + Path: localFile.Path, + MediaId: localFile.MediaId, + }) + } + } + + r.savedIssueReport = mo.Some(issueReport) + + return nil +} + +func (r *Repository) GetSavedIssueReport() (*IssueReport, bool) { + if r.savedIssueReport.IsAbsent() { + return nil, false + } + + return r.savedIssueReport.MustGet(), true +} diff --git a/seanime-2.9.10/internal/server/server.go b/seanime-2.9.10/internal/server/server.go new file mode 100644 index 0000000..de083a6 --- /dev/null +++ b/seanime-2.9.10/internal/server/server.go @@ -0,0 +1,112 @@ +package server + +import ( + "embed" + "fmt" + golog "log" + "os" + "path/filepath" + "seanime/internal/core" + "seanime/internal/cron" + "seanime/internal/handlers" + "seanime/internal/updater" + "seanime/internal/util" + "seanime/internal/util/crashlog" + "time" + + "github.com/rs/zerolog/log" +) + +func startApp(embeddedLogo []byte) (*core.App, core.SeanimeFlags, *updater.SelfUpdater) { + // Print the header + core.PrintHeader() + + // Get the flags + flags := core.GetSeanimeFlags() + + selfupdater := updater.NewSelfUpdater() + + // Create the app instance + app := core.NewApp(&core.ConfigOptions{ + DataDir: flags.DataDir, + EmbeddedLogo: embeddedLogo, + IsDesktopSidecar: flags.IsDesktopSidecar, + }, selfupdater) + + // Create log file + logFilePath := filepath.Join(app.Config.Logs.Dir, fmt.Sprintf("seanime-%s.log", time.Now().Format("2006-01-02_15-04-05"))) + // Open the log file + logFile, _ := os.OpenFile( + logFilePath, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0664, + ) + + log.Logger = *app.Logger + golog.SetOutput(app.Logger) + util.SetupLoggerSignalHandling(logFile) + crashlog.GlobalCrashLogger.SetLogDir(app.Config.Logs.Dir) + + app.OnFlushLogs = func() { + util.WriteGlobalLogBufferToFile(logFile) + logFile.Sync() + } + + if !flags.Update { + go func() { + for { + util.WriteGlobalLogBufferToFile(logFile) + time.Sleep(5 * time.Second) + } + }() + } + + return app, flags, selfupdater +} + +func startAppLoop(webFS *embed.FS, app *core.App, flags core.SeanimeFlags, selfupdater *updater.SelfUpdater) { + updateMode := flags.Update + +appLoop: + for { + switch updateMode { + case true: + + log.Log().Msg("Running in update mode") + + // Print the header + core.PrintHeader() + + // Run the self-updater + err := selfupdater.Run() + if err != nil { + } + + log.Log().Msg("Shutting down in 10 seconds...") + time.Sleep(10 * time.Second) + + break appLoop + case false: + + // Create the echo app instance + echoApp := core.NewEchoApp(app, webFS) + + // Initialize the routes + handlers.InitRoutes(app, echoApp) + + // Run the server + core.RunEchoServer(app, echoApp) + + // Run the jobs in the background + cron.RunJobs(app) + + select { + case <-selfupdater.Started(): + app.Cleanup() + updateMode = true + break + } + } + continue + } +} diff --git a/seanime-2.9.10/internal/server/server_unix.go b/seanime-2.9.10/internal/server/server_unix.go new file mode 100644 index 0000000..cdfbc13 --- /dev/null +++ b/seanime-2.9.10/internal/server/server_unix.go @@ -0,0 +1,14 @@ +//go:build (linux || darwin) && !windows + +package server + +import ( + "embed" +) + +func StartServer(webFS embed.FS, embeddedLogo []byte) { + + app, flags, selfupdater := startApp(embeddedLogo) + + startAppLoop(&webFS, app, flags, selfupdater) +} diff --git a/seanime-2.9.10/internal/server/server_windows.go b/seanime-2.9.10/internal/server/server_windows.go new file mode 100644 index 0000000..5de13ae --- /dev/null +++ b/seanime-2.9.10/internal/server/server_windows.go @@ -0,0 +1,98 @@ +//go:build windows && !nosystray + +package server + +import ( + "embed" + "fmt" + "fyne.io/systray" + "github.com/cli/browser" + "github.com/gonutz/w32/v2" + "github.com/rs/zerolog/log" + "seanime/internal/constants" + "seanime/internal/core" + "seanime/internal/handlers" + "seanime/internal/icon" + "seanime/internal/updater" +) + +func StartServer(webFS embed.FS, embeddedLogo []byte) { + onExit := func() {} + hideConsole() + + app, flags, selfupdater := startApp(embeddedLogo) + + // Blocks until systray.Quit() is called + systray.Run(onReady(&webFS, app, flags, selfupdater), onExit) +} + +func addQuitItem() { + systray.AddSeparator() + mQuit := systray.AddMenuItem("Quit Seanime", "Quit the whole app") + mQuit.Enable() + go func() { + <-mQuit.ClickedCh + log.Trace().Msg("systray: Quitting system tray") + systray.Quit() + log.Trace().Msg("systray: Quit system tray") + }() +} + +func onReady(webFS *embed.FS, app *core.App, flags core.SeanimeFlags, selfupdater *updater.SelfUpdater) func() { + return func() { + systray.SetTemplateIcon(icon.Data, icon.Data) + systray.SetTitle(fmt.Sprintf("Seanime v%s", constants.Version)) + systray.SetTooltip(fmt.Sprintf("Seanime v%s", constants.Version)) + log.Trace().Msg("systray: App is ready") + + // Menu items + systray.AddMenuItem("Seanime v"+constants.Version, "Seanime version") + mWeb := systray.AddMenuItem(app.Config.GetServerURI("127.0.0.1"), "Open web interface") + mOpenLibrary := systray.AddMenuItem("Open Anime Library", "Open anime library") + mOpenDataDir := systray.AddMenuItem("Open Data Directory", "Open data directory") + mOpenLogsDir := systray.AddMenuItem("Open Log Directory", "Open log directory") + + addQuitItem() + + go func() { + // Close the systray when the app exits + defer systray.Quit() + + startAppLoop(webFS, app, flags, selfupdater) + }() + + go func() { + for { + select { + case <-mWeb.ClickedCh: + _ = browser.OpenURL(app.Config.GetServerURI("127.0.0.1")) + case <-mOpenLibrary.ClickedCh: + handlers.OpenDirInExplorer(app.LibraryDir) + case <-mOpenDataDir.ClickedCh: + handlers.OpenDirInExplorer(app.Config.Data.AppDataDir) + case <-mOpenLogsDir.ClickedCh: + handlers.OpenDirInExplorer(app.Config.Logs.Dir) + } + } + }() + } +} + +// hideConsole will hide the terminal window if the app was not started with the -H=windowsgui flag. +// NOTE: This will only minimize the terminal window on Windows 11 if the 'default terminal app' is set to 'Windows Terminal' or 'Let Windows choose' instead of 'Windows Console Host' +func hideConsole() { + console := w32.GetConsoleWindow() + if console == 0 { + return // no console attached + } + // If this application is the process that created the console window, then + // this program was not compiled with the -H=windowsgui flag and on start-up + // it created a console along with the main application window. In this case + // hide the console window. + // See + // http://stackoverflow.com/questions/9009333/how-to-check-if-the-program-is-run-from-a-console + _, consoleProcID := w32.GetWindowThreadProcessId(console) + if w32.GetCurrentProcessId() == consoleProcID { + w32.ShowWindow(console, w32.SW_HIDE) + } +} diff --git a/seanime-2.9.10/internal/server/server_windows_nosystray.go b/seanime-2.9.10/internal/server/server_windows_nosystray.go new file mode 100644 index 0000000..601d593 --- /dev/null +++ b/seanime-2.9.10/internal/server/server_windows_nosystray.go @@ -0,0 +1,14 @@ +//go:build windows && nosystray + +package server + +import ( + "embed" +) + +func StartServer(webFS embed.FS, embeddedLogo []byte) { + + app, flags, selfupdater := startApp(embeddedLogo) + + startAppLoop(&webFS, app, flags, selfupdater) +} diff --git a/seanime-2.9.10/internal/test_utils/cache.go b/seanime-2.9.10/internal/test_utils/cache.go new file mode 100644 index 0000000..cd7c2c1 --- /dev/null +++ b/seanime-2.9.10/internal/test_utils/cache.go @@ -0,0 +1,11 @@ +package test_utils + +import "fmt" + +func GetTestDataPath(name string) string { + return fmt.Sprintf("%s/%s.json", TestDataPath, name) +} + +func GetDataPath(name string) string { + return fmt.Sprintf("%s/%s.json", DataPath, name) +} diff --git a/seanime-2.9.10/internal/test_utils/data.go b/seanime-2.9.10/internal/test_utils/data.go new file mode 100644 index 0000000..f7b134b --- /dev/null +++ b/seanime-2.9.10/internal/test_utils/data.go @@ -0,0 +1,195 @@ +package test_utils + +import ( + "fmt" + "log" + "os" + "testing" + + "github.com/spf13/viper" +) + +var ConfigData = &Config{} + +const ( + TwoLevelDeepTestConfigPath = "../../test" + TwoLevelDeepDataPath = "../../test/data" + TwoLevelDeepTestDataPath = "../../test/testdata" + ThreeLevelDeepTestConfigPath = "../../../test" + ThreeLevelDeepDataPath = "../../../test/data" + ThreeLevelDeepTestDataPath = "../../../test/testdata" +) + +var ConfigPath = ThreeLevelDeepTestConfigPath +var TestDataPath = ThreeLevelDeepTestDataPath +var DataPath = ThreeLevelDeepDataPath + +type ( + Config struct { + Provider ProviderConfig `mapstructure:"provider"` + Path PathConfig `mapstructure:"path"` + Database DatabaseConfig `mapstructure:"database"` + Flags FlagsConfig `mapstructure:"flags"` + } + + FlagsConfig struct { + EnableAnilistTests bool `mapstructure:"enable_anilist_tests"` + EnableAnilistMutationTests bool `mapstructure:"enable_anilist_mutation_tests"` + EnableMalTests bool `mapstructure:"enable_mal_tests"` + EnableMalMutationTests bool `mapstructure:"enable_mal_mutation_tests"` + EnableMediaPlayerTests bool `mapstructure:"enable_media_player_tests"` + EnableTorrentClientTests bool `mapstructure:"enable_torrent_client_tests"` + EnableTorrentstreamTests bool `mapstructure:"enable_torrentstream_tests"` + } + + ProviderConfig struct { + AnilistJwt string `mapstructure:"anilist_jwt"` + AnilistUsername string `mapstructure:"anilist_username"` + MalJwt string `mapstructure:"mal_jwt"` + QbittorrentHost string `mapstructure:"qbittorrent_host"` + QbittorrentPort int `mapstructure:"qbittorrent_port"` + QbittorrentUsername string `mapstructure:"qbittorrent_username"` + QbittorrentPassword string `mapstructure:"qbittorrent_password"` + QbittorrentPath string `mapstructure:"qbittorrent_path"` + TransmissionHost string `mapstructure:"transmission_host"` + TransmissionPort int `mapstructure:"transmission_port"` + TransmissionPath string `mapstructure:"transmission_path"` + TransmissionUsername string `mapstructure:"transmission_username"` + TransmissionPassword string `mapstructure:"transmission_password"` + MpcHost string `mapstructure:"mpc_host"` + MpcPort int `mapstructure:"mpc_port"` + MpcPath string `mapstructure:"mpc_path"` + VlcHost string `mapstructure:"vlc_host"` + VlcPort int `mapstructure:"vlc_port"` + VlcPassword string `mapstructure:"vlc_password"` + VlcPath string `mapstructure:"vlc_path"` + MpvPath string `mapstructure:"mpv_path"` + MpvSocket string `mapstructure:"mpv_socket"` + IinaPath string `mapstructure:"iina_path"` + IinaSocket string `mapstructure:"iina_socket"` + TorBoxApiKey string `mapstructure:"torbox_api_key"` + RealDebridApiKey string `mapstructure:"realdebrid_api_key"` + } + PathConfig struct { + DataDir string `mapstructure:"dataDir"` + } + + DatabaseConfig struct { + Name string `mapstructure:"name"` + } + + FlagFunc func() bool +) + +func Anilist() FlagFunc { + return func() bool { + return ConfigData.Flags.EnableAnilistTests + } +} + +func AnilistMutation() FlagFunc { + return func() bool { + f := ConfigData.Flags.EnableAnilistMutationTests + if !f { + fmt.Println("skipping anilist mutation tests") + return false + } + if ConfigData.Provider.AnilistJwt == "" { + fmt.Println("skipping anilist mutation tests, no anilist jwt") + return false + } + return true + } +} +func MyAnimeList() FlagFunc { + return func() bool { + return ConfigData.Flags.EnableMalTests + } +} +func MyAnimeListMutation() FlagFunc { + return func() bool { + f := ConfigData.Flags.EnableMalMutationTests + if !f { + fmt.Println("skipping mal mutation tests") + return false + } + if ConfigData.Provider.MalJwt == "" { + fmt.Println("skipping mal mutation tests, no mal jwt") + return false + } + return true + } +} +func MediaPlayer() FlagFunc { + return func() bool { + f := ConfigData.Flags.EnableMediaPlayerTests + if !f { + fmt.Println("skipping media player tests") + return false + } + if ConfigData.Provider.MpvPath == "" { + fmt.Println("skipping media player tests, no mpv path") + return false + } + return true + } +} +func TorrentClient() FlagFunc { + return func() bool { + return ConfigData.Flags.EnableTorrentClientTests + } +} +func Torrentstream() FlagFunc { + return func() bool { + return ConfigData.Flags.EnableTorrentstreamTests + } +} + +// InitTestProvider populates the ConfigData and skips the test if the given flags are not set +func InitTestProvider(t *testing.T, args ...FlagFunc) { + if os.Getenv("TEST_CONFIG_PATH") == "" { + err := os.Setenv("TEST_CONFIG_PATH", ConfigPath) + if err != nil { + log.Fatalf("couldn't set TEST_CONFIG_PATH: %s", err) + } + } + ConfigData = getConfig() + + for _, fn := range args { + if !fn() { + t.Skip() + break + } + } +} + +func SetTestConfigPath(path string) { + err := os.Setenv("TEST_CONFIG_PATH", path) + if err != nil { + log.Fatalf("couldn't set TEST_CONFIG_PATH: %s", err) + } +} +func SetTwoLevelDeep() { + ConfigPath = TwoLevelDeepTestConfigPath + TestDataPath = TwoLevelDeepTestDataPath + DataPath = TwoLevelDeepDataPath +} + +func getConfig() *Config { + configPath, exists := os.LookupEnv("TEST_CONFIG_PATH") + if !exists { + log.Fatalf("TEST_CONFIG_PATH not set") + } + + v := viper.New() + v.SetConfigName("config") + v.AddConfigPath(configPath) + if err := v.ReadInConfig(); err != nil { + log.Fatalf("couldn't load config: %s", err) + } + var c Config + if err := v.Unmarshal(&c); err != nil { + fmt.Printf("couldn't read config: %s", err) + } + return &c +} diff --git a/seanime-2.9.10/internal/test_utils/data_test.go b/seanime-2.9.10/internal/test_utils/data_test.go new file mode 100644 index 0000000..0d13d5b --- /dev/null +++ b/seanime-2.9.10/internal/test_utils/data_test.go @@ -0,0 +1,14 @@ +package test_utils + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGetConfig(t *testing.T) { + assert.Empty(t, ConfigData) + + InitTestProvider(t) + + assert.NotEmpty(t, ConfigData) +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/application/client.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/application/client.go new file mode 100644 index 0000000..538d824 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/application/client.go @@ -0,0 +1,61 @@ +package qbittorrent_application + +import ( + "github.com/rs/zerolog" + "net/http" + qbittorrent_model "seanime/internal/torrent_clients/qbittorrent/model" + qbittorrent_util "seanime/internal/torrent_clients/qbittorrent/util" +) + +type Client struct { + BaseUrl string + Client *http.Client + Logger *zerolog.Logger +} + +func (c Client) GetAppVersion() (string, error) { + var res string + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/version", nil); err != nil { + return "", err + } + return res, nil +} + +func (c Client) GetAPIVersion() (string, error) { + var res string + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/webapiVersion", nil); err != nil { + return "", err + } + return res, nil +} + +func (c Client) GetBuildInfo() (*qbittorrent_model.BuildInfo, error) { + var res qbittorrent_model.BuildInfo + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/buildInfo", nil); err != nil { + return nil, err + } + return &res, nil +} + +func (c Client) GetAppPreferences() (*qbittorrent_model.Preferences, error) { + var res qbittorrent_model.Preferences + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/preferences", nil); err != nil { + return nil, err + } + return &res, nil +} + +func (c Client) SetAppPreferences(p *qbittorrent_model.Preferences) error { + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/setPreferences", p); err != nil { + return err + } + return nil +} + +func (c Client) GetDefaultSavePath() (string, error) { + var res string + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/defaultSavePath", nil); err != nil { + return "", err + } + return res, nil +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/client.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/client.go new file mode 100644 index 0000000..68c70c0 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/client.go @@ -0,0 +1,162 @@ +package qbittorrent + +import ( + "fmt" + "github.com/rs/zerolog" + "net/http" + "net/http/cookiejar" + "net/url" + "seanime/internal/torrent_clients/qbittorrent/application" + "seanime/internal/torrent_clients/qbittorrent/log" + "seanime/internal/torrent_clients/qbittorrent/rss" + "seanime/internal/torrent_clients/qbittorrent/search" + "seanime/internal/torrent_clients/qbittorrent/sync" + "seanime/internal/torrent_clients/qbittorrent/torrent" + "seanime/internal/torrent_clients/qbittorrent/transfer" + "strings" + + "golang.org/x/net/publicsuffix" +) + +type Client struct { + baseURL string + logger *zerolog.Logger + client *http.Client + Username string + Password string + Port int + Host string + Path string + DisableBinaryUse bool + Tags string + Application qbittorrent_application.Client + Log qbittorrent_log.Client + RSS qbittorrent_rss.Client + Search qbittorrent_search.Client + Sync qbittorrent_sync.Client + Torrent qbittorrent_torrent.Client + Transfer qbittorrent_transfer.Client +} + +type NewClientOptions struct { + Logger *zerolog.Logger + Username string + Password string + Port int + Host string + Path string + DisableBinaryUse bool + Tags string +} + +func NewClient(opts *NewClientOptions) *Client { + baseURL := fmt.Sprintf("http://%s:%d/api/v2", opts.Host, opts.Port) + + if strings.HasPrefix(opts.Host, "https://") { + opts.Host = strings.TrimPrefix(opts.Host, "https://") + baseURL = fmt.Sprintf("https://%s:%d/api/v2", opts.Host, opts.Port) + } + + client := &http.Client{} + return &Client{ + baseURL: baseURL, + logger: opts.Logger, + client: client, + Username: opts.Username, + Password: opts.Password, + Port: opts.Port, + Path: opts.Path, + DisableBinaryUse: opts.DisableBinaryUse, + Host: opts.Host, + Tags: opts.Tags, + Application: qbittorrent_application.Client{ + BaseUrl: baseURL + "/app", + Client: client, + Logger: opts.Logger, + }, + Log: qbittorrent_log.Client{ + BaseUrl: baseURL + "/log", + Client: client, + Logger: opts.Logger, + }, + RSS: qbittorrent_rss.Client{ + BaseUrl: baseURL + "/rss", + Client: client, + Logger: opts.Logger, + }, + Search: qbittorrent_search.Client{ + BaseUrl: baseURL + "/search", + Client: client, + Logger: opts.Logger, + }, + Sync: qbittorrent_sync.Client{ + BaseUrl: baseURL + "/sync", + Client: client, + Logger: opts.Logger, + }, + Torrent: qbittorrent_torrent.Client{ + BaseUrl: baseURL + "/torrents", + Client: client, + Logger: opts.Logger, + }, + Transfer: qbittorrent_transfer.Client{ + BaseUrl: baseURL + "/transfer", + Client: client, + Logger: opts.Logger, + }, + } +} + +func (c *Client) Login() error { + endpoint := c.baseURL + "/auth/login" + data := url.Values{} + data.Add("username", c.Username) + data.Add("password", c.Password) + request, err := http.NewRequest("POST", endpoint, strings.NewReader(data.Encode())) + if err != nil { + return err + } + request.Header.Add("content-type", "application/x-www-form-urlencoded") + resp, err := c.client.Do(request) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Err(err).Msg("failed to close login response body") + } + }() + if resp.StatusCode != 200 { + return fmt.Errorf("invalid status %s", resp.Status) + } + if len(resp.Cookies()) < 1 { + return fmt.Errorf("no cookies in login response") + } + apiURL, err := url.Parse(c.baseURL) + if err != nil { + return err + } + jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + if err != nil { + return err + } + jar.SetCookies(apiURL, []*http.Cookie{resp.Cookies()[0]}) + c.client.Jar = jar + return nil +} + +func (c *Client) Logout() error { + endpoint := c.baseURL + "/auth/logout" + request, err := http.NewRequest("POST", endpoint, nil) + if err != nil { + return err + } + resp, err := c.client.Do(request) + if err != nil { + return err + } + if resp.StatusCode != 200 { + return fmt.Errorf("invalid status %s", resp.Status) + } + return nil +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/client_test.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/client_test.go new file mode 100644 index 0000000..a21c789 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/client_test.go @@ -0,0 +1,91 @@ +package qbittorrent + +import ( + "github.com/stretchr/testify/require" + "seanime/internal/test_utils" + "seanime/internal/torrent_clients/qbittorrent/model" + "seanime/internal/util" + "testing" +) + +func TestGetList(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.TorrentClient()) + + client := NewClient(&NewClientOptions{ + Logger: util.NewLogger(), + Username: test_utils.ConfigData.Provider.QbittorrentUsername, + Password: test_utils.ConfigData.Provider.QbittorrentPassword, + Port: test_utils.ConfigData.Provider.QbittorrentPort, + Host: test_utils.ConfigData.Provider.QbittorrentHost, + Path: test_utils.ConfigData.Provider.QbittorrentPath, + }) + + res, err := client.Torrent.GetList(&qbittorrent_model.GetTorrentListOptions{ + Filter: "", + Category: nil, + Sort: "", + Reverse: false, + Limit: 0, + Offset: 0, + Hashes: "", + }) + require.NoError(t, err) + + for _, torrent := range res { + t.Logf("%+v", torrent) + } + +} + +func TestGetMainDataList(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.TorrentClient()) + + client := NewClient(&NewClientOptions{ + Logger: util.NewLogger(), + Username: test_utils.ConfigData.Provider.QbittorrentUsername, + Password: test_utils.ConfigData.Provider.QbittorrentPassword, + Port: test_utils.ConfigData.Provider.QbittorrentPort, + Host: test_utils.ConfigData.Provider.QbittorrentHost, + Path: test_utils.ConfigData.Provider.QbittorrentPath, + }) + + res, err := client.Sync.GetMainData(0) + require.NoError(t, err) + + for _, torrent := range res.Torrents { + t.Logf("%+v", torrent) + } + + res2, err := client.Sync.GetMainData(res.RID) + require.NoError(t, err) + + require.Equal(t, 0, len(res2.Torrents)) + + for _, torrent := range res2.Torrents { + t.Logf("%+v", torrent) + } + +} + +func TestGetActiveTorrents(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.TorrentClient()) + + client := NewClient(&NewClientOptions{ + Logger: util.NewLogger(), + Username: test_utils.ConfigData.Provider.QbittorrentUsername, + Password: test_utils.ConfigData.Provider.QbittorrentPassword, + Port: test_utils.ConfigData.Provider.QbittorrentPort, + Host: test_utils.ConfigData.Provider.QbittorrentHost, + Path: test_utils.ConfigData.Provider.QbittorrentPath, + }) + + res, err := client.Torrent.GetList(&qbittorrent_model.GetTorrentListOptions{ + Filter: "active", + }) + require.NoError(t, err) + + for _, torrent := range res { + t.Logf("%+v", torrent.Name) + } + +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/log/client.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/log/client.go new file mode 100644 index 0000000..649403d --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/log/client.go @@ -0,0 +1,44 @@ +package qbittorrent_log + +import ( + "github.com/google/go-querystring/query" + "github.com/rs/zerolog" + "net/http" + "net/url" + "seanime/internal/torrent_clients/qbittorrent/model" + "seanime/internal/torrent_clients/qbittorrent/util" + "strconv" +) + +type Client struct { + BaseUrl string + Client *http.Client + Logger *zerolog.Logger +} + +func (c Client) GetLog(options *qbittorrent_model.GetLogOptions) ([]*qbittorrent_model.LogEntry, error) { + endpoint := c.BaseUrl + "/main" + if options != nil { + params, err := query.Values(options) + if err != nil { + return nil, err + } + endpoint += "?" + params.Encode() + } + var res []*qbittorrent_model.LogEntry + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) GetPeerLog(lastKnownID int) ([]*qbittorrent_model.PeerLogEntry, error) { + params := url.Values{} + params.Add("last_known_id", strconv.Itoa(lastKnownID)) + endpoint := c.BaseUrl + "/peers?" + params.Encode() + var res []*qbittorrent_model.PeerLogEntry + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + return res, nil +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/add_torrents_options.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/add_torrents_options.go new file mode 100644 index 0000000..3220e4d --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/add_torrents_options.go @@ -0,0 +1,30 @@ +package qbittorrent_model + +type AddTorrentsOptions struct { + // Download folder + Savepath string `json:"savepath,omitempty"` + // Cookie sent to download the .torrent file + Cookie string `json:"cookie,omitempty"` + // Category for the torrent + Category string `json:"category,omitempty"` + // Skip hash checking. + SkipChecking bool `json:"skip_checking,omitempty"` + // Add torrents in the paused state. + Paused string `json:"paused,omitempty"` + // Create the root folder. Possible values are true, false, unset (default) + RootFolder string `json:"root_folder,omitempty"` + // Rename torrent + Rename string `json:"rename,omitempty"` + // Set torrent upload speed limit. Unit in bytes/second + UpLimit int `json:"upLimit,omitempty"` + // Set torrent download speed limit. Unit in bytes/second + DlLimit int `json:"dlLimit,omitempty"` + // Whether Automatic Torrent Management should be used + UseAutoTMM bool `json:"useAutoTMM,omitempty"` + // Enable sequential download. Possible values are true, false (default) + SequentialDownload bool `json:"sequentialDownload,omitempty"` + // Prioritize download first last piece. Possible values are true, false (default) + FirstLastPiecePrio bool `json:"firstLastPiecePrio,omitempty"` + // Tags for the torrent, split by ',' + Tags string `json:"tags,omitempty"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/build_info.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/build_info.go new file mode 100644 index 0000000..4635366 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/build_info.go @@ -0,0 +1,9 @@ +package qbittorrent_model + +type BuildInfo struct { + QT string `json:"qt"` + LibTorrent string `json:"libtorrent"` + Boost string `json:"boost"` + OpenSSL string `json:"openssl"` + Bitness string `json:"bitness"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/category.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/category.go new file mode 100644 index 0000000..8639fb1 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/category.go @@ -0,0 +1,6 @@ +package qbittorrent_model + +type Category struct { + Name string `json:"name"` + SavePath string `json:"savePath"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/get_log_options.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/get_log_options.go new file mode 100644 index 0000000..6662534 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/get_log_options.go @@ -0,0 +1,9 @@ +package qbittorrent_model + +type GetLogOptions struct { + Normal bool `url:"normal"` + Info bool `url:"info"` + Warning bool `url:"warning"` + Critical bool `url:"critical"` + LastKnownID int `url:"lastKnownId"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/get_torrents_list_options.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/get_torrents_list_options.go new file mode 100644 index 0000000..da081c5 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/get_torrents_list_options.go @@ -0,0 +1,23 @@ +package qbittorrent_model + +type GetTorrentListOptions struct { + Filter TorrentListFilter `url:"filter,omitempty"` + Category *string `url:"category,omitempty"` + Sort string `url:"sort,omitempty"` + Reverse bool `url:"reverse,omitempty"` + Limit int `url:"limit,omitempty"` + Offset int `url:"offset,omitempty"` + Hashes string `url:"hashes,omitempty"` +} + +type TorrentListFilter string + +const ( + FilterAll TorrentListFilter = "all" + FilterDownloading = "downloading" + FilterCompleted = "completed" + FilterPaused = "paused" + FilterActive = "active" + FilterInactive = "inactive" + FilterResumed = "resumed" +) diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/log_entry.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/log_entry.go new file mode 100644 index 0000000..0c2191e --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/log_entry.go @@ -0,0 +1,44 @@ +package qbittorrent_model + +import ( + "encoding/json" + "time" +) + +type LogEntry struct { + ID int `json:"id"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` + Type LogType `json:"type"` +} + +func (l *LogEntry) UnmarshalJSON(data []byte) error { + var raw rawLogEntry + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + t := time.Unix(0, int64(raw.Timestamp)*int64(time.Millisecond)) + *l = LogEntry{ + ID: raw.ID, + Message: raw.Message, + Timestamp: t, + Type: raw.Type, + } + return nil +} + +type LogType int + +const ( + TypeNormal LogType = iota << 1 + TypeInfo + TypeWarning + TypeCritical +) + +type rawLogEntry struct { + ID int `json:"id"` + Message string `json:"message"` + Timestamp int `json:"timestamp"` + Type LogType `json:"type"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/peer.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/peer.go new file mode 100644 index 0000000..ab458ac --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/peer.go @@ -0,0 +1,19 @@ +package qbittorrent_model + +type Peer struct { + Client string `json:"client"` + Connection string `json:"connection"` + Country string `json:"country"` + CountryCode string `json:"country_code"` + DLSpeed int `json:"dlSpeed"` + Downloaded int `json:"downloaded"` + Files string `json:"files"` + Flags string `json:"flags"` + FlagsDescription string `json:"flags_desc"` + IP string `json:"ip"` + Port int `json:"port"` + Progress float64 `json:"progress"` + Relevance int `json:"relevance"` + ULSpeed int `json:"up_speed"` + Uploaded int `json:"uploaded"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/peer_log_entry.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/peer_log_entry.go new file mode 100644 index 0000000..14eb4c6 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/peer_log_entry.go @@ -0,0 +1,38 @@ +package qbittorrent_model + +import ( + "encoding/json" + "time" +) + +type PeerLogEntry struct { + ID int `json:"id"` + IP string `json:"ip"` + Timestamp time.Time `json:"timestamp"` + Blocked bool `json:"blocked"` + Reason string `json:"reason"` +} + +func (l *PeerLogEntry) UnmarshalJSON(data []byte) error { + var raw rawPeerLogEntry + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + t := time.Unix(0, int64(raw.Timestamp)*int64(time.Millisecond)) + *l = PeerLogEntry{ + ID: raw.ID, + IP: raw.IP, + Timestamp: t, + Blocked: raw.Blocked, + Reason: raw.Reason, + } + return nil +} + +type rawPeerLogEntry struct { + ID int `json:"id"` + IP string `json:"ip"` + Timestamp int `json:"timestamp"` + Blocked bool `json:"blocked"` + Reason string `json:"reason"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/preferences.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/preferences.go new file mode 100644 index 0000000..3045e35 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/preferences.go @@ -0,0 +1,213 @@ +package qbittorrent_model + +type Preferences struct { + // Currently selected language (e.g. en_GB for English) + Locale string `json:"locale"` + // True if a subfolder should be created when adding a torrent + CreateSubfolderEnabled bool `json:"create_subfolder_enabled"` + // True if torrents should be added in a Paused state + StartPausedEnabled bool `json:"start_paused_enabled"` + // No documentation provided + AutoDeleteMode int `json:"auto_delete_mode"` + // True if disk space should be pre-allocated for all files + PreallocateAll bool `json:"preallocate_all"` + // True if ".!qB" should be appended to incomplete files + IncompleteFilesExt bool `json:"incomplete_files_ext"` + // True if Automatic Torrent Management is enabled by default + AutoTmmEnabled bool `json:"auto_tmm_enabled"` + // True if torrent should be relocated when its Category changes + TorrentChangedTmmEnabled bool `json:"torrent_changed_tmm_enabled"` + // True if torrent should be relocated when the default save path changes + SavePathChangedTmmEnabled bool `json:"save_path_changed_tmm_enabled"` + // True if torrent should be relocated when its Category's save path changes + CategoryChangedTmmEnabled bool `json:"category_changed_tmm_enabled"` + // Default save path for torrents, separated by slashes + SavePath string `json:"save_path"` + // True if folder for incomplete torrents is enabled + TempPathEnabled bool `json:"temp_path_enabled"` + // Path for incomplete torrents, separated by slashes + TempPath string `json:"temp_path"` + // Property: directory to watch for torrent files, value: where torrents loaded from this directory should be downloaded to (see list of possible values below). Slashes are used as path separators; multiple key/value pairs can be specified + ScanDirs map[string]interface{} `json:"scan_dirs"` + // Path to directory to copy .torrent files to. Slashes are used as path separators + ExportDir string `json:"export_dir"` + // Path to directory to copy .torrent files of completed downloads to. Slashes are used as path separators + ExportDirFin string `json:"export_dir_fin"` + // True if e-mail notification should be enabled + MailNotificationEnabled bool `json:"mail_notification_enabled"` + // e-mail where notifications should originate from + MailNotificationSender string `json:"mail_notification_sender"` + // e-mail to send notifications to + MailNotificationEmail string `json:"mail_notification_email"` + // smtp server for e-mail notifications + MailNotificationSmtp string `json:"mail_notification_smtp"` + // True if smtp server requires SSL connection + MailNotificationSslEnabled bool `json:"mail_notification_ssl_enabled"` + // True if smtp server requires authentication + MailNotificationAuthEnabled bool `json:"mail_notification_auth_enabled"` + // Username for smtp authentication + MailNotificationUsername string `json:"mail_notification_username"` + // Password for smtp authentication + MailNotificationPassword string `json:"mail_notification_password"` + // True if external program should be run after torrent has finished downloading + AutorunEnabled bool `json:"autorun_enabled"` + // Program path/name/arguments to run if autorun_enabled is enabled; path is separated by slashes; you can use %f and %n arguments, which will be expanded by qBittorent as path_to_torrent_file and torrent_name (from the GUI; not the .torrent file name) respectively + AutorunProgram string `json:"autorun_program"` + // True if torrent queuing is enabled + QueueingEnabled bool `json:"queueing_enabled"` + // Maximum number of active simultaneous downloads + MaxActiveDownloads int `json:"max_active_downloads"` + // Maximum number of active simultaneous downloads and uploads + MaxActiveTorrents int `json:"max_active_torrents"` + // Maximum number of active simultaneous uploads + MaxActiveUploads int `json:"max_active_uploads"` + // If true torrents w/o any activity (stalled ones) will not be counted towards max_active_* limits; see dont_count_slow_torrents for more information + DontCountSlowTorrents bool `json:"dont_count_slow_torrents"` + // Download rate in KiB/s for a torrent to be considered "slow" + SlowTorrentDlRateThreshold int `json:"slow_torrent_dl_rate_threshold"` + // Upload rate in KiB/s for a torrent to be considered "slow" + SlowTorrentUlRateThreshold int `json:"slow_torrent_ul_rate_threshold"` + // Seconds a torrent should be inactive before considered "slow" + SlowTorrentInactiveTimer int `json:"slow_torrent_inactive_timer"` + // True if share ratio limit is enabled + MaxRatioEnabled bool `json:"max_ratio_enabled"` + // Get the global share ratio limit + MaxRatio float64 `json:"max_ratio"` + // Action performed when a torrent reaches the maximum share ratio. See list of possible values here below. + MaxRatioAct MaxRatioAction `json:"max_ratio_act"` + // Port for incoming connections + ListenPort int `json:"listen_port"` + // True if UPnP/NAT-PMP is enabled + Upnp bool `json:"upnp"` + // True if the port is randomly selected + RandomPort bool `json:"random_port"` + // Global download speed limit in KiB/s; -1 means no limit is applied + DlLimit int `json:"dl_limit"` + // Global upload speed limit in KiB/s; -1 means no limit is applied + UpLimit int `json:"up_limit"` + // Maximum global number of simultaneous connections + MaxConnec int `json:"max_connec"` + // Maximum number of simultaneous connections per torrent + MaxConnecPerTorrent int `json:"max_connec_per_torrent"` + // Maximum number of upload slots + MaxUploads int `json:"max_uploads"` + // Maximum number of upload slots per torrent + MaxUploadsPerTorrent int `json:"max_uploads_per_torrent"` + // True if uTP protocol should be enabled; this option is only available in qBittorent built against libtorrent version 0.16.X and higher + EnableUtp bool `json:"enable_utp"` + // True if [du]l_limit should be applied to uTP connections; this option is only available in qBittorent built against libtorrent version 0.16.X and higher + LimitUtpRate bool `json:"limit_utp_rate"` + // True if [du]l_limit should be applied to estimated TCP overhead (service data: e.g. packet headers) + LimitTcpOverhead bool `json:"limit_tcp_overhead"` + // True if [du]l_limit should be applied to peers on the LAN + LimitLanPeers bool `json:"limit_lan_peers"` + // Alternative global download speed limit in KiB/s + AltDlLimit int `json:"alt_dl_limit"` + // Alternative global upload speed limit in KiB/s + AltUpLimit int `json:"alt_up_limit"` + // True if alternative limits should be applied according to schedule + SchedulerEnabled bool `json:"scheduler_enabled"` + // Scheduler starting hour + ScheduleFromHour int `json:"schedule_from_hour"` + // Scheduler starting minute + ScheduleFromMin int `json:"schedule_from_min"` + // Scheduler ending hour + ScheduleToHour int `json:"schedule_to_hour"` + // Scheduler ending minute + ScheduleToMin int `json:"schedule_to_min"` + // Scheduler days. See possible values here below + SchedulerDays int `json:"scheduler_days"` + // True if DHT is enabled + Dht bool `json:"dht"` + // True if DHT port should match TCP port + DhtSameAsBT bool `json:"dhtSameAsBT"` + // DHT port if dhtSameAsBT is false + DhtPort int `json:"dht_port"` + // True if PeX is enabled + Pex bool `json:"pex"` + // True if LSD is enabled + Lsd bool `json:"lsd"` + // See list of possible values here below + Encryption int `json:"encryption"` + // If true anonymous mode will be enabled; read more here; this option is only available in qBittorent built against libtorrent version 0.16.X and higher + AnonymousMode bool `json:"anonymous_mode"` + // See list of possible values here below + ProxyType int `json:"proxy_type"` + // Proxy IP address or domain name + ProxyIp string `json:"proxy_ip"` + // Proxy port + ProxyPort int `json:"proxy_port"` + // True if peer and web seed connections should be proxified; this option will have any effect only in qBittorent built against libtorrent version 0.16.X and higher + ProxyPeerConnections bool `json:"proxy_peer_connections"` + // True if the connections not supported by the proxy are disabled + ForceProxy bool `json:"force_proxy"` + // True proxy requires authentication; doesn't apply to SOCKS4 proxies + ProxyAuthEnabled bool `json:"proxy_auth_enabled"` + // Username for proxy authentication + ProxyUsername string `json:"proxy_username"` + // Password for proxy authentication + ProxyPassword string `json:"proxy_password"` + // True if external IP filter should be enabled + IpFilterEnabled bool `json:"ip_filter_enabled"` + // Path to IP filter file (.dat, .p2p, .p2b files are supported); path is separated by slashes + IpFilterPath string `json:"ip_filter_path"` + // True if IP filters are applied to trackers + IpFilterTrackers bool `json:"ip_filter_trackers"` + // Comma-separated list of domains to accept when performing Host header validation + WebUiDomainList string `json:"web_ui_domain_list"` + // IP address to use for the WebUI + WebUiAddress string `json:"web_ui_address"` + // WebUI port + WebUiPort int `json:"web_ui_port"` + // True if UPnP is used for the WebUI port + WebUiUpnp bool `json:"web_ui_upnp"` + // WebUI username + WebUiUsername string `json:"web_ui_username"` + // For API ≥ v2.3.0: Plaintext WebUI password, not readable, write-only. For API < v2.3.0: MD5 hash of WebUI password, hash is generated from the following string: username:Web UI Access:plain_text_web_ui_password + WebUiPassword string `json:"web_ui_password"` + // True if WebUI CSRF protection is enabled + WebUiCsrfProtectionEnabled bool `json:"web_ui_csrf_protection_enabled"` + // True if WebUI clickjacking protection is enabled + WebUiClickjackingProtectionEnabled bool `json:"web_ui_clickjacking_protection_enabled"` + // True if authentication challenge for loopback address (127.0.0.1) should be disabled + BypassLocalAuth bool `json:"bypass_local_auth"` + // True if webui authentication should be bypassed for clients whose ip resides within (at least) one of the subnets on the whitelist + BypassAuthSubnetWhitelistEnabled bool `json:"bypass_auth_subnet_whitelist_enabled"` + // (White)list of ipv4/ipv6 subnets for which webui authentication should be bypassed; list entries are separated by commas + BypassAuthSubnetWhitelist string `json:"bypass_auth_subnet_whitelist"` + // True if an alternative WebUI should be used + AlternativeWebuiEnabled bool `json:"alternative_webui_enabled"` + // File path to the alternative WebUI + AlternativeWebuiPath string `json:"alternative_webui_path"` + // True if WebUI HTTPS access is enabled + UseHttps bool `json:"use_https"` + // SSL keyfile contents (this is a not a path) + SslKey string `json:"ssl_key"` + // SSL certificate contents (this is a not a path) + SslCert string `json:"ssl_cert"` + // True if server DNS should be updated dynamically + DyndnsEnabled bool `json:"dyndns_enabled"` + // See list of possible values here below + DyndnsService int `json:"dyndns_service"` + // Username for DDNS service + DyndnsUsername string `json:"dyndns_username"` + // Password for DDNS service + DyndnsPassword string `json:"dyndns_password"` + // Your DDNS domain name + DyndnsDomain string `json:"dyndns_domain"` + // RSS refresh interval + RssRefreshInterval int `json:"rss_refresh_interval"` + // Max stored articles per RSS feed + RssMaxArticlesPerFeed int `json:"rss_max_articles_per_feed"` + // Enable processing of RSS feeds + RssProcessingEnabled bool `json:"rss_processing_enabled"` + // Enable auto-downloading of torrents from the RSS feeds + RssAutoDownloadingEnabled bool `json:"rss_auto_downloading_enabled"` +} + +type MaxRatioAction int + +const ( + ActionPause MaxRatioAction = 0 + ActionRemove = 1 +) diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/rule_definition.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/rule_definition.go new file mode 100644 index 0000000..67a002d --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/rule_definition.go @@ -0,0 +1,30 @@ +package qbittorrent_model + +type RuleDefinition struct { + // Whether the rule is enabled + Enabled bool `json:"enabled"` + // The substring that the torrent name must contain + MustContain string `json:"mustContain"` + // The substring that the torrent name must not contain + MustNotContain string `json:"mustNotContain"` + // Enable regex mode in "mustContain" and "mustNotContain" + UseRegex bool `json:"useRegex"` + // Episode filter definition + EpisodeFilter string `json:"episodeFilter"` + // Enable smart episode filter + SmartFilter bool `json:"smartFilter"` + // The list of episode IDs already matched by smart filter + PreviouslyMatchedEpisodes []string `json:"previouslyMatchedEpisodes"` + // The feed URLs the rule applied to + AffectedFeeds []string `json:"affectedFeeds"` + // Ignore sunsequent rule matches + IgnoreDays int `json:"ignoreDays"` + // The rule last match time + LastMatch string `json:"lastMatch"` + // Add matched torrent in paused mode + AddPaused bool `json:"addPaused"` + // Assign category to the torrent + AssignedCategory string `json:"assignedCategory"` + // Save torrent to the given directory + SavePath string `json:"savePath"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_plugin.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_plugin.go new file mode 100644 index 0000000..54d0207 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_plugin.go @@ -0,0 +1,10 @@ +package qbittorrent_model + +type SearchPlugin struct { + Enabled bool `json:"enabled"` + FullName string `json:"fullName"` + Name string `json:"name"` + SupportedCategories []string `json:"supportedCategories"` + URL string `json:"url"` + Version string `json:"version"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_result.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_result.go new file mode 100644 index 0000000..004fc6b --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_result.go @@ -0,0 +1,18 @@ +package qbittorrent_model + +type SearchResult struct { + // URL of the torrent's description page + DescriptionLink string `json:"descrLink"` + // Name of the file + FileName string `json:"fileName"` + // Size of the file in Bytes + FileSize int `json:"fileSize"` + // Torrent download link (usually either .torrent file or magnet link) + FileUrl string `json:"fileUrl"` + // Number of leechers + NumLeechers int `json:"nbLeechers"` + // int of seeders + NumSeeders int `json:"nbSeeders"` + // URL of the torrent site + SiteUrl string `json:"siteUrl"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_results_paging.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_results_paging.go new file mode 100644 index 0000000..bc49a0d --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_results_paging.go @@ -0,0 +1,7 @@ +package qbittorrent_model + +type SearchResultsPaging struct { + Results []SearchResult `json:"results"` + Status string `json:"status"` + Total int `json:"total"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_status.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_status.go new file mode 100644 index 0000000..de412d5 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/search_status.go @@ -0,0 +1,7 @@ +package qbittorrent_model + +type SearchStatus struct { + ID int `json:"id"` + Status string `json:"status"` + Total int `json:"total"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/server_state.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/server_state.go new file mode 100644 index 0000000..910b457 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/server_state.go @@ -0,0 +1,80 @@ +package qbittorrent_model + +import ( + "encoding/json" + "strconv" +) + +type ServerState struct { + TransferInfo + AlltimeDl int `json:"alltime_dl"` + AlltimeUl int `json:"alltime_ul"` + AverageTimeQueue int `json:"average_time_queue"` + FreeSpaceOnDisk int `json:"free_space_on_disk"` + GlobalRatio float64 `json:"global_ratio"` + QueuedIoJobs int `json:"queued_io_jobs"` + ReadCacheHits float64 `json:"read_cache_hits"` + ReadCacheOverload float64 `json:"read_cache_overload"` + TotalBuffersSize int `json:"total_buffers_size"` + TotalPeerConnections int `json:"total_peer_connections"` + TotalQueuedSize int `json:"total_queued_size"` + TotalWastedSession int `json:"total_wasted_session"` + WriteCacheOverload float64 `json:"write_cache_overload"` +} + +func (s *ServerState) UnmarshalJSON(data []byte) error { + var raw rawServerState + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + globalRatio, err := strconv.ParseFloat(raw.GlobalRatio, 64) + if err != nil { + return err + } + readCacheHits, err := strconv.ParseFloat(raw.ReadCacheHits, 64) + if err != nil { + return err + } + readCacheOverload, err := strconv.ParseFloat(raw.ReadCacheOverload, 64) + if err != nil { + return err + } + writeCacheOverload, err := strconv.ParseFloat(raw.WriteCacheOverload, 64) + if err != nil { + return err + } + *s = ServerState{ + TransferInfo: raw.TransferInfo, + AlltimeDl: raw.AlltimeDl, + AlltimeUl: raw.AlltimeUl, + AverageTimeQueue: raw.AverageTimeQueue, + FreeSpaceOnDisk: raw.FreeSpaceOnDisk, + GlobalRatio: globalRatio, + QueuedIoJobs: raw.QueuedIoJobs, + ReadCacheHits: readCacheHits, + ReadCacheOverload: readCacheOverload, + TotalBuffersSize: raw.TotalBuffersSize, + TotalPeerConnections: raw.TotalPeerConnections, + TotalQueuedSize: raw.TotalQueuedSize, + TotalWastedSession: raw.TotalWastedSession, + WriteCacheOverload: writeCacheOverload, + } + return nil +} + +type rawServerState struct { + TransferInfo + AlltimeDl int `json:"alltime_dl"` + AlltimeUl int `json:"alltime_ul"` + AverageTimeQueue int `json:"average_time_queue"` + FreeSpaceOnDisk int `json:"free_space_on_disk"` + GlobalRatio string `json:"global_ratio"` + QueuedIoJobs int `json:"queued_io_jobs"` + ReadCacheHits string `json:"read_cache_hits"` + ReadCacheOverload string `json:"read_cache_overload"` + TotalBuffersSize int `json:"total_buffers_size"` + TotalPeerConnections int `json:"total_peer_connections"` + TotalQueuedSize int `json:"total_queued_size"` + TotalWastedSession int `json:"total_wasted_session"` + WriteCacheOverload string `json:"write_cache_overload"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/sync_main_data.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/sync_main_data.go new file mode 100644 index 0000000..c25c199 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/sync_main_data.go @@ -0,0 +1,12 @@ +package qbittorrent_model + +type SyncMainData struct { + RID int `json:"rid"` + FullUpdate bool `json:"full_update"` + Torrents map[string]*Torrent `json:"torrents"` + TorrentsRemoved []string `json:"torrents_removed"` + Categories map[string]Category `json:"categories"` + CategoriesRemoved map[string]Category `json:"categories_removed"` + Queueing bool `json:"queueing"` + ServerState ServerState `json:"server_state"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/sync_peers_data.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/sync_peers_data.go new file mode 100644 index 0000000..4a81840 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/sync_peers_data.go @@ -0,0 +1,8 @@ +package qbittorrent_model + +type SyncPeersData struct { + FullUpdate bool `json:"full_update"` + Peers map[string]Peer `json:"peers"` + RID int `json:"rid"` + ShowFlags bool `json:"show_flags"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent.go new file mode 100644 index 0000000..2f9363d --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent.go @@ -0,0 +1,119 @@ +package qbittorrent_model + +type Torrent struct { + // Torrent hash + Hash string `json:"hash"` + // Torrent name + Name string `json:"name"` + // Total size (bytes) of files selected for download + Size int `json:"size"` + // Torrent progress (percentage/100) + Progress float64 `json:"progress"` + // Torrent download speed (bytes/s) + Dlspeed int `json:"dlspeed"` + // Torrent upload speed (bytes/s) + Upspeed int `json:"upspeed"` + // Torrent priority. Returns -1 if queuing is disabled or torrent is in seed mode + Priority int `json:"priority"` + // Number of seeds connected to + NumSeeds int `json:"num_seeds"` + // Number of seeds in the swarm + NumComplete int `json:"num_complete"` + // Number of leechers connected to + NumLeechs int `json:"num_leechs"` + // Number of leechers in the swarm + NumIncomplete int `json:"num_incomplete"` + // Torrent share ratio. Max ratio value: 9999. + Ratio float64 `json:"ratio"` + // Torrent ETA (seconds) + Eta int `json:"eta"` + // Torrent state. See table here below for the possible values + State TorrentState `json:"state"` + // True if sequential download is enabled + SeqDl bool `json:"seq_dl"` + // True if first last piece are prioritized + FLPiecePrio bool `json:"f_l_piece_prio"` + // Category of the torrent + Category string `json:"category"` + // True if super seeding is enabled + SuperSeeding bool `json:"super_seeding"` + // True if force start is enabled for this torrent + ForceStart bool `json:"force_start"` + + // New added fields + AddedOn int `json:"added_on"` + AmountLeft int `json:"amount_left"` + AutoTmm bool `json:"auto_tmm"` + Availability float64 `json:"availability"` + Completed int64 `json:"completed"` + CompletionOn int `json:"completion_on"` + ContentPath string `json:"content_path"` + DlLimit int `json:"dl_limit"` + DownloadPath string `json:"download_path"` + Downloaded int64 `json:"downloaded"` + DownloadedSession int `json:"downloaded_session"` + InfohashV1 string `json:"infohash_v1"` + InfohashV2 string `json:"infohash_v2"` + LastActivity int `json:"last_activity"` + MagnetUri string `json:"magnet_uri"` + MaxRatio int `json:"max_ratio"` + MaxSeedingTime int `json:"max_seeding_time"` + RatioLimit int `json:"ratio_limit"` + SavePath string `json:"save_path"` + SeedingTime int `json:"seeding_time"` + SeedingTimeLimit int `json:"seeding_time_limit"` + SeenComplete int `json:"seen_complete"` + Tags string `json:"tags"` + TimeActive int `json:"time_active"` + TotalSize int64 `json:"total_size"` + Tracker string `json:"tracker"` + TrackersCount int `json:"trackers_count"` + UpLimit int `json:"up_limit"` + Uploaded int64 `json:"uploaded"` + UploadedSession int64 `json:"uploaded_session"` +} + +type TorrentState string + +const ( + // Some error occurred, applies to paused torrents + StateError TorrentState = "error" + // Torrent data files is missing + StateMissingFiles TorrentState = "missingFiles" + // Torrent is being seeded and data is being transferred + StateUploading TorrentState = "uploading" + // Torrent is paused and has finished downloading + StatePausedUP TorrentState = "pausedUP" + StateStoppedUP TorrentState = "stoppedUP" + // Queuing is enabled and torrent is queued for upload + StateQueuedUP TorrentState = "queuedUP" + // Torrent is being seeded, but no connection were made + StateStalledUP TorrentState = "stalledUP" + // Torrent has finished downloading and is being checked + StateCheckingUP TorrentState = "checkingUP" + // Torrent is forced to uploading and ignore queue limit + StateForcedUP TorrentState = "forcedUP" + // Torrent is allocating disk space for download + StateAllocating TorrentState = "allocating" + // Torrent is being downloaded and data is being transferred + StateDownloading TorrentState = "downloading" + // Torrent has just started downloading and is fetching metadata + StateMetaDL TorrentState = "metaDL" + // Torrent is paused and has NOT finished downloading + StatePausedDL TorrentState = "pausedDL" + StateStoppedDL TorrentState = "stoppedDL" + // Queuing is enabled and torrent is queued for download + StateQueuedDL TorrentState = "queuedDL" + // Torrent is being downloaded, but no connection were made + StateStalledDL TorrentState = "stalledDL" + // Same as checkingUP, but torrent has NOT finished downloading + StateCheckingDL TorrentState = "checkingDL" + // Torrent is forced to downloading to ignore queue limit + StateForceDL TorrentState = "forceDL" + // Checking resume data on qBt startup + StateCheckingResumeData TorrentState = "checkingResumeData" + // Torrent is moving to another location + StateMoving TorrentState = "moving" + // Unknown status + StateUnknown TorrentState = "unknown" +) diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_content.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_content.go new file mode 100644 index 0000000..8df04fd --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_content.go @@ -0,0 +1,27 @@ +package qbittorrent_model + +type TorrentContent struct { + // File name (including relative path) + Name string `json:" name"` + // File size (bytes) + Size int `json:" size"` + // File progress (percentage/100) + Progress float64 `json:" progress"` + // File priority. See possible values here below + Priority TorrentPriority `json:" priority"` + // True if file is seeding/complete + IsSeed bool `json:" is_seed"` + // The first number is the starting piece index and the second number is the ending piece index (inclusive) + PieceRange []int `json:" piece_range"` + // Percentage of file pieces currently available + Availability float64 `json:" availability"` +} + +type TorrentPriority int + +const ( + PriorityDoNotDownload TorrentPriority = 0 + PriorityNormal = 1 + PriorityHigh = 6 + PriorityMaximum = 7 +) diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_piece_state.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_piece_state.go new file mode 100644 index 0000000..7cfbec5 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_piece_state.go @@ -0,0 +1,9 @@ +package qbittorrent_model + +type TorrentPieceState int + +const ( + PieceStateNotDownloaded TorrentPieceState = iota + PieceStateDownloading + PieceStateDownloaded +) diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_properties.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_properties.go new file mode 100644 index 0000000..d794308 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_properties.go @@ -0,0 +1,194 @@ +package qbittorrent_model + +import ( + "encoding/json" + "time" +) + +type TorrentProperties struct { + // Torrent save path + SavePath string `json:"save_path"` + // Torrent creation date (Unix timestamp) + CreationDate time.Time `json:"creation_date"` + // Torrent piece size (bytes) + PieceSize int `json:"piece_size"` + // Torrent comment + Comment string `json:"comment"` + // Total data wasted for torrent (bytes) + TotalWasted int `json:"total_wasted"` + // Total data uploaded for torrent (bytes) + TotalUploaded int `json:"total_uploaded"` + // Total data uploaded this session (bytes) + TotalUploadedSession int `json:"total_uploaded_session"` + // Total data downloaded for torrent (bytes) + TotalDownloaded int `json:"total_downloaded"` + // Total data downloaded this session (bytes) + TotalDownloadedSession int `json:"total_downloaded_session"` + // Torrent upload limit (bytes/s) + UpLimit int `json:"up_limit"` + // Torrent download limit (bytes/s) + DlLimit int `json:"dl_limit"` + // Torrent elapsed time (seconds) + TimeElapsed int `json:"time_elapsed"` + // Torrent elapsed time while complete (seconds) + SeedingTime time.Duration `json:"seeding_time"` + // Torrent connection count + NbConnections int `json:"nb_connections"` + // Torrent connection count limit + NbConnectionsLimit int `json:"nb_connections_limit"` + // Torrent share ratio + ShareRatio float64 `json:"share_ratio"` + // When this torrent was added (unix timestamp) + AdditionDate time.Time `json:"addition_date"` + // Torrent completion date (unix timestamp) + CompletionDate time.Time `json:"completion_date"` + // Torrent creator + CreatedBy string `json:"created_by"` + // Torrent average download speed (bytes/second) + DlSpeedAvg int `json:"dl_speed_avg"` + // Torrent download speed (bytes/second) + DlSpeed int `json:"dl_speed"` + // Torrent ETA (seconds) + Eta time.Duration `json:"eta"` + // Last seen complete date (unix timestamp) + LastSeen time.Time `json:"last_seen"` + // Number of peers connected to + Peers int `json:"peers"` + // Number of peers in the swarm + PeersTotal int `json:"peers_total"` + // Number of pieces owned + PiecesHave int `json:"pieces_have"` + // Number of pieces of the torrent + PiecesNum int `json:"pieces_num"` + // Number of seconds until the next announce + Reannounce time.Duration `json:"reannounce"` + // Number of seeds connected to + Seeds int `json:"seeds"` + // Number of seeds in the swarm + SeedsTotal int `json:"seeds_total"` + // Torrent total size (bytes) + TotalSize int `json:"total_size"` + // Torrent average upload speed (bytes/second) + UpSpeedAvg int `json:"up_speed_avg"` + // Torrent upload speed (bytes/second) + UpSpeed int `json:"up_speed"` +} + +func (p *TorrentProperties) UnmarshalJSON(data []byte) error { + var raw rawTorrentProperties + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + creationDate := time.Unix(int64(raw.CreationDate), 0) + seedingTime := time.Duration(raw.SeedingTime) * time.Second + additionDate := time.Unix(int64(raw.AdditionDate), 0) + completionDate := time.Unix(int64(raw.CompletionDate), 0) + eta := time.Duration(raw.Eta) * time.Second + lastSeen := time.Unix(int64(raw.LastSeen), 0) + reannounce := time.Duration(raw.Reannounce) * time.Second + *p = TorrentProperties{ + SavePath: raw.SavePath, + CreationDate: creationDate, + PieceSize: raw.PieceSize, + Comment: raw.Comment, + TotalWasted: raw.TotalWasted, + TotalUploaded: raw.TotalUploaded, + TotalUploadedSession: raw.TotalUploadedSession, + TotalDownloaded: raw.TotalDownloaded, + TotalDownloadedSession: raw.TotalDownloadedSession, + UpLimit: raw.UpLimit, + DlLimit: raw.DlLimit, + TimeElapsed: raw.TimeElapsed, + SeedingTime: seedingTime, + NbConnections: raw.NbConnections, + NbConnectionsLimit: raw.NbConnectionsLimit, + ShareRatio: raw.ShareRatio, + AdditionDate: additionDate, + CompletionDate: completionDate, + CreatedBy: raw.CreatedBy, + DlSpeedAvg: raw.DlSpeedAvg, + DlSpeed: raw.DlSpeed, + Eta: eta, + LastSeen: lastSeen, + Peers: raw.Peers, + PeersTotal: raw.PeersTotal, + PiecesHave: raw.PiecesHave, + PiecesNum: raw.PiecesNum, + Reannounce: reannounce, + Seeds: raw.Seeds, + SeedsTotal: raw.SeedsTotal, + TotalSize: raw.TotalSize, + UpSpeedAvg: raw.UpSpeedAvg, + UpSpeed: raw.UpSpeed, + } + return nil +} + +type rawTorrentProperties struct { + // Torrent save path + SavePath string `json:"save_path"` + // Torrent creation date (Unix timestamp) + CreationDate int `json:"creation_date"` + // Torrent piece size (bytes) + PieceSize int `json:"piece_size"` + // Torrent comment + Comment string `json:"comment"` + // Total data wasted for torrent (bytes) + TotalWasted int `json:"total_wasted"` + // Total data uploaded for torrent (bytes) + TotalUploaded int `json:"total_uploaded"` + // Total data uploaded this session (bytes) + TotalUploadedSession int `json:"total_uploaded_session"` + // Total data downloaded for torrent (bytes) + TotalDownloaded int `json:"total_downloaded"` + // Total data downloaded this session (bytes) + TotalDownloadedSession int `json:"total_downloaded_session"` + // Torrent upload limit (bytes/s) + UpLimit int `json:"up_limit"` + // Torrent download limit (bytes/s) + DlLimit int `json:"dl_limit"` + // Torrent elapsed time (seconds) + TimeElapsed int `json:"time_elapsed"` + // Torrent elapsed time while complete (seconds) + SeedingTime int `json:"seeding_time"` + // Torrent connection count + NbConnections int `json:"nb_connections"` + // Torrent connection count limit + NbConnectionsLimit int `json:"nb_connections_limit"` + // Torrent share ratio + ShareRatio float64 `json:"share_ratio"` + // When this torrent was added (unix timestamp) + AdditionDate int `json:"addition_date"` + // Torrent completion date (unix timestamp) + CompletionDate int `json:"completion_date"` + // Torrent creator + CreatedBy string `json:"created_by"` + // Torrent average download speed (bytes/second) + DlSpeedAvg int `json:"dl_speed_avg"` + // Torrent download speed (bytes/second) + DlSpeed int `json:"dl_speed"` + // Torrent ETA (seconds) + Eta int `json:"eta"` + // Last seen complete date (unix timestamp) + LastSeen int `json:"last_seen"` + // Number of peers connected to + Peers int `json:"peers"` + // Number of peers in the swarm + PeersTotal int `json:"peers_total"` + // Number of pieces owned + PiecesHave int `json:"pieces_have"` + // Number of pieces of the torrent + PiecesNum int `json:"pieces_num"` + // Number of seconds until the next announce + Reannounce int `json:"reannounce"` + // Number of seeds connected to + Seeds int `json:"seeds"` + // Number of seeds in the swarm + SeedsTotal int `json:"seeds_total"` + // Torrent total size (bytes) + TotalSize int `json:"total_size"` + // Torrent average upload speed (bytes/second) + UpSpeedAvg int `json:"up_speed_avg"` + // Torrent upload speed (bytes/second) + UpSpeed int `json:"up_speed"` +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_tracker.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_tracker.go new file mode 100644 index 0000000..e90966f --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/torrent_tracker.go @@ -0,0 +1,22 @@ +package qbittorrent_model + +type TorrentTracker struct { + URL string `json:"url"` + Status TrackerStatus `json:"status"` + Tier int `json:"tier"` + NumPeers int `json:"num_peers"` + NumSeeds int `json:"num_seeds"` + NumLeeches int `json:"num_leeches"` + NumDownloaded int `json:"num_downloaded"` + Message string `json:"msg"` +} + +type TrackerStatus int + +const ( + TrackerStatusDisabled TrackerStatus = iota + TrackerStatusNotContacted + TrackerStatusWorking + TrackerStatusUpdating + TrackerStatusNotWorking +) diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/transfer_info.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/transfer_info.go new file mode 100644 index 0000000..b923dc7 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/model/transfer_info.go @@ -0,0 +1,23 @@ +package qbittorrent_model + +type TransferInfo struct { + ConnectionStatus ConnectionStatus `json:"connection_status"` + DhtNodes int `json:"dht_nodes"` + DlInfoData int `json:"dl_info_data"` + DlInfoSpeed int `json:"dl_info_speed"` + DlRateLimit int `json:"dl_rate_limit"` + UpInfoData int `json:"up_info_data"` + UpInfoSpeed int `json:"up_info_speed"` + UpRateLimit int `json:"up_rate_limit"` + UseAltSpeedLimits bool `json:"use_alt_speed_limits"` + Queueing bool `json:"queueing"` + RefreshInterval int `json:"refresh_interval"` +} + +type ConnectionStatus string + +const ( + StatusConnected ConnectionStatus = "connected" + StatusFirewalled = "firewalled" + StatusDisconnected = "disconnected" +) diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/rss/client.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/rss/client.go new file mode 100644 index 0000000..9854b7d --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/rss/client.go @@ -0,0 +1,97 @@ +package qbittorrent_rss + +import ( + "encoding/json" + "github.com/rs/zerolog" + "net/http" + "net/url" + qbittorrent_model "seanime/internal/torrent_clients/qbittorrent/model" + qbittorrent_util "seanime/internal/torrent_clients/qbittorrent/util" +) + +type Client struct { + BaseUrl string + Client *http.Client + Logger *zerolog.Logger +} + +func (c Client) AddFolder(folder string) error { + params := url.Values{} + params.Add("path", folder) + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/addFolder?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) AddFeed(link string, folder string) error { + params := url.Values{} + params.Add("path", folder) + if folder != "" { + params.Add("path", folder) + } + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/addFeed?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) RemoveItem(folder string) error { + params := url.Values{} + params.Add("path", folder) + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/removeItem?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) MoveItem(currentFolder, destinationFolder string) error { + params := url.Values{} + params.Add("itemPath", currentFolder) + params.Add("destPath", destinationFolder) + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/moveItem?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) AddRule(name string, def qbittorrent_model.RuleDefinition) error { + params := url.Values{} + b, err := json.Marshal(def) + if err != nil { + return err + } + params.Add("ruleName", name) + params.Add("ruleDef", string(b)) + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/setRule?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) RenameRule(old, new string) error { + params := url.Values{} + params.Add("ruleName", old) + params.Add("newRuleName", new) + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/renameRule?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) RemoveRule(name string) error { + params := url.Values{} + params.Add("ruleName", name) + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/removeRule?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) GetRules() (map[string]qbittorrent_model.RuleDefinition, error) { + var res map[string]qbittorrent_model.RuleDefinition + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/rules", nil); err != nil { + return nil, err + } + return res, nil +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/search/client.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/search/client.go new file mode 100644 index 0000000..90acec3 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/search/client.go @@ -0,0 +1,140 @@ +package qbittorrent_search + +import ( + "fmt" + "github.com/rs/zerolog" + "net/http" + "net/url" + "seanime/internal/torrent_clients/qbittorrent/model" + "seanime/internal/torrent_clients/qbittorrent/util" + "strconv" + "strings" +) + +type Client struct { + BaseUrl string + Client *http.Client + Logger *zerolog.Logger +} + +func (c Client) Start(pattern string, plugins, categories []string) (int, error) { + params := url.Values{} + params.Add("pattern", pattern) + params.Add("plugins", strings.Join(plugins, "|")) + params.Add("category", strings.Join(categories, "|")) + var res struct { + ID int `json:"id"` + } + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/start?"+params.Encode(), nil); err != nil { + return 0, err + } + return res.ID, nil +} + +func (c Client) Stop(id int) error { + params := url.Values{} + params.Add("id", strconv.Itoa(id)) + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/stop?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) GetStatus(id int) (*qbittorrent_model.SearchStatus, error) { + params := url.Values{} + params.Add("id", strconv.Itoa(id)) + var res []*qbittorrent_model.SearchStatus + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/status?"+params.Encode(), nil); err != nil { + return nil, err + } + if len(res) < 1 { + return nil, fmt.Errorf("response did not contain any statuses") + } + return res[0], nil +} + +func (c Client) GetStatuses() ([]*qbittorrent_model.SearchStatus, error) { + var res []*qbittorrent_model.SearchStatus + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/status", nil); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) GetResults(id, limit, offset int) (*qbittorrent_model.SearchResultsPaging, error) { + params := url.Values{} + params.Add("id", strconv.Itoa(id)) + params.Add("limit", strconv.Itoa(limit)) + params.Add("offset", strconv.Itoa(offset)) + var res qbittorrent_model.SearchResultsPaging + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/results?"+params.Encode(), nil); err != nil { + return nil, err + } + return &res, nil +} + +func (c Client) Delete(id int) error { + params := url.Values{} + params.Add("id", strconv.Itoa(id)) + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/delete?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) GetCategories(plugins []string) ([]string, error) { + endpoint := c.BaseUrl + "/categories" + if plugins != nil { + params := url.Values{} + params.Add("pluginName", strings.Join(plugins, "|")) + endpoint += "?" + params.Encode() + } + var res []string + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) GetPlugins() ([]qbittorrent_model.SearchPlugin, error) { + var res []qbittorrent_model.SearchPlugin + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/plugins", nil); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) InstallPlugins(sources []string) error { + params := url.Values{} + params.Add("sources", strings.Join(sources, "|")) + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/installPlugin?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) UninstallPlugins(plugins []string) error { + params := url.Values{} + params.Add("names", strings.Join(plugins, "|")) + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/uninstallPlugin?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) EnablePlugins(plugins []string, enable bool) error { + params := url.Values{} + params.Add("names", strings.Join(plugins, "|")) + params.Add("enable", fmt.Sprintf("%v", enable)) + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/enablePlugin?"+params.Encode(), nil); err != nil { + return err + } + return nil +} + +func (c Client) updatePlugins() error { + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/updatePlugins", nil); err != nil { + return err + } + return nil +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/start.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/start.go new file mode 100644 index 0000000..d89e988 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/start.go @@ -0,0 +1,90 @@ +package qbittorrent + +import ( + "errors" + "runtime" + "seanime/internal/util" + "time" +) + +func (c *Client) getExecutableName() string { + switch runtime.GOOS { + case "windows": + return "qbittorrent.exe" + default: + return "qbittorrent" + } +} + +func (c *Client) getExecutablePath() string { + + if len(c.Path) > 0 { + return c.Path + } + + switch runtime.GOOS { + case "windows": + return "C:/Program Files/qBittorrent/qbittorrent.exe" + case "linux": + return "/usr/bin/qbittorrent" // Default path for Client on most Linux distributions + case "darwin": + return "/Applications/qbittorrent.app/Contents/MacOS/qbittorrent" // Default path for Client on macOS + default: + return "C:/Program Files/qBittorrent/qbittorrent.exe" + } +} + +func (c *Client) Start() error { + + // If the path is empty, do not check if qBittorrent is running + if c.Path == "" { + return nil + } + + name := c.getExecutableName() + if util.ProgramIsRunning(name) { + return nil + } + + exe := c.getExecutablePath() + cmd := util.NewCmd(exe) + err := cmd.Start() + if err != nil { + return errors.New("failed to start qBittorrent") + } + + time.Sleep(1 * time.Second) + + return nil +} + +func (c *Client) CheckStart() bool { + if c == nil { + return false + } + + // If the path is empty, assume it's running + if c.Path == "" { + return true + } + + _, err := c.Application.GetAppVersion() + if err == nil { + return true + } + + err = c.Start() + timeout := time.After(30 * time.Second) + ticker := time.Tick(1 * time.Second) + for { + select { + case <-ticker: + _, err = c.Application.GetAppVersion() + if err == nil { + return true + } + case <-timeout: + return false + } + } +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/start_test.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/start_test.go new file mode 100644 index 0000000..8299df9 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/start_test.go @@ -0,0 +1,41 @@ +package qbittorrent + +import ( + "github.com/stretchr/testify/assert" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" +) + +func TestClient_Start(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.TorrentClient()) + + client := NewClient(&NewClientOptions{ + Logger: util.NewLogger(), + Username: test_utils.ConfigData.Provider.QbittorrentUsername, + Password: test_utils.ConfigData.Provider.QbittorrentPassword, + Port: test_utils.ConfigData.Provider.QbittorrentPort, + Host: test_utils.ConfigData.Provider.QbittorrentHost, + Path: test_utils.ConfigData.Provider.QbittorrentPath, + }) + + err := client.Start() + assert.Nil(t, err) + +} + +func TestClient_CheckStart(t *testing.T) { + + client := NewClient(&NewClientOptions{ + Logger: util.NewLogger(), + Username: test_utils.ConfigData.Provider.QbittorrentUsername, + Password: test_utils.ConfigData.Provider.QbittorrentPassword, + Port: test_utils.ConfigData.Provider.QbittorrentPort, + Host: test_utils.ConfigData.Provider.QbittorrentHost, + Path: test_utils.ConfigData.Provider.QbittorrentPath, + }) + + started := client.CheckStart() + assert.True(t, started) + +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/sync/client.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/sync/client.go new file mode 100644 index 0000000..1ee38be --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/sync/client.go @@ -0,0 +1,39 @@ +package qbittorrent_sync + +import ( + "github.com/rs/zerolog" + "net/http" + "net/url" + "seanime/internal/torrent_clients/qbittorrent/model" + "seanime/internal/torrent_clients/qbittorrent/util" + "strconv" +) + +type Client struct { + BaseUrl string + Client *http.Client + Logger *zerolog.Logger +} + +func (c Client) GetMainData(rid int) (*qbittorrent_model.SyncMainData, error) { + params := url.Values{} + params.Add("rid", strconv.Itoa(rid)) + endpoint := c.BaseUrl + "/maindata?" + params.Encode() + var res qbittorrent_model.SyncMainData + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + return &res, nil +} + +func (c Client) GetTorrentPeersData(hash string, rid int) (*qbittorrent_model.SyncPeersData, error) { + params := url.Values{} + params.Add("hash", hash) + params.Add("rid", strconv.Itoa(rid)) + endpoint := c.BaseUrl + "/torrentPeers?" + params.Encode() + var res qbittorrent_model.SyncPeersData + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + return &res, nil +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/torrent/client.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/torrent/client.go new file mode 100644 index 0000000..6888c39 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/torrent/client.go @@ -0,0 +1,451 @@ +package qbittorrent_torrent + +import ( + "fmt" + "github.com/rs/zerolog" + "net/http" + "net/url" + qbittorrent_model "seanime/internal/torrent_clients/qbittorrent/model" + qbittorrent_util "seanime/internal/torrent_clients/qbittorrent/util" + "strconv" + "strings" + + "github.com/google/go-querystring/query" +) + +type Client struct { + BaseUrl string + Client *http.Client + Logger *zerolog.Logger +} + +func (c Client) GetList(options *qbittorrent_model.GetTorrentListOptions) ([]*qbittorrent_model.Torrent, error) { + endpoint := c.BaseUrl + "/info" + if options != nil { + params, err := query.Values(options) + if err != nil { + return nil, err + } + endpoint += "?" + params.Encode() + } + var res []*qbittorrent_model.Torrent + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) GetProperties(hash string) (*qbittorrent_model.TorrentProperties, error) { + params := url.Values{} + params.Add("hash", hash) + endpoint := c.BaseUrl + "/properties?" + params.Encode() + var res qbittorrent_model.TorrentProperties + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + return &res, nil +} + +func (c Client) GetTrackers(hash string) ([]*qbittorrent_model.TorrentTracker, error) { + params := url.Values{} + params.Add("hash", hash) + endpoint := c.BaseUrl + "/trackers?" + params.Encode() + var res []*qbittorrent_model.TorrentTracker + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) GetWebSeeds(hash string) ([]string, error) { + params := url.Values{} + params.Add("hash", hash) + endpoint := c.BaseUrl + "/trackers?" + params.Encode() + var res []struct { + URL string `json:"url"` + } + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + var seeds []string + for _, seed := range res { + seeds = append(seeds, seed.URL) + } + return seeds, nil +} + +func (c Client) GetContents(hash string) ([]*qbittorrent_model.TorrentContent, error) { + params := url.Values{} + params.Add("hash", hash) + endpoint := c.BaseUrl + "/files?" + params.Encode() + var res []*qbittorrent_model.TorrentContent + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) GetPieceStates(hash string) ([]qbittorrent_model.TorrentPieceState, error) { + params := url.Values{} + params.Add("hash", hash) + endpoint := c.BaseUrl + "/pieceStates?" + params.Encode() + var res []qbittorrent_model.TorrentPieceState + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) GetPieceHashes(hash string) ([]string, error) { + params := url.Values{} + params.Add("hash", hash) + endpoint := c.BaseUrl + "/pieceHashes?" + params.Encode() + var res []string + if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) StopTorrents(hashes []string) error { + value := strings.Join(hashes, "|") + params := url.Values{} + params.Add("hashes", value) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/pause", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/stop", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded") + } + return nil +} + +func (c Client) ResumeTorrents(hashes []string) error { + value := strings.Join(hashes, "|") + params := url.Values{} + params.Add("hashes", value) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/resume", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/start", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded") + } + return nil +} + +func (c Client) DeleteTorrents(hashes []string, deleteFiles bool) error { + value := strings.Join(hashes, "|") + params := url.Values{} + params.Add("deleteFiles", fmt.Sprintf("%v", deleteFiles)) + params.Add("hashes", value) + //endpoint := c.BaseUrl + "/delete?" + params.Encode() + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/delete", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) RecheckTorrents(hashes []string) error { + value := strings.Join(hashes, "|") + params := url.Values{} + params.Add("hashes", value) + endpoint := c.BaseUrl + "/recheck?" + params.Encode() + if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil { + return err + } + return nil +} + +func (c Client) ReannounceTorrents(hashes []string) error { + value := strings.Join(hashes, "|") + params := url.Values{} + params.Add("hashes", value) + endpoint := c.BaseUrl + "/reannounce?" + params.Encode() + if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil { + return err + } + return nil +} + +func (c Client) AddURLs(urls []string, options *qbittorrent_model.AddTorrentsOptions) error { + if err := qbittorrent_util.PostMultipartLinks(c.Client, c.BaseUrl+"/add", options, urls); err != nil { + return err + } + return nil +} + +func (c Client) AddFiles(files map[string][]byte, options *qbittorrent_model.AddTorrentsOptions) error { + if err := qbittorrent_util.PostMultipartFiles(c.Client, c.BaseUrl+"/add", options, files); err != nil { + return err + } + return nil +} + +func (c Client) AddTrackers(hash string, trackerURLs []string) error { + params := url.Values{} + params.Add("hash", hash) + params.Add("urls", strings.Join(trackerURLs, "\n")) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/addTrackers", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) EditTrackers(hash, old, new string) error { + params := url.Values{} + params.Add("hash", hash) + params.Add("origUrl", old) + params.Add("newUrl", new) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/editTracker", + strings.NewReader(params.Encode()), + "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) RemoveTrackers(hash string, trackerURLs []string) error { + params := url.Values{} + params.Add("hash", hash) + params.Add("urls", strings.Join(trackerURLs, "|")) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/removeTrackers", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) IncreasePriority(hashes []string) error { + value := strings.Join(hashes, "|") + params := url.Values{} + params.Add("hashes", value) + endpoint := c.BaseUrl + "/increasePrio?" + params.Encode() + if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil { + return err + } + return nil +} + +func (c Client) DecreasePriority(hashes []string) error { + value := strings.Join(hashes, "|") + params := url.Values{} + params.Add("hashes", value) + endpoint := c.BaseUrl + "/decreasePrio?" + params.Encode() + if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil { + return err + } + return nil +} + +func (c Client) SetMaximumPriority(hashes []string) error { + value := strings.Join(hashes, "|") + params := url.Values{} + params.Add("hashes", value) + endpoint := c.BaseUrl + "/topPrio?" + params.Encode() + if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil { + return err + } + return nil +} + +func (c Client) SetMinimumPriority(hashes []string) error { + value := strings.Join(hashes, "|") + params := url.Values{} + params.Add("hashes", value) + endpoint := c.BaseUrl + "/bottomPrio?" + params.Encode() + if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil { + return err + } + return nil +} + +func (c Client) SetFilePriorities(hash string, ids []string, priority qbittorrent_model.TorrentPriority) error { + params := url.Values{} + params.Add("hash", hash) + params.Add("id", strings.Join(ids, "|")) + params.Add("priority", strconv.Itoa(int(priority))) + //endpoint := c.BaseUrl + "/filePrio?" + params.Encode() + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/filePrio", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) GetDownloadLimits(hashes []string) (map[string]int, error) { + params := url.Values{} + params.Add("hashes", strings.Join(hashes, "|")) + var res map[string]int + if err := qbittorrent_util.GetIntoWithContentType(c.Client, &res, c.BaseUrl+"/downloadLimit", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) SetDownloadLimits(hashes []string, limit int) error { + params := url.Values{} + params.Add("hashes", strings.Join(hashes, "|")) + params.Add("limit", strconv.Itoa(limit)) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setDownloadLimit", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) SetShareLimits(hashes []string, ratioLimit float64, seedingTimeLimit int) error { + params := url.Values{} + params.Add("hashes", strings.Join(hashes, "|")) + params.Add("ratioLimit", strconv.FormatFloat(ratioLimit, 'f', -1, 64)) + params.Add("seedingTimeLimit", strconv.Itoa(seedingTimeLimit)) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setShareLimits", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) GetUploadLimits(hashes []string) (map[string]int, error) { + params := url.Values{} + params.Add("hashes", strings.Join(hashes, "|")) + var res map[string]int + if err := qbittorrent_util.GetIntoWithContentType(c.Client, &res, c.BaseUrl+"/uploadLimit", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) SetUploadLimits(hashes []string, limit int) error { + params := url.Values{} + params.Add("hashes", strings.Join(hashes, "|")) + params.Add("limit", strconv.Itoa(limit)) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setUploadLimit", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) SetLocations(hashes []string, location string) error { + params := url.Values{} + params.Add("hashes", strings.Join(hashes, "|")) + params.Add("location", location) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setLocation", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) SetName(hash string, name string) error { + params := url.Values{} + params.Add("hash", hash) + params.Add("name", name) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/rename", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) SetCategories(hashes []string, category string) error { + params := url.Values{} + params.Add("hashes", strings.Join(hashes, "|")) + params.Add("category", category) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setCategory", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) GetCategories() (map[string]*qbittorrent_model.Category, error) { + var res map[string]*qbittorrent_model.Category + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/categories", nil); err != nil { + return nil, err + } + return res, nil +} + +func (c Client) AddCategory(category string, savePath string) error { + params := url.Values{} + params.Add("category", category) + params.Add("savePath", savePath) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/createCategory", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) EditCategory(category string, savePath string) error { + params := url.Values{} + params.Add("category", category) + params.Add("savePath", savePath) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/editCategory", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) RemoveCategory(categories []string) error { + params := url.Values{} + params.Add("categories", strings.Join(categories, "\n")) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/removeCategories", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) SetAutomaticManagement(hashes []string, enable bool) error { + params := url.Values{} + params.Add("hashes", strings.Join(hashes, "|")) + params.Add("enable", fmt.Sprintf("%v", enable)) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setAutoManagement", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) ToggleSequentialDownload(hashes []string) error { + value := strings.Join(hashes, "|") + params := url.Values{} + params.Add("hashes", value) + endpoint := c.BaseUrl + "/toggleSequentialDownload?" + params.Encode() + if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil { + return err + } + return nil +} + +func (c Client) ToggleFirstLastPiecePriority(hashes []string) error { + value := strings.Join(hashes, "|") + params := url.Values{} + params.Add("hashes", value) + endpoint := c.BaseUrl + "/toggleFirstLastPiecePrio?" + params.Encode() + if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil { + return err + } + return nil +} + +func (c Client) SetForceStart(hashes []string, enable bool) error { + params := url.Values{} + params.Add("hashes", strings.Join(hashes, "|")) + params.Add("value", fmt.Sprintf("%v", enable)) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setForceStart", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} + +func (c Client) SetSuperSeeding(hashes []string, enable bool) error { + params := url.Values{} + params.Add("hashes", strings.Join(hashes, "|")) + params.Add("value", fmt.Sprintf("%v", enable)) + if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setSuperSeeding", + strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil { + return err + } + return nil +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/transfer/client.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/transfer/client.go new file mode 100644 index 0000000..5f9222d --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/transfer/client.go @@ -0,0 +1,75 @@ +package qbittorrent_transfer + +import ( + "github.com/rs/zerolog" + "net/http" + "net/url" + "seanime/internal/torrent_clients/qbittorrent/model" + "seanime/internal/torrent_clients/qbittorrent/util" + "strconv" +) + +type Client struct { + BaseUrl string + Client *http.Client + Logger *zerolog.Logger +} + +func (c Client) GetTransferInfo() (*qbittorrent_model.TransferInfo, error) { + var res qbittorrent_model.TransferInfo + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/info", nil); err != nil { + return nil, err + } + return &res, nil +} + +func (c Client) AlternativeSpeedLimitsEnabled() (bool, error) { + var res int + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/speedLimitsMode", nil); err != nil { + return false, err + } + return res == 1, nil +} + +func (c Client) ToggleAlternativeSpeedLimits() error { + if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/toggleSpeedLimitsMode", nil); err != nil { + return err + } + return nil +} + +func (c Client) GetGlobalDownloadLimit() (int, error) { + var res int + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/downloadLimit", nil); err != nil { + return 0, err + } + return res, nil +} + +func (c Client) SetGlobalDownloadLimit(limit int) error { + params := url.Values{} + params.Add("limit", strconv.Itoa(limit)) + endpoint := c.BaseUrl + "/setDownloadLimit?" + params.Encode() + if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil { + return err + } + return nil +} + +func (c Client) GetGlobalUploadLimit() (int, error) { + var res int + if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/uploadLimit", nil); err != nil { + return 0, err + } + return res, nil +} + +func (c Client) SetGlobalUploadLimit(limit int) error { + params := url.Values{} + params.Add("limit", strconv.Itoa(limit)) + endpoint := c.BaseUrl + "/setUploadLimit?" + params.Encode() + if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil { + return err + } + return nil +} diff --git a/seanime-2.9.10/internal/torrent_clients/qbittorrent/util/util.go b/seanime-2.9.10/internal/torrent_clients/qbittorrent/util/util.go new file mode 100644 index 0000000..b8d19e1 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/qbittorrent/util/util.go @@ -0,0 +1,228 @@ +package qbittorrent_util + +import ( + "bytes" + "fmt" + "github.com/goccy/go-json" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "seanime/internal/torrent_clients/qbittorrent/model" + "strings" +) + +func GetInto(client *http.Client, target interface{}, url string, body interface{}) (err error) { + var buffer bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buffer).Encode(body); err != nil { + return err + } + } + r, err := http.NewRequest("GET", url, &buffer) + if err != nil { + return err + } + resp, err := client.Do(r) + if err != nil { + return err + } + defer func() { + if err2 := resp.Body.Close(); err2 != nil { + err = err2 + } + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("invalid response status %s", resp.Status) + } + buf, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if err := json.NewDecoder(bytes.NewReader(buf)).Decode(target); err != nil { + if err2 := json.NewDecoder(strings.NewReader(`"` + string(buf) + `"`)).Decode(target); err2 != nil { + return err + } + } + return nil +} + +func Post(client *http.Client, url string, body interface{}) (err error) { + var buffer bytes.Buffer + if err := json.NewEncoder(&buffer).Encode(body); err != nil { + return err + } + r, err := http.NewRequest("POST", url, &buffer) + if err != nil { + return err + } + resp, err := client.Do(r) + if err != nil { + return err + } + defer func() { + if err2 := resp.Body.Close(); err2 != nil { + err = err2 + } + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("invalid status %s", resp.Status) + } + return nil +} + +func createFormFileWithHeader(writer *multipart.Writer, name, filename string, headers map[string]string) (io.Writer, error) { + header := textproto.MIMEHeader{} + header.Add("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, name, filename)) + for key, value := range headers { + header.Add(key, value) + } + return writer.CreatePart(header) +} + +func PostMultipartLinks(client *http.Client, url string, options *qbittorrent_model.AddTorrentsOptions, links []string) (err error) { + var o map[string]interface{} + if options != nil { + b, err := json.Marshal(options) + if err != nil { + return err + } + if err := json.Unmarshal(b, &o); err != nil { + return err + } + } + buf := bytes.Buffer{} + form := multipart.NewWriter(&buf) + if err := form.WriteField("urls", strings.Join(links, "\n")); err != nil { + return err + } + for key, value := range o { + if err := form.WriteField(key, fmt.Sprintf("%v", value)); err != nil { + return err + } + } + if err := form.Close(); err != nil { + return err + } + req, err := http.NewRequest("POST", url, &buf) + if err != nil { + return err + } + req.Header.Add("content-type", "multipart/form-data; boundary="+form.Boundary()) + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { + if err2 := resp.Body.Close(); err2 != nil { + err = err2 + } + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("invalid status %s", resp.Status) + } + return nil +} + +func PostMultipartFiles(client *http.Client, url string, options *qbittorrent_model.AddTorrentsOptions, files map[string][]byte) (err error) { + var o map[string]interface{} + if options != nil { + b, err := json.Marshal(options) + if err != nil { + return err + } + if err := json.Unmarshal(b, &o); err != nil { + return err + } + } + buf := bytes.Buffer{} + form := multipart.NewWriter(&buf) + for filename, file := range files { + writer, err := createFormFileWithHeader(form, "torrents", filename, map[string]string{ + "content-type": "application/x-bittorrent", + }) + if err != nil { + return err + } + if _, err := writer.Write(file); err != nil { + return err + } + } + for key, value := range o { + if err := form.WriteField(key, fmt.Sprintf("%v", value)); err != nil { + return err + } + } + if err := form.Close(); err != nil { + return err + } + req, err := http.NewRequest("POST", url, &buf) + if err != nil { + return err + } + req.Header.Add("content-type", "multipart/form-data; boundary="+form.Boundary()) + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { + if err2 := resp.Body.Close(); err2 != nil { + err = err2 + } + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("invalid status %s", resp.Status) + } + return nil +} + +func PostWithContentType(client *http.Client, url string, body io.Reader, contentType string) (err error) { + r, err := http.NewRequest("POST", url, body) + if err != nil { + return err + } + r.Header.Add("content-type", contentType) + resp, err := client.Do(r) + if err != nil { + return err + } + defer func() { + if err2 := resp.Body.Close(); err2 != nil { + err = err2 + } + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("invalid status %s", resp.Status) + } + return nil +} + +func GetIntoWithContentType(client *http.Client, target interface{}, url string, body io.Reader, contentType string) (err error) { + r, err := http.NewRequest("GET", url, body) + if err != nil { + return err + } + r.Header.Add("content-type", contentType) + resp, err := client.Do(r) + if err != nil { + return err + } + defer func() { + if err2 := resp.Body.Close(); err2 != nil { + err = err2 + } + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("invalid response status %s", resp.Status) + } + buf, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if err := json.NewDecoder(bytes.NewReader(buf)).Decode(target); err != nil { + if err2 := json.NewDecoder(strings.NewReader(`"` + string(buf) + `"`)).Decode(target); err2 != nil { + return err + } + } + return nil +} diff --git a/seanime-2.9.10/internal/torrent_clients/torrent_client/hook_events.go b/seanime-2.9.10/internal/torrent_clients/torrent_client/hook_events.go new file mode 100644 index 0000000..8bfb015 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/torrent_client/hook_events.go @@ -0,0 +1 @@ +package torrent_client diff --git a/seanime-2.9.10/internal/torrent_clients/torrent_client/repository.go b/seanime-2.9.10/internal/torrent_clients/torrent_client/repository.go new file mode 100644 index 0000000..cfed732 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/torrent_client/repository.go @@ -0,0 +1,420 @@ +package torrent_client + +import ( + "context" + "errors" + "github.com/hekmon/transmissionrpc/v3" + "github.com/rs/zerolog" + "seanime/internal/api/metadata" + "seanime/internal/events" + "seanime/internal/torrent_clients/qbittorrent" + "seanime/internal/torrent_clients/qbittorrent/model" + "seanime/internal/torrent_clients/transmission" + "seanime/internal/torrents/torrent" + "strconv" + "time" +) + +const ( + QbittorrentClient = "qbittorrent" + TransmissionClient = "transmission" + NoneClient = "none" +) + +type ( + Repository struct { + logger *zerolog.Logger + qBittorrentClient *qbittorrent.Client + transmission *transmission.Transmission + torrentRepository *torrent.Repository + provider string + metadataProvider metadata.Provider + activeTorrentCountCtxCancel context.CancelFunc + activeTorrentCount *ActiveCount + } + + NewRepositoryOptions struct { + Logger *zerolog.Logger + QbittorrentClient *qbittorrent.Client + Transmission *transmission.Transmission + TorrentRepository *torrent.Repository + Provider string + MetadataProvider metadata.Provider + } + + ActiveCount struct { + Downloading int `json:"downloading"` + Seeding int `json:"seeding"` + Paused int `json:"paused"` + } +) + +func NewRepository(opts *NewRepositoryOptions) *Repository { + if opts.Provider == "" { + opts.Provider = QbittorrentClient + } + return &Repository{ + logger: opts.Logger, + qBittorrentClient: opts.QbittorrentClient, + transmission: opts.Transmission, + torrentRepository: opts.TorrentRepository, + provider: opts.Provider, + metadataProvider: opts.MetadataProvider, + activeTorrentCount: &ActiveCount{}, + } +} + +func (r *Repository) Shutdown() { + if r.activeTorrentCountCtxCancel != nil { + r.activeTorrentCountCtxCancel() + r.activeTorrentCountCtxCancel = nil + } +} + +func (r *Repository) InitActiveTorrentCount(enabled bool, wsEventManager events.WSEventManagerInterface) { + if r.activeTorrentCountCtxCancel != nil { + r.activeTorrentCountCtxCancel() + } + + if !enabled { + return + } + + var ctx context.Context + ctx, r.activeTorrentCountCtxCancel = context.WithCancel(context.Background()) + go func(ctx context.Context) { + ticker := time.NewTicker(time.Second * 5) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.GetActiveCount(r.activeTorrentCount) + wsEventManager.SendEvent(events.ActiveTorrentCountUpdated, r.activeTorrentCount) + } + } + }(ctx) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) GetProvider() string { + return r.provider +} + +func (r *Repository) Start() bool { + switch r.provider { + case QbittorrentClient: + return r.qBittorrentClient.CheckStart() + case TransmissionClient: + return r.transmission.CheckStart() + case NoneClient: + return true + default: + return false + } +} +func (r *Repository) TorrentExists(hash string) bool { + switch r.provider { + case QbittorrentClient: + p, err := r.qBittorrentClient.Torrent.GetProperties(hash) + return err == nil && p != nil + case TransmissionClient: + torrents, err := r.transmission.Client.TorrentGetAllForHashes(context.Background(), []string{hash}) + return err == nil && len(torrents) > 0 + default: + return false + } +} + +// GetList will return all torrents from the torrent client. +func (r *Repository) GetList() ([]*Torrent, error) { + switch r.provider { + case QbittorrentClient: + torrents, err := r.qBittorrentClient.Torrent.GetList(&qbittorrent_model.GetTorrentListOptions{Filter: "all"}) + if err != nil { + r.logger.Err(err).Msg("torrent client: Error while getting torrent list (qBittorrent)") + return nil, err + } + return r.FromQbitTorrents(torrents), nil + case TransmissionClient: + torrents, err := r.transmission.Client.TorrentGetAll(context.Background()) + if err != nil { + r.logger.Err(err).Msg("torrent client: Error while getting torrent list (Transmission)") + return nil, err + } + return r.FromTransmissionTorrents(torrents), nil + default: + return nil, errors.New("torrent client: No torrent client provider found") + } +} + +// GetActiveCount will return the count of active torrents (downloading, seeding, paused). +func (r *Repository) GetActiveCount(ret *ActiveCount) { + ret.Seeding = 0 + ret.Downloading = 0 + ret.Paused = 0 + switch r.provider { + case QbittorrentClient: + torrents, err := r.qBittorrentClient.Torrent.GetList(&qbittorrent_model.GetTorrentListOptions{Filter: "downloading"}) + if err != nil { + return + } + torrents2, err := r.qBittorrentClient.Torrent.GetList(&qbittorrent_model.GetTorrentListOptions{Filter: "seeding"}) + if err != nil { + return + } + torrents = append(torrents, torrents2...) + for _, t := range torrents { + switch fromQbitTorrentStatus(t.State) { + case TorrentStatusDownloading: + ret.Downloading++ + case TorrentStatusSeeding: + ret.Seeding++ + case TorrentStatusPaused: + ret.Paused++ + } + } + case TransmissionClient: + torrents, err := r.transmission.Client.TorrentGet(context.Background(), []string{"id", "status", "isFinished"}, nil) + if err != nil { + return + } + for _, t := range torrents { + if t.Status == nil || t.IsFinished == nil { + continue + } + switch fromTransmissionTorrentStatus(*t.Status, *t.IsFinished) { + case TorrentStatusDownloading: + ret.Downloading++ + case TorrentStatusSeeding: + ret.Seeding++ + case TorrentStatusPaused: + ret.Paused++ + } + } + return + default: + return + } +} + +// GetActiveTorrents will return all torrents that are currently downloading, paused or seeding. +func (r *Repository) GetActiveTorrents() ([]*Torrent, error) { + torrents, err := r.GetList() + if err != nil { + return nil, err + } + var active []*Torrent + for _, t := range torrents { + if t.Status == TorrentStatusDownloading || t.Status == TorrentStatusSeeding || t.Status == TorrentStatusPaused { + active = append(active, t) + } + } + return active, nil +} + +func (r *Repository) AddMagnets(magnets []string, dest string) error { + r.logger.Trace().Any("magnets", magnets).Msg("torrent client: Adding magnets") + + if len(magnets) == 0 { + r.logger.Debug().Msg("torrent client: No magnets to add") + return nil + } + + var err error + switch r.provider { + case QbittorrentClient: + err = r.qBittorrentClient.Torrent.AddURLs(magnets, &qbittorrent_model.AddTorrentsOptions{ + Savepath: dest, + Tags: r.qBittorrentClient.Tags, + }) + case TransmissionClient: + for _, magnet := range magnets { + _, err = r.transmission.Client.TorrentAdd(context.Background(), transmissionrpc.TorrentAddPayload{ + Filename: &magnet, + DownloadDir: &dest, + }) + if err != nil { + r.logger.Err(err).Msg("torrent client: Error while adding magnets (Transmission)") + break + } + } + case NoneClient: + return errors.New("torrent client: No torrent client selected") + } + + if err != nil { + r.logger.Err(err).Msg("torrent client: Error while adding magnets") + return err + } + + r.logger.Debug().Msg("torrent client: Added torrents") + + return nil +} + +func (r *Repository) RemoveTorrents(hashes []string) error { + r.logger.Trace().Msg("torrent client: Removing torrents") + + var err error + switch r.provider { + case QbittorrentClient: + err = r.qBittorrentClient.Torrent.DeleteTorrents(hashes, true) + case TransmissionClient: + torrents, err := r.transmission.Client.TorrentGetAllForHashes(context.Background(), hashes) + if err != nil { + r.logger.Err(err).Msg("torrent client: Error while fetching torrents (Transmission)") + return err + } + ids := make([]int64, len(torrents)) + for i, t := range torrents { + ids[i] = *t.ID + } + err = r.transmission.Client.TorrentRemove(context.Background(), transmissionrpc.TorrentRemovePayload{ + IDs: ids, + DeleteLocalData: true, + }) + if err != nil { + r.logger.Err(err).Msg("torrent client: Error while removing torrents (Transmission)") + return err + } + } + if err != nil { + r.logger.Err(err).Msg("torrent client: Error while removing torrents") + return err + } + + r.logger.Debug().Any("hashes", hashes).Msg("torrent client: Removed torrents") + return nil +} + +func (r *Repository) PauseTorrents(hashes []string) error { + r.logger.Trace().Msg("torrent client: Pausing torrents") + + var err error + switch r.provider { + case QbittorrentClient: + err = r.qBittorrentClient.Torrent.StopTorrents(hashes) + case TransmissionClient: + err = r.transmission.Client.TorrentStopHashes(context.Background(), hashes) + } + + if err != nil { + r.logger.Err(err).Msg("torrent client: Error while pausing torrents") + return err + } + + r.logger.Debug().Any("hashes", hashes).Msg("torrent client: Paused torrents") + + return nil +} + +func (r *Repository) ResumeTorrents(hashes []string) error { + r.logger.Trace().Msg("torrent client: Resuming torrents") + + var err error + switch r.provider { + case QbittorrentClient: + err = r.qBittorrentClient.Torrent.ResumeTorrents(hashes) + case TransmissionClient: + err = r.transmission.Client.TorrentStartHashes(context.Background(), hashes) + } + + if err != nil { + r.logger.Err(err).Msg("torrent client: Error while resuming torrents") + return err + } + + r.logger.Debug().Any("hashes", hashes).Msg("torrent client: Resumed torrents") + + return nil +} + +func (r *Repository) DeselectFiles(hash string, indices []int) error { + + var err error + switch r.provider { + case QbittorrentClient: + strIndices := make([]string, len(indices), len(indices)) + for i, v := range indices { + strIndices[i] = strconv.Itoa(v) + } + err = r.qBittorrentClient.Torrent.SetFilePriorities(hash, strIndices, 0) + case TransmissionClient: + torrents, err := r.transmission.Client.TorrentGetAllForHashes(context.Background(), []string{hash}) + if err != nil || torrents[0].ID == nil { + r.logger.Err(err).Msg("torrent client: Error while deselecting files (Transmission)") + return err + } + id := *torrents[0].ID + ind := make([]int64, len(indices), len(indices)) + for i, v := range indices { + ind[i] = int64(v) + } + err = r.transmission.Client.TorrentSet(context.Background(), transmissionrpc.TorrentSetPayload{ + FilesUnwanted: ind, + IDs: []int64{id}, + }) + } + + if err != nil { + r.logger.Err(err).Msg("torrent client: Error while deselecting files") + return err + } + + r.logger.Debug().Str("hash", hash).Any("indices", indices).Msg("torrent client: Deselected torrent files") + + return nil +} + +// GetFiles blocks until the files are retrieved, or until timeout. +func (r *Repository) GetFiles(hash string) (filenames []string, err error) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + filenames = make([]string, 0) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + done := make(chan struct{}) + + go func() { + r.logger.Debug().Str("hash", hash).Msg("torrent client: Getting torrent files") + defer close(done) + for { + select { + case <-ctx.Done(): + err = errors.New("torrent client: Unable to retrieve torrent files (timeout)") + return + case <-ticker.C: + switch r.provider { + case QbittorrentClient: + qbitFiles, err := r.qBittorrentClient.Torrent.GetContents(hash) + if err == nil && qbitFiles != nil && len(qbitFiles) > 0 { + r.logger.Debug().Str("hash", hash).Int("count", len(qbitFiles)).Msg("torrent client: Retrieved torrent files") + for _, f := range qbitFiles { + filenames = append(filenames, f.Name) + } + return + } + case TransmissionClient: + torrents, err := r.transmission.Client.TorrentGetAllForHashes(context.Background(), []string{hash}) + if err == nil && len(torrents) > 0 && torrents[0].Files != nil && len(torrents[0].Files) > 0 { + transmissionFiles := torrents[0].Files + r.logger.Debug().Str("hash", hash).Int("count", len(transmissionFiles)).Msg("torrent client: Retrieved torrent files") + for _, f := range transmissionFiles { + filenames = append(filenames, f.Name) + } + return + } + } + } + } + }() + + <-done // wait for the files to be retrieved + + return +} diff --git a/seanime-2.9.10/internal/torrent_clients/torrent_client/smart_select.go b/seanime-2.9.10/internal/torrent_clients/torrent_client/smart_select.go new file mode 100644 index 0000000..63c6e0a --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/torrent_client/smart_select.go @@ -0,0 +1,154 @@ +package torrent_client + +import ( + "errors" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/platforms/platform" + "seanime/internal/torrents/analyzer" + "time" + + hibiketorrent "seanime/internal/extension/hibike/torrent" +) + +type ( + SmartSelectParams struct { + Torrent *hibiketorrent.AnimeTorrent + EpisodeNumbers []int + Media *anilist.CompleteAnime + Destination string + ShouldAddTorrent bool + Platform platform.Platform + } +) + +// SmartSelect will automatically the provided episode files from the torrent. +// If the torrent has not been added yet, set SmartSelect.ShouldAddTorrent to true. +// The torrent will NOT be removed if the selection fails. +func (r *Repository) SmartSelect(p *SmartSelectParams) error { + if p.Media == nil || p.Platform == nil || r.torrentRepository == nil { + r.logger.Error().Msg("torrent client: media or platform is nil (smart select)") + return errors.New("media or anilist client wrapper is nil") + } + + providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(p.Torrent.Provider) + if !ok { + r.logger.Error().Str("provider", p.Torrent.Provider).Msg("torrent client: provider extension not found (smart select)") + return errors.New("provider extension not found") + } + + if p.Media.IsMovieOrSingleEpisode() { + return errors.New("smart select is not supported for movies or single-episode series") + } + + if len(p.EpisodeNumbers) == 0 { + r.logger.Error().Msg("torrent client: no episode numbers provided (smart select)") + return errors.New("no episode numbers provided") + } + + if p.ShouldAddTorrent { + r.logger.Info().Msg("torrent client: adding torrent (smart select)") + // Get magnet + magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(p.Torrent) + if err != nil { + return err + } + // Add the torrent + err = r.AddMagnets([]string{magnet}, p.Destination) + if err != nil { + return err + } + } + + filepaths, err := r.GetFiles(p.Torrent.InfoHash) + if err != nil { + r.logger.Err(err).Msg("torrent client: error getting files (smart select)") + _ = r.RemoveTorrents([]string{p.Torrent.InfoHash}) + return fmt.Errorf("error getting files, torrent still added: %w", err) + } + + // Pause the torrent + err = r.PauseTorrents([]string{p.Torrent.InfoHash}) + if err != nil { + r.logger.Err(err).Msg("torrent client: error while pausing torrent (smart select)") + _ = r.RemoveTorrents([]string{p.Torrent.InfoHash}) + return fmt.Errorf("error while selecting files: %w", err) + } + + // AnalyzeTorrentFiles the torrent files + analyzer := torrent_analyzer.NewAnalyzer(&torrent_analyzer.NewAnalyzerOptions{ + Logger: r.logger, + Filepaths: filepaths, + Media: p.Media, + Platform: p.Platform, + MetadataProvider: r.metadataProvider, + }) + + r.logger.Debug().Msg("torrent client: analyzing torrent files (smart select)") + + analysis, err := analyzer.AnalyzeTorrentFiles() + if err != nil { + r.logger.Err(err).Msg("torrent client: error while analyzing torrent files (smart select)") + _ = r.RemoveTorrents([]string{p.Torrent.InfoHash}) + return fmt.Errorf("error while analyzing torrent files: %w", err) + } + + r.logger.Debug().Msg("torrent client: finished analyzing torrent files (smart select)") + + mainFiles := analysis.GetCorrespondingMainFiles() + + // find episode number duplicates + dup := make(map[int]int) // map[episodeNumber]count + for _, f := range mainFiles { + if _, ok := dup[f.GetLocalFile().GetEpisodeNumber()]; ok { + dup[f.GetLocalFile().GetEpisodeNumber()]++ + } else { + dup[f.GetLocalFile().GetEpisodeNumber()] = 1 + } + } + dupCount := 0 + for _, count := range dup { + if count > 1 { + dupCount++ + } + } + if dupCount > 2 { + _ = r.RemoveTorrents([]string{p.Torrent.InfoHash}) + return errors.New("failed to select files, can't tell seasons apart") + } + + selectedFiles := make(map[int]*torrent_analyzer.File) + selectedCount := 0 + for idx, f := range mainFiles { + for _, ep := range p.EpisodeNumbers { + if f.GetLocalFile().GetEpisodeNumber() == ep { + selectedCount++ + selectedFiles[idx] = f + } + } + } + + if selectedCount == 0 || selectedCount < len(p.EpisodeNumbers) { + _ = r.RemoveTorrents([]string{p.Torrent.InfoHash}) + return errors.New("failed to select files, could not find the right season files") + } + + indicesToRemove := analysis.GetUnselectedIndices(selectedFiles) + + if len(indicesToRemove) > 0 { + // Deselect files + err = r.DeselectFiles(p.Torrent.InfoHash, indicesToRemove) + if err != nil { + r.logger.Err(err).Msg("torrent client: error while deselecting files (smart select)") + _ = r.RemoveTorrents([]string{p.Torrent.InfoHash}) + return fmt.Errorf("error while deselecting files: %w", err) + } + } + + time.Sleep(1 * time.Second) + + // Resume the torrent + _ = r.ResumeTorrents([]string{p.Torrent.InfoHash}) + + return nil +} diff --git a/seanime-2.9.10/internal/torrent_clients/torrent_client/smart_select_test.go b/seanime-2.9.10/internal/torrent_clients/torrent_client/smart_select_test.go new file mode 100644 index 0000000..35edfdd --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/torrent_client/smart_select_test.go @@ -0,0 +1,80 @@ +package torrent_client + +//func TestSmartSelect(t *testing.T) { +// t.Skip("Refactor test") +// test_utils.InitTestProvider(t, test_utils.TorrentClient()) +// +// _ = t.TempDir() +// +// anilistClient := anilist.TestGetMockAnilistClient() +// _ = anilist_platform.NewAnilistPlatform(anilistClient, util.NewLogger()) +// +// // get repo +// +// tests := []struct { +// name string +// mediaId int +// url string +// selectedEpisodes []int +// client string +// }{ +// { +// name: "Kakegurui xx (Season 2)", +// mediaId: 100876, +// url: "https://nyaa.si/view/1553978", // kakegurui season 1 + season 2 +// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12 in season 2 +// client: QbittorrentClient, +// }, +// { +// name: "Spy x Family", +// mediaId: 140960, +// url: "https://nyaa.si/view/1661695", // spy x family (01-25) +// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12 +// client: QbittorrentClient, +// }, +// { +// name: "Spy x Family Part 2", +// mediaId: 142838, +// url: "https://nyaa.si/view/1661695", // spy x family (01-25) +// selectedEpisodes: []int{10, 11, 12, 13}, // should select 22, 23, 24, 25 +// client: QbittorrentClient, +// }, +// { +// name: "Kakegurui xx (Season 2)", +// mediaId: 100876, +// url: "https://nyaa.si/view/1553978", // kakegurui season 1 + season 2 +// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12 in season 2 +// client: TransmissionClient, +// }, +// { +// name: "Spy x Family", +// mediaId: 140960, +// url: "https://nyaa.si/view/1661695", // spy x family (01-25) +// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12 +// client: TransmissionClient, +// }, +// { +// name: "Spy x Family Part 2", +// mediaId: 142838, +// url: "https://nyaa.si/view/1661695", // spy x family (01-25) +// selectedEpisodes: []int{10, 11, 12, 13}, // should select 22, 23, 24, 25 +// client: TransmissionClient, +// }, +// } +// +// for _, tt := range tests { +// +// t.Run(tt.name, func(t *testing.T) { +// +// repo := getTestRepo(t, tt.client) +// +// ok := repo.Start() +// if !assert.True(t, ok) { +// return +// } +// +// }) +// +// } +// +//} diff --git a/seanime-2.9.10/internal/torrent_clients/torrent_client/torrent.go b/seanime-2.9.10/internal/torrent_clients/torrent_client/torrent.go new file mode 100644 index 0000000..4f22798 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/torrent_client/torrent.go @@ -0,0 +1,164 @@ +package torrent_client + +import ( + "seanime/internal/torrent_clients/qbittorrent/model" + "seanime/internal/util" + + "github.com/hekmon/transmissionrpc/v3" +) + +const ( + TorrentStatusDownloading TorrentStatus = "downloading" + TorrentStatusSeeding TorrentStatus = "seeding" + TorrentStatusPaused TorrentStatus = "paused" + TorrentStatusOther TorrentStatus = "other" + TorrentStatusStopped TorrentStatus = "stopped" +) + +type ( + Torrent struct { + Name string `json:"name"` + Hash string `json:"hash"` + Seeds int `json:"seeds"` + UpSpeed string `json:"upSpeed"` + DownSpeed string `json:"downSpeed"` + Progress float64 `json:"progress"` + Size string `json:"size"` + Eta string `json:"eta"` + Status TorrentStatus `json:"status"` + ContentPath string `json:"contentPath"` + } + TorrentStatus string +) + +//var torrentPool = util.NewPool[*Torrent](func() *Torrent { +// return &Torrent{} +//}) + +func (r *Repository) FromTransmissionTorrents(t []transmissionrpc.Torrent) []*Torrent { + ret := make([]*Torrent, 0, len(t)) + for _, t := range t { + ret = append(ret, r.FromTransmissionTorrent(&t)) + } + return ret +} + +func (r *Repository) FromTransmissionTorrent(t *transmissionrpc.Torrent) *Torrent { + torrent := &Torrent{} + + torrent.Name = "N/A" + if t.Name != nil { + torrent.Name = *t.Name + } + + torrent.Hash = "N/A" + if t.HashString != nil { + torrent.Hash = *t.HashString + } + + torrent.Seeds = 0 + if t.PeersSendingToUs != nil { + torrent.Seeds = int(*t.PeersSendingToUs) + } + + torrent.UpSpeed = "0 KB/s" + if t.RateUpload != nil { + torrent.UpSpeed = util.ToHumanReadableSpeed(int(*t.RateUpload)) + } + + torrent.DownSpeed = "0 KB/s" + if t.RateDownload != nil { + torrent.DownSpeed = util.ToHumanReadableSpeed(int(*t.RateDownload)) + } + + torrent.Progress = 0.0 + if t.PercentDone != nil { + torrent.Progress = *t.PercentDone + } + + torrent.Size = "N/A" + if t.TotalSize != nil { + torrent.Size = util.Bytes(uint64(*t.TotalSize)) + } + + torrent.Eta = "???" + if t.ETA != nil { + torrent.Eta = util.FormatETA(int(*t.ETA)) + } + + torrent.ContentPath = "" + if t.DownloadDir != nil { + torrent.ContentPath = *t.DownloadDir + } + + torrent.Status = TorrentStatusOther + if t.Status != nil && t.IsFinished != nil { + torrent.Status = fromTransmissionTorrentStatus(*t.Status, *t.IsFinished) + } + + return torrent +} + +// fromTransmissionTorrentStatus returns a normalized status for the torrent. +func fromTransmissionTorrentStatus(st transmissionrpc.TorrentStatus, isFinished bool) TorrentStatus { + if st == transmissionrpc.TorrentStatusSeed || st == transmissionrpc.TorrentStatusSeedWait { + return TorrentStatusSeeding + } else if st == transmissionrpc.TorrentStatusStopped && isFinished { + return TorrentStatusStopped + } else if st == transmissionrpc.TorrentStatusStopped && !isFinished { + return TorrentStatusPaused + } else if st == transmissionrpc.TorrentStatusDownload || st == transmissionrpc.TorrentStatusDownloadWait { + return TorrentStatusDownloading + } else { + return TorrentStatusOther + } +} + +func (r *Repository) FromQbitTorrents(t []*qbittorrent_model.Torrent) []*Torrent { + ret := make([]*Torrent, 0, len(t)) + for _, t := range t { + ret = append(ret, r.FromQbitTorrent(t)) + } + return ret +} +func (r *Repository) FromQbitTorrent(t *qbittorrent_model.Torrent) *Torrent { + torrent := &Torrent{} + + torrent.Name = t.Name + torrent.Hash = t.Hash + torrent.Seeds = t.NumSeeds + torrent.UpSpeed = util.ToHumanReadableSpeed(t.Upspeed) + torrent.DownSpeed = util.ToHumanReadableSpeed(t.Dlspeed) + torrent.Progress = t.Progress + torrent.Size = util.Bytes(uint64(t.Size)) + torrent.Eta = util.FormatETA(t.Eta) + torrent.ContentPath = t.ContentPath + torrent.Status = fromQbitTorrentStatus(t.State) + + return torrent +} + +// fromQbitTorrentStatus returns a normalized status for the torrent. +func fromQbitTorrentStatus(st qbittorrent_model.TorrentState) TorrentStatus { + if st == qbittorrent_model.StateQueuedUP || + st == qbittorrent_model.StateStalledUP || + st == qbittorrent_model.StateForcedUP || + st == qbittorrent_model.StateCheckingUP || + st == qbittorrent_model.StateUploading { + return TorrentStatusSeeding + } else if st == qbittorrent_model.StatePausedDL || st == qbittorrent_model.StateStoppedDL { + return TorrentStatusPaused + } else if st == qbittorrent_model.StateDownloading || + st == qbittorrent_model.StateCheckingDL || + st == qbittorrent_model.StateStalledDL || + st == qbittorrent_model.StateQueuedDL || + st == qbittorrent_model.StateMetaDL || + st == qbittorrent_model.StateAllocating || + st == qbittorrent_model.StateForceDL { + return TorrentStatusDownloading + } else if st == qbittorrent_model.StatePausedUP || st == qbittorrent_model.StateStoppedUP { + return TorrentStatusStopped + } else { + return TorrentStatusOther + } +} diff --git a/seanime-2.9.10/internal/torrent_clients/transmission/start.go b/seanime-2.9.10/internal/torrent_clients/transmission/start.go new file mode 100644 index 0000000..43eea16 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/transmission/start.go @@ -0,0 +1,92 @@ +package transmission + +import ( + "context" + "errors" + "runtime" + "seanime/internal/util" + "time" +) + +func (c *Transmission) getExecutableName() string { + switch runtime.GOOS { + case "windows": + return "transmission-qt.exe" + default: + return "transmission-qt" + } +} + +func (c *Transmission) getExecutablePath() string { + + if len(c.Path) > 0 { + return c.Path + } + + switch runtime.GOOS { + case "windows": + return "C:/Program Files/Transmission/transmission-qt.exe" + case "linux": + return "/usr/bin/transmission-qt" // Default path for Transmission on most Linux distributions + case "darwin": + return "/Applications/Transmission.app/Contents/MacOS/transmission-qt" + // Default path for Transmission on macOS + default: + return "C:/Program Files/Transmission/transmission-qt.exe" + } +} + +func (c *Transmission) Start() error { + + // If the path is empty, do not check if Transmission is running + if c.Path == "" { + return nil + } + + name := c.getExecutableName() + if util.ProgramIsRunning(name) { + return nil + } + + exe := c.getExecutablePath() + cmd := util.NewCmd(exe) + err := cmd.Start() + if err != nil { + return errors.New("failed to start Transmission") + } + + time.Sleep(1 * time.Second) + + return nil +} + +func (c *Transmission) CheckStart() bool { + if c == nil { + return false + } + + // If the path is empty, assume it's running + if c.Path == "" { + return true + } + + _, _, _, err := c.Client.RPCVersion(context.Background()) + if err == nil { + return true + } + + err = c.Start() + timeout := time.After(30 * time.Second) + ticker := time.Tick(1 * time.Second) + for { + select { + case <-ticker: + _, _, _, err := c.Client.RPCVersion(context.Background()) + if err == nil { + return true + } + case <-timeout: + return false + } + } +} diff --git a/seanime-2.9.10/internal/torrent_clients/transmission/transmission.go b/seanime-2.9.10/internal/torrent_clients/transmission/transmission.go new file mode 100644 index 0000000..a7f4b48 --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/transmission/transmission.go @@ -0,0 +1,62 @@ +package transmission + +import ( + "fmt" + "github.com/hekmon/transmissionrpc/v3" + "github.com/rs/zerolog" + "net/url" + "strings" +) + +type ( + Transmission struct { + Client *transmissionrpc.Client + Path string + Logger *zerolog.Logger + } + + NewTransmissionOptions struct { + Path string + Logger *zerolog.Logger + Username string + Password string + Host string // Default: 127.0.0.1 + Port int + } +) + +func New(options *NewTransmissionOptions) (*Transmission, error) { + // Set default host + if options.Host == "" { + options.Host = "127.0.0.1" + } + + baseUrl := fmt.Sprintf("http://%s:%s@%s:%d/transmission/rpc", + options.Username, + url.QueryEscape(options.Password), + options.Host, + options.Port, + ) + + if strings.HasPrefix(options.Host, "https://") { + options.Host = strings.TrimPrefix(options.Host, "https://") + baseUrl = fmt.Sprintf("https://%s:%s@%s:%d/transmission/rpc", + options.Username, + url.QueryEscape(options.Password), + options.Host, + options.Port, + ) + } + + _url, err := url.Parse(baseUrl) + if err != nil { + return nil, err + } + + client, _ := transmissionrpc.New(_url, nil) + return &Transmission{ + Client: client, + Path: options.Path, + Logger: options.Logger, + }, nil +} diff --git a/seanime-2.9.10/internal/torrent_clients/transmission/transmission_test.go b/seanime-2.9.10/internal/torrent_clients/transmission/transmission_test.go new file mode 100644 index 0000000..741cc0a --- /dev/null +++ b/seanime-2.9.10/internal/torrent_clients/transmission/transmission_test.go @@ -0,0 +1,106 @@ +package transmission + +import ( + "context" + "github.com/davecgh/go-spew/spew" + "github.com/hekmon/transmissionrpc/v3" + "github.com/stretchr/testify/assert" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + "time" +) + +//func TestGetActiveTorrents(t *testing.T) { +// t.Skip("Provide magnets") +// test_utils.InitTestProvider(t, test_utils.TorrentClient()) +// +// trans, err := New(&NewTransmissionOptions{ +// Host: test_utils.ConfigData.Provider.TransmissionHost, +// Path: test_utils.ConfigData.Provider.TransmissionPath, +// Port: test_utils.ConfigData.Provider.TransmissionPort, +// Username: test_utils.ConfigData.Provider.TransmissionUsername, +// Password: test_utils.ConfigData.Provider.TransmissionPassword, +// Logger: util.NewLogger(), +// }) +// if err != nil { +// t.Fatal(err) +// } +// +//} + +func TestGetFiles(t *testing.T) { + t.Skip("Provide magnets") + test_utils.InitTestProvider(t, test_utils.TorrentClient()) + + tempDir := t.TempDir() + + tests := []struct { + name string + url string + magnet string + mediaId int + expectedNbFiles int + }{ + { + name: "[EMBER] Demon Slayer (2023) (Season 3)", + url: "https://animetosho.org/view/ember-demon-slayer-2023-season-3-bdrip-1080p.n1778316", + magnet: "", + mediaId: 145139, + expectedNbFiles: 11, + }, + { + name: "[Tenrai-Sensei] Kakegurui (Season 1-2 + OVAs)", + url: "https://nyaa.si/view/1553978", + magnet: "", + mediaId: 98314, + expectedNbFiles: 27, + }, + } + + trans, err := New(&NewTransmissionOptions{ + Host: test_utils.ConfigData.Provider.TransmissionHost, + Path: test_utils.ConfigData.Provider.TransmissionPath, + Port: test_utils.ConfigData.Provider.TransmissionPort, + Username: test_utils.ConfigData.Provider.TransmissionUsername, + Password: test_utils.ConfigData.Provider.TransmissionPassword, + Logger: util.NewLogger(), + }) + if err != nil { + t.Fatal(err) + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + to, err := trans.Client.TorrentAdd(context.Background(), transmissionrpc.TorrentAddPayload{ + Filename: &tt.magnet, + DownloadDir: &tempDir, + }) + + if assert.NoError(t, err) { + + time.Sleep(20 * time.Second) + + // Get files + torrents, err := trans.Client.TorrentGetAllFor(context.Background(), []int64{*to.ID}) + to = torrents[0] + + spew.Dump(to.Files) + + // Remove torrent + err = trans.Client.TorrentRemove(context.Background(), transmissionrpc.TorrentRemovePayload{ + IDs: []int64{*to.ID}, + DeleteLocalData: true, + }) + + assert.NoError(t, err) + + } + + }) + + } + +} diff --git a/seanime-2.9.10/internal/torrents/analyzer/analyzer.go b/seanime-2.9.10/internal/torrents/analyzer/analyzer.go new file mode 100644 index 0000000..af1c70a --- /dev/null +++ b/seanime-2.9.10/internal/torrents/analyzer/analyzer.go @@ -0,0 +1,293 @@ +package torrent_analyzer + +import ( + "errors" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/library/anime" + "seanime/internal/library/scanner" + "seanime/internal/platforms/platform" + "seanime/internal/util" + "seanime/internal/util/limiter" + + "github.com/rs/zerolog" + lop "github.com/samber/lo/parallel" +) + +type ( + // Analyzer is a service similar to the scanner, but it is used to analyze torrent files. + // i.e. torrent files instead of local files. + Analyzer struct { + files []*File + media *anilist.CompleteAnime + platform platform.Platform + logger *zerolog.Logger + metadataProvider metadata.Provider + forceMatch bool + } + + // Analysis contains the results of the analysis. + Analysis struct { + files []*File // Hydrated after scanFiles is called + selectedFiles []*File // Hydrated after findCorrespondingFiles is called + media *anilist.CompleteAnime + } + + // File represents a torrent file and contains its metadata. + File struct { + index int + path string + localFile *anime.LocalFile + } +) + +type ( + NewAnalyzerOptions struct { + Logger *zerolog.Logger + Filepaths []string // Filepath of the torrent files + Media *anilist.CompleteAnime // The media to compare the files with + Platform platform.Platform + MetadataProvider metadata.Provider + // This basically skips the matching process and forces the media ID to be set. + // Used for the auto-select feature because the media is already known. + ForceMatch bool + } +) + +func NewAnalyzer(opts *NewAnalyzerOptions) *Analyzer { + files := lop.Map(opts.Filepaths, func(filepath string, idx int) *File { + return newFile(idx, filepath) + }) + return &Analyzer{ + files: files, + media: opts.Media, + platform: opts.Platform, + logger: opts.Logger, + metadataProvider: opts.MetadataProvider, + forceMatch: opts.ForceMatch, + } +} + +// AnalyzeTorrentFiles scans the files and returns an Analysis struct containing methods to get the results. +func (a *Analyzer) AnalyzeTorrentFiles() (*Analysis, error) { + if a.platform == nil { + return nil, errors.New("anilist client wrapper is nil") + } + + if err := a.scanFiles(); err != nil { + return nil, err + } + + analysis := &Analysis{ + files: a.files, + media: a.media, + } + + return analysis, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (a *Analysis) GetCorrespondingFiles() map[int]*File { + ret, _ := a.getCorrespondingFiles(func(f *File) bool { + return true + }) + return ret +} + +func (a *Analysis) GetCorrespondingMainFiles() map[int]*File { + ret, _ := a.getCorrespondingFiles(func(f *File) bool { + return f.localFile.IsMain() + }) + return ret +} + +func (a *Analysis) GetMainFileByEpisode(episodeNumber int) (*File, bool) { + ret, _ := a.getCorrespondingFiles(func(f *File) bool { + return f.localFile.IsMain() + }) + for _, f := range ret { + if f.localFile.Metadata.Episode == episodeNumber { + return f, true + } + } + return nil, false +} + +func (a *Analysis) GetFileByAniDBEpisode(episode string) (*File, bool) { + for _, f := range a.files { + if f.localFile.Metadata.AniDBEpisode == episode { + return f, true + } + } + return nil, false +} + +func (a *Analysis) GetUnselectedFiles() map[int]*File { + _, uRet := a.getCorrespondingFiles(func(f *File) bool { + return true + }) + return uRet +} + +func (a *Analysis) getCorrespondingFiles(filter func(f *File) bool) (map[int]*File, map[int]*File) { + ret := make(map[int]*File) + uRet := make(map[int]*File) + for _, af := range a.files { + if af.localFile.MediaId == a.media.ID { + if filter(af) { + ret[af.index] = af + } else { + uRet[af.index] = af + } + } else { + uRet[af.index] = af + } + } + return ret, uRet +} + +// GetIndices returns the indices of the files. +// +// Example: +// +// selectedFilesMap := analysis.GetCorrespondingMainFiles() +// selectedIndices := analysis.GetIndices(selectedFilesMap) +func (a *Analysis) GetIndices(files map[int]*File) []int { + indices := make([]int, 0) + for i := range files { + indices = append(indices, i) + } + return indices +} + +func (a *Analysis) GetFiles() []*File { + return a.files +} + +// GetUnselectedIndices takes a map of selected files and returns the indices of the unselected files. +// +// Example: +// +// analysis, _ := analyzer.AnalyzeTorrentFiles() +// selectedFiles := analysis.GetCorrespondingMainFiles() +// indicesToRemove := analysis.GetUnselectedIndices(selectedFiles) +func (a *Analysis) GetUnselectedIndices(files map[int]*File) []int { + indices := make([]int, 0) + for i := range a.files { + if _, ok := files[i]; !ok { + indices = append(indices, i) + } + } + return indices +} + +func (f *File) GetLocalFile() *anime.LocalFile { + return f.localFile +} + +func (f *File) GetIndex() int { + return f.index +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// scanFiles scans the files and matches them with the media. +func (a *Analyzer) scanFiles() error { + + completeAnimeCache := anilist.NewCompleteAnimeCache() + anilistRateLimiter := limiter.NewAnilistLimiter() + + lfs := a.getLocalFiles() // Extract local files from the Files + + // +---------------------+ + // | MediaContainer | + // +---------------------+ + + tree := anilist.NewCompleteAnimeRelationTree() + if err := a.media.FetchMediaTree(anilist.FetchMediaTreeAll, a.platform.GetAnilistClient(), anilistRateLimiter, tree, completeAnimeCache); err != nil { + return err + } + + allMedia := tree.Values() + + mc := scanner.NewMediaContainer(&scanner.MediaContainerOptions{ + AllMedia: allMedia, + }) + + //scanLogger, _ := scanner.NewScanLogger("./logs") + + // +---------------------+ + // | Matcher | + // +---------------------+ + + matcher := &scanner.Matcher{ + LocalFiles: lfs, + MediaContainer: mc, + CompleteAnimeCache: completeAnimeCache, + Logger: util.NewLogger(), + ScanLogger: nil, + ScanSummaryLogger: nil, + } + + err := matcher.MatchLocalFilesWithMedia() + if err != nil { + return err + } + + if a.forceMatch { + for _, lf := range lfs { + lf.MediaId = a.media.GetID() + } + } + + // +---------------------+ + // | FileHydrator | + // +---------------------+ + + fh := &scanner.FileHydrator{ + LocalFiles: lfs, + AllMedia: mc.NormalizedMedia, + CompleteAnimeCache: completeAnimeCache, + Platform: a.platform, + MetadataProvider: a.metadataProvider, + AnilistRateLimiter: anilistRateLimiter, + Logger: a.logger, + ScanLogger: nil, + ScanSummaryLogger: nil, + ForceMediaId: map[bool]int{true: a.media.GetID(), false: 0}[a.forceMatch], + } + + fh.HydrateMetadata() + + for _, af := range a.files { + for _, lf := range lfs { + if lf.Path == af.localFile.Path { + af.localFile = lf // Update the local file in the File + break + } + } + } + + return nil +} + +// newFile creates a new File from a file path. +func newFile(idx int, path string) *File { + path = filepath.ToSlash(path) + + return &File{ + index: idx, + path: path, + localFile: anime.NewLocalFile(path, ""), + } +} + +func (a *Analyzer) getLocalFiles() []*anime.LocalFile { + files := make([]*anime.LocalFile, len(a.files)) + for i, f := range a.files { + files[i] = f.localFile + } + return files +} diff --git a/seanime-2.9.10/internal/torrents/analyzer/analyzer_test.go b/seanime-2.9.10/internal/torrents/analyzer/analyzer_test.go new file mode 100644 index 0000000..b4c85fa --- /dev/null +++ b/seanime-2.9.10/internal/torrents/analyzer/analyzer_test.go @@ -0,0 +1,110 @@ +package torrent_analyzer + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestSelectFilesFromSeason tests the selection of the accurate season files from a list of files from all seasons. +func TestSelectFilesFromSeason(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + logger := util.NewLogger() + anilistClient := anilist.TestGetMockAnilistClient() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + metadataProvider := metadata.GetMockProvider(t) + + tests := []struct { + name string + mediaId int // The media ID of the season + filepaths []string // All filepaths from all seasons + expectedIndices []int // The indices of the selected files + }{ + { + name: "Kakegurui xx", + filepaths: []string{ + "Kakegurui [BD][1080p][HEVC 10bit x265][Dual Audio][Tenrai-Sensei]/Season 1/Kakegurui - S01E01 - The Woman Called Yumeko Jabami.mkv", // should be selected + "Kakegurui [BD][1080p][HEVC 10bit x265][Dual Audio][Tenrai-Sensei]/Season 2/Kakegurui xx - S02E01 - The Woman Called Yumeko Jabami.mkv", + }, + mediaId: 98314, + expectedIndices: []int{0}, + }, + { + name: "Kimi ni Todoke Season 2", + filepaths: []string{ + "[Judas] Kimi ni Todoke (Seasons 1-2) [BD 1080p][HEVC x265 10bit][Eng-Subs]/[Judas] Kimi ni Todoke S1/[Judas] Kimi ni Todoke - S01E01.mkv", + "[Judas] Kimi ni Todoke (Seasons 1-2) [BD 1080p][HEVC x265 10bit][Eng-Subs]/[Judas] Kimi ni Todoke S1/[Judas] Kimi ni Todoke - S01E02.mkv", + "[Judas] Kimi ni Todoke (Seasons 1-2) [BD 1080p][HEVC x265 10bit][Eng-Subs]/[Judas] Kimi ni Todoke S2/[Judas] Kimi ni Todoke - S02E01.mkv", // should be selected + "[Judas] Kimi ni Todoke (Seasons 1-2) [BD 1080p][HEVC x265 10bit][Eng-Subs]/[Judas] Kimi ni Todoke S2/[Judas] Kimi ni Todoke - S02E02.mkv", // should be selected + }, + mediaId: 9656, + expectedIndices: []int{2, 3}, + }, + { + name: "Spy x Family Part 2", + filepaths: []string{ + "[SubsPlease] Spy x Family (01-25) (1080p) [Batch]/[SubsPlease] Spy x Family - 10v2 (1080p) [F9F5C62B].mkv", + "[SubsPlease] Spy x Family (01-25) (1080p) [Batch]/[SubsPlease] Spy x Family - 11v2 (1080p) [F9F5C62B].mkv", + "[SubsPlease] Spy x Family (01-25) (1080p) [Batch]/[SubsPlease] Spy x Family - 12v2 (1080p) [F9F5C62B].mkv", + "[SubsPlease] Spy x Family (01-25) (1080p) [Batch]/[SubsPlease] Spy x Family - 13v2 (1080p) [F9F5C62B].mkv", // should be selected + "[SubsPlease] Spy x Family (01-25) (1080p) [Batch]/[SubsPlease] Spy x Family - 14v2 (1080p) [F9F5C62B].mkv", // should be selected + "[SubsPlease] Spy x Family (01-25) (1080p) [Batch]/[SubsPlease] Spy x Family - 15v2 (1080p) [F9F5C62B].mkv", // should be selected + }, + mediaId: 142838, + expectedIndices: []int{3, 4, 5}, + }, + { + name: "Mushoku Tensei: Jobless Reincarnation Season 2 Part 2", + filepaths: []string{ + "[EMBER] Mushoku Tensei S2 - 13.mkv", // should be selected + "[EMBER] Mushoku Tensei S2 - 14.mkv", // should be selected + "[EMBER] Mushoku Tensei S2 - 15.mkv", // should be selected + "[EMBER] Mushoku Tensei S2 - 16.mkv", // should be selected + }, + mediaId: 166873, + expectedIndices: []int{0, 1, 2, 3}, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + // Get media + media, err := anilistPlatform.GetAnimeWithRelations(t.Context(), tt.mediaId) + if err != nil { + t.Fatal("expected result, got error:", err.Error()) + } + + analyzer := NewAnalyzer(&NewAnalyzerOptions{ + Logger: logger, + Filepaths: tt.filepaths, + Media: media, + Platform: anilistPlatform, + MetadataProvider: metadataProvider, + ForceMatch: false, + }) + + // AnalyzeTorrentFiles + analysis, err := analyzer.AnalyzeTorrentFiles() + if assert.NoError(t, err) { + + selectedFilesMap := analysis.GetCorrespondingMainFiles() + selectedIndices := analysis.GetIndices(selectedFilesMap) + + // Check selected files + assert.ElementsMatch(t, tt.expectedIndices, selectedIndices) + + } + + }) + + } + +} diff --git a/seanime-2.9.10/internal/torrents/animetosho/animetosho.go b/seanime-2.9.10/internal/torrents/animetosho/animetosho.go new file mode 100644 index 0000000..2a34118 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/animetosho/animetosho.go @@ -0,0 +1,32 @@ +package animetosho + +type ( + Torrent struct { + Id int `json:"id"` + Title string `json:"title"` + Link string `json:"link"` + Timestamp int `json:"timestamp"` + Status string `json:"status"` + ToshoId int `json:"tosho_id,omitempty"` + NyaaId int `json:"nyaa_id,omitempty"` + NyaaSubdom interface{} `json:"nyaa_subdom,omitempty"` + AniDexId int `json:"anidex_id,omitempty"` + TorrentUrl string `json:"torrent_url"` + InfoHash string `json:"info_hash"` + InfoHashV2 string `json:"info_hash_v2,omitempty"` + MagnetUri string `json:"magnet_uri"` + Seeders int `json:"seeders"` + Leechers int `json:"leechers"` + TorrentDownloadCount int `json:"torrent_download_count"` + TrackerUpdated interface{} `json:"tracker_updated,omitempty"` + NzbUrl string `json:"nzb_url,omitempty"` + TotalSize int64 `json:"total_size"` + NumFiles int `json:"num_files"` + AniDbAid int `json:"anidb_aid"` + AniDbEid int `json:"anidb_eid"` + AniDbFid int `json:"anidb_fid"` + ArticleUrl string `json:"article_url"` + ArticleTitle string `json:"article_title"` + WebsiteUrl string `json:"website_url"` + } +) diff --git a/seanime-2.9.10/internal/torrents/animetosho/provider.go b/seanime-2.9.10/internal/torrents/animetosho/provider.go new file mode 100644 index 0000000..134ba32 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/animetosho/provider.go @@ -0,0 +1,731 @@ +package animetosho + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "seanime/internal/api/anilist" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/util" + "strings" + "sync" + "time" + + "github.com/5rahim/habari" + "github.com/goccy/go-json" + "github.com/rs/zerolog" + "github.com/samber/lo" +) + +var ( + JsonFeedUrl = util.Decode("aHR0cHM6Ly9mZWVkLmFuaW1ldG9zaG8ub3JnL2pzb24=") + ProviderName = "animetosho" +) + +type ( + Provider struct { + logger *zerolog.Logger + sneedexNyaaIDs map[int]struct{} + } +) + +func NewProvider(logger *zerolog.Logger) hibiketorrent.AnimeProvider { + ret := &Provider{ + logger: logger, + sneedexNyaaIDs: make(map[int]struct{}), + } + + go ret.loadSneedex() + + return ret +} + +func (at *Provider) GetSettings() hibiketorrent.AnimeProviderSettings { + return hibiketorrent.AnimeProviderSettings{ + Type: hibiketorrent.AnimeProviderTypeMain, + CanSmartSearch: true, + SmartSearchFilters: []hibiketorrent.AnimeProviderSmartSearchFilter{ + hibiketorrent.AnimeProviderSmartSearchFilterBatch, + hibiketorrent.AnimeProviderSmartSearchFilterEpisodeNumber, + hibiketorrent.AnimeProviderSmartSearchFilterResolution, + hibiketorrent.AnimeProviderSmartSearchFilterBestReleases, + }, + SupportsAdult: false, + } +} + +// GetLatest returns all the latest torrents currently visible on the site +func (at *Provider) GetLatest() (ret []*hibiketorrent.AnimeTorrent, err error) { + at.logger.Debug().Msg("animetosho: Fetching latest torrents") + query := "?q=" + torrents, err := at.fetchTorrents(query) + if err != nil { + return nil, err + } + + ret = at.torrentSliceToAnimeTorrentSlice(torrents, false, &hibiketorrent.Media{}) + + return ret, nil +} + +func (at *Provider) Search(opts hibiketorrent.AnimeSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) { + at.logger.Debug().Str("query", opts.Query).Msg("animetosho: Searching for torrents") + query := fmt.Sprintf("?q=%s", url.QueryEscape(sanitizeTitle(opts.Query))) + atTorrents, err := at.fetchTorrents(query) + if err != nil { + return nil, err + } + + ret = at.torrentSliceToAnimeTorrentSlice(atTorrents, false, &opts.Media) + + return ret, nil +} + +func (at *Provider) SmartSearch(opts hibiketorrent.AnimeSmartSearchOptions) ([]*hibiketorrent.AnimeTorrent, error) { + if opts.BestReleases { + return at.smartSearchBestReleases(&opts) + } + if opts.Batch { + return at.smartSearchBatch(&opts) + } + return at.smartSearchSingleEpisode(&opts) +} + +func (at *Provider) smartSearchSingleEpisode(opts *hibiketorrent.AnimeSmartSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) { + ret = make([]*hibiketorrent.AnimeTorrent, 0) + + at.logger.Debug().Int("aid", opts.AnidbAID).Msg("animetosho: Searching batches by Episode ID") + + foundByID := false + + atTorrents := make([]*Torrent, 0) + + if opts.AnidbEID > 0 { + // Get all torrents by Episode ID + atTorrents, err = at.searchByEID(opts.AnidbEID, opts.Resolution) + if err != nil { + return nil, err + } + + foundByID = true + } + + if foundByID { + // Get all torrents with only 1 file + atTorrents = lo.Filter(atTorrents, func(t *Torrent, _ int) bool { + return t.NumFiles == 1 + }) + ret = at.torrentSliceToAnimeTorrentSlice(atTorrents, true, &opts.Media) + return + } + + at.logger.Debug().Msg("animetosho: Searching batches by query") + + // If we couldn't find batches by AniDB Episode ID, use query builder + queries := buildSmartSearchQueries(opts) + + wg := sync.WaitGroup{} + mu := sync.Mutex{} + + for _, query := range queries { + wg.Add(1) + go func(query string) { + defer wg.Done() + + at.logger.Trace().Str("query", query).Msg("animetosho: Searching by query") + torrents, err := at.fetchTorrents(fmt.Sprintf("?only_tor=1&q=%s&qx=1", url.QueryEscape(query))) + if err != nil { + return + } + for _, t := range torrents { + // Skip if torrent has more than 1 file + if t.NumFiles > 1 && !(opts.Media.Format == string(anilist.MediaFormatMovie) && opts.Media.EpisodeCount == 1) { + continue + } + mu.Lock() + ret = append(ret, t.toAnimeTorrent(&opts.Media)) + mu.Unlock() + } + }(query) + } + + wg.Wait() + + // Remove duplicates + lo.UniqBy(ret, func(t *hibiketorrent.AnimeTorrent) string { + return t.Link + }) + + return +} + +func (at *Provider) smartSearchBatch(opts *hibiketorrent.AnimeSmartSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) { + ret = make([]*hibiketorrent.AnimeTorrent, 0) + + at.logger.Debug().Int("aid", opts.AnidbAID).Msg("animetosho: Searching batches by Anime ID") + + foundByID := false + + atTorrents := make([]*Torrent, 0) + + if opts.AnidbAID > 0 { + // Get all torrents by Anime ID + atTorrents, err = at.searchByAID(opts.AnidbAID, opts.Resolution) + if err != nil { + return nil, err + } + + // Retain batches ONLY if the media is NOT a movie or single-episode + // i.e. if the media is a movie or single-episode return all torrents + if !(opts.Media.Format == string(anilist.MediaFormatMovie) || opts.Media.EpisodeCount == 1) { + batchTorrents := lo.Filter(atTorrents, func(t *Torrent, _ int) bool { + return t.NumFiles > 1 + }) + if len(batchTorrents) > 0 { + atTorrents = batchTorrents + } + } + + if len(atTorrents) > 0 { + foundByID = true + } + } + + if foundByID { + ret = at.torrentSliceToAnimeTorrentSlice(atTorrents, true, &opts.Media) + return + } + + at.logger.Debug().Msg("animetosho: Searching batches by query") + + // If we couldn't find batches by AniDB Anime ID, use query builder + queries := buildSmartSearchQueries(opts) + + wg := sync.WaitGroup{} + mu := sync.Mutex{} + + for _, query := range queries { + wg.Add(1) + go func(query string) { + defer wg.Done() + + at.logger.Trace().Str("query", query).Msg("animetosho: Searching by query") + torrents, err := at.fetchTorrents(fmt.Sprintf("?only_tor=1&q=%s&order=size-d", url.QueryEscape(query))) + if err != nil { + return + } + for _, t := range torrents { + // Skip if not batch only if the media is not a movie or single-episode + if t.NumFiles == 1 && !(opts.Media.Format == string(anilist.MediaFormatMovie) && opts.Media.EpisodeCount == 1) { + continue + } + mu.Lock() + ret = append(ret, t.toAnimeTorrent(&opts.Media)) + mu.Unlock() + } + }(query) + } + + wg.Wait() + + // Remove duplicates + lo.UniqBy(ret, func(t *hibiketorrent.AnimeTorrent) string { + return t.Link + }) + + return +} + +type sneedexItem struct { + NyaaIDs []int `json:"nyaaIDs"` + EntryID string `json:"entryID"` +} + +func (at *Provider) loadSneedex() { + // Load Sneedex Nyaa IDs + resp, err := http.Get("https://sneedex.moe/api/public/nyaa") + if err != nil { + at.logger.Error().Err(err).Msg("animetosho: Failed to fetch Sneedex Nyaa IDs") + return + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + at.logger.Error().Err(err).Msg("animetosho: Failed to read Sneedex Nyaa IDs response") + return + } + + var sneedexItems []*sneedexItem + if err := json.Unmarshal(b, &sneedexItems); err != nil { + at.logger.Error().Err(err).Msg("animetosho: Failed to unmarshal Sneedex Nyaa IDs") + return + } + + for _, item := range sneedexItems { + for _, nyaaID := range item.NyaaIDs { + at.sneedexNyaaIDs[nyaaID] = struct{}{} + } + } + + at.logger.Debug().Int("count", len(at.sneedexNyaaIDs)).Msg("animetosho: Loaded Sneedex Nyaa IDs") +} + +func (at *Provider) smartSearchBestReleases(opts *hibiketorrent.AnimeSmartSearchOptions) ([]*hibiketorrent.AnimeTorrent, error) { + return at.findSneedexBestReleases(opts) +} + +func (at *Provider) findSneedexBestReleases(opts *hibiketorrent.AnimeSmartSearchOptions) ([]*hibiketorrent.AnimeTorrent, error) { + ret := make([]*hibiketorrent.AnimeTorrent, 0) + + at.logger.Debug().Int("aid", opts.AnidbAID).Msg("animetosho: Searching best releases by Anime ID") + + if opts.AnidbAID > 0 { + // Get all torrents by Anime ID + atTorrents, err := at.searchByAID(opts.AnidbAID, opts.Resolution) + if err != nil { + return nil, err + } + + // Filter by Sneedex Nyaa IDs + atTorrents = lo.Filter(atTorrents, func(t *Torrent, _ int) bool { + _, found := at.sneedexNyaaIDs[t.NyaaId] + return found + }) + + ret = at.torrentSliceToAnimeTorrentSlice(atTorrents, true, &opts.Media) + } + + return ret, nil +} + +//--------------------------------------------------------------------------------------------------------------------------------------------------// + +func (at *Provider) GetTorrentInfoHash(torrent *hibiketorrent.AnimeTorrent) (string, error) { + return torrent.InfoHash, nil +} + +func (at *Provider) GetTorrentMagnetLink(torrent *hibiketorrent.AnimeTorrent) (string, error) { + return torrent.MagnetLink, nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func buildSmartSearchQueries(opts *hibiketorrent.AnimeSmartSearchOptions) (ret []string) { + + hasSingleEpisode := opts.Media.EpisodeCount == 1 || opts.Media.Format == string(anilist.MediaFormatMovie) + + var queryStr []string // Final search query string, used for caching + allTitles := []string{opts.Media.RomajiTitle} + if opts.Media.EnglishTitle != nil { + allTitles = append(allTitles, *opts.Media.EnglishTitle) + } + for _, title := range opts.Media.Synonyms { + allTitles = append(allTitles, title) + } + + // + // Media only has 1 episode + // + if hasSingleEpisode { + str := "" + // 1. Build a query string + qTitles := "(" + for _, title := range allTitles { + qTitles += fmt.Sprintf("%s | ", sanitizeTitle(title)) + } + qTitles = qTitles[:len(qTitles)-3] + ")" + + str += qTitles + // 2. Add resolution + if opts.Resolution != "" { + str += " " + opts.Resolution + } + + // e.g. (Attack on Titan|Shingeki no Kyojin) 1080p + queryStr = []string{str} + + } else { + + // + // Media has multiple episodes + // + if !opts.Batch { // Single episode search + + qTitles := buildTitleString(opts) + qEpisodes := buildEpisodeString(opts) + + str := "" + // 1. Add titles + str += qTitles + // 2. Add episodes + if qEpisodes != "" { + str += " " + qEpisodes + } + // 3. Add resolution + if opts.Resolution != "" { + str += " " + opts.Resolution + } + + queryStr = append(queryStr, str) + + // If we can also search for absolute episodes (there is an offset) + if opts.Media.AbsoluteSeasonOffset > 0 { + // Parse a good title + metadata := habari.Parse(opts.Media.RomajiTitle) + // 1. Start building a new query string + absoluteQueryStr := metadata.Title + // 2. Add episodes + ep := opts.EpisodeNumber + opts.Media.AbsoluteSeasonOffset + absoluteQueryStr += fmt.Sprintf(` ("%d"|"e%d"|"ep%d")`, ep, ep, ep) + // 3. Add resolution + if opts.Resolution != "" { + absoluteQueryStr += " " + opts.Resolution + } + // Overwrite queryStr by adding the absolute query string + queryStr = append(queryStr, fmt.Sprintf("(%s) | (%s)", absoluteQueryStr, str)) + } + + } else { + + // Batch search + // e.g. "(Shingeki No Kyojin | Attack on Titan) ("Batch"|"Complete Series") 1080" + str := fmt.Sprintf(`(%s)`, opts.Media.RomajiTitle) + if opts.Media.EnglishTitle != nil { + str = fmt.Sprintf(`(%s | %s)`, opts.Media.RomajiTitle, *opts.Media.EnglishTitle) + } + str += " " + buildBatchGroup(&opts.Media) + if opts.Resolution != "" { + str += " " + opts.Resolution + } + + queryStr = []string{str} + } + + } + + for _, q := range queryStr { + ret = append(ret, q) + ret = append(ret, q+" -S0") + } + + return +} + +// searches for torrents by Anime ID +func (at *Provider) searchByAID(aid int, quality string) (torrents []*Torrent, err error) { + q := url.QueryEscape(formatQuality(quality)) + query := fmt.Sprintf(`?order=size-d&aid=%d&q=%s`, aid, q) + return at.fetchTorrents(query) +} + +// searches for torrents by Episode ID +func (at *Provider) searchByEID(eid int, quality string) (torrents []*Torrent, err error) { + q := url.QueryEscape(formatQuality(quality)) + query := fmt.Sprintf(`?eid=%d&q=%s`, eid, q) + return at.fetchTorrents(query) +} + +func (at *Provider) fetchTorrents(suffix string) (torrents []*Torrent, err error) { + furl := JsonFeedUrl + suffix + + at.logger.Debug().Str("url", furl).Msg("animetosho: Fetching torrents") + + resp, err := http.Get(furl) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Check if the request was successful (status code 200) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch torrents, %s", resp.Status) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Parse the feed + var ret []*Torrent + if err := json.Unmarshal(b, &ret); err != nil { + return nil, err + } + + for _, t := range ret { + if t.Seeders > 100000 { + t.Seeders = 0 + } + if t.Leechers > 100000 { + t.Leechers = 0 + } + } + + return ret, nil +} + +func formatQuality(quality string) string { + if quality == "" { + return "" + } + quality = strings.TrimSuffix(quality, "p") + return fmt.Sprintf(`%s`, quality) +} + +// sanitizeTitle removes characters that impact the search query +func sanitizeTitle(t string) string { + // Replace hyphens with spaces + t = strings.ReplaceAll(t, "-", " ") + // Remove everything except alphanumeric characters, spaces. + re := regexp.MustCompile(`[^a-zA-Z0-9\s]`) + t = re.ReplaceAllString(t, "") + + // Trim large spaces + re2 := regexp.MustCompile(`\s+`) + t = re2.ReplaceAllString(t, " ") + + // return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(t, "!", ""), ":", ""), "[", ""), "]", "") + return t +} + +func getAllTitles(media *hibiketorrent.Media) []string { + titles := make([]string, 0) + titles = append(titles, media.RomajiTitle) + if media.EnglishTitle != nil { + titles = append(titles, *media.EnglishTitle) + } + for _, title := range media.Synonyms { + titles = append(titles, title) + } + return titles +} + +// ("01"|"e01") -S0 +func buildEpisodeString(opts *hibiketorrent.AnimeSmartSearchOptions) string { + episodeStr := "" + if opts.EpisodeNumber != -1 { + pEp := zeropad(opts.EpisodeNumber) + episodeStr = fmt.Sprintf(`("%s"|"e%d") -S0`, pEp, opts.EpisodeNumber) + } + return episodeStr +} + +func buildTitleString(opts *hibiketorrent.AnimeSmartSearchOptions) string { + + romTitle := sanitizeTitle(opts.Media.RomajiTitle) + engTitle := "" + if opts.Media.EnglishTitle != nil { + engTitle = sanitizeTitle(*opts.Media.EnglishTitle) + } + + season := 0 + + // create titles by extracting season/part info + titles := make([]string, 0) + for _, title := range getAllTitles(&opts.Media) { + s, cTitle := util.ExtractSeasonNumber(title) + if s != 0 { // update season if it got parsed + season = s + } + if cTitle != "" { // add "cleaned" titles + titles = append(titles, sanitizeTitle(cTitle)) + } + } + + // Check season from synonyms, only update season if it's still 0 + for _, synonym := range opts.Media.Synonyms { + s, _ := util.ExtractSeasonNumber(synonym) + if s != 0 && season == 0 { + season = s + } + } + + // add romaji and english titles to the title list + titles = append(titles, romTitle) + if len(engTitle) > 0 { + titles = append(titles, engTitle) + } + + // convert III and II to season + // these will get cleaned later + if season == 0 && strings.Contains(strings.ToLower(romTitle), " iii") { + season = 3 + } + if season == 0 && strings.Contains(strings.ToLower(romTitle), " ii") { + season = 2 + } + + if engTitle != "" { + if season == 0 && strings.Contains(strings.ToLower(engTitle), " iii") { + season = 3 + } + if season == 0 && strings.Contains(strings.ToLower(engTitle), " ii") { + season = 2 + } + } + + // also, split romaji title by colon, + // if first part is long enough, add it to the title list + // DEVNOTE maybe we should only do that if the season IS found + split := strings.Split(romTitle, ":") + if len(split) > 1 && len(split[0]) > 8 { + titles = append(titles, split[0]) + } + if engTitle != "" { + split = strings.Split(engTitle, ":") + if len(split) > 1 && len(split[0]) > 8 { + titles = append(titles, split[0]) + } + } + + // clean titles + for i, title := range titles { + titles[i] = strings.TrimSpace(strings.ReplaceAll(title, ":", " ")) + titles[i] = strings.TrimSpace(strings.ReplaceAll(titles[i], "-", " ")) + titles[i] = strings.Join(strings.Fields(titles[i]), " ") + titles[i] = strings.ToLower(titles[i]) + if season != 0 { + titles[i] = strings.ReplaceAll(titles[i], " iii", "") + titles[i] = strings.ReplaceAll(titles[i], " ii", "") + } + } + titles = lo.Uniq(titles) + + shortestTitle := "" + for _, title := range titles { + if shortestTitle == "" || len(title) < len(shortestTitle) { + shortestTitle = title + } + } + + /////////////////////// Season + seasonBuff := bytes.NewBufferString("") + if season > 0 { + // (season 1|season 01|s1|s01) + // Season section + // e.g. S1, season 1, season 01 + seasonBuff.WriteString(fmt.Sprintf(`"%s %s%d" | `, shortestTitle, "season ", season)) + seasonBuff.WriteString(fmt.Sprintf(`"%s %s%s" | `, shortestTitle, "season ", zeropad(season))) + seasonBuff.WriteString(fmt.Sprintf(`"%s %s%d" | `, shortestTitle, "s", season)) + seasonBuff.WriteString(fmt.Sprintf(`"%s %s%s"`, shortestTitle, "s", zeropad(season))) + } + + qTitles := "(" + for idx, title := range titles { + qTitles += "\"" + title + "\"" + " | " + if idx == len(titles)-1 { + qTitles = qTitles[:len(qTitles)-3] + } + } + qTitles += seasonBuff.String() + qTitles += ")" + + return qTitles +} + +func zeropad(v interface{}) string { + switch i := v.(type) { + case int: + return fmt.Sprintf("%02d", i) + case string: + return fmt.Sprintf("%02s", i) + default: + return "" + } +} + +func buildBatchGroup(m *hibiketorrent.Media) string { + buff := bytes.NewBufferString("") + buff.WriteString("(") + // e.g. 01-12 + s1 := fmt.Sprintf(`"%s%s%s"`, zeropad("1"), " - ", zeropad(m.EpisodeCount)) + buff.WriteString(s1) + buff.WriteString("|") + // e.g. 01~12 + s2 := fmt.Sprintf(`"%s%s%s"`, zeropad("1"), " ~ ", zeropad(m.EpisodeCount)) + buff.WriteString(s2) + buff.WriteString("|") + // e.g. 01~12 + buff.WriteString(`"Batch"|`) + buff.WriteString(`"Complete"|`) + buff.WriteString(`"+ OVA"|`) + buff.WriteString(`"+ Specials"|`) + buff.WriteString(`"+ Special"|`) + buff.WriteString(`"Seasons"|`) + buff.WriteString(`"Parts"`) + buff.WriteString(")") + return buff.String() +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (at *Provider) torrentSliceToAnimeTorrentSlice(torrents []*Torrent, confirmed bool, media *hibiketorrent.Media) []*hibiketorrent.AnimeTorrent { + wg := sync.WaitGroup{} + mu := sync.Mutex{} + + ret := make([]*hibiketorrent.AnimeTorrent, 0) + for _, torrent := range torrents { + wg.Add(1) + go func(torrent *Torrent) { + defer wg.Done() + t := torrent.toAnimeTorrent(media) + _, isBest := at.sneedexNyaaIDs[torrent.NyaaId] + t.IsBestRelease = isBest + t.Confirmed = confirmed + mu.Lock() + ret = append(ret, t) + mu.Unlock() + }(torrent) + } + wg.Wait() + + return ret +} + +func (t *Torrent) toAnimeTorrent(media *hibiketorrent.Media) *hibiketorrent.AnimeTorrent { + metadata := habari.Parse(t.Title) + + formattedDate := "" + parsedDate := time.Unix(int64(t.Timestamp), 0) + formattedDate = parsedDate.Format(time.RFC3339) + + ret := &hibiketorrent.AnimeTorrent{ + Name: t.Title, + Date: formattedDate, + Size: t.TotalSize, + FormattedSize: util.Bytes(uint64(t.TotalSize)), + Seeders: t.Seeders, + Leechers: t.Leechers, + DownloadCount: t.TorrentDownloadCount, + Link: t.Link, + DownloadUrl: t.TorrentUrl, + MagnetLink: t.MagnetUri, + InfoHash: t.InfoHash, + Resolution: metadata.VideoResolution, + IsBatch: t.NumFiles > 1, + EpisodeNumber: 0, + ReleaseGroup: metadata.ReleaseGroup, + Provider: ProviderName, + IsBestRelease: false, + Confirmed: false, + } + + episode := -1 + + if len(metadata.EpisodeNumber) == 1 { + episode = util.StringToIntMust(metadata.EpisodeNumber[0]) + } + + // Force set episode number to 1 if it's a movie or single-episode and the torrent isn't a batch + if !ret.IsBatch && episode == -1 && (media.EpisodeCount == 1 || media.Format == string(anilist.MediaFormatMovie)) { + episode = 1 + } + + ret.EpisodeNumber = episode + + return ret +} diff --git a/seanime-2.9.10/internal/torrents/animetosho/provider_test.go b/seanime-2.9.10/internal/torrents/animetosho/provider_test.go new file mode 100644 index 0000000..9a9dd29 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/animetosho/provider_test.go @@ -0,0 +1,185 @@ +package animetosho + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSmartSearch(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + anilistClient := anilist.TestGetMockAnilistClient() + logger := util.NewLogger() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + + toshoPlatform := NewProvider(util.NewLogger()) + + metadataProvider := metadata.GetMockProvider(t) + + tests := []struct { + name string + mId int + batch bool + episodeNumber int + absoluteOffset int + resolution string + }{ + { + name: "Bungou Stray Dogs 5th Season Episode 11", + mId: 163263, + batch: false, + episodeNumber: 11, + absoluteOffset: 45, + resolution: "1080", + }, + { + name: "SPY×FAMILY Season 1 Part 2", + mId: 142838, + batch: false, + episodeNumber: 12, + absoluteOffset: 12, + resolution: "1080", + }, + { + name: "Jujutsu Kaisen Season 2", + mId: 145064, + batch: false, + episodeNumber: 2, + absoluteOffset: 24, + resolution: "", + }, + { + name: "Violet Evergarden The Movie", + mId: 103047, + batch: true, + episodeNumber: 1, + absoluteOffset: 0, + resolution: "720", + }, + { + name: "Sousou no Frieren", + mId: 154587, + batch: false, + episodeNumber: 10, + absoluteOffset: 0, + resolution: "1080", + }, + { + name: "Tokubetsu-hen Hibike! Euphonium: Ensemble", + mId: 150429, + batch: false, + episodeNumber: 1, + absoluteOffset: 0, + resolution: "1080", + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + media, err := anilistPlatform.GetAnime(t.Context(), tt.mId) + animeMetadata, err := metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, tt.mId) + require.NoError(t, err) + + queryMedia := hibiketorrent.Media{ + ID: media.GetID(), + IDMal: media.GetIDMal(), + Status: string(*media.GetStatus()), + Format: string(*media.GetFormat()), + EnglishTitle: media.GetTitle().GetEnglish(), + RomajiTitle: media.GetRomajiTitleSafe(), + EpisodeCount: media.GetTotalEpisodeCount(), + AbsoluteSeasonOffset: tt.absoluteOffset, + Synonyms: media.GetSynonymsContainingSeason(), + IsAdult: *media.GetIsAdult(), + StartDate: &hibiketorrent.FuzzyDate{ + Year: *media.GetStartDate().GetYear(), + Month: media.GetStartDate().GetMonth(), + Day: media.GetStartDate().GetDay(), + }, + } + + if assert.NoError(t, err) { + + episodeMetadata, ok := animeMetadata.FindEpisode(strconv.Itoa(tt.episodeNumber)) + require.True(t, ok) + + torrents, err := toshoPlatform.SmartSearch(hibiketorrent.AnimeSmartSearchOptions{ + Media: queryMedia, + Query: "", + Batch: tt.batch, + EpisodeNumber: tt.episodeNumber, + Resolution: tt.resolution, + AnidbAID: animeMetadata.Mappings.AnidbId, + AnidbEID: episodeMetadata.AnidbEid, + BestReleases: false, + }) + + require.NoError(t, err) + require.GreaterOrEqual(t, len(torrents), 1, "expected at least 1 torrent") + + for _, torrent := range torrents { + t.Log(torrent.Name) + t.Logf("\tLink: %s", torrent.Link) + t.Logf("\tMagnet: %s", torrent.MagnetLink) + t.Logf("\tEpisodeNumber: %d", torrent.EpisodeNumber) + t.Logf("\tResolution: %s", torrent.Resolution) + t.Logf("\tIsBatch: %v", torrent.IsBatch) + t.Logf("\tConfirmed: %v", torrent.Confirmed) + } + + } + + }) + + } +} + +func TestSearch2(t *testing.T) { + + toshoPlatform := NewProvider(util.NewLogger()) + torrents, err := toshoPlatform.Search(hibiketorrent.AnimeSearchOptions{ + Media: hibiketorrent.Media{}, + Query: "Kusuriya no Hitorigoto 05", + }) + require.NoError(t, err) + require.GreaterOrEqual(t, len(torrents), 1, "expected at least 1 torrent") + + for _, torrent := range torrents { + t.Log(torrent.Name) + t.Logf("\tLink: %s", torrent.Link) + t.Logf("\tMagnet: %s", torrent.MagnetLink) + t.Logf("\tEpisodeNumber: %d", torrent.EpisodeNumber) + t.Logf("\tResolution: %s", torrent.Resolution) + t.Logf("\tIsBatch: %v", torrent.IsBatch) + t.Logf("\tConfirmed: %v", torrent.Confirmed) + } +} + +func TestGetLatest(t *testing.T) { + + toshoPlatform := NewProvider(util.NewLogger()) + torrents, err := toshoPlatform.GetLatest() + require.NoError(t, err) + require.GreaterOrEqual(t, len(torrents), 1, "expected at least 1 torrent") + + for _, torrent := range torrents { + t.Log(torrent.Name) + t.Logf("\tLink: %s", torrent.Link) + t.Logf("\tMagnet: %s", torrent.MagnetLink) + t.Logf("\tEpisodeNumber: %d", torrent.EpisodeNumber) + t.Logf("\tResolution: %s", torrent.Resolution) + t.Logf("\tIsBatch: %v", torrent.IsBatch) + t.Logf("\tConfirmed: %v", torrent.Confirmed) + } +} diff --git a/seanime-2.9.10/internal/torrents/animetosho/scraping.go b/seanime-2.9.10/internal/torrents/animetosho/scraping.go new file mode 100644 index 0000000..a2700d5 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/animetosho/scraping.go @@ -0,0 +1,81 @@ +package animetosho + +import ( + "errors" + "github.com/gocolly/colly" + "strings" +) + +func TorrentFile(viewURL string) (string, error) { + var torrentLink string + + c := colly.NewCollector() + + c.OnHTML("a[href]", func(e *colly.HTMLElement) { + if strings.HasSuffix(e.Attr("href"), ".torrent") { + torrentLink = e.Attr("href") + } + }) + + var e error + c.OnError(func(r *colly.Response, err error) { + e = err + }) + if e != nil { + return "", e + } + + c.Visit(viewURL) + + if torrentLink == "" { + return "", errors.New("download link not found") + } + + return torrentLink, nil +} + +func TorrentMagnet(viewURL string) (string, error) { + var magnetLink string + + c := colly.NewCollector() + + c.OnHTML("a[href]", func(e *colly.HTMLElement) { + if strings.HasPrefix(e.Attr("href"), "magnet:?xt=") { + magnetLink = e.Attr("href") + } + }) + + var e error + c.OnError(func(r *colly.Response, err error) { + e = err + }) + if e != nil { + return "", e + } + + c.Visit(viewURL) + + if magnetLink == "" { + return "", errors.New("magnet link not found") + } + + return magnetLink, nil +} + +func TorrentHash(viewURL string) (string, error) { + + file, err := TorrentFile(viewURL) + if err != nil { + return "", err + } + + file = strings.Replace(file, "https://", "", 1) + //template := "%s/storage/torrent/%s/%s" + parts := strings.Split(file, "/") + + if len(parts) < 4 { + return "", errors.New("hash not found") + } + + return parts[3], nil +} diff --git a/seanime-2.9.10/internal/torrents/animetosho/scraping_test.go b/seanime-2.9.10/internal/torrents/animetosho/scraping_test.go new file mode 100644 index 0000000..2f8b108 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/animetosho/scraping_test.go @@ -0,0 +1,47 @@ +package animetosho + +import ( + "seanime/internal/util" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMagnet(t *testing.T) { + + url := util.Decode("aHR0cHM6Ly9hbmltZXRvc2hvLm9yZy92aWV3L2thaXpva3UtanVqdXRzdS1rYWlzZW4tMjYtYTFjOWJhYjEtc2Vhc29uLTIubjE3MTAxMTY=") + + magnet, err := TorrentMagnet(url) + + if assert.NoError(t, err) { + if assert.NotEmptyf(t, magnet, "magnet link not found") { + t.Log(magnet) + } + } +} + +func TestTorrentFile(t *testing.T) { + + url := util.Decode("aHR0cHM6Ly9hbmltZXRvc2hvLm9yZy92aWV3L2thaXpva3UtanVqdXRzdS1rYWlzZW4tMjYtYTFjOWJhYjEtc2Vhc29uLTIubjE3MTAxMTY=") + + link, err := TorrentFile(url) + + if assert.NoError(t, err) { + if assert.NotEmptyf(t, link, "download link not found") { + t.Log(link) + } + } +} + +func TestTorrentHash(t *testing.T) { + + url := util.Decode("aHR0cHM6Ly9hbmltZXRvc2hvLm9yZy92aWV3L2thaXpva3UtanVqdXRzdS1rYWlzZW4tMjYtYTFjOWJhYjEtc2Vhc29uLTIubjE3MTAxMTY=") + + hash, err := TorrentHash(url) + + if assert.NoError(t, err) { + if assert.NotEmptyf(t, hash, "hash not found") { + t.Log(hash) + } + } +} diff --git a/seanime-2.9.10/internal/torrents/nyaa/nyaa.go b/seanime-2.9.10/internal/torrents/nyaa/nyaa.go new file mode 100644 index 0000000..178da5a --- /dev/null +++ b/seanime-2.9.10/internal/torrents/nyaa/nyaa.go @@ -0,0 +1,259 @@ +package nyaa + +import ( + "fmt" + gourl "net/url" + "seanime/internal/util" +) + +type ( + Torrent struct { + Category string `json:"category"` + Name string `json:"name"` + Description string `json:"description"` + Date string `json:"date"` + Size string `json:"size"` + Seeders string `json:"seeders"` + Leechers string `json:"leechers"` + Downloads string `json:"downloads"` + IsTrusted string `json:"isTrusted"` + IsRemake string `json:"isRemake"` + Comments string `json:"comments"` + Link string `json:"link"` + GUID string `json:"guid"` + CategoryID string `json:"categoryID"` + InfoHash string `json:"infoHash"` + } + + BuildURLOptions struct { + Provider string + Query string + Category string + SortBy string + Filter string + } + + Comment struct { + User string `json:"user"` + Date string `json:"date"` + Text string `json:"text"` + } +) + +func (t *Torrent) GetSizeInBytes() int64 { + bytes, _ := util.StringSizeToBytes(t.Size) + return bytes +} + +var ( + nyaaBaseURL = util.Decode("aHR0cHM6Ly9ueWFhLnNpLz9wYWdlPXJzcyZxPSs=") + sukebeiBaseURL = util.Decode("aHR0cHM6Ly9zdWtlYmVpLm55YWEuc2kvP3BhZ2U9cnNzJnE9Kw==") + nyaaView = util.Decode("aHR0cHM6Ly9ueWFhLnNpL3ZpZXcv") + sukebeiView = util.Decode("aHR0cHM6Ly9zdWtlYmVpLm55YWEuc2kvdmlldy8=") +) + +const ( + sortByComments = "&s=comments&o=desc" + sortBySeeders = "&s=seeders&o=desc" + sortByLeechers = "&s=leechers&o=desc" + sortByDownloads = "&s=downloads&o=desc" + sortBySizeDsc = "&s=size&o=desc" + sortBySizeAsc = "&s=size&o=asc" + sortByDate = "&s=id&o=desc" + + filterNoFilter = "&f=0" + filterNoRemakes = "&f=1" + filterTrustedOnly = "&f=2" + + categoryAll = "&c=0_0" + + categoryAnime = "&c=1_0" + CategoryAnime = "&c=1_0" + categoryAnimeAMV = "&c=1_1" + categoryAnimeEng = "&c=1_2" + CategoryAnimeEng = "&c=1_2" + categoryAnimeNonEng = "&c=1_3" + CategoryAnimeNonEng = "&c=1_3" + categoryAnimeRaw = "&c=1_4" + + categoryAudio = "&c=2_0" + categoryAudioLossless = "&c=2_1" + categoryAudioLossy = "&c=2_2" + + categoryLiterature = "&c=3_0" + categoryLiteratureEng = "&c=3_1" + categoryLiteratureNonEng = "&c=3_2" + categoryLiteratureRaw = "&c=3_3" + + categoryLiveAction = "&c=4_0" + categoryLiveActionRaw = "&c=4_4" + categoryLiveActionEng = "&c=4_1" + categoryLiveActionNonEng = "&c=4_3" + categoryLiveActionIdolProm = "&c=4_2" + + categoryPictures = "&c=5_0" + categoryPicturesGraphics = "&c=5_1" + categoryPicturesPhotos = "&c=5_2" + + categorySoftware = "&c=6_0" + categorySoftwareApps = "&c=6_1" + categorySoftwareGames = "&c=6_2" + + categoryArt = "&c=1_0" + categoryArtAnime = "&c=1_1" + categoryArtDoujinshi = "&c=1_2" + categoryArtGames = "&c=1_3" + categoryArtManga = "&c=1_4" + categoryArtPictures = "&c=1_5" + + categoryRealLife = "&c=2_0" + categoryRealLifePhotos = "&c=2_1" + categoryRealLifeVideos = "&c=2_2" +) + +func buildURL(baseUrl string, opts BuildURLOptions) (string, error) { + var url string + + if baseUrl == "" { + if opts.Provider == "nyaa" { + url = nyaaBaseURL + } else if opts.Provider == "sukebei" { + url = sukebeiBaseURL + } else { + err := fmt.Errorf("provider option could be nyaa or sukebei") + return "", err + } + } else { + url = baseUrl + } + + if opts.Query != "" { + url += gourl.QueryEscape(opts.Query) + } + + if opts.Provider == "nyaa" { + if opts.Category != "" { + switch opts.Category { + case "all": + url += categoryAll + case "anime": + url += categoryAnime + case "anime-amv": + url += categoryAnimeAMV + case "anime-eng": + url += categoryAnimeEng + case "anime-non-eng": + url += categoryAnimeNonEng + case "anime-raw": + url += categoryAnimeRaw + case "audio": + url += categoryAudio + case "audio-lossless": + url += categoryAudioLossless + case "audio-lossy": + url += categoryAudioLossy + case "literature": + url += categoryLiterature + case "literature-eng": + url += categoryLiteratureEng + case "literature-non-eng": + url += categoryLiteratureNonEng + case "literature-raw": + url += categoryLiteratureRaw + case "live-action": + url += categoryLiveAction + case "live-action-raw": + url += categoryLiveActionRaw + case "live-action-eng": + url += categoryLiveActionEng + case "live-action-non-eng": + url += categoryLiveActionNonEng + case "live-action-idol-prom": + url += categoryLiveActionIdolProm + case "pictures": + url += categoryPictures + case "pictures-graphics": + url += categoryPicturesGraphics + case "pictures-photos": + url += categoryPicturesPhotos + case "software": + url += categorySoftware + case "software-apps": + url += categorySoftwareApps + case "software-games": + url += categorySoftwareGames + default: + err := fmt.Errorf("such nyaa category option does not exitst") + return "", err + } + } + } + + if opts.Provider == "sukebei" { + if opts.Category != "" { + switch opts.Category { + case "all": + url += categoryAll + case "art": + url += categoryArt + case "art-anime": + url += categoryArtAnime + case "art-doujinshi": + url += categoryArtDoujinshi + case "art-games": + url += categoryArtGames + case "art-manga": + url += categoryArtManga + case "art-pictures": + url += categoryArtPictures + case "real-life": + url += categoryRealLife + case "real-life-photos": + url += categoryRealLifePhotos + case "real-life-videos": + url += categoryRealLifeVideos + default: + err := fmt.Errorf("such sukebei category option does not exitst") + return "", err + } + } + } + + if opts.SortBy != "" { + switch opts.SortBy { + case "downloads": + url += sortByDownloads + case "comments": + url += sortByComments + case "seeders": + url += sortBySeeders + case "leechers": + url += sortByLeechers + case "size-asc": + url += sortBySizeAsc + case "size-dsc": + url += sortBySizeDsc + case "date": + url += sortByDate + default: + err := fmt.Errorf("such sort option does not exitst") + return "", err + } + } + + if opts.Filter != "" { + switch opts.Filter { + case "no-filter": + url += filterNoFilter + case "no-remakes": + url += filterNoRemakes + case "trusted-only": + url += filterTrustedOnly + default: + err := fmt.Errorf("such filter option does not exitst") + return "", err + } + } + + return url, nil +} diff --git a/seanime-2.9.10/internal/torrents/nyaa/provider.go b/seanime-2.9.10/internal/torrents/nyaa/provider.go new file mode 100644 index 0000000..8649fdc --- /dev/null +++ b/seanime-2.9.10/internal/torrents/nyaa/provider.go @@ -0,0 +1,569 @@ +package nyaa + +import ( + "bytes" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/extension" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/util" + "seanime/internal/util/comparison" + "strconv" + "strings" + "sync" + "time" + + "github.com/5rahim/habari" + "github.com/mmcdole/gofeed" + "github.com/rs/zerolog" + "github.com/samber/lo" +) + +const ( + NyaaProviderName = "nyaa" +) + +type Provider struct { + logger *zerolog.Logger + category string + + baseUrl string +} + +func NewProvider(logger *zerolog.Logger, category string) hibiketorrent.AnimeProvider { + return &Provider{ + logger: logger, + category: category, + } +} + +func (n *Provider) SetSavedUserConfig(config extension.SavedUserConfig) { + n.baseUrl, _ = config.Values["apiUrl"] +} + +func (n *Provider) GetSettings() hibiketorrent.AnimeProviderSettings { + return hibiketorrent.AnimeProviderSettings{ + Type: hibiketorrent.AnimeProviderTypeMain, + CanSmartSearch: true, + SmartSearchFilters: []hibiketorrent.AnimeProviderSmartSearchFilter{ + hibiketorrent.AnimeProviderSmartSearchFilterBatch, + hibiketorrent.AnimeProviderSmartSearchFilterEpisodeNumber, + hibiketorrent.AnimeProviderSmartSearchFilterResolution, + hibiketorrent.AnimeProviderSmartSearchFilterQuery, + }, + SupportsAdult: false, + } +} + +func (n *Provider) GetLatest() (ret []*hibiketorrent.AnimeTorrent, err error) { + fp := gofeed.NewParser() + + url, err := buildURL(n.baseUrl, BuildURLOptions{ + Provider: "nyaa", + Query: "", + Category: n.category, + SortBy: "seeders", + Filter: "", + }) + if err != nil { + return nil, err + } + + // get content + feed, err := fp.ParseURL(url) + if err != nil { + return nil, err + } + + // parse content + res := convertRSS(feed) + + ret = torrentSliceToAnimeTorrentSlice(res, NyaaProviderName) + + return +} + +func (n *Provider) Search(opts hibiketorrent.AnimeSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) { + fp := gofeed.NewParser() + + n.logger.Trace().Str("query", opts.Query).Msg("nyaa: Search query") + + url, err := buildURL(n.baseUrl, BuildURLOptions{ + Provider: "nyaa", + Query: opts.Query, + Category: n.category, + SortBy: "seeders", + Filter: "", + }) + if err != nil { + return nil, err + } + + // get content + feed, err := fp.ParseURL(url) + if err != nil { + return nil, err + } + + // parse content + res := convertRSS(feed) + + ret = torrentSliceToAnimeTorrentSlice(res, NyaaProviderName) + + return +} + +func (n *Provider) SmartSearch(opts hibiketorrent.AnimeSmartSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) { + + queries, ok := buildSmartSearchQueries(&opts) + if !ok { + return nil, fmt.Errorf("could not build queries") + } + + wg := sync.WaitGroup{} + mu := sync.Mutex{} + + for _, query := range queries { + wg.Add(1) + go func(query string) { + defer wg.Done() + fp := gofeed.NewParser() + n.logger.Trace().Str("query", query).Msg("nyaa: Smart search query") + url, err := buildURL(n.baseUrl, BuildURLOptions{ + Provider: "nyaa", + Query: query, + Category: n.category, + SortBy: "seeders", + Filter: "", + }) + if err != nil { + return + } + n.logger.Trace().Str("url", url).Msg("nyaa: Smart search url") + // get content + feed, err := fp.ParseURL(url) + if err != nil { + return + } + // parse content + res := convertRSS(feed) + + mu.Lock() + ret = torrentSliceToAnimeTorrentSlice(res, NyaaProviderName) + mu.Unlock() + }(query) + } + wg.Wait() + + // remove duplicates + lo.UniqBy(ret, func(i *hibiketorrent.AnimeTorrent) string { + return i.Link + }) + + if !opts.Batch { + // Single-episode search + // If the episode number is provided, we can filter the results + ret = lo.Filter(ret, func(i *hibiketorrent.AnimeTorrent, _ int) bool { + relEp := i.EpisodeNumber + if relEp == -1 { + return false + } + absEp := opts.Media.AbsoluteSeasonOffset + opts.EpisodeNumber + + return opts.EpisodeNumber == relEp || absEp == relEp + }) + } + + return +} + +func (n *Provider) GetTorrentInfoHash(torrent *hibiketorrent.AnimeTorrent) (string, error) { + return torrent.InfoHash, nil +} + +func (n *Provider) GetTorrentMagnetLink(torrent *hibiketorrent.AnimeTorrent) (string, error) { + return TorrentMagnet(torrent.Link) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// ADVANCED SEARCH +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// buildSmartSearchQueries will return a slice of queries for nyaa.si. +// The second index of the returned slice is the absolute episode query. +// If the function returns false, the query could not be built. +// BuildSearchQueryOptions.Title will override the constructed title query but not other parameters. +func buildSmartSearchQueries(opts *hibiketorrent.AnimeSmartSearchOptions) ([]string, bool) { + + romTitle := opts.Media.RomajiTitle + engTitle := opts.Media.EnglishTitle + + allTitles := []*string{&romTitle, engTitle} + for _, synonym := range opts.Media.Synonyms { + allTitles = append(allTitles, &synonym) + } + + season := 0 + part := 0 + + // create titles by extracting season/part info + titles := make([]string, 0) + + // Build titles if no query provided + if opts.Query == "" { + for _, title := range allTitles { + if title == nil { + continue + } + s, cTitle := util.ExtractSeasonNumber(*title) + p, cTitle := util.ExtractPartNumber(cTitle) + if s != 0 { // update season if it got parsed + season = s + } + if p != 0 { // update part if it got parsed + part = p + } + if cTitle != "" { // add "cleaned" titles + titles = append(titles, cTitle) + } + } + + // Check season from synonyms, only update season if it's still 0 + for _, synonym := range opts.Media.Synonyms { + s, _ := util.ExtractSeasonNumber(synonym) + if s != 0 && season == 0 { + season = s + } + } + + // no season or part got parsed, meaning there is no "cleaned" title, + // add romaji and english titles to the title list + if season == 0 && part == 0 { + titles = append(titles, romTitle) + if engTitle != nil { + if len(*engTitle) > 0 { + titles = append(titles, *engTitle) + } + } + } + + // convert III and II to season + // these will get cleaned later + if season == 0 && (strings.Contains(strings.ToLower(romTitle), " iii")) { + season = 3 + } + if season == 0 && (strings.Contains(strings.ToLower(romTitle), " ii")) { + season = 2 + } + if engTitle != nil { + if season == 0 && (strings.Contains(strings.ToLower(*engTitle), " iii")) { + season = 3 + } + if season == 0 && (strings.Contains(strings.ToLower(*engTitle), " ii")) { + season = 2 + } + } + + // also, split romaji title by colon, + // if first part is long enough, add it to the title list + // DEVNOTE maybe we should only do that if the season IS found + split := strings.Split(romTitle, ":") + if len(split) > 1 && len(split[0]) > 8 { + titles = append(titles, split[0]) + } + if engTitle != nil { + split := strings.Split(*engTitle, ":") + if len(split) > 1 && len(split[0]) > 8 { + titles = append(titles, split[0]) + } + } + + // clean titles + for i, title := range titles { + titles[i] = strings.TrimSpace(strings.ReplaceAll(title, ":", " ")) + titles[i] = strings.TrimSpace(strings.ReplaceAll(titles[i], "-", " ")) + titles[i] = strings.Join(strings.Fields(titles[i]), " ") + titles[i] = strings.ToLower(titles[i]) + if season != 0 { + titles[i] = strings.ReplaceAll(titles[i], " iii", "") + titles[i] = strings.ReplaceAll(titles[i], " ii", "") + } + } + titles = lo.Uniq(titles) + } else { + titles = append(titles, strings.ToLower(opts.Query)) + } + + // + // Parameters + // + + // can batch if media stopped airing + canBatch := false + if opts.Media.Status == string(anilist.MediaStatusFinished) && opts.Media.EpisodeCount > 0 { + canBatch = true + } + + normalBuff := bytes.NewBufferString("") + + // Batch section - empty unless: + // 1. If the media is finished and has more than 1 episode + // 2. If the media is not a movie + // 3. If the media is not a single episode + batchBuff := bytes.NewBufferString("") + if opts.Batch && canBatch && !(opts.Media.Format == string(anilist.MediaFormatMovie) && opts.Media.EpisodeCount == 1) { + if season != 0 { + batchBuff.WriteString(buildSeasonString(season)) + } + if part != 0 { + batchBuff.WriteString(buildPartString(part)) + } + batchBuff.WriteString(buildBatchString(&opts.Media)) + + } else { + + normalBuff.WriteString(buildSeasonString(season)) + if part != 0 { + normalBuff.WriteString(buildPartString(part)) + } + + if !(opts.Media.Format == string(anilist.MediaFormatMovie) && opts.Media.EpisodeCount == 1) { + normalBuff.WriteString(buildEpisodeString(opts.EpisodeNumber)) + } + + } + + titleStr := buildTitleString(titles) + batchStr := batchBuff.String() + normalStr := normalBuff.String() + + // Replace titleStr if user provided one + if opts.Query != "" { + titleStr = fmt.Sprintf(`(%s)`, opts.Query) + } + + //println(spew.Sdump(titleStr, batchStr, normalStr)) + + query := fmt.Sprintf("%s%s%s", titleStr, batchStr, normalStr) + if opts.Resolution != "" { + query = fmt.Sprintf("%s(%s)", query, opts.Resolution) + } else { + query = fmt.Sprintf("%s(%s)", query, strings.Join([]string{"360", "480", "720", "1080"}, "|")) + } + query2 := "" + + // Absolute episode addition + if !opts.Batch && opts.Media.AbsoluteSeasonOffset > 0 && !(opts.Media.Format == string(anilist.MediaFormatMovie) && opts.Media.EpisodeCount == 1) { + query2 = fmt.Sprintf("%s", buildAbsoluteGroupString(titleStr, opts.Resolution, opts)) // e.g. jujutsu kaisen 25 + } + + ret := []string{query} + if query2 != "" { + ret = append(ret, query2) + } + + return ret, true +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +//func sanitizeTitle(t string) string { +// return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(t, "!", ""), ":", ""), "[", ""), "]", ""), ".", "") +//} + +// (title) +// ("jjk"|"jujutsu kaisen") +func buildTitleString(titles []string) string { + // Single titles are not wrapped in quotes + if len(titles) == 1 { + return fmt.Sprintf(`(%s)`, titles[0]) + } + + return fmt.Sprintf("(%s)", strings.Join(lo.Map(titles, func(item string, _ int) string { + return fmt.Sprintf(`"%s"`, item) + }), "|")) +} + +func buildAbsoluteGroupString(title, resolution string, opts *hibiketorrent.AnimeSmartSearchOptions) string { + return fmt.Sprintf("%s(%d)(%s)", title, opts.EpisodeNumber+opts.Media.AbsoluteSeasonOffset, resolution) +} + +// (s01e01) +func buildSeasonAndEpisodeGroup(season int, ep int) string { + if season == 0 { + season = 1 + } + return fmt.Sprintf(`"s%se%s"`, zeropad(season), zeropad(ep)) +} + +// (01|e01|e01v|ep01|ep1) +func buildEpisodeString(ep int) string { + pEp := zeropad(ep) + //return fmt.Sprintf(`("%s"|"e%s"|"e%sv"|"%sv"|"ep%s"|"ep%d")`, pEp, pEp, pEp, pEp, pEp, ep) + return fmt.Sprintf(`(%s|e%s|e%sv|%sv|ep%s|ep%d)`, pEp, pEp, pEp, pEp, pEp, ep) +} + +// (season 1|season 01|s1|s01) +func buildSeasonString(season int) string { + // Season section + seasonBuff := bytes.NewBufferString("") + // e.g. S1, season 1, season 01 + if season != 0 { + seasonBuff.WriteString(fmt.Sprintf(`("%s%d"|`, "season ", season)) + seasonBuff.WriteString(fmt.Sprintf(`"%s%s"|`, "season ", zeropad(season))) + seasonBuff.WriteString(fmt.Sprintf(`"%s%d"|`, "s", season)) + seasonBuff.WriteString(fmt.Sprintf(`"%s%s")`, "s", zeropad(season))) + } + return seasonBuff.String() +} + +func buildPartString(part int) string { + partBuff := bytes.NewBufferString("") + if part != 0 { + partBuff.WriteString(fmt.Sprintf(`("%s%d")`, "part ", part)) + } + return partBuff.String() +} + +func buildBatchString(m *hibiketorrent.Media) string { + + buff := bytes.NewBufferString("") + buff.WriteString("(") + // e.g. 01-12 + s1 := fmt.Sprintf(`"%s%s%s"`, zeropad("1"), " - ", zeropad(m.EpisodeCount)) + buff.WriteString(s1) + buff.WriteString("|") + // e.g. 01~12 + s2 := fmt.Sprintf(`"%s%s%s"`, zeropad("1"), " ~ ", zeropad(m.EpisodeCount)) + buff.WriteString(s2) + buff.WriteString("|") + // e.g. 01~12 + buff.WriteString(`"Batch"|`) + buff.WriteString(`"Complete"|`) + buff.WriteString(`"+ OVA"|`) + buff.WriteString(`"+ Specials"|`) + buff.WriteString(`"+ Special"|`) + buff.WriteString(`"Seasons"|`) + buff.WriteString(`"Parts"`) + buff.WriteString(")") + return buff.String() +} + +func zeropad(v interface{}) string { + switch i := v.(type) { + case int: + return fmt.Sprintf("%02d", i) + case string: + return fmt.Sprintf("%02s", i) + default: + return "" + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func convertRSS(feed *gofeed.Feed) []Torrent { + var res []Torrent + + for _, item := range feed.Items { + res = append( + res, + Torrent{ + Name: item.Title, + Link: item.Link, + Date: item.Published, + Description: item.Description, + GUID: item.GUID, + Comments: item.Extensions["nyaa"]["comments"][0].Value, + IsTrusted: item.Extensions["nyaa"]["trusted"][0].Value, + IsRemake: item.Extensions["nyaa"]["remake"][0].Value, + Size: item.Extensions["nyaa"]["size"][0].Value, + Seeders: item.Extensions["nyaa"]["seeders"][0].Value, + Leechers: item.Extensions["nyaa"]["leechers"][0].Value, + Downloads: item.Extensions["nyaa"]["downloads"][0].Value, + Category: item.Extensions["nyaa"]["category"][0].Value, + CategoryID: item.Extensions["nyaa"]["categoryId"][0].Value, + InfoHash: item.Extensions["nyaa"]["infoHash"][0].Value, + }, + ) + } + return res +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func torrentSliceToAnimeTorrentSlice(torrents []Torrent, providerName string) []*hibiketorrent.AnimeTorrent { + wg := sync.WaitGroup{} + mu := sync.Mutex{} + + ret := make([]*hibiketorrent.AnimeTorrent, 0) + for _, torrent := range torrents { + wg.Add(1) + go func(torrent Torrent) { + defer wg.Done() + mu.Lock() + ret = append(ret, torrent.toAnimeTorrent(providerName)) + mu.Unlock() + }(torrent) + } + wg.Wait() + + return ret +} + +func (t *Torrent) toAnimeTorrent(providerName string) *hibiketorrent.AnimeTorrent { + metadata := habari.Parse(t.Name) + + seeders, _ := strconv.Atoi(t.Seeders) + leechers, _ := strconv.Atoi(t.Leechers) + downloads, _ := strconv.Atoi(t.Downloads) + + formattedDate := "" + parsedDate, err := time.Parse("Mon, 02 Jan 2006 15:04:05 -0700", t.Date) + if err == nil { + formattedDate = parsedDate.Format(time.RFC3339) + } + + ret := &hibiketorrent.AnimeTorrent{ + Name: t.Name, + Date: formattedDate, + Size: t.GetSizeInBytes(), + FormattedSize: t.Size, + Seeders: seeders, + Leechers: leechers, + DownloadCount: downloads, + Link: t.GUID, + DownloadUrl: t.Link, + InfoHash: t.InfoHash, + MagnetLink: "", // Should be scraped + Resolution: "", // Should be parsed + IsBatch: false, // Should be parsed + EpisodeNumber: -1, // Should be parsed + ReleaseGroup: "", // Should be parsed + Provider: providerName, + IsBestRelease: false, + Confirmed: false, + } + + isBatchByGuess := false + episode := -1 + + if len(metadata.EpisodeNumber) > 1 || comparison.ValueContainsBatchKeywords(t.Name) { + isBatchByGuess = true + } + if len(metadata.EpisodeNumber) == 1 { + episode = util.StringToIntMust(metadata.EpisodeNumber[0]) + } + + ret.Resolution = metadata.VideoResolution + ret.ReleaseGroup = metadata.ReleaseGroup + + // Only change batch status if it wasn't already 'true' + if ret.IsBatch == false && isBatchByGuess { + ret.IsBatch = true + } + + ret.EpisodeNumber = episode + + return ret +} diff --git a/seanime-2.9.10/internal/torrents/nyaa/provider_test.go b/seanime-2.9.10/internal/torrents/nyaa/provider_test.go new file mode 100644 index 0000000..f787a00 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/nyaa/provider_test.go @@ -0,0 +1,167 @@ +package nyaa + +import ( + "seanime/internal/api/anilist" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/util" + "seanime/internal/util/limiter" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSearch(t *testing.T) { + + nyaaProvider := NewProvider(util.NewLogger(), categoryAnime) + + torrents, err := nyaaProvider.Search(hibiketorrent.AnimeSearchOptions{ + Query: "One Piece", + }) + require.NoError(t, err) + + for _, torrent := range torrents { + t.Log(torrent.Name) + } +} + +func TestSmartSearch(t *testing.T) { + + anilistLimiter := limiter.NewAnilistLimiter() + anilistClient := anilist.TestGetMockAnilistClient() + logger := util.NewLogger() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + + nyaaProvider := NewProvider(util.NewLogger(), categoryAnime) + + tests := []struct { + name string + mId int + batch bool + episodeNumber int + absoluteOffset int + resolution string + scrapeMagnet bool + }{ + { + name: "Bungou Stray Dogs 5th Season Episode 11", + mId: 163263, + batch: false, + episodeNumber: 11, + absoluteOffset: 45, + resolution: "1080", + scrapeMagnet: true, + }, + { + name: "SPY×FAMILY Season 1 Part 2", + mId: 142838, + batch: false, + episodeNumber: 12, + absoluteOffset: 12, + resolution: "1080", + scrapeMagnet: false, + }, + { + name: "Jujutsu Kaisen Season 2", + mId: 145064, + batch: false, + episodeNumber: 2, + absoluteOffset: 24, + resolution: "1080", + scrapeMagnet: false, + }, + { + name: "Violet Evergarden The Movie", + mId: 103047, + batch: true, + episodeNumber: 1, + absoluteOffset: 0, + resolution: "720", + scrapeMagnet: false, + }, + { + name: "Sousou no Frieren", + mId: 154587, + batch: false, + episodeNumber: 10, + absoluteOffset: 0, + resolution: "1080", + scrapeMagnet: false, + }, + { + name: "Tokubetsu-hen Hibike! Euphonium: Ensemble", + mId: 150429, + batch: false, + episodeNumber: 1, + absoluteOffset: 0, + resolution: "1080", + scrapeMagnet: false, + }, + } + + for _, tt := range tests { + + anilistLimiter.Wait() + + t.Run(tt.name, func(t *testing.T) { + + media, err := anilistPlatform.GetAnime(t.Context(), tt.mId) + require.NoError(t, err) + require.NotNil(t, media) + + queryMedia := hibiketorrent.Media{ + ID: media.GetID(), + IDMal: media.GetIDMal(), + Status: string(*media.GetStatus()), + Format: string(*media.GetFormat()), + EnglishTitle: media.GetTitle().GetEnglish(), + RomajiTitle: media.GetRomajiTitleSafe(), + EpisodeCount: media.GetTotalEpisodeCount(), + AbsoluteSeasonOffset: tt.absoluteOffset, + Synonyms: media.GetSynonymsContainingSeason(), + IsAdult: *media.GetIsAdult(), + StartDate: &hibiketorrent.FuzzyDate{ + Year: *media.GetStartDate().GetYear(), + Month: media.GetStartDate().GetMonth(), + Day: media.GetStartDate().GetDay(), + }, + } + + torrents, err := nyaaProvider.SmartSearch(hibiketorrent.AnimeSmartSearchOptions{ + Media: queryMedia, + Query: "", + Batch: tt.batch, + EpisodeNumber: tt.episodeNumber, + Resolution: tt.resolution, + AnidbAID: 0, // Not supported + AnidbEID: 0, // Not supported + BestReleases: false, // Not supported + }) + require.NoError(t, err, "error searching nyaa") + + for _, torrent := range torrents { + + scrapedMagnet := "" + if tt.scrapeMagnet { + magn, err := nyaaProvider.GetTorrentMagnetLink(torrent) + if err == nil { + scrapedMagnet = magn + } + } + + t.Log(torrent.Name) + t.Logf("\tMagnet: %s", torrent.MagnetLink) + if scrapedMagnet != "" { + t.Logf("\tMagnet (Scraped): %s", scrapedMagnet) + } + t.Logf("\tEpisodeNumber: %d", torrent.EpisodeNumber) + t.Logf("\tResolution: %s", torrent.Resolution) + t.Logf("\tIsBatch: %v", torrent.IsBatch) + t.Logf("\tConfirmed: %v", torrent.Confirmed) + } + + }) + + } + +} diff --git a/seanime-2.9.10/internal/torrents/nyaa/scraping.go b/seanime-2.9.10/internal/torrents/nyaa/scraping.go new file mode 100644 index 0000000..c619f58 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/nyaa/scraping.go @@ -0,0 +1,159 @@ +package nyaa + +import ( + "errors" + "github.com/gocolly/colly" + "regexp" + "strconv" + "strings" +) + +func TorrentFiles(viewURL string) ([]string, error) { + var folders []string + var files []string + + c := colly.NewCollector() + + c.OnHTML(".folder", func(e *colly.HTMLElement) { + folders = append(folders, e.Text) + }) + + c.OnHTML(".torrent-file-list", func(e *colly.HTMLElement) { + files = append(files, e.ChildText("li")) + }) + + var e error + c.OnError(func(r *colly.Response, err error) { + e = err + }) + if e != nil { + return nil, e + } + + c.Visit(viewURL) + + if len(folders) == 0 { + return files, nil + } + + return folders, nil +} + +func TorrentMagnet(viewURL string) (string, error) { + var magnetLink string + + c := colly.NewCollector() + + c.OnHTML("a.card-footer-item", func(e *colly.HTMLElement) { + magnetLink = e.Attr("href") + }) + + var e error + c.OnError(func(r *colly.Response, err error) { + e = err + }) + if e != nil { + return "", e + } + + c.Visit(viewURL) + + if magnetLink == "" { + return "", errors.New("magnet link not found") + } + + return magnetLink, nil +} + +func TorrentInfo(viewURL string) (title string, seeders int, leechers int, completed int, formattedSize string, infoHash string, magnetLink string, err error) { + + c := colly.NewCollector() + + c.OnHTML("a.card-footer-item", func(e *colly.HTMLElement) { + magnetLink = e.Attr("href") + }) + + c.OnHTML(".panel-title", func(e *colly.HTMLElement) { + if title == "" { + title = strings.TrimSpace(e.Text) + } + }) + + // Find and extract information from the specified div elements + c.OnHTML(".panel-body", func(e *colly.HTMLElement) { + + if seeders == 0 { + // Extract seeders + e.ForEach("div:contains('Seeders:') span", func(_ int, el *colly.HTMLElement) { + if el.Attr("style") == "color: green;" { + seeders, _ = strconv.Atoi(el.Text) + } + }) + } + + if leechers == 0 { + // Extract leechers + e.ForEach("div:contains('Leechers:') span", func(_ int, el *colly.HTMLElement) { + if el.Attr("style") == "color: red;" { + leechers, _ = strconv.Atoi(el.Text) + } + }) + } + + if completed == 0 { + // Extract completed + e.ForEach("div:contains('Completed:')", func(_ int, el *colly.HTMLElement) { + completed, _ = strconv.Atoi(el.DOM.Parent().Find("div").Next().Next().Next().Text()) + }) + } + + if formattedSize == "" { + // Extract completed + e.ForEach("div:contains('File size:')", func(_ int, el *colly.HTMLElement) { + text := el.DOM.Parent().ChildrenFiltered("div:nth-child(2)").Text() + if !strings.Contains(text, "\t") { + formattedSize = text + } + }) + } + + if infoHash == "" { + // Extract info hash + e.ForEach("div:contains('Info hash:') kbd", func(_ int, el *colly.HTMLElement) { + infoHash = el.Text + }) + } + }) + + var e error + c.OnError(func(r *colly.Response, err error) { + e = err + }) + if e != nil { + err = e + return + } + + _ = c.Visit(viewURL) + + if magnetLink == "" { + err = errors.New("magnet link not found") + return + } + + return +} + +func TorrentHash(viewURL string) (string, error) { + magnet, err := TorrentMagnet(viewURL) + if err != nil { + return "", err + } + + re := regexp.MustCompile(`magnet:\?xt=urn:btih:([^&]+)`) + match := re.FindStringSubmatch(magnet) + if len(match) > 1 { + return match[1], nil + } + return "", errors.New("could not extract hash") +} diff --git a/seanime-2.9.10/internal/torrents/nyaa/scraping_test.go b/seanime-2.9.10/internal/torrents/nyaa/scraping_test.go new file mode 100644 index 0000000..c79b4e9 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/nyaa/scraping_test.go @@ -0,0 +1,54 @@ +package nyaa + +import ( + "seanime/internal/util" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" +) + +func TestTorrentFiles(t *testing.T) { + + files, err := TorrentFiles(util.Decode("aHR0cHM6Ly9ueWFhLnNpL3ZpZXcvMTU0MjA1Nw==")) // durarara complete series + assert.NoError(t, err) + + t.Log(spew.Sdump(files)) + assert.NotEmpty(t, files) + +} + +func TestTorrentMagnet(t *testing.T) { + + magnet, err := TorrentMagnet(util.Decode("aHR0cHM6Ly9ueWFhLnNpL3ZpZXcvMTg4Njg4Ng==")) + assert.NoError(t, err) + + t.Log(magnet) + assert.NotEmpty(t, magnet) + +} + +func TestTorrentInfo(t *testing.T) { + + title, a, b, c, fs, d, e, err := TorrentInfo(util.Decode("aHR0cHM6Ly9ueWFhLnNpL3ZpZXcvMTcyNzkyMg==")) + assert.NoError(t, err) + + t.Logf("Title: %s\n", title) + t.Logf("Seeders: %d\n", a) + t.Logf("Leechers: %d\n", b) + t.Logf("Downloads: %d\n", c) + t.Logf("Formatted Size: %s\n", fs) + t.Logf("Info Hash: %s\n", d) + t.Logf("Download link: %s\n", e) + +} + +func TestTorrentHash(t *testing.T) { + + hash, err := TorrentHash(util.Decode("aHR0cHM6Ly9ueWFhLnNpL3ZpZXcvMTc0MTY5MQ==")) + assert.NoError(t, err) + + t.Log(hash) + assert.NotEmpty(t, hash) + +} diff --git a/seanime-2.9.10/internal/torrents/nyaa/sukebei_provider.go b/seanime-2.9.10/internal/torrents/nyaa/sukebei_provider.go new file mode 100644 index 0000000..14b821e --- /dev/null +++ b/seanime-2.9.10/internal/torrents/nyaa/sukebei_provider.go @@ -0,0 +1,133 @@ +package nyaa + +import ( + "seanime/internal/extension" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "sync" + + "github.com/mmcdole/gofeed" + "github.com/rs/zerolog" +) + +const ( + SukebeiProviderName = "nyaa-sukebei" +) + +type SukebeiProvider struct { + logger *zerolog.Logger + baseUrl string +} + +func NewSukebeiProvider(logger *zerolog.Logger) hibiketorrent.AnimeProvider { + return &SukebeiProvider{ + logger: logger, + } +} + +func (n *SukebeiProvider) SetSavedUserConfig(config extension.SavedUserConfig) { + n.baseUrl, _ = config.Values["apiUrl"] +} + +func (n *SukebeiProvider) GetSettings() hibiketorrent.AnimeProviderSettings { + return hibiketorrent.AnimeProviderSettings{ + Type: hibiketorrent.AnimeProviderTypeSpecial, + CanSmartSearch: false, + SupportsAdult: true, + } +} + +func (n *SukebeiProvider) GetLatest() (ret []*hibiketorrent.AnimeTorrent, err error) { + fp := gofeed.NewParser() + + url, err := buildURL(n.baseUrl, BuildURLOptions{ + Provider: "sukebei", + Query: "", + Category: "art-anime", + SortBy: "seeders", + Filter: "", + }) + if err != nil { + return nil, err + } + + // get content + feed, err := fp.ParseURL(url) + if err != nil { + return nil, err + } + + // parse content + res := convertRSS(feed) + + wg := sync.WaitGroup{} + mu := sync.Mutex{} + + for _, torrent := range res { + wg.Add(1) + go func(torrent Torrent) { + defer wg.Done() + mu.Lock() + ret = append(ret, torrent.toAnimeTorrent(SukebeiProviderName)) + mu.Unlock() + }(torrent) + } + + wg.Wait() + + return +} + +func (n *SukebeiProvider) Search(opts hibiketorrent.AnimeSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) { + fp := gofeed.NewParser() + + n.logger.Trace().Str("query", opts.Query).Msg("nyaa: Search query") + + url, err := buildURL(n.baseUrl, BuildURLOptions{ + Provider: "sukebei", + Query: opts.Query, + Category: "art-anime", + SortBy: "seeders", + Filter: "", + }) + if err != nil { + return nil, err + } + + // get content + feed, err := fp.ParseURL(url) + if err != nil { + return nil, err + } + + // parse content + res := convertRSS(feed) + + wg := sync.WaitGroup{} + mu := sync.Mutex{} + + for _, torrent := range res { + wg.Add(1) + go func(torrent Torrent) { + defer wg.Done() + mu.Lock() + ret = append(ret, torrent.toAnimeTorrent(SukebeiProviderName)) + mu.Unlock() + }(torrent) + } + + wg.Wait() + + return +} + +func (n *SukebeiProvider) SmartSearch(opts hibiketorrent.AnimeSmartSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) { + return +} + +func (n *SukebeiProvider) GetTorrentInfoHash(torrent *hibiketorrent.AnimeTorrent) (string, error) { + return TorrentHash(torrent.Link) +} + +func (n *SukebeiProvider) GetTorrentMagnetLink(torrent *hibiketorrent.AnimeTorrent) (string, error) { + return TorrentMagnet(torrent.Link) +} diff --git a/seanime-2.9.10/internal/torrents/seadex/provider.go b/seanime-2.9.10/internal/torrents/seadex/provider.go new file mode 100644 index 0000000..a9c56e7 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/seadex/provider.go @@ -0,0 +1,149 @@ +package seadex + +import ( + "context" + "github.com/5rahim/habari" + "github.com/rs/zerolog" + "net/http" + "seanime/internal/torrents/nyaa" + "sync" + "time" + + hibiketorrent "seanime/internal/extension/hibike/torrent" +) + +const ( + ProviderName = "seadex" +) + +type Provider struct { + logger *zerolog.Logger + seadex *SeaDex +} + +func NewProvider(logger *zerolog.Logger) hibiketorrent.AnimeProvider { + return &Provider{ + logger: logger, + seadex: New(logger), + } +} + +func (n *Provider) GetSettings() hibiketorrent.AnimeProviderSettings { + return hibiketorrent.AnimeProviderSettings{ + Type: hibiketorrent.AnimeProviderTypeSpecial, + CanSmartSearch: true, // Setting to true to allow previews + SupportsAdult: false, + } +} + +func (n *Provider) GetType() hibiketorrent.AnimeProviderType { + return hibiketorrent.AnimeProviderTypeSpecial +} + +func (n *Provider) GetLatest() (ret []*hibiketorrent.AnimeTorrent, err error) { + return +} + +func (n *Provider) Search(opts hibiketorrent.AnimeSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) { + return n.findTorrents(&opts.Media) +} + +func (n *Provider) SmartSearch(opts hibiketorrent.AnimeSmartSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) { + return n.findTorrents(&opts.Media) +} + +func (n *Provider) findTorrents(media *hibiketorrent.Media) (ret []*hibiketorrent.AnimeTorrent, err error) { + seadexTorrents, err := n.seadex.FetchTorrents(media.ID, media.RomajiTitle) + if err != nil { + return nil, err + } + + wg := sync.WaitGroup{} + mu := sync.Mutex{} + wg.Add(len(seadexTorrents)) + + for _, t := range seadexTorrents { + go func(t *Torrent) { + defer wg.Done() + mu.Lock() + ret = append(ret, t.toAnimeTorrent(ProviderName)) + mu.Unlock() + }(t) + } + + wg.Wait() + + return +} + +//--------------------------------------------------------------------------------------------------------------------------------------------------// + +func (n *Provider) GetTorrentInfoHash(torrent *hibiketorrent.AnimeTorrent) (string, error) { + return torrent.MagnetLink, nil +} + +func (n *Provider) GetTorrentMagnetLink(torrent *hibiketorrent.AnimeTorrent) (string, error) { + return nyaa.TorrentMagnet(torrent.Link) +} + +func (t *Torrent) toAnimeTorrent(providerName string) *hibiketorrent.AnimeTorrent { + metadata := habari.Parse(t.Name) + + ret := &hibiketorrent.AnimeTorrent{ + Name: t.Name, + Date: t.Date, + Size: 0, // Should be scraped + FormattedSize: "", // Should be scraped + Seeders: 0, // Should be scraped + Leechers: 0, // Should be scraped + DownloadCount: 0, // Should be scraped + Link: t.Link, + DownloadUrl: "", // Should be scraped + InfoHash: t.InfoHash, + MagnetLink: "", // Should be scraped + Resolution: "", // Should be parsed + IsBatch: true, // Should be parsed + EpisodeNumber: -1, // Should be parsed + ReleaseGroup: "", // Should be parsed + Provider: providerName, + IsBestRelease: true, + Confirmed: true, + } + + var seeders, leechers, downloads int + var title, downloadUrl, formattedSize string + + // Try scraping from Nyaa + // Since nyaa tends to be blocked, try for a few seconds only + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if t.Link != "" { + downloadUrl = t.Link + + client := http.DefaultClient + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ret.Link, nil) + if err == nil { + resp, err := client.Do(req) + if err == nil { + defer resp.Body.Close() + + title, seeders, leechers, downloads, formattedSize, _, _, err = nyaa.TorrentInfo(ret.Link) + if err == nil && title != "" { + ret.Name = title // Override title + ret.Seeders = seeders + ret.Leechers = leechers + ret.DownloadCount = downloads + ret.DownloadUrl = downloadUrl + ret.Size = 1 + ret.FormattedSize = formattedSize + } + } + } + } + + ret.Resolution = metadata.VideoResolution + ret.ReleaseGroup = metadata.ReleaseGroup + + return ret +} diff --git a/seanime-2.9.10/internal/torrents/seadex/seadex.go b/seanime-2.9.10/internal/torrents/seadex/seadex.go new file mode 100644 index 0000000..ae4ab8a --- /dev/null +++ b/seanime-2.9.10/internal/torrents/seadex/seadex.go @@ -0,0 +1,109 @@ +package seadex + +import ( + "fmt" + "net/http" + "seanime/internal/extension" + "seanime/internal/util" + "strings" + + "github.com/goccy/go-json" + "github.com/rs/zerolog" +) + +type ( + SeaDex struct { + logger *zerolog.Logger + uri string + } + + Torrent struct { + Name string `json:"name"` + Date string `json:"date"` + Size int64 `json:"size"` + Link string `json:"link"` + InfoHash string `json:"infoHash"` + ReleaseGroup string `json:"releaseGroup,omitempty"` + } +) + +func New(logger *zerolog.Logger) *SeaDex { + return &SeaDex{ + logger: logger, + uri: util.Decode("aHR0cHM6Ly9yZWxlYXNlcy5tb2UvYXBpL2NvbGxlY3Rpb25zL2VudHJpZXMvcmVjb3Jkcw=="), + } +} + +func (s *SeaDex) SetSavedUserConfig(savedConfig *extension.SavedUserConfig) { + url, _ := savedConfig.Values["apiUrl"] + if url != "" { + s.uri = url + } +} + +func (s *SeaDex) FetchTorrents(mediaId int, title string) (ret []*Torrent, err error) { + + ret = make([]*Torrent, 0) + + records, err := s.fetchRecords(mediaId) + if err != nil { + return nil, err + } + + if len(records) == 0 { + return ret, nil + } + + if len(records[0].Expand.Trs) == 0 { + return ret, nil + } + for _, tr := range records[0].Expand.Trs { + if tr.InfoHash == "" || tr.InfoHash == "" || tr.Tracker != "Nyaa" || !strings.Contains(tr.URL, "nyaa.si") { + continue + } + ret = append(ret, &Torrent{ + Name: fmt.Sprintf("[%s] %s%s", tr.ReleaseGroup, title, map[bool]string{true: " [Dual-Audio]", false: ""}[tr.DualAudio]), + Date: tr.Created, + Size: int64(s.getTorrentSize(tr.Files)), + Link: tr.URL, + InfoHash: tr.InfoHash, + ReleaseGroup: tr.ReleaseGroup, + }) + } + + return ret, nil + +} + +func (s *SeaDex) fetchRecords(mediaId int) (ret []*RecordItem, err error) { + + uri := fmt.Sprintf("%s?page=1&perPage=1&filter=alID%%3D%%22%d%%22&skipTotal=1&expand=trs", s.uri, mediaId) + + resp, err := http.Get(uri) + if err != nil { + s.logger.Error().Err(err).Msgf("seadex: error getting media records: %v", mediaId) + return nil, err + } + defer resp.Body.Close() + + var res RecordsResponse + if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { + s.logger.Error().Err(err).Msgf("seadex: error decoding response: %v", mediaId) + return nil, err + } + + return res.Items, nil +} + +func (s *SeaDex) getTorrentSize(fls []*TrFile) int { + if fls == nil || len(fls) == 0 { + return 0 + } + + var size int + for _, f := range fls { + size += f.Length + } + + return size +} diff --git a/seanime-2.9.10/internal/torrents/seadex/seadex_test.go b/seanime-2.9.10/internal/torrents/seadex/seadex_test.go new file mode 100644 index 0000000..3750011 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/seadex/seadex_test.go @@ -0,0 +1,48 @@ +package seadex + +import ( + "context" + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + "seanime/internal/api/anilist" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" +) + +func TestSeaDex(t *testing.T) { + test_utils.InitTestProvider(t, test_utils.Anilist()) + + anilistClient := anilist.TestGetMockAnilistClient() + + tests := []struct { + name string + mediaId int + }{ + { + name: "86 - Eighty Six Part 2", + mediaId: 131586, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + mediaF, err := anilistClient.BaseAnimeByID(context.Background(), &tt.mediaId) + if assert.NoErrorf(t, err, "error getting media: %v", tt.mediaId) { + + media := mediaF.GetMedia() + + torrents, err := New(util.NewLogger()).FetchTorrents(tt.mediaId, media.GetRomajiTitleSafe()) + if assert.NoErrorf(t, err, "error fetching records: %v", tt.mediaId) { + + spew.Dump(torrents) + + } + + } + + }) + } + +} diff --git a/seanime-2.9.10/internal/torrents/seadex/types.go b/seanime-2.9.10/internal/torrents/seadex/types.go new file mode 100644 index 0000000..24a4ff3 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/seadex/types.go @@ -0,0 +1,42 @@ +package seadex + +type ( + RecordsResponse struct { + Items []*RecordItem `json:"items"` + } + + RecordItem struct { + AlID int `json:"alID"` + CollectionID string `json:"collectionId"` + CollectionName string `json:"collectionName"` + Comparison string `json:"comparison"` + Created string `json:"created"` + Expand struct { + Trs []*Tr `json:"trs"` + } `json:"expand"` + Trs []string `json:"trs"` + Updated string `json:"updated"` + ID string `json:"id"` + Incomplete bool `json:"incomplete"` + Notes string `json:"notes"` + TheoreticalBest string `json:"theoreticalBest"` + } + + Tr struct { + Created string `json:"created"` + CollectionID string `json:"collectionId"` + CollectionName string `json:"collectionName"` + DualAudio bool `json:"dualAudio"` + Files []*TrFile `json:"files"` + ID string `json:"id"` + InfoHash string `json:"infoHash"` + IsBest bool `json:"isBest"` + ReleaseGroup string `json:"releaseGroup"` + Tracker string `json:"tracker"` + URL string `json:"url"` + } + TrFile struct { + Length int `json:"length"` + Name string `json:"name"` + } +) diff --git a/seanime-2.9.10/internal/torrents/torrent/README.md b/seanime-2.9.10/internal/torrents/torrent/README.md new file mode 100644 index 0000000..4a7b6b4 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/torrent/README.md @@ -0,0 +1,2 @@ +Do not import: +- torrent_client diff --git a/seanime-2.9.10/internal/torrents/torrent/repository.go b/seanime-2.9.10/internal/torrents/torrent/repository.go new file mode 100644 index 0000000..51aea09 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/torrent/repository.go @@ -0,0 +1,142 @@ +package torrent + +import ( + "seanime/internal/api/metadata" + "seanime/internal/extension" + "seanime/internal/util/result" + "sync" + + "github.com/rs/zerolog" +) + +type ( + Repository struct { + logger *zerolog.Logger + extensionBank *extension.UnifiedBank + animeProviderSearchCaches *result.Map[string, *result.Cache[string, *SearchData]] + animeProviderSmartSearchCaches *result.Map[string, *result.Cache[string, *SearchData]] + settings RepositorySettings + metadataProvider metadata.Provider + mu sync.Mutex + } + + RepositorySettings struct { + DefaultAnimeProvider string // Default torrent provider + } +) + +type NewRepositoryOptions struct { + Logger *zerolog.Logger + MetadataProvider metadata.Provider +} + +func NewRepository(opts *NewRepositoryOptions) *Repository { + ret := &Repository{ + logger: opts.Logger, + metadataProvider: opts.MetadataProvider, + extensionBank: extension.NewUnifiedBank(), + animeProviderSearchCaches: result.NewResultMap[string, *result.Cache[string, *SearchData]](), + animeProviderSmartSearchCaches: result.NewResultMap[string, *result.Cache[string, *SearchData]](), + settings: RepositorySettings{}, + mu: sync.Mutex{}, + } + + return ret +} + +func (r *Repository) InitExtensionBank(bank *extension.UnifiedBank) { + r.mu.Lock() + defer r.mu.Unlock() + r.extensionBank = bank + + go func() { + for { + select { + case <-bank.OnExtensionAdded(): + //r.logger.Debug().Msg("torrent repo: Anime provider extension added") + r.OnExtensionReloaded() + } + } + }() + + go func() { + for { + select { + case <-bank.OnExtensionRemoved(): + r.OnExtensionReloaded() + } + } + }() + + r.logger.Debug().Msg("torrent repo: Initialized anime provider extension bank") +} + +func (r *Repository) OnExtensionReloaded() { + r.mu.Lock() + defer r.mu.Unlock() + r.reloadExtensions() +} + +// This is called each time a new extension is added or removed +func (r *Repository) reloadExtensions() { + // Clear the search caches + r.animeProviderSearchCaches = result.NewResultMap[string, *result.Cache[string, *SearchData]]() + r.animeProviderSmartSearchCaches = result.NewResultMap[string, *result.Cache[string, *SearchData]]() + + go func() { + // Create new caches for each provider + extension.RangeExtensions(r.extensionBank, func(provider string, value extension.AnimeTorrentProviderExtension) bool { + r.animeProviderSearchCaches.Set(provider, result.NewCache[string, *SearchData]()) + r.animeProviderSmartSearchCaches.Set(provider, result.NewCache[string, *SearchData]()) + return true + }) + }() + + // Check if the default provider is in the list of providers + //if r.settings.DefaultAnimeProvider != "" && r.settings.DefaultAnimeProvider != "none" { + // if _, ok := r.extensionBank.Get(r.settings.DefaultAnimeProvider); !ok { + // //r.logger.Error().Str("defaultProvider", r.settings.DefaultAnimeProvider).Msg("torrent repo: Default torrent provider not found in extensions") + // // Set the default provider to empty + // r.settings.DefaultAnimeProvider = "" + // } + //} + + //r.logger.Trace().Str("defaultProvider", r.settings.DefaultAnimeProvider).Msg("torrent repo: Reloaded extensions") +} + +// SetSettings should be called after the repository is created and settings are refreshed +func (r *Repository) SetSettings(s *RepositorySettings) { + r.mu.Lock() + defer r.mu.Unlock() + + r.logger.Trace().Msg("torrent repo: Setting settings") + + if s != nil { + r.settings = *s + } else { + r.settings = RepositorySettings{ + DefaultAnimeProvider: "", + } + } + + if r.settings.DefaultAnimeProvider == "none" { + r.settings.DefaultAnimeProvider = "" + } + + // Reload extensions after settings change + r.reloadExtensions() +} + +func (r *Repository) GetDefaultAnimeProviderExtension() (extension.AnimeTorrentProviderExtension, bool) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.settings.DefaultAnimeProvider == "" { + return nil, false + } + return extension.GetExtension[extension.AnimeTorrentProviderExtension](r.extensionBank, r.settings.DefaultAnimeProvider) +} + +func (r *Repository) GetAnimeProviderExtension(id string) (extension.AnimeTorrentProviderExtension, bool) { + return extension.GetExtension[extension.AnimeTorrentProviderExtension](r.extensionBank, id) +} diff --git a/seanime-2.9.10/internal/torrents/torrent/repository_test.go b/seanime-2.9.10/internal/torrents/torrent/repository_test.go new file mode 100644 index 0000000..378e718 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/torrent/repository_test.go @@ -0,0 +1,67 @@ +package torrent + +import ( + "seanime/internal/api/metadata" + "seanime/internal/extension" + "seanime/internal/torrents/animetosho" + "seanime/internal/torrents/nyaa" + "seanime/internal/torrents/seadex" + "seanime/internal/util" + "testing" +) + +func getTestRepo(t *testing.T) *Repository { + logger := util.NewLogger() + metadataProvider := metadata.GetMockProvider(t) + + extensionBank := extension.NewUnifiedBank() + + extensionBank.Set("nyaa", extension.NewAnimeTorrentProviderExtension(&extension.Extension{ + ID: "nyaa", + Name: "Nyaa", + Version: "1.0.0", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + }, nyaa.NewProvider(logger, nyaa.CategoryAnimeEng))) + + extensionBank.Set("nyaa-sukebei", extension.NewAnimeTorrentProviderExtension(&extension.Extension{ + ID: "nyaa-sukebei", + Name: "Nyaa Sukebei", + Version: "1.0.0", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + }, nyaa.NewSukebeiProvider(logger))) + + extensionBank.Set("animetosho", extension.NewAnimeTorrentProviderExtension(&extension.Extension{ + ID: "animetosho", + Name: "AnimeTosho", + Version: "1.0.0", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + }, animetosho.NewProvider(logger))) + + extensionBank.Set("seadex", extension.NewAnimeTorrentProviderExtension(&extension.Extension{ + ID: "seadex", + Name: "SeaDex", + Version: "1.0.0", + Language: extension.LanguageGo, + Type: extension.TypeAnimeTorrentProvider, + Author: "Seanime", + }, seadex.NewProvider(logger))) + + repo := NewRepository(&NewRepositoryOptions{ + Logger: logger, + MetadataProvider: metadataProvider, + }) + + repo.InitExtensionBank(extensionBank) + + repo.SetSettings(&RepositorySettings{ + DefaultAnimeProvider: ProviderAnimeTosho, + }) + + return repo +} diff --git a/seanime-2.9.10/internal/torrents/torrent/search.go b/seanime-2.9.10/internal/torrents/torrent/search.go new file mode 100644 index 0000000..85b4a80 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/torrent/search.go @@ -0,0 +1,463 @@ +package torrent + +import ( + "cmp" + "context" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/debrid/debrid" + "seanime/internal/extension" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/library/anime" + "seanime/internal/util" + "seanime/internal/util/comparison" + "seanime/internal/util/result" + "slices" + "strconv" + "sync" + + "github.com/5rahim/habari" + "github.com/samber/lo" + "github.com/samber/mo" +) + +const ( + AnimeSearchTypeSmart AnimeSearchType = "smart" + AnimeSearchTypeSimple AnimeSearchType = "simple" +) + +var ( + metadataCache = result.NewResultMap[string, *TorrentMetadata]() +) + +type ( + AnimeSearchType string + + AnimeSearchOptions struct { + // Provider extension ID + Provider string + Type AnimeSearchType + Media *anilist.BaseAnime + // Search options + Query string + // Filter options + Batch bool + EpisodeNumber int + BestReleases bool + Resolution string + } + + // Preview contains the torrent and episode information + Preview struct { + Episode *anime.Episode `json:"episode"` // nil if batch + Torrent *hibiketorrent.AnimeTorrent `json:"torrent"` + } + + TorrentMetadata struct { + Distance int `json:"distance"` + Metadata *habari.Metadata `json:"metadata"` + } + + // SearchData is the struct returned by NewSmartSearch + SearchData struct { + Torrents []*hibiketorrent.AnimeTorrent `json:"torrents"` // Torrents found + Previews []*Preview `json:"previews"` // TorrentPreview for each torrent + TorrentMetadata map[string]*TorrentMetadata `json:"torrentMetadata"` // Torrent metadata + DebridInstantAvailability map[string]debrid.TorrentItemInstantAvailability `json:"debridInstantAvailability"` // Debrid instant availability + AnimeMetadata *metadata.AnimeMetadata `json:"animeMetadata"` // Animap media + } +) + +func (r *Repository) SearchAnime(ctx context.Context, opts AnimeSearchOptions) (ret *SearchData, err error) { + defer util.HandlePanicInModuleWithError("torrents/torrent/SearchAnime", &err) + + r.logger.Debug().Str("provider", opts.Provider).Str("type", string(opts.Type)).Str("query", opts.Query).Msg("torrent repo: Searching for anime torrents") + + // Find the provider by ID + providerExtension, ok := extension.GetExtension[extension.AnimeTorrentProviderExtension](r.extensionBank, opts.Provider) + if !ok { + // Get the default provider + providerExtension, ok = r.GetDefaultAnimeProviderExtension() + if !ok { + return nil, fmt.Errorf("torrent provider not found") + } + } + + if opts.Type == AnimeSearchTypeSmart && !providerExtension.GetProvider().GetSettings().CanSmartSearch { + return nil, fmt.Errorf("provider does not support smart search") + } + + var torrents []*hibiketorrent.AnimeTorrent + + // Fetch Animap media + animeMetadata := mo.None[*metadata.AnimeMetadata]() + animeMetadataF, err := r.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, opts.Media.GetID()) + if err == nil { + animeMetadata = mo.Some(animeMetadataF) + } + + queryMedia := hibiketorrent.Media{ + ID: opts.Media.GetID(), + IDMal: opts.Media.GetIDMal(), + Status: string(*opts.Media.GetStatus()), + Format: string(*opts.Media.GetFormat()), + EnglishTitle: opts.Media.GetTitle().GetEnglish(), + RomajiTitle: opts.Media.GetRomajiTitleSafe(), + EpisodeCount: opts.Media.GetTotalEpisodeCount(), + AbsoluteSeasonOffset: 0, + Synonyms: opts.Media.GetSynonymsContainingSeason(), + IsAdult: *opts.Media.GetIsAdult(), + StartDate: &hibiketorrent.FuzzyDate{ + Year: *opts.Media.GetStartDate().GetYear(), + Month: opts.Media.GetStartDate().GetMonth(), + Day: opts.Media.GetStartDate().GetDay(), + }, + } + + //// Force simple search if Animap media is absent + //if opts.Type == AnimeSearchTypeSmart && animeMetadata.IsAbsent() { + // opts.Type = AnimeSearchTypeSimple + //} + + var queryKey string + + switch opts.Type { + case AnimeSearchTypeSmart: + anidbAID := 0 + anidbEID := 0 + + // Get the AniDB Anime ID and Episode ID + if animeMetadata.IsPresent() { + // Override absolute offset value of queryMedia + queryMedia.AbsoluteSeasonOffset = animeMetadata.MustGet().GetOffset() + + if animeMetadata.MustGet().GetMappings() != nil { + + anidbAID = animeMetadata.MustGet().GetMappings().AnidbId + // Find Animap Episode based on inputted episode number + episodeMetadata, found := animeMetadata.MustGet().FindEpisode(strconv.Itoa(opts.EpisodeNumber)) + if found { + anidbEID = episodeMetadata.AnidbEid + } + } + } + + queryKey = fmt.Sprintf("%d-%s-%d-%d-%d-%s-%t-%t", opts.Media.GetID(), opts.Query, opts.EpisodeNumber, anidbAID, anidbEID, opts.Resolution, opts.BestReleases, opts.Batch) + if cache, found := r.animeProviderSmartSearchCaches.Get(opts.Provider); found { + // Check the cache + data, found := cache.Get(queryKey) + if found { + r.logger.Debug().Str("provider", opts.Provider).Str("type", string(opts.Type)).Msg("torrent repo: Cache HIT") + return data, nil + } + } + + // Check for context cancellation before making the request + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + torrents, err = providerExtension.GetProvider().SmartSearch(hibiketorrent.AnimeSmartSearchOptions{ + Media: queryMedia, + Query: opts.Query, + Batch: opts.Batch, + EpisodeNumber: opts.EpisodeNumber, + Resolution: opts.Resolution, + AnidbAID: anidbAID, + AnidbEID: anidbEID, + BestReleases: opts.BestReleases, + }) + + torrents = lo.UniqBy(torrents, func(t *hibiketorrent.AnimeTorrent) string { + return t.InfoHash + }) + + case AnimeSearchTypeSimple: + + queryKey = fmt.Sprintf("%d-%s", opts.Media.GetID(), opts.Query) + if cache, found := r.animeProviderSearchCaches.Get(opts.Provider); found { + // Check the cache + data, found := cache.Get(queryKey) + if found { + r.logger.Debug().Str("provider", opts.Provider).Str("type", string(opts.Type)).Msg("torrent repo: Cache HIT") + return data, nil + } + } + + // Check for context cancellation before making the request + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + torrents, err = providerExtension.GetProvider().Search(hibiketorrent.AnimeSearchOptions{ + Media: queryMedia, + Query: opts.Query, + }) + } + if err != nil { + return nil, err + } + + // + // Torrent metadata + // + torrentMetadata := make(map[string]*TorrentMetadata) + mu := sync.Mutex{} + wg := sync.WaitGroup{} + wg.Add(len(torrents)) + for _, t := range torrents { + go func(t *hibiketorrent.AnimeTorrent) { + defer wg.Done() + metadata, found := metadataCache.Get(t.Name) + if !found { + m := habari.Parse(t.Name) + var distance *comparison.LevenshteinResult + distance, ok := comparison.FindBestMatchWithLevenshtein(&m.Title, opts.Media.GetAllTitles()) + if !ok { + distance = &comparison.LevenshteinResult{ + Distance: 1000, + } + } + metadata = &TorrentMetadata{ + Distance: distance.Distance, + Metadata: m, + } + metadataCache.Set(t.Name, metadata) + } + mu.Lock() + torrentMetadata[t.InfoHash] = metadata + mu.Unlock() + }(t) + } + wg.Wait() + + // + // Previews + // + previews := make([]*Preview, 0) + + if opts.Type == AnimeSearchTypeSmart { + + wg := sync.WaitGroup{} + wg.Add(len(torrents)) + for _, t := range torrents { + go func(t *hibiketorrent.AnimeTorrent) { + defer wg.Done() + + // Check for context cancellation in each goroutine + select { + case <-ctx.Done(): + return + default: + } + + preview := r.createAnimeTorrentPreview(createAnimeTorrentPreviewOptions{ + torrent: t, + media: opts.Media, + animeMetadata: animeMetadata, + searchOpts: &opts, + }) + if preview != nil { + previews = append(previews, preview) + } + }(t) + } + wg.Wait() + + // Check if context was cancelled during preview creation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + } + + // sort both by seeders + slices.SortFunc(torrents, func(i, j *hibiketorrent.AnimeTorrent) int { + return cmp.Compare(j.Seeders, i.Seeders) + }) + previews = lo.Filter(previews, func(p *Preview, _ int) bool { + return p != nil && p.Torrent != nil + }) + slices.SortFunc(previews, func(i, j *Preview) int { + return cmp.Compare(j.Torrent.Seeders, i.Torrent.Seeders) + }) + + ret = &SearchData{ + Torrents: torrents, + Previews: previews, + TorrentMetadata: torrentMetadata, + } + + if animeMetadata.IsPresent() { + ret.AnimeMetadata = animeMetadata.MustGet() + } + + // Store the data in the cache + switch opts.Type { + case AnimeSearchTypeSmart: + if cache, found := r.animeProviderSmartSearchCaches.Get(opts.Provider); found { + cache.Set(queryKey, ret) + } + case AnimeSearchTypeSimple: + if cache, found := r.animeProviderSearchCaches.Get(opts.Provider); found { + cache.Set(queryKey, ret) + } + } + + return +} + +type createAnimeTorrentPreviewOptions struct { + torrent *hibiketorrent.AnimeTorrent + media *anilist.BaseAnime + animeMetadata mo.Option[*metadata.AnimeMetadata] + searchOpts *AnimeSearchOptions +} + +func (r *Repository) createAnimeTorrentPreview(opts createAnimeTorrentPreviewOptions) *Preview { + + var parsedData *habari.Metadata + metadata, found := metadataCache.Get(opts.torrent.Name) + if !found { // Should always be found + parsedData = habari.Parse(opts.torrent.Name) + metadataCache.Set(opts.torrent.Name, &TorrentMetadata{ + Distance: 1000, + Metadata: parsedData, + }) + } + parsedData = metadata.Metadata + + isBatch := opts.torrent.IsBestRelease || + opts.torrent.IsBatch || + comparison.ValueContainsBatchKeywords(opts.torrent.Name) || // Contains batch keywords + (!opts.media.IsMovieOrSingleEpisode() && len(parsedData.EpisodeNumber) > 1) // Multiple episodes parsed & not a movie + + if opts.torrent.ReleaseGroup == "" { + opts.torrent.ReleaseGroup = parsedData.ReleaseGroup + } + + if opts.torrent.Resolution == "" { + opts.torrent.Resolution = parsedData.VideoResolution + } + + if opts.torrent.FormattedSize == "" { + opts.torrent.FormattedSize = util.Bytes(uint64(opts.torrent.Size)) + } + + if isBatch { + return &Preview{ + Episode: nil, // Will be displayed as batch + Torrent: opts.torrent, + } + } + + // If past this point we haven't detected a batch but the episode number returned from the provider is -1 + // we will parse it from the torrent name + if opts.torrent.EpisodeNumber == -1 && len(parsedData.EpisodeNumber) == 1 { + opts.torrent.EpisodeNumber = util.StringToIntMust(parsedData.EpisodeNumber[0]) + } + + // If the torrent is confirmed, use the episode number from the search options + // because it could be absolute + if opts.torrent.Confirmed { + opts.torrent.EpisodeNumber = opts.searchOpts.EpisodeNumber + } + + // If there was no single episode number parsed but the media is movie, set the episode number to 1 + if opts.torrent.EpisodeNumber == -1 && opts.media.IsMovieOrSingleEpisode() { + opts.torrent.EpisodeNumber = 1 + } + + if opts.animeMetadata.IsPresent() { + + // normalize episode number + if opts.torrent.EpisodeNumber >= 0 && opts.torrent.EpisodeNumber > opts.media.GetCurrentEpisodeCount() { + opts.torrent.EpisodeNumber = opts.torrent.EpisodeNumber - opts.animeMetadata.MustGet().GetOffset() + } + + animeMetadata := opts.animeMetadata.MustGet() + _, foundEp := animeMetadata.FindEpisode(strconv.Itoa(opts.searchOpts.EpisodeNumber)) + + if foundEp { + var episode *anime.Episode + + // Remove the episode if the parsed episode number is not the same as the search option + if isProbablySameEpisode(parsedData.EpisodeNumber, opts.searchOpts.EpisodeNumber, opts.animeMetadata.MustGet().GetOffset()) { + ep := opts.searchOpts.EpisodeNumber + episode = anime.NewEpisode(&anime.NewEpisodeOptions{ + LocalFile: nil, + OptionalAniDBEpisode: strconv.Itoa(ep), + AnimeMetadata: animeMetadata, + Media: opts.media, + ProgressOffset: 0, + IsDownloaded: false, + MetadataProvider: r.metadataProvider, + }) + episode.IsInvalid = false + + if episode.DisplayTitle == "" { + episode.DisplayTitle = parsedData.Title + } + } + + return &Preview{ + Episode: episode, + Torrent: opts.torrent, + } + } + + var episode *anime.Episode + + // Remove the episode if the parsed episode number is not the same as the search option + if isProbablySameEpisode(parsedData.EpisodeNumber, opts.searchOpts.EpisodeNumber, opts.animeMetadata.MustGet().GetOffset()) { + displayTitle := "" + if len(parsedData.EpisodeNumber) == 1 && parsedData.EpisodeNumber[0] != strconv.Itoa(opts.searchOpts.EpisodeNumber) { + displayTitle = fmt.Sprintf("Episode %s", parsedData.EpisodeNumber[0]) + } + // If the episode number could not be found in the Animap media, create a new episode + episode = &anime.Episode{ + Type: anime.LocalFileTypeMain, + DisplayTitle: displayTitle, + EpisodeTitle: "", + EpisodeNumber: opts.searchOpts.EpisodeNumber, + ProgressNumber: opts.searchOpts.EpisodeNumber, + AniDBEpisode: "", + AbsoluteEpisodeNumber: 0, + LocalFile: nil, + IsDownloaded: false, + EpisodeMetadata: anime.NewEpisodeMetadata(opts.animeMetadata.MustGet(), nil, opts.media, r.metadataProvider), + FileMetadata: nil, + IsInvalid: false, + MetadataIssue: "", + BaseAnime: opts.media, + } + } + + return &Preview{ + Episode: episode, + Torrent: opts.torrent, + } + + } + + return &Preview{ + Episode: nil, + Torrent: opts.torrent, + } +} + +func isProbablySameEpisode(parsedEpisode []string, searchEpisode int, absoluteOffset int) bool { + if len(parsedEpisode) == 1 { + if util.StringToIntMust(parsedEpisode[0]) == searchEpisode || util.StringToIntMust(parsedEpisode[0]) == searchEpisode+absoluteOffset { + return true + } + } + + return false +} diff --git a/seanime-2.9.10/internal/torrents/torrent/search_test.go b/seanime-2.9.10/internal/torrents/torrent/search_test.go new file mode 100644 index 0000000..8f19bf2 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/torrent/search_test.go @@ -0,0 +1,111 @@ +package torrent + +import ( + "context" + "seanime/internal/api/anilist" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" +) + +func TestSmartSearch(t *testing.T) { + test_utils.InitTestProvider(t) + + anilistClient := anilist.TestGetMockAnilistClient() + logger := util.NewLogger() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + + repo := getTestRepo(t) + + tests := []struct { + smartSearch bool + query string + episodeNumber int + batch bool + mediaId int + absoluteOffset int + resolution string + provider string + }{ + { + smartSearch: true, + query: "", + episodeNumber: 5, + batch: false, + mediaId: 162670, // Dr. Stone S3 + absoluteOffset: 48, + resolution: "1080", + provider: "animetosho", + }, + { + smartSearch: true, + query: "", + episodeNumber: 1, + batch: true, + mediaId: 77, // Mahou Shoujo Lyrical Nanoha A's + absoluteOffset: 0, + resolution: "1080", + provider: "animetosho", + }, + { + smartSearch: true, + query: "", + episodeNumber: 1, + batch: true, + mediaId: 109731, // Hibike Season 3 + absoluteOffset: 0, + resolution: "1080", + provider: "animetosho", + }, + { + smartSearch: true, + query: "", + episodeNumber: 1, + batch: true, + mediaId: 1915, // Magical Girl Lyrical Nanoha StrikerS + absoluteOffset: 0, + resolution: "", + provider: "animetosho", + }, + } + + for _, tt := range tests { + t.Run(tt.query, func(t *testing.T) { + + media, err := anilistPlatform.GetAnime(t.Context(), tt.mediaId) + if err != nil { + t.Fatalf("could not fetch media id %d", tt.mediaId) + } + + data, err := repo.SearchAnime(context.Background(), AnimeSearchOptions{ + Provider: tt.provider, + Type: AnimeSearchTypeSmart, + Media: media, + Query: "", + Batch: tt.batch, + EpisodeNumber: tt.episodeNumber, + BestReleases: false, + Resolution: tt.resolution, + }) + if err != nil { + t.Errorf("NewSmartSearch() failed: %v", err) + } + + t.Log("----------------------- Previews --------------------------") + for _, preview := range data.Previews { + t.Logf("> %s", preview.Torrent.Name) + if preview.Episode != nil { + t.Logf("\t\t %s", preview.Episode.DisplayTitle) + } else { + t.Logf("\t\t Batch") + } + } + t.Log("----------------------- Torrents --------------------------") + for _, torrent := range data.Torrents { + t.Logf("> %s", torrent.Name) + } + + }) + } +} diff --git a/seanime-2.9.10/internal/torrents/torrent/torrent.go b/seanime-2.9.10/internal/torrents/torrent/torrent.go new file mode 100644 index 0000000..6eaf013 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/torrent/torrent.go @@ -0,0 +1,7 @@ +package torrent + +const ( + ProviderNyaa = "nyaa" + ProviderAnimeTosho = "animetosho" + ProviderNone = "none" +) diff --git a/seanime-2.9.10/internal/torrents/torrent/utils.go b/seanime-2.9.10/internal/torrents/torrent/utils.go new file mode 100644 index 0000000..240e2e4 --- /dev/null +++ b/seanime-2.9.10/internal/torrents/torrent/utils.go @@ -0,0 +1,20 @@ +package torrent + +import ( + "bytes" + "github.com/anacrolix/torrent/metainfo" +) + +func StrDataToMagnetLink(data string) (string, error) { + meta, err := metainfo.Load(bytes.NewReader([]byte(data))) + if err != nil { + return "", err + } + + magnetLink, err := meta.MagnetV2() + if err != nil { + return "", err + } + + return magnetLink.String(), nil +} diff --git a/seanime-2.9.10/internal/torrents/torrent/utils_test.go b/seanime-2.9.10/internal/torrents/torrent/utils_test.go new file mode 100644 index 0000000..34bbcab --- /dev/null +++ b/seanime-2.9.10/internal/torrents/torrent/utils_test.go @@ -0,0 +1,43 @@ +package torrent + +import ( + "io" + "net/http" + "testing" +) + +func TestFileToMagnetLink(t *testing.T) { + + tests := []struct { + name string + url string + }{ + { + name: "1", + url: "https://animetosho.org/storage/torrent/da9aad67b6f8bb82757bb3ef95235b42624c34f7/%5BSubsPlease%5D%20Make%20Heroine%20ga%20Oosugiru%21%20-%2011%20%281080p%29%20%5B58B3496A%5D.torrent", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := http.Client{} + resp, err := client.Get(test.url) + if err != nil { + t.Fatalf("Error: %v", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error: %v", err) + } + + magnet, err := StrDataToMagnetLink(string(data)) + if err != nil { + t.Fatalf("Error: %v", err) + } + t.Log(magnet) + }) + } + +} diff --git a/seanime-2.9.10/internal/torrentstream/client.go b/seanime-2.9.10/internal/torrentstream/client.go new file mode 100644 index 0000000..d79acf8 --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/client.go @@ -0,0 +1,489 @@ +package torrentstream + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/util" + "strings" + "sync" + "time" + + alog "github.com/anacrolix/log" + "github.com/anacrolix/torrent" + "github.com/anacrolix/torrent/storage" + "github.com/samber/mo" + "golang.org/x/time/rate" +) + +type ( + Client struct { + repository *Repository + + torrentClient mo.Option[*torrent.Client] + currentTorrent mo.Option[*torrent.Torrent] + currentFile mo.Option[*torrent.File] + currentTorrentStatus TorrentStatus + cancelFunc context.CancelFunc + + mu sync.Mutex + stopCh chan struct{} // Closed when the media player stops + mediaPlayerPlaybackStatusCh chan *mediaplayer.PlaybackStatus // Continuously receives playback status + timeSinceLoggedSeeding time.Time + lastSpeedCheck time.Time // Track the last time we checked speeds + lastBytesCompleted int64 // Track the last bytes completed + lastBytesWrittenData int64 // Track the last bytes written data + } + + TorrentStatus struct { + UploadProgress int64 `json:"uploadProgress"` + DownloadProgress int64 `json:"downloadProgress"` + ProgressPercentage float64 `json:"progressPercentage"` + DownloadSpeed string `json:"downloadSpeed"` + UploadSpeed string `json:"uploadSpeed"` + Size string `json:"size"` + Seeders int `json:"seeders"` + } + + NewClientOptions struct { + Repository *Repository + } +) + +func NewClient(repository *Repository) *Client { + ret := &Client{ + repository: repository, + torrentClient: mo.None[*torrent.Client](), + currentFile: mo.None[*torrent.File](), + currentTorrent: mo.None[*torrent.Torrent](), + stopCh: make(chan struct{}), + mediaPlayerPlaybackStatusCh: make(chan *mediaplayer.PlaybackStatus, 1), + } + + return ret +} + +// initializeClient will create and torrent client. +// The client is designed to support only one torrent at a time, and seed it. +// Upon initialization, the client will drop all torrents. +func (c *Client) initializeClient() error { + // Fail if no settings + if err := c.repository.FailIfNoSettings(); err != nil { + return err + } + + // Cancel the previous context, terminating the goroutine if it's running + if c.cancelFunc != nil { + c.cancelFunc() + } + + // Context for the client's goroutine + var ctx context.Context + ctx, c.cancelFunc = context.WithCancel(context.Background()) + + // Get the settings + settings := c.repository.settings.MustGet() + + // Define torrent client settings + cfg := torrent.NewDefaultClientConfig() + cfg.Seed = true + cfg.DisableIPv6 = settings.DisableIPV6 + cfg.Logger = alog.Logger{} + + // TEST ONLY: Limit download speed to 1mb/s + // cfg.DownloadRateLimiter = rate.NewLimiter(rate.Limit(1<<20), 1<<20) + + if settings.SlowSeeding { + cfg.DialRateLimiter = rate.NewLimiter(rate.Limit(1), 1) + cfg.UploadRateLimiter = rate.NewLimiter(rate.Limit(1<<20), 2<<20) + } + + if settings.TorrentClientHost != "" { + cfg.ListenHost = func(network string) string { return settings.TorrentClientHost } + } + + if settings.TorrentClientPort == 0 { + settings.TorrentClientPort = 43213 + } + cfg.ListenPort = settings.TorrentClientPort + // Set the download directory + // e.g. /path/to/temp/seanime/torrentstream/{infohash} + cfg.DefaultStorage = storage.NewFileByInfoHash(settings.DownloadDir) + + c.mu.Lock() + // Create the torrent client + client, err := torrent.NewClient(cfg) + if err != nil { + c.mu.Unlock() + return fmt.Errorf("error creating a new torrent client: %v", err) + } + c.repository.logger.Info().Msgf("torrentstream: Initialized torrent client on port %d", settings.TorrentClientPort) + c.torrentClient = mo.Some(client) + c.dropTorrents() + c.mu.Unlock() + + go func(ctx context.Context) { + + for { + select { + case <-ctx.Done(): + c.repository.logger.Debug().Msg("torrentstream: Context cancelled, stopping torrent client") + return + + case status := <-c.mediaPlayerPlaybackStatusCh: + // DEVNOTE: When this is received, "default" case is executed right after + if status != nil && c.currentFile.IsPresent() && c.repository.playback.currentVideoDuration == 0 { + // If the stored video duration is 0 but the media player status shows a duration that is not 0 + // we know that the video has been loaded and is playing + if c.repository.playback.currentVideoDuration == 0 && status.Duration > 0 { + // The media player has started playing the video + c.repository.logger.Debug().Msg("torrentstream: Media player started playing the video, sending event") + c.repository.sendStateEvent(eventTorrentStartedPlaying) + // Update the stored video duration + c.repository.playback.currentVideoDuration = status.Duration + } + } + default: + c.mu.Lock() + if c.torrentClient.IsPresent() && c.currentTorrent.IsPresent() && c.currentFile.IsPresent() { + t := c.currentTorrent.MustGet() + f := c.currentFile.MustGet() + + // Get the current time + now := time.Now() + elapsed := now.Sub(c.lastSpeedCheck).Seconds() + + // downloadProgress is the number of bytes downloaded + downloadProgress := t.BytesCompleted() + + downloadSpeed := "" + if elapsed > 0 { + bytesPerSecond := float64(downloadProgress-c.lastBytesCompleted) / elapsed + if bytesPerSecond > 0 { + downloadSpeed = fmt.Sprintf("%s/s", util.Bytes(uint64(bytesPerSecond))) + } + } + size := util.Bytes(uint64(f.Length())) + + bytesWrittenData := t.Stats().BytesWrittenData + uploadSpeed := "" + if elapsed > 0 { + bytesPerSecond := float64((&bytesWrittenData).Int64()-c.lastBytesWrittenData) / elapsed + if bytesPerSecond > 0 { + uploadSpeed = fmt.Sprintf("%s/s", util.Bytes(uint64(bytesPerSecond))) + } + } + + // Update the stored values for next calculation + c.lastBytesCompleted = downloadProgress + c.lastBytesWrittenData = (&bytesWrittenData).Int64() + c.lastSpeedCheck = now + + if t.PeerConns() != nil { + c.currentTorrentStatus.Seeders = len(t.PeerConns()) + } + + c.currentTorrentStatus = TorrentStatus{ + Size: size, + UploadProgress: (&bytesWrittenData).Int64() - c.currentTorrentStatus.UploadProgress, + DownloadSpeed: downloadSpeed, + UploadSpeed: uploadSpeed, + DownloadProgress: downloadProgress, + ProgressPercentage: c.getTorrentPercentage(c.currentTorrent, c.currentFile), + Seeders: t.Stats().ConnectedSeeders, + } + c.repository.sendStateEvent(eventTorrentStatus, c.currentTorrentStatus) + // Always log the progress so the user knows what's happening + c.repository.logger.Trace().Msgf("torrentstream: Progress: %.2f%%, Download speed: %s, Upload speed: %s, Size: %s", + c.currentTorrentStatus.ProgressPercentage, + c.currentTorrentStatus.DownloadSpeed, + c.currentTorrentStatus.UploadSpeed, + c.currentTorrentStatus.Size) + c.timeSinceLoggedSeeding = time.Now() + } + c.mu.Unlock() + if c.torrentClient.IsPresent() { + if time.Since(c.timeSinceLoggedSeeding) > 20*time.Second { + c.timeSinceLoggedSeeding = time.Now() + for _, t := range c.torrentClient.MustGet().Torrents() { + if t.Seeding() { + c.repository.logger.Trace().Msgf("torrentstream: Seeding last torrent, %d peers", t.Stats().ActivePeers) + } + } + } + } + time.Sleep(3 * time.Second) + } + } + }(ctx) + + return nil +} + +func (c *Client) GetStreamingUrl() string { + if c.torrentClient.IsAbsent() { + return "" + } + if c.currentFile.IsAbsent() { + return "" + } + settings, ok := c.repository.settings.Get() + if !ok { + return "" + } + + host := settings.Host + if host == "0.0.0.0" { + host = "127.0.0.1" + } + address := fmt.Sprintf("%s:%d", host, settings.Port) + if settings.StreamUrlAddress != "" { + address = settings.StreamUrlAddress + } + ret := fmt.Sprintf("http://%s/api/v1/torrentstream/stream/%s", address, url.PathEscape(c.currentFile.MustGet().DisplayPath())) + if strings.HasPrefix(ret, "http://http") { + ret = strings.Replace(ret, "http://http", "http", 1) + } + return ret +} + +func (c *Client) GetExternalPlayerStreamingUrl() string { + if c.torrentClient.IsAbsent() { + return "" + } + if c.currentFile.IsAbsent() { + return "" + } + + ret := fmt.Sprintf("{{SCHEME}}://{{HOST}}/api/v1/torrentstream/stream/%s", url.PathEscape(c.currentFile.MustGet().DisplayPath())) + + return ret +} + +func (c *Client) AddTorrent(id string) (*torrent.Torrent, error) { + if c.torrentClient.IsAbsent() { + return nil, errors.New("torrent client is not initialized") + } + + // Drop all torrents + for _, t := range c.torrentClient.MustGet().Torrents() { + t.Drop() + } + + if strings.HasPrefix(id, "magnet") { + return c.addTorrentMagnet(id) + } + + if strings.HasPrefix(id, "http") { + return c.addTorrentFromDownloadURL(id) + } + + return c.addTorrentFromFile(id) +} + +func (c *Client) addTorrentMagnet(magnet string) (*torrent.Torrent, error) { + if c.torrentClient.IsAbsent() { + return nil, errors.New("torrent client is not initialized") + } + + t, err := c.torrentClient.MustGet().AddMagnet(magnet) + if err != nil { + return nil, err + } + + c.repository.logger.Trace().Msgf("torrentstream: Waiting to retrieve torrent info") + select { + case <-t.GotInfo(): + break + case <-t.Closed(): + //t.Drop() + return nil, errors.New("torrent closed") + case <-time.After(1 * time.Minute): + t.Drop() + return nil, errors.New("timeout waiting for torrent info") + } + c.repository.logger.Info().Msgf("torrentstream: Torrent added: %s", t.InfoHash().AsString()) + return t, nil +} + +func (c *Client) addTorrentFromFile(fp string) (*torrent.Torrent, error) { + if c.torrentClient.IsAbsent() { + return nil, errors.New("torrent client is not initialized") + } + + t, err := c.torrentClient.MustGet().AddTorrentFromFile(fp) + if err != nil { + return nil, err + } + c.repository.logger.Trace().Msgf("torrentstream: Waiting to retrieve torrent info") + <-t.GotInfo() + c.repository.logger.Info().Msgf("torrentstream: Torrent added: %s", t.InfoHash().AsString()) + return t, nil +} + +func (c *Client) addTorrentFromDownloadURL(url string) (*torrent.Torrent, error) { + if c.torrentClient.IsAbsent() { + return nil, errors.New("torrent client is not initialized") + } + + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + filename := path.Base(url) + file, err := os.Create(path.Join(os.TempDir(), filename)) + if err != nil { + return nil, err + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return nil, err + } + + t, err := c.torrentClient.MustGet().AddTorrentFromFile(file.Name()) + if err != nil { + return nil, err + } + c.repository.logger.Trace().Msgf("torrentstream: Waiting to retrieve torrent info") + select { + case <-t.GotInfo(): + break + case <-t.Closed(): + t.Drop() + return nil, errors.New("torrent closed") + case <-time.After(1 * time.Minute): + t.Drop() + return nil, errors.New("timeout waiting for torrent info") + } + c.repository.logger.Info().Msgf("torrentstream: Added torrent: %s", t.InfoHash().AsString()) + return t, nil +} + +// Shutdown closes the torrent client and drops all torrents. +// This SHOULD NOT be called if you don't intend to reinitialize the client. +func (c *Client) Shutdown() (errs []error) { + if c.torrentClient.IsAbsent() { + return + } + c.dropTorrents() + c.currentTorrent = mo.None[*torrent.Torrent]() + c.currentTorrentStatus = TorrentStatus{} + c.repository.logger.Debug().Msg("torrentstream: Closing torrent client") + return c.torrentClient.MustGet().Close() +} + +func (c *Client) FindTorrent(infoHash string) (*torrent.Torrent, error) { + if c.torrentClient.IsAbsent() { + return nil, errors.New("torrent client is not initialized") + } + + torrents := c.torrentClient.MustGet().Torrents() + for _, t := range torrents { + if t.InfoHash().AsString() == infoHash { + c.repository.logger.Debug().Msgf("torrentstream: Found torrent: %s", infoHash) + return t, nil + } + } + return nil, fmt.Errorf("no torrent found") +} + +func (c *Client) RemoveTorrent(infoHash string) error { + if c.torrentClient.IsAbsent() { + return errors.New("torrent client is not initialized") + } + + c.repository.logger.Trace().Msgf("torrentstream: Removing torrent: %s", infoHash) + + torrents := c.torrentClient.MustGet().Torrents() + for _, t := range torrents { + if t.InfoHash().AsString() == infoHash { + t.Drop() + c.repository.logger.Debug().Msgf("torrentstream: Removed torrent: %s", infoHash) + return nil + } + } + return fmt.Errorf("no torrent found") +} + +func (c *Client) dropTorrents() { + if c.torrentClient.IsAbsent() { + return + } + c.repository.logger.Trace().Msg("torrentstream: Dropping all torrents") + + for _, t := range c.torrentClient.MustGet().Torrents() { + t.Drop() + } + + if c.repository.settings.IsPresent() { + // Delete all torrents + fe, err := os.ReadDir(c.repository.settings.MustGet().DownloadDir) + if err == nil { + for _, f := range fe { + if f.IsDir() { + _ = os.RemoveAll(path.Join(c.repository.settings.MustGet().DownloadDir, f.Name())) + } + } + } + } + + c.repository.logger.Debug().Msg("torrentstream: Dropped all torrents") +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// getTorrentPercentage returns the percentage of the current torrent file +// If no torrent is selected, it returns -1 +func (c *Client) getTorrentPercentage(t mo.Option[*torrent.Torrent], f mo.Option[*torrent.File]) float64 { + if t.IsAbsent() || f.IsAbsent() { + return -1 + } + + if f.MustGet().Length() == 0 { + return 0 + } + + return float64(f.MustGet().BytesCompleted()) / float64(f.MustGet().Length()) * 100 +} + +// readyToStream determines if enough of the file has been downloaded to begin streaming +// Uses both absolute size (minimum buffer) and a percentage-based approach +func (c *Client) readyToStream() bool { + if c.currentTorrent.IsAbsent() || c.currentFile.IsAbsent() { + return false + } + + file := c.currentFile.MustGet() + + // Always need at least 1MB to start playback (typical header size for many formats) + const minimumBufferBytes int64 = 1 * 1024 * 1024 // 1MB + + // For large files, use a smaller percentage + var percentThreshold float64 + fileSize := file.Length() + + switch { + case fileSize > 5*1024*1024*1024: // > 5GB + percentThreshold = 0.1 // 0.1% for very large files + case fileSize > 1024*1024*1024: // > 1GB + percentThreshold = 0.5 // 0.5% for large files + default: + percentThreshold = 0.5 // 0.5% for smaller files + } + + bytesCompleted := file.BytesCompleted() + percentCompleted := float64(bytesCompleted) / float64(fileSize) * 100 + + // Ready when both minimum buffer is met AND percentage threshold is reached + return bytesCompleted >= minimumBufferBytes && percentCompleted >= percentThreshold +} diff --git a/seanime-2.9.10/internal/torrentstream/collection.go b/seanime-2.9.10/internal/torrentstream/collection.go new file mode 100644 index 0000000..6e58da1 --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/collection.go @@ -0,0 +1,235 @@ +package torrentstream + +import ( + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/hook" + "seanime/internal/library/anime" + "strconv" + "sync" + + "github.com/samber/lo" +) + +type ( + // StreamCollection is used to "complete" the anime.LibraryCollection if the user chooses + // to include torrent streams in the library view. + StreamCollection struct { + ContinueWatchingList []*anime.Episode `json:"continueWatchingList"` + Anime []*anilist.BaseAnime `json:"anime"` + ListData map[int]*anime.EntryListData `json:"listData"` + } + + HydrateStreamCollectionOptions struct { + AnimeCollection *anilist.AnimeCollection + LibraryCollection *anime.LibraryCollection + MetadataProvider metadata.Provider + } +) + +func (r *Repository) HydrateStreamCollection(opts *HydrateStreamCollectionOptions) { + + reqEvent := new(anime.AnimeLibraryStreamCollectionRequestedEvent) + reqEvent.AnimeCollection = opts.AnimeCollection + reqEvent.LibraryCollection = opts.LibraryCollection + err := hook.GlobalHookManager.OnAnimeLibraryStreamCollectionRequested().Trigger(reqEvent) + if err != nil { + return + } + opts.AnimeCollection = reqEvent.AnimeCollection + opts.LibraryCollection = reqEvent.LibraryCollection + + lists := opts.AnimeCollection.MediaListCollection.GetLists() + // Get the anime that are currently being watched + var currentlyWatching *anilist.AnimeCollection_MediaListCollection_Lists + for _, list := range lists { + if list.Status == nil { + continue + } + if *list.Status == anilist.MediaListStatusCurrent { + //currentlyWatching = list.CopyT() + currentlyWatching = &anilist.AnimeCollection_MediaListCollection_Lists{ + Status: lo.ToPtr(anilist.MediaListStatusCurrent), + Name: lo.ToPtr("CURRENT"), + IsCustomList: lo.ToPtr(false), + Entries: list.Entries, + } + continue + } + } + for _, list := range lists { + if list.Status == nil { + continue + } + if *list.Status == anilist.MediaListStatusRepeating { + if currentlyWatching == nil { + currentlyWatching = list + } else { + for _, entry := range list.Entries { + currentlyWatching.Entries = append(currentlyWatching.Entries, entry) + } + } + break + } + } + + if currentlyWatching == nil { + return + } + + ret := &StreamCollection{ + ContinueWatchingList: make([]*anime.Episode, 0), + Anime: make([]*anilist.BaseAnime, 0), + ListData: make(map[int]*anime.EntryListData), + } + + visitedMediaIds := make(map[int]struct{}) + + animeAdded := make(map[int]*anilist.AnimeListEntry) + + // Go through each entry in the currently watching list + wg := sync.WaitGroup{} + mu := sync.Mutex{} + wg.Add(len(currentlyWatching.Entries)) + for _, entry := range currentlyWatching.Entries { + go func(entry *anilist.AnimeListEntry) { + defer wg.Done() + + mu.Lock() + if _, found := visitedMediaIds[entry.GetMedia().GetID()]; found { + mu.Unlock() + return + } + // Get the next episode to watch + // i.e. if the user has watched episode 1, the next episode to watch is 2 + nextEpisodeToWatch := entry.GetProgressSafe() + 1 + if nextEpisodeToWatch > entry.GetMedia().GetCurrentEpisodeCount() { + mu.Unlock() + return // Skip this entry if the user has watched all episodes + } + mediaId := entry.GetMedia().GetID() + visitedMediaIds[mediaId] = struct{}{} + mu.Unlock() + // Check if the anime's "next episode to watch" is already in the library collection + // If it is, we don't need to add it to the stream collection + for _, libraryEp := range opts.LibraryCollection.ContinueWatchingList { + if libraryEp.BaseAnime.ID == mediaId && libraryEp.GetProgressNumber() == nextEpisodeToWatch { + return + } + } + + if *entry.GetMedia().GetStatus() == anilist.MediaStatusNotYetReleased { + return + } + + // Get the media info + animeMetadata, err := opts.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mediaId) + if err != nil { + r.logger.Error().Err(err).Msg("torrentstream: could not fetch AniDB media") + return + } + + _, found := animeMetadata.FindEpisode(strconv.Itoa(nextEpisodeToWatch)) + //if !found { + // r.logger.Error().Msg("torrentstream: could not find episode in AniDB") + // return + //} + + progressOffset := 0 + anidbEpisode := strconv.Itoa(nextEpisodeToWatch) + if anime.FindDiscrepancy(entry.GetMedia(), animeMetadata) == anime.DiscrepancyAniListCountsEpisodeZero { + progressOffset = 1 + if nextEpisodeToWatch == 1 { + anidbEpisode = "S1" + } + } + + // Add the anime & episode + episode := anime.NewEpisode(&anime.NewEpisodeOptions{ + LocalFile: nil, + OptionalAniDBEpisode: anidbEpisode, + AnimeMetadata: animeMetadata, + Media: entry.GetMedia(), + ProgressOffset: progressOffset, + IsDownloaded: false, + MetadataProvider: r.metadataProvider, + }) + if !found { + episode.EpisodeTitle = entry.GetMedia().GetPreferredTitle() + episode.DisplayTitle = fmt.Sprintf("Episode %d", nextEpisodeToWatch) + episode.ProgressNumber = nextEpisodeToWatch + episode.EpisodeNumber = nextEpisodeToWatch + episode.EpisodeMetadata = &anime.EpisodeMetadata{ + Image: entry.GetMedia().GetBannerImageSafe(), + } + } + + if episode == nil { + r.logger.Error().Msg("torrentstream: could not get anime entry episode") + return + } + + mu.Lock() + ret.ContinueWatchingList = append(ret.ContinueWatchingList, episode) + animeAdded[mediaId] = entry + mu.Unlock() + }(entry) + } + wg.Wait() + + libraryAnimeMap := make(map[int]struct{}) + + // Remove anime that are already in the library collection + for _, list := range opts.LibraryCollection.Lists { + if list.Status == anilist.MediaListStatusCurrent { + for _, entry := range list.Entries { + libraryAnimeMap[entry.MediaId] = struct{}{} + if _, found := animeAdded[entry.MediaId]; found { + delete(animeAdded, entry.MediaId) + } + } + } + } + + for _, entry := range currentlyWatching.Entries { + if _, found := libraryAnimeMap[entry.GetMedia().GetID()]; found { + continue + } + if *entry.GetMedia().GetStatus() == anilist.MediaStatusNotYetReleased { + continue + } + animeAdded[entry.GetMedia().GetID()] = entry + } + + for _, a := range animeAdded { + ret.Anime = append(ret.Anime, a.GetMedia()) + ret.ListData[a.GetMedia().GetID()] = &anime.EntryListData{ + Progress: a.GetProgressSafe(), + Score: a.GetScoreSafe(), + Status: a.GetStatus(), + Repeat: a.GetRepeatSafe(), + StartedAt: anilist.FuzzyDateToString(a.StartedAt), + CompletedAt: anilist.FuzzyDateToString(a.CompletedAt), + } + } + + if len(ret.ContinueWatchingList) == 0 && len(ret.Anime) == 0 { + return + } + + sc := &anime.StreamCollection{ + ContinueWatchingList: ret.ContinueWatchingList, + Anime: ret.Anime, + ListData: ret.ListData, + } + + event := new(anime.AnimeLibraryStreamCollectionEvent) + event.StreamCollection = sc + err = hook.GlobalHookManager.OnAnimeLibraryStreamCollection().Trigger(event) + if err != nil { + return + } + + opts.LibraryCollection.Stream = event.StreamCollection +} diff --git a/seanime-2.9.10/internal/torrentstream/collection_test.go b/seanime-2.9.10/internal/torrentstream/collection_test.go new file mode 100644 index 0000000..1f34d46 --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/collection_test.go @@ -0,0 +1,82 @@ +package torrentstream + +import ( + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/events" + "seanime/internal/library/anime" + "seanime/internal/platforms/anilist_platform" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/samber/lo" + "github.com/stretchr/testify/require" +) + +func TestStreamCollection(t *testing.T) { + t.Skip("Incomplete") + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t, test_utils.Anilist()) + + logger := util.NewLogger() + metadataProvider := metadata.GetMockProvider(t) + anilistClient := anilist.TestGetMockAnilistClient() + anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger) + anilistPlatform.SetUsername(test_utils.ConfigData.Provider.AnilistUsername) + animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), false) + require.NoError(t, err) + require.NotNil(t, animeCollection) + + repo := NewRepository(&NewRepositoryOptions{ + Logger: logger, + BaseAnimeCache: anilist.NewBaseAnimeCache(), + CompleteAnimeCache: anilist.NewCompleteAnimeCache(), + Platform: anilistPlatform, + MetadataProvider: metadataProvider, + WSEventManager: events.NewMockWSEventManager(logger), + TorrentRepository: nil, + PlaybackManager: nil, + Database: nil, + }) + + // Mock Anilist collection and local files + // User is currently watching Sousou no Frieren and One Piece + lfs := make([]*anime.LocalFile, 0) + + // Sousou no Frieren + // 7 episodes downloaded, 4 watched + mediaId := 154587 + lfs = append(lfs, anime.MockHydratedLocalFiles( + anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - %ep (1080p) [F02B9CEE].mkv", mediaId, []anime.MockHydratedLocalFileWrapperOptionsMetadata{ + {MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 5, MetadataAniDbEpisode: "5", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 6, MetadataAniDbEpisode: "6", MetadataType: anime.LocalFileTypeMain}, + {MetadataEpisode: 7, MetadataAniDbEpisode: "7", MetadataType: anime.LocalFileTypeMain}, + }), + )...) + anilist.TestModifyAnimeCollectionEntry(animeCollection, mediaId, anilist.TestModifyAnimeCollectionEntryInput{ + Status: lo.ToPtr(anilist.MediaListStatusCurrent), + Progress: lo.ToPtr(4), // Mock progress + }) + + libraryCollection, err := anime.NewLibraryCollection(t.Context(), &anime.NewLibraryCollectionOptions{ + AnimeCollection: animeCollection, + LocalFiles: lfs, + Platform: anilistPlatform, + MetadataProvider: metadataProvider, + }) + require.NoError(t, err) + + // Create the stream collection + repo.HydrateStreamCollection(&HydrateStreamCollectionOptions{ + AnimeCollection: animeCollection, + LibraryCollection: libraryCollection, + }) + spew.Dump(libraryCollection) + +} diff --git a/seanime-2.9.10/internal/torrentstream/events.go b/seanime-2.9.10/internal/torrentstream/events.go new file mode 100644 index 0000000..c529b0f --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/events.go @@ -0,0 +1,50 @@ +package torrentstream + +import "seanime/internal/events" + +const ( + eventLoading = "loading" + eventLoadingFailed = "loading-failed" + eventTorrentLoaded = "loaded" + eventTorrentStartedPlaying = "started-playing" + eventTorrentStatus = "status" + eventTorrentStopped = "stopped" +) + +type TorrentLoadingStatusState string + +const ( + TLSStateLoading TorrentLoadingStatusState = "LOADING" + TLSStateSearchingTorrents TorrentLoadingStatusState = "SEARCHING_TORRENTS" + TLSStateCheckingTorrent TorrentLoadingStatusState = "CHECKING_TORRENT" + TLSStateAddingTorrent TorrentLoadingStatusState = "ADDING_TORRENT" + TLSStateSelectingFile TorrentLoadingStatusState = "SELECTING_FILE" + TLSStateStartingServer TorrentLoadingStatusState = "STARTING_SERVER" + TLSStateSendingStreamToMediaPlayer TorrentLoadingStatusState = "SENDING_STREAM_TO_MEDIA_PLAYER" +) + +type TorrentStreamState struct { + State string `json:"state"` +} + +func (r *Repository) sendStateEvent(event string, data ...interface{}) { + var dataToSend interface{} + + if len(data) > 0 { + dataToSend = data[0] + } + r.wsEventManager.SendEvent(events.TorrentStreamState, struct { + State string `json:"state"` + Data interface{} `json:"data"` + }{ + State: event, + Data: dataToSend, + }) +} + +//func (r *Repository) sendTorrentLoadingStatus(event TorrentLoadingStatusState, checking string) { +// r.wsEventManager.SendEvent(eventTorrentLoadingStatus, &TorrentLoadingStatus{ +// TorrentBeingChecked: checking, +// State: event, +// }) +//} diff --git a/seanime-2.9.10/internal/torrentstream/finder.go b/seanime-2.9.10/internal/torrentstream/finder.go new file mode 100644 index 0000000..4de59c1 --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/finder.go @@ -0,0 +1,414 @@ +package torrentstream + +import ( + "cmp" + "context" + "fmt" + "seanime/internal/api/anilist" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/hook" + torrentanalyzer "seanime/internal/torrents/analyzer" + itorrent "seanime/internal/torrents/torrent" + "seanime/internal/util" + "seanime/internal/util/torrentutil" + "slices" + "time" + + "github.com/anacrolix/torrent" + "github.com/samber/lo" +) + +var ( + ErrNoTorrentsFound = fmt.Errorf("no torrents found, please select manually") + ErrNoEpisodeFound = fmt.Errorf("could not select episode from torrents, please select manually") +) + +type ( + playbackTorrent struct { + Torrent *torrent.Torrent + File *torrent.File + } +) + +// setPriorityDownloadStrategy sets piece priorities for optimal streaming experience +// This helps to optimize initial buffering, seeking, and end-of-file playback +func (r *Repository) setPriorityDownloadStrategy(t *torrent.Torrent, file *torrent.File) { + torrentutil.PrioritizeDownloadPieces(t, file, r.logger) +} + +func (r *Repository) findBestTorrent(media *anilist.CompleteAnime, aniDbEpisode string, episodeNumber int) (ret *playbackTorrent, err error) { + defer util.HandlePanicInModuleWithError("torrentstream/findBestTorrent", &err) + + r.logger.Debug().Msgf("torrentstream: Finding best torrent for %s, Episode %d", media.GetTitleSafe(), episodeNumber) + + providerId := itorrent.ProviderAnimeTosho // todo: get provider from settings + fallbackProviderId := itorrent.ProviderNyaa + + // Get AnimeTosho provider extension + providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(providerId) + if !ok { + r.logger.Error().Str("provider", itorrent.ProviderAnimeTosho).Msg("torrentstream: AnimeTosho provider extension not found") + return nil, fmt.Errorf("provider extension not found") + } + + searchBatch := false + // Search batch if not a movie and finished + yearsSinceStart := 999 + if media.StartDate != nil && *media.StartDate.Year > 0 { + yearsSinceStart = time.Now().Year() - *media.StartDate.Year // e.g. 2024 - 2020 = 4 + } + if !media.IsMovie() && media.IsFinished() && yearsSinceStart > 4 { + searchBatch = true + } + + r.sendStateEvent(eventLoading, TLSStateSearchingTorrents) + + var data *itorrent.SearchData + var currentProvider string = providerId +searchLoop: + for { + var err error + data, err = r.torrentRepository.SearchAnime(context.Background(), itorrent.AnimeSearchOptions{ + Provider: currentProvider, + Type: itorrent.AnimeSearchTypeSmart, + Media: media.ToBaseAnime(), + Query: "", + Batch: searchBatch, + EpisodeNumber: episodeNumber, + BestReleases: false, + Resolution: r.settings.MustGet().PreferredResolution, + }) + // If we are searching for batches, we don't want to return an error if no torrents are found + // We will just search again without the batch flag + if err != nil && !searchBatch { + r.logger.Error().Err(err).Msg("torrentstream: Error searching torrents") + + // Try fallback provider if we're still on primary provider + if currentProvider == providerId { + r.logger.Debug().Msgf("torrentstream: Primary provider failed, trying fallback provider %s", fallbackProviderId) + currentProvider = fallbackProviderId + // Get fallback provider extension + providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider) + if !ok { + r.logger.Error().Str("provider", fallbackProviderId).Msg("torrentstream: Fallback provider extension not found") + return nil, fmt.Errorf("fallback provider extension not found") + } + continue + } + + return nil, err + } else if err != nil { + searchBatch = false + continue + } + + // This whole thing below just means that + // If we are looking for batches, there should be at least 3 torrents found or the max seeders should be at least 15 + if searchBatch { + nbFound := len(data.Torrents) + seedersArr := lo.Map(data.Torrents, func(t *hibiketorrent.AnimeTorrent, _ int) int { + return t.Seeders + }) + if len(seedersArr) == 0 { + searchBatch = false + continue + } + maxSeeders := slices.Max(seedersArr) + if maxSeeders >= 15 || nbFound > 2 { + break searchLoop + } else { + searchBatch = false + } + } else { + break searchLoop + } + } + + if data == nil || len(data.Torrents) == 0 { + // Try fallback provider if we're still on primary provider + if currentProvider == providerId { + r.logger.Debug().Msgf("torrentstream: No torrents found with primary provider, trying fallback provider %s", fallbackProviderId) + currentProvider = fallbackProviderId + // Get fallback provider extension + providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider) + if !ok { + r.logger.Error().Str("provider", fallbackProviderId).Msg("torrentstream: Fallback provider extension not found") + return nil, fmt.Errorf("fallback provider extension not found") + } + + // Try searching with fallback provider (reset searchBatch) + searchBatch = false + if !media.IsMovie() && media.IsFinished() && yearsSinceStart > 4 { + searchBatch = true + } + + // Restart the search with fallback provider + goto searchLoop + } + + r.logger.Error().Msg("torrentstream: No torrents found") + return nil, ErrNoTorrentsFound + } + + // Sort by seeders from highest to lowest + slices.SortStableFunc(data.Torrents, func(a, b *hibiketorrent.AnimeTorrent) int { + return cmp.Compare(b.Seeders, a.Seeders) + }) + + // Trigger hook + fetchedEvent := &TorrentStreamAutoSelectTorrentsFetchedEvent{ + Torrents: data.Torrents, + } + _ = hook.GlobalHookManager.OnTorrentStreamAutoSelectTorrentsFetched().Trigger(fetchedEvent) + data.Torrents = fetchedEvent.Torrents + + r.logger.Debug().Msgf("torrentstream: Found %d torrents", len(data.Torrents)) + + // Go through the top 3 torrents + // - For each torrent, add it, get the files, and check if it has the episode + // - If it does, return the magnet link + var selectedTorrent *torrent.Torrent + var selectedFile *torrent.File + tries := 0 + + for _, searchT := range data.Torrents { + if tries >= 2 { + break + } + r.sendStateEvent(eventLoading, struct { + State any `json:"state"` + TorrentBeingLoaded string `json:"torrentBeingLoaded"` + }{ + State: TLSStateAddingTorrent, + TorrentBeingLoaded: searchT.Name, + }) + r.logger.Trace().Msgf("torrentstream: Getting torrent magnet") + magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(searchT) + if err != nil { + r.logger.Warn().Err(err).Msgf("torrentstream: Error scraping magnet link for %s", searchT.Link) + tries++ + continue + } + r.logger.Debug().Msgf("torrentstream: Adding torrent %s from magnet", searchT.Link) + + t, err := r.client.AddTorrent(magnet) + if err != nil { + r.logger.Warn().Err(err).Msgf("torrentstream: Error adding torrent %s", searchT.Link) + tries++ + continue + } + + r.sendStateEvent(eventLoading, struct { + State any `json:"state"` + TorrentBeingLoaded string `json:"torrentBeingLoaded"` + }{ + State: TLSStateCheckingTorrent, + TorrentBeingLoaded: searchT.Name, + }) + + // If the torrent has only one file, return it + if len(t.Files()) == 1 { + tFile := t.Files()[0] + tFile.Download() + r.setPriorityDownloadStrategy(t, tFile) + r.logger.Debug().Msgf("torrentstream: Found single file torrent: %s", tFile.DisplayPath()) + + return &playbackTorrent{ + Torrent: t, + File: tFile, + }, nil + } + + r.sendStateEvent(eventLoading, TLSStateSelectingFile) + + // DEVNOTE: The gap between adding the torrent and file analysis causes some pieces to be downloaded + // We currently can't Pause/Resume torrents so :shrug: + + filepaths := lo.Map(t.Files(), func(f *torrent.File, _ int) string { + return f.DisplayPath() + }) + + if len(filepaths) == 0 { + r.logger.Error().Msg("torrentstream: No files found in the torrent") + return nil, fmt.Errorf("no files found in the torrent") + } + + // Create a new Torrent Analyzer + analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{ + Logger: r.logger, + Filepaths: filepaths, + Media: media, + Platform: r.platform, + MetadataProvider: r.metadataProvider, + ForceMatch: true, + }) + + r.logger.Debug().Msgf("torrentstream: Analyzing torrent %s", searchT.Link) + + // Analyze torrent files + analysis, err := analyzer.AnalyzeTorrentFiles() + if err != nil { + r.logger.Warn().Err(err).Msg("torrentstream: Error analyzing torrent files") + // Remove torrent on failure + go func() { + _ = r.client.RemoveTorrent(t.InfoHash().AsString()) + }() + tries++ + continue + } + + analysisFile, found := analysis.GetFileByAniDBEpisode(aniDbEpisode) + // Check if analyzer found the episode + if !found { + r.logger.Error().Msgf("torrentstream: Failed to auto-select episode from torrent %s", searchT.Link) + // Remove torrent on failure + go func() { + _ = r.client.RemoveTorrent(t.InfoHash().AsString()) + }() + tries++ + continue + } + + r.logger.Debug().Msgf("torrentstream: Found corresponding file for episode %s: %s", aniDbEpisode, analysisFile.GetLocalFile().Name) + + // Download the file and unselect the rest + for i, f := range t.Files() { + if i != analysisFile.GetIndex() { + f.SetPriority(torrent.PiecePriorityNone) + } + } + tFile := t.Files()[analysisFile.GetIndex()] + r.logger.Debug().Msgf("torrentstream: Selecting file %s", tFile.DisplayPath()) + r.setPriorityDownloadStrategy(t, tFile) + + selectedTorrent = t + selectedFile = tFile + break + } + + if selectedTorrent == nil { + return nil, ErrNoEpisodeFound + } + + ret = &playbackTorrent{ + Torrent: selectedTorrent, + File: selectedFile, + } + + return ret, nil +} + +// findBestTorrentFromManualSelection is like findBestTorrent but no need to search for the best torrent first +func (r *Repository) findBestTorrentFromManualSelection(t *hibiketorrent.AnimeTorrent, media *anilist.CompleteAnime, aniDbEpisode string, chosenFileIndex *int) (*playbackTorrent, error) { + + r.logger.Debug().Msgf("torrentstream: Analyzing torrent from %s for %s", t.Link, media.GetTitleSafe()) + + // Get the torrent's provider extension + providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(t.Provider) + if !ok { + r.logger.Error().Str("provider", t.Provider).Msg("torrentstream: provider extension not found") + return nil, fmt.Errorf("provider extension not found") + } + + // First, add the torrent + magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(t) + if err != nil { + r.logger.Error().Err(err).Msgf("torrentstream: Error scraping magnet link for %s", t.Link) + return nil, fmt.Errorf("could not get magnet link from %s", t.Link) + } + selectedTorrent, err := r.client.AddTorrent(magnet) + if err != nil { + r.logger.Error().Err(err).Msgf("torrentstream: Error adding torrent %s", t.Link) + return nil, err + } + + // If the torrent has only one file, return it + if len(selectedTorrent.Files()) == 1 { + tFile := selectedTorrent.Files()[0] + tFile.Download() + r.setPriorityDownloadStrategy(selectedTorrent, tFile) + r.logger.Debug().Msgf("torrentstream: Found single file torrent: %s", tFile.DisplayPath()) + + return &playbackTorrent{ + Torrent: selectedTorrent, + File: tFile, + }, nil + } + + var fileIndex int + + // If the file index is already selected + if chosenFileIndex != nil { + + fileIndex = *chosenFileIndex + + } else { + + // We know the torrent has multiple files, so we'll need to analyze it + filepaths := lo.Map(selectedTorrent.Files(), func(f *torrent.File, _ int) string { + return f.DisplayPath() + }) + + if len(filepaths) == 0 { + r.logger.Error().Msg("torrentstream: No files found in the torrent") + return nil, fmt.Errorf("no files found in the torrent") + } + + // Create a new Torrent Analyzer + analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{ + Logger: r.logger, + Filepaths: filepaths, + Media: media, + Platform: r.platform, + MetadataProvider: r.metadataProvider, + ForceMatch: true, + }) + + // Analyze torrent files + analysis, err := analyzer.AnalyzeTorrentFiles() + if err != nil { + r.logger.Warn().Err(err).Msg("torrentstream: Error analyzing torrent files") + // Remove torrent on failure + go func() { + _ = r.client.RemoveTorrent(selectedTorrent.InfoHash().AsString()) + }() + return nil, err + } + + analysisFile, found := analysis.GetFileByAniDBEpisode(aniDbEpisode) + // Check if analyzer found the episode + if !found { + r.logger.Error().Msgf("torrentstream: Failed to auto-select episode from torrent %s", selectedTorrent.Info().Name) + // Remove torrent on failure + go func() { + _ = r.client.RemoveTorrent(selectedTorrent.InfoHash().AsString()) + }() + return nil, ErrNoEpisodeFound + } + + r.logger.Debug().Msgf("torrentstream: Found corresponding file for episode %s: %s", aniDbEpisode, analysisFile.GetLocalFile().Name) + + fileIndex = analysisFile.GetIndex() + + } + + // Download the file and unselect the rest + for i, f := range selectedTorrent.Files() { + if i != fileIndex { + f.SetPriority(torrent.PiecePriorityNone) + } + } + //selectedTorrent.Files()[fileIndex].SetPriority(torrent.PiecePriorityNormal) + r.logger.Debug().Msgf("torrentstream: Selected torrent %s", selectedTorrent.Files()[fileIndex].DisplayPath()) + + tFile := selectedTorrent.Files()[fileIndex] + tFile.Download() + r.setPriorityDownloadStrategy(selectedTorrent, tFile) + + ret := &playbackTorrent{ + Torrent: selectedTorrent, + File: selectedTorrent.Files()[fileIndex], + } + + return ret, nil +} diff --git a/seanime-2.9.10/internal/torrentstream/handler.go b/seanime-2.9.10/internal/torrentstream/handler.go new file mode 100644 index 0000000..930d61f --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/handler.go @@ -0,0 +1,82 @@ +package torrentstream + +import ( + "net/http" + "seanime/internal/util/torrentutil" + "strconv" + "time" + + "github.com/anacrolix/torrent" +) + +var _ = http.Handler(&handler{}) + +type ( + // handler serves the torrent stream + handler struct { + repository *Repository + } +) + +func newHandler(repository *Repository) *handler { + return &handler{ + repository: repository, + } +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.repository.logger.Trace().Str("range", r.Header.Get("Range")).Msg("torrentstream: Stream endpoint hit") + + if h.repository.client.currentFile.IsAbsent() || h.repository.client.currentTorrent.IsAbsent() { + h.repository.logger.Error().Msg("torrentstream: No torrent to stream") + http.Error(w, "No torrent to stream", http.StatusNotFound) + return + } + + if r.Method == http.MethodHead { + r.Response.Header.Set("Content-Type", "video/mp4") + r.Response.Header.Set("Content-Length", strconv.Itoa(int(h.repository.client.currentFile.MustGet().Length()))) + r.Response.Header.Set("Content-Disposition", "inline; filename="+h.repository.client.currentFile.MustGet().DisplayPath()) + r.Response.Header.Set("Accept-Ranges", "bytes") + r.Response.Header.Set("Cache-Control", "no-cache") + r.Response.Header.Set("Pragma", "no-cache") + r.Response.Header.Set("Expires", "0") + r.Response.Header.Set("X-Content-Type-Options", "nosniff") + + // No content, just headers + w.WriteHeader(http.StatusOK) + return + } + + file := h.repository.client.currentFile.MustGet() + h.repository.logger.Trace().Str("file", file.DisplayPath()).Msg("torrentstream: New reader") + tr := file.NewReader() + defer func(tr torrent.Reader) { + h.repository.logger.Trace().Msg("torrentstream: Closing reader") + _ = tr.Close() + }(tr) + + tr.SetResponsive() + // Read ahead 5MB for better streaming performance + // DEVNOTE: Not sure if dynamic prioritization overwrites this but whatever + tr.SetReadahead(5 * 1024 * 1024) + + // If this is a range request for a later part of the file, prioritize those pieces + rangeHeader := r.Header.Get("Range") + if rangeHeader != "" && h.repository.client.currentTorrent.IsPresent() { + t := h.repository.client.currentTorrent.MustGet() + // Attempt to prioritize the pieces requested in the range + torrentutil.PrioritizeRangeRequestPieces(rangeHeader, t, file, h.repository.logger) + } + + h.repository.logger.Trace().Str("file", file.DisplayPath()).Msg("torrentstream: Serving file content") + w.Header().Set("Content-Type", "video/mp4") + http.ServeContent( + w, + r, + file.DisplayPath(), + time.Now(), + tr, + ) + h.repository.logger.Trace().Msg("torrentstream: File content served") +} diff --git a/seanime-2.9.10/internal/torrentstream/history.go b/seanime-2.9.10/internal/torrentstream/history.go new file mode 100644 index 0000000..dfedaaa --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/history.go @@ -0,0 +1,34 @@ +package torrentstream + +import ( + "seanime/internal/database/db_bridge" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/util" +) + +type BatchHistoryResponse struct { + Torrent *hibiketorrent.AnimeTorrent `json:"torrent"` +} + +func (r *Repository) GetBatchHistory(mId int) (ret *BatchHistoryResponse) { + defer util.HandlePanicInModuleThen("torrentstream/GetBatchHistory", func() { + ret = &BatchHistoryResponse{} + }) + + torrent, err := db_bridge.GetTorrentstreamHistory(r.db, mId) + if err != nil { + return &BatchHistoryResponse{} + } + + return &BatchHistoryResponse{ + torrent, + } +} + +func (r *Repository) AddBatchHistory(mId int, torrent *hibiketorrent.AnimeTorrent) { + go func() { + defer util.HandlePanicInModuleThen("torrentstream/AddBatchHistory", func() {}) + + _ = db_bridge.InsertTorrentstreamHistory(r.db, mId, torrent) + }() +} diff --git a/seanime-2.9.10/internal/torrentstream/hook_events.go b/seanime-2.9.10/internal/torrentstream/hook_events.go new file mode 100644 index 0000000..211e8c7 --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/hook_events.go @@ -0,0 +1,26 @@ +package torrentstream + +import ( + "seanime/internal/api/anilist" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/hook_resolver" +) + +// TorrentStreamAutoSelectTorrentsFetchedEvent is triggered when the torrents are fetched for auto select. +// The torrents are sorted by seeders from highest to lowest. +// This event is triggered before the top 3 torrents are analyzed. +type TorrentStreamAutoSelectTorrentsFetchedEvent struct { + hook_resolver.Event + Torrents []*hibiketorrent.AnimeTorrent +} + +// TorrentStreamSendStreamToMediaPlayerEvent is triggered when the torrent stream is about to send a stream to the media player. +// Prevent default to skip the default playback and override the playback. +type TorrentStreamSendStreamToMediaPlayerEvent struct { + hook_resolver.Event + WindowTitle string `json:"windowTitle"` + StreamURL string `json:"streamURL"` + Media *anilist.BaseAnime `json:"media"` + AniDbEpisode string `json:"aniDbEpisode"` + PlaybackType string `json:"playbackType"` +} diff --git a/seanime-2.9.10/internal/torrentstream/playback.go b/seanime-2.9.10/internal/torrentstream/playback.go new file mode 100644 index 0000000..b2e6c74 --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/playback.go @@ -0,0 +1,112 @@ +package torrentstream + +import ( + "context" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/nativeplayer" +) + +type ( + playback struct { + mediaPlayerCtxCancelFunc context.CancelFunc + // Stores the video duration returned by the media player + // When this is greater than 0, the video is considered to be playing + currentVideoDuration int + } +) + +func (r *Repository) listenToMediaPlayerEvents() { + r.mediaPlayerRepositorySubscriber = r.mediaPlayerRepository.Subscribe("torrentstream") + + if r.playback.mediaPlayerCtxCancelFunc != nil { + r.playback.mediaPlayerCtxCancelFunc() + } + + var ctx context.Context + ctx, r.playback.mediaPlayerCtxCancelFunc = context.WithCancel(context.Background()) + + go func(ctx context.Context) { + for { + select { + case <-ctx.Done(): + r.logger.Debug().Msg("torrentstream: Media player context cancelled") + return + case event := <-r.mediaPlayerRepositorySubscriber.EventCh: + switch e := event.(type) { + case mediaplayer.StreamingTrackingStartedEvent: + // Reset the current video duration, as the video has stopped + // DEVNOTE: This is changed in client.go as well when the duration is updated over 0 + r.playback.currentVideoDuration = 0 + case mediaplayer.StreamingVideoCompletedEvent: + case mediaplayer.StreamingTrackingStoppedEvent: + if r.client.currentTorrent.IsPresent() { + go func() { + defer func() { + if r := recover(); r != nil { + } + }() + r.logger.Debug().Msg("torrentstream: Media player stopped event received") + // Stop the stream + _ = r.StopStream() + }() + } + case mediaplayer.StreamingPlaybackStatusEvent: + go func() { + if e.Status != nil && r.client.currentTorrent.IsPresent() { + r.client.mediaPlayerPlaybackStatusCh <- e.Status + } + }() + } + } + } + }(ctx) +} + +func (r *Repository) listenToNativePlayerEvents() { + r.nativePlayerSubscriber = r.nativePlayer.Subscribe("torrentstream") + + go func() { + for { + select { + case event, ok := <-r.nativePlayerSubscriber.Events(): + if !ok { // shouldn't happen + r.logger.Debug().Msg("torrentstream: Native player subscriber channel closed") + return + } + + switch event := event.(type) { + case *nativeplayer.VideoLoadedMetadataEvent: + go func() { + if r.client.currentFile.IsPresent() && r.playback.currentVideoDuration == 0 { + // If the stored video duration is 0 but the media player status shows a duration that is not 0 + // we know that the video has been loaded and is playing + if r.playback.currentVideoDuration == 0 && event.Duration > 0 { + // The media player has started playing the video + r.logger.Debug().Msg("torrentstream: Media player started playing the video, sending event") + r.sendStateEvent(eventTorrentStartedPlaying) + // Update the stored video duration + r.playback.currentVideoDuration = int(event.Duration) + } + } + }() + case *nativeplayer.VideoTerminatedEvent: + r.logger.Debug().Msg("torrentstream: Native player terminated event received") + r.playback.currentVideoDuration = 0 + // Only handle the event if we actually have a current torrent to avoid unnecessary cleanup + if r.client.currentTorrent.IsPresent() { + go func() { + defer func() { + if rec := recover(); rec != nil { + r.logger.Error().Msg("torrentstream: Recovered from panic in VideoTerminatedEvent handler") + } + }() + r.logger.Debug().Msg("torrentstream: Stopping stream due to native player termination") + // Stop the stream + _ = r.StopStream() + }() + } + } + } + } + }() +} diff --git a/seanime-2.9.10/internal/torrentstream/previews.go b/seanime-2.9.10/internal/torrentstream/previews.go new file mode 100644 index 0000000..d157245 --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/previews.go @@ -0,0 +1,130 @@ +package torrentstream + +import ( + "fmt" + "github.com/5rahim/habari" + "github.com/anacrolix/torrent" + "seanime/internal/api/anilist" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/util" + "seanime/internal/util/comparison" + "sync" +) + +type ( + FilePreview struct { + Path string `json:"path"` + DisplayPath string `json:"displayPath"` + DisplayTitle string `json:"displayTitle"` + EpisodeNumber int `json:"episodeNumber"` + RelativeEpisodeNumber int `json:"relativeEpisodeNumber"` + IsLikely bool `json:"isLikely"` + Index int `json:"index"` + } + + GetTorrentFilePreviewsOptions struct { + Torrent *hibiketorrent.AnimeTorrent + Magnet string + EpisodeNumber int + AbsoluteOffset int + Media *anilist.BaseAnime + } +) + +func (r *Repository) GetTorrentFilePreviewsFromManualSelection(opts *GetTorrentFilePreviewsOptions) (ret []*FilePreview, err error) { + defer util.HandlePanicInModuleWithError("torrentstream/GetTorrentFilePreviewsFromManualSelection", &err) + + if opts.Torrent == nil || opts.Magnet == "" || opts.Media == nil { + return nil, fmt.Errorf("torrentstream: Invalid options") + } + + r.logger.Trace().Str("hash", opts.Torrent.InfoHash).Msg("torrentstream: Getting file previews for torrent selection") + + selectedTorrent, err := r.client.AddTorrent(opts.Magnet) + if err != nil { + r.logger.Error().Err(err).Msgf("torrentstream: Error adding torrent %s", opts.Magnet) + return nil, err + } + + fileMetadataMap := make(map[string]*habari.Metadata) + wg := sync.WaitGroup{} + mu := sync.RWMutex{} + wg.Add(len(selectedTorrent.Files())) + for _, file := range selectedTorrent.Files() { + go func(file *torrent.File) { + defer wg.Done() + defer util.HandlePanicInModuleThen("debridstream/GetTorrentFilePreviewsFromManualSelection", func() {}) + + metadata := habari.Parse(file.DisplayPath()) + mu.Lock() + fileMetadataMap[file.Path()] = metadata + mu.Unlock() + }(file) + } + wg.Wait() + + containsAbsoluteEps := false + for _, metadata := range fileMetadataMap { + if len(metadata.EpisodeNumber) == 1 { + ep := util.StringToIntMust(metadata.EpisodeNumber[0]) + if ep > opts.Media.GetTotalEpisodeCount() { + containsAbsoluteEps = true + break + } + } + } + + wg = sync.WaitGroup{} + mu2 := sync.Mutex{} + + for i, file := range selectedTorrent.Files() { + wg.Add(1) + go func(i int, file *torrent.File) { + defer wg.Done() + defer util.HandlePanicInModuleThen("torrentstream/GetTorrentFilePreviewsFromManualSelection", func() {}) + + mu.RLock() + metadata := fileMetadataMap[file.Path()] + mu.RUnlock() + + displayTitle := file.DisplayPath() + + isLikely := false + parsedEpisodeNumber := -1 + + if metadata != nil && !comparison.ValueContainsSpecial(displayTitle) && !comparison.ValueContainsNC(displayTitle) { + if len(metadata.EpisodeNumber) == 1 { + ep := util.StringToIntMust(metadata.EpisodeNumber[0]) + parsedEpisodeNumber = ep + displayTitle = fmt.Sprintf("Episode %d", ep) + if metadata.EpisodeTitle != "" { + displayTitle = fmt.Sprintf("%s - %s", displayTitle, metadata.EpisodeTitle) + } + } + } + + if !containsAbsoluteEps { + isLikely = parsedEpisodeNumber == opts.EpisodeNumber + } + + mu2.Lock() + // Get the file preview + ret = append(ret, &FilePreview{ + Path: file.Path(), + DisplayPath: file.DisplayPath(), + DisplayTitle: displayTitle, + EpisodeNumber: parsedEpisodeNumber, + IsLikely: isLikely, + Index: i, + }) + mu2.Unlock() + }(i, file) + } + + wg.Wait() + + r.logger.Debug().Str("hash", opts.Torrent.InfoHash).Msg("torrentstream: Got file previews for torrent selection, dropping torrent") + go selectedTorrent.Drop() + + return +} diff --git a/seanime-2.9.10/internal/torrentstream/repository.go b/seanime-2.9.10/internal/torrentstream/repository.go new file mode 100644 index 0000000..c4a48e5 --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/repository.go @@ -0,0 +1,231 @@ +package torrentstream + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/database/db" + "seanime/internal/database/models" + "seanime/internal/directstream" + "seanime/internal/events" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/library/anime" + "seanime/internal/library/playbackmanager" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/nativeplayer" + "seanime/internal/platforms/platform" + "seanime/internal/torrents/torrent" + "seanime/internal/util" + "seanime/internal/util/result" + + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +type ( + Repository struct { + client *Client + handler *handler + playback playback + settings mo.Option[Settings] // None by default, set and refreshed by [SetSettings] + + selectionHistoryMap *result.Map[int, *hibiketorrent.AnimeTorrent] // Key: AniList media ID + + // Injected dependencies + torrentRepository *torrent.Repository + baseAnimeCache *anilist.BaseAnimeCache + completeAnimeCache *anilist.CompleteAnimeCache + platform platform.Platform + wsEventManager events.WSEventManagerInterface + metadataProvider metadata.Provider + playbackManager *playbackmanager.PlaybackManager + mediaPlayerRepository *mediaplayer.Repository + mediaPlayerRepositorySubscriber *mediaplayer.RepositorySubscriber + nativePlayerSubscriber *nativeplayer.Subscriber + directStreamManager *directstream.Manager + nativePlayer *nativeplayer.NativePlayer + logger *zerolog.Logger + db *db.Database + + onEpisodeCollectionChanged func(ec *anime.EpisodeCollection) + + previousStreamOptions mo.Option[*StartStreamOptions] + } + + Settings struct { + models.TorrentstreamSettings + Host string + Port int + } + + NewRepositoryOptions struct { + Logger *zerolog.Logger + TorrentRepository *torrent.Repository + BaseAnimeCache *anilist.BaseAnimeCache + CompleteAnimeCache *anilist.CompleteAnimeCache + Platform platform.Platform + MetadataProvider metadata.Provider + PlaybackManager *playbackmanager.PlaybackManager + WSEventManager events.WSEventManagerInterface + Database *db.Database + DirectStreamManager *directstream.Manager + NativePlayer *nativeplayer.NativePlayer + } +) + +// NewRepository creates a new injectable Repository instance +func NewRepository(opts *NewRepositoryOptions) *Repository { + ret := &Repository{ + client: nil, + handler: nil, + settings: mo.Option[Settings]{}, + selectionHistoryMap: result.NewResultMap[int, *hibiketorrent.AnimeTorrent](), + torrentRepository: opts.TorrentRepository, + baseAnimeCache: opts.BaseAnimeCache, + completeAnimeCache: opts.CompleteAnimeCache, + platform: opts.Platform, + wsEventManager: opts.WSEventManager, + metadataProvider: opts.MetadataProvider, + playbackManager: opts.PlaybackManager, + mediaPlayerRepository: nil, + mediaPlayerRepositorySubscriber: nil, + logger: opts.Logger, + db: opts.Database, + directStreamManager: opts.DirectStreamManager, + nativePlayer: opts.NativePlayer, + previousStreamOptions: mo.None[*StartStreamOptions](), + } + ret.client = NewClient(ret) + ret.handler = newHandler(ret) + return ret +} + +func (r *Repository) IsEnabled() bool { + return r.settings.IsPresent() && r.settings.MustGet().Enabled && r.client != nil +} + +func (r *Repository) GetPreviousStreamOptions() (*StartStreamOptions, bool) { + return r.previousStreamOptions.OrElse(nil), r.previousStreamOptions.IsPresent() +} + +// SetMediaPlayerRepository sets the mediaplayer repository and listens to events. +// This MUST be called after instantiating the repository and will run even if the module is disabled. +// +// // Note: This is also used for Debrid streaming +func (r *Repository) SetMediaPlayerRepository(mediaPlayerRepository *mediaplayer.Repository) { + r.mediaPlayerRepository = mediaPlayerRepository + r.listenToMediaPlayerEvents() +} + +// InitModules sets the settings for the torrentstream module. +// It should be called before any other method, to ensure the module is active. +func (r *Repository) InitModules(settings *models.TorrentstreamSettings, host string, port int) (err error) { + r.client.Shutdown() + + defer util.HandlePanicInModuleWithError("torrentstream/InitModules", &err) + + if settings == nil { + r.logger.Error().Msg("torrentstream: Cannot initialize module, no settings provided") + r.settings = mo.None[Settings]() + return errors.New("torrentstream: Cannot initialize module, no settings provided") + } + + s := *settings + + if s.Enabled == false { + r.logger.Info().Msg("torrentstream: Module is disabled") + r.Shutdown() + r.settings = mo.None[Settings]() + return nil + } + + // Set default download directory, which is a temporary directory + if s.DownloadDir == "" { + s.DownloadDir = r.getDefaultDownloadPath() + _ = os.MkdirAll(s.DownloadDir, os.ModePerm) // Create the directory if it doesn't exist + } + + // DEVNOTE: Commented code below causes error log after initializing the client + //// Empty the download directory + //_ = os.RemoveAll(s.DownloadDir) + + if s.StreamingServerPort == 0 { + s.StreamingServerPort = 43214 + } + if s.TorrentClientPort == 0 { + s.TorrentClientPort = 43213 + } + if s.StreamingServerHost == "" { + s.StreamingServerHost = "127.0.0.1" + } + + // Set the settings + r.settings = mo.Some(Settings{ + TorrentstreamSettings: s, + Host: host, + Port: port, + }) + + // Initialize the torrent client + err = r.client.initializeClient() + if err != nil { + return err + } + + // Start listening to native player events + r.listenToNativePlayerEvents() + + r.logger.Info().Msg("torrentstream: Module initialized") + return nil +} + +func (r *Repository) HTTPStreamHandler() http.Handler { + return r.handler +} + +func (r *Repository) FailIfNoSettings() error { + if r.settings.IsAbsent() { + return errors.New("torrentstream: no settings provided, the module is dormant") + } + return nil +} + +// Shutdown closes the torrent client and streaming server +// TEST-ONLY +func (r *Repository) Shutdown() { + r.logger.Debug().Msg("torrentstream: Shutting down module") + r.client.Shutdown() +} + +//// Cleanup shuts down the module and removes the download directory +//func (r *Repository) Cleanup() { +// if r.settings.IsAbsent() { +// return +// } +// r.client.Close() +// +// // Remove the download directory +// downloadDir := r.GetDownloadDir() +// _ = os.RemoveAll(downloadDir) +//} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) GetDownloadDir() string { + if r.settings.IsAbsent() { + return r.getDefaultDownloadPath() + } + if r.settings.MustGet().DownloadDir == "" { + return r.getDefaultDownloadPath() + } + return r.settings.MustGet().DownloadDir +} + +func (r *Repository) getDefaultDownloadPath() string { + tempDir := os.TempDir() + downloadDirPath := filepath.Join(tempDir, "seanime", "torrentstream") + return downloadDirPath +} diff --git a/seanime-2.9.10/internal/torrentstream/stream.go b/seanime-2.9.10/internal/torrentstream/stream.go new file mode 100644 index 0000000..79d0fbb --- /dev/null +++ b/seanime-2.9.10/internal/torrentstream/stream.go @@ -0,0 +1,391 @@ +package torrentstream + +import ( + "context" + "fmt" + "seanime/internal/api/anilist" + "seanime/internal/api/metadata" + "seanime/internal/directstream" + "seanime/internal/events" + hibiketorrent "seanime/internal/extension/hibike/torrent" + "seanime/internal/hook" + "seanime/internal/library/playbackmanager" + "seanime/internal/util" + "strconv" + "time" + + "github.com/anacrolix/torrent" + "github.com/samber/mo" +) + +type PlaybackType string + +const ( + PlaybackTypeExternal PlaybackType = "default" // External player + PlaybackTypeExternalPlayerLink PlaybackType = "externalPlayerLink" + PlaybackTypeNativePlayer PlaybackType = "nativeplayer" + PlaybackTypeNone PlaybackType = "none" + PlaybackTypeNoneAndAwait PlaybackType = "noneAndAwait" +) + +type StartStreamOptions struct { + MediaId int + EpisodeNumber int // RELATIVE Episode number to identify the file + AniDBEpisode string // Animap episode + AutoSelect bool // Automatically select the best file to stream + Torrent *hibiketorrent.AnimeTorrent // Selected torrent (Manual selection) + FileIndex *int // Index of the file to stream (Manual selection) + UserAgent string + ClientId string + PlaybackType PlaybackType +} + +// StartStream is called by the client to start streaming a torrent +func (r *Repository) StartStream(ctx context.Context, opts *StartStreamOptions) (err error) { + defer util.HandlePanicInModuleWithError("torrentstream/stream/StartStream", &err) + // DEVNOTE: Do not + //r.Shutdown() + + r.previousStreamOptions = mo.Some(opts) + + r.logger.Info(). + Str("clientId", opts.ClientId). + Any("playbackType", opts.PlaybackType). + Int("mediaId", opts.MediaId).Msgf("torrentstream: Starting stream for episode %s", opts.AniDBEpisode) + + r.sendStateEvent(eventLoading) + r.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "torrentstream") + defer func() { + r.wsEventManager.SendEvent(events.HideIndefiniteLoader, "torrentstream") + }() + + if opts.PlaybackType == PlaybackTypeNativePlayer { + r.directStreamManager.PrepareNewStream(opts.ClientId, "Selecting torrent...") + } + + // + // Get the media info + // + media, _, err := r.GetMediaInfo(ctx, opts.MediaId) + if err != nil { + return err + } + + episodeNumber := opts.EpisodeNumber + aniDbEpisode := strconv.Itoa(episodeNumber) + + // + // Find the best torrent / Select the torrent + // + var torrentToStream *playbackTorrent + if opts.AutoSelect { + torrentToStream, err = r.findBestTorrent(media, aniDbEpisode, episodeNumber) + if err != nil { + r.sendStateEvent(eventLoadingFailed) + return err + } + } else { + if opts.Torrent == nil { + return fmt.Errorf("torrentstream: No torrent provided") + } + torrentToStream, err = r.findBestTorrentFromManualSelection(opts.Torrent, media, aniDbEpisode, opts.FileIndex) + if err != nil { + r.sendStateEvent(eventLoadingFailed) + return err + } + } + + if torrentToStream == nil { + r.sendStateEvent(eventLoadingFailed) + return fmt.Errorf("torrentstream: No torrent selected") + } + + // + // Set current file & torrent + // + r.client.currentFile = mo.Some(torrentToStream.File) + r.client.currentTorrent = mo.Some(torrentToStream.Torrent) + + r.sendStateEvent(eventLoading, TLSStateSendingStreamToMediaPlayer) + + go func() { + // Add the torrent to the history if it is a batch & manually selected + if len(r.client.currentTorrent.MustGet().Files()) > 1 && opts.Torrent != nil { + r.AddBatchHistory(opts.MediaId, opts.Torrent) // ran in goroutine + } + }() + + // + // Start the playback + // + go func() { + switch opts.PlaybackType { + case PlaybackTypeNone: + r.logger.Warn().Msg("torrentstream: Playback type is set to 'none'") + // Signal to the client that the torrent has started playing (remove loading status) + // There will be no tracking + r.sendStateEvent(eventTorrentStartedPlaying) + case PlaybackTypeNoneAndAwait: + r.logger.Warn().Msg("torrentstream: Playback type is set to 'noneAndAwait'") + // Signal to the client that the torrent has started playing (remove loading status) + // There will be no tracking + for { + if r.client.readyToStream() { + break + } + time.Sleep(3 * time.Second) // Wait for 3 secs before checking again + } + r.sendStateEvent(eventTorrentStartedPlaying) + // + // External player + // + case PlaybackTypeExternal, PlaybackTypeExternalPlayerLink: + r.sendStreamToExternalPlayer(opts, media, aniDbEpisode) + // + // Direct stream + // + case PlaybackTypeNativePlayer: + readyCh, err := r.directStreamManager.PlayTorrentStream(ctx, directstream.PlayTorrentStreamOptions{ + ClientId: opts.ClientId, + EpisodeNumber: opts.EpisodeNumber, + AnidbEpisode: opts.AniDBEpisode, + Media: media.ToBaseAnime(), + Torrent: r.client.currentTorrent.MustGet(), + File: r.client.currentFile.MustGet(), + }) + if err != nil { + r.logger.Error().Err(err).Msg("torrentstream: Failed to prepare new stream") + r.sendStateEvent(eventLoadingFailed) + return + } + + if opts.PlaybackType == PlaybackTypeNativePlayer { + r.directStreamManager.PrepareNewStream(opts.ClientId, "Downloading metadata...") + } + + // Make sure the client is ready and the torrent is partially downloaded + for { + if r.client.readyToStream() { + break + } + // If for some reason the torrent is dropped, we kill the goroutine + if r.client.torrentClient.IsAbsent() || r.client.currentTorrent.IsAbsent() { + return + } + r.logger.Debug().Msg("torrentstream: Waiting for playable threshold to be reached") + time.Sleep(3 * time.Second) // Wait for 3 secs before checking again + } + close(readyCh) + } + }() + + r.sendStateEvent(eventTorrentLoaded) + r.logger.Info().Msg("torrentstream: Stream started") + + return nil +} + +// sendStreamToExternalPlayer sends the stream to the desktop player or external player link. +// It blocks until the some pieces have been downloaded before sending the stream for faster playback. +func (r *Repository) sendStreamToExternalPlayer(opts *StartStreamOptions, completeAnime *anilist.CompleteAnime, aniDbEpisode string) { + + baseAnime := completeAnime.ToBaseAnime() + + r.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "torrentstream") + defer func() { + r.wsEventManager.SendEvent(events.HideIndefiniteLoader, "torrentstream") + }() + + // Make sure the client is ready and the torrent is partially downloaded + for { + if r.client.readyToStream() { + break + } + // If for some reason the torrent is dropped, we kill the goroutine + if r.client.torrentClient.IsAbsent() || r.client.currentTorrent.IsAbsent() { + return + } + r.logger.Debug().Msg("torrentstream: Waiting for playable threshold to be reached") + time.Sleep(3 * time.Second) // Wait for 3 secs before checking again + } + + event := &TorrentStreamSendStreamToMediaPlayerEvent{ + WindowTitle: "", + StreamURL: r.client.GetStreamingUrl(), + Media: baseAnime, + AniDbEpisode: aniDbEpisode, + PlaybackType: string(opts.PlaybackType), + } + err := hook.GlobalHookManager.OnTorrentStreamSendStreamToMediaPlayer().Trigger(event) + if err != nil { + r.logger.Error().Err(err).Msg("torrentstream: Failed to trigger hook") + return + } + windowTitle := event.WindowTitle + streamURL := event.StreamURL + baseAnime = event.Media + aniDbEpisode = event.AniDbEpisode + playbackType := PlaybackType(event.PlaybackType) + + if event.DefaultPrevented { + r.logger.Debug().Msg("torrentstream: Stream prevented by hook") + return + } + + switch playbackType { + // + // Desktop player + // + case PlaybackTypeExternal: + r.logger.Debug().Msgf("torrentstream: Starting the media player %s", streamURL) + err = r.playbackManager.StartStreamingUsingMediaPlayer(windowTitle, &playbackmanager.StartPlayingOptions{ + Payload: streamURL, + UserAgent: opts.UserAgent, + ClientId: opts.ClientId, + }, baseAnime, aniDbEpisode) + if err != nil { + // Failed to start the stream, we'll drop the torrents and stop the server + r.sendStateEvent(eventLoadingFailed) + _ = r.StopStream() + r.logger.Error().Err(err).Msg("torrentstream: Failed to start the stream") + r.wsEventManager.SendEventTo(opts.ClientId, events.ErrorToast, err.Error()) + } + + r.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "torrentstream") + defer func() { + r.wsEventManager.SendEvent(events.HideIndefiniteLoader, "torrentstream") + }() + + r.playbackManager.RegisterMediaPlayerCallback(func(event playbackmanager.PlaybackEvent, cancelFunc func()) { + switch event.(type) { + case playbackmanager.StreamStartedEvent: + r.logger.Debug().Msg("torrentstream: Media player started playing") + r.wsEventManager.SendEvent(events.HideIndefiniteLoader, "torrentstream") + cancelFunc() + } + }) + + // + // External player link + // + case PlaybackTypeExternalPlayerLink: + r.logger.Debug().Msgf("torrentstream: Sending stream to external player %s", streamURL) + r.wsEventManager.SendEventTo(opts.ClientId, events.ExternalPlayerOpenURL, struct { + Url string `json:"url"` + MediaId int `json:"mediaId"` + EpisodeNumber int `json:"episodeNumber"` + MediaTitle string `json:"mediaTitle"` + }{ + Url: r.client.GetExternalPlayerStreamingUrl(), + MediaId: opts.MediaId, + EpisodeNumber: opts.EpisodeNumber, + MediaTitle: baseAnime.GetPreferredTitle(), + }) + + // Signal to the client that the torrent has started playing (remove loading status) + // We can't know for sure + r.sendStateEvent(eventTorrentStartedPlaying) + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type StartUntrackedStreamOptions struct { + Magnet string + FileIndex int + WindowTitle string + UserAgent string + ClientId string + PlaybackType PlaybackType +} + +func (r *Repository) StopStream() error { + defer func() { + if r := recover(); r != nil { + } + }() + r.logger.Info().Msg("torrentstream: Stopping stream") + + // Stop the client + // This will stop the stream and close the server + // This also sends the eventTorrentStopped event + r.client.mu.Lock() + //r.client.stopCh = make(chan struct{}) + r.client.repository.logger.Debug().Msg("torrentstream: Handling media player stopped event") + // This is to prevent the client from downloading the whole torrent when the user stops watching + // Also, the torrent might be a batch - so we don't want to download the whole thing + if r.client.currentTorrent.IsPresent() { + if r.client.currentTorrentStatus.ProgressPercentage < 70 { + r.client.repository.logger.Debug().Msg("torrentstream: Dropping torrent, completion is less than 70%") + r.client.dropTorrents() + } + r.client.repository.logger.Debug().Msg("torrentstream: Resetting current torrent and status") + } + r.client.currentTorrent = mo.None[*torrent.Torrent]() // Reset the current torrent + r.client.currentFile = mo.None[*torrent.File]() // Reset the current file + r.client.currentTorrentStatus = TorrentStatus{} // Reset the torrent status + r.client.repository.sendStateEvent(eventTorrentStopped, nil) // Send torrent stopped event + r.client.repository.mediaPlayerRepository.Stop() // Stop the media player gracefully if it's running + r.client.mu.Unlock() + + go func() { + r.nativePlayer.Stop() + }() + + r.logger.Info().Msg("torrentstream: Stream stopped") + + return nil +} + +func (r *Repository) DropTorrent() error { + r.logger.Info().Msg("torrentstream: Dropping last torrent") + + if r.client.torrentClient.IsAbsent() { + return nil + } + + for _, t := range r.client.torrentClient.MustGet().Torrents() { + t.Drop() + } + + r.mediaPlayerRepository.Stop() + + r.logger.Info().Msg("torrentstream: Dropped last torrent") + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Repository) GetMediaInfo(ctx context.Context, mediaId int) (media *anilist.CompleteAnime, animeMetadata *metadata.AnimeMetadata, err error) { + // Get the media + var found bool + media, found = r.completeAnimeCache.Get(mediaId) + if !found { + // Fetch the media + media, err = r.platform.GetAnimeWithRelations(ctx, mediaId) + if err != nil { + return nil, nil, fmt.Errorf("torrentstream: Failed to fetch media: %w", err) + } + } + + // Get the media + animeMetadata, err = r.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mediaId) + if err != nil { + //return nil, nil, fmt.Errorf("torrentstream: Could not fetch AniDB media: %w", err) + animeMetadata = &metadata.AnimeMetadata{ + Titles: make(map[string]string), + Episodes: make(map[string]*metadata.EpisodeMetadata), + EpisodeCount: 0, + SpecialCount: 0, + Mappings: &metadata.AnimeMappings{ + AnilistId: media.GetID(), + }, + } + animeMetadata.Titles["en"] = media.GetTitleSafe() + animeMetadata.Titles["x-jat"] = media.GetRomajiTitleSafe() + err = nil + } + + return +} diff --git a/seanime-2.9.10/internal/troubleshooter/check.go b/seanime-2.9.10/internal/troubleshooter/check.go new file mode 100644 index 0000000..424b21c --- /dev/null +++ b/seanime-2.9.10/internal/troubleshooter/check.go @@ -0,0 +1,74 @@ +package troubleshooter + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" +) + +// IsExecutable checks if a given path points to an executable file or if a command exists in PATH +func IsExecutable(name string) (string, error) { + // If name contains any path separators, treat it as a path + if strings.Contains(name, string(os.PathSeparator)) { + path, err := filepath.Abs(name) + if err != nil { + return "", err + } + return checkExecutable(path) + } + + // Otherwise, search in PATH + return findInPath(name) +} + +// findInPath searches for an executable in the system's PATH +func findInPath(name string) (string, error) { + // On Windows, also check for .exe extension if not provided + if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(name), ".exe") { + name += ".exe" + } + + // Get system PATH + pathEnv := os.Getenv("PATH") + paths := strings.Split(pathEnv, string(os.PathListSeparator)) + + // Search each directory in PATH + for _, dir := range paths { + if dir == "" { + continue + } + path := filepath.Join(dir, name) + fullPath, err := checkExecutable(path) + if err == nil { + return fullPath, nil + } + } + + return "", errors.New("executable not found in PATH") +} + +// checkExecutable verifies if a given path points to an executable file +func checkExecutable(path string) (string, error) { + fileInfo, err := os.Stat(path) + if err != nil { + return "", err + } + + if fileInfo.IsDir() { + return "", errors.New("path points to a directory") + } + + // On Windows, just check if the file exists (as Windows uses file extensions) + if runtime.GOOS == "windows" { + return path, nil + } + + // On Unix-like systems, check if the file is executable + if fileInfo.Mode()&0111 != 0 { + return path, nil + } + + return "", errors.New("file is not executable") +} diff --git a/seanime-2.9.10/internal/troubleshooter/logs.go b/seanime-2.9.10/internal/troubleshooter/logs.go new file mode 100644 index 0000000..ff2350e --- /dev/null +++ b/seanime-2.9.10/internal/troubleshooter/logs.go @@ -0,0 +1,381 @@ +package troubleshooter + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "seanime/internal/util" + "sort" + "strings" + "time" +) + +type AnalysisResult struct { + Items []AnalysisResultItem `json:"items"` +} + +type AnalysisResultItem struct { + Observation string `json:"observation"` + Recommendation string `json:"recommendation"` + Severity string `json:"severity"` + Errors []string `json:"errors"` + Warnings []string `json:"warnings"` + Logs []string `json:"logs"` +} + +// RuleBuilder provides a fluent interface for building rules +type RuleBuilder struct { + name string + description string + conditions []condition + platforms []string + branches []branch + defaultBranch *branch + state *AppState +} + +type condition struct { + check func(LogLine) bool + message string // For debugging/logging +} + +type branch struct { + conditions []condition + observation string + recommendation string + severity string +} + +// NewRule starts building a new rule +func NewRule(name string) *RuleBuilder { + return &RuleBuilder{ + name: name, + conditions: []condition{}, + branches: []branch{}, + defaultBranch: &branch{ + severity: "info", + }, + } +} + +// Desc adds a description to the rule +func (r *RuleBuilder) Desc(desc string) *RuleBuilder { + r.description = desc + return r +} + +// When adds a base condition that must be met +func (r *RuleBuilder) When(check func(LogLine) bool, message string) *RuleBuilder { + r.conditions = append(r.conditions, condition{check: check, message: message}) + return r +} + +// ModuleIs adds a module condition +func (r *RuleBuilder) ModuleIs(module Module) *RuleBuilder { + return r.When(func(l LogLine) bool { + return l.Module == string(module) + }, "module is "+string(module)) +} + +// LevelIs adds a level condition +func (r *RuleBuilder) LevelIs(level Level) *RuleBuilder { + return r.When(func(l LogLine) bool { + return l.Level == string(level) + }, "level is "+string(level)) +} + +// MessageContains adds a message contains condition +func (r *RuleBuilder) MessageContains(substr string) *RuleBuilder { + return r.When(func(l LogLine) bool { + return strings.Contains(l.Message, substr) + }, "message contains "+substr) +} + +// MessageMatches adds a message regex condition +func (r *RuleBuilder) MessageMatches(pattern string) *RuleBuilder { + return r.When(func(l LogLine) bool { + matched, err := util.MatchesRegex(l.Message, pattern) + return err == nil && matched + }, "message matches "+pattern) +} + +// OnPlatforms restricts the rule to specific platforms +func (r *RuleBuilder) OnPlatforms(platforms ...string) *RuleBuilder { + r.platforms = platforms + return r +} + +// Branch adds a new branch with additional conditions +func (r *RuleBuilder) Branch() *BranchBuilder { + return &BranchBuilder{ + rule: r, + branch: branch{ + conditions: []condition{}, + }, + } +} + +// Then sets the default observation and recommendation +func (r *RuleBuilder) Then(observation, recommendation string) *RuleBuilder { + r.defaultBranch.observation = observation + r.defaultBranch.recommendation = recommendation + return r +} + +// WithSeverity sets the default severity +func (r *RuleBuilder) WithSeverity(severity string) *RuleBuilder { + r.defaultBranch.severity = severity + return r +} + +// BranchBuilder helps build conditional branches +type BranchBuilder struct { + rule *RuleBuilder + branch branch +} + +// When adds a condition to the branch +func (b *BranchBuilder) When(check func(LogLine) bool, message string) *BranchBuilder { + b.branch.conditions = append(b.branch.conditions, condition{check: check, message: message}) + return b +} + +// Then sets the branch observation and recommendation +func (b *BranchBuilder) Then(observation, recommendation string) *RuleBuilder { + b.branch.observation = observation + b.branch.recommendation = recommendation + b.rule.branches = append(b.rule.branches, b.branch) + return b.rule +} + +// WithSeverity sets the branch severity +func (b *BranchBuilder) WithSeverity(severity string) *BranchBuilder { + b.branch.severity = severity + return b +} + +// matches checks if a log line matches the rule and returns the matching branch +func (r *RuleBuilder) matches(line LogLine, platform string) (bool, *branch) { + // Check platform restrictions + if len(r.platforms) > 0 && !util.Contains(r.platforms, platform) { + return false, nil + } + + // Check base conditions + for _, cond := range r.conditions { + if !cond.check(line) { + return false, nil + } + } + + // Check branches in order + for _, branch := range r.branches { + matches := true + for _, cond := range branch.conditions { + if !cond.check(line) { + matches = false + break + } + } + if matches { + return true, &branch + } + } + + // If no branches match but base conditions do, use default branch + return true, r.defaultBranch +} + +// NewAnalyzer creates a new analyzer with the default rule groups +func NewAnalyzer(opts NewTroubleshooterOptions) *Troubleshooter { + a := &Troubleshooter{ + logsDir: opts.LogsDir, + logger: opts.Logger, + state: opts.State, + rules: defaultRules(), + } + return a +} + +// defaultRules returns the default set of rules +func defaultRules() []RuleBuilder { + return []RuleBuilder{ + *mpvRules(), + } +} + +// Analyze analyzes the logs in the logs directory and returns an AnalysisResult +// App.OnFlushLogs should be called before this function +func (t *Troubleshooter) Analyze() (AnalysisResult, error) { + + files, err := os.ReadDir(t.logsDir) + if err != nil { + return AnalysisResult{}, err + } + + if len(files) == 0 { + return AnalysisResult{}, errors.New("no logs found") + } + + // Get the latest server log file + // name: seanime-.log + // e.g., seanime-2025-01-21-12-00-00.log + sort.Slice(files, func(i, j int) bool { + return files[i].Name() > files[j].Name() + }) + + latestFile := files[0] + + return analyzeLogFile(filepath.Join(t.logsDir, latestFile.Name())) +} + +// LogLine represents a parsed log line +type LogLine struct { + Timestamp time.Time + Line string + Module string + Level string + Message string +} + +// analyzeLogFile analyzes a log file and returns an AnalysisResult +func analyzeLogFile(filepath string) (res AnalysisResult, err error) { + platform := runtime.GOOS + + // Read the log file + content, err := os.ReadFile(filepath) + if err != nil { + return res, err + } + + lines := strings.Split(string(content), "\n") + + // Get lines no older than 1 hour + _lines := []string{} + for _, line := range lines { + timestamp, ok := util.SliceStrTo(line, len(time.DateTime)) + if !ok { + continue + } + timestampTime, err := time.Parse(time.DateTime, timestamp) + if err != nil { + continue + } + if time.Since(timestampTime) < 1*time.Hour { + _lines = append(_lines, line) + } + } + lines = _lines + + // Parse lines into LogLine + logLines := []LogLine{} + for _, line := range lines { + logLine, err := parseLogLine(line) + if err != nil { + continue + } + logLines = append(logLines, logLine) + } + + // Group log lines by rule group + type matchGroup struct { + ruleGroup *RuleBuilder + branch *branch + logLines []LogLine + } + matches := make(map[string]*matchGroup) // key is rule group name + + // For each log line, check against all rules + for _, logLine := range logLines { + for _, ruleGroup := range defaultRules() { + if matched, branch := ruleGroup.matches(logLine, platform); matched { + if _, ok := matches[ruleGroup.name]; !ok { + matches[ruleGroup.name] = &matchGroup{ + ruleGroup: &ruleGroup, + branch: branch, + logLines: []LogLine{}, + } + } + matches[ruleGroup.name].logLines = append(matches[ruleGroup.name].logLines, logLine) + break // Stop checking other rules in this group once we find a match + } + } + } + + // Convert matches to analysis result items + for _, group := range matches { + item := AnalysisResultItem{ + Observation: group.branch.observation, + Recommendation: group.branch.recommendation, + Severity: group.branch.severity, + } + + // Add log lines based on their level + for _, logLine := range group.logLines { + switch logLine.Level { + case "error": + item.Errors = append(item.Errors, logLine.Line) + case "warning": + item.Warnings = append(item.Warnings, logLine.Line) + default: + item.Logs = append(item.Logs, logLine.Line) + } + } + + res.Items = append(res.Items, item) + } + + return res, nil +} + +func parseLogLine(line string) (ret LogLine, err error) { + + ret.Line = line + + timestamp, ok := util.SliceStrTo(line, len(time.DateTime)) + if !ok { + return LogLine{}, errors.New("failed to parse timestamp") + } + timestampTime, err := time.Parse(time.DateTime, timestamp) + if err != nil { + return LogLine{}, errors.New("failed to parse timestamp") + } + ret.Timestamp = timestampTime + + rest, ok := util.SliceStrFrom(line, len(timestamp)) + if !ok { + return LogLine{}, errors.New("failed to parse rest") + } + rest = strings.TrimSpace(rest) + if strings.HasPrefix(rest, "|ERR|") { + ret.Level = "error" + } else if strings.HasPrefix(rest, "|WRN|") { + ret.Level = "warning" + } else if strings.HasPrefix(rest, "|INF|") { + ret.Level = "info" + } else if strings.HasPrefix(rest, "|DBG|") { + ret.Level = "debug" + } else if strings.HasPrefix(rest, "|TRC|") { + ret.Level = "trace" + } else if strings.HasPrefix(rest, "|PNC|") { + ret.Level = "panic" + } + + // Remove the level prefix + rest, ok = util.SliceStrFrom(rest, 6) + if !ok { + return LogLine{}, errors.New("failed to parse rest") + } + + // Get the module (string before `>`) + moduleCaretIndex := strings.Index(rest, ">") + if moduleCaretIndex != -1 { + ret.Module = strings.TrimSpace(rest[:moduleCaretIndex]) + rest = strings.TrimSpace(rest[moduleCaretIndex+1:]) + } + + ret.Message = rest + + return +} diff --git a/seanime-2.9.10/internal/troubleshooter/mpv_rule.go b/seanime-2.9.10/internal/troubleshooter/mpv_rule.go new file mode 100644 index 0000000..8cc31af --- /dev/null +++ b/seanime-2.9.10/internal/troubleshooter/mpv_rule.go @@ -0,0 +1,31 @@ +package troubleshooter + +import ( + "strings" +) + +func mpvRules() *RuleBuilder { + return NewRule("MPV Player"). + Desc("Rules for detecting MPV player related issues"). + ModuleIs(ModuleMediaPlayer). + LevelIs(LevelError). + Branch(). + When(func(l LogLine) bool { + return strings.Contains(l.Message, "Could not open and play video using MPV") + }, "MPV player failed to open video"). + Then( + "Seanime cannot communicate with MPV", + "Go to the settings and set the correct application path for MPV", + ). + WithSeverity("error"). + Branch(). + When(func(l LogLine) bool { + return strings.Contains(l.Message, "fork/exec") + }, "MPV player process failed to start"). + Then( + "The MPV player process failed to start", + "Check if MPV is installed correctly and the application path is valid", + ). + WithSeverity("error") + +} diff --git a/seanime-2.9.10/internal/troubleshooter/settings_rule.go b/seanime-2.9.10/internal/troubleshooter/settings_rule.go new file mode 100644 index 0000000..46da8ac --- /dev/null +++ b/seanime-2.9.10/internal/troubleshooter/settings_rule.go @@ -0,0 +1,19 @@ +package troubleshooter + +func mediaPlayerRules(state *AppState) *RuleBuilder { + return NewRule("Media Player") + // Desc("Rules for detecting media player issues"). + // ModuleIs(ModuleMediaPlayer). + // LevelIs(LevelError). + // // Branch that checks if MPV is configured + // Branch(). + // When(func(l LogLine) bool { + // mpvPath, ok := state.Settings["mpv_path"].(string) + // return strings.Contains(l.Message, "player not found") && (!ok || mpvPath == "") + // }, "MPV not configured"). + // Then( + // "MPV player is not configured", + // "Go to settings and configure the MPV player path", + // ). + // WithSeverity("error"). +} diff --git a/seanime-2.9.10/internal/troubleshooter/troubleshooter.go b/seanime-2.9.10/internal/troubleshooter/troubleshooter.go new file mode 100644 index 0000000..d736d1e --- /dev/null +++ b/seanime-2.9.10/internal/troubleshooter/troubleshooter.go @@ -0,0 +1,172 @@ +package troubleshooter + +import ( + "fmt" + "seanime/internal/database/models" + "seanime/internal/mediaplayers/mediaplayer" + "seanime/internal/onlinestream" + "seanime/internal/torrentstream" + + "github.com/rs/zerolog" +) + +type ( + Troubleshooter struct { + logsDir string + logger *zerolog.Logger + rules []RuleBuilder + state *AppState // For accessing app state like settings + modules *Modules + clientParams ClientParams + currentResult Result + } + + Modules struct { + MediaPlayerRepository *mediaplayer.Repository + OnlinestreamRepository *onlinestream.Repository + TorrentstreamRepository *torrentstream.Repository + } + + NewTroubleshooterOptions struct { + LogsDir string + Logger *zerolog.Logger + State *AppState + } + + AppState struct { + Settings *models.Settings + TorrentstreamSettings *models.TorrentstreamSettings + MediastreamSettings *models.MediastreamSettings + DebridSettings *models.DebridSettings + } + + Result struct { + Items []ResultItem `json:"items"` + } + + ResultItem struct { + Module Module `json:"module"` + Observation string `json:"observation"` + Recommendation string `json:"recommendation"` + Level Level `json:"level"` + Errors []string `json:"errors"` + Warnings []string `json:"warnings"` + Logs []string `json:"logs"` + } +) + +type ( + Module string + Level string +) + +const ( + LevelError Level = "error" + LevelWarning Level = "warning" + LevelInfo Level = "info" + LevelDebug Level = "debug" +) + +const ( + ModulePlayback Module = "Playback" + ModuleMediaPlayer Module = "Media player" + ModuleAnimeLibrary Module = "Anime library" + ModuleMediaStreaming Module = "Media streaming" + ModuleTorrentStreaming Module = "Torrent streaming" +) + +func NewTroubleshooter(opts NewTroubleshooterOptions, modules *Modules) *Troubleshooter { + return &Troubleshooter{ + logsDir: opts.LogsDir, + logger: opts.Logger, + state: opts.State, + modules: modules, + } +} + +//////////////////// + +type ( + ClientParams struct { + LibraryPlaybackOption string `json:"libraryPlaybackOption"` // "desktop_media_player" or "media_streaming" or "external_player_link" + TorrentOrDebridPlaybackOption string `json:"torrentOrDebridPlaybackOption"` // "desktop_torrent_player" or "external_player_link" + } +) + +func (t *Troubleshooter) Run(clientParams ClientParams) { + t.logger.Info().Msg("troubleshooter: Running troubleshooter") + t.clientParams = clientParams + t.currentResult = Result{} + + go t.checkModule(ModulePlayback) + +} + +func (t *Troubleshooter) checkModule(module Module) { + t.logger.Info().Str("module", string(module)).Msg("troubleshooter: Checking module") + + switch module { + case ModulePlayback: + t.checkPlayback() + } +} + +func (t *Troubleshooter) checkPlayback() { + t.logger.Info().Msg("troubleshooter: Checking playback") + + switch t.clientParams.LibraryPlaybackOption { + case "desktop_media_player": + t.currentResult.AddItem(ResultItem{ + Module: ModulePlayback, + Observation: "Your downloaded anime files will be played using the desktop media player you have selected on this device.", + Level: LevelInfo, + }) + t.checkDesktopMediaPlayer() + case "media_streaming": + t.currentResult.AddItem(ResultItem{ + Module: ModulePlayback, + Observation: "Your downloaded anime files will be played using the media streaming (integrated player) on this device.", + Level: LevelInfo, + }) + case "external_player_link": + t.currentResult.AddItem(ResultItem{ + Module: ModulePlayback, + Observation: "Your downloaded anime files will be played using the external player link you have entered on this device.", + Level: LevelInfo, + }) + } +} + +func (t *Troubleshooter) checkDesktopMediaPlayer() { + t.logger.Info().Msg("troubleshooter: Checking desktop media player") + + binaryPath := t.modules.MediaPlayerRepository.GetExecutablePath() + defaultPlayer := t.modules.MediaPlayerRepository.GetDefault() + + if binaryPath == "" { + t.currentResult.AddItem(ResultItem{ + Module: ModuleMediaPlayer, + Observation: fmt.Sprintf("You have selected %s as your desktop media player, but haven't set up the application path for it in the settings.", defaultPlayer), + Recommendation: "Set up the application path for your desktop media player in the settings.", + Level: LevelError, + }) + } + + _, err := IsExecutable(binaryPath) + if err != nil { + t.currentResult.AddItem(ResultItem{ + Module: ModuleMediaPlayer, + Observation: fmt.Sprintf("The application path for your desktop media player is not valid"), + Recommendation: "Set up the application path for your desktop media player in the settings.", + Level: LevelError, + Errors: []string{err.Error()}, + Logs: []string{binaryPath}, + }) + } +} + +///////// + +func (r *Result) AddItem(item ResultItem) { + r.Items = append(r.Items, item) +} diff --git a/seanime-2.9.10/internal/troubleshooter/troubleshooter_test.go b/seanime-2.9.10/internal/troubleshooter/troubleshooter_test.go new file mode 100644 index 0000000..6d288fa --- /dev/null +++ b/seanime-2.9.10/internal/troubleshooter/troubleshooter_test.go @@ -0,0 +1,24 @@ +package troubleshooter + +import ( + "path/filepath" + "seanime/internal/test_utils" + "seanime/internal/util" + "testing" +) + +func TestAnalyze(t *testing.T) { + test_utils.SetTwoLevelDeep() + test_utils.InitTestProvider(t) + + analyzer := NewAnalyzer(NewTroubleshooterOptions{ + LogsDir: filepath.Join(test_utils.ConfigData.Path.DataDir, "logs"), + }) + + res, err := analyzer.Analyze() + if err != nil { + t.Fatalf("Error analyzing logs: %v", err) + } + + util.Spew(res) +} diff --git a/seanime-2.9.10/internal/updater/announcement.go b/seanime-2.9.10/internal/updater/announcement.go new file mode 100644 index 0000000..c9d6d8d --- /dev/null +++ b/seanime-2.9.10/internal/updater/announcement.go @@ -0,0 +1,161 @@ +package updater + +import ( + "io" + "net/http" + "runtime" + "seanime/internal/constants" + "seanime/internal/database/models" + "seanime/internal/events" + "slices" + + "github.com/Masterminds/semver/v3" + "github.com/goccy/go-json" +) + +type AnnouncementType string + +const ( + AnnouncementTypeToast AnnouncementType = "toast" + AnnouncementTypeDialog AnnouncementType = "dialog" + AnnouncementTypeBanner AnnouncementType = "banner" +) + +type AnnouncementSeverity string + +const ( + AnnouncementSeverityInfo AnnouncementSeverity = "info" + AnnouncementSeverityWarning AnnouncementSeverity = "warning" + AnnouncementSeverityError AnnouncementSeverity = "error" + AnnouncementSeverityCritical AnnouncementSeverity = "critical" +) + +type AnnouncementAction struct { + Label string `json:"label"` + URL string `json:"url"` + Type string `json:"type"` +} + +type AnnouncementConditions struct { + OS []string `json:"os,omitempty"` // ["windows", "darwin", "linux"] + Platform []string `json:"platform,omitempty"` // ["tauri", "web", "denshi"] + // FeatureFlags []string `json:"featureFlags,omitempty"` // Required feature flags + VersionConstraint string `json:"versionConstraint,omitempty"` // e.g. "<= 2.9.0", "2.9.0" + UserSettingsPath string `json:"userSettingsPath,omitempty"` // JSON path to check in user settings + UserSettingsValue []string `json:"userSettingsValue,omitempty"` // Expected values at that path +} + +type Announcement struct { + ID string `json:"id"` // Unique identifier for tracking + Title string `json:"title,omitempty"` // Title for dialogs/banners + Message string `json:"message"` // The message to display + Type AnnouncementType `json:"type"` // The type of announcement + Severity AnnouncementSeverity `json:"severity"` // Severity level + Date interface{} `json:"date"` // Date of the announcement + + NotDismissible bool `json:"notDismissible"` // Can user dismiss it + + Conditions *AnnouncementConditions `json:"conditions,omitempty"` // Advanced targeting + + Actions []AnnouncementAction `json:"actions,omitempty"` // Action buttons + + Priority int `json:"priority"` +} + +func (u *Updater) GetAnnouncements(version string, platform string, settings *models.Settings) []Announcement { + var filteredAnnouncements []Announcement + if !u.checkForUpdate { + return filteredAnnouncements + } + // filter out + for _, announcement := range u.announcements { + if announcement.Conditions == nil { + filteredAnnouncements = append(filteredAnnouncements, announcement) + continue + } + + conditions := announcement.Conditions + + if len(conditions.OS) > 0 && !slices.Contains(conditions.OS, runtime.GOOS) { + continue + } + + if conditions.Platform != nil && !slices.Contains(conditions.Platform, platform) { + continue + } + + if conditions.VersionConstraint != "" { + versionConstraint, err := semver.NewConstraint(conditions.VersionConstraint) + if err != nil { + u.logger.Error().Err(err).Msgf("updater: Failed to parse version constraint") + continue + } + + currVersion, err := semver.NewVersion(version) + if err != nil { + u.logger.Error().Err(err).Msgf("updater: Failed to parse current version") + continue + } + + if !versionConstraint.Check(currVersion) { + continue + } + + } + + filteredAnnouncements = append(filteredAnnouncements, announcement) + } + + u.announcements = filteredAnnouncements + + return u.announcements +} + +func (u *Updater) FetchAnnouncements() []Announcement { + var announcements []Announcement + + response, err := http.Get(constants.AnnouncementURL) + if err != nil { + u.logger.Error().Err(err).Msgf("updater: Failed to get announcements") + return announcements + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + u.logger.Error().Err(err).Msgf("updater: Failed to read announcements") + return announcements + } + + err = json.Unmarshal(body, &announcements) + if err != nil { + u.logger.Error().Err(err).Msgf("updater: Failed to unmarshal announcements") + return announcements + } + + // Filter out announcements + var filteredAnnouncements []Announcement + for _, announcement := range announcements { + if announcement.Conditions == nil { + filteredAnnouncements = append(filteredAnnouncements, announcement) + continue + } + + conditions := announcement.Conditions + + if len(conditions.OS) > 0 && !slices.Contains(conditions.OS, runtime.GOOS) { + continue + } + + filteredAnnouncements = append(filteredAnnouncements, announcement) + } + + u.announcements = announcements + + if u.wsEventManager.IsPresent() { + // Tell the client to send a request to fetch the latest announcements + u.wsEventManager.MustGet().SendEvent(events.CheckForAnnouncements, nil) + } + + return announcements +} diff --git a/seanime-2.9.10/internal/updater/check.go b/seanime-2.9.10/internal/updater/check.go new file mode 100644 index 0000000..73e6aed --- /dev/null +++ b/seanime-2.9.10/internal/updater/check.go @@ -0,0 +1,207 @@ +package updater + +import ( + "errors" + "fmt" + "io" + "runtime" + "strings" + + "github.com/goccy/go-json" +) + +// We fetch the latest release from the website first, if it fails we fallback to GitHub API +// This allows updates even if Seanime is removed from GitHub +var ( + websiteUrl = "https://seanime.app/api/release" + fallbackGithubUrl = "https://api.github.com/repos/5rahim/seanime/releases/latest" +) + +type ( + GitHubResponse struct { + Url string `json:"url"` + AssetsUrl string `json:"assets_url"` + UploadUrl string `json:"upload_url"` + HtmlUrl string `json:"html_url"` + ID int64 `json:"id"` + NodeID string `json:"node_id"` + TagName string `json:"tag_name"` + TargetCommitish string `json:"target_commitish"` + Name string `json:"name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + CreatedAt string `json:"created_at"` + PublishedAt string `json:"published_at"` + Assets []struct { + Url string `json:"url"` + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + Label string `json:"label"` + ContentType string `json:"content_type"` + State string `json:"state"` + Size int64 `json:"size"` + DownloadCount int64 `json:"download_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` + TarballURL string `json:"tarball_url"` + ZipballURL string `json:"zipball_url"` + Body string `json:"body"` + } + + DocsResponse struct { + Release Release `json:"release"` + } + + Release struct { + Url string `json:"url"` + HtmlUrl string `json:"html_url"` + NodeId string `json:"node_id"` + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + PublishedAt string `json:"published_at"` + Released bool `json:"released"` + Version string `json:"version"` + Assets []ReleaseAsset `json:"assets"` + } + ReleaseAsset struct { + Url string `json:"url"` + Id int64 `json:"id"` + NodeId string `json:"node_id"` + Name string `json:"name"` + ContentType string `json:"content_type"` + Uploaded bool `json:"uploaded"` + Size int64 `json:"size"` + BrowserDownloadUrl string `json:"browser_download_url"` + } +) + +func (u *Updater) GetReleaseName(version string) string { + + arch := runtime.GOARCH + switch runtime.GOARCH { + case "amd64": + arch = "x86_64" + case "arm64": + arch = "arm64" + case "386": + return "i386" + } + oos := runtime.GOOS + switch runtime.GOOS { + case "linux": + oos = "Linux" + case "windows": + oos = "Windows" + case "darwin": + oos = "MacOS" + } + + ext := "tar.gz" + if oos == "Windows" { + ext = "zip" + } + + return fmt.Sprintf("seanime-%s_%s_%s.%s", version, oos, arch, ext) +} + +func (u *Updater) fetchLatestRelease() (*Release, error) { + var release *Release + docsRelease, err := u.fetchLatestReleaseFromDocs() + if err != nil { + ghRelease, err := u.fetchLatestReleaseFromGitHub() + if err != nil { + return nil, err + } + release = ghRelease + } else { + release = docsRelease + } + + return release, nil +} + +func (u *Updater) fetchLatestReleaseFromGitHub() (*Release, error) { + + response, err := u.client.Get(fallbackGithubUrl) + if err != nil { + return nil, err + } + defer response.Body.Close() + + byteArr, readErr := io.ReadAll(response.Body) + if readErr != nil { + return nil, fmt.Errorf("error reading response: %w\n", readErr) + } + + var res GitHubResponse + err = json.Unmarshal(byteArr, &res) + if err != nil { + return nil, err + } + + release := &Release{ + Url: res.Url, + HtmlUrl: res.HtmlUrl, + NodeId: res.NodeID, + TagName: res.TagName, + Name: res.Name, + Body: res.Body, + PublishedAt: res.PublishedAt, + Released: !res.Prerelease && !res.Draft, + Version: strings.TrimPrefix(res.TagName, "v"), + Assets: make([]ReleaseAsset, len(res.Assets)), + } + + for i, asset := range res.Assets { + release.Assets[i] = ReleaseAsset{ + Url: asset.Url, + Id: asset.ID, + NodeId: asset.NodeID, + Name: asset.Name, + ContentType: asset.ContentType, + Uploaded: asset.State == "uploaded", + Size: asset.Size, + BrowserDownloadUrl: asset.BrowserDownloadURL, + } + } + + return release, nil +} + +func (u *Updater) fetchLatestReleaseFromDocs() (*Release, error) { + + response, err := u.client.Get(websiteUrl) + if err != nil { + return nil, err + } + defer response.Body.Close() + + statusCode := response.StatusCode + + if statusCode == 429 { + return nil, errors.New("rate limited, try again later") + } + + if !((statusCode >= 200) && (statusCode <= 299)) { + return nil, fmt.Errorf("http error code: %d\n", statusCode) + } + + byteArr, readErr := io.ReadAll(response.Body) + if readErr != nil { + return nil, fmt.Errorf("error reading response: %w", readErr) + } + + var res DocsResponse + err = json.Unmarshal(byteArr, &res) + if err != nil { + return nil, err + } + + res.Release.Version = strings.TrimPrefix(res.Release.TagName, "v") + + return &res.Release, nil +} diff --git a/seanime-2.9.10/internal/updater/check_test.go b/seanime-2.9.10/internal/updater/check_test.go new file mode 100644 index 0000000..fa3ae86 --- /dev/null +++ b/seanime-2.9.10/internal/updater/check_test.go @@ -0,0 +1,138 @@ +package updater + +import ( + "seanime/internal/constants" + "seanime/internal/events" + "seanime/internal/util" + "strings" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdater_getReleaseName(t *testing.T) { + + updater := Updater{} + + t.Log(updater.GetReleaseName(constants.Version)) +} + +func TestUpdater_FetchLatestRelease(t *testing.T) { + + fallbackGithubUrl = "https://seanimedud.app/api/releases" // simulate dead endpoint + //githubUrl = "https://api.github.com/repos/zbonfo/seanime-desktop/releases/latest" + + updater := New(constants.Version, util.NewLogger(), events.NewMockWSEventManager(util.NewLogger())) + release, err := updater.fetchLatestRelease() + if err != nil { + t.Fatal(err) + } + + if assert.NotNil(t, release) { + spew.Dump(release) + } +} + +func TestUpdater_FetchLatestReleaseFromDocs(t *testing.T) { + + updater := New(constants.Version, util.NewLogger(), events.NewMockWSEventManager(util.NewLogger())) + release, err := updater.fetchLatestReleaseFromDocs() + if err != nil { + t.Fatal(err) + } + + if assert.NotNil(t, release) { + spew.Dump(release) + } +} + +func TestUpdater_FetchLatestReleaseFromGitHub(t *testing.T) { + + updater := New(constants.Version, util.NewLogger(), events.NewMockWSEventManager(util.NewLogger())) + release, err := updater.fetchLatestReleaseFromGitHub() + if err != nil { + t.Fatal(err) + } + + if assert.NotNil(t, release) { + spew.Dump(release) + } +} + +func TestUpdater_CompareVersion(t *testing.T) { + + tests := []struct { + currVersion string + latestVersion string + shouldUpdate bool + }{ + { + currVersion: "0.2.2", + latestVersion: "0.2.2", + shouldUpdate: false, + }, + { + currVersion: "2.2.0-prerelease", + latestVersion: "2.2.0", + shouldUpdate: true, + }, + { + currVersion: "2.2.0", + latestVersion: "2.2.0-prerelease", + shouldUpdate: false, + }, + { + currVersion: "0.2.2", + latestVersion: "0.2.3", + shouldUpdate: true, + }, + { + currVersion: "0.2.2", + latestVersion: "0.3.0", + shouldUpdate: true, + }, + { + currVersion: "0.2.2", + latestVersion: "1.0.0", + shouldUpdate: true, + }, + { + currVersion: "0.2.2", + latestVersion: "0.2.1", + shouldUpdate: false, + }, + { + currVersion: "1.0.0", + latestVersion: "0.2.1", + shouldUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.latestVersion, func(t *testing.T) { + updateType, shouldUpdate := util.CompareVersion(tt.currVersion, tt.latestVersion) + assert.Equal(t, tt.shouldUpdate, shouldUpdate) + t.Log(tt.latestVersion, updateType) + }) + } + +} + +func TestUpdater(t *testing.T) { + + u := New(constants.Version, util.NewLogger(), events.NewMockWSEventManager(util.NewLogger())) + + rl, err := u.GetLatestRelease() + require.NoError(t, err) + + rl.TagName = "v2.2.1" + newV := strings.TrimPrefix(rl.TagName, "v") + updateTypeI, shouldUpdate := util.CompareVersion(u.CurrentVersion, newV) + isOlder := util.VersionIsOlderThan(u.CurrentVersion, newV) + + util.Spew(isOlder) + util.Spew(shouldUpdate) + util.Spew(updateTypeI) +} diff --git a/seanime-2.9.10/internal/updater/download.go b/seanime-2.9.10/internal/updater/download.go new file mode 100644 index 0000000..0b00b2a --- /dev/null +++ b/seanime-2.9.10/internal/updater/download.go @@ -0,0 +1,300 @@ +package updater + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "seanime/internal/util" +) + +var ( + ErrExtractionFailed = errors.New("could not extract assets") +) + +// DownloadLatestRelease will download the latest release assets and extract them +// If the decompression fails, the returned string will be the directory to the compressed file +// If the decompression is successful, the returned string will be the directory to the extracted files +func (u *Updater) DownloadLatestRelease(assetUrl, dest string) (string, error) { + if u.LatestRelease == nil { + return "", errors.New("no new release found") + } + + u.logger.Debug().Str("asset_url", assetUrl).Str("dest", dest).Msg("updater: Downloading latest release") + + fpath, err := u.downloadAsset(assetUrl, dest) + if err != nil { + return "", err + } + + dest = filepath.Dir(fpath) + + u.logger.Info().Str("dest", dest).Msg("updater: Downloaded release assets") + + fp, err := u.decompressAsset(fpath, "") + if err != nil { + u.logger.Error().Err(err).Msg("updater: Failed to decompress release assets") + return fp, ErrExtractionFailed + } + dest = fp + + u.logger.Info().Str("dest", dest).Msg("updater: Successfully decompressed downloaded release assets") + + return dest, nil +} + +func (u *Updater) DownloadLatestReleaseN(assetUrl, dest, folderName string) (string, error) { + if u.LatestRelease == nil { + return "", errors.New("no new release found") + } + + u.logger.Debug().Str("asset_url", assetUrl).Str("dest", dest).Msg("updater: Downloading latest release") + + fpath, err := u.downloadAsset(assetUrl, dest) + if err != nil { + return "", err + } + + dest = filepath.Dir(fpath) + + u.logger.Info().Str("dest", dest).Msg("updater: Downloaded release assets") + + fp, err := u.decompressAsset(fpath, folderName) + if err != nil { + u.logger.Error().Err(err).Msg("updater: Failed to decompress release assets") + return fp, err + } + dest = fp + + u.logger.Info().Str("dest", dest).Msg("updater: Successfully decompressed downloaded release assets") + + return dest, nil +} + +func (u *Updater) decompressZip(archivePath string, folderName string) (dest string, err error) { + topFolderName := "seanime-" + u.LatestRelease.Version + if folderName != "" { + topFolderName = folderName + } + // "/seanime-repo/seanime-v1.0.0.zip" -> "/seanime-repo/seanime-1.0.0/" + dest = filepath.Join(filepath.Dir(archivePath), topFolderName) // "/seanime-repo/seanime-v1.0.0" + + // Check if the destination folder already exists + if _, err := os.Stat(dest); err == nil { + return dest, errors.New("destination folder already exists") + } + + // Create the destination folder + err = os.MkdirAll(dest, os.ModePerm) + if err != nil { + return dest, err + } + + r, err := zip.OpenReader(archivePath) + if err != nil { + return dest, err + } + defer r.Close() + + u.logger.Debug().Msg("updater: Decompressing release assets (zip)") + + for _, f := range r.File { + fpath := filepath.Join(dest, f.Name) + if f.FileInfo().IsDir() { + os.MkdirAll(fpath, os.ModePerm) + continue + } + + if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { + return dest, err + } + + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return dest, err + } + + rc, err := f.Open() + if err != nil { + outFile.Close() + return dest, err + } + + _, err = io.Copy(outFile, rc) + outFile.Close() + rc.Close() + + if err != nil { + return dest, err + } + } + + r.Close() + + err = os.Remove(archivePath) + if err != nil { + u.logger.Error().Err(err).Msg("updater: Failed to remove compressed file") + return dest, nil + } + + u.logger.Debug().Msg("updater: Decompressed release assets (zip)") + + return dest, nil +} + +func (u *Updater) decompressTarGz(archivePath string, folderName string) (dest string, err error) { + topFolderName := "seanime-" + u.LatestRelease.Version + if folderName != "" { + topFolderName = folderName + } + dest = filepath.Join(filepath.Dir(archivePath), topFolderName) + + if _, err := os.Stat(dest); err == nil { + return dest, errors.New("destination folder already exists") + } + + err = os.MkdirAll(dest, os.ModePerm) + if err != nil { + return dest, err + } + + file, err := os.Open(archivePath) + if err != nil { + return dest, err + } + defer file.Close() + + gzr, err := gzip.NewReader(file) + if err != nil { + return dest, err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + + u.logger.Debug().Msg("updater: Decompressing release assets (gzip)") + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return dest, err + } + + fpath := filepath.Join(dest, header.Name) + if header.Typeflag == tar.TypeDir { + if err := os.MkdirAll(fpath, os.ModePerm); err != nil { + return dest, err + } + } else { + if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { + return dest, err + } + + outFile, err := os.Create(fpath) + if err != nil { + return dest, err + } + + if _, err := io.Copy(outFile, tr); err != nil { + outFile.Close() + return dest, err + } + outFile.Close() + } + } + + gzr.Close() + file.Close() + + err = os.Remove(archivePath) + if err != nil { + u.logger.Error().Err(err).Msg("updater: Failed to remove compressed file") + return dest, nil + } + + u.logger.Debug().Msg("updater: Decompressed release assets (gzip)") + + return dest, nil +} + +// decompressAsset will uncompress the release assets and delete the compressed folder +// - "/seanime-repo/seanime-v1.0.0.zip" -> "/seanime-repo/seanime-1.0.0/" +func (u *Updater) decompressAsset(archivePath string, folderName string) (dest string, err error) { + + defer util.HandlePanicInModuleWithError("updater/download/decompressAsset", &err) + + u.logger.Debug().Str("archive_path", archivePath).Msg("updater: Decompressing release assets") + + ext := filepath.Ext(archivePath) + if ext == ".zip" { + return u.decompressZip(archivePath, folderName) + } else if ext == ".gz" { + return u.decompressTarGz(archivePath, folderName) + } + + u.logger.Error().Msg("updater: Failed to decompress release assets, unsupported archive format") + + return "", fmt.Errorf("unsupported archive format: %s", ext) + +} + +// downloadAsset will download the release assets to a folder +// - "seanime-v1.zip" -> "/seanime-repo/seanime-v1.zip" +func (u *Updater) downloadAsset(assetUrl, dest string) (fp string, err error) { + + defer util.HandlePanicInModuleWithError("updater/download/downloadAsset", &err) + + u.logger.Debug().Msg("updater: Downloading assets") + + fp = u.getFilePath(assetUrl, dest) + + // Get the data + resp, err := http.Get(assetUrl) + if err != nil { + return "", err + } + defer resp.Body.Close() + + // Check if the request was successful (status code 200) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download file, %s", resp.Status) + } + + // Create the destination folder if it doesn't exist + err = os.MkdirAll(dest, os.ModePerm) + if err != nil { + u.logger.Error().Err(err).Msg("updater: Failed to download assets") + return "", err + } + + // Create the file + out, err := os.Create(fp) + if err != nil { + u.logger.Error().Err(err).Msg("updater: Failed to download assets") + return "", err + } + defer out.Close() + + // Write the body to file + _, err = io.Copy(out, resp.Body) + if err != nil { + u.logger.Error().Err(err).Msg("updater: Failed to download assets") + return "", err + } + + return +} + +func (u *Updater) getFilePath(url, dest string) string { + // Get the file name from the URL + fileName := filepath.Base(url) + return filepath.Join(dest, fileName) +} diff --git a/seanime-2.9.10/internal/updater/download_test.go b/seanime-2.9.10/internal/updater/download_test.go new file mode 100644 index 0000000..62024d4 --- /dev/null +++ b/seanime-2.9.10/internal/updater/download_test.go @@ -0,0 +1,90 @@ +package updater + +import ( + "github.com/samber/lo" + "os" + "seanime/internal/util" + "strings" + "testing" +) + +func TestUpdater_DownloadLatestRelease(t *testing.T) { + + updater := New("0.2.0", util.NewLogger(), nil) + + //tempDir := "E:\\SEANIME-REPO-TEST" + tempDir := t.TempDir() + + // Get the latest release + release, err := updater.GetLatestRelease() + if err != nil { + t.Fatal(err) + } + + // Find the asset (zip file) + asset, ok := lo.Find(release.Assets, func(asset ReleaseAsset) bool { + return strings.HasSuffix(asset.BrowserDownloadUrl, "Windows_x86_64.zip") + }) + if !ok { + t.Fatal("could not find release asset") + } + + // Download the asset + folderPath, err := updater.DownloadLatestRelease(asset.BrowserDownloadUrl, tempDir) + if err != nil { + t.Log("Downloaded to:", folderPath) + t.Fatal(err) + } + + t.Log("Downloaded to:", folderPath) + + // Check if the folder is not empty + entries, err := os.ReadDir(folderPath) + if err != nil { + t.Fatal(err) + } + + if len(entries) == 0 { + t.Fatal("folder is empty") + } + + for _, entry := range entries { + t.Log(entry.Name()) + } + + // Delete the folder + if err := os.RemoveAll(folderPath); err != nil { + t.Fatal(err) + } + + // Find the asset (.tar.gz file) + asset2, ok := lo.Find(release.Assets, func(asset ReleaseAsset) bool { + return strings.HasSuffix(asset.BrowserDownloadUrl, "MacOS_arm64.tar.gz") + }) + if !ok { + t.Fatal("could not find release asset") + } + + // Download the asset + folderPath2, err := updater.DownloadLatestRelease(asset2.BrowserDownloadUrl, tempDir) + if err != nil { + t.Log("Downloaded to:", folderPath2) + t.Fatal(err) + } + + t.Log("Downloaded to:", folderPath2) + + // Check if the folder is not empty + entries2, err := os.ReadDir(folderPath2) + if err != nil { + t.Fatal(err) + } + + if len(entries2) == 0 { + t.Fatal("folder is empty") + } + + for _, entry := range entries2 { + t.Log(entry.Name()) + } +} diff --git a/seanime-2.9.10/internal/updater/selfupdate.go b/seanime-2.9.10/internal/updater/selfupdate.go new file mode 100644 index 0000000..49c1bca --- /dev/null +++ b/seanime-2.9.10/internal/updater/selfupdate.go @@ -0,0 +1,356 @@ +package updater + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "seanime/internal/constants" + "seanime/internal/util" + "slices" + "strings" + "syscall" + "time" + + "github.com/rs/zerolog" + "github.com/samber/lo" + "github.com/samber/mo" +) + +const ( + tempReleaseDir = "seanime_new_release" + backupDirName = "backup_restore_if_failed" +) + +type ( + SelfUpdater struct { + logger *zerolog.Logger + breakLoopCh chan struct{} + originalExePath mo.Option[string] + updater *Updater + fallbackDest string + + tmpExecutableName string + } +) + +func NewSelfUpdater() *SelfUpdater { + logger := util.NewLogger() + ret := &SelfUpdater{ + logger: logger, + breakLoopCh: make(chan struct{}), + originalExePath: mo.None[string](), + updater: New(constants.Version, logger, nil), + } + + ret.tmpExecutableName = "seanime.exe.old" + switch runtime.GOOS { + case "windows": + ret.tmpExecutableName = "seanime.exe.old" + default: + ret.tmpExecutableName = "seanime.old" + } + + go func() { + // Delete all files with the .old extension + exePath := getExePath() + entries, err := os.ReadDir(filepath.Dir(exePath)) + if err != nil { + return + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".old") { + _ = os.RemoveAll(filepath.Join(filepath.Dir(exePath), entry.Name())) + } + } + + }() + + return ret +} + +// Started returns a channel that will be closed when the app loop should be broken +func (su *SelfUpdater) Started() <-chan struct{} { + return su.breakLoopCh +} + +func (su *SelfUpdater) StartSelfUpdate(fallbackDestination string) { + su.fallbackDest = fallbackDestination + close(su.breakLoopCh) +} + +// recover will just print a message and attempt to download the latest release +func (su *SelfUpdater) recover(assetUrl string) { + + if su.originalExePath.IsAbsent() { + return + } + + if su.fallbackDest != "" { + su.logger.Info().Str("dest", su.fallbackDest).Msg("selfupdate: Attempting to download the latest release") + _, _ = su.updater.DownloadLatestRelease(assetUrl, su.fallbackDest) + } + + su.logger.Error().Msg("selfupdate: Failed to install update. Update downloaded to 'seanime_new_release'") +} + +func getExePath() string { + exe, err := os.Executable() // /path/to/seanime.exe + if err != nil { + return "" + } + exePath, err := filepath.EvalSymlinks(exe) // /path/to/seanime.exe + if err != nil { + return "" + } + + return exePath +} + +func (su *SelfUpdater) Run() error { + + exePath := getExePath() + + su.originalExePath = mo.Some(exePath) + + exeDir := filepath.Dir(exePath) // /path/to + + var files []string + + switch runtime.GOOS { + case "windows": + files = []string{ + "seanime.exe", + "LICENSE", + } + default: + files = []string{ + "seanime", + "LICENSE", + } + } + + // Get the new assets + su.logger.Info().Msg("selfupdate: Fetching latest release info") + + // Get the latest release + release, err := su.updater.GetLatestRelease() + if err != nil { + su.logger.Error().Err(err).Msg("selfupdate: Failed to get latest release") + return err + } + + // Find the asset + assetName := su.updater.GetReleaseName(release.Version) + asset, ok := lo.Find(release.Assets, func(asset ReleaseAsset) bool { + return asset.Name == assetName + }) + if !ok { + su.logger.Error().Msg("selfupdate: Asset not found") + return err + } + + su.logger.Info().Msg("selfupdate: Downloading latest release") + + // Download the asset to exeDir/seanime_tmp + newReleaseDir, err := su.updater.DownloadLatestReleaseN(asset.BrowserDownloadUrl, exeDir, tempReleaseDir) + if err != nil { + su.logger.Error().Err(err).Msg("selfupdate: Failed to download latest release") + return err + } + + // DEVNOTE: Past this point, the application will be broken + // Use "recover" to attempt to recover the application + + su.logger.Info().Msg("selfupdate: Creating backup") + + // Delete the backup directory if it exists + _ = os.RemoveAll(filepath.Join(exeDir, backupDirName)) + // Create the backup directory + backupDir := filepath.Join(exeDir, backupDirName) + _ = os.MkdirAll(backupDir, 0755) + + // Backup the current assets + // Copy the files to the backup directory + // seanime.exe + /backup_restore_if_failed/seanime.exe + // LICENSE + /backup_restore_if_failed/LICENSE + for _, file := range files { + // We don't check for errors here because we don't want to stop the update process if LICENSE is not found for example + _ = copyFile(filepath.Join(exeDir, file), filepath.Join(backupDir, file)) + } + + su.logger.Info().Msg("selfupdate: Renaming assets") + time.Sleep(2 * time.Second) + + renamingFailed := false + failedEntryNames := make([]string, 0) + + // Rename the current assets + // seanime.exe -> seanime.exe.old + // LICENSE -> LICENSE.old + for _, file := range files { + err = os.Rename(filepath.Join(exeDir, file), filepath.Join(exeDir, file+".old")) + // We care about the error ONLY if the file is the executable + if err != nil && (file == "seanime" || file == "seanime.exe") { + renamingFailed = true + failedEntryNames = append(failedEntryNames, file) + //su.recover() + su.logger.Error().Err(err).Msg("selfupdate: Failed to rename entry") + //return err + } + } + + if renamingFailed { + fmt.Println("---------------------------------") + fmt.Println("A second attempt will be made in 30 seconds") + fmt.Println("---------------------------------") + time.Sleep(30 * time.Second) + // Here `failedEntryNames` should only contain NECESSARY files that failed to rename + for _, entry := range failedEntryNames { + err = os.Rename(filepath.Join(exeDir, entry), filepath.Join(exeDir, entry+".old")) + if err != nil { + su.logger.Error().Err(err).Msg("selfupdate: Failed to rename entry") + su.recover(asset.BrowserDownloadUrl) + return err + } + } + } + + // Now all the files have been renamed, we can move the new release to the exeDir + + su.logger.Info().Msg("selfupdate: Moving assets") + + // Move the new release elements to the exeDir + err = moveContents(newReleaseDir, exeDir) + if err != nil { + su.recover(asset.BrowserDownloadUrl) + su.logger.Error().Err(err).Msg("selfupdate: Failed to move assets") + return err + } + + _ = os.Chmod(su.originalExePath.MustGet(), 0755) + + // Delete the new release directory + _ = os.RemoveAll(newReleaseDir) + + // Start the new executable + su.logger.Info().Msg("selfupdate: Starting new executable") + + switch runtime.GOOS { + case "windows": + err = openWindows(su.originalExePath.MustGet()) + case "darwin": + err = openMacOS(su.originalExePath.MustGet()) + case "linux": + err = openLinux(su.originalExePath.MustGet()) + default: + su.logger.Fatal().Msg("selfupdate: Unsupported platform") + } + + // Remove .old files (will fail on Windows for executable) + // Remove seanime.exe.old and LICENSE.old + for _, file := range files { + _ = os.RemoveAll(filepath.Join(exeDir, file+".old")) + } + + // Remove the backup directory + _ = os.RemoveAll(backupDir) + + os.Exit(0) + return nil +} + +func openWindows(path string) error { + cmd := util.NewCmd("cmd", "/c", "start", "cmd", "/k", path) + return cmd.Start() +} + +func openMacOS(path string) error { + script := fmt.Sprintf(` + tell application "Terminal" + do script "%s" + activate + end tell`, path) + cmd := exec.Command("osascript", "-e", script) + return cmd.Start() +} + +func openLinux(path string) error { + // Filter out the -update flag or we end up in an infinite update loop + filteredArgs := slices.DeleteFunc(os.Args, func(s string) bool { return s == "-update" }) + + // Replace the current process with the updated executable + return syscall.Exec(path, filteredArgs, os.Environ()) +} + +// moveContents moves contents of newReleaseDir to exeDir without deleting existing files +func moveContents(newReleaseDir, exeDir string) error { + // Ensure exeDir exists + if err := os.MkdirAll(exeDir, 0755); err != nil { + return err + } + + // Copy contents of newReleaseDir to exeDir + return copyDir(newReleaseDir, exeDir) +} + +// copyFile copies a single file from src to dst +func copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + // Create destination directory if it does not exist + if err = os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + destinationFile, err := os.Create(dst) + if err != nil { + return err + } + defer destinationFile.Close() + + _, err = io.Copy(destinationFile, sourceFile) + return err +} + +// copyDir recursively copies a directory tree, attempting to preserve permissions. +func copyDir(src string, dst string) error { + src = filepath.Clean(src) + dst = filepath.Clean(dst) + + info, err := os.Stat(src) + if err != nil { + return err + } + + if err := os.MkdirAll(dst, info.Mode()); err != nil { + return err + } + + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + if err := copyFile(srcPath, dstPath); err != nil { + return err + } + } + } + return nil +} diff --git a/seanime-2.9.10/internal/updater/updater.go b/seanime-2.9.10/internal/updater/updater.go new file mode 100644 index 0000000..c84a7f6 --- /dev/null +++ b/seanime-2.9.10/internal/updater/updater.go @@ -0,0 +1,127 @@ +package updater + +import ( + "net/http" + "seanime/internal/events" + "seanime/internal/util" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/samber/mo" +) + +const ( + PatchRelease = "patch" + MinorRelease = "minor" + MajorRelease = "major" +) + +type ( + Updater struct { + CurrentVersion string + hasCheckedForUpdate bool + LatestRelease *Release + checkForUpdate bool + logger *zerolog.Logger + client *http.Client + wsEventManager mo.Option[events.WSEventManagerInterface] + announcements []Announcement + } + + Update struct { + Release *Release `json:"release,omitempty"` + CurrentVersion string `json:"current_version,omitempty"` + Type string `json:"type"` + } +) + +func New(currVersion string, logger *zerolog.Logger, wsEventManager events.WSEventManagerInterface) *Updater { + ret := &Updater{ + CurrentVersion: currVersion, + hasCheckedForUpdate: false, + checkForUpdate: true, + logger: logger, + client: &http.Client{ + Timeout: time.Second * 10, + }, + wsEventManager: mo.None[events.WSEventManagerInterface](), + } + + if wsEventManager != nil { + ret.wsEventManager = mo.Some[events.WSEventManagerInterface](wsEventManager) + } + + return ret +} + +func (u *Updater) GetLatestUpdate() (*Update, error) { + if !u.checkForUpdate { + return nil, nil + } + + rl, err := u.GetLatestRelease() + if err != nil { + return nil, err + } + + if rl == nil || rl.TagName == "" { + return nil, nil + } + + if !rl.Released { + return nil, nil + } + + newV := strings.TrimPrefix(rl.TagName, "v") + updateTypeI, shouldUpdate := util.CompareVersion(u.CurrentVersion, newV) + if !shouldUpdate { + return nil, nil + } + + updateType := "" + if updateTypeI == -1 { + updateType = MinorRelease + } else if updateTypeI == -2 { + updateType = PatchRelease + } else if updateTypeI == -3 { + updateType = MajorRelease + } + + return &Update{ + Release: rl, + CurrentVersion: u.CurrentVersion, + Type: updateType, + }, nil +} + +func (u *Updater) ShouldRefetchReleases() { + u.hasCheckedForUpdate = false + + if u.wsEventManager.IsPresent() { + // Tell the client to send a request to fetch the latest release + u.wsEventManager.MustGet().SendEvent(events.CheckForUpdates, nil) + } +} + +func (u *Updater) SetEnabled(checkForUpdate bool) { + u.checkForUpdate = checkForUpdate +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// GetLatestRelease returns the latest release from the GitHub repository. +func (u *Updater) GetLatestRelease() (*Release, error) { + if u.hasCheckedForUpdate { + return u.LatestRelease, nil + } + + release, err := u.fetchLatestRelease() + if err != nil { + return nil, err + } + + u.hasCheckedForUpdate = true + u.LatestRelease = release + return release, nil +} diff --git a/seanime-2.9.10/internal/updater/updater_test.go b/seanime-2.9.10/internal/updater/updater_test.go new file mode 100644 index 0000000..2ece84f --- /dev/null +++ b/seanime-2.9.10/internal/updater/updater_test.go @@ -0,0 +1,22 @@ +package updater + +import ( + "seanime/internal/util" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpdater_GetLatestUpdate(t *testing.T) { + + fallbackGithubUrl = "https://seanime.app/api/releases" // simulate dead endpoint + + u := New("2.0.2", util.NewLogger(), nil) + + update, err := u.GetLatestUpdate() + require.NoError(t, err) + + require.NotNil(t, update) + + util.Spew(update) +} diff --git a/seanime-2.9.10/internal/user/user.go b/seanime-2.9.10/internal/user/user.go new file mode 100644 index 0000000..2bd5ddd --- /dev/null +++ b/seanime-2.9.10/internal/user/user.go @@ -0,0 +1,49 @@ +package user + +import ( + "errors" + "seanime/internal/api/anilist" + "seanime/internal/database/models" + + "github.com/goccy/go-json" +) + +const SimulatedUserToken = "SIMULATED" + +type User struct { + Viewer *anilist.GetViewer_Viewer `json:"viewer"` + Token string `json:"token"` + // IsSimulated indicates whether the user is not a real AniList account. + IsSimulated bool `json:"isSimulated"` +} + +// NewUser creates a new User entity from a models.User +// This is returned to the client +func NewUser(model *models.Account) (*User, error) { + if model == nil { + return nil, errors.New("account is nil") + } + var acc anilist.GetViewer_Viewer + if err := json.Unmarshal(model.Viewer, &acc); err != nil { + return nil, err + } + return &User{ + Viewer: &acc, + Token: model.Token, + }, nil +} + +func NewSimulatedUser() *User { + acc := anilist.GetViewer_Viewer{ + Name: "User", + Avatar: nil, + BannerImage: nil, + IsBlocked: nil, + Options: nil, + } + return &User{ + Viewer: &acc, + Token: SimulatedUserToken, + IsSimulated: true, + } +} diff --git a/seanime-2.9.10/internal/util/cachedreadseeker.go b/seanime-2.9.10/internal/util/cachedreadseeker.go new file mode 100644 index 0000000..b1f2e84 --- /dev/null +++ b/seanime-2.9.10/internal/util/cachedreadseeker.go @@ -0,0 +1,111 @@ +package util + +import ( + "fmt" + "io" +) + +// CachedReadSeeker wraps an io.ReadSeekCloser and caches bytes as they are read. +// It implements io.ReadSeeker, allowing seeking within the already-cached +// range without hitting the underlying reader again. +// Additional reads beyond the cache will append to the cache automatically. +type CachedReadSeeker struct { + src io.ReadSeekCloser // underlying source + cache []byte // bytes read so far + pos int64 // current read position +} + +func (c *CachedReadSeeker) Close() error { + return c.src.Close() +} + +var _ io.ReadSeekCloser = (*CachedReadSeeker)(nil) + +// NewCachedReadSeeker constructs a new CachedReadSeeker wrapping a io.ReadSeekCloser. +func NewCachedReadSeeker(r io.ReadSeekCloser) *CachedReadSeeker { + return &CachedReadSeeker{src: r} +} + +// Read reads up to len(p) bytes into p. It first serves from cache +// if possible, then reads any remaining bytes from the underlying source, +// appending them to the cache. +func (c *CachedReadSeeker) Read(p []byte) (n int, err error) { + // Check if any part of the request can be served from cache + if c.pos < int64(len(c.cache)) { + // Calculate how much we can read from cache + available := int64(len(c.cache)) - c.pos + toRead := int64(len(p)) + if available >= toRead { + // Can serve entirely from cache + n = copy(p, c.cache[c.pos:c.pos+toRead]) + c.pos += int64(n) + return n, nil + } + // Read what we can from cache + n = copy(p, c.cache[c.pos:]) + c.pos += int64(n) + if n == len(p) { + return n, nil + } + // Read the rest from source + m, err := c.readFromSrc(p[n:]) + n += m + return n, err + } + + // Nothing in cache, read from source + return c.readFromSrc(p) +} + +// readFromSrc reads from the underlying source at the current position, +// appends those bytes to cache, and updates the current position. +func (c *CachedReadSeeker) readFromSrc(p []byte) (n int, err error) { + // Seek to the current position in the source + if _, err = c.src.Seek(c.pos, io.SeekStart); err != nil { + return 0, err + } + + // Read the requested data + n, err = c.src.Read(p) + if n > 0 { + // If reading sequentially or within small gap of cache, append to cache + if c.pos <= int64(len(c.cache)) { + c.cache = append(c.cache, p[:n]...) + } + c.pos += int64(n) + } + return n, err +} + +// Seek sets the read position for subsequent Read calls. Seeking within the +// cached range simply updates the position. Seeking beyond will position +// Read to fetch new data from the underlying source (and cache it). +func (c *CachedReadSeeker) Seek(offset int64, whence int) (int64, error) { + var target int64 + switch whence { + case io.SeekStart: + target = offset + case io.SeekCurrent: + target = c.pos + offset + case io.SeekEnd: + // determine end by seeking underlying + end, err := c.src.Seek(0, io.SeekEnd) + if err != nil { + return 0, err + } + target = end + offset + // Cache the end position for future SeekEnd calls + if int64(len(c.cache)) < end { + c.cache = append(c.cache, make([]byte, end-int64(len(c.cache)))...) + } + default: + return 0, fmt.Errorf("invalid whence: %d", whence) + } + + if target < 0 { + return 0, fmt.Errorf("negative position: %d", target) + } + + c.pos = target + return c.pos, nil +} diff --git a/seanime-2.9.10/internal/util/cachedreadseeker_test.go b/seanime-2.9.10/internal/util/cachedreadseeker_test.go new file mode 100644 index 0000000..a8c2bc5 --- /dev/null +++ b/seanime-2.9.10/internal/util/cachedreadseeker_test.go @@ -0,0 +1,306 @@ +package util + +import ( + "bytes" + "errors" + "io" + "testing" + "time" +) + +// mockSlowReader simulates a slow reader (like network or disk) by adding artificial delay +type mockSlowReader struct { + data []byte + pos int64 + delay time.Duration + readCnt int // count of actual reads from source +} + +func newMockSlowReader(data []byte, delay time.Duration) *mockSlowReader { + return &mockSlowReader{ + data: data, + delay: delay, + } +} + +func (m *mockSlowReader) Read(p []byte) (n int, err error) { + if m.pos >= int64(len(m.data)) { + return 0, io.EOF + } + + // Simulate latency + time.Sleep(m.delay) + + m.readCnt++ // track actual reads from source + n = copy(p, m.data[m.pos:]) + m.pos += int64(n) + return n, nil +} + +func (m *mockSlowReader) Seek(offset int64, whence int) (int64, error) { + var abs int64 + switch whence { + case io.SeekStart: + abs = offset + case io.SeekCurrent: + abs = m.pos + offset + case io.SeekEnd: + abs = int64(len(m.data)) + offset + default: + return 0, errors.New("invalid whence") + } + if abs < 0 { + return 0, errors.New("negative position") + } + m.pos = abs + return abs, nil +} + +func (m *mockSlowReader) Close() error { + return nil +} + +func TestCachedReadSeeker_CachingBehavior(t *testing.T) { + data := []byte("Hello, this is test data for streaming!") + delay := 10 * time.Millisecond + + mock := newMockSlowReader(data, delay) + cached := NewCachedReadSeeker(mock) + + // First read - should hit the source + buf1 := make([]byte, 5) + n, err := cached.Read(buf1) + if err != nil || n != 5 || string(buf1) != "Hello" { + t.Errorf("First read failed: got %q, want %q", buf1, "Hello") + } + + // Seek back to start - should not hit source + _, err = cached.Seek(0, io.SeekStart) + if err != nil { + t.Errorf("Seek failed: %v", err) + } + + // Second read of same data - should be from cache + readCntBefore := mock.readCnt + buf2 := make([]byte, 5) + n, err = cached.Read(buf2) + if err != nil || n != 5 || string(buf2) != "Hello" { + t.Errorf("Second read failed: got %q, want %q", buf2, "Hello") + } + if mock.readCnt != readCntBefore { + t.Error("Second read hit source when it should have used cache") + } +} + +func TestCachedReadSeeker_Performance(t *testing.T) { + data := bytes.Repeat([]byte("abcdefghijklmnopqrstuvwxyz"), 1000) // ~26KB of data + delay := 10 * time.Millisecond + + t.Run("Without Cache", func(t *testing.T) { + mock := newMockSlowReader(data, delay) + start := time.Now() + + // Read entire data + if _, err := io.ReadAll(mock); err != nil { + t.Fatal(err) + } + + // Seek back and read again + mock.Seek(0, io.SeekStart) + if _, err := io.ReadAll(mock); err != nil { + t.Fatal(err) + } + + uncachedDuration := time.Since(start) + t.Logf("Without cache duration: %v", uncachedDuration) + }) + + t.Run("With Cache", func(t *testing.T) { + mock := newMockSlowReader(data, delay) + cached := NewCachedReadSeeker(mock) + start := time.Now() + + // Read entire data + if _, err := io.ReadAll(cached); err != nil { + t.Fatal(err) + } + + // Seek back and read again + cached.Seek(0, io.SeekStart) + if _, err := io.ReadAll(cached); err != nil { + t.Fatal(err) + } + + cachedDuration := time.Since(start) + t.Logf("With cache duration: %v", cachedDuration) + }) +} + +func TestCachedReadSeeker_SeekBehavior(t *testing.T) { + data := []byte("0123456789") + mock := newMockSlowReader(data, 0) + cached := NewCachedReadSeeker(mock) + + tests := []struct { + name string + offset int64 + whence int + wantPos int64 + wantRead string + readBufSize int + }{ + {"SeekStart", 3, io.SeekStart, 3, "3456", 4}, + {"SeekCurrent", 2, io.SeekCurrent, 9, "9", 4}, + {"SeekEnd", -5, io.SeekEnd, 5, "56789", 5}, + {"SeekStartZero", 0, io.SeekStart, 0, "0123", 4}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pos, err := cached.Seek(tt.offset, tt.whence) + if err != nil { + t.Errorf("Seek failed: %v", err) + return + } + if pos != tt.wantPos { + t.Errorf("Seek position = %d, want %d", pos, tt.wantPos) + } + + buf := make([]byte, tt.readBufSize) + n, err := cached.Read(buf) + if err != nil && err != io.EOF { + t.Errorf("Read failed: %v", err) + return + } + got := string(buf[:n]) + if got != tt.wantRead { + t.Errorf("Read after seek = %q, want %q", got, tt.wantRead) + } + }) + } +} + +func TestCachedReadSeeker_LargeReads(t *testing.T) { + // Test with larger data to simulate real streaming scenarios + data := bytes.Repeat([]byte("abcdefghijklmnopqrstuvwxyz"), 1000) // ~26KB + mock := newMockSlowReader(data, 0) + cached := NewCachedReadSeeker(mock) + + // Read in chunks + chunkSize := 1024 + buf := make([]byte, chunkSize) + + var totalRead int + for { + n, err := cached.Read(buf) + totalRead += n + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("Read error: %v", err) + } + } + + if totalRead != len(data) { + t.Errorf("Total read = %d, want %d", totalRead, len(data)) + } + + // Verify cache by seeking back and reading again + cached.Seek(0, io.SeekStart) + readCntBefore := mock.readCnt + + totalRead = 0 + for { + n, err := cached.Read(buf) + totalRead += n + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("Second read error: %v", err) + } + } + + if mock.readCnt != readCntBefore { + t.Error("Second read hit source when it should have used cache") + } +} + +func TestCachedReadSeeker_ChunkedReadsAndSeeks(t *testing.T) { + // Create ~1MB of test data + data := bytes.Repeat([]byte("abcdefghijklmnopqrstuvwxyz0123456789"), 30_000) + delay := 300 * time.Millisecond // 10ms delay per read to simulate network/disk latency + + // Define read patterns to simulate real-world streaming + type readOp struct { + seekOffset int64 + seekWhence int + readSize int + desc string + } + + // Simulate typical streaming behavior with repeated reads + ops := []readOp{ + {0, io.SeekStart, 10 * 1024 * 1024, "initial header"}, // Read first 10MB (headers) + {500_000, io.SeekStart, 15 * 1024 * 1024, "middle preview"}, // Seek to middle, read 15MB + {0, io.SeekStart, len(data), "full read after random seeks"}, // Read entire file + {0, io.SeekStart, len(data), "re-read entire file"}, // Re-read entire file (should be cached) + } + + var uncachedDuration, cachedDuration time.Duration + var uncachedReads, cachedReads int + + runTest := func(name string, useCache bool) { + t.Run(name, func(t *testing.T) { + mock := newMockSlowReader(data, delay) + var reader io.ReadSeekCloser = mock + if useCache { + reader = NewCachedReadSeeker(mock) + } + + start := time.Now() + var totalRead int64 + + for i, op := range ops { + pos, err := reader.Seek(op.seekOffset, op.seekWhence) + if err != nil { + t.Fatalf("op %d (%s) - seek failed: %v", i, op.desc, err) + } + + buf := make([]byte, op.readSize) + n, err := io.ReadFull(reader, buf) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + t.Fatalf("op %d (%s) - read failed: %v", i, op.desc, err) + } + + totalRead += int64(n) + t.Logf("op %d (%s) - seek to %d, read %d bytes", i, op.desc, pos, n) + } + + duration := time.Since(start) + t.Logf("Total bytes read: %d", totalRead) + t.Logf("Total time: %v", duration) + t.Logf("Read count from source: %d", mock.readCnt) + + if useCache { + cachedDuration = duration + cachedReads = mock.readCnt + } else { + uncachedDuration = duration + uncachedReads = mock.readCnt + } + }) + } + + // Run both tests + runTest("Without Cache", false) + runTest("With Cache", true) + + // Report performance comparison + t.Logf("\nPerformance comparison:") + t.Logf("Uncached: %v (%d reads from source)", uncachedDuration, uncachedReads) + t.Logf("Cached: %v (%d reads from source)", cachedDuration, cachedReads) + t.Logf("Speed improvement: %.2fx", float64(uncachedDuration)/float64(cachedDuration)) + t.Logf("Read reduction: %.2fx", float64(uncachedReads)/float64(cachedReads)) +} diff --git a/seanime-2.9.10/internal/util/cmd_not_win.go b/seanime-2.9.10/internal/util/cmd_not_win.go new file mode 100644 index 0000000..843804b --- /dev/null +++ b/seanime-2.9.10/internal/util/cmd_not_win.go @@ -0,0 +1,22 @@ +//go:build !windows + +package util + +import ( + "context" + "os/exec" +) + +func NewCmd(arg string, args ...string) *exec.Cmd { + if len(args) == 0 { + return exec.Command(arg) + } + return exec.Command(arg, args...) +} + +func NewCmdCtx(ctx context.Context, arg string, args ...string) *exec.Cmd { + if len(args) == 0 { + return exec.CommandContext(ctx, arg) + } + return exec.CommandContext(ctx, arg, args...) +} diff --git a/seanime-2.9.10/internal/util/cmd_win.go b/seanime-2.9.10/internal/util/cmd_win.go new file mode 100644 index 0000000..c372a39 --- /dev/null +++ b/seanime-2.9.10/internal/util/cmd_win.go @@ -0,0 +1,37 @@ +//go:build windows + +package util + +import ( + "context" + "os/exec" + "syscall" +) + +// NewCmd creates a new exec.Cmd object with the given arguments. +// Since for Windows, the app is built as a GUI application, we need to hide the console windows launched when running commands. +func NewCmd(arg string, args ...string) *exec.Cmd { + //cmdPrompt := "C:\\Windows\\system32\\cmd.exe" + //cmdArgs := append([]string{"/c", arg}, args...) + //cmd := exec.Command(cmdPrompt, cmdArgs...) + //cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + cmd := exec.Command(arg, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: 0x08000000, + //HideWindow: true, + } + return cmd +} + +func NewCmdCtx(ctx context.Context, arg string, args ...string) *exec.Cmd { + //cmdPrompt := "C:\\Windows\\system32\\cmd.exe" + //cmdArgs := append([]string{"/c", arg}, args...) + //cmd := exec.CommandContext(ctx, cmdPrompt, cmdArgs...) + //cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + cmd := exec.CommandContext(ctx, arg, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: 0x08000000, + //HideWindow: true, + } + return cmd +} diff --git a/seanime-2.9.10/internal/util/comparison/filtering.go b/seanime-2.9.10/internal/util/comparison/filtering.go new file mode 100644 index 0000000..c258872 --- /dev/null +++ b/seanime-2.9.10/internal/util/comparison/filtering.go @@ -0,0 +1,157 @@ +package comparison + +import ( + "regexp" + "strconv" + "strings" +) + +func ValueContainsSeason(val string) bool { + val = strings.ToLower(val) + + if strings.IndexRune(val, '第') != -1 { + return false + } + if ValueContainsSpecial(val) { + return false + } + + if strings.Contains(val, "season") { + return true + } + + re := regexp.MustCompile(`\d(st|nd|rd|th) [Ss].*`) + if re.MatchString(val) { + return true + } + + return false +} + +func ExtractSeasonNumber(val string) int { + val = strings.ToLower(val) + + // Check for the word "season" followed by a number + re := regexp.MustCompile(`season (\d+)`) + matches := re.FindStringSubmatch(val) + if len(matches) > 1 { + season, err := strconv.Atoi(matches[1]) + if err == nil { + return season + } + } + + // Check for a number followed by "st", "nd", "rd", or "th", followed by "s" or "S" + re = regexp.MustCompile(`(\d+)(st|nd|rd|th) [sS]`) + matches = re.FindStringSubmatch(val) + if len(matches) > 1 { + season, err := strconv.Atoi(matches[1]) + if err == nil { + return season + } + } + + // No season number found + return -1 +} + +// ExtractResolutionInt extracts the resolution from a string and returns it as an integer. +// This is used for comparing resolutions. +// If the resolution is not found, it returns 0. +func ExtractResolutionInt(val string) int { + val = strings.ToLower(val) + + if strings.Contains(strings.ToUpper(val), "4K") { + return 2160 + } + if strings.Contains(val, "2160") { + return 2160 + } + if strings.Contains(val, "1080") { + return 1080 + } + if strings.Contains(val, "720") { + return 720 + } + if strings.Contains(val, "540") { + return 540 + } + if strings.Contains(val, "480") { + return 480 + } + + re := regexp.MustCompile(`^\d{3,4}([pP])$`) + matches := re.FindStringSubmatch(val) + if len(matches) > 1 { + res, err := strconv.Atoi(matches[1]) + if err != nil { + return 0 + } + return res + } + + return 0 +} + +func ValueContainsSpecial(val string) bool { + regexes := []*regexp.Regexp{ + regexp.MustCompile(`(?i)(^|(?P.*?)[ _.\-(]+)(SP|OAV|OVA|OAD|ONA) ?(?P\d{1,2})(-(?P[0-9]{1,3}))? ?(?P.*)$`), + regexp.MustCompile(`(?i)[-._( ](OVA|ONA)[-._) ]`), + regexp.MustCompile(`(?i)[-._ ](S|SP)(?P<season>(0|00))([Ee]\d)`), + regexp.MustCompile(`[({\[]?(OVA|ONA|OAV|OAD|SP|SPECIAL)[])}]?`), + } + + for _, regex := range regexes { + if regex.MatchString(val) { + return true + } + } + + return false +} + +func ValueContainsIgnoredKeywords(val string) bool { + regexes := []*regexp.Regexp{ + regexp.MustCompile(`(?i)^\s?[({\[]?\s?(EXTRAS?|OVAS?|OTHERS?|SPECIALS|MOVIES|SEASONS|NC)\s?[])}]?\s?$`), + } + + for _, regex := range regexes { + if regex.MatchString(val) { + return true + } + } + + return false +} +func ValueContainsBatchKeywords(val string) bool { + regexes := []*regexp.Regexp{ + regexp.MustCompile(`(?i)[({\[]?\s?(EXTRAS|OVAS|OTHERS|SPECIALS|MOVIES|SEASONS|BATCH|COMPLETE|COMPLETE SERIES)\s?[])}]?\s?`), + } + + for _, regex := range regexes { + if regex.MatchString(val) { + return true + } + } + + return false +} + +func ValueContainsNC(val string) bool { + regexes := []*regexp.Regexp{ + regexp.MustCompile(`(?i)(^|(?P<show>.*?)[ _.\-(]+)\b(OP|NCOP|OPED)\b ?(?P<ep>\d{1,2}[a-z]?)? ?([ _.\-)]+(?P<title>.*))?`), + regexp.MustCompile(`(?i)(^|(?P<show>.*?)[ _.\-(]+)\b(ED|NCED)\b ?(?P<ep>\d{1,2}[a-z]?)? ?([ _.\-)]+(?P<title>.*))?`), + regexp.MustCompile(`(?i)(^|(?P<show>.*?)[ _.\-(]+)\b(TRAILER|PROMO|PV)\b ?(?P<ep>\d{1,2}) ?([ _.\-)]+(?P<title>.*))?`), + regexp.MustCompile(`(?i)(^|(?P<show>.*?)[ _.\-(]+)\b(OTHERS?)\b(?P<ep>\d{1,2}) ?[ _.\-)]+(?P<title>.*)`), + regexp.MustCompile(`(?i)(^|(?P<show>.*?)[ _.\-(]+)\b(CM|COMMERCIAL|AD)\b ?(?P<ep>\d{1,2}) ?([ _.\-)]+(?P<title>.*))?`), + regexp.MustCompile(`(?i)(^|(?P<show>.*?)[ _.\-(]+)\b(CREDITLESS|NCOP|NCED|OP|ED)\b ?(?P<ep>\d{1,2}[a-z]?)? ?([ _.\-)]+(?P<title>.*))?`), + } + + for _, regex := range regexes { + if regex.MatchString(val) { + return true + } + } + + return false +} diff --git a/seanime-2.9.10/internal/util/comparison/filtering_test.go b/seanime-2.9.10/internal/util/comparison/filtering_test.go new file mode 100644 index 0000000..878f6e5 --- /dev/null +++ b/seanime-2.9.10/internal/util/comparison/filtering_test.go @@ -0,0 +1,344 @@ +package comparison + +import ( + "testing" +) + +func TestValueContainsSeason(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "Contains 'season' in lowercase", + input: "JJK season 2", + expected: true, + }, + { + name: "Contains 'season' in uppercase", + input: "JJK SEASON 2", + expected: true, + }, + { + name: "Contains '2nd S' in lowercase", + input: "Spy x Family 2nd Season", + expected: true, + }, + { + name: "Contains '2nd S' in uppercase", + input: "Spy x Family 2ND SEASON", + expected: true, + }, + { + name: "Does not contain 'season' or '1st S'", + input: "This is a test", + expected: false, + }, + { + name: "Contains special characters", + input: "JJK season 2 (OVA)", + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := ValueContainsSeason(test.input) + if result != test.expected { + t.Errorf("ValueContainsSeason() with args %v, expected %v, but got %v.", test.input, test.expected, result) + } + }) + } +} + +func TestExtractSeasonNumber(t *testing.T) { + tests := []struct { + name string + input string + expected int + }{ + { + name: "Contains 'season' followed by a number", + input: "JJK season 2", + expected: 2, + }, + { + name: "Contains a number followed by 'st', 'nd', 'rd', or 'th', followed by 's' or 'S'", + input: "Spy x Family 2nd S", + expected: 2, + }, + { + name: "Does not contain 'season' or '1st S'", + input: "This is a test", + expected: -1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := ExtractSeasonNumber(test.input) + if result != test.expected { + t.Errorf("ExtractSeasonNumber() with args %v, expected %v, but got %v.", test.input, test.expected, result) + } + }) + } +} + +func TestExtractResolutionInt(t *testing.T) { + tests := []struct { + name string + input string + expected int + }{ + { + name: "Contains '4K' in uppercase", + input: "4K", + expected: 2160, + }, + { + name: "Contains '4k' in lowercase", + input: "4k", + expected: 2160, + }, + { + name: "Contains '2160'", + input: "2160", + expected: 2160, + }, + { + name: "Contains '1080'", + input: "1080", + expected: 1080, + }, + { + name: "Contains '720'", + input: "720", + expected: 720, + }, + { + name: "Contains '480'", + input: "480", + expected: 480, + }, + { + name: "Does not contain a resolution", + input: "This is a test", + expected: 0, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := ExtractResolutionInt(test.input) + if result != test.expected { + t.Errorf("ExtractResolutionInt() with args %v, expected %v, but got %v.", test.input, test.expected, result) + } + }) + } +} + +func TestValueContainsSpecial(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "Contains 'OVA' in uppercase", + input: "JJK OVA", + expected: true, + }, + { + name: "Contains 'ova' in lowercase", + input: "JJK ova", + expected: false, + }, + { + name: "Does not contain special keywords", + input: "This is a test", + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := ValueContainsSpecial(test.input) + if result != test.expected { + t.Errorf("ValueContainsSpecial() with args %v, expected %v, but got %v.", test.input, test.expected, result) + } + }) + } +} + +func TestValueContainsIgnoredKeywords(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "Contains 'EXTRAS' in uppercase", + input: "EXTRAS", + expected: true, + }, + { + name: "Contains 'extras' in lowercase", + input: "extras", + expected: true, + }, + { + name: "Does not contain ignored keywords", + input: "This is a test", + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := ValueContainsIgnoredKeywords(test.input) + if result != test.expected { + t.Errorf("ValueContainsIgnoredKeywords() with args %v, expected %v, but got %v.", test.input, test.expected, result) + } + }) + } +} + +func TestValueContainsBatchKeywords(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "Contains 'BATCH' in uppercase", + input: "BATCH", + expected: true, + }, + { + name: "Contains 'batch' in lowercase", + input: "batch", + expected: true, + }, + { + name: "Does not contain batch keywords", + input: "This is a test", + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := ValueContainsBatchKeywords(test.input) + if result != test.expected { + t.Errorf("ValueContainsBatchKeywords() with args %v, expected %v, but got %v.", test.input, test.expected, result) + } + }) + } +} + +func TestValueContainsNC(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + { + input: "NCOP", + expected: true, + }, + { + input: "ncop", + expected: true, + }, + { + input: "One Piece - 1000 - NCOP", + expected: true, + }, + { + input: "One Piece ED 2", + expected: true, + }, + { + input: "This is a test", + expected: false, + }, { + input: "This is a test", + expected: false, + }, + { + input: "Himouto.Umaru.chan.S01E02.1080p.BluRay.Opus2.0.x265-smol", + expected: false, + }, + { + input: "Himouto.Umaru.chan.S01E02.1080p.BluRay.x265-smol", + expected: false, + }, + { + input: "One Piece - 1000 - Operation something something", + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + result := ValueContainsNC(test.input) + if result != test.expected { + t.Errorf("ValueContainsNC() with args %v, expected %v, but got %v.", test.input, test.expected, result) + } + }) + } +} + +//func TestLikelyNC(t *testing.T) { +// tests := []struct { +// name string +// input string +// expected bool +// }{ +// { +// name: "Does not contain NC keywords 1", +// input: "Himouto.Umaru.chan.S01E02.1080p.BluRay.Opus2.0.x265-smol", +// expected: false, +// }, +// { +// name: "Does not contain NC keywords 2", +// input: "Himouto.Umaru.chan.S01E02.1080p.BluRay.x265-smol", +// expected: false, +// }, +// { +// name: "Contains NC keywords 1", +// input: "Himouto.Umaru.chan.S00E02.1080p.BluRay.x265-smol", +// expected: true, +// }, +// { +// name: "Contains NC keywords 2", +// input: "Himouto.Umaru.chan.OP02.1080p.BluRay.x265-smol", +// expected: true, +// }, +// } +// +// for _, test := range tests { +// t.Run(test.name, func(t *testing.T) { +// metadata := habari.Parse(test.input) +// var episode string +// var season string +// +// if len(metadata.SeasonNumber) > 0 { +// if len(metadata.SeasonNumber) == 1 { +// season = metadata.SeasonNumber[0] +// } +// } +// +// if len(metadata.EpisodeNumber) > 0 { +// if len(metadata.EpisodeNumber) == 1 { +// episode = metadata.EpisodeNumber[0] +// } +// } +// +// result := LikelyNC(test.input, season, episode) +// if result != test.expected { +// t.Errorf("ValueContainsNC() with args %v, expected %v, but got %v.", test.input, test.expected, result) +// } +// }) +// } +//} diff --git a/seanime-2.9.10/internal/util/comparison/matching.go b/seanime-2.9.10/internal/util/comparison/matching.go new file mode 100644 index 0000000..a95395a --- /dev/null +++ b/seanime-2.9.10/internal/util/comparison/matching.go @@ -0,0 +1,230 @@ +// Package comparison contains helpers related to comparison, comparison and filtering of media titles. +package comparison + +import ( + "github.com/adrg/strutil/metrics" +) + +// LevenshteinResult is a struct that holds a string and its Levenshtein distance compared to another string. +type LevenshteinResult struct { + OriginalValue *string + Value *string + Distance int +} + +// CompareWithLevenshtein compares a string to a slice of strings and returns a slice of LevenshteinResult containing the Levenshtein distance for each string. +func CompareWithLevenshtein(v *string, vals []*string) []*LevenshteinResult { + return CompareWithLevenshteinCleanFunc(v, vals, func(val string) string { + return val + }) +} +func CompareWithLevenshteinCleanFunc(v *string, vals []*string, cleanFunc func(val string) string) []*LevenshteinResult { + + lev := metrics.NewLevenshtein() + lev.CaseSensitive = false + //lev.DeleteCost = 1 + + res := make([]*LevenshteinResult, len(vals)) + + for _, val := range vals { + res = append(res, &LevenshteinResult{ + OriginalValue: v, + Value: val, + Distance: lev.Distance(cleanFunc(*v), cleanFunc(*val)), + }) + } + + return res +} + +// FindBestMatchWithLevenshtein returns the best match from a slice of strings as a reference to a LevenshteinResult. +// It also returns a boolean indicating whether the best match was found. +func FindBestMatchWithLevenshtein(v *string, vals []*string) (*LevenshteinResult, bool) { + res := CompareWithLevenshtein(v, vals) + + if len(res) == 0 { + return nil, false + } + + var bestResult *LevenshteinResult + for _, result := range res { + if bestResult == nil || result.Distance < bestResult.Distance { + bestResult = result + } + } + + return bestResult, true +} + +//---------------------------------------------------------------------------------------------------------------------- + +// JaroWinklerResult is a struct that holds a string and its JaroWinkler distance compared to another string. +type JaroWinklerResult struct { + OriginalValue *string + Value *string + Rating float64 +} + +// CompareWithJaroWinkler compares a string to a slice of strings and returns a slice of JaroWinklerResult containing the JaroWinkler distance for each string. +func CompareWithJaroWinkler(v *string, vals []*string) []*JaroWinklerResult { + + jw := metrics.NewJaroWinkler() + jw.CaseSensitive = false + + res := make([]*JaroWinklerResult, len(vals)) + + for _, val := range vals { + res = append(res, &JaroWinklerResult{ + OriginalValue: v, + Value: val, + Rating: jw.Compare(*v, *val), + }) + } + + return res +} + +// FindBestMatchWithJaroWinkler returns the best match from a slice of strings as a reference to a JaroWinklerResult. +// It also returns a boolean indicating whether the best match was found. +func FindBestMatchWithJaroWinkler(v *string, vals []*string) (*JaroWinklerResult, bool) { + res := CompareWithJaroWinkler(v, vals) + + if len(res) == 0 { + return nil, false + } + + var bestResult *JaroWinklerResult + for _, result := range res { + if bestResult == nil || result.Rating > bestResult.Rating { + bestResult = result + } + } + + return bestResult, true +} + +//---------------------------------------------------------------------------------------------------------------------- + +// JaccardResult is a struct that holds a string and its Jaccard distance compared to another string. +type JaccardResult struct { + OriginalValue *string + Value *string + Rating float64 +} + +// CompareWithJaccard compares a string to a slice of strings and returns a slice of JaccardResult containing the Jaccard distance for each string. +func CompareWithJaccard(v *string, vals []*string) []*JaccardResult { + + jw := metrics.NewJaccard() + jw.CaseSensitive = false + jw.NgramSize = 1 + + res := make([]*JaccardResult, len(vals)) + + for _, val := range vals { + res = append(res, &JaccardResult{ + OriginalValue: v, + Value: val, + Rating: jw.Compare(*v, *val), + }) + } + + return res +} + +// FindBestMatchWithJaccard returns the best match from a slice of strings as a reference to a JaccardResult. +// It also returns a boolean indicating whether the best match was found. +func FindBestMatchWithJaccard(v *string, vals []*string) (*JaccardResult, bool) { + res := CompareWithJaccard(v, vals) + + if len(res) == 0 { + return nil, false + } + + var bestResult *JaccardResult + for _, result := range res { + if bestResult == nil || result.Rating > bestResult.Rating { + bestResult = result + } + } + + return bestResult, true +} + +//---------------------------------------------------------------------------------------------------------------------- + +type SorensenDiceResult struct { + OriginalValue *string + Value *string + Rating float64 +} + +func CompareWithSorensenDice(v *string, vals []*string) []*SorensenDiceResult { + + dice := metrics.NewSorensenDice() + dice.CaseSensitive = false + + res := make([]*SorensenDiceResult, len(vals)) + + for _, val := range vals { + res = append(res, &SorensenDiceResult{ + OriginalValue: v, + Value: val, + Rating: dice.Compare(*v, *val), + }) + } + + return res +} + +func FindBestMatchWithSorensenDice(v *string, vals []*string) (*SorensenDiceResult, bool) { + res := CompareWithSorensenDice(v, vals) + + if len(res) == 0 { + return nil, false + } + + var bestResult *SorensenDiceResult + for _, result := range res { + if bestResult == nil || result.Rating > bestResult.Rating { + bestResult = result + } + } + + return bestResult, true +} + +func EliminateLeastSimilarValue(arr []string) []string { + if len(arr) < 3 { + return arr + } + + sd := metrics.NewSorensenDice() + sd.CaseSensitive = false + + leastSimilarIndex := -1 + leastSimilarScore := 2.0 + + for i := 0; i < len(arr); i++ { + totalSimilarity := 0.0 + + for j := 0; j < len(arr); j++ { + if i != j { + score := sd.Compare(arr[i], arr[j]) + totalSimilarity += score + } + } + + if totalSimilarity < leastSimilarScore { + leastSimilarScore = totalSimilarity + leastSimilarIndex = i + } + } + + if leastSimilarIndex != -1 { + arr = append(arr[:leastSimilarIndex], arr[leastSimilarIndex+1:]...) + } + + return arr + +} diff --git a/seanime-2.9.10/internal/util/comparison/matching_test.go b/seanime-2.9.10/internal/util/comparison/matching_test.go new file mode 100644 index 0000000..61696e6 --- /dev/null +++ b/seanime-2.9.10/internal/util/comparison/matching_test.go @@ -0,0 +1,114 @@ +package comparison + +import ( + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestFindBestMatchWithLevenstein(t *testing.T) { + + tests := []struct { + title string + comparisonTitles []string + expectedResult string + expectedDistance int + }{ + { + title: "jujutsu kaisen 2", + comparisonTitles: []string{"JJK", "Jujutsu Kaisen", "Jujutsu Kaisen 2"}, + expectedResult: "Jujutsu Kaisen 2", + expectedDistance: 0, + }, + } + + for _, test := range tests { + + t.Run(test.title, func(t *testing.T) { + res, ok := FindBestMatchWithLevenshtein(&test.title, lo.ToSlicePtr(test.comparisonTitles)) + + if assert.True(t, ok) { + assert.Equal(t, test.expectedResult, *res.Value, "expected result does not match") + assert.Equal(t, test.expectedDistance, res.Distance, "expected distance does not match") + t.Logf("value: %s, distance: %d", *res.Value, res.Distance) + } + + }) + + } + +} +func TestFindBestMatchWithDice(t *testing.T) { + + tests := []struct { + title string + comparisonTitles []string + expectedResult string + expectedRating float64 + }{ + { + title: "jujutsu kaisen 2", + comparisonTitles: []string{"JJK", "Jujutsu Kaisen", "Jujutsu Kaisen 2"}, + expectedResult: "Jujutsu Kaisen 2", + expectedRating: 1, + }, + } + + for _, test := range tests { + + t.Run(test.title, func(t *testing.T) { + res, ok := FindBestMatchWithSorensenDice(&test.title, lo.ToSlicePtr(test.comparisonTitles)) + + if assert.True(t, ok, "expected result, got nil") { + assert.Equal(t, test.expectedResult, *res.Value, "expected result does not match") + assert.Equal(t, test.expectedRating, res.Rating, "expected rating does not match") + t.Logf("value: %s, rating: %f", *res.Value, res.Rating) + } + + }) + + } + +} + +func TestEliminateLestSimilarValue(t *testing.T) { + + tests := []struct { + title string + comparisonTitles []string + expectedEliminated string + }{ + { + title: "jujutsu kaisen 2", + comparisonTitles: []string{"JJK", "Jujutsu Kaisen", "Jujutsu Kaisen 2"}, + expectedEliminated: "JJK", + }, + { + title: "One Piece - Film Z", + comparisonTitles: []string{"One Piece - Film Z", "One Piece Film Z", "One Piece Gold"}, + expectedEliminated: "One Piece Gold", + }, + { + title: "One Piece - Film Z", + comparisonTitles: []string{"One Piece - Film Z", "One Piece Film Z", "One Piece Z"}, + expectedEliminated: "One Piece Z", + }, + { + title: "Mononogatari", + comparisonTitles: []string{"Mononogatari", "Mononogatari Cour 2", "Nekomonogatari"}, + expectedEliminated: "Nekomonogatari", + }, + } + + for _, test := range tests { + t.Run(test.title, func(t *testing.T) { + res := EliminateLeastSimilarValue(test.comparisonTitles) + for _, n := range res { + if n == test.expectedEliminated { + t.Fatalf("expected \"%s\" to be eliminated from %v", n, res) + } + } + }) + } + +} diff --git a/seanime-2.9.10/internal/util/crashlog/crashlog.go b/seanime-2.9.10/internal/util/crashlog/crashlog.go new file mode 100644 index 0000000..c22d330 --- /dev/null +++ b/seanime-2.9.10/internal/util/crashlog/crashlog.go @@ -0,0 +1,148 @@ +package crashlog + +import ( + "bytes" + "context" + "fmt" + "github.com/rs/zerolog" + "github.com/samber/mo" + "io" + "os" + "path/filepath" + "seanime/internal/util" + "sync" + "time" +) + +// Global variable that continuously records logs from specific programs and writes them to a file when something unexpected happens. + +type CrashLogger struct { + //logger *zerolog.Logger + //logBuffer *bytes.Buffer + //mu sync.Mutex + logDir mo.Option[string] +} + +type CrashLoggerArea struct { + name string + logger *zerolog.Logger + logBuffer *bytes.Buffer + mu sync.Mutex + ctx context.Context + cancelFunc context.CancelFunc +} + +var GlobalCrashLogger = NewCrashLogger() + +// NewCrashLogger creates a new CrashLogger instance. +func NewCrashLogger() *CrashLogger { + + //var logBuffer bytes.Buffer + // + //fileOutput := zerolog.ConsoleWriter{ + // Out: &logBuffer, + // TimeFormat: time.DateTime, + // FormatMessage: util.ZerologFormatMessageSimple, + // FormatLevel: util.ZerologFormatLevelSimple, + // NoColor: true, + //} + // + //multi := zerolog.MultiLevelWriter(fileOutput) + //logger := zerolog.New(multi).With().Timestamp().Logger() + + return &CrashLogger{ + //logger: &logger, + //logBuffer: &logBuffer, + //mu: sync.Mutex{}, + logDir: mo.None[string](), + } +} + +func (c *CrashLogger) SetLogDir(dir string) { + c.logDir = mo.Some(dir) +} + +// InitArea creates a new CrashLoggerArea instance. +// This instance can be used to log crashes in a specific area. +func (c *CrashLogger) InitArea(area string) *CrashLoggerArea { + + var logBuffer bytes.Buffer + + fileOutput := zerolog.ConsoleWriter{ + Out: &logBuffer, + TimeFormat: time.DateTime, + FormatLevel: util.ZerologFormatLevelSimple, + NoColor: true, + } + + multi := zerolog.MultiLevelWriter(fileOutput) + logger := zerolog.New(multi).With().Timestamp().Logger() + + //ctx, cancelFunc := context.WithCancel(context.Background()) + + return &CrashLoggerArea{ + logger: &logger, + name: area, + logBuffer: &logBuffer, + mu: sync.Mutex{}, + //ctx: ctx, + //cancelFunc: cancelFunc, + } +} + +// Stdout returns the CrashLoggerArea's log buffer so that it can be used as a writer. +// +// Example: +// crashLogger := crashlog.GlobalCrashLogger.InitArea("ffmpeg") +// defer crashLogger.Close() +// +// cmd.Stdout = crashLogger.Stdout() +func (a *CrashLoggerArea) Stdout() io.Writer { + return a.logBuffer +} + +func (a *CrashLoggerArea) LogError(msg string) { + a.logger.Error().Msg(msg) +} + +func (a *CrashLoggerArea) LogErrorf(format string, args ...interface{}) { + a.logger.Error().Msgf(format, args...) +} + +func (a *CrashLoggerArea) LogInfof(format string, args ...interface{}) { + a.logger.Info().Msgf(format, args...) +} + +// Close should be always called using defer when a new area is created +// +// logArea := crashlog.GlobalCrashLogger.InitArea("ffmpeg") +// defer logArea.Close() +func (a *CrashLoggerArea) Close() { + a.logBuffer.Reset() + //a.cancelFunc() +} + +func (c *CrashLogger) WriteAreaLogToFile(area *CrashLoggerArea) { + logDir, found := c.logDir.Get() + if !found { + return + } + + // e.g. crash-ffmpeg-2021-09-01_15-04-05.log + logFilePath := filepath.Join(logDir, fmt.Sprintf("crash-%s-%s.log", area.name, time.Now().Format("2006-01-02_15-04-05"))) + + // Create file + logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0664) + if err != nil { + fmt.Printf("Failed to open log file: %s\n", logFilePath) + return + } + defer logFile.Close() + + area.mu.Lock() + defer area.mu.Unlock() + if _, err := area.logBuffer.WriteTo(logFile); err != nil { + fmt.Printf("Failed to write crash log buffer to file for %s\n", area.name) + } + area.logBuffer.Reset() +} diff --git a/seanime-2.9.10/internal/util/data/user_agents.jsonl b/seanime-2.9.10/internal/util/data/user_agents.jsonl new file mode 100644 index 0000000..8044e9c --- /dev/null +++ b/seanime-2.9.10/internal/util/data/user_agents.jsonl @@ -0,0 +1,119748 @@ +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.06813698994201217, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.06264256343016018, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.05697442655537689, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.05653145233988486, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.054767908471821336, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.051441313637680605, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 5.1.1; KFDOWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/108.15.4 like Chrome/108.0.5359.220 Safari/537.36", + "percent": 0.051185867765394924, + "type": "tablet", + "device_brand": "Amazon", + "browser": "Amazon Silk", + "browser_version": "108.15.4", + "browser_version_major_minor": 108.15, + "os": "Android", + "os_version": "5.1.1", + "platform": "Linux armv8l" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.04958848617813366, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.04924339807050542, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.04850738137313905, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.04723551221001197, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.047218346116068094, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.04692739124203464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.04679707320978865, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0467939647197787, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.04647547285261838, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.04562013078339299, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.04548226397399027, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.043648135380339216, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.04333639997894971, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.04330797271746663, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.04292172927046815, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.04250724970062513, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.042494856124529405, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36", + "percent": 0.04147547322278993, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "119.0.0.0", + "browser_version_major_minor": 119.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.04045589284670537, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.04003159557027137, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.03982681213943141, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.0388497613374691, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03872745639353998, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.038260044416060714, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0377939892223961, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03777054091923743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.037587754005838046, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03755671041185491, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03754762627705849, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.036662695017642466, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03665805411731358, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0366161868019558, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.036338372705694635, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.03629785043580056, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.03600967025478459, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "percent": 0.03589815982915847, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.035864722785428904, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.03570271263333402, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.035654197295521446, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.035621441677278744, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.03526709942163689, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.03525098030727382, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.03491534262855779, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03487745300164148, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.034760927778336705, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03465887874479984, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03462478628592339, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.03455170803411866, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.034542365584618415, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03450854484555958, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.034441417410881255, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.03443511621371586, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.03428665864998791, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03425259714865492, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03415730859757279, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0341415543372994, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.03402433698509716, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.033947871442925416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.033838061479619916, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.03380365744038866, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.03367543788369055, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.033635068093448475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.03361936621585187, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.03344510504464393, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.033260274205771434, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03319700953456954, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03316293314669943, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.033048603018920396, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "percent": 0.0329838819816208, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.0", + "browser_version_major_minor": 17.0, + "os": "iOS", + "os_version": "17.0.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.03285245649633709, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.03279273167111281, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.032706607789186146, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.032652299380363764, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.032574637937416544, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03244200159876435, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.03232943811500128, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03230870496067065, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03227697241170569, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03227291274875735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.032177874445850775, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.03208270184117852, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.03202416323114177, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.03201079901132423, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03199163048543363, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.031934008547675224, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.031925590813171846, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.031924532663827436, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.03191784217693714, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.031916405688690704, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.031910293325393746, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0318869428175219, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.03186382773694569, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.031822412294633874, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.031775572655019034, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.031761135923130485, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.03175851571842182, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03174128669229676, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.031520238308107636, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.031495517247320984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14092.77.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.107 Safari/537.36", + "percent": 0.03146208852198743, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "93.0.4577.107", + "browser_version_major_minor": 93.0, + "os": "Chrome OS", + "os_version": "14092.77.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03137261992437345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/600.8.9 (KHTML, like Gecko) Version/8.0.8 Safari/600.8.9", + "percent": 0.03136087073511572, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "8.0.8", + "browser_version_major_minor": 8.0, + "os": "Mac OS X", + "os_version": "10.10.5", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.031357157329358364, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.031317638937170024, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03118888479153864, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.031184603978223813, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.031177839088792323, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03114603000416025, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03113792060241194, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0311323765845164, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.031119712224086087, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03111650207347989, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.031088801666235323, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.030995188307785807, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.030979987055699272, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.030954869053946874, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.030903565865943228, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.030851519674742735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.030835470610662153, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.03079746302949133, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03079410833901409, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0307817461054601, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.030754353399785717, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.030749542079968804, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.030704127807367174, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.0306920300226005, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.030626940522697113, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.030551616048334188, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.030547051315120182, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.03052758977085495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.030457203059585353, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.030445238287192974, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.03036928341909851, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.030352787489518758, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.030188979529064536, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.03010483721880879, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.030075015458830408, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.03006295565568762, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/339.0.686111475 Mobile/15E148 Safari/604.1", + "percent": 0.029985465296320832, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "339.0.686111475", + "browser_version_major_minor": 339.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02995312282331164, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.029874927121452746, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.029872239967280008, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.029860483413667947, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.02984899374958407, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.029841219231573714, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02982833963247612, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15", + "percent": 0.029810288791411774, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.5", + "browser_version_major_minor": 15.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.029808271492409066, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02973691445916021, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.02965865314625975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.029645622222315086, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.029645546008308404, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.029611802265497165, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02960830039786033, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.029584303561548182, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.029534974885090944, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02947813075124118, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/130.0.2849.68 Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.029441417206862927, + "type": "mobile", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "130.0.2849.68", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.029415542023121743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.029350504011311992, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.8.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.029197340115136673, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.0291927263460808, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02908823661918825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.029076522294042135, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02899412516606422, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.028962250506894962, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.028955628996743043, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.028930999330175726, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.169 Mobile/15E148 Safari/604.1", + "percent": 0.02892477623347051, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "119.0.6045.169", + "browser_version_major_minor": 119.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.028923827195649506, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.028909667267141188, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.028908574051881766, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.028904850604699203, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0288965946007075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02888795547028516, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.028867034090100198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.028857282957908872, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02880657285083195, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02872249303345862, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.028685129449205225, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.028631753884420425, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02861225143861563, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.028595403333367984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.028589761072820186, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.028539279178332926, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.028513353024009733, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.028486741678356763, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0284637308004476, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.028409133097321054, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/485.1.0.45.110;FBBV/665337277;FBDV/iPhone15,5;FBMD/iPhone;FBSN/iOS;FBSV/17.3;FBSS/3;FBCR/;FBID/phone;FBLC/en_US;FBOP/80]", + "percent": 0.028401334232917883, + "type": "mobile", + "device_brand": "Apple", + "browser": "Facebook", + "browser_version": "485.1.0", + "browser_version_major_minor": 485.1, + "os": "iOS", + "os_version": "17.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.028399912566068337, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.028370201037802627, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02827361852255752, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.028207452499648882, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.028182940253206366, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.028168326298212816, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.02816740287001125, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.028109481690834964, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.028078465381968103, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.028063950714938484, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.028019624521468306, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.028000726552106774, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.027874742396012408, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02780673551377138, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 12; HD1901 Build/SKQ1.211113.001; ) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.2478.64", + "percent": 0.027802668831235857, + "type": "mobile", + "device_brand": "OnePlus", + "browser": "Edge Mobile", + "browser_version": "124.0.2478.64", + "browser_version_major_minor": 124.0, + "os": "Android", + "os_version": "12", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02776521026890964, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.027712049628242592, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.027629194188171745, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.027624129407712538, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02760905622358143, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02759439662517249, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.027592159630043876, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "percent": 0.02756610172402322, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.027473646580316556, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.027462000050426195, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.027428064466500958, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02734840564349855, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02731112559215458, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.027282619190025007, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.027250976770645082, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02715628891766919, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02714378926980103, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.027046482833507142, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.027016200302230318, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.027009627397431398, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.026988806418807233, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.02692627441320273, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.02688976191838988, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.026769900804653354, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02676827082339128, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "14.1.1", + "browser_version_major_minor": 14.1, + "os": "iOS", + "os_version": "14.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.026767969403486604, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.026725232649741876, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.02671841444800001, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1", + "percent": 0.026662524875420347, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "14.1.2", + "browser_version_major_minor": 14.1, + "os": "iOS", + "os_version": "14.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.026632447648133037, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.02662570673509531, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.026607242420306747, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02660425700729412, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.026591417725889514, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02658959749401051, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.026578902018322824, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02654664313461283, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.026543331877411758, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.026531538157995218, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.026518084932734207, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.026493481400893477, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Safari/605.1.15", + "percent": 0.02648984441209367, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.026439261905562405, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.026391024884722503, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02638365544701228, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02631866695854047, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.026311498031684664, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.026298016242967143, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.026282788300270404, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02626467280873485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.026244410600858133, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1 Ddg/17.2", + "percent": 0.026205620215739987, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.026203162433655693, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02619668397751273, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02619462670425846, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.026121330469089175, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.026101598234321177, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.137 Safari/537.36", + "percent": 0.02603782558645344, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "128.0.6613.137", + "browser_version_major_minor": 128.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02602989908374891, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02602667921707146, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.026021731193916882, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "percent": 0.02598539346557026, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "116.0.0.0", + "browser_version_major_minor": 116.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.025922296881735928, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.025919877516998504, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.025906417655522963, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.02589258902277875, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.025884162026548762, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02583966966292607, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15", + "percent": 0.025837404141550602, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.025780414476531958, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.025753530916721726, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.02573630044208982, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.025718064900806543, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.025703948634048283, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.025698595955675647, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.025692576434744773, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02566869302617304, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.025661487704107453, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02564870827991558, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.025639272801711317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02562933341755883, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.025618597332054245, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.025596719972864564, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.025586905220296535, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.02554807216755074, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.025490913020157022, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.025467424450219555, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0254148952065722, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02539737236186969, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.025376588241828302, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.025364180268132704, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.025361068629639605, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.02534820872208631, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.025335169874356835, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02527386429044715, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.02526015947714595, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.025251829109717346, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.025238411614047124, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.025235504182521535, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02522760815640063, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.025177376400403323, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.025175282691528127, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.025170896515470997, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.025128118020432563, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.025113765392328187, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.025102008263695134, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.025101982798389152, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.025082598604425056, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.025063393723042462, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.025047624925765028, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0250189328990496, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.024994065079226684, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.024902721778817142, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.024843694207420004, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.02483252342152567, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.024829245236631558, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02481517756240523, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.024798449957784175, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02479280658473437, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.02479222759125975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.024786654881588186, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.024765407321138574, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02476096549094093, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.024750465430056046, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.024746752640494668, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.024736084040188174, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02468120782170828, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.02468080117683808, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.024659037092196544, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02465553142038785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.024654560154564537, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02462904715153425, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.02462145624970447, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.024587045481797704, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02455337380046365, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.024547691944996022, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.02452423754043196, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.024451374673833466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.024443226895732896, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02443463990554419, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "percent": 0.02442169494296821, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "132.1", + "browser_version_major_minor": 132.1, + "os": "iOS", + "os_version": "17.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0244016295989449, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.024392706686768136, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.024383616106665743, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.024382073081413002, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.024350781137665863, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.024349102372960552, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.024346037982623064, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.024340009833095904, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.024319336725923426, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.024286413862261394, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02422371646916867, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02421399666688265, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.024201602344299175, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.024196403999198722, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02418368269327887, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.024147845624407867, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.02414513216733603, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.024132959689347252, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.024126084609774966, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.024094874526784552, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02408844426709411, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02408489345455888, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02407875984246303, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.024058340866227417, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.024045856335309104, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02404470180782744, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.024021751350010636, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02396869430729068, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.023948682698421785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.02391204049751339, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.023903822565860617, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023899894415869705, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.023885953406900955, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.023862971742199934, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.023833249297583934, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/21.0 Chrome/110.0.5481.154 Safari/537.36", + "percent": 0.023824559243157375, + "type": "desktop", + "device_brand": null, + "browser": "Samsung Internet", + "browser_version": "21.0", + "browser_version_major_minor": 21.0, + "os": "Linux", + "os_version": "", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.023819264573344868, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02380530749295997, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0237910797968752, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.023787400234049665, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.023778180445075275, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.023723039935308252, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0237150879385757, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02370788833053684, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02368328360030781, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023660761318689508, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02365893465426961, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.023656752886241562, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.023653543830541014, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.023632556058315986, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0236282412820579, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023626649194376764, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02361575563828314, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.02360774693178335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023561931425322716, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.023520284298738103, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "percent": 0.023515796088964678, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.023510788009151084, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.023505684373587448, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.023498944037096757, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.023486597413762657, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.023483789761928883, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.023454549698056527, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023445697012645043, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.023444488233823953, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023429874172116647, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023427995450632295, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.023422596594644438, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.023404030937222552, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.02339069420820174, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.02338386838689097, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02334930187690514, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023348000894458203, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023342298601647174, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023322037237063723, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.023317515269251743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.02328271437433985, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.02327491141922889, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02327423111232893, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023249410539104818, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.023237167441929295, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.023227152216162804, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.023215980660703597, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.023212977945601215, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.02319859189382311, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02319228636175633, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.02318119589306779, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.023170977362171864, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02311475007602543, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.023109548981718023, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02308942726419307, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.023069468054432808, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.023024718578733016, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.023014704431724962, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.022993083156832243, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.022989927655489278, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.022970260089495157, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02296578070409065, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.022960298294024746, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.022957391507791854, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.022944807307537594, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.022939324647091516, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.022918872237332882, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.022901398950962916, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02289856646517251, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0228784117314347, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02286756779570247, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.022865908737296395, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.022801060535343264, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "percent": 0.02279017319681953, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.022760640297978117, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.022718826703102962, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.022681897918055527, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.022644214860453732, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.022581708428851856, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.02256460100997465, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + "percent": 0.022563259926281293, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "26.0", + "browser_version_major_minor": 26.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.022424190543822355, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.022406440297412712, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.022400622007346365, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.022386347251905938, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.022381473572467593, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.022377445308103917, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02236543332252296, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02235463908977967, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.022324954862290063, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02231925899107288, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02230244415141674, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02229693402617768, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.022294885442920825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.022291500741910678, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02228742353654387, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.022283157903434242, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02226569957077225, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.022255533002147197, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.022251261081478613, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02222439816904248, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.022221417494342362, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.022220733129651588, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02222033207207402, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.022219961424840055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.022209255439388592, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.02220655760534542, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "percent": 0.022196130142845067, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02219110393480535, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.022190658503629883, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.02218371384475904, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02217879333272155, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.022163916154421755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.022160612882499806, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.022132294795251476, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02212504213489558, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02211145300890079, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02210135372928567, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.022097085845192432, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.02208577582809831, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.022073209179710136, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.022060636233622102, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02204235182367084, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.022040778179208867, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Safari/605.1.15", + "percent": 0.022035032970765752, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.022032620779294895, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.022015212870597742, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.02200912584439914, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02200392623479372, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.02199725498900926, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02198507889019433, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.021965556434894205, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.021958611997292057, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02188201846122303, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.021881267664486052, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.021876445981018518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.021860286233562443, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.021854046730611396, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.021844604256293972, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.021840342442700292, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02181478237707598, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.021805971878887394, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.021797721233184443, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.021794888363798032, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.021784701605405205, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.021783318633463486, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02177303039433314, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.021772752132706574, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.021769367125263322, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.021763409904690147, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.021730016153591165, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0", + "percent": 0.021721187748286627, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02171513258207957, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0217018853542254, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.021697519677738916, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.021691883726936506, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.021678577442174423, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/331.0.665236494 Mobile/15E148 Safari/604.1", + "percent": 0.021672835260548615, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "331.0.665236494", + "browser_version_major_minor": 331.0, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02166911750147544, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.021664264319386093, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02166416207164461, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021662441273752758, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.02162660370910794, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021601739363221673, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021591015770933312, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02158166208806619, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02156024051148108, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.021550931062071812, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.021550348472534994, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.02152456451606745, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.021477938151150597, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02145451775998127, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.021448661673588696, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021430289333623596, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021428166222643652, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02139030416343985, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.021370384946035083, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02135321071532191, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.021349487694842573, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021335691441973555, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "percent": 0.021326685649844, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "116.0.0.0", + "browser_version_major_minor": 116.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02131127374877967, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021306321447978137, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021305740244522912, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02130504167289828, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.021300880333215998, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.021296760370790584, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.021295531848308902, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "percent": 0.021283620899670178, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0212802655893338, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021274049463055507, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.021251062141595976, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.02123679096418728, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02121011844266143, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.021179519739764566, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:130.0) Gecko/20100101 Firefox/130.0", + "percent": 0.02113785276536965, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "130.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02112792034179352, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021107003553811444, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.02109546350080916, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.021081207841761332, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.021066612200458343, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021057627031471043, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02103643202978343, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02103627785330175, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02103390986383168, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.021017628252298245, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02101151047327248, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.021006821992502606, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.020978314075514655, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02097003192712161, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02096141012020025, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.020961086212560695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02095109535414407, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.020939866405105367, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020918963650375287, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.020915937947891854, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", + "percent": 0.02090815216489637, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "124.0.0.0", + "browser_version_major_minor": 124.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "percent": 0.02090002408667271, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "125.0", + "browser_version_major_minor": 125.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020897098836955952, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02088797225187914, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.020885420144124325, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.020872164914550292, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0208655620218147, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020864715457320094, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.02086386973398155, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020861811408243634, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.02085920711544371, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02085368700831043, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.02083276476266861, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.02082052921777623, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.02081462524304323, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02080623385134314, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02080297346230457, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.020798756985065284, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02079106364741142, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.020783872891711058, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "percent": 0.020771655688979065, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.02076954672141155, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020764618300756554, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02075081723439655, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0207254178857957, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020721908747484346, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020716931190124445, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020707614484546274, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.020707511199292195, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020702903348210653, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.020695043060028225, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.02069285122935825, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02069198973078141, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.02069132303841366, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020691121743969445, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020679605721044603, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020671011199568153, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020665690820013828, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.020661935331811064, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020656243795065764, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020649870470489533, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.020649579453348398, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.020647073142934888, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.02064217117448163, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.020630341086547484, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020614835032126667, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020614092285614106, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.020609120527190383, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02060833214047584, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.02060381068239936, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02060100506156252, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.02059058633359808, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.02057264314599412, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020539363380779056, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.020535149317910766, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02052779839690096, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020521028310606027, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15", + "percent": 0.020512769304432216, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6.1", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020507844039693357, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020502751057214073, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.020498654291064237, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0204984562885618, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02049125538163514, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020485242103592996, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02047966689675109, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020447565948043495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020405753584525607, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02040468171278379, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020402307517711566, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.020395375704187476, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020380349625575694, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020367596637937312, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.02035510926245968, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.020346596743803007, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020343250403777702, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020337698371135485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.020330342238504415, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020328923437961773, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020318038858531604, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020309147269340702, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020300977701673387, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020246666544822194, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.020234751475886357, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.020230280154457214, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020213319606181362, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020212846254065903, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.020211707258171126, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.020210085238030015, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.020201101751177463, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.020200892309413764, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020177030971823405, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.020174648343936678, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.020170970960455083, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02014216038470016, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.020134219178507964, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.02011802037033416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02011775039992564, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020112796827623222, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.02011113520825391, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.02007693076466659, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.02006694135585323, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.020062209411722872, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.020041848801138993, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020028795541397918, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020008399437229842, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.020003893354287268, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.019998384574637817, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019997145120699944, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019993888021228656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0199898139514303, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.019969379801513757, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01996750641958588, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019966553081973284, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.019938891635999573, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019937925081815026, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01992040177311618, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0199076951968767, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019907392678798517, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.019895351220828708, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019891211909851145, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01989051409432708, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01988938006616327, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0198755029290788, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019875060862700543, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.019872506013347977, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.019869077442585696, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019867749598842355, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01986120895357232, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01985084006034716, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.01984479700209297, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01984259931439141, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.019840596480884473, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01983328997963733, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.019822588469073147, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.019818638789444013, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.019815296264359548, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.019815050383635867, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.019795945167674307, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.019757683689616747, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01975445575472239, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.01974014509826359, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.019730959332273052, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019713420696362078, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01970581043749284, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.019691316083401005, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01968780017048326, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.019681665229365746, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01967652349142578, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01966198008972399, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01964043570385655, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01963223319271984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.01962917166765054, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.019629075545460048, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019627484669750384, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", + "percent": 0.019624962038787972, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.2.1", + "browser_version_major_minor": 17.2, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.019616694541998457, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.019610470853360595, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/209.0.442442103 Mobile/15E148 Safari/604.1", + "percent": 0.019606190860113053, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "209.0.442442103", + "browser_version_major_minor": 209.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.019602407256516376, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01959884980501562, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.019584702614199943, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01957751508710379, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01956982911079502, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.01954680531652108, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.019536124601567638, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.019531660077404395, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01950525858605919, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01950376300697331, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0194870016015552, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.019476202370785126, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019474827668457446, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.019470881583597, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0194626328865909, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.019457190517830215, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019454102853237776, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019453964609935707, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.019441322286508234, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.019439573998237786, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01943841709256202, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.019426104732284215, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019425038010544758, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.019421004705351224, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01942016599321235, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019417263699616553, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.019408548132107375, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019404437465104757, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01940224268754153, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.0.1", + "browser_version_major_minor": 17.0, + "os": "iOS", + "os_version": "17.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.019400051822499636, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.019385554309813962, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019373761374194512, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01936814990914524, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019364503946380975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01934667442664101, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.01933908292331376, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Windows", + "os_version": "10", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0193219444671962, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "percent": 0.019318387822588077, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019318157355661936, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01929316191115589, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.019289359782343522, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01928607240706992, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.019277910681925123, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.019273760563157584, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.019269221135848873, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.019260648779263884, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01925225523888874, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.019240915045050853, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019235452399201654, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019223582424047828, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.019220229365881466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01921741225616991, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019206259139446548, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019192197829570445, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019181783259166134, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.01918092953717567, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019164158848249048, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01916370099294022, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01915750710049348, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01915236798825141, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019147741888601435, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019129969773703195, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019125313200211352, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01912300681587048, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01912183147438396, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36", + "percent": 0.019117295496893994, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "83.0.4103.116", + "browser_version_major_minor": 83.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0191156766115284, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019113756346228576, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.019104828392376757, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.01910339121967395, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.019099961224373956, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.019094147416092742, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.019091184126084064, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0190772066205784, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.019069521179243865, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.019062597242649745, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.01906094005264053, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.01906043681090481, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 9; KFTRWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/130.4.1 like Chrome/130.0.6723.102 Safari/537.36", + "percent": 0.01904433657048117, + "type": "tablet", + "device_brand": "Amazon", + "browser": "Amazon Silk", + "browser_version": "130.4.1", + "browser_version_major_minor": 130.4, + "os": "Android", + "os_version": "9", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01903380804770157, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01902403683644157, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.019015388834057206, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.019011035283767325, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01900619766946648, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01899364932890216, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018989488223515438, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01898845120772444, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.018980188892949425, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018970958873476558, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01897020422818337, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.01896939812205163, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018948428506498544, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.018947384947046737, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.33 Safari/537.36", + "percent": 0.018936143844497155, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.6778.33", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "percent": 0.018927767913457932, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "340.3.689937600", + "browser_version_major_minor": 340.3, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01892621214922719, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.018919288655083105, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01890423123427039, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018887775556978327, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018887256239212412, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018886649750177138, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018872329594739293, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018869387688885027, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01886793967119454, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018862642319734772, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018860458185760605, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018859308362741228, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0", + "percent": 0.018849345429396754, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "134.0", + "browser_version_major_minor": 134.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018845749393514608, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.018834707044726694, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01882106611059221, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.01879451946588215, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018786682059970337, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018779188062151415, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018768520166988933, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018766386160762205, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01876076900142972, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01875605017765604, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018754515944061904, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.01874091095987934, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018718528036911108, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.018712773730490947, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.3.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018707879682739754, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.018706979532561906, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01870175203522177, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018696990050042293, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.018696272920034143, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01869515868048642, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018694689141618245, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.018680384293240072, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018674245397019222, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.018669377838203667, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018669076814120805, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018662372889476107, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01864927768274604, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01864761180379111, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0186449800579125, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01863229139940932, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.018617991933057215, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.018602415271337342, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.018600092127755485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01859445366216834, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01858699996315316, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.018579606316312742, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018578629117822616, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018574763004556412, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.018573601342977253, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01857262177547787, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.018570385121727113, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018560625682351423, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.018538767543598216, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018533756339302408, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.018511542456786694, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.018493550715973618, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.018490539411568002, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018482290306550537, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018482150833623288, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.018476329138633275, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018470122523129875, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01846939808627038, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018468214240037566, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36", + "percent": 0.018457126128868635, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "119.0.0.0", + "browser_version_major_minor": 119.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018450164083349648, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018438672037968395, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.01841239591189956, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018401902995945962, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018401020587842026, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018358539057467132, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018351395768292592, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.018351225331462766, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018333472613380596, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.018327547724037285, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018323882164297173, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01832229887539693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018314825764874716, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018313982258328577, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.018306217136357383, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01830586368385068, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018304748619798743, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.018294938213044162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01828933225689578, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018283231291862247, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.018277790331905013, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018265374593796988, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.01826429581618754, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1", + "percent": 0.01826125105828031, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01825449992145988, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018248641697152873, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01823293325052515, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018226661964683727, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01821794758250426, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018210468854493022, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.018197108694968527, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018176538966126015, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01817422604945443, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018171622865867564, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018162069127616743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.01816183653143262, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01815616611512227, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01814385168502696, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.018126545519855757, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018125708810091797, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018112512393698692, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01810579047015927, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018103665799928715, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018099005828350605, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018096310365086687, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "percent": 0.01807686718867367, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "123.0.0.0", + "browser_version_major_minor": 123.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "percent": 0.01807407897059105, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.018064981955609467, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018051121705717633, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.0180508462232057, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01804960155738386, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.01803460981778031, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.018030671968396778, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.018025860061229287, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "percent": 0.018025342934736142, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.018024098641566646, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01802408586759555, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.01801292721973204, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017984103598617976, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017978431988913274, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.017975953533700722, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.017966831631297673, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/118.0.5993.92 Mobile/15E148 Safari/604.1", + "percent": 0.01796609918379485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "118.0.5993.92", + "browser_version_major_minor": 118.0, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.017965419995317033, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017956738838652633, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01795550016071613, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "percent": 0.017954931097600352, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.017936242543809267, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.017930673035510523, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01792204690862133, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017906536600504317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.01789858524848897, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0178930458823151, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01788886690696081, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.017877665126756485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017869690415538687, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.017869091244026226, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.017865220234636135, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017862140223007673, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017859517673450457, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017858576252760466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.01784577660417087, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "percent": 0.0178421112501517, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/326.0.653331328 Mobile/15E148 Safari/604.1", + "percent": 0.01783928024846664, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "326.0.653331328", + "browser_version_major_minor": 326.0, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.017839195814268773, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.017833873701733432, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01783383175148055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017819388220883546, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01779647962299349, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017796221427960078, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017792936631914228, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01779177666795262, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017787967422999294, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017786115793423478, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017784377054800977, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0177793032234602, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01777441587538562, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017768367033313096, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017768067624793195, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01776370421557853, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017762976639096355, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017762043598487202, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.017759105961164864, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.01775702187691918, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.017744226773977162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01774344005506948, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01773464758702844, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01772037044864812, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01771200436004771, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.017706328331854905, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017704652301149454, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01770079727952854, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01769771325073483, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01769749873972845, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017692155007359038, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017662327276676288, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.017656547791999874, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.017647060063726395, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017640907414503, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017636604315788323, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017632208282058956, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.01761755683448369, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01761495036319043, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132 Mobile/15E148 Version/15.0", + "percent": 0.017612987702712815, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari UI/WKWebView", + "browser_version": "15.0", + "browser_version_major_minor": 15.0, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.017611788197354494, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1", + "percent": 0.01759023033954801, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.5", + "browser_version_major_minor": 16.5, + "os": "iOS", + "os_version": "16.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0175861889144204, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01758348211596768, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017583401061866122, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017583273153795997, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017582005350744705, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.017581746771505074, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.01758159647472315, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017569197525645334, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017565226206359363, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017553659903784578, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.017539383290230078, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01753629088354878, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01751968161870784, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01751092875486644, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0174949081668066, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01748764181650872, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.017479426471756872, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01744209603892962, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01742799305866253, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017426630676919474, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017426146846921554, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.01742224264315628, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0174213827838196, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01742015742615954, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01740595998385978, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.017405888895358507, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017401494664218062, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.017397950889702637, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01739601260134537, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01738523016874193, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15", + "percent": 0.01737588098572206, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01737526908880611, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01736471779972974, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.017363918694168826, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.8.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.2 Safari/605.1.15", + "percent": 0.017331621073812913, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "14.0.2", + "browser_version_major_minor": 14.0, + "os": "Mac OS X", + "os_version": "10.15.6", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0173314233872156, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.0", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.017328689553299706, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01732653070349136, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Safari/605.1.15", + "percent": 0.017322651769347806, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0173226027045263, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01732143087228244, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01732014804397009, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01731918455048005, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01731424337972834, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01731336976098575, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01731111913976485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01730828974311461, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01730252754177129, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01729791663125836, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.017297873063514033, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017292305183833928, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.017290152400012925, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.017275570275785523, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0172750342697533, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01727470715429616, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.017274510925917797, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017253805666732364, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.017250685927396755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.017248382270006284, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.017242804488110254, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01723020087847439, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.01720438539142894, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01720366058348487, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.0172032468219843, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.017197458802670873, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.017196236785856828, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0171910541806704, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.017182195715951663, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.017181623472402787, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/338.1.685896509 Mobile/15E148 Safari/604.1", + "percent": 0.017180607082563967, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "338.1.685896509", + "browser_version_major_minor": 338.1, + "os": "iOS", + "os_version": "15.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01717780549639819, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.017177414122622053, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.017165422713281985, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017158298954359587, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01715164399771083, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01714898511566052, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017146153580273667, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017143707237676397, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.017133709223670336, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.017129913505601405, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0171189223692231, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.017114287528683613, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.01709449817865739, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.017091542941292462, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017089255983549795, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01708331015696167, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "percent": 0.017080332579743895, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01706489311265751, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017064769428016238, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017059662403313827, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01705604101523634, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.017054768287666965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.017051682774114854, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "percent": 0.017050688904646383, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01704725664866191, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017045598288253465, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.017043822155753556, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.017040804261727507, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.017034304172246145, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "percent": 0.01702988452096263, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.2", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017027217721864784, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.017025734889623056, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01702524357738271, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01700954866007809, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017006915668917943, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.017001840360614293, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01698690369329989, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.016977260841009867, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01697726031862149, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.6", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.016976832460295112, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016975953505728253, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.016975782475101942, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01697078002025012, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.016962059331001606, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016957719454548104, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016951758456999748, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.016951376259802173, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01694461859922847, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.016930344774281954, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016922400116255887, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016921298813611384, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.01691896867073492, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016909056705057702, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "percent": 0.016907799376877582, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "125.0", + "browser_version_major_minor": 125.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016899600145085517, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016889304400700415, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016883084672146175, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016881586470898075, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016870566253053887, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.01687038031158605, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016866190100251054, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.016865855974387292, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016865084407086933, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.016860076016068714, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.016847942173884495, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016843276601656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01684049678661268, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016836756241990384, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016836171013591448, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016833779692759693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.016815622328089367, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016805491406633462, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01680476570192687, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016803648897666078, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1", + "percent": 0.016803519054330293, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.5", + "browser_version_major_minor": 15.5, + "os": "iOS", + "os_version": "15.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016797129266303768, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016780491798470635, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.016772568363340536, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016768473210393944, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016763223864351005, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016763182574301858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016756587287025886, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01674834283950391, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016738249580441917, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01673541892063298, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.016734502419717332, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016729783127152016, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01671871098240101, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.01671605970759722, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.016704582630882505, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Safari/605.1.15", + "percent": 0.016703165392897543, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.016700037330096274, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01669906360423398, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016693037514592463, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.0166915856865883, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.016690468391369173, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01668663918225663, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.01668508718785642, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016677822451916294, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016673700095090224, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016667229999147277, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.016667138343329193, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01666500470888292, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.016664570845671068, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.016658490851801107, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.016657096400350985, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016649145524081112, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.016648069792721338, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01664473312620046, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01664416450298504, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016639604584694944, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01663924245010558, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016636416574408314, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016635380888833564, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.016631549576205162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.016630661832593843, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01662604326126309, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.016614149369581978, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01661086150843254, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01660664205637416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.016602892148730036, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.016601616791834348, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016595203781152725, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016590997112576368, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016586475838441375, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.016586396707908596, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.016580278667108404, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01658022686413588, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.37 Mobile/15E148 Safari/604.1", + "percent": 0.016578009176644266, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "130.0.6723.37", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016573341637124636, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.016571106574701247, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.78 Mobile/15E148 Safari/604.1", + "percent": 0.01657037291128424, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "130.0.6723.78", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01656981220925081, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.016569257381429416, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016558813137593824, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.01655423953297186, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016554229426196613, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01655230246525343, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01655214736548931, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0165447446290804, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01654270042840087, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016538664947722905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016538303947897584, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016531765481018566, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016527454532134892, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.016513904267671665, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01651349326587062, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016512033656351916, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22B91 Twitter for iPhone/10.68.1", + "percent": 0.01651019027434059, + "type": "mobile", + "device_brand": "Apple", + "browser": "Twitter", + "browser_version": "10.68", + "browser_version_major_minor": 10.68, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.01648912274314531, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016484850745340025, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016477890407544715, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016460451559972007, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22B91 Twitter for iPhone/10.68.1", + "percent": 0.016459616392214337, + "type": "mobile", + "device_brand": "Apple", + "browser": "Twitter", + "browser_version": "10.68", + "browser_version_major_minor": 10.68, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0164580612572736, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.016457833340482876, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.016457609905909394, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.016455554640529606, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01645494648423263, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016452720381897226, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.016451243322864757, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01645107076614459, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.016444418050157835, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.016441897543620788, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016439004080432907, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01641680888074076, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.016413670471177734, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.01641325771845432, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016409491061769123, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.016405239118487833, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.016403463078408717, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01640144419247291, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016395328276186266, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01639299376688603, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016387535110006554, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016384882722784176, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.01638250116228962, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.016376531524353265, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.0.1", + "browser_version_major_minor": 17.0, + "os": "iOS", + "os_version": "17.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01637348978982891, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016371947444496743, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01636593885270966, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016363095498979994, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016356363078700833, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01633982817200163, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "percent": 0.016336570472703316, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "109.0.0.0", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01633554774766882, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016330567619970354, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01632697512190782, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.016309265301213204, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01630344040444937, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016294545964912675, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01628106011282782, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "percent": 0.016280975522853416, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016280772878497617, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016274921488216132, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016271624192593856, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016268242859365872, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01626664954134598, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016265880509276725, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.01626340051984696, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01626304656494393, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016256632118019452, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01625555916250815, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016254393700455193, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016253740547705255, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01624311330577304, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.016240713157796346, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016239180845378384, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01622977941308485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 9; KFTRWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/130.4.1 like Chrome/130.0.6723.102 Safari/537.36", + "percent": 0.016219834746270083, + "type": "tablet", + "device_brand": "Amazon", + "browser": "Amazon Silk", + "browser_version": "130.4.1", + "browser_version_major_minor": 130.4, + "os": "Android", + "os_version": "9", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.016218405721414617, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016217097285454864, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.016216697895969724, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.01621206132001369, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016208768268061745, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016205194327162716, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01620039124164916, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01619229635315306, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016186154449879066, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016185335834258224, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016184397847381723, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01617330702683158, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016172386952192256, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.01617026887346421, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.01616875810577223, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016162729836677235, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.016161787086364298, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016158265964499815, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.016142714294153888, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016141962431323015, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.016140353888162328, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016139348840948475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016136765612718363, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0161350325293641, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.016132337154009677, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0161283720393898, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01612643788395165, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01612346867459204, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016117522073418548, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.016110709294716172, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.016109277603100807, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016100934282664913, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.016098081774987696, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016096602049105627, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016087871455823393, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/262.0.527316235 Mobile/15E148 Safari/604.1", + "percent": 0.016078116566806293, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "262.0.527316235", + "browser_version_major_minor": 262.0, + "os": "iOS", + "os_version": "16.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01607098347972019, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01607036224787187, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.016069955733092986, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01606672694098109, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01605441169110928, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016050471234282052, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01603875175566361, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01603686707986278, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01603132170178224, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.016029762615530425, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016028625909571922, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01601950489713646, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:109.0) Gecko/20100101 Firefox/115.0", + "percent": 0.016018992804479576, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "115.0", + "browser_version_major_minor": 115.0, + "os": "Mac OS X", + "os_version": "10.12", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.016015820329640343, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01601265230151414, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.016003180643368392, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01599911887629137, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:109.0) Gecko/20100101 Firefox/115.0", + "percent": 0.01599521280347071, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "115.0", + "browser_version_major_minor": 115.0, + "os": "Mac OS X", + "os_version": "10.13", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01598448627275496, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015982542300964124, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.015978936345833077, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015978103784651723, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01596926217853531, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Android 8.1.0; Mobile; rv:123.0) Gecko/123.0 Firefox/123.0", + "percent": 0.01596798538410529, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "123.0", + "browser_version_major_minor": 123.0, + "os": "Android", + "os_version": "8.1.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/339.0.686111475 Mobile/15E148 Safari/604.1", + "percent": 0.01596376993158162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "339.0.686111475", + "browser_version_major_minor": 339.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015959477894083588, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015956653347122002, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.015953313004482668, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015951649577437343, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.015943897474397558, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01593175166634724, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01593165212072975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015919030141274253, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.015917318567693704, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015910559982805078, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.015908022789462718, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01590765948052306, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015905966875673944, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01590320518139716, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015902904296814485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0159007500721552, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015898129317776462, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01589497338681625, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.01589488334025911, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015894528138800695, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.015894177534360333, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015885003673947382, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.01587952291251661, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015876427517362464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.015875279261209397, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.01587062644902223, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015860600099740423, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015855922242989026, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015851227242197608, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01585022736431312, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01584128350565401, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 12; SAMSUNG SM-S127DL) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/16.0 Chrome/92.0.4515.166 Mobile Safari/537.36", + "percent": 0.015836188057029934, + "type": "mobile", + "device_brand": "Samsung", + "browser": "Samsung Internet", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "Android", + "os_version": "12", + "platform": "Linux armv8l" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.015833687735861524, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015831167690024326, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015826447156228877, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.015820319964748423, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01581317951835339, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.015810423778395796, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01580647599498927, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.01580417960644617, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015801979683094016, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015790891238037518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.015786511434144532, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015785433813266508, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.015784701829355742, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.015781648787798558, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015779526813858268, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.015777058925690736, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015771087431758257, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.015768438895252014, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.0157679778180624, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01576682500320753, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015763955468946093, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015760227169861474, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01575380434157004, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015749657934321457, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01574866964415111, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015740894288878274, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01573798932825404, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015735065666566253, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015735009216553197, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.015734478047257628, + "type": "mobile", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "131.0.2903.68", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015731613554088017, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0157310760809211, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015730241424882774, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.015724561972938426, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.01572338361559323, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01572239019936823, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015708351352617576, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015707503615897865, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015701916091320618, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.015700349987414892, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01569105967241498, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015687041767058856, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015678182041846282, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.015670740174244416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01567011723913738, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01566534903938546, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015664713827979727, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.015649056105562824, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01564871525903331, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/308.0.615969171 Mobile/15E148 Safari/604.1", + "percent": 0.015646274675842287, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "308.0.615969171", + "browser_version_major_minor": 308.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015641406270037676, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01563391693730633, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01563163482212546, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015614515319459054, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01561225331235615, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015608422584417974, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015607103268984193, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01560509940994718, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015603566954950306, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015597823794794984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015597461004751905, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015597404157943631, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15", + "percent": 0.015595252396783912, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015588007484793987, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015587210430074806, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.015583615160527973, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015576593171170107, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015575528479492853, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015575491302567978, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.01557249616915819, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015570639955597083, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01556973710093422, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36", + "percent": 0.01555890691696474, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "99.0.4844.51", + "browser_version_major_minor": 99.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015557382721196448, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.015557269074801329, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01555676386922815, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015551596461354351, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015549196752501888, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015543688529050774, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1", + "percent": 0.01554364644996787, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015537516095826123, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01553678164071906, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01553520119043954, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015535174271243997, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015533623942712587, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015523181753941549, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.015517336183151978, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015511062855471961, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.015510627177016964, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015509663297299893, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015501127083048124, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "percent": 0.01550077336876757, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "124.0.0.0", + "browser_version_major_minor": 124.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015495578493877058, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.015485822369904029, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.015476821269228244, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.015476409531915453, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01547021590364864, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.015467992632550965, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.015464002895950645, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.015463323147733361, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015460169412166908, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015458489465902914, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/324.0.648915268 Mobile/15E148 Safari/604.1", + "percent": 0.015443279148948089, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "324.0.648915268", + "browser_version_major_minor": 324.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.8 Mobile/15E148 Safari/604.1 Ddg/15.8", + "percent": 0.015436703794909416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.8", + "browser_version_major_minor": 15.8, + "os": "iOS", + "os_version": "15.8.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015433474295376564, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015430751600904557, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.01542437869599642, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015423166483750835, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01542234150293837, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015418858882161709, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015416159639949271, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015407069599756218, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015403325103932587, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.015402061595114709, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015397836183598078, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01539526160264895, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01539458660632554, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015394026991442069, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015389303330693013, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.015384763487801843, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015383593935800317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "percent": 0.015383074394371138, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "121.0.0.0", + "browser_version_major_minor": 121.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015377643852898656, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.015377508037697139, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01537740200236494, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015359327507251616, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015352828945336608, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015344404804130385, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015344317019333914, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015339912778534345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01533608308151548, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015331842688460728, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.015330863949266567, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015329013150554212, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.015321590788964978, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015316076622687292, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01531114707199576, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01530049078321189, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015300435911786337, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.015297182974499581, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0", + "percent": 0.015292646247957979, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "131.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.015290928865303161, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015287263500889261, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015286110698587782, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.015282840178744499, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015271584550831507, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015260821453657362, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/236.0.484392333 Mobile/15E148 Safari/604.1", + "percent": 0.015253267827957991, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "236.0.484392333", + "browser_version_major_minor": 236.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015252197178693163, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01524640315149949, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01524631551295281, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015239872138975432, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015238949608546536, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015237661147299455, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015237207135626662, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01523374771428036, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015231260912920283, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.015222910812862532, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.015222252931889824, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015217926158866664, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.015216399625327576, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015215972793083486, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015213527017531852, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.118 Mobile Safari/537.36 XiaoMi/MiuiBrowser/14.22.1-gn", + "percent": 0.015207176420508959, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "MiuiBrowser", + "browser_version": "14.22.1", + "browser_version_major_minor": 14.22, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015206477086295833, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0152030856553623, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015200105174083574, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015198718415385863, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015193369953642448, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.015181352789087426, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015180331056741119, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.015177211354397644, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015173405001268068, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01516818863829123, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015159836830365639, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01515304722259899, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.015141633754231273, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015137040366953899, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.015136759731829255, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.01513639805054783, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.015131962012454028, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015131770028883749, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01512739402530865, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "percent": 0.015125823904006512, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.015124157458584834, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01511807057698331, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015118047194310523, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015117861630381948, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015116894320184112, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015112766527827896, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.01511188793363943, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.015103922148128753, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01510349650236818, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015102053209773399, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015101498410783211, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01510117856882375, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015097570335670442, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015084698060499743, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.015070018246293847, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015067865693440765, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015062379386014684, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015053934682837054, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015051847661339973, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.015048215826279002, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015039613941111078, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.015038271860146538, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015032149576402145, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.015025459996753957, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015024778101582937, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.015018973648611609, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015015618074176593, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015014247177824923, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.015009170405600432, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01500833906294082, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.015006875294839895, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.015005030826249597, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.015003962816576429, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01500021692779978, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01499663477818026, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014989950369188949, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/485.1.0.45.110;FBBV/665337277;FBDV/iPhone14,5;FBMD/iPhone;FBSN/iOS;FBSV/18.0.1;FBSS/3;FBCR/;FBID/phone;FBLC/en_US;FBOP/80]", + "percent": 0.014985503338894734, + "type": "mobile", + "device_brand": "Apple", + "browser": "Facebook", + "browser_version": "485.1.0", + "browser_version_major_minor": 485.1, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014981691159600592, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01497086443788853, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.014967629797495201, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014961507861062474, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014961099643445748, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.014955882235848915, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.014942442252723986, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014928953715268343, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014925079736790903, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.014922078075662246, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01491845054625695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.014918241995501671, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.3 Safari/605.1.15", + "percent": 0.014917525107157971, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.3", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.6", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.014916525522477823, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014908095431304677, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01490425910755739, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.014900503932883407, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014896753361918022, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.014893709890131378, + "type": "mobile", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "131.0.2903.68", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014892472437433996, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.014891450501190565, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0148870064600961, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.014886534105317055, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014878246799389665, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014873753356733004, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014871178847211314, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01486785172646921, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014856330502177289, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014848700023267629, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014847483790050112, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.014838335444709133, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014836998204569582, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.014834394130865004, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014831895617313073, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.014831391279732875, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.014827445084134614, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014826530086843155, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.014824197356401989, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014820512926174681, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.014819865122964475, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014809907004861329, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014802376803108771, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014792591965690896, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.014785342296325582, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01478091883763597, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014780063986107622, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.014776864983261504, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.014775023182775878, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014774412417550224, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014772894789728748, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014772458070934556, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01477231955466375, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01475912887416792, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01475902799476967, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014754705824971076, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014753660283691706, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014752283222860296, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01475157665486816, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014746345626608374, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014744999492044317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.014734932232855758, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014724071053968087, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014721092709273097, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01471688029179555, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014712999948839641, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.014710055890643446, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.014709090287854372, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014708840420505893, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014697393336116402, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014692441628610504, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014690297016649805, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0146832007379541, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014681543966176108, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.014676421732547339, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014667621092849805, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.014665566681893116, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014651752924096931, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014650547935723792, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.01464894707331133, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014648914713793334, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014647058159061466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01463690781636088, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014635050358111064, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014633254433884555, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014618148560214143, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0146177241199866, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014615120433867241, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.014611014252989716, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01460462924851108, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.014604046530650713, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014603991771665345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014599851895060823, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Mobile/15E148 Safari/604.1", + "percent": 0.014597240914869823, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.3", + "browser_version_major_minor": 15.3, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014594968354867423, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01459441862442398, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.014593471593196296, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014593032356518144, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014579976375053805, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014571836433357476, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014559202319718596, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.014557571774927804, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014549071064002972, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014542442125114817, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.014540470099320538, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "percent": 0.014533873038080715, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "340.3.689937600", + "browser_version_major_minor": 340.3, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014531519447716414, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.014530773389803017, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.014529946626580317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014529748373289774, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014527823299577611, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014527738219471717, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014527121196950802, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014518150246219648, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014515550575160978, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014507734884196656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.014507008143038465, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01450574764698705, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.014504841407903915, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014502439425434267, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6315.2 Safari/537.36", + "percent": 0.014499680796176723, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "124.0.6315.2", + "browser_version_major_minor": 124.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014478160845066975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.014478015742085043, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014471634217289933, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0144708703975405, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.014469847038469378, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01446840838261966, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014464523978115582, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014453178082031965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014451632599454844, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.014447842425555175, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01444271282680471, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.014442267600232987, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.014436664639568277, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014427035866986166, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014424691426365068, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014423247496726846, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.014415067461939437, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014405137622705046, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014401739576902842, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014401228687776829, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014400501694388312, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014399746203498043, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1 Ddg/17.6", + "percent": 0.014394156249053904, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014389394979400514, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014386777370708693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014383805978392016, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014379636593467188, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01437772621968155, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014376019645626585, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014369748032372303, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014368765307775987, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014365096172594944, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014361730654801008, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014355353557536745, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014350181525474289, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014349236472167471, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.014348768134706341, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01433226124296564, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.014330328971179036, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014322560962900797, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014317576661091302, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014315768486222869, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014311930181102477, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.014310297403071872, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014305224892353287, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014303515188554794, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014300271783735751, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014298728867697825, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.01429238403495507, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014291900534707606, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014279387697331399, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014269495930034196, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.014266775988283858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014265653216426739, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.014263446477872409, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014262306714681199, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.014255151036380876, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "percent": 0.01425109438423868, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.2", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014250450392953404, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014250026762190483, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/334.0.674067880 Mobile/15E148 Safari/604.1", + "percent": 0.014245740209200448, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "334.0.674067880", + "browser_version_major_minor": 334.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014239242110421598, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014238923030977391, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01423520623191403, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014230928487865302, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.014229148760219712, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014226730106562524, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014222512522535645, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.014220353752716167, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014219141069804795, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01421743810220014, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.014217175417209632, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0142170743863946, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014216119228811316, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014214156668765134, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01421312922270485, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014210641003830453, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014206076279421522, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014199984703505635, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014199674184712916, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014198288069442276, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.014196562642709536, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014196290103718439, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014195839947134642, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.014194394514529122, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014189329285218495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014189247430805086, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014186544371952488, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.014185524076921599, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01418407426969997, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0141778388441909, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014177262931995031, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014168470662798823, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014163432883928642, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014162874559632164, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014156166160395929, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.014153634893937584, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 10_3_3 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) GSA/68.0.234683655 Mobile/14G60 Safari/602.1", + "percent": 0.014149538563202184, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "68.0.234683655", + "browser_version_major_minor": 68.0, + "os": "iOS", + "os_version": "10.3.3", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.014131740770822085, + "type": "tablet", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "131.0.2903.68", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014131218717069807, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01412517567637586, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014123535868744165, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014116503553157668, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014107447889974206, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014107316579522973, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0141057119762689, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.014104294903999096, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01410369764094143, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.014103296369881875, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.014101813075628177, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014099209775925473, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014098447793763012, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014097828182690869, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014095155945476336, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0140869107796112, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014083946008590947, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.014083725500547034, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014080997175712422, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014076205161533432, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0140748766727241, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014074466413706892, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014068619808079276, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.014068452367363966, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014068160568384827, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014059216669124606, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014052829267988853, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014044170321027847, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01404332817498805, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.014039979356175413, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.014034652925584484, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014031501670041616, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01402997527132816, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014026788368713848, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.014023141166261006, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014020169200389877, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.014018737957482109, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.014017952123190935, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.014014249941693048, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.014010840714343966, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.014005288231803949, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013998605096406604, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013996010930823479, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013993485599155945, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0139906366093822, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013987138714470685, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013985020865864404, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013984138882301288, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.01398154386996654, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.013980927014694729, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013980260690005927, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013978609727266963, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.013977932060782409, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01396771186587876, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013957125541358018, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013956164910095955, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013952446800558757, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013951619234518263, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", + "percent": 0.013949103910691362, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013942972697740367, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013940501909598927, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013939663121213272, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013939591412629973, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013935261426339897, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013934941269492162, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.01393210706404904, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013932091935142882, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013930314197644266, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013927730464177968, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013927396274793813, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013926712167364885, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01392241791236471, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013914558334808062, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/251.0.508228821 Mobile/15E148 Safari/604.1", + "percent": 0.013911714618343923, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "251.0.508228821", + "browser_version_major_minor": 251.0, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01390908679508734, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013908842754779233, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013908011300050985, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.013903748361464244, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013901898508929852, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.01390126810397046, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.013898733726304907, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013894598051506822, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013890674224097285, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013889651532275754, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.013877562258601256, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013873051952061866, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013868120572047992, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013865780738915454, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013865179344332557, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01386126240884155, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013854949291954074, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013854580205638279, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.013850822887653052, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.013848251489337538, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013845611085768823, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013834339392990346, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013833355058266519, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013831277914330576, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.013827028250010406, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013826732118739431, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013823209242538617, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013822709579914378, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01381953621668123, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01381777732190586, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013813371539524532, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01381261592019723, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013807852769997443, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013807840492970692, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01380600065706214, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Android 10; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.013805751233097432, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.013803348324569817, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01380075893126097, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013799826738192297, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013795782489695903, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013794549675833837, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013785148875194374, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01378449283212731, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013778780346646721, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013776680209912507, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.013771773242146181, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.01377118629277941, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.013770664959469263, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01376475727102855, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013763552787710595, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013763433709867875, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.013762541760535608, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013762453020594952, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01375496633158539, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15", + "percent": 0.013752841011765592, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013750154637582898, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01374937110055835, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013746184606236826, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013745887526470785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013745833943141385, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/129.0.6668.46 Mobile/15E148 Safari/604.1", + "percent": 0.013745720393075493, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "129.0.6668.46", + "browser_version_major_minor": 129.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013743261682412543, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013734100154915788, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013731214406467114, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013727121044936084, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013726893661013243, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "percent": 0.013714114224659202, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013713989509115943, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.013713968685790716, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.8.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013711748715382943, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.013711705181378087, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.013707983410206834, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013693305949483658, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.013691360134493284, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.01369015039700579, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.013686883322589537, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013681396472896417, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013681149234101223, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "percent": 0.013664810370953313, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "109.0.0.0", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013663707996649574, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.013662229080932104, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013661525326774724, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.013660497518968407, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1", + "percent": 0.01365624318879168, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "130.0.6723.90", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.013655794015417088, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.013654709481010713, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013654642057503427, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.013646967258974949, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013641552214928252, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013641332765654351, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013639432274401963, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01363182833470873, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013629124319785565, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013627866126509475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.013627624882331605, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013624699569811223, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013624395755313552, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.013615739194798986, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01361479090696803, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.013613589242703294, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013607592469367139, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0136004161379936, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.013599833295651807, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.013594533923646481, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01358954535068015, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.013589076824055968, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013585164118820816, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013582977996599363, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013581241977239988, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013579217077805343, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01357722523412948, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.013573630794532627, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013573513527526188, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013571758994880664, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013564736710077057, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013563501403031692, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.013560184665738627, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.013560048947999667, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013558169759853118, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.013556712375538512, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013556704428819422, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01355663581197837, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.013554915344165771, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013545854609670933, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013544833819043717, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013543805368616177, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013539814346565386, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013538070528358433, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.013536183418262078, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "16.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01353193354628712, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013528658201952365, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013521346710702509, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013521275555122418, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013520746718724747, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013513860814617976, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.01350751788047837, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013506979694107309, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01350401449019685, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.013501257602432483, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013501036335937619, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.013500462130926469, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.01349437431760084, + "type": "mobile", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "131.0.2903.68", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "percent": 0.013487801768172247, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013482884352807806, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01348120682483375, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013479124774621161, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013478966969510172, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013478943064235084, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0134764996932428, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013476178766826436, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0134738154637304, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013471919467611027, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013471546281808817, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013470281838005714, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.013470045937066164, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.013466660071633966, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.013461826125541664, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013459601750995224, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013458861879263297, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013454931163000427, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013454892577746225, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0134517871940094, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.013450442771040442, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013446581120946842, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.01344578092507818, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.013441260053780898, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013438382074599261, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01343634997647001, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013435653451570502, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013426010943007343, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.013422345415938744, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013421911782375942, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "percent": 0.013419945132198212, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Edge Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013417486172054863, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013410784214597566, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013410709538217274, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.013407772817027182, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1", + "percent": 0.013404760361007838, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "130.0.6723.90", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.013404530219668741, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013401920359020441, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.3", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013401153222152446, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013400062815323074, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013398780549317464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013397517685857677, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.013396057562863717, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013394643622256616, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01339139365030764, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013391056515996553, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "percent": 0.013384586105778171, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "125.0", + "browser_version_major_minor": 125.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.013384395222872244, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.013383991309465442, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013382824334235197, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013381450610425708, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013381198592240927, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.013381125928416579, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013377894595961927, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.013377040772033947, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013371442278463168, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013370966863732162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013370344492352214, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013363891278350812, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "percent": 0.013359137916446585, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "109.0.0.0", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "10", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013358162487254572, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013355532830005502, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013354537649180908, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "percent": 0.013352777609727673, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "120.0.0.0", + "browser_version_major_minor": 120.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013346688132059099, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013346279219229932, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.013338835790594823, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013333285082051857, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013329495375451002, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013325784589086353, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.013320930871497872, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.013318009470639788, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.013317768676423494, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013306047324479717, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013305282050887006, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013303679770544669, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013299905542846246, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013299198223078313, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013298269824428718, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013296403638560998, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013289066904861055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013284014859386045, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.013283734128222565, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013280436539271055, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01327200682170484, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.013272002276200495, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013271848991501141, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.01325974112455193, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013248899031749006, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013243930121936398, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013233455557980897, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.013231912777227062, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013230852593461608, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.013230072821232338, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013227255577275047, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.013221061995241054, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.013215382070121684, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.013215239533147767, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013214622886607104, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.013214448898382972, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013214004321872051, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013211437511504064, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013207437651131342, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013202632929422043, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.01320023331771078, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0", + "percent": 0.013199883716175795, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "127.0.0.0", + "browser_version_major_minor": 127.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Safari/605.1.15", + "percent": 0.013199812613128633, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "14.0.3", + "browser_version_major_minor": 14.0, + "os": "Mac OS X", + "os_version": "10.15.6", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013194827323031259, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013194098873934348, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013192631441980046, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013191646124012166, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013187007390830105, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", + "percent": 0.013186659104327222, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "124.0.0.0", + "browser_version_major_minor": 124.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.0131803747071975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "131.0.2903.68", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01317888446173432, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013170441191961698, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01316729856696341, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013164504221118095, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.013161149544380659, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.013156465217444924, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013156371441581995, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.4", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.013156195347623323, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013152810957840346, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.013150211635809, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.013148936792213456, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013148436087262867, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013145900388711261, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013145589421944765, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013141684359951214, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013139961042114545, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013133384348710858, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.013132284330440004, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013125724367678903, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.013124995359209721, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01312131069103446, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013117202714172565, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0131157657532594, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013113952973682239, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0131119085844056, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013111707563879152, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.013111269010264428, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013110479076305031, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.013109048749858661, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013108449138582858, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01310390957935418, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01310002465984953, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01308189438166081, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013080359915024056, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.013070340517591766, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013067939960013984, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013067863030836603, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013062539982988853, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01305897765090372, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013055194405966513, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.013052012608478776, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013050063832693665, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.013044187311458193, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013040834245686434, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.013040056407451019, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.013038537975341592, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013024150681633075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013023018715655402, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.013017614916809356, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.013016061539612368, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013012646695052013, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01301221009297673, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.013010236726345267, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.013007156037577575, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012999581926542493, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01299461016355881, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01299114010263171, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012988375986244048, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01298824493251235, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012986566215910414, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.01298343189404538, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012975035522440172, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012970722393521015, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012970508907033244, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012969931351740079, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012961618196265722, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012954785990473058, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.012945916304798747, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012943393737575073, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012938846985232965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01293868315038656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012938447439776325, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "percent": 0.012935329660184092, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.012925863098000466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012921914890288247, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012916452443893844, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012916042785928198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01291423306956415, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0129086994708455, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012907400783906066, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0129002181549468, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.012893062434671494, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.012892656271810986, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012891648005417344, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.012890697561829858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012885158439211538, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.012884632526302054, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012883273229012676, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01288190871185591, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012880783457458462, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012874954244362326, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.012873841724732578, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012872863694379452, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.01287025336436946, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012870132964755933, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.012867361808467146, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012865390681447613, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.012860288947561926, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012858953117932692, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36", + "percent": 0.012858594219429314, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "123.0.0.0", + "browser_version_major_minor": 123.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012856959691564645, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.0128546391049532, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012849573441587263, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012848503926660648, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012846837044363566, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012846381398365138, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012839999713466036, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012838068035977151, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012836469796402872, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012834788229668789, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012831839530341215, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012831635601334443, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012827493296746343, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012826083360990177, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012822123620272909, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012821086411169134, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "percent": 0.012817702775676844, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012816407947346047, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012816299862004492, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012801919847172815, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012801564697734658, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012796111089236827, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012789966396667076, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012788550499281603, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012786381171180888, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.012785737923442113, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012775213478867722, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012774694923792145, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.012770272779360945, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012766087931541058, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012762442826029301, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012761639035258076, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012757935421492398, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012756853527753317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.012756727902132713, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012754412151477441, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012746770891476541, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012745021558635564, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012743311092899322, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012739743890684737, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012738229505376914, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.012738215569679684, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01273815603116651, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0127381136465696, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012737840220418083, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012737267792286046, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012731260589027361, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012729835797402068, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/317.0.634488990 Mobile/15E148 Safari/604.1", + "percent": 0.012725450580795586, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "317.0.634488990", + "browser_version_major_minor": 317.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.012723167797417768, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012723013408495144, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0127211825140678, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.012718293499692715, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012716180049363595, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01271603726821889, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.012707944478330306, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012707279917070475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012707176172454895, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.012706805716744023, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01270317263420419, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012700564388713993, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01269721409547546, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012684951303603413, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012682080953735046, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012678262212594505, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012673706917434953, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012670311102676975, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012667413841382433, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01266049880428798, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012656282805202599, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012655759061429682, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012655678803722011, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01265336026441501, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012652414161774782, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.012650472434938635, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.137 Safari/537.36", + "percent": 0.012649570475735145, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "128.0.6613.137", + "browser_version_major_minor": 128.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012647616447570276, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012647068339891087, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012646898188410659, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012640907645422784, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.012637073788026242, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012634912133863526, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012634662848763693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012634606242113301, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.012631839607943765, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012630787530953674, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012628772116245471, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012620393500088178, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/324.0.648915268 Mobile/15E148 Safari/604.1", + "percent": 0.012619245913753947, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "324.0.648915268", + "browser_version_major_minor": 324.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012618080288686481, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012617549215961022, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012615449016590185, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012613767369297204, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012610792371215663, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01261035420176233, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01260700256058831, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012606905319010347, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01260580436388128, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012599707194146716, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01259891137705147, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012598806060259557, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012597233660440483, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.012596820420425031, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.012593787416716311, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.012587855651408346, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01257964352031196, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012579582956571233, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012577848572249067, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012570643532081201, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012570509075138599, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012565730315904342, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012565444424875064, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012564884414117405, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012561821449124853, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012559279867698725, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012557095666017133, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/128.0.6613.98 Mobile/15E148 Safari/604.1", + "percent": 0.012552116721263428, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "128.0.6613.98", + "browser_version_major_minor": 128.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.012550741909628998, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01254326957143198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012542591429968856, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.012541238322275237, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012538128940699863, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012533692770224118, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012533251655031611, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.01253165682170509, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012529980817942462, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.01252861749647466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012523354106940736, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012516277423667066, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.012515560310372405, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012515461570138125, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012512462629528555, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012508870054298517, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012499899544769051, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01249667302543998, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "percent": 0.012496485237208373, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.0", + "browser_version_major_minor": 17.0, + "os": "iOS", + "os_version": "17.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.012494201264551913, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012494029536275241, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012492171885837485, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012491244373430974, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012489939118692187, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.012489255645956666, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012487734161165925, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012484794248154673, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.012484593307475473, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012482945963402653, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.012478007588139552, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.012477876161709391, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012470873677991582, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012466553704582406, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01245855364171714, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012448393828071763, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "percent": 0.012447026692076464, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "125.0", + "browser_version_major_minor": 125.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.012446233068148972, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012445730327078106, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012445071433863716, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012441784958683932, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012440987087362541, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01244009457335287, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012438979656915044, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012436126851755403, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012433469042955083, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012433204008731451, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.012431457950120897, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012427789749331215, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.01242764621586237, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.012426282682603672, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01242155759830148, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.012420021258154886, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.012416428538580663, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012414334152278033, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.012411564103571478, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012407346176437717, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01240443179924946, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012402581590190132, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012397855424286166, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.012389623151982648, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012389609586891887, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012389007612828433, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012386597230312177, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01238612729454856, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012370795071358496, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012369221477968805, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012360413818878195, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.01236016777866757, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012360027304424384, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "percent": 0.012359800034958608, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "116.0.0.0", + "browser_version_major_minor": 116.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012358789699345655, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012357896910693418, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01235652815338902, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012350484674832017, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012349718126130671, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012349296354060253, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012348036733054693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012347391232711412, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.012342053279033264, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012341447900103908, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012340825942675635, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012340771001963456, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012339917671434393, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14816.131.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36", + "percent": 0.01233920576422209, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "103.0.0.0", + "browser_version_major_minor": 103.0, + "os": "Chrome OS", + "os_version": "14816.131.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012335841784686304, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01233408405645107, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012318561874675782, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012314439766778837, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012313649589649079, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012312174312196241, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.012311829886816275, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012308095203414422, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012307350776453563, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.012294087504968361, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", + "percent": 0.012292671105823992, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012291981795126838, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012288167580145655, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012287970830058764, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.012286951624210847, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012286130196023627, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012285034941153107, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012283256110807049, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012282774674246669, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012281806221489769, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012270273370230277, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "percent": 0.012268223328676739, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "125.0", + "browser_version_major_minor": 125.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.012267296431229584, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012265003815530213, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012264597035956958, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012262579448602687, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012261329059226953, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012259367473862142, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01225846479080786, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012250327265491145, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.012250158939617836, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012245887620769691, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.01224412234844612, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.012242190858450071, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012241586230763414, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.012241236580459763, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012237880381378772, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012235227556564863, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.01223474409299164, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012231269034614026, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.012225562314534645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012224961459618442, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012223794327108356, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012220973783149151, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.012218734966733763, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01221689889898135, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012212779949237763, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1", + "percent": 0.012212573584365763, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "120.0.6099.119", + "browser_version_major_minor": 120.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012212558045135782, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012208097547030963, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012207754774918715, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01220726158912877, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.012199799709666517, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012199325627184598, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012196867829681893, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01219494141907791, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012186217691537809, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012185839378787807, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.01218574569178183, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01218566888726334, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012184522676226127, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012179957935901506, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012177661697759388, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012174589758846457, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.012174278221106118, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012171284535129502, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012165908136433413, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012165869516080612, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01216529240594277, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012162052237113254, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.012158568313814727, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012155017614945083, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 14_8_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1", + "percent": 0.012154590283327837, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "14.1.2", + "browser_version_major_minor": 14.1, + "os": "iOS", + "os_version": "14.8.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012153784145443455, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.012149929118205671, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012149896667412419, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01214829455966526, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.012145330541305814, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012145113581765957, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012144782236426684, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01214252559533466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.012141827747317858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012141231018586437, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0121358846445874, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012132823026971273, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01213210296477688, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012129468916855709, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012128497482606151, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012128032046241619, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012125513416279751, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012123826199684974, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012120020656793148, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012118094776788046, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.012115386224062172, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012114257225879641, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.012109734082773362, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.012106469623234459, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01210559657610302, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012104988184166847, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012100113990815738, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012098163653862766, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.012094117908486267, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01208772425274064, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012086056647602652, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012084249032481127, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012082189973131742, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "percent": 0.012081958838475845, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012076764588388283, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01207631544698018, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/285.0.570543384 Mobile/15E148 Safari/604.1", + "percent": 0.012072965800173752, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "285.0.570543384", + "browser_version_major_minor": 285.0, + "os": "iOS", + "os_version": "16.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01206917597743996, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012068377832658298, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0120683202150645, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/341.3.692278309 Mobile/15E148 Safari/604.1", + "percent": 0.012067639992692888, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "341.3.692278309", + "browser_version_major_minor": 341.3, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012067610466771277, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012062860926530472, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01206268496357442, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012061060112823572, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012058987251925935, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012058467777467132, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012055092502998513, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012051187697869411, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01204929749604414, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012047468381455385, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.012046756910496656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012044510966776162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.012042673673508418, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012039663389351904, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0120366947748471, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012036604262253286, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01203598405826998, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.012035493968778286, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.012034969617235977, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.012032073324439056, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01202607975109154, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.012025038534414098, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.012023725807504299, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012016901013424085, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.012011162958804425, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.012002619017876456, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.012001098016014575, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011999729605323419, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01199853108661607, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011994760719752537, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011992141172470367, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011992075572679391, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011990320659717111, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0119841411160105, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011980692460111695, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01197879045351911, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011977619302617962, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011975633813346225, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011973464159673182, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.011972812789565164, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01197261962150262, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011970302584016681, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011969369638045187, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011965265342211677, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.01196258211145566, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011959311484855436, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011957670249402915, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "percent": 0.011957451012075335, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Edge Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01194628049859522, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0119459041426599, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011940756978818476, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01194036356787884, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01193851100301647, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.011934950585443057, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011932110417511499, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011931082431665983, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.011925996571909497, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01192481217692417, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011920107582317188, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011919195862238474, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.011916896231218487, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011913323520302879, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011911617436272696, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011911351074895838, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + "percent": 0.011905282526360505, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "26.0", + "browser_version_major_minor": 26.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv8l" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011903080278197455, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011902272497016812, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/95.0.4638.50 Mobile/15E148 Safari/604.1", + "percent": 0.01190160149840391, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "95.0.4638.50", + "browser_version_major_minor": 95.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.011898673651047178, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011893087203709785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011891068551721529, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.011890795089448177, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011890138413215956, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011889526211542632, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01188660125616891, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011883784906566924, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/87.0.4280.77 Mobile/15E148 Safari/604.1", + "percent": 0.011882040661079863, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "87.0.4280.77", + "browser_version_major_minor": 87.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011876714994001735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011872953434788707, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011872102431145146, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011869228256352182, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.011863769785762902, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011862388496176704, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011861360952112977, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011859896046315401, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.011858305100087311, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011854970902396962, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011853828964661153, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01185323638318919, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011852452841960143, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011847360146744394, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011842908553071916, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 YaBrowser/24.10.0.0 Safari/537.36", + "percent": 0.0118422171974564, + "type": "desktop", + "device_brand": null, + "browser": "Yandex Browser", + "browser_version": "24.10.0", + "browser_version_major_minor": 24.1, + "os": "Windows", + "os_version": "10", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011840716057748573, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011840614647394212, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011838199889509586, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0118346043988636, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011830522942250347, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.011829509221382315, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.011828341657446052, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.011828161902466957, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011827731437431606, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/341.3.692278309 Mobile/15E148 Safari/604.1", + "percent": 0.011826228916016522, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "341.3.692278309", + "browser_version_major_minor": 341.3, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.011824335919951814, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "percent": 0.011822747200634936, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "127.0.0.0", + "browser_version_major_minor": 127.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011819828354753462, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011814812126930939, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011814562053396118, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011813495828763043, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011813454597602371, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.01181330343700009, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.011812213487075129, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011812189645409017, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011810853403373785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011809542605380946, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011808501315664752, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.011808076887792228, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011807767583358589, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011807239516037578, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01180447641560522, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "15.8", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01180247182187546, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011799386773666556, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011792326189790009, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01179183512682713, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011791722598871054, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011785292819576496, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011780220607485134, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.011777168984290157, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011777160808994167, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.011775457549531533, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011774236889859416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011771168016097242, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011771008071395458, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01176755310211096, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011765503964320414, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011764931107115273, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011761506477274098, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011757277098047502, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011752673936071026, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011752362134248526, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011751908235449263, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011750701482276707, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011749540182285701, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/117.0.5938.117 Mobile/15E148 Safari/604.1", + "percent": 0.01174746780571975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "117.0.5938.117", + "browser_version_major_minor": 117.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011746366281422609, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011742898143305263, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.011741155390308938, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.01174044245381662, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011736244958698325, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36", + "percent": 0.011734556890325949, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "99.0.4844.51", + "browser_version_major_minor": 99.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.01173372139286198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011732347536710413, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.01173149689167428, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01172853301150366, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01172591679167414, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011723363324036805, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.011722287749370074, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011719442426919732, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.011718329931264253, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011717438233668357, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011716395033947119, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.011716333252407547, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011715930834667291, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.011715590367566073, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01171269229956896, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011712452391903072, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01171225997165538, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0117120180477574, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011710567336937119, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01171007059405667, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01170996525679444, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011708581114307693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011707786358925443, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.011707343957773135, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.011705307588051734, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011698180734190135, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011697099773229245, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011690921785608223, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.011690874313252893, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.011690317237373243, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011690072126584652, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011687486071418651, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011685460963230435, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011681769390132837, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.011678970975496684, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/309.0.616685607 Mobile/15E148 Safari/604.1", + "percent": 0.011678934552571716, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "309.0.616685607", + "browser_version_major_minor": 309.0, + "os": "iOS", + "os_version": "16.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011674140227060373, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011673537658446965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011673024373494103, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011670767464784617, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011668273252136757, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011665889172112828, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.011665150969881468, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011663912453678836, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011662694678431418, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011657435084132467, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.011653034428550382, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01164879579252569, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011647967912210903, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.011647726288490517, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011647109717482711, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.011642312579118026, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01164193653959836, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011640929364573031, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.011639425828460889, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011637953903424975, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011635004504094737, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011629026406101695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011628754145540626, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011628689165050988, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011628184321264131, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0", + "percent": 0.011620919356632169, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "132.0.0.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011620855852674779, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011620771086311182, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011620498181918633, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011620322231520342, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01161694844008115, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011615805857514762, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01161204816673222, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011611634371911017, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011611577799548491, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "percent": 0.011608902025481049, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "120.0.0.0", + "browser_version_major_minor": 120.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011608848769987693, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.011606524180759747, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011604530482440527, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011600882742101937, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011595880795738464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1 OPT/5.1.1", + "percent": 0.011592123913096162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011589301587910267, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011584750640547347, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.011580898441283505, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011580841693449835, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011580713909953144, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011576007435404666, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01157477857657977, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.011574731972710837, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.011573395190270952, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011572658347614602, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011570628129353519, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01156104148424755, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011560857800521464, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01156043950871074, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "percent": 0.011554787236376733, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "120.0.0.0", + "browser_version_major_minor": 120.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011552918432589052, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01154753804519177, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.011545512941247244, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011543208640164747, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.011542558505559533, + "type": "mobile", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "131.0.2903.68", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.011541314961915525, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.011540576366211159, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011540544338011095, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011539103678392063, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/326.0.653331328 Mobile/15E148 Safari/604.1", + "percent": 0.011537846768918938, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "326.0.653331328", + "browser_version_major_minor": 326.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.011536015307997466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.011535523341799369, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011535036364275332, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011534090116405405, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011530398439294962, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011528517728714896, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011527039472251605, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011526576270665735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011525069972330906, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "percent": 0.011524460413026124, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "125.0.0.0", + "browser_version_major_minor": 125.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011524377771893076, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011524348254380428, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011522759802750882, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011517508243924156, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011517495253815456, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.011514501316117304, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1", + "percent": 0.011512903113207702, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "130.0.6723.90", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011501952679512054, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01150171487691999, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011501263802168548, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011500719027585544, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/112.0.5615.46 Mobile/15E148 Safari/604.1", + "percent": 0.011494563148151315, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "112.0.5615.46", + "browser_version_major_minor": 112.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.01149391442209569, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.011490382542993935, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 6.0.1; Z978) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Mobile Safari/537.36", + "percent": 0.011489225352764825, + "type": "mobile", + "device_brand": "ZTE", + "browser": "Chrome Mobile", + "browser_version": "106.0.0.0", + "browser_version_major_minor": 106.0, + "os": "Android", + "os_version": "6.0.1", + "platform": "Linux armv8l" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011487511632777881, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.011486758153692166, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011485140922962997, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.011484314921578215, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011479959300072872, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01147782977488762, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011474673183354933, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01147211126908147, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011470457228243943, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011467994021074625, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011463832052430888, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011463262665224084, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.011461282284656687, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.011460847114267706, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011460686509847015, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.011455596275072484, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01145521138903688, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011454312548744853, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011453706617256984, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.011451392429620217, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011451050640284021, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011448447466900323, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.011447808396481025, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.011439192804907138, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01143526638630296, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011432737971495495, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011426077351376731, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.011421200743511004, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011418758506830835, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01141500312543546, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011411939684201173, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01140792175161289, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011407637594645005, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011406621556867533, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011405459892059707, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011402752097650968, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.011401793173698254, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011397730641791093, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011394560372428338, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.011394101324905816, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011394091304188928, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011392268059732941, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.011391093671685991, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01138878456408705, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011383228752136813, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011381400331190216, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011380158332442958, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01137732813870945, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011375641000787275, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.011375457274505549, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011374029136720651, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011369413764604578, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.011368864957966051, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011362690684777067, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.011361987313732683, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011360674494991303, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01135812258264533, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011357237912700937, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01135456057362132, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011353250684548297, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011348015235481106, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011346831558214091, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011340833479333326, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/114.0.5735.124 Mobile/15E148 Safari/604.1", + "percent": 0.01134030639661435, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "114.0.5735.124", + "browser_version_major_minor": 114.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011335964769351965, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011330364490761751, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "percent": 0.011326030110501316, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "125.0", + "browser_version_major_minor": 125.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "percent": 0.011324769165966649, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "340.3.689937600", + "browser_version_major_minor": 340.3, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011319029034911568, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011316097438613407, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011316057558099722, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01131173008102454, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011311418589860414, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011311251038611563, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011310899155880497, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011304635297329692, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011302886092541202, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.011302219999180916, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011301234754846767, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011299524740604963, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011293909504534055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011290315144975154, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011289062329023094, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011288316862650756, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0112880863376863, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011287105448676575, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011286689809648831, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011286364961024576, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011283978574042376, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/341.3.692278309 Mobile/15E148 Safari/604.1", + "percent": 0.01128233531265117, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "341.3.692278309", + "browser_version_major_minor": 341.3, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011279161518011697, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011272987928059131, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011272937039859035, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.011272785722597259, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011262544855823111, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011259117125106941, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.011258010590579104, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011257217996629224, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011257196869832984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011254314694359596, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011254274773712946, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.4", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011254248210473968, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.011251770055540431, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011250178063392527, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.011248830600985534, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.011245735298414066, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011244899402411433, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15", + "percent": 0.011243540202482685, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6.1", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01124204238570495, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01123829870909893, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01123034136584182, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011228480952094833, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011225745241979886, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011222565196053098, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.011221178483072941, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01121939254935942, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "percent": 0.011218379661203504, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "340.3.689937600", + "browser_version_major_minor": 340.3, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011216180567269885, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.011215000431883356, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.011214008433465218, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011213415314910777, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.01121333302299026, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011210419988576754, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011207913130872665, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "percent": 0.011206264438243529, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "percent": 0.011205875374059586, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.011202018143221266, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011200002416828416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.01119731208197124, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011192233147063097, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011191263528236194, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011188181344783202, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011186192940888535, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011183512532406216, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011182259920199144, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011182077732371919, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011175994795721607, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.011175400022083032, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.011174314540197586, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011173928700298392, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011173280222391495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011166397613478533, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01116632825189159, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011164241074716773, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011161593075669, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011161544372535734, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011159093811498705, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011157611149387568, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "percent": 0.011154794236774613, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.2", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011154096213787401, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011151041977875876, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "percent": 0.01114943134046182, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011146433786345668, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011144614374494967, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011143260279206823, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011142224638686301, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011140527566784658, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.011139455359083816, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011138584387142086, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011132291200991858, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011131184876476907, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01113108484822903, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011130265450105061, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + "percent": 0.011130054047880585, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.011127064576068565, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011125844177610691, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011124608535580314, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011124523108268189, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011124407161148703, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.01112438988735295, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011119516286809702, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01111772949242656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011116961916215818, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011115386163234004, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011112299541636204, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01111190686224347, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011110820558268904, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011109923178534396, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011108439021114549, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.011107427458123684, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01110583824852621, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011102570746347745, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011099994573735889, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011097782659932623, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011097020029956406, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011095746783459226, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011095708237016845, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011094757068801685, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011094017504660578, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011091572801078808, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011087287555937525, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01108706083145437, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011085747856134505, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011083638332945796, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011082601521459418, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.01107986280234518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011078917728731373, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011078685929154501, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011076829206007804, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011070333011651844, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011062558291586025, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011061234000146265, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011059291164188075, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011056133674867996, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01105337800120317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01105217173044248, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.011051963793495198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011051375500228876, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011050808497636599, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011050269240712805, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.011047860688552862, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011045646621437798, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011042119374054705, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.01104159593653406, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011041296612146598, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011034549808845865, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011032766628076564, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011030540241177689, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0110300011298386, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011028060412175179, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.011027775964747959, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011026744894437664, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011024248277198034, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.01102076839330927, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011020451336956177, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011019675955722796, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01101783494575127, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.011014876151759407, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.011014812326848525, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011014008735670558, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.011013079407774637, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.011010258560410652, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.011009828114767495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.011005058083601235, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0 (Edition std-1)", + "percent": 0.01100412142338577, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.011003134296015127, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.011001664133160205, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.011000146074354067, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010999936107151134, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010998202569863575, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010997677268261612, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010997087059803264, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/334.0.674067880 Mobile/15E148 Safari/604.1", + "percent": 0.010996670000036355, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "334.0.674067880", + "browser_version_major_minor": 334.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.010992882186544043, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01099262369491247, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.010988424209252043, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01098504443797541, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "percent": 0.010981581296580369, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "124.0.0.0", + "browser_version_major_minor": 124.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010980233510507736, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01097816668523055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010977104310732313, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.01097687275256773, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010975287851992787, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01097500260374835, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010973003769219095, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.010971668764444758, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0109699979742896, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010966153298363089, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010964827576179063, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010964084679811567, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010963503975377474, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010962123019567485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010958866703062007, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010957182461000638, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010955772411562918, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15 Ddg/15.7", + "percent": 0.010954666533085168, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.5", + "browser_version_major_minor": 16.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010954473179879968, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010950049173441148, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010946480754458811, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010946298625334893, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010946208366917376, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01094557768316577, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010939776966582604, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010939233496643878, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010938955431826533, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0109334098890564, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010929422738998575, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010928920292657799, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010928788216764993, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010926731780755386, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010925039871544123, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010922300302839269, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010922140298823012, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010920153613643629, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010919051540358594, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010918841075839047, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "percent": 0.010906967197416134, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010906632726662705, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010906482471142028, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010905689426426161, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010905338594491514, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010903359613810023, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.010903026479857414, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010902331858931202, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010901314958966393, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.010896821169157314, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010895650926056686, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.010890452909236063, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010884152113579125, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010882264816518685, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010881612188299814, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010880623106549126, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010878305066689829, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010872097050861564, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010866813040232645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010866515127666083, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010864065689071735, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010863336486015368, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010861439470964216, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010861257235131648, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010859924985131985, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01085669609956063, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010856161235442337, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010854922856963831, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01085338490798158, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010853197966875271, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010851739194928746, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.010848849772046084, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "percent": 0.010848804965036157, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.010847159045034274, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010844543489410085, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010844524126445529, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010843287659763116, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010838784176478948, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010838225753532006, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "percent": 0.010837810229593707, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.1", + "browser_version_major_minor": 17.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010837258144999494, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010835131334170413, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010834822468429726, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010834628578036035, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010834583766761129, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010832045811717669, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.010828420427782134, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010826115732132184, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010825269793334919, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.010824861466035274, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010824556237872398, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010822704263669984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010822673292447432, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.010822180553159607, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010819890639754056, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010819415624398396, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010818521810601157, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.010812631352402399, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.010809602600485064, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010803935763125118, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010801198841328352, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010798072351310351, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.010791632379040662, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010790115053143387, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010789791226350053, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01078859229013665, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01078793506048694, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010784157068065068, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010782117325368946, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.01078136034758153, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01078105888365647, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010777154212280867, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010773856675847694, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.010770062462021647, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010770046897662072, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010766090853046575, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010763034657166593, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01076270909511495, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01076027985065535, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01075891045259481, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010750237449831819, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.010749584804768751, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01074893221800069, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01074691601990926, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; SAMSUNG SM-A107F) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/18.0 Chrome/99.0.4844.88 Mobile Safari/537.36", + "percent": 0.010745661138967714, + "type": "mobile", + "device_brand": "Samsung", + "browser": "Samsung Internet", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv8l" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01074514090705511, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010742741458637245, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010740970110976099, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010738173166516101, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.010736535605422096, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010733190353736738, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010732169289185528, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010732157088034938, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010729511649858078, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010729068753977947, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010726399057889536, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010724807353855377, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.010723586707789319, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010723515580371995, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010720534433336223, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010717608968952774, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010717026962723178, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01071349407358786, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.01071240172669827, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.01071043642394804, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010709398897287573, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010705396715772579, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01070405427163657, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010703620979429954, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "percent": 0.01070069929720459, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "116.0.0.0", + "browser_version_major_minor": 116.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010698213887944603, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010698132367727875, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010695546363448749, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010695424024256992, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010693480297966348, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010691841562471186, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.010690198626260642, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010687526632590674, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010682619993329258, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010682509406562755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010681010286043407, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01068093318689288, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01068049948461952, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.01067973079760799, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010677837391420678, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010674850580245393, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010670904626830955, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0106694882842174, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010666084605691031, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/21F90 Twitter for iPhone/10.68.1", + "percent": 0.010666004450346276, + "type": "mobile", + "device_brand": "Apple", + "browser": "Twitter", + "browser_version": "10.68", + "browser_version_major_minor": 10.68, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.010665103159716267, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "percent": 0.010664223796936899, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010662379098731196, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010662244193864015, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010655440448983077, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010654483872217827, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010653617564972826, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0106485241333903, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010647104452835234, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010645825264725064, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01064487435473722, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010643005417933645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010640734540363779, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010636973509655399, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010631098818107683, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.010629397987111959, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.010624836440345332, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010623897852009534, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010623745568794467, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010622614299058248, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010619430596191295, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010619133551580942, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.010618032425626842, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010616451040407681, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010614100521848076, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010613862398022832, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010611920911820684, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010611809450245974, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010611669390840496, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010610138903826463, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.010609430606082418, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.01060852331260787, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01060213065093667, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010601764247980181, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.010599694988981317, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01059793947804595, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010597409720996579, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.010594715107658895, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", + "percent": 0.010592213332213923, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010590706590078916, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01058860542833331, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010585196258434966, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0105846998601724, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010584641319035575, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01058066661064412, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010577167186296536, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010575350986686946, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010573319861946075, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010572348901128995, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010571510499512358, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010571085509908916, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010570532369841085, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010568739246571219, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01056851401781307, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010564714471109406, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010560353527418657, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.010556415499560393, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010553515766710087, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010550312574275024, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010547364495393735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01054291955185806, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010541711360070961, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010539574609153479, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010536081256605555, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010532963515703804, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "percent": 0.010532295643448973, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.2", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010528751930840557, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010527174258135463, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.010526597003620046, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01052487247608214, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010523987860469044, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01052316708459815, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010514027258649165, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010512530134477118, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010512500670057143, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.010505676555459578, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01050492395251001, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010502618728850385, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010501712984590477, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010501496857083343, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.010501069637587075, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010498704181819168, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010493869896172596, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.010490724914416908, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010490180411163392, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010487202149112735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010486294013536006, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.010485267174992867, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010483567341904702, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010481536115620043, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010481219312634302, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.010480623504909192, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010480113097154313, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.010479335333885459, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010478151717685688, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01047506636932654, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010468351431360589, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010467507223168773, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010466051144778877, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010465622323924964, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01046060313142712, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010459909833868462, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010459386157333023, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.010455360213879137, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010453323913383425, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010453090530907093, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010447166897149281, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01044565025677578, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.010443366735944, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010442496638129998, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1", + "percent": 0.010441964880316203, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "123.0.6312.52", + "browser_version_major_minor": 123.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010440899173671154, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Mobile/15E148 Safari/604.1", + "percent": 0.010438750890061643, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.3", + "browser_version_major_minor": 15.3, + "os": "iOS", + "os_version": "15.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01043618794362694, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010434995895432567, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010434975630335297, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01043030897195779, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.010425573000350932, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010425474604671556, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010422326042041382, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.010421255190228995, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010421138095913126, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010421074143872317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01042036169839619, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.010417280143063555, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010416438940420972, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.010414552674932501, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010411604995843754, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010409524573873508, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.010408445221703722, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010407706378937398, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010403926665764389, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010396714016694123, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010396475659067367, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.010392212907605435, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", + "percent": 0.010390648166654673, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.010389673731316953, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010386799503824201, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010386733243859473, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010386123714889521, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010381316283484873, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010380143356493723, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010379040477278195, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.010373703340900679, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010367439122933052, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.010364835242144384, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010362564492694192, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010361351853748294, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.010353086282456542, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010352153905178038, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010352135302755732, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.3 Safari/605.1.15", + "percent": 0.010352096820687518, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "14.1.3", + "browser_version_major_minor": 14.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.010350209703832354, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010348804728353053, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.010348319792631466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010347993585033877, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010347237940348376, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01034677844635015, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010345789708920201, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010345432843764999, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.010341894947851298, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010340616729632225, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/295.0.590048842 Mobile/15E148 Safari/604.1", + "percent": 0.01033946446483838, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "295.0.590048842", + "browser_version_major_minor": 295.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "percent": 0.01033610771179493, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.0", + "browser_version_major_minor": 17.0, + "os": "iOS", + "os_version": "17.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010335710425314792, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010333459206328948, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01033286704376574, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.010331121949567314, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010328027198652577, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010324543844483844, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01032361282243763, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010322624594508008, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010321755748534056, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01032132382980683, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010315550302872512, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01031248279660911, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010312279907543362, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010306856699417666, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0103056302026062, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01029313098537018, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.010293024158964598, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/21G93 Twitter for iPhone/10.68.1", + "percent": 0.010291990224969703, + "type": "mobile", + "device_brand": "Apple", + "browser": "Twitter", + "browser_version": "10.68", + "browser_version_major_minor": 10.68, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010291099961553715, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010287053980031914, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010284800974418645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01027778754417776, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.010277690197654388, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/250.0.505561494 Mobile/15E148 Safari/604.1", + "percent": 0.010276423458468421, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "250.0.505561494", + "browser_version_major_minor": 250.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01027345859846718, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.010273307158147732, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010269388512835338, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01026803523510883, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01026765012859721, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.01026603140890648, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010265993094802733, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010261043418770951, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010260151597332873, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.010258875903206989, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010258785805068305, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010257556392869661, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01025744406662242, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010257148848414582, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010253998524516646, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010252750114213827, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010252421424117378, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01024951390613456, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01024938082661323, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010247875544943283, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010247567811437076, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.010246638599822822, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010244293819334295, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01024327556736984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010241692373085282, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010238274048502475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010238046115463646, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/485.1.0.45.110;FBBV/665337277;FBDV/iPhone17,3;FBMD/iPhone;FBSN/iOS;FBSV/18.1.1;FBSS/3;FBCR/;FBID/phone;FBLC/en_GB;FBOP/80]", + "percent": 0.01023797923572938, + "type": "mobile", + "device_brand": "Apple", + "browser": "Facebook", + "browser_version": "485.1.0", + "browser_version_major_minor": 485.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010237247298861331, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010236617587868082, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.010231541165166851, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.010230488057567464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.010229704777670037, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.01022691906847087, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010222946689252239, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010221792986114997, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01022122690285526, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.010221179828104571, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010221079478120221, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Safari/605.1.15", + "percent": 0.010219228549773203, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010218544272043181, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010218114097146664, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010215256764272073, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010214878564044474, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01021463824645234, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.01021423720082321, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010209777055901113, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", + "percent": 0.010207705955518426, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.2.1", + "browser_version_major_minor": 17.2, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010206614177935691, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010205866120965038, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010205663710045866, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010204505687070282, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.010202994881183196, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010197296566219783, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01019691257334091, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010194021270269964, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010192359002590565, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.010192126216025651, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010191024142975154, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01019014391047464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010186013672240556, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.107 Mobile/15E148 Safari/604.1", + "percent": 0.010185939522319666, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "127.0.6533.107", + "browser_version_major_minor": 127.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010177335233668863, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010173389953400986, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010170830026057736, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010170461013203088, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.010166754475004453, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010164912014299272, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010163828262738176, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010163692229937536, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010163375226274902, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010159149137655093, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.010157394736606252, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010155257593271566, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.010153103496860324, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.010151734097815538, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010150791199646042, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010149875714359893, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010148008550409374, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010145681678764245, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010144884592334041, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010143283506571628, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.010143132636263036, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.01014303719102075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010142607001418637, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010140327205526671, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01013984702436234, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010138972699590626, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010138259921564625, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010138100118219858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010137894919988436, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010137880188870532, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.01013632064991288, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010132883704609333, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010131721741322213, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010130400385961623, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010130132752948277, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010129681532562485, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010128997628482113, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010128758337738243, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010127896177750504, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010126949411417917, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0101267630574961, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010126226416066647, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010125985388953433, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.010123851401291755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.010119683087533284, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.01011959127340852, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010117568362871155, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010117548806533337, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.01011643117349032, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/304.0.607401217 Mobile/15E148 Safari/604.1", + "percent": 0.010115413201315364, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "304.0.607401217", + "browser_version_major_minor": 304.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01011452009915316, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.010108821425088751, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010106170517902902, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010105894623754282, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010104224105458444, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010103493354421534, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010102494920469277, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.010095661933536056, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010094187082483374, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010093644583011372, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.010091547147960983, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.010087433051401808, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010083791151284166, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010081242853287877, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.01008075105795883, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010080289708760763, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.010078175329327178, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010077958819242509, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.01007777482634374, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010077312111425383, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010075181347235156, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010072600024230487, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010069674710364044, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010067485710069175, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010065296748506926, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01006286039143146, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010062677770174291, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010058270065720775, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010052713629809482, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010051386298098495, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01005100398444363, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010048835616904353, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010046245355054748, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010044386414558718, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01004381585025294, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010043196058113721, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.010042777333481892, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.010039818472682855, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.010039532341833024, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010033968825241574, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.010033455747960432, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010032390692723391, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0100294289989799, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010028948496613253, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010027043558718652, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010026066938388738, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010025358869138915, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010025248707493774, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01002155145510518, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.01002137362899896, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.01002011203024071, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.010019765235808564, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.01001667092544407, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.010016638027751604, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010015882344335718, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22B91 [FBAN/FBIOS;FBAV/491.1.0.62.114;FBBV/667691447;FBDV/iPhone15,3;FBMD/iPhone;FBSN/iOS;FBSV/18.1.1;FBSS/3;FBID/phone;FBLC/en_US;FBOP/5;FBRV/669286544;IABMV/1]", + "percent": 0.01001301223311383, + "type": "mobile", + "device_brand": "Apple", + "browser": "Facebook", + "browser_version": "491.1.0", + "browser_version_major_minor": 491.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010011969524643127, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010010665279188617, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.010010512789040074, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.010006950810511731, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010005931910506267, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.010003172835944353, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.01000199375626779, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009999975341045804, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.009999148570031882, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009997381305317679, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009995523439735414, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009993146392286486, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009989921910356183, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009989043679651555, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009985508639624722, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009984319189856369, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009984283545944692, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.009981127628096838, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.009977832907406755, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 9; SM-G955U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Mobile Safari/537.36", + "percent": 0.009974763179201455, + "type": "mobile", + "device_brand": "Samsung", + "browser": "Chrome Mobile", + "browser_version": "91.0.4472.101", + "browser_version_major_minor": 91.0, + "os": "Android", + "os_version": "9", + "platform": "Linux armv8l" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009974426994167644, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009973205308112533, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009972262076394445, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.009969461152526405, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1", + "percent": 0.009967785049558982, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.5", + "browser_version_major_minor": 15.5, + "os": "iOS", + "os_version": "15.5", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009967089646474253, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00996671821373231, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009965684449351819, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1", + "percent": 0.009965192623812163, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.4", + "browser_version_major_minor": 15.4, + "os": "iOS", + "os_version": "14.4", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009961684438587186, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00996142684549312, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009959483104852633, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009958925369994383, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009958897099628449, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009957722602376453, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "percent": 0.009957589822780277, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009957302699027974, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.009952438340121198, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009950286566007945, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009949661622298298, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009949119266514097, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009948399601739258, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009945833980079324, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009943522140250832, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009940691778167856, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009938109805348282, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009937157321939701, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009936893646026856, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009929894520065248, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009929759570628157, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009929347425298916, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.009928394057596475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/71.1.241847734 Mobile/15E148 Safari/605.1", + "percent": 0.00992494325830254, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "71.1.241847734", + "browser_version_major_minor": 71.1, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.009924712788221924, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.009922988341932452, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00992209154304367, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009919886985584736, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009919885581549692, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009916200841140976, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009913190659776881, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "percent": 0.009911148022729561, + "type": "mobile", + "device_brand": "Samsung", + "browser": "Android", + "browser_version": "4.3", + "browser_version_major_minor": 4.3, + "os": "Android", + "os_version": "4.3", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009910799060918709, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00990877530607524, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009908149747725423, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00990632169252934, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009901741307909892, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.00990157315587924, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.009899888516395344, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00989974807085554, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/267.0.537056344 Mobile/15E148 Safari/604.1", + "percent": 0.009898132495671026, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "267.0.537056344", + "browser_version_major_minor": 267.0, + "os": "iOS", + "os_version": "16.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009897957932842252, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009896169966700112, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00989418830955838, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009892739923564478, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.009890865812811267, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "percent": 0.009890693152158233, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00988945277965846, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009888574034325295, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009888186386383264, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009885133225675625, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.009884067793666674, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009881634701753406, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "15.8", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00987980568379244, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009874003807747282, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.009870447372673898, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009869879037646401, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00986957138999551, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009868407069218205, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00986815357367456, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009863234501329218, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", + "percent": 0.009859706909390956, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.00985922177433279, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009857314116307827, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009857005046998034, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009855927808187104, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.009854490345534568, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.00985223885445982, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009848965758731792, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009847414598569332, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009845341743549715, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009843601451805363, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.009842774729327765, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00984084380213361, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00983886590074324, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00983885335304385, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009837589461512005, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009835897397660689, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009835264919367917, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009834487709881603, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.009833314125007563, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0098315370193627, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009831269229294259, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009830309228220091, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.009829789603554295, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009826750704127071, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009823543926902366, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00982105699720899, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.1 Mobile/15E148 Safari/604.1", + "percent": 0.00982032486010103, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.5.1", + "browser_version_major_minor": 16.5, + "os": "iOS", + "os_version": "16.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009819082663044754, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009818162175623913, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009815355020752791, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.00981498237457727, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.009812989939706676, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009812046324805972, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00981022596662168, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009807940140916092, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009807510190473639, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009805323597034914, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009802968617243137, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009802658488460302, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.009801270222300523, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.009790176848179384, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.00978963230606228, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009789038096842116, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00978887003465238, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009788187517638427, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.009785502657878704, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009785395657581904, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0097830703404247, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.00978089108848782, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009779379024262073, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009778880974256108, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009777880094863307, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009777287233868942, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "15.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009775533710394588, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00977407540540886, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009772064982405716, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009771707037880321, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009771526661359945, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009771367901923435, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009770749207903431, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "percent": 0.009769021903847366, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "118.0.0.0", + "browser_version_major_minor": 118.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009767252746844897, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009765617366055425, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009765476799169615, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009763981172518013, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009761691574068107, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36 OPR/86.0.0.0", + "percent": 0.00975949015964587, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Opera Mobile", + "browser_version": "86.0.0", + "browser_version_major_minor": 86.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.00975728528664263, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009757061872961043, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.009757034003344212, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009756121849104087, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009754453038580215, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009753550164606838, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.009752775545501469, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.00975174043377154, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009750613934891638, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009749363262615579, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009748038828156521, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009746761371401918, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009741982723240011, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009740305818902516, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00973839170057218, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009737930345076003, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009735575792566861, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009734574412296908, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009734573862226818, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00973395251705807, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009733094303554232, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.009732114762189397, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.00973163540662537, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.111 Mobile/15E148 Safari/604.1", + "percent": 0.009731255746488952, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "124.0.6367.111", + "browser_version_major_minor": 124.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00972836400441453, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009726679070744652, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009725927817368741, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009725655709712537, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009725326425535879, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.009724606221184377, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009721132127048612, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.009721024050541904, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.009716397389316485, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.009714783306537383, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009713174996446151, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009712950013713188, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009711628681630832, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.009711188673132958, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.00970777614860723, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1", + "percent": 0.00970720320322187, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "130.0.6723.90", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009706645205702484, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009704243597178815, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009703706296386049, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.009703552891190572, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009702378951649703, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.009699932733628314, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.009698864394988543, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009698188259194718, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009697094182421413, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009695222099914726, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00969301321718742, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009692134673728267, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009691545183821633, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.009690535831712414, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00968789451564456, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.009673381768082344, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009672513580744209, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009671446117784047, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.009668239554773129, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009668238062658383, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009664128247144245, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36", + "percent": 0.009660374629444959, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "132.0.0.0", + "browser_version_major_minor": 132.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0096597465518427, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009658335139994046, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009657879238637082, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00965771026722107, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.009655826243345624, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009654190744539841, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36", + "percent": 0.009653364410202332, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "119.0.0.0", + "browser_version_major_minor": 119.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009653198870893926, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009653154007140976, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00965236732486064, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009651572709745745, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009651559331771111, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009645462173708225, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009642943010364042, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009642640491701326, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009641826802898174, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.009639800612751954, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009635644496278352, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009633651069671043, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00963317194897395, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009630014002413138, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009629939703102332, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009627133054067998, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009626653342887316, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.009623549160422426, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009620007053017009, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009616358020157279, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36", + "percent": 0.009615784675353648, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "103.0.0.0", + "browser_version_major_minor": 103.0, + "os": "Mac OS X", + "os_version": "10.11.6", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.009614979641257347, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "percent": 0.009614446859037992, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "121.0.0.0", + "browser_version_major_minor": 121.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009613893236086997, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009613653216846813, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00961253409055461, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009612467507707466, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009610973178277308, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009605261535638237, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00960413117778918, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009602616127305768, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00960024971213522, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.00959594123980022, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009593907520281391, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009593775880403414, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "percent": 0.009591207608432769, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "132.1", + "browser_version_major_minor": 132.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009590146787563633, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009589818603953701, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0095883901789441, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009588277335834521, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.009587463280788654, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009585220311220225, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009584121851796011, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009583991936816675, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009583734294129802, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009582996789343528, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.009578999191696554, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.009577778494830215, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009577322257856988, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009576158492561734, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.009574693837405227, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009574154122390025, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009571641202219322, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009566142554347857, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009565928161044512, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009563916870755263, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009560400446692239, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009559648538271072, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.009559314802695049, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009559254079252767, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009557016738371251, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009555023775535706, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/129.0.6668.69 Mobile/15E148 Safari/604.1", + "percent": 0.009554126699146641, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "129.0.6668.69", + "browser_version_major_minor": 129.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.009553522815662489, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009550423938097945, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009549617815225455, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.009546552625473978, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009546396014066727, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009545808412879837, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009545715270887682, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009543471166977482, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00954324988168776, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009542976148270045, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009542642941862847, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1", + "percent": 0.009542567484323812, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "120.0.6099.119", + "browser_version_major_minor": 120.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "percent": 0.00953891349684581, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.1", + "browser_version_major_minor": 17.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.00953829344874875, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009537367541302457, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0095361784158425, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009536165015275239, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.009535662657931043, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009534512901710437, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.009533628122198717, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009533613313281442, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00953211739347313, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009531959173980075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00952817665124081, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.00952731838903868, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.009525893309221614, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00952480202899014, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009524410877066626, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009523099035210172, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.009519136942161301, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.009517919842572421, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009515656347182728, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009511466111671465, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009510901053014149, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009509502612632368, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.009507537444841297, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00950725484393658, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009506096830532485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.009502612670420064, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00950194194180857, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009501892236891681, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00949936290024916, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.009498943250430558, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009497017533725123, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.00949428804176512, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009492180950744411, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009491948452935694, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009490697439691321, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.009488884180788705, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "125.0.0.0", + "browser_version_major_minor": 125.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009488548514134587, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009486693024348798, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0094848850552055, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009483730729217437, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "percent": 0.009483241089401202, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009482203567131016, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/308.0.615969171 Mobile/15E148 Safari/604.1", + "percent": 0.009481504925415545, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "308.0.615969171", + "browser_version_major_minor": 308.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009480132427940323, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.009477155472351421, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009476028440948516, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00947602517431098, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.009474287085136536, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.8.3", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009473831579982103, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009473705380440232, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.009473110374228738, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009472302333190347, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009472113933832679, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/21G93 Twitter for iPhone/10.68.1", + "percent": 0.009470880012038397, + "type": "mobile", + "device_brand": "Apple", + "browser": "Twitter", + "browser_version": "10.68", + "browser_version_major_minor": 10.68, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009470433861823816, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.009465984947570808, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.009465871692477595, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009462277852196548, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009460246489588967, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009458018153581965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009455868070069498, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00945584684963863, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15", + "percent": 0.009455430272134146, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009454852307785597, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009454045705760425, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009453767162886907, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.009449317722279124, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009439409416342866, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00943885073633817, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00943853689705845, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009435751030998743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009433680864178317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009432909181584983, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00942882314065636, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00942680817657394, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009424571581912764, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009423347942673285, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009420967633766056, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009419959168738794, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009419006378415359, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009417747644081797, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009417711974857444, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009416319897503115, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009412090463337722, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009410676467954515, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00940817615698742, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009407480128838542, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.009406580436261795, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009403984585660366, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.009401643691530553, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009399273499881818, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.009399251112232311, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.009398689100205648, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009396074756623206, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.009395713438050585, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009395611533377198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009394804556161799, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009394364473255057, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 Ddg/18.0", + "percent": 0.009393820831926495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009392673135306739, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009391635575453545, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.009391078142006246, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00938905395838152, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009389018979150149, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.009388669488385999, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009387746113425367, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00938599215298361, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.1 Mobile/15E148 Safari/604.1", + "percent": 0.009382264067053798, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.5.1", + "browser_version_major_minor": 16.5, + "os": "iOS", + "os_version": "16.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.009379632095103249, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009376930617642677, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009374646751136074, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009373348930200854, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009372071960185753, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.009371983223238994, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.009371156445672713, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009371114762578134, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00936649382901421, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009365937147196352, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009363382481083022, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009362177102116648, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009359792441170446, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009357140351633311, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.009354553944831671, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009352987079586896, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00934776231779278, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009347006341687633, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.00934528602856032, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009345131192961632, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.009342331977783051, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009339815843693762, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009339222278905055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009339121446663691, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009338819955736353, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.107 Mobile/15E148 Safari/604.1", + "percent": 0.009338638873965079, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "127.0.6533.107", + "browser_version_major_minor": 127.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009337627608207846, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009336614416765006, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00933281152933561, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009332160128646709, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009331807649901625, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009330696252585457, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009330153524718615, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.009329952080636967, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.009328460679430033, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009327962073260171, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009326792188655855, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00932238949751918, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Mobile Safari/537.36", + "percent": 0.009317110454411972, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "117.0.0.0", + "browser_version_major_minor": 117.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.00931682555564167, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "percent": 0.009315286773162927, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009315157916887895, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00931384552409532, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009313043274364084, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009312943650977666, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.00931065081835242, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.009309943464103817, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009308794771437532, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009295024319184332, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.0.1", + "browser_version_major_minor": 17.0, + "os": "iOS", + "os_version": "17.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009292607558446431, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00928661518189537, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.009286082017694926, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36", + "percent": 0.00928415020349773, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "127.0.0.0", + "browser_version_major_minor": 127.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009283771510503604, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.009280505231748844, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009280450829680197, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009280029455890267, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009279895479175843, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009279864944441926, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0092795345422521, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009279099128968146, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009277958839479341, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009276315992015689, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009275711519901452, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.009272999587008363, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00926967586944888, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009268410978123356, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00926322663649787, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00926288189431861, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009260819782311901, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.009259336520511866, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009257958964106796, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00925703509051182, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009255847073378119, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009255664071112335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.009255378580630615, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009255284832538117, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00925198546710435, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.009249264116677992, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009247691188315062, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.00924739754475305, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "percent": 0.009246492367519378, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.1", + "browser_version_major_minor": 17.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009244305027755055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00924381576130258, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00924227567130662, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009241976136283511, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009240262203749768, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.0092402568014924, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.009239938241271154, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009238845499284333, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.009237242832065739, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.009236969010308167, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00923634467021729, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009233658765104732, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009233536090204681, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00923290144903787, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009232648522029263, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009230103067979596, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009229672173765526, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00922934653571225, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009228830642086785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00922880509764635, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009228319206981988, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.009226695913344879, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.00922524454055801, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00922455621702341, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009223370047208962, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009220733896244155, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009217912918846216, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009215990777183627, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009214880187545012, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009207913862794917, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.009207546859867933, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009206265933611242, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009205752473995653, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.009205210432526345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009203166551257426, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009202538888662402, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009201600182774071, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00919910546606746, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.009198759347510034, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.009198106435751937, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009196837405176975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.00919670169877466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "percent": 0.009195904783356525, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "122.0.0.0", + "browser_version_major_minor": 122.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009194375097038885, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009194000031994089, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009191811607832157, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009187974684046668, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009187764116865999, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009185247391555943, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00918414200679103, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009184058670544134, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009181891118644276, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009181506331314671, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009180692664904264, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009180413827516226, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.009179782520426547, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009178219692036877, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009176555626680055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.009175628444816236, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009175109500550633, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.009171395238506777, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009170316908131986, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009167556602110414, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009163159094632762, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009163032931372652, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009162742259278961, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009162362395798604, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009160836395944639, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.009159587193455887, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009158635371149223, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009152680122670722, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009151261227271081, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.00915123153589889, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009150670280250486, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009149289186356685, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009146422300441645, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009143485030623795, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009140754218714152, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009140052008677834, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009139886428579973, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009139696085433547, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009138773586301833, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.009138450925035688, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009132320062340054, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009132276055110878, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009132003601862556, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009131848878122741, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009129702302219177, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009129385394977436, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00912810813132174, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009127272925639791, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009125480965452345, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009124060618312753, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009122923181775398, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00912164907326614, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0091164228068882, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009116367077111128, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.009113471605897447, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Ubuntu", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.009110180631506894, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009108540972957753, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00910775320405029, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009106888154054341, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0091061840512729, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009098077878985637, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009098074386158173, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009097730501501323, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "percent": 0.009097552733915331, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "116.0.0.0", + "browser_version_major_minor": 116.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009093731798055829, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009093582552401424, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.009091807729775253, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00909161187559574, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00908824730136844, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00908806049228106, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009085724696276297, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00908504381519763, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009083973038486695, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009083880081008465, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.009080258025272835, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009078098972996473, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.009075631740235889, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.009074340488578596, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00907357883838875, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009073453229525085, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.0090733211218741, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009070592875563025, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.009065110209296269, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Whale/3.6.6.2 Mobile Safari/537.36", + "percent": 0.009064908715922625, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Whale", + "browser_version": "3.6.6.2", + "browser_version_major_minor": 3.6, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009061676781078488, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009057081838215271, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.009055874443423045, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.009053323796006109, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009050850271344217, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.009045982303157622, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009044888632271573, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.009043081446733658, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009042586333023794, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00904220670634455, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00904066133397234, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009039054481844142, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009037977133386119, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "percent": 0.009037904650378733, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.009037101540365933, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009037057892173036, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009036618592711477, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009035873524276759, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009031934167784215, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009030692159877427, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009030522115103549, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.009026013659268058, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009024190022896825, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009020774272657732, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009020198104105858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.009019838242783636, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00901897237841216, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009016699296189356, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009016363152548676, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009015936596270771, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.009015407170136288, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.009014989058240453, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009014350109775419, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.0.1", + "browser_version_major_minor": 17.0, + "os": "iOS", + "os_version": "17.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.009014144402716194, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0090130255190306, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00901288212319941, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.00901137854198805, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.009010700207694145, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.009009465799698798, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009009376053922458, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.009008010789969116, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.009007705878851299, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.00900616073473563, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.009001931998389641, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009001448847793409, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.009001084094980965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.008995220602005818, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008995037215538314, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00899491064202538, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00899265733896904, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.008990125574486101, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008988783606601188, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008987017389624935, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008986305558728919, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008985644390308054, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008985373788185235, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "percent": 0.008985022348963692, + "type": "desktop", + "device_brand": null, + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.008984574450598434, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008980716586295762, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008978817287779139, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008978296764623033, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00897821622526743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00897816197396637, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008977869042329731, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008976324202539208, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/338.1.685896509 Mobile/15E148 Safari/604.1", + "percent": 0.008973919303848369, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "338.1.685896509", + "browser_version_major_minor": 338.1, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008969048489103659, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008967609593820647, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008967243957200737, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.008964489406118461, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008963080881073168, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.008962258352139684, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008961132299654681, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008957328220500186, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008956955698146377, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00895648905914269, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008955767014056036, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008952595814800797, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00895173288368317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00894775619329035, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008942409566508358, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008941966710933322, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008941451316580582, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008940435532191536, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 OPX/2.6.1", + "percent": 0.008940225355822782, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008939948132306808, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008939781520235807, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008939558572596106, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008938824293655206, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008932494946914234, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0089264696294862, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008925432313898418, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00891883244902168, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00891838219150389, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008918050598051307, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00891599993812018, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008914149793680945, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.008913091588916367, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.008912108154750778, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008911286451459815, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00891083438147558, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", + "percent": 0.008910086948071558, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.008910038442599293, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008909792746888131, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00890960829832854, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008908382287031015, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008907325052628388, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008906265709171555, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008905725230770458, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008901412925441535, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.008897145468104271, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.008896886875177427, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0088960920740013, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1 Ddg/17.6", + "percent": 0.008895692725783057, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008895245316338838, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00889165872844001, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008891516373714372, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008891151810358858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008890730478005558, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008890185042499223, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.008887537662415984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008885467466771416, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008884545271911062, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008884543410424028, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/307.0.613445279 Mobile/15E148 Safari/604.1", + "percent": 0.00888392915095051, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "307.0.613445279", + "browser_version_major_minor": 307.0, + "os": "iOS", + "os_version": "17.4", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008883099403454501, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "percent": 0.008881866003948215, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "116.0.0.0", + "browser_version_major_minor": 116.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00887802524786543, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008877774708642004, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008876792964808967, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00887353515710149, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008871365042586194, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008871141661814987, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00887093693430237, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/114.0.5735.124 Mobile/15E148 Safari/604.1", + "percent": 0.008870368195039354, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "114.0.5735.124", + "browser_version_major_minor": 114.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0088703587594993, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.008868863781694929, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008866816730137641, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008866503496429885, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/274.0.549390226 Mobile/15E148 Safari/604.1", + "percent": 0.00886632074810825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "274.0.549390226", + "browser_version_major_minor": 274.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008864103348490788, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008863217743621614, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008862207411883746, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008859746295934449, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.008858684127687705, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008856230384562989, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "percent": 0.008852877370724325, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.6", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.008852662325755324, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008852572209788736, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008851101544218169, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0088510703275013, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008849820587411236, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008849760129465348, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.008847923398292672, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008846341404442692, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00884569754580346, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008842279214783998, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008841345744837645, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008840297555800644, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.008839889605942597, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008839687569917707, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.008837306952248734, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008837271495619933, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008836686768349075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008835200131942236, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.008834121189690233, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008830592663390799, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00882889201653063, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008826047788196682, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.00882387612553822, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00882214258037382, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008819228193156465, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Safari/605.1.15", + "percent": 0.00881915624286371, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.6", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00881903916977845, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008816761870229315, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.00881484520864814, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008813623847388596, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008811339932479397, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.008810837370204239, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008810675753932155, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008810569305237175, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008809642628735157, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008808731937162586, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008807206973497447, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008803943111236218, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008803669053269876, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.008803359151370838, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Ubuntu", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008802211197207862, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008801202033167659, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008797623923663813, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008797415849962223, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008795577485422488, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008793694744953964, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00879316077431079, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.008792385792977935, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008791606790893398, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00878898462719991, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008788906692575394, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008788334467730034, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008785178206261622, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008784402685569891, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008779091654401612, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008778957374365848, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008778820358626708, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008776698305810371, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008776139088121207, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.008775695500175635, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008775461037698506, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008774909999551396, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008773615047211308, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.008772302559068338, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0087669807783131, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008765997900285585, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008762941098304498, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008761626455741535, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008760868889504248, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.008754990614817767, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "percent": 0.008754396395002183, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00875418996729678, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008752335479597643, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008750532132615734, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008750111801603642, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.153 Mobile/15E148 Safari/604.1", + "percent": 0.008749426003996816, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "126.0.6478.153", + "browser_version_major_minor": 126.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.008748537450292124, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008746998014877166, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008746844109675056, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008743847605160929, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008743219690295614, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008741526904157233, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008735660293640169, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.008733463341952467, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00873273169135779, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008732686986907828, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00873201360864011, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008731927974674546, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008731752041877833, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008730510973853977, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00872925741432111, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.0087252386258197, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008725091819344746, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008722879190260335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00872229102061952, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008721985373653665, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008721593058114417, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1", + "percent": 0.008721573819802278, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.5", + "browser_version_major_minor": 16.5, + "os": "iOS", + "os_version": "16.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008718421862228463, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008717751900301374, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.008717151962175474, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008716504501088986, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.00871627107441565, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008715470282180327, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008714283072724538, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.00871341962687203, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.008713109824295406, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0087112617965256, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008710496965961101, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00870366624080871, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.008703662326566167, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008703026197629598, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008701969607855308, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008699851098833596, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008699094287629019, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008698373272874352, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008698276310582013, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008697366116546786, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008695831360811064, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-S111DL) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/13.2 Chrome/83.0.4103.106 Mobile Safari/537.36", + "percent": 0.008693943289302394, + "type": "mobile", + "device_brand": "Samsung", + "browser": "Samsung Internet", + "browser_version": "13.2", + "browser_version_major_minor": 13.2, + "os": "Android", + "os_version": "11", + "platform": "Linux armv8l" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008691476883985676, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008690109196701667, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.008689947533985996, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00868991712757868, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.008688075297361566, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008687014219677587, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "percent": 0.008686254910490365, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008685549102647735, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008685017814254957, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36", + "percent": 0.00868463072128449, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "99.0.4844.51", + "browser_version_major_minor": 99.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008684529876999232, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008682849586740506, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.00868279586201699, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008682702782065815, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008680511526183357, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008678962200878059, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00867425171365464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008674070834496908, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.00867084393025356, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008665950499492007, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008665107747485652, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008664764565303221, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008663897498866353, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "percent": 0.008663383360447339, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "118.0.0.0", + "browser_version_major_minor": 118.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008663286075685906, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008662784786828713, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.008660689569460889, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008660557884730446, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008660161253627776, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008658999372245233, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008657008105011299, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008655111903378483, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008654820296726687, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008653152983715098, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008647847002739842, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008647592278566275, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0086470929773306, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008645366339348771, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008642630271030637, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00864117078496159, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008640729899818027, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008640598144103447, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008639736793022898, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008639656805111832, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008639010401238456, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008638598064021792, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008637442852803875, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.008637140056533486, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008633852881112528, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.008633232498309308, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008631183580208401, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008629645988293149, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008627534874294586, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0", + "percent": 0.008627033525024542, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008624214028601456, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008621706446628888, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008621373562888116, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.008619456796960193, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008618379392319514, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008614798692862375, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008613598685992738, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008611994223285473, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008611151101297202, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00860668553468019, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008603812565830022, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008602773662080147, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008599554280098025, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008598955483726587, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008598445710994617, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1", + "percent": 0.008597554694830182, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00859434985321875, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.008592519298311908, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008591732148202197, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008589614042480096, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008588962276809248, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.008586523867299755, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00858473811563023, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008583120928133944, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008582653239014179, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.008581616722554426, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008576727681830893, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.008572051910231446, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008571486298418094, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.008571350274398799, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008571256664024264, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008570589689953554, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008569917527140534, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008567841602347845, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008567441203855651, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008567097214492891, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.008562716099258502, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008562623968654811, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008561266834229975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008559306832407236, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00855921877139822, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/295.0.590048842 Mobile/15E148 Safari/604.1", + "percent": 0.008559047437316824, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "295.0.590048842", + "browser_version_major_minor": 295.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008558179063545942, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008556413649656583, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0085560119098398, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00855416983776133, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008553082303562123, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008551752644131326, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00855163938171598, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008551022591148042, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008550475952893018, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.00854823822187257, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.008547956622386114, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.008545412964668342, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.008542960990425464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008542495597932931, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008538296168651604, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Mobile Safari/537.36", + "percent": 0.008537451970820238, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "111.0.0.0", + "browser_version_major_minor": 111.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.008537390765708853, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00853588830619768, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00853236308330583, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008531947385141575, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008530330474148294, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008530125465598804, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.008529523240099089, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008528961669427466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008528782718175252, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.008524297816528114, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008524070458768254, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008523531066327427, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008523259261767335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.008521830671094426, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008519045371109365, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008517381250872334, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008513741833632066, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008513434832300961, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008512945211491884, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00851275469414672, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.008512216892185133, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008510696518811492, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008510264951958023, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008510128812389762, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008509414208561314, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.008507317663501105, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008507043410327285, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "percent": 0.008506087917344404, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "340.3.689937600", + "browser_version_major_minor": 340.3, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.008504875080229518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.008504706272999926, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00850321117660412, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00849538933610915, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.00848972710709934, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008488927450757348, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008488875908430372, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00848813427333328, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008487733170691965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.00848673905389646, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008484207191828488, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008480738933767152, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008480616748736527, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008478388252116399, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008477408281326267, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.008476582921209357, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008474711774480975, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008473206549909539, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00847191371675328, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008471155427174267, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008471110495442274, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008469060813148235, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.008467246817502308, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00846452798962456, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "percent": 0.008464444735499581, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.0.0", + "browser_version_major_minor": 127.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/133.0 Mobile/15E148 Safari/605.1.15", + "percent": 0.008464284426209936, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.008463238800109128, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", + "percent": 0.00846321930812095, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.2.1", + "browser_version_major_minor": 17.2, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.0084631297461988, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.008459809694665628, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008458097866670854, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.00845708953376807, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008456912435919636, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008455676601900596, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008451635585203628, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.008449953053522004, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008447965035643623, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008447532651255198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.008443029754661104, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.008441473970744474, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008440857188709997, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008440453384318519, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008437455540824187, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008437205569027937, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008436264621221178, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008434592061098974, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/130.0.0.0 Mobile DuckDuckGo/5 Safari/537.36", + "percent": 0.008432935387810942, + "type": "mobile", + "device_brand": "Generic", + "browser": "DuckDuckGo Mobile", + "browser_version": "5", + "browser_version_major_minor": 5.0, + "os": "Android", + "os_version": "13", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008432792347561693, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008432720119492839, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008430919886405725, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0084306654719552, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008429608637321865, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008428315833486051, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008427735681696203, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008427043033811825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008425704795965404, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008425669977614954, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008422323643337882, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008420894787389577, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008420278170275792, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/17.0 Mobile/15E148 Safari/604.1", + "percent": 0.008418338128538495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "131.0.2903.68", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.008417302119330304, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008417298750413034, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008413454956540181, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008411875667207408, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15", + "percent": 0.008411059617744857, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.1.2", + "browser_version_major_minor": 17.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008409660397738977, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.008408217351807763, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008407820410882269, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008404845604188832, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00840440037659503, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008402749449644713, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00840194016619141, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008401656029729062, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00839966844785055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008398911956988758, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008394547046539965, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008394214515596417, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00839277092134711, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008392488590197348, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008385021201070602, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/267.0.537056344 Mobile/15E148 Safari/604.1", + "percent": 0.008384748417870905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "267.0.537056344", + "browser_version_major_minor": 267.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008384694412269408, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008383586444265866, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008382198428167804, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008381849075476091, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008381766443213473, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008381669534247266, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008381402183923558, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008380872891107253, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008379770907007672, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008379748182614723, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008379525836509692, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "percent": 0.00837732847266721, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.008376227594333074, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008374928407188141, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008372961716936973, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00837010504020268, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.008369693070239475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008369244187634418, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.008367283720461288, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.008365107251462674, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.008364194320299605, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008363105648492897, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.008362938009004924, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008361278309997208, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0083594315079661, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.008357296831843405, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.008352778208524925, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008351697974202092, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.008350527040438012, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00834967357879632, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.008348867695886218, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008348685016994426, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008348346001699541, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008348051395141493, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008347733021763616, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008346120873541394, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.008342237685517295, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008341075573081643, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008339549928105068, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008339394179559003, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008337691566992218, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008337151977595271, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008328162797073518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008326788018404568, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008326163550880354, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008322964610136435, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/307.0.613445279 Mobile/15E148 Safari/604.1", + "percent": 0.008322729634637408, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "307.0.613445279", + "browser_version_major_minor": 307.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.008321901719676549, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008319177544869476, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008318150189717845, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "percent": 0.00831689996206486, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "120.0.0.0", + "browser_version_major_minor": 120.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008315641783945902, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008315631989537704, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008314786260044746, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008314447932460636, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00831430614317409, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00831309913151444, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008312146686929726, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00830912794238667, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008305864532999821, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008305417776400344, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008304607564308461, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.5511.1627 Mobile Safari/537.36", + "percent": 0.008304031860463489, + "type": "mobile", + "device_brand": "Samsung", + "browser": "Chrome Mobile", + "browser_version": "55.0.5511.1627", + "browser_version_major_minor": 55.0, + "os": "Android", + "os_version": "5.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.008303594736785595, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008299036678312928, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00829845097689595, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008298000067896997, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.008294443112281719, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008292698465102949, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008291444160449838, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00829127563205012, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00828650901087656, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008276759829891612, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008276372791055494, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008272908757313699, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008271306730821866, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008270504195546409, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00826612965245695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008263122436008267, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.00826122775238736, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv8l" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008259307252152955, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008258603944467932, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008258477356264846, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.008255315077179987, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008255254025941069, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008254987827560432, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008254947099472667, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008253388587119968, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0082516649383183, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008250857253007123, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008248562562469038, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008245841825531456, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "percent": 0.008242523457827625, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "132.1", + "browser_version_major_minor": 132.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.008237881004742353, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008234553420087162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008231186158249984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008230664562075794, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008226770731355203, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.008223341782607365, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008223300812007263, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008223258880314865, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.00822101201303091, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00821959112295381, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008217413923778354, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008215837179002991, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008215456814225499, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008213720461890839, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.00821339389568001, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008213200520350667, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.1 Mobile/15E148 Safari/604.1", + "percent": 0.008212608806950024, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.5.1", + "browser_version_major_minor": 16.5, + "os": "iOS", + "os_version": "16.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008210107515452534, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.008208635147266255, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008208458984470533, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008208426151970486, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008205963481146418, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008205128325687835, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008203363246451809, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008202800536592371, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008201448055805045, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.00820112285706283, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008196217296086496, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008193984630324391, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0081938366533957, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.008193019351871049, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008192756607627336, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.008192386388831682, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "percent": 0.008191175207137818, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "123.0.0.0", + "browser_version_major_minor": 123.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.008191160298511475, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008188413436088568, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008185774949729942, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.008184512421814559, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.00818420114018982, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/341.3.692278309 Mobile/15E148 Safari/604.1", + "percent": 0.0081834880937808, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "341.3.692278309", + "browser_version_major_minor": 341.3, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008183282726742687, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00818300797223742, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00818257073803465, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008182474324321036, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008180656203382633, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008179836850820327, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008179612689370397, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008178957763701686, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.008178379023960241, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008177207682204828, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00817696923337654, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.008176726177032807, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008176585868674863, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00817587765742271, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008175074566121059, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008173376963198923, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008171743890118804, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008171174405324274, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00816954145575289, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008168610232566311, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008168020400033926, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008166030237917458, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0081658524050319, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.008163564574395996, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008162954847964338, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008162698732993147, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008160999277589462, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008160921956514898, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008159681540829269, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008158546287890528, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008156083235457232, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00815324026483326, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008152758976493136, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008152102398582817, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008149118652685644, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00814721141657941, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00814700073377882, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008145758364643558, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.00814258360336471, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00814094661875551, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008140098467541603, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00813949306683602, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008135690260363475, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008133964257522202, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008131406607389254, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008131080795020834, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008129977170079619, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008129425899404488, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008129130332108308, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008126738924488196, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008125711497698973, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00812430664016403, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0081241701928008, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00812239257473844, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008122201372868674, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00812053477725282, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008120000714023442, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008119800906521616, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "15.8", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008119635162152132, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00811882003713564, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.008116589114732882, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008115888053341645, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008115495798277028, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00811513129549167, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008114941255989591, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008114207686631259, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008113347539454478, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00811192099865722, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00811098096366581, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008110951848411487, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008110368103243181, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00811008444585612, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008108702215483838, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008108144918549276, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.008105383002095038, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008104827241300452, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008104788120661007, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008101277544817567, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.008100347990388898, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.008100303650582393, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008097972937788748, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008096982105402885, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008096787451717942, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 OPR/85.0.0.0", + "percent": 0.00809563889770699, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Opera Mobile", + "browser_version": "85.0.0", + "browser_version_major_minor": 85.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008094574834010704, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008094483230938814, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008094379593286686, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00809343108328826, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.008093373610113088, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.008091703929786646, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008090582638794652, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.00809042522742476, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00808991579515747, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008088760944117182, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0080875461028378, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008086629533127608, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008086081943589174, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008084817989088007, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008083260397919966, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008079626756273703, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00807874033501052, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00807672316690162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008076068745485188, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008073866988771949, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008073681925700675, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008072728445540094, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00807257031574453, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36", + "percent": 0.008071614395773542, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "132.0.0.0", + "browser_version_major_minor": 132.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008069989923293353, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008069657342123428, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.008067261706362723, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00806667336075889, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008065458488412263, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.008065176987933733, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00806266546323639, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.008062164282385242, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008061044295909647, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008060761747833276, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.008059133835318687, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/485.1.0.45.110;FBBV/665337277;FBDV/iPhone14,5;FBMD/iPhone;FBSN/iOS;FBSV/17.6.1;FBSS/3;FBCR/;FBID/phone;FBLC/el_GR;FBOP/80]", + "percent": 0.008057616684599842, + "type": "mobile", + "device_brand": "Apple", + "browser": "Facebook", + "browser_version": "485.1.0", + "browser_version_major_minor": 485.1, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008057142792031343, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.00805591623424886, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008054657021295264, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0080526367703799, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008052485204194106, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008052358051208202, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008051203067514643, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008050989989854866, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008050124499787079, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008049718063433435, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008047653177338245, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.008046076706956585, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.1781.1915 Mobile Safari/537.36", + "percent": 0.008043693409175686, + "type": "mobile", + "device_brand": "Google", + "browser": "Chrome Mobile", + "browser_version": "49.0.1781.1915", + "browser_version_major_minor": 49.0, + "os": "Android", + "os_version": "8.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008042583515454756, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008042399935560645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008042237526465743, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008041841685900135, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008041075014892724, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00803990704302419, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008039848224105604, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.008038321530976677, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.008036469460590058, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008035908327769296, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.008035759694553451, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.008034799841628209, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008033383637493943, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008029896117689532, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.008028642662240308, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008027122318139672, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008024401628531195, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.008023714472639533, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008015945578618492, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008015093587188573, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.008012826010988922, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008009781702563123, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008008447395316344, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.008008217959945196, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.00800655252299041, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.008004820519294834, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.7735.1194 Mobile Safari/537.36", + "percent": 0.008004593233414098, + "type": "mobile", + "device_brand": "LG", + "browser": "Chrome Mobile", + "browser_version": "50.0.7735.1194", + "browser_version_major_minor": 50.0, + "os": "Android", + "os_version": "6.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Viewer/98.9.8233.81", + "percent": 0.007999255909354732, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007998226175459327, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.007997769712407841, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007996838716583096, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007995696456770437, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.007994645679679781, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007992156928318894, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007990330352351915, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007987730898370703, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007986865328280827, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.007986693517948968, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007986002223882854, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007985464869776054, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00798271305117531, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007982286510406908, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00798119068473814, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007980365228065065, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007979672711680395, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007977617654112291, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007976060563097487, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007975690653240056, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007975136254197922, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007974010018525742, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007972904654247858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007972533197891152, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007972367488863378, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007972071094045474, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.007964188451269779, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007963126758053782, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007962090155382004, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007961747024041075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007961431494444178, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007961032142240742, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007957817072241897, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00795702859201319, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007954787602390177, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0079542255362914, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007953074097077722, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007951809396974008, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007950452844012482, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007949394676409195, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.00794931346246438, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007949208319726994, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.007948043490935586, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007945123048420377, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007944898798137949, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007944718169109986, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007941300744741139, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007941175373294895, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007937774704144071, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007937704776076995, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007937175896699405, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007936993582517392, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.007934252194122754, + "type": "mobile", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "131.0.2903.68", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.007933625865769959, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007930523432585863, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007927436358951665, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007926486451070204, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007924802169568375, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + "percent": 0.007924738608971723, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "26.0", + "browser_version_major_minor": 26.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007923608100584073, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00792302503922421, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007922447171651545, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007922351515460925, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007921727295595447, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.007921691267940961, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007921020273003704, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007919899971409614, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.007919060901076105, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007915713277636021, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.007915336160466227, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007915008072521138, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007913929309441693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.007911291763808791, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007911199028634222, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007911029613192615, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007911024464427534, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007910683034020068, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007910396768294735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007909362152826572, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007908153804603614, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.007907712290206464, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007907706503522493, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.007907275803907123, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00790704758236454, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007906784046288522, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.0079051055220032, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "percent": 0.007904680736363448, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.007904144101935982, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.007904083276122824, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007903960346373282, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007903960040928042, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007903516104371552, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007903243143528636, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007903228275960536, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007902606028909057, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007900469964865783, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007899236501113465, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007898653805280875, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007897747971024053, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007897697425108449, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00789756774692807, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.00789737009491694, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007896905046308758, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007894562507494847, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007892893327714099, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007891638056533801, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007891064674253328, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.007889505258734804, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007888553153868924, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007887496557516666, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0078871117932951, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007887070119162193, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007884562208889376, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007884455846658677, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007883324502927556, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007881490247624695, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.007881058387499144, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007880522566351276, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.007877688265517963, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007877059655732443, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.007876117593520242, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007876073917644694, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007875463250465568, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", + "percent": 0.007874951715222279, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007874473770848718, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007873910151593238, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007873446179074615, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007871981248591845, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007871081958809843, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007869320319857449, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007869094351853088, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007868491460145673, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007867153385862935, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007863972227746308, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007863425801710561, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007862211400292522, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007862041194615517, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007860919208261498, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007860349793203178, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.007858790104733776, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007858248059428417, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0078544986188517, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007854277917382113, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0", + "percent": 0.007854031021034562, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "131.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007853977800475871, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00785269785055004, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00785170854322412, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007851428618725623, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007850812257939445, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.007850479222041849, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007849504465839019, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007847175937351064, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.007844659767471084, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.007844642393342847, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "percent": 0.007843647399357025, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.6533.72", + "browser_version_major_minor": 127.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007841161913073191, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007840766402614711, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00783588040940312, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007833882985154509, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.007832722062675043, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007832174620262552, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007828017427913949, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007826786132164745, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.00782660954426635, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36", + "percent": 0.007824385301262397, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "110.0.0.0", + "browser_version_major_minor": 110.0, + "os": "Windows", + "os_version": "10", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007824016026789749, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007822077679903642, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "percent": 0.007821702355179639, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00781960303491756, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007819446601849172, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007819384459044367, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0078182105094548, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.00781808201376865, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00781352814554655, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007811410875590531, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007809871353767752, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007808774785167869, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007807560457765148, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/107.0.5304.66 Mobile/15E148 Safari/604.1", + "percent": 0.007807115863984262, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "107.0.5304.66", + "browser_version_major_minor": 107.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.007806685232291874, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007806192220186963, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007806105469351426, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007805423555774769, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/175.0.393249130 Mobile/15E148 Safari/604.1", + "percent": 0.007805042199687749, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "175.0.393249130", + "browser_version_major_minor": 175.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007804618648351959, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007804226552026839, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007803897812773488, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.007802712496198198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007802650371224092, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00780113240995069, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007800887898919894, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00779608378505166, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Android 10; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.007796035789229439, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007795447037527089, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.007794986327837362, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007794816807644191, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007793576308032233, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007793378950934429, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007792751271445395, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0077921974552093475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007792159492433286, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007791103078620711, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007789864895440411, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007788952443771108, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0077889293496885975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.0077889026116417705, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.007788246472796858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007787014991521365, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "percent": 0.0077868709987632765, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007784338948112972, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0077841873533479764, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007781169399402092, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007779628142352916, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007777508899546893, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00777553249639955, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007775094185082302, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007774648085958859, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007773436729736958, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007771061853663936, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007770502565852997, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007769856536326743, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007769827562205323, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007768459116055676, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007766836163517446, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0077642924359858975, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007758441565761909, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007757326614051569, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0077554581236827765, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007755268212450353, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007754636333312109, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00775341822497931, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.007752587829979648, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007752459554171689, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007750773909899861, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007750316378705147, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00774977668267858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007748830412232212, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007745133072738079, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007744277283123441, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007742900775273413, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.00774244147872142, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.00774187435252432, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0077418062065854375, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.007740633228471967, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007739787537982828, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00773939797764124, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00773851456029351, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007737116646902754, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0077357676192422314, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0077355476744456145, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.007730518162563941, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0077297191800528246, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.007728848040232813, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.007724815098948544, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007724508004077753, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007722042666264563, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0077219218442301715, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.77 Mobile/15E148 Safari/604.1", + "percent": 0.007721051366552966, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "127.0.6533.77", + "browser_version_major_minor": 127.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.007719484037098025, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0077189974105168185, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007716517641067647, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007716180048843687, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007715818168237936, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007712634081330014, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0077125872328752745, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007706760768723713, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.007706020227273011, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007703353385489732, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007702128961244112, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007701607894281027, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007701383294053523, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007700784241829135, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007699650369016295, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.007699273302628399, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007698932369091624, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.153 Mobile/15E148 Safari/604.1", + "percent": 0.007696529632805343, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "126.0.6478.153", + "browser_version_major_minor": 126.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00769520631073106, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007694817618140174, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007694103686543694, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0076933181598363735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007691198616545146, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00768957108209816, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0076880118375502755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007684398889423393, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007684248513204133, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007684248209773237, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007684071011117131, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007683920339344473, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007683453178050345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007679773844081723, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007678573542754205, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007676776059168427, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007676507016098492, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007676176426327697, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.007674495949006608, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007674031269867543, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007673739796521271, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0076736939402917695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007673369576702078, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007672273829057517, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", + "percent": 0.007671250502119356, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.2.1", + "browser_version_major_minor": 17.2, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007669812830510515, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.007664722726975283, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00766319243129693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.007662717561226308, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007661273022334272, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.007660986445750376, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007658001283351675, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "percent": 0.00765538072034329, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007654612481461839, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.0076538961422490765, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.007653553784320083, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.007653392276778494, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.007650609232632825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007649468032895826, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007648506884261304, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007646729677466921, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00764633788036785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007645888989612571, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007644045810045947, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.007643843389256017, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007643610329548908, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007641960724331698, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007640828918561492, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00763933905535397, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007639338381169484, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007638839864804909, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007638689558700318, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007638104829402758, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007637986261366589, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007637368451655384, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.007636146204459725, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007635631605621386, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007635512026261796, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0076345189339005375, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007634125798623375, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007633409752388731, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0076314368439078335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007629779427627904, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007629229620438638, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.007628568332438718, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007624787731828153, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", + "percent": 0.007621662237275162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007620619908131423, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.007620059327814982, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007619479980480257, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007618578882983968, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007617815966378927, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007617506468434595, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007615912403579077, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007615190731775017, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.007614772856883863, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.00761345406283678, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0076127741087078276, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.78 Mobile/15E148 Safari/604.1", + "percent": 0.007610631573960646, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "130.0.6723.78", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007610474291982716, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007610282385190424, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007609757710643732, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007609674848347195, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007608487749032973, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007608070558565825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007607847689630161, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0076052011333769855, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007603941340343445, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/274.0.549390226 Mobile/15E148 Safari/604.1", + "percent": 0.007603657912823421, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "274.0.549390226", + "browser_version_major_minor": 274.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.007603173018643808, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007602548609958019, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007602102796665724, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007601914579017075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007600650851898957, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007599429446332554, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.007599191331088981, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007598208704315955, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0075957720572924076, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15", + "percent": 0.007595499781993441, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.5", + "browser_version_major_minor": 16.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0075946226160051155, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.007594379994418022, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.007593236295317191, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007589613964956372, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0075895764894210556, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007586632037459733, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007586247090010268, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007584548925520083, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007583902554117185, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0075813352241693496, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007580776726103697, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007580408262694726, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007580151548229345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007578713727890547, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007577586934902804, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007576130981379841, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007576048029587952, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007574262174931248, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007572490643185645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007571552723728783, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007571002424660523, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0075700213334300905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007568536808202846, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.007564048078579692, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.007563986110126078, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.007562913137722511, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00756213276145702, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007561759786098976, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00756065891110366, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00755967611460557, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007559350899147607, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007559087149446057, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007558323633308358, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007558239645693199, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007553791336227809, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007552333358237, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.007552234996410349, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0075506529925404835, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00755032175290262, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007549695028147097, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007549416632599464, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007548138145645188, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007546205304447039, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007545757976733475, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007545166769233654, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.007542829638094503, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.007541849772586549, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007540995726571964, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007540173984368154, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0075397830889134656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0075386287697173605, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00753846009961477, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "percent": 0.007538378048653393, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "125.0", + "browser_version_major_minor": 125.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007538193000774264, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007536700503785055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007535728547945155, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007534118247050677, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0075336442279443, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.6.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.0075325871232733, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007531272745116526, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007529597736359162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007528561216545882, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007527533813361655, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007522714278161357, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00752198056814845, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007521007568967317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007520923995175644, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00752011958849195, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.0075176054225894445, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007517216964250482, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007515819584989594, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007512474316866261, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007511159481455598, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007508028688310035, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00750662041802047, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0075056139826369305, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007505080541379044, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007503563060704302, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007502601291674236, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007502298253860209, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007502277315856514, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007501337731478506, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.007500072157788852, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007498871761110672, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007496856615451373, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007496153365279928, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007495407743438303, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007494808025003521, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007494181540735536, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.007492713120038783, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007492270450123176, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007491757097221505, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007489548071587392, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.5468.1892 Mobile Safari/537.36", + "percent": 0.00748475255630476, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile", + "browser_version": "51.0.5468.1892", + "browser_version_major_minor": 51.0, + "os": "iOS", + "os_version": "11.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00748474650891072, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00748314447980687, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/122.0.6261.89 Mobile/15E148 Safari/604.1", + "percent": 0.007483082446298616, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "122.0.6261.89", + "browser_version_major_minor": 122.0, + "os": "iOS", + "os_version": "17.4", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007481744246213579, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.007481228839901837, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007480839649362768, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007478292093915324, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00747792174705812, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007477365179306009, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.0074767675859643895, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007476231848952837, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007475960069821233, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 13_1_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007469313264478511, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "13.0.1", + "browser_version_major_minor": 13.0, + "os": "iOS", + "os_version": "13.1.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007468439602351261, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007467705568079769, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007465613982852208, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007463746759209871, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007460397239078364, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007459667564818442, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "percent": 0.007457226843570196, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "127.0.0.0", + "browser_version_major_minor": 127.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36", + "percent": 0.007455119537372333, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "127.0.0.0", + "browser_version_major_minor": 127.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0074532039962026606, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Safari/605.1.15", + "percent": 0.007452448986668237, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.1.1", + "browser_version_major_minor": 17.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007451520867273034, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007449637509330341, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007448314085431923, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007448235889715656, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007447443860249092, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007446722086259218, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.007445954816274272, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007445836038692463, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007445666657538545, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007444154928248771, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0074431808152186085, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007440458944817445, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0074400528133410585, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0074386150427301736, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.007438140329645398, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0074365749230584826, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007434555321057322, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00743382367133129, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.00743241705285989, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.007431737789773361, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007429405815577088, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0074284463984617246, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007427941292583908, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.007427171309176555, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007426516860407335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007424010159468388, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007423253504879157, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007423109378530936, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007422406820716146, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007422191838584192, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007421742727504956, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0074209840527182545, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007420738607274169, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.007420505120557011, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007419877088877807, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007418854987287738, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007416359170256775, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007416256858304668, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007415491826817857, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007415085099481081, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007414806461702883, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007414202699136175, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007413674189618778, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0074110140279092206, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007410551164428172, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.0074087116021593884, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007407927182797924, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0074072346206107715, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007405495940018246, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007404524809847193, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007404190665202407, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00740383966886464, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007403782321847379, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007403764435705497, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007403431089288375, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "15.8", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007403253352970086, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007402934771215677, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0074028428018602915, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007401094697700367, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0073996809892454, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.007399055794725411, + "type": "desktop", + "device_brand": "Apple", + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007397786685394863, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007393155319618981, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007390738697334721, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007389861200431345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.007386349564679204, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.007383430701228014, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0073811589812051315, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007381109481481851, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0073783690346011024, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007376504545428092, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007376016267941156, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007375605887203839, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.007375027605788202, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1", + "percent": 0.007374678264252214, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.5", + "browser_version_major_minor": 15.5, + "os": "iOS", + "os_version": "15.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007374176161723689, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007374096039238146, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0073706443143071775, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007370330669376925, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007370082453185323, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007369559918575963, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007367941582712994, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1", + "percent": 0.007366707582649467, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.5", + "browser_version_major_minor": 15.5, + "os": "iOS", + "os_version": "15.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.007364322274397665, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0073625033098532475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007362410749951095, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007362064343780712, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007360034672266379, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00735837459506567, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007357828911733083, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.00735607385415801, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007354963661382754, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0073527210015225655, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007352320056137744, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0073520377222903955, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007351818054677821, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007348498007370254, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007345984778069012, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.007344219530403581, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007343102404037468, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007342508468025038, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007340735264829197, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007338040646580408, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007337839414270266, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0073365522724611, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007334776964198232, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.007334644307744784, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "percent": 0.007333203757426869, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.00733174671004598, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007329962240361992, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00732906643477284, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/130.0.0.0 Mobile DuckDuckGo/5 Safari/537.36", + "percent": 0.007328523357999866, + "type": "mobile", + "device_brand": "Generic", + "browser": "DuckDuckGo Mobile", + "browser_version": "5", + "browser_version_major_minor": 5.0, + "os": "Android", + "os_version": "14", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007327061621885984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007323220456266781, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007322917320780341, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007322490805530039, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "percent": 0.0073210436355663, + "type": "desktop", + "device_brand": null, + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.0073202618867621, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007320147603510059, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007318560159375612, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.007315836632778224, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007315356790509846, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007314954306720689, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007314895575442884, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007314449647942114, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0073138356236009516, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007313513467209555, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007311771686261506, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0073100088768137455, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007309638278927637, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "percent": 0.007309600925156706, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "109.0.0.0", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007309518862825546, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/331.0.665236494 Mobile/15E148 Safari/604.1", + "percent": 0.0073083573993182145, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "331.0.665236494", + "browser_version_major_minor": 331.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0073069382286397346, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0073053694287861706, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007305288504128977, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007305207150278869, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007304321047107755, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007302754218249247, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0073018910352399624, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00730038887615721, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007299568641014572, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007299497741791113, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0072977788681019085, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007296102498248944, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007295731995443736, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007295595216834109, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007294599162944365, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007294004607582068, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007293318683693223, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.007292711516684282, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007291932076666409, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007291780427865801, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007290958344515001, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "percent": 0.007290601278400577, + "type": "desktop", + "device_brand": null, + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007289238425174122, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15", + "percent": 0.00728870941522787, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007288701000023749, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00728801126564935, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0072874457067781265, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007284767248823162, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007283884105118087, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007283390521581105, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1 Ddg/17.6", + "percent": 0.00728249562719784, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007281881890221531, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007281595405898871, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007280122559143385, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.0072782655590599085, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007276727843925091, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.2", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "percent": 0.007274712753839319, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "127.0.0.0", + "browser_version_major_minor": 127.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.007274256037819053, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007273712315234442, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007273503387959775, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007273202124401839, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007270502823906835, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007270378577319341, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.007270324622887129, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0072682811468496675, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007268267490320829, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.00726749546736894, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007267400767166832, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007265199038710408, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007264956329049647, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007259228197220966, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007259211323715277, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007258418197132931, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007256374466846633, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0072558461299467554, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.007254839325759269, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007254775366153221, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007254639894639748, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007254327313570684, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00725431070662865, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007253277529145664, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0072506206095673395, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007250221651597024, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007249092119288786, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007247553137544668, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007246026623003073, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007243116306426787, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0072402764011790695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007238618781869023, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007237654821980242, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007235881554465156, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0072358291597499555, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007234414084319917, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007234016794730266, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007233612127169434, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00723302352125366, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007231077539015126, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007227875614240157, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0072264226142147395, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007225410531582162, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0072235810285505835, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007223477362738084, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007221002367873379, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007220959803586012, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007220825568447046, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0072203361196603215, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007219472997934934, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.00721911235473182, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00721909550835549, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007215773918217845, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.0072149259717688005, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007212755194724699, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007212524040416556, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007212509514836826, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0072120950025433275, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007210625878941744, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007210479194261268, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "percent": 0.007209265811818725, + "type": "desktop", + "device_brand": null, + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.007208277150668081, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007207673667534711, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007207608332366877, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007206964516985348, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007205746581117772, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007204514068348641, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007201388053139536, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007200380100729461, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007199474098007501, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007196106969749875, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007196005615380543, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.007195800844665464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007194498995355596, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007193766544182223, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0071921651742404905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007191798491098385, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.007191359444373847, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007190913776433331, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007190174033396483, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007190051049045347, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.0071887453005266165, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007188436754139344, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007186818621153397, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007185417570658029, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007184847203653694, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007182539467592253, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007182161899158679, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007181043738101442, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.007180227672345519, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007178746549437169, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.007177897469797717, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007177218113746116, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.007175808400399056, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.007174906377918535, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.007173973691747554, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007172833142953011, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007171949514556498, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007169938384109412, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.007169365347925863, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0071687844075708235, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007168189375669781, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007167042611087806, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007166553258797424, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007165865455247507, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007165404424857006, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007163632920988634, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0071614750065012835, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.007159578416332372, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007159172107244939, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007158958099924338, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007158729926221533, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.007157738927424108, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "percent": 0.007156485317161492, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "121.0.0.0", + "browser_version_major_minor": 121.0, + "os": "Windows", + "os_version": "10", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007156339369429196, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007155731184529196, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007155249090858199, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00715480670259186, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007154618216401617, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007153322553883718, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007152655525714039, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00715170263536915, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007151054678028366, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007150948868385034, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0071506426629209235, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007150272071308364, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.007149681101913443, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007146683446297621, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.007146594928216696, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "percent": 0.007144165436298336, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "118.0.0.0", + "browser_version_major_minor": 118.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007142588146645876, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.007141923748047549, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007140659537241267, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007140463179365041, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007140370868800198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007138118311319579, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007137501319357125, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007136407412037614, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007134658785154638, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007134102657665056, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007130500292089619, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007129440111695696, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007127481556058832, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007124682335472019, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0071213515778935695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0071212212475813, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.007120403728367329, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0071187567855914725, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007118368209856219, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.0071170647645195815, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007116722824037943, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007116380437051014, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00711571339003558, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.007114167728617986, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007114076879536809, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007112769181005072, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0071123367147640825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007110306257586022, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.107 Mobile/15E148 Safari/604.1", + "percent": 0.0071081233343712295, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "127.0.6533.107", + "browser_version_major_minor": 127.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/338.1.685896509 Mobile/15E148 Safari/604.1", + "percent": 0.0071078990913429774, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "338.1.685896509", + "browser_version_major_minor": 338.1, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007107144571529673, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007106991035731409, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0071058019262113335, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0071047869331091335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.007104371769847023, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.007104371030975709, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.8.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007102588110837068, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007102279715443066, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007101468124198531, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007101320887472965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007101130546582501, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007099301894109383, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00709898976528108, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00709755984532286, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.007094919923606262, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.007093754814555281, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00709357570870025, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.007092683766017723, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007092450581419258, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007091571358374285, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007091147537963066, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007090964646164735, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007090126841954647, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0070899554259827, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0070895725686344665, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007089306277547191, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1", + "percent": 0.00708907279725565, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.007088953929020691, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.0070887270148336946, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00708843862444761, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007088427433613975, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007086820414847942, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007085883247819974, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007084412674365428, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007084124718186022, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007083566764601773, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007082971534857245, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007082514309037538, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00708200740994851, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.007081361073541952, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6.1", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007079116769952301, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007077709875396093, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0070773985542280414, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007076490648339712, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007076125975576031, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007075227494917258, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007075041294642615, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0070739759198972215, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007072926609184846, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Safari/605.1.15", + "percent": 0.007072301948445105, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.007072216538484306, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007071957124372593, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0070707893573753065, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00706978811005004, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007069502366220006, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0070694580465601635, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007069088599456059, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007069069623490756, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.007068169361690653, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007067999579019451, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007067781947665003, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007066515976054107, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007065725907018591, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007065465900157305, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007064913006220235, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007062562840945542, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007061446071824334, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007058087178816437, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007057148128896789, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007055897916295443, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007054309176494725, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.007052925053615523, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007052168385615085, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007051524872656429, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "percent": 0.0070515166233673515, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "124.0.0.0", + "browser_version_major_minor": 124.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00705010233024901, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007049839350552846, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.007048541359588039, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00704755284745588, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0070469009037663, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.007045125247602718, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "percent": 0.007044557229020751, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007044207200376814, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0070431457962139, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0070425969764555795, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00704174966349003, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007040250812809866, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007037364809908756, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007035222982834994, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007035089154269691, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.0070326184396109345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007031805236326991, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007029962461953201, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "percent": 0.007029681599154283, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007029628089812647, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007029544497076661, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007028936786497527, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007027563935174799, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007026994022144107, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.007025267155988183, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007023622283013281, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007022613864345836, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00702195384169862, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.0070212844452767825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "percent": 0.007020565265949218, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.007018305912303543, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0070160337402021275, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.0070152108798589565, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007014660788594138, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15", + "percent": 0.007013443230840276, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007013100810183005, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0070128291975914345, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0070114735273713565, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007011342145069176, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.007010074078227707, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0070072842290280195, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.007007137315981419, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.007006252913446199, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.007005881411305865, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007005838617235597, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00700467855425385, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.007004611367857231, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007004037587004286, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007003785361032157, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.007003642555441263, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.007003103677624151, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.007001893895110399, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.007001178767498246, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0070009054898853024, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.007000525234000056, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.007000187366251412, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006999896864995311, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006998944412222208, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006997189196566648, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0069961177532677585, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006994541933594567, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006993873962899192, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0069927664120975576, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.006992142748792921, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00699168883376784, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006991384828401889, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006988868558071777, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.006987720350750643, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006985885404252506, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006985661749970857, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006981453743785678, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006981412145082651, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.0069807197921550245, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006978695114926967, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006978338627832519, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006978093263975499, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006977053381915857, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006976035158187262, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.006975761187544473, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006970069128328077, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006967657080919638, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006967647450008397, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.006967356029066987, + "type": "tablet", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "131.0.2903.68", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006966880146630576, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006966679839269222, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006964437359639063, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006963988799512169, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006962878342889676, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.006962458167636443, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006962246910477463, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0069606689556156245, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006960405053759987, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006959919377491719, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006959702688641057, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0069581268266665905, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006957854345381766, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0069560122072889075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006955504638063316, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006953041527575834, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006950993683087113, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00694964312623673, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006949536474692316, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 11; EC211001) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Mobile Safari/537.36", + "percent": 0.006948034243378901, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "92.0.4515.131", + "browser_version_major_minor": 92.0, + "os": "Android", + "os_version": "11", + "platform": "Linux armv8l" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.006947655194214111, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006947633662226597, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.006946923760323278, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "percent": 0.006944465369698143, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "124.0.0.0", + "browser_version_major_minor": 124.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00694386989070158, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006943110417906987, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006942120689151267, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006940984452552616, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006939806433991327, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006938456983936353, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006935523937075709, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006927866015609905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006927039844045803, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006926500973776362, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006926387734495853, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006925678370411584, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.006925360742916382, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.006923531682065927, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006923481779486689, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006920509838535594, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006920233295416574, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006917602304538404, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006913997863572222, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006913802712141261, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006912013980752374, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006911849925086917, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.006911450885342715, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0069107040138844085, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/133.0 Mobile/15E148 Safari/605.1.15", + "percent": 0.006910445624113431, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006907790745151569, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006907610547755044, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006907599797524755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006902150494928691, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 Ddg/18.0", + "percent": 0.00690163783428347, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.006895519790356583, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006895470227432129, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0068951150399002865, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00689318215954357, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006893041147842479, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.006893024311974702, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6.1", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1", + "percent": 0.006891858341957214, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.4", + "browser_version_major_minor": 16.4, + "os": "iOS", + "os_version": "16.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0068912216013329785, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Mobile Safari/537.36", + "percent": 0.006890493545006191, + "type": "mobile", + "device_brand": "LG", + "browser": "Chrome Mobile", + "browser_version": "89.0.4389.82", + "browser_version_major_minor": 89.0, + "os": "Android", + "os_version": "6.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006889789611002763, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00688832312520706, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006887632362372218, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006884085466910284, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.006883897446563886, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.006881222802682035, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1", + "percent": 0.006880986801566399, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0068808428533010425, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15", + "percent": 0.006880359720556198, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006880084818620979, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0068794263876407165, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.006874465492913652, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006874050755434711, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1", + "percent": 0.006873732845800571, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "120.0.6099.119", + "browser_version_major_minor": 120.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006873272003183016, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006871051941156044, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006870576358321192, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006870470291404361, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.0068698938452472465, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006869556166724857, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006869207030251575, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.00686721271816093, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006867132360261985, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006863576606009933, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006862366547553728, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "percent": 0.006861426277076924, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006860848407719948, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006858612284842308, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006858023676255701, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.006857415767192284, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006856845102397491, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006856083337229169, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006855672970096453, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0068545054866292416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006853613637236518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006853157226669485, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006852485743943805, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "percent": 0.00685229866885451, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0068519250401888315, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.006851670326396375, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006851579933638592, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006850846919485057, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006850286568560995, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006849982772317576, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006848571509343297, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.006846836437799454, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.006845368712631225, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0068446005930899514, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.006841486665658839, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00683797093352624, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006837298161747909, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0068362472115737665, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.006834685555861155, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006833670526435647, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.006832051290756243, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006831773439834721, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/133.0 Mobile/15E148 Safari/605.1.15", + "percent": 0.006828760702811485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006828448563511454, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.006828055075005493, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006827673980826948, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006827306366959608, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006826653401100597, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006824378359713636, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006822685144683191, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006819499766802143, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006818841083757294, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0068186418809954, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006818474184560176, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006817710472284291, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006816702168525748, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006815438149244111, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0068144672542230795, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00681429662952295, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006814109696801091, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006810824552121201, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.006810703586801779, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.1 Mobile/15E148 Safari/604.1", + "percent": 0.006810001111119992, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.5.1", + "browser_version_major_minor": 16.5, + "os": "iOS", + "os_version": "16.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0068087105514337476, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006807676414032572, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00680669735725113, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.006806320581218708, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006804917322262796, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00680465107798726, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006802087287755777, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006801925107503672, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0067982891973322095, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006797707134469254, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.006796591331291293, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.0067965824173774735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006796372231233993, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006795266961953512, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0067948982128654195, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006793730242091304, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/339.0.686111475 Mobile/15E148 Safari/604.1", + "percent": 0.00679325322600658, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "339.0.686111475", + "browser_version_major_minor": 339.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006793220321058303, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006792106879930121, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006791547131108813, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006791143131130563, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006791095702953406, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006789576192969183, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006789095027822593, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006788968214352114, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006788308950525411, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006787621722823467, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006786454045307445, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006785426971927381, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.0067849704245962076, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006783108279912606, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006781591439352948, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0067809004675347916, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00678008874533693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006779836431019702, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.006778035794320721, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006777962840662842, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006777051656999085, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00677621644805775, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006775581251385492, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006773869261899609, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006773324016331783, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0067730585081769695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.006771437267527124, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.006771264955565773, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006769991683514283, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006769770234635688, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006768012036030605, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006767065366126104, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006765661187598809, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0067643355598738485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0067639884604047806, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0067628654703960735, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0067625178957047465, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00676222640455721, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006761649207931999, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00676151522763405, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.006760901672211616, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006758329207770532, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006756016729227691, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.006755611210594315, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0067548871322931905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15", + "percent": 0.006754070269514395, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.5", + "browser_version_major_minor": 16.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.77 Mobile/15E148 Safari/604.1", + "percent": 0.006752018343345207, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "127.0.6533.77", + "browser_version_major_minor": 127.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006751545047966816, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Safari/605.1.15", + "percent": 0.006745519518717137, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.6", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.006744329362426875, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006743364854821244, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006743104599587719, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0067427878241954915, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006741508197701673, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006740297204444044, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006738974773986498, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006737155459189426, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006736402109045657, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0067362157825068495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006735868533932163, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.00673539371987469, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0067351222393623655, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006730112802650244, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006730000600898609, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0067294932241106584, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006728725450452659, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006728431953909613, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00672758454781885, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0067266668902988885, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006725637349417777, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006725403735015357, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.006724560688210181, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00672383981361095, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.006723403210805182, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.006722976068816567, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0067224067325368664, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006721212019747694, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006720101742998098, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.006719964756682869, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006719899570329132, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006719288913085858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006716315204744403, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006715945702865029, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006715237451432133, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006714760319469803, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006714136612764009, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0067098559994448435, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006708921063015292, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006708282459101019, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006708148721243944, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0", + "percent": 0.006707484692272144, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "115.0", + "browser_version_major_minor": 115.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006706380356527086, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006705682798085336, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/302.0.603406840 Mobile/15E148 Safari/604.1", + "percent": 0.006703736733742246, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "302.0.603406840", + "browser_version_major_minor": 302.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006701988119797086, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00670171448997716, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.006701046510613114, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006700544838194974, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36", + "percent": 0.006700512137443001, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "123.0.0.0", + "browser_version_major_minor": 123.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.117 Safari/537.36", + "percent": 0.006699578525754876, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.6723.117", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006699283497302052, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.006698403747560496, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.006695978889351439, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006695968101065313, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006695699787955631, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006695100819232128, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006694318819956996, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006693425955471265, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006691777315302909, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006690943696700916, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006690510506810265, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0066853696414479885, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0066850782306591035, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006684761077685236, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006684231805752399, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.0066825306297225, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006681981447644097, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006681635282741166, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006680767822418063, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006680666317046499, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006679697044808855, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006678802673861401, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006676931924360232, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006676509829476064, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_7_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.006674723911492322, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.7.8", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 12; RMX3363 Build/RKQ1.210503.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/130.0.6723.107 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/485.2.0.68.111;]", + "percent": 0.006673199487308915, + "type": "mobile", + "device_brand": "Oppo", + "browser": "Facebook", + "browser_version": "485.2.0", + "browser_version_major_minor": 485.2, + "os": "Android", + "os_version": "12", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.006672982165959278, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006670919833767866, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.00667001778783901, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.006668719615852948, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.006668653631227906, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006667064501117941, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.006664889981397729, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.0066632831389156055, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006662494097587803, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006662088739956603, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.37 Mobile/15E148 Safari/604.1", + "percent": 0.006662075083245072, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "130.0.6723.37", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00666135547672321, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0066599268391297475, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.0066594737712180815, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006659138231260364, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006659003777254891, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0066589266276585145, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006656790556076627, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006656136485809853, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006655854422829684, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0066548673544687634, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.006653490212174494, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006653433743869451, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006652906078090895, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006652852536245068, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.006652727669976967, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006652619281035534, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.006651816193975742, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006651389444267043, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006651004002959861, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00665087659933253, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006650770801320143, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006649571458036952, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006648871596250045, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0066487644700442845, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006648098392439451, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006645634302877947, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0066447511632785235, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0066418936101272546, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.0066411113425261, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0066389242306770695, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006637054679758614, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006636856134205429, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006636464429051999, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.0066334756795416085, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.006633454916758168, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006633263870505122, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006632761539723917, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006631989149117052, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006631716506043157, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006630694117735821, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.006629843922021359, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006629429383733599, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006629129866588854, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006628359741337073, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0066274707380867175, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0066260927076609456, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.006626014191076768, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006624688515049318, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0066245765352205355, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.006624331837510752, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006624324384445844, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006623037330732987, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006619505342548692, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006616467350915472, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.006616258888365113, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006614739901131425, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006614334063231276, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006613998023353836, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0066135666457326735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.006611086931906974, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006611052883900652, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.006611036915140783, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", + "percent": 0.006610393031982854, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006608710898052277, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.006608436030739423, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00660836088541572, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00660795142667734, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006607918787514779, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006607697645525875, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00660750759028041, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006606417920941365, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006604459732819062, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006603705974609473, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006602809691554281, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.006602212174523426, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0066016677348276305, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006601180012642982, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006600955382512515, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006600014126856357, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.006599833327833456, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006599276932315551, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.153 Mobile/15E148 Safari/604.1", + "percent": 0.006598852191986585, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "126.0.6478.153", + "browser_version_major_minor": 126.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006598369467007886, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006598000649289772, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006597027278132516, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.006595180700352929, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006594344198927781, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "percent": 0.006592898802524374, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Edge Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0065919749900835755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0065919408478895825, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.006590797889626575, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006587364367791833, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006587201519360129, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006586395625302582, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006586081371507093, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006585984585185615, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0065849262430192025, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006584180402520249, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006583953435396751, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006583574181411451, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006581321563455067, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006581311100802004, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006578075107840441, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.006575310200367836, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006573813473377833, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006572149808523895, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0065709168808315436, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.006570180628067352, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006570095836532329, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006569984557720433, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.00656955912459448, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006566960806995491, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006566388472458981, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006566239758763444, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006566167095952951, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0065661495545909975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006565175619917114, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006564493451095917, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006564393224235984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006563683552087557, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006563566144986244, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006563271329435812, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006562765496538321, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.006562621675561008, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006561265078857698, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006559017761749389, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.006558980797714236, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Safari/605.1.15", + "percent": 0.0065588829811040065, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.6", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.006558831265932294, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006558162817929026, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/221.0.461030601 Mobile/15E148 Safari/604.1", + "percent": 0.006558156135418077, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "221.0.461030601", + "browser_version_major_minor": 221.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006557862946345808, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00655668557865283, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006555079024805038, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006553786004934775, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006553440122684374, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006552887972018646, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006552461043385297, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006552024960574117, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.006551948090645502, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006548867604588428, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006547861386626443, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.00654676320192956, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006544425131735353, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0065444043276752396, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.006543211650075458, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006539153450961792, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006538884096468643, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0065386829759400186, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006538652002545362, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.006538250836167304, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00653779927974269, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006533381394832486, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006530832098008731, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006529473234500025, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006527434345627839, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.006525757562169175, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006525541675043273, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006524581055010222, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006523748833468156, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.1 Mobile/15E148 Safari/604.1", + "percent": 0.006518714426594494, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.1", + "browser_version_major_minor": 15.1, + "os": "iOS", + "os_version": "15.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006517408212997404, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006516405742712862, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0065158543491877574, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006515328986603811, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "percent": 0.006512950778827398, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.2", + "browser_version_major_minor": 17.2, + "os": "iOS", + "os_version": "17.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006512598043682011, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006511094607012788, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006510688214011537, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006510344534506607, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006508687300396301, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006506890798785113, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.006506737538898591, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006506298294049145, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006505380584664969, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006502500422118966, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006500979698127237, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006500727180775211, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.006500235844613612, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.0064990361516593705, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006496947414047762, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006495663619511726, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006495071377191791, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006494171474570548, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0064930997975213706, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006492039174591939, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.006491989771823609, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006491424027223319, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006488742970545254, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006485623052285165, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006484149977997593, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006483075968429237, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006483057588061055, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.006481378192587993, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00648108050760725, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006480863398808516, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.0064800508861322355, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00647972930793226, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.006479286583783026, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006478628922926391, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006478535537156476, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0064776504682631615, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006475997323961006, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006473636528848649, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.006471150112401568, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006470552833045756, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006469986439099656, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006469473331887163, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006469026680998741, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006467630078389481, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006467437436259028, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006466187109252593, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.006466186989741703, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "percent": 0.006466102244143138, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "124.0.0.0", + "browser_version_major_minor": 124.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006464295444262743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006464159144062704, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006464137670448414, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006463743480277105, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006463576330018798, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.006462051272523212, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006461219302768965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006459730463366687, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0064580458045296555, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006457397984231181, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006456354082385697, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006455879221896925, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006455141042710411, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.006453884053947461, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006452553647508161, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006452290894163612, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006451667439822425, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006450360863985819, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006449920892654912, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 12_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/92.0.4515.90 Mobile/15E148 Safari/604.1", + "percent": 0.006448799425219942, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "92.0.4515.90", + "browser_version_major_minor": 92.0, + "os": "iOS", + "os_version": "12.5", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006448229340388557, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006446915757698482, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00644556742951651, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006444771279730079, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.006444615926531785, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006443265199064008, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.006442167790450819, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0064418625371919225, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006441497394391226, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0064412352396478485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.006440983917928283, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006440780749318557, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0064399843088770795, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006439549257319002, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0064390893477564715, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006438943873267344, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006437841021398317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0", + "percent": 0.006437780716668629, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006437729694402083, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006436665602662167, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00643656077224619, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006434663764548635, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0064339894498489664, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006432165383177969, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0064321402043134, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006431045341722858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006427927873498239, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0064277955793721146, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006427470022229241, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00642728865503193, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006424133361581843, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0064239981202475135, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006423852287683392, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00642355109645031, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006419779784072238, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.00641819919013162, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006417937243287278, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006417409842352879, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0064170883163201615, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "percent": 0.006416658728460463, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006416036779458248, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00641525314753561, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006413921507819072, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006413769398475893, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0064129813062215315, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0064129535891962125, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006412025426912483, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006411207146754587, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0064104605636321295, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006410045012606943, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.006409490812548827, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006409455736224066, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006407765819589655, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006407527976378848, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006407303236786231, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.006407081035111921, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006406643561919201, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006406129244170978, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006405086824044135, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006404989348573859, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006404779685430867, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00640268438493308, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006402410524417524, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006402209344840783, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.006401557350845058, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0064002768050430234, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006399124709066785, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006398955554302418, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.006397419063799675, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006397401636244698, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.006394993759991358, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006393544567143002, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006393489964280348, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006391630447191293, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.00639082720718896, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0063889103661666094, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0063885698811152365, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006388039951015164, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006386901083577437, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006386424769740227, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.006385505237448593, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006385116474877579, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.00638494426866988, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006384590890219439, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006383820204359795, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0063835153399498315, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006382505205196394, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006382307515308916, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0063816702898222485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006380226174290085, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "percent": 0.00637989399579163, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006375490732554431, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.006374935019671902, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006374881479637408, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006373560212311349, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006371544925993044, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006370025622275632, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "percent": 0.006368830143485508, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.2", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00636755510468604, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0063652830068621335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.006365130539900258, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006365121920313303, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006363816376277866, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006362936872496753, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006360597040823877, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006359055686933382, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006358801244745906, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006358285126013324, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.00635797401865189, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006357910652518643, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006357661966668717, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.0063575089235574506, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0063569670155839645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006355255609558581, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006352479769177597, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.006350226879164156, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006348254313831571, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006347937422553869, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006347638569326548, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006347465723875509, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006347300386632264, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006347214967097274, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006346947625096396, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.0063464105753863525, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006345597105266654, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006345127434694761, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006344981433750831, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006344396449908641, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006343577657905345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006340773090534124, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006340672701929727, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006340339856027641, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0063400153396657565, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.006339928192347919, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006338859578671181, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006337794930510884, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006337688100927404, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006336307557631946, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006336124237722189, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006335314917722961, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006333301486922603, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006331188648800676, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.006327060156565612, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00632642719239699, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006325327100251029, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006324772943713481, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.006324042483540914, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006321882353088244, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006319177995431706, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006317690045621283, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006317496860897309, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006315910867109554, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006315364462614935, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006314694451856882, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/339.0.686111475 Mobile/15E148 Safari/604.1", + "percent": 0.006314172524083273, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "339.0.686111475", + "browser_version_major_minor": 339.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.006313722495491962, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0063117227183955025, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006310694459653744, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006310442971549416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006309176721291407, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00630848545810015, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006307849212367748, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006304638241352041, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006303578977733645, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006303352773359995, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006303025149396147, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006301056941947859, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006300591937772125, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006300114552741106, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006297819658500208, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00629692040646172, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006295091617898618, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006294982557657564, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006293630371951709, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0062925933201468925, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006292456581717902, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00629120041089215, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.006290098764091829, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.8.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006289439503724315, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006289180145794018, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0062865415300843224, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006286033661654879, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006284871444464142, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.006284233858244708, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006283367007853374, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006283270747767131, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.006282477906794114, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006281162234147427, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.00628008652896878, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006278897852971946, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006278209449727987, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006277631736964758, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006277248877917929, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00627585286274012, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006275810570478206, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006274274732085984, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006270943016088267, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006270469314779908, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.006270357864373566, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006270193517710744, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006268236557691488, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.0062675040590565055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0062656114700879685, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0062624796461239664, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00626222237588651, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006261208463040735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006260580353366133, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006260464085299671, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006258696779017163, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0062586512276468585, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006257587872090721, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36", + "percent": 0.006257545801204091, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "112.0.0.0", + "browser_version_major_minor": 112.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.00625704681050634, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006255994204386673, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0062551471875706725, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0062550480276568005, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006254883760241164, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006254614131507459, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006253147064601794, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00625150677631179, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.50 Mobile/15E148 Safari/604.1", + "percent": 0.0062511038864681515, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "120.0.6099.50", + "browser_version_major_minor": 120.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.006251098401250647, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006247255704185685, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00624324001152183, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006242705404068937, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006242617753819836, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006242183385833433, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006240649572665013, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006240646457871187, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0062400155968403105, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006239218812789423, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006237264218616212, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.00623522937203996, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006233112558893598, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.006232832884805079, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006231424007982347, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006230478894805492, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.0062289398545038715, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00622703916569628, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006226725051494034, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006226444610347848, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006226048391656065, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006225885522110304, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006225607010316348, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006224314355705975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006224280776307878, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006221985926961385, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006220098374407022, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006219305974101712, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006218568252600636, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006216650520703127, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006213747151473154, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006212077126725306, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006211373887515538, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.006210485793140607, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.006209818846901165, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006208472048624259, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006208323294455662, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006207517847064676, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006207491986337507, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.006206609205819454, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006206264307617914, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.006203878213135319, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006203323793903949, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.00620257836772289, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006201581748652793, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006200940419104758, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006200340799600369, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0061993827035984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006197946482273743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006197577038762546, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006196441111069815, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006195280197096756, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv8l" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006195241395629399, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0061948415656365215, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006193677489578068, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006191260945812517, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.006191035811649132, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00618846464985673, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006187489023353531, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0061874452226701555, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006186535596182568, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006185264289931658, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.006184673429581483, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00618416827970954, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0061816720958711244, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006181573482301838, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.006181197889996495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.006180658812081863, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006179531574858997, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006179471604101426, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006178921633746054, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0061770017490398585, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006175919391125801, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.006175744327439401, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006175013520572018, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "percent": 0.006170777494254582, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "120.0.0.0", + "browser_version_major_minor": 120.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15", + "percent": 0.006165820356435404, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0061657911751169855, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006165412515624309, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006165055092899195, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006164758854529391, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006164730601452683, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00616435235043276, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006164345666198959, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006164230663121778, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006162299926259578, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.0061619134597029465, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006160024693060588, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0061579802759747174, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006155510096433085, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006155367878162018, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006155198671192671, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006155076111332562, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006154488774816557, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006153579822063562, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.006152011773498732, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0061514796401376, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00615082331590305, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006149461488058424, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0061485430554359104, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.006148226179891892, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006147027666524405, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006145881591096394, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00614580852019636, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006145348083303211, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.0061440471099601665, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.00614273055678056, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006142546342167117, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "percent": 0.006142071821201692, + "type": "mobile", + "device_brand": "Samsung", + "browser": "Android", + "browser_version": "4.3", + "browser_version_major_minor": 4.3, + "os": "Android", + "os_version": "4.3", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.006141262944126869, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.006140457592033896, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00613866594458963, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.006138255492271907, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006136938695098413, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006136163667615983, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006134227654881559, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006131927673266764, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006131243644206315, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0", + "percent": 0.0061276784426835314, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "132.0.0.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006125335414295828, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.006124178991837883, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.0061234175607440535, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006121175406877267, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.006120721690615729, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006119503206520894, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0061169234519307955, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0061147912257587264, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006114217358785591, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0061132132703339065, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006113181759142645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006113049874746486, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "percent": 0.0061127150434727075, + "type": "mobile", + "device_brand": "Samsung", + "browser": "Android", + "browser_version": "4.3", + "browser_version_major_minor": 4.3, + "os": "Android", + "os_version": "4.3", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.006111713189503281, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.006110637142654255, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006109825957605327, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.00610974429159741, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006109640286948919, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006109269331503802, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006108145806439858, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006107296717098313, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006105416831803426, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006103418793518094, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006102640927282148, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006101950962728165, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006101190700487525, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006100760593010972, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006100629443433108, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.006100557346930313, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.00609959499302111, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006098770367306031, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.0060987279509888605, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0060984402332258155, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006098424180796225, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006097306604225682, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00609727475195237, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0060971595431376716, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.006094349438449372, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.8.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006093883028624443, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0060928539671811065, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00609152669220047, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006090977174846415, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0060882817544704905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.0060879349243549135, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006087600775830072, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006087302175917884, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006086684399593939, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0060863333803928095, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006084617368425419, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.006082154420761781, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00608187091315552, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006080394699533678, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006079718579932051, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006079575232560256, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006078976852585299, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 CCleaner/130.0.0.0", + "percent": 0.0060783636092481665, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006077665753887548, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00607692611873078, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0060767554284849755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00607597763374431, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006075381857302677, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0060751127065418065, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "percent": 0.006073742688680011, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006072284147989695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006072210347246056, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0060718146000984475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.006070601764459432, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.0060690993880147025, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.006068944701747382, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006067157207225487, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006066771066904062, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.006066019226785984, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006065811312977642, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006065455929293525, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00606218459312817, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006060965866952041, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006056888059946506, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.006054802725579372, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006052470695309815, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006048680209716187, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006046867415804229, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006046744351075684, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00604562502064501, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006045131710944941, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 Ddg/18.0", + "percent": 0.006044922389358896, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006042796919418163, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006042650850112201, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006041402225004968, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006038680113314614, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.006037774345950485, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006035345570765443, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00603515823518108, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006034038534694983, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006032697403939134, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.006032318790978537, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.006031475232451116, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.006031050674649368, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "percent": 0.006030703271004077, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "109.0.0.0", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006030539955956612, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0060302369226480365, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006029997929818037, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006029829115825202, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00602767134942812, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006026259285080491, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006026160838923879, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006024235002115752, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "percent": 0.00602389063950948, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "340.3.689937600", + "browser_version_major_minor": 340.3, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.006023886449788627, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/93.0.4577.78 Mobile/15E148 Safari/604.1", + "percent": 0.0060234403976386, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "93.0.4577.78", + "browser_version_major_minor": 93.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006023349687024429, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0060229792113297735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006022658603758915, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0060188295513206825, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006018380367200648, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006018249205700269, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006018227134850012, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006017147789955912, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0060161748285388165, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00601597893154963, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006014013655138951, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0060139860226595685, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006013086494820689, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.006012758734828291, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006012626987601978, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006011397008277801, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.006011282420276001, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006006496658180123, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006005598865406292, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006005189134681935, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006004941906018911, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006004767330585598, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.006003377690628478, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006002752477445814, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006002507670358504, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.006002432700012095, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006002108957906539, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.006001703206138472, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006000628431517435, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.006000588121168458, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.006000001629177782, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "percent": 0.0059998016472384, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "124.0.0.0", + "browser_version_major_minor": 124.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005998064924190741, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005997247497174464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005994712020001088, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005993981202324561, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005993210928764062, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005993195689900778, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005989513692843374, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005987061708808783, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005985151893122829, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0059826983137952015, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22B91 Twitter for iPhone/10.68.1", + "percent": 0.005982057208127915, + "type": "mobile", + "device_brand": "Apple", + "browser": "Twitter", + "browser_version": "10.68", + "browser_version_major_minor": 10.68, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005976311354589736, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005975209862147685, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005975012680119197, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.0059724675922925635, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005972311636827246, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "percent": 0.005970368656306789, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "125.0", + "browser_version_major_minor": 125.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005969446900676937, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005968672736957054, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005968098198459289, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005966374046488956, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0059638457918331535, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005960794714735908, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005960578286020422, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00596052324172922, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005959830597153346, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.005959098306331389, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005958756403367279, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005958518039435833, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005957821314374408, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005957073369298239, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005954196331924868, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005954104320268817, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005953856981177907, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005953109619363988, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.005952688067795743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.005952573119330836, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005951232591841339, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005950296926839494, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005950101033149595, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005947433679917026, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005946607961170396, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005945861975628979, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.005945598310471114, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005944842529413009, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005943445661423668, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005943265226960489, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005943029768073858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0059420039278134515, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005936837899140159, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.005936634446996998, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005935565191650073, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005935277625723162, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00593360213034625, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005933426799300304, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005931919198409668, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0059311681794476, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005929916940786716, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0059274736744656525, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005923156030890843, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005921158430366269, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005920738740103077, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005920021305272776, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005919735338539552, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005919675165173238, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005919432313345823, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005916681591573695, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0059160782031076, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0059142039540982854, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1", + "percent": 0.0059132574378984335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005912124704186218, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005911081834492844, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005910424770581639, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005909099886521258, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005909019838870468, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.005906962238662591, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.005906420047148359, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005906046031391168, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1 Ddg/16.2", + "percent": 0.00590577219073876, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0059056146924193155, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005905502497871144, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005903790089966292, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005903487501155749, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005902228296226088, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005901798861101551, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0059014883115776325, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00590034067693736, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005898267575668618, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005897567969551284, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0058974359195073855, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0058968310026200335, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005896626576120325, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005896281377682506, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00589477662500418, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.00589416169297785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.169 Mobile/15E148 Safari/604.1", + "percent": 0.005894106889391531, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "119.0.6045.169", + "browser_version_major_minor": 119.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005894077141045913, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005892384427080504, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005889645682412275, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005889017679271817, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0058889943017909245, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005886436533296279, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005885053737298554, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005884309543914768, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.005883657734145906, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005883308510223499, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005882892863072512, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005880604842068071, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005879363236831926, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005877768103382569, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005877065439237425, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005876307953627867, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00587462807554132, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005872950440922052, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005872840438845593, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00587190444614218, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005870781913959573, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.005870751123676903, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005869614896042022, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.00586897220577645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005868810010876398, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005868217363359523, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0058681367125924975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005867814722058686, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005867607114687154, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005867199840037343, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005865995964374981, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "percent": 0.005864927540092418, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "109.0.0.0", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00586292553301518, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005862512204872668, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005861795376595247, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005860737605151393, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "percent": 0.005860269665677877, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "127.0.0.0", + "browser_version_major_minor": 127.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0058585199805666135, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005857356866037619, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00585703759623424, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005856933878672893, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0058566295991117475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005856176048030177, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005854202622874304, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005852951061101306, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005852905795990536, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005852877823645394, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005852875551750466, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0058519573242133555, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005849078058184479, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005847446566970438, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.005847190704310127, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005847189353688416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0058468366134132015, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.005846127118956544, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15", + "percent": 0.005844046978791686, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.5", + "browser_version_major_minor": 15.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005844008230591903, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.00584384911862592, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005842679896209928, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005841608465652059, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005839616671872703, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005839107448893955, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00583864063431319, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005837786553551252, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005836326097186802, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005835680529650883, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005834998502597354, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005834872973535807, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005834827664885282, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005834535791579558, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.005833970008001347, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005833883409990866, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005833762646902357, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005833232316821496, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005832863975850911, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.00583203513399709, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005831918362407396, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005830521196961062, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005830390563005819, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "percent": 0.0058302359117270545, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "118.0.0.0", + "browser_version_major_minor": 118.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00582940749817609, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005829173789166521, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005827376253944561, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005826851847537096, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005825431901608598, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005823893723853406, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005823774179884318, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005823467518634744, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0058232808538339, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005823217022219302, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0058229296087912615, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005822722550818807, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005822341727342036, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00582165400937294, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005820989745424422, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005819858607712189, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005819721223650151, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005818759142931921, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005817066824404685, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005816487642679044, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.78 Mobile/15E148 Safari/604.1", + "percent": 0.005816163651767452, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "130.0.6723.78", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.140", + "percent": 0.005815116982258649, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "109.0.1518.140", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "7", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005813043627580588, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005812869829927118, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00581271405524472, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005812100506847931, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005811200334239475, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005808604253714183, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005808475648758421, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0058081268881535674, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.005806178208904589, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005805846772660785, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005803516286619747, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00579992622833134, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005799544510027989, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005798942448156009, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005797862395250163, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005797703093085027, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005797543687578851, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005796341418750218, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005796002013122847, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005795894666407441, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.005795235129116008, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.8.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005795033913445835, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005792771360168129, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.005792590620944627, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0057918462751344905, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005790269502144236, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.005789404937165987, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005789338044895001, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005787040107205996, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005786362242951724, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005785316816011094, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005785187420478376, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005785137440944429, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005784492346429277, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0057844485018340094, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.0057842194275540375, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "percent": 0.00578420920739427, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005784201045300454, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Avast/130.0.0.0", + "percent": 0.005783394608742264, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005782169394388792, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005778164634437352, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005777967729153072, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005775870615623268, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005775640515039756, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.005775021482898954, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005774701539232639, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005774357290759086, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005774077809778987, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005772916587017863, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005770944673733809, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0057708912439202046, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005770676924290177, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005770332134640821, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005768911710868201, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.005768200421971347, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0057677709741282, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005767464983543011, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "percent": 0.0057667449964523545, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005766322677539789, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005765344676865788, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005765162772663775, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00576405430154069, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005763944721705385, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "percent": 0.005763495586999138, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "124.0.0.0", + "browser_version_major_minor": 124.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005763194302714868, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005763010680205998, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005762804601000883, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005761671978837996, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005761378807148492, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 OPR/113.0.0.0", + "percent": 0.005760698784380914, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "113.0.0", + "browser_version_major_minor": 113.0, + "os": "Windows", + "os_version": "10", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005760191761606921, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 9; KFTRWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/130.4.1 like Chrome/130.0.6723.102 Safari/537.36", + "percent": 0.005759704681556188, + "type": "tablet", + "device_brand": "Amazon", + "browser": "Amazon Silk", + "browser_version": "130.4.1", + "browser_version_major_minor": 130.4, + "os": "Android", + "os_version": "9", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005758749396109941, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005757672407620849, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005752064720937746, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005751615506431141, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005750764392784244, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005750172666606518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.005750012524740223, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005747648417731845, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005746892925694008, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0057464884916536155, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005746064507019545, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "percent": 0.005742321919877792, + "type": "mobile", + "device_brand": "Samsung", + "browser": "Android", + "browser_version": "4.3", + "browser_version_major_minor": 4.3, + "os": "Android", + "os_version": "4.3", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005742028150596317, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005741203874145139, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005741155305179764, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00574089124817037, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005740103376468965, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.00573908529688127, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005738841313254217, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005737396916998756, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005737378817479124, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005735990696302564, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00573367403904676, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005733220878444281, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0057329974390450565, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005732907323805471, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0057321700012925155, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005731402506830971, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005729701247197674, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/334.0.674067880 Mobile/15E148 Safari/604.1", + "percent": 0.005728781635867802, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "334.0.674067880", + "browser_version_major_minor": 334.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005728092202672277, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0057260308907131465, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005725498979376023, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005725159295864079, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0057238499855678995, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005723174542243419, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005722101705362175, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005721583987315392, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005721262854864011, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005720770190971873, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005720280578260706, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005720204400349114, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005719033618457542, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005718582388895836, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "percent": 0.005716446992704385, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.1", + "browser_version_major_minor": 17.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005716429909130764, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0057160378148750565, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.005715365229140009, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0057138953387869, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005713234446754637, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0057131158846836505, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005712815387483185, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005712678890793128, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "percent": 0.005708912968528892, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005708851373146313, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/125.0.6422.80 Mobile/15E148 Safari/604.1", + "percent": 0.005708652620086594, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "125.0.6422.80", + "browser_version_major_minor": 125.0, + "os": "iOS", + "os_version": "15.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00570859982922072, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.005708405150786576, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005707568331649008, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005707404104834886, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005707022059000117, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.0057063557833453675, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005704139749466236, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.005703067235799013, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005702894236702309, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005702070055824379, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.0057011389068827205, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.0057006347889275305, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0056990913793344, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005698056662050115, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005695800551261911, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005695743838531197, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005695404721653263, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005694700384485903, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005694089190485623, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.0056940400575375034, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0056930238051514275, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005692617154075789, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005691779732029152, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005691708445822455, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Mobile/15E148 Safari/604.1", + "percent": 0.005691545684910994, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.5.2", + "browser_version_major_minor": 16.5, + "os": "iOS", + "os_version": "16.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.005691331983610832, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005689536649065549, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0056885409788178885, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0056885204390770445, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005688072717700171, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.005687519162143135, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005687394064166354, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005686831497474435, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005686234338206926, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00568482533435342, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0056847440733217265, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/130.0.2849.80 Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.005684683643641772, + "type": "mobile", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "130.0.2849.80", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.005684355710011166, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005684121652283192, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005682946640047553, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0056828900718684265, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005682346955397643, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005681806639472166, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0056811162945749885, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0056798363211456185, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005678920453407485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005678138991422296, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005677531483855833, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "percent": 0.00567615005217325, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "118.0.0.0", + "browser_version_major_minor": 118.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005675059833557719, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005672466804347514, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005672137613027742, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005671695328108373, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005671250648649016, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005668917824348571, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005668468975100605, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "percent": 0.005666975987216686, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "116.0.0.0", + "browser_version_major_minor": 116.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0056666182443942765, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00566501467032646, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005663632651157648, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005662984081817728, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005662817916825694, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005662565060548491, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005662069051584555, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.005661728332944113, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0056610651287241995, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.00566002376905032, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/255.0.515161012 Mobile/15E148 Safari/604.1", + "percent": 0.005659969681367786, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "255.0.515161012", + "browser_version_major_minor": 255.0, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005657492413683428, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005657163787809827, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005656137045169788, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.005655342896937283, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.37 Mobile/15E148 Safari/604.1", + "percent": 0.0056549048951091896, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "130.0.6723.37", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005653696015374761, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005650259828695441, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0056499355355567275, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005648805246680578, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005648522933320111, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005647046363783239, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0056468166268198675, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005645976420347789, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "percent": 0.005645342429633679, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00564498429213991, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/605.1.15", + "percent": 0.005644278141907912, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0056429579766999844, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0056419274132108186, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00564124628464179, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005641078658154366, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005640184548757749, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.005639651948694856, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005638927335689784, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005638099165164196, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0056380721097049995, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005637982003733186, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005636965581346723, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005635783202740466, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "percent": 0.005635545567105864, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.0", + "browser_version_major_minor": 17.0, + "os": "iOS", + "os_version": "17.0.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005634209082511547, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0056329014787681875, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005631504934280412, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005627365986774494, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/84.0.4147.122 Mobile/15E148 Safari/604.1", + "percent": 0.0056266745128435635, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "84.0.4147.122", + "browser_version_major_minor": 84.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005624699686770947, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005624548414013908, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005623450037081633, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005622204465289655, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005621943100602727, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005621692436263638, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0056211788285761, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0056199407003159115, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.005619924163792439, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0056197724463661084, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005619592411604367, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005619176539710602, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005618833845245542, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0056186874554119935, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005616879747470891, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.005616564800020192, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005616230257062539, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.005616138390403976, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005615877791118085, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005615571498644179, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.005615082951655593, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005614868946820098, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005614087528128141, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005612325886634538, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.00561200171356604, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005611919545819648, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005609865586591893, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005606171915095463, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005606019733690419, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005605641615614637, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005605468121877021, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005605390380123454, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005604951713062405, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005604395441704907, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005604271574426023, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005603243183492523, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.005601884691315606, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005600587699768096, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005599756321969379, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.005599670515132152, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.0055985034986570726, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005597150253647257, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005594337229928136, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.005592166856827695, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.005590739869768916, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005589256198168773, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005588591755602413, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005588082043853023, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005587748047221721, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00558774507168263, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005586143529415716, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0055856998976092235, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.0055849766188057854, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005582405656581298, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0055823026530298445, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005581323904953736, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005581107048150184, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005580719504727774, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0055793458393683295, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005578429707768319, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0055781836942791685, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005578075943427946, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005577210900338046, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005574116897348878, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + "percent": 0.00557370362668424, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "26.0", + "browser_version_major_minor": 26.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005573462128118008, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0055713747915818596, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.0055701099794834875, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005569436419534207, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00556876425886002, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0055668514858502, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.00556557425865772, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005565390831169636, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005565388001036938, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005565375550002126, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0055626204319397755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.005560398796662771, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00556013157435561, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.00555943121690867, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005558003649136168, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005556510528694697, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005553628900736265, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005553175828012816, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005552977542940014, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005552657789007917, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005551721369801834, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.3 Safari/605.1.15", + "percent": 0.005551114679753508, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "14.1.3", + "browser_version_major_minor": 14.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005550937215182899, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005548120490553495, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0055479695602788, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00554745227673631, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005546779117446878, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005546659550314765, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005546339654693164, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0055456666905495285, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005545625041350126, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0055450514368625275, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.005543988122658132, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005543967539438284, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.005537970805305778, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0055363223628323655, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00553508237403915, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005533927724561541, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005533279982143356, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005529175285140069, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005528461420926045, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "percent": 0.00552797569927839, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "109.0.0.0", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "percent": 0.005525555390549694, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "109.0.0.0", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0055243720964303486, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005523066375366817, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00552288164305381, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005522334921627933, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005521826650072693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005521426763265649, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005521119581604143, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.005520390416733203, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.0055203576031903874, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0055186570205853535, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.005516829852628569, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005516193262198161, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00551476267410834, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005513895918066824, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005510449885810562, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00551022137804377, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005506907248365449, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005506433747634372, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005506363562593593, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005501672024652729, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005501255440652736, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0055012146103219466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005500581216344656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005499410125273281, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00549857419291811, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.00549794214147339, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005497132090615343, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005496827049835226, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "percent": 0.005495899956889799, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "109.0.0.0", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005495855539039026, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00549411196010984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "percent": 0.005494008519006608, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "125.0.0.0", + "browser_version_major_minor": 125.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005493420116663939, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005493256960118278, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00549048277210011, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005490061043747984, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.005487679775748255, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.005487169285787724, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.005487105492240297, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0054863368448216645, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005485619660185098, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.005485308358528458, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005485244445288842, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005484610365700931, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005483853429269917, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005483669523162001, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0054821722929974835, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005481120356402528, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005480914983948626, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005480519967959075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005480444732652548, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005480082361035955, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005479999486153629, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.2", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005479115955928595, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005478936723511486, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.005476906709465495, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0054766971153771766, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005476188336256273, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005476002359141644, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005474774779930766, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005473555266041618, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005472960224131291, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005472783501491236, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005472138240234852, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005471916623955213, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005471264013857271, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00547123033809953, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.0054709024523691635, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005470830407418217, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0054683921923153475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005467070097269393, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005466081445065929, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005463689263053215, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005463677001428807, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005463203054293094, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005461695716849957, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.005460659522042424, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005459298671588197, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005459287867363534, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005457172386135816, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005457097433355177, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005456194953157253, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0054559964357994055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005454497979324626, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005453729952743346, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005453475407130091, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00545225820656223, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", + "percent": 0.005452083208442429, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "132.0.0.0", + "browser_version_major_minor": 132.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.0054495309685582615, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005449099999490001, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005447651992509844, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005446660940137115, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005444020254809696, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005442633598337436, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005442202708624214, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005440876183237631, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005437918188111997, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005437051163699166, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005436771927715928, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005436133970771078, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005435133987703611, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005434689689653898, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.0054345437489665685, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0054339295892956675, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "percent": 0.0054333109491283265, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005433076873545786, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005431999319405313, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005430409150178263, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005429013950091913, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005428751190724408, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + "percent": 0.005427275131896133, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "26.0", + "browser_version_major_minor": 26.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0054243396029267895, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005423786539562234, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.00542233582273402, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005421135894403439, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005421076252294125, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005420337673682922, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005419495326269895, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005419321751668155, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005417280666715293, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005417173903918243, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005416506938233846, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005415481034964032, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005415389550591286, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005414993350353087, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.005414717860041019, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005414625225685322, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005414173304469693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0054140466069496004, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005413516783305363, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005411697555492164, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005410734678410886, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0054103676172784975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005409346196084039, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.005409261293377004, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0054079376161393095, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005406719715782365, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.005405778955203649, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0054050631513860884, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005404181110897891, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005404138550592788, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005403913525875528, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005403843929810738, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005403744238995032, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005403323489679309, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005400510131346274, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005400246117737218, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005399466255727931, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005399335894510709, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005399319171174506, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005397677302452997, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005397353802203917, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005396990858751922, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005396087428034184, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005395945959611149, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005395608443755002, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0053953206579646015, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.005394885406399834, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005393983214795615, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005393586757570746, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0053935272275408485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005393054595495425, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.005392285979326625, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005392017554753902, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0053916816083199965, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00539161138775636, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0053903381942769476, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.005388896066734272, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005387995841464514, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005386145458247592, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005385906792287205, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005385358925822317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.005383195833143305, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005382996138516651, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0053820447404962874, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005381641540647228, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005380577586094397, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0053795200419883855, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005376670126661702, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005375586201779837, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005375134016141706, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005374154915898395, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005374153196418633, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00537306538106466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005372969631378777, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005372654597896605, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0053726470566266326, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 6.1; rv:109.0) Gecko/20100101 Firefox/115.0", + "percent": 0.005371112245359441, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "115.0", + "browser_version_major_minor": 115.0, + "os": "Windows", + "os_version": "7", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005370423017472961, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.005368614780838737, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.8.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005368499362112217, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", + "percent": 0.005368225039251509, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0053656433295110415, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005365508049370304, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005365319983891648, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.0053644616715902175, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005363504482832746, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005362766375562285, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005361744398543812, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.005361400105231804, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005361392390075446, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005359341273647883, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0053592371289667515, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005358831250146383, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005358200125409039, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.005357957925237732, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005357128941748828, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "percent": 0.0053561982424227975, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "122.0.0.0", + "browser_version_major_minor": 122.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005356081799406591, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005355559836891573, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005355493191031197, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00535530267106415, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0053522525250737935, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00535187632479403, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005351083243183934, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005350732660645152, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005350230647641409, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005350063832614189, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0053497707440135385, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005348270885507349, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005346568313648591, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005345190707803778, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005343259384121684, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0053427178509804765, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005342115945145625, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005341471080892753, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005341212990552579, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005338744732881247, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005338609007432164, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005338492418761281, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005335484597429887, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005334453245486884, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005334252525283456, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005334174262403624, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005333273708065161, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005332692596551214, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005332577208012756, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005331153692832242, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.005330833966894362, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0053291051531755545, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005328481635444526, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005327768709068451, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005326307888686138, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.005325554168371438, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "percent": 0.005324869578911316, + "type": "mobile", + "device_brand": "Samsung", + "browser": "Android", + "browser_version": "4.3", + "browser_version_major_minor": 4.3, + "os": "Android", + "os_version": "4.3", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005323604951593464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0053221292501212615, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005320956014631485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005319303414314809, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005319194124821002, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.00531917623956719, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005318332410278392, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00531773507202519, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005317563038467669, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005317263424730818, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.0053170982584794775, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005315796303675447, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005315681780399949, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005315631279414342, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005315360469728552, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/295.0.590048842 Mobile/15E148 Safari/604.1", + "percent": 0.005314765091658786, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "295.0.590048842", + "browser_version_major_minor": 295.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005314701354965138, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0053138902616020715, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.005310418672561867, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005309706125026412, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0053074633431205685, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005307361361844273, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005306759495506779, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005306276564032863, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005306215107379925, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0053040700016637006, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005303976889744065, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.0053039238846940085, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005302676012182978, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005301574308711657, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0053015542304759855, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005299988628087244, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.005298673726714931, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005298500567795513, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.005297015171312574, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005296010936075302, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1", + "percent": 0.0052958405692900825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "130.0.6723.90", + "browser_version_major_minor": 130.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005295724920956923, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005295082288276139, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005294629004734023, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.005293736628213589, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005293647101405844, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005292114127949459, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "percent": 0.0052918575241390434, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.00529068644579582, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005290083817762268, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00528895961855103, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0052866513000089715, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.005286436471375766, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005286125895515846, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0052860751053623815, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.005283953079948103, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005283266392274163, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36", + "percent": 0.005282751317196237, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "99.0.4844.51", + "browser_version_major_minor": 99.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0052824746526271725, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.005281151606868534, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005281090454922494, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00528091152957875, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005280713185449552, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005280712908190396, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005280485056535676, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005280318210009126, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.005279882113038914, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.005279452078083379, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005277975491781883, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005277721001866328, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.005275251132242685, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00527515002424451, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005274848013692564, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005273387355823682, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005273065332361499, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "percent": 0.005271699888572414, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "118.0.0.0", + "browser_version_major_minor": 118.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0052715696849821355, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0052713150226436295, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0052707844427105485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005269285668904442, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005268288755614087, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005267476771205618, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005263476726818587, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005263458239333879, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005262690396777311, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.005261503946158815, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005260993903321804, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.005259837135324833, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005259547691221644, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005259473789548637, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "125.0.0.0", + "browser_version_major_minor": 125.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005257747031909176, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.005256615428710712, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005255992695196876, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.005255516364993769, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.005255486411537842, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005254901917945243, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005254404752470751, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005254021275600234, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005252653052801358, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.4", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005251381656879755, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0052513680095255585, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005249687526605807, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00524924394320518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00524914968634754, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005248960455422607, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005247257417725797, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 12; SM-S127DL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Mobile Safari/537.36", + "percent": 0.005246253203720127, + "type": "mobile", + "device_brand": "Samsung", + "browser": "Chrome Mobile", + "browser_version": "108.0.0.0", + "browser_version_major_minor": 108.0, + "os": "Android", + "os_version": "12", + "platform": "Linux armv8l" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005245595605875301, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005245025080012791, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005244485425479844, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005244458795093374, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005242207459449375, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 14; moto g play - 2024 Build/U1TFS34.100-35-4-1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.107 Mobile Safari/537.36", + "percent": 0.0052419111190069295, + "type": "mobile", + "device_brand": "Motorola", + "browser": "Chrome Mobile", + "browser_version": "130.0.6723.107", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "14", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005241718581193599, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.005241459023429924, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00524136974354812, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.005240554177595115, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005238843732160101, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005238159216665352, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005237650501343595, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0052361323540131175, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005236042140751887, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005233000922486799, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.005232084930811745, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005231474336272255, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005231294477066226, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005231228478332428, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005230119290767273, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005230100653913587, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005229957173518235, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005229760402164091, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005228724200340595, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005228041152972239, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005227792689206361, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005226384679147434, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005226294787134523, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005224575332609992, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0052242661036126315, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005223743158615047, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0052231925775079804, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005221162093774995, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005221001729612639, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00522098761319655, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005220945413543633, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0052202248763184725, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "percent": 0.005220168680930021, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "120.0.0.0", + "browser_version_major_minor": 120.0, + "os": "Windows", + "os_version": "10", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005220096181003383, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.005220009961264336, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005219775344926528, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005218905215470258, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005217371648647285, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005216669866432184, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0052164409125166865, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005216369556504071, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005216040879711894, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005215161063643587, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.005214999450673984, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0052141641628825905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00521405525774021, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005213993101066119, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005208182518640441, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0052060015453007655, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.005205362660752871, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0052045146900520645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005204036505836468, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "percent": 0.005201375368319133, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Edge Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005200326152788363, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005199965623650816, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0051980365661465616, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005197182354897359, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005196102998368707, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005195932700712707, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0051938250673503415, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.005193546926224434, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0051932659138526, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005193261864818643, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005193225439688705, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005191332628457827, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005191062414813566, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005189099329150173, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0051875601058531855, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005187314968923695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005187067561939521, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005186890414172174, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0051867937751974656, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0051861321290747175, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005183148922171659, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.005181561865240246, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005179461991261379, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00517922222628059, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005178141676245631, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005177572102423139, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005177535755314896, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005177255546550652, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005175871021372519, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005175761220468668, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005174887447477106, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005172409624671287, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0051719266343207325, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005171602887481481, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005170885453008848, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005170117789559488, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005170082326192876, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.005169271630876982, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005168868298222737, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0051641787771752705, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005163884788175518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.005161305947044534, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "percent": 0.005161289603036251, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005160892260066062, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0051607394801238046, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.153 Mobile/15E148 Safari/604.1", + "percent": 0.005160249324253629, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "126.0.6478.153", + "browser_version_major_minor": 126.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0051595781102166975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005157850171186822, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005156766013472422, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005156081857494939, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005155115291027217, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0051539951532213754, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005153926202493813, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005153041237890232, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0051521400734952025, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.005151590986714388, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005150654786366525, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005150576965084414, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005148844950166583, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0051473218895004245, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005145632926902715, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005145409551666432, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00514504720941279, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00514440392778125, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005144402171990838, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.0051434560898793715, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.005143337742461356, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005142633143019063, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.0051425116134954105, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005142164409300089, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005141626546682049, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005140725726366312, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0051379653266078575, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.005136959198096681, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0051365540836447315, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005136075877811608, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005135837793594337, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0051347649715144964, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005133858630514929, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0051316000852004206, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005130729974435794, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0051287528846369395, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005127996014761774, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005126543793272511, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005126482610995655, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005124232482157762, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.005124225637460029, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005124006599535422, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005123467809854142, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00512183513031141, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.0051178287652918234, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005117279676718118, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005117059751342703, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005116878695065732, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005116631432245018, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.0051163523747073435, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0051162593515277546, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005115079357954795, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005114681122821156, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005114651608563264, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005114272475615031, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005114262272954012, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005114032469070051, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005113947926863298, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005111798971938807, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:109.0) Gecko/20100101 Firefox/115.0", + "percent": 0.00511165736044319, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "115.0", + "browser_version_major_minor": 115.0, + "os": "Mac OS X", + "os_version": "10.13", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005110928597377366, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005110927232680354, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005110668150666538, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005109720761086491, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005108512967647481, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005107638695071826, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005105927883603132, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0051043883124120325, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0051035394000797565, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005102479329785391, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005102461733725829, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005099728095334551, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005097481831119243, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005096980545390193, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00509508403278407, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005094654875818631, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005093986624659744, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.00509351476424276, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.169 Mobile/15E148 Safari/604.1", + "percent": 0.005093449472737936, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "119.0.6045.169", + "browser_version_major_minor": 119.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005092871246088704, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005091745300750345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005089087533483809, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005089077406932326, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0050886628956955905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005085193360445635, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.005085127952063518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005085080362115547, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.005085077573623139, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005084377527942869, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005084073249181744, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005079606933468277, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005079341177717125, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005079122049102656, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005078923743016156, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "percent": 0.005078875794122796, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "132.1", + "browser_version_major_minor": 132.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005078169081168086, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005078122807083021, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005075926652884452, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.00507466234930665, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005074366770019516, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005074194174676278, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "percent": 0.005073396252681058, + "type": "desktop", + "device_brand": null, + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005072752528410936, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.005071940684506766, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005071824178489513, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00507130146934914, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005071287436427507, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005071280514870815, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005070986086564861, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005070328594427822, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005069290752095417, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005068963298955916, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005068714918050546, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.005067214392613397, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005066187925919716, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005066046027383084, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005065643866964795, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005064053666157081, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005063968447683347, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005063713590828075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0050636330701058045, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0050631795670327785, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00506269209259757, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005061948383109464, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005061519164038354, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00506126881927982, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005059248283941496, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005059236026128281, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005059151795986469, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005058663721011303, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0050583004778415345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.005056849522712624, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0050565839532068476, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005056575062772645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 12_5_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Mobile/15E148 Safari/604.1", + "percent": 0.005056555768048817, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "12.1.2", + "browser_version_major_minor": 12.1, + "os": "iOS", + "os_version": "12.5.7", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005055875609820499, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00505526212975011, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.005053988398019462, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005053690589962166, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.005053639191060711, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005052904080537947, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0050527362074323555, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0050510039241849, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.005050747685141245, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005050431502819612, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.00505005010472221, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005049907163137921, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0050495589652141265, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005048137622160996, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0050473810005788895, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.005045763945092665, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005042343358889799, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005041849835553066, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005041286199698463, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005041280745496471, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36", + "percent": 0.005040607559939917, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "127.0.0.0", + "browser_version_major_minor": 127.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.005040497475103767, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005039807533427788, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005039807405311464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005039457208518527, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005038293634914558, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0050372473538052515, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0050369559781805896, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0050360541439042, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005035068109416365, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005034928901369759, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005033806442085281, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00503307500101743, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.005032058349061412, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00503164006042648, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/328.0.658461140 Mobile/15E148 Safari/604.1", + "percent": 0.005031052454203824, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "328.0.658461140", + "browser_version_major_minor": 328.0, + "os": "iOS", + "os_version": "15.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.005030321697764368, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/128.0.6613.92 Mobile/15E148 Safari/604.1", + "percent": 0.005029545296306284, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "128.0.6613.92", + "browser_version_major_minor": 128.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005029304508019883, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005028964953954732, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.005028916884867273, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005028719479617041, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005027324798155524, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.005026969473038622, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.005026862728953295, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "percent": 0.005025595761518311, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005025469775120403, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005025361864888506, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005022044149845105, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005021877874809132, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.005021769206101476, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005021154026407239, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.005020438486036967, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0050193685860258856, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005019282113585489, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00501858565682286, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005018439445497508, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005017734373573294, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005015747633958227, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005015701496391512, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.005014815056393777, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005014583436291495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005014493306761102, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005013899655705183, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005013207975241354, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005011396367215988, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00501138888400277, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.005010848845230518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.005010470788021518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005010256488413377, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005007821653285503, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0050076812282415515, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0050073767379953515, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0050073093223996544, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.005005329375375104, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.005005299304615194, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.005004882077643134, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0050046720813948, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0050045195085198285, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.005003561297830251, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.005003482915621918, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.005002753488323519, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0050023995293811295, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.005002376704805264, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0050022624885302375, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.005002247424234921, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005001061447914814, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.005000657668320765, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0050006354212968, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004998977855666284, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004997227747752072, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004996174086749335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0049953416403369375, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004994899816221537, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004993144120070877, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004992857910379439, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004991698347886227, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.004991461850116526, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004990462777298135, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15 Ddg/17.7", + "percent": 0.0049903316585521995, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.5", + "browser_version_major_minor": 16.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004988709241374872, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004987872404191561, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004987526013128066, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004986992963955286, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004986362383664969, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.0049854961760557644, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004984407536522537, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004984232882382105, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004983950574877834, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004983610835729308, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004980597190988562, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004980596782559557, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004980574311056215, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/25.0 Chrome/121.0.0.0 Mobile Safari/537.36", + "percent": 0.00498053400401588, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "25.0", + "browser_version_major_minor": 25.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004980489726409543, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004980315763372936, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004980124798071432, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.5238.1570 Mobile Safari/537.36", + "percent": 0.004979149734710947, + "type": "mobile", + "device_brand": "LG", + "browser": "Chrome Mobile", + "browser_version": "51.0.5238.1570", + "browser_version_major_minor": 51.0, + "os": "Android", + "os_version": "6.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1 Ddg/17.6", + "percent": 0.004977855039153115, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.004977410014427477, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004976492187860936, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004976483139689806, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004975373125060533, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004975280999039334, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0049746612082773745, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004973511330028965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004973289243969622, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004972715314568787, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004972702057618941, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0049723082220563194, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0049677930733093905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004967180169190274, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Unique/97.7.7269.70", + "percent": 0.004967113098171197, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004966589353540757, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00496493019876545, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.004964176244938899, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004962193229088763, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004961778017967512, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00496094185262728, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004960723584575318, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.0049590546230053175, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0049578309387056235, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004957179042992019, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "percent": 0.004956950255958973, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.6", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004956570810276644, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004956466737689666, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0049564379683007965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0049561397991439654, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004956038461800048, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004953612091281521, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004952269196985711, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.0049518576300376, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004951168286846089, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004950178213795154, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.0049483478180388, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0049483445279988264, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004948081836767907, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.004947738301634213, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004947580527240663, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_7_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.4 Mobile/15E148 Safari/604.1", + "percent": 0.004947543243786722, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.4", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.7.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0049475019028891246, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.004946739543314472, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004946385028720778, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0049457854112983785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004945485758683916, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0049453321170116185, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0049448854041122805, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0049443240284551045, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004943755867062118, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0049433089678200875, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.004941516945814281, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004941100918508377, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004941050269045206, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.0049400752458882025, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004940020432524346, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004939670604809992, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004938903985067237, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004938729250569072, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004938320232253772, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004938161994613564, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004937298952060521, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1 Ddg/18.2", + "percent": 0.004937215384412024, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004937093751099798, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004936013221665889, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00493531491253021, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.004934448204573724, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.004934224352387053, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0049341720656532135, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00493363242275703, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00493311862046199, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.004932907293047392, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "percent": 0.004932507396633281, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "121.0.0.0", + "browser_version_major_minor": 121.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004932273442680052, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.004931134129751988, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004930395907957608, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.00493037859857626, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15", + "percent": 0.0049299618051637335, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.0", + "browser_version_major_minor": 15.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004929487509240578, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004929237701424621, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0049285079186849705, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004927405588169376, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004926891923570001, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004926868723854539, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0049263892722086625, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004926359192404235, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.004925942035056818, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6.1", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.00492593478049743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.004924791013555782, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0049246548159373365, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004924536546291393, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004923797843756539, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004922867896074189, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004922819501357046, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004922336476722914, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004922161573597056, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004922107884695013, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004921919356061174, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004920895686833929, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004919749986016337, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004919706122939296, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004918363515942198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004918033620384205, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004917140546222915, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004916801509479747, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00491640703914852, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004915857346517722, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004915318174063528, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004913766085639703, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004913661149839483, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004913039502258164, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36", + "percent": 0.00490982959091265, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "110.0.0.0", + "browser_version_major_minor": 110.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004909420235474295, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.004908715459561158, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004908421585768792, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00490567642109702, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004905573386923979, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004905538057793685, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004904864005661383, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00490461227031329, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0049038528028029285, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004903716930440924, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004902495411848226, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.004901826547243241, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004901646843009921, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.004901206074960303, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004901016395266613, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0048997601024262805, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004899499724281799, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004899062852854755, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004898721583277629, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36", + "percent": 0.0048986910091299565, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "87.0.4280.88", + "browser_version_major_minor": 87.0, + "os": "Mac OS X", + "os_version": "10.10.5", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004896284517597686, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004895232892197641, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004894047215970891, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0048940322180135686, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0048935634372574074, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.004892431906173733, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004890418201453386, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0048902183246644785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004889878798009721, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004889040123488952, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004888861868413603, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004888302341129601, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0048882922060051485, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004886555513892565, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0048856677252980835, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00488533200075938, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004884653399753681, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004884303300113318, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004884267411510822, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004883471394784269, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004883128955273989, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.004882737289986786, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004880453971448677, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004879073790460342, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004878831920602694, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0048785252782412615, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004878119302230555, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004877691749943305, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004876697252882905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004876266821288879, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004875089686453869, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004874556619676278, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004872734819334984, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/341.3.692278309 Mobile/15E148 Safari/604.1", + "percent": 0.004872581381381353, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "341.3.692278309", + "browser_version_major_minor": 341.3, + "os": "iOS", + "os_version": "17.6", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004872396530876986, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004871838989093643, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004870587521589267, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004870474917856969, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0048700780104908945, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004869600147104372, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004869163888679132, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004868075979531237, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004868014710490405, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004867585406468325, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004866996364752652, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0048661343429693095, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0048658092656882045, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.004865239891445382, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004865112192567076, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.004864306735754351, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004864081407936939, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004863884690452238, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004862766032822591, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00486263599359906, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004860548421502823, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "percent": 0.004860358326435625, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "122.0.0.0", + "browser_version_major_minor": 122.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004860355411107464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004860288440164415, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004857511725911295, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00485678965515668, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.004856226859751689, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004855996130273452, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/321.1.642804631 Mobile/15E148 Safari/604.1", + "percent": 0.0048558329266955415, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "321.1.642804631", + "browser_version_major_minor": 321.1, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004854046485660894, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004853617276861912, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004852741860557665, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004852052179338743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004852016060998266, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004850073953116176, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.004849686206079145, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004848403040170664, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004847208891074008, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004847204725788332, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004846371438490811, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004845984891394441, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.004843771484319971, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.0048433587449976485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004843322616064505, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004843100528412583, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004841215086385026, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00483929383951163, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "percent": 0.0048374812178054736, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004834905417585512, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004834037471867998, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004833417066708399, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004833327495857386, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004830743993933428, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004830602316159961, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004830201586008713, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004829683155507907, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004828853083935484, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0048281552385957905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004827246880158025, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.004826708394079013, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0048237789341349264, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00482311483872927, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004822845686389015, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004821449175095707, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004820516205333475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004820254870587875, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004819725818743344, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004819177315653572, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.004818899763460299, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004817136002814065, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004816390623335236, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004816042396417526, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004815326313531731, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004814844278164612, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004814481397577342, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004814077710487234, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.004813892377001454, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6.1", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004812990637695606, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004812852050094724, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004811848739386282, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004810202031860674, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004808833754021709, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004807459713882426, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004805558392687677, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004805300955899258, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004805077534278412, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004804910871757284, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004804300875299444, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.004804082881464767, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004803967378465222, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00480319738114864, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.004803046815379207, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00480228408333011, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0048019558153988996, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004801276796731091, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004801147926794211, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004800814355865633, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.004800543236915087, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.004800441295952593, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.004799953457632847, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004799658607475445, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004799414194687876, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0047979932506709315, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004797975448390752, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004797434988788623, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004797259627304848, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004796015725998079, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004795406180586464, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004794995726585148, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004794681304228859, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004793621127143493, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.00479233600608212, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0047913787378869805, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004790302606704688, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004790296366752882, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004790280253437759, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.004789838096864008, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.004789812274508272, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.004789634795308473, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004788850371576568, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004788701151403881, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004786877627241863, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004786462222052095, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004785509655386587, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004784557573402365, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004782803618726389, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004781453995014317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004781251516333364, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004781185675988259, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004780793483891691, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004780487873750155, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.0047796319295880475, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004779446688168931, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.107 Mobile/15E148 Safari/604.1", + "percent": 0.0047780716786677896, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "127.0.6533.107", + "browser_version_major_minor": 127.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004777956204811568, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004777885456280945, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "percent": 0.004777729228692852, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "132.1", + "browser_version_major_minor": 132.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004777408187040608, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004775235075491576, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004775090703773566, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004775000361698685, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.0047749337097260335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0047730442240783025, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004772969409572736, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00477282165436791, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004772722900057213, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004772227188732667, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004771657608851292, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.004771283076898387, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004771018806629359, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0047696935344270905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004769576665065391, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004768083605499572, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004767979792617469, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.004767088117226692, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/103.0.5060.63 Mobile/15E148 Safari/604.1", + "percent": 0.00476682497832536, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "103.0.5060.63", + "browser_version_major_minor": 103.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004766552805578562, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004766222281383693, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0047647834054850985, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00476394752693498, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0047636690953851025, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.0047632783829075175, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004762799558923042, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0", + "percent": 0.004760912949580204, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "115.0", + "browser_version_major_minor": 115.0, + "os": "Windows", + "os_version": "7", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004759691359940825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004759247317151879, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.004758953590127015, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004758673424194136, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004758381281536291, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004756563562261827, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004755724864886047, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004755095238059987, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004754932917817303, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004753183340971546, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0047531356146382035, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0047526370367104165, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004752603565937479, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004749021835882767, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004748349332463927, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004748314743269571, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004747663321891283, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004746564594632114, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004746265168357174, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "percent": 0.0047457920349458175, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004745539133304457, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004745417094988772, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004743703717948899, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004741486289293549, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004741327359920997, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004741174506084007, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004740245250894311, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004739496900943126, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.0047393885908160495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004737923919960894, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0047371427429592885, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004736350446186042, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004736263539335988, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004736121644335093, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0047357836143269725, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0047349339334775056, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004733378669334099, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.004733128902284301, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.004732681739091858, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004732409728031526, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 Ddg/18.0", + "percent": 0.004732345827947471, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004731818790389309, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004731653251316222, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004731493811801459, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0047309585074301625, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004730661052322678, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00473012453507271, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004730034399471922, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.0047288140650154934, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.8.3", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004728699135307493, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004728469049193841, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004727143178567455, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00472707485199986, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004726980480218335, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.004726640399540153, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004726356921857847, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0047261008409882075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004725384512667941, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004725365285341948, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0047247771214556766, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.004724364955206683, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004723743599802319, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004723709820527932, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004722775084281309, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0047218060647838565, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004720169211328485, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004720106545390187, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "percent": 0.00471904562725016, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "132.1", + "browser_version_major_minor": 132.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004718630928987638, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004718449321339839, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0047183389248630645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00471785785144485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36", + "percent": 0.004717402968762201, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "132.0.0.0", + "browser_version_major_minor": 132.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004716765391134751, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004716109297155112, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004715960038653259, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.004715832547226407, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.004715224670745654, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004715144977986844, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004715092372144245, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004714875305604704, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004714322862018369, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004713414932620406, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004713013951812504, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004712089024434247, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0047085018825727834, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004708476876939172, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004708155508620379, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/131.0 Mobile/15E148 Safari/605.1.15", + "percent": 0.0047081308025495065, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "131.0", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0047074822679906295, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004707298052840342, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.0047069783171382095, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004706971291071919, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004706883340417198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004706846320403809, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", + "percent": 0.004706053190222113, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004706007862154654, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004705839305194042, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004705308305993015, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004704940212297137, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.004703665914674091, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004703224983874576, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.004702953719768485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004701984780452945, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004701397584453509, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004700911621292903, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004700640959408897, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0046996582250387695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004699256679326212, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004698854114142211, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.004698474518720873, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004698373911393679, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004698021033885754, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004697087367041916, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004696393316477825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.004696071894312201, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004695589266937039, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "percent": 0.004694656053837256, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "342.0.693598186", + "browser_version_major_minor": 342.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004694494828877692, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004693799509653602, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004692673153084263, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004692473093987485, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0046922356405755235, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004691600672676837, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004691263734699756, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004690052328459883, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004688727089669836, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0046865141119191095, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004686507553570534, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004686166014433811, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004685281203580228, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004683431104151343, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00468323924564816, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004682715078541686, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0046820972213506016, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.004681821854983995, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0046817287569101505, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004680808080452721, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004680782410995102, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004680257257196719, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.00467989875274427, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004679810146576717, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00467949659810092, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/101.0.4951.58 Mobile/15E148 Safari/604.1", + "percent": 0.004679294825666493, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "101.0.4951.58", + "browser_version_major_minor": 101.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.004677708593243237, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.004676911867440126, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004676572557108588, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.004675872158084846, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00467579948173773, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004675463401644826, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004675116969633298, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00467465548127327, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "percent": 0.0046744235126278556, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.2", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.00467333391233336, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004672255431108088, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004670868246323618, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00466927481285014, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.004668768655877537, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00466826120407351, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004667868038771026, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004666238499388359, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004664592043104732, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004664214334056595, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004664104307566508, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004663504220796907, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004662966255230759, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004661497352518711, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004659571393719352, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004659449773169184, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004657189577708298, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004656947445655425, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004656523563659448, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0046555456127086725, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004655383039652905, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004654495555626015, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004654169990109302, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.004654097793408617, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0046540521021063656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004654019129962762, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004653513176654478, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Android 13; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.004653451689131207, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "13", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0046531159015786834, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.004652522532001749, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004652424158484941, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004652140208424226, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004651245937237342, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.004650598473940005, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004648090857639396, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004648074165115045, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "percent": 0.004648012302862039, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "116.0.0.0", + "browser_version_major_minor": 116.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0046464473276076525, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36", + "percent": 0.004644465773360967, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "117.0.0.0", + "browser_version_major_minor": 117.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004644415927647953, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004644018371639886, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004643019334667031, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004642563029668867, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00464253390626445, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004642046164504731, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004641830962089428, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004641120348786698, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004640396195454067, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004640059807588368, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.004639834348095396, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004639228972022291, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004638113778064172, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0046368939919751925, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004636546079274183, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0046364944183476834, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004634235345935957, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004634060252366791, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00463377667808443, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.004633397207282152, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00463330056043244, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004631672165310276, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004631585097297503, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0046309937649756565, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004630015679177079, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0046285537432841735, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004627869508333838, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.00462773991802416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004627363698179694, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.004626509524473977, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004625545931618438, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.004625409788962574, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004624735476810284, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.0046246426118465135, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004624342584640584, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0046242349830172644, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00462398585297646, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.0046230995814982015, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0046230184747536604, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.004623016403861954, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004622732637906101, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004621758890234388, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004621722924348283, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004620461374075451, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004618897952206273, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004618125886081333, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0046179924897171805, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004616100693920904, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004616076404602023, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.00461583115237018, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004615182585795999, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004614747022065191, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004614452742975195, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00461443196251872, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0046144155036970036, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.004612210742317011, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.004611419499388996, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/286.0.572986368 Mobile/15E148 Safari/604.1", + "percent": 0.004611009764146443, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "286.0.572986368", + "browser_version_major_minor": 286.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0046101932902010345, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004608363531889765, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004607708355440401, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004607536364140971, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004607058785803009, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.004606572087945027, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004606011836400603, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0046056379898918596, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004605267808934568, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0", + "percent": 0.004604587106483591, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "131.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.004603240503253949, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004601429008257855, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0046012749366561604, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36", + "percent": 0.004600445200549219, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "119.0.0.0", + "browser_version_major_minor": 119.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004599591298399308, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0045992471013713895, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.004599206437675951, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004599001209379067, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15", + "percent": 0.0045985279364350925, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.5", + "browser_version_major_minor": 16.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004598309678133029, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00459813318674559, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004598039273447051, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004597528005605044, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00459691837450478, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004595982499068187, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0045946051504410475, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004594585053614756, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004594277120258922, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004593268372437755, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0", + "percent": 0.004592520209743486, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "125.0.0.0", + "browser_version_major_minor": 125.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004592075479352519, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004590377233558132, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004590219700862729, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004589742868967662, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.004588692953754686, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004588686746628369, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004588414732979084, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004587276554174937, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004586390932839816, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004586301245553122, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004586181521143523, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00458586368648938, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0045856254700591325, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004585045887932727, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004584859890890856, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004584440718746357, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004584027827533512, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0045836908539051355, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004582392525360572, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004582128196132674, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.004582026114861276, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004580811800304736, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004580091587102986, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004579999878378516, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004579965142809193, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004579953441489602, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00457900133111927, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0045782257542340285, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.004577438763260099, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00457454784103678, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0045745403620064875, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00457431399799952, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0045740135098321895, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0045736562830437835, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004572660599219688, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004572187551204414, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.0045721006568509685, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004572058328898755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004571583166560283, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.0045712987073755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004570175020073825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004570170730104884, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "percent": 0.004569229881818409, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "121.0.0.0", + "browser_version_major_minor": 121.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004568246125973872, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1", + "percent": 0.004568030195027454, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.4", + "browser_version_major_minor": 15.4, + "os": "iOS", + "os_version": "15.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004567094387953834, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004566124135770719, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004565472558436694, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "percent": 0.00456514511030195, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004563905598868589, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004562632808996014, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.00456245278349413, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "percent": 0.004562396073466512, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004562374957224211, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.004562199875963364, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004562101033025484, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004560969141728054, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004560727423486368, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004560037970433151, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004559507629891303, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004559342255951896, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004558558507182348, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004557853447039683, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004556541438225095, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0045548750745309325, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004554529095813554, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0045544227644663555, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0045537434647515114, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004553689736149621, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004553353004475543, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004552809472642556, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.004552269914562226, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004551656527011163, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.0045515617563022664, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004551048373941632, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004550797373736795, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00455052635898177, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004546930878205235, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004544768499197623, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0045443488069720735, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004544237632467242, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.004544002553612333, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004543838124933241, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0045429035847331396, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004541427356835453, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00454108771124616, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004540673562010631, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "percent": 0.004540505727851028, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.3.1", + "browser_version_major_minor": 17.3, + "os": "iOS", + "os_version": "17.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004539849565190044, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004539732991990289, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004539588333164414, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004539529031455764, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004539528606320009, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004538540797149335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0045381256185135195, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "percent": 0.0045379122562793665, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "109.0.0.0", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00453647117795561, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004535549287906203, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004535109488595553, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004535092724894089, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004534775098855442, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0045341642009398804, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004533659306070434, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004533598442011292, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004533595860738435, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004533444129599516, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004533265717967941, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00453280954727577, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004532133949365672, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00453108937815266, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004530785355242662, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004529757344077427, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.004528791274462079, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004528497335601031, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0045278492958364105, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0045275940864892604, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004527342284835153, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004526931719062419, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0045262251374644175, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004524217878435651, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004523304758906144, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004522338801872028, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.004521279673829859, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.004521072160170453, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0045209366090190085, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004520076977005077, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004519341883615836, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.004519205449533097, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0045191149525718064, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004518871589139686, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004518808313222729, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "percent": 0.004518511680367449, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "132.1", + "browser_version_major_minor": 132.1, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004517205335874612, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.004517074602364538, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004516952826489388, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004516814444932174, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004516468290897611, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004516078099367255, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004515463124886212, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004515352133827911, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004515274484208867, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "percent": 0.004513903852014577, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.0", + "browser_version_major_minor": 16.0, + "os": "iOS", + "os_version": "16.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004513090784129886, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004512124887676175, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004510955751132055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.0.1", + "browser_version_major_minor": 17.0, + "os": "iOS", + "os_version": "17.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004506776579076827, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004506316653028785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004506171904693212, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36", + "percent": 0.004505607937964305, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "92.0.4515.159", + "browser_version_major_minor": 92.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0045055485880892586, + "type": "tablet", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00450523536228765, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004504254664051589, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004504180708768493, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.004503046213043435, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004502810884243142, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004502703573490105, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0044997668023949566, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36 OPR/86.0.0.0", + "percent": 0.004499708943791988, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Opera Mobile", + "browser_version": "86.0.0", + "browser_version_major_minor": 86.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004499466257650645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004498983650938969, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004497519280462558, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004497210715106603, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004496710925069425, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.0.1", + "browser_version_major_minor": 17.0, + "os": "iOS", + "os_version": "17.0.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0044961097798456705, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004495018428328251, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004494914581744964, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004494446244195462, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0044943669108618795, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004492145179449267, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004491866389381755, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004490603567801811, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004490317653939256, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004489286258377671, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004489252195890249, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "percent": 0.004489191274463596, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Edge Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.004488794383255611, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004488234718132292, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004487506879170489, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004487168539896663, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004484542005841875, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.00448448116077224, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004483219042105582, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "percent": 0.004483029806231761, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "116.0.0.0", + "browser_version_major_minor": 116.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004482138184614913, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004481448437339413, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004481140858203452, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004480457019142347, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004480072572418805, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004477903537228603, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.004476788908516999, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00447627138124107, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004476271058269029, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004476085588349329, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004474796997687748, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004474645206960413, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004473087863917226, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004472229651549076, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0044721290939337055, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004469698548631624, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0044691720259851814, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/133.0 Mobile/15E148 Safari/605.1.15", + "percent": 0.004469062898817564, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004468886900044926, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.00446886260229869, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004468342298606551, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004468325482954841, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004467688920157319, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004466424110778328, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004465316743325579, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0044652575631339425, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.00446514954100679, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004464160709320477, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004463011436763868, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004461847980079111, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004461812810433953, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004460882491444673, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "percent": 0.00446056161901642, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "132.1", + "browser_version_major_minor": 132.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004460260362150053, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004460254135428079, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004460195720880298, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004459330866975244, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36", + "percent": 0.004457803669677193, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "122.0.0.0", + "browser_version_major_minor": 122.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004456275422835855, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004454864796495281, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004454846872613023, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "percent": 0.004454744655263055, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004454167212493859, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.004453569433975789, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.0044527270549761305, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004451968701751763, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.004450594843162216, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004450291532859861, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004448753738580823, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0044470355756153395, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004446850890148429, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0044463900650956786, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004446178882528835, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "percent": 0.004444764887541842, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0044445832349929, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00444442164170296, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131 Version/11.1.1 Safari/605.1.15", + "percent": 0.004443199314014476, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "11.1.1", + "browser_version_major_minor": 11.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00444309991601956, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004443040853345704, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004442703675286518, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.004442301309557813, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0044418822246176624, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0044411902230194365, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004440854678307103, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.004440759624399802, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0044406972597651456, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004439732117120009, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004438312908254461, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004437834791701165, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004437492048066691, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004437349085335155, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004436731996047677, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0044356220514710715, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0044353573949434235, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/21G93 Twitter for iPhone/10.18", + "percent": 0.00443513174785836, + "type": "mobile", + "device_brand": "Apple", + "browser": "Twitter", + "browser_version": "10.18", + "browser_version_major_minor": 10.18, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0 (Edition std-2)", + "percent": 0.004434791673726874, + "type": "desktop", + "device_brand": null, + "browser": "Opera", + "browser_version": "114.0.0", + "browser_version_major_minor": 114.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0044328785009060566, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.004430806065842809, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004429963062258026, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004429848632438579, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004428903868148075, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004428680579187427, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004427651831314899, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004427541756296723, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004427436590706643, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004426986806657923, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004426491210297069, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004426155850156277, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004425951843811776, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00442464102261075, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004424528927125378, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004423270072025131, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.004422786346301017, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004422770573387566, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00442263380855658, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0044222961055471475, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.004420463304219969, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004420417809554055, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004420053143755238, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.0044175262206940175, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004416964384501145, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.004415910554990935, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004414088676210962, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.004413709795765948, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004412607894906343, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.004410658078371164, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004410340219071116, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004409690999184841, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.004409388021659516, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004407805871006566, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004407286444900843, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004406478243843007, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0044057159323110376, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.00440501535839523, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004402674756360189, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004402382743280727, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004400973550504514, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.00440090110639949, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004400829068093654, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0043999218665977564, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004399840205987326, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004399694435258209, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004397562593020314, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004396753237908429, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004396449914649263, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004396438094521198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004395772700360867, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.004394858667973699, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004393553257677527, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004393480370035752, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.004391300192208364, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004391022226231542, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004390871347918218, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004390628000483456, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004390576263211787, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.004389954422423221, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/133.0 Mobile/15E148 Safari/605.1.15", + "percent": 0.004389731172419472, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004389237808595668, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004389104208515748, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0043872214837300545, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0043872102553035456, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004385344016177956, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.0043848454683761895, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004384742070181664, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004382231880421298, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004381021914814803, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Android 15; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "percent": 0.004380087207425741, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Android", + "os_version": "15", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "percent": 0.004380010098840882, + "type": "desktop", + "device_brand": null, + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004378749048898819, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004377985585576038, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.0043758980778518045, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004375809153125352, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00437563767722604, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004373455291566116, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004373172322636482, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004371910642838044, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0043716494087194776, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00437153870360635, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004371351131792307, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0043695385450311834, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + "percent": 0.004369364006596582, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0043692447286351865, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0043671291066389, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004366959301353378, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004366889756170538, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0043664831698649355, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004364771377979508, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0043639608423065605, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004362863860024886, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004362380625560511, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.2.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0043610594914318635, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00435979647531023, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004359552182920237, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004357790299213287, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00435758326777779, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004357372859761206, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004357322916124839, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "percent": 0.004357273373348904, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "121.0.0.0", + "browser_version_major_minor": 121.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004357215121950409, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.004356856025140599, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004355695593495723, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.004355654819091867, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004355261391524405, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004355169213319449, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "percent": 0.004354702527216713, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.7", + "browser_version_major_minor": 17.7, + "os": "iOS", + "os_version": "17.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004354613270817139, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004353262933536523, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004353106394776495, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004351990256305623, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "percent": 0.004351526743693826, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", + "percent": 0.004351197261774787, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.004350959729735425, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004350554186054523, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004350239750812946, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004350034574226804, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004349418812721016, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004347828836738115, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0043474561880291465, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004347235061524679, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.004347213286896375, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0043464772188400395, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004344579899536861, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0043440280765662115, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:109.0) Gecko/20100101 Firefox/115.0", + "percent": 0.004342682127839272, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "115.0", + "browser_version_major_minor": 115.0, + "os": "Mac OS X", + "os_version": "10.14", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004342362878027957, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004342131932319675, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004341750911431401, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00434173055866791, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004341444274149146, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004341421726310584, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004341284515871388, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004340103505271118, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004337550653064462, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004337412169424519, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004336133795677281, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004334517858238775, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.004332719438557383, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0043310013571296255, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004330749105253097, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004330608938508554, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0043305369091372, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004330471741615344, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004330101008659559, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004330057439301684, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004329871679143349, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.004328230973976959, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004327778549373267, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004327586939020996, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0043268389211473255, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004325913620094421, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004325818272804161, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004325172009591181, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.004324117400094631, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004324038064667476, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004323156517303427, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00432181104642678, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00432106639326295, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00432077108041346, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004320579398846866, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0043203072807510515, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004320168345539777, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00431978549185571, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004318521932603729, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00431812893771301, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004317998755102466, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004317721219448399, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.004317510593507381, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004317009074388324, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004316991905506345, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "percent": 0.004316566651281245, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0043163551716893876, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004316190379850754, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004315510174768203, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.004314960675389319, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004314585263296484, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004314296685058962, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004313999980695527, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004313024987465042, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.1.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004312802141304086, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00431233530629144, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004310407930844352, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004309840382246828, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004309697575266277, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004309627014092587, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004308464588335186, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.004308440887406496, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00430830496862224, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004308080843698019, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004305783273496362, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0043052758945587005, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.004305182056222553, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004304643921115669, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004304172564313583, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004303831411899605, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004302620502883159, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004302416667315744, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004302367489749996, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.8", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0043012325028744455, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0043010666438544065, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004300292897536625, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004299676122658433, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004299578509061851, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004298841955562941, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.00429867565505637, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0042986379606622365, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.1.1", + "browser_version_major_minor": 17.1, + "os": "iOS", + "os_version": "17.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00429813519541762, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004297989806338664, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004297976046677246, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00429779563347396, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004296879382917139, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004294004933417733, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004294004590001799, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004292078953111695, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004292004123590474, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.004291708552123645, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0042908221321933775, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004288820225449051, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004288542995355774, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00428831505915303, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004286076581606452, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004285836696251373, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/122.0.6261.89 Mobile/15E148 Safari/604.1", + "percent": 0.004283986263380158, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "122.0.6261.89", + "browser_version_major_minor": 122.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00428395459020114, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.004283815568080148, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004282523186655664, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004282322397711402, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004281698083385613, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004281112929004114, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004280914446346657, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004280907468157923, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004280550801496599, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004280521272411561, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0042803777464016065, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004280361791865072, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004278731869071841, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.004278368300050349, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004277126601535583, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004276146106822607, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004275825118334107, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004275767633806989, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.004275455220641233, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0", + "percent": 0.004275299531230575, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "124.0", + "browser_version_major_minor": 124.0, + "os": "Windows", + "os_version": "10", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.004274834652184009, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/120.0.2210.150 Version/17.0 Mobile/15E148 Safari/604.1", + "percent": 0.004274409712929692, + "type": "mobile", + "device_brand": "Apple", + "browser": "Edge Mobile", + "browser_version": "120.0.2210.150", + "browser_version_major_minor": 120.0, + "os": "iOS", + "os_version": "17.3", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.004274059023153519, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004272296248678862, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004271786255204744, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.004271773571261116, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0", + "percent": 0.004270189737466844, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "128.0", + "browser_version_major_minor": 128.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004267021598214913, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004266568057418735, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0042662175150930525, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004265719990072675, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0042638803425842425, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004262941397454317, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004261348818253515, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004259779622847621, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004259127887700674, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004259049487693077, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004258089579593729, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004256715153278348, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", + "percent": 0.004255866473329678, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "124.0.0.0", + "browser_version_major_minor": 124.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004255773757343515, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004255736172394402, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004255500911598356, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004255499527697771, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004255220879500109, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004254998776927012, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004254407625914647, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0042541553576929255, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.0042521686382427665, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004251667408586899, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.004251264687955807, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.004250965616768225, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00424926026627734, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.00424865612832209, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004248274668635272, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004248028568901506, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.7", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004247876970405884, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004247396904440911, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004247181823906909, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0042469730107965184, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004246145482522, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004243675122443905, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.0042434631880810545, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.00424226826379831, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004241925525106016, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004241786764061368, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004241397034113522, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004241387086220059, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0042404303526633845, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004238815402716011, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.00423872578165148, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004238205568980488, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004237539017884467, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004235300776314142, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004234776505212401, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00423287289822615, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0042320763684707795, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004231781130853986, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004231030222667948, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004230779431068094, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004230588652280862, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0042293339975745465, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004228514597415184, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0042280751391275025, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004227701375516222, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004227070453499589, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004226951066420081, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004226679315354478, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "percent": 0.004224558262536379, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004224086360334656, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004224057432813821, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.004223525853232865, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/132.0.6834.14 Mobile/15E148 Safari/604.1", + "percent": 0.004223159760826286, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "132.0.6834.14", + "browser_version_major_minor": 132.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004222941394810857, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "percent": 0.00422035277986403, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.6.6", + "browser_version_major_minor": 15.6, + "os": "iOS", + "os_version": "15.8.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0042188949770088965, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0042188762174135, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004217331562370165, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.004215849531086911, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004215676508191726, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004214920623952929, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0042146649451172274, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004214190053548804, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004213427779433424, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004212650697287153, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "percent": 0.004212153151189505, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004212074620894447, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.00421192963739527, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0042116849294628654, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 OPR/85.0.0.0", + "percent": 0.004211674039120818, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Opera Mobile", + "browser_version": "85.0.0", + "browser_version_major_minor": 85.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0042100409103464215, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00420991284401507, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004209067861825117, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "percent": 0.004207239388054822, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Edge Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004206249333395048, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004206030224200311, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + "percent": 0.004205659316098683, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.0", + "browser_version_major_minor": 17.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004205464971214516, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004205290147163987, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004204674457365567, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004203009724601462, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0042021670568514954, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004201965316196011, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00420188205284786, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0042016735192155034, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0042012962011863395, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004201233611926087, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004200562086825182, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004199621687372423, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0041987846573878825, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004198751376268264, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "percent": 0.00419870784138586, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "132.1", + "browser_version_major_minor": 132.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004198388772171773, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.3 Safari/605.1.15", + "percent": 0.004196019490669731, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "13.1.3", + "browser_version_major_minor": 13.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004195913902599903, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004194185128806184, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004193977877543034, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.88 Mobile/15E148 Safari/604.1", + "percent": 0.004192807776980288, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "124.0.6367.88", + "browser_version_major_minor": 124.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "percent": 0.004192524696281822, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004192441468807899, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004191868235979819, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004190867879530852, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.004190812299845372, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004190213233980577, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.0041897803195244734, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004189636576651816, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.004186930236897957, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00418599001040462, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004184516436370359, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004184221129475787, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.0041841794877800174, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004181879163477238, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004180791797863813, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004179178653712497, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004177710839846275, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004176899285071239, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0041763243857378856, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00417602737170547, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004175839251030786, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004175685924496537, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004175071959795217, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004173687284638548, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004172790339340998, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004172479922120494, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0041724285601033705, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004171433542750444, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0041684222265604065, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004167557974746277, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004166245701557782, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004166158395808278, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004165399011650072, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004163784397441274, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004163424360837294, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004162379151990641, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004162324795912399, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004162252611739699, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004161260575402656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.00416083360736693, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0041600915239145605, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004159695345783745, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.0041595567466039375, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004158972498223416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004157970070804033, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.004157348841025375, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004156046672599977, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004155994054741126, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0041557997187569055, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00415524541286829, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004155079993062305, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.0041537064128031554, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.8", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004153369145841688, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004152432644488122, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004152054672601546, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.004151118283303227, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0041510378270770325, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0041479543523283225, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004147523266867055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004147419709750286, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.004145734889693111, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0041454752500972745, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004144882216649725, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "percent": 0.004144507273243295, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.8.1", + "browser_version_major_minor": 17.8, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004143725041128067, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0041436495550915865, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004143604522372318, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004142434335595508, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004142292369406416, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004141418831027958, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004141170932858202, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004140820095364464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004138962191275559, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0041370741640836045, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004134825987816134, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004133810394675364, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004132497371954583, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1", + "percent": 0.0041305919060594795, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.5", + "browser_version_major_minor": 15.5, + "os": "iOS", + "os_version": "15.5", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004130586834109753, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.0041304157499699274, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00413035673904798, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004128480531309414, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004128018697230877, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0041273912067976565, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004126488408795229, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004126240356023747, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/298.0.595435837 Mobile/15E148 Safari/604.1", + "percent": 0.004124749516569371, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "298.0.595435837", + "browser_version_major_minor": 298.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.004124524337846381, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004124206590942732, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004122106624667135, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "percent": 0.004121490555133785, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004120588874682127, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004120147433384199, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.0041199218392151855, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004118768810469667, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004118760600366975, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004118709596468639, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0041183959497561005, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.004118343562632573, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.0041171996893957065, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004116114131388249, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0041159308031797565, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004115908585948935, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.004115290484288925, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/128.0.6613.92 Mobile/15E148 Safari/604.1", + "percent": 0.004115230903528915, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "128.0.6613.92", + "browser_version_major_minor": 128.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0041129575305519795, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004112849054674645, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004111351158047908, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0041101453541899586, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004108997582771988, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004108561860337442, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004108298137490502, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004108292747721092, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004106826057626676, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/93.0.4577.39 Mobile/15E148 Safari/604.1", + "percent": 0.004106479113679186, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "93.0.4577.39", + "browser_version_major_minor": 93.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004105220073523839, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004104969082981212, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.004104529434425732, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004103915359005667, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004103362350158131, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0040977020774029555, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004097381337565656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004096712834050418, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "percent": 0.004095980511701144, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.5", + "browser_version_major_minor": 17.5, + "os": "iOS", + "os_version": "17.5.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004095129726313416, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004094486753006377, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004093269517335123, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.0040926612855352274, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004092290165994552, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004091993090120374, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004091636215759704, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004091024218566927, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "percent": 0.00409077714178561, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.1", + "browser_version_major_minor": 16.1, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.004090583134596616, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004090226041505271, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0040889942385764315, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00408867306343144, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.004088251902561574, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004088090267759603, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004087339158099015, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004086713594068701, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004084125897792123, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004083991959912007, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004083524628286438, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0040834411090196684, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004082198665916085, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "percent": 0.004081327686670603, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "15.6.1", + "browser_version_major_minor": 15.6, + "os": "Mac OS X", + "os_version": "10.15.6", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004079138014539999, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004078759402350568, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004078735548480892, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004078213074833098, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004077806995905292, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004076071039635343, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004075701804346176, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0040755864180476616, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.004075034086798532, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004074803859724436, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00407408723812686, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004072630457680785, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004072599219983014, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004072561029302725, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004072227140412836, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004072182878898793, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004071931280497264, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004071185689238692, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.0040707558606715545, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004066439632792169, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004065573300396747, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004065184294789501, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004063130006427799, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004063081784887221, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004063048254183309, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004061938866668525, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004060476784208234, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "percent": 0.0040598933728849245, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Chrome OS", + "os_version": "14541.0.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004059037786728867, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004058336298098268, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004058329871626961, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1", + "percent": 0.00405808013141295, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "14.1.2", + "browser_version_major_minor": 14.1, + "os": "iOS", + "os_version": "14.8", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004057931795083638, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004057477638002367, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004057008901866489, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004056334827854764, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0040554726629379945, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004055351623836115, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004054823166157718, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 11; KFSNWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/130.4.1 like Chrome/130.0.6723.102 Safari/537.36", + "percent": 0.0040543677855323715, + "type": "tablet", + "device_brand": "Amazon", + "browser": "Amazon Silk", + "browser_version": "130.4.1", + "browser_version_major_minor": 130.4, + "os": "Android", + "os_version": "11", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0040533166315786205, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004053218237666651, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004052694262645091, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004050801928882354, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004050538758304056, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.004050320405434095, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0040502079145639915, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0040501599611867355, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00404843275142914, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004045553072983132, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004044987830637762, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004044935178573716, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004044760306751259, + "type": "tablet", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004044285827659451, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004043434219293897, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "percent": 0.004042123724492598, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004041934809040318, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004041904697456035, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004041695001347595, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004041081183905303, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004039260160791555, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "percent": 0.004038985242950108, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "126.0.0.0", + "browser_version_major_minor": 126.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004038120041340374, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "percent": 0.004037189075577917, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "iOS", + "os_version": "17.4.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.00403590959889765, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004035353125198967, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004035258553086047, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004034845745970041, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/20G81 Twitter for iPhone/10.68.1", + "percent": 0.004034003397251356, + "type": "mobile", + "device_brand": "Apple", + "browser": "Twitter", + "browser_version": "10.68", + "browser_version_major_minor": 10.68, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004033692382584422, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004033469197632583, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "percent": 0.004032411384729723, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004031304636979767, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.31 Mobile/15E148 Safari/604.1", + "percent": 0.004031213893779388, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.31", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004030951045939176, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0040308143812959815, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004030139970860204, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.0040282853056492025, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004027950640313509, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004027921598005747, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.004027593806370077, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/265.0.533000180 Mobile/15E148 Safari/604.1", + "percent": 0.004027333648150868, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "265.0.533000180", + "browser_version_major_minor": 265.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0040271243454041965, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0040270786657042685, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.004025827161162408, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.004025415346619854, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.10", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004023996637773476, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004023617648225532, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0040234758828386575, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004023103435753049, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004021847191798347, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + "percent": 0.004021347240871404, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004020616353920768, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004020406764356926, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004020124421272626, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004020081215064463, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.004019558952890433, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.004018738224117117, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.004018264545990398, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004018157409416443, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004017903267890472, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Mobile/15E148 Safari/604.1", + "percent": 0.004017560666430743, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "15.2", + "browser_version_major_minor": 15.2, + "os": "iOS", + "os_version": "15.2.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.004017533494350033, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.004016169885380932, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00401562430417616, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004015256671882493, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux aarch64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004015203288121469, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0040143615903264875, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004014174628010984, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004013605219041958, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004012735381555686, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0", + "percent": 0.004011056145534827, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "128.0.0.0", + "browser_version_major_minor": 128.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004010568077214651, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0040100494048174945, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004009882996678571, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0", + "percent": 0.004009483684661358, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "130.0", + "browser_version_major_minor": 130.0, + "os": "Ubuntu", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004005689904285559, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.004005482818977365, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004004299258072762, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004003904977208178, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004002405584426104, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.00400197272629327, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0040017840556822335, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.004001666383605679, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "percent": 0.004001380500446973, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "109.0.0.0", + "browser_version_major_minor": 109.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.004001369722918842, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.004001343270604766, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.004000790013033667, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "percent": 0.004000453990701001, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.2", + "browser_version_major_minor": 16.2, + "os": "iOS", + "os_version": "16.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003999418756483026, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003998808913473338, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0039987000235476656, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003998421573158822, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003998083541140304, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.003997789730744324, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "percent": 0.003997282292441414, + "type": "mobile", + "device_brand": "Apple", + "browser": "Firefox iOS", + "browser_version": "132.1", + "browser_version_major_minor": 132.1, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "percent": 0.003997273732209198, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003997254996984681, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0039970750472824645, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15", + "percent": 0.003996663733161861, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "16.6.1", + "browser_version_major_minor": 16.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.00399629889538843, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003995234656439962, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003994458076093435, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003994024839949809, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.003993656203046088, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003993050409938497, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.003991208588873277, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Android 14; Mobile; rv:134.0) Gecko/134.0 Firefox/134.0", + "percent": 0.0039911509479538245, + "type": "mobile", + "device_brand": "Generic", + "browser": "Firefox Mobile", + "browser_version": "134.0", + "browser_version_major_minor": 134.0, + "os": "Android", + "os_version": "14", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003990711771214586, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00399006283149675, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003989844045666132, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003989429768585661, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; CrOS x86_64 14989.107.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36", + "percent": 0.00398787673039127, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "105.0.0.0", + "browser_version_major_minor": 105.0, + "os": "Chrome OS", + "os_version": "14989.107.0", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003987476351332717, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.003987458310618529, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00398662529559787, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003986537056760973, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.0039861372028509034, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003986100388327339, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 OPR/85.0.0.0", + "percent": 0.003985444917264507, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Opera Mobile", + "browser_version": "85.0.0", + "browser_version_major_minor": 85.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003985195488402043, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "percent": 0.003984506319250262, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003984309596644756, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003982717187033429, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.003982694171694217, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "percent": 0.0039820178370728885, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.003981119585939333, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.7.4", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003979821242370048, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003979676966235526, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003978443797703039, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003978383714520724, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.00397654618586787, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003976442651456623, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003976320428372152, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003976117830661296, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.003975271536828576, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.003975195199039334, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003973983976207217, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003972760345113864, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003972316555926516, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003971410557508936, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00397130965854298, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003970323222110094, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.00396956258275273, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.003969480215844884, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003969476508139959, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003969370686664692, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003967562225807831, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.003966243122504108, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "percent": 0.003966186522658425, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00396560201946538, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "percent": 0.003965218015642408, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "133.0", + "browser_version_major_minor": 133.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003964759790926832, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "percent": 0.003963198579414981, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003962886184494306, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "percent": 0.003962444796113944, + "type": "desktop", + "device_brand": "Apple", + "browser": "Safari", + "browser_version": "17.4.1", + "browser_version_major_minor": 17.4, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003962331276950527, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003961322075782861, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003960872303258442, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.003960463117896991, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0039602944566444614, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6.1 Mobile/15E148 Safari/604.1", + "percent": 0.003959586188519775, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6.1", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003958939886180386, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.00395851686103672, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0039584189687958534, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003957912520958754, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003957794721120617, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0039566218163689165, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003955195087153507, + "type": "tablet", + "device_brand": "Generic_Android_Tablet", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0039544454445238995, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.003953553575167519, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "percent": 0.003953206013509476, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003951820519583544, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.00395173164735448, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.0039515180140326485, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003951067264525163, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003950898000707351, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003950840424996871, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "percent": 0.003950828248548164, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "129.0.0.0", + "browser_version_major_minor": 129.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003950615423472784, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003950178504822942, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "percent": 0.003948988804669655, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "125.0", + "browser_version_major_minor": 125.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00394841063158286, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "percent": 0.003947670004012934, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Samsung Internet", + "browser_version": "27.0", + "browser_version_major_minor": 27.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003945854402890446, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00394515639652377, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0039450174042288625, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0039449361823485464, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.003944544741091096, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "percent": 0.003942511424720452, + "type": "desktop", + "device_brand": null, + "browser": "Edge", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003942439614341594, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003941885003807249, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003941189979524623, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003940985516207905, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003940917018846936, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003940802758627401, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.003939593679803963, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003939570304676678, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003938552792612039, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.003937625823495048, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Linux", + "os_version": "", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003937164897085526, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003936822643404105, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003934867994623755, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003934272273116706, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003934023992942108, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "percent": 0.003933994035955942, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.2", + "browser_version_major_minor": 18.2, + "os": "iOS", + "os_version": "18.2", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "percent": 0.00393331005940407, + "type": "desktop", + "device_brand": "Apple", + "browser": "Chrome", + "browser_version": "130.0.0.0", + "browser_version_major_minor": 130.0, + "os": "Mac OS X", + "os_version": "10.15.7", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003932704799688588, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00393269487633997, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.003932527517637334, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.003931760429443248, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.003930476159996784, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "percent": 0.003928496342130432, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "344.0.697830199", + "browser_version_major_minor": 344.0, + "os": "iOS", + "os_version": "18.0", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003928142846584494, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0", + "percent": 0.003926047019377497, + "type": "desktop", + "device_brand": "Apple", + "browser": "Firefox", + "browser_version": "131.0", + "browser_version_major_minor": 131.0, + "os": "Mac OS X", + "os_version": "10.15", + "platform": "MacIntel" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003925825692797171, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.003925375153850306, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.003924984497412516, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003924477681194476, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003924454776653881, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00392252066110941, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.003922058367705871, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "percent": 0.003921370902837441, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.3", + "browser_version_major_minor": 16.3, + "os": "iOS", + "os_version": "16.3.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0039204275320294055, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003920401842917886, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003919332080440479, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003918713164036762, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0039171331113214925, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003916275330066742, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003915934021577436, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003914735543509766, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "percent": 0.003914158209796061, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Edge Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.003914022850432418, + "type": "tablet", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.5", + "platform": "iPad" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003913598989127326, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.003913294809310181, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "percent": 0.003912838404219182, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.8", + "browser_version_major_minor": 17.8, + "os": "iOS", + "os_version": "17.7.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "percent": 0.003912186612506118, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.0.1", + "browser_version_major_minor": 18.0, + "os": "iOS", + "os_version": "18.0.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003912056101798358, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.003911227778220333, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.00391046206997672, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003910216641880676, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0039088501512926435, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003908372013093459, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0039062195388542965, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0039053590871941457, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0039035472346790265, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003903342647557186, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.00390285033509148, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "percent": 0.0039026999876978544, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "114.0.0.0", + "browser_version_major_minor": 114.0, + "os": "Linux", + "os_version": "", + "platform": "Linux x86_64" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "percent": 0.003902631688011659, + "type": "mobile", + "device_brand": "Apple", + "browser": "Chrome Mobile iOS", + "browser_version": "131.0.6778.73", + "browser_version_major_minor": 131.0, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0039010050259947137, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "18.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "percent": 0.0038980433415814683, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "17.6", + "browser_version_major_minor": 17.6, + "os": "iOS", + "os_version": "17.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003897290018931463, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0038963441670868525, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.0038963290192416972, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.0038962722269285164, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003896153450977635, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1", + "percent": 0.0038950334304208825, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "13.0.3", + "browser_version_major_minor": 13.0, + "os": "iOS", + "os_version": "13.2.3", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "percent": 0.0038943330720697536, + "type": "mobile", + "device_brand": "Apple", + "browser": "Google", + "browser_version": "343.0.695551749", + "browser_version_major_minor": 343.0, + "os": "iOS", + "os_version": "17.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "percent": 0.0038943306203337693, + "type": "desktop", + "device_brand": null, + "browser": "Chrome", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "percent": 0.0038941762922062146, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "16.6", + "browser_version_major_minor": 16.6, + "os": "iOS", + "os_version": "16.6", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.0038930162369005166, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "percent": 0.003891428557284956, + "type": "mobile", + "device_brand": "Generic_Android", + "browser": "Chrome Mobile", + "browser_version": "131.0.0.0", + "browser_version_major_minor": 131.0, + "os": "Android", + "os_version": "10", + "platform": "Linux armv81" +} +{ + "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "percent": 0.003890059976645047, + "type": "mobile", + "device_brand": "Apple", + "browser": "Mobile Safari", + "browser_version": "18.1.1", + "browser_version_major_minor": 18.1, + "os": "iOS", + "os_version": "18.1.1", + "platform": "iPhone" +} +{ + "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "percent": 0.0038899089691452975, + "type": "desktop", + "device_brand": null, + "browser": "Firefox", + "browser_version": "132.0", + "browser_version_major_minor": 132.0, + "os": "Windows", + "os_version": "10", + "platform": "Win32" +} diff --git a/seanime-2.9.10/internal/util/date.go b/seanime-2.9.10/internal/util/date.go new file mode 100644 index 0000000..efd8295 --- /dev/null +++ b/seanime-2.9.10/internal/util/date.go @@ -0,0 +1,11 @@ +package util + +import ( + "fmt" + "time" +) + +func TimestampToDateStr(timestamp int64) string { + tm := time.Unix(timestamp, 0) + return fmt.Sprintf("%v", tm) +} diff --git a/seanime-2.9.10/internal/util/filecache/filecache.go b/seanime-2.9.10/internal/util/filecache/filecache.go new file mode 100644 index 0000000..4250e47 --- /dev/null +++ b/seanime-2.9.10/internal/util/filecache/filecache.go @@ -0,0 +1,483 @@ +package filecache + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/goccy/go-json" + "github.com/samber/lo" +) + +// CacheStore represents a single-process, file-based, key/value cache store. +type CacheStore struct { + filePath string + mu sync.Mutex + data map[string]*cacheItem +} + +// Bucket represents a cache bucket with a name and TTL. +type Bucket struct { + name string + ttl time.Duration +} + +type PermanentBucket struct { + name string +} + +func NewBucket(name string, ttl time.Duration) Bucket { + return Bucket{name: name, ttl: ttl} +} + +func (b *Bucket) Name() string { + return b.name +} + +func NewPermanentBucket(name string) PermanentBucket { + return PermanentBucket{name: name} +} + +func (b *PermanentBucket) Name() string { + return b.name +} + +// Cacher represents a single-process, file-based, key/value cache. +type Cacher struct { + dir string + stores map[string]*CacheStore + mu sync.Mutex +} + +type cacheItem struct { + Value interface{} `json:"value"` + Expiration *time.Time `json:"expiration,omitempty"` +} + +// NewCacher creates a new instance of Cacher. +func NewCacher(dir string) (*Cacher, error) { + // Check if the directory exists + _, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return nil, err + } + } else { + return nil, err + } + } + return &Cacher{ + stores: make(map[string]*CacheStore), + dir: dir, + }, nil +} + +// Close closes all the cache stores. +func (c *Cacher) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + for _, store := range c.stores { + if err := store.saveToFile(); err != nil { + return err + } + } + return nil +} + +// getStore returns a cache store for the given bucket name and TTL. +func (c *Cacher) getStore(name string) (*CacheStore, error) { + c.mu.Lock() + defer c.mu.Unlock() + + store, ok := c.stores[name] + if !ok { + store = &CacheStore{ + filePath: filepath.Join(c.dir, name+".cache"), + data: make(map[string]*cacheItem), + } + if err := store.loadFromFile(); err != nil { + return nil, err + } + c.stores[name] = store + } + return store, nil +} + +// Set sets the value for the given key in the given bucket. +func (c *Cacher) Set(bucket Bucket, key string, value interface{}) error { + store, err := c.getStore(bucket.name) + if err != nil { + return err + } + store.mu.Lock() + defer store.mu.Unlock() + store.data[key] = &cacheItem{Value: value, Expiration: lo.ToPtr(time.Now().Add(bucket.ttl))} + return store.saveToFile() +} + +func Range[T any](c *Cacher, bucket Bucket, f func(key string, value T) bool) error { + store, err := c.getStore(bucket.name) + if err != nil { + return err + } + store.mu.Lock() + defer store.mu.Unlock() + + for key, item := range store.data { + if item.Expiration != nil && time.Now().After(*item.Expiration) { + delete(store.data, key) + } else { + itemVal, err := json.Marshal(item.Value) + if err != nil { + return err + } + var out T + err = json.Unmarshal(itemVal, &out) + if err != nil { + return err + } + if !f(key, out) { + break + } + } + } + + return store.saveToFile() +} + +// Get retrieves the value for the given key from the given bucket. +func (c *Cacher) Get(bucket Bucket, key string, out interface{}) (bool, error) { + store, err := c.getStore(bucket.name) + if err != nil { + return false, err + } + store.mu.Lock() + defer store.mu.Unlock() + item, ok := store.data[key] + if !ok { + return false, nil + } + if item.Expiration != nil && time.Now().After(*item.Expiration) { + delete(store.data, key) + _ = store.saveToFile() // Ignore errors here + return false, nil + } + data, err := json.Marshal(item.Value) + if err != nil { + return false, err + } + return true, json.Unmarshal(data, out) +} + +func GetAll[T any](c *Cacher, bucket Bucket) (map[string]T, error) { + store, err := c.getStore(bucket.name) + if err != nil { + return nil, err + } + + data := make(map[string]T) + err = Range(c, bucket, func(key string, value T) bool { + data[key] = value + return true + }) + if err != nil { + return nil, err + } + + store.mu.Lock() + defer store.mu.Unlock() + + return data, store.saveToFile() +} + +// Delete deletes the value for the given key from the given bucket. +func (c *Cacher) Delete(bucket Bucket, key string) error { + store, err := c.getStore(bucket.name) + if err != nil { + return err + } + store.mu.Lock() + defer store.mu.Unlock() + delete(store.data, key) + return store.saveToFile() +} + +func DeleteIf[T any](c *Cacher, bucket Bucket, cond func(key string, value T) bool) error { + store, err := c.getStore(bucket.name) + if err != nil { + return err + } + store.mu.Lock() + defer store.mu.Unlock() + + for key, item := range store.data { + itemVal, err := json.Marshal(item.Value) + if err != nil { + return err + } + var out T + err = json.Unmarshal(itemVal, &out) + if err != nil { + return err + } + if cond(key, out) { + delete(store.data, key) + } + } + + return store.saveToFile() +} + +// Empty empties the given bucket. +func (c *Cacher) Empty(bucket Bucket) error { + store, err := c.getStore(bucket.name) + if err != nil { + return err + } + store.mu.Lock() + defer store.mu.Unlock() + store.data = make(map[string]*cacheItem) + return store.saveToFile() +} + +// Remove removes the given bucket. +func (c *Cacher) Remove(bucketName string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if _, ok := c.stores[bucketName]; ok { + delete(c.stores, bucketName) + } + + _ = os.Remove(filepath.Join(c.dir, bucketName+".cache")) + + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// SetPerm sets the value for the given key in the permanent bucket (no expiration). +func (c *Cacher) SetPerm(bucket PermanentBucket, key string, value interface{}) error { + store, err := c.getStore(bucket.name) + if err != nil { + return err + } + store.mu.Lock() + defer store.mu.Unlock() + store.data[key] = &cacheItem{Value: value, Expiration: nil} // No expiration + return store.saveToFile() +} + +// GetPerm retrieves the value for the given key from the permanent bucket (ignores expiration). +func (c *Cacher) GetPerm(bucket PermanentBucket, key string, out interface{}) (bool, error) { + store, err := c.getStore(bucket.name) + if err != nil { + return false, err + } + store.mu.Lock() + defer store.mu.Unlock() + item, ok := store.data[key] + if !ok { + return false, nil + } + data, err := json.Marshal(item.Value) + if err != nil { + return false, err + } + return true, json.Unmarshal(data, out) +} + +// DeletePerm deletes the value for the given key from the permanent bucket. +func (c *Cacher) DeletePerm(bucket PermanentBucket, key string) error { + store, err := c.getStore(bucket.name) + if err != nil { + return err + } + store.mu.Lock() + defer store.mu.Unlock() + delete(store.data, key) + return store.saveToFile() +} + +// EmptyPerm empties the permanent bucket. +func (c *Cacher) EmptyPerm(bucket PermanentBucket) error { + store, err := c.getStore(bucket.name) + if err != nil { + return err + } + store.mu.Lock() + defer store.mu.Unlock() + store.data = make(map[string]*cacheItem) + return store.saveToFile() +} + +// RemovePerm calls Remove. +func (c *Cacher) RemovePerm(bucketName string) error { + return c.Remove(bucketName) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (cs *CacheStore) loadFromFile() error { + file, err := os.Open(cs.filePath) + if err != nil { + if os.IsNotExist(err) { + return nil // File does not exist, so nothing to load + } + return fmt.Errorf("filecache: failed to open cache file: %w", err) + } + defer file.Close() + + if err := json.NewDecoder(file).Decode(&cs.data); err != nil { + // If decode fails (empty or corrupted file), initialize with empty data + cs.data = make(map[string]*cacheItem) + return nil + } + + return nil +} + +func (cs *CacheStore) saveToFile() error { + file, err := os.Create(cs.filePath) + if err != nil { + return fmt.Errorf("filecache: failed to create cache file: %w", err) + } + defer file.Close() + + if err := json.NewEncoder(file).Encode(cs.data); err != nil { + return fmt.Errorf("filecache: failed to encode cache data: %w", err) + } + return nil +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// RemoveAllBy removes all files in the cache directory that match the given filter. +func (c *Cacher) RemoveAllBy(filter func(filename string) bool) error { + c.mu.Lock() + defer c.mu.Unlock() + + err := filepath.Walk(c.dir, func(_ string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + if !strings.HasSuffix(info.Name(), ".cache") { + return nil + } + if filter(info.Name()) { + if err := os.Remove(filepath.Join(c.dir, info.Name())); err != nil { + return fmt.Errorf("filecache: failed to remove file: %w", err) + } + } + } + return nil + }) + + c.stores = make(map[string]*CacheStore) + return err +} + +// ClearMediastreamVideoFiles clears all mediastream video file caches. +func (c *Cacher) ClearMediastreamVideoFiles() error { + c.mu.Lock() + + // Remove the contents of the directory + files, err := os.ReadDir(filepath.Join(c.dir, "videofiles")) + if err != nil { + c.mu.Unlock() + return nil + } + for _, file := range files { + _ = os.RemoveAll(filepath.Join(c.dir, "videofiles", file.Name())) + } + c.mu.Unlock() + + err = c.RemoveAllBy(func(filename string) bool { + return strings.HasPrefix(filename, "mediastream") + }) + + c.mu.Lock() + c.stores = make(map[string]*CacheStore) + c.mu.Unlock() + return err +} + +// TrimMediastreamVideoFiles clears all mediastream video file caches if the number of files exceeds the given limit. +func (c *Cacher) TrimMediastreamVideoFiles() error { + c.mu.Lock() + defer c.mu.Unlock() + + // Remove the contents of the "videofiles" cache directory + files, err := os.ReadDir(filepath.Join(c.dir, "videofiles")) + if err != nil { + return nil + } + + // If the number of files exceeds 10, remove all files + if len(files) > 10 { + for _, file := range files { + _ = os.RemoveAll(filepath.Join(c.dir, "videofiles", file.Name())) + } + } + + c.stores = make(map[string]*CacheStore) + return err +} + +func (c *Cacher) GetMediastreamVideoFilesTotalSize() (int64, error) { + c.mu.Lock() + defer c.mu.Unlock() + + _, err := os.Stat(filepath.Join(c.dir, "videofiles")) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + + var totalSize int64 + err = filepath.Walk(filepath.Join(c.dir, "videofiles"), func(_ string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + totalSize += info.Size() + } + return nil + }) + if err != nil { + return 0, fmt.Errorf("filecache: failed to walk the cache directory: %w", err) + } + + return totalSize, nil +} + +// GetTotalSize returns the total size of all files in the cache directory that match the given filter. +// The size is in bytes. +func (c *Cacher) GetTotalSize() (int64, error) { + c.mu.Lock() + defer c.mu.Unlock() + + var totalSize int64 + err := filepath.Walk(c.dir, func(_ string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + totalSize += info.Size() + } + return nil + }) + + if err != nil { + return 0, fmt.Errorf("filecache: failed to walk the cache directory: %w", err) + } + + return totalSize, nil +} diff --git a/seanime-2.9.10/internal/util/filecache/filecache_test.go b/seanime-2.9.10/internal/util/filecache/filecache_test.go new file mode 100644 index 0000000..df9810b --- /dev/null +++ b/seanime-2.9.10/internal/util/filecache/filecache_test.go @@ -0,0 +1,162 @@ +package filecache + +import ( + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "path/filepath" + "seanime/internal/test_utils" + "sync" + "testing" + "time" +) + +func TestCacherFunctions(t *testing.T) { + test_utils.InitTestProvider(t) + + tempDir := t.TempDir() + t.Log(tempDir) + + cacher, err := NewCacher(filepath.Join(tempDir, "cache")) + require.NoError(t, err) + + bucket := Bucket{ + name: "test", + ttl: 10 * time.Second, + } + + keys := []string{"key1", "key2", "key3"} + + type valStruct = struct { + Name string + } + + values := []*valStruct{ + { + Name: "value1", + }, + { + Name: "value2", + }, + { + Name: "value3", + }, + } + + for i, key := range keys { + err = cacher.Set(bucket, key, values[i]) + if err != nil { + t.Fatalf("Failed to set the value: %v", err) + } + } + + allVals, err := GetAll[*valStruct](cacher, bucket) + if err != nil { + t.Fatalf("Failed to get all values: %v", err) + } + + if len(allVals) != len(keys) { + t.Fatalf("Failed to get all values: expected %d, got %d", len(keys), len(allVals)) + } + + spew.Dump(allVals) +} + +func TestCacherSetAndGet(t *testing.T) { + test_utils.InitTestProvider(t) + + tempDir := t.TempDir() + t.Log(tempDir) + + cacher, err := NewCacher(filepath.Join(test_utils.ConfigData.Path.DataDir, "cache")) + + bucket := Bucket{ + name: "test", + ttl: 4 * time.Second, + } + key := "key" + value := struct { + Name string + }{ + Name: "value", + } + // Add "key" -> value to the bucket, with a TTL of 4 seconds + err = cacher.Set(bucket, key, value) + if err != nil { + t.Fatalf("Failed to set the value: %v", err) + } + + var out struct { + Name string + } + // Get the value of "key" from the bucket, it shouldn't be expired + found, err := cacher.Get(bucket, key, &out) + if err != nil { + t.Errorf("Failed to get the value: %v", err) + } + if !found || !assert.Equal(t, value, out) { + t.Errorf("Failed to get the correct value. Expected %v, got %v", value, out) + } + + spew.Dump(out) + + time.Sleep(3 * time.Second) + + // Get the value of "key" from the bucket again, it shouldn't be expired + found, err = cacher.Get(bucket, key, &out) + if !found { + t.Errorf("Failed to get the value") + } + if !found || out != value { + t.Errorf("Failed to get the correct value. Expected %v, got %v", value, out) + } + + spew.Dump(out) + + // Spin up a goroutine to set "key2" -> value2 to the bucket, with a TTL of 1 second + // cacher should be thread-safe + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + key2 := "key2" + value2 := struct { + Name string + }{ + Name: "value2", + } + var out2 struct { + Name string + } + err = cacher.Set(bucket, key2, value2) + if err != nil { + t.Errorf("Failed to set the value: %v", err) + } + + found, err = cacher.Get(bucket, key2, &out2) + if err != nil { + t.Errorf("Failed to get the value: %v", err) + } + + if !found || !assert.Equal(t, value2, out2) { + t.Errorf("Failed to get the correct value. Expected %v, got %v", value2, out2) + } + + _ = cacher.Delete(bucket, key2) + + spew.Dump(out2) + + }() + + time.Sleep(2 * time.Second) + + // Get the value of "key" from the bucket, it should be expired + found, _ = cacher.Get(bucket, key, &out) + if found { + t.Errorf("Failed to delete the value") + spew.Dump(out) + } + + wg.Wait() + +} diff --git a/seanime-2.9.10/internal/util/fs.go b/seanime-2.9.10/internal/util/fs.go new file mode 100644 index 0000000..1800a8b --- /dev/null +++ b/seanime-2.9.10/internal/util/fs.go @@ -0,0 +1,419 @@ +package util + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/nwaples/rardecode/v2" +) + +func DirSize(path string) (uint64, error) { + var size int64 + err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + size += info.Size() + } + return err + }) + return uint64(size), err +} + +func IsValidMediaFile(path string) bool { + return !strings.HasPrefix(path, "._") +} +func IsValidVideoExtension(ext string) bool { + validExtensions := map[string]struct{}{ + ".mp4": {}, ".avi": {}, ".mkv": {}, ".mov": {}, ".flv": {}, ".wmv": {}, ".webm": {}, + ".mpeg": {}, ".mpg": {}, ".m4v": {}, ".3gp": {}, ".3g2": {}, ".ogg": {}, ".ogv": {}, + ".vob": {}, ".mts": {}, ".m2ts": {}, ".ts": {}, ".f4v": {}, ".ogm": {}, ".rm": {}, + ".rmvb": {}, ".drc": {}, ".yuv": {}, ".asf": {}, ".amv": {}, ".m2v": {}, ".mpe": {}, + ".mpv": {}, ".mp2": {}, ".svi": {}, ".mxf": {}, ".roq": {}, ".nsv": {}, ".f4p": {}, + ".f4a": {}, ".f4b": {}, + } + ext = strings.ToLower(ext) + _, exists := validExtensions[ext] + return exists +} + +func IsSubdirectory(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return rel != "." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} + +func IsSubdirectoryOfAny(dirs []string, child string) bool { + for _, dir := range dirs { + if IsSubdirectory(dir, child) { + return true + } + } + return false +} + +func IsSameDir(dir1, dir2 string) bool { + if runtime.GOOS == "windows" { + dir1 = strings.ToLower(dir1) + dir2 = strings.ToLower(dir2) + } + + absDir1, err := filepath.Abs(dir1) + if err != nil { + return false + } + absDir2, err := filepath.Abs(dir2) + if err != nil { + return false + } + return absDir1 == absDir2 +} + +func IsFileUnderDir(filePath, dir string) bool { + // Get the absolute path of the file + absFilePath, err := filepath.Abs(filePath) + if err != nil { + return false + } + + // Get the absolute path of the directory + absDir, err := filepath.Abs(dir) + if err != nil { + return false + } + + if runtime.GOOS == "windows" { + absFilePath = strings.ToLower(absFilePath) + absDir = strings.ToLower(absDir) + } + + // Check if the file path starts with the directory path + return strings.HasPrefix(absFilePath, absDir+string(os.PathSeparator)) +} + +// UnzipFile unzips a file to the destination. +// +// Example: +// // If "file.zip" contains `folder > file.text` +// UnzipFile("file.zip", "/path/to/dest") // -> "/path/to/dest/folder/file.txt" +// // If "file.zip" contains `file.txt` +// UnzipFile("file.zip", "/path/to/dest") // -> "/path/to/dest/file.txt" +func UnzipFile(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return fmt.Errorf("failed to open zip file: %w", err) + } + defer r.Close() + + // Create a temporary folder to extract the files + extractedDir, err := os.MkdirTemp(filepath.Dir(dest), ".extracted-") + if err != nil { + return fmt.Errorf("failed to create temp folder: %w", err) + } + defer os.RemoveAll(extractedDir) + + // Iterate through the files in the archive + for _, f := range r.File { + // Get the full path of the file in the destination + fpath := filepath.Join(extractedDir, f.Name) + // If the file is a directory, create it in the destination + if f.FileInfo().IsDir() { + _ = os.MkdirAll(fpath, os.ModePerm) + continue + } + // Make sure the parent directory exists (will not return an error if it already exists) + if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + // Open the file in the destination + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + // Open the file in the archive + rc, err := f.Open() + if err != nil { + _ = outFile.Close() + return fmt.Errorf("failed to open file in archive: %w", err) + } + + // Copy the file from the archive to the destination + _, err = io.Copy(outFile, rc) + _ = outFile.Close() + _ = rc.Close() + + if err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + } + + // Ensure the destination directory exists + if err := os.MkdirAll(dest, os.ModePerm); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // Move the contents of the extracted directory to the destination + entries, err := os.ReadDir(extractedDir) + if err != nil { + return fmt.Errorf("failed to read extracted directory: %w", err) + } + + for _, entry := range entries { + srcPath := filepath.Join(extractedDir, entry.Name()) + destPath := filepath.Join(dest, entry.Name()) + + // Remove existing file/directory at destination if it exists + _ = os.RemoveAll(destPath) + + // Move the file/directory to the destination + if err := os.Rename(srcPath, destPath); err != nil { + return fmt.Errorf("failed to move extracted item %s: %w", entry.Name(), err) + } + } + + return nil +} + +// UnrarFile unzips a rar file to the destination. +func UnrarFile(src, dest string) error { + r, err := rardecode.OpenReader(src) + if err != nil { + return fmt.Errorf("failed to open rar file: %w", err) + } + defer r.Close() + + // Create a temporary folder to extract the files + extractedDir, err := os.MkdirTemp(filepath.Dir(dest), ".extracted-") + if err != nil { + return fmt.Errorf("failed to create temp folder: %w", err) + } + defer os.RemoveAll(extractedDir) + + // Iterate through the files in the archive + for { + header, err := r.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to get next file in archive: %w", err) + } + + // Get the full path of the file in the destination + fpath := filepath.Join(extractedDir, header.Name) + // If the file is a directory, create it in the destination + if header.IsDir { + _ = os.MkdirAll(fpath, os.ModePerm) + continue + } + + // Make sure the parent directory exists (will not return an error if it already exists) + if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + // Open the file in the destination + outFile, err := os.Create(fpath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + // Copy the file from the archive to the destination + _, err = io.Copy(outFile, r) + outFile.Close() + + if err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + } + + // Ensure the destination directory exists + if err := os.MkdirAll(dest, os.ModePerm); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // Move the contents of the extracted directory to the destination + entries, err := os.ReadDir(extractedDir) + if err != nil { + return fmt.Errorf("failed to read extracted directory: %w", err) + } + + for _, entry := range entries { + srcPath := filepath.Join(extractedDir, entry.Name()) + destPath := filepath.Join(dest, entry.Name()) + + // Remove existing file/directory at destination if it exists + _ = os.RemoveAll(destPath) + + // Move the file/directory to the destination + if err := os.Rename(srcPath, destPath); err != nil { + return fmt.Errorf("failed to move extracted item %s: %w", entry.Name(), err) + } + } + + return nil +} + +// MoveToDestination moves a folder or file to the destination +// +// Example: +// MoveToDestination("/path/to/src/folder", "/path/to/dest") // -> "/path/to/dest/folder" +func MoveToDestination(src, dest string) error { + // Ensure the destination folder exists + if _, err := os.Stat(dest); os.IsNotExist(err) { + err := os.MkdirAll(dest, os.ModePerm) + if err != nil { + return fmt.Errorf("failed to create destination folder: %v", err) + } + } + + destFolder := filepath.Join(dest, filepath.Base(src)) + + // Move the folder by renaming it + err := os.Rename(src, destFolder) + if err != nil { + return fmt.Errorf("failed to move folder: %v", err) + } + + return nil +} + +// UnwrapAndMove moves the last subfolder containing the files to the destination. +// If there is a single file, it will move that file only. +// +// Example: +// +// Case 1: +// src/ +// - Anime/ +// - Ep1.mkv +// - Ep2.mkv +// UnwrapAndMove("/path/to/src", "/path/to/dest") // -> "/path/to/dest/Anime" +// +// Case 2: +// src/ +// - {HASH}/ +// - Anime/ +// - Ep1.mkv +// - Ep2.mkv +// UnwrapAndMove("/path/to/src", "/path/to/dest") // -> "/path/to/dest/Anime" +// +// Case 3: +// src/ +// - {HASH}/ +// - Anime/ +// - Ep1.mkv +// UnwrapAndMove("/path/to/src", "/path/to/dest") // -> "/path/to/dest/Ep1.mkv" +// +// Case 4: +// src/ +// - {HASH}/ +// - Anime/ +// - Anime 1/ +// - Ep1.mkv +// - Ep2.mkv +// - Anime 2/ +// - Ep1.mkv +// - Ep2.mkv +// UnwrapAndMove("/path/to/src", "/path/to/dest") // -> "/path/to/dest/Anime" +func UnwrapAndMove(src, dest string) error { + // Ensure the source and destination directories exist + if _, err := os.Stat(src); os.IsNotExist(err) { + return fmt.Errorf("source directory does not exist: %s", src) + } + _ = os.MkdirAll(dest, os.ModePerm) + + srcEntries, err := os.ReadDir(src) + if err != nil { + return err + } + + // If the source folder contains multiple files or folders, move its contents to the destination + if len(srcEntries) > 1 { + for _, srcEntry := range srcEntries { + err := MoveToDestination(filepath.Join(src, srcEntry.Name()), dest) + if err != nil { + return err + } + } + return nil + } + + folderMap := make(map[string]int) + err = FindFolderChildCount(src, folderMap) + if err != nil { + return err + } + + var folderToMove string + for folder, count := range folderMap { + if count > 1 { + if folderToMove == "" || len(folder) < len(folderToMove) { + folderToMove = folder + } + continue + } + } + + // It's a single file, move that file only + if folderToMove == "" { + fp := GetDeepestFile(src) + if fp == "" { + return fmt.Errorf("no files found in the source directory") + } + return MoveToDestination(fp, dest) + } + + // Move the folder containing multiple files or folders + err = MoveToDestination(folderToMove, dest) + if err != nil { + return err + } + + return nil +} + +// Finds the folder to move to the destination +func FindFolderChildCount(src string, folderMap map[string]int) error { + srcEntries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, srcEntry := range srcEntries { + folderMap[src]++ + if srcEntry.IsDir() { + err = FindFolderChildCount(filepath.Join(src, srcEntry.Name()), folderMap) + if err != nil { + return err + } + } + } + + return nil +} + +func GetDeepestFile(src string) (fp string) { + srcEntries, err := os.ReadDir(src) + if err != nil { + return "" + } + + for _, srcEntry := range srcEntries { + if srcEntry.IsDir() { + return GetDeepestFile(filepath.Join(src, srcEntry.Name())) + } + return filepath.Join(src, srcEntry.Name()) + } + + return "" +} diff --git a/seanime-2.9.10/internal/util/fs_test.go b/seanime-2.9.10/internal/util/fs_test.go new file mode 100644 index 0000000..1c750c5 --- /dev/null +++ b/seanime-2.9.10/internal/util/fs_test.go @@ -0,0 +1,91 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidVideoExtension(t *testing.T) { + tests := []struct { + ext string + expected bool + }{ + {ext: ".mp4", expected: true}, + {ext: ".avi", expected: true}, + {ext: ".mkv", expected: true}, + {ext: ".mov", expected: true}, + {ext: ".unknown", expected: false}, + {ext: ".MP4", expected: true}, + {ext: ".AVI", expected: true}, + {ext: "", expected: false}, + } + + for _, test := range tests { + t.Run(test.ext, func(t *testing.T) { + result := IsValidVideoExtension(test.ext) + require.Equal(t, test.expected, result) + }) + } +} + +func TestSubdirectory(t *testing.T) { + tests := []struct { + parent string + child string + expected bool + }{ + {parent: "C:\\parent", child: "C:\\parent\\child", expected: true}, + {parent: "C:\\parent", child: "C:\\parent\\child.txt", expected: true}, + {parent: "C:\\parent", child: "C:/PARENT/child.txt", expected: true}, + {parent: "C:\\parent", child: "C:\\parent\\..\\child", expected: false}, + {parent: "C:\\parent", child: "C:\\parent", expected: false}, + } + + for _, test := range tests { + t.Run(test.child, func(t *testing.T) { + result := IsSubdirectory(test.parent, test.child) + require.Equal(t, test.expected, result) + }) + } +} + +func TestIsFileUnderDir(t *testing.T) { + tests := []struct { + parent string + child string + expected bool + }{ + {parent: "C:\\parent", child: "C:\\parent\\child", expected: true}, + {parent: "C:\\parent", child: "C:\\parent\\child.txt", expected: true}, + {parent: "C:\\parent", child: "C:/PARENT/child.txt", expected: true}, + {parent: "C:\\parent", child: "C:\\parent\\..\\child", expected: false}, + {parent: "C:\\parent", child: "C:\\parent", expected: false}, + } + + for _, test := range tests { + t.Run(test.child, func(t *testing.T) { + result := IsFileUnderDir(test.parent, test.child) + require.Equal(t, test.expected, result) + }) + } +} + +func TestSameDir(t *testing.T) { + tests := []struct { + dir1 string + dir2 string + expected bool + }{ + {dir1: "C:\\dir", dir2: "C:\\dir", expected: true}, + {dir1: "C:\\dir", dir2: "C:\\DIR", expected: true}, + {dir1: "C:\\dir1", dir2: "C:\\dir2", expected: false}, + } + + for _, test := range tests { + t.Run(test.dir2, func(t *testing.T) { + result := IsSameDir(test.dir1, test.dir2) + require.Equal(t, test.expected, result) + }) + } +} diff --git a/seanime-2.9.10/internal/util/goja/async.go b/seanime-2.9.10/internal/util/goja/async.go new file mode 100644 index 0000000..d3aecb0 --- /dev/null +++ b/seanime-2.9.10/internal/util/goja/async.go @@ -0,0 +1,41 @@ +package goja_util + +import ( + "fmt" + "time" + + "github.com/dop251/goja" +) + +// BindAwait binds the $await function to the Goja runtime. +// Hooks don't wait for promises to resolve, so $await is used to wrap a promise and wait for it to resolve. +func BindAwait(vm *goja.Runtime) { + vm.Set("$await", func(promise goja.Value) (goja.Value, error) { + if promise, ok := promise.Export().(*goja.Promise); ok { + doneCh := make(chan struct{}) + + // Wait for the promise to resolve + go func() { + for promise.State() == goja.PromiseStatePending { + time.Sleep(10 * time.Millisecond) + } + close(doneCh) + }() + + <-doneCh + + // If the promise is rejected, return the error + if promise.State() == goja.PromiseStateRejected { + err := promise.Result() + return nil, fmt.Errorf("promise rejected: %v", err) + } + + // If the promise is fulfilled, return the result + res := promise.Result() + return res, nil + } + + // If the promise is not a Goja promise, return the value as is + return promise, nil + }) +} diff --git a/seanime-2.9.10/internal/util/goja/mutable.go b/seanime-2.9.10/internal/util/goja/mutable.go new file mode 100644 index 0000000..31ab73e --- /dev/null +++ b/seanime-2.9.10/internal/util/goja/mutable.go @@ -0,0 +1,228 @@ +package goja_util + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/dop251/goja" +) + +func BindMutable(vm *goja.Runtime) { + vm.Set("$mutable", vm.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) { + return vm.NewObject() + } + + // Convert the input to a map first + jsonBytes, err := json.Marshal(call.Arguments[0].Export()) + if err != nil { + panic(vm.NewTypeError("Failed to marshal input: %v", err)) + } + + var objMap map[string]interface{} + if err := json.Unmarshal(jsonBytes, &objMap); err != nil { + panic(vm.NewTypeError("Failed to unmarshal input: %v", err)) + } + + // Create a new object with getters and setters + obj := vm.NewObject() + + for key, val := range objMap { + // Capture current key and value + k, v := key, val + + if mapVal, ok := v.(map[string]interface{}); ok { + // For nested objects, create a new mutable object + nestedObj := vm.NewObject() + + // Add get method + nestedObj.Set("get", vm.ToValue(func() interface{} { + return mapVal + })) + + // Add set method + nestedObj.Set("set", vm.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 { + newVal := call.Arguments[0].Export() + if newMap, ok := newVal.(map[string]interface{}); ok { + mapVal = newMap + objMap[k] = newMap + } + } + return goja.Undefined() + })) + + // Add direct property access + for mk, mv := range mapVal { + // Capture map key and value + mapKey := mk + mapValue := mv + nestedObj.DefineAccessorProperty(mapKey, vm.ToValue(func() interface{} { + return mapValue + }), vm.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 { + mapVal[mapKey] = call.Arguments[0].Export() + } + return goja.Undefined() + }), goja.FLAG_FALSE, goja.FLAG_TRUE) + } + + obj.Set(k, nestedObj) + } else if arrVal, ok := v.([]interface{}); ok { + // For arrays, create a proxy object that allows index access + arrObj := vm.NewObject() + for i, av := range arrVal { + idx := i + val := av + arrObj.DefineAccessorProperty(fmt.Sprintf("%d", idx), vm.ToValue(func() interface{} { + return val + }), vm.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 { + arrVal[idx] = call.Arguments[0].Export() + objMap[k] = arrVal + } + return goja.Undefined() + }), goja.FLAG_FALSE, goja.FLAG_TRUE) + } + arrObj.Set("length", len(arrVal)) + + // Add explicit get/set methods for arrays + arrObj.Set("get", vm.ToValue(func() interface{} { + return arrVal + })) + arrObj.Set("set", vm.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 { + newVal := call.Arguments[0].Export() + if newArr, ok := newVal.([]interface{}); ok { + arrVal = newArr + objMap[k] = newArr + arrObj.Set("length", len(newArr)) + } + } + return goja.Undefined() + })) + obj.Set(k, arrObj) + } else { + // For primitive values, create simple getter/setter + obj.DefineAccessorProperty(k, vm.ToValue(func() interface{} { + return objMap[k] + }), vm.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 { + objMap[k] = call.Arguments[0].Export() + } + return goja.Undefined() + }), goja.FLAG_FALSE, goja.FLAG_TRUE) + } + } + + // Add a toJSON method that creates a fresh copy + obj.Set("toJSON", vm.ToValue(func() interface{} { + // Convert to JSON and back to create a fresh copy with no shared references + jsonBytes, err := json.Marshal(objMap) + if err != nil { + panic(vm.NewTypeError("Failed to marshal to JSON: %v", err)) + } + + var freshCopy interface{} + if err := json.Unmarshal(jsonBytes, &freshCopy); err != nil { + panic(vm.NewTypeError("Failed to unmarshal from JSON: %v", err)) + } + + return freshCopy + })) + + // Add a replace method to completely replace a Go struct's contents. + // Usage in JS: mutableAnime.replace(e.anime) + obj.Set("replace", vm.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(vm.NewTypeError("replace requires one argument: target")) + } + + // Use the current internal state. + jsonBytes, err := json.Marshal(objMap) + if err != nil { + panic(vm.NewTypeError("Failed to marshal state: %v", err)) + } + + // Get the reflect.Value of the target pointer + target := call.Arguments[0].Export() + targetVal := reflect.ValueOf(target) + if targetVal.Kind() != reflect.Ptr { + // panic(vm.NewTypeError("Target must be a pointer")) + return goja.Undefined() + } + + // Create a new instance of the target type and unmarshal into it + newVal := reflect.New(targetVal.Elem().Type()) + if err := json.Unmarshal(jsonBytes, newVal.Interface()); err != nil { + panic(vm.NewTypeError("Failed to unmarshal into target: %v", err)) + } + + // Replace the contents of the target with the new value + targetVal.Elem().Set(newVal.Elem()) + + return goja.Undefined() + })) + + return obj + })) + + // Add replace function to completely replace a Go struct's contents + vm.Set("$replace", vm.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + panic(vm.NewTypeError("replace requires two arguments: target and source")) + } + + target := call.Arguments[0].Export() + source := call.Arguments[1].Export() + + // Marshal source to JSON + sourceJSON, err := json.Marshal(source) + if err != nil { + panic(vm.NewTypeError("Failed to marshal source: %v", err)) + } + + // Get the reflect.Value of the target pointer + targetVal := reflect.ValueOf(target) + if targetVal.Kind() != reflect.Ptr { + // panic(vm.NewTypeError("Target must be a pointer")) + // TODO: Handle non-pointer targets + return goja.Undefined() + } + + // Create a new instance of the target type + newVal := reflect.New(targetVal.Elem().Type()) + + // Unmarshal JSON into the new instance + if err := json.Unmarshal(sourceJSON, newVal.Interface()); err != nil { + panic(vm.NewTypeError("Failed to unmarshal into target: %v", err)) + } + + // Replace the contents of the target with the new value + targetVal.Elem().Set(newVal.Elem()) + + return goja.Undefined() + })) + + vm.Set("$clone", vm.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) == 0 { + return goja.Undefined() + } + + // First convert to JSON to strip all pointers and references + jsonBytes, err := json.Marshal(call.Arguments[0].Export()) + if err != nil { + panic(vm.NewTypeError("Failed to marshal input: %v", err)) + } + + // Then unmarshal into a fresh interface{} to get a completely new object + var newObj interface{} + if err := json.Unmarshal(jsonBytes, &newObj); err != nil { + panic(vm.NewTypeError("Failed to unmarshal input: %v", err)) + } + + // Convert back to a goja value + return vm.ToValue(newObj) + })) +} diff --git a/seanime-2.9.10/internal/util/goja/scheduler.go b/seanime-2.9.10/internal/util/goja/scheduler.go new file mode 100644 index 0000000..5babf5b --- /dev/null +++ b/seanime-2.9.10/internal/util/goja/scheduler.go @@ -0,0 +1,234 @@ +package goja_util + +import ( + "context" + "fmt" + "runtime/debug" + "sync" + "time" + + "github.com/samber/mo" +) + +// Job represents a task to be executed in the VM +type Job struct { + fn func() error + resultCh chan error + async bool // Flag to indicate if the job is async (doesn't need to wait for result) +} + +// Scheduler handles all VM operations added concurrently in a single goroutine +// Any goroutine that needs to execute a VM operation must schedule it because the UI VM isn't thread safe +type Scheduler struct { + jobQueue chan *Job + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + // Track the currently executing job to detect nested scheduling + currentJob *Job + currentJobLock sync.Mutex + + onException mo.Option[func(err error)] +} + +func NewScheduler() *Scheduler { + ctx, cancel := context.WithCancel(context.Background()) + s := &Scheduler{ + jobQueue: make(chan *Job, 9999), + ctx: ctx, + onException: mo.None[func(err error)](), + cancel: cancel, + } + + s.start() + return s +} + +func (s *Scheduler) SetOnException(onException func(err error)) { + s.onException = mo.Some(onException) +} + +func (s *Scheduler) start() { + s.wg.Add(1) + go func() { + defer s.wg.Done() + for { + select { + case <-s.ctx.Done(): + return + case job := <-s.jobQueue: + // Set the current job before execution + s.currentJobLock.Lock() + s.currentJob = job + s.currentJobLock.Unlock() + + err := job.fn() + + // Clear the current job after execution + s.currentJobLock.Lock() + s.currentJob = nil + s.currentJobLock.Unlock() + + // Only send result if the job is not async + if !job.async { + job.resultCh <- err + } + + if err != nil { + if onException, ok := s.onException.Get(); ok { + onException(err) + } + } + } + } + }() +} + +func (s *Scheduler) Stop() { + if s.cancel != nil { + s.cancel() + } + //s.wg.Wait() +} + +// Schedule adds a job to the queue and waits for its completion +func (s *Scheduler) Schedule(fn func() error) error { + resultCh := make(chan error, 1) + job := &Job{ + fn: func() error { + defer func() { + if r := recover(); r != nil { + resultCh <- fmt.Errorf("panic: %v", r) + } + }() + return fn() + }, + resultCh: resultCh, + async: false, + } + + // Check if we're already in a job execution context + s.currentJobLock.Lock() + isNestedCall := s.currentJob != nil && !s.currentJob.async + s.currentJobLock.Unlock() + + // If this is a nested call from a synchronous job, we need to be careful + // We can't execute directly because the VM isn't thread-safe + // Instead, we'll queue it and use a separate goroutine to wait for the result + if isNestedCall { + // Queue the job + select { + case <-s.ctx.Done(): + return fmt.Errorf("scheduler stopped") + case s.jobQueue <- job: + // Create a separate goroutine to wait for the result + // This prevents deadlock while still ensuring the job runs in the scheduler + resultCh2 := make(chan error, 1) + go func() { + resultCh2 <- <-resultCh + }() + return <-resultCh2 + } + } + + // Otherwise, queue the job normally + select { + case <-s.ctx.Done(): + return fmt.Errorf("scheduler stopped") + case s.jobQueue <- job: + return <-resultCh + } +} + +// ScheduleAsync adds a job to the queue without waiting for completion +// This is useful for fire-and-forget operations or when a job needs to schedule another job +func (s *Scheduler) ScheduleAsync(fn func() error) { + job := &Job{ + fn: func() error { + defer func() { + if r := recover(); r != nil { + // Get stack trace for better identification + stack := debug.Stack() + jobInfo := fmt.Sprintf("async job panic: %v\nStack: %s", r, stack) + + if onException, ok := s.onException.Get(); ok { + onException(fmt.Errorf("panic in async job: %v\n%s", r, jobInfo)) + } + } + }() + return fn() + }, + resultCh: nil, // No result channel needed + async: true, + } + // Queue the job without blocking + select { + case <-s.ctx.Done(): + // Scheduler is stopped, just ignore + return + case s.jobQueue <- job: + // Job queued successfully + // fmt.Printf("job queued successfully, length: %d\n", len(s.jobQueue)) + return + default: + // Queue is full, log an error + if onException, ok := s.onException.Get(); ok { + onException(fmt.Errorf("async job queue is full")) + } + } +} + +// ScheduleWithTimeout schedules a job with a timeout +func (s *Scheduler) ScheduleWithTimeout(fn func() error, timeout time.Duration) error { + resultCh := make(chan error, 1) + job := &Job{ + fn: func() error { + defer func() { + if r := recover(); r != nil { + resultCh <- fmt.Errorf("panic: %v", r) + } + }() + return fn() + }, + resultCh: resultCh, + async: false, + } + + // Check if we're already in a job execution context + s.currentJobLock.Lock() + isNestedCall := s.currentJob != nil && !s.currentJob.async + s.currentJobLock.Unlock() + + // If this is a nested call from a synchronous job, handle it specially + if isNestedCall { + // Queue the job + select { + case <-s.ctx.Done(): + return fmt.Errorf("scheduler stopped") + case s.jobQueue <- job: + // Create a separate goroutine to wait for the result with timeout + resultCh2 := make(chan error, 1) + go func() { + select { + case err := <-resultCh: + resultCh2 <- err + case <-time.After(timeout): + resultCh2 <- fmt.Errorf("operation timed out") + } + }() + return <-resultCh2 + } + } + + select { + case <-s.ctx.Done(): + return fmt.Errorf("scheduler stopped") + case s.jobQueue <- job: + select { + case err := <-resultCh: + return err + case <-time.After(timeout): + return fmt.Errorf("operation timed out") + } + } +} diff --git a/seanime-2.9.10/internal/util/hide_file.go b/seanime-2.9.10/internal/util/hide_file.go new file mode 100644 index 0000000..20e704f --- /dev/null +++ b/seanime-2.9.10/internal/util/hide_file.go @@ -0,0 +1,24 @@ +//go:build !windows + +package util + +import ( + "os" + "path/filepath" + "strings" +) + +func HideFile(path string) (string, error) { + filename := filepath.Base(path) + if strings.HasPrefix(filename, ".") { + return path, nil + } + + newPath := filepath.Join(filepath.Dir(path), "."+filename) + err := os.Rename(path, newPath) + if err != nil { + return "", err + } + + return newPath, nil +} diff --git a/seanime-2.9.10/internal/util/hide_file_win.go b/seanime-2.9.10/internal/util/hide_file_win.go new file mode 100644 index 0000000..07517dc --- /dev/null +++ b/seanime-2.9.10/internal/util/hide_file_win.go @@ -0,0 +1,21 @@ +//go:build windows + +package util + +import ( + "syscall" +) + +func HideFile(path string) (string, error) { + defer HandlePanicInModuleThen("HideFile", func() {}) + + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + return "", err + } + err = syscall.SetFileAttributes(p, syscall.FILE_ATTRIBUTE_HIDDEN) + if err != nil { + return "", err + } + return path, nil +} diff --git a/seanime-2.9.10/internal/util/hmac_auth.go b/seanime-2.9.10/internal/util/hmac_auth.go new file mode 100644 index 0000000..243db3e --- /dev/null +++ b/seanime-2.9.10/internal/util/hmac_auth.go @@ -0,0 +1,176 @@ +package util + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" +) + +type TokenClaims struct { + Endpoint string `json:"endpoint"` // The endpoint this token is valid for + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` +} + +type HMACAuth struct { + secret []byte + ttl time.Duration +} + +// base64URLEncode encodes to base64url without padding (to match frontend) +func base64URLEncode(data []byte) string { + return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=") +} + +// base64URLDecode decodes from base64url with or without padding +func base64URLDecode(data string) ([]byte, error) { + // Add padding if needed + if m := len(data) % 4; m != 0 { + data += strings.Repeat("=", 4-m) + } + return base64.URLEncoding.DecodeString(data) +} + +// NewHMACAuth creates a new HMAC authentication instance +func NewHMACAuth(secret string, ttl time.Duration) *HMACAuth { + return &HMACAuth{ + secret: []byte(secret), + ttl: ttl, + } +} + +// GenerateToken generates an HMAC-signed token for the given endpoint +func (h *HMACAuth) GenerateToken(endpoint string) (string, error) { + now := time.Now().Unix() + claims := TokenClaims{ + Endpoint: endpoint, + IssuedAt: now, + ExpiresAt: now + int64(h.ttl.Seconds()), + } + + // Serialize claims to JSON + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("failed to marshal claims: %w", err) + } + + // Encode claims as base64url without padding + claimsB64 := base64URLEncode(claimsJSON) + + // Generate HMAC signature + mac := hmac.New(sha256.New, h.secret) + mac.Write([]byte(claimsB64)) + signature := base64URLEncode(mac.Sum(nil)) + + // Return token in format: claims.signature + return claimsB64 + "." + signature, nil +} + +// ValidateToken validates an HMAC token and returns the claims if valid +func (h *HMACAuth) ValidateToken(token string, endpoint string) (*TokenClaims, error) { + // Split token into claims and signature + parts := splitToken(token) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid token format - expected 2 parts, got %d", len(parts)) + } + + claimsB64, signature := parts[0], parts[1] + + // Verify signature + mac := hmac.New(sha256.New, h.secret) + mac.Write([]byte(claimsB64)) + expectedSignature := base64URLEncode(mac.Sum(nil)) + + if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { + return nil, fmt.Errorf("invalid token signature, the password hashes may not match") + } + + // Decode claims + claimsJSON, err := base64URLDecode(claimsB64) + if err != nil { + return nil, fmt.Errorf("failed to decode claims: %w", err) + } + + var claims TokenClaims + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + return nil, fmt.Errorf("failed to unmarshal claims: %w", err) + } + + // Validate expiration + now := time.Now().Unix() + if claims.ExpiresAt < now { + return nil, fmt.Errorf("token expired - expires at %d, current time %d", claims.ExpiresAt, now) + } + + // Validate endpoint (optional, can be wildcard *) + if endpoint != "" && claims.Endpoint != "*" && claims.Endpoint != endpoint { + return nil, fmt.Errorf("token not valid for endpoint %s - claim endpoint: %s", endpoint, claims.Endpoint) + } + + return &claims, nil +} + +// GenerateQueryParam generates a query parameter string with the HMAC token +func (h *HMACAuth) GenerateQueryParam(endpoint string, symbol string) (string, error) { + token, err := h.GenerateToken(endpoint) + if err != nil { + return "", err + } + + if symbol == "" { + symbol = "?" + } + + return fmt.Sprintf("%stoken=%s", symbol, token), nil +} + +// ValidateQueryParam extracts and validates token from query parameter +func (h *HMACAuth) ValidateQueryParam(tokenParam string, endpoint string) (*TokenClaims, error) { + if tokenParam == "" { + return nil, fmt.Errorf("no token provided") + } + + return h.ValidateToken(tokenParam, endpoint) +} + +// splitToken splits a token string by the last dot separator +func splitToken(token string) []string { + // Find the last dot to split claims from signature + for i := len(token) - 1; i >= 0; i-- { + if token[i] == '.' { + return []string{token[:i], token[i+1:]} + } + } + return []string{token} +} + +func (h *HMACAuth) GetTokenExpiry(token string) (time.Time, error) { + parts := splitToken(token) + if len(parts) != 2 { + return time.Time{}, fmt.Errorf("invalid token format") + } + + claimsJSON, err := base64URLDecode(parts[0]) + if err != nil { + return time.Time{}, fmt.Errorf("failed to decode claims: %w", err) + } + + var claims TokenClaims + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + return time.Time{}, fmt.Errorf("failed to unmarshal claims: %w", err) + } + + return time.Unix(claims.ExpiresAt, 0), nil +} + +func (h *HMACAuth) IsTokenExpired(token string) bool { + expiry, err := h.GetTokenExpiry(token) + if err != nil { + return true + } + return time.Now().After(expiry) +} diff --git a/seanime-2.9.10/internal/util/http/filestream.go b/seanime-2.9.10/internal/util/http/filestream.go new file mode 100644 index 0000000..535daae --- /dev/null +++ b/seanime-2.9.10/internal/util/http/filestream.go @@ -0,0 +1,388 @@ +package httputil + +import ( + "context" + "errors" + "io" + "os" + "sync" + "time" + + "github.com/rs/zerolog" +) + +type piece struct { + start int64 + end int64 +} + +// FileStream saves a HTTP file being streamed to disk. +// It allows multiple readers to read the file concurrently. +// It works by being fed the stream from the HTTP response body. It will simultaneously write to disk and to the HTTP writer. +type FileStream struct { + length int64 + file *os.File + closed bool + mu sync.Mutex + pieces map[int64]*piece + readers []FileStreamReader + readersMu sync.Mutex + ctx context.Context + cancel context.CancelFunc + logger *zerolog.Logger +} + +type FileStreamReader interface { + io.ReadSeekCloser +} + +// NewFileStream creates a new FileStream instance with a temporary file +func NewFileStream(ctx context.Context, logger *zerolog.Logger, contentLength int64) (*FileStream, error) { + file, err := os.CreateTemp("", "filestream_*.tmp") + if err != nil { + return nil, err + } + + // Pre-allocate the file to the expected content length + if contentLength > 0 { + if err := file.Truncate(contentLength); err != nil { + _ = file.Close() + _ = os.Remove(file.Name()) + return nil, err + } + } + + ctx, cancel := context.WithCancel(ctx) + + return &FileStream{ + file: file, + ctx: ctx, + cancel: cancel, + logger: logger, + pieces: make(map[int64]*piece), + length: contentLength, + }, nil +} + +// WriteAndFlush writes the stream to the file at the given offset and flushes it to the HTTP writer +func (fs *FileStream) WriteAndFlush(src io.Reader, dst io.Writer, offset int64) error { + fs.mu.Lock() + if fs.closed { + fs.mu.Unlock() + return io.ErrClosedPipe + } + fs.mu.Unlock() + + buffer := make([]byte, 32*1024) // 32KB buffer + currentOffset := offset + + for { + select { + case <-fs.ctx.Done(): + return fs.ctx.Err() + default: + } + + n, readErr := src.Read(buffer) + if n > 0 { + // Write to file + fs.mu.Lock() + if !fs.closed { + if _, err := fs.file.WriteAt(buffer[:n], currentOffset); err != nil { + fs.mu.Unlock() + return err + } + + // Update pieces map + pieceEnd := currentOffset + int64(n) - 1 + fs.updatePieces(currentOffset, pieceEnd) + } + fs.mu.Unlock() + + // Write to HTTP response + if _, err := dst.Write(buffer[:n]); err != nil { + return err + } + + // Flush if possible + if flusher, ok := dst.(interface{ Flush() }); ok { + flusher.Flush() + } + + currentOffset += int64(n) + } + + if readErr != nil { + if readErr == io.EOF { + break + } + return readErr + } + } + + // Sync file to ensure data is written + fs.mu.Lock() + if !fs.closed { + _ = fs.file.Sync() + } + fs.mu.Unlock() + + return nil +} + +// updatePieces merges the new piece with existing pieces +func (fs *FileStream) updatePieces(start, end int64) { + newPiece := &piece{start: start, end: end} + + // Find overlapping or adjacent pieces + var toMerge []*piece + var toDelete []int64 + + for key, p := range fs.pieces { + if p.start <= end+1 && p.end >= start-1 { + toMerge = append(toMerge, p) + toDelete = append(toDelete, key) + } + } + + // Merge all overlapping pieces + for _, p := range toMerge { + if p.start < newPiece.start { + newPiece.start = p.start + } + if p.end > newPiece.end { + newPiece.end = p.end + } + } + + // Delete old pieces + for _, key := range toDelete { + delete(fs.pieces, key) + } + + // Add the merged piece + fs.pieces[newPiece.start] = newPiece +} + +// isRangeAvailable checks if a given range is completely downloaded +func (fs *FileStream) isRangeAvailable(start, end int64) bool { + for _, p := range fs.pieces { + if p.start <= start && p.end >= end { + return true + } + } + return false +} + +// NewReader creates a new FileStreamReader for concurrent reading +func (fs *FileStream) NewReader() (FileStreamReader, error) { + fs.mu.Lock() + defer fs.mu.Unlock() + + if fs.closed { + return nil, io.ErrClosedPipe + } + + reader := &fileStreamReader{ + fs: fs, + file: fs.file, + offset: 0, + } + + fs.readersMu.Lock() + fs.readers = append(fs.readers, reader) + fs.readersMu.Unlock() + + return reader, nil +} + +// Close closes the FileStream and cleans up resources +func (fs *FileStream) Close() error { + fs.mu.Lock() + defer fs.mu.Unlock() + + if fs.closed { + return nil + } + + fs.closed = true + fs.cancel() + + // Close all readers + fs.readersMu.Lock() + for _, reader := range fs.readers { + go reader.Close() + } + fs.readers = nil + fs.readersMu.Unlock() + + // Remove the temp file and close + fileName := fs.file.Name() + _ = fs.file.Close() + _ = os.Remove(fileName) + + return nil +} + +// Length returns the current length of the stream +func (fs *FileStream) Length() int64 { + return fs.length +} + +// fileStreamReader implements FileStreamReader interface +type fileStreamReader struct { + fs *FileStream + file *os.File + offset int64 + closed bool + mu sync.Mutex +} + +// Read reads data from the file stream, blocking if data is not yet available +func (r *fileStreamReader) Read(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.closed { + return 0, io.ErrClosedPipe + } + + readSize := int64(len(p)) + readEnd := r.offset + readSize - 1 + + if readEnd >= r.fs.length { + readEnd = r.fs.length - 1 + readSize = r.fs.length - r.offset + if readSize <= 0 { + return 0, io.EOF + } + } + + for { + select { + case <-r.fs.ctx.Done(): + return 0, r.fs.ctx.Err() + default: + } + + r.fs.mu.Lock() + streamClosed := r.fs.closed + + // Check if the range we want to read is available + available := r.fs.isRangeAvailable(r.offset, readEnd) + + // If not fully available, check what we can read + var actualReadSize int64 = readSize + if !available { + // Find the largest available chunk starting from our offset + var maxRead int64 = 0 + for _, piece := range r.fs.pieces { + if piece.start <= r.offset && piece.end >= r.offset { + chunkEnd := piece.end + if chunkEnd >= readEnd { + maxRead = readSize + } else { + maxRead = chunkEnd - r.offset + 1 + } + break + } + } + actualReadSize = maxRead + } + r.fs.mu.Unlock() + + // If we have some data to read, or if stream is closed, attempt the read + if available || actualReadSize > 0 || streamClosed { + var n int + var err error + + if actualReadSize > 0 { + n, err = r.file.ReadAt(p[:actualReadSize], r.offset) + } else if streamClosed { + // If stream is closed and no data available, try reading anyway to get proper EOF + n, err = r.file.ReadAt(p[:readSize], r.offset) + } + + if n > 0 { + r.offset += int64(n) + } + + // If we read less than requested and stream is closed, return EOF + if n < len(p) && streamClosed && r.offset >= r.fs.length { + if err == nil { + err = io.EOF + } + } + + // If no data was read and stream is closed, return EOF + if n == 0 && streamClosed { + return 0, io.EOF + } + + // Return what we got, even if it's 0 bytes (this prevents hanging) + return n, err + } + + // Wait a bit before checking again + r.mu.Unlock() + select { + case <-r.fs.ctx.Done(): + r.mu.Lock() + return 0, r.fs.ctx.Err() + case <-time.After(10 * time.Millisecond): + r.mu.Lock() + } + } +} + +// Seek sets the offset for the next Read +func (r *fileStreamReader) Seek(offset int64, whence int) (int64, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.closed { + return 0, io.ErrClosedPipe + } + + switch whence { + case io.SeekStart: + r.offset = offset + case io.SeekCurrent: + r.offset += offset + case io.SeekEnd: + r.fs.mu.Lock() + r.offset = r.fs.length + offset + r.fs.mu.Unlock() + default: + return 0, errors.New("invalid whence") + } + + if r.offset < 0 { + r.offset = 0 + } + + return r.offset, nil +} + +// Close closes the reader +func (r *fileStreamReader) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.closed { + return nil + } + + r.closed = true + + r.fs.readersMu.Lock() + for i, reader := range r.fs.readers { + if reader == r { + r.fs.readers = append(r.fs.readers[:i], r.fs.readers[i+1:]...) + break + } + } + r.fs.readersMu.Unlock() + + return nil +} diff --git a/seanime-2.9.10/internal/util/http/http_range.go b/seanime-2.9.10/internal/util/http/http_range.go new file mode 100644 index 0000000..ab1a05b --- /dev/null +++ b/seanime-2.9.10/internal/util/http/http_range.go @@ -0,0 +1,106 @@ +package httputil + +import ( + "errors" + "fmt" + "net/textproto" + "strconv" + "strings" +) + +// Range specifies the byte range to be sent to the client. +type Range struct { + Start int64 + Length int64 +} + +// ContentRange returns Content-Range header value. +func (r Range) ContentRange(size int64) string { + return fmt.Sprintf("bytes %d-%d/%d", r.Start, r.Start+r.Length-1, size) +} + +var ( + // ErrNoOverlap is returned by ParseRange if first-byte-pos of + // all of the byte-range-spec values is greater than the content size. + ErrNoOverlap = errors.New("invalid range: failed to overlap") + + // ErrInvalid is returned by ParseRange on invalid input. + ErrInvalid = errors.New("invalid range") +) + +// ParseRange parses a Range header string as per RFC 7233. +// ErrNoOverlap is returned if none of the ranges overlap. +// ErrInvalid is returned if s is invalid range. +func ParseRange(s string, size int64) ([]Range, error) { + if s == "" { + return nil, nil // header not present + } + const b = "bytes=" + if !strings.HasPrefix(s, b) { + return nil, ErrInvalid + } + var ranges []Range + noOverlap := false + for _, ra := range strings.Split(s[len(b):], ",") { + ra = textproto.TrimString(ra) + if ra == "" { + continue + } + i := strings.Index(ra, "-") + if i < 0 { + return nil, ErrInvalid + } + start, end := textproto.TrimString(ra[:i]), textproto.TrimString(ra[i+1:]) + var r Range + if start == "" { + // If no start is specified, end specifies the + // range start relative to the end of the file, + // and we are dealing with <suffix-length> + // which has to be a non-negative integer as per + // RFC 7233 Section 2.1 "Byte-Ranges". + if end == "" || end[0] == '-' { + return nil, ErrInvalid + } + i, err := strconv.ParseInt(end, 10, 64) + if i < 0 || err != nil { + return nil, ErrInvalid + } + if i > size { + i = size + } + r.Start = size - i + r.Length = size - r.Start + } else { + i, err := strconv.ParseInt(start, 10, 64) + if err != nil || i < 0 { + return nil, ErrInvalid + } + if i >= size { + // If the range begins after the size of the content, + // then it does not overlap. + noOverlap = true + continue + } + r.Start = i + if end == "" { + // If no end is specified, range extends to end of the file. + r.Length = size - r.Start + } else { + i, err := strconv.ParseInt(end, 10, 64) + if err != nil || r.Start > i { + return nil, ErrInvalid + } + if i >= size { + i = size - 1 + } + r.Length = i - r.Start + 1 + } + } + ranges = append(ranges, r) + } + if noOverlap && len(ranges) == 0 { + // The specified ranges did not overlap with the content. + return nil, ErrNoOverlap + } + return ranges, nil +} diff --git a/seanime-2.9.10/internal/util/http/httprs.go b/seanime-2.9.10/internal/util/http/httprs.go new file mode 100644 index 0000000..6a61fec --- /dev/null +++ b/seanime-2.9.10/internal/util/http/httprs.go @@ -0,0 +1,289 @@ +package httputil + +// Original source: https://github.com/jfbus/httprs/tree/master + +/* +Package httprs provides a ReadSeeker for http.Response.Body. + +Usage : + + resp, err := http.Get(url) + rs := httprs.NewHttpReadSeeker(resp) + defer rs.Close() + io.ReadFull(rs, buf) // reads the first bytes from the response body + rs.Seek(1024, 0) // moves the position, but does no range request + io.ReadFull(rs, buf) // does a range request and reads from the response body + +If you want to use a specific http.Client for additional range requests : + + rs := httprs.NewHttpReadSeeker(resp, client) +*/ + +import ( + "fmt" + "io" + "net/http" + "seanime/internal/util/limiter" + "strconv" + "strings" + "sync" +) + +// HttpReadSeeker implements io.ReadSeeker for HTTP responses +// It allows seeking within an HTTP response by using HTTP Range requests +type HttpReadSeeker struct { + url string // The URL of the resource + client *http.Client // HTTP client to use for requests + resp *http.Response // Current response + offset int64 // Current offset in the resource + size int64 // Size of the resource, -1 if unknown + readBuf []byte // Buffer for reading + readOffset int // Current offset in readBuf + mu sync.Mutex // Mutex for thread safety + rateLimiter *limiter.Limiter +} + +// NewHttpReadSeeker creates a new HttpReadSeeker from an http.Response +func NewHttpReadSeeker(resp *http.Response) *HttpReadSeeker { + url := "" + if resp.Request != nil { + url = resp.Request.URL.String() + } + + size := int64(-1) + if resp.ContentLength > 0 { + size = resp.ContentLength + } + + return &HttpReadSeeker{ + url: url, + client: http.DefaultClient, + resp: resp, + offset: 0, + size: size, + readBuf: nil, + readOffset: 0, + } +} + +func NewHttpReadSeekerFromURL(url string) (*HttpReadSeeker, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("httprs: failed to get URL %s: %w", url, err) + } + + return NewHttpReadSeeker(resp), nil +} + +// Read implements io.Reader +func (hrs *HttpReadSeeker) Read(p []byte) (n int, err error) { + hrs.mu.Lock() + defer hrs.mu.Unlock() + + // If we have buffered data, read from it first + if hrs.readBuf != nil && hrs.readOffset < len(hrs.readBuf) { + n = copy(p, hrs.readBuf[hrs.readOffset:]) + hrs.readOffset += n + hrs.offset += int64(n) + + // Clear buffer if we've read it all + if hrs.readOffset >= len(hrs.readBuf) { + hrs.readBuf = nil + hrs.readOffset = 0 + } + + return n, nil + } + + // If we don't have a response or it's been closed, get a new one + if hrs.resp == nil { + if err := hrs.makeRangeRequest(); err != nil { + return 0, err + } + } + + // Read from the response body + n, err = hrs.resp.Body.Read(p) + hrs.offset += int64(n) + + return n, err +} + +// Seek implements io.Seeker +func (hrs *HttpReadSeeker) Seek(offset int64, whence int) (int64, error) { + hrs.mu.Lock() + defer hrs.mu.Unlock() + + var newOffset int64 + + switch whence { + case io.SeekStart: + newOffset = offset + case io.SeekCurrent: + newOffset = hrs.offset + offset + case io.SeekEnd: + if hrs.size < 0 { + // If we don't know the size, we need to determine it + if err := hrs.determineSize(); err != nil { + return hrs.offset, err + } + } + newOffset = hrs.size + offset + default: + return hrs.offset, fmt.Errorf("httprs: invalid whence %d", whence) + } + + if newOffset < 0 { + return hrs.offset, fmt.Errorf("httprs: negative position") + } + + // If we're just moving the offset without reading, we can skip the request + // We'll make a new request when Read is called + if hrs.resp != nil { + hrs.resp.Body.Close() + hrs.resp = nil + } + + hrs.offset = newOffset + hrs.readBuf = nil + hrs.readOffset = 0 + + return hrs.offset, nil +} + +// Close closes the underlying response body +func (hrs *HttpReadSeeker) Close() error { + hrs.mu.Lock() + defer hrs.mu.Unlock() + + if hrs.resp != nil { + err := hrs.resp.Body.Close() + hrs.resp = nil + return err + } + + return nil +} + +// makeRangeRequest makes a new HTTP request with the Range header +func (hrs *HttpReadSeeker) makeRangeRequest() error { + req, err := http.NewRequest("GET", hrs.url, nil) + if err != nil { + return err + } + + // Set Range header from current offset + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", hrs.offset)) + + // Make the request + resp, err := hrs.client.Do(req) + if err != nil { + return err + } + + // Check if the server supports range requests + if resp.StatusCode != http.StatusPartialContent && hrs.offset > 0 { + resp.Body.Close() + return fmt.Errorf("httprs: server does not support range requests") + } + + // Update our response and offset + if hrs.resp != nil { + hrs.resp.Body.Close() + } + hrs.resp = resp + + // Update the size if we get it from Content-Range + if contentRange := resp.Header.Get("Content-Range"); contentRange != "" { + // Format: bytes <start>-<end>/<size> + parts := strings.Split(contentRange, "/") + if len(parts) > 1 && parts[1] != "*" { + if size, err := strconv.ParseInt(parts[1], 10, 64); err == nil { + hrs.size = size + } + } + } else if resp.ContentLength > 0 { + // If we don't have a Content-Range header but we do have Content-Length, + // then the size is the current offset plus the content length + hrs.size = hrs.offset + resp.ContentLength + } + + return nil +} + +// determineSize makes a HEAD request to determine the size of the resource +func (hrs *HttpReadSeeker) determineSize() error { + req, err := http.NewRequest("HEAD", hrs.url, nil) + if err != nil { + return err + } + + resp, err := hrs.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.ContentLength > 0 { + hrs.size = resp.ContentLength + } else { + // If we still don't know the size, return an error + return fmt.Errorf("httprs: unable to determine resource size") + } + + return nil +} + +// ReadAt implements io.ReaderAt +func (hrs *HttpReadSeeker) ReadAt(p []byte, off int64) (n int, err error) { + // Save current offset + currentOffset := hrs.offset + + // Seek to the requested offset + if _, err := hrs.Seek(off, io.SeekStart); err != nil { + return 0, err + } + + // Read the data + n, err = hrs.Read(p) + + // Restore the original offset + if _, seekErr := hrs.Seek(currentOffset, io.SeekStart); seekErr != nil { + // If we can't restore the offset, return that error instead + if err == nil { + err = seekErr + } + } + + return n, err +} + +// Size returns the size of the resource, or -1 if unknown +func (hrs *HttpReadSeeker) Size() int64 { + hrs.mu.Lock() + defer hrs.mu.Unlock() + + if hrs.size < 0 { + // Try to determine the size + _ = hrs.determineSize() + } + + return hrs.size +} + +// WithClient returns a new HttpReadSeeker with the specified client +func (hrs *HttpReadSeeker) WithClient(client *http.Client) *HttpReadSeeker { + hrs.mu.Lock() + defer hrs.mu.Unlock() + + hrs.client = client + return hrs +} + +func (hrs *HttpReadSeeker) WithRateLimiter(rl *limiter.Limiter) *HttpReadSeeker { + hrs.mu.Lock() + defer hrs.mu.Unlock() + + hrs.rateLimiter = rl + return hrs +} diff --git a/seanime-2.9.10/internal/util/image_downloader/image_downloader.go b/seanime-2.9.10/internal/util/image_downloader/image_downloader.go new file mode 100644 index 0000000..9e7b048 --- /dev/null +++ b/seanime-2.9.10/internal/util/image_downloader/image_downloader.go @@ -0,0 +1,389 @@ +package image_downloader + +import ( + "bytes" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "os" + "path/filepath" + "seanime/internal/util" + "seanime/internal/util/limiter" + "slices" + "sync" + "time" + + "github.com/goccy/go-json" + "github.com/google/uuid" + "github.com/rs/zerolog" + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" +) + +const ( + RegistryFilename = "registry.json" +) + +type ( + ImageDownloader struct { + downloadDir string + registry Registry + cancelChannel chan struct{} + logger *zerolog.Logger + actionMu sync.Mutex + registryMu sync.Mutex + } + + Registry struct { + content *RegistryContent + logger *zerolog.Logger + downloadDir string + registryPath string + mu sync.Mutex + } + RegistryContent struct { + UrlToId map[string]string `json:"url_to_id"` + IdToUrl map[string]string `json:"id_to_url"` + IdToExt map[string]string `json:"id_to_ext"` + } +) + +func NewImageDownloader(downloadDir string, logger *zerolog.Logger) *ImageDownloader { + _ = os.MkdirAll(downloadDir, os.ModePerm) + + return &ImageDownloader{ + downloadDir: downloadDir, + logger: logger, + registry: Registry{ + logger: logger, + registryPath: filepath.Join(downloadDir, RegistryFilename), + downloadDir: downloadDir, + content: &RegistryContent{}, + }, + cancelChannel: make(chan struct{}), + } +} + +// DownloadImages downloads multiple images concurrently. +func (id *ImageDownloader) DownloadImages(urls []string) (err error) { + id.cancelChannel = make(chan struct{}) + + if err = id.registry.setup(); err != nil { + return + } + + rateLimiter := limiter.NewLimiter(1*time.Second, 10) + var wg sync.WaitGroup + for _, url := range urls { + wg.Add(1) + go func(url string) { + defer wg.Done() + select { + case <-id.cancelChannel: + id.logger.Warn().Msg("image downloader: Download process canceled") + return + default: + rateLimiter.Wait() + id.downloadImage(url) + } + }(url) + } + wg.Wait() + + if err = id.registry.save(urls); err != nil { + return + } + + return +} + +func (id *ImageDownloader) DeleteDownloads() { + id.actionMu.Lock() + defer id.actionMu.Unlock() + + id.registryMu.Lock() + defer id.registryMu.Unlock() + + _ = os.RemoveAll(id.downloadDir) + id.registry.content = &RegistryContent{} +} + +// CancelDownload cancels the download process. +func (id *ImageDownloader) CancelDownload() { + close(id.cancelChannel) +} + +func (id *ImageDownloader) GetImageFilenameByUrl(url string) (filename string, ok bool) { + id.actionMu.Lock() + defer id.actionMu.Unlock() + + id.registryMu.Lock() + defer id.registryMu.Unlock() + + if err := id.registry.setup(); err != nil { + return + } + + var imgID string + imgID, ok = id.registry.content.UrlToId[url] + if !ok { + return + } + + filename = imgID + "." + id.registry.content.IdToExt[imgID] + return +} + +// GetImageFilenamesByUrls returns a map of URLs to image filenames. +// +// e.g., {"url1": "filename1.png", "url2": "filename2.jpg"} +func (id *ImageDownloader) GetImageFilenamesByUrls(urls []string) (ret map[string]string, err error) { + id.actionMu.Lock() + defer id.actionMu.Unlock() + + id.registryMu.Lock() + defer id.registryMu.Unlock() + + ret = make(map[string]string) + + if err = id.registry.setup(); err != nil { + return nil, err + } + + for _, url := range urls { + imgID, ok := id.registry.content.UrlToId[url] + if !ok { + continue + } + + ret[url] = imgID + "." + id.registry.content.IdToExt[imgID] + } + return +} + +func (id *ImageDownloader) DeleteImagesByUrls(urls []string) (err error) { + id.actionMu.Lock() + defer id.actionMu.Unlock() + + id.registryMu.Lock() + defer id.registryMu.Unlock() + + if err = id.registry.setup(); err != nil { + return + } + + for _, url := range urls { + imgID, ok := id.registry.content.UrlToId[url] + if !ok { + continue + } + + err = os.Remove(filepath.Join(id.downloadDir, imgID+"."+id.registry.content.IdToExt[imgID])) + if err != nil { + continue + } + + delete(id.registry.content.UrlToId, url) + delete(id.registry.content.IdToUrl, imgID) + delete(id.registry.content.IdToExt, imgID) + } + return +} + +// downloadImage downloads an image from a URL. +func (id *ImageDownloader) downloadImage(url string) { + + defer util.HandlePanicInModuleThen("util/image_downloader/downloadImage", func() { + }) + + if url == "" { + id.logger.Warn().Msg("image downloader: Empty URL provided, skipping download") + return + } + + // Check if the image has already been downloaded + id.registryMu.Lock() + if _, ok := id.registry.content.UrlToId[url]; ok { + id.registryMu.Unlock() + id.logger.Debug().Msgf("image downloader: Image from URL %s has already been downloaded", url) + return + } + id.registryMu.Unlock() + + // Download image from URL + id.logger.Info().Msgf("image downloader: Downloading image from URL: %s", url) + + imgID := uuid.NewString() + + // Download the image + resp, err := http.Get(url) + if err != nil { + id.logger.Error().Err(err).Msgf("image downloader: Failed to download image from URL %s", url) + return + } + defer resp.Body.Close() + + buf, err := io.ReadAll(resp.Body) + if err != nil { + id.logger.Error().Err(err).Msgf("image downloader: Failed to read image data from URL %s", url) + return + } + + // Get the image format + _, format, err := image.DecodeConfig(bytes.NewReader(buf)) + if err != nil { + id.logger.Error().Err(err).Msgf("image downloader: Failed to decode image format from URL %s", url) + return + } + + // Create the file + filePath := filepath.Join(id.downloadDir, imgID+"."+format) + file, err := os.Create(filePath) + if err != nil { + id.logger.Error().Err(err).Msgf("image downloader: Failed to create file for image %s", imgID) + return + } + defer file.Close() + + // Copy the image data to the file + _, err = io.Copy(file, bytes.NewReader(buf)) + if err != nil { + id.logger.Error().Err(err).Msgf("image downloader: Failed to write image data to file for image from %s", url) + return + } + + // Update registry + id.registryMu.Lock() + id.registry.addUrl(imgID, url, format) + id.registryMu.Unlock() + + return +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +func (r *Registry) setup() (err error) { + r.mu.Lock() + defer r.mu.Unlock() + + defer util.HandlePanicInModuleThen("util/image_downloader/setup", func() { + err = fmt.Errorf("image downloader: Failed to setup registry") + }) + + if r.content.IdToUrl != nil && r.content.UrlToId != nil { + return nil + } + + r.content.UrlToId = make(map[string]string) + r.content.IdToUrl = make(map[string]string) + r.content.IdToExt = make(map[string]string) + + // Check if the registry exists + _ = os.MkdirAll(filepath.Dir(r.registryPath), os.ModePerm) + _, err = os.Stat(r.registryPath) + if os.IsNotExist(err) { + // Create the registry file + err = os.WriteFile(r.registryPath, []byte("{}"), os.ModePerm) + if err != nil { + return err + } + } + + // Read the registry file + file, err := os.Open(r.registryPath) + if err != nil { + return err + } + defer file.Close() + + // Decode the registry file if there is content + if file != nil { + r.logger.Debug().Msg("image downloader: Reading registry content") + err = json.NewDecoder(file).Decode(&r.content) + if err != nil { + return err + } + } + + if r.content == nil { + r.content = &RegistryContent{ + UrlToId: make(map[string]string), + IdToUrl: make(map[string]string), + IdToExt: make(map[string]string), + } + } + + return nil +} + +// save verifies and saves the registry content. +func (r *Registry) save(urls []string) (err error) { + r.mu.Lock() + defer r.mu.Unlock() + + defer util.HandlePanicInModuleThen("util/image_downloader/save", func() { + err = fmt.Errorf("image downloader: Failed to save registry content") + }) + + // Verify all images have been downloaded + allDownloaded := true + for _, url := range urls { + if url == "" { + continue + } + if _, ok := r.content.UrlToId[url]; !ok { + allDownloaded = false + break + } + } + + if !allDownloaded { + // Clean up downloaded images + go func() { + r.logger.Error().Msg("image downloader: Not all images have been downloaded, aborting") + // Read the directory + files, err := os.ReadDir(r.downloadDir) + if err != nil { + r.logger.Error().Err(err).Msg("image downloader: Failed to abort") + return + } + // Delete all files that have been downloaded (are in the registry) + for _, file := range files { + fileNameWithoutExt := file.Name()[:len(file.Name())-len(filepath.Ext(file.Name()))] + if url, ok := r.content.IdToUrl[fileNameWithoutExt]; ok && slices.Contains(urls, url) { + err = os.Remove(filepath.Join(r.downloadDir, file.Name())) + if err != nil { + r.logger.Error().Err(err).Msgf("image downloader: Failed to delete file %s", file.Name()) + } + } + } + }() + return fmt.Errorf("image downloader: Not all images have been downloaded, operation aborted") + } + + data, err := json.Marshal(r.content) + if err != nil { + r.logger.Error().Err(err).Msg("image downloader: Failed to marshal registry content") + } + // Overwrite the registry file + err = os.WriteFile(r.registryPath, data, 0644) + if err != nil { + r.logger.Error().Err(err).Msg("image downloader: Failed to write registry content") + return err + } + + return nil +} + +func (r *Registry) addUrl(imgID, url, format string) { + r.mu.Lock() + defer r.mu.Unlock() + r.content.UrlToId[url] = imgID + r.content.IdToUrl[imgID] = url + r.content.IdToExt[imgID] = format +} diff --git a/seanime-2.9.10/internal/util/image_downloader/image_downloader_test.go b/seanime-2.9.10/internal/util/image_downloader/image_downloader_test.go new file mode 100644 index 0000000..2836580 --- /dev/null +++ b/seanime-2.9.10/internal/util/image_downloader/image_downloader_test.go @@ -0,0 +1,75 @@ +package image_downloader + +import ( + "fmt" + "github.com/stretchr/testify/require" + "seanime/internal/util" + "testing" + "time" +) + +func TestImageDownloader_DownloadImages(t *testing.T) { + + tests := []struct { + name string + urls []string + downloadDir string + expectedNum int + cancelAfter int + }{ + { + name: "test1", + urls: []string{ + "https://s4.anilist.co/file/anilistcdn/media/anime/banner/153518-7uRvV7SLqmHV.jpg", + "https://s4.anilist.co/file/anilistcdn/media/anime/banner/153518-7uRvV7SLqmHV.jpg", + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx153518-LEK6pAXtI03D.jpg", + }, + downloadDir: t.TempDir(), + expectedNum: 2, + cancelAfter: 0, + }, + //{ + // name: "test1", + // urls: []string{"https://s4.anilist.co/file/anilistcdn/media/anime/banner/153518-7uRvV7SLqmHVn.jpg"}, + // downloadDir: t.TempDir(), + // cancelAfter: 0, + //}, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + id := NewImageDownloader(tt.downloadDir, util.NewLogger()) + + if tt.cancelAfter > 0 { + go func() { + time.Sleep(time.Duration(tt.cancelAfter) * time.Second) + close(id.cancelChannel) + }() + } + + fmt.Print(tt.downloadDir) + + if err := id.DownloadImages(tt.urls); err != nil { + t.Errorf("ImageDownloader.DownloadImages() error = %v", err) + } + + downloadedImages := make(map[string]string, 0) + for _, url := range tt.urls { + imgPath, ok := id.GetImageFilenameByUrl(url) + downloadedImages[imgPath] = imgPath + if !ok { + t.Errorf("ImageDownloader.GetImagePathByUrl() error") + } else { + t.Logf("ImageDownloader.GetImagePathByUrl() = %v", imgPath) + } + } + + require.Len(t, downloadedImages, tt.expectedNum) + }) + + } + + time.Sleep(1 * time.Second) +} diff --git a/seanime-2.9.10/internal/util/limited_read_seeker.go b/seanime-2.9.10/internal/util/limited_read_seeker.go new file mode 100644 index 0000000..b783d56 --- /dev/null +++ b/seanime-2.9.10/internal/util/limited_read_seeker.go @@ -0,0 +1,110 @@ +package util + +import ( + "errors" + "io" +) + +// Common errors that might occur during operations +var ( + ErrInvalidOffset = errors.New("invalid offset: negative or beyond limit") + ErrInvalidWhence = errors.New("invalid whence value") + ErrReadLimit = errors.New("read would exceed limit") +) + +// LimitedReadSeeker wraps an io.ReadSeeker and limits the number of bytes +// that can be read from it. +type LimitedReadSeeker struct { + rs io.ReadSeeker // The underlying ReadSeeker + offset int64 // Current read position relative to start + limit int64 // Maximum number of bytes that can be read + basePos int64 // Original position in the underlying ReadSeeker +} + +// NewLimitedReadSeeker creates a new LimitedReadSeeker from the provided +// io.ReadSeeker, starting at the current position and with the given limit. +// The limit parameter specifies the maximum number of bytes that can be +// read from the underlying ReadSeeker. +func NewLimitedReadSeeker(rs io.ReadSeeker, limit int64) (*LimitedReadSeeker, error) { + if limit < 0 { + return nil, errors.New("negative limit") + } + + // Get the current position + pos, err := rs.Seek(0, io.SeekCurrent) + if err != nil { + return nil, err + } + + return &LimitedReadSeeker{ + rs: rs, + offset: 0, + limit: limit, + basePos: pos, + }, nil +} + +// Read implements the io.Reader interface. +func (lrs *LimitedReadSeeker) Read(p []byte) (n int, err error) { + if lrs.offset >= lrs.limit { + return 0, io.EOF + } + + // Calculate how many bytes we can read + maxToRead := lrs.limit - lrs.offset + if int64(len(p)) > maxToRead { + p = p[:maxToRead] + } + + n, err = lrs.rs.Read(p) + lrs.offset += int64(n) + return +} + +// Seek implements the io.Seeker interface. +func (lrs *LimitedReadSeeker) Seek(offset int64, whence int) (int64, error) { + var absoluteOffset int64 + + // Calculate the absolute offset based on whence + switch whence { + case io.SeekStart: + absoluteOffset = offset + case io.SeekCurrent: + absoluteOffset = lrs.offset + offset + case io.SeekEnd: + absoluteOffset = lrs.limit + offset + default: + return 0, ErrInvalidWhence + } + + // Check if the offset is valid + if absoluteOffset < 0 || absoluteOffset > lrs.limit { + return 0, ErrInvalidOffset + } + + // Seek in the underlying ReadSeeker + _, err := lrs.rs.Seek(lrs.basePos+absoluteOffset, io.SeekStart) + if err != nil { + return 0, err + } + + // Update our offset + lrs.offset = absoluteOffset + return absoluteOffset, nil +} + +// Size returns the limit of this LimitedReadSeeker. +func (lrs *LimitedReadSeeker) Size() int64 { + return lrs.limit +} + +// Remaining returns the number of bytes that can still be read. +func (lrs *LimitedReadSeeker) Remaining() int64 { + return lrs.limit - lrs.offset +} + +// Reset resets the read position to the beginning of the limited section. +func (lrs *LimitedReadSeeker) Reset() error { + _, err := lrs.Seek(0, io.SeekStart) + return err +} diff --git a/seanime-2.9.10/internal/util/limiter/limiter.go b/seanime-2.9.10/internal/util/limiter/limiter.go new file mode 100644 index 0000000..f5dd69f --- /dev/null +++ b/seanime-2.9.10/internal/util/limiter/limiter.go @@ -0,0 +1,53 @@ +package limiter + +import ( + "sync" + "time" +) + +// https://stackoverflow.com/a/72452542 + +func NewAnilistLimiter() *Limiter { + //return NewLimiter(15*time.Second, 18) + return NewLimiter(6*time.Second, 8) +} + +//---------------------------------------------------------------------------------------------------------------------- + +type Limiter struct { + tick time.Duration + count uint + entries []time.Time + index uint + mu sync.Mutex +} + +func NewLimiter(tick time.Duration, count uint) *Limiter { + l := Limiter{ + tick: tick, + count: count, + index: 0, + } + l.entries = make([]time.Time, count) + before := time.Now().Add(-2 * tick) + for i := range l.entries { + l.entries[i] = before + } + return &l +} + +func (l *Limiter) Wait() { + l.mu.Lock() + defer l.mu.Unlock() + last := &l.entries[l.index] + next := last.Add(l.tick) + now := time.Now() + if now.Before(next) { + time.Sleep(next.Sub(now)) + } + *last = time.Now() + l.index = l.index + 1 + if l.index == l.count { + l.index = 0 + } +} diff --git a/seanime-2.9.10/internal/util/logger.go b/seanime-2.9.10/internal/util/logger.go new file mode 100644 index 0000000..6bbe164 --- /dev/null +++ b/seanime-2.9.10/internal/util/logger.go @@ -0,0 +1,178 @@ +package util + +import ( + "bytes" + "fmt" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/rs/zerolog/log" + + "github.com/rs/zerolog" +) + +const ( + colorBlack = iota + 30 + colorRed + colorGreen + colorYellow + colorBlue + colorMagenta + colorCyan + colorWhite + + colorBold = 1 + colorDarkGray = 90 + + unknownLevel = "???" +) + +// Stores logs from all loggers. Used to write logs to a file when WriteGlobalLogBufferToFile is called. +// It is reset after writing to a file. +var logBuffer bytes.Buffer +var logBufferMutex = &sync.Mutex{} + +func NewLogger() *zerolog.Logger { + + timeFormat := fmt.Sprintf("%s", time.DateTime) + fieldsOrder := []string{"method", "status", "error", "uri", "latency_human"} + fieldsExclude := []string{"host", "latency", "referer", "remote_ip", "user_agent", "bytes_in", "bytes_out", "file"} + + // Set up logger + consoleOutput := zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: timeFormat, + FormatLevel: ZerologFormatLevelPretty, + FormatMessage: ZerologFormatMessagePretty, + FieldsExclude: fieldsExclude, + FieldsOrder: fieldsOrder, + } + + fileOutput := zerolog.ConsoleWriter{ + Out: &logBuffer, + TimeFormat: timeFormat, + FormatMessage: ZerologFormatMessageSimple, + FormatLevel: ZerologFormatLevelSimple, + NoColor: true, // Needed to prevent color codes from being written to the file + FieldsExclude: fieldsExclude, + FieldsOrder: fieldsOrder, + } + + multi := zerolog.MultiLevelWriter(consoleOutput, fileOutput) + logger := zerolog.New(multi).With().Timestamp().Logger() + return &logger +} + +func WriteGlobalLogBufferToFile(file *os.File) { + defer HandlePanicInModuleThen("util/WriteGlobalLogBufferToFile", func() {}) + + if file == nil { + return + } + logBufferMutex.Lock() + defer logBufferMutex.Unlock() + if _, err := logBuffer.WriteTo(file); err != nil { + fmt.Print("Failed to write log buffer to file") + } + logBuffer.Reset() +} + +func SetupLoggerSignalHandling(file *os.File) { + if file == nil { + return + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigChan + log.Trace().Msgf("Received signal: %s", sig) + // Flush log buffer to the log file when the app exits + WriteGlobalLogBufferToFile(file) + _ = file.Close() + os.Exit(0) + }() +} + +func ZerologFormatMessagePretty(i interface{}) string { + if msg, ok := i.(string); ok { + if bytes.ContainsRune([]byte(msg), ':') { + parts := strings.SplitN(msg, ":", 2) + if len(parts) > 1 { + return colorizeb(parts[0], colorCyan) + colorizeb(" >", colorDarkGray) + parts[1] + } + } + return msg + } + return "" +} + +func ZerologFormatMessageSimple(i interface{}) string { + if msg, ok := i.(string); ok { + if bytes.ContainsRune([]byte(msg), ':') { + parts := strings.SplitN(msg, ":", 2) + if len(parts) > 1 { + return parts[0] + " >" + parts[1] + } + } + return msg + } + return "" +} + +func ZerologFormatLevelPretty(i interface{}) string { + if ll, ok := i.(string); ok { + s := strings.ToLower(ll) + switch s { + case "debug": + s = "DBG" + colorizeb(" -", colorDarkGray) + case "info": + s = fmt.Sprint(colorizeb("INF", colorBold)) + colorizeb(" -", colorDarkGray) + case "warn": + s = colorizeb("WRN", colorYellow) + colorizeb(" -", colorDarkGray) + case "trace": + s = colorizeb("TRC", colorDarkGray) + colorizeb(" -", colorDarkGray) + case "error": + s = colorizeb("ERR", colorRed) + colorizeb(" -", colorDarkGray) + case "fatal": + s = colorizeb("FTL", colorRed) + colorizeb(" -", colorDarkGray) + case "panic": + s = colorizeb("PNC", colorRed) + colorizeb(" -", colorDarkGray) + } + return fmt.Sprint(s) + } + return "" +} + +func ZerologFormatLevelSimple(i interface{}) string { + if ll, ok := i.(string); ok { + s := strings.ToLower(ll) + switch s { + case "debug": + s = "|DBG|" + case "info": + s = "|INF|" + case "warn": + s = "|WRN|" + case "trace": + s = "|TRC|" + case "error": + s = "|ERR|" + case "fatal": + s = "|FTL|" + case "panic": + s = "|PNC|" + } + return fmt.Sprint(s) + } + return "" +} + +func colorizeb(s interface{}, c int) string { + return fmt.Sprintf("\x1b[%dm%v\x1b[0m", c, s) +} diff --git a/seanime-2.9.10/internal/util/map.go b/seanime-2.9.10/internal/util/map.go new file mode 100644 index 0000000..21c3a81 --- /dev/null +++ b/seanime-2.9.10/internal/util/map.go @@ -0,0 +1,94 @@ +package util + +import ( + "sync" +) + +// https://sreramk.medium.com/go-inside-sync-map-how-does-sync-map-work-internally-97e87b8e6bf + +// RWMutexMap is an implementation of mapInterface using a sync.RWMutex. +type RWMutexMap struct { + mu sync.RWMutex + dirty map[interface{}]interface{} +} + +func (m *RWMutexMap) Load(key interface{}) (value interface{}, ok bool) { + m.mu.RLock() + value, ok = m.dirty[key] + m.mu.RUnlock() + return +} + +func (m *RWMutexMap) Store(key, value interface{}) { + m.mu.Lock() + if m.dirty == nil { + m.dirty = make(map[interface{}]interface{}) + } + m.dirty[key] = value + m.mu.Unlock() +} + +func (m *RWMutexMap) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) { + m.mu.Lock() + actual, loaded = m.dirty[key] + if !loaded { + actual = value + if m.dirty == nil { + m.dirty = make(map[interface{}]interface{}) + } + m.dirty[key] = value + } + m.mu.Unlock() + return actual, loaded +} + +func (m *RWMutexMap) LoadAndDelete(key interface{}) (value interface{}, loaded bool) { + m.mu.Lock() + value, loaded = m.dirty[key] + if !loaded { + m.mu.Unlock() + return nil, false + } + delete(m.dirty, key) + m.mu.Unlock() + return value, loaded +} + +func (m *RWMutexMap) Delete(key interface{}) { + m.mu.Lock() + delete(m.dirty, key) + m.mu.Unlock() +} + +func (m *RWMutexMap) Range(f func(key, value interface{}) (shouldContinue bool)) { + m.mu.RLock() + keys := make([]interface{}, 0, len(m.dirty)) + for k := range m.dirty { + keys = append(keys, k) + } + m.mu.RUnlock() + + for _, k := range keys { + v, ok := m.Load(k) + if !ok { + continue + } + if !f(k, v) { + break + } + } +} + +// MapInterface is the interface Map implements. +type MapInterface interface { + Load(interface{}) (interface{}, bool) + Store(key, value interface{}) + LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) + LoadAndDelete(key interface{}) (value interface{}, loaded bool) + Delete(interface{}) + Range(func(key, value interface{}) (shouldContinue bool)) +} + +func NewRWMutexMap() MapInterface { + return &RWMutexMap{} +} diff --git a/seanime-2.9.10/internal/util/mem.go b/seanime-2.9.10/internal/util/mem.go new file mode 100644 index 0000000..83f7359 --- /dev/null +++ b/seanime-2.9.10/internal/util/mem.go @@ -0,0 +1,30 @@ +package util + +import ( + "fmt" + "os/exec" + "runtime" + "strings" +) + +func GetMemAddrStr(v interface{}) string { + return fmt.Sprintf("%p", v) +} + +func ProgramIsRunning(name string) bool { + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = NewCmd("tasklist") + case "linux": + cmd = NewCmd("pgrep", name) + case "darwin": + cmd = NewCmd("pgrep", name) + default: + return false + } + + output, _ := cmd.Output() + + return strings.Contains(string(output), name) +} diff --git a/seanime-2.9.10/internal/util/numbers.go b/seanime-2.9.10/internal/util/numbers.go new file mode 100644 index 0000000..ea710cd --- /dev/null +++ b/seanime-2.9.10/internal/util/numbers.go @@ -0,0 +1,93 @@ +package util + +import ( + "math" + "strconv" + "strings" +) + +func StringToInt(str string) (int, bool) { + dotIndex := strings.IndexByte(str, '.') + if dotIndex != -1 { + str = str[:dotIndex] + } + i, err := strconv.Atoi(str) + if err != nil { + return 0, false + } + return i, true +} + +func StringToIntMust(str string) int { + dotIndex := strings.IndexByte(str, '.') + if dotIndex != -1 { + str = str[:dotIndex] + } + i, err := strconv.Atoi(str) + if err != nil { + return 0 + } + return i +} + +func IntegerToRoman(number int) string { + maxRomanNumber := 3999 + if number > maxRomanNumber { + return strconv.Itoa(number) + } + + conversions := []struct { + value int + digit string + }{ + {1000, "M"}, + {900, "CM"}, + {500, "D"}, + {400, "CD"}, + {100, "C"}, + {90, "XC"}, + {50, "L"}, + {40, "XL"}, + {10, "X"}, + {9, "IX"}, + {5, "V"}, + {4, "IV"}, + {1, "I"}, + } + + var roman strings.Builder + for _, conversion := range conversions { + for number >= conversion.value { + roman.WriteString(conversion.digit) + number -= conversion.value + } + } + + return roman.String() +} + +// Ordinal returns the ordinal string for a specific integer. +func toOrdinal(number int) string { + absNumber := int(math.Abs(float64(number))) + + i := absNumber % 100 + if i == 11 || i == 12 || i == 13 { + return "th" + } + + switch absNumber % 10 { + case 1: + return "st" + case 2: + return "nd" + case 3: + return "rd" + default: + return "th" + } +} + +// IntegerToOrdinal the number by adding the Ordinal to the number. +func IntegerToOrdinal(number int) string { + return strconv.Itoa(number) + toOrdinal(number) +} diff --git a/seanime-2.9.10/internal/util/os.go b/seanime-2.9.10/internal/util/os.go new file mode 100644 index 0000000..84963ef --- /dev/null +++ b/seanime-2.9.10/internal/util/os.go @@ -0,0 +1,59 @@ +package util + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +func DownloadDir() (string, error) { + return userDir("Downloads") +} + +func DesktopDir() (string, error) { + return userDir("Desktop") +} + +func DocumentsDir() (string, error) { + return userDir("Documents") +} + +// userDir returns the path to the specified user directory (Desktop or Documents). +func userDir(dirType string) (string, error) { + var dir string + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + switch runtime.GOOS { + case "windows": + dir = filepath.Join(home, dirType) + + case "darwin": + dir = filepath.Join(home, dirType) + + case "linux": + // Linux: Use $XDG_DESKTOP_DIR / $XDG_DOCUMENTS_DIR / $XDG_DOWNLOAD_DIR if set, otherwise default + envVar := "" + if dirType == "Desktop" { + envVar = os.Getenv("XDG_DESKTOP_DIR") + } else if dirType == "Documents" { + envVar = os.Getenv("XDG_DOCUMENTS_DIR") + } else if dirType == "Downloads" { + envVar = os.Getenv("XDG_DOWNLOAD_DIR") + } + + if envVar != "" { + dir = envVar + } else { + dir = filepath.Join(home, dirType) + } + + default: + return "", fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + return dir, nil +} diff --git a/seanime-2.9.10/internal/util/panic.go b/seanime-2.9.10/internal/util/panic.go new file mode 100644 index 0000000..ce86905 --- /dev/null +++ b/seanime-2.9.10/internal/util/panic.go @@ -0,0 +1,73 @@ +package util + +import ( + "errors" + "github.com/rs/zerolog/log" + "runtime/debug" + "sync" +) + +var printLock = sync.Mutex{} + +func printRuntimeError(r any, module string) string { + printLock.Lock() + debugStr := string(debug.Stack()) + logger := NewLogger() + log.Error().Msgf("go: PANIC RECOVERY") + if module != "" { + log.Error().Msgf("go: Runtime error in \"%s\"", module) + } + log.Error().Msgf("go: A runtime error occurred, please send the logs to the developer\n") + log.Printf("go: ========================================= Stack Trace =========================================\n") + logger.Error().Msgf("%+v\n\n%+v", r, debugStr) + log.Printf("go: ===================================== End of Stack Trace ======================================\n") + printLock.Unlock() + return debugStr +} + +func HandlePanicWithError(err *error) { + if r := recover(); r != nil { + *err = errors.New("fatal error occurred, please report this issue") + printRuntimeError(r, "") + } +} + +func HandlePanicInModuleWithError(module string, err *error) { + if r := recover(); r != nil { + *err = errors.New("fatal error occurred, please report this issue") + printRuntimeError(r, module) + } +} + +func HandlePanicThen(f func()) { + if r := recover(); r != nil { + f() + printRuntimeError(r, "") + } +} + +func HandlePanicInModuleThen(module string, f func()) { + if r := recover(); r != nil { + f() + printRuntimeError(r, module) + } +} + +func HandlePanicInModuleThenS(module string, f func(stackTrace string)) { + if r := recover(); r != nil { + str := printRuntimeError(r, module) + f(str) + } +} + +func Recover() { + if r := recover(); r != nil { + printRuntimeError(r, "") + } +} + +func RecoverInModule(module string) { + if r := recover(); r != nil { + printRuntimeError(r, module) + } +} diff --git a/seanime-2.9.10/internal/util/panic_test.go b/seanime-2.9.10/internal/util/panic_test.go new file mode 100644 index 0000000..0006c28 --- /dev/null +++ b/seanime-2.9.10/internal/util/panic_test.go @@ -0,0 +1,55 @@ +package util + +import "testing" + +func TestHandlePanicInModuleThen(t *testing.T) { + + type testStruct struct { + mediaId int + } + + testDangerousWork := func(obj *testStruct, work func()) { + defer HandlePanicInModuleThen("util/panic_test", func() { + obj.mediaId = 0 + }) + + work() + } + + var testCases = []struct { + name string + obj testStruct + work func() + expectedMediaId int + }{ + { + name: "Test 1", + obj: testStruct{mediaId: 1}, + work: func() { + panic("Test 1") + }, + expectedMediaId: 0, + }, + { + name: "Test 2", + obj: testStruct{mediaId: 2}, + work: func() { + // Do nothing + }, + expectedMediaId: 2, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + testDangerousWork(&tc.obj, tc.work) + + if tc.obj.mediaId != tc.expectedMediaId { + t.Errorf("Expected mediaId to be %d, got %d", tc.expectedMediaId, tc.obj.mediaId) + } + + }) + } + +} diff --git a/seanime-2.9.10/internal/util/parallel/parallel.go b/seanime-2.9.10/internal/util/parallel/parallel.go new file mode 100644 index 0000000..d78f2a5 --- /dev/null +++ b/seanime-2.9.10/internal/util/parallel/parallel.go @@ -0,0 +1,96 @@ +package parallel + +import ( + "github.com/samber/lo" + "seanime/internal/util/limiter" + "sync" +) + +// EachTask iterates over elements of collection and invokes the task function for each element. +// `task` is called in parallel. +func EachTask[T any](collection []T, task func(item T, index int)) { + var wg sync.WaitGroup + + for i, item := range collection { + wg.Add(1) + go func(_item T, _i int) { + defer wg.Done() + task(_item, _i) + }(item, i) + } + + wg.Wait() +} + +// EachTaskL is the same as EachTask, but takes a pointer to limiter.Limiter. +func EachTaskL[T any](collection []T, rl *limiter.Limiter, task func(item T, index int)) { + var wg sync.WaitGroup + + for i, item := range collection { + wg.Add(1) + go func(_item T, _i int) { + defer wg.Done() + rl.Wait() + task(_item, _i) + }(item, i) + } + + wg.Wait() +} + +type SettledResults[T comparable, R any] struct { + Collection []T + Fulfilled map[T]R + Results []R + Rejected map[T]error +} + +// NewSettledResults returns a pointer to a new SettledResults struct. +func NewSettledResults[T comparable, R any](c []T) *SettledResults[T, R] { + return &SettledResults[T, R]{ + Collection: c, + Fulfilled: map[T]R{}, + Rejected: map[T]error{}, + } +} + +// GetFulfilledResults returns a pointer to the slice of fulfilled results and a boolean indicating whether the slice is not nil. +func (sr *SettledResults[T, R]) GetFulfilledResults() (*[]R, bool) { + if sr.Results != nil { + return &sr.Results, true + } + return nil, false +} + +// AllSettled executes the provided task function once, in parallel for each element in the slice passed to NewSettledResults. +// It returns a map of fulfilled results and a map of errors whose keys are the elements of the slice. +func (sr *SettledResults[T, R]) AllSettled(task func(item T, index int) (R, error)) (map[T]R, map[T]error) { + var wg sync.WaitGroup + var mu sync.Mutex + + for i, item := range sr.Collection { + wg.Add(1) + go func(_item T, _i int) { + + res, err := task(_item, _i) + + mu.Lock() + if err != nil { + sr.Rejected[_item] = err + } else { + sr.Fulfilled[_item] = res + } + mu.Unlock() + wg.Done() + + }(item, i) + } + + wg.Wait() + + sr.Results = lo.MapToSlice(sr.Fulfilled, func(key T, value R) R { + return value + }) + + return sr.Fulfilled, sr.Rejected +} diff --git a/seanime-2.9.10/internal/util/parallel/parallel_test.go b/seanime-2.9.10/internal/util/parallel/parallel_test.go new file mode 100644 index 0000000..c9a42ef --- /dev/null +++ b/seanime-2.9.10/internal/util/parallel/parallel_test.go @@ -0,0 +1,78 @@ +package parallel + +import ( + "fmt" + "github.com/sourcegraph/conc/pool" + "github.com/sourcegraph/conc/stream" + "testing" + "time" +) + +func fakeAPICall(id int) (int, error) { + //time.Sleep(time.Millisecond * time.Duration(100+rand.Intn(500))) + time.Sleep(time.Millisecond * 200) + return id, nil +} + +func TestAllSettled(t *testing.T) { + + ids := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30} + + sr := NewSettledResults[int, int](ids) + sr.AllSettled(func(item int, index int) (int, error) { + return fakeAPICall(item) + }) + + fulfilled, ok := sr.GetFulfilledResults() + + if !ok { + t.Error("expected results, got error") + } + + for _, v := range *fulfilled { + t.Log(v) + } + +} + +func TestConc(t *testing.T) { + + ids := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30} + + fetch := func(ids []int) ([]int, error) { + p := pool.NewWithResults[int]().WithErrors() + for _, id := range ids { + id := id + p.Go(func() (int, error) { + return fakeAPICall(id) + }) + } + return p.Wait() + } + + res, _ := fetch(ids) + + for _, v := range res { + t.Log(v) + } + +} + +func TestConcStream(t *testing.T) { + + ids := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30} + + strm := stream.New() + for _, id := range ids { + id := id + strm.Go(func() stream.Callback { + res, err := fakeAPICall(id) + // This will print in the order the tasks were submitted + return func() { + fmt.Println(res, err) + } + }) + } + strm.Wait() + +} diff --git a/seanime-2.9.10/internal/util/parsing.go b/seanime-2.9.10/internal/util/parsing.go new file mode 100644 index 0000000..7377753 --- /dev/null +++ b/seanime-2.9.10/internal/util/parsing.go @@ -0,0 +1,59 @@ +package util + +import ( + "regexp" + "strings" +) + +func ExtractSeasonNumber(title string) (int, string) { + title = strings.ToLower(title) + + rgx := regexp.MustCompile(`((?P<a>\d+)(st|nd|rd|th)?\s*(season))|((season)\s*(?P<b>\d+))`) + + matches := rgx.FindStringSubmatch(title) + if len(matches) < 1 { + return 0, title + } + m := matches[rgx.SubexpIndex("a")] + if m == "" { + m = matches[rgx.SubexpIndex("b")] + } + if m == "" { + return 0, title + } + ret, ok := StringToInt(m) + if !ok { + return 0, title + } + + cTitle := strings.TrimSpace(rgx.ReplaceAllString(title, "")) + + return ret, cTitle +} + +func ExtractPartNumber(title string) (int, string) { + title = strings.ToLower(title) + + rgx := regexp.MustCompile(`((?P<a>\d+)(st|nd|rd|th)?\s*(cour|part))|((cour|part)\s*(?P<b>\d+))`) + + matches := rgx.FindStringSubmatch(title) + if len(matches) < 1 { + return 0, title + } + m := matches[rgx.SubexpIndex("a")] + if m == "" { + m = matches[rgx.SubexpIndex("b")] + } + if m == "" { + return 0, title + } + ret, ok := StringToInt(m) + if !ok { + return 0, title + } + + cTitle := strings.TrimSpace(rgx.ReplaceAllString(title, "")) + + return ret, cTitle + +} diff --git a/seanime-2.9.10/internal/util/pool.go b/seanime-2.9.10/internal/util/pool.go new file mode 100644 index 0000000..4aee6ac --- /dev/null +++ b/seanime-2.9.10/internal/util/pool.go @@ -0,0 +1,25 @@ +package util + +import "sync" + +type Pool[T any] struct { + sync.Pool +} + +func (p *Pool[T]) Get() T { + return p.Pool.Get().(T) +} + +func (p *Pool[T]) Put(x T) { + p.Pool.Put(x) +} + +func NewPool[T any](newF func() T) *Pool[T] { + return &Pool[T]{ + Pool: sync.Pool{ + New: func() interface{} { + return newF() + }, + }, + } +} diff --git a/seanime-2.9.10/internal/util/proxies/image_proxy.go b/seanime-2.9.10/internal/util/proxies/image_proxy.go new file mode 100644 index 0000000..fa56bef --- /dev/null +++ b/seanime-2.9.10/internal/util/proxies/image_proxy.go @@ -0,0 +1,70 @@ +package util + +import ( + "encoding/json" + "io" + "net/http" + "seanime/internal/util" + + "github.com/labstack/echo/v4" +) + +type ImageProxy struct{} + +func (ip *ImageProxy) GetImage(url string, headers map[string]string) ([]byte, error) { + client := &http.Client{} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + for key, value := range headers { + req.Header.Add(key, value) + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return body, nil +} + +func (ip *ImageProxy) setHeaders(c echo.Context) { + c.Set("Content-Type", "image/jpeg") + c.Set("Cache-Control", "public, max-age=31536000") + c.Set("Access-Control-Allow-Origin", "*") + c.Set("Access-Control-Allow-Methods", "GET") + c.Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept") + c.Set("Access-Control-Allow-Credentials", "true") +} + +func (ip *ImageProxy) ProxyImage(c echo.Context) (err error) { + defer util.HandlePanicInModuleWithError("util/ImageProxy", &err) + + url := c.QueryParam("url") + headersJSON := c.QueryParam("headers") + + if url == "" || headersJSON == "" { + return c.String(echo.ErrBadRequest.Code, "No URL provided") + } + + headers := make(map[string]string) + if err := json.Unmarshal([]byte(headersJSON), &headers); err != nil { + return c.String(echo.ErrBadRequest.Code, "Error parsing headers JSON") + } + + ip.setHeaders(c) + imageBuffer, err := ip.GetImage(url, headers) + if err != nil { + return c.String(echo.ErrInternalServerError.Code, "Error fetching image") + } + + return c.Blob(http.StatusOK, c.Response().Header().Get("Content-Type"), imageBuffer) +} diff --git a/seanime-2.9.10/internal/util/proxies/proxy.go b/seanime-2.9.10/internal/util/proxies/proxy.go new file mode 100644 index 0000000..a2ce042 --- /dev/null +++ b/seanime-2.9.10/internal/util/proxies/proxy.go @@ -0,0 +1,284 @@ +package util + +import ( + "bytes" + "io" + "net/http" + url2 "net/url" + "seanime/internal/util" + "strconv" + "strings" + "time" + + "github.com/Eyevinn/hls-m3u8/m3u8" + "github.com/goccy/go-json" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" +) + +var proxyUA = util.GetRandomUserAgent() + +var videoProxyClient = &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + ForceAttemptHTTP2: false, // Fixes issues on Linux + }, + Timeout: 60 * time.Second, +} + +func VideoProxy(c echo.Context) (err error) { + defer util.HandlePanicInModuleWithError("util/VideoProxy", &err) + + url := c.QueryParam("url") + headers := c.QueryParam("headers") + + // Always use GET request internally, even for HEAD requests + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + log.Error().Err(err).Msg("proxy: Error creating request") + return echo.NewHTTPError(http.StatusInternalServerError) + } + + var headerMap map[string]string + if headers != "" { + if err := json.Unmarshal([]byte(headers), &headerMap); err != nil { + log.Error().Err(err).Msg("proxy: Error unmarshalling headers") + return echo.NewHTTPError(http.StatusInternalServerError) + } + for key, value := range headerMap { + req.Header.Set(key, value) + } + } + + req.Header.Set("User-Agent", proxyUA) + req.Header.Set("Accept", "*/*") + if rangeHeader := c.Request().Header.Get("Range"); rangeHeader != "" { + req.Header.Set("Range", rangeHeader) + } + + resp, err := videoProxyClient.Do(req) + + if err != nil { + log.Error().Err(err).Msg("proxy: Error sending request") + return echo.NewHTTPError(http.StatusInternalServerError) + } + defer resp.Body.Close() + + // Copy response headers + for k, vs := range resp.Header { + for _, v := range vs { + if !strings.EqualFold(k, "Content-Length") { // Skip Content-Length header, fixes net::ERR_CONTENT_LENGTH_MISMATCH + c.Response().Header().Set(k, v) + } + } + } + + // Set CORS headers + c.Response().Header().Set("Access-Control-Allow-Origin", "*") + c.Response().Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Response().Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + + // For HEAD requests, return only headers + if c.Request().Method == http.MethodHead { + return c.NoContent(http.StatusOK) + } + + isHlsPlaylist := strings.HasSuffix(url, ".m3u8") || strings.Contains(resp.Header.Get("Content-Type"), "mpegurl") + + if !isHlsPlaylist { + return c.Stream(resp.StatusCode, c.Response().Header().Get("Content-Type"), resp.Body) + } + + // HLS Playlist + //log.Debug().Str("url", url).Msg("proxy: Processing HLS playlist") + + bodyBytes, readErr := io.ReadAll(resp.Body) + if readErr != nil { + log.Error().Err(readErr).Str("url", url).Msg("proxy: Error reading HLS response body") + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read HLS playlist") + } + + buffer := bytes.NewBuffer(bodyBytes) + playlist, listType, decodeErr := m3u8.Decode(*buffer, true) + if decodeErr != nil { + // Playlist might be valid but not decodable by the library, or simply corrupted. + // Option 1: Proxy as-is (might be preferred if decoding fails unexpectedly) + log.Warn().Err(decodeErr).Str("url", url).Msg("proxy: Failed to decode M3U8 playlist, proxying raw content") + c.Response().Header().Set(echo.HeaderContentType, resp.Header.Get("Content-Type")) // Use original Content-Type + c.Response().Header().Set(echo.HeaderContentLength, strconv.Itoa(len(bodyBytes))) + c.Response().WriteHeader(resp.StatusCode) + _, writeErr := c.Response().Writer.Write(bodyBytes) + return writeErr + } + + var modifiedPlaylistBytes []byte + needsRewrite := false // Flag to check if we actually need to rewrite + + if listType == m3u8.MEDIA { + mediaPl := playlist.(*m3u8.MediaPlaylist) + baseURL, _ := url2.Parse(url) // Base URL for resolving relative paths + + for _, segment := range mediaPl.Segments { + if segment != nil { + // Rewrite Segment URI + if !isAlreadyProxied(segment.URI) { + if segment.URI != "" { + if !strings.HasPrefix(segment.URI, "http") { + segment.URI = resolveURL(baseURL, segment.URI) + } + segment.URI = rewriteProxyURL(segment.URI, headerMap) + needsRewrite = true + } + } + + // Rewrite encryption key URIs + for i, key := range segment.Keys { + if key.URI != "" { + if !isAlreadyProxied(key.URI) { + keyURI := key.URI + if !strings.HasPrefix(key.URI, "http") { + keyURI = resolveURL(baseURL, key.URI) + } + segment.Keys[i].URI = rewriteProxyURL(keyURI, headerMap) + needsRewrite = true + } + } + } + } + } + + // Rewrite playlist-level encryption key URIs + for i, key := range mediaPl.Keys { + if key.URI != "" { + if !isAlreadyProxied(key.URI) { + keyURI := key.URI + if !strings.HasPrefix(key.URI, "http") { + keyURI = resolveURL(baseURL, key.URI) + } + mediaPl.Keys[i].URI = rewriteProxyURL(keyURI, headerMap) + needsRewrite = true + } + } + } + + // Encode the modified media playlist + buffer := mediaPl.Encode() + modifiedPlaylistBytes = buffer.Bytes() + + } else if listType == m3u8.MASTER { + // Rewrite URIs in Master playlists + masterPl := playlist.(*m3u8.MasterPlaylist) + baseURL, _ := url2.Parse(url) // Base URL for resolving relative paths + + for _, variant := range masterPl.Variants { + if variant != nil && variant.URI != "" { + if !isAlreadyProxied(variant.URI) { + variantURI := variant.URI + if !strings.HasPrefix(variant.URI, "http") { + variantURI = resolveURL(baseURL, variant.URI) + } + variant.URI = rewriteProxyURL(variantURI, headerMap) + needsRewrite = true + } + } + + // Handle alternative media groups (audio, subtitles, etc.) for each variant + if variant != nil { + for _, alternative := range variant.Alternatives { + if alternative != nil && alternative.URI != "" { + if !isAlreadyProxied(alternative.URI) { + alternativeURI := alternative.URI + if !strings.HasPrefix(alternative.URI, "http") { + alternativeURI = resolveURL(baseURL, alternative.URI) + } + alternative.URI = rewriteProxyURL(alternativeURI, headerMap) + needsRewrite = true + } + } + } + } + } + + allAlternatives := masterPl.GetAllAlternatives() + for _, alternative := range allAlternatives { + if alternative != nil && alternative.URI != "" { + if !isAlreadyProxied(alternative.URI) { + alternativeURI := alternative.URI + if !strings.HasPrefix(alternative.URI, "http") { + alternativeURI = resolveURL(baseURL, alternative.URI) + } + alternative.URI = rewriteProxyURL(alternativeURI, headerMap) + needsRewrite = true + } + } + } + + // Rewrite session key URIs + for i, sessionKey := range masterPl.SessionKeys { + if sessionKey.URI != "" { + if !isAlreadyProxied(sessionKey.URI) { + sessionKeyURI := sessionKey.URI + if !strings.HasPrefix(sessionKey.URI, "http") { + sessionKeyURI = resolveURL(baseURL, sessionKey.URI) + } + masterPl.SessionKeys[i].URI = rewriteProxyURL(sessionKeyURI, headerMap) + needsRewrite = true + } + } + } + + // Encode the modified master playlist + buffer := masterPl.Encode() + modifiedPlaylistBytes = buffer.Bytes() + + } else { + // Unknown type, pass through + modifiedPlaylistBytes = bodyBytes + } + + // Set headers *after* potential modification + contentType := "application/vnd.apple.mpegurl" + c.Response().Header().Set(echo.HeaderContentType, contentType) + // Set Content-Length based on the *modified* playlist + c.Response().Header().Set(echo.HeaderContentLength, strconv.Itoa(len(modifiedPlaylistBytes))) + + // Set Cache-Control headers appropriate for playlists (often no-cache for live) + if resp.Header.Get("Cache-Control") == "" { + c.Response().Header().Set("Cache-Control", "no-cache") + } + + log.Debug().Bool("rewritten", needsRewrite).Str("url", url).Msg("proxy: Sending modified HLS playlist") + c.Response().WriteHeader(resp.StatusCode) + + return c.Blob(http.StatusOK, c.Response().Header().Get("Content-Type"), modifiedPlaylistBytes) +} + +func resolveURL(base *url2.URL, relativeURI string) string { + if base == nil { + return relativeURI // Cannot resolve without a base + } + relativeURL, err := url2.Parse(relativeURI) + if err != nil { + return relativeURI // Invalid relative URI + } + return base.ResolveReference(relativeURL).String() +} + +func rewriteProxyURL(targetMediaURL string, headerMap map[string]string) string { + proxyURL := "/api/v1/proxy?url=" + url2.QueryEscape(targetMediaURL) + if len(headerMap) > 0 { + headersStrB, err := json.Marshal(headerMap) + // Ignore marshalling errors here? Or log them? For simplicity, ignoring now. + if err == nil && len(headersStrB) > 2 { // Check > 2 for "{}" empty map + proxyURL += "&headers=" + url2.QueryEscape(string(headersStrB)) + } + } + return proxyURL +} + +func isAlreadyProxied(url string) bool { + // Check if the URL contains the proxy pattern + return strings.Contains(url, "/api/v1/proxy?url=") || strings.Contains(url, url2.QueryEscape("/api/v1/proxy?url=")) +} diff --git a/seanime-2.9.10/internal/util/regex.go b/seanime-2.9.10/internal/util/regex.go new file mode 100644 index 0000000..820cbd1 --- /dev/null +++ b/seanime-2.9.10/internal/util/regex.go @@ -0,0 +1,12 @@ +package util + +import "regexp" + +// MatchesRegex checks if a string matches a regex pattern +func MatchesRegex(str, pattern string) (bool, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return false, err + } + return re.MatchString(str), nil +} diff --git a/seanime-2.9.10/internal/util/result/boundedcache.go b/seanime-2.9.10/internal/util/result/boundedcache.go new file mode 100644 index 0000000..ecb764c --- /dev/null +++ b/seanime-2.9.10/internal/util/result/boundedcache.go @@ -0,0 +1,199 @@ +package result + +import ( + "container/list" + "sync" + "time" +) + +// BoundedCache implements an LRU cache with a maximum capacity +type BoundedCache[K comparable, V any] struct { + mu sync.RWMutex + capacity int + items map[K]*list.Element + order *list.List +} + +type boundedCacheItem[K comparable, V any] struct { + key K + value V + expiration time.Time +} + +// NewBoundedCache creates a new bounded cache with the specified capacity +func NewBoundedCache[K comparable, V any](capacity int) *BoundedCache[K, V] { + return &BoundedCache[K, V]{ + capacity: capacity, + items: make(map[K]*list.Element), + order: list.New(), + } +} + +// Set adds or updates an item in the cache with a default TTL +func (c *BoundedCache[K, V]) Set(key K, value V) { + c.SetT(key, value, time.Hour) // Default TTL of 1 hour +} + +// SetT adds or updates an item in the cache with a specific TTL +func (c *BoundedCache[K, V]) SetT(key K, value V, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + expiration := time.Now().Add(ttl) + item := &boundedCacheItem[K, V]{ + key: key, + value: value, + expiration: expiration, + } + + // If key already exists, update it and move to front + if elem, exists := c.items[key]; exists { + elem.Value = item + c.order.MoveToFront(elem) + return + } + + // If at capacity, remove oldest item + if len(c.items) >= c.capacity { + c.evictOldest() + } + + // Add new item to front + elem := c.order.PushFront(item) + c.items[key] = elem + + // Set up expiration cleanup + go func() { + <-time.After(ttl) + c.Delete(key) + }() +} + +// Get retrieves an item from the cache and marks it as recently used +func (c *BoundedCache[K, V]) Get(key K) (V, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + var zero V + elem, exists := c.items[key] + if !exists { + return zero, false + } + + item := elem.Value.(*boundedCacheItem[K, V]) + + // Check if expired + if time.Now().After(item.expiration) { + c.delete(key) + return zero, false + } + + // Move to front (mark as recently used) + c.order.MoveToFront(elem) + return item.value, true +} + +// Has checks if a key exists in the cache without updating access time +func (c *BoundedCache[K, V]) Has(key K) bool { + c.mu.RLock() + defer c.mu.RUnlock() + + elem, exists := c.items[key] + if !exists { + return false + } + + item := elem.Value.(*boundedCacheItem[K, V]) + if time.Now().After(item.expiration) { + return false + } + + return true +} + +// Delete removes an item from the cache +func (c *BoundedCache[K, V]) Delete(key K) { + c.mu.Lock() + defer c.mu.Unlock() + c.delete(key) +} + +// delete removes an item from the cache (internal, assumes lock is held) +func (c *BoundedCache[K, V]) delete(key K) { + if elem, exists := c.items[key]; exists { + c.order.Remove(elem) + delete(c.items, key) + } +} + +// Clear removes all items from the cache +func (c *BoundedCache[K, V]) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + + c.items = make(map[K]*list.Element) + c.order.Init() +} + +// GetOrSet retrieves an item or creates it if it doesn't exist +func (c *BoundedCache[K, V]) GetOrSet(key K, createFunc func() (V, error)) (V, error) { + // Try to get the value first + value, ok := c.Get(key) + if ok { + return value, nil + } + + // Create new value + newValue, err := createFunc() + if err != nil { + return newValue, err + } + + // Set the new value + c.Set(key, newValue) + return newValue, nil +} + +// Size returns the current number of items in the cache +func (c *BoundedCache[K, V]) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.items) +} + +// Capacity returns the maximum capacity of the cache +func (c *BoundedCache[K, V]) Capacity() int { + return c.capacity +} + +// evictOldest removes the least recently used item (assumes lock is held) +func (c *BoundedCache[K, V]) evictOldest() { + if c.order.Len() == 0 { + return + } + + elem := c.order.Back() + if elem != nil { + item := elem.Value.(*boundedCacheItem[K, V]) + c.delete(item.key) + } +} + +// Range iterates over all items in the cache +func (c *BoundedCache[K, V]) Range(callback func(key K, value V) bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + for elem := c.order.Front(); elem != nil; elem = elem.Next() { + item := elem.Value.(*boundedCacheItem[K, V]) + + // Skip expired items + if time.Now().After(item.expiration) { + continue + } + + if !callback(item.key, item.value) { + break + } + } +} diff --git a/seanime-2.9.10/internal/util/result/resultcache.go b/seanime-2.9.10/internal/util/result/resultcache.go new file mode 100644 index 0000000..83f6034 --- /dev/null +++ b/seanime-2.9.10/internal/util/result/resultcache.go @@ -0,0 +1,101 @@ +package result + +import ( + "seanime/internal/constants" + "seanime/internal/util" + "time" +) + +type Cache[K interface{}, V any] struct { + store util.RWMutexMap +} + +type cacheItem[K interface{}, V any] struct { + value V + expiration time.Time +} + +func NewCache[K interface{}, V any]() *Cache[K, V] { + return &Cache[K, V]{} +} + +func (c *Cache[K, V]) Set(key K, value V) { + ttl := constants.GcTime + c.store.Store(key, &cacheItem[K, V]{value, time.Now().Add(ttl)}) + go func() { + <-time.After(ttl) + c.Delete(key) + }() +} + +func (c *Cache[K, V]) SetT(key K, value V, ttl time.Duration) { + c.store.Store(key, &cacheItem[K, V]{value, time.Now().Add(ttl)}) + go func() { + <-time.After(ttl) + c.Delete(key) + }() +} + +func (c *Cache[K, V]) Get(key K) (V, bool) { + item, ok := c.store.Load(key) + if !ok { + return (&cacheItem[K, V]{}).value, false + } + ci := item.(*cacheItem[K, V]) + if time.Now().After(ci.expiration) { + c.Delete(key) + return (&cacheItem[K, V]{}).value, false + } + return ci.value, true +} + +func (c *Cache[K, V]) Pop() (K, V, bool) { + var key K + var value V + var ok bool + c.store.Range(func(k, v interface{}) bool { + key = k.(K) + value = v.(*cacheItem[K, V]).value + ok = true + c.store.Delete(k) + return false + }) + return key, value, ok +} + +func (c *Cache[K, V]) Has(key K) bool { + _, ok := c.store.Load(key) + return ok +} + +func (c *Cache[K, V]) GetOrSet(key K, createFunc func() (V, error)) (V, error) { + value, ok := c.Get(key) + if ok { + return value, nil + } + + newValue, err := createFunc() + if err != nil { + return newValue, err + } + c.Set(key, newValue) + return newValue, nil +} + +func (c *Cache[K, V]) Delete(key K) { + c.store.Delete(key) +} + +func (c *Cache[K, V]) Clear() { + c.store.Range(func(key interface{}, value interface{}) bool { + c.store.Delete(key) + return true + }) +} + +func (c *Cache[K, V]) Range(callback func(key K, value V) bool) { + c.store.Range(func(key, value interface{}) bool { + ci := value.(*cacheItem[K, V]) + return callback(key.(K), ci.value) + }) +} diff --git a/seanime-2.9.10/internal/util/result/resultmap.go b/seanime-2.9.10/internal/util/result/resultmap.go new file mode 100644 index 0000000..7081ddf --- /dev/null +++ b/seanime-2.9.10/internal/util/result/resultmap.go @@ -0,0 +1,97 @@ +package result + +import ( + "seanime/internal/util" +) + +type Map[K interface{}, V any] struct { + store util.RWMutexMap +} + +type mapItem[K interface{}, V any] struct { + value V +} + +func NewResultMap[K interface{}, V any]() *Map[K, V] { + return &Map[K, V]{} +} + +func (c *Map[K, V]) Set(key K, value V) { + c.store.Store(key, &mapItem[K, V]{value}) +} + +func (c *Map[K, V]) Get(key K) (V, bool) { + item, ok := c.store.Load(key) + if !ok { + return (&mapItem[K, V]{}).value, false + } + ci := item.(*mapItem[K, V]) + return ci.value, true +} + +func (c *Map[K, V]) Has(key K) bool { + _, ok := c.store.Load(key) + return ok +} + +func (c *Map[K, V]) GetOrSet(key K, createFunc func() (V, error)) (V, error) { + value, ok := c.Get(key) + if ok { + return value, nil + } + + newValue, err := createFunc() + if err != nil { + return newValue, err + } + c.Set(key, newValue) + return newValue, nil +} + +func (c *Map[K, V]) Delete(key K) { + c.store.Delete(key) +} + +func (c *Map[K, V]) Clear() { + c.store.Range(func(key interface{}, value interface{}) bool { + c.store.Delete(key) + return true + }) +} + +// ClearN clears the map and returns the number of items cleared +func (c *Map[K, V]) ClearN() int { + count := 0 + c.store.Range(func(key interface{}, value interface{}) bool { + c.store.Delete(key) + count++ + return true + }) + return count +} + +func (c *Map[K, V]) Range(callback func(key K, value V) bool) { + c.store.Range(func(key, value interface{}) bool { + ci := value.(*mapItem[K, V]) + return callback(key.(K), ci.value) + }) +} + +func (c *Map[K, V]) Values() []V { + values := make([]V, 0) + c.store.Range(func(key, value interface{}) bool { + item := value.(*mapItem[K, V]) // Correct type assertion + values = append(values, item.value) + return true + }) + return values +} + +func (c *Map[K, V]) Keys() []K { + keys := make([]K, 0) + c.store.Range(func(key, value interface{}) bool { + keys = append(keys, key.(K)) + return true + }) + return keys +} diff --git a/seanime-2.9.10/internal/util/round_tripper.go b/seanime-2.9.10/internal/util/round_tripper.go new file mode 100644 index 0000000..cd613f7 --- /dev/null +++ b/seanime-2.9.10/internal/util/round_tripper.go @@ -0,0 +1,126 @@ +package util + +import ( + "crypto/tls" + "errors" + "net/http" + "time" +) + +// Full credit to https://github.com/DaRealFreak/cloudflare-bp-go + +// RetryConfig configures the retry behavior +type RetryConfig struct { + MaxRetries int + RetryDelay time.Duration + TimeoutOnly bool // Only retry on timeout errors +} + +// cloudFlareRoundTripper is a custom round tripper add the validated request headers. +type cloudFlareRoundTripper struct { + inner http.RoundTripper + options Options + retry *RetryConfig +} + +// Options the option to set custom headers +type Options struct { + AddMissingHeaders bool + Headers map[string]string +} + +// AddCloudFlareByPass returns a round tripper adding the required headers for the CloudFlare checks +// and updates the TLS configuration of the passed inner transport. +func AddCloudFlareByPass(inner http.RoundTripper, options ...Options) http.RoundTripper { + if trans, ok := inner.(*http.Transport); ok { + trans.TLSClientConfig = getCloudFlareTLSConfiguration() + } + + roundTripper := &cloudFlareRoundTripper{ + inner: inner, + retry: &RetryConfig{ + MaxRetries: 3, + RetryDelay: 2 * time.Second, + TimeoutOnly: true, + }, + } + + if options != nil && len(options) > 0 { + roundTripper.options = options[0] + } else { + roundTripper.options = GetDefaultOptions() + } + + return roundTripper +} + +// RoundTrip adds the required request headers to pass CloudFlare checks. +func (ug *cloudFlareRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + var lastErr error + attempts := 0 + + for attempts <= ug.retry.MaxRetries { + // Add headers for this attempt + if ug.options.AddMissingHeaders { + for header, value := range ug.options.Headers { + if _, ok := r.Header[header]; !ok { + if header == "User-Agent" { + // Generate new random user agent for each attempt + r.Header.Set(header, GetRandomUserAgent()) + } else { + r.Header.Set(header, value) + } + } + } + } + + // Make the request + var resp *http.Response + var err error + + // in case we don't have an inner transport layer from the round tripper + if ug.inner == nil { + resp, err = (&http.Transport{ + TLSClientConfig: getCloudFlareTLSConfiguration(), + ForceAttemptHTTP2: false, + }).RoundTrip(r) + } else { + resp, err = ug.inner.RoundTrip(r) + } + + // If successful or not a timeout error, return immediately + if err == nil || (ug.retry.TimeoutOnly && !errors.Is(err, http.ErrHandlerTimeout)) { + return resp, err + } + + lastErr = err + attempts++ + + // If we have more retries, wait before next attempt + if attempts <= ug.retry.MaxRetries { + time.Sleep(ug.retry.RetryDelay) + } + } + + return nil, lastErr +} + +// getCloudFlareTLSConfiguration returns an accepted client TLS configuration to not get detected by CloudFlare directly +// in case the configuration needs to be updated later on: https://wiki.mozilla.org/Security/Server_Side_TLS . +func getCloudFlareTLSConfiguration() *tls.Config { + return &tls.Config{ + CurvePreferences: []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.CurveP521, tls.X25519}, + } +} + +// GetDefaultOptions returns the options set by default +func GetDefaultOptions() Options { + return Options{ + AddMissingHeaders: true, + Headers: map[string]string{ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "User-Agent": GetRandomUserAgent(), + }, + } +} diff --git a/seanime-2.9.10/internal/util/slice.go b/seanime-2.9.10/internal/util/slice.go new file mode 100644 index 0000000..a329cd6 --- /dev/null +++ b/seanime-2.9.10/internal/util/slice.go @@ -0,0 +1,39 @@ +package util + +func SliceFrom[T any](slice []T, idx int) (ret []T, ok bool) { + if idx < 0 || idx >= len(slice) { + return []T{}, false + } + return slice[idx:], true +} + +func SliceTo[T any](slice []T, idx int) (ret []T, ok bool) { + if idx < 0 || idx >= len(slice) { + return []T{}, false + } + return slice[:idx], true +} + +func SliceStrFrom(slice string, idx int) (ret string, ok bool) { + if idx < 0 || idx >= len(slice) { + return "", false + } + return slice[idx:], true +} + +func SliceStrTo(slice string, idx int) (ret string, ok bool) { + if idx < 0 || idx >= len(slice) { + return "", false + } + return slice[:idx], true +} + +// Contains checks if a string slice contains a specific string +func Contains[T comparable](slice []T, item T) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/seanime-2.9.10/internal/util/spew.go b/seanime-2.9.10/internal/util/spew.go new file mode 100644 index 0000000..2c64fd7 --- /dev/null +++ b/seanime-2.9.10/internal/util/spew.go @@ -0,0 +1,28 @@ +package util + +import ( + "fmt" + "strings" + + "github.com/kr/pretty" +) + +func Spew(v interface{}) { + fmt.Printf("%# v\n", pretty.Formatter(v)) +} + +func SpewMany(v ...interface{}) { + fmt.Println("\nSpewing values:") + for _, val := range v { + Spew(val) + } + fmt.Println() +} + +func SpewT(v interface{}) string { + return fmt.Sprintf("%# v\n", pretty.Formatter(v)) +} + +func InlineSpewT(v interface{}) string { + return strings.ReplaceAll(SpewT(v), "\n", "") +} diff --git a/seanime-2.9.10/internal/util/strings.go b/seanime-2.9.10/internal/util/strings.go new file mode 100644 index 0000000..adb3bb8 --- /dev/null +++ b/seanime-2.9.10/internal/util/strings.go @@ -0,0 +1,273 @@ +package util + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "math/big" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "time" + "unicode" + + "github.com/dustin/go-humanize" +) + +func Bytes(size uint64) string { + switch runtime.GOOS { + case "darwin": + return humanize.Bytes(size) + default: + return humanize.IBytes(size) + } +} + +func Decode(s string) string { + decoded, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "" + } + return string(decoded) +} + +func GenerateCryptoID() string { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + panic(err) + } + return hex.EncodeToString(bytes) +} + +func IsMostlyLatinString(str string) bool { + if len(str) <= 0 { + return false + } + latinLength := 0 + nonLatinLength := 0 + for _, r := range str { + if isLatinRune(r) { + latinLength++ + } else { + nonLatinLength++ + } + } + return latinLength > nonLatinLength +} + +func isLatinRune(r rune) bool { + return unicode.In(r, unicode.Latin) +} + +// ToHumanReadableSpeed converts an integer representing bytes per second to a human-readable format using binary notation +func ToHumanReadableSpeed(bytesPerSecond int) string { + if bytesPerSecond <= 0 { + return `0 KiB/s` + } + + const unit = 1024 + if bytesPerSecond < unit { + return fmt.Sprintf("%d B/s", bytesPerSecond) + } + div, exp := int64(unit), 0 + for n := int64(bytesPerSecond) / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB/s", float64(bytesPerSecond)/float64(div), "KMGTPE"[exp]) +} + +func StringSizeToBytes(str string) (int64, error) { + // Regular expression to extract size and unit + re := regexp.MustCompile(`(?i)^(\d+(\.\d+)?)\s*([KMGT]?i?B)$`) + + match := re.FindStringSubmatch(strings.TrimSpace(str)) + if match == nil { + return 0, fmt.Errorf("invalid size format: %s", str) + } + + // Extract the numeric part and convert to float64 + size, err := strconv.ParseFloat(match[1], 64) + if err != nil { + return 0, fmt.Errorf("failed to parse size: %s", err) + } + + // Extract the unit and convert to lowercase + unit := strings.ToLower(match[3]) + + // Map units to their respective multipliers + unitMultipliers := map[string]int64{ + "b": 1, + "bi": 1, + "kb": 1024, + "kib": 1024, + "mb": 1024 * 1024, + "mib": 1024 * 1024, + "gb": 1024 * 1024 * 1024, + "gib": 1024 * 1024 * 1024, + "tb": 1024 * 1024 * 1024 * 1024, + "tib": 1024 * 1024 * 1024 * 1024, + } + + // Apply the multiplier based on the unit + multiplier, ok := unitMultipliers[unit] + if !ok { + return 0, fmt.Errorf("invalid unit: %s", unit) + } + + // Calculate the total bytes + bytes := int64(size * float64(multiplier)) + return bytes, nil +} + +// FormatETA formats an ETA (in seconds) into a human-readable string +func FormatETA(etaInSeconds int) string { + const noETA = 8640000 + + if etaInSeconds == noETA { + return "No ETA" + } + + etaDuration := time.Duration(etaInSeconds) * time.Second + + hours := int(etaDuration.Hours()) + minutes := int(etaDuration.Minutes()) % 60 + seconds := int(etaDuration.Seconds()) % 60 + + switch { + case hours > 0: + return fmt.Sprintf("%d hours left", hours) + case minutes > 0: + return fmt.Sprintf("%d minutes left", minutes) + case seconds < 0: + return "No ETA" + default: + return fmt.Sprintf("%d seconds left", seconds) + } +} + +func Pluralize(count int, singular, plural string) string { + if count == 1 { + return singular + } + return plural +} + +// NormalizePath normalizes a path by converting it to lowercase and replacing backslashes with forward slashes +// Warning: Do not use the returned string for anything filesystem related, only for comparison +func NormalizePath(path string) (ret string) { + return strings.ToLower(filepath.ToSlash(path)) +} + +func Base64EncodeStr(str string) string { + return base64.StdEncoding.EncodeToString([]byte(str)) +} + +func Base64DecodeStr(str string) (string, error) { + decoded, err := base64.StdEncoding.DecodeString(str) + if err != nil { + return "", err + } + return string(decoded), nil +} + +func IsBase64(s string) bool { + // 1. Check if string is empty + if len(s) == 0 { + return false + } + + // 2. Check if length is valid (must be multiple of 4) + if len(s)%4 != 0 { + return false + } + + // 3. Check for valid padding + padding := strings.Count(s, "=") + if padding > 2 { + return false + } + + // 4. Check if padding is at the end only + if padding > 0 && !strings.HasSuffix(s, strings.Repeat("=", padding)) { + return false + } + + // 5. Check if string contains only valid base64 characters + validChars := regexp.MustCompile("^[A-Za-z0-9+/]*=*$") + if !validChars.MatchString(s) { + return false + } + + // 6. Try to decode - this is the final verification + _, err := base64.StdEncoding.DecodeString(s) + return err == nil +} + +var snakecaseSplitRegex = regexp.MustCompile(`[\W_]+`) + +func Snakecase(str string) string { + var result strings.Builder + + // split at any non word character and underscore + words := snakecaseSplitRegex.Split(str, -1) + + for _, word := range words { + if word == "" { + continue + } + + if result.Len() > 0 { + result.WriteString("_") + } + + for i, c := range word { + if unicode.IsUpper(c) && i > 0 && + // is not a following uppercase character + !unicode.IsUpper(rune(word[i-1])) { + result.WriteString("_") + } + + result.WriteRune(c) + } + } + + return strings.ToLower(result.String()) +} + +// randomStringWithAlphabet generates a cryptographically random string +// with the specified length and characters set. +// +// It panics if for some reason rand.Int returns a non-nil error. +func RandomStringWithAlphabet(length int, alphabet string) string { + b := make([]byte, length) + max := big.NewInt(int64(len(alphabet))) + + for i := range b { + n, err := rand.Int(rand.Reader, max) + if err != nil { + panic(err) + } + b[i] = alphabet[n.Int64()] + } + + return string(b) +} + +func FileExt(str string) string { + lastDotIndex := strings.LastIndex(str, ".") + if lastDotIndex == -1 { + return "" + } + return str[lastDotIndex:] +} + +func HashSHA256Hex(s string) string { + h := sha256.New() + h.Write([]byte(s)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/seanime-2.9.10/internal/util/strings_test.go b/seanime-2.9.10/internal/util/strings_test.go new file mode 100644 index 0000000..32139bf --- /dev/null +++ b/seanime-2.9.10/internal/util/strings_test.go @@ -0,0 +1,55 @@ +package util + +import "testing" + +func TestSizeInBytes(t *testing.T) { + + tests := []struct { + size string + bytes int64 + }{ + {"1.5 gb", 1610612736}, + {"1.5 GB", 1610612736}, + {"1.5 GiB", 1610612736}, + {"385.5 mib", 404226048}, + } + + for _, test := range tests { + + bytes, err := StringSizeToBytes(test.size) + if err != nil { + t.Errorf("Error converting size to bytes: %s", err) + } + if bytes != test.bytes { + t.Errorf("Expected %d bytes, got %d", test.bytes, bytes) + } + + } + +} + +func TestIsBase64Encoded(t *testing.T) { + + tests := []struct { + str string + isBase64 bool + }{ + {"SGVsbG8gV29ybGQ=", true}, // "Hello World" + {"", false}, // Empty string + {"SGVsbG8gV29ybGQ", false}, // Invalid padding + {"SGVsbG8gV29ybGQ==", false}, // Invalid padding + {"SGVsbG8=V29ybGQ=", false}, // Padding in middle + {"SGVsbG8gV29ybGQ!!", false}, // Invalid characters + {"=SGVsbG8gV29ybGQ=", false}, // Padding at start + {"SGVsbG8gV29ybGQ===", false}, // Too much padding + {"A", false}, // Single character + {"AA==", true}, // Valid minimal string + {"YWJjZA==", true}, // "abcd" + } + + for _, test := range tests { + if IsBase64(test.str) != test.isBase64 { + t.Errorf("Expected %t for %s, got %t", test.isBase64, test.str, IsBase64(test.str)) + } + } +} diff --git a/seanime-2.9.10/internal/util/torrentutil/torrentutil.go b/seanime-2.9.10/internal/util/torrentutil/torrentutil.go new file mode 100644 index 0000000..8e81534 --- /dev/null +++ b/seanime-2.9.10/internal/util/torrentutil/torrentutil.go @@ -0,0 +1,494 @@ +package torrentutil + +import ( + "fmt" + "io" + "sync" + "time" + + "github.com/anacrolix/torrent" + "github.com/rs/zerolog" +) + +// +-----------------------+ +// + anacrolix/torrent + +// +-----------------------+ + +const ( + piecesForNow = int64(5) + piecesForHighBefore = int64(2) + piecesForNext = int64(30) + piecesForReadahead = int64(30) +) + +// readerInfo tracks information about an active reader +type readerInfo struct { + id string + position int64 + lastAccess time.Time +} + +// priorityManager manages piece priorities for multiple readers on the same file +type priorityManager struct { + mu sync.RWMutex + readers map[string]*readerInfo + torrent *torrent.Torrent + file *torrent.File + logger *zerolog.Logger +} + +// global map to track priority managers per torrent+file combination +var ( + priorityManagers = make(map[string]*priorityManager) + priorityManagersMu sync.RWMutex +) + +// getPriorityManager gets or creates a priority manager for a torrent+file combination +func getPriorityManager(t *torrent.Torrent, file *torrent.File, logger *zerolog.Logger) *priorityManager { + key := fmt.Sprintf("%s:%s", t.InfoHash().String(), file.Path()) + + priorityManagersMu.Lock() + defer priorityManagersMu.Unlock() + + if pm, exists := priorityManagers[key]; exists { + return pm + } + + pm := &priorityManager{ + readers: make(map[string]*readerInfo), + torrent: t, + file: file, + logger: logger, + } + priorityManagers[key] = pm + + // Start cleanup goroutine for the first manager + if len(priorityManagers) == 1 { + go pm.cleanupStaleReaders() + } + + return pm +} + +// registerReader registers a new reader with the priority manager +func (pm *priorityManager) registerReader(readerID string, position int64) { + pm.mu.Lock() + defer pm.mu.Unlock() + + pm.readers[readerID] = &readerInfo{ + id: readerID, + position: position, + lastAccess: time.Now(), + } + + pm.updatePriorities() +} + +// updateReaderPosition updates a reader's position and recalculates priorities +func (pm *priorityManager) updateReaderPosition(readerID string, position int64) { + pm.mu.Lock() + defer pm.mu.Unlock() + + if reader, exists := pm.readers[readerID]; exists { + reader.position = position + reader.lastAccess = time.Now() + pm.updatePriorities() + } +} + +// unregisterReader removes a reader from tracking +func (pm *priorityManager) unregisterReader(readerID string) { + pm.mu.Lock() + defer pm.mu.Unlock() + + delete(pm.readers, readerID) + + // If no more readers, clean up and recalculate priorities + if len(pm.readers) == 0 { + pm.resetAllPriorities() + } else { + pm.updatePriorities() + } +} + +// updatePriorities recalculates piece priorities based on all active readers +func (pm *priorityManager) updatePriorities() { + if pm.torrent == nil || pm.file == nil || pm.torrent.Info() == nil { + return + } + + t := pm.torrent + file := pm.file + pieceLength := t.Info().PieceLength + + if pieceLength == 0 { + if pm.logger != nil { + pm.logger.Warn().Msg("torrentutil: piece length is zero, cannot prioritize") + } + return + } + + numTorrentPieces := int64(t.NumPieces()) + if numTorrentPieces == 0 { + if pm.logger != nil { + pm.logger.Warn().Msg("torrentutil: torrent has zero pieces, cannot prioritize") + } + return + } + + // Calculate file piece range + fileFirstPieceIdx := file.Offset() / pieceLength + fileLastPieceIdx := (file.Offset() + file.Length() - 1) / pieceLength + + // Collect all needed piece ranges from all active readers + neededPieces := make(map[int64]torrent.PiecePriority) + + for _, reader := range pm.readers { + position := reader.position + // Remove 1MB from the position (for subtitle cluster) + position -= 1 * 1024 * 1024 + if position < 0 { + position = 0 + } + if position < 0 { + position = 0 + } + + currentGlobalSeekPieceIdx := (file.Offset() + position) / pieceLength + + // Pieces needed NOW (immediate) + for i := int64(0); i < piecesForNow; i++ { + idx := currentGlobalSeekPieceIdx + i + if idx >= fileFirstPieceIdx && idx <= fileLastPieceIdx && idx < numTorrentPieces { + if current, exists := neededPieces[idx]; !exists || current < torrent.PiecePriorityNow { + neededPieces[idx] = torrent.PiecePriorityNow + } + } + } + + // Pieces needed HIGH (before current position for rewinds) + for i := int64(1); i <= piecesForHighBefore; i++ { + idx := currentGlobalSeekPieceIdx - i + if idx >= fileFirstPieceIdx && idx <= fileLastPieceIdx && idx >= 0 { + if current, exists := neededPieces[idx]; !exists || current < torrent.PiecePriorityHigh { + neededPieces[idx] = torrent.PiecePriorityHigh + } + } + } + + // Pieces needed NEXT (immediate readahead) + nextStartIdx := currentGlobalSeekPieceIdx + piecesForNow + for i := int64(0); i < piecesForNext; i++ { + idx := nextStartIdx + i + if idx >= fileFirstPieceIdx && idx <= fileLastPieceIdx && idx < numTorrentPieces { + if current, exists := neededPieces[idx]; !exists || current < torrent.PiecePriorityNext { + neededPieces[idx] = torrent.PiecePriorityNext + } + } + } + + // Pieces needed for READAHEAD (further readahead) + readaheadStartIdx := nextStartIdx + piecesForNext + for i := int64(0); i < piecesForReadahead; i++ { + idx := readaheadStartIdx + i + if idx >= fileFirstPieceIdx && idx <= fileLastPieceIdx && idx < numTorrentPieces { + if current, exists := neededPieces[idx]; !exists || current < torrent.PiecePriorityReadahead { + neededPieces[idx] = torrent.PiecePriorityReadahead + } + } + } + } + + // Reset pieces that are no longer needed by any reader + for idx := fileFirstPieceIdx; idx <= fileLastPieceIdx; idx++ { + if idx < 0 || idx >= numTorrentPieces { + continue + } + + piece := t.Piece(int(idx)) + currentPriority := piece.State().Priority + + if neededPriority, needed := neededPieces[idx]; needed { + // Set to the highest priority needed by any reader + if currentPriority != neededPriority { + piece.SetPriority(neededPriority) + } + } else { + // Only reset to normal if not completely unwanted and not already at highest priority + if currentPriority != torrent.PiecePriorityNone && currentPriority != torrent.PiecePriorityNow { + piece.SetPriority(torrent.PiecePriorityNormal) + } + } + } + + if pm.logger != nil { + pm.logger.Debug().Msgf("torrentutil: Updated priorities for %d readers, %d pieces prioritized", len(pm.readers), len(neededPieces)) + } +} + +// resetAllPriorities resets all file pieces to normal priority +func (pm *priorityManager) resetAllPriorities() { + if pm.torrent == nil || pm.file == nil || pm.torrent.Info() == nil { + return + } + + t := pm.torrent + file := pm.file + pieceLength := t.Info().PieceLength + + if pieceLength == 0 { + return + } + + numTorrentPieces := int64(t.NumPieces()) + fileFirstPieceIdx := file.Offset() / pieceLength + fileLastPieceIdx := (file.Offset() + file.Length() - 1) / pieceLength + + for idx := fileFirstPieceIdx; idx <= fileLastPieceIdx; idx++ { + if idx >= 0 && idx < numTorrentPieces { + piece := t.Piece(int(idx)) + if piece.State().Priority != torrent.PiecePriorityNone { + piece.SetPriority(torrent.PiecePriorityNormal) + } + } + } +} + +// cleanupStaleReaders periodically removes readers that haven't been accessed recently +func (pm *priorityManager) cleanupStaleReaders() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + pm.mu.Lock() + cutoff := time.Now().Add(-2 * time.Minute) + + for id, reader := range pm.readers { + if reader.lastAccess.Before(cutoff) { + delete(pm.readers, id) + if pm.logger != nil { + pm.logger.Debug().Msgf("torrentutil: Cleaned up stale reader %s", id) + } + } + } + + // Update priorities after cleanup + if len(pm.readers) > 0 { + pm.updatePriorities() + } + + pm.mu.Unlock() + } +} + +// ReadSeeker implements io.ReadSeekCloser for a torrent file being streamed. +// It allows dynamic prioritization of pieces when seeking, optimized for streaming +// and supports multiple concurrent readers on the same file. +type ReadSeeker struct { + id string + torrent *torrent.Torrent + file *torrent.File + reader torrent.Reader + priorityManager *priorityManager + logger *zerolog.Logger +} + +var _ io.ReadSeekCloser = &ReadSeeker{} + +func NewReadSeeker(t *torrent.Torrent, file *torrent.File, logger ...*zerolog.Logger) io.ReadSeekCloser { + tr := file.NewReader() + tr.SetResponsive() + // Read ahead 5MB for better streaming performance + // DEVNOTE: Not sure if dynamic prioritization overwrites this but whatever + tr.SetReadahead(5 * 1024 * 1024) + + var loggerPtr *zerolog.Logger + if len(logger) > 0 { + loggerPtr = logger[0] + } + + pm := getPriorityManager(t, file, loggerPtr) + + rs := &ReadSeeker{ + id: fmt.Sprintf("reader_%d_%d", time.Now().UnixNano(), len(pm.readers)), + torrent: t, + file: file, + reader: tr, + priorityManager: pm, + logger: loggerPtr, + } + + // Register this reader with the priority manager + pm.registerReader(rs.id, 0) + + return rs +} + +func (rs *ReadSeeker) Read(p []byte) (n int, err error) { + return rs.reader.Read(p) +} + +func (rs *ReadSeeker) Seek(offset int64, whence int) (int64, error) { + newOffset, err := rs.reader.Seek(offset, whence) + if err != nil { + if rs.logger != nil { + rs.logger.Error().Err(err).Int64("offset", offset).Int("whence", whence).Msg("torrentutil: ReadSeeker seek error") + } + return newOffset, err + } + + // Update this reader's position in the priority manager + rs.priorityManager.updateReaderPosition(rs.id, newOffset) + + return newOffset, nil +} + +// Close closes the underlying torrent file reader and unregisters from priority manager. +// This makes ReadSeeker implement io.ReadSeekCloser. +func (rs *ReadSeeker) Close() error { + // Unregister from priority manager + rs.priorityManager.unregisterReader(rs.id) + + if rs.reader != nil { + return rs.reader.Close() + } + return nil +} + +// PrioritizeDownloadPieces sets high priority for the first 3% of pieces and the last few pieces to ensure faster loading. +func PrioritizeDownloadPieces(t *torrent.Torrent, file *torrent.File, logger *zerolog.Logger) { + // Calculate file's pieces + firstPieceIdx := file.Offset() * int64(t.NumPieces()) / t.Length() + endPieceIdx := (file.Offset() + file.Length()) * int64(t.NumPieces()) / t.Length() + + // Prioritize more pieces at the beginning for faster initial loading (3% for beginning) + numPiecesForStart := (endPieceIdx - firstPieceIdx + 1) * 3 / 100 + if logger != nil { + logger.Debug().Msgf("torrentuil: Setting high priority for first 3%% - pieces %d to %d (total %d)", + firstPieceIdx, firstPieceIdx+numPiecesForStart, numPiecesForStart) + } + for idx := firstPieceIdx; idx <= firstPieceIdx+numPiecesForStart; idx++ { + t.Piece(int(idx)).SetPriority(torrent.PiecePriorityNow) + } + + // Also prioritize the last few pieces + numPiecesForEnd := (endPieceIdx - firstPieceIdx + 1) * 1 / 100 + if logger != nil { + logger.Debug().Msgf("torrentuil: Setting priority for last pieces %d to %d (total %d)", + endPieceIdx-numPiecesForEnd, endPieceIdx, numPiecesForEnd) + } + for idx := endPieceIdx - numPiecesForEnd; idx <= endPieceIdx; idx++ { + if idx >= 0 && int(idx) < t.NumPieces() { + t.Piece(int(idx)).SetPriority(torrent.PiecePriorityNow) + } + } +} + +// PrioritizeRangeRequestPieces attempts to prioritize pieces needed for the range request. +func PrioritizeRangeRequestPieces(rangeHeader string, t *torrent.Torrent, file *torrent.File, logger *zerolog.Logger) { + // Parse the range header (format: bytes=START-END) + var start int64 + _, _ = fmt.Sscanf(rangeHeader, "bytes=%d-", &start) + + if start >= 0 { + // Calculate file's pieces range + fileOffset := file.Offset() + fileLength := file.Length() + + // Calculate the total range of pieces for this file + firstFilePieceIdx := fileOffset * int64(t.NumPieces()) / t.Length() + endFilePieceIdx := (fileOffset + fileLength) * int64(t.NumPieces()) / t.Length() + + // Calculate the piece index for this seek offset with small padding + // Subtract a small amount to ensure we don't miss the beginning of a needed piece + seekPosition := start + if seekPosition >= 1024*1024 { // If we're at least 1MB in, add some padding + seekPosition -= 1024 * 512 // Subtract 512KB to ensure we get the right piece + } + seekPieceIdx := (fileOffset + seekPosition) * int64(t.NumPieces()) / t.Length() + + // Prioritize the next several pieces from this point + // This is especially important for seeking + numPiecesToPrioritize := int64(10) // Prioritize next 10 pieces, adjust as needed + + if seekPieceIdx+numPiecesToPrioritize > endFilePieceIdx { + numPiecesToPrioritize = endFilePieceIdx - seekPieceIdx + } + + if logger != nil { + logger.Debug().Msgf("torrentutil: Prioritizing range request pieces %d to %d", + seekPieceIdx, seekPieceIdx+numPiecesToPrioritize) + } + + // Set normal priority for pieces far from our current position + // This allows background downloading while still prioritizing the seek point + for idx := firstFilePieceIdx; idx <= endFilePieceIdx; idx++ { + if idx >= 0 && int(idx) < t.NumPieces() { + // Don't touch the beginning pieces which should maintain their high priority + // for the next potential restart, and don't touch pieces near our seek point + if idx > firstFilePieceIdx+100 && idx < seekPieceIdx-100 || + idx > seekPieceIdx+numPiecesToPrioritize+100 { + // Set to normal priority - allow background downloading + t.Piece(int(idx)).SetPriority(torrent.PiecePriorityNormal) + } + } + } + + // Now set the highest priority for the pieces we need right now + for idx := seekPieceIdx; idx < seekPieceIdx+numPiecesToPrioritize; idx++ { + if idx >= 0 && int(idx) < t.NumPieces() { + t.Piece(int(idx)).SetPriority(torrent.PiecePriorityNow) + } + } + + // Also prioritize a small buffer before the seek point to handle small rewinds + // This is useful for MPV's default rewind behavior + bufferBeforeCount := int64(5) // 5 pieces buffer before seek point + if seekPieceIdx > firstFilePieceIdx+bufferBeforeCount { + for idx := seekPieceIdx - bufferBeforeCount; idx < seekPieceIdx; idx++ { + if idx >= 0 && int(idx) < t.NumPieces() { + t.Piece(int(idx)).SetPriority(torrent.PiecePriorityHigh) + } + } + } + + // Also prioritize the next readahead segment after our immediate needs + // This helps prepare for continued playback + nextReadStart := seekPieceIdx + numPiecesToPrioritize + nextReadCount := int64(100) // 100 additional pieces for nextRead + if nextReadStart+nextReadCount > endFilePieceIdx { + nextReadCount = endFilePieceIdx - nextReadStart + } + + if nextReadCount > 0 { + if logger != nil { + logger.Debug().Msgf("torrentutil: Setting next priority for pieces %d to %d", + nextReadStart, nextReadStart+nextReadCount) + } + for idx := nextReadStart; idx < nextReadStart+nextReadCount; idx++ { + if idx >= 0 && int(idx) < t.NumPieces() { + t.Piece(int(idx)).SetPriority(torrent.PiecePriorityNext) + } + } + } + + // Also prioritize the next readahead segment after our immediate needs + // This helps prepare for continued playback + readAheadCount := int64(100) + if nextReadStart+readAheadCount > endFilePieceIdx { + readAheadCount = endFilePieceIdx - nextReadStart + } + + if readAheadCount > 0 { + if logger != nil { + logger.Debug().Msgf("torrentutil: Setting read ahead priority for pieces %d to %d", + nextReadStart, nextReadStart+readAheadCount) + } + for idx := nextReadStart; idx < nextReadStart+readAheadCount; idx++ { + if idx >= 0 && int(idx) < t.NumPieces() { + t.Piece(int(idx)).SetPriority(torrent.PiecePriorityReadahead) + } + } + } + } +} diff --git a/seanime-2.9.10/internal/util/user_agent.go b/seanime-2.9.10/internal/util/user_agent.go new file mode 100644 index 0000000..1c9432f --- /dev/null +++ b/seanime-2.9.10/internal/util/user_agent.go @@ -0,0 +1,77 @@ +package util + +import ( + "bufio" + "encoding/json" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +var ( + userAgentList []string + uaMu sync.RWMutex +) + +func init() { + go func() { + defer func() { + if r := recover(); r != nil { + log.Warn().Msgf("util: Failed to get online user agents: %v", r) + } + }() + + agents, err := getOnlineUserAgents() + if err != nil { + log.Warn().Err(err).Msg("util: Failed to get online user agents") + return + } + + uaMu.Lock() + userAgentList = agents + uaMu.Unlock() + }() +} + +func getOnlineUserAgents() ([]string, error) { + link := "https://raw.githubusercontent.com/fake-useragent/fake-useragent/refs/heads/main/src/fake_useragent/data/browsers.jsonl" + + client := &http.Client{ + Timeout: 10 * time.Second, + } + response, err := client.Get(link) + if err != nil { + return nil, err + } + defer response.Body.Close() + + var agents []string + type UserAgent struct { + UserAgent string `json:"useragent"` + } + + scanner := bufio.NewScanner(response.Body) + for scanner.Scan() { + line := scanner.Text() + var ua UserAgent + if err := json.Unmarshal([]byte(line), &ua); err != nil { + return nil, err + } + agents = append(agents, ua.UserAgent) + } + + return agents, nil +} + +func GetRandomUserAgent() string { + uaMu.RLock() + defer uaMu.RUnlock() + + if len(userAgentList) > 0 { + return userAgentList[rand.Intn(len(userAgentList))] + } + return UserAgentList[rand.Intn(len(UserAgentList))] +} diff --git a/seanime-2.9.10/internal/util/user_agent_list.go b/seanime-2.9.10/internal/util/user_agent_list.go new file mode 100644 index 0000000..10d4f38 --- /dev/null +++ b/seanime-2.9.10/internal/util/user_agent_list.go @@ -0,0 +1,9983 @@ +package util + +var UserAgentList = []string{ + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 5.1.1; KFDOWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/108.15.4 like Chrome/108.0.5359.220 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14092.77.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.107 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/600.8.9 (KHTML, like Gecko) Version/8.0.8 Safari/600.8.9", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/339.0.686111475 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/130.0.2849.68 Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.169 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/485.1.0.45.110;FBBV/665337277;FBDV/iPhone15,5;FBMD/iPhone;FBSN/iOS;FBSV/17.3;FBSS/3;FBCR/;FBID/phone;FBLC/en_US;FBOP/80]", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 12; HD1901 Build/SKQ1.211113.001; ) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.2478.64", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1 Ddg/17.2", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.137 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/21.0 Chrome/110.0.5481.154 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Android 14; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/331.0.665236494 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:130.0) Gecko/20100101 Firefox/130.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/209.0.442442103 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 9; KFTRWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/130.4.1 like Chrome/130.0.6723.102 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.33 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/118.0.5993.92 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/326.0.653331328 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132 Mobile/15E148 Version/15.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.2 Safari/605.1.15", + "Mozilla/5.0 (iPad; CPU OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/338.1.685896509 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.37 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.78 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22B91 Twitter for iPhone/10.68.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22B91 Twitter for iPhone/10.68.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 9; KFTRWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/130.4.1 like Chrome/130.0.6723.102 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/262.0.527316235 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:109.0) Gecko/20100101 Firefox/115.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:109.0) Gecko/20100101 Firefox/115.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Android 8.1.0; Mobile; rv:123.0) Gecko/123.0 Firefox/123.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/339.0.686111475 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 12; SAMSUNG SM-S127DL) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/16.0 Chrome/92.0.4515.166 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/308.0.615969171 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/324.0.648915268 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.8 Mobile/15E148 Safari/604.1 Ddg/15.8", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/236.0.484392333 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.118 Mobile Safari/537.36 XiaoMi/MiuiBrowser/14.22.1-gn", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/485.1.0.45.110;FBBV/665337277;FBDV/iPhone14,5;FBMD/iPhone;FBSN/iOS;FBSV/18.0.1;FBSS/3;FBCR/;FBID/phone;FBLC/en_US;FBOP/80]", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.3 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6315.2 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1 Ddg/17.6", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/334.0.674067880 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 10_3_3 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) GSA/68.0.234683655 Mobile/14G60 Safari/602.1", + "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/251.0.508228821 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Android 10; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/129.0.6668.46 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPad; CPU OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/317.0.634488990 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.137 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/324.0.648915268 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/128.0.6613.98 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14816.131.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 14_8_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/285.0.570543384 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/341.3.692278309 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/95.0.4638.50 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/87.0.4280.77 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 YaBrowser/24.10.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/341.3.692278309 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/117.0.5938.117 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/309.0.616685607 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1 OPT/5.1.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/326.0.653331328 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/112.0.5615.46 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 6.0.1; Z978) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/114.0.5735.124 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/341.3.692278309 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0 (Edition std-1)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/334.0.674067880 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15 Ddg/15.7", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; SAMSUNG SM-A107F) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/18.0 Chrome/99.0.4844.88 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/21F90 Twitter for iPhone/10.68.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.3 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/295.0.590048842 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/21G93 Twitter for iPhone/10.68.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/250.0.505561494 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/485.1.0.45.110;FBBV/665337277;FBDV/iPhone17,3;FBMD/iPhone;FBSN/iOS;FBSV/18.1.1;FBSS/3;FBCR/;FBID/phone;FBLC/en_GB;FBOP/80]", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.107 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/304.0.607401217 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22B91 [FBAN/FBIOS;FBAV/491.1.0.62.114;FBBV/667691447;FBDV/iPhone15,3;FBMD/iPhone;FBSN/iOS;FBSV/18.1.1;FBSS/3;FBID/phone;FBLC/en_US;FBOP/5;FBRV/669286544;IABMV/1]", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 9; SM-G955U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/71.1.241847734 Mobile/15E148 Safari/605.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/267.0.537056344 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36 OPR/86.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.111 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/129.0.6668.69 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/308.0.615969171 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/21G93 Twitter for iPhone/10.68.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 Ddg/18.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.107 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Whale/3.6.6.2 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/338.1.685896509 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 OPX/2.6.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1 Ddg/17.6", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/307.0.613445279 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/114.0.5735.124 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/274.0.549390226 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.153 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-S111DL) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/13.2 Chrome/83.0.4103.106 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/295.0.590048842 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/133.0 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/130.0.0.0 Mobile DuckDuckGo/5 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/17.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/267.0.537056344 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/307.0.613445279 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.5511.1627 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/341.3.692278309 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 OPR/85.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/485.1.0.45.110;FBBV/665337277;FBDV/iPhone14,5;FBMD/iPhone;FBSN/iOS;FBSV/17.6.1;FBSS/3;FBCR/;FBID/phone;FBLC/el_GR;FBOP/80]", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.1781.1915 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.7735.1194 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Viewer/98.9.8233.81", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.72 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/107.0.5304.66 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/175.0.393249130 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Android 10; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.77 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.153 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.78 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/274.0.549390226 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.5468.1892 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/122.0.6261.89 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 13_1_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/130.0.0.0 Mobile DuckDuckGo/5 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/331.0.665236494 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1 Ddg/17.6", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.107 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/338.1.685896509 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.0.2903.68 Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 11; EC211001) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/133.0 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 Ddg/18.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/133.0 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/339.0.686111475 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.77 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/302.0.603406840 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.117 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_7_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 12; RMX3363 Build/RKQ1.210503.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/130.0.6723.107 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/485.2.0.68.111;]", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.37 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.153 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/221.0.461030601 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 12_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/92.0.4515.90 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/339.0.686111475 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.50 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 CCleaner/130.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 Ddg/18.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/340.3.689937600 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/93.0.4577.78 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22B91 Twitter for iPhone/10.68.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1 Ddg/16.2", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.169 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.78 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.140", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Avast/130.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 OPR/113.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 9; KFTRWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/130.4.1 like Chrome/130.0.6723.102 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/334.0.674067880 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/125.0.6422.80 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/130.0.2849.80 Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/255.0.515161012 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.37 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/84.0.4147.122 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.3 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 6.1; rv:109.0) Gecko/20100101 Firefox/115.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/295.0.590048842 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 12; SM-S127DL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 14; moto g play - 2024 Build/U1TFS34.100-35-4-1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.107 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.153 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:109.0) Gecko/20100101 Firefox/115.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.169 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 12_5_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/328.0.658461140 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/128.0.6613.92 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15 Ddg/17.7", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/25.0 Chrome/121.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.5238.1570 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1 Ddg/17.6", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Unique/97.7.7269.70", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_7_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.4 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1 Ddg/18.2", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/341.3.692278309 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/321.1.642804631 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/127.0.6533.107 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/103.0.5060.63 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 Ddg/18.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/131.0 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/342.0.693598186 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/101.0.4951.58 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Android 13; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/286.0.572986368 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36 OPR/86.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/133.0 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131 Version/11.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/21G93 Twitter for iPhone/10.18", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0 (Edition std-2)", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/133.0 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Android 15; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:109.0) Gecko/20100101 Firefox/115.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/122.0.6261.89 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/120.0.2210.150 Version/17.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/132.0.6834.14 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 OPR/85.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.3 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.88 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/298.0.595435837 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/128.0.6613.92 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/93.0.4577.39 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 11; KFSNWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/130.4.1 like Chrome/130.0.6723.102 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/20G81 Twitter for iPhone/10.68.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.31 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/265.0.533000180 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.1 Mobile/15E148 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Android 14; Mobile; rv:134.0) Gecko/134.0 Firefox/134.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; CrOS x86_64 14989.107.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 OPR/85.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPad; CPU OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1 Ddg/18.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/344.0.697830199 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 EdgA/131.0.0.0", + "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/343.0.695551749 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", +} diff --git a/seanime-2.9.10/internal/util/user_agent_test.go b/seanime-2.9.10/internal/util/user_agent_test.go new file mode 100644 index 0000000..bdb0ae6 --- /dev/null +++ b/seanime-2.9.10/internal/util/user_agent_test.go @@ -0,0 +1,59 @@ +package util + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestGetOnlineUserAgents(t *testing.T) { + userAgents, err := getOnlineUserAgents() + if err != nil { + t.Fatalf("Failed to get online user agents: %v", err) + } + t.Logf("Online user agents: %v", userAgents) +} + +func TestTransformUserAgentJsonlToSliceFile(t *testing.T) { + + jsonlFilePath := filepath.Join("data", "user_agents.jsonl") + + jsonlFile, err := os.Open(jsonlFilePath) + if err != nil { + t.Fatalf("Failed to open JSONL file: %v", err) + } + defer jsonlFile.Close() + + sliceFilePath := filepath.Join("user_agent_list.go") + sliceFile, err := os.Create(sliceFilePath) + if err != nil { + t.Fatalf("Failed to create slice file: %v", err) + } + defer sliceFile.Close() + + sliceFile.WriteString("package util\n\nvar UserAgentList = []string{\n") + + type UserAgent struct { + UserAgent string `json:"useragent"` + } + + scanner := bufio.NewScanner(jsonlFile) + for scanner.Scan() { + line := scanner.Text() + var ua UserAgent + if err := json.Unmarshal([]byte(line), &ua); err != nil { + t.Fatalf("Failed to unmarshal line: %v", err) + } + sliceFile.WriteString(fmt.Sprintf("\t\"%s\",\n", ua.UserAgent)) + } + sliceFile.WriteString("}\n") + + if err := scanner.Err(); err != nil { + t.Fatalf("Failed to read JSONL file: %v", err) + } + + t.Logf("User agent list generated successfully: %s", sliceFilePath) +} diff --git a/seanime-2.9.10/internal/util/useragent.go b/seanime-2.9.10/internal/util/useragent.go new file mode 100644 index 0000000..55949a9 --- /dev/null +++ b/seanime-2.9.10/internal/util/useragent.go @@ -0,0 +1,48 @@ +package util + +import "github.com/mileusna/useragent" + +const ( + PlatformAndroid = "android" + PlatformIOS = "ios" + PlatformLinux = "linux" + PlatformMac = "mac" + PlatformWindows = "windows" + PlatformChromeOS = "chromeos" +) + +const ( + DeviceDesktop = "desktop" + DeviceMobile = "mobile" + DeviceTablet = "tablet" +) + +type ClientInfo struct { + Device string + Platform string +} + +func GetClientInfo(userAgent string) ClientInfo { + ua := useragent.Parse(userAgent) + + var device string + var platform string + + if ua.Mobile { + device = DeviceMobile + } else if ua.Tablet { + device = DeviceTablet + } else { + device = DeviceDesktop + } + + platform = ua.OS + if platform == "" { + platform = "-" + } + + return ClientInfo{ + Device: device, + Platform: platform, + } +} diff --git a/seanime-2.9.10/internal/util/version.go b/seanime-2.9.10/internal/util/version.go new file mode 100644 index 0000000..2998bba --- /dev/null +++ b/seanime-2.9.10/internal/util/version.go @@ -0,0 +1,78 @@ +package util + +import ( + "strconv" + "strings" + + "github.com/Masterminds/semver/v3" +) + +func IsValidVersion(version string) bool { + parts := strings.Split(version, ".") + if len(parts) != 3 { + return false + } + + for _, part := range parts { + if _, err := strconv.Atoi(part); err != nil { + return false + } + } + + return true +} + +// CompareVersion compares two versions and returns the difference between them. +// +// 3: Current version is newer by major version. +// 2: Current version is newer by minor version. +// 1: Current version is newer by patch version. +// -3: Current version is older by major version. +// -2: Current version is older by minor version. +// -1: Current version is older by patch version. +func CompareVersion(current string, b string) (comp int, shouldUpdate bool) { + + currV, err := semver.NewVersion(current) + if err != nil { + return 0, false + } + otherV, err := semver.NewVersion(b) + if err != nil { + return 0, false + } + + comp = currV.Compare(otherV) + if comp == 0 { + return 0, false + } + + if currV.GreaterThan(otherV) { + shouldUpdate = false + + if currV.Major() > otherV.Major() { + comp *= 3 + } else if currV.Minor() > otherV.Minor() { + comp *= 2 + } else if currV.Patch() > otherV.Patch() { + comp *= 1 + } + } else if currV.LessThan(otherV) { + shouldUpdate = true + + if currV.Major() < otherV.Major() { + comp *= 3 + } else if currV.Minor() < otherV.Minor() { + comp *= 2 + } else if currV.Patch() < otherV.Patch() { + comp *= 1 + } + } + + return comp, shouldUpdate +} + +func VersionIsOlderThan(version string, compare string) bool { + comp, shouldUpdate := CompareVersion(version, compare) + // shouldUpdate is false means the current version is newer + return comp < 0 && shouldUpdate +} diff --git a/seanime-2.9.10/internal/util/version_test.go b/seanime-2.9.10/internal/util/version_test.go new file mode 100644 index 0000000..8ed6b6c --- /dev/null +++ b/seanime-2.9.10/internal/util/version_test.go @@ -0,0 +1,248 @@ +package util + +import ( + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" + "testing" +) + +func TestCompareVersion(t *testing.T) { + testCases := []struct { + name string + otherVersion string + currVersion string + expectedOutput int + shouldUpdate bool + }{ + { + name: "Current version is newer by major version", + currVersion: "2.0.0", + otherVersion: "1.0.0", + expectedOutput: 3, + shouldUpdate: false, + }, + { + name: "Current version is older by major version", + currVersion: "2.0.0", + otherVersion: "3.0.0", + expectedOutput: -3, + shouldUpdate: true, + }, + { + name: "Current version is older by minor version", + currVersion: "0.2.2", + otherVersion: "0.3.0", + expectedOutput: -2, + shouldUpdate: true, + }, + { + name: "Current version is older by major version", + currVersion: "0.2.2", + otherVersion: "3.0.0", + expectedOutput: -3, + shouldUpdate: true, + }, + { + name: "Current version is older by minor version", + currVersion: "0.2.2", + otherVersion: "0.2.3", + expectedOutput: -1, + shouldUpdate: true, + }, + { + name: "Current version is newer by minor version", + currVersion: "1.2.0", + otherVersion: "1.1.0", + expectedOutput: 2, + shouldUpdate: false, + }, + { + name: "Current version is older by minor version", + currVersion: "1.2.0", + otherVersion: "1.3.0", + expectedOutput: -2, + shouldUpdate: true, + }, + { + name: "Current version is newer by patch version", + currVersion: "1.1.2", + otherVersion: "1.1.1", + expectedOutput: 1, + shouldUpdate: false, + }, + { + name: "Current version is older by patch version", + currVersion: "1.1.2", + otherVersion: "1.1.3", + expectedOutput: -1, + shouldUpdate: true, + }, + { + name: "Versions are equal", + currVersion: "1.1.1", + otherVersion: "1.1.1", + expectedOutput: 0, + shouldUpdate: false, + }, + { + name: "Current version is newer by patch version", + currVersion: "1.1.1", + otherVersion: "1.1", + expectedOutput: 1, + shouldUpdate: false, + }, + { + name: "Current version is newer by minor version + prerelease", + currVersion: "2.2.0-prerelease", + otherVersion: "2.1.0", + expectedOutput: 2, + shouldUpdate: false, + }, + { + name: "Current version is newer (not prerelease)", + currVersion: "2.2.0", + otherVersion: "2.2.0-prerelease", + expectedOutput: 1, + shouldUpdate: false, + }, + { + name: "Current version is older (is prerelease)", + currVersion: "2.2.0-prerelease", + otherVersion: "2.2.0", + expectedOutput: -1, + shouldUpdate: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + output, boolOutput := CompareVersion(tc.currVersion, tc.otherVersion) + if output != tc.expectedOutput || boolOutput != tc.shouldUpdate { + t.Errorf("Expected output to be %d and shouldUpdate to be %v, got output=%d and shouldUpdate=%v", tc.expectedOutput, tc.shouldUpdate, output, boolOutput) + } + }) + } +} + +func TestVersionIsOlderThan(t *testing.T) { + + testCases := []struct { + name string + version string + compare string + isOlder bool + }{ + { + name: "Version is older than compare", + version: "1.7.3", + compare: "2.0.0", + isOlder: true, + }, + { + name: "Version is newer than compare", + version: "2.0.1", + compare: "2.0.0", + isOlder: false, + }, + { + name: "Version is equal to compare", + version: "2.0.0", + compare: "2.0.0", + isOlder: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + output := VersionIsOlderThan(tc.version, tc.compare) + if output != tc.isOlder { + t.Errorf("Expected output to be %v, got %v", tc.isOlder, output) + } + }) + } + +} + +func TestHasUpdated(t *testing.T) { + + testCases := []struct { + name string + previousVersion string + currentVersion string + hasUpdated bool + }{ + { + name: "previousVersion is older than currentVersion", + previousVersion: "1.7.3", + currentVersion: "2.0.0", + hasUpdated: true, + }, + { + name: "previousVersion is newer than currentVersion", + previousVersion: "2.0.1", + currentVersion: "2.0.0", + hasUpdated: false, + }, + { + name: "previousVersion is equal to currentVersion", + previousVersion: "2.0.0", + currentVersion: "2.0.0", + hasUpdated: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + hasUpdated := VersionIsOlderThan(tc.previousVersion, tc.currentVersion) + if hasUpdated != tc.hasUpdated { + t.Errorf("Expected output to be %v, got %v", tc.hasUpdated, hasUpdated) + } + }) + } + +} + +func TestSemverConstraints(t *testing.T) { + + testCases := []struct { + name string + version string + constraints string + expectedOutput bool + }{ + { + name: "Version is within constraint", + version: "1.2.0", + constraints: ">= 1.2.0, <= 1.3.0", + expectedOutput: true, + }, + { + name: "Updating from 2.0.0", + version: "2.0.1", + constraints: "< 2.1.0", + expectedOutput: true, + }, + { + name: "Version is still 2.1.0", + version: "2.1.0", + constraints: "< 2.1.0", + expectedOutput: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + c, err := semver.NewConstraint(tc.constraints) + require.NoError(t, err) + + v, err := semver.NewVersion(tc.version) + require.NoError(t, err) + + output := c.Check(v) + if output != tc.expectedOutput { + t.Errorf("Expected output to be %v, got %v for version %s and constraint %s", tc.expectedOutput, output, tc.version, tc.constraints) + } + }) + } + +} diff --git a/seanime-2.9.10/internal/vendor_habari/vendor_habari.go b/seanime-2.9.10/internal/vendor_habari/vendor_habari.go new file mode 100644 index 0000000..9d27f89 --- /dev/null +++ b/seanime-2.9.10/internal/vendor_habari/vendor_habari.go @@ -0,0 +1,28 @@ +package vendor_habari + +type Metadata struct { + SeasonNumber []string `json:"season_number,omitempty"` + PartNumber []string `json:"part_number,omitempty"` + Title string `json:"title,omitempty"` + FormattedTitle string `json:"formatted_title,omitempty"` + AnimeType []string `json:"anime_type,omitempty"` + Year string `json:"year,omitempty"` + AudioTerm []string `json:"audio_term,omitempty"` + DeviceCompatibility []string `json:"device_compatibility,omitempty"` + EpisodeNumber []string `json:"episode_number,omitempty"` + OtherEpisodeNumber []string `json:"other_episode_number,omitempty"` + EpisodeNumberAlt []string `json:"episode_number_alt,omitempty"` + EpisodeTitle string `json:"episode_title,omitempty"` + FileChecksum string `json:"file_checksum,omitempty"` + FileExtension string `json:"file_extension,omitempty"` + FileName string `json:"file_name,omitempty"` + Language []string `json:"language,omitempty"` + ReleaseGroup string `json:"release_group,omitempty"` + ReleaseInformation []string `json:"release_information,omitempty"` + ReleaseVersion []string `json:"release_version,omitempty"` + Source []string `json:"source,omitempty"` + Subtitles []string `json:"subtitles,omitempty"` + VideoResolution string `json:"video_resolution,omitempty"` + VideoTerm []string `json:"video_term,omitempty"` + VolumeNumber []string `json:"volume_number,omitempty"` +} \ No newline at end of file diff --git a/seanime-2.9.10/main.go b/seanime-2.9.10/main.go new file mode 100644 index 0000000..b34d888 --- /dev/null +++ b/seanime-2.9.10/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "embed" + "seanime/internal/server" +) + +//go:embed all:web +var WebFS embed.FS + +//go:embed internal/icon/logo.png +var embeddedLogo []byte + +func main() { + server.StartServer(WebFS, embeddedLogo) +} diff --git a/seanime-2.9.10/seanime-denshi/.gitignore b/seanime-2.9.10/seanime-denshi/.gitignore new file mode 100644 index 0000000..0cc19de --- /dev/null +++ b/seanime-2.9.10/seanime-denshi/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +binaries/ +.DS_Store +web-denshi/ diff --git a/seanime-2.9.10/seanime-denshi/README.md b/seanime-2.9.10/seanime-denshi/README.md new file mode 100644 index 0000000..f8677dc --- /dev/null +++ b/seanime-2.9.10/seanime-denshi/README.md @@ -0,0 +1,156 @@ +<p align="center"> +<img src="../seanime-web/public/logo_2.png" alt="preview" width="150px"/> +</p> + +<h2 align="center"><b>Seanime Denshi</b></h2> + +<p align="center"> +Electron-based desktop client for Seanime. Embeds server and web interface. Successor to Seanime Desktop. +</p> + +<p align="center"> +<img src="../docs/images/4/anime-entry-torrent-stream--sq.jpg" alt="preview" width="70%"/> +</p> + +--- + +## Prerequisites + +- Go 1.24+ +- Node.js 20+ and npm + +--- + +## Seanime Denshi vs Seanime Desktop + +Pros: +- Linux support +- Better consistency accross platforms (fewer bugs) +- Built-in player support for torrent/debrid streaming without transcoding + +Cons: +- Greater memory usage +- Larger binary size (from ~80mb to ~300mb) + +## TODO + +- [ ] Built-in player + - Server: Stream subtitle extraction, thumbnail generation +- [ ] Testing on Windows (Fix titlebar in fullscreen) +- [ ] Fix crash screen +- [ ] Test server reconnection +- [ ] Test updates, auto updates + +## Development + +### Web Interface + +```shell +# Working dir: ./seanime-web +npm run dev:denshi +``` + +### Sidecar + +1. Build the server + + ```shell + # Working dir: . + + # Windows + go build -o seanime.exe -trimpath -ldflags="-s -w" -tags=nosystray + + # Linux, macOS + go build -o seanime -trimpath -ldflags="-s -w" + ``` + +2. Move the binary to `./seanime-denshi/binaries` + +3. Rename the binary: + + - For Windows: `seanime-server-windows.exe` + - For macOS/Intel: `seanime-server-darwin-amd64` + - For macOS/ARM: `seanime-server-darwin-arm64` + - For Linux/x86_64: `seanime-server-linux-amd64` + - For Linux/ARM64: `seanime-server-linux-arm64` + +### Electron + +1. Setup + + ```shell + # Working dir: ./seanime-denshi + npm install + ``` + +2. Run + + `TEST_DATADIR` can be used in development mode, it should point to a dummy data directory for testing purposes. + + ```shell + # Working dir: ./seanime-desktop + TEST_DATADIR="/path/to/data/dir" npm run dev + ``` + +--- + +## Build + +### Web Interface + +```shell +# Working dir: ./seanime-web +npm run build +npm run build:denshi +``` + +Move the output `./seanime-web/out` to `./web` +Move the output `./seanime-web/out-denshi` to `./seanime-denshi/web-denshi` + +```shell +# UNIX command +mv ./seanime-web/out ./web +mv ./seanime-web/out-denshi ./seanime-denshi/web-denshi +``` + +### Sidecar + +1. Build the server + + ```shell + # Working dir: . + + # Windows + go build -o seanime.exe -trimpath -ldflags="-s -w" -tags=nosystray + + # Linux, macOS + go build -o seanime -trimpath -ldflags="-s -w" + ``` + +2. Move the binary to `./seanime-denshi/binaries` + +3. Rename the binary: + + - For Windows: `seanime-server-windows.exe` + - For macOS/Intel: `seanime-server-darwin-amd64` + - For macOS/ARM: `seanime-server-darwin-arm64` + - For Linux/x86_64: `seanime-server-linux-amd64` + - For Linux/ARM64: `seanime-server-linux-arm64` + +### Electron + +To build the desktop client for all platforms: + +``` +npm run build +``` + +To build for specific platforms: + +``` +npm run build:mac +npm run build:win +npm run build:linux +``` + +Output is in `./seanime-denshi/dist/...` \ No newline at end of file diff --git a/seanime-2.9.10/seanime-denshi/assets/18x18.png b/seanime-2.9.10/seanime-denshi/assets/18x18.png new file mode 100644 index 0000000..e2ad514 Binary files /dev/null and b/seanime-2.9.10/seanime-denshi/assets/18x18.png differ diff --git a/seanime-2.9.10/seanime-denshi/assets/32x32.png b/seanime-2.9.10/seanime-denshi/assets/32x32.png new file mode 100644 index 0000000..1c32894 Binary files /dev/null and b/seanime-2.9.10/seanime-denshi/assets/32x32.png differ diff --git a/seanime-2.9.10/seanime-denshi/assets/entitlements.mac.plist b/seanime-2.9.10/seanime-denshi/assets/entitlements.mac.plist new file mode 100644 index 0000000..554aac4 --- /dev/null +++ b/seanime-2.9.10/seanime-denshi/assets/entitlements.mac.plist @@ -0,0 +1,23 @@ +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.cs.allow-jit</key> + <true/> + <key>com.apple.security.cs.allow-unsigned-executable-memory</key> + <true/> + <key>com.apple.security.cs.disable-library-validation</key> + <true/> + <key>com.apple.security.inherit</key> + <true/> + <key>com.apple.security.cs.allow-dyld-environment-variables</key> + <true/> + <key>com.apple.security.automation.apple-events</key> + <true/> + <key>com.apple.security.files.user-selected.read-write</key> + <true/> + <key>com.apple.security.files.downloads.read-write</key> + <true/> + <key>com.apple.security.files.all</key> + <true/> +</dict> +</plist> \ No newline at end of file diff --git a/seanime-2.9.10/seanime-denshi/assets/icon.icns b/seanime-2.9.10/seanime-denshi/assets/icon.icns new file mode 100644 index 0000000..1be9c69 Binary files /dev/null and b/seanime-2.9.10/seanime-denshi/assets/icon.icns differ diff --git a/seanime-2.9.10/seanime-denshi/assets/icon.ico b/seanime-2.9.10/seanime-denshi/assets/icon.ico new file mode 100644 index 0000000..c3da932 Binary files /dev/null and b/seanime-2.9.10/seanime-denshi/assets/icon.ico differ diff --git a/seanime-2.9.10/seanime-denshi/assets/icon.png b/seanime-2.9.10/seanime-denshi/assets/icon.png new file mode 100644 index 0000000..900458b Binary files /dev/null and b/seanime-2.9.10/seanime-denshi/assets/icon.png differ diff --git a/seanime-2.9.10/seanime-denshi/package-lock.json b/seanime-2.9.10/seanime-denshi/package-lock.json new file mode 100644 index 0000000..dc7752b --- /dev/null +++ b/seanime-2.9.10/seanime-denshi/package-lock.json @@ -0,0 +1,4349 @@ +{ + "name": "seanime-denshi", + "version": "2.9.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "seanime-denshi", + "version": "2.9.0", + "dependencies": { + "electron-log": "^5.0.0", + "electron-serve": "^1.3.0", + "electron-updater": "^6.1.7", + "strip-ansi": "^7.1.0" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "electron": "^36.1.2", + "electron-builder": "^24.13.3" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", + "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", + "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz", + "integrity": "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.1", + "@malept/cross-spawn-promise": "^1.1.0", + "debug": "^4.3.1", + "dir-compare": "^3.0.0", + "fs-extra": "^9.0.1", + "minimatch": "^3.0.4", + "plist": "^3.0.4" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.15.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", + "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", + "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz", + "integrity": "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.2.1", + "@electron/osx-sign": "1.0.5", + "@electron/universal": "1.5.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chromium-pickle-js": "^0.2.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "electron-publish": "24.13.1", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "minimatch": "^5.1.1", + "read-config-file": "6.3.2", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "24.13.3", + "electron-builder-squirrel-windows": "24.13.3" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", + "integrity": "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "4.0.0", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz", + "integrity": "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", + "integrity": "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.10", + "typescript": "^5.3.3" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", + "integrity": "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal": "^1.0.0", + "minimatch": "^3.0.4" + } + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", + "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", + "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "36.2.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-36.2.1.tgz", + "integrity": "sha512-mm1Y+Ms46xcOTA69h8hpqfX392HfV4lga9aEkYkd/Syx1JBStvcACOIouCgGrnZpxNZPVS1jM8NTcMkNjuK6BQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz", + "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "dmg-builder": "24.13.3", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "read-config-file": "6.3.2", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz", + "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "24.13.3", + "archiver": "^5.3.1", + "builder-util": "24.13.1", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-log": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.0.tgz", + "integrity": "sha512-AXI5OVppskrWxEAmCxuv8ovX+s2Br39CpCAgkGMNHQtjYT3IiVbSQTncEjFVGPgoH35ZygRm/mvUMBDWwhRxgg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/electron-publish": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", + "integrity": "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-serve": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.3.0.tgz", + "integrity": "sha512-OEC/48ZBJxR6XNSZtCl4cKPyQ1lvsu8yp8GdCplMWwGS1eEyMcEmzML5BRs/io/RLDnpgyf+7rSL+X6ICifRIg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-updater": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.6.2.tgz", + "integrity": "sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.3.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "^7.6.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz", + "integrity": "sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.4.tgz", + "integrity": "sha512-YKBKVkKhty7s8rxddb40oOkuP0NbaeXrQvLin6QMHL7Ypiy2RW9LwOVrVgZRyOrhQlayMd9t+D8yDy8MKFTSDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jake/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jake/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-config-file": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", + "integrity": "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-file-ts": "^0.2.4", + "dotenv": "^9.0.2", + "dotenv-expand": "^5.1.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.0", + "lazy-val": "^1.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/seanime-2.9.10/seanime-denshi/package.json b/seanime-2.9.10/seanime-denshi/package.json new file mode 100644 index 0000000..32f08e1 --- /dev/null +++ b/seanime-2.9.10/seanime-denshi/package.json @@ -0,0 +1,117 @@ +{ + "name": "seanime-denshi", + "version": "2.10.0", + "description": "Electron-based Desktop client for Seanime", + "main": "src/main.js", + "scripts": { + "dev": "NODE_ENV=development electron .", + "build": "electron-builder build", + "build:mac": "electron-builder build --mac", + "build:win": "electron-builder build --win", + "build:linux": "electron-builder build --linux" + }, + "dependencies": { + "electron-log": "^5.0.0", + "electron-serve": "^1.3.0", + "electron-updater": "^6.1.7", + "mime-types": "^2.1.35", + "strip-ansi": "^7.1.0" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "electron": "^36.1.2", + "electron-builder": "^24.13.3" + }, + "build": { + "appId": "app.seanime.denshi", + "productName": "Seanime Denshi", + "asar": true, + "extraResources": [ + { + "from": "binaries", + "to": "binaries" + } + ], + "generateUpdatesFilesForAllChannels": true, + "publish": { + "provider": "generic", + "url": "https://github.com/5rahim/seanime/releases/latest/download", + "channel": "latest", + "publishAutoUpdate": true, + "useMultipleRangeRequest": false + }, + "mac": { + "category": "public.app-category.entertainment", + "target": [ + { + "target": "dmg", + "arch": [ + "x64", + "arm64" + ] + } + ], + "darkModeSupport": true, + "notarize": false, + "hardenedRuntime": true, + "gatekeeperAssess": false, + "entitlements": "assets/entitlements.mac.plist", + "entitlementsInherit": "assets/entitlements.mac.plist", + "artifactName": "seanime-denshi-${version}_MacOS_${arch}.${ext}" + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "artifactName": "seanime-denshi-${version}_Windows_${arch}.${ext}" + }, + "linux": { + "target": [ + { + "target": "AppImage", + "arch": [ + "x64", + "arm64" + ] + } + ], + "category": "Entertainment", + "artifactName": "seanime-denshi-${version}_Linux_${arch}.${ext}" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "shortcutName": "Seanime Denshi", + "artifactName": "seanime-denshi-${version}_Windows_${arch}.${ext}" + }, + "directories": { + "buildResources": "assets", + "output": "dist" + }, + "files": [ + "src/**/*", + "web-denshi/**/*", + "assets/**/*", + "package.json", + "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}", + "!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}", + "!**/node_modules/*.d.ts", + "!**/node_modules/.bin", + "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}", + "!.editorconfig", + "!**/._*", + "!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}", + "!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}", + "!**/{appveyor.yml,.travis.yml,circle.yml}", + "!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}" + ] + }, + "private": true +} diff --git a/seanime-2.9.10/seanime-denshi/src/main.js b/seanime-2.9.10/seanime-denshi/src/main.js new file mode 100644 index 0000000..a6188ff --- /dev/null +++ b/seanime-2.9.10/seanime-denshi/src/main.js @@ -0,0 +1,911 @@ +const {app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, remote, net} = require('electron'); +const path = require('path'); +const serve = require('electron-serve'); +const {spawn} = require('child_process'); +const fs = require('fs'); +let stripAnsi; +import('strip-ansi').then(module => { + stripAnsi = module.default; +}); +const {autoUpdater} = require('electron-updater'); +const log = require('electron-log'); + +function setupChromiumFlags() { + // Bypass CSP and security + app.commandLine.appendSwitch('bypasscsp-schemes'); + app.commandLine.appendSwitch('no-sandbox'); + app.commandLine.appendSwitch('no-zygote'); + + + app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); + app.commandLine.appendSwitch('force_high_performance_gpu'); + + app.commandLine.appendSwitch('disk-cache-size', (400 * 1000 * 1000).toString()); + app.commandLine.appendSwitch('force-effective-connection-type', '4g'); + + // Disable features that can interfere with playback + app.commandLine.appendSwitch('disable-features', ['Vulkan', 'WidgetLayering', 'ColorProviderRedirection', 'WebContentsForceDarkMode', // 'ForcedColors' + ].join(',')); + + // Color management and rendering optimizations + // app.commandLine.appendSwitch('force-color-profile', 'srgb'); + // app.commandLine.appendSwitch('disable-color-correct-rendering'); + // app.commandLine.appendSwitch('disable-web-contents-color-extraction'); + // app.commandLine.appendSwitch('disable-color-management'); + // app.commandLine.appendSwitch('force-color-profile-interpretation', 'all-images'); + // app.commandLine.appendSwitch('force-raster-color-profile', 'srgb'); + + // Hardware acceleration and GPU optimizations + app.commandLine.appendSwitch('force-high-performance-gpu'); + // app.commandLine.appendSwitch('enable-gpu-rasterization'); + app.commandLine.appendSwitch('enable-zero-copy'); + app.commandLine.appendSwitch('enable-hardware-overlays', 'single-fullscreen,single-on-top,underlay'); + app.commandLine.appendSwitch('ignore-gpu-blocklist'); + + // Video-specific optimizations + app.commandLine.appendSwitch('enable-accelerated-video-decode'); + + // Enable advanced features + app.commandLine.appendSwitch('enable-features', ['ThrottleDisplayNoneAndVisibilityHiddenCrossOriginIframes', 'PlatformEncryptedDolbyVision', 'CanvasOopRasterization', 'UseSkiaRenderer', 'WebAssemblyLazyCompilation', 'RawDraw', // "Vulkan", + // 'MediaFoundationHEVC', + 'PlatformHEVCDecoderSupport',].join(',')); + + app.commandLine.appendSwitch('enable-unsafe-webgpu'); + app.commandLine.appendSwitch('enable-gpu-rasterization'); + app.commandLine.appendSwitch('enable-oop-rasterization'); + + // Background processing optimizations + app.commandLine.appendSwitch('disable-background-timer-throttling'); + app.commandLine.appendSwitch('disable-backgrounding-occluded-windows'); + app.commandLine.appendSwitch('disable-renderer-backgrounding'); + app.commandLine.appendSwitch('disable-background-media-suspend'); + + app.commandLine.appendSwitch('double-buffer-compositing'); + app.commandLine.appendSwitch('disable-direct-composition-video-overlays'); +} + +const _development = process.env.NODE_ENV === 'development'; +// const _development = false; + +// Setup electron-serve for production +const appServe = !_development ? serve({ + directory: path.join(__dirname, '../web-denshi') +}) : null; + +// Custom protocol handler for routing in production +if (!_development) { + app.whenReady().then(() => { + const {protocol} = require('electron'); + const mime = require('mime-types'); + + // Register a custom protocol to handle routing + protocol.handle('app', async (request) => { + const url = new URL(request.url); + let filePath = url.pathname; + + // Remove leading slash + if (filePath.startsWith('/')) { + filePath = filePath.substring(1); + } + + // Handle root path + if (filePath === '' || filePath === '-') { + filePath = 'index.html'; + } else if (!filePath.endsWith('.html') && !filePath.includes('.') && !filePath.includes('/')) { + // If it's a route without extension, try to find corresponding HTML file + filePath = filePath + '.html'; + } + + // Handle subdirectories (like splashscreen/crash) + if (filePath.includes('/')) { + const parts = filePath.split('/'); + if (parts.length > 1) { + // For nested routes like splashscreen/crash, try the nested structure first + const nestedPath = parts.join('/'); + const nestedFullPath = path.join(__dirname, '../web-denshi', nestedPath); + if (fs.existsSync(nestedFullPath)) { + filePath = nestedPath; + } else { + // Try with .html extension + const nestedHtmlPath = nestedPath + '.html'; + const nestedHtmlFullPath = path.join(__dirname, '../web-denshi', nestedHtmlPath); + if (fs.existsSync(nestedHtmlFullPath)) { + filePath = nestedHtmlPath; + } else { + // Fall back to the last part with .html + filePath = parts[parts.length - 1] + '.html'; + } + } + } + } + + const fullPath = path.join(__dirname, '../web-denshi', filePath); + + // Check if file exists, otherwise fall back to index.html + if (!fs.existsSync(fullPath)) { + filePath = 'index.html'; + } + + const finalPath = path.join(__dirname, '../web-denshi', filePath); + + try { + const fileContent = fs.readFileSync(finalPath); + const mimeType = mime.lookup(finalPath) || 'application/octet-stream'; + return new Response(fileContent, { + headers: { + 'Content-Type': mimeType, + 'Cache-Control': 'no-cache' + } + }); + } catch (error) { + console.error('[Protocol] Error reading file:', finalPath, error); + return new Response('File not found', { + status: 404, + headers: {'Content-Type': 'text/plain'} + }); + } + }); + }); +} + +// Setup update events for logging +autoUpdater.logger = log; +log.transports.file.level = 'debug'; + +// Redirect console logging to electron-log +console.log = log.info; +console.error = log.error; + +function logStartupEvent(stage, detail = '') { + const message = `[STARTUP] ${stage}: ${detail}`; + log.info(message); + // console.info(message); +} + +// Global error handlers to catch unhandled exceptions +process.on('uncaughtException', (error) => { + log.error('Uncaught Exception:', error); + + if (app.isReady()) { + dialog.showErrorBox('An error occurred', `Uncaught Exception: ${error.message}\n\nCheck the logs for more details.`); + } + + logStartupEvent('UNCAUGHT EXCEPTION', error.stack || error.message); +}); + +process.on('unhandledRejection', (reason, promise) => { + log.error('Unhandled Rejection at:', promise, 'reason:', reason); + logStartupEvent('UNHANDLED REJECTION', reason?.stack || reason?.message || JSON.stringify(reason)); +}); + +// Dumps important environment information for debugging +function logEnvironmentInfo() { + logStartupEvent('NODE_ENV', process.env.NODE_ENV || 'not set'); + logStartupEvent('Platform', process.platform); + logStartupEvent('Architecture', process.arch); + logStartupEvent('Node version', process.version); + logStartupEvent('Electron version', process.versions.electron); + logStartupEvent('App path', app.getAppPath()); + logStartupEvent('Dir name', __dirname); + logStartupEvent('User data path', app.getPath('userData')); + logStartupEvent('Executable path', app.getPath('exe')); + + if (process.resourcesPath) { + logStartupEvent('Resources path', process.resourcesPath); + try { + // const resourceFiles = fs.readdirSync(process.resourcesPath); + // logStartupEvent('Resources directory contents', JSON.stringify(resourceFiles)); + + // Check if binaries directory exists + const binariesDir = path.join(process.resourcesPath, 'binaries'); + if (fs.existsSync(binariesDir)) { + const binariesFiles = fs.readdirSync(binariesDir); + logStartupEvent('Binaries directory contents', JSON.stringify(binariesFiles)); + } else { + logStartupEvent('ERROR', 'Binaries directory not found'); + } + } catch (err) { + logStartupEvent('ERROR reading resources', err.message); + } + } + + // Check app directory structure + try { + const appPath = app.getAppPath(); + // logStartupEvent('App directory contents', JSON.stringify(fs.readdirSync(appPath))); + + const webPath = path.join(appPath, 'web-denshi'); + if (fs.existsSync(webPath)) { + // logStartupEvent('Web directory contents', JSON.stringify(fs.readdirSync(webPath))); + } else { + logStartupEvent('ERROR', 'web-denshi directory not found in app path'); + } + } catch (err) { + logStartupEvent('ERROR reading app directory', err.message); + } +} + +const updateConfig = { + provider: 'generic', + url: 'https://github.com/5rahim/seanime/releases/latest/download', + channel: 'latest', + allowPrerelease: false, + verifyUpdateCodeSignature: false, +}; + +// Override with environment variable if set +if (process.env.UPDATES_URL) { + updateConfig.url = process.env.UPDATES_URL; +} + +// Configure the updater +autoUpdater.setFeedURL(updateConfig); + +// Enable automatic download +autoUpdater.autoDownload = true; +autoUpdater.autoInstallOnAppQuit = true; + +// App state +let mainWindow = null; +let splashScreen = null; +let crashScreen = null; +let tray = null; +let serverProcess = null; +let isShutdown = false; +let serverStarted = false; +let updateDownloaded = false; + +// Setup autoUpdater events with improved error handling +autoUpdater.on('checking-for-update', () => { + autoUpdater.logger.info('Checking for update...'); +}); + +autoUpdater.on('update-available', (info) => { + autoUpdater.logger.info('Update available:', info); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('update-available', { + version: info.version, releaseDate: info.releaseDate, files: info.files + }); + } +}); + +autoUpdater.on('update-not-available', (info) => { + autoUpdater.logger.info('Update not available:', info); +}); + +autoUpdater.on('download-progress', (progressObj) => { + autoUpdater.logger.info(`Download progress: ${progressObj.percent}%`); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('download-progress', { + percent: progressObj.percent, bytesPerSecond: progressObj.bytesPerSecond, transferred: progressObj.transferred, total: progressObj.total + }); + } +}); + +autoUpdater.on('update-downloaded', (info) => { + autoUpdater.logger.info('Update downloaded:', info); + updateDownloaded = true; + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('update-downloaded', { + version: info.version, releaseDate: info.releaseDate, files: info.files + }); + } +}); + +autoUpdater.on('error', (err) => { + autoUpdater.logger.error('Error in auto-updater:', err); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('update-error', { + code: err.code || 'unknown', message: err.message, stack: err.stack + }); + } +}); + +/** + * Create the tray icon and menu + */ +function createTray() { + let iconPath = path.join(__dirname, '../assets/icon.png'); + if (process.platform === 'darwin') { + iconPath = path.join(__dirname, '../assets/18x18.png'); + } + tray = new Tray(iconPath); + + const contextMenu = Menu.buildFromTemplate([{ + id: 'toggle_visibility', label: 'Toggle visibility', click: () => { + if (mainWindow.isVisible()) { + mainWindow.hide(); + if (process.platform === 'darwin') { + app.dock.hide(); + } + } else { + mainWindow.show(); + mainWindow.focus(); + if (process.platform === 'darwin') { + app.dock.show(); + } + } + } + }, ...(process.platform === 'darwin' ? [{ + id: 'accessory_mode', label: 'Remove from dock', click: () => { + app.dock.hide(); + } + }] : []), { + id: 'quit', label: 'Quit Seanime', click: () => { + cleanupAndExit(); + } + }]); + + tray.setToolTip('Seanime'); + tray.setContextMenu(contextMenu); + + tray.on('click', () => { + mainWindow.show(); + mainWindow.focus(); + if (process.platform === 'darwin') { + app.dock.show(); + } + }); +} + +/** + * Launch the Seanime server + */ +async function launchSeanimeServer(isRestart) { + return new Promise((resolve, reject) => { + // TEST ONLY: Check for -no-binary flag + if (process.argv.includes('-no-binary')) { + logStartupEvent('SKIPPING SERVER LAUNCH', 'Detected -no-binary flag'); + console.log('[Main] Skipping server launch due to -no-binary flag'); + serverStarted = true; // Assume server is "started" for UI flow + // Resolve immediately to bypass server spawning + if (splashScreen && !splashScreen.isDestroyed()) { + splashScreen.close(); + splashScreen = null; + } + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.maximize(); + mainWindow.show(); + } + return resolve(); + } + + // Determine the correct binary to use based on platform and architecture + let binaryName = ''; + if (process.platform === 'win32') { + binaryName = 'seanime-server-windows.exe'; + } else if (process.platform === 'darwin') { + const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'; + binaryName = `seanime-server-darwin-${arch}`; + } else if (process.platform === 'linux') { + const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'; + binaryName = `seanime-server-linux-${arch}`; + } + + let binaryPath; + + if (_development) { + // In development, look for binaries in the project directory + binaryPath = path.join(__dirname, '../binaries', binaryName); + } else { + // In production, use the resources path + binaryPath = path.join(process.resourcesPath, 'binaries', binaryName); + } + + logStartupEvent('Using binary', `${binaryPath} (${process.arch})`); + logStartupEvent('Resources path', process.resourcesPath); + + // Check if binary exists and is executable + if (!fs.existsSync(binaryPath)) { + const error = new Error(`Server binary not found at ${binaryPath}`); + logStartupEvent('ERROR', error.message); + return reject(error); + } + + // Make binary executable (for macOS/Linux) + if (process.platform !== 'win32') { + try { + fs.chmodSync(binaryPath, '755'); + } catch (error) { + console.error(`Failed to make binary executable: ${error}`); + } + } + + // Arguments + const args = []; + + // Development mode + if (_development && process.env.TEST_DATADIR) { + console.log('[Main] TEST_DATADIR', process.env.TEST_DATADIR); + args.push('-datadir', process.env.TEST_DATADIR); + } + + args.push('-desktop-sidecar', 'true'); + + console.log('\x1b[32m[Main] Spawning server process\x1b[0m', {args, binaryPath}); + + // Spawn the process + try { + serverProcess = spawn(binaryPath, args); + } catch (spawnError) { + console.error('[Main] Failed to spawn server process synchronously:', spawnError); + return reject(spawnError); + } + + serverProcess.stdout.on('data', (data) => { + const dataStr = data.toString(); + const lineStr = stripAnsi ? stripAnsi(dataStr) : dataStr; + + // // Check if mainWindow exists and is not destroyed + // if (mainWindow && !mainWindow.isDestroyed()) { + // mainWindow.webContents.send('message', lineStr); + // } + + // Check if the frontend is connected + if (!serverStarted && lineStr.includes('Client connected')) { + console.log('[Main] Server started'); + serverStarted = true; + setTimeout(() => { + console.log('[Main] Server started timeout'); + if (splashScreen && !splashScreen.isDestroyed()) { + splashScreen.close(); + splashScreen = null; + } + console.log('[Main] Server started close splash screen'); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.maximize(); + mainWindow.show(); + } + resolve(); + }, 1000); + } + }); + + serverProcess.stderr.on('data', (data) => { + console.error(data.toString()); + }); + + serverProcess.on('close', (code) => { + console.log(`[Main] Server process exited with code ${code}`); + + // If the server didn't start properly and we're not in the process of shutting down + if (!serverStarted && !isShutdown) { + console.log('[Main] Server process exited before starting'); + reject(new Error(`Server process exited prematurely with code ${code} before starting.`)); + + // close splash screen and main window + if (splashScreen && !splashScreen.isDestroyed()) { + splashScreen.close(); + splashScreen = null; + } + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.close(); + } + + // show crash screen + if (crashScreen && !crashScreen.isDestroyed()) { + crashScreen.show(); + crashScreen.webContents.send('crash', `Seanime server process terminated with status: ${code}. Closing in 10 seconds.`); + + setTimeout(() => { + app.exit(1); + }, 10000); + } + } + }); + + // Handle spawn errors + serverProcess.on('error', (err) => { + console.error('[Main] Server process spawn error event:', err); + reject(err); + }); + }); +} + +/** + * Create main application window + */ +function createMainWindow() { + logStartupEvent('Creating main window'); + const windowOptions = { + width: 800, height: 600, show: false, webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js'), + webSecurity: false, + allowRunningInsecureContent: true, + enableBlinkFeatures: 'FontAccess, AudioVideoTracks', + backgroundThrottling: false + } + }; + + // contextMenu({ + // showInspectElement: true + // }); + + // Set title bar style based on platform + if (process.platform === 'darwin') { + windowOptions.titleBarStyle = 'hiddenInset'; + } + + if (process.platform === 'win32') { + windowOptions.titleBarStyle = 'hidden'; + } + + mainWindow = new BrowserWindow(windowOptions); + + // Hide the title bar on Windows + if (process.platform === 'win32' || process.platform === 'linux') { + mainWindow.setMenuBarVisibility(false); + } + + mainWindow.on('render-process-gone', (event, details) => { + console.log('[Main] Render process gone', details); + if (crashScreen && !crashScreen.isDestroyed()) { + crashScreen.show(); + } + }); + + mainWindow.webContents.setWindowOpenHandler(({url}) => { + // Open external links in the default browser + if (url.startsWith('http://') || url.startsWith('https://')) { + shell.openExternal(url); + return {action: 'deny'}; + } + // Allow other URLs to open in the app + return {action: 'allow'}; + }) + + // Load the web content + if (_development) { + // In development, load from the dev server + logStartupEvent('Loading from dev server', 'http://127.0.0.1:43210'); + mainWindow.loadURL('http://127.0.0.1:43210'); + // mainWindow.loadURL('chrome://gpu'); + } else { + // Load from custom protocol handler in production + logStartupEvent('Loading production build with custom protocol'); + mainWindow.loadURL('app://-'); + } + + // Development tools + if (_development) { + mainWindow.webContents.openDevTools(); + } + + mainWindow.on('close', (event) => { + if (!isShutdown) { + event.preventDefault(); + mainWindow.hide(); + if (process.platform === 'darwin') { + app.dock.hide(); + } + } + }); +} + +/** + * Create splash screen window + */ +function createSplashScreen() { + logStartupEvent('Creating splash screen'); + splashScreen = new BrowserWindow({ + width: 800, height: 600, frame: false, resizable: false, webPreferences: { + nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, 'preload.js') + } + }); + + // Load the web content + if (_development) { + // In development, load from the dev server + logStartupEvent('Loading splash from dev server', 'http://127.0.0.1:43210/splashscreen'); + splashScreen.loadURL('http://127.0.0.1:43210/splashscreen'); + } else { + // Load from custom protocol handler + logStartupEvent('Loading splash screen with custom protocol'); + splashScreen.loadURL('app://splashscreen'); + } +} + +/** + * Create crash screen window + */ +function createCrashScreen() { + crashScreen = new BrowserWindow({ + width: 800, height: 600, frame: false, resizable: false, show: false, webPreferences: { + nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, 'preload.js') + } + }); + + // Load the web content + if (_development) { + // In development, load from the dev server + crashScreen.loadURL('http://127.0.0.1:43210/splashscreen/crash'); + } else { + // Load from custom protocol handler + crashScreen.loadURL('app://splashscreen/crash'); + } +} + +/** + * Cleanup and exit the application gracefully + */ +function cleanupAndExit() { + console.log('[Main] Cleaning up and exiting'); + isShutdown = true; + + // Kill server process first + if (serverProcess) { + console.log('[Main] Killing server process'); + try { + serverProcess.kill(); + serverProcess = null; + } catch (err) { + console.error('[Main] Error killing server process:', err); + } + } + + // Exit the app after a short delay to allow cleanup + setTimeout(() => { + app.exit(0); + }, 500); +} + +// Initialize the app +app.whenReady().then(async () => { + logStartupEvent('App ready'); + + // Set up Chromium flags for better video playback + setupChromiumFlags(); + + // Log environment information + logEnvironmentInfo(); + + // Setup IPC handlers for update functions + ipcMain.handle('check-for-updates', async () => { + try { + console.log('[Main] Checking for updates...'); + const result = await autoUpdater.checkForUpdates(); + return { + updateAvailable: !!result?.updateInfo, updateInfo: result?.updateInfo + }; + } catch (error) { + console.error('[Main] Error checking for updates:', error); + throw error; // Let the renderer handle the error + } + }); + + ipcMain.handle('install-update', async () => { + try { + if (!updateDownloaded) { + throw new Error('Update not downloaded yet'); + } + console.log('[Main] Installing update...'); + autoUpdater.quitAndInstall(false, true); + return true; + } catch (error) { + console.error('[Main] Error installing update:', error); + throw error; + } + }); + + ipcMain.handle('kill-server', async () => { + if (serverProcess) { + console.log('[Main] Killing server before update...'); + serverProcess.kill(); + return true; + } + return false; + }); + + // Linux fix for compositing mode + if (process.platform === 'linux') { + process.env.WEBKIT_DISABLE_COMPOSITING_MODE = '1'; + } + + // Create windows + createMainWindow(); + createSplashScreen(); + createCrashScreen(); + + // Create tray + createTray(); + + // Launch server + try { + logStartupEvent('Attempting to launch server'); + await launchSeanimeServer(false); + logStartupEvent('Server launched successfully'); + // Check for updates only after server launch and main window setup is successful + autoUpdater.checkForUpdatesAndNotify(); + } catch (error) { + logStartupEvent('Server launch failed', error.message); + console.error('[Main] Failed to start server:', error); + if (splashScreen && !splashScreen.isDestroyed()) { + splashScreen.close(); + splashScreen = null; + } + + if (crashScreen && !crashScreen.isDestroyed()) { + crashScreen.show(); + crashScreen.webContents.send('crash', `The server failed to start: ${error}. Closing in 10 seconds.`); + + setTimeout(() => { + console.error('[Main] Exiting due to server start failure.'); + app.exit(1); + }, 10000); + } + } + + // Register Window Control IPC handlers + ipcMain.on('window:minimize', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.minimize(); + } + }); + + ipcMain.on('window:maximize', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.maximize(); + } + }); + + ipcMain.on('window:close', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.close(); + } + }); + + ipcMain.on('window:toggleMaximize', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + } + }); + + ipcMain.on('window:setFullscreen', (_, fullscreen) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setFullScreen(fullscreen); + } + }); + + ipcMain.on('window:hide', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.hide(); + } + }); + + ipcMain.on('window:show', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show(); + } + }); + + ipcMain.handle('window:getCurrentWindow', () => { + const win = BrowserWindow.fromWebContents(mainWindow.webContents); + return win?.id; + }); + + // Window state query handlers + ipcMain.handle('window:isMaximized', () => { + return mainWindow && !mainWindow.isDestroyed() ? mainWindow.isMaximized() : false; + }); + + ipcMain.handle('window:isMinimizable', () => { + return mainWindow && !mainWindow.isDestroyed() ? mainWindow.minimizable : false; + }); + + ipcMain.handle('window:isMaximizable', () => { + return mainWindow && !mainWindow.isDestroyed() ? mainWindow.maximizable : false; + }); + + ipcMain.handle('window:isClosable', () => { + return mainWindow && !mainWindow.isDestroyed() ? mainWindow.closable : false; + }); + + ipcMain.handle('window:isFullscreen', () => { + return mainWindow && !mainWindow.isDestroyed() ? mainWindow.isFullScreen() : false; + }); + + ipcMain.handle('window:isVisible', () => { + return mainWindow && !mainWindow.isDestroyed() ? mainWindow.isVisible() : false; + }); + + // Clipboard handler + ipcMain.handle('clipboard:writeText', (_, text) => { + if (text) { + return require('electron').clipboard.writeText(text); + } + return false; + }); + + // Register server IPC handlers + ipcMain.on('restart-server', () => { + console.log('EVENT restart-server'); + if (serverProcess) { + console.log('Killing existing server process'); + serverProcess.kill(); + } + // devnote: don't set this to false or it will trigger the crashscreen + // serverStarted = false; + launchSeanimeServer(true).catch(console.error); + }); + + ipcMain.on('kill-server', () => { + console.log('EVENT kill-server'); + if (serverProcess) { + console.log('Killing server process'); + serverProcess.kill(); + } + }); + + // Watch for window events to notify renderer + if (mainWindow) { + mainWindow.on('maximize', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('window:maximized'); + } + }); + + mainWindow.on('unmaximize', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('window:unmaximized'); + } + }); + + mainWindow.on('enter-full-screen', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('window:fullscreen', true); + } + }); + + mainWindow.on('leave-full-screen', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('window:fullscreen', false); + } + }); + } + + // macOS specific events + ipcMain.on('macos-activation-policy-accessory', () => { + console.log('EVENT macos-activation-policy-accessory'); + if (process.platform === 'darwin') { + app.dock.hide(); + mainWindow.show(); + mainWindow.setFullScreen(true); + + setTimeout(() => { + mainWindow.focus(); + mainWindow.webContents.send('macos-activation-policy-accessory-done', ''); + }, 150); + } + }); + + ipcMain.on('macos-activation-policy-regular', () => { + console.log('EVENT macos-activation-policy-regular'); + if (process.platform === 'darwin') { + app.dock.show(); + } + }); + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createMainWindow(); + } + }); + + app.on('before-quit', () => { + console.log('EVENT before-quit'); + cleanupAndExit(); + }); +}); diff --git a/seanime-2.9.10/seanime-denshi/src/preload.js b/seanime-2.9.10/seanime-denshi/src/preload.js new file mode 100644 index 0000000..971ae89 --- /dev/null +++ b/seanime-2.9.10/seanime-denshi/src/preload.js @@ -0,0 +1,101 @@ +const {contextBridge, ipcRenderer, ipcMain, shell, BrowserWindow} = require('electron'); + +// Expose protected methods that allow the renderer process to use +// the ipcRenderer without exposing the entire object +contextBridge.exposeInMainWorld( + 'electron', { + // Window Controls + window: { + minimize: () => ipcRenderer.send('window:minimize'), + maximize: () => ipcRenderer.send('window:maximize'), + close: () => ipcRenderer.send('window:close'), + isMaximized: () => ipcRenderer.invoke('window:isMaximized'), + isMinimizable: () => ipcRenderer.invoke('window:isMinimizable'), + isMaximizable: () => ipcRenderer.invoke('window:isMaximizable'), + isClosable: () => ipcRenderer.invoke('window:isClosable'), + isFullscreen: () => ipcRenderer.invoke('window:isFullscreen'), + setFullscreen: (fullscreen) => ipcRenderer.send('window:setFullscreen', fullscreen), + toggleMaximize: () => ipcRenderer.send('window:toggleMaximize'), + hide: () => ipcRenderer.send('window:hide'), + show: () => ipcRenderer.send('window:show'), + isVisible: () => ipcRenderer.invoke('window:isVisible'), + setTitleBarStyle: (style) => ipcRenderer.send('window:setTitleBarStyle', style), + getCurrentWindow: () => ipcRenderer.invoke('window:getCurrentWindow') + }, + + // Event listeners + on: (channel, callback) => { + // Whitelist channels + const validChannels = [ + 'message', + 'crash', + 'window:maximized', + 'window:unmaximized', + 'window:fullscreen', + 'update-downloaded', + 'update-error', + 'update-available', + 'download-progress', + 'window:currentWindow' + ]; + if (validChannels.includes(channel)) { + // Remove the event listener to avoid memory leaks + ipcRenderer.removeAllListeners(channel); + // Add the event listener + ipcRenderer.on(channel, (_, ...args) => callback(...args)); + + // Return a function to remove the listener + return () => { + ipcRenderer.removeAllListeners(channel); + }; + } + }, + + // Send events + emit: (channel, data) => { + // Whitelist channels + const validChannels = [ + 'restart-server', + 'kill-server', + 'macos-activation-policy-accessory', + 'macos-activation-policy-regular' + ]; + if (validChannels.includes(channel)) { + ipcRenderer.send(channel, data); + } + }, + + // General send method for any whitelisted channel + send: (channel, ...args) => { + // Whitelist channels + const validChannels = [ + 'restart-app', + 'quit-app' + ]; + if (validChannels.includes(channel)) { + ipcRenderer.send(channel, ...args); + } + }, + + // Platform + platform: process.platform, + + // Shell functions + shell: { + open: (url) => shell.openExternal(url) + }, + + // Clipboard + clipboard: { + writeText: (text) => ipcRenderer.invoke('clipboard:writeText', text) + }, + + // Update functions + checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), + installUpdate: () => ipcRenderer.invoke('install-update'), + killServer: () => ipcRenderer.invoke('kill-server') + } +); + +// Set __isElectronDesktop__ global variable +contextBridge.exposeInMainWorld('__isElectronDesktop__', true); diff --git a/seanime-2.9.10/seanime-desktop/.gitignore b/seanime-2.9.10/seanime-desktop/.gitignore new file mode 100644 index 0000000..29c5fd3 --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/.gitignore @@ -0,0 +1,3 @@ +node_modules +.idea +.run \ No newline at end of file diff --git a/seanime-2.9.10/seanime-desktop/README.md b/seanime-2.9.10/seanime-desktop/README.md new file mode 100644 index 0000000..3392adb --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/README.md @@ -0,0 +1,139 @@ +<p align="center"> +<img src="../seanime-web/public/logo_2.png" alt="preview" width="150px"/> +</p> + +<h2 align="center"><b>Seanime Desktop</b></h2> + +<p align="center"> +Desktop app for Seanime. Embeds server and web interface. +</p> + +<img src="../docs/images/4/anime-entry-torrent-stream--sq.jpg" alt="preview" width="100%"/> + +--- + +## Prerequisites + +- Go 1.24+ +- Node.js 20+ and npm +- Rust 1.75+ + +--- + +## Development + +### Web Interface + +```shell +# Working dir: ./seanime-web +npm run dev:desktop +``` + +### Sidecar + +1. Build the server + + ```shell + # Working dir: . + + # Windows + go build -o seanime.exe -trimpath -ldflags="-s -w" -tags=nosystray + + # Linux, macOS + go build -o seanime -trimpath -ldflags="-s -w" + ``` + +2. Move the binary to `./seanime-desktop/src-tauri/binaries` + +3. Rename the binary to `seanime-{TARGET_TRIPLE}`. [Reference](https://v2.tauri.app/develop/sidecar/). + + - `seanime-x86_64-pc-windows-msvc.exe` for Windows + - `seanime-x86_64-unknown-linux-musl` for Linux + - `seanime-x86_64-apple-darwin` for macOS (Intel-based) + - `seanime-aarch64-apple-darwin` for macOS (Apple Silicon) + +### Tauri + +1. Setup + + ```shell + # Working dir: ./seanime-desktop + npm install + ``` +2. Run + + `TEST_DATADIR` is needed when running in development mode, it should point to a dummy data directory for Seanime. + + ```shell + # Working dir: ./seanime-desktop + TEST_DATADIR="/path/to/data/dir" npm run start + # or + TEST_DATADIR="/path/to/data/dir" tauri dev + ``` + +--- + +## Build + +### Web Interface + +- **Option A:** + + Uses `.env.desktop` and outputs to `./seanime-web/out-desktop` + + ```shell + # Working dir: ./seanime-web + npm run build:desktop + ``` + + Move the output to `./web-desktop` + + ```shell + # UNIX command + mv ./seanime-web/out-desktop ./web-desktop + ``` + +- **Option B:** + + Uses `.env.development.desktop` and outputs to `./web-desktop` + + ```shell + # Working dir: ./seanime-web + npm run build:development:desktop + ``` + +### Sidecar + +1. Build the server + + ```shell + # Working dir: . + + # Windows + go build -o seanime.exe -trimpath -ldflags="-s -w" -tags=nosystray + + # Linux, macOS + go build -o seanime -trimpath -ldflags="-s -w" + ``` + +2. Move the binary to `./seanime-desktop/src-tauri/binaries` + +3. Rename the binary to `seanime-{TARGET_TRIPLE}`. [Reference](https://v2.tauri.app/develop/sidecar/). + + - `seanime-x86_64-pc-windows-msvc.exe` for Windows + - `seanime-x86_64-unknown-linux-musl` for Linux + - `seanime-x86_64-apple-darwin` for macOS (Intel-based) + - `seanime-aarch64-apple-darwin` for macOS (Apple Silicon) + +### Tauri + +Set the signing private key and password. [Reference](https://v2.tauri.app/plugin/updater/#signing-updates). +- `TAURI_SIGNING_PRIVATE_KEY` +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` + +```shell +# Working dir: ./seanime-desktop +TAURI_SIGNING_PRIVATE_KEY="" TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" npm run tauri build +``` + +Output is in `./seanime-desktop/src-tauri/target/release/bundle/...` diff --git a/seanime-2.9.10/seanime-desktop/package-lock.json b/seanime-2.9.10/seanime-desktop/package-lock.json new file mode 100644 index 0000000..ae8bd23 --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/package-lock.json @@ -0,0 +1,231 @@ +{ + "name": "seanime-desktop", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "seanime-desktop", + "version": "1.0.0", + "dependencies": { + "@tauri-apps/api": "^2.4.1", + "@tauri-apps/cli": "^2.4.1" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.4.1.tgz", + "integrity": "sha512-5sYwZCSJb6PBGbBL4kt7CnE5HHbBqwH+ovmOW6ZVju3nX4E3JX6tt2kRklFEH7xMOIwR0btRkZktuLhKvyEQYg==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.4.1.tgz", + "integrity": "sha512-9Ta81jx9+57FhtU/mPIckDcOBtPTUdKM75t4+aA0X84b8Sclb0jy1xA8NplmcRzp2fsfIHNngU2NiRxsW5+yOQ==", + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.4.1", + "@tauri-apps/cli-darwin-x64": "2.4.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.4.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.4.1", + "@tauri-apps/cli-linux-arm64-musl": "2.4.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.4.1", + "@tauri-apps/cli-linux-x64-gnu": "2.4.1", + "@tauri-apps/cli-linux-x64-musl": "2.4.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.4.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.4.1", + "@tauri-apps/cli-win32-x64-msvc": "2.4.1" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.4.1.tgz", + "integrity": "sha512-QME7s8XQwy3LWClTVlIlwXVSLKkeJ/z88pr917Mtn9spYOjnBfsgHAgGdmpWD3NfJxjg7CtLbhH49DxoFL+hLg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.4.1.tgz", + "integrity": "sha512-/r89IcW6Ya1sEsFUEH7wLNruDTj7WmDWKGpPy7gATFtQr5JEY4heernqE82isjTUimnHZD8SCr0jA3NceI4ybw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.4.1.tgz", + "integrity": "sha512-9tDijkRB+CchAGjXxYdY9l/XzFpLp1yihUtGXJz9eh+3qIoRI043n3e+6xmU8ZURr7XPnu+R4sCmXs6HD+NCEQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.4.1.tgz", + "integrity": "sha512-pnFGDEXBAzS4iDYAVxTRhAzNu3K2XPGflYyBc0czfHDBXopqRgMyj5Q9Wj7HAwv6cM8BqzXINxnb2ZJFGmbSgA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.1.tgz", + "integrity": "sha512-Hp0zXgeZNKmT+eoJSCxSBUm2QndNuRxR55tmIeNm3vbyUMJN/49uW7nurZ5fBPsacN4Pzwlx1dIMK+Gnr9A69w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.4.1.tgz", + "integrity": "sha512-3T3bo2E4fdYRvzcXheWUeQOVB+LunEEi92iPRgOyuSVexVE4cmHYl+MPJF+EUV28Et0hIVTsHibmDO0/04lAFg==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.4.1.tgz", + "integrity": "sha512-kLN0FdNONO+2i+OpU9+mm6oTGufRC00e197TtwjpC0N6K2K8130w7Q3FeODIM2CMyg0ov3tH+QWqKW7GNhHFzg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.1.tgz", + "integrity": "sha512-a8exvA5Ub9eg66a6hsMQKJIkf63QAf9OdiuFKOsEnKZkNN2x0NLgfvEcqdw88VY0UMs9dBoZ1AGbWMeYnLrLwQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.4.1.tgz", + "integrity": "sha512-4JFrslsMCJQG1c573T9uqQSAbF3j/tMKkMWzsIssv8jvPiP++OG61A2/F+y9te9/Q/O95cKhDK63kaiO5xQaeg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.4.1.tgz", + "integrity": "sha512-9eXfFORehYSCRwxg2KodfmX/mhr50CI7wyBYGbPLePCjr5z0jK/9IyW6r0tC+ZVjwpX48dkk7hKiUgI25jHjzA==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.4.1.tgz", + "integrity": "sha512-60a4Ov7Jrwqz2hzDltlS7301dhSAmM9dxo+IRBD3xz7yobKrgaHXYpWvnRomYItHcDd51VaKc9292H8/eE/gsw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/seanime-2.9.10/seanime-desktop/package.json b/seanime-2.9.10/seanime-desktop/package.json new file mode 100644 index 0000000..11e148c --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/package.json @@ -0,0 +1,16 @@ +{ + "name": "seanime-desktop", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "tauri dev", + "build": "tauri build", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2.4.1", + "@tauri-apps/cli": "^2.4.1" + }, + "private": true +} diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/.gitignore b/seanime-2.9.10/seanime-desktop/src-tauri/.gitignore new file mode 100644 index 0000000..9e87fc2 --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/.gitignore @@ -0,0 +1,8 @@ +.idea +# Generated by Cargo +# will have compiled files and executables +/target/ +/gen/schemas +# Sidecar binaries +/binaries/*.exe +/binaries/seanime-aarch64-apple-darwin diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/Cargo.lock b/seanime-2.9.10/seanime-desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..7c79ea2 --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/Cargo.lock @@ -0,0 +1,6196 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "ahash" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0453232ace82dee0dd0b4c87a59bd90f7b53b314f3e0f61fe2ee7c8a16482289" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arboard" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1df21f715862ede32a0c525ce2ca4d52626bb0007f8c18b87a384503ac33e70" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.0", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.0", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 0.38.44", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 0.38.44", + "tracing", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "async-signal" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 0.38.44", + "signal-hook-registry", + "slab", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +dependencies = [ + "serde", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d59b4c170e16f0405a2e95aff44432a0d41aa97675f3d52623effe95792a037" +dependencies = [ + "objc2 0.6.0", +] + +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fa05ad7d803d413eb8380983b092cbbaf9a85f151b871360e7b00cd7060b37" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "bytemuck" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.9.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.12", +] + +[[package]] +name = "cargo_toml" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "cc" +version = "1.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525046617d8376e3db1deffb079e91cef90a89fc3ca5c185bbf8c9ecdd15cd5c" +dependencies = [ + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "clipboard-win" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +dependencies = [ + "error-code", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 0.4.8", + "matches", + "phf 0.8.0", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.100", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.100", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.100", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "derive_more" +version = "0.99.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da29a38df43d6f156149c9b43ded5e018ddff2a855cf2cfd62e8cd7d079c69f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.100", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "dlopen2" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1297103d2bbaea85724fcee6294c2d50b1081f9ad47d0f6f6f61eda65315a6" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b99bf03862d7f545ebc28ddd33a665b50865f4dfd84031a393823879bd4c54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "dlv-list" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68df3f2b690c1b86e65ef7830956aededf3cb0a16f898f79b9a6f421a7b6211b" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + +[[package]] +name = "embed-resource" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbc6e0d8e0c03a655b53ca813f0463d2c956bc4db8138dbc89f120b066551e3" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enigo" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802e4b2ae123615659085369b453cba87c5562e46ed8050a909fee18a9bc3157" +dependencies = [ + "core-graphics 0.23.2", + "libc", + "objc", + "pkg-config", + "windows 0.51.1", +] + +[[package]] +name = "enumflags2" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba2f4b465f5318854c6f8dd686ede6c0a9dc67d4b1ac241cf0eb51521a309147" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "error-code" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f" + +[[package]] +name = "event-listener" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "file-locker" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6c3e69656680c6c3d76750b46dfa64bf07626bd2130c540d6cf2d306ba595a8" +dependencies = [ + "nix", +] + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freedesktop_entry_parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9c27b72f19a99a895f8ca89e2d26e4ef31013376e56fdafef697627306c3e4" +dependencies = [ + "nom", + "thiserror 1.0.69", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +dependencies = [ + "libc", + "windows-targets 0.48.5", +] + +[[package]] +name = "gethostname" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7131e57abbde63513e0e6636f76668a1ca9798dcae2df4e283cae9ee83859e" +dependencies = [ + "rustix 1.0.5", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.9.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "hashbrown" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.15", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "itoa 1.0.15", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", + "serde", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.9.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 1.9.3", + "matches", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "linicon" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee8c5653188a809616c97296180a0547a61dba205bcdcbdd261dbd022a25fd9" +dependencies = [ + "file-locker", + "freedesktop_entry_parser", + "linicon-theme", + "memmap2", + "thiserror 1.0.69", +] + +[[package]] +name = "linicon-theme" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f8240c33bb08c5d8b8cdea87b683b05e61037aa76ff26bef40672cc6ecbb80" +dependencies = [ + "freedesktop_entry_parser", + "rust-ini", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen 0.10.0", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memmap2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "minisign-verify" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6367d84fb54d4242af283086402907277715b8fe46976963af5ebf173f8efba3" + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "muda" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de14a9b5d569ca68d7c891d613b390cf5ab4f851c77aaa2f9e435555d3d9492" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.0", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.0", + "once_cell", + "png", + "serde", + "thiserror 2.0.12", + "windows-sys 0.59.0", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3531f65190d9cff863b77a99857e74c314dd16bf56c538c4b57c7cbc3f3a6e59" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.0", + "libc", + "objc2 0.6.0", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-foundation 0.3.0", + "objc2-quartz-core 0.3.0", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c1948a9be5f469deadbd6bcb86ad7ff9e47b4f632380139722f7d9840c0d42c" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.0", + "objc2-foundation 0.3.0", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f860f8e841f6d32f754836f51e6bc7777cd7e7053cf18528233f6811d3eceb4" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.0", + "objc2-foundation 0.3.0", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.0", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dca602628b65356b6513290a21a6405b4d4027b8b250f0b98dddbb28b7de02" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.0", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ffa6bea72bf42c78b0b34e89c0bafac877d5f80bf91e159a5d96ea7f693ca56" +dependencies = [ + "objc2 0.6.0", + "objc2-foundation 0.3.0", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.9.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a21c6c9014b82c39515db5b396f91645182611c97d24637cf56ac01e5f8d998" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.0", + "libc", + "objc2 0.6.0", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161a8b87e32610086e1a7a9e9ec39f84459db7b3a0881c1f16ca5a2605581c19" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.0", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.9.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ac59da3ceebc4a82179b35dc550431ad9458f9cc326e053f49ba371ce76c5a" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.0", + "objc2-app-kit", + "objc2-foundation 0.3.0", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.9.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fb3794501bb1bee12f08dcad8c61f2a5875791ad1c6f47faa71a0f033f20071" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.0", + "objc2-foundation 0.3.0", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777a571be14a42a3990d4ebedaeb8b54cd17377ec21b92e8200ac03797b3bee1" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.0", + "objc2-core-foundation", + "objc2-foundation 0.3.0", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b717127e4014b0f9f3e8bba3d3f2acec81f1bde01f656823036e823ed2c94dce" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.0", + "objc2 0.6.0", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.0", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "open" +version = "5.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c672c7ad9ec066e428c00eb917124a06f08db19e2584de982cc34b1f4c12485" +dependencies = [ + "dlv-list", + "hashbrown 0.9.1", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_info" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a604e53c24761286860eba4e2c8b23a0161526476b1de520139d69cdb85a6b5" +dependencies = [ + "log", + "serde", + "windows-sys 0.52.0", +] + +[[package]] +name = "os_pipe" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2 0.6.0", + "objc2-foundation 0.3.0", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.12", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.9.0", +] + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_macros 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d" +dependencies = [ + "base64 0.22.1", + "indexmap 2.9.0", + "quick-xml 0.32.0", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 0.38.44", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.7", +] + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit 0.22.24", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.37.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.12", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc" +dependencies = [ + "bytes", + "getrandom 0.3.2", + "rand 0.9.0", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.12", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", + "zerocopy", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.2", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +dependencies = [ + "bitflags 2.9.0", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", + "windows-registry", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust-ini" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63471c4aa97a1cf8332a5f97709a79a4234698de6a1f5087faf66f2dae810e22" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.9.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags 2.9.0", + "errno", + "libc", + "linux-raw-sys 0.9.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +dependencies = [ + "web-time", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.100", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seanime-desktop" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "strip-ansi-escapes", + "tauri", + "tauri-build", + "tauri-plugin-clipboard-manager", + "tauri-plugin-decorum", + "tauri-plugin-os", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tokio", +] + +[[package]] +name = "selectors" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "matches", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", + "thin-slice", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299d9c19d7d466db4ab10addd5703e4c615dec2a5a16dbbafe191045e87ee66e" +dependencies = [ + "erased-serde", + "serde", + "typeid", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa 1.0.15", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa 1.0.15", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.9.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9823f2d3b6a81d98228151fdeaf848206a7855a7a042bbf9bf870449a66cafb" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74064874e9f6a15f04c1f3cb627902d0e6b410abbf36668afa873c61889f1763" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "servo_arc" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_child" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fa9338aed9a1df411814a5b2252f7cd206c55ae9bf2fa763f8de84603aa60c" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "socket2" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "bytemuck", + "cfg_aliases", + "core-graphics 0.24.0", + "foreign-types", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", + "raw-window-handle", + "redox_syscall", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63c8b1020610b9138dd7b1e06cf259ae91aa05c30f3bd0d6b42a03997b92dec1" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2 0.6.0", + "objc2-app-kit", + "objc2-foundation 0.3.0", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.60.0", + "windows-core 0.60.1", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d08db1ff9e011e04014e737ec022610d756c0eae0b3b3a9037bccaf3003173a" +dependencies = [ + "anyhow", + "bytes", + "dirs", + "dunce", + "embed_plist", + "futures-util", + "getrandom 0.2.15", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.0", + "objc2-app-kit", + "objc2-foundation 0.3.0", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.12", + "tokio", + "tray-icon", + "url", + "urlpattern", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.60.0", +] + +[[package]] +name = "tauri-build" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd20e4661c2cce65343319e6e8da256958f5af958cafc47c0d0af66a55dcd17" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458258b19032450ccf975840116ecf013e539eadbb74420bd890e8c56ab2b1a4" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.100", + "tauri-utils", + "thiserror 2.0.12", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d402813d3b9c773a0fa58697c457c771f10e735498fdcb7b343264d18e5a601f" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.100", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4190775d6ff73fe66d9af44c012739a2659720efd9c0e1e56a918678038699d" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars", + "serde", + "serde_json", + "tauri-utils", + "toml", + "walkdir", +] + +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab4cb42fdf745229b768802e9180920a4be63122cf87ed1c879103f7609d98e" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", +] + +[[package]] +name = "tauri-plugin-decorum" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db925c61a04a937028bc91ad8ae64a93b84a1715b964530925a54e793d494999" +dependencies = [ + "anyhow", + "cocoa", + "enigo", + "linicon", + "objc", + "rand 0.8.5", + "serde", + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-os" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424f19432397850c2ddd42aa58078630c15287bbce3866eb1d90e7dbee680637" +dependencies = [ + "gethostname 1.0.1", + "log", + "os_info", + "serde", + "serde_json", + "serialize-to-javascript", + "sys-locale", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d5eb3368b959937ad2aeaf6ef9a8f5d11e01ffe03629d3530707bbcb27ff5d" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tokio", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1320af4d866a7fb5f5721d299d14d0dd9e4e6bc0359ff3e263124a2bf6814efa" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.12", + "tracing", + "windows-sys 0.59.0", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82da763248e635d60ee4aed56c862290e523acc838d83097171f9a544708387" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.12", + "time", + "tokio", + "url", + "windows-sys 0.59.0", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00ada7ac2f9276f09b8c3afffd3215fd5d9bff23c22df8a7c70e7ef67cacd532" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.12", + "url", + "windows 0.60.0", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2e5842c57e154af43a20a49c7efee0ce2578c20b4c2bdf266852b422d2e421" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.0", + "objc2-app-kit", + "objc2-foundation 0.3.0", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.60.0", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f037e66c7638cc0a2213f61566932b9a06882b8346486579c90e4b019bac447" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.12", + "toml", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56eaa45f707bedf34d19312c26d350bc0f3c59a47e58e8adbeecdc850d2c13a0" +dependencies = [ + "embed-resource", + "toml", +] + +[[package]] +name = "tempfile" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +dependencies = [ + "fastrand", + "getrandom 0.3.2", + "once_cell", + "rustix 1.0.5", + "windows-sys 0.59.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thin-slice" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa 1.0.15", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.22.24", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.9.0", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap 2.9.0", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +dependencies = [ + "indexmap 2.9.0", + "serde", + "serde_spanned", + "toml_datetime", + "winnow 0.7.6", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d433764348e7084bad2c5ea22c96c71b61b17afe3a11645710f533bd72b6a2b5" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.0", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.0", + "once_cell", + "png", + "serde", + "thiserror 2.0.12", + "windows-sys 0.59.0", +] + +[[package]] +name = "tree_magic_mini" +version = "3.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac5e8971f245c3389a5a76e648bfc80803ae066a1243a75db0064d7c1129d63" +dependencies = [ + "fnv", + "memchr", + "nom", + "once_cell", + "petgraph", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.2", + "serde", +] + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.100", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7208998eaa3870dad37ec8836979581506e0c5c64c20c9e79e9d2a10d6f47bf" +dependencies = [ + "cc", + "downcast-rs", + "rustix 0.38.44", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2120de3d33638aaef5b9f4472bff75f07c56379cf76ea320bd3a3d65ecaf73f" +dependencies = [ + "bitflags 2.9.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0781cf46869b37e36928f7b432273c0995aa8aed9552c556fb18754420541efc" +dependencies = [ + "bitflags 2.9.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248a02e6f595aad796561fa82d25601bd2c8c3b145b1c7453fc8f94c1a58f8b2" +dependencies = [ + "bitflags 2.9.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "0.26.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0d606f600e5272b514dbb66539dd068211cc20155be8d3958201b4b5bd79ed3" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.60.0", + "windows-core 0.60.1", + "windows-implement 0.59.0", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "webview2-com-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfb27fccd3c27f68e9a6af1bcf48c2d82534b8675b83608a4d81446d095a17ac" +dependencies = [ + "thiserror 2.0.12", + "windows 0.60.0", + "windows-core 0.60.1", +] + +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.0", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.0", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9" +dependencies = [ + "windows-core 0.51.1", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529" +dependencies = [ + "windows-collections", + "windows-core 0.60.1", + "windows-future", + "windows-link", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec" +dependencies = [ + "windows-core 0.60.1", +] + +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-core" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247" +dependencies = [ + "windows-implement 0.59.0", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings 0.3.1", +] + +[[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement 0.60.0", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings 0.4.0", +] + +[[package]] +name = "windows-future" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0" +dependencies = [ + "windows-core 0.60.1", + "windows-link", +] + +[[package]] +name = "windows-implement" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-numerics" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed" +dependencies = [ + "windows-core 0.60.1", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result", + "windows-strings 0.3.1", + "windows-targets 0.53.0", +] + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-version" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04a5c6627e310a23ad2358483286c7df260c964eb2d003d8efd6d0f4e79265c" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.0", +] + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 0.38.44", + "tempfile", + "thiserror 2.0.12", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "wry" +version = "0.50.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b19b78efae8b853c6c817e8752fc1dbf9cab8a8ffe9c30f399bd750ccf0f0730" +dependencies = [ + "base64 0.22.1", + "block2 0.6.0", + "cookie", + "crossbeam-channel", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2 0.6.0", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.0", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.12", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.60.0", + "windows-core 0.60.1", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +dependencies = [ + "gethostname 0.4.3", + "rustix 0.38.44", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" + +[[package]] +name = "xattr" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" +dependencies = [ + "libc", + "rustix 1.0.5", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c333f648ea1b647bc95dc1d34807c8e25ed7a6feff3394034dc4776054b236" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.59.0", + "winnow 0.7.6", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f325ad10eb0d0a3eb060203494c3b7ec3162a01a59db75d2deee100339709fc0" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.100", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.7.6", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "zip" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "indexmap 2.9.0", + "memchr", +] + +[[package]] +name = "zvariant" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2df9ee044893fcffbdc25de30546edef3e32341466811ca18421e3cd6c5a3ac" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "winnow 0.7.6", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74170caa85b8b84cc4935f2d56a57c7a15ea6185ccdd7eadb57e6edd90f94b2f" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.100", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "static_assertions", + "syn 2.0.100", + "winnow 0.7.6", +] diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/Cargo.toml b/seanime-2.9.10/seanime-desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..54e393f --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "seanime-desktop" +version = "0.1.0" +description = "Seanime Desktop" +authors = ["you"] +license = "" +repository = "" +edition = "2021" +rust-version = "1.71" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +name = "app_lib" +crate-type = ["staticlib", "cdylib", "lib"] + +[build-dependencies] +tauri-build = { version = "2.1.1", features = [] } + +[dependencies] +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +tauri = { version = "2.4.1", features = ["macos-private-api", "tray-icon", "devtools"] } +tauri-plugin-shell = "2.2.1" +strip-ansi-escapes = "0.2.1" +tokio = "1.43.0" +tauri-plugin-decorum = "1.1.1" +tauri-plugin-os = "2.2.1" +tauri-plugin-clipboard-manager = "2.2.2" + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-single-instance = "2" +tauri-plugin-updater = "2.4.0" diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/app-icon.png b/seanime-2.9.10/seanime-desktop/src-tauri/app-icon.png new file mode 100644 index 0000000..885d5a7 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/app-icon.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/binaries/.gitkeep b/seanime-2.9.10/seanime-desktop/src-tauri/binaries/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/build.rs b/seanime-2.9.10/seanime-desktop/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/capabilities/desktop.json b/seanime-2.9.10/seanime-desktop/src-tauri/capabilities/desktop.json new file mode 100644 index 0000000..1a20c13 --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/capabilities/desktop.json @@ -0,0 +1,15 @@ +{ + "identifier": "desktop-capability", + "platforms": [ + "macOS", + "windows", + "linux" + ], + "permissions": [ + "updater:default", + "updater:allow-check", + "updater:allow-download", + "updater:allow-download-and-install", + "updater:allow-install" + ] +} \ No newline at end of file diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/capabilities/main.json b/seanime-2.9.10/seanime-desktop/src-tauri/capabilities/main.json new file mode 100644 index 0000000..268aad6 --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/capabilities/main.json @@ -0,0 +1,92 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": [ + "main", + "splashscreen", + "crash_screen" + ], + "permissions": [ + "os:allow-arch", + "os:allow-hostname", + "os:allow-os-type", + "core:path:default", + "core:event:default", + "core:window:default", + "core:app:default", + "core:image:default", + "core:resources:default", + "core:menu:default", + "core:tray:default", + "shell:allow-open", + "shell:default", + "core:window:allow-set-title-bar-style", + "core:window:allow-center", + "core:window:allow-request-user-attention", + "core:window:allow-set-resizable", + "core:window:allow-set-maximizable", + "core:window:allow-set-minimizable", + "core:window:allow-set-closable", + "core:window:allow-set-title", + "core:window:allow-maximize", + "core:window:allow-unmaximize", + "core:window:allow-minimize", + "core:window:allow-unminimize", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-close", + "core:window:allow-set-decorations", + "core:window:allow-set-shadow", + "core:window:allow-set-effects", + "core:window:allow-set-always-on-top", + "core:window:allow-set-always-on-bottom", + "core:window:allow-set-content-protected", + "core:window:allow-set-size", + "core:window:allow-set-min-size", + "core:window:allow-set-max-size", + "core:window:allow-set-position", + "core:window:allow-set-fullscreen", + "core:window:allow-set-focus", + "core:window:allow-set-skip-taskbar", + "core:window:allow-set-cursor-grab", + "core:window:allow-set-cursor-visible", + "core:window:allow-set-cursor-icon", + "core:window:allow-set-cursor-position", + "core:window:allow-set-ignore-cursor-events", + "core:window:allow-start-dragging", + "core:window:allow-set-progress-bar", + "core:window:allow-set-icon", + "core:window:allow-toggle-maximize", + "core:webview:allow-create-webview-window", + "core:webview:allow-print", + "updater:default", + "updater:allow-check", + "updater:allow-download", + "updater:allow-download-and-install", + "updater:allow-install", + "clipboard-manager:allow-clear", + "clipboard-manager:allow-write-text", + "clipboard-manager:allow-write-html", + "clipboard-manager:allow-write-image", + "clipboard-manager:allow-read-text", + "clipboard-manager:allow-read-image", + { + "identifier": "shell:allow-execute", + "allow": [ + { + "args": [ + "-desktop-sidecar", + "true", + "-datadir", + { + "validator": "[\\S+ ]" + } + ], + "name": "binaries/seanime", + "sidecar": true + } + ] + } + ] +} diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/128x128.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..5729af7 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/128x128.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/128x128@2x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..8c00734 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/128x128@2x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/32x32.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..1c32894 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/32x32.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square107x107Logo.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..c79dc43 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square142x142Logo.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..14b16bd Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square150x150Logo.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..f2f031c Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square284x284Logo.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..fce4932 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square30x30Logo.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..e7ee71f Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square310x310Logo.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..698347c Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square44x44Logo.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..8abe795 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square71x71Logo.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..a8c8923 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square89x89Logo.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..037f883 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/StoreLogo.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..50124a6 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/StoreLogo.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..2f4c73c Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..93354f7 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..2f4c73c Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..712691a Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5f5f704 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..712691a Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..85b2cd0 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2522cec Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..85b2cd0 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..dcaf4c8 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..63ae3b6 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..dcaf4c8 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..5ffd56a Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c9eea3f Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..5ffd56a Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/icon.icns b/seanime-2.9.10/seanime-desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..1be9c69 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/icon.icns differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/icon.ico b/seanime-2.9.10/seanime-desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..c3da932 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/icon.ico differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/icon.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..313484b Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/icon.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..45ca899 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..84ddeda Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..84ddeda Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..c5162ef Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..3d2caab Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..0103613 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..0103613 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..31bc102 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..84ddeda Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..5e3057d Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..5e3057d Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..db5200b Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..a64e393 Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..db5200b Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..a1a488c Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..50ebc6d Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..f57c75f Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..1a8d1ba Binary files /dev/null and b/seanime-2.9.10/seanime-desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/src/constants.rs b/seanime-2.9.10/seanime-desktop/src-tauri/src/constants.rs new file mode 100644 index 0000000..3a64822 --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/src/constants.rs @@ -0,0 +1,3 @@ +pub const MAIN_WINDOW_LABEL: &str = "main"; +pub const SPLASHSCREEN_WINDOW_LABEL: &str = "splashscreen"; +pub const CRASH_SCREEN_WINDOW_LABEL: &str = "crash_screen"; diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/src/lib.rs b/seanime-2.9.10/seanime-desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..29e3419 --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/src/lib.rs @@ -0,0 +1,193 @@ +mod constants; +mod server; +#[cfg(desktop)] +mod tray; + +use constants::MAIN_WINDOW_LABEL; +use std::sync::{Arc, Mutex}; +#[cfg(target_os = "macos")] +use tauri::utils::TitleBarStyle; +use tauri::{Emitter, Listener, Manager}; +use tauri_plugin_os; + +pub fn run() { + let server_process = Arc::new(Mutex::new( + None::<tauri_plugin_shell::process::CommandChild>, + )); + let server_process_for_setup = Arc::clone(&server_process); + let server_process_for_restart = Arc::clone(&server_process); + // + let is_shutdown = Arc::new(Mutex::new(false)); + let is_shutdown_for_setup = Arc::clone(&is_shutdown); + let is_shutdown_for_restart = Arc::clone(&is_shutdown); + + let server_started = Arc::new(Mutex::new(false)); + let server_started_for_setup = Arc::clone(&server_started); + let server_started_for_restart = Arc::clone(&server_started); + + tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _cmd, _args| { + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + window.show().unwrap(); + window.set_focus().unwrap(); + } + })) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .setup(move |app| { + #[cfg(all(desktop))] + { + let handle = app.handle(); + tray::create_tray(handle)?; + } + + let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap(); + main_window.hide().unwrap(); + + // Set overlay title bar only when building for macOS + #[cfg(target_os = "macos")] + main_window + .set_title_bar_style(TitleBarStyle::Overlay) + .unwrap(); + + // Hide the title bar on Windows + #[cfg(any(target_os = "windows"))] + main_window.set_decorations(false).unwrap(); + + // Open dev tools only when in dev mode + #[cfg(debug_assertions)] + { + main_window.open_devtools(); + } + + server::launch_seanime_server( + app.handle().clone(), + server_process_for_setup, + is_shutdown_for_setup, + server_started_for_setup, + ); + + let app_handle = app.handle().clone(); + app.listen("restart-server", move |_| { + println!("EVENT restart-server"); + let mut child_guard = server_process_for_restart.lock().unwrap(); + if let Some(child) = child_guard.take() { + println!("Killing existing server process"); + // Kill the existing server process + if let Err(e) = child.kill() { + eprintln!("Failed to kill server process: {}", e); + } + } + server::launch_seanime_server( + app_handle.clone(), + Arc::clone(&server_process_for_restart), + Arc::clone(&is_shutdown_for_restart), + Arc::clone(&server_started_for_restart), + ); + }); + + let app_handle_1 = app.handle().clone(); + let main_window_clone = main_window.clone(); + main_window.listen("macos-activation-policy-accessory", move |_| { + println!("EVENT macos-activation-policy-accessory"); + #[cfg(target_os = "macos")] + { + if let Err(e) = app_handle_1.set_activation_policy(tauri::ActivationPolicy::Accessory) { + eprintln!("Failed to set activation policy to accessory: {}", e); + } else { + if let Err(e) = main_window_clone.show() { + eprintln!("Failed to show main window: {}", e); + } + if let Err(e) = main_window_clone.set_fullscreen(true) { + eprintln!("Failed to set fullscreen: {}", e); + } else { + std::thread::sleep(std::time::Duration::from_millis(150)); + if let Err(e) = main_window_clone.set_focus() { + eprintln!("Failed to set focus after fullscreen: {}", e); + } + main_window_clone.emit("macos-activation-policy-accessory-done", "").unwrap(); + } + } + } + }); + + // main_window.on_window_event() + + let app_handle_2 = app.handle().clone(); + main_window.listen("macos-activation-policy-regular", move |_| { + println!("EVENT macos-activation-policy-regular"); + #[cfg(target_os = "macos")] + app_handle_2 + .set_activation_policy(tauri::ActivationPolicy::Regular) + .unwrap(); + }); + + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("error while running tauri application") + .run({ + let server_process_for_exit = Arc::clone(&server_process); + let is_shutdown_for_exit = Arc::clone(&is_shutdown); + move |app, event| { + let server_process_for_exit_ = Arc::clone(&server_process); + app.listen("kill-server", move |_| { + let mut child_guard = server_process_for_exit_.lock().unwrap(); + if let Some(child) = child_guard.take() { + // Kill server process + if let Err(e) = child.kill() { + eprintln!("Failed to kill server process: {}", e); + } + } + }); + + match event { + tauri::RunEvent::WindowEvent { + label, + event: tauri::WindowEvent::CloseRequested { api, .. }, + .. + } => { + let is_shutdown_guard = is_shutdown_for_exit.lock().unwrap(); + if label.as_str() == MAIN_WINDOW_LABEL && !*is_shutdown_guard { + println!("Main window close request"); + // Hide the window when user clicks 'X' + let win = app.get_webview_window(label.as_str()).unwrap(); + win.hide().unwrap(); + // Prevent the window from being closed + api.prevent_close(); + #[cfg(target_os = "macos")] + app.set_activation_policy(tauri::ActivationPolicy::Accessory) + .unwrap(); + } + } + + // tauri::RunEvent::Exit => { + // let mut child_guard = server_process_for_exit.lock().unwrap(); + // if let Some(child) = child_guard.take() { + // // Kill server process + // if let Err(e) = child.kill() { + // eprintln!("Failed to kill server process: {}", e); + // } + // } + // } + + // The app is about to exit + tauri::RunEvent::ExitRequested { .. } => { + println!("Main window exit request"); + let mut child_guard = server_process_for_exit.lock().unwrap(); + if let Some(child) = child_guard.take() { + // Kill server process + if let Err(e) = child.kill() { + eprintln!("Failed to kill server process: {}", e); + } + } + } + _ => {} + } + } + }); +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/src/main.rs b/seanime-2.9.10/seanime-desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..d40abde --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/src/main.rs @@ -0,0 +1,12 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + #[cfg(target_os = "linux")] + { + std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + } + + + app_lib::run(); +} diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/src/server.rs b/seanime-2.9.10/seanime-desktop/src-tauri/src/server.rs new file mode 100644 index 0000000..7f38657 --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/src/server.rs @@ -0,0 +1,116 @@ +use crate::constants::{CRASH_SCREEN_WINDOW_LABEL, MAIN_WINDOW_LABEL, SPLASHSCREEN_WINDOW_LABEL}; +use std::sync::{Arc, Mutex}; +use strip_ansi_escapes; +use tauri::{AppHandle, Emitter, Manager}; +use tauri_plugin_shell::process::CommandEvent; +use tauri_plugin_shell::ShellExt; +use tokio::time::{sleep, Duration}; + +pub fn launch_seanime_server( + app: AppHandle, + child_process: Arc<Mutex<Option<tauri_plugin_shell::process::CommandChild>>>, + is_shutdown: Arc<Mutex<bool>>, + server_started: Arc<Mutex<bool>>, +) { + tauri::async_runtime::spawn(async move { + let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap(); + // let splashscreen = app.get_webview_window(SPLASHSCREEN_WINDOW_LABEL).unwrap(); + // let crash_screen = app.get_webview_window(CRASH_SCREEN_WINDOW_LABEL).unwrap(); + + let mut sidecar_command = app.shell().sidecar("seanime").unwrap(); + + // Use test data dir during development + #[cfg(dev)] + { + sidecar_command = sidecar_command.args(["-datadir", env!("TEST_DATADIR")]); + } + + sidecar_command = sidecar_command.args(["-desktop-sidecar", "true"]); + + + let (mut rx, child) = match sidecar_command.spawn() { + Ok(result) => result, + Err(e) => { + // Seanime server failed to open -> close splashscreen and display crash screen + if let Some(splashscreen) = app.get_webview_window(SPLASHSCREEN_WINDOW_LABEL) { + splashscreen.close().unwrap(); + } + if let Some(crash_screen) = app.get_webview_window(CRASH_SCREEN_WINDOW_LABEL) { + crash_screen.show().unwrap(); + } + app.emit( + "crash", + format!("The server failed to start: {}. Closing in 10 seconds.", e), + ) + .expect("failed to emit event"); + sleep(Duration::from_secs(10)).await; + std::process::exit(1); + } + }; + + // Store the child process + *child_process.lock().unwrap() = Some(child); + + // let mut server_started = false; + + // Read server terminal output + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Stdout(line) => { + let line_without_colors = strip_ansi_escapes::strip(line); + match String::from_utf8(line_without_colors) { + Ok(line_str) => { + if !server_started.lock().unwrap().clone() { + if line_str.contains("Client connected") { + sleep(Duration::from_secs(2)).await; + + *server_started.lock().unwrap() = true; + if let Some(splashscreen) = app.get_webview_window(SPLASHSCREEN_WINDOW_LABEL) { + splashscreen.close().unwrap(); + } + main_window.maximize().unwrap(); + main_window.show().unwrap(); + } + } + // Emit the line to the main window + main_window + .emit("message", Some(format!("{}", line_str))) + .expect("failed to emit event"); + + println!("{}", line_str); + } + Err(_) => {} + } + } + CommandEvent::Terminated(status) => { + eprintln!( + "Seanime server process terminated with status: {:?} {:?}", + status, server_started.lock().unwrap() + ); + *is_shutdown.lock().unwrap() = true; + // Only terminate the app if the desktop app hadn't launched + if !server_started.lock().unwrap().clone() { + if let Some(splashscreen) = app.get_webview_window(SPLASHSCREEN_WINDOW_LABEL) { + splashscreen.close().unwrap(); + } + #[cfg(debug_assertions)] + { + main_window.close_devtools(); + } + main_window.close().unwrap(); + if let Some(crash_screen) = app.get_webview_window(CRASH_SCREEN_WINDOW_LABEL) { + crash_screen.show().unwrap(); + } + + app.emit("crash", format!("Seanime server process terminated with status: {}. Closing in 10 seconds.", status.code.unwrap_or(1))).expect("failed to emit event"); + + sleep(Duration::from_secs(10)).await; + app.exit(1); + } + break; + } + _ => {} + } + } + }); +} diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/src/tray.rs b/seanime-2.9.10/seanime-desktop/src-tauri/src/tray.rs new file mode 100644 index 0000000..6a4d080 --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/src/tray.rs @@ -0,0 +1,102 @@ +use crate::constants::MAIN_WINDOW_LABEL; +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + Manager, Runtime, +}; + +pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> { + let quit_i = MenuItem::with_id(app, "quit", "Quit Seanime", true, None::<&str>)?; + // let restart_i = MenuItem::with_id(app, "restart", "Restart Seanime", true, None::<&str>)?; + // let open_web_i = MenuItem::with_id(app, "open_web", "Open Web UI", true, None::<&str>)?; + let toggle_visibility_i = MenuItem::with_id( + app, + "toggle_visibility", + "Toggle visibility", + true, + None::<&str>, + )?; + let accessory_mode_i = MenuItem::with_id( + app, + "accessory_mode", + "Remove from dock", + true, + None::<&str>, + )?; + let mut items: Vec<&dyn tauri::menu::IsMenuItem<R>> = vec![&toggle_visibility_i, &quit_i]; + + #[cfg(target_os = "macos")] + { + items = vec![&toggle_visibility_i, &accessory_mode_i, &quit_i]; + } + + let menu = Menu::with_items(app, &items)?; + + let _ = TrayIconBuilder::with_id("tray") + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .menu_on_left_click(false) + .on_menu_event(move |app, event| match event.id.as_ref() { + "quit" => { + app.exit(0); + } + // "restart" => app.restart(), + "toggle_visibility" => { + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + if !window.is_visible().unwrap() { + let _ = window.show(); + let _ = window.set_focus(); + #[cfg(target_os = "macos")] + app.set_activation_policy(tauri::ActivationPolicy::Regular) + .unwrap(); + } else { + let _ = window.hide(); + #[cfg(target_os = "macos")] + app.set_activation_policy(tauri::ActivationPolicy::Accessory) + .unwrap(); + } + } + } + "accessory_mode" => { + #[cfg(target_os = "macos")] + app.set_activation_policy(tauri::ActivationPolicy::Accessory) + .unwrap(); + } + // "hide" => { + // if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + // if window.is_minimized().unwrap() { + // let _ = window.show(); + // let _ = window.set_focus(); + // #[cfg(target_os = "macos")] + // app.set_activation_policy(tauri::ActivationPolicy::Regular).unwrap(); + // } else { + // let _ = window.hide(); + // #[cfg(target_os = "macos")] + // app.set_activation_policy(tauri::ActivationPolicy::Accessory).unwrap(); + // } + // } + // } + // Add more events here + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + let _ = window.show(); + let _ = window.set_focus(); + #[cfg(target_os = "macos")] + app.set_activation_policy(tauri::ActivationPolicy::Regular) + .unwrap(); + } + } + }) + .build(app); + + Ok(()) +} diff --git a/seanime-2.9.10/seanime-desktop/src-tauri/tauri.conf.json b/seanime-2.9.10/seanime-desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..1858278 --- /dev/null +++ b/seanime-2.9.10/seanime-desktop/src-tauri/tauri.conf.json @@ -0,0 +1,89 @@ +{ + "productName": "Seanime Desktop", + "version": "2.9.10", + "identifier": "app.seanime.desktop", + "build": { + "frontendDist": "../../web-desktop", + "devUrl": "http://127.0.0.1:43210" + }, + "app": { + "withGlobalTauri": true, + "macOSPrivateApi": true, + "windows": [ + { + "label": "main", + "title": "Seanime", + "width": 800, + "height": 600, + "resizable": true, + "fullscreen": false, + "visible": false, + "hiddenTitle": true + }, + { + "label": "splashscreen", + "title": "Seanime", + "width": 800, + "height": 600, + "resizable": false, + "decorations": false, + "url": "/splashscreen", + "hiddenTitle": true + }, + { + "label": "crash_screen", + "title": "Seanime", + "width": 800, + "height": 600, + "resizable": false, + "decorations": false, + "url": "/splashscreen/crash", + "hiddenTitle": true, + "visible": false + } + ], + "security": { + "csp": null + } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDg4Q0RFQTc5NTQyRDU4RDYKUldUV1dDMVVlZXJOaU8xMlBhbU1xNG1IY2lLVG1oMXBnWm81VTNKem11N3EzcWk4NHI0SXhtbGkK", + "endpoints": [ + "https://github.com/5rahim/seanime/releases/latest/download/latest.json" + ] + } + }, + "bundle": { + "active": true, + "createUpdaterArtifacts": true, + "targets": [ + "appimage", + "nsis", + "app" + ], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "linux": { + "appimage": { + "bundleMediaFramework": true + } + }, + "windows": { + "nsis": { + "minimumWebview2Version": "110.0.1531.0" + } + }, + "macOS": { + "signingIdentity": "-" + }, + "externalBin": [ + "binaries/seanime" + ] + } +} diff --git a/seanime-2.9.10/seanime-web/.env.denshi b/seanime-2.9.10/seanime-web/.env.denshi new file mode 100644 index 0000000..704a3a1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/.env.denshi @@ -0,0 +1,2 @@ +NEXT_PUBLIC_PLATFORM="desktop" +NEXT_PUBLIC_DESKTOP="electron" diff --git a/seanime-2.9.10/seanime-web/.env.desktop b/seanime-2.9.10/seanime-web/.env.desktop new file mode 100644 index 0000000..4157867 --- /dev/null +++ b/seanime-2.9.10/seanime-web/.env.desktop @@ -0,0 +1,2 @@ +NEXT_PUBLIC_PLATFORM="desktop" +NEXT_PUBLIC_DESKTOP="tauri" diff --git a/seanime-2.9.10/seanime-web/.env.development.desktop b/seanime-2.9.10/seanime-web/.env.development.desktop new file mode 100644 index 0000000..75eaf44 --- /dev/null +++ b/seanime-2.9.10/seanime-web/.env.development.desktop @@ -0,0 +1,2 @@ +NEXT_PUBLIC_PLATFORM="desktop" +NEXT_PUBLIC_DEVBUILD="true" diff --git a/seanime-2.9.10/seanime-web/.env.mobile b/seanime-2.9.10/seanime-web/.env.mobile new file mode 100644 index 0000000..7f19d93 --- /dev/null +++ b/seanime-2.9.10/seanime-web/.env.mobile @@ -0,0 +1 @@ +NEXT_PUBLIC_PLATFORM="mobile" diff --git a/seanime-2.9.10/seanime-web/.env.web b/seanime-2.9.10/seanime-web/.env.web new file mode 100644 index 0000000..cc97073 --- /dev/null +++ b/seanime-2.9.10/seanime-web/.env.web @@ -0,0 +1 @@ +NEXT_PUBLIC_PLATFORM="web" diff --git a/seanime-2.9.10/seanime-web/.gitignore b/seanime-2.9.10/seanime-web/.gitignore new file mode 100644 index 0000000..aa11f7c --- /dev/null +++ b/seanime-2.9.10/seanime-web/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.idea +snapshot +logs +analyze +TODO-priv.md +CHANGELOG-priv.md \ No newline at end of file diff --git a/seanime-2.9.10/seanime-web/Makefile b/seanime-2.9.10/seanime-web/Makefile new file mode 100644 index 0000000..b1419cc --- /dev/null +++ b/seanime-2.9.10/seanime-web/Makefile @@ -0,0 +1,21 @@ +build-web-only: + rm -rf ../web + npm run build + cp -r out ../web + +build-web: + rm -rf ../web + rm -rf ../web-desktop + npm run build + cp -r out ../web + npm run build:desktop + cp -r out-desktop ../web-desktop + +build-denshi: + rm -rf ../web-denshi + npm run build:denshi + cp -r out-denshi ../web-denshi + rm -rf ../seanime-denshi/web-denshi + cp -r ../web-denshi ../seanime-denshi/web-denshi + +.PHONY: build-web build-denshi diff --git a/seanime-2.9.10/seanime-web/README.md b/seanime-2.9.10/seanime-web/README.md new file mode 100644 index 0000000..0ba1546 --- /dev/null +++ b/seanime-2.9.10/seanime-web/README.md @@ -0,0 +1,45 @@ +<p align="center"> +<img src="public/logo_2.png" alt="preview" width="150px"/> +</p> + +<h2 align="center"><b>Seanime Web</b></h2> + +<h4 align="center">Web interface</h4> + +```txt +📁 api + ├── 📁 client + ├── 📁 generated + └── 📁 hooks +📁 app/(main) + ├── 📁 _atoms + ├── 📁 _features + ├── 📁 _hooks + ├── 📁 _listeners + └── 📁 {route} + ├── 📁 _containers + ├── 📁 _components + ├── 📁 _lib + ├── 📄 layout.tsx + └── 📄 page.tsx +📁 components +``` + +- `api`: API related code. + - `client`: React-Query and Axios related code. + - `generated`: Generated types and endpoints. + - `hooks`: Data-fetching hooks. + + +- `app` + - `_atoms`: Global Jotai atoms + - `_hooks`: Top-level queries (loaders) and global state hooks. + - `_features`: Specialized components that are used across multiple pages. + - `_listeners`: Websocket listeners. + - `{route}`: Route directory. + - `_components`: Route-specific components that only depend on props. + - `_containers`: Route-specific components that interact with global state and API. + - `_lib`: Route-specific utility functions, hooks, constants, and data-related functions. + + +- `components`: Primitive components, not tied to any feature or route. diff --git a/seanime-2.9.10/seanime-web/internal/logo.png b/seanime-2.9.10/seanime-web/internal/logo.png new file mode 100644 index 0000000..70ae2e2 Binary files /dev/null and b/seanime-2.9.10/seanime-web/internal/logo.png differ diff --git a/seanime-2.9.10/seanime-web/next.config.js b/seanime-2.9.10/seanime-web/next.config.js new file mode 100644 index 0000000..b335b8d --- /dev/null +++ b/seanime-2.9.10/seanime-web/next.config.js @@ -0,0 +1,25 @@ +const isProd = process.env.NODE_ENV === 'production'; +const isDesktop = process.env.NEXT_PUBLIC_PLATFORM === 'desktop'; +const isTauriDesktop = process.env.NEXT_PUBLIC_DESKTOP === 'tauri'; +const isElectronDesktop = process.env.NEXT_PUBLIC_DESKTOP === 'electron'; +const internalHost = process.env.TAURI_DEV_HOST || '127.0.0.1'; + + +/** @type {import('next').NextConfig} */ +const nextConfig = { + ...(isProd && {output: "export"}), + distDir: isDesktop ? (isElectronDesktop ? "out-denshi" : "out-desktop") : undefined, + cleanDistDir: true, + reactStrictMode: false, + images: { + unoptimized: true, + }, + transpilePackages: ["@uiw/react-textarea-code-editor", "@replit/codemirror-vscode-keymap", "media-chrome", "anime4k-webgpu"], + assetPrefix: isProd ? undefined : (isDesktop ? `http://${internalHost}:43210` : undefined), + experimental: { + reactCompiler: true, + }, + devIndicators: false, +} + +module.exports = nextConfig diff --git a/seanime-2.9.10/seanime-web/package-lock.json b/seanime-2.9.10/seanime-web/package-lock.json new file mode 100644 index 0000000..2d0b9fe --- /dev/null +++ b/seanime-2.9.10/seanime-web/package-lock.json @@ -0,0 +1,9056 @@ +{ + "name": "seanime-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "seanime-web", + "version": "0.1.0", + "dependencies": { + "@codemirror/autocomplete": "^6.18.6", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/language": "^6.11.1", + "@codemirror/legacy-modes": "^6.5.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@headlessui/react": "1.7.18", + "@headlessui/tailwindcss": "0.2.1", + "@hookform/resolvers": "^5.1.1", + "@next/bundle-analyzer": "^15.3.4", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-context-menu": "^2.2.15", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-hover-card": "^1.1.14", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.7", + "@replit/codemirror-vscode-keymap": "^6.0.2", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-query": "^5.81.5", + "@tanstack/react-query-devtools": "^5.81.5", + "@tanstack/react-table": "^8.21.3", + "@tauri-apps/api": "^2.4.1", + "@tauri-apps/plugin-clipboard-manager": "^2.2.2", + "@tauri-apps/plugin-os": "^2.2.1", + "@tauri-apps/plugin-process": "^2.2.1", + "@tauri-apps/plugin-shell": "^2.2.1", + "@tauri-apps/plugin-updater": "^2.7.0", + "@total-typescript/ts-reset": "^0.6.1", + "@uidotdev/usehooks": "^2.4.1", + "@uiw/codemirror-theme-vscode": "^4.23.14", + "@uiw/react-codemirror": "^4.23.14", + "@vidstack/react": "^1.10.9", + "@zag-js/number-input": "^1.15.7", + "@zag-js/react": "^1.15.7", + "anime4k-webgpu": "^1.0.0", + "artplayer": "^5.2.3", + "autoprefixer": "^10", + "axios": "^1.11.0", + "babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250405", + "bottleneck": "^2.19.5", + "bytes-iec": "^3.1.1", + "chalk": "^5.4.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "collect.js": "^4.36.1", + "colord": "^2.9.3", + "concurrently": "^8.2.2", + "copy-to-clipboard": "^3.3.3", + "crypto-js": "^4.2.0", + "date-fns": "^3.6.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "embla-carousel-auto-scroll": "^8.6.0", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", + "env-cmd": "^10.1.0", + "execa": "^8.0.1", + "fast-deep-equal": "^3.1.3", + "fs-extra": "^11.3.0", + "hls.js": "1.5.20", + "inflection": "^3.0.2", + "install": "^0.13.0", + "jassub": "^1.8.6", + "jotai": "^2.13.0", + "jotai-derive": "^0.1.3", + "jotai-immer": "^0.4.1", + "jotai-optics": "^0.4.0", + "jotai-scope": "^0.9.3", + "js-cookies": "^1.0.4", + "js-levenshtein": "^1.1.6", + "js-sha256": "^0.11.1", + "json2toml": "^6.1.1", + "libphonenumber-js": "^1.12.9", + "media-icons": "^0.10.0", + "memory-cache": "^0.2.0", + "motion": "^12.23.12", + "mousetrap": "^1.6.5", + "needle": "^3.3.1", + "next": "15.3.0", + "next-themes": "^0.4.6", + "normalize-path": "^3.0.0", + "ora": "^8.2.0", + "path-browserify": "^1.0.1", + "postcss": "^8", + "react": "^18", + "react-colorful": "^5.6.1", + "react-cookie": "^8.0.1", + "react-currency-input-field": "^3.10.0", + "react-day-picker": "^8.10.1", + "react-dom": "^18", + "react-draggable": "^4.4.6", + "react-dropzone": "^14.3.8", + "react-error-boundary": "^5.0.0", + "react-hook-form": "^7.62.0", + "react-icons": "^5.5.0", + "react-phone-number-input": "^3.4.12", + "react-remove-scroll-bar": "^2.3.8", + "react-resizable-panels": "^2.1.7", + "react-use": "^17.6.0", + "react-virtuoso": "^4.13.0", + "recharts": "^2.15.3", + "rehype-prism-plus": "^2.0.1", + "sonner": "2.0.5", + "tailwind-merge": "~2.6.0", + "tailwind-scrollbar-hide": "~1.1.7", + "tailwindcss-animate": "~1.0.7", + "tsx": "^4.20.3", + "typescript": "^5", + "upath": "^2.0.1", + "use-debounce": "^10.0.5", + "vaul": "^1.1.2", + "vitest": "^3.2.4", + "zod": "^3.25.67" + }, + "devDependencies": { + "@graphql-codegen/client-preset": "4.8.2", + "@types/crypto-js": "^4.2.2", + "@types/fs-extra": "^11.0.4", + "@types/google.maps": "^3.58.1", + "@types/js-levenshtein": "^1.1.3", + "@types/lodash": "^4.17.18", + "@types/memory-cache": "^0.2.6", + "@types/mousetrap": "^1.6.15", + "@types/needle": "^3.3.0", + "@types/node": "^22.15.32", + "@types/path-browserify": "^1.0.3", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@types/react-window": "^1.8.8", + "autoprefixer": "^10", + "encoding": "^0.1.13", + "postcss": "^8", + "tailwindcss": "^3.4.17", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ardatan/relay-compiler": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@ardatan/relay-compiler/-/relay-compiler-12.0.3.tgz", + "integrity": "sha512-mBDFOGvAoVlWaWqs3hm1AciGHSQE1rqFc/liZTyYz/Oek9yZdT5H26pH2zAFuEiTiBVPPyMuqf5VjOFPI2DGsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/runtime": "^7.26.10", + "chalk": "^4.0.0", + "fb-watchman": "^2.0.0", + "immutable": "~3.7.6", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "relay-runtime": "12.0.0", + "signedsource": "^1.0.0" + }, + "bin": { + "relay-compiler": "bin/relay-compiler" + }, + "peerDependencies": { + "graphql": "*" + } + }, + "node_modules/@ardatan/relay-compiler/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@ardatan/relay-compiler/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz", + "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.10", + "@babel/types": "^7.26.10", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", + "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", + "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.10" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", + "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", + "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.18.6", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz", + "integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.0.tgz", + "integrity": "sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.1.tgz", + "integrity": "sha512-5kS1U7emOGV84vxC+ruBty5sUgcD0te6dyupyRVG2zaSjhTDM73LhVKUtVwiqSe6QwmEoA4SCiU8AKPFyumAWQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.1.tgz", + "integrity": "sha512-DJYQQ00N1/KdESpZV7jg9hafof/iBNp9h7TYo1SLMk86TWl9uDsVdho2dzd81K+v4retmK6mdC7WpuOQDytQqw==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.1.tgz", + "integrity": "sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.6", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz", + "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", + "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", + "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.29.1.tgz", + "integrity": "sha512-7r+DlO/QFwPqKp73uq5mmrS4TuLPUVotbNOKYzN3OLP5ScrOVXcm4g13/48b6ZXGhdmzMinzFYqH0vo+qihIkQ==", + "dependencies": { + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", + "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz", + "integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz", + "integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.3.tgz", + "integrity": "sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, + "node_modules/@graphql-codegen/add": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-5.0.3.tgz", + "integrity": "sha512-SxXPmramkth8XtBlAHu4H4jYcYXM/o3p01+psU+0NADQowA8jtYkK6MW5rV6T+CxkEaNZItfSmZRPgIuypcqnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.3", + "tslib": "~2.6.0" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/client-preset": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-4.8.2.tgz", + "integrity": "sha512-YoH2obkNLorgT7bs5cbveg6A1fM4ZW5AE/CWLaSzViMTAXk51q0z/5+sTrDW2Ft6Or3mTxFLEByCgXhPgAj2Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/template": "^7.20.7", + "@graphql-codegen/add": "^5.0.3", + "@graphql-codegen/gql-tag-operations": "4.0.17", + "@graphql-codegen/plugin-helpers": "^5.1.1", + "@graphql-codegen/typed-document-node": "^5.1.1", + "@graphql-codegen/typescript": "^4.1.6", + "@graphql-codegen/typescript-operations": "^4.6.1", + "@graphql-codegen/visitor-plugin-common": "^5.8.0", + "@graphql-tools/documents": "^1.0.0", + "@graphql-tools/utils": "^10.0.0", + "@graphql-typed-document-node/core": "3.2.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "graphql-sock": "^1.0.0" + }, + "peerDependenciesMeta": { + "graphql-sock": { + "optional": true + } + } + }, + "node_modules/@graphql-codegen/gql-tag-operations": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-4.0.17.tgz", + "integrity": "sha512-2pnvPdIG6W9OuxkrEZ6hvZd142+O3B13lvhrZ48yyEBh2ujtmKokw0eTwDHtlXUqjVS0I3q7+HB2y12G/m69CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-tools/utils": "^10.0.0", + "auto-bind": "~4.0.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/plugin-helpers": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-5.1.1.tgz", + "integrity": "sha512-28GHODK2HY1NhdyRcPP3sCz0Kqxyfiz7boIZ8qIxFYmpLYnlDgiYok5fhFLVSZihyOpCs4Fa37gVHf/Q4I2FEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^10.0.0", + "change-case-all": "1.0.15", + "common-tags": "1.8.2", + "import-from": "4.0.0", + "lodash": "~4.17.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/schema-ast": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-4.1.0.tgz", + "integrity": "sha512-kZVn0z+th9SvqxfKYgztA6PM7mhnSZaj4fiuBWvMTqA+QqQ9BBed6Pz41KuD/jr0gJtnlr2A4++/0VlpVbCTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-tools/utils": "^10.0.0", + "tslib": "~2.6.0" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/typed-document-node": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-5.1.1.tgz", + "integrity": "sha512-Bp/BrMZDKRwzuVeLv+pSljneqONM7gqu57ZaV34Jbncu2hZWMRDMfizTKghoEwwZbRCYYfJO9tA0sYVVIfI1kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/visitor-plugin-common": "5.8.0", + "auto-bind": "~4.0.0", + "change-case-all": "1.0.15", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/typescript": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-4.1.6.tgz", + "integrity": "sha512-vpw3sfwf9A7S+kIUjyFxuvrywGxd4lmwmyYnnDVjVE4kSQ6Td3DpqaPTy8aNQ6O96vFoi/bxbZS2BW49PwSUUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/schema-ast": "^4.0.2", + "@graphql-codegen/visitor-plugin-common": "5.8.0", + "auto-bind": "~4.0.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/typescript-operations": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-4.6.1.tgz", + "integrity": "sha512-k92laxhih7s0WZ8j5WMIbgKwhe64C0As6x+PdcvgZFMudDJ7rPJ/hFqJ9DCRxNjXoHmSjnr6VUuQZq4lT1RzCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/typescript": "^4.1.6", + "@graphql-codegen/visitor-plugin-common": "5.8.0", + "auto-bind": "~4.0.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "graphql-sock": "^1.0.0" + }, + "peerDependenciesMeta": { + "graphql-sock": { + "optional": true + } + } + }, + "node_modules/@graphql-codegen/visitor-plugin-common": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-5.8.0.tgz", + "integrity": "sha512-lC1E1Kmuzi3WZUlYlqB4fP6+CvbKH9J+haU1iWmgsBx5/sO2ROeXJG4Dmt8gP03bI2BwjiwV5WxCEMlyeuzLnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-tools/optimize": "^2.0.0", + "@graphql-tools/relay-operation-optimizer": "^7.0.0", + "@graphql-tools/utils": "^10.0.0", + "auto-bind": "~4.0.0", + "change-case-all": "1.0.15", + "dependency-graph": "^0.11.0", + "graphql-tag": "^2.11.0", + "parse-filepath": "^1.0.2", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-tools/documents": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/documents/-/documents-1.0.1.tgz", + "integrity": "sha512-aweoMH15wNJ8g7b2r4C4WRuJxZ0ca8HtNO54rkye/3duxTkW4fGBEutCx03jCIr5+a1l+4vFJNP859QnAVBVCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/optimize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/optimize/-/optimize-2.0.0.tgz", + "integrity": "sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/relay-operation-optimizer": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.19.tgz", + "integrity": "sha512-xnjLpfzw63yIX1bo+BVh4j1attSwqEkUbpJ+HAhdiSUa3FOQFfpWgijRju+3i87CwhjBANqdTZbcsqLT1hEXig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ardatan/relay-compiler": "^12.0.3", + "@graphql-tools/utils": "^10.8.6", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "10.8.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.8.6.tgz", + "integrity": "sha512-Alc9Vyg0oOsGhRapfL3xvqh1zV8nKoFUdtLhXX7Ki4nClaIJXckrA86j+uxEuG3ic6j4jlM1nvcWXRn/71AVLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "dset": "^3.1.4", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dev": true, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@headlessui/react": { + "version": "1.7.18", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.18.tgz", + "integrity": "sha512-4i5DOrzwN4qSgNsL4Si61VMkUcWbcSKueUV7sFhpHzQcSShdlHENE5+QBntMSRvHt8NyoFO2AGG8si9lq+w4zQ==", + "dependencies": { + "@tanstack/react-virtual": "^3.0.0-beta.60", + "client-only": "^0.0.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, + "node_modules/@headlessui/tailwindcss": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.1.tgz", + "integrity": "sha512-2+5+NZ+RzMyrVeCZOxdbvkUSssSxGvcUxphkIfSVLpRiKsj+/63T2TOL9dBYMXVfj/CGr6hMxSRInzXv6YY7sA==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "tailwindcss": "^3.0" + } + }, + "node_modules/@hookform/resolvers": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.1.1.tgz", + "integrity": "sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz", + "integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.3.tgz", + "integrity": "sha512-p+Zh1sb6EfrfVaS86jlHGQ9HA66fJhV9x5LiE5vCbZtXEHAuhcmUZUdZ4WrFpUBfNalr2OkAJI5AcKEQF+Lebw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", + "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", + "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.4.17", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.17.tgz", + "integrity": "sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", + "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@next/bundle-analyzer": { + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-15.3.4.tgz", + "integrity": "sha512-AN9H9S+4WaIIahyJBGe6arLj5kopvVZPLffAJsDhkbQPGqirYqaHhwO6vheytXtdq3xNjwJLpbmYNa5ZQnitSw==", + "license": "MIT", + "dependencies": { + "webpack-bundle-analyzer": "4.10.1" + } + }, + "node_modules/@next/env": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.0.tgz", + "integrity": "sha512-6mDmHX24nWlHOlbwUiAOmMyY7KELimmi+ed8qWcJYjqXeC+G6JzPZ3QosOAfjNwgMIzwhXBiRiCgdh8axTTdTA==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.0.tgz", + "integrity": "sha512-PDQcByT0ZfF2q7QR9d+PNj3wlNN4K6Q8JoHMwFyk252gWo4gKt7BF8Y2+KBgDjTFBETXZ/TkBEUY7NIIY7A/Kw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.0.tgz", + "integrity": "sha512-m+eO21yg80En8HJ5c49AOQpFDq+nP51nu88ZOMCorvw3g//8g1JSUsEiPSiFpJo1KCTQ+jm9H0hwXK49H/RmXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.0.tgz", + "integrity": "sha512-H0Kk04ZNzb6Aq/G6e0un4B3HekPnyy6D+eUBYPJv9Abx8KDYgNMWzKt4Qhj57HXV3sTTjsfc1Trc1SxuhQB+Tg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.0.tgz", + "integrity": "sha512-k8GVkdMrh/+J9uIv/GpnHakzgDQhrprJ/FbGQvwWmstaeFG06nnAoZCJV+wO/bb603iKV1BXt4gHG+s2buJqZA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.0.tgz", + "integrity": "sha512-ZMQ9yzDEts/vkpFLRAqfYO1wSpIJGlQNK9gZ09PgyjBJUmg8F/bb8fw2EXKgEaHbCc4gmqMpDfh+T07qUphp9A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.0.tgz", + "integrity": "sha512-RFwq5VKYTw9TMr4T3e5HRP6T4RiAzfDJ6XsxH8j/ZeYq2aLsBqCkFzwMI0FmnSsLaUbOb46Uov0VvN3UciHX5A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.0.tgz", + "integrity": "sha512-a7kUbqa/k09xPjfCl0RSVAvEjAkYBYxUzSVAzk2ptXiNEL+4bDBo9wNC43G/osLA/EOGzG4CuNRFnQyIHfkRgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.0.tgz", + "integrity": "sha512-vHUQS4YVGJPmpjn7r5lEZuMhK5UQBNBRSB+iGDvJjaNk649pTIcRluDWNb9siunyLLiu/LDPHfvxBtNamyuLTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.24", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", + "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==" + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.11.tgz", + "integrity": "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collapsible": "1.1.11", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", + "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz", + "integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.15.tgz", + "integrity": "sha512-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz", + "integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.14.tgz", + "integrity": "sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", + "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.13.tgz", + "integrity": "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", + "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz", + "integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz", + "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", + "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.5.tgz", + "integrity": "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz", + "integrity": "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@replit/codemirror-vscode-keymap": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vscode-keymap/-/codemirror-vscode-keymap-6.0.2.tgz", + "integrity": "sha512-j45qTwGxzpsv82lMD/NreGDORFKSctMDVkGRopaP+OrzSzv+pXDQuU3LnFvKpasyjVT0lf+PKG1v2DSCn/vxxg==", + "license": "MIT", + "peerDependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", + "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@swc/helpers/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.81.5.tgz", + "integrity": "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.81.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.81.2.tgz", + "integrity": "sha512-jCeJcDCwKfoyyBXjXe9+Lo8aTkavygHHsUHAlxQKKaDeyT0qyQNLKl7+UyqYH2dDF6UN/14873IPBHchcsU+Zg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz", + "integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.81.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.81.5.tgz", + "integrity": "sha512-lCGMu4RX0uGnlrlLeSckBfnW/UV+KMlTBVqa97cwK7Z2ED5JKnZRSjNXwoma6sQBTJrcULvzgx2K6jEPvNUpDw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.81.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.81.5", + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.1.3.tgz", + "integrity": "sha512-YCzcbF/Ws/uZ0q3Z6fagH+JVhx4JLvbSflgldMgLsuvB8aXjZLLb3HvrEVxY480F9wFlBiXlvQxOyXb5ENPrNA==", + "dependencies": { + "@tanstack/virtual-core": "3.1.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.1.3.tgz", + "integrity": "sha512-Y5B4EYyv1j9V8LzeAoOVeTg0LI7Fo5InYKgAjkY1Pu9GjtUwX/EKxNcU7ng3sKr99WEf+bPTcktAeybyMOYo+g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.4.1.tgz", + "integrity": "sha512-5sYwZCSJb6PBGbBL4kt7CnE5HHbBqwH+ovmOW6ZVju3nX4E3JX6tt2kRklFEH7xMOIwR0btRkZktuLhKvyEQYg==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-clipboard-manager": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.2.2.tgz", + "integrity": "sha512-bZvDLMqfcNmsw7Ag8I49jlaCjdpDvvlJHnpp6P+Gg/3xtpSERdwlDxm7cKGbs2mj46dsw4AuG3RoAgcpwgioUA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-os": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.2.1.tgz", + "integrity": "sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.2.1.tgz", + "integrity": "sha512-cF/k8J+YjjuowhNG1AboHNTlrGiOwgX5j6NzsX6WFf9FMzyZUchkCgZMxCdSE5NIgFX0vvOgLQhODFJgbMenLg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.2.1.tgz", + "integrity": "sha512-G1GFYyWe/KlCsymuLiNImUgC8zGY0tI0Y3p8JgBCWduR5IEXlIJS+JuG1qtveitwYXlfJrsExt3enhv5l2/yhA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.7.0.tgz", + "integrity": "sha512-oBug5UCH2wOsoYk0LW5LEMAT51mszjg11s8eungRH26x/qOrEjLvnuJJoxVVr9nsWowJ6vnpXKS+lUMfFTlvHQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, + "node_modules/@total-typescript/ts-reset": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.6.1.tgz", + "integrity": "sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, + "node_modules/@types/google.maps": { + "version": "3.58.1", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", + "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", + "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, + "node_modules/@types/js-cookie": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", + "integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==" + }, + "node_modules/@types/js-levenshtein": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.3.tgz", + "integrity": "sha512-jd+Q+sD20Qfu9e2aEXogiO3vpOC1PYJOUdyN9gvs4Qrvkg4wF43L5OhqrPeokdv8TL0/mXoYfpkcoGZMNN2pkQ==", + "dev": true + }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.18", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.18.tgz", + "integrity": "sha512-KJ65INaxqxmU6EoCiJmRPZC9H9RVWCRd349tXM2M3O5NA7cY6YL7c0bHAHQ93NOfTObEQ004kd2QVHs/r0+m4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/memory-cache": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/memory-cache/-/memory-cache-0.2.6.tgz", + "integrity": "sha512-G+9tuwWqss2hvX+T/RNY9CY8qSvuCx8uwnl+tXQWwXtBelXUGcn4j6zknDR+2EfdrgBsMZik0jCKNlDEHIBONQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mousetrap": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.15.tgz", + "integrity": "sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw==", + "dev": true + }, + "node_modules/@types/needle": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@types/needle/-/needle-3.3.0.tgz", + "integrity": "sha512-UFIuc1gdyzAqeVUYpSL+cliw2MmU/ZUhVZKE7Zo4wPbgc8hbljeKSnn6ls6iG8r5jpegPXLUIhJ+Wb2kLVs8cg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.15.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz", + "integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/path-browserify": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/path-browserify/-/path-browserify-1.0.3.tgz", + "integrity": "sha512-ZmHivEbNCBtAfcrFeBCiTjdIc2dey0l7oCGNGpSuRTy8jP6UVND7oUowlvDujBy8r2Hoa8bfFUOCiPWfmtkfxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prismjs": { + "version": "1.26.4", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.4.tgz", + "integrity": "sha512-rlAnzkW2sZOjbqZ743IHUhFcvzaGbqijwOu8QZnZCjfQzBqFE3s4lOTJEsxikImav9uzz/42I+O7YUs1mWgMlg==" + }, + "node_modules/@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", + "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" + }, + "node_modules/@uidotdev/usehooks": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz", + "integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.23.14", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.14.tgz", + "integrity": "sha512-lCseubZqjN9bFwHJdQlZEKEo2yO1tCiMMVL0gu3ZXwhqMdfnd6ky/fUCYbn8aJkW+cXKVwjEVhpKjOphNiHoNw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/codemirror-theme-vscode": { + "version": "4.23.14", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-vscode/-/codemirror-theme-vscode-4.23.14.tgz", + "integrity": "sha512-4edbdHWusd+QSUM7+JS6/gavObRDqSMYzqK+YS0HyMQ47WU0Ya+L3O2KQH2vcxl1IX2Qmn3aVzGZu9Cf4mXpSw==", + "license": "MIT", + "dependencies": { + "@uiw/codemirror-themes": "4.23.14" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + } + }, + "node_modules/@uiw/codemirror-themes": { + "version": "4.23.14", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.23.14.tgz", + "integrity": "sha512-VoctEjY2fDOzTb0gSNRB8VcTbt11azUKLJzxXC/s4YNgz59C9i4X5vM99NgQAnEQ9vWaUBDgkmfkt1JiHBD++A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/language": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.23.14", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.23.14.tgz", + "integrity": "sha512-/CmlSh8LGUEZCxg/f78MEkEMehKnVklqJvJlL10AXXrO/2xOyPqHb8SK10GhwOqd0kHhHgVYp4+6oK5S+UIEuQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.23.14", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@vidstack/react": { + "version": "1.10.9", + "resolved": "https://registry.npmjs.org/@vidstack/react/-/react-1.10.9.tgz", + "integrity": "sha512-xMRhk8T2V/ULmDylYm2+a6F6zDAjXG2lHIUL/oRbERXDV/Ou+ZE+QDxcBFu2aIQ2rQriCyhh3pNpST8f9ePWVw==", + "license": "MIT", + "dependencies": { + "media-captions": "^1.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.64", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.64.tgz", + "integrity": "sha512-84kRIAGV46LJTlJZWxShiOrNL30A+9KokD7RB3dRCIqODFjodS5tCD5yyiZ8kIReGVZSDfA3XkkwyyOIF6K62A==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.0.tgz", + "integrity": "sha512-486CouizxHXucj8Ky153DDragfkMcHtVEToF5Pn/fInhUUSiCmt9Q4JVBa6UK5q4RammFBtGQ4C9qhGlXU9YbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@whatwg-node/promise-helpers/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@xobotyi/scrollbar-width": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz", + "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==" + }, + "node_modules/@zag-js/anatomy": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@zag-js/anatomy/-/anatomy-1.15.7.tgz", + "integrity": "sha512-kh1y7l+wtrMLX2/7SpoFDOu7pdVWADOD+Eta/ZmGG5UjTVzuhQExXZSqxIefJ6LwXU9ylFrl+1u3Ok6wovT44g==", + "license": "MIT" + }, + "node_modules/@zag-js/core": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@zag-js/core/-/core-1.15.7.tgz", + "integrity": "sha512-CCKe4RdDVKKIkeLfR/jhPVysjWuqI4JbPSxN7Glt+galePZQFJKavKqV3QpwqYo0+JfO7F8wktYBmtp73wtQqw==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.15.7", + "@zag-js/utils": "1.15.7" + } + }, + "node_modules/@zag-js/dom-query": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.15.7.tgz", + "integrity": "sha512-EDGICVzJ5Rh+hPYFG3UUARrF6q+VrjJgGVULMQ3moVI6M4/VMWyp6vN61hIxlUALBTyQtvqiSlzz11JRztAaNQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.15.7" + } + }, + "node_modules/@zag-js/number-input": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@zag-js/number-input/-/number-input-1.15.7.tgz", + "integrity": "sha512-a5WVApRzaauCT9zT3a3ESuLyTx+o32/X/TpWkZRZs4tSAoJhLJPkh84L8aa7buXMFgbni6hRLpC5NA2XV0bqTg==", + "license": "MIT", + "dependencies": { + "@internationalized/number": "3.6.3", + "@zag-js/anatomy": "1.15.7", + "@zag-js/core": "1.15.7", + "@zag-js/dom-query": "1.15.7", + "@zag-js/types": "1.15.7", + "@zag-js/utils": "1.15.7" + } + }, + "node_modules/@zag-js/react": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@zag-js/react/-/react-1.15.7.tgz", + "integrity": "sha512-JaUZ9VyIVtDsTuNd5HeB+NZrutPZ4pV9sBAkgsxiRgoHsjsWOXCi3Dvk9D9WmXyr0pTLGQ3ejJJV2toqGZoLLw==", + "license": "MIT", + "dependencies": { + "@zag-js/core": "1.15.7", + "@zag-js/store": "1.15.7", + "@zag-js/types": "1.15.7", + "@zag-js/utils": "1.15.7" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@zag-js/store": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@zag-js/store/-/store-1.15.7.tgz", + "integrity": "sha512-Z7vLUlITaHUaEACSPWfePEryG0C7htjj9uYkxXLYOwJTGWG7LGiyrrz6BMqL6RVXPT64l8dQV4k9+bLDKjslsQ==", + "license": "MIT", + "dependencies": { + "proxy-compare": "3.0.1" + } + }, + "node_modules/@zag-js/types": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-1.15.7.tgz", + "integrity": "sha512-kTsL/F1ebTYyzdyPZgGRc/KJC878vegjFRqidwxGLmnYy+TRNK1abnY+nczfHsuQHOzNfPrFPr+7JH9wBQFJKw==", + "license": "MIT", + "dependencies": { + "csstype": "3.1.3" + } + }, + "node_modules/@zag-js/utils": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-1.15.7.tgz", + "integrity": "sha512-fa5uZ8odfFqgU8cV+wKey+Iw3W/cTWHXjVqjtxL39jdaXqY34rb+ILez5QuxNpHJKSsGH5EDJLXsx1+G6pJiZQ==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anime4k-webgpu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/anime4k-webgpu/-/anime4k-webgpu-1.0.0.tgz", + "integrity": "sha512-syZZyDRHYukFnDLxdES4AuMHzKwxlu5Lo52yhEEc1hwKQmvza8DGC62/VDPrrWOu4KArU5jh/0yFJQBwetGuxg==", + "license": "MIT", + "peerDependencies": { + "@webgpu/types": "^0.1.38" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/artplayer": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/artplayer/-/artplayer-5.2.3.tgz", + "integrity": "sha512-WaOZQrpZn/L+GgI2f0TEsoAL3Wb+v16Mu0JmWh7qKFYuvr11WNt3dWhWeIaCfoHy3NtkCWM9jTP+xwwsxdElZQ==", + "license": "MIT", + "dependencies": { + "option-validator": "^2.0.6" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/auto-bind": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz", + "integrity": "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.18", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.18.tgz", + "integrity": "sha512-1DKbDfsr6KUElM6wg+0zRNkB/Q7WcKYAaK+pzXn+Xqmszm/5Xa9coeNdtP88Vi+dPzZnMjhge8GIV49ZQkDa+g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001591", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "19.0.0-beta-ebf51a3-20250411", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.0.0-beta-ebf51a3-20250411.tgz", + "integrity": "sha512-q84bNR9JG1crykAlJUt5Ud0/5BUyMFuQww/mrwIQDFBaxsikqBDj3f/FNDsVd2iR26A1HvXKWPEIfgJDv8/V2g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==" + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes-iec": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bytes-iec/-/bytes-iec-3.1.1.tgz", + "integrity": "sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001662", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001662.tgz", + "integrity": "sha512-sgMUVwLmGseH8ZIrm1d51UbrhqMCH3jvS7gF/M6byuHOnKyLOBL7W8yz5V02OHwgLGA36o/AFhWzzh4uc5aqTA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/capital-case": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", + "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", + "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", + "dev": true, + "dependencies": { + "camel-case": "^4.1.2", + "capital-case": "^1.0.4", + "constant-case": "^3.0.4", + "dot-case": "^3.0.4", + "header-case": "^2.0.4", + "no-case": "^3.0.4", + "param-case": "^3.0.4", + "pascal-case": "^3.1.2", + "path-case": "^3.0.4", + "sentence-case": "^3.0.4", + "snake-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/change-case-all": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.15.tgz", + "integrity": "sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ==", + "dev": true, + "dependencies": { + "change-case": "^4.1.2", + "is-lower-case": "^2.0.2", + "is-upper-case": "^2.0.2", + "lower-case": "^2.0.2", + "lower-case-first": "^2.0.2", + "sponge-case": "^1.0.1", + "swap-case": "^2.0.2", + "title-case": "^3.0.3", + "upper-case": "^2.0.2", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/codemirror": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", + "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/collect.js": { + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/collect.js/-/collect.js-4.36.1.tgz", + "integrity": "sha512-jd97xWPKgHn6uvK31V6zcyPd40lUJd7gpYxbN2VOVxGWO4tyvS9Li4EpsFjXepGTo2tYcOTC4a8YsbQXMJ4XUw==" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/constant-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", + "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case": "^2.0.2" + } + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/country-flag-icons": { + "version": "1.5.18", + "resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.5.18.tgz", + "integrity": "sha512-z+Uzesi8u8IdkViqqbzzbkf3+a7WJpcET5B7sPwTg7GXqPYpVEgNlZ/FC3l8KO4mEf+mNkmzKLppKTN4PlCJEQ==", + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.27", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.27.tgz", + "integrity": "sha512-o37j1vZqCoEgBuWWXLHQgTN/KDKe7zwpiY5CPeq2RvUqOyJw9xnrULzZAEVQ5p4h+zjMk7hgtOoPdnLxr7m/jw==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-auto-scroll": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-auto-scroll/-/embla-carousel-auto-scroll-8.6.0.tgz", + "integrity": "sha512-WT9fWhNXFpbQ6kP+aS07oF5IHYLZ1Dx4DkwgCY8Hv2ZyYd2KMCPfMV1q/cA3wFGuLO7GMgKiySLX90/pQkcOdQ==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.6.0.tgz", + "integrity": "sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-cmd": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-10.1.0.tgz", + "integrity": "sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==", + "dependencies": { + "commander": "^4.0.0", + "cross-spawn": "^7.0.0" + }, + "bin": { + "env-cmd": "bin/env-cmd.js" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", + "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.1", + "@esbuild/android-arm": "0.25.1", + "@esbuild/android-arm64": "0.25.1", + "@esbuild/android-x64": "0.25.1", + "@esbuild/darwin-arm64": "0.25.1", + "@esbuild/darwin-x64": "0.25.1", + "@esbuild/freebsd-arm64": "0.25.1", + "@esbuild/freebsd-x64": "0.25.1", + "@esbuild/linux-arm": "0.25.1", + "@esbuild/linux-arm64": "0.25.1", + "@esbuild/linux-ia32": "0.25.1", + "@esbuild/linux-loong64": "0.25.1", + "@esbuild/linux-mips64el": "0.25.1", + "@esbuild/linux-ppc64": "0.25.1", + "@esbuild/linux-riscv64": "0.25.1", + "@esbuild/linux-s390x": "0.25.1", + "@esbuild/linux-x64": "0.25.1", + "@esbuild/netbsd-arm64": "0.25.1", + "@esbuild/netbsd-x64": "0.25.1", + "@esbuild/openbsd-arm64": "0.25.1", + "@esbuild/openbsd-x64": "0.25.1", + "@esbuild/sunos-x64": "0.25.1", + "@esbuild/win32-arm64": "0.25.1", + "@esbuild/win32-ia32": "0.25.1", + "@esbuild/win32-x64": "0.25.1" + } + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-shallow-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz", + "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==" + }, + "node_modules/fastest-stable-stringify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz", + "integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==" + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/file-selector/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz", + "integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.12", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.5.tgz", + "integrity": "sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/graphql": { + "version": "16.8.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.2.tgz", + "integrity": "sha512-cvVIBILwuoSyD54U4cF/UXDh5yAobhNV/tPygI4lZhgOIJQE/WLWC4waBRb4I6bDVYb3OVx3lfHbaQOEoUD5sg==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.1.tgz", + "integrity": "sha512-RXQBLMl9kjKVNkJTIO6bZyb2n+cUH8LFaSSzo82jiLT6Tfc+Pt7VQCS+/h3YwG4jaNE2TA2sdJisGWR+aJrp0g==", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.1.tgz", + "integrity": "sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^8.0.0", + "property-information": "^6.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.0.tgz", + "integrity": "sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-8.0.0.tgz", + "integrity": "sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/header-case": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", + "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", + "dev": true, + "dependencies": { + "capital-case": "^1.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/hls.js": { + "version": "1.5.20", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz", + "integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==", + "license": "Apache-2.0" + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immer": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz", + "integrity": "sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/immutable": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", + "integrity": "sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/import-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-from/-/import-from-4.0.0.tgz", + "integrity": "sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==", + "dev": true, + "engines": { + "node": ">=12.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflection": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-3.0.2.tgz", + "integrity": "sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", + "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, + "node_modules/input-format": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.10.tgz", + "integrity": "sha512-5cFv/kOZD7Ch0viprVkuYPDkAU7HBZYBx8QrIpQ6yXUWbAQ0+RQ8IIojDJOf/RO6FDJLL099HDSK2KoVZ2zevg==", + "dependencies": { + "prop-types": "^15.8.1" + } + }, + "node_modules/install": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/install/-/install-0.13.0.tgz", + "integrity": "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz", + "integrity": "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unc-path": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unc-path-regex": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-upper-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz", + "integrity": "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jassub": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/jassub/-/jassub-1.8.6.tgz", + "integrity": "sha512-56ZTtjM7LfdKsi7boUN/seNOQSOclLuDWEXxnHO55xNakj95SlBrv36hLyNDw0NmoOtLVeqzbpBU1VxT+ubFpg==", + "license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0", + "dependencies": { + "rvfc-polyfill": "^1.0.7" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jotai": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.13.0.tgz", + "integrity": "sha512-H43zXdanNTdpfOEJ4NVbm4hgmrctpXLZagjJNcqAywhUv+sTE7esvFjwm5oBg/ywT9Qw63lIkM6fjrhFuW8UDg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0", + "@babel/template": ">=7.0.0", + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@babel/template": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/jotai-derive": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/jotai-derive/-/jotai-derive-0.1.3.tgz", + "integrity": "sha512-0jRoDpQ0prXxGRxh1rOqaM8Lkku+LohCRVES1cKtlWq7d80HviIDtCdWnP2QmQtUtyRtRW5JbYB4V7tODvnLFg==", + "deprecated": "This package has been moved to https://www.npmjs.com/package/jotai-eager", + "license": "MIT", + "peerDependencies": { + "jotai": ">=2.0.0" + } + }, + "node_modules/jotai-immer": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/jotai-immer/-/jotai-immer-0.4.1.tgz", + "integrity": "sha512-nQTt1HBKie/5OJDck1qLpV1PeBA6bjJLAczEYAx70PD8R4Mbu7gtexfBUCzJh6W6ecsOfwHksAYAesVth6SN9A==", + "peerDependencies": { + "immer": ">=9.0.0", + "jotai": ">=2.0.0" + } + }, + "node_modules/jotai-optics": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/jotai-optics/-/jotai-optics-0.4.0.tgz", + "integrity": "sha512-osbEt9AgS55hC4YTZDew2urXKZkaiLmLqkTS/wfW5/l0ib8bmmQ7kBXSFaosV6jDDWSp00IipITcJARFHdp42g==", + "peerDependencies": { + "jotai": ">=2.0.0", + "optics-ts": ">=2.0.0" + } + }, + "node_modules/jotai-scope": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/jotai-scope/-/jotai-scope-0.9.3.tgz", + "integrity": "sha512-uwigjUy8HA3Ly/QLZkzMdsvVVbH/9yqs0dSfJxg0kAoSA/k6b6paMkC59M7GMhVOl2RenoGl4irDO7YP3MS03A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "jotai": ">=2.12.0", + "react": ">=16.0.0" + } + }, + "node_modules/js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" + }, + "node_modules/js-cookies": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/js-cookies/-/js-cookies-1.0.4.tgz", + "integrity": "sha512-cO1SHDH7zJsi8FihHmDtcWx90mWmrfGOrcLKPeaEX6tLyuTK2wnzgdmNa34Q6rNAd6VhQUgjDt5Eyl90VI/Fpg==" + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-sha256": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", + "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json2toml": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/json2toml/-/json2toml-6.1.1.tgz", + "integrity": "sha512-kv8qBrRbdK7TVUdQuMcyJxw6pmDCYS5WNco23kcZ9foh+Jp4Q/up65wCOizRMT5EoAhcAHD15jDI0dbKEglzEQ==", + "license": "MIT", + "dependencies": { + "lodash.isdate": "^4.0.1", + "lodash.isempty": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "strftime": "^0.10.3" + }, + "engines": { + "node": "18 || >=20" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.9.tgz", + "integrity": "sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==" + }, + "node_modules/lodash.isdate": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isdate/-/lodash.isdate-4.0.1.tgz", + "integrity": "sha512-hg5B1GD+R9egsBgMwmAhk+V53Us03TVvXT4dnyKugEfsD4QKuG9Wlyvxq8OGy2nu7qVGsh4DRSnMk33hoWBq/Q==", + "license": "MIT" + }, + "node_modules/lodash.isempty": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", + "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz", + "integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==", + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lower-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case-first/-/lower-case-first-2.0.2.tgz", + "integrity": "sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + }, + "node_modules/media-captions": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/media-captions/-/media-captions-1.0.4.tgz", + "integrity": "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/media-icons": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/media-icons/-/media-icons-0.10.0.tgz", + "integrity": "sha512-M9loX7KUWsID3T8pRSN6/+MNKPEm9dNteqJk7yfo9ZaAIEYzEd07jWTVRlRmgVMKoAh1kY7funD6Qe1prrTJMQ==", + "engines": { + "node": ">=16" + } + }, + "node_modules/memory-cache": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", + "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/motion": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.12.tgz", + "integrity": "sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.23.12", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz", + "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, + "node_modules/mousetrap": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", + "integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==" + }, + "node_modules/mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nano-css": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.6.2.tgz", + "integrity": "sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "css-tree": "^1.1.2", + "csstype": "^3.1.2", + "fastest-stable-stringify": "^2.0.2", + "inline-style-prefixer": "^7.0.1", + "rtl-css-js": "^1.16.1", + "stacktrace-js": "^2.0.2", + "stylis": "^4.3.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/needle": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", + "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/next": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/next/-/next-15.3.0.tgz", + "integrity": "sha512-k0MgP6BsK8cZ73wRjMazl2y2UcXj49ZXLDEgx6BikWuby/CN+nh81qFFI16edgd7xYpe/jj2OZEIwCoqnzz0bQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.3.0", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.15", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.3.0", + "@next/swc-darwin-x64": "15.3.0", + "@next/swc-linux-arm64-gnu": "15.3.0", + "@next/swc-linux-arm64-musl": "15.3.0", + "@next/swc-linux-x64-gnu": "15.3.0", + "@next/swc-linux-x64-musl": "15.3.0", + "@next/swc-win32-arm64-msvc": "15.3.0", + "@next/swc-win32-x64-msvc": "15.3.0", + "sharp": "^0.34.1" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/optics-ts": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/optics-ts/-/optics-ts-2.4.1.tgz", + "integrity": "sha512-HaYzMHvC80r7U/LqAd4hQyopDezC60PO2qF5GuIwALut2cl5rK1VWHsqTp0oqoJJWjiv6uXKqsO+Q2OO0C3MmQ==", + "peer": true + }, + "node_modules/option-validator": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/option-validator/-/option-validator-2.0.6.tgz", + "integrity": "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==", + "dependencies": { + "kind-of": "^6.0.3" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.0.0.tgz", + "integrity": "sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parse-entities": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.1.tgz", + "integrity": "sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", + "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" + }, + "node_modules/parse-filepath": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", + "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-absolute": "^1.0.0", + "map-cache": "^0.2.0", + "path-root": "^0.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/parse-numeric-range": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz", + "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==" + }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/path-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", + "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", + "dev": true, + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-root-regex": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-scurry": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", + "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "dependencies": { + "lru-cache": "^9.1.1 || ^10.0.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", + "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz", + "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/react-cookie": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-8.0.1.tgz", + "integrity": "sha512-QNdAd0MLuAiDiLcDU/2s/eyKmmfMHtjPUKJ2dZ/5CcQ9QKUium4B3o61/haq6PQl/YWFqC5PO8GvxeHKhy3GFA==", + "license": "MIT", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.6", + "hoist-non-react-statics": "^3.3.2", + "universal-cookie": "^8.0.0" + }, + "peerDependencies": { + "react": ">= 16.3.0" + } + }, + "node_modules/react-currency-input-field": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/react-currency-input-field/-/react-currency-input-field-3.10.0.tgz", + "integrity": "sha512-GRmZogHh1e1LrmgXg/fKHSuRLYUnj/c/AumfvfuDMA0UX1mDR6u2NR0fzDemRdq4tNHNLucJeJ2OKCr3ehqyDA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-day-picker": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", + "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-error-boundary": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-5.0.0.tgz", + "integrity": "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, + "node_modules/react-hook-form": { + "version": "7.62.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", + "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-phone-number-input": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.12.tgz", + "integrity": "sha512-Raob77KdtLGm49iC6nuOX9qy6Mg16idkgC7Y1mHmvG2WBYoauHpzxYNlfmFskQKeiztrJIwPhPzBhjFwjenNCA==", + "license": "MIT", + "dependencies": { + "classnames": "^2.5.1", + "country-flag-icons": "^1.5.17", + "input-format": "^0.3.10", + "libphonenumber-js": "^1.11.20", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", + "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz", + "integrity": "sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/react-universal-interface": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz", + "integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==", + "peerDependencies": { + "react": "*", + "tslib": "*" + } + }, + "node_modules/react-use": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/react-use/-/react-use-17.6.0.tgz", + "integrity": "sha512-OmedEScUMKFfzn1Ir8dBxiLLSOzhKe/dPZwVxcujweSj45aNM7BEGPb9BEVIgVEqEXx6f3/TsXzwIktNgUR02g==", + "license": "Unlicense", + "dependencies": { + "@types/js-cookie": "^2.2.6", + "@xobotyi/scrollbar-width": "^1.9.5", + "copy-to-clipboard": "^3.3.1", + "fast-deep-equal": "^3.1.3", + "fast-shallow-equal": "^1.0.0", + "js-cookie": "^2.2.1", + "nano-css": "^5.6.2", + "react-universal-interface": "^0.6.2", + "resize-observer-polyfill": "^1.5.1", + "screenfull": "^5.1.0", + "set-harmonic-interval": "^1.0.1", + "throttle-debounce": "^3.0.1", + "ts-easing": "^0.2.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/react-virtuoso": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.13.0.tgz", + "integrity": "sha512-XHv2Fglpx80yFPdjZkV9d1baACKghg/ucpDFEXwaix7z0AfVQj+mF6lM+YQR6UC/TwzXG2rJKydRMb3+7iV3PA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.3.tgz", + "integrity": "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/refractor": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-4.8.1.tgz", + "integrity": "sha512-/fk5sI0iTgFYlmVGYVew90AoYnNMP6pooClx/XKqyeeCQXrL0Kvgn8V0VEht5ccdljbzzF1i3Q213gcntkRExg==", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^7.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/refractor/node_modules/@types/unist": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", + "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" + }, + "node_modules/refractor/node_modules/hast-util-parse-selector": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz", + "integrity": "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==", + "dependencies": { + "@types/hast": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/refractor/node_modules/hastscript": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-7.2.0.tgz", + "integrity": "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==", + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^3.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "node_modules/rehype-parse": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.0.tgz", + "integrity": "sha512-WG7nfvmWWkCR++KEkZevZb/uw41E8TsH4DsY9UxsTbIXCVGbAs4S+r8FrQ+OtH5EEQAs+5UxKC42VinkmpA1Yw==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-prism-plus": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rehype-prism-plus/-/rehype-prism-plus-2.0.1.tgz", + "integrity": "sha512-Wglct0OW12tksTUseAPyWPo3srjBOY7xKlql/DPKi7HbsdZTyaLCAoO58QBKSczFQxElTsQlOY3JDOFzB/K++Q==", + "license": "MIT", + "dependencies": { + "hast-util-to-string": "^3.0.0", + "parse-numeric-range": "^1.3.0", + "refractor": "^4.8.0", + "rehype-parse": "^9.0.0", + "unist-util-filter": "^5.0.0", + "unist-util-visit": "^5.0.0" + } + }, + "node_modules/relay-runtime": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-12.0.0.tgz", + "integrity": "sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "fbjs": "^3.0.0", + "invariant": "^2.2.4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", + "integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.44.0", + "@rollup/rollup-android-arm64": "4.44.0", + "@rollup/rollup-darwin-arm64": "4.44.0", + "@rollup/rollup-darwin-x64": "4.44.0", + "@rollup/rollup-freebsd-arm64": "4.44.0", + "@rollup/rollup-freebsd-x64": "4.44.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.0", + "@rollup/rollup-linux-arm-musleabihf": "4.44.0", + "@rollup/rollup-linux-arm64-gnu": "4.44.0", + "@rollup/rollup-linux-arm64-musl": "4.44.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.0", + "@rollup/rollup-linux-riscv64-gnu": "4.44.0", + "@rollup/rollup-linux-riscv64-musl": "4.44.0", + "@rollup/rollup-linux-s390x-gnu": "4.44.0", + "@rollup/rollup-linux-x64-gnu": "4.44.0", + "@rollup/rollup-linux-x64-musl": "4.44.0", + "@rollup/rollup-win32-arm64-msvc": "4.44.0", + "@rollup/rollup-win32-ia32-msvc": "4.44.0", + "@rollup/rollup-win32-x64-msvc": "4.44.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rtl-css-js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz", + "integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rvfc-polyfill": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/rvfc-polyfill/-/rvfc-polyfill-1.0.7.tgz", + "integrity": "sha512-seBl7J1J3/k0LuzW2T9fG6JIOpni5AbU+/87LA+zTYKgTVhsfShmS8K/yOo1eeEjGJHnAdkVAUUM+PEjN9Mpkw==" + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sax": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", + "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==" + }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/screenfull": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz", + "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sentence-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", + "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/set-harmonic-interval": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz", + "integrity": "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==", + "engines": { + "node": ">=6.9" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz", + "integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.7.1" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.1", + "@img/sharp-darwin-x64": "0.34.1", + "@img/sharp-libvips-darwin-arm64": "1.1.0", + "@img/sharp-libvips-darwin-x64": "1.1.0", + "@img/sharp-libvips-linux-arm": "1.1.0", + "@img/sharp-libvips-linux-arm64": "1.1.0", + "@img/sharp-libvips-linux-ppc64": "1.1.0", + "@img/sharp-libvips-linux-s390x": "1.1.0", + "@img/sharp-libvips-linux-x64": "1.1.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", + "@img/sharp-libvips-linuxmusl-x64": "1.1.0", + "@img/sharp-linux-arm": "0.34.1", + "@img/sharp-linux-arm64": "0.34.1", + "@img/sharp-linux-s390x": "0.34.1", + "@img/sharp-linux-x64": "0.34.1", + "@img/sharp-linuxmusl-arm64": "0.34.1", + "@img/sharp-linuxmusl-x64": "0.34.1", + "@img/sharp-wasm32": "0.34.1", + "@img/sharp-win32-ia32": "0.34.1", + "@img/sharp-win32-x64": "0.34.1" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/signedsource": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/signedsource/-/signedsource-1.0.0.tgz", + "integrity": "sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT", + "optional": true + }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/sonner": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.5.tgz", + "integrity": "sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==" + }, + "node_modules/sponge-case": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-1.0.1.tgz", + "integrity": "sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/stack-generator": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", + "integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "license": "MIT" + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" + }, + "node_modules/stacktrace-gps": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz", + "integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==", + "dependencies": { + "source-map": "0.5.6", + "stackframe": "^1.3.4" + } + }, + "node_modules/stacktrace-gps/node_modules/source-map": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stacktrace-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", + "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", + "dependencies": { + "error-stack-parser": "^2.0.6", + "stack-generator": "^2.0.5", + "stacktrace-gps": "^3.0.4" + } + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/strftime": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/strftime/-/strftime-0.10.3.tgz", + "integrity": "sha512-DZrDUeIF73eKJ4/GgGuv8UHWcUQPYDYfDeQFj3jrx+JZl6GQE656MbHIpvbo4mEG9a5DgS8GRCc5DxJXD2udDQ==", + "license": "MIT", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==" + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swap-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-2.0.2.tgz", + "integrity": "sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-scrollbar-hide": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/tailwind-scrollbar-hide/-/tailwind-scrollbar-hide-1.1.7.tgz", + "integrity": "sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA==" + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tailwindcss/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/throttle-debounce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz", + "integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==", + "engines": { + "node": ">=10" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/title-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", + "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-easing": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz", + "integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==" + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/tsx": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ua-parser-js": { + "version": "1.0.40", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", + "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-filter": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/unist-util-filter/-/unist-util-filter-5.0.1.tgz", + "integrity": "sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universal-cookie": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-8.0.1.tgz", + "integrity": "sha512-B6ks9FLLnP1UbPPcveOidfvB9pHjP+wekP2uRYB9YDfKVpvcjKgy1W5Zj+cEXJ9KTPnqOKGfVDQBmn8/YCQfRg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", + "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/upper-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", + "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/upper-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", + "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-debounce": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.5.tgz", + "integrity": "sha512-Q76E3lnIV+4YT9AHcrHEHYmAd9LKwUAbPXDm7FlqVGDHiSOhX3RDjT8dm0AxbJup6WgOb1YEcKyCr11kBJR5KQ==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/vfile": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.2.tgz", + "integrity": "sha512-zND7NlS8rJYb/sPqkb13ZvbbUoExdbi4w3SfRrMq6R3FvnLQmmfpajJNITuuYm6AZ5uao9vy4BAos3EXBPf2rg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.1", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.1.tgz", + "integrity": "sha512-+pZIP+U3pEJdDCeFmsXwHzV7vNHQC/eIbHklfe2ZCZqayYRH7lQbHcVgsJ0XOOv27hWs4jH4MONgXxHMObTMSA==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz", + "integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "is-plain-object": "^5.0.0", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.67", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", + "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/seanime-2.9.10/seanime-web/package.json b/seanime-2.9.10/seanime-web/package.json new file mode 100644 index 0000000..9b90184 --- /dev/null +++ b/seanime-2.9.10/seanime-web/package.json @@ -0,0 +1,167 @@ +{ + "name": "seanime-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "env-cmd -f .env.web next dev --hostname=0.0.0.0 --port=43210 --turbo", + "dev:mobile": "env-cmd -f .env.mobile next dev --hostname=0.0.0.0 --port=43210 --turbo", + "dev:desktop": "env-cmd -f .env.desktop next dev --hostname=0.0.0.0 --port=43210 --turbo", + "dev:denshi": "env-cmd -f .env.denshi next dev --hostname=0.0.0.0 --port=43210 --turbo", + "build": "next build", + "build:desktop": "env-cmd -f .env.desktop next build", + "build:denshi": "env-cmd -f .env.denshi next build", + "build:development:desktop": "env-cmd -f .env.development.desktop next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@codemirror/autocomplete": "^6.18.6", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/language": "^6.11.1", + "@codemirror/legacy-modes": "^6.5.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@headlessui/react": "1.7.18", + "@headlessui/tailwindcss": "0.2.1", + "@hookform/resolvers": "^5.1.1", + "@next/bundle-analyzer": "^15.3.4", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-context-menu": "^2.2.15", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-hover-card": "^1.1.14", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.7", + "@replit/codemirror-vscode-keymap": "^6.0.2", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-query": "^5.81.5", + "@tanstack/react-query-devtools": "^5.81.5", + "@tanstack/react-table": "^8.21.3", + "@tauri-apps/api": "^2.4.1", + "@tauri-apps/plugin-clipboard-manager": "^2.2.2", + "@tauri-apps/plugin-os": "^2.2.1", + "@tauri-apps/plugin-process": "^2.2.1", + "@tauri-apps/plugin-shell": "^2.2.1", + "@tauri-apps/plugin-updater": "^2.7.0", + "@total-typescript/ts-reset": "^0.6.1", + "@uidotdev/usehooks": "^2.4.1", + "@uiw/codemirror-theme-vscode": "^4.23.14", + "@uiw/react-codemirror": "^4.23.14", + "@vidstack/react": "^1.10.9", + "@zag-js/number-input": "^1.15.7", + "@zag-js/react": "^1.15.7", + "anime4k-webgpu": "^1.0.0", + "artplayer": "^5.2.3", + "autoprefixer": "^10", + "axios": "^1.11.0", + "babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250405", + "bottleneck": "^2.19.5", + "bytes-iec": "^3.1.1", + "chalk": "^5.4.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "collect.js": "^4.36.1", + "colord": "^2.9.3", + "concurrently": "^8.2.2", + "copy-to-clipboard": "^3.3.3", + "crypto-js": "^4.2.0", + "date-fns": "^3.6.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "embla-carousel-auto-scroll": "^8.6.0", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", + "env-cmd": "^10.1.0", + "execa": "^8.0.1", + "fast-deep-equal": "^3.1.3", + "fs-extra": "^11.3.0", + "hls.js": "1.5.20", + "inflection": "^3.0.2", + "install": "^0.13.0", + "jassub": "^1.8.6", + "jotai": "^2.13.0", + "jotai-derive": "^0.1.3", + "jotai-immer": "^0.4.1", + "jotai-optics": "^0.4.0", + "jotai-scope": "^0.9.3", + "js-cookies": "^1.0.4", + "js-levenshtein": "^1.1.6", + "js-sha256": "^0.11.1", + "json2toml": "^6.1.1", + "libphonenumber-js": "^1.12.9", + "media-icons": "^0.10.0", + "memory-cache": "^0.2.0", + "motion": "^12.23.12", + "mousetrap": "^1.6.5", + "needle": "^3.3.1", + "next": "15.3.0", + "next-themes": "^0.4.6", + "normalize-path": "^3.0.0", + "ora": "^8.2.0", + "path-browserify": "^1.0.1", + "postcss": "^8", + "react": "^18", + "react-colorful": "^5.6.1", + "react-cookie": "^8.0.1", + "react-currency-input-field": "^3.10.0", + "react-day-picker": "^8.10.1", + "react-dom": "^18", + "react-draggable": "^4.4.6", + "react-dropzone": "^14.3.8", + "react-error-boundary": "^5.0.0", + "react-hook-form": "^7.62.0", + "react-icons": "^5.5.0", + "react-phone-number-input": "^3.4.12", + "react-remove-scroll-bar": "^2.3.8", + "react-resizable-panels": "^2.1.7", + "react-use": "^17.6.0", + "react-virtuoso": "^4.13.0", + "recharts": "^2.15.3", + "rehype-prism-plus": "^2.0.1", + "sonner": "2.0.5", + "tailwind-merge": "~2.6.0", + "tailwind-scrollbar-hide": "~1.1.7", + "tailwindcss-animate": "~1.0.7", + "tsx": "^4.20.3", + "typescript": "^5", + "upath": "^2.0.1", + "use-debounce": "^10.0.5", + "vaul": "^1.1.2", + "vitest": "^3.2.4", + "zod": "^3.25.67" + }, + "devDependencies": { + "@graphql-codegen/client-preset": "4.8.2", + "@types/crypto-js": "^4.2.2", + "@types/fs-extra": "^11.0.4", + "@types/google.maps": "^3.58.1", + "@types/js-levenshtein": "^1.1.3", + "@types/lodash": "^4.17.18", + "@types/memory-cache": "^0.2.6", + "@types/mousetrap": "^1.6.15", + "@types/needle": "^3.3.0", + "@types/node": "^22.15.32", + "@types/path-browserify": "^1.0.3", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@types/react-window": "^1.8.8", + "autoprefixer": "^10", + "encoding": "^0.1.13", + "postcss": "^8", + "tailwindcss": "^3.4.17", + "typescript": "^5" + } +} diff --git a/seanime-2.9.10/seanime-web/postcss.config.cjs b/seanime-2.9.10/seanime-web/postcss.config.cjs new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/seanime-2.9.10/seanime-web/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/seanime-2.9.10/seanime-web/public/icons/android-chrome-192x192.png b/seanime-2.9.10/seanime-web/public/icons/android-chrome-192x192.png new file mode 100644 index 0000000..8daf63c Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/icons/android-chrome-192x192.png differ diff --git a/seanime-2.9.10/seanime-web/public/icons/android-chrome-512x512.png b/seanime-2.9.10/seanime-web/public/icons/android-chrome-512x512.png new file mode 100644 index 0000000..70ae2e2 Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/icons/android-chrome-512x512.png differ diff --git a/seanime-2.9.10/seanime-web/public/icons/apple-icon.png b/seanime-2.9.10/seanime-web/public/icons/apple-icon.png new file mode 100644 index 0000000..a87083d Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/icons/apple-icon.png differ diff --git a/seanime-2.9.10/seanime-web/public/icons/favicon-16x16.png b/seanime-2.9.10/seanime-web/public/icons/favicon-16x16.png new file mode 100644 index 0000000..6203701 Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/icons/favicon-16x16.png differ diff --git a/seanime-2.9.10/seanime-web/public/icons/favicon-32x32.png b/seanime-2.9.10/seanime-web/public/icons/favicon-32x32.png new file mode 100644 index 0000000..e6224b9 Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/icons/favicon-32x32.png differ diff --git a/seanime-2.9.10/seanime-web/public/icons/favicon.ico b/seanime-2.9.10/seanime-web/public/icons/favicon.ico new file mode 100644 index 0000000..006f721 Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/icons/favicon.ico differ diff --git a/seanime-2.9.10/seanime-web/public/jassub/Roboto-Medium.ttf b/seanime-2.9.10/seanime-web/public/jassub/Roboto-Medium.ttf new file mode 100644 index 0000000..a3c8563 Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/jassub/Roboto-Medium.ttf differ diff --git a/seanime-2.9.10/seanime-web/public/jassub/default.woff2 b/seanime-2.9.10/seanime-web/public/jassub/default.woff2 new file mode 100644 index 0000000..a562391 Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/jassub/default.woff2 differ diff --git a/seanime-2.9.10/seanime-web/public/jassub/jassub-worker-modern.wasm b/seanime-2.9.10/seanime-web/public/jassub/jassub-worker-modern.wasm new file mode 100644 index 0000000..2e8a0be Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/jassub/jassub-worker-modern.wasm differ diff --git a/seanime-2.9.10/seanime-web/public/jassub/jassub-worker.js b/seanime-2.9.10/seanime-web/public/jassub/jassub-worker.js new file mode 100644 index 0000000..1e510aa --- /dev/null +++ b/seanime-2.9.10/seanime-web/public/jassub/jassub-worker.js @@ -0,0 +1,15 @@ +"use strict";var Module=function(f={}){var f=f,h;f.ready=new Promise(function(t,r){h=t});var C=function(t){return console.log(t)},y=function(t){return console.error(t)};function _(){h(f)}function A(t,r){if(!t)throw r}var w=null;C=function(t){t==="JASSUB: No usable fontconfig configuration file found, using fallback."?console.debug(t):console.log(t)},y=function(t){t==="Fontconfig error: Cannot load default config file: No such file: (null)"?console.debug(t):console.error(t)},Y=function(t){return function(){t(),self.wasmMemory=B,self.HEAPU8C=new Uint8ClampedArray(B.buffer),self.HEAPU8=new Uint8Array(B.buffer)}}(Y);function F(t){throw t}var j,E,M,O,J,k,fe,le,B,ue;function Y(){var t=B.buffer;j=new Int8Array(t),E=new Int16Array(t),M=new Int32Array(t),O=new Uint8Array(t),J=new Uint16Array(t),k=new Uint32Array(t),fe=new Float32Array(t),le=new Float64Array(t)}if(B=new WebAssembly.Memory({initial:256,maximum:32768}),Y(),(!Math.imul||Math.imul(4294967295,5)!==-5)&&(Math.imul=function(t,r){var n=t>>>16,i=t&65535,o=r>>>16,u=r&65535;return i*u+(n*u+i*o<<16)|0}),!Math.fround){var ce=new Float32Array(1);Math.fround=function(t){return ce[0]=t,ce[0]}}Math.clz32||(Math.clz32=function(t){var r=32,n=t>>16;return n&&(r-=16,t=n),n=t>>8,n&&(r-=8,t=n),n=t>>4,n&&(r-=4,t=n),n=t>>2,n&&(r-=2,t=n),n=t>>1,n?r-2:r-t}),Math.trunc||(Math.trunc=function(t){return t<0?Math.ceil(t):Math.floor(t)});var de=typeof TextDecoder<"u"?new TextDecoder("utf8"):void 0;function pe(t,r,n){for(var i=r+n,o=r;t[o]&&!(o>=i);)++o;if(o-r>16&&t.buffer&&de)return de.decode(t.subarray(r,o));for(var u="";r<o;){var p=t[r++];if(!(p&128)){u+=String.fromCharCode(p);continue}var l=t[r++]&63;if((p&224)==192){u+=String.fromCharCode((p&31)<<6|l);continue}var v=t[r++]&63;if((p&240)==224?p=(p&15)<<12|l<<6|v:p=(p&7)<<18|l<<12|v<<6|t[r++]&63,p<65536)u+=String.fromCharCode(p);else{var g=p-65536;u+=String.fromCharCode(55296|g>>10,56320|g&1023)}}return u}function X(t,r){return t?pe(O,t,r):""}function Te(t,r,n,i){F("Assertion failed: "+X(t)+", at: "+[r?X(r):"unknown filename",n,i?X(i):"unknown function"])}function Pe(t,r,n){return 0}function Ae(t,r){}function Fe(t,r,n,i){if(!(i>0))return 0;for(var o=n,u=n+i-1,p=0;p<t.length;++p){var l=t.charCodeAt(p);if(l>=55296&&l<=57343){var v=t.charCodeAt(++p);l=65536+((l&1023)<<10)|v&1023}if(l<=127){if(n>=u)break;r[n++]=l}else if(l<=2047){if(n+1>=u)break;r[n++]=192|l>>6,r[n++]=128|l&63}else if(l<=65535){if(n+2>=u)break;r[n++]=224|l>>12,r[n++]=128|l>>6&63,r[n++]=128|l&63}else{if(n+3>=u)break;r[n++]=240|l>>18,r[n++]=128|l>>12&63,r[n++]=128|l>>6&63,r[n++]=128|l&63}}return r[n]=0,n-o}function at(t,r,n){return Fe(t,O,r,n)}function st(t,r,n){}function ot(t,r,n){return 0}function ft(t,r,n,i){}function lt(t,r,n,i){}function ut(t,r){}function ct(t,r,n,i,o){}function $e(t){switch(t){case 1:return 0;case 2:return 1;case 4:return 2;case 8:return 3;default:throw new TypeError("Unknown type size: "+t)}}function dt(){for(var t=new Array(256),r=0;r<256;++r)t[r]=String.fromCharCode(r);Le=t}var Le=void 0;function D(t){for(var r="",n=t;O[n];)r+=Le[O[n++]];return r}var Q={},Z={},ge={},pt=48,gt=57;function Se(t){if(t===void 0)return"_unknown";t=t.replace(/[^a-zA-Z0-9_]/g,"$");var r=t.charCodeAt(0);return r>=pt&&r<=gt?"_"+t:t}function je(t,r){t=Se(t);var n={};return(n[t]=function(){return r.apply(this,arguments)},n)[t]}function Oe(t,r){var n=je(r,function(i){this.name=r,this.message=i;var o=new Error(i).stack;o!==void 0&&(this.stack=this.toString()+` +`+o.replace(/^Error(:[^\n]*)?\n/,""))});return n.prototype=Object.create(t.prototype),n.prototype.constructor=n,n.prototype.toString=function(){return this.message===void 0?this.name:this.name+": "+this.message},n}var N=void 0;function T(t){throw new N(t)}var He=void 0;function he(t){throw new He(t)}function K(t,r,n){t.forEach(function(l){ge[l]=r});function i(l){var v=n(l);v.length!==t.length&&he("Mismatched type converter count");for(var g=0;g<t.length;++g)x(t[g],v[g])}var o=new Array(r.length),u=[],p=0;r.forEach(function(l,v){Z.hasOwnProperty(l)?o[v]=Z[l]:(u.push(l),Q.hasOwnProperty(l)||(Q[l]=[]),Q[l].push(function(){o[v]=Z[l],++p,p===u.length&&i(o)}))}),u.length===0&&i(o)}function x(t,r,n){if(n=n===void 0?{}:n,!("argPackAdvance"in r))throw new TypeError("registerType registeredInstance requires argPackAdvance");var i=r.name;if(t||T('type "'+i+'" must have a positive integer typeid pointer'),Z.hasOwnProperty(t)){if(n.ignoreDuplicateRegistrations)return;T("Cannot register type '"+i+"' twice")}if(Z[t]=r,delete ge[t],Q.hasOwnProperty(t)){var o=Q[t];delete Q[t],o.forEach(function(u){return u()})}}function ht(t,r,n,i,o){var u=$e(n);r=D(r),x(t,{name:r,fromWireType:function(p){return!!p},toWireType:function(p,l){return l?i:o},argPackAdvance:8,readValueFromPointer:function(p){var l;if(n===1)l=j;else if(n===2)l=E;else if(n===4)l=M;else throw new TypeError("Unknown boolean type size: "+r);return this.fromWireType(l[p>>u])},destructorFunction:null})}function vt(t){if(!(this instanceof G)||!(t instanceof G))return!1;for(var r=this.$$.ptrType.registeredClass,n=this.$$.ptr,i=t.$$.ptrType.registeredClass,o=t.$$.ptr;r.baseClass;)n=r.upcast(n),r=r.baseClass;for(;i.baseClass;)o=i.upcast(o),i=i.baseClass;return r===i&&n===o}function yt(t){return{count:t.count,deleteScheduled:t.deleteScheduled,preservePointerOnDelete:t.preservePointerOnDelete,ptr:t.ptr,ptrType:t.ptrType,smartPtr:t.smartPtr,smartPtrType:t.smartPtrType}}function Ee(t){function r(n){return n.$$.ptrType.registeredClass.name}T(r(t)+" instance already deleted")}var Me=!1;function Be(t){}function bt(t){t.smartPtr?t.smartPtrType.rawDestructor(t.smartPtr):t.ptrType.registeredClass.rawDestructor(t.ptr)}function xe(t){t.count.value-=1;var r=t.count.value===0;r&&bt(t)}function Ve(t,r,n){if(r===n)return t;if(n.baseClass===void 0)return null;var i=Ve(t,r,n.baseClass);return i===null?null:n.downcast(i)}var ze={};function mt(){return Object.keys(re).length}function wt(){var t=[];for(var r in re)re.hasOwnProperty(r)&&t.push(re[r]);return t}var ee=[];function ke(){for(;ee.length;){var t=ee.pop();t.$$.deleteScheduled=!1,t.delete()}}var te=void 0;function Ct(t){te=t,ee.length&&te&&te(ke)}function _t(){f.getInheritedInstanceCount=mt,f.getLiveInheritedInstances=wt,f.flushPendingDeletes=ke,f.setDelayFunction=Ct}var re={};function Tt(t,r){for(r===void 0&&T("ptr should not be undefined");t.baseClass;)r=t.upcast(r),t=t.baseClass;return r}function Pt(t,r){return r=Tt(t,r),re[r]}function ve(t,r){(!r.ptrType||!r.ptr)&&he("makeClassHandle requires ptr and ptrType");var n=!!r.smartPtrType,i=!!r.smartPtr;return n!==i&&he("Both smartPtrType and smartPtr must be specified"),r.count={value:1},ne(Object.create(t,{$$:{value:r}}))}function At(t){var r=this.getPointee(t);if(!r)return this.destructor(t),null;var n=Pt(this.registeredClass,r);if(n!==void 0){if(n.$$.count.value===0)return n.$$.ptr=r,n.$$.smartPtr=t,n.clone();var i=n.clone();return this.destructor(t),i}function o(){return this.isSmartPointer?ve(this.registeredClass.instancePrototype,{ptrType:this.pointeeType,ptr:r,smartPtrType:this,smartPtr:t}):ve(this.registeredClass.instancePrototype,{ptrType:this,ptr:t})}var u=this.registeredClass.getActualType(r),p=ze[u];if(!p)return o.call(this);var l;this.isConst?l=p.constPointerType:l=p.pointerType;var v=Ve(r,this.registeredClass,l.registeredClass);return v===null?o.call(this):this.isSmartPointer?ve(l.registeredClass.instancePrototype,{ptrType:l,ptr:v,smartPtrType:this,smartPtr:t}):ve(l.registeredClass.instancePrototype,{ptrType:l,ptr:v})}function ne(t){return typeof FinalizationRegistry>"u"?(ne=function(r){return r},t):(Me=new FinalizationRegistry(function(r){xe(r.$$)}),ne=function(r){var n=r.$$,i=!!n.smartPtr;if(i){var o={$$:n};Me.register(r,o,r)}return r},Be=function(r){return Me.unregister(r)},ne(t))}function Ft(){if(this.$$.ptr||Ee(this),this.$$.preservePointerOnDelete)return this.$$.count.value+=1,this;var t=ne(Object.create(Object.getPrototypeOf(this),{$$:{value:yt(this.$$)}}));return t.$$.count.value+=1,t.$$.deleteScheduled=!1,t}function $t(){this.$$.ptr||Ee(this),this.$$.deleteScheduled&&!this.$$.preservePointerOnDelete&&T("Object already scheduled for deletion"),Be(this),xe(this.$$),this.$$.preservePointerOnDelete||(this.$$.smartPtr=void 0,this.$$.ptr=void 0)}function St(){return!this.$$.ptr}function jt(){return this.$$.ptr||Ee(this),this.$$.deleteScheduled&&!this.$$.preservePointerOnDelete&&T("Object already scheduled for deletion"),ee.push(this),ee.length===1&&te&&te(ke),this.$$.deleteScheduled=!0,this}function Ot(){G.prototype.isAliasOf=vt,G.prototype.clone=Ft,G.prototype.delete=$t,G.prototype.isDeleted=St,G.prototype.deleteLater=jt}function G(){}function Je(t,r,n){if(t[r].overloadTable===void 0){var i=t[r];t[r]=function(){return t[r].overloadTable.hasOwnProperty(arguments.length)||T("Function '"+n+"' called with an invalid number of arguments ("+arguments.length+") - expects one of ("+t[r].overloadTable+")!"),t[r].overloadTable[arguments.length].apply(this,arguments)},t[r].overloadTable=[],t[r].overloadTable[i.argCount]=i}}function Et(t,r,n){f.hasOwnProperty(t)?((n===void 0||f[t].overloadTable!==void 0&&f[t].overloadTable[n]!==void 0)&&T("Cannot register public name '"+t+"' twice"),Je(f,t,t),f.hasOwnProperty(n)&&T("Cannot register multiple overloads of a function with the same number of arguments ("+n+")!"),f[t].overloadTable[n]=r):(f[t]=r,n!==void 0&&(f[t].numArguments=n))}function Mt(t,r,n,i,o,u,p,l){this.name=t,this.constructor=r,this.instancePrototype=n,this.rawDestructor=i,this.baseClass=o,this.getActualType=u,this.upcast=p,this.downcast=l,this.pureVirtualFunctions=[]}function ye(t,r,n){for(;r!==n;)r.upcast||T("Expected null or instance of "+n.name+", got an instance of "+r.name),t=r.upcast(t),r=r.baseClass;return t}function kt(t,r){if(r===null)return this.isReference&&T("null is not a valid "+this.name),0;r.$$||T('Cannot pass "'+Re(r)+'" as a '+this.name),r.$$.ptr||T("Cannot pass deleted object as a pointer of type "+this.name);var n=r.$$.ptrType.registeredClass,i=ye(r.$$.ptr,n,this.registeredClass);return i}function Ut(t,r){var n;if(r===null)return this.isReference&&T("null is not a valid "+this.name),this.isSmartPointer?(n=this.rawConstructor(),t!==null&&t.push(this.rawDestructor,n),n):0;r.$$||T('Cannot pass "'+Re(r)+'" as a '+this.name),r.$$.ptr||T("Cannot pass deleted object as a pointer of type "+this.name),!this.isConst&&r.$$.ptrType.isConst&&T("Cannot convert argument of type "+(r.$$.smartPtrType?r.$$.smartPtrType.name:r.$$.ptrType.name)+" to parameter type "+this.name);var i=r.$$.ptrType.registeredClass;if(n=ye(r.$$.ptr,i,this.registeredClass),this.isSmartPointer)switch(r.$$.smartPtr===void 0&&T("Passing raw pointer to smart pointer is illegal"),this.sharingPolicy){case 0:r.$$.smartPtrType===this?n=r.$$.smartPtr:T("Cannot convert argument of type "+(r.$$.smartPtrType?r.$$.smartPtrType.name:r.$$.ptrType.name)+" to parameter type "+this.name);break;case 1:n=r.$$.smartPtr;break;case 2:if(r.$$.smartPtrType===this)n=r.$$.smartPtr;else{var o=r.clone();n=this.rawShare(n,Ue.toHandle(function(){o.delete()})),t!==null&&t.push(this.rawDestructor,n)}break;default:T("Unsupporting sharing policy")}return n}function Rt(t,r){if(r===null)return this.isReference&&T("null is not a valid "+this.name),0;r.$$||T('Cannot pass "'+Re(r)+'" as a '+this.name),r.$$.ptr||T("Cannot pass deleted object as a pointer of type "+this.name),r.$$.ptrType.isConst&&T("Cannot convert argument of type "+r.$$.ptrType.name+" to parameter type "+this.name);var n=r.$$.ptrType.registeredClass,i=ye(r.$$.ptr,n,this.registeredClass);return i}function be(t){return this.fromWireType(M[t>>2])}function Wt(t){return this.rawGetPointee&&(t=this.rawGetPointee(t)),t}function It(t){this.rawDestructor&&this.rawDestructor(t)}function Dt(t){t!==null&&t.delete()}function Lt(){V.prototype.getPointee=Wt,V.prototype.destructor=It,V.prototype.argPackAdvance=8,V.prototype.readValueFromPointer=be,V.prototype.deleteObject=Dt,V.prototype.fromWireType=At}function V(t,r,n,i,o,u,p,l,v,g,m){this.name=t,this.registeredClass=r,this.isReference=n,this.isConst=i,this.isSmartPointer=o,this.pointeeType=u,this.sharingPolicy=p,this.rawGetPointee=l,this.rawConstructor=v,this.rawShare=g,this.rawDestructor=m,!o&&r.baseClass===void 0?i?(this.toWireType=kt,this.destructorFunction=null):(this.toWireType=Rt,this.destructorFunction=null):this.toWireType=Ut}function Ht(t,r,n){f.hasOwnProperty(t)||he("Replacing nonexistant public symbol"),f[t].overloadTable!==void 0&&n!==void 0?f[t].overloadTable[n]=r:(f[t]=r,f[t].argCount=n)}function Bt(t,r,n){var i=dynCalls[t];return n&&n.length?i.apply(null,[r].concat(n)):i.call(null,r)}var me=[];function ie(t){var r=me[t];return r||(t>=me.length&&(me.length=t+1),me[t]=r=ue.get(t)),r}function xt(t,r,n){if(t.includes("j"))return Bt(t,r,n);var i=ie(r).apply(null,n);return i}function Vt(t,r){var n=[];return function(){return n.length=0,Object.assign(n,arguments),xt(t,r,n)}}function q(t,r){t=D(t);function n(){return t.includes("j")?Vt(t,r):ie(r)}var i=n();return typeof i!="function"&&T("unknown function pointer with signature "+t+": "+r),i}var Ge=void 0;function zt(t){var r=Ne(t),n=D(r);return z(r),n}function ae(t,r){var n=[],i={};function o(u){if(!i[u]&&!Z[u]){if(ge[u]){ge[u].forEach(o);return}n.push(u),i[u]=!0}}throw r.forEach(o),new Ge(t+": "+n.map(zt).join([", "]))}function Jt(t,r,n,i,o,u,p,l,v,g,m,P,$){m=D(m),u=q(o,u),l&&(l=q(p,l)),g&&(g=q(v,g)),$=q(P,$);var S=Se(m);Et(S,function(){ae("Cannot construct "+m+" due to unbound types",[i])}),K([t,r,n],i?[i]:[],function(U){U=U[0];var W,R;i?(W=U.registeredClass,R=W.instancePrototype):R=G.prototype;var L=je(S,function(){if(Object.getPrototypeOf(this)!==oe)throw new N("Use 'new' to construct "+m);if(I.constructor_body===void 0)throw new N(m+" has no accessible constructor");var it=I.constructor_body[arguments.length];if(it===void 0)throw new N("Tried to invoke ctor of "+m+" with invalid number of parameters ("+arguments.length+") - expected ("+Object.keys(I.constructor_body).toString()+") parameters instead!");return it.apply(this,arguments)}),oe=Object.create(R,{constructor:{value:L}});L.prototype=oe;var I=new Mt(m,L,oe,$,W,u,l,g);I.baseClass&&(I.baseClass.__derivedClasses===void 0&&(I.baseClass.__derivedClasses=[]),I.baseClass.__derivedClasses.push(I));var Hr=new V(m,I,!0,!1,!1),rt=new V(m+"*",I,!1,!1,!1),nt=new V(m+" const*",I,!1,!0,!1);return ze[t]={pointerType:rt,constPointerType:nt},Ht(S,L),[Hr,rt,nt]})}function qe(t,r){for(var n=[],i=0;i<t;i++)n.push(k[r+i*4>>2]);return n}function Ke(t){for(;t.length;){var r=t.pop(),n=t.pop();n(r)}}function Gt(t,r){if(!(t instanceof Function))throw new TypeError("new_ called with constructor type "+typeof t+" which is not a function");var n=je(t.name||"unknownFunctionName",function(){});n.prototype=t.prototype;var i=new n,o=t.apply(i,r);return o instanceof Object?o:i}function Ye(t,r,n,i,o,u){var p=r.length;p<2&&T("argTypes array size mismatch! Must at least get return value and 'this' types!");for(var l=r[1]!==null&&n!==null,v=!1,g=1;g<r.length;++g)if(r[g]!==null&&r[g].destructorFunction===void 0){v=!0;break}for(var m=r[0].name!=="void",P="",$="",g=0;g<p-2;++g)P+=(g!==0?", ":"")+"arg"+g,$+=(g!==0?", ":"")+"arg"+g+"Wired";var S=` + return function `+Se(t)+"("+P+`) { + if (arguments.length !== `+(p-2)+`) { + throwBindingError('function `+t+" called with "+arguments.length+" arguments, expected "+(p-2)+` args!'); + }`;v&&(S+=`var destructors = []; +`);var U=v?"destructors":"null",W=["throwBindingError","invoker","fn","runDestructors","retType","classParam"],R=[T,i,o,Ke,r[0],r[1]];l&&(S+="var thisWired = classParam.toWireType("+U+`, this); +`);for(var g=0;g<p-2;++g)S+="var arg"+g+"Wired = argType"+g+".toWireType("+U+", arg"+g+"); // "+r[g+2].name+` +`,W.push("argType"+g),R.push(r[g+2]);if(l&&($="thisWired"+($.length>0?", ":"")+$),S+=(m||u?"var rv = ":"")+"invoker(fn"+($.length>0?", ":"")+$+`); +`,v)S+=`runDestructors(destructors); +`;else for(var g=l?1:2;g<r.length;++g){var L=g===1?"thisWired":"arg"+(g-2)+"Wired";r[g].destructorFunction!==null&&(S+=L+"_dtor("+L+"); // "+r[g].name+` +`,W.push(L+"_dtor"),R.push(r[g].destructorFunction))}return m&&(S+=`var ret = retType.fromWireType(rv); +return ret; +`),S+=`} +`,W.push(S),Gt(Function,W).apply(null,R)}function qt(t,r,n,i,o,u){A(r>0);var p=qe(r,n);o=q(i,o),K([],[t],function(l){l=l[0];var v="constructor "+l.name;if(l.registeredClass.constructor_body===void 0&&(l.registeredClass.constructor_body=[]),l.registeredClass.constructor_body[r-1]!==void 0)throw new N("Cannot register multiple constructors with identical number of parameters ("+(r-1)+") for class '"+l.name+"'! Overload resolution is currently only performed using the parameter count, not actual type info!");return l.registeredClass.constructor_body[r-1]=function(){ae("Cannot construct "+l.name+" due to unbound types",p)},K([],p,function(g){return g.splice(1,0,null),l.registeredClass.constructor_body[r-1]=Ye(v,g,null,o,u),[]}),[]})}function Kt(t,r,n,i,o,u,p,l,v){var g=qe(n,i);r=D(r),u=q(o,u),K([],[t],function(m){m=m[0];var P=m.name+"."+r;r.startsWith("@@")&&(r=Symbol[r.substring(2)]),l&&m.registeredClass.pureVirtualFunctions.push(r);function $(){ae("Cannot call "+P+" due to unbound types",g)}var S=m.registeredClass.instancePrototype,U=S[r];return U===void 0||U.overloadTable===void 0&&U.className!==m.name&&U.argCount===n-2?($.argCount=n-2,$.className=m.name,S[r]=$):(Je(S,r,P),S[r].overloadTable[n-2]=$),K([],g,function(W){var R=Ye(P,W,m,u,p,v);return S[r].overloadTable===void 0?(R.argCount=n-2,S[r]=R):S[r].overloadTable[n-2]=R,[]}),[]})}function Xe(t,r,n){return t instanceof Object||T(n+' with invalid "this": '+t),t instanceof r.registeredClass.constructor||T(n+' incompatible with "this" of type '+t.constructor.name),t.$$.ptr||T("cannot call emscripten binding method "+n+" on deleted object"),ye(t.$$.ptr,t.$$.ptrType.registeredClass,r.registeredClass)}function Yt(t,r,n,i,o,u,p,l,v,g){r=D(r),o=q(i,o),K([],[t],function(m){m=m[0];var P=m.name+"."+r,$={get:function(){ae("Cannot access "+P+" due to unbound types",[n,p])},enumerable:!0,configurable:!0};return v?$.set=function(){ae("Cannot access "+P+" due to unbound types",[n,p])}:$.set=function(S){T(P+" is a read-only property")},Object.defineProperty(m.registeredClass.instancePrototype,r,$),K([],v?[n,p]:[n],function(S){var U=S[0],W={get:function(){var L=Xe(this,m,P+" getter");return U.fromWireType(o(u,L))},enumerable:!0};if(v){v=q(l,v);var R=S[1];W.set=function(L){var oe=Xe(this,m,P+" setter"),I=[];v(g,oe,R.toWireType(I,L)),Ke(I)}}return Object.defineProperty(m.registeredClass.instancePrototype,r,W),[]}),[]})}function Xt(){this.allocated=[void 0],this.freelist=[],this.get=function(t){return this.allocated[t]},this.has=function(t){return this.allocated[t]!==void 0},this.allocate=function(t){var r=this.freelist.pop()||this.allocated.length;return this.allocated[r]=t,r},this.free=function(t){this.allocated[t]=void 0,this.freelist.push(t)}}var H=new Xt;function Qt(t){t>=H.reserved&&--H.get(t).refcount===0&&H.free(t)}function Zt(){for(var t=0,r=H.reserved;r<H.allocated.length;++r)H.allocated[r]!==void 0&&++t;return t}function Nt(){H.allocated.push({value:void 0},{value:null},{value:!0},{value:!1}),H.reserved=H.allocated.length,f.count_emval_handles=Zt}var Ue={toValue:function(t){return t||T("Cannot use deleted val. handle = "+t),H.get(t).value},toHandle:function(t){switch(t){case void 0:return 1;case null:return 2;case!0:return 3;case!1:return 4;default:return H.allocate({refcount:1,value:t})}}};function er(t,r){r=D(r),x(t,{name:r,fromWireType:function(n){var i=Ue.toValue(n);return Qt(n),i},toWireType:function(n,i){return Ue.toHandle(i)},argPackAdvance:8,readValueFromPointer:be,destructorFunction:null})}function Re(t){if(t===null)return"null";var r=typeof t;return r==="object"||r==="array"||r==="function"?t.toString():""+t}function tr(t,r){switch(r){case 2:return function(n){return this.fromWireType(fe[n>>2])};case 3:return function(n){return this.fromWireType(le[n>>3])};default:throw new TypeError("Unknown float type: "+t)}}function rr(t,r,n){var i=$e(n);r=D(r),x(t,{name:r,fromWireType:function(o){return o},toWireType:function(o,u){return u},argPackAdvance:8,readValueFromPointer:tr(r,i),destructorFunction:null})}function nr(t,r,n){switch(r){case 0:return n?function(o){return j[o]}:function(o){return O[o]};case 1:return n?function(o){return E[o>>1]}:function(o){return J[o>>1]};case 2:return n?function(o){return M[o>>2]}:function(o){return k[o>>2]};default:throw new TypeError("Unknown integer type: "+t)}}function ir(t,r,n,i,o){r=D(r);var u=$e(n),p=function(P){return P};if(i===0){var l=32-8*n;p=function(P){return P<<l>>>l}}var v=r.includes("unsigned"),g=function(P,$){},m;v?m=function(P,$){return g($,this.name),$>>>0}:m=function(P,$){return g($,this.name),$},x(t,{name:r,fromWireType:p,toWireType:m,argPackAdvance:8,readValueFromPointer:nr(r,u,i!==0),destructorFunction:null})}function ar(t,r,n){var i=[Int8Array,Uint8Array,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array],o=i[r];function u(p){p=p>>2;var l=k,v=l[p],g=l[p+1];return new o(l.buffer,g,v)}n=D(n),x(t,{name:n,fromWireType:u,argPackAdvance:8,readValueFromPointer:u},{ignoreDuplicateRegistrations:!0})}function sr(t){for(var r=0,n=0;n<t.length;++n){var i=t.charCodeAt(n);i<=127?r++:i<=2047?r+=2:i>=55296&&i<=57343?(r+=4,++n):r+=3}return r}function or(t,r){r=D(r);var n=r==="std::string";x(t,{name:r,fromWireType:function(i){var o=k[i>>2],u=i+4,p;if(n)for(var l=u,v=0;v<=o;++v){var g=u+v;if(v==o||O[g]==0){var m=g-l,P=X(l,m);p===void 0?p=P:(p+=String.fromCharCode(0),p+=P),l=g+1}}else{for(var $=new Array(o),v=0;v<o;++v)$[v]=String.fromCharCode(O[u+v]);p=$.join("")}return z(i),p},toWireType:function(i,o){o instanceof ArrayBuffer&&(o=new Uint8Array(o));var u,p=typeof o=="string";p||o instanceof Uint8Array||o instanceof Uint8ClampedArray||o instanceof Int8Array||T("Cannot pass non-string to std::string"),n&&p?u=sr(o):u=o.length;var l=De(4+u+1),v=l+4;if(k[l>>2]=u,n&&p)at(o,v,u+1);else if(p)for(var g=0;g<u;++g){var m=o.charCodeAt(g);m>255&&(z(v),T("String has UTF-16 code units that do not fit in 8 bits")),O[v+g]=m}else for(var g=0;g<u;++g)O[v+g]=o[g];return i!==null&&i.push(z,l),l},argPackAdvance:8,readValueFromPointer:be,destructorFunction:function(i){z(i)}})}var Qe=typeof TextDecoder<"u"?new TextDecoder("utf-16le"):void 0;function fr(t,r){for(var n=t,i=n>>1,o=i+r/2;!(i>=o)&&J[i];)++i;if(n=i<<1,n-t>32&&Qe)return Qe.decode(O.subarray(t,n));for(var u="",p=0;!(p>=r/2);++p){var l=E[t+p*2>>1];if(l==0)break;u+=String.fromCharCode(l)}return u}function lr(t,r,n){if(n===void 0&&(n=2147483647),n<2)return 0;n-=2;for(var i=r,o=n<t.length*2?n/2:t.length,u=0;u<o;++u){var p=t.charCodeAt(u);E[r>>1]=p,r+=2}return E[r>>1]=0,r-i}function ur(t){return t.length*2}function cr(t,r){for(var n=0,i="";!(n>=r/4);){var o=M[t+n*4>>2];if(o==0)break;if(++n,o>=65536){var u=o-65536;i+=String.fromCharCode(55296|u>>10,56320|u&1023)}else i+=String.fromCharCode(o)}return i}function dr(t,r,n){if(n===void 0&&(n=2147483647),n<4)return 0;for(var i=r,o=i+n-4,u=0;u<t.length;++u){var p=t.charCodeAt(u);if(p>=55296&&p<=57343){var l=t.charCodeAt(++u);p=65536+((p&1023)<<10)|l&1023}if(M[r>>2]=p,r+=4,r+4>o)break}return M[r>>2]=0,r-i}function pr(t){for(var r=0,n=0;n<t.length;++n){var i=t.charCodeAt(n);i>=55296&&i<=57343&&++n,r+=4}return r}function gr(t,r,n){n=D(n);var i,o,u,p,l;r===2?(i=fr,o=lr,p=ur,u=function(){return J},l=1):r===4&&(i=cr,o=dr,p=pr,u=function(){return k},l=2),x(t,{name:n,fromWireType:function(v){for(var g=k[v>>2],m=u(),P,$=v+4,S=0;S<=g;++S){var U=v+4+S*r;if(S==g||m[U>>l]==0){var W=U-$,R=i($,W);P===void 0?P=R:(P+=String.fromCharCode(0),P+=R),$=U+r}}return z(v),P},toWireType:function(v,g){typeof g!="string"&&T("Cannot pass non-string to C++ string type "+n);var m=p(g),P=De(4+m+r);return k[P>>2]=m>>l,o(g,P+4,m+r),v!==null&&v.push(z,P),P},argPackAdvance:8,readValueFromPointer:be,destructorFunction:function(v){z(v)}})}function hr(t,r){r=D(r),x(t,{isVoid:!0,name:r,argPackAdvance:0,fromWireType:function(){},toWireType:function(n,i){}})}function vr(){throw 1/0}function yr(t,r,n,i,o,u,p){return-52}function br(t,r,n,i,o,u){}function mr(){F("")}var We;typeof performance<"u"&&performance.now?We=function(){return performance.now()}:We=Date.now;function wr(){return 2147483648}function Cr(t){var r=B.buffer;try{return B.grow(t-r.byteLength+65535>>>16),Y(),1}catch{}}function _r(t){var r=O.length;t=t>>>0;var n=wr();if(t>n)return!1;for(var i=function(v,g){return v+(g-v%g)%g},o=1;o<=4;o*=2){var u=r*(1+.2/o);u=Math.min(u,t+100663296);var p=Math.min(n,i(Math.max(t,u),65536)),l=Cr(p);if(l)return!0}return!1}var Ie={};function Tr(){return"./this.program"}function se(){if(!se.strings){var t=(typeof navigator=="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8",r={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:t,_:Tr()};for(var n in Ie)Ie[n]===void 0?delete r[n]:r[n]=Ie[n];var i=[];for(var n in r)i.push(n+"="+r[n]);se.strings=i}return se.strings}function Pr(t,r){for(var n=0;n<t.length;++n)j[r++>>0]=t.charCodeAt(n);j[r>>0]=0}function Ar(t,r){var n=0;return se().forEach(function(i,o){var u=r+n;k[t+o*4>>2]=u,Pr(i,u),n+=i.length+1}),0}function Fr(t,r){var n=se();k[t>>2]=n.length;var i=0;return n.forEach(function(o){i+=o.length+1}),k[r>>2]=i,0}function $r(t){throw"exit("+t+")"}var Sr=$r;function jr(t){return 52}function Or(t,r,n,i){return 52}function Er(t,r,n,i,o){return 70}var Mr=[null,[],[]];function kr(t,r){var n=Mr[t];r===0||r===10?((t===1?C:y)(pe(n,0)),n.length=0):n.push(r)}function Ur(t,r,n,i){for(var o=0,u=0;u<n;u++){var p=k[r>>2],l=k[r+4>>2];r+=8;for(var v=0;v<l;v++)kr(t,O[p+v]);o+=l}return k[i>>2]=o,0}dt(),N=f.BindingError=Oe(Error,"BindingError"),He=f.InternalError=Oe(Error,"InternalError"),Ot(),_t(),Lt(),Ge=f.UnboundTypeError=Oe(Error,"UnboundTypeError"),Nt();var Rr={b:Te,k:Pe,I:Ae,B:st,K:ot,G:ft,n:lt,H:ut,u:ct,r:ht,j:Jt,A:qt,d:Kt,c:Yt,L:er,p:rr,g:ir,e:ar,q:or,l:gr,s:hr,y:vr,C:yr,D:br,f:mr,m:We,z:_r,E:Ar,F:Fr,h:Sr,i:jr,o:Or,t:Er,J:Ur,x:Wr,v:Dr,w:Ir,a:B};function Wr(t,r,n){var i=Ce();try{return ie(t)(r,n)}catch(o){if(_e(i),o!==o+0)throw o;we(1,0)}}function Ir(t,r,n,i,o){var u=Ce();try{return ie(t)(r,n,i,o)}catch(p){if(_e(u),p!==p+0)throw p;we(1,0)}}function Dr(t,r,n,i){var o=Ce();try{return ie(t)(r,n,i)}catch(u){if(_e(o),u!==u+0)throw u;we(1,0)}}f.getTempRet0=tt,f.setTempRet0=et;function Lr(t){t.M()}var Ze={a:Rr},De,z,Ne,we,et,tt,Ce,_e;return(WebAssembly.instantiateStreaming?WebAssembly.instantiateStreaming(fetch("jassub-worker-modern.wasm"),Ze):WebAssembly.instantiate(f.wasm,Ze)).then(function(t){w=(t.instance||t).exports,f._malloc=De=w.N,z=w.O,Ne=w.P,f.__embind_initialize_bindings=w.Q,we=w.S,et=w.T,tt=w.U,Ce=w.V,_e=w.W,w.X,w.Y,w.Z,w._,ue=w.R,Lr(w),_()}),f.ready};String.prototype.startsWith||(String.prototype.startsWith=function(s,f=0){return this.substring(f,s.length)===s});String.prototype.includes||(String.prototype.includes=function(s,f){return this.indexOf(s,f)!==-1});Uint8Array.prototype.slice||(Uint8Array.prototype.slice=function(s,f){return new Uint8Array(this.subarray(s,f))});function toAbsoluteIndex(s,f){const h=s>>0;return h<0?Math.max(h+f,0):Math.min(h,f)}Uint8Array.prototype.fill||(Int8Array.prototype.fill=Int16Array.prototype.fill=Int32Array.prototype.fill=Uint8Array.prototype.fill=Uint16Array.prototype.fill=Uint32Array.prototype.fill=Float32Array.prototype.fill=Float64Array.prototype.fill=Array.prototype.fill=function(s){if(this==null)throw new TypeError("this is null or not defined");const f=Object(this),h=f.length>>>0,C=arguments.length;let y=toAbsoluteIndex(C>1?arguments[1]:void 0,h);const _=C>2?arguments[2]:void 0,A=_===void 0?h:toAbsoluteIndex(_,h);for(;A>y;)f[y++]=s;return f});Uint8Array.prototype.copyWithin||(Int8Array.prototype.copyWithin=Int16Array.prototype.copyWithin=Int32Array.prototype.copyWithin=Uint8Array.prototype.copyWithin=Uint16Array.prototype.copyWithin=Uint32Array.prototype.copyWithin=Float32Array.prototype.copyWithin=Float64Array.prototype.copyWithin=Array.prototype.copyWithin=function(s,f){const h=Object(this),C=h.length>>>0;let y=toAbsoluteIndex(s,C),_=toAbsoluteIndex(f,C);const A=arguments.length>2?arguments[2]:void 0;let w=Math.min((A===void 0?C:toAbsoluteIndex(A,C))-_,C-y),F=1;for(_<y&&y<_+w&&(F=-1,_+=w-1,y+=w-1);w-- >0;)_ in h?h[y]=h[_]:delete h[y],y+=F,_+=F;return h});Date.now||(Date.now=()=>new Date().getTime());"performance"in self||(self.performance={now:()=>Date.now()});if(typeof console>"u"){const s=(f,h)=>{postMessage({target:"console",command:f,content:JSON.stringify(Array.prototype.slice.call(h))})};self.console={log:function(){s("log",arguments)},debug:function(){s("debug",arguments)},info:function(){s("info",arguments)},warn:function(){s("warn",arguments)},error:function(){s("error",arguments)}},console.log("Detected lack of console, overridden console")}let promiseSupported=typeof Promise<"u";if(promiseSupported)try{let s;new Promise(f=>{s=f}),s()}catch{promiseSupported=!1}promiseSupported||(self.Promise=function(s){let f=()=>{};return s(h=>setTimeout(()=>f(h),0)),{then:h=>f=h}});const read_=(s,f)=>{const h=new XMLHttpRequest;return h.open("GET",s,!1),h.responseType=f?"arraybuffer":"text",h.send(null),h.response},readAsync=(s,f,h)=>{const C=new XMLHttpRequest;C.open("GET",s,!0),C.responseType="arraybuffer",C.onload=()=>{if((C.status===200||C.status===0)&&C.response)return f(C.response)},C.onerror=h,C.send(null)};let lastCurrentTime=0;const rate=1;let rafId=null,nextIsRaf=!1,lastCurrentTimeReceivedAt=Date.now(),targetFps=24,useLocalFonts=!1,blendMode="js",availableFonts={};const fontMap_={};let fontId=0,debug;self.width=0;self.height=0;let asyncRender=!1;self.addFont=({font:s})=>asyncWrite(s);const findAvailableFonts=s=>{s=s.trim().toLowerCase(),s.startsWith("@")&&(s=s.substring(1)),!fontMap_[s]&&(fontMap_[s]=!0,availableFonts[s]?asyncWrite(availableFonts[s]):useLocalFonts&&postMessage({target:"getLocalFont",font:s}))},asyncWrite=s=>{typeof s=="string"?readAsync(s,f=>{allocFont(new Uint8Array(f))},console.error):allocFont(s)},allocFont=s=>{const f=_malloc(s.byteLength);self.HEAPU8.set(s,f),jassubObj.addFont("font-"+fontId++,f,s.byteLength),jassubObj.reloadFonts()},processAvailableFonts=s=>{if(!availableFonts)return;const f=parseAss(s);for(let y=0;y<f.length;y++)for(let _=0;_<f[y].body.length;_++)f[y].body[_].key==="Style"&&findAvailableFonts(f[y].body[_].value.Fontname);const h=/\\fn([^\\}]*?)[\\}]/g;let C;for(;(C=h.exec(s))!==null;)findAvailableFonts(C[1])};self.setTrack=({content:s})=>{processAvailableFonts(s),dropAllBlur&&(s=dropBlur(s)),jassubObj.createTrackMem(s),subtitleColorSpace=libassYCbCrMap[jassubObj.trackColorSpace],postMessage({target:"verifyColorSpace",subtitleColorSpace})};self.getColorSpace=()=>postMessage({target:"verifyColorSpace",subtitleColorSpace});self.freeTrack=()=>{jassubObj.removeTrack()};self.setTrackByUrl=({url:s})=>{self.setTrack({content:read_(s)})};const getCurrentTime=()=>{const s=(Date.now()-lastCurrentTimeReceivedAt)/1e3;return _isPaused?lastCurrentTime:(s>5&&(console.error("Didn't received currentTime > 5 seconds. Assuming video was paused."),setIsPaused(!0)),lastCurrentTime+s*rate)},setCurrentTime=s=>{lastCurrentTime=s,lastCurrentTimeReceivedAt=Date.now(),rafId||(nextIsRaf?rafId=requestAnimationFrame(renderLoop):(renderLoop(),setTimeout(()=>{nextIsRaf=!1},20)))};let _isPaused=!0;const setIsPaused=s=>{s!==_isPaused&&(_isPaused=s,s?rafId&&(clearTimeout(rafId),rafId=null):(lastCurrentTimeReceivedAt=Date.now(),rafId=requestAnimationFrame(renderLoop)))},a="BT601",b="BT709",c="SMPTE240M",d="FCC",libassYCbCrMap=[null,a,null,a,a,b,b,c,c,d,d],render=(s,f)=>{const h={},C=performance.now(),y=blendMode==="wasm"?jassubObj.renderBlend(s,f||0):jassubObj.renderImage(s,f||0);if(debug){const _=performance.now(),A=jassubObj.time;h.WASMRenderTime=A-C,h.WASMBitmapDecodeTime=_-A,h.JSRenderTime=Date.now()}if(jassubObj.changed!==0||f){const _=[],A=[];if(!y)return paintImages({images:_,buffers:A,times:h});if(asyncRender){const w=[];for(let F=y,j=0;j<jassubObj.count;F=F.next,++j){const E={w:F.w,h:F.h,x:F.x,y:F.y},M=F.image,O=hasBitmapBug?self.HEAPU8C.slice(M,M+E.w*E.h*4):self.HEAPU8C.subarray(M,M+E.w*E.h*4);w.push(createImageBitmap(new ImageData(O,E.w,E.h))),_.push(E)}Promise.all(w).then(F=>{for(let j=0;j<_.length;j++)_[j].image=F[j];debug&&(h.JSBitmapGenerationTime=Date.now()-h.JSRenderTime),paintImages({images:_,buffers:F,times:h})})}else{for(let w=y,F=0;F<jassubObj.count;w=w.next,++F){const j={w:w.w,h:w.h,x:w.x,y:w.y,image:w.image};if(!offCanvasCtx){const E=self.wasmMemory.buffer.slice(w.image,w.image+w.w*w.h*4);A.push(E),j.image=E}_.push(j)}paintImages({images:_,buffers:A,times:h})}}else postMessage({target:"unbusy"})};self.demand=({time:s})=>{lastCurrentTime=s,render(s)};const renderLoop=s=>{rafId=0,render(getCurrentTime(),s),_isPaused||(rafId=requestAnimationFrame(renderLoop))},paintImages=({times:s,images:f,buffers:h})=>{const C={target:"render",asyncRender,images:f,times:s,width:self.width,height:self.height,colorSpace:subtitleColorSpace};if(offscreenRender){(offCanvas.height!==self.height||offCanvas.width!==self.width)&&(offCanvas.width=self.width,offCanvas.height=self.height),offCanvasCtx.clearRect(0,0,self.width,self.height);for(const y of f)y.image&&(asyncRender?(offCanvasCtx.drawImage(y.image,y.x,y.y),y.image.close()):(bufferCanvas.width=y.w,bufferCanvas.height=y.h,bufferCtx.putImageData(new ImageData(self.HEAPU8C.subarray(y.image,y.image+y.w*y.h*4),y.w,y.h),0,0),offCanvasCtx.drawImage(bufferCanvas,y.x,y.y)));if(offscreenRender==="hybrid"){if(!f.length)return postMessage(C);debug&&(s.bitmaps=f.length);try{const y=offCanvas.transferToImageBitmap();C.images=[{image:y,x:0,y:0}],C.asyncRender=!0,postMessage(C,[y])}catch{postMessage({target:"unbusy"})}}else{if(debug){s.JSRenderTime=Date.now()-s.JSRenderTime-(s.JSBitmapGenerationTime||0);let y=0;for(const _ in s)y+=s[_];console.log("Bitmaps: "+f.length+" Total: "+(y|0)+"ms",s)}postMessage({target:"unbusy"})}}else postMessage(C,h)},parseAss=s=>{let f,h,C,y,_,A,w,F,j,E;const M=[],O=s.split(/[\r\n]+/g);for(F=0;F<O.length;F++)if(f=O[F].match(/^\[(.*)\]$/),f)h=null,M.push({name:f[1],body:[]});else{if(/^\s*$/.test(O[F])||M.length===0)continue;if(E=M[M.length-1].body,O[F][0]===";")E.push({type:"comment",value:O[F].substring(1)});else{if(y=O[F].split(":"),_=y[0],A=y.slice(1).join(":").trim(),(h||_==="Format")&&(A=A.split(","),h&&A.length>h.length&&(C=A.slice(h.length-1).join(","),A=A.slice(0,h.length-1),A.push(C)),A=A.map(J=>J.trim()),h)){for(w={},j=0;j<A.length;j++)w[h[j]]=A[j];A=w}_==="Format"&&(h=A),E.push({key:_,value:A})}}return M},blurRegex=/\\blur(?:[0-9]+\.)?[0-9]+/gm,dropBlur=s=>s.replace(blurRegex,""),requestAnimationFrame=(()=>{let s=0;return f=>{const h=Date.now();if(s===0)s=h+1e3/targetFps;else for(;h+2>=s;)s+=1e3/targetFps;const C=Math.max(s-h,0);return setTimeout(f,C)}})(),_applyKeys=(s,f)=>{for(const h of Object.keys(s))f[h]=s[h]};let offCanvas,offCanvasCtx,offscreenRender,bufferCanvas,bufferCtx,jassubObj,subtitleColorSpace,dropAllBlur,_malloc,hasBitmapBug;self.init=data=>{hasBitmapBug=data.hasBitmapBug;try{const s=new WebAssembly.Module(Uint8Array.of(0,97,115,109,1,0,0,0));if(!(s instanceof WebAssembly.Module)||!(new WebAssembly.Instance(s)instanceof WebAssembly.Instance))throw new Error("WASM not supported")}catch(e){console.warn(e),eval(read_(data.legacyWasmUrl))}if(WebAssembly.instantiateStreaming){const s=self.fetch;self.fetch=f=>s(data.wasmUrl)}Module({wasm:!WebAssembly.instantiateStreaming&&read_(data.wasmUrl,!0)}).then(s=>{_malloc=s._malloc,self.width=data.width,self.height=data.height,blendMode=data.blendMode,asyncRender=data.asyncRender,asyncRender&&typeof createImageBitmap>"u"&&(asyncRender=!1,console.error("'createImageBitmap' needed for 'asyncRender' unsupported!")),availableFonts=data.availableFonts,debug=data.debug,targetFps=data.targetFps||targetFps,useLocalFonts=data.useLocalFonts,dropAllBlur=data.dropAllBlur;const f=data.fallbackFont.toLowerCase();jassubObj=new s.JASSUB(self.width,self.height,f||null,debug),f&&findAvailableFonts(f);let h=data.subContent;h||(h=read_(data.subUrl)),processAvailableFonts(h),dropAllBlur&&(h=dropBlur(h));for(const C of data.fonts||[])asyncWrite(C);jassubObj.createTrackMem(h),subtitleColorSpace=libassYCbCrMap[jassubObj.trackColorSpace],jassubObj.setDropAnimations(data.dropAllAnimations||0),(data.libassMemoryLimit>0||data.libassGlyphLimit>0)&&jassubObj.setMemoryLimits(data.libassGlyphLimit||0,data.libassMemoryLimit||0),postMessage({target:"ready"}),postMessage({target:"verifyColorSpace",subtitleColorSpace})})};self.offscreenCanvas=({transferable:s})=>{offCanvas=s[0],offCanvasCtx=offCanvas.getContext("2d"),asyncRender||(bufferCanvas=new OffscreenCanvas(self.height,self.width),bufferCtx=bufferCanvas.getContext("2d",{desynchronized:!0})),offscreenRender=!0};self.detachOffscreen=()=>{offCanvas=new OffscreenCanvas(self.height,self.width),offCanvasCtx=offCanvas.getContext("2d",{desynchronized:!0}),offscreenRender="hybrid"};self.canvas=({width:s,height:f,force:h})=>{if(s==null)throw new Error("Invalid canvas size specified");self.width=s,self.height=f,jassubObj&&jassubObj.resizeCanvas(s,f),h&&render(lastCurrentTime,!0)};self.video=({currentTime:s,isPaused:f,rate:h})=>{s!=null&&setCurrentTime(s),f!=null&&setIsPaused(f),h=h||h};self.destroy=()=>{jassubObj.quitLibrary()};self.createEvent=({event:s})=>{_applyKeys(s,jassubObj.getEvent(jassubObj.allocEvent()))};self.getEvents=()=>{const s=[];for(let f=0;f<jassubObj.getEventCount();f++){const{Start:h,Duration:C,ReadOrder:y,Layer:_,Style:A,MarginL:w,MarginR:F,MarginV:j,Name:E,Text:M,Effect:O}=jassubObj.getEvent(f);s.push({Start:h,Duration:C,ReadOrder:y,Layer:_,Style:A,MarginL:w,MarginR:F,MarginV:j,Name:E,Text:M,Effect:O})}postMessage({target:"getEvents",events:s})};self.setEvent=({event:s,index:f})=>{_applyKeys(s,jassubObj.getEvent(f))};self.removeEvent=({index:s})=>{jassubObj.removeEvent(s)};self.createStyle=({style:s})=>{_applyKeys(s,jassubObj.getStyle(jassubObj.allocStyle()))};self.getStyles=()=>{const s=[];for(let f=0;f<jassubObj.getStyleCount();f++){const{Name:h,FontName:C,FontSize:y,PrimaryColour:_,SecondaryColour:A,OutlineColour:w,BackColour:F,Bold:j,Italic:E,Underline:M,StrikeOut:O,ScaleX:J,ScaleY:k,Spacing:fe,Angle:le,BorderStyle:B,Outline:ue,Shadow:Y,Alignment:ce,MarginL:de,MarginR:pe,MarginV:X,Encoding:Te,treat_fontname_as_pattern:Pe,Blur:Ae,Justify:Fe}=jassubObj.getStyle(f);s.push({Name:h,FontName:C,FontSize:y,PrimaryColour:_,SecondaryColour:A,OutlineColour:w,BackColour:F,Bold:j,Italic:E,Underline:M,StrikeOut:O,ScaleX:J,ScaleY:k,Spacing:fe,Angle:le,BorderStyle:B,Outline:ue,Shadow:Y,Alignment:ce,MarginL:de,MarginR:pe,MarginV:X,Encoding:Te,treat_fontname_as_pattern:Pe,Blur:Ae,Justify:Fe})}postMessage({target:"getStyles",time:Date.now(),styles:s})};self.setStyle=({style:s,index:f})=>{_applyKeys(s,jassubObj.getStyle(f))};self.removeStyle=({index:s})=>{jassubObj.removeStyle(s)};onmessage=({data:s})=>{if(self[s.target])self[s.target](s);else throw new Error("Unknown event target "+s.target)}; diff --git a/seanime-2.9.10/seanime-web/public/jassub/jassub-worker.wasm b/seanime-2.9.10/seanime-web/public/jassub/jassub-worker.wasm new file mode 100644 index 0000000..ad64426 Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/jassub/jassub-worker.wasm differ diff --git a/seanime-2.9.10/seanime-web/public/logo.png b/seanime-2.9.10/seanime-web/public/logo.png new file mode 100644 index 0000000..900458b Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/logo.png differ diff --git a/seanime-2.9.10/seanime-web/public/logo_2.png b/seanime-2.9.10/seanime-web/public/logo_2.png new file mode 100644 index 0000000..f100a7a Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/logo_2.png differ diff --git a/seanime-2.9.10/seanime-web/public/luffy-01.png b/seanime-2.9.10/seanime-web/public/luffy-01.png new file mode 100644 index 0000000..fb30d42 Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/luffy-01.png differ diff --git a/seanime-2.9.10/seanime-web/public/no-cover.png b/seanime-2.9.10/seanime-web/public/no-cover.png new file mode 100644 index 0000000..14e0afd Binary files /dev/null and b/seanime-2.9.10/seanime-web/public/no-cover.png differ diff --git a/seanime-2.9.10/seanime-web/public/pattern-1.svg b/seanime-2.9.10/seanime-web/public/pattern-1.svg new file mode 100644 index 0000000..30c993c --- /dev/null +++ b/seanime-2.9.10/seanime-web/public/pattern-1.svg @@ -0,0 +1,28 @@ +<svg width="273" height="568" viewBox="0 0 273 568" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clip-path="url(#a)" stroke="#fff" stroke-width=".5" stroke-miterlimit="10" style="mix-blend-mode:overlay"> + <path d="M269.428.185c11.813 41.392 45.921 68.306 61.736 106.356 12.766 30.44 4.382 65.336-10.671 99.488-9.908 22.459-20.007 43.062-24.199 62.18-4.192 18.562-5.526 36.937-3.239 56.612 4.573 37.308-2.668 73.502-11.242 109.511-11.242 47.517-31.44 93.92-12.957 142.922"/> + <path d="M254.565.185c11.814 41.206 47.636 67.563 64.594 105.242 13.719 30.441 5.145 65.336-9.908 99.674-9.908 22.459-20.007 43.248-24.58 62.18-4.382 18.561-5.907 37.123-4.001 56.798 3.81 37.308-3.43 73.688-12.005 109.882-11.242 47.702-31.82 94.105-14.862 143.293"/> + <path d="M239.512.186c11.814 41.205 49.16 66.634 67.262 104.128 14.481 30.255 6.097 65.335-9.146 99.674-9.908 22.459-20.198 43.433-24.771 62.365-4.573 18.562-6.288 37.308-4.763 56.798 3.049 37.308-4.192 73.688-12.767 110.068-11.242 47.702-32.392 94.476-16.577 143.664"/> + <path d="M224.65 0c12.004 41.206 50.875 65.707 69.929 103.015 15.434 30.254 6.86 65.521-8.193 99.859-9.908 22.459-20.388 43.433-24.961 62.366-4.573 18.747-6.669 37.308-5.526 56.983 2.287 37.493-4.954 73.873-13.528 110.439-11.242 47.888-32.774 94.848-18.292 144.221"/> + <path d="M209.788 0c12.004 41.02 52.589 64.778 72.787 101.901 16.387 30.069 7.812 65.521-7.431 100.045-9.908 22.645-20.388 43.619-25.342 62.551-4.764 18.747-7.05 37.494-6.288 57.169 1.524 37.493-5.526 74.059-14.291 110.625-11.433 48.073-33.345 95.033-20.198 144.592"/> + <path d="M194.735 0c12.004 41.02 54.305 63.85 75.455 100.787 17.149 30.069 8.574 65.707-6.669 100.231-9.908 22.645-20.579 43.804-25.533 62.551-4.954 18.747-7.431 37.679-7.05 57.354.762 37.494-6.288 74.245-15.053 110.996-11.432 48.26-33.726 95.405-21.912 144.964"/> + <path d="M179.872 0c12.195 41.02 56.02 63.108 78.123 99.674 18.102 29.884 9.337 65.707-5.907 100.416-9.908 22.645-20.769 43.805-25.723 62.737-5.145 18.933-8.003 37.865-7.812 57.354.19 37.494-7.051 74.431-15.815 111.182-11.433 48.445-34.298 95.776-23.628 145.334"/> + <path d="M164.82 0c12.194 40.835 57.734 62.18 80.98 98.746 19.055 29.883 10.29 65.706-4.954 100.601-9.908 22.645-20.769 43.99-25.914 62.737-5.144 18.933-8.193 37.865-8.574 57.54-.762 37.679-7.812 74.616-16.577 111.553-11.433 48.63-34.679 96.147-25.343 145.706"/> + <path d="M149.957 0c12.195 40.835 59.259 61.252 83.649 97.632 19.816 29.698 11.051 65.892-4.192 100.787-9.909 22.645-20.96 44.176-26.295 62.923-5.335 19.118-8.575 38.05-9.337 57.725-1.524 37.68-8.574 74.616-17.339 111.739-11.433 48.816-35.06 96.333-27.248 146.262"/> + <path d="M134.904 0c12.195 40.835 60.974 60.324 86.316 96.518 20.769 29.698 12.004 65.893-3.43 100.973-9.908 22.645-21.15 44.176-26.485 62.923-5.526 19.118-8.955 38.236-10.099 57.725-2.286 37.679-9.336 74.802-18.101 112.11-11.433 49.002-35.632 96.704-28.963 146.634"/> + <path d="M120.042 0c12.385 40.649 62.689 59.396 89.174 95.404 21.722 29.513 12.767 65.893-2.477 101.159-9.908 22.83-21.15 44.361-26.676 63.108-5.716 19.118-9.336 38.236-10.861 57.911-3.048 37.68-10.099 74.988-18.864 112.296-11.432 49.187-36.012 97.075-30.677 147.005"/> + <path d="M105.179 0c12.386 40.649 64.404 58.468 91.842 94.29 22.484 29.328 13.529 66.079-1.715 101.345-9.908 22.83-21.341 44.547-26.866 63.108-5.717 19.304-9.718 38.422-11.623 58.097-3.811 37.865-10.861 75.173-19.626 112.667-11.623 49.372-36.584 97.26-32.583 147.376"/> + <path d="M90.127 0c12.385 40.649 66.118 57.725 94.509 93.177 23.437 29.327 14.481 66.078-.953 101.53-9.908 22.83-21.531 44.547-27.247 63.294-5.907 19.304-10.099 38.607-12.386 58.282-4.573 37.865-11.623 75.359-20.388 112.852-11.623 49.559-36.965 97.632-34.297 147.933"/> + <path d="M75.265 0c12.575 40.464 67.833 56.798 97.367 92.064 24.389 29.141 15.243 66.078-.191 101.53-9.908 22.83-21.531 44.732-27.438 63.294-6.097 19.303-10.48 38.792-13.147 58.282-5.526 37.865-12.195 75.358-21.15 113.223-11.624 49.559-37.537 98.003-36.013 148.304"/> + <path d="M60.402 0c12.576 40.464 69.548 55.87 100.035 90.95 25.152 29.141 16.006 66.264.762 101.716-9.908 22.83-21.722 44.732-27.628 63.479-6.288 19.489-10.861 38.793-13.91 58.468-6.288 37.865-12.957 75.544-21.912 113.409-11.624 49.744-37.919 98.189-37.919 148.675"/> + <path d="M45.35 0c12.575 40.463 71.262 54.941 102.702 90.022 26.104 28.955 16.958 66.263 1.524 101.901-9.908 23.016-21.912 44.918-28.01 63.479-6.478 19.49-11.242 38.979-14.671 58.654-7.05 38.05-13.72 75.729-22.675 113.78-11.623 49.93-38.49 98.56-39.633 149.047"/> + <path d="M30.487 0c12.576 40.278 72.978 54.013 105.561 88.908 27.057 28.77 17.72 66.449 2.286 102.087-9.908 23.016-21.912 45.104-28.2 63.665-6.479 19.489-11.433 39.164-15.434 58.653-7.812 38.051-14.481 75.916-23.437 113.966-11.623 50.115-38.87 98.931-41.348 149.604"/> + <path d="M15.434 0C28.2 40.278 90.127 53.085 123.662 87.795c28.01 28.77 18.674 66.449 3.24 102.272-9.909 23.016-22.103 45.104-28.391 63.851-6.67 19.674-11.814 39.164-16.197 58.839-8.574 38.05-15.243 76.101-24.198 114.337-11.623 50.301-39.443 99.117-43.254 149.974"/> + <path d="M.572 0c12.766 40.278 76.217 52.157 110.896 86.68 47.254 46.961-8.194 118.607-24.58 166.309C66.88 310.9 58.497 368.44 44.968 426.537 33.345 477.023 5.145 526.025 0 576.883"/> + </g> + <defs> + <clipPath id="a"> + <path fill="#fff" transform="matrix(0 -1 -1 0 552.032 577.439)" d="M0 0h577.44v552.032H0z"/> + </clipPath> + </defs> +</svg> diff --git a/seanime-2.9.10/seanime-web/public/pattern-2.svg b/seanime-2.9.10/seanime-web/public/pattern-2.svg new file mode 100644 index 0000000..4282c03 --- /dev/null +++ b/seanime-2.9.10/seanime-web/public/pattern-2.svg @@ -0,0 +1,4 @@ +<svg height='194' width='1240' viewBox='0 0 1240 194' fill='none' xmlns='http://www.w3.org/2000/svg'> + <path d='M0.269769 -488.938C226.877 -470.693 421.374 -364.623 647.172 -443.649C663.956 -449.533 680.794 -454.661 697.686 -459.249C874.481 -506.751 1059.43 -486.455 1239.89 -487.913M0.323789 -452.826C230.871 -434.851 443.609 -312.749 673.346 -386.863C857.698 -446.348 1051.33 -425.188 1240 -451.8M0.323789 -416.714C234.163 -402.085 461.472 -270.105 693.746 -339.901C872.808 -393.718 1058.78 -371.587 1240 -415.688M0.269769 -380.547C236.861 -371.371 475.072 -234.911 708.965 -301.144C884.411 -350.805 1064.45 -323.599 1239.95 -379.522M0.269769 -344.435C158.933 -342.924 308.745 -309.78 463.846 -271.023C556.022 -247.974 628.176 -244.357 719.488 -269.026C892.83 -315.934 1068.06 -279.66 1239.95 -343.355M0.269769 -308.323C161.523 -311.993 314.196 -286.461 471.995 -247.272C563.524 -224.547 634.76 -218.231 725.802 -242.09C898.497 -287.325 1069.63 -238.527 1239.95 -307.297M0.269769 -272.157C162.657 -281.441 317.164 -264.545 476.583 -225.842C567.409 -203.819 637.728 -195.452 728.339 -218.609C902.113 -263.034 1068.92 -198.744 1239.95 -271.077M0.269769 -236.044C162.603 -250.943 318.297 -243.493 478.31 -205.87C568.273 -184.71 637.62 -174.616 727.583 -197.233C904.055 -241.604 1065.96 -159.609 1239.89 -235.019M0.269769 -199.932C161.577 -220.12 318.027 -222.603 477.986 -186.221C566.923 -165.979 634.922 -154.211 724.129 -176.235C904.865 -220.876 1060.94 -120.15 1239.89 -198.96M0.215749 -163.766C155.317 -188.218 308.367 -200.904 463.415 -168.624C547.981 -150.973 615.926 -130.029 701.571 -149.515C769.678 -165.007 828.448 -186.977 898.011 -168.3C1030.55 -132.728 1102.98 -101.042 1239.89 -162.794M0.215749 -127.653C153.482 -155.939 307.396 -178.016 461.58 -146.492C544.311 -129.597 609.881 -106.494 694.016 -124.793C761.528 -139.475 819.003 -161.984 887.487 -140.285C1025.91 -96.3453 1094.4 -59.6393 1239.84 -126.682M0.215749 -91.5411C151.323 -123.227 304.536 -150.703 457.478 -121.446C538.537 -105.954 602.272 -82.0407 684.787 -99.2602C752.462 -113.403 809.451 -134.185 877.827 -111.028C915.928 -98.1266 950.359 -71.2988 988.622 -58.4517C1080.31 -27.7373 1151.98 -51.8122 1239.95 -90.5155M0.215749 -55.4288C149.327 -90.8394 301.028 -122.255 452.999 -96.6152C533.41 -83.0124 596.389 -59.8552 677.987 -76.6968C746.795 -90.9474 804.81 -110.758 874.104 -85.8193C912.366 -72.0546 946.527 -44.0391 984.736 -29.8425C1077.07 4.48843 1149.82 -18.021 1239.89 -54.4032M0.215749 -19.2626C147.546 -58.6676 297.79 -94.1321 449.059 -72.3784C529.578 -60.7728 592.504 -39.019 673.994 -56.1845C744.313 -71.0289 804.648 -91.4871 875.183 -63.7956C913.122 -48.8973 946.204 -19.1006 983.765 -3.12265C1075.62 36.0125 1148.53 15.3383 1239.95 -18.2369M0.161894 16.8497C145.765 -26.7118 294.66 -66.2248 445.228 -48.5735C526.502 -39.0191 589.859 -19.2626 671.781 -37.2378C743.881 -53.0538 807.616 -75.5093 879.662 -44.3091C916.9 -28.1693 948.254 3.94857 984.736 22.0857C1075.18 66.9966 1147.61 48.3737 1239.84 17.8753M0.161894 52.962C143.984 5.19018 291.422 -38.3713 441.45 -24.7685C523.804 -17.3193 588.078 0.00811946 670.81 -19.1006C744.691 -36.1581 813.013 -61.8524 886.57 -26.4418C922.998 -8.8985 952.086 25.8643 987.218 46.2686C1075.51 97.657 1147.01 81.1394 1239.78 53.9877M0.161894 89.0743C142.149 37.0381 288.022 -10.4099 437.349 -0.747544C521.051 4.70439 586.405 19.1709 670.216 -1.28737C745.77 -19.7484 819.813 -49.599 894.665 -9.49228C930.229 9.56248 956.835 47.0782 990.457 69.9655C1076.16 128.263 1146.53 113.905 1239.84 90.1539M0.161894 125.187C140.044 69.0479 284.136 17.8214 432.599 23.921C517.651 27.3757 584.193 38.9274 669.083 16.9577C746.148 -2.96071 826.721 -37.5616 902.652 7.34938C937.515 27.9695 961.476 68.2383 993.587 93.5546C1076.48 158.924 1145.83 146.67 1239.84 126.212M0.161894 161.299C137.67 101.274 279.711 46.5924 426.933 49.4533C513.172 51.1267 580.793 59.6015 666.708 36.1744C745.015 14.7985 832.603 -24.6605 909.452 24.8927C943.775 47.0243 965.254 89.884 996.015 117.576C1076.21 189.854 1144.75 179.652 1239.84 162.379M0.161894 197.411C134.216 134.039 274.908 74.6078 420.133 76.2271C507.398 77.1988 575.72 81.787 662.283 56.9564C741.777 34.1771 836.057 -9.81617 913.769 43.8394C947.93 67.4285 967.305 112.501 996.933 142.46C1075.02 221.216 1143.13 212.849 1239.78 198.491M0.107875 233.577C130.762 167.075 268.702 104.728 411.768 104.566C499.573 104.458 567.949 106.132 654.944 80.0057C735.678 55.7689 835.571 8.159 914.417 65.0534C948.848 89.9379 966.657 136.792 995.691 168.64C1072.59 253.064 1140.92 246.425 1239.78 234.549M0.107875 269.69C126.661 202.809 260.769 138.465 400.111 135.443C487.916 133.553 557.533 132.474 644.42 106.24C726.72 81.3551 829.797 32.2878 910.208 89.7759C945.718 115.146 963.959 163.188 993.533 196.278C1070.92 282.915 1138.7 279.676 1239.73 270.715M0.107875 305.802C120.346 243.348 247.493 177.061 379.172 166.805C465.843 160.003 542.099 158.222 628.284 135.497C713.66 112.987 817.708 67.6444 900.817 121.678C938.378 146.131 960.829 193.956 992.777 226.668C1072.43 308.285 1138.33 312.01 1239.84 306.828M0.107875 341.968C115.004 282.159 236.915 209.934 362.388 194.658C453.269 183.592 540.588 180.462 631.522 161.191C728.878 140.571 825.425 113.311 914.039 181.001C954.353 211.824 983.549 261.809 1024.13 292.955C1094.77 347.096 1159.16 344.289 1239.78 342.94M0.107875 378.08C105.29 322.32 216.516 245.021 330.71 222.836C420.727 205.346 515.385 209.233 606.535 196.655C707.885 182.621 807.994 162.27 901.465 223.969C944.854 252.632 979.825 299.001 1022.89 328.851C1094.4 378.512 1159.8 378.782 1239.84 379.106M0.053855 414.193C95.7374 362.319 194.605 279.946 297.142 251.661C386.674 226.938 488.833 240.703 580.415 235.035C685.489 228.503 790.563 215.98 888.891 270.985C1018.14 343.318 1089.27 412.681 1239.73 415.218M0.053855 450.359C87.7503 402.155 172.425 316.004 264.168 282.591C353.214 250.149 464.008 275.735 555.914 276.653C664.496 277.733 775.128 273.09 878.367 321.132C1008.43 381.697 1094.77 447.822 1239.73 451.385M0.053855 486.471C82.2456 441.83 151.215 354.222 233.947 316.706C322.291 276.653 443.609 314.871 535.407 321.564C646.579 329.715 764.551 329.877 871.513 373.384C1000.28 425.69 1100.71 483.61 1239.73 487.497M0.053855 522.583C79.8171 481.883 132.597 395.894 208.798 355.517C296.71 308.879 429.847 358.81 521.429 370.145C636.703 384.396 757.481 390.712 870.11 426.824C997.472 467.686 1105.84 519.669 1239.73 523.663M0.053855 558.75C80.1949 523.555 118.458 442.046 190.881 400.266C279.819 348.985 424.073 407.823 516.41 422.452C635.515 441.29 758.129 451.439 875.939 480.372C999.847 510.816 1111.99 555.727 1239.68 559.775M0.053855 594.862C82.7853 568.358 110.956 493.165 182.624 452.248C275.502 399.24 427.527 462.019 522.994 478.698C645.283 500.074 768.868 511.086 890.833 533.056C1008.59 554.269 1119.98 591.461 1239.68 595.888M0 630.974C86.4012 617.372 112.143 549.681 186.132 512.759C287.104 462.396 441.18 521.612 543.556 539.155C668.112 560.477 791.696 569.546 916.468 583.958C1024.94 596.482 1130.45 626.494 1239.68 632' + stroke='white' stroke-opacity='0.24' stroke-miterlimit='10'></path> +</svg> diff --git a/seanime-2.9.10/seanime-web/public/pattern-3.svg b/seanime-2.9.10/seanime-web/public/pattern-3.svg new file mode 100644 index 0000000..6ce2a34 --- /dev/null +++ b/seanime-2.9.10/seanime-web/public/pattern-3.svg @@ -0,0 +1,4 @@ +<svg xmlns='http://www.w3.org/2000/svg' width='100' height='18' viewBox='0 0 100 18'> + <path fill='#a9aed6' fill-opacity='0.3' + d='M61.82 18c3.47-1.45 6.86-3.78 11.3-7.34C78 6.76 80.34 5.1 83.87 3.42 88.56 1.16 93.75 0 100 0v6.16C98.76 6.05 97.43 6 96 6c-9.59 0-14.23 2.23-23.13 9.34-1.28 1.03-2.39 1.9-3.4 2.66h-7.65zm-23.64 0H22.52c-1-.76-2.1-1.63-3.4-2.66C11.57 9.3 7.08 6.78 0 6.16V0c6.25 0 11.44 1.16 16.14 3.42 3.53 1.7 5.87 3.35 10.73 7.24 4.45 3.56 7.84 5.9 11.31 7.34zM61.82 0h7.66a39.57 39.57 0 0 1-7.34 4.58C57.44 6.84 52.25 8 46 8S34.56 6.84 29.86 4.58A39.57 39.57 0 0 1 22.52 0h15.66C41.65 1.44 45.21 2 50 2c4.8 0 8.35-.56 11.82-2z'></path> +</svg> diff --git a/seanime-2.9.10/seanime-web/src/api/client/requests.ts b/seanime-2.9.10/seanime-web/src/api/client/requests.ts new file mode 100644 index 0000000..6f2151d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/client/requests.ts @@ -0,0 +1,181 @@ +"use client" +import { getServerBaseUrl } from "@/api/client/server-url" +import { serverAuthTokenAtom } from "@/app/(main)/_atoms/server-status.atoms" +import { useMutation, UseMutationOptions, useQuery, UseQueryOptions } from "@tanstack/react-query" +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios" +import { useAtomValue } from "jotai" +import { useAtom } from "jotai/react" +import { usePathname } from "next/navigation" +import { useEffect } from "react" +import { toast } from "sonner" + +type SeaError = AxiosError<{ error: string }> + +type SeaQuery<D> = { + endpoint: string + method: "POST" | "GET" | "PATCH" | "DELETE" | "PUT" + data?: D + params?: D + password?: string +} + +/** + * Create axios query to the server + * - First generic: Return type + * - Second generic: Params/Data type + */ +export async function buildSeaQuery<T, D extends any = any>( + { + endpoint, + method, + data, + params, + password, + }: SeaQuery<D>): Promise<T | undefined> { + + axios.interceptors.request.use((request: InternalAxiosRequestConfig) => { + if (password) { + request.headers.set("X-Seanime-Token", password) + } + return request + }, + ) + + const res = await axios<T>({ + url: getServerBaseUrl() + endpoint, + method, + data, + params, + }) + const response = _handleSeaResponse<T>(res.data) + return response.data +} + +type ServerMutationProps<R, V = void> = UseMutationOptions<R | undefined, SeaError, V, unknown> & { + endpoint: string + method: "POST" | "GET" | "PATCH" | "DELETE" | "PUT" +} + +/** + * Create mutation hook to the server + * - First generic: Return type + * - Second generic: Params/Data type + */ +export function useServerMutation<R = void, V = void>( + { + endpoint, + method, + ...options + }: ServerMutationProps<R, V>) { + + const password = useAtomValue(serverAuthTokenAtom) + + return useMutation<R | undefined, SeaError, V>({ + onError: error => { + console.log("Mutation error", error) + toast.error(_handleSeaError(error.response?.data)) + }, + mutationFn: async (variables) => { + return buildSeaQuery<R, V>({ + endpoint: endpoint, + method: method, + data: variables, + password: password, + }) + }, + ...options, + }) +} + + +type ServerQueryProps<R, V> = UseQueryOptions<R | undefined, SeaError, R | undefined> & { + endpoint: string + method: "POST" | "GET" | "PATCH" | "DELETE" | "PUT" + params?: V + data?: V + muteError?: boolean +} + +/** + * Create query hook to the server + * - First generic: Return type + * - Second generic: Params/Data type + */ +export function useServerQuery<R, V = any>( + { + endpoint, + method, + params, + data, + muteError, + ...options + }: ServerQueryProps<R | undefined, V>) { + + const pathname = usePathname() + const [password, setPassword] = useAtom(serverAuthTokenAtom) + + const props = useQuery<R | undefined, SeaError>({ + queryFn: async () => { + return buildSeaQuery<R, V>({ + endpoint: endpoint, + method: method, + params: params, + data: data, + password: password, + }) + }, + ...options, + }) + + useEffect(() => { + if (!muteError && props.isError) { + if (props.error?.response?.data?.error === "UNAUTHENTICATED" && pathname !== "/public/auth") { + setPassword(undefined) + window.location.href = "/public/auth" + return + } + console.log("Server error", props.error) + toast.error(_handleSeaError(props.error?.response?.data)) + } + }, [props.error, props.isError, muteError]) + + return props +} + +//---------------------------------------------------------------------------------------------------------------------- + +function _handleSeaError(data: any): string { + if (typeof data === "string") return "Server Error: " + data + + const err = data?.error as string + + if (!err) return "Unknown error" + + if (err.includes("Too many requests")) + return "AniList: Too many requests, please wait a moment and try again." + + try { + const graphqlErr = JSON.parse(err) as any + console.log("AniList error", graphqlErr) + if (graphqlErr.graphqlErrors && graphqlErr.graphqlErrors.length > 0 && !!graphqlErr.graphqlErrors[0]?.message) { + return "AniList error: " + graphqlErr.graphqlErrors[0]?.message + } + return "AniList error" + } + catch (e) { + return "Error: " + err + } +} + +function _handleSeaResponse<T>(res: unknown): { data: T | undefined, error: string | undefined } { + + if (typeof res === "object" && !!res && "error" in res && typeof res.error === "string") { + return { data: undefined, error: res.error } + } + if (typeof res === "object" && !!res && "data" in res) { + return { data: res.data as T, error: undefined } + } + + return { data: undefined, error: "No response from the server" } + +} diff --git a/seanime-2.9.10/seanime-web/src/api/client/server-url.ts b/seanime-2.9.10/seanime-web/src/api/client/server-url.ts new file mode 100644 index 0000000..5ced100 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/client/server-url.ts @@ -0,0 +1,42 @@ +import { __DEV_SERVER_PORT } from "@/lib/server/config" +import { __isDesktop__ } from "@/types/constants" + +function devOrProd(dev: string, prod: string): string { + return process.env.NODE_ENV === "development" ? dev : prod +} + +export function getServerBaseUrl(removeProtocol: boolean = false): string { + if (__isDesktop__) { + let ret = devOrProd(`http://127.0.0.1:${__DEV_SERVER_PORT}`, "http://127.0.0.1:43211") + if (removeProtocol) { + ret = ret.replace("http://", "").replace("https://", "") + } + return ret + } + + // // DEV ONLY: Hack to allow multiple development servers for the same web server + // // localhost:43210 -> 127.0.0.1:43001 + // // 192.168.1.100:43210 -> 127.0.0.1:43002 + // if (process.env.NODE_ENV === "development" && window.location.host.includes("localhost")) { + // let ret = `http://127.0.0.1:${TESTONLY__DEV_SERVER_PORT2}` + // if (removeProtocol) { + // ret = ret.replace("http://", "").replace("https://", "") + // } + // return ret + // } + // if (process.env.NODE_ENV === "development" && window.location.host.startsWith("192.168")) { + // let ret = `http://127.0.0.1:${TESTONLY__DEV_SERVER_PORT3}` + // if (removeProtocol) { + // ret = ret.replace("http://", "").replace("https://", "") + // } + // return ret + // } + + let ret = typeof window !== "undefined" + ? (`${window?.location?.protocol}//` + devOrProd(`${window?.location?.hostname}:${__DEV_SERVER_PORT}`, window?.location?.host)) + : "" + if (removeProtocol) { + ret = ret.replace("http://", "").replace("https://", "") + } + return ret +} diff --git a/seanime-2.9.10/seanime-web/src/api/generated/endpoint.types.ts b/seanime-2.9.10/seanime-web/src/api/generated/endpoint.types.ts new file mode 100644 index 0000000..8c1f7c5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/generated/endpoint.types.ts @@ -0,0 +1,1815 @@ +// This code was generated by codegen/main.go. DO NOT EDIT. + +import type { + AL_AiringSort, + AL_BaseAnime, + AL_FuzzyDateInput, + AL_MediaFormat, + AL_MediaListStatus, + AL_MediaSeason, + AL_MediaSort, + AL_MediaStatus, + Anime_AutoDownloaderRule, + Anime_AutoDownloaderRuleEpisodeType, + Anime_AutoDownloaderRuleTitleComparisonType, + Anime_LocalFileMetadata, + ChapterDownloader_DownloadID, + Continuity_UpdateWatchHistoryItemOptions, + DebridClient_CancelStreamOptions, + DebridClient_StreamPlaybackType, + Debrid_TorrentItem, + HibikeTorrent_AnimeTorrent, + Mediastream_StreamType, + Models_AnilistSettings, + Models_DebridSettings, + Models_DiscordSettings, + Models_LibrarySettings, + Models_MangaSettings, + Models_MediaPlayerSettings, + Models_MediastreamSettings, + Models_NakamaSettings, + Models_NotificationSettings, + Models_Theme, + Models_TorrentSettings, + Models_TorrentstreamSettings, + Nakama_WatchPartySessionSettings, + Report_ClickLog, + Report_ConsoleLog, + Report_NetworkLog, + Report_ReactQueryLog, + RunPlaygroundCodeParams, + Torrentstream_PlaybackType, +} from "@/api/generated/types.ts" + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// anilist +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/anilist.go + * - Filename: anilist.go + * - Endpoint: /api/v1/anilist/list-entry + * @description + * Route updates the user's list entry on Anilist. + */ +export type EditAnilistListEntry_Variables = { + mediaId?: number + status?: AL_MediaListStatus + score?: number + progress?: number + startedAt?: AL_FuzzyDateInput + completedAt?: AL_FuzzyDateInput + type: string +} + +/** + * - Filepath: internal/handlers/anilist.go + * - Filename: anilist.go + * - Endpoint: /api/v1/anilist/media-details/{id} + * @description + * Route returns more details about an AniList anime entry. + */ +export type GetAnilistAnimeDetails_Variables = { + /** + * The AniList anime ID + */ + id: number +} + +/** + * - Filepath: internal/handlers/anilist.go + * - Filename: anilist.go + * - Endpoint: /api/v1/anilist/studio-details/{id} + * @description + * Route returns details about a studio. + */ +export type GetAnilistStudioDetails_Variables = { + /** + * The AniList studio ID + */ + id: number +} + +/** + * - Filepath: internal/handlers/anilist.go + * - Filename: anilist.go + * - Endpoint: /api/v1/anilist/list-entry + * @description + * Route deletes an entry from the user's AniList list. + */ +export type DeleteAnilistListEntry_Variables = { + mediaId?: number + type?: string +} + +/** + * - Filepath: internal/handlers/anilist.go + * - Filename: anilist.go + * - Endpoint: /api/v1/anilist/list-anime + * @description + * Route returns a list of anime based on the search parameters. + */ +export type AnilistListAnime_Variables = { + page?: number + search?: string + perPage?: number + sort?: Array<AL_MediaSort> + status?: Array<AL_MediaStatus> + genres?: Array<string> + averageScore_greater?: number + season?: AL_MediaSeason + seasonYear?: number + format?: AL_MediaFormat + isAdult?: boolean +} + +/** + * - Filepath: internal/handlers/anilist.go + * - Filename: anilist.go + * - Endpoint: /api/v1/anilist/list-recent-anime + * @description + * Route returns a list of recently aired anime. + */ +export type AnilistListRecentAiringAnime_Variables = { + page?: number + search?: string + perPage?: number + airingAt_greater?: number + airingAt_lesser?: number + notYetAired?: boolean + sort?: Array<AL_AiringSort> +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// anime +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/anime.go + * - Filename: anime.go + * - Endpoint: /api/v1/anime/episode-collection/{id} + * @description + * Route gets list of main episodes + */ +export type GetAnimeEpisodeCollection_Variables = { + /** + * AniList anime media ID + */ + id: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// anime_collection +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/anime_collection.go + * - Filename: anime_collection.go + * - Endpoint: /api/v1/library/unknown-media + * @description + * Route adds the given media to the user's AniList planning collections + */ +export type AddUnknownMedia_Variables = { + mediaIds: Array<number> +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// anime_entries +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/anime_entries.go + * - Filename: anime_entries.go + * - Endpoint: /api/v1/library/anime-entry/{id} + * @description + * Route return a media entry for the given AniList anime media id. + */ +export type GetAnimeEntry_Variables = { + /** + * AniList anime media ID + */ + id: number +} + +/** + * - Filepath: internal/handlers/anime_entries.go + * - Filename: anime_entries.go + * - Endpoint: /api/v1/library/anime-entry/bulk-action + * @description + * Route perform given action on all the local files for the given media id. + */ +export type AnimeEntryBulkAction_Variables = { + mediaId: number + action: string +} + +/** + * - Filepath: internal/handlers/anime_entries.go + * - Filename: anime_entries.go + * - Endpoint: /api/v1/library/anime-entry/open-in-explorer + * @description + * Route opens the directory of a media entry in the file explorer. + */ +export type OpenAnimeEntryInExplorer_Variables = { + mediaId: number +} + +/** + * - Filepath: internal/handlers/anime_entries.go + * - Filename: anime_entries.go + * - Endpoint: /api/v1/library/anime-entry/suggestions + * @description + * Route returns a list of media suggestions for files in the given directory. + */ +export type FetchAnimeEntrySuggestions_Variables = { + dir: string +} + +/** + * - Filepath: internal/handlers/anime_entries.go + * - Filename: anime_entries.go + * - Endpoint: /api/v1/library/anime-entry/manual-match + * @description + * Route matches un-matched local files in the given directory to the given media. + */ +export type AnimeEntryManualMatch_Variables = { + paths: Array<string> + mediaId: number +} + +/** + * - Filepath: internal/handlers/anime_entries.go + * - Filename: anime_entries.go + * - Endpoint: /api/v1/library/anime-entry/silence/{id} + * @description + * Route returns the silence status of a media entry. + */ +export type GetAnimeEntrySilenceStatus_Variables = { + /** + * The ID of the media entry. + */ + id: number +} + +/** + * - Filepath: internal/handlers/anime_entries.go + * - Filename: anime_entries.go + * - Endpoint: /api/v1/library/anime-entry/silence + * @description + * Route toggles the silence status of a media entry. + */ +export type ToggleAnimeEntrySilenceStatus_Variables = { + mediaId: number +} + +/** + * - Filepath: internal/handlers/anime_entries.go + * - Filename: anime_entries.go + * - Endpoint: /api/v1/library/anime-entry/update-progress + * @description + * Route update the progress of the given anime media entry. + */ +export type UpdateAnimeEntryProgress_Variables = { + mediaId: number + malId?: number + episodeNumber: number + totalEpisodes: number +} + +/** + * - Filepath: internal/handlers/anime_entries.go + * - Filename: anime_entries.go + * - Endpoint: /api/v1/library/anime-entry/update-repeat + * @description + * Route update the repeat value of the given anime media entry. + */ +export type UpdateAnimeEntryRepeat_Variables = { + mediaId: number + repeat: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// auth +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/auth.go + * - Filename: auth.go + * - Endpoint: /api/v1/auth/login + * @description + * Route logs in the user by saving the JWT token in the database. + */ +export type Login_Variables = { + token: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// auto_downloader +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/auto_downloader.go + * - Filename: auto_downloader.go + * - Endpoint: /api/v1/auto-downloader/rule/{id} + * @description + * Route returns the rule with the given DB id. + */ +export type GetAutoDownloaderRule_Variables = { + /** + * The DB id of the rule + */ + id: number +} + +/** + * - Filepath: internal/handlers/auto_downloader.go + * - Filename: auto_downloader.go + * - Endpoint: /api/v1/auto-downloader/rule/anime/{id} + * @description + * Route returns the rules with the given media id. + */ +export type GetAutoDownloaderRulesByAnime_Variables = { + /** + * The AniList anime id of the rules + */ + id: number +} + +/** + * - Filepath: internal/handlers/auto_downloader.go + * - Filename: auto_downloader.go + * - Endpoint: /api/v1/auto-downloader/rule + * @description + * Route creates a new rule. + */ +export type CreateAutoDownloaderRule_Variables = { + enabled: boolean + mediaId: number + releaseGroups: Array<string> + resolutions: Array<string> + additionalTerms: Array<string> + comparisonTitle: string + titleComparisonType: Anime_AutoDownloaderRuleTitleComparisonType + episodeType: Anime_AutoDownloaderRuleEpisodeType + episodeNumbers?: Array<number> + destination: string +} + +/** + * - Filepath: internal/handlers/auto_downloader.go + * - Filename: auto_downloader.go + * - Endpoint: /api/v1/auto-downloader/rule + * @description + * Route updates a rule. + */ +export type UpdateAutoDownloaderRule_Variables = { + rule?: Anime_AutoDownloaderRule +} + +/** + * - Filepath: internal/handlers/auto_downloader.go + * - Filename: auto_downloader.go + * - Endpoint: /api/v1/auto-downloader/rule/{id} + * @description + * Route deletes a rule. + */ +export type DeleteAutoDownloaderRule_Variables = { + /** + * The DB id of the rule + */ + id: number +} + +/** + * - Filepath: internal/handlers/auto_downloader.go + * - Filename: auto_downloader.go + * - Endpoint: /api/v1/auto-downloader/item + * @description + * Route delete a queued item. + */ +export type DeleteAutoDownloaderItem_Variables = { + id: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// continuity +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/continuity.go + * - Filename: continuity.go + * - Endpoint: /api/v1/continuity/item + * @description + * Route Updates watch history item. + */ +export type UpdateContinuityWatchHistoryItem_Variables = { + options: Continuity_UpdateWatchHistoryItemOptions +} + +/** + * - Filepath: internal/handlers/continuity.go + * - Filename: continuity.go + * - Endpoint: /api/v1/continuity/item/{id} + * @description + * Route Returns a watch history item. + */ +export type GetContinuityWatchHistoryItem_Variables = { + /** + * AniList anime media ID + */ + id: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// debrid +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/debrid.go + * - Filename: debrid.go + * - Endpoint: /api/v1/debrid/settings + * @description + * Route save debrid settings. + */ +export type SaveDebridSettings_Variables = { + settings: Models_DebridSettings +} + +/** + * - Filepath: internal/handlers/debrid.go + * - Filename: debrid.go + * - Endpoint: /api/v1/debrid/torrents + * @description + * Route add torrent to debrid. + */ +export type DebridAddTorrents_Variables = { + torrents: Array<HibikeTorrent_AnimeTorrent> + media?: AL_BaseAnime + destination: string +} + +/** + * - Filepath: internal/handlers/debrid.go + * - Filename: debrid.go + * - Endpoint: /api/v1/debrid/torrents/download + * @description + * Route download torrent from debrid. + */ +export type DebridDownloadTorrent_Variables = { + torrentItem: Debrid_TorrentItem + destination: string +} + +/** + * - Filepath: internal/handlers/debrid.go + * - Filename: debrid.go + * - Endpoint: /api/v1/debrid/torrents/cancel + * @description + * Route cancel download from debrid. + */ +export type DebridCancelDownload_Variables = { + itemID: string +} + +/** + * - Filepath: internal/handlers/debrid.go + * - Filename: debrid.go + * - Endpoint: /api/v1/debrid/torrent + * @description + * Route remove torrent from debrid. + */ +export type DebridDeleteTorrent_Variables = { + torrentItem: Debrid_TorrentItem +} + +/** + * - Filepath: internal/handlers/debrid.go + * - Filename: debrid.go + * - Endpoint: /api/v1/debrid/torrents/info + * @description + * Route get torrent info from debrid. + */ +export type DebridGetTorrentInfo_Variables = { + torrent: HibikeTorrent_AnimeTorrent +} + +/** + * - Filepath: internal/handlers/debrid.go + * - Filename: debrid.go + * - Endpoint: /api/v1/debrid/torrents/file-previews + * @description + * Route get list of torrent files + */ +export type DebridGetTorrentFilePreviews_Variables = { + torrent?: HibikeTorrent_AnimeTorrent + episodeNumber: number + media?: AL_BaseAnime +} + +/** + * - Filepath: internal/handlers/debrid.go + * - Filename: debrid.go + * - Endpoint: /api/v1/debrid/stream/start + * @description + * Route start stream from debrid. + */ +export type DebridStartStream_Variables = { + mediaId: number + episodeNumber: number + aniDBEpisode: string + autoSelect: boolean + torrent?: HibikeTorrent_AnimeTorrent + fileId: string + fileIndex?: number + playbackType: DebridClient_StreamPlaybackType + clientId: string +} + +/** + * - Filepath: internal/handlers/debrid.go + * - Filename: debrid.go + * - Endpoint: /api/v1/debrid/stream/cancel + * @description + * Route cancel stream from debrid. + */ +export type DebridCancelStream_Variables = { + options?: DebridClient_CancelStreamOptions +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// directory_selector +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/directory_selector.go + * - Filename: directory_selector.go + * - Endpoint: /api/v1/directory-selector + * @description + * Route returns directory content based on the input path. + */ +export type DirectorySelector_Variables = { + input: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// directstream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/directstream.go + * - Filename: directstream.go + * - Endpoint: /api/v1/directstream/play/localfile + * @description + * Route request local file stream. + */ +export type DirectstreamPlayLocalFile_Variables = { + path: string + clientId: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// discord +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/discord.go + * - Filename: discord.go + * - Endpoint: /api/v1/discord/presence/manga + * @description + * Route sets manga activity for discord rich presence. + */ +export type SetDiscordMangaActivity_Variables = { + mediaId: number + title: string + image: string + chapter: string +} + +/** + * - Filepath: internal/handlers/discord.go + * - Filename: discord.go + * - Endpoint: /api/v1/discord/presence/legacy-anime + * @description + * Route sets anime activity for discord rich presence. + */ +export type SetDiscordLegacyAnimeActivity_Variables = { + mediaId: number + title: string + image: string + isMovie: boolean + episodeNumber: number +} + +/** + * - Filepath: internal/handlers/discord.go + * - Filename: discord.go + * - Endpoint: /api/v1/discord/presence/anime + * @description + * Route sets anime activity for discord rich presence with progress. + */ +export type SetDiscordAnimeActivityWithProgress_Variables = { + mediaId: number + title: string + image: string + isMovie: boolean + episodeNumber: number + progress: number + duration: number + totalEpisodes?: number + currentEpisodeCount?: number + episodeTitle?: string +} + +/** + * - Filepath: internal/handlers/discord.go + * - Filename: discord.go + * - Endpoint: /api/v1/discord/presence/anime-update + * @description + * Route updates the anime activity for discord rich presence with progress. + */ +export type UpdateDiscordAnimeActivityWithProgress_Variables = { + progress: number + duration: number + paused: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// docs +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// download +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/download.go + * - Filename: download.go + * - Endpoint: /api/v1/download-torrent-file + * @description + * Route downloads torrent files to the destination folder + */ +export type DownloadTorrentFile_Variables = { + download_urls: Array<string> + destination: string + media?: AL_BaseAnime +} + +/** + * - Filepath: internal/handlers/download.go + * - Filename: download.go + * - Endpoint: /api/v1/download-release + * @description + * Route downloads selected release asset to the destination folder. + */ +export type DownloadRelease_Variables = { + download_url: string + destination: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// explorer +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/explorer.go + * - Filename: explorer.go + * - Endpoint: /api/v1/open-in-explorer + * @description + * Route opens the given directory in the file explorer. + */ +export type OpenInExplorer_Variables = { + path: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// extensions +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/extensions.go + * - Filename: extensions.go + * - Endpoint: /api/v1/extensions/external/fetch + * @description + * Route returns the extension data from the given manifest uri. + */ +export type FetchExternalExtensionData_Variables = { + manifestUri: string +} + +/** + * - Filepath: internal/handlers/extensions.go + * - Filename: extensions.go + * - Endpoint: /api/v1/extensions/external/install + * @description + * Route installs the extension from the given manifest uri. + */ +export type InstallExternalExtension_Variables = { + manifestUri: string +} + +/** + * - Filepath: internal/handlers/extensions.go + * - Filename: extensions.go + * - Endpoint: /api/v1/extensions/external/uninstall + * @description + * Route uninstalls the extension with the given ID. + */ +export type UninstallExternalExtension_Variables = { + id: string +} + +/** + * - Filepath: internal/handlers/extensions.go + * - Filename: extensions.go + * - Endpoint: /api/v1/extensions/external/edit-payload + * @description + * Route updates the extension code with the given ID and reloads the extensions. + */ +export type UpdateExtensionCode_Variables = { + id: string + payload: string +} + +/** + * - Filepath: internal/handlers/extensions.go + * - Filename: extensions.go + * - Endpoint: /api/v1/extensions/external/reload + * @description + * Route reloads the external extension with the given ID. + */ +export type ReloadExternalExtension_Variables = { + id: string +} + +/** + * - Filepath: internal/handlers/extensions.go + * - Filename: extensions.go + * - Endpoint: /api/v1/extensions/all + * @description + * Route returns all loaded and invalid extensions. + */ +export type GetAllExtensions_Variables = { + withUpdates: boolean +} + +/** + * - Filepath: internal/handlers/extensions.go + * - Filename: extensions.go + * - Endpoint: /api/v1/extensions/plugin-settings/pinned-trays + * @description + * Route sets the pinned trays in the plugin settings. + */ +export type SetPluginSettingsPinnedTrays_Variables = { + pinnedTrayPluginIds: Array<string> +} + +/** + * - Filepath: internal/handlers/extensions.go + * - Filename: extensions.go + * - Endpoint: /api/v1/extensions/plugin-permissions/grant + * @description + * Route grants the plugin permissions to the extension with the given ID. + */ +export type GrantPluginPermissions_Variables = { + id: string +} + +/** + * - Filepath: internal/handlers/extensions.go + * - Filename: extensions.go + * - Endpoint: /api/v1/extensions/playground/run + * @description + * Route runs the code in the extension playground. + */ +export type RunExtensionPlaygroundCode_Variables = { + params?: RunPlaygroundCodeParams +} + +/** + * - Filepath: internal/handlers/extensions.go + * - Filename: extensions.go + * - Endpoint: /api/v1/extensions/user-config + * @description + * Route saves the user config for the extension with the given ID and reloads it. + */ +export type SaveExtensionUserConfig_Variables = { + id: string + version: number + values: Record<string, string> +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// filecache +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/filecache.go + * - Filename: filecache.go + * - Endpoint: /api/v1/filecache/bucket + * @description + * Route deletes all buckets with the given prefix. + */ +export type RemoveFileCacheBucket_Variables = { + bucket: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// local +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/local.go + * - Filename: local.go + * - Endpoint: /api/v1/local/offline + * @description + * Route sets the offline mode. + */ +export type SetOfflineMode_Variables = { + enabled: boolean +} + +/** + * - Filepath: internal/handlers/local.go + * - Filename: local.go + * - Endpoint: /api/v1/local/track + * @description + * Route adds one or multiple media to be tracked for offline sync. + */ +export type LocalAddTrackedMedia_Variables = { + media: Array<{ mediaId: number; type: string; }> +} + +/** + * - Filepath: internal/handlers/local.go + * - Filename: local.go + * - Endpoint: /api/v1/local/track + * @description + * Route remove media from being tracked for offline sync. + */ +export type LocalRemoveTrackedMedia_Variables = { + mediaId: number + type: string +} + +/** + * - Filepath: internal/handlers/local.go + * - Filename: local.go + * - Endpoint: /api/v1/local/track/{id}/{type} + * @description + * Route checks if media is being tracked for offline sync. + */ +export type LocalGetIsMediaTracked_Variables = { + /** + * AniList anime media ID + */ + id: number + /** + * Type of media (anime/manga) + */ + type: string +} + +/** + * - Filepath: internal/handlers/local.go + * - Filename: local.go + * - Endpoint: /api/v1/local/updated + * @description + * Route sets the flag to determine if there are local changes that need to be synced with AniList. + */ +export type LocalSetHasLocalChanges_Variables = { + updated: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// localfiles +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/localfiles.go + * - Filename: localfiles.go + * - Endpoint: /api/v1/library/local-files/import + * @description + * Route imports local files from the given path. + */ +export type ImportLocalFiles_Variables = { + dataFilePath: string +} + +/** + * - Filepath: internal/handlers/localfiles.go + * - Filename: localfiles.go + * - Endpoint: /api/v1/library/local-files + * @description + * Route performs an action on all local files. + */ +export type LocalFileBulkAction_Variables = { + action: string +} + +/** + * - Filepath: internal/handlers/localfiles.go + * - Filename: localfiles.go + * - Endpoint: /api/v1/library/local-file + * @description + * Route updates the local file with the given path. + */ +export type UpdateLocalFileData_Variables = { + path: string + metadata?: Anime_LocalFileMetadata + locked: boolean + ignored: boolean + mediaId: number +} + +/** + * - Filepath: internal/handlers/localfiles.go + * - Filename: localfiles.go + * - Endpoint: /api/v1/library/local-files + * @description + * Route updates local files with the given paths. + */ +export type UpdateLocalFiles_Variables = { + paths: Array<string> + action: string + mediaId?: number +} + +/** + * - Filepath: internal/handlers/localfiles.go + * - Filename: localfiles.go + * - Endpoint: /api/v1/library/local-files + * @description + * Route deletes local files with the given paths. + */ +export type DeleteLocalFiles_Variables = { + paths: Array<string> +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// mal +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/mal.go + * - Filename: mal.go + * - Endpoint: /api/v1/mal/auth + * @description + * Route fetches the access and refresh tokens for the given code. + */ +export type MALAuth_Variables = { + code: string + state: string + code_verifier: string +} + +/** + * - Filepath: internal/handlers/mal.go + * - Filename: mal.go + * - Endpoint: /api/v1/mal/list-entry/progress + * @description + * Route updates the progress of a MAL list entry. + */ +export type EditMALListEntryProgress_Variables = { + mediaId?: number + progress?: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// manga +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/anilist/collection + * @description + * Route returns the user's AniList manga collection. + */ +export type GetAnilistMangaCollection_Variables = { + bypassCache: boolean +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/entry/{id} + * @description + * Route returns a manga entry for the given AniList manga id. + */ +export type GetMangaEntry_Variables = { + /** + * AniList manga media ID + */ + id: number +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/entry/{id}/details + * @description + * Route returns more details about an AniList manga entry. + */ +export type GetMangaEntryDetails_Variables = { + /** + * AniList manga media ID + */ + id: number +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/refetch-chapter-containers + * @description + * Route refetches the chapter containers for all manga entries previously cached. + */ +export type RefetchMangaChapterContainers_Variables = { + selectedProviderMap: Record<number, string> +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/entry/cache + * @description + * Route empties the cache for a manga entry. + */ +export type EmptyMangaEntryCache_Variables = { + mediaId: number +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/chapters + * @description + * Route returns the chapters for a manga entry based on the provider. + */ +export type GetMangaEntryChapters_Variables = { + mediaId: number + provider: string +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/pages + * @description + * Route returns the pages for a manga entry based on the provider and chapter id. + */ +export type GetMangaEntryPages_Variables = { + mediaId: number + provider: string + chapterId: string + doublePage: boolean +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/downloaded-chapters/{id} + * @description + * Route returns all download chapters for a manga entry, + */ +export type GetMangaEntryDownloadedChapters_Variables = { + /** + * AniList manga media ID + */ + id: number +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/anilist/list + * @description + * Route returns a list of manga based on the search parameters. + */ +export type AnilistListManga_Variables = { + page?: number + search?: string + perPage?: number + sort?: Array<AL_MediaSort> + status?: Array<AL_MediaStatus> + genres?: Array<string> + averageScore_greater?: number + year?: number + countryOfOrigin?: string + isAdult?: boolean + format?: AL_MediaFormat +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/update-progress + * @description + * Route updates the progress of a manga entry. + */ +export type UpdateMangaProgress_Variables = { + mediaId: number + malId?: number + chapterNumber: number + totalChapters: number +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/search + * @description + * Route returns search results for a manual search. + */ +export type MangaManualSearch_Variables = { + provider: string + query: string +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/manual-mapping + * @description + * Route manually maps a manga entry to a manga ID from the provider. + */ +export type MangaManualMapping_Variables = { + provider: string + mediaId: number + mangaId: string +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/get-mapping + * @description + * Route returns the mapping for a manga entry. + */ +export type GetMangaMapping_Variables = { + provider: string + mediaId: number +} + +/** + * - Filepath: internal/handlers/manga.go + * - Filename: manga.go + * - Endpoint: /api/v1/manga/remove-mapping + * @description + * Route removes the mapping for a manga entry. + */ +export type RemoveMangaMapping_Variables = { + provider: string + mediaId: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// manga_download +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/manga_download.go + * - Filename: manga_download.go + * - Endpoint: /api/v1/manga/download-chapters + * @description + * Route adds chapters to the download queue. + */ +export type DownloadMangaChapters_Variables = { + mediaId: number + provider: string + chapterIds: Array<string> + startNow: boolean +} + +/** + * - Filepath: internal/handlers/manga_download.go + * - Filename: manga_download.go + * - Endpoint: /api/v1/manga/download-data + * @description + * Route returns the download data for a specific media. + */ +export type GetMangaDownloadData_Variables = { + mediaId: number + cached: boolean +} + +/** + * - Filepath: internal/handlers/manga_download.go + * - Filename: manga_download.go + * - Endpoint: /api/v1/manga/download-chapter + * @description + * Route deletes downloaded chapters. + */ +export type DeleteMangaDownloadedChapters_Variables = { + downloadIds: Array<ChapterDownloader_DownloadID> +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// manual_dump +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// mediaplayer +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// mediastream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/mediastream.go + * - Filename: mediastream.go + * - Endpoint: /api/v1/mediastream/settings + * @description + * Route save mediastream settings. + */ +export type SaveMediastreamSettings_Variables = { + settings: Models_MediastreamSettings +} + +/** + * - Filepath: internal/handlers/mediastream.go + * - Filename: mediastream.go + * - Endpoint: /api/v1/mediastream/request + * @description + * Route request media stream. + */ +export type RequestMediastreamMediaContainer_Variables = { + path: string + streamType: Mediastream_StreamType + audioStreamIndex: number + clientId: string +} + +/** + * - Filepath: internal/handlers/mediastream.go + * - Filename: mediastream.go + * - Endpoint: /api/v1/mediastream/preload + * @description + * Route preloads media stream for playback. + */ +export type PreloadMediastreamMediaContainer_Variables = { + path: string + streamType: Mediastream_StreamType + audioStreamIndex: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// metadata +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/metadata.go + * - Filename: metadata.go + * - Endpoint: /api/v1/metadata-provider/filler + * @description + * Route fetches and caches filler data for the given media. + */ +export type PopulateFillerData_Variables = { + mediaId: number +} + +/** + * - Filepath: internal/handlers/metadata.go + * - Filename: metadata.go + * - Endpoint: /api/v1/metadata-provider/filler + * @description + * Route removes filler data cache. + */ +export type RemoveFillerData_Variables = { + mediaId: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// nakama +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/nakama.go + * - Filename: nakama.go + * - Endpoint: /api/v1/nakama/message + * @description + * Route sends a custom message through Nakama. + */ +export type SendNakamaMessage_Variables = { + messageType: string + payload: any + peerId?: string +} + +/** + * - Filepath: internal/handlers/nakama.go + * - Filename: nakama.go + * - Endpoint: /api/v1/nakama/host/anime/library/files/{id} + * @description + * Route return the local files for the given AniList anime media id. + */ +export type GetNakamaAnimeLibraryFiles_Variables = { + /** + * AniList anime media ID + */ + id: number +} + +/** + * - Filepath: internal/handlers/nakama.go + * - Filename: nakama.go + * - Endpoint: /api/v1/nakama/play + * @description + * Route plays the media from the host. + */ +export type NakamaPlayVideo_Variables = { + path: string + mediaId: number + anidbEpisode: string +} + +/** + * - Filepath: internal/handlers/nakama.go + * - Filename: nakama.go + * - Endpoint: /api/v1/nakama/watch-party/create + * @description + * Route creates a new watch party session. + */ +export type NakamaCreateWatchParty_Variables = { + settings?: Nakama_WatchPartySessionSettings +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// onlinestream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/onlinestream.go + * - Filename: onlinestream.go + * - Endpoint: /api/v1/onlinestream/episode-list + * @description + * Route returns the episode list for the given media and provider. + */ +export type GetOnlineStreamEpisodeList_Variables = { + mediaId: number + dubbed: boolean + provider?: string +} + +/** + * - Filepath: internal/handlers/onlinestream.go + * - Filename: onlinestream.go + * - Endpoint: /api/v1/onlinestream/episode-source + * @description + * Route returns the video sources for the given media, episode number and provider. + */ +export type GetOnlineStreamEpisodeSource_Variables = { + episodeNumber: number + mediaId: number + provider: string + dubbed: boolean +} + +/** + * - Filepath: internal/handlers/onlinestream.go + * - Filename: onlinestream.go + * - Endpoint: /api/v1/onlinestream/cache + * @description + * Route empties the cache for the given media. + */ +export type OnlineStreamEmptyCache_Variables = { + mediaId: number +} + +/** + * - Filepath: internal/handlers/onlinestream.go + * - Filename: onlinestream.go + * - Endpoint: /api/v1/onlinestream/search + * @description + * Route returns search results for a manual search. + */ +export type OnlinestreamManualSearch_Variables = { + provider: string + query: string + dubbed: boolean +} + +/** + * - Filepath: internal/handlers/onlinestream.go + * - Filename: onlinestream.go + * - Endpoint: /api/v1/onlinestream/manual-mapping + * @description + * Route manually maps an anime entry to an anime ID from the provider. + */ +export type OnlinestreamManualMapping_Variables = { + provider: string + mediaId: number + animeId: string +} + +/** + * - Filepath: internal/handlers/onlinestream.go + * - Filename: onlinestream.go + * - Endpoint: /api/v1/onlinestream/get-mapping + * @description + * Route returns the mapping for an anime entry. + */ +export type GetOnlinestreamMapping_Variables = { + provider: string + mediaId: number +} + +/** + * - Filepath: internal/handlers/onlinestream.go + * - Filename: onlinestream.go + * - Endpoint: /api/v1/onlinestream/remove-mapping + * @description + * Route removes the mapping for an anime entry. + */ +export type RemoveOnlinestreamMapping_Variables = { + provider: string + mediaId: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// playback_manager +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/playback_manager.go + * - Filename: playback_manager.go + * - Endpoint: /api/v1/playback-manager/play + * @description + * Route plays the video with the given path using the default media player. + */ +export type PlaybackPlayVideo_Variables = { + path: string +} + +/** + * - Filepath: internal/handlers/playback_manager.go + * - Filename: playback_manager.go + * - Endpoint: /api/v1/playback-manager/start-playlist + * @description + * Route starts playing a playlist. + */ +export type PlaybackStartPlaylist_Variables = { + dbId: number +} + +/** + * - Filepath: internal/handlers/playback_manager.go + * - Filename: playback_manager.go + * - Endpoint: /api/v1/playback-manager/manual-tracking/start + * @description + * Route starts manual tracking of a media. + */ +export type PlaybackStartManualTracking_Variables = { + mediaId: number + episodeNumber: number + clientId: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// playlist +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/playlist.go + * - Filename: playlist.go + * - Endpoint: /api/v1/playlist + * @description + * Route creates a new playlist. + */ +export type CreatePlaylist_Variables = { + name: string + paths: Array<string> +} + +/** + * - Filepath: internal/handlers/playlist.go + * - Filename: playlist.go + * - Endpoint: /api/v1/playlist + * @description + * Route updates a playlist. + */ +export type UpdatePlaylist_Variables = { + dbId: number + name: string + paths: Array<string> +} + +/** + * - Filepath: internal/handlers/playlist.go + * - Filename: playlist.go + * - Endpoint: /api/v1/playlist + * @description + * Route deletes a playlist. + */ +export type DeletePlaylist_Variables = { + dbId: number +} + +/** + * - Filepath: internal/handlers/playlist.go + * - Filename: playlist.go + * - Endpoint: /api/v1/playlist/episodes/{id}/{progress} + * @description + * Route returns all the local files of a playlist media entry that have not been watched. + */ +export type GetPlaylistEpisodes_Variables = { + /** + * The ID of the media entry. + */ + id: number + /** + * The progress of the media entry. + */ + progress: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// releases +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/releases.go + * - Filename: releases.go + * - Endpoint: /api/v1/install-update + * @description + * Route installs the latest update. + */ +export type InstallLatestUpdate_Variables = { + fallback_destination: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// report +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/report.go + * - Filename: report.go + * - Endpoint: /api/v1/report/issue + * @description + * Route saves the issue report in memory. + */ +export type SaveIssueReport_Variables = { + clickLogs: Array<Report_ClickLog> + networkLogs: Array<Report_NetworkLog> + reactQueryLogs: Array<Report_ReactQueryLog> + consoleLogs: Array<Report_ConsoleLog> + isAnimeLibraryIssue: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// scan +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/scan.go + * - Filename: scan.go + * - Endpoint: /api/v1/library/scan + * @description + * Route scans the user's library. + */ +export type ScanLocalFiles_Variables = { + enhanced: boolean + skipLockedFiles: boolean + skipIgnoredFiles: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// scan_summary +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// settings +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/settings.go + * - Filename: settings.go + * - Endpoint: /api/v1/start + * @description + * Route updates the app settings. + */ +export type GettingStarted_Variables = { + library: Models_LibrarySettings + mediaPlayer: Models_MediaPlayerSettings + torrent: Models_TorrentSettings + anilist: Models_AnilistSettings + discord: Models_DiscordSettings + manga: Models_MangaSettings + notifications: Models_NotificationSettings + nakama: Models_NakamaSettings + enableTranscode: boolean + enableTorrentStreaming: boolean + debridProvider: string + debridApiKey: string +} + +/** + * - Filepath: internal/handlers/settings.go + * - Filename: settings.go + * - Endpoint: /api/v1/settings + * @description + * Route updates the app settings. + */ +export type SaveSettings_Variables = { + library: Models_LibrarySettings + mediaPlayer: Models_MediaPlayerSettings + torrent: Models_TorrentSettings + anilist: Models_AnilistSettings + discord: Models_DiscordSettings + manga: Models_MangaSettings + notifications: Models_NotificationSettings + nakama: Models_NakamaSettings +} + +/** + * - Filepath: internal/handlers/settings.go + * - Filename: settings.go + * - Endpoint: /api/v1/settings/auto-downloader + * @description + * Route updates the auto-downloader settings. + */ +export type SaveAutoDownloaderSettings_Variables = { + interval: number + enabled: boolean + downloadAutomatically: boolean + enableEnhancedQueries: boolean + enableSeasonCheck: boolean + useDebrid: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// status +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/status.go + * - Filename: status.go + * - Endpoint: /api/v1/logs + * @description + * Route deletes certain log files. + */ +export type DeleteLogs_Variables = { + filenames: Array<string> +} + +/** + * - Filepath: internal/handlers/status.go + * - Filename: status.go + * - Endpoint: /api/v1/announcements + * @description + * Route returns the server announcements. + */ +export type GetAnnouncements_Variables = { + platform: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// theme +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/theme.go + * - Filename: theme.go + * - Endpoint: /api/v1/theme + * @description + * Route updates the theme settings. + */ +export type UpdateTheme_Variables = { + theme: Models_Theme +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// torrent_client +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/torrent_client.go + * - Filename: torrent_client.go + * - Endpoint: /api/v1/torrent-client/action + * @description + * Route performs an action on a torrent. + */ +export type TorrentClientAction_Variables = { + hash: string + action: string + dir: string +} + +/** + * - Filepath: internal/handlers/torrent_client.go + * - Filename: torrent_client.go + * - Endpoint: /api/v1/torrent-client/download + * @description + * Route adds torrents to the torrent client. + */ +export type TorrentClientDownload_Variables = { + torrents: Array<HibikeTorrent_AnimeTorrent> + destination: string + smartSelect: { enabled: boolean; missingEpisodeNumbers: Array<number>; } + media?: AL_BaseAnime +} + +/** + * - Filepath: internal/handlers/torrent_client.go + * - Filename: torrent_client.go + * - Endpoint: /api/v1/torrent-client/rule-magnet + * @description + * Route adds magnets to the torrent client based on the AutoDownloader item. + */ +export type TorrentClientAddMagnetFromRule_Variables = { + magnetUrl: string + ruleId: number + queuedItemId: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// torrent_search +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/torrent_search.go + * - Filename: torrent_search.go + * - Endpoint: /api/v1/torrent/search + * @description + * Route searches torrents and returns a list of torrents and their previews. + */ +export type SearchTorrent_Variables = { + /** + * "smart" or "simple" + * + * "smart" or "simple" + */ + type?: string + provider?: string + query?: string + episodeNumber?: number + batch?: boolean + media?: AL_BaseAnime + absoluteOffset?: number + resolution?: string + bestRelease?: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// torrentstream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/torrentstream.go + * - Filename: torrentstream.go + * - Endpoint: /api/v1/torrentstream/settings + * @description + * Route save torrentstream settings. + */ +export type SaveTorrentstreamSettings_Variables = { + settings: Models_TorrentstreamSettings +} + +/** + * - Filepath: internal/handlers/torrentstream.go + * - Filename: torrentstream.go + * - Endpoint: /api/v1/torrentstream/torrent-file-previews + * @description + * Route get list of torrent files from a batch + */ +export type GetTorrentstreamTorrentFilePreviews_Variables = { + torrent?: HibikeTorrent_AnimeTorrent + episodeNumber: number + media?: AL_BaseAnime +} + +/** + * - Filepath: internal/handlers/torrentstream.go + * - Filename: torrentstream.go + * - Endpoint: /api/v1/torrentstream/start + * @description + * Route starts a torrent stream. + */ +export type TorrentstreamStartStream_Variables = { + mediaId: number + episodeNumber: number + aniDBEpisode: string + autoSelect: boolean + torrent?: HibikeTorrent_AnimeTorrent + fileIndex?: number + playbackType: Torrentstream_PlaybackType + clientId: string +} + +/** + * - Filepath: internal/handlers/torrentstream.go + * - Filename: torrentstream.go + * - Endpoint: /api/v1/torrentstream/batch-history + * @description + * Route returns the most recent batch selected. + */ +export type GetTorrentstreamBatchHistory_Variables = { + mediaId: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// websocket +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + diff --git a/seanime-2.9.10/seanime-web/src/api/generated/endpoints.ts b/seanime-2.9.10/seanime-web/src/api/generated/endpoints.ts new file mode 100644 index 0000000..5c10496 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/generated/endpoints.ts @@ -0,0 +1,1996 @@ +// This code was generated by codegen/main.go. DO NOT EDIT. + +export type ApiEndpoints = Record<string, Record<string, { + key: string, + methods: ("POST" | "GET" | "PATCH" | "PUT" | "DELETE")[], + endpoint: string +}>> + +export const API_ENDPOINTS = { + ANILIST: { + /** + * @description + * Route returns the user's AniList anime collection. + * Calling GET will return the cached anime collection. + * The manga collection is also refreshed in the background, and upon completion, a WebSocket event is sent. + * Calling POST will refetch both the anime and manga collections. + */ + GetAnimeCollection: { + key: "ANILIST-get-anime-collection", + methods: ["GET", "POST"], + endpoint: "/api/v1/anilist/collection", + }, + /** + * @description + * Route returns the user's AniList anime collection without filtering out custom lists. + * Calling GET will return the cached anime collection. + */ + GetRawAnimeCollection: { + key: "ANILIST-get-raw-anime-collection", + methods: ["GET", "POST"], + endpoint: "/api/v1/anilist/collection/raw", + }, + /** + * @description + * Route updates the user's list entry on Anilist. + * This is used to edit an entry on AniList. + * The "type" field is used to determine if the entry is an anime or manga and refreshes the collection accordingly. + * The client should refetch collection-dependent queries after this mutation. + */ + EditAnilistListEntry: { + key: "ANILIST-edit-anilist-list-entry", + methods: ["POST"], + endpoint: "/api/v1/anilist/list-entry", + }, + /** + * @description + * Route returns more details about an AniList anime entry. + * This fetches more fields omitted from the base queries. + */ + GetAnilistAnimeDetails: { + key: "ANILIST-get-anilist-anime-details", + methods: ["GET"], + endpoint: "/api/v1/anilist/media-details/{id}", + }, + /** + * @description + * Route returns details about a studio. + * This fetches media produced by the studio. + */ + GetAnilistStudioDetails: { + key: "ANILIST-get-anilist-studio-details", + methods: ["GET"], + endpoint: "/api/v1/anilist/studio-details/{id}", + }, + /** + * @description + * Route deletes an entry from the user's AniList list. + * This is used to delete an entry on AniList. + * The "type" field is used to determine if the entry is an anime or manga and refreshes the collection accordingly. + * The client should refetch collection-dependent queries after this mutation. + */ + DeleteAnilistListEntry: { + key: "ANILIST-delete-anilist-list-entry", + methods: ["DELETE"], + endpoint: "/api/v1/anilist/list-entry", + }, + /** + * @description + * Route returns a list of anime based on the search parameters. + * This is used by the "Discover" and "Advanced Search". + */ + AnilistListAnime: { + key: "ANILIST-anilist-list-anime", + methods: ["POST"], + endpoint: "/api/v1/anilist/list-anime", + }, + /** + * @description + * Route returns a list of recently aired anime. + * This is used by the "Schedule" page to display recently aired anime. + */ + AnilistListRecentAiringAnime: { + key: "ANILIST-anilist-list-recent-airing-anime", + methods: ["POST"], + endpoint: "/api/v1/anilist/list-recent-anime", + }, + /** + * @description + * Route returns a list of sequels not in the user's list. + * This is used by the "Discover" page to display sequels the user may have missed. + */ + AnilistListMissedSequels: { + key: "ANILIST-anilist-list-missed-sequels", + methods: ["GET"], + endpoint: "/api/v1/anilist/list-missed-sequels", + }, + /** + * @description + * Route returns the anilist stats. + * This returns the AniList stats for the user. + */ + GetAniListStats: { + key: "ANILIST-get-ani-list-stats", + methods: ["GET"], + endpoint: "/api/v1/anilist/stats", + }, + }, + ANIME: { + /** + * @description + * Route gets list of main episodes + * This returns a list of main episodes for the given AniList anime media id. + * It also loads the episode list into the different modules. + */ + GetAnimeEpisodeCollection: { + key: "ANIME-get-anime-episode-collection", + methods: ["GET"], + endpoint: "/api/v1/anime/episode-collection/{id}", + }, + }, + ANIME_COLLECTION: { + /** + * @description + * Route returns the main local anime collection. + * This creates a new LibraryCollection struct and returns it. + * This is used to get the main anime collection of the user. + * It uses the cached Anilist anime collection for the GET method. + * It refreshes the AniList anime collection if the POST method is used. + */ + GetLibraryCollection: { + key: "ANIME-COLLECTION-get-library-collection", + methods: ["GET", "POST"], + endpoint: "/api/v1/library/collection", + }, + /** + * @description + * Route returns anime collection schedule + * This is used by the "Schedule" page to display the anime schedule. + */ + GetAnimeCollectionSchedule: { + key: "ANIME-COLLECTION-get-anime-collection-schedule", + methods: ["GET"], + endpoint: "/api/v1/library/schedule", + }, + /** + * @description + * Route adds the given media to the user's AniList planning collections + * Since media not found in the user's AniList collection are not displayed in the library, this route is used to add them. + * The response is ignored in the frontend, the client should just refetch the entire library collection. + */ + AddUnknownMedia: { + key: "ANIME-COLLECTION-add-unknown-media", + methods: ["POST"], + endpoint: "/api/v1/library/unknown-media", + }, + }, + ANIME_ENTRIES: { + /** + * @description + * Route return a media entry for the given AniList anime media id. + * This is used by the anime media entry pages to get all the data about the anime. + * This includes episodes and metadata (if any), AniList list data, download info... + */ + GetAnimeEntry: { + key: "ANIME-ENTRIES-get-anime-entry", + methods: ["GET"], + endpoint: "/api/v1/library/anime-entry/{id}", + }, + /** + * @description + * Route perform given action on all the local files for the given media id. + * This is used to unmatch or toggle the lock status of all the local files for a specific media entry + * The response is not used in the frontend. The client should just refetch the entire media entry data. + */ + AnimeEntryBulkAction: { + key: "ANIME-ENTRIES-anime-entry-bulk-action", + methods: ["PATCH"], + endpoint: "/api/v1/library/anime-entry/bulk-action", + }, + /** + * @description + * Route opens the directory of a media entry in the file explorer. + * This finds a common directory for all media entry local files and opens it in the file explorer. + * Returns 'true' whether the operation was successful or not, errors are ignored. + */ + OpenAnimeEntryInExplorer: { + key: "ANIME-ENTRIES-open-anime-entry-in-explorer", + methods: ["POST"], + endpoint: "/api/v1/library/anime-entry/open-in-explorer", + }, + /** + * @description + * Route returns a list of media suggestions for files in the given directory. + * This is used by the "Resolve unmatched media" feature to suggest media entries for the local files in the given directory. + * If some matches files are found in the directory, it will ignore them and base the suggestions on the remaining files. + */ + FetchAnimeEntrySuggestions: { + key: "ANIME-ENTRIES-fetch-anime-entry-suggestions", + methods: ["POST"], + endpoint: "/api/v1/library/anime-entry/suggestions", + }, + /** + * @description + * Route matches un-matched local files in the given directory to the given media. + * It is used by the "Resolve unmatched media" feature to manually match local files to a specific media entry. + * Matching involves the use of scanner.FileHydrator. It will also lock the files. + * The response is not used in the frontend. The client should just refetch the entire library collection. + */ + AnimeEntryManualMatch: { + key: "ANIME-ENTRIES-anime-entry-manual-match", + methods: ["POST"], + endpoint: "/api/v1/library/anime-entry/manual-match", + }, + /** + * @description + * Route returns a list of episodes missing from the user's library collection + * It detects missing episodes by comparing the user's AniList collection 'next airing' data with the local files. + * This route can be called multiple times, as it does not bypass the cache. + */ + GetMissingEpisodes: { + key: "ANIME-ENTRIES-get-missing-episodes", + methods: ["GET"], + endpoint: "/api/v1/library/missing-episodes", + }, + GetAnimeEntrySilenceStatus: { + key: "ANIME-ENTRIES-get-anime-entry-silence-status", + methods: ["GET"], + endpoint: "/api/v1/library/anime-entry/silence/{id}", + }, + /** + * @description + * Route toggles the silence status of a media entry. + * The missing episodes should be re-fetched after this. + */ + ToggleAnimeEntrySilenceStatus: { + key: "ANIME-ENTRIES-toggle-anime-entry-silence-status", + methods: ["POST"], + endpoint: "/api/v1/library/anime-entry/silence", + }, + /** + * @description + * Route update the progress of the given anime media entry. + * This is used to update the progress of the given anime media entry on AniList. + * The response is not used in the frontend, the client should just refetch the entire media entry data. + * NOTE: This is currently only used by the 'Online streaming' feature since anime progress updates are handled by the Playback Manager. + */ + UpdateAnimeEntryProgress: { + key: "ANIME-ENTRIES-update-anime-entry-progress", + methods: ["POST"], + endpoint: "/api/v1/library/anime-entry/update-progress", + }, + /** + * @description + * Route update the repeat value of the given anime media entry. + * This is used to update the repeat value of the given anime media entry on AniList. + * The response is not used in the frontend, the client should just refetch the entire media entry data. + */ + UpdateAnimeEntryRepeat: { + key: "ANIME-ENTRIES-update-anime-entry-repeat", + methods: ["POST"], + endpoint: "/api/v1/library/anime-entry/update-repeat", + }, + }, + AUTH: { + /** + * @description + * Route logs in the user by saving the JWT token in the database. + * This is called when the JWT token is obtained from AniList after logging in with redirection on the client. + * It also fetches the Viewer data from AniList and saves it in the database. + * It creates a new handlers.Status and refreshes App modules. + */ + Login: { + key: "AUTH-login", + methods: ["POST"], + endpoint: "/api/v1/auth/login", + }, + /** + * @description + * Route logs out the user by removing JWT token from the database. + * It removes JWT token and Viewer data from the database. + * It creates a new handlers.Status and refreshes App modules. + */ + Logout: { + key: "AUTH-logout", + methods: ["POST"], + endpoint: "/api/v1/auth/logout", + }, + }, + AUTO_DOWNLOADER: { + /** + * @description + * Route tells the AutoDownloader to check for new episodes if enabled. + * This will run the AutoDownloader if it is enabled. + * It does nothing if the AutoDownloader is disabled. + */ + RunAutoDownloader: { + key: "AUTO-DOWNLOADER-run-auto-downloader", + methods: ["POST"], + endpoint: "/api/v1/auto-downloader/run", + }, + /** + * @description + * Route returns the rule with the given DB id. + * This is used to get a specific rule, useful for editing. + */ + GetAutoDownloaderRule: { + key: "AUTO-DOWNLOADER-get-auto-downloader-rule", + methods: ["GET"], + endpoint: "/api/v1/auto-downloader/rule/{id}", + }, + GetAutoDownloaderRulesByAnime: { + key: "AUTO-DOWNLOADER-get-auto-downloader-rules-by-anime", + methods: ["GET"], + endpoint: "/api/v1/auto-downloader/rule/anime/{id}", + }, + /** + * @description + * Route returns all rules. + * This is used to list all rules. It returns an empty slice if there are no rules. + */ + GetAutoDownloaderRules: { + key: "AUTO-DOWNLOADER-get-auto-downloader-rules", + methods: ["GET"], + endpoint: "/api/v1/auto-downloader/rules", + }, + /** + * @description + * Route creates a new rule. + * The body should contain the same fields as entities.AutoDownloaderRule. + * It returns the created rule. + */ + CreateAutoDownloaderRule: { + key: "AUTO-DOWNLOADER-create-auto-downloader-rule", + methods: ["POST"], + endpoint: "/api/v1/auto-downloader/rule", + }, + /** + * @description + * Route updates a rule. + * The body should contain the same fields as entities.AutoDownloaderRule. + * It returns the updated rule. + */ + UpdateAutoDownloaderRule: { + key: "AUTO-DOWNLOADER-update-auto-downloader-rule", + methods: ["PATCH"], + endpoint: "/api/v1/auto-downloader/rule", + }, + /** + * @description + * Route deletes a rule. + * It returns 'true' if the rule was deleted. + */ + DeleteAutoDownloaderRule: { + key: "AUTO-DOWNLOADER-delete-auto-downloader-rule", + methods: ["DELETE"], + endpoint: "/api/v1/auto-downloader/rule/{id}", + }, + /** + * @description + * Route returns all queued items. + * Queued items are episodes that are downloaded but not scanned or not yet downloaded. + * The AutoDownloader uses these items in order to not download the same episode twice. + */ + GetAutoDownloaderItems: { + key: "AUTO-DOWNLOADER-get-auto-downloader-items", + methods: ["GET"], + endpoint: "/api/v1/auto-downloader/items", + }, + /** + * @description + * Route delete a queued item. + * This is used to remove a queued item from the list. + * Returns 'true' if the item was deleted. + */ + DeleteAutoDownloaderItem: { + key: "AUTO-DOWNLOADER-delete-auto-downloader-item", + methods: ["DELETE"], + endpoint: "/api/v1/auto-downloader/item", + }, + }, + CONTINUITY: { + /** + * @description + * Route Updates watch history item. + * This endpoint is used to update a watch history item. + * Since this is low priority, we ignore any errors. + */ + UpdateContinuityWatchHistoryItem: { + key: "CONTINUITY-update-continuity-watch-history-item", + methods: ["PATCH"], + endpoint: "/api/v1/continuity/item", + }, + /** + * @description + * Route Returns a watch history item. + * This endpoint is used to retrieve a watch history item. + */ + GetContinuityWatchHistoryItem: { + key: "CONTINUITY-get-continuity-watch-history-item", + methods: ["GET"], + endpoint: "/api/v1/continuity/item/{id}", + }, + /** + * @description + * Route Returns the continuity watch history + * This endpoint is used to retrieve all watch history items. + */ + GetContinuityWatchHistory: { + key: "CONTINUITY-get-continuity-watch-history", + methods: ["GET"], + endpoint: "/api/v1/continuity/history", + }, + }, + DEBRID: { + /** + * @description + * Route get debrid settings. + * This returns the debrid settings. + */ + GetDebridSettings: { + key: "DEBRID-get-debrid-settings", + methods: ["GET"], + endpoint: "/api/v1/debrid/settings", + }, + /** + * @description + * Route save debrid settings. + * This saves the debrid settings. + * The client should refetch the server status. + */ + SaveDebridSettings: { + key: "DEBRID-save-debrid-settings", + methods: ["PATCH"], + endpoint: "/api/v1/debrid/settings", + }, + /** + * @description + * Route add torrent to debrid. + * This adds a torrent to the debrid service. + */ + DebridAddTorrents: { + key: "DEBRID-debrid-add-torrents", + methods: ["POST"], + endpoint: "/api/v1/debrid/torrents", + }, + /** + * @description + * Route download torrent from debrid. + * Manually downloads a torrent from the debrid service locally. + */ + DebridDownloadTorrent: { + key: "DEBRID-debrid-download-torrent", + methods: ["POST"], + endpoint: "/api/v1/debrid/torrents/download", + }, + /** + * @description + * Route cancel download from debrid. + * This cancels a download from the debrid service. + */ + DebridCancelDownload: { + key: "DEBRID-debrid-cancel-download", + methods: ["POST"], + endpoint: "/api/v1/debrid/torrents/cancel", + }, + /** + * @description + * Route remove torrent from debrid. + * This removes a torrent from the debrid service. + */ + DebridDeleteTorrent: { + key: "DEBRID-debrid-delete-torrent", + methods: ["DELETE"], + endpoint: "/api/v1/debrid/torrent", + }, + /** + * @description + * Route get torrents from debrid. + * This gets the torrents from the debrid service. + */ + DebridGetTorrents: { + key: "DEBRID-debrid-get-torrents", + methods: ["GET"], + endpoint: "/api/v1/debrid/torrents", + }, + /** + * @description + * Route get torrent info from debrid. + * This gets the torrent info from the debrid service. + */ + DebridGetTorrentInfo: { + key: "DEBRID-debrid-get-torrent-info", + methods: ["POST"], + endpoint: "/api/v1/debrid/torrents/info", + }, + DebridGetTorrentFilePreviews: { + key: "DEBRID-debrid-get-torrent-file-previews", + methods: ["POST"], + endpoint: "/api/v1/debrid/torrents/file-previews", + }, + /** + * @description + * Route start stream from debrid. + * This starts streaming a torrent from the debrid service. + */ + DebridStartStream: { + key: "DEBRID-debrid-start-stream", + methods: ["POST"], + endpoint: "/api/v1/debrid/stream/start", + }, + /** + * @description + * Route cancel stream from debrid. + * This cancels a stream from the debrid service. + */ + DebridCancelStream: { + key: "DEBRID-debrid-cancel-stream", + methods: ["POST"], + endpoint: "/api/v1/debrid/stream/cancel", + }, + }, + DIRECTORY_SELECTOR: { + /** + * @description + * Route returns directory content based on the input path. + * This used by the directory selector component to get directory validation and suggestions. + * It returns subdirectories based on the input path. + * It returns 500 error if the directory does not exist (or cannot be accessed). + */ + DirectorySelector: { + key: "DIRECTORY-SELECTOR-directory-selector", + methods: ["POST"], + endpoint: "/api/v1/directory-selector", + }, + }, + DIRECTSTREAM: { + /** + * @description + * Route request local file stream. + * This requests a local file stream and returns the media container to start the playback. + */ + DirectstreamPlayLocalFile: { + key: "DIRECTSTREAM-directstream-play-local-file", + methods: ["POST"], + endpoint: "/api/v1/directstream/play/localfile", + }, + }, + DISCORD: { + SetDiscordMangaActivity: { + key: "DISCORD-set-discord-manga-activity", + methods: ["POST"], + endpoint: "/api/v1/discord/presence/manga", + }, + SetDiscordLegacyAnimeActivity: { + key: "DISCORD-set-discord-legacy-anime-activity", + methods: ["POST"], + endpoint: "/api/v1/discord/presence/legacy-anime", + }, + SetDiscordAnimeActivityWithProgress: { + key: "DISCORD-set-discord-anime-activity-with-progress", + methods: ["POST"], + endpoint: "/api/v1/discord/presence/anime", + }, + UpdateDiscordAnimeActivityWithProgress: { + key: "DISCORD-update-discord-anime-activity-with-progress", + methods: ["POST"], + endpoint: "/api/v1/discord/presence/anime-update", + }, + CancelDiscordActivity: { + key: "DISCORD-cancel-discord-activity", + methods: ["POST"], + endpoint: "/api/v1/discord/presence/cancel", + }, + }, + DOCS: { + GetDocs: { + key: "DOCS-get-docs", + methods: ["GET"], + endpoint: "/api/v1/internal/docs", + }, + }, + DOWNLOAD: { + DownloadTorrentFile: { + key: "DOWNLOAD-download-torrent-file", + methods: ["POST"], + endpoint: "/api/v1/download-torrent-file", + }, + /** + * @description + * Route downloads selected release asset to the destination folder. + * Downloads the selected release asset to the destination folder and extracts it if possible. + * If the extraction fails, the error message will be returned in the successful response. + * The successful response will contain the destination path of the extracted files. + * It only returns an error if the download fails. + */ + DownloadRelease: { + key: "DOWNLOAD-download-release", + methods: ["POST"], + endpoint: "/api/v1/download-release", + }, + }, + EXPLORER: { + /** + * @description + * Route opens the given directory in the file explorer. + * It returns 'true' whether the operation was successful or not. + */ + OpenInExplorer: { + key: "EXPLORER-open-in-explorer", + methods: ["POST"], + endpoint: "/api/v1/open-in-explorer", + }, + }, + EXTENSIONS: { + FetchExternalExtensionData: { + key: "EXTENSIONS-fetch-external-extension-data", + methods: ["POST"], + endpoint: "/api/v1/extensions/external/fetch", + }, + InstallExternalExtension: { + key: "EXTENSIONS-install-external-extension", + methods: ["POST"], + endpoint: "/api/v1/extensions/external/install", + }, + UninstallExternalExtension: { + key: "EXTENSIONS-uninstall-external-extension", + methods: ["POST"], + endpoint: "/api/v1/extensions/external/uninstall", + }, + UpdateExtensionCode: { + key: "EXTENSIONS-update-extension-code", + methods: ["POST"], + endpoint: "/api/v1/extensions/external/edit-payload", + }, + ReloadExternalExtensions: { + key: "EXTENSIONS-reload-external-extensions", + methods: ["POST"], + endpoint: "/api/v1/extensions/external/reload", + }, + ReloadExternalExtension: { + key: "EXTENSIONS-reload-external-extension", + methods: ["POST"], + endpoint: "/api/v1/extensions/external/reload", + }, + ListExtensionData: { + key: "EXTENSIONS-list-extension-data", + methods: ["GET"], + endpoint: "/api/v1/extensions/list", + }, + GetExtensionPayload: { + key: "EXTENSIONS-get-extension-payload", + methods: ["GET"], + endpoint: "/api/v1/extensions/payload/{id}", + }, + ListDevelopmentModeExtensions: { + key: "EXTENSIONS-list-development-mode-extensions", + methods: ["GET"], + endpoint: "/api/v1/extensions/list/development", + }, + GetAllExtensions: { + key: "EXTENSIONS-get-all-extensions", + methods: ["POST"], + endpoint: "/api/v1/extensions/all", + }, + GetExtensionUpdateData: { + key: "EXTENSIONS-get-extension-update-data", + methods: ["GET"], + endpoint: "/api/v1/extensions/updates", + }, + ListMangaProviderExtensions: { + key: "EXTENSIONS-list-manga-provider-extensions", + methods: ["GET"], + endpoint: "/api/v1/extensions/list/manga-provider", + }, + ListOnlinestreamProviderExtensions: { + key: "EXTENSIONS-list-onlinestream-provider-extensions", + methods: ["GET"], + endpoint: "/api/v1/extensions/list/onlinestream-provider", + }, + ListAnimeTorrentProviderExtensions: { + key: "EXTENSIONS-list-anime-torrent-provider-extensions", + methods: ["GET"], + endpoint: "/api/v1/extensions/list/anime-torrent-provider", + }, + GetPluginSettings: { + key: "EXTENSIONS-get-plugin-settings", + methods: ["GET"], + endpoint: "/api/v1/extensions/plugin-settings", + }, + SetPluginSettingsPinnedTrays: { + key: "EXTENSIONS-set-plugin-settings-pinned-trays", + methods: ["POST"], + endpoint: "/api/v1/extensions/plugin-settings/pinned-trays", + }, + GrantPluginPermissions: { + key: "EXTENSIONS-grant-plugin-permissions", + methods: ["POST"], + endpoint: "/api/v1/extensions/plugin-permissions/grant", + }, + /** + * @description + * Route runs the code in the extension playground. + * Returns the logs + */ + RunExtensionPlaygroundCode: { + key: "EXTENSIONS-run-extension-playground-code", + methods: ["POST"], + endpoint: "/api/v1/extensions/playground/run", + }, + GetExtensionUserConfig: { + key: "EXTENSIONS-get-extension-user-config", + methods: ["GET"], + endpoint: "/api/v1/extensions/user-config/{id}", + }, + SaveExtensionUserConfig: { + key: "EXTENSIONS-save-extension-user-config", + methods: ["POST"], + endpoint: "/api/v1/extensions/user-config", + }, + GetMarketplaceExtensions: { + key: "EXTENSIONS-get-marketplace-extensions", + methods: ["GET"], + endpoint: "/api/v1/extensions/marketplace", + }, + }, + FILECACHE: { + /** + * @description + * Route returns the total size of cache files. + * The total size of the cache files is returned in human-readable format. + */ + GetFileCacheTotalSize: { + key: "FILECACHE-get-file-cache-total-size", + methods: ["GET"], + endpoint: "/api/v1/filecache/total-size", + }, + /** + * @description + * Route deletes all buckets with the given prefix. + * The bucket value is the prefix of the cache files that should be deleted. + * Returns 'true' if the operation was successful. + */ + RemoveFileCacheBucket: { + key: "FILECACHE-remove-file-cache-bucket", + methods: ["DELETE"], + endpoint: "/api/v1/filecache/bucket", + }, + /** + * @description + * Route returns the total size of cached video file data. + * The total size of the cache video file data is returned in human-readable format. + */ + GetFileCacheMediastreamVideoFilesTotalSize: { + key: "FILECACHE-get-file-cache-mediastream-video-files-total-size", + methods: ["GET"], + endpoint: "/api/v1/filecache/mediastream/videofiles/total-size", + }, + /** + * @description + * Route deletes the contents of the mediastream video file cache directory. + * Returns 'true' if the operation was successful. + */ + ClearFileCacheMediastreamVideoFiles: { + key: "FILECACHE-clear-file-cache-mediastream-video-files", + methods: ["DELETE"], + endpoint: "/api/v1/filecache/mediastream/videofiles", + }, + }, + LOCAL: { + /** + * @description + * Route sets the offline mode. + * Returns true if the offline mode is active, false otherwise. + */ + SetOfflineMode: { + key: "LOCAL-set-offline-mode", + methods: ["POST"], + endpoint: "/api/v1/local/offline", + }, + LocalGetTrackedMediaItems: { + key: "LOCAL-local-get-tracked-media-items", + methods: ["GET"], + endpoint: "/api/v1/local/track", + }, + LocalAddTrackedMedia: { + key: "LOCAL-local-add-tracked-media", + methods: ["POST"], + endpoint: "/api/v1/local/track", + }, + /** + * @description + * Route remove media from being tracked for offline sync. + * This will remove anime from being tracked for offline sync and delete any associated data. + */ + LocalRemoveTrackedMedia: { + key: "LOCAL-local-remove-tracked-media", + methods: ["DELETE"], + endpoint: "/api/v1/local/track", + }, + LocalGetIsMediaTracked: { + key: "LOCAL-local-get-is-media-tracked", + methods: ["GET"], + endpoint: "/api/v1/local/track/{id}/{type}", + }, + LocalSyncData: { + key: "LOCAL-local-sync-data", + methods: ["POST"], + endpoint: "/api/v1/local/local", + }, + /** + * @description + * Route gets the current sync queue state. + * This will return the list of media that are currently queued for syncing. + */ + LocalGetSyncQueueState: { + key: "LOCAL-local-get-sync-queue-state", + methods: ["GET"], + endpoint: "/api/v1/local/queue", + }, + LocalSyncAnilistData: { + key: "LOCAL-local-sync-anilist-data", + methods: ["POST"], + endpoint: "/api/v1/local/anilist", + }, + LocalSetHasLocalChanges: { + key: "LOCAL-local-set-has-local-changes", + methods: ["POST"], + endpoint: "/api/v1/local/updated", + }, + LocalGetHasLocalChanges: { + key: "LOCAL-local-get-has-local-changes", + methods: ["GET"], + endpoint: "/api/v1/local/updated", + }, + LocalGetLocalStorageSize: { + key: "LOCAL-local-get-local-storage-size", + methods: ["GET"], + endpoint: "/api/v1/local/storage/size", + }, + LocalSyncSimulatedDataToAnilist: { + key: "LOCAL-local-sync-simulated-data-to-anilist", + methods: ["POST"], + endpoint: "/api/v1/local/sync-simulated-to-anilist", + }, + }, + LOCALFILES: { + /** + * @description + * Route returns all local files. + * Reminder that local files are scanned from the library path. + */ + GetLocalFiles: { + key: "LOCALFILES-get-local-files", + methods: ["GET"], + endpoint: "/api/v1/library/local-files", + }, + /** + * @description + * Route imports local files from the given path. + * This will import local files from the given path. + * The response is ignored, the client should refetch the entire library collection and media entry. + */ + ImportLocalFiles: { + key: "LOCALFILES-import-local-files", + methods: ["POST"], + endpoint: "/api/v1/library/local-files/import", + }, + /** + * @description + * Route performs an action on all local files. + * This will perform the given action on all local files. + * The response is ignored, the client should refetch the entire library collection and media entry. + */ + LocalFileBulkAction: { + key: "LOCALFILES-local-file-bulk-action", + methods: ["POST"], + endpoint: "/api/v1/library/local-files", + }, + /** + * @description + * Route updates the local file with the given path. + * This will update the local file with the given path. + * The response is ignored, the client should refetch the entire library collection and media entry. + */ + UpdateLocalFileData: { + key: "LOCALFILES-update-local-file-data", + methods: ["PATCH"], + endpoint: "/api/v1/library/local-file", + }, + /** + * @description + * Route updates local files with the given paths. + * The client should refetch the entire library collection and media entry. + */ + UpdateLocalFiles: { + key: "LOCALFILES-update-local-files", + methods: ["PATCH"], + endpoint: "/api/v1/library/local-files", + }, + /** + * @description + * Route deletes local files with the given paths. + * This will delete the local files with the given paths. + * The client should refetch the entire library collection and media entry. + */ + DeleteLocalFiles: { + key: "LOCALFILES-delete-local-files", + methods: ["DELETE"], + endpoint: "/api/v1/library/local-files", + }, + /** + * @description + * Route removes empty directories. + * This will remove empty directories in the library path. + */ + RemoveEmptyDirectories: { + key: "LOCALFILES-remove-empty-directories", + methods: ["DELETE"], + endpoint: "/api/v1/library/empty-directories", + }, + }, + MAL: { + /** + * @description + * Route fetches the access and refresh tokens for the given code. + * This is used to authenticate the user with MyAnimeList. + * It will save the info in the database, effectively logging the user in. + * The client should re-fetch the server status after this. + */ + MALAuth: { + key: "MAL-mal-auth", + methods: ["POST"], + endpoint: "/api/v1/mal/auth", + }, + EditMALListEntryProgress: { + key: "MAL-edit-mal-list-entry-progress", + methods: ["POST"], + endpoint: "/api/v1/mal/list-entry/progress", + }, + /** + * @description + * Route logs the user out of MyAnimeList. + * This will delete the MAL info from the database, effectively logging the user out. + * The client should re-fetch the server status after this. + */ + MALLogout: { + key: "MAL-mal-logout", + methods: ["POST"], + endpoint: "/api/v1/mal/logout", + }, + }, + MANGA: { + GetAnilistMangaCollection: { + key: "MANGA-get-anilist-manga-collection", + methods: ["GET"], + endpoint: "/api/v1/manga/anilist/collection", + }, + GetRawAnilistMangaCollection: { + key: "MANGA-get-raw-anilist-manga-collection", + methods: ["GET", "POST"], + endpoint: "/api/v1/manga/anilist/collection/raw", + }, + /** + * @description + * Route returns the user's main manga collection. + * This is an object that contains all the user's manga entries in a structured format. + */ + GetMangaCollection: { + key: "MANGA-get-manga-collection", + methods: ["GET"], + endpoint: "/api/v1/manga/collection", + }, + /** + * @description + * Route returns a manga entry for the given AniList manga id. + * This is used by the manga media entry pages to get all the data about the anime. It includes metadata and AniList list data. + */ + GetMangaEntry: { + key: "MANGA-get-manga-entry", + methods: ["GET"], + endpoint: "/api/v1/manga/entry/{id}", + }, + /** + * @description + * Route returns more details about an AniList manga entry. + * This fetches more fields omitted from the base queries. + */ + GetMangaEntryDetails: { + key: "MANGA-get-manga-entry-details", + methods: ["GET"], + endpoint: "/api/v1/manga/entry/{id}/details", + }, + GetMangaLatestChapterNumbersMap: { + key: "MANGA-get-manga-latest-chapter-numbers-map", + methods: ["GET"], + endpoint: "/api/v1/manga/latest-chapter-numbers", + }, + RefetchMangaChapterContainers: { + key: "MANGA-refetch-manga-chapter-containers", + methods: ["POST"], + endpoint: "/api/v1/manga/refetch-chapter-containers", + }, + /** + * @description + * Route empties the cache for a manga entry. + * This will empty the cache for a manga entry (chapter lists and pages), allowing the client to fetch fresh data. + * HandleGetMangaEntryChapters should be called after this to fetch the new chapter list. + * Returns 'true' if the operation was successful. + */ + EmptyMangaEntryCache: { + key: "MANGA-empty-manga-entry-cache", + methods: ["DELETE"], + endpoint: "/api/v1/manga/entry/cache", + }, + GetMangaEntryChapters: { + key: "MANGA-get-manga-entry-chapters", + methods: ["POST"], + endpoint: "/api/v1/manga/chapters", + }, + /** + * @description + * Route returns the pages for a manga entry based on the provider and chapter id. + * This will return the pages for a manga chapter. + * If the app is offline and the chapter is not downloaded, it will return an error. + * If the app is online and the chapter is not downloaded, it will return the pages from the provider. + * If the chapter is downloaded, it will return the appropriate struct. + * If 'double page' is requested, it will fetch image sizes and include the dimensions in the response. + */ + GetMangaEntryPages: { + key: "MANGA-get-manga-entry-pages", + methods: ["POST"], + endpoint: "/api/v1/manga/pages", + }, + GetMangaEntryDownloadedChapters: { + key: "MANGA-get-manga-entry-downloaded-chapters", + methods: ["GET"], + endpoint: "/api/v1/manga/downloaded-chapters/{id}", + }, + /** + * @description + * Route returns a list of manga based on the search parameters. + * This is used by "Advanced Search" and search function. + */ + AnilistListManga: { + key: "MANGA-anilist-list-manga", + methods: ["POST"], + endpoint: "/api/v1/manga/anilist/list", + }, + /** + * @description + * Route updates the progress of a manga entry. + * Note: MyAnimeList is not supported + */ + UpdateMangaProgress: { + key: "MANGA-update-manga-progress", + methods: ["POST"], + endpoint: "/api/v1/manga/update-progress", + }, + /** + * @description + * Route returns search results for a manual search. + * Returns search results for a manual search. + */ + MangaManualSearch: { + key: "MANGA-manga-manual-search", + methods: ["POST"], + endpoint: "/api/v1/manga/search", + }, + /** + * @description + * Route manually maps a manga entry to a manga ID from the provider. + * This is used to manually map a manga entry to a manga ID from the provider. + * The client should re-fetch the chapter container after this. + */ + MangaManualMapping: { + key: "MANGA-manga-manual-mapping", + methods: ["POST"], + endpoint: "/api/v1/manga/manual-mapping", + }, + /** + * @description + * Route returns the mapping for a manga entry. + * This is used to get the mapping for a manga entry. + * An empty string is returned if there's no manual mapping. If there is, the manga ID will be returned. + */ + GetMangaMapping: { + key: "MANGA-get-manga-mapping", + methods: ["POST"], + endpoint: "/api/v1/manga/get-mapping", + }, + /** + * @description + * Route removes the mapping for a manga entry. + * This is used to remove the mapping for a manga entry. + * The client should re-fetch the chapter container after this. + */ + RemoveMangaMapping: { + key: "MANGA-remove-manga-mapping", + methods: ["POST"], + endpoint: "/api/v1/manga/remove-mapping", + }, + GetLocalMangaPage: { + key: "MANGA-get-local-manga-page", + methods: ["GET"], + endpoint: "/api/v1/manga/local-page/{path}", + }, + }, + MANGA_DOWNLOAD: { + DownloadMangaChapters: { + key: "MANGA-DOWNLOAD-download-manga-chapters", + methods: ["POST"], + endpoint: "/api/v1/manga/download-chapters", + }, + /** + * @description + * Route returns the download data for a specific media. + * This is used to display information about the downloaded and queued chapters in the UI. + * If the 'cached' parameter is false, it will refresh the data by rescanning the download folder. + */ + GetMangaDownloadData: { + key: "MANGA-DOWNLOAD-get-manga-download-data", + methods: ["POST"], + endpoint: "/api/v1/manga/download-data", + }, + GetMangaDownloadQueue: { + key: "MANGA-DOWNLOAD-get-manga-download-queue", + methods: ["GET"], + endpoint: "/api/v1/manga/download-queue", + }, + /** + * @description + * Route starts the download queue if it's not already running. + * This will start the download queue if it's not already running. + * Returns 'true' whether the queue was started or not. + */ + StartMangaDownloadQueue: { + key: "MANGA-DOWNLOAD-start-manga-download-queue", + methods: ["POST"], + endpoint: "/api/v1/manga/download-queue/start", + }, + /** + * @description + * Route stops the manga download queue. + * This will stop the manga download queue. + * Returns 'true' whether the queue was stopped or not. + */ + StopMangaDownloadQueue: { + key: "MANGA-DOWNLOAD-stop-manga-download-queue", + methods: ["POST"], + endpoint: "/api/v1/manga/download-queue/stop", + }, + /** + * @description + * Route clears all chapters from the download queue. + * This will clear all chapters from the download queue. + * Returns 'true' whether the queue was cleared or not. + * This will also send a websocket event telling the client to refetch the download queue. + */ + ClearAllChapterDownloadQueue: { + key: "MANGA-DOWNLOAD-clear-all-chapter-download-queue", + methods: ["DELETE"], + endpoint: "/api/v1/manga/download-queue", + }, + /** + * @description + * Route resets the errored chapters in the download queue. + * This will reset the errored chapters in the download queue, so they can be re-downloaded. + * Returns 'true' whether the queue was reset or not. + * This will also send a websocket event telling the client to refetch the download queue. + */ + ResetErroredChapterDownloadQueue: { + key: "MANGA-DOWNLOAD-reset-errored-chapter-download-queue", + methods: ["POST"], + endpoint: "/api/v1/manga/download-queue/reset-errored", + }, + /** + * @description + * Route deletes downloaded chapters. + * This will delete downloaded chapters from the filesystem. + * Returns 'true' whether the chapters were deleted or not. + * The client should refetch the download data after this. + */ + DeleteMangaDownloadedChapters: { + key: "MANGA-DOWNLOAD-delete-manga-downloaded-chapters", + methods: ["DELETE"], + endpoint: "/api/v1/manga/download-chapter", + }, + /** + * @description + * Route displays the list of downloaded manga. + * This analyzes the download folder and returns a well-formatted structure for displaying downloaded manga. + * It returns a list of manga.DownloadListItem where the media data might be nil if it's not in the AniList collection. + */ + GetMangaDownloadsList: { + key: "MANGA-DOWNLOAD-get-manga-downloads-list", + methods: ["GET"], + endpoint: "/api/v1/manga/downloads", + }, + }, + MANUAL_DUMP: { + TestDump: { + key: "MANUAL-DUMP-test-dump", + methods: ["POST"], + endpoint: "/api/v1/test-dump", + }, + }, + MEDIAPLAYER: { + StartDefaultMediaPlayer: { + key: "MEDIAPLAYER-start-default-media-player", + methods: ["POST"], + endpoint: "/api/v1/media-player/start", + }, + }, + MEDIASTREAM: { + /** + * @description + * Route get mediastream settings. + * This returns the mediastream settings. + */ + GetMediastreamSettings: { + key: "MEDIASTREAM-get-mediastream-settings", + methods: ["GET"], + endpoint: "/api/v1/mediastream/settings", + }, + /** + * @description + * Route save mediastream settings. + * This saves the mediastream settings. + */ + SaveMediastreamSettings: { + key: "MEDIASTREAM-save-mediastream-settings", + methods: ["PATCH"], + endpoint: "/api/v1/mediastream/settings", + }, + /** + * @description + * Route request media stream. + * This requests a media stream and returns the media container to start the playback. + */ + RequestMediastreamMediaContainer: { + key: "MEDIASTREAM-request-mediastream-media-container", + methods: ["POST"], + endpoint: "/api/v1/mediastream/request", + }, + /** + * @description + * Route preloads media stream for playback. + * This preloads a media stream by extracting the media information and attachments. + */ + PreloadMediastreamMediaContainer: { + key: "MEDIASTREAM-preload-mediastream-media-container", + methods: ["POST"], + endpoint: "/api/v1/mediastream/preload", + }, + /** + * @description + * Route shuts down the transcode stream + * This requests the transcoder to shut down. It should be called when unmounting the player (playback is no longer needed). + * This will also send an events.MediastreamShutdownStream event. + * It will not return any error and is safe to call multiple times. + */ + MediastreamShutdownTranscodeStream: { + key: "MEDIASTREAM-mediastream-shutdown-transcode-stream", + methods: ["POST"], + endpoint: "/api/v1/mediastream/shutdown-transcode", + }, + }, + METADATA: { + /** + * @description + * Route fetches and caches filler data for the given media. + * This will fetch and cache filler data for the given media. + */ + PopulateFillerData: { + key: "METADATA-populate-filler-data", + methods: ["POST"], + endpoint: "/api/v1/metadata-provider/filler", + }, + /** + * @description + * Route removes filler data cache. + * This will remove the filler data cache for the given media. + */ + RemoveFillerData: { + key: "METADATA-remove-filler-data", + methods: ["DELETE"], + endpoint: "/api/v1/metadata-provider/filler", + }, + }, + NAKAMA: { + /** + * @description + * Route handles WebSocket connections for Nakama peers. + * This endpoint handles WebSocket connections from Nakama peers when this instance is acting as a host. + */ + NakamaWebSocket: { + key: "NAKAMA-nakama-web-socket", + methods: ["GET"], + endpoint: "/api/v1/nakama/ws", + }, + /** + * @description + * Route sends a custom message through Nakama. + * This allows sending custom messages to connected peers or the host. + */ + SendNakamaMessage: { + key: "NAKAMA-send-nakama-message", + methods: ["POST"], + endpoint: "/api/v1/nakama/message", + }, + /** + * @description + * Route shares the local anime collection with Nakama clients. + * This creates a new LibraryCollection struct and returns it. + * This is used to share the local anime collection with Nakama clients. + */ + GetNakamaAnimeLibrary: { + key: "NAKAMA-get-nakama-anime-library", + methods: ["GET"], + endpoint: "/api/v1/nakama/host/anime/library/collection", + }, + /** + * @description + * Route shares the local anime collection with Nakama clients. + * This creates a new LibraryCollection struct and returns it. + * This is used to share the local anime collection with Nakama clients. + */ + GetNakamaAnimeLibraryCollection: { + key: "NAKAMA-get-nakama-anime-library-collection", + methods: ["GET"], + endpoint: "/api/v1/nakama/host/anime/library/collection", + }, + /** + * @description + * Route return the local files for the given AniList anime media id. + * This is used by the anime media entry pages to get all the data about the anime. + */ + GetNakamaAnimeLibraryFiles: { + key: "NAKAMA-get-nakama-anime-library-files", + methods: ["POST"], + endpoint: "/api/v1/nakama/host/anime/library/files/{id}", + }, + /** + * @description + * Route return all the local files for the host. + * This is used to share the local anime collection with Nakama clients. + */ + GetNakamaAnimeAllLibraryFiles: { + key: "NAKAMA-get-nakama-anime-all-library-files", + methods: ["POST"], + endpoint: "/api/v1/nakama/host/anime/library/files", + }, + NakamaPlayVideo: { + key: "NAKAMA-nakama-play-video", + methods: ["POST"], + endpoint: "/api/v1/nakama/play", + }, + /** + * @description + * Route reconnects to the Nakama host. + * This attempts to reconnect to the configured Nakama host if the connection was lost. + */ + NakamaReconnectToHost: { + key: "NAKAMA-nakama-reconnect-to-host", + methods: ["POST"], + endpoint: "/api/v1/nakama/reconnect", + }, + /** + * @description + * Route removes stale peer connections. + * This removes peer connections that haven't responded to ping messages for a while. + */ + NakamaRemoveStaleConnections: { + key: "NAKAMA-nakama-remove-stale-connections", + methods: ["POST"], + endpoint: "/api/v1/nakama/cleanup", + }, + /** + * @description + * Route creates a new watch party session. + * This creates a new watch party that peers can join to watch content together in sync. + */ + NakamaCreateWatchParty: { + key: "NAKAMA-nakama-create-watch-party", + methods: ["POST"], + endpoint: "/api/v1/nakama/watch-party/create", + }, + /** + * @description + * Route joins an existing watch party. + * This allows a peer to join an active watch party session. + */ + NakamaJoinWatchParty: { + key: "NAKAMA-nakama-join-watch-party", + methods: ["POST"], + endpoint: "/api/v1/nakama/watch-party/join", + }, + /** + * @description + * Route leaves the current watch party. + * This removes the user from the active watch party session. + */ + NakamaLeaveWatchParty: { + key: "NAKAMA-nakama-leave-watch-party", + methods: ["POST"], + endpoint: "/api/v1/nakama/watch-party/leave", + }, + }, + ONLINESTREAM: { + /** + * @description + * Route returns the episode list for the given media and provider. + * It returns the episode list for the given media and provider. + * The episodes are cached using a file cache. + * The episode list is just a list of episodes with no video sources, it's what the client uses to display the episodes and subsequently fetch the sources. + * The episode list might be nil or empty if nothing could be found, but the media will always be returned. + */ + GetOnlineStreamEpisodeList: { + key: "ONLINESTREAM-get-online-stream-episode-list", + methods: ["POST"], + endpoint: "/api/v1/onlinestream/episode-list", + }, + GetOnlineStreamEpisodeSource: { + key: "ONLINESTREAM-get-online-stream-episode-source", + methods: ["POST"], + endpoint: "/api/v1/onlinestream/episode-source", + }, + OnlineStreamEmptyCache: { + key: "ONLINESTREAM-online-stream-empty-cache", + methods: ["DELETE"], + endpoint: "/api/v1/onlinestream/cache", + }, + /** + * @description + * Route returns search results for a manual search. + * Returns search results for a manual search. + */ + OnlinestreamManualSearch: { + key: "ONLINESTREAM-onlinestream-manual-search", + methods: ["POST"], + endpoint: "/api/v1/onlinestream/search", + }, + /** + * @description + * Route manually maps an anime entry to an anime ID from the provider. + * This is used to manually map an anime entry to an anime ID from the provider. + * The client should re-fetch the chapter container after this. + */ + OnlinestreamManualMapping: { + key: "ONLINESTREAM-onlinestream-manual-mapping", + methods: ["POST"], + endpoint: "/api/v1/onlinestream/manual-mapping", + }, + /** + * @description + * Route returns the mapping for an anime entry. + * This is used to get the mapping for an anime entry. + * An empty string is returned if there's no manual mapping. If there is, the anime ID will be returned. + */ + GetOnlinestreamMapping: { + key: "ONLINESTREAM-get-onlinestream-mapping", + methods: ["POST"], + endpoint: "/api/v1/onlinestream/get-mapping", + }, + /** + * @description + * Route removes the mapping for an anime entry. + * This is used to remove the mapping for an anime entry. + * The client should re-fetch the chapter container after this. + */ + RemoveOnlinestreamMapping: { + key: "ONLINESTREAM-remove-onlinestream-mapping", + methods: ["POST"], + endpoint: "/api/v1/onlinestream/remove-mapping", + }, + }, + PLAYBACK_MANAGER: { + /** + * @description + * Route plays the video with the given path using the default media player. + * This tells the Playback Manager to play the video using the default media player and start tracking progress. + * This returns 'true' if the video was successfully played. + */ + PlaybackPlayVideo: { + key: "PLAYBACK-MANAGER-playback-play-video", + methods: ["POST"], + endpoint: "/api/v1/playback-manager/play", + }, + /** + * @description + * Route plays a random, unwatched video using the default media player. + * This tells the Playback Manager to play a random, unwatched video using the media player and start tracking progress. + * It respects the user's progress data and will prioritize "current" and "repeating" media if they are many of them. + * This returns 'true' if the video was successfully played. + */ + PlaybackPlayRandomVideo: { + key: "PLAYBACK-MANAGER-playback-play-random-video", + methods: ["POST"], + endpoint: "/api/v1/playback-manager/play-random", + }, + /** + * @description + * Route updates the AniList progress of the currently playing media. + * This is called after 'Update progress' is clicked when watching a media. + * This route returns the media ID of the currently playing media, so the client can refetch the media entry data. + */ + PlaybackSyncCurrentProgress: { + key: "PLAYBACK-MANAGER-playback-sync-current-progress", + methods: ["POST"], + endpoint: "/api/v1/playback-manager/sync-current-progress", + }, + /** + * @description + * Route plays the next episode of the currently playing media. + * This will play the next episode of the currently playing media. + * This is non-blocking so the client should prevent multiple calls until the next status is received. + */ + PlaybackPlayNextEpisode: { + key: "PLAYBACK-MANAGER-playback-play-next-episode", + methods: ["POST"], + endpoint: "/api/v1/playback-manager/next-episode", + }, + /** + * @description + * Route gets the next episode of the currently playing media. + * This is used by the client's autoplay feature + */ + PlaybackGetNextEpisode: { + key: "PLAYBACK-MANAGER-playback-get-next-episode", + methods: ["GET"], + endpoint: "/api/v1/playback-manager/next-episode", + }, + /** + * @description + * Route plays the next episode of the currently playing media. + * This will play the next episode of the currently playing media. + */ + PlaybackAutoPlayNextEpisode: { + key: "PLAYBACK-MANAGER-playback-auto-play-next-episode", + methods: ["POST"], + endpoint: "/api/v1/playback-manager/autoplay-next-episode", + }, + /** + * @description + * Route starts playing a playlist. + * The client should refetch playlists. + */ + PlaybackStartPlaylist: { + key: "PLAYBACK-MANAGER-playback-start-playlist", + methods: ["POST"], + endpoint: "/api/v1/playback-manager/start-playlist", + }, + /** + * @description + * Route ends the current playlist. + * This will stop the current playlist. This is non-blocking. + */ + PlaybackCancelCurrentPlaylist: { + key: "PLAYBACK-MANAGER-playback-cancel-current-playlist", + methods: ["POST"], + endpoint: "/api/v1/playback-manager/cancel-playlist", + }, + /** + * @description + * Route moves to the next item in the current playlist. + * This is non-blocking so the client should prevent multiple calls until the next status is received. + */ + PlaybackPlaylistNext: { + key: "PLAYBACK-MANAGER-playback-playlist-next", + methods: ["POST"], + endpoint: "/api/v1/playback-manager/playlist-next", + }, + /** + * @description + * Route starts manual tracking of a media. + * Used for tracking progress of media that is not played through any integrated media player. + * This should only be used for trackable episodes (episodes that count towards progress). + * This returns 'true' if the tracking was successfully started. + */ + PlaybackStartManualTracking: { + key: "PLAYBACK-MANAGER-playback-start-manual-tracking", + methods: ["POST"], + endpoint: "/api/v1/playback-manager/manual-tracking/start", + }, + /** + * @description + * Route cancels manual tracking of a media. + * This will stop the server from expecting progress updates for the media. + */ + PlaybackCancelManualTracking: { + key: "PLAYBACK-MANAGER-playback-cancel-manual-tracking", + methods: ["POST"], + endpoint: "/api/v1/playback-manager/manual-tracking/cancel", + }, + }, + PLAYLIST: { + /** + * @description + * Route creates a new playlist. + * This will create a new playlist with the given name and local file paths. + * The response is ignored, the client should re-fetch the playlists after this. + */ + CreatePlaylist: { + key: "PLAYLIST-create-playlist", + methods: ["POST"], + endpoint: "/api/v1/playlist", + }, + GetPlaylists: { + key: "PLAYLIST-get-playlists", + methods: ["GET"], + endpoint: "/api/v1/playlists", + }, + /** + * @description + * Route updates a playlist. + * The response is ignored, the client should re-fetch the playlists after this. + */ + UpdatePlaylist: { + key: "PLAYLIST-update-playlist", + methods: ["PATCH"], + endpoint: "/api/v1/playlist", + }, + DeletePlaylist: { + key: "PLAYLIST-delete-playlist", + methods: ["DELETE"], + endpoint: "/api/v1/playlist", + }, + GetPlaylistEpisodes: { + key: "PLAYLIST-get-playlist-episodes", + methods: ["GET"], + endpoint: "/api/v1/playlist/episodes/{id}/{progress}", + }, + }, + RELEASES: { + /** + * @description + * Route installs the latest update. + * This will install the latest update and launch the new version. + */ + InstallLatestUpdate: { + key: "RELEASES-install-latest-update", + methods: ["POST"], + endpoint: "/api/v1/install-update", + }, + /** + * @description + * Route returns the latest update. + * This will return the latest update. + * If an error occurs, it will return an empty update. + */ + GetLatestUpdate: { + key: "RELEASES-get-latest-update", + methods: ["GET"], + endpoint: "/api/v1/latest-update", + }, + GetChangelog: { + key: "RELEASES-get-changelog", + methods: ["GET"], + endpoint: "/api/v1/changelog", + }, + }, + REPORT: { + SaveIssueReport: { + key: "REPORT-save-issue-report", + methods: ["POST"], + endpoint: "/api/v1/report/issue", + }, + DownloadIssueReport: { + key: "REPORT-download-issue-report", + methods: ["GET"], + endpoint: "/api/v1/report/issue/download", + }, + }, + SCAN: { + /** + * @description + * Route scans the user's library. + * This will scan the user's library. + * The response is ignored, the client should re-fetch the library after this. + */ + ScanLocalFiles: { + key: "SCAN-scan-local-files", + methods: ["POST"], + endpoint: "/api/v1/library/scan", + }, + }, + SCAN_SUMMARY: { + GetScanSummaries: { + key: "SCAN-SUMMARY-get-scan-summaries", + methods: ["GET"], + endpoint: "/api/v1/library/scan-summaries", + }, + }, + SETTINGS: { + GetSettings: { + key: "SETTINGS-get-settings", + methods: ["GET"], + endpoint: "/api/v1/settings", + }, + /** + * @description + * Route updates the app settings. + * This will update the app settings. + * The client should re-fetch the server status after this. + */ + GettingStarted: { + key: "SETTINGS-getting-started", + methods: ["POST"], + endpoint: "/api/v1/start", + }, + /** + * @description + * Route updates the app settings. + * This will update the app settings. + * The client should re-fetch the server status after this. + */ + SaveSettings: { + key: "SETTINGS-save-settings", + methods: ["PATCH"], + endpoint: "/api/v1/settings", + }, + SaveAutoDownloaderSettings: { + key: "SETTINGS-save-auto-downloader-settings", + methods: ["PATCH"], + endpoint: "/api/v1/settings/auto-downloader", + }, + }, + STATUS: { + /** + * @description + * Route returns the server status. + * The server status includes app info, auth info and settings. + * The client uses this to set the UI. + * It is called on every page load to get the most up-to-date data. + * It should be called right after updating the settings. + */ + GetStatus: { + key: "STATUS-get-status", + methods: ["GET"], + endpoint: "/api/v1/status", + }, + /** + * @description + * Route returns the log filenames. + * This returns the filenames of all log files in the logs directory. + */ + GetLogFilenames: { + key: "STATUS-get-log-filenames", + methods: ["GET"], + endpoint: "/api/v1/logs/filenames", + }, + /** + * @description + * Route deletes certain log files. + * This deletes the log files with the given filenames. + */ + DeleteLogs: { + key: "STATUS-delete-logs", + methods: ["DELETE"], + endpoint: "/api/v1/logs", + }, + /** + * @description + * Route returns the content of the latest server log file. + * This returns the content of the most recent seanime- log file after flushing logs. + */ + GetLatestLogContent: { + key: "STATUS-get-latest-log-content", + methods: ["GET"], + endpoint: "/api/v1/logs/latest", + }, + /** + * @description + * Route returns the server announcements. + * This returns the announcements for the server. + */ + GetAnnouncements: { + key: "STATUS-get-announcements", + methods: ["POST"], + endpoint: "/api/v1/announcements", + }, + /** + * @description + * Route returns current memory statistics. + * This returns real-time memory usage statistics from the Go runtime. + */ + GetMemoryStats: { + key: "STATUS-get-memory-stats", + methods: ["GET"], + endpoint: "/api/v1/memory/stats", + }, + /** + * @description + * Route generates and returns a memory profile. + * This generates a memory profile that can be analyzed with go tool pprof. + * Query parameters: heap=true for heap profile, allocs=true for alloc profile. + */ + GetMemoryProfile: { + key: "STATUS-get-memory-profile", + methods: ["GET"], + endpoint: "/api/v1/memory/profile", + }, + /** + * @description + * Route generates and returns a goroutine profile. + * This generates a goroutine profile showing all running goroutines and their stack traces. + */ + GetGoRoutineProfile: { + key: "STATUS-get-go-routine-profile", + methods: ["GET"], + endpoint: "/api/v1/memory/goroutine", + }, + /** + * @description + * Route generates and returns a CPU profile. + * This generates a CPU profile for the specified duration (default 30 seconds). + * Query parameter: duration=30 for duration in seconds. + */ + GetCPUProfile: { + key: "STATUS-get-c-p-u-profile", + methods: ["GET"], + endpoint: "/api/v1/memory/cpu", + }, + /** + * @description + * Route forces garbage collection and returns memory stats. + * This forces a garbage collection cycle and returns the updated memory statistics. + */ + ForceGC: { + key: "STATUS-force-g-c", + methods: ["POST"], + endpoint: "/api/v1/memory/gc", + }, + }, + THEME: { + GetTheme: { + key: "THEME-get-theme", + methods: ["GET"], + endpoint: "/api/v1/theme", + }, + /** + * @description + * Route updates the theme settings. + * The server status should be re-fetched after this on the client. + */ + UpdateTheme: { + key: "THEME-update-theme", + methods: ["PATCH"], + endpoint: "/api/v1/theme", + }, + }, + TORRENT_CLIENT: { + /** + * @description + * Route returns all active torrents. + * This handler is used by the client to display the active torrents. + */ + GetActiveTorrentList: { + key: "TORRENT-CLIENT-get-active-torrent-list", + methods: ["GET"], + endpoint: "/api/v1/torrent-client/list", + }, + /** + * @description + * Route performs an action on a torrent. + * This handler is used to pause, resume or remove a torrent. + */ + TorrentClientAction: { + key: "TORRENT-CLIENT-torrent-client-action", + methods: ["POST"], + endpoint: "/api/v1/torrent-client/action", + }, + /** + * @description + * Route adds torrents to the torrent client. + * It fetches the magnets from the provided URLs and adds them to the torrent client. + * If smart select is enabled, it will try to select the best torrent based on the missing episodes. + */ + TorrentClientDownload: { + key: "TORRENT-CLIENT-torrent-client-download", + methods: ["POST"], + endpoint: "/api/v1/torrent-client/download", + }, + /** + * @description + * Route adds magnets to the torrent client based on the AutoDownloader item. + * This is used to download torrents that were queued by the AutoDownloader. + * The item will be removed from the queue if the magnet was added successfully. + * The AutoDownloader items should be re-fetched after this. + */ + TorrentClientAddMagnetFromRule: { + key: "TORRENT-CLIENT-torrent-client-add-magnet-from-rule", + methods: ["POST"], + endpoint: "/api/v1/torrent-client/rule-magnet", + }, + }, + TORRENT_SEARCH: { + /** + * @description + * Route searches torrents and returns a list of torrents and their previews. + * This will search for torrents and return a list of torrents with previews. + * If smart search is enabled, it will filter the torrents based on search parameters. + */ + SearchTorrent: { + key: "TORRENT-SEARCH-search-torrent", + methods: ["POST"], + endpoint: "/api/v1/torrent/search", + }, + }, + TORRENTSTREAM: { + /** + * @description + * Route get torrentstream settings. + * This returns the torrentstream settings. + */ + GetTorrentstreamSettings: { + key: "TORRENTSTREAM-get-torrentstream-settings", + methods: ["GET"], + endpoint: "/api/v1/torrentstream/settings", + }, + /** + * @description + * Route save torrentstream settings. + * This saves the torrentstream settings. + * The client should refetch the server status. + */ + SaveTorrentstreamSettings: { + key: "TORRENTSTREAM-save-torrentstream-settings", + methods: ["PATCH"], + endpoint: "/api/v1/torrentstream/settings", + }, + /** + * @description + * Route get list of torrent files from a batch + * This returns a list of file previews from the torrent + */ + GetTorrentstreamTorrentFilePreviews: { + key: "TORRENTSTREAM-get-torrentstream-torrent-file-previews", + methods: ["POST"], + endpoint: "/api/v1/torrentstream/torrent-file-previews", + }, + /** + * @description + * Route starts a torrent stream. + * This starts the entire streaming process. + */ + TorrentstreamStartStream: { + key: "TORRENTSTREAM-torrentstream-start-stream", + methods: ["POST"], + endpoint: "/api/v1/torrentstream/start", + }, + /** + * @description + * Route stop a torrent stream. + * This stops the entire streaming process and drops the torrent if it's below a threshold. + * This is made to be used while the stream is running. + */ + TorrentstreamStopStream: { + key: "TORRENTSTREAM-torrentstream-stop-stream", + methods: ["POST"], + endpoint: "/api/v1/torrentstream/stop", + }, + /** + * @description + * Route drops a torrent stream. + * This stops the entire streaming process and drops the torrent completely. + * This is made to be used to force drop a torrent. + */ + TorrentstreamDropTorrent: { + key: "TORRENTSTREAM-torrentstream-drop-torrent", + methods: ["POST"], + endpoint: "/api/v1/torrentstream/drop", + }, + /** + * @description + * Route returns the most recent batch selected. + * This returns the most recent batch selected. + */ + GetTorrentstreamBatchHistory: { + key: "TORRENTSTREAM-get-torrentstream-batch-history", + methods: ["POST"], + endpoint: "/api/v1/torrentstream/batch-history", + }, + }, +} satisfies ApiEndpoints + diff --git a/seanime-2.9.10/seanime-web/src/api/generated/hooks_template.ts b/seanime-2.9.10/seanime-web/src/api/generated/hooks_template.ts new file mode 100644 index 0000000..ab9a773 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/generated/hooks_template.ts @@ -0,0 +1,2401 @@ +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// anilist +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetAnimeCollection() { +// return useServerQuery<AL_AnimeCollection>({ +// endpoint: API_ENDPOINTS.ANILIST.GetAnimeCollection.endpoint, +// method: API_ENDPOINTS.ANILIST.GetAnimeCollection.methods[0], +// queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key], +// enabled: true, +// }) +// } + +// export function useGetAnimeCollection() { +// return useServerMutation<AL_AnimeCollection>({ +// endpoint: API_ENDPOINTS.ANILIST.GetAnimeCollection.endpoint, +// method: API_ENDPOINTS.ANILIST.GetAnimeCollection.methods[1], +// mutationKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetRawAnimeCollection() { +// return useServerQuery<AL_AnimeCollection>({ +// endpoint: API_ENDPOINTS.ANILIST.GetRawAnimeCollection.endpoint, +// method: API_ENDPOINTS.ANILIST.GetRawAnimeCollection.methods[0], +// queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key], +// enabled: true, +// }) +// } + +// export function useGetRawAnimeCollection() { +// return useServerMutation<AL_AnimeCollection>({ +// endpoint: API_ENDPOINTS.ANILIST.GetRawAnimeCollection.endpoint, +// method: API_ENDPOINTS.ANILIST.GetRawAnimeCollection.methods[1], +// mutationKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useEditAnilistListEntry() { +// return useServerMutation<true, EditAnilistListEntry_Variables>({ +// endpoint: API_ENDPOINTS.ANILIST.EditAnilistListEntry.endpoint, +// method: API_ENDPOINTS.ANILIST.EditAnilistListEntry.methods[0], +// mutationKey: [API_ENDPOINTS.ANILIST.EditAnilistListEntry.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetAnilistAnimeDetails(id: number) { +// return useServerQuery<AL_AnimeDetailsById_Media>({ +// endpoint: API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.methods[0], +// queryKey: [API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.key], +// enabled: true, +// }) +// } + +// export function useGetAnilistStudioDetails(id: number) { +// return useServerQuery<AL_StudioDetails>({ +// endpoint: API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.methods[0], +// queryKey: [API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.key], +// enabled: true, +// }) +// } + +// export function useDeleteAnilistListEntry() { +// return useServerMutation<boolean, DeleteAnilistListEntry_Variables>({ +// endpoint: API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.endpoint, +// method: API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.methods[0], +// mutationKey: [API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useAnilistListAnime() { +// return useServerMutation<AL_ListAnime, AnilistListAnime_Variables>({ +// endpoint: API_ENDPOINTS.ANILIST.AnilistListAnime.endpoint, +// method: API_ENDPOINTS.ANILIST.AnilistListAnime.methods[0], +// mutationKey: [API_ENDPOINTS.ANILIST.AnilistListAnime.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useAnilistListRecentAiringAnime() { +// return useServerMutation<AL_ListRecentAnime, AnilistListRecentAiringAnime_Variables>({ +// endpoint: API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.endpoint, +// method: API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.methods[0], +// mutationKey: [API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useAnilistListMissedSequels() { +// return useServerQuery<Array<AL_BaseAnime>>({ +// endpoint: API_ENDPOINTS.ANILIST.AnilistListMissedSequels.endpoint, +// method: API_ENDPOINTS.ANILIST.AnilistListMissedSequels.methods[0], +// queryKey: [API_ENDPOINTS.ANILIST.AnilistListMissedSequels.key], +// enabled: true, +// }) +// } + +// export function useGetAniListStats() { +// return useServerQuery<AL_Stats>({ +// endpoint: API_ENDPOINTS.ANILIST.GetAniListStats.endpoint, +// method: API_ENDPOINTS.ANILIST.GetAniListStats.methods[0], +// queryKey: [API_ENDPOINTS.ANILIST.GetAniListStats.key], +// enabled: true, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// anime +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetAnimeEpisodeCollection(id: number) { +// return useServerQuery<Anime_EpisodeCollection>({ +// endpoint: API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.methods[0], +// queryKey: [API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.key], +// enabled: true, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// anime_collection +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetLibraryCollection() { +// return useServerQuery<Anime_LibraryCollection>({ +// endpoint: API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.endpoint, +// method: API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.methods[0], +// queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key], +// enabled: true, +// }) +// } + +// export function useGetLibraryCollection() { +// return useServerMutation<Anime_LibraryCollection>({ +// endpoint: API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.endpoint, +// method: API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.methods[1], +// mutationKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetAnimeCollectionSchedule() { +// return useServerQuery<Array<Anime_ScheduleItem>>({ +// endpoint: API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.endpoint, +// method: API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.methods[0], +// queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.key], +// enabled: true, +// }) +// } + +// export function useAddUnknownMedia() { +// return useServerMutation<AL_AnimeCollection, AddUnknownMedia_Variables>({ +// endpoint: API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.endpoint, +// method: API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.methods[0], +// mutationKey: [API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// anime_entries +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetAnimeEntry(id: number) { +// return useServerQuery<Anime_Entry>({ +// endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.methods[0], +// queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key], +// enabled: true, +// }) +// } + +// export function useAnimeEntryBulkAction() { +// return useServerMutation<Array<Anime_LocalFile>, AnimeEntryBulkAction_Variables>({ +// endpoint: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.endpoint, +// method: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.methods[0], +// mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useOpenAnimeEntryInExplorer() { +// return useServerMutation<boolean, OpenAnimeEntryInExplorer_Variables>({ +// endpoint: API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.endpoint, +// method: API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.methods[0], +// mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useFetchAnimeEntrySuggestions() { +// return useServerMutation<Array<AL_BaseAnime>, FetchAnimeEntrySuggestions_Variables>({ +// endpoint: API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.endpoint, +// method: API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.methods[0], +// mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useAnimeEntryManualMatch() { +// return useServerMutation<Array<Anime_LocalFile>, AnimeEntryManualMatch_Variables>({ +// endpoint: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.endpoint, +// method: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.methods[0], +// mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetMissingEpisodes() { +// return useServerQuery<Anime_MissingEpisodes>({ +// endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.endpoint, +// method: API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.methods[0], +// queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key], +// enabled: true, +// }) +// } + +// export function useGetAnimeEntrySilenceStatus(id: number) { +// return useServerQuery<Models_SilencedMediaEntry>({ +// endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.methods[0], +// queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.key], +// enabled: true, +// }) +// } + +// export function useToggleAnimeEntrySilenceStatus() { +// return useServerMutation<boolean, ToggleAnimeEntrySilenceStatus_Variables>({ +// endpoint: API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.endpoint, +// method: API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.methods[0], +// mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useUpdateAnimeEntryProgress() { +// return useServerMutation<boolean, UpdateAnimeEntryProgress_Variables>({ +// endpoint: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.endpoint, +// method: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.methods[0], +// mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useUpdateAnimeEntryRepeat() { +// return useServerMutation<boolean, UpdateAnimeEntryRepeat_Variables>({ +// endpoint: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.endpoint, +// method: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.methods[0], +// mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// auth +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useLogin() { +// return useServerMutation<Status, Login_Variables>({ +// endpoint: API_ENDPOINTS.AUTH.Login.endpoint, +// method: API_ENDPOINTS.AUTH.Login.methods[0], +// mutationKey: [API_ENDPOINTS.AUTH.Login.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useLogout() { +// return useServerMutation<Status>({ +// endpoint: API_ENDPOINTS.AUTH.Logout.endpoint, +// method: API_ENDPOINTS.AUTH.Logout.methods[0], +// mutationKey: [API_ENDPOINTS.AUTH.Logout.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// auto_downloader +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useRunAutoDownloader() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.endpoint, +// method: API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.methods[0], +// mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetAutoDownloaderRule(id: number) { +// return useServerQuery<Anime_AutoDownloaderRule>({ +// endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.methods[0], +// queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.key], +// enabled: true, +// }) +// } + +// export function useGetAutoDownloaderRulesByAnime(id: number) { +// return useServerQuery<Array<Anime_AutoDownloaderRule>>({ +// endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.methods[0], +// queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key], +// enabled: true, +// }) +// } + +// export function useGetAutoDownloaderRules() { +// return useServerQuery<Array<Anime_AutoDownloaderRule>>({ +// endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.endpoint, +// method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.methods[0], +// queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key], +// enabled: true, +// }) +// } + +// export function useCreateAutoDownloaderRule() { +// return useServerMutation<Anime_AutoDownloaderRule, CreateAutoDownloaderRule_Variables>({ +// endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.endpoint, +// method: API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.methods[0], +// mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useUpdateAutoDownloaderRule() { +// return useServerMutation<Anime_AutoDownloaderRule, UpdateAutoDownloaderRule_Variables>({ +// endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.endpoint, +// method: API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.methods[0], +// mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDeleteAutoDownloaderRule(id: number) { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.methods[0], +// mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetAutoDownloaderItems() { +// return useServerQuery<Array<Models_AutoDownloaderItem>>({ +// endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.endpoint, +// method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.methods[0], +// queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key], +// enabled: true, +// }) +// } + +// export function useDeleteAutoDownloaderItem(id: number) { +// return useServerMutation<boolean, DeleteAutoDownloaderItem_Variables>({ +// endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.methods[0], +// mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// continuity +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useUpdateContinuityWatchHistoryItem() { +// return useServerMutation<boolean, UpdateContinuityWatchHistoryItem_Variables>({ +// endpoint: API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.endpoint, +// method: API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.methods[0], +// mutationKey: [API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetContinuityWatchHistoryItem(id: number) { +// return useServerQuery<Continuity_WatchHistoryItemResponse>({ +// endpoint: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.methods[0], +// queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.key], +// enabled: true, +// }) +// } + +// export function useGetContinuityWatchHistory() { +// return useServerQuery<Continuity_WatchHistory>({ +// endpoint: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.endpoint, +// method: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.methods[0], +// queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.key], +// enabled: true, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// debrid +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetDebridSettings() { +// return useServerQuery<Models_DebridSettings>({ +// endpoint: API_ENDPOINTS.DEBRID.GetDebridSettings.endpoint, +// method: API_ENDPOINTS.DEBRID.GetDebridSettings.methods[0], +// queryKey: [API_ENDPOINTS.DEBRID.GetDebridSettings.key], +// enabled: true, +// }) +// } + +// export function useSaveDebridSettings() { +// return useServerMutation<Models_DebridSettings, SaveDebridSettings_Variables>({ +// endpoint: API_ENDPOINTS.DEBRID.SaveDebridSettings.endpoint, +// method: API_ENDPOINTS.DEBRID.SaveDebridSettings.methods[0], +// mutationKey: [API_ENDPOINTS.DEBRID.SaveDebridSettings.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDebridAddTorrents() { +// return useServerMutation<boolean, DebridAddTorrents_Variables>({ +// endpoint: API_ENDPOINTS.DEBRID.DebridAddTorrents.endpoint, +// method: API_ENDPOINTS.DEBRID.DebridAddTorrents.methods[0], +// mutationKey: [API_ENDPOINTS.DEBRID.DebridAddTorrents.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDebridDownloadTorrent() { +// return useServerMutation<boolean, DebridDownloadTorrent_Variables>({ +// endpoint: API_ENDPOINTS.DEBRID.DebridDownloadTorrent.endpoint, +// method: API_ENDPOINTS.DEBRID.DebridDownloadTorrent.methods[0], +// mutationKey: [API_ENDPOINTS.DEBRID.DebridDownloadTorrent.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDebridCancelDownload() { +// return useServerMutation<boolean, DebridCancelDownload_Variables>({ +// endpoint: API_ENDPOINTS.DEBRID.DebridCancelDownload.endpoint, +// method: API_ENDPOINTS.DEBRID.DebridCancelDownload.methods[0], +// mutationKey: [API_ENDPOINTS.DEBRID.DebridCancelDownload.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDebridDeleteTorrent() { +// return useServerMutation<boolean, DebridDeleteTorrent_Variables>({ +// endpoint: API_ENDPOINTS.DEBRID.DebridDeleteTorrent.endpoint, +// method: API_ENDPOINTS.DEBRID.DebridDeleteTorrent.methods[0], +// mutationKey: [API_ENDPOINTS.DEBRID.DebridDeleteTorrent.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDebridGetTorrents() { +// return useServerQuery<Array<Debrid_TorrentItem>>({ +// endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrents.endpoint, +// method: API_ENDPOINTS.DEBRID.DebridGetTorrents.methods[0], +// queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrents.key], +// enabled: true, +// }) +// } + +// export function useDebridGetTorrentInfo() { +// return useServerMutation<Debrid_TorrentInfo, DebridGetTorrentInfo_Variables>({ +// endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.endpoint, +// method: API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.methods[0], +// mutationKey: [API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDebridGetTorrentFilePreviews() { +// return useServerMutation<Array<DebridClient_FilePreview>, DebridGetTorrentFilePreviews_Variables>({ +// endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.endpoint, +// method: API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.methods[0], +// mutationKey: [API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDebridStartStream() { +// return useServerMutation<boolean, DebridStartStream_Variables>({ +// endpoint: API_ENDPOINTS.DEBRID.DebridStartStream.endpoint, +// method: API_ENDPOINTS.DEBRID.DebridStartStream.methods[0], +// mutationKey: [API_ENDPOINTS.DEBRID.DebridStartStream.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDebridCancelStream() { +// return useServerMutation<boolean, DebridCancelStream_Variables>({ +// endpoint: API_ENDPOINTS.DEBRID.DebridCancelStream.endpoint, +// method: API_ENDPOINTS.DEBRID.DebridCancelStream.methods[0], +// mutationKey: [API_ENDPOINTS.DEBRID.DebridCancelStream.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// directory_selector +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useDirectorySelector() { +// return useServerMutation<DirectorySelectorResponse, DirectorySelector_Variables>({ +// endpoint: API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.endpoint, +// method: API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.methods[0], +// mutationKey: [API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// directstream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useDirectstreamPlayLocalFile() { +// return useServerMutation<Mediastream_MediaContainer, DirectstreamPlayLocalFile_Variables>({ +// endpoint: API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.endpoint, +// method: API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.methods[0], +// mutationKey: [API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// discord +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useSetDiscordMangaActivity() { +// return useServerMutation<boolean, SetDiscordMangaActivity_Variables>({ +// endpoint: API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.endpoint, +// method: API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.methods[0], +// mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useSetDiscordLegacyAnimeActivity() { +// return useServerMutation<boolean, SetDiscordLegacyAnimeActivity_Variables>({ +// endpoint: API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.endpoint, +// method: API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.methods[0], +// mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useSetDiscordAnimeActivityWithProgress() { +// return useServerMutation<boolean, SetDiscordAnimeActivityWithProgress_Variables>({ +// endpoint: API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.endpoint, +// method: API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.methods[0], +// mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useUpdateDiscordAnimeActivityWithProgress() { +// return useServerMutation<boolean, UpdateDiscordAnimeActivityWithProgress_Variables>({ +// endpoint: API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.endpoint, +// method: API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.methods[0], +// mutationKey: [API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useCancelDiscordActivity() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.DISCORD.CancelDiscordActivity.endpoint, +// method: API_ENDPOINTS.DISCORD.CancelDiscordActivity.methods[0], +// mutationKey: [API_ENDPOINTS.DISCORD.CancelDiscordActivity.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// docs +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetDocs() { +// return useServerQuery<Array<ApiDocsGroup>>({ +// endpoint: API_ENDPOINTS.DOCS.GetDocs.endpoint, +// method: API_ENDPOINTS.DOCS.GetDocs.methods[0], +// queryKey: [API_ENDPOINTS.DOCS.GetDocs.key], +// enabled: true, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// download +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useDownloadTorrentFile() { +// return useServerMutation<boolean, DownloadTorrentFile_Variables>({ +// endpoint: API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.endpoint, +// method: API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.methods[0], +// mutationKey: [API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDownloadRelease() { +// return useServerMutation<DownloadReleaseResponse, DownloadRelease_Variables>({ +// endpoint: API_ENDPOINTS.DOWNLOAD.DownloadRelease.endpoint, +// method: API_ENDPOINTS.DOWNLOAD.DownloadRelease.methods[0], +// mutationKey: [API_ENDPOINTS.DOWNLOAD.DownloadRelease.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// explorer +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useOpenInExplorer() { +// return useServerMutation<boolean, OpenInExplorer_Variables>({ +// endpoint: API_ENDPOINTS.EXPLORER.OpenInExplorer.endpoint, +// method: API_ENDPOINTS.EXPLORER.OpenInExplorer.methods[0], +// mutationKey: [API_ENDPOINTS.EXPLORER.OpenInExplorer.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// extensions +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useFetchExternalExtensionData() { +// return useServerMutation<Extension_Extension, FetchExternalExtensionData_Variables>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.methods[0], +// mutationKey: [API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useInstallExternalExtension() { +// return useServerMutation<ExtensionRepo_ExtensionInstallResponse, InstallExternalExtension_Variables>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.methods[0], +// mutationKey: [API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useUninstallExternalExtension() { +// return useServerMutation<boolean, UninstallExternalExtension_Variables>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.methods[0], +// mutationKey: [API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useUpdateExtensionCode() { +// return useServerMutation<boolean, UpdateExtensionCode_Variables>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.methods[0], +// mutationKey: [API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useReloadExternalExtensions() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.methods[0], +// mutationKey: [API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useReloadExternalExtension() { +// return useServerMutation<boolean, ReloadExternalExtension_Variables>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.methods[0], +// mutationKey: [API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useListExtensionData() { +// return useServerQuery<Array<Extension_Extension>>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.ListExtensionData.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.ListExtensionData.methods[0], +// queryKey: [API_ENDPOINTS.EXTENSIONS.ListExtensionData.key], +// enabled: true, +// }) +// } + +// export function useGetExtensionPayload() { +// return useServerQuery<string>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.methods[0], +// queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.key], +// enabled: true, +// }) +// } + +// export function useListDevelopmentModeExtensions() { +// return useServerQuery<Array<Extension_Extension>>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.methods[0], +// queryKey: [API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.key], +// enabled: true, +// }) +// } + +// export function useGetAllExtensions() { +// return useServerMutation<ExtensionRepo_AllExtensions, GetAllExtensions_Variables>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.GetAllExtensions.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.GetAllExtensions.methods[0], +// mutationKey: [API_ENDPOINTS.EXTENSIONS.GetAllExtensions.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetExtensionUpdateData() { +// return useServerQuery<Array<ExtensionRepo_UpdateData>>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.methods[0], +// queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.key], +// enabled: true, +// }) +// } + +// export function useListMangaProviderExtensions() { +// return useServerQuery<Array<ExtensionRepo_MangaProviderExtensionItem>>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.methods[0], +// queryKey: [API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.key], +// enabled: true, +// }) +// } + +// export function useListOnlinestreamProviderExtensions() { +// return useServerQuery<Array<ExtensionRepo_OnlinestreamProviderExtensionItem>>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.methods[0], +// queryKey: [API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.key], +// enabled: true, +// }) +// } + +// export function useListAnimeTorrentProviderExtensions() { +// return useServerQuery<Array<ExtensionRepo_AnimeTorrentProviderExtensionItem>>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.methods[0], +// queryKey: [API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.key], +// enabled: true, +// }) +// } + +// export function useGetPluginSettings() { +// return useServerQuery<ExtensionRepo_StoredPluginSettingsData>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.GetPluginSettings.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.GetPluginSettings.methods[0], +// queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key], +// enabled: true, +// }) +// } + +// export function useSetPluginSettingsPinnedTrays() { +// return useServerMutation<boolean, SetPluginSettingsPinnedTrays_Variables>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.methods[0], +// mutationKey: [API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGrantPluginPermissions() { +// return useServerMutation<boolean, GrantPluginPermissions_Variables>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.methods[0], +// mutationKey: [API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useRunExtensionPlaygroundCode() { +// return useServerMutation<RunPlaygroundCodeResponse, RunExtensionPlaygroundCode_Variables>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.methods[0], +// mutationKey: [API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetExtensionUserConfig() { +// return useServerQuery<ExtensionRepo_ExtensionUserConfig>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.methods[0], +// queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.key], +// enabled: true, +// }) +// } + +// export function useSaveExtensionUserConfig() { +// return useServerMutation<boolean, SaveExtensionUserConfig_Variables>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.methods[0], +// mutationKey: [API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetMarketplaceExtensions() { +// return useServerQuery<Array<Extension_Extension>>({ +// endpoint: API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.endpoint, +// method: API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.methods[0], +// queryKey: [API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.key], +// enabled: true, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// filecache +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetFileCacheTotalSize() { +// return useServerQuery<string>({ +// endpoint: API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.endpoint, +// method: API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.methods[0], +// queryKey: [API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.key], +// enabled: true, +// }) +// } + +// export function useRemoveFileCacheBucket() { +// return useServerMutation<boolean, RemoveFileCacheBucket_Variables>({ +// endpoint: API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.endpoint, +// method: API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.methods[0], +// mutationKey: [API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetFileCacheMediastreamVideoFilesTotalSize() { +// return useServerQuery<string>({ +// endpoint: API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.endpoint, +// method: API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.methods[0], +// queryKey: [API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.key], +// enabled: true, +// }) +// } + +// export function useClearFileCacheMediastreamVideoFiles() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.endpoint, +// method: API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.methods[0], +// mutationKey: [API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// local +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useSetOfflineMode() { +// return useServerMutation<boolean, SetOfflineMode_Variables>({ +// endpoint: API_ENDPOINTS.LOCAL.SetOfflineMode.endpoint, +// method: API_ENDPOINTS.LOCAL.SetOfflineMode.methods[0], +// mutationKey: [API_ENDPOINTS.LOCAL.SetOfflineMode.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useLocalGetTrackedMediaItems() { +// return useServerQuery<Array<Local_TrackedMediaItem>>({ +// endpoint: API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.endpoint, +// method: API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.methods[0], +// queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key], +// enabled: true, +// }) +// } + +// export function useLocalAddTrackedMedia() { +// return useServerMutation<boolean, LocalAddTrackedMedia_Variables>({ +// endpoint: API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.endpoint, +// method: API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.methods[0], +// mutationKey: [API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useLocalRemoveTrackedMedia() { +// return useServerMutation<boolean, LocalRemoveTrackedMedia_Variables>({ +// endpoint: API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.endpoint, +// method: API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.methods[0], +// mutationKey: [API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useLocalGetIsMediaTracked(id: number, type: string) { +// return useServerQuery<boolean>({ +// endpoint: API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.endpoint.replace("{id}", String(id)).replace("{type}", String(type)), +// method: API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.methods[0], +// queryKey: [API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.key], +// enabled: true, +// }) +// } + +// export function useLocalSyncData() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.LOCAL.LocalSyncData.endpoint, +// method: API_ENDPOINTS.LOCAL.LocalSyncData.methods[0], +// mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncData.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useLocalGetSyncQueueState() { +// return useServerQuery<Local_QueueState>({ +// endpoint: API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.endpoint, +// method: API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.methods[0], +// queryKey: [API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.key], +// enabled: true, +// }) +// } + +// export function useLocalSyncAnilistData() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.LOCAL.LocalSyncAnilistData.endpoint, +// method: API_ENDPOINTS.LOCAL.LocalSyncAnilistData.methods[0], +// mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncAnilistData.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useLocalSetHasLocalChanges() { +// return useServerMutation<boolean, LocalSetHasLocalChanges_Variables>({ +// endpoint: API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.endpoint, +// method: API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.methods[0], +// mutationKey: [API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useLocalGetHasLocalChanges() { +// return useServerQuery<boolean>({ +// endpoint: API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.endpoint, +// method: API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.methods[0], +// queryKey: [API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.key], +// enabled: true, +// }) +// } + +// export function useLocalGetLocalStorageSize() { +// return useServerQuery<string>({ +// endpoint: API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.endpoint, +// method: API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.methods[0], +// queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.key], +// enabled: true, +// }) +// } + +// export function useLocalSyncSimulatedDataToAnilist() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.endpoint, +// method: API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.methods[0], +// mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// localfiles +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetLocalFiles() { +// return useServerQuery<Array<Anime_LocalFile>>({ +// endpoint: API_ENDPOINTS.LOCALFILES.GetLocalFiles.endpoint, +// method: API_ENDPOINTS.LOCALFILES.GetLocalFiles.methods[0], +// queryKey: [API_ENDPOINTS.LOCALFILES.GetLocalFiles.key], +// enabled: true, +// }) +// } + +// export function useImportLocalFiles() { +// return useServerMutation<boolean, ImportLocalFiles_Variables>({ +// endpoint: API_ENDPOINTS.LOCALFILES.ImportLocalFiles.endpoint, +// method: API_ENDPOINTS.LOCALFILES.ImportLocalFiles.methods[0], +// mutationKey: [API_ENDPOINTS.LOCALFILES.ImportLocalFiles.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useLocalFileBulkAction() { +// return useServerMutation<Array<Anime_LocalFile>, LocalFileBulkAction_Variables>({ +// endpoint: API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.endpoint, +// method: API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.methods[0], +// mutationKey: [API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useUpdateLocalFileData() { +// return useServerMutation<Array<Anime_LocalFile>, UpdateLocalFileData_Variables>({ +// endpoint: API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.endpoint, +// method: API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.methods[0], +// mutationKey: [API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useUpdateLocalFiles() { +// return useServerMutation<boolean, UpdateLocalFiles_Variables>({ +// endpoint: API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.endpoint, +// method: API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.methods[0], +// mutationKey: [API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDeleteLocalFiles() { +// return useServerMutation<boolean, DeleteLocalFiles_Variables>({ +// endpoint: API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.endpoint, +// method: API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.methods[0], +// mutationKey: [API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useRemoveEmptyDirectories() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.endpoint, +// method: API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.methods[0], +// mutationKey: [API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// mal +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useMALAuth() { +// return useServerMutation<MalAuthResponse, MALAuth_Variables>({ +// endpoint: API_ENDPOINTS.MAL.MALAuth.endpoint, +// method: API_ENDPOINTS.MAL.MALAuth.methods[0], +// mutationKey: [API_ENDPOINTS.MAL.MALAuth.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useEditMALListEntryProgress() { +// return useServerMutation<boolean, EditMALListEntryProgress_Variables>({ +// endpoint: API_ENDPOINTS.MAL.EditMALListEntryProgress.endpoint, +// method: API_ENDPOINTS.MAL.EditMALListEntryProgress.methods[0], +// mutationKey: [API_ENDPOINTS.MAL.EditMALListEntryProgress.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useMALLogout() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.MAL.MALLogout.endpoint, +// method: API_ENDPOINTS.MAL.MALLogout.methods[0], +// mutationKey: [API_ENDPOINTS.MAL.MALLogout.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// manga +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetAnilistMangaCollection() { +// return useServerQuery<AL_MangaCollection, GetAnilistMangaCollection_Variables>({ +// endpoint: API_ENDPOINTS.MANGA.GetAnilistMangaCollection.endpoint, +// method: API_ENDPOINTS.MANGA.GetAnilistMangaCollection.methods[0], +// queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key], +// enabled: true, +// }) +// } + +// export function useGetRawAnilistMangaCollection() { +// return useServerQuery<AL_MangaCollection>({ +// endpoint: API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.endpoint, +// method: API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.methods[0], +// queryKey: [API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.key], +// enabled: true, +// }) +// } + +// export function useGetRawAnilistMangaCollection() { +// return useServerMutation<AL_MangaCollection>({ +// endpoint: API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.endpoint, +// method: API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.methods[1], +// mutationKey: [API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetMangaCollection() { +// return useServerQuery<Manga_Collection>({ +// endpoint: API_ENDPOINTS.MANGA.GetMangaCollection.endpoint, +// method: API_ENDPOINTS.MANGA.GetMangaCollection.methods[0], +// queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key], +// enabled: true, +// }) +// } + +// export function useGetMangaEntry(id: number) { +// return useServerQuery<Manga_Entry>({ +// endpoint: API_ENDPOINTS.MANGA.GetMangaEntry.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.MANGA.GetMangaEntry.methods[0], +// queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key], +// enabled: true, +// }) +// } + +// export function useGetMangaEntryDetails(id: number) { +// return useServerQuery<AL_MangaDetailsById_Media>({ +// endpoint: API_ENDPOINTS.MANGA.GetMangaEntryDetails.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.MANGA.GetMangaEntryDetails.methods[0], +// queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryDetails.key], +// enabled: true, +// }) +// } + +// export function useGetMangaLatestChapterNumbersMap() { +// return useServerQuery<Record<number, Array<Manga_MangaLatestChapterNumberItem>>>({ +// endpoint: API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.endpoint, +// method: API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.methods[0], +// queryKey: [API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.key], +// enabled: true, +// }) +// } + +// export function useRefetchMangaChapterContainers() { +// return useServerMutation<boolean, RefetchMangaChapterContainers_Variables>({ +// endpoint: API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.endpoint, +// method: API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useEmptyMangaEntryCache() { +// return useServerMutation<boolean, EmptyMangaEntryCache_Variables>({ +// endpoint: API_ENDPOINTS.MANGA.EmptyMangaEntryCache.endpoint, +// method: API_ENDPOINTS.MANGA.EmptyMangaEntryCache.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA.EmptyMangaEntryCache.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetMangaEntryChapters() { +// return useServerMutation<Manga_ChapterContainer, GetMangaEntryChapters_Variables>({ +// endpoint: API_ENDPOINTS.MANGA.GetMangaEntryChapters.endpoint, +// method: API_ENDPOINTS.MANGA.GetMangaEntryChapters.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetMangaEntryPages() { +// return useServerMutation<Manga_PageContainer, GetMangaEntryPages_Variables>({ +// endpoint: API_ENDPOINTS.MANGA.GetMangaEntryPages.endpoint, +// method: API_ENDPOINTS.MANGA.GetMangaEntryPages.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetMangaEntryDownloadedChapters(id: number) { +// return useServerQuery<Array<Manga_ChapterContainer>>({ +// endpoint: API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.methods[0], +// queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.key], +// enabled: true, +// }) +// } + +// export function useAnilistListManga() { +// return useServerMutation<AL_ListManga, AnilistListManga_Variables>({ +// endpoint: API_ENDPOINTS.MANGA.AnilistListManga.endpoint, +// method: API_ENDPOINTS.MANGA.AnilistListManga.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA.AnilistListManga.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useUpdateMangaProgress() { +// return useServerMutation<boolean, UpdateMangaProgress_Variables>({ +// endpoint: API_ENDPOINTS.MANGA.UpdateMangaProgress.endpoint, +// method: API_ENDPOINTS.MANGA.UpdateMangaProgress.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA.UpdateMangaProgress.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useMangaManualSearch() { +// return useServerMutation<Array<HibikeManga_SearchResult>, MangaManualSearch_Variables>({ +// endpoint: API_ENDPOINTS.MANGA.MangaManualSearch.endpoint, +// method: API_ENDPOINTS.MANGA.MangaManualSearch.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA.MangaManualSearch.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useMangaManualMapping() { +// return useServerMutation<boolean, MangaManualMapping_Variables>({ +// endpoint: API_ENDPOINTS.MANGA.MangaManualMapping.endpoint, +// method: API_ENDPOINTS.MANGA.MangaManualMapping.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA.MangaManualMapping.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetMangaMapping() { +// return useServerMutation<Manga_MappingResponse, GetMangaMapping_Variables>({ +// endpoint: API_ENDPOINTS.MANGA.GetMangaMapping.endpoint, +// method: API_ENDPOINTS.MANGA.GetMangaMapping.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA.GetMangaMapping.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useRemoveMangaMapping() { +// return useServerMutation<boolean, RemoveMangaMapping_Variables>({ +// endpoint: API_ENDPOINTS.MANGA.RemoveMangaMapping.endpoint, +// method: API_ENDPOINTS.MANGA.RemoveMangaMapping.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA.RemoveMangaMapping.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetLocalMangaPage() { +// return useServerQuery<Manga_PageContainer>({ +// endpoint: API_ENDPOINTS.MANGA.GetLocalMangaPage.endpoint, +// method: API_ENDPOINTS.MANGA.GetLocalMangaPage.methods[0], +// queryKey: [API_ENDPOINTS.MANGA.GetLocalMangaPage.key], +// enabled: true, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// manga_download +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useDownloadMangaChapters() { +// return useServerMutation<boolean, DownloadMangaChapters_Variables>({ +// endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.endpoint, +// method: API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetMangaDownloadData() { +// return useServerMutation<Manga_MediaDownloadData, GetMangaDownloadData_Variables>({ +// endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.endpoint, +// method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetMangaDownloadQueue() { +// return useServerQuery<Array<Models_ChapterDownloadQueueItem>>({ +// endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.endpoint, +// method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.methods[0], +// queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key], +// enabled: true, +// }) +// } + +// export function useStartMangaDownloadQueue() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.endpoint, +// method: API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useStopMangaDownloadQueue() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.endpoint, +// method: API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useClearAllChapterDownloadQueue() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.endpoint, +// method: API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useResetErroredChapterDownloadQueue() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.endpoint, +// method: API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDeleteMangaDownloadedChapters() { +// return useServerMutation<boolean, DeleteMangaDownloadedChapters_Variables>({ +// endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.endpoint, +// method: API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.methods[0], +// mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetMangaDownloadsList() { +// return useServerQuery<Array<Manga_DownloadListItem>>({ +// endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.endpoint, +// method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.methods[0], +// queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.key], +// enabled: true, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// manual_dump +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useTestDump() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.MANUAL_DUMP.TestDump.endpoint, +// method: API_ENDPOINTS.MANUAL_DUMP.TestDump.methods[0], +// mutationKey: [API_ENDPOINTS.MANUAL_DUMP.TestDump.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// mediaplayer +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useStartDefaultMediaPlayer() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.endpoint, +// method: API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.methods[0], +// mutationKey: [API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// mediastream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetMediastreamSettings() { +// return useServerQuery<Models_MediastreamSettings>({ +// endpoint: API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.endpoint, +// method: API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.methods[0], +// queryKey: [API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.key], +// enabled: true, +// }) +// } + +// export function useSaveMediastreamSettings() { +// return useServerMutation<Models_MediastreamSettings, SaveMediastreamSettings_Variables>({ +// endpoint: API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.endpoint, +// method: API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.methods[0], +// mutationKey: [API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useRequestMediastreamMediaContainer() { +// return useServerMutation<Mediastream_MediaContainer, RequestMediastreamMediaContainer_Variables>({ +// endpoint: API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.endpoint, +// method: API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.methods[0], +// mutationKey: [API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function usePreloadMediastreamMediaContainer() { +// return useServerMutation<boolean, PreloadMediastreamMediaContainer_Variables>({ +// endpoint: API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.endpoint, +// method: API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.methods[0], +// mutationKey: [API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useMediastreamShutdownTranscodeStream() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.endpoint, +// method: API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.methods[0], +// mutationKey: [API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// metadata +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function usePopulateFillerData() { +// return useServerMutation<true, PopulateFillerData_Variables>({ +// endpoint: API_ENDPOINTS.METADATA.PopulateFillerData.endpoint, +// method: API_ENDPOINTS.METADATA.PopulateFillerData.methods[0], +// mutationKey: [API_ENDPOINTS.METADATA.PopulateFillerData.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useRemoveFillerData() { +// return useServerMutation<boolean, RemoveFillerData_Variables>({ +// endpoint: API_ENDPOINTS.METADATA.RemoveFillerData.endpoint, +// method: API_ENDPOINTS.METADATA.RemoveFillerData.methods[0], +// mutationKey: [API_ENDPOINTS.METADATA.RemoveFillerData.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// nakama +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useNakamaWebSocket() { +// return useServerQuery<boolean>({ +// endpoint: API_ENDPOINTS.NAKAMA.NakamaWebSocket.endpoint, +// method: API_ENDPOINTS.NAKAMA.NakamaWebSocket.methods[0], +// queryKey: [API_ENDPOINTS.NAKAMA.NakamaWebSocket.key], +// enabled: true, +// }) +// } + +// export function useSendNakamaMessage() { +// return useServerMutation<Nakama_MessageResponse, SendNakamaMessage_Variables>({ +// endpoint: API_ENDPOINTS.NAKAMA.SendNakamaMessage.endpoint, +// method: API_ENDPOINTS.NAKAMA.SendNakamaMessage.methods[0], +// mutationKey: [API_ENDPOINTS.NAKAMA.SendNakamaMessage.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetNakamaAnimeLibrary() { +// return useServerQuery<Nakama_NakamaAnimeLibrary>({ +// endpoint: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibrary.endpoint, +// method: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibrary.methods[0], +// queryKey: [API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibrary.key], +// enabled: true, +// }) +// } + +// export function useGetNakamaAnimeLibraryCollection() { +// return useServerQuery<Anime_LibraryCollection>({ +// endpoint: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.endpoint, +// method: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.methods[0], +// queryKey: [API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.key], +// enabled: true, +// }) +// } + +// export function useGetNakamaAnimeLibraryFiles(id: number) { +// return useServerMutation<Array<Anime_LocalFile>>({ +// endpoint: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryFiles.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryFiles.methods[0], +// mutationKey: [API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryFiles.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetNakamaAnimeAllLibraryFiles() { +// return useServerMutation<Array<Anime_LocalFile>>({ +// endpoint: API_ENDPOINTS.NAKAMA.GetNakamaAnimeAllLibraryFiles.endpoint, +// method: API_ENDPOINTS.NAKAMA.GetNakamaAnimeAllLibraryFiles.methods[0], +// mutationKey: [API_ENDPOINTS.NAKAMA.GetNakamaAnimeAllLibraryFiles.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useNakamaPlayVideo() { +// return useServerMutation<boolean, NakamaPlayVideo_Variables>({ +// endpoint: API_ENDPOINTS.NAKAMA.NakamaPlayVideo.endpoint, +// method: API_ENDPOINTS.NAKAMA.NakamaPlayVideo.methods[0], +// mutationKey: [API_ENDPOINTS.NAKAMA.NakamaPlayVideo.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useNakamaReconnectToHost() { +// return useServerMutation<Nakama_MessageResponse>({ +// endpoint: API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.endpoint, +// method: API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.methods[0], +// mutationKey: [API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useNakamaRemoveStaleConnections() { +// return useServerMutation<Nakama_MessageResponse>({ +// endpoint: API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.endpoint, +// method: API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.methods[0], +// mutationKey: [API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useNakamaCreateWatchParty() { +// return useServerMutation<boolean, NakamaCreateWatchParty_Variables>({ +// endpoint: API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.endpoint, +// method: API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.methods[0], +// mutationKey: [API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useNakamaJoinWatchParty() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.endpoint, +// method: API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.methods[0], +// mutationKey: [API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useNakamaLeaveWatchParty() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.endpoint, +// method: API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.methods[0], +// mutationKey: [API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// onlinestream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetOnlineStreamEpisodeList() { +// return useServerMutation<Onlinestream_EpisodeListResponse, GetOnlineStreamEpisodeList_Variables>({ +// endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.endpoint, +// method: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.methods[0], +// mutationKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetOnlineStreamEpisodeSource() { +// return useServerMutation<Onlinestream_EpisodeSource, GetOnlineStreamEpisodeSource_Variables>({ +// endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.endpoint, +// method: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.methods[0], +// mutationKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useOnlineStreamEmptyCache() { +// return useServerMutation<boolean, OnlineStreamEmptyCache_Variables>({ +// endpoint: API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.endpoint, +// method: API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.methods[0], +// mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useOnlinestreamManualSearch() { +// return useServerMutation<Array<HibikeOnlinestream_SearchResult>, OnlinestreamManualSearch_Variables>({ +// endpoint: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.endpoint, +// method: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.methods[0], +// mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useOnlinestreamManualMapping() { +// return useServerMutation<boolean, OnlinestreamManualMapping_Variables>({ +// endpoint: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.endpoint, +// method: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.methods[0], +// mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetOnlinestreamMapping() { +// return useServerMutation<Onlinestream_MappingResponse, GetOnlinestreamMapping_Variables>({ +// endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.endpoint, +// method: API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.methods[0], +// mutationKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useRemoveOnlinestreamMapping() { +// return useServerMutation<boolean, RemoveOnlinestreamMapping_Variables>({ +// endpoint: API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.endpoint, +// method: API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.methods[0], +// mutationKey: [API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// playback_manager +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function usePlaybackPlayVideo() { +// return useServerMutation<boolean, PlaybackPlayVideo_Variables>({ +// endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.endpoint, +// method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function usePlaybackPlayRandomVideo() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.endpoint, +// method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function usePlaybackSyncCurrentProgress() { +// return useServerMutation<number>({ +// endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.endpoint, +// method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function usePlaybackPlayNextEpisode() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.endpoint, +// method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function usePlaybackGetNextEpisode() { +// return useServerQuery<Anime_LocalFile>({ +// endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.endpoint, +// method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.methods[0], +// queryKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.key], +// enabled: true, +// }) +// } + +// export function usePlaybackAutoPlayNextEpisode() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.endpoint, +// method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function usePlaybackStartPlaylist() { +// return useServerMutation<boolean, PlaybackStartPlaylist_Variables>({ +// endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.endpoint, +// method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function usePlaybackCancelCurrentPlaylist() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.endpoint, +// method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function usePlaybackPlaylistNext() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.endpoint, +// method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function usePlaybackStartManualTracking() { +// return useServerMutation<boolean, PlaybackStartManualTracking_Variables>({ +// endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.endpoint, +// method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function usePlaybackCancelManualTracking() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.endpoint, +// method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// playlist +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useCreatePlaylist() { +// return useServerMutation<Anime_Playlist, CreatePlaylist_Variables>({ +// endpoint: API_ENDPOINTS.PLAYLIST.CreatePlaylist.endpoint, +// method: API_ENDPOINTS.PLAYLIST.CreatePlaylist.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYLIST.CreatePlaylist.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetPlaylists() { +// return useServerQuery<Array<Anime_Playlist>>({ +// endpoint: API_ENDPOINTS.PLAYLIST.GetPlaylists.endpoint, +// method: API_ENDPOINTS.PLAYLIST.GetPlaylists.methods[0], +// queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key], +// enabled: true, +// }) +// } + +// export function useUpdatePlaylist(id: number) { +// return useServerMutation<Anime_Playlist, UpdatePlaylist_Variables>({ +// endpoint: API_ENDPOINTS.PLAYLIST.UpdatePlaylist.endpoint.replace("{id}", String(id)), +// method: API_ENDPOINTS.PLAYLIST.UpdatePlaylist.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYLIST.UpdatePlaylist.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDeletePlaylist() { +// return useServerMutation<boolean, DeletePlaylist_Variables>({ +// endpoint: API_ENDPOINTS.PLAYLIST.DeletePlaylist.endpoint, +// method: API_ENDPOINTS.PLAYLIST.DeletePlaylist.methods[0], +// mutationKey: [API_ENDPOINTS.PLAYLIST.DeletePlaylist.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetPlaylistEpisodes(id: number, progress: number) { +// return useServerQuery<Array<Anime_LocalFile>>({ +// endpoint: API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.endpoint.replace("{id}", String(id)).replace("{progress}", String(progress)), +// method: API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.methods[0], +// queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.key], +// enabled: true, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// releases +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useInstallLatestUpdate() { +// return useServerMutation<Status, InstallLatestUpdate_Variables>({ +// endpoint: API_ENDPOINTS.RELEASES.InstallLatestUpdate.endpoint, +// method: API_ENDPOINTS.RELEASES.InstallLatestUpdate.methods[0], +// mutationKey: [API_ENDPOINTS.RELEASES.InstallLatestUpdate.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetLatestUpdate() { +// return useServerQuery<Updater_Update>({ +// endpoint: API_ENDPOINTS.RELEASES.GetLatestUpdate.endpoint, +// method: API_ENDPOINTS.RELEASES.GetLatestUpdate.methods[0], +// queryKey: [API_ENDPOINTS.RELEASES.GetLatestUpdate.key], +// enabled: true, +// }) +// } + +// export function useGetChangelog() { +// return useServerQuery<string>({ +// endpoint: API_ENDPOINTS.RELEASES.GetChangelog.endpoint, +// method: API_ENDPOINTS.RELEASES.GetChangelog.methods[0], +// queryKey: [API_ENDPOINTS.RELEASES.GetChangelog.key], +// enabled: true, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// report +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useSaveIssueReport() { +// return useServerMutation<boolean, SaveIssueReport_Variables>({ +// endpoint: API_ENDPOINTS.REPORT.SaveIssueReport.endpoint, +// method: API_ENDPOINTS.REPORT.SaveIssueReport.methods[0], +// mutationKey: [API_ENDPOINTS.REPORT.SaveIssueReport.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useDownloadIssueReport() { +// return useServerQuery<Report_IssueReport>({ +// endpoint: API_ENDPOINTS.REPORT.DownloadIssueReport.endpoint, +// method: API_ENDPOINTS.REPORT.DownloadIssueReport.methods[0], +// queryKey: [API_ENDPOINTS.REPORT.DownloadIssueReport.key], +// enabled: true, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// scan +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useScanLocalFiles() { +// return useServerMutation<Array<Anime_LocalFile>, ScanLocalFiles_Variables>({ +// endpoint: API_ENDPOINTS.SCAN.ScanLocalFiles.endpoint, +// method: API_ENDPOINTS.SCAN.ScanLocalFiles.methods[0], +// mutationKey: [API_ENDPOINTS.SCAN.ScanLocalFiles.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// scan_summary +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetScanSummaries() { +// return useServerQuery<Array<Summary_ScanSummaryItem>>({ +// endpoint: API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.endpoint, +// method: API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.methods[0], +// queryKey: [API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.key], +// enabled: true, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// settings +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetSettings() { +// return useServerQuery<Models_Settings>({ +// endpoint: API_ENDPOINTS.SETTINGS.GetSettings.endpoint, +// method: API_ENDPOINTS.SETTINGS.GetSettings.methods[0], +// queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key], +// enabled: true, +// }) +// } + +// export function useGettingStarted() { +// return useServerMutation<Status, GettingStarted_Variables>({ +// endpoint: API_ENDPOINTS.SETTINGS.GettingStarted.endpoint, +// method: API_ENDPOINTS.SETTINGS.GettingStarted.methods[0], +// mutationKey: [API_ENDPOINTS.SETTINGS.GettingStarted.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useSaveSettings() { +// return useServerMutation<Status, SaveSettings_Variables>({ +// endpoint: API_ENDPOINTS.SETTINGS.SaveSettings.endpoint, +// method: API_ENDPOINTS.SETTINGS.SaveSettings.methods[0], +// mutationKey: [API_ENDPOINTS.SETTINGS.SaveSettings.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useSaveAutoDownloaderSettings() { +// return useServerMutation<boolean, SaveAutoDownloaderSettings_Variables>({ +// endpoint: API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.endpoint, +// method: API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.methods[0], +// mutationKey: [API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// status +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetStatus() { +// return useServerQuery<Status>({ +// endpoint: API_ENDPOINTS.STATUS.GetStatus.endpoint, +// method: API_ENDPOINTS.STATUS.GetStatus.methods[0], +// queryKey: [API_ENDPOINTS.STATUS.GetStatus.key], +// enabled: true, +// }) +// } + +// export function useGetLogFilenames() { +// return useServerQuery<Array<string>>({ +// endpoint: API_ENDPOINTS.STATUS.GetLogFilenames.endpoint, +// method: API_ENDPOINTS.STATUS.GetLogFilenames.methods[0], +// queryKey: [API_ENDPOINTS.STATUS.GetLogFilenames.key], +// enabled: true, +// }) +// } + +// export function useDeleteLogs() { +// return useServerMutation<boolean, DeleteLogs_Variables>({ +// endpoint: API_ENDPOINTS.STATUS.DeleteLogs.endpoint, +// method: API_ENDPOINTS.STATUS.DeleteLogs.methods[0], +// mutationKey: [API_ENDPOINTS.STATUS.DeleteLogs.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetLatestLogContent() { +// return useServerQuery<string>({ +// endpoint: API_ENDPOINTS.STATUS.GetLatestLogContent.endpoint, +// method: API_ENDPOINTS.STATUS.GetLatestLogContent.methods[0], +// queryKey: [API_ENDPOINTS.STATUS.GetLatestLogContent.key], +// enabled: true, +// }) +// } + +// export function useGetAnnouncements() { +// return useServerMutation<Array<Updater_Announcement>, GetAnnouncements_Variables>({ +// endpoint: API_ENDPOINTS.STATUS.GetAnnouncements.endpoint, +// method: API_ENDPOINTS.STATUS.GetAnnouncements.methods[0], +// mutationKey: [API_ENDPOINTS.STATUS.GetAnnouncements.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetMemoryStats() { +// return useServerQuery<MemoryStatsResponse>({ +// endpoint: API_ENDPOINTS.STATUS.GetMemoryStats.endpoint, +// method: API_ENDPOINTS.STATUS.GetMemoryStats.methods[0], +// queryKey: [API_ENDPOINTS.STATUS.GetMemoryStats.key], +// enabled: true, +// }) +// } + +// export function useGetMemoryProfile() { +// return useServerQuery<null>({ +// endpoint: API_ENDPOINTS.STATUS.GetMemoryProfile.endpoint, +// method: API_ENDPOINTS.STATUS.GetMemoryProfile.methods[0], +// queryKey: [API_ENDPOINTS.STATUS.GetMemoryProfile.key], +// enabled: true, +// }) +// } + +// export function useGetGoRoutineProfile() { +// return useServerQuery<null>({ +// endpoint: API_ENDPOINTS.STATUS.GetGoRoutineProfile.endpoint, +// method: API_ENDPOINTS.STATUS.GetGoRoutineProfile.methods[0], +// queryKey: [API_ENDPOINTS.STATUS.GetGoRoutineProfile.key], +// enabled: true, +// }) +// } + +// export function useGetCPUProfile() { +// return useServerQuery<null>({ +// endpoint: API_ENDPOINTS.STATUS.GetCPUProfile.endpoint, +// method: API_ENDPOINTS.STATUS.GetCPUProfile.methods[0], +// queryKey: [API_ENDPOINTS.STATUS.GetCPUProfile.key], +// enabled: true, +// }) +// } + +// export function useForceGC() { +// return useServerMutation<MemoryStatsResponse>({ +// endpoint: API_ENDPOINTS.STATUS.ForceGC.endpoint, +// method: API_ENDPOINTS.STATUS.ForceGC.methods[0], +// mutationKey: [API_ENDPOINTS.STATUS.ForceGC.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// theme +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetTheme() { +// return useServerQuery<Models_Theme>({ +// endpoint: API_ENDPOINTS.THEME.GetTheme.endpoint, +// method: API_ENDPOINTS.THEME.GetTheme.methods[0], +// queryKey: [API_ENDPOINTS.THEME.GetTheme.key], +// enabled: true, +// }) +// } + +// export function useUpdateTheme() { +// return useServerMutation<Models_Theme, UpdateTheme_Variables>({ +// endpoint: API_ENDPOINTS.THEME.UpdateTheme.endpoint, +// method: API_ENDPOINTS.THEME.UpdateTheme.methods[0], +// mutationKey: [API_ENDPOINTS.THEME.UpdateTheme.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// torrent_client +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetActiveTorrentList() { +// return useServerQuery<Array<TorrentClient_Torrent>>({ +// endpoint: API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.endpoint, +// method: API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.methods[0], +// queryKey: [API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.key], +// enabled: true, +// }) +// } + +// export function useTorrentClientAction() { +// return useServerMutation<boolean, TorrentClientAction_Variables>({ +// endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.endpoint, +// method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.methods[0], +// mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useTorrentClientDownload() { +// return useServerMutation<boolean, TorrentClientDownload_Variables>({ +// endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.endpoint, +// method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.methods[0], +// mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useTorrentClientAddMagnetFromRule() { +// return useServerMutation<boolean, TorrentClientAddMagnetFromRule_Variables>({ +// endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.endpoint, +// method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.methods[0], +// mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// torrent_search +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useSearchTorrent() { +// return useServerMutation<Torrent_SearchData, SearchTorrent_Variables>({ +// endpoint: API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.endpoint, +// method: API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.methods[0], +// mutationKey: [API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// torrentstream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// export function useGetTorrentstreamSettings() { +// return useServerQuery<Models_TorrentstreamSettings>({ +// endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.endpoint, +// method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.methods[0], +// queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.key], +// enabled: true, +// }) +// } + +// export function useSaveTorrentstreamSettings() { +// return useServerMutation<Models_TorrentstreamSettings, SaveTorrentstreamSettings_Variables>({ +// endpoint: API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.endpoint, +// method: API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.methods[0], +// mutationKey: [API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetTorrentstreamTorrentFilePreviews() { +// return useServerMutation<Array<Torrentstream_FilePreview>, GetTorrentstreamTorrentFilePreviews_Variables>({ +// endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.endpoint, +// method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.methods[0], +// mutationKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useTorrentstreamStartStream() { +// return useServerMutation<boolean, TorrentstreamStartStream_Variables>({ +// endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.endpoint, +// method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.methods[0], +// mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useTorrentstreamStopStream() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.endpoint, +// method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.methods[0], +// mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useTorrentstreamDropTorrent() { +// return useServerMutation<boolean>({ +// endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.endpoint, +// method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.methods[0], +// mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.key], +// onSuccess: async () => { +// +// }, +// }) +// } + +// export function useGetTorrentstreamBatchHistory() { +// return useServerMutation<Torrentstream_BatchHistoryResponse, GetTorrentstreamBatchHistory_Variables>({ +// endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.endpoint, +// method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.methods[0], +// mutationKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.key], +// onSuccess: async () => { +// +// }, +// }) +// } + diff --git a/seanime-2.9.10/seanime-web/src/api/generated/queries_tmpl.ts b/seanime-2.9.10/seanime-web/src/api/generated/queries_tmpl.ts new file mode 100644 index 0000000..db927d8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/generated/queries_tmpl.ts @@ -0,0 +1,31 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { useQueryClient } from "@tanstack/react-query" + +type QueryHookProps = { + key: any[] +} + +export function useTemplateQuery(props: QueryHookProps) { + return useServerQuery({ + endpoint: API_ENDPOINTS.DOCS.GetDocs.endpoint, + method: API_ENDPOINTS.DOCS.GetDocs.methods[0], + queryKey: [API_ENDPOINTS.DOCS.GetDocs.key, ...props.key], + }) +} + +// type MutationHookProps = { +// onSuccess +// } + +export function useTemplateMutation() { + const queryClient = useQueryClient() + return useServerMutation({ + endpoint: API_ENDPOINTS.DOCS.GetDocs.endpoint, + method: API_ENDPOINTS.DOCS.GetDocs.methods[0], + mutationKey: [API_ENDPOINTS.DOCS.GetDocs.key], + onSuccess: async () => { + + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/generated/types.ts b/seanime-2.9.10/seanime-web/src/api/generated/types.ts new file mode 100644 index 0000000..cb5b194 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/generated/types.ts @@ -0,0 +1,4630 @@ +// This code was generated by codegen/main.go. DO NOT EDIT. + +export type Nullish<T> = T | null | undefined + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Anilist +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/api/anilist/models_gen.go + * - Filename: models_gen.go + * - Package: anilist + * @description + * Airing schedule sort enums + */ +export type AL_AiringSort = "ID" | + "ID_DESC" | + "MEDIA_ID" | + "MEDIA_ID_DESC" | + "TIME" | + "TIME_DESC" | + "EPISODE" | + "EPISODE_DESC" + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeCollection = { + MediaListCollection?: AL_AnimeCollection_MediaListCollection +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeCollection_MediaListCollection = { + lists?: Array<AL_AnimeCollection_MediaListCollection_Lists> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeCollection_MediaListCollection_Lists = { + status?: AL_MediaListStatus + name?: string + isCustomList?: boolean + entries?: Array<AL_AnimeCollection_MediaListCollection_Lists_Entries> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeCollection_MediaListCollection_Lists_Entries = { + id: number + score?: number + progress?: number + status?: AL_MediaListStatus + notes?: string + repeat?: number + private?: boolean + startedAt?: AL_AnimeCollection_MediaListCollection_Lists_Entries_StartedAt + completedAt?: AL_AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt + media?: AL_BaseAnime +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeCollection_MediaListCollection_Lists_Entries_StartedAt = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media = { + siteUrl?: string + id: number + duration?: number + genres?: Array<string> + averageScore?: number + popularity?: number + meanScore?: number + description?: string + trailer?: AL_AnimeDetailsById_Media_Trailer + startDate?: AL_AnimeDetailsById_Media_StartDate + endDate?: AL_AnimeDetailsById_Media_EndDate + studios?: AL_AnimeDetailsById_Media_Studios + characters?: AL_AnimeDetailsById_Media_Characters + staff?: AL_AnimeDetailsById_Media_Staff + rankings?: Array<AL_AnimeDetailsById_Media_Rankings> + recommendations?: AL_AnimeDetailsById_Media_Recommendations + relations?: AL_AnimeDetailsById_Media_Relations +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Characters = { + edges?: Array<AL_AnimeDetailsById_Media_Characters_Edges> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Characters_Edges = { + id?: number + role?: AL_CharacterRole + name?: string + node?: AL_BaseCharacter +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_EndDate = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Rankings = { + context: string + type: AL_MediaRankType + rank: number + year?: number + format: AL_MediaFormat + allTime?: boolean + season?: AL_MediaSeason +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Recommendations = { + edges?: Array<AL_AnimeDetailsById_Media_Recommendations_Edges> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Recommendations_Edges = { + node?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Recommendations_Edges_Node = { + mediaRecommendation?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation = { + id: number + idMal?: number + siteUrl?: string + status?: AL_MediaStatus + isAdult?: boolean + season?: AL_MediaSeason + type?: AL_MediaType + format?: AL_MediaFormat + meanScore?: number + description?: string + episodes?: number + trailer?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer + startDate?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate + coverImage?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage + bannerImage?: string + title?: AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage = { + extraLarge?: string + large?: string + medium?: string + color?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title = { + romaji?: string + english?: string + native?: string + userPreferred?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Trailer = { + id?: string + site?: string + thumbnail?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Relations = { + edges?: Array<AL_AnimeDetailsById_Media_Relations_Edges> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Relations_Edges = { + relationType?: AL_MediaRelation + node?: AL_BaseAnime +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Staff = { + edges?: Array<AL_AnimeDetailsById_Media_Staff_Edges> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Staff_Edges = { + role?: string + node?: AL_AnimeDetailsById_Media_Staff_Edges_Node +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Staff_Edges_Node = { + name?: AL_AnimeDetailsById_Media_Staff_Edges_Node_Name + id: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Staff_Edges_Node_Name = { + full?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_StartDate = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Studios = { + nodes?: Array<AL_AnimeDetailsById_Media_Studios_Nodes> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Studios_Nodes = { + name: string + id: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_AnimeDetailsById_Media_Trailer = { + id?: string + site?: string + thumbnail?: string +} + +/** + * - Filepath: internal/api/anilist/collection_helper.go + * - Filename: collection_helper.go + * - Package: anilist + */ +export type AL_AnimeListEntry = AL_AnimeCollection_MediaListCollection_Lists_Entries + +/** + * - Filepath: internal/api/anilist/stats.go + * - Filename: stats.go + * - Package: anilist + */ +export type AL_AnimeStats = { + count: number + minutesWatched: number + episodesWatched: number + meanScore: number + genres?: Array<AL_UserGenreStats> + formats?: Array<AL_UserFormatStats> + statuses?: Array<AL_UserStatusStats> + studios?: Array<AL_UserStudioStats> + scores?: Array<AL_UserScoreStats> + startYears?: Array<AL_UserStartYearStats> + releaseYears?: Array<AL_UserReleaseYearStats> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseAnime = { + id: number + idMal?: number + siteUrl?: string + status?: AL_MediaStatus + season?: AL_MediaSeason + type?: AL_MediaType + format?: AL_MediaFormat + seasonYear?: number + bannerImage?: string + episodes?: number + synonyms?: Array<string> + isAdult?: boolean + countryOfOrigin?: string + meanScore?: number + description?: string + genres?: Array<string> + duration?: number + trailer?: AL_BaseAnime_Trailer + title?: AL_BaseAnime_Title + coverImage?: AL_BaseAnime_CoverImage + startDate?: AL_BaseAnime_StartDate + endDate?: AL_BaseAnime_EndDate + nextAiringEpisode?: AL_BaseAnime_NextAiringEpisode +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseAnime_CoverImage = { + extraLarge?: string + large?: string + medium?: string + color?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseAnime_EndDate = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseAnime_NextAiringEpisode = { + airingAt: number + timeUntilAiring: number + episode: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseAnime_StartDate = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseAnime_Title = { + userPreferred?: string + romaji?: string + english?: string + native?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseAnime_Trailer = { + id?: string + site?: string + thumbnail?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseCharacter = { + id: number + isFavourite: boolean + gender?: string + age?: string + dateOfBirth?: AL_BaseCharacter_DateOfBirth + name?: AL_BaseCharacter_Name + image?: AL_BaseCharacter_Image + description?: string + siteUrl?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseCharacter_DateOfBirth = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseCharacter_Image = { + large?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseCharacter_Name = { + full?: string + native?: string + alternative?: Array<string> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseManga = { + id: number + idMal?: number + siteUrl?: string + status?: AL_MediaStatus + season?: AL_MediaSeason + type?: AL_MediaType + format?: AL_MediaFormat + bannerImage?: string + chapters?: number + volumes?: number + synonyms?: Array<string> + isAdult?: boolean + countryOfOrigin?: string + meanScore?: number + description?: string + genres?: Array<string> + title?: AL_BaseManga_Title + coverImage?: AL_BaseManga_CoverImage + startDate?: AL_BaseManga_StartDate + endDate?: AL_BaseManga_EndDate +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseManga_CoverImage = { + extraLarge?: string + large?: string + medium?: string + color?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseManga_EndDate = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseManga_StartDate = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_BaseManga_Title = { + userPreferred?: string + romaji?: string + english?: string + native?: string +} + +/** + * - Filepath: internal/api/anilist/models_gen.go + * - Filename: models_gen.go + * - Package: anilist + * @description + * The role the character plays in the media + */ +export type AL_CharacterRole = "MAIN" | "SUPPORTING" | "BACKGROUND" + +/** + * - Filepath: internal/api/anilist/models_gen.go + * - Filename: models_gen.go + * - Package: anilist + * @description + * Date object that allows for incomplete date values (fuzzy) + */ +export type AL_FuzzyDateInput = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_GetViewer_Viewer = { + name: string + avatar?: AL_GetViewer_Viewer_Avatar + bannerImage?: string + isBlocked?: boolean + options?: AL_GetViewer_Viewer_Options +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_GetViewer_Viewer_Avatar = { + large?: string + medium?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_GetViewer_Viewer_Options = { + displayAdultContent?: boolean + airingNotifications?: boolean + profileColor?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_ListAnime = { + Page?: AL_ListAnime_Page +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_ListAnime_Page = { + pageInfo?: AL_ListAnime_Page_PageInfo + media?: Array<AL_BaseAnime> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_ListAnime_Page_PageInfo = { + hasNextPage?: boolean + total?: number + perPage?: number + currentPage?: number + lastPage?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_ListManga = { + Page?: AL_ListManga_Page +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_ListManga_Page = { + pageInfo?: AL_ListManga_Page_PageInfo + media?: Array<AL_BaseManga> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_ListManga_Page_PageInfo = { + hasNextPage?: boolean + total?: number + perPage?: number + currentPage?: number + lastPage?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_ListRecentAnime = { + Page?: AL_ListRecentAnime_Page +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_ListRecentAnime_Page = { + pageInfo?: AL_ListRecentAnime_Page_PageInfo + airingSchedules?: Array<AL_ListRecentAnime_Page_AiringSchedules> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_ListRecentAnime_Page_AiringSchedules = { + id: number + airingAt: number + episode: number + timeUntilAiring: number + media?: AL_BaseAnime +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_ListRecentAnime_Page_PageInfo = { + hasNextPage?: boolean + total?: number + perPage?: number + currentPage?: number + lastPage?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaCollection = { + MediaListCollection?: AL_MangaCollection_MediaListCollection +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaCollection_MediaListCollection = { + lists?: Array<AL_MangaCollection_MediaListCollection_Lists> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaCollection_MediaListCollection_Lists = { + status?: AL_MediaListStatus + name?: string + isCustomList?: boolean + entries?: Array<AL_MangaCollection_MediaListCollection_Lists_Entries> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaCollection_MediaListCollection_Lists_Entries = { + id: number + score?: number + progress?: number + status?: AL_MediaListStatus + notes?: string + repeat?: number + private?: boolean + startedAt?: AL_MangaCollection_MediaListCollection_Lists_Entries_StartedAt + completedAt?: AL_MangaCollection_MediaListCollection_Lists_Entries_CompletedAt + media?: AL_BaseManga +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaCollection_MediaListCollection_Lists_Entries_CompletedAt = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaCollection_MediaListCollection_Lists_Entries_StartedAt = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media = { + siteUrl?: string + id: number + duration?: number + genres?: Array<string> + rankings?: Array<AL_MangaDetailsById_Media_Rankings> + characters?: AL_MangaDetailsById_Media_Characters + recommendations?: AL_MangaDetailsById_Media_Recommendations + relations?: AL_MangaDetailsById_Media_Relations +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Characters = { + edges?: Array<AL_MangaDetailsById_Media_Characters_Edges> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Characters_Edges = { + id?: number + role?: AL_CharacterRole + name?: string + node?: AL_BaseCharacter +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Rankings = { + context: string + type: AL_MediaRankType + rank: number + year?: number + format: AL_MediaFormat + allTime?: boolean + season?: AL_MediaSeason +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Recommendations = { + edges?: Array<AL_MangaDetailsById_Media_Recommendations_Edges> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Recommendations_Edges = { + node?: AL_MangaDetailsById_Media_Recommendations_Edges_Node +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Recommendations_Edges_Node = { + mediaRecommendation?: AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation = { + id: number + idMal?: number + siteUrl?: string + status?: AL_MediaStatus + season?: AL_MediaSeason + type?: AL_MediaType + format?: AL_MediaFormat + bannerImage?: string + chapters?: number + volumes?: number + synonyms?: Array<string> + isAdult?: boolean + countryOfOrigin?: string + meanScore?: number + description?: string + title?: AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title + coverImage?: AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage + startDate?: AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate + endDate?: AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_CoverImage = { + extraLarge?: string + large?: string + medium?: string + color?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_EndDate = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_StartDate = { + year?: number + month?: number + day?: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Recommendations_Edges_Node_MediaRecommendation_Title = { + userPreferred?: string + romaji?: string + english?: string + native?: string +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Relations = { + edges?: Array<AL_MangaDetailsById_Media_Relations_Edges> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_MangaDetailsById_Media_Relations_Edges = { + relationType?: AL_MediaRelation + node?: AL_BaseManga +} + +/** + * - Filepath: internal/api/anilist/manga.go + * - Filename: manga.go + * - Package: anilist + */ +export type AL_MangaListEntry = AL_MangaCollection_MediaListCollection_Lists_Entries + +/** + * - Filepath: internal/api/anilist/stats.go + * - Filename: stats.go + * - Package: anilist + */ +export type AL_MangaStats = { + count: number + chaptersRead: number + meanScore: number + genres?: Array<AL_UserGenreStats> + statuses?: Array<AL_UserStatusStats> + scores?: Array<AL_UserScoreStats> + startYears?: Array<AL_UserStartYearStats> + releaseYears?: Array<AL_UserReleaseYearStats> +} + +/** + * - Filepath: internal/api/anilist/models_gen.go + * - Filename: models_gen.go + * - Package: anilist + * @description + * The format the media was released in + */ +export type AL_MediaFormat = "TV" | + "TV_SHORT" | + "MOVIE" | + "SPECIAL" | + "OVA" | + "ONA" | + "MUSIC" | + "MANGA" | + "NOVEL" | + "ONE_SHOT" + +/** + * - Filepath: internal/api/anilist/models_gen.go + * - Filename: models_gen.go + * - Package: anilist + * @description + * Media list watching/reading status enum. + */ +export type AL_MediaListStatus = "CURRENT" | + "PLANNING" | + "COMPLETED" | + "DROPPED" | + "PAUSED" | + "REPEATING" + +/** + * - Filepath: internal/api/anilist/models_gen.go + * - Filename: models_gen.go + * - Package: anilist + * @description + * The type of ranking + */ +export type AL_MediaRankType = "RATED" | "POPULAR" + +/** + * - Filepath: internal/api/anilist/models_gen.go + * - Filename: models_gen.go + * - Package: anilist + * @description + * Type of relation media has to its parent. + */ +export type AL_MediaRelation = "ADAPTATION" | + "PREQUEL" | + "SEQUEL" | + "PARENT" | + "SIDE_STORY" | + "CHARACTER" | + "SUMMARY" | + "ALTERNATIVE" | + "SPIN_OFF" | + "OTHER" | + "SOURCE" | + "COMPILATION" | + "CONTAINS" + +/** + * - Filepath: internal/api/anilist/models_gen.go + * - Filename: models_gen.go + * - Package: anilist + */ +export type AL_MediaSeason = "WINTER" | "SPRING" | "SUMMER" | "FALL" + +/** + * - Filepath: internal/api/anilist/models_gen.go + * - Filename: models_gen.go + * - Package: anilist + * @description + * Media sort enums + */ +export type AL_MediaSort = "ID" | + "ID_DESC" | + "TITLE_ROMAJI" | + "TITLE_ROMAJI_DESC" | + "TITLE_ENGLISH" | + "TITLE_ENGLISH_DESC" | + "TITLE_NATIVE" | + "TITLE_NATIVE_DESC" | + "TYPE" | + "TYPE_DESC" | + "FORMAT" | + "FORMAT_DESC" | + "START_DATE" | + "START_DATE_DESC" | + "END_DATE" | + "END_DATE_DESC" | + "SCORE" | + "SCORE_DESC" | + "POPULARITY" | + "POPULARITY_DESC" | + "TRENDING" | + "TRENDING_DESC" | + "EPISODES" | + "EPISODES_DESC" | + "DURATION" | + "DURATION_DESC" | + "STATUS" | + "STATUS_DESC" | + "CHAPTERS" | + "CHAPTERS_DESC" | + "VOLUMES" | + "VOLUMES_DESC" | + "UPDATED_AT" | + "UPDATED_AT_DESC" | + "SEARCH_MATCH" | + "FAVOURITES" | + "FAVOURITES_DESC" + +/** + * - Filepath: internal/api/anilist/models_gen.go + * - Filename: models_gen.go + * - Package: anilist + * @description + * The current releasing status of the media + */ +export type AL_MediaStatus = "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" + +/** + * - Filepath: internal/api/anilist/models_gen.go + * - Filename: models_gen.go + * - Package: anilist + * @description + * Media type enum, anime or manga. + */ +export type AL_MediaType = "ANIME" | "MANGA" + +/** + * - Filepath: internal/api/anilist/stats.go + * - Filename: stats.go + * - Package: anilist + */ +export type AL_Stats = { + animeStats?: AL_AnimeStats + mangaStats?: AL_MangaStats +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_StudioDetails = { + Studio?: AL_StudioDetails_Studio +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_StudioDetails_Studio = { + id: number + isAnimationStudio: boolean + name: string + media?: AL_StudioDetails_Studio_Media +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_StudioDetails_Studio_Media = { + nodes?: Array<AL_BaseAnime> +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_UserFormatStats = { + format?: AL_MediaFormat + meanScore: number + count: number + minutesWatched: number + mediaIds?: Array<number> + chaptersRead: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_UserGenreStats = { + genre?: string + meanScore: number + count: number + minutesWatched: number + mediaIds?: Array<number> + chaptersRead: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_UserReleaseYearStats = { + releaseYear?: number + meanScore: number + count: number + minutesWatched: number + mediaIds?: Array<number> + chaptersRead: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_UserScoreStats = { + score?: number + meanScore: number + count: number + minutesWatched: number + mediaIds?: Array<number> + chaptersRead: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_UserStartYearStats = { + startYear?: number + meanScore: number + count: number + minutesWatched: number + mediaIds?: Array<number> + chaptersRead: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_UserStatusStats = { + status?: AL_MediaListStatus + meanScore: number + count: number + minutesWatched: number + mediaIds?: Array<number> + chaptersRead: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_UserStudioStats = { + studio?: AL_UserStudioStats_Studio + meanScore: number + count: number + minutesWatched: number + mediaIds?: Array<number> + chaptersRead: number +} + +/** + * - Filepath: internal/api/anilist/client_gen.go + * - Filename: client_gen.go + * - Package: anilist + */ +export type AL_UserStudioStats_Studio = { + id: number + name: string + isAnimationStudio: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Anime +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/library/anime/autodownloader_rule.go + * - Filename: autodownloader_rule.go + * - Package: anime + */ +export type Anime_AutoDownloaderRule = { + /** + * Will be set when fetched from the database + */ + dbId: number + enabled: boolean + mediaId: number + releaseGroups?: Array<string> + resolutions?: Array<string> + comparisonTitle: string + titleComparisonType: Anime_AutoDownloaderRuleTitleComparisonType + episodeType: Anime_AutoDownloaderRuleEpisodeType + episodeNumbers?: Array<number> + destination: string + additionalTerms?: Array<string> +} + +/** + * - Filepath: internal/library/anime/autodownloader_rule.go + * - Filename: autodownloader_rule.go + * - Package: anime + */ +export type Anime_AutoDownloaderRuleEpisodeType = "recent" | "selected" + +/** + * - Filepath: internal/library/anime/autodownloader_rule.go + * - Filename: autodownloader_rule.go + * - Package: anime + */ +export type Anime_AutoDownloaderRuleTitleComparisonType = "contains" | "likely" + +/** + * - Filepath: internal/library/anime/entry.go + * - Filename: entry.go + * - Package: anime + */ +export type Anime_Entry = { + mediaId: number + media?: AL_BaseAnime + listData?: Anime_EntryListData + libraryData?: Anime_EntryLibraryData + downloadInfo?: Anime_EntryDownloadInfo + episodes?: Array<Anime_Episode> + nextEpisode?: Anime_Episode + localFiles?: Array<Anime_LocalFile> + anidbId: number + currentEpisodeCount: number + _isNakamaEntry: boolean + nakamaLibraryData?: Anime_NakamaEntryLibraryData +} + +/** + * - Filepath: internal/library/anime/entry_download_info.go + * - Filename: entry_download_info.go + * - Package: anime + */ +export type Anime_EntryDownloadEpisode = { + episodeNumber: number + aniDBEpisode: string + episode?: Anime_Episode +} + +/** + * - Filepath: internal/library/anime/entry_download_info.go + * - Filename: entry_download_info.go + * - Package: anime + */ +export type Anime_EntryDownloadInfo = { + episodesToDownload?: Array<Anime_EntryDownloadEpisode> + canBatch: boolean + batchAll: boolean + hasInaccurateSchedule: boolean + rewatch: boolean + absoluteOffset: number +} + +/** + * - Filepath: internal/library/anime/entry_library_data.go + * - Filename: entry_library_data.go + * - Package: anime + */ +export type Anime_EntryLibraryData = { + allFilesLocked: boolean + sharedPath: string + unwatchedCount: number + mainFileCount: number +} + +/** + * - Filepath: internal/library/anime/entry.go + * - Filename: entry.go + * - Package: anime + */ +export type Anime_EntryListData = { + progress?: number + score?: number + status?: AL_MediaListStatus + repeat?: number + startedAt?: string + completedAt?: string +} + +/** + * - Filepath: internal/library/anime/episode.go + * - Filename: episode.go + * - Package: anime + */ +export type Anime_Episode = { + type: Anime_LocalFileType + /** + * e.g, Show: "Episode 1", Movie: "Violet Evergarden The Movie" + */ + displayTitle: string + /** + * e.g, "Shibuya Incident - Gate, Open" + */ + episodeTitle: string + episodeNumber: number + /** + * AniDB episode number + */ + aniDBEpisode?: string + absoluteEpisodeNumber: number + /** + * Usually the same as EpisodeNumber, unless there is a discrepancy between AniList and AniDB + */ + progressNumber: number + localFile?: Anime_LocalFile + /** + * Is in the local files + */ + isDownloaded: boolean + /** + * (image, airDate, length, summary, overview) + */ + episodeMetadata?: Anime_EpisodeMetadata + /** + * (episode, aniDBEpisode, type...) + */ + fileMetadata?: Anime_LocalFileMetadata + /** + * No AniDB data + */ + isInvalid: boolean + /** + * Alerts the user that there is a discrepancy between AniList and AniDB + */ + metadataIssue?: string + baseAnime?: AL_BaseAnime + _isNakamaEpisode: boolean +} + +/** + * - Filepath: internal/library/anime/episode_collection.go + * - Filename: episode_collection.go + * - Package: anime + */ +export type Anime_EpisodeCollection = { + hasMappingError: boolean + episodes?: Array<Anime_Episode> + metadata?: Metadata_AnimeMetadata +} + +/** + * - Filepath: internal/library/anime/episode.go + * - Filename: episode.go + * - Package: anime + */ +export type Anime_EpisodeMetadata = { + anidbId?: number + image?: string + airDate?: string + length?: number + summary?: string + overview?: string + isFiller?: boolean + /** + * Indicates if the episode has a real image + */ + hasImage?: boolean +} + +/** + * - Filepath: internal/library/anime/collection.go + * - Filename: collection.go + * - Package: anime + */ +export type Anime_LibraryCollection = { + continueWatchingList?: Array<Anime_Episode> + lists?: Array<Anime_LibraryCollectionList> + unmatchedLocalFiles?: Array<Anime_LocalFile> + unmatchedGroups?: Array<Anime_UnmatchedGroup> + ignoredLocalFiles?: Array<Anime_LocalFile> + unknownGroups?: Array<Anime_UnknownGroup> + stats?: Anime_LibraryCollectionStats + /** + * Hydrated by the route handler + */ + stream?: Anime_StreamCollection +} + +/** + * - Filepath: internal/library/anime/collection.go + * - Filename: collection.go + * - Package: anime + */ +export type Anime_LibraryCollectionEntry = { + media?: AL_BaseAnime + mediaId: number + /** + * Library data + */ + libraryData?: Anime_EntryLibraryData + /** + * Library data from Nakama + */ + nakamaLibraryData?: Anime_NakamaEntryLibraryData + /** + * AniList list data + */ + listData?: Anime_EntryListData +} + +/** + * - Filepath: internal/library/anime/collection.go + * - Filename: collection.go + * - Package: anime + */ +export type Anime_LibraryCollectionList = { + type?: AL_MediaListStatus + status?: AL_MediaListStatus + entries?: Array<Anime_LibraryCollectionEntry> +} + +/** + * - Filepath: internal/library/anime/collection.go + * - Filename: collection.go + * - Package: anime + */ +export type Anime_LibraryCollectionStats = { + totalEntries: number + totalFiles: number + totalShows: number + totalMovies: number + totalSpecials: number + totalSize: string +} + +/** + * - Filepath: internal/library/anime/localfile.go + * - Filename: localfile.go + * - Package: anime + */ +export type Anime_LocalFile = { + path: string + name: string + parsedInfo?: Anime_LocalFileParsedData + parsedFolderInfo?: Array<Anime_LocalFileParsedData> + metadata?: Anime_LocalFileMetadata + locked: boolean + /** + * Unused for now + */ + ignored: boolean + mediaId: number +} + +/** + * - Filepath: internal/library/anime/localfile.go + * - Filename: localfile.go + * - Package: anime + */ +export type Anime_LocalFileMetadata = { + episode: number + aniDBEpisode: string + type: Anime_LocalFileType +} + +/** + * - Filepath: internal/library/anime/localfile.go + * - Filename: localfile.go + * - Package: anime + */ +export type Anime_LocalFileParsedData = { + original: string + title?: string + releaseGroup?: string + season?: string + seasonRange?: Array<string> + part?: string + partRange?: Array<string> + episode?: string + episodeRange?: Array<string> + episodeTitle?: string + year?: string +} + +/** + * - Filepath: internal/library/anime/localfile.go + * - Filename: localfile.go + * - Package: anime + */ +export type Anime_LocalFileType = "main" | "special" | "nc" + +/** + * - Filepath: internal/library/anime/missing_episodes.go + * - Filename: missing_episodes.go + * - Package: anime + */ +export type Anime_MissingEpisodes = { + episodes?: Array<Anime_Episode> + silencedEpisodes?: Array<Anime_Episode> +} + +/** + * - Filepath: internal/library/anime/entry_library_data.go + * - Filename: entry_library_data.go + * - Package: anime + */ +export type Anime_NakamaEntryLibraryData = { + unwatchedCount: number + mainFileCount: number +} + +/** + * - Filepath: internal/library/anime/playlist.go + * - Filename: playlist.go + * - Package: anime + */ +export type Anime_Playlist = { + /** + * DbId is the database ID of the models.PlaylistEntry + */ + dbId: number + /** + * Name is the name of the playlist + */ + name: string + /** + * LocalFiles is a list of local files in the playlist, in order + */ + localFiles?: Array<Anime_LocalFile> +} + +/** + * - Filepath: internal/library/anime/schedule.go + * - Filename: schedule.go + * - Package: anime + */ +export type Anime_ScheduleItem = { + mediaId: number + title: string + time: string + dateTime?: string + image: string + episodeNumber: number + isMovie: boolean + isSeasonFinale: boolean +} + +/** + * - Filepath: internal/library/anime/collection.go + * - Filename: collection.go + * - Package: anime + */ +export type Anime_StreamCollection = { + continueWatchingList?: Array<Anime_Episode> + anime?: Array<AL_BaseAnime> + listData?: Record<number, Anime_EntryListData> +} + +/** + * - Filepath: internal/library/anime/collection.go + * - Filename: collection.go + * - Package: anime + */ +export type Anime_UnknownGroup = { + mediaId: number + localFiles?: Array<Anime_LocalFile> +} + +/** + * - Filepath: internal/library/anime/collection.go + * - Filename: collection.go + * - Package: anime + */ +export type Anime_UnmatchedGroup = { + dir: string + localFiles?: Array<Anime_LocalFile> + suggestions?: Array<AL_BaseAnime> +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// ChapterDownloader +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/manga/downloader/chapter_downloader.go + * - Filename: chapter_downloader.go + * - Package: chapter_downloader + */ +export type ChapterDownloader_DownloadID = { + provider: string + mediaId: number + chapterId: string + chapterNumber: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Continuity +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/continuity/manager.go + * - Filename: manager.go + * - Package: continuity + */ +export type Continuity_Kind = "onlinestream" | "mediastream" | "external_player" + +/** + * - Filepath: internal/continuity/history.go + * - Filename: history.go + * - Package: continuity + */ +export type Continuity_UpdateWatchHistoryItemOptions = { + currentTime: number + duration: number + mediaId: number + episodeNumber: number + filepath?: string + kind: Continuity_Kind +} + +/** + * - Filepath: internal/continuity/history.go + * - Filename: history.go + * - Package: continuity + */ +export type Continuity_WatchHistory = Record<number, Continuity_WatchHistoryItem> + +/** + * - Filepath: internal/continuity/history.go + * - Filename: history.go + * - Package: continuity + */ +export type Continuity_WatchHistoryItem = { + kind: Continuity_Kind + filepath: string + mediaId: number + episodeNumber: number + currentTime: number + duration: number + timeAdded?: string + timeUpdated?: string +} + +/** + * - Filepath: internal/continuity/history.go + * - Filename: history.go + * - Package: continuity + */ +export type Continuity_WatchHistoryItemResponse = { + item?: Continuity_WatchHistoryItem + found: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Core +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/core/feature_flags.go + * - Filename: feature_flags.go + * - Package: core + */ +export type INTERNAL_FeatureFlags = { + MainServerTorrentStreaming: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Debrid +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/debrid/debrid/debrid.go + * - Filename: debrid.go + * - Package: debrid + */ +export type Debrid_CachedFile = { + size: number + name: string +} + +/** + * - Filepath: internal/debrid/debrid/debrid.go + * - Filename: debrid.go + * - Package: debrid + */ +export type Debrid_TorrentInfo = { + /** + * ID of the torrent if added to the debrid service + */ + id?: string + name: string + hash: string + size: number + files?: Array<Debrid_TorrentItemFile> +} + +/** + * - Filepath: internal/debrid/debrid/debrid.go + * - Filename: debrid.go + * - Package: debrid + */ +export type Debrid_TorrentItem = { + id: string + /** + * Name of the torrent or file + */ + name: string + /** + * SHA1 hash of the torrent + */ + hash: string + /** + * Size of the selected files (size in bytes) + */ + size: number + /** + * Formatted size of the selected files + */ + formattedSize: string + /** + * Progress percentage (0 to 100) + */ + completionPercentage: number + /** + * Formatted estimated time remaining + */ + eta: string + /** + * Current download status + */ + status: Debrid_TorrentItemStatus + /** + * Date when the torrent was added, RFC3339 format + */ + added: string + /** + * Current download speed (optional, present in downloading state) + */ + speed?: string + /** + * Number of seeders (optional, present in downloading state) + */ + seeders?: number + /** + * Whether the torrent is ready to be downloaded + */ + isReady: boolean + /** + * List of files in the torrent (optional) + */ + files?: Array<Debrid_TorrentItemFile> +} + +/** + * - Filepath: internal/debrid/debrid/debrid.go + * - Filename: debrid.go + * - Package: debrid + */ +export type Debrid_TorrentItemFile = { + /** + * ID of the file, usually the index + */ + id: string + index: number + name: string + path: string + size: number +} + +/** + * - Filepath: internal/debrid/debrid/debrid.go + * - Filename: debrid.go + * - Package: debrid + */ +export type Debrid_TorrentItemInstantAvailability = { + /** + * Key is the file ID (or index) + */ + cachedFiles?: Record<string, Debrid_CachedFile> +} + +/** + * - Filepath: internal/debrid/debrid/debrid.go + * - Filename: debrid.go + * - Package: debrid + */ +export type Debrid_TorrentItemStatus = "downloading" | + "completed" | + "seeding" | + "error" | + "stalled" | + "paused" | + "other" + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// DebridClient +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/debrid/client/stream.go + * - Filename: stream.go + * - Package: debrid_client + */ +export type DebridClient_CancelStreamOptions = { + removeTorrent: boolean +} + +/** + * - Filepath: internal/debrid/client/previews.go + * - Filename: previews.go + * - Package: debrid_client + */ +export type DebridClient_FilePreview = { + path: string + displayPath: string + displayTitle: string + episodeNumber: number + relativeEpisodeNumber: number + isLikely: boolean + index: number + fileId: string +} + +/** + * - Filepath: internal/debrid/client/stream.go + * - Filename: stream.go + * - Package: debrid_client + */ +export type DebridClient_StreamPlaybackType = "none" | "noneAndAwait" | "default" | "nativeplayer" | "externalPlayerLink" + +/** + * - Filepath: internal/debrid/client/stream.go + * - Filename: stream.go + * - Package: debrid_client + */ +export type DebridClient_StreamState = { + status: DebridClient_StreamStatus + torrentName: string + message: string +} + +/** + * - Filepath: internal/debrid/client/stream.go + * - Filename: stream.go + * - Package: debrid_client + */ +export type DebridClient_StreamStatus = "downloading" | "ready" | "failed" | "started" + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Extension +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/extension/plugin.go + * - Filename: plugin.go + * - Package: extension + * @description + * CommandArg represents an argument for a command + */ +export type Extension_CommandArg = { + value?: string + validator?: string +} + +/** + * - Filepath: internal/extension/plugin.go + * - Filename: plugin.go + * - Package: extension + * @description + * CommandScope defines a specific command or set of commands that can be executed + * with specific arguments and validation rules. + */ +export type Extension_CommandScope = { + description?: string + command: string + args?: Array<Extension_CommandArg> +} + +/** + * - Filepath: internal/extension/extension.go + * - Filename: extension.go + * - Package: extension + */ +export type Extension_ConfigField = { + type: Extension_ConfigFieldType + name: string + label: string + options?: Array<Extension_ConfigFieldSelectOption> + default?: string +} + +/** + * - Filepath: internal/extension/extension.go + * - Filename: extension.go + * - Package: extension + */ +export type Extension_ConfigFieldSelectOption = { + value: string + label: string +} + +/** + * - Filepath: internal/extension/extension.go + * - Filename: extension.go + * - Package: extension + */ +export type Extension_ConfigFieldType = "text" | "switch" | "select" + +/** + * - Filepath: internal/extension/extension.go + * - Filename: extension.go + * - Package: extension + */ +export type Extension_Extension = { + /** + * e.g. "extension-example" + */ + id: string + /** + * e.g. "Extension" + */ + name: string + /** + * e.g. "1.0.0" + */ + version: string + semverConstraint?: string + /** + * e.g. "http://cdn.something.app/extensions/extension-example/manifest.json" + */ + manifestURI: string + /** + * e.g. "go" + */ + language: Extension_Language + /** + * e.g. "anime-torrent-provider" + */ + type: Extension_Type + /** + * e.g. "This extension provides torrents" + */ + description: string + /** + * e.g. "Seanime" + */ + author: string + icon: string + website: string + lang: string + /** + * NOT IMPLEMENTED + */ + permissions?: Array<string> + userConfig?: Extension_UserConfig + payload: string + payloadURI?: string + plugin?: Extension_PluginManifest + isDevelopment?: boolean +} + +/** + * - Filepath: internal/extension/extension.go + * - Filename: extension.go + * - Package: extension + */ +export type Extension_InvalidExtension = { + id: string + path: string + extension: Extension_Extension + reason: string + code: Extension_InvalidExtensionErrorCode + pluginPermissionDescription?: string +} + +/** + * - Filepath: internal/extension/extension.go + * - Filename: extension.go + * - Package: extension + */ +export type Extension_InvalidExtensionErrorCode = "invalid_manifest" | + "invalid_payload" | + "user_config_error" | + "invalid_authorization" | + "plugin_permissions_not_granted" | + "invalid_semver_constraint" + +/** + * - Filepath: internal/extension/extension.go + * - Filename: extension.go + * - Package: extension + */ +export type Extension_Language = "javascript" | "typescript" | "go" + +/** + * - Filepath: internal/extension/plugin.go + * - Filename: plugin.go + * - Package: extension + * @description + * PluginAllowlist is a list of system permissions that the plugin is asking for. + * + * The user must acknowledge these permissions before the plugin can be loaded. + */ +export type Extension_PluginAllowlist = { + readPaths?: Array<string> + writePaths?: Array<string> + commandScopes?: Array<Extension_CommandScope> +} + +/** + * - Filepath: internal/extension/plugin.go + * - Filename: plugin.go + * - Package: extension + */ +export type Extension_PluginManifest = { + version: string + permissions?: Extension_PluginPermissions +} + +/** + * - Filepath: internal/extension/extension.go + * - Filename: extension.go + * - Package: extension + */ +export type Extension_PluginPermissionScope = string + +/** + * - Filepath: internal/extension/plugin.go + * - Filename: plugin.go + * - Package: extension + */ +export type Extension_PluginPermissions = { + scopes?: Array<Extension_PluginPermissionScope> + allow?: Extension_PluginAllowlist +} + +/** + * - Filepath: internal/extension/extension.go + * - Filename: extension.go + * - Package: extension + */ +export type Extension_SavedUserConfig = { + version: number + values?: Record<string, string> +} + +/** + * - Filepath: internal/extension/extension.go + * - Filename: extension.go + * - Package: extension + */ +export type Extension_Type = "anime-torrent-provider" | "manga-provider" | "onlinestream-provider" | "plugin" + +/** + * - Filepath: internal/extension/extension.go + * - Filename: extension.go + * - Package: extension + */ +export type Extension_UserConfig = { + version: number + requiresConfig: boolean + fields?: Array<Extension_ConfigField> +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// ExtensionPlayground +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/extension_playground/playground.go + * - Filename: playground.go + * - Package: extension_playground + */ +export type RunPlaygroundCodeParams = { + type?: Extension_Type + language?: Extension_Language + code: string + inputs?: Record<string, any> + function: string +} + +/** + * - Filepath: internal/extension_playground/playground.go + * - Filename: playground.go + * - Package: extension_playground + */ +export type RunPlaygroundCodeResponse = { + logs: string + value: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// ExtensionRepo +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/extension_repo/repository.go + * - Filename: repository.go + * - Package: extension_repo + */ +export type ExtensionRepo_AllExtensions = { + extensions?: Array<Extension_Extension> + invalidExtensions?: Array<Extension_InvalidExtension> + invalidUserConfigExtensions?: Array<Extension_InvalidExtension> + hasUpdate?: Array<ExtensionRepo_UpdateData> +} + +/** + * - Filepath: internal/extension_repo/repository.go + * - Filename: repository.go + * - Package: extension_repo + */ +export type ExtensionRepo_AnimeTorrentProviderExtensionItem = { + id: string + name: string + /** + * ISO 639-1 language code + */ + lang: string + settings?: HibikeTorrent_AnimeProviderSettings +} + +/** + * - Filepath: internal/extension_repo/external.go + * - Filename: external.go + * - Package: extension_repo + */ +export type ExtensionRepo_ExtensionInstallResponse = { + message: string +} + +/** + * - Filepath: internal/extension_repo/userconfig.go + * - Filename: userconfig.go + * - Package: extension_repo + */ +export type ExtensionRepo_ExtensionUserConfig = { + userConfig?: Extension_UserConfig + savedUserConfig?: Extension_SavedUserConfig +} + +/** + * - Filepath: internal/extension_repo/repository.go + * - Filename: repository.go + * - Package: extension_repo + */ +export type ExtensionRepo_MangaProviderExtensionItem = { + id: string + name: string + /** + * ISO 639-1 language code + */ + lang: string + settings?: HibikeManga_Settings +} + +/** + * - Filepath: internal/extension_repo/repository.go + * - Filename: repository.go + * - Package: extension_repo + */ +export type ExtensionRepo_OnlinestreamProviderExtensionItem = { + id: string + name: string + /** + * ISO 639-1 language code + */ + lang: string + episodeServers?: Array<string> + supportsDub: boolean +} + +/** + * - Filepath: internal/extension_repo/external_plugin.go + * - Filename: external_plugin.go + * - Package: extension_repo + */ +export type ExtensionRepo_StoredPluginSettingsData = { + pinnedTrayPluginIds?: Array<string> + /** + * Extension ID -> Permission Hash + */ + pluginGrantedPermissions?: Record<string, string> +} + +/** + * - Filepath: internal/extension_repo/repository.go + * - Filename: repository.go + * - Package: extension_repo + */ +export type ExtensionRepo_UpdateData = { + extensionID: string + manifestURI: string + version: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Handlers +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/handlers/docs.go + * - Filename: docs.go + * - Package: handlers + */ +export type ApiDocsGroup = { + filename: string + name: string + handlers?: Array<RouteHandler> +} + +/** + * - Filepath: internal/handlers/directory_selector.go + * - Filename: directory_selector.go + * - Package: handlers + */ +export type DirectoryInfo = { + fullPath: string + folderName: string +} + +/** + * - Filepath: internal/handlers/directory_selector.go + * - Filename: directory_selector.go + * - Package: handlers + */ +export type DirectorySelectorResponse = { + fullPath: string + exists: boolean + basePath: string + suggestions?: Array<DirectoryInfo> + content?: Array<DirectoryInfo> +} + +/** + * - Filepath: internal/handlers/download.go + * - Filename: download.go + * - Package: handlers + */ +export type DownloadReleaseResponse = { + destination: string + error?: string +} + +/** + * - Filepath: internal/handlers/mal.go + * - Filename: mal.go + * - Package: handlers + */ +export type MalAuthResponse = { + access_token: string + refresh_token: string + expires_in: number + token_type: string +} + +/** + * - Filepath: internal/handlers/status.go + * - Filename: status.go + * - Package: handlers + */ +export type MemoryStatsResponse = { + /** + * bytes allocated and not yet freed + */ + alloc: number + /** + * bytes allocated (even if freed) + */ + totalAlloc: number + /** + * bytes obtained from system + */ + sys: number + /** + * number of pointer lookups + */ + lookups: number + /** + * number of mallocs + */ + mallocs: number + /** + * number of frees + */ + frees: number + /** + * bytes allocated and not yet freed + */ + heapAlloc: number + /** + * bytes obtained from system + */ + heapSys: number + /** + * bytes in idle spans + */ + heapIdle: number + /** + * bytes in non-idle span + */ + heapInuse: number + /** + * bytes released to OS + */ + heapReleased: number + /** + * total number of allocated objects + */ + heapObjects: number + /** + * bytes used by stack allocator + */ + stackInuse: number + /** + * bytes obtained from system for stack allocator + */ + stackSys: number + /** + * bytes used by mspan structures + */ + mSpanInuse: number + /** + * bytes obtained from system for mspan structures + */ + mSpanSys: number + /** + * bytes used by mcache structures + */ + mCacheInuse: number + /** + * bytes obtained from system for mcache structures + */ + mCacheSys: number + /** + * bytes used by the profiling bucket hash table + */ + buckHashSys: number + /** + * bytes used for garbage collection system metadata + */ + gcSys: number + /** + * bytes used for other system allocations + */ + otherSys: number + /** + * next collection will happen when HeapAlloc ≥ this amount + */ + nextGC: number + /** + * time the last garbage collection finished + */ + lastGC: number + /** + * cumulative nanoseconds in GC stop-the-world pauses + */ + pauseTotalNs: number + /** + * nanoseconds in recent GC stop-the-world pause + */ + pauseNs: number + /** + * number of completed GC cycles + */ + numGC: number + /** + * number of GC cycles that were forced by the application calling the GC function + */ + numForcedGC: number + /** + * fraction of this program's available CPU time used by the GC since the program started + */ + gcCPUFraction: number + /** + * boolean that indicates GC is enabled + */ + enableGC: boolean + /** + * boolean that indicates GC debug mode is enabled + */ + debugGC: boolean + /** + * number of goroutines + */ + numGoroutine: number +} + +/** + * - Filepath: internal/handlers/docs.go + * - Filename: docs.go + * - Package: handlers + */ +export type RouteHandler = { + name: string + trimmedName: string + comments?: Array<string> + filepath: string + filename: string + api?: RouteHandlerApi +} + +/** + * - Filepath: internal/handlers/docs.go + * - Filename: docs.go + * - Package: handlers + */ +export type RouteHandlerApi = { + summary: string + descriptions?: Array<string> + endpoint: string + methods?: Array<string> + params?: Array<RouteHandlerParam> + bodyFields?: Array<RouteHandlerParam> + returns: string + returnGoType: string + returnTypescriptType: string +} + +/** + * - Filepath: internal/handlers/docs.go + * - Filename: docs.go + * - Package: handlers + */ +export type RouteHandlerParam = { + name: string + jsonName: string + /** + * e.g., []models.User + */ + goType: string + /** + * e.g., models.User + */ + usedStructType: string + /** + * e.g., Array<User> + */ + typescriptType: string + required: boolean + descriptions?: Array<string> +} + +/** + * - Filepath: internal/handlers/status.go + * - Filename: status.go + * - Package: handlers + * @description + * Status is a struct containing the user data, settings, and OS. + * It is used by the client in various places to access necessary information. + */ +export type Status = { + os: string + clientDevice: string + clientPlatform: string + clientUserAgent: string + dataDir: string + user?: User + settings?: Models_Settings + version: string + versionName: string + themeSettings?: Models_Theme + isOffline: boolean + mediastreamSettings?: Models_MediastreamSettings + torrentstreamSettings?: Models_TorrentstreamSettings + debridSettings?: Models_DebridSettings + anilistClientId: string + /** + * If true, a new screen will be displayed + */ + updating: boolean + /** + * The server is running as a desktop sidecar + */ + isDesktopSidecar: boolean + featureFlags?: INTERNAL_FeatureFlags + serverReady: boolean + serverHasPassword: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Hibikemanga +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/extension/hibike/manga/types.go + * - Filename: types.go + * - Package: hibikemanga + */ +export type HibikeManga_ChapterDetails = { + provider: string + id: string + url: string + title: string + chapter: string + index: number + scanlator?: string + language?: string + rating?: number + updatedAt?: string + localIsPDF?: boolean +} + +/** + * - Filepath: internal/extension/hibike/manga/types.go + * - Filename: types.go + * - Package: hibikemanga + */ +export type HibikeManga_ChapterPage = { + provider: string + url: string + index: number + headers?: Record<string, string> +} + +/** + * - Filepath: internal/extension/hibike/manga/types.go + * - Filename: types.go + * - Package: hibikemanga + */ +export type HibikeManga_SearchResult = { + provider: string + id: string + title: string + synonyms?: Array<string> + year?: number + image?: string + searchRating?: number +} + +/** + * - Filepath: internal/extension/hibike/manga/types.go + * - Filename: types.go + * - Package: hibikemanga + */ +export type HibikeManga_Settings = { + supportsMultiScanlator: boolean + supportsMultiLanguage: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Hibikeonlinestream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/extension/hibike/onlinestream/types.go + * - Filename: types.go + * - Package: hibikeonlinestream + */ +export type HibikeOnlinestream_SearchResult = { + id: string + title: string + url: string + subOrDub: HibikeOnlinestream_SubOrDub +} + +/** + * - Filepath: internal/extension/hibike/onlinestream/types.go + * - Filename: types.go + * - Package: hibikeonlinestream + */ +export type HibikeOnlinestream_SubOrDub = "sub" | "dub" | "both" + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Hibiketorrent +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/extension/hibike/torrent/types.go + * - Filename: types.go + * - Package: hibiketorrent + */ +export type HibikeTorrent_AnimeProviderSettings = { + canSmartSearch: boolean + smartSearchFilters?: Array<HibikeTorrent_AnimeProviderSmartSearchFilter> + supportsAdult: boolean + type: HibikeTorrent_AnimeProviderType +} + +/** + * - Filepath: internal/extension/hibike/torrent/types.go + * - Filename: types.go + * - Package: hibiketorrent + */ +export type HibikeTorrent_AnimeProviderSmartSearchFilter = "batch" | "episodeNumber" | "resolution" | "query" | "bestReleases" + +/** + * - Filepath: internal/extension/hibike/torrent/types.go + * - Filename: types.go + * - Package: hibiketorrent + */ +export type HibikeTorrent_AnimeProviderType = "main" | "special" + +/** + * - Filepath: internal/extension/hibike/torrent/types.go + * - Filename: types.go + * - Package: hibiketorrent + */ +export type HibikeTorrent_AnimeTorrent = { + provider?: string + name: string + date: string + size: number + formattedSize: string + seeders: number + leechers: number + downloadCount: number + link: string + downloadUrl: string + magnetLink?: string + infoHash?: string + resolution?: string + isBatch?: boolean + episodeNumber?: number + releaseGroup?: string + isBestRelease: boolean + confirmed: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Local +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/local/sync.go + * - Filename: sync.go + * - Package: local + */ +export type Local_QueueMediaTask = { + mediaId: number + image: string + title: string + type: string +} + +/** + * - Filepath: internal/local/sync.go + * - Filename: sync.go + * - Package: local + */ +export type Local_QueueState = { + animeTasks?: Record<number, Local_QueueMediaTask> + mangaTasks?: Record<number, Local_QueueMediaTask> +} + +/** + * - Filepath: internal/local/manager.go + * - Filename: manager.go + * - Package: local + */ +export type Local_TrackedMediaItem = { + mediaId: number + type: string + animeEntry?: AL_AnimeListEntry + mangaEntry?: AL_MangaListEntry +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Manga +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/manga/chapter_container.go + * - Filename: chapter_container.go + * - Package: manga + */ +export type Manga_ChapterContainer = { + mediaId: number + provider: string + chapters?: Array<HibikeManga_ChapterDetails> +} + +/** + * - Filepath: internal/manga/collection.go + * - Filename: collection.go + * - Package: manga + */ +export type Manga_Collection = { + lists?: Array<Manga_CollectionList> +} + +/** + * - Filepath: internal/manga/collection.go + * - Filename: collection.go + * - Package: manga + */ +export type Manga_CollectionEntry = { + media?: AL_BaseManga + mediaId: number + /** + * AniList list data + */ + listData?: Manga_EntryListData +} + +/** + * - Filepath: internal/manga/collection.go + * - Filename: collection.go + * - Package: manga + */ +export type Manga_CollectionList = { + type?: AL_MediaListStatus + status?: AL_MediaListStatus + entries?: Array<Manga_CollectionEntry> +} + +/** + * - Filepath: internal/manga/download.go + * - Filename: download.go + * - Package: manga + */ +export type Manga_DownloadListItem = { + mediaId: number + media?: AL_BaseManga + downloadData: Manga_ProviderDownloadMap +} + +/** + * - Filepath: internal/manga/manga_entry.go + * - Filename: manga_entry.go + * - Package: manga + */ +export type Manga_Entry = { + mediaId: number + media?: AL_BaseManga + listData?: Manga_EntryListData +} + +/** + * - Filepath: internal/manga/manga_entry.go + * - Filename: manga_entry.go + * - Package: manga + */ +export type Manga_EntryListData = { + progress?: number + score?: number + status?: AL_MediaListStatus + repeat?: number + startedAt?: string + completedAt?: string +} + +/** + * - Filepath: internal/manga/chapter_container.go + * - Filename: chapter_container.go + * - Package: manga + */ +export type Manga_MangaLatestChapterNumberItem = { + provider: string + scanlator: string + language: string + number: number +} + +/** + * - Filepath: internal/manga/chapter_container_mapping.go + * - Filename: chapter_container_mapping.go + * - Package: manga + */ +export type Manga_MappingResponse = { + mangaId?: string +} + +/** + * - Filepath: internal/manga/download.go + * - Filename: download.go + * - Package: manga + */ +export type Manga_MediaDownloadData = { + downloaded: Manga_ProviderDownloadMap + queued: Manga_ProviderDownloadMap +} + +/** + * - Filepath: internal/manga/chapter_page_container.go + * - Filename: chapter_page_container.go + * - Package: manga + */ +export type Manga_PageContainer = { + mediaId: number + provider: string + chapterId: string + pages?: Array<HibikeManga_ChapterPage> + /** + * Indexed by page number + */ + pageDimensions?: Record<number, Manga_PageDimension> + /** + * TODO remove + */ + isDownloaded: boolean +} + +/** + * - Filepath: internal/manga/chapter_page_container.go + * - Filename: chapter_page_container.go + * - Package: manga + */ +export type Manga_PageDimension = { + width: number + height: number +} + +/** + * - Filepath: internal/manga/download.go + * - Filename: download.go + * - Package: manga + */ +export type Manga_ProviderDownloadMap = Record<string, Array<Manga_ProviderDownloadMapChapterInfo>> + +/** + * - Filepath: internal/manga/download.go + * - Filename: download.go + * - Package: manga + */ +export type Manga_ProviderDownloadMapChapterInfo = { + chapterId: string + chapterNumber: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Mediaplayer +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/mediaplayers/mediaplayer/repository.go + * - Filename: repository.go + * - Package: mediaplayer + */ +export type PlaybackStatus = { + completionPercentage: number + playing: boolean + filename: string + path: string + /** + * in ms + */ + duration: number + filepath: string + /** + * in seconds + */ + currentTimeInSeconds: number + /** + * in seconds + */ + durationInSeconds: number + /** + * "file", "stream" + */ + playbackType: PlaybackType +} + +/** + * - Filepath: internal/mediaplayers/mediaplayer/repository.go + * - Filename: repository.go + * - Package: mediaplayer + */ +export type PlaybackType = "file" | "stream" + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Mediastream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/mediastream/playback.go + * - Filename: playback.go + * - Package: mediastream + */ +export type Mediastream_MediaContainer = { + filePath: string + hash: string + /** + * Tells the frontend how to play the media. + */ + streamType: Mediastream_StreamType + /** + * The relative endpoint to stream the media. + */ + streamUrl: string + mediaInfo?: MediaInfo +} + +/** + * - Filepath: internal/mediastream/playback.go + * - Filename: playback.go + * - Package: mediastream + */ +export type Mediastream_StreamType = "transcode" | "optimized" | "direct" + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Metadata +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/api/metadata/types.go + * - Filename: types.go + * - Package: metadata + */ +export type Metadata_AnimeMappings = { + animeplanetId: string + kitsuId: number + malId: number + type: string + anilistId: number + anisearchId: number + anidbId: number + notifymoeId: string + livechartId: number + thetvdbId: number + imdbId: string + themoviedbId: string +} + +/** + * - Filepath: internal/api/metadata/types.go + * - Filename: types.go + * - Package: metadata + */ +export type Metadata_AnimeMetadata = { + titles?: Record<string, string> + episodes?: Record<string, Metadata_EpisodeMetadata> + episodeCount: number + specialCount: number + mappings?: Metadata_AnimeMappings +} + +/** + * - Filepath: internal/api/metadata/types.go + * - Filename: types.go + * - Package: metadata + */ +export type Metadata_EpisodeMetadata = { + anidbId: number + tvdbId: number + title: string + image: string + airDate: string + length: number + summary: string + overview: string + episodeNumber: number + episode: string + seasonNumber: number + absoluteEpisodeNumber: number + anidbEid: number + /** + * Indicates if the episode has a real image + */ + hasImage: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Mkvparser +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/mkvparser/metadata.go + * - Filename: metadata.go + * - Package: mkvparser + * @description + * AttachmentInfo holds extracted information about an attachment. + */ +export type MKVParser_AttachmentInfo = { + uid: number + filename: string + mimetype: string + size: number + description?: string + type?: MKVParser_AttachmentType +} + +/** + * - Filepath: internal/mkvparser/metadata.go + * - Filename: metadata.go + * - Package: mkvparser + */ +export type MKVParser_AttachmentType = "font" | "subtitle" | "other" + +/** + * - Filepath: internal/mkvparser/structs.go + * - Filename: structs.go + * - Package: mkvparser + * @description + * AudioTrack contains audio-specific track data + */ +export type MKVParser_AudioTrack = { + SamplingFrequency: number + Channels: number + BitDepth: number +} + +/** + * - Filepath: internal/mkvparser/metadata.go + * - Filename: metadata.go + * - Package: mkvparser + * @description + * ChapterInfo holds extracted information about a chapter. + */ +export type MKVParser_ChapterInfo = { + uid: number + /** + * Start time in seconds + */ + start: number + /** + * End time in seconds + */ + end?: number + text?: string + /** + * Legacy 3-letter language codes + */ + languages?: Array<string> + /** + * IETF language tags + */ + languagesIETF?: Array<string> +} + +/** + * - Filepath: internal/mkvparser/structs.go + * - Filename: structs.go + * - Package: mkvparser + * @description + * ContentCompression describes how the track data is compressed + */ +export type MKVParser_ContentCompression = { + ContentCompAlgo: number + ContentCompSettings?: Array<string> +} + +/** + * - Filepath: internal/mkvparser/structs.go + * - Filename: structs.go + * - Package: mkvparser + * @description + * ContentEncoding describes a single encoding applied to the track data + */ +export type MKVParser_ContentEncoding = { + ContentEncodingOrder: number + ContentEncodingScope: number + ContentEncodingType: number + ContentCompression?: MKVParser_ContentCompression +} + +/** + * - Filepath: internal/mkvparser/structs.go + * - Filename: structs.go + * - Package: mkvparser + * @description + * ContentEncodings contains information about how the track data is encoded + */ +export type MKVParser_ContentEncodings = { + ContentEncoding?: Array<MKVParser_ContentEncoding> +} + +/** + * - Filepath: internal/mkvparser/metadata.go + * - Filename: metadata.go + * - Package: mkvparser + * @description + * Metadata holds all extracted metadata. + */ +export type MKVParser_Metadata = { + title?: string + /** + * Duration in seconds + */ + duration: number + /** + * Original timecode scale from Info + */ + timecodeScale: number + muxingApp?: string + writingApp?: string + tracks?: Array<MKVParser_TrackInfo> + videoTracks?: Array<MKVParser_TrackInfo> + audioTracks?: Array<MKVParser_TrackInfo> + subtitleTracks?: Array<MKVParser_TrackInfo> + chapters?: Array<MKVParser_ChapterInfo> + attachments?: Array<MKVParser_AttachmentInfo> + /** + * RFC 6381 codec string + */ + mimeCodec?: string +} + +/** + * - Filepath: internal/mkvparser/mkvparser.go + * - Filename: mkvparser.go + * - Package: mkvparser + * @description + * SubtitleEvent holds information for a single subtitle entry. + */ +export type MKVParser_SubtitleEvent = { + trackNumber: number + /** + * Content + */ + text: string + /** + * Start time in seconds + */ + startTime: number + /** + * Duration in seconds + */ + duration: number + /** + * e.g., "S_TEXT/ASS", "S_TEXT/UTF8" + */ + codecID: string + extraData?: Record<string, string> +} + +/** + * - Filepath: internal/mkvparser/metadata.go + * - Filename: metadata.go + * - Package: mkvparser + * @description + * TrackInfo holds extracted information about a media track. + */ +export type MKVParser_TrackInfo = { + number: number + uid: number + /** + * "video", "audio", "subtitle", etc. + */ + type: MKVParser_TrackType + codecID: string + name?: string + /** + * Best effort language code + */ + language?: string + /** + * IETF language code + */ + languageIETF?: string + default: boolean + forced: boolean + enabled: boolean + /** + * Raw CodecPrivate data, often used for subtitle headers (e.g., ASS/SSA styles) + */ + codecPrivate?: string + video?: MKVParser_VideoTrack + audio?: MKVParser_AudioTrack +} + +/** + * - Filepath: internal/mkvparser/metadata.go + * - Filename: metadata.go + * - Package: mkvparser + * @description + * TrackType represents the type of a Matroska track. + */ +export type MKVParser_TrackType = "video" | + "audio" | + "subtitle" | + "logo" | + "buttons" | + "complex" | + "unknown" + +/** + * - Filepath: internal/mkvparser/structs.go + * - Filename: structs.go + * - Package: mkvparser + * @description + * VideoTrack contains video-specific track data + */ +export type MKVParser_VideoTrack = { + PixelWidth: number + PixelHeight: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Models +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_AnilistSettings = { + hideAudienceScore: boolean + enableAdultContent: boolean + blurAdultContent: boolean +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_AutoDownloaderItem = { + ruleId: number + mediaId: number + episode: number + link: string + hash: string + magnet: string + torrentName: string + downloaded: boolean + id: number + createdAt?: string + updatedAt?: string +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_AutoDownloaderSettings = { + provider: string + interval: number + enabled: boolean + downloadAutomatically: boolean + enableEnhancedQueries: boolean + enableSeasonCheck: boolean + useDebrid: boolean +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_ChapterDownloadQueueItem = { + provider: string + mediaId: number + chapterId: string + chapterNumber: string + /** + * Contains map of page index to page details + */ + pageData?: Array<string> + status: string + id: number + createdAt?: string + updatedAt?: string +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_DebridSettings = { + enabled: boolean + provider: string + apiKey: string + includeDebridStreamInLibrary: boolean + streamAutoSelect: boolean + streamPreferredResolution: string + id: number + createdAt?: string + updatedAt?: string +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_DiscordSettings = { + enableRichPresence: boolean + enableAnimeRichPresence: boolean + enableMangaRichPresence: boolean + richPresenceHideSeanimeRepositoryButton: boolean + richPresenceShowAniListMediaButton: boolean + richPresenceShowAniListProfileButton: boolean + richPresenceUseMediaTitleStatus: boolean +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_IntSlice = Array<number> + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_LibraryPaths = Array<string> + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_LibrarySettings = { + libraryPath: string + autoUpdateProgress: boolean + disableUpdateCheck: boolean + torrentProvider: string + autoScan: boolean + enableOnlinestream: boolean + includeOnlineStreamingInLibrary: boolean + disableAnimeCardTrailers: boolean + enableManga: boolean + dohProvider: string + openTorrentClientOnStart: boolean + openWebURLOnStart: boolean + refreshLibraryOnStart: boolean + autoPlayNextEpisode: boolean + enableWatchContinuity: boolean + libraryPaths: Models_LibraryPaths + autoSyncOfflineLocalData: boolean + scannerMatchingThreshold: number + scannerMatchingAlgorithm: string + autoSyncToLocalAccount: boolean + autoSaveCurrentMediaOffline: boolean +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_ListSyncSettings = { + automatic: boolean + origin: string +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_MangaSettings = { + defaultMangaProvider: string + mangaAutoUpdateProgress: boolean + mangaLocalSourceDirectory: string +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_MediaPlayerSettings = { + /** + * "vlc" or "mpc-hc" + */ + defaultPlayer: string + host: string + vlcUsername: string + vlcPassword: string + vlcPort: number + vlcPath: string + mpcPort: number + mpcPath: string + mpvSocket: string + mpvPath: string + mpvArgs: string + iinaSocket: string + iinaPath: string + iinaArgs: string +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_MediastreamSettings = { + transcodeEnabled: boolean + transcodeHwAccel: string + transcodeThreads: number + transcodePreset: string + disableAutoSwitchToDirectPlay: boolean + directPlayOnly: boolean + preTranscodeEnabled: boolean + preTranscodeLibraryDir: string + ffmpegPath: string + ffprobePath: string + transcodeHwAccelCustomSettings: string + id: number + createdAt?: string + updatedAt?: string +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_NakamaSettings = { + enabled: boolean + username: string + isHost: boolean + hostPassword: string + remoteServerURL: string + remoteServerPassword: string + includeNakamaAnimeLibrary: boolean + hostShareLocalAnimeLibrary: boolean + hostUnsharedAnimeIds: Models_IntSlice + hostEnablePortForwarding: boolean +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_NotificationSettings = { + disableNotifications: boolean + disableAutoDownloaderNotifications: boolean + disableAutoScannerNotifications: boolean +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_Settings = { + library?: Models_LibrarySettings + mediaPlayer?: Models_MediaPlayerSettings + torrent?: Models_TorrentSettings + manga?: Models_MangaSettings + anilist?: Models_AnilistSettings + listSync?: Models_ListSyncSettings + autoDownloader?: Models_AutoDownloaderSettings + discord?: Models_DiscordSettings + notifications?: Models_NotificationSettings + nakama?: Models_NakamaSettings + id: number + createdAt?: string + updatedAt?: string +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_SilencedMediaEntry = { + id: number + createdAt?: string + updatedAt?: string +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_StringSlice = Array<string> + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_Theme = { + enableColorSettings: boolean + backgroundColor: string + accentColor: string + /** + * DEPRECATED + */ + sidebarBackgroundColor: string + /** + * DEPRECATED + */ + animeEntryScreenLayout: string + expandSidebarOnHover: boolean + hideTopNavbar: boolean + enableMediaCardBlurredBackground: boolean + libraryScreenCustomBackgroundImage: string + libraryScreenCustomBackgroundOpacity: number + smallerEpisodeCarouselSize: boolean + libraryScreenBannerType: string + libraryScreenCustomBannerImage: string + libraryScreenCustomBannerPosition: string + libraryScreenCustomBannerOpacity: number + disableLibraryScreenGenreSelector: boolean + libraryScreenCustomBackgroundBlur: string + enableMediaPageBlurredBackground: boolean + disableSidebarTransparency: boolean + /** + * DEPRECATED + */ + useLegacyEpisodeCard: boolean + disableCarouselAutoScroll: boolean + mediaPageBannerType: string + mediaPageBannerSize: string + mediaPageBannerInfoBoxSize: string + showEpisodeCardAnimeInfo: boolean + continueWatchingDefaultSorting: string + animeLibraryCollectionDefaultSorting: string + mangaLibraryCollectionDefaultSorting: string + showAnimeUnwatchedCount: boolean + showMangaUnreadCount: boolean + hideEpisodeCardDescription: boolean + hideDownloadedEpisodeCardFilename: boolean + customCSS: string + mobileCustomCSS: string + unpinnedMenuItems: Models_StringSlice + id: number + createdAt?: string + updatedAt?: string +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_TorrentSettings = { + defaultTorrentClient: string + qbittorrentPath: string + qbittorrentHost: string + qbittorrentPort: number + qbittorrentUsername: string + qbittorrentPassword: string + qbittorrentTags: string + transmissionPath: string + transmissionHost: string + transmissionPort: number + transmissionUsername: string + transmissionPassword: string + showActiveTorrentCount: boolean + hideTorrentList: boolean +} + +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + */ +export type Models_TorrentstreamSettings = { + enabled: boolean + autoSelect: boolean + preferredResolution: string + disableIPV6: boolean + downloadDir: string + addToLibrary: boolean + torrentClientHost: string + torrentClientPort: number + streamingServerHost: string + streamingServerPort: number + includeInLibrary: boolean + streamUrlAddress: string + slowSeeding: boolean + id: number + createdAt?: string + updatedAt?: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Nakama +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/nakama/nakama.go + * - Filename: nakama.go + * - Package: nakama + * @description + * HostConnectionStatus represents the status of the host connection + */ +export type Nakama_HostConnectionStatus = { + connected: boolean + authenticated: boolean + url: string + lastPing?: string + peerId: string + username: string +} + +/** + * - Filepath: internal/nakama/nakama.go + * - Filename: nakama.go + * - Package: nakama + * @description + * MessageResponse represents a response to message sending requests + */ +export type Nakama_MessageResponse = { + success: boolean + message: string +} + +/** + * - Filepath: internal/nakama/share.go + * - Filename: share.go + * - Package: nakama + */ +export type Nakama_NakamaAnimeLibrary = { + localFiles?: Array<Anime_LocalFile> + animeCollection?: AL_AnimeCollection +} + +/** + * - Filepath: internal/nakama/nakama.go + * - Filename: nakama.go + * - Package: nakama + * @description + * NakamaStatus represents the overall status of Nakama connections + */ +export type Nakama_NakamaStatus = { + isHost: boolean + connectedPeers?: Array<string> + isConnectedToHost: boolean + hostConnectionStatus?: Nakama_HostConnectionStatus + currentWatchPartySession?: Nakama_WatchPartySession +} + +/** + * - Filepath: internal/nakama/watch_party.go + * - Filename: watch_party.go + * - Package: nakama + */ +export type Nakama_OnlineStreamParams = { + mediaId: number + provider: string + server: string + dubbed: boolean + episodeNumber: number + quality: string +} + +/** + * - Filepath: internal/nakama/watch_party.go + * - Filename: watch_party.go + * - Package: nakama + */ +export type Nakama_WatchPartySession = { + id: string + participants?: Record<string, Nakama_WatchPartySessionParticipant> + settings?: Nakama_WatchPartySessionSettings + createdAt?: string + /** + * can be nil if not set + */ + currentMediaInfo?: Nakama_WatchPartySessionMediaInfo + /** + * Whether this session is in relay mode + */ + isRelayMode: boolean +} + +/** + * - Filepath: internal/nakama/watch_party.go + * - Filename: watch_party.go + * - Package: nakama + */ +export type Nakama_WatchPartySessionMediaInfo = { + mediaId: number + episodeNumber: number + aniDbEpisode: string + /** + * "file", "torrent", "debrid", "online" + */ + streamType: string + /** + * URL for stream playback (e.g. /api/v1/nakama/stream?type=file&path=...) + */ + streamPath: string + onlineStreamParams?: Nakama_OnlineStreamParams + optionalTorrentStreamStartOptions?: Torrentstream_StartStreamOptions +} + +/** + * - Filepath: internal/nakama/watch_party.go + * - Filename: watch_party.go + * - Package: nakama + */ +export type Nakama_WatchPartySessionParticipant = { + /** + * PeerID (UUID) for unique identification + */ + id: string + /** + * Display name + */ + username: string + isHost: boolean + canControl: boolean + isReady: boolean + lastSeen?: string + /** + * in milliseconds + */ + latency: number + isBuffering: boolean + /** + * 0.0 to 1.0, how much buffer is available + */ + bufferHealth: number + /** + * Current playback status + */ + playbackStatus?: PlaybackStatus + /** + * Whether this peer is the origin for relay mode + */ + isRelayOrigin: boolean +} + +/** + * - Filepath: internal/nakama/watch_party.go + * - Filename: watch_party.go + * - Package: nakama + */ +export type Nakama_WatchPartySessionSettings = { + /** + * Seconds of desync before forcing sync + */ + syncThreshold: number + /** + * Max time to wait for buffering peers (seconds) + */ + maxBufferWaitTime: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Nativeplayer +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/nativeplayer/events.go + * - Filename: events.go + * - Package: nativeplayer + */ +export type NativePlayer_ClientEvent = "video-paused" | + "video-resumed" | + "video-completed" | + "video-ended" | + "video-seeked" | + "video-error" | + "loaded-metadata" | + "subtitle-file-uploaded" | + "video-terminated" | + "video-time-update" + +/** + * - Filepath: internal/nativeplayer/nativeplayer.go + * - Filename: nativeplayer.go + * - Package: nativeplayer + */ +export type NativePlayer_PlaybackInfo = { + id: string + streamType: NativePlayer_StreamType + /** + * e.g. "video/mp4", "video/webm" + */ + mimeType: string + /** + * URL of the stream + */ + streamUrl: string + /** + * Size of the stream in bytes + */ + contentLength: number + /** + * nil if not ebml + */ + mkvMetadata?: MKVParser_Metadata + /** + * nil if not in list + */ + entryListData?: Anime_EntryListData + episode?: Anime_Episode + media?: AL_BaseAnime +} + +/** + * - Filepath: internal/nativeplayer/events.go + * - Filename: events.go + * - Package: nativeplayer + */ +export type NativePlayer_ServerEvent = "open-and-await" | + "watch" | + "subtitle-event" | + "set-tracks" | + "pause" | + "resume" | + "seek" | + "error" | + "add-subtitle-track" | + "terminate" + +/** + * - Filepath: internal/nativeplayer/nativeplayer.go + * - Filename: nativeplayer.go + * - Package: nativeplayer + */ +export type NativePlayer_StreamType = "torrent" | "localfile" | "debrid" + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Onlinestream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/onlinestream/repository.go + * - Filename: repository.go + * - Package: onlinestream + */ +export type Onlinestream_Episode = { + number: number + title?: string + image?: string + description?: string + isFiller?: boolean +} + +/** + * - Filepath: internal/onlinestream/repository.go + * - Filename: repository.go + * - Package: onlinestream + */ +export type Onlinestream_EpisodeListResponse = { + episodes?: Array<Onlinestream_Episode> + media?: AL_BaseAnime +} + +/** + * - Filepath: internal/onlinestream/repository.go + * - Filename: repository.go + * - Package: onlinestream + */ +export type Onlinestream_EpisodeSource = { + number: number + videoSources?: Array<Onlinestream_VideoSource> + subtitles?: Array<Onlinestream_Subtitle> +} + +/** + * - Filepath: internal/onlinestream/manual_mapping.go + * - Filename: manual_mapping.go + * - Package: onlinestream + */ +export type Onlinestream_MappingResponse = { + animeId?: string +} + +/** + * - Filepath: internal/onlinestream/repository.go + * - Filename: repository.go + * - Package: onlinestream + */ +export type Onlinestream_Subtitle = { + url: string + language: string +} + +/** + * - Filepath: internal/onlinestream/repository.go + * - Filename: repository.go + * - Package: onlinestream + */ +export type Onlinestream_VideoSource = { + server: string + headers?: Record<string, string> + url: string + quality: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Report +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/report/report.go + * - Filename: report.go + * - Package: report + */ +export type Report_ClickLog = { + timestamp?: string + element: string + pageUrl: string + text?: string + className?: string +} + +/** + * - Filepath: internal/report/report.go + * - Filename: report.go + * - Package: report + */ +export type Report_ConsoleLog = { + type: string + content: string + pageUrl: string + timestamp?: string +} + +/** + * - Filepath: internal/report/report.go + * - Filename: report.go + * - Package: report + */ +export type Report_IssueReport = { + createdAt?: string + userAgent: string + appVersion: string + os: string + arch: string + clickLogs?: Array<Report_ClickLog> + networkLogs?: Array<Report_NetworkLog> + reactQueryLogs?: Array<Report_ReactQueryLog> + consoleLogs?: Array<Report_ConsoleLog> + unlockedLocalFiles?: Array<Report_UnlockedLocalFile> + scanLogs?: Array<string> + serverLogs?: string + status?: string +} + +/** + * - Filepath: internal/report/report.go + * - Filename: report.go + * - Package: report + */ +export type Report_NetworkLog = { + type: string + method: string + url: string + pageUrl: string + status: number + duration: number + dataPreview: string + body: string + timestamp?: string +} + +/** + * - Filepath: internal/report/report.go + * - Filename: report.go + * - Package: report + */ +export type Report_ReactQueryLog = { + type: string + pageUrl: string + status: string + hash: string + error: any + dataPreview: string + dataType: string + timestamp?: string +} + +/** + * - Filepath: internal/report/report.go + * - Filename: report.go + * - Package: report + */ +export type Report_UnlockedLocalFile = { + path: string + mediaId: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Summary +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/library/summary/scan_summary.go + * - Filename: scan_summary.go + * - Package: summary + */ +export type Summary_ScanSummary = { + id: string + groups?: Array<Summary_ScanSummaryGroup> + unmatchedFiles?: Array<Summary_ScanSummaryFile> +} + +/** + * - Filepath: internal/library/summary/scan_summary.go + * - Filename: scan_summary.go + * - Package: summary + */ +export type Summary_ScanSummaryFile = { + id: string + localFile?: Anime_LocalFile + logs?: Array<Summary_ScanSummaryLog> +} + +/** + * - Filepath: internal/library/summary/scan_summary.go + * - Filename: scan_summary.go + * - Package: summary + */ +export type Summary_ScanSummaryGroup = { + id: string + files?: Array<Summary_ScanSummaryFile> + mediaId: number + mediaTitle: string + mediaImage: string + /** + * Whether the media is in the user's AniList collection + */ + mediaIsInCollection: boolean +} + +/** + * - Filepath: internal/library/summary/scan_summary.go + * - Filename: scan_summary.go + * - Package: summary + */ +export type Summary_ScanSummaryItem = { + createdAt?: string + scanSummary?: Summary_ScanSummary +} + +/** + * - Filepath: internal/library/summary/scan_summary.go + * - Filename: scan_summary.go + * - Package: summary + */ +export type Summary_ScanSummaryLog = { + id: string + filePath: string + level: string + message: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Torrent +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/torrents/torrent/search.go + * - Filename: search.go + * - Package: torrent + */ +export type Torrent_Preview = { + /** + * nil if batch + */ + episode?: Anime_Episode + torrent?: HibikeTorrent_AnimeTorrent +} + +/** + * - Filepath: internal/torrents/torrent/search.go + * - Filename: search.go + * - Package: torrent + */ +export type Torrent_SearchData = { + /** + * Torrents found + */ + torrents?: Array<HibikeTorrent_AnimeTorrent> + /** + * TorrentPreview for each torrent + */ + previews?: Array<Torrent_Preview> + /** + * Torrent metadata + */ + torrentMetadata?: Record<string, Torrent_TorrentMetadata> + /** + * Debrid instant availability + */ + debridInstantAvailability?: Record<string, Debrid_TorrentItemInstantAvailability> + /** + * Animap media + */ + animeMetadata?: Metadata_AnimeMetadata +} + +/** + * - Filepath: internal/torrents/torrent/search.go + * - Filename: search.go + * - Package: torrent + */ +export type Torrent_TorrentMetadata = { + distance: number + metadata?: Habari_Metadata +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// TorrentClient +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/torrent_clients/torrent_client/torrent.go + * - Filename: torrent.go + * - Package: torrent_client + */ +export type TorrentClient_Torrent = { + name: string + hash: string + seeds: number + upSpeed: string + downSpeed: string + progress: number + size: string + eta: string + status: TorrentClient_TorrentStatus + contentPath: string +} + +/** + * - Filepath: internal/torrent_clients/torrent_client/torrent.go + * - Filename: torrent.go + * - Package: torrent_client + */ +export type TorrentClient_TorrentStatus = "downloading" | "seeding" | "paused" | "other" | "stopped" + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Torrentstream +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/torrentstream/history.go + * - Filename: history.go + * - Package: torrentstream + */ +export type Torrentstream_BatchHistoryResponse = { + torrent?: HibikeTorrent_AnimeTorrent +} + +/** + * - Filepath: internal/torrentstream/previews.go + * - Filename: previews.go + * - Package: torrentstream + */ +export type Torrentstream_FilePreview = { + path: string + displayPath: string + displayTitle: string + episodeNumber: number + relativeEpisodeNumber: number + isLikely: boolean + index: number +} + +/** + * - Filepath: internal/torrentstream/stream.go + * - Filename: stream.go + * - Package: torrentstream + */ +export type Torrentstream_PlaybackType = "default" | "externalPlayerLink" | "nativeplayer" | "none" | "noneAndAwait" + +/** + * - Filepath: internal/torrentstream/stream.go + * - Filename: stream.go + * - Package: torrentstream + */ +export type Torrentstream_StartStreamOptions = { + MediaId: number + /** + * RELATIVE Episode number to identify the file + */ + EpisodeNumber: number + /** + * Animap episode + */ + AniDBEpisode: string + /** + * Automatically select the best file to stream + */ + AutoSelect: boolean + /** + * Selected torrent (Manual selection) + */ + Torrent?: HibikeTorrent_AnimeTorrent + /** + * Index of the file to stream (Manual selection) + */ + FileIndex?: number + UserAgent: string + ClientId: string + PlaybackType: Torrentstream_PlaybackType +} + +/** + * - Filepath: internal/torrentstream/client.go + * - Filename: client.go + * - Package: torrentstream + */ +export type Torrentstream_TorrentStatus = { + uploadProgress: number + downloadProgress: number + progressPercentage: number + downloadSpeed: string + uploadSpeed: string + size: string + seeders: number +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Updater +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/updater/announcement.go + * - Filename: announcement.go + * - Package: updater + */ +export type Updater_Announcement = { + /** + * Unique identifier for tracking + */ + id: string + /** + * Title for dialogs/banners + */ + title?: string + /** + * The message to display + */ + message: string + /** + * The type of announcement + */ + type: Updater_AnnouncementType + /** + * Severity level + */ + severity: Updater_AnnouncementSeverity + /** + * Date of the announcement + */ + date: any + /** + * Can user dismiss it + */ + notDismissible: boolean + /** + * Advanced targeting + */ + conditions?: Updater_AnnouncementConditions + /** + * Action buttons + */ + actions?: Array<Updater_AnnouncementAction> + priority: number +} + +/** + * - Filepath: internal/updater/announcement.go + * - Filename: announcement.go + * - Package: updater + */ +export type Updater_AnnouncementAction = { + label: string + url: string + type: string +} + +/** + * - Filepath: internal/updater/announcement.go + * - Filename: announcement.go + * - Package: updater + */ +export type Updater_AnnouncementConditions = { + /** + * ["windows", "darwin", "linux"] + */ + os?: Array<string> + /** + * ["tauri", "web", "denshi"] + */ + platform?: Array<string> + /** + * e.g. "<= 2.9.0", "2.9.0" + */ + versionConstraint?: string + /** + * JSON path to check in user settings + */ + userSettingsPath?: string + /** + * Expected values at that path + */ + userSettingsValue?: Array<string> +} + +/** + * - Filepath: internal/updater/announcement.go + * - Filename: announcement.go + * - Package: updater + */ +export type Updater_AnnouncementSeverity = "info" | "warning" | "error" | "critical" + +/** + * - Filepath: internal/updater/announcement.go + * - Filename: announcement.go + * - Package: updater + */ +export type Updater_AnnouncementType = "toast" | "dialog" | "banner" + +/** + * - Filepath: internal/updater/check.go + * - Filename: check.go + * - Package: updater + */ +export type Updater_Release = { + url: string + html_url: string + node_id: string + tag_name: string + name: string + body: string + published_at: string + released: boolean + version: string + assets?: Array<Updater_ReleaseAsset> +} + +/** + * - Filepath: internal/updater/check.go + * - Filename: check.go + * - Package: updater + */ +export type Updater_ReleaseAsset = { + url: string + id: number + node_id: string + name: string + content_type: string + uploaded: boolean + size: number + browser_download_url: string +} + +/** + * - Filepath: internal/updater/updater.go + * - Filename: updater.go + * - Package: updater + */ +export type Updater_Update = { + release?: Updater_Release + current_version?: string + type: string +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// User +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/user/user.go + * - Filename: user.go + * - Package: user + */ +export type User = { + viewer?: AL_GetViewer_Viewer + token: string + isSimulated: boolean +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// VendorHabari +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/vendor_habari/vendor_habari.go + * - Filename: vendor_habari.go + * - Package: vendor_habari + */ +export type Habari_Metadata = { + season_number?: Array<string> + part_number?: Array<string> + title?: string + formatted_title?: string + anime_type?: Array<string> + year?: string + audio_term?: Array<string> + device_compatibility?: Array<string> + episode_number?: Array<string> + other_episode_number?: Array<string> + episode_number_alt?: Array<string> + episode_title?: string + file_checksum?: string + file_extension?: string + file_name?: string + language?: Array<string> + release_group?: string + release_information?: Array<string> + release_version?: Array<string> + source?: Array<string> + subtitles?: Array<string> + video_resolution?: string + video_term?: Array<string> + volume_number?: Array<string> +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Videofile +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - Filepath: internal/mediastream/videofile/info.go + * - Filename: info.go + * - Package: videofile + */ +export type Audio = { + index: number + title?: string + language?: string + codec: string + mimeCodec?: string + isDefault: boolean + isForced: boolean + channels: number +} + +/** + * - Filepath: internal/mediastream/videofile/info.go + * - Filename: info.go + * - Package: videofile + */ +export type Chapter = { + startTime: number + endTime: number + name: string +} + +/** + * - Filepath: internal/mediastream/videofile/info.go + * - Filename: info.go + * - Package: videofile + */ +export type MediaInfo = { + ready: any + sha: string + path: string + extension: string + mimeCodec?: string + size: number + duration: number + container?: string + video?: Video + videos?: Array<Video> + audios?: Array<Audio> + subtitles?: Array<Subtitle> + fonts?: Array<string> + chapters?: Array<Chapter> +} + +/** + * - Filepath: internal/mediastream/videofile/video_quality.go + * - Filename: video_quality.go + * - Package: videofile + */ +export type Quality = "240p" | + "360p" | + "480p" | + "720p" | + "1080p" | + "1440p" | + "4k" | + "8k" | + "original" + +/** + * - Filepath: internal/mediastream/videofile/info.go + * - Filename: info.go + * - Package: videofile + */ +export type Subtitle = { + index: number + title?: string + language?: string + codec: string + extension?: string + isDefault: boolean + isForced: boolean + isExternal: boolean + link?: string +} + +/** + * - Filepath: internal/mediastream/videofile/info.go + * - Filename: info.go + * - Package: videofile + */ +export type Video = { + codec: string + mimeCodec?: string + language?: string + quality: Quality + width: number + height: number + bitrate: number +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/anilist.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/anilist.hooks.ts new file mode 100644 index 0000000..706e39c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/anilist.hooks.ts @@ -0,0 +1,162 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + AnilistListAnime_Variables, + AnilistListRecentAiringAnime_Variables, + DeleteAnilistListEntry_Variables, + EditAnilistListEntry_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { + AL_AnimeCollection, + AL_AnimeDetailsById_Media, + AL_BaseAnime, + AL_ListAnime, + AL_ListRecentAnime, + AL_Stats, + AL_StudioDetails, + Nullish, +} from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useGetAnimeCollection() { + return useServerQuery<AL_AnimeCollection>({ + endpoint: API_ENDPOINTS.ANILIST.GetAnimeCollection.endpoint, + method: API_ENDPOINTS.ANILIST.GetAnimeCollection.methods[0], + queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key], + enabled: true, + }) +} + +export function useGetRawAnimeCollection() { + return useServerQuery<AL_AnimeCollection>({ + endpoint: API_ENDPOINTS.ANILIST.GetRawAnimeCollection.endpoint, + method: API_ENDPOINTS.ANILIST.GetRawAnimeCollection.methods[0], + queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key], + enabled: true, + }) +} + +export function useRefreshAnimeCollection() { + const queryClient = useQueryClient() + + return useServerMutation<AL_AnimeCollection>({ + endpoint: API_ENDPOINTS.ANILIST.GetAnimeCollection.endpoint, + method: API_ENDPOINTS.ANILIST.GetAnimeCollection.methods[1], + mutationKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key], + onSuccess: async () => { + toast.success("AniList is up-to-date") + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.key] }) + }, + }) +} + +export function useEditAnilistListEntry(id: Nullish<string | number>, type: "anime" | "manga") { + const queryClient = useQueryClient() + + return useServerMutation<boolean, EditAnilistListEntry_Variables>({ + endpoint: API_ENDPOINTS.ANILIST.EditAnilistListEntry.endpoint, + method: API_ENDPOINTS.ANILIST.EditAnilistListEntry.methods[0], + mutationKey: [API_ENDPOINTS.ANILIST.EditAnilistListEntry.key, String(id)], + onSuccess: async () => { + toast.success("Entry updated") + if (type === "anime") { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] }) + } else if (type === "manga") { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] }) + } + }, + }) +} + +export function useGetAnilistAnimeDetails(id: Nullish<number | string>) { + return useServerQuery<AL_AnimeDetailsById_Media>({ + endpoint: API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.endpoint.replace("{id}", String(id)), + method: API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.methods[0], + queryKey: [API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.key, String(id)], + enabled: !!id, + }) +} + +export function useDeleteAnilistListEntry(id: Nullish<string | number>, type: "anime" | "manga", onSuccess: () => void) { + const queryClient = useQueryClient() + + return useServerMutation<boolean, DeleteAnilistListEntry_Variables>({ + endpoint: API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.endpoint, + method: API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.methods[0], + mutationKey: [API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.key], + onSuccess: async () => { + toast.success("Entry deleted") + if (type === "anime") { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] }) + } else if (type === "manga") { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] }) + } + onSuccess() + }, + }) +} + +export function useAnilistListAnime(variables: AnilistListAnime_Variables, enabled: boolean) { + return useServerQuery<AL_ListAnime, AnilistListAnime_Variables>({ + endpoint: API_ENDPOINTS.ANILIST.AnilistListAnime.endpoint, + method: API_ENDPOINTS.ANILIST.AnilistListAnime.methods[0], + queryKey: [API_ENDPOINTS.ANILIST.AnilistListAnime.key, variables], + data: variables, + enabled: enabled ?? true, + }) +} + +export function useAnilistListRecentAiringAnime(variables: AnilistListRecentAiringAnime_Variables, enabled: boolean = true) { + return useServerQuery<AL_ListRecentAnime, AnilistListRecentAiringAnime_Variables>({ + endpoint: API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.endpoint, + method: API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.methods[0], + queryKey: [API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.key, JSON.stringify(variables)], + data: variables, + enabled: enabled, + }) +} + +export function useGetAnilistStudioDetails(id: number) { + return useServerQuery<AL_StudioDetails>({ + endpoint: API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.endpoint.replace("{id}", String(id)), + method: API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.methods[0], + queryKey: [API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.key, String(id)], + enabled: true, + }) +} + +export function useGetAniListStats(enabled: boolean = true) { + return useServerQuery<AL_Stats>({ + endpoint: API_ENDPOINTS.ANILIST.GetAniListStats.endpoint, + method: API_ENDPOINTS.ANILIST.GetAniListStats.methods[0], + queryKey: [API_ENDPOINTS.ANILIST.GetAniListStats.key], + enabled: enabled, + }) +} + +export function useAnilistListMissedSequels(enabled: boolean) { + return useServerQuery<Array<AL_BaseAnime>>({ + endpoint: API_ENDPOINTS.ANILIST.AnilistListMissedSequels.endpoint, + method: API_ENDPOINTS.ANILIST.AnilistListMissedSequels.methods[0], + queryKey: [API_ENDPOINTS.ANILIST.AnilistListMissedSequels.key], + enabled: enabled, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/anime.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/anime.hooks.ts new file mode 100644 index 0000000..a575632 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/anime.hooks.ts @@ -0,0 +1,12 @@ +import { useServerQuery } from "@/api/client/requests" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Anime_EpisodeCollection } from "@/api/generated/types" + +export function useGetAnimeEpisodeCollection(id: number) { + return useServerQuery<Anime_EpisodeCollection>({ + endpoint: API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.endpoint.replace("{id}", String(id)), + method: API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.methods[0], + queryKey: [API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.key, String(id)], + enabled: true, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/anime_collection.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/anime_collection.hooks.ts new file mode 100644 index 0000000..a6bace1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/anime_collection.hooks.ts @@ -0,0 +1,40 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { AddUnknownMedia_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { AL_AnimeCollection, Anime_LibraryCollection, Anime_ScheduleItem } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useGetLibraryCollection() { + return useServerQuery<Anime_LibraryCollection>({ + endpoint: API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.endpoint, + method: API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.methods[0], + queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key], + enabled: true, + }) +} + +export function useAddUnknownMedia() { + const queryClient = useQueryClient() + + return useServerMutation<AL_AnimeCollection, AddUnknownMedia_Variables>({ + endpoint: API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.endpoint, + method: API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.methods[0], + mutationKey: [API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.key], + onSuccess: async () => { + toast.success("Media added successfully") + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] }) + }, + }) +} + +export function useGetAnimeCollectionSchedule() { + return useServerQuery<Array<Anime_ScheduleItem>>({ + endpoint: API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.endpoint, + method: API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.methods[0], + queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.key], + enabled: true, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/anime_entries.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/anime_entries.hooks.ts new file mode 100644 index 0000000..9b1d2c1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/anime_entries.hooks.ts @@ -0,0 +1,143 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + AnimeEntryBulkAction_Variables, + AnimeEntryManualMatch_Variables, + FetchAnimeEntrySuggestions_Variables, + OpenAnimeEntryInExplorer_Variables, + ToggleAnimeEntrySilenceStatus_Variables, + UpdateAnimeEntryProgress_Variables, + UpdateAnimeEntryRepeat_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { AL_BaseAnime, Anime_Entry, Anime_LocalFile, Anime_MissingEpisodes, Nullish } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useGetAnimeEntry(id: Nullish<string | number>) { + return useServerQuery<Anime_Entry>({ + endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.endpoint.replace("{id}", String(id)), + method: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.methods[0], + queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)], + enabled: !!id, + }) +} + +export function useAnimeEntryBulkAction(id?: Nullish<number>, onSuccess?: () => void) { + const queryClient = useQueryClient() + + return useServerMutation<Array<Anime_LocalFile>, AnimeEntryBulkAction_Variables>({ + endpoint: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.endpoint, + method: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.methods[0], + mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.key, String(id)], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] }) + onSuccess?.() + }, + }) +} + +export function useOpenAnimeEntryInExplorer() { + return useServerMutation<boolean, OpenAnimeEntryInExplorer_Variables>({ + endpoint: API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.endpoint, + method: API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.methods[0], + mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.key], + onSuccess: async () => { + + }, + }) +} + +export function useFetchAnimeEntrySuggestions() { + return useServerMutation<Array<AL_BaseAnime>, FetchAnimeEntrySuggestions_Variables>({ + endpoint: API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.endpoint, + method: API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.methods[0], + mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.key], + onSuccess: async () => { + + }, + }) +} + +export function useAnimeEntryManualMatch() { + const queryClient = useQueryClient() + + return useServerMutation<Array<Anime_LocalFile>, AnimeEntryManualMatch_Variables>({ + endpoint: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.endpoint, + method: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.methods[0], + mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + toast.success("Files matched") + }, + }) +} + +export function useGetMissingEpisodes(enabled?: boolean) { + return useServerQuery<Anime_MissingEpisodes>({ + endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.endpoint, + method: API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.methods[0], + queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key], + enabled: enabled ?? true, // Default to true if not provided + }) +} + +export function useGetAnimeEntrySilenceStatus(id: Nullish<string | number>) { + const { data, ...rest } = useServerQuery({ + endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.endpoint.replace("{id}", String(id)), + method: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.methods[0], + queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.key], + enabled: !!id, + }) + + return { isSilenced: !!data, ...rest } +} + +export function useToggleAnimeEntrySilenceStatus() { + const queryClient = useQueryClient() + + return useServerMutation<boolean, ToggleAnimeEntrySilenceStatus_Variables>({ + endpoint: API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.endpoint, + method: API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.methods[0], + mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] }) + }, + }) +} + +export function useUpdateAnimeEntryProgress(id: Nullish<string | number>, episodeNumber: number) { + const queryClient = useQueryClient() + + return useServerMutation<boolean, UpdateAnimeEntryProgress_Variables>({ + endpoint: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.endpoint, + method: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.methods[0], + mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.key, id, episodeNumber], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + if (id) { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] }) + } + toast.success("Progress updated successfully") + }, + }) +} + +export function useUpdateAnimeEntryRepeat(id: Nullish<string | number>) { + const queryClient = useQueryClient() + + return useServerMutation<boolean, UpdateAnimeEntryRepeat_Variables>({ + endpoint: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.endpoint, + method: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.methods[0], + mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.key, id], + onSuccess: async () => { + // if (id) { + // await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] }) + // } + // toast.success("Updated successfully") + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/auth.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/auth.hooks.ts new file mode 100644 index 0000000..848ded1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/auth.hooks.ts @@ -0,0 +1,61 @@ +import { useServerMutation } from "@/api/client/requests" +import { Login_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Status } from "@/api/generated/types" +import { useSetServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useQueryClient } from "@tanstack/react-query" +import { useRouter } from "next/navigation" +import { toast } from "sonner" + +export function useLogin() { + const queryClient = useQueryClient() + const router = useRouter() + const setServerStatus = useSetServerStatus() + + return useServerMutation<Status, Login_Variables>({ + endpoint: API_ENDPOINTS.AUTH.Login.endpoint, + method: API_ENDPOINTS.AUTH.Login.methods[0], + mutationKey: [API_ENDPOINTS.AUTH.Login.key], + onSuccess: async data => { + if (data) { + toast.success("Successfully authenticated") + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] }) + setServerStatus(data) + router.push("/") + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] }) + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] }) + } + }, + onError: async error => { + toast.error(error.message) + router.push("/") + }, + }) +} + +export function useLogout() { + const queryClient = useQueryClient() + const router = useRouter() + const setServerStatus = useSetServerStatus() + + return useServerMutation<Status>({ + endpoint: API_ENDPOINTS.AUTH.Logout.endpoint, + method: API_ENDPOINTS.AUTH.Logout.methods[0], + mutationKey: [API_ENDPOINTS.AUTH.Logout.key], + onSuccess: async () => { + toast.success("Successfully logged out") + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] }) + router.push("/") + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] }) + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] }) + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/auto_downloader.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/auto_downloader.hooks.ts new file mode 100644 index 0000000..d2da6d6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/auto_downloader.hooks.ts @@ -0,0 +1,122 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + CreateAutoDownloaderRule_Variables, + DeleteAutoDownloaderItem_Variables, + UpdateAutoDownloaderRule_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Anime_AutoDownloaderRule, Models_AutoDownloaderItem, Nullish } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useRunAutoDownloader() { + const queryClient = useQueryClient() + + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.endpoint, + method: API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.methods[0], + mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.key], + onSuccess: async () => { + toast.success("Auto downloader started") + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] }) + }, 1000) + }, + }) +} + +export function useGetAutoDownloaderRule(id: number) { + return useServerQuery<Anime_AutoDownloaderRule>({ + endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.endpoint.replace("{id}", String(id)), + method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.methods[0], + queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.key], + enabled: true, + }) +} + +export function useGetAutoDownloaderRules() { + return useServerQuery<Array<Anime_AutoDownloaderRule>>({ + endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.endpoint, + method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.methods[0], + queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key], + enabled: true, + }) +} + +export function useCreateAutoDownloaderRule() { + const queryClient = useQueryClient() + + return useServerMutation<Anime_AutoDownloaderRule, CreateAutoDownloaderRule_Variables>({ + endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.endpoint, + method: API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.methods[0], + mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] }) + toast.success("Rule created") + }, + }) +} + +export function useUpdateAutoDownloaderRule() { + const queryClient = useQueryClient() + + return useServerMutation<Anime_AutoDownloaderRule, UpdateAutoDownloaderRule_Variables>({ + endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.endpoint, + method: API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.methods[0], + mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] }) + toast.success("Rule updated") + }, + }) +} + +export function useDeleteAutoDownloaderRule(id: Nullish<number>) { + const queryClient = useQueryClient() + + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.endpoint.replace("{id}", String(id)), + method: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.methods[0], + mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.key, String(id)], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] }) + toast.success("Rule deleted") + }, + }) +} + +export function useGetAutoDownloaderItems(enabled: boolean = true) { + return useServerQuery<Array<Models_AutoDownloaderItem>>({ + endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.endpoint, + method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.methods[0], + queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key], + enabled: enabled, + }) +} + +export function useDeleteAutoDownloaderItem() { + const queryClient = useQueryClient() + + return useServerMutation<boolean, DeleteAutoDownloaderItem_Variables>({ + endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.endpoint, + method: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.methods[0], + mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.key], + onSuccess: async () => { + toast.success("Item deleted") + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] }) + }, + }) +} + +export function useGetAutoDownloaderRulesByAnime(id: number, enabled: boolean) { + return useServerQuery<Array<Anime_AutoDownloaderRule>>({ + endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.endpoint.replace("{id}", String(id)), + method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.methods[0], + queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key, String(id)], + enabled: enabled, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/continuity.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/continuity.hooks.ts new file mode 100644 index 0000000..ea2ebb7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/continuity.hooks.ts @@ -0,0 +1,125 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { GetContinuityWatchHistoryItem_Variables, UpdateContinuityWatchHistoryItem_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Continuity_WatchHistory, Continuity_WatchHistoryItemResponse, Nullish } from "@/api/generated/types" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { logger } from "@/lib/helpers/debug" +import { useQueryClient } from "@tanstack/react-query" +import { MediaPlayerInstance } from "@vidstack/react" +import React from "react" + +export function useUpdateContinuityWatchHistoryItem() { + const qc = useQueryClient() + return useServerMutation<boolean, UpdateContinuityWatchHistoryItem_Variables>({ + endpoint: API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.endpoint, + method: API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.methods[0], + mutationKey: [API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.key] }) + }, + }) +} + +export function useGetContinuityWatchHistoryItem(mediaId: Nullish<number | string>) { + const serverStatus = useServerStatus() + return useServerQuery<Continuity_WatchHistoryItemResponse, GetContinuityWatchHistoryItem_Variables>({ + endpoint: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.endpoint.replace("{id}", String(mediaId)), + method: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.methods[0], + queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.key, String(mediaId)], + enabled: serverStatus?.settings?.library?.enableWatchContinuity && !!mediaId, + }) +} + +export function useGetContinuityWatchHistory() { + return useServerQuery<Continuity_WatchHistory>({ + endpoint: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.endpoint, + method: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.methods[0], + queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.key], + enabled: true, + }) +} + +export function getEpisodePercentageComplete(history: Nullish<Continuity_WatchHistory>, mediaId: number, progressNumber: number) { + if (!history) return 0 + const item = history[mediaId] + if (!item || !item.currentTime || !item.duration) return 0 + if (item.episodeNumber !== progressNumber) return 0 + const percent = Math.round((item.currentTime / item.duration) * 100) + if (percent > 90 || percent < 5) return 0 + return percent +} + +export function getEpisodeMinutesRemaining(history: Nullish<Continuity_WatchHistory>, mediaId: number, progressNumber: number) { + if (!history) return 0 + const item = history[mediaId] + if (!item || !item.currentTime || !item.duration) return 0 + if (item.episodeNumber !== progressNumber) return 0 + return Math.round((item.duration - item.currentTime) / 60) +} + +export function useHandleContinuityWithMediaPlayer(playerRef: React.RefObject<MediaPlayerInstance | HTMLVideoElement>, + episodeNumber: Nullish<number>, + mediaId: Nullish<number | string>, +) { + const serverStatus = useServerStatus() + const qc = useQueryClient() + + React.useEffect(() => { + (async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.key] }) + })() + }, [episodeNumber ?? 0]) + + const { mutate: updateWatchHistory } = useUpdateContinuityWatchHistoryItem() + + function handleUpdateWatchHistory() { + if (!serverStatus?.settings?.library?.enableWatchContinuity) return + + if (playerRef.current?.duration && playerRef.current?.currentTime) { + logger("CONTINUITY").info("Watch history updated", { + currentTime: playerRef.current?.currentTime, + duration: playerRef.current?.duration, + }) + + updateWatchHistory({ + options: { + currentTime: playerRef.current?.currentTime ?? 0, + duration: playerRef.current?.duration ?? 0, + mediaId: Number(mediaId), + episodeNumber: episodeNumber ?? 0, + kind: "onlinestream", + }, + }) + } + } + + return { handleUpdateWatchHistory } +} + +export function useHandleCurrentMediaContinuity(mediaId: Nullish<number | string>) { + const serverStatus = useServerStatus() + + const { data: watchHistory, isLoading: watchHistoryLoading } = useGetContinuityWatchHistoryItem(mediaId) + + const waitForWatchHistory = watchHistoryLoading && serverStatus?.settings?.library?.enableWatchContinuity + + function getEpisodeContinuitySeekTo(episodeNumber: Nullish<number>, playerCurrentTime: Nullish<number>, playerDuration: Nullish<number>) { + if (!serverStatus?.settings?.library?.enableWatchContinuity || !mediaId || !watchHistory || !playerDuration || !episodeNumber) return 0 + const item = watchHistory?.item + if (!item || !item.currentTime || !item.duration || item.episodeNumber !== episodeNumber) return 0 + if (!(item.currentTime > 0 && item.currentTime < playerDuration) || (item.currentTime / item.duration) > 90) return 0 + logger("CONTINUITY").info("Found last watched time", { + currentTime: item.currentTime, + duration: item.duration, + episodeNumber: item.episodeNumber, + }) + return item.currentTime + } + + return { + watchHistory, + waitForWatchHistory, + getEpisodeContinuitySeekTo, + } +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/debrid.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/debrid.hooks.ts new file mode 100644 index 0000000..b976d3f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/debrid.hooks.ts @@ -0,0 +1,142 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + DebridAddTorrents_Variables, + DebridCancelDownload_Variables, + DebridCancelStream_Variables, + DebridDeleteTorrent_Variables, + DebridDownloadTorrent_Variables, + DebridGetTorrentFilePreviews_Variables, + DebridGetTorrentInfo_Variables, + DebridStartStream_Variables, + SaveDebridSettings_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Debrid_TorrentInfo, Debrid_TorrentItem, DebridClient_FilePreview, Models_DebridSettings } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useGetDebridSettings() { + return useServerQuery<Models_DebridSettings>({ + endpoint: API_ENDPOINTS.DEBRID.GetDebridSettings.endpoint, + method: API_ENDPOINTS.DEBRID.GetDebridSettings.methods[0], + queryKey: [API_ENDPOINTS.DEBRID.GetDebridSettings.key], + enabled: true, + }) +} + +export function useSaveDebridSettings() { + const qc = useQueryClient() + return useServerMutation<Models_DebridSettings, SaveDebridSettings_Variables>({ + endpoint: API_ENDPOINTS.DEBRID.SaveDebridSettings.endpoint, + method: API_ENDPOINTS.DEBRID.SaveDebridSettings.methods[0], + mutationKey: [API_ENDPOINTS.DEBRID.SaveDebridSettings.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.DEBRID.GetDebridSettings.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] }) + toast.success("Settings saved") + }, + }) +} + +export function useDebridAddTorrents(onSuccess: () => void) { + const qc = useQueryClient() + return useServerMutation<boolean, DebridAddTorrents_Variables>({ + endpoint: API_ENDPOINTS.DEBRID.DebridAddTorrents.endpoint, + method: API_ENDPOINTS.DEBRID.DebridAddTorrents.methods[0], + mutationKey: [API_ENDPOINTS.DEBRID.DebridAddTorrents.key], + onSuccess: async () => { + onSuccess() + toast.success("Torrent added") + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrents.key] }) + }, + }) +} + +export function useDebridDownloadTorrent() { + return useServerMutation<boolean, DebridDownloadTorrent_Variables>({ + endpoint: API_ENDPOINTS.DEBRID.DebridDownloadTorrent.endpoint, + method: API_ENDPOINTS.DEBRID.DebridDownloadTorrent.methods[0], + mutationKey: [API_ENDPOINTS.DEBRID.DebridDownloadTorrent.key], + onSuccess: async () => { + toast.info("Download started") + }, + }) +} + +export function useDebridCancelDownload() { + return useServerMutation<boolean, DebridCancelDownload_Variables>({ + endpoint: API_ENDPOINTS.DEBRID.DebridCancelDownload.endpoint, + method: API_ENDPOINTS.DEBRID.DebridCancelDownload.methods[0], + mutationKey: [API_ENDPOINTS.DEBRID.DebridCancelDownload.key], + onSuccess: async () => { + toast.info("Download cancelled") + }, + }) +} + +export function useDebridDeleteTorrent() { + const qc = useQueryClient() + return useServerMutation<boolean, DebridDeleteTorrent_Variables>({ + endpoint: API_ENDPOINTS.DEBRID.DebridDeleteTorrent.endpoint, + method: API_ENDPOINTS.DEBRID.DebridDeleteTorrent.methods[0], + mutationKey: [API_ENDPOINTS.DEBRID.DebridDeleteTorrent.key], + onSuccess: async () => { + toast.success("Torrent deleted") + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrents.key] }) + }, + }) +} + +export function useDebridGetTorrents(enabled: boolean, refetchInterval: number) { + return useServerQuery<Array<Debrid_TorrentItem>>({ + endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrents.endpoint, + method: API_ENDPOINTS.DEBRID.DebridGetTorrents.methods[0], + queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrents.key], + enabled: enabled, + retry: 3, + refetchInterval: refetchInterval, + }) +} + +export function useDebridGetTorrentInfo(variables: Partial<DebridGetTorrentInfo_Variables>, enabled: boolean) { + return useServerQuery<Debrid_TorrentInfo, DebridGetTorrentInfo_Variables>({ + endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.endpoint, + method: API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.methods[0], + queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.key, variables?.torrent?.infoHash], + data: variables as DebridGetTorrentInfo_Variables, + enabled: enabled, + gcTime: 0, + }) +} + +export function useDebridGetTorrentFilePreviews(variables: Partial<DebridGetTorrentFilePreviews_Variables>, enabled: boolean) { + return useServerQuery<Array<DebridClient_FilePreview>, DebridGetTorrentFilePreviews_Variables>({ + endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.endpoint, + method: API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.methods[0], + queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.key, variables?.torrent?.infoHash], + data: variables as DebridGetTorrentFilePreviews_Variables, + enabled: enabled, + gcTime: 0, + }) +} + +export function useDebridStartStream() { + return useServerMutation<boolean, DebridStartStream_Variables>({ + endpoint: API_ENDPOINTS.DEBRID.DebridStartStream.endpoint, + method: API_ENDPOINTS.DEBRID.DebridStartStream.methods[0], + mutationKey: [API_ENDPOINTS.DEBRID.DebridStartStream.key], + onSuccess: async () => { + }, + }) +} + +export function useDebridCancelStream() { + return useServerMutation<boolean, DebridCancelStream_Variables>({ + endpoint: API_ENDPOINTS.DEBRID.DebridCancelStream.endpoint, + method: API_ENDPOINTS.DEBRID.DebridCancelStream.methods[0], + mutationKey: [API_ENDPOINTS.DEBRID.DebridCancelStream.key], + onSuccess: async () => { + toast.success("Stream cancelled") + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/directory_selector.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/directory_selector.hooks.ts new file mode 100644 index 0000000..b68ae37 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/directory_selector.hooks.ts @@ -0,0 +1,16 @@ +import { useServerQuery } from "@/api/client/requests" +import { DirectorySelector_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { DirectorySelectorResponse } from "@/api/generated/types" + +export function useDirectorySelector(debouncedInput: string) { + return useServerQuery<DirectorySelectorResponse, DirectorySelector_Variables>({ + endpoint: API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.endpoint, + method: API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.methods[0], + queryKey: [API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.key, debouncedInput], + data: { input: debouncedInput }, + enabled: debouncedInput.length > 0, + muteError: true, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/directstream.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/directstream.hooks.ts new file mode 100644 index 0000000..e31ac70 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/directstream.hooks.ts @@ -0,0 +1,15 @@ +import { useServerMutation } from "../client/requests" +import { DirectstreamPlayLocalFile_Variables } from "../generated/endpoint.types" +import { API_ENDPOINTS } from "../generated/endpoints" +import { Mediastream_MediaContainer } from "../generated/types" + +export function useDirectstreamPlayLocalFile() { + return useServerMutation<Mediastream_MediaContainer, DirectstreamPlayLocalFile_Variables>({ + endpoint: API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.endpoint, + method: API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.methods[0], + mutationKey: [API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.key], + onSuccess: async () => { + + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/discord.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/discord.hooks.ts new file mode 100644 index 0000000..d5a641e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/discord.hooks.ts @@ -0,0 +1,64 @@ +import { useServerMutation } from "@/api/client/requests" +import { + SetDiscordAnimeActivityWithProgress_Variables, + SetDiscordLegacyAnimeActivity_Variables, + SetDiscordMangaActivity_Variables, + UpdateDiscordAnimeActivityWithProgress_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" + +export function useSetDiscordMangaActivity() { + return useServerMutation<boolean, SetDiscordMangaActivity_Variables>({ + endpoint: API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.endpoint, + method: API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.methods[0], + mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.key], + onSuccess: async () => { + + }, + }) +} + +export function useSetDiscordLegacyAnimeActivity() { + return useServerMutation<boolean, SetDiscordLegacyAnimeActivity_Variables>({ + endpoint: API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.endpoint, + method: API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.methods[0], + mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.key], + onSuccess: async () => { + + }, + }) +} + +export function useSetDiscordAnimeActivityWithProgress() { + return useServerMutation<boolean, SetDiscordAnimeActivityWithProgress_Variables>({ + endpoint: API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.endpoint, + method: API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.methods[0], + mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.key], + onSuccess: async () => { + + }, + }) +} + +export function useUpdateDiscordAnimeActivityWithProgress() { + return useServerMutation<boolean, UpdateDiscordAnimeActivityWithProgress_Variables>({ + endpoint: API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.endpoint, + method: API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.methods[0], + mutationKey: [API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.key], + onSuccess: async () => { + + }, + }) +} + +export function useCancelDiscordActivity() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.DISCORD.CancelDiscordActivity.endpoint, + method: API_ENDPOINTS.DISCORD.CancelDiscordActivity.methods[0], + mutationKey: [API_ENDPOINTS.DISCORD.CancelDiscordActivity.key], + onSuccess: async () => { + + }, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/docs.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/docs.hooks.ts new file mode 100644 index 0000000..71698a9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/docs.hooks.ts @@ -0,0 +1,13 @@ +import { useServerQuery } from "@/api/client/requests" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { ApiDocsGroup } from "@/api/generated/types" + +export function useGetDocs() { + return useServerQuery<ApiDocsGroup[]>({ + endpoint: API_ENDPOINTS.DOCS.GetDocs.endpoint, + method: API_ENDPOINTS.DOCS.GetDocs.methods[0], + queryKey: [API_ENDPOINTS.DOCS.GetDocs.key], + enabled: true, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/download.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/download.hooks.ts new file mode 100644 index 0000000..74c0198 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/download.hooks.ts @@ -0,0 +1,40 @@ +import { useServerMutation } from "@/api/client/requests" +import { DownloadRelease_Variables, DownloadTorrentFile_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { DownloadReleaseResponse } from "@/api/generated/types" +import { useOpenInExplorer } from "@/api/hooks/explorer.hooks" +import { toast } from "sonner" + +export function useDownloadTorrentFile(onSuccess?: () => void) { + return useServerMutation<boolean, DownloadTorrentFile_Variables>({ + endpoint: API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.endpoint, + method: API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.methods[0], + mutationKey: [API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.key], + onSuccess: async () => { + toast.success("Files downloaded") + onSuccess?.() + }, + }) +} + +export function useDownloadRelease() { + const { mutate: openInExplorer } = useOpenInExplorer() + + return useServerMutation<DownloadReleaseResponse, DownloadRelease_Variables>({ + endpoint: API_ENDPOINTS.DOWNLOAD.DownloadRelease.endpoint, + method: API_ENDPOINTS.DOWNLOAD.DownloadRelease.methods[0], + mutationKey: [API_ENDPOINTS.DOWNLOAD.DownloadRelease.key], + onSuccess: async data => { + toast.success("Update downloaded successfully!") + if (data?.error) { + toast.error(data.error) + } + if (data?.destination) { + openInExplorer({ + path: data.destination, + }) + } + }, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/explorer.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/explorer.hooks.ts new file mode 100644 index 0000000..b9b87ba --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/explorer.hooks.ts @@ -0,0 +1,15 @@ +import { useServerMutation } from "@/api/client/requests" +import { OpenInExplorer_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" + +export function useOpenInExplorer() { + return useServerMutation<boolean, OpenInExplorer_Variables>({ + endpoint: API_ENDPOINTS.EXPLORER.OpenInExplorer.endpoint, + method: API_ENDPOINTS.EXPLORER.OpenInExplorer.methods[0], + mutationKey: [API_ENDPOINTS.EXPLORER.OpenInExplorer.key], + onSuccess: async () => { + + }, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/extensions.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/extensions.hooks.ts new file mode 100644 index 0000000..311033f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/extensions.hooks.ts @@ -0,0 +1,271 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + FetchExternalExtensionData_Variables, + GetAllExtensions_Variables, + GrantPluginPermissions_Variables, + InstallExternalExtension_Variables, + ReloadExternalExtension_Variables, + RunExtensionPlaygroundCode_Variables, + SaveExtensionUserConfig_Variables, + SetPluginSettingsPinnedTrays_Variables, + UninstallExternalExtension_Variables, + UpdateExtensionCode_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { + Extension_Extension, + ExtensionRepo_AllExtensions, + ExtensionRepo_AnimeTorrentProviderExtensionItem, + ExtensionRepo_ExtensionInstallResponse, + ExtensionRepo_ExtensionUserConfig, + ExtensionRepo_MangaProviderExtensionItem, + ExtensionRepo_OnlinestreamProviderExtensionItem, + ExtensionRepo_StoredPluginSettingsData, + ExtensionRepo_UpdateData, + Nullish, + RunPlaygroundCodeResponse, +} from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" +import { toast } from "sonner" + +const unauthorizedPluginCountAtom = atom(0) + +export function useUnauthorizedPluginCount() { + const [count] = useAtom(unauthorizedPluginCountAtom) + return count +} + +export function useGetAllExtensions(withUpdates: boolean) { + const { data, ...rest } = useServerQuery<ExtensionRepo_AllExtensions, GetAllExtensions_Variables>({ + endpoint: API_ENDPOINTS.EXTENSIONS.GetAllExtensions.endpoint, + method: API_ENDPOINTS.EXTENSIONS.GetAllExtensions.methods[0], + queryKey: [API_ENDPOINTS.EXTENSIONS.GetAllExtensions.key, withUpdates], + data: { + withUpdates: withUpdates, + }, + gcTime: 0, + enabled: true, + }) + + const [, setCount] = useAtom(unauthorizedPluginCountAtom) + React.useEffect(() => { + setCount((data?.invalidExtensions ?? []).filter(n => n.code === "plugin_permissions_not_granted")?.length ?? 0) + }, [data]) + + return { data, ...rest } +} + +export function useFetchExternalExtensionData(id: Nullish<string>) { + return useServerMutation<Extension_Extension, FetchExternalExtensionData_Variables>({ + endpoint: API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.endpoint, + method: API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.methods[0], + mutationKey: [API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.key, id], + onSuccess: async () => { + + }, + }) +} + +export function useInstallExternalExtension() { + return useServerMutation<ExtensionRepo_ExtensionInstallResponse, InstallExternalExtension_Variables>({ + endpoint: API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.endpoint, + method: API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.methods[0], + mutationKey: [API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.key], + onSuccess: async () => { + // DEVNOTE: No need to refetch, the websocket listener will do it + }, + }) +} + +export function useUninstallExternalExtension() { + return useServerMutation<boolean, UninstallExternalExtension_Variables>({ + endpoint: API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.endpoint, + method: API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.methods[0], + mutationKey: [API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.key], + onSuccess: async () => { + // DEVNOTE: No need to refetch, the websocket listener will do it + toast.success("Extension uninstalled successfully.") + }, + }) +} + +export function useGetExtensionPayload(id: string) { + return useServerQuery<string>({ + endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.endpoint.replace("{id}", id), + method: API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.methods[0], + queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.key, id], + enabled: true, + }) +} + +export function useUpdateExtensionCode() { + return useServerMutation<boolean, UpdateExtensionCode_Variables>({ + endpoint: API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.endpoint, + method: API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.methods[0], + mutationKey: [API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.key], + onSuccess: async () => { + // DEVNOTE: No need to refetch, the websocket listener will do it + toast.success("Extension updated successfully.") + }, + }) +} + +export function useReloadExternalExtensions() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.endpoint, + method: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.methods[0], + mutationKey: [API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.key], + onSuccess: async () => { + + }, + }) +} + +export function useListExtensionData() { + return useServerQuery<Array<Extension_Extension>>({ + endpoint: API_ENDPOINTS.EXTENSIONS.ListExtensionData.endpoint, + method: API_ENDPOINTS.EXTENSIONS.ListExtensionData.methods[0], + queryKey: [API_ENDPOINTS.EXTENSIONS.ListExtensionData.key], + enabled: true, + }) +} + +export function useListMangaProviderExtensions() { + return useServerQuery<Array<ExtensionRepo_MangaProviderExtensionItem>>({ + endpoint: API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.endpoint, + method: API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.methods[0], + queryKey: [API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.key], + enabled: true, + }) +} + +export function useListOnlinestreamProviderExtensions() { + return useServerQuery<Array<ExtensionRepo_OnlinestreamProviderExtensionItem>>({ + endpoint: API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.endpoint, + method: API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.methods[0], + queryKey: [API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.key], + enabled: true, + }) +} + +export function useAnimeListTorrentProviderExtensions() { + return useServerQuery<Array<ExtensionRepo_AnimeTorrentProviderExtensionItem>>({ + endpoint: API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.endpoint, + method: API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.methods[0], + queryKey: [API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.key], + enabled: true, + }) +} + +export function useRunExtensionPlaygroundCode() { + return useServerMutation<RunPlaygroundCodeResponse, RunExtensionPlaygroundCode_Variables>({ + endpoint: API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.endpoint, + method: API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.methods[0], + mutationKey: [API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.key], + onSuccess: async () => { + + }, + }) +} + +export function useGetExtensionUserConfig(id: string) { + return useServerQuery<ExtensionRepo_ExtensionUserConfig>({ + endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.endpoint.replace("{id}", id), + method: API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.methods[0], + queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.key, id], + enabled: true, + gcTime: 0, + }) +} + +export function useSaveExtensionUserConfig() { + return useServerMutation<boolean, SaveExtensionUserConfig_Variables>({ + endpoint: API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.endpoint, + method: API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.methods[0], + mutationKey: [API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.key], + onSuccess: async () => { + // DEVNOTE: No need to refetch, the websocket listener will do it + toast.success("Config saved successfully.") + }, + }) +} + +export function useListDevelopmentModeExtensions() { + return useServerQuery<Array<Extension_Extension>>({ + endpoint: API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.endpoint, + method: API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.methods[0], + queryKey: [API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.key], + enabled: true, + }) +} + +export function useReloadExternalExtension() { + const queryClient = useQueryClient() + return useServerMutation<boolean, ReloadExternalExtension_Variables>({ + endpoint: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.endpoint, + method: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.methods[0], + mutationKey: [API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.key], + onSuccess: async () => { + toast.success("Extension reloaded successfully.") + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.key] }) + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key] }) + // DEVNOTE: No need to refetch, the websocket listener will do it + }, + }) +} + +export function useGetPluginSettings() { + return useServerQuery<ExtensionRepo_StoredPluginSettingsData>({ + endpoint: API_ENDPOINTS.EXTENSIONS.GetPluginSettings.endpoint, + method: API_ENDPOINTS.EXTENSIONS.GetPluginSettings.methods[0], + queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key], + enabled: true, + }) +} + +export function useSetPluginSettingsPinnedTrays() { + const queryClient = useQueryClient() + return useServerMutation<boolean, SetPluginSettingsPinnedTrays_Variables>({ + endpoint: API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.endpoint, + method: API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.methods[0], + mutationKey: [API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.key], + onSuccess: async () => { + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key] }) + }, + }) +} + +export function useGrantPluginPermissions() { + const queryClient = useQueryClient() + return useServerMutation<boolean, GrantPluginPermissions_Variables>({ + endpoint: API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.endpoint, + method: API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.methods[0], + mutationKey: [API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.key], + onSuccess: async () => { + toast.success("Plugin permissions granted successfully.") + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key] }) + }, + }) +} + +export function useGetMarketplaceExtensions(marketplaceUrl?: string) { + const url = marketplaceUrl ? `?marketplace=${encodeURIComponent(marketplaceUrl)}` : "" + return useServerQuery<Array<Extension_Extension>>({ + endpoint: `${API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.endpoint}${url}`, + method: API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.methods[0], + queryKey: [API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.key, marketplaceUrl], + enabled: true, + }) +} + +export function useGetExtensionUpdateData() { + return useServerQuery<Array<ExtensionRepo_UpdateData>>({ + endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.endpoint, + method: API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.methods[0], + queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.key], + enabled: true, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/filecache.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/filecache.hooks.ts new file mode 100644 index 0000000..9f6b028 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/filecache.hooks.ts @@ -0,0 +1,44 @@ +import { useServerMutation } from "@/api/client/requests" +import { RemoveFileCacheBucket_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { toast } from "sonner" + +export function useGetFileCacheTotalSize() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.endpoint, + method: API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.methods[0], + mutationKey: [API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.key], + }) +} + +export function useRemoveFileCacheBucket(onSuccess?: () => void) { + return useServerMutation<boolean, RemoveFileCacheBucket_Variables>({ + endpoint: API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.endpoint, + method: API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.methods[0], + mutationKey: [API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.key], + onSuccess: async () => { + toast.success("Cache cleared") + onSuccess?.() + }, + }) +} + +export function useGetFileCacheMediastreamVideoFilesTotalSize() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.endpoint, + method: API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.methods[0], + mutationKey: [API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.key], + }) +} + +export function useClearFileCacheMediastreamVideoFiles(onSuccess?: () => void) { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.endpoint, + method: API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.methods[0], + mutationKey: [API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.key], + onSuccess: async () => { + toast.success("Cache cleared") + onSuccess?.() + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/local.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/local.hooks.ts new file mode 100644 index 0000000..dc43703 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/local.hooks.ts @@ -0,0 +1,165 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Local_QueueState, Local_TrackedMediaItem } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import { + LocalAddTrackedMedia_Variables, + LocalRemoveTrackedMedia_Variables, + LocalSetHasLocalChanges_Variables, + SetOfflineMode_Variables, +} from "../generated/endpoint.types" + +export function useLocalGetTrackedMediaItems() { + return useServerQuery<Array<Local_TrackedMediaItem>>({ + endpoint: API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.endpoint, + method: API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.methods[0], + queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key], + enabled: true, + gcTime: 0, + }) +} + +export function useLocalAddTrackedMedia() { + const qc = useQueryClient() + return useServerMutation<boolean, LocalAddTrackedMedia_Variables>({ + endpoint: API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.endpoint, + method: API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.methods[0], + mutationKey: [API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] }) + toast.success("Added media for offline syncing") + }, + }) +} + +export function useLocalRemoveTrackedMedia() { + const qc = useQueryClient() + return useServerMutation<boolean, LocalRemoveTrackedMedia_Variables>({ + endpoint: API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.endpoint, + method: API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.methods[0], + mutationKey: [API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] }) + toast.success("Removed offline data") + }, + }) +} + +export function useLocalSyncData() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.LOCAL.LocalSyncData.endpoint, + method: API_ENDPOINTS.LOCAL.LocalSyncData.methods[0], + mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncData.key], + onSuccess: async () => { + toast.info("Syncing local data...") + }, + }) +} + +export function useLocalGetSyncQueueData() { + return useServerQuery<Local_QueueState>({ + endpoint: API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.endpoint, + method: API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.methods[0], + queryKey: [API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.key], + enabled: true, + }) +} + +export function useLocalGetIsMediaTracked(id: number, type: string) { + return useServerQuery<boolean>({ + endpoint: API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.endpoint.replace("{id}", String(id)).replace("{type}", String(type)), + method: API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.methods[0], + queryKey: [API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.key, id, type], + enabled: true, + }) +} + +export function useLocalSyncAnilistData() { + const qc = useQueryClient() + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.LOCAL.LocalSyncAnilistData.endpoint, + method: API_ENDPOINTS.LOCAL.LocalSyncAnilistData.methods[0], + mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncAnilistData.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] }) + toast.success("Updated Anilist data") + }, + }) +} + +export function useLocalSetHasLocalChanges() { + const qc = useQueryClient() + return useServerMutation<boolean, LocalSetHasLocalChanges_Variables>({ + endpoint: API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.endpoint, + method: API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.methods[0], + mutationKey: [API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.key] }) + }, + }) +} + +export function useLocalGetHasLocalChanges() { + return useServerQuery<boolean>({ + endpoint: API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.endpoint, + method: API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.methods[0], + queryKey: [API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.key], + enabled: true, + }) +} + +export function useLocalGetLocalStorageSize() { + return useServerQuery<string>({ + endpoint: API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.endpoint, + method: API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.methods[0], + queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.key], + enabled: true, + }) +} + +export function useLocalSyncSimulatedDataToAnilist() { + const qc = useQueryClient() + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.endpoint, + method: API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.methods[0], + mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.key], + onSuccess: async () => { + ({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] }) + toast.success("Updated Anilist data") + }, + }) +} + +export function useSetOfflineMode() { + return useServerMutation<boolean, SetOfflineMode_Variables>({ + endpoint: API_ENDPOINTS.LOCAL.SetOfflineMode.endpoint, + method: API_ENDPOINTS.LOCAL.SetOfflineMode.methods[0], + mutationKey: [API_ENDPOINTS.LOCAL.SetOfflineMode.key], + onSuccess: async (data) => { + if (data) { + toast.success("Offline mode enabled") + window.location.href = "/offline" + } else { + toast.success("Offline mode disabled") + window.location.href = "/" + } + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/localfiles.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/localfiles.hooks.ts new file mode 100644 index 0000000..183fc50 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/localfiles.hooks.ts @@ -0,0 +1,125 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + DeleteLocalFiles_Variables, + ImportLocalFiles_Variables, + LocalFileBulkAction_Variables, + UpdateLocalFileData_Variables, + UpdateLocalFiles_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Anime_LocalFile, Nullish } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useGetLocalFiles() { + return useServerQuery<Array<Anime_LocalFile>>({ + endpoint: API_ENDPOINTS.LOCALFILES.GetLocalFiles.endpoint, + method: API_ENDPOINTS.LOCALFILES.GetLocalFiles.methods[0], + queryKey: [API_ENDPOINTS.LOCALFILES.GetLocalFiles.key], + enabled: true, + }) +} + +export function useLocalFileBulkAction() { + const qc = useQueryClient() + + return useServerMutation<Array<Anime_LocalFile>, LocalFileBulkAction_Variables>({ + endpoint: API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.endpoint, + method: API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.methods[0], + mutationKey: [API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + }, + }) +} + +export function useUpdateLocalFiles() { + const qc = useQueryClient() + + return useServerMutation<boolean, UpdateLocalFiles_Variables>({ + endpoint: API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.endpoint, + method: API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.methods[0], + mutationKey: [API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + }, + }) +} + +export function useUpdateLocalFileData(id: Nullish<number>) { + const qc = useQueryClient() + + const opts = useServerMutation<Array<Anime_LocalFile>, UpdateLocalFileData_Variables>({ + endpoint: API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.endpoint, + method: API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.methods[0], + mutationKey: [API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.key], + onSuccess: async () => { + toast.success("File metadata updated") + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + if (id) { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] }) + } + }, + }) + + return { + updateLocalFile: (lf: Anime_LocalFile, variables: Partial<UpdateLocalFileData_Variables>, onSuccess?: () => void) => { + opts.mutate({ + path: lf.path, + metadata: lf.metadata, + locked: lf.locked, + ignored: lf.ignored, + mediaId: lf.mediaId, + ...variables, + }, { + onSuccess: () => onSuccess?.(), + }) + }, + ...opts, + } +} + +export function useDeleteLocalFiles(id: Nullish<number>) { + const qc = useQueryClient() + + return useServerMutation<Array<Anime_LocalFile>, DeleteLocalFiles_Variables>({ + endpoint: API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.endpoint, + method: API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.methods[0], + mutationKey: [API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.key], + onSuccess: async () => { + toast.success("Files deleted") + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + if (id) { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] }) + } + }, + }) +} + +export function useRemoveEmptyDirectories() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.endpoint, + method: API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.methods[0], + mutationKey: [API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.key], + onSuccess: async () => { + toast.success("Empty directories removed") + }, + }) +} + +export function useImportLocalFiles() { + const qc = useQueryClient() + + return useServerMutation<boolean, ImportLocalFiles_Variables>({ + endpoint: API_ENDPOINTS.LOCALFILES.ImportLocalFiles.endpoint, + method: API_ENDPOINTS.LOCALFILES.ImportLocalFiles.methods[0], + mutationKey: [API_ENDPOINTS.LOCALFILES.ImportLocalFiles.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + toast.success("Local files imported") + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/mal.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/mal.hooks.ts new file mode 100644 index 0000000..f86d76c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/mal.hooks.ts @@ -0,0 +1,42 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { EditMALListEntryProgress_Variables, MALAuth_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { MalAuthResponse } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useMALAuth(variables: Partial<MALAuth_Variables>, enabled: boolean) { + return useServerQuery<MalAuthResponse, MALAuth_Variables>({ + endpoint: API_ENDPOINTS.MAL.MALAuth.endpoint, + method: API_ENDPOINTS.MAL.MALAuth.methods[0], + queryKey: [API_ENDPOINTS.MAL.MALAuth.key], + data: variables as MALAuth_Variables, + enabled: enabled, + }) +} + +export function useEditMALListEntryProgress() { + return useServerMutation<boolean, EditMALListEntryProgress_Variables>({ + endpoint: API_ENDPOINTS.MAL.EditMALListEntryProgress.endpoint, + method: API_ENDPOINTS.MAL.EditMALListEntryProgress.methods[0], + mutationKey: [API_ENDPOINTS.MAL.EditMALListEntryProgress.key], + onSuccess: async () => { + + }, + }) +} + +export function useMALLogout() { + const queryClient = useQueryClient() + + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.MAL.MALLogout.endpoint, + method: API_ENDPOINTS.MAL.MALLogout.methods[0], + mutationKey: [API_ENDPOINTS.MAL.MALLogout.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] }) + toast.success("Successfully logged out of MyAnimeList") + }, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/manga.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/manga.hooks.ts new file mode 100644 index 0000000..2a21409 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/manga.hooks.ts @@ -0,0 +1,222 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + AnilistListManga_Variables, + EmptyMangaEntryCache_Variables, + GetAnilistMangaCollection_Variables, + GetMangaEntryChapters_Variables, + GetMangaEntryPages_Variables, + GetMangaMapping_Variables, + MangaManualMapping_Variables, + MangaManualSearch_Variables, + RefetchMangaChapterContainers_Variables, + RemoveMangaMapping_Variables, + UpdateMangaProgress_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { + AL_ListManga, + AL_MangaCollection, + AL_MangaDetailsById_Media, + HibikeManga_SearchResult, + Manga_ChapterContainer, + Manga_Collection, + Manga_Entry, + Manga_MangaLatestChapterNumberItem, + Manga_MappingResponse, + Manga_PageContainer, + Nullish, +} from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useGetAnilistMangaCollection() { + return useServerQuery<AL_MangaCollection, GetAnilistMangaCollection_Variables>({ + endpoint: API_ENDPOINTS.MANGA.GetAnilistMangaCollection.endpoint, + method: API_ENDPOINTS.MANGA.GetAnilistMangaCollection.methods[0], + queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key], + enabled: true, + }) +} + + +export function useGetRawAnilistMangaCollection() { + return useServerQuery<AL_MangaCollection, GetAnilistMangaCollection_Variables>({ + endpoint: API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.endpoint, + method: API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.methods[0], + queryKey: [API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.key], + enabled: true, + }) +} + +export function useGetMangaCollection() { + return useServerQuery<Manga_Collection>({ + endpoint: API_ENDPOINTS.MANGA.GetMangaCollection.endpoint, + method: API_ENDPOINTS.MANGA.GetMangaCollection.methods[0], + queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key], + enabled: true, + }) +} + +export function useGetMangaEntry(id: Nullish<string | number>) { + return useServerQuery<Manga_Entry>({ + endpoint: API_ENDPOINTS.MANGA.GetMangaEntry.endpoint.replace("{id}", String(id)), + method: API_ENDPOINTS.MANGA.GetMangaEntry.methods[0], + queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)], + enabled: !!id, + }) +} + +export function useGetMangaEntryDetails(id: Nullish<string | number>) { + return useServerQuery<AL_MangaDetailsById_Media>({ + endpoint: API_ENDPOINTS.MANGA.GetMangaEntryDetails.endpoint.replace("{id}", String(id)), + method: API_ENDPOINTS.MANGA.GetMangaEntryDetails.methods[0], + queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryDetails.key, String(id)], + enabled: !!id, + }) +} + +export function useEmptyMangaEntryCache() { + const queryClient = useQueryClient() + + return useServerMutation<boolean, EmptyMangaEntryCache_Variables>({ + endpoint: API_ENDPOINTS.MANGA.EmptyMangaEntryCache.endpoint, + method: API_ENDPOINTS.MANGA.EmptyMangaEntryCache.methods[0], + mutationKey: [API_ENDPOINTS.MANGA.EmptyMangaEntryCache.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] }) + }, + }) +} + +export function useGetMangaEntryChapters(variables: Partial<GetMangaEntryChapters_Variables>) { + return useServerQuery<Manga_ChapterContainer, GetMangaEntryChapters_Variables>({ + endpoint: API_ENDPOINTS.MANGA.GetMangaEntryChapters.endpoint, + method: API_ENDPOINTS.MANGA.GetMangaEntryChapters.methods[0], + queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key, String(variables.mediaId), variables.provider], + data: variables as GetMangaEntryChapters_Variables, + enabled: !!variables.mediaId && !!variables.provider, + muteError: true, + }) +} + +export function useGetMangaEntryPages(variables: Partial<GetMangaEntryPages_Variables>) { + return useServerQuery<Manga_PageContainer, GetMangaEntryPages_Variables>({ + endpoint: API_ENDPOINTS.MANGA.GetMangaEntryPages.endpoint, + method: API_ENDPOINTS.MANGA.GetMangaEntryPages.methods[0], + queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key, String(variables.mediaId), variables.provider, variables.chapterId, + variables.doublePage], + data: variables as GetMangaEntryPages_Variables, + enabled: !!variables.mediaId && !!variables.provider && !!variables.chapterId, + }) +} + +export function useAnilistListManga(variables: AnilistListManga_Variables, enabled?: boolean) { + return useServerQuery<AL_ListManga, AnilistListManga_Variables>({ + endpoint: API_ENDPOINTS.MANGA.AnilistListManga.endpoint, + method: API_ENDPOINTS.MANGA.AnilistListManga.methods[0], + queryKey: [API_ENDPOINTS.MANGA.AnilistListManga.key, variables], + data: variables, + enabled: enabled ?? true, + }) +} + +export function useUpdateMangaProgress(id: Nullish<string | number>) { + const queryClient = useQueryClient() + + return useServerMutation<boolean, UpdateMangaProgress_Variables>({ + endpoint: API_ENDPOINTS.MANGA.UpdateMangaProgress.endpoint, + method: API_ENDPOINTS.MANGA.UpdateMangaProgress.methods[0], + mutationKey: [API_ENDPOINTS.MANGA.UpdateMangaProgress.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] }) + }, + }) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function useMangaManualSearch(mediaId: Nullish<number>, provider: Nullish<string>) { + return useServerMutation<Array<HibikeManga_SearchResult>, MangaManualSearch_Variables>({ + endpoint: API_ENDPOINTS.MANGA.MangaManualSearch.endpoint, + method: API_ENDPOINTS.MANGA.MangaManualSearch.methods[0], + mutationKey: [API_ENDPOINTS.MANGA.MangaManualSearch.key, String(mediaId), provider], + gcTime: 0, + }) +} + +export function useMangaManualMapping() { + const queryClient = useQueryClient() + + return useServerMutation<boolean, MangaManualMapping_Variables>({ + endpoint: API_ENDPOINTS.MANGA.MangaManualMapping.endpoint, + method: API_ENDPOINTS.MANGA.MangaManualMapping.methods[0], + mutationKey: [API_ENDPOINTS.MANGA.MangaManualMapping.key], + onSuccess: async () => { + toast.success("Mapping added") + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaMapping.key] }) + }, + }) +} + +export function useGetMangaMapping(variables: Partial<GetMangaMapping_Variables>) { + return useServerQuery<Manga_MappingResponse, GetMangaMapping_Variables>({ + endpoint: API_ENDPOINTS.MANGA.GetMangaMapping.endpoint, + method: API_ENDPOINTS.MANGA.GetMangaMapping.methods[0], + queryKey: [API_ENDPOINTS.MANGA.GetMangaMapping.key, String(variables.mediaId), variables.provider], + data: variables as GetMangaMapping_Variables, + enabled: !!variables.provider && !!variables.mediaId, + }) +} + +export function useRemoveMangaMapping() { + const queryClient = useQueryClient() + + return useServerMutation<boolean, RemoveMangaMapping_Variables>({ + endpoint: API_ENDPOINTS.MANGA.RemoveMangaMapping.endpoint, + method: API_ENDPOINTS.MANGA.RemoveMangaMapping.methods[0], + mutationKey: [API_ENDPOINTS.MANGA.RemoveMangaMapping.key], + onSuccess: async () => { + toast.info("Mapping removed") + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaMapping.key] }) + }, + }) +} + +export function useGetMangaEntryDownloadedChapters(mId: Nullish<string | number>) { + return useServerQuery<Array<Manga_ChapterContainer>>({ + endpoint: API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.endpoint.replace("{id}", String(mId)), + method: API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.methods[0], + queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.key, String(mId)], + }) +} + +export function useGetMangaLatestChapterNumbersMap() { + return useServerQuery<Record<number, Array<Manga_MangaLatestChapterNumberItem>>>({ + endpoint: API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.endpoint, + method: API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.methods[0], + queryKey: [API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.key], + enabled: true, + }) +} + +export function useRefetchMangaChapterContainers() { + const queryClient = useQueryClient() + + return useServerMutation<boolean, RefetchMangaChapterContainers_Variables>({ + endpoint: API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.endpoint, + method: API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.methods[0], + mutationKey: [API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] }) + toast.success("Sources refreshed") + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/manga_download.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/manga_download.hooks.ts new file mode 100644 index 0000000..f51d1e4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/manga_download.hooks.ts @@ -0,0 +1,122 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + DeleteMangaDownloadedChapters_Variables, + DownloadMangaChapters_Variables, + GetMangaDownloadData_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Manga_DownloadListItem, Manga_MediaDownloadData, Models_ChapterDownloadQueueItem, Nullish } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useDownloadMangaChapters(id: Nullish<string | number>, provider: Nullish<string>) { + const queryClient = useQueryClient() + + return useServerMutation<boolean, DownloadMangaChapters_Variables>({ + endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.endpoint, + method: API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.methods[0], + mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.key, String(id), provider], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key] }) + }, + }) +} + +export function useGetMangaDownloadData(variables: Partial<GetMangaDownloadData_Variables>) { + return useServerQuery<Manga_MediaDownloadData, GetMangaDownloadData_Variables>({ + endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.endpoint, + method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.methods[0], + queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key, String(variables.mediaId), String(variables.cached)], + data: variables as GetMangaDownloadData_Variables, + enabled: !!variables.mediaId, + }) +} + +export function useGetMangaDownloadQueue() { + return useServerQuery<Array<Models_ChapterDownloadQueueItem>>({ + endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.endpoint, + method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.methods[0], + queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key], + enabled: true, + }) +} + +export function useStartMangaDownloadQueue() { + const queryClient = useQueryClient() + + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.endpoint, + method: API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.methods[0], + mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] }) + toast.info("Downloading chapters") + }, + }) +} + +export function useStopMangaDownloadQueue() { + const queryClient = useQueryClient() + + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.endpoint, + method: API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.methods[0], + mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] }) + toast.success("Download queue stopped") + }, + }) +} + +export function useClearAllChapterDownloadQueue() { + const queryClient = useQueryClient() + + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.endpoint, + method: API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.methods[0], + mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] }) + toast.success("Download queue cleared") + }, + }) +} + +export function useResetErroredChapterDownloadQueue() { + const queryClient = useQueryClient() + + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.endpoint, + method: API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.methods[0], + mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] }) + toast.success("Reset errored chapters") + }, + }) +} + +export function useDeleteMangaDownloadedChapters(id: Nullish<string | number>, provider: string | null) { + const queryClient = useQueryClient() + return useServerMutation<boolean, DeleteMangaDownloadedChapters_Variables>({ + endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.endpoint, + method: API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.methods[0], + mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.key, String(id), provider], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key] }) + toast.success("Chapters deleted") + }, + }) +} + +export function useGetMangaDownloadsList() { + + return useServerQuery<Array<Manga_DownloadListItem>>({ + endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.endpoint, + method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.methods[0], + queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.key], + enabled: true, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/mediaplayer.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/mediaplayer.hooks.ts new file mode 100644 index 0000000..14ee5b9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/mediaplayer.hooks.ts @@ -0,0 +1,14 @@ +import { useServerMutation } from "@/api/client/requests" +import { API_ENDPOINTS } from "@/api/generated/endpoints" + +export function useStartDefaultMediaPlayer() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.endpoint, + method: API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.methods[0], + mutationKey: [API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.key], + onSuccess: async () => { + + }, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/mediastream.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/mediastream.hooks.ts new file mode 100644 index 0000000..86c6c83 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/mediastream.hooks.ts @@ -0,0 +1,66 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + PreloadMediastreamMediaContainer_Variables, + RequestMediastreamMediaContainer_Variables, + SaveMediastreamSettings_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Mediastream_MediaContainer, Models_MediastreamSettings } from "@/api/generated/types" +import { logger } from "@/lib/helpers/debug" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useGetMediastreamSettings(enabled?: boolean) { + return useServerQuery<Models_MediastreamSettings>({ + endpoint: API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.endpoint, + method: API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.methods[0], + queryKey: [API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.key], + enabled: enabled, + }) +} + +export function useSaveMediastreamSettings() { + const qc = useQueryClient() + return useServerMutation<Models_MediastreamSettings, SaveMediastreamSettings_Variables>({ + endpoint: API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.endpoint, + method: API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.methods[0], + mutationKey: [API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] }) + toast.success("Settings saved") + }, + }) +} + +export function useRequestMediastreamMediaContainer(variables: Partial<RequestMediastreamMediaContainer_Variables>, enabled: boolean) { + return useServerQuery<Mediastream_MediaContainer, RequestMediastreamMediaContainer_Variables>({ + endpoint: API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.endpoint, + method: API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.methods[0], + queryKey: [API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.key, variables?.path, variables?.streamType], + data: variables as RequestMediastreamMediaContainer_Variables, + enabled: !!variables.path && !!variables.streamType && enabled, + }) +} + +export function usePreloadMediastreamMediaContainer() { + return useServerMutation<boolean, PreloadMediastreamMediaContainer_Variables>({ + endpoint: API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.endpoint, + method: API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.methods[0], + mutationKey: [API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.key], + onSuccess: async () => { + logger("MEDIASTREAM").success("Preloaded mediastream media container") + }, + }) +} + +export function useMediastreamShutdownTranscodeStream() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.endpoint, + method: API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.methods[0], + mutationKey: [API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.key], + onSuccess: async () => { + + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/metadata.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/metadata.hooks.ts new file mode 100644 index 0000000..e660dc2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/metadata.hooks.ts @@ -0,0 +1,36 @@ +import { useServerMutation } from "@/api/client/requests" +import { PopulateFillerData_Variables, RemoveFillerData_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function usePopulateFillerData() { + const queryClient = useQueryClient() + + return useServerMutation<true, PopulateFillerData_Variables>({ + endpoint: API_ENDPOINTS.METADATA.PopulateFillerData.endpoint, + method: API_ENDPOINTS.METADATA.PopulateFillerData.methods[0], + mutationKey: [API_ENDPOINTS.METADATA.PopulateFillerData.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + toast.success("Filler data fetched") + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.key] }) + }, + }) +} + +export function useRemoveFillerData() { + const queryClient = useQueryClient() + + return useServerMutation<boolean, RemoveFillerData_Variables>({ + endpoint: API_ENDPOINTS.METADATA.RemoveFillerData.endpoint, + method: API_ENDPOINTS.METADATA.RemoveFillerData.methods[0], + mutationKey: [API_ENDPOINTS.METADATA.RemoveFillerData.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + toast.success("Filler data removed") + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.key] }) + }, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/nakama.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/nakama.hooks.ts new file mode 100644 index 0000000..6ee2183 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/nakama.hooks.ts @@ -0,0 +1,100 @@ +import { useServerMutation, useServerQuery } from "../client/requests" +import { NakamaCreateWatchParty_Variables, NakamaPlayVideo_Variables, SendNakamaMessage_Variables } from "../generated/endpoint.types" +import { API_ENDPOINTS } from "../generated/endpoints" +import { Anime_LibraryCollection, Nakama_MessageResponse } from "../generated/types" + +export function useNakamaWebSocket() { + return useServerQuery<boolean>({ + endpoint: API_ENDPOINTS.NAKAMA.NakamaWebSocket.endpoint, + method: API_ENDPOINTS.NAKAMA.NakamaWebSocket.methods[0], + queryKey: [API_ENDPOINTS.NAKAMA.NakamaWebSocket.key], + enabled: true, + }) +} + + +export function useSendNakamaMessage() { + return useServerMutation<Nakama_MessageResponse, SendNakamaMessage_Variables>({ + endpoint: API_ENDPOINTS.NAKAMA.SendNakamaMessage.endpoint, + method: API_ENDPOINTS.NAKAMA.SendNakamaMessage.methods[0], + mutationKey: [API_ENDPOINTS.NAKAMA.SendNakamaMessage.key], + onSuccess: async () => { + + }, + }) +} + +export function useNakamaReconnectToHost() { + return useServerMutation<Nakama_MessageResponse, {}>({ + endpoint: API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.endpoint, + method: API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.methods[0], + mutationKey: [API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.key], + onSuccess: async () => { + + }, + }) +} + +export function useNakamaRemoveStaleConnections() { + return useServerMutation<Nakama_MessageResponse, {}>({ + endpoint: API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.endpoint, + method: API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.methods[0], + mutationKey: [API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.key], + onSuccess: async () => { + + }, + }) +} + +export function useGetNakamaAnimeLibraryCollection() { + return useServerQuery<Anime_LibraryCollection>({ + endpoint: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.endpoint, + method: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.methods[0], + queryKey: [API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.key], + enabled: true, + }) +} + +export function useNakamaPlayVideo() { + return useServerMutation<boolean, NakamaPlayVideo_Variables>({ + endpoint: API_ENDPOINTS.NAKAMA.NakamaPlayVideo.endpoint, + method: API_ENDPOINTS.NAKAMA.NakamaPlayVideo.methods[0], + mutationKey: [API_ENDPOINTS.NAKAMA.NakamaPlayVideo.key], + onSuccess: async () => { + + }, + }) +} + +export function useNakamaCreateWatchParty() { + return useServerMutation<any, NakamaCreateWatchParty_Variables>({ + endpoint: API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.endpoint, + method: API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.methods[0], + mutationKey: [API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.key], + onSuccess: async () => { + + }, + }) +} + +export function useNakamaJoinWatchParty() { + return useServerMutation<Nakama_MessageResponse>({ + endpoint: API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.endpoint, + method: API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.methods[0], + mutationKey: [API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.key], + onSuccess: async () => { + + }, + }) +} + +export function useNakamaLeaveWatchParty() { + return useServerMutation<Nakama_MessageResponse>({ + endpoint: API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.endpoint, + method: API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.methods[0], + mutationKey: [API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.key], + onSuccess: async () => { + + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/onlinestream.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/onlinestream.hooks.ts new file mode 100644 index 0000000..8d7fe0e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/onlinestream.hooks.ts @@ -0,0 +1,125 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + GetOnlineStreamEpisodeList_Variables, + GetOnlineStreamEpisodeSource_Variables, + GetOnlinestreamMapping_Variables, + OnlineStreamEmptyCache_Variables, + OnlinestreamManualMapping_Variables, + OnlinestreamManualSearch_Variables, + RemoveOnlinestreamMapping_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { + HibikeOnlinestream_SearchResult, + Nullish, + Onlinestream_EpisodeListResponse, + Onlinestream_EpisodeSource, + Onlinestream_MappingResponse, +} from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useGetOnlineStreamEpisodeList(id: Nullish<string | number>, provider: Nullish<string>, dubbed: boolean) { + return useServerQuery<Onlinestream_EpisodeListResponse, GetOnlineStreamEpisodeList_Variables>({ + endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.endpoint, + method: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.methods[0], + queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key, String(id), provider, dubbed], + data: { + mediaId: Number(id), + provider: provider!, + dubbed, + }, + enabled: !!id, + muteError: true, + }) +} + +export function useGetOnlineStreamEpisodeSource(id: Nullish<string | number>, + provider: Nullish<string>, + episodeNumber: Nullish<number>, + dubbed: boolean, + enabled: boolean, +) { + return useServerQuery<Onlinestream_EpisodeSource, GetOnlineStreamEpisodeSource_Variables>({ + endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.endpoint, + method: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.methods[0], + queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key, String(id), provider, episodeNumber, dubbed], + data: { + mediaId: Number(id), + episodeNumber: episodeNumber!, + dubbed: dubbed, + provider: provider!, + }, + enabled: enabled && !!provider, + muteError: true, + }) +} + +export function useOnlineStreamEmptyCache() { + const queryClient = useQueryClient() + + return useServerMutation<boolean, OnlineStreamEmptyCache_Variables>({ + endpoint: API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.endpoint, + method: API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.methods[0], + mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key] }) + toast.info("Stream cache emptied") + }, + }) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function useOnlinestreamManualSearch(mId: number, provider: Nullish<string>) { + return useServerMutation<Array<HibikeOnlinestream_SearchResult>, OnlinestreamManualSearch_Variables>({ + endpoint: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.endpoint, + method: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.methods[0], + mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.key], + onSuccess: async () => { + + }, + }) +} + +export function useOnlinestreamManualMapping() { + const qc = useQueryClient() + return useServerMutation<boolean, OnlinestreamManualMapping_Variables>({ + endpoint: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.endpoint, + method: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.methods[0], + mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.key], + onSuccess: async () => { + toast.success("Mapping added") + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key] }) + }, + }) +} + +export function useGetOnlinestreamMapping(variables: Partial<GetOnlinestreamMapping_Variables>) { + return useServerQuery<Onlinestream_MappingResponse, GetOnlinestreamMapping_Variables>({ + endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.endpoint, + method: API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.methods[0], + queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.key, String(variables.mediaId), variables.provider], + data: variables as GetOnlinestreamMapping_Variables, + enabled: !!variables.provider && !!variables.mediaId, + }) +} + +export function useRemoveOnlinestreamMapping() { + const qc = useQueryClient() + + return useServerMutation<boolean, RemoveOnlinestreamMapping_Variables>({ + endpoint: API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.endpoint, + method: API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.methods[0], + mutationKey: [API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.key], + onSuccess: async () => { + toast.info("Mapping removed") + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key] }) + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/playback_manager.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/playback_manager.hooks.ts new file mode 100644 index 0000000..f0a7dd8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/playback_manager.hooks.ts @@ -0,0 +1,137 @@ +import { useServerMutation } from "@/api/client/requests" +import { PlaybackPlayVideo_Variables, PlaybackStartManualTracking_Variables, PlaybackStartPlaylist_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Anime_LocalFile } from "@/api/generated/types" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function usePlaybackSyncCurrentProgress() { + const serverStatus = useServerStatus() + const queryClient = useQueryClient() + + return useServerMutation<number>({ + endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.endpoint, + method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.methods[0], + mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.key], + onSuccess: async mediaId => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(mediaId)] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] }) + }, + }) +} + +export function usePlaybackPlayNextEpisode(...keys: any) { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.endpoint, + method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.methods[0], + mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.key, ...keys], + onSuccess: async () => { + + }, + }) +} + +export function usePlaybackStartPlaylist({ + onSuccess, +}: { + onSuccess?: () => void +}) { + const queryClient = useQueryClient() + + return useServerMutation<boolean, PlaybackStartPlaylist_Variables>({ + endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.endpoint, + method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.methods[0], + mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.key], + onSuccess: async () => { + await queryClient.refetchQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] }) + onSuccess?.() + }, + }) +} + +export function usePlaybackCancelCurrentPlaylist(...keys: any) { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.endpoint, + method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.methods[0], + mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.key, ...keys], + onSuccess: async () => { + toast.info("Cancelling playlist") + }, + }) +} + +export function usePlaybackPlaylistNext(...keys: any) { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.endpoint, + method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.methods[0], + mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.key, ...keys], + onSuccess: async () => { + toast.info("Loading next file") + }, + }) +} + +export function usePlaybackPlayVideo() { + return useServerMutation<boolean, PlaybackPlayVideo_Variables>({ + endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.endpoint, + method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.methods[0], + mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.key], + onSuccess: async () => { + + }, + }) +} + +export function usePlaybackPlayRandomVideo() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.endpoint, + method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.methods[0], + mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.key], + onSuccess: async () => { + toast.success("Playing random episode") + }, + }) +} + +export function usePlaybackStartManualTracking() { + return useServerMutation<boolean, PlaybackStartManualTracking_Variables>({ + endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.endpoint, + method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.methods[0], + mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.key], + onSuccess: async () => { + + }, + }) +} + +export function usePlaybackCancelManualTracking({ onSuccess }: { onSuccess?: () => void }) { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.endpoint, + method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.methods[0], + mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.key], + onSuccess: async () => { + onSuccess?.() + }, + }) +} + +export function usePlaybackGetNextEpisode() { + return useServerMutation<Anime_LocalFile>({ + endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.endpoint, + method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.methods[0], + mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.key], + }) +} + +export function usePlaybackAutoPlayNextEpisode() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.endpoint, + method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.methods[0], + mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.key], + onSuccess: async () => { + toast.info("Loading next episode") + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/playlist.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/playlist.hooks.ts new file mode 100644 index 0000000..6304567 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/playlist.hooks.ts @@ -0,0 +1,68 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { CreatePlaylist_Variables, DeletePlaylist_Variables, UpdatePlaylist_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Anime_LocalFile, Anime_Playlist } from "@/api/generated/types" +import { Nullish } from "@/types/common" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useCreatePlaylist() { + const queryClient = useQueryClient() + + return useServerMutation<Anime_Playlist, CreatePlaylist_Variables>({ + endpoint: API_ENDPOINTS.PLAYLIST.CreatePlaylist.endpoint, + method: API_ENDPOINTS.PLAYLIST.CreatePlaylist.methods[0], + mutationKey: [API_ENDPOINTS.PLAYLIST.CreatePlaylist.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] }) + toast.success("Playlist created") + }, + }) +} + +export function useGetPlaylists() { + return useServerQuery<Array<Anime_Playlist>>({ + endpoint: API_ENDPOINTS.PLAYLIST.GetPlaylists.endpoint, + method: API_ENDPOINTS.PLAYLIST.GetPlaylists.methods[0], + queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key], + enabled: true, + }) +} + +export function useUpdatePlaylist() { + const queryClient = useQueryClient() + + return useServerMutation<Anime_Playlist, UpdatePlaylist_Variables>({ + endpoint: API_ENDPOINTS.PLAYLIST.UpdatePlaylist.endpoint, + method: API_ENDPOINTS.PLAYLIST.UpdatePlaylist.methods[0], + mutationKey: [API_ENDPOINTS.PLAYLIST.UpdatePlaylist.key], + onSuccess: async () => { + await queryClient.refetchQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] }) + toast.success("Playlist updated") + }, + }) +} + +export function useDeletePlaylist() { + const queryClient = useQueryClient() + + return useServerMutation<boolean, DeletePlaylist_Variables>({ + endpoint: API_ENDPOINTS.PLAYLIST.DeletePlaylist.endpoint, + method: API_ENDPOINTS.PLAYLIST.DeletePlaylist.methods[0], + mutationKey: [API_ENDPOINTS.PLAYLIST.DeletePlaylist.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] }) + toast.success("Playlist deleted") + }, + }) +} + +export function useGetPlaylistEpisodes(id: Nullish<number>, progress: Nullish<number>) { + return useServerQuery<Array<Anime_LocalFile>>({ + endpoint: API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.endpoint.replace("{id}", String(id)).replace("{progress}", String(progress || 0)), + method: API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.methods[0], + queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.key, String(id), String(progress || 0)], + enabled: !!id, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/releases.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/releases.hooks.ts new file mode 100644 index 0000000..726a2b2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/releases.hooks.ts @@ -0,0 +1,37 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { InstallLatestUpdate_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Status, Updater_Update } from "@/api/generated/types" +import { useSetServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { toast } from "sonner" + +export function useGetLatestUpdate(enabled: boolean) { + return useServerQuery<Updater_Update>({ + endpoint: API_ENDPOINTS.RELEASES.GetLatestUpdate.endpoint, + method: API_ENDPOINTS.RELEASES.GetLatestUpdate.methods[0], + queryKey: [API_ENDPOINTS.RELEASES.GetLatestUpdate.key], + enabled: enabled, + }) +} + +export function useInstallLatestUpdate() { + const setServerStatus = useSetServerStatus() + return useServerMutation<Status, InstallLatestUpdate_Variables>({ + endpoint: API_ENDPOINTS.RELEASES.InstallLatestUpdate.endpoint, + method: API_ENDPOINTS.RELEASES.InstallLatestUpdate.methods[0], + mutationKey: [API_ENDPOINTS.RELEASES.InstallLatestUpdate.key], + onSuccess: async (data) => { + setServerStatus(data) // Update server status + toast.info("Installing update...") + }, + }) +} + +export function useGetChangelog(before: string, after: string, enabled: boolean) { + return useServerQuery<{ version: string, lines: string[] }[]>({ + endpoint: API_ENDPOINTS.RELEASES.GetChangelog.endpoint + `?before=${before}&after=${after}`, + method: API_ENDPOINTS.RELEASES.GetChangelog.methods[0], + queryKey: [API_ENDPOINTS.RELEASES.GetChangelog.key], + enabled: enabled, + }) +} \ No newline at end of file diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/report.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/report.hooks.ts new file mode 100644 index 0000000..84eb961 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/report.hooks.ts @@ -0,0 +1,23 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { SaveIssueReport_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" + +export function useSaveIssueReport() { + return useServerMutation<boolean, SaveIssueReport_Variables>({ + endpoint: API_ENDPOINTS.REPORT.SaveIssueReport.endpoint, + method: API_ENDPOINTS.REPORT.SaveIssueReport.methods[0], + mutationKey: [API_ENDPOINTS.REPORT.SaveIssueReport.key], + onSuccess: async () => { + + }, + }) +} + +export function useDownloadIssueReport() { + return useServerQuery<string>({ + endpoint: API_ENDPOINTS.REPORT.DownloadIssueReport.endpoint, + method: API_ENDPOINTS.REPORT.DownloadIssueReport.methods[0], + queryKey: [API_ENDPOINTS.REPORT.DownloadIssueReport.key], + enabled: true, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/scan.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/scan.hooks.ts new file mode 100644 index 0000000..e2a8d01 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/scan.hooks.ts @@ -0,0 +1,26 @@ +import { useServerMutation } from "@/api/client/requests" +import { ScanLocalFiles_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Anime_LocalFile } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useScanLocalFiles(onSuccess?: () => void) { + const queryClient = useQueryClient() + + return useServerMutation<Array<Anime_LocalFile>, ScanLocalFiles_Variables>({ + endpoint: API_ENDPOINTS.SCAN.ScanLocalFiles.endpoint, + method: API_ENDPOINTS.SCAN.ScanLocalFiles.methods[0], + mutationKey: [API_ENDPOINTS.SCAN.ScanLocalFiles.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + toast.success("Library scanned") + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + onSuccess?.() + }, + }) +} + + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/scan_summary.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/scan_summary.hooks.ts new file mode 100644 index 0000000..94080d3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/scan_summary.hooks.ts @@ -0,0 +1,13 @@ +import { useServerQuery } from "@/api/client/requests" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Summary_ScanSummaryItem } from "@/api/generated/types" + +export function useGetScanSummaries() { + return useServerQuery<Array<Summary_ScanSummaryItem>>({ + endpoint: API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.endpoint, + method: API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.methods[0], + queryKey: [API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.key], + enabled: true, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/settings.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/settings.hooks.ts new file mode 100644 index 0000000..2bad3d6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/settings.hooks.ts @@ -0,0 +1,64 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { GettingStarted_Variables, SaveAutoDownloaderSettings_Variables, SaveSettings_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Models_Settings, Status } from "@/api/generated/types" +import { isLoginModalOpenAtom } from "@/app/(main)/_atoms/server-status.atoms" +import { useQueryClient } from "@tanstack/react-query" +import { useSetAtom } from "jotai/react" +import { toast } from "sonner" + +export function useGetSettings() { + return useServerQuery<Models_Settings>({ + endpoint: API_ENDPOINTS.SETTINGS.GetSettings.endpoint, + method: API_ENDPOINTS.SETTINGS.GetSettings.methods[0], + queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key], + enabled: true, + }) +} + +export function useGettingStarted() { + const queryClient = useQueryClient() + const setLoginModalOpen = useSetAtom(isLoginModalOpenAtom) + + return useServerMutation<Status, GettingStarted_Variables>({ + endpoint: API_ENDPOINTS.SETTINGS.GettingStarted.endpoint, + method: API_ENDPOINTS.SETTINGS.GettingStarted.methods[0], + mutationKey: [API_ENDPOINTS.SETTINGS.GettingStarted.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] }) + setLoginModalOpen(true) + }, + }) +} + +export function useSaveSettings() { + const queryClient = useQueryClient() + + return useServerMutation<Status, SaveSettings_Variables>({ + endpoint: API_ENDPOINTS.SETTINGS.SaveSettings.endpoint, + method: API_ENDPOINTS.SETTINGS.SaveSettings.methods[0], + mutationKey: [API_ENDPOINTS.SETTINGS.SaveSettings.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] }) + toast.success("Settings saved") + }, + }) +} + +export function useSaveAutoDownloaderSettings() { + const queryClient = useQueryClient() + + return useServerMutation<boolean, SaveAutoDownloaderSettings_Variables>({ + endpoint: API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.endpoint, + method: API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.methods[0], + mutationKey: [API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key] }) + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] }) + toast.success("Settings saved") + }, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/status.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/status.hooks.ts new file mode 100644 index 0000000..920105e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/status.hooks.ts @@ -0,0 +1,278 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { getServerBaseUrl } from "@/api/client/server-url" +import { DeleteLogs_Variables, GetAnnouncements_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { MemoryStatsResponse, Status, Updater_Announcement } from "@/api/generated/types" +import { serverAuthTokenAtom } from "@/app/(main)/_atoms/server-status.atoms" +import { copyToClipboard, openTab } from "@/lib/helpers/browser" +import { __isDesktop__ } from "@/types/constants" +import { useQueryClient } from "@tanstack/react-query" +import { useAtomValue } from "jotai" +import { toast } from "sonner" + +export function useGetStatus() { + return useServerQuery<Status>({ + endpoint: API_ENDPOINTS.STATUS.GetStatus.endpoint, + method: API_ENDPOINTS.STATUS.GetStatus.methods[0], + queryKey: [API_ENDPOINTS.STATUS.GetStatus.key], + enabled: true, + retryDelay: 1000, + // Fixes macOS desktop app startup issue + retry: 6, + // Mute error if the platform is desktop + muteError: __isDesktop__, + }) +} + +export function useGetLogFilenames() { + return useServerQuery<Array<string>>({ + endpoint: API_ENDPOINTS.STATUS.GetLogFilenames.endpoint, + method: API_ENDPOINTS.STATUS.GetLogFilenames.methods[0], + queryKey: [API_ENDPOINTS.STATUS.GetLogFilenames.key], + enabled: true, + }) +} + +export function useDeleteLogs() { + const qc = useQueryClient() + return useServerMutation<boolean, DeleteLogs_Variables>({ + endpoint: API_ENDPOINTS.STATUS.DeleteLogs.endpoint, + method: API_ENDPOINTS.STATUS.DeleteLogs.methods[0], + mutationKey: [API_ENDPOINTS.STATUS.DeleteLogs.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetLogFilenames.key] }) + toast.success("Logs deleted") + }, + }) +} + +export function useGetLatestLogContent() { + const qc = useQueryClient() + return useServerMutation<string>({ + endpoint: API_ENDPOINTS.STATUS.GetLatestLogContent.endpoint, + method: API_ENDPOINTS.STATUS.GetLatestLogContent.methods[0], + mutationKey: [API_ENDPOINTS.STATUS.GetLatestLogContent.key], + onSuccess: async data => { + if (!data) return toast.error("Couldn't fetch logs") + try { + await copyToClipboard(data) + toast.success("Copied to clipboard") + } + catch (err: any) { + console.error("Clipboard write error:", err) + toast.error("Failed to copy logs: " + err.message) + } + }, + }) +} + +export function useGetAnnouncements() { + return useServerMutation<Array<Updater_Announcement>, GetAnnouncements_Variables>({ + endpoint: API_ENDPOINTS.STATUS.GetAnnouncements.endpoint, + method: API_ENDPOINTS.STATUS.GetAnnouncements.methods[0], + mutationKey: [API_ENDPOINTS.STATUS.GetAnnouncements.key], + }) +} + +// Memory profiling hooks + +export function useGetMemoryStats() { + return useServerQuery<MemoryStatsResponse>({ + endpoint: API_ENDPOINTS.STATUS.GetMemoryStats.endpoint, + method: API_ENDPOINTS.STATUS.GetMemoryStats.methods[0], + queryKey: [API_ENDPOINTS.STATUS.GetMemoryStats.key], + enabled: false, // Manual trigger only + refetchInterval: false, + }) +} + +export function useForceGC() { + const qc = useQueryClient() + return useServerMutation<MemoryStatsResponse>({ + endpoint: API_ENDPOINTS.STATUS.ForceGC.endpoint, + method: API_ENDPOINTS.STATUS.ForceGC.methods[0], + mutationKey: [API_ENDPOINTS.STATUS.ForceGC.key], + onSuccess: async () => { + // Invalidate and refetch memory stats after GC + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetMemoryStats.key] }) + toast.success("Garbage collection completed") + }, + }) +} + +export function useDownloadMemoryProfile() { + const password = useAtomValue(serverAuthTokenAtom) + + return useServerMutation<string, { profileType: "heap" | "allocs" }>({ + endpoint: API_ENDPOINTS.STATUS.GetMemoryProfile.endpoint, + method: API_ENDPOINTS.STATUS.GetMemoryProfile.methods[0], + mutationKey: [API_ENDPOINTS.STATUS.GetMemoryProfile.key], + onMutate: async (variables) => { + const profileType = variables.profileType || "heap" + toast.info(`Generating ${profileType} profile...`) + + let downloadUrl = getServerBaseUrl() + API_ENDPOINTS.STATUS.GetMemoryProfile.endpoint + if (profileType === "heap") { + downloadUrl += "?heap=true" + } else if (profileType === "allocs") { + downloadUrl += "?allocs=true" + } + + try { + const headers: Record<string, string> = {} + if (password) { + headers["X-Seanime-Token"] = password + } + + const response = await fetch(downloadUrl, { + method: "GET", + headers, + }) + + if (!response.ok) { + throw new Error(`HTTP error: status: ${response.status}`) + } + + const blob = await response.blob() + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0] + "_" + + new Date().toISOString().replace(/[:.]/g, "-").split("T")[1].split(".")[0] + const filename = `seanime-${profileType}-profile-${timestamp}.pprof` + + const url = window.URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.setAttribute("download", filename) + link.style.display = "none" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + toast.success(`Profile "${profileType}" downloaded`) + } + catch (error) { + console.error("Download error:", error) + toast.error(`Failed to download ${profileType} profile`) + } + + throw new Error("Download handled in onMutate") + }, + onError: (error) => { + if (error.message !== "Download handled in onMutate") { + toast.error("Failed to download memory profile") + } + }, + }) +} + +export function useDownloadGoRoutineProfile() { + const password = useAtomValue(serverAuthTokenAtom) + + return useServerMutation<string>({ + endpoint: API_ENDPOINTS.STATUS.GetGoRoutineProfile.endpoint, + method: API_ENDPOINTS.STATUS.GetGoRoutineProfile.methods[0], + mutationKey: [API_ENDPOINTS.STATUS.GetGoRoutineProfile.key], + onMutate: async () => { + toast.info("Generating goroutine profile...") + + const downloadUrl = getServerBaseUrl() + API_ENDPOINTS.STATUS.GetGoRoutineProfile.endpoint + + try { + const headers: Record<string, string> = {} + if (password) { + headers["X-Seanime-Token"] = password + } + + const response = await fetch(downloadUrl, { + method: "GET", + headers, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const blob = await response.blob() + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0] + "_" + + new Date().toISOString().replace(/[:.]/g, "-").split("T")[1].split(".")[0] + const filename = `seanime-goroutine-profile-${timestamp}.pprof` + + const url = window.URL.createObjectURL(blob) + openTab(url) + + toast.success("Goroutine profile downloaded") + } + catch (error) { + console.error("Download error:", error) + toast.error("Failed to download goroutine profile") + } + + throw new Error("Download handled in onMutate") + }, + onError: (error) => { + if (error.message !== "Download handled in onMutate") { + toast.error("Failed to download goroutine profile") + } + }, + }) +} + +export function useDownloadCPUProfile() { + const password = useAtomValue(serverAuthTokenAtom) + + return useServerMutation<string, { duration?: number }>({ + endpoint: API_ENDPOINTS.STATUS.GetCPUProfile.endpoint, + method: API_ENDPOINTS.STATUS.GetCPUProfile.methods[0], + mutationKey: [API_ENDPOINTS.STATUS.GetCPUProfile.key], + onMutate: async (variables) => { + const duration = variables?.duration || 30 + toast.info(`Generating CPU profile for ${duration} seconds...`) + + const downloadUrl = `${getServerBaseUrl()}${API_ENDPOINTS.STATUS.GetCPUProfile.endpoint}?duration=${duration}` + + try { + const headers: Record<string, string> = {} + if (password) { + headers["X-Seanime-Token"] = password + } + + const response = await fetch(downloadUrl, { + method: "GET", + headers, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const blob = await response.blob() + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0] + "_" + + new Date().toISOString().replace(/[:.]/g, "-").split("T")[1].split(".")[0] + const filename = `seanime-cpu-profile-${timestamp}.pprof` + + const url = window.URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.setAttribute("download", filename) + link.style.display = "none" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + toast.success(`CPU profile (${duration}s) downloaded`) + } + catch (error) { + console.error("Download error:", error) + toast.error(`Failed to download CPU profile`) + } + + throw new Error("Download handled in onMutate") + }, + onError: (error) => { + if (error.message !== "Download handled in onMutate") { + toast.error("Failed to download CPU profile") + } + }, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/theme.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/theme.hooks.ts new file mode 100644 index 0000000..d8e98a8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/theme.hooks.ts @@ -0,0 +1,30 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { UpdateTheme_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Models_Theme } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useGetTheme() { + return useServerQuery<Models_Theme>({ + endpoint: API_ENDPOINTS.THEME.GetTheme.endpoint, + method: API_ENDPOINTS.THEME.GetTheme.methods[0], + queryKey: [API_ENDPOINTS.THEME.GetTheme.key], + enabled: true, + }) +} + +export function useUpdateTheme() { + const queryClient = useQueryClient() + + return useServerMutation<Models_Theme, UpdateTheme_Variables>({ + endpoint: API_ENDPOINTS.THEME.UpdateTheme.endpoint, + method: API_ENDPOINTS.THEME.UpdateTheme.methods[0], + mutationKey: [API_ENDPOINTS.THEME.UpdateTheme.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] }) + toast.success("UI settings saved") + }, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/torrent_client.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/torrent_client.hooks.ts new file mode 100644 index 0000000..580c0ff --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/torrent_client.hooks.ts @@ -0,0 +1,63 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + TorrentClientAction_Variables, + TorrentClientAddMagnetFromRule_Variables, + TorrentClientDownload_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { TorrentClient_Torrent } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useGetActiveTorrentList(enabled: boolean) { + return useServerQuery<Array<TorrentClient_Torrent>>({ + endpoint: API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.endpoint, + method: API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.methods[0], + queryKey: [API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.key], + refetchInterval: 1500, + gcTime: 0, + enabled: enabled, + }) +} + +export function useTorrentClientAction(onSuccess?: () => void) { + const queryClient = useQueryClient() + + return useServerMutation<boolean, TorrentClientAction_Variables>({ + endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.endpoint, + method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.methods[0], + mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.key], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.key] }) + toast.success("Action performed") + onSuccess?.() + }, + }) +} + +export function useTorrentClientDownload(onSuccess?: () => void) { + return useServerMutation<boolean, TorrentClientDownload_Variables>({ + endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.endpoint, + method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.methods[0], + mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.key], + onSuccess: async () => { + toast.success("Download started") + onSuccess?.() + }, + }) +} + +export function useTorrentClientAddMagnetFromRule() { + const queryClient = useQueryClient() + + return useServerMutation<boolean, TorrentClientAddMagnetFromRule_Variables>({ + endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.endpoint, + method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.methods[0], + mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.key], + onSuccess: async () => { + toast.success("Download started") + await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] }) + }, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/torrent_search.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/torrent_search.hooks.ts new file mode 100644 index 0000000..c2aa09b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/torrent_search.hooks.ts @@ -0,0 +1,15 @@ +import { useServerQuery } from "@/api/client/requests" +import { SearchTorrent_Variables } from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Torrent_SearchData } from "@/api/generated/types" + +export function useSearchTorrent(variables: SearchTorrent_Variables, enabled: boolean) { + return useServerQuery<Torrent_SearchData, SearchTorrent_Variables>({ + endpoint: API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.endpoint, + method: API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.methods[0], + data: variables, + queryKey: [API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.key, JSON.stringify(variables)], + enabled: enabled, + gcTime: variables.episodeNumber === 0 ? 0 : undefined, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/api/hooks/torrentstream.hooks.ts b/seanime-2.9.10/seanime-web/src/api/hooks/torrentstream.hooks.ts new file mode 100644 index 0000000..d98a6fb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/api/hooks/torrentstream.hooks.ts @@ -0,0 +1,88 @@ +import { useServerMutation, useServerQuery } from "@/api/client/requests" +import { + GetTorrentstreamBatchHistory_Variables, + GetTorrentstreamTorrentFilePreviews_Variables, + SaveTorrentstreamSettings_Variables, + TorrentstreamStartStream_Variables, +} from "@/api/generated/endpoint.types" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Models_TorrentstreamSettings, Nullish, Torrentstream_BatchHistoryResponse, Torrentstream_FilePreview } from "@/api/generated/types" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export function useGetTorrentstreamSettings() { + return useServerQuery<Models_TorrentstreamSettings>({ + endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.endpoint, + method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.methods[0], + queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.key], + enabled: true, + }) +} + +export function useSaveTorrentstreamSettings() { + const qc = useQueryClient() + return useServerMutation<Models_TorrentstreamSettings, SaveTorrentstreamSettings_Variables>({ + endpoint: API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.endpoint, + method: API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.methods[0], + mutationKey: [API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.key], + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] }) + toast.success("Settings saved") + }, + }) +} + +export function useTorrentstreamStartStream() { + return useServerMutation<boolean, TorrentstreamStartStream_Variables>({ + endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.endpoint, + method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.methods[0], + mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.key], + onSuccess: async () => { + }, + }) +} + +export function useTorrentstreamStopStream() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.endpoint, + method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.methods[0], + mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.key], + onSuccess: async () => { + toast.success("Stream stopped") + }, + }) +} + +export function useTorrentstreamDropTorrent() { + return useServerMutation<boolean>({ + endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.endpoint, + method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.methods[0], + mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.key], + onSuccess: async () => { + toast.success("Torrent dropped") + }, + }) +} + +export function useGetTorrentstreamTorrentFilePreviews(variables: Partial<GetTorrentstreamTorrentFilePreviews_Variables>, enabled: boolean) { + return useServerQuery<Array<Torrentstream_FilePreview>, GetTorrentstreamTorrentFilePreviews_Variables>({ + endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.endpoint, + method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.methods[0], + queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.key, variables], + data: variables as GetTorrentstreamTorrentFilePreviews_Variables, + enabled: enabled, + }) +} + +export function useGetTorrentstreamBatchHistory(mediaId: Nullish<string | number>, enabled: boolean) { + return useServerQuery<Torrentstream_BatchHistoryResponse, GetTorrentstreamBatchHistory_Variables>({ + endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.endpoint, + method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.methods[0], + queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.key, String(mediaId), enabled], + data: { + mediaId: Number(mediaId)!, + }, + enabled: !!mediaId, + }) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_components/library-header.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_components/library-header.tsx new file mode 100644 index 0000000..ac6089e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_components/library-header.tsx @@ -0,0 +1,187 @@ +"use client" +import { Anime_Episode } from "@/api/generated/types" +import { __libraryHeaderEpisodeAtom } from "@/app/(main)/(library)/_containers/continue-watching" +import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles" +import { cn } from "@/components/ui/core/styling" +import { getImageUrl } from "@/lib/server/assets" +import { ThemeMediaPageBannerType, useThemeSettings } from "@/lib/theme/hooks" +import { atom, useAtomValue } from "jotai" +import { useSetAtom } from "jotai/react" +import { AnimatePresence, motion } from "motion/react" +import Image from "next/image" +import React, { useEffect, useState } from "react" +import { useWindowScroll } from "react-use" + +export const __libraryHeaderImageAtom = atom<{ bannerImage?: string | null, episodeImage?: string | null } | null>({ + bannerImage: null, + episodeImage: null, +}) + +const MotionImage = motion.create(Image) + +export function LibraryHeader({ list }: { list: Anime_Episode[] }) { + + const ts = useThemeSettings() + + const image = useAtomValue(__libraryHeaderImageAtom) + const [actualImage, setActualImage] = useState<string | null>(null) + const [prevImage, setPrevImage] = useState<string | null>(null) + const [dimmed, setDimmed] = useState(false) + + const setHeaderEpisode = useSetAtom(__libraryHeaderEpisodeAtom) + + const bannerImage = image?.bannerImage || image?.episodeImage || "" + const shouldHideBanner = ( + (ts.mediaPageBannerType === ThemeMediaPageBannerType.HideWhenUnavailable && !image?.bannerImage) + || ts.mediaPageBannerType === ThemeMediaPageBannerType.Hide + ) + const shouldBlurBanner = (ts.mediaPageBannerType === ThemeMediaPageBannerType.BlurWhenUnavailable && !image?.bannerImage) || + ts.mediaPageBannerType === ThemeMediaPageBannerType.Blur + + useEffect(() => { + if (image != actualImage) { + if (actualImage === null) { + setActualImage(bannerImage) + } else { + setActualImage(null) + } + } + }, [image]) + + React.useLayoutEffect(() => { + const t = setTimeout(() => { + if (image != actualImage) { + setActualImage(bannerImage) + setHeaderEpisode(list.find(ep => ep.baseAnime?.bannerImage === image?.episodeImage || ep.baseAnime?.coverImage?.extraLarge === image?.episodeImage || ep.episodeMetadata?.image === image?.episodeImage) || null) + } + }, 600) + + return () => { + clearTimeout(t) + } + }, [image]) + + useEffect(() => { + if (actualImage) { + setPrevImage(actualImage) + setHeaderEpisode(list.find(ep => ep.baseAnime?.bannerImage === actualImage || ep.baseAnime?.coverImage?.extraLarge === actualImage || ep.episodeMetadata?.image === actualImage) || null) + } + }, [actualImage]) + + const { y } = useWindowScroll() + + useEffect(() => { + if (y > 100) + setDimmed(true) + else + setDimmed(false) + }, [(y > 100)]) + + if (!image) return null + + return ( + <> + <div + data-library-header-container + className={cn( + "LIB_HEADER_CONTAINER __header h-[25rem] z-[1] top-0 w-full absolute group/library-header pointer-events-none", + // Make it not fixed when the user scrolls down if a background image is set + !ts.libraryScreenCustomBackgroundImage && "fixed", + )} + > + + <div + data-library-header-banner-top-gradient + className={cn( + "w-full z-[3] absolute bottom-[-10rem] h-[10rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent", + !ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE, + )} + /> + + <motion.div + data-library-header-inner-container + initial={{ opacity: 0 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0 }} + transition={{ duration: 1, delay: 0.2 }} + className={cn( + "LIB_HEADER_INNER_CONTAINER h-full z-[0] w-full flex-none object-cover object-center absolute top-0 overflow-hidden bg-[--background]", + !ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE, + )} + > + + {!ts.disableSidebarTransparency && <div + data-library-header-banner-inner-container-top-gradient + className="hidden lg:block h-full absolute z-[2] w-[20%] opacity-70 left-0 top-0 bg-gradient bg-gradient-to-r from-[var(--background)] to-transparent" + />} + + <div + data-library-header-banner-inner-container-bottom-gradient + className="w-full z-[3] opacity-50 absolute top-0 h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent" + /> + + {/*<div*/} + {/* className="LIB_HEADER_TOP_FADE w-full absolute z-[2] top-0 h-[10rem] opacity-20 bg-gradient-to-b from-[var(--background)] to-transparent via"*/} + {/*/>*/} + <AnimatePresence> + {!!actualImage && ( + <motion.div + key="library-header-banner-image-container" + data-library-header-image-container + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0.4 }} + transition={{ duration: 0.5 }} + > + <MotionImage + data-library-header-banner-image + src={getImageUrl(actualImage || prevImage!)} + alt="banner image" + fill + quality={100} + priority + sizes="100vw" + className={cn( + "object-cover object-center z-[1] opacity-100 transition-opacity duration-700 scroll-locked-offset", + (shouldHideBanner || shouldBlurBanner) && "opacity-15", + { "opacity-5": dimmed }, + )} + initial={{ scale: 1.01, y: 0 }} + animate={{ + scale: Math.min(1 + y * 0.0002, 1.03), + // y: Math.max(y * -0.9, -10) + }} + exit={{ scale: 1.01, y: 0 }} + transition={{ duration: 0.6, ease: "easeOut" }} + /> + </motion.div> + )} + </AnimatePresence> + {/* {prevImage && <MotionImage + data-library-header-banner-previous-image + src={getImageUrl(actualImage || prevImage!)} + alt="banner image" + fill + quality={100} + priority + sizes="100vw" + className={cn( + "object-cover object-center z-[1] opacity-50 transition-opacity scroll-locked-offset", + (shouldHideBanner || shouldBlurBanner) && "opacity-15", + { "opacity-5": dimmed }, + )} + initial={{ scale: 1, y: 0 }} + animate={{ scale: Math.min(1 + y * 0.0002, 1.03), y: Math.max(y * -0.9, -10) }} + exit={{ scale: 1, y: 0 }} + transition={{ duration: 0.6, ease: "easeOut" }} + />} */} + <div + data-library-header-banner-bottom-gradient + className="LIB_HEADER_IMG_BOTTOM_FADE w-full z-[2] absolute bottom-0 h-[20rem] lg:h-[15rem] bg-gradient-to-t from-[--background] lg:via-opacity-50 lg:via-10% to-transparent" + /> + </motion.div> + </div> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/bulk-action-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/bulk-action-modal.tsx new file mode 100644 index 0000000..af7e8dc --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/bulk-action-modal.tsx @@ -0,0 +1,136 @@ +import { useLocalFileBulkAction, useRemoveEmptyDirectories } from "@/api/hooks/localfiles.hooks" +import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { atom, useAtom } from "jotai" +import React from "react" +import { BiLockAlt, BiLockOpenAlt } from "react-icons/bi" +import { toast } from "sonner" + +export const __bulkAction_modalAtomIsOpen = atom<boolean>(false) + +export function BulkActionModal() { + + const [isOpen, setIsOpen] = useAtom(__bulkAction_modalAtomIsOpen) + + const { mutate: performBulkAction, isPending } = useLocalFileBulkAction() + + function handleLockFiles() { + performBulkAction({ + action: "lock", + }, { + onSuccess: () => { + setIsOpen(false) + toast.success("Files locked") + }, + }) + } + + function handleUnlockFiles() { + performBulkAction({ + action: "unlock", + }, { + onSuccess: () => { + setIsOpen(false) + toast.success("Files unlocked") + }, + }) + } + + const { mutate: removeEmptyDirectories, isPending: isRemoving } = useRemoveEmptyDirectories() + + function handleRemoveEmptyDirectories() { + removeEmptyDirectories(undefined, { + onSuccess: () => { + setIsOpen(false) + }, + }) + } + + const confirmRemoveEmptyDirs = useConfirmationDialog({ + title: "Remove empty directories", + description: "This action will remove all empty directories in the library. Are you sure you want to continue?", + onConfirm: () => { + handleRemoveEmptyDirectories() + }, + }) + + const { inject, remove } = useSeaCommandInject() + React.useEffect(() => { + inject("anime-library-bulk-actions", { + priority: 1, + items: [ + { + id: "lock-files", value: "lock", heading: "Library", + render: () => ( + <p>Lock all files</p> + ), + onSelect: ({ ctx }) => { + handleLockFiles() + }, + }, + { + id: "unlock-files", value: "unlock", heading: "Library", + render: () => ( + <p>Unlock all files</p> + ), + onSelect: ({ ctx }) => { + handleUnlockFiles() + }, + }, + ], + filter: ({ item, input }) => { + if (!input) return true + return item.value.toLowerCase().includes(input.toLowerCase()) + }, + shouldShow: ({ ctx }) => ctx.router.pathname === "/", + showBasedOnInput: "startsWith", + }) + + return () => remove("anime-library-bulk-actions") + }, []) + + return ( + <Modal + open={isOpen} onOpenChange={() => setIsOpen(false)} title="Bulk actions" + contentClass="space-y-4" + > + <AppLayoutStack spacing="sm"> + {/*<p>These actions do not affect ignored files.</p>*/} + <div className="flex gap-2 flex-col md:flex-row"> + <Button + leftIcon={<BiLockAlt className="text-2xl" />} + intent="gray-outline" + className="w-full" + disabled={isPending || isRemoving} + onClick={handleLockFiles} + > + Lock all files + </Button> + <Button + leftIcon={<BiLockOpenAlt className="text-2xl" />} + intent="gray-outline" + className="w-full" + disabled={isPending || isRemoving} + onClick={handleUnlockFiles} + > + Unlock all files + </Button> + </div> + <Button + intent="gray-outline" + className="w-full" + disabled={isPending} + loading={isRemoving} + onClick={() => confirmRemoveEmptyDirs.open()} + > + Remove empty directories + </Button> + </AppLayoutStack> + <ConfirmationDialog {...confirmRemoveEmptyDirs} /> + </Modal> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/continue-watching.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/continue-watching.tsx new file mode 100644 index 0000000..1d64bc2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/continue-watching.tsx @@ -0,0 +1,272 @@ +"use client" +import { Anime_Episode, Continuity_WatchHistory } from "@/api/generated/types" +import { getEpisodeMinutesRemaining, getEpisodePercentageComplete, useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks" +import { __libraryHeaderImageAtom } from "@/app/(main)/(library)/_components/library-header" +import { usePlayNext } from "@/app/(main)/_atoms/playback.atoms" +import { EpisodeCard } from "@/app/(main)/_features/anime/_components/episode-card" +import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { episodeCardCarouselItemClass } from "@/components/shared/classnames" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { TextGenerateEffect } from "@/components/shared/text-generate-effect" +import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel" +import { useDebounce } from "@/hooks/use-debounce" +import { anilist_animeIsMovie } from "@/lib/helpers/media" +import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks" +import { useWindowSize } from "@uidotdev/usehooks" +import { atom } from "jotai/index" +import { useAtom, useSetAtom } from "jotai/react" +import Image from "next/image" +import { useRouter } from "next/navigation" +import React from "react" +import { seaCommand_compareMediaTitles } from "../../_features/sea-command/utils" + +export const __libraryHeaderEpisodeAtom = atom<Anime_Episode | null>(null) + +export function ContinueWatching({ episodes, isLoading, linkTemplate }: { + episodes: Anime_Episode[], + isLoading: boolean + linkTemplate?: string +}) { + + const router = useRouter() + const ts = useThemeSettings() + + const { data: watchHistory } = useGetContinuityWatchHistory() + + const setHeaderImage = useSetAtom(__libraryHeaderImageAtom) + const [headerEpisode, setHeaderEpisode] = useAtom(__libraryHeaderEpisodeAtom) + + const [episodeRefs, setEpisodeRefs] = React.useState<React.RefObject<any>[]>([]) + const [inViewEpisodes, setInViewEpisodes] = React.useState<number[]>([]) + const debouncedInViewEpisodes = useDebounce(inViewEpisodes, 500) + + const { width } = useWindowSize() + + // Create refs for each episode + React.useEffect(() => { + setEpisodeRefs(episodes.map(() => React.createRef())) + }, [episodes]) + + // Observe each episode + React.useEffect(() => { + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + const index = episodeRefs.findIndex(ref => ref.current === entry.target) + if (index !== -1) { + if (entry.isIntersecting) { + setInViewEpisodes(prev => prev.includes(index) ? prev : [...prev, index]) + } else { + setInViewEpisodes(prev => prev.filter((idx) => idx !== index)) + } + } + }) + }, { threshold: 0.5 }) // Trigger callback when 50% of the element is visible + + episodeRefs.forEach((ref) => { + if (ref.current) { + observer.observe(ref.current) + } + }) + + return () => { + episodeRefs.forEach((ref) => { + if (ref.current) { + observer.unobserve(ref.current) + } + }) + } + }, [episodeRefs, width]) + + const prevSelectedEpisodeRef = React.useRef<Anime_Episode | null>(null) + + // Set header image when new episode is in view + React.useEffect(() => { + if (debouncedInViewEpisodes.length === 0) return + + let candidateIndices = debouncedInViewEpisodes + // Exclude previously selected episode if possible + if (prevSelectedEpisodeRef.current && debouncedInViewEpisodes.length > 1) { + candidateIndices = debouncedInViewEpisodes.filter(idx => episodes[idx]?.baseAnime?.id !== prevSelectedEpisodeRef.current?.baseAnime?.id) + } + if (candidateIndices.length === 0) candidateIndices = debouncedInViewEpisodes + + const randomCandidateIdx = candidateIndices[Math.floor(Math.random() * candidateIndices.length)] + const selectedEpisode = episodes[randomCandidateIdx] + + if (selectedEpisode) { + setHeaderImage({ + bannerImage: selectedEpisode.baseAnime?.bannerImage || null, + episodeImage: selectedEpisode.baseAnime?.bannerImage || selectedEpisode.baseAnime?.coverImage?.extraLarge || null, + }) + prevSelectedEpisodeRef.current = selectedEpisode + } + }, [debouncedInViewEpisodes, episodes, setHeaderImage]) + + const { setPlayNext } = usePlayNext() + + const { inject, remove } = useSeaCommandInject() + + React.useEffect(() => { + inject("continue-watching", { + items: episodes.map(episode => ({ + data: episode, + id: `${episode.localFile?.path || episode.baseAnime?.title?.userPreferred || ""}-${episode.episodeNumber || 1}`, + value: `${episode.episodeNumber || 1}`, + heading: "Continue Watching", + priority: 100, + render: () => ( + <> + <div className="w-12 aspect-[6/5] flex-none rounded-[--radius-md] relative overflow-hidden"> + <Image + src={episode.episodeMetadata?.image || ""} + alt="episode image" + fill + className="object-center object-cover" + /> + </div> + <div className="flex gap-1 items-center w-full"> + <p className="max-w-[70%] truncate">{episode.baseAnime?.title?.userPreferred || ""}</p> -  + {!anilist_animeIsMovie(episode.baseAnime) ? <> + <p className="text-[--muted]">Ep</p><span>{episode.episodeNumber}</span> + </> : <> + <p className="text-[--muted]">Movie</p> + </>} + + </div> + </> + ), + onSelect: () => setPlayNext(episode.baseAnime?.id, () => { + router.push(`/entry?id=${episode.baseAnime?.id}`) + }), + })), + filter: ({ item, input }) => { + if (!input) return true + return item.value.toLowerCase().includes(input.toLowerCase()) || + seaCommand_compareMediaTitles(item.data.baseAnime?.title, input) + }, + priority: 100, + }) + + return () => remove("continue-watching") + }, [episodes, inject, remove, router, setPlayNext]) + + if (episodes.length > 0) return ( + <PageWrapper className="space-y-3 lg:space-y-6 p-4 relative z-[4]" data-continue-watching-container> + <h2 data-continue-watching-title>Continue watching</h2> + {(ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && headerEpisode?.baseAnime) && <TextGenerateEffect + data-continue-watching-media-title + words={headerEpisode?.baseAnime?.title?.userPreferred || ""} + className="w-full text-xl lg:text-5xl lg:max-w-[50%] h-[3.2rem] !mt-1 line-clamp-1 truncate text-ellipsis hidden lg:block pb-1" + />} + <Carousel + className="w-full max-w-full" + gap="md" + opts={{ + align: "start", + }} + autoScroll + autoScrollDelay={8000} + > + <CarouselDotButtons /> + <CarouselContent> + {episodes.map((episode, idx) => ( + <CarouselItem + key={episode?.localFile?.path || idx} + className={episodeCardCarouselItemClass(ts.smallerEpisodeCarouselSize)} + > + <_EpisodeCard + key={episode.localFile?.path || ""} + episode={episode} + mRef={episodeRefs[idx]} + overrideLink={linkTemplate} + watchHistory={watchHistory} + /> + </CarouselItem> + ))} + </CarouselContent> + </Carousel> + </PageWrapper> + ) + return null +} + +const _EpisodeCard = React.memo(({ episode, mRef, overrideLink, watchHistory }: { + episode: Anime_Episode, + mRef: React.RefObject<any>, + overrideLink?: string + watchHistory: Continuity_WatchHistory | undefined +}) => { + const serverStatus = useServerStatus() + const router = useRouter() + const setHeaderImage = useSetAtom(__libraryHeaderImageAtom) + const setHeaderEpisode = useSetAtom(__libraryHeaderEpisodeAtom) + + React.useEffect(() => { + setHeaderImage(prev => { + if (prev?.episodeImage === null) { + return { + bannerImage: episode.baseAnime?.bannerImage || null, + episodeImage: episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge || null, + } + } + return prev + }) + setHeaderEpisode(prev => { + if (prev === null) { + return episode + } + return prev + }) + }, []) + + const { setPlayNext } = usePlayNext() + + return ( + <EpisodeCard + key={episode.localFile?.path || ""} + episode={episode} + image={episode.episodeMetadata?.image || episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge} + topTitle={episode.episodeTitle || episode?.baseAnime?.title?.userPreferred} + title={episode.displayTitle} + isInvalid={episode.isInvalid} + progressTotal={episode.baseAnime?.episodes} + progressNumber={episode.progressNumber} + episodeNumber={episode.episodeNumber} + length={episode.episodeMetadata?.length} + hasDiscrepancy={episode.episodeNumber !== episode.progressNumber} + percentageComplete={getEpisodePercentageComplete(watchHistory, episode.baseAnime?.id || 0, episode.episodeNumber)} + minutesRemaining={getEpisodeMinutesRemaining(watchHistory, episode.baseAnime?.id || 0, episode.episodeNumber)} + anime={{ + id: episode?.baseAnime?.id || 0, + image: episode?.baseAnime?.coverImage?.medium, + title: episode?.baseAnime?.title?.userPreferred, + }} + onMouseEnter={() => { + React.startTransition(() => { + setHeaderImage({ + bannerImage: episode.baseAnime?.bannerImage || null, + episodeImage: episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge || null, + }) + }) + }} + mRef={mRef} + onClick={() => { + if (!overrideLink) { + setPlayNext(episode.baseAnime?.id, () => { + if (!serverStatus?.isOffline) { + router.push(`/entry?id=${episode.baseAnime?.id}`) + } else { + router.push(`/offline/entry/anime?id=${episode.baseAnime?.id}`) + } + }) + } else { + setPlayNext(episode.baseAnime?.id, () => { + router.push(overrideLink.replace("{id}", episode.baseAnime?.id ? String(episode.baseAnime.id) : "")) + }) + } + }} + /> + ) +}) + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/custom-library-banner.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/custom-library-banner.tsx new file mode 100644 index 0000000..ea072aa --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/custom-library-banner.tsx @@ -0,0 +1,112 @@ +"use client" +import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles" +import { cn } from "@/components/ui/core/styling" +import { getAssetUrl } from "@/lib/server/assets" +import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks" +import { motion } from "motion/react" +import React, { useEffect } from "react" +import { useWindowScroll } from "react-use" + +type CustomLibraryBannerProps = { + discrete?: boolean + isLibraryScreen?: boolean // Anime library or manga library +} + +export function CustomLibraryBanner(props: CustomLibraryBannerProps) { + /** + * Library screens: Shows the custom banner IF theme settings are set to use a custom banner + * Other pages: Shows the custom banner + */ + const { discrete, isLibraryScreen } = props + const ts = useThemeSettings() + const image = React.useMemo(() => ts.libraryScreenCustomBannerImage ? getAssetUrl(ts.libraryScreenCustomBannerImage) : "", + [ts.libraryScreenCustomBannerImage]) + const [dimmed, setDimmed] = React.useState(false) + + const { y } = useWindowScroll() + + useEffect(() => { + if (y > 100) + setDimmed(true) + else + setDimmed(false) + }, [(y > 100)]) + + if (isLibraryScreen && ts.libraryScreenBannerType !== ThemeLibraryScreenBannerType.Custom) return null + if (discrete && !!ts.libraryScreenCustomBackgroundImage) return null + if (!image) return null + + return ( + <> + {!discrete && <div + data-custom-library-banner-top-spacer + className={cn( + "py-20", + ts.hideTopNavbar && "py-28", + )} + ></div>} + <div + data-custom-library-banner-container + className={cn( + "__header h-[30rem] z-[1] top-0 w-full fixed group/library-header transition-opacity duration-1000", + discrete && "opacity-20", + !!ts.libraryScreenCustomBackgroundImage && "absolute", // If there's a background image, make the banner absolute + (!ts.libraryScreenCustomBackgroundImage && dimmed) && "opacity-5", // If the user has scrolled down, dim the banner + !ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE, + "scroll-locked-offset", + )} + > + {(!ts.disableSidebarTransparency && !discrete) && <div + data-custom-library-banner-top-gradient + className="hidden lg:block h-full absolute z-[2] w-[20rem] opacity-70 left-0 top-0 bg-gradient bg-gradient-to-r from-[var(--background)] to-transparent" + />} + + <div + data-custom-library-banner-bottom-gradient + className="w-full z-[3] absolute bottom-[-5rem] h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent" + /> + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0 }} + transition={{ duration: 1, delay: 0.2 }} + className={cn( + "h-[30rem] z-[0] w-full flex-none absolute top-0 overflow-hidden", + "scroll-locked-offset", + )} + data-custom-library-banner-inner-container + > + <div + data-custom-library-banner-top-gradient + className={cn( + "CUSTOM_LIB_BANNER_TOP_FADE w-full absolute z-[2] top-0 h-[5rem] opacity-40 bg-gradient-to-b from-[--background] to-transparent via", + discrete && "opacity-70", + )} + /> + <div + data-custom-library-banner-image + className={cn( + "CUSTOM_LIB_BANNER_IMG z-[1] absolute inset-0 w-full h-full bg-cover bg-no-repeat transition-opacity duration-1000", + "scroll-locked-offset", + )} + style={{ + backgroundImage: `url(${image})`, + backgroundPosition: ts.libraryScreenCustomBannerPosition || "50% 50%", + opacity: (ts.libraryScreenCustomBannerOpacity || 100) / 100, + backgroundRepeat: "no-repeat", + backgroundSize: "cover", + }} + /> + <div + data-custom-library-banner-bottom-gradient + className={cn( + "CUSTOM_LIB_BANNER_BOTTOM_FADE w-full z-[2] absolute bottom-0 h-[20rem] bg-gradient-to-t from-[--background] via-opacity-50 via-10% to-transparent via", + discrete && "via-50% via-opacity-100 h-[40rem]", + )} + /> + </motion.div> + </div> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/ignored-file-manager.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/ignored-file-manager.tsx new file mode 100644 index 0000000..9bed288 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/ignored-file-manager.tsx @@ -0,0 +1,135 @@ +import { Anime_LocalFile } from "@/api/generated/types" +import { useOpenInExplorer } from "@/api/hooks/explorer.hooks" +import { useUpdateLocalFiles } from "@/api/hooks/localfiles.hooks" +import { LuffyError } from "@/components/shared/luffy-error" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Drawer } from "@/components/ui/drawer" +import { upath } from "@/lib/helpers/upath" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" +import { TbFileSad } from "react-icons/tb" +import { toast } from "sonner" + +export const __ignoredFileManagerIsOpen = atom(false) + +type IgnoredFileManagerProps = { + files: Anime_LocalFile[] +} + +export function IgnoredFileManager(props: IgnoredFileManagerProps) { + + const { files } = props + + const [isOpen, setIsOpen] = useAtom(__ignoredFileManagerIsOpen) + + const { mutate: openInExplorer } = useOpenInExplorer() + + const { mutate: updateLocalFiles, isPending: isUpdating } = useUpdateLocalFiles() + + const [selectedPaths, setSelectedPaths] = React.useState<string[]>([]) + + React.useLayoutEffect(() => { + setSelectedPaths(files?.map(lf => lf.path) ?? []) + }, [files]) + + function handleUnIgnoreSelected() { + if (selectedPaths.length > 0) { + updateLocalFiles({ + paths: selectedPaths, + action: "unignore", + }, { + onSuccess: () => { + toast.success("Files un-ignored") + }, + }) + } + } + + + return ( + <Drawer + open={isOpen} + onOpenChange={() => setIsOpen(false)} + // contentClass="max-w-5xl" + size="xl" + title="Ignored files" + > + <AppLayoutStack className="mt-4"> + + {files.length > 0 && <div className="flex flex-wrap items-center gap-2"> + <div className="flex flex-1"></div> + <Button + leftIcon={<TbFileSad className="text-lg" />} + intent="white" + size="sm" + rounded + loading={isUpdating} + onClick={handleUnIgnoreSelected} + > + Un-ignore selection + </Button> + </div>} + + {files.length === 0 && <LuffyError title={null}> + No ignored files + </LuffyError>} + + {files.length > 0 && + <div className="bg-gray-950 border p-2 px-2 divide-y divide-[--border] rounded-[--radius-md] max-h-[85vh] max-w-full overflow-x-auto overflow-y-auto text-sm"> + + <div className="p-2"> + <Checkbox + label={`Select all files`} + value={(selectedPaths.length === files?.length) ? true : (selectedPaths.length === 0 + ? false + : "indeterminate")} + onValueChange={checked => { + if (typeof checked === "boolean") { + setSelectedPaths(draft => { + if (draft.length === files?.length) { + return [] + } else { + return files?.map(lf => lf.path) ?? [] + } + }) + } + }} + fieldClass="w-[fit-content]" + /> + </div> + + {files.map((lf, index) => ( + <div + key={`${lf.path}-${index}`} + className="p-2 " + > + <div className="flex items-center"> + <Checkbox + label={`${upath.basename(lf.path)}`} + value={selectedPaths.includes(lf.path)} + onValueChange={checked => { + if (typeof checked === "boolean") { + setSelectedPaths(draft => { + if (checked) { + return [...draft, lf.path] + } else { + return draft.filter(p => p !== lf.path) + } + }) + } + }} + fieldClass="w-[fit-content]" + /> + </div> + </div> + ))} + </div>} + + </AppLayoutStack> + </Drawer> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/library-collection.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/library-collection.tsx new file mode 100644 index 0000000..e418b67 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/library-collection.tsx @@ -0,0 +1,138 @@ +import { Anime_LibraryCollectionEntry, Anime_LibraryCollectionList } from "@/api/generated/types" +import { __mainLibrary_paramsAtom } from "@/app/(main)/(library)/_lib/handle-library-collection" +import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { IconButton } from "@/components/ui/button" +import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { getLibraryCollectionTitle } from "@/lib/server/utils" +import { useAtom } from "jotai/react" +import React from "react" +import { LuListFilter } from "react-icons/lu" + +export function LibraryCollectionLists({ collectionList, isLoading, streamingMediaIds }: { + collectionList: Anime_LibraryCollectionList[], + isLoading: boolean, + streamingMediaIds: number[] +}) { + + return ( + <PageWrapper + key="library-collection-lists" + className="space-y-8" + data-library-collection-lists + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.25, + }, + }}> + {collectionList.map(collection => { + if (!collection.entries?.length) return null + return <LibraryCollectionListItem key={collection.type} list={collection} streamingMediaIds={streamingMediaIds} /> + })} + </PageWrapper> + ) + +} + +export function LibraryCollectionFilteredLists({ collectionList, isLoading, streamingMediaIds }: { + collectionList: Anime_LibraryCollectionList[], + isLoading: boolean, + streamingMediaIds: number[] +}) { + + // const params = useAtomValue(__mainLibrary_paramsAtom) + + return ( + <PageWrapper + key="library-filtered-lists" + className="space-y-8" + data-library-filtered-lists + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.25, + }, + }}> + {/*<h3 className="text-center truncate">*/} + {/* {params.genre?.join(", ")}*/} + {/*</h3>*/} + <MediaCardLazyGrid itemCount={collectionList?.flatMap(n => n.entries)?.length ?? 0}> + {collectionList?.flatMap(n => n.entries)?.filter(Boolean)?.map(entry => { + return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} /> + })} + </MediaCardLazyGrid> + </PageWrapper> + ) + +} + +export const LibraryCollectionListItem = React.memo(({ list, streamingMediaIds }: { + list: Anime_LibraryCollectionList, + streamingMediaIds: number[] +}) => { + + const isCurrentlyWatching = list.type === "CURRENT" + + const [params, setParams] = useAtom(__mainLibrary_paramsAtom) + + return ( + <React.Fragment key={list.type}> + <div className="flex gap-3 items-center" data-library-collection-list-item-header data-list-type={list.type}> + <h2 className="p-0 m-0">{getLibraryCollectionTitle(list.type)}</h2> + <div className="flex flex-1"></div> + {isCurrentlyWatching && <DropdownMenu + trigger={<IconButton + intent="white-basic" + size="xs" + className="mt-1" + icon={<LuListFilter />} + />} + > + <DropdownMenuItem + onClick={() => { + setParams(draft => { + draft.continueWatchingOnly = !draft.continueWatchingOnly + return + }) + }} + > + {params.continueWatchingOnly ? "Show all" : "Show unwatched only"} + </DropdownMenuItem> + </DropdownMenu>} + </div> + <MediaCardLazyGrid + itemCount={list?.entries?.length || 0} + data-library-collection-list-item-media-card-lazy-grid + data-list-type={list.type} + > + {list.entries?.map(entry => { + return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} /> + })} + </MediaCardLazyGrid> + </React.Fragment> + ) +}) + +export const LibraryCollectionEntryItem = React.memo(({ entry, streamingMediaIds }: { + entry: Anime_LibraryCollectionEntry, + streamingMediaIds: number[] +}) => { + return ( + <MediaEntryCard + media={entry.media!} + listData={entry.listData} + libraryData={entry.libraryData} + nakamaLibraryData={entry.nakamaLibraryData} + showListDataButton + withAudienceScore={false} + type="anime" + showLibraryBadge={!!streamingMediaIds?.length && !streamingMediaIds.includes(entry.mediaId) && entry.listData?.status === "CURRENT"} + /> + ) +}) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/library-toolbar.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/library-toolbar.tsx new file mode 100644 index 0000000..6646711 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/library-toolbar.tsx @@ -0,0 +1,191 @@ +"use client" +import { Anime_LibraryCollectionList, Anime_LocalFile, Anime_UnknownGroup } from "@/api/generated/types" +import { useOpenInExplorer } from "@/api/hooks/explorer.hooks" +import { __bulkAction_modalAtomIsOpen } from "@/app/(main)/(library)/_containers/bulk-action-modal" +import { __ignoredFileManagerIsOpen } from "@/app/(main)/(library)/_containers/ignored-file-manager" +import { PlayRandomEpisodeButton } from "@/app/(main)/(library)/_containers/play-random-episode-button" +import { __playlists_modalOpenAtom } from "@/app/(main)/(library)/_containers/playlists/playlists-modal" +import { __scanner_modalIsOpen } from "@/app/(main)/(library)/_containers/scanner-modal" +import { __unknownMedia_drawerIsOpen } from "@/app/(main)/(library)/_containers/unknown-media-manager" +import { __unmatchedFileManagerIsOpen } from "@/app/(main)/(library)/_containers/unmatched-file-manager" +import { __library_viewAtom } from "@/app/(main)/(library)/_lib/library-view.atoms" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { SeaLink } from "@/components/shared/sea-link" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { Tooltip } from "@/components/ui/tooltip" +import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks" +import { useAtom, useSetAtom } from "jotai/react" +import React from "react" +import { BiCollection, BiDotsVerticalRounded, BiFolder } from "react-icons/bi" +import { FiSearch } from "react-icons/fi" +import { IoLibrary, IoLibrarySharp } from "react-icons/io5" +import { MdOutlineVideoLibrary } from "react-icons/md" +import { PiClockCounterClockwiseFill } from "react-icons/pi" +import { TbFileSad, TbReload } from "react-icons/tb" +import { PluginAnimeLibraryDropdownItems } from "../../_features/plugin/actions/plugin-actions" + +export type LibraryToolbarProps = { + collectionList: Anime_LibraryCollectionList[] + ignoredLocalFiles: Anime_LocalFile[] + unmatchedLocalFiles: Anime_LocalFile[] + unknownGroups: Anime_UnknownGroup[] + isLoading: boolean + hasEntries: boolean + isStreamingOnly: boolean + isNakamaLibrary: boolean +} + +export function LibraryToolbar(props: LibraryToolbarProps) { + + const { + collectionList, + ignoredLocalFiles, + unmatchedLocalFiles, + unknownGroups, + hasEntries, + isStreamingOnly, + isNakamaLibrary, + } = props + + const ts = useThemeSettings() + const setBulkActionIsOpen = useSetAtom(__bulkAction_modalAtomIsOpen) + + const status = useServerStatus() + const setScannerModalOpen = useSetAtom(__scanner_modalIsOpen) + const setUnmatchedFileManagerOpen = useSetAtom(__unmatchedFileManagerIsOpen) + const setIgnoredFileManagerOpen = useSetAtom(__ignoredFileManagerIsOpen) + const setUnknownMediaManagerOpen = useSetAtom(__unknownMedia_drawerIsOpen) + const setPlaylistsModalOpen = useSetAtom(__playlists_modalOpenAtom) + + const [libraryView, setLibraryView] = useAtom(__library_viewAtom) + + const { mutate: openInExplorer } = useOpenInExplorer() + + const hasLibraryPath = !!status?.settings?.library?.libraryPath + + return ( + <> + {(ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && hasEntries) && <div + className={cn( + "h-28", + ts.hideTopNavbar && "h-40", + )} + data-library-toolbar-top-padding + ></div>} + <div className="flex flex-wrap w-full justify-end gap-2 p-4 relative z-[10]" data-library-toolbar-container> + <div className="flex flex-1" data-library-toolbar-spacer></div> + {(hasEntries) && ( + <> + <Tooltip + trigger={<IconButton + data-library-toolbar-switch-view-button + intent={libraryView === "base" ? "white-subtle" : "white"} + icon={<IoLibrary className="text-2xl" />} + onClick={() => setLibraryView(p => p === "detailed" ? "base" : "detailed")} + />} + > + Switch view + </Tooltip> + + {!(isStreamingOnly || isNakamaLibrary) && <Tooltip + trigger={<IconButton + data-library-toolbar-playlists-button + intent={"white-subtle"} + icon={<MdOutlineVideoLibrary className="text-2xl" />} + onClick={() => setPlaylistsModalOpen(true)} + />} + >Playlists</Tooltip>} + + {!(isStreamingOnly || isNakamaLibrary) && <PlayRandomEpisodeButton />} + + {/*Show up even when there's no local entries*/} + {!isNakamaLibrary && hasLibraryPath && <Button + data-library-toolbar-scan-button + intent={hasEntries ? "primary-subtle" : "primary"} + leftIcon={hasEntries ? <TbReload className="text-xl" /> : <FiSearch className="text-xl" />} + onClick={() => setScannerModalOpen(true)} + hideTextOnSmallScreen + > + {hasEntries ? "Refresh library" : "Scan your library"} + </Button>} + </> + )} + {(unmatchedLocalFiles.length > 0) && <Button + data-library-toolbar-unmatched-button + intent="alert" + leftIcon={<IoLibrarySharp />} + className="animate-bounce" + onClick={() => setUnmatchedFileManagerOpen(true)} + > + Resolve unmatched ({unmatchedLocalFiles.length}) + </Button>} + {(unknownGroups.length > 0) && <Button + data-library-toolbar-unknown-button + intent="warning" + leftIcon={<IoLibrarySharp />} + className="animate-bounce" + onClick={() => setUnknownMediaManagerOpen(true)} + > + Resolve hidden media ({unknownGroups.length}) + </Button>} + + {(!isStreamingOnly && !isNakamaLibrary && hasLibraryPath) && + <DropdownMenu + trigger={<IconButton + data-library-toolbar-dropdown-menu-trigger + icon={<BiDotsVerticalRounded />} intent="gray-basic" + />} + > + + <DropdownMenuItem + data-library-toolbar-open-directory-button + disabled={!hasLibraryPath} + className={cn("cursor-pointer", { "!text-[--muted]": !hasLibraryPath })} + onClick={() => { + openInExplorer({ path: status?.settings?.library?.libraryPath ?? "" }) + }} + > + <BiFolder /> + <span>Open directory</span> + </DropdownMenuItem> + + <DropdownMenuItem + data-library-toolbar-bulk-actions-button + onClick={() => setBulkActionIsOpen(true)} + disabled={!hasEntries} + className={cn({ "!text-[--muted]": !hasEntries })} + > + <BiCollection /> + <span>Bulk actions</span> + </DropdownMenuItem> + + <DropdownMenuItem + data-library-toolbar-ignored-files-button + onClick={() => setIgnoredFileManagerOpen(true)} + // disabled={!hasEntries} + className={cn({ "!text-[--muted]": !hasEntries })} + > + <TbFileSad /> + <span>Ignored files</span> + </DropdownMenuItem> + + <SeaLink href="/scan-summaries"> + <DropdownMenuItem + data-library-toolbar-scan-summaries-button + // className={cn({ "!text-[--muted]": !hasEntries })} + > + <PiClockCounterClockwiseFill /> + <span>Scan summaries</span> + </DropdownMenuItem> + </SeaLink> + + <PluginAnimeLibraryDropdownItems /> + </DropdownMenu>} + + </div> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/play-random-episode-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/play-random-episode-button.tsx new file mode 100644 index 0000000..2bbaf11 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/play-random-episode-button.tsx @@ -0,0 +1,33 @@ +import { usePlaybackPlayRandomVideo } from "@/api/hooks/playback_manager.hooks" +import { IconButton } from "@/components/ui/button" +import { Tooltip } from "@/components/ui/tooltip" +import React from "react" +import { LiaRandomSolid } from "react-icons/lia" + +type PlayRandomEpisodeButtonProps = { + children?: React.ReactNode +} + +export function PlayRandomEpisodeButton(props: PlayRandomEpisodeButtonProps) { + + const { + children, + ...rest + } = props + + const { mutate: playRandom, isPending } = usePlaybackPlayRandomVideo() + + return ( + <> + <Tooltip + trigger={<IconButton + data-play-random-episode-button + intent={"white-subtle"} + icon={<LiaRandomSolid className="text-2xl" />} + loading={isPending} + onClick={() => playRandom()} + />} + >Play random anime</Tooltip> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/playlist-manager.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/playlist-manager.tsx new file mode 100644 index 0000000..a1df6a4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/playlist-manager.tsx @@ -0,0 +1,358 @@ +import { AL_BaseAnime, Anime_LibraryCollectionEntry, Anime_LocalFile } from "@/api/generated/types" +import { useGetLocalFiles } from "@/api/hooks/localfiles.hooks" +import { useGetPlaylistEpisodes } from "@/api/hooks/playlist.hooks" +import { animeLibraryCollectionAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms" +import { imageShimmer } from "@/components/shared/image-helpers" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import { Select } from "@/components/ui/select" +import { TextInput } from "@/components/ui/text-input" +import { useDebounce } from "@/hooks/use-debounce" +import { DndContext, DragEndEvent } from "@dnd-kit/core" +import { restrictToVerticalAxis } from "@dnd-kit/modifiers" +import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { useAtomValue } from "jotai/react" +import Image from "next/image" +import React from "react" +import { BiPlus, BiTrash } from "react-icons/bi" +import { toast } from "sonner" + + +type PlaylistManagerProps = { + paths: string[] + setPaths: (paths: string[]) => void +} + +export function PlaylistManager(props: PlaylistManagerProps) { + + const { + paths: controlledPaths, + setPaths: onChange, + ...rest + } = props + + const libraryCollection = useAtomValue(animeLibraryCollectionAtom) + + const { data: localFiles } = useGetLocalFiles() + + const firstRender = React.useRef(true) + + const [paths, setPaths] = React.useState(controlledPaths) + + React.useEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + setPaths(controlledPaths) + }, [controlledPaths]) + + React.useEffect(() => { + onChange(paths) + }, [paths]) + + const handleDragEnd = React.useCallback((event: DragEndEvent) => { + const { active, over } = event + + if (active.id !== over?.id) { + setPaths((items) => { + const oldIndex = items.indexOf(active.id as any) + const newIndex = items.indexOf(over?.id as any) + + return arrayMove(items, oldIndex, newIndex) + }) + } + }, []) + + const [selectedCategory, setSelectedCategory] = React.useState("CURRENT") + const [searchInput, setSearchInput] = React.useState("") + const debouncedSearchInput = useDebounce(searchInput, 500) + + const entries = React.useMemo(() => { + if (debouncedSearchInput.length !== 0) return (libraryCollection?.lists + ?.filter(n => n.type === "PLANNING" || n.type === "PAUSED" || n.type === "CURRENT") + ?.flatMap(n => n.entries) + ?.filter(Boolean) ?? []).filter(n => n?.media?.title?.english?.toLowerCase()?.includes(debouncedSearchInput.toLowerCase()) || + n?.media?.title?.romaji?.toLowerCase()?.includes(debouncedSearchInput.toLowerCase())) + + return libraryCollection?.lists?.filter(n => { + if (selectedCategory === "-") return n.type === "PLANNING" || n.type === "PAUSED" || n.type === "CURRENT" + return n.type === selectedCategory + }) + ?.flatMap(n => n.entries) + ?.filter(n => !!n?.libraryData) + ?.filter(Boolean) ?? [] + }, [libraryCollection, debouncedSearchInput, selectedCategory]) + + + return ( + <div className="space-y-4"> + + <div className="space-y-2"> + <Modal + title="Select an anime" + contentClass="max-w-4xl" + trigger={<Button + leftIcon={<BiPlus className="text-2xl" />} + intent="white" + className="rounded-full" + disabled={paths.length >= 10} + >Add an episode</Button>} + > + + <div className="grid grid-cols-[150px,1fr] gap-2"> + <Select + value={selectedCategory} + onValueChange={v => setSelectedCategory(v)} + options={[ + { label: "Current", value: "CURRENT" }, + { label: "Paused", value: "PAUSED" }, + { label: "Planning", value: "PLANNING" }, + { label: "All", value: "-" }, + ]} + disabled={searchInput.length !== 0} + /> + + <TextInput + placeholder="Search" + value={searchInput} + onChange={e => setSearchInput(e.target.value)} + /> + + </div> + + <div className="grid grid-cols-2 md:grid-cols-5 gap-2"> + {entries?.map(entry => { + return ( + <PlaylistMediaEntry key={entry.mediaId} entry={entry} paths={paths} setPaths={setPaths} /> + ) + })} + </div> + </Modal> + </div> + + <DndContext + modifiers={[restrictToVerticalAxis]} + onDragEnd={handleDragEnd} + > + <SortableContext + strategy={verticalListSortingStrategy} + items={paths} + > + <div className="space-y-2"> + <ul className="space-y-2"> + {paths.map(path => localFiles?.find(n => n.path === path))?.filter(Boolean).map((lf, index) => ( + <SortableItem + key={lf.path} + id={lf.path} + localFile={lf} + media={libraryCollection?.lists?.flatMap(n => n.entries) + ?.filter(Boolean) + ?.find(n => lf?.mediaId === n.mediaId)?.media} + setPaths={setPaths} + /> + ))} + </ul> + </div> + </SortableContext> + </DndContext> + </div> + ) +} + +type PlaylistMediaEntryProps = { + entry: Anime_LibraryCollectionEntry + paths: string[] + setPaths: React.Dispatch<React.SetStateAction<string[]>> +} + +function PlaylistMediaEntry(props: PlaylistMediaEntryProps) { + const { entry, paths, setPaths } = props + return <Modal + title={entry.media?.title?.userPreferred || entry.media?.title?.romaji || ""} + trigger={( + <div + key={entry.mediaId} + className="col-span-1 aspect-[6/7] rounded-[--radius-md] border overflow-hidden relative transition cursor-pointer bg-[var(--background)] md:opacity-60 md:hover:opacity-100" + > + <Image + src={entry.media?.coverImage?.large || entry.media?.bannerImage || ""} + placeholder={imageShimmer(700, 475)} + sizes="10rem" + fill + alt="" + className="object-center object-cover" + /> + <p className="line-clamp-2 text-sm absolute m-2 bottom-0 font-semibold z-[10]"> + {entry.media?.title?.userPreferred || entry.media?.title?.romaji} + </p> + <div + className="z-[5] absolute bottom-0 w-full h-[80%] bg-gradient-to-t from-[--background] to-transparent" + /> + </div> + )} + > + <EntryEpisodeList + selectedPaths={paths} + setSelectedPaths={setPaths} + entry={entry} + /> + </Modal> +} + +function SortableItem({ localFile, id, media, setPaths }: { + id: string, + localFile: Anime_LocalFile | undefined, + media: AL_BaseAnime | undefined, + setPaths: any +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + if (!localFile) return null + + if (!media) return ( + <li ref={setNodeRef} style={style}> + <div + className="px-2.5 py-2 bg-[var(--background)] border-[--red] rounded-[--radius-md] border flex gap-3 relative" + + > + <IconButton + className="absolute top-2 right-2 rounded-full" + icon={<BiTrash />} + intent="alert-subtle" + size="sm" + onClick={() => setPaths((prev: string[]) => prev.filter(n => n !== id))} + + /> + <div + + className="rounded-full w-4 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move" + {...attributes} {...listeners} + /> + <div> + <p className="text-lg text-white font-semibold"> + <span> + ??? + </span> + <span className="text-gray-400 font-medium max-w-lg truncate"> + </span> + </p> + <p className="text-sm text-[--muted] font-normal italic line-clamp-1">{localFile.name}</p> + </div> + </div> + </li> + ) + + return ( + <li ref={setNodeRef} style={style}> + <div + className="px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border flex gap-3 relative" + + > + <IconButton + className="absolute top-2 right-2 rounded-full" + icon={<BiTrash />} + intent="alert-subtle" + size="sm" + onClick={() => setPaths((prev: string[]) => prev.filter(n => n !== id))} + + /> + <div + className="rounded-full w-4 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move" + {...attributes} {...listeners} + /> + <div + + className="w-16 aspect-square rounded-[--radius-md] border overflow-hidden relative transition bg-[var(--background)]" + > + <Image + src={media?.coverImage?.large || media?.bannerImage || ""} + placeholder={imageShimmer(700, 475)} + sizes="10rem" + fill + alt="" + className="object-center object-cover" + + /> + </div> + <div> + <div className="text-lg text-white font-semibold flex gap-1"> + {localFile.metadata && <p> + {media?.format !== "MOVIE" ? `Episode ${localFile.metadata?.episode}` : "Movie"} + </p>} + <p className="max-w-full truncate text-gray-400 font-medium max-w-lg truncate"> + {" - "}{media?.title?.userPreferred || media?.title?.romaji} + </p> + </div> + <p className="text-sm text-[--muted] font-normal italic line-clamp-1">{localFile.name}</p> + </div> + </div> + </li> + ) +} + + +type EntryEpisodeListProps = { + entry: Anime_LibraryCollectionEntry + selectedPaths: string[] + setSelectedPaths: React.Dispatch<React.SetStateAction<string[]>> +} + +function EntryEpisodeList(props: EntryEpisodeListProps) { + + const { + entry, + selectedPaths, + setSelectedPaths, + ...rest + } = props + + const { data } = useGetPlaylistEpisodes(entry.mediaId, entry.listData?.progress || 0) + + const handleSelect = (value: string) => { + if (selectedPaths.length <= 10) { + setSelectedPaths(prev => { + if (prev.includes(value)) { + return prev.filter(n => n !== value) + } + return [...prev, value] + }) + } else { + toast.error("You can't add more than 10 episodes to a playlist") + } + } + + return ( + <div className="flex flex-col gap-2 overflow-auto p-1"> + {data?.filter(n => !!n.metadata)?.sort((a, b) => a.metadata!.episode - b.metadata!.episode)?.map(lf => { + return ( + <div + key={lf.path} + className={cn( + "px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border cursor-pointer opacity-80 max-w-full", + selectedPaths.includes(lf.path) ? "bg-gray-800 opacity-100 text-white ring-1 ring-[--zinc]" : "hover:bg-[--subtle]", + "transition", + )} + onClick={() => handleSelect(lf.path)} + > + <p className="">{entry.media?.format !== "MOVIE" ? `Episode ${lf.metadata!.episode}` : "Movie"}</p> + <p className="text-sm text-[--muted] font-normal italic max-w-lg line-clamp-1">{lf.name}</p> + </div> + ) + })} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/playlist-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/playlist-modal.tsx new file mode 100644 index 0000000..2c17ec3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/playlist-modal.tsx @@ -0,0 +1,109 @@ +import { Anime_Playlist } from "@/api/generated/types" +import { useCreatePlaylist, useDeletePlaylist, useUpdatePlaylist } from "@/api/hooks/playlist.hooks" +import { PlaylistManager } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-manager" +import { Button } from "@/components/ui/button" +import { DangerZone } from "@/components/ui/form" +import { Modal } from "@/components/ui/modal" +import { Separator } from "@/components/ui/separator" +import { TextInput } from "@/components/ui/text-input" +import React from "react" +import { toast } from "sonner" + +type PlaylistModalProps = { + playlist?: Anime_Playlist + trigger: React.ReactElement +} + +export function PlaylistModal(props: PlaylistModalProps) { + + const { + playlist, + trigger, + } = props + + const [isOpen, setIsOpen] = React.useState(false) + const [name, setName] = React.useState(playlist?.name ?? "") + const [paths, setPaths] = React.useState<string[]>(playlist?.localFiles?.map(l => l.path) ?? []) + + const isUpdate = !!playlist + + const { mutate: createPlaylist, isPending: isCreating } = useCreatePlaylist() + + const { mutate: deletePlaylist, isPending: isDeleting } = useDeletePlaylist() + + const { mutate: updatePlaylist, isPending: isUpdating } = useUpdatePlaylist() + + function reset() { + setName("") + setPaths([]) + } + + React.useEffect(() => { + if (isUpdate && !!playlist && !!playlist.localFiles) { + setName(playlist.name) + setPaths(playlist.localFiles.map(l => l.path)) + } + }, [playlist, isOpen]) + + function handleSubmit() { + if (name.length === 0) { + toast.error("Please enter a name for the playlist") + return + } + if (isUpdate && !!playlist) { + updatePlaylist({ dbId: playlist.dbId, name, paths }) + } else { + setIsOpen(false) + createPlaylist({ name, paths }, { + onSuccess: () => { + reset() + }, + }) + } + } + + return ( + <Modal + title={isUpdate ? "Edit playlist" : "Create a playlist"} + trigger={trigger} + open={isOpen} + onOpenChange={v => setIsOpen(v)} + contentClass="max-w-4xl" + > + <div className="space-y-4"> + + <div className="space-y-4"> + <TextInput + label="Name" + value={name} + onChange={e => setName(e.target.value)} + /> + + <Separator /> + + <PlaylistManager + paths={paths} + setPaths={setPaths} + /> + <div className=""> + <Button disabled={paths.length === 0} onClick={handleSubmit} loading={isCreating || isDeleting || isUpdating}> + {isUpdate ? "Update" : "Create"} + </Button> + </div> + </div> + + {isUpdate && <DangerZone + actionText="Delete playlist" onDelete={() => { + if (isUpdate && !!playlist) { + deletePlaylist({ dbId: playlist.dbId }, { + onSuccess: () => { + setIsOpen(false) + }, + }) + } + }} + />} + </div> + </Modal> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/playlists-list.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/playlists-list.tsx new file mode 100644 index 0000000..8ea3b48 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/playlists-list.tsx @@ -0,0 +1,123 @@ +import { useGetPlaylists } from "@/api/hooks/playlist.hooks" +import { PlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-modal" +import { StartPlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/start-playlist-modal" +import { __playlists_modalOpenAtom } from "@/app/(main)/(library)/_containers/playlists/playlists-modal" +import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms" +import { MediaCardBodyBottomGradient } from "@/app/(main)/_features/custom-ui/item-bottom-gradients" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { imageShimmer } from "@/components/shared/image-helpers" +import { Button } from "@/components/ui/button" +import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { useAtomValue, useSetAtom } from "jotai/react" +import Image from "next/image" +import React from "react" +import { BiEditAlt } from "react-icons/bi" +import { FaCirclePlay } from "react-icons/fa6" +import { MdOutlineVideoLibrary } from "react-icons/md" + +type PlaylistsListProps = {} + +export function PlaylistsList(props: PlaylistsListProps) { + + const {} = props + + const { data: playlists, isLoading } = useGetPlaylists() + const userMedia = useAtomValue(__anilist_userAnimeMediaAtom) + const serverStatus = useServerStatus() + + const setOpen = useSetAtom(__playlists_modalOpenAtom) + + const handlePlaylistLoaded = React.useCallback(() => { + setOpen(false) + }, []) + + if (isLoading) return <LoadingSpinner /> + + if (!playlists?.length) { + return ( + <div className="text-center text-[--muted] space-y-1"> + <MdOutlineVideoLibrary className="mx-auto text-5xl text-[--muted]" /> + <div> + No playlists + </div> + </div> + ) + } + + + return ( + // <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"> + <Carousel + className="w-full max-w-full" + gap="none" + opts={{ + align: "start", + }} + > + <CarouselDotButtons /> + <CarouselContent> + {playlists.map(p => { + + const mainMedia = userMedia?.find(m => m.id === p.localFiles?.[0]?.mediaId) + + return ( + <CarouselItem + key={p.dbId} + className={cn( + "md:basis-1/3 lg:basis-1/4 2xl:basis-1/6 min-[2000px]:basis-1/6", + "aspect-[7/6] p-2", + )} + // onClick={() => handleSelect(lf.path)} + > + <div className="group/playlist-item flex gap-3 h-full justify-between items-center bg-gray-950 rounded-[--radius-md] transition relative overflow-hidden"> + {(mainMedia?.coverImage?.large || mainMedia?.bannerImage) && <Image + src={mainMedia?.coverImage?.extraLarge || mainMedia?.bannerImage || ""} + placeholder={imageShimmer(700, 475)} + sizes="10rem" + fill + alt="" + className="object-center object-cover z-[1]" + />} + + <div className="absolute inset-0 z-[2] bg-gray-900 opacity-50 hover:opacity-70 transition-opacity flex items-center justify-center" /> + <div className="absolute inset-0 z-[6] flex items-center justify-center"> + <StartPlaylistModal + canStart={serverStatus?.settings?.library?.autoUpdateProgress} + playlist={p} + onPlaylistLoaded={handlePlaylistLoaded} + trigger={ + <FaCirclePlay className="block text-5xl cursor-pointer opacity-50 hover:opacity-100 transition-opacity" />} + /> + </div> + <div className="absolute top-2 right-2 z-[6] flex items-center justify-center"> + <PlaylistModal + trigger={<Button + className="w-full flex-none rounded-full" + leftIcon={<BiEditAlt />} + intent="white-subtle" + size="sm" + + >Edit</Button>} playlist={p} + /> + </div> + <div className="absolute w-full bottom-0 h-fit z-[6]"> + <div className="space-y-0 pb-3 items-center"> + <p className="text-md font-bold text-white max-w-lg truncate text-center">{p.name}</p> + {p.localFiles && + <p className="text-sm text-[--muted] font-normal line-clamp-1 text-center">{p.localFiles.length} episode{p.localFiles.length > 1 + ? `s` + : ""}</p>} + </div> + </div> + + <MediaCardBodyBottomGradient /> + </div> + </CarouselItem> + ) + })} + </CarouselContent> + </Carousel> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/start-playlist-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/start-playlist-modal.tsx new file mode 100644 index 0000000..e7c56c8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/_components/start-playlist-modal.tsx @@ -0,0 +1,60 @@ +import { Anime_Playlist } from "@/api/generated/types" +import { usePlaybackStartPlaylist } from "@/api/hooks/playback_manager.hooks" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import React from "react" +import { FaPlay } from "react-icons/fa" + +type StartPlaylistModalProps = { + trigger?: React.ReactElement + playlist: Anime_Playlist + canStart?: boolean + onPlaylistLoaded: () => void +} + +export function StartPlaylistModal(props: StartPlaylistModalProps) { + + const { + trigger, + playlist, + canStart, + onPlaylistLoaded, + ...rest + } = props + + const { mutate: startPlaylist, isPending } = usePlaybackStartPlaylist({ + onSuccess: onPlaylistLoaded, + }) + + if (!playlist?.localFiles?.length) return null + + return ( + <Modal + title="Start playlist" + titleClass="text-center" + trigger={trigger} + > + <p className="text-center"> + You are about to start the playlist <strong>"{playlist.name}"</strong>, + which contains {playlist.localFiles.length} episode{playlist.localFiles.length > 1 ? "s" : ""}. + </p> + <p className="text-[--muted] text-center"> + Reminder: The playlist will be deleted once you start it, whether you finish it or not. + </p> + {!canStart && ( + <p className="text-orange-300 text-center"> + Please enable "Automatically update progress" to start + </p> + )} + <Button + className="w-full flex-none" + leftIcon={<FaPlay />} + intent="primary" + size="lg" + loading={isPending} + disabled={!canStart} + onClick={() => startPlaylist({ dbId: playlist.dbId })} + >Start</Button> + </Modal> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/playlists-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/playlists-modal.tsx new file mode 100644 index 0000000..52d8686 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/playlists/playlists-modal.tsx @@ -0,0 +1,75 @@ +import { PlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-modal" +import { PlaylistsList } from "@/app/(main)/(library)/_containers/playlists/_components/playlists-list" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { Alert } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { Drawer } from "@/components/ui/drawer" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" + +type PlaylistsModalProps = {} + +export const __playlists_modalOpenAtom = atom(false) + +export function PlaylistsModal(props: PlaylistsModalProps) { + + const {} = props + + const serverStatus = useServerStatus() + const [isOpen, setIsOpen] = useAtom(__playlists_modalOpenAtom) + + return ( + <> + <Drawer + open={isOpen} + onOpenChange={v => setIsOpen(v)} + size="lg" + side="bottom" + contentClass="" + > + {/*<div*/} + {/* className="!mt-0 bg-[url(/pattern-2.svg)] z-[-1] w-full h-[5rem] absolute opacity-30 top-0 left-0 bg-no-repeat bg-right bg-cover"*/} + {/*>*/} + {/* <div*/} + {/* className="w-full absolute top-0 h-full bg-gradient-to-t from-[--background] to-transparent z-[-2]"*/} + {/* />*/} + {/*</div>*/} + + <div className="space-y-6"> + <div className="flex flex-col md:flex-row justify-between items-center gap-4"> + <div> + <h4>Playlists</h4> + <p className="text-[--muted] text-sm"> + Playlists only work with system media players and local files. + </p> + </div> + <div className="flex gap-2 items-center md:pr-8"> + <PlaylistModal + trigger={ + <Button intent="white" className="rounded-full"> + Add a playlist + </Button> + } + /> + </div> + </div> + + {!serverStatus?.settings?.library?.autoUpdateProgress && <Alert + className="max-w-2xl mx-auto" + intent="warning" + description={<> + <p> + You need to enable "Automatically update progress" to use playlists. + </p> + </>} + />} + + <div className=""> + <PlaylistsList /> + </div> + </div> + </Drawer> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/scan-progress-bar.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/scan-progress-bar.tsx new file mode 100644 index 0000000..842d032 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/scan-progress-bar.tsx @@ -0,0 +1,70 @@ +"use client" +import { __scanner_isScanningAtom } from "@/app/(main)/(library)/_containers/scanner-modal" + +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { Card, CardDescription, CardHeader } from "@/components/ui/card" +import { Spinner } from "@/components/ui/loading-spinner" +import { ProgressBar } from "@/components/ui/progress-bar" +import { WSEvents } from "@/lib/server/ws-events" +import { useAtom } from "jotai/react" +import React, { useState } from "react" + +export function ScanProgressBar() { + + const [isScanning] = useAtom(__scanner_isScanningAtom) + + const [progress, setProgress] = useState(0) + const [status, setStatus] = useState("Scanning...") + + React.useEffect(() => { + if (!isScanning) { + setProgress(0) + setStatus("Scanning...") + } + }, [isScanning]) + + useWebsocketMessageListener<number>({ + type: WSEvents.SCAN_PROGRESS, + onMessage: data => { + console.log("Scan progress", data) + setProgress(data) + }, + }) + + useWebsocketMessageListener<string>({ + type: WSEvents.SCAN_STATUS, + onMessage: data => { + console.log("Scan status", data) + setStatus(data) + }, + }) + + if (!isScanning) return null + + return ( + <> + <div className="w-full bg-gray-950 fixed top-0 left-0 z-[100]" data-scan-progress-bar-container> + <ProgressBar size="xs" value={progress} /> + </div> + {/*<div className="fixed left-0 top-8 w-full flex justify-center z-[100]">*/} + {/* <div className="bg-gray-900 rounded-full border h-14 px-6 flex gap-2 items-center">*/} + {/* <Spinner className="w-4 h-4" />*/} + {/* <p>{progress}% - {status}</p>*/} + {/* </div>*/} + {/*</div>*/} + <div className="z-50 fixed bottom-4 right-4" data-scan-progress-bar-card-container> + <PageWrapper> + <Card className="w-fit max-w-[400px] relative" data-scan-progress-bar-card> + <CardHeader> + <CardDescription className="flex items-center gap-2 text-base text-[--foregorund]"> + <Spinner className="size-6" /> {progress}% - {status} + </CardDescription> + </CardHeader> + </Card> + </PageWrapper> + </div> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/scanner-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/scanner-modal.tsx new file mode 100644 index 0000000..d39f29f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/scanner-modal.tsx @@ -0,0 +1,181 @@ +import { useScanLocalFiles } from "@/api/hooks/scan.hooks" +import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms" + +import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { GlowingEffect } from "@/components/shared/glowing-effect" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { Separator } from "@/components/ui/separator" +import { Switch } from "@/components/ui/switch" +import { useBoolean } from "@/hooks/use-disclosure" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" +import { FiSearch } from "react-icons/fi" + +export const __scanner_modalIsOpen = atom(false) +export const __scanner_isScanningAtom = atom(false) + + +export function ScannerModal() { + const serverStatus = useServerStatus() + const [isOpen, setOpen] = useAtom(__scanner_modalIsOpen) + const [, setScannerIsScanning] = useAtom(__scanner_isScanningAtom) + const [userMedia] = useAtom(__anilist_userAnimeMediaAtom) + const anilistDataOnly = useBoolean(true) + const skipLockedFiles = useBoolean(true) + const skipIgnoredFiles = useBoolean(true) + + const { mutate: scanLibrary, isPending: isScanning } = useScanLocalFiles(() => { + setOpen(false) + }) + + React.useEffect(() => { + if (!userMedia?.length) anilistDataOnly.off() + else anilistDataOnly.on() + }, [userMedia]) + + React.useEffect(() => { + setScannerIsScanning(isScanning) + }, [isScanning]) + + function handleScan() { + scanLibrary({ + enhanced: !anilistDataOnly.active, + skipLockedFiles: skipLockedFiles.active, + skipIgnoredFiles: skipIgnoredFiles.active, + }) + setOpen(false) + } + + const { inject, remove } = useSeaCommandInject() + React.useEffect(() => { + inject("scanner-controls", { + priority: 1, + items: [{ + id: "refresh", + value: "refresh", + heading: "Library", + render: () => ( + <p>Refresh library</p> + ), + onSelect: ({ ctx }) => { + ctx.close() + setTimeout(() => { + handleScan() + }, 500) + }, + showBasedOnInput: "startsWith", + }], + filter: ({ item, input }) => { + if (!input) return true + return item.value.toLowerCase().includes(input.toLowerCase()) + }, + shouldShow: ({ ctx }) => ctx.router.pathname === "/", + showBasedOnInput: "startsWith", + }) + + return () => remove("scanner-controls") + }, []) + + return ( + <> + <Modal + data-scanner-modal + open={isOpen} + onOpenChange={o => { + // if (!isScanning) { + // setOpen(o) + // } + setOpen(o) + }} + // title="Library scanner" + titleClass="text-center" + contentClass="space-y-4 max-w-2xl bg-gray-950 bg-opacity-70 backdrop-blur-sm firefox:bg-opacity-100 firefox:backdrop-blur-none rounded-xl" + overlayClass="bg-gray-950/70 backdrop-blur-sm" + > + <GlowingEffect + spread={50} + glow={true} + disabled={false} + proximity={100} + inactiveZone={0.01} + // movementDuration={4} + className="!mt-0 opacity-30" + /> + + {/* <div + data-scanner-modal-top-pattern + className="!mt-0 bg-[url(/pattern-2.svg)] z-[-1] w-full h-[4rem] absolute opacity-40 top-0 left-0 bg-no-repeat bg-right bg-cover" + > + <div + className="w-full absolute top-0 h-full bg-gradient-to-t from-[--background] to-transparent z-[-2]" + /> + </div> */} + + {serverStatus?.user?.isSimulated && <div className="border border-dashed rounded-md py-2 px-4 !mt-5"> + Using this feature without an AniList account is not recommended if you have a large library, as it may lead to rate limits and + slower scanning. Please consider using an account for a better experience. + </div>} + + <div className="space-y-4" data-scanner-modal-content> + + <AppLayoutStack className="space-y-2"> + <h5 className="text-[--muted]">Local files</h5> + <Switch + side="right" + label="Skip locked files" + value={skipLockedFiles.active} + onValueChange={v => skipLockedFiles.set(v as boolean)} + // size="lg" + /> + <Switch + side="right" + label="Skip ignored files" + value={skipIgnoredFiles.active} + onValueChange={v => skipIgnoredFiles.set(v as boolean)} + // size="lg" + /> + + <Separator /> + + <AppLayoutStack className="space-y-2"> + <h5 className="text-[--muted]">Matching data</h5> + <Switch + side="right" + label="Use my AniList lists only" + moreHelp="Disabling this will cause Seanime to send more API requests which may lead to rate limits and slower scanning" + // label="Enhanced scanning" + value={anilistDataOnly.active} + onValueChange={v => anilistDataOnly.set(v as boolean)} + // className="data-[state=checked]:bg-amber-700 dark:data-[state=checked]:bg-amber-700" + // size="lg" + help={!anilistDataOnly.active + ? <span><span className="text-[--orange]">Slower for large libraries</span>. For faster scanning, add the anime + entries present in your library to your + lists and re-enable this before + scanning.</span> + : ""} + disabled={!userMedia?.length} + /> + </AppLayoutStack> + + </AppLayoutStack> + </div> + <Button + onClick={handleScan} + intent="primary" + leftIcon={<FiSearch />} + loading={isScanning} + className="w-full" + disabled={!serverStatus?.settings?.library?.libraryPath} + > + Scan + </Button> + </Modal> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/unknown-media-manager.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/unknown-media-manager.tsx new file mode 100644 index 0000000..05d0936 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/unknown-media-manager.tsx @@ -0,0 +1,148 @@ +import { Anime_UnknownGroup } from "@/api/generated/types" +import { useAddUnknownMedia } from "@/api/hooks/anime_collection.hooks" +import { useAnimeEntryBulkAction } from "@/api/hooks/anime_entries.hooks" +import { SeaLink } from "@/components/shared/sea-link" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { Drawer } from "@/components/ui/drawer" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React, { useCallback } from "react" +import { BiLinkExternal, BiPlus } from "react-icons/bi" +import { TbDatabasePlus } from "react-icons/tb" +import { toast } from "sonner" + +export const __unknownMedia_drawerIsOpen = atom(false) + +type UnknownMediaManagerProps = { + unknownGroups: Anime_UnknownGroup[] +} + +export function UnknownMediaManager(props: UnknownMediaManagerProps) { + + const { unknownGroups } = props + + const [isOpen, setIsOpen] = useAtom(__unknownMedia_drawerIsOpen) + + const { mutate: addUnknownMedia, isPending: isAdding } = useAddUnknownMedia() + const { mutate: performBulkAction, isPending: isUnmatching } = useAnimeEntryBulkAction() + + /** + * Add all unknown media to AniList + */ + const handleAddUnknownMedia = useCallback(() => { + addUnknownMedia({ mediaIds: unknownGroups.map(n => n.mediaId) }) + }, [unknownGroups]) + + /** + * Close the drawer if there are no unknown groups + */ + React.useEffect(() => { + if (unknownGroups.length === 0) { + setIsOpen(false) + } + }, [unknownGroups]) + + /** + * Unmatch all files for a media + */ + const handleUnmatchMedia = useCallback((mediaId: number) => { + performBulkAction({ + mediaId, + action: "unmatch", + }, { + onSuccess: () => { + toast.success("Media unmatched") + }, + }) + }, []) + + if (unknownGroups.length === 0) return null + + return ( + <Drawer + data-unknown-media-manager-drawer + open={isOpen} + onOpenChange={o => { + if (!isAdding) { + setIsOpen(o) + } + }} + size="xl" + title="Resolve hidden media" + + > + <AppLayoutStack className="mt-4"> + + <p className=""> + Seanime matched {unknownGroups.length} group{unknownGroups.length === 1 ? "" : "s"} to media that {unknownGroups.length === 1 + ? "is" + : "are"} absent from your + AniList collection.<br /> + Add the media to be able to see the entry in your library or unmatch them if incorrect. + </p> + + <Button + leftIcon={<TbDatabasePlus />} + onClick={handleAddUnknownMedia} + loading={isAdding} + disabled={isUnmatching} + > + Add all to AniList + </Button> + + <div className="divide divide-y divide-[--border] space-y-4"> + + {unknownGroups.map(group => { + return ( + <div key={group.mediaId} className="pt-4 space-y-2"> + <div className="flex items-center w-full justify-between"> + <h4 className="font-semibold flex gap-2 items-center"> + <span>Anilist ID:{" "}</span> + <SeaLink + href={`https://anilist.co/anime/${group.mediaId}`} + target="_blank" + className="underline text-brand-200 flex gap-1.5 items-center" + > + {group.mediaId} <BiLinkExternal /> + </SeaLink> + </h4> + <div className="flex gap-2 items-center"> + <Button + size="sm" + intent="primary-subtle" + disabled={isAdding} + onClick={() => addUnknownMedia({ mediaIds: [group.mediaId] })} + leftIcon={<BiPlus />} + > + Add to AniList + </Button> + <Button + size="sm" + intent="warning-subtle" + disabled={isUnmatching} + onClick={() => handleUnmatchMedia(group.mediaId)} + > + Unmatch + </Button> + </div> + </div> + <div className="bg-gray-900 border p-2 px-2 rounded-[--radius-md] space-y-1 max-h-40 max-w-full overflow-x-auto overflow-y-auto text-sm"> + {group.localFiles?.sort((a, b) => ((Number(a.parsedInfo?.episode ?? 0)) - (Number(b.parsedInfo?.episode ?? 0)))) + .map(lf => { + return <p key={lf.path} className="text-[--muted] line-clamp-1 tracking-wide"> + {lf.parsedInfo?.original || lf.path} + </p> + })} + </div> + </div> + ) + + })} + </div> + + </AppLayoutStack> + </Drawer> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/unmatched-file-manager.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/unmatched-file-manager.tsx new file mode 100644 index 0000000..c4d96fd --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_containers/unmatched-file-manager.tsx @@ -0,0 +1,374 @@ +import { Anime_UnmatchedGroup } from "@/api/generated/types" +import { useAnimeEntryManualMatch, useFetchAnimeEntrySuggestions } from "@/api/hooks/anime_entries.hooks" +import { useOpenInExplorer } from "@/api/hooks/explorer.hooks" +import { useUpdateLocalFiles } from "@/api/hooks/localfiles.hooks" +import { SeaLink } from "@/components/shared/sea-link" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { cn } from "@/components/ui/core/styling" +import { Drawer } from "@/components/ui/drawer" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { NumberInput } from "@/components/ui/number-input" +import { RadioGroup } from "@/components/ui/radio-group" +import { upath } from "@/lib/helpers/upath" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import Image from "next/image" +import React from "react" +import { FaArrowLeft, FaArrowRight } from "react-icons/fa" +import { FcFolder } from "react-icons/fc" +import { FiSearch } from "react-icons/fi" +import { TbFileSad } from "react-icons/tb" +import { toast } from "sonner" + +export const __unmatchedFileManagerIsOpen = atom(false) + +type UnmatchedFileManagerProps = { + unmatchedGroups: Anime_UnmatchedGroup[] +} + +export function UnmatchedFileManager(props: UnmatchedFileManagerProps) { + + const { unmatchedGroups } = props + + const [isOpen, setIsOpen] = useAtom(__unmatchedFileManagerIsOpen) + const [page, setPage] = React.useState(0) + const maxPage = unmatchedGroups.length - 1 + const [currentGroup, setCurrentGroup] = React.useState(unmatchedGroups?.[0]) + + const [selectedPaths, setSelectedPaths] = React.useState<string[]>([]) + + const [anilistId, setAnilistId] = React.useState(0) + + const { mutate: openInExplorer } = useOpenInExplorer() + + const { + data: suggestions, + mutate: fetchSuggestions, + isPending: suggestionsLoading, + reset: resetSuggestions, + } = useFetchAnimeEntrySuggestions() + + const { mutate: updateLocalFiles, isPending: isUpdatingFile } = useUpdateLocalFiles() + + const { mutate: manualMatch, isPending: isMatching } = useAnimeEntryManualMatch() + + const isUpdating = isUpdatingFile || isMatching + + const [_r, setR] = React.useState(0) + + const handleSelectAnime = React.useCallback((value: string | null) => { + if (value && !isNaN(Number(value))) { + setAnilistId(Number(value)) + setR(r => r + 1) + } + }, []) + + // Reset the selected paths when the current group changes + React.useLayoutEffect(() => { + setSelectedPaths(currentGroup?.localFiles?.map(lf => lf.path) ?? []) + }, [currentGroup]) + + // Reset the current group and page when the drawer is opened + React.useEffect(() => { + setPage(0) + setCurrentGroup(unmatchedGroups[0]) + }, [isOpen, unmatchedGroups]) + + // Set the current group when the page changes + React.useEffect(() => { + setCurrentGroup(unmatchedGroups[page]) + setAnilistId(0) + resetSuggestions() + }, [page, unmatchedGroups]) + + const AnilistIdInput = React.useCallback(() => { + return <NumberInput + value={anilistId} + onValueChange={v => setAnilistId(v)} + formatOptions={{ + useGrouping: false, + }} + /> + }, [currentGroup?.dir, _r]) + + function onActionSuccess() { + if (page === 0 && unmatchedGroups.length === 1) { + setIsOpen(false) + } + setAnilistId(0) + resetSuggestions() + setPage(0) + setCurrentGroup(unmatchedGroups[0]) + } + + /** + * Manually match the current group with the specified Anilist ID. + * If the current group is the last group and there are no more unmatched groups, close the drawer. + */ + function handleMatchSelected() { + if (!!currentGroup && anilistId > 0 && selectedPaths.length > 0) { + manualMatch({ + paths: selectedPaths, + mediaId: anilistId, + }, { + onSuccess: () => { + onActionSuccess() + }, + }) + } + } + + const handleFetchSuggestions = React.useCallback(() => { + fetchSuggestions({ + dir: currentGroup.dir, + }) + }, [currentGroup?.dir, fetchSuggestions]) + + function handleIgnoreSelected() { + if (selectedPaths.length > 0) { + updateLocalFiles({ + paths: selectedPaths, + action: "ignore", + }, { + onSuccess: () => { + onActionSuccess() + toast.success("Files ignored") + }, + }) + } + } + + React.useEffect(() => { + if (!currentGroup) { + setIsOpen(false) + } + }, [currentGroup]) + + if (!currentGroup) return null + + return ( + <Drawer + data-unmatched-file-manager-drawer + open={isOpen} + onOpenChange={() => setIsOpen(false)} + // contentClass="max-w-5xl" + size="xl" + title="Unmatched files" + > + <AppLayoutStack className="mt-4"> + + <div className={cn("flex w-full justify-between", { "hidden": unmatchedGroups.length <= 1 })}> + <Button + intent="gray-subtle" + leftIcon={<FaArrowLeft />} + disabled={page === 0} + onClick={() => { + setPage(p => p - 1) + }} + className={cn("transition-opacity", { "opacity-0": page === 0 })} + >Previous</Button> + + <p> + {page + 1} / {maxPage + 1} + </p> + + <Button + intent="gray-subtle" + rightIcon={<FaArrowRight />} + disabled={page >= maxPage} + onClick={() => { + setPage(p => p + 1) + }} + className={cn("transition-opacity", { "opacity-0": page >= maxPage })} + >Next</Button> + </div> + + <div + className="bg-gray-900 border p-2 px-4 rounded-[--radius-md] line-clamp-1 flex gap-2 items-center cursor-pointer transition hover:bg-opacity-80" + onClick={() => openInExplorer({ + path: currentGroup.dir, + })} + > + <FcFolder className="text-2xl" /> + {currentGroup.dir} + </div> + + <div className="flex items-center flex-wrap gap-2"> + <div className="flex gap-2 items-center w-full"> + <p className="flex-none text-lg mr-2 font-semibold">Anilist ID</p> + <AnilistIdInput /> + <Button + intent="white" + onClick={handleMatchSelected} + disabled={isUpdating} + >Match selection</Button> + </div> + + {/*<div className="flex flex-1">*/} + {/*</div>*/} + </div> + + <div className="bg-gray-950 border p-2 px-2 divide-y divide-[--border] rounded-[--radius-md] max-h-[50vh] max-w-full overflow-x-auto overflow-y-auto text-sm"> + + <div className="p-2"> + <Checkbox + label={`Select all files`} + value={(selectedPaths.length === currentGroup?.localFiles?.length) ? true : (selectedPaths.length === 0 + ? false + : "indeterminate")} + onValueChange={checked => { + if (typeof checked === "boolean") { + setSelectedPaths(draft => { + if (draft.length === currentGroup?.localFiles?.length) { + return [] + } else { + return currentGroup?.localFiles?.map(lf => lf.path) ?? [] + } + }) + } + }} + fieldClass="w-[fit-content]" + /> + </div> + + {currentGroup.localFiles?.sort((a, b) => ((Number(a.parsedInfo?.episode ?? 0)) - (Number(b.parsedInfo?.episode ?? 0)))) + .map((lf, index) => ( + <div + key={`${lf.path}-${index}`} + className="p-2 " + > + <div className="flex items-center"> + <Checkbox + label={`${upath.basename(lf.path)}`} + value={selectedPaths.includes(lf.path)} + onValueChange={checked => { + if (typeof checked === "boolean") { + setSelectedPaths(draft => { + if (checked) { + return [...draft, lf.path] + } else { + return draft.filter(p => p !== lf.path) + } + }) + } + }} + labelClass="text-sm tracking-wide data-[checked=false]:opacity-50" + fieldClass="w-[fit-content]" + /> + </div> + </div> + ))} + </div> + + {/*<Separator />*/} + + {/*<Separator />*/} + + + <div className="flex flex-wrap items-center gap-1"> + <Button + leftIcon={<FiSearch />} + intent="primary-subtle" + onClick={handleFetchSuggestions} + > + Fetch suggestions + </Button> + + <SeaLink + target="_blank" + href={`https://anilist.co/search/anime?search=${encodeURIComponent(currentGroup?.localFiles?.[0]?.parsedInfo?.title || currentGroup?.localFiles?.[0]?.parsedFolderInfo?.[0]?.title || "")}`} + > + <Button + intent="white-link" + > + Search on AniList + </Button> + </SeaLink> + + <div className="flex flex-1"></div> + + <Button + leftIcon={<TbFileSad className="text-lg" />} + intent="warning-subtle" + size="sm" + rounded + disabled={isUpdating} + onClick={handleIgnoreSelected} + > + Ignore selection + </Button> + </div> + + {suggestionsLoading && <LoadingSpinner />} + + {(!suggestionsLoading && !!suggestions?.length) && <RadioGroup + defaultValue="1" + fieldClass="w-full" + fieldLabelClass="text-md" + label="Select Anime" + value={String(anilistId)} + onValueChange={handleSelectAnime} + options={suggestions?.map((media) => ( + { + label: <div> + <p className="text-base md:text-md font-medium !-mt-1.5 line-clamp-1">{media.title?.userPreferred || media.title?.english || media.title?.romaji || "N/A"}</p> + <div className="mt-2 flex w-full gap-4"> + {media.coverImage?.medium && <div + className="h-28 w-28 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden" + > + <Image + src={media.coverImage.medium} + alt={""} + fill + quality={100} + priority + sizes="10rem" + className="object-cover object-center" + /> + </div>} + <div className="text-[--muted]"> + <p>Type: <span + className="text-gray-200 font-semibold" + >{media.format}</span> + </p> + <p>Aired: {media.startDate?.year ? new Intl.DateTimeFormat("en-US", { + year: "numeric", + }).format(new Date(media.startDate?.year || 0, media.startDate?.month || 0)) : "-"}</p> + <p>Status: {media.status}</p> + <SeaLink href={`https://anilist.co/anime/${media.id}`} target="_blank"> + <Button + intent="primary-link" + size="sm" + className="px-0" + >Open on AniList</Button> + </SeaLink> + </div> + </div> + </div>, + value: String(media.id) || "", + } + ))} + stackClass="grid grid-cols-1 md:grid-cols-2 gap-2 space-y-0" + itemContainerClass={cn( + "items-start cursor-pointer transition border-transparent rounded-[--radius] p-4 w-full", + "bg-gray-50 hover:bg-[--subtle] dark:bg-gray-900", + "data-[state=checked]:bg-white dark:data-[state=checked]:bg-gray-950", + "focus:ring-2 ring-brand-100 dark:ring-brand-900 ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition", + "border border-transparent data-[state=checked]:border-[--brand] data-[state=checked]:ring-offset-0", + )} + itemClass={cn( + "border-transparent absolute top-2 right-2 bg-transparent dark:bg-transparent dark:data-[state=unchecked]:bg-transparent", + "data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent dark:data-[state=unchecked]:hover:bg-transparent", + "focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:ring-offset-transparent", + )} + itemIndicatorClass="hidden" + itemLabelClass="font-medium flex flex-col items-center data-[state=checked]:text-[--brand] cursor-pointer" + />} + + </AppLayoutStack> + </Drawer> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_lib/handle-detailed-library-collection.ts b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_lib/handle-detailed-library-collection.ts new file mode 100644 index 0000000..d2e6112 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_lib/handle-detailed-library-collection.ts @@ -0,0 +1,121 @@ +import { Anime_LibraryCollectionList } from "@/api/generated/types" +import { useGetLibraryCollection } from "@/api/hooks/anime_collection.hooks" +import { useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useDebounce } from "@/hooks/use-debounce" +import { CollectionParams, DEFAULT_ANIME_COLLECTION_PARAMS, filterAnimeCollectionEntries, filterEntriesByTitle } from "@/lib/helpers/filtering" +import { useThemeSettings } from "@/lib/theme/hooks" +import { atomWithImmer } from "jotai-immer" +import { useAtom, useAtomValue } from "jotai/index" +import React from "react" + +export const DETAILED_LIBRARY_DEFAULT_PARAMS: CollectionParams<"anime"> = { + ...DEFAULT_ANIME_COLLECTION_PARAMS, + sorting: "TITLE", +} + +// export const __library_paramsAtom = atomWithStorage("sea-library-sorting-params", DETAILED_LIBRARY_DEFAULT_PARAMS, undefined, { getOnInit: true }) +export const __library_paramsAtom = atomWithImmer(DETAILED_LIBRARY_DEFAULT_PARAMS) + +export const __library_selectedListAtom = atomWithImmer<string>("-") + +export const __library_debouncedSearchInputAtom = atomWithImmer<string>("") + +export function useHandleDetailedLibraryCollection() { + const serverStatus = useServerStatus() + + const { animeLibraryCollectionDefaultSorting } = useThemeSettings() + + const { data: watchHistory } = useGetContinuityWatchHistory() + + /** + * Fetch the library collection data + */ + const { data, isLoading } = useGetLibraryCollection() + + const [paramsToDebounce, setParamsToDebounce] = useAtom(__library_paramsAtom) + const debouncedParams = useDebounce(paramsToDebounce, 500) + + const debouncedSearchInput = useAtomValue(__library_debouncedSearchInputAtom) + + React.useLayoutEffect(() => { + let _params = { ...paramsToDebounce } + _params.sorting = animeLibraryCollectionDefaultSorting as any + setParamsToDebounce(_params) + }, [data, animeLibraryCollectionDefaultSorting]) + + + /** + * Sort and filter the collection data + */ + const _filteredCollection: Anime_LibraryCollectionList[] = React.useMemo(() => { + if (!data || !data.lists) return [] + + let _lists = data.lists.map(obj => { + if (!obj) return obj + const arr = filterAnimeCollectionEntries(obj.entries, + paramsToDebounce, + serverStatus?.settings?.anilist?.enableAdultContent, + data.continueWatchingList, + watchHistory) + return { + type: obj.type, + status: obj.status, + entries: arr, + } + }) + return [ + _lists.find(n => n.type === "CURRENT"), + _lists.find(n => n.type === "PAUSED"), + _lists.find(n => n.type === "PLANNING"), + _lists.find(n => n.type === "COMPLETED"), + _lists.find(n => n.type === "DROPPED"), + ].filter(Boolean) + }, [data, debouncedParams, serverStatus?.settings?.anilist?.enableAdultContent, watchHistory]) + + const filteredCollection: Anime_LibraryCollectionList[] = React.useMemo(() => { + return _filteredCollection.map(obj => { + if (!obj) return obj + const arr = filterEntriesByTitle(obj.entries, debouncedSearchInput) + return { + type: obj.type, + status: obj.status, + entries: arr, + } + }).filter(Boolean) + }, [_filteredCollection, debouncedSearchInput]) + + const continueWatchingList = React.useMemo(() => { + if (!data?.continueWatchingList) return [] + + if (!serverStatus?.settings?.anilist?.enableAdultContent || serverStatus?.settings?.anilist?.blurAdultContent) { + return data.continueWatchingList.filter(entry => entry.baseAnime?.isAdult === false) + } + + return data.continueWatchingList + }, [ + data?.continueWatchingList, + serverStatus?.settings?.anilist?.enableAdultContent, + serverStatus?.settings?.anilist?.blurAdultContent, + ]) + + const libraryGenres = React.useMemo(() => { + const allGenres = filteredCollection?.flatMap(l => { + return l.entries?.flatMap(e => e.media?.genres) ?? [] + }) + return [...new Set(allGenres)].filter(Boolean)?.sort((a, b) => a.localeCompare(b)) + }, [filteredCollection]) + + return { + isLoading: isLoading, + stats: data?.stats, + libraryCollectionList: filteredCollection, + libraryGenres: libraryGenres, + continueWatchingList: continueWatchingList, + unmatchedLocalFiles: data?.unmatchedLocalFiles ?? [], + ignoredLocalFiles: data?.ignoredLocalFiles ?? [], + unmatchedGroups: data?.unmatchedGroups ?? [], + unknownGroups: data?.unknownGroups ?? [], + } + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_lib/handle-library-collection.ts b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_lib/handle-library-collection.ts new file mode 100644 index 0000000..44ff3b6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_lib/handle-library-collection.ts @@ -0,0 +1,232 @@ +import { useGetLibraryCollection } from "@/api/hooks/anime_collection.hooks" +import { useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks" +import { animeLibraryCollectionAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { + CollectionParams, + DEFAULT_ANIME_COLLECTION_PARAMS, + filterAnimeCollectionEntries, + sortContinueWatchingEntries, +} from "@/lib/helpers/filtering" +import { useThemeSettings } from "@/lib/theme/hooks" +import { atomWithImmer } from "jotai-immer" +import { useAtomValue, useSetAtom } from "jotai/react" +import React from "react" + +export const MAIN_LIBRARY_DEFAULT_PARAMS: CollectionParams<"anime"> = { + ...DEFAULT_ANIME_COLLECTION_PARAMS, + sorting: "TITLE", // Will be set to default sorting on mount + continueWatchingOnly: false, +} + +export const __mainLibrary_paramsAtom = atomWithImmer<CollectionParams<"anime">>(MAIN_LIBRARY_DEFAULT_PARAMS) + +export const __mainLibrary_paramsInputAtom = atomWithImmer<CollectionParams<"anime">>(MAIN_LIBRARY_DEFAULT_PARAMS) + +export function useHandleLibraryCollection() { + const serverStatus = useServerStatus() + + const atom_setLibraryCollection = useSetAtom(animeLibraryCollectionAtom) + + const { animeLibraryCollectionDefaultSorting, continueWatchingDefaultSorting } = useThemeSettings() + + const { data: watchHistory } = useGetContinuityWatchHistory() + + /** + * Fetch the anime library collection + */ + const { data, isLoading } = useGetLibraryCollection() + + /** + * Store the received data in `libraryCollectionAtom` + */ + React.useEffect(() => { + if (!!data) { + atom_setLibraryCollection(data) + } + }, [data]) + + /** + * Get the current params + */ + const params = useAtomValue(__mainLibrary_paramsAtom) + + /** + * Sort the collection + * - This is displayed when there's no filters applied + */ + const sortedCollection = React.useMemo(() => { + if (!data || !data.lists) return [] + + // Stream + if (data.stream) { + // Add to current list + let currentList = data.lists.find(n => n.type === "CURRENT") + if (currentList) { + let entries = [...(currentList.entries ?? [])] + for (let anime of (data.stream.anime ?? [])) { + if (!entries.some(e => e.mediaId === anime.id)) { + entries.push({ + media: anime, + mediaId: anime.id, + listData: data.stream.listData?.[anime.id], + libraryData: undefined, + }) + } + } + data.lists.find(n => n.type === "CURRENT")!.entries = entries + } + } + + let _lists = data.lists.map(obj => { + if (!obj) return obj + + // + let sortingParams = { + ...DEFAULT_ANIME_COLLECTION_PARAMS, + continueWatchingOnly: params.continueWatchingOnly, + sorting: animeLibraryCollectionDefaultSorting as any, + } as CollectionParams<"anime"> + + let continueWatchingList = [...(data.continueWatchingList ?? [])] + + if (data.stream) { + for (let entry of (data.stream?.continueWatchingList ?? [])) { + continueWatchingList = [...continueWatchingList, entry] + } + } + let arr = filterAnimeCollectionEntries( + obj.entries, + sortingParams, + serverStatus?.settings?.anilist?.enableAdultContent, + continueWatchingList, + watchHistory, + ) + + // Reset `continueWatchingOnly` to false if it's about to make the list disappear + if (arr.length === 0 && sortingParams.continueWatchingOnly) { + + // TODO: Add a toast to notify the user that the list is empty + sortingParams = { + ...sortingParams, + continueWatchingOnly: false, // Override + } + + arr = filterAnimeCollectionEntries( + obj.entries, + sortingParams, + serverStatus?.settings?.anilist?.enableAdultContent, + continueWatchingList, + watchHistory, + ) + } + + return { + type: obj.type, + status: obj.status, + entries: arr, + } + }) + return [ + _lists.find(n => n.type === "CURRENT"), + _lists.find(n => n.type === "PAUSED"), + _lists.find(n => n.type === "PLANNING"), + _lists.find(n => n.type === "COMPLETED"), + _lists.find(n => n.type === "DROPPED"), + ].filter(Boolean) + }, [data, params, animeLibraryCollectionDefaultSorting, serverStatus?.settings?.anilist?.enableAdultContent]) + + /** + * Filter the collection + * - This is displayed when there's filters applied + */ + const filteredCollection = React.useMemo(() => { + if (!data || !data.lists) return [] + + let _lists = data.lists.map(obj => { + if (!obj) return obj + const paramsToApply = { + ...params, + sorting: animeLibraryCollectionDefaultSorting, + } as CollectionParams<"anime"> + const arr = filterAnimeCollectionEntries(obj.entries, + paramsToApply, + serverStatus?.settings?.anilist?.enableAdultContent, + data.continueWatchingList, + watchHistory) + return { + type: obj.type, + status: obj.status, + entries: arr, + } + }) + return [ + _lists.find(n => n.type === "CURRENT"), + _lists.find(n => n.type === "PAUSED"), + _lists.find(n => n.type === "PLANNING"), + _lists.find(n => n.type === "COMPLETED"), + _lists.find(n => n.type === "DROPPED"), + ].filter(Boolean) + }, [data, params, serverStatus?.settings?.anilist?.enableAdultContent, watchHistory]) + + /** + * Sort the continue watching list + */ + const continueWatchingList = React.useMemo(() => { + if (!data?.continueWatchingList) return [] + + let list = [...data.continueWatchingList] + + + if (data.stream) { + for (let entry of (data.stream.continueWatchingList ?? [])) { + list = [...list, entry] + } + } + + const entries = sortedCollection.flatMap(n => n.entries) + + list = sortContinueWatchingEntries(list, continueWatchingDefaultSorting as any, entries, watchHistory) + + if (!serverStatus?.settings?.anilist?.enableAdultContent || serverStatus?.settings?.anilist?.blurAdultContent) { + return list.filter(entry => entry.baseAnime?.isAdult === false) + } + + return list + }, [ + data?.stream, + sortedCollection, + data?.continueWatchingList, + continueWatchingDefaultSorting, + serverStatus?.settings?.anilist?.enableAdultContent, + serverStatus?.settings?.anilist?.blurAdultContent, + watchHistory, + ]) + + /** + * Get the genres from all media in the library + */ + const libraryGenres = React.useMemo(() => { + const allGenres = filteredCollection?.flatMap(l => { + return l.entries?.flatMap(e => e.media?.genres) ?? [] + }) + return [...new Set(allGenres)].filter(Boolean)?.sort((a, b) => a.localeCompare(b)) + }, [filteredCollection]) + + return { + libraryGenres, + isLoading: isLoading, + libraryCollectionList: sortedCollection, + filteredLibraryCollectionList: filteredCollection, + continueWatchingList: continueWatchingList, + unmatchedLocalFiles: data?.unmatchedLocalFiles ?? [], + ignoredLocalFiles: data?.ignoredLocalFiles ?? [], + unmatchedGroups: data?.unmatchedGroups ?? [], + unknownGroups: data?.unknownGroups ?? [], + streamingMediaIds: data?.stream?.anime?.map(n => n.id) ?? [], + hasEntries: sortedCollection.some(n => n.entries?.length > 0), + isStreamingOnly: sortedCollection.every(n => n.entries?.every(e => !e.libraryData)), + isNakamaLibrary: React.useMemo(() => data?.lists?.some(l => l.entries?.some(e => !!e.nakamaLibraryData)) ?? false, [data?.lists]), + } + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_lib/library-view.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_lib/library-view.atoms.ts new file mode 100644 index 0000000..6946da9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_lib/library-view.atoms.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai" + +export const __library_viewAtom = atom<"base" | "detailed">("base") diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_screens/detailed-library-view.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_screens/detailed-library-view.tsx new file mode 100644 index 0000000..f37ace4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_screens/detailed-library-view.tsx @@ -0,0 +1,366 @@ +import { Anime_Episode, Anime_LibraryCollectionEntry, Anime_LibraryCollectionList } from "@/api/generated/types" +import { + __library_debouncedSearchInputAtom, + __library_paramsAtom, + __library_selectedListAtom, + DETAILED_LIBRARY_DEFAULT_PARAMS, + useHandleDetailedLibraryCollection, +} from "@/app/(main)/(library)/_lib/handle-detailed-library-collection" +import { __library_viewAtom } from "@/app/(main)/(library)/_lib/library-view.atoms" +import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector" +import { useNakamaStatus } from "@/app/(main)/_features/nakama/nakama-manager" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { ADVANCED_SEARCH_FORMATS, ADVANCED_SEARCH_SEASONS, ADVANCED_SEARCH_STATUS } from "@/app/(main)/search/_lib/advanced-search-constants" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Select } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { StaticTabs } from "@/components/ui/tabs" +import { TextInput } from "@/components/ui/text-input" +import { useDebounce } from "@/hooks/use-debounce" +import { ANIME_COLLECTION_SORTING_OPTIONS } from "@/lib/helpers/filtering" +import { getLibraryCollectionTitle } from "@/lib/server/utils" +import { useThemeSettings } from "@/lib/theme/hooks" +import { getYear } from "date-fns" +import { useAtomValue, useSetAtom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" +import { AiOutlineArrowLeft } from "react-icons/ai" +import { BiTrash } from "react-icons/bi" +import { FaSortAmountDown } from "react-icons/fa" +import { FiSearch } from "react-icons/fi" +import { LuCalendar, LuLeaf } from "react-icons/lu" +import { MdPersonalVideo } from "react-icons/md" +import { RiSignalTowerLine } from "react-icons/ri" + +type LibraryViewProps = { + collectionList: Anime_LibraryCollectionList[] + continueWatchingList: Anime_Episode[] + isLoading: boolean + hasEntries: boolean + streamingMediaIds: number[] + isNakamaLibrary: boolean +} + +export function DetailedLibraryView(props: LibraryViewProps) { + + const { + // collectionList: _collectionList, + continueWatchingList, + isLoading, + hasEntries, + streamingMediaIds, + isNakamaLibrary, + ...rest + } = props + + const ts = useThemeSettings() + const setView = useSetAtom(__library_viewAtom) + const nakamaStatus = useNakamaStatus() + + const { + stats, + libraryCollectionList, + libraryGenres, + } = useHandleDetailedLibraryCollection() + + if (isLoading) return <LoadingSpinner /> + + if (!hasEntries) return null + + return ( + <PageWrapper className="p-4 space-y-8 relative z-[4]" data-detailed-library-view-container> + + {/* <div + className={cn( + "absolute top-[-20rem] left-0 w-full h-[30rem] bg-gradient-to-t from-[--background] to-transparent z-[-1]", + TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE, + )} + /> */} + + <div className="flex flex-col md:flex-row gap-4 justify-between" data-detailed-library-view-header-container> + <div className="flex gap-4 items-center relative w-fit"> + <IconButton + icon={<AiOutlineArrowLeft />} + rounded + intent="white-outline" + size="sm" + onClick={() => setView("base")} + /> + {!isNakamaLibrary && <h3 className="text-ellipsis truncate">Library</h3>} + {isNakamaLibrary && + <h3 className="text-ellipsis truncate">{nakamaStatus?.hostConnectionStatus?.username || "Host"}'s Library</h3>} + </div> + + <SearchInput /> + </div> + + <div + className={cn( + "grid grid-cols-3 lg:grid-cols-6 gap-4 [&>div]:text-center [&>div>p]:text-[--muted]", + isNakamaLibrary && "lg:grid-cols-5", + )} + data-detailed-library-view-stats-container + > + {!isNakamaLibrary && <div> + <h3>{stats?.totalSize}</h3> + <p>Library</p> + </div>} + <div> + <h3>{stats?.totalFiles}</h3> + <p>Files</p> + </div> + <div> + <h3>{stats?.totalEntries}</h3> + <p>Entries</p> + </div> + <div> + <h3>{stats?.totalShows}</h3> + <p>TV Shows</p> + </div> + <div> + <h3>{stats?.totalMovies}</h3> + <p>Movies</p> + </div> + <div> + <h3>{stats?.totalSpecials}</h3> + <p>Specials</p> + </div> + </div> + + <SearchOptions /> + + <GenreSelector genres={libraryGenres} /> + + {libraryCollectionList.map(collection => { + if (!collection.entries?.length) return null + return <LibraryCollectionListItem key={collection.type} list={collection} streamingMediaIds={streamingMediaIds} /> + })} + </PageWrapper> + ) +} + +const LibraryCollectionListItem = React.memo(({ list, streamingMediaIds }: { list: Anime_LibraryCollectionList, streamingMediaIds: number[] }) => { + + const selectedList = useAtomValue(__library_selectedListAtom) + + if (selectedList !== "-" && selectedList !== list.type) return null + + return ( + <React.Fragment key={list.type}> + <h2>{getLibraryCollectionTitle(list.type)} <span className="text-[--muted] font-medium ml-3">{list?.entries?.length ?? 0}</span></h2> + <MediaCardLazyGrid itemCount={list?.entries?.length || 0}> + {list.entries?.map(entry => { + return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} /> + })} + </MediaCardLazyGrid> + </React.Fragment> + ) +}) + +const LibraryCollectionEntryItem = React.memo(({ entry, streamingMediaIds }: { + entry: Anime_LibraryCollectionEntry, + streamingMediaIds: number[] +}) => { + return ( + <MediaEntryCard + media={entry.media!} + listData={entry.listData} + libraryData={entry.libraryData} + nakamaLibraryData={entry.nakamaLibraryData} + showListDataButton + withAudienceScore={false} + type="anime" + showLibraryBadge={!!streamingMediaIds?.length && !streamingMediaIds.includes(entry.mediaId)} + /> + ) +}) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +const SearchInput = () => { + + const [inputValue, setInputValue] = React.useState("") + const setDebouncedInput = useSetAtom(__library_debouncedSearchInputAtom) + const debouncedInput = useDebounce(inputValue, 500) + + React.useEffect(() => { + setDebouncedInput(inputValue) + }, [debouncedInput]) + + + return ( + <div className="w-full md:w-[300px]"> + <TextInput + leftIcon={<FiSearch />} + value={inputValue} + onValueChange={v => { + setInputValue(v) + }} + className="rounded-full bg-gray-900/50" + /> + </div> + ) +} + +export function SearchOptions() { + + const serverStatus = useServerStatus() + const [params, setParams] = useAtom(__library_paramsAtom) + const [selectedIndex, setSelectedIndex] = useAtom(__library_selectedListAtom) + + return ( + <AppLayoutStack className="px-4 xl:px-0" data-detailed-library-view-search-options-container> + <div className="flex w-full justify-center"> + <StaticTabs + className="h-10 w-fit pb-6" + triggerClass="px-4 py-1" + items={[ + { name: "All", isCurrent: selectedIndex === "-", onClick: () => setSelectedIndex("-") }, + { name: "Watching", isCurrent: selectedIndex === "CURRENT", onClick: () => setSelectedIndex("CURRENT") }, + { name: "Planning", isCurrent: selectedIndex === "PLANNING", onClick: () => setSelectedIndex("PLANNING") }, + { name: "Paused", isCurrent: selectedIndex === "PAUSED", onClick: () => setSelectedIndex("PAUSED") }, + { name: "Completed", isCurrent: selectedIndex === "COMPLETED", onClick: () => setSelectedIndex("COMPLETED") }, + { name: "Dropped", isCurrent: selectedIndex === "DROPPED", onClick: () => setSelectedIndex("DROPPED") }, + ]} + /> + </div> + <div + className="grid grid-cols-2 md:grid-cols-3 2xl:grid-cols-[1fr_1fr_1fr_1fr_1fr_auto_auto] gap-4" + data-detailed-library-view-search-options-grid + > + <Select + label="Sorting" + leftAddon={<FaSortAmountDown className={cn(params.sorting !== "TITLE" && "text-indigo-300 font-bold text-xl")} />} + className="w-full" + fieldClass="flex items-center" + inputContainerClass="w-full" + options={ANIME_COLLECTION_SORTING_OPTIONS} + value={params.sorting || "TITLE"} + onValueChange={v => setParams(draft => { + draft.sorting = v as any + return + })} + fieldLabelClass="hidden" + // disabled={!!params.title && params.title.length > 0} + /> + <Select + leftAddon={ + <MdPersonalVideo className={cn((params.format as any) !== null && (params.format as any) !== "" && "text-indigo-300 font-bold text-xl")} />} + label="Format" placeholder="All formats" + className="w-full" + fieldClass="w-full" + options={ADVANCED_SEARCH_FORMATS} + value={params.format || ""} + onValueChange={v => setParams(draft => { + draft.format = v as any + return + })} + fieldLabelClass="hidden" + /> + <Select + leftAddon={ + <RiSignalTowerLine className={cn((params.status as any) !== null && (params.status as any) !== "" && "text-indigo-300 font-bold text-xl")} />} + label="Status" placeholder="All statuses" + className="w-full" + fieldClass="w-full" + options={[ + ...ADVANCED_SEARCH_STATUS, + ]} + value={params.status || ""} + onValueChange={v => setParams(draft => { + draft.status = v as any + return + })} + fieldLabelClass="hidden" + /> + <Select + leftAddon={ + <LuLeaf className={cn((params.season as any) !== null && (params.season as any) !== "" && "text-indigo-300 font-bold text-xl")} />} + label="Season" + placeholder="All seasons" + className="w-full" + fieldClass="w-full flex items-center" + inputContainerClass="w-full" + options={ADVANCED_SEARCH_SEASONS.map(season => ({ value: season.toUpperCase(), label: season }))} + value={params.season || ""} + onValueChange={v => setParams(draft => { + draft.season = v as any + return + })} + fieldLabelClass="hidden" + /> + <Select + leftAddon={<LuCalendar className={cn((params.year !== null && params.year !== "") && "text-indigo-300 font-bold text-xl")} />} + label="Year" placeholder="Timeless" + className="w-full" + fieldClass="w-full" + options={[...Array(70)].map((v, idx) => getYear(new Date()) - idx).map(year => ({ + value: String(year), + label: String(year), + }))} + value={params.year || ""} + onValueChange={v => setParams(draft => { + draft.year = v as any + return + })} + fieldLabelClass="hidden" + /> + <div className="flex gap-4 items-center w-full"> + <IconButton + icon={<BiTrash />} intent="alert-subtle" className="flex-none" onClick={() => { + setParams(DETAILED_LIBRARY_DEFAULT_PARAMS) + }} + /> + </div> + {serverStatus?.settings?.anilist?.enableAdultContent && <div className="flex h-full items-center"> + <Switch + label="Adult" + value={params.isAdult} + onValueChange={v => setParams(draft => { + draft.isAdult = v + return + })} + fieldLabelClass="hidden" + /> + </div>} + </div> + + </AppLayoutStack> + ) +} + +function GenreSelector({ genres }: { genres: string[] }) { + const [params, setParams] = useAtom(__library_paramsAtom) + return ( + <MediaGenreSelector + items={[ + { + name: "All", + isCurrent: !params!.genre?.length, + onClick: () => setParams(draft => { + draft.genre = [] + return + }), + }, + ...genres.map(genre => ({ + name: genre, + isCurrent: params!.genre?.includes(genre) ?? false, + onClick: () => setParams(draft => { + if (draft.genre?.includes(genre)) { + draft.genre = draft.genre?.filter(g => g !== genre) + } else { + draft.genre = [...(draft.genre || []), genre] + } + return + }), + })), + ]} + /> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_screens/empty-library-view.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_screens/empty-library-view.tsx new file mode 100644 index 0000000..52a8499 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_screens/empty-library-view.tsx @@ -0,0 +1,148 @@ +import { __scanner_modalIsOpen } from "@/app/(main)/(library)/_containers/scanner-modal" +import { __mainLibrary_paramsAtom, __mainLibrary_paramsInputAtom } from "@/app/(main)/(library)/_lib/handle-library-collection" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { DiscoverPageHeader } from "@/app/(main)/discover/_components/discover-page-header" +import { DiscoverTrending } from "@/app/(main)/discover/_containers/discover-trending" +import { LuffyError } from "@/components/shared/luffy-error" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { SeaLink } from "@/components/shared/sea-link" +import { Button } from "@/components/ui/button" +import { HorizontalDraggableScroll } from "@/components/ui/horizontal-draggable-scroll" +import { StaticTabs } from "@/components/ui/tabs" +import { useDebounce } from "@/hooks/use-debounce" +import { useSetAtom } from "jotai/index" +import { useAtom } from "jotai/react" +import React from "react" +import { FiSearch } from "react-icons/fi" +import { LuCog } from "react-icons/lu" + +type EmptyLibraryViewProps = { + isLoading: boolean + hasEntries: boolean +} + +export function EmptyLibraryView(props: EmptyLibraryViewProps) { + + const { + isLoading, + hasEntries, + ...rest + } = props + + const serverStatus = useServerStatus() + const setScannerModalOpen = useSetAtom(__scanner_modalIsOpen) + + if (hasEntries || isLoading) return null + + /** + * Show empty library message and trending if library is empty + */ + return ( + <> + <DiscoverPageHeader /> + <PageWrapper className="p-4 sm:p-8 pt-0 space-y-8 relative z-[4]" data-empty-library-view-container> + <div className="text-center space-y-4"> + <div className="w-fit mx-auto space-y-4"> + {!!serverStatus?.settings?.library?.libraryPath ? <> + <h2>Empty library</h2> + <Button + intent="primary-outline" + leftIcon={<FiSearch />} + size="xl" + rounded + onClick={() => setScannerModalOpen(true)} + > + Scan your library + </Button> + </> : ( + <LuffyError + title="Your library is empty" + className="" + > + <div className="text-center space-y-4"> + <SeaLink href="/settings?tab=library"> + <Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}> + Set the path to your local library and scan it + </Button> + </SeaLink> + {serverStatus?.settings?.library?.enableOnlinestream && <p> + <SeaLink href="/settings?tab=onlinestream"> + <Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}> + Include online streaming in your library + </Button> + </SeaLink> + </p>} + {serverStatus?.torrentstreamSettings?.enabled && <p> + <SeaLink href="/settings?tab=torrentstream"> + <Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}> + Include torrent streaming in your library + </Button> + </SeaLink> + </p>} + {serverStatus?.debridSettings?.enabled && <p> + <SeaLink href="/settings?tab=debrid"> + <Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}> + Include debrid streaming in your library + </Button> + </SeaLink> + </p>} + </div> + </LuffyError> + )} + </div> + </div> + <div className=""> + <h3>Trending this season</h3> + <DiscoverTrending /> + </div> + </PageWrapper> + </> + ) +} + +function GenreSelector({ + genres, +}: { genres: string[] }) { + const [params, setParams] = useAtom(__mainLibrary_paramsInputAtom) + const setActualParams = useSetAtom(__mainLibrary_paramsAtom) + const debouncedParams = useDebounce(params, 500) + + React.useEffect(() => { + setActualParams(params) + }, [debouncedParams]) + + if (!genres.length) return null + + return ( + <HorizontalDraggableScroll className="scroll-pb-1 pt-4 flex"> + <div className="flex flex-1"></div> + <StaticTabs + className="px-2 overflow-visible gap-2 py-4 w-fit" + triggerClass="text-base rounded-[--radius-md] ring-2 ring-transparent data-[current=true]:ring-brand-500 data-[current=true]:text-brand-300" + items={[ + // { + // name: "All", + // isCurrent: !params!.genre?.length, + // onClick: () => setParams(draft => { + // draft.genre = [] + // return + // }), + // }, + ...genres.map(genre => ({ + name: genre, + isCurrent: params!.genre?.includes(genre) ?? false, + onClick: () => setParams(draft => { + if (draft.genre?.includes(genre)) { + draft.genre = draft.genre?.filter(g => g !== genre) + } else { + draft.genre = [...(draft.genre || []), genre] + } + return + }), + })), + ]} + /> + <div className="flex flex-1"></div> + </HorizontalDraggableScroll> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_screens/library-view.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_screens/library-view.tsx new file mode 100644 index 0000000..09139d1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/_screens/library-view.tsx @@ -0,0 +1,136 @@ +import { Anime_Episode, Anime_LibraryCollectionList } from "@/api/generated/types" +import { ContinueWatching } from "@/app/(main)/(library)/_containers/continue-watching" +import { LibraryCollectionFilteredLists, LibraryCollectionLists } from "@/app/(main)/(library)/_containers/library-collection" +import { __mainLibrary_paramsAtom, __mainLibrary_paramsInputAtom } from "@/app/(main)/(library)/_lib/handle-library-collection" +import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { cn } from "@/components/ui/core/styling" +import { Skeleton } from "@/components/ui/skeleton" +import { useDebounce } from "@/hooks/use-debounce" +import { useThemeSettings } from "@/lib/theme/hooks" +import { useSetAtom } from "jotai/index" +import { useAtom } from "jotai/react" +import { AnimatePresence } from "motion/react" +import React from "react" + + +type LibraryViewProps = { + genres: string[] + collectionList: Anime_LibraryCollectionList[] + filteredCollectionList: Anime_LibraryCollectionList[] + continueWatchingList: Anime_Episode[] + isLoading: boolean + hasEntries: boolean + streamingMediaIds: number[] +} + +export function LibraryView(props: LibraryViewProps) { + + const { + genres, + collectionList, + continueWatchingList, + filteredCollectionList, + isLoading, + hasEntries, + streamingMediaIds, + ...rest + } = props + + const ts = useThemeSettings() + + const [params, setParams] = useAtom(__mainLibrary_paramsAtom) + + if (isLoading) return <React.Fragment> + <div className="p-4 space-y-4 relative z-[4]"> + <Skeleton className="h-12 w-full max-w-lg relative" /> + <div + className={cn( + "grid h-[22rem] min-[2000px]:h-[24rem] grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7 min-[2000px]:grid-cols-8 gap-4", + )} + > + {[1, 2, 3, 4, 5, 6, 7, 8]?.map((_, idx) => { + return <Skeleton + key={idx} className={cn( + "h-[22rem] min-[2000px]:h-[24rem] col-span-1 aspect-[6/7] flex-none rounded-[--radius-md] relative overflow-hidden", + "[&:nth-child(8)]:hidden min-[2000px]:[&:nth-child(8)]:block", + "[&:nth-child(7)]:hidden 2xl:[&:nth-child(7)]:block", + "[&:nth-child(6)]:hidden xl:[&:nth-child(6)]:block", + "[&:nth-child(5)]:hidden xl:[&:nth-child(5)]:block", + "[&:nth-child(4)]:hidden lg:[&:nth-child(4)]:block", + "[&:nth-child(3)]:hidden md:[&:nth-child(3)]:block", + )} + /> + })} + </div> + </div> + </React.Fragment> + + return ( + <> + <ContinueWatching + episodes={continueWatchingList} + isLoading={isLoading} + /> + + {( + !ts.disableLibraryScreenGenreSelector && + collectionList.flatMap(n => n.entries)?.length > 2 + ) && <GenreSelector genres={genres} />} + + <PageWrapper key="library-collection-lists" className="p-4 space-y-8 relative z-[4]" data-library-collection-lists-container> + <AnimatePresence mode="wait" initial={false}> + {!params.genre?.length ? + <LibraryCollectionLists + key="library-collection-lists" + collectionList={collectionList} + isLoading={isLoading} + streamingMediaIds={streamingMediaIds} + /> + : <LibraryCollectionFilteredLists + key="library-filtered-lists" + collectionList={filteredCollectionList} + isLoading={isLoading} + streamingMediaIds={streamingMediaIds} + /> + } + </AnimatePresence> + </PageWrapper> + </> + ) +} + +function GenreSelector({ + genres, +}: { genres: string[] }) { + const [params, setParams] = useAtom(__mainLibrary_paramsInputAtom) + const setActualParams = useSetAtom(__mainLibrary_paramsAtom) + const debouncedParams = useDebounce(params, 200) + + React.useEffect(() => { + setActualParams(params) + }, [debouncedParams]) + + if (!genres.length) return null + + return ( + <PageWrapper className="space-y-3 lg:space-y-6 relative z-[4]" data-library-genre-selector-container> + <MediaGenreSelector + items={[ + ...genres.map(genre => ({ + name: genre, + isCurrent: params!.genre?.includes(genre) ?? false, + onClick: () => setParams(draft => { + if (draft.genre?.includes(genre)) { + draft.genre = draft.genre?.filter(g => g !== genre) + } else { + draft.genre = [...(draft.genre || []), genre] + } + return + }), + })), + ]} + /> + </PageWrapper> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/layout.tsx new file mode 100644 index 0000000..043694d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/layout.tsx @@ -0,0 +1,18 @@ +"use client" +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) + +} + + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(library)/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/page.tsx new file mode 100644 index 0000000..72e1d57 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(library)/page.tsx @@ -0,0 +1,120 @@ +"use client" +import { LibraryHeader } from "@/app/(main)/(library)/_components/library-header" +import { BulkActionModal } from "@/app/(main)/(library)/_containers/bulk-action-modal" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { IgnoredFileManager } from "@/app/(main)/(library)/_containers/ignored-file-manager" +import { LibraryToolbar } from "@/app/(main)/(library)/_containers/library-toolbar" +import { UnknownMediaManager } from "@/app/(main)/(library)/_containers/unknown-media-manager" +import { UnmatchedFileManager } from "@/app/(main)/(library)/_containers/unmatched-file-manager" +import { useHandleLibraryCollection } from "@/app/(main)/(library)/_lib/handle-library-collection" +import { __library_viewAtom } from "@/app/(main)/(library)/_lib/library-view.atoms" +import { DetailedLibraryView } from "@/app/(main)/(library)/_screens/detailed-library-view" +import { EmptyLibraryView } from "@/app/(main)/(library)/_screens/empty-library-view" +import { LibraryView } from "@/app/(main)/(library)/_screens/library-view" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks" +import { useAtom } from "jotai/react" +import { AnimatePresence } from "motion/react" +import React from "react" + +export const dynamic = "force-static" + +export default function Library() { + + const { + libraryGenres, + libraryCollectionList, + filteredLibraryCollectionList, + isLoading, + continueWatchingList, + unmatchedLocalFiles, + ignoredLocalFiles, + unmatchedGroups, + unknownGroups, + streamingMediaIds, + hasEntries, + isStreamingOnly, + isNakamaLibrary, + } = useHandleLibraryCollection() + + const [view, setView] = useAtom(__library_viewAtom) + + const ts = useThemeSettings() + + return ( + <div data-library-page-container> + + {hasEntries && ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Custom && <CustomLibraryBanner isLibraryScreen />} + {hasEntries && ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && <LibraryHeader list={continueWatchingList} />} + <LibraryToolbar + collectionList={libraryCollectionList} + unmatchedLocalFiles={unmatchedLocalFiles} + ignoredLocalFiles={ignoredLocalFiles} + unknownGroups={unknownGroups} + isLoading={isLoading} + hasEntries={hasEntries} + isStreamingOnly={isStreamingOnly} + isNakamaLibrary={isNakamaLibrary} + /> + + <EmptyLibraryView isLoading={isLoading} hasEntries={hasEntries} /> + + <AnimatePresence mode="wait"> + {view === "base" && <PageWrapper + key="base" + className="relative 2xl:order-first pb-10 pt-4" + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.25, + }, + }} + > + <LibraryView + genres={libraryGenres} + collectionList={libraryCollectionList} + filteredCollectionList={filteredLibraryCollectionList} + continueWatchingList={continueWatchingList} + isLoading={isLoading} + hasEntries={hasEntries} + streamingMediaIds={streamingMediaIds} + /> + </PageWrapper>} + {view === "detailed" && <PageWrapper + key="detailed" + className="relative 2xl:order-first pb-10 pt-4" + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.25, + }, + }} + > + <DetailedLibraryView + collectionList={libraryCollectionList} + continueWatchingList={continueWatchingList} + isLoading={isLoading} + hasEntries={hasEntries} + streamingMediaIds={streamingMediaIds} + isNakamaLibrary={isNakamaLibrary} + /> + </PageWrapper>} + </AnimatePresence> + + <UnmatchedFileManager + unmatchedGroups={unmatchedGroups} + /> + <UnknownMediaManager + unknownGroups={unknownGroups} + /> + <IgnoredFileManager + files={ignoredLocalFiles} + /> + <BulkActionModal /> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/_components/offline-anime-lists.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/_components/offline-anime-lists.tsx new file mode 100644 index 0000000..d653525 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/_components/offline-anime-lists.tsx @@ -0,0 +1,47 @@ +import { LibraryHeader } from "@/app/(main)/(library)/_components/library-header" +import { useHandleLibraryCollection } from "@/app/(main)/(library)/_lib/handle-library-collection" +import { LibraryView } from "@/app/(main)/(library)/_screens/library-view" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { cn } from "@/components/ui/core/styling" +import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks" +import React from "react" + +export function OfflineAnimeLists() { + const ts = useThemeSettings() + + const { + libraryGenres, + libraryCollectionList, + filteredLibraryCollectionList, + isLoading, + continueWatchingList, + streamingMediaIds, + } = useHandleLibraryCollection() + + return ( + <> + {ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && <> + <LibraryHeader list={continueWatchingList} /> + <div + className={cn( + "h-28", + ts.hideTopNavbar && "h-40", + )} + ></div> + </>} + <PageWrapper + className="pt-4 relative space-y-8" + > + <LibraryView + genres={libraryGenres} + collectionList={libraryCollectionList} + filteredCollectionList={filteredLibraryCollectionList} + continueWatchingList={continueWatchingList} + isLoading={isLoading} + hasEntries={true} + streamingMediaIds={streamingMediaIds} + /> + </PageWrapper> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/_components/offline-manga-lists.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/_components/offline-manga-lists.tsx new file mode 100644 index 0000000..3b329e4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/_components/offline-manga-lists.tsx @@ -0,0 +1,57 @@ +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { MediaEntryPageLoadingDisplay } from "@/app/(main)/_features/media/_components/media-entry-page-loading-display" +import { LibraryHeader } from "@/app/(main)/manga/_components/library-header" +import { useHandleMangaCollection } from "@/app/(main)/manga/_lib/handle-manga-collection" +import { MangaLibraryView } from "@/app/(main)/manga/_screens/manga-library-view" +import { cn } from "@/components/ui/core/styling" +import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks" +import React from "react" + +export function OfflineMangaLists() { + const { + mangaCollection, + filteredMangaCollection, + genres, + mangaCollectionLoading, + storedProviders, + hasManga, + } = useHandleMangaCollection() + + const ts = useThemeSettings() + + if (!mangaCollection || mangaCollectionLoading) return <MediaEntryPageLoadingDisplay /> + + return ( + <div> + {( + (!!ts.libraryScreenCustomBannerImage && ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Custom) + ) && ( + <> + <CustomLibraryBanner isLibraryScreen /> + <div + className={cn("h-14")} + ></div> + </> + )} + {ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && ( + <> + <LibraryHeader manga={mangaCollection?.lists?.flatMap(l => l.entries)?.flatMap(e => e?.media)?.filter(Boolean) || []} /> + <div + className={cn( + "h-28", + ts.hideTopNavbar && "h-40", + )} + ></div> + </> + )} + + <MangaLibraryView + genres={genres} + collection={mangaCollection} + filteredCollection={filteredMangaCollection} + storedProviders={storedProviders} + hasManga={hasManga} + /> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/_components/offline-top-menu.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/_components/offline-top-menu.tsx new file mode 100644 index 0000000..4a84602 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/_components/offline-top-menu.tsx @@ -0,0 +1,45 @@ +"use client" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { NavigationMenu, NavigationMenuProps } from "@/components/ui/navigation-menu" +import { usePathname } from "next/navigation" +import React, { useMemo } from "react" + +interface OfflineTopMenuProps { + children?: React.ReactNode +} + +export const OfflineTopMenu: React.FC<OfflineTopMenuProps> = (props) => { + + const { children, ...rest } = props + + const serverStatus = useServerStatus() + + const pathname = usePathname() + + const navigationItems = useMemo<NavigationMenuProps["items"]>(() => { + + return [ + { + href: "/offline", + // icon: IoLibrary, + isCurrent: pathname === "/offline", + name: "My library", + }, + ...[serverStatus?.settings?.library?.enableManga && { + href: "/offline/manga", + icon: null, + isCurrent: pathname.includes("/offline/manga"), + name: "Manga", + }].filter(Boolean) as NavigationMenuProps["items"], + ].filter(Boolean) + }, [pathname, serverStatus?.settings?.library?.enableManga]) + + return ( + <NavigationMenu + className="p-0 hidden lg:inline-block" + itemClass="text-xl" + items={navigationItems} + /> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/_components/offline-meta-section.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/_components/offline-meta-section.tsx new file mode 100644 index 0000000..840751b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/_components/offline-meta-section.tsx @@ -0,0 +1,54 @@ +"use client" +import { AL_BaseAnime, AL_BaseManga, Anime_Entry, Manga_Entry } from "@/api/generated/types" +import { MediaEntryAudienceScore } from "@/app/(main)/_features/media/_components/media-entry-metadata-components" +import { + MediaPageHeader, + MediaPageHeaderDetailsContainer, + MediaPageHeaderEntryDetails, +} from "@/app/(main)/_features/media/_components/media-page-header-components" +import React from "react" + +type OfflineMetaSectionProps<T extends "anime" | "manga"> = { + type: T, + entry: T extends "anime" ? Anime_Entry : Manga_Entry +} + +export function OfflineMetaSection<T extends "anime" | "manga">(props: OfflineMetaSectionProps<T>) { + + const { type, entry } = props + + if (!entry?.media) return null + + return ( + <MediaPageHeader + backgroundImage={entry.media?.bannerImage} + coverImage={entry.media?.coverImage?.extraLarge} + > + + <MediaPageHeaderDetailsContainer> + + <MediaPageHeaderEntryDetails + coverImage={entry.media?.coverImage?.extraLarge || entry.media?.coverImage?.large} + title={entry.media?.title?.userPreferred} + color={entry.media?.coverImage?.color} + englishTitle={entry.media?.title?.english} + romajiTitle={entry.media?.title?.romaji} + startDate={entry.media?.startDate} + season={entry.media?.season} + progressTotal={type === "anime" ? (entry.media as AL_BaseAnime)?.episodes : (entry.media as AL_BaseManga)?.chapters} + status={entry.media?.status} + description={entry.media?.description} + listData={entry.listData} + media={entry.media} + type="anime" + /> + + + <div className="flex gap-2 items-center"> + <MediaEntryAudienceScore meanScore={entry.media?.meanScore} badgeClass="bg-transparent" /> + </div> + </MediaPageHeaderDetailsContainer> + </MediaPageHeader> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/anime/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/anime/page.tsx new file mode 100644 index 0000000..9e236a6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/anime/page.tsx @@ -0,0 +1,43 @@ +"use client" + +import { useGetAnimeEntry } from "@/api/hooks/anime_entries.hooks" +import { OfflineMetaSection } from "@/app/(main)/(offline)/offline/entry/_components/offline-meta-section" +import { MediaEntryPageLoadingDisplay } from "@/app/(main)/_features/media/_components/media-entry-page-loading-display" +import { EpisodeSection } from "@/app/(main)/entry/_containers/episode-list/episode-section" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { useRouter, useSearchParams } from "next/navigation" +import React from "react" + +export default function Page() { + const router = useRouter() + const mediaId = useSearchParams().get("id") + + const { data: animeEntry, isLoading: animeEntryLoading } = useGetAnimeEntry(mediaId) + + React.useEffect(() => { + if (!mediaId || (!animeEntryLoading && !animeEntry)) { + router.push("/offline") + } + }, [animeEntry, animeEntryLoading]) + + if (animeEntryLoading) return <MediaEntryPageLoadingDisplay /> + if (!animeEntry) return null + + return ( + <> + <OfflineMetaSection type="anime" entry={animeEntry} /> + <PageWrapper + className="p-4 relative" + data-media={JSON.stringify(animeEntry.media)} + data-anime-entry-list-data={JSON.stringify(animeEntry.listData)} + > + <EpisodeSection + entry={animeEntry} + details={undefined} + bottomSection={<></>} + /> + </PageWrapper> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/layout.tsx new file mode 100644 index 0000000..58859d4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/layout.tsx @@ -0,0 +1,15 @@ +"use client" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + + return ( + <> + {children} + </> + ) + +} + + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/manga/_components/offline-chapter-list.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/manga/_components/offline-chapter-list.tsx new file mode 100644 index 0000000..7bda642 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/manga/_components/offline-chapter-list.tsx @@ -0,0 +1,173 @@ +import { HibikeManga_ChapterDetails, Manga_ChapterContainer, Manga_Entry } from "@/api/generated/types" +import { useGetMangaEntryDownloadedChapters } from "@/api/hooks/manga.hooks" +import { ChapterReaderDrawer } from "@/app/(main)/manga/_containers/chapter-reader/chapter-reader-drawer" +import { __manga_selectedChapterAtom } from "@/app/(main)/manga/_lib/handle-chapter-reader" +import { useHandleMangaDownloadData } from "@/app/(main)/manga/_lib/handle-manga-downloads" +import { getChapterNumberFromChapter } from "@/app/(main)/manga/_lib/handle-manga-utils" +import { primaryPillCheckboxClasses } from "@/components/shared/classnames" +import { IconButton } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { DataGrid, defineDataGridColumns } from "@/components/ui/datagrid" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { useSetAtom } from "jotai" +import React from "react" +import { GiOpenBook } from "react-icons/gi" + +type OfflineChapterListProps = { + entry: Manga_Entry | undefined + children?: React.ReactNode +} + +export function OfflineChapterList(props: OfflineChapterListProps) { + + const { + entry, + children, + ...rest + } = props + + const { data: chapterContainers, isLoading } = useGetMangaEntryDownloadedChapters(entry?.mediaId) + + /** + * Set selected chapter + */ + const setSelectedChapter = useSetAtom(__manga_selectedChapterAtom) + + // Load download data + useHandleMangaDownloadData(entry?.mediaId) + + const chapters = React.useMemo(() => { + return chapterContainers?.flatMap(n => n.chapters)?.filter(Boolean) ?? [] + }, [chapterContainers]) + + + const chapterNumbersMap = React.useMemo(() => { + const map = new Map<string, number>() + + for (const chapter of chapters) { + map.set(chapter.id, getChapterNumberFromChapter(chapter.chapter)) + } + + return map + }, [chapterContainers]) + + const [selectedChapterContainer, setSelectedChapterContainer] = React.useState<Manga_ChapterContainer | undefined>(undefined) + + const columns = React.useMemo(() => defineDataGridColumns<HibikeManga_ChapterDetails>(() => [ + { + accessorKey: "title", + header: "Name", + size: 90, + }, + { + accessorKey: "provider", + header: "Provider", + size: 10, + enableSorting: true, + }, + { + id: "number", + header: "Number", + size: 10, + enableSorting: true, + accessorFn: (row) => { + return chapterNumbersMap.get(row.id) + }, + }, + { + id: "_actions", + size: 5, + enableSorting: false, + enableGlobalFilter: false, + cell: ({ row }) => { + return ( + <div className="flex justify-end gap-2 items-center w-full"> + <IconButton + intent="gray-subtle" + size="sm" + onClick={() => { + // setProvider(row.original.provider) + setSelectedChapterContainer(chapterContainers?.find(c => c.provider === row.original.provider)) + React.startTransition(() => { + setSelectedChapter({ + chapterId: row.original.id, + chapterNumber: row.original.chapter, + provider: row.original.provider, + mediaId: Number(entry?.mediaId), + }) + }) + }} + icon={<GiOpenBook />} + /> + </div> + ) + }, + }, + ]), [entry, chapterNumbersMap]) + + const [showUnreadChapter, setShowUnreadChapter] = React.useState(false) + + const retainUnreadChapters = React.useCallback((chapter: HibikeManga_ChapterDetails) => { + if (!entry?.listData || !chapterNumbersMap.has(chapter.id) || !entry?.listData?.progress) return true + + const chapterNumber = chapterNumbersMap.get(chapter.id) + return !!chapterNumber && chapterNumber > entry.listData?.progress + }, [chapterNumbersMap, chapterContainers, entry]) + + const unreadChapters = React.useMemo(() => chapters.filter(ch => retainUnreadChapters(ch)) ?? [], + [chapters, entry]) + + React.useEffect(() => { + setShowUnreadChapter(!!unreadChapters.length) + }, [unreadChapters]) + + const tableChapters = React.useMemo(() => { + return showUnreadChapter ? unreadChapters : chapters + }, [showUnreadChapter, chapters, unreadChapters]) + + if (!entry || isLoading) return <LoadingSpinner /> + + return ( + <> + <div className="space-y-4 border rounded-[--radius-md] bg-[--paper] p-4"> + + <div className="flex flex-wrap items-center gap-4"> + <Checkbox + label="Show unread" + value={showUnreadChapter} + onValueChange={v => setShowUnreadChapter(v as boolean)} + fieldClass="w-fit" + {...primaryPillCheckboxClasses} + /> + </div> + + <DataGrid<HibikeManga_ChapterDetails> + columns={columns} + data={tableChapters} + rowCount={tableChapters?.length || 0} + isLoading={!tableChapters} + rowSelectionPrimaryKey="id" + initialState={{ + pagination: { + pageIndex: 0, + pageSize: 10, + }, + sorting: [ + { + id: "number", + desc: false, + }, + ], + }} + className="" + /> + + {(!!selectedChapterContainer) && <ChapterReaderDrawer + entry={entry} + chapterIdToNumbersMap={chapterNumbersMap} + chapterContainer={selectedChapterContainer} + />} + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/manga/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/manga/page.tsx new file mode 100644 index 0000000..401a3bf --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/entry/manga/page.tsx @@ -0,0 +1,38 @@ +"use client" + +import { useGetMangaEntry } from "@/api/hooks/manga.hooks" +import { OfflineMetaSection } from "@/app/(main)/(offline)/offline/entry/_components/offline-meta-section" +import { OfflineChapterList } from "@/app/(main)/(offline)/offline/entry/manga/_components/offline-chapter-list" +import { MediaEntryPageLoadingDisplay } from "@/app/(main)/_features/media/_components/media-entry-page-loading-display" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { useRouter, useSearchParams } from "next/navigation" +import React from "react" + +export default function Page() { + const router = useRouter() + const mediaId = useSearchParams().get("id") + + const { data: mangaEntry, isLoading: mangaEntryLoading } = useGetMangaEntry(mediaId) + + React.useEffect(() => { + if (!mediaId || (!mangaEntryLoading && !mangaEntry)) { + router.push("/offline") + } + }, [mangaEntry, mangaEntryLoading]) + + if (mangaEntryLoading) return <MediaEntryPageLoadingDisplay /> + if (!mangaEntry) return null + + return ( + <> + <OfflineMetaSection type="manga" entry={mangaEntry} /> + <PageWrapper className="p-4 space-y-6"> + + <h2>Chapters</h2> + + <OfflineChapterList entry={mangaEntry} /> + </PageWrapper> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/layout.tsx new file mode 100644 index 0000000..043694d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/layout.tsx @@ -0,0 +1,18 @@ +"use client" +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) + +} + + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/manga/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/manga/page.tsx new file mode 100644 index 0000000..1f4d437 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/manga/page.tsx @@ -0,0 +1,18 @@ +"use client" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { OfflineMangaLists } from "@/app/(main)/(offline)/offline/_components/offline-manga-lists" +import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks" +import React from "react" + +export const dynamic = "force-static" + +export default function Page() { + const ts = useThemeSettings() + + return ( + <> + {ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Custom && <CustomLibraryBanner isLibraryScreen />} + <OfflineMangaLists /> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/page.tsx new file mode 100644 index 0000000..7f15e6a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/(offline)/offline/page.tsx @@ -0,0 +1,18 @@ +"use client" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { OfflineAnimeLists } from "@/app/(main)/(offline)/offline/_components/offline-anime-lists" +import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks" +import React from "react" + +export const dynamic = "force-static" + +export default function Page() { + const ts = useThemeSettings() + + return ( + <> + {ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Custom && <CustomLibraryBanner isLibraryScreen />} + <OfflineAnimeLists /> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/anilist.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/anilist.atoms.ts new file mode 100644 index 0000000..20b4688 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/anilist.atoms.ts @@ -0,0 +1,9 @@ +import { AL_BaseAnime, Anime_EntryListData, Manga_EntryListData } from "@/api/generated/types" +import { atom } from "jotai/index" + +export const __anilist_userAnimeMediaAtom = atom<AL_BaseAnime[] | undefined>(undefined) + +// e.g. { "123": { ... } } +export const __anilist_userAnimeListDataAtom = atom<Record<string, Anime_EntryListData>>({}) + +export const __anilist_userMangaListDataAtom = atom<Record<string, Manga_EntryListData>>({}) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/anime-library-collection.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/anime-library-collection.atoms.ts new file mode 100644 index 0000000..546c573 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/anime-library-collection.atoms.ts @@ -0,0 +1,27 @@ +import { Anime_LibraryCollection } from "@/api/generated/types" +import { derive } from "jotai-derive" +import { atom } from "jotai/index" + +export const animeLibraryCollectionAtom = atom<Anime_LibraryCollection | undefined>(undefined) +export const animeLibraryCollectionWithoutStreamsAtom = derive([animeLibraryCollectionAtom], (animeLibraryCollection) => { + if (!animeLibraryCollection) { + return undefined + } + return { + ...animeLibraryCollection, + lists: animeLibraryCollection.lists?.map(list => ({ + ...list, + entries: list.entries?.filter(n => !!n.libraryData), + })), + } as Anime_LibraryCollection +}) + +export const getAtomicLibraryEntryAtom = atom(get => get(animeLibraryCollectionAtom)?.lists?.length, + (get, set, payload: number) => { + const lists = get(animeLibraryCollectionAtom)?.lists + if (!lists) { + return undefined + } + return lists.flatMap(n => n.entries)?.filter(Boolean).find(n => n.mediaId === payload) + }, +) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/autodownloader.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/autodownloader.atoms.ts new file mode 100644 index 0000000..adbdad5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/autodownloader.atoms.ts @@ -0,0 +1,5 @@ +import { Models_AutoDownloaderItem } from "@/api/generated/types" +import { atom } from "jotai/index" + +export const autoDownloaderItemsAtom = atom<Models_AutoDownloaderItem[]>([]) +export const autoDownloaderItemCountAtom = atom(get => get(autoDownloaderItemsAtom).length) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/builtin-mediaplayer.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/builtin-mediaplayer.atoms.ts new file mode 100644 index 0000000..f1b4c95 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/builtin-mediaplayer.atoms.ts @@ -0,0 +1,3 @@ +import { atomWithStorage } from "jotai/utils" + +export const __mediaplayer_discreteControlsAtom = atomWithStorage("sea-mediaplayer-discrete-controls", false) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/missing-episodes.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/missing-episodes.atoms.ts new file mode 100644 index 0000000..1d825db --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/missing-episodes.atoms.ts @@ -0,0 +1,9 @@ +import { Anime_Episode } from "@/api/generated/types" +import { atom } from "jotai" + +export const missingEpisodesAtom = atom<Anime_Episode[]>([]) + +export const missingSilencedEpisodesAtom = atom<Anime_Episode[]>([]) + +export const missingEpisodeCountAtom = atom(get => get(missingEpisodesAtom).length) + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/playback.atoms.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/playback.atoms.tsx new file mode 100644 index 0000000..05891a6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/playback.atoms.tsx @@ -0,0 +1,117 @@ +import { Nullish } from "@/api/generated/types" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import { FaShareFromSquare } from "react-icons/fa6" +import { PiVideoFill } from "react-icons/pi" + +export const enum ElectronPlaybackMethod { + NativePlayer = "nativePlayer", // Desktop media player or Integrated player (media streaming) + Default = "default", // Desktop media player, media streaming or external player link +} + +export const __playback_electronPlaybackMethodAtom = atomWithStorage<string>("sea-playback-electron-playback-method", + ElectronPlaybackMethod.NativePlayer) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const enum PlaybackDownloadedMedia { + Default = "default", // Desktop media player or Integrated player (media streaming) + ExternalPlayerLink = "externalPlayerLink", // External player link +} + + +export const playbackDownloadedMediaOptions = [ + { + label: <div className="flex items-center gap-4 md:gap-2 w-full"> + <PiVideoFill className="text-2xl flex-none" /> + <p className="max-w-[90%]">Desktop media player or Transcoding / Direct Play</p> + </div>, value: PlaybackDownloadedMedia.Default, + }, + { + label: <div className="flex items-center gap-4 md:gap-2 w-full"> + <FaShareFromSquare className="text-2xl flex-none" /> + <p className="max-w-[90%]">External player link</p> + </div>, value: PlaybackDownloadedMedia.ExternalPlayerLink, + }, +] + +export const __playback_downloadedMediaAtom = atomWithStorage<string>("sea-playback-downloaded-media", PlaybackDownloadedMedia.Default) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const enum PlaybackTorrentStreaming { + Default = "default", // Desktop media player + ExternalPlayerLink = "externalPlayerLink", +} + +export const playbackTorrentStreamingOptions = [ + { + label: <div className="flex items-center gap-4 md:gap-2 w-full"> + <PiVideoFill className="text-2xl flex-none" /> + <p className="max-w-[90%]">Desktop media player</p> + </div>, value: PlaybackTorrentStreaming.Default, + }, + { + label: <div className="flex items-center gap-4 md:gap-2 w-full"> + <FaShareFromSquare className="text-2xl flex-none" /> + <p className="max-w-[90%]">External player link</p> + </div>, value: PlaybackTorrentStreaming.ExternalPlayerLink, + }, +] + + +export const __playback_torrentStreamingAtom = atomWithStorage<string>("sea-playback-torrentstream", PlaybackTorrentStreaming.Default) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function useCurrentDevicePlaybackSettings() { + + const [downloadedMediaPlayback, setDownloadedMediaPlayback] = useAtom(__playback_downloadedMediaAtom) + const [torrentStreamingPlayback, setTorrentStreamingPlayback] = useAtom(__playback_torrentStreamingAtom) + const [electronPlaybackMethod, setElectronPlaybackMethod] = useAtom(__playback_electronPlaybackMethodAtom) + return { + downloadedMediaPlayback, + setDownloadedMediaPlayback, + torrentStreamingPlayback, + setTorrentStreamingPlayback, + electronPlaybackMethod, + setElectronPlaybackMethod, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const __playback_externalPlayerLink = atomWithStorage<string>("sea-playback-external-player-link", "") +export const __playback_externalPlayerLink_encodePath = atomWithStorage<boolean>("sea-playback-external-player-link-encode-path", false) + +export function useExternalPlayerLink() { + const [externalPlayerLink, setExternalPlayerLink] = useAtom(__playback_externalPlayerLink) + const [encodePath, setEncodePath] = useAtom(__playback_externalPlayerLink_encodePath) + return { + externalPlayerLink, + setExternalPlayerLink, + encodePath, + setEncodePath, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +const __playback_playNext = atom<number | null>(null) + +export function usePlayNext() { + const [playNext, _setPlayNext] = useAtom(__playback_playNext) + + function setPlayNext(ep: Nullish<number>, callback: () => void) { + if (!ep) return + _setPlayNext(ep) + callback() + } + + return { + playNext, + setPlayNext, + resetPlayNext: () => _setPlayNext(null), + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/server-status.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/server-status.atoms.ts new file mode 100644 index 0000000..a3f21d7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/server-status.atoms.ts @@ -0,0 +1,10 @@ +import { Status } from "@/api/generated/types" +import { atom } from "jotai" +import { atomWithImmer } from "jotai-immer" +import { atomWithStorage } from "jotai/utils" + +export const serverStatusAtom = atomWithImmer<Status | undefined>(undefined) + +export const isLoginModalOpenAtom = atom(false) + +export const serverAuthTokenAtom = atomWithStorage<string | undefined>("sea-server-auth-token", undefined, undefined, { getOnInit: true }) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/sync.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/sync.atoms.ts new file mode 100644 index 0000000..e69228c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/sync.atoms.ts @@ -0,0 +1,9 @@ +import { atom } from "jotai" +import { useAtom } from "jotai/react" + +const syncIsActiveAtom = atom(false) + +export function useSyncIsActive() { + const [syncIsActive, setSyncIsActive] = useAtom(syncIsActiveAtom) + return { syncIsActive, setSyncIsActive } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/websocket.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/websocket.atoms.ts new file mode 100644 index 0000000..dfed067 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_atoms/websocket.atoms.ts @@ -0,0 +1,7 @@ +import { atom } from "jotai" +import { createContext } from "react" + +export const WebSocketContext = createContext<WebSocket | null>(null) + +export const websocketAtom = atom<WebSocket | null>(null) + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-crash-screen.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-crash-screen.tsx new file mode 100644 index 0000000..17afd8d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-crash-screen.tsx @@ -0,0 +1,26 @@ +"use client" + +import React from "react" + +export function ElectronCrashScreenError() { + const [msg, setMsg] = React.useState("") + + React.useEffect(() => { + + if (window.electron) { + const u = window.electron.on("crash", (msg: string) => { + console.log("Received crash event", msg) + setMsg(msg) + }) + return () => { + u?.() + } + } + }, []) + + return ( + <p className="px-4"> + {msg || "An error occurred"} + </p> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-manager.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-manager.tsx new file mode 100644 index 0000000..399a459 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-manager.tsx @@ -0,0 +1,19 @@ +"use client" + +import React from "react" + +type ElectronManagerProps = { + children?: React.ReactNode +} + +// This is only rendered on the Electron Desktop client +export function ElectronManager(props: ElectronManagerProps) { + const { + children, + ...rest + } = props + + // No-op + + return null +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-padding.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-padding.tsx new file mode 100644 index 0000000..1b6b00b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-padding.tsx @@ -0,0 +1,11 @@ +import React from "react" + +export function ElectronSidebarPaddingMacOS() { + if (window.electron?.platform !== "darwin") return null + + return ( + <div className="h-4"> + {/* Extra padding for macOS */} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-restart-server-prompt.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-restart-server-prompt.tsx new file mode 100644 index 0000000..bd8dff0 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-restart-server-prompt.tsx @@ -0,0 +1,102 @@ +import { isUpdateInstalledAtom, isUpdatingAtom } from "@/app/(main)/_tauri/tauri-update-modal" +import { websocketConnectedAtom, websocketConnectionErrorCountAtom } from "@/app/websocket-provider" +import { LuffyError } from "@/components/shared/luffy-error" +import { Button } from "@/components/ui/button" +import { LoadingOverlay } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { useAtom, useAtomValue } from "jotai/react" +import React from "react" +import { toast } from "sonner" + +export function ElectronRestartServerPrompt() { + + const [hasRendered, setHasRendered] = React.useState(false) + + const [isConnected, setIsConnected] = useAtom(websocketConnectedAtom) + const connectionErrorCount = useAtomValue(websocketConnectionErrorCountAtom) + const [hasClickedRestarted, setHasClickedRestarted] = React.useState(false) + const isUpdatedInstalled = useAtomValue(isUpdateInstalledAtom) + const isUpdating = useAtomValue(isUpdatingAtom) + + React.useEffect(() => { + (async () => { + if (window.electron) { + await window.electron.window.getCurrentWindow() // TODO: Isn't called + setHasRendered(true) + } + })() + }, []) + + const handleRestart = async () => { + setHasClickedRestarted(true) + toast.info("Restarting server...") + if (window.electron) { + window.electron.emit("restart-server") + React.startTransition(() => { + setTimeout(() => { + setHasClickedRestarted(false) + }, 5000) + }) + } + } + + // Try to reconnect automatically + const tryAutoReconnectRef = React.useRef(true) + React.useEffect(() => { + if (!isConnected && connectionErrorCount >= 10 && tryAutoReconnectRef.current && !isUpdatedInstalled) { + tryAutoReconnectRef.current = false + console.log("Connection error count reached 10, restarting server automatically") + handleRestart() + } + }, [connectionErrorCount]) + + React.useEffect(() => { + if (isConnected) { + setHasClickedRestarted(false) + tryAutoReconnectRef.current = true + } + }, [isConnected]) + + if (!hasRendered) return null + + // Not connected for 10 seconds + return ( + <> + {(!isConnected && connectionErrorCount < 10 && !isUpdating && !isUpdatedInstalled) && ( + <LoadingOverlay className="fixed left-0 top-0 z-[9999]"> + <p> + The server connection has been lost. Please wait while we attempt to reconnect. + </p> + </LoadingOverlay> + )} + + <Modal + open={!isConnected && connectionErrorCount >= 10 && !isUpdatedInstalled} + onOpenChange={() => {}} + hideCloseButton + contentClass="max-w-2xl" + > + <LuffyError> + <div className="space-y-4 flex flex-col items-center"> + <p className="text-lg max-w-sm"> + The background server process has stopped responding. Please restart it to continue. + </p> + + <Button + onClick={handleRestart} + loading={hasClickedRestarted} + intent="white-outline" + size="lg" + className="rounded-full" + > + Restart server + </Button> + <p className="text-[--muted] text-sm max-w-xl"> + If this message persists after multiple tries, please relaunch the application. + </p> + </div> + </LuffyError> + </Modal> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-update-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-update-modal.tsx new file mode 100644 index 0000000..3815025 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-update-modal.tsx @@ -0,0 +1,205 @@ +"use client" +import { useGetLatestUpdate } from "@/api/hooks/releases.hooks" +import { UpdateChangelogBody } from "@/app/(main)/_features/update/update-helper" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { SeaLink } from "@/components/shared/sea-link" +import { Alert } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { VerticalMenu } from "@/components/ui/vertical-menu" +import { logger } from "@/lib/helpers/debug" +import { WSEvents } from "@/lib/server/ws-events" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" +import { AiFillExclamationCircle } from "react-icons/ai" +import { BiLinkExternal } from "react-icons/bi" +import { FiArrowRight } from "react-icons/fi" +import { GrInstall } from "react-icons/gr" +import { toast } from "sonner" + +type UpdateModalProps = { + collapsed?: boolean +} + +const updateModalOpenAtom = atom<boolean>(false) +export const isUpdateInstalledAtom = atom<boolean>(false) +export const isUpdatingAtom = atom<boolean>(false) + +export function ElectronUpdateModal(props: UpdateModalProps) { + const serverStatus = useServerStatus() + const [updateModalOpen, setUpdateModalOpen] = useAtom(updateModalOpenAtom) + const [isUpdating, setIsUpdating] = useAtom(isUpdatingAtom) + + const { data: updateData, isLoading, refetch } = useGetLatestUpdate(!!serverStatus && !serverStatus?.settings?.library?.disableUpdateCheck) + + useWebsocketMessageListener({ + type: WSEvents.CHECK_FOR_UPDATES, + onMessage: () => { + refetch().then(() => checkElectronUpdate()) + }, + }) + + const [updateLoading, setUpdateLoading] = React.useState(true) + const [electronUpdate, setUpdate] = React.useState<boolean>(false) + const [updateError, setUpdateError] = React.useState("") + const [isInstalled, setIsInstalled] = useAtom(isUpdateInstalledAtom) + + const checkElectronUpdate = React.useCallback(() => { + try { + if (window.electron) { + // Check if the update is available + setUpdateLoading(true); + window.electron.checkForUpdates() + .then((updateAvailable: boolean) => { + setUpdate(updateAvailable) + setUpdateLoading(false) + }) + .catch((error: any) => { + logger("ELECTRON").error("Failed to check for updates", error) + setUpdateError(JSON.stringify(error)) + setUpdateLoading(false) + }) + } + } + catch (e) { + logger("ELECTRON").error("Failed to check for updates", e) + setIsUpdating(false) + } + }, []) + + React.useEffect(() => { + checkElectronUpdate() + + // Listen for update events from Electron + if (window.electron) { + // Register listeners for update events + const removeUpdateDownloaded = window.electron.on("update-downloaded", () => { + toast.info("Update downloaded and ready to install") + }) + + const removeUpdateError = window.electron.on("update-error", (error: string) => { + logger("ELECTRON").error("Update error", error) + toast.error(`Update error: ${error}`) + setIsUpdating(false) + }) + + return () => { + // Clean up listeners + removeUpdateDownloaded?.() + removeUpdateError?.() + } + } + }, []) + + React.useEffect(() => { + if (updateData && updateData.release) { + setUpdateModalOpen(true) + } + }, [updateData]) + + async function handleInstallUpdate() { + if (!electronUpdate || isUpdating) return + + try { + setIsUpdating(true) + + // Tell Electron to download and install the update + if (window.electron) { + toast.info("Downloading update...") + + // Kill the currently running server before installing update + try { + toast.info("Shutting down server...") + await window.electron.killServer() + } + catch (e) { + logger("ELECTRON").error("Failed to kill server", e) + } + + // Install update + toast.info("Installing update...") + await window.electron.installUpdate() + setIsInstalled(true) + + // Electron will automatically restart the app + toast.info("Update installed. Restarting app...") + } + } + catch (e) { + logger("ELECTRON").error("Failed to download update", e) + toast.error(`Failed to download update: ${JSON.stringify(e)}`) + setIsUpdating(false) + } + } + + if (serverStatus?.settings?.library?.disableUpdateCheck) return null + + if (isLoading || updateLoading || !updateData || !updateData.release) return null + + if (isInstalled) return ( + <div className="fixed top-0 left-0 w-full h-full bg-[--background] flex items-center z-[9999]"> + <div className="container max-w-4xl py-10"> + <div className="mb-4 flex justify-center w-full"> + <img src="/logo_2.png" alt="logo" className="w-36 h-auto" /> + </div> + <p className="text-center text-lg"> + Update installed. The app will restart automatically. + </p> + </div> + </div> + ) + + return ( + <> + <VerticalMenu + collapsed={props.collapsed} + items={[ + { + iconType: AiFillExclamationCircle, + name: "Update available", + onClick: () => setUpdateModalOpen(true), + }, + ]} + itemIconClass="text-brand-300" + /> + <Modal + open={updateModalOpen} + onOpenChange={v => !isUpdating && setUpdateModalOpen(v)} + contentClass="max-w-3xl" + > + <div className="space-y-2"> + <h3 className="text-center">A new update is available!</h3> + <h4 className="font-bold flex gap-2 text-center items-center justify-center"> + <span className="text-[--muted]">{updateData.current_version}</span> <FiArrowRight /> + <span className="text-indigo-200">{updateData.release.version}</span></h4> + + {!electronUpdate && ( + <Alert intent="warning"> + This update is not yet available for desktop clients. + Wait a few minutes or check the GitHub page for more information. + </Alert> + )} + + <UpdateChangelogBody updateData={updateData} /> + + <div className="flex gap-2 w-full !mt-4"> + {!!electronUpdate && <Button + leftIcon={<GrInstall className="text-2xl" />} + onClick={handleInstallUpdate} + loading={isUpdating} + disabled={isLoading} + > + Update now + </Button>} + <div className="flex flex-1" /> + <SeaLink href={updateData?.release?.html_url || ""} target="_blank"> + <Button intent="white-subtle" rightIcon={<BiLinkExternal />}>See on GitHub</Button> + </SeaLink> + </div> + </div> + </Modal> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-window-title-bar.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-window-title-bar.tsx new file mode 100644 index 0000000..caf7db6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_electron/electron-window-title-bar.tsx @@ -0,0 +1,132 @@ +"use client" + +import { IconButton } from "@/components/ui/button" +import React from "react" +import { VscChromeClose, VscChromeMaximize, VscChromeMinimize, VscChromeRestore } from "react-icons/vsc" + +type ElectronWindowTitleBarProps = { + children?: React.ReactNode +} + +export function ElectronWindowTitleBar(props: ElectronWindowTitleBarProps) { + const { + children, + ...rest + } = props + + const [showControls, setShowControls] = React.useState(true) + const [displayDragRegion, setDisplayDragRegion] = React.useState(true) + const [maximized, setMaximized] = React.useState(false) + const [currentPlatform, setCurrentPlatform] = React.useState("") + + // Handle window control actions + function handleMinimize() { + if (window.electron?.window) { + window.electron.window.minimize() + } + } + + function toggleMaximized() { + if (window.electron?.window) { + window.electron.window.toggleMaximize() + } + } + + function handleClose() { + if (window.electron?.window) { + window.electron.window.close() + } + } + + // Check fullscreen state + function onFullscreenChange() { + if (window.electron?.window) { + window.electron.window.isFullscreen().then((fullscreen: boolean) => { + setShowControls(!fullscreen) + setDisplayDragRegion(!fullscreen) + }) + } + } + + React.useEffect(() => { + // Get platform + if (window.electron) { + setCurrentPlatform(window.electron.platform) + } + + // Setup window event listeners + const removeMaximizedListener = window.electron?.on("window:maximized", () => { + setMaximized(true) + }) + + const removeUnmaximizedListener = window.electron?.on("window:unmaximized", () => { + setMaximized(false) + }) + + const removeFullscreenListener = window.electron?.on("window:fullscreen", (isFullscreen: boolean) => { + setShowControls(!isFullscreen) + setDisplayDragRegion(!isFullscreen) + }) + + // Check window capabilities + // if (window.electron?.window) { + // Promise.all([ + // window.electron.window.isMinimizable(), + // window.electron.window.isMaximizable(), + // window.electron.window.isClosable(), + // window.electron.window.isMaximized() + // ]).then(([minimizable, maximizable, closable, isMaximized]) => { + // setMaximized(isMaximized) + // setShowControls(minimizable || maximizable || closable) + // }) + // } + + document.addEventListener("fullscreenchange", onFullscreenChange) + + // Cleanup + return () => { + if (removeMaximizedListener) removeMaximizedListener() + if (removeUnmaximizedListener) removeUnmaximizedListener() + if (removeFullscreenListener) removeFullscreenListener() + document.removeEventListener("fullscreenchange", onFullscreenChange) + } + }, []) + + // Only show on Windows and macOS + if (!(currentPlatform === "win32" || currentPlatform === "darwin")) return null + + return ( + <> + <div + className="__electron-window-title-bar scroll-locked-offset bg-transparent fixed top-0 left-0 h-10 z-[999] w-full bg-opacity-90 flex pointer-events-[all]" + style={{ + pointerEvents: "all", + }} + > + {displayDragRegion && + <div className="flex flex-1 cursor-grab active:cursor-grabbing" style={{ WebkitAppRegion: "drag" } as any}></div>} + {(currentPlatform === "win32" && showControls) && + <div className="flex h-10 items-center justify-center gap-1 mr-2 !cursor-default"> + <IconButton + className="outline-none w-11 size-8 rounded-lg duration-0 shadow-none text-white hover:text-white bg-transparent hover:bg-[rgba(255,255,255,0.05)] active:text-white active:bg-[rgba(255,255,255,0.1)]" + icon={<VscChromeMinimize className="text-[0.95rem]" />} + onClick={handleMinimize} + tabIndex={-1} + /> + <IconButton + className="outline-none w-11 size-8 rounded-lg duration-0 shadow-none text-white hover:text-white bg-transparent hover:bg-[rgba(255,255,255,0.05)] active:text-white active:bg-[rgba(255,255,255,0.1)]" + icon={maximized ? <VscChromeRestore className="text-[0.95rem]" /> : <VscChromeMaximize className="text-[0.95rem]" />} + onClick={toggleMaximized} + tabIndex={-1} + /> + <IconButton + className="outline-none w-11 size-8 rounded-lg duration-0 shadow-none text-white hover:text-white bg-transparent hover:bg-red-500 active:bg-red-600 active:text-white" + icon={<VscChromeClose className="text-[0.95rem]" />} + onClick={handleClose} + tabIndex={-1} + /> + </div>} + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/anilist/refresh-anilist-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anilist/refresh-anilist-button.tsx new file mode 100644 index 0000000..4e24c25 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anilist/refresh-anilist-button.tsx @@ -0,0 +1,52 @@ +"use client" +import { useRefreshAnimeCollection } from "@/api/hooks/anilist.hooks" +import { Button } from "@/components/ui/button" +import { Tooltip } from "@/components/ui/tooltip" +import React from "react" +import { IoReload } from "react-icons/io5" + +interface RefreshAnilistButtonProps { + children?: React.ReactNode +} + +export const RefreshAnilistButton: React.FC<RefreshAnilistButtonProps> = (props) => { + + const { children, ...rest } = props + + /** + * @description + * - Asks the server to fetch an up-to-date version of the user's AniList collection. + */ + const { mutate, isPending } = useRefreshAnimeCollection() + + return ( + <> + <Tooltip + data-refresh-anilist-button-tooltip + trigger={ + <Button + data-refresh-anilist-button + onClick={() => mutate()} + intent="warning-subtle" + size="sm" + rightIcon={<IoReload />} + loading={isPending} + leftIcon={<svg + xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" + viewBox="0 0 24 24" role="img" + > + <path + d="M6.361 2.943 0 21.056h4.942l1.077-3.133H11.4l1.052 3.133H22.9c.71 0 1.1-.392 1.1-1.101V17.53c0-.71-.39-1.101-1.1-1.101h-6.483V4.045c0-.71-.392-1.102-1.101-1.102h-2.422c-.71 0-1.101.392-1.101 1.102v1.064l-.758-2.166zm2.324 5.948 1.688 5.018H7.144z" + /> + </svg>} + className={""} + > + </Button> + } + > + Refresh AniList + </Tooltip> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/anilist-media-entry-list.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/anilist-media-entry-list.tsx new file mode 100644 index 0000000..63611ce --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/anilist-media-entry-list.tsx @@ -0,0 +1,47 @@ +import { AL_AnimeCollection_MediaListCollection_Lists } from "@/api/generated/types" +import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import React from "react" + + +type AnilistAnimeEntryListProps = { + list: AL_AnimeCollection_MediaListCollection_Lists | undefined + type: "anime" | "manga" +} + +/** + * Displays a list of media entry card from an Anilist media list collection. + */ +export function AnilistAnimeEntryList(props: AnilistAnimeEntryListProps) { + + const { + list, + type, + ...rest + } = props + + return ( + <MediaCardLazyGrid itemCount={list?.entries?.filter(Boolean)?.length || 0} data-anilist-anime-entry-list> + {list?.entries?.filter(Boolean)?.map((entry) => ( + <MediaEntryCard + key={`${entry.media?.id}`} + listData={{ + progress: entry.progress!, + score: entry.score!, + status: entry.status!, + startedAt: entry.startedAt?.year ? new Date(entry.startedAt.year, + (entry.startedAt.month || 1) - 1, + entry.startedAt.day || 1).toISOString() : undefined, + completedAt: entry.completedAt?.year ? new Date(entry.completedAt.year, + (entry.completedAt.month || 1) - 1, + entry.completedAt.day || 1).toISOString() : undefined, + }} + showLibraryBadge + media={entry.media!} + showListDataButton + type={type} + /> + ))} + </MediaCardLazyGrid> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/episode-card.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/episode-card.tsx new file mode 100644 index 0000000..7d1d34a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/episode-card.tsx @@ -0,0 +1,240 @@ +import { Anime_Episode } from "@/api/generated/types" +import { SeaContextMenu } from "@/app/(main)/_features/context-menu/sea-context-menu" +import { EpisodeItemBottomGradient } from "@/app/(main)/_features/custom-ui/item-bottom-gradients" +import { useMediaPreviewModal } from "@/app/(main)/_features/media/_containers/media-preview-modal" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { imageShimmer } from "@/components/shared/image-helpers" +import { ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuTrigger } from "@/components/ui/context-menu" +import { cn } from "@/components/ui/core/styling" +import { ProgressBar } from "@/components/ui/progress-bar" +import { getImageUrl } from "@/lib/server/assets" +import { useThemeSettings } from "@/lib/theme/hooks" +import Image from "next/image" +import { usePathname, useRouter } from "next/navigation" +import React from "react" +import { AiFillPlayCircle } from "react-icons/ai" +import { PluginEpisodeCardContextMenuItems } from "../../plugin/actions/plugin-actions" + +type EpisodeCardProps = { + title: React.ReactNode + actionIcon?: React.ReactElement | null + image?: string + onClick?: () => void + topTitle?: string + meta?: string + type?: "carousel" | "grid" + isInvalid?: boolean + containerClass?: string + episodeNumber?: number + progressNumber?: number + progressTotal?: number + mRef?: React.RefObject<HTMLDivElement> + hasDiscrepancy?: boolean + length?: string | number | null + imageClass?: string + badge?: React.ReactNode + percentageComplete?: number + minutesRemaining?: number + anime?: { + id?: number + image?: string + title?: string + } + episode?: Anime_Episode // Optional, used for plugin actions +} & Omit<React.ComponentPropsWithoutRef<"div">, "title"> + +export function EpisodeCard(props: EpisodeCardProps) { + + const { + children, + actionIcon = props.actionIcon !== null ? <AiFillPlayCircle className="opacity-50" /> : undefined, + image, + onClick, + topTitle, + meta, + title, + type = "carousel", + isInvalid, + className, + containerClass, + mRef, + episodeNumber, + progressTotal, + progressNumber, + hasDiscrepancy, + length, + imageClass, + badge, + percentageComplete, + minutesRemaining, + anime, + episode, + ...rest + } = props + + const router = useRouter() + const pathname = usePathname() + const serverStatus = useServerStatus() + const ts = useThemeSettings() + const { setPreviewModalMediaId } = useMediaPreviewModal() + + const showAnimeInfo = ts.showEpisodeCardAnimeInfo && !!anime + const showTotalEpisodes = React.useMemo(() => !!progressTotal && progressTotal > 1, [progressTotal]) + const offset = React.useMemo(() => hasDiscrepancy ? 1 : 0, [hasDiscrepancy]) + + const Meta = () => ( + <div data-episode-card-info-container className="relative z-[3] w-full space-y-0"> + <p + data-episode-card-title + className="w-[80%] line-clamp-1 text-md md:text-lg transition-colors duration-200 text-[--foreground] font-semibold" + > + {topTitle?.replaceAll("`", "'")} + </p> + <div data-episode-card-info-content className="w-full justify-between flex flex-none items-center"> + <p data-episode-card-subtitle className="line-clamp-1 flex items-center"> + <span className="flex-none text-base md:text-xl font-medium">{title}{showTotalEpisodes ? + <span className="opacity-40">{` / `}{progressTotal! - offset}</span> + : ``}</span> + <span className="text-[--muted] text-base md:text-xl ml-2 font-normal line-clamp-1">{showAnimeInfo + ? "- " + anime.title + : ""}</span> + </p> + {(!!meta || !!length) && ( + <p data-episode-card-meta-text className="text-[--muted] flex-none ml-2 text-sm md:text-base line-clamp-2 text-right"> + {meta}{!!meta && !!length && ` • `}{length ? `${length}m` : ""} + </p>)} + </div> + </div> + ) + + return ( + <SeaContextMenu + hideMenuIf={!anime?.id} + content={ + <ContextMenuGroup> + <ContextMenuLabel className="text-[--muted] line-clamp-1 py-0 my-2"> + {anime?.title} + </ContextMenuLabel> + + {pathname !== "/entry" && <> + <ContextMenuItem + onClick={() => { + if (!serverStatus?.isOffline) { + router.push(`/entry?id=${anime?.id}`) + } else { + router.push(`/offline/entry/anime?id=${anime?.id}`) + } + }} + > + Open page + </ContextMenuItem> + {!serverStatus?.isOffline && <ContextMenuItem + onClick={() => { + setPreviewModalMediaId(anime?.id || 0, "anime") + }} + > + Preview + </ContextMenuItem>} + + </>} + + <PluginEpisodeCardContextMenuItems episode={props.episode} /> + + </ContextMenuGroup> + } + > + <ContextMenuTrigger> + <div + ref={mRef} + className={cn( + "rounded-lg overflow-hidden space-y-2 flex-none group/episode-card cursor-pointer", + "select-none", + type === "carousel" && "w-full", + type === "grid" && "aspect-[4/2] w-72 lg:w-[26rem]", + className, + containerClass, + )} + onClick={onClick} + data-episode-card + data-episode-number={episodeNumber} + data-media-id={anime?.id} + data-progress-total={progressTotal} + data-progress-number={progressNumber} + {...rest} + > + <div data-episode-card-image-container className="w-full h-full rounded-lg overflow-hidden z-[1] aspect-[4/2] relative"> + {!!image ? <Image + data-episode-card-image + src={getImageUrl(image)} + alt={""} + fill + quality={100} + placeholder={imageShimmer(700, 475)} + sizes="20rem" + className={cn( + "object-cover rounded-lg object-center transition lg:group-hover/episode-card:scale-105 duration-200", + imageClass, + )} + /> : <div + data-episode-card-image-bottom-gradient + className="h-full block rounded-lg absolute w-full bg-gradient-to-t from-gray-800 to-transparent z-[2]" + ></div>} + {/*[CUSTOM UI] BOTTOM GRADIENT*/} + <EpisodeItemBottomGradient /> + + {(serverStatus?.settings?.library?.enableWatchContinuity && !!percentageComplete) && + <div + data-episode-card-progress-bar-container + className="absolute bottom-0 left-0 w-full z-[3]" + data-episode-number={episodeNumber} + data-media-id={anime?.id} + data-progress-total={progressTotal} + data-progress-number={progressNumber} + > + <ProgressBar value={percentageComplete} size="xs" /> + {minutesRemaining && <div className="absolute bottom-2 right-2"> + <p className="text-[--muted] text-sm">{minutesRemaining}m left</p> + </div>} + </div>} + + <div + data-episode-card-action-icon + className={cn( + "group-hover/episode-card:opacity-100 text-6xl text-gray-200", + "cursor-pointer opacity-0 transition-opacity bg-gray-950 bg-opacity-60 z-[2] absolute w-[105%] h-[105%] items-center justify-center", + "hidden md:flex", + )} + > + {actionIcon && actionIcon} + </div> + + {isInvalid && + <p data-episode-card-invalid-metadata className="text-red-300 opacity-50 absolute left-2 bottom-2 z-[2]">No metadata + found</p>} + </div> + {(showAnimeInfo) ? <div data-episode-card-anime-info-container className="flex gap-3 items-center"> + <div + data-episode-card-anime-image-container + className="flex-none w-12 aspect-[5/6] rounded-lg overflow-hidden z-[1] relative" + > + {!!anime?.image && <Image + data-episode-card-anime-image + src={getImageUrl(anime.image)} + alt={""} + fill + quality={100} + placeholder={imageShimmer(700, 475)} + sizes="20rem" + className={cn( + "object-cover rounded-lg object-center transition lg:group-hover/episode-card:scale-105 duration-200", + imageClass, + )} + />} + </div> + <Meta /> + </div> : <Meta />} + </div> + </ContextMenuTrigger> + </SeaContextMenu> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/episode-grid-item.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/episode-grid-item.tsx new file mode 100644 index 0000000..82fae28 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/episode-grid-item.tsx @@ -0,0 +1,218 @@ +import { AL_BaseAnime } from "@/api/generated/types" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { imageShimmer } from "@/components/shared/image-helpers" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/components/ui/core/styling" +import { ProgressBar } from "@/components/ui/progress-bar" +import { getImageUrl } from "@/lib/server/assets" +import { useThemeSettings } from "@/lib/theme/hooks" +import Image from "next/image" +import React from "react" +import { AiFillPlayCircle, AiFillWarning } from "react-icons/ai" + +type EpisodeGridItemProps = { + media: AL_BaseAnime, + children?: React.ReactNode + action?: React.ReactNode + image?: string | null + onClick?: () => void + title: string, + episodeTitle?: string | null + description?: string | null + fileName?: string + isWatched?: boolean + isSelected?: boolean + unoptimizedImage?: boolean + isInvalid?: boolean + className?: string + disabled?: boolean + actionIcon?: React.ReactElement | null + isFiller?: boolean + length?: string | number | null + imageClassName?: string + imageContainerClassName?: string + episodeTitleClassName?: string + percentageComplete?: number + minutesRemaining?: number + episodeNumber?: number + progressNumber?: number +} + +export const EpisodeGridItem: React.FC<EpisodeGridItemProps & React.ComponentPropsWithoutRef<"div">> = (props) => { + + const { + children, + action, + image, + onClick, + episodeTitle, + description, + title, + fileName, + media, + isWatched, + isSelected, + unoptimizedImage, + isInvalid, + imageClassName, + imageContainerClassName, + className, + disabled, + isFiller, + length, + actionIcon = props.actionIcon !== null ? <AiFillPlayCircle data-episode-grid-item-action-icon className="opacity-70 text-4xl" /> : undefined, + episodeTitleClassName, + percentageComplete, + minutesRemaining, + episodeNumber, + progressNumber, + ...rest + } = props + + const serverStatus = useServerStatus() + const ts = useThemeSettings() + + return <> + <div + data-episode-grid-item + data-media-id={media.id} + data-media-type={media.type} + data-filename={fileName} + data-episode-number={episodeNumber} + data-progress-number={progressNumber} + data-is-watched={isWatched} + data-description={description} + data-episode-title={episodeTitle} + data-title={title} + data-file-name={fileName} + data-is-invalid={isInvalid} + data-is-filler={isFiller} + className={cn( + "max-w-full", + "rounded-lg relative transition group/episode-list-item select-none", + !!ts.libraryScreenCustomBackgroundImage && ts.libraryScreenCustomBackgroundOpacity > 5 ? "bg-[--background] p-3" : "py-3", + "pr-12", + disabled && "cursor-not-allowed opacity-50 pointer-events-none", + className, + )} + {...rest} + > + + {isFiller && ( + <Badge + data-episode-grid-item-filler-badge + className={cn( + "font-semibold absolute top-3 left-0 z-[5] text-white bg-orange-800 !bg-opacity-100 rounded-[--radius-md] text-base rounded-bl-none rounded-tr-none", + !!ts.libraryScreenCustomBackgroundImage && ts.libraryScreenCustomBackgroundOpacity > 5 && "top-3 left-3", + )} + intent="gray" + size="lg" + >Filler</Badge> + )} + + <div + data-episode-grid-item-container + className={cn( + "flex gap-4 relative", + )} + > + <div + data-episode-grid-item-image-container + className={cn( + "w-36 h-28 lg:w-44 lg:h-32", + (ts.hideEpisodeCardDescription) && "w-36 h-28 lg:w-40 lg:h-28", + "flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden", + "group/ep-item-img-container", + onClick && "cursor-pointer", + { + "border-2 border-red-700": isInvalid, + "border-2 border-yellow-900": isFiller, + "border-2 border-[--brand]": isSelected, + }, + + imageContainerClassName, + )} + onClick={onClick} + > + <div data-episode-grid-item-image-overlay className="absolute z-[1] rounded-[--radius-md] w-full h-full"></div> + <div + data-episode-grid-item-image-background + className="bg-[--background] absolute z-[0] rounded-[--radius-md] w-full h-full" + ></div> + {!!onClick && <div + data-episode-grid-item-action-overlay + className={cn( + "absolute inset-0 bg-gray-950 bg-opacity-60 z-[1] flex items-center justify-center", + "transition-opacity opacity-0 group-hover/ep-item-img-container:opacity-100", + )} + > + {actionIcon && actionIcon} + </div>} + {(image || media.coverImage?.medium) && <Image + data-episode-grid-item-image + src={getImageUrl(image || media.coverImage?.medium || "")} + alt="episode image" + fill + quality={60} + placeholder={imageShimmer(700, 475)} + sizes="10rem" + className={cn("object-cover object-center transition select-none", { + "opacity-25 lg:group-hover/episode-list-item:opacity-100": isWatched && !isSelected, + }, imageClassName)} + data-src={image} + />} + + {(serverStatus?.settings?.library?.enableWatchContinuity && !!percentageComplete && !isWatched) && + <div data-episode-grid-item-progress-bar-container className="absolute bottom-0 left-0 w-full z-[3]"> + <ProgressBar value={percentageComplete} size="xs" /> + </div>} + </div> + + <div data-episode-grid-item-content className="relative overflow-hidden"> + {isInvalid && <p data-episode-grid-item-invalid-metadata className="flex gap-2 text-red-300 items-center"><AiFillWarning + className="text-lg text-red-500" + /> Unidentified</p>} + {isInvalid && + <p data-episode-grid-item-invalid-metadata className="flex gap-2 text-red-200 text-sm items-center">No metadata found</p>} + + <p + className={cn( + !episodeTitle && "text-lg font-semibold", + !!episodeTitle && "transition line-clamp-2 text-base text-[--muted]", + // { "opacity-50 group-hover/episode-list-item:opacity-100": isWatched }, + )} + data-episode-grid-item-title + > + <span + className={cn( + "font-medium text-[--foreground]", + isSelected && "text-[--brand]", + )} + > + {title?.replaceAll("`", "'")}</span>{(!!episodeTitle && !!length) && + <span className="ml-4">{length}m</span>} + </p> + + {!!episodeTitle && + <p + data-episode-grid-item-episode-title + className={cn("text-md font-medium lg:text-lg text-gray-300 line-clamp-2 lg:!leading-6", + episodeTitleClassName)} + >{episodeTitle?.replaceAll("`", "'")}</p>} + + + {!!description && !ts.hideEpisodeCardDescription && + <p data-episode-grid-item-episode-description className="text-sm text-[--muted] line-clamp-2">{description.replaceAll("`", + "'")}</p>} + {!!fileName && !ts.hideDownloadedEpisodeCardFilename && <p data-episode-grid-item-filename className="text-xs tracking-wider opacity-75 line-clamp-1 mt-1">{fileName}</p>} + {children && children} + </div> + </div> + + {action && <div data-episode-grid-item-action className="absolute right-1 top-1 flex flex-col items-center"> + {action} + </div>} + </div> + </> + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/trailer-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/trailer-modal.tsx new file mode 100644 index 0000000..5e6324f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_components/trailer-modal.tsx @@ -0,0 +1,102 @@ +import { LuffyError } from "@/components/shared/luffy-error" +import { cn } from "@/components/ui/core/styling" +import { Drawer } from "@/components/ui/drawer" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import React from "react" + +type PlaylistsModalProps = { + trigger?: React.ReactElement + trailerId?: string | null + isOpen?: boolean + setIsOpen?: (v: boolean) => void +} + +export function TrailerModal(props: PlaylistsModalProps) { + + const { + trigger, + trailerId, + isOpen, + setIsOpen, + ...rest + } = props + + return ( + <> + <Drawer + open={isOpen} + onOpenChange={v => setIsOpen?.(v)} + trigger={trigger} + size="xl" + side="right" + contentClass="flex items-center justify-center" + > + <div + className="!mt-0 bg-[url(/pattern-2.svg)] z-[-1] w-full h-[5rem] absolute opacity-30 top-0 left-0 bg-no-repeat bg-right bg-cover" + > + <div + className="w-full absolute top-0 h-full bg-gradient-to-t from-[--background] to-transparent z-[-2]" + /> + </div> + + <Content trailerId={trailerId} /> + </Drawer> + </> + ) +} + +type ContentProps = { + trailerId?: string | null +} + +export function Content(props: ContentProps) { + + const { + trailerId, + ...rest + } = props + + const [loaded, setLoaded] = React.useState(true) + const [muted, setMuted] = React.useState(true) + + if (!trailerId) return <LuffyError title="No trailer found" /> + + return ( + <> + {!loaded && <LoadingSpinner className="" />} + <div + className={cn( + "relative aspect-video h-[85dvh] flex items-center overflow-hidden rounded-xl", + !loaded && "hidden", + )} + > + <iframe + src={`https://www.youtube.com/embed/${trailerId}`} + title="YouTube Video" + className="w-full aspect-video rounded-xl" + allowFullScreen + loading="lazy" // Lazy load the iframe + /> + {/*<video*/} + {/* src={`https://yewtu.be/latest_version?id=${animeDetails?.trailer?.id}&itag=18`}*/} + {/* className={cn(*/} + {/* "w-full h-full absolute left-0",*/} + {/* )}*/} + {/* playsInline*/} + {/* preload="none"*/} + {/* loop*/} + {/* autoPlay*/} + {/* muted={muted}*/} + {/* onLoadedData={() => setLoaded(true)}*/} + {/*/>*/} + {/*{<IconButton*/} + {/* intent="white-basic"*/} + {/* className="absolute bottom-4 left-4"*/} + {/* icon={muted ? <FaVolumeMute /> : <FaVolumeHigh />}*/} + {/* onClick={() => setMuted(p => !p)}*/} + {/*/>}*/} + </div> + </> + ) +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_containers/anime-auto-downloader-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_containers/anime-auto-downloader-button.tsx new file mode 100644 index 0000000..bb8517e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_containers/anime-auto-downloader-button.tsx @@ -0,0 +1,124 @@ +import { Anime_AutoDownloaderRule, Anime_Entry } from "@/api/generated/types" +import { useGetAutoDownloaderRulesByAnime } from "@/api/hooks/auto_downloader.hooks" +import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { AutoDownloaderRuleItem } from "@/app/(main)/auto-downloader/_components/autodownloader-rule-item" +import { AutoDownloaderRuleForm } from "@/app/(main)/auto-downloader/_containers/autodownloader-rule-form" +import { LuffyError } from "@/components/shared/luffy-error" +import { Button, IconButton } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { useBoolean } from "@/hooks/use-disclosure" +import { useAtomValue } from "jotai/react" +import React from "react" +import { BiPlus } from "react-icons/bi" +import { TbWorldDownload } from "react-icons/tb" + +type AnimeAutoDownloaderButtonProps = { + entry: Anime_Entry + size?: "sm" | "md" | "lg" +} + +export function AnimeAutoDownloaderButton(props: AnimeAutoDownloaderButtonProps) { + + const { + entry, + size, + ...rest + } = props + + const serverStatus = useServerStatus() + const { data: rules, isLoading } = useGetAutoDownloaderRulesByAnime(entry.mediaId, !!serverStatus?.settings?.autoDownloader?.enabled) + + const [isModalOpen, setIsModalOpen] = React.useState(false) + + if ( + isLoading + || !serverStatus?.settings?.autoDownloader?.enabled + || !entry.listData + ) return null + + const isTracked = !!rules?.length + + return ( + <> + <Modal + title="Auto Downloader" + contentClass="max-w-3xl" + open={isModalOpen} + onOpenChange={setIsModalOpen} + trigger={<IconButton + icon={isTracked ? <TbWorldDownload /> : <TbWorldDownload />} + loading={isLoading} + intent={isTracked ? "primary-subtle" : "gray-subtle"} + size={size} + {...rest} + />} + > + <Content entry={entry} rules={rules} /> + </Modal> + </> + ) +} + +type ContentProps = { + entry: Anime_Entry + rules: Anime_AutoDownloaderRule[] | undefined +} + +export function Content(props: ContentProps) { + + const { + entry, + rules, + ...rest + } = props + + const userMedia = useAtomValue(__anilist_userAnimeMediaAtom) + const createRuleModal = useBoolean(false) + + return ( + <div className="space-y-4"> + + <div className="flex w-full"> + <div className="flex-1"></div> + <Modal + open={createRuleModal.active} + onOpenChange={createRuleModal.set} + title="Create a new rule" + contentClass="max-w-3xl" + trigger={<Button + className="rounded-full" + intent="success-subtle" + leftIcon={<BiPlus />} + onClick={() => { + createRuleModal.on() + }} + > + New Rule + </Button>} + > + <AutoDownloaderRuleForm + mediaId={entry.mediaId} + type="create" + onRuleCreatedOrDeleted={() => createRuleModal.off()} + /> + </Modal> + </div> + + {!rules?.length && ( + <LuffyError title={null}> + No rules found for this anime. + </LuffyError> + )} + + {rules?.map(rule => ( + <AutoDownloaderRuleItem + key={rule.dbId} + rule={rule} + userMedia={userMedia} + /> + ))} + + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_containers/anime-entry-card-unwatched-badge.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_containers/anime-entry-card-unwatched-badge.tsx new file mode 100644 index 0000000..055fe4a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_containers/anime-entry-card-unwatched-badge.tsx @@ -0,0 +1,61 @@ +import { AL_BaseAnime, Anime_EntryLibraryData, Anime_NakamaEntryLibraryData, Nullish } from "@/api/generated/types" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/components/ui/core/styling" +import { anilist_getCurrentEpisodes } from "@/lib/helpers/media" +import { useThemeSettings } from "@/lib/theme/hooks" +import React from "react" +import { MdOutlinePlayCircleOutline } from "react-icons/md" + +type AnimeEntryCardUnwatchedBadgeProps = { + progress: number + media: Nullish<AL_BaseAnime> + libraryData: Nullish<Anime_EntryLibraryData> + nakamaLibraryData: Nullish<Anime_NakamaEntryLibraryData> +} + +export function AnimeEntryCardUnwatchedBadge(props: AnimeEntryCardUnwatchedBadgeProps) { + + const { + media, + libraryData, + progress, + nakamaLibraryData, + ...rest + } = props + + const { showAnimeUnwatchedCount } = useThemeSettings() + + if (!showAnimeUnwatchedCount) return null + + const progressTotal = anilist_getCurrentEpisodes(media) + const unwatched = progressTotal - (progress ?? 0) + + const unwatchedFromLibrary = nakamaLibraryData?.unwatchedCount ?? libraryData?.unwatchedCount ?? 0 + const isInLibrary = !!nakamaLibraryData?.mainFileCount || !!libraryData?.mainFileCount + + const unwatchedCount = isInLibrary ? unwatchedFromLibrary : unwatched + + if (unwatchedCount <= 0) return null + + return ( + <div + data-anime-entry-card-unwatched-badge-container + className={cn( + "flex w-full z-[5]", + )} + > + <Badge + intent="unstyled" + size="lg" + className="text-sm tracking-wide flex gap-1 items-center rounded-[--radius-md] border-0 bg-transparent px-1.5" + data-anime-entry-card-unwatched-badge + > + <MdOutlinePlayCircleOutline className="text-lg" /><span className="text-[--foreground] font-normal">{unwatchedCount}</span> + </Badge> + </div> + ) + + // return ( + // <MediaEntryProgressBadge progress={progress} progressTotal={progressTotal} {...rest} /> + // ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_containers/toggle-lock-files-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_containers/toggle-lock-files-button.tsx new file mode 100644 index 0000000..66342b2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/anime/_containers/toggle-lock-files-button.tsx @@ -0,0 +1,47 @@ +import { useAnimeEntryBulkAction } from "@/api/hooks/anime_entries.hooks" +import { IconButton, IconButtonProps } from "@/components/ui/button" +import { Tooltip } from "@/components/ui/tooltip" +import React, { memo } from "react" +import { BiLockOpenAlt } from "react-icons/bi" +import { VscVerified } from "react-icons/vsc" + +type ToggleLockFilesButtonProps = { + mediaId: number + allFilesLocked: boolean + size?: IconButtonProps["size"] +} + +export const ToggleLockFilesButton = memo((props: ToggleLockFilesButtonProps) => { + const { mediaId, allFilesLocked, size = "sm" } = props + const [isLocked, setIsLocked] = React.useState(allFilesLocked) + const { mutate: performBulkAction, isPending } = useAnimeEntryBulkAction(mediaId) + + React.useLayoutEffect(() => { + setIsLocked(allFilesLocked) + }, [allFilesLocked]) + + const handleToggle = React.useCallback(() => { + performBulkAction({ + mediaId, + action: "toggle-lock", + }) + setIsLocked(p => !p) + }, [mediaId]) + + return ( + <Tooltip + trigger={ + <IconButton + icon={isLocked ? <VscVerified /> : <BiLockOpenAlt />} + intent={isLocked ? "success-subtle" : "warning-subtle"} + size={size} + className="hover:opacity-60" + loading={isPending} + onClick={handleToggle} + /> + } + > + {isLocked ? "Unlock all files" : "Lock all files"} + </Tooltip> + ) +}) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/announcements.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/announcements.tsx new file mode 100644 index 0000000..3f68728 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/announcements.tsx @@ -0,0 +1,265 @@ +"use client" + +import { Updater_Announcement, Updater_AnnouncementAction, Updater_AnnouncementSeverity } from "@/api/generated/types" +import { useGetAnnouncements } from "@/api/hooks/status.hooks" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { Alert } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { useUpdateEffect } from "@/components/ui/core/hooks" +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import { logger } from "@/lib/helpers/debug" +import { WSEvents } from "@/lib/server/ws-events" +import { __isElectronDesktop__, __isTauriDesktop__ } from "@/types/constants" +import { useAtom } from "jotai" +import { atomWithStorage } from "jotai/utils" +import React from "react" +import { FiAlertTriangle, FiInfo } from "react-icons/fi" +import { useEffectOnce } from "react-use" +import { toast } from "sonner" + +const dismissedAnnouncementsAtom = atomWithStorage<string[]>("sea-dismissed-announcements", []) + +export function Announcements() { + const { data: announcements, mutate: getAnnouncements } = useGetAnnouncements() + const [dismissedAnnouncements, setDismissedAnnouncements] = useAtom(dismissedAnnouncementsAtom) + const [hasShownToasts, setHasShownToasts] = React.useState<string[]>([]) + + function handleCheckForAnnouncements() { + getAnnouncements({ + platform: __isElectronDesktop__ ? "denshi" : __isTauriDesktop__ ? "tauri" : "web", + }) + } + + useWebsocketMessageListener({ + type: WSEvents.CHECK_FOR_ANNOUNCEMENTS, + onMessage: () => { + handleCheckForAnnouncements() + }, + }) + + useEffectOnce(() => { + handleCheckForAnnouncements() + }) + + useUpdateEffect(() => { + if (announcements) { + logger("Announcement").info("Fetched announcements", announcements) + // Clean up dismissed announcements that are no longer in the announcements + setDismissedAnnouncements(prev => prev.filter(id => announcements.some(a => a.id === id))) + } + }, [announcements]) + + const filteredAnnouncements = React.useMemo(() => { + if (!announcements) return [] + + return announcements + .filter(announcement => { + if (announcement.notDismissible) return true + if (dismissedAnnouncements.includes(announcement.id)) return false + return true + }) + .sort((a, b) => b.priority - a.priority) + }, [announcements, dismissedAnnouncements]) + + const bannerAnnouncements = filteredAnnouncements.filter(a => a.type === "banner") + const dialogAnnouncements = filteredAnnouncements.filter(a => a.type === "dialog") + const toastAnnouncements = filteredAnnouncements.filter(a => a.type === "toast") + + const dismissAnnouncement = (id: string) => { + setDismissedAnnouncements(prev => [...prev, id]) + } + + const getSeverityIcon = (severity: Updater_AnnouncementSeverity) => { + switch (severity) { + case "info": + return <FiInfo className="size-5 mt-1" /> + case "warning": + return <FiAlertTriangle className="size-5 mt-1" /> + case "error": + return <FiAlertTriangle className="size-5 mt-1" /> + case "critical": + return <FiAlertTriangle className="size-5 mt-1" /> + default: + return <FiInfo className="size-5 mt-1" /> + } + } + + const getSeverityIntent = (severity: Updater_AnnouncementSeverity) => { + switch (severity) { + case "info": + return "info-basic" + case "warning": + return "warning-basic" + case "error": + return "alert-basic" + case "critical": + return "alert-basic" + default: + return "info-basic" + } + } + + const getSeverityBadgeIntent = (severity: Updater_AnnouncementSeverity) => { + switch (severity) { + case "info": + return "blue" + case "warning": + return "warning" + case "error": + return "alert" + case "critical": + return "alert-solid" + default: + return "blue" + } + } + + React.useEffect(() => { + toastAnnouncements.forEach(announcement => { + if (!hasShownToasts.includes(announcement.id)) { + const toastFunction = announcement.severity === "error" || announcement.severity === "critical" + ? toast.error + : announcement.severity === "warning" + ? toast.warning + : toast.info + + toastFunction(announcement.message, { + position: "top-right", + id: announcement.id, + duration: Infinity, + action: !announcement.notDismissible ? { + label: "OK", + onClick: () => dismissAnnouncement(announcement.id), + } : undefined, + onDismiss: !announcement.notDismissible ? () => dismissAnnouncement(announcement.id) : undefined, + onAutoClose: !announcement.notDismissible ? () => dismissAnnouncement(announcement.id) : undefined, + }) + + setHasShownToasts(prev => [...prev, announcement.id]) + } + }) + }, [toastAnnouncements, hasShownToasts]) + + const handleDialogClose = (announcement: Updater_Announcement) => { + if (!announcement.notDismissible) { + dismissAnnouncement(announcement.id) + } + } + + const handleActionClick = (action: Updater_AnnouncementAction) => { + if (action.type === "link" && action.url) { + window.open(action.url, "_blank") + } + } + + return ( + <> + {bannerAnnouncements.map((announcement, index) => ( + <div + key={announcement.id + "" + String(index)} className={cn( + "fixed bottom-0 left-0 right-0 z-[999] bg-[--background] border-b border-[--border] shadow-lg bg-gradient-to-br", + )} + > + <Alert + intent={getSeverityIntent(announcement.severity) as any} + title={announcement.title} + + description={<div className="space-y-2"> + <p> + {announcement.message} + </p> + {announcement.actions && announcement.actions.length > 0 && ( + <div className="flex gap-2"> + {announcement.actions.map((action, index) => ( + <Button + key={index} + size="sm" + intent="white-outline" + onClick={() => handleActionClick(action)} + > + {action.label} + </Button> + ))} + </div> + )} + </div>} + icon={getSeverityIcon(announcement.severity)} + onClose={!announcement.notDismissible ? () => dismissAnnouncement(announcement.id) : undefined} + className={cn( + "rounded-none border-0 border-t shadow-[0_0_10px_0_rgba(0,0,0,0.05)] bg-gradient-to-br", + announcement.severity === "critical" && "from-red-950/95 to-red-900/60 dark:text-red-100", + announcement.severity === "error" && "from-red-950/95 to-red-900/60 dark:text-red-100", + announcement.severity === "warning" && "from-amber-950/95 to-amber-900/60 dark:text-amber-100", + announcement.severity === "info" && "from-blue-950/95 to-blue-900/60 dark:text-blue-100", + )} + /> + </div> + ))} + + {dialogAnnouncements.map((announcement, index) => ( + <Modal + key={announcement.id + "" + String(index)} + open={true} + onOpenChange={(open) => { + if (!open) { + handleDialogClose(announcement) + } + }} + hideCloseButton={announcement.notDismissible} + title={ + <div className="flex items-center gap-2"> + <span + className={cn( + announcement.severity === "info" && "text-blue-300", + announcement.severity === "warning" && "text-amber-300", + announcement.severity === "error" && "text-red-300", + announcement.severity === "critical" && "text-red-300", + )} + > + {getSeverityIcon(announcement.severity)} + </span> + {announcement.title || "Announcement"} + {/* <Badge + intent={getSeverityBadgeIntent(announcement.severity) as any} + size="sm" + > + {announcement.severity.toUpperCase()} + </Badge> */} + </div> + } + overlayClass="bg-gray-950/10" + > + <div className="space-y-4"> + <p className="text-[--muted]"> + {announcement.message} + </p> + + <div className="flex gap-2 pt-2"> + {announcement.actions && announcement.actions.length > 0 && ( + <div className="flex gap-2 flex-wrap"> + {announcement.actions.map((action, index) => ( + <Button + key={index} + intent="gray-outline" + onClick={() => handleActionClick(action)} + > + {action.label} + </Button> + ))} + </div> + )} + <div className="flex-1" /> + <Button + intent="white" + onClick={() => handleDialogClose(announcement)} + > + OK + </Button> + </div> + </div> + </Modal> + ))} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/context-menu/sea-context-menu.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/context-menu/sea-context-menu.tsx new file mode 100644 index 0000000..5d373ac --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/context-menu/sea-context-menu.tsx @@ -0,0 +1,34 @@ +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { ContextMenuContent } from "@/components/ui/context-menu" +import { ContextMenu } from "@radix-ui/react-context-menu" + +export type SeaContextMenuProps = { + content: React.ReactNode + children?: React.ReactNode + availableWhenOffline?: boolean + hideMenuIf?: boolean +} + +export function SeaContextMenu(props: SeaContextMenuProps) { + + const { + content, + children, + availableWhenOffline = true, + hideMenuIf, + ...rest + } = props + + const serverStatus = useServerStatus() + + return ( + <ContextMenu data-sea-context-menu {...rest}> + {children} + + {(((serverStatus?.isOffline && availableWhenOffline) || !serverStatus?.isOffline) && !hideMenuIf) && + <ContextMenuContent className="max-w-xs" data-sea-context-menu-content> + {content} + </ContextMenuContent>} + </ContextMenu> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/custom-ui/custom-background-image.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/custom-ui/custom-background-image.tsx new file mode 100644 index 0000000..da24ab5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/custom-ui/custom-background-image.tsx @@ -0,0 +1,51 @@ +"use client" +import { cn } from "@/components/ui/core/styling" +import { getAssetUrl } from "@/lib/server/assets" +import { useThemeSettings } from "@/lib/theme/hooks" +import { motion } from "motion/react" +import React from "react" + +type CustomBackgroundImageProps = React.ComponentPropsWithoutRef<"div"> & {} + +export function CustomBackgroundImage(props: CustomBackgroundImageProps) { + + const { + className, + ...rest + } = props + + const ts = useThemeSettings() + + return ( + <> + {!!ts.libraryScreenCustomBackgroundImage && ( + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0 }} + transition={{ duration: 1, delay: 0.1 }} + className="fixed w-full h-full inset-0" + > + + {ts.libraryScreenCustomBackgroundBlur !== "" && <div + className="fixed w-full h-full inset-0 z-[0]" + style={{ backdropFilter: `blur(${ts.libraryScreenCustomBackgroundBlur})` }} + > + </div>} + + <div + className={cn( + "fixed w-full h-full inset-0 z-[-1] bg-no-repeat bg-cover bg-center transition-opacity duration-1000", + className, + )} + style={{ + backgroundImage: `url(${getAssetUrl(ts.libraryScreenCustomBackgroundImage)})`, + opacity: ts.libraryScreenCustomBackgroundOpacity / 100, + }} + {...rest} + /> + </motion.div> + )} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/custom-ui/item-bottom-gradients.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/custom-ui/item-bottom-gradients.tsx new file mode 100644 index 0000000..989c4ae --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/custom-ui/item-bottom-gradients.tsx @@ -0,0 +1,54 @@ +/** + * This file contains bottom gradients for items + * They change responsively based on the UI settings + */ + +import { useThemeSettings } from "@/lib/theme/hooks" +import React from "react" + +export function MediaCardBodyBottomGradient() { + + const ts = useThemeSettings() + + if (!!ts.libraryScreenCustomBackgroundImage || ts.hasCustomBackgroundColor) { + return ( + <div + data-media-card-body-bottom-gradient + className="z-[5] absolute inset-x-0 bottom-0 w-full h-[40%] opacity-80 bg-gradient-to-t from-[#0c0c0c] to-transparent" + /> + ) + } + + return ( + <div + data-media-card-body-bottom-gradient + className="z-[5] absolute inset-x-0 bottom-0 w-full opacity-90 to-40% h-[50%] bg-gradient-to-t from-[#0c0c0c] to-transparent" + /> + ) +} + + +export function EpisodeItemBottomGradient() { + + const ts = useThemeSettings() + + // if (!!ts.libraryScreenCustomBackgroundImage || ts.hasCustomBackgroundColor) { + // return ( + // <div + // className="z-[1] absolute inset-x-0 bottom-0 w-full h-full opacity-80 md:h-[60%] bg-gradient-to-t from-[--background] to-transparent" + // /> + // ) + // } + + if (ts.useLegacyEpisodeCard) { + return <div + data-episode-item-bottom-gradient + className="z-[1] absolute inset-x-0 bottom-0 w-full h-full opacity-90 md:h-[80%] bg-gradient-to-t from-[#0c0c0c] to-transparent" + /> + } + + return <div + data-episode-item-bottom-gradient + className="z-[1] absolute inset-x-0 bottom-0 w-full h-full opacity-50 md:h-[70%] bg-gradient-to-t from-[#0c0c0c] to-transparent" + /> +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/custom-ui/styles.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/custom-ui/styles.ts new file mode 100644 index 0000000..5958d00 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/custom-ui/styles.ts @@ -0,0 +1,5 @@ +import { cn } from "@/components/ui/core/styling" + +export const TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE = cn( + "lg:w-[calc(100%_+_5rem)] lg:left-[-5rem]", +) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/error-explainer/error-explainer.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/error-explainer/error-explainer.tsx new file mode 100644 index 0000000..998a067 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/error-explainer/error-explainer.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/components/ui/core/styling" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" + +const __errorExplainer_overlayOpenAtom = atom(false) +const __errorExplainer_errorAtom = atom<string | null>(null) + +export function ErrorExplainer() { + const [open, setOpen] = useAtom(__errorExplainer_overlayOpenAtom) + + return ( + <> + {open && <div + className={cn( + "error-explainer-ui", + "fixed z-[100] bottom-8 w-fit left-20 h-fit flex", + "transition-all duration-300 select-none", + // !isRecording && "hover:translate-y-[-2px]", + // isRecording && "justify-end", + )} + > + <div + className={cn( + "p-4 bg-gray-900 border text-white rounded-xl", + "transition-colors duration-300", + // isRecording && "p-0 border-transparent bg-transparent", + )} + > + </div> + </div>} + </> + ) +} + +export function useErrorExplainer() { + const [error, setError] = useAtom(__errorExplainer_errorAtom) + + const explaination = React.useMemo(() => { + if (!error) return null + + if (error.includes("could not open and play video")) { + + } + + return "" + }, [error]) + + return { error, setError } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/external-player/external-player-link-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/external-player/external-player-link-button.tsx new file mode 100644 index 0000000..9b8d31d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/external-player/external-player-link-button.tsx @@ -0,0 +1,42 @@ +import { SeaLink } from "@/components/shared/sea-link" +import { Button } from "@/components/ui/button" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import { useRouter } from "next/navigation" +import React from "react" + +type ExternalPlayerLinkButtonProps = {} + +export const __externalPlayerLinkButton_linkAtom = atom<string | null>(null) + +export function ExternalPlayerLinkButton(props: ExternalPlayerLinkButtonProps) { + + const {} = props + + const router = useRouter() + + const [link, setLink] = useAtom(__externalPlayerLinkButton_linkAtom) + + if (!link) return null + + return ( + <> + <div className="fixed bottom-2 right-2 z-50"> + <SeaLink href={link} target="_blank" prefetch={false}> + <Button + rounded + size="lg" + className="animate-bounce" + onClick={() => { + React.startTransition(() => { + setLink(null) + }) + }} + > + Open media in external player + </Button> + </SeaLink> + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/getting-started/getting-started-page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/getting-started/getting-started-page.tsx new file mode 100644 index 0000000..ac61243 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/getting-started/getting-started-page.tsx @@ -0,0 +1,880 @@ +import { Status } from "@/api/generated/types" +import { useGettingStarted } from "@/api/hooks/settings.hooks" +import { useSetServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { GlowingEffect } from "@/components/shared/glowing-effect" +import { LoadingOverlayWithLogo } from "@/components/shared/loading-overlay-with-logo" +import { Alert } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { Card, CardProps } from "@/components/ui/card" +import { cn } from "@/components/ui/core/styling" +import { Field, Form } from "@/components/ui/form" +import { + DEFAULT_TORRENT_PROVIDER, + getDefaultIinaSocket, + getDefaultMpvSocket, + getDefaultSettings, + gettingStartedSchema, + TORRENT_PROVIDER, + useDefaultSettingsPaths, +} from "@/lib/server/settings" +import { AnimatePresence, motion } from "motion/react" +import { useRouter } from "next/navigation" +import React from "react" +import { useFormContext, useWatch } from "react-hook-form" +import { BiChevronLeft, BiChevronRight, BiCloud, BiCog, BiDownload, BiFolder, BiPlay, BiRocket } from "react-icons/bi" +import { FaBook, FaDiscord } from "react-icons/fa" +import { HiOutlineDesktopComputer } from "react-icons/hi" +import { HiEye, HiGlobeAlt } from "react-icons/hi2" +import { ImDownload } from "react-icons/im" +import { IoPlayForwardCircleSharp } from "react-icons/io5" +import { MdOutlineBroadcastOnHome } from "react-icons/md" +import { RiFolderDownloadFill } from "react-icons/ri" +import { SiMpv, SiQbittorrent, SiTransmission, SiVlcmediaplayer } from "react-icons/si" + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + exit: { + opacity: 0, + transition: { + staggerChildren: 0.03, + staggerDirection: -1, + }, + }, +} + +const itemVariants = { + hidden: { + opacity: 0, + y: 10, + }, + visible: { + opacity: 1, + y: 0, + }, + exit: { + opacity: 0, + y: -10, + }, +} + +const stepVariants = { + enter: (direction: number) => ({ + x: direction > 0 ? 40 : -40, + opacity: 0, + }), + center: { + zIndex: 1, + x: 0, + opacity: 1, + }, + exit: (direction: number) => ({ + zIndex: 0, + x: direction < 0 ? 40 : -40, + opacity: 0, + }), +} + +const STEPS = [ + { + id: "library", + title: "Anime Library", + description: "Choose your anime collection folder", + icon: BiFolder, + gradient: "from-blue-500 to-cyan-500", + }, + { + id: "player", + title: "Media Player", + description: "Configure your video player", + icon: BiPlay, + gradient: "from-green-500 to-emerald-500", + }, + { + id: "torrents", + title: "Torrent Setup", + description: "Set up downloading and providers", + icon: BiDownload, + gradient: "from-orange-500 to-red-500", + }, + { + id: "debrid", + title: "Debrid Service", + description: "Optional premium streaming", + icon: BiCloud, + gradient: "from-indigo-500 to-purple-500", + }, + { + id: "features", + title: "Features", + description: "Enable additional features", + icon: BiCog, + gradient: "from-teal-500 to-blue-500", + }, +] + +function StepIndicator({ currentStep, totalSteps, onStepClick }: { currentStep: number; totalSteps: number; onStepClick: (step: number) => void }) { + return ( + <div className="mb-12"> + <div className="flex items-center justify-center mb-6"> + <div className="relative mx-auto w-24 h-24"> + <motion.img + src="/logo_2.png" + alt="Seanime Logo" + className="w-full h-full object-contain" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.3 }} + /> + </div> + </div> + + <div className="text-center mb-8"> + <p className="text-[--muted] text-sm "> + These settings can be changed later + </p> + </div> + + <div className="flex items-start justify-between max-w-4xl mx-auto px-4 border p-4 rounded-lg relative bg-gray-900/50 backdrop-blur-sm"> + <GlowingEffect + spread={40} + glow={true} + disabled={false} + proximity={100} + inactiveZone={0.01} + // movementDuration={4} + className="opacity-30" + /> + + {STEPS.map((step, i) => ( + <div + key={step.id} + onClick={(e) => { + onStepClick(i) + }} + className={cn("flex flex-col items-center relative group transition-all duration-200 focus:outline-none rounded-lg p-2 w-36", + "cursor-pointer")} + > + <motion.div + className={cn( + "w-12 h-12 rounded-full flex items-center justify-center mb-3 transition-all duration-200", + // i <= currentStep + // ? `bg-gradient-to-r ${step.gradient} text-white` + // : "bg-gray-700 text-gray-500", + i <= currentStep + ? "bg-gradient-to-br from-brand-500/20 to-purple-500/20 border border-brand-500/20" + : "bg-[--subtle] text-[--muted]", + i <= currentStep && "group-hover:shadow-md", + )} + initial={{ scale: 0.9 }} + animate={{ + scale: i === currentStep ? 1.05 : 1, + }} + transition={{ duration: 0.2 }} + > + <step.icon className="w-6 h-6" /> + </motion.div> + + <div className="text-center"> + <h3 + className={cn( + "text-sm font-medium transition-colors duration-200 tracking-wide", + i <= currentStep ? "text-white" : "text-[--muted]", + "group-hover:text-[--brand]", + )} + > + {step.title} + </h3> + {/* <p className="text-xs text-gray-500 mt-1 max-w-20"> + {step.description} + </p> */} + </div> + + {/* {i < STEPS.length - 1 && ( + <div className="absolute top-8 left-full w-[40%] h-0.5 -translate-y-0 hidden md:block"> + <div className={cn( + "h-full transition-all duration-300", + i < currentStep + ? "bg-[--subtle]" + : "bg-gray-600" + )} /> + </div> + )} */} + </div> + ))} + </div> + </div> + ) +} + +function StepCard({ children, className, ...props }: CardProps) { + return ( + <motion.div + variants={itemVariants} + className={cn( + "relative rounded-xl bg-gray-900/50 backdrop-blur-sm border", + className, + )} + > + <GlowingEffect + spread={40} + glow={true} + disabled={false} + proximity={100} + inactiveZone={0.01} + // movementDuration={4} + className="opacity-30" + /> + <Card className="bg-transparent border-none shadow-none p-6"> + {children} + </Card> + </motion.div> + ) +} + + +function LibraryStep({ form }: { form: any }) { + return ( + <motion.div + variants={containerVariants} + initial="hidden" + animate="visible" + exit="exit" + className="space-y-8" + > + <motion.div variants={itemVariants} className="text-center space-y-4"> + <h2 className="text-3xl font-bold">Anime Library</h2> + <p className="text-[--muted] text-sm max-w-lg mx-auto"> + Choose the folder where your anime files are stored. This is where Seanime will scan for your collection. + </p> + </motion.div> + + <StepCard className="max-w-2xl mx-auto"> + <motion.div variants={itemVariants}> + <Field.DirectorySelector + name="libraryPath" + label="Anime Library Path" + leftIcon={<BiFolder className="text-blue-500" />} + shouldExist + help="Select the main folder containing your anime collection. You can add more folders later." + className="w-full" + /> + </motion.div> + </StepCard> + + </motion.div> + ) +} + +function PlayerStep({ form, status }: { form: any, status: Status }) { + const { watch } = useFormContext() + const defaultPlayer = useWatch({ name: "defaultPlayer" }) + + return ( + <motion.div + variants={containerVariants} + initial="hidden" + animate="visible" + exit="exit" + className="space-y-8" + > + <motion.div variants={itemVariants} className="text-center space-y-4"> + <h2 className="text-3xl font-bold">Media Player</h2> + <p className="text-[--muted] text-sm max-w-lg mx-auto"> + Configure your preferred media player for watching anime and tracking progress automatically. + </p> + </motion.div> + + <StepCard className="max-w-2xl mx-auto"> + <motion.div variants={itemVariants} className="space-y-6"> + <Field.Select + name="defaultPlayer" + label="Media Player" + help={status?.os !== "darwin" + ? "MPV is recommended for better subtitle rendering, torrent streaming." + : "Both MPV and IINA are recommended for macOS."} + required + leftIcon={<BiPlay className="text-green-500" />} + options={[ + { label: "MPV (Recommended)", value: "mpv" }, + { label: "VLC", value: "vlc" }, + ...(status?.os === "windows" ? [{ label: "MPC-HC", value: "mpc-hc" }] : []), + ...(status?.os === "darwin" ? [{ label: "IINA", value: "iina" }] : []), + ]} + /> + + <AnimatePresence mode="wait"> + {defaultPlayer === "mpv" && ( + <> + <p> + On Windows, install MPV easily using Scoop or Chocolatey. On macOS, install MPV using Homebrew. + </p> + <motion.div + key="mpv" + initial={{ opacity: 0, height: 0 }} + animate={{ opacity: 1, height: "auto" }} + exit={{ opacity: 0, height: 0 }} + className="space-y-4 p-4 rounded-lg bg-gray-800/30" + > + <div className="flex items-center space-x-3"> + <SiMpv className="w-6 h-6 text-purple-400" /> + <h4 className="font-semibold">MPV Configuration</h4> + </div> + <Field.Text + name="mpvSocket" + label="Socket / Pipe Path" + help="Path for MPV IPC communication" + /> + </motion.div> + </> + )} + + {defaultPlayer === "iina" && ( + <motion.div + key="iina" + initial={{ opacity: 0, height: 0 }} + animate={{ opacity: 1, height: "auto" }} + exit={{ opacity: 0, height: 0 }} + className="space-y-4 p-4 rounded-lg bg-gray-800/30" + > + <div className="flex items-center space-x-3"> + <IoPlayForwardCircleSharp className="w-6 h-6 text-blue-400" /> + <h4 className="font-semibold">IINA Configuration</h4> + </div> + <Field.Text + name="iinaSocket" + label="Socket / Pipe Path" + help="Path for IINA IPC communication" + /> + + <Alert + intent="info-basic" + description={<p>For IINA to work correctly with Seanime, make sure <strong>Quit after all windows are + closed</strong> is <span + className="underline" + >checked</span> and <strong>Keep window open after playback + finishes</strong> is <span className="underline">unchecked</span> in + your IINA general settings.</p>} + /> + </motion.div> + )} + + {defaultPlayer === "vlc" && ( + <motion.div + key="vlc" + initial={{ opacity: 0, height: 0 }} + animate={{ opacity: 1, height: "auto" }} + exit={{ opacity: 0, height: 0 }} + className="space-y-4 p-4 rounded-lg bg-gray-800/30" + > + <div className="flex items-center space-x-3"> + <SiVlcmediaplayer className="w-6 h-6 text-orange-500" /> + <h4 className="font-semibold">VLC Configuration</h4> + </div> + <div className="grid grid-cols-2 gap-4"> + <Field.Text name="mediaPlayerHost" label="Host" /> + <Field.Number name="vlcPort" label="Port" formatOptions={{ useGrouping: false }} /> + </div> + <div className="grid grid-cols-2 gap-4"> + <Field.Text name="vlcUsername" label="Username" /> + <Field.Text name="vlcPassword" label="Password" /> + </div> + <Field.Text name="vlcPath" label="VLC Executable Path" /> + </motion.div> + )} + + {defaultPlayer === "mpc-hc" && ( + <motion.div + key="mpc-hc" + initial={{ opacity: 0, height: 0 }} + animate={{ opacity: 1, height: "auto" }} + exit={{ opacity: 0, height: 0 }} + className="space-y-4 p-4 rounded-lg bg-gray-800/30" + > + <div className="flex items-center space-x-3"> + <HiOutlineDesktopComputer className="w-6 h-6 text-blue-500" /> + <h4 className="font-semibold">MPC-HC Configuration</h4> + </div> + <div className="grid grid-cols-2 gap-4"> + <Field.Text name="mediaPlayerHost" label="Host" /> + <Field.Number name="mpcPort" label="Port" formatOptions={{ useGrouping: false }} /> + </div> + <Field.Text name="mpcPath" label="MPC-HC Executable Path" /> + </motion.div> + )} + </AnimatePresence> + </motion.div> + </StepCard> + </motion.div> + ) +} + +function TorrentStep({ form }: { form: any }) { + const { watch } = useFormContext() + const defaultTorrentClient = useWatch({ name: "defaultTorrentClient" }) + + return ( + <motion.div + variants={containerVariants} + initial="hidden" + animate="visible" + exit="exit" + className="space-y-8" + > + <motion.div variants={itemVariants} className="text-center space-y-4"> + <h2 className="text-3xl font-bold">Torrent Setup</h2> + <p className="text-[--muted] text-sm max-w-lg mx-auto"> + Configure your default torrent provider and client. + </p> + </motion.div> + + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 max-w-6xl mx-auto"> + <StepCard> + <motion.div variants={itemVariants} className="space-y-4"> + <div className="flex items-center space-x-3 mb-4"> + <RiFolderDownloadFill className="w-6 h-6 text-orange-500" /> + <h3 className="text-xl font-semibold">Torrent Provider</h3> + </div> + <p className="text-sm text-[--muted]"> + Extension for finding anime torrents + </p> + <Field.Select + name="torrentProvider" + label="Provider" + required + options={[ + { label: "AnimeTosho (Recommended)", value: TORRENT_PROVIDER.ANIMETOSHO }, + { label: "Nyaa", value: TORRENT_PROVIDER.NYAA }, + { label: "Nyaa (Non-English)", value: TORRENT_PROVIDER.NYAA_NON_ENG }, + ]} + help="AnimeTosho search results are more precise in most cases." + /> + </motion.div> + </StepCard> + + <StepCard> + <motion.div variants={itemVariants} className="space-y-4"> + <div className="flex items-center space-x-3 mb-4"> + <ImDownload className="w-6 h-6 text-blue-500" /> + <h3 className="text-xl font-semibold">Torrent Client</h3> + </div> + <p className="text-sm text-[--muted]"> + Client used to download anime torrents + </p> + <Field.Select + name="defaultTorrentClient" + label="Client" + options={[ + { label: "qBittorrent", value: "qbittorrent" }, + { label: "Transmission", value: "transmission" }, + { label: "None", value: "none" }, + ]} + /> + </motion.div> + </StepCard> + </div> + + <AnimatePresence mode="wait"> + {(defaultTorrentClient === "qbittorrent" || defaultTorrentClient === "transmission") && ( + <StepCard className="max-w-4xl mx-auto"> + <motion.div + key={defaultTorrentClient} + initial={{ opacity: 0, scale: 0.95 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + className="space-y-6" + > + {defaultTorrentClient === "qbittorrent" && ( + <> + <div className="flex items-center space-x-3"> + <SiQbittorrent className="w-8 h-8 text-blue-600" /> + <h4 className="text-xl font-semibold">qBittorrent Settings</h4> + </div> + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> + <Field.Text name="qbittorrentHost" label="Host" /> + <Field.Text name="qbittorrentUsername" label="Username" /> + <Field.Text name="qbittorrentPassword" label="Password" /> + </div> + <div className="grid grid-cols-2 gap-4 lg:grid-cols-[200px_1fr]"> + <Field.Number name="qbittorrentPort" label="Port" formatOptions={{ useGrouping: false }} /> + <Field.Text name="qbittorrentPath" label="Executable Path" /> + </div> + </> + )} + + {defaultTorrentClient === "transmission" && ( + <> + <div className="flex items-center space-x-3"> + <SiTransmission className="w-8 h-8 text-red-600" /> + <h4 className="text-xl font-semibold">Transmission Settings</h4> + </div> + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> + <Field.Text name="transmissionHost" label="Host" /> + <Field.Text name="transmissionUsername" label="Username" /> + <Field.Text name="transmissionPassword" label="Password" /> + </div> + <div className="grid grid-cols-2 gap-4 lg:grid-cols-[200px_1fr]"> + <Field.Number name="transmissionPort" label="Port" formatOptions={{ useGrouping: false }} /> + <Field.Text name="transmissionPath" label="Executable Path" /> + </div> + </> + )} + </motion.div> + </StepCard> + )} + </AnimatePresence> + </motion.div> + ) +} + +function DebridStep({ form }: { form: any }) { + const debridProvider = useWatch({ name: "debridProvider" }) + + return ( + <motion.div + variants={containerVariants} + initial="hidden" + animate="visible" + exit="exit" + className="space-y-8" + > + <motion.div variants={itemVariants} className="text-center space-y-4"> + <h2 className="text-3xl font-bold">Debrid Service</h2> + <p className="text-[--muted] text-sm max-w-lg mx-auto"> + Debrid services offer faster downloads and instant streaming from the cloud. + </p> + </motion.div> + + <StepCard className="max-w-2xl mx-auto"> + <motion.div variants={itemVariants} className="space-y-6"> + <Field.Select + name="debridProvider" + label="Debrid Service" + leftIcon={<BiCloud className="text-[--purple]" />} + options={[ + { label: "None", value: "none" }, + { label: "TorBox", value: "torbox" }, + { label: "Real-Debrid", value: "realdebrid" }, + ]} + /> + + <AnimatePresence> + {debridProvider !== "none" && debridProvider !== "" && ( + <motion.div + initial={{ opacity: 0, height: 0 }} + animate={{ opacity: 1, height: "auto" }} + exit={{ opacity: 0, height: 0 }} + className="space-y-4 p-4 rounded-lg bg-gray-800/30" + > + <Field.Text + name="debridApiKey" + label="API Key" + help="The API key provided by the debrid service." + /> + </motion.div> + )} + </AnimatePresence> + </motion.div> + </StepCard> + </motion.div> + ) +} + +function FeaturesStep({ form }: { form: any }) { + const features = [ + { + name: "enableManga", + icon: FaBook, + title: "Manga", + description: "Read and download manga chapters", + gradient: "from-orange-500 to-yellow-700", + }, + { + name: "enableTorrentStreaming", + icon: BiDownload, + title: "Torrent Streaming", + description: "Stream torrents without waiting for download", + gradient: "from-cyan-500 to-teal-500", + }, + { + name: "enableAdultContent", + icon: HiEye, + title: "NSFW Content", + description: "Show adult content in library and search", + gradient: "from-red-500 to-pink-500", + }, + { + name: "enableOnlinestream", + icon: HiGlobeAlt, + title: "Online Streaming", + description: "Watch anime from online sources", + gradient: "from-purple-500 to-violet-500", + }, + { + name: "enableRichPresence", + icon: FaDiscord, + title: "Discord Rich Presence", + description: "Show what you're watching on Discord", + gradient: "from-indigo-500 to-blue-500", + }, + { + name: "enableTranscode", + icon: MdOutlineBroadcastOnHome, + title: "Transcoding / Direct Play", + description: "Stream downloaded files on other devices", + gradient: "from-cyan-500 to-indigo-500", + }, + ] + + return ( + <motion.div + variants={containerVariants} + initial="hidden" + animate="visible" + exit="exit" + className="space-y-8" + > + <motion.div variants={itemVariants} className="text-center space-y-4"> + <h2 className="text-3xl font-bold">Additional Features</h2> + <p className="text-[--muted] text-sm max-w-lg mx-auto"> + Choose which additional features you'd like to enable. You can enable or disable these later in settings. + </p> + </motion.div> + + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-w-6xl mx-auto"> + {features.map((feature, index) => ( + <motion.div + key={feature.name} + variants={itemVariants} + custom={index} + > + <Field.Checkbox + name={feature.name} + label={ + <div className="flex items-start space-x-4 p-4"> + <div + className={cn( + "w-12 h-12 rounded-lg flex items-center justify-center", + `bg-gradient-to-br ${feature.gradient}`, + )} + > + <feature.icon className="w-6 h-6 text-white" /> + </div> + <div className="flex-1 min-w-0"> + <h3 className="font-semibold text-sm">{feature.title}</h3> + <p className="text-xs text-gray-400 mt-1 leading-relaxed"> + {feature.description} + </p> + </div> + </div> + } + size="lg" + labelClass={cn( + "block cursor-pointer transition-all duration-200 overflow-hidden w-full rounded-xl", + "bg-gray-900/50 hover:bg-gray-800/80", + "border border-gray-700/50", + "hover:border-gray-600", + // "hover:shadow-lg hover:scale-[1.02]", + "data-[checked=true]:bg-gradient-to-br data-[checked=true]:from-gray-900 data-[checked=true]:to-gray-900", + "data-[checked=true]:border-brand-600", + // "data-[checked=true]:shadow-lg data-[checked=true]:scale-[1.02]" + )} + containerClass="flex items-center justify-between h-full" + className="absolute top-2 right-2 z-10" + fieldClass="relative" + /> + </motion.div> + ))} + </div> + </motion.div> + ) +} + +export function GettingStartedPage({ status }: { status: Status }) { + const router = useRouter() + const { getDefaultVlcPath, getDefaultQBittorrentPath, getDefaultTransmissionPath } = useDefaultSettingsPaths() + const setServerStatus = useSetServerStatus() + + const { mutate, data, isPending, isSuccess } = useGettingStarted() + + const [currentStep, setCurrentStep] = React.useState(0) + const [direction, setDirection] = React.useState(0) + + /** + * If the settings are returned, redirect to the home page + */ + React.useEffect(() => { + if (!isPending && !!data?.settings) { + setServerStatus(data) + router.push("/") + } + }, [data, isPending]) + + const vlcDefaultPath = React.useMemo(() => getDefaultVlcPath(status.os), [status.os]) + const qbittorrentDefaultPath = React.useMemo(() => getDefaultQBittorrentPath(status.os), [status.os]) + const transmissionDefaultPath = React.useMemo(() => getDefaultTransmissionPath(status.os), [status.os]) + const mpvSocketPath = React.useMemo(() => getDefaultMpvSocket(status.os), [status.os]) + const iinaSocketPath = React.useMemo(() => getDefaultIinaSocket(status.os), [status.os]) + + const nextStep = () => { + if (currentStep < STEPS.length - 1) { + setDirection(1) + setCurrentStep(currentStep + 1) + } + } + + const prevStep = () => { + if (currentStep > 0) { + setDirection(-1) + setCurrentStep(currentStep - 1) + } + } + + const goToStep = (step: number) => { + if (step >= 0 && step < STEPS.length) { + setDirection(step > currentStep ? 1 : -1) + setCurrentStep(step) + } + } + + if (isPending) return <LoadingOverlayWithLogo /> + + if (!data) return ( + <div className="min-h-screen bg-gradient-to-br from-[--background] via-[--background] to-purple-950/10"> + <div className="absolute inset-0 overflow-hidden pointer-events-none"> + {/* <div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl" /> */} + {/* <div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl" /> */} + {/* <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-pink-500/5 rounded-full blur-3xl" /> */} + </div> + + <div className="container max-w-6xl mx-auto px-4 py-8 relative z-10"> + <Form + schema={gettingStartedSchema} + onSubmit={data => { + if (currentStep === STEPS.length - 1) { + mutate(getDefaultSettings(data)) + } else { + nextStep() + } + }} + defaultValues={{ + mediaPlayerHost: "127.0.0.1", + vlcPort: 8080, + mpcPort: 13579, + defaultPlayer: "mpv", + vlcPath: vlcDefaultPath, + qbittorrentPath: qbittorrentDefaultPath, + qbittorrentHost: "127.0.0.1", + qbittorrentPort: 8081, + transmissionPath: transmissionDefaultPath, + transmissionHost: "127.0.0.1", + transmissionPort: 9091, + mpcPath: "C:/Program Files/MPC-HC/mpc-hc64.exe", + torrentProvider: DEFAULT_TORRENT_PROVIDER, + mpvSocket: mpvSocketPath, + iinaSocket: iinaSocketPath, + enableRichPresence: false, + autoScan: false, + enableManga: true, + enableOnlinestream: false, + enableAdultContent: true, + enableTorrentStreaming: true, + enableTranscode: false, + debridProvider: "none", + debridApiKey: "", + nakamaUsername: "", + enableWatchContinuity: true, + }} + > + {(f) => ( + <div className="space-y-8"> + <StepIndicator currentStep={currentStep} totalSteps={STEPS.length} onStepClick={goToStep} /> + + <AnimatePresence mode="wait" custom={direction}> + <motion.div + key={currentStep} + custom={direction} + variants={stepVariants} + initial="enter" + animate="center" + exit="exit" + transition={{ + x: { duration: 0.3, ease: "easeInOut" }, + opacity: { duration: 0.2 }, + }} + className="" + > + {currentStep === 0 && <LibraryStep form={f} />} + {currentStep === 1 && <PlayerStep form={f} status={status} />} + {currentStep === 2 && <TorrentStep form={f} />} + {currentStep === 3 && <DebridStep form={f} />} + {currentStep === 4 && <FeaturesStep form={f} />} + </motion.div> + </AnimatePresence> + + <motion.div + className="flex justify-between items-center max-w-2xl mx-auto pt-8" + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.5 }} + > + <Button + type="button" + intent="gray-outline" + onClick={e => { + e.preventDefault() + prevStep() + }} + disabled={currentStep === 0} + className="flex items-center space-x-2" + leftIcon={<BiChevronLeft />} + > + Previous + </Button> + + {currentStep === STEPS.length - 1 ? ( + <Button + type="submit" + className="flex items-center bg-gradient-to-r from-brand-600 to-indigo-600 hover:ring-2 ring-brand-600" + loading={isPending} + rightIcon={<BiRocket className="size-6" />} + > + <span>Launch Seanime</span> + </Button> + ) : ( + <Button + type="button" + intent="primary-subtle" + onClick={e => { + e.preventDefault() + nextStep() + }} + className="flex items-center space-x-2" + rightIcon={<BiChevronRight />} + > + Next + </Button> + )} + </motion.div> + </div> + )} + </Form> + + <motion.p + className="text-center text-[--muted] mt-12" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ delay: 1 }} + > + Made by 5rahim + </motion.p> + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/global-search/global-search.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/global-search/global-search.tsx new file mode 100644 index 0000000..cee7dbb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/global-search/global-search.tsx @@ -0,0 +1,279 @@ +"use client" +import { useAnilistListAnime } from "@/api/hooks/anilist.hooks" +import { useAnilistListManga } from "@/api/hooks/manga.hooks" +import { SeaLink } from "@/components/shared/sea-link" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Select } from "@/components/ui/select" +import { useDebounce } from "@/hooks/use-debounce" +import { Combobox, Dialog, Transition } from "@headlessui/react" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import capitalize from "lodash/capitalize" +import Image from "next/image" +import { useRouter } from "next/navigation" +import React, { Fragment, useEffect, useRef } from "react" +import { BiChevronRight } from "react-icons/bi" +import { FiSearch } from "react-icons/fi" + +export const __globalSearch_isOpenAtom = atom(false) + +export function GlobalSearch() { + + const [inputValue, setInputValue] = React.useState("") + const debouncedQuery = useDebounce(inputValue, 500) + const inputRef = useRef<HTMLInputElement>(null) + + const [type, setType] = React.useState<string>("anime") + + const router = useRouter() + + const [open, setOpen] = useAtom(__globalSearch_isOpenAtom) + + useEffect(() => { + if(open) { + setTimeout(() => { + console.log("open", open, inputRef.current) + console.log("focusing") + inputRef.current?.focus() + }, 300) + } + }, [open]) + + const { data: animeData, isLoading: animeIsLoading, isFetching: animeIsFetching } = useAnilistListAnime({ + search: debouncedQuery, + page: 1, + perPage: 10, + status: ["FINISHED", "CANCELLED", "NOT_YET_RELEASED", "RELEASING"], + sort: ["SEARCH_MATCH"], + }, debouncedQuery.length > 0 && type === "anime") + + const { data: mangaData, isLoading: mangaIsLoading, isFetching: mangaIsFetching } = useAnilistListManga({ + search: debouncedQuery, + page: 1, + perPage: 10, + status: ["FINISHED", "CANCELLED", "NOT_YET_RELEASED", "RELEASING"], + sort: ["SEARCH_MATCH"], + }, debouncedQuery.length > 0 && type === "manga") + + const isLoading = type === "anime" ? animeIsLoading : mangaIsLoading + const isFetching = type === "anime" ? animeIsFetching : mangaIsFetching + + const media = React.useMemo(() => type === "anime" ? animeData?.Page?.media?.filter(Boolean) : mangaData?.Page?.media?.filter(Boolean), + [animeData, mangaData, type]) + + return ( + <> + <Transition.Root show={open} as={Fragment} afterLeave={() => setInputValue("")} appear> + <Dialog as="div" className="relative z-50" onClose={setOpen}> + <Transition.Child + as={Fragment} + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <div className="fixed inset-0 bg-black bg-opacity-70 transition-opacity backdrop-blur-sm" /> + </Transition.Child> + + <div className="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-6 md:p-20"> + <Transition.Child + as={Fragment} + enter="ease-out duration-300" + enterFrom="opacity-0 scale-95" + enterTo="opacity-100 scale-100" + leave="ease-in duration-200" + leaveFrom="opacity-100 scale-100" + leaveTo="opacity-0 scale-95" + > + <Dialog.Panel + className="mx-auto max-w-3xl transform space-y-4 transition-all" + > + <div className="absolute right-2 -top-7 z-10"> + <SeaLink + href="/search" + className="text-[--muted] hover:text-[--foreground] font-bold" + onClick={() => setOpen(false)} + > + Advanced search → + </SeaLink> + </div> + <Combobox> + {({ activeOption }: any) => ( + <> + <div + className="relative border bg-gray-950 shadow-2xl ring-1 ring-black ring-opacity-5 w-full rounded-lg " + > + <FiSearch + className="pointer-events-none absolute top-4 left-4 h-6 w-6 text-[--muted]" + aria-hidden="true" + /> + <Combobox.Input + ref={inputRef} + className="h-14 w-full border-0 bg-transparent pl-14 pr-4 text-white placeholder-[--muted] focus:ring-0 sm:text-md" + placeholder="Search..." + onChange={(event) => setInputValue(event.target.value)} + /> + <div className="block fixed lg:absolute top-2 right-2 z-1"> + <Select + fieldClass="w-fit" + value={type} + onValueChange={(value) => setType(value)} + options={[ + { value: "anime", label: "Anime" }, + { value: "manga", label: "Manga" }, + ]} + /> + </div> + </div> + + {(!!media && media.length > 0) && ( + <Combobox.Options + as="div" static hold + className="flex divide-[--border] bg-gray-950 shadow-2xl ring-1 ring-black ring-opacity-5 rounded-lg border " + > + <div + className={cn( + "max-h-96 min-w-0 flex-auto scroll-py-2 overflow-y-auto px-6 py-2 my-2", + { "sm:h-96": activeOption }, + )} + > + <div className="-mx-2 text-sm text-[--foreground]"> + {(media).map((item: any) => ( + <Combobox.Option + as="div" + key={item.id} + value={item} + onClick={() => { + if (type === "anime") { + router.push(`/entry?id=${item.id}`) + } else { + router.push(`/manga/entry?id=${item.id}`) + } + setOpen(false) + }} + className={({ active }) => + cn( + "flex select-none items-center rounded-[--radius-md] p-2 text-[--muted] cursor-pointer", + active && "bg-gray-800 text-white", + ) + } + > + {({ active }) => ( + <> + <div + className="h-10 w-10 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden" + > + {item.coverImage?.medium && <Image + src={item.coverImage?.medium} + alt={""} + fill + quality={50} + priority + sizes="10rem" + className="object-cover object-center" + />} + </div> + <span + className="ml-3 flex-auto truncate" + >{item.title?.userPreferred}</span> + {active && ( + <BiChevronRight + className="ml-3 h-7 w-7 flex-none text-gray-400" + aria-hidden="true" + /> + )} + </> + )} + </Combobox.Option> + ))} + </div> + </div> + + {activeOption && ( + <div + className="hidden min-h-96 w-1/2 flex-none flex-col overflow-y-auto sm:flex p-4" + > + <div className="flex-none p-6 text-center"> + <div + className="h-40 w-32 mx-auto flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden" + > + {activeOption.coverImage?.large && <Image + src={activeOption.coverImage?.large} + alt={""} + fill + quality={100} + priority + sizes="10rem" + className="object-cover object-center" + />} + </div> + <h4 className="mt-3 font-semibold text-[--foreground] line-clamp-3">{activeOption.title?.userPreferred}</h4> + <p className="text-sm leading-6 text-[--muted]"> + {activeOption.format}{activeOption.season + ? ` - ${capitalize(activeOption.season)} ` + : " - "}{activeOption.seasonYear + ? activeOption.seasonYear + : "-"} + </p> + </div> + <SeaLink + href={type === "anime" + ? `/entry?id=${activeOption.id}` + : `/manga/entry?id=${activeOption.id}`} + onClick={() => setOpen(false)} + > + <Button + type="button" + className="w-full" + intent="gray-subtle" + > + Open + </Button> + </SeaLink> + </div> + )} + </Combobox.Options> + )} + + {(debouncedQuery !== "" && (!media || media.length === 0) && (isLoading || isFetching)) && ( + <LoadingSpinner /> + )} + + {debouncedQuery !== "" && !isLoading && !isFetching && (!media || media.length === 0) && ( + <div className="py-14 px-6 text-center text-sm sm:px-14"> + {<div + className="h-[10rem] w-[10rem] mx-auto flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden" + > + <Image + src="/luffy-01.png" + alt={""} + fill + quality={100} + priority + sizes="10rem" + className="object-contain object-top" + /> + </div>} + <h5 className="mt-4 font-semibold text-[--foreground]">Nothing + found</h5> + <p className="mt-2 text-[--muted]"> + We couldn't find anything with that name. Please try again. + </p> + </div> + )} + </> + )} + </Combobox> + </Dialog.Panel> + </Transition.Child> + </div> + </Dialog> + </Transition.Root> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/issue-report/issue-report.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/issue-report/issue-report.tsx new file mode 100644 index 0000000..0488c84 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/issue-report/issue-report.tsx @@ -0,0 +1,339 @@ +import { getServerBaseUrl } from "@/api/client/server-url" +import { Report_ClickLog, Report_ConsoleLog, Report_NetworkLog, Report_ReactQueryLog } from "@/api/generated/types" +import { useSaveIssueReport } from "@/api/hooks/report.hooks" +import { useServerHMACAuth } from "@/app/(main)/_hooks/use-server-status" +import { IconButton } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { cn } from "@/components/ui/core/styling" +import { Tooltip } from "@/components/ui/tooltip" +import { openTab } from "@/lib/helpers/browser" +import { useQueryClient } from "@tanstack/react-query" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import { usePathname, useRouter } from "next/navigation" +import React from "react" +import { BiX } from "react-icons/bi" +import { PiRecordFill, PiStopCircleFill } from "react-icons/pi" +import { VscDebugAlt } from "react-icons/vsc" +import { toast } from "sonner" + +export const __issueReport_overlayOpenAtom = atom<boolean>(false) +export const __issueReport_recordingAtom = atom<boolean>(false) +export const __issueReport_streamAtom = atom<any>() + +export const __issueReport_clickLogsAtom = atom<Report_ClickLog[]>([]) + +export const __issueReport_consoleAtom = atom<Report_ConsoleLog[]>([]) + +export const __issueReport_networkAtom = atom<Report_NetworkLog[]>([]) + +export const __issueReport_reactQueryAtom = atom<Report_ReactQueryLog[]>([]) + +export function IssueReport() { + const router = useRouter() + const pathname = usePathname() + const queryClient = useQueryClient() + + const [open, setOpen] = useAtom(__issueReport_overlayOpenAtom) + const [isRecording, setRecording] = useAtom(__issueReport_recordingAtom) + const [consoleLogs, setConsoleLogs] = useAtom(__issueReport_consoleAtom) + const [clickLogs, setClickLogs] = useAtom(__issueReport_clickLogsAtom) + const [networkLogs, setNetworkLogs] = useAtom(__issueReport_networkAtom) + const [reactQueryLogs, setReactQueryLogs] = useAtom(__issueReport_reactQueryAtom) + const [recordLocalFiles, setRecordLocalFiles] = React.useState(false) + + const { mutate, isPending } = useSaveIssueReport() + + React.useEffect(() => { + if (!open) { + setRecording(false) + } + }, [open]) + + React.useEffect(() => { + if (!isRecording) { + setConsoleLogs([]) + setClickLogs([]) + setNetworkLogs([]) + setReactQueryLogs([]) + } + }, [isRecording]) + + React.useEffect(() => { + if (!isRecording) return + + const captureClick = (e: MouseEvent) => { + const element = e.target as HTMLElement + setClickLogs(prev => [...prev, { + pageUrl: window.location.href.replace(window.location.host, "{client}"), + timestamp: new Date().toISOString(), + element: element.tagName, + className: JSON.stringify(element.className?.length && element.className.length > 50 + ? element.className.slice(0, 100) + "..." + : element.className), + text: element.innerText, + }]) + } + + window.addEventListener("click", captureClick) + + return () => { + window.removeEventListener("click", captureClick) + } + }, [isRecording]) + + React.useEffect(() => { + if (!isRecording) return + + const originalConsole = { + log: console.log, + error: console.error, + warn: console.warn, + } + + const logInterceptor = (type: Report_ConsoleLog["type"]) => (...args: any[]) => { + (originalConsole as any)[type](...args) + try { + setConsoleLogs(prev => [...prev, { + type, + pageUrl: window.location.href.replace(window.location.host, "{client}"), + content: args.map(arg => + typeof arg === "object" ? JSON.stringify(arg) : String(arg), + ).join(" "), + timestamp: new Date().toISOString(), + }]) + } + catch (e) { + // console.error("Error capturing console logs", e) + } + } + + console.log = logInterceptor("log") + console.error = logInterceptor("error") + console.warn = logInterceptor("warn") + + return () => { + Object.assign(console, originalConsole) + } + }, [isRecording]) + + React.useEffect(() => { + if (!isRecording) return + + const queryUnsubscribe = queryClient.getQueryCache().subscribe(listener => { + if (listener.query.state.status === "pending") return + setReactQueryLogs(prev => [...prev, { + type: "query", + pageUrl: window.location.href.replace(window.location.host, "{client}"), + status: listener.query.state.status, + hash: listener.query.queryHash, + error: listener.query.state.error, + timestamp: new Date().toISOString(), + dataPreview: typeof listener.query.state.data === "object" + ? JSON.stringify(listener.query.state.data).slice(0, 200) + : "", + dataType: typeof listener.query.state.data, + }]) + }) + + const mutationUnsubscribe = queryClient.getMutationCache().subscribe(listener => { + if (!listener.mutation) return + if (listener.mutation.state.status === "pending" || listener.mutation.state.status === "idle") return + + // Don't log the save issue report mutation to prevent feedback loop + const mutationKey = listener.mutation.options.mutationKey + if (Array.isArray(mutationKey) && mutationKey.includes("REPORT-save-issue-report")) return + + setReactQueryLogs(prev => [...prev, { + type: "mutation", + pageUrl: window.location.href.replace(window.location.host, "{client}"), + status: listener.mutation!.state.status, + hash: JSON.stringify(listener.mutation!.options.mutationKey), + error: listener.mutation!.state.error, + timestamp: new Date().toISOString(), + dataPreview: typeof listener.mutation!.state.data === "object" ? JSON.stringify(listener.mutation!.state.data) + .slice(0, 200) : "", + dataType: typeof listener.mutation!.state.data, + }]) + }) + + return () => { + queryUnsubscribe() + mutationUnsubscribe() + } + }, [isRecording]) + + React.useEffect(() => { + if (!isRecording) return + + const originalXhrOpen = XMLHttpRequest.prototype.open + const originalXhrSend = XMLHttpRequest.prototype.send + + XMLHttpRequest.prototype.open = function (method, url) { + // @ts-ignore + this._url = url + // @ts-ignore + this._method = method + // @ts-ignore + originalXhrOpen.apply(this, arguments) + } + + XMLHttpRequest.prototype.send = function (body) { + const startTime = Date.now() + // @ts-ignore + const url = this._url + // @ts-ignore + const method = this._method + + const _url = new URL(url) + // remove host and port + _url.host = "{server}" + _url.port = "" + + this.addEventListener("load", () => { + const duration = Date.now() - startTime + setNetworkLogs(prev => [...prev, { + type: "xhr", + method, + url: _url.href, + pageUrl: window.location.href.replace(window.location.host, "{client}"), + status: this.status, + duration, + dataPreview: this.responseText.slice(0, 200), + timestamp: new Date().toISOString(), + body: JSON.stringify(body), + }]) + }) + + // @ts-ignore + originalXhrSend.apply(this, arguments) + } + + return () => { + XMLHttpRequest.prototype.open = originalXhrOpen + XMLHttpRequest.prototype.send = originalXhrSend + } + }, [isRecording]) + + function handleStartRecording() { + setRecording(true) + } + + const { getHMACTokenQueryParam } = useServerHMACAuth() + + async function handleStopRecording() { + const logsToSave = { + clickLogs, + consoleLogs, + networkLogs, + reactQueryLogs, + } + + setRecording(false) + + mutate({ + ...logsToSave, + isAnimeLibraryIssue: recordLocalFiles, + }, { + onSuccess: async () => { + toast.success("Issue report saved successfully") + + setTimeout(async () => { + try { + const endpoint = "/api/v1/report/issue/download" + const tokenQuery = await getHMACTokenQueryParam(endpoint) + openTab(`${getServerBaseUrl()}${endpoint}${tokenQuery}`) + } + catch (error) { + toast.error("Failed to generate download token") + } + }, 1000) + }, + }) + } + + + return ( + <> + {open && <div + className={cn( + "issue-reporter-ui", + "fixed z-[100] bottom-8 w-fit left-20 h-fit flex", + "transition-all duration-300 select-none", + !isRecording && "hover:translate-y-[-2px]", + isRecording && "justify-end", + )} + > + <div + className={cn( + "p-4 bg-gray-900 border text-white rounded-xl", + "transition-colors duration-300", + isRecording && "p-0 border-transparent bg-transparent", + )} + > + {!isRecording ? <div className="space-y-2"> + <div className="flex items-center justify-center gap-4"> + <VscDebugAlt className="text-2xl text-[--brand]" /> + <div className=""> + <p> + Issue recorder + </p> + <p className="text-[--muted] text-sm text-center"> + Record your issue and generate a report + </p> + <div className="pt-2"> + <Checkbox + label="Anime library issue" + value={recordLocalFiles} + onValueChange={v => typeof v === "boolean" && setRecordLocalFiles(v)} + size="sm" + /> + </div> + </div> + <div className="flex items-center gap-0"> + <Tooltip + trigger={<IconButton + intent="gray-basic" + icon={<PiRecordFill className="text-red-500" />} + onClick={handleStartRecording} + />} + > + Start recording + </Tooltip> + <Tooltip + trigger={<IconButton + intent="gray-basic" + icon={<BiX />} + onClick={() => setOpen(false)} + />} + > + Close + </Tooltip> + </div> + </div> + </div> : <div className="flex items-center justify-center gap-0"> + <Tooltip + trigger={<IconButton + intent="alert" + icon={<PiStopCircleFill className="text-white animate-pulse" />} + onClick={handleStopRecording} + />} + > + Stop recording + </Tooltip> + <Tooltip + trigger={<IconButton + intent="white" + size="xs" + icon={<BiX />} + onClick={() => setRecording(false)} + />} + > + Cancel + </Tooltip> + </div>} + </div> + </div>} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/_components/layout-header-background.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/_components/layout-header-background.tsx new file mode 100644 index 0000000..3d3d7e5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/_components/layout-header-background.tsx @@ -0,0 +1,36 @@ +"use client" +import { cn } from "@/components/ui/core/styling" +import { usePathname } from "next/navigation" +import React from "react" +import { SiAnilist } from "react-icons/si" + +export function LayoutHeaderBackground() { + + const pathname = usePathname() + + return ( + <> + {!pathname.startsWith("/entry") && <> + <div + data-layout-header-background + className={cn( + // "bg-[url(/pattern-3.svg)] bg-[#000] opacity-50 bg-contain bg-center bg-repeat z-[-2] w-full h-[20rem] absolute bottom-0", + "bg-[#000] opacity-50 bg-contain bg-center bg-repeat z-[-2] w-full h-[20rem] absolute bottom-0", + )} + > + </div> + {pathname.startsWith("/anilist") && + <div + data-layout-header-background-anilist-icon-container + className="w-full flex items-center justify-center absolute bottom-0 h-[5rem] lg:hidden 2xl:flex" + > + <SiAnilist className="text-5xl text-white relative z-[2] opacity-40" /> + </div>} + <div + data-layout-header-background-gradient + className="w-full absolute bottom-0 h-[8rem] bg-gradient-to-t from-[--background] to-transparent z-[-2]" + /> + </>} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/main-layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/main-layout.tsx new file mode 100644 index 0000000..88f3de7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/main-layout.tsx @@ -0,0 +1,114 @@ +"use client" +import { PlaylistsModal } from "@/app/(main)/(library)/_containers/playlists/playlists-modal" +import { ScanProgressBar } from "@/app/(main)/(library)/_containers/scan-progress-bar" +import { ScannerModal } from "@/app/(main)/(library)/_containers/scanner-modal" +import { ErrorExplainer } from "@/app/(main)/_features/error-explainer/error-explainer" +import { GlobalSearch } from "@/app/(main)/_features/global-search/global-search" +import { IssueReport } from "@/app/(main)/_features/issue-report/issue-report" +import { LibraryWatcher } from "@/app/(main)/_features/library-watcher/library-watcher" +import { MediaPreviewModal } from "@/app/(main)/_features/media/_containers/media-preview-modal" +import { MainSidebar } from "@/app/(main)/_features/navigation/main-sidebar" +import { PluginManager } from "@/app/(main)/_features/plugin/plugin-manager" +import { ManualProgressTracking } from "@/app/(main)/_features/progress-tracking/manual-progress-tracking" +import { PlaybackManagerProgressTracking } from "@/app/(main)/_features/progress-tracking/playback-manager-progress-tracking" +import { SeaCommand } from "@/app/(main)/_features/sea-command/sea-command" +import { VideoCoreProvider } from "@/app/(main)/_features/video-core/video-core" +import { useAnimeCollectionLoader } from "@/app/(main)/_hooks/anilist-collection-loader" +import { useAnimeLibraryCollectionLoader } from "@/app/(main)/_hooks/anime-library-collection-loader" +import { useMissingEpisodesLoader } from "@/app/(main)/_hooks/missing-episodes-loader" +import { useAnimeCollectionListener } from "@/app/(main)/_listeners/anilist-collection.listeners" +import { useAutoDownloaderItemListener } from "@/app/(main)/_listeners/autodownloader.listeners" +import { useExtensionListener } from "@/app/(main)/_listeners/extensions.listeners" +import { useExternalPlayerLinkListener } from "@/app/(main)/_listeners/external-player-link.listeners" +import { useMangaListener } from "@/app/(main)/_listeners/manga.listeners" +import { useMiscEventListeners } from "@/app/(main)/_listeners/misc-events.listeners" +import { useSyncListener } from "@/app/(main)/_listeners/sync.listeners" +import { DebridStreamOverlay } from "@/app/(main)/entry/_containers/debrid-stream/debrid-stream-overlay" +import { TorrentStreamOverlay } from "@/app/(main)/entry/_containers/torrent-stream/torrent-stream-overlay" +import { ChapterDownloadsDrawer } from "@/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-drawer" +import { LoadingOverlayWithLogo } from "@/components/shared/loading-overlay-with-logo" +import { AppLayout, AppLayoutContent, AppLayoutSidebar, AppSidebarProvider } from "@/components/ui/app-layout" +import { __isElectronDesktop__ } from "@/types/constants" +import { usePathname, useRouter } from "next/navigation" +import React from "react" +import { useServerStatus } from "../../_hooks/use-server-status" +import { useInvalidateQueriesListener } from "../../_listeners/invalidate-queries.listeners" +import { Announcements } from "../announcements" +import { NakamaManager } from "../nakama/nakama-manager" +import { NativePlayer } from "../native-player/native-player" +import { TopIndefiniteLoader } from "../top-indefinite-loader" + +export const MainLayout = ({ children }: { children: React.ReactNode }) => { + + /** + * Data loaders + */ + useAnimeLibraryCollectionLoader() + useAnimeCollectionLoader() + useMissingEpisodesLoader() + + /** + * Websocket listeners + */ + useAutoDownloaderItemListener() + useAnimeCollectionListener() + useMiscEventListeners() + useExtensionListener() + useMangaListener() + useExternalPlayerLinkListener() + useSyncListener() + useInvalidateQueriesListener() + + const serverStatus = useServerStatus() + const router = useRouter() + const pathname = usePathname() + + React.useEffect(() => { + if (!serverStatus?.isOffline && pathname.startsWith("/offline")) { + router.push("/") + } + }, [serverStatus?.isOffline, pathname]) + + if (serverStatus?.isOffline) { + return <LoadingOverlayWithLogo /> + } + + return ( + <> + <GlobalSearch /> + <ScanProgressBar /> + <LibraryWatcher /> + <ScannerModal /> + <PlaylistsModal /> + <ChapterDownloadsDrawer /> + <TorrentStreamOverlay /> + <DebridStreamOverlay /> + <MediaPreviewModal /> + <PlaybackManagerProgressTracking /> + <ManualProgressTracking /> + <IssueReport /> + <ErrorExplainer /> + <SeaCommand /> + <PluginManager /> + {__isElectronDesktop__ && <VideoCoreProvider> + <NativePlayer /> + </VideoCoreProvider>} + <NakamaManager /> + <TopIndefiniteLoader /> + <Announcements /> + + <AppSidebarProvider> + <AppLayout withSidebar sidebarSize="slim"> + <AppLayoutSidebar> + <MainSidebar /> + </AppLayoutSidebar> + <AppLayout> + <AppLayoutContent> + {children} + </AppLayoutContent> + </AppLayout> + </AppLayout> + </AppSidebarProvider> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/offline-layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/offline-layout.tsx new file mode 100644 index 0000000..bb0233f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/offline-layout.tsx @@ -0,0 +1,83 @@ +import { ErrorExplainer } from "@/app/(main)/_features/error-explainer/error-explainer" +import { IssueReport } from "@/app/(main)/_features/issue-report/issue-report" +import { OfflineSidebar } from "@/app/(main)/_features/navigation/offline-sidebar" +import { PluginManager } from "@/app/(main)/_features/plugin/plugin-manager" +import { ManualProgressTracking } from "@/app/(main)/_features/progress-tracking/manual-progress-tracking" +import { PlaybackManagerProgressTracking } from "@/app/(main)/_features/progress-tracking/playback-manager-progress-tracking" +import { VideoCoreProvider } from "@/app/(main)/_features/video-core/video-core" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useInvalidateQueriesListener } from "@/app/(main)/_listeners/invalidate-queries.listeners" +import { LoadingOverlayWithLogo } from "@/components/shared/loading-overlay-with-logo" +import { AppLayout, AppLayoutContent, AppLayoutSidebar, AppSidebarProvider } from "@/components/ui/app-layout" +import { __isElectronDesktop__ } from "@/types/constants" +import { usePathname, useRouter } from "next/navigation" +import React from "react" +import { NativePlayer } from "../native-player/native-player" +import { SeaCommand } from "../sea-command/sea-command" +import { TopIndefiniteLoader } from "../top-indefinite-loader" + +type OfflineLayoutProps = { + children?: React.ReactNode +} + +export function OfflineLayout(props: OfflineLayoutProps) { + + const { + children, + ...rest + } = props + + + const serverStatus = useServerStatus() + const pathname = usePathname() + const router = useRouter() + + useInvalidateQueriesListener() + + const [cont, setContinue] = React.useState(false) + + React.useEffect(() => { + + if ( + pathname.startsWith("/offline") || + pathname.startsWith("/settings") || + pathname.startsWith("/mediastream") || + pathname.startsWith("/medialinks") + ) { + setContinue(true) + return + } + + router.push("/offline") + }, [pathname, serverStatus?.isOffline]) + + if (!cont) return <LoadingOverlayWithLogo /> + + return ( + <> + <PlaybackManagerProgressTracking /> + <ManualProgressTracking /> + <IssueReport /> + <ErrorExplainer /> + <SeaCommand /> + <PluginManager /> + {__isElectronDesktop__ && <VideoCoreProvider> + <NativePlayer /> + </VideoCoreProvider>} + <TopIndefiniteLoader /> + + <AppSidebarProvider> + <AppLayout withSidebar sidebarSize="slim"> + <AppLayoutSidebar> + <OfflineSidebar /> + </AppLayoutSidebar> + <AppLayout> + <AppLayoutContent> + {children} + </AppLayoutContent> + </AppLayout> + </AppLayout> + </AppSidebarProvider> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/top-navbar.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/top-navbar.tsx new file mode 100644 index 0000000..78cdebf --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/layout/top-navbar.tsx @@ -0,0 +1,137 @@ +import { useRefreshAnimeCollection } from "@/api/hooks/anilist.hooks" +import { OfflineTopMenu } from "@/app/(main)/(offline)/offline/_components/offline-top-menu" +import { RefreshAnilistButton } from "@/app/(main)/_features/anilist/refresh-anilist-button" +import { LayoutHeaderBackground } from "@/app/(main)/_features/layout/_components/layout-header-background" +import { TopMenu } from "@/app/(main)/_features/navigation/top-menu" +import { ManualProgressTrackingButton } from "@/app/(main)/_features/progress-tracking/manual-progress-tracking" +import { PlaybackManagerProgressTrackingButton } from "@/app/(main)/_features/progress-tracking/playback-manager-progress-tracking" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { ChapterDownloadsButton } from "@/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-button" +import { __manga_chapterDownloadsDrawerIsOpenAtom } from "@/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-drawer" +import { AppSidebarTrigger } from "@/components/ui/app-layout" +import { cn } from "@/components/ui/core/styling" +import { Separator } from "@/components/ui/separator/separator" +import { VerticalMenu } from "@/components/ui/vertical-menu" +import { useThemeSettings } from "@/lib/theme/hooks" +import { __isDesktop__ } from "@/types/constants" +import { useSetAtom } from "jotai/react" +import { usePathname } from "next/navigation" +import React from "react" +import { FaDownload } from "react-icons/fa" +import { IoReload } from "react-icons/io5" +import { PluginSidebarTray } from "../plugin/tray/plugin-sidebar-tray" + +type TopNavbarProps = { + children?: React.ReactNode +} + +export function TopNavbar(props: TopNavbarProps) { + + const { + children, + ...rest + } = props + + const serverStatus = useServerStatus() + const isOffline = serverStatus?.isOffline + const ts = useThemeSettings() + + return ( + <> + <div + data-top-navbar + className={cn( + "w-full h-[5rem] relative overflow-hidden flex items-center", + (ts.hideTopNavbar || __isDesktop__) && "lg:hidden", + )} + > + <div data-top-navbar-content-container className="relative z-10 px-4 w-full flex flex-row md:items-center overflow-x-auto"> + <div data-top-navbar-content className="flex items-center w-full gap-3"> + <AppSidebarTrigger /> + {!isOffline ? <TopMenu /> : <OfflineTopMenu />} + <PlaybackManagerProgressTrackingButton /> + <ManualProgressTrackingButton /> + <div data-top-navbar-content-separator className="flex flex-1"></div> + <PluginSidebarTray place="top" /> + {!isOffline && <ChapterDownloadsButton />} + {!isOffline && <RefreshAnilistButton />} + </div> + </div> + <LayoutHeaderBackground /> + </div> + </> + ) +} + + +type SidebarNavbarProps = { + isCollapsed: boolean + handleExpandSidebar: () => void + handleUnexpandedSidebar: () => void +} + +export function SidebarNavbar(props: SidebarNavbarProps) { + + const { + isCollapsed, + handleExpandSidebar, + handleUnexpandedSidebar, + ...rest + } = props + + const serverStatus = useServerStatus() + const ts = useThemeSettings() + const pathname = usePathname() + + const openDownloadQueue = useSetAtom(__manga_chapterDownloadsDrawerIsOpenAtom) + const isMangaPage = pathname.startsWith("/manga") + + /** + * @description + * - Asks the server to fetch an up-to-date version of the user's AniList collection. + */ + const { mutate: refreshAC, isPending: isRefreshingAC } = useRefreshAnimeCollection() + + if (!ts.hideTopNavbar && process.env.NEXT_PUBLIC_PLATFORM !== "desktop") return null + + return ( + <div data-sidebar-navbar className="flex flex-col gap-1"> + <div data-sidebar-navbar-spacer className="px-4 lg:py-1"> + <Separator className="px-4" /> + </div> + {!serverStatus?.isOffline && <VerticalMenu + data-sidebar-navbar-vertical-menu + className="px-4" + collapsed={isCollapsed} + itemClass="relative" + onMouseEnter={handleExpandSidebar} + onMouseLeave={handleUnexpandedSidebar} + items={[ + { + iconType: IoReload, + name: "Refresh AniList", + onClick: () => { + if (isRefreshingAC) return + refreshAC() + }, + }, + ...(isMangaPage ? [ + { + iconType: FaDownload, + name: "Manga downloads", + onClick: () => { + openDownloadQueue(true) + }, + }, + ] : []), + ]} + />} + <div data-sidebar-navbar-playback-manager-progress-tracking-button className="flex justify-center"> + <PlaybackManagerProgressTrackingButton asSidebarButton /> + </div> + <div data-sidebar-navbar-manual-progress-tracking-button className="flex justify-center"> + <ManualProgressTrackingButton asSidebarButton /> + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/library-watcher/library-watcher.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/library-watcher/library-watcher.tsx new file mode 100644 index 0000000..0e92007 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/library-watcher/library-watcher.tsx @@ -0,0 +1,152 @@ +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { __scanner_modalIsOpen } from "@/app/(main)/(library)/_containers/scanner-modal" + +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { Button, CloseButton } from "@/components/ui/button" +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Spinner } from "@/components/ui/loading-spinner" +import { useBoolean } from "@/hooks/use-disclosure" +import { WSEvents } from "@/lib/server/ws-events" +import { useQueryClient } from "@tanstack/react-query" +import { useSetAtom } from "jotai/react" +import React, { useState } from "react" +import { BiSolidBinoculars } from "react-icons/bi" +import { FiSearch } from "react-icons/fi" +import { toast } from "sonner" + +type LibraryWatcherProps = { + children?: React.ReactNode +} + +export function LibraryWatcher(props: LibraryWatcherProps) { + + const { + children, + ...rest + } = props + + const qc = useQueryClient() + const serverStatus = useServerStatus() + const [fileEvent, setFileEvent] = useState<string | null>(null) + const fileAdded = useBoolean(false) + const fileRemoved = useBoolean(false) + const autoScanning = useBoolean(false) + const [progress, setProgress] = useState(0) + + const setScannerModalOpen = useSetAtom(__scanner_modalIsOpen) + + useWebsocketMessageListener<string>({ + type: WSEvents.LIBRARY_WATCHER_FILE_ADDED, + onMessage: data => { + console.log("Library watcher", data) + if (!serverStatus?.settings?.library?.autoScan) { // Only show the notification if auto scan is disabled + fileAdded.on() + setFileEvent(data) + } + }, + }) + + useWebsocketMessageListener<string>({ + type: WSEvents.LIBRARY_WATCHER_FILE_REMOVED, + onMessage: data => { + console.log("Library watcher", data) + if (!serverStatus?.settings?.library?.autoScan) { // Only show the notification if auto scan is disabled + fileRemoved.on() + setFileEvent(data) + } + }, + }) + + // Scan progress event + useWebsocketMessageListener<number>({ + type: WSEvents.SCAN_PROGRESS, + onMessage: data => { + // Remove notification of file added or removed + setFileEvent(null) + fileAdded.off() + fileRemoved.off() + setProgress(data) + // reset progress + if (data === 100) { + setTimeout(() => { + setProgress(0) + }, 2000) + } + }, + }) + + // Auto scan event started + useWebsocketMessageListener<string>({ + type: WSEvents.AUTO_SCAN_STARTED, + onMessage: _ => { + autoScanning.on() + }, + }) + // Auto scan event completed + useWebsocketMessageListener<string>({ + type: WSEvents.AUTO_SCAN_COMPLETED, + onMessage: _ => { + autoScanning.off() + toast.success("Library scanned") + qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] }) + qc.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] }) + }, + }) + + function handleCancel() { + setFileEvent(null) + fileAdded.off() + fileRemoved.off() + } + + if (autoScanning.active && progress > 0) { + return ( + <div className="z-50 fixed bottom-4 right-4"> + <PageWrapper> + <Card className="w-fit max-w-[400px]"> + <CardHeader> + <CardDescription className="flex items-center gap-2 text-base"> + <Spinner className="size-6" /> {progress}% Refreshing your library... + </CardDescription> + </CardHeader> + </Card> + </PageWrapper> + </div> + ) + } else if (!!fileEvent) { + return ( + <div className="z-50 fixed bottom-4 right-4"> + <PageWrapper> + <Card className="w-full max-w-[400px] min-h-[150px] relative"> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <BiSolidBinoculars className="text-brand-400" /> + Library watcher + </CardTitle> + <CardDescription className="flex items-center gap-2 text-base"> + A change has been detected in your library, refresh your entries. + </CardDescription> + </CardHeader> + <CardFooter> + <Button + intent="primary-outline" + leftIcon={<FiSearch />} + size="sm" + onClick={() => setScannerModalOpen(true)} + className="rounded-full" + > + Scan your library + </Button> + </CardFooter> + <CloseButton className="absolute top-2 right-2" onClick={handleCancel} /> + </Card> + </PageWrapper> + </div> + ) + } + + return null +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/anime-entry-studio.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/anime-entry-studio.tsx new file mode 100644 index 0000000..30dcecd --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/anime-entry-studio.tsx @@ -0,0 +1,89 @@ +import { useGetAnilistStudioDetails } from "@/api/hooks/anilist.hooks" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { Badge } from "@/components/ui/badge" +import { Drawer } from "@/components/ui/drawer" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import React from "react" + +type AnimeEntryStudioProps = { + studios?: { nodes?: Array<{ name: string, id: number } | null> | null } | null | undefined +} + +export function AnimeEntryStudio(props: AnimeEntryStudioProps) { + + const { + studios, + ...rest + } = props + + if (!studios?.nodes) return null + + return ( + <AnimeEntryStudioDetailsModal studios={studios}> + <Badge + size="lg" + intent="gray" + className="rounded-full px-0 border-transparent bg-transparent cursor-pointer transition-all hover:bg-transparent hover:text-white hover:-translate-y-0.5" + data-anime-entry-studio-badge + > + {studios?.nodes?.[0]?.name} + </Badge> + </AnimeEntryStudioDetailsModal> + ) +} + +function AnimeEntryStudioDetailsModal(props: AnimeEntryStudioProps & { children: React.ReactElement }) { + + const { + studios, + children, + ...rest + } = props + + const studio = studios?.nodes?.[0] + + if (!studio?.name) return null + + return ( + <> + <Drawer + trigger={children} + size="xl" + title={studio.name} + data-anime-entry-studio-details-modal + > + <div data-anime-entry-studio-details-modal-top-padding className="py-4"></div> + <AnimeEntryStudioDetailsModalContent studios={studios} /> + </Drawer> + </> + ) +} + +function AnimeEntryStudioDetailsModalContent(props: AnimeEntryStudioProps) { + + const { + studios, + ...rest + } = props + + const { data, isLoading } = useGetAnilistStudioDetails(studios?.nodes?.[0]?.id!) + + if (isLoading) return <LoadingSpinner /> + + return ( + <div + data-anime-entry-studio-details-modal-content + className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4" + > + {data?.Studio?.media?.nodes?.map(media => { + return <div key={media?.id!} className="col-span-1" data-anime-entry-studio-details-modal-content-media-entry-card> + <MediaEntryCard + media={media} + type="anime" + showLibraryBadge + /> + </div> + })} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/filepath-selector.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/filepath-selector.tsx new file mode 100644 index 0000000..a4a7a8c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/filepath-selector.tsx @@ -0,0 +1,82 @@ +import { Checkbox } from "@/components/ui/checkbox" +import { cn } from "@/components/ui/core/styling" +import { Separator } from "@/components/ui/separator" +import { upath } from "@/lib/helpers/upath" +import React from "react" + +type FilepathSelectorProps = { + filepaths: string[] + allFilepaths: string[] + onFilepathSelected: React.Dispatch<React.SetStateAction<string[]>> + showFullPath?: boolean +} & React.ComponentPropsWithoutRef<"div"> + +export function FilepathSelector(props: FilepathSelectorProps) { + + const { + filepaths, + allFilepaths, + onFilepathSelected, + showFullPath, + className, + ...rest + } = props + + const allFilesChecked = filepaths.length === allFilepaths.length + + return ( + <> + <div + className={cn( + "overflow-y-auto px-2 space-y-1", + className, + )} {...rest}> + + <div className=""> + <Checkbox + label="Select all files" + value={allFilesChecked ? true : filepaths.length === 0 ? false : "indeterminate"} + onValueChange={checked => { + if (typeof checked === "boolean") { + onFilepathSelected(checked ? allFilepaths : []) + } + }} + fieldClass="w-[fit-content]" + /> + </div> + + <Separator /> + + <div className="divide-[--border] divide-y"> + {allFilepaths?.toSorted((a, b) => a.localeCompare(b)).map((path, index) => ( + <div + key={`${path}-${index}`} + className="py-2" + > + <div className="flex items-center"> + <Checkbox + label={<span className={cn("", showFullPath && "text-[--muted]")}> + {showFullPath ? path.replace(upath.basename(path), "") : upath.basename(path)}{showFullPath && + <span className="text-[--foreground]">{upath.basename(path)}</span>} + </span>} + value={filepaths.includes(path)} + onValueChange={checked => { + if (typeof checked === "boolean") { + onFilepathSelected(prev => checked + ? [...prev, path] + : prev.filter(p => p !== path), + ) + } + }} + labelClass="break-all tracking-wide text-sm" + fieldLabelClass="break-all" + fieldClass="w-[fit-content]" + /> + </div> + </div> + ))} + </div> + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-card-grid.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-card-grid.tsx new file mode 100644 index 0000000..135800d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-card-grid.tsx @@ -0,0 +1,322 @@ +import { LuffyError } from "@/components/shared/luffy-error" +import { cn } from "@/components/ui/core/styling" +import { Skeleton } from "@/components/ui/skeleton" +import React from "react" + +const gridClass = cn( + "grid grid-cols-2 min-[768px]:grid-cols-3 min-[1080px]:grid-cols-4 min-[1320px]:grid-cols-5 min-[1750px]:grid-cols-6 min-[1850px]:grid-cols-7 min-[2000px]:grid-cols-8 gap-4", +) + +type MediaCardGridProps = { + children?: React.ReactNode +} & React.HTMLAttributes<HTMLDivElement> + +export function MediaCardGrid(props: MediaCardGridProps) { + + const { + children, + ...rest + } = props + + if (React.Children.toArray(children).length === 0) { + return <LuffyError title={null}> + <p>Nothing to see</p> + </LuffyError> + } + + return ( + <> + <div + data-media-card-grid + className={cn(gridClass)} + {...rest} + > + {children} + </div> + </> + ) +} + +type MediaCardLazyGridProps = { + children: React.ReactNode + itemCount: number + containerRef?: React.RefObject<HTMLElement> +} & React.HTMLAttributes<HTMLDivElement>; + +export function MediaCardLazyGrid({ + children, + itemCount, + ...rest +}: MediaCardLazyGridProps) { + if (itemCount === 0) { + return <LuffyError title={null}> + <p>Nothing to see</p> + </LuffyError> + } + + if (itemCount <= 48) { + return ( + <MediaCardGrid {...rest}> + {children} + </MediaCardGrid> + ) + } + + return ( + <MediaCardLazyGridRenderer itemCount={itemCount} {...rest}> + {children} + </MediaCardLazyGridRenderer> + ) +} + +const colClasses = [ + { min: 0, cols: 2 }, + { min: 768, cols: 3 }, + { min: 1080, cols: 4 }, + { min: 1320, cols: 5 }, + { min: 1750, cols: 6 }, + { min: 1850, cols: 7 }, + { min: 2000, cols: 8 }, +] + +export function MediaCardLazyGridRenderer({ + children, + itemCount, + ...rest +}: MediaCardLazyGridProps) { + const [visibleIndices, setVisibleIndices] = React.useState<Set<number>>(new Set()) + const [itemHeights, setItemHeights] = React.useState<Map<number, number>>(new Map()) + const gridRef = React.useRef<HTMLDivElement>(null) + const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]) + const observerRef = React.useRef<IntersectionObserver | null>(null) + + // Determine initial columns based on window width + const initialColumns = React.useMemo(() => + colClasses.find(c => window.innerWidth >= c.min)?.cols ?? 8, + [], + ) + + // Initialize visible indices with first row + React.useEffect(() => { + const initialVisibleIndices = new Set( + Array.from(Array(Math.min(initialColumns, itemCount)).keys()), + ) + setVisibleIndices(initialVisibleIndices) + + // Clear heights when component unmounts + return () => { + setItemHeights(new Map()) + } + }, [initialColumns, itemCount]) + + // Intersection Observer to track which items become visible + React.useEffect(() => { + if (!gridRef.current) return + + const observerOptions = { + root: null, + rootMargin: "200px 0px", + threshold: 0, + } + + observerRef.current = new IntersectionObserver((entries) => { + entries.forEach(entry => { + const index = parseInt(entry.target.getAttribute("data-index") ?? "-1") + + if (entry.isIntersecting) { + // Add to visible indices + setVisibleIndices(prev => { + const updated = new Set(prev) + updated.add(index) + return updated + }) + } else { + // Remove from visible indices when scrolled out + setVisibleIndices(prev => { + const updated = new Set(prev) + // Keep initial row always visible + if (index >= initialColumns) { + updated.delete(index) + } + return updated + }) + } + }) + }, observerOptions) + + // Observe all items + itemRefs.current.forEach(ref => { + if (ref) observerRef.current?.observe(ref) + }) + + return () => { + observerRef.current?.disconnect() + } + }, [itemCount, initialColumns]) + + // Function to update item heights + const updateItemHeight = React.useCallback((index: number, height: number) => { + setItemHeights(prev => { + const updated = new Map(prev) + updated.set(index, height) + return updated + }) + }, []) + + return ( + <div data-media-card-lazy-grid-renderer {...rest}> + <div data-media-card-lazy-grid className={cn(gridClass)} ref={gridRef}> + {React.Children.map(children, (child, index) => { + const isVisible = visibleIndices.has(index) + const storedHeight = itemHeights.get(index) + + return ( + <div + data-media-card-lazy-grid-item + ref={el => { itemRefs.current[index] = el }} + data-index={index} + key={!!(child as React.ReactElement)?.key ? (child as React.ReactElement)?.key : index} + className="transition-all duration-300 ease-in-out" + > + {isVisible ? ( + <div + data-media-card-lazy-grid-item-content + ref={(el) => { + // Measure and store height when first rendered + if (el && !storedHeight) { + updateItemHeight(index, el.offsetHeight) + } + }} + > + {child} + </div> + ) : ( + <Skeleton + data-media-card-lazy-grid-item-skeleton + className="w-full" + style={{ + height: storedHeight || "300px", + }} + ></Skeleton> + )} + </div> + ) + })} + </div> + </div> + ) +} + + +// type MediaCardLazyGridProps = { +// children: React.ReactNode +// itemCount: number +// } & React.HTMLAttributes<HTMLDivElement>; +// +// export function MediaCardLazyGrid({ +// children, +// itemCount, +// ...rest +// }: MediaCardLazyGridProps) { +// +// if (itemCount === 0) { +// return <LuffyError title={null}> +// <p>Nothing to see</p> +// </LuffyError> +// } +// +// if (itemCount <= 48) { +// return ( +// <MediaCardGrid {...rest}> +// {children} +// </MediaCardGrid> +// ) +// } +// +// return ( +// <MediaCardLazyGridRenderer itemCount={itemCount} {...rest}> +// {children} +// </MediaCardLazyGridRenderer> +// ) +// } +// +// const colClasses = [ +// { min: 0, cols: 2 }, +// { min: 768, cols: 3 }, +// { min: 1080, cols: 4 }, +// { min: 1320, cols: 5 }, +// { min: 1750, cols: 6 }, +// { min: 1850, cols: 7 }, +// { min: 2000, cols: 8 }, +// ] +// +// export function MediaCardLazyGridRenderer({ +// children, +// itemCount, +// ...rest +// }: MediaCardLazyGridProps) { +// +// const itemRef = React.useRef<HTMLDivElement | null>(null) +// const [itemHeight, setItemHeight] = React.useState<number | null>(null) +// +// const [initialRenderArr] = React.useState(Array.from(Array(colClasses.find(c => window.innerWidth >= c.min)?.cols ?? 8).keys())) +// +// // Render the first row of items +// const [indicesToRender, setIndicesToRender] = React.useState<number[]>(initialRenderArr) +// +// React.useLayoutEffect(() => { +// if (itemRef.current) { +// const itemRect = itemRef.current.getBoundingClientRect() +// const itemHeight = itemRect.height +// setItemHeight(itemHeight) +// setIndicesToRender(Array.from(Array(itemCount).keys())) +// } +// }, [itemRef.current]) +// +// const visibleChildren = indicesToRender.map((index) => (children as any)[index]) +// +// return ( +// <div {...rest}> +// <div +// className={cn(gridClass)} +// > +// {visibleChildren.map((child, index) => ( +// <MediaCardLazyGridItem +// key={!!(child as React.ReactElement)?.key ? (child as React.ReactElement)?.key : index} +// ref={index === 0 ? itemRef : null} +// itemHeight={itemHeight} +// initialRenderCount={initialRenderArr.length} +// index={index} +// > +// {child} +// </MediaCardLazyGridItem> +// ))} +// </div> +// </div> +// ) +// } +// +// const MediaCardLazyGridItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & { +// itemHeight: number | null, +// index: number, +// initialRenderCount: number +// }>(({ +// children, +// itemHeight, +// initialRenderCount, +// index, +// ...rest +// }, mRef) => { +// const ref = React.useRef<HTMLDivElement | null>(null) +// const isInView = useInView(ref as any, { +// margin: "200px", +// once: true, +// }) +// +// return ( +// <div ref={mergeRefs([mRef, ref])} {...rest}> +// {(index < initialRenderCount || isInView) ? children : <div className="w-full" style={{ height: itemHeight || 0 }}></div>} +// </div> +// +// ) +// }) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-card-components.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-card-components.tsx new file mode 100644 index 0000000..6a135b9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-card-components.tsx @@ -0,0 +1,578 @@ +import { AL_BaseAnime_NextAiringEpisode, AL_MediaListStatus, AL_MediaStatus } from "@/api/generated/types" +import { MediaCardBodyBottomGradient } from "@/app/(main)/_features/custom-ui/item-bottom-gradients" +import { MediaEntryProgressBadge } from "@/app/(main)/_features/media/_components/media-entry-progress-badge" +import { __ui_fixBorderRenderingArtifacts } from "@/app/(main)/settings/_containers/ui-settings" +import { GlowingEffect } from "@/components/shared/glowing-effect" +import { imageShimmer } from "@/components/shared/image-helpers" +import { SeaLink } from "@/components/shared/sea-link" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/components/ui/core/styling" +import { Tooltip } from "@/components/ui/tooltip" +import { getImageUrl } from "@/lib/server/assets" +import { useThemeSettings } from "@/lib/theme/hooks" +import { addSeconds, formatDistanceToNow } from "date-fns" +import { atom, useAtom } from "jotai/index" +import { useAtomValue } from "jotai/react" +import capitalize from "lodash/capitalize" +import startCase from "lodash/startCase" +import Image from "next/image" +import React, { memo } from "react" +import { BiCalendarAlt } from "react-icons/bi" +import { IoLibrarySharp } from "react-icons/io5" +import { RiSignalTowerLine } from "react-icons/ri" + +type MediaEntryCardContainerProps = { + children?: React.ReactNode + mRef?: React.RefObject<HTMLDivElement> +} & React.HTMLAttributes<HTMLDivElement> + +export function MediaEntryCardContainer(props: MediaEntryCardContainerProps) { + + const { + children, + className, + mRef, + ...rest + } = props + + return ( + <div + data-media-entry-card-container + ref={mRef} + className={cn( + "h-full col-span-1 group/media-entry-card relative flex flex-col place-content-stretch focus-visible:outline-0 flex-none", + className, + )} + {...rest} + > + {children} + </div> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type MediaEntryCardOverlayProps = { + overlay?: React.ReactNode +} + +export function MediaEntryCardOverlay(props: MediaEntryCardOverlayProps) { + + const { + overlay, + ...rest + } = props + + return ( + <div + data-media-entry-card-overlay + className={cn( + "absolute z-[14] top-0 left-0 w-full", + )} + >{overlay}</div> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type MediaEntryCardHoverPopupProps = { + children?: React.ReactNode + coverImage?: string +} & React.HTMLAttributes<HTMLDivElement> + +export function MediaEntryCardHoverPopup(props: MediaEntryCardHoverPopupProps) { + + const { + children, + className, + coverImage, + ...rest + } = props + + const ts = useThemeSettings() + const markBorderRenderingArtifacts = useAtomValue(__ui_fixBorderRenderingArtifacts) + + return ( + <div + data-media-entry-card-hover-popup + className={cn( + !ts.enableMediaCardBlurredBackground ? "bg-[--media-card-popup-background]" : "bg-[--background]", + "absolute z-[15] opacity-0 scale-100 border border-[rgb(255_255_255_/_5%)] duration-150", + "group-hover/media-entry-card:opacity-100 group-hover/media-entry-card:scale-100", + "group-focus-visible/media-entry-card:opacity-100 group-focus-visible/media-entry-card:scale-100", + "focus-visible:opacity-100 focus-visible:scale-100", + "h-[105%] w-[100%] -top-[5%] rounded-[0.7rem] transition ease-in-out", + "focus-visible:ring-2 ring-brand-400 focus-visible:outline-0", + "hidden lg:block", // Hide on small screens + markBorderRenderingArtifacts && "w-[101%] -left-[0.5%]", + )} + {...rest} + > + <GlowingEffect + spread={50} + glow={true} + disabled={false} + proximity={100} + inactiveZone={0.01} + // movementDuration={4} + className="opacity-15" + /> + {(ts.enableMediaCardBlurredBackground && !!coverImage) && + <div + data-media-entry-card-hover-popup-image-container + className="absolute top-0 left-0 w-full h-full rounded-[--radius] overflow-hidden" + > + <Image + data-media-entry-card-hover-popup-image + src={getImageUrl(coverImage || "")} + alt={"cover image"} + fill + placeholder={imageShimmer(700, 475)} + quality={100} + sizes="20rem" + className="object-cover object-center transition opacity-20" + /> + + <div + data-media-entry-card-hover-popup-image-blur-overlay + className="absolute top-0 w-full h-full backdrop-blur-xl z-[0]" + ></div> + </div>} + + {ts.enableMediaCardBlurredBackground && <div + data-media-entry-card-hover-popup-image-blur-gradient + className="w-full absolute top-0 h-full opacity-60 bg-gradient-to-b from-70% from-[--background] to-transparent z-[2] rounded-[--radius]" + />} + + <div data-media-entry-card-hover-popup-content className="p-2 h-full w-full flex flex-col justify-between relative z-[2]"> + {children} + </div> + </div> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type MediaEntryCardHoverPopupBodyProps = { + children?: React.ReactNode +} & React.HTMLAttributes<HTMLDivElement> + +export function MediaEntryCardHoverPopupBody(props: MediaEntryCardHoverPopupBodyProps) { + + const { + children, + className, + ...rest + } = props + + return ( + <div + data-media-entry-card-hover-popup-body + className={cn( + "space-y-1 select-none", + className, + )} + {...rest} + > + {children} + </div> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type MediaEntryCardHoverPopupFooterProps = { + children?: React.ReactNode +} & React.HTMLAttributes<HTMLDivElement> + +export function MediaEntryCardHoverPopupFooter(props: MediaEntryCardHoverPopupFooterProps) { + + const { + children, + className, + ...rest + } = props + + return ( + <div + data-media-entry-card-hover-popup-footer + className={cn( + "flex gap-2 items-center", + className, + )} + {...rest} + > + {children} + </div> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type MediaEntryCardHoverPopupTitleSectionProps = { + link: string + title: string + season?: string + year?: number + format?: string +} + +export function MediaEntryCardHoverPopupTitleSection(props: MediaEntryCardHoverPopupTitleSectionProps) { + + const { + link, + title, + season, + year, + format, + ...rest + } = props + + return ( + <> + <div data-media-entry-card-hover-popup-title className="select-none"> + <SeaLink + href={link} + className="text-center text-pretty font-medium text-sm lg:text-base px-4 leading-0 line-clamp-2 hover:text-brand-100" + > + {title} + </SeaLink> + </div> + {!!year && <div> + <p + data-media-entry-card-hover-popup-title-section-year-season + className="justify-center text-sm text-[--muted] flex w-full gap-1 items-center" + > + {startCase(format || "")} - <BiCalendarAlt /> {capitalize(season ?? "")} {year} + </p> + </div>} + </> + ) +} + + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type AnimeEntryCardNextAiringProps = { + nextAiring: AL_BaseAnime_NextAiringEpisode | undefined +} + +export function AnimeEntryCardNextAiring(props: AnimeEntryCardNextAiringProps) { + + const { + nextAiring, + ...rest + } = props + + if (!nextAiring) return null + + return ( + <> + <div data-anime-entry-card-next-airing-container className="flex gap-1 items-center justify-center"> + {/*<p className="text-xs min-[2000px]:text-md">Next episode:</p>*/} + <p data-anime-entry-card-next-airing className="text-justify font-normal text-xs min-[2000px]:text-md"> + Episode <span className="font-semibold">{nextAiring?.episode}</span> {formatDistanceToNow(addSeconds(new Date(), + nextAiring?.timeUntilAiring), { addSuffix: true })} + {/*<Badge*/} + {/* size="sm"*/} + {/* className="bg-transparent rounded-[--radius]"*/} + {/*>{nextAiring?.episode}</Badge>*/} + </p> + </div> + </> + ) +} + + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type MediaEntryCardBodyProps = { + link: string + type: "anime" | "manga" + title: string + season?: string + listStatus?: AL_MediaListStatus + status?: AL_MediaStatus + showProgressBar?: boolean + progress?: number + progressTotal?: number + startDate?: { year?: number, month?: number, day?: number } + bannerImage?: string + isAdult?: boolean + showLibraryBadge?: boolean + children?: React.ReactNode + blurAdultContent?: boolean +} + +export function MediaEntryCardBody(props: MediaEntryCardBodyProps) { + + const { + link, + type, + title, + season, + listStatus, + status, + showProgressBar, + progress, + progressTotal, + startDate, + bannerImage, + isAdult, + showLibraryBadge, + children, + blurAdultContent, + ...rest + } = props + + return ( + <> + <SeaLink + href={link} + className="w-full relative focus-visible:ring-2 ring-[--brand]" + data-media-entry-card-body-link + > + <div + data-media-entry-card-body + className={cn( + "media-entry-card__body aspect-[6/8] flex-none rounded-[--radius] object-cover object-center relative overflow-hidden select-none", + )} + > + + {/*[CUSTOM UI] BOTTOM GRADIENT*/} + <MediaCardBodyBottomGradient /> + + {(showProgressBar && progress && progressTotal) && ( + <div + data-media-entry-card-body-progress-bar-container + className={cn( + "absolute top-0 w-full h-1 z-[2] bg-gray-700 left-0", + listStatus === "COMPLETED" && "hidden", + )} + > + <div + data-media-entry-card-body-progress-bar + className={cn( + "h-1 absolute z-[2] left-0 bg-gray-200 transition-all", + (listStatus === "CURRENT") ? "bg-brand-400" : "bg-gray-400", + )} + style={{ + width: `${String(Math.ceil((progress / progressTotal) * 100))}%`, + }} + ></div> + </div> + )} + + {(showLibraryBadge) && + <div data-media-entry-card-body-library-badge className="absolute z-[1] left-0 top-0"> + <Badge + size="xl" intent="warning-solid" + className="rounded-[--radius] rounded-bl-none rounded-tr-none text-orange-900" + ><IoLibrarySharp /></Badge> + </div>} + + {/*RELEASING BADGE*/} + {(status === "RELEASING" || status === "NOT_YET_RELEASED") && + <div data-media-entry-card-body-releasing-badge-container className="absolute z-[10] right-1 top-2"> + <Badge intent={status === "RELEASING" ? "primary-solid" : "zinc-solid"} size="lg"><RiSignalTowerLine /></Badge> + </div>} + + + {children} + + <Image + data-media-entry-card-body-image + src={getImageUrl(bannerImage || "")} + alt={""} + fill + placeholder={imageShimmer(700, 475)} + quality={100} + sizes="20rem" + className={cn( + "object-cover object-center transition-transform", + "group-hover/media-entry-card:scale-110", + (blurAdultContent && isAdult) && "opacity-80", + )} + /> + + {(blurAdultContent && isAdult) && <div + data-media-entry-card-body-blur-adult-content-overlay + className="absolute top-0 w-[125%] h-[125%] -translate-x-[10%] -translate-y-[10%] backdrop-blur-xl z-[3] rounded-[--radius]" + ></div>} + </div> + </SeaLink> + </> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type MediaEntryCardTitleSectionProps = { + title: string + season?: string + year?: number + format?: string +} + +export function MediaEntryCardTitleSection(props: MediaEntryCardTitleSectionProps) { + + const { + title, + season, + year, + format, + ...rest + } = props + + return ( + <div data-media-entry-card-title-section className="pt-2 space-y-1 flex flex-col justify-between h-full select-none"> + <div> + <p + data-media-entry-card-title-section-title + className="text-pretty font-medium min-[2000px]:font-semibold text-sm lg:text-[1rem] min-[2000px]:text-lg line-clamp-2" + >{title}</p> + </div> + {(!!season || !!year) && <div> + <p data-media-entry-card-title-section-year-season className="text-sm text-[--muted] inline-flex gap-1 items-center"> + {capitalize(season ?? "")} {year} + </p> + </div>} + </div> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const __mediaEntryCard_hoveredPopupId = atom<number | undefined>(undefined) + +export const MediaEntryCardHoverPopupBanner = memo(({ + trailerId, + showProgressBar, + mediaId, + progress, + progressTotal, + showTrailer, + disableAnimeCardTrailers, + bannerImage, + isAdult, + blurAdultContent, + link, + listStatus, + status, +}: { + mediaId: number + trailerId?: string + progress?: number + progressTotal?: number + bannerImage?: string + showProgressBar: boolean + showTrailer?: boolean + link: string + disableAnimeCardTrailers?: boolean + blurAdultContent?: boolean + isAdult?: boolean + listStatus?: AL_MediaListStatus + status?: AL_MediaStatus +}) => { + + const [trailerLoaded, setTrailerLoaded] = React.useState(false) + const [actionPopupHoverId] = useAtom(__mediaEntryCard_hoveredPopupId) + const actionPopupHover = actionPopupHoverId === mediaId + const [trailerEnabled, setTrailerEnabled] = React.useState(!!trailerId && !disableAnimeCardTrailers && showTrailer) + + const ts = useThemeSettings() + + React.useEffect(() => { + setTrailerEnabled(!!trailerId && !disableAnimeCardTrailers && showTrailer) + }, [!!trailerId, !disableAnimeCardTrailers, showTrailer]) + + return <SeaLink tabIndex={-1} href={link} data-media-entry-card-hover-popup-banner-link> + <div data-media-entry-card-hover-popup-banner-container className="aspect-[4/2] relative rounded-[--radius] mb-2 cursor-pointer"> + {(showProgressBar && progress && listStatus && progressTotal && progress !== progressTotal) && + <div + data-media-entry-card-hover-popup-banner-progress-bar-container + className="absolute rounded-[--radius] overflow-hidden top-0 w-full h-1 z-[2] bg-gray-700 left-0" + > + <div + data-media-entry-card-hover-popup-banner-progress-bar + className={cn( + "h-1 absolute z-[2] left-0 bg-gray-200 transition-all", + (listStatus === "CURRENT" || listStatus === "COMPLETED") ? "bg-brand-400" : "bg-gray-400", + )} + style={{ width: `${String(Math.ceil((progress / progressTotal) * 100))}%` }} + ></div> + </div>} + + {(status === "RELEASING" || status === "NOT_YET_RELEASED") && + <div data-media-entry-card-hover-popup-banner-releasing-badge-container className="absolute z-[10] right-1 top-2"> + <Tooltip + trigger={<Badge intent={status === "RELEASING" ? "primary-solid" : "zinc-solid"} size="lg"><RiSignalTowerLine /></Badge>} + > + {status === "RELEASING" ? "Releasing" : "Not yet released"} + </Tooltip> + </div>} + + {(!!bannerImage) ? <div className="absolute object-cover top-0 object-center w-full h-full rounded-[--radius] overflow-hidden"><Image + data-media-entry-card-hover-popup-banner-image + src={getImageUrl(bannerImage || "")} + alt={"banner"} + fill + placeholder={imageShimmer(700, 475)} + quality={100} + sizes="20rem" + className={cn( + "object-cover top-0 object-center rounded-[--radius] transition scale-[1.04] duration-200", + "group-hover/media-entry-card:scale-100", + trailerLoaded && "hidden", + )} + /></div> : <div + data-media-entry-card-hover-popup-banner-image-gradient + className="h-full block absolute w-full bg-gradient-to-t from-gray-800 to-transparent" + ></div>} + + {(blurAdultContent && isAdult) && <div + data-media-entry-card-hover-popup-banner-blur-adult-content-overlay + className="absolute top-0 w-full h-full backdrop-blur-xl z-[3] rounded-[--radius]" + ></div>} + + <div data-media-entry-card-hover-popup-banner-progress-badge-container className="absolute z-[4] left-0 bottom-0"> + <MediaEntryProgressBadge + progress={progress} + progressTotal={progressTotal} + forceShowTotal + /> + </div> + + {(trailerEnabled && actionPopupHover) && <div + data-media-entry-card-hover-popup-banner-trailer-container + className={cn( + "absolute w-full h-full overflow-hidden rounded-[--radius]", + !trailerLoaded && "hidden", + )} + > + <iframe + data-media-entry-card-hover-popup-banner-trailer + src={`https://www.youtube-nocookie.com/embed/${trailerId}?autoplay=1&controls=0&mute=1&disablekb=1&loop=1&vq=medium&playlist=${trailerId}&cc_lang_pref=ja`} + className={cn( + "aspect-video w-full absolute left-0", + )} + // playsInline + // preload="none" + // loop + allow="autoplay" + // muted + onLoad={() => setTrailerLoaded(true)} + onError={() => setTrailerEnabled(false)} + /> + </div>} + + {<div + data-media-entry-card-hover-popup-banner-gradient + className={cn( + "w-full absolute -bottom-1 h-[80%] from-10% bg-gradient-to-t from-[--media-card-popup-background] to-transparent z-[2]", + ts.enableMediaCardBlurredBackground && "from-[--background] from-0% bottom-0 rounded-[--radius] opacity-80", + )} + />} + </div> + </SeaLink> +}) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-card-skeleton.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-card-skeleton.tsx new file mode 100644 index 0000000..1a516ab --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-card-skeleton.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from "@/components/ui/skeleton" +import React from "react" + +export const MediaEntryCardSkeleton = () => { + return ( + <> + <Skeleton + data-media-entry-card-skeleton + className="min-w-[250px] basis-[250px] max-w-[250px] h-[350px] bg-gray-900 rounded-[--radius-md] mt-8 mx-2" + /> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-card.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-card.tsx new file mode 100644 index 0000000..b1f538b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-card.tsx @@ -0,0 +1,362 @@ +import { + AL_BaseAnime, + AL_BaseManga, + Anime_EntryLibraryData, + Anime_EntryListData, + Anime_NakamaEntryLibraryData, + Manga_EntryListData, +} from "@/api/generated/types" +import { getAtomicLibraryEntryAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms" +import { usePlayNext } from "@/app/(main)/_atoms/playback.atoms" +import { AnimeEntryCardUnwatchedBadge } from "@/app/(main)/_features/anime/_containers/anime-entry-card-unwatched-badge" +import { ToggleLockFilesButton } from "@/app/(main)/_features/anime/_containers/toggle-lock-files-button" +import { SeaContextMenu } from "@/app/(main)/_features/context-menu/sea-context-menu" +import { + __mediaEntryCard_hoveredPopupId, + AnimeEntryCardNextAiring, + MediaEntryCardBody, + MediaEntryCardContainer, + MediaEntryCardHoverPopup, + MediaEntryCardHoverPopupBanner, + MediaEntryCardHoverPopupBody, + MediaEntryCardHoverPopupFooter, + MediaEntryCardHoverPopupTitleSection, + MediaEntryCardOverlay, + MediaEntryCardTitleSection, +} from "@/app/(main)/_features/media/_components/media-entry-card-components" +import { MediaEntryAudienceScore } from "@/app/(main)/_features/media/_components/media-entry-metadata-components" +import { MediaEntryProgressBadge } from "@/app/(main)/_features/media/_components/media-entry-progress-badge" +import { MediaEntryScoreBadge } from "@/app/(main)/_features/media/_components/media-entry-score-badge" +import { AnilistMediaEntryModal } from "@/app/(main)/_features/media/_containers/anilist-media-entry-modal" +import { useMediaPreviewModal } from "@/app/(main)/_features/media/_containers/media-preview-modal" +import { useAnilistUserAnimeListData } from "@/app/(main)/_hooks/anilist-collection-loader" +import { useMissingEpisodes } from "@/app/(main)/_hooks/missing-episodes-loader" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { MangaEntryCardUnreadBadge } from "@/app/(main)/manga/_containers/manga-entry-card-unread-badge" +import { SeaLink } from "@/components/shared/sea-link" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuTrigger } from "@/components/ui/context-menu" +import { useAtom } from "jotai" +import { useSetAtom } from "jotai/react" +import capitalize from "lodash/capitalize" +import { usePathname, useRouter } from "next/navigation" +import React, { useState } from "react" +import { BiPlay } from "react-icons/bi" +import { IoLibrarySharp } from "react-icons/io5" +import { RiCalendarLine } from "react-icons/ri" +import { PluginMediaCardContextMenuItems } from "../../plugin/actions/plugin-actions" + +type MediaEntryCardBaseProps = { + overlay?: React.ReactNode + withAudienceScore?: boolean + containerClassName?: string + showListDataButton?: boolean +} + +type MediaEntryCardProps<T extends "anime" | "manga"> = { + type: T + media: T extends "anime" ? AL_BaseAnime : T extends "manga" ? AL_BaseManga : never + // Anime-only + listData?: T extends "anime" ? Anime_EntryListData : T extends "manga" ? Manga_EntryListData : never + showLibraryBadge?: T extends "anime" ? boolean : never + showTrailer?: T extends "anime" ? boolean : never + libraryData?: T extends "anime" ? Anime_EntryLibraryData : never + nakamaLibraryData?: T extends "anime" ? Anime_NakamaEntryLibraryData : never + hideUnseenCountBadge?: boolean + hideAnilistEntryEditButton?: boolean +} & MediaEntryCardBaseProps + +export function MediaEntryCard<T extends "anime" | "manga">(props: MediaEntryCardProps<T>) { + + const { + media, + listData: _listData, + libraryData: _libraryData, + nakamaLibraryData, + overlay, + showListDataButton, + showTrailer: _showTrailer, + type, + withAudienceScore = true, + hideUnseenCountBadge = false, + hideAnilistEntryEditButton = false, + } = props + + const router = useRouter() + const serverStatus = useServerStatus() + const missingEpisodes = useMissingEpisodes() + const [listData, setListData] = useState<Anime_EntryListData | undefined>(_listData) + const [libraryData, setLibraryData] = useState<Anime_EntryLibraryData | undefined>(_libraryData) + const setActionPopupHover = useSetAtom(__mediaEntryCard_hoveredPopupId) + + const ref = React.useRef<HTMLDivElement>(null) + + const [__atomicLibraryCollection, getAtomicLibraryEntry] = useAtom(getAtomicLibraryEntryAtom) + + const showLibraryBadge = !!libraryData && !!props.showLibraryBadge + + const showProgressBar = React.useMemo(() => { + return !!listData?.progress + && type === "anime" ? !!(media as AL_BaseAnime)?.episodes : !!(media as AL_BaseManga)?.chapters + && listData?.status !== "COMPLETED" + }, [listData?.progress, media, listData?.status]) + + const showTrailer = React.useMemo(() => _showTrailer && !libraryData && !media?.isAdult, [_showTrailer, libraryData, media]) + + const MANGA_LINK = serverStatus?.isOffline ? `/offline/entry/manga?id=${media.id}` : `/manga/entry?id=${media.id}` + const ANIME_LINK = serverStatus?.isOffline ? `/offline/entry/anime?id=${media.id}` : `/entry?id=${media.id}` + + const link = React.useMemo(() => { + return type === "anime" ? ANIME_LINK : MANGA_LINK + }, [serverStatus?.isOffline, type]) + + const progressTotal = type === "anime" ? (media as AL_BaseAnime)?.episodes : (media as AL_BaseManga)?.chapters + + const pathname = usePathname() + // + // // Dynamically refresh data when LibraryCollection is updated + React.useEffect(() => { + if (pathname !== "/") { + const entry = getAtomicLibraryEntry(media.id) + if (!_listData) { + setListData(entry?.listData) + } + if (!_libraryData) { + setLibraryData(entry?.libraryData) + } + } + }, [pathname, __atomicLibraryCollection]) + + React.useLayoutEffect(() => { + setListData(_listData) + }, [_listData]) + + React.useLayoutEffect(() => { + setLibraryData(_libraryData) + }, [_libraryData]) + + const listDataFromCollection = useAnilistUserAnimeListData(media.id) + + React.useEffect(() => { + if (listDataFromCollection && !_listData) { + setListData(listDataFromCollection) + } + }, [listDataFromCollection, _listData]) + + const { setPlayNext } = usePlayNext() + const handleWatchButtonClicked = React.useCallback(() => { + if ((!!listData?.progress && (listData?.status !== "COMPLETED"))) { + setPlayNext(media.id, () => { + router.push(ANIME_LINK) + }) + } else { + router.push(ANIME_LINK) + } + }, [listData?.progress, listData?.status, media.id]) + + const onPopupMouseEnter = React.useCallback(() => { + setActionPopupHover(media.id) + }, [media.id]) + + const onPopupMouseLeave = React.useCallback(() => { + setActionPopupHover(undefined) + }, [media.id]) + + const { setPreviewModalMediaId } = useMediaPreviewModal() + + if (!media) return null + + return ( + <MediaEntryCardContainer + data-media-id={media.id} + data-media-mal-id={media.idMal} + data-media-type={type} + mRef={ref} + className={props.containerClassName} + data-list-data={JSON.stringify(listData)} + > + + <MediaEntryCardOverlay overlay={overlay} /> + + <SeaContextMenu + content={<ContextMenuGroup> + <ContextMenuLabel className="text-[--muted] line-clamp-1 py-0 my-2"> + {media.title?.userPreferred} + </ContextMenuLabel> + {!serverStatus?.isOffline && <ContextMenuItem + onClick={() => { + setPreviewModalMediaId(media.id!, type) + }} + > + Preview + </ContextMenuItem>} + + <PluginMediaCardContextMenuItems for={type} media={media} /> + </ContextMenuGroup>} + > + <ContextMenuTrigger> + + {/*ACTION POPUP*/} + <MediaEntryCardHoverPopup + onMouseEnter={onPopupMouseEnter} + onMouseLeave={onPopupMouseLeave} + coverImage={media.bannerImage || media.coverImage?.extraLarge || ""} + > + + {/*METADATA SECTION*/} + <MediaEntryCardHoverPopupBody> + + <MediaEntryCardHoverPopupBanner + trailerId={(media as any)?.trailer?.id} + showProgressBar={showProgressBar} + mediaId={media.id} + progress={listData?.progress} + progressTotal={progressTotal} + showTrailer={showTrailer} + disableAnimeCardTrailers={serverStatus?.settings?.library?.disableAnimeCardTrailers} + bannerImage={media.bannerImage || media.coverImage?.extraLarge} + isAdult={media.isAdult} + blurAdultContent={serverStatus?.settings?.anilist?.blurAdultContent} + link={link} + listStatus={listData?.status} + status={media.status} + /> + + <MediaEntryCardHoverPopupTitleSection + title={media.title?.userPreferred || ""} + year={(media as AL_BaseAnime).seasonYear ?? media.startDate?.year} + season={media.season} + format={media.format} + link={link} + /> + + {type === "anime" && ( + <AnimeEntryCardNextAiring nextAiring={(media as AL_BaseAnime).nextAiringEpisode} /> + )} + + {type === "anime" && <div className="py-1"> + <Button + leftIcon={<BiPlay className="text-2xl" />} + intent="gray-subtle" + size="sm" + className="w-full text-sm" + tabIndex={-1} + onClick={handleWatchButtonClicked} + > + {!!listData?.progress && (listData?.status === "CURRENT" || listData?.status === "PAUSED") + ? "Continue watching" + : "Watch"} + </Button> + </div>} + + {type === "manga" && <SeaLink + href={MANGA_LINK} + > + <Button + leftIcon={<IoLibrarySharp />} + intent="gray-subtle" + size="sm" + className="w-full text-sm mt-2" + tabIndex={-1} + > + Read + </Button> + </SeaLink>} + + {(listData?.status) && + <p className="text-center text-sm text-[--muted]"> + {listData?.status === "CURRENT" ? type === "anime" ? "Watching" : "Reading" + : capitalize(listData?.status ?? "")} + </p>} + + </MediaEntryCardHoverPopupBody> + + <MediaEntryCardHoverPopupFooter> + + {(type === "anime" && !!libraryData) && + <ToggleLockFilesButton mediaId={media.id} allFilesLocked={libraryData.allFilesLocked} />} + + {!hideAnilistEntryEditButton && <AnilistMediaEntryModal listData={listData} media={media} type={type} forceModal />} + + {withAudienceScore && + <MediaEntryAudienceScore + meanScore={media.meanScore} + />} + + </MediaEntryCardHoverPopupFooter> + </MediaEntryCardHoverPopup> + </ContextMenuTrigger> + </SeaContextMenu> + + + <MediaEntryCardBody + link={link} + type={type} + title={media.title?.userPreferred || ""} + season={media.season} + listStatus={listData?.status} + status={media.status} + showProgressBar={showProgressBar} + progress={listData?.progress} + progressTotal={progressTotal} + startDate={media.startDate} + bannerImage={media.coverImage?.extraLarge || ""} + isAdult={media.isAdult} + showLibraryBadge={showLibraryBadge} + blurAdultContent={serverStatus?.settings?.anilist?.blurAdultContent} + > + <div data-media-entry-card-body-progress-badge-container className="absolute z-[10] left-0 bottom-0 flex items-end"> + <MediaEntryProgressBadge + progress={listData?.progress} + progressTotal={progressTotal} + forceShowTotal={type === "manga"} + // forceShowProgress={listData?.status === "CURRENT"} + top={!hideUnseenCountBadge ? <> + + {(type === "anime" && (listData?.status === "CURRENT" || listData?.status === "REPEATING")) && ( + <AnimeEntryCardUnwatchedBadge + progress={listData?.progress || 0} + media={media} + libraryData={libraryData} + nakamaLibraryData={nakamaLibraryData} + /> + )} + {type === "manga" && + <MangaEntryCardUnreadBadge mediaId={media.id} progress={listData?.progress} progressTotal={progressTotal} />} + </> : null} + /> + </div> + <div data-media-entry-card-body-score-badge-container className="absolute z-[10] right-0 bottom-0"> + <MediaEntryScoreBadge + isMediaCard + score={listData?.score} + /> + </div> + {(type === "anime" && !!libraryData && missingEpisodes.find(n => n.baseAnime?.id === media.id)) && ( + <div + data-media-entry-card-body-missing-episodes-badge-container + className="absolute z-[10] w-full flex justify-center left-1 bottom-0" + > + <Badge + className="font-semibold animate-pulse text-white bg-gray-950 !bg-opacity-90 rounded-[--radius-md] text-base rounded-bl-none rounded-br-none" + intent="gray-solid" + size="xl" + ><RiCalendarLine /></Badge> + </div> + )} + + </MediaEntryCardBody> + + <MediaEntryCardTitleSection + title={media.title?.userPreferred || ""} + year={(media as AL_BaseAnime).seasonYear ?? media.startDate?.year} + season={media.season} + format={media.format} + /> + + </MediaEntryCardContainer> + ) +} + + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-characters-section.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-characters-section.tsx new file mode 100644 index 0000000..5a8bcd8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-characters-section.tsx @@ -0,0 +1,116 @@ +import { AL_AnimeDetailsById_Media, AL_MangaDetailsById_Media } from "@/api/generated/types" +import { imageShimmer } from "@/components/shared/image-helpers" +import { SeaLink } from "@/components/shared/sea-link" +import { cn } from "@/components/ui/core/styling" +import { useThemeSettings } from "@/lib/theme/hooks" +import Image from "next/image" +import React from "react" +import { BiSolidHeart } from "react-icons/bi" + +type RelationsRecommendationsSectionProps = { + details: AL_AnimeDetailsById_Media | AL_MangaDetailsById_Media | undefined + isMangaPage?: boolean +} + +export function MediaEntryCharactersSection(props: RelationsRecommendationsSectionProps) { + + const { + details, + isMangaPage, + ...rest + } = props + + const ts = useThemeSettings() + + const characters = React.useMemo(() => { + return details?.characters?.edges?.filter(n => n.role === "MAIN" || n.role === "SUPPORTING") || [] + }, [details?.characters?.edges]) + + if (characters.length === 0) return null + + return ( + <> + {/*{!isMangaPage && <Separator />}*/} + + <h2 data-media-entry-characters-section-title>Characters</h2> + + <div + data-media-entry-characters-section-grid + className={cn( + "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4", + isMangaPage && "grid-cols-1 md:grid-col-2 lg:grid-cols-3 xl:grid-cols-2 2xl:grid-cols-2", + )} + > + {characters?.slice(0, 10).map(edge => { + return <div key={edge?.node?.id} className="col-span-1" data-media-entry-characters-section-grid-item> + <div + data-media-entry-characters-section-grid-item-container + className={cn( + "max-w-full flex gap-4", + "rounded-lg relative transition group/episode-list-item select-none", + !!ts.libraryScreenCustomBackgroundImage && ts.libraryScreenCustomBackgroundOpacity > 5 + ? "bg-[--background] p-3" + : "py-3", + "pr-12", + )} + {...rest} + > + + <div + data-media-entry-characters-section-grid-item-image-container + className={cn( + "size-20 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden", + "group/ep-item-img-container", + )} + > + <div + data-media-entry-characters-section-grid-item-image-overlay + className="absolute z-[1] rounded-[--radius-md] w-full h-full" + ></div> + <div + data-media-entry-characters-section-grid-item-image-background + className="bg-[--background] absolute z-[0] rounded-[--radius-md] w-full h-full" + ></div> + {(edge?.node?.image?.large) && <Image + data-media-entry-characters-section-grid-item-image + src={edge?.node?.image?.large || ""} + alt="episode image" + fill + quality={60} + placeholder={imageShimmer(700, 475)} + sizes="10rem" + className={cn("object-cover object-center transition select-none")} + data-src={edge?.node?.image?.large} + />} + </div> + + <div data-media-entry-characters-section-grid-item-content> + <SeaLink href={edge?.node?.siteUrl || "#"} target="_blank" data-media-entry-characters-section-grid-item-content-link> + <p + className={cn( + "text-lg font-semibold transition line-clamp-2 leading-5 hover:text-brand-100", + )} + > + {edge?.node?.name?.full} + </p> + </SeaLink> + + {edge?.node?.age && <p data-media-entry-characters-section-grid-item-content-age className="text-sm"> + {edge?.node?.age} years old + </p>} + + <p data-media-entry-characters-section-grid-item-content-role className="text-[--muted] text-xs"> + {edge?.role} + </p> + + {edge?.node?.isFavourite && <div data-media-entry-characters-section-grid-item-content-favourite> + <BiSolidHeart className="text-pink-600 text-lg block" /> + </div>} + </div> + </div> + </div> + })} + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-metadata-components.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-metadata-components.tsx new file mode 100644 index 0000000..ed52152 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-metadata-components.tsx @@ -0,0 +1,225 @@ +import { AL_AnimeDetailsById_Media_Rankings, AL_MangaDetailsById_Media_Rankings } from "@/api/generated/types" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { SeaLink } from "@/components/shared/sea-link" +import { Badge } from "@/components/ui/badge" +import { IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Disclosure, DisclosureContent, DisclosureItem, DisclosureTrigger } from "@/components/ui/disclosure" +import { Tooltip } from "@/components/ui/tooltip" +import { getScoreColor } from "@/lib/helpers/score" +import capitalize from "lodash/capitalize" +import React from "react" +import { AiFillStar, AiOutlineHeart, AiOutlineStar } from "react-icons/ai" +import { BiHeart, BiHide } from "react-icons/bi" + +type MediaEntryGenresListProps = { + genres?: Array<string | null> | null | undefined + className?: string + type?: "anime" | "manga" +} + +export function MediaEntryGenresList(props: MediaEntryGenresListProps) { + + const { + genres, + className, + type, + ...rest + } = props + + const serverStatus = useServerStatus() + + if (!genres) return null + + if (serverStatus?.isOffline) { + return ( + <> + <div data-media-entry-genres-list-container className={cn("items-center flex flex-wrap gap-3", className)}> + {genres?.map(genre => { + return <Badge + key={genre!} + className={cn( + "opacity-75 hover:opacity-100 transition-all px-0 border-transparent bg-transparent hover:bg-transparent hover:text-white")} + size="lg" + data-media-entry-genres-list-item + > + {genre} + </Badge> + })} + </div> + </> + ) + } else { + return ( + <> + <div data-media-entry-genres-list className={cn("items-center flex flex-wrap gap-3", className)}> + {genres?.map(genre => { + return <SeaLink href={`/search?genre=${genre}&sorting=TRENDING_DESC${type === "manga" ? "&format=MANGA" : ""}`} key={genre!}> + <Badge + className={cn( + "opacity-75 hover:opacity-100 transition-all px-0 border-transparent bg-transparent hover:bg-transparent hover:text-white")} + size="lg" + data-media-entry-genres-list-item + > + {genre} + </Badge> + </SeaLink> + })} + </div> + </> + ) + } +} + +type MediaEntryAudienceScoreProps = { + meanScore?: number | null + badgeClass?: string +} + +export function MediaEntryAudienceScore(props: MediaEntryAudienceScoreProps) { + + const { + meanScore, + badgeClass, + ...rest + } = props + + const status = useServerStatus() + const hideAudienceScore = React.useMemo(() => status?.settings?.anilist?.hideAudienceScore ?? false, + [status?.settings?.anilist?.hideAudienceScore]) + + if (!meanScore) return null + + return ( + <> + {hideAudienceScore ? <Disclosure type="single" collapsible data-media-entry-audience-score-disclosure> + <DisclosureItem value="item-1" className="flex items-center gap-0"> + <Tooltip + side="right" + trigger={<DisclosureTrigger asChild> + <IconButton + data-media-entry-audience-score-disclosure-trigger + intent="gray-basic" + icon={<BiHide className="text-sm" />} + rounded + size="sm" + /> + </DisclosureTrigger>} + >Show audience score</Tooltip> + <DisclosureContent> + <Badge + data-media-entry-audience-score + intent="unstyled" + size="lg" + className={cn(getScoreColor(meanScore, "audience"), badgeClass)} + leftIcon={<BiHeart className="text-xs" />} + >{meanScore / 10}</Badge> + </DisclosureContent> + </DisclosureItem> + </Disclosure> : <Badge + data-media-entry-audience-score + intent="unstyled" + size="lg" + className={cn(getScoreColor(meanScore, "audience"), badgeClass)} + leftIcon={<BiHeart className="text-xs" />} + >{meanScore / 10}</Badge>} + </> + ) +} + +type AnimeEntryRankingsProps = { + rankings?: AL_AnimeDetailsById_Media_Rankings[] | AL_MangaDetailsById_Media_Rankings[] +} + +export function AnimeEntryRankings(props: AnimeEntryRankingsProps) { + + const { + rankings, + ...rest + } = props + + const serverStatus = useServerStatus() + + const seasonMostPopular = rankings?.find(r => (!!r?.season || !!r?.year) && r?.type === "POPULAR" && r.rank <= 10) + const allTimeHighestRated = rankings?.find(r => !!r?.allTime && r?.type === "RATED" && r.rank <= 100) + const seasonHighestRated = rankings?.find(r => (!!r?.season || !!r?.year) && r?.type === "RATED" && r.rank <= 5) + const allTimeMostPopular = rankings?.find(r => !!r?.allTime && r?.type === "POPULAR" && r.rank <= 100) + + const formatFormat = React.useCallback((format: string) => { + if (format === "MANGA") return "" + return (format === "TV" ? "" : format).replace("_", " ") + }, []) + + const Link = React.useCallback((props: { children: React.ReactNode, href: string }) => { + if (serverStatus?.isOffline) { + return <>{props.children}</> + } + + return <SeaLink href={props.href}>{props.children}</SeaLink> + }, [serverStatus]) + + if (!rankings) return null + + return ( + <> + {(!!allTimeHighestRated || !!seasonMostPopular) && + <div className="Sea-AnimeEntryRankings__container flex-wrap gap-2 hidden md:flex" data-anime-entry-rankings> + {allTimeHighestRated && <Link + href={`/search?sorting=SCORE_DESC${allTimeHighestRated.format ? `&format=${allTimeHighestRated.format}` : ""}`} + data-anime-entry-rankings-item + data-anime-entry-rankings-item-all-time-highest-rated + > + <Badge + size="lg" + intent="gray" + leftIcon={<AiFillStar className="text-lg" />} + iconClass="text-yellow-500" + className="opacity-75 transition-all hover:opacity-100 rounded-full bg-transparent border-transparent px-0 hover:bg-transparent hover:text-white" + > + #{String(allTimeHighestRated.rank)} Highest + Rated {formatFormat(allTimeHighestRated.format)} of All + Time + </Badge> + </Link>} + {seasonHighestRated && <Link + href={`/search?sorting=SCORE_DESC${seasonHighestRated.format + ? `&format=${seasonHighestRated.format}` + : ""}${seasonHighestRated.season ? `&season=${seasonHighestRated.season}` : ""}&year=${seasonHighestRated.year}`} + data-anime-entry-rankings-item + data-anime-entry-rankings-item-season-highest-rated + > + <Badge + size="lg" + intent="gray" + leftIcon={<AiOutlineStar />} + iconClass="text-yellow-500" + className="opacity-75 transition-all hover:opacity-100 rounded-full border-transparent bg-transparent px-0 hover:bg-transparent hover:text-white" + > + #{String(seasonHighestRated.rank)} Highest + Rated {formatFormat(seasonHighestRated.format)} of {capitalize(seasonHighestRated.season!)} {seasonHighestRated.year} + </Badge> + </Link>} + {seasonMostPopular && <Link + href={`/search?sorting=POPULARITY_DESC${seasonMostPopular.format + ? `&format=${seasonMostPopular.format}` + : ""}${seasonMostPopular.year ? `&year=${seasonMostPopular.year}` : ""}${seasonMostPopular.season + ? `&season=${seasonMostPopular.season}` + : ""}`} + data-anime-entry-rankings-item + data-anime-entry-rankings-item-season-most-popular + > + <Badge + size="lg" + intent="gray" + leftIcon={<AiOutlineHeart />} + iconClass="text-pink-500" + className="opacity-75 transition-all hover:opacity-100 rounded-full border-transparent bg-transparent px-0 hover:bg-transparent hover:text-white" + > + #{(String(seasonMostPopular.rank))} Most + Popular {formatFormat(seasonMostPopular.format)} of {capitalize(seasonMostPopular.season!)} {seasonMostPopular.year} + </Badge> + </Link>} + </div>} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-page-loading-display.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-page-loading-display.tsx new file mode 100644 index 0000000..54df42d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-page-loading-display.tsx @@ -0,0 +1,32 @@ +import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles" +import { cn } from "@/components/ui/core/styling" +import { Skeleton } from "@/components/ui/skeleton" +import { useThemeSettings } from "@/lib/theme/hooks" +import React from "react" + +export function MediaEntryPageLoadingDisplay() { + const ts = useThemeSettings() + + if (!!ts.libraryScreenCustomBackgroundImage) { + return null + } + + return ( + <div data-media-entry-page-loading-display className="__header h-[30rem] fixed left-0 top-0 w-full"> + <div + className={cn( + "h-[30rem] w-full flex-none object-cover object-center absolute top-0 overflow-hidden", + !ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE, + )} + > + <div + className="w-full absolute z-[1] top-0 h-[15rem] bg-gradient-to-b from-[--background] to-transparent via" + /> + <Skeleton className="h-full absolute w-full" /> + <div + className="w-full absolute bottom-0 h-[20rem] bg-gradient-to-t from-[--background] via-transparent to-transparent" + /> + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-page-small-banner.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-page-small-banner.tsx new file mode 100644 index 0000000..b641b79 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-page-small-banner.tsx @@ -0,0 +1,55 @@ +import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles" +import { cn } from "@/components/ui/core/styling" +import { useThemeSettings } from "@/lib/theme/hooks" +import { __isDesktop__ } from "@/types/constants" +import Image from "next/image" +import React from "react" + +type MediaEntryPageSmallBannerProps = { + bannerImage?: string +} + +export function MediaEntryPageSmallBanner(props: MediaEntryPageSmallBannerProps) { + + const { + bannerImage, + ...rest + } = props + + const ts = useThemeSettings() + + return ( + <> + <div + data-media-entry-page-small-banner + className={cn( + "h-[30rem] w-full flex-none object-cover object-center absolute -top-[5rem] overflow-hidden bg-[--background]", + (ts.hideTopNavbar || __isDesktop__) && "h-[27rem]", + !ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE, + )} + > + <div + data-media-entry-page-small-banner-gradient + className="w-full absolute z-[2] top-0 h-[8rem] opacity-40 bg-gradient-to-b from-[--background] to-transparent via" + /> + <div data-media-entry-page-small-banner-image-container className="absolute w-full h-full"> + {(!!bannerImage) && <Image + data-media-entry-page-small-banner-image + src={bannerImage || ""} + alt="banner image" + fill + quality={100} + priority + sizes="100vw" + className="object-cover object-center z-[1]" + />} + </div> + <div + data-media-entry-page-small-banner-bottom-gradient + className="w-full z-[3] absolute bottom-0 h-[32rem] bg-gradient-to-t from-[--background] via-[--background] via-50% to-transparent" + /> + + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-progress-badge.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-progress-badge.tsx new file mode 100644 index 0000000..bbbb563 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-progress-badge.tsx @@ -0,0 +1,44 @@ +import { Badge } from "@/components/ui/badge" +import { cn } from "@/components/ui/core/styling" +import React from "react" + +type MediaEntryProgressBadgeProps = { + progress?: number + progressTotal?: number + forceShowTotal?: boolean + forceShowProgress?: boolean + top?: React.ReactNode +} + +export const MediaEntryProgressBadge = (props: MediaEntryProgressBadgeProps) => { + const { progress, progressTotal, forceShowTotal, forceShowProgress, top } = props + + // if (!progress) return null + + return ( + <Badge + intent="unstyled" + size="lg" + className="font-semibold tracking-wide flex-col rounded-[--radius-md] rounded-tl-none rounded-br-none border-0 bg-zinc-950/40 px-1.5 py-0.5 gap-0 !h-auto" + data-media-entry-progress-badge + > + {top && <span data-media-entry-progress-badge-top className="block"> + {top} + </span>} + {(!!progress || forceShowProgress) && <span + data-media-entry-progress-badge-progress + className="block" + data-progress={progress} + data-progress-total={progressTotal} + data-force-show-total={forceShowTotal} + > + {progress || 0}{(!!progressTotal || forceShowTotal) && <span + data-media-entry-progress-badge-progress-total + className={cn( + "text-[--muted]", + )} + >/{(!!progressTotal) ? progressTotal : "-"}</span>} + </span>} + </Badge> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-score-badge.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-score-badge.tsx new file mode 100644 index 0000000..ed4072c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-entry-score-badge.tsx @@ -0,0 +1,27 @@ +import { cn } from "@/components/ui/core/styling" +import { getScoreColor } from "@/lib/helpers/score" +import React from "react" +import { BiSolidStar, BiStar } from "react-icons/bi" + +type MediaEntryScoreBadgeProps = { + isMediaCard?: boolean + score?: number // 0-100 +} + +export const MediaEntryScoreBadge = (props: MediaEntryScoreBadgeProps) => { + const { score, isMediaCard } = props + + if (!score) return null + return ( + <div + data-media-entry-score-badge + className={cn( + "backdrop-blur-lg inline-flex items-center justify-center border gap-1 w-14 h-7 rounded-full font-bold bg-opacity-70 drop-shadow-sm shadow-lg", + isMediaCard && "rounded-none rounded-tl-lg border-none", + getScoreColor(score, "user"), + )} + > + {score >= 90 ? <BiSolidStar className="text-sm" /> : <BiStar className="text-sm" />} {(score === 0) ? "-" : score / 10} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-episode-info-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-episode-info-modal.tsx new file mode 100644 index 0000000..6ec50c7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-episode-info-modal.tsx @@ -0,0 +1,120 @@ +import { Nullish } from "@/api/generated/types" +import { IconButton } from "@/components/ui/button" +import { Modal, ModalProps } from "@/components/ui/modal" +import { Popover, PopoverProps } from "@/components/ui/popover" +import { Separator } from "@/components/ui/separator" +import Image from "next/image" +import React from "react" +import { AiFillWarning } from "react-icons/ai" +import { MdInfo } from "react-icons/md" +import { useWindowSize } from "react-use" + +type MediaEpisodeInfoModalProps = { + title?: Nullish<string> + image?: Nullish<string> + episodeTitle?: Nullish<string> + airDate?: Nullish<string> + length?: Nullish<number | string> + summary?: Nullish<string> + isInvalid?: Nullish<boolean> + filename?: Nullish<string> +} + +function IsomorphicPopover(props: PopoverProps & ModalProps) { + const { title, children, ...rest } = props + const { width } = useWindowSize() + + if (width && width > 1024) { + return <Popover + {...rest} + className="max-w-xl !w-full overflow-hidden min-w-md" + > + {children} + </Popover> + } + + return <Modal + {...rest} + title={title} + titleClass="text-xl" + contentClass="max-w-2xl overflow-hidden" + > + {children} + </Modal> +} + +export function MediaEpisodeInfoModal(props: MediaEpisodeInfoModalProps) { + + const { + title, + image, + episodeTitle, + airDate, + length, + summary, + isInvalid, + filename, + ...rest + } = props + + if (!episodeTitle && !filename && !summary) return null + + return ( + <> + <IsomorphicPopover + data-media-episode-info-modal + trigger={<IconButton + icon={<MdInfo />} + className="opacity-30 hover:opacity-100 transform-opacity" + intent="gray-basic" + size="xs" + />} + title={title || "Episode"} + // contentClass="max-w-2xl" + // titleClass="text-xl" + > + + {image && <div + data-media-episode-info-modal-image-container + className="h-[8rem] rounded-t-md w-full flex-none object-cover object-center overflow-hidden absolute left-0 top-0 z-[0]" + > + <Image + data-media-episode-info-modal-image + src={image} + alt="banner" + fill + quality={80} + priority + sizes="20rem" + className="object-cover object-center opacity-30" + /> + <div + data-media-episode-info-modal-image-gradient + className="z-[5] absolute bottom-0 w-full h-[80%] bg-gradient-to-t from-[--background] to-transparent" + /> + </div>} + + <div data-media-episode-info-modal-content className="space-y-4 z-[5] relative"> + <p data-media-episode-info-modal-content-title className="text-lg line-clamp-2 font-semibold"> + {episodeTitle?.replaceAll("`", "'")} + {isInvalid && <AiFillWarning />} + </p> + {!(!airDate && !length) && <p className="text-[--muted]"> + {airDate || "Unknown airing date"} - {length || "N/A"} minutes + </p>} + <p className="text-gray-300"> + {summary?.replaceAll("`", "'") || "No summary"} + </p> + + {filename && <> + <Separator /> + <p className="text-[--muted] line-clamp-2"> + {filename} + </p> + </>} + </div> + + </IsomorphicPopover> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-genre-selector.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-genre-selector.tsx new file mode 100644 index 0000000..6fd3c10 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-genre-selector.tsx @@ -0,0 +1,48 @@ +import { cn } from "@/components/ui/core/styling" +import { HorizontalDraggableScroll } from "@/components/ui/horizontal-draggable-scroll" +import { StaticTabs, StaticTabsItem } from "@/components/ui/tabs" +import React from "react" + +type MediaGenreSelectorProps = { + items: StaticTabsItem[] + className?: string + staticTabsClass?: string, + staticTabsTriggerClass?: string +} + +export function MediaGenreSelector(props: MediaGenreSelectorProps) { + + const { + items, + className, + staticTabsClass, + staticTabsTriggerClass, + ...rest + } = props + + return ( + <> + <HorizontalDraggableScroll + data-media-genre-selector + className={cn( + "scroll-pb-1 flex", + className, + )} + > + <div data-media-genre-selector-scroll-container className="flex flex-1"></div> + <StaticTabs + className={cn( + "px-2 overflow-visible gap-2 py-4 w-fit", + staticTabsClass, + )} + triggerClass={cn( + "text-base rounded-[--radius-md] ring-1 ring-transparent data-[current=true]:ring-brand-500 data-[current=true]:text-brand-300", + staticTabsTriggerClass, + )} + items={items} + /> + <div data-media-genre-selector-scroll-container-end className="flex flex-1"></div> + </HorizontalDraggableScroll> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-page-header-components.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-page-header-components.tsx new file mode 100644 index 0000000..325d303 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_components/media-page-header-components.tsx @@ -0,0 +1,542 @@ +import { AL_BaseAnime, AL_BaseManga, AL_MediaStatus, Anime_EntryListData, Manga_EntryListData, Nullish } from "@/api/generated/types" +import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles" +import { AnilistMediaEntryModal } from "@/app/(main)/_features/media/_containers/anilist-media-entry-modal" +import { imageShimmer } from "@/components/shared/image-helpers" +import { TextGenerateEffect } from "@/components/shared/text-generate-effect" +import { Badge } from "@/components/ui/badge" +import { IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Popover } from "@/components/ui/popover" +import { Tooltip } from "@/components/ui/tooltip" +import { getScoreColor } from "@/lib/helpers/score" +import { getImageUrl } from "@/lib/server/assets" +import { ThemeMediaPageBannerSize, ThemeMediaPageBannerType, ThemeMediaPageInfoBoxSize, useIsMobile, useThemeSettings } from "@/lib/theme/hooks" +import capitalize from "lodash/capitalize" +import { motion } from "motion/react" +import Image from "next/image" +import React from "react" +import { BiCalendarAlt, BiSolidStar, BiStar } from "react-icons/bi" +import { MdOutlineSegment } from "react-icons/md" +import { RiSignalTowerFill } from "react-icons/ri" +import { useWindowScroll, useWindowSize } from "react-use" + +const MotionImage = motion.create(Image) + +type MediaPageHeaderProps = { + children?: React.ReactNode + backgroundImage?: string + coverImage?: string +} + +export function MediaPageHeader(props: MediaPageHeaderProps) { + + const { + children, + backgroundImage, + coverImage, + ...rest + } = props + + const ts = useThemeSettings() + const { y } = useWindowScroll() + const { isMobile } = useIsMobile() + + const bannerImage = backgroundImage || coverImage + const shouldHideBanner = ( + (ts.mediaPageBannerType === ThemeMediaPageBannerType.HideWhenUnavailable && !backgroundImage) + || ts.mediaPageBannerType === ThemeMediaPageBannerType.Hide + ) + const shouldBlurBanner = (ts.mediaPageBannerType === ThemeMediaPageBannerType.BlurWhenUnavailable && !backgroundImage) || + ts.mediaPageBannerType === ThemeMediaPageBannerType.Blur + + const shouldDimBanner = (ts.mediaPageBannerType === ThemeMediaPageBannerType.DimWhenUnavailable && !backgroundImage) || + ts.mediaPageBannerType === ThemeMediaPageBannerType.Dim + + const shouldShowBlurredBackground = ts.enableMediaPageBlurredBackground && ( + y > 100 + || (shouldHideBanner && !ts.libraryScreenCustomBackgroundImage) + ) + + + return ( + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + transition={{ duration: 1, ease: "easeOut" }} + className="__meta-page-header relative group/media-page-header" + data-media-page-header + > + + {/*<div*/} + {/* className={cn(MediaPageHeaderAnatomy.fadeBg({ size }))}*/} + {/*/>*/} + + {(ts.enableMediaPageBlurredBackground) && <div + data-media-page-header-blurred-background + className={cn( + "fixed opacity-0 transition-opacity duration-1000 top-0 left-0 w-full h-full z-[4] bg-[--background] rounded-xl", + shouldShowBlurredBackground && "opacity-100", + )} + > + <Image + data-media-page-header-blurred-background-image + src={getImageUrl(bannerImage || "")} + alt={""} + fill + quality={100} + sizes="20rem" + className={cn( + "object-cover object-bottom transition opacity-10", + ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && "object-left", + )} + /> + + <div + data-media-page-header-blurred-background-blur + className="absolute top-0 w-full h-full backdrop-blur-2xl z-[2]" + ></div> + </div>} + + {children} + + <div + data-media-page-header-banner + className={cn( + "w-full scroll-locked-offset flex-none object-cover object-center z-[3] bg-[--background] h-[20rem]", + ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small ? "lg:h-[28rem]" : "h-[20rem] lg:h-[32rem] 2xl:h-[36.5rem]", + ts.libraryScreenCustomBackgroundImage ? "absolute -top-[5rem]" : "fixed transition-opacity top-0 duration-1000", + !ts.libraryScreenCustomBackgroundImage && y > 100 && (ts.enableMediaPageBlurredBackground ? "opacity-0" : shouldDimBanner + ? "opacity-15" + : (y > 300 ? "opacity-5" : "opacity-15")), + !ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE, + shouldHideBanner && "bg-transparent", + )} + // style={{ + // opacity: !ts.libraryScreenCustomBackgroundImage && y > 100 ? (ts.enableMediaPageBlurredBackground ? 0 : shouldDimBanner ? 0.15 + // : 1 - Math.min(y * 0.005, 0.9) ) : 1, }} + > + {/*TOP FADE*/} + <div + data-media-page-header-banner-top-gradient + className={cn( + "w-full absolute z-[2] top-0 h-[8rem] opacity-40 bg-gradient-to-b from-[--background] to-transparent via", + )} + /> + + {/*BOTTOM OVERFLOW FADE*/} + <div + data-media-page-header-banner-bottom-gradient + className={cn( + "w-full z-[2] absolute scroll-locked-offset bottom-[-5rem] h-[5em] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent", + !ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE, + shouldHideBanner && "hidden", + )} + /> + + <motion.div + data-media-page-header-banner-image-container + className={cn( + "absolute top-0 left-0 scroll-locked-offset w-full h-full overflow-hidden", + // shouldBlurBanner && "blur-xl", + shouldHideBanner && "hidden", + )} + initial={{ scale: 1, y: 0 }} + animate={{ + scale: !ts.libraryScreenCustomBackgroundImage ? Math.min(1 + y * 0.0002, 1.03) : 1, + y: isMobile ? 0 : Math.max(y * -0.9, -10), + }} + exit={{ scale: 1, y: 0 }} + transition={{ duration: 0.6, ease: "easeOut" }} + > + {(!!bannerImage) && <MotionImage + data-media-page-header-banner-image + src={getImageUrl(bannerImage || "")} + alt="banner image" + fill + quality={100} + priority + sizes="100vw" + className={cn( + "object-cover object-center scroll-locked-offset z-[1]", + // shouldDimBanner && "!opacity-30", + )} + initial={{ scale: 1.05, x: 0, y: -10, opacity: 0 }} + animate={{ scale: 1, x: 0, y: 1, opacity: shouldDimBanner ? 0.3 : 1 }} + transition={{ duration: 0.6, delay: 0.2, ease: "easeOut" }} + />} + + {shouldBlurBanner && <div + data-media-page-header-banner-blur + className="absolute top-0 w-full h-full backdrop-blur-xl z-[2] " + ></div>} + + {/*LEFT MASK*/} + <div + data-media-page-header-banner-left-gradient + className={cn( + "hidden lg:block max-w-[60rem] xl:max-w-[100rem] w-full z-[2] h-full absolute left-0 bg-gradient-to-r from-[--background] transition-opacity to-transparent", + "opacity-85 duration-1000", + // y > 300 && "opacity-70", + )} + /> + <div + data-media-page-header-banner-right-gradient + className={cn( + "hidden lg:block max-w-[60rem] xl:max-w-[80rem] w-full z-[2] h-full absolute left-0 bg-gradient-to-r from-[--background] from-25% transition-opacity to-transparent", + "opacity-50 duration-500", + )} + /> + </motion.div> + + {/*BOTTOM FADE*/} + <div + data-media-page-header-banner-bottom-gradient + className={cn( + "w-full z-[3] absolute bottom-0 h-[50%] bg-gradient-to-t from-[--background] via-transparent via-100% to-transparent", + shouldHideBanner && "hidden", + )} + /> + + <div + data-media-page-header-banner-dim + className={cn( + "absolute h-full w-full block lg:hidden bg-[--background] opacity-70 z-[2]", + shouldHideBanner && "hidden", + )} + /> + + </div> + </motion.div> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type MediaPageHeaderDetailsContainerProps = { + children?: React.ReactNode +} + +export function MediaPageHeaderDetailsContainer(props: MediaPageHeaderDetailsContainerProps) { + + const { + children, + ...rest + } = props + + const ts = useThemeSettings() + const { y } = useWindowScroll() + const { width } = useWindowSize() + + return ( + <> + <motion.div + initial={{ opacity: 1, y: 0 }} + animate={{ + opacity: (width >= 1024 && y > 400) ? Math.max(1 - y * 0.006, 0.1) : 1, + y: (width >= 1024 && y > 200) ? Math.max(y * -0.05, -40) : 0, + }} + transition={{ duration: 0.4, ease: "easeOut" }} + className="relative z-[4]" + > + <motion.div + initial={{ opacity: 0, x: -20 }} + animate={{ opacity: 1, x: 0 }} + exit={{ opacity: 0, x: -20 }} + transition={{ duration: 0.7, delay: 0.4 }} + className="relative z-[4]" + data-media-page-header-details-container + > + <div + data-media-page-header-details-inner-container + className={cn( + "space-y-8 p-6 sm:p-8 relative", + ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && "p-6 sm:py-4 sm:px-8", + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid + ? "w-full" + : "lg:max-w-[100%] xl:max-w-[80%] 2xl:max-w-[65rem] 5xl:max-w-[80rem]", + )} + > + <motion.div + {...{ + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { + type: "spring", + damping: 20, + stiffness: 100, + delay: 0.1, + }, + }} + className="space-y-4" + data-media-page-header-details-motion-container + > + + {children} + + </motion.div> + + </div> + </motion.div> + </motion.div> + </> + ) +} + + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + +type MediaPageHeaderEntryDetailsProps = { + children?: React.ReactNode + coverImage?: string + color?: string + title?: string + englishTitle?: string + romajiTitle?: string + startDate?: { year?: number, month?: number } + season?: string + progressTotal?: number + status?: AL_MediaStatus + description?: string + smallerTitle?: boolean + + listData?: Anime_EntryListData | Manga_EntryListData + media: AL_BaseAnime | AL_BaseManga + type: "anime" | "manga" + offlineAnilistAnimeEntryModal?: React.ReactNode +} + +export function MediaPageHeaderEntryDetails(props: MediaPageHeaderEntryDetailsProps) { + + const { + children, + coverImage, + title, + englishTitle, + romajiTitle, + startDate, + season, + progressTotal, + status, + description, + color, + smallerTitle, + + listData, + media, + type, + offlineAnilistAnimeEntryModal, + ...rest + } = props + + const ts = useThemeSettings() + const { y } = useWindowScroll() + + return ( + <> + <div className="flex flex-col lg:flex-row gap-8" data-media-page-header-entry-details> + + {!!coverImage && <motion.div + initial={{ opacity: 0 }} + animate={{ + opacity: 1, + // scale: Math.max(1 - y * 0.0002, 0.96), + // y: Math.max(y * -0.1, -10) + }} + transition={{ duration: 0.3 }} + data-media-page-header-entry-details-cover-image-container + className={cn( + "flex-none aspect-[6/8] max-w-[150px] mx-auto lg:m-0 h-auto sm:max-w-[200px] lg:max-w-[230px] w-full relative rounded-[--radius-md] overflow-hidden bg-[--background] shadow-md block", + ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && "max-w-[150px] lg:m-0 h-auto sm:max-w-[195px] lg:max-w-[210px] -top-1", + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "lg:max-w-[270px]", + (ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid) && "lg:max-w-[220px]", + )} + > + <motion.div + initial={{ scale: 1.1, x: -10 }} + animate={{ scale: 1, x: 0 }} + transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }} + className="w-full h-full" + > + <MotionImage + data-media-page-header-entry-details-cover-image + src={getImageUrl(coverImage)} + alt="cover image" + fill + priority + placeholder={imageShimmer(700, 475)} + className="object-cover object-center" + initial={{ scale: 1.1, x: 0 }} + animate={{ scale: Math.min(1 + y * 0.0002, 1.05), x: 0 }} + transition={{ duration: 0.3, ease: "easeOut" }} + /> + </motion.div> + </motion.div>} + + + <div + data-media-page-header-entry-details-content + className={cn( + "space-y-2 lg:space-y-4", + (ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small || ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid) && "lg:space-y-3", + )} + > + {/*TITLE*/} + <div className="space-y-2" data-media-page-header-entry-details-title-container> + <TextGenerateEffect + className={cn( + "[text-shadow:_0_1px_10px_rgb(0_0_0_/_20%)] text-white line-clamp-2 pb-1 text-center lg:text-left text-pretty text-3xl 2xl:text-5xl xl:max-w-[50vw]", + smallerTitle && "text-3xl 2xl:text-3xl", + )} + words={title || ""} + /> + {(!!englishTitle && title?.toLowerCase() !== englishTitle?.toLowerCase()) && + <h4 className="text-gray-200 line-clamp-1 text-center lg:text-left xl:max-w-[50vw]">{englishTitle}</h4>} + {(!!romajiTitle && title?.toLowerCase() !== romajiTitle?.toLowerCase()) && + <h4 className="text-gray-200 line-clamp-1 text-center lg:text-left xl:max-w-[50vw]">{romajiTitle}</h4>} + </div> + + {/*DATE*/} + {!!startDate?.year && ( + <div + className="flex gap-4 items-center flex-wrap justify-center lg:justify-start" + data-media-page-header-entry-details-date-container + > + <p className="text-lg text-white flex gap-1 items-center"> + <BiCalendarAlt /> {new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + }).format(new Date(startDate?.year || 0, startDate?.month ? startDate?.month - 1 : 0))}{!!season + ? ` - ${capitalize(season)}` + : ""} + </p> + + {((status !== "FINISHED" && type === "anime") || type === "manga") && <Badge + size="lg" + intent={status === "RELEASING" ? "primary" : "gray"} + className="bg-transparent border-transparent dark:text-brand-200 px-0 rounded-none" + leftIcon={<RiSignalTowerFill />} + data-media-page-header-entry-details-date-badge + > + {capitalize(status || "")?.replaceAll("_", " ")} + </Badge>} + + {ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && <Popover + trigger={ + <IconButton + intent="white-subtle" + className="rounded-full" + size="sm" + icon={<MdOutlineSegment />} + /> + } + className="max-w-[40rem] bg-[--background] p-4 w-[20rem] lg:w-[40rem] text-md" + > + <span className="transition-colors">{description?.replace(/(<([^>]+)>)/ig, "")}</span> + </Popover>} + </div> + )} + + + {/*LIST*/} + <div className="flex gap-2 md:gap-4 items-center justify-center lg:justify-start" data-media-page-header-entry-details-more-info> + + <MediaPageHeaderScoreAndProgress + score={listData?.score} + progress={listData?.progress} + episodes={progressTotal} + /> + + <AnilistMediaEntryModal listData={listData} media={media} type={type} /> + + {(listData?.status || listData?.repeat) && + <div + data-media-page-header-entry-details-status + className="text-base text-white md:text-lg flex items-center" + >{capitalize(listData?.status === "CURRENT" + ? type === "anime" ? "watching" : "reading" + : listData?.status)} + {listData?.repeat && <Tooltip + trigger={<Badge + size="md" + intent="gray" + className="ml-3" + data-media-page-header-entry-details-repeating-badge + > + {listData?.repeat} + + </Badge>} + > + {listData?.repeat} {type === "anime" ? "rewatch" : "reread"}{listData?.repeat > 1 + ? type === "anime" ? "es" : "s" + : ""} + </Tooltip>} + </div>} + + </div> + + {ts.mediaPageBannerSize !== ThemeMediaPageBannerSize.Small && <Popover + trigger={<div + className={cn( + "cursor-pointer max-h-16 line-clamp-3 col-span-2 left-[-.5rem] text-[--muted] 2xl:max-w-[50vw] hover:text-white transition-colors duration-500 text-sm pr-2", + "bg-transparent rounded-[--radius-md] text-center lg:text-left", + )} + data-media-page-header-details-description-trigger + > + {description?.replace(/(<([^>]+)>)/ig, "")} + </div>} + className="max-w-[40rem] bg-[--background] p-4 w-[20rem] lg:w-[40rem] text-md" + data-media-page-header-details-description-popover + > + <span className="transition-colors">{description?.replace(/(<([^>]+)>)/ig, "")}</span> + </Popover>} + + {children} + + </div> + + </div> + </> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function MediaPageHeaderScoreAndProgress({ score, progress, episodes }: { + score: Nullish<number>, + progress: Nullish<number>, + episodes: Nullish<number>, +}) { + + return ( + <> + {!!score && <Badge + leftIcon={score >= 90 ? <BiSolidStar className="text-sm" /> : <BiStar className="text-sm" />} + size="xl" + intent="unstyled" + className={getScoreColor(score, "user")} + data-media-page-header-score-badge + > + {score / 10} + </Badge>} + <Badge + size="xl" + intent="basic" + className="!text-xl font-bold !text-white px-0 gap-0 rounded-none" + data-media-page-header-progress-badge + > + <span data-media-page-header-progress-badge-progress>{`${progress ?? 0}`}</span><span + data-media-page-header-progress-total + className={cn( + (!progress || progress !== episodes) && "opacity-60", + )} + >/{episodes || "-"}</span> + </Badge> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_containers/anilist-media-entry-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_containers/anilist-media-entry-modal.tsx new file mode 100644 index 0000000..ac2e65a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_containers/anilist-media-entry-modal.tsx @@ -0,0 +1,327 @@ +"use client" +import { AL_BaseAnime, AL_BaseManga, AL_MediaListStatus, Anime_EntryListData, Manga_EntryListData } from "@/api/generated/types" +import { useDeleteAnilistListEntry, useEditAnilistListEntry } from "@/api/hooks/anilist.hooks" +import { useUpdateAnimeEntryRepeat } from "@/api/hooks/anime_entries.hooks" +import { useCurrentUser } from "@/app/(main)/_hooks/use-server-status" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Disclosure, DisclosureContent, DisclosureItem, DisclosureTrigger } from "@/components/ui/disclosure" +import { defineSchema, Field, Form } from "@/components/ui/form" +import { Modal, ModalProps } from "@/components/ui/modal" +import { NumberInput } from "@/components/ui/number-input" +import { Popover, PopoverProps } from "@/components/ui/popover" +import { Tooltip } from "@/components/ui/tooltip" +import { normalizeDate } from "@/lib/helpers/date" +import { getImageUrl } from "@/lib/server/assets" +import { useWindowSize } from "@uidotdev/usehooks" +import Image from "next/image" +import React, { Fragment } from "react" +import { AiFillEdit } from "react-icons/ai" +import { BiListPlus, BiPlus, BiStar, BiTrash } from "react-icons/bi" +import { useToggle } from "react-use" + +type AnilistMediaEntryModalProps = { + children?: React.ReactNode + listData?: Anime_EntryListData | Manga_EntryListData + media?: AL_BaseAnime | AL_BaseManga + hideButton?: boolean + type?: "anime" | "manga" + forceModal?: boolean +} + +export const mediaListDataSchema = defineSchema(({ z, presets }) => z.object({ + status: z.custom<AL_MediaListStatus>().nullish(), + score: z.number().min(0).max(100).nullish(), + progress: z.number().min(0).nullish(), + startedAt: presets.datePicker.nullish(), + completedAt: presets.datePicker.nullish(), +})) + +function IsomorphicPopover(props: PopoverProps & ModalProps & { media?: AL_BaseAnime | AL_BaseManga, forceModal?: boolean }) { + const { title, children, media, forceModal, ...rest } = props + const { width } = useWindowSize() + + if ((width && width > 1024) && !forceModal) { + return <Popover + {...rest} + className="max-w-5xl !w-full overflow-hidden bg-gray-950/95 backdrop-blur-sm rounded-xl" + > + <p className="mb-4 font-semibold text-center px-6 line-clamp-1"> + {media?.title?.userPreferred} + </p> + {children} + </Popover> + } + + return <Modal + {...rest} + title={title} + titleClass="text-xl" + contentClass="max-w-3xl overflow-hidden" + > + {media?.bannerImage && <div + data-anilist-media-entry-modal-banner-image-container + className="h-24 w-full flex-none object-cover object-center overflow-hidden absolute left-0 top-0 z-[0]" + > + <Image + data-anilist-media-entry-modal-banner-image + src={getImageUrl(media?.bannerImage!)} + alt="banner" + fill + quality={80} + priority + sizes="20rem" + className="object-cover object-center opacity-5 z-[1]" + /> + <div + data-anilist-media-entry-modal-banner-image-bottom-gradient + className="z-[5] absolute bottom-0 w-full h-[60%] bg-gradient-to-t from-[--background] to-transparent" + /> + </div>} + {children} + </Modal> +} + + +export const AnilistMediaEntryModal = (props: AnilistMediaEntryModalProps) => { + const [open, toggle] = useToggle(false) + + const { children, media, listData, hideButton, type = "anime", forceModal, ...rest } = props + + const user = useCurrentUser() + + const { mutate, isPending: _isPending1, isSuccess } = useEditAnilistListEntry(media?.id, type) + const { mutate: mutateRepeat, isPending: _isPending2 } = useUpdateAnimeEntryRepeat(media?.id) + const isPending = _isPending1 + const { mutate: deleteEntry, isPending: isDeleting } = useDeleteAnilistListEntry(media?.id, type, () => { + toggle(false) + }) + + const [repeat, setRepeat] = React.useState(0) + + React.useEffect(() => { + setRepeat(listData?.repeat || 0) + }, [listData]) + + if (!user) return null + + return ( + <> + {!hideButton && <> + {(!listData) && <Tooltip + trigger={<IconButton + data-anilist-media-entry-modal-add-button + intent="primary-subtle" + icon={<BiPlus />} + rounded + size="sm" + loading={isPending || isDeleting} + className={cn({ "hidden": isSuccess })} // Hide button when mutation is successful + onClick={() => mutate({ + mediaId: media?.id || 0, + status: "PLANNING", + score: 0, + progress: 0, + startedAt: undefined, + completedAt: undefined, + type: type, + })} + />} + > + Add to list + </Tooltip>} + </>} + + {!!listData && <IsomorphicPopover + forceModal={forceModal} + open={open} + onOpenChange={o => toggle(o)} + title={media?.title?.userPreferred ?? undefined} + trigger={<span> + {!hideButton && <> + {!!listData && <IconButton + data-anilist-media-entry-modal-edit-button + intent="white-subtle" + icon={<AiFillEdit />} + rounded + size="sm" + loading={isPending || isDeleting} + onClick={toggle} + />} + </>} + </span>} + media={media} + > + + {(!!listData) && <Form + data-anilist-media-entry-modal-form + schema={mediaListDataSchema} + onSubmit={data => { + if (repeat !== (listData?.repeat ?? 0)) { + // Update repeat count + mutateRepeat({ + mediaId: media?.id || 0, + repeat: repeat, + }) + } + mutate({ + mediaId: media?.id || 0, + status: data.status || "PLANNING", + score: data.score ? data.score * 10 : 0, // should be 0-100 + progress: data.progress || 0, + startedAt: data.startedAt ? { + // @ts-ignore + day: data.startedAt.getDate(), + month: data.startedAt.getMonth() + 1, + year: data.startedAt.getFullYear(), + } : undefined, + completedAt: data.completedAt ? { + // @ts-ignore + day: data.completedAt.getDate(), + month: data.completedAt.getMonth() + 1, + year: data.completedAt.getFullYear(), + } : undefined, + type: type, + }) + }} + className={cn( + // { + // "mt-8": !!media?.bannerImage, + // }, + )} + onError={console.log} + defaultValues={{ + status: listData?.status, + score: listData?.score ? listData?.score / 10 : undefined, // Returned score is 0-100 + progress: listData?.progress, + startedAt: listData?.startedAt ? (normalizeDate(listData?.startedAt)) : undefined, + completedAt: listData?.completedAt ? (normalizeDate(listData?.completedAt)) : undefined, + }} + > + <div className="flex flex-col sm:flex-row gap-4"> + <Field.Select + label="Status" + name="status" + options={[ + media?.status !== "NOT_YET_RELEASED" ? { + value: "CURRENT", + label: type === "anime" ? "Watching" : "Reading", + } : undefined, + { value: "PLANNING", label: "Planning" }, + media?.status !== "NOT_YET_RELEASED" ? { + value: "PAUSED", + label: "Paused", + } : undefined, + media?.status !== "NOT_YET_RELEASED" ? { + value: "COMPLETED", + label: "Completed", + } : undefined, + media?.status !== "NOT_YET_RELEASED" ? { + value: "DROPPED", + label: "Dropped", + } : undefined, + media?.status !== "NOT_YET_RELEASED" ? { + value: "REPEATING", + label: "Repeating", + } : undefined, + ].filter(Boolean)} + /> + {media?.status !== "NOT_YET_RELEASED" && <> + <Field.Number + label="Score" + name="score" + min={0} + max={10} + formatOptions={{ + maximumFractionDigits: 1, + minimumFractionDigits: 0, + useGrouping: false, + }} + rightIcon={<BiStar />} + /> + <Field.Number + label="Progress" + name="progress" + min={0} + max={type === "anime" ? (!!(media as AL_BaseAnime)?.nextAiringEpisode?.episode + ? (media as AL_BaseAnime)?.nextAiringEpisode?.episode! - 1 + : ((media as AL_BaseAnime)?.episodes + ? (media as AL_BaseAnime).episodes + : undefined)) : (media as AL_BaseManga)?.chapters} + formatOptions={{ + maximumFractionDigits: 0, + minimumFractionDigits: 0, + useGrouping: false, + }} + rightIcon={<BiListPlus />} + /> + </>} + </div> + {media?.status !== "NOT_YET_RELEASED" && <div className="flex flex-col sm:flex-row gap-4"> + <Field.DatePicker + label="Start date" + name="startedAt" + // defaultValue={(state.startedAt && state.startedAt.year) ? parseAbsoluteToLocal(new Date(state.startedAt.year, + // (state.startedAt.month || 1)-1, state.startedAt.day || 1).toISOString()) : undefined} + /> + <Field.DatePicker + label="Completion date" + name="completedAt" + // defaultValue={(state.completedAt && state.completedAt.year) ? parseAbsoluteToLocal(new Date(state.completedAt.year, + // (state.completedAt.month || 1)-1, state.completedAt.day || 1).toISOString()) : undefined} + /> + + <NumberInput + name="repeat" + label={type === "anime" ? "Total rewatches" : "Total rereads"} + min={0} + max={1000} + value={repeat} + onValueChange={setRepeat} + formatOptions={{ + maximumFractionDigits: 0, + minimumFractionDigits: 0, + useGrouping: false, + }} + /> + </div>} + + <div className="flex w-full items-center justify-between mt-4"> + <div> + <Disclosure type="multiple" defaultValue={["item-2"]}> + <DisclosureItem value="item-1" className="flex items-center gap-1"> + <DisclosureTrigger> + <IconButton + intent="alert-subtle" + icon={<BiTrash />} + rounded + size="md" + /> + </DisclosureTrigger> + <DisclosureContent> + <Button + intent="alert-basic" + rounded + size="md" + loading={isDeleting} + onClick={() => deleteEntry({ + mediaId: media?.id!, + type: type, + })} + >Confirm</Button> + </DisclosureContent> + </DisclosureItem> + </Disclosure> + </div> + + <Field.Submit role="save" disableIfInvalid={true} loading={isPending} disabled={isDeleting}> + Save + </Field.Submit> + </div> + </Form>} + + </IsomorphicPopover>} + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_containers/media-preview-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_containers/media-preview-modal.tsx new file mode 100644 index 0000000..7e4fe98 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_containers/media-preview-modal.tsx @@ -0,0 +1,319 @@ +import { AL_AnimeDetailsById_Media, AL_BaseAnime, AL_MangaDetailsById_Media, Anime_Entry, Manga_Entry, Nullish } from "@/api/generated/types" +import { useGetAnilistAnimeDetails } from "@/api/hooks/anilist.hooks" +import { useGetAnimeEntry } from "@/api/hooks/anime_entries.hooks" +import { useGetMangaEntry, useGetMangaEntryDetails } from "@/api/hooks/manga.hooks" +import { TrailerModal } from "@/app/(main)/_features/anime/_components/trailer-modal" +import { AnimeEntryStudio } from "@/app/(main)/_features/media/_components/anime-entry-studio" +import { + AnimeEntryRankings, + MediaEntryAudienceScore, + MediaEntryGenresList, +} from "@/app/(main)/_features/media/_components/media-entry-metadata-components" +import { MediaPageHeaderEntryDetails } from "@/app/(main)/_features/media/_components/media-page-header-components" +import { useHasDebridService, useHasTorrentProvider, useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { RelationsRecommendationsSection } from "@/app/(main)/entry/_components/relations-recommendations-section" +import { TorrentSearchButton } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-button" +import { __torrentSearch_selectedTorrentsAtom } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-container" +import { + __torrentSearch_selectionAtom, + __torrentSearch_selectionEpisodeAtom, + TorrentSearchDrawer, +} from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { MangaRecommendations } from "@/app/(main)/manga/_components/manga-recommendations" +import { SeaLink } from "@/components/shared/sea-link" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { Skeleton } from "@/components/ui/skeleton" +import { getImageUrl } from "@/lib/server/assets" +import { TORRENT_CLIENT } from "@/lib/server/settings" +import { ThemeMediaPageBannerSize, ThemeMediaPageInfoBoxSize, useThemeSettings } from "@/lib/theme/hooks" +import { usePrevious } from "@uidotdev/usehooks" +import { atom } from "jotai" +import { ScopeProvider } from "jotai-scope" +import { useAtom, useSetAtom } from "jotai/react" +import Image from "next/image" +import { usePathname } from "next/navigation" +import React from "react" +import { BiX } from "react-icons/bi" +import { GoArrowLeft } from "react-icons/go" +import { SiAnilist } from "react-icons/si" + + +// unused + +type AnimePreviewModalProps = { + children?: React.ReactNode +} + +const __mediaPreview_mediaIdAtom = atom<{ mediaId: number, type: "anime" | "manga" } | undefined>(undefined) + +export function useMediaPreviewModal() { + const setInfo = useSetAtom(__mediaPreview_mediaIdAtom) + return { + setPreviewModalMediaId: (mediaId: number, type: "anime" | "manga") => { + setInfo({ mediaId, type }) + }, + } +} + +export function MediaPreviewModal(props: AnimePreviewModalProps) { + + const { + children, + ...rest + } = props + + const [info, setInfo] = useAtom(__mediaPreview_mediaIdAtom) + const previousInfo = usePrevious(info) + + const pathname = usePathname() + + React.useEffect(() => { + setInfo(undefined) + }, [pathname]) + + return ( + <> + <Modal + open={!!info} + onOpenChange={v => setInfo(prev => v ? prev : undefined)} + contentClass="max-w-7xl relative" + hideCloseButton + {...rest} + > + + {info && <div className="z-[12] absolute right-2 top-2 flex gap-2 items-center"> + {(!!previousInfo && previousInfo.mediaId !== info.mediaId) && <IconButton + intent="white-subtle" size="sm" className="rounded-full" icon={<GoArrowLeft />} + onClick={() => { + setInfo(previousInfo) + }} + />} + <IconButton + intent="alert" size="sm" className="rounded-full" icon={<BiX />} + onClick={() => { + setInfo(undefined) + }} + /> + </div>} + + {info?.type === "anime" && <Anime mediaId={info.mediaId} />} + {info?.type === "manga" && <Manga mediaId={info.mediaId} />} + + + </Modal> + </> + ) +} + +function Anime({ mediaId }: { mediaId: number }) { + const { data: entry, isLoading: entryLoading } = useGetAnimeEntry(mediaId) + const { data: details, isLoading: detailsLoading } = useGetAnilistAnimeDetails(mediaId) + + return <Content entry={entry} details={details} entryLoading={entryLoading} detailsLoading={detailsLoading} type="anime" /> +} + +function Manga({ mediaId }: { mediaId: number }) { + const { data: entry, isLoading: entryLoading } = useGetMangaEntry(mediaId) + const { data: details, isLoading: detailsLoading } = useGetMangaEntryDetails(mediaId) + + return <Content entry={entry} details={details} entryLoading={entryLoading} detailsLoading={detailsLoading} type="manga" /> +} + +function Content({ entry, entryLoading, detailsLoading, details, type }: { + entry: Nullish<Anime_Entry | Manga_Entry>, + entryLoading: boolean, + detailsLoading: boolean, + details: Nullish<AL_AnimeDetailsById_Media | AL_MangaDetailsById_Media> + type: "anime" | "manga" +}) { + + const serverStatus = useServerStatus() + + const ts = useThemeSettings() + const media = entry?.media + const bannerImage = media?.bannerImage || media?.coverImage?.extraLarge + + const { hasTorrentProvider } = useHasTorrentProvider() + const { hasDebridService } = useHasDebridService() + + return ( + <ScopeProvider atoms={[__torrentSearch_selectionAtom, __torrentSearch_selectionEpisodeAtom, __torrentSearch_selectedTorrentsAtom]}> + <div + className={cn( + "absolute z-[0] opacity-30 w-full rounded-t-[--radius] overflow-hidden", + "w-full flex-none object-cover object-center z-[3] bg-[--background] h-[12rem]", + ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small ? "lg:h-[23rem]" : "h-[12rem] lg:h-[22rem] 2xl:h-[30rem]", + )} + > + + {/*BOTTOM OVERFLOW FADE*/} + <div + className={cn( + "w-full z-[2] absolute bottom-[-5rem] h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent", + )} + /> + + <div + className={cn( + "absolute top-0 left-0 w-full h-full", + )} + > + {(!!bannerImage) && <Image + src={getImageUrl(bannerImage || "")} + alt="banner image" + fill + quality={100} + priority + sizes="100vw" + className={cn( + "object-cover object-center z-[1]", + )} + />} + + {/*LEFT MASK*/} + <div + className={cn( + "hidden lg:block max-w-[60rem] xl:max-w-[100rem] w-full z-[2] h-full absolute left-0 bg-gradient-to-r from-[--background] transition-opacity to-transparent", + "opacity-85 duration-1000", + // y > 300 && "opacity-70", + )} + /> + <div + className={cn( + "hidden lg:block max-w-[60rem] xl:max-w-[80rem] w-full z-[2] h-full absolute left-0 bg-gradient-to-r from-[--background] from-25% transition-opacity to-transparent", + "opacity-50 duration-500", + )} + /> + </div> + + {/*BOTTOM FADE*/} + <div + className={cn( + "w-full z-[3] absolute bottom-0 h-[50%] bg-gradient-to-t from-[--background] via-transparent via-100% to-transparent", + )} + /> + + <div + className={cn( + "absolute h-full w-full block lg:hidden bg-[--background] opacity-70 z-[2]", + )} + /> + + </div> + + {entryLoading && <div className="space-y-4 relative z-[5]"> + <Skeleton + className={cn( + "h-[12rem]", + ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small ? "lg:h-[23rem]" : "h-[12rem] lg:h-[22rem] 2xl:h-[30rem]", + )} + /> + {/*<LoadingSpinner />*/} + </div>} + + {(!entryLoading && entry) && <> + + <div className="z-[5] relative"> + <MediaPageHeaderEntryDetails + coverImage={entry.media?.coverImage?.extraLarge || entry.media?.coverImage?.large} + title={entry.media?.title?.userPreferred} + color={entry.media?.coverImage?.color} + englishTitle={entry.media?.title?.english} + romajiTitle={entry.media?.title?.romaji} + startDate={entry.media?.startDate} + season={entry.media?.season} + progressTotal={(entry.media as AL_BaseAnime)?.episodes} + status={entry.media?.status} + description={entry.media?.description} + listData={entry.listData} + media={entry.media!} + smallerTitle + type="anime" + > + <div + className={cn( + "flex gap-3 flex-wrap items-center relative z-[10]", + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "justify-center lg:justify-start lg:max-w-[65vw]", + )} + > + <MediaEntryAudienceScore meanScore={entry?.media?.meanScore} badgeClass="bg-transparent" /> + + {(details as AL_AnimeDetailsById_Media)?.studios && + <AnimeEntryStudio studios={(details as AL_AnimeDetailsById_Media)?.studios} />} + + <MediaEntryGenresList genres={details?.genres} /> + + <div + className={cn( + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid ? "w-full" : "contents", + )} + > + <AnimeEntryRankings rankings={details?.rankings} /> + </div> + </div> + </MediaPageHeaderEntryDetails> + + <div className="mt-6 flex gap-3 items-center"> + + <SeaLink href={type === "anime" ? `/entry?id=${media?.id}` : `/manga/entry?id=${media?.id}`}> + <Button className="px-0" intent="gray-link"> + Open page + </Button> + </SeaLink> + + {type === "anime" && !!(entry?.media as AL_BaseAnime)?.trailer?.id && <TrailerModal + trailerId={(entry?.media as AL_BaseAnime)?.trailer?.id} trigger={ + <Button intent="gray-link" className="px-0"> + Trailer + </Button>} + />} + + <SeaLink href={`https://anilist.co/${type}/${entry.mediaId}`} target="_blank"> + <IconButton intent="gray-link" className="px-0" icon={<SiAnilist className="text-lg" />} /> + </SeaLink> + + {( + type === "anime" && + entry?.media?.status !== "NOT_YET_RELEASED" + && hasTorrentProvider + && ( + serverStatus?.settings?.torrent?.defaultTorrentClient !== TORRENT_CLIENT.NONE + || hasDebridService + ) + ) && ( + <TorrentSearchButton + entry={entry as Anime_Entry} + /> + )} + </div> + + {detailsLoading ? <LoadingSpinner /> : <div className="space-y-6 pt-6"> + {type === "anime" && <RelationsRecommendationsSection entry={entry as Anime_Entry} details={details} />} + {type === "manga" && <MangaRecommendations entry={entry as Manga_Entry} details={details} />} + </div>} + </div> + + {/*<div className="absolute top-0 left-0 w-full h-full z-[0] bg-[--background] rounded-xl">*/} + {/* <Image*/} + {/* src={media?.bannerImage || ""}*/} + {/* alt={""}*/} + {/* fill*/} + {/* quality={100}*/} + {/* sizes="20rem"*/} + {/* className="object-cover object-center transition opacity-15"*/} + {/* />*/} + + {/* <div*/} + {/* className="absolute top-0 w-full h-full backdrop-blur-2xl z-[2] "*/} + {/* ></div>*/} + {/*</div>*/} + + </>} + + {(type === "anime" && !!entry) && <TorrentSearchDrawer entry={entry as Anime_Entry} />} + </ScopeProvider> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_containers/media-sync-track-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_containers/media-sync-track-button.tsx new file mode 100644 index 0000000..2e894a4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/media/_containers/media-sync-track-button.tsx @@ -0,0 +1,66 @@ +import { useLocalAddTrackedMedia, useLocalGetIsMediaTracked, useLocalRemoveTrackedMedia } from "@/api/hooks/local.hooks" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { IconButton } from "@/components/ui/button" +import { Tooltip } from "@/components/ui/tooltip" +import React from "react" +import { MdOutlineDownloadForOffline, MdOutlineOfflinePin } from "react-icons/md" + +type MediaSyncTrackButtonProps = { + mediaId: number + type: "anime" | "manga" + size?: "sm" | "md" | "lg" +} + +export function MediaSyncTrackButton(props: MediaSyncTrackButtonProps) { + + const { + mediaId, + type, + size, + ...rest + } = props + + const { data: isTracked, isLoading } = useLocalGetIsMediaTracked(mediaId, type) + const { mutate: addMedia, isPending: isAdding } = useLocalAddTrackedMedia() + const { mutate: removeMedia, isPending: isRemoving } = useLocalRemoveTrackedMedia() + + function handleToggle() { + if (isTracked) { + removeMedia({ mediaId, type }) + } else { + addMedia({ + media: [{ + mediaId: mediaId, + type: type, + }], + }) + } + } + + const confirmUntrack = useConfirmationDialog({ + title: "Remove offline data", + description: "This action will remove the offline data for this media entry. Are you sure you want to proceed?", + onConfirm: () => { + handleToggle() + }, + }) + + return ( + <> + <Tooltip + trigger={<IconButton + icon={isTracked ? <MdOutlineOfflinePin /> : <MdOutlineDownloadForOffline />} + onClick={() => isTracked ? confirmUntrack.open() : handleToggle()} + loading={isLoading || isAdding || isRemoving} + intent={isTracked ? "primary-subtle" : "gray-subtle"} + size={size} + {...rest} + />} + > + {isTracked ? `Remove offline data` : `Save locally`} + </Tooltip> + + <ConfirmationDialog {...confirmUntrack} /> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/nakama/nakama-manager.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/nakama/nakama-manager.tsx new file mode 100644 index 0000000..bc61e5a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/nakama/nakama-manager.tsx @@ -0,0 +1,655 @@ +import { Nakama_NakamaStatus, Nakama_WatchPartySession, Nakama_WatchPartySessionSettings } from "@/api/generated/types" +import { + useNakamaCreateWatchParty, + useNakamaJoinWatchParty, + useNakamaLeaveWatchParty, + useNakamaReconnectToHost, + useNakamaRemoveStaleConnections, +} from "@/api/hooks/nakama.hooks" +import { useWebsocketMessageListener, useWebsocketSender } from "@/app/(main)/_hooks/handle-websockets" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useNakamaOnlineStreamWatchParty } from "@/app/(main)/onlinestream/_lib/handle-onlinestream" +import { AlphaBadge } from "@/components/shared/beta-badge" +import { GlowingEffect } from "@/components/shared/glowing-effect" +import { SeaLink } from "@/components/shared/sea-link" +import { Badge } from "@/components/ui/badge" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { Tooltip } from "@/components/ui/tooltip" +import { WSEvents } from "@/lib/server/ws-events" +import { atom, useAtom, useAtomValue } from "jotai" +import React from "react" +import { BiCog } from "react-icons/bi" +import { FaBroadcastTower } from "react-icons/fa" +import { HiOutlinePlay } from "react-icons/hi2" +import { LuPopcorn } from "react-icons/lu" +import { MdAdd, MdCleaningServices, MdOutlineConnectWithoutContact, MdPlayArrow, MdRefresh } from "react-icons/md" +import { toast } from "sonner" + +export const nakamaModalOpenAtom = atom(false) +export const nakamaStatusAtom = atom<Nakama_NakamaStatus | null | undefined>(undefined) + +export const watchPartySessionAtom = atom<Nakama_WatchPartySession | null | undefined>(undefined) + +export function useNakamaStatus() { + return useAtomValue(nakamaStatusAtom) +} + +export function useWatchPartySession() { + return useAtomValue(watchPartySessionAtom) +} + +export function NakamaManager() { + const { sendMessage } = useWebsocketSender() + const [isModalOpen, setIsModalOpen] = useAtom(nakamaModalOpenAtom) + const [nakamaStatus, setNakamaStatus] = useAtom(nakamaStatusAtom) + const [watchPartySession, setWatchPartySession] = useAtom(watchPartySessionAtom) + + // const { data: status, refetch: refetchStatus, isLoading } = useGetNakamaStatus() + const { mutate: reconnectToHost, isPending: isReconnecting } = useNakamaReconnectToHost() + const { mutate: removeStaleConnections, isPending: isCleaningUp } = useNakamaRemoveStaleConnections() + const { mutate: createWatchParty, isPending: isCreatingWatchParty } = useNakamaCreateWatchParty() + const { mutate: joinWatchParty, isPending: isJoiningWatchParty } = useNakamaJoinWatchParty() + const { mutate: leaveWatchParty, isPending: isLeavingWatchParty } = useNakamaLeaveWatchParty() + + // Watch party settings for creating a new session + const [watchPartySettings, setWatchPartySettings] = React.useState<Nakama_WatchPartySessionSettings>({ + syncThreshold: 3.0, + maxBufferWaitTime: 10, + }) + + function refetchStatus() { + sendMessage({ + type: WSEvents.NAKAMA_STATUS_REQUESTED, + payload: null, + }) + } + + useWebsocketMessageListener({ + type: WSEvents.NAKAMA_STATUS, + onMessage: (data: Nakama_NakamaStatus | null) => { + setNakamaStatus(data ?? null) + }, + }) + + // NAKAMA_WATCH_PARTY_STATE tells the client to refetch the status + useWebsocketMessageListener({ + type: WSEvents.NAKAMA_WATCH_PARTY_STATE, + onMessage: (data: any) => { + refetchStatus() + }, + }) + + React.useEffect(() => { + if (nakamaStatus?.currentWatchPartySession) { + setWatchPartySession(nakamaStatus.currentWatchPartySession) + } else { + setWatchPartySession(null) + } + }, [nakamaStatus]) + + React.useEffect(() => { + refetchStatus() + }, []) + + React.useEffect(() => { + if (isModalOpen) { + refetchStatus() + } + }, [isModalOpen]) + + const handleReconnect = React.useCallback(() => { + reconnectToHost({}, { + onSuccess: () => { + toast.success("Reconnection initiated") + refetchStatus() + }, + onError: (error) => { + toast.error(`Failed to reconnect: ${error.message}`) + }, + }) + }, [reconnectToHost, refetchStatus]) + + const handleCleanupStaleConnections = React.useCallback(() => { + removeStaleConnections({}, { + onSuccess: () => { + toast.success("Stale connections cleaned up") + refetchStatus() + }, + onError: (error) => { + toast.error(`Failed to cleanup: ${error.message}`) + }, + }) + }, [removeStaleConnections, refetchStatus]) + + const handleCreateWatchParty = React.useCallback(() => { + createWatchParty({ settings: watchPartySettings }, { + onSuccess: () => { + toast.success("Watch party created") + refetchStatus() + }, + onError: (error) => { + toast.error(`Failed to create watch party: ${error.message}`) + }, + }) + }, [createWatchParty, watchPartySettings, refetchStatus]) + + const handleJoinWatchParty = React.useCallback(() => { + joinWatchParty(undefined, { + onSuccess: () => { + toast.info("Joining watch party") + refetchStatus() + }, + }) + }, [joinWatchParty, refetchStatus]) + + const handleLeaveWatchParty = React.useCallback(() => { + leaveWatchParty(undefined, { + onSuccess: () => { + toast.info("Leaving watch party") + setWatchPartySession(null) + refetchStatus() + }, + }) + }, [leaveWatchParty, setWatchPartySession, refetchStatus]) + + useWebsocketMessageListener({ + type: WSEvents.NAKAMA_HOST_STARTED, + onMessage: () => { + refetchStatus() + }, + }) + + useWebsocketMessageListener({ + type: WSEvents.NAKAMA_HOST_STOPPED, + onMessage: () => { + refetchStatus() + }, + }) + + useWebsocketMessageListener({ + type: WSEvents.NAKAMA_PEER_CONNECTED, + onMessage: () => { + refetchStatus() + }, + }) + + useWebsocketMessageListener({ + type: WSEvents.NAKAMA_PEER_DISCONNECTED, + onMessage: () => { + refetchStatus() + }, + }) + + useWebsocketMessageListener({ + type: WSEvents.NAKAMA_HOST_CONNECTED, + onMessage: () => { + refetchStatus() + }, + }) + + useWebsocketMessageListener({ + type: WSEvents.NAKAMA_HOST_DISCONNECTED, + onMessage: () => { + refetchStatus() + }, + }) + + useWebsocketMessageListener({ + type: WSEvents.NAKAMA_ERROR, + onMessage: () => { + refetchStatus() + }, + }) + + /////// Online stream + + const { startOnlineStream } = useNakamaOnlineStreamWatchParty() + useWebsocketMessageListener({ + type: WSEvents.NAKAMA_ONLINE_STREAM_EVENT, + onMessage: (_data: { type: string, payload: { type: string, payload: any } }) => { + console.log(_data) + switch (_data.type) { + case "online-stream-playback-status": + const data = _data.payload + switch (data.type) { + case "start": + startOnlineStream(data.payload) + break + } + } + }, + }) + + return <> + <Modal + open={isModalOpen} + onOpenChange={setIsModalOpen} + title={<div className="flex items-center gap-2 w-full justify-center"> + <MdOutlineConnectWithoutContact className="size-8" /> + Nakama + <AlphaBadge className="border-transparent" /> + </div>} + contentClass="max-w-3xl bg-gray-950 bg-opacity-60 backdrop-blur-sm firefox:bg-opacity-100 firefox:backdrop-blur-none sm:rounded-3xl" + overlayClass="bg-gray-950/70 backdrop-blur-sm" + // allowOutsideInteraction + > + + <GlowingEffect + variant="classic" + spread={40} + glow={true} + disabled={false} + proximity={64} + inactiveZone={0.01} + className="opacity-50" + /> + + <div className="absolute top-4 right-14"> + <SeaLink href="/settings?tab=nakama" onClick={() => setIsModalOpen(false)}> + <IconButton intent="gray-basic" size="sm" icon={<BiCog />} /> + </SeaLink> + </div> + + {nakamaStatus === undefined && <LoadingSpinner />} + + {!nakamaStatus?.isHost && ( + <div className="flex items-center justify-between"> + <div></div> + <Button + onClick={handleReconnect} + disabled={isReconnecting} + size="sm" + intent="gray-basic" + leftIcon={<MdRefresh />} + > + {isReconnecting ? "Reconnecting..." : "Reconnect"} + </Button> + </div> + )} + + {nakamaStatus !== undefined && (nakamaStatus?.isHost || nakamaStatus?.isConnectedToHost) && ( + <> + + {nakamaStatus?.isHost && ( + <> + <div className="flex items-center justify-between"> + <Badge intent="success-solid" className="px-0 text-indigo-300 bg-transparent">Currently hosting</Badge> + <Button + onClick={handleCleanupStaleConnections} + disabled={isCleaningUp} + size="sm" + intent="gray-basic" + leftIcon={<MdCleaningServices />} + > + {isCleaningUp ? "Cleaning up..." : "Remove stale connections"} + </Button> + </div> + <h4>Connected peers ({nakamaStatus?.connectedPeers?.length ?? 0})</h4> + <div className="p-4 border rounded-lg bg-gray-950"> + {!nakamaStatus?.connectedPeers?.length && + <p className="text-center text-sm text-[--muted]">No connected peers</p>} + {nakamaStatus?.connectedPeers?.map((peer, index) => ( + <div key={index} className="flex items-center justify-between py-1"> + <span className="font-medium">{peer}</span> + </div> + ))} + </div> + </> + )} + + {nakamaStatus?.isConnectedToHost && ( + <> + + <h4>Host connection</h4> + <div className="p-4 border rounded-lg bg-gray-950"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <span className="text-sm text-[--muted]">Host</span> + <span className="font-medium text-sm tracking-wide"> + {nakamaStatus?.hostConnectionStatus?.username || "Unknown"} + </span> + </div> + </div> + </div> + </> + )} + + {/* Watch Party Content */} + {(() => { + const isHost = nakamaStatus?.isHost || false + const isConnectedToHost = nakamaStatus?.isConnectedToHost || false + const currentPeerID = nakamaStatus?.hostConnectionStatus?.peerId + + // Check if user is in the participant list by comparing peer ID + const isUserInSession = watchPartySession && ( + isHost || + (currentPeerID && watchPartySession.participants && currentPeerID in watchPartySession.participants) + ) + + // Show session view if there's a session AND user is in it + if (watchPartySession && isUserInSession) { + return ( + <WatchPartySessionView + session={watchPartySession} + isHost={isHost} + onLeave={handleLeaveWatchParty} + isLeaving={isLeavingWatchParty} + /> + ) + } + + // Otherwise show creation/join options + return ( + <WatchPartyCreation + isHost={isHost} + isConnectedToHost={isConnectedToHost} + hasActiveSession={!!watchPartySession} + settings={watchPartySettings} + onSettingsChange={setWatchPartySettings} + onCreateWatchParty={handleCreateWatchParty} + onJoinWatchParty={handleJoinWatchParty} + isCreating={isCreatingWatchParty} + isJoining={isJoiningWatchParty} + /> + ) + })()} + </> + )} + + {!nakamaStatus?.isHost && !nakamaStatus?.isConnectedToHost && nakamaStatus !== undefined && ( + <div className="text-center py-8"> + <p className="text-[--muted]">Nakama is not active</p> + <p className="text-sm text-[--muted] mt-2"> + Configure Nakama in settings to connect to a host or start hosting + </p> + </div> + )} + </Modal> + </> +} + +interface WatchPartyCreationProps { + isHost: boolean + isConnectedToHost: boolean + hasActiveSession: boolean + settings: Nakama_WatchPartySessionSettings + onSettingsChange: (settings: Nakama_WatchPartySessionSettings) => void + onCreateWatchParty: () => void + onJoinWatchParty: () => void + isCreating: boolean + isJoining: boolean +} + +function WatchPartyCreation({ + isHost, + isConnectedToHost, + hasActiveSession, + settings, + onSettingsChange, + onCreateWatchParty, + onJoinWatchParty, + isCreating, + isJoining, +}: WatchPartyCreationProps) { + return ( + <div className="space-y-4"> + <h4 className="flex items-center gap-2"><LuPopcorn className="size-6" /> Watch Party</h4> + {isHost && ( + <div className="p-4 border rounded-lg bg-gray-950"> + <div className="space-y-4"> + {/* <div className="space-y-3"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium">Allow participant control</label> + <Switch + value={settings.allowParticipantControl} + onValueChange={(checked: boolean) => + onSettingsChange({ ...settings, allowParticipantControl: checked }) + } + /> + </div> + + <div className="space-y-2"> + <label className="text-sm font-medium">Sync threshold (seconds)</label> + <NumberInput + value={settings.syncThreshold} + onValueChange={(value) => + onSettingsChange({ ...settings, syncThreshold: value || 3.0 }) + } + min={1} + max={10} + step={0.5} + /> + <p className="text-xs text-[--muted]">How far out of sync before forcing synchronization</p> + </div> + + <div className="space-y-2"> + <label className="text-sm font-medium">Max buffer wait time (seconds)</label> + <NumberInput + value={settings.maxBufferWaitTime} + onValueChange={(value) => + onSettingsChange({ ...settings, maxBufferWaitTime: value || 10 }) + } + min={5} + max={60} + /> + <p className="text-xs text-[--muted]">Maximum time to wait for peers to buffer</p> + </div> + </div> */} + + <Button + onClick={onCreateWatchParty} + disabled={isCreating} + className="w-full" + intent="primary" + leftIcon={<MdAdd />} + > + {isCreating ? "Creating..." : "Create Watch Party"} + </Button> + </div> + </div> + )} + + {isConnectedToHost && !isHost && hasActiveSession && ( + <div className="p-4 border rounded-lg bg-gray-950"> + <div className="space-y-4"> + <p className="text-sm text-[--muted]"> + There's an active watch party! Join to watch content together in sync. + </p> + <Button + onClick={onJoinWatchParty} + disabled={isJoining} + className="w-full" + intent="primary" + leftIcon={<MdPlayArrow />} + > + {isJoining ? "Joining..." : "Join Watch Party"} + </Button> + </div> + </div> + )} + + {!isHost && !isConnectedToHost && ( + <div className="text-center py-8"> + <p className="text-[--muted]">Connect to a host to join a watch party</p> + </div> + )} + + {!isHost && isConnectedToHost && !hasActiveSession && ( + <div className="text-center py-8"> + <p className="text-[--muted]">No active watch party</p> + </div> + )} + </div> + ) +} + +interface WatchPartySessionViewProps { + session: Nakama_WatchPartySession + isHost: boolean + onLeave: () => void + isLeaving: boolean +} + +function WatchPartySessionView({ session, isHost, onLeave, isLeaving }: WatchPartySessionViewProps) { + const { sendMessage } = useWebsocketSender() + const nakamaStatus = useNakamaStatus() + const participants = Object.values(session.participants || {}) + const participantCount = participants.length + const serverStatus = useServerStatus() + + const [enablingRelayMode, setEnablingRelayMode] = React.useState(false) + + // Identify current user - either "host" if hosting, or the peer ID if connected as peer + const currentUserId = isHost ? "host" : nakamaStatus?.hostConnectionStatus?.peerId + + function handleEnableRelayMode(peerId: string) { + sendMessage({ + type: WSEvents.NAKAMA_WATCH_PARTY_ENABLE_RELAY_MODE, + payload: { peerId }, + }) + } + + return ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h4 className="flex items-center gap-2"><LuPopcorn className="size-6" /> Watch Party</h4> + <div className="flex items-center gap-2"> + {/*Enable relay mode*/} + {isHost && !session.isRelayMode && ( + <Tooltip + trigger={<IconButton + size="sm" + intent={!enablingRelayMode ? "primary-subtle" : "primary"} + icon={<FaBroadcastTower />} + onClick={() => setEnablingRelayMode(p => !p)} + className={cn(enablingRelayMode && "animate-pulse")} + />} + > + Enable relay mode + </Tooltip> + )} + <Button + onClick={onLeave} + disabled={isLeaving} + size="sm" + intent="alert-basic" + // leftIcon={isHost ? <MdStop /> : <MdExitToApp />} + > + {isLeaving ? "Leaving..." : isHost ? "Stop" : "Leave"} + </Button> + </div> + </div> + + {/* <SettingsCard title="Session Details"> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-[--muted]">Session ID:</span> + <span className="font-mono text-xs">{session.id}</span> + </div> + + <div className="flex items-center justify-between"> + <span className="text-sm text-[--muted]">Created:</span> + <span className="text-sm">{session.createdAt ? new Date(session.createdAt).toLocaleString() : "Unknown"}</span> + </div> + + {session.currentMediaInfo && ( + <> + <div className="flex items-center justify-between"> + <span className="text-sm text-[--muted]">Current Media:</span> + <span className="text-sm">Episode {session.currentMediaInfo.episodeNumber}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-[--muted]">Stream Type:</span> + <Badge className=""> + {session.currentMediaInfo.streamType} + </Badge> + </div> + </> + )} + </div> + </SettingsCard> */} + + <h5>Participants ({participantCount})</h5> + <div className="p-4 border rounded-lg bg-gray-950"> + <div className="space-y-0"> + {participants.map((participant) => { + const isCurrentUser = participant.id === currentUserId + return ( + <div key={participant.id} className="flex items-center justify-between py-1"> + <div className="flex items-center gap-2"> + <span className="font-medium text-sm tracking-wide"> + {participant.username} + {isCurrentUser && <span className="text-[--muted] font-normal"> (me)</span>} + </span> + {session.isRelayMode && participant.isHost && ( + <Badge intent="unstyled" className="text-xs" leftIcon={<FaBroadcastTower />}>Relay</Badge> + )} + {participant.isHost && ( + <Badge className="text-xs">Host</Badge> + )} + {participant.isRelayOrigin && ( + <Badge intent="warning" className="text-xs">Origin</Badge> + )} + {enablingRelayMode && !participant.isHost && !participant.isRelayOrigin && !session.isRelayMode && ( + <Button + size="sm" intent="white" leftIcon={<HiOutlinePlay />} + onClick={() => handleEnableRelayMode(participant.id)} + >Promote to origin</Button> + )} + </div> + <div className="flex items-center gap-2 text-xs text-[--muted]"> + {!participant.isHost && participant.bufferHealth !== undefined && ( + <Tooltip + trigger={<div className="flex items-center gap-1"> + <span className="text-xs">Buffer</span> + <div className="w-8 h-1 bg-gray-300 rounded-full overflow-hidden"> + <div + className="h-full bg-green-500 transition-all duration-300" + style={{ width: `${Math.max(0, Math.min(100, participant.bufferHealth * 100))}%` }} + /> + </div> + <span className="text-xs">{Math.round(participant.bufferHealth * 100)}%</span> + </div>} + > + Synchronization buffer health + </Tooltip> + )} + {participant.latency > 0 && ( + <span>{participant.latency}ms</span> + )} + {participant.isBuffering ? ( + <Badge intent="alert-solid" className="text-xs"> + Buffering + </Badge> + ) : null} + </div> + </div> + ) + })} + </div> + </div> + + {/* <SettingsCard title="Settings"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <span className="text-sm text-[--muted]">Participant Control:</span> + <span className="text-sm"> + {session.settings?.allowParticipantControl ? "Enabled" : "Disabled"} + </span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-[--muted]">Sync Threshold:</span> + <span className="text-sm">{session.settings?.syncThreshold}s</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-[--muted]">Max Buffer Wait:</span> + <span className="text-sm">{session.settings?.maxBufferWaitTime}s</span> + </div> + </div> + </SettingsCard> */} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/native-player/native-player.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/native-player/native-player.atoms.ts new file mode 100644 index 0000000..c89fab5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/native-player/native-player.atoms.ts @@ -0,0 +1,18 @@ +import { NativePlayer_PlaybackInfo } from "@/api/generated/types" +import { atomWithImmer } from "jotai-immer" + +export type NativePlayerState = { + active: boolean + playbackInfo: NativePlayer_PlaybackInfo | null + playbackError: string | null + loadingState: string | null +} + +export const nativePlayer_initialState: NativePlayerState = { + active: false, + playbackInfo: null, + playbackError: null, + loadingState: null, +} + +export const nativePlayer_stateAtom = atomWithImmer<NativePlayerState>(nativePlayer_initialState) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/native-player/native-player.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/native-player/native-player.tsx new file mode 100644 index 0000000..acda108 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/native-player/native-player.tsx @@ -0,0 +1,407 @@ +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { MKVParser_SubtitleEvent, MKVParser_TrackInfo, NativePlayer_PlaybackInfo, NativePlayer_ServerEvent } from "@/api/generated/types" +import { useUpdateAnimeEntryProgress } from "@/api/hooks/anime_entries.hooks" +import { useHandleCurrentMediaContinuity } from "@/api/hooks/continuity.hooks" +import { __seaMediaPlayer_autoNextAtom } from "@/app/(main)/_features/sea-media-player/sea-media-player.atoms" +import { vc_dispatchAction, vc_miniPlayer, vc_subtitleManager, vc_videoElement, VideoCore } from "@/app/(main)/_features/video-core/video-core" +import { clientIdAtom } from "@/app/websocket-provider" +import { logger } from "@/lib/helpers/debug" +import { WSEvents } from "@/lib/server/ws-events" +import { useQueryClient } from "@tanstack/react-query" +import { useAtom, useAtomValue } from "jotai" +import { useSetAtom } from "jotai/react" +import React from "react" +import { toast } from "sonner" +import { useWebsocketMessageListener, useWebsocketSender } from "../../_hooks/handle-websockets" +import { useServerStatus } from "../../_hooks/use-server-status" +import { useSkipData } from "../sea-media-player/aniskip" +import { nativePlayer_stateAtom } from "./native-player.atoms" + +const enum VideoPlayerEvents { + LOADED_METADATA = "loaded-metadata", + VIDEO_SEEKED = "video-seeked", + SUBTITLE_FILE_UPLOADED = "subtitle-file-uploaded", + VIDEO_PAUSED = "video-paused", + VIDEO_RESUMED = "video-resumed", + VIDEO_ENDED = "video-ended", + VIDEO_ERROR = "video-error", + VIDEO_CAN_PLAY = "video-can-play", + VIDEO_STARTED = "video-started", + VIDEO_COMPLETED = "video-completed", + VIDEO_TERMINATED = "video-terminated", + VIDEO_TIME_UPDATE = "video-time-update", +} + +const log = logger("NATIVE PLAYER") + +export function NativePlayer() { + const serverStatus = useServerStatus() + const clientId = useAtomValue(clientIdAtom) + const { sendMessage } = useWebsocketSender() + + const autoPlayNext = useAtomValue(__seaMediaPlayer_autoNextAtom) + const videoElement = useAtomValue(vc_videoElement) + const [state, setState] = useAtom(nativePlayer_stateAtom) + const [miniPlayer, setMiniPlayer] = useAtom(vc_miniPlayer) + const subtitleManager = useAtomValue(vc_subtitleManager) + const dispatchEvent = useSetAtom(vc_dispatchAction) + + // Continuity + const { watchHistory, waitForWatchHistory, getEpisodeContinuitySeekTo } = useHandleCurrentMediaContinuity(state?.playbackInfo?.media?.id) + + // AniSkip + const { data: aniSkipData } = useSkipData(state?.playbackInfo?.media?.idMal, state?.playbackInfo?.episode?.progressNumber ?? -1) + + // + // Start + // + + const qc = useQueryClient() + + React.useEffect(() => { + qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.key] }) + }, [state]) + + + // Update progress + const { mutate: updateProgress, isPending: isUpdatingProgress, isSuccess: isProgressUpdateSuccess } = useUpdateAnimeEntryProgress( + state.playbackInfo?.media?.id, + state.playbackInfo?.episode?.progressNumber ?? 0, + ) + + const handleTimeInterval = () => { + if (videoElement) { + sendMessage({ + type: WSEvents.NATIVE_PLAYER, + payload: { + clientId: clientId, + type: VideoPlayerEvents.VIDEO_TIME_UPDATE, + payload: { + currentTime: videoElement.currentTime, + duration: videoElement.duration, + paused: videoElement.paused, + }, + }, + }) + } + } + + // Time update interval + React.useEffect(() => { + const interval = setInterval(handleTimeInterval, 2000) + return () => clearInterval(interval) + }, [videoElement]) + + // + // Event Handlers + // + + const handleCompleted = () => { + const v = videoElement + if (!v) return + sendMessage({ + type: WSEvents.NATIVE_PLAYER, + payload: { + clientId: clientId, + type: VideoPlayerEvents.VIDEO_COMPLETED, + payload: { + currentTime: v.currentTime, + duration: v.duration, + }, + }, + }) + } + + const handleTimeUpdate = () => { + const v = videoElement + if (!v) return + + } + + const handleEnded = () => { + log.info("Ended") + + sendMessage({ + type: WSEvents.NATIVE_PLAYER, + payload: { + clientId: clientId, + type: VideoPlayerEvents.VIDEO_ENDED, + payload: { + autoNext: autoPlayNext, + }, + }, + }) + + } + + const handleError = (value: string) => { + const v = videoElement + if (!v) return + + const error = value || v.error + let errorMessage = value || "Unknown error" + let detailedInfo = "" + + if (error instanceof MediaError) { + switch (error.code) { + case MediaError.MEDIA_ERR_ABORTED: + errorMessage = "Media playback aborted" + break + case MediaError.MEDIA_ERR_NETWORK: + errorMessage = "Network error occurred" + break + case MediaError.MEDIA_ERR_DECODE: + errorMessage = "Media decode error - codec not supported or corrupted file" + detailedInfo = "This is likely a codec compatibility issue." + break + case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: + errorMessage = "Media format not supported" + detailedInfo = "The video codec/container format is not supported." + break + default: + errorMessage = error.message || "Unknown media error" + } + log.error("Media error", { + code: error?.code, + message: error?.message, + src: v.src, + networkState: v.networkState, + readyState: v.readyState, + }) + } + + + const fullErrorMessage = detailedInfo ? `${errorMessage}\n\n${detailedInfo}` : errorMessage + + log.error("Media error", fullErrorMessage) + + sendMessage({ + type: WSEvents.NATIVE_PLAYER, + payload: { + clientId: clientId, + type: VideoPlayerEvents.VIDEO_ERROR, + payload: { error: fullErrorMessage }, + }, + }) + } + + const handleSeeked = (currentTime: number) => { + const v = videoElement + if (!v) return + + log.info("Video seeked to", currentTime) + + sendMessage({ + type: WSEvents.NATIVE_PLAYER, + payload: { + clientId: clientId, + type: VideoPlayerEvents.VIDEO_SEEKED, + payload: { currentTime: currentTime, duration: v.duration }, + }, + }) + } + + /** + * Metadata is loaded + * - Handle captions + * - Initialize the subtitle manager if the stream is MKV + * - Initialize the audio manager if the stream is MKV + * - Initialize the thumbnailer if the stream is local file + */ + const handleLoadedMetadata = () => { + const v = videoElement + if (!v) return + + + sendMessage({ + type: WSEvents.NATIVE_PLAYER, + payload: { + clientId: clientId, + type: VideoPlayerEvents.LOADED_METADATA, + payload: { + currentTime: v.currentTime, + duration: v.duration, + }, + }, + }) + + if (state.playbackInfo?.episode?.progressNumber && watchHistory?.found && watchHistory.item?.episodeNumber === state.playbackInfo?.episode?.progressNumber) { + const lastWatchedTime = getEpisodeContinuitySeekTo(state.playbackInfo?.episode?.progressNumber, + videoElement?.currentTime, + videoElement?.duration) + logger("MEDIA PLAYER").info("Watch continuity: Seeking to last watched time", { lastWatchedTime }) + if (lastWatchedTime > 0) { + logger("MEDIA PLAYER").info("Watch continuity: Seeking to", lastWatchedTime) + dispatchEvent({ type: "restoreProgress", payload: { time: lastWatchedTime } }) + // const isPaused = videoElement?.paused + // videoElement?.play?.() + // setTimeout(() => { + // + // if (isPaused) { + // setTimeout(() => { + // videoElement?.pause?.() + // }, 200) + // } + // }, 1000) + } + } + } + + const handlePause = () => { + const v = videoElement + if (!v) return + + sendMessage({ + type: WSEvents.NATIVE_PLAYER, + payload: { + clientId: clientId, + type: VideoPlayerEvents.VIDEO_PAUSED, + payload: { + currentTime: v.currentTime, + duration: v.duration, + }, + }, + }) + } + + const handlePlay = () => { + const v = videoElement + if (!v) return + + sendMessage({ + type: WSEvents.NATIVE_PLAYER, + payload: { + clientId: clientId, + type: VideoPlayerEvents.VIDEO_RESUMED, + payload: { + currentTime: v.currentTime, + duration: v.duration, + }, + }, + }) + } + + function handleFileUploaded(data: { name: string, content: string }) { + sendMessage({ + type: WSEvents.NATIVE_PLAYER, + payload: { + clientId: clientId, + type: VideoPlayerEvents.SUBTITLE_FILE_UPLOADED, + payload: { filename: data.name, content: data.content }, + }, + }) + } + + // + // Server events + // + + useWebsocketMessageListener({ + type: WSEvents.NATIVE_PLAYER, + onMessage: ({ type, payload }: { type: NativePlayer_ServerEvent, payload: unknown }) => { + switch (type) { + // 1. Open and await + // The server is loading the stream + case "open-and-await": + log.info("Open and await event received", { payload }) + setState(draft => { + draft.active = true + draft.loadingState = payload as string + draft.playbackInfo = null + draft.playbackError = null + return + }) + setMiniPlayer(false) + + break + // 2. Watch + // We received the playback info + case "watch": + log.info("Watch event received", { payload }) + setState(draft => { + draft.playbackInfo = payload as NativePlayer_PlaybackInfo + draft.loadingState = null + draft.playbackError = null + return + }) + setMiniPlayer(false) + break + // 3. Subtitle event (MKV) + // We receive the subtitle events after the server received the loaded-metadata event + case "subtitle-event": + subtitleManager?.onSubtitleEvent(payload as MKVParser_SubtitleEvent) + break + case "add-subtitle-track": + subtitleManager?.onTrackAdded(payload as MKVParser_TrackInfo) + break + case "terminate": + log.info("Terminate event received") + handleTerminateStream() + break + case "error": + log.error("Error event received", payload) + toast.error("An error occurred while playing the stream. " + ((payload as { error: string }).error)) + setState(draft => { + draft.playbackError = (payload as { error: string }).error + return + }) + break + } + }, + }) + + // + // Handlers + // + + function handleTerminateStream() { + // Clean up player first + if (videoElement) { + log.info("Cleaning up media") + videoElement.pause() + } + + setMiniPlayer(true) + setState(draft => { + draft.playbackInfo = null + draft.playbackError = null + draft.loadingState = "Ending stream..." + return + }) + + setTimeout(() => { + setState(draft => { + draft.active = false + return + }) + }, 700) + + sendMessage({ + type: WSEvents.NATIVE_PLAYER, + payload: { + clientId: clientId, + type: VideoPlayerEvents.VIDEO_TERMINATED, + }, + }) + } + + return ( + <> + + <VideoCore + state={state} + aniSkipData={aniSkipData} + onTerminateStream={handleTerminateStream} + onLoadedMetadata={handleLoadedMetadata} + onTimeUpdate={handleTimeUpdate} + onEnded={handleEnded} + onSeeked={handleSeeked} + onCompleted={handleCompleted} + onError={handleError} + onPlay={handlePlay} + onPause={handlePause} + onFileUploaded={handleFileUploaded} + /> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/navigation/main-sidebar.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/navigation/main-sidebar.tsx new file mode 100644 index 0000000..6bdd8c1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/navigation/main-sidebar.tsx @@ -0,0 +1,491 @@ +"use client" +import { useLogout } from "@/api/hooks/auth.hooks" +import { useGetExtensionUpdateData as useGetExtensionUpdateData } from "@/api/hooks/extensions.hooks" +import { isLoginModalOpenAtom } from "@/app/(main)/_atoms/server-status.atoms" +import { useSyncIsActive } from "@/app/(main)/_atoms/sync.atoms" +import { ElectronUpdateModal } from "@/app/(main)/_electron/electron-update-modal" +import { __globalSearch_isOpenAtom } from "@/app/(main)/_features/global-search/global-search" +import { SidebarNavbar } from "@/app/(main)/_features/layout/top-navbar" +import { useOpenSeaCommand } from "@/app/(main)/_features/sea-command/sea-command" +import { UpdateModal } from "@/app/(main)/_features/update/update-modal" +import { useAutoDownloaderQueueCount } from "@/app/(main)/_hooks/autodownloader-queue-count" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { useMissingEpisodeCount } from "@/app/(main)/_hooks/missing-episodes-loader" +import { useCurrentUser, useServerStatus, useSetServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { TauriUpdateModal } from "@/app/(main)/_tauri/tauri-update-modal" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { AppSidebar, useAppSidebarContext } from "@/components/ui/app-layout" +import { Avatar } from "@/components/ui/avatar" +import { Badge } from "@/components/ui/badge" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { defineSchema, Field, Form } from "@/components/ui/form" +import { HoverCard } from "@/components/ui/hover-card" +import { Modal } from "@/components/ui/modal" +import { VerticalMenu, VerticalMenuItem } from "@/components/ui/vertical-menu" +import { openTab } from "@/lib/helpers/browser" +import { ANILIST_OAUTH_URL, ANILIST_PIN_URL } from "@/lib/server/config" +import { TORRENT_CLIENT, TORRENT_PROVIDER } from "@/lib/server/settings" +import { WSEvents } from "@/lib/server/ws-events" +import { useThemeSettings } from "@/lib/theme/hooks" +import { __isDesktop__, __isElectronDesktop__, __isTauriDesktop__ } from "@/types/constants" +import { useAtom, useSetAtom } from "jotai" +import Link from "next/link" +import { usePathname, useRouter } from "next/navigation" +import React from "react" +import { BiCalendarAlt, BiChevronRight, BiDownload, BiExtension, BiLogIn, BiLogOut, BiNews } from "react-icons/bi" +import { FaBookReader } from "react-icons/fa" +import { FiLogIn, FiSearch, FiSettings } from "react-icons/fi" +import { GrTest } from "react-icons/gr" +import { HiOutlineServerStack } from "react-icons/hi2" +import { IoCloudOfflineOutline, IoLibrary } from "react-icons/io5" +import { MdOutlineConnectWithoutContact } from "react-icons/md" +import { PiArrowCircleLeftDuotone, PiArrowCircleRightDuotone, PiClockCounterClockwiseFill, PiListChecksFill } from "react-icons/pi" +import { SiAnilist } from "react-icons/si" +import { TbWorldDownload } from "react-icons/tb" +import { nakamaModalOpenAtom, useNakamaStatus } from "../nakama/nakama-manager" +import { PluginSidebarTray } from "../plugin/tray/plugin-sidebar-tray" + +/** + * @description + * - Displays navigation items + * - Button to logout + * - Shows count of missing episodes and auto downloader queue + */ +export function MainSidebar() { + + const ctx = useAppSidebarContext() + const ts = useThemeSettings() + + const [expandedSidebar, setExpandSidebar] = React.useState(false) + const [dropdownOpen, setDropdownOpen] = React.useState(false) + // const isCollapsed = !ctx.isBelowBreakpoint && !expandedSidebar + const isCollapsed = ts.expandSidebarOnHover ? (!ctx.isBelowBreakpoint && !expandedSidebar) : !ctx.isBelowBreakpoint + + const router = useRouter() + const pathname = usePathname() + const serverStatus = useServerStatus() + const setServerStatus = useSetServerStatus() + const user = useCurrentUser() + + const { setSeaCommandOpen } = useOpenSeaCommand() + + const missingEpisodeCount = useMissingEpisodeCount() + const autoDownloaderQueueCount = useAutoDownloaderQueueCount() + + // Logout + const { mutate: logout, data, isPending } = useLogout() + + React.useEffect(() => { + if (!isPending) { + setServerStatus(data) + } + }, [isPending, data]) + + const setGlobalSearchIsOpen = useSetAtom(__globalSearch_isOpenAtom) + const [loginModal, setLoginModal] = useAtom(isLoginModalOpenAtom) + const [nakamaModalOpen, setNakamaModalOpen] = useAtom(nakamaModalOpenAtom) + const nakamaStatus = useNakamaStatus() + + const handleExpandSidebar = () => { + if (!ctx.isBelowBreakpoint && ts.expandSidebarOnHover) { + setExpandSidebar(true) + } + } + const handleUnexpandedSidebar = () => { + if (expandedSidebar && ts.expandSidebarOnHover) { + setExpandSidebar(false) + } + } + + const confirmSignOut = useConfirmationDialog({ + title: "Sign out", + description: "Are you sure you want to sign out?", + onConfirm: () => { + logout() + }, + }) + + const [activeTorrentCount, setActiveTorrentCount] = React.useState({ downloading: 0, paused: 0, seeding: 0 }) + useWebsocketMessageListener<{ downloading: number, paused: number, seeding: number }>({ + type: WSEvents.ACTIVE_TORRENT_COUNT_UPDATED, + onMessage: data => { + setActiveTorrentCount(data) + }, + }) + + const { syncIsActive } = useSyncIsActive() + + const { data: updateData } = useGetExtensionUpdateData() + + const [loggingIn, setLoggingIn] = React.useState(false) + + const items = [ + { + id: "library", + iconType: IoLibrary, + name: "Library", + href: "/", + isCurrent: pathname === "/", + }, + ...(process.env.NODE_ENV === "development" ? [{ + id: "test", + iconType: GrTest, + name: "Test", + href: "/test", + isCurrent: pathname === "/test", + }] : []), + { + id: "schedule", + iconType: BiCalendarAlt, + name: "Schedule", + href: "/schedule", + isCurrent: pathname === "/schedule", + addon: missingEpisodeCount > 0 ? <Badge + className="absolute right-0 top-0" size="sm" + intent="alert-solid" + >{missingEpisodeCount}</Badge> : undefined, + }, + ...serverStatus?.settings?.library?.enableManga ? [{ + id: "manga", + iconType: FaBookReader, + name: "Manga", + href: "/manga", + isCurrent: pathname.startsWith("/manga"), + }] : [], + { + id: "discover", + iconType: BiNews, + name: "Discover", + href: "/discover", + isCurrent: pathname === "/discover", + }, + { + id: "anilist", + iconType: user?.isSimulated ? PiListChecksFill : SiAnilist, + name: user?.isSimulated ? "My lists" : "AniList", + href: "/anilist", + isCurrent: pathname === "/anilist", + }, + ...serverStatus?.settings?.nakama?.enabled ? [{ + id: "nakama", + iconType: MdOutlineConnectWithoutContact, + iconClass: "size-6", + name: "Nakama", + isCurrent: nakamaModalOpen, + onClick: () => setNakamaModalOpen(true), + addon: <> + {nakamaStatus?.isHost && !!nakamaStatus?.connectedPeers?.length && <Badge + className="absolute right-0 top-0" size="sm" + intent="info" + >{nakamaStatus?.connectedPeers?.length}</Badge>} + + {nakamaStatus?.isConnectedToHost && <div + className="absolute right-2 top-2 animate-pulse size-2 bg-green-500 rounded-full" + ></div>} + </>, + }] : [], + ...serverStatus?.settings?.library?.torrentProvider !== TORRENT_PROVIDER.NONE ? [{ + id: "auto-downloader", + iconType: TbWorldDownload, + name: "Auto Downloader", + href: "/auto-downloader", + isCurrent: pathname === "/auto-downloader", + addon: autoDownloaderQueueCount > 0 ? <Badge + className="absolute right-0 top-0" size="sm" + intent="alert-solid" + >{autoDownloaderQueueCount}</Badge> : undefined, + }] : [], + ...( + serverStatus?.settings?.library?.torrentProvider !== TORRENT_PROVIDER.NONE + && !serverStatus?.settings?.torrent?.hideTorrentList + && serverStatus?.settings?.torrent?.defaultTorrentClient !== TORRENT_CLIENT.NONE) + ? [{ + id: "torrent-list", + iconType: BiDownload, + name: (activeTorrentCount.seeding === 0 || !serverStatus?.settings?.torrent?.showActiveTorrentCount) + ? "Torrent list" + : `Torrent list (${activeTorrentCount.seeding} seeding)`, + href: "/torrent-list", + isCurrent: pathname === "/torrent-list", + addon: ((activeTorrentCount.downloading + activeTorrentCount.paused) > 0 && serverStatus?.settings?.torrent?.showActiveTorrentCount) + ? <Badge + className="absolute right-0 top-0 bg-green-500" size="sm" + intent="alert-solid" + >{activeTorrentCount.downloading + activeTorrentCount.paused}</Badge> + : undefined, + }] : [], + ...(serverStatus?.debridSettings?.enabled && !!serverStatus?.debridSettings?.provider) ? [{ + id: "debrid", + iconType: HiOutlineServerStack, + name: "Debrid", + href: "/debrid", + isCurrent: pathname === "/debrid", + }] : [], + { + id: "scan-summaries", + iconType: PiClockCounterClockwiseFill, + name: "Scan summaries", + href: "/scan-summaries", + isCurrent: pathname === "/scan-summaries", + }, + { + id: "search", + iconType: FiSearch, + name: "Search", + onClick: () => { + ctx.setOpen(false) + setGlobalSearchIsOpen(true) + }, + }, + ] + + const pinnedMenuItems = React.useMemo(() => { + return items.filter(item => !ts.unpinnedMenuItems?.includes(item.id)) + }, [items, ts.unpinnedMenuItems]) + + const unpinnedMenuItems = React.useMemo(() => { + if (ts.unpinnedMenuItems?.length === 0 || items.length === 0) return [] + return [ + { + iconType: BiChevronRight, + name: "More", + subContent: <VerticalMenu + items={items.filter(item => ts.unpinnedMenuItems?.includes(item.id))} + />, + } as VerticalMenuItem, + ] + }, [items, ts.unpinnedMenuItems, ts.hideTopNavbar]) + + return ( + <> + <AppSidebar + className={cn( + "group/main-sidebar h-full flex flex-col justify-between transition-gpu w-full transition-[width] duration-300", + (!ctx.isBelowBreakpoint && expandedSidebar) && "w-[260px]", + (!ctx.isBelowBreakpoint && !ts.disableSidebarTransparency) && "bg-transparent", + (!ctx.isBelowBreakpoint && !ts.disableSidebarTransparency && ts.expandSidebarOnHover) && "hover:bg-[--background]", + )} + onMouseEnter={handleExpandSidebar} + onMouseLeave={handleUnexpandedSidebar} + > + {(!ctx.isBelowBreakpoint && ts.expandSidebarOnHover && ts.disableSidebarTransparency) && <div + className={cn( + "fixed h-full translate-x-0 w-[50px] bg-gradient bg-gradient-to-r via-[--background] from-[--background] to-transparent", + "group-hover/main-sidebar:translate-x-[250px] transition opacity-0 duration-300 group-hover/main-sidebar:opacity-100", + )} + ></div>} + + <div> + <div className="mb-4 p-4 pb-0 flex justify-center w-full"> + <img src="/logo.png" alt="logo" className="w-15 h-10" /> + </div> + <VerticalMenu + className="px-4" + collapsed={isCollapsed} + itemClass="relative" + itemChevronClass="hidden" + itemIconClass="transition-transform group-data-[state=open]/verticalMenu_parentItem:rotate-90" + items={[ + ...pinnedMenuItems, + ...unpinnedMenuItems, + ]} + subContentClass={cn((ts.hideTopNavbar || __isDesktop__) && "border-transparent !border-b-0")} + onLinkItemClick={() => ctx.setOpen(false)} + /> + + <SidebarNavbar + isCollapsed={isCollapsed} + handleExpandSidebar={() => { }} + handleUnexpandedSidebar={() => { }} + /> + {__isDesktop__ && <div className="w-full flex justify-center px-4"> + <HoverCard + side="right" + sideOffset={-8} + className="bg-transparent border-none" + trigger={<IconButton + intent="gray-basic" + className="!text-[--muted] hover:!text-[--foreground]" + icon={<PiArrowCircleLeftDuotone />} + onClick={() => { + router.back() + }} + />} + > + <IconButton + icon={<PiArrowCircleRightDuotone />} + intent="gray-subtle" + className="opacity-50 hover:opacity-100" + onClick={() => { + router.forward() + }} + /> + </HoverCard> + </div>} + + <PluginSidebarTray place="sidebar" /> + + </div> + <div className="flex w-full gap-2 flex-col px-4"> + {!__isDesktop__ ? <UpdateModal collapsed={isCollapsed} /> : + __isTauriDesktop__ ? <TauriUpdateModal collapsed={isCollapsed} /> : + __isElectronDesktop__ ? <ElectronUpdateModal collapsed={isCollapsed} /> : + null} + <div> + <VerticalMenu + collapsed={isCollapsed} + itemClass="relative" + onMouseEnter={() => { }} + onMouseLeave={() => { }} + onLinkItemClick={() => ctx.setOpen(false)} + items={[ + // { + // iconType: RiSlashCommands2, + // name: "Command palette", + // onClick: () => { + // setSeaCommandOpen(true) + // } + // }, + { + iconType: BiExtension, + name: "Extensions", + href: "/extensions", + isCurrent: pathname.includes("/extensions"), + addon: !!updateData?.length + ? <Badge + className="absolute right-0 top-0 bg-red-500 animate-pulse" size="sm" + intent="alert-solid" + > + {updateData?.length || 1} + </Badge> + : undefined, + }, + { + iconType: IoCloudOfflineOutline, + name: "Offline", + href: "/sync", + isCurrent: pathname.includes("/sync"), + addon: (syncIsActive) + ? <Badge + className="absolute right-0 top-0 bg-blue-500" size="sm" + intent="alert-solid" + > + 1 + </Badge> + : undefined, + }, + { + iconType: FiSettings, + name: "Settings", + href: "/settings", + isCurrent: pathname === ("/settings"), + }, + ...(ctx.isBelowBreakpoint ? [ + { + iconType: user?.isSimulated ? FiLogIn : BiLogOut, + name: user?.isSimulated ? "Sign in" : "Sign out", + onClick: user?.isSimulated ? () => setLoginModal(true) : confirmSignOut.open, + }, + ] : []), + ]} + /> + </div> + {!user && ( + <div> + <VerticalMenu + collapsed={isCollapsed} + itemClass="relative" + onMouseEnter={handleExpandSidebar} + onMouseLeave={handleUnexpandedSidebar} + onLinkItemClick={() => ctx.setOpen(false)} + items={[ + { + iconType: FiLogIn, + name: "Login", + onClick: () => openTab(ANILIST_OAUTH_URL), + }, + ]} + /> + </div> + )} + {!!user && <div className="flex w-full gap-2 flex-col"> + <DropdownMenu + trigger={<div + className={cn( + "w-full flex p-2.5 pt-1 items-center space-x-2", + { "hidden": ctx.isBelowBreakpoint }, + )} + > + <Avatar size="sm" className="cursor-pointer" src={user?.viewer?.avatar?.medium || undefined} /> + {expandedSidebar && <p className="truncate">{user?.viewer?.name}</p>} + </div>} + open={dropdownOpen} + onOpenChange={setDropdownOpen} + > + {!user.isSimulated ? <DropdownMenuItem onClick={confirmSignOut.open}> + <BiLogOut /> Sign out + </DropdownMenuItem> : <DropdownMenuItem onClick={() => setLoginModal(true)}> + <BiLogIn /> Log in with AniList + </DropdownMenuItem>} + </DropdownMenu> + </div>} + </div> + </AppSidebar> + + <Modal + title="Log in with AniList" + description="Using an AniList account is recommended." + open={loginModal && user?.isSimulated} + onOpenChange={(v) => setLoginModal(v)} + overlayClass="bg-opacity-95 bg-gray-950" + contentClass="border" + > + <div className="mt-5 text-center space-y-4"> + + <Link + href={ANILIST_PIN_URL} + target="_blank" + > + <Button + leftIcon={<svg + xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" + viewBox="0 0 24 24" role="img" + > + <path + d="M6.361 2.943 0 21.056h4.942l1.077-3.133H11.4l1.052 3.133H22.9c.71 0 1.1-.392 1.1-1.101V17.53c0-.71-.39-1.101-1.1-1.101h-6.483V4.045c0-.71-.392-1.102-1.101-1.102h-2.422c-.71 0-1.101.392-1.101 1.102v1.064l-.758-2.166zm2.324 5.948 1.688 5.018H7.144z" + /> + </svg>} + intent="white" + size="md" + >Get AniList token</Button> + </Link> + + <Form + schema={defineSchema(({ z }) => z.object({ + token: z.string().min(1, "Token is required"), + }))} + onSubmit={data => { + setLoggingIn(true) + router.push("/auth/callback#access_token=" + data.token.trim()) + setLoginModal(false) + setLoggingIn(false) + }} + > + <Field.Textarea + name="token" + label="Enter the token" + fieldClass="px-4" + /> + <Field.Submit showLoadingOverlayOnSuccess loading={loggingIn}>Continue</Field.Submit> + </Form> + + </div> + </Modal> + + <ConfirmationDialog {...confirmSignOut} /> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/navigation/offline-sidebar.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/navigation/offline-sidebar.tsx new file mode 100644 index 0000000..2714cda --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/navigation/offline-sidebar.tsx @@ -0,0 +1,150 @@ +"use client" +import { useSetOfflineMode } from "@/api/hooks/local.hooks" +import { SidebarNavbar } from "@/app/(main)/_features/layout/top-navbar" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { AppSidebar, useAppSidebarContext } from "@/components/ui/app-layout" +import { Avatar } from "@/components/ui/avatar" +import { cn } from "@/components/ui/core/styling" +import { VerticalMenu } from "@/components/ui/vertical-menu" +import { useThemeSettings } from "@/lib/theme/hooks" +import { usePathname } from "next/navigation" +import React from "react" +import { FaBookReader } from "react-icons/fa" +import { FiSettings } from "react-icons/fi" +import { IoCloudyOutline, IoLibrary } from "react-icons/io5" +import { PluginSidebarTray } from "../plugin/tray/plugin-sidebar-tray" + + +export function OfflineSidebar() { + const serverStatus = useServerStatus() + const ctx = useAppSidebarContext() + const ts = useThemeSettings() + + const [expandedSidebar, setExpandSidebar] = React.useState(false) + const isCollapsed = ts.expandSidebarOnHover ? (!ctx.isBelowBreakpoint && !expandedSidebar) : !ctx.isBelowBreakpoint + + const { mutate: setOfflineMode, isPending: isSettingOfflineMode } = useSetOfflineMode() + + const pathname = usePathname() + + const handleExpandSidebar = () => { + if (!ctx.isBelowBreakpoint && ts.expandSidebarOnHover) { + setExpandSidebar(true) + } + } + const handleUnexpandedSidebar = () => { + if (expandedSidebar && ts.expandSidebarOnHover) { + setExpandSidebar(false) + } + } + + const confirmDialog = useConfirmationDialog({ + title: "Disable offline mode", + description: "Are you sure you want to disable offline mode?", + actionText: "Yes", + actionIntent: "primary", + onConfirm: () => { + setOfflineMode({ enabled: false }) + }, + }) + + return ( + <> + <AppSidebar + className={cn( + "h-full flex flex-col justify-between transition-gpu w-full transition-[width]", + (!ctx.isBelowBreakpoint && expandedSidebar) && "w-[260px]", + (!ctx.isBelowBreakpoint && !ts.disableSidebarTransparency) && "bg-transparent", + (!ctx.isBelowBreakpoint && !ts.disableSidebarTransparency && ts.expandSidebarOnHover) && "hover:bg-[--background]", + )} + onMouseEnter={handleExpandSidebar} + onMouseLeave={handleUnexpandedSidebar} + > + {(!ctx.isBelowBreakpoint && ts.expandSidebarOnHover && ts.disableSidebarTransparency) && <div + className={cn( + "fixed h-full translate-x-0 w-[50px] bg-gradient bg-gradient-to-r via-[--background] from-[--background] to-transparent", + "group-hover/main-sidebar:translate-x-[250px] transition opacity-0 duration-300 group-hover/main-sidebar:opacity-100", + )} + ></div>} + + + <div> + <div className="mb-4 p-4 pb-0 flex justify-center w-full"> + <img src="/logo.png" alt="logo" className="w-15 h-10" /> + </div> + <VerticalMenu + className="px-4" + collapsed={isCollapsed} + itemClass="relative" + items={[ + { + iconType: IoLibrary, + name: "Library", + href: "/offline", + isCurrent: pathname === "/offline", + }, + ...[serverStatus?.settings?.library?.enableManga && { + iconType: FaBookReader, + name: "Manga", + href: "/offline/manga", + isCurrent: pathname.startsWith("/offline/manga"), + }].filter(Boolean) as any, + ].filter(Boolean)} + onLinkItemClick={() => ctx.setOpen(false)} + /> + + <SidebarNavbar + isCollapsed={isCollapsed} + handleExpandSidebar={() => { }} + handleUnexpandedSidebar={() => { }} + /> + + <PluginSidebarTray place="sidebar" /> + </div> + <div className="flex w-full gap-2 flex-col px-4"> + <div> + <VerticalMenu + collapsed={isCollapsed} + itemClass="relative" + onLinkItemClick={() => ctx.setOpen(false)} + items={[ + { + iconType: IoCloudyOutline, + name: "Disable offline mode", + onClick: () => { + confirmDialog.open() + }, + }, + { + iconType: FiSettings, + name: "Settings", + href: "/settings", + isCurrent: pathname === ("/settings"), + }, + ]} + /> + </div> + <div className="flex w-full gap-2 flex-col"> + <div + className={cn( + "w-full flex p-2.5 pt-1 items-center space-x-2", + { "hidden": ctx.isBelowBreakpoint }, + )} + > + <Avatar + size="sm" + className="cursor-pointer" + /> + {expandedSidebar && <p className="truncate">Offline</p>} + </div> + </div> + </div> + </AppSidebar> + <ConfirmationDialog + {...confirmDialog} + /> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/navigation/top-menu.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/navigation/top-menu.tsx new file mode 100644 index 0000000..fa5d7a4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/navigation/top-menu.tsx @@ -0,0 +1,72 @@ +"use client" +import { useMissingEpisodeCount } from "@/app/(main)/_hooks/missing-episodes-loader" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { Badge } from "@/components/ui/badge" +import { NavigationMenu, NavigationMenuProps } from "@/components/ui/navigation-menu" +import { usePathname } from "next/navigation" +import React, { useMemo } from "react" + +interface TopMenuProps { + children?: React.ReactNode +} + +export const TopMenu: React.FC<TopMenuProps> = (props) => { + + const { children, ...rest } = props + + const serverStatus = useServerStatus() + + const pathname = usePathname() + + const missingEpisodeCount = useMissingEpisodeCount() + + const navigationItems = useMemo<NavigationMenuProps["items"]>(() => { + + return [ + { + href: "/", + // icon: IoLibrary, + isCurrent: pathname === "/", + name: "My library", + }, + { + href: "/schedule", + icon: null, + isCurrent: pathname.startsWith("/schedule"), + name: "Schedule", + addon: missingEpisodeCount > 0 ? <Badge + className="absolute top-1 right-2 h-2 w-2 p-0" size="sm" + intent="alert-solid" + /> : undefined, + }, + ...[serverStatus?.settings?.library?.enableManga && { + href: "/manga", + icon: null, + isCurrent: pathname.startsWith("/manga"), + name: "Manga", + }].filter(Boolean) as NavigationMenuProps["items"], + { + href: "/discover", + icon: null, + isCurrent: pathname.startsWith("/discover") || pathname.startsWith("/search"), + name: "Discover", + }, + { + href: "/anilist", + icon: null, + isCurrent: pathname.startsWith("/anilist"), + name: serverStatus?.user?.isSimulated ? "My lists" : "AniList", + }, + ].filter(Boolean) + }, [pathname, missingEpisodeCount, serverStatus?.settings?.library?.enableManga]) + + return ( + <NavigationMenu + className="p-0 hidden lg:inline-block" + itemClass="text-xl" + items={navigationItems} + data-top-menu + /> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/actions/plugin-actions.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/actions/plugin-actions.tsx new file mode 100644 index 0000000..ad4604c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/actions/plugin-actions.tsx @@ -0,0 +1,409 @@ +import { AL_BaseAnime, AL_BaseManga, Anime_Episode, Onlinestream_Episode } from "@/api/generated/types" +import { Button, ButtonProps, IconButton } from "@/components/ui/button" +import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu" +import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu" +import React, { useEffect, useState } from "react" +import { BiDotsHorizontal } from "react-icons/bi" +import { + usePluginListenActionRenderAnimeLibraryDropdownItemsEvent, + usePluginListenActionRenderAnimePageButtonsEvent, + usePluginListenActionRenderAnimePageDropdownItemsEvent, + usePluginListenActionRenderEpisodeCardContextMenuItemsEvent, + usePluginListenActionRenderEpisodeGridItemMenuItemsEvent, + usePluginListenActionRenderMangaPageButtonsEvent, + usePluginListenActionRenderMediaCardContextMenuItemsEvent, + usePluginSendActionClickedEvent, + usePluginSendActionRenderAnimeLibraryDropdownItemsEvent, + usePluginSendActionRenderAnimePageButtonsEvent, + usePluginSendActionRenderAnimePageDropdownItemsEvent, + usePluginSendActionRenderEpisodeCardContextMenuItemsEvent, + usePluginSendActionRenderEpisodeGridItemMenuItemsEvent, + usePluginSendActionRenderMangaPageButtonsEvent, + usePluginSendActionRenderMediaCardContextMenuItemsEvent, +} from "../generated/plugin-events" + +function sortItems<T extends { label: string }>(items: T[]) { + return items.sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true })) +} + +type PluginAnimePageButton = { + extensionId: string + intent: string + onClick: string + label: string + style: React.CSSProperties + id: string +} + +export function PluginAnimePageButtons(props: { media: AL_BaseAnime }) { + const [buttons, setButtons] = useState<PluginAnimePageButton[]>([]) + + const { sendActionRenderAnimePageButtonsEvent } = usePluginSendActionRenderAnimePageButtonsEvent() + const { sendActionClickedEvent } = usePluginSendActionClickedEvent() + + useEffect(() => { + sendActionRenderAnimePageButtonsEvent({}, "") + }, []) + + // Listen for the action to render the anime page buttons + usePluginListenActionRenderAnimePageButtonsEvent((event, extensionId) => { + setButtons(p => { + const otherButtons = p.filter(b => b.extensionId !== extensionId) + const extButtons = event.buttons.map((b: Record<string, any>) => ({ ...b, extensionId } as PluginAnimePageButton)) + return sortItems([...otherButtons, ...extButtons]) + }) + }, "") + + // Send + function handleClick(button: PluginAnimePageButton) { + sendActionClickedEvent({ + actionId: button.id, + event: { + media: props.media, + }, + }, button.extensionId) + } + + if (buttons.length === 0) return null + + return <> + {buttons.map(b => ( + <Button + key={b.id} + intent={b.intent as ButtonProps["intent"] || "white-subtle"} + onClick={() => handleClick(b)} + style={b.style} + >{b.label || "???"}</Button> + ))} + </> +} + + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type PluginMangaPageButton = { + extensionId: string + intent: string + onClick: string + label: string + style: React.CSSProperties + id: string +} + +export function PluginMangaPageButtons(props: { media: AL_BaseManga }) { + const [buttons, setButtons] = useState<PluginMangaPageButton[]>([]) + + const { sendActionRenderMangaPageButtonsEvent } = usePluginSendActionRenderMangaPageButtonsEvent() + const { sendActionClickedEvent } = usePluginSendActionClickedEvent() + + useEffect(() => { + sendActionRenderMangaPageButtonsEvent({}, "") + }, []) + + // Listen for the action to render the manga page buttons + usePluginListenActionRenderMangaPageButtonsEvent((event, extensionId) => { + setButtons(p => { + const otherButtons = p.filter(b => b.extensionId !== extensionId) + const extButtons = event.buttons.map((b: Record<string, any>) => ({ ...b, extensionId } as PluginMangaPageButton)) + return sortItems([...otherButtons, ...extButtons]) + }) + }, "") + + // Send + function handleClick(button: PluginMangaPageButton) { + sendActionClickedEvent({ + actionId: button.id, + event: { + media: props.media, + }, + }, button.extensionId) + } + + if (buttons.length === 0) return null + + return <> + {buttons.map(b => ( + <Button + key={b.id} + intent={b.intent as ButtonProps["intent"] || "white-subtle"} + onClick={() => handleClick(b)} + style={b.style} + >{b.label || "???"}</Button> + ))} + </> +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type PluginMediaCardContextMenuItem = { + extensionId: string + onClick: string + label: string + style: React.CSSProperties + id: string + for: "anime" | "manga" | "both" +} + +type PluginMediaCardContextMenuItemsProps = { + for: "anime" | "manga", + media: AL_BaseAnime | AL_BaseManga +} + +export function PluginMediaCardContextMenuItems(props: PluginMediaCardContextMenuItemsProps) { + const [items, setItems] = useState<PluginMediaCardContextMenuItem[]>([]) + + const { sendActionRenderMediaCardContextMenuItemsEvent } = usePluginSendActionRenderMediaCardContextMenuItemsEvent() + const { sendActionClickedEvent } = usePluginSendActionClickedEvent() + + useEffect(() => { + sendActionRenderMediaCardContextMenuItemsEvent({}, "") + }, []) + + // Listen for the action to render the media card context menu items + usePluginListenActionRenderMediaCardContextMenuItemsEvent((event, extensionId) => { + setItems(p => { + const otherItems = p.filter(b => b.extensionId !== extensionId) + const extItems = event.items + .filter((i: PluginMediaCardContextMenuItem) => i.for === props.for || i.for === "both") + .map((b: Record<string, any>) => ({ ...b, extensionId } as PluginMangaPageButton)) + return sortItems([...otherItems, ...extItems]) + }) + }, "") + + // Send + function handleClick(item: PluginMediaCardContextMenuItem) { + sendActionClickedEvent({ + actionId: item.id, + event: { + media: props.media, + }, + }, item.extensionId) + } + + if (items.length === 0) return null + + return <> + <ContextMenuSeparator className="my-2" /> + {items.map(i => ( + <ContextMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</ContextMenuItem> + ))} + </> +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type PluginAnimeLibraryDropdownMenuItem = { + extensionId: string + onClick: string + label: string + id: string + style: React.CSSProperties +} + +export function PluginAnimeLibraryDropdownItems() { + const [items, setItems] = useState<PluginAnimeLibraryDropdownMenuItem[]>([]) + + const { sendActionRenderAnimeLibraryDropdownItemsEvent } = usePluginSendActionRenderAnimeLibraryDropdownItemsEvent() + const { sendActionClickedEvent } = usePluginSendActionClickedEvent() + + useEffect(() => { + sendActionRenderAnimeLibraryDropdownItemsEvent({}, "") + }, []) + + + // Listen for the action to render the anime library dropdown items + usePluginListenActionRenderAnimeLibraryDropdownItemsEvent((event, extensionId) => { + setItems(p => { + const otherItems = p.filter(i => i.extensionId !== extensionId) + const extItems = event.items.map((i: Record<string, any>) => ({ ...i, extensionId } as PluginAnimeLibraryDropdownMenuItem)) + return sortItems([...otherItems, ...extItems]) + }) + }, "") + + // Send + function handleClick(item: PluginAnimeLibraryDropdownMenuItem) { + sendActionClickedEvent({ + actionId: item.id, + event: {}, + }, item.extensionId) + } + + if (items.length === 0) return null + + return <> + <DropdownMenuSeparator /> + {items.map(i => ( + <DropdownMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</DropdownMenuItem> + ))} + </> +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type PluginEpisodeCardContextMenuItem = { + extensionId: string + onClick: string + label: string + id: string + style: React.CSSProperties +} + +export function PluginEpisodeCardContextMenuItems(props: { episode: Anime_Episode | undefined }) { + const [items, setItems] = useState<PluginEpisodeCardContextMenuItem[]>([]) + + const { sendActionRenderEpisodeCardContextMenuItemsEvent } = usePluginSendActionRenderEpisodeCardContextMenuItemsEvent() + const { sendActionClickedEvent } = usePluginSendActionClickedEvent() + + useEffect(() => { + sendActionRenderEpisodeCardContextMenuItemsEvent({}, "") + }, []) + + // Listen for the action to render the episode card context menu items + usePluginListenActionRenderEpisodeCardContextMenuItemsEvent((event, extensionId) => { + setItems(p => { + const otherItems = p.filter(i => i.extensionId !== extensionId) + const extItems = event.items.map((i: Record<string, any>) => ({ ...i, extensionId } as PluginEpisodeCardContextMenuItem)) + return sortItems([...otherItems, ...extItems]) + }) + }, "") + + // Send + function handleClick(item: PluginEpisodeCardContextMenuItem) { + sendActionClickedEvent({ + actionId: item.id, + event: { + episode: props.episode, + }, + }, item.extensionId) + } + + if (items.length === 0) return null + + return <> + <ContextMenuSeparator /> + {items.map(i => ( + <ContextMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</ContextMenuItem> + ))} + </> +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type PluginEpisodeGridItemMenuItem = { + extensionId: string + onClick: string + label: string + id: string + type: "library" | "torrentstream" | "debridstream" | "onlinestream" | "undownloaded" | "medialinks" | "mediastream" + style: React.CSSProperties +} + +export function PluginEpisodeGridItemMenuItems(props: { + isDropdownMenu: boolean, + type: PluginEpisodeGridItemMenuItem["type"], + episode: Anime_Episode | Onlinestream_Episode | undefined +}) { + const [items, setItems] = useState<PluginEpisodeGridItemMenuItem[]>([]) + + const { sendActionRenderEpisodeGridItemMenuItemsEvent } = usePluginSendActionRenderEpisodeGridItemMenuItemsEvent() + const { sendActionClickedEvent } = usePluginSendActionClickedEvent() + + useEffect(() => { + sendActionRenderEpisodeGridItemMenuItemsEvent({}, "") + }, []) + + // Listen for the action to render the episode grid item context menu items + usePluginListenActionRenderEpisodeGridItemMenuItemsEvent((event, extensionId) => { + setItems(p => { + const otherItems = p.filter(i => i.extensionId !== extensionId && i.type === props.type) + const extItems = event.items.filter((i: PluginEpisodeGridItemMenuItem) => i.type === props.type) + .map((i: Record<string, any>) => ({ ...i, extensionId } as PluginEpisodeGridItemMenuItem)) + return sortItems([...otherItems, ...extItems]) + }) + }, "") + + // Send + function handleClick(item: PluginEpisodeGridItemMenuItem) { + sendActionClickedEvent({ + actionId: item.id, + event: { + episode: props.episode, + }, + }, item.extensionId) + } + + if (items.length === 0) return null + + if (props.isDropdownMenu) { + return <DropdownMenu + trigger={ + <IconButton + icon={<BiDotsHorizontal />} + intent="gray-basic" + size="xs" + /> + } + > + {items.map(i => ( + <DropdownMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</DropdownMenuItem> + ))} + </DropdownMenu> + } + + return <> + <DropdownMenuSeparator /> + {items.map(i => ( + <DropdownMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</DropdownMenuItem> + ))} + </> +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type PluginAnimePageDropdownMenuItem = { + extensionId: string + onClick: string + label: string + id: string + style: React.CSSProperties +} + +export function PluginAnimePageDropdownItems(props: { media: AL_BaseAnime }) { + const [items, setItems] = useState<PluginAnimePageDropdownMenuItem[]>([]) + + const { sendActionRenderAnimePageDropdownItemsEvent } = usePluginSendActionRenderAnimePageDropdownItemsEvent() + const { sendActionClickedEvent } = usePluginSendActionClickedEvent() + + useEffect(() => { + sendActionRenderAnimePageDropdownItemsEvent({}, "") + }, []) + + // Listen for the action to render the anime page dropdown items + usePluginListenActionRenderAnimePageDropdownItemsEvent((event, extensionId) => { + setItems(p => { + const otherItems = p.filter(i => i.extensionId !== extensionId) + const extItems = event.items.map((i: Record<string, any>) => ({ ...i, extensionId } as PluginAnimePageDropdownMenuItem)) + return sortItems([...otherItems, ...extItems]) + }) + }, "") + + // Send + function handleClick(item: PluginAnimePageDropdownMenuItem) { + sendActionClickedEvent({ + actionId: item.id, + event: { + media: props.media, + }, + }, item.extensionId) + } + + if (items.length === 0) return null + + return <> + <DropdownMenuSeparator /> + {items.map(i => ( + <DropdownMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</DropdownMenuItem> + ))} + </> + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/command/plugin-command-palette.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/command/plugin-command-palette.tsx new file mode 100644 index 0000000..666dfe2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/command/plugin-command-palette.tsx @@ -0,0 +1,209 @@ +import { CommandDialog, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" +import { useUpdateEffect } from "@/components/ui/core/hooks" +import mousetrap from "mousetrap" +import { usePathname, useRouter } from "next/navigation" +import React from "react" +import { PluginProvider, registry, RenderPluginComponents } from "../components/registry" +import { + usePluginListenCommandPaletteCloseEvent, + usePluginListenCommandPaletteGetInputEvent, + usePluginListenCommandPaletteOpenEvent, + usePluginListenCommandPaletteSetInputEvent, + usePluginListenCommandPaletteUpdatedEvent, + usePluginSendCommandPaletteClosedEvent, + usePluginSendCommandPaletteInputEvent, + usePluginSendCommandPaletteItemSelectedEvent, + usePluginSendCommandPaletteOpenedEvent, + usePluginSendRenderCommandPaletteEvent, +} from "../generated/plugin-events" + +export type PluginCommandPaletteInfo = { + extensionId: string + placeholder: string + keyboardShortcut: string +} + +type CommandItem = { + id: string + value: string + filterType: string + heading: string + + // Either the label or the components should be set + label: string // empty string if components are set + components?: any +} + +export function PluginCommandPalette(props: { extensionId: string, info: PluginCommandPaletteInfo }) { + + const { extensionId, info } = props + + const router = useRouter() + const pathname = usePathname() + + const [open, setOpen] = React.useState(false) + const [input, setInput] = React.useState("") + const [activeItemId, setActiveItemId] = React.useState("") + const [items, setItems] = React.useState<CommandItem[]>([]) + const [placeholder, setPlaceholder] = React.useState(info.placeholder) + + const { sendRenderCommandPaletteEvent } = usePluginSendRenderCommandPaletteEvent() + const { sendCommandPaletteInputEvent } = usePluginSendCommandPaletteInputEvent() + const { sendCommandPaletteOpenedEvent } = usePluginSendCommandPaletteOpenedEvent() + const { sendCommandPaletteClosedEvent } = usePluginSendCommandPaletteClosedEvent() + const { sendCommandPaletteItemSelectedEvent } = usePluginSendCommandPaletteItemSelectedEvent() + + // const parsedCommandProps = useSeaCommand_ParseCommand(input) + + // Register the keyboard shortcut + React.useEffect(() => { + if (!!info.keyboardShortcut) { + mousetrap.bind(info.keyboardShortcut, () => { + setInput("") + React.startTransition(() => { + setOpen(true) + }) + }) + + return () => { + mousetrap.unbind(info.keyboardShortcut) + } + } + }, [info.keyboardShortcut]) + + // Render the command palette + useUpdateEffect(() => { + if (!open) { + setInput("") + sendCommandPaletteClosedEvent({}, extensionId) + } + + if (open) { + sendCommandPaletteOpenedEvent({}, extensionId) + sendRenderCommandPaletteEvent({}, extensionId) + } + }, [open, extensionId]) + + // Send the input when the server requests it + usePluginListenCommandPaletteGetInputEvent((data) => { + sendCommandPaletteInputEvent({ value: input }, extensionId) + }, extensionId) + + // Set the input when the server sends it + usePluginListenCommandPaletteSetInputEvent((data) => { + setInput(data.value) + }, extensionId) + + // Open the command palette when the server requests it + usePluginListenCommandPaletteOpenEvent((data) => { + setOpen(true) + }, extensionId) + + // Close the command palette when the server requests it + usePluginListenCommandPaletteCloseEvent((data) => { + setOpen(false) + }, extensionId) + + // Continuously listen to render the command palette + usePluginListenCommandPaletteUpdatedEvent((data) => { + setItems(data.items) + setPlaceholder(data.placeholder) + }, extensionId) + + const commandListRef = React.useRef<HTMLDivElement>(null) + + function scrollToTop() { + const list = commandListRef.current + if (!list) return () => { } + + const t = setTimeout(() => { + list.scrollTop = 0 + // Find and focus the first command item + const firstItem = list.querySelector("[cmdk-item]") as HTMLElement + if (firstItem) { + const value = firstItem.getAttribute("data-value") + if (value) { + setActiveItemId(value) + } + } + }, 100) + + return () => clearTimeout(t) + } + + React.useEffect(() => { + const cl = scrollToTop() + return () => cl() + }, [input, pathname]) + + // Group items by heading and sort by priority + const groupedItems = React.useMemo(() => { + const groups: Record<string, CommandItem[]> = {} + + const _items = items.filter(item => + item.filterType === "includes" ? + item.value.toLowerCase().includes(input.toLowerCase()) : + item.filterType === "startsWith" ? + item.value.toLowerCase().startsWith(input.toLowerCase()) : + true) + + _items.forEach(item => { + const heading = item.heading || "" + if (!groups[heading]) groups[heading] = [] + groups[heading].push(item) + }) + + // Scroll to top when items are rendered + scrollToTop() + + return groups + }, [items, input]) + + function handleSelect(item: CommandItem) { + // setInput("") + sendCommandPaletteItemSelectedEvent({ itemId: item.id }, extensionId) + } + + + return ( + <CommandDialog + open={open} + onOpenChange={setOpen} + commandProps={{ + value: activeItemId, + onValueChange: setActiveItemId, + }} + overlayClass="bg-black/30" + contentClass="max-w-2xl" + commandClass="h-[300px]" + > + <CommandInput + placeholder={placeholder || ""} + value={input} + onValueChange={setInput} + /> + <CommandList className="mb-2" ref={commandListRef}> + + <PluginProvider registry={registry}> + {Object.entries(groupedItems).map(([heading, items]) => ( + <CommandGroup key={heading} heading={heading}> + {items.map(item => ( + <CommandItem + key={item.id} + value={item.value} + onSelect={() => { + handleSelect(item) + }} + className="block" + > + {!!item.label ? item.label : <RenderPluginComponents data={item.components} />} + </CommandItem> + ))} + </CommandGroup> + ))} + </PluginProvider> + + </CommandList> + </CommandDialog> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/command/plugin-command-palettes.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/command/plugin-command-palettes.tsx new file mode 100644 index 0000000..b7bc3d2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/command/plugin-command-palettes.tsx @@ -0,0 +1,63 @@ +import { useAtom } from "jotai/react" +import { atom } from "jotai/vanilla" +import React from "react" +import { usePluginListenCommandPaletteInfoEvent, usePluginSendListCommandPalettesEvent } from "../generated/plugin-events" +import { PluginCommandPalette, PluginCommandPaletteInfo } from "./plugin-command-palette" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { WSEvents } from "@/lib/server/ws-events" + + +export const __plugin_commandPalettesAtom = atom<PluginCommandPaletteInfo[]>([]) + +export function PluginCommandPalettes() { + const [commandPalettes, setCommandPalettes] = useAtom(__plugin_commandPalettesAtom) + + /** + * 1. Send a request to the server to list all command palettes + * 2. Receive the command palettes from the server + * 3. Set the command palettes in the state to display them + */ + const { sendListCommandPalettesEvent } = usePluginSendListCommandPalettesEvent() + + React.useEffect(() => { + // Send a request to all plugins to list their command palettes. + // Only plugins with a registered command palette will respond. + sendListCommandPalettesEvent({}, "") + }, []) + + /** + * TODO: Listen to other events from Extension Repository to refetch command palettes + * - When an extension is loaded + * - When an extension is unloaded + * - When an extension is updated + */ + + usePluginListenCommandPaletteInfoEvent((data, extensionId) => { + if (data.keyboardShortcut === "q" || data.keyboardShortcut === "meta+j") return + + setCommandPalettes(prev => { + const oldCommandPalettes = prev.filter(palette => palette.extensionId !== extensionId) + return [...oldCommandPalettes, { + extensionId, + ...data, + }].sort((a, b) => a.extensionId.localeCompare(b.extensionId, undefined, { numeric: true })) + }) + }, "") + + useWebsocketMessageListener({ + type: WSEvents.PLUGIN_UNLOADED, + onMessage: (extensionId) => { + setCommandPalettes(prev => prev.filter(palette => palette.extensionId !== extensionId)) + } + }) + + if (!commandPalettes) return null + + return ( + <> + {commandPalettes.map((palette, index) => ( + <PluginCommandPalette extensionId={palette.extensionId} info={palette} key={index} /> + ))} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/components/registry-components.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/components/registry-components.tsx new file mode 100644 index 0000000..347c0f1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/components/registry-components.tsx @@ -0,0 +1,808 @@ +import { RenderPluginComponents } from "@/app/(main)/_features/plugin/components/registry" +import { useWebsocketSender } from "@/app/(main)/_hooks/handle-websockets" +import { Button, ButtonProps } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { cn } from "@/components/ui/core/styling" +import { DatePicker } from "@/components/ui/date-picker" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { RadioGroup } from "@/components/ui/radio-group" +import { Select } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { TextInput } from "@/components/ui/text-input" +import { Textarea } from "@/components/ui/textarea" +import { useDebounce } from "@/hooks/use-debounce" +import React, { useEffect } from "react" +import { Controller, useForm } from "react-hook-form" +import * as z from "zod" +import { + usePluginListenFieldRefSetValueEvent, + usePluginListenFormResetEvent, + usePluginListenFormSetValuesEvent, + usePluginSendEventHandlerTriggeredEvent, + usePluginSendFieldRefSendValueEvent, + usePluginSendFormSubmittedEvent, +} from "../generated/plugin-events" +import { usePluginTray } from "../tray/plugin-tray" + +type FieldRef<T> = { + current: T + __ID: string +} + + +/////////////////// + + +interface PluginButtonProps { + label?: string + style?: React.CSSProperties + intent?: ButtonProps["intent"] + onClick?: string + disabled?: boolean + loading?: boolean + size?: "xs" | "sm" | "md" | "lg" + className?: string +} + +export function PluginButton(props: PluginButtonProps) { + const { sendEventHandlerTriggeredEvent } = usePluginSendEventHandlerTriggeredEvent() + const { trayIcon } = usePluginTray() + + function handleClick() { + if (props.onClick) { + sendEventHandlerTriggeredEvent({ + handlerName: props.onClick, + event: {}, + }, trayIcon.extensionId) + } + } + + return ( + <Button + intent={props.intent || "white-subtle"} + style={props.style} + onClick={handleClick} + disabled={props.disabled} + loading={props.loading} + size={props.size || "sm"} + className={props.className} + > + {props.label || "Button"} + </Button> + ) +} + +/////////////////// + +interface PluginAnchorProps { + text?: string + href?: string + target?: string + onClick?: string + style?: React.CSSProperties + className?: string +} + +export function PluginAnchor(props: PluginAnchorProps) { + const { sendEventHandlerTriggeredEvent } = usePluginSendEventHandlerTriggeredEvent() + const { trayIcon } = usePluginTray() + + function handleClick(e: React.MouseEvent) { + if (props.onClick) { + e.preventDefault() + sendEventHandlerTriggeredEvent({ + handlerName: props.onClick, + event: { + href: props.href, + text: props.text, + }, + }, trayIcon.extensionId) + } + } + + return ( + <a + href={props.href} + target={props.target || "_blank"} + rel="noopener noreferrer" + style={props.style} + onClick={handleClick} + className={cn("underline cursor-pointer", props.className)} + > + {props.text || "Link"} + </a> + ) +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Fields +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +interface InputProps { + placeholder?: string + label?: string + id?: string + style?: React.CSSProperties + value?: string + onChange?: string + onSelect?: string + fieldRef?: FieldRef<string> + disabled?: boolean + size?: "sm" | "md" | "lg" + className?: string + textarea?: boolean +} + +export function PluginInput(props: InputProps) { + const { trayIcon } = usePluginTray() + const { sendEventHandlerTriggeredEvent } = usePluginSendEventHandlerTriggeredEvent() + const { sendFieldRefSendValueEvent } = usePluginSendFieldRefSendValueEvent() + const [value, setValue] = React.useState(props.value || props.fieldRef?.current) + const debouncedValue = useDebounce(value, 200) + + const inputRef = React.useRef<HTMLInputElement>(null) + const textareaRef = React.useRef<HTMLTextAreaElement>(null) + + const firstRender = React.useRef(true) + useEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + if (props.onChange) { + sendEventHandlerTriggeredEvent({ + handlerName: props.onChange, + event: { + value: debouncedValue, + }, + }, trayIcon.extensionId) + } + if (props.fieldRef) { + sendFieldRefSendValueEvent({ + fieldRef: props.fieldRef.__ID, + value: debouncedValue, + }, trayIcon.extensionId) + } + }, [debouncedValue]) + + usePluginListenFieldRefSetValueEvent((data) => { + if (data.fieldRef === props.fieldRef?.__ID) { + setValue(data.value) + } + }, trayIcon.extensionId) + + const [selectedText, setSelectedText] = React.useState<{ value: string, cursorStart: number, cursorEnd: number } | null>(null) + const debouncedSelectedText = useDebounce(selectedText, 400) + + function handleTextSelected(e: any) { + if (props.onSelect) { + const cursorStart = props.textarea ? textareaRef.current?.selectionStart : inputRef.current?.selectionStart + const cursorEnd = props.textarea ? textareaRef.current?.selectionEnd : inputRef.current?.selectionEnd + const selectedText = props.textarea ? textareaRef.current?.value.slice(cursorStart ?? 0, cursorEnd ?? 0) : inputRef.current?.value.slice( + cursorStart ?? 0, + cursorEnd ?? 0) + + setSelectedText({ value: selectedText ?? "", cursorStart: cursorStart ?? 0, cursorEnd: cursorEnd ?? 0 }) + } + } + + useEffect(() => { + if (props.onSelect && debouncedSelectedText) { + sendEventHandlerTriggeredEvent({ + handlerName: props.onSelect, + event: { + value: debouncedSelectedText.value, + cursorStart: debouncedSelectedText.cursorStart, + cursorEnd: debouncedSelectedText.cursorEnd, + }, + }, trayIcon.extensionId) + } + }, [debouncedSelectedText?.value, debouncedSelectedText?.cursorStart, debouncedSelectedText?.cursorEnd]) + + if (props.textarea) { + return ( + <Textarea + id={props.id} + label={props.label} + placeholder={props.placeholder} + style={props.style} + value={value} + onValueChange={(value) => setValue(value)} + onSelect={handleTextSelected} + disabled={props.disabled} + fieldClass={props.className} + ref={textareaRef} + /> + ) + } + + return ( + <TextInput + id={props.id} + label={props.label} + placeholder={props.placeholder} + style={props.style} + value={value} + onValueChange={(value) => setValue(value)} + onSelect={handleTextSelected} + disabled={props.disabled} + size={props.size || "md"} + fieldClass={props.className} + ref={inputRef} + /> + ) +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +interface SelectProps { + options: Array<{ + label: string + value: string + }> + id?: string + label?: string + onChange?: string + fieldRef?: FieldRef<string> + style?: React.CSSProperties + value?: string + disabled?: boolean + size?: "sm" | "md" | "lg" + className?: string +} + +export function PluginSelect(props: SelectProps) { + const { trayIcon } = usePluginTray() + const { sendEventHandlerTriggeredEvent } = usePluginSendEventHandlerTriggeredEvent() + const { sendFieldRefSendValueEvent } = usePluginSendFieldRefSendValueEvent() + const [value, setValue] = React.useState(props.value || props.fieldRef?.current) + const debouncedValue = useDebounce(value, 200) + + const firstRender = React.useRef(true) + useEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + if (props.onChange) { + sendEventHandlerTriggeredEvent({ + handlerName: props.onChange, + event: { value: debouncedValue }, + }, trayIcon.extensionId) + } + if (props.fieldRef) { + sendFieldRefSendValueEvent({ + fieldRef: props.fieldRef.__ID, + value: debouncedValue, + }, trayIcon.extensionId) + } + }, [debouncedValue]) + + usePluginListenFieldRefSetValueEvent((data) => { + if (data.fieldRef === props.fieldRef?.__ID) { + setValue(data.value) + } + }, trayIcon.extensionId) + + return ( + <Select + id={props.id} + label={props.label} + style={props.style} + options={props.options} + value={value} + onValueChange={(value) => setValue(value)} + disabled={props.disabled} + size={props.size || "md"} + fieldClass={props.className} + /> + ) +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +interface CheckboxProps { + label?: string + id?: string + style?: React.CSSProperties + value?: boolean + onChange?: string + fieldRef?: FieldRef<boolean> + disabled?: boolean + size?: "sm" | "md" | "lg" + className?: string +} + +export function PluginCheckbox(props: CheckboxProps) { + const { trayIcon } = usePluginTray() + const { sendEventHandlerTriggeredEvent } = usePluginSendEventHandlerTriggeredEvent() + const { sendFieldRefSendValueEvent } = usePluginSendFieldRefSendValueEvent() + const [value, setValue] = React.useState(props.value || props.fieldRef?.current) + const debouncedValue = useDebounce(value, 200) + + const firstRender = React.useRef(true) + useEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + if (props.onChange) { + sendEventHandlerTriggeredEvent({ + handlerName: props.onChange, + event: { value: value }, + }, trayIcon.extensionId) + } + if (props.fieldRef) { + sendFieldRefSendValueEvent({ + fieldRef: props.fieldRef.__ID, + value: value, + }, trayIcon.extensionId) + } + }, [debouncedValue]) + + usePluginListenFieldRefSetValueEvent((data) => { + if (data.fieldRef === props.fieldRef?.__ID) { + setValue(data.value) + } + }, trayIcon.extensionId) + + return ( + <Checkbox + id={props.id} + label={props.label} + style={props.style} + value={value} + onValueChange={(value) => typeof value === "boolean" && setValue(value)} + disabled={props.disabled} + size={props.size || "md"} + fieldClass={props.className} + /> + ) +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +interface SwitchProps { + label?: string + id?: string + style?: React.CSSProperties + value?: boolean + onChange?: string + fieldRef?: FieldRef<boolean> + disabled?: boolean + size?: "sm" | "md" | "lg" + side?: "left" | "right" + className?: string +} + +export function PluginSwitch(props: SwitchProps) { + const { trayIcon } = usePluginTray() + const { sendEventHandlerTriggeredEvent } = usePluginSendEventHandlerTriggeredEvent() + const { sendFieldRefSendValueEvent } = usePluginSendFieldRefSendValueEvent() + const [value, setValue] = React.useState(props.value || props.fieldRef?.current) + const debouncedValue = useDebounce(value, 200) + + const firstRender = React.useRef(true) + useEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + if (props.onChange) { + sendEventHandlerTriggeredEvent({ + handlerName: props.onChange, + event: { value: value }, + }, trayIcon.extensionId) + } + if (props.fieldRef) { + sendFieldRefSendValueEvent({ + fieldRef: props.fieldRef.__ID, + value: value, + }, trayIcon.extensionId) + } + }, [debouncedValue]) + + usePluginListenFieldRefSetValueEvent((data) => { + if (data.fieldRef === props.fieldRef?.__ID) { + setValue(data.value) + } + }, trayIcon.extensionId) + + return ( + <Switch + side={props.side || "right"} + id={props.id} + label={props.label} + style={props.style} + value={value} + onValueChange={(value) => typeof value === "boolean" && setValue(value)} + disabled={props.disabled} + size={props.size || "sm"} + fieldClass={props.className} + /> + ) +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +interface RadioGroupProps { + options: Array<{ + label: string + value: string + }> + id?: string + label?: string + onChange?: string + fieldRef?: FieldRef<string> + style?: React.CSSProperties + value?: string + disabled?: boolean + size?: "sm" | "md" | "lg" + className?: string +} + +export function PluginRadioGroup(props: RadioGroupProps) { + const { trayIcon } = usePluginTray() + const { sendEventHandlerTriggeredEvent } = usePluginSendEventHandlerTriggeredEvent() + const { sendFieldRefSendValueEvent } = usePluginSendFieldRefSendValueEvent() + const [value, setValue] = React.useState(props.value || props.fieldRef?.current) + const debouncedValue = useDebounce(value, 200) + + const firstRender = React.useRef(true) + useEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + if (props.onChange) { + sendEventHandlerTriggeredEvent({ + handlerName: props.onChange, + event: { value: value }, + }, trayIcon.extensionId) + } + if (props.fieldRef) { + sendFieldRefSendValueEvent({ + fieldRef: props.fieldRef.__ID, + value: value, + }, trayIcon.extensionId) + } + }, [debouncedValue]) + + usePluginListenFieldRefSetValueEvent((data) => { + if (data.fieldRef === props.fieldRef?.__ID) { + setValue(data.value) + } + }, trayIcon.extensionId) + + return ( + <RadioGroup + id={props.id} + label={props.label} + options={props.options} + value={value} + onValueChange={(value) => setValue(value)} + disabled={props.disabled} + size={props.size || "md"} + fieldClass={props.className} + /> + ) +} + + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +interface FlexProps { + items?: any[] + direction?: "row" | "column" + gap?: number + style?: React.CSSProperties + className?: string +} + +export function PluginFlex({ items = [], direction = "row", gap = 2, style, className }: FlexProps) { + return ( + <div + className={cn("flex", className)} + style={{ + ...(style || {}), + gap: `${gap * 0.25}rem`, + flexDirection: direction, + }} + > + {items && items.length > 0 && <RenderPluginComponents data={items} />} + </div> + ) +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +interface StackProps { + items?: any[] + style?: React.CSSProperties, + gap?: number + className?: string +} + +export function PluginStack({ items = [], style, gap = 2, className }: StackProps) { + return ( + <div + className={cn("flex", className)} + style={{ + ...(style || {}), + gap: `${gap * 0.25}rem`, + flexDirection: "column", + }} + > + {items && items.length > 0 && <RenderPluginComponents data={items} />} + </div> + ) +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +interface DivProps { + items?: any[] + style?: React.CSSProperties + className?: string +} + +export function PluginDiv({ items = [], style, className }: DivProps) { + return ( + <div + className={cn("relative", className)} + style={style} + > + {items && items.length > 0 && <RenderPluginComponents data={items} />} + </div> + ) +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +interface TextProps { + text: string + style?: React.CSSProperties + className?: string +} + +export function PluginText({ text, style, className }: TextProps) { + return <p className={cn("w-full break-all", className)} style={style}>{text}</p> +} + +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +interface FormProps { + name: string + fields: Array<{ + id: string + type: string + name: string + label: string + placeholder?: string + value?: any + options?: Array<{ + label: string + value: any + }> + }> +} + +export function PluginForm({ name, fields: _fields }: FormProps) { + const { sendPluginMessage } = useWebsocketSender() + + const [fields, setFields] = React.useState(_fields) + + // Create a dynamic schema based on the fields + const schema = z.object( + fields.reduce((acc, field) => { + if (!field.name) return acc // Skip fields without names + + switch (field.type) { + case "input": + acc[field.name] = z.string().optional() + break + case "number": + acc[field.name] = z.number().optional() + break + case "select": + acc[field.name] = z.string().optional() + break + case "checkbox": + acc[field.name] = z.boolean().optional() + break + case "radio": + acc[field.name] = z.string().optional() + break + case "date": + acc[field.name] = z.date().optional() + break + } + return acc + }, {} as { [key: string]: any }), + ) + + type FormData = z.infer<typeof schema> + + const form = useForm<FormData>({ + // resolver: zodResolver(schema), + defaultValues: fields.reduce((acc, field) => { + if (!field.name) return acc // Skip fields without names + acc[field.name] = field.value ?? "" + return acc + }, {} as { [key: string]: any }), + }) + + const { trayIcon } = usePluginTray() + + const { sendFormSubmittedEvent } = usePluginSendFormSubmittedEvent() + + const onSubmit = (data: FormData) => { + sendFormSubmittedEvent({ + formName: name, + data: data, + }, trayIcon.extensionId) + } + + usePluginListenFormResetEvent((data) => { + if (data.formName === name) { + if (!!data.fieldToReset) { + form.resetField(data.fieldToReset) + } else { + form.reset() + setFields([]) + setTimeout(() => { + setFields(_fields) + }, 250) + } + } + }, trayIcon.extensionId) + + usePluginListenFormSetValuesEvent((data) => { + if (data.formName === name) { + for (const [key, value] of Object.entries(data.data)) { + form.setValue(key, value) + } + } + }, trayIcon.extensionId) + + + return ( + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {!fields?.length ? <LoadingSpinner /> : + fields.map((field) => { + if (!field.name && field.type !== "submit") return null // Skip fields without names + + switch (field.type) { + case "input": + return ( + <TextInput + key={field.id} + label={field.label} + placeholder={field.placeholder} + {...form.register(field.name)} + // value={form.watch(field.name)} + // onValueChange={(value) => form.setValue(field.name, value)} + /> + ) + case "number": + return ( + <TextInput + key={field.id} + type="number" + label={field.label} + placeholder={field.placeholder} + {...form.register(field.name)} + // value={form.watch(field.name)} + // onValueChange={(value) => form.setValue(field.name, Number(value))} + /> + ) + case "select": + return ( + <Controller + key={field.id} + control={form.control} + name={field.name} + defaultValue={field.value ?? ""} + render={({ field: fField }) => ( + <Select + key={field.id} + label={field.label} + name={field.name} + options={field.options?.map(opt => ({ + label: opt.label, + value: String(opt.value), + })) ?? []} + placeholder={field.placeholder} + value={fField.value} + onValueChange={(value) => fField.onChange(value)} + /> + )} + /> + ) + case "checkbox": + return ( + <Controller + key={field.id} + control={form.control} + name={field.name} + defaultValue={field.value ?? false} + render={({ field: fField }) => ( + <Checkbox + key={field.id} + label={field.label} + value={fField.value} + onValueChange={(value) => fField.onChange(value)} + /> + )} + /> + ) + case "switch": + return ( + <Controller + key={field.id} + control={form.control} + name={field.name} + defaultValue={field.value ?? false} + render={({ field: fField }) => ( + <Switch + key={field.id} + label={field.label} + value={fField.value} + onValueChange={(value) => fField.onChange(value)} + /> + )} + /> + ) + case "radio": + return ( + <Controller + key={field.id} + control={form.control} + name={field.name} + defaultValue={field.value ?? ""} + render={({ field: fField }) => ( + <RadioGroup + key={field.id} + label={field.label} + name={field.name} + options={field.options?.map(opt => ({ + label: opt.label, + value: String(opt.value), + })) ?? []} + value={fField.value} + onValueChange={(value) => fField.onChange(value)} + /> + )} + /> + ) + case "date": + return ( + <Controller + key={field.id} + control={form.control} + name={field.name} + defaultValue={field.value ?? ""} + render={({ field: fField }) => ( + <DatePicker + key={field.id} + name={field.name} + label={field.label} + value={fField.value} + onValueChange={(date) => fField.onChange(date)} + /> + )} + /> + ) + case "submit": + return ( + <Button key={field.id} type="submit"> + {field.label} + </Button> + ) + default: + return null + } + })} + </form> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/components/registry.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/components/registry.tsx new file mode 100644 index 0000000..edd16c9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/components/registry.tsx @@ -0,0 +1,136 @@ +"use client" + +import { + PluginAnchor, + PluginButton, + PluginCheckbox, + PluginDiv, + PluginFlex, + PluginForm, + PluginInput, + PluginRadioGroup, + PluginSelect, + PluginStack, + PluginSwitch, + PluginText, +} from "@/app/(main)/_features/plugin/components/registry-components" +import type React from "react" +import { createContext, useContext } from "react" +import { ErrorBoundary } from "react-error-boundary" + +// Create and initialize the registry +export const registry: ComponentRegistry = new Map([ + ["flex", PluginFlex], + ["text", PluginText], + ["div", PluginDiv], + ["stack", PluginStack], + ["input", PluginInput], + ["button", PluginButton], + ["anchor", PluginAnchor], + ["form", PluginForm], + ["switch", PluginSwitch], + ["radio-group", PluginRadioGroup], + ["checkbox", PluginCheckbox], + ["select", PluginSelect], +] as any) + + +//////////////////////////// + +interface RenderPluginComponentsProps { + data: PluginComponent | PluginComponent[] + fallback?: (props: any) => React.ReactNode +} + +// Fallback component when type is not found +function DefaultFallback({ type }: { type: string }) { + return <div className="p-4 text-muted-foreground">Component type "{type}" not found</div> +} + +// Error fallback +function ErrorFallbackComponent({ error }: { error: Error }) { + return ( + <div className="p-4 text-destructive" role="alert"> + <p>Something went wrong:</p> + <pre className="mt-2 text-sm">{error.message}</pre> + </div> + ) +} + +export function RenderPluginComponents({ data, fallback: Fallback = DefaultFallback }: RenderPluginComponentsProps) { + const registry = usePluginRegistry() + + // Handle array of components + if (Array.isArray(data)) { + return ( + <> + {data.map((component) => ( + <RenderPluginComponents key={component.key || component.id} data={component} fallback={Fallback} /> + ))} + </> + ) + } + + // Get component from registry + const Component = registry.get(data.type) + + if (!Component) { + return <Fallback type={data.type} /> + } + + // Render component with error boundary + return ( + <ErrorBoundary FallbackComponent={ErrorFallbackComponent}> + <Component key={data.id} {...data.props} /> + </ErrorBoundary> + ) +} + + +// Type for component props +export type BaseComponentProps = Record<string, any> + +// Type for component definition +export interface PluginComponent<T extends BaseComponentProps = BaseComponentProps> { + type: string + props: T + id: string + key?: string +} + +// Type for nested components +export interface PluginComponentWithChildren<T extends BaseComponentProps = BaseComponentProps> + extends PluginComponent<T> { + props: T & { + items?: PluginComponent[] + } +} + +// Type for component renderer function +export type ComponentRenderer<T extends BaseComponentProps = BaseComponentProps> = React.ComponentType<T> + +// Registry type +export type ComponentRegistry = Map<string, ComponentRenderer> + +// Create context for the registry +const PluginContext = createContext<ComponentRegistry | null>(null) + +export function usePluginRegistry() { + const context = useContext(PluginContext) + if (!context) { + throw new Error("usePluginRegistry must be used within a PluginProvider") + } + return context +} + +// Provider component +export function PluginProvider({ + children, + registry, +}: { + children: React.ReactNode + registry: ComponentRegistry +}) { + return <PluginContext.Provider value={registry}>{children}</PluginContext.Provider> +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/dom-manager.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/dom-manager.ts new file mode 100644 index 0000000..f2c530b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/dom-manager.ts @@ -0,0 +1,882 @@ +import { useWebsocketSender } from "@/app/(main)/_hooks/handle-websockets" +import { logger } from "@/lib/helpers/debug" +import { useEffect, useRef } from "react" +import { PluginDOMElement } from "./generated/plugin-dom-types" +import { + Plugin_Server_DOMCreateEventPayload, + Plugin_Server_DOMManipulateEventPayload, + Plugin_Server_DOMObserveEventPayload, + Plugin_Server_DOMObserveInViewEventPayload, + Plugin_Server_DOMQueryEventPayload, + Plugin_Server_DOMQueryOneEventPayload, + Plugin_Server_DOMStopObserveEventPayload, + PluginClientEvents, +} from "./generated/plugin-events" + +// ID generation based on a counter for shorter, more consistent IDs +let globalIdCounter = 0 +const ELEMENT_ID_PREFIX = "pe" + +function generateElementId(extensionId: string): string { + const counter = (globalIdCounter++).toString(36) // Convert to base-36 for shorter strings + return `${ELEMENT_ID_PREFIX}-${extensionId.substring(0, 4)}-${counter}` +} + +type ElementToDOMElementOptions = { + withInnerHTML?: boolean + withOuterHTML?: boolean + identifyChildren?: boolean +} + +/** + * DOM Manager for plugins + * Handles DOM manipulation requests from plugins + */ +export function useDOMManager(extensionId: string) { + const { sendPluginMessage } = useWebsocketSender() + + // Store initial element IDs to ensure persistence across rerenders + const elementIdsMapRef = useRef<Map<Element, string>>(new Map()) + const elementObserversRef = useRef<Map<string, { + selector: string; + withInnerHTML?: boolean; + withOuterHTML?: boolean; + identifyChildren?: boolean; + callback: (elements: Element[]) => void + }>>(new Map()) + const observedElementsRef = useRef<Map<string, Set<string>>>(new Map()) // Track observed elements by observerId + const eventListenersRef = useRef<Map<string, { elementId: string; eventType: string; callback: (event: Event) => void }>>(new Map()) + const mutationObserverRef = useRef<MutationObserver | null>(null) + const disposedRef = useRef<boolean>(false) + const domReadySentRef = useRef<boolean>(false) + // Track only elements created by this plugin + const createdElementsRef = useRef<Set<string>>(new Set()) + const intersectionObserversRef = useRef<Map<string, IntersectionObserver>>(new Map()) + + // Ensure element has a persistent ID + const ensureElementId = (element: Element): string => { + // If we already assigned an ID to this element, reuse it + if (elementIdsMapRef.current.has(element)) { + return elementIdsMapRef.current.get(element)! + } + + // If element already has an ID, use it + if (element.id) { + // Store the existing ID in our map + elementIdsMapRef.current.set(element, element.id) + return element.id + } + + // Generate and assign a new ID + const newId = generateElementId(extensionId) + element.id = newId + elementIdsMapRef.current.set(element, newId) + return newId + } + + const safeSendPluginMessage = (type: string, payload: any) => { + if (disposedRef.current) return // Prevent sending messages if disposed + sendPluginMessage(type, payload, extensionId) + } + + // Send DOM ready event when document is loaded + const sendDOMReadyEvent = () => { + if (disposedRef.current || domReadySentRef.current) return + domReadySentRef.current = true + safeSendPluginMessage(PluginClientEvents.DOMReady, {}) + } + + // Convert a DOM element to a serializable object + const elementToDOMElement = (element: Element, options?: ElementToDOMElementOptions): PluginDOMElement => { + const attributes: Record<string, string> = {} + + // Get all attributes + for (let i = 0; i < element.attributes.length; i++) { + const attr = element.attributes[i] + attributes[attr.name] = attr.value + } + + // Ensure the element has an ID + const id = ensureElementId(element) + attributes.id = id + + // Add dataset as attributes with data- prefix + if (element instanceof HTMLElement) { + for (const key in element.dataset) { + if (Object.prototype.hasOwnProperty.call(element.dataset, key)) { + attributes[`data-${key}`] = element.dataset[key] || "" + } + } + } + + // If identifyChildren is true, assign IDs to all children recursively + if (options?.identifyChildren) { + // Get all descendants (not just direct children) + element.querySelectorAll("*").forEach(child => { + if (!child.id) { + ensureElementId(child) + } + }) + } + + return { + id: attributes.id, + tagName: element.tagName.toLowerCase(), + attributes, + // textContent: element.textContent || undefined, + innerHTML: options?.withInnerHTML ? element.innerHTML : undefined, + outerHTML: options?.withOuterHTML ? element.outerHTML : undefined, + children: [], + // children: Array.from(element.children).map(child => elementToDOMElement(child)), + } + } + + // Convert an event to a serializable object + const eventToObject = (event: Event): Record<string, any> => { + const result: Record<string, any> = { + type: event.type, + bubbles: event.bubbles, + cancelable: event.cancelable, + composed: event.composed, + timeStamp: event.timeStamp, + } + + // Add properties from MouseEvent + if (event instanceof MouseEvent) { + result.clientX = event.clientX + result.clientY = event.clientY + result.screenX = event.screenX + result.screenY = event.screenY + result.altKey = event.altKey + result.ctrlKey = event.ctrlKey + result.shiftKey = event.shiftKey + result.metaKey = event.metaKey + result.button = event.button + result.buttons = event.buttons + } + + // Add properties from KeyboardEvent + if (event instanceof KeyboardEvent) { + result.key = event.key + result.code = event.code + result.location = event.location + result.repeat = event.repeat + result.altKey = event.altKey + result.ctrlKey = event.ctrlKey + result.shiftKey = event.shiftKey + result.metaKey = event.metaKey + } + + return result + } + + // Initialize mutation observer to watch for DOM changes + const initMutationObserver = () => { + if (typeof window === "undefined" || typeof MutationObserver === "undefined") return + + mutationObserverRef.current = new MutationObserver((mutations) => { + if (disposedRef.current) return // Skip processing if disposed + + // Process each mutation to find modified elements that match our selectors + const processedElements = new Set<Element>() + + mutations.forEach(mutation => { + // Handle added nodes + if (mutation.type === "childList") { + mutation.addedNodes.forEach(node => { + if (node instanceof Element) { + processedElements.add(node) + // Also check descendant elements + node.querySelectorAll("*").forEach(el => processedElements.add(el)) + } + }) + } + + // Handle modified nodes (attributes or character data) + if (mutation.type === "attributes" || mutation.type === "characterData") { + const target = mutation.target instanceof Element ? + mutation.target : + mutation.target.parentElement + + if (target) processedElements.add(target) + } + }) + + // Check each observer against processed elements + elementObserversRef.current.forEach((observer, observerId) => { + // Track newly matched elements for this observer + const matchedElements: Element[] = [] + const observedSet = observedElementsRef.current.get(observerId) || new Set() + + // Check if any of the processed elements match our selector + processedElements.forEach(element => { + if (element.matches(observer.selector)) { + // Ensure ID if element matches the selector + ensureElementId(element) + matchedElements.push(element) + } + }) + + // Also do a general query to catch any elements that might match but weren't directly modified + document.querySelectorAll(observer.selector).forEach(element => { + const id = ensureElementId(element) + + // If we haven't seen this element before, add it + if (!observedSet.has(id) && !matchedElements.includes(element)) { + matchedElements.push(element) + } + }) + + if (matchedElements.length > 0) { + // Convert to DOM elements + const domElements = matchedElements.map(e => { + return elementToDOMElement(e, { + withInnerHTML: observer.withInnerHTML, + withOuterHTML: observer.withOuterHTML, + identifyChildren: observer.identifyChildren, + }) + }) + + // Update observed set with any new elements + domElements.forEach(elem => observedSet.add(elem.id)) + observedElementsRef.current.set(observerId, observedSet) + + // Call the callback + observer.callback(matchedElements) + + // Send the elements to the plugin + safeSendPluginMessage(PluginClientEvents.DOMObserveResult, { + observerId, + elements: domElements, + }) + } + }) + }) + + // Start observing the document with the configured parameters + mutationObserverRef.current.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }) + } + + // Handler functions + const handleDOMQuery = (payload: Plugin_Server_DOMQueryEventPayload) => { + const { selector, requestId, withInnerHTML, identifyChildren, withOuterHTML } = payload + if (disposedRef.current) return + const elements = document.querySelectorAll(selector) + const domElements = Array.from(elements).map(e => elementToDOMElement(e, { withInnerHTML, identifyChildren, withOuterHTML })) + safeSendPluginMessage(PluginClientEvents.DOMQueryResult, { + requestId, + elements: domElements, + }) + } + + const handleDOMQueryOne = (payload: Plugin_Server_DOMQueryOneEventPayload) => { + const { selector, requestId, withInnerHTML, identifyChildren, withOuterHTML } = payload + if (disposedRef.current) return + const element = document.querySelector(selector) + const domElement = element ? elementToDOMElement(element, { withInnerHTML, identifyChildren, withOuterHTML }) : null + + safeSendPluginMessage(PluginClientEvents.DOMQueryOneResult, { + requestId, + element: domElement, + }) + } + + const handleDOMObserve = (payload: Plugin_Server_DOMObserveEventPayload) => { + const { selector, observerId, withInnerHTML, identifyChildren, withOuterHTML } = payload + if (disposedRef.current) return + + // console.log(`Registering observer ${observerId} for selector ${selector}`) + + // Initialize set to track observed elements for this observer + observedElementsRef.current.set(observerId, new Set()) + + // Store the observer + elementObserversRef.current.set(observerId, { + selector, + withInnerHTML, + withOuterHTML, + identifyChildren, + callback: (elements) => { + // This callback is called when elements matching the selector are found + // console.log(`Observer ${observerId} callback with ${elements.length} elements matching ${selector}`, elements.map(e => e.id)) + }, + }) + + // Immediately check for matching elements + const elements = document.querySelectorAll(selector) + if (elements.length > 0) { + // Ensure each element has an ID and add to matched set + const matchedElements = Array.from(elements).map(element => { + ensureElementId(element) + return element + }) + + // Convert to DOM elements for sending to plugin + const domElements = matchedElements.map(e => elementToDOMElement(e, { withInnerHTML, identifyChildren, withOuterHTML })) + + // Track these elements as observed + const observedSet = observedElementsRef.current.get(observerId)! + domElements.forEach(elem => observedSet.add(elem.id)) + + // Call the callback + elementObserversRef.current.get(observerId)?.callback(matchedElements) + + // Send matched elements to the plugin + safeSendPluginMessage(PluginClientEvents.DOMObserveResult, { + observerId, + elements: domElements, + }) + } + } + + const handleDOMObserveInView = (payload: Plugin_Server_DOMObserveInViewEventPayload) => { + const { selector, observerId, withInnerHTML, identifyChildren, withOuterHTML, margin } = payload + if (disposedRef.current) return + + // Stop any existing observer with the same ID + if (intersectionObserversRef.current.has(observerId)) { + intersectionObserversRef.current.get(observerId)?.disconnect() + intersectionObserversRef.current.delete(observerId) + } + + // Initialize set to track observed elements for this observer + observedElementsRef.current.set(observerId, new Set()) + + // Store the observer configuration + elementObserversRef.current.set(observerId, { + selector, + withInnerHTML, + withOuterHTML, + identifyChildren, + callback: (elements) => { + // This callback is called when elements matching the selector are in view + // console.log(`InView Observer ${observerId} callback with ${elements.length} elements matching ${selector}`, elements.map(e => + // e.id)) + }, + }) + + // First, find all elements matching the selector + const elements = document.querySelectorAll(selector) + + // Create an array to track which elements are in view + const visibleElements: Element[] = [] + + // Create an IntersectionObserver to watch for elements in the viewport + const observer = new IntersectionObserver((entries) => { + // Filter for entries that are intersecting (visible) + const newlyVisibleElements = entries + .filter(entry => entry.isIntersecting) + .map(entry => entry.target) + + if (newlyVisibleElements.length > 0) { + // Convert to DOM elements + const domElements = newlyVisibleElements.map(e => { + return elementToDOMElement(e, { + withInnerHTML, + withOuterHTML, + identifyChildren, + }) + }) + + // Track these elements as observed + const observedSet = observedElementsRef.current.get(observerId) || new Set() + domElements.forEach(elem => observedSet.add(elem.id)) + observedElementsRef.current.set(observerId, observedSet) + + // Call the callback + elementObserversRef.current.get(observerId)?.callback(newlyVisibleElements) + + // Send matched elements to the plugin + safeSendPluginMessage(PluginClientEvents.DOMObserveResult, { + observerId, + elements: domElements, + }) + } + }, { + root: null, // viewport + rootMargin: margin, // margin around the viewport (e.g., "10px" or "10px 20px 30px 40px") + threshold: 0.1, // trigger when at least 10% of the target is visible + }) + + // Store the observer for later cleanup + intersectionObserversRef.current.set(observerId, observer) + + // Start observing all matching elements + if (elements.length > 0) { + elements.forEach(element => { + // Ensure element has an ID + ensureElementId(element) + // Start observing this element + observer.observe(element) + }) + } + } + + const handleDOMStopObserve = (payload: Plugin_Server_DOMStopObserveEventPayload) => { + const { observerId } = payload + elementObserversRef.current.delete(observerId) + observedElementsRef.current.delete(observerId) + } + + const handleDOMCreate = (payload: Plugin_Server_DOMCreateEventPayload) => { + const { tagName, requestId } = payload + if (disposedRef.current) return + const element = document.createElement(tagName) + const elementId = generateElementId(extensionId) + element.id = elementId + + // Store in our map for persistence + elementIdsMapRef.current.set(element, elementId) + + // Track this element as it was created by the plugin + createdElementsRef.current.add(elementId) + + // Add to a hidden container for now + let container = document.getElementById("plugin-dom-container") + if (!container) { + container = document.createElement("div") + container.id = "plugin-dom-container" + container.style.display = "none" + document.body.appendChild(container) + } + + container.appendChild(element) + + safeSendPluginMessage(PluginClientEvents.DOMCreateResult, { + requestId, + element: elementToDOMElement(element), + }) + } + + const handleDOMManipulate = (payload: Plugin_Server_DOMManipulateEventPayload) => { + if (disposedRef.current) return + const { elementId, action, params, requestId } = payload + const element = document.getElementById(elementId) + + if (!element) { + // console.error(`Element with ID ${elementId} not found`) + safeSendPluginMessage(PluginClientEvents.DOMElementUpdated, { + elementId, + action, + result: undefined, + requestId, + }) + return + } + + let result: any = null + + // Utility to safely store original value in data-original attribute + const storeOriginalValue = (el: Element, type: string, key: string, value: any) => { + if (!(el instanceof HTMLElement)) return + + let originalData: Record<string, Record<string, any>> = {} + if (el.dataset.original) { + try { + originalData = JSON.parse(el.dataset.original) as Record<string, Record<string, any>> + } + catch { + originalData = {} + } + } + + // Initialize type record if it doesn't exist + if (!originalData[type]) { + originalData[type] = {} + } + + // Only store the value if it's not already set for this type+key + if (originalData[type][key] === undefined) { + originalData[type][key] = value + el.dataset.original = JSON.stringify(originalData) + } + } + + switch (action) { + case "setAttribute": + // Store previous attribute value + if (element instanceof HTMLElement && params.name) { + const prevValue = element.getAttribute(params.name) + storeOriginalValue(element, "attribute", params.name, prevValue) + } + + element.setAttribute(params.name, params.value) + result = true + break + case "removeAttribute": + // Store previous attribute value + if (element instanceof HTMLElement && params.name) { + const prevValue = element.getAttribute(params.name) + storeOriginalValue(element, "attribute", params.name, prevValue) + } + + element.removeAttribute(params.name) + break + case "setInnerHTML": + // Store previous HTML + // if (element instanceof HTMLElement) { + // storeOriginalValue(element, "html", "innerHTML", element.innerHTML) + // } + + element.innerHTML = params.html + break + case "setOuterHTML": + // Store previous HTML + // if (element instanceof HTMLElement) { + // storeOriginalValue(element, "html", "outerHTML", element.outerHTML) + // } + + element.outerHTML = params.html + case "appendChild": + const child = document.getElementById(params.childId) + if (child) { + element.appendChild(child) + } + break + case "removeChild": + const childToRemove = document.getElementById(params.childId) + if (childToRemove && element.contains(childToRemove)) { + element.removeChild(childToRemove) + } + break + case "getText": + result = element.textContent + break + case "setText": + // Store previous text content + if (element instanceof HTMLElement) { + storeOriginalValue(element, "text", "textContent", element.textContent) + } + + element.textContent = params.text + break + case "getAttribute": + result = element.getAttribute(params.name) + break + case "getAttributes": + result = {} + for (let i = 0; i < element.attributes.length; i++) { + const attr = element.attributes[i] + result[attr.name] = attr.value + } + break + case "hasAttribute": + result = element.hasAttribute(params.name) + break + case "getProperty": + result = (element as any)[params.name] + break + case "setProperty": + // Store previous property value + if (element instanceof HTMLElement && params.name) { + storeOriginalValue(element, "property", params.name, (element as any)[params.name]) + } + + (element as any)[params.name] = params.value + break + case "addClass": + element.classList.add(...(params.classNames as string[])) + break + case "removeClass": + // // Store previous class presence + // if (element instanceof HTMLElement && params.classNames) { + // storeOriginalValue(element, "class", params.className, element.classList.contains(params.className)) + // } + + element.classList.remove(...(params.classNames as string[])) + break + case "hasClass": + result = element.classList.contains(params.className) + break + case "setStyle": + // Store previous style value + if (element instanceof HTMLElement && params.property) { + storeOriginalValue(element, "style", params.property, element.style.getPropertyValue(params.property)) + } + + element.style.setProperty(params.property, params.value) + break + case "setCssText": + // Store previous styles + if (element instanceof HTMLElement && params.cssText) { + storeOriginalValue(element, "style", "cssText", element.style.cssText) + } + + // Set the styles + element.style.cssText = params.cssText + break + case "getStyle": + if (params.property) { + result = element.style.getPropertyValue(params.property) + } else { + result = {} + for (let i = 0; i < element.style.length; i++) { + const prop = element.style[i] + result[prop] = element.style.getPropertyValue(prop) + } + } + break + case "getComputedStyle": + result = window.getComputedStyle(element).getPropertyValue(params.property) + break + case "append": + const childToAppend = document.getElementById(params.childId) + if (childToAppend) { + element.appendChild(childToAppend) + } + break + case "before": + const siblingBefore = document.getElementById(params.siblingId) + if (siblingBefore && element.parentNode) { + element.parentNode.insertBefore(siblingBefore, element) + } + break + case "after": + const siblingAfter = document.getElementById(params.siblingId) + if (siblingAfter && element.parentNode) { + element.parentNode.insertBefore(siblingAfter, element.nextSibling) + } + break + case "remove": + element.remove() + break + case "getParent": + result = element.parentElement ? elementToDOMElement(element.parentElement, { + withInnerHTML: params.withInnerHTML, + withOuterHTML: params.withOuterHTML, + identifyChildren: params.identifyChildren, + }) : null + break + case "getChildren": + result = Array.from(element.children).map(e => elementToDOMElement(e, { + withInnerHTML: params.withInnerHTML, + withOuterHTML: params.withOuterHTML, + identifyChildren: params.identifyChildren, + })) + break + case "query": + // Find elements within the current element using the provided selector + const queryElements = element.querySelectorAll(params.selector) + const queryDomElements = Array.from(queryElements).map(e => elementToDOMElement(e, { + withInnerHTML: params.withInnerHTML, + identifyChildren: params.identifyChildren, + withOuterHTML: params.withOuterHTML, + })) + + // Send the results back using the DOMQueryResult event + safeSendPluginMessage(PluginClientEvents.DOMQueryResult, { + requestId: params.requestId, + elements: queryDomElements, + }) + return // Return early since we're sending separate event + case "queryOne": + // Find a single element within the current element using the provided selector + const queryOneElement = element.querySelector(params.selector) + const _queryOneElements = element.querySelectorAll(params.selector) + const queryOneDomElement = queryOneElement ? elementToDOMElement(queryOneElement, { + withInnerHTML: params.withInnerHTML, + identifyChildren: params.identifyChildren, + withOuterHTML: params.withOuterHTML, + }) : null + + // Send the result back using the DOMQueryOneResult event + safeSendPluginMessage(PluginClientEvents.DOMQueryOneResult, { + requestId: params.requestId, + element: queryOneDomElement, + }) + return // Return early since we're sending separate event + case "addEventListener": + const listenerId = params.listenerId + const eventType = params.event + + // Store the event listener + eventListenersRef.current.set(listenerId, { + elementId, + eventType, + callback: (event) => { + // Convert event to a serializable object + const eventData = eventToObject(event) + + // Send the event to the plugin + safeSendPluginMessage(PluginClientEvents.DOMEventTriggered, { + elementId, + eventType, + event: eventData, + }) + }, + }) + + // Add the event listener + element.addEventListener(eventType, eventListenersRef.current.get(listenerId)!.callback) + break + case "removeEventListener": + const listenerIdToRemove = params.listenerId + const eventTypeToRemove = params.event + + // Get the event listener + const listener = eventListenersRef.current.get(listenerIdToRemove) + if (listener) { + // Remove the event listener + element.removeEventListener(eventTypeToRemove, listener.callback) + // Remove from the map + eventListenersRef.current.delete(listenerIdToRemove) + } + break + case "getDataAttribute": + if (element instanceof HTMLElement) { + result = element.dataset[params.key] + } + break + case "getDataAttributes": + if (element instanceof HTMLElement) { + result = { ...element.dataset } + } else { + result = {} + } + break + case "setDataAttribute": + if (element instanceof HTMLElement) { + // Store previous data attribute value + if (params.key) { + storeOriginalValue(element, "dataset", params.key, element.dataset[params.key]) + } + + element.dataset[params.key] = params.value + } + break + case "removeDataAttribute": + if (element instanceof HTMLElement) { + // Store previous data attribute value + if (params.key) { + storeOriginalValue(element, "dataset", params.key, element.dataset[params.key]) + } + + delete element.dataset[params.key] + } + break + case "hasDataAttribute": + if (element instanceof HTMLElement) { + result = params.key in element.dataset + } else { + result = false + } + break + case "hasStyle": + result = element.style.getPropertyValue(params.property) !== "" + break + case "removeStyle": + // Store previous style value + if (element instanceof HTMLElement && params.property) { + storeOriginalValue(element, "style", params.property, element.style.getPropertyValue(params.property)) + } + + element.style.removeProperty(params.property) + break + default: + console.warn(`Unknown DOM action: ${action}`) + } + + // Send the result back to the plugin + safeSendPluginMessage(PluginClientEvents.DOMElementUpdated, { + elementId, + action, + result, + requestId, + }) + } + + const cleanup = () => { + logger("DOMManager").info("Cleaning up DOMManager for extension", extensionId) + // Mark as disposed to prevent further message sending + disposedRef.current = true + domReadySentRef.current = false + + // Stop the mutation observer + if (mutationObserverRef.current) { + mutationObserverRef.current.disconnect() + mutationObserverRef.current = null + } + + // Clean up intersection observers + intersectionObserversRef.current.forEach((observer) => { + observer.disconnect() + }) + intersectionObserversRef.current.clear() + + // Remove all event listeners + eventListenersRef.current.forEach((listener, listenerId) => { + const element = document.getElementById(listener.elementId) + if (element) { + element.removeEventListener(listener.eventType, listener.callback) + } + }) + + // Remove only elements that were created by this plugin + createdElementsRef.current.forEach(elementId => { + const element = document.getElementById(elementId) + if (element) { + // Remove any event listeners attached to this element + const elementListeners = Array.from(eventListenersRef.current.values()) + .filter(l => l.elementId === elementId) + elementListeners.forEach(listener => { + element.removeEventListener(listener.eventType, listener.callback) + }) + // Remove the element itself + element.remove() + } + }) + + // Clear the maps + elementObserversRef.current.clear() + eventListenersRef.current.clear() + observedElementsRef.current.clear() + createdElementsRef.current.clear() + elementIdsMapRef.current.clear() + + // Remove plugin container if it exists and is empty + const container = document.getElementById("plugin-dom-container") + if (container && (!container.hasChildNodes() || container.children.length === 0)) { + container.remove() + } + } + + useEffect(() => { + logger("DOMManager").info("DOMManager hook initialized for extension", extensionId) + + // Send DOM ready event if document is already loaded + if (document.readyState === "complete") { + sendDOMReadyEvent() + } else { + // Otherwise wait for the document to be loaded + window.addEventListener("load", sendDOMReadyEvent) + } + + // Initialize mutation observer + initMutationObserver() + + // Cleanup function + return () => { + cleanup() + // Remove load event listener if added + if (!domReadySentRef.current) { + window.removeEventListener("load", sendDOMReadyEvent) + } + } + }, [extensionId]) + + return { + handleDOMQuery, + handleDOMQueryOne, + handleDOMObserve, + handleDOMObserveInView, + handleDOMStopObserve, + handleDOMCreate, + handleDOMManipulate, + cleanup, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/generated/plugin-dom-types.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/generated/plugin-dom-types.ts new file mode 100644 index 0000000..deb3924 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/generated/plugin-dom-types.ts @@ -0,0 +1,76 @@ +export type PluginDOMElement = { + id: string + tagName: string + attributes: Record<string, string> + children: PluginDOMElement[] + textContent?: string + innerHTML?: string + outerHTML?: string +} + +export type PluginDOMQueryResult = { + elements: PluginDOMElement[] +} + +export type PluginDOMQueryOneResult = { + element?: PluginDOMElement +} + +export type PluginDOMObserveResult = { + observerId: string +} + +export type PluginDOMCreateResult = { + element: PluginDOMElement +} + +export type PluginDOMManipulateOptions = { + elementId: string + requestId: string + action: "setAttribute" + | "removeAttribute" + | "setInnerHTML" + | "setOuterHTML" + | "appendChild" + | "removeChild" + | "getText" + | "setText" + | "getAttribute" + | "getAttributes" + | "addClass" + | "removeClass" + | "hasClass" + | "setStyle" + | "getStyle" + | "hasStyle" + | "removeStyle" + | "getComputedStyle" + | "append" + | "before" + | "after" + | "remove" + | "getParent" + | "getChildren" + | "addEventListener" + | "removeEventListener" + | "getDataAttribute" + | "getDataAttributes" + | "setDataAttribute" + | "removeDataAttribute" + | "hasAttribute" + | "hasDataAttribute" + | "getProperty" + | "setProperty" + | "query" + | "queryOne" + | "setCssText" + | "observeInView" + params: Record<string, any> +} + +export type PluginDOMEventData = { + observerId: string + elementId: string + eventType: string + data: any +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/generated/plugin-events.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/generated/plugin-events.ts new file mode 100644 index 0000000..d1a5422 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/generated/plugin-events.ts @@ -0,0 +1,988 @@ +// This file is auto-generated. Do not edit. +import { useWebsocketPluginMessageListener, useWebsocketSender } from "@/app/(main)/_hooks/handle-websockets" +import { useCallback } from "react" + +export enum PluginClientEvents { + RenderTray = "tray:render", + ListTrayIcons = "tray:list-icons", + TrayOpened = "tray:opened", + TrayClosed = "tray:closed", + TrayClicked = "tray:clicked", + ListCommandPalettes = "command-palette:list", + CommandPaletteOpened = "command-palette:opened", + CommandPaletteClosed = "command-palette:closed", + RenderCommandPalette = "command-palette:render", + CommandPaletteInput = "command-palette:input", + CommandPaletteItemSelected = "command-palette:item-selected", + ActionRenderAnimePageButtons = "action:anime-page-buttons:render", + ActionRenderAnimePageDropdownItems = "action:anime-page-dropdown-items:render", + ActionRenderMangaPageButtons = "action:manga-page-buttons:render", + ActionRenderMediaCardContextMenuItems = "action:media-card-context-menu-items:render", + ActionRenderAnimeLibraryDropdownItems = "action:anime-library-dropdown-items:render", + ActionRenderEpisodeCardContextMenuItems = "action:episode-card-context-menu-items:render", + ActionRenderEpisodeGridItemMenuItems = "action:episode-grid-item-menu-items:render", + ActionClicked = "action:clicked", + FormSubmitted = "form:submitted", + ScreenChanged = "screen:changed", + EventHandlerTriggered = "handler:triggered", + FieldRefSendValue = "field-ref:send-value", + DOMQueryResult = "dom:query-result", + DOMQueryOneResult = "dom:query-one-result", + DOMObserveResult = "dom:observe-result", + DOMStopObserve = "dom:stop-observe", + DOMCreateResult = "dom:create-result", + DOMElementUpdated = "dom:element-updated", + DOMEventTriggered = "dom:event-triggered", + DOMReady = "dom:ready", +} + +export enum PluginServerEvents { + TrayUpdated = "tray:updated", + TrayIcon = "tray:icon", + TrayBadgeUpdated = "tray:badge-updated", + TrayOpen = "tray:open", + TrayClose = "tray:close", + CommandPaletteInfo = "command-palette:info", + CommandPaletteUpdated = "command-palette:updated", + CommandPaletteOpen = "command-palette:open", + CommandPaletteClose = "command-palette:close", + CommandPaletteGetInput = "command-palette:get-input", + CommandPaletteSetInput = "command-palette:set-input", + ActionRenderAnimePageButtons = "action:anime-page-buttons:updated", + ActionRenderAnimePageDropdownItems = "action:anime-page-dropdown-items:updated", + ActionRenderMangaPageButtons = "action:manga-page-buttons:updated", + ActionRenderMediaCardContextMenuItems = "action:media-card-context-menu-items:updated", + ActionRenderEpisodeCardContextMenuItems = "action:episode-card-context-menu-items:updated", + ActionRenderEpisodeGridItemMenuItems = "action:episode-grid-item-menu-items:updated", + ActionRenderAnimeLibraryDropdownItems = "action:anime-library-dropdown-items:updated", + FormReset = "form:reset", + FormSetValues = "form:set-values", + FieldRefSetValue = "field-ref:set-value", + FatalError = "fatal-error", + ScreenNavigateTo = "screen:navigate-to", + ScreenReload = "screen:reload", + ScreenGetCurrent = "screen:get-current", + DOMQuery = "dom:query", + DOMQueryOne = "dom:query-one", + DOMObserve = "dom:observe", + DOMStopObserve = "dom:stop-observe", + DOMCreate = "dom:create", + DOMManipulate = "dom:manipulate", + DOMObserveInView = "dom:observe-in-view", +} + +///////////////////////////////////////////////////////////////////////////////////// +// Client to server +///////////////////////////////////////////////////////////////////////////////////// + +export type Plugin_Client_RenderTrayEventPayload = { +} + +export function usePluginSendRenderTrayEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendRenderTrayEvent = useCallback((payload: Plugin_Client_RenderTrayEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.RenderTray, payload, extensionID) + }, []) + + return { + sendRenderTrayEvent, + } +} + +export type Plugin_Client_ListTrayIconsEventPayload = { +} + +export function usePluginSendListTrayIconsEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendListTrayIconsEvent = useCallback((payload: Plugin_Client_ListTrayIconsEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.ListTrayIcons, payload, extensionID) + }, []) + + return { + sendListTrayIconsEvent, + } +} + +export type Plugin_Client_TrayOpenedEventPayload = { +} + +export function usePluginSendTrayOpenedEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendTrayOpenedEvent = useCallback((payload: Plugin_Client_TrayOpenedEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.TrayOpened, payload, extensionID) + }, []) + + return { + sendTrayOpenedEvent, + } +} + +export type Plugin_Client_TrayClosedEventPayload = { +} + +export function usePluginSendTrayClosedEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendTrayClosedEvent = useCallback((payload: Plugin_Client_TrayClosedEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.TrayClosed, payload, extensionID) + }, []) + + return { + sendTrayClosedEvent, + } +} + +export type Plugin_Client_TrayClickedEventPayload = { +} + +export function usePluginSendTrayClickedEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendTrayClickedEvent = useCallback((payload: Plugin_Client_TrayClickedEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.TrayClicked, payload, extensionID) + }, []) + + return { + sendTrayClickedEvent, + } +} + +export type Plugin_Client_ListCommandPalettesEventPayload = { +} + +export function usePluginSendListCommandPalettesEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendListCommandPalettesEvent = useCallback((payload: Plugin_Client_ListCommandPalettesEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.ListCommandPalettes, payload, extensionID) + }, []) + + return { + sendListCommandPalettesEvent, + } +} + +export type Plugin_Client_CommandPaletteOpenedEventPayload = { +} + +export function usePluginSendCommandPaletteOpenedEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendCommandPaletteOpenedEvent = useCallback((payload: Plugin_Client_CommandPaletteOpenedEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.CommandPaletteOpened, payload, extensionID) + }, []) + + return { + sendCommandPaletteOpenedEvent, + } +} + +export type Plugin_Client_CommandPaletteClosedEventPayload = { +} + +export function usePluginSendCommandPaletteClosedEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendCommandPaletteClosedEvent = useCallback((payload: Plugin_Client_CommandPaletteClosedEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.CommandPaletteClosed, payload, extensionID) + }, []) + + return { + sendCommandPaletteClosedEvent, + } +} + +export type Plugin_Client_RenderCommandPaletteEventPayload = { +} + +export function usePluginSendRenderCommandPaletteEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendRenderCommandPaletteEvent = useCallback((payload: Plugin_Client_RenderCommandPaletteEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.RenderCommandPalette, payload, extensionID) + }, []) + + return { + sendRenderCommandPaletteEvent, + } +} + +export type Plugin_Client_CommandPaletteInputEventPayload = { + value: string +} + +export function usePluginSendCommandPaletteInputEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendCommandPaletteInputEvent = useCallback((payload: Plugin_Client_CommandPaletteInputEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.CommandPaletteInput, payload, extensionID) + }, []) + + return { + sendCommandPaletteInputEvent, + } +} + +export type Plugin_Client_CommandPaletteItemSelectedEventPayload = { + itemId: string +} + +export function usePluginSendCommandPaletteItemSelectedEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendCommandPaletteItemSelectedEvent = useCallback((payload: Plugin_Client_CommandPaletteItemSelectedEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.CommandPaletteItemSelected, payload, extensionID) + }, []) + + return { + sendCommandPaletteItemSelectedEvent, + } +} + +export type Plugin_Client_ActionRenderAnimePageButtonsEventPayload = { +} + +export function usePluginSendActionRenderAnimePageButtonsEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendActionRenderAnimePageButtonsEvent = useCallback((payload: Plugin_Client_ActionRenderAnimePageButtonsEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.ActionRenderAnimePageButtons, payload, extensionID) + }, []) + + return { + sendActionRenderAnimePageButtonsEvent, + } +} + +export type Plugin_Client_ActionRenderAnimePageDropdownItemsEventPayload = { +} + +export function usePluginSendActionRenderAnimePageDropdownItemsEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendActionRenderAnimePageDropdownItemsEvent = useCallback((payload: Plugin_Client_ActionRenderAnimePageDropdownItemsEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.ActionRenderAnimePageDropdownItems, payload, extensionID) + }, []) + + return { + sendActionRenderAnimePageDropdownItemsEvent, + } +} + +export type Plugin_Client_ActionRenderMangaPageButtonsEventPayload = { +} + +export function usePluginSendActionRenderMangaPageButtonsEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendActionRenderMangaPageButtonsEvent = useCallback((payload: Plugin_Client_ActionRenderMangaPageButtonsEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.ActionRenderMangaPageButtons, payload, extensionID) + }, []) + + return { + sendActionRenderMangaPageButtonsEvent, + } +} + +export type Plugin_Client_ActionRenderMediaCardContextMenuItemsEventPayload = { +} + +export function usePluginSendActionRenderMediaCardContextMenuItemsEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendActionRenderMediaCardContextMenuItemsEvent = useCallback((payload: Plugin_Client_ActionRenderMediaCardContextMenuItemsEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.ActionRenderMediaCardContextMenuItems, payload, extensionID) + }, []) + + return { + sendActionRenderMediaCardContextMenuItemsEvent, + } +} + +export type Plugin_Client_ActionRenderAnimeLibraryDropdownItemsEventPayload = { +} + +export function usePluginSendActionRenderAnimeLibraryDropdownItemsEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendActionRenderAnimeLibraryDropdownItemsEvent = useCallback((payload: Plugin_Client_ActionRenderAnimeLibraryDropdownItemsEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.ActionRenderAnimeLibraryDropdownItems, payload, extensionID) + }, []) + + return { + sendActionRenderAnimeLibraryDropdownItemsEvent, + } +} + +export type Plugin_Client_ActionRenderEpisodeCardContextMenuItemsEventPayload = { +} + +export function usePluginSendActionRenderEpisodeCardContextMenuItemsEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendActionRenderEpisodeCardContextMenuItemsEvent = useCallback((payload: Plugin_Client_ActionRenderEpisodeCardContextMenuItemsEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.ActionRenderEpisodeCardContextMenuItems, payload, extensionID) + }, []) + + return { + sendActionRenderEpisodeCardContextMenuItemsEvent, + } +} + +export type Plugin_Client_ActionRenderEpisodeGridItemMenuItemsEventPayload = { +} + +export function usePluginSendActionRenderEpisodeGridItemMenuItemsEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendActionRenderEpisodeGridItemMenuItemsEvent = useCallback((payload: Plugin_Client_ActionRenderEpisodeGridItemMenuItemsEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.ActionRenderEpisodeGridItemMenuItems, payload, extensionID) + }, []) + + return { + sendActionRenderEpisodeGridItemMenuItemsEvent, + } +} + +export type Plugin_Client_ActionClickedEventPayload = { + actionId: string + event: Record<string, any> +} + +export function usePluginSendActionClickedEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendActionClickedEvent = useCallback((payload: Plugin_Client_ActionClickedEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.ActionClicked, payload, extensionID) + }, []) + + return { + sendActionClickedEvent, + } +} + +export type Plugin_Client_FormSubmittedEventPayload = { + formName: string + data: Record<string, any> +} + +export function usePluginSendFormSubmittedEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendFormSubmittedEvent = useCallback((payload: Plugin_Client_FormSubmittedEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.FormSubmitted, payload, extensionID) + }, []) + + return { + sendFormSubmittedEvent, + } +} + +export type Plugin_Client_ScreenChangedEventPayload = { + pathname: string + query: string +} + +export function usePluginSendScreenChangedEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendScreenChangedEvent = useCallback((payload: Plugin_Client_ScreenChangedEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.ScreenChanged, payload, extensionID) + }, []) + + return { + sendScreenChangedEvent, + } +} + +export type Plugin_Client_EventHandlerTriggeredEventPayload = { + handlerName: string + event: Record<string, any> +} + +export function usePluginSendEventHandlerTriggeredEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendEventHandlerTriggeredEvent = useCallback((payload: Plugin_Client_EventHandlerTriggeredEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.EventHandlerTriggered, payload, extensionID) + }, []) + + return { + sendEventHandlerTriggeredEvent, + } +} + +export type Plugin_Client_FieldRefSendValueEventPayload = { + fieldRef: string + value: any +} + +export function usePluginSendFieldRefSendValueEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendFieldRefSendValueEvent = useCallback((payload: Plugin_Client_FieldRefSendValueEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.FieldRefSendValue, payload, extensionID) + }, []) + + return { + sendFieldRefSendValueEvent, + } +} + +export type Plugin_Client_DOMQueryResultEventPayload = { + requestId: string + elements: Array<any> +} + +export function usePluginSendDOMQueryResultEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendDOMQueryResultEvent = useCallback((payload: Plugin_Client_DOMQueryResultEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.DOMQueryResult, payload, extensionID) + }, []) + + return { + sendDOMQueryResultEvent, + } +} + +export type Plugin_Client_DOMQueryOneResultEventPayload = { + requestId: string + element: any +} + +export function usePluginSendDOMQueryOneResultEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendDOMQueryOneResultEvent = useCallback((payload: Plugin_Client_DOMQueryOneResultEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.DOMQueryOneResult, payload, extensionID) + }, []) + + return { + sendDOMQueryOneResultEvent, + } +} + +export type Plugin_Client_DOMObserveResultEventPayload = { + observerId: string + elements: Array<any> +} + +export function usePluginSendDOMObserveResultEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendDOMObserveResultEvent = useCallback((payload: Plugin_Client_DOMObserveResultEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.DOMObserveResult, payload, extensionID) + }, []) + + return { + sendDOMObserveResultEvent, + } +} + +export type Plugin_Client_DOMStopObserveEventPayload = { + observerId: string +} + +export function usePluginSendDOMStopObserveEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendDOMStopObserveEvent = useCallback((payload: Plugin_Client_DOMStopObserveEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.DOMStopObserve, payload, extensionID) + }, []) + + return { + sendDOMStopObserveEvent, + } +} + +export type Plugin_Client_DOMCreateResultEventPayload = { + requestId: string + element: any +} + +export function usePluginSendDOMCreateResultEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendDOMCreateResultEvent = useCallback((payload: Plugin_Client_DOMCreateResultEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.DOMCreateResult, payload, extensionID) + }, []) + + return { + sendDOMCreateResultEvent, + } +} + +export type Plugin_Client_DOMElementUpdatedEventPayload = { + elementId: string + action: string + result: any + requestId: string +} + +export function usePluginSendDOMElementUpdatedEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendDOMElementUpdatedEvent = useCallback((payload: Plugin_Client_DOMElementUpdatedEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.DOMElementUpdated, payload, extensionID) + }, []) + + return { + sendDOMElementUpdatedEvent, + } +} + +export type Plugin_Client_DOMEventTriggeredEventPayload = { + elementId: string + eventType: string + event: Record<string, any> +} + +export function usePluginSendDOMEventTriggeredEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendDOMEventTriggeredEvent = useCallback((payload: Plugin_Client_DOMEventTriggeredEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.DOMEventTriggered, payload, extensionID) + }, []) + + return { + sendDOMEventTriggeredEvent, + } +} + +export type Plugin_Client_DOMReadyEventPayload = { +} + +export function usePluginSendDOMReadyEvent() { + const { sendPluginMessage } = useWebsocketSender() + + const sendDOMReadyEvent = useCallback((payload: Plugin_Client_DOMReadyEventPayload, extensionID?: string) => { + sendPluginMessage(PluginClientEvents.DOMReady, payload, extensionID) + }, []) + + return { + sendDOMReadyEvent, + } +} + +///////////////////////////////////////////////////////////////////////////////////// +// Server to client +///////////////////////////////////////////////////////////////////////////////////// + +export type Plugin_Server_TrayUpdatedEventPayload = { + components: any +} + +export function usePluginListenTrayUpdatedEvent(cb: (payload: Plugin_Server_TrayUpdatedEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_TrayUpdatedEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.TrayUpdated, + onMessage: cb, + }) +} + +export type Plugin_Server_TrayIconEventPayload = { + extensionId: string + extensionName: string + iconUrl: string + withContent: boolean + tooltipText: string + badgeNumber: number + badgeIntent: string + width: string + minHeight: string +} + +export function usePluginListenTrayIconEvent(cb: (payload: Plugin_Server_TrayIconEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_TrayIconEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.TrayIcon, + onMessage: cb, + }) +} + +export type Plugin_Server_TrayBadgeUpdatedEventPayload = { + badgeNumber: number + badgeIntent: string +} + +export function usePluginListenTrayBadgeUpdatedEvent(cb: (payload: Plugin_Server_TrayBadgeUpdatedEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_TrayBadgeUpdatedEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.TrayBadgeUpdated, + onMessage: cb, + }) +} + +export type Plugin_Server_TrayOpenEventPayload = { + extensionId: string +} + +export function usePluginListenTrayOpenEvent(cb: (payload: Plugin_Server_TrayOpenEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_TrayOpenEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.TrayOpen, + onMessage: cb, + }) +} + +export type Plugin_Server_TrayCloseEventPayload = { + extensionId: string +} + +export function usePluginListenTrayCloseEvent(cb: (payload: Plugin_Server_TrayCloseEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_TrayCloseEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.TrayClose, + onMessage: cb, + }) +} + +export type Plugin_Server_CommandPaletteInfoEventPayload = { + placeholder: string + keyboardShortcut: string +} + +export function usePluginListenCommandPaletteInfoEvent(cb: (payload: Plugin_Server_CommandPaletteInfoEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_CommandPaletteInfoEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.CommandPaletteInfo, + onMessage: cb, + }) +} + +export type Plugin_Server_CommandPaletteUpdatedEventPayload = { + placeholder: string + items: any +} + +export function usePluginListenCommandPaletteUpdatedEvent(cb: (payload: Plugin_Server_CommandPaletteUpdatedEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_CommandPaletteUpdatedEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.CommandPaletteUpdated, + onMessage: cb, + }) +} + +export type Plugin_Server_CommandPaletteOpenEventPayload = { +} + +export function usePluginListenCommandPaletteOpenEvent(cb: (payload: Plugin_Server_CommandPaletteOpenEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_CommandPaletteOpenEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.CommandPaletteOpen, + onMessage: cb, + }) +} + +export type Plugin_Server_CommandPaletteCloseEventPayload = { +} + +export function usePluginListenCommandPaletteCloseEvent(cb: (payload: Plugin_Server_CommandPaletteCloseEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_CommandPaletteCloseEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.CommandPaletteClose, + onMessage: cb, + }) +} + +export type Plugin_Server_CommandPaletteGetInputEventPayload = { +} + +export function usePluginListenCommandPaletteGetInputEvent(cb: (payload: Plugin_Server_CommandPaletteGetInputEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_CommandPaletteGetInputEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.CommandPaletteGetInput, + onMessage: cb, + }) +} + +export type Plugin_Server_CommandPaletteSetInputEventPayload = { + value: string +} + +export function usePluginListenCommandPaletteSetInputEvent(cb: (payload: Plugin_Server_CommandPaletteSetInputEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_CommandPaletteSetInputEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.CommandPaletteSetInput, + onMessage: cb, + }) +} + +export type Plugin_Server_ActionRenderAnimePageButtonsEventPayload = { + buttons: any +} + +export function usePluginListenActionRenderAnimePageButtonsEvent(cb: (payload: Plugin_Server_ActionRenderAnimePageButtonsEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_ActionRenderAnimePageButtonsEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.ActionRenderAnimePageButtons, + onMessage: cb, + }) +} + +export type Plugin_Server_ActionRenderAnimePageDropdownItemsEventPayload = { + items: any +} + +export function usePluginListenActionRenderAnimePageDropdownItemsEvent(cb: (payload: Plugin_Server_ActionRenderAnimePageDropdownItemsEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_ActionRenderAnimePageDropdownItemsEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.ActionRenderAnimePageDropdownItems, + onMessage: cb, + }) +} + +export type Plugin_Server_ActionRenderMangaPageButtonsEventPayload = { + buttons: any +} + +export function usePluginListenActionRenderMangaPageButtonsEvent(cb: (payload: Plugin_Server_ActionRenderMangaPageButtonsEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_ActionRenderMangaPageButtonsEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.ActionRenderMangaPageButtons, + onMessage: cb, + }) +} + +export type Plugin_Server_ActionRenderMediaCardContextMenuItemsEventPayload = { + items: any +} + +export function usePluginListenActionRenderMediaCardContextMenuItemsEvent(cb: (payload: Plugin_Server_ActionRenderMediaCardContextMenuItemsEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_ActionRenderMediaCardContextMenuItemsEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.ActionRenderMediaCardContextMenuItems, + onMessage: cb, + }) +} + +export type Plugin_Server_ActionRenderEpisodeCardContextMenuItemsEventPayload = { + items: any +} + +export function usePluginListenActionRenderEpisodeCardContextMenuItemsEvent(cb: (payload: Plugin_Server_ActionRenderEpisodeCardContextMenuItemsEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_ActionRenderEpisodeCardContextMenuItemsEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.ActionRenderEpisodeCardContextMenuItems, + onMessage: cb, + }) +} + +export type Plugin_Server_ActionRenderEpisodeGridItemMenuItemsEventPayload = { + items: any +} + +export function usePluginListenActionRenderEpisodeGridItemMenuItemsEvent(cb: (payload: Plugin_Server_ActionRenderEpisodeGridItemMenuItemsEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_ActionRenderEpisodeGridItemMenuItemsEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.ActionRenderEpisodeGridItemMenuItems, + onMessage: cb, + }) +} + +export type Plugin_Server_ActionRenderAnimeLibraryDropdownItemsEventPayload = { + items: any +} + +export function usePluginListenActionRenderAnimeLibraryDropdownItemsEvent(cb: (payload: Plugin_Server_ActionRenderAnimeLibraryDropdownItemsEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_ActionRenderAnimeLibraryDropdownItemsEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.ActionRenderAnimeLibraryDropdownItems, + onMessage: cb, + }) +} + +export type Plugin_Server_FormResetEventPayload = { + formName: string + fieldToReset: string +} + +export function usePluginListenFormResetEvent(cb: (payload: Plugin_Server_FormResetEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_FormResetEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.FormReset, + onMessage: cb, + }) +} + +export type Plugin_Server_FormSetValuesEventPayload = { + formName: string + data: Record<string, any> +} + +export function usePluginListenFormSetValuesEvent(cb: (payload: Plugin_Server_FormSetValuesEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_FormSetValuesEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.FormSetValues, + onMessage: cb, + }) +} + +export type Plugin_Server_FieldRefSetValueEventPayload = { + fieldRef: string + value: any +} + +export function usePluginListenFieldRefSetValueEvent(cb: (payload: Plugin_Server_FieldRefSetValueEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_FieldRefSetValueEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.FieldRefSetValue, + onMessage: cb, + }) +} + +export type Plugin_Server_FatalErrorEventPayload = { + error: string +} + +export function usePluginListenFatalErrorEvent(cb: (payload: Plugin_Server_FatalErrorEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_FatalErrorEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.FatalError, + onMessage: cb, + }) +} + +export type Plugin_Server_ScreenNavigateToEventPayload = { + path: string +} + +export function usePluginListenScreenNavigateToEvent(cb: (payload: Plugin_Server_ScreenNavigateToEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_ScreenNavigateToEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.ScreenNavigateTo, + onMessage: cb, + }) +} + +export type Plugin_Server_ScreenReloadEventPayload = { +} + +export function usePluginListenScreenReloadEvent(cb: (payload: Plugin_Server_ScreenReloadEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_ScreenReloadEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.ScreenReload, + onMessage: cb, + }) +} + +export type Plugin_Server_ScreenGetCurrentEventPayload = { +} + +export function usePluginListenScreenGetCurrentEvent(cb: (payload: Plugin_Server_ScreenGetCurrentEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_ScreenGetCurrentEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.ScreenGetCurrent, + onMessage: cb, + }) +} + +export type Plugin_Server_DOMQueryEventPayload = { + selector: string + requestId: string + withInnerHTML: boolean + withOuterHTML: boolean + identifyChildren: boolean +} + +export function usePluginListenDOMQueryEvent(cb: (payload: Plugin_Server_DOMQueryEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_DOMQueryEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.DOMQuery, + onMessage: cb, + }) +} + +export type Plugin_Server_DOMQueryOneEventPayload = { + selector: string + requestId: string + withInnerHTML: boolean + withOuterHTML: boolean + identifyChildren: boolean +} + +export function usePluginListenDOMQueryOneEvent(cb: (payload: Plugin_Server_DOMQueryOneEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_DOMQueryOneEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.DOMQueryOne, + onMessage: cb, + }) +} + +export type Plugin_Server_DOMObserveEventPayload = { + selector: string + observerId: string + withInnerHTML: boolean + withOuterHTML: boolean + identifyChildren: boolean +} + +export function usePluginListenDOMObserveEvent(cb: (payload: Plugin_Server_DOMObserveEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_DOMObserveEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.DOMObserve, + onMessage: cb, + }) +} + +export type Plugin_Server_DOMStopObserveEventPayload = { + observerId: string +} + +export function usePluginListenDOMStopObserveEvent(cb: (payload: Plugin_Server_DOMStopObserveEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_DOMStopObserveEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.DOMStopObserve, + onMessage: cb, + }) +} + +export type Plugin_Server_DOMCreateEventPayload = { + tagName: string + requestId: string +} + +export function usePluginListenDOMCreateEvent(cb: (payload: Plugin_Server_DOMCreateEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_DOMCreateEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.DOMCreate, + onMessage: cb, + }) +} + +export type Plugin_Server_DOMManipulateEventPayload = { + elementId: string + action: string + params: Record<string, any> + requestId: string +} + +export function usePluginListenDOMManipulateEvent(cb: (payload: Plugin_Server_DOMManipulateEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_DOMManipulateEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.DOMManipulate, + onMessage: cb, + }) +} + +export type Plugin_Server_DOMObserveInViewEventPayload = { + selector: string + observerId: string + withInnerHTML: boolean + withOuterHTML: boolean + identifyChildren: boolean + margin: string +} + +export function usePluginListenDOMObserveInViewEvent(cb: (payload: Plugin_Server_DOMObserveInViewEventPayload, extensionId: string) => void, extensionID: string) { + return useWebsocketPluginMessageListener<Plugin_Server_DOMObserveInViewEventPayload>({ + extensionId: extensionID, + type: PluginServerEvents.DOMObserveInView, + onMessage: cb, + }) +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/plugin-handler.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/plugin-handler.tsx new file mode 100644 index 0000000..9e16977 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/plugin-handler.tsx @@ -0,0 +1,96 @@ +import { useWebsocketMessageListener, useWebsocketPluginMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { WSEvents } from "@/lib/server/ws-events" +import { useDOMManager } from "./dom-manager" +import { + Plugin_Server_DOMCreateEventPayload, + Plugin_Server_DOMManipulateEventPayload, + Plugin_Server_DOMObserveEventPayload, + Plugin_Server_DOMObserveInViewEventPayload, + Plugin_Server_DOMQueryEventPayload, + Plugin_Server_DOMQueryOneEventPayload, + Plugin_Server_DOMStopObserveEventPayload, + PluginServerEvents, +} from "./generated/plugin-events" + +export function PluginHandler({ extensionId, onUnloaded }: { extensionId: string, onUnloaded: () => void }) { + // DOM Manager + const { + handleDOMQuery, + handleDOMQueryOne, + handleDOMObserve, + handleDOMObserveInView, + handleDOMStopObserve, + handleDOMCreate, + handleDOMManipulate, + cleanup: cleanupDOMManager, + } = useDOMManager(extensionId) + + useWebsocketMessageListener({ + type: WSEvents.PLUGIN_UNLOADED, + onMessage: (_extensionId) => { + if (_extensionId === extensionId) { + cleanupDOMManager() + onUnloaded() + } + }, + }) + + // Listen for DOM events + useWebsocketPluginMessageListener({ + extensionId, + type: PluginServerEvents.DOMQuery, + onMessage: (payload: Plugin_Server_DOMQueryEventPayload) => { + handleDOMQuery(payload) + }, + }) + + useWebsocketPluginMessageListener({ + extensionId, + type: PluginServerEvents.DOMQueryOne, + onMessage: (payload: Plugin_Server_DOMQueryOneEventPayload) => { + handleDOMQueryOne(payload) + }, + }) + + useWebsocketPluginMessageListener({ + extensionId, + type: PluginServerEvents.DOMObserve, + onMessage: (payload: Plugin_Server_DOMObserveEventPayload) => { + handleDOMObserve(payload) + }, + }) + + useWebsocketPluginMessageListener({ + extensionId, + type: PluginServerEvents.DOMObserveInView, + onMessage: (payload: Plugin_Server_DOMObserveInViewEventPayload) => { + handleDOMObserveInView(payload) + }, + }) + + useWebsocketPluginMessageListener({ + extensionId, + type: PluginServerEvents.DOMStopObserve, + onMessage: (payload: Plugin_Server_DOMStopObserveEventPayload) => { + handleDOMStopObserve(payload) + }, + }) + + useWebsocketPluginMessageListener({ + extensionId, + type: PluginServerEvents.DOMCreate, + onMessage: (payload: Plugin_Server_DOMCreateEventPayload) => { + handleDOMCreate(payload) + }, + }) + + useWebsocketPluginMessageListener({ + extensionId, + type: PluginServerEvents.DOMManipulate, + onMessage: (payload: Plugin_Server_DOMManipulateEventPayload) => { + handleDOMManipulate(payload) + }, + }) + + return null +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/plugin-manager.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/plugin-manager.tsx new file mode 100644 index 0000000..f852f28 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/plugin-manager.tsx @@ -0,0 +1,71 @@ +import { useListExtensionData } from "@/api/hooks/extensions.hooks" +import { WSEvents } from "@/lib/server/ws-events" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { startTransition, useEffect, useState } from "react" +import { useWebsocketMessageListener } from "../../_hooks/handle-websockets" +import { PluginCommandPalettes } from "./command/plugin-command-palettes" +import { + usePluginListenScreenGetCurrentEvent, + usePluginListenScreenNavigateToEvent, + usePluginListenScreenReloadEvent, + usePluginSendScreenChangedEvent, +} from "./generated/plugin-events" +import { PluginHandler } from "./plugin-handler" + +export function PluginManager() { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const { sendScreenChangedEvent } = usePluginSendScreenChangedEvent() + + const { data: extensions } = useListExtensionData() + + const [unloadedExtensions, setUnloadedExtensions] = useState<string[]>([]) + + useWebsocketMessageListener({ + type: WSEvents.PLUGIN_LOADED, + onMessage: (extensionId: string) => { + startTransition(() => { + setUnloadedExtensions(prev => prev.filter(id => id !== extensionId)) + }) + }, + }) + + useEffect(() => { + sendScreenChangedEvent({ + pathname: pathname, + query: window.location.search, + }) + }, [pathname, searchParams]) + + usePluginListenScreenGetCurrentEvent((event, extensionId) => { + sendScreenChangedEvent({ + pathname: pathname, + query: window.location.search, + }, extensionId) + }, "") // Listen to all plugins + + usePluginListenScreenNavigateToEvent((event) => { + if ([ + "/entry", "/anilist", "/search", "/manga", + "/settings", "/auto-downloader", "/debrid", "/torrent-list", + "/schedule", "/extensions", "/sync", "/discover", + "/scan-summaries", + + ].some(path => event.path.startsWith(path))) { + router.push(event.path) + } + }, "") // Listen to all plugins + + usePluginListenScreenReloadEvent((event) => { + router.refresh() + }, "") // Listen to all plugins + + return <> + {/* Render plugin handlers for each extension */} + {extensions?.filter(e => e.type === "plugin" && !unloadedExtensions.includes(e.id)).map(extension => ( + <PluginHandler key={extension.id} extensionId={extension.id} onUnloaded={() => setUnloadedExtensions(prev => [...prev, extension.id])} /> + ))} + <PluginCommandPalettes /> + </> +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/tray/plugin-sidebar-tray.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/tray/plugin-sidebar-tray.tsx new file mode 100644 index 0000000..24b55a9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/tray/plugin-sidebar-tray.tsx @@ -0,0 +1,430 @@ +import { Extension_Extension, ExtensionRepo_StoredPluginSettingsData } from "@/api/generated/types" +import { + useGetPluginSettings, + useListDevelopmentModeExtensions, + useReloadExternalExtension, + useSetPluginSettingsPinnedTrays, +} from "@/api/hooks/extensions.hooks" +import { WebSocketContext } from "@/app/(main)/_atoms/websocket.atoms" +import { PluginTray, TrayIcon } from "@/app/(main)/_features/plugin/tray/plugin-tray" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Popover } from "@/components/ui/popover" +import { Tooltip } from "@/components/ui/tooltip" +import { WSEvents } from "@/lib/server/ws-events" +import { useWindowSize } from "@uidotdev/usehooks" +import { useAtom } from "jotai/react" +import { atom } from "jotai/vanilla" +import Image from "next/image" +import { usePathname } from "next/navigation" +import React from "react" +import { LuBlocks, LuBug, LuCircleDashed, LuRefreshCw, LuShapes } from "react-icons/lu" +import { TbPinned, TbPinnedFilled } from "react-icons/tb" +import { + usePluginListenTrayCloseEvent, + usePluginListenTrayIconEvent, + usePluginListenTrayOpenEvent, + usePluginSendListTrayIconsEvent, +} from "../generated/plugin-events" + +export const __plugin_trayIconsAtom = atom<TrayIcon[]>([]) + +export const __plugin_hasNavigatedAtom = atom<boolean>(false) + +export const __plugin_unpinnedTrayIconClickedAtom = atom<TrayIcon | null>(null) + +const ExtensionList = ({ + place, + developmentModeExtensions, + isReloadingExtension, + reloadExternalExtension, + trayIcons, + settings, + width, +}: { + place: "sidebar" | "top"; + developmentModeExtensions: Extension_Extension[]; + isReloadingExtension: boolean; + reloadExternalExtension: (params: { id: string }) => void; + trayIcons: TrayIcon[]; + settings: ExtensionRepo_StoredPluginSettingsData | undefined; + width: number | null; +}) => { + + const { mutate: setPluginSettingsPinnedTrays, isPending: isSettingPluginSettingsPinnedTrays } = useSetPluginSettingsPinnedTrays() + + const pinnedTrayPluginIds = settings?.pinnedTrayPluginIds || [] + + const isPinned = (extensionId: string) => pinnedTrayPluginIds.includes(extensionId) + + const [unpinnedTrayIconClicked, setUnpinnedTrayIconClicked] = useAtom(__plugin_unpinnedTrayIconClickedAtom) + + const [trayIconListOpen, setTrayIconListOpen] = React.useState(false) + + const pinnedTrayIcons = trayIcons.filter(trayIcon => isPinned(trayIcon.extensionId) || trayIcon.extensionId === unpinnedTrayIconClicked?.extensionId) + + usePluginListenTrayOpenEvent((data) => { + if (!data.extensionId) return + + if (!isPinned(data.extensionId)) { + setUnpinnedTrayIconClicked(trayIcons.find(t => t.extensionId === data.extensionId) || null) + } + }, "") + + usePluginListenTrayCloseEvent((data) => { + if (!data.extensionId) return + + if (!isPinned(data.extensionId)) { + setUnpinnedTrayIconClicked(null) + } + }, "") + + return ( + <> + <div + data-plugin-sidebar-tray + className={cn( + "w-10 mx-auto p-1 my-2", + "flex flex-col gap-1 items-center border border-transparent justify-center rounded-full transition-all duration-300 select-none", + place === "top" && "flex-row w-auto my-0 justify-start px-2 py-2 border-none", + pinnedTrayIcons.length > 0 && "border-[--border]", + )} + > + + <Popover + open={trayIconListOpen} + onOpenChange={setTrayIconListOpen} + side={place === "top" ? "bottom" : "right"} + trigger={<div> + <Tooltip + side="right" + trigger={<IconButton + intent="gray-basic" + size="sm" + icon={<LuShapes className="size-5 text-[--muted]" />} + className="rounded-full hover:rotate-360 transition-all duration-300" + />} + >Tray plugins</Tooltip> + </div>} + className="p-2 w-[300px]" + data-plugin-sidebar-debug-popover + modal={false} + > + <div className="space-y-1 max-h-[310px] overflow-y-auto" data-plugin-sidebar-debug-popover-content> + {/* <div className="text-sm"> + <p className="font-bold"> + Plugins + </p> + </div> */} + {trayIcons?.map(trayIcon => ( + <div key={trayIcon.extensionId} className="flex items-center gap-2 justify-between bg-[--subtle] rounded-md px-2 py-1 max-w-full"> + <div + className="flex items-center gap-2 cursor-pointer max-w-full" + onClick={() => { + setUnpinnedTrayIconClicked(trayIcon) + setTrayIconListOpen(false) + }} + > + <div className="w-8 h-8 rounded-full flex items-center justify-center overflow-hidden relative flex-none"> + {trayIcon.iconUrl ? <Image + src={trayIcon.iconUrl} + alt="logo" + fill + className="p-1 w-full h-full object-contain" + data-plugin-tray-icon-image + /> : <div + className="w-8 h-8 rounded-full flex items-center justify-center flex-none" + data-plugin-tray-icon-image-fallback + > + <LuCircleDashed className="text-2xl" /> + </div>} + </div> + <p className="text-sm font-medium line-clamp-1 tracking-wide">{trayIcon.extensionName}</p> + </div> + <div className="flex items-center gap-1"> + {/* <IconButton + intent="gray-basic" + size="sm" + icon={<LuRefreshCw className="size-4" />} + className="rounded-full" + onClick={() => reloadExternalExtension({ id: trayIcon.extensionId })} + loading={isReloadingExtension} + /> */} + <Tooltip + trigger={<div> + {isPinned(trayIcon.extensionId) ? <IconButton + intent="primary-basic" + size="sm" + icon={<TbPinnedFilled className="size-5" />} + className="rounded-full" + onClick={() => { + setPluginSettingsPinnedTrays({ + pinnedTrayPluginIds: pinnedTrayPluginIds.filter(id => id !== trayIcon.extensionId), + }) + }} + disabled={isSettingPluginSettingsPinnedTrays} + /> : <IconButton + intent="gray-basic" + size="sm" + icon={<TbPinned className="size-5 text-[--muted]" />} + className="rounded-full" + onClick={() => { + setPluginSettingsPinnedTrays({ + pinnedTrayPluginIds: [...pinnedTrayPluginIds, trayIcon.extensionId], + }) + }} + disabled={isSettingPluginSettingsPinnedTrays} + />} + </div>} + > + {isPinned(trayIcon.extensionId) ? "Unpin" : "Pin"} + </Tooltip> + </div> + </div> + ))} + {!trayIcons.length && <p className="text-sm text-[--muted] py-1 text-center w-full"> + No tray plugins + </p>} + + {/* {developmentModeExtensions?.map(extension => ( + <div key={extension.id} className="flex items-center gap-2 justify-between bg-[--subtle] rounded-md p-2"> + <p className="text-sm font-medium">{extension.id}</p> + <div> + <IconButton + intent="warning-basic" + size="sm" + icon={<LuRefreshCw className="size-5" />} + className="rounded-full" + onClick={() => reloadExternalExtension({ id: extension.id })} + loading={isReloadingExtension} + /> + </div> + </div> + ))} */} + </div> + </Popover> + + {!!developmentModeExtensions.length && <Popover + side={place === "top" ? "bottom" : "right"} + trigger={<div> + <IconButton + intent="warning-basic" + size="sm" + icon={<LuBug className="size-4" />} + className="rounded-full" + /> + </div>} + className="p-2" + data-plugin-sidebar-debug-popover + modal={false} + > + <div className="space-y-1" data-plugin-sidebar-debug-popover-content> + <div className="text-sm space-y-1"> + <p className="font-bold"> + Debug + </p> + <p className="text-xs text-[--muted]"> + These extensions are loaded in development mode. + </p> + </div> + {developmentModeExtensions?.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })).map(extension => ( + <div key={extension.id} className="flex items-center gap-2 justify-between bg-[--subtle] rounded-md px-2 py-1"> + <p className="text-sm font-medium">{extension.id}</p> + <div> + <IconButton + intent="warning-basic" + size="sm" + icon={<LuRefreshCw className="size-5" />} + className="rounded-full" + onClick={() => reloadExternalExtension({ id: extension.id })} + loading={isReloadingExtension} + /> + </div> + </div> + ))} + </div> + </Popover>} + + {pinnedTrayIcons.map((trayIcon, index) => ( + <PluginTray + trayIcon={trayIcon} + isPinned={isPinned(trayIcon.extensionId)} + key={index} + place={place} + width={width} + /> + ))} + </div> + </> + ) +} + +export function PluginSidebarTray({ place }: { place: "sidebar" | "top" }) { + const { width } = useWindowSize() + const [trayIcons, setTrayIcons] = useAtom(__plugin_trayIconsAtom) + + const [hasNavigated, setHasNavigated] = useAtom(__plugin_hasNavigatedAtom) + const pathname = usePathname() + + const { data: pluginSettings } = useGetPluginSettings() + + const [mobileExtensionListOpen, setMobileExtensionListOpen] = React.useState(false) + const [pendingTrayOpenExtensionId, setPendingTrayOpenExtensionId] = React.useState<string | null>(null) + + const socket = React.useContext(WebSocketContext) + + const firstRender = React.useRef(true) + React.useEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + if (!hasNavigated) { + setHasNavigated(true) + } + }, [pathname, hasNavigated]) + + /** + * 1. Send a request to the server to list all tray icons + * 2. Receive the tray icons from the server + * 3. Set the tray icons in the state to display them + */ + const { sendListTrayIconsEvent } = usePluginSendListTrayIconsEvent() + + React.useEffect(() => { + // Send a request to all plugins to list their tray icons. + // Only plugins with a registered tray icon will respond. + sendListTrayIconsEvent({}, "") + }, []) + + /** + * TODO: Listen to other events from Extension Repository to refetch tray icons + * - When an extension is loaded + * - When an extension is unloaded + * - When an extension is updated + */ + + usePluginListenTrayIconEvent((data, extensionId) => { + setTrayIcons(prev => { + const oldTrayIcons = prev.filter(icon => icon.extensionId !== extensionId) + return [...oldTrayIcons, { + ...data, + }].sort((a, b) => a.extensionId.localeCompare(b.extensionId, undefined, { numeric: true })) + }) + }, "") + + /** + * Handling mobile programmatic tray open/close + */ + + usePluginListenTrayOpenEvent((data) => { + const isMobile = width && width < 1024 + if (isMobile && place === "top") { + // Store which extension triggered the open event + setPendingTrayOpenExtensionId(data.extensionId || "") + // Scroll to top + window.scrollTo({ top: 0, behavior: "smooth" }) + // Open the mobile extension list + setMobileExtensionListOpen(true) + } + }, "") + + usePluginListenTrayCloseEvent((data) => { + const isMobile = width && width < 1024 + if (isMobile && place === "top") { + setPendingTrayOpenExtensionId(null) + setTimeout(() => { + setMobileExtensionListOpen(false) + }, 300) + } + }, "") + + // Re-send the tray open event after the extension list is opened and rendered. + // This is necessary because the first tray open event is ignored since the tray is not rendered yet. + React.useEffect(() => { + if (mobileExtensionListOpen && pendingTrayOpenExtensionId && socket) { + // Small delay to ensure the PluginTray components are rendered + const timeout = setTimeout(() => { + // Re-send the tray open event now that the extension list is open + const fakeEvent = new MessageEvent("message", { + data: JSON.stringify({ + type: "plugin", + payload: { + type: "tray:open", + extensionId: pendingTrayOpenExtensionId, + payload: { + extensionId: pendingTrayOpenExtensionId, + }, + }, + }), + }) + socket.dispatchEvent(fakeEvent) + }, 100) + return () => clearTimeout(timeout) + } + }, [mobileExtensionListOpen, pendingTrayOpenExtensionId, socket]) + + // End + + const { data: developmentModeExtensions, refetch } = useListDevelopmentModeExtensions() + const { mutate: reloadExternalExtension, isPending: isReloadingExtension } = useReloadExternalExtension() + + useWebsocketMessageListener({ + type: WSEvents.PLUGIN_UNLOADED, + onMessage: (extensionId) => { + setTrayIcons(prev => prev.filter(icon => icon.extensionId !== extensionId)) + setTimeout(() => { + refetch() + }, 1000) + } + }) + + const isMobile = width && width < 1024 + + + if (!trayIcons) return null + + return ( + <> + {!isMobile && place === "sidebar" && <ExtensionList + place={place} + developmentModeExtensions={developmentModeExtensions || []} + isReloadingExtension={isReloadingExtension} + reloadExternalExtension={reloadExternalExtension} + trayIcons={trayIcons} + settings={pluginSettings} + width={width} + />} + {isMobile && place === "top" && <div className=""> + <Popover + open={mobileExtensionListOpen} + onOpenChange={setMobileExtensionListOpen} + side="bottom" + trigger={<div> + <IconButton + intent="gray-basic" + size="sm" + icon={<LuBlocks className="size-5 text-[--muted]" />} + /> + </div>} + className="rounded-full p-0 overflow-y-auto bg-[--background] mx-4" + style={{ + width: width ? width - 50 : "100%", + // transform: "translateX(10px)", + }} + > + <ExtensionList + place={place} + developmentModeExtensions={developmentModeExtensions || []} + isReloadingExtension={isReloadingExtension} + reloadExternalExtension={reloadExternalExtension} + trayIcons={trayIcons} + settings={pluginSettings} + width={width} + /> + </Popover> + </div>} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/tray/plugin-tray.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/tray/plugin-tray.tsx new file mode 100644 index 0000000..4074408 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/plugin/tray/plugin-tray.tsx @@ -0,0 +1,284 @@ +import { PluginProvider, registry, RenderPluginComponents } from "@/app/(main)/_features/plugin/components/registry" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { PopoverAnatomy } from "@/components/ui/popover" +import { Tooltip } from "@/components/ui/tooltip" +import { getPixelsFromLength } from "@/lib/helpers/css" +import * as PopoverPrimitive from "@radix-ui/react-popover" +import { useAtom, useAtomValue } from "jotai" +import Image from "next/image" +import React from "react" +import { LuCircleDashed } from "react-icons/lu" +import { + Plugin_Server_TrayIconEventPayload, + usePluginListenTrayBadgeUpdatedEvent, + usePluginListenTrayCloseEvent, + usePluginListenTrayOpenEvent, + usePluginListenTrayUpdatedEvent, + usePluginSendRenderTrayEvent, + usePluginSendTrayClickedEvent, + usePluginSendTrayClosedEvent, + usePluginSendTrayOpenedEvent, +} from "../generated/plugin-events" +import { __plugin_hasNavigatedAtom, __plugin_unpinnedTrayIconClickedAtom } from "./plugin-sidebar-tray" + +/** + * TrayIcon + */ +export type TrayIcon = Plugin_Server_TrayIconEventPayload + +type TrayPluginProps = { + trayIcon: TrayIcon + place: "sidebar" | "top" + width: number | null + isPinned: boolean +} + +export const PluginTrayContext = React.createContext<TrayPluginProps>({ + place: "sidebar", + width: null, + isPinned: false, + trayIcon: { + extensionId: "", + extensionName: "", + iconUrl: "", + withContent: false, + tooltipText: "", + badgeNumber: 0, + badgeIntent: "info", + width: "30rem", + minHeight: "auto", + }, +}) + +function PluginTrayProvider(props: { children: React.ReactNode, props: TrayPluginProps }) { + return <PluginTrayContext.Provider value={props.props}> + {props.children} + </PluginTrayContext.Provider> +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function usePluginTray() { + const context = React.useContext(PluginTrayContext) + if (!context) { + throw new Error("usePluginTray must be used within a PluginTrayProvider") + } + return context +} + +export function PluginTray(props: TrayPluginProps) { + + const [open, setOpen] = React.useState(false) + const [badgeNumber, setBadgeNumber] = React.useState(0) + const [badgeIntent, setBadgeIntent] = React.useState("info") + + const { sendTrayOpenedEvent } = usePluginSendTrayOpenedEvent() + const { sendTrayClosedEvent } = usePluginSendTrayClosedEvent() + const { sendTrayClickedEvent } = usePluginSendTrayClickedEvent() + + const hasNavigated = useAtomValue(__plugin_hasNavigatedAtom) + + const [unpinnedTrayIconClicked, setUnpinnedTrayIconClicked] = useAtom(__plugin_unpinnedTrayIconClickedAtom) + + const firstRender = React.useRef(true) + React.useEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + if (open) { + sendTrayOpenedEvent({}, props.trayIcon.extensionId) + } else { + sendTrayClosedEvent({}, props.trayIcon.extensionId) + } + }, [open]) + + // Handle unpinned tray icon click to open the tray + const unpinnedTrayIconClickedOpenedRef = React.useRef(false) + React.useEffect(() => { + if (unpinnedTrayIconClicked?.extensionId === props.trayIcon.extensionId) { + if (!unpinnedTrayIconClickedOpenedRef.current) { + const timeout = setTimeout(() => { + setOpen(true) + unpinnedTrayIconClickedOpenedRef.current = true + }, 100) + return () => clearTimeout(timeout) + } + } + }, [unpinnedTrayIconClicked]) + + // Reset unpinned tray icon click state when closing the tray + React.useEffect(() => { + if (unpinnedTrayIconClicked?.extensionId === props.trayIcon.extensionId && !open && unpinnedTrayIconClickedOpenedRef.current) { + setUnpinnedTrayIconClicked(null) + unpinnedTrayIconClickedOpenedRef.current = false + } + }, [open]) + + usePluginListenTrayBadgeUpdatedEvent((data) => { + setBadgeNumber(data.badgeNumber) + setBadgeIntent(data.badgeIntent) + }, props.trayIcon.extensionId) + + usePluginListenTrayOpenEvent((data) => { + setOpen(true) + }, props.trayIcon.extensionId) + + usePluginListenTrayCloseEvent((data) => { + setOpen(false) + }, props.trayIcon.extensionId) + + function handleClick() { + sendTrayClickedEvent({}, props.trayIcon.extensionId) + } + + const tooltipText = props.trayIcon.extensionName + + const TrayIcon = () => { + return ( + <div + data-plugin-tray-icon + className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-gray-800 cursor-pointer transition-all relative select-none" + onClick={handleClick} + > + <div className="w-8 h-8 rounded-full flex items-center justify-center overflow-hidden relative" data-plugin-tray-icon-inner-container> + {props.trayIcon.iconUrl ? <Image + src={props.trayIcon.iconUrl} + alt="logo" + fill + className="p-1 w-full h-full object-contain" + data-plugin-tray-icon-image + /> : <div className="w-8 h-8 rounded-full flex items-center justify-center" data-plugin-tray-icon-image-fallback> + <LuCircleDashed className="text-2xl" /> + </div>} + </div> + {!!badgeNumber && <Badge + intent={`${badgeIntent}-solid` as any} + size="sm" + className="absolute -top-2 -right-2 z-10 select-none pointer-events-none" + data-plugin-tray-icon-badge + > + {badgeNumber} + </Badge>} + </div> + ) + } + + const designatedWidthPx = getPixelsFromLength(props.trayIcon.width || "30rem") + const popoverWidth = (props.width && props.width < 1024) + ? (designatedWidthPx >= props.width ? `calc(100vw - 30px)` : designatedWidthPx) + : props.trayIcon.width || "30rem" + + if (!props.trayIcon.withContent) { + return <div className="cursor-pointer"> + {!!tooltipText ? <Tooltip + side="right" + trigger={<div data-plugin-tray-icon-tooltip-trigger> + <TrayIcon /> + </div>} + data-plugin-tray-icon-tooltip + > + {tooltipText} + </Tooltip> : <TrayIcon />} + </div> + } + + + // console.log("popoverWidth", popoverWidth) + // console.log("designatedWidthPx", designatedWidthPx) + // console.log("props.width", props.width) + + return ( + <> + <PopoverPrimitive.Root + open={open} + onOpenChange={setOpen} + modal={false} + > + <PopoverPrimitive.Trigger + asChild + > + <div data-plugin-tray-icon-trigger={props.trayIcon.extensionId}> + {!!tooltipText ? <Tooltip + side={props.place === "sidebar" ? "right" : "bottom"} + trigger={<div data-plugin-tray-icon-tooltip-trigger> + <TrayIcon /> + </div>} + data-plugin-tray-icon-tooltip + > + {tooltipText} + </Tooltip> : <TrayIcon />} + </div> + </PopoverPrimitive.Trigger> + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + sideOffset={10} + side={props.place === "sidebar" ? "right" : "bottom"} + className={cn(PopoverAnatomy.root(), "bg-gray-950 p-0 shadow-xl rounded-xl mb-4")} + onOpenAutoFocus={(e) => e.preventDefault()} + style={{ + width: popoverWidth, + minHeight: props.trayIcon.minHeight || "auto", + marginLeft: props.width && props.width < 1024 ? `10px` : undefined, + }} + data-plugin-tray-popover-content={props.trayIcon.extensionId} + > + <PluginTrayProvider props={props}> + <PluginTrayContent + open={open} + setOpen={setOpen} + {...props} + /> + </PluginTrayProvider> + </PopoverPrimitive.Content> + </PopoverPrimitive.Portal> + </PopoverPrimitive.Root> + </> + ) +} + +type PluginTrayContentProps = { + open: boolean + setOpen: (open: boolean) => void +} & TrayPluginProps + +function PluginTrayContent(props: PluginTrayContentProps) { + const { + trayIcon, + open, + setOpen, + } = props + + const { sendRenderTrayEvent } = usePluginSendRenderTrayEvent() + + React.useEffect(() => { + if (open) { + sendRenderTrayEvent({}, trayIcon.extensionId) + } + }, [open]) + + const [data, setData] = React.useState<any>(null) + + usePluginListenTrayUpdatedEvent((data) => { + // console.log("tray:updated", extensionID, data) + setData(data) + }, trayIcon.extensionId) + + return ( + <div> + <div + className={cn( + "max-h-[35rem] overflow-y-auto p-3", + )} + > + + <PluginProvider registry={registry}> + {!!data && data.components ? <RenderPluginComponents data={data.components} /> : <LoadingSpinner />} + </PluginProvider> + + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/_components/autoplay-countdown-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/_components/autoplay-countdown-modal.tsx new file mode 100644 index 0000000..d5df9ad --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/_components/autoplay-countdown-modal.tsx @@ -0,0 +1,139 @@ +import { AutoplayState } from "@/app/(main)/_features/progress-tracking/_lib/autoplay" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { logger } from "@/lib/helpers/debug" +import { BiSolidSkipNextCircle } from "react-icons/bi" + +interface AutoplayCountdownModalProps { + autoplayState: AutoplayState + onCancel: () => void + onPlayNow?: () => void +} + +export function AutoplayCountdownModal({ + autoplayState, + onCancel, + onPlayNow, +}: AutoplayCountdownModalProps) { + + const { isActive, countdown, nextEpisode, streamingType } = autoplayState + + const handleClose = () => { + logger("AutoplayCountdownModal").info("User cancelled autoplay") + onCancel() + } + + const handlePlayNow = () => { + logger("AutoplayCountdownModal").info("User requested immediate play") + onPlayNow?.() + } + + const getStreamingTypeLabel = () => { + switch (streamingType) { + case "local": + return "Local File" + case "torrent": + return "Torrent Stream" + case "debrid": + return "Debrid Stream" + default: + return "Unknown" + } + } + + const getNextEpisodeInfo = () => { + if (nextEpisode) { + return { + title: nextEpisode.displayTitle, + episodeTitle: nextEpisode.episodeTitle, + image: nextEpisode.episodeMetadata?.image || nextEpisode.baseAnime?.coverImage?.large, + } + } + + return { + title: "Next Episode", + episodeTitle: null, + image: null, + } + } + + if (!isActive) return null + + const episodeInfo = getNextEpisodeInfo() + + return ( + <Modal + open={isActive} + onOpenChange={(open) => { + if (!open) handleClose() + }} + titleClass="text-center" + hideCloseButton + title="Playing next episode in" + contentClass="!space-y-4 relative max-w-xl border-transparent !rounded-3xl" + closeClass="!text-[--red]" + > + <div className="text-center space-y-4"> + <div className="rounded-[--radius-md] text-center"> + <h3 className="text-5xl font-bold">{countdown}</h3> + {/* <p className="text-[--muted] text-sm mt-1"> + {countdown === 1 ? "second" : "seconds"} + </p> */} + </div> + + {/*<div className="space-y-2">*/} + {/* {episodeInfo.image && (*/} + {/* <div className="size-16 rounded-full relative mx-auto overflow-hidden">*/} + {/* <Image*/} + {/* src={episodeInfo.image}*/} + {/* alt="episode thumbnail"*/} + {/* fill*/} + {/* className="object-cover object-center"*/} + {/* placeholder={imageShimmer(64, 64)}*/} + {/* />*/} + {/* </div>*/} + {/* )}*/} + + {/* <div>*/} + {/* <h4 className="font-medium text-lg line-clamp-1">*/} + {/* {episodeInfo.title}*/} + {/* </h4>*/} + + {/* {episodeInfo.episodeTitle && (*/} + {/* <p className="text-[--muted] text-sm line-clamp-2">*/} + {/* {episodeInfo.episodeTitle}*/} + {/* </p>*/} + {/* )}*/} + + {/* <p className="text-xs text-[--muted] mt-1">*/} + {/* {getStreamingTypeLabel()}*/} + {/* </p>*/} + {/* </div>*/} + {/*</div>*/} + + <div className="flex gap-2 pt-2"> + <Button + intent="gray-basic" + onClick={handleClose} + className="flex-1" + size="sm" + > + Cancel + </Button> + + {onPlayNow && ( + <Button + intent="primary" + onClick={handlePlayNow} + className="flex-1" + size="sm" + leftIcon={<BiSolidSkipNextCircle />} + > + Play Now + </Button> + )} + </div> + </div> + </Modal> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/_lib/autoplay.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/_lib/autoplay.ts new file mode 100644 index 0000000..6fb74fc --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/_lib/autoplay.ts @@ -0,0 +1,225 @@ +import { Anime_Episode } from "@/api/generated/types" +import { useGetAnimeEntry } from "@/api/hooks/anime_entries.hooks" +import { PlaybackManager_PlaybackState } from "@/app/(main)/_features/progress-tracking/_lib/playback-manager.types" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useDebridStreamAutoplay, useTorrentStreamAutoplay } from "@/app/(main)/entry/_containers/torrent-stream/_lib/handle-torrent-stream" +import { useHandlePlayMedia } from "@/app/(main)/entry/_lib/handle-play-media" +import { logger } from "@/lib/helpers/debug" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React, { useState } from "react" +import { toast } from "sonner" + +const __autoplay_isActiveAtom = atom(false) +const __autoplay_countdownAtom = atom(5) +export const __autoplay_nextEpisodeAtom = atom<Anime_Episode | null>(null) +const __autoplay_streamingTypeAtom = atom<"local" | "torrent" | "debrid" | null>(null) + +export interface AutoplayState { + isActive: boolean + countdown: number + nextEpisode: Anime_Episode | null + streamingType: "local" | "torrent" | "debrid" | null +} + +export function useAutoplay() { + const serverStatus = useServerStatus() + + // Autoplay state + const [isActive, setIsActive] = useState(false) + const [countdown, setCountdown] = useAtom(__autoplay_countdownAtom) + const [nextEpisode, setNextEpisode] = useAtom(__autoplay_nextEpisodeAtom) + const [streamingType, setStreamingType] = useAtom(__autoplay_streamingTypeAtom) + + const { hasNextTorrentstreamEpisode, autoplayNextTorrentstreamEpisode, resetTorrentstreamAutoplayInfo } = useTorrentStreamAutoplay() + const { hasNextDebridstreamEpisode, autoplayNextDebridstreamEpisode, resetDebridstreamAutoplayInfo } = useDebridStreamAutoplay() + + // Local playback + const { playMediaFile } = useHandlePlayMedia() + + const isActiveRef = React.useRef(isActive) + + // refs for cleanup + const timerRef = React.useRef<NodeJS.Timeout | null>(null) + const countdownRef = React.useRef<NodeJS.Timeout | null>(null) + + // Clear all timers + const clearTimers = () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + if (countdownRef.current) { + clearInterval(countdownRef.current) + countdownRef.current = null + } + } + + const cancelAutoplay = () => { + logger("Autoplay").info("Cancelling autoplay") + + clearTimers() + setIsActive(_ => { + isActiveRef.current = false + return false + }) + setNextEpisode(null) + setStreamingType(null) + setCountdown(5) + + // Reset streaming autoplay info + resetTorrentstreamAutoplayInfo() + resetDebridstreamAutoplayInfo() + } + + const startAutoplay = ( + playbackState: PlaybackManager_PlaybackState, + nextEp?: Anime_Episode, + type: "local" | "torrent" | "debrid" = "local", + ) => { + if (!serverStatus?.settings?.library?.autoPlayNextEpisode) { + logger("Autoplay").info("Autoplay disabled in settings") + return + } + + if (isActiveRef.current) { + logger("Autoplay").info("Autoplay already active") + return + } + + // Determine next episode and streaming type + let episodeToPlay: Anime_Episode | null = null + let detectedType: "local" | "torrent" | "debrid" | null = null + + if (nextEp) { + episodeToPlay = nextEp + detectedType = type + } else if (hasNextTorrentstreamEpisode) { + detectedType = "torrent" + } else if (hasNextDebridstreamEpisode) { + detectedType = "debrid" + } else { + // For local episodes, we'll pass the episode in the nextEp + // The caller is responsible for getting the next episode + detectedType = "local" + } + + if (!episodeToPlay && !hasNextTorrentstreamEpisode && !hasNextDebridstreamEpisode) { + logger("Autoplay").info("No next episode found") + return + } + + logger("Autoplay").info("Starting autoplay countdown", { + nextEpisode: episodeToPlay?.displayTitle, + type: detectedType, + }) + + setNextEpisode(episodeToPlay) + setStreamingType(detectedType) + setIsActive(_ => { + isActiveRef.current = true + return true + }) + + setCountdown(5) + + // Start countdown timer + countdownRef.current = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + if (countdownRef.current) { + clearInterval(countdownRef.current) + countdownRef.current = null + } + return 0 + } + return prev - 1 + }) + }, 1000) + + // Start main timer to trigger autoplay + timerRef.current = setTimeout(() => { + executeAutoplay(episodeToPlay, detectedType, playbackState) + }, 5000) + + } + + // Execute the actual autoplay + const executeAutoplay = ( + episode: Anime_Episode | null, + type: "local" | "torrent" | "debrid" | null, + playbackState: PlaybackManager_PlaybackState, + ) => { + logger("Autoplay").info("Executing autoplay", { type, episode: episode?.displayTitle, isActive: isActiveRef.current }) + + try { + switch (type) { + case "local": + if (episode?.localFile?.path) { + playMediaFile({ + path: episode.localFile.path, + mediaId: playbackState.mediaId, + episode: episode, + }) + toast.info("Playing next episode") + } + break + case "torrent": + autoplayNextTorrentstreamEpisode() + break + case "debrid": + autoplayNextDebridstreamEpisode() + break + default: + logger("Autoplay").warning("Unknown streaming type", type) + } + } + catch (error) { + logger("Autoplay").error("Error executing autoplay", error) + toast.error("Failed to play next episode") + } + finally { + logger("Autoplay").info("Autoplay execution finished, resetting state") + // Reset state + setIsActive(_ => { + isActiveRef.current = false + return false + }) + setNextEpisode(null) + setStreamingType(null) + setCountdown(5) + } + } + + // Cleanup on unmount + // useUnmount(() => { + // clearTimers() + // }) + + return { + state: { + isActive, + countdown, + nextEpisode, + streamingType, + } as AutoplayState, + + startAutoplay, + cancelAutoplay, + + hasNextEpisode: !!nextEpisode || hasNextTorrentstreamEpisode || hasNextDebridstreamEpisode, + resetAutoplayInfo: cancelAutoplay, + } +} + +// get next episode from anime entry +export function useNextEpisodeResolver(mediaId: number, currentEpisodeNumber: number) { + const { data: animeEntry } = useGetAnimeEntry(!!mediaId ? mediaId : null) + + return React.useMemo(() => { + if (!animeEntry?.episodes) return null + + const mainEpisodes = animeEntry.episodes.filter(ep => ep.type === "main") + return mainEpisodes.find(ep => ep.progressNumber === currentEpisodeNumber + 1) || null + }, [animeEntry?.episodes, currentEpisodeNumber]) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/_lib/playback-manager.types.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/_lib/playback-manager.types.ts new file mode 100644 index 0000000..b59d705 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/_lib/playback-manager.types.ts @@ -0,0 +1,22 @@ +export type PlaybackManager_PlaybackState = { + filename: string + mediaTitle: string + mediaTotalEpisodes: number + episodeNumber: number + completionPercentage: number + canPlayNext: boolean + progressUpdated: boolean + mediaId: number + mediaCoverImage: string +} + +export type PlaybackManager_PlaylistState = { + current: PlaybackManager_PlaylistStateItem | null + next: PlaybackManager_PlaylistStateItem | null + remaining: number +} + +export type PlaybackManager_PlaylistStateItem = { + name: string + mediaImage: string +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/manual-progress-tracking.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/manual-progress-tracking.tsx new file mode 100644 index 0000000..703c0ad --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/manual-progress-tracking.tsx @@ -0,0 +1,152 @@ +import { usePlaybackCancelManualTracking, usePlaybackStartManualTracking, usePlaybackSyncCurrentProgress } from "@/api/hooks/playback_manager.hooks" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import { WSEvents } from "@/lib/server/ws-events" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import Image from "next/image" +import React from "react" +import { PiPopcornFill } from "react-icons/pi" +import { PlaybackManager_PlaybackState } from "./_lib/playback-manager.types" + +type ManualProgressTrackingProps = { + asSidebarButton?: boolean +} + +const __mpt_isWatchingAtom = atom<boolean>(false) +const __mpt_showModalAtom = atom<boolean>(false) + +export function ManualProgressTrackingButton(props: ManualProgressTrackingProps) { + + const { + asSidebarButton, + ...rest + } = props + + const [isWatching, setIsWatching] = useAtom(__mpt_isWatchingAtom) + const [showModal, setShowModal] = useAtom(__mpt_showModalAtom) + + return ( + <> + {isWatching && ( + <> + {asSidebarButton ? ( + <IconButton + data-manual-progress-tracking-button + intent="primary-subtle" + className={cn("animate-pulse")} + icon={<PiPopcornFill />} + onClick={() => setShowModal(true)} + /> + ) : ( + <Button + data-manual-progress-tracking-button + intent="primary" + className={cn("animate-pulse")} + leftIcon={<PiPopcornFill />} + onClick={() => setShowModal(true)} + > + Currently watching + </Button>)} + </> + )} + </> + ) +} + +export function ManualProgressTracking() { + + const [isWatching, setIsWatching] = useAtom(__mpt_isWatchingAtom) + const stateRef = React.useRef<PlaybackManager_PlaybackState | null>(null) + const [state, setState] = React.useState<PlaybackManager_PlaybackState | null>(null) + const [showModal, setShowModal] = useAtom(__mpt_showModalAtom) + + // Playback state + useWebsocketMessageListener<PlaybackManager_PlaybackState | null>({ + type: WSEvents.PLAYBACK_MANAGER_MANUAL_TRACKING_PLAYBACK_STATE, + onMessage: data => { + if (!isWatching) { + setIsWatching(true) + } + setState(data) + }, + }) + + React.useEffect(() => { + if (stateRef.current === null) { + setShowModal(true) + } + stateRef.current = state + }, [state]) + + useWebsocketMessageListener({ + type: WSEvents.PLAYBACK_MANAGER_MANUAL_TRACKING_STOPPED, + onMessage: () => { + setIsWatching(false) + setShowModal(false) + setState(null) + }, + }) + + const { mutate: syncProgress, isPending: isSyncing } = usePlaybackSyncCurrentProgress() + + const { mutate: startManualTracking, isPending: isStarting } = usePlaybackStartManualTracking() + + // Get the server to stop reporting the progress + const { mutate: cancelManualTracking, isPending: isCanceling } = usePlaybackCancelManualTracking({ + onSuccess: () => { + setShowModal(false) + setState(null) + setIsWatching(false) + }, + }) + + return ( + <> + <Modal + data-manual-progress-tracking-modal + open={showModal && isWatching} + onOpenChange={v => setShowModal(v)} + // title="Progress" + titleClass="text-center" + contentClass="!space-y-2 relative max-w-2xl" + > + {state && <div data-manual-progress-tracking-modal-content className="text-center relative overflow-hidden space-y-2"> + <p className="text-[--muted]">Playing externally</p> + {state.mediaCoverImage && <div className="size-16 rounded-full relative mx-auto overflow-hidden mb-3"> + <Image src={state.mediaCoverImage} alt="cover image" fill className="object-cover object-center" /> + </div>} + <h3 className="text-lg font-medium line-clamp-1">{state?.mediaTitle}</h3> + <p className="text-2xl font-bold">Episode {state?.episodeNumber} + <span className="text-[--muted]">{" / "}{(!!state?.mediaTotalEpisodes && state?.mediaTotalEpisodes > 0) + ? state?.mediaTotalEpisodes + : "-"}</span></p> + </div>} + + <div data-manual-progress-tracking-modal-buttons className="flex gap-2 w-full"> + <Button + intent="primary-subtle" + disabled={isSyncing || isStarting || isCanceling} + onClick={() => syncProgress()} + className="w-full" + loading={isSyncing} + > + Update progress now + </Button> + <Button + intent="alert-subtle" + disabled={isSyncing || isStarting || isCanceling} + onClick={() => cancelManualTracking()} + className="w-full" + loading={isCanceling} + > + Stop + </Button> + </div> + </Modal> + + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/playback-manager-progress-tracking.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/playback-manager-progress-tracking.tsx new file mode 100644 index 0000000..0b62d4c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/progress-tracking/playback-manager-progress-tracking.tsx @@ -0,0 +1,454 @@ +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { + usePlaybackCancelCurrentPlaylist, + usePlaybackPlaylistNext, + usePlaybackPlayNextEpisode, + usePlaybackSyncCurrentProgress, +} from "@/api/hooks/playback_manager.hooks" +import { AutoplayCountdownModal } from "@/app/(main)/_features/progress-tracking/_components/autoplay-countdown-modal" +import { useAutoplay, useNextEpisodeResolver } from "@/app/(main)/_features/progress-tracking/_lib/autoplay" +import { PlaybackManager_PlaybackState, PlaybackManager_PlaylistState } from "@/app/(main)/_features/progress-tracking/_lib/playback-manager.types" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { imageShimmer } from "@/components/shared/image-helpers" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import { ProgressBar } from "@/components/ui/progress-bar" +import { logger } from "@/lib/helpers/debug" +import { WSEvents } from "@/lib/server/ws-events" +import { useQueryClient } from "@tanstack/react-query" +import { atom, useAtomValue } from "jotai" +import { useAtom } from "jotai/react" +import mousetrap from "mousetrap" +import Image from "next/image" +import React from "react" +import { BiSolidSkipNextCircle } from "react-icons/bi" +import { MdCancel } from "react-icons/md" +import { PiPopcornFill } from "react-icons/pi" +import { toast } from "sonner" + +const __pt_showModalAtom = atom(false) +const __pt_isTrackingAtom = atom(false) +const __pt_isCompletedAtom = atom(false) + +type Props = { + asSidebarButton?: boolean +} + +export function PlaybackManagerProgressTrackingButton({ asSidebarButton }: Props) { + const [showModal, setShowModal] = useAtom(__pt_showModalAtom) + + const isTracking = useAtomValue(__pt_isTrackingAtom) + + const isCompleted = useAtomValue(__pt_isCompletedAtom) + + // \/ Modal can be displayed when progress tracking or video is completed + // Basically, keep the modal visible if there's no more tracking but the video is completed + const shouldBeDisplayed = isTracking || isCompleted + + return ( + <> + {shouldBeDisplayed && ( + <> + {asSidebarButton ? ( + <IconButton + data-progress-tracking-button + intent="primary-subtle" + className={cn("animate-pulse")} + icon={<PiPopcornFill />} + onClick={() => setShowModal(true)} + /> + ) : ( + <Button + data-progress-tracking-button + intent="primary" + className={cn("animate-pulse")} + leftIcon={<PiPopcornFill />} + onClick={() => setShowModal(true)} + > + Currently watching + </Button>)} + </> + )} + </> + ) +} + +export function PlaybackManagerProgressTracking() { + const serverStatus = useServerStatus() + const qc = useQueryClient() + + const [showModal, setShowModal] = useAtom(__pt_showModalAtom) + + /** + * Progress tracking states + * - 'True' when tracking has started + * - 'False' when tracking has stopped + */ + const [isTracking, setIsTracking] = useAtom(__pt_isTrackingAtom) + /** + * Video completion state + * - 'True' when the video has been completed + * - 'False' by default + */ + const [isCompleted, setIsCompleted] = useAtom(__pt_isCompletedAtom) + + // \/ Modal can be displayed when progress tracking or video is completed + // Basically, keep the modal visible if there's no more tracking but the video is completed + const shouldBeDisplayed = isTracking || isCompleted + + const [state, setState] = React.useState<PlaybackManager_PlaybackState | null>(null) + const [playlistState, setPlaylistState] = React.useState<PlaybackManager_PlaylistState | null>(null) + + const { state: autoplayState, startAutoplay, cancelAutoplay } = useAutoplay() + + // Get next episode for local playback + const nextEpisodeToPlay = useNextEpisodeResolver( + state?.mediaId || 0, + state?.episodeNumber || 0, + ) + + const { mutate: syncProgress, isPending } = usePlaybackSyncCurrentProgress() + + const { mutate: playlistNext, isSuccess: submittedPlaylistNext } = usePlaybackPlaylistNext([playlistState?.current?.name]) + + const { mutate: stopPlaylist, isSuccess: submittedStopPlaylist } = usePlaybackCancelCurrentPlaylist([playlistState?.current?.name]) + + const { + mutate: playNextEpisodeAction, + isSuccess: submittedNextEpisode, + isPending: submittingNextEpisode, + } = usePlaybackPlayNextEpisode([state?.filename]) + + // Tracking started + useWebsocketMessageListener<PlaybackManager_PlaybackState | null>({ + type: WSEvents.PLAYBACK_MANAGER_PROGRESS_TRACKING_STARTED, + onMessage: data => { + logger("PlaybackManagerProgressTracking").info("Tracking started", data) + setIsTracking(true) + setIsCompleted(false) + setShowModal(true) // Show the modal when tracking starts + setState(data) + }, + }) + + // Video completed + useWebsocketMessageListener<PlaybackManager_PlaybackState | null>({ + type: WSEvents.PLAYBACK_MANAGER_PROGRESS_VIDEO_COMPLETED, + onMessage: data => { + logger("PlaybackManagerProgressTracking").info("Video completed", data) + setIsCompleted(true) + setState(data) + }, + }) + + // Tracking stopped completely + useWebsocketMessageListener<string>({ + type: WSEvents.PLAYBACK_MANAGER_PROGRESS_TRACKING_STOPPED, + onMessage: data => { + logger("PlaybackManagerProgressTracking").info("Tracking stopped", data, "Completion percentage:", state?.completionPercentage) + setIsTracking(false) + // Letting 'isCompleted' be true if the progress hasn't been updated + // so the modal is left available for the user to update the progress manually + if (state?.progressUpdated) { + // Setting 'isCompleted' to 'false' to hide the modal + logger("PlaybackManagerProgressTracking").info("Progress updated, setting isCompleted to false") + setIsCompleted(false) + } + + if (data === "Player closed") { + toast.info("Player closed") + + if ((state?.completionPercentage || 0) <= 0.8) { + setIsCompleted(false) + } + } else if (data === "Tracking stopped") { + toast.info("Tracking stopped") + } else { + toast.error(data) + } + + qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.key] }).then() + + // Start unified autoplay if conditions are met + if (!playlistState && state && state.completionPercentage && state.completionPercentage > 0.7) { + if (!autoplayState.isActive) { + startAutoplay(state, nextEpisodeToPlay || undefined, "local") + } + } + setState(null) + }, + }) + + + + + // Playback state + useWebsocketMessageListener<PlaybackManager_PlaybackState | null>({ + type: WSEvents.PLAYBACK_MANAGER_PROGRESS_PLAYBACK_STATE, + onMessage: data => { + if (!isTracking) { + setIsTracking(true) + } + setState(data) + }, + }) + + const queryClient = useQueryClient() + + // Progress has been updated + useWebsocketMessageListener<PlaybackManager_PlaybackState | null>({ + type: WSEvents.PLAYBACK_MANAGER_PROGRESS_UPDATED, + onMessage: data => { + if (data) { + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(data.mediaId)] }) + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] }) + + setState(data) + toast.success("Progress updated") + } + }, + }) + + useWebsocketMessageListener<PlaybackManager_PlaylistState | null>({ + type: WSEvents.PLAYBACK_MANAGER_PLAYLIST_STATE, + onMessage: data => { + setPlaylistState(data) + }, + }) + + + const confirmPlayNext = useConfirmationDialog({ + title: "Play next episode", + description: "Are you sure you want to play the next episode?", + actionText: "Confirm", + actionIntent: "success", + onConfirm: () => { + if (!submittedPlaylistNext) playlistNext() + }, + }) + + // Progress update keyboard shortcuts + React.useEffect(() => { + mousetrap.bind("u", () => { + if (!isPending && state?.completionPercentage && state?.completionPercentage > 0.7) { + syncProgress() + } + }) + + mousetrap.bind("space", () => { + if (!isPending && state?.completionPercentage && state?.completionPercentage > 0.7) { + cancelAutoplay() + if (state?.canPlayNext && !playlistState) { + playNextEpisodeAction() + } + if (!!playlistState?.next) { + playlistNext() + } + } + }) + + return () => { + mousetrap.unbind("u") + mousetrap.unbind("space") + } + }, [state?.completionPercentage && state?.completionPercentage > 0.7, state?.canPlayNext, !!playlistState?.next, cancelAutoplay]) + + const confirmNextEpisode = useConfirmationDialog({ + title: "Play next episode", + description: "Are you sure you want to play the next episode?", + actionText: "Confirm", + actionIntent: "success", + onConfirm: () => { + if (!submittedNextEpisode) playNextEpisodeAction() + }, + }) + + const confirmStopPlaylist = useConfirmationDialog({ + title: "Play next", + actionText: "Confirm", + actionIntent: "alert", + description: "Are you sure you want to stop the playlist? It will be deleted.", + onConfirm: () => { + if (!submittedStopPlaylist) stopPlaylist() + }, + }) + + + function handleUpdateProgress() { + syncProgress() + } + + // React.useEffect(() => { + // mousetrap.bind("esc", () => { + // cancelAutoPlay() + // setShowModal(false) + // setShowAutoPlayCountdownModal(false) + // setIsTracking(false) + // setIsCompleted(false) + // setState(null) + // setPlaylistState(null) + // setWillAutoPlay(false) + // resetTorrentstreamAutoplayInfo() + // resetDebridstreamAutoplayInfo() + // clearTimers() + // }) + + // return () => { + // mousetrap.unbind("esc") + // } + // }, []) + + return ( + <> + <Modal + data-progress-tracking-modal + open={showModal && shouldBeDisplayed} + onOpenChange={v => setShowModal(v)} + titleClass="text-center" + contentClass="!space-y-0 relative max-w-2xl overflow-hidden" + > + {!!state?.completionPercentage && <div data-progress-tracking-modal-progress-bar className="absolute left-0 top-0 w-full"> + <ProgressBar className="h-2 rounded-lg" value={state.completionPercentage * 100} /> + </div>} + {state && <div data-progress-tracking-main-content className="text-center relative overflow-hidden py-2 space-y-2"> + {state.mediaCoverImage && <div className="size-16 rounded-full relative mx-auto overflow-hidden mb-3"> + <Image src={state.mediaCoverImage} alt="cover image" fill className="object-cover object-center" /> + </div>} + {/*<p className="text-[--muted]">Currently watching</p>*/} + <div data-progress-tracking-title> + <h3 className="text-lg font-medium line-clamp-1">{state?.mediaTitle}</h3> + <p className="text-2xl font-bold">Episode {state?.episodeNumber} + <span className="text-[--muted]">{" / "}{state?.mediaTotalEpisodes || "-"}</span> + </p> + </div> + {(serverStatus?.settings?.library?.autoUpdateProgress && !state?.progressUpdated) && ( + <p data-progress-tracking-auto-update-progress className="text-[--muted] text-center text-sm"> + Your progress will be automatically updated + </p> + )} + {(state?.progressUpdated) && ( + <p data-progress-tracking-progress-updated className="text-green-300 text-center"> + Progress updated + </p> + )} + </div>} + + {( + !!state?.completionPercentage + && state?.completionPercentage > 0.7 + && !state.progressUpdated + ) && <div data-progress-tracking-update-progress-button className="flex gap-2 justify-center items-center"> + <Button + intent="primary-subtle" + disabled={isPending || state?.progressUpdated} + onClick={handleUpdateProgress} + className="w-full animate-pulse" + loading={isPending} + > + Update progress now + </Button> + </div>} + + {( + !!state?.completionPercentage + && state?.completionPercentage > 0.7 + && state?.canPlayNext + && !playlistState + ) && <div data-progress-tracking-play-next-episode-button className="flex gap-2 justify-center items-center"> + <Button + intent="gray-subtle" + onClick={() => { + cancelAutoplay() + confirmNextEpisode.open() + }} + className="w-full" + disabled={submittedNextEpisode} + loading={submittingNextEpisode} + leftIcon={<BiSolidSkipNextCircle className="text-2xl" />} + > + Play next episode + </Button> + </div>} + {!!playlistState?.next && ( + <div data-progress-tracking-playlist className="border rounded-[--radius-md] p-4 text-center relative overflow-hidden"> + <div className="space-y-3"> + <div> + <h4 className="text-lg font-medium text-center text-[--muted] mb-2 uppercase tracking-wide">Playlist</h4> + {!!playlistState.remaining && + <p + data-progress-tracking-playlist-remaining + className="text-[--muted]" + >{playlistState.remaining} episode{playlistState.remaining > 1 ? "s" : ""} after this + one</p>} + <p + data-progress-tracking-playlist-next + className="text-center truncate line-clamp-1" + >Next: <span className="font-semibold">{playlistState?.next?.name}</span> + </p> + </div> + <div + data-progress-tracking-playlist-next-episode-button + className={cn( + "w-full rounded-[--radius-md] relative overflow-hidden", + submittedPlaylistNext ? "opacity-50 pointer-events-none" : "cursor-pointer", + )} + onClick={() => { + if (!submittedPlaylistNext) { + cancelAutoplay() + confirmPlayNext.open() + } + }} + > + {(playlistState.next?.mediaImage) && <Image + data-progress-tracking-playlist-next-episode-button-image + src={playlistState.next?.mediaImage || ""} + placeholder={imageShimmer(700, 475)} + sizes="10rem" + fill + alt="" + className="object-center object-cover z-[1]" + />} + <div + data-progress-tracking-playlist-next-episode-button-container + className="inset-0 relative z-[2] bg-black border bg-opacity-70 hover:bg-opacity-80 transition flex flex-col gap-2 items-center justify-center p-4" + > + <p data-progress-tracking-playlist-next-episode-button-text className="flex gap-2 items-center"> + <BiSolidSkipNextCircle className="block text-2xl" /> Play next</p> + </div> + </div> + <div data-progress-tracking-playlist-next-episode-button-stop-button-container className="absolute -top-0.5 right-2"> + <IconButton + intent="alert-subtle" + onClick={() => { + if (!submittedStopPlaylist) { + cancelAutoplay() + confirmStopPlaylist.open() + } + }} + size="sm" + disabled={submittedPlaylistNext} + loading={submittedStopPlaylist} + icon={<MdCancel />} + /> + </div> + </div> + </div> + )} + </Modal> + + <AutoplayCountdownModal + autoplayState={autoplayState} + onCancel={cancelAutoplay} + /> + + <ConfirmationDialog {...confirmPlayNext} /> + <ConfirmationDialog {...confirmStopPlaylist} /> + <ConfirmationDialog {...confirmNextEpisode} /> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/_components/command-utils.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/_components/command-utils.tsx new file mode 100644 index 0000000..c26ee00 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/_components/command-utils.tsx @@ -0,0 +1,64 @@ +import { AL_BaseAnime, AL_BaseManga } from "@/api/generated/types" +import { imageShimmer } from "@/components/shared/image-helpers" +import { CommandGroup, CommandItem, CommandShortcut } from "@/components/ui/command" +import Image from "next/image" +import { useSeaCommandContext } from "../sea-command" + + +export function CommandItemMedia({ media }: { media: AL_BaseAnime | AL_BaseManga }) { + return ( + <div className="flex gap-3 items-center"> + <div className="size-12 flex-none rounded-[--radius-md] relative overflow-hidden"> + <Image + src={media.coverImage?.medium || ""} + alt="episode image" + fill + className="object-center object-cover" + placeholder={imageShimmer(700, 475)} + /> + </div> + <div className="flex gap-1 items-center w-full"> + <p className="w-full line-clamp-1">{media?.title?.userPreferred || ""}</p> + </div> + </div> + ) +} + +export function CommandHelperText({ command, description, show }: { command: string, description: string, show: boolean }) { + if (!show) return null + return ( + <p className="py-1 px-6 text-center text-sm sm:px-14 tracking-widest text-[--muted]"> + <span className="text-[--foreground]">{command}</span> <span className="tracking-wide">{description}</span> + </p> + ) +} + +export function SeaCommandAutocompleteSuggestions({ + commands, +}: { + commands: { command: string, description: string, show?: boolean }[] +}) { + + const { input, setInput, select, command: { isCommand, command, args }, scrollToTop } = useSeaCommandContext() + + if (input !== "/") return null + + return ( + <> + + <CommandGroup heading="Suggestions"> + {commands.filter(command => command.show === true).map(command => ( + <CommandItem + key={command.command} + onSelect={() => { + setInput(`/${command.command}`) + }} + > + <span className="tracking-widest text-sm">/{command.command}</span> + <CommandShortcut className="text-[--muted]">{command.description}</CommandShortcut> + </CommandItem> + ))} + </CommandGroup> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/config.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/config.tsx new file mode 100644 index 0000000..4cbdbbc --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/config.tsx @@ -0,0 +1,17 @@ +import React from "react" +import { SeaCommandContextProps, useSeaCommandContext } from "./sea-command" + +export type SeaCommandHandlerProps = { + shouldShow: (props: SeaCommandContextProps) => boolean + render: (props: SeaCommandContextProps) => React.ReactNode +} + +export function SeaCommandHandler(props: SeaCommandHandlerProps) { + const { shouldShow, render } = props + + const ctx = useSeaCommandContext() + + if (!shouldShow(ctx)) return null + + return render(ctx) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-actions.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-actions.tsx new file mode 100644 index 0000000..992cc86 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-actions.tsx @@ -0,0 +1,57 @@ +import { __issueReport_overlayOpenAtom, __issueReport_recordingAtom } from "@/app/(main)/_features/issue-report/issue-report" +import { useHandleCopyLatestLogs } from "@/app/(main)/_hooks/logs" +import { CommandGroup, CommandItem, CommandShortcut } from "@/components/ui/command" +import { useSetAtom } from "jotai/react" +import React from "react" +import { useSeaCommandContext } from "./sea-command" + +export function SeaCommandActions() { + + const { input, select, command: { isCommand, command, args }, scrollToTop, close } = useSeaCommandContext() + + const setIssueRecorderOpen = useSetAtom(__issueReport_overlayOpenAtom) + const setIssueRecorderIsRecording = useSetAtom(__issueReport_recordingAtom) + + const { handleCopyLatestLogs } = useHandleCopyLatestLogs() + + return ( + <> + {command === "logs" && ( + <CommandGroup heading="Actions"> + <CommandItem + value="Logs" + onSelect={() => { + select(() => { + handleCopyLatestLogs() + }) + }} + > + Copy current server logs + <CommandShortcut>Enter</CommandShortcut> + </CommandItem> + </CommandGroup> + )} + {command === "issue" && ( + <CommandGroup heading="Actions"> + <CommandItem + value="Issue" + onSelect={() => { + select(() => { + close() + React.startTransition(() => { + setIssueRecorderOpen(true) + setTimeout(() => { + setIssueRecorderIsRecording(true) + }, 500) + }) + }) + }} + > + Record an issue + <CommandShortcut>Enter</CommandShortcut> + </CommandItem> + </CommandGroup> + )} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-injectables.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-injectables.tsx new file mode 100644 index 0000000..b852762 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-injectables.tsx @@ -0,0 +1,84 @@ +import { SeaCommandInjectableItem, useSeaCommandInjectables } from "@/app/(main)/_features/sea-command/use-inject" +import { CommandGroup, CommandItem } from "@/components/ui/command" +import React from "react" +import { useSeaCommandContext } from "./sea-command" + +export function SeaCommandInjectables() { + const ctx = useSeaCommandContext() + const { input, select, scrollToTop } = ctx + const injectables = useSeaCommandInjectables() + + // Group items by heading and sort by priority + const groupedItems = React.useMemo(() => { + const groups: Record<string, SeaCommandInjectableItem[]> = {} + + Object.values(injectables).forEach(injectable => { + if (injectable.shouldShow?.({ ctx }) === false) return + if (!injectable.isCommand && input.startsWith("/")) return + + // const items = injectable.items.filter(item => + // injectable.filter?.(item, input) ?? //Apply custom filter if provided + // item.value.toLowerCase().includes(input.toLowerCase()), + // ).filter(item => // If the item should be rendered based on the input + + + const items = injectable.items + .filter(item => + item.shouldShow?.({ ctx }) ?? true, // Apply custom filter if provided, otherwise don't filter + ).filter(item => + injectable.filter?.({ item, input }) ?? true, // Apply custom filter if provided, otherwise don't filter + ).filter(item => // If the item should be rendered based on the input + item.showBasedOnInput === "includes" ? + item.value.toLowerCase().includes(input.toLowerCase()) : + item.showBasedOnInput === "startsWith" ? + item.value.toLowerCase().startsWith(input.toLowerCase()) : + true, + ).filter(item => // If the group of items should be rendered based on the input + injectable.showBasedOnInput === "includes" ? + item.value.toLowerCase().includes(input.toLowerCase()) : + item.showBasedOnInput === "startsWith" ? + item.value.toLowerCase().startsWith(input.toLowerCase()) : + true, + ) + + items.forEach(item => { + const heading = item.heading || "Custom" + if (!groups[heading]) groups[heading] = [] + groups[heading].push(item) + }) + }) + + // Sort items in each group by priority + Object.keys(groups).forEach(heading => { + groups[heading].sort((a, b) => (b.priority || 0) - (a.priority || 0)) + }) + + // Scroll to top when items are rendered + scrollToTop() + + return groups + }, [injectables, input]) + + if (Object.keys(groupedItems).length === 0) return null + + return ( + <> + {Object.entries(groupedItems).map(([heading, items]) => ( + <CommandGroup key={heading} heading={heading}> + {items.map(item => ( + <CommandItem + key={item.id} + value={item.id} + onSelect={() => { + select(() => item.onSelect({ ctx })) + }} + className="gap-3" + > + {item.render()} + </CommandItem> + ))} + </CommandGroup> + ))} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-list.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-list.tsx new file mode 100644 index 0000000..0bdc853 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-list.tsx @@ -0,0 +1,58 @@ +import { CommandGroup, CommandItem, CommandShortcut } from "@/components/ui/command" +import { useSeaCommandContext } from "./sea-command" + +// renders when "/" is typed +export function SeaCommandList() { + + const { input, setInput, select, command: { isCommand, command, args }, scrollToTop } = useSeaCommandContext() + + const commands = [ + { + command: "anime", + description: "Find in your collection", + show: true, + }, + { + command: "manga", + description: "Find in your collection", + show: true, + }, + { + command: "search", + description: "Search on AniList", + show: true, + }, + { + command: "logs", + description: "Copy the current logs", + show: true, + }, + { + command: "issue", + description: "Record an issue", + show: true, + }, + ] + + const filtered = commands.filter(n => n.show && n.command.startsWith(command) && n.command != command) + + if (!filtered?.length) return null + + return ( + <> + <CommandGroup heading="Autocomplete"> + {filtered.map(command => ( + <CommandItem + key={command.command} + onSelect={() => { + setInput(`/${command.command}`) + }} + > + <span className="tracking-widest text-sm">/{command.command}</span> + <CommandShortcut className="text-[--muted]">{command.description}</CommandShortcut> + </CommandItem> + ))} + </CommandGroup> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-navigation.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-navigation.tsx new file mode 100644 index 0000000..a5d1fdb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-navigation.tsx @@ -0,0 +1,252 @@ +import { useGetAnimeCollection } from "@/api/hooks/anilist.hooks" +import { useGetMangaCollection } from "@/api/hooks/manga.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { CommandGroup, CommandItem, CommandShortcut } from "@/components/ui/command" +import { useRouter } from "next/navigation" +import React from "react" +import { BiArrowBack } from "react-icons/bi" +import { CommandHelperText, CommandItemMedia } from "./_components/command-utils" +import { useSeaCommandContext } from "./sea-command" +import { seaCommand_compareMediaTitles } from "./utils" + +// only rendered when typing "/anime", "/library" or "/manga" +export function SeaCommandUserMediaNavigation() { + + const { input, select, command: { isCommand, command, args }, scrollToTop } = useSeaCommandContext() + const { data: animeCollection, isLoading: isAnimeLoading } = useGetAnimeCollection() // should be available instantly + const { data: mangaCollection, isLoading: isMangaLoading } = useGetMangaCollection() + + const anime = animeCollection?.MediaListCollection?.lists?.flatMap(n => n?.entries)?.filter(Boolean)?.map(n => n.media)?.filter(Boolean) ?? [] + const manga = mangaCollection?.lists?.flatMap(n => n?.entries)?.filter(Boolean)?.map(n => n.media)?.filter(Boolean) ?? [] + + const router = useRouter() + + const query = args.join(" ") + const filteredAnime = (command === "anime" && query.length > 0) ? anime.filter(n => seaCommand_compareMediaTitles(n.title, query)) : [] + const filteredManga = (command === "manga" && query.length > 0) ? manga.filter(n => seaCommand_compareMediaTitles(n.title, query)) : [] + + return ( + <> + {query.length === 0 && ( + <> + <CommandHelperText + command="/anime [title]" + description="Find anime in your collection" + show={command === "anime"} + /> + <CommandHelperText + command="/manga [title]" + description="Find manga in your collection" + show={command === "manga"} + /> + <CommandHelperText + command="/library [title]" + description="Find anime in your library" + show={command === "library"} + /> + </> + )} + + {command === "anime" && filteredAnime.length > 0 && ( + <CommandGroup heading="My anime"> + {filteredAnime.map(n => ( + <CommandItem + key={n.id} + onSelect={() => { + select(() => { + router.push(`/entry?id=${n.id}`) + }) + }} + > + <CommandItemMedia media={n} /> + </CommandItem> + ))} + </CommandGroup> + )} + {command === "manga" && filteredManga.length > 0 && ( + <CommandGroup heading="My manga"> + {filteredManga.map(n => ( + <CommandItem + key={n.id} + onSelect={() => { + select(() => { + router.push(`/manga/entry?id=${n.id}`) + }) + }} + > + <CommandItemMedia media={n} /> + </CommandItem> + ))} + </CommandGroup> + )} + </> + ) +} + +export function SeaCommandNavigation() { + + const serverStatus = useServerStatus() + + const { input, select, command: { isCommand, command, args } } = useSeaCommandContext() + + const router = useRouter() + + const pages = [ + { + name: "My library", + href: "/", + flag: "library", + show: !serverStatus?.isOffline, + }, + { + name: "Schedule", + href: "/schedule", + flag: "schedule", + show: !serverStatus?.isOffline, + }, + { + name: "Settings", + href: "/settings", + flag: "settings", + show: !serverStatus?.isOffline, + }, + { + name: "Manga", + href: "/manga", + flag: "manga", + show: !serverStatus?.isOffline, + }, + { + name: "Discover", + href: "/discover", + flag: "discover", + show: !serverStatus?.isOffline, + }, + { + name: "AniList", + href: "/anilist", + flag: "anilist", + show: !serverStatus?.isOffline, + }, + { + name: "Auto Downloader", + href: "/auto-downloader", + flag: "auto-downloader", + show: !serverStatus?.isOffline, + }, + { + name: "Torrent list", + href: "/torrent-list", + flag: "torrent-list", + show: !serverStatus?.isOffline, + }, + { + name: "Scan summaries", + href: "/scan-summaries", + flag: "scan-summaries", + show: !serverStatus?.isOffline, + }, + { + name: "Extensions", + href: "/extensions", + flag: "extensions", + show: !serverStatus?.isOffline, + }, + { + name: "Advanced search", + href: "/search", + flag: "search", + show: !serverStatus?.isOffline, + }, + ] + + // If no args, show all pages + // If args, show pages that match the args + const filteredPages = pages.filter(page => page.flag.startsWith(command)) + + + // if (!input.startsWith("/")) return null + + + return ( + <> + {command.startsWith("ba") && ( + <CommandGroup heading="Navigation"> + <CommandItem + onSelect={() => { + select(() => { + router.back() + }) + }} + > + <BiArrowBack className="mr-2 h-4 w-4" /> + <span>Go back</span> + </CommandItem> + </CommandGroup> + )} + {command.startsWith("fo") && ( + <CommandGroup heading="Navigation"> + <CommandItem + onSelect={() => { + select(() => { + router.forward() + }) + }} + > + <BiArrowBack className="mr-2 h-4 w-4 rotate-180" /> + <span>Go forward</span> + </CommandItem> + </CommandGroup> + )} + + {/*Typing `/library`, `/schedule`, etc. without args*/} + {isCommand && filteredPages.length > 0 && args.length === 0 && ( + <CommandGroup heading="Screens"> + <> + {filteredPages.filter(page => page.show).map(page => ( + <CommandItem + key={page.flag} + onSelect={() => { + select(() => { + router.push(page.href) + }) + }} + > + <span className="text-sm tracking-wide font-bold text-[--muted]">Go to: </span>{" "}{page.name} + {command === page.flag ? <CommandShortcut>Enter</CommandShortcut> : <CommandShortcut>/{page.flag}</CommandShortcut>} + </CommandItem> + ))} + </> + </CommandGroup> + )} + {(command !== "back" && command !== "forward") && ( + <CommandGroup heading="Navigation"> + {/* {command === "" && ( */} + <> + <CommandItem + onSelect={() => { + select(() => { + router.back() + }) + }} + > + <BiArrowBack className="mr-2 h-4 w-4" /> + <span>Go back</span> + </CommandItem> + <CommandItem + onSelect={() => { + select(() => { + router.forward() + }) + }} + > + <BiArrowBack className="mr-2 h-4 w-4 rotate-180" /> + <span>Go forward</span> + </CommandItem> + </> + {/* )} */} + </CommandGroup> + )} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-search.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-search.tsx new file mode 100644 index 0000000..677cacb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command-search.tsx @@ -0,0 +1,118 @@ +import { useAnilistListAnime } from "@/api/hooks/anilist.hooks" +import { useAnilistListManga } from "@/api/hooks/manga.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { CommandGroup, CommandItem } from "@/components/ui/command" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { useDebounce } from "@/hooks/use-debounce" +import Image from "next/image" +import { useRouter } from "next/navigation" +import React from "react" +import { CommandHelperText, CommandItemMedia } from "./_components/command-utils" +import { useSeaCommandContext } from "./sea-command" + +export function SeaCommandSearch() { + + const serverStatus = useServerStatus() + + const { input, select, scrollToTop, commandListRef, command: { isCommand, command, args } } = useSeaCommandContext() + + const router = useRouter() + + const animeSearchInput = args.join(" ") + const mangaSearchInput = args.slice(1).join(" ") + const type = args[0] !== "manga" ? "anime" : "manga" + + const debouncedQuery = useDebounce(type === "anime" ? animeSearchInput : mangaSearchInput, 500) + + const { data: animeData, isLoading: animeIsLoading, isFetching: animeIsFetching } = useAnilistListAnime({ + search: debouncedQuery, + page: 1, + perPage: 10, + status: ["FINISHED", "CANCELLED", "NOT_YET_RELEASED", "RELEASING"], + sort: ["SEARCH_MATCH"], + }, debouncedQuery.length > 0 && type === "anime") + + const { data: mangaData, isLoading: mangaIsLoading, isFetching: mangaIsFetching } = useAnilistListManga({ + search: debouncedQuery, + page: 1, + perPage: 10, + status: ["FINISHED", "CANCELLED", "NOT_YET_RELEASED", "RELEASING"], + sort: ["SEARCH_MATCH"], + }, debouncedQuery.length > 0 && type === "manga") + + const isLoading = type === "anime" ? animeIsLoading : mangaIsLoading + const isFetching = type === "anime" ? animeIsFetching : mangaIsFetching + + const media = React.useMemo(() => type === "anime" ? animeData?.Page?.media?.filter(Boolean) : mangaData?.Page?.media?.filter(Boolean), + [animeData, mangaData, type]) + + React.useEffect(() => { + const cl = scrollToTop() + return () => cl() + }, [input, isLoading, isFetching]) + + + return ( + <> + {(animeSearchInput === "" && mangaSearchInput === "") ? ( + <> + <CommandHelperText + command="/search [title]" + description="Search anime" + show={true} + /> + <CommandHelperText + command="/search manga [title]" + description="Search manga" + show={true} + /> + </> + ) : ( + + <CommandGroup heading={`${type === "anime" ? "Anime" : "Manga"} results`}> + {(debouncedQuery !== "" && (!media || media.length === 0) && (isLoading || isFetching)) && ( + <LoadingSpinner /> + )} + {debouncedQuery !== "" && !isLoading && !isFetching && (!media || media.length === 0) && ( + <div className="py-14 px-6 text-center text-sm sm:px-14"> + {<div + className="h-[10rem] w-[10rem] mx-auto flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden" + > + <Image + src="/luffy-01.png" + alt={""} + fill + quality={100} + priority + sizes="10rem" + className="object-contain object-top" + /> + </div>} + <h5 className="mt-4 font-semibold text-[--foreground]">Nothing + found</h5> + <p className="mt-2 text-[--muted]"> + We couldn't find anything with that name. Please try again. + </p> + </div> + )} + {media?.map(item => ( + <CommandItem + key={item?.id || ""} + onSelect={() => { + select(() => { + if (type === "anime") { + router.push(`/entry?id=${item.id}`) + } else { + router.push(`/manga/entry?id=${item.id}`) + } + }) + }} + > + <CommandItemMedia media={item} /> + </CommandItem> + ))} + </CommandGroup> + )} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command.tsx new file mode 100644 index 0000000..9485722 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/sea-command.tsx @@ -0,0 +1,227 @@ +import { SeaCommandActions } from "@/app/(main)/_features/sea-command/sea-command-actions" +import { SeaCommandSearch } from "@/app/(main)/_features/sea-command/sea-command-search" +import { SeaCommand_ParsedCommandProps, useSeaCommand_ParseCommand } from "@/app/(main)/_features/sea-command/utils" +import { CommandDialog, CommandInput, CommandList } from "@/components/ui/command" +import { atom } from "jotai" +import { useAtom, useSetAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import mousetrap from "mousetrap" +import { usePathname, useRouter } from "next/navigation" +import React from "react" +import { SeaCommandHandler } from "./config" +import { SeaCommandInjectables } from "./sea-command-injectables" +import { SeaCommandList } from "./sea-command-list" +import { SeaCommandNavigation, SeaCommandUserMediaNavigation } from "./sea-command-navigation" + +export const __seaCommand_shortcuts = atomWithStorage<string[]>("sea-command-shortcuts", ["meta+j", "q"], undefined, { getOnInit: true }) + +export type SeaCommandContextProps = { + input: string + setInput: (input: string) => void + resetInput: () => void + close: () => void + select: (func?: () => void) => void + command: SeaCommand_ParsedCommandProps + scrollToTop: () => () => void + commandListRef: React.RefObject<HTMLDivElement> + router: { + pathname: string + } +} + +export const SeaCommandContext = React.createContext<SeaCommandContextProps>({ + input: "", + setInput: () => { }, + resetInput: () => { }, + close: () => { }, + select: () => { }, + command: { command: "", isCommand: false, args: [] }, + scrollToTop: () => () => { }, + commandListRef: React.createRef<HTMLDivElement>(), + router: { + pathname: "", + }, +}) + +export function useSeaCommandContext() { + return React.useContext(SeaCommandContext) as SeaCommandContextProps +} + +const __seaCommand_isOpen = atom(false) + +export function useOpenSeaCommand() { + const setOpen = useSetAtom(__seaCommand_isOpen) + return { + setSeaCommandOpen: setOpen, + } +} + +export function SeaCommand() { + + const router = useRouter() + const pathname = usePathname() + + const [open, setOpen] = useAtom(__seaCommand_isOpen) + const [input, setInput] = React.useState("") + const [activeItemId, setActiveItemId] = React.useState("") + + const [shortcuts, setShortcuts] = useAtom(__seaCommand_shortcuts) + + const parsedCommandProps = useSeaCommand_ParseCommand(input) + + React.useEffect(() => { + mousetrap.bind(shortcuts, () => { + setInput("") + React.startTransition(() => { + setOpen(true) + }) + }) + + return () => { + mousetrap.unbind(shortcuts) + } + }, [shortcuts]) + + React.useEffect(() => { + if (!open) setInput("") + }, [open]) + + React.useEffect(() => { + mousetrap.bind(["s"], () => { + setOpen(true) + React.startTransition(() => { + setTimeout(() => { + setInput("/search ") + }, 100) + }) + }) + + return () => { + mousetrap.unbind(["s"]) + } + }, []) + + const commandListRef = React.useRef<HTMLDivElement>(null) + + function scrollToTop() { + const list = commandListRef.current + if (!list) return () => {} + + const t = setTimeout(() => { + list.scrollTop = 0 + // Find and focus the first command item + const firstItem = list.querySelector("[cmdk-item]") as HTMLElement + if (firstItem) { + const value = firstItem.getAttribute("data-value") + if (value) { + setActiveItemId(value) + } + } + }, 100) + + return () => clearTimeout(t) + } + + React.useEffect(() => { + const cl = scrollToTop() + return () => cl() + }, [input, pathname]) + + return ( + <SeaCommandContext.Provider + value={{ + input: input, + setInput: setInput, + resetInput: () => setInput(""), + close: () => { + React.startTransition(() => { + setOpen(false) + }) + }, + select: (func?: () => void) => { + func?.() + setInput("") + }, + scrollToTop, + command: parsedCommandProps, + commandListRef: commandListRef, + router: { + pathname: pathname, + }, + }} + > + <CommandDialog + open={open} + onOpenChange={setOpen} + commandProps={{ + value: activeItemId, + onValueChange: setActiveItemId, + }} + overlayClass="bg-black/30" + contentClass="max-w-2xl" + commandClass="h-[300px]" + > + + <CommandInput + placeholder="Type a command or input..." + value={input} + onValueChange={setInput} + /> + <CommandList className="mb-2" ref={commandListRef}> + + {/*Active commands*/} + <SeaCommandHandler + shouldShow={ctx => ctx.command.command === "search"} + render={() => <SeaCommandSearch />} + /> + <SeaCommandHandler + shouldShow={ctx => ( + ctx.command.command === "anime" + || ctx.command.command === "manga" + // || ctx.command.command === "library" + )} + render={() => <SeaCommandUserMediaNavigation />} + /> + <SeaCommandHandler + shouldShow={ctx => ( + ctx.command.command === "logs" + || ctx.command.command === "issue" + )} + render={() => <SeaCommandActions />} + /> + + {/*Injected items*/} + <SeaCommandHandler + shouldShow={() => true} + render={() => <SeaCommandInjectables />} + /> + + {/*Page items*/} + {/* <SeaCommandHandler + type="anime-entry" + shouldShow={ctx => !ctx.command.isCommand && ctx.params.page === "anime-entry"} + render={() => <SeaCommandAnimeEntry />} + /> */} + {/* <SeaCommandHandler + type="anime-library" + shouldShow={ctx => !ctx.command.isCommand && ctx.params.page === "anime-library"} + render={() => <SeaCommandAnimeLibrary />} + /> */} + + {/*Suggestions*/} + <SeaCommandHandler + shouldShow={ctx => ctx.input.startsWith("/")} + render={() => <SeaCommandList />} + /> + + {/*Navigation*/} + <SeaCommandHandler + shouldShow={() => true} + render={() => <SeaCommandNavigation />} + /> + + </CommandList> + </CommandDialog> + </SeaCommandContext.Provider> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/use-inject.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/use-inject.ts new file mode 100644 index 0000000..ba97e62 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/use-inject.ts @@ -0,0 +1,89 @@ +import { SeaCommandContextProps } from "@/app/(main)/_features/sea-command/sea-command" +import { useAtomValue } from "jotai" +import { atom, useSetAtom } from "jotai/index" +import React from "react" + +export type SeaCommandInjectableItem = { + id: string // Unique identifier for the item + value: string // Value used for filtering/searching + heading?: string // Optional group heading + priority?: number // Priority of the item (higher = shown first) (used to sort items in a group) + render: () => React.ReactNode // Render function for the item + onSelect: (props: { ctx: SeaCommandContextProps }) => void // What happens when item is selected + shouldShow?: (props: { ctx: SeaCommandContextProps }) => boolean + showBasedOnInput?: "startsWith" | "includes" // Optional automatic filtered based on the item value and input + data?: any +} + +export type SeaCommandInjectable = { + items: SeaCommandInjectableItem[] + filter?: (props: { item: SeaCommandInjectableItem, input: string }) => boolean // Custom filter function + shouldShow?: (props: { ctx: SeaCommandContextProps }) => boolean // When to show these items + isCommand?: boolean + showBasedOnInput?: "startsWith" | "includes" // Optional automatic filtered based on the item value and input + priority?: number // Priority of the items (used to sort groups) +} + +const injectablesAtom = atom<Record<string, SeaCommandInjectable>>({}) +// useSeaCommandInject +// Example: +// const { inject, remove } = useSeaCommandInject() +// React.useEffect(() => { +// inject("continue-watching", { +// items: episodes.map(episode => ({ +// data: episode, +// id: `${episode.type}-${episode.localFile?.path || ""}-${episode.episodeNumber}`, +// value: `${episode.episodeNumber}`, +// heading: "Continue Watching", +// })), +// priority: 100, +// }) +// +// return () => { +// remove("continue-watching") +// } +// }, [episodes]) + +export function useSeaCommandInjectables() { + return useAtomValue(injectablesAtom) +} + +export function useSeaCommandInject() { + const setInjectables = useSetAtom(injectablesAtom) + + const inject = (key: string, injectable: SeaCommandInjectable) => { + // setInjectables(prev => ({ + // ...prev, + // [key]: injectable, + // })) + // Add to injectables based on priority + setInjectables(prev => { + // Transform into an array of items + const items = Object.keys(prev).map(key => ({ injectable: prev[key], key })) + // Add the new injectable to the array + items.push({ injectable, key }) + // Sort the items by priority + items.sort((a, b) => (b.injectable.priority || 0) - (a.injectable.priority || 0)) + // Transform back into an object + const ret = items.reduce((acc, item) => { + acc[item.key] = item.injectable + return acc + }, {} as Record<string, SeaCommandInjectable>) + return ret + }) + } + + const remove = (key: string) => { + setInjectables(prev => { + const next = { ...prev } + delete next[key] + return next + }) + } + + return { + inject, + remove, + } + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/utils.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/utils.ts new file mode 100644 index 0000000..25623c2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-command/utils.ts @@ -0,0 +1,27 @@ +import { AL_BaseAnime_Title, AL_BaseManga_Title } from "@/api/generated/types" +import { Nullish } from "@/types/common" + +export function useSeaCommand_ParseCommand(input: string) { + const [command, ...args] = input.split(/\s+/) + + const ret = { + isCommand: input.startsWith("/"), + command: command.slice(1), + args: args, + } + + return ret +} + +export type SeaCommand_ParsedCommandProps = ReturnType<typeof useSeaCommand_ParseCommand> + +export function seaCommand_compareMediaTitles(titles: Nullish<AL_BaseAnime_Title | AL_BaseManga_Title>, query: string) { + if (!titles) return false + return (!!titles.english && cleanMediaTitle(titles.english).includes(cleanMediaTitle(query))) + || (!!titles.romaji && cleanMediaTitle(titles.romaji).includes(cleanMediaTitle(query))) +} + +function cleanMediaTitle(str: string) { + // remove all non-alphanumeric characters + return str.replace(/[^a-zA-Z0-9 ]/g, "").toLowerCase() +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/aniskip.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/aniskip.ts new file mode 100644 index 0000000..4e775ec --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/aniskip.ts @@ -0,0 +1,56 @@ +import { useQuery } from "@tanstack/react-query" + +/* ------------------------------------------------------------------------------------------------- + * @link https://github.com/lexesjan/typescript-aniskip-extension/blob/main/src/api/aniskip-http-client/aniskip-http-client.types.ts + * -----------------------------------------------------------------------------------------------*/ + +export const SKIP_TYPE_NAMES: Record<AniSkipType, string> = { + op: "Opening", + ed: "Ending", + "mixed-op": "Mixed opening", + "mixed-ed": "Mixed ending", + recap: "Recap", +} as const + +export const SKIP_TYPES = [ + "op", + "ed", + "mixed-op", + "mixed-ed", + "recap", +] as const + +export type AniSkipType = (typeof SKIP_TYPES)[number] + +export type AniSkipTime = { + interval: { + startTime: number + endTime: number + } + skipType: AniSkipType + skipId: string + episodeLength: number +} + +export function useSkipData(mediaMalId: number | null | undefined, episodeNumber: number) { + const res = useQuery({ + queryKey: ["skip-data", mediaMalId, episodeNumber], + queryFn: async () => { + const result = await fetch( + `https://api.aniskip.com/v2/skip-times/${mediaMalId}/${episodeNumber}?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=`, + ) + const skip = (await result.json()) as { + found: boolean, + results: AniSkipTime[] + } + if (!!skip.results && skip.found) return { + op: skip.results?.find((item) => item.skipType === "op") || null, + ed: skip.results?.find((item) => item.skipType === "ed") || null, + } + return { op: null, ed: null } + }, + refetchOnWindowFocus: false, + enabled: !!mediaMalId && episodeNumber != -1, + }) + return { data: res.data, isLoading: res.isLoading || res.isFetching, isError: res.isError } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/hooks.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/hooks.ts new file mode 100644 index 0000000..4b793ee --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/hooks.ts @@ -0,0 +1,16 @@ +import { isMobile } from "@/lib/utils/browser-detection" + +export function useIsCodecSupported() { + const isCodecSupported = (codec: string) => { + if (isMobile()) return false + if (navigator.userAgent.search("Firefox") === -1) + codec = codec.replace("video/x-matroska", "video/mp4") + const videos = document.getElementsByTagName("video") + const video = videos.item(0) ?? document.createElement("video") + return video.canPlayType(codec) === "probably" + } + + return { + isCodecSupported, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/macos-tauri-fullscreen.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/macos-tauri-fullscreen.tsx new file mode 100644 index 0000000..20343a6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/macos-tauri-fullscreen.tsx @@ -0,0 +1,64 @@ +import { __isDesktop__ } from "@/types/constants" +import { MediaEnterFullscreenRequestEvent, MediaFullscreenRequestTarget, MediaPlayerInstance } from "@vidstack/react" +import React from "react" + +export function useFullscreenHandler(playerRef: React.RefObject<MediaPlayerInstance>) { + + React.useEffect(() => { + let unlisten: any | null = null + if ((window as any)?.__TAURI__) { + const currentWindow: any | undefined = (window as any)?.__TAURI__?.window?.getCurrentWindow?.() + if (currentWindow) { + (async () => { + unlisten = await currentWindow.listen("macos-activation-policy-accessory-done", () => { + console.log("macos policy accessory event done") + try { + console.log("requesting fullscreen") + playerRef.current?.enterFullscreen() + } + catch (e) { + console.log("failed to enter fullscreen from 'macos-activation-policy-accessory-done'", e) + } + }) + })() + } + } + return () => { + unlisten?.() + } + }, []) + + function onMediaEnterFullscreenRequest(detail: MediaFullscreenRequestTarget, nativeEvent: MediaEnterFullscreenRequestEvent) { + if (__isDesktop__) { + try { + if ((window as any)?.__TAURI__) { + const platform: string | undefined = (window as any)?.__TAURI__?.os?.platform?.() + const currentWindow: any | undefined = (window as any)?.__TAURI__?.window?.getCurrentWindow?.() + if (!!platform && platform === "macos") { + nativeEvent.preventDefault() + console.log("native fullscreen event prevented, sending macos policy accessory event") + currentWindow.emit("macos-activation-policy-accessory").then(() => { + console.log("macos policy accessory event sent") + if (nativeEvent.defaultPrevented) { + // console.log("requesting fullscreen") + try { + // playerRef.current?.enterFullscreen() + } + catch (e) { + console.log("failed to enter fullscreen from onMediaEnterFullscreenRequest", e) + } + } + }) + } + } + } + catch { + + } + } + } + + return { + onMediaEnterFullscreenRequest, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player-components.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player-components.tsx new file mode 100644 index 0000000..4d50bb7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player-components.tsx @@ -0,0 +1,93 @@ +import { submenuClass, VdsSubmenuButton } from "@/app/(main)/onlinestream/_components/onlinestream-video-addons" +import { Switch } from "@/components/ui/switch" +import { Menu } from "@vidstack/react" +import { useAtom } from "jotai/react" +import React from "react" +import { AiFillPlayCircle } from "react-icons/ai" +import { MdPlaylistPlay } from "react-icons/md" +import { RxSlider } from "react-icons/rx" +import { + __seaMediaPlayer_autoNextAtom, + __seaMediaPlayer_autoPlayAtom, + __seaMediaPlayer_autoSkipIntroOutroAtom, + __seaMediaPlayer_discreteControlsAtom, +} from "./sea-media-player.atoms" + +export function SeaMediaPlayerPlaybackSubmenu() { + + const [autoPlay, setAutoPlay] = useAtom(__seaMediaPlayer_autoPlayAtom) + const [autoNext, setAutoNext] = useAtom(__seaMediaPlayer_autoNextAtom) + const [autoSkipIntroOutro, setAutoSkipIntroOutro] = useAtom(__seaMediaPlayer_autoSkipIntroOutroAtom) + const [discreteControls, setDiscreteControls] = useAtom(__seaMediaPlayer_discreteControlsAtom) + + return ( + <> + <Menu.Root> + <VdsSubmenuButton + label={`Auto Play`} + hint={autoPlay ? "On" : "Off"} + disabled={false} + icon={AiFillPlayCircle} + /> + <Menu.Content className={submenuClass}> + <Switch + label="Auto play" + fieldClass="py-2 px-2" + value={autoPlay} + onValueChange={setAutoPlay} + /> + </Menu.Content> + </Menu.Root> + <Menu.Root> + <VdsSubmenuButton + label={`Auto Play Next Episode`} + hint={autoNext ? "On" : "Off"} + disabled={false} + icon={MdPlaylistPlay} + /> + <Menu.Content className={submenuClass}> + <Switch + label="Auto play next episode" + fieldClass="py-2 px-2" + value={autoNext} + onValueChange={setAutoNext} + /> + </Menu.Content> + </Menu.Root> + <Menu.Root> + <VdsSubmenuButton + label={`Skip Intro/Outro`} + hint={autoSkipIntroOutro ? "On" : "Off"} + disabled={false} + icon={MdPlaylistPlay} + /> + <Menu.Content className={submenuClass}> + <Switch + label="Skip intro/outro" + fieldClass="py-2 px-2" + value={autoSkipIntroOutro} + onValueChange={setAutoSkipIntroOutro} + /> + </Menu.Content> + </Menu.Root> + <Menu.Root> + <VdsSubmenuButton + label={`Discrete Controls`} + hint={discreteControls ? "On" : "Off"} + disabled={false} + icon={RxSlider} + /> + <Menu.Content className={submenuClass}> + <Switch + label="Discrete controls" + help="Only show the controls when the mouse is over the bottom part. (Large screens only)" + fieldClass="py-2 px-2" + value={discreteControls} + onValueChange={setDiscreteControls} + fieldHelpTextClass="max-w-xs" + /> + </Menu.Content> + </Menu.Root> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player-layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player-layout.tsx new file mode 100644 index 0000000..9fb2969 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player-layout.tsx @@ -0,0 +1,184 @@ +import { useUpdateAnimeEntryProgress } from "@/api/hooks/anime_entries.hooks" +import { + __seaMediaPlayer_scopedCurrentProgressAtom, + __seaMediaPlayer_scopedProgressItemAtom, + useSeaMediaPlayer, +} from "@/app/(main)/_features/sea-media-player/sea-media-player-provider" +import { SeaLink } from "@/components/shared/sea-link" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { useAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import React from "react" +import { AiOutlineArrowLeft } from "react-icons/ai" +import { TbLayoutSidebarRightCollapse, TbLayoutSidebarRightExpand } from "react-icons/tb" +import { useWindowSize } from "react-use" + +const theaterModeAtom = atomWithStorage("sea-media-theater-mode", false) + +export type SeaMediaPlayerLayoutProps = { + mediaId?: string | number + title?: string + hideBackButton?: boolean + leftHeaderActions?: React.ReactNode + rightHeaderActions?: React.ReactNode + mediaPlayer: React.ReactNode + episodeList: React.ReactNode + episodes: any[] | undefined + loading?: boolean +} + +export function SeaMediaPlayerLayout(props: SeaMediaPlayerLayoutProps) { + const { + mediaId, + title, + hideBackButton, + leftHeaderActions, + rightHeaderActions, + mediaPlayer, + episodeList, + episodes, + loading, + } = props + + const [theaterMode, setTheaterMode] = useAtom(theaterModeAtom) + const { media, progress } = useSeaMediaPlayer() + const [currentProgress, setCurrentProgress] = useAtom(__seaMediaPlayer_scopedCurrentProgressAtom) + const [progressItem, setProgressItem] = useAtom(__seaMediaPlayer_scopedProgressItemAtom) + + /** Progress update **/ + const { mutate: updateProgress, isPending: isUpdatingProgress, isSuccess: hasUpdatedProgress } = useUpdateAnimeEntryProgress( + media?.id, + currentProgress, + ) + + const { width } = useWindowSize() + + /** Scroll to selected episode element when the episode list changes (on mount) **/ + const episodeListContainerRef = React.useRef<HTMLDivElement>(null) + const scrollTimeoutRef = React.useRef<NodeJS.Timeout>() + + React.useEffect(() => { + if (!episodeListContainerRef.current || width <= 1536 || !progress.currentEpisodeNumber) return + + // Clear any existing timeout + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current) + } + + // Set a new timeout to scroll after a brief delay + scrollTimeoutRef.current = setTimeout(() => { + let element = document.getElementById(`episode-${progress.currentEpisodeNumber}`) + if (theaterMode) { + element = document.getElementById("sea-media-player-container") + } + if (element) { + element.scrollIntoView({ behavior: "smooth" }) + } + }, 100) + + // Cleanup + return () => { + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current) + } + } + }, [width, episodes, loading, progress.currentEpisodeNumber, theaterMode]) + + const handleProgressUpdate = React.useCallback(() => { + if (!media || !progressItem || isUpdatingProgress || hasUpdatedProgress) return + + updateProgress({ + episodeNumber: progressItem.episodeNumber, + mediaId: media.id, + totalEpisodes: media.episodes || 0, + malId: media.idMal || undefined, + }, { + onSuccess: () => { + setProgressItem(null) + setCurrentProgress(progressItem.episodeNumber) + }, + }) + }, [media, progressItem, isUpdatingProgress, hasUpdatedProgress]) + + return ( + <div data-sea-media-player-layout className="space-y-4"> + <div data-sea-media-player-layout-header className="flex flex-col lg:flex-row gap-2 w-full justify-between"> + {!hideBackButton && <div className="flex w-full gap-4 items-center relative"> + <SeaLink href={`/entry?id=${mediaId}`}> + <IconButton icon={<AiOutlineArrowLeft />} rounded intent="gray-outline" size="sm" /> + </SeaLink> + <h3 className="max-w-full lg:max-w-[50%] text-ellipsis truncate">{title}</h3> + </div>} + + <div data-sea-media-player-layout-header-actions className="flex flex-wrap gap-2 items-center lg:justify-end w-full"> + {leftHeaderActions} + <div className="flex flex-1"></div> + {(!!progressItem && progressItem.episodeNumber > currentProgress) && ( + <Button + className="animate-pulse" + loading={isUpdatingProgress} + disabled={hasUpdatedProgress} + onClick={handleProgressUpdate} + > + Update progress + </Button> + )} + {rightHeaderActions} + <IconButton + onClick={() => setTheaterMode(p => !p)} + intent="gray-basic" + icon={theaterMode ? <TbLayoutSidebarRightExpand /> : <TbLayoutSidebarRightCollapse />} + /> + </div> + </div> + + {!(loading === false) ? <div + data-sea-media-player-layout-content + className={cn( + "flex gap-4 w-full flex-col 2xl:flex-row", + theaterMode && "block space-y-4", + )} + > + <div + id="sea-media-player-container" + data-sea-media-player-layout-content-player + className={cn( + "aspect-video relative w-full self-start mx-auto", + theaterMode && "max-h-[90vh] !w-auto aspect-video mx-auto", + )} + > + {mediaPlayer} + </div> + + <ScrollArea + ref={episodeListContainerRef} + data-sea-media-player-layout-content-episode-list + className={cn( + "2xl:max-w-[450px] w-full relative 2xl:sticky h-[75dvh] overflow-y-auto pr-4 pt-0 -mt-3", + theaterMode && "2xl:max-w-full", + )} + > + <div data-sea-media-player-layout-content-episode-list-container className="space-y-3"> + {episodeList} + </div> + <div + data-sea-media-player-layout-content-episode-list-bottom-gradient + className={"z-[5] absolute bottom-0 w-full h-[2rem] bg-gradient-to-t from-[--background] to-transparent"} + /> + </ScrollArea> + </div> : <div + className="grid 2xl:grid-cols-[1fr,450px] gap-4 xl:gap-4" + > + <div className="w-full min-h-[70dvh] relative"> + <Skeleton className="h-full w-full absolute" /> + </div> + + <Skeleton className="hidden 2xl:block relative h-[78dvh] overflow-y-auto pr-4 pt-0" /> + + </div>} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player-provider.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player-provider.tsx new file mode 100644 index 0000000..bdb6994 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player-provider.tsx @@ -0,0 +1,48 @@ +import { AL_BaseAnime } from "@/api/generated/types" +import { atom } from "jotai" +import { ScopeProvider } from "jotai-scope" +import React, { createContext, useContext } from "react" + +type MediaPlayerProviderProps = { + media: AL_BaseAnime | null + progress: { + currentProgress: number | null + currentEpisodeNumber: number | null + currentEpisodeTitle: string | null + } +} +type MediaPlayerState = {} & MediaPlayerProviderProps + +type ProgressItem = { + episodeNumber: number +} + +export const __seaMediaPlayer_scopedProgressItemAtom = atom<ProgressItem | null>(null) +export const __seaMediaPlayer_scopedCurrentProgressAtom = atom<number>(0) + +const MediaPlayerContext = createContext<MediaPlayerState>({ + media: null, + progress: { currentProgress: null, currentEpisodeNumber: null, currentEpisodeTitle: null }, +}) + +export function SeaMediaPlayerProvider({ children, ...providerProps }: { children?: React.ReactNode } & MediaPlayerProviderProps) { + + return ( + <MediaPlayerContext.Provider + value={{ + ...providerProps, + }} + > + <ScopeProvider atoms={[__seaMediaPlayer_scopedProgressItemAtom, __seaMediaPlayer_scopedCurrentProgressAtom]}> + {children} + </ScopeProvider> + </MediaPlayerContext.Provider> + ) +} + + +export function useSeaMediaPlayer() { + return useContext(MediaPlayerContext) +} + + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player.atoms.ts new file mode 100644 index 0000000..d24b939 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player.atoms.ts @@ -0,0 +1,15 @@ +import { atomWithStorage } from "jotai/utils" + +export const __seaMediaPlayer_autoPlayAtom = atomWithStorage("sea-media-player-autoplay", false, undefined, { getOnInit: true }) + +export const __seaMediaPlayer_autoNextAtom = atomWithStorage("sea-media-player-autonext", false, undefined, { getOnInit: true }) + +export const __seaMediaPlayer_autoSkipIntroOutroAtom = atomWithStorage("sea-media-player-autoskip-intro-outro", false, undefined, { getOnInit: true }) + +export const __seaMediaPlayer_discreteControlsAtom = atomWithStorage("sea-media-player-discrete-controls", false, undefined, { getOnInit: true }) + +export const __seaMediaPlayer_volumeAtom = atomWithStorage("sea-media-player-volume", 1, undefined, { getOnInit: true }) + +export const __seaMediaPlayer_mutedAtom = atomWithStorage("sea-media-player-muted", false, undefined, { getOnInit: true }) + +export const __seaMediaPlayer_playbackRateAtom = atomWithStorage("sea-media-playback-rate", 1, undefined, { getOnInit: true }) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player.tsx new file mode 100644 index 0000000..a47ef1a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/sea-media-player/sea-media-player.tsx @@ -0,0 +1,652 @@ +import { useUpdateAnimeEntryProgress } from "@/api/hooks/anime_entries.hooks" +import { useHandleContinuityWithMediaPlayer, useHandleCurrentMediaContinuity } from "@/api/hooks/continuity.hooks" +import { + useCancelDiscordActivity, + useSetDiscordAnimeActivityWithProgress, + useUpdateDiscordAnimeActivityWithProgress, +} from "@/api/hooks/discord.hooks" + +import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject" +import { useSkipData } from "@/app/(main)/_features/sea-media-player/aniskip" +import { useFullscreenHandler } from "@/app/(main)/_features/sea-media-player/macos-tauri-fullscreen" +import { SeaMediaPlayerPlaybackSubmenu } from "@/app/(main)/_features/sea-media-player/sea-media-player-components" +import { + __seaMediaPlayer_scopedCurrentProgressAtom, + __seaMediaPlayer_scopedProgressItemAtom, + useSeaMediaPlayer, +} from "@/app/(main)/_features/sea-media-player/sea-media-player-provider" +import { + __seaMediaPlayer_autoNextAtom, + __seaMediaPlayer_autoPlayAtom, + __seaMediaPlayer_autoSkipIntroOutroAtom, + __seaMediaPlayer_discreteControlsAtom, + __seaMediaPlayer_mutedAtom, + __seaMediaPlayer_volumeAtom, +} from "@/app/(main)/_features/sea-media-player/sea-media-player.atoms" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { LuffyError } from "@/components/shared/luffy-error" +import { vidstackLayoutIcons } from "@/components/shared/vidstack" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Skeleton } from "@/components/ui/skeleton" +import { logger } from "@/lib/helpers/debug" +import { __isDesktop__ } from "@/types/constants" +import { + MediaCanPlayDetail, + MediaCanPlayEvent, + MediaDurationChangeEvent, + MediaEndedEvent, + MediaFullscreenChangeEvent, + MediaPlayer, + MediaPlayerInstance, + MediaProvider, + MediaProviderAdapter, + MediaProviderChangeEvent, + MediaProviderSetupEvent, + MediaTimeUpdateEvent, + MediaTimeUpdateEventDetail, + Track, + type TrackProps, +} from "@vidstack/react" +import { DefaultVideoLayout, DefaultVideoLayoutProps } from "@vidstack/react/player/layouts/default" +import { useAtomValue } from "jotai" +import { useAtom } from "jotai/react" +import capitalize from "lodash/capitalize" +import mousetrap from "mousetrap" +import Image from "next/image" +import React from "react" + +export type SeaMediaPlayerProps = { + url?: string | { src: string, type: string } + poster?: string + isLoading?: boolean + isPlaybackError?: string + playerRef: React.RefObject<MediaPlayerInstance> + onProviderChange?: (provider: MediaProviderAdapter | null, e: MediaProviderChangeEvent) => void + onProviderSetup?: (provider: MediaProviderAdapter, e: MediaProviderSetupEvent) => void + onTimeUpdate?: (detail: MediaTimeUpdateEventDetail, e: MediaTimeUpdateEvent) => void + onCanPlay?: (detail: MediaCanPlayDetail, e: MediaCanPlayEvent) => void + onEnded?: (e: MediaEndedEvent) => void + onDurationChange?: (detail: number, e: MediaDurationChangeEvent) => void + tracks?: TrackProps[] + chapters?: ChapterProps[] + videoLayoutSlots?: Omit<DefaultVideoLayoutProps["slots"], "settingsMenuEndItems"> + settingsItems?: React.ReactElement + loadingText?: React.ReactNode + onGoToNextEpisode: () => void + onGoToPreviousEpisode?: () => void + mediaInfoDuration?: number +} + +type ChapterProps = { + title: string + startTime: number + endTime: number +} + +export function SeaMediaPlayer(props: SeaMediaPlayerProps) { + const { + url, + poster, + isLoading, + isPlaybackError, + playerRef, + tracks = [], + chapters = [], + videoLayoutSlots, + loadingText, + onCanPlay: _onCanPlay, + onProviderChange: _onProviderChange, + onProviderSetup: _onProviderSetup, + onEnded: _onEnded, + onTimeUpdate: _onTimeUpdate, + onDurationChange: _onDurationChange, + onGoToNextEpisode, + onGoToPreviousEpisode, + settingsItems, + mediaInfoDuration, + } = props + + const serverStatus = useServerStatus() + + const [duration, setDuration] = React.useState(0) + + const { media, progress } = useSeaMediaPlayer() + + const [trueDuration, setTrueDuration] = React.useState(0) + React.useEffect(() => { + if (!mediaInfoDuration) return + const durationFixed = mediaInfoDuration || 0 + if (durationFixed) { + setTrueDuration(durationFixed) + } + }, [mediaInfoDuration]) + + const [progressItem, setProgressItem] = useAtom(__seaMediaPlayer_scopedProgressItemAtom) // scoped + + const autoPlay = useAtomValue(__seaMediaPlayer_autoPlayAtom) + const autoNext = useAtomValue(__seaMediaPlayer_autoNextAtom) + const discreteControls = useAtomValue(__seaMediaPlayer_discreteControlsAtom) + const autoSkipIntroOutro = useAtomValue(__seaMediaPlayer_autoSkipIntroOutroAtom) + const [volume, setVolume] = useAtom(__seaMediaPlayer_volumeAtom) + const [muted, setMuted] = useAtom(__seaMediaPlayer_mutedAtom) + + // Store the updated progress + const [currentProgress, setCurrentProgress] = useAtom(__seaMediaPlayer_scopedCurrentProgressAtom) + React.useEffect(() => { + setCurrentProgress(progress.currentProgress ?? 0) + }, [progress.currentProgress]) + + const [showSkipIntroButton, setShowSkipIntroButton] = React.useState(false) + const [showSkipEndingButton, setShowSkipEndingButton] = React.useState(false) + + const watchHistoryRef = React.useRef<number>(0) + const checkTimeRef = React.useRef<number>(0) + const canPlayRef = React.useRef<boolean>(false) + const previousUrlRef = React.useRef<string | { src: string, type: string } | undefined>(undefined) + const wasFullscreenBeforeNextEpisodeRef = React.useRef(false) + const currentFullscreenStateRef = React.useRef(false) + + // Track last focused element + const lastFocusedElementRef = React.useRef<HTMLElement | null>(null) + + /** AniSkip **/ + const { data: aniSkipData } = useSkipData(media?.idMal, progress.currentEpisodeNumber ?? -1) + + /** Progress update **/ + const { mutate: updateProgress, isPending: isUpdatingProgress, isSuccess: hasUpdatedProgress } = useUpdateAnimeEntryProgress( + media?.id, + currentProgress, + ) + + const onDurationChange = React.useCallback((detail: number, e: MediaDurationChangeEvent) => { + _onDurationChange?.(detail, e) + + setDuration(detail) + }, []) + + /** + * Continuity + */ + const { handleUpdateWatchHistory } = useHandleContinuityWithMediaPlayer(playerRef, progress.currentEpisodeNumber, media?.id) + + /** + * Discord Rich Presence + */ + // const { mutate: setAnimeDiscordActivity } = useSetDiscordLegacyAnimeActivity() + const { mutate: setAnimeDiscordActivity } = useSetDiscordAnimeActivityWithProgress() + const { mutate: updateAnimeDiscordActivity } = useUpdateDiscordAnimeActivityWithProgress() + const { mutate: cancelDiscordActivity } = useCancelDiscordActivity() + + // useInterval(() => { + // if(!playerRef.current) return + + // if (serverStatus?.settings?.discord?.enableRichPresence && serverStatus?.settings?.discord?.enableAnimeRichPresence) { + // updateAnimeDiscordActivity({ + // progress: Math.floor(playerRef.current?.currentTime ?? 0), + // duration: Math.floor(playerRef.current?.duration ?? 0), + // paused: playerRef.current?.paused ?? false, + // }) + // } + // }, 6000) + React.useEffect(() => { + const interval = setInterval(() => { + if (!playerRef.current || !canPlayRef.current) return + + if (serverStatus?.settings?.discord?.enableRichPresence && serverStatus?.settings?.discord?.enableAnimeRichPresence) { + updateAnimeDiscordActivity({ + progress: Math.floor(playerRef.current?.currentTime ?? 0), + duration: Math.floor(playerRef.current?.duration ?? 0), + paused: playerRef.current?.paused ?? false, + }) + } + }, 6000) + + return () => clearInterval(interval) + }, [serverStatus?.settings?.discord, url, canPlayRef.current]) + + React.useEffect(() => { + if (previousUrlRef.current === url) return + previousUrlRef.current = url + + // Reset the canPlayRef when the url changes + canPlayRef.current = false + }, [url]) + + const onTimeUpdate = (detail: MediaTimeUpdateEventDetail, e: MediaTimeUpdateEvent) => { // let React compiler optimize + _onTimeUpdate?.(detail, e) + + /** + * AniSkip + */ + if ( + aniSkipData?.op?.interval && + !!detail?.currentTime && + detail?.currentTime >= aniSkipData.op.interval.startTime && + detail?.currentTime <= aniSkipData.op.interval.endTime + ) { + setShowSkipIntroButton(true) + if (autoSkipIntroOutro) { + seekTo(aniSkipData?.op?.interval?.endTime || 0) + } + } else { + setShowSkipIntroButton(false) + } + if ( + aniSkipData?.ed?.interval && + Math.abs(aniSkipData.ed.interval.startTime - (aniSkipData?.ed?.episodeLength)) < 500 && + !!detail?.currentTime && + detail?.currentTime >= aniSkipData.ed.interval.startTime && + detail?.currentTime <= aniSkipData.ed.interval.endTime + ) { + setShowSkipEndingButton(true) + if (autoSkipIntroOutro) { + seekTo(aniSkipData?.ed?.interval?.endTime || 0) + } + } else { + setShowSkipEndingButton(false) + } + + if (watchHistoryRef.current > 2000) { + watchHistoryRef.current = 0 + handleUpdateWatchHistory() + } + + watchHistoryRef.current++ + + /** + * Progress + */ + if (checkTimeRef.current < 200) { + checkTimeRef.current++ + return + } + checkTimeRef.current = 0 + + // Use trueDuration if available, otherwise fallback to the dynamic duration + const effectiveDuration = trueDuration || duration + + if ( + media && + // valid episode number + progress.currentEpisodeNumber != null && + progress.currentEpisodeNumber > 0 && + // progress wasn't updated + (!progressItem || progress.currentEpisodeNumber > progressItem.episodeNumber) && + // video is almost complete using the fixed duration + effectiveDuration > 0 && (detail.currentTime / effectiveDuration) >= 0.8 && + // episode number greater than progress + progress.currentEpisodeNumber > (currentProgress ?? 0) + ) { + if (serverStatus?.settings?.library?.autoUpdateProgress) { + if (!isUpdatingProgress) { + updateProgress({ + episodeNumber: progress.currentEpisodeNumber, + mediaId: media?.id, + totalEpisodes: media?.episodes || 0, + malId: media?.idMal || undefined, + }, { + onSuccess: () => { + setCurrentProgress(progress.currentEpisodeNumber!) + }, + }) + } + } else { + setProgressItem({ + episodeNumber: progress.currentEpisodeNumber, + }) + } + } + } + + /** + * Watch continuity + */ + const { watchHistory, waitForWatchHistory, getEpisodeContinuitySeekTo } = useHandleCurrentMediaContinuity(media?.id) + + const wentToNextEpisodeRef = React.useRef(false) + const onEnded = (e: MediaEndedEvent) => { + _onEnded?.(e) + + if (autoNext && !wentToNextEpisodeRef.current) { + wasFullscreenBeforeNextEpisodeRef.current = currentFullscreenStateRef.current + onGoToNextEpisode() + wentToNextEpisodeRef.current = true + } + } + + const onProviderChange = (provider: MediaProviderAdapter | null, e: MediaProviderChangeEvent) => { + _onProviderChange?.(provider, e) + } + + const onProviderSetup = (provider: MediaProviderAdapter, e: MediaProviderSetupEvent) => { + _onProviderSetup?.(provider, e) + } + + + const onCanPlay = (e: MediaCanPlayDetail, event: MediaCanPlayEvent) => { + _onCanPlay?.(e, event) + + canPlayRef.current = true + + if (__isDesktop__ && wentToNextEpisodeRef.current && wasFullscreenBeforeNextEpisodeRef.current) { + logger("MEDIA PLAYER").info("Restoring fullscreen") + try { + playerRef.current?.enterFullscreen() + playerRef.current?.el?.focus() + } + catch { + } + } + + wentToNextEpisodeRef.current = false + + // If the watch history is found and the episode number matches, seek to the last watched time + if (progress.currentEpisodeNumber && watchHistory?.found && watchHistory.item?.episodeNumber === progress.currentEpisodeNumber) { + const lastWatchedTime = getEpisodeContinuitySeekTo(progress.currentEpisodeNumber, + playerRef.current?.currentTime, + playerRef.current?.duration) + logger("MEDIA PLAYER").info("Watch continuity: Seeking to last watched time", { lastWatchedTime }) + if (lastWatchedTime > 0) { + logger("MEDIA PLAYER").info("Watch continuity: Seeking to", lastWatchedTime) + Object.assign(playerRef.current || {}, { currentTime: lastWatchedTime }) + } + } + + if ( + serverStatus?.settings?.discord?.enableRichPresence && + serverStatus?.settings?.discord?.enableAnimeRichPresence && + media?.id + ) { + const videoProgress = playerRef.current?.currentTime ?? 0 + const videoDuration = playerRef.current?.duration ?? 0 + logger("MEDIA PLAYER").info("Setting discord activity", { + videoProgress, + videoDuration, + }) + setAnimeDiscordActivity({ + mediaId: media?.id ?? 0, + title: media?.title?.userPreferred || media?.title?.romaji || media?.title?.english || "Watching", + image: media?.coverImage?.large || media?.coverImage?.medium || "", + isMovie: media?.format === "MOVIE", + episodeNumber: progress.currentEpisodeNumber ?? 0, + progress: Math.floor(videoProgress), + duration: Math.floor(videoDuration), + totalEpisodes: media?.episodes, + currentEpisodeCount: media?.nextAiringEpisode?.episode ? media?.nextAiringEpisode?.episode - 1 : media?.episodes, + episodeTitle: progress?.currentEpisodeTitle || undefined, + }) + } + + if (autoPlay) { + playerRef.current?.play() + } + } + + function seekTo(time: number) { + Object.assign(playerRef.current ?? {}, { currentTime: time }) + } + + function onSkipIntro() { + if (!aniSkipData?.op?.interval?.endTime) return + seekTo(aniSkipData?.op?.interval?.endTime || 0) + } + + function onSkipOutro() { + if (!aniSkipData?.ed?.interval?.endTime) return + seekTo(aniSkipData?.ed?.interval?.endTime || 0) + } + + const cues = React.useMemo(() => { + const introStart = aniSkipData?.op?.interval?.startTime ?? 0 + const introEnd = aniSkipData?.op?.interval?.endTime ?? 0 + const outroStart = aniSkipData?.ed?.interval?.startTime ?? 0 + const outroEnd = aniSkipData?.ed?.interval?.endTime ?? 0 + const ret = [] + if (introEnd > introStart) { + ret.push({ + startTime: introStart, + endTime: introEnd, + text: "Intro", + }) + } + if (outroEnd > outroStart) { + ret.push({ + startTime: outroStart, + endTime: outroEnd, + text: "Outro", + }) + } + return ret + }, []) + + React.useEffect(() => { + mousetrap.bind("f", () => { + logger("MEDIA PLAYER").info("Fullscreen key pressed") + try { + playerRef.current?.enterFullscreen() + playerRef.current?.el?.focus() + } + catch { + } + }) + + return () => { + mousetrap.unbind("f") + + if (serverStatus?.settings?.discord?.enableRichPresence && serverStatus?.settings?.discord?.enableAnimeRichPresence) { + cancelDiscordActivity() + } + } + }, []) + + const { inject, remove } = useSeaCommandInject() + + React.useEffect(() => { + + inject("media-player-controls", { + items: [ + { + id: "toggle-play", + value: "toggle-play", + heading: "Controls", + priority: 100, + render: () => ( + <> + <p>Toggle Play</p> + </> + ), + onSelect: () => { + if (playerRef.current?.paused) { + playerRef.current?.play() + } else { + playerRef.current?.pause() + } + }, + }, + { + id: "fullscreen", + value: "fullscreen", + heading: "Controls", + priority: 99, + render: () => ( + <> + <p>Fullscreen</p> + </> + ), + onSelect: () => { + playerRef.current?.enterFullscreen() + }, + }, + { + id: "next-episode", + value: "next-episode", + heading: "Controls", + priority: 98, + render: () => ( + <> + <p>Next Episode</p> + </> + ), + onSelect: () => onGoToNextEpisode(), + }, + { + id: "previous-episode", + value: "previous-episode", + heading: "Controls", + priority: 97, + render: () => ( + <> + <p>Previous Episode</p> + </> + ), + onSelect: () => onGoToPreviousEpisode?.(), + }, + ], + }) + + return () => remove("media-player-controls") + }, [url]) + + const { onMediaEnterFullscreenRequest } = useFullscreenHandler(playerRef) + + return ( + <> + <div data-sea-media-player-container className="aspect-video relative w-full self-start mx-auto"> + {(!!isPlaybackError?.length) ? ( + <LuffyError title="Oops!"> + <p className="max-w-md"> + {capitalize(isPlaybackError)} + </p> + </LuffyError> + ) : (!!url && !isLoading) ? ( + <MediaPlayer + data-sea-media-player + streamType="on-demand" + playsInline + ref={playerRef} + autoPlay={autoPlay} + crossOrigin + src={url} + poster={poster} + aspectRatio="16/9" + controlsDelay={discreteControls ? 500 : undefined} + className={cn(discreteControls && "discrete-controls")} + onProviderChange={onProviderChange} + onMediaEnterFullscreenRequest={onMediaEnterFullscreenRequest} + onFullscreenChange={(isFullscreen: boolean, event: MediaFullscreenChangeEvent) => { + currentFullscreenStateRef.current = isFullscreen + + if (isFullscreen) { + // Store the currently focused element + lastFocusedElementRef.current = document.activeElement as HTMLElement + } else { + // Restore focus + setTimeout(() => { + lastFocusedElementRef.current?.focus() + }, 100) + } + }} + onProviderSetup={onProviderSetup} + volume={volume} + onVolumeChange={detail => setVolume(detail.volume)} + onTimeUpdate={onTimeUpdate} + onDurationChange={onDurationChange} + onCanPlay={onCanPlay} + onEnded={onEnded} + muted={muted} + onMediaMuteRequest={() => setMuted(true)} + onMediaUnmuteRequest={() => setMuted(false)} + > + <MediaProvider> + {tracks.map((track, index) => ( + <Track key={`track-${index}`} {...track} /> + ))} + {/*{chapters?.length > 0 ? chapters.map((chapter, index) => (*/} + {/* <Track kind="chapters" key={`chapter-${index}`} {...chapter} />*/} + {/*)) : cues.length > 0 ? cues.map((cue, index) => (*/} + {/* <Track kind="chapters" key={`cue-${index}`} {...cue} />*/} + {/*)) : null}*/} + </MediaProvider> + <div + data-sea-media-player-skip-intro-outro-container + className="absolute bottom-24 px-4 w-full justify-between flex items-center" + > + <div> + {showSkipIntroButton && ( + <Button intent="white" size="sm" onClick={onSkipIntro} loading={autoSkipIntroOutro}> + Skip opening + </Button> + )} + </div> + <div> + {showSkipEndingButton && ( + <Button intent="white" size="sm" onClick={onSkipOutro} loading={autoSkipIntroOutro}> + Skip ending + </Button> + )} + </div> + </div> + <DefaultVideoLayout + icons={vidstackLayoutIcons} + slots={{ + ...videoLayoutSlots, + settingsMenuEndItems: <> + {settingsItems} + <SeaMediaPlayerPlaybackSubmenu /> + </>, + // centerControlsGroupStart: <div> + // {onGoToPreviousEpisode && ( + // <IconButton + // intent="white-basic" + // size="lg" + // onClick={onGoToPreviousEpisode} + // aria-label="Previous Episode" + // icon={<LuArrowLeft className="size-12" />} + // /> + // )} + // </div>, + // centerControlsGroupEnd: <div className="flex items-center justify-center gap-2"> + // {onGoToNextEpisode && ( + // <IconButton + // intent="white-basic" + // size="lg" + // onClick={onGoToNextEpisode} + // aria-label="Next Episode" + // icon={<LuArrowRight className="size-12" />} + // /> + // )} + // </div> + }} + /> + </MediaPlayer> + ) : ( + <Skeleton + data-sea-media-player-loading-container + className="w-full h-full absolute flex justify-center items-center flex-col space-y-4" + > + <LoadingSpinner + spinner={ + <div className="w-16 h-16 lg:w-[100px] lg:h-[100px] relative"> + <Image + src="/logo_2.png" + alt="Loading..." + priority + fill + className="animate-pulse" + /> + </div> + } + /> + <div className="text-center text-xs lg:text-sm"> + {loadingText ?? <> + <p>Loading...</p> + </>} + </div> + </Skeleton> + )} + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/top-indefinite-loader.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/top-indefinite-loader.tsx new file mode 100644 index 0000000..d101c3e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/top-indefinite-loader.tsx @@ -0,0 +1,56 @@ +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { ProgressBar } from "@/components/ui/progress-bar" +import { logger } from "@/lib/helpers/debug" +import { WSEvents } from "@/lib/server/ws-events" +import React from "react" + +const log = logger("IndefiniteLoader") + +export function TopIndefiniteLoader() { + + const [showStack, setShowStack] = React.useState<string[]>([]) + + // Empty after 3 minutes + // timeout resets each time a new loader is shown + React.useEffect(() => { + const timeout = setTimeout(() => { + setShowStack([]) + }, 3 * 60 * 1000) + return () => clearTimeout(timeout) + }, [showStack]) + + useWebsocketMessageListener<string>({ + type: WSEvents.SHOW_INDEFINITE_LOADER, + onMessage: data => { + if (data) { + log.info("Showing indefinite loader", data) + setShowStack(prev => { + if (prev.includes(data)) { + return prev + } + return [...prev, data] + }) + } + }, + }) + + useWebsocketMessageListener<string>({ + type: WSEvents.HIDE_INDEFINITE_LOADER, + onMessage: data => { + if (data) { + log.info("Hiding indefinite loader", data) + setShowStack(prev => { + return prev.filter(item => item !== data) + }) + } + }, + }) + + return ( + <> + {showStack.length > 0 && <div className="w-full bg-gray-950 fixed top-0 left-0 z-[100]"> + <ProgressBar size="xs" isIndeterminate /> + </div>} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/update/update-helper.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/update/update-helper.tsx new file mode 100644 index 0000000..e5139a5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/update/update-helper.tsx @@ -0,0 +1,97 @@ +import { Updater_Update } from "@/api/generated/types" +import { useGetChangelog } from "@/api/hooks/releases.hooks" +import React from "react" +import { AiFillExclamationCircle } from "react-icons/ai" + +type UpdateChangelogBodyProps = { + updateData: Updater_Update | undefined + children?: React.ReactNode +} + +export function UpdateChangelogBody(props: UpdateChangelogBodyProps) { + + const { + updateData, + children, + ...rest + } = props + + const { data: changelog } = useGetChangelog(updateData?.release?.version!, updateData?.current_version!, !!updateData?.release?.version! && !!updateData?.current_version) + + const { body } = useUpdateChangelogBody(updateData) + + function RenderLines({ lines }: { lines: string[] }) { + return <> + {lines.map((line, index) => { + if (line.includes("🚑️")) return <p key={index} className="text-red-300 font-semibold flex gap-2 items-center">{line} + <AiFillExclamationCircle /></p> + if (line.includes("🎉")) return <p key={index} className="text-white">{line}</p> + if (line.includes("✨")) return <p key={index} className="text-white">{line}</p> + if (line.includes("⚡️")) return <p key={index} className="">{line}</p> + if (line.includes("💄")) return <p key={index} className="">{line}</p> + if (line.includes("🦺")) return <p key={index} className="">{line}</p> + if (line.includes("⬆️")) return <p key={index} className="">{line}</p> + if (line.includes("🏗️")) return <p key={index} className="">{line}</p> + if (line.includes("🚀")) return <p key={index} className="">{line}</p> + if (line.includes("🔧")) return <p key={index} className="">{line}</p> + if (line.includes("🔍")) return <p key={index} className="">{line}</p> + if (line.includes("🔒")) return <p key={index} className="">{line}</p> + if (line.includes("🔑")) return <p key={index} className="">{line}</p> + if (line.includes("🔗")) return <p key={index} className="">{line}</p> + if (line.includes("🔨")) return <p key={index} className="">{line}</p> + if (line.includes("🎨")) return <p key={index} className="">{line}</p> + if (line.includes("📝")) return <p key={index} className="">{line}</p> + if (line.includes("♻️")) return <p key={index} className="">{line}</p> + if (line.includes("🔄")) return <p key={index} className="">{line}</p> + if (line.includes("⏪️")) return <p key={index} className="">{line}</p> + if (line.includes("🩹")) return <p key={index} className="">{line}</p> + return ( + <p key={index} className="opacity-75 pl-4 text-sm">{line}<br /></p> + ) + })} + </> + } + + return ( + <> + <div className="bg-gray-950/50 rounded-[--radius] p-4 max-h-[70vh] overflow-y-auto halo-2"> + {body.some(n => n.includes("🚑️")) && + <p className="text-red-300 font-semibold flex gap-2 items-center">This update includes a critical patch</p>} + <div className="rounded-[--radius] space-y-1"> + <h5>What's new?</h5> + <RenderLines lines={body} /> + </div> + </div> + + {!!changelog?.length && <> + <p className="text-center font-semibold">Other updates you've missed</p> + <div className="bg-gray-950/50 rounded-[--radius] p-4 max-h-[40vh] overflow-y-auto space-y-1.5"> + {changelog?.map((item) => ( + <div key={item.version} className="rounded-[--radius]"> + <p key={item.version} className="text-center font-bold">{item.version}</p> + <div className="text-sm"> + <RenderLines lines={item.lines} /> + </div> + </div> + ))} + </div> + </>} + </> + ) +} + + +export function useUpdateChangelogBody(updateData: Updater_Update | undefined) { + const body = React.useMemo(() => { + if (!updateData || !updateData.release) return [] + let body = updateData.release.body + if (body.includes("---")) { + body = body.split("---")[0] + } + return body.split(/\n/).filter((line) => line.trim() !== "" && line.trim().startsWith("-")) + }, [updateData]) + + return { + body, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/update/update-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/update/update-modal.tsx new file mode 100644 index 0000000..80993e3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/update/update-modal.tsx @@ -0,0 +1,222 @@ +"use client" +import { Updater_Release } from "@/api/generated/types" +import { useDownloadRelease } from "@/api/hooks/download.hooks" +import { useGetLatestUpdate, useInstallLatestUpdate } from "@/api/hooks/releases.hooks" +import { UpdateChangelogBody } from "@/app/(main)/_features/update/update-helper" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { DirectorySelector } from "@/components/shared/directory-selector" +import { SeaLink } from "@/components/shared/sea-link" +import { Alert } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import { RadioGroup } from "@/components/ui/radio-group" +import { VerticalMenu } from "@/components/ui/vertical-menu" +import { WSEvents } from "@/lib/server/ws-events" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" +import { AiFillExclamationCircle } from "react-icons/ai" +import { BiDownload, BiLinkExternal } from "react-icons/bi" +import { FiArrowRight } from "react-icons/fi" +import { GrInstall } from "react-icons/gr" +import { toast } from "sonner" + +type UpdateModalProps = { + collapsed?: boolean +} + + +export const updateModalOpenAtom = atom<boolean>(false) +const downloaderOpenAtom = atom<boolean>(false) + +export function UpdateModal(props: UpdateModalProps) { + const serverStatus = useServerStatus() + const [updateModalOpen, setUpdateModalOpen] = useAtom(updateModalOpenAtom) + const [downloaderOpen, setDownloaderOpen] = useAtom(downloaderOpenAtom) + + const { data: updateData, isLoading, refetch } = useGetLatestUpdate(!!serverStatus && !serverStatus?.settings?.library?.disableUpdateCheck) + + useWebsocketMessageListener({ + type: WSEvents.CHECK_FOR_UPDATES, + onMessage: () => { + refetch() + }, + }) + + // Install update + const { mutate: installUpdate, isPending } = useInstallLatestUpdate() + const [fallbackDestination, setFallbackDestination] = React.useState<string>("") + + React.useEffect(() => { + if (updateData && updateData.release) { + localStorage.setItem("latest-available-update", JSON.stringify(updateData.release.version)) + const latestVersionNotified = localStorage.getItem("notified-available-update") + if (!latestVersionNotified || (latestVersionNotified !== JSON.stringify(updateData.release.version))) { + setUpdateModalOpen(true) + } + } + }, [updateData]) + + const ignoreUpdate = () => { + if (updateData && updateData.release) { + localStorage.setItem("notified-available-update", JSON.stringify(updateData.release.version)) + setUpdateModalOpen(false) + } + } + + function handleInstallUpdate() { + installUpdate({ fallback_destination: "" }) + } + + if (serverStatus?.settings?.library?.disableUpdateCheck) return null + + if (isLoading || !updateData || !updateData.release) return null + + return ( + <> + <VerticalMenu + collapsed={props.collapsed} + items={[ + { + iconType: AiFillExclamationCircle, + name: "Update available", + onClick: () => setUpdateModalOpen(true), + }, + ]} + itemIconClass="text-brand-300" + /> + <Modal + open={updateModalOpen} + onOpenChange={() => ignoreUpdate()} + contentClass="max-w-3xl" + > + <Downloader release={updateData.release} /> + {/*<div*/} + {/* className="bg-[url(/pattern-2.svg)] z-[-1] w-full h-[4rem] absolute opacity-60 left-0 bg-no-repeat bg-right bg-cover"*/} + {/*>*/} + {/* <div*/} + {/* className="w-full absolute bottom-0 h-[4rem] bg-gradient-to-t from-[--background] to-transparent z-[-2]"*/} + {/* />*/} + {/*</div>*/} + <div className="space-y-2"> + <h3 className="text-center">A new update is available!</h3> + <h4 className="font-bold flex gap-2 text-center items-center justify-center"> + <span className="text-[--muted]">{updateData.current_version}</span> <FiArrowRight /> + <span className="text-indigo-200">{updateData.release.version}</span></h4> + + {serverStatus?.isDesktopSidecar && <Alert + intent="info" + description="Update Seanime from the desktop application." + />} + + <UpdateChangelogBody updateData={updateData} /> + + <div className="flex gap-2 w-full items-center !mt-4"> + {!serverStatus?.isDesktopSidecar && <Modal + trigger={<Button leftIcon={<GrInstall className="text-2xl" />}> + Update now + </Button>} + contentClass="max-w-xl" + title={<span>Update Seanime</span>} + > + <div className="space-y-4"> + <p> + Seanime will perform an update by downloading and replacing existing files. + Refer to the documentation for more information. + </p> + <Button className="w-full" onClick={handleInstallUpdate} disabled={isPending}> + Download and Install + </Button> + </div> + </Modal>} + <div className="flex flex-1" /> + <SeaLink href={updateData?.release?.html_url || ""} target="_blank"> + <Button intent="white-subtle" rightIcon={<BiLinkExternal />}>See on GitHub</Button> + </SeaLink> + {!serverStatus?.isDesktopSidecar && + <Button intent="white" leftIcon={<BiDownload />} onClick={() => setDownloaderOpen(true)}>Download</Button>} + </div> + </div> + </Modal> + </> + ) + +} + +type DownloaderProps = { + children?: React.ReactNode + release?: Updater_Release +} + +export function Downloader(props: DownloaderProps) { + + const [downloaderOpen, setDownloaderOpen] = useAtom(downloaderOpenAtom) + const [destination, setDestination] = React.useState<string>("") + const [asset, setAsset] = React.useState<string>("") + + const { + children, + release, + ...rest + } = props + + const { mutate, isPending } = useDownloadRelease() + + function handleDownloadRelease() { + if (!asset || !destination) { + return toast.error("Missing options") + } + mutate({ destination, download_url: asset }, { + onSuccess: () => { + setDownloaderOpen(false) + }, + }) + } + + if (!release) return null + + return ( + <Modal + open={downloaderOpen} + onOpenChange={() => setDownloaderOpen(false)} + title="Download new release" + contentClass="space-y-4 max-w-2xl overflow-hidden" + > + <div> + <RadioGroup + itemClass={cn( + "border-transparent absolute top-2 right-2 bg-transparent dark:bg-transparent dark:data-[state=unchecked]:bg-transparent", + "data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent dark:data-[state=unchecked]:hover:bg-transparent", + "focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:ring-offset-transparent", + )} + itemIndicatorClass="hidden" + itemLabelClass="font-normal tracking-wide line-clamp-1 truncate flex flex-col items-center data-[state=checked]:text-[--brand] cursor-pointer" + itemContainerClass={cn( + "items-start cursor-pointer transition border-transparent rounded-[--radius] py-1.5 px-2 w-full", + "bg-gray-50 hover:bg-[--subtle] dark:bg-gray-900", + "data-[state=checked]:bg-white dark:data-[state=checked]:bg-gray-950", + "focus:ring-2 ring-transparent dark:ring-transparent outline-none ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition", + "border border-transparent data-[state=checked]:border-[--brand] data-[state=checked]:ring-offset-0", + )} + value={asset} + onValueChange={v => !!v ? setAsset(v) : {}} + options={release.assets?.filter(n => !n.name.endsWith(".txt")).map((asset) => ({ + label: asset.name, + value: asset.browser_download_url, + })) || []} + /> + </div> + <DirectorySelector + label="Select destination" + onSelect={setDestination} + value={destination} + rightAddon={`/seanime-${release.version}`} + /> + <div className="flex gap-2 justify-end mt-2"> + <Button intent="white" leftIcon={<BiDownload />} onClick={handleDownloadRelease} loading={isPending}>Download</Button> + </div> + </Modal> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-action-display.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-action-display.tsx new file mode 100644 index 0000000..cfa74d7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-action-display.tsx @@ -0,0 +1,81 @@ +// Flash notification system +import { vc_miniPlayer, vc_paused } from "@/app/(main)/_features/video-core/video-core" +import { cn } from "@/components/ui/core/styling" +import { atom, useAtomValue } from "jotai" +import { useAtom } from "jotai/index" +import { motion } from "motion/react" +import React from "react" +import { PiPauseDuotone, PiPlayDuotone } from "react-icons/pi" + +type VideoCoreFlashAction = { + id: string + message: string + timestamp: number + type: "message" | "time" | "icon" +} +export const vc_flashAction = atom<VideoCoreFlashAction | null>(null) +export const vc_flashActionTimeout = atom<ReturnType<typeof setTimeout> | null>(null) +export const vc_doFlashAction = atom(null, (get, set, payload: { message: string, type?: "message" | "time" | "icon", duration?: number }) => { + const id = Date.now().toString() + const timeout = get(vc_flashActionTimeout) + const paused = get(vc_paused) + set(vc_flashAction, { id, message: payload.message, timestamp: Date.now(), type: payload.type ?? "message" }) + if (timeout) { + clearTimeout(timeout) + } + const t = setTimeout(() => { + set(vc_flashAction, null) + set(vc_flashActionTimeout, null) + }, payload.duration ?? (payload.type === "icon" ? 200 : (paused ? 1000 : 500))) // stays longer when paused + set(vc_flashActionTimeout, t) + +}) + +export function VideoCoreActionDisplay() { + const [notification] = useAtom(vc_flashAction) + const isMiniPlayer = useAtomValue(vc_miniPlayer) + + if (!notification) return null + + if (notification.type === "icon") { + return ( + <motion.div + initial={{ opacity: 0.2, scale: 1 }} + animate={{ opacity: 0.5, scale: 1.6 }} + exit={{ opacity: 1, scale: 1 }} + transition={{ duration: 0.06, ease: "easeOut" }} + className="absolute w-full h-full pointer-events-none flex z-[50] items-center justify-center" + > + {notification.message === "PLAY" && + <PiPlayDuotone + className={cn("size-24 text-white", isMiniPlayer && "size-10")} + style={{ textShadow: "0 1px 10px rgba(0, 0, 0, 0.8)" }} + />} + {notification.message === "PAUSE" && + <PiPauseDuotone + className={cn("size-24 text-white", isMiniPlayer && "size-10")} + style={{ textShadow: "0 1px 10px rgba(0, 0, 0, 0.8)" }} + />} + </motion.div> + ) + } + + return ( + <div className="absolute top-16 left-1/2 transform -translate-x-1/2 z-50 pointer-events-none"> + <div + className={cn( + "text-white px-2 py-1 !text-xl font-semibold rounded-lg bg-black/50 backdrop-blur-sm tracking-wide", + isMiniPlayer && "text-sm", + )} + > + {notification.message} + </div> + </div> + ) +} + +export function useVideoCoreFlashAction() { + const [, flashAction] = useAtom(vc_doFlashAction) + + return { flashAction } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-anime-4k-manager.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-anime-4k-manager.ts new file mode 100644 index 0000000..a0753f6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-anime-4k-manager.ts @@ -0,0 +1,446 @@ +import { VideoCoreSettings } from "@/app/(main)/_features/video-core/video-core.atoms" +import { logger } from "@/lib/helpers/debug" +import { + Anime4KPipeline, + CNNx2M, + CNNx2UL, + CNNx2VL, + DenoiseCNNx2VL, + GANx3L, + GANx4UUL, + ModeA, + ModeAA, + ModeB, + ModeBB, + ModeC, + ModeCA, + render, +} from "anime4k-webgpu" + +const log = logger("VIDEO CORE ANIME 4K MANAGER") + +export type Anime4KOption = + "off" + | "mode-a" + | "mode-b" + | "mode-c" + | "mode-aa" + | "mode-bb" + | "mode-ca" + | "cnn-2x-medium" + | "cnn-2x-very-large" + | "denoise-cnn-2x-very-large" + | "cnn-2x-ultra-large" + | "gan-3x-large" + | "gan-4x-ultra-large" + +interface FrameDropState { + enabled: boolean + frameDropThreshold: number + frameDropCount: number + lastFrameTime: number + targetFrameTime: number + performanceGracePeriod: number + initTime: number +} + +export class VideoCoreAnime4KManager { + canvas: HTMLCanvasElement | null = null + private readonly videoElement: HTMLVideoElement + private settings: VideoCoreSettings + private _currentOption: Anime4KOption = "off" + private _webgpuResources: { device?: GPUDevice; pipelines?: any[] } | null = null + private _renderLoopId: number | null = null + private _abortController: AbortController | null = null + private _frameDropState: FrameDropState = { + enabled: true, + frameDropThreshold: 5, + frameDropCount: 0, + lastFrameTime: 0, + targetFrameTime: 1000 / 30, // 30fps target + performanceGracePeriod: 1000, + initTime: 0, + } + private readonly _onFallback?: (message: string) => void + private readonly _onOptionChanged?: (option: Anime4KOption) => void + private _boxSize: { width: number; height: number } = { width: 0, height: 0 } + private _initializationTimeout: NodeJS.Timeout | null = null + private _initialized = false + private _onCanvasCreatedCallbacks: Set<(canvas: HTMLCanvasElement) => void> = new Set() + + constructor({ + videoElement, + settings, + onFallback, + onOptionChanged, + }: { + videoElement: HTMLVideoElement + settings: VideoCoreSettings + onFallback?: (message: string) => void + onOptionChanged?: (option: Anime4KOption) => void + }) { + this.videoElement = videoElement + this.settings = settings + this._onFallback = onFallback + this._onOptionChanged = onOptionChanged + + log.info("Anime4K manager initialized") + } + + updateCanvasSize(size: { width: number; height: number }) { + this._boxSize = size + if (this.canvas) { + this.canvas.width = size.width + this.canvas.height = size.height + this.canvas.style.top = this.videoElement.getBoundingClientRect().top + "px" + log.info("Updating canvas size", { width: size.width, height: size.height, top: this.videoElement.getBoundingClientRect().top }) + } + } + + // Adds a function to be called whenever the canvas is created or recreated + registerOnCanvasCreated(callback: (canvas: HTMLCanvasElement) => void) { + this._onCanvasCreatedCallbacks.add(callback) + } + + // Select an Anime4K option + async setOption(option: Anime4KOption, state?: { + isMiniPlayer: boolean + isPip: boolean + seeking: boolean + }) { + + const previousOption = this._currentOption + this._currentOption = option + + if (option === "off") { + log.info("Anime4K turned off") + this.destroy() + return + } + + // Handle change of state + if (state) { + // For PIP or mini player, completely destroy the canvas + if (state.isMiniPlayer || state.isPip) { + log.info("Destroying canvas due to PIP/mini player mode") + this.destroy() + return + } + + // For seeking, just hide the canvas + if (state.seeking) { + this._hideCanvas() + return + } + } + + // Skip initialization if size isn't set + if (this._boxSize.width === 0 || this._boxSize.height === 0) { + return + } + + // If canvas exists but is hidden, show it + if (this.canvas && this._isCanvasHidden()) { + log.info("Showing previously hidden canvas") + this._showCanvas() + return + } + + // If option changed or no canvas exists, reinitialize + if (previousOption !== option || !this.canvas) { + log.info("Change detected, reinitializing canvas") + this.destroy() + try { + await this._initialize() + } + catch (error) { + log.error("Failed to initialize Anime4K", error) + this._handleError(error instanceof Error ? error.message : "Unknown error") + } + } + } + + // initialize the canvas and start rendering + + // Destroy and cleanup resources + destroy() { + this.videoElement.style.opacity = "1" + + this._initialized = false + + if (this._initializationTimeout) { + clearTimeout(this._initializationTimeout) + this._initializationTimeout = null + } + + if (this.canvas) { + this.canvas.remove() + this.canvas = null + } + + if (this._renderLoopId) { + cancelAnimationFrame(this._renderLoopId) + this._renderLoopId = null + } + + if (this._webgpuResources?.device) { + this._webgpuResources.device.destroy() + this._webgpuResources = null + } + + if (this._abortController) { + this._abortController.abort() + this._abortController = null + } + + this._frameDropState.frameDropCount = 0 + this._frameDropState.lastFrameTime = 0 + } + + // throws if initialization fails + private async _initialize() { + if (this._initialized || this._currentOption === "off") { + return + } + + log.info("Initializing Anime4K", this._currentOption) + + this._abortController = new AbortController() + this._frameDropState = { + ...this._frameDropState, + frameDropCount: 0, + initTime: performance.now(), + lastFrameTime: 0, + } + + // Check WebGPU support, create canvas, and start rendering + try { + const gpuInfo = await this.getGPUInfo() + if (!gpuInfo) { + throw new Error("WebGPU not supported") + } + + if (this._abortController.signal.aborted) return + + this._createCanvas() + + if (this._abortController.signal.aborted) return + + await this._startRendering() + + this._initialized = true + log.info("Anime4K initialized") + } + catch (error) { + if (!this._abortController?.signal.aborted) { + log.error("Initialization failed", error) + throw error + } + } + } + + // Create and position the canvas + private _createCanvas() { + if (this._abortController?.signal.aborted) return + + this.canvas = document.createElement("canvas") + + this.canvas.width = this._boxSize.width + this.canvas.height = this._boxSize.height + this.canvas.style.objectFit = "cover" + this.canvas.style.position = "absolute" + this.canvas.style.top = this.videoElement.getBoundingClientRect().top + "px" + this.canvas.style.left = "0" + this.canvas.style.right = "0" + this.canvas.style.pointerEvents = "none" + this.canvas.style.zIndex = "2" + this.canvas.style.display = "block" + this.canvas.className = "vc-anime4k-canvas" + log.info("Creating canvas", { width: this.canvas.width, height: this.canvas.height, top: this.canvas.style.top }) + + this.videoElement.parentElement?.appendChild(this.canvas) + this.videoElement.style.opacity = "0" + + for (const callback of this._onCanvasCreatedCallbacks) { + callback(this.canvas) + } + } + + // WebGPU rendering + private async _startRendering() { + if (!this.canvas || !this.videoElement || this._currentOption === "off") { + console.warn("stopped started") + return + } + + const nativeDimensions = { + width: this.videoElement.videoWidth, + height: this.videoElement.videoHeight, + } + + const targetDimensions = { + width: this.canvas.width, + height: this.canvas.height, + } + + log.info("Rendering started") + + await render({ + video: this.videoElement, + canvas: this.canvas, + pipelineBuilder: (device, inputTexture) => { + this._webgpuResources = { device } + + const commonProps = { + device, + inputTexture, + nativeDimensions, + targetDimensions, + } + + return this.createPipeline(commonProps) + }, + }) + + // Start frame drop detection if enabled + if (this._frameDropState.enabled && this._isOptionSelected(this._currentOption)) { + this._startFrameDropDetection() + } + } + + private createPipeline(commonProps: any): [Anime4KPipeline] { + switch (this._currentOption) { + case "mode-a": + return [new ModeA(commonProps)] + case "mode-b": + return [new ModeB(commonProps)] + case "mode-c": + return [new ModeC(commonProps)] + case "mode-aa": + return [new ModeAA(commonProps)] + case "mode-bb": + return [new ModeBB(commonProps)] + case "mode-ca": + return [new ModeCA(commonProps)] + case "cnn-2x-medium": + return [new CNNx2M(commonProps)] + case "cnn-2x-very-large": + return [new CNNx2VL(commonProps)] + case "denoise-cnn-2x-very-large": + return [new DenoiseCNNx2VL(commonProps)] + case "cnn-2x-ultra-large": + return [new CNNx2UL(commonProps)] + case "gan-3x-large": + return [new GANx3L(commonProps)] + case "gan-4x-ultra-large": + return [new GANx4UUL(commonProps)] + default: + return [new ModeA(commonProps)] + } + } + + // Start frame drop detection loop + private _startFrameDropDetection() { + const frameDetectionLoop = () => { + if (this._isOptionSelected(this._currentOption) && this._renderLoopId !== null) { + this._detectFrameDrops() + this._renderLoopId = requestAnimationFrame(frameDetectionLoop) + } + } + this._renderLoopId = requestAnimationFrame(frameDetectionLoop) + } + + // Detect frame drops and stop when it gets bad + private _detectFrameDrops() { + if (!this._isOptionSelected(this._currentOption)) { + return + } + + const now = performance.now() + const timeSinceInit = now - this._frameDropState.initTime + + // Skip detection during grace period + if (timeSinceInit < this._frameDropState.performanceGracePeriod) { + this._frameDropState.lastFrameTime = now + return + } + + if (this._frameDropState.lastFrameTime > 0) { + const frameTime = now - this._frameDropState.lastFrameTime + const isFrameDrop = frameTime > this._frameDropState.targetFrameTime * 1.5 // 50% tolerance + + if (isFrameDrop) { + this._frameDropState.frameDropCount++ + + if (this._frameDropState.frameDropCount >= this._frameDropState.frameDropThreshold) { + log.warning(`Detected ${this._frameDropState.frameDropCount} consecutive frame drops. Falling back to 'off' mode.`) + this._handlePerformanceFallback() + return + } + } else { + // Reset on successful frame + this._frameDropState.frameDropCount = 0 + } + } + + this._frameDropState.lastFrameTime = now + } + + private _handlePerformanceFallback() { + this._onFallback?.("Performance degraded. Turning off Anime4K.") + this.setOption("off") + this._onOptionChanged?.("off") + } + + private _handleError(message: string) { + this._onFallback?.(`Anime4K: ${message}`) + this.setOption("off") + this._onOptionChanged?.("off") + } + + // Get GPU information + private async getGPUInfo() { + if (!navigator.gpu) return null + + try { + const adapter = await navigator.gpu.requestAdapter() + if (!adapter) return null + + const device = await adapter.requestDevice() + if (!device) return null + + const info = (adapter as any).info || {} + + return { + gpu: info.vendor || info.architecture || "Unknown GPU", + vendor: info.vendor || "Unknown", + device, + } + } + catch { + return null + } + } + + private _isOptionSelected(option: Anime4KOption): boolean { + return option !== "off" + } + + private _hideCanvas() { + if (this.canvas) { + this.canvas.style.display = "none" + this.videoElement.style.opacity = "1" + } + } + + private _showCanvas() { + if (this.canvas) { + this.canvas.style.display = "block" + this.videoElement.style.opacity = "0" + } + } + + private _isCanvasHidden(): boolean { + return this.canvas ? this.canvas.style.display === "none" : false + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-anime-4k.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-anime-4k.ts new file mode 100644 index 0000000..48e182e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-anime-4k.ts @@ -0,0 +1,187 @@ +import { vc_anime4kManager, vc_miniPlayer, vc_pip, vc_realVideoSize, vc_seeking, vc_videoElement } from "@/app/(main)/_features/video-core/video-core" +import { Anime4KOption } from "@/app/(main)/_features/video-core/video-core-anime-4k-manager" +import { logger } from "@/lib/helpers/debug" +import { useAtomValue } from "jotai" +import { useAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import React from "react" + +const log = logger("VIDEO CORE ANIME 4K") + +export const vc_anime4kOption = atomWithStorage<Anime4KOption>("sea-video-core-anime4k", "off", undefined, { getOnInit: true }) + +export const VideoCoreAnime4K = () => { + const realVideoSize = useAtomValue(vc_realVideoSize) + const seeking = useAtomValue(vc_seeking) + const isMiniPlayer = useAtomValue(vc_miniPlayer) + const isPip = useAtomValue(vc_pip) + const video = useAtomValue(vc_videoElement) + + const manager = useAtomValue(vc_anime4kManager) + const [selectedOption] = useAtom(vc_anime4kOption) + + // Update manager with real video size + React.useEffect(() => { + if (manager) { + manager.updateCanvasSize(realVideoSize) + } + }, [manager, realVideoSize]) + + // Handle option changes + React.useEffect(() => { + if (video && manager) { + log.info("Setting Anime4K option", selectedOption) + manager.setOption(selectedOption, { + isMiniPlayer, + isPip, + seeking, + }) + } + }, [video, manager, selectedOption, isMiniPlayer, isPip, seeking]) + + return null +} + +export const anime4kOptions: { value: Anime4KOption; label: string; description: string; performance: "light" | "medium" | "heavy" }[] = [ + { value: "off", label: "Off", description: "Disabled", performance: "light" }, + { value: "mode-a", label: "Mode A", description: "Removes compression artifacts then upscales", performance: "light" }, + { value: "mode-b", label: "Mode B", description: "Gentle artifact removal then upscales", performance: "light" }, + { value: "mode-c", label: "Mode C", description: "Upscales with denoising then upscales again", performance: "light" }, + { value: "mode-aa", label: "Mode A+A", description: "Enhanced restoration for better quality", performance: "medium" }, + { value: "mode-bb", label: "Mode B+B", description: "Double soft restoration", performance: "medium" }, + { value: "mode-ca", label: "Mode C+A", description: "Denoising + restoration hybrid", performance: "medium" }, + { value: "cnn-2x-medium", label: "CNN 2x M", description: "Balanced speed and quality", performance: "medium" }, + { value: "cnn-2x-very-large", label: "CNN 2x VL", description: "High quality neural network", performance: "heavy" }, + { value: "denoise-cnn-2x-very-large", label: "Denoise CNN 2x VL", description: "Removes noise while upscaling", performance: "heavy" }, + { value: "cnn-2x-ultra-large", label: "CNN 2x UL", description: "Maximum CNN quality", performance: "heavy" }, + { value: "gan-3x-large", label: "GAN 3x L", description: "Generative adversarial network for perceptual quality", performance: "heavy" }, + { value: "gan-4x-ultra-large", label: "GAN 4x UL", description: "Maximum upscaling with GAN technology", performance: "heavy" }, +] + +export const getAnime4KOptionByValue = (value: Anime4KOption) => { + return anime4kOptions.find(option => option.value === value) +} + +export const getRecommendedAnime4KOptions = (videoResolution: { width: number; height: number }) => { + const is720pOrLower = videoResolution.height <= 720 + const is1080pOrLower = videoResolution.height <= 1080 + + if (is720pOrLower) { + return anime4kOptions.filter(option => + ["mode-a", "mode-b", "mode-aa", "mode-bb", "cnn-2x-medium", "cnn-2x-very-large"].includes(option.value), + ) + } else if (is1080pOrLower) { + return anime4kOptions.filter(option => + ["mode-a", "mode-b", "mode-c", "cnn-2x-medium"].includes(option.value), + ) + } else { + return anime4kOptions.filter(option => + ["mode-a", "mode-b", "mode-c"].includes(option.value), + ) + } +} + +export const getPerformanceRecommendation = (gpu?: string) => { + const isHighEnd = gpu && ( + gpu.includes("RTX 40") || + gpu.includes("RTX 3080") || + gpu.includes("RTX 3090") || + gpu.includes("RX 6800") || + gpu.includes("RX 6900") || + gpu.includes("M1 Pro") || + gpu.includes("M1 Max") || + gpu.includes("M2") || + gpu.includes("M3") + ) + + const isMidRange = gpu && ( + gpu.includes("RTX 30") || + gpu.includes("RTX 20") || + gpu.includes("GTX 16") || + gpu.includes("RX 6600") || + gpu.includes("RX 5") || + gpu.includes("M1") + ) + + if (isHighEnd) { + return { + maxPerformance: "heavy" as const, + recommendedOptions: anime4kOptions.filter(opt => opt.performance !== "heavy").slice(0, 8), + } + } else if (isMidRange) { + return { + maxPerformance: "medium" as const, + recommendedOptions: anime4kOptions.filter(opt => opt.performance === "light" || opt.performance === "medium"), + } + } else { + return { + maxPerformance: "light" as const, + recommendedOptions: anime4kOptions.filter(opt => opt.performance === "light"), + } + } +} + +export const isWebGPUAvailable = async (): Promise<boolean> => { + if (!navigator.gpu) { + return false + } + + try { + const adapter = await navigator.gpu.requestAdapter() + if (!adapter) return false + + const device = await adapter.requestDevice() + return !!device + } + catch { + return false + } +} + +export const getOptimalAnime4KSettings = async (videoResolution: { width: number; height: number }) => { + const webGPUAvailable = await isWebGPUAvailable() + + if (!webGPUAvailable) { + return { + supported: false, + recommendation: "off" as Anime4KOption, + reason: "WebGPU not supported on this device", + } + } + + const gpuInfo = await getGPUInfo() + const recommendation = getPerformanceRecommendation(gpuInfo?.gpu) + const videoRecommendations = getRecommendedAnime4KOptions(videoResolution) + + const optimalOption = anime4kOptions.find(option => + videoRecommendations.some(vr => vr.value === option.value) && + recommendation.recommendedOptions.some(pr => pr.value === option.value), + ) + + return { + supported: true, + recommendation: optimalOption?.value || "mode-a" as Anime4KOption, + reason: `Recommended for ${videoResolution.height}p video on ${gpuInfo?.gpu || "current GPU"}`, + alternatives: recommendation.recommendedOptions.slice(0, 3), + } +} + +const getGPUInfo = async () => { + if (!navigator.gpu) return null + + try { + const adapter = await navigator.gpu.requestAdapter() + if (!adapter) return null + + const info = (adapter as any).info || {} + + return { + gpu: info.vendor || info.architecture || "Unknown GPU", + vendor: info.vendor || "Unknown", + } + } + catch { + return null + } +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-audio.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-audio.ts new file mode 100644 index 0000000..ba3efc0 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-audio.ts @@ -0,0 +1,91 @@ +import { NativePlayer_PlaybackInfo } from "@/api/generated/types" +import { VideoCoreSettings } from "@/app/(main)/_features/video-core/video-core.atoms" +import { logger } from "@/lib/helpers/debug" + +const audioLog = logger("AUDIO") + +export class VideoCoreAudioManager { + + onError: (error: string) => void + private videoElement: HTMLVideoElement + private settings: VideoCoreSettings + // Playback info + private playbackInfo: NativePlayer_PlaybackInfo + + constructor({ + videoElement, + settings, + playbackInfo, + onError, + }: { + videoElement: HTMLVideoElement + settings: VideoCoreSettings + playbackInfo: NativePlayer_PlaybackInfo + onError: (error: string) => void + }) { + this.videoElement = videoElement + this.settings = settings + this.playbackInfo = playbackInfo + this.onError = onError + + if (this.videoElement.audioTracks) { + // Check that audio tracks are loaded + if (this.videoElement.audioTracks.length <= 0) { + this.onError("The video element does not support the media's audio codec. Please try another media.") + return + } + audioLog.info("Audio tracks", this.videoElement.audioTracks) + } + + // Select the default track + this._selectDefaultTrack() + } + + _selectDefaultTrack() { + const foundTracks = this.playbackInfo.mkvMetadata?.audioTracks?.filter?.(t => (t.language || "eng") === this.settings.preferredAudioLanguage) + if (foundTracks?.length) { + // Find default or forced track + const defaultIndex = foundTracks.findIndex(t => t.forced) + this.selectTrack(foundTracks[defaultIndex >= 0 ? defaultIndex : 0].number) + } + } + + selectTrackByLabel(trackLabel: string) { + const track = this.playbackInfo.mkvMetadata?.audioTracks?.find?.(t => t.name === trackLabel) + if (track) { + this.selectTrack(track.number) + } else { + audioLog.error("Audio track not found", trackLabel) + } + } + + selectTrack(trackNumber: number) { + if (!this.videoElement.audioTracks) return + + let trackChanged = false + for (let i = 0; i < this.videoElement.audioTracks.length; i++) { + const shouldEnable = this.videoElement.audioTracks[i].id === trackNumber.toString() + if (this.videoElement.audioTracks[i].enabled !== shouldEnable) { + this.videoElement.audioTracks[i].enabled = shouldEnable + trackChanged = true + } + } + + if (trackChanged && this.videoElement.audioTracks.dispatchEvent) { + this.videoElement.audioTracks.dispatchEvent(new Event("change")) + } + } + + getSelectedTrack(): number | null { + if (!this.videoElement.audioTracks) return null + + for (let i = 0; i < this.videoElement.audioTracks.length; i++) { + if (this.videoElement.audioTracks[i].enabled) { + return Number(this.videoElement.audioTracks[i].id) + } + } + + return null + } + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-control-bar.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-control-bar.tsx new file mode 100644 index 0000000..4ec1be2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-control-bar.tsx @@ -0,0 +1,758 @@ +import { nativePlayer_stateAtom } from "@/app/(main)/_features/native-player/native-player.atoms" +import { + __seaMediaPlayer_mutedAtom, + __seaMediaPlayer_playbackRateAtom, + __seaMediaPlayer_volumeAtom, +} from "@/app/(main)/_features/sea-media-player/sea-media-player.atoms" +import { + vc_audioManager, + vc_containerElement, + vc_currentTime, + vc_cursorBusy, + vc_dispatchAction, + vc_duration, + vc_isFullscreen, + vc_isMuted, + vc_miniPlayer, + vc_paused, + vc_pip, + vc_playbackRate, + vc_seeking, + vc_subtitleManager, + vc_videoElement, + vc_volume, + VIDEOCORE_DEBUG_ELEMENTS, +} from "@/app/(main)/_features/video-core/video-core" +import { anime4kOptions, getAnime4KOptionByValue, vc_anime4kOption } from "@/app/(main)/_features/video-core/video-core-anime-4k" +import { vc_fullscreenManager } from "@/app/(main)/_features/video-core/video-core-fullscreen" +import { videoCoreKeybindingsModalAtom } from "@/app/(main)/_features/video-core/video-core-keybindings" +import { + VideoCoreMenu, + VideoCoreMenuBody, + VideoCoreMenuOption, + VideoCoreMenuSectionBody, + VideoCoreMenuSubmenuBody, + VideoCoreMenuTitle, + VideoCoreSettingSelect, +} from "@/app/(main)/_features/video-core/video-core-menu" +import { vc_pipManager } from "@/app/(main)/_features/video-core/video-core-pip" +import { vc_beautifyImageAtom, vc_highlightOPEDChaptersAtom, vc_showChapterMarkersAtom } from "@/app/(main)/_features/video-core/video-core.atoms" +import { vc_formatTime } from "@/app/(main)/_features/video-core/video-core.utils" +import { cn } from "@/components/ui/core/styling" +import { Switch } from "@/components/ui/switch" +import { atom, useAtomValue } from "jotai" +import { useAtom, useSetAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import { AnimatePresence, motion } from "motion/react" +import React from "react" +import { + LuCaptions, + LuChevronLeft, + LuChevronRight, + LuChevronUp, + LuHeadphones, + LuKeyboard, + LuPaintbrush, + LuSparkles, + LuVolume, + LuVolume1, + LuVolume2, + LuVolumeOff, +} from "react-icons/lu" +import { MdSpeed } from "react-icons/md" +import { RiPauseLargeLine, RiPlayLargeLine } from "react-icons/ri" +import { RxEnterFullScreen, RxExitFullScreen } from "react-icons/rx" +import { TbPictureInPicture, TbPictureInPictureOff } from "react-icons/tb" + +const VIDEOCORE_CONTROL_BAR_VPADDING = 5 +const VIDEOCORE_CONTROL_BAR_MAIN_SECTION_HEIGHT = 48 +const VIDEOCORE_CONTROL_BAR_MAIN_SECTION_HEIGHT_MINI = 28 + +export const vc_hoveringControlBar = atom(false) + +type VideoCoreControlBarType = "default" | "classic" +const VIDEOCORE_CONTROL_BAR_TYPE: VideoCoreControlBarType = "default" + +// VideoControlBar sits on the bottom of the video container +// shows up when cursor hovers bottom of the player or video is paused +export function VideoCoreControlBar(props: { + children?: React.ReactNode + timeRange: React.ReactNode +}) { + const { children, timeRange } = props + + const paused = useAtomValue(vc_paused) + const isMiniPlayer = useAtomValue(vc_miniPlayer) + const cursorBusy = useAtomValue(vc_cursorBusy) + const [hoveringControlBar, setHoveringControlBar] = useAtom(vc_hoveringControlBar) + const containerElement = useAtomValue(vc_containerElement) + const seeking = useAtomValue(vc_seeking) + + const mainSectionHeight = isMiniPlayer ? VIDEOCORE_CONTROL_BAR_MAIN_SECTION_HEIGHT_MINI : VIDEOCORE_CONTROL_BAR_MAIN_SECTION_HEIGHT + + // when the user is approaching the control bar + const [cursorPosition, setCursorPosition] = React.useState<"outside" | "approaching" | "hover">("outside") + + const showOnlyTimeRange = + VIDEOCORE_CONTROL_BAR_TYPE === "classic" ? ( + (!paused && cursorPosition === "approaching") + ) : + // cursor is approaching and video is not paused + (!paused && cursorPosition === "approaching") + // or cursor not hovering and video is paused + || (paused && cursorPosition === "outside") || (paused && cursorPosition === "approaching") + + const controlBarBottomPx = VIDEOCORE_CONTROL_BAR_TYPE === "classic" ? (cursorBusy || hoveringControlBar || paused) ? 0 : ( + showOnlyTimeRange ? -(mainSectionHeight) : ( + cursorPosition === "hover" ? 0 : -300 + ) + ) : ( + (cursorBusy || hoveringControlBar) ? 0 : ( + showOnlyTimeRange ? -(mainSectionHeight) : ( + cursorPosition === "hover" ? 0 : -300 + ) + ) + ) + + const hideShadow = isMiniPlayer ? !paused : VIDEOCORE_CONTROL_BAR_TYPE === "classic" + ? (!paused && cursorPosition !== "hover" && !cursorBusy) + : (cursorPosition !== "hover" && !cursorBusy) + + // const hideControlBar = !showOnlyTimeRange && !cursorBusy && !hoveringControlBar + const hideControlBar = !showOnlyTimeRange && !cursorBusy && !hoveringControlBar && (VIDEOCORE_CONTROL_BAR_TYPE === "classic" ? !paused : true) + + function handleVideoContainerPointerMove(e: Event) { + if (!containerElement) { + setCursorPosition("outside") + return + } + + const rect = containerElement.getBoundingClientRect() + const y = e instanceof PointerEvent ? e.clientY - rect.top : 0 + const registerThreshold = !isMiniPlayer ? 150 : 100 // pixels from the bottom to start registering position + const showOnlyTimeRangeOffset = !isMiniPlayer ? 50 : 50 + + if ((y >= rect.height - registerThreshold && y < rect.height - registerThreshold + showOnlyTimeRangeOffset)) { + setCursorPosition("approaching") + } else if (y < rect.height - registerThreshold) { + setCursorPosition("outside") + } else { + setCursorPosition("hover") + } + } + + function handleVideoContainerPointerLeave(e: Event) { + setCursorPosition("outside") + } + + React.useEffect(() => { + if (!containerElement) return + containerElement.addEventListener("pointermove", handleVideoContainerPointerMove) + containerElement.addEventListener("pointerleave", handleVideoContainerPointerLeave) + containerElement.addEventListener("pointercancel", handleVideoContainerPointerLeave) + return () => { + containerElement.removeEventListener("pointermove", handleVideoContainerPointerMove) + containerElement.removeEventListener("pointerup", handleVideoContainerPointerLeave) + containerElement.removeEventListener("pointercancel", handleVideoContainerPointerLeave) + } + }, [containerElement, paused, isMiniPlayer, seeking, hoveringControlBar]) + + return ( + <> + <div + className={cn( + "vc-control-bar-bottom-gradient pointer-events-none", + "absolute bottom-0 left-0 right-0 w-full z-[5] h-28 transition-opacity duration-300 opacity-0", + "bg-gradient-to-t to-transparent", + !isMiniPlayer ? "from-black/40" : "from-black/80 via-black/40", + isMiniPlayer && "h-20", + !hideShadow && "opacity-100", + )} + /> + {!isMiniPlayer && <div + className={cn( + "vc-control-bar-bottom-gradient-time-range-only pointer-events-none", + "absolute bottom-0 left-0 right-0 w-full z-[5] h-14 transition-opacity duration-400 opacity-0", + "bg-gradient-to-t to-transparent", + !isMiniPlayer ? "from-black/40" : "from-black/60", + isMiniPlayer && "h-10", + (showOnlyTimeRange && paused && hideShadow) && "opacity-100", + )} + />} + <div + data-vc-control-bar-section + className={cn( + "vc-control-bar-section", + "absolute left-0 bottom-0 right-0 flex flex-col text-white", + "transition-all duration-300 opacity-0", + "z-[100] h-28", + !hideControlBar && "opacity-100", + VIDEOCORE_DEBUG_ELEMENTS && "bg-purple-500/20", + )} + style={{ + bottom: `${controlBarBottomPx}px`, + }} + onPointerEnter={() => { + setHoveringControlBar(true) + }} + onPointerLeave={() => { + setHoveringControlBar(false) + }} + onPointerCancel={() => { + setHoveringControlBar(false) + }} + > + <div + className={cn( + "vc-control-bar", + "absolute bottom-0 w-full px-4", + VIDEOCORE_DEBUG_ELEMENTS && "bg-purple-800/40", + )} + // style={{ + // paddingTop: VIDEOCORE_CONTROL_BAR_VPADDING, + // paddingBottom: VIDEOCORE_CONTROL_BAR_VPADDING, + // }} + > + {timeRange} + + <div + className={cn( + "vc-control-bar-main-section", + "transform-gpu duration-100 flex items-center pb-2", + )} + style={{ + height: `${mainSectionHeight}px`, + // "--tw-translate-y": showOnlyTimeRange ? `-${mainSectionHeight}px` : 0, + } as React.CSSProperties} + > + {children} + </div> + </div> + </div> + </> + ) +} + +type VideoCoreControlButtonProps = { + icons: [string, React.ElementType][] + state: string + className?: string + iconClass?: string + onClick: () => void +} + +function VideoCoreControlButtonIcon(props: VideoCoreControlButtonProps) { + const { icons, state, className, iconClass, onClick } = props + + const isMiniPlayer = useAtomValue(vc_miniPlayer) + + const size = isMiniPlayer ? VIDEOCORE_CONTROL_BAR_MAIN_SECTION_HEIGHT_MINI : VIDEOCORE_CONTROL_BAR_MAIN_SECTION_HEIGHT + + return ( + <button + role="button" + style={{}} + className={cn( + "vc-control-button flex items-center justify-center px-2 transition-opacity hover:opacity-80 relative h-full", + "text-3xl", + isMiniPlayer && "text-2xl", + className, + )} + onClick={onClick} + > + <AnimatePresence> + {icons.map(n => { + const [iconState, Icon] = n + if (state !== iconState) return null + return ( + <motion.span + key={iconState} + className="block" + initial={{ opacity: 0, y: 10, position: "relative" }} + animate={{ opacity: 1, y: 0, position: "relative" }} + exit={{ opacity: 0, y: 10, position: "absolute" }} + transition={{ duration: 0.15 }} + > + <Icon + className={cn( + "vc-control-button-icon", + iconClass, + )} + /> + </motion.span> + ) + })} + </AnimatePresence> + </button> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function VideoCorePlayButton() { + const paused = useAtomValue(vc_paused) + const action = useSetAtom(vc_dispatchAction) + + return ( + <VideoCoreControlButtonIcon + icons={[ + ["playing", RiPauseLargeLine], + ["paused", RiPlayLargeLine], + ]} + state={paused ? "paused" : "playing"} + onClick={() => { + action({ type: "togglePlay" }) + }} + /> + ) +} + +export function VideoCoreVolumeButton() { + const volume = useAtomValue(vc_volume) + const muted = useAtomValue(vc_isMuted) + const setVolume = useSetAtom(__seaMediaPlayer_volumeAtom) + const setMuted = useSetAtom(__seaMediaPlayer_mutedAtom) + const isMiniPlayer = useAtomValue(vc_miniPlayer) + + const [isSliding, setIsSliding] = React.useState(false) + + // Uses a power curve to give more granular control at lower volumes + function linearToVolume(linear: number): number { + return Math.pow(linear, 2) + } + + function volumeToLinear(vol: number): number { + return Math.pow(vol, 1 / 2) + } + + function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) { + e.stopPropagation() + e.currentTarget.setPointerCapture(e.pointerId) + setIsSliding(true) + } + + function handleSetVolume(e: React.PointerEvent<HTMLDivElement>) { + const rect = e.currentTarget.getBoundingClientRect() + const x = e.clientX - rect.left + const width = e.currentTarget.clientWidth + const linearPosition = Math.max(0, Math.min(1, x / width)) + const nonLinearVolume = linearToVolume(linearPosition) + setVolume(nonLinearVolume) + setMuted(nonLinearVolume === 0) + } + + function handlePointerUp(e: React.PointerEvent<HTMLDivElement>) { + if (isSliding) { + e.stopPropagation() + e.currentTarget.setPointerCapture(e.pointerId) + setIsSliding(false) + + handleSetVolume(e) + } + } + + function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) { + if (isSliding) { + e.stopPropagation() + + handleSetVolume(e) + } + } + + return ( + <div + className={cn( + "vc-control-volume group/vc-control-volume", + "flex items-center justify-center h-full gap-2", + )} + > + <VideoCoreControlButtonIcon + icons={[ + ["low", LuVolume], + ["mid", LuVolume1], + ["high", LuVolume2], + ["muted", LuVolumeOff], + ]} + state={ + muted ? "muted" : + volume >= 0.5 ? "high" : + volume > 0.1 ? "mid" : + "low" + } + className={isMiniPlayer ? "text-[1.3rem]" : "text-2xl"} + onClick={() => { + setMuted(p => { + if (p && volume === 0) setVolume(0.1) + return !p + }) + }} + /> + <div + className={cn( + "vc-control-volume-slider-container relative w-0 flex group-hover/vc-control-volume:w-[6rem] h-6", + "transition-[width] duration-300", + )} + > + <div + className={cn( + "vc-control-volume-slider", + "flex h-full w-full relative items-center", + "rounded-full", + "cursor-pointer", + "transition-all duration-300", + )} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + > + <div + className={cn( + "vc-control-volume-slider-progress h-1.5", + "absolute bg-white", + "rounded-full", + )} + style={{ + width: `${volumeToLinear(volume) * 100}%`, + }} + /> + <div + className={cn( + "vc-control-volume-slider-progress h-1.5 w-full", + "absolute bg-white/20", + "rounded-full", + )} + /> + </div> + <div className="w-4" /> + </div> + </div> + ) +} + +export function VideoCoreNextButton({ onClick }: { onClick: () => void }) { + const isMiniPlayer = useAtomValue(vc_miniPlayer) + if (isMiniPlayer) return null + + return ( + <VideoCoreControlButtonIcon + icons={[ + ["default", LuChevronRight], + ]} + state="default" + onClick={onClick} + /> + ) +} + + +export function VideoCorePreviousButton({ onClick }: { onClick: () => void }) { + const isMiniPlayer = useAtomValue(vc_miniPlayer) + if (isMiniPlayer) return null + + return ( + <VideoCoreControlButtonIcon + icons={[ + ["default", LuChevronLeft], + ]} + state="default" + onClick={onClick} + /> + ) +} + +const vc_timestampType = atomWithStorage("sea-video-core-timestamp-type", "elapsed", undefined, { getOnInit: true }) + +export function VideoCoreTimestamp() { + const duration = useAtomValue(vc_duration) + const currentTime = useAtomValue(vc_currentTime) + const [type, setType] = useAtom(vc_timestampType) + + function handleSwitchType() { + setType(p => p === "elapsed" ? "remaining" : "elapsed") + } + + if (duration <= 1 || isNaN(duration)) return null + + return ( + <p className="font-medium text-sm opacity-100 hover:opacity-80 cursor-pointer" onClick={handleSwitchType}> + {type === "remaining" ? "-" : ""}{vc_formatTime(Math.max(0, + Math.min(duration, type === "elapsed" ? currentTime : duration - currentTime)))} / {vc_formatTime(duration)} + </p> + ) +} + +export function VideoCoreAudioButton() { + const action = useSetAtom(vc_dispatchAction) + const isMiniPlayer = useAtomValue(vc_miniPlayer) + const state = useAtomValue(nativePlayer_stateAtom) + const audioManager = useAtomValue(vc_audioManager) + const videoElement = useAtomValue(vc_videoElement) + const [selectedTrack, setSelectedTrack] = React.useState<number | null>(null) + + const audioTracks = state.playbackInfo?.mkvMetadata?.audioTracks + + function onAudioChange() { + setSelectedTrack(audioManager?.getSelectedTrack?.() ?? null) + } + + React.useEffect(() => { + if (!videoElement || !audioManager) return + + videoElement?.audioTracks?.addEventListener?.("change", onAudioChange) + return () => { + videoElement?.audioTracks?.removeEventListener?.("change", onAudioChange) + } + }, [videoElement, audioManager]) + + React.useEffect(() => { + onAudioChange() + }, [audioManager]) + + if (isMiniPlayer || !audioTracks?.length || audioTracks.length === 1) return null + + return ( + <VideoCoreMenu + trigger={<VideoCoreControlButtonIcon + icons={[ + ["default", LuHeadphones], + ]} + state="default" + className="text-2xl" + onClick={() => { + + }} + />} + > + <VideoCoreMenuTitle>Audio</VideoCoreMenuTitle> + <VideoCoreMenuBody> + <VideoCoreSettingSelect + options={audioTracks.map(track => ({ + label: `${track.name}`, + value: track.number, + moreInfo: track.language?.toUpperCase(), + }))} + onValueChange={(value) => { + audioManager?.selectTrack(value) + action({ type: "seek", payload: { time: -1 } }) + }} + value={selectedTrack || 0} + /> + </VideoCoreMenuBody> + </VideoCoreMenu> + ) +} + +export function VideoCoreSubtitleButton() { + const action = useSetAtom(vc_dispatchAction) + const isMiniPlayer = useAtomValue(vc_miniPlayer) + const state = useAtomValue(nativePlayer_stateAtom) + const subtitleManager = useAtomValue(vc_subtitleManager) + const videoElement = useAtomValue(vc_videoElement) + const [selectedTrack, setSelectedTrack] = React.useState<number | null>(null) + + const subtitleTracks = state.playbackInfo?.mkvMetadata?.subtitleTracks + + function onAudioChange() { + setSelectedTrack(subtitleManager?.getSelectedTrack?.() ?? null) + } + + React.useEffect(() => { + if (!videoElement || !subtitleManager) return + + videoElement?.textTracks?.addEventListener?.("change", onAudioChange) + return () => { + videoElement?.textTracks?.removeEventListener?.("change", onAudioChange) + } + }, [videoElement, subtitleManager]) + + React.useEffect(() => { + onAudioChange() + }, [subtitleManager]) + + if (isMiniPlayer || !subtitleTracks?.length) return null + + return ( + <VideoCoreMenu + trigger={<VideoCoreControlButtonIcon + icons={[ + ["default", LuCaptions], + ]} + state="default" + onClick={() => { + + }} + />} + > + <VideoCoreMenuTitle>Subtitles</VideoCoreMenuTitle> + <VideoCoreMenuBody> + <VideoCoreSettingSelect + options={subtitleTracks.map(track => ({ + label: `${track.name}`, + value: track.number, + moreInfo: track.language + ? `${track.language.toUpperCase()}${track.codecID ? "/" + getSubtitleTrackType(track.codecID) : ``}` + : undefined, + }))} + onValueChange={(value) => { + subtitleManager?.selectTrack(value) + }} + value={selectedTrack || 0} + /> + </VideoCoreMenuBody> + </VideoCoreMenu> + ) +} + +function getSubtitleTrackType(codecID: string) { + switch (codecID) { + case "S_TEXT/ASS": + return "SSA" + case "S_TEXT/SSA": + return "SSA" + case "S_TEXT/UTF8": + return "TEXT" + case "S_HDMV/PGS": + return "PGS" + } + return "unknown" +} + +export function VideoCoreSettingsButton() { + const action = useSetAtom(vc_dispatchAction) + const isMiniPlayer = useAtomValue(vc_miniPlayer) + const playbackRate = useAtomValue(vc_playbackRate) + const setPlaybackRate = useSetAtom(__seaMediaPlayer_playbackRateAtom) + + const [anime4kOption, setAnime4kOption] = useAtom(vc_anime4kOption) + const currentAnime4kOption = getAnime4KOptionByValue(anime4kOption) + + const [, setKeybindingsModelOpen] = useAtom(videoCoreKeybindingsModalAtom) + + const [showChapterMarkers, setShowChapterMarkers] = useAtom(vc_showChapterMarkersAtom) + const [highlightOPEDChapters, setHighlightOPEDChapters] = useAtom(vc_highlightOPEDChaptersAtom) + const [beautifyImage, setBeautifyImage] = useAtom(vc_beautifyImageAtom) + + if (isMiniPlayer) return null + + return ( + <VideoCoreMenu + trigger={<VideoCoreControlButtonIcon + icons={[ + ["default", LuChevronUp], + ]} + state="default" + onClick={() => { + }} + />} + > + <VideoCoreMenuSectionBody> + <VideoCoreMenuTitle>Settings</VideoCoreMenuTitle> + <VideoCoreMenuOption title="Playback Speed" icon={MdSpeed} value={`${playbackRate}x`} /> + <VideoCoreMenuOption title="Anime4K" icon={LuSparkles} value={currentAnime4kOption?.label || "Off"} /> + <VideoCoreMenuOption title="Appearance" icon={LuPaintbrush} /> + <VideoCoreMenuOption title="Keybinds" icon={LuKeyboard} onClick={() => setKeybindingsModelOpen(true)} /> + </VideoCoreMenuSectionBody> + <VideoCoreMenuSubmenuBody> + <VideoCoreMenuOption title="Playback Speed" icon={MdSpeed}> + <VideoCoreSettingSelect + options={[ + { label: "0.5x", value: 0.5 }, + { label: "0.9x", value: 0.9 }, + { label: "1x", value: 1 }, + { label: "1.1x", value: 1.1 }, + { label: "1.5x", value: 1.5 }, + { label: "2x", value: 2 }, + ]} + onValueChange={(v: number) => { + setPlaybackRate(v) + }} + value={playbackRate} + /> + </VideoCoreMenuOption> + <VideoCoreMenuOption title="Anime4K" icon={LuSparkles}> + <p className="text-[--muted] text-sm mb-2"> + Real-time upscaling. Do not enable if you have a low-end GPU or none. + </p> + <VideoCoreSettingSelect + options={anime4kOptions.map(option => ({ + label: `${option.label}`, + value: option.value, + moreInfo: option.performance === "heavy" ? "Heavy" : undefined, + description: option.description, + }))} + onValueChange={(value) => { + setAnime4kOption(value) + }} + value={anime4kOption} + /> + </VideoCoreMenuOption> + <VideoCoreMenuOption title="Appearance" icon={LuPaintbrush}> + <Switch + label="Show Chapter Markers" + side="right" + fieldClass="hover:bg-transparent px-0 ml-0 w-full" + size="sm" + value={showChapterMarkers} + onValueChange={setShowChapterMarkers} + /> + <Switch + label="Highlight OP/ED Chapters" + side="right" + fieldClass="hover:bg-transparent px-0 ml-0 w-full" + size="sm" + value={highlightOPEDChapters} + onValueChange={setHighlightOPEDChapters} + /> + <Switch + label="Apply enhancement filters" + side="right" + fieldClass="hover:bg-transparent px-0 ml-0 w-full" + size="sm" + value={beautifyImage} + onValueChange={setBeautifyImage} + /> + </VideoCoreMenuOption> + </VideoCoreMenuSubmenuBody> + </VideoCoreMenu> + ) +} + +export function VideoCorePipButton() { + const pipManager = useAtomValue(vc_pipManager) + const isPip = useAtomValue(vc_pip) + const isMiniPlayer = useAtomValue(vc_miniPlayer) + + if (isMiniPlayer) return null + + return ( + <VideoCoreControlButtonIcon + icons={[ + ["default", TbPictureInPicture], + ["pip", TbPictureInPictureOff], + ]} + state={isPip ? "pip" : "default"} + onClick={() => { + pipManager?.togglePip() + }} + /> + ) +} + +export function VideoCoreFullscreenButton() { + const fullscreenManager = useAtomValue(vc_fullscreenManager) + const isFullscreen = useAtomValue(vc_isFullscreen) + + return ( + <VideoCoreControlButtonIcon + icons={[ + ["default", RxEnterFullScreen], + ["fullscreen", RxExitFullScreen], + ]} + state={isFullscreen ? "fullscreen" : "default"} + onClick={() => { + fullscreenManager?.toggleFullscreen() + }} + /> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-drawer.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-drawer.tsx new file mode 100644 index 0000000..1ca4d48 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-drawer.tsx @@ -0,0 +1,486 @@ +"use client" + +import { CloseButton } from "@/components/ui/button" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "@/components/ui/core/styling" +import { __isDesktop__ } from "@/types/constants" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { VisuallyHidden } from "@radix-ui/react-visually-hidden" +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const DrawerAnatomy = defineStyleAnatomy({ + overlay: cva([ + "UI-Drawer__overlay", + // "transition-opacity duration-300", + ]), + content: cva([ + "UI-Drawer__content", + "fixed z-50 w-full", + "transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-500 data-[state=open]:duration-500", + "focus:outline-none focus-visible:outline-none outline-none", + __isDesktop__ && "select-none", + ], { + variants: { + side: { + player: "w-full inset-x-0 top-0 data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + top: "w-full lg:w-[calc(100%_-_20px)] inset-x-0 top-0 border data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: "w-full lg:w-[calc(100%_-_20px)] inset-x-0 bottom-0 border data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full lg:h-[calc(100%_-_20px)] border data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left", + right: "inset-y-0 right-0 h-full lg:h-[calc(100%_-_20px)] border data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right", + }, + size: { sm: null, md: null, lg: null, xl: null, full: null }, + }, + defaultVariants: { + side: "right", + size: "md", + }, + compoundVariants: [ + { size: "sm", side: "left", className: "sm:max-w-sm" }, + { size: "sm", side: "right", className: "sm:max-w-sm" }, + { size: "md", side: "left", className: "sm:max-w-md" }, + { size: "md", side: "right", className: "sm:max-w-md" }, + { size: "lg", side: "left", className: "sm:max-w-2xl" }, + { size: "lg", side: "right", className: "sm:max-w-2xl" }, + { size: "xl", side: "left", className: "sm:max-w-5xl" }, + { size: "xl", side: "right", className: "sm:max-w-5xl" }, + /**/ + { size: "full", side: "top", className: "h-dvh" }, + { size: "full", side: "bottom", className: "h-dvh" }, + ], + }), + close: cva([ + "UI-Drawer__close", + "absolute right-4 top-4", + ]), + header: cva([ + "UI-Drawer__header", + "flex flex-col space-y-1.5 text-center sm:text-left", + ]), + footer: cva([ + "UI-Drawer__footer", + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + ]), + title: cva([ + "UI-Drawer__title", + "text-xl font-semibold leading-none tracking-tight", + ]), + description: cva([ + "UI-Drawer__description", + "text-sm text-[--muted]", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Drawer + * -----------------------------------------------------------------------------------------------*/ + +type DrawerProps = Omit<React.ComponentPropsWithoutRef<typeof DialogPrimitive.Root>, "modal"> & + Pick<React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>, + "onOpenAutoFocus" | "onCloseAutoFocus" | "onEscapeKeyDown" | "onPointerDownCapture" | "onInteractOutside"> & + VariantProps<typeof DrawerAnatomy.content> & + ComponentAnatomy<typeof DrawerAnatomy> & { + /** + * Interaction with outside elements will be enabled and other elements will be visible to screen readers. + */ + allowOutsideInteraction?: boolean + /** + * The button that opens the modal + */ + trigger?: React.ReactElement + /** + * Title of the modal + */ + title?: React.ReactNode + /** + * An optional accessible description to be announced when the dialog is opened. + */ + description?: React.ReactNode + /** + * Footer of the modal + */ + footer?: React.ReactNode + /** + * Optional replacement for the default close button + */ + closeButton?: React.ReactElement + /** + * Whether to hide the close button + */ + hideCloseButton?: boolean + /** + * Portal container + */ + portalContainer?: HTMLElement + + borderToBorder?: boolean + + miniPlayer?: boolean + + onMiniPlayerClick?: () => void +} + +export function VideoCoreDrawer(props: DrawerProps) { + + const { + allowOutsideInteraction = false, + trigger, + title, + footer, + description, + children, + closeButton, + overlayClass, + contentClass, + closeClass, + headerClass, + footerClass, + titleClass, + descriptionClass, + hideCloseButton, + side = "right", + size, + open, + // Content + onOpenAutoFocus, + onCloseAutoFocus, + onEscapeKeyDown, + onPointerDownCapture, + onInteractOutside, + portalContainer, + miniPlayer, + onMiniPlayerClick, + ...rest + } = props + + React.useEffect(() => { + if (open && size === "full") { + document.body.setAttribute("data-scroll-locked", "1") + } else if (size === "full") { + document.body.removeAttribute("data-scroll-locked") + } + }, [open, size]) + + const isMiniPlayerRef = React.useRef(miniPlayer) + + React.useEffect(() => { + setTimeout(() => { + isMiniPlayerRef.current = miniPlayer + }, 500) + }, [miniPlayer]) + + // Dragging + const contentRef = React.useRef<HTMLDivElement>(null) + const draggableAreaRef = React.useRef<HTMLDivElement>(null) + + // Calculate initial position immediately based on known dimensions + const getInitialPosition = React.useCallback(() => { + // Use the known CSS dimensions from the className + const width = window.innerWidth >= 1024 ? 400 : 300 // lg:w-[400px] w-[300px] + const height = width * (9 / 16) // aspect-video + + const rightBoundary = window.innerWidth - width - PADDING + const bottomBoundary = window.innerHeight - height - PADDING + + return { x: rightBoundary, y: bottomBoundary } + }, []) + + // Dragging functionality + const [position, setPosition] = React.useState({ x: 0, y: 0 }) + const [isDragging, setIsDragging] = React.useState(false) + const [isHidden, setIsHidden] = React.useState(false) + const dragStartPos = React.useRef({ x: 0, y: 0 }) + const elementStartPos = React.useRef({ x: 0, y: 0 }) + const PADDING = 20 // Define padding constant + const AUTO_HIDE_THRESHOLD = 0.5 // Hide when 50% is overflowing + + const calculateBoundaries = React.useCallback(() => { + if (!contentRef.current) return null + + const width = contentRef.current.offsetWidth || 0 + const height = contentRef.current.offsetHeight || 0 + + return { + leftBoundary: 80 + PADDING, + rightBoundary: window.innerWidth - width - PADDING, + topBoundary: PADDING, + bottomBoundary: window.innerHeight - height - PADDING, + width, + height, + } + }, []) + + React.useLayoutEffect(() => { + if (miniPlayer) { + setPosition(getInitialPosition()) + setIsHidden(false) + } + }, [miniPlayer, getInitialPosition]) + + // Handle dragging only when in mini player mode + React.useEffect(() => { + if (!miniPlayer || !contentRef.current) return + + const handleMouseDown = (e: MouseEvent) => { + setIsDragging(true) + dragStartPos.current = { x: e.clientX, y: e.clientY } + elementStartPos.current = { x: position.x, y: position.y } + } + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging || !contentRef.current) return + + const deltaX = e.clientX - dragStartPos.current.x + const deltaY = e.clientY - dragStartPos.current.y + + const newX = elementStartPos.current.x + deltaX + const newY = elementStartPos.current.y + deltaY + + const boundaries = calculateBoundaries() + if (!boundaries) return + + // Check for auto-hide (when dragged to the right edge) + const hideThresholdX = window.innerWidth - (boundaries.width * AUTO_HIDE_THRESHOLD) + if (newX >= hideThresholdX) { + if (!isHidden) { + setIsHidden(true) + } + setPosition({ x: window.innerWidth - boundaries.width * 0.15, y: newY }) // Show just a sliver + return + } else { + if (isHidden) { + setIsHidden(false) + } + } + + // Apply boundaries for normal dragging + const boundedX = Math.max(boundaries.leftBoundary, Math.min(newX, boundaries.rightBoundary * 1.25)) + const boundedY = Math.max(boundaries.topBoundary, Math.min(newY, boundaries.bottomBoundary)) + + setPosition({ x: boundedX, y: boundedY }) + } + + const handleMouseUp = (e: MouseEvent) => { + setIsDragging(false) + + // If hidden, snap to hidden position or reveal based on drag behavior + if (isHidden) { + const boundaries = calculateBoundaries() + if (!boundaries) return + + // If dragged back far enough to the left, show it again + if (position.x < window.innerWidth - boundaries.width * 0.5) { + setIsHidden(false) + setPosition({ x: boundaries.rightBoundary, y: position.y }) + } else { + // Keep it hidden at the edge + setPosition({ x: window.innerWidth - boundaries.width * 0.1, y: position.y }) + } + return + } + + // if it's just a click and the target is the draggable area, do nothing + if (Math.abs(position.x - elementStartPos.current.x) < 10 && Math.abs(position.y - elementStartPos.current.y) < 10) { + if (e.target === draggableAreaRef.current) { + onMiniPlayerClick?.() + return + } + } + + // Snap to the nearest corner when dragging stops + const boundaries = calculateBoundaries() + if (!boundaries) return + + const corners = [ + { x: boundaries.leftBoundary, y: boundaries.topBoundary }, // Top-left + { x: boundaries.rightBoundary, y: boundaries.topBoundary }, // Top-right + { x: boundaries.leftBoundary, y: boundaries.bottomBoundary }, // Bottom-left + { x: boundaries.rightBoundary, y: boundaries.bottomBoundary }, // Bottom-right + ] + + // Find the nearest corner + let nearestCorner = corners[0] + let minDistance = Number.MAX_VALUE + + corners.forEach(corner => { + const distance = Math.sqrt( + Math.pow(position.x - corner.x, 2) + + Math.pow(position.y - corner.y, 2), + ) + + if (distance < minDistance) { + minDistance = distance + nearestCorner = corner + } + }) + + // Snap to the nearest corner + setPosition({ x: nearestCorner.x, y: nearestCorner.y }) + } + + // Add event listeners + draggableAreaRef.current?.addEventListener("mousedown", handleMouseDown) + window.addEventListener("mousemove", handleMouseMove) + window.addEventListener("mouseup", handleMouseUp) + + // Clean up + return () => { + draggableAreaRef.current?.removeEventListener("mousedown", handleMouseDown) + window.removeEventListener("mousemove", handleMouseMove) + window.removeEventListener("mouseup", handleMouseUp) + } + }, [miniPlayer, isDragging, position, calculateBoundaries, isHidden]) + + // Handle window resize to maintain proper positioning + React.useEffect(() => { + if (!miniPlayer) return + + const handleResize = () => { + const boundaries = calculateBoundaries() + if (!boundaries) return + + // Adjust position if it's now outside boundaries + setPosition(prevPosition => { + let newX = prevPosition.x + let newY = prevPosition.y + + // If hidden, maintain hidden state but adjust position + if (isHidden) { + newX = window.innerWidth - boundaries.width * 0.1 + } else { + // Ensure position is within new boundaries + newX = Math.max(boundaries.leftBoundary, Math.min(prevPosition.x, boundaries.rightBoundary)) + newY = Math.max(boundaries.topBoundary, Math.min(prevPosition.y, boundaries.bottomBoundary)) + } + + return { x: newX, y: newY } + }) + } + + window.addEventListener("resize", handleResize) + return () => window.removeEventListener("resize", handleResize) + }, [miniPlayer, calculateBoundaries, isHidden]) + + + // Apply position styles when in mini player mode + React.useEffect(() => { + if (!contentRef.current || !miniPlayer) return + + // Use current position or calculate initial position if not set + const currentPosition = (position.x === 0 && position.y === 0) ? getInitialPosition() : position + + contentRef.current.style.position = "fixed" + contentRef.current.style.left = `${currentPosition.x}px` + contentRef.current.style.top = `${currentPosition.y}px` + contentRef.current.style.zIndex = isHidden ? "40" : "50" // Lower z-index when hidden + + // Handle opacity and scale for hiding/showing + contentRef.current.style.opacity = isHidden ? "0.7" : "1" + contentRef.current.style.transform = isHidden ? "scale(0.95)" : "scale(1)" + + // Add transition for smooth snapping and hiding/showing, remove it during dragging + if (isMiniPlayerRef.current) { + if (!isDragging) { + contentRef.current.style.transition = "left 0.3s cubic-bezier(0.4, 0, 0.2, 1), top 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease-out, transform 0.2s ease-out" + } else { + contentRef.current.style.transition = "opacity 0.2s ease-out, transform 0.2s ease-out" // Keep opacity and scale transition during + // drag + } + } + + return () => { + if (contentRef.current) { + contentRef.current.style.position = "" + contentRef.current.style.left = "" + contentRef.current.style.top = "" + contentRef.current.style.transform = "" + contentRef.current.style.cursor = "" + contentRef.current.style.transition = "" + contentRef.current.style.zIndex = "" + contentRef.current.style.opacity = "" + } + } + }, [miniPlayer, position, isDragging, isHidden, getInitialPosition]) + + return ( + <DialogPrimitive.Root modal={!allowOutsideInteraction} open={open} {...rest}> + + {trigger && <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>} + + <DialogPrimitive.Portal container={portalContainer}> + + {/* <DialogPrimitive.Overlay className={cn(DrawerAnatomy.overlay(), overlayClass)} /> */} + + <DialogPrimitive.Content + className={cn( + DrawerAnatomy.content({ size, side: "player" }), + contentClass, + "w-full h-full transition-all duration-300 overflow-hidden fixed", + miniPlayer && "aspect-video w-[300px] lg:w-[400px] h-auto rounded-lg shadow-xl", + isHidden && "ring-2 ring-brand-300", + )} + ref={contentRef} + onOpenAutoFocus={e => e.preventDefault()} + onCloseAutoFocus={onCloseAutoFocus} + onEscapeKeyDown={onEscapeKeyDown} + onPointerDownCapture={onPointerDownCapture} + onInteractOutside={e => e.preventDefault()} + tabIndex={-1} + > + {!title && !description ? ( + <VisuallyHidden> + <DialogPrimitive.Title>Drawer</DialogPrimitive.Title> + </VisuallyHidden> + ) : ( + <div className={cn(DrawerAnatomy.header(), headerClass)}> + <DialogPrimitive.Title + className={cn( + DrawerAnatomy.title(), + __isDesktop__ && "relative", + titleClass, + )} + > + {title} + </DialogPrimitive.Title> + {description && ( + <DialogPrimitive.Description className={cn(DrawerAnatomy.description(), descriptionClass)}> + {description} + </DialogPrimitive.Description> + )} + </div> + )} + + {miniPlayer && <div ref={draggableAreaRef} className="vc-drawer-draggable-area absolute inset-0 z-[6]"> + + </div>} + + + {children} + + {footer && <div className={cn(DrawerAnatomy.footer(), footerClass)}> + {footer} + </div>} + + {!hideCloseButton && <DialogPrimitive.Close + className={cn( + DrawerAnatomy.close(), + // __isDesktop__ && "!top-10 !right-4", + closeClass, + )} + asChild + > + {closeButton ? closeButton : <CloseButton />} + </DialogPrimitive.Close>} + + </DialogPrimitive.Content> + + </DialogPrimitive.Portal> + + </DialogPrimitive.Root> + ) +} + +VideoCoreDrawer.displayName = "NativePlayerDrawer" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-fullscreen.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-fullscreen.ts new file mode 100644 index 0000000..d08fec5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-fullscreen.ts @@ -0,0 +1,105 @@ +import { logger } from "@/lib/helpers/debug" +import { atom } from "jotai" + +const log = logger("VIDEO CORE FULLSCREEN") + +export const vc_fullscreenManager = atom<VideoCoreFullscreenManager | null>(null) + +export class VideoCoreFullscreenManager { + private containerElement: HTMLElement | null = null + private controller = new AbortController() + private onFullscreenChange: (isFullscreen: boolean) => void + + constructor(onFullscreenChange: (isFullscreen: boolean) => void) { + this.onFullscreenChange = onFullscreenChange + this.attachDocumentListeners() + } + + setContainer(containerElement: HTMLElement) { + this.containerElement = containerElement + } + + isFullscreen(): boolean { + return !!( + document.fullscreenElement || + (document as any).webkitFullscreenElement || + (document as any).mozFullScreenElement || + (document as any).msFullscreenElement + ) + } + + async toggleFullscreen() { + if (this.isFullscreen()) { + await this.exitFullscreen() + } else { + await this.enterFullscreen() + } + } + + async exitFullscreen() { + try { + if (document.exitFullscreen) { + await document.exitFullscreen() + } else if ((document as any).webkitExitFullscreen) { + await (document as any).webkitExitFullscreen() + } else if ((document as any).mozCancelFullScreen) { + await (document as any).mozCancelFullScreen() + } else if ((document as any).msExitFullscreen) { + await (document as any).msExitFullscreen() + } + log.info("Exited fullscreen") + } + catch (error) { + log.error("Failed to exit fullscreen", error) + } + } + + async enterFullscreen() { + if (!this.containerElement) { + log.warning("Container element not set") + return + } + + try { + if (this.containerElement.requestFullscreen) { + await this.containerElement.requestFullscreen() + } else if ((this.containerElement as any).webkitRequestFullscreen) { + await (this.containerElement as any).webkitRequestFullscreen() + } else if ((this.containerElement as any).mozRequestFullScreen) { + await (this.containerElement as any).mozRequestFullScreen() + } else if ((this.containerElement as any).msRequestFullscreen) { + await (this.containerElement as any).msRequestFullscreen() + } + log.info("Entered fullscreen") + } + catch (error) { + log.error("Failed to enter fullscreen", error) + } + } + + destroy() { + this.controller.abort() + this.containerElement = null + } + + private attachDocumentListeners() { + document.addEventListener("fullscreenchange", this.handleFullscreenChange, { + signal: this.controller.signal, + }) + document.addEventListener("webkitfullscreenchange", this.handleFullscreenChange, { + signal: this.controller.signal, + }) + document.addEventListener("mozfullscreenchange", this.handleFullscreenChange, { + signal: this.controller.signal, + }) + document.addEventListener("msfullscreenchange", this.handleFullscreenChange, { + signal: this.controller.signal, + }) + } + + private handleFullscreenChange = () => { + const isFullscreen = this.isFullscreen() + log.info("Fullscreen state changed:", isFullscreen) + this.onFullscreenChange(isFullscreen) + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-keybindings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-keybindings.tsx new file mode 100644 index 0000000..9f06d92 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-keybindings.tsx @@ -0,0 +1,737 @@ +import { __seaMediaPlayer_mutedAtom, __seaMediaPlayer_volumeAtom } from "@/app/(main)/_features/sea-media-player/sea-media-player.atoms" +import { + vc_audioManager, + vc_dispatchAction, + vc_isFullscreen, + vc_isMuted, + vc_pip, + vc_subtitleManager, + vc_volume, + VideoCoreChapterCue, +} from "@/app/(main)/_features/video-core/video-core" +import { useVideoCoreFlashAction } from "@/app/(main)/_features/video-core/video-core-action-display" +import { vc_fullscreenManager } from "@/app/(main)/_features/video-core/video-core-fullscreen" +import { vc_defaultKeybindings, vc_keybindingsAtom, VideoCoreKeybindings } from "@/app/(main)/_features/video-core/video-core.atoms" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import { NumberInput } from "@/components/ui/number-input" +import { logger } from "@/lib/helpers/debug" +import { atom, useAtom, useAtomValue } from "jotai" +import { useSetAtom } from "jotai/react" +import React, { useCallback, useEffect, useRef, useState } from "react" +import { useVideoCoreScreenshot } from "./video-core-screenshot" + +export const videoCoreKeybindingsModalAtom = atom(false) + +const KeybindingValueInput = React.memo(({ + actionKey, + value, + onValueChange, +}: { + actionKey: keyof VideoCoreKeybindings + value: number + onValueChange: (value: number) => void +}) => { + return ( + <NumberInput + value={value} + onValueChange={onValueChange} + size="sm" + fieldClass="w-16" + hideControls + min={0} + step={actionKey.includes("Speed") ? 0.25 : 1} + onKeyDown={(e) => e.stopPropagation()} + onInput={(e) => e.stopPropagation()} + /> + ) +}) + +export function VideoCoreKeybindingsModal() { + const [open, setOpen] = useAtom(videoCoreKeybindingsModalAtom) + const [keybindings, setKeybindings] = useAtom(vc_keybindingsAtom) + const [editedKeybindings, setEditedKeybindings] = useState<VideoCoreKeybindings>(keybindings) + const [recordingKey, setRecordingKey] = useState<string | null>(null) + + // Reset edited keybindings when modal opens + useEffect(() => { + if (open) { + setEditedKeybindings(keybindings) + } + }, [open, keybindings]) + + const handleKeyRecord = (actionKey: keyof VideoCoreKeybindings) => { + setRecordingKey(actionKey) + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + + setEditedKeybindings(prev => ({ + ...prev, + [actionKey]: { + ...prev[actionKey], + key: e.code, + }, + })) + + setRecordingKey(null) + document.removeEventListener("keydown", handleKeyDown, true) + } + + document.addEventListener("keydown", handleKeyDown, true) + } + + const handleSave = () => { + setKeybindings(editedKeybindings) + setOpen(false) + } + + const handleReset = () => { + setEditedKeybindings(vc_defaultKeybindings) + } + + const formatKeyDisplay = (keyCode: string) => { + const keyMap: Record<string, string> = { + "KeyA": "A", "KeyB": "B", "KeyC": "C", "KeyD": "D", "KeyE": "E", "KeyF": "F", + "KeyG": "G", "KeyH": "H", "KeyI": "I", "KeyJ": "J", "KeyK": "K", "KeyL": "L", + "KeyM": "M", "KeyN": "N", "KeyO": "O", "KeyP": "P", "KeyQ": "Q", "KeyR": "R", + "KeyS": "S", "KeyT": "T", "KeyU": "U", "KeyV": "V", "KeyW": "W", "KeyX": "X", + "KeyY": "Y", "KeyZ": "Z", + "ArrowUp": "↑", "ArrowDown": "↓", "ArrowLeft": "←", "ArrowRight": "→", + "BracketLeft": "[", "BracketRight": "]", + "Space": "⎵", + } + return keyMap[keyCode] || keyCode + } + + const KeybindingRow = React.useCallback(({ + action, + description, + actionKey, + hasValue = false, + valueLabel = "", + }: { + action: string + description: string + actionKey: keyof VideoCoreKeybindings + hasValue?: boolean + valueLabel?: string + }) => ( + <div className="flex items-center justify-between py-3 border-b border-border/50 last:border-b-0"> + <div className="flex-1"> + <div className="font-medium text-sm">{action}</div> + {hasValue && ( + <div className="flex items-center gap-2 mt-1"> + <span className="text-xs text-muted-foreground">{valueLabel}:</span> + <KeybindingValueInput + actionKey={actionKey} + value={("value" in editedKeybindings[actionKey]) ? (editedKeybindings[actionKey] as any).value : 0} + onValueChange={(value) => setEditedKeybindings(prev => ({ + ...prev, + [actionKey]: { ...prev[actionKey], value: value || 0 }, + }))} + /> + </div> + )} + </div> + <div className="flex items-center gap-2"> + <Button + intent={recordingKey === actionKey ? "white-subtle" : "gray-outline"} + size="sm" + onClick={() => handleKeyRecord(actionKey)} + className={cn( + "h-8 px-3 text-lg font-mono", + recordingKey === actionKey && "!text-xs text-white", + )} + > + {recordingKey === actionKey ? "Press key..." : formatKeyDisplay(editedKeybindings?.[actionKey]?.key ?? "")} + </Button> + </div> + </div> + ), [editedKeybindings, recordingKey, formatKeyDisplay, setEditedKeybindings, handleKeyRecord]) + + return ( + <Modal + title="Keyboard Shortcuts" + description="Customize the keyboard shortcuts for the player" + open={open} + onOpenChange={setOpen} + contentClass="max-w-5xl focus:outline-none focus-visible:outline-none outline-none bg-black/80 backdrop-blur-sm z-[101]" + > + <div className="grid grid-cols-3 gap-8"> + <div> + {/* <h3 className="text-lg font-semibold mb-4 text-white">Playback</h3> */} + <div className="space-y-0"> + <KeybindingRow + action="Seek Forward" + description="Seek forward" + actionKey="seekForward" + hasValue={true} + valueLabel="Seconds" + /> + <KeybindingRow + action="Seek Backward" + description="Seek backward" + actionKey="seekBackward" + hasValue={true} + valueLabel="Seconds" + /> + <KeybindingRow + action="Seek Forward (Fine)" + description="Seek forward (fine)" + actionKey="seekForwardFine" + hasValue={true} + valueLabel="Seconds" + /> + <KeybindingRow + action="Seek Backward (Fine)" + description="Seek backward (fine)" + actionKey="seekBackwardFine" + hasValue={true} + valueLabel="Seconds" + /> + <KeybindingRow + action="Increase Speed" + description="Increase playback speed" + actionKey="increaseSpeed" + hasValue={true} + valueLabel="increment" + /> + <KeybindingRow + action="Decrease Speed" + description="Decrease playback speed" + actionKey="decreaseSpeed" + hasValue={true} + valueLabel="increment" + /> + </div> + </div> + + <div> + {/* <h3 className="text-lg font-semibold mb-4 text-white">Navigation</h3> */} + <div className="space-y-0"> + <KeybindingRow + action="Next Chapter" + description="Skip to next chapter" + actionKey="nextChapter" + /> + <KeybindingRow + action="Previous Chapter" + description="Skip to previous chapter" + actionKey="previousChapter" + /> + <KeybindingRow + action="Next Episode" + description="Play next episode" + actionKey="nextEpisode" + /> + <KeybindingRow + action="Previous Episode" + description="Play previous episode" + actionKey="previousEpisode" + /> + <KeybindingRow + action="Cycle Subtitles" + description="Cycle through subtitle tracks" + actionKey="cycleSubtitles" + /> + <KeybindingRow + action="Fullscreen" + description="Toggle fullscreen" + actionKey="fullscreen" + /> + <KeybindingRow + action="Picture in Picture" + description="Toggle picture in picture" + actionKey="pictureInPicture" + /> + <KeybindingRow + action="Take Screenshot" + description="Take screenshot" + actionKey="takeScreenshot" + /> + </div> + </div> + + <div> + {/* <h3 className="text-lg font-semibold mb-4 text-white">Audio</h3> */} + <div className="space-y-0"> + <KeybindingRow + action="Volume Up" + description="Increase volume" + actionKey="volumeUp" + hasValue={true} + valueLabel="Percent" + /> + <KeybindingRow + action="Volume Down" + description="Decrease volume" + actionKey="volumeDown" + hasValue={true} + valueLabel="Percent" + /> + <KeybindingRow + action="Mute" + description="Toggle mute" + actionKey="mute" + /> + <KeybindingRow + action="Cycle Audio" + description="Cycle through audio tracks" + actionKey="cycleAudio" + /> + </div> + </div> + </div> + + <div className="flex items-center justify-between pt-6 mt-6 border-t border-border"> + <Button + intent="gray-outline" + onClick={handleReset} + > + Reset to Defaults + </Button> + <div className="flex gap-2"> + <Button + intent="gray-outline" + onClick={() => setOpen(false)} + > + Cancel + </Button> + <Button + intent="primary" + onClick={handleSave} + > + Save Changes + </Button> + </div> + </div> + </Modal> + ) +} + +export function VideoCoreKeybindingController(props: { + active: boolean + videoRef: React.RefObject<HTMLVideoElement>, + chapterCues: VideoCoreChapterCue[], + introEndTime: number | undefined, + introStartTime: number | undefined + endingEndTime: number | undefined, + endingStartTime: number | undefined +}) { + const { + active, + videoRef, + chapterCues, + introEndTime, + introStartTime, + endingEndTime, + endingStartTime, + } = props + + const [keybindings] = useAtom(vc_keybindingsAtom) + const isKeybindingsModalOpen = useAtomValue(videoCoreKeybindingsModalAtom) + const fullscreen = useAtomValue(vc_isFullscreen) + const pip = useAtomValue(vc_pip) + const volume = useAtomValue(vc_volume) + const setVolume = useSetAtom(__seaMediaPlayer_volumeAtom) + const muted = useAtomValue(vc_isMuted) + const setMuted = useSetAtom(__seaMediaPlayer_mutedAtom) + const { flashAction } = useVideoCoreFlashAction() + + const action = useSetAtom(vc_dispatchAction) + + const subtitleManager = useAtomValue(vc_subtitleManager) + const audioManager = useAtomValue(vc_audioManager) + const fullscreenManager = useAtomValue(vc_fullscreenManager) + + // Rate limiting for seeking operations + const lastSeekTime = useRef(0) + const SEEK_THROTTLE_MS = 100 // Minimum time between seek operations + + function seek(seconds: number) { + action({ type: "seek", payload: { time: seconds, flashTime: true } }) + } + + function seekTo(to: number) { + action({ type: "seekTo", payload: { time: to, flashTime: true } }) + } + + const { takeScreenshot } = useVideoCoreScreenshot() + + // + // Keyboard shortcuts + // + + const handleKeyboardShortcuts = useCallback((e: KeyboardEvent) => { + // Don't handle shortcuts if in an input/textarea or while keybindings modal is open + if (isKeybindingsModalOpen || e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return + } + + if (!videoRef.current || !active) { + return + } + + const video = videoRef.current + + + if (e.code === "Space" || e.code === "Enter") { + e.preventDefault() + if (video.paused) { + video.play() + flashAction({ message: "PLAY", type: "icon" }) + } else { + video.pause() + flashAction({ message: "PAUSE", type: "icon" }) + } + return + } + + // Home, go to beginning + if (e.code === "Home") { + e.preventDefault() + seekTo(0) + flashAction({ message: "Beginning" }) + return + } + + // End, go to end + if (e.code === "End") { + e.preventDefault() + seekTo(video.duration) + flashAction({ message: "End" }) + return + } + + // Escape - Exit fullscreen + if (e.code === "Escape" && fullscreen) { + e.preventDefault() + document.exitFullscreen() + return + } + + // Number keys 0-9, seek to percentage (0%, 10%, 20%, ..., 90%) + if (e.code.startsWith("Digit") && e.code.length === 6) { + e.preventDefault() + const digit = parseInt(e.code.slice(-1)) + const percentage = digit * 10 + const seekTime = Math.max(0, Math.min(video.duration, (video.duration * percentage) / 100)) + seekTo(seekTime) + // flashAction({ message: `${percentage}%` }) + return + } + + // frame-by-frame seeking, assuming 24fps + if (e.code === "Comma") { + e.preventDefault() + seek(-1 / 24) + flashAction({ message: "Previous Frame" }) + return + } + + if (e.code === "Period") { + e.preventDefault() + seek(1 / 24) + flashAction({ message: "Next Frame" }) + return + } + + // Helper function to check if seeking is rate limited + const canSeek = () => { + const now = Date.now() + if (now - lastSeekTime.current < SEEK_THROTTLE_MS) { + return false + } + lastSeekTime.current = now + return true + } + + // Check which shortcut was pressed + if (e.code === keybindings.seekForward.key) { + e.preventDefault() + if (!canSeek()) return + + if (props.introEndTime && props.introStartTime && video.currentTime < props.introEndTime && video.currentTime >= props.introStartTime) { + seekTo(props.introEndTime) + flashAction({ message: "Skipped Opening" }) + return + } + if (props.endingEndTime && props.endingStartTime && video.currentTime < props.endingEndTime && video.currentTime >= props.endingStartTime) { + seekTo(props.endingEndTime) + flashAction({ message: "Skipped Ending" }) + return + } + seek(keybindings.seekForward.value) + video.dispatchEvent(new Event("seeked")) + } else if (e.code === keybindings.seekBackward.key) { + e.preventDefault() + if (!canSeek()) return + seek(-keybindings.seekBackward.value) + video.dispatchEvent(new Event("seeked")) + } else if (e.code === keybindings.seekForwardFine.key) { + e.preventDefault() + // if (!canSeek()) return + video.dispatchEvent(new Event("seeking")) + seek(keybindings.seekForwardFine.value) + video.dispatchEvent(new Event("seeked")) + } else if (e.code === keybindings.seekBackwardFine.key) { + e.preventDefault() + // if (!canSeek()) return + video.dispatchEvent(new Event("seeking")) + seek(-keybindings.seekBackwardFine.value) + video.dispatchEvent(new Event("seeked")) + } else if (e.code === keybindings.nextChapter.key) { + e.preventDefault() + handleNextChapter() + } else if (e.code === keybindings.previousChapter.key) { + e.preventDefault() + handlePreviousChapter() + } else if (e.code === keybindings.volumeUp.key) { + e.preventDefault() + const newVolume = Math.min(1, volume + keybindings.volumeUp.value / 100) + setVolume(newVolume) + } else if (e.code === keybindings.volumeDown.key) { + e.preventDefault() + const newVolume = Math.max(0, volume - keybindings.volumeDown.value / 100) + setVolume(newVolume) + } else if (e.code === keybindings.mute.key) { + e.preventDefault() + setMuted(!muted) + } else if (e.code === keybindings.cycleSubtitles.key) { + e.preventDefault() + handleCycleSubtitles() + } else if (e.code === keybindings.cycleAudio.key) { + e.preventDefault() + handleCycleAudio() + } else if (e.code === keybindings.nextEpisode.key) { + e.preventDefault() + handleNextEpisode() + } else if (e.code === keybindings.previousEpisode.key) { + e.preventDefault() + handlePreviousEpisode() + } else if (e.code === keybindings.fullscreen.key) { + e.preventDefault() + handleToggleFullscreen() + } else if (e.code === keybindings.pictureInPicture.key) { + e.preventDefault() + handleTogglePictureInPicture() + } else if (e.code === keybindings.increaseSpeed.key) { + e.preventDefault() + const newRate = Math.min(8, video.playbackRate + keybindings.increaseSpeed.value) + video.playbackRate = newRate + flashAction({ message: `Speed: ${newRate.toFixed(2)}x` }) + } else if (e.code === keybindings.decreaseSpeed.key) { + e.preventDefault() + const newRate = Math.max(0.20, video.playbackRate - keybindings.decreaseSpeed.value) + video.playbackRate = newRate + flashAction({ message: `Speed: ${newRate.toFixed(2)}x` }) + } else if (e.code === keybindings.takeScreenshot.key) { + e.preventDefault() + takeScreenshot() + } + }, [keybindings, volume, muted, seek, active, fullscreen, pip, flashAction, introEndTime, introStartTime, isKeybindingsModalOpen]) + + // Keyboard shortcut handlers + const handleNextChapter = useCallback(() => { + if (!videoRef.current || !chapterCues) return + + const currentTime = videoRef.current.currentTime + + // Sort chapters by start time to ensure proper order + const sortedChapters = [...chapterCues].sort((a, b) => a.startTime - b.startTime) + + // Find the next chapter (with a small buffer to avoid edge cases) + const nextChapter = sortedChapters.find(chapter => chapter.startTime > currentTime + 1) + if (nextChapter) { + seekTo(nextChapter.startTime) + // Try to get chapter name from video track cues + const chapterName = nextChapter.text + flashAction({ message: chapterName ? `Chapter: ${chapterName}` : `Chapter ${sortedChapters.indexOf(nextChapter) + 1}` }) + } else { + // If no next chapter, go to the end + const lastChapter = sortedChapters[sortedChapters.length - 1] + if (lastChapter && lastChapter.endTime) { + seekTo(lastChapter.endTime) + flashAction({ message: "End of chapters" }) + } + } + }, [chapterCues, seekTo, flashAction]) + + const handlePreviousChapter = useCallback(() => { + if (!videoRef.current || !chapterCues) return + + const currentTime = videoRef.current.currentTime + + // Sort chapters by start time to ensure proper order + const sortedChapters = [...chapterCues].sort((a, b) => a.startTime - b.startTime) + + // Find the current chapter first + const currentChapterIndex = sortedChapters.findIndex((chapter, index) => { + const nextChapter = sortedChapters[index + 1] + return chapter.startTime <= currentTime && (!nextChapter || currentTime < nextChapter.startTime) + }) + + if (currentChapterIndex > 0) { + // Go to previous chapter + const previousChapter = sortedChapters[currentChapterIndex - 1] + seekTo(previousChapter.startTime) + const chapterName = previousChapter.text + flashAction({ message: chapterName ? `Chapter: ${chapterName}` : `Chapter ${currentChapterIndex}` }) + } else if (currentChapterIndex === 0) { + // Already in first chapter, go to the beginning + seekTo(0) + const firstChapter = sortedChapters[0] + const chapterName = firstChapter.text + flashAction({ message: chapterName ? `Chapter: ${chapterName}` : "Chapter 1" }) + } else { + // If we can't determine current chapter, just go to the beginning + seekTo(0) + flashAction({ message: "Beginning" }) + } + }, [chapterCues, seekTo, flashAction]) + + + const handleCycleSubtitles = useCallback(() => { + if (!videoRef.current) return + + const textTracks = Array.from(videoRef.current.textTracks).filter(track => track.kind === "subtitles") + if (textTracks.length === 0) { + flashAction({ message: "No subtitle tracks" }) + return + } + + // Find currently showing track + let currentTrackIndex = -1 + for (let i = 0; i < textTracks.length; i++) { + if (textTracks[i].mode === "showing") { + currentTrackIndex = i + break + } + } + + // Cycle to next track or disable if we're at the end + const nextIndex = currentTrackIndex + 1 + + // Disable all tracks first + for (let i = 0; i < textTracks.length; i++) { + textTracks[i].mode = "disabled" + } + + // Enable next track if available + if (nextIndex < textTracks.length) { + textTracks[nextIndex].mode = "showing" + subtitleManager?.selectTrack(Number(textTracks[nextIndex].id)) + const trackName = textTracks[nextIndex].label || `Track ${nextIndex + 1}` + flashAction({ message: `Subtitles: ${trackName}` }) + } else { + // If we've cycled through all, disable subtitles + subtitleManager?.setNoTrack() + flashAction({ message: "Subtitles: Off" }) + } + }, [subtitleManager]) + + const handleCycleAudio = useCallback(() => { + if (!videoRef.current) return + + const audioTracks = videoRef.current.audioTracks + if (!audioTracks || audioTracks.length <= 1) { + flashAction({ message: "No additional audio tracks" }) + return + } + + // Find currently enabled track + let currentTrackIndex = -1 + for (let i = 0; i < audioTracks.length; i++) { + if (audioTracks[i].enabled) { + currentTrackIndex = i + break + } + } + + // Cycle to next track + const nextIndex = (currentTrackIndex + 1) % audioTracks.length + + // Disable all tracks first + for (let i = 0; i < audioTracks.length; i++) { + audioTracks[i].enabled = false + } + + // Enable next track + audioTracks[nextIndex].enabled = true + audioManager?.selectTrack(nextIndex) + + const trackName = audioTracks[nextIndex].label || audioTracks[nextIndex].language || `Track ${nextIndex + 1}` + flashAction({ message: `Audio: ${trackName}` }) + }, []) + + const log = logger("VideoCoreKeybindings") + + const handleNextEpisode = useCallback(() => { + // Placeholder for next episode functionality + log.info("Next episode shortcut pressed - not implemented yet") + }, []) + + const handlePreviousEpisode = useCallback(() => { + // Placeholder for previous episode functionality + log.info("Previous episode shortcut pressed - not implemented yet") + }, []) + + const handleToggleFullscreen = useCallback(() => { + fullscreenManager?.toggleFullscreen() + + React.startTransition(() => { + setTimeout(() => { + videoRef.current?.focus() + }, 100) + }) + }, [fullscreenManager]) + + const handleTogglePictureInPicture = useCallback(() => { + // mediaStore.dispatch({ + // type: pip ? "mediaexitpiprequest" : "mediaenterpiprequest", + // }) + + React.startTransition(() => { + setTimeout(() => { + videoRef.current?.focus() + }, 100) + }) + }, [pip]) + + // Add keyboard event listeners + useEffect(() => { + if (!active) return + + document.addEventListener("keydown", handleKeyboardShortcuts) + + return () => { + document.removeEventListener("keydown", handleKeyboardShortcuts) + } + }, [handleKeyboardShortcuts, active]) + + // Handle fullscreen state changes to ensure video gets focused + useEffect(() => { + if (!active) return + + const handleFullscreenChange = () => { + // Small delay to ensure fullscreen transition is complete + setTimeout(() => { + if (document.fullscreenElement && videoRef.current) { + videoRef.current.focus() + } + }, 100) + } + + document.addEventListener("fullscreenchange", handleFullscreenChange) + + return () => { + document.removeEventListener("fullscreenchange", handleFullscreenChange) + } + }, [active]) + + return null +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-menu.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-menu.tsx new file mode 100644 index 0000000..634bb0c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-menu.tsx @@ -0,0 +1,252 @@ +import { vc_containerElement, vc_isFullscreen, vc_menuOpen } from "@/app/(main)/_features/video-core/video-core" +import { Popover } from "@/components/ui/popover" +import { Tooltip } from "@/components/ui/tooltip" +import { atom } from "jotai" +import { useAtom, useAtomValue } from "jotai/react" +import { motion } from "motion/react" +import React, { useRef } from "react" +import { AiFillInfoCircle } from "react-icons/ai" +import { LuCheck, LuChevronLeft, LuChevronRight } from "react-icons/lu" + +const vc_menuSectionOpen = atom<string | null>(null) + +type VideoCoreMenuProps = { + trigger: React.ReactElement + children?: React.ReactNode +} + +export function VideoCoreMenu(props: VideoCoreMenuProps) { + + const { trigger, children, ...rest } = props + const [menuOpen, setMenuOpen] = useAtom(vc_menuOpen) + + const [open, setOpen] = React.useState(false) + + const [openSection, setOpenSection] = useAtom(vc_menuSectionOpen) + + // Get fullscreen state and container element for proper portal mounting + const isFullscreen = useAtomValue(vc_isFullscreen) + const containerElement = useAtomValue(vc_containerElement) + + const t = useRef<NodeJS.Timeout | null>(null) + React.useEffect(() => { + if (!menuOpen) { + t.current = setTimeout(() => { + setOpenSection(null) + }, 300) + } + return () => { + if (t.current) { + clearTimeout(t.current) + } + } + }, [menuOpen]) + + return ( + <Popover + open={open} + onOpenChange={v => { + setOpen(v) + setMenuOpen(v) + }} + trigger={<div>{trigger}</div>} + sideOffset={4} + align="center" + modal={false} + className="bg-black/85 rounded-xl p-3 backdrop-blur-sm w-[20rem] z-[100]" + portalContainer={isFullscreen ? containerElement || undefined : undefined} + > + <div className="h-auto"> + {children} + </div> + </Popover> + ) + +} + +export function VideoCoreMenuTitle(props: { children: React.ReactNode }) { + + const { children, ...rest } = props + return ( + <div className="text-white/70 font-bold text-sm pb-3 text-center border-b mb-3" {...rest}> + {children} + </div> + ) + +} + +export function VideoCoreMenuSectionBody(props: { children: React.ReactNode }) { + const { children, ...rest } = props + + const [openSection, setOpen] = useAtom(vc_menuSectionOpen) + + return ( + <div className="vc-menu-section-body"> + {/*<AnimatePresence mode="wait">*/} + {!openSection && ( + <motion.div + key="section-body" + className="h-auto" + initial={{ opacity: 0, scale: 1.0, x: -10 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + exit={{ opacity: 0, scale: 1.0, x: -10 }} + transition={{ duration: 0.15 }} + > + {children} + </motion.div> + )} + {/*</AnimatePresence>*/} + </div> + ) +} + +export function VideoCoreMenuBody(props: { children: React.ReactNode }) { + const { children, ...rest } = props + + return ( + <div className="max-h-[18rem] overflow-y-auto"> + {children} + </div> + ) +} + +export function VideoCoreMenuSubmenuBody(props: { children: React.ReactNode }) { + const { children, ...rest } = props + + const [openSection, setOpen] = useAtom(vc_menuSectionOpen) + + return ( + <div className="vc-menu-submenu-body"> + {/*<AnimatePresence mode="wait">*/} + {openSection && ( + <motion.div + key="section-body" + className="h-auto" + initial={{ opacity: 0, scale: 1.0, x: 10 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + exit={{ opacity: 0, scale: 1.0, x: 10 }} + transition={{ duration: 0.15 }} + > + {children} + </motion.div> + )} + {/*</AnimatePresence>*/} + </div> + ) +} + +export function VideoCoreMenuOption(props: { + title: string, + value?: string, + icon: React.ElementType, + children?: React.ReactNode, + onClick?: () => void +}) { + const { children, title, icon: Icon, onClick, value, ...rest } = props + + const [openSection, setOpen] = useAtom(vc_menuSectionOpen) + + function handleClick() { + if (onClick) { + onClick() + return + } + + // open the section + setOpen(title) + } + + return ( + <> + {!openSection && <button + role="button" + className="w-full p-2 h-10 flex items-center justify-between rounded-lg group/vc-menu-option hover:bg-white/10 active:bg-white/20 transition-colors" + onClick={handleClick} + > + <span className="w-8 flex justify-start items-center h-full"> + <Icon className="text-xl" /> + </span> + <span className="w-full flex flex-1 text-sm font-medium"> + {title} + </span> + {value && <span className="text-sm font-medium tracking-wide text-[--muted] mr-2"> + {value} + </span>} + <LuChevronRight className="text-lg" /> + </button>} + + {openSection === title && ( + <div + key={title} + > + <button + role="button" + className="w-full pb-2 h-10 mb-2 flex items-center justify-between rounded-lg transition-colors border-b" + onClick={() => setOpen(null)} + > + <span className="w-8 flex justify-start items-center h-full"> + <LuChevronLeft className="text-lg" /> + </span> + <span className="w-full flex flex-1 text-sm font-medium"> + {title} + </span> + </button> + + <VideoCoreMenuBody> + {children} + </VideoCoreMenuBody> + </div> + )} + </> + ) +} + +type VideoCoreSettingSelectProps<T extends string | number> = { + options: { + label: string + value: T + moreInfo?: string + description?: string + }[] + value: T + onValueChange: (value: T) => void + isFullscreen?: boolean + containerElement?: HTMLElement | null +} + +export function VideoCoreSettingSelect<T extends string | number>(props: VideoCoreSettingSelectProps<T>) { + const { options, value, onValueChange, isFullscreen, containerElement } = props + return ( + <div className="block"> + {options.map(option => ( + <div + key={option.value} + role="button" + className="w-full p-2 flex items-center justify-between rounded-lg group/vc-menu-option hover:bg-white/10 active:bg-white/20 transition-colors" + onClick={() => { + onValueChange(option.value) + }} + > + <span className="w-8 flex justify-start items-center h-full flex-none"> + {value === option.value && <LuCheck className="text-lg" />} + </span> + <span className="w-full flex flex-1 text-sm font-medium line-clamp-2"> + {option.label} + </span> + {(option.moreInfo || option.description) && <div className="w-fit flex-none ml-2 flex gap-2 items-center"> + {option.moreInfo && <span className="text-xs font-medium tracking-wide text-[--muted]"> + {option.moreInfo} + </span>} + {option.description && <Tooltip + trigger={<AiFillInfoCircle className="text-sm" />} + portalContainer={isFullscreen ? containerElement || undefined : undefined} + > + {option.description} + </Tooltip>} + </div>} + + </div> + ))} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-pip.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-pip.ts new file mode 100644 index 0000000..02cde9a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-pip.ts @@ -0,0 +1,386 @@ +import { NativePlayer_PlaybackInfo } from "@/api/generated/types" +import { VideoCoreSubtitleManager } from "@/app/(main)/_features/video-core/video-core-subtitles" +import { logger } from "@/lib/helpers/debug" +import { atom } from "jotai" + +const log = logger("VIDEO CORE PIP") + +export const vc_pipElement = atom<HTMLVideoElement | null>(null) +export const vc_pipManager = atom<VideoCorePipManager | null>(null) + +export class VideoCorePipManager { + private video: HTMLVideoElement | null = null + private subtitleManager: VideoCoreSubtitleManager | null = null + private controller = new AbortController() + private canvasController: AbortController | null = null + private readonly onPipElementChange: (element: HTMLVideoElement | null) => void + private mediaSessionSetup = false + private pipProxy: HTMLVideoElement | null = null + private isSyncingFromMain = false + private isSyncingFromPip = false + private playbackInfo: NativePlayer_PlaybackInfo | null = null + + constructor(onPipElementChange: (element: HTMLVideoElement | null) => void) { + this.onPipElementChange = onPipElementChange + document.addEventListener("enterpictureinpicture", this.handleEnterPip, { + signal: this.controller.signal, + }) + document.addEventListener("leavepictureinpicture", this.handleLeavePip, { + signal: this.controller.signal, + }) + window.addEventListener("visibilitychange", () => { + const shouldAutoPip = document.visibilityState !== "visible" && + this.video && + !this.video.paused + + if (shouldAutoPip) { + this.togglePip(true) + } + }, { signal: this.controller.signal }) + } + + setVideo(video: HTMLVideoElement, playbackInfo: NativePlayer_PlaybackInfo) { + this.video = video + + if (this.video) { + this.video.addEventListener("play", this.handleMainVideoPlay, { + signal: this.controller.signal, + }) + this.video.addEventListener("pause", this.handleMainVideoPause, { + signal: this.controller.signal, + }) + } + this.playbackInfo = playbackInfo + } + + setSubtitleManager(subtitleManager: VideoCoreSubtitleManager) { + this.subtitleManager = subtitleManager + } + + togglePip(enable?: boolean) { + const isCurrentlyInPip = document.pictureInPictureElement !== null + const shouldEnable = enable !== undefined ? enable : !isCurrentlyInPip + + if (shouldEnable) { + this.enterPip() + } else { + this.exitPip() + } + } + + exitPip() { + if (document.pictureInPictureElement) { + document.exitPictureInPicture().catch(err => { + log.error("Failed to exit PiP", err) + }) + } + } + + async enterPip() { + if (document.pictureInPictureElement || !this.video) { + log.warning("PiP already in use or video not set") + return + } + + try { + const hasActiveSubtitles = this.subtitleManager?.getSelectedTrack?.() !== null + if (!hasActiveSubtitles) { + log.info("Entering PiP without subtitles") + await this.video.requestPictureInPicture() + return + } + + log.info("Entering PiP with subtitle burning") + await this.enterPipWithSubtitles() + } + catch (error) { + log.error("Failed to enter PiP", error) + } + } + + destroy() { + this.exitPip() + this.clearMediaSession() + this.canvasController?.abort() + this.controller.abort() + this.video = null + this.subtitleManager = null + } + + private handleEnterPip = () => { + const pipElement = document.pictureInPictureElement as HTMLVideoElement | null + log.info("Entered PiP", pipElement) + this.setupMediaSession() + this.updateMediaSessionPlaybackState() + this.onPipElementChange(pipElement) + } + + private handleLeavePip = () => { + log.info("Left PiP") + this.clearMediaSession() + this.onPipElementChange(null) + + if (this.video) { + this.video.focus() + } + this.pipProxy = null + } + + private newPipVideo() { + const element = document.createElement("video") + element.addEventListener("enterpictureinpicture", this.handleEnterPip, { + signal: this.controller.signal, + }) + element.addEventListener("leavepictureinpicture", this.handleLeavePip, { + signal: this.controller.signal, + }) + return element + } + + private setupMediaSession() { + if (!("mediaSession" in navigator) || this.mediaSessionSetup) { + return + } + + try { + // Set up media session metadata + navigator.mediaSession.metadata = new MediaMetadata({ + title: this.playbackInfo?.episode?.displayTitle ?? "Seanime", + artist: this.playbackInfo?.episode?.baseAnime?.title?.userPreferred ?? "Video Player", + artwork: [ + { + src: this.playbackInfo?.episode?.episodeMetadata?.image ?? "", + sizes: "100px", + type: "image/webp", + }, + ], + }) + + // Set up action handlers for play/pause + navigator.mediaSession.setActionHandler("play", () => { + log.info("Play") + if (this.video?.paused) { + this.video.play().catch(err => { + log.error("Failed to play video from media session", err) + }) + } + }) + + navigator.mediaSession.setActionHandler("pause", () => { + log.info("Pause") + if (this.video && !this.video.paused) { + this.video.pause() + } + }) + + this.mediaSessionSetup = true + log.info("Media session setup complete") + } + catch (error) { + log.error("Failed to setup media session", error) + } + } + + private clearMediaSession() { + if (!("mediaSession" in navigator) || !this.mediaSessionSetup) { + return + } + + try { + // Clear action handlers + navigator.mediaSession.setActionHandler("play", null) + navigator.mediaSession.setActionHandler("pause", null) + navigator.mediaSession.metadata = null + + this.mediaSessionSetup = false + log.info("Media session cleared") + } + catch (error) { + log.error("Failed to clear media session", error) + } + } + + private updateMediaSessionPlaybackState = () => { + if ("mediaSession" in navigator && this.mediaSessionSetup && this.video) { + navigator.mediaSession.playbackState = this.video.paused ? "paused" : "playing" + } + } + + private renderToCanvas = ( + pipVideo: HTMLVideoElement, + context: CanvasRenderingContext2D, + animationFrameRef: { current: number }, + ) => (now?: number, metadata?: VideoFrameCallbackMetadata) => { + if (!this.video || !context) return + + // sync play/pause state + if (now !== undefined) { + if (this.video.paused && !pipVideo.paused) { + if (!this.isSyncingFromPip) { + pipVideo.pause() + } + } else if (!this.video.paused && pipVideo.paused) { + if (!this.isSyncingFromPip) { + pipVideo.play().catch(() => {}) + } + } + this.updateMediaSessionPlaybackState() + } + + context.drawImage(this.video, 0, 0) + const subtitleCanvas = this.subtitleManager?.libassRenderer?._canvas + if (subtitleCanvas && context.canvas.width && context.canvas.height) { + context.drawImage(subtitleCanvas, 0, 0, context.canvas.width, context.canvas.height) + } + animationFrameRef.current = this.video.requestVideoFrameCallback(this.renderToCanvas(pipVideo, context, animationFrameRef)) + } + + private handleMainVideoPlay = () => { + if (this.isSyncingFromPip) return + this.updateMediaSessionPlaybackState() + if (this.pipProxy && this.pipProxy.paused) { + this.isSyncingFromMain = true + this.pipProxy.play().catch(() => {}) + this.isSyncingFromMain = false + } + } + + private handleMainVideoPause = () => { + if (this.isSyncingFromPip) return + this.updateMediaSessionPlaybackState() + if (this.pipProxy && !this.pipProxy.paused) { + this.isSyncingFromMain = true + this.pipProxy.pause() + this.isSyncingFromMain = false + } + } + + private async enterPipWithSubtitles() { + if (!this.video || !this.subtitleManager) return + + const canvas = document.createElement("canvas") + const context = canvas.getContext("2d") + if (!context) { + log.error("Failed to get canvas context") + return + } + + const pipVideo = this.newPipVideo() + pipVideo.srcObject = canvas.captureStream() + pipVideo.muted = true + this.pipProxy = pipVideo + + canvas.width = this.video.videoWidth + canvas.height = this.video.videoHeight + + if (this.subtitleManager?.libassRenderer) { + this.subtitleManager.libassRenderer.resize(this.video.videoWidth, this.video.videoHeight) + } + + this.canvasController = new AbortController() + + // Forward PiP overlay play/pause controls to the main video element + // In the canvas path the PiP element is not the main <video> and PiP UI + // controls act on this proxy element instead. + const forwardPlay = () => { + if (this.video && this.video.paused) { + this.isSyncingFromPip = true + this.video.play().catch(err => { + log.error("Failed to play main video from PiP overlay", err) + }).finally(() => { + this.isSyncingFromPip = false + }) + } + } + const forwardPause = () => { + if (this.video && !this.video.paused) { + this.isSyncingFromPip = true + this.video.pause() + this.isSyncingFromPip = false + } + } + pipVideo.addEventListener("play", forwardPlay, { signal: this.canvasController.signal }) + pipVideo.addEventListener("pause", forwardPause, { signal: this.canvasController.signal }) + const animationFrameRef = { current: 0 } + + // draw initial frame + context.drawImage(this.video, 0, 0) + const subtitleCanvas = this.subtitleManager?.libassRenderer?._canvas + if (subtitleCanvas && canvas.width && canvas.height) { + context.drawImage(subtitleCanvas, 0, 0, canvas.width, canvas.height) + } + + // wait for metadata + await new Promise<void>((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for PiP video metadata")) + }, 5000) + + pipVideo.addEventListener("loadedmetadata", () => { + clearTimeout(timeout) + resolve() + }, { once: true }) + + pipVideo.addEventListener("error", () => { + clearTimeout(timeout) + reject(new Error("Error loading PiP video metadata")) + }, { once: true }) + }) + + const cleanup = () => { + if (this.subtitleManager?.libassRenderer) { + this.subtitleManager.libassRenderer.resize() + } + if (animationFrameRef.current && this.video) { + this.video.cancelVideoFrameCallback(animationFrameRef.current) + } + canvas.remove() + pipVideo.remove() + this.pipProxy = null + } + + this.canvasController.signal.addEventListener("abort", cleanup) + this.controller.signal.addEventListener("abort", () => { + this.canvasController?.abort() + }) + pipVideo.addEventListener("leavepictureinpicture", () => { + this.canvasController?.abort() + }, { signal: this.canvasController.signal }) + + try { + // start the continuous rendering loop + this.renderToCanvas(pipVideo, context, animationFrameRef)(performance.now()) + + // always start the canvas stream + try { + await pipVideo.play() + if (this.video.paused) { + pipVideo.pause() + } + } + catch (playError) { + if (playError instanceof DOMException && playError.name === "AbortError") { + } else { + throw playError + } + } + + const pipWindow = await pipVideo.requestPictureInPicture() + + pipWindow.addEventListener("resize", () => { + const { width, height } = pipWindow + if (isNaN(width) || isNaN(height) || !isFinite(width) || !isFinite(height)) { + return + } + this.subtitleManager?.libassRenderer?.resize(width, height) + }, { signal: this.canvasController.signal }) + + log.info("Successfully entered PiP") + } + catch (error) { + log.error("Failed to enter PiP", error) + this.canvasController?.abort() + throw error + } + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-playlist.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-playlist.ts new file mode 100644 index 0000000..52323f3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-playlist.ts @@ -0,0 +1,63 @@ +import { Anime_Episode } from "@/api/generated/types" +import { useGetAnimeEntry } from "@/api/hooks/anime_entries.hooks" +import { nativePlayer_stateAtom, NativePlayerState } from "@/app/(main)/_features/native-player/native-player.atoms" +import { atom, useAtomValue } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" + +type VideoCorePlaylistState = { + type: NonNullable<NativePlayerState["playbackInfo"]>["streamType"] + episodes: Anime_Episode[] + previousEpisode: Anime_Episode | null + nextEpisode: Anime_Episode | null + currentEpisode: Anime_Episode +} + +export const vc_playlistState = atom<VideoCorePlaylistState | null>(null) + +// call once, maintains playlist state +export function useVideoCorePlaylistSetup() { + const [playlistState, setPlaylistState] = useAtom(vc_playlistState) + + const state = useAtomValue(nativePlayer_stateAtom) + const playbackInfo = state.playbackInfo + const mediaId = state.playbackInfo?.media?.id + const mediaType = state.playbackInfo?.streamType + + const currProgressNumber = playbackInfo?.episode?.progressNumber || 0 + + const { data: animeEntry } = useGetAnimeEntry(!!mediaId ? mediaId : null) + + const episodes = React.useMemo(() => { + if (!animeEntry?.episodes) return null + + return animeEntry.episodes.filter(ep => ep.type === "main") + }, [animeEntry?.episodes, currProgressNumber]) + + const currentEpisode = episodes?.find?.(ep => ep.progressNumber === currProgressNumber) ?? null + const previousEpisode = episodes?.find?.(ep => ep.progressNumber === currProgressNumber - 1) ?? null + const nextEpisode = episodes?.find?.(ep => ep.progressNumber === currProgressNumber + 1) ?? null + + React.useEffect(() => { + if (!playbackInfo || !currentEpisode || !episodes?.length) { + setPlaylistState(null) + return + } + + setPlaylistState({ + type: mediaType!, + episodes: episodes ?? [], + currentEpisode, + previousEpisode, + nextEpisode, + }) + }, [playbackInfo, currentEpisode, previousEpisode, nextEpisode]) +} + +export function useVideoCorePlaylist() { + const playlistState = useAtomValue(vc_playlistState) + + const playNextEpisode = () => { + + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-preview.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-preview.ts new file mode 100644 index 0000000..38b9a0c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-preview.ts @@ -0,0 +1,247 @@ +export const VIDEOCORE_PREVIEW_THUMBNAIL_SIZE = 200 +export const VIDEOCORE_PREVIEW_CAPTURE_INTERVAL_SECONDS = 4 + +export class VideoCorePreviewManager { + private previewCache: Map<number, string> = new Map() + private inFlightPromises: Map<number, Promise<string | undefined>> = new Map() + private jobs: { current: Job | undefined, pending: Job | undefined } = { current: undefined, pending: undefined } + private currentMediaSource?: string + private videoSyncController = new AbortController() + private videoElement: HTMLVideoElement + private lastCapturedSegment: number = -1 + private captureThrottleTimeout: number | null = null + private highestCachedIndex: number = -1 + + private readonly _dummyVideoElement = document.createElement("video") + private readonly _offscreenCanvas = new OffscreenCanvas(0, 0) + private readonly _drawingContext = this._offscreenCanvas.getContext("2d")! + + constructor(videoElement: HTMLVideoElement, mediaSource?: string) { + this.initializeDummyVideoElement() + if (mediaSource) { + this.loadMediaSource(mediaSource + "&thumbnail=true") + } + this.videoElement = videoElement + this._bindToVideoPlayer() + } + + _bindToVideoPlayer(): void { + this.detachFromCurrentPlayer() + this.videoSyncController = new AbortController() + + // Only capture previews occasionally during normal playback, not on every timeupdate + this.videoElement.addEventListener("timeupdate", () => { + const segmentIndex = this.calculateSegmentIndex(this.videoElement.currentTime) + + // Only capture if we've moved to a new segment and throttle the captures + if (segmentIndex !== this.lastCapturedSegment && !this.previewCache.has(segmentIndex)) { + this.throttledCaptureFrame(segmentIndex) + } + }, { signal: this.videoSyncController.signal }) + } + + changeMediaSource(newSource?: string): void { + if (newSource === this.currentMediaSource || !newSource) return + + this.clearPreviewCache() + this.resetOperationQueue() + this.loadMediaSource(newSource) + this.lastCapturedSegment = -1 + + // Clear any pending throttled captures + if (this.captureThrottleTimeout) { + clearTimeout(this.captureThrottleTimeout) + this.captureThrottleTimeout = null + } + } + + cleanup(): void { + this._dummyVideoElement.remove() + this.detachFromCurrentPlayer() + this.clearPreviewCache() + + // Clear any pending throttled captures + if (this.captureThrottleTimeout) { + clearTimeout(this.captureThrottleTimeout) + this.captureThrottleTimeout = null + } + } + + async retrievePreviewForSegment(segmentIndex: number): Promise<string | undefined> { + const cachedPreview = this.previewCache.get(segmentIndex) + if (cachedPreview) return cachedPreview + + const inFlight = this.inFlightPromises.get(segmentIndex) + if (inFlight) return inFlight + + return await this.schedulePreviewGeneration(segmentIndex) + } + + getLastestCachedIndex(): number { + return this.highestCachedIndex + } + + calculateTimeFromIndex(segmentIndex: number): number { + return segmentIndex * VIDEOCORE_PREVIEW_CAPTURE_INTERVAL_SECONDS + } + + private throttledCaptureFrame(segmentIndex: number): void { + // Clear any pending capture + if (this.captureThrottleTimeout) { + clearTimeout(this.captureThrottleTimeout) + } + + // Throttle captures to avoid spamming + this.captureThrottleTimeout = window.setTimeout(() => { + if (!this.previewCache.has(segmentIndex) && !this.inFlightPromises.has(segmentIndex)) { + const promise = this.captureFrameFromCurrentVideo(segmentIndex) + this.inFlightPromises.set(segmentIndex, promise) + promise.finally(() => this.inFlightPromises.delete(segmentIndex)) + this.lastCapturedSegment = segmentIndex + } + this.captureThrottleTimeout = null + }, 500) // Wait 500ms before capturing + } + + private initializeDummyVideoElement(): void { + this._dummyVideoElement.crossOrigin = "anonymous" + this._dummyVideoElement.playbackRate = 0 + this._dummyVideoElement.muted = true + this._dummyVideoElement.preload = "none" + } + + private loadMediaSource(source: string): void { + this._dummyVideoElement.src = this.currentMediaSource = source + this._dummyVideoElement.load() + } + + private detachFromCurrentPlayer(): void { + this.videoSyncController.abort() + + // Clear any pending throttled captures when detaching + if (this.captureThrottleTimeout) { + clearTimeout(this.captureThrottleTimeout) + this.captureThrottleTimeout = null + } + } + + private calculateSegmentIndex(currentTime: number): number { + return Math.floor(currentTime / VIDEOCORE_PREVIEW_CAPTURE_INTERVAL_SECONDS) + } + + private async captureFrameFromCurrentVideo(segmentIndex: number): Promise<string | undefined> { + if (this.videoElement.readyState < 2) return // Not enough data loaded + + const frameWidth = this.videoElement.videoWidth + const frameHeight = this.videoElement.videoHeight + + if (!frameWidth || !frameHeight) return + + this.configureRenderingSurface(frameWidth, frameHeight) + this._drawingContext.drawImage(this.videoElement, 0, 0, this._offscreenCanvas.width, this._offscreenCanvas.height) + + const imageBlob = await this._offscreenCanvas.convertToBlob({ type: "image/webp", quality: 0.8 }) + const previewUrl = URL.createObjectURL(imageBlob) + + this.previewCache.set(segmentIndex, previewUrl) + if (segmentIndex > this.highestCachedIndex) { + this.highestCachedIndex = segmentIndex + } + return previewUrl + } + + private addJob(segmentIndex: number): Job { + // @ts-ignore + const { promise, resolve } = Promise.withResolvers<string | undefined>() + + const execute = (): void => { + this._dummyVideoElement.requestVideoFrameCallback(async (_timestamp, metadata) => { + const preview = await this.captureFrameAtSegment(this._dummyVideoElement, segmentIndex, metadata.width, metadata.height) + resolve(preview) + this.processNextJob() + }) + this._dummyVideoElement.currentTime = segmentIndex * VIDEOCORE_PREVIEW_CAPTURE_INTERVAL_SECONDS + } + + return { segmentIndex, execute, promise } + } + + private processNextJob(): void { + this.jobs.current = undefined + if (this.jobs.pending) { + this.jobs.current = this.jobs.pending + this.jobs.pending = undefined + this.jobs.current.execute() + } + } + + private schedulePreviewGeneration(segmentIndex: number): Promise<string | undefined> { + if (!this.jobs.current) { + this.jobs.current = this.addJob(segmentIndex) + this.jobs.current.execute() + return this.jobs.current.promise + } + + if (this.jobs.current.segmentIndex === segmentIndex) { + return this.jobs.current.promise + } + + if (!this.jobs.pending) { + this.jobs.pending = this.addJob(segmentIndex) + return this.jobs.pending.promise + } + + if (this.jobs.pending.segmentIndex === segmentIndex) { + return this.jobs.pending.promise + } + + this.jobs.pending = this.addJob(segmentIndex) + return this.jobs.pending.promise + } + + private async captureFrameAtSegment( + videoElement: HTMLVideoElement, + segmentIndex: number, + frameWidth = videoElement.videoWidth, + frameHeight = videoElement.videoHeight, + ): Promise<string | undefined> { + const existingPreview = this.previewCache.get(segmentIndex) + if (existingPreview) return existingPreview + + if (!frameWidth || !frameHeight) return undefined + + this.configureRenderingSurface(frameWidth, frameHeight) + this._drawingContext.drawImage(videoElement, 0, 0, this._offscreenCanvas.width, this._offscreenCanvas.height) + + const imageBlob = await this._offscreenCanvas.convertToBlob({ type: "image/webp", quality: 0.8 }) + const previewUrl = URL.createObjectURL(imageBlob) + + this.previewCache.set(segmentIndex, previewUrl) + if (segmentIndex > this.highestCachedIndex) { + this.highestCachedIndex = segmentIndex + } + return previewUrl + } + + private configureRenderingSurface(sourceWidth: number, sourceHeight: number): void { + this._offscreenCanvas.width = VIDEOCORE_PREVIEW_THUMBNAIL_SIZE + this._offscreenCanvas.height = (sourceHeight / sourceWidth) * VIDEOCORE_PREVIEW_THUMBNAIL_SIZE + } + + private clearPreviewCache(): void { + this.previewCache.forEach(previewUrl => URL.revokeObjectURL(previewUrl)) + this.previewCache.clear() + this.inFlightPromises.clear() + } + + private resetOperationQueue(): void { + this.jobs.current = undefined + this.jobs.pending = undefined + } +} + +type Job = { + segmentIndex: number + execute: () => void + promise: Promise<string | undefined> +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-screenshot.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-screenshot.ts new file mode 100644 index 0000000..d1b612d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-screenshot.ts @@ -0,0 +1,135 @@ +import { useAtomValue, useSetAtom } from "jotai" +import React from "react" +import { vc_anime4kManager, vc_subtitleManager, vc_videoElement } from "./video-core" +import { vc_doFlashAction } from "./video-core-action-display" +import { vc_anime4kOption } from "./video-core-anime-4k" + +export function useVideoCoreScreenshot() { + + const videoElement = useAtomValue(vc_videoElement) + const subtitleManager = useAtomValue(vc_subtitleManager) + const flashAction = useSetAtom(vc_doFlashAction) + const anime4kManager = useAtomValue(vc_anime4kManager) + const anime4kOption = useAtomValue(vc_anime4kOption) + + const screenshotTimeout = React.useRef<NodeJS.Timeout | null>(null) + + async function saveToClipboard(blob: Blob, isAnime4K: boolean = false) { + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]) + flashAction({ message: "Screenshot saved to clipboard", type: "message" }) + } + + async function addSubtitles(canvas: HTMLCanvasElement): Promise<void> { + const libassRenderer = subtitleManager?.libassRenderer + if (!libassRenderer) return + + const ctx = canvas.getContext("2d") + if (!ctx) return + + return new Promise((resolve) => { + libassRenderer.resize(canvas.width, canvas.height) + screenshotTimeout.current = setTimeout(() => { + ctx.drawImage(libassRenderer._canvas, 0, 0, canvas.width, canvas.height) + libassRenderer.resize(0, 0, 0, 0) + resolve() + }, 300) + }) + } + + async function createVideoCanvas(source: HTMLVideoElement | HTMLCanvasElement): Promise<Blob | null> { + return new Promise(async (resolve) => { + if (source instanceof HTMLCanvasElement) { + source.toBlob(resolve, "image/png") + } else { + const canvas = document.createElement("canvas") + const ctx = canvas.getContext("2d") + if (!ctx) return resolve(null) + + canvas.width = source.videoWidth + canvas.height = source.videoHeight + ctx.drawImage(source, 0, 0) + + await addSubtitles(canvas) + canvas.toBlob((blob) => { + canvas.remove() + resolve(blob) + }) + } + }) + } + + async function createEnhancedCanvas(anime4kBlob: Blob): Promise<Blob | null> { + return new Promise((resolve) => { + const img = new Image() + img.onload = async () => { + const canvas = document.createElement("canvas") + const ctx = canvas.getContext("2d") + if (!ctx) return resolve(null) + + canvas.width = img.width + canvas.height = img.height + ctx.drawImage(img, 0, 0) + + await addSubtitles(canvas) + canvas.toBlob((blob) => { + canvas.remove() + URL.revokeObjectURL(img.src) + resolve(blob) + }) + } + img.src = URL.createObjectURL(anime4kBlob) + }) + } + + async function takeScreenshot() { + if (screenshotTimeout.current) { + clearTimeout(screenshotTimeout.current) + } + + if (!videoElement) return + + const isPaused = videoElement.paused + + videoElement.pause() + flashAction({ message: "Taking screenshot..." }) + + try { + let blob: Blob | null = null + let isAnime4K = false + + if (anime4kOption !== "off" && anime4kManager?.canvas) { + const anime4kBlob = await createVideoCanvas(anime4kManager.canvas) + if (anime4kBlob) { + if (subtitleManager?.libassRenderer) { + blob = await createEnhancedCanvas(anime4kBlob) + } else { + blob = anime4kBlob + } + isAnime4K = true + } + } + + if (!blob) { + blob = await createVideoCanvas(videoElement) + } + + if (blob) { + await saveToClipboard(blob, isAnime4K) + } + + } + catch (error) { + console.error("Screenshot failed:", error) + flashAction({ message: "Screenshot failed" }) + } + finally { + if (!isPaused) { + videoElement.play() + } + } + } + + return { + takeScreenshot, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-subtitles.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-subtitles.ts new file mode 100644 index 0000000..a3544af --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-subtitles.ts @@ -0,0 +1,369 @@ +import { getServerBaseUrl } from "@/api/client/server-url" +import { MKVParser_SubtitleEvent, MKVParser_TrackInfo, NativePlayer_PlaybackInfo } from "@/api/generated/types" +import { VideoCoreSettings } from "@/app/(main)/_features/video-core/video-core.atoms" +import { logger } from "@/lib/helpers/debug" +import { legacy_getAssetUrl } from "@/lib/server/assets" +import { isApple } from "@/lib/utils/browser-detection" +import JASSUB, { ASS_Event, JassubOptions } from "jassub" +import { toast } from "sonner" + +const subtitleLog = logger("SUBTITLE") + +const NO_TRACK_NUMBER = -1 + +export class VideoCoreSubtitleManager { + private readonly videoElement: HTMLVideoElement + private readonly jassubOffscreenRender: boolean + libassRenderer: JASSUB | null = null + private settings: VideoCoreSettings + private defaultSubtitleHeader = `[Script Info] +Title: English (US) +ScriptType: v4.00+ +WrapStyle: 0 +PlayResX: 640 +PlayResY: 360 +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default, Roboto Medium,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1.3,0,2,20,20,23,0 +[Events] + +` + + private tracks: Record<string, { + info: MKVParser_TrackInfo + events: Map<string, { event: MKVParser_SubtitleEvent, assEvent: ASS_Event }> + styles: Record<string, number> + }> = {} + + private playbackInfo: NativePlayer_PlaybackInfo + private currentTrackNumber: number = NO_TRACK_NUMBER + private fonts: string[] = [] + + constructor({ + videoElement, + jassubOffscreenRender, + playbackInfo, + settings, + }: { + videoElement: HTMLVideoElement + jassubOffscreenRender: boolean + playbackInfo: NativePlayer_PlaybackInfo + settings: VideoCoreSettings + }) { + this.videoElement = videoElement + this.jassubOffscreenRender = jassubOffscreenRender + this.playbackInfo = playbackInfo + this.settings = settings + + if (!this.playbackInfo?.mkvMetadata?.subtitleTracks) return + + // Store the tracks + for (const track of this.playbackInfo.mkvMetadata.subtitleTracks) { + this._addTrack(track) + } + this._storeTrackStyles() // Store their styles + this._selectDefaultTrack() + + subtitleLog.info("Text tracks", this.videoElement.textTracks) + subtitleLog.info("Tracks", this.tracks) + } + + // Selects a track by its label. + selectTrackByLabel(trackLabel: string) { + const track = this.playbackInfo.mkvMetadata?.subtitleTracks?.find?.(t => t.name === trackLabel) + if (track) { + this.selectTrack(track.number) + } else { + subtitleLog.error("Track not found", trackLabel) + this.setNoTrack() + } + } + + // Sets the track to no track. + setNoTrack() { + this.currentTrackNumber = NO_TRACK_NUMBER + this.libassRenderer?.setTrack(this.defaultSubtitleHeader) + this.libassRenderer?.resize?.() + } + + // Selects a track by its number. + selectTrack(trackNumber: number) { + subtitleLog.info("Track selection requested", trackNumber) + this._init() + + if (this.currentTrackNumber === trackNumber) { + subtitleLog.info("Track already selected", trackNumber) + return + } + + if (trackNumber === NO_TRACK_NUMBER) { + subtitleLog.info("No track selected", trackNumber) + this.setNoTrack() + return + } + + const track = this._getTracks()?.find?.(t => t.info.number === trackNumber) + subtitleLog.info("Selecting track", trackNumber, track) + + if (this.videoElement.textTracks) { + subtitleLog.info("Updating video element's textTracks", this.videoElement.textTracks) + for (const textTrack of this.videoElement.textTracks) { + if (track && textTrack.id === track.info.number.toString()) { + textTrack.mode = "showing" + } else { + textTrack.mode = "disabled" + } + } + this.videoElement.textTracks.dispatchEvent(new Event("change")) + } + + if (!track) { + subtitleLog.info("Track not found", trackNumber) + this.setNoTrack() + return + } + + const codecPrivate = track.info.codecPrivate?.slice?.(0, -1) || this.defaultSubtitleHeader + + this.currentTrackNumber = track.info.number // update the current track number + + // Set the track + this.libassRenderer?.setTrack(codecPrivate) + const trackEventMap = this.tracks[track.info.number]?.events + if (!trackEventMap) { + return + } + subtitleLog.info("Found", trackEventMap.size, "events for track", track.info.number) + + // Add the events to the libass renderer + for (const { assEvent } of trackEventMap.values()) { + this.libassRenderer?.createEvent(assEvent) + } + + this.libassRenderer?.resize?.() + } + + // This will record the events and add them to the libass renderer if they are new. + onSubtitleEvent(event: MKVParser_SubtitleEvent) { + // Record the event + const { isNew, assEvent } = this._recordSubtitleEvent(event) + // subtitleLog.info("Subtitle event received", event.trackNumber, this.currentTrackNumber) + + if (!assEvent) return + + // if the event is new and is from the selected track, add it to the libass renderer + if (this.libassRenderer && isNew && event.trackNumber === this.currentTrackNumber) { + // console.log("Creating event", event.text) + // console.table(assEvent) + this.libassRenderer.createEvent(assEvent) + } + } + + onTrackAdded(track: MKVParser_TrackInfo) { + subtitleLog.info("Subtitle track added", track) + toast.info(`Subtitle track added: ${track.name}`) + this._addTrack(track) + this._storeTrackStyles() + // Add the track to the video element + const trackEl = document.createElement("track") + trackEl.id = track.number.toString() + trackEl.kind = "subtitles" + trackEl.label = track.name || "" + trackEl.srclang = track.language || "eng" + this.videoElement.appendChild(trackEl) + this._selectDefaultTrack() + this._init() + this.libassRenderer?.resize?.() + + } + + destroy() { + subtitleLog.info("Destroying subtitle manager") + this.libassRenderer?.destroy() + this.libassRenderer = null + for (const trackNumber in this.tracks) { + this.tracks[trackNumber].events.clear() + } + this.tracks = {} + this.currentTrackNumber = NO_TRACK_NUMBER + } + + // ----------- Private methods ----------- // + + // + // Selects a track to be used. + // This should be called after the tracks are loaded. + // When called for the first time, it will initialize the libass renderer. + // + private _selectDefaultTrack() { + if (this.currentTrackNumber !== NO_TRACK_NUMBER) return + const tracks = this._getTracks() + + if (!tracks?.length) { + this.setNoTrack() + return + } + + if (tracks.length === 1) { + this.selectTrack(tracks[0].info.number) + return + } + + const foundTracks = tracks?.filter?.(t => t.info.language === this.settings.preferredSubtitleLanguage) + if (foundTracks?.length) { + // Find default or forced track + const defaultIndex = foundTracks.findIndex(t => t.info.forced) + this.selectTrack(foundTracks[defaultIndex >= 0 ? defaultIndex : 0].info.number) + return + } + + // No default tracks found, select the english track + const englishTracks = tracks?.filter?.(t => (t.info.language || "eng") === "eng" || t.info.language === "en") + if (englishTracks?.length) { + const defaultIndex = englishTracks.findIndex(t => t.info.forced || t.info.default) + this.selectTrack(englishTracks[defaultIndex >= 0 ? defaultIndex : 0].info.number) + return + } + + // No tracks found, select the first track + this.selectTrack(tracks?.[0]?.info?.number ?? NO_TRACK_NUMBER) + } + + private _addTrack(track: MKVParser_TrackInfo) { + this.tracks[track.number] = { + info: track, + events: new Map(), + styles: {}, + } + return this.tracks[track.number] + } + + private _getTracks() { + return Object.values(this.tracks).sort((a, b) => a.info.number - b.info.number) + } + + getSelectedTrack(): number | null { + if (!this.videoElement.textTracks) return null + + for (let i = 0; i < this.videoElement.textTracks.length; i++) { + if (this.videoElement.textTracks[i].mode === "showing") { + return Number(this.videoElement.textTracks[i].id) + } + } + + return null + } + + // + // Stores the styles for each track. + // + private _storeTrackStyles() { + if (!this.playbackInfo?.mkvMetadata?.subtitleTracks) return + for (const track of this.playbackInfo.mkvMetadata.subtitleTracks) { + const codecPrivate = track.codecPrivate?.slice?.(0, -1) || this.defaultSubtitleHeader + const lines = codecPrivate.replaceAll("\r\n", "\n").split("\n").filter(line => line.startsWith("Style:")) + let index = 1 + const s: Record<string, number> = {} + this.tracks[track.number].styles = s // reset styles + for (const line of lines) { + let styleName = line.split("Style:")[1] + styleName = (styleName.split(",")[0] || "").trim() + if (styleName && !s[styleName]) { + s[styleName] = index++ + } + } + this.tracks[track.number].styles = s + } + } + + private __eventMapKey(event: MKVParser_SubtitleEvent): string { + return `${event.trackNumber}-${event.startTime}-${event.duration}-${event.extraData?.style}-${event.extraData?.name}-${event.extraData?.marginL}-${event.extraData?.marginR}-${event.extraData?.marginV}-${event.extraData?.effect}-${event.extraData?.readOrder}-${event.extraData?.layer}` + } + + private _init() { + if (!!this.libassRenderer) return + + subtitleLog.info("Initializing libass renderer") + + const wasmUrl = new URL("/jassub/jassub-worker.wasm", window.location.origin).toString() + const workerUrl = new URL("/jassub/jassub-worker.js", window.location.origin).toString() + // const legacyWasmUrl = new URL("/jassub/jassub-worker.wasm.js", window.location.origin).toString() + const modernWasmUrl = new URL("/jassub/jassub-worker-modern.wasm", window.location.origin).toString() + + const legacyWasmUrl = process.env.NODE_ENV === "development" + ? "/jassub/jassub-worker.wasm.js" : legacy_getAssetUrl("/jassub/jassub-worker.wasm.js") + + const defaultFontUrl = "/jassub/Roboto-Medium.ttf" + + this.libassRenderer = new JASSUB({ + video: this.videoElement, + subContent: this.defaultSubtitleHeader, // needed + // subUrl: new URL("/jassub/test.ass", window.location.origin).toString(), + wasmUrl: wasmUrl, + workerUrl: workerUrl, + legacyWasmUrl: legacyWasmUrl, + modernWasmUrl: modernWasmUrl, + // Both parameters needed for subs to work on iOS, ref: jellyfin-vue + offscreenRender: isApple() ? false : this.jassubOffscreenRender, // should be false for iOS + prescaleFactor: 0.8, + onDemandRender: false, + fonts: this.fonts, + fallbackFont: "roboto medium", + availableFonts: { + "roboto medium": defaultFontUrl, + }, + libassGlyphLimit: 60500, + }) + + this.fonts = this.playbackInfo.mkvMetadata?.attachments?.filter(a => a.type === "font") + ?.map(a => `${getServerBaseUrl()}/api/v1/directstream/att/${a.filename}`) || [] + + this.fonts = [defaultFontUrl, ...this.fonts] + + for (const font of this.fonts) { + this.libassRenderer.addFont(font) + } + } + + private _createAssEvent(event: MKVParser_SubtitleEvent, index: number): ASS_Event { + return { + Start: event.startTime, + Duration: event.duration, + Style: String(event.extraData?.style ? this.tracks[event.trackNumber]?.styles?.[event.extraData?.style ?? "Default"] : 1), + Name: event.extraData?.name ?? "", + MarginL: event.extraData?.marginL ? Number(event.extraData.marginL) : 0, + MarginR: event.extraData?.marginR ? Number(event.extraData.marginR) : 0, + MarginV: event.extraData?.marginV ? Number(event.extraData.marginV) : 0, + Effect: event.extraData?.effect ?? "", + Text: event.text, + ReadOrder: event.extraData?.readOrder ? Number(event.extraData.readOrder) : 1, + Layer: event.extraData?.layer ? Number(event.extraData.layer) : 0, + // index is based on the order of the events in the record + _index: index, + } + } + + + private _recordSubtitleEvent(event: MKVParser_SubtitleEvent): { isNew: boolean, assEvent: ASS_Event | null } { + const trackEventMap = this.tracks[event.trackNumber]?.events // get the map + if (!trackEventMap) { + return { isNew: false, assEvent: null } + } + + const eventKey = this.__eventMapKey(event) + + // Check if the event is already in the record + // If it is, return false + if (trackEventMap.has(eventKey)) { + return { isNew: false, assEvent: trackEventMap.get(eventKey)?.assEvent! } + } + + // record the event + const assEvent = this._createAssEvent(event, trackEventMap.size) + trackEventMap.set(eventKey, { event, assEvent }) + return { isNew: true, assEvent } + } +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-time-range.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-time-range.tsx new file mode 100644 index 0000000..fa2d1ea --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-time-range.tsx @@ -0,0 +1,423 @@ +import { + vc_closestBufferedTime, + vc_currentTime, + vc_dispatchAction, + vc_duration, + vc_miniPlayer, + vc_previewManager, + vc_previousPausedState, + vc_seeking, + vc_seekingTargetProgress, + vc_videoElement, + VIDEOCORE_DEBUG_ELEMENTS, + VideoCoreChapterCue, +} from "@/app/(main)/_features/video-core/video-core" +import { VIDEOCORE_PREVIEW_CAPTURE_INTERVAL_SECONDS, VIDEOCORE_PREVIEW_THUMBNAIL_SIZE } from "@/app/(main)/_features/video-core/video-core-preview" +import { vc_highlightOPEDChaptersAtom, vc_showChapterMarkersAtom } from "@/app/(main)/_features/video-core/video-core.atoms" +import { vc_formatTime } from "@/app/(main)/_features/video-core/video-core.utils" +import { cn } from "@/components/ui/core/styling" +import { logger } from "@/lib/helpers/debug" +import { atom } from "jotai" +import { useAtomValue } from "jotai/index" +import { useAtom, useSetAtom } from "jotai/react" +import Image from "next/image" +import React from "react" +import { FaDiamond } from "react-icons/fa6" + +type VideoCoreTimeRangeChapter = { + width: number + percentageOffset: number + label: string | null +} + +export interface VideoCoreTimeRangeProps { + chapterCues: VideoCoreChapterCue[] +} + +const CHAPTER_GAP = 3 + +export const vc_timeRangeElement = atom<HTMLDivElement | null>(null) + +export function VideoCoreTimeRange(props: VideoCoreTimeRangeProps) { + const { + chapterCues, + } = props + + const videoElement = useAtomValue(vc_videoElement) + + const currentTime = useAtomValue(vc_currentTime) + const duration = useAtomValue(vc_duration) + const buffered = useAtomValue(vc_closestBufferedTime) + const [seekingTargetProgress, setSeekingTargetProgress] = useAtom(vc_seekingTargetProgress) + const [seeking, setSeeking] = useAtom(vc_seeking) + const [previouslyPaused, setPreviouslyPaused] = useAtom(vc_previousPausedState) + const action = useSetAtom(vc_dispatchAction) + const [showChapterMarkers] = useAtom(vc_showChapterMarkersAtom) + + const bufferedPercentage = React.useMemo(() => { + return (buffered / duration) * 100 + }, [buffered]) + + const chapters = React.useMemo(() => { + if (!chapterCues?.length) return [{ + width: 100, + percentageOffset: 0, + label: null, + }] + + let percentageOffset = 0 + return chapterCues + .toSorted((a, b) => a.startTime - b.startTime) + .filter(chapter => + (chapter.startTime || chapter.endTime) && + !(chapter.endTime > 0 && chapter.endTime < chapter.startTime), + ) + .map(chapter => { + const start = chapter.startTime ?? 0 + const end = chapter.endTime ?? duration + const chapterDuration = end - start + const width = (chapterDuration / duration) * 100 + const result = { + width, + percentageOffset, + label: chapter.text || null, + } + percentageOffset += width + return result + }) + }, [chapterCues, duration]) + + const [progressPercentage, setProgressPercentage] = React.useState((currentTime / duration) * 100) + + React.useEffect(() => { + setProgressPercentage((currentTime / duration) * 100) + }, [currentTime, duration]) + + + // start seeking + function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) { + e.stopPropagation() + if (!videoElement) return + if (e.button !== 0) return + e.currentTarget.setPointerCapture(e.pointerId) // capture movement outside + setSeeking(true) + // pause while seeking + setPreviouslyPaused(videoElement.paused) + if (!videoElement.paused) videoElement.pause() + // move the progress + handlePointerMove(e) + videoElement?.dispatchEvent(new Event("seeking")) + } + + // stop seeking + function handlePointerUp(e: React.PointerEvent<HTMLDivElement>) { + e.stopPropagation() + if (e.button !== 0) return + e.currentTarget.releasePointerCapture(e.pointerId) + setSeeking(false) + // actually seek the video + action({ type: "seekTo", payload: { time: (duration * seekingTargetProgress) / 100 } }) + // resume playing + if (!previouslyPaused) videoElement?.play() + } + + // stop seeking + function handlePointerLeave(e: React.PointerEvent<HTMLDivElement>) { + // don't reset while actively seeking + if (!seeking) { + setSeekingTargetProgress(0) + } + } + + function getPointerProgress<T extends HTMLElement>(e: React.PointerEvent<T>) { // 0-100 + const rect = e.currentTarget.getBoundingClientRect() + const x = e.clientX - rect.left + return Math.max(0, Math.min(100, (x / rect.width * 100))) + } + + // move progress + function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) { + const target = getPointerProgress(e) + if (seeking) { + e.stopPropagation() + setProgressPercentage(target) + } + setSeekingTargetProgress(target) + } + + const setTimeRangeElement = useSetAtom(vc_timeRangeElement) + const combineRef = (instance: HTMLDivElement | null) => { + // if (ref as unknown instanceof Function) (ref as any)(instance) + // else if (ref) (ref as any).current = instance + // if (instance) measureRef(instance) + setTimeRangeElement(instance) + } + + return ( + <div + ref={combineRef} + className={cn( + "vc-time-range", + "w-full relative group/vc-time-range z-[2] flex h-8", + "cursor-pointer outline-none", + )} + role="slider" + tabIndex={0} + aria-valuemin={0} + aria-valuenow={0} + aria-valuetext="0%" + aria-orientation="horizontal" + aria-label="Video playback time" + onPointerDown={handlePointerDown} + onPointerUp={handlePointerUp} + onPointerLeave={handlePointerLeave} + onPointerCancel={handlePointerLeave} + onPointerMove={handlePointerMove} + > + + <VideoCoreTimePreview chapters={chapters} /> + + {chapters.map((chapter, i) => { + return <VideoCoreTimeRangeSegment + key={i} + idx={i} + progressPercentage={progressPercentage} + bufferedPercentage={bufferedPercentage} + chapter={chapter} + showMarker={i < chapters.length - 1 && showChapterMarkers} + /> + })} + + </div> + ) +} + +function VideoCoreTimeRangeSegment(props: { + idx: number, + progressPercentage: number, + bufferedPercentage: number, + chapter: VideoCoreTimeRangeChapter, + showMarker: boolean, +}) { + const { idx, chapter, progressPercentage, bufferedPercentage, showMarker } = props + + const duration = useAtomValue(vc_duration) + const seekingTargetProgress = useAtomValue(vc_seekingTargetProgress) + const action = useSetAtom(vc_dispatchAction) + const highlightOPEDChapters = useAtomValue(vc_highlightOPEDChaptersAtom) + + const focused = !!seekingTargetProgress && chapter.percentageOffset <= seekingTargetProgress && chapter.percentageOffset + chapter.width >= seekingTargetProgress + + // returns x position of the bar in percentage + function getChapterBarPosition(chapter: VideoCoreTimeRangeChapter, percentage: number) { + const ret = (percentage - chapter.percentageOffset) * (100 / chapter.width) + return (ret <= 0 ? -CHAPTER_GAP : ret >= 100 ? 100 : ret) - 100 + } + + return ( + <div + className={cn( + "vc-time-range-chapter-segment", + "relative", + "w-full h-full flex items-center", + VIDEOCORE_DEBUG_ELEMENTS && "bg-yellow-500/10", + )} + style={{ + width: `${chapter.width}%`, + }} + > + <div + className={cn( + "vc-time-range-chapter", + "relative h-1 transition-[height] flex items-center justify-center overflow-hidden rounded-lg", + focused && "h-2", + VIDEOCORE_DEBUG_ELEMENTS && "bg-yellow-500/50", + )} + style={{ + width: idx > 0 ? `calc(100% - ${CHAPTER_GAP / 2}px)` : `100%`, + marginLeft: idx > 0 ? `${CHAPTER_GAP}px` : `0px`, + }} + > + <div + className={cn( + "vc-time-range-chapter-progress-bar", + "bg-white absolute w-full h-full left-0 transform-gpu hover:duration-[30ms] z-[10]", + focused && "duration-[30ms]", + )} + style={{ + "--tw-translate-x": duration > 1 ? `${getChapterBarPosition(chapter, progressPercentage)}%` : "-100%", + } as React.CSSProperties} + /> + <div + className={cn( + "vc-time-range-chapter-seeking-target-bar", + "bg-white/30 absolute w-full h-full left-0 transform-gpu z-[9]", + )} + style={{ + "--tw-translate-x": duration > 1 ? `${getChapterBarPosition(chapter, seekingTargetProgress)}%` : "-100%", + } as React.CSSProperties} + /> + <div + className={cn( + "vc-time-range-chapter-buffer-bar", + "bg-white/10 absolute w-full h-full left-0 transform-gpu z-[8]", + )} + style={{ + "--tw-translate-x": duration > 1 ? `${getChapterBarPosition(chapter, bufferedPercentage)}%` : "-100%", + } as React.CSSProperties} + /> + <div + className={cn( + "vc-time-range-chapter-bar", + "bg-white/20 absolute left-0 w-full h-full z-[1]", + (["opening", "op", "ending", + "ed"].includes(chapter.label?.toLowerCase?.() || "") && highlightOPEDChapters) && "bg-blue-300/50", + )} + /> + </div> + {showMarker && ( + <button + type="button" + onPointerDown={e => e.stopPropagation()} + onPointerUp={e => e.stopPropagation()} + onClick={e => { + e.stopPropagation() + action({ type: "seekTo", payload: { time: ((duration * (chapter.percentageOffset + chapter.width))) / 100 } }) + }} + className={cn( + "vc-time-range-chapter-marker", + "absolute top-0 right-0 size-4 flex items-center justify-center -translate-y-1/2 translate-x-1/2 cursor-pointer z-[20] ", + )} + style={{ + right: `-${CHAPTER_GAP / 2}px`, + }} + aria-label={`Seek to end of chapter ${idx + 1}`} + tabIndex={-1} + > + <FaDiamond className="size-2.5 text-white/20 hover:text-white/100 transition-colors duration-100" /> + </button> + )} + </div> + ) +} + +const timePreviewLog = logger("VIDEO CORE / TIME PREVIEW") + +function VideoCoreTimePreview(props: { chapters: VideoCoreTimeRangeChapter[] }) { + const { chapters } = props + + const videoElement = useAtomValue(vc_videoElement) + + const duration = useAtomValue(vc_duration) + const isMiniPlayer = useAtomValue(vc_miniPlayer) + const seekingTargetProgress = useAtomValue(vc_seekingTargetProgress) + const seeking = useAtomValue(vc_seeking) + const action = useSetAtom(vc_dispatchAction) + const previewManager = useAtomValue(vc_previewManager) + const timeRangeElement = useAtomValue(vc_timeRangeElement) + + const [previewThumbnail, setPreviewThumbnail] = React.useState<string | null>(null) + + const targetTime = (duration * seekingTargetProgress) / 100 // in seconds + + const chapterLabel = React.useMemo(() => { + // returns chapter name at the current target + const chapter = chapters.find(chapter => chapter.percentageOffset <= seekingTargetProgress && chapter.percentageOffset + chapter.width >= seekingTargetProgress) + return chapter?.label + }, [seekingTargetProgress, chapters]) + + const handleTimeRangePreview = React.useCallback(async (event: MouseEvent) => { + if (!previewManager || !duration || !timeRangeElement) { + return + } + + setPreviewThumbnail(null) + timeRangeElement.removeAttribute("data-preview-image") + + // Calculate preview time based on mouse position + const rect = timeRangeElement.getBoundingClientRect() + const x = event.clientX - rect.left + const percentage = Math.max(0, Math.min(1, x / rect.width)) + const previewTime = percentage * duration + + if (previewTime >= 0 && previewTime <= duration) { + const thumbnailIndex = Math.floor(previewTime / VIDEOCORE_PREVIEW_CAPTURE_INTERVAL_SECONDS) + + try { + const thumbnail = await previewManager.retrievePreviewForSegment(thumbnailIndex) + if (thumbnail) { + timeRangeElement.setAttribute("data-preview-image", thumbnail) + setPreviewThumbnail(thumbnail) + } + } + catch (error) { + timePreviewLog.error("Failed to get thumbnail", error) + } + } + }, [previewManager, timeRangeElement, duration]) + + React.useEffect(() => { + if (!timeRangeElement) { + return + } + + const handleMouseLeave = () => { + timeRangeElement.removeAttribute("data-preview-image") + setPreviewThumbnail(null) + } + + timeRangeElement.addEventListener("mouseleave", handleMouseLeave) + timeRangeElement.addEventListener("mousemove", handleTimeRangePreview) + + return () => { + timeRangeElement.removeEventListener("mouseleave", handleMouseLeave) + timeRangeElement.removeEventListener("mousemove", handleTimeRangePreview) + } + }, [handleTimeRangePreview, timeRangeElement]) + + const showThumbnail = (!isMiniPlayer && previewManager && (seeking || !!targetTime)) && + targetTime <= previewManager.getLastestCachedIndex() * VIDEOCORE_PREVIEW_CAPTURE_INTERVAL_SECONDS + + return <> + + {showThumbnail && <div + className={cn( + "absolute bottom-full aspect-video overflow-hidden rounded-md bg-black border border-white/50", + )} + style={{ + left: `clamp(${VIDEOCORE_PREVIEW_THUMBNAIL_SIZE / 2}px, ${(targetTime / duration) * 100}%, calc(100% - ${VIDEOCORE_PREVIEW_THUMBNAIL_SIZE / 2}px))`, + width: VIDEOCORE_PREVIEW_THUMBNAIL_SIZE + "px", + transform: "translateX(-50%) translateY(-54%)", + }} + > + {!!previewThumbnail && <Image + src={previewThumbnail || ""} + alt="Preview" + fill + sizes={VIDEOCORE_PREVIEW_THUMBNAIL_SIZE + "px"} + className="object-cover rounded-md" + decoding="async" + loading="lazy" + />} + </div>} + + {(seeking || !!targetTime) && <div + className={cn( + "absolute bottom-full mb-3 px-2 py-1 bg-black/70 text-white text-center text-sm rounded-md", + "whitespace-nowrap z-20 pointer-events-none", + "", + )} + style={{ + left: `${(targetTime / duration) * 100}%`, + transform: "translateX(-50%)", + }} + > + {chapterLabel && <p className="text-xs font-medium max-w-2xl truncate">{chapterLabel}</p>} + <p>{vc_formatTime(targetTime)}</p> + + <div + className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-black/70" + /> + </div>} + </> +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-top-section.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-top-section.tsx new file mode 100644 index 0000000..cb62f89 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core-top-section.tsx @@ -0,0 +1,71 @@ +import { NativePlayerState } from "@/app/(main)/_features/native-player/native-player.atoms" +import { vc_busy, vc_miniPlayer, vc_paused } from "@/app/(main)/_features/video-core/video-core" +import { vc_hoveringControlBar } from "@/app/(main)/_features/video-core/video-core-control-bar" +import { cn } from "@/components/ui/core/styling" +import { useAtomValue } from "jotai" +import { motion } from "motion/react" +import React from "react" + +export function VideoCoreTopSection(props: { children?: React.ReactNode }) { + const { children, ...rest } = props + + const busy = useAtomValue(vc_busy) + const paused = useAtomValue(vc_paused) + const isMiniPlayer = useAtomValue(vc_miniPlayer) + const hoveringControlBar = useAtomValue(vc_hoveringControlBar) + + return ( + <> + <div + data-vc-control-bar-top-section + className={cn( + "vc-control-bar-top-section", + "top-8 absolute left-0 w-full py-4 px-5 duration-200 transition-opacity opacity-0 z-[999]", + (busy || paused || hoveringControlBar) && "opacity-100", + isMiniPlayer && "top-0", + )} + > + {children} + </div> + + <div + className={cn( + "vc-control-bar-top-gradient pointer-events-none", + "absolute top-0 left-0 right-0 w-full z-[5] transition-opacity duration-300 opacity-0", + "bg-gradient-to-b from-black/60 to-transparent", + "h-20", + (isMiniPlayer && paused) && "opacity-100", + )} + /> + </> + ) +} + +export function VideoCoreTopPlaybackInfo(props: { state: NativePlayerState, children?: React.ReactNode }) { + const { state, children, ...rest } = props + + const busy = useAtomValue(vc_busy) + const paused = useAtomValue(vc_paused) + const isMiniPlayer = useAtomValue(vc_miniPlayer) + const hoveringControlBar = useAtomValue(vc_hoveringControlBar) + + if (isMiniPlayer) return null + + return ( + <> + <div + className={cn( + "transition-opacity duration-200 opacity-0", + (paused || hoveringControlBar) && "opacity-100", + )} + > + <p className="text-white font-bold text-lg"> + {state.playbackInfo?.episode?.displayTitle} + </p> + <p className="text-white/50 text-base !font-normal"> + {state.playbackInfo?.episode?.episodeTitle} + </p> + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core.atoms.ts new file mode 100644 index 0000000..ccd3eda --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core.atoms.ts @@ -0,0 +1,79 @@ +import { atomWithStorage } from "jotai/utils" + +export type VideoCoreSettings = { + preferredSubtitleLanguage: string + preferredAudioLanguage: string + // Video enhancement settings + videoEnhancement: { + enabled: boolean + contrast: number // 0.8 - 1.2 (1.0 = default) + saturation: number // 0.8 - 1.3 (1.0 = default) + brightness: number // 0.9 - 1.1 (1.0 = default) + } +} + +export const vc_initialSettings: VideoCoreSettings = { + preferredSubtitleLanguage: "eng", + preferredAudioLanguage: "jpn", + videoEnhancement: { + enabled: true, + contrast: 1.05, + saturation: 1.1, + brightness: 1.02, + }, +} + +export const vc_settings = atomWithStorage<VideoCoreSettings>("sea-video-core-settings", + vc_initialSettings, + undefined, + { getOnInit: true }) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export interface VideoCoreKeybindings { + seekForward: { key: string; value: number } + seekBackward: { key: string; value: number } + seekForwardFine: { key: string; value: number } + seekBackwardFine: { key: string; value: number } + nextChapter: { key: string } + previousChapter: { key: string } + volumeUp: { key: string; value: number } + volumeDown: { key: string; value: number } + mute: { key: string } + cycleSubtitles: { key: string } + cycleAudio: { key: string } + nextEpisode: { key: string } + previousEpisode: { key: string } + fullscreen: { key: string } + pictureInPicture: { key: string } + increaseSpeed: { key: string; value: number } + decreaseSpeed: { key: string; value: number } + takeScreenshot: { key: string } +} + +export const vc_defaultKeybindings: VideoCoreKeybindings = { + seekForward: { key: "KeyD", value: 30 }, + seekBackward: { key: "KeyA", value: 30 }, + seekForwardFine: { key: "ArrowRight", value: 2 }, + seekBackwardFine: { key: "ArrowLeft", value: 2 }, + nextChapter: { key: "KeyE" }, + previousChapter: { key: "KeyQ" }, + volumeUp: { key: "ArrowUp", value: 5 }, + volumeDown: { key: "ArrowDown", value: 5 }, + mute: { key: "KeyM" }, + cycleSubtitles: { key: "KeyJ" }, + cycleAudio: { key: "KeyK" }, + nextEpisode: { key: "KeyN" }, + previousEpisode: { key: "KeyB" }, + fullscreen: { key: "KeyF" }, + pictureInPicture: { key: "KeyP" }, + increaseSpeed: { key: "BracketRight", value: 0.1 }, + decreaseSpeed: { key: "BracketLeft", value: 0.1 }, + takeScreenshot: { key: "KeyI" }, +} + +export const vc_keybindingsAtom = atomWithStorage("sea-video-core-keybindings", vc_defaultKeybindings, undefined, { getOnInit: true }) + +export const vc_showChapterMarkersAtom = atomWithStorage("sea-video-core-chapter-markers", true, undefined, { getOnInit: true }) +export const vc_highlightOPEDChaptersAtom = atomWithStorage("sea-video-core-highlight-op-ed-chapters", true, undefined, { getOnInit: true }) +export const vc_beautifyImageAtom = atomWithStorage("sea-video-core-beautify-image", true, undefined, { getOnInit: true }) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core.tsx new file mode 100644 index 0000000..b422dd6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core.tsx @@ -0,0 +1,1068 @@ +import { getServerBaseUrl } from "@/api/client/server-url" +import { NativePlayerState } from "@/app/(main)/_features/native-player/native-player.atoms" +import { AniSkipTime } from "@/app/(main)/_features/sea-media-player/aniskip" +import { + __seaMediaPlayer_autoNextAtom, + __seaMediaPlayer_autoPlayAtom, + __seaMediaPlayer_autoSkipIntroOutroAtom, + __seaMediaPlayer_mutedAtom, + __seaMediaPlayer_playbackRateAtom, + __seaMediaPlayer_volumeAtom, +} from "@/app/(main)/_features/sea-media-player/sea-media-player.atoms" +import { vc_doFlashAction, VideoCoreActionDisplay } from "@/app/(main)/_features/video-core/video-core-action-display" +import { vc_anime4kOption, VideoCoreAnime4K } from "@/app/(main)/_features/video-core/video-core-anime-4k" +import { Anime4KOption, VideoCoreAnime4KManager } from "@/app/(main)/_features/video-core/video-core-anime-4k-manager" +import { VideoCoreAudioManager } from "@/app/(main)/_features/video-core/video-core-audio" +import { + vc_hoveringControlBar, + VideoCoreAudioButton, + VideoCoreControlBar, + VideoCoreFullscreenButton, + VideoCoreNextButton, + VideoCorePipButton, + VideoCorePlayButton, + VideoCorePreviousButton, + VideoCoreSettingsButton, + VideoCoreSubtitleButton, + VideoCoreTimestamp, + VideoCoreVolumeButton, +} from "@/app/(main)/_features/video-core/video-core-control-bar" +import { VideoCoreDrawer } from "@/app/(main)/_features/video-core/video-core-drawer" +import { vc_fullscreenManager, VideoCoreFullscreenManager } from "@/app/(main)/_features/video-core/video-core-fullscreen" +import { VideoCoreKeybindingController, VideoCoreKeybindingsModal } from "@/app/(main)/_features/video-core/video-core-keybindings" +import { vc_pipElement, vc_pipManager, VideoCorePipManager } from "@/app/(main)/_features/video-core/video-core-pip" +import { useVideoCorePlaylistSetup } from "@/app/(main)/_features/video-core/video-core-playlist" +import { VideoCorePreviewManager } from "@/app/(main)/_features/video-core/video-core-preview" +import { VideoCoreSubtitleManager } from "@/app/(main)/_features/video-core/video-core-subtitles" +import { VideoCoreTimeRange } from "@/app/(main)/_features/video-core/video-core-time-range" +import { VideoCoreTopPlaybackInfo, VideoCoreTopSection } from "@/app/(main)/_features/video-core/video-core-top-section" +import { vc_beautifyImageAtom, vc_settings } from "@/app/(main)/_features/video-core/video-core.atoms" +import { + detectSubtitleType, + isSubtitleFile, + useVideoCoreBindings, + vc_createChapterCues, + vc_createChaptersFromAniSkip, + vc_formatTime, + vc_logGeneralInfo, +} from "@/app/(main)/_features/video-core/video-core.utils" +import { TorrentStreamOverlay } from "@/app/(main)/entry/_containers/torrent-stream/torrent-stream-overlay" +import { LuffyError } from "@/components/shared/luffy-error" +import { Button, IconButton } from "@/components/ui/button" +import { useUpdateEffect } from "@/components/ui/core/hooks" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { logger } from "@/lib/helpers/debug" +import { __isDesktop__ } from "@/types/constants" +import { atom, useAtomValue } from "jotai" +import { derive } from "jotai-derive" +import { createIsolation } from "jotai-scope" +import { useAtom, useSetAtom } from "jotai/react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { BiExpand, BiX } from "react-icons/bi" +import { FiMinimize2 } from "react-icons/fi" +import { PiSpinnerDuotone } from "react-icons/pi" +import { RemoveScrollBar } from "react-remove-scroll-bar" +import { useMeasure } from "react-use" +import { toast } from "sonner" + +const VideoCoreIsolation = createIsolation() + +const log = logger("VIDEO CORE") + +export const VIDEOCORE_DEBUG_ELEMENTS = false + +const DELAY_BEFORE_NOT_BUSY = 1_000 //ms + +export const vc_videoSize = atom({ width: 1, height: 1 }) +export const vc_realVideoSize = atom({ width: 0, height: 0 }) +export const vc_duration = atom(1) +export const vc_currentTime = atom(0) +export const vc_playbackRate = atom(1) +export const vc_readyState = atom(0) +export const vc_buffering = atom(false) +export const vc_isMuted = atom(false) +export const vc_volume = atom(1) +export const vc_subtitleDelay = atom(0) +export const vc_isFullscreen = atom(false) +export const vc_pip = derive([vc_pipElement], (pipElement) => pipElement !== null) +export const vc_seeking = atom(false) +export const vc_seekingTargetProgress = atom(0) // 0-100 +export const vc_timeRanges = atom<TimeRanges | null>(null) +export const vc_closestBufferedTime = derive([vc_timeRanges, vc_currentTime], (tr, currentTime) => { + if (!tr) return 0 + let closest = 0 + for (let i = 0; i < tr.length; i++) { + const start = tr.start(i) + const end = tr.end(i) + if (currentTime >= start && currentTime <= end) { + return end + } + if (end >= currentTime && closest > end) { + closest = end + } + } + return closest +}) +export const vc_ended = atom(false) +export const vc_paused = atom(true) +export const vc_miniPlayer = atom(false) +export const vc_menuOpen = atom(false) +export const vc_cursorBusy = derive([vc_hoveringControlBar, vc_menuOpen], (f1, f2) => { + return f1 || f2 +}) +export const vc_cursorPosition = atom({ x: 0, y: 0 }) +export const vc_busy = atom(true) + +export const vc_videoElement = atom<HTMLVideoElement | null>(null) +export const vc_containerElement = atom<HTMLDivElement | null>(null) + +export const vc_subtitleManager = atom<VideoCoreSubtitleManager | null>(null) +export const vc_audioManager = atom<VideoCoreAudioManager | null>(null) +export const vc_previewManager = atom<VideoCorePreviewManager | null>(null) +export const vc_anime4kManager = atom<VideoCoreAnime4KManager | null>(null) + +export const vc_previousPausedState = atom(false) + +export const vc_lastKnownProgress = atom(0) + +type VideoCoreAction = "seekTo" | "seek" | "togglePlay" | "restoreProgress" + +export const vc_dispatchAction = atom(null, (get, set, action: { type: VideoCoreAction; payload?: any }) => { + const videoElement = get(vc_videoElement) + const duration = get(vc_duration) + let t = 0 + if (videoElement) { + switch (action.type) { + // for smooth seeking, we don't want to peg the current time to the actual video time + // instead act like the target time is instantly reached + case "seekTo": + t = Math.min(duration, Math.max(0, action.payload.time)) + videoElement.currentTime = t + set(vc_currentTime, t) + if (action.payload.flashTime) { + set(vc_doFlashAction, { message: `${vc_formatTime(t)} / ${vc_formatTime(duration)}`, type: "message" }) + } + break + case "seek": + const currentTime = get(vc_currentTime) + t = Math.min(duration, Math.max(0, currentTime + action.payload.time)) + videoElement.currentTime = t + set(vc_currentTime, t) + if (action.payload.flashTime) { + set(vc_doFlashAction, { message: `${vc_formatTime(t)} / ${vc_formatTime(duration)}`, type: "message" }) + } + break + case "togglePlay": + videoElement.paused ? videoElement.play() : videoElement.pause() + break + case "restoreProgress": + // Restore time to the last known position + set(vc_lastKnownProgress, Math.max(0, action.payload.time)) + break + } + } +}) + +export function VideoCoreProvider(props: { children: React.ReactNode }) { + const { children } = props + return ( + <VideoCoreIsolation.Provider> + {children} + </VideoCoreIsolation.Provider> + ) +} + +export type VideoCoreChapterCue = { + startTime: number + endTime: number + text: string +} + +export interface VideoCoreProps { + state: NativePlayerState + aniSkipData?: { + op: AniSkipTime | null + ed: AniSkipTime | null + } | undefined + onTerminateStream: () => void + onEnded?: () => void + onCompleted?: () => void + onPlay?: () => void + onPause?: () => void + onTimeUpdate?: () => void + onLoadedData?: () => void + onLoadedMetadata?: () => void + onVolumeChange?: () => void + onSeeking?: () => void + onSeeked?: (time: number) => void + onError?: (error: string) => void + onPlaybackRateChange?: () => void + onFileUploaded: (data: { name: string, content: string }) => void +} + +export function VideoCore(props: VideoCoreProps) { + const { + state, + aniSkipData, + onTerminateStream, + onEnded, + onPlay, + onCompleted, + onPause, + onTimeUpdate, + onLoadedData, + onLoadedMetadata, + onVolumeChange, + onSeeking, + onSeeked, + onError, + onPlaybackRateChange, + onFileUploaded, + ...rest + } = props + + const videoRef = useRef<HTMLVideoElement | null>(null) + const containerRef = useRef<HTMLDivElement | null>(null) + + const setVideoElement = useSetAtom(vc_videoElement) + const setRealVideoSize = useSetAtom(vc_realVideoSize) + useVideoCoreBindings(state.playbackInfo) + // useVideoCoreAnime4K() + useVideoCorePlaylistSetup() + + const videoCompletedRef = useRef(false) + const currentPlaybackRef = useRef<string | null>(null) + + const [, setContainerElement] = useAtom(vc_containerElement) + + const [subtitleManager, setSubtitleManager] = useAtom(vc_subtitleManager) + const [audioManager, setAudioManager] = useAtom(vc_audioManager) + const [previewManager, setPreviewManager] = useAtom(vc_previewManager) + const [anime4kManager, setAnime4kManager] = useAtom(vc_anime4kManager) + const [pipManager, setPipManager] = useAtom(vc_pipManager) + const setPipElement = useSetAtom(vc_pipElement) + const [fullscreenManager, setFullscreenManager] = useAtom(vc_fullscreenManager) + const setIsFullscreen = useSetAtom(vc_isFullscreen) + const action = useSetAtom(vc_dispatchAction) + + // States + const settings = useAtomValue(vc_settings) + const [isMiniPlayer, setIsMiniPlayer] = useAtom(vc_miniPlayer) + const [busy, setBusy] = useAtom(vc_busy) + const [buffering, setBuffering] = useAtom(vc_buffering) + const duration = useAtomValue(vc_duration) + const fullscreen = useAtomValue(vc_isFullscreen) + const paused = useAtomValue(vc_paused) + const readyState = useAtomValue(vc_readyState) + const beautifyImage = useAtomValue(vc_beautifyImageAtom) + const isPip = useAtomValue(vc_pip) + const flashAction = useSetAtom(vc_doFlashAction) + const dispatchAction = useSetAtom(vc_dispatchAction) + + const [showSkipIntroButton, setShowSkipIntroButton] = useState(false) + const [showSkipEndingButton, setShowSkipEndingButton] = useState(false) + + const [autoNext, setAutoNext] = useAtom(__seaMediaPlayer_autoNextAtom) + const [autoPlay] = useAtom(__seaMediaPlayer_autoPlayAtom) + const [autoSkipIntroOutro] = useAtom(__seaMediaPlayer_autoSkipIntroOutroAtom) + const [volume] = useAtom(__seaMediaPlayer_volumeAtom) + const [muted] = useAtom(__seaMediaPlayer_mutedAtom) + const [playbackRate, setPlaybackRate] = useAtom(__seaMediaPlayer_playbackRateAtom) + + const [measureRef, { width, height }] = useMeasure<HTMLVideoElement>() + React.useEffect(() => { + setRealVideoSize({ + width, + height, + }) + }, [width, height]) + + + React.useEffect(() => { + if (state.active && videoRef.current && !!state.playbackInfo) { + // Small delay to ensure the video element is fully rendered + setTimeout(() => { + videoRef.current?.focus() + }, 100) + } + }, [state.active]) + + const combineRef = (instance: HTMLVideoElement | null) => { + videoRef.current = instance + if (instance) measureRef(instance) + setVideoElement(instance) + } + const combineContainerRef = (instance: HTMLDivElement | null) => { + containerRef.current = instance + setContainerElement(instance) + } + + // actions + function togglePlay() { + if (videoRef?.current?.paused) { + videoRef?.current?.play() + onPlay?.() + flashAction({ message: "PLAY", type: "icon" }) + } else { + videoRef?.current?.pause() + onPause?.() + flashAction({ message: "PAUSE", type: "icon" }) + } + } + + function onCaptionsChange() { + log.info("Subtitles changed", videoRef.current?.textTracks) + if (videoRef.current) { + let trackFound = false + for (let i = 0; i < videoRef.current.textTracks.length; i++) { + const track = videoRef.current.textTracks[i] + if (track.mode === "showing") { + subtitleManager?.selectTrack(Number(track.id)) + trackFound = true + } + } + if (!trackFound) { + subtitleManager?.setNoTrack() + } + } + } + + function onAudioChange() { + log.info("Audio changed", videoRef.current?.audioTracks) + if (videoRef.current?.audioTracks) { + for (let i = 0; i < videoRef.current.audioTracks.length; i++) { + const track = videoRef.current.audioTracks[i] + if (track.enabled) { + audioManager?.selectTrack(Number(track.id)) + break + } + } + } + action({ type: "seek", payload: { time: -1 } }) + } + + useUpdateEffect(() => { + if (!state.playbackInfo) { + log.info("Cleaning up") + setVideoElement(null) + subtitleManager?.destroy?.() + setSubtitleManager(null) + previewManager?.cleanup?.() + setPreviewManager(null) + setAudioManager(null) + anime4kManager?.destroy?.() + setAnime4kManager(null) + pipManager?.destroy?.() + setPipManager(null) + setPipElement(null) + fullscreenManager?.destroy?.() + setFullscreenManager(null) + setIsFullscreen(false) + currentPlaybackRef.current = null + videoRef.current = null + } + + if (!!state.playbackInfo && (!currentPlaybackRef.current || state.playbackInfo.id !== currentPlaybackRef.current)) { + log.info("New stream loaded") + vc_logGeneralInfo(videoRef.current) + } + }, [state.playbackInfo, videoRef.current]) + + const streamUrl = state?.playbackInfo?.streamUrl?.replace?.("{{SERVER_URL}}", getServerBaseUrl()) + + const [anime4kOption, setAnime4kOption] = useAtom(vc_anime4kOption) + + // events + const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement>) => { + onLoadedMetadata?.() + if (!videoRef.current) return + const v = videoRef.current + + log.info("Loaded metadata", v.duration) + log.info("Audio tracks", v.audioTracks) + log.info("Text tracks", v.textTracks) + + onCaptionsChange() + onAudioChange() + + videoCompletedRef.current = false + + if (!state.playbackInfo) return // shouldn't happen + + // setHasUpdatedProgress(false) + + currentPlaybackRef.current = state.playbackInfo.id + + // Initialize the subtitle manager if the stream is MKV + if (!!state.playbackInfo?.mkvMetadata) { + setSubtitleManager(p => { + if (p) p.destroy() + return new VideoCoreSubtitleManager({ + videoElement: v!, + playbackInfo: state.playbackInfo!, + jassubOffscreenRender: true, + settings: settings, + }) + }) + + setAudioManager(new VideoCoreAudioManager({ + videoElement: v!, + playbackInfo: state.playbackInfo, + settings: settings, + onError: (error) => { + log.error("Audio manager error", error) + onError?.(error) + }, + })) + } + + // Initialize Anime4K manager + setAnime4kManager(p => { + if (p) p.destroy() + return new VideoCoreAnime4KManager({ + videoElement: v!, + settings: settings, + onFallback: (message) => { + flashAction({ message, duration: 2000 }) + }, + onOptionChanged: (opt) => { + console.warn("here", opt) + setAnime4kOption(opt) + }, + }) + }) + + // Initialize PIP manager + setPipManager(p => { + if (p) p.destroy() + const manager = new VideoCorePipManager((element) => { + setPipElement(element) + }) + manager.setVideo(v!, state.playbackInfo!) + return manager + }) + + // Initialize fullscreen manager + setFullscreenManager(p => { + if (p) p.destroy() + return new VideoCoreFullscreenManager((isFullscreen: boolean) => { + setIsFullscreen(isFullscreen) + }) + }) + + log.info("Initializing preview manager") + // TODO uncomment + // setPreviewManager(p => { + // if (p) p.cleanup() + // return new VideoCorePreviewManager(v!, streamUrl) + // }) + } + + const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => { + onTimeUpdate?.() + if (!videoRef.current) return + const v = videoRef.current + + // Video completed event + const percent = v.currentTime / v.duration + if (!!v.duration && !videoCompletedRef.current && percent >= 0.8) { + videoCompletedRef.current = true + + } + + /** + * AniSkip + */ + if ( + aniSkipData?.op?.interval && + !!e.currentTarget.currentTime && + e.currentTarget.currentTime >= aniSkipData.op.interval.startTime && + e.currentTarget.currentTime <= aniSkipData.op.interval.endTime + ) { + setShowSkipIntroButton(true) + if (autoSkipIntroOutro) { + action({ type: "seekTo", payload: { time: aniSkipData?.op?.interval?.endTime || 0 } }) + } + } else { + setShowSkipIntroButton(false) + } + if ( + aniSkipData?.ed?.interval && + Math.abs(aniSkipData.ed.interval.startTime - (aniSkipData?.ed?.episodeLength)) < 500 && + !!e.currentTarget.currentTime && + e.currentTarget.currentTime >= aniSkipData.ed.interval.startTime && + e.currentTarget.currentTime <= aniSkipData.ed.interval.endTime + ) { + setShowSkipEndingButton(true) + if (autoSkipIntroOutro) { + action({ type: "seekTo", payload: { time: aniSkipData?.ed?.interval?.endTime || 0 } }) + } + } else { + setShowSkipEndingButton(false) + } + } + + const handleEnded = (e: React.SyntheticEvent<HTMLVideoElement>) => { + log.info("Video ended") + onEnded?.() + } + + const handleClick = (e: React.SyntheticEvent<HTMLVideoElement>) => { + log.info("Video clicked") + togglePlay() + } + + const handleDoubleClick = (e: React.SyntheticEvent<HTMLVideoElement>) => { + // fullscreenManager?.toggleFullscreen() + } + + const handlePlay = (e: React.SyntheticEvent<HTMLVideoElement>) => { + log.info("Video resumed") + onPlay?.() + } + + const handlePause = (e: React.SyntheticEvent<HTMLVideoElement>) => { + log.info("Video paused") + onPause?.() + } + + const handleLoadedData = (e: React.SyntheticEvent<HTMLVideoElement>) => { + log.info("Loaded data") + onLoadedData?.() + } + + const handleVolumeChange = (e: React.SyntheticEvent<HTMLVideoElement>) => { + const v = e.currentTarget + log.info("Volume changed", v.volume) + onVolumeChange?.() + } + + const handleRateChange = (e: React.SyntheticEvent<HTMLVideoElement>) => { + const v = e.currentTarget + log.info("Playback rate changed", v.playbackRate) + onPlaybackRateChange?.() + + if (v.playbackRate !== playbackRate) { + setPlaybackRate(v.playbackRate) + } + } + + const handleError = (e: React.SyntheticEvent<HTMLVideoElement>) => { + onError?.("") + } + + const handleWaiting = (e: React.SyntheticEvent<HTMLVideoElement>) => { + setBuffering(true) + } + + const handleCanPlay = (e: React.SyntheticEvent<HTMLVideoElement>) => { + setBuffering(false) + } + + const handleStalled = (e: React.SyntheticEvent<HTMLVideoElement>) => { + setBuffering(true) + } + + // external state + + // Handle volume changes + React.useEffect(() => { + if (videoRef.current && volume !== undefined && volume !== videoRef.current.volume) { + videoRef.current.volume = volume + } + }, [volume, videoRef.current]) + + // Handle mute changes + React.useEffect(() => { + if (videoRef.current && muted !== undefined && muted !== videoRef.current.muted) { + videoRef.current.muted = muted + } + }, [muted, videoRef.current]) + + // Handle playback rate changes + React.useEffect(() => { + if (videoRef.current && playbackRate !== undefined && playbackRate !== videoRef.current.playbackRate) { + videoRef.current.playbackRate = playbackRate + } + }, [playbackRate, videoRef.current]) + + // Update PIP manager + React.useEffect(() => { + if (pipManager && videoRef.current && state.playbackInfo) { + pipManager.setVideo(videoRef.current, state.playbackInfo) + if (subtitleManager) pipManager.setSubtitleManager(subtitleManager) + } + }, [pipManager, subtitleManager, videoRef.current, state.playbackInfo]) + + // Update fullscreen manager + React.useEffect(() => { + if (fullscreenManager && containerRef.current) { + fullscreenManager.setContainer(containerRef.current) + } + }, [fullscreenManager, containerRef.current]) + + // + + // container events + const cursorBusy = useAtomValue(vc_cursorBusy) + const setNotBusyTimeout = React.useRef<NodeJS.Timeout | null>(null) + const lastPointerPosition = React.useRef({ x: 0, y: 0 }) + const handleContainerPointerMove = (e: React.PointerEvent<HTMLDivElement>) => { + const { x, y } = e.nativeEvent + const dx = x - lastPointerPosition.current.x + const dy = y - lastPointerPosition.current.y + if (Math.abs(dx) < 15 && Math.abs(dy) < 15) return + if (setNotBusyTimeout?.current) { + clearTimeout(setNotBusyTimeout.current) + } + setBusy(true) + setNotBusyTimeout.current = setTimeout(() => { + if (!cursorBusy) { + setBusy(false) + } + }, DELAY_BEFORE_NOT_BUSY) + lastPointerPosition.current = { x, y } + } + + const chapterCues = useMemo(() => { + // If we have MKV chapters, use them + if (state.playbackInfo?.mkvMetadata?.chapters?.length) { + const cues = vc_createChapterCues(state.playbackInfo.mkvMetadata.chapters, duration) + log.info("Chapter cues from MKV", cues) + return cues + } + + // Otherwise, create chapters from AniSkip data if available + if (!!aniSkipData?.op?.interval && duration > 0) { + const chapters = vc_createChaptersFromAniSkip(aniSkipData, duration, state?.playbackInfo?.media?.format) + const cues = vc_createChapterCues(chapters, duration) + log.info("Chapter cues from AniSkip", cues) + return cues + } + + return [] + }, + [ + state.playbackInfo?.mkvMetadata?.chapters, + aniSkipData?.op?.interval, + aniSkipData?.ed?.interval, + duration, + state?.playbackInfo?.media?.format, + ]) + + /** + * Upload subtitle files + */ + type UploadEvent = { + dataTransfer?: DataTransfer + clipboardData?: DataTransfer + } + const handleUpload = useCallback(async (e: UploadEvent & Event) => { + e.preventDefault() + log.info("Upload event", e) + const items = [...(e.dataTransfer ?? e.clipboardData)?.items ?? []] + + // First, try to get actual files + const actualFiles = items + .filter(item => item.kind === "file") + .map(item => item.getAsFile()) + .filter(file => file !== null) + + if (actualFiles.length > 0) { + // Process actual files + for (const f of actualFiles) { + if (f && isSubtitleFile(f.name)) { + const content = await f.text() + // console.log("Uploading subtitle file", f.name, content) + onFileUploaded({ name: f.name, content }) + } + } + } else { + // If no actual files, try to process text content + // Only process plain text, ignore RTF and HTML + const textItems = items.filter(item => + item.kind === "string" && + item.type === "text/plain", + ) + + if (textItems.length > 0) { + // Only take the first plain text item to avoid duplicates + const textItem = textItems[0] + textItem.getAsString(str => { + log.info("Uploading subtitle content from clipboard") + const type = detectSubtitleType(str) + log.info("Detected subtitle type", type) + if (type === "unknown") { + toast.error("Unknown subtitle type") + log.info("Unknown subtitle type, skipping") + return + } + const filename = `PLACEHOLDER.${type}` + onFileUploaded({ name: filename, content: str }) + }) + } + } + }, []) + + function suppressEvent(e: Event) { + e.preventDefault() + } + + useEffect(() => { + const playerContainer = containerRef.current + if (!playerContainer || !state.active) return + + playerContainer.addEventListener("paste", handleUpload) + playerContainer.addEventListener("drop", handleUpload) + playerContainer.addEventListener("dragover", suppressEvent) + playerContainer.addEventListener("dragenter", suppressEvent) + + return () => { + playerContainer.removeEventListener("paste", handleUpload) + playerContainer.removeEventListener("drop", handleUpload) + playerContainer.removeEventListener("dragover", suppressEvent) + playerContainer.removeEventListener("dragenter", suppressEvent) + } + }, [handleUpload, state.active]) + + /** + * Restore last position + */ + const [restoreProgressTo, setRestoreProgressTo] = useAtom(vc_lastKnownProgress) + React.useEffect(() => { + if (!anime4kManager || !restoreProgressTo) return + + if (anime4kOption === "off" || anime4kManager.canvas !== null) { + dispatchAction({ type: "seekTo", payload: { time: restoreProgressTo } }) + } else if (anime4kOption !== ("off" as Anime4KOption) && anime4kManager.canvas === null) { + anime4kManager.registerOnCanvasCreated(() => { + dispatchAction({ type: "seekTo", payload: { time: restoreProgressTo } }) + setRestoreProgressTo(0) + }) + } + + }, [anime4kManager, anime4kOption, restoreProgressTo]) + + return ( + <> + <VideoCoreAnime4K /> + <VideoCoreKeybindingsModal /> + {state.active && !isMiniPlayer && <RemoveScrollBar />} + + <VideoCoreDrawer + open={state.active} + onOpenChange={(v) => { + if (!v) { + // if (state.playbackError) { + // handleTerminateStream() + // return + // } + if (!isMiniPlayer) { + setIsMiniPlayer(true) + } else { + onTerminateStream() + } + } + }} + borderToBorder + miniPlayer={isMiniPlayer} + size={isMiniPlayer ? "md" : "full"} + side={isMiniPlayer ? "right" : "bottom"} + contentClass={cn( + "p-0 m-0", + !isMiniPlayer && "h-full", + )} + allowOutsideInteraction={true} + overlayClass={cn( + isMiniPlayer && "hidden", + )} + hideCloseButton + closeClass={cn( + "z-[99]", + __isDesktop__ && !isMiniPlayer && "top-8", + isMiniPlayer && "left-4", + )} + data-native-player-drawer + onMiniPlayerClick={() => { + togglePlay() + }} + > + {!(!!state.playbackInfo?.streamUrl && !state.loadingState) && <TorrentStreamOverlay isNativePlayerComponent />} + + {(state?.playbackError) && ( + <div className="h-full w-full bg-black/80 flex items-center justify-center z-[200] absolute p-4"> + <div className="text-white text-center"> + {!isMiniPlayer ? ( + <LuffyError title="Playback Error" /> + ) : ( + <h1 className={cn("text-2xl font-bold", isMiniPlayer && "text-lg")}>Playback Error</h1> + )} + <p className={cn("text-base text-white/50", isMiniPlayer && "text-sm max-w-lg mx-auto")}> + {state.playbackError || "An error occurred while playing the stream. Please try again later."} + </p> + </div> + </div> + )} + + <div + ref={combineContainerRef} + className={cn( + "relative w-full h-full bg-black overflow-clip flex items-center justify-center", + (!busy && !isMiniPlayer) && "cursor-none", // show cursor when not busy + )} + onPointerMove={handleContainerPointerMove} + // onPointerLeave={() => setBusy(false)} + // onPointerCancel={() => setBusy(false)} + > + + {(!!state.playbackInfo?.streamUrl && !state.loadingState) ? ( + <> + + <VideoCoreKeybindingController + videoRef={videoRef} + active={state.active} + chapterCues={chapterCues ?? []} + introStartTime={aniSkipData?.op?.interval?.startTime} + introEndTime={aniSkipData?.op?.interval?.endTime} + endingStartTime={aniSkipData?.ed?.interval?.startTime} + endingEndTime={aniSkipData?.ed?.interval?.endTime} + /> + + <VideoCoreActionDisplay /> + + {buffering && ( + <div className="absolute inset-0 flex items-center justify-center z-[50] pointer-events-none"> + <div className="bg-black/20 backdrop-blur-sm rounded-full p-4"> + <PiSpinnerDuotone className="size-12 text-white animate-spin" /> + </div> + </div> + )} + + {busy && <> + {showSkipIntroButton && !isMiniPlayer && !state.playbackInfo?.mkvMetadata?.chapters?.length && ( + <div className="absolute left-5 bottom-28 z-[60] native-player-hide-on-fullscreen"> + <Button + size="sm" + intent="gray-basic" + onClick={e => { + e.stopPropagation() + action({ type: "seekTo", payload: { time: aniSkipData?.op?.interval?.endTime || 0 } }) + }} + onPointerMove={e => e.stopPropagation()} + > + Skip Opening + </Button> + </div> + )} + + {showSkipEndingButton && !isMiniPlayer && !state.playbackInfo?.mkvMetadata?.chapters?.length && ( + <div className="absolute right-5 bottom-28 z-[60] native-player-hide-on-fullscreen"> + <Button + size="sm" + intent="gray-basic" + onClick={e => { + e.stopPropagation() + action({ type: "seekTo", payload: { time: aniSkipData?.ed?.interval?.endTime || 0 } }) + }} + onPointerMove={e => e.stopPropagation()} + > + Skip Ending + </Button> + </div> + )} + </>} + + <div className="relative w-full h-full flex items-center justify-center"> + <video + data-video-core-element + crossOrigin="anonymous" + // preload="metadata" + preload="auto" + src={streamUrl!} + ref={combineRef} + onLoadedMetadata={handleLoadedMetadata} + onTimeUpdate={handleTimeUpdate} + onEnded={handleEnded} + onPlay={handlePlay} + onPause={handlePause} + onClick={handleClick} + onDoubleClick={handleDoubleClick} + onLoadedData={handleLoadedData} + onVolumeChange={handleVolumeChange} + onRateChange={handleRateChange} + onError={handleError} + onWaiting={handleWaiting} + onCanPlay={handleCanPlay} + onStalled={handleStalled} + autoPlay={autoPlay} + muted={muted} + playsInline + controls={false} + style={{ + border: "none", + width: "100%", + height: "auto", + filter: (settings.videoEnhancement.enabled && beautifyImage) + ? `contrast(${settings.videoEnhancement.contrast}) saturate(${settings.videoEnhancement.saturation}) brightness(${settings.videoEnhancement.brightness})` + : "none", + imageRendering: "crisp-edges", + }} + > + {state.playbackInfo?.mkvMetadata?.subtitleTracks?.map(track => ( + <track + id={track.number.toString()} + key={track.number} + kind="subtitles" + srcLang={track.language || "eng"} + label={track.name} + /> + ))} + </video> + </div> + + <VideoCoreTopSection> + <VideoCoreTopPlaybackInfo state={state} /> + + <div + className={cn( + "opacity-0", + "transition-opacity duration-200 ease-in-out", + (busy || paused) && "opacity-100", + )} + > + <FloatingButtons part="video" onTerminateStream={onTerminateStream} /> + </div> + {/*<TorrentStreamOverlay isNativePlayerComponent="info" />*/} + </VideoCoreTopSection> + + {isPip && <div className="absolute top-0 left-0 w-full h-full z-[100] bg-black flex items-center justify-center"> + <Button + intent="gray-outline" size="xl" onClick={() => { + pipManager?.togglePip() + }} + > + Exit PiP + </Button> + </div>} + + <VideoCoreControlBar + timeRange={<VideoCoreTimeRange + chapterCues={chapterCues ?? []} + />} + > + + <VideoCorePlayButton /> + + <VideoCorePreviousButton onClick={() => { }} /> + <VideoCoreNextButton onClick={() => { }} /> + + <VideoCoreVolumeButton /> + + <VideoCoreTimestamp /> + + <div className="flex flex-1" /> + + {!isMiniPlayer && <TorrentStreamOverlay isNativePlayerComponent="control-bar" />} + + <VideoCoreSettingsButton /> + + <VideoCoreAudioButton /> + + <VideoCoreSubtitleButton /> + + <VideoCorePipButton /> + + <VideoCoreFullscreenButton /> + + + </VideoCoreControlBar> + + </> + ) : ( + <div + className="w-full h-full absolute flex justify-center items-center flex-col space-y-4 bg-black rounded-md" + > + + {/* {!state.miniPlayer && <SquareBg className="absolute top-0 left-0 w-full h-full z-[0]" />} */} + <FloatingButtons part="loading" onTerminateStream={onTerminateStream} /> + + {state.loadingState && <LoadingSpinner + title={state.loadingState || "Loading..."} + spinner={<PiSpinnerDuotone className="size-20 text-white animate-spin" />} + containerClass="z-[1]" + />} + </div> + )} + + + </div> + </VideoCoreDrawer> + </> + ) +} + +function FloatingButtons(props: { part: "video" | "loading", onTerminateStream: () => void }) { + const { part, onTerminateStream } = props + const fullscreen = useAtomValue(vc_isFullscreen) + const [isMiniPlayer, setIsMiniPlayer] = useAtom(vc_miniPlayer) + if (fullscreen) return null + const Content = () => ( + <> + {!isMiniPlayer && <> + <IconButton + icon={<FiMinimize2 className="text-2xl" />} + intent="gray-basic" + className="rounded-full absolute top-0 flex-none right-4 z-[999]" + onClick={() => { + setIsMiniPlayer(true) + }} + /> + </>} + + {isMiniPlayer && <> + <IconButton + type="button" + intent="gray" + size="sm" + className={cn( + "rounded-full text-2xl flex-none absolute z-[999] right-4 top-4 pointer-events-auto bg-black/30 hover:bg-black/40", + isMiniPlayer && "text-xl", + )} + icon={<BiExpand />} + onClick={() => { + setIsMiniPlayer(false) + }} + /> + <IconButton + type="button" + intent="alert-subtle" + size="sm" + className={cn( + "rounded-full text-2xl flex-none absolute z-[999] left-4 top-4 pointer-events-auto", + isMiniPlayer && "text-xl", + )} + icon={<BiX />} + onClick={() => { + onTerminateStream() + }} + /> + </>} + </> + ) + + if (part === "loading") { + return ( + <div + className={cn( + "absolute top-8 w-full z-[100]", + isMiniPlayer && "top-0", + )} + > + <Content /> + </div> + ) + } + + return <Content /> +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core.utils.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core.utils.ts new file mode 100644 index 0000000..cca04e5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_features/video-core/video-core.utils.ts @@ -0,0 +1,295 @@ +import { MKVParser_ChapterInfo, NativePlayer_PlaybackInfo } from "@/api/generated/types" +import { + vc_buffering, + vc_currentTime, + vc_duration, + vc_ended, + vc_isMuted, + vc_paused, + vc_playbackRate, + vc_readyState, + vc_timeRanges, + vc_videoElement, + vc_videoSize, + vc_volume, + VideoCoreChapterCue, +} from "@/app/(main)/_features/video-core/video-core" +import { useAtomValue } from "jotai" +import { useSetAtom } from "jotai/react" +import { useEffect } from "react" + +export type VideoCoreChapter = { + start: number + end: number + title: string +} + +export function useVideoCoreBindings(playbackInfo: NativePlayer_PlaybackInfo | null | undefined) { + + const v = useAtomValue(vc_videoElement) + const setVideoSize = useSetAtom(vc_videoSize) + const setDuration = useSetAtom(vc_duration) + const setCurrentTime = useSetAtom(vc_currentTime) + const setPlaybackRate = useSetAtom(vc_playbackRate) + const setReadyState = useSetAtom(vc_readyState) + const setBuffering = useSetAtom(vc_buffering) + const setIsMuted = useSetAtom(vc_isMuted) + const setVolume = useSetAtom(vc_volume) + const setBuffered = useSetAtom(vc_timeRanges) + const setEnded = useSetAtom(vc_ended) + const setPaused = useSetAtom(vc_paused) + + useEffect(() => { + if (!v) return + const handler = () => { + setVideoSize({ + width: v.videoWidth, + height: v.videoHeight, + }) + setDuration(v.duration) + setCurrentTime(v.currentTime) + setPlaybackRate(v.playbackRate) + setReadyState(v.readyState) + // Set buffering to true if readyState is less than HAVE_ENOUGH_DATA (3) and video is not paused + setBuffering(v.readyState < 3 && !v.paused) + setIsMuted(v.muted) + setVolume(v.volume) + setBuffered(v.buffered.length > 0 ? v.buffered : null) + setEnded(v.ended) + setPaused(v.paused) + } + const events = ["timeupdate", "loadedmetadata", "progress", "play", "pause", "ratechange", "volumechange", "ended", "loadeddata", "resize", + "waiting", "canplay", "stalled"] + events.forEach(e => v.addEventListener(e, handler)) + handler() // initialize state once + + return () => { + console.log("Removing video event listeners") + events.forEach(e => v.removeEventListener(e, handler)) + } + }, [v, playbackInfo]) + +} + +export const vc_createChapterCues = (chapters: Array<MKVParser_ChapterInfo> | undefined, duration: number): VideoCoreChapterCue[] => { + if (!chapters || chapters.length === 0 || duration === 0) { + return [] + } + + return chapters.map((chapter, index) => ({ + startTime: chapter.start / 1e6, + endTime: chapter.end ? chapter.end / 1e6 : (chapters[index + 1]?.start ? chapters[index + 1].start / 1e6 : duration), + text: chapter.text || ``, + })) +} + +export const vc_createChapterVTT = (chapters: Array<MKVParser_ChapterInfo> | undefined, duration: number) => { + if (!chapters || chapters.length === 0 || duration === 0) { + return "" + } + + let vttContent = "WEBVTT\n\n" + + chapters.forEach((chapter, index) => { + const startTime = chapter.start / 1e6 + const endTime = chapter.end ? chapter.end / 1e6 : (chapters[index + 1]?.start ? chapters[index + 1].start / 1e6 : duration) + + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = (seconds % 60).toFixed(3) + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs.padStart(6, "0")}` + } + + vttContent += `${index + 1}\n` + vttContent += `${formatTime(startTime)} --> ${formatTime(endTime)}\n` + vttContent += `${chapter.text || ``}\n\n` + }) + + return vttContent +} + +export function isSubtitleFile(filename: string) { + const subRx = /\.srt$|\.ass$|\.ssa$|\.vtt$|\.txt$|\.ttml$|\.stl$/i + return subRx.test(filename) +} + +export function detectSubtitleType(content: string): "ass" | "vtt" | "ttml" | "stl" | "srt" | "unknown" { + const trimmed = content.trim() + + // ASS/SSA: [Script Info] or [V4+ Styles] or [V4 Styles] + if ( + /^\[Script Info\]/im.test(trimmed) || + /^\[V4\+ Styles\]/im.test(trimmed) || + /^\[V4 Styles\]/im.test(trimmed) + ) { + return "ass" + } + + // VTT: WEBVTT at start, optionally with BOM or comments + if (/^(?:\uFEFF)?WEBVTT\b/im.test(trimmed)) { + return "vtt" + } + + // TTML: XML root with <tt> or <tt:tt> + if ( + /^<\?xml[\s\S]*?<tt[:\s>]/im.test(trimmed) || + /^<tt[:\s>]/im.test(trimmed) + ) { + return "ttml" + } + + // STL: { ... } lines (MicroDVD/other curly-brace formats) + if (/^\{\d+\}/m.test(trimmed)) { + return "stl" + } + + // SRT: 1\n00:00:00,000 --> 00:00:05,000 + if ( + /^\d+\s*\n\s*\d{2}:\d{2}:\d{2},\d{3}\s*-->\s*\d{2}:\d{2}:\d{2},\d{3}/m.test(trimmed) || + /\d{2}:\d{2}:\d{2},\d{3}\s*-->\s*\d{2}:\d{2}:\d{2},\d{3}/.test(trimmed) + ) { + return "srt" + } + + // Fallback: check for VTT/SRT timecodes + if (/\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}/.test(trimmed)) { + return "vtt" + } + if (/\d{2}:\d{2}:\d{2},\d{3}\s*-->\s*\d{2}:\d{2}:\d{2},\d{3}/.test(trimmed)) { + return "srt" + } + + return "unknown" +} + +export function vc_createChaptersFromAniSkip( + aniSkipData: { + op: { interval: { startTime: number; endTime: number } } | null; + ed: { interval: { startTime: number; endTime: number } } | null + } | undefined, + duration: number, + mediaFormat?: string, +): Array<MKVParser_ChapterInfo> { + if (!aniSkipData?.op?.interval || duration <= 0) { + return [] + } + + let chapters: MKVParser_ChapterInfo[] = [] + + if (aniSkipData?.op?.interval) { + chapters.push({ + uid: 91, + start: aniSkipData.op.interval.startTime > 5 ? aniSkipData.op.interval.startTime : 0, + end: aniSkipData.op.interval.endTime, + text: "Opening", + }) + } + + if (aniSkipData?.ed?.interval) { + chapters.push({ + uid: 92, + start: aniSkipData.ed.interval.startTime, + end: aniSkipData.ed.interval.endTime, + text: "Ending", + }) + } + + if (chapters.length === 0) return [] + + // Add beginning chapter + if (aniSkipData.op?.interval?.startTime > 5) { + chapters.push({ + uid: 90, + start: 0, + end: aniSkipData.op.interval.startTime, + // text: aniSkipData.op.interval.startTime > 1.5 * 60 ? "Intro" : "Recap", + text: "Prologue", + }) + } + + // Add middle chapter + chapters.push({ + uid: 93, + start: aniSkipData.op?.interval?.endTime || 0, + end: aniSkipData.ed?.interval?.startTime || duration, + text: mediaFormat !== "MOVIE" ? "Episode" : "Movie", + }) + + // Add ending chapter + if (aniSkipData.ed?.interval?.endTime && aniSkipData.ed.interval.endTime < duration - 5) { + chapters.push({ + uid: 94, + start: aniSkipData.ed.interval.endTime, + end: duration, + text: ((duration) - aniSkipData.ed.interval.endTime) > 0.5 * 60 ? "Ending" : "Preview", + }) + } + + chapters.sort((a, b) => a.start - b.start) + // Make sure last chapter is clamped to the end of the video + if (chapters.length > 0) { + chapters[chapters.length - 1].end = duration + } + + chapters = chapters.map((chapter, index) => ({ + ...chapter, + start: chapter.start * 1e6, + end: chapter.end ? index === chapters.length - 1 ? duration * 1e6 : chapter.end * 1e6 : undefined, + })) + + return chapters +} + +export const vc_formatTime = (seconds: number) => { + const sign = seconds < 0 ? "-" : "" + const absSeconds = Math.abs(seconds) + const hours = Math.floor(absSeconds / 3600) + const minutes = Math.floor((absSeconds % 3600) / 60) + const secs = Math.floor(absSeconds % 60) + + if (hours > 0) { + return `${sign}${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}` + } + return `${sign}${minutes}:${secs.toString().padStart(2, "0")}` +} + +export const vc_logGeneralInfo = (video: HTMLVideoElement | null) => { + if (!video) return + // MP4 container codec tests + console.log("HEVC main ->", video.canPlayType("video/mp4;codecs=\"hev1.1.6.L120.90\"") || "❌") + console.log("HEVC main 10 ->", video.canPlayType("video/mp4;codecs=\"hev1.2.4.L120.90\"") || "❌") + console.log("HEVC main still-picture ->", video.canPlayType("video/mp4;codecs=\"hev1.3.E.L120.90\"") || "❌") + console.log("HEVC range extensions ->", video.canPlayType("video/mp4;codecs=\"hev1.4.10.L120.90\"") || "❌") + + // Audio codec tests + console.log("Dolby AC3 ->", video.canPlayType("audio/mp4; codecs=\"ac-3\"") || "❌") + console.log("Dolby EC3 ->", video.canPlayType("audio/mp4; codecs=\"ec-3\"") || "❌") + + // GPU and hardware acceleration status + const canvas = document.createElement("canvas") + const gl = canvas.getContext("webgl2") || canvas.getContext("webgl") + if (gl) { + const debugInfo = gl.getExtension("WEBGL_debug_renderer_info") + if (debugInfo) { + console.log("GPU Vendor ->", gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL)) + console.log("GPU Renderer ->", gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL)) + } + } + console.log("Hardware concurrency ->", navigator.hardwareConcurrency) + console.log("User agent ->", navigator.userAgent) + + // Web GPU + if (navigator.gpu) { + navigator.gpu.requestAdapter().then(adapter => { + if (adapter) { + console.log("WebGPU adapter ->", adapter) + console.log("WebGPU adapter features ->", adapter.features) + } else { + console.log("⚠️ No WebGPU adapter found.") + } + }) + } else { + console.log("❌ WebGPU not supported.") + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/anilist-collection-loader.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/anilist-collection-loader.ts new file mode 100644 index 0000000..f749cf3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/anilist-collection-loader.ts @@ -0,0 +1,53 @@ +import { Anime_EntryListData, Nullish } from "@/api/generated/types" +import { useGetAnimeCollection } from "@/api/hooks/anilist.hooks" +import { __anilist_userAnimeListDataAtom, __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms" +import { useAtomValue, useSetAtom } from "jotai/react" +import React from "react" + +/** + * @description + * - Fetches the Anilist collection + */ +export function useAnimeCollectionLoader() { + const setAnilistUserMedia = useSetAtom(__anilist_userAnimeMediaAtom) + + const setAnilistUserMediaListData = useSetAtom(__anilist_userAnimeListDataAtom) + + const { data } = useGetAnimeCollection() + + // Store the user's media in `userMediaAtom` + React.useEffect(() => { + if (!!data) { + const allMedia = data.MediaListCollection?.lists?.flatMap(n => n?.entries)?.filter(Boolean)?.map(n => n.media)?.filter(Boolean) ?? [] + setAnilistUserMedia(allMedia) + + const listData = data.MediaListCollection?.lists?.flatMap(n => n?.entries)?.filter(Boolean)?.reduce((acc, n) => { + acc[String(n.media?.id!)] = { + status: n.status, + progress: n.progress || 0, + score: n.score || 0, + startedAt: (n.startedAt?.year && n.startedAt?.month) ? new Date(n.startedAt.year || 0, + (n.startedAt.month || 1) - 1, + n.startedAt.day || 1).toISOString() : undefined, + completedAt: (n.completedAt?.year && n.completedAt?.month) ? new Date(n.completedAt.year || 0, + (n.completedAt.month || 1) - 1, + n.completedAt.day || 1).toISOString() : undefined, + } + return acc + }, {} as Record<string, Anime_EntryListData>) + setAnilistUserMediaListData(listData || {}) + } + }, [data]) + + return null +} + +export function useAnilistUserAnime() { + return useAtomValue(__anilist_userAnimeMediaAtom) +} + +export function useAnilistUserAnimeListData(mId: Nullish<number | string>): Anime_EntryListData | undefined { + const data = useAtomValue(__anilist_userAnimeListDataAtom) + + return data[String(mId)] +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/anime-library-collection-loader.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/anime-library-collection-loader.ts new file mode 100644 index 0000000..7685933 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/anime-library-collection-loader.ts @@ -0,0 +1,27 @@ +import { useGetLibraryCollection } from "@/api/hooks/anime_collection.hooks" +import { animeLibraryCollectionAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms" +import { useAtomValue, useSetAtom } from "jotai/react" +import React from "react" + +/** + * @description + * - Fetches the library collection and sets it in the atom + */ +export function useAnimeLibraryCollectionLoader() { + + const setter = useSetAtom(animeLibraryCollectionAtom) + + const { data, status } = useGetLibraryCollection() + + React.useEffect(() => { + if (status === "success") { + setter(data) + } + }, [data, status]) + + return null +} + +export function useLibraryCollection() { + return useAtomValue(animeLibraryCollectionAtom) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/autodownloader-queue-count.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/autodownloader-queue-count.ts new file mode 100644 index 0000000..a8d42a7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/autodownloader-queue-count.ts @@ -0,0 +1,7 @@ +import { autoDownloaderItemCountAtom } from "@/app/(main)/_atoms/autodownloader.atoms" +import { useAtomValue } from "jotai/react" + +export function useAutoDownloaderQueueCount() { + return useAtomValue(autoDownloaderItemCountAtom) +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/handle-websockets.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/handle-websockets.ts new file mode 100644 index 0000000..3ecd21f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/handle-websockets.ts @@ -0,0 +1,365 @@ +import { WebSocketContext } from "@/app/(main)/_atoms/websocket.atoms" +import { clientIdAtom, websocketConnectedAtom } from "@/app/websocket-provider" +import { logger } from "@/lib/helpers/debug" +import { SeaWebsocketEvent, SeaWebsocketPluginEvent } from "@/lib/server/queries.types" +import { WSEvents } from "@/lib/server/ws-events" +import { useAtom } from "jotai" +import { useContext, useEffect, useRef } from "react" +import useUpdateEffect from "react-use/lib/useUpdateEffect" + +export function useWebsocketSender() { + const socket = useContext(WebSocketContext) + const [clientId] = useAtom(clientIdAtom) + const [isConnected] = useAtom(websocketConnectedAtom) + + // Store message queue in a ref so it persists across rerenders and socket changes + const messageQueue = useRef<SeaWebsocketEvent<any>[]>([]) + const processingQueueRef = useRef<NodeJS.Timeout | null>(null) + + // Plugin event batching + const pluginEventBatchRef = useRef<Array<{ type: string, payload: any, extensionId?: string }>>([]) + const pluginBatchTimerRef = useRef<NodeJS.Timeout | null>(null) + const MAX_PLUGIN_BATCH_SIZE = 20 + const PLUGIN_BATCH_FLUSH_INTERVAL = 10 // ms + + // Keep a local latest reference to socket to ensure we're using the most recent one + const latestSocketRef = useRef<WebSocket | null>(null) + + // Update the socket ref whenever the socket changes + useEffect(() => { + latestSocketRef.current = socket + + // Log socket changes + // logger("WebsocketSender").info(`Socket updated: ${socket ? getReadyStateString(socket.readyState) : "null"}`) + + // When socket becomes available and open, immediately process any queued messages + if (socket && socket.readyState === WebSocket.OPEN && messageQueue.current.length > 0) { + // logger("WebsocketSender").info(`New socket connected with ${messageQueue.current.length} queued messages, processing immediately`) + setTimeout(() => processQueue(), 100) // Small delay to ensure socket is fully established + } + }, [socket]) + + // Clean up event batch timer on unmount + useEffect(() => { + return () => { + if (pluginBatchTimerRef.current) { + clearTimeout(pluginBatchTimerRef.current) + pluginBatchTimerRef.current = null + } + } + }, []) + + function flushPluginEventBatch() { + if (pluginBatchTimerRef.current) { + clearTimeout(pluginBatchTimerRef.current) + pluginBatchTimerRef.current = null + } + + if (pluginEventBatchRef.current.length === 0) return + + // Create a copy of the current batch + const events = [...pluginEventBatchRef.current] + pluginEventBatchRef.current = [] + + // Deduplicate events by type, extension ID, and payload + const deduplicatedEvents = events.filter((event, index, self) => { + return index === self.findIndex((t) => (t.type === event.type && t.extensionId === event.extensionId && JSON.stringify(t.payload) === JSON.stringify( + event.payload)), + // ||(event.type === PluginClientEvents.DOMElementUpdated && t.type === event.type && t.extensionId === event.extensionId) + ) + }) + // const deduplicatedEvents = events + + // if (events.length !== deduplicatedEvents.length) { + // logger("WebsocketSender").info(`Deduplicated ${events.length - deduplicatedEvents.length} events from batch of ${events.length}`) + // } + + // If only one event, send it directly without batching + if (deduplicatedEvents.length === 1) { + const event = deduplicatedEvents[0] + sendMessage({ + type: "plugin", + payload: { + type: event.type, + extensionId: event.extensionId, + payload: event.payload, + }, + }) + return + } + + // Send the batch + sendMessage({ + type: "plugin", + payload: { + type: "client:batch-events", + extensionId: "", // Do not use extension ID for batch events + payload: { + events: deduplicatedEvents, + }, + }, + }) + } + + function getReadyStateString(state?: number): string { + if (state === undefined) return "UNDEFINED" + switch (state) { + case WebSocket.CONNECTING: + return "CONNECTING" + case WebSocket.OPEN: + return "OPEN" + case WebSocket.CLOSING: + return "CLOSING" + case WebSocket.CLOSED: + return "CLOSED" + default: + return `UNKNOWN (${state})` + } + } + + function sendMessage<TData>(data: SeaWebsocketEvent<TData>) { + // Always use the latest socket reference + const currentSocket = latestSocketRef.current + + if (currentSocket && currentSocket.readyState === WebSocket.OPEN) { + try { + const message = JSON.stringify({ ...data, clientId: clientId }) + currentSocket.send(message) + // logger("WebsocketSender").info(`Sent message of type ${data.type}`); + return true + } + catch (e) { + // logger("WebsocketSender").error(`Failed to send message of type ${data.type}`, e) + messageQueue.current.push(data) + return false + } + } else { + if (messageQueue.current.length < 500) { // Limit queue size to prevent memory issues + messageQueue.current.push(data) + logger("WebsocketSender") + .info(`Queued message of type ${data.type}, queue size: ${messageQueue.current.length}, socket state: ${currentSocket + ? getReadyStateString(currentSocket.readyState) + : "null"}`) + } else { + logger("WebsocketSender").warning(`Message queue full (500), dropping message of type ${data.type}`) + } + + // Always ensure queue processor is running + ensureQueueProcessorIsRunning() + return false + } + } + + // Add a plugin event to the batch + function addPluginEventToBatch(type: string, payload: any, extensionId?: string) { + pluginEventBatchRef.current.push({ + type, + payload, + extensionId, + }) + + // If this is the first event, start the timer + if (pluginEventBatchRef.current.length === 1) { + pluginBatchTimerRef.current = setTimeout(() => { + flushPluginEventBatch() + }, PLUGIN_BATCH_FLUSH_INTERVAL) + } + + // If we've reached the max batch size, flush immediately + if (pluginEventBatchRef.current.length >= MAX_PLUGIN_BATCH_SIZE) { + flushPluginEventBatch() + } + } + + function ensureQueueProcessorIsRunning() { + if (!processingQueueRef.current) { + processQueue() + } + } + + function processQueue() { + // Clear any existing processor + if (processingQueueRef.current) { + clearTimeout(processingQueueRef.current) + processingQueueRef.current = null + } + + // Always use the latest socket reference + const currentSocket = latestSocketRef.current + + // Process the queue if socket is connected + if (currentSocket && currentSocket.readyState === WebSocket.OPEN && messageQueue.current.length > 0) { + // logger("WebsocketSender").info(`Processing ${messageQueue.current.length} queued messages`) + + // Create a copy of the queue to avoid modification issues during iteration + const queueCopy = [...messageQueue.current] + const successfulMessages: number[] = [] + + // Try to send all queued messages + queueCopy.forEach((message, index) => { + try { + const messageStr = JSON.stringify({ ...message, clientId: clientId }) + currentSocket.send(messageStr) + successfulMessages.push(index) + // logger("WebsocketSender").info(`Successfully sent queued message of type ${message.type}`); + } + catch (e) { + // logger("WebsocketSender").error(`Failed to send queued message of type ${message.type}`, e); + } + }) + + // Remove successfully sent messages + if (successfulMessages.length > 0) { + // Create a new array without the successfully sent messages + messageQueue.current = queueCopy.filter((_, index) => !successfulMessages.includes(index)) + // logger("WebsocketSender").info(`Sent ${successfulMessages.length}/${queueCopy.length} queued messages, ${messageQueue.current.length} remaining`) + } + } else { + // const reason = !currentSocket ? "no socket" : + // currentSocket.readyState !== WebSocket.OPEN ? `socket not open (${getReadyStateString(currentSocket.readyState)})` : + // "no messages in queue"; + // logger("WebsocketSender").info(`Skipped queue processing: ${reason}`); + } + + // Always schedule next run if there are messages or socket isn't ready + const shouldReschedule = messageQueue.current.length > 0 || !currentSocket || currentSocket.readyState !== WebSocket.OPEN + + if (shouldReschedule) { + processingQueueRef.current = setTimeout(() => { + processQueue() + }, 1000) // Process every second for faster recovery + } else { + processingQueueRef.current = null + } + } + + // Process queue whenever connection status changes + useEffect(() => { + if (isConnected && latestSocketRef.current?.readyState === WebSocket.OPEN) { + // logger("WebsocketSender").info(`Connection reestablished, processing message queue (${messageQueue.current.length} messages)`) + // Force immediate processing with a small delay to ensure everything is ready + setTimeout(() => processQueue(), 100) + } + + return () => { + if (processingQueueRef.current) { + clearTimeout(processingQueueRef.current) + processingQueueRef.current = null + } + + // Flush any batched events before unmounting + if (pluginEventBatchRef.current.length > 0) { + flushPluginEventBatch() + } + } + }, [isConnected]); + + return { + sendMessage, + sendPluginMessage: (type: string, payload: any, extensionId?: string) => { + // Use batching for plugin messages + addPluginEventToBatch(type, payload, extensionId) + return true + }, + } +} + +export function useWebsocketSendEffect<TData>(data: SeaWebsocketEvent<TData>, ...deps: any[]) { + const { sendMessage } = useWebsocketSender() + + useUpdateEffect(() => { + sendMessage(data) + }, [...deps]) +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export type WebSocketMessageListener<TData> = { + type: WSEvents | string + onMessage: (data: TData) => void +} + +export function useWebsocketMessageListener<TData = unknown>({ type, onMessage }: WebSocketMessageListener<TData>) { + const socket = useContext(WebSocketContext) + + useEffect(() => { + if (socket) { + const messageHandler = (event: MessageEvent) => { + try { + const parsed = JSON.parse(event.data) as SeaWebsocketEvent<TData> + if (!!parsed.type && parsed.type === type) { + onMessage(parsed.payload) + } + } + catch (e) { + logger("Websocket").error("Error parsing message", e) + } + } + + socket.addEventListener("message", messageHandler) + + return () => { + socket.removeEventListener("message", messageHandler) + } + } + }, [socket, onMessage]) + + return null +} + +export type WebSocketPluginMessageListener<TData> = { + type: string + extensionId: string // If empty, get message from all plugins + onMessage: (data: TData, extensionId: string) => void +} + +export function useWebsocketPluginMessageListener<TData = unknown>({ type, extensionId, onMessage }: WebSocketPluginMessageListener<TData>) { + const socket = useContext(WebSocketContext) + + useEffect(() => { + if (socket) { + const messageHandler = (event: MessageEvent) => { + try { + const parsed = JSON.parse(event.data) as SeaWebsocketEvent<TData> + if (!!parsed.type && parsed.type === "plugin") { + const message = parsed.payload as SeaWebsocketPluginEvent<TData> + + + // Handle batch events + if (message.type === "plugin:batch-events" && message.payload && (message.payload as any).events) { + // Extract and process each event in the batch + const batchPayload = message.payload as any + const events = batchPayload.events || [] + + // Process each event in the batch + for (const event of events) { + if (event.type === type && + (!extensionId || extensionId === event.extensionId || extensionId === "")) { + onMessage(event.payload as TData, event.extensionId) + } + } + return + } + + // Handle regular events + if (message.type === type && + (!extensionId || extensionId === message.extensionId || extensionId === "")) { + onMessage(message.payload as TData, message.extensionId) + } + } + } + catch (e) { + logger("Websocket").error("Error parsing plugin message", e) + } + } + + socket.addEventListener("message", messageHandler) + + return () => { + socket.removeEventListener("message", messageHandler) + } + } + }, [socket, onMessage, type, extensionId]) + + return null +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/logs.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/logs.ts new file mode 100644 index 0000000..4e1318f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/logs.ts @@ -0,0 +1,14 @@ +import { useGetLatestLogContent } from "@/api/hooks/status.hooks" + +export function useHandleCopyLatestLogs() { + + const { mutate: fetchLogs, data, isPending } = useGetLatestLogContent() + + function handleCopyLatestLogs() { + fetchLogs() + } + + return { + handleCopyLatestLogs, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/missing-episodes-loader.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/missing-episodes-loader.ts new file mode 100644 index 0000000..e30e890 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/missing-episodes-loader.ts @@ -0,0 +1,32 @@ +import { useGetMissingEpisodes } from "@/api/hooks/anime_entries.hooks" +import { missingEpisodeCountAtom, missingEpisodesAtom, missingSilencedEpisodesAtom } from "@/app/(main)/_atoms/missing-episodes.atoms" +import { useAtomValue, useSetAtom } from "jotai/react" +import { usePathname } from "next/navigation" +import { useEffect } from "react" + +export function useMissingEpisodeCount() { + return useAtomValue(missingEpisodeCountAtom) +} + +export function useMissingEpisodes() { + return useAtomValue(missingEpisodesAtom) +} + +/** + * @description + * - When the user is not on the main page, send a request to get missing episodes + */ +export function useMissingEpisodesLoader() { + const pathname = usePathname() + const setter = useSetAtom(missingEpisodesAtom) + const silencedSetter = useSetAtom(missingSilencedEpisodesAtom) + + const { data } = useGetMissingEpisodes(pathname !== "/schedule") + + useEffect(() => { + setter(data?.episodes ?? []) + silencedSetter(data?.silencedEpisodes ?? []) + }, [data]) + + return null +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/use-server-status.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/use-server-status.ts new file mode 100644 index 0000000..f4a22c9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_hooks/use-server-status.ts @@ -0,0 +1,118 @@ +import { serverAuthTokenAtom, serverStatusAtom } from "@/app/(main)/_atoms/server-status.atoms" +import { createNakamaHMACAuth, createServerPasswordHMACAuth } from "@/lib/server/hmac-auth" +import { TORRENT_PROVIDER } from "@/lib/server/settings" +import { useAtomValue } from "jotai" +import { useAtom } from "jotai/index" +import { useSetAtom } from "jotai/react" +import React from "react" + +export function useServerStatus() { + return useAtomValue(serverStatusAtom) +} + +export function useSetServerStatus() { + return useSetAtom(serverStatusAtom) +} + +export function useCurrentUser() { + const serverStatus = useServerStatus() + return React.useMemo(() => serverStatus?.user, [serverStatus?.user]) +} + +export function useHasTorrentProvider() { + const serverStatus = useServerStatus() + return { + hasTorrentProvider: React.useMemo(() => !!serverStatus?.settings?.library?.torrentProvider && serverStatus?.settings?.library?.torrentProvider !== TORRENT_PROVIDER.NONE, + [serverStatus?.settings?.library?.torrentProvider]), + } +} + +export function useHasDebridService() { + const serverStatus = useServerStatus() + return { + hasDebridService: React.useMemo(() => !!serverStatus?.debridSettings?.enabled && !!serverStatus?.debridSettings?.provider, + [serverStatus?.debridSettings]), + } +} + +export function useServerPassword() { + const serverStatus = useServerStatus() + const [password] = useAtom(serverAuthTokenAtom) + return { + getServerPasswordQueryParam: (symbol?: string) => { + if (!serverStatus?.serverHasPassword) return "" + return `${symbol ? `${symbol}` : "?"}password=${password ?? ""}` + }, + } +} + +export function useServerHMACAuth() { + const serverStatus = useServerStatus() + const [password] = useAtom(serverAuthTokenAtom) + + return { + getHMACTokenQueryParam: async (endpoint: string, symbol?: string): Promise<string> => { + if (!serverStatus?.serverHasPassword || !password) return "" + + try { + const hmacAuth = createServerPasswordHMACAuth(password) + return await hmacAuth.generateQueryParam(endpoint, symbol) + } + catch (error) { + console.error("Failed to generate HMAC token:", error) + return "" + } + }, + generateHMACToken: async (endpoint: string) => { + if (!serverStatus?.serverHasPassword || !password) return "" + + try { + const hmacAuth = createServerPasswordHMACAuth(password) + return await hmacAuth.generateToken(endpoint) + } + catch (error) { + console.error("Failed to generate HMAC token:", error) + return "" + } + }, + } +} + +export function useNakamaHMACAuth() { + const serverStatus = useServerStatus() + + const nakamaPassword = serverStatus?.settings?.nakama?.isHost + ? serverStatus?.settings?.nakama?.hostPassword + : serverStatus?.settings?.nakama?.remoteServerPassword + + return { + getHMACTokenQueryParam: async (endpoint: string, symbol?: string) => { + if (!serverStatus?.settings?.nakama?.enabled) return "" + + if (!nakamaPassword) return "" + + try { + const hmacAuth = createNakamaHMACAuth(nakamaPassword) + return await hmacAuth.generateQueryParam(endpoint, symbol) + } + catch (error) { + console.error("Failed to generate Nakama HMAC token:", error) + return "" + } + }, + generateHMACToken: async (endpoint: string) => { + if (!serverStatus?.settings?.nakama?.enabled) return "" + + if (!nakamaPassword) return "" + + try { + const hmacAuth = createNakamaHMACAuth(nakamaPassword) + return await hmacAuth.generateToken(endpoint) + } + catch (error) { + console.error("Failed to generate Nakama HMAC token:", error) + return "" + } + }, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/anilist-collection.listeners.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/anilist-collection.listeners.ts new file mode 100644 index 0000000..51be209 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/anilist-collection.listeners.ts @@ -0,0 +1,39 @@ +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { WSEvents } from "@/lib/server/ws-events" +import { useQueryClient } from "@tanstack/react-query" + +/** + * @description + * - Listens to REFRESHED_ANILIST_COLLECTION events and re-fetches queries associated with AniList collection. + */ +export function useAnimeCollectionListener() { + + const qc = useQueryClient() + + useWebsocketMessageListener({ + type: WSEvents.REFRESHED_ANILIST_ANIME_COLLECTION, + onMessage: data => { + (async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.key] }) + })() + }, + }) + + useWebsocketMessageListener({ + type: WSEvents.REFRESHED_ANILIST_MANGA_COLLECTION, + onMessage: data => { + (async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] }) + })() + }, + }) + +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/autodownloader.listeners.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/autodownloader.listeners.ts new file mode 100644 index 0000000..26f7b3a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/autodownloader.listeners.ts @@ -0,0 +1,34 @@ +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { useGetAutoDownloaderItems } from "@/api/hooks/auto_downloader.hooks" +import { autoDownloaderItemsAtom } from "@/app/(main)/_atoms/autodownloader.atoms" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { WSEvents } from "@/lib/server/ws-events" +import { useQueryClient } from "@tanstack/react-query" +import { useSetAtom } from "jotai/react" +import { usePathname } from "next/navigation" +import { useEffect } from "react" + +/** + * @description + * - When the user is not on the main page, send a request to get auto downloader queue items + */ +export function useAutoDownloaderItemListener() { + const pathname = usePathname() + const setter = useSetAtom(autoDownloaderItemsAtom) + const qc = useQueryClient() + + const { data } = useGetAutoDownloaderItems(pathname !== "/auto-downloader") + + useWebsocketMessageListener<string>({ + type: WSEvents.AUTO_DOWNLOADER_ITEM_ADDED, + onMessage: data => { + qc.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] }) + }, + }) + + useEffect(() => { + setter(data ?? []) + }, [data]) + + return null +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/extensions.listeners.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/extensions.listeners.ts new file mode 100644 index 0000000..552cc32 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/extensions.listeners.ts @@ -0,0 +1,48 @@ +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { ExtensionRepo_UpdateData } from "@/api/generated/types" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { WSEvents } from "@/lib/server/ws-events" +import { useQueryClient } from "@tanstack/react-query" + +/** + * @description + * - Re-fetches queries associated with extension data + */ +export function useExtensionListener() { + + const qc = useQueryClient() + + useWebsocketMessageListener<number>({ + type: WSEvents.EXTENSIONS_RELOADED, + onMessage: () => { + (async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.ListExtensionData.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetAllExtensions.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.key] }) + })() + }, + }) + + useWebsocketMessageListener<number>({ + type: WSEvents.PLUGIN_UNLOADED, + onMessage: () => { + (async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.key] }) + })() + }, + }) + + useWebsocketMessageListener<ExtensionRepo_UpdateData[]>({ + type: WSEvents.EXTENSION_UPDATES_FOUND, + onMessage: async (data) => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetAllExtensions.key] }) + }, + }) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/external-player-link.listeners.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/external-player-link.listeners.ts new file mode 100644 index 0000000..033329d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/external-player-link.listeners.ts @@ -0,0 +1,67 @@ +import { usePlaybackStartManualTracking } from "@/api/hooks/playback_manager.hooks" +import { useExternalPlayerLink } from "@/app/(main)/_atoms/playback.atoms" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { clientIdAtom } from "@/app/websocket-provider" +import { ExternalPlayerLink } from "@/lib/external-player-link/external-player-link" +import { openTab } from "@/lib/helpers/browser" +import { logger } from "@/lib/helpers/debug" +import { WSEvents } from "@/lib/server/ws-events" +import { useAtomValue } from "jotai" +import { toast } from "sonner" + +type ExternalPlayerLinkEventProps = { + url: string + mediaId: number + episodeNumber: number + mediaTitle?: string +} + +export function useExternalPlayerLinkListener() { + + const clientId = useAtomValue(clientIdAtom) + const { externalPlayerLink } = useExternalPlayerLink() + + const { mutate: startManualTracking } = usePlaybackStartManualTracking() + + useWebsocketMessageListener<ExternalPlayerLinkEventProps>({ + type: WSEvents.EXTERNAL_PLAYER_OPEN_URL, + onMessage: data => { + if (!externalPlayerLink?.length) { + toast.error("External player link is not set.") + return + } + + toast.info("Opening media file in external player.") + + logger("EXTERNAL PLAYER LINK").info("Opening external player", data) + + const link = new ExternalPlayerLink(externalPlayerLink) + link.setEpisodeNumber(data.episodeNumber) + link.setMediaTitle(data.mediaTitle) + link.setUrl(data.url) + openTab(link.getFullUrl()) + + if (data.mediaId != 0) { + logger("EXTERNAL PLAYER LINK").info("Starting manual tracking", { + mediaId: data.mediaId, + episodeNumber: data.episodeNumber, + clientId: clientId || "", + }) + + // Get the server to start asking the progress + startManualTracking({ + mediaId: data.mediaId, + episodeNumber: data.episodeNumber, + clientId: clientId || "", + }) + } else { + logger("EXTERNAL PLAYER LINK").info("No manual tracking", { + url: data.url, + mediaId: data.mediaId, + episodeNumber: data.episodeNumber, + }) + } + }, + }) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/invalidate-queries.listeners.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/invalidate-queries.listeners.ts new file mode 100644 index 0000000..0d8c0c3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/invalidate-queries.listeners.ts @@ -0,0 +1,18 @@ +import { WSEvents } from "@/lib/server/ws-events" +import { useQueryClient } from "@tanstack/react-query" +import { useWebsocketMessageListener } from "../_hooks/handle-websockets" + +export function useInvalidateQueriesListener() { + + const queryClient = useQueryClient() + + useWebsocketMessageListener<string[]>({ + type: WSEvents.INVALIDATE_QUERIES, + onMessage: async (data) => { + await Promise.all(data.map(async (queryKey) => { + await queryClient.invalidateQueries({ queryKey: [queryKey] }) + })) + }, + }) + +} \ No newline at end of file diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/manga.listeners.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/manga.listeners.ts new file mode 100644 index 0000000..e4e0cea --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/manga.listeners.ts @@ -0,0 +1,36 @@ +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { WSEvents } from "@/lib/server/ws-events" +import { useQueryClient } from "@tanstack/react-query" + +/** + * @description + * - Listens to DOWNLOADED_CHAPTER events and re-fetches queries associated with media ID + */ +export function useMangaListener() { + + const qc = useQueryClient() + + useWebsocketMessageListener<number>({ + type: WSEvents.REFRESHED_MANGA_DOWNLOAD_DATA, + onMessage: mediaId => { + (async () => { + // \/ Causes infinite loop, oops + // await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.key] }) + })() + }, + }) + + useWebsocketMessageListener({ + type: WSEvents.CHAPTER_DOWNLOAD_QUEUE_UPDATED, + onMessage: data => { + (async () => { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] }) + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.key] }) + })() + }, + }) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/misc-events.listeners.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/misc-events.listeners.ts new file mode 100644 index 0000000..64f21fe --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/misc-events.listeners.ts @@ -0,0 +1,51 @@ +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { WSEvents } from "@/lib/server/ws-events" +import { toast } from "sonner" + +export function useMiscEventListeners() { + + useWebsocketMessageListener<string>({ + type: WSEvents.INFO_TOAST, onMessage: data => { + if (!!data) { + toast.info(data) + } + }, + }) + + useWebsocketMessageListener<string>({ + type: WSEvents.SUCCESS_TOAST, onMessage: data => { + if (!!data) { + toast.success(data) + } + }, + }) + + useWebsocketMessageListener<string>({ + type: WSEvents.WARNING_TOAST, onMessage: data => { + if (!!data) { + toast.warning(data) + } + }, + }) + + useWebsocketMessageListener<string>({ + type: WSEvents.ERROR_TOAST, onMessage: data => { + if (!!data) { + toast.error(data) + } + }, + }) + + useWebsocketMessageListener<string>({ + type: WSEvents.CONSOLE_LOG, onMessage: data => { + console.log(data) + }, + }) + + useWebsocketMessageListener<string>({ + type: WSEvents.CONSOLE_WARN, onMessage: data => { + console.warn(data) + }, + }) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/sync.listeners.ts b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/sync.listeners.ts new file mode 100644 index 0000000..1ae0883 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_listeners/sync.listeners.ts @@ -0,0 +1,34 @@ +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { Local_QueueState } from "@/api/generated/types" +import { useSyncIsActive } from "@/app/(main)/_atoms/sync.atoms" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { logger } from "@/lib/helpers/debug" +import { WSEvents } from "@/lib/server/ws-events" +import { useQueryClient } from "@tanstack/react-query" +import React from "react" + +export function useSyncListener() { + const qc = useQueryClient() + + useWebsocketMessageListener({ + type: WSEvents.SYNC_LOCAL_FINISHED, + onMessage: _ => { + qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key] }) + }, + }) + + const [queueState, setQueueState] = React.useState<Local_QueueState | null>(null) + useWebsocketMessageListener<Local_QueueState>({ + type: WSEvents.SYNC_LOCAL_QUEUE_STATE, + onMessage: data => { + logger("SYNC").info("Queue state", queueState) + setQueueState(data) + }, + }) + + const { setSyncIsActive } = useSyncIsActive() + + React.useEffect(() => { + setSyncIsActive(!!queueState && (Object.keys(queueState.animeTasks!).length > 0 || Object.keys(queueState.mangaTasks!).length > 0)) + }, [queueState]) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-crash-screen-error.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-crash-screen-error.tsx new file mode 100644 index 0000000..8e5e8e3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-crash-screen-error.tsx @@ -0,0 +1,25 @@ +import { emit, listen } from "@tauri-apps/api/event" +import React from "react" + +export function TauriCrashScreenError() { + + const [msg, setMsg] = React.useState("") + + React.useEffect(() => { + emit("crash-screen-loaded").then(() => {}) + + const u = listen<string>("crash", (event) => { + console.log("Received crash event", event.payload) + setMsg(event.payload) + }) + return () => { + u.then((f) => f()) + } + }, []) + + return ( + <p> + {msg || "An error occurred"} + </p> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-manager.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-manager.tsx new file mode 100644 index 0000000..f1d1c21 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-manager.tsx @@ -0,0 +1,70 @@ +"use client" + +import { listen } from "@tauri-apps/api/event" +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow" +import { Window } from "@tauri-apps/api/window" +import mousetrap from "mousetrap" +import React from "react" + +type TauriManagerProps = { + children?: React.ReactNode +} + +// This is only rendered on the Desktop client +export function TauriManager(props: TauriManagerProps) { + + const { + children, + ...rest + } = props + + React.useEffect(() => { + const u = listen("message", (event) => { + const message = event.payload + console.log("Received message from Rust:", message) + }) + + mousetrap.bind("f11", () => { + toggleFullscreen() + }) + + mousetrap.bind("esc", () => { + const appWindow = new Window("main") + appWindow.isFullscreen().then((isFullscreen) => { + if (isFullscreen) { + toggleFullscreen() + } + }) + }) + + document.addEventListener("fullscreenchange", toggleFullscreen) + + return () => { + u.then((f) => f()) + mousetrap.unbind("f11") + document.removeEventListener("fullscreenchange", toggleFullscreen) + } + }, []) + + function toggleFullscreen() { + const appWindow = new Window("main") + + // Only toggle fullscreen on the main window + if (getCurrentWebviewWindow().label !== "main") return + + appWindow.isFullscreen().then((fullscreen) => { + // DEVNOTE: When decorations are not shown in fullscreen move there will be a gap at the bottom of the window (Windows) + // Hide the decorations when exiting fullscreen + // Show the decorations when entering fullscreen + appWindow.setDecorations(!fullscreen) + + appWindow.setFullscreen(!fullscreen) + }) + } + + return ( + <> + + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-padding.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-padding.tsx new file mode 100644 index 0000000..2d06cf0 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-padding.tsx @@ -0,0 +1,20 @@ +import { platform } from "@tauri-apps/plugin-os" +import React from "react" + + +export function TauriSidebarPaddingMacOS() { + + const [currentPlatform, setCurrentPlatform] = React.useState("") + + React.useEffect(() => { + setCurrentPlatform(platform()) + }, []) + + if (currentPlatform !== "macos") return null + + return ( + <div className="h-4"> + + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-restart-server-prompt.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-restart-server-prompt.tsx new file mode 100644 index 0000000..53540e4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-restart-server-prompt.tsx @@ -0,0 +1,103 @@ +import { isUpdateInstalledAtom, isUpdatingAtom } from "@/app/(main)/_tauri/tauri-update-modal" +import { websocketConnectedAtom, websocketConnectionErrorCountAtom } from "@/app/websocket-provider" +import { LuffyError } from "@/components/shared/luffy-error" +import { Button } from "@/components/ui/button" +import { LoadingOverlay } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { emit } from "@tauri-apps/api/event" +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow" +import { useAtom, useAtomValue } from "jotai/react" +import React from "react" +import { toast } from "sonner" + +export function TauriRestartServerPrompt() { + + const [hasRendered, setHasRendered] = React.useState(false) + + const [isConnected, setIsConnected] = useAtom(websocketConnectedAtom) + const connectionErrorCount = useAtomValue(websocketConnectionErrorCountAtom) + const [hasClickedRestarted, setHasClickedRestarted] = React.useState(false) + const isUpdatedInstalled = useAtomValue(isUpdateInstalledAtom) + const isUpdating = useAtomValue(isUpdatingAtom) + + React.useEffect(() => { + if (getCurrentWebviewWindow().label === "main") { + setHasRendered(true) + } + }, []) + + const handleRestart = async () => { + setHasClickedRestarted(true) + toast.info("Restarting server...") + emit("restart-server").catch((error) => { + console.log("Failed to emit restart-server event:", error) + }).then(() => { + console.log("Successfully emitted restart-server event") + }) + React.startTransition(() => { + setTimeout(() => { + setHasClickedRestarted(false) + }, 5000) + }) + } + + // Try to reconnect automatically + const tryAutoReconnectRef = React.useRef(true) + React.useEffect(() => { + if (!isConnected && connectionErrorCount >= 10 && tryAutoReconnectRef.current && !isUpdatedInstalled) { + tryAutoReconnectRef.current = false + console.log("Connection error count reached 10, restarting server automatically") + handleRestart() + } + }, [connectionErrorCount]) + + React.useEffect(() => { + if (isConnected) { + setHasClickedRestarted(false) + tryAutoReconnectRef.current = true + } + }, [isConnected]) + + if (!hasRendered) return null + + // Not connected for 10 seconds + return ( + <> + {(!isConnected && connectionErrorCount < 10 && !isUpdating && !isUpdatedInstalled) && ( + <LoadingOverlay className="fixed left-0 top-0 z-[9999]"> + <p> + The server connection has been lost. Please wait while we attempt to reconnect. + </p> + </LoadingOverlay> + )} + + <Modal + open={!isConnected && connectionErrorCount >= 10 && !isUpdatedInstalled} + onOpenChange={() => {}} + hideCloseButton + contentClass="max-w-2xl" + > + <LuffyError> + <div className="space-y-4 flex flex-col items-center"> + <p className="text-lg max-w-sm"> + The background server process has stopped responding. Please restart it to continue. + </p> + + <Button + onClick={handleRestart} + loading={hasClickedRestarted} + intent="white-outline" + size="lg" + className="rounded-full" + > + Restart server + </Button> + <p className="text-[--muted] text-sm max-w-xl"> + If this message persists after multiple tries, please relaunch the application. + </p> + </div> + </LuffyError> + </Modal> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-update-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-update-modal.tsx new file mode 100644 index 0000000..78c31d1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-update-modal.tsx @@ -0,0 +1,198 @@ +"use client" +import { useGetLatestUpdate } from "@/api/hooks/releases.hooks" +import { UpdateChangelogBody } from "@/app/(main)/_features/update/update-helper" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { SeaLink } from "@/components/shared/sea-link" +import { Alert } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { VerticalMenu } from "@/components/ui/vertical-menu" +import { logger } from "@/lib/helpers/debug" +import { WSEvents } from "@/lib/server/ws-events" +import { emit } from "@tauri-apps/api/event" +import { platform } from "@tauri-apps/plugin-os" +import { relaunch } from "@tauri-apps/plugin-process" +import { check, Update } from "@tauri-apps/plugin-updater" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" +import { AiFillExclamationCircle } from "react-icons/ai" +import { BiLinkExternal } from "react-icons/bi" +import { FiArrowRight } from "react-icons/fi" +import { GrInstall } from "react-icons/gr" +import { toast } from "sonner" + + +type UpdateModalProps = { + collapsed?: boolean +} + +const updateModalOpenAtom = atom<boolean>(false) +export const isUpdateInstalledAtom = atom<boolean>(false) +export const isUpdatingAtom = atom<boolean>(false) + +export function TauriUpdateModal(props: UpdateModalProps) { + const serverStatus = useServerStatus() + const [updateModalOpen, setUpdateModalOpen] = useAtom(updateModalOpenAtom) + + const [isUpdating, setIsUpdating] = useAtom(isUpdatingAtom) + + const { data: updateData, isLoading, refetch } = useGetLatestUpdate(!!serverStatus && !serverStatus?.settings?.library?.disableUpdateCheck) + + useWebsocketMessageListener({ + type: WSEvents.CHECK_FOR_UPDATES, + onMessage: () => { + refetch().then(() => checkTauriUpdate()) + }, + }) + + const [updateLoading, setUpdateLoading] = React.useState(true) + const [tauriUpdate, setUpdate] = React.useState<Update | null>(null) + const [tauriError, setTauriError] = React.useState("") + const [isInstalled, setIsInstalled] = useAtom(isUpdateInstalledAtom) + + const checkTauriUpdate = React.useCallback(() => { + try { + (async () => { + try { + const update = await check() + setUpdate(update) + setUpdateLoading(false) + } + catch (error) { + logger("TAURI").error("Failed to check for updates", error) + setTauriError(JSON.stringify(error)) + setUpdateLoading(false) + } + })() + } + catch (e) { + logger("TAURI").error("Failed to check for updates", e) + setIsUpdating(false) + } + }, []) + + React.useEffect(() => { + checkTauriUpdate() + }, []) + + const [currentPlatform, setCurrentPlatform] = React.useState("") + + React.useEffect(() => { + (async () => { + setCurrentPlatform(platform()) + })() + }, []) + + + async function handleInstallUpdate() { + if (!tauriUpdate || isUpdating) return + + try { + setIsUpdating(true) + + // Wait for the update be downloaded + toast.info("Downloading update...") + await tauriUpdate.download() + // Kill the currently running server + toast.info("Shutting down server...") + await emit("kill-server") + // Wait 1 second before installing the update + toast.info("Installing update...") + setTimeout(async () => { + await tauriUpdate.install() + setIsInstalled(true) + // Relaunch the app once the update is installed + // on macOS, the app will be closed and the user will have to reopen it + if (currentPlatform === "macos") { + toast.info("Update installed. Please reopen the app.") + } else { + toast.info("Relaunching app...") + } + + await relaunch() + }, 1000) + } + catch (e) { + logger("TAURI").error("Failed to download update", e) + toast.error(`Failed to download update: ${JSON.stringify(e)}`) + setIsUpdating(false) + } + } + + React.useEffect(() => { + if (updateData && updateData.release) { + setUpdateModalOpen(true) + } + }, [updateData]) + + if (serverStatus?.settings?.library?.disableUpdateCheck) return null + + if (isLoading || updateLoading || !updateData || !updateData.release) return null + + if (isInstalled) return ( + <div className="fixed top-0 left-0 w-full h-full bg-[--background] flex items-center z-[9999]"> + <div className="container max-w-4xl py-10"> + <div className="mb-4 flex justify-center w-full"> + <img src="/logo_2.png" alt="logo" className="w-36 h-auto" /> + </div> + <p className="text-center text-lg"> + Update installed. Please reopen the app if it doesn't restart automatically. + </p> + </div> + </div> + ) + + return ( + <> + <VerticalMenu + collapsed={props.collapsed} + items={[ + { + iconType: AiFillExclamationCircle, + name: "Update available", + onClick: () => setUpdateModalOpen(true), + }, + ]} + itemIconClass="text-brand-300" + /> + <Modal + open={updateModalOpen} + onOpenChange={v => !isUpdating && setUpdateModalOpen(v)} + contentClass="max-w-3xl" + > + <div className="space-y-2"> + <h3 className="text-center">A new update is available!</h3> + <h4 className="font-bold flex gap-2 text-center items-center justify-center"> + <span className="text-[--muted]">{updateData.current_version}</span> <FiArrowRight /> + <span className="text-indigo-200">{updateData.release.version}</span></h4> + + {!tauriUpdate && ( + <Alert intent="warning"> + This update is not yet available for desktop clients. + Wait a few minutes or check the GitHub page for more information. + </Alert> + )} + + <UpdateChangelogBody updateData={updateData} /> + + <div className="flex gap-2 w-full !mt-4"> + {!!tauriUpdate && <Button + leftIcon={<GrInstall className="text-2xl" />} + onClick={handleInstallUpdate} + loading={isUpdating} + disabled={isLoading} + > + Update now + </Button>} + <div className="flex flex-1" /> + <SeaLink href={updateData?.release?.html_url || ""} target="_blank"> + <Button intent="white-subtle" rightIcon={<BiLinkExternal />}>See on GitHub</Button> + </SeaLink> + </div> + </div> + </Modal> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-window-title-bar.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-window-title-bar.tsx new file mode 100644 index 0000000..221a651 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/_tauri/tauri-window-title-bar.tsx @@ -0,0 +1,194 @@ +"use client" +import { IconButton } from "@/components/ui/button" +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow" +import { platform } from "@tauri-apps/plugin-os" +import React from "react" +import { VscChromeClose, VscChromeMaximize, VscChromeMinimize, VscChromeRestore } from "react-icons/vsc" + +type TauriWindowTitleBarProps = { + children?: React.ReactNode +} + +export function TauriWindowTitleBar(props: TauriWindowTitleBarProps) { + + const { + children, + ...rest + } = props + + const [showTrafficLights, setShowTrafficLights] = React.useState(false) + const [displayDragRegion, setDisplayDragRegion] = React.useState(true) + const dragRegionRef = React.useRef<HTMLDivElement>(null) + + + function handleMinimize() { + getCurrentWebviewWindow().minimize().then() + } + + const [maximized, setMaximized] = React.useState(true) + + function toggleMaximized() { + getCurrentWebviewWindow().toggleMaximize().then() + } + + function handleClose() { + getCurrentWebviewWindow().close().then() + } + + // Check if the window is in fullscreen mode, and hide the traffic lights & drag region if it is + function onFullscreenChange(ev?: Event) { + if (getCurrentWebviewWindow().label !== "main") return + + + if (platform() === "macos") { + // ev?.preventDefault() + if (!document.fullscreenElement) { + getCurrentWebviewWindow().setTitleBarStyle("overlay").then() + setDisplayDragRegion(true) + setShowTrafficLights(true) + } else { + setDisplayDragRegion(false) + setShowTrafficLights(false) + } + } else { + getCurrentWebviewWindow().isFullscreen().then((fullscreen) => { + console.log("setting displayDragRegion to", !fullscreen) + setShowTrafficLights(!fullscreen) + setDisplayDragRegion(!fullscreen) + }) + } + } + + React.useEffect(() => { + + const listener = getCurrentWebviewWindow().onResized(() => { + onFullscreenChange() + // Get the current window maximized state + getCurrentWebviewWindow().isMaximized().then((maximized) => { + setMaximized(maximized) + }) + }) + + document.addEventListener("fullscreenchange", onFullscreenChange) + + return () => { + listener.then((f) => f()) + document.removeEventListener("fullscreenchange", onFullscreenChange) + } + }, []) + + /** + * + * const policyRef = React.useRef("regular") + * + * // Check if the window is in fullscreen mode, and hide the traffic lights & drag region if it is + * function onFullscreenChange(ev?: Event) { + * if (getCurrentWebviewWindow().label !== "main") return + * + * if (platform() === "macos") { + * // Bug fix for macOS where fullscreen doesn't work if the activation policy is set to Regular + * if (document.fullscreenElement && policyRef.current !== "accessory") { // entering fullscreen + * getCurrentWebviewWindow().setFullscreen(true).then(() => { + * setShowTrafficLights(false) + * setDisplayDragRegion(false) + * }) + * getCurrentWebviewWindow().emit("macos-activation-policy-accessory").then(() => { + * policyRef.current = "accessory" + * }) + * } else if (policyRef.current !== "regular") { // exiting fullscreen + * getCurrentWebviewWindow().setFullscreen(false).then(() => { + * setShowTrafficLights(true) + * setDisplayDragRegion(true) + * }) + * getCurrentWebviewWindow().emit("macos-activation-policy-regular").then(() => { + * getCurrentWebviewWindow().setTitleBarStyle("overlay").then() + * }) + * policyRef.current = "regular" + * } + * } else { + * getCurrentWebviewWindow().isFullscreen().then((fullscreen) => { + * setShowTrafficLights(!fullscreen) + * setDisplayDragRegion(!fullscreen) + * }) + * } + * } + * + * React.useEffect(() => { + * const listener = getCurrentWebviewWindow().onResized(() => { + * onFullscreenChange() + * // Get the current window maximized state + * getCurrentWebviewWindow().isMaximized().then((maximized) => { + * setMaximized(maximized) + * }) + * }) + * + * document.addEventListener("fullscreenchange", onFullscreenChange) + * + * return () => { + * listener.then((f) => f()) // remove the listener + * document.removeEventListener("fullscreenchange", onFullscreenChange) + * } + * }, []) + */ + + const [currentPlatform, setCurrentPlatform] = React.useState("") + + React.useEffect(() => { + (async () => { + setCurrentPlatform(platform()) + const win = getCurrentWebviewWindow() + const minimizable = await win.isMinimizable() + const maximizable = await win.isMaximizable() + const closable = await win.isClosable() + setShowTrafficLights(_ => { + let showTrafficLights = false + + if (win.label === "splashscreen") { + return false + } + + if (minimizable || maximizable || closable) { + showTrafficLights = true + } + + return showTrafficLights + }) + })() + }, []) + + if (!(currentPlatform === "windows" || currentPlatform === "macos")) return null + + return ( + <> + <div + className="__tauri-window-traffic-lights scroll-locked-offset bg-transparent fixed top-0 left-0 h-10 z-[999] w-full bg-opacity-90 flex pointer-events-[all]" + style={{ + pointerEvents: "all", + }} + > + {displayDragRegion && <div className="flex flex-1 cursor-grab active:cursor-grabbing" data-tauri-drag-region></div>} + {(currentPlatform === "windows" && showTrafficLights) && + <div className="flex h-10 items-center justify-center gap-1 mr-2 !cursor-default"> + <IconButton + className="outline-none w-11 size-8 rounded-lg duration-0 shadow-none text-white hover:text-white bg-transparent hover:bg-[rgba(255,255,255,0.05)] active:text-white active:bg-[rgba(255,255,255,0.1)]" + icon={<VscChromeMinimize className="text-[0.95rem]" />} + onClick={handleMinimize} + tabIndex={-1} + /> + <IconButton + className="outline-none w-11 size-8 rounded-lg duration-0 shadow-none text-white hover:text-white bg-transparent hover:bg-[rgba(255,255,255,0.05)] active:text-white active:bg-[rgba(255,255,255,0.1)]" + icon={maximized ? <VscChromeRestore className="text-[0.95rem]" /> : <VscChromeMaximize className="text-[0.95rem]" />} + onClick={toggleMaximized} + tabIndex={-1} + /> + <IconButton + className="outline-none w-11 size-8 rounded-lg duration-0 shadow-none text-white hover:text-white bg-transparent hover:bg-red-500 active:bg-red-600 active:text-white" + icon={<VscChromeClose className="text-[0.95rem]" />} + onClick={handleClose} + tabIndex={-1} + /> + </div>} + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/anilist/_containers/anilist-collection-lists.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/anilist/_containers/anilist-collection-lists.tsx new file mode 100644 index 0000000..ad8df1e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/anilist/_containers/anilist-collection-lists.tsx @@ -0,0 +1,376 @@ +import { AL_AnimeCollection_MediaListCollection_Lists } from "@/api/generated/types" +import { useGetAniListStats } from "@/api/hooks/anilist.hooks" +import { AnilistAnimeEntryList } from "@/app/(main)/_features/anime/_components/anilist-media-entry-list" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { AnilistStats } from "@/app/(main)/anilist/_containers/anilist-stats" +import { + __myLists_selectedTypeAtom, + __myListsSearch_paramsAtom, + __myListsSearch_paramsInputAtom, + useHandleUserAnilistLists, +} from "@/app/(main)/anilist/_lib/handle-user-anilist-lists" +import { + ADVANCED_SEARCH_FORMATS, + ADVANCED_SEARCH_MEDIA_GENRES, + ADVANCED_SEARCH_SEASONS, + ADVANCED_SEARCH_STATUS, +} from "@/app/(main)/search/_lib/advanced-search-constants" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { IconButton } from "@/components/ui/button" +import { Combobox } from "@/components/ui/combobox" +import { cn } from "@/components/ui/core/styling" +import { Select } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { StaticTabs } from "@/components/ui/tabs" +import { TextInput } from "@/components/ui/text-input" +import { useDebounce } from "@/hooks/use-debounce" +import { COLLECTION_SORTING_OPTIONS } from "@/lib/helpers/filtering" +import { getYear } from "date-fns" +import { atom } from "jotai/index" +import { useAtom, useAtomValue, useSetAtom } from "jotai/react" +import { AnimatePresence } from "motion/react" +import React from "react" +import { BiTrash } from "react-icons/bi" +import { FaSortAmountDown } from "react-icons/fa" +import { FiSearch } from "react-icons/fi" +import { LuCalendar, LuLeaf } from "react-icons/lu" +import { MdPersonalVideo } from "react-icons/md" +import { RiSignalTowerLine } from "react-icons/ri" +import { TbSwords } from "react-icons/tb" + +const selectedIndexAtom = atom("-") +const watchListSearchInputAtom = atom<string>("") + +export function AnilistCollectionLists() { + const serverStatus = useServerStatus() + const [pageType, setPageType] = useAtom(__myLists_selectedTypeAtom) + const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom) + const searchInput = useAtomValue(watchListSearchInputAtom) + const debouncedSearchInput = useDebounce(searchInput, 500) + + const { + currentList, + repeatingList, + planningList, + pausedList, + completedList, + droppedList, + customLists, + } = useHandleUserAnilistLists(debouncedSearchInput) + + const { data: stats, isLoading: statsLoading } = useGetAniListStats(!!serverStatus?.user && !serverStatus?.user?.isSimulated) + + const setParams = useSetAtom(__myListsSearch_paramsAtom) + + // useMount(() => { + // setParams({ + // sorting: "SCORE_DESC", + // genre: null, + // status: null, + // format: null, + // season: null, + // year: null, + // isAdult: false, + // unreadOnly: false, + // continueWatchingOnly: false, + // }) + // }) + + return ( + <AppLayoutStack className="space-y-6" data-anilist-collection-lists> + <div className="w-full flex justify-center" data-anilist-collection-lists-tabs-container> + <StaticTabs + data-anilist-collection-lists-tabs + className="h-10 w-fit border rounded-full" + triggerClass="px-4 py-1" + items={[ + { name: "Anime", isCurrent: pageType === "anime", onClick: () => setPageType("anime") }, + ...[serverStatus?.settings?.library?.enableManga && { + name: "Manga", + isCurrent: pageType === "manga", + onClick: () => setPageType("manga"), + }], + ...[!serverStatus?.user?.isSimulated && { + name: "Stats", + isCurrent: pageType === "stats", + onClick: () => setPageType("stats"), + }], + ].filter(Boolean)} + /> + </div> + + + <AnimatePresence mode="wait" initial={false} data-anilist-collection-lists-content> + {pageType !== "stats" && <PageWrapper + key="lists" + className="space-y-6" + {...{ + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { + duration: 0.35, + }, + }} + > + <SearchOptions customLists={customLists} /> + + <div className="py-6 space-y-6" data-anilist-collection-lists-stack> + {(!!currentList?.entries?.length && ["-", "CURRENT"].includes(selectedIndex)) && <> + <h2>Current <span className="text-[--muted] font-medium ml-3">{currentList?.entries?.length}</span></h2> + <AnilistAnimeEntryList type={pageType} list={currentList} /> + </>} + {(!!repeatingList?.entries?.length && ["-", "REPEATING"].includes(selectedIndex)) && <> + <h2>Repeating <span className="text-[--muted] font-medium ml-3">{repeatingList?.entries?.length}</span></h2> + <AnilistAnimeEntryList type={pageType} list={repeatingList} /> + </>} + {(!!planningList?.entries?.length && ["-", "PLANNING"].includes(selectedIndex)) && <> + <h2>Planning <span className="text-[--muted] font-medium ml-3">{planningList?.entries?.length}</span></h2> + <AnilistAnimeEntryList type={pageType} list={planningList} /> + </>} + {(!!pausedList?.entries?.length && ["-", "PAUSED"].includes(selectedIndex)) && <> + <h2>Paused <span className="text-[--muted] font-medium ml-3">{pausedList?.entries?.length}</span></h2> + <AnilistAnimeEntryList type={pageType} list={pausedList} /> + </>} + {(!!completedList?.entries?.length && ["-", "COMPLETED"].includes(selectedIndex)) && <> + <h2>Completed <span className="text-[--muted] font-medium ml-3">{completedList?.entries?.length}</span></h2> + <AnilistAnimeEntryList type={pageType} list={completedList} /> + </>} + {(!!droppedList?.entries?.length && ["-", "DROPPED"].includes(selectedIndex)) && <> + <h2>Dropped <span className="text-[--muted] font-medium ml-3">{droppedList?.entries?.length}</span></h2> + <AnilistAnimeEntryList type={pageType} list={droppedList} /> + </>} + {customLists?.map(list => { + return (!!list.entries?.length && ["-", list.name || "N/A"].includes(selectedIndex)) ? <div + key={list.name} + className="space-y-6" + > + <h2>{list.name}</h2> + <AnilistAnimeEntryList type={pageType} list={list} /> + </div> : null + })} + </div> + </PageWrapper>} + + {pageType === "stats" && !serverStatus?.user?.isSimulated && <PageWrapper + key="stats" + className="space-y-6" + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + data-anilist-collection-lists-stats-wrapper + > + <AnilistStats + stats={stats} + isLoading={statsLoading} + /> + </PageWrapper>} + </AnimatePresence> + + </AppLayoutStack> + ) +} + + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +const SearchInput = () => { + + const [input, setter] = useAtom(watchListSearchInputAtom) + + return ( + <div className="w-full"> + <TextInput + leftIcon={<FiSearch />} + value={input} + onValueChange={v => { + setter(v) + }} + /> + </div> + ) +} + +export function SearchOptions({ + customLists, +}: { + customLists?: AL_AnimeCollection_MediaListCollection_Lists[] +}) { + + const serverStatus = useServerStatus() + const [params, setParams] = useAtom(__myListsSearch_paramsInputAtom) + const setActualParams = useSetAtom(__myListsSearch_paramsAtom) + const debouncedParams = useDebounce(params, 500) + const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom) + const [pageType, setPageType] = useAtom(__myLists_selectedTypeAtom) + + React.useEffect(() => { + setActualParams(params) + }, [debouncedParams]) + + const [input, setInput] = useAtom(watchListSearchInputAtom) + + const highlightTrash = React.useMemo(() => { + return !(!input.length && params.sorting === "SCORE_DESC" && (params.genre === null || !params.genre.length) && params.status === null && params.format === null && params.season === null && params.year === null && params.isAdult === false) + }, [params, input]) + + return ( + <AppLayoutStack className="px-4 xl:px-0" data-anilist-collection-lists-search-options-stack> + <div className="flex flex-col lg:flex-row gap-4" data-anilist-collection-lists-search-options-container> + <Select + // label="Sorting" + className="w-full" + fieldClass="lg:w-[200px]" + options={[ + { value: "-", label: "All lists" }, + { value: "CURRENT", label: "Watching" }, + { value: "REPEATING", label: "Repeating" }, + { value: "PLANNING", label: "Planning" }, + { value: "PAUSED", label: "Paused" }, + { value: "COMPLETED", label: "Completed" }, + { value: "DROPPED", label: "Dropped" }, + ...(customLists || []).map(list => ({ value: list.name || "N/A", label: list.name || "N/A" })), + ]} + value={selectedIndex || "-"} + onValueChange={v => setSelectedIndex(v as any)} + // disabled={!!params.title && params.title.length > 0} + /> + <div className="flex gap-4 items-center w-full" data-anilist-collection-lists-search-options-actions> + <SearchInput /> + <IconButton + icon={<BiTrash />} intent={highlightTrash ? "alert" : "gray-subtle"} className="flex-none" onClick={() => { + setParams(prev => ({ + ...prev, + sorting: "SCORE_DESC", + genre: null, + status: null, + format: null, + season: null, + year: null, + isAdult: false, + })) + setInput("") + }} + disabled={!highlightTrash} + /> + </div> + </div> + <div + className={cn( + "grid grid-cols-2 gap-5", + pageType === "anime" ? "xl:grid-cols-6" : "lg:grid-cols-4", + )} + data-anilist-collection-lists-search-options-grid + > + <Combobox + multiple + leftAddon={<TbSwords className={cn((params.genre !== null && !!params.genre?.length) && "text-indigo-300 font-bold text-xl")} />} + emptyMessage="No options found" + label="Genre" placeholder="All genres" + className="w-full" + fieldClass="w-full" + options={ADVANCED_SEARCH_MEDIA_GENRES.map(genre => ({ value: genre, label: genre, textValue: genre }))} + value={params.genre ? params.genre : []} + onValueChange={v => setParams(draft => { + draft.genre = v + return + })} + fieldLabelClass="hidden" + /> + <Select + label="Sorting" + leftAddon={<FaSortAmountDown className={cn((params.sorting !== "SCORE_DESC") && "text-indigo-300 font-bold text-xl")} />} + className="w-full" + fieldClass="flex items-center" + inputContainerClass="w-full" + options={COLLECTION_SORTING_OPTIONS} + value={params.sorting || "SCORE_DESC"} + onValueChange={v => setParams(draft => { + draft.sorting = v as any + return + })} + fieldLabelClass="hidden" + // disabled={!!params.title && params.title.length > 0} + /> + {pageType === "anime" && <Select + leftAddon={ + <MdPersonalVideo className={cn((params.format !== null && !!params.format?.length) && "text-indigo-300 font-bold text-xl")} />} + label="Format" placeholder="All formats" + className="w-full" + fieldClass="w-full" + options={ADVANCED_SEARCH_FORMATS} + value={params.format || ""} + onValueChange={v => setParams(draft => { + draft.format = v as any + return + })} + fieldLabelClass="hidden" + />} + <Select + leftAddon={ + <RiSignalTowerLine className={cn((params.status !== null && !!params.status?.length) && "text-indigo-300 font-bold text-xl")} />} + label="Status" placeholder="All statuses" + className="w-full" + fieldClass="w-full" + options={[ + ...ADVANCED_SEARCH_STATUS, + ]} + value={params.status || ""} + onValueChange={v => setParams(draft => { + draft.status = v as any + return + })} + fieldLabelClass="hidden" + /> + {pageType === "anime" && <Select + leftAddon={<LuLeaf className={cn((params.season !== null && !!params.season?.length) && "text-indigo-300 font-bold text-xl")} />} + label="Season" + placeholder="All seasons" + className="w-full" + fieldClass="w-full flex items-center" + inputContainerClass="w-full" + options={ADVANCED_SEARCH_SEASONS.map(season => ({ value: season.toUpperCase(), label: season }))} + value={params.season || ""} + onValueChange={v => setParams(draft => { + draft.season = v as any + return + })} + fieldLabelClass="hidden" + />} + <Select + leftAddon={<LuCalendar className={cn((params.year !== null && !!params.year?.length) && "text-indigo-300 font-bold text-xl")} />} + label="Year" placeholder="Timeless" + className="w-full" + fieldClass="w-full" + options={[...Array(70)].map((v, idx) => getYear(new Date()) - idx).map(year => ({ + value: String(year), + label: String(year), + }))} + value={params.year || ""} + onValueChange={v => setParams(draft => { + draft.year = v as any + return + })} + fieldLabelClass="hidden" + /> + </div> + + {serverStatus?.settings?.anilist?.enableAdultContent && <Switch + label="Adult" + value={params.isAdult} + onValueChange={v => setParams(draft => { + draft.isAdult = v + return + })} + fieldLabelClass="hidden" + />} + + </AppLayoutStack> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/anilist/_containers/anilist-stats.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/anilist/_containers/anilist-stats.tsx new file mode 100644 index 0000000..734bd1d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/anilist/_containers/anilist-stats.tsx @@ -0,0 +1,405 @@ +import { AL_Stats } from "@/api/generated/types" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { AreaChart, BarChart, DonutChart } from "@/components/ui/charts" +import { Separator } from "@/components/ui/separator" +import { Stats } from "@/components/ui/stats" +import React from "react" +import { FaRegStar } from "react-icons/fa" +import { FiBookOpen } from "react-icons/fi" +import { LuHourglass } from "react-icons/lu" +import { PiTelevisionSimpleBold } from "react-icons/pi" +import { TbHistory } from "react-icons/tb" + +type AnilistStatsProps = { + stats?: AL_Stats + isLoading?: boolean +} + +const formatName: Record<string, string> = { + TV: "TV", + TV_SHORT: "TV Short", + MOVIE: "Movie", + SPECIAL: "Special", + OVA: "OVA", + ONA: "ONA", + MUSIC: "Music", +} + +const statusName: Record<string, string> = { + CURRENT: "Current", + PLANNING: "Planning", + COMPLETED: "Completed", + DROPPED: "Dropped", + PAUSED: "Paused", + REPEATING: "Repeating", +} + +export function AnilistStats(props: AnilistStatsProps) { + + const { + stats, + isLoading, + } = props + + const anime_formatsStats = React.useMemo(() => { + if (!stats?.animeStats?.formats) return [] + + return stats.animeStats.formats.map((item) => { + return { + name: formatName[item.format as string], + count: item.count, + hoursWatched: Math.round(item.minutesWatched / 60), + meanScore: Number((item.meanScore / 10).toFixed(1)), + } + }) + }, [stats?.animeStats?.formats]) + + const anime_statusesStats = React.useMemo(() => { + if (!stats?.animeStats?.statuses) return [] + + return stats.animeStats.statuses.map((item) => { + return { + name: statusName[item.status as string], + count: item.count, + hoursWatched: Math.round(item.minutesWatched / 60), + meanScore: Number((item.meanScore / 10).toFixed(1)), + } + }) + }, [stats?.animeStats?.statuses]) + + const anime_genresStats = React.useMemo(() => { + if (!stats?.animeStats?.genres) return [] + + return stats.animeStats.genres.map((item) => { + return { + name: item.genre, + "Count": item.count, + hoursWatched: Math.round(item.minutesWatched / 60), + "Average score": Number((item.meanScore / 10).toFixed(1)), + } + }).sort((a, b) => b["Count"] - a["Count"]) + }, [stats?.animeStats?.genres]) + + const [anime_thisYearStats, anime_lastYearStats] = React.useMemo(() => { + if (!stats?.animeStats?.startYears) return [] + const thisYear = new Date().getFullYear() + return [ + stats.animeStats.startYears.find((item) => item.startYear === thisYear), + stats.animeStats.startYears.find((item) => item.startYear === thisYear - 1), + ] + }, [stats?.animeStats?.startYears]) + + const anime_releaseYearsStats = React.useMemo(() => { + if (!stats?.animeStats?.releaseYears) return [] + + return stats.animeStats.releaseYears.sort((a, b) => a.releaseYear! - b.releaseYear!).map((item) => { + return { + name: item.releaseYear, + "Count": item.count, + "Hours watched": Math.round(item.minutesWatched / 60), + "Mean score": Number((item.meanScore / 10).toFixed(1)), + } + }) + }, [stats?.animeStats?.releaseYears]) + + ///// + + const manga_statusesStats = React.useMemo(() => { + if (!stats?.mangaStats?.statuses) return [] + + return stats.mangaStats.statuses.map((item) => { + return { + name: statusName[item.status as string], + count: item.count, + chaptersRead: item.chaptersRead, + meanScore: Number((item.meanScore / 10).toFixed(1)), + } + }) + }, [stats?.mangaStats?.statuses]) + + const manga_genresStats = React.useMemo(() => { + if (!stats?.mangaStats?.genres) return [] + + return stats.mangaStats.genres.map((item) => { + return { + name: item.genre, + "Count": item.count, + chaptersRead: item.chaptersRead, + "Average score": Number((item.meanScore / 10).toFixed(1)), + } + }).sort((a, b) => b["Count"] - a["Count"]) + }, [stats?.mangaStats?.genres]) + + const [manga_thisYearStats, manga_lastYearStats] = React.useMemo(() => { + if (!stats?.mangaStats?.startYears) return [] + const thisYear = new Date().getFullYear() + return [ + stats.mangaStats.startYears.find((item) => item.startYear === thisYear), + stats.mangaStats.startYears.find((item) => item.startYear === thisYear - 1), + ] + }, [stats?.mangaStats?.startYears]) + + const manga_releaseYearsStats = React.useMemo(() => { + if (!stats?.mangaStats?.releaseYears) return [] + + return stats.mangaStats.releaseYears.sort((a, b) => a.releaseYear! - b.releaseYear!).map((item) => { + return { + name: item.releaseYear, + "Count": item.count, + "Chapters read": item.chaptersRead, + "Mean score": Number((item.meanScore / 10).toFixed(1)), + } + }) + }, [stats?.mangaStats?.releaseYears]) + + return ( + <AppLayoutStack className="py-4 space-y-10" data-anilist-stats> + + <h1 className="text-center" data-anilist-stats-anime-title>Anime</h1> + + <div data-anilist-stats-anime-stats> + <Stats + className="w-full" + size="lg" + items={[ + { + icon: <PiTelevisionSimpleBold />, + name: "Total Anime", + value: stats?.animeStats?.count ?? 0, + }, + { + icon: <LuHourglass />, + name: "Watch time", + value: Math.round((stats?.animeStats?.minutesWatched ?? 0) / 60), + unit: "hours", + }, + { + icon: <FaRegStar />, + name: "Average score", + value: ((stats?.animeStats?.meanScore ?? 0) / 10).toFixed(1), + }, + ]} + /> + <Separator /> + <Stats + className="w-full" + size="lg" + items={[ + { + icon: <PiTelevisionSimpleBold />, + name: "Anime watched this year", + value: anime_thisYearStats?.count ?? 0, + }, + { + icon: <TbHistory />, + name: "Anime watched last year", + value: anime_lastYearStats?.count ?? 0, + }, + { + icon: <FaRegStar />, + name: "Average score this year", + value: ((anime_thisYearStats?.meanScore ?? 0) / 10).toFixed(1), + }, + ]} + /> + </div> + + <h3 className="text-center" data-anilist-stats-anime-formats-title>Formats</h3> + + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 w-full" data-anilist-stats-anime-formats-container> + <ChartContainer legend="Total" data-anilist-stats-anime-formats-container-total> + <DonutChart + data={anime_formatsStats} + index="name" + category="count" + variant="pie" + /> + </ChartContainer> + <ChartContainer legend="Hours watched" data-anilist-stats-anime-formats-container-hours-watched> + <DonutChart + data={anime_formatsStats} + index="name" + category="hoursWatched" + variant="pie" + /> + </ChartContainer> + <ChartContainer legend="Average score" data-anilist-stats-anime-formats-container-average-score> + <DonutChart + data={anime_formatsStats} + index="name" + category="meanScore" + variant="pie" + /> + </ChartContainer> + </div> + + <Separator /> + + <h3 className="text-center" data-anilist-stats-anime-statuses-title>Statuses</h3> + + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full" data-anilist-stats-anime-statuses-container> + <ChartContainer legend="Total" data-anilist-stats-anime-statuses-container-total> + <DonutChart + data={anime_statusesStats} + index="name" + category="count" + variant="pie" + /> + </ChartContainer> + <ChartContainer legend="Hours watched" data-anilist-stats-anime-statuses-container-hours-watched> + <DonutChart + data={anime_statusesStats} + index="name" + category="hoursWatched" + variant="pie" + /> + </ChartContainer> + </div> + + <Separator /> + + <h3 className="text-center" data-anilist-stats-anime-genres-title>Genres</h3> + + <div className="grid grid-cols-1 gap-6 w-full" data-anilist-stats-anime-genres-container> + <ChartContainer legend="Favorite genres" data-anilist-stats-anime-genres-container-favorite-genres> + <BarChart + data={anime_genresStats} + index="name" + categories={["Count", "Average score"]} + colors={["brand", "blue"]} + /> + </ChartContainer> + </div> + + <Separator /> + + <h3 className="text-center" data-anilist-stats-anime-years-title>Years</h3> + + <div className="grid grid-cols-1 gap-6 w-full" data-anilist-stats-anime-years-container> + <ChartContainer legend="Anime watched per release year" data-anilist-stats-anime-years-container-anime-watched-per-release-year> + <AreaChart + data={anime_releaseYearsStats} + index="name" + categories={["Count"]} + angledLabels + /> + </ChartContainer> + </div> + + {/*////////////////////////////////////////////////////*/} + {/*////////////////////////////////////////////////////*/} + {/*////////////////////////////////////////////////////*/} + + <h1 className="text-center pt-20" data-anilist-stats-manga-title>Manga</h1> + + <div data-anilist-stats-manga-stats> + <Stats + className="w-full" + size="lg" + items={[ + { + icon: <FiBookOpen />, + name: "Total Manga", + value: stats?.mangaStats?.count ?? 0, + }, + { + icon: <LuHourglass />, + name: "Total chapters", + value: stats?.mangaStats?.chaptersRead ?? 0, + }, + { + icon: <FaRegStar />, + name: "Average score", + value: ((stats?.mangaStats?.meanScore ?? 0) / 10).toFixed(1), + }, + ]} + /> + <Separator /> + <Stats + className="w-full" + size="lg" + items={[ + { + icon: <FiBookOpen />, + name: "Manga read this year", + value: manga_thisYearStats?.count ?? 0, + }, + { + icon: <TbHistory />, + name: "Manga read last year", + value: manga_lastYearStats?.count ?? 0, + }, + { + icon: <FaRegStar />, + name: "Average score this year", + value: ((manga_thisYearStats?.meanScore ?? 0) / 10).toFixed(1), + }, + ]} + /> + </div> + + <Separator /> + + <h3 className="text-center" data-anilist-stats-manga-statuses-title>Statuses</h3> + + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full" data-anilist-stats-manga-statuses-container> + <ChartContainer legend="Total" data-anilist-stats-manga-statuses-container-total> + <DonutChart + data={manga_statusesStats} + index="name" + category="count" + variant="pie" + /> + </ChartContainer> + <ChartContainer legend="Chapters read" data-anilist-stats-manga-statuses-container-chapters-read> + <DonutChart + data={manga_statusesStats} + index="name" + category="chaptersRead" + variant="pie" + /> + </ChartContainer> + </div> + + <Separator /> + + <h3 className="text-center" data-anilist-stats-manga-genres-title>Genres</h3> + + <div className="grid grid-cols-1 gap-6 w-full" data-anilist-stats-manga-genres-container> + <ChartContainer legend="Favorite genres" data-anilist-stats-manga-genres-container-favorite-genres> + <BarChart + data={manga_genresStats} + index="name" + categories={["Count", "Average score"]} + colors={["brand", "blue"]} + /> + </ChartContainer> + </div> + + <Separator /> + + <h3 className="text-center" data-anilist-stats-manga-years-title>Years</h3> + + <div className="grid grid-cols-1 gap-6 w-full" data-anilist-stats-manga-years-container> + <ChartContainer legend="Manga read per release year" data-anilist-stats-manga-years-container-manga-read-per-release-year> + <AreaChart + data={manga_releaseYearsStats} + index="name" + categories={["Count"]} + angledLabels + /> + </ChartContainer> + </div> + + </AppLayoutStack> + ) +} + +function ChartContainer(props: { children: React.ReactNode, legend: string }) { + return ( + <div className="text-center w-full space-y-4" data-anilist-stats-chart-container> + {props.children} + <p className="text-center text-lg font-semibold">{props.legend}</p> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/anilist/_lib/handle-user-anilist-lists.ts b/seanime-2.9.10/seanime-web/src/app/(main)/anilist/_lib/handle-user-anilist-lists.ts new file mode 100644 index 0000000..addcd45 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/anilist/_lib/handle-user-anilist-lists.ts @@ -0,0 +1,91 @@ +import { AL_AnimeCollection_MediaListCollection_Lists } from "@/api/generated/types" +import { useGetRawAnimeCollection } from "@/api/hooks/anilist.hooks" +import { useGetRawAnilistMangaCollection } from "@/api/hooks/manga.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { CollectionParams, CollectionType, DEFAULT_COLLECTION_PARAMS, filterEntriesByTitle, filterListEntries } from "@/lib/helpers/filtering" +import { atomWithImmer } from "jotai-immer" +import { useAtom } from "jotai/react" +import React from "react" +import { useDebounce } from "use-debounce" + +export const MYLISTS_DEFAULT_PARAMS: CollectionParams<"anime"> | CollectionParams<"manga"> = { + ...DEFAULT_COLLECTION_PARAMS, + sorting: "SCORE_DESC", + unreadOnly: false, + continueWatchingOnly: false, +} + +export const __myListsSearch_paramsAtom = atomWithImmer<CollectionParams<"anime"> | CollectionParams<"manga">>(MYLISTS_DEFAULT_PARAMS) + +export const __myListsSearch_paramsInputAtom = atomWithImmer<CollectionParams<"anime"> | CollectionParams<"manga">>(MYLISTS_DEFAULT_PARAMS) + +export const __myLists_selectedTypeAtom = atomWithImmer<"anime" | "manga" | "stats">("anime") + +export function useHandleUserAnilistLists(debouncedSearchInput: string) { + + const serverStatus = useServerStatus() + const [selectedType, setSelectedType] = useAtom(__myLists_selectedTypeAtom) + const { data: animeData } = useGetRawAnimeCollection() + const { data: mangaData } = useGetRawAnilistMangaCollection() + + const data = React.useMemo(() => { + return selectedType === "anime" ? animeData : mangaData + }, [selectedType, animeData, mangaData]) + + const lists = React.useMemo(() => data?.MediaListCollection?.lists, [data]) + + const [params, _setParams] = useAtom(__myListsSearch_paramsAtom) + const [debouncedParams] = useDebounce(params, 500) + + React.useLayoutEffect(() => { + if (selectedType === "manga" && !serverStatus?.settings?.library?.enableManga) { + setSelectedType("anime") + } + }, [serverStatus?.settings?.library?.enableManga]) + + React.useLayoutEffect(() => { + _setParams(MYLISTS_DEFAULT_PARAMS) + }, [selectedType]) + + const _filteredLists: AL_AnimeCollection_MediaListCollection_Lists[] = React.useMemo(() => { + return lists?.map(obj => { + if (!obj) return undefined + const arr = filterListEntries(selectedType as CollectionType, obj?.entries, params, serverStatus?.settings?.anilist?.enableAdultContent) + return { + name: obj?.name, + isCustomList: obj?.isCustomList, + status: obj?.status, + entries: arr, + } + }).filter(Boolean) ?? [] + }, [lists, debouncedParams, selectedType, serverStatus?.settings?.anilist?.enableAdultContent]) + + const filteredLists: AL_AnimeCollection_MediaListCollection_Lists[] = React.useMemo(() => { + return _filteredLists?.map(obj => { + if (!obj) return undefined + const arr = filterEntriesByTitle(obj?.entries, debouncedSearchInput) + return { + name: obj?.name, + isCustomList: obj?.isCustomList, + status: obj?.status, + entries: arr, + } + })?.filter(Boolean) ?? [] + }, [_filteredLists, debouncedSearchInput]) + + const customLists = React.useMemo(() => { + return filteredLists?.filter(obj => obj?.isCustomList) ?? [] + }, [filteredLists]) + + return { + currentList: React.useMemo(() => filteredLists?.find(l => l?.status === "CURRENT"), [filteredLists]), + repeatingList: React.useMemo(() => filteredLists?.find(l => l?.status === "REPEATING"), [filteredLists]), + planningList: React.useMemo(() => filteredLists?.find(l => l?.status === "PLANNING"), [filteredLists]), + pausedList: React.useMemo(() => filteredLists?.find(l => l?.status === "PAUSED"), [filteredLists]), + completedList: React.useMemo(() => filteredLists?.find(l => l?.status === "COMPLETED"), [filteredLists]), + droppedList: React.useMemo(() => filteredLists?.find(l => l?.status === "DROPPED"), [filteredLists]), + customLists, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/anilist/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/anilist/layout.tsx new file mode 100644 index 0000000..3efab03 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/anilist/layout.tsx @@ -0,0 +1,15 @@ +"use client" +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/anilist/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/anilist/page.tsx new file mode 100644 index 0000000..4522e27 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/anilist/page.tsx @@ -0,0 +1,33 @@ +"use client" + +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { AnilistCollectionLists } from "@/app/(main)/anilist/_containers/anilist-collection-lists" +import { PageWrapper } from "@/components/shared/page-wrapper" +import React from "react" + +export const dynamic = "force-static" + +export default function Home() { + + return ( + <> + <CustomLibraryBanner discrete /> + <PageWrapper + className="p-4 sm:p-8 pt-4 relative" + data-anilist-page + {...{ + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: 10 }, + transition: { + type: "spring", + damping: 20, + stiffness: 100, + }, + }} + > + <AnilistCollectionLists /> + </PageWrapper> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/auth/_containers/callback-page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/auth/_containers/callback-page.tsx new file mode 100644 index 0000000..626db95 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/auth/_containers/callback-page.tsx @@ -0,0 +1,48 @@ +import { useLogin } from "@/api/hooks/auth.hooks" +import { websocketConnectedAtom } from "@/app/websocket-provider" +import { LoadingOverlay } from "@/components/ui/loading-spinner" +import { useAtomValue } from "jotai/react" +import { useRouter } from "next/navigation" +import React from "react" +import { toast } from "sonner" + +type CallbackPageProps = {} + +/** + * @description + * - Logs the user in using the AniList token present in the URL hash + */ +export function CallbackPage(props: CallbackPageProps) { + const router = useRouter() + const {} = props + + const websocketConnected = useAtomValue(websocketConnectedAtom) + + const { mutate: login } = useLogin() + + const called = React.useRef(false) + + React.useEffect(() => { + if (typeof window !== "undefined" && websocketConnected) { + /** + * Get the AniList token from the URL hash + */ + const _token = window?.location?.hash?.replace("#access_token=", "")?.replace(/&.*/, "") + if (!!_token && !called.current) { + login({ token: _token }) + called.current = true + } else { + toast.error("Invalid token") + router.push("/") + } + } + }, [websocketConnected]) + + return ( + <div> + <LoadingOverlay className="fixed w-full h-full z-[80]"> + <h3 className="mt-2">Authenticating...</h3> + </LoadingOverlay> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/auth/callback/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/auth/callback/page.tsx new file mode 100644 index 0000000..78d7b54 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/auth/callback/page.tsx @@ -0,0 +1,12 @@ +"use client" +import { CallbackPage } from "@/app/(main)/auth/_containers/callback-page" +import React from "react" + +export const dynamic = "force-static" + +export default function Page() { + + return ( + <CallbackPage /> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_components/autodownloader-rule-item.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_components/autodownloader-rule-item.tsx new file mode 100644 index 0000000..5ae10c1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_components/autodownloader-rule-item.tsx @@ -0,0 +1,88 @@ +import { AL_BaseAnime, Anime_AutoDownloaderRule } from "@/api/generated/types" +import { AutoDownloaderRuleForm } from "@/app/(main)/auto-downloader/_containers/autodownloader-rule-form" +import { IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import { useBoolean } from "@/hooks/use-disclosure" +import React from "react" +import { BiChevronRight } from "react-icons/bi" +import { FaSquareRss } from "react-icons/fa6" + +export type AutoDownloaderRuleItemProps = { + rule: Anime_AutoDownloaderRule + userMedia: AL_BaseAnime[] | undefined +} + +export function AutoDownloaderRuleItem(props: AutoDownloaderRuleItemProps) { + + const { + rule, + userMedia, + ...rest + } = props + + const modal = useBoolean(false) + + const media = React.useMemo(() => { + return userMedia?.find(media => media.id === rule.mediaId) + }, [(userMedia?.length || 0), rule]) + + return ( + <> + <div className="rounded-[--radius] bg-gray-900 hover:bg-gray-800 transition-colors"> + <div className="flex justify-between p-3 gap-2 items-center cursor-pointer" onClick={() => modal.on()}> + + <div className="space-y-1 w-full"> + <p + className={cn( + "font-medium text-base tracking-wide line-clamp-1", + )} + ><span className="text-gray-400 italic font-normal pr-1">Rule for</span> "{rule.comparisonTitle}"</p> + <p className="text-sm text-gray-400 line-clamp-1 flex space-x-2 items-center divide-x divide-[--border] [&>span]:pl-2"> + <FaSquareRss + className={cn( + "text-xl", + rule.enabled ? "text-green-500" : "text-gray-500", + (!media) && "text-red-300", + )} + /> + {!!rule.releaseGroups?.length && <span>{rule.releaseGroups.join(", ")}</span>} + {!!rule.resolutions?.length && <span>{rule.resolutions.join(", ")}</span>} + {!!rule.episodeType && <span>{getEpisodeTypeName(rule.episodeType)}</span>} + {!!media ? ( + <> + {media.status === "FINISHED" && + <span className="text-orange-300 opacity-70">This anime is no longer airing</span>} + </> + ) : ( + <span className="text-red-300">This anime is not in your library</span> + )} + </p> + </div> + + <div> + <IconButton intent="white-basic" icon={<BiChevronRight />} size="sm" /> + </div> + </div> + </div> + <Modal + open={modal.active} + onOpenChange={modal.off} + title="Edit rule" + contentClass="max-w-4xl" + + > + <AutoDownloaderRuleForm type="edit" rule={rule} /> + </Modal> + </> + ) +} + +function getEpisodeTypeName(episodeType: Anime_AutoDownloaderRule["episodeType"]) { + switch (episodeType) { + case "recent": + return "Recent releases" + case "selected": + return "Select episodes" + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-batch-rule-form.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-batch-rule-form.tsx new file mode 100644 index 0000000..c799169 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-batch-rule-form.tsx @@ -0,0 +1,394 @@ +import { + AL_BaseAnime, + Anime_AutoDownloaderRuleEpisodeType, + Anime_AutoDownloaderRuleTitleComparisonType, + Anime_LibraryCollection, +} from "@/api/generated/types" +import { useCreateAutoDownloaderRule } from "@/api/hooks/auto_downloader.hooks" +import { useAnilistUserAnime } from "@/app/(main)/_hooks/anilist-collection-loader" +import { useLibraryCollection } from "@/app/(main)/_hooks/anime-library-collection-loader" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { TextArrayField } from "@/app/(main)/auto-downloader/_containers/autodownloader-rule-form" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { CloseButton, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { defineSchema, Field, Form, InferType } from "@/components/ui/form" +import { Select } from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { TextInput } from "@/components/ui/text-input" +import { upath } from "@/lib/helpers/upath" +import { uniq } from "lodash" +import Image from "next/image" +import React from "react" +import { useFieldArray, UseFormReturn } from "react-hook-form" +import { BiPlus } from "react-icons/bi" +import { FcFolder } from "react-icons/fc" +import { LuTextCursorInput } from "react-icons/lu" +import { MdVerified } from "react-icons/md" +import { toast } from "sonner" + +type AutoDownloaderBatchRuleFormProps = { + onRuleCreated: () => void +} + +const schema = defineSchema(({ z }) => z.object({ + enabled: z.boolean(), + entries: z.array(z.object({ + mediaId: z.number(), + destination: z.string(), + comparisonTitle: z.string(), + })).min(1), + releaseGroups: z.array(z.string()).transform(value => uniq(value.filter(Boolean))), + resolutions: z.array(z.string()).transform(value => uniq(value.filter(Boolean))), + additionalTerms: z.array(z.string()).optional().transform(value => !value?.length ? [] : uniq(value.filter(Boolean))), + titleComparisonType: z.string(), +})) + +export function AutoDownloaderBatchRuleForm(props: AutoDownloaderBatchRuleFormProps) { + + const { + onRuleCreated, + } = props + + const userMedia = useAnilistUserAnime() + const libraryCollection = useLibraryCollection() + + const allMedia = React.useMemo(() => { + return userMedia ?? [] + }, [userMedia]) + + // Upcoming & airing media + const notFinishedMedia = React.useMemo(() => { + return allMedia.filter(media => media.status !== "FINISHED") + }, [allMedia]) + + const { mutate: createRule, isPending: creatingRule } = useCreateAutoDownloaderRule() + + const isPending = creatingRule + + function handleSave(data: InferType<typeof schema>) { + for (const entry of data.entries) { + if (entry.destination === "" || entry.mediaId === 0) { + continue + } + createRule({ + titleComparisonType: data.titleComparisonType as Anime_AutoDownloaderRuleTitleComparisonType, + episodeType: "recent" as Anime_AutoDownloaderRuleEpisodeType, + enabled: data.enabled, + mediaId: entry.mediaId, + releaseGroups: data.releaseGroups, + resolutions: data.resolutions, + additionalTerms: data.additionalTerms, + comparisonTitle: entry.comparisonTitle, + destination: entry.destination, + }) + } + onRuleCreated?.() + } + + if (allMedia.length === 0) { + return <div className="p-4 text-[--muted] text-center">No media found in your library</div> + } + + return ( + <div className="space-y-4 mt-2"> + <Form + schema={schema} + onSubmit={handleSave} + onError={errors => { + console.log(errors) + toast.error("An error occurred, verify the fields.") + }} + defaultValues={{ + enabled: true, + titleComparisonType: "likely", + }} + > + {(f) => <RuleFormFields + form={f} + allMedia={allMedia} + isPending={isPending} + notFinishedMedia={notFinishedMedia} + libraryCollection={libraryCollection} + />} + </Form> + </div> + ) +} + +type RuleFormFieldsProps = { + form: UseFormReturn<InferType<typeof schema>> + allMedia: AL_BaseAnime[] + isPending: boolean + notFinishedMedia: AL_BaseAnime[] + libraryCollection?: Anime_LibraryCollection | undefined +} + +function RuleFormFields(props: RuleFormFieldsProps) { + + const { + form, + allMedia, + isPending, + notFinishedMedia, + libraryCollection, + ...rest + } = props + + const serverStatus = useServerStatus() + + return ( + <> + <Field.Switch name="enabled" label="Enabled" /> + <Separator /> + <div + className={cn( + "space-y-3", + // !form.watch("enabled") && "opacity-50 pointer-events-none", + )} + > + + <MediaArrayField + allMedia={notFinishedMedia} + libraryPath={serverStatus?.settings?.library?.libraryPath || ""} + name="entries" + control={form.control} + label="Library entries" + separatorText="AND" + form={form} + /> + + <div className="border rounded-[--radius] p-4 relative !mt-8 space-y-3"> + <div className="absolute -top-2.5 tracking-wide font-semibold uppercase text-sm left-4 bg-gray-950 px-2">Title</div> + <Field.RadioCards + label="Type of search" + name="titleComparisonType" + options={[ + { + label: <div className="w-full"> + <p className="mb-1 flex items-center"><MdVerified className="text-lg inline-block mr-2" />Most likely</p> + <p className="font-normal text-sm text-[--muted]">The torrent name will be parsed and analyzed using a comparison + algorithm</p> + </div>, + value: "likely", + }, + { + label: <div className="w-full"> + <p className="mb-1 flex items-center"><LuTextCursorInput className="text-lg inline-block mr-2" />Exact match</p> + <p className="font-normal text-sm text-[--muted]">The torrent name must contain the comparison title you set (case + insensitive)</p> + </div>, + value: "contains", + }, + ]} + /> + </div> + + <div className="border rounded-[--radius] p-4 relative !mt-8 space-y-3"> + <div className="absolute -top-2.5 tracking-wide font-semibold uppercase text-sm left-4 bg-gray-950 px-2">Release Groups</div> + <p className="text-sm"> + List of release groups to look for. If empty, any release group will be accepted. + </p> + + <TextArrayField + name="releaseGroups" + control={form.control} + type="text" + placeholder="e.g. SubsPlease" + separatorText="OR" + /> + </div> + + <div className="border rounded-[--radius] p-4 relative !mt-8 space-y-3"> + <div className="absolute -top-2.5 tracking-wide font-semibold uppercase text-sm left-4 bg-gray-950 px-2">Resolutions</div> + <p className="text-sm"> + List of resolutions to look for. If empty, the highest resolution will be accepted. + </p> + + <TextArrayField + name="resolutions" + control={form.control} + type="text" + placeholder="e.g. 1080p" + separatorText="OR" + /> + </div> + + <Accordion type="single" collapsible className="!my-4"> + <AccordionItem value="more"> + <AccordionTrigger className="border rounded-[--radius] bg-gray-900"> + More filters + </AccordionTrigger> + <AccordionContent className="pt-0"> + <div className="border rounded-[--radius] p-4 relative !mt-8 space-y-3"> + <div className="absolute -top-2.5 tracking-wide font-semibold uppercase text-sm left-4 bg-gray-950 px-2">Additional + terms + </div> + <div> + <p className="text-sm -top-2 relative"><span className="text-red-100"> + All options must be included for the torrent to be accepted.</span> Within each option, you can + include variations separated by + commas. For example, adding + "H265,H.265, H 265,x265" and + "10bit,10-bit,10 bit" will match + <code className="text-gray-400"> [Group] Torrent name [HEVC 10bit + x265]</code> but not <code className="text-gray-400">[Group] Torrent name + [H265]</code>. Case + insensitive.</p> + </div> + + <TextArrayField + name="additionalTerms" + control={form.control} + type="text" + placeholder="e.g. H265,H.265,H 265,x265" + separatorText="AND" + /> + </div> + </AccordionContent> + </AccordionItem> + </Accordion> + + </div> + <div className="flex gap-2"> + <Field.Submit role="create" loading={isPending} disableOnSuccess={false} showLoadingOverlayOnSuccess>Create</Field.Submit> + </div> + </> + ) +} + +type MediaArrayFieldProps = { + name: string + control: any + allMedia: AL_BaseAnime[] + libraryPath: string + label?: string + separatorText?: string + form: UseFormReturn<InferType<typeof schema>> +} + +interface MediaEntry { + mediaId: number + destination: string + comparisonTitle: string +} + +interface FormValues { + [key: string]: MediaEntry[] +} + +export function MediaArrayField(props: MediaArrayFieldProps) { + const { fields, append, remove, update } = useFieldArray<FormValues>({ + control: props.control, + name: props.name, + }) + + const handleFieldChange = (index: number, updatedValues: Partial<MediaEntry>, field: MediaEntry) => { + if ("mediaId" in updatedValues) { + const mediaId = updatedValues.mediaId! + const romaji = props.allMedia.find(m => m.id === mediaId)?.title?.romaji || "" + const sanitizedTitle = sanitizeDirectoryName(romaji) + + update(index, { + ...field, + ...updatedValues, + destination: upath.join(props.libraryPath, sanitizedTitle), + // destination: field.destination === props.libraryPath + // ? upath.join(props.libraryPath, sanitizedTitle) + // : field.destination, + comparisonTitle: sanitizedTitle, + }) + } else { + update(index, { ...field, ...updatedValues }) + } + } + + return ( + <div className="space-y-2"> + {props.label && ( + <div className="flex items-center"> + <div className="text-base font-semibold">{props.label}</div> + </div> + )} + {fields.map((field, index) => ( + <div key={field.id}> + <div className="flex gap-4 items-center w-full"> + <div className="flex flex-col gap-2 w-full"> + <div className="border rounded-[--radius] p-4 relative !mt-8 space-y-3"> + <div className="flex gap-4 items-center"> + <div + className="size-[5rem] rounded-[--radius] flex-none object-cover object-center overflow-hidden relative bg-gray-800" + > + {!!props.allMedia.find(m => m.id === field?.mediaId)?.coverImage?.large && <Image + src={props.allMedia.find(m => m.id === field?.mediaId)!.coverImage!.large!} + alt="banner" + fill + quality={80} + priority + sizes="20rem" + className="object-cover object-center" + />} + </div> + <Select + label="Library Entry" + options={props.allMedia + .map(media => ({ + label: media.title?.userPreferred || "N/A", + value: String(media.id), + })) + .toSorted((a, b) => a.label.localeCompare(b.label)) + } + value={String(field.mediaId)} + onValueChange={(v) => handleFieldChange(index, { mediaId: parseInt(v) }, field)} + /> + </div> + <Field.DirectorySelector + name={`entries.${index}.destination`} + label="Destination" + help="Folder in your local library where the files will be saved" + leftIcon={<FcFolder />} + shouldExist={false} + defaultValue={props.libraryPath} + /> + <TextInput + // name="comparisonTitle" + label="Comparison title" + help="Used for comparison purposes. When using 'Exact match', use a title most likely to be used in a torrent name." + {...props.form.register(`entries.${index}.comparisonTitle`)} /> + </div> + </div> + <CloseButton + size="sm" + intent="alert-subtle" + onClick={() => remove(index)} + /> + </div> + {(!!props.separatorText && index < fields.length - 1) && ( + <p className="text-center text-[--muted]">{props.separatorText}</p> + )} + </div> + ))} + <IconButton + intent="success" + className="rounded-full" + onClick={() => append({ + mediaId: 0, + destination: props.libraryPath, + comparisonTitle: "", + })} + icon={<BiPlus />} + /> + </div> + ) +} + + +function sanitizeDirectoryName(input: string): string { + const disallowedChars = /[<>:"/\\|?*\x00-\x1F.!`]/g // Pattern for disallowed characters + // Replace disallowed characters with an underscore + const sanitized = input.replace(disallowedChars, " ") + // Remove leading/trailing spaces and dots (periods) which are not allowed + const trimmed = sanitized.trim().replace(/^\.+|\.+$/g, "").replace(/\s+/g, " ") + // Ensure the directory name is not empty after sanitization + return trimmed || "Untitled" +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-item-list.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-item-list.tsx new file mode 100644 index 0000000..0a65d25 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-item-list.tsx @@ -0,0 +1,118 @@ +import { Models_AutoDownloaderItem } from "@/api/generated/types" +import { useDeleteAutoDownloaderItem } from "@/api/hooks/auto_downloader.hooks" +import { useTorrentClientAddMagnetFromRule } from "@/api/hooks/torrent_client.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { SeaLink } from "@/components/shared/sea-link" +import { Button } from "@/components/ui/button" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { formatDateAndTimeShort } from "@/lib/server/utils" +import React from "react" +import { BiDownload, BiTrash } from "react-icons/bi" + +type AutoDownloaderItemListProps = { + children?: React.ReactNode + items: Models_AutoDownloaderItem[] | undefined + isLoading: boolean +} + +export function AutoDownloaderItemList(props: AutoDownloaderItemListProps) { + + const { + children, + items: data, + isLoading, + ...rest + } = props + + const serverStatus = useServerStatus() + + const { mutate: deleteItem, isPending } = useDeleteAutoDownloaderItem() + + const { mutate: addMagnet, isPending: isAdding } = useTorrentClientAddMagnetFromRule() + + if (isLoading) return <LoadingSpinner /> + + return ( + <div className="space-y-4"> + <ul className="text-base text-[--muted]"> + <li> + The queue shows items waiting to be downloaded or scanned. + </li> + {/* <li> + Removing an item from the queue can cause it to be re-added if the rule is still active and the episode isn't downloaded and scanned. + </li> */} + </ul> + {!data?.length && ( + <p className="text-center text-[--muted]"> + Queue is empty + </p> + )} + {data?.map((item) => ( + <div className="rounded-[--radius] p-3 bg-gray-900" key={item.id}> + <div className="flex items-center justify-between"> + <div> + <h3 className="text-base font-medium tracking-wide">{item.torrentName}</h3> + <p className="text-base text-gray-400 flex gap-2 items-center"> + {item.downloaded && <span className="text-green-200">File downloaded </span>} + {!item.downloaded && <span className="text-brand-300 italic">Queued </span>} + {item.createdAt && formatDateAndTimeShort(item.createdAt)} + </p> + {item.downloaded && ( + <p className="text-sm text-[--muted]"> + Not yet scanned + </p> + )} + </div> + <div className="flex gap-2 items-center"> + {!item.downloaded && ( + <> + {!serverStatus?.settings?.autoDownloader?.useDebrid ? ( + <Button + leftIcon={<BiDownload />} + size="sm" + intent="primary-subtle" + onClick={() => { + addMagnet({ + magnetUrl: item.magnet, + ruleId: item.ruleId, + queuedItemId: item.id, + }) + }} + loading={isAdding} + disabled={isPending} + > + Download + </Button> + ) : ( + <SeaLink href="/debrid"> + <Button + leftIcon={<BiDownload />} + size="sm" + intent="primary-subtle" + disabled={isPending} + > + Download + </Button> + </SeaLink> + )} + </> + )} + <Button + leftIcon={<BiTrash />} + size="sm" + intent="alert" + onClick={() => { + deleteItem({ id: item.id }) + }} + disabled={isPending || isAdding} + loading={isPending} + > + Delete + </Button> + </div> + </div> + </div> + ))} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-page.tsx new file mode 100644 index 0000000..2083561 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-page.tsx @@ -0,0 +1,261 @@ +import { useGetAutoDownloaderItems, useGetAutoDownloaderRules, useRunAutoDownloader } from "@/api/hooks/auto_downloader.hooks" +import { useSaveAutoDownloaderSettings } from "@/api/hooks/settings.hooks" +import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { AutoDownloaderRuleItem } from "@/app/(main)/auto-downloader/_components/autodownloader-rule-item" +import { AutoDownloaderBatchRuleForm } from "@/app/(main)/auto-downloader/_containers/autodownloader-batch-rule-form" +import { AutoDownloaderItemList } from "@/app/(main)/auto-downloader/_containers/autodownloader-item-list" +import { AutoDownloaderRuleForm } from "@/app/(main)/auto-downloader/_containers/autodownloader-rule-form" +import { SettingsCard } from "@/app/(main)/settings/_components/settings-card" +import { tabsListClass, tabsTriggerClass } from "@/components/shared/classnames" +import { Alert } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Drawer } from "@/components/ui/drawer" +import { defineSchema, Field, Form } from "@/components/ui/form" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { useBoolean } from "@/hooks/use-disclosure" +import { useAtomValue } from "jotai/react" +import React from "react" +import { BiPlus } from "react-icons/bi" +import { FaSquareRss } from "react-icons/fa6" +import { toast } from "sonner" + +const settingsSchema = defineSchema(({ z }) => z.object({ + interval: z.number().transform(n => { + if (n < 15) { + toast.info("Interval changed to be at least 15 minutes") + return 15 + } + return n + }), + enabled: z.boolean(), + downloadAutomatically: z.boolean(), + enableEnhancedQueries: z.boolean(), + enableSeasonCheck: z.boolean(), + useDebrid: z.boolean(), +})) + +export function AutoDownloaderPage() { + const serverStatus = useServerStatus() + const userMedia = useAtomValue(__anilist_userAnimeMediaAtom) + + const createRuleModal = useBoolean(false) + const createBatchRuleModal = useBoolean(false) + + const { mutate: runAutoDownloader, isPending: isRunning } = useRunAutoDownloader() + + const { mutate: updateSettings, isPending } = useSaveAutoDownloaderSettings() + + const { data, isLoading } = useGetAutoDownloaderRules() + + const { data: items, isLoading: itemsLoading } = useGetAutoDownloaderItems() + + return ( + <div className="space-y-4"> + + <Tabs + defaultValue="rules" + triggerClass={tabsTriggerClass} + listClass={tabsListClass} + > + <TabsList> + <TabsTrigger value="rules">Rules</TabsTrigger> + <TabsTrigger value="queue"> + Queue + {!!items?.length && ( + <Badge className="ml-1 font-bold" intent="alert"> + {items.length} + </Badge> + )} + </TabsTrigger> + <TabsTrigger value="settings">Settings</TabsTrigger> + </TabsList> + <TabsContent value="rules"> + <div className="pt-4"> + {isLoading && <LoadingSpinner />} + {!isLoading && ( + <div className="space-y-4"> + <div className="w-full flex items-center gap-2"> + <Button + className="rounded-full" + intent="primary-subtle" + leftIcon={<FaSquareRss />} + onClick={() => { + runAutoDownloader() + }} + loading={isRunning} + disabled={!serverStatus?.settings?.autoDownloader?.enabled} + > + Check RSS feed + </Button> + <div className="flex flex-1"></div> + <Button + className="rounded-full" + intent="success-subtle" + leftIcon={<BiPlus />} + onClick={() => { + createRuleModal.on() + }} + > + New Rule + </Button> + <Button + className="rounded-full" + intent="gray-subtle" + leftIcon={<BiPlus />} + onClick={() => { + createBatchRuleModal.on() + }} + > + New Rules + </Button> + </div> + + <ul className="text-base text-[--muted]"> + <li><em className="font-semibold">Rules</em> allow you to programmatically download new episodes based on the + parameters you set. + </li> + </ul> + + {(!data?.length) && <div className="p-4 text-[--muted] text-center">No rules</div>} + {(!!data?.length) && <div className="space-y-4"> + {data?.map(rule => ( + <AutoDownloaderRuleItem + key={rule.dbId} + rule={rule} + userMedia={userMedia} + /> + ))} + </div>} + </div> + )} + </div> + </TabsContent> + + + <TabsContent value="queue"> + + <div className="pt-4"> + <AutoDownloaderItemList items={items} isLoading={itemsLoading} /> + </div> + + </TabsContent> + + <TabsContent value="settings"> + <div className="pt-4"> + <Form + schema={settingsSchema} + onSubmit={data => { + updateSettings(data) + }} + defaultValues={{ + enabled: serverStatus?.settings?.autoDownloader?.enabled ?? false, + interval: serverStatus?.settings?.autoDownloader?.interval || 15, + downloadAutomatically: serverStatus?.settings?.autoDownloader?.downloadAutomatically ?? false, + enableEnhancedQueries: serverStatus?.settings?.autoDownloader?.enableEnhancedQueries ?? false, + enableSeasonCheck: serverStatus?.settings?.autoDownloader?.enableSeasonCheck ?? false, + useDebrid: serverStatus?.settings?.autoDownloader?.useDebrid ?? false, + }} + stackClass="space-y-6" + > + {(f) => ( + <> + <SettingsCard> + <Field.Switch + side="right" + label="Enabled" + name="enabled" + /> + + <Field.Switch + side="right" + label="Use Debrid service" + name="useDebrid" + /> + + {f.watch("useDebrid") && !(serverStatus?.debridSettings?.enabled && !!serverStatus?.debridSettings?.provider) && ( + <Alert + intent="alert" + title="Auto Downloader deactivated" + description="Debrid service is not enabled or configured. Please enable it in the settings." + /> + )} + </SettingsCard> + + <SettingsCard + className={cn( + !f.watch("enabled") && "pointer-events-none opacity-50", + )} + > + <Field.Switch + side="right" + label="Use enhanced queries" + name="enableEnhancedQueries" + help="Seanime will use multiple custom queries instead of a single one. Enable this if you notice some missing downloads." + /> + <Field.Switch + side="right" + label="Verify season" + name="enableSeasonCheck" + help="Seanime will perform an additional check to ensure the season number is correct. This is not needed in most cases." + /> + <Field.Switch + side="right" + label="Download episodes immediately" + name="downloadAutomatically" + help="If disabled, torrents will be added to the queue." + /> + <Field.Number + label="Interval" + help="How often to check for new episodes." + name="interval" + leftAddon="Every" + rightAddon="minutes" + size="sm" + className="text-center w-20" + min={15} + /> + </SettingsCard> + + <Field.Submit role="save" loading={isPending}>Save</Field.Submit> + </> + )} + </Form> + </div> + </TabsContent> + + </Tabs> + + + <Modal + open={createRuleModal.active} + onOpenChange={createRuleModal.off} + title="Create a new rule" + contentClass="max-w-3xl" + > + <AutoDownloaderRuleForm type="create" onRuleCreatedOrDeleted={() => createRuleModal.off()} /> + </Modal> + + + <Drawer + open={createBatchRuleModal.active} + onOpenChange={createBatchRuleModal.off} + title="Create new rules" + size="xl" + > + <p className="text-[--muted] py-4"> + Create multiple rules at once. Each rule will be created with the same parameters, except for the destination folder. + By default, the episode type will be "Recent releases". + </p> + <AutoDownloaderBatchRuleForm onRuleCreated={() => createBatchRuleModal.off()} /> + </Drawer> + </div> + ) + +} + + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-rule-form.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-rule-form.tsx new file mode 100644 index 0000000..5d66be8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/_containers/autodownloader-rule-form.tsx @@ -0,0 +1,474 @@ +import { + AL_BaseAnime, + Anime_AutoDownloaderRule, + Anime_AutoDownloaderRuleEpisodeType, + Anime_AutoDownloaderRuleTitleComparisonType, + Anime_LibraryCollection, +} from "@/api/generated/types" +import { useCreateAutoDownloaderRule, useDeleteAutoDownloaderRule, useUpdateAutoDownloaderRule } from "@/api/hooks/auto_downloader.hooks" +import { useAnilistUserAnime } from "@/app/(main)/_hooks/anilist-collection-loader" +import { useLibraryCollection } from "@/app/(main)/_hooks/anime-library-collection-loader" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { CloseButton, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { DangerZone, defineSchema, Field, Form, InferType } from "@/components/ui/form" +import { Select } from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { TextInput } from "@/components/ui/text-input" +import { upath } from "@/lib/helpers/upath" +import { uniq } from "lodash" +import Image from "next/image" +import React from "react" +import { useFieldArray, UseFormReturn, useWatch } from "react-hook-form" +import { BiPlus } from "react-icons/bi" +import { FcFolder } from "react-icons/fc" +import { LuTextCursorInput } from "react-icons/lu" +import { MdVerified } from "react-icons/md" +import { toast } from "sonner" + +type AutoDownloaderRuleFormProps = { + type: "create" | "edit" + rule?: Anime_AutoDownloaderRule + mediaId?: number + onRuleCreatedOrDeleted?: () => void +} + +const schema = defineSchema(({ z }) => z.object({ + enabled: z.boolean(), + mediaId: z.number().min(1), + releaseGroups: z.array(z.string()).transform(value => uniq(value.filter(Boolean))), + resolutions: z.array(z.string()).transform(value => uniq(value.filter(Boolean))), + episodeNumbers: z.array(z.number()).transform(value => uniq(value.filter(Boolean))), + additionalTerms: z.array(z.string()).transform(value => uniq(value.filter(Boolean))), + comparisonTitle: z.string().min(1), + titleComparisonType: z.string(), + episodeType: z.string(), + destination: z.string().min(1), +})) + +export function AutoDownloaderRuleForm(props: AutoDownloaderRuleFormProps) { + + const { + type, + rule, + onRuleCreatedOrDeleted, + mediaId, + } = props + + const userMedia = useAnilistUserAnime() + const libraryCollection = useLibraryCollection() + + const allMedia = React.useMemo(() => { + return userMedia ?? [] + }, [userMedia]) + + // Upcoming & airing media + const notFinishedMedia = React.useMemo(() => { + return allMedia.filter(media => media.status !== "FINISHED") + }, [allMedia]) + + const { mutate: createRule, isPending: creatingRule } = useCreateAutoDownloaderRule() + + const { mutate: updateRule, isPending: updatingRule } = useUpdateAutoDownloaderRule() + + const { mutate: deleteRule, isPending: deletingRule } = useDeleteAutoDownloaderRule(rule?.dbId) + + const isPending = creatingRule || updatingRule || deletingRule + + function handleSave(data: InferType<typeof schema>) { + if (data.episodeType === "selected" && data.episodeNumbers.length === 0) { + return toast.error("You must specify at least one episode number") + } + if (type === "create") { + createRule({ + ...data, + titleComparisonType: data.titleComparisonType as Anime_AutoDownloaderRuleTitleComparisonType, + episodeType: data.episodeType as Anime_AutoDownloaderRuleEpisodeType, + }, { + onSuccess: () => onRuleCreatedOrDeleted?.(), + }) + } + if (type === "edit" && rule?.dbId) { + updateRule({ + rule: { + ...data, + dbId: rule.dbId || 0, + titleComparisonType: data.titleComparisonType as Anime_AutoDownloaderRuleTitleComparisonType, + episodeType: data.episodeType as Anime_AutoDownloaderRuleEpisodeType, + }, + }, { + onSuccess: () => onRuleCreatedOrDeleted?.(), + }) + } + } + + if (type === "create" && allMedia.length === 0) { + return <div className="p-4 text-[--muted] text-center">No media found in your library</div> + } + + return ( + <div className="space-y-4 mt-2"> + <Form + schema={schema} + onSubmit={handleSave} + defaultValues={{ + enabled: rule?.enabled ?? true, + mediaId: mediaId ?? rule?.mediaId ?? notFinishedMedia[0]?.id, + releaseGroups: rule?.releaseGroups ?? [], + resolutions: rule?.resolutions ?? [], + comparisonTitle: rule?.comparisonTitle ?? "", + titleComparisonType: rule?.titleComparisonType ?? "likely", + episodeType: rule?.episodeType ?? "recent", + episodeNumbers: rule?.episodeNumbers ?? [], + destination: rule?.destination ?? "", + additionalTerms: rule?.additionalTerms ?? [], + }} + onError={() => { + toast.error("An error occurred, verify the fields.") + }} + > + {(f) => <RuleFormFields + form={f} + allMedia={allMedia} + mediaId={mediaId} + type={type} + isPending={isPending} + notFinishedMedia={notFinishedMedia} + libraryCollection={libraryCollection} + rule={rule} + />} + </Form> + {type === "edit" && <DangerZone + actionText="Delete this rule" + onDelete={() => { + if (rule?.dbId) { + deleteRule() + } + }} + />} + </div> + ) +} + +type RuleFormFieldsProps = { + form: UseFormReturn<InferType<typeof schema>> + allMedia: AL_BaseAnime[] + mediaId?: number + type: "create" | "edit" + isPending: boolean + notFinishedMedia: AL_BaseAnime[] + libraryCollection?: Anime_LibraryCollection | undefined + rule?: Anime_AutoDownloaderRule +} + +export function RuleFormFields(props: RuleFormFieldsProps) { + + const { + form, + allMedia, + mediaId, + type, + isPending, + notFinishedMedia, + libraryCollection, + rule, + ...rest + } = props + + const serverStatus = useServerStatus() + + const form_mediaId = useWatch({ name: "mediaId" }) as number + const form_episodeType = useWatch({ name: "episodeType" }) as Anime_AutoDownloaderRuleEpisodeType + + const selectedMedia = allMedia.find(media => media.id === Number(form_mediaId)) + + React.useEffect(() => { + const id = Number(form_mediaId) + const destination = libraryCollection?.lists?.flatMap(list => list.entries)?.find(entry => entry?.media?.id === id)?.libraryData?.sharedPath + if (!isNaN(id) && !rule?.comparisonTitle) { + const media = allMedia.find(media => media.id === id) + if (media) { + form.setValue("comparisonTitle", media.title?.romaji || "") + } + } + // If no rule is passed, set the comparison title to the media title + if (!rule) { + if (destination) { + form.setValue("destination", destination) + } else if (type === "create") { + // form.setValue("destination", "") + const newDestination = upath.join(upath.normalizeSafe(serverStatus?.settings?.library?.libraryPath || ""), + sanitizeDirectoryName(selectedMedia?.title?.romaji || selectedMedia?.title?.english || "")) + form.setValue("destination", newDestination) + } + } + }, [form_mediaId, selectedMedia, libraryCollection, rule]) + + if (!selectedMedia) { + return <div className="p-4 text-[--muted] text-center">Media is not in your library</div> + } + // if (type === "create" && selectedMedia?.status != "RELEASING") { + // return <div className="p-4 text-[--muted] text-center">You can only create rules for airing anime</div> + // } + + return ( + <> + <Field.Switch name="enabled" label="Enabled" /> + <Separator /> + <div + className={cn( + "space-y-3", + // !form.watch("enabled") && "opacity-50 pointer-events-none", + )} + > + {!mediaId && <div className="flex gap-4 items-end"> + <div + className="w-[6rem] h-[6rem] rounded-[--radius] flex-none object-cover object-center overflow-hidden relative bg-gray-800" + > + {!!selectedMedia?.coverImage?.large && <Image + src={selectedMedia.coverImage.large} + alt="banner" + fill + quality={80} + priority + sizes="20rem" + className="object-cover object-center" + />} + </div> + <Select + name="mediaId" + label="Library Entry" + options={notFinishedMedia.map(media => ({ label: media.title?.userPreferred || "N/A", value: String(media.id) })) + .toSorted((a, b) => a.label.localeCompare(b.label))} + value={String(form_mediaId)} + onValueChange={(v) => form.setValue("mediaId", parseInt(v))} + help={!mediaId ? "The anime must be airing or upcoming" : undefined} + disabled={type === "edit" || !!mediaId} + /> + </div>} + + {selectedMedia?.status === "FINISHED" && <div className="py-2 text-red-300 text-center">This anime is no longer airing</div>} + + <Field.DirectorySelector + name="destination" + label="Destination" + help="Folder in your local library where the files will be saved" + leftIcon={<FcFolder />} + shouldExist={false} + /> + + <div className="border rounded-[--radius] p-4 relative !mt-8 space-y-3"> + <div className="absolute -top-2.5 tracking-wide font-semibold uppercase text-sm left-4 bg-gray-950 px-2">Title</div> + <Field.Text + name="comparisonTitle" + label="Comparison title" + help="Used for comparison purposes. When using 'Exact match', use a title most likely to be used in a torrent name." + /> + <Field.RadioCards + label="Type of search" + name="titleComparisonType" + options={[ + { + label: <div className="w-full"> + <p className="mb-1 flex items-center"><MdVerified className="text-lg inline-block mr-2" />Most likely</p> + <p className="font-normal text-sm text-[--muted]">The torrent name will be parsed and analyzed using a comparison + algorithm</p> + </div>, + value: "likely", + }, + { + label: <div className="w-full"> + <p className="mb-1 flex items-center"><LuTextCursorInput className="text-lg inline-block mr-2" />Exact match</p> + <p className="font-normal text-sm text-[--muted]">The torrent name must contain the comparison title you set (case + insensitive)</p> + </div>, + value: "contains", + }, + ]} + /> + </div> + <div + className={cn( + "border rounded-[--radius] p-4 relative !mt-8 space-y-3", + (selectedMedia?.format === "MOVIE" || (!!selectedMedia.episodes && selectedMedia.episodes === 1)) && "opacity-50 pointer-events-none", + )} + > + <div className="absolute -top-2.5 tracking-wide font-semibold uppercase text-sm left-4 bg-gray-950 px-2">Episodes</div> + <Field.RadioCards + name="episodeType" + label="Episodes to look for" + options={[ + { + label: <div className="w-full"> + <p>Recent releases</p> + <p className="font-normal text-sm text-[--muted]">New episodes you have not yet watched</p> + </div>, + value: "recent", + }, + { + label: <div className="w-full"> + <p>Select</p> + <p className="font-normal text-sm text-[--muted]">Only the specified episodes that aren't in your library</p> + </div>, + value: "selected", + }, + ]} + /> + + {form_episodeType === "selected" && <TextArrayField + label="Episode numbers" + name="episodeNumbers" + control={form.control} + type="number" + />} + </div> + + <div className="border rounded-[--radius] p-4 relative !mt-8 space-y-3"> + <div className="absolute -top-2.5 tracking-wide font-semibold uppercase text-sm left-4 bg-gray-950 px-2">Release Groups</div> + <p className="text-sm"> + List of release groups to look for. If empty, any release group will be accepted. + </p> + + <TextArrayField + name="releaseGroups" + control={form.control} + type="text" + placeholder="e.g. SubsPlease" + separatorText="OR" + /> + </div> + + <div className="border rounded-[--radius] p-4 relative !mt-8 space-y-3"> + <div className="absolute -top-2.5 tracking-wide font-semibold uppercase text-sm left-4 bg-gray-950 px-2">Resolutions</div> + <p className="text-sm"> + List of resolutions to look for. If empty, the highest resolution will be accepted. + </p> + + <TextArrayField + name="resolutions" + control={form.control} + type="text" + placeholder="e.g. 1080p" + separatorText="OR" + /> + </div> + + <Accordion type="single" collapsible className="!my-4" defaultValue={!!rule?.additionalTerms?.length ? "more" : undefined}> + <AccordionItem value="more"> + <AccordionTrigger className="border rounded-[--radius] bg-gray-900"> + More filters + </AccordionTrigger> + <AccordionContent className="pt-0"> + <div className="border rounded-[--radius] p-4 relative !mt-8 space-y-3"> + <div className="absolute -top-2.5 tracking-wide font-semibold uppercase text-sm left-4 bg-gray-950 px-2">Additional + terms + </div> + <div> + {/*<p className="text-sm">*/} + {/* List of video terms to look for. If any term is found in the torrent name, it will be accepted.*/} + {/*</p>*/} + <p className="text-sm -top-2 relative"><span className="text-red-100"> + All options must be included for the torrent to be accepted.</span> Within each option, you can + include variations separated by + commas. For example, adding + "H265,H.265, H 265,x265" and + "10bit,10-bit,10 bit" will match + <code className="text-gray-400"> [Group] Torrent name [HEVC 10bit + x265]</code> but not <code className="text-gray-400">[Group] Torrent name + [H265]</code>. Case + insensitive.</p> + </div> + + <TextArrayField + name="additionalTerms" + control={form.control} + // value={additionalTerms} + // onChange={(value) => form.setValue("additionalTerms", value)} + type="text" + placeholder="e.g. H265,H.265,H 265,x265" + separatorText="AND" + /> + </div> + </AccordionContent> + </AccordionItem> + </Accordion> + + </div> + {type === "create" && + <Field.Submit role="create" loading={isPending} disableOnSuccess={false} showLoadingOverlayOnSuccess>Create</Field.Submit>} + {type === "edit" && <Field.Submit role="update" loading={isPending}>Update</Field.Submit>} + </> + ) +} + +type TextArrayFieldProps<T extends string | number> = { + name: string + control: any + type?: "text" | "number" + label?: string + placeholder?: string + separatorText?: string +} + +export function TextArrayField<T extends string | number>(props: TextArrayFieldProps<T>) { + const { fields, append, remove } = useFieldArray({ + control: props.control, + name: props.name, + }) + + return ( + <div className="space-y-2"> + {props.label && <div className="flex items-center"> + <div className="text-base font-semibold">{props.label}</div> + </div>} + {fields.map((field, index) => ( + <React.Fragment key={field.id}> + <div className="flex gap-2 items-center"> + {props.type === "text" && ( + <TextInput + {...props.control.register(`${props.name}.${index}`)} + placeholder={props.placeholder} + /> + )} + {props.type === "number" && ( + <TextInput + type="number" + {...props.control.register(`${props.name}.${index}`, { + valueAsNumber: true, + min: 1, + validate: (value: number) => !isNaN(value), + })} + /> + )} + <CloseButton + size="sm" + intent="alert-subtle" + onClick={() => remove(index)} + /> + </div> + {(!!props.separatorText && index < fields.length - 1) && ( + <p className="text-center text-[--muted]">{props.separatorText}</p> + )} + </React.Fragment> + ))} + <IconButton + intent="success" + className="rounded-full" + onClick={() => append(props.type === "number" ? 1 : "")} + icon={<BiPlus />} + /> + </div> + ) +} + + +function sanitizeDirectoryName(input: string): string { + const disallowedChars = /[<>:"/\\|?*\x00-\x1F.!`]/g // Pattern for disallowed characters + // Replace disallowed characters with an underscore + const sanitized = input.replace(disallowedChars, " ") + // Remove leading/trailing spaces and dots (periods) which are not allowed + const trimmed = sanitized.trim().replace(/^\.+|\.+$/g, "").replace(/\s+/g, " ") + // Ensure the directory name is not empty after sanitization + return trimmed || "Untitled" +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/layout.tsx new file mode 100644 index 0000000..949dc21 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/layout.tsx @@ -0,0 +1,18 @@ +"use client" +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + + +export default function Layout({ children }: { children: React.ReactNode }) { + + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) + +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/page.tsx new file mode 100644 index 0000000..ff573ae --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/auto-downloader/page.tsx @@ -0,0 +1,28 @@ +"use client" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { AutoDownloaderPage } from "@/app/(main)/auto-downloader/_containers/autodownloader-page" +import { PageWrapper } from "@/components/shared/page-wrapper" +import React from "react" + +export const dynamic = "force-static" + +export default function Page() { + + return ( + <> + <CustomLibraryBanner discrete /> + <PageWrapper className="p-4 sm:p-8 space-y-4"> + <div className="flex justify-between items-center w-full relative"> + <div> + <h2>Auto Downloader</h2> + <p className="text-[--muted]"> + Add and manage auto-downloading rules for your favorite anime. + </p> + </div> + </div> + <AutoDownloaderPage /> + </PageWrapper> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/debrid/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/debrid/layout.tsx new file mode 100644 index 0000000..995ee96 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/debrid/layout.tsx @@ -0,0 +1,16 @@ +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) + +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/debrid/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/debrid/page.tsx new file mode 100644 index 0000000..c6ed575 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/debrid/page.tsx @@ -0,0 +1,386 @@ +"use client" +import { Debrid_TorrentItem } from "@/api/generated/types" +import { useDebridCancelDownload, useDebridDeleteTorrent, useDebridDownloadTorrent, useDebridGetTorrents } from "@/api/hooks/debrid.hooks" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { LuffyError } from "@/components/shared/luffy-error" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { SeaLink } from "@/components/shared/sea-link" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { defineSchema, Field, Form } from "@/components/ui/form" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { Tooltip } from "@/components/ui/tooltip" +import { WSEvents } from "@/lib/server/ws-events" +import { formatDate } from "date-fns" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import capitalize from "lodash/capitalize" +import React from "react" +import { BiDownArrow, BiLinkExternal, BiRefresh, BiTime, BiTrash, BiX } from "react-icons/bi" +import { FiDownload } from "react-icons/fi" +import { HiFolderDownload } from "react-icons/hi" +import { toast } from "sonner" + +export const dynamic = "force-static" + +function getServiceName(provider: string) { + switch (provider) { + case "realdebrid": + return "Real-Debrid" + case "torbox": + return "TorBox" + default: + return provider + } +} + +function getDashboardLink(provider: string) { + switch (provider) { + case "torbox": + return "https://torbox.app/dashboard" + case "realdebrid": + return "https://real-debrid.com/torrents" + default: + return "" + } +} + +export default function Page() { + const serverStatus = useServerStatus() + + if (!serverStatus) return <LoadingSpinner /> + + if (!serverStatus?.debridSettings?.enabled || !serverStatus?.debridSettings?.provider) return <LuffyError + title="Debrid not enabled" + > + Debrid service is not enabled or configured + </LuffyError> + + return ( + <> + <CustomLibraryBanner discrete /> + <PageWrapper + className="space-y-4 p-4 sm:p-8" + > + <Content /> + </PageWrapper> + <TorrentItemModal /> + </> + ) +} + +function Content() { + const serverStatus = useServerStatus() + const [enabled, setEnabled] = React.useState(true) + const [refetchInterval, setRefetchInterval] = React.useState(30000) + + const { data, isLoading, status, refetch } = useDebridGetTorrents(enabled, refetchInterval) + + React.useEffect(() => { + const hasDownloads = data?.filter(t => t.status === "downloading" || t.status === "paused")?.length ?? 0 + setRefetchInterval(hasDownloads ? 5000 : 30000) + }, [data]) + + React.useEffect(() => { + if (status === "error") { + setEnabled(false) + } + }, [status]) + + if (!enabled) return <LuffyError title="Failed to connect"> + <div className="flex flex-col gap-4 items-center"> + <p className="max-w-md">Failed to connect to the Debrid service, verify your settings.</p> + <Button + intent="primary-subtle" onClick={() => { + setEnabled(true) + }} + >Retry</Button> + </div> + </LuffyError> + + if (isLoading) return <LoadingSpinner /> + + return ( + <> + <div className="flex items-center w-full"> + <div> + <h2>{getServiceName(serverStatus?.debridSettings?.provider!)}</h2> + <p className="text-[--muted]"> + See your debrid service torrents + </p> + </div> + <div className="flex flex-1"></div> + <div className="flex gap-2 items-center"> + <Button + intent="white-subtle" + leftIcon={<BiRefresh className="text-2xl" />} + onClick={() => { + refetch() + toast.info("Refreshed") + }} + >Refresh</Button> + {!!getDashboardLink(serverStatus?.debridSettings?.provider!) && ( + <SeaLink href={getDashboardLink(serverStatus?.debridSettings?.provider!)} target="_blank"> + <Button + intent="primary-subtle" + rightIcon={<BiLinkExternal className="text-xl" />} + >Dashboard</Button> + </SeaLink> + )} + </div> + </div> + + <div className="pb-10"> + <AppLayoutStack className={""}> + + <div> + <ul className="text-[--muted] flex flex-wrap gap-4"> + <li>Downloading: {data?.filter(t => t.status === "downloading" || t.status === "paused")?.length ?? 0}</li> + <li>Seeding: {data?.filter(t => t.status === "seeding")?.length ?? 0}</li> + </ul> + </div> + + {data?.filter(Boolean)?.map(torrent => { + return <TorrentItem + key={torrent.id} + torrent={torrent} + /> + })} + {(!isLoading && !data?.length) && <LuffyError title="Nothing to see">No active torrents</LuffyError>} + </AppLayoutStack> + </div> + </> + ) + +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +const selectedTorrentItemAtom = atom<Debrid_TorrentItem | null>(null) + + +type TorrentItemProps = { + torrent: Debrid_TorrentItem + isPending?: boolean +} + +type DownloadProgress = { + status: string + itemID: string + totalBytes: string + totalSize: string + speed: string +} + +const TorrentItem = React.memo(function TorrentItem({ torrent, isPending }: TorrentItemProps) { + + const { mutate: deleteTorrent, isPending: isDeleting } = useDebridDeleteTorrent() + + const { mutate: cancelDownload, isPending: isCancelling } = useDebridCancelDownload() + + const [_, setSelectedTorrentItem] = useAtom(selectedTorrentItemAtom) + + const confirmDeleteTorrentProps = useConfirmationDialog({ + title: "Remove torrent", + description: "This action cannot be undone.", + onConfirm: () => { + deleteTorrent({ + torrentItem: torrent, + }) + }, + }) + + const [progress, setProgress] = React.useState<DownloadProgress | null>(null) + + useWebsocketMessageListener<DownloadProgress>({ + type: WSEvents.DEBRID_DOWNLOAD_PROGRESS, + onMessage: data => { + if (data.itemID === torrent.id) { + if (data.status === "downloading") { + setProgress(data) + } else { + setProgress(null) + } + } + }, + }) + + function handleCancelDownload() { + cancelDownload({ + itemID: torrent.id, + }) + } + + return ( + <div className="p-4 border rounded-[--radius-md] overflow-hidden relative flex gap-2"> + <div className="absolute top-0 w-full h-1 z-[1] bg-gray-700 left-0"> + <div + className={cn( + "h-1 absolute z-[2] left-0 bg-gray-200 transition-all", + { + "bg-green-300": torrent.status === "downloading", + "bg-gray-500": torrent.status === "paused", + "bg-blue-500": torrent.status === "seeding", + "bg-gray-600": torrent.status === "completed", + "bg-orange-800": torrent.status === "other", + }, + )} + style={{ width: `${String(Math.floor(torrent.completionPercentage))}%` }} + ></div> + </div> + <div className="w-full"> + <div + className={cn({ + "opacity-50": torrent.status === "paused", + })} + >{torrent.name}</div> + <div className="text-[--muted]"> + <span className={cn({ "text-green-300": torrent.status === "downloading" })}>{torrent.completionPercentage}%</span> + {` `} + <BiDownArrow className="inline-block mx-2" /> + {torrent.speed} + {(torrent.eta && torrent.status === "downloading") && <> + {` `} + <BiTime className="inline-block mx-2 mb-0.5" /> + {torrent.eta} + </>} + {` - `} + <span className="text-[--foreground]"> + {formatDate(torrent.added, "yyyy-MM-dd HH:mm")} + </span> + {` - `} + <strong + className={cn( + torrent.status === "seeding" && "text-blue-300", + torrent.status === "completed" && "text-green-300", + )} + >{(torrent.status === "other" || !torrent.isReady) ? "" : capitalize(torrent.status)}</strong> + </div> + </div> + <div className="flex-none flex gap-2 items-center"> + {(torrent.isReady && !progress) && <Button + leftIcon={<FiDownload />} + size="sm" + intent="white-subtle" + className="flex-none" + disabled={isDeleting || isCancelling} + onClick={() => { + setSelectedTorrentItem(torrent) + }} + > + Download + </Button>} + {(!!progress && progress.itemID === torrent.id) && <div className="flex gap-2 items-center"> + <Tooltip + trigger={<p> + <HiFolderDownload className="text-2xl animate-pulse text-[--blue]" /> + </p>} + > + Downloading locally + </Tooltip> + <p> + {progress?.totalBytes}<span className="text-[--muted]"> / {progress?.totalSize}</span> + </p> + <Tooltip + trigger={<p> + <IconButton + icon={<BiX className="text-xl" />} + intent="gray-subtle" + rounded + size="sm" + onClick={handleCancelDownload} + loading={isCancelling} + /> + </p>} + > + Cancel download + </Tooltip> + </div>} + <IconButton + icon={<BiTrash />} + size="sm" + intent="alert-subtle" + className="flex-none" + onClick={async () => { + confirmDeleteTorrentProps.open() + }} + disabled={isCancelling} + loading={isDeleting} + /> + </div> + <ConfirmationDialog {...confirmDeleteTorrentProps} /> + </div> + ) +}) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type TorrentItemModalProps = {} + +const downloadSchema = defineSchema(({ z }) => z.object({ + destination: z.string().min(2), +})) + +function TorrentItemModal(props: TorrentItemModalProps) { + + const { + ...rest + } = props + + const serverStatus = useServerStatus() + const [selectedTorrentItem, setSelectedTorrentItem] = useAtom(selectedTorrentItemAtom) + const { mutate: downloadTorrent, isPending: isDownloading } = useDebridDownloadTorrent() + + return ( + <Modal + open={!!selectedTorrentItem} + onOpenChange={() => { + setSelectedTorrentItem(null) + }} + title="Download" + contentClass="max-w-2xl" + > + <p className="text-center line-clamp-2 text-sm"> + {selectedTorrentItem?.name} + </p> + + <Form + schema={downloadSchema} + onSubmit={data => { + downloadTorrent({ + torrentItem: selectedTorrentItem!, + destination: data.destination, + }, { + onSuccess: () => { + setSelectedTorrentItem(null) + }, + }) + }} + defaultValues={{ + destination: serverStatus?.settings?.library?.libraryPath ?? "", + }} + > + <Field.DirectorySelector + name="destination" + label="Destination" + shouldExist={false} + help="Where to save the torrent" + /> + <div className="flex justify-end"> + <Field.Submit + intent="white" + leftIcon={<FiDownload className="text-xl" />} + loading={isDownloading} + > + Download + </Field.Submit> + </div> + </Form> + </Modal> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/_components/discover-page-header.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_components/discover-page-header.tsx new file mode 100644 index 0000000..6b0d2e9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_components/discover-page-header.tsx @@ -0,0 +1,435 @@ +import { AL_BaseAnime } from "@/api/generated/types" +import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles" +import { MediaEntryAudienceScore } from "@/app/(main)/_features/media/_components/media-entry-metadata-components" +import { useMediaPreviewModal } from "@/app/(main)/_features/media/_containers/media-preview-modal" +import { + __discover_animeRandomNumberAtom, + __discover_animeTotalItemsAtom, + __discover_headerIsTransitioningAtom, + __discover_randomTrendingAtom, + __discover_setAnimeRandomNumberAtom, +} from "@/app/(main)/discover/_containers/discover-trending" +import { __discover_mangaTotalItemsAtom } from "@/app/(main)/discover/_containers/discover-trending-country" +import { __discord_pageTypeAtom } from "@/app/(main)/discover/_lib/discover.atoms" +import { imageShimmer } from "@/components/shared/image-helpers" +import { SeaLink } from "@/components/shared/sea-link" +import { TextGenerateEffect } from "@/components/shared/text-generate-effect" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { ScrollArea } from "@/components/ui/scroll-area" +import { ThemeMediaPageBannerSize, ThemeMediaPageBannerType, useThemeSettings } from "@/lib/theme/hooks" +import { __isDesktop__ } from "@/types/constants" +import { atom, useAtomValue } from "jotai" +import { useAtom, useSetAtom } from "jotai/react" +import { AnimatePresence, motion } from "motion/react" +import Image from "next/image" +import { usePathname } from "next/navigation" +import React from "react" +import { RiSignalTowerLine } from "react-icons/ri" +import { __discover_mangaRandomNumberAtom, __discover_setMangaRandomNumberAtom } from "../_containers/discover-trending-country" + + +export const __discover_hoveringHeaderAtom = atom(false) +export const __discover_clickedCarouselDotAtom = atom(0) + +const MotionImage = motion.create(Image) +const MotionIframe = motion.create("iframe") + +type HeaderCarouselDotsProps = { + className?: string +} + +function HeaderCarouselDots({ className }: HeaderCarouselDotsProps) { + const ts = useThemeSettings() + const [pageType] = useAtom(__discord_pageTypeAtom) + const pathname = usePathname() + + const setClickedCarouselDot = useSetAtom(__discover_clickedCarouselDotAtom) + + const animeRandomNumber = useAtomValue(__discover_animeRandomNumberAtom) + const animeTotalItems = useAtomValue(__discover_animeTotalItemsAtom) + const setAnimeRandomNumber = useSetAtom(__discover_setAnimeRandomNumberAtom) + + const mangaRandomNumber = useAtomValue(__discover_mangaRandomNumberAtom) + const mangaTotalItems = useAtomValue(__discover_mangaTotalItemsAtom) + const setMangaRandomNumber = useSetAtom(__discover_setMangaRandomNumberAtom) + + const currentIndex = pageType === "anime" ? animeRandomNumber : mangaRandomNumber + const totalItems = pageType === "anime" ? animeTotalItems : mangaTotalItems + const setCurrentIndex = pageType === "anime" ? setAnimeRandomNumber : setMangaRandomNumber + + // Don't render if there are no items or only one item + if (totalItems <= 1) return null + + const maxDots = Math.min(totalItems, 12) + + return ( + <div + data-discover-page-header-carousel-dots + className={cn( + "absolute hidden lg:flex items-center justify-center gap-2 z-[10] pl-8", + ts.hideTopNavbar && !__isDesktop__ && "top-[4rem]", + __isDesktop__ && "top-[2rem]", + ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && !__isDesktop__ && "top-[4.2rem]", + ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && __isDesktop__ && "top-[0.6rem]", + pathname === "/" && "hidden lg:hidden", + className, + )} + > + {Array.from({ length: maxDots }).map((_, index) => ( + <button + key={index} + className={cn( + "h-1.5 rounded-sm transition-all duration-300 cursor-pointer", + index === currentIndex ? "w-6 bg-[--muted]" : "w-3 bg-[--subtle] hover:bg-gray-300", + )} + onClick={() => { + setCurrentIndex(index) + setClickedCarouselDot(n => n + 1) + }} + aria-label={`Go to slide ${index + 1}`} + /> + ))} + </div> + ) +} + +export function DiscoverPageHeader() { + const ts = useThemeSettings() + const pathname = usePathname() + + const [pageType, setPageType] = useAtom(__discord_pageTypeAtom) + + const randomTrending = useAtomValue(__discover_randomTrendingAtom) + const isTransitioning = useAtomValue(__discover_headerIsTransitioningAtom) + + const [isHoveringHeader, setHoveringHeader] = useAtom(__discover_hoveringHeaderAtom) + const [showTrailer, setShowTrailer] = React.useState(false) + const [trailerLoaded, setTrailerLoaded] = React.useState(false) + const hoverTimerRef = React.useRef<NodeJS.Timeout | null>(null) + + // Reset page type to anime when on home page + React.useLayoutEffect(() => { + if (pathname === "/") { + setPageType("anime") + } + }, [pathname]) + + // Handle hover with timer + React.useEffect(() => { + if (isHoveringHeader && !showTrailer && (randomTrending as AL_BaseAnime)?.trailer?.id) { + hoverTimerRef.current = setTimeout(() => { + setShowTrailer(true) + }, 1000) // 1 second delay before showing trailer + } else if (!isHoveringHeader) { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current) + hoverTimerRef.current = null + } + setShowTrailer(false) + setTrailerLoaded(false) + } + + return () => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current) + hoverTimerRef.current = null + } + } + }, [isHoveringHeader, showTrailer, randomTrending]) + + const bannerImage = randomTrending?.bannerImage || randomTrending?.coverImage?.extraLarge + const trailerId = (randomTrending as AL_BaseAnime)?.trailer?.id + const trailerSite = (randomTrending as AL_BaseAnime)?.trailer?.site || "youtube" + + const shouldBlurBanner = (ts.mediaPageBannerType === ThemeMediaPageBannerType.BlurWhenUnavailable && !randomTrending?.bannerImage) + + const { setPreviewModalMediaId } = useMediaPreviewModal() + + return ( + <motion.div + data-discover-page-header + className={cn( + "__header lg:h-[28rem]", + ts.hideTopNavbar && "lg:h-[32rem]", + ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && "lg:h-[24rem]", + (ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && ts.hideTopNavbar) && "lg:h-[28rem]", + // (__isDesktop__ && ts.mediaPageBannerSize !== ThemeMediaPageBannerSize.Small) && "lg:h-[32rem]", + // (__isDesktop__ && ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small) && "lg:h-[33rem]", + )} + {...{ + initial: { opacity: 0 }, + animate: { opacity: 1 }, + transition: { + duration: 1.2, + }, + }} + > + {/* Carousel Rectangular Dots */} + <HeaderCarouselDots /> + <div + data-discover-page-header-banner-image-container + className={cn( + "lg:h-[35rem] w-full flex-none object-cover object-center absolute top-0 bg-[--background]", + !ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE, + __isDesktop__ && "top-[-2rem]", + ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && "lg:h-[30rem]", + )} + > + <div + data-discover-page-header-banner-image-top-gradient + className={cn( + "w-full z-[2] absolute bottom-[-10rem] h-[10rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent", + )} + /> + + <div + data-discover-page-header-banner-image-top-gradient-2 + className="w-full absolute z-[2] top-0 h-[10rem] opacity-50 bg-gradient-to-b from-[--background] to-transparent via" + /> + <div + data-discover-page-header-banner-image-background + className={cn( + "opacity-0 duration-1000 bg-[var(--background)] w-full h-full absolute z-[2]", + isTransitioning && "opacity-70", + )} + /> + <AnimatePresence> + <div className="w-full h-full absolute z-[1] overflow-hidden"> + {(!!bannerImage) && ( + <MotionImage + data-discover-page-header-banner-image + src={bannerImage} + alt="banner image" + fill + quality={100} + priority + className={cn( + "object-cover object-center z-[1] transition-all duration-1000", + isTransitioning && "scale-[1.01] -translate-x-0.5", + !isTransitioning && "scale-100 translate-x-0", + !randomTrending?.bannerImage && "opacity-35", + trailerLoaded && "opacity-0", + )} + /> + )} + </div> + </AnimatePresence> + + {/* Trailer */} + {(showTrailer && trailerId && trailerSite === "youtube") && ( + <div + data-discover-page-header-trailer-container + className={cn( + "absolute w-full h-full overflow-hidden z-[1]", + !trailerLoaded && "opacity-0", + )} + > + <MotionIframe + data-discover-page-header-trailer + src={`https://www.youtube-nocookie.com/embed/${trailerId}?autoplay=1&controls=0&mute=1&disablekb=1&loop=1&vq=medium&playlist=${trailerId}&cc_lang_pref=ja`} + className="w-full h-full absolute left-0 object-cover object-center lg:scale-[1.8] 2xl:scale-[2.5]" + allow="autoplay" + initial={{ opacity: 0 }} + animate={{ opacity: trailerLoaded ? 1 : 0 }} + transition={{ duration: 0.5 }} + onLoad={() => setTrailerLoaded(true)} + onError={() => { + setShowTrailer(false) + setTrailerLoaded(false) + }} + /> + </div> + )} + {shouldBlurBanner && <div + data-discover-page-header-banner-image-blur + className="absolute top-0 w-full h-full backdrop-blur-2xl z-[2] " + ></div>} + + {/*LEFT FADE*/} + <div + data-discover-page-header-banner-image-right-gradient + className={cn( + "hidden lg:block max-w-[80rem] w-full z-[2] h-full absolute left-0 bg-gradient-to-r from-[--background] from-5% via-[--background] transition-opacity via-opacity-50 via-5% to-transparent", + "opacity-100 duration-500", + )} + /> + + {/*LEFT FADE IF SIDEBAR IS TRANSPARENT*/} + {!ts.disableSidebarTransparency && <div + data-discover-page-header-banner-image-left-gradient + className={cn( + "hidden lg:block max-w-[10rem] w-full z-[2] h-full absolute left-0 bg-gradient-to-r from-[--background] via-[--background] transition-opacity via-opacity-50 via-5% to-transparent", + "opacity-70 duration-500", + )} + />} + <div + data-discover-page-header-banner-image-bottom-gradient + className="w-full z-[2] absolute bottom-0 h-[20rem] bg-gradient-to-t from-[--background] via-[--background] via-opacity-50 via-10% to-transparent" + /> + </div> + <AnimatePresence> + {(!!randomTrending && !isTransitioning && (pageType === "anime" || pageType === "manga")) && ( + <motion.div + data-discover-page-header-metadata-container + {...{ + initial: { opacity: 0, x: -40 }, + animate: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: -20 }, + transition: { + type: "spring", + damping: 20, + stiffness: 100, + }, + }} + className={cn( + "absolute left-2 w-fit h-[20rem] bg-gradient-to-t z-[3] hidden lg:block", + "top-[5rem]", + ts.hideTopNavbar && "top-[4rem]", + ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && "top-[4rem]", + (ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && ts.hideTopNavbar) && "top-[2rem]", + (__isDesktop__ && ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small) && "top-[0rem]", + (__isDesktop__ && ts.mediaPageBannerSize !== ThemeMediaPageBannerSize.Small) && "top-[2rem]", + )} + data-media-id={randomTrending?.id} + data-media-mal-id={randomTrending?.idMal} + > + <div + data-discover-page-header-metadata-inner-container + className="flex items-center relative gap-6 p-6 pr-3 w-fit overflow-hidden" + onMouseEnter={() => setHoveringHeader(true)} + onMouseLeave={() => setHoveringHeader(false)} + > + <motion.div + className="flex-none" + initial={{ opacity: 0, scale: 0.7, skew: 5 } as any} + animate={{ opacity: 1, scale: 1, skew: 0 } as any} + exit={{ opacity: 1, scale: 1, skewY: 1 }} + transition={{ duration: 0.5 }} + data-discover-page-header-metadata-media-image-container + > + <SeaLink + href={pageType === "anime" + ? `/entry?id=${randomTrending.id}` + : `/manga/entry?id=${randomTrending.id}`} + data-discover-page-header-metadata-media-image-link + > + {randomTrending.coverImage?.large && <div + className="w-[180px] h-[280px] relative rounded-[--radius-md] overflow-hidden bg-[--background] shadow-md" + data-discover-page-header-metadata-media-image-inner-container + > + <Image + src={randomTrending.coverImage.large} + alt="cover image" + fill + priority + placeholder={imageShimmer(700, 475)} + className={cn( + "object-cover object-center transition-opacity duration-1000", + isTransitioning && "opacity-30", + !isTransitioning && "opacity-100", + )} + data-discover-page-header-metadata-media-image + /> + </div>} + </SeaLink> + </motion.div> + <motion.div + className="flex-auto space-y-2 z-[1]" + initial={{ opacity: 0, x: 10 }} + animate={{ opacity: 1, x: 0 }} + transition={{ duration: 0.5, delay: 0.6 }} + data-discover-page-header-metadata-inner-container + > + <SeaLink + href={pageType === "anime" + ? `/entry?id=${randomTrending.id}` + : `/manga/entry?id=${randomTrending.id}`} + data-discover-page-header-metadata-media-title + > + <TextGenerateEffect + className="[text-shadow:_0_1px_10px_rgb(0_0_0_/_20%)] text-white leading-8 line-clamp-2 pb-1 max-w-md text-pretty text-3xl overflow-ellipsis" + words={randomTrending.title?.userPreferred || ""} + /> + </SeaLink> + <div className="flex flex-wrap gap-2"> + {randomTrending.genres?.map((genre) => ( + <div key={genre} className="text-sm font-semibold px-1 text-gray-300"> + {genre} + </div> + ))} + </div> + {/*<h1 className="text-3xl text-gray-200 leading-8 line-clamp-2 font-bold max-w-md">{randomTrending.title?.userPreferred}</h1>*/} + <div className="flex items-center max-w-lg gap-4" data-discover-page-header-metadata-media-info> + {randomTrending.meanScore && + <div className="rounded-full w-fit inline-block" data-discover-page-header-metadata-media-score> + <MediaEntryAudienceScore + meanScore={randomTrending.meanScore} + /> + </div>} + {!!(randomTrending as AL_BaseAnime)?.nextAiringEpisode?.airingAt && + <p + className="text-base text-brand-200 inline-flex items-center gap-1.5" + data-discover-page-header-metadata-media-airing-now + > + <RiSignalTowerLine /> Releasing now + </p>} + {((!!(randomTrending as AL_BaseAnime)?.nextAiringEpisode || !!(randomTrending as AL_BaseAnime).episodes) && (randomTrending as AL_BaseAnime)?.format !== "MOVIE") && ( + <p className="text-base font-medium" data-discover-page-header-metadata-media-episodes> + {!!(randomTrending as AL_BaseAnime).nextAiringEpisode?.episode ? + <span>{(randomTrending as AL_BaseAnime).nextAiringEpisode?.episode! - 1} episode{(randomTrending as AL_BaseAnime).nextAiringEpisode?.episode! - 1 === 1 + ? "" + : "s"} released</span> : + <span>{(randomTrending as AL_BaseAnime).episodes} total + episode{(randomTrending as AL_BaseAnime).episodes === 1 + ? "" + : "s"}</span>} + </p> + )} + + </div> + <motion.div + className="pt-0 left-0" + initial={{ opacity: 0, x: 10 }} + animate={{ opacity: 1, x: 0 }} + transition={{ duration: 0.5, delay: 0.7 }} + data-discover-page-header-metadata-media-description-container + > + <ScrollArea + data-discover-page-header-metadata-media-description-scroll-area + className="max-w-lg leading-3 h-[77px] mb-4 p-0 text-sm" + >{(randomTrending as any)?.description?.replace( + /(<([^>]+)>)/ig, + "")}</ScrollArea> + + <Button + size="sm" intent="gray-outline" className="rounded-full" + // rightIcon={<ImEnlarge2 />} + onClick={() => setPreviewModalMediaId(randomTrending?.id, pageType)} + > + Preview + </Button> + {/*<SeaLink*/} + {/* href={pageType === "anime"*/} + {/* ? `/entry?id=${randomTrending.id}`*/} + {/* : `/manga/entry?id=${randomTrending.id}`}*/} + {/*>*/} + {/* <Button*/} + {/* intent="white-basic"*/} + {/* size="md"*/} + {/* className="text-md w-[14rem] border-opacity-50 text-sm"*/} + {/* >*/} + {/* {randomTrending.status === "NOT_YET_RELEASED" ? "Preview" :*/} + {/* pageType === "anime" ? "Watch now" : "Read now"}*/} + {/* </Button>*/} + {/*</SeaLink>*/} + </motion.div> + </motion.div> + </div> + </motion.div> + )} + </AnimatePresence> + </motion.div> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-airing-schedule.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-airing-schedule.tsx new file mode 100644 index 0000000..8fcc27f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-airing-schedule.tsx @@ -0,0 +1,172 @@ +import { useAnilistListRecentAiringAnime } from "@/api/hooks/anilist.hooks" +import { SeaLink } from "@/components/shared/sea-link" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Separator } from "@/components/ui/separator" +import { format, isSameMonth, isToday, subDays } from "date-fns" +import { addDays } from "date-fns/addDays" +import { isSameDay } from "date-fns/isSameDay" +import { SeaContextMenu } from "@/app/(main)/_features/context-menu/sea-context-menu" +import { ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuTrigger } from "@/components/ui/context-menu" +import { useMediaPreviewModal } from "@/app/(main)/_features/media/_containers/media-preview-modal" +import { useRouter } from "next/navigation" +import Image from "next/image" +import React from "react" + + +export function DiscoverAiringSchedule() { + const { data, isLoading } = useAnilistListRecentAiringAnime({ + page: 1, + perPage: 50, + airingAt_lesser: Math.floor(addDays(new Date(), 14).getTime() / 1000), + airingAt_greater: Math.floor(subDays(new Date(), 2).getTime() / 1000), + notYetAired: true, + sort: ["TIME"], + }) + const { data: data2, isLoading: isLoading2 } = useAnilistListRecentAiringAnime({ + page: 2, + perPage: 50, + airingAt_lesser: Math.floor(addDays(new Date(), 14).getTime() / 1000), + airingAt_greater: Math.floor(subDays(new Date(), 2).getTime() / 1000), + notYetAired: true, + sort: ["TIME"], + }) + + const media = React.useMemo(() => [...(data?.Page?.airingSchedules?.filter(item => item?.media?.isAdult === false + && item?.media?.type === "ANIME" + && item?.media?.countryOfOrigin === "JP" + && item?.media?.format !== "TV_SHORT", + ).filter(Boolean) || []), + ...(data2?.Page?.airingSchedules?.filter(item => item?.media?.isAdult === false + && item?.media?.type === "ANIME" + && item?.media?.countryOfOrigin === "JP" + && item?.media?.format !== "TV_SHORT", + ).filter(Boolean) || []), + ], [isLoading, isLoading2]) + + const router = useRouter() + const { setPreviewModalMediaId } = useMediaPreviewModal() + + // State for the current displayed month + const [currentDate, setCurrentDate] = React.useState(new Date()) + + const days = React.useMemo(() => { + + // Ensure startOfWeek aligns with the correct day + const start = subDays(new Date(), 1) + + const daysArray = [] + let day = new Date(start.setHours(0, 0, 0, 0)) // Ensure the day starts at midnight + const endDate = addDays(day, 14) // 14-day range from the current start + + while (day <= endDate) { + const upcomingMedia = media.filter((item) => !!item?.airingAt && isSameDay(new Date(item.airingAt * 1000), day)).map((item) => { + if (item.media?.id === 162804) console.log(item.airingAt) + return { + id: item.id + item?.episode!, + name: item.media?.title?.userPreferred, + time: format(new Date(item?.airingAt! * 1000), "h:mm a"), + datetime: format(new Date(item?.airingAt! * 1000), "yyyy-MM-dd'T'HH:mm"), + href: `/entry?id=${item.id}`, + media: item.media, + episode: item?.media?.nextAiringEpisode?.episode || 1, + } + }) + + daysArray.push({ + date: format(day, "yyyy-MM-dd'T'HH:mm"), + isCurrentMonth: isSameMonth(day, currentDate), + isToday: isToday(day), + isSelected: false, + events: upcomingMedia, + }) + day = addDays(day, 1) + } + return daysArray + }, [media, currentDate]) + + if (isLoading || isLoading2) return <LoadingSpinner /> + + if (!data?.Page?.airingSchedules?.length) return null + + return ( + <div className="space-y-4 z-[5] relative" data-discover-airing-schedule-container> + <h2 className="text-center">Airing schedule</h2> + <div className="space-y-6"> + {days.map((day, index) => { + if (day.events.length === 0) return null + return ( + <React.Fragment key={day.date}> + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <h3 className="font-semibold">{format(new Date(day.date), "EEEE, PP")}</h3> + {day.isToday && <span className="text-[--muted]">Today</span>} + </div> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"> + {day.events?.toSorted((a, b) => a.datetime.localeCompare(b.datetime))?.map((event, index) => { + return ( + <React.Fragment key={event.id}> + <SeaContextMenu + content={<ContextMenuGroup> + <ContextMenuLabel className="text-[--muted] line-clamp-2 py-0 my-2"> + {event.media?.title?.userPreferred} + </ContextMenuLabel> + <ContextMenuItem + onClick={() => { + router.push(`/entry?id=${event.media?.id}`) + }} + > + Open page + </ContextMenuItem> + <ContextMenuItem + onClick={() => { + setPreviewModalMediaId(event.media?.id || 0, "anime") + }} + > + Preview + </ContextMenuItem> + </ContextMenuGroup>} + > + <ContextMenuTrigger> + <div key={String(`${event.id}${index}`)} + className="flex gap-3 bg-[--background] rounded-[--radius-md] p-2" + > + <div + className="w-[5rem] h-[5rem] rounded-[--radius] flex-none object-cover object-center overflow-hidden relative" + > + <Image + src={event.media?.coverImage?.large || event.media?.bannerImage || "/no-cover.png"} + alt="banner" + fill + quality={80} + priority + sizes="20rem" + className="object-cover object-center" + /> + </div> + + <div className="space-y-1"> + <SeaLink + href={`/entry?id=${event.media?.id}`} + className="font-medium tracking-wide line-clamp-1" + >{event.media?.title?.userPreferred}</SeaLink> + + <p className="text-[--muted]"> + Ep {event.episode} airing at {event.time} + </p> + </div> + </div> + </ContextMenuTrigger> + </SeaContextMenu> + </React.Fragment> + ) + })} + </div> + </div> + {!!days[index + 1]?.events?.length && <Separator />} + </React.Fragment> + ) + })} + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-missed-sequels.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-missed-sequels.tsx new file mode 100644 index 0000000..bf84245 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-missed-sequels.tsx @@ -0,0 +1,50 @@ +import { useAnilistListMissedSequels } from "@/api/hooks/anilist.hooks" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { MediaEntryCardSkeleton } from "@/app/(main)/_features/media/_components/media-entry-card-skeleton" +import { Carousel, CarouselContent, CarouselDotButtons } from "@/components/ui/carousel" +import { useInView } from "motion/react" +import React from "react" + + +export function DiscoverMissedSequelsSection() { + const ref = React.useRef(null) + const isInView = useInView(ref, { once: true }) + const { data, isLoading } = useAnilistListMissedSequels(isInView) + + if (!isInView && !data) return <div ref={ref} /> + + if (!data?.length) return null + + return ( + <div className="space-y-2 z-[5] relative" data-discover-missed-sequels-container> + <h2>You Might Have Missed</h2> + <Carousel + className="w-full max-w-full" + gap="xl" + opts={{ + align: "start", + dragFree: true, + }} + autoScroll + > + {/*<CarouselMasks />*/} + <CarouselDotButtons /> + <CarouselContent className="px-6" ref={ref}> + {!isLoading ? data?.filter(Boolean).map(media => { + return ( + <MediaEntryCard + key={media.id} + media={media} + showLibraryBadge + containerClassName="basis-[200px] md:basis-[250px] mx-2 my-8" + showTrailer + type="anime" + /> + ) + }) : [...Array(10).keys()].map((v, idx) => <MediaEntryCardSkeleton key={idx} />)} + </CarouselContent> + </Carousel> + </div> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-popular.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-popular.tsx new file mode 100644 index 0000000..eb87a0f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-popular.tsx @@ -0,0 +1,153 @@ +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { MediaEntryCardSkeleton } from "@/app/(main)/_features/media/_components/media-entry-card-skeleton" +import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector" +import { + __discover_currentSeasonGenresAtom, + __discover_pastSeasonGenresAtom, + useDiscoverCurrentSeasonAnime, + useDiscoverPastSeasonAnime, + useDiscoverPopularAnime, +} from "@/app/(main)/discover/_lib/handle-discover-queries" +import { ADVANCED_SEARCH_MEDIA_GENRES } from "@/app/(main)/search/_lib/advanced-search-constants" +import { Carousel, CarouselContent, CarouselDotButtons } from "@/components/ui/carousel" +import { useAtom } from "jotai/react" +import React from "react" + +export function DiscoverPopular() { + + const ref = React.useRef<HTMLDivElement>(null) + const { data, isLoading } = useDiscoverPopularAnime(ref) + + return ( + <Carousel + className="w-full max-w-full" + gap="xl" + opts={{ + align: "start", + dragFree: true, + }} + autoScroll + > + {/*<CarouselMasks />*/} + <CarouselDotButtons flag={data?.Page?.media} /> + <CarouselContent className="px-6" ref={ref}> + {!!data ? data?.Page?.media?.filter(Boolean).map(media => { + return ( + <MediaEntryCard + key={media.id} + media={media} + showLibraryBadge + containerClassName="basis-[200px] md:basis-[250px] mx-2 my-8" + showTrailer + type="anime" + /> + ) + }) : [...Array(10).keys()].map((v, idx) => <MediaEntryCardSkeleton key={idx} />)} + </CarouselContent> + </Carousel> + ) +} + +export function DiscoverThisSeason() { + + const ref = React.useRef<HTMLDivElement>(null) + const { data } = useDiscoverCurrentSeasonAnime(ref) + + const [selectedGenre, setSelectedGenre] = useAtom(__discover_currentSeasonGenresAtom) + + + return ( + <Carousel + className="w-full max-w-full" + gap="xl" + opts={{ + align: "start", + dragFree: true, + }} + autoScroll + > + <MediaGenreSelector + items={[ + { + name: "All", + isCurrent: selectedGenre.length === 0, + onClick: () => setSelectedGenre([]), + }, + ...ADVANCED_SEARCH_MEDIA_GENRES.map(genre => ({ + name: genre, + isCurrent: selectedGenre.includes(genre), + onClick: () => setSelectedGenre([genre]), + })), + ]} + /> + {/*<CarouselMasks />*/} + <CarouselDotButtons /> + <CarouselContent className="px-6" ref={ref}> + {!!data ? data?.Page?.media?.filter(Boolean)?.sort((a, b) => b.meanScore! - a.meanScore!).map(media => { + return ( + <MediaEntryCard + key={media.id} + media={media} + showLibraryBadge + containerClassName="basis-[200px] md:basis-[250px] mx-2 my-8" + showTrailer + type="anime" + /> + ) + }) : [...Array(10).keys()].map((v, idx) => <MediaEntryCardSkeleton key={idx} />)} + </CarouselContent> + </Carousel> + ) +} + +export function DiscoverPastSeason() { + + const ref = React.useRef<HTMLDivElement>(null) + const { data } = useDiscoverPastSeasonAnime(ref) + + const [selectedGenre, setSelectedGenre] = useAtom(__discover_pastSeasonGenresAtom) + + + return ( + <Carousel + className="w-full max-w-full" + gap="xl" + opts={{ + align: "start", + dragFree: true, + }} + autoScroll + > + <MediaGenreSelector + items={[ + { + name: "All", + isCurrent: selectedGenre.length === 0, + onClick: () => setSelectedGenre([]), + }, + ...ADVANCED_SEARCH_MEDIA_GENRES.map(genre => ({ + name: genre, + isCurrent: selectedGenre.includes(genre), + onClick: () => setSelectedGenre([genre]), + })), + ]} + /> + {/*<CarouselMasks />*/} + <CarouselDotButtons /> + <CarouselContent className="px-6" ref={ref}> + {!!data ? data?.Page?.media?.filter(Boolean)?.sort((a, b) => b.meanScore! - a.meanScore!).map(media => { + return ( + <MediaEntryCard + key={media.id} + media={media} + showLibraryBadge + containerClassName="basis-[200px] md:basis-[250px] mx-2 my-8" + showTrailer + type="anime" + /> + ) + }) : [...Array(10).keys()].map((v, idx) => <MediaEntryCardSkeleton key={idx} />)} + </CarouselContent> + </Carousel> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending-country.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending-country.tsx new file mode 100644 index 0000000..b51884b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending-country.tsx @@ -0,0 +1,147 @@ +import { useAnilistListManga } from "@/api/hooks/manga.hooks" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { MediaEntryCardSkeleton } from "@/app/(main)/_features/media/_components/media-entry-card-skeleton" +import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector" +import { __discover_hoveringHeaderAtom } from "@/app/(main)/discover/_components/discover-page-header" +import { __discover_headerIsTransitioningAtom, __discover_randomTrendingAtom } from "@/app/(main)/discover/_containers/discover-trending" +import { ADVANCED_SEARCH_MEDIA_GENRES } from "@/app/(main)/search/_lib/advanced-search-constants" +import { Carousel, CarouselContent, CarouselDotButtons } from "@/components/ui/carousel" +import { atom } from "jotai/index" +import { useAtom, useAtomValue, useSetAtom } from "jotai/react" +import React, { useEffect, useState } from "react" + +const trendingGenresAtom = atom<string[]>([]) + +export const __discover_mangaRandomNumberAtom = atom<number>(0) +export const __discover_mangaTotalItemsAtom = atom<number>(0) +export const __discover_setMangaRandomNumberAtom = atom( + null, + (get, set, randomNumber: number) => { + set(__discover_mangaRandomNumberAtom, randomNumber) + }, +) + +export function DiscoverTrendingCountry({ country }: { country: string }) { + const genres = useAtomValue(trendingGenresAtom) + const { data, isLoading } = useAnilistListManga({ + page: 1, + perPage: 20, + sort: ["TRENDING_DESC"], + countryOfOrigin: country || undefined, + genres: genres.length > 0 ? genres : undefined, + }) + + const setRandomTrendingAtom = useSetAtom(__discover_randomTrendingAtom) + const isHoveringHeader = useAtomValue(__discover_hoveringHeaderAtom) + const setHeaderIsTransitioning = useSetAtom(__discover_headerIsTransitioningAtom) + const setMangaTotalItems = useSetAtom(__discover_mangaTotalItemsAtom) + const [mangaRandomNumber, setMangaRandomNumber] = useAtom(__discover_mangaRandomNumberAtom) + + const [randomNumber, setRandomNumber] = useState(0) + + // Update the atom when randomNumber changes + useEffect(() => { + setMangaRandomNumber(randomNumber) + }, [randomNumber]) + + // Update randomNumber when mangaRandomNumber changes from outside + useEffect(() => { + if (mangaRandomNumber !== randomNumber) { + setHeaderIsTransitioning(true) + setTimeout(() => { + setRandomNumber(mangaRandomNumber) + setHeaderIsTransitioning(false) + }, 900) + } + }, [mangaRandomNumber]) + + useEffect(() => { + if (country !== "JP") return + const t = setInterval(() => { + setHeaderIsTransitioning(true) + setTimeout(() => { + setRandomNumber(p => { + return p < 11 ? p + 1 : 0 + }) + setHeaderIsTransitioning(false) + }, 900) + }, 6000) + if (isHoveringHeader) { + clearInterval(t) + } + return () => clearInterval(t) + }, [isHoveringHeader, country]) + + const firedRef = React.useRef(false) + React.useEffect(() => { + if (country !== "JP") return + if (!firedRef.current && data) { + const mediaItems = data?.Page?.media?.filter(Boolean) || [] + const random = mediaItems[randomNumber] + if (random) { + setMangaTotalItems(mediaItems.length) + setRandomTrendingAtom(random) + firedRef.current = true + } + } + }, [data, randomNumber, country]) + + React.useEffect(() => { + if (country !== "JP") return + if (firedRef.current) { + const random = data?.Page?.media?.filter(Boolean)[randomNumber] + if (random) { + setRandomTrendingAtom(random) + } + } + }, [randomNumber, country]) + + return ( + <Carousel + className="w-full max-w-full" + gap="md" + opts={{ + align: "start", + dragFree: true, + }} + autoScroll + > + <GenreSelector /> + <CarouselDotButtons /> + <CarouselContent className="px-6"> + {!isLoading ? data?.Page?.media?.filter(Boolean).map(media => { + return ( + <MediaEntryCard + key={media.id} + media={media} + containerClassName="basis-[200px] md:basis-[250px] mx-2 my-8" + type="manga" + /> + ) + }) : [...Array(10).keys()].map((v, idx) => <MediaEntryCardSkeleton key={idx} />)} + </CarouselContent> + </Carousel> + ) +} + +function GenreSelector() { + + const [selectedGenre, setSelectedGenre] = useAtom(trendingGenresAtom) + + return ( + <MediaGenreSelector + items={[ + { + name: "All", + isCurrent: selectedGenre.length === 0, + onClick: () => setSelectedGenre([]), + }, + ...ADVANCED_SEARCH_MEDIA_GENRES.map(genre => ({ + name: genre, + isCurrent: selectedGenre.includes(genre), + onClick: () => setSelectedGenre([genre]), + })), + ]} + /> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending-manga-all.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending-manga-all.tsx new file mode 100644 index 0000000..7aa7834 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending-manga-all.tsx @@ -0,0 +1,183 @@ +import { useAnilistListManga } from "@/api/hooks/manga.hooks" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { MediaEntryCardSkeleton } from "@/app/(main)/_features/media/_components/media-entry-card-skeleton" +import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector" +import { __discover_hoveringHeaderAtom } from "@/app/(main)/discover/_components/discover-page-header" +import { __discover_headerIsTransitioningAtom, __discover_randomTrendingAtom } from "@/app/(main)/discover/_containers/discover-trending" +import { ADVANCED_SEARCH_MEDIA_GENRES } from "@/app/(main)/search/_lib/advanced-search-constants" +import { Carousel, CarouselContent, CarouselDotButtons } from "@/components/ui/carousel" +import { TextInput } from "@/components/ui/text-input" +import { useDebounce } from "@/hooks/use-debounce" +import { atom } from "jotai/index" +import { useAtom, useAtomValue, useSetAtom } from "jotai/react" +import React from "react" +import { FiSearch } from "react-icons/fi" + +const trendingGenresAtom = atom<string[]>([]) + +export function DiscoverTrendingMangaAll() { + const genres = useAtomValue(trendingGenresAtom) + const { data, isLoading } = useAnilistListManga({ + page: 1, + perPage: 20, + sort: ["TRENDING_DESC"], + genres: genres.length > 0 ? genres : undefined, + }) + + const setRandomTrendingAtom = useSetAtom(__discover_randomTrendingAtom) + const isHoveringHeader = useAtomValue(__discover_hoveringHeaderAtom) + const setHeaderIsTransitioning = useSetAtom(__discover_headerIsTransitioningAtom) + + + // useEffect(() => { + // const t = setInterval(() => { + // setHeaderIsTransitioning(true) + // setTimeout(() => { + // setRandomNumber(p => { + // return p < 10 ? p + 1 : 0 + // }) + // setHeaderIsTransitioning(false) + // }, 900) + // }, 6000) + // if (isHoveringHeader) { + // clearInterval(t) + // } + // return () => clearInterval(t) + // }, [isHoveringHeader]) + // + // // Update randomNumber when mangaRandomNumber changes from outside + // useEffect(() => { + // if (mangaRandomNumber !== randomNumber) { + // setHeaderIsTransitioning(true) + // setTimeout(() => { + // setRandomNumber(mangaRandomNumber) + // setHeaderIsTransitioning(false) + // }, 900) + // } + // }, [mangaRandomNumber]) + // + // const firedRef = React.useRef(false) + // React.useEffect(() => { + // console.log("firedRef.current", firedRef.current, data?.Page?.media?.length) + // if (!firedRef.current && data) { + // const mediaItems = data?.Page?.media?.filter(Boolean) || [] + // setMangaTotalItems(mediaItems.length) + // const random = mediaItems[randomNumber] + // if (random) { + // setRandomTrendingAtom(random) + // firedRef.current = true + // } + // } + // }, [data, randomNumber]) + // + // React.useEffect(() => { + // if (firedRef.current) { + // const mediaItems = data?.Page?.media?.filter(Boolean) || [] + // const random = mediaItems[randomNumber] + // if (random) { + // setRandomTrendingAtom(random) + // } + // } + // }, [randomNumber, data]) + + return ( + <Carousel + className="w-full max-w-full" + gap="md" + opts={{ + align: "start", + dragFree: true, + }} + autoScroll + > + <GenreSelector /> + <CarouselDotButtons /> + <CarouselContent className="px-6"> + {!isLoading ? data?.Page?.media?.filter(Boolean).map(media => { + return ( + <MediaEntryCard + key={media.id} + media={media} + containerClassName="basis-[200px] md:basis-[250px] mx-2 my-8" + type="manga" + /> + ) + }) : [...Array(10).keys()].map((v, idx) => <MediaEntryCardSkeleton key={idx} />)} + </CarouselContent> + </Carousel> + ) +} + +function GenreSelector() { + + const [selectedGenre, setSelectedGenre] = useAtom(trendingGenresAtom) + + return ( + <MediaGenreSelector + items={[ + { + name: "All", + isCurrent: selectedGenre.length === 0, + onClick: () => setSelectedGenre([]), + }, + ...ADVANCED_SEARCH_MEDIA_GENRES.map(genre => ({ + name: genre, + isCurrent: selectedGenre.includes(genre), + onClick: () => setSelectedGenre([genre]), + })), + ]} + /> + ) +} + +const mangaSearchInputAtom = atom<string>("") + +export function DiscoverMangaSearchBar() { + const [searchInput, setSearchInput] = useAtom(mangaSearchInputAtom) + const search = useDebounce(searchInput, 500) + + const { data, isLoading, isFetching } = useAnilistListManga({ + page: 1, + perPage: 10, + search: search, + }) + + return ( + <div className="space-y-4" data-discover-manga-search-bar-container> + <div className="container" data-discover-manga-search-bar-inner-container> + <TextInput + leftIcon={<FiSearch />} + value={searchInput} + onValueChange={v => { + setSearchInput(v) + }} + className="rounded-full" + placeholder="Search manga" + /> + </div> + + {!!search && <Carousel + className="w-full max-w-full" + gap="md" + opts={{ + align: "start", + dragFree: true, + }} + autoScroll + > + <CarouselContent className="px-6"> + {!(isLoading || isFetching) ? data?.Page?.media?.filter(Boolean).map(media => { + return ( + <MediaEntryCard + key={media.id} + media={media} + containerClassName="basis-[200px] md:basis-[250px] mx-2 my-8" + type="manga" + /> + ) + }) : [...Array(10).keys()].map((v, idx) => <MediaEntryCardSkeleton key={idx} />)} + </CarouselContent> + </Carousel>} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending-movies.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending-movies.tsx new file mode 100644 index 0000000..af129a3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending-movies.tsx @@ -0,0 +1,41 @@ +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { MediaEntryCardSkeleton } from "@/app/(main)/_features/media/_components/media-entry-card-skeleton" +import { useDiscoverTrendingMovies } from "@/app/(main)/discover/_lib/handle-discover-queries" +import { Carousel, CarouselContent, CarouselDotButtons } from "@/components/ui/carousel" +import React from "react" + +export function DiscoverTrendingMovies() { + + const ref = React.useRef<HTMLDivElement>(null) + const { data, isLoading } = useDiscoverTrendingMovies(ref) + + return ( + <Carousel + className="w-full max-w-full" + gap="xl" + opts={{ + align: "start", + dragFree: true, + }} + autoScroll + > + {/*<CarouselMasks />*/} + <CarouselDotButtons /> + <CarouselContent className="px-6" ref={ref}> + {!!data ? data?.Page?.media?.filter(Boolean).map(media => { + return ( + + <MediaEntryCard + key={media.id} + media={media} + showLibraryBadge + containerClassName="basis-[200px] md:basis-[250px] mx-2 my-8" + showTrailer + type="anime" + /> + ) + }) : [...Array(10).keys()].map((v, idx) => <MediaEntryCardSkeleton key={idx} />)} + </CarouselContent> + </Carousel> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending.tsx new file mode 100644 index 0000000..223d510 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-trending.tsx @@ -0,0 +1,154 @@ +import { AL_BaseAnime, AL_BaseManga } from "@/api/generated/types" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { MediaEntryCardSkeleton } from "@/app/(main)/_features/media/_components/media-entry-card-skeleton" +import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector" +import { __discover_clickedCarouselDotAtom, __discover_hoveringHeaderAtom } from "@/app/(main)/discover/_components/discover-page-header" +import { __discover_trendingGenresAtom, useDiscoverTrendingAnime } from "@/app/(main)/discover/_lib/handle-discover-queries" +import { ADVANCED_SEARCH_MEDIA_GENRES } from "@/app/(main)/search/_lib/advanced-search-constants" +import { Carousel, CarouselContent, CarouselDotButtons } from "@/components/ui/carousel" +import { atom } from "jotai" +import { useAtom, useAtomValue, useSetAtom } from "jotai/react" +import React, { useEffect, useState } from "react" + +export const __discover_randomTrendingAtom = atom<AL_BaseAnime | AL_BaseManga | undefined>(undefined) +export const __discover_headerIsTransitioningAtom = atom(false) +export const __discover_animeRandomNumberAtom = atom<number>(0) +export const __discover_animeTotalItemsAtom = atom<number>(0) +export const __discover_setAnimeRandomNumberAtom = atom( + null, + (get, set, randomNumber: number) => { + set(__discover_animeRandomNumberAtom, randomNumber) + }, +) + +export function DiscoverTrending() { + + const { data, isLoading } = useDiscoverTrendingAnime() + const setRandomTrendingAtom = useSetAtom(__discover_randomTrendingAtom) + const isHoveringHeader = useAtomValue(__discover_hoveringHeaderAtom) + const clickedHeaderDot = useAtomValue(__discover_clickedCarouselDotAtom) // clears interval + const setHeaderIsTransitioning = useSetAtom(__discover_headerIsTransitioningAtom) + const setAnimeTotalItems = useSetAtom(__discover_animeTotalItemsAtom) + const [animeRandomNumber, setAnimeRandomNumber] = useAtom(__discover_animeRandomNumberAtom) + + // Random number between 0 and 12 + const [randomNumber, setRandomNumber] = useState(0) + + // Update the atom when randomNumber changes + useEffect(() => { + setAnimeRandomNumber(randomNumber) + }, [randomNumber]) + + useEffect(() => { + const t = setInterval(() => { + setHeaderIsTransitioning(true) + setTimeout(() => { + setRandomNumber(p => { + return p < 11 ? p + 1 : 0 + }) + setHeaderIsTransitioning(false) + }, 900) + }, 6000) + if (isHoveringHeader) { + clearInterval(t) + } + return () => clearInterval(t) + }, [isHoveringHeader, clickedHeaderDot]) + + // Update randomNumber when animeRandomNumber changes from outside + useEffect(() => { + if (animeRandomNumber !== randomNumber) { + setHeaderIsTransitioning(true) + setTimeout(() => { + setRandomNumber(animeRandomNumber) + setHeaderIsTransitioning(false) + }, 900) + } + }, [animeRandomNumber]) + + const firedRef = React.useRef(false) + React.useEffect(() => { + if (!firedRef.current && data) { + const mediaItems = data?.Page?.media?.filter(Boolean) || [] + setAnimeTotalItems(mediaItems.length) + const random = mediaItems[randomNumber] + if (random) { + setRandomTrendingAtom(random) + firedRef.current = true + } + } + }, [data, randomNumber]) + + React.useEffect(() => { + if (firedRef.current) { + const mediaItems = data?.Page?.media?.filter(Boolean) || [] + const random = mediaItems[randomNumber] + if (random) { + setRandomTrendingAtom(random) + } + } + }, [randomNumber, data]) + + return ( + <Carousel + className="w-full max-w-full" + gap="xl" + opts={{ + align: "start", + dragFree: true, + }} + autoScroll + > + <GenreSelector /> + {/*<CarouselMasks />*/} + <CarouselDotButtons /> + <CarouselContent className="px-6"> + {!isLoading ? data?.Page?.media?.filter(Boolean).map(media => { + return ( + + <MediaEntryCard + key={media.id} + media={media} + showLibraryBadge + containerClassName="basis-[200px] md:basis-[250px] mx-2 my-8" + showTrailer + type="anime" + /> + ) + }) : [...Array(10).keys()].map((v, idx) => <MediaEntryCardSkeleton key={idx} />)} + </CarouselContent> + </Carousel> + ) + +} + +type GenreSelectorProps = { + children?: React.ReactNode +} + +function GenreSelector(props: GenreSelectorProps) { + + const { + children, + ...rest + } = props + + const [selectedGenre, setSelectedGenre] = useAtom(__discover_trendingGenresAtom) + + return ( + <MediaGenreSelector + items={[ + { + name: "All", + isCurrent: selectedGenre.length === 0, + onClick: () => setSelectedGenre([]), + }, + ...ADVANCED_SEARCH_MEDIA_GENRES.map(genre => ({ + name: genre, + isCurrent: selectedGenre.includes(genre), + onClick: () => setSelectedGenre([genre]), + })), + ]} + /> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-upcoming.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-upcoming.tsx new file mode 100644 index 0000000..8ff5a89 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_containers/discover-upcoming.tsx @@ -0,0 +1,42 @@ +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { MediaEntryCardSkeleton } from "@/app/(main)/_features/media/_components/media-entry-card-skeleton" +import { useDiscoverUpcomingAnime } from "@/app/(main)/discover/_lib/handle-discover-queries" +import { Carousel, CarouselContent, CarouselDotButtons } from "@/components/ui/carousel" +import React from "react" + +export function DiscoverUpcoming() { + + const ref = React.useRef<HTMLDivElement>(null) + const { data, isLoading } = useDiscoverUpcomingAnime(ref) + + return ( + <Carousel + className="w-full max-w-full" + gap="xl" + opts={{ + align: "start", + dragFree: true, + }} + autoScroll + > + {/*<CarouselMasks />*/} + <CarouselDotButtons /> + <CarouselContent className="px-6" ref={ref}> + {!isLoading ? data?.Page?.media?.filter(Boolean).map(media => { + return ( + + <MediaEntryCard + key={media.id} + media={media} + showLibraryBadge + containerClassName="basis-[200px] md:basis-[250px] mx-2 my-8" + showTrailer + type="anime" + /> + ) + }) : [...Array(10).keys()].map((v, idx) => <MediaEntryCardSkeleton key={idx} />)} + </CarouselContent> + </Carousel> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/_lib/discover.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_lib/discover.atoms.ts new file mode 100644 index 0000000..1d2c1a7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_lib/discover.atoms.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai/index" + +export const __discord_pageTypeAtom = atom<"anime" | "schedule" | "manga">("anime") diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/_lib/handle-discover-queries.ts b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_lib/handle-discover-queries.ts new file mode 100644 index 0000000..f63127b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/_lib/handle-discover-queries.ts @@ -0,0 +1,132 @@ +import { AL_MediaSeason } from "@/api/generated/types" +import { useAnilistListAnime } from "@/api/hooks/anilist.hooks" +import { atom } from "jotai" +import { useAtomValue } from "jotai/react" +import { useInView } from "motion/react" + +export const __discover_trendingGenresAtom = atom<string[]>([]) +export const __discover_currentSeasonGenresAtom = atom<string[]>([]) +export const __discover_pastSeasonGenresAtom = atom<string[]>([]) + +export function useDiscoverTrendingAnime() { + const genres = useAtomValue(__discover_trendingGenresAtom) + + return useAnilistListAnime({ + page: 1, + perPage: 20, + sort: ["TRENDING_DESC"], + genres: genres.length > 0 ? genres : undefined, + }, true) + +} + +export function useDiscoverCurrentSeasonAnime(ref: any) { + const genres = useAtomValue(__discover_currentSeasonGenresAtom) + const isInView = useInView(ref, { once: true }) + const currentMonth = new Date().getMonth() + 1 + let currentYear = new Date().getFullYear() + let season: AL_MediaSeason = "SUMMER" + switch (currentMonth) { + case 1: + case 2: + case 3: + season = "WINTER" + break + case 4: + case 5: + case 6: + season = "SPRING" + break + case 7: + case 8: + case 9: + season = "SUMMER" + break + case 10: + case 11: + case 12: + season = "FALL" + break + } + + + return useAnilistListAnime({ + page: 1, + perPage: 20, + sort: ["SCORE_DESC"], + season: season, + seasonYear: currentYear, + genres: genres.length > 0 ? genres : undefined, + }, isInView) +} + +export function useDiscoverPastSeasonAnime(ref: any) { + const genres = useAtomValue(__discover_pastSeasonGenresAtom) + const isInView = useInView(ref, { once: true }) + const currentMonth = new Date().getMonth() + 1 + const currentYear = new Date().getFullYear() + let season: AL_MediaSeason = "SUMMER" + switch (currentMonth) { + case 1: + case 2: + case 3: + season = "WINTER" + break + case 4: + case 5: + case 6: + season = "SPRING" + break + case 7: + case 8: + case 9: + season = "SUMMER" + break + case 10: + case 11: + case 12: + season = "FALL" + break + } + const pastSeason = season === "WINTER" ? "FALL" : season === "SPRING" ? "WINTER" : season === "SUMMER" ? "SPRING" : "SUMMER" + const pastYear = season === "WINTER" ? currentYear - 1 : currentYear + + return useAnilistListAnime({ + page: 1, + perPage: 20, + sort: ["SCORE_DESC"], + season: pastSeason, + seasonYear: pastYear, + genres: genres.length > 0 ? genres : undefined, + }, isInView) +} + +export function useDiscoverUpcomingAnime(ref: any) { + const isInView = useInView(ref, { once: true }) + return useAnilistListAnime({ + page: 1, + perPage: 20, + sort: ["TRENDING_DESC"], + status: ["NOT_YET_RELEASED"], + }, isInView) +} + +export function useDiscoverPopularAnime(ref: any) { + const isInView = useInView(ref, { once: true }) + return useAnilistListAnime({ + page: 1, + perPage: 20, + sort: ["POPULARITY_DESC"], + }, isInView) +} + +export function useDiscoverTrendingMovies(ref: any) { + const isInView = useInView(ref, { once: true }) + return useAnilistListAnime({ + page: 1, + perPage: 20, + format: "MOVIE", + sort: ["TRENDING_DESC"], + status: ["RELEASING", "FINISHED"], + }, isInView) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/discover/layout.tsx new file mode 100644 index 0000000..3efab03 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/layout.tsx @@ -0,0 +1,15 @@ +"use client" +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/discover/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/discover/page.tsx new file mode 100644 index 0000000..0fb2f1a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/discover/page.tsx @@ -0,0 +1,174 @@ +"use client" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { DiscoverPageHeader } from "@/app/(main)/discover/_components/discover-page-header" +import { DiscoverAiringSchedule } from "@/app/(main)/discover/_containers/discover-airing-schedule" +import { DiscoverMissedSequelsSection } from "@/app/(main)/discover/_containers/discover-missed-sequels" +import { DiscoverPastSeason, DiscoverThisSeason } from "@/app/(main)/discover/_containers/discover-popular" +import { DiscoverTrending } from "@/app/(main)/discover/_containers/discover-trending" +import { DiscoverTrendingCountry } from "@/app/(main)/discover/_containers/discover-trending-country" +import { DiscoverTrendingMovies } from "@/app/(main)/discover/_containers/discover-trending-movies" +import { DiscoverUpcoming } from "@/app/(main)/discover/_containers/discover-upcoming" +import { __discord_pageTypeAtom } from "@/app/(main)/discover/_lib/discover.atoms" +import { RecentReleases } from "@/app/(main)/schedule/_containers/recent-releases" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { Button } from "@/components/ui/button" +import { StaticTabs } from "@/components/ui/tabs" +import { useAtom } from "jotai/react" +import { AnimatePresence, motion } from "motion/react" +import { useRouter, useSearchParams } from "next/navigation" +import React from "react" +import { FaSearch } from "react-icons/fa" + +export const dynamic = "force-static" + + +export default function Page() { + + const serverStatus = useServerStatus() + const router = useRouter() + const [pageType, setPageType] = useAtom(__discord_pageTypeAtom) + const searchParams = useSearchParams() + const searchType = searchParams.get("type") + + React.useEffect(() => { + if (searchType) { + setPageType(searchType as any) + } + }, [searchParams]) + + return ( + <> + <DiscoverPageHeader /> + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.5, delay: 0.6 }} + className="p-4 sm:p-8 space-y-10 pb-10 relative z-[4]" + data-discover-page-container + > + <div + className="lg:absolute w-full lg:-top-10 left-0 flex gap-4 p-4 items-center justify-center flex-wrap" + data-discover-page-header-tabs-container + > + <div className="max-w-fit border rounded-full" data-discover-page-header-tabs-inner-container> + <StaticTabs + className="h-10 overflow-hidden" + triggerClass="px-4 py-1" + items={[ + { name: "Anime", isCurrent: pageType === "anime", onClick: () => setPageType("anime") }, + { name: "Schedule", isCurrent: pageType === "schedule", onClick: () => setPageType("schedule") }, + ...(serverStatus?.settings?.library?.enableManga ? [{ + name: "Manga", + isCurrent: pageType === "manga", + onClick: () => setPageType("manga"), + }] : []), + ]} + /> + </div> + <div data-discover-page-header-advanced-search-container> + <Button + leftIcon={<FaSearch />} + intent="gray-outline" + // size="lg" + className="rounded-full" + onClick={() => router.push("/search")} + > + Advanced search + </Button> + </div> + </div> + <AnimatePresence mode="wait" initial={false}> + {pageType === "anime" && <PageWrapper + key="anime" + className="relative 2xl:order-first pb-10 pt-4" + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + data-discover-page-anime-container + > + <div className="space-y-2 z-[5] relative" data-discover-page-anime-trending-container> + <h2>Trending Right Now</h2> + <DiscoverTrending /> + </div> + <RecentReleases /> + <div className="space-y-2 z-[5] relative" data-discover-page-anime-highest-rated-container> + <h2>Top of the Season</h2> + <DiscoverThisSeason /> + </div> + <div className="space-y-2 z-[5] relative" data-discover-page-anime-highest-rated-container> + <h2>Best of Last Season</h2> + <DiscoverPastSeason /> + </div> + <DiscoverMissedSequelsSection /> + <div className="space-y-2 z-[5] relative" data-discover-page-anime-upcoming-container> + <h2>Coming Soon</h2> + <DiscoverUpcoming /> + </div> + <div className="space-y-2 z-[5] relative" data-discover-page-anime-trending-movies-container> + <h2>Trending Movies</h2> + <DiscoverTrendingMovies /> + </div> + {/*<div className="space-y-2 z-[5] relative">*/} + {/* <h2>Popular shows</h2>*/} + {/* <DiscoverPopular />*/} + {/*</div>*/} + </PageWrapper>} + {pageType === "schedule" && <PageWrapper + key="schedule" + className="relative 2xl:order-first pb-10 pt-4" + data-discover-page-schedule-container + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + > + <DiscoverAiringSchedule /> + </PageWrapper>} + {pageType === "manga" && <PageWrapper + key="manga" + className="relative 2xl:order-first pb-10 pt-4" + data-discover-page-manga-container + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + > + {/*<div className="space-y-2 z-[5] relative">*/} + {/* <h2>Trending right now</h2>*/} + {/* <DiscoverTrendingMangaAll />*/} + {/*</div>*/} + <div className="space-y-2 z-[5] relative" data-discover-page-manga-trending-container> + <h2>Trending Manga</h2> + <DiscoverTrendingCountry country="JP" /> + </div> + <div className="space-y-2 z-[5] relative" data-discover-page-manga-trending-manhwa-container> + <h2>Trending Manhwa</h2> + <DiscoverTrendingCountry country="KR" /> + </div> + <div className="space-y-2 z-[5] relative" data-discover-page-manga-trending-manhua-container> + <h2>Trending Manhua</h2> + <DiscoverTrendingCountry country="CN" /> + </div> + {/*<div className="space-y-2 z-[5] relative">*/} + {/* <DiscoverMangaSearchBar />*/} + {/*</div>*/} + </PageWrapper>} + </AnimatePresence> + + </motion.div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/anime-onlinestream-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/anime-onlinestream-button.tsx new file mode 100644 index 0000000..750e751 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/anime-onlinestream-button.tsx @@ -0,0 +1,60 @@ +import { Anime_Entry } from "@/api/generated/types" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { AnimeMetaActionButton } from "@/app/(main)/entry/_components/meta-section" +import { useAnimeEntryPageView } from "@/app/(main)/entry/_containers/anime-entry-page" +import React from "react" +import { AiOutlineArrowLeft } from "react-icons/ai" +import { FiPlayCircle } from "react-icons/fi" + +type AnimeOnlinestreamButtonProps = { + children?: React.ReactNode + entry: Anime_Entry | undefined +} + +export function AnimeOnlinestreamButton(props: AnimeOnlinestreamButtonProps) { + + const { + children, + entry, + ...rest + } = props + + const status = useServerStatus() + + const { isLibraryView, isOnlineStreamingView, toggleOnlineStreamingView } = useAnimeEntryPageView() + + + if ( + !entry || + entry.media?.status === "NOT_YET_RELEASED" || + !status?.settings?.library?.enableOnlinestream + ) return null + + if (!isLibraryView && !isOnlineStreamingView) return null + + // if (!status?.settings?.library?.includeOnlineStreamingInLibrary) return ( + // <> + // <SeaLink href={`/onlinestream?id=${entry?.mediaId}`}> + // <Button + // intent="primary-subtle" + // leftIcon={<FiPlayCircle className="text-xl" />} + // > + // Stream online + // </Button> + // </SeaLink> + // </> + // ) + + return ( + <AnimeMetaActionButton + data-anime-onlinestream-button + intent={isOnlineStreamingView ? "gray-subtle" : "white-subtle"} + // className={cn((status?.settings?.library?.includeOnlineStreamingInLibrary || isOnlineStreamingView) && "w-full")} + size="md" + leftIcon={isOnlineStreamingView ? <AiOutlineArrowLeft className="text-xl" /> : <FiPlayCircle className="text-2xl" />} + onClick={() => toggleOnlineStreamingView()} + > + {isOnlineStreamingView ? "Close Online streaming" : "Online streaming"} + </AnimeMetaActionButton> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/episode-list-grid.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/episode-list-grid.tsx new file mode 100644 index 0000000..1dca8ef --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/episode-list-grid.tsx @@ -0,0 +1,168 @@ +import { cn } from "@/components/ui/core/styling" +import { Pagination, PaginationEllipsis, PaginationItem, PaginationTrigger } from "@/components/ui/pagination" +import React, { useState } from "react" + +type EpisodeListGridProps = { + children?: React.ReactNode +} + +export function EpisodeListGrid(props: EpisodeListGridProps) { + + const { + children, + ...rest + } = props + + + return ( + <div + className={cn( + "grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 min-[2000px]:grid-cols-4", + "gap-4", + )} + {...rest} + data-episode-list-grid + > + {children} + </div> + ) +} + +type EpisodeListPaginatedGridProps = { + length: number + renderItem: (index: number) => React.ReactNode + itemsPerPage?: number + minLengthBeforePagination?: number + shouldDefaultToPageWithEpisode?: number // episode number +} + +export function EpisodeListPaginatedGrid(props: EpisodeListPaginatedGridProps) { + const { + length, + renderItem, + itemsPerPage = 24, + minLengthBeforePagination = 29, + shouldDefaultToPageWithEpisode, + } = props + + const [page, setPage] = useState(1) + + // Update page when shouldDefaultToPageWithEpisode changes + React.useEffect(() => { + if (shouldDefaultToPageWithEpisode && length >= minLengthBeforePagination) { + const targetPage = Math.ceil(shouldDefaultToPageWithEpisode / itemsPerPage) + const maxPage = Math.ceil(length / itemsPerPage) + const validPage = Math.min(Math.max(1, targetPage), maxPage) + setPage(validPage) + } else { + setPage(1) + } + }, [shouldDefaultToPageWithEpisode]) + + // Only use pagination if we have enough items + const shouldPaginate = length >= minLengthBeforePagination + + const totalPages = shouldPaginate ? Math.ceil(length / itemsPerPage) : 1 + const startIndex = shouldPaginate ? (page - 1) * itemsPerPage : 0 + const endIndex = shouldPaginate ? Math.min(startIndex + itemsPerPage, length) : length + + const currentItems = Array.from({ length: endIndex - startIndex }, (_, index) => renderItem(startIndex + index)) + + // Calculate which page numbers to show + const getVisiblePages = () => { + const pages: (number | "ellipsis")[] = [] + const maxVisiblePages = 5 + + if (totalPages <= maxVisiblePages) { + // Show all pages if total is small + for (let i = 1; i <= totalPages; i++) { + pages.push(i) + } + } else { + // Always show first page + pages.push(1) + + if (page <= 3) { + // Show pages 1,2,3,4,5 with ellipsis at end + for (let i = 2; i <= 4; i++) { + pages.push(i) + } + if (totalPages > 4) { + pages.push("ellipsis") + pages.push(totalPages) + } + } else if (page >= totalPages - 2) { + // Show ellipsis at start and last few pages + pages.push("ellipsis") + for (let i = totalPages - 3; i <= totalPages; i++) { + if (i > 1) pages.push(i) + } + } else { + // Show ellipsis on both sides + pages.push("ellipsis") + pages.push(page - 1) + pages.push(page) + pages.push(page + 1) + pages.push("ellipsis") + pages.push(totalPages) + } + } + + return pages + } + + const handlePageChange = (newPage: number) => { + if (newPage >= 1 && newPage <= totalPages) { + setPage(newPage) + } + } + + if (length === 0) { + return null + } + + return ( + <> + <div + className={cn( + "grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 min-[2000px]:grid-cols-4", + "gap-4", + )} + data-episode-list-grid + > + {currentItems} + </div> + + {shouldPaginate && totalPages > 1 && ( + <div className="flex justify-center mt-6"> + <Pagination> + <PaginationTrigger + direction="previous" + data-disabled={page === 1} + onClick={() => handlePageChange(page - 1)} + /> + + {getVisiblePages().map((pageNum, index) => ( + pageNum === "ellipsis" ? ( + <PaginationEllipsis key={`ellipsis-${index}`} /> + ) : ( + <PaginationItem + key={pageNum} + value={pageNum} + data-selected={page === pageNum} + onClick={() => handlePageChange(pageNum)} + /> + ) + ))} + + <PaginationTrigger + direction="next" + data-disabled={page === totalPages} + onClick={() => handlePageChange(page + 1)} + /> + </Pagination> + </div> + )} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/meta-section.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/meta-section.tsx new file mode 100644 index 0000000..3252d72 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/meta-section.tsx @@ -0,0 +1,240 @@ +"use client" +import { AL_AnimeDetailsById_Media, Anime_Entry } from "@/api/generated/types" +import { TrailerModal } from "@/app/(main)/_features/anime/_components/trailer-modal" +import { AnimeAutoDownloaderButton } from "@/app/(main)/_features/anime/_containers/anime-auto-downloader-button" +import { ToggleLockFilesButton } from "@/app/(main)/_features/anime/_containers/toggle-lock-files-button" +import { AnimeEntryStudio } from "@/app/(main)/_features/media/_components/anime-entry-studio" +import { + AnimeEntryRankings, + MediaEntryAudienceScore, + MediaEntryGenresList, +} from "@/app/(main)/_features/media/_components/media-entry-metadata-components" +import { + MediaPageHeader, + MediaPageHeaderDetailsContainer, + MediaPageHeaderEntryDetails, +} from "@/app/(main)/_features/media/_components/media-page-header-components" +import { MediaSyncTrackButton } from "@/app/(main)/_features/media/_containers/media-sync-track-button" +import { useHasDebridService, useHasTorrentProvider, useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { AnimeOnlinestreamButton } from "@/app/(main)/entry/_components/anime-onlinestream-button" +import { NextAiringEpisode } from "@/app/(main)/entry/_components/next-airing-episode" +import { useAnimeEntryPageView } from "@/app/(main)/entry/_containers/anime-entry-page" +import { DebridStreamButton } from "@/app/(main)/entry/_containers/debrid-stream/debrid-stream-button" +import { AnimeEntryDropdownMenu } from "@/app/(main)/entry/_containers/entry-actions/anime-entry-dropdown-menu" +import { AnimeEntrySilenceToggle } from "@/app/(main)/entry/_containers/entry-actions/anime-entry-silence-toggle" +import { TorrentSearchButton } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-button" +import { TorrentStreamButton } from "@/app/(main)/entry/_containers/torrent-stream/torrent-stream-button" +import { SeaLink } from "@/components/shared/sea-link" +import { Button, ButtonProps, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { TORRENT_CLIENT } from "@/lib/server/settings" +import { ThemeMediaPageInfoBoxSize, useThemeSettings } from "@/lib/theme/hooks" +import React from "react" +import { IoInformationCircle } from "react-icons/io5" +import { MdOutlineConnectWithoutContact } from "react-icons/md" +import { SiAnilist } from "react-icons/si" +import { useNakamaStatus } from "../../_features/nakama/nakama-manager" +import { PluginAnimePageButtons } from "../../_features/plugin/actions/plugin-actions" + +export function AnimeMetaActionButton({ className, ...rest }: ButtonProps) { + const ts = useThemeSettings() + return <Button + className={cn( + "w-full", + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "lg:w-full lg:max-w-[280px]", + className, + )} + {...rest} + // intent="gray-outline" + /> +} + +export function MetaSection(props: { entry: Anime_Entry, details: AL_AnimeDetailsById_Media | undefined }) { + const serverStatus = useServerStatus() + const { entry, details } = props + const ts = useThemeSettings() + const nakamaStatus = useNakamaStatus() + + if (!entry.media) return null + + const { hasTorrentProvider } = useHasTorrentProvider() + const { hasDebridService } = useHasDebridService() + const { currentView, isTorrentStreamingView, isDebridStreamingView, isOnlineStreamingView } = useAnimeEntryPageView() + + const ActionButtons = () => ( + <div + data-anime-meta-section-action-buttons + className={cn( + "w-full flex flex-wrap gap-4 items-center", + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "w-auto flex-wrap lg:flex-nowrap", + )} + > + + <div className="flex items-center gap-4 justify-center w-full lg:w-fit" data-anime-meta-section-action-buttons-inner-container> + + <SeaLink href={`https://anilist.co/anime/${entry.mediaId}`} target="_blank"> + <IconButton intent="gray-link" className="px-0" icon={<SiAnilist className="text-lg" />} /> + </SeaLink> + + {!!entry?.media?.trailer?.id && <TrailerModal + trailerId={entry?.media?.trailer?.id} trigger={ + <Button intent="gray-link" className="px-0"> + Trailer + </Button>} + />} + </div> + + {ts.mediaPageBannerInfoBoxSize !== ThemeMediaPageInfoBoxSize.Fluid && + <div className="flex-1 hidden lg:flex" data-anime-meta-section-action-buttons-spacer></div>} + + <div className="flex items-center gap-4 justify-center w-full lg:w-fit" data-anime-meta-section-action-buttons-inner-container> + <AnimeAutoDownloaderButton entry={entry} size="md" /> + + {!entry._isNakamaEntry && !!entry.libraryData && <> + <MediaSyncTrackButton mediaId={entry.mediaId} type="anime" size="md" /> + <AnimeEntrySilenceToggle mediaId={entry.mediaId} size="md" /> + <ToggleLockFilesButton + allFilesLocked={entry.libraryData.allFilesLocked} + mediaId={entry.mediaId} + size="md" + /> + </>} + <AnimeEntryDropdownMenu entry={entry} /> + </div> + + <PluginAnimePageButtons media={entry.media!} /> + </div> + ) + + const Details = () => ( + <div + data-anime-meta-section-details + className={cn( + "flex gap-3 flex-wrap items-center", + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "justify-center lg:justify-start lg:max-w-[65vw]", + )} + > + <MediaEntryAudienceScore meanScore={details?.meanScore} badgeClass="bg-transparent" /> + + <AnimeEntryStudio studios={details?.studios} /> + + <MediaEntryGenresList genres={details?.genres} /> + + <div + data-anime-meta-section-rankings-container + className={cn( + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid ? "w-full" : "contents", + )} + > + <AnimeEntryRankings rankings={details?.rankings} /> + </div> + </div> + ) + + return ( + <MediaPageHeader + backgroundImage={entry.media?.bannerImage} + coverImage={entry.media?.coverImage?.extraLarge} + > + + <MediaPageHeaderDetailsContainer> + + <MediaPageHeaderEntryDetails + coverImage={entry.media?.coverImage?.extraLarge || entry.media?.coverImage?.large} + title={entry.media?.title?.userPreferred} + color={entry.media?.coverImage?.color} + englishTitle={entry.media?.title?.english} + romajiTitle={entry.media?.title?.romaji} + startDate={entry.media?.startDate} + season={entry.media?.season} + progressTotal={entry.media?.episodes} + status={entry.media?.status} + description={entry.media?.description} + listData={entry.listData} + media={entry.media} + type="anime" + > + {ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && <Details />} + </MediaPageHeaderEntryDetails> + + {ts.mediaPageBannerInfoBoxSize !== ThemeMediaPageInfoBoxSize.Fluid && <Details />} + + <div + data-anime-meta-section-buttons-container + className={cn( + "flex flex-col lg:flex-row w-full gap-3 items-center", + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "flex-wrap", + )} + > + + {ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && <ActionButtons />} + + {( + entry.media.status !== "NOT_YET_RELEASED" + && currentView === "library" + && hasTorrentProvider + && ( + serverStatus?.settings?.torrent?.defaultTorrentClient !== TORRENT_CLIENT.NONE + || hasDebridService + ) + && !entry._isNakamaEntry + ) && ( + <TorrentSearchButton + entry={entry} + /> + )} + + {entry._isNakamaEntry && currentView === "library" && + <div className="flex items-center gap-2 h-10 px-4 border rounded-md flex-none"> + <MdOutlineConnectWithoutContact className="size-6 animate-pulse text-[--blue]" /> + <span className="text-sm tracking-wide">Shared by {nakamaStatus?.hostConnectionStatus?.username}</span> + </div>} + + <TorrentStreamButton + entry={entry} + /> + + <DebridStreamButton + entry={entry} + /> + + <AnimeOnlinestreamButton entry={entry} /> + + </div> + + <NextAiringEpisode media={entry.media} /> + + {entry.downloadInfo?.hasInaccurateSchedule && <p + className={cn( + "text-[--muted] text-sm text-center mb-3", + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "text-left", + )} + data-anime-meta-section-inaccurate-schedule-message + > + <span className="block">Could not retrieve accurate scheduling information for this show.</span> + <span className="block text-[--muted]">Please check the schedule online for more information.</span> + </p>} + + {ts.mediaPageBannerInfoBoxSize !== ThemeMediaPageInfoBoxSize.Fluid && <ActionButtons />} + + {(!entry.anidbId || entry.anidbId === 0) && ( + <p + className={cn( + "text-center text-gray-200 opacity-50 text-sm flex gap-1 items-center", + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "text-left", + )} + data-anime-meta-section-no-metadata-message + > + <IoInformationCircle /> + Episode metadata retrieval not available for this entry. + </p> + )} + + + </MediaPageHeaderDetailsContainer> + + </MediaPageHeader> + + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/next-airing-episode.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/next-airing-episode.tsx new file mode 100644 index 0000000..f7a120d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/next-airing-episode.tsx @@ -0,0 +1,26 @@ +import { AL_BaseAnime } from "@/api/generated/types" +import { cn } from "@/components/ui/core/styling" +import { ThemeMediaPageInfoBoxSize, useThemeSettings } from "@/lib/theme/hooks" +import { addSeconds, format, formatDistanceToNow } from "date-fns" +import React from "react" +import { BiCalendarAlt } from "react-icons/bi" + +export function NextAiringEpisode(props: { media: AL_BaseAnime }) { + const distance = formatDistanceToNow(addSeconds(new Date(), props.media.nextAiringEpisode?.timeUntilAiring || 0), { addSuffix: true }) + const day = format(addSeconds(new Date(), props.media.nextAiringEpisode?.timeUntilAiring || 0), "EEEE") + const ts = useThemeSettings() + return <> + {!!props.media.nextAiringEpisode && ( + <div + className={cn( + "flex gap-2 items-center justify-center text-lg", + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "justify-start", + )} + > + <span className="font-semibold">Episode {props.media.nextAiringEpisode?.episode}</span> {distance} + <BiCalendarAlt className="text-lg text-[--muted]" /> + <span className="text-[--muted]">{day}</span> + </div> + )} + </> +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/relations-recommendations-section.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/relations-recommendations-section.tsx new file mode 100644 index 0000000..eb3f02f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_components/relations-recommendations-section.tsx @@ -0,0 +1,91 @@ +import { AL_AnimeDetailsById_Media, Anime_Entry, Nullish } from "@/api/generated/types" +import { MediaCardGrid } from "@/app/(main)/_features/media/_components/media-card-grid" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import capitalize from "lodash/capitalize" +import React from "react" + +type RelationsRecommendationsSectionProps = { + entry: Nullish<Anime_Entry> + details: Nullish<AL_AnimeDetailsById_Media> + containerRef?: React.RefObject<HTMLElement> +} + +export function RelationsRecommendationsSection(props: RelationsRecommendationsSectionProps) { + + const { + entry, + details, + containerRef, + ...rest + } = props + + const serverStatus = useServerStatus() + + const sourceManga = React.useMemo(() => { + return serverStatus?.settings?.library?.enableManga + ? details?.relations?.edges?.find(edge => (edge?.relationType === "SOURCE" || edge?.relationType === "ADAPTATION") && edge?.node?.format === "MANGA")?.node + : undefined + }, [details?.relations?.edges, serverStatus?.settings?.library?.enableManga]) + + const relations = React.useMemo(() => (details?.relations?.edges?.map(edge => edge) || []) + .filter(Boolean) + .filter(n => (n.node?.format === "TV" || n.node?.format === "OVA" || n.node?.format === "MOVIE" || n.node?.format === "SPECIAL") && (n.relationType === "PREQUEL" || n.relationType === "SEQUEL" || n.relationType === "PARENT" || n.relationType === "SIDE_STORY" || n.relationType === "ALTERNATIVE" || n.relationType === "ADAPTATION")), + [details?.relations?.edges]) + + const recommendations = React.useMemo(() => details?.recommendations?.edges?.map(edge => edge?.node?.mediaRecommendation)?.filter(Boolean) || [], + [details?.recommendations?.edges]) + + if (!entry || !details) return null + + return ( + <> + {/*{(!!sourceManga || relations.length > 0 || recommendations.length > 0) && <Separator />}*/} + {(!!sourceManga || relations.length > 0) && ( + <> + <h2>Relations</h2> + <MediaCardGrid> + {!!sourceManga && <div className="col-span-1"> + <MediaEntryCard + media={sourceManga!} + overlay={<p + className="font-semibold text-white bg-gray-950 z-[-1] absolute right-0 w-fit px-4 py-1.5 text-center !bg-opacity-90 text-sm lg:text-base rounded-none rounded-bl-lg border border-t-0 border-r-0" + >Manga</p>} + type="manga" + /></div>} + {relations.slice(0, 4).map(edge => { + return <div key={edge.node?.id} className="col-span-1"> + <MediaEntryCard + media={edge.node!} + overlay={<p + className="font-semibold text-white bg-gray-950 z-[-1] absolute right-0 w-fit px-4 py-1.5 text-center !bg-opacity-90 text-sm lg:text-base rounded-none rounded-bl-lg border border-t-0 border-r-0" + >{edge.node?.format === "MOVIE" + ? capitalize(edge.relationType || "").replace("_", " ") + " (Movie)" + : capitalize(edge.relationType || "").replace("_", " ")}</p>} + showLibraryBadge + showTrailer + type="anime" + /> + </div> + })} + </MediaCardGrid> + </> + )} + {recommendations.length > 0 && <> + <h2>Recommendations</h2> + <MediaCardGrid> + {recommendations.map(media => { + return <div key={media.id} className="col-span-1"> + <MediaEntryCard + media={media!} + showLibraryBadge + showTrailer + type="anime" + /> + </div> + })} + </MediaCardGrid> + </>} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/anime-entry-page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/anime-entry-page.tsx new file mode 100644 index 0000000..6dc5c81 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/anime-entry-page.tsx @@ -0,0 +1,304 @@ +import { useGetAnilistAnimeDetails } from "@/api/hooks/anilist.hooks" +import { useGetAnimeEntry } from "@/api/hooks/anime_entries.hooks" +import { MediaEntryCharactersSection } from "@/app/(main)/_features/media/_components/media-entry-characters-section" +import { MediaEntryPageLoadingDisplay } from "@/app/(main)/_features/media/_components/media-entry-page-loading-display" +import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { MetaSection } from "@/app/(main)/entry/_components/meta-section" +import { RelationsRecommendationsSection } from "@/app/(main)/entry/_components/relations-recommendations-section" +import { DebridStreamPage } from "@/app/(main)/entry/_containers/debrid-stream/debrid-stream-page" +import { EpisodeSection } from "@/app/(main)/entry/_containers/episode-list/episode-section" +import { __torrentSearch_selectionAtom, TorrentSearchDrawer } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { TorrentStreamPage } from "@/app/(main)/entry/_containers/torrent-stream/torrent-stream-page" +import { OnlinestreamPage } from "@/app/(main)/onlinestream/_containers/onlinestream-page" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { ThemeMediaPageInfoBoxSize, useThemeSettings } from "@/lib/theme/hooks" +import { atom } from "jotai" +import { useAtom, useSetAtom } from "jotai/react" +import { AnimatePresence } from "motion/react" +import { useRouter, useSearchParams } from "next/navigation" +import React from "react" +import { useUnmount } from "react-use" + +export const __anime_entryPageViewAtom = atom<"library" | "torrentstream" | "debridstream" | "onlinestream">("library") + +export function useAnimeEntryPageView() { + const [currentView, setView] = useAtom(__anime_entryPageViewAtom) + + const isLibraryView = currentView === "library" + const isTorrentStreamingView = currentView === "torrentstream" + const isDebridStreamingView = currentView === "debridstream" + const isOnlineStreamingView = currentView === "onlinestream" + + function toggleTorrentStreamingView() { + setView(p => p === "torrentstream" ? "library" : "torrentstream") + } + + function toggleDebridStreamingView() { + setView(p => p === "debridstream" ? "library" : "debridstream") + } + + function toggleOnlineStreamingView() { + setView(p => p === "onlinestream" ? "library" : "onlinestream") + } + + return { + currentView, + setView, + isLibraryView, + isTorrentStreamingView, + isDebridStreamingView, + isOnlineStreamingView, + toggleTorrentStreamingView, + toggleDebridStreamingView, + toggleOnlineStreamingView, + } +} + +export function AnimeEntryPage() { + + const serverStatus = useServerStatus() + const router = useRouter() + const searchParams = useSearchParams() + const mediaId = searchParams.get("id") + const { data: animeEntry, isLoading: animeEntryLoading } = useGetAnimeEntry(mediaId) + const { data: animeDetails, isLoading: animeDetailsLoading } = useGetAnilistAnimeDetails(mediaId) + const ts = useThemeSettings() + + const { currentView, isLibraryView, setView } = useAnimeEntryPageView() + + React.useEffect(() => { + try { + if (animeEntry?.media?.title?.userPreferred) { + document.title = `${animeEntry?.media?.title?.userPreferred} | Seanime` + } + } + catch { + } + }, [animeEntry]) + + // useWebsocketSendEffect({ + // type: WebviewEvents.ANIME_ENTRY_PAGE_VIEWED, + // payload: { + // animeEntry, + // }, + // }, animeEntry) + + const switchedView = React.useRef(false) + React.useLayoutEffect(() => { + if (!animeEntryLoading && + animeEntry?.media?.status !== "NOT_YET_RELEASED" && // Anime is not yet released + searchParams.get("tab") && searchParams.get("tab") !== "library" && // Tab is not library + !switchedView.current // View has not been switched yet + ) { + switchedView.current = true + if (serverStatus?.debridSettings?.enabled && searchParams.get("tab") === "debridstream") { + setView("debridstream") + } else if (serverStatus?.torrentstreamSettings?.enabled && searchParams.get("tab") === "torrentstream") { + setView("torrentstream") + } else if (serverStatus?.settings?.library?.enableOnlinestream && searchParams.get("tab") === "onlinestream") { + setView("onlinestream") + } + } + + if (!animeEntryLoading && + animeEntry?.media?.status !== "NOT_YET_RELEASED" && // Anime is not yet released + !animeEntry?.libraryData && // Anime is not in library + isLibraryView && // Current view is library + ( + // If any of the fallbacks are enabled and the view has not been switched yet + (serverStatus?.torrentstreamSettings?.enabled && serverStatus?.torrentstreamSettings?.includeInLibrary) || + (serverStatus?.debridSettings?.enabled && serverStatus?.debridSettings?.includeDebridStreamInLibrary) || + (serverStatus?.settings?.library?.enableOnlinestream && serverStatus?.settings?.library?.includeOnlineStreamingInLibrary) + ) && + !switchedView.current // View has not been switched yet + ) { + switchedView.current = true + if (serverStatus?.debridSettings?.enabled && serverStatus?.debridSettings?.includeDebridStreamInLibrary) { + setView("debridstream") + } else if (serverStatus?.torrentstreamSettings?.enabled && serverStatus?.torrentstreamSettings?.includeInLibrary) { + setView("torrentstream") + } else if (serverStatus?.settings?.library?.enableOnlinestream && serverStatus?.settings?.library?.includeOnlineStreamingInLibrary) { + setView("onlinestream") + } + } + + }, [animeEntryLoading, searchParams, serverStatus?.torrentstreamSettings?.includeInLibrary, currentView]) + + React.useEffect(() => { + if (!mediaId || (!animeEntryLoading && !animeEntry)) { + router.push("/") + } + }, [animeEntry, animeEntryLoading]) + + // Reset view when unmounting + useUnmount(() => { + setView("library") + }) + + const setTorrentSearchDrawer = useSetAtom(__torrentSearch_selectionAtom) + + const { inject, remove } = useSeaCommandInject() + React.useEffect(() => { + inject("anime-entry-navigation", { + items: [ + ...[{ + id: "library", + description: "Downloaded episodes", + show: currentView !== "library", + }, + { + id: "torrentstream", + description: "Torrent streaming", + show: serverStatus?.torrentstreamSettings?.enabled && currentView !== "torrentstream", + }, + { + id: "debridstream", + description: "Debrid streaming", + show: serverStatus?.debridSettings?.enabled && currentView !== "debridstream", + }, + { + id: "onlinestream", + description: "Online streaming", + show: serverStatus?.settings?.library?.enableOnlinestream && currentView !== "onlinestream", + }, + ].map(item => ({ + id: item.id, + value: item.id, + heading: "Views", + data: item, + render: () => <div>{item.description}</div>, + onSelect: () => setView(item.id as any), + shouldShow: () => !!item.show, + })), + { + id: "download", + value: "download", + render: () => <div>Download torrents</div>, + heading: "Views", + data: "download torrents", + onSelect: () => setTorrentSearchDrawer("download"), + shouldShow: () => currentView === "library", + }, + ], + filter: ({ item, input }) => { + if (!input) return true + return item.data?.description?.toLowerCase().startsWith(input.toLowerCase()) + }, + priority: -1, + }) + + return () => remove("anime-entry-navigation") + }, [currentView, serverStatus]) + + if (animeEntryLoading || animeDetailsLoading) return <MediaEntryPageLoadingDisplay /> + if (!animeEntry) return null + + return ( + <div data-anime-entry-page data-media={JSON.stringify(animeEntry.media)} data-anime-entry-list-data={JSON.stringify(animeEntry.listData)}> + <MetaSection entry={animeEntry} details={animeDetails} /> + + <div className="px-4 md:px-8 relative z-[8]" data-anime-entry-page-content-container> + <PageWrapper + data-anime-entry-page-content + className="relative 2xl:order-first pb-10 lg:min-h-[calc(100vh-10rem)]" + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: 60 }, + transition: { + type: "spring", + damping: 10, + stiffness: 80, + delay: 0.6, + }, + }} + > + {(ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid) && ( + <> + {/*{currentView !== "library" ? <div className="h-10 lg:h-0" /> : }*/} + </> + )} + <AnimatePresence mode="wait" initial={false}> + + {(currentView === "library") && <PageWrapper + data-anime-entry-page-episode-list-view + key="episode-list" + className="relative 2xl:order-first pb-10" + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + > + <div className="h-10" /> + <EpisodeSection + entry={animeEntry} + details={animeDetails} + bottomSection={<> + <MediaEntryCharactersSection details={animeDetails} /> + <RelationsRecommendationsSection entry={animeEntry} details={animeDetails} /> + </>} + /> + </PageWrapper>} + + {currentView === "torrentstream" && + <TorrentStreamPage + entry={animeEntry} + bottomSection={<> + <MediaEntryCharactersSection details={animeDetails} /> + <RelationsRecommendationsSection entry={animeEntry} details={animeDetails} /> + </>} + />} + + {currentView === "debridstream" && + <DebridStreamPage + entry={animeEntry} + bottomSection={<> + <MediaEntryCharactersSection details={animeDetails} /> + <RelationsRecommendationsSection entry={animeEntry} details={animeDetails} /> + </>} + />} + + {currentView === "onlinestream" && <PageWrapper + data-anime-entry-page-online-streaming-view + key="online-streaming-episodes" + className="relative 2xl:order-first pb-10 lg:pt-0" + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + > + <div className="h-10 lg:h-0" /> + <div className="space-y-4" data-anime-entry-page-online-streaming-view-content> + <div + className="absolute right-0 top-[-0.5rem] lg:top-[-3rem]" + data-anime-entry-page-online-streaming-view-content-title-container + > + <h2 className="text-xl lg:text-3xl flex items-center gap-3">Online streaming</h2> + </div> + <OnlinestreamPage + animeEntry={animeEntry} + animeEntryLoading={animeEntryLoading} + hideBackButton + /> + <MediaEntryCharactersSection details={animeDetails} /> + </div> + </PageWrapper>} + + </AnimatePresence> + </PageWrapper> + </div> + + <TorrentSearchDrawer entry={animeEntry} /> + </div> + ) +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/_lib/handle-debrid-stream.ts b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/_lib/handle-debrid-stream.ts new file mode 100644 index 0000000..0882705 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/_lib/handle-debrid-stream.ts @@ -0,0 +1,92 @@ +import { Anime_Entry, HibikeTorrent_AnimeTorrent, Torrentstream_PlaybackType } from "@/api/generated/types" +import { useDebridStartStream } from "@/api/hooks/debrid.hooks" +import { + ElectronPlaybackMethod, + PlaybackTorrentStreaming, + useCurrentDevicePlaybackSettings, + useExternalPlayerLink, +} from "@/app/(main)/_atoms/playback.atoms" +import { __debridstream_stateAtom } from "@/app/(main)/entry/_containers/debrid-stream/debrid-stream-overlay" +import { clientIdAtom } from "@/app/websocket-provider" +import { __isElectronDesktop__ } from "@/types/constants" +import { useAtomValue } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" + +type DebridStreamSelectionProps = { + torrent: HibikeTorrent_AnimeTorrent + entry: Anime_Entry + episodeNumber: number + aniDBEpisode: string + chosenFileId: string +} +type DebridStreamAutoSelectProps = { + entry: Anime_Entry + episodeNumber: number + aniDBEpisode: string +} + +export function useHandleStartDebridStream() { + + const { mutate, isPending } = useDebridStartStream() + + const { torrentStreamingPlayback, electronPlaybackMethod } = useCurrentDevicePlaybackSettings() + const { externalPlayerLink } = useExternalPlayerLink() + const clientId = useAtomValue(clientIdAtom) + + const [state, setState] = useAtom(__debridstream_stateAtom) + + const playbackType = React.useMemo<Torrentstream_PlaybackType>(() => { + if (__isElectronDesktop__ && electronPlaybackMethod === ElectronPlaybackMethod.NativePlayer) { + return "nativeplayer" + } + if (!!externalPlayerLink?.length && torrentStreamingPlayback === PlaybackTorrentStreaming.ExternalPlayerLink) { + return "externalPlayerLink" + } + return "default" + }, [torrentStreamingPlayback, externalPlayerLink]) + + const handleStreamSelection = React.useCallback((params: DebridStreamSelectionProps) => { + mutate({ + mediaId: params.entry.mediaId, + episodeNumber: params.episodeNumber, + torrent: params.torrent, + aniDBEpisode: params.aniDBEpisode, + fileId: params.chosenFileId, + playbackType: playbackType, + clientId: clientId || "", + autoSelect: false, + }, { + onSuccess: () => { + }, + onError: () => { + setState(null) + }, + }) + }, [playbackType, clientId]) + + const handleAutoSelectStream = React.useCallback((params: DebridStreamAutoSelectProps) => { + mutate({ + mediaId: params.entry.mediaId, + episodeNumber: params.episodeNumber, + torrent: undefined, + aniDBEpisode: params.aniDBEpisode, + fileId: "", + playbackType: playbackType, + clientId: clientId || "", + autoSelect: true, + }, { + onSuccess: () => { + }, + onError: () => { + setState(null) + }, + }) + }, [playbackType, clientId]) + + return { + handleStreamSelection, + handleAutoSelectStream, + isPending, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-button.tsx new file mode 100644 index 0000000..edc3df2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-button.tsx @@ -0,0 +1,47 @@ +import { Anime_Entry } from "@/api/generated/types" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { AnimeMetaActionButton } from "@/app/(main)/entry/_components/meta-section" +import { useAnimeEntryPageView } from "@/app/(main)/entry/_containers/anime-entry-page" +import React from "react" +import { AiOutlineArrowLeft } from "react-icons/ai" +import { HiOutlineServerStack } from "react-icons/hi2" + +type DebridStreamButtonProps = { + children?: React.ReactNode + entry: Anime_Entry +} + +export function DebridStreamButton(props: DebridStreamButtonProps) { + + const { + children, + entry, + ...rest + } = props + + const serverStatus = useServerStatus() + + const { isLibraryView, isDebridStreamingView, toggleDebridStreamingView } = useAnimeEntryPageView() + + if ( + !entry || + entry.media?.status === "NOT_YET_RELEASED" || + !serverStatus?.debridSettings?.enabled + ) return null + + if (!isLibraryView && !isDebridStreamingView) return null + + return ( + <> + <AnimeMetaActionButton + data-debrid-stream-button + intent={isDebridStreamingView ? "gray-subtle" : "white-subtle"} + size="md" + leftIcon={isDebridStreamingView ? <AiOutlineArrowLeft className="text-xl" /> : <HiOutlineServerStack className="text-2xl" />} + onClick={() => toggleDebridStreamingView()} + > + {isDebridStreamingView ? "Close Debrid streaming" : "Debrid streaming"} + </AnimeMetaActionButton> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-file-selection-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-file-selection-modal.tsx new file mode 100644 index 0000000..731f36e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-file-selection-modal.tsx @@ -0,0 +1,184 @@ +import { Anime_Entry } from "@/api/generated/types" +import { useDebridGetTorrentFilePreviews } from "@/api/hooks/debrid.hooks" +import { useHandleStartDebridStream } from "@/app/(main)/entry/_containers/debrid-stream/_lib/handle-debrid-stream" +import { useTorrentSearchSelectedStreamEpisode } from "@/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-selection" +import { __torrentSearch_selectionAtom } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { __torrentSearch_torrentstreamSelectedTorrentAtom } from "@/app/(main)/entry/_containers/torrent-stream/torrent-stream-file-selection-modal" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { RadioGroup } from "@/components/ui/radio-group" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Tooltip } from "@/components/ui/tooltip" +import { useAtom } from "jotai/react" +import React from "react" +import { IoPlayCircle } from "react-icons/io5" +import { MdVerified } from "react-icons/md" + +type DebridStreamFileSelectionModalProps = { + entry: Anime_Entry +} + +export function DebridStreamFileSelectionModal(props: DebridStreamFileSelectionModalProps) { + + const { + entry, + } = props + + const [, setter] = useAtom(__torrentSearch_selectionAtom) + + const [selectedTorrent, setSelectedTorrent] = useAtom(__torrentSearch_torrentstreamSelectedTorrentAtom) + + const [selectedFileId, setSelectedFileIdx] = React.useState("") + + const { torrentStreamingSelectedEpisode } = useTorrentSearchSelectedStreamEpisode() + + const { data: previews, isLoading } = useDebridGetTorrentFilePreviews({ + torrent: selectedTorrent!, + media: entry.media, + episodeNumber: torrentStreamingSelectedEpisode?.episodeNumber, + }, !!selectedTorrent) + + const { handleStreamSelection } = useHandleStartDebridStream() + + function onStream(selectedFileId: string) { + if (selectedFileId == "" || !selectedTorrent || !torrentStreamingSelectedEpisode || !torrentStreamingSelectedEpisode.aniDBEpisode) return + + handleStreamSelection({ + torrent: selectedTorrent, + entry, + aniDBEpisode: torrentStreamingSelectedEpisode.aniDBEpisode, + episodeNumber: torrentStreamingSelectedEpisode.episodeNumber, + chosenFileId: selectedFileId, + }) + + setSelectedTorrent(undefined) + setSelectedFileIdx("") + setter(undefined) + } + + React.useEffect(() => { + if (previews && previews.length === 1) { + setSelectedFileIdx(String(previews[0].fileId)) + React.startTransition(() => { + onStream(String(previews[0].fileId)) + }) + } + }, [previews]) + + const hasLikelyMatch = previews?.some(f => f.isLikely) + const hasOneLikelyMatch = hasLikelyMatch && previews?.filter(f => f.isLikely).length === 1 + const likelyMatchRef = React.useRef<HTMLDivElement>(null) + + const FileSelection = React.useCallback(() => { + return <RadioGroup + value={selectedFileId} + onValueChange={v => setSelectedFileIdx(v)} + options={(previews?.toSorted((a, b) => a.path.localeCompare(b.path))?.map((f, i) => { + return { + label: <div + className={cn( + "w-full", + (hasLikelyMatch && !f.isLikely) && "opacity-60", + )} + ref={hasOneLikelyMatch && f.isLikely ? likelyMatchRef : undefined} + > + <p className="mb-1 line-clamp-1"> + {f.displayTitle} + </p> + {f.isLikely && <p className="flex items-center"> + <MdVerified className="text-[--green] mr-1" /> + <span className="text-white">Likely match</span> + </p>} + <Tooltip trigger={<p className="font-normal line-clamp-1 text-sm text-[--muted]">{f.displayPath}</p>}> + {f.path} + </Tooltip> + </div>, + value: String(f.fileId), + } + }) || [])} + itemContainerClass={cn( + "items-start cursor-pointer transition border-transparent rounded-[--radius] p-2 w-full", + "hover:bg-[--subtle] bg-gray-900 hover:bg-gray-950", + "data-[state=checked]:bg-white dark:data-[state=checked]:bg-gray-950", + "focus:ring-2 ring-brand-100 dark:ring-brand-900 ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition", + "border border-transparent data-[state=checked]:border-[--brand] data-[state=checked]:ring-offset-0", + )} + itemClass={cn( + "border-transparent absolute top-2 right-2 bg-transparent dark:bg-transparent dark:data-[state=unchecked]:bg-transparent", + "data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent dark:data-[state=unchecked]:hover:bg-transparent", + "focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:ring-offset-transparent", + )} + itemIndicatorClass="hidden" + itemLabelClass="font-medium flex flex-col items-center data-[state=checked]:text-[--brand] cursor-pointer" + stackClass="flex flex-col gap-2 space-y-0" + /> + }, [previews, selectedFileId, hasLikelyMatch]) + + const scrollRef = React.useRef<HTMLDivElement>(null) + + // Scroll to the likely match on mount + React.useEffect(() => { + if (hasOneLikelyMatch && likelyMatchRef.current && scrollRef.current) { + const t = setTimeout(() => { + const element = likelyMatchRef.current + const container = scrollRef.current + + if (element && container) { + const elementRect = element.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + + const scrollTop = elementRect.top - containerRect.top + container.scrollTop - 16 // 16px offset for padding + + container.scrollTo({ + top: scrollTop, + behavior: "smooth", + }) + } + }, 1000) // Increased timeout to ensure DOM is ready + return () => clearTimeout(t) + } + }, [hasOneLikelyMatch, likelyMatchRef.current]) + + return ( + <Modal + open={!!selectedTorrent} + onOpenChange={open => { + if (!open) { + setSelectedTorrent(undefined) + setSelectedFileIdx("") + } + }} + // size="xl" + contentClass="max-w-5xl" + title={previews?.length !== 1 ? "Choose a file to stream" : "Launching stream..."} + > + {(isLoading || previews?.length === 1) ? <LoadingSpinner + title={previews?.length === 1 ? "Launching stream..." : "Fetching torrent info..."} + /> : ( + <AppLayoutStack className="mt-4"> + + <div className="flex"> + <div className="flex flex-1"></div> + <Button + intent="primary" + className="" + rightIcon={<IoPlayCircle className="text-xl" />} + disabled={selectedFileId === "" || isLoading} + onClick={() => onStream(selectedFileId)} + > + Stream + </Button> + </div> + + <ScrollArea viewportRef={scrollRef} className="h-[75dvh] overflow-y-auto p-4 border rounded-[--radius-md]"> + <FileSelection /> + </ScrollArea> + + </AppLayoutStack> + )} + </Modal> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-overlay.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-overlay.tsx new file mode 100644 index 0000000..b2a41e1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-overlay.tsx @@ -0,0 +1,197 @@ +import { DebridClient_StreamState } from "@/api/generated/types" +import { useDebridCancelStream } from "@/api/hooks/debrid.hooks" +import { PlaybackManager_PlaybackState } from "@/app/(main)/_features/progress-tracking/_lib/playback-manager.types" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { LoadingSpinner, Spinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { WSEvents } from "@/lib/server/ws-events" +import { atom } from "jotai/index" +import { useAtom } from "jotai/react" +import React from "react" +import { HiOutlineServerStack } from "react-icons/hi2" +import { toast } from "sonner" + +// export const __debridstream_stateAtom = atom<DebridClient_StreamState | null>({ +// status: "downloading", +// torrentName: "[Seanime] Some Anime - S01E03.mkv", +// message: "Downloading torrent...", +// }) + +export const __debridstream_stateAtom = atom<DebridClient_StreamState | null>(null) + +export function DebridStreamOverlay() { + + const [state, setState] = useAtom(__debridstream_stateAtom) + + const { mutate: cancelStream, isPending: isCancelling } = useDebridCancelStream() + + const [minimized, setMinimized] = React.useState(true) + + const [showMediaPlayerLoading, setShowMediaPlayerLoading] = React.useState(false) + + // Reset showMediaPlayerLoading after 3 minutes + React.useEffect(() => { + const timeout = setTimeout(() => { + setShowMediaPlayerLoading(false) + }, 2 * 60 * 1000) + return () => clearTimeout(timeout) + }, [showMediaPlayerLoading]) + + useWebsocketMessageListener<DebridClient_StreamState>({ + type: WSEvents.DEBRID_STREAM_STATE, + onMessage: data => { + if (data) { + if (data.status === "downloading" || data.status === "started") { + setState(data) + setShowMediaPlayerLoading(false) + return + } + if (data.status === "failed") { + setState(null) + toast.error(data.message) + setShowMediaPlayerLoading(false) + return + } + if (data.status === "ready") { + setState(null) + toast.info("Sending stream to player...", { duration: 1 }) + setShowMediaPlayerLoading(true) + return + } + } + }, + }) + + useWebsocketMessageListener<PlaybackManager_PlaybackState | null>({ + type: WSEvents.PLAYBACK_MANAGER_PROGRESS_TRACKING_STARTED, + onMessage: data => { + if (data) { + setShowMediaPlayerLoading(false) + } + }, + }) + + const confirmCancelAndRemoveTorrent = useConfirmationDialog({ + title: "Cancel and remove torrent", + description: "Are you sure you want to cancel the stream and remove the torrent?", + onConfirm: () => { + cancelStream({ + options: { + removeTorrent: true, + }, + }, { + onSuccess: () => { + setState(null) + }, + }) + }, + }) + + const confirmCancelStream = useConfirmationDialog({ + title: "Cancel stream", + description: "Are you sure you want to cancel the stream?", + onConfirm: () => { + cancelStream({ + options: { + removeTorrent: false, + }, + }, { + onSuccess: () => { + setState(null) + }, + }) + }, + }) + + if (!state) return ( + <> + {/*{(showMediaPlayerLoading) && <div className="w-full bg-gray-950 fixed top-0 left-0 z-[100]">*/} + {/* <ProgressBar size="xs" isIndeterminate />*/} + {/*</div>}*/} + </> + ) + + return ( + <> + + {minimized && ( + <div className="fixed z-[100] bottom-8 w-full h-fit flex justify-center"> + <div + className="shadow-2xl p-4 bg-gray-900 border text-white rounded-3xl cursor-pointer hover:border-gray-600" + onClick={() => setMinimized(false)} + > + <div className="flex items-center justify-center gap-4"> + <HiOutlineServerStack className="text-2xl text-[--brand]" /> + <div className=""> + <p> + Awaiting stream from Debrid service + </p> + <p className="text-[--muted] text-sm"> + {state?.message} + </p> + </div> + <Spinner className="size-5" /> + </div> + </div> + </div> + )} + + {/*{state?.status === "downloading" && <div className="w-full bg-gray-950 fixed top-0 left-0 z-[100]">*/} + {/* <ProgressBar size="xs" isIndeterminate />*/} + {/*</div>}*/} + + <Modal + contentClass="max-w-xl sm:rounded-3xl" + // title="Awaiting stream" + open={!minimized && !!state} + onOpenChange={v => setMinimized(!v)} + > + + <AppLayoutStack> + + <p className="text-[--muted] italic text-sm"> + Closing this modal will not cancel the stream + </p> + + <div className="p-4 pb-0"> + <p className="text-center text-sm line-clamp-1 tracking-wide"> + {state?.torrentName} + </p> + + <LoadingSpinner + title={state?.message} + /> + </div> + + <div className="flex justify-center gap-1 mt-4"> + <Button + onClick={() => confirmCancelStream.open()} + intent="alert-basic" + disabled={isCancelling || state?.status !== "downloading" || state?.message === "Downloading torrent..."} + size="sm" + > + Cancel + </Button> + <Button + onClick={() => confirmCancelAndRemoveTorrent.open()} + intent="alert-basic" + disabled={isCancelling || state?.status !== "downloading" || state?.message === "Downloading torrent..."} + size="sm" + > + Cancel and remove torrent + </Button> + </div> + + </AppLayoutStack> + + + </Modal> + + <ConfirmationDialog {...confirmCancelStream} /> + <ConfirmationDialog {...confirmCancelAndRemoveTorrent} /> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-page.tsx new file mode 100644 index 0000000..8cdaa5c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/debrid-stream/debrid-stream-page.tsx @@ -0,0 +1,213 @@ +import { Anime_Entry, Anime_Episode } from "@/api/generated/types" +import { useGetAnimeEpisodeCollection } from "@/api/hooks/anime.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useHandleStartDebridStream } from "@/app/(main)/entry/_containers/debrid-stream/_lib/handle-debrid-stream" +import { useTorrentSearchSelectedStreamEpisode } from "@/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-selection" +import { + __torrentSearch_selectionAtom, + __torrentSearch_selectionEpisodeAtom, +} from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { TorrentStreamEpisodeSection } from "@/app/(main)/entry/_containers/torrent-stream/_components/torrent-stream-episode-section" +import { useDebridStreamAutoplay } from "@/app/(main)/entry/_containers/torrent-stream/_lib/handle-torrent-stream" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Switch } from "@/components/ui/switch" +import { logger } from "@/lib/helpers/debug" +import { useAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import React from "react" + +type DebridStreamPageProps = { + children?: React.ReactNode + entry: Anime_Entry + bottomSection?: React.ReactNode +} + +const autoSelectFileAtom = atomWithStorage("sea-debridstream-manually-select-file", false) + +// DEVNOTE: This page uses some utility functions from the TorrentStream feature + +export function DebridStreamPage(props: DebridStreamPageProps) { + + const { + children, + entry, + bottomSection, + ...rest + } = props + + const serverStatus = useServerStatus() + + // State to manage auto-select setting + const [autoSelect, setAutoSelect] = React.useState(serverStatus?.debridSettings?.streamAutoSelect) + const [autoSelectFile, setAutoSelectFile] = useAtom(autoSelectFileAtom) + + /** + * Get all episodes to watch + */ + const { data: episodeCollection, isLoading } = useGetAnimeEpisodeCollection(entry.mediaId) + + React.useLayoutEffect(() => { + // Set auto-select to the server status value + if (!episodeCollection?.hasMappingError) { + setAutoSelect(serverStatus?.debridSettings?.streamAutoSelect) + } else { + // Fall back to manual select if no download info (no Animap data) + setAutoSelect(false) + } + }, [serverStatus?.torrentstreamSettings?.autoSelect, episodeCollection]) + + // Atoms to control the torrent search drawer state + const [, setTorrentSearchDrawerOpen] = useAtom(__torrentSearch_selectionAtom) + const [, setTorrentSearchEpisode] = useAtom(__torrentSearch_selectionEpisodeAtom) + + // Stores the episode that was clicked + const { setTorrentStreamingSelectedEpisode } = useTorrentSearchSelectedStreamEpisode() + + // Function to handle playing the next episode on mount + function handlePlayNextEpisodeOnMount(episode: Anime_Episode) { + if (autoSelect) { + handleAutoSelect(entry, episode) + } else { + handleEpisodeClick(episode) + } + } + + // Hook to handle starting the debrid stream + const { handleAutoSelectStream } = useHandleStartDebridStream() + + // Hook to manage debrid stream autoplay information + const { setDebridstreamAutoplayInfo } = useDebridStreamAutoplay() + + // Function to set the debrid stream autoplay info + // It checks if there is a next episode and if it has aniDBEpisode + // If so, it sets the autoplay info + // Otherwise, it resets the autoplay info + function handleSetDebridstreamAutoplayInfo(episode: Anime_Episode | undefined) { + if (!episode || !episode.aniDBEpisode || !episodeCollection?.episodes) return + const nextEpisode = episodeCollection?.episodes?.find(e => e.episodeNumber === episode.episodeNumber + 1) + logger("TORRENTSTREAM").info("Auto select, Next episode", nextEpisode) + if (nextEpisode && !!nextEpisode.aniDBEpisode) { + setDebridstreamAutoplayInfo({ + allEpisodes: episodeCollection?.episodes, + entry: entry, + episodeNumber: nextEpisode.episodeNumber, + aniDBEpisode: nextEpisode.aniDBEpisode, + type: "debridstream", + }) + } else { + setDebridstreamAutoplayInfo(null) + } + } + + // Function to handle auto-selecting an episode + function handleAutoSelect(entry: Anime_Entry, episode: Anime_Episode | undefined) { + if (!episode || !episode.aniDBEpisode || !episodeCollection?.episodes) return + // Start the debrid stream + handleAutoSelectStream({ + entry: entry, + episodeNumber: episode.episodeNumber, + aniDBEpisode: episode.aniDBEpisode, + }) + + // Set the debrid stream autoplay info + handleSetDebridstreamAutoplayInfo(episode) + } + + // Function to handle episode click events + const handleEpisodeClick = (episode: Anime_Episode) => { + if (!episode || !episode.aniDBEpisode) return + + setTorrentStreamingSelectedEpisode(episode) + + if (autoSelect) { + handleAutoSelect(entry, episode) + } else { + React.startTransition(() => { + setTorrentSearchEpisode(episode.episodeNumber) + React.startTransition(() => { + // If auto-select file is enabled, open the debrid stream select drawer + if (autoSelectFile) { + setTorrentSearchDrawerOpen("debridstream-select") + + // Set the debrid stream autoplay info + handleSetDebridstreamAutoplayInfo(episode) + } else { + // Otherwise, open the debrid stream select file drawer + setTorrentSearchDrawerOpen("debridstream-select-file") + } + }) + }) + } + } + + if (!entry.media) return null + if (isLoading) return <LoadingSpinner /> + + return ( + <> + <PageWrapper + data-anime-entry-page-debrid-stream-view + key="torrent-streaming-episodes" + className="relative 2xl:order-first pb-10 lg:pt-0" + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + > + <div className="h-10 lg:h-0" /> + <AppLayoutStack data-debrid-stream-page> + <div className="absolute right-0 top-[-3rem]" data-debrid-stream-page-title-container> + <h2 className="text-xl lg:text-3xl flex items-center gap-3">Debrid streaming</h2> + </div> + + <div className="flex flex-col md:flex-row gap-6 pb-6 2xl:py-0" data-debrid-stream-page-content-actions-container> + <Switch + label="Auto-select" + value={autoSelect} + onValueChange={v => { + setAutoSelect(v) + }} + // help="Automatically select the best torrent and file to stream" + fieldClass="w-fit" + /> + + {!autoSelect && ( + <Switch + label="Auto-select file" + value={autoSelectFile} + onValueChange={v => { + setAutoSelectFile(v) + }} + moreHelp="The episode file will be automatically selected from your chosen batch torrent" + fieldClass="w-fit" + /> + )} + </div> + + {episodeCollection?.hasMappingError && ( + <div data-debrid-stream-page-no-metadata-message-container> + <p className="text-red-200 opacity-50"> + No metadata info available for this anime. You may need to manually select the file to stream. + </p> + </div> + + )} + + <TorrentStreamEpisodeSection + episodeCollection={episodeCollection} + entry={entry} + onEpisodeClick={handleEpisodeClick} + onPlayNextEpisodeOnMount={handlePlayNextEpisodeOnMount} + bottomSection={bottomSection} + /> + </AppLayoutStack> + </PageWrapper> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-bulk-delete-files-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-bulk-delete-files-modal.tsx new file mode 100644 index 0000000..7cc963c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-bulk-delete-files-modal.tsx @@ -0,0 +1,100 @@ +import { Anime_Entry } from "@/api/generated/types" +import { useDeleteLocalFiles } from "@/api/hooks/localfiles.hooks" +import { FilepathSelector } from "@/app/(main)/_features/media/_components/filepath-selector" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" + +export type AnimeEntryBulkDeleteFilesModalProps = { + entry: Anime_Entry +} + +export const __bulkDeleteFilesModalIsOpenAtom = atom(false) + + +export function AnimeEntryBulkDeleteFilesModal({ entry }: AnimeEntryBulkDeleteFilesModalProps) { + + const [open, setOpen] = useAtom(__bulkDeleteFilesModalIsOpenAtom) + + return ( + <Modal + open={open} + onOpenChange={() => setOpen(false)} + contentClass="max-w-2xl" + title={<span>Select files to delete</span>} + titleClass="text-center" + + > + <Content entry={entry} /> + </Modal> + ) + +} + +function Content({ entry }: { entry: Anime_Entry }) { + + const [open, setOpen] = useAtom(__bulkDeleteFilesModalIsOpenAtom) + + const [filepaths, setFilepaths] = React.useState<string[]>([]) + + const media = entry.media + + React.useEffect(() => { + if (entry.localFiles) { + setFilepaths(entry.localFiles.map(f => f.path)) + } + }, [entry.localFiles]) + + + const { mutate: deleteFiles, isPending: isDeleting } = useDeleteLocalFiles(entry.mediaId) + + const confirmUnmatch = useConfirmationDialog({ + title: "Delete files", + description: "This action cannot be undone.", + onConfirm: () => { + if (filepaths.length === 0) return + + deleteFiles({ paths: filepaths }, { + onSuccess: () => { + setOpen(false) + }, + }) + }, + }) + + if (!media) return null + + return ( + <div className="space-y-2 mt-2"> + + <FilepathSelector + className="max-h-96" + filepaths={filepaths} + allFilepaths={entry.localFiles?.map(n => n.path) ?? []} + onFilepathSelected={setFilepaths} + showFullPath + /> + + <div className="flex justify-end gap-2 mt-2"> + <Button + intent="alert" + onClick={() => confirmUnmatch.open()} + loading={isDeleting} + > + Delete + </Button> + <Button + intent="white" + onClick={() => setOpen(false)} + disabled={isDeleting} + > + Cancel + </Button> + </div> + <ConfirmationDialog {...confirmUnmatch} /> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-download-files-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-download-files-modal.tsx new file mode 100644 index 0000000..020a6a9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-download-files-modal.tsx @@ -0,0 +1,84 @@ +import { getServerBaseUrl } from "@/api/client/server-url" +import { Anime_Entry } from "@/api/generated/types" +import { FilepathSelector } from "@/app/(main)/_features/media/_components/filepath-selector" +import { useServerHMACAuth } from "@/app/(main)/_hooks/use-server-status" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { Separator } from "@/components/ui/separator" +import { openTab } from "@/lib/helpers/browser" +import { atom } from "jotai/index" +import { useAtom } from "jotai/react" +import React from "react" + +export type AnimeEntryDownloadFilesModalProps = { + entry: Anime_Entry +} + +export const __animeEntryDownloadFilesModalIsOpenAtom = atom(false) + + +export function AnimeEntryDownloadFilesModal({ entry }: AnimeEntryDownloadFilesModalProps) { + + const [open, setOpen] = useAtom(__animeEntryDownloadFilesModalIsOpenAtom) + + + return ( + <Modal + open={open} + onOpenChange={() => setOpen(false)} + contentClass="max-w-2xl" + title={<span>Select files to download</span>} + titleClass="text-center" + + > + <Content entry={entry} /> + </Modal> + ) + +} + +function Content({ entry }: { entry: Anime_Entry }) { + + const [open, setOpen] = useAtom(__animeEntryDownloadFilesModalIsOpenAtom) + const [filepaths, setFilepaths] = React.useState<string[]>([]) + const { getHMACTokenQueryParam } = useServerHMACAuth() + + async function handleDownload() { + for (const filepath of filepaths) { + const endpoint = "/api/v1/mediastream/file?path=" + encodeURIComponent(filepath) + const tokenQueryParam = await getHMACTokenQueryParam("/api/v1/mediastream/file", "&") + const url = getServerBaseUrl() + endpoint + tokenQueryParam + openTab(url) + } + setOpen(false) + } + + if (!entry.media) return null + + return ( + <div className="space-y-2 mt-2"> + + <p className="text-[--muted]"> + Seanime will open a new tab for each file you download. Make sure your browser allows popups. + </p> + + <Separator /> + + <FilepathSelector + className="max-h-96" + filepaths={filepaths} + allFilepaths={entry.localFiles?.map(n => n.path) ?? []} + onFilepathSelected={setFilepaths} + showFullPath + /> + <div className="flex justify-end gap-2 mt-2"> + <Button + intent="white" + onClick={() => handleDownload()} + > + Download + </Button> + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-dropdown-menu.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-dropdown-menu.tsx new file mode 100644 index 0000000..62a960c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-dropdown-menu.tsx @@ -0,0 +1,123 @@ +"use client" +import { Anime_Entry } from "@/api/generated/types" +import { useOpenAnimeEntryInExplorer } from "@/api/hooks/anime_entries.hooks" +import { useStartDefaultMediaPlayer } from "@/api/hooks/mediaplayer.hooks" +import { PluginAnimePageDropdownItems } from "@/app/(main)/_features/plugin/actions/plugin-actions" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { + __bulkDeleteFilesModalIsOpenAtom, + AnimeEntryBulkDeleteFilesModal, +} from "@/app/(main)/entry/_containers/entry-actions/anime-entry-bulk-delete-files-modal" +import { + __animeEntryDownloadFilesModalIsOpenAtom, + AnimeEntryDownloadFilesModal, +} from "@/app/(main)/entry/_containers/entry-actions/anime-entry-download-files-modal" +import { __metadataManager_isOpenAtom, AnimeEntryMetadataManager } from "@/app/(main)/entry/_containers/entry-actions/anime-entry-metadata-manager" +import { + __animeEntryUnmatchFilesModalIsOpenAtom, + AnimeEntryUnmatchFilesModal, +} from "@/app/(main)/entry/_containers/entry-actions/anime-entry-unmatch-files-modal" +import { IconButton } from "@/components/ui/button" +import { DropdownMenu, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu" +import { openTab } from "@/lib/helpers/browser" +import { useSetAtom } from "jotai" +import React from "react" +import { BiDotsVerticalRounded, BiFolder, BiRightArrowAlt } from "react-icons/bi" +import { FiArrowUpRight, FiDownload, FiTrash } from "react-icons/fi" +import { LuGlobe, LuImage } from "react-icons/lu" +import { MdOutlineRemoveDone } from "react-icons/md" + +export function AnimeEntryDropdownMenu({ entry }: { entry: Anime_Entry }) { + + const serverStatus = useServerStatus() + const setIsMetadataManagerOpen = useSetAtom(__metadataManager_isOpenAtom) + + const inLibrary = !!entry.libraryData + + // Start default media player + const { mutate: startDefaultMediaPlayer } = useStartDefaultMediaPlayer() + // Open entry in explorer + const { mutate: openEntryInExplorer } = useOpenAnimeEntryInExplorer() + + const setBulkDeleteFilesModalOpen = useSetAtom(__bulkDeleteFilesModalIsOpenAtom) + const setAnimeEntryUnmatchFilesModalOpen = useSetAtom(__animeEntryUnmatchFilesModalIsOpenAtom) + const setDownloadFilesModalOpen = useSetAtom(__animeEntryDownloadFilesModalIsOpenAtom) + + return ( + <> + <DropdownMenu + data-anime-entry-dropdown-menu + trigger={<IconButton + data-anime-entry-dropdown-menu-trigger + icon={<BiDotsVerticalRounded />} + intent="gray-basic" + size="md" + />} + > + + {(inLibrary && !entry._isNakamaEntry) && <> + <DropdownMenuItem + onClick={() => openEntryInExplorer({ mediaId: entry.mediaId })} + > + <BiFolder /> Open directory + </DropdownMenuItem> + + {/*{serverStatus?.settings?.mediaPlayer?.defaultPlayer != "mpv" && <DropdownMenuItem*/} + {/* onClick={() => startDefaultMediaPlayer()}*/} + {/*>*/} + {/* <PiVideoFill /> Start external media player*/} + {/*</DropdownMenuItem>}*/} + {/*<DropdownMenuSeparator />*/} + </>} + + {!!entry.anidbId && <DropdownMenuItem + onClick={() => openTab(`https://anidb.net/anime/${entry.anidbId}`)} + className="flex justify-between items-center" + > + <span className="flex items-center gap-2"><LuGlobe className="text-lg" /> Open on AniDB</span> + <FiArrowUpRight className="text-[--muted] text-sm" /> + </DropdownMenuItem>} + + <DropdownMenuItem + onClick={() => setIsMetadataManagerOpen(p => !p)} + > + <LuImage /> Metadata + </DropdownMenuItem> + + + {(inLibrary && !entry._isNakamaEntry) && <> + <DropdownMenuSeparator /> + <DropdownMenuLabel>Bulk actions</DropdownMenuLabel> + <DropdownMenuItem + className="flex justify-between" + onClick={() => setDownloadFilesModalOpen(p => !p)} + > + <span className="flex items-center gap-2"><FiDownload className="text-lg" /> Download some files</span> <BiRightArrowAlt /> + </DropdownMenuItem> + <DropdownMenuItem + className="text-orange-500 dark:text-orange-200 flex justify-between" + onClick={() => setAnimeEntryUnmatchFilesModalOpen(true)} + > + <span className="flex items-center gap-2"><MdOutlineRemoveDone className="text-lg" /> Unmatch some files</span> + <BiRightArrowAlt /> + </DropdownMenuItem> + <DropdownMenuItem + className="text-red-500 dark:text-red-200 flex justify-between" + onClick={() => setBulkDeleteFilesModalOpen(true)} + > + <span className="flex items-center gap-2"><FiTrash className="text-lg" /> Delete some files</span> <BiRightArrowAlt /> + </DropdownMenuItem> + </>} + + <PluginAnimePageDropdownItems media={entry.media!} /> + + </DropdownMenu> + + <AnimeEntryDownloadFilesModal entry={entry} /> + <AnimeEntryMetadataManager entry={entry} /> + <AnimeEntryBulkDeleteFilesModal entry={entry} /> + <AnimeEntryUnmatchFilesModal entry={entry} /> + + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-metadata-manager.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-metadata-manager.tsx new file mode 100644 index 0000000..75810d6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-metadata-manager.tsx @@ -0,0 +1,60 @@ +import { Anime_Entry } from "@/api/generated/types" +import { usePopulateFillerData, useRemoveFillerData } from "@/api/hooks/metadata.hooks" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" +import { IoSearchCircle } from "react-icons/io5" + +type AnimeEntryMetadataManagerProps = { + entry: Anime_Entry +} + +export const __metadataManager_isOpenAtom = atom(false) + +export function AnimeEntryMetadataManager(props: AnimeEntryMetadataManagerProps) { + + const { entry } = props + + const [isOpen, setOpen] = useAtom(__metadataManager_isOpenAtom) + + const { mutate: filler_populate, isPending: filler_isPopulating } = usePopulateFillerData() + const { mutate: filler_remove, isPending: filler_isRemoving } = useRemoveFillerData() + + const cannotAddMetadata = entry.media?.format !== "TV" && entry.media?.format !== "TV_SHORT" && entry.media?.format !== "ONA" + + return ( + <Modal + open={isOpen} + onOpenChange={setOpen} + title="Metadata" + contentClass="max-w-xl" + titleClass="" + > + <h3 className="text-center">AnimeFillerList</h3> + + <div className="flex lg:flex-row flex-col gap-2"> + <Button + className="w-full" + intent="primary-subtle" + leftIcon={<IoSearchCircle className="text-xl" />} + onClick={() => filler_populate({ mediaId: entry.mediaId })} + loading={filler_isPopulating} + disabled={filler_isPopulating || filler_isRemoving || cannotAddMetadata} + > + {filler_isPopulating ? "Fetching..." : "Fetch filler info"} + </Button> + <Button + className="w-full" + intent="gray-subtle" + onClick={() => filler_remove({ mediaId: entry.mediaId })} + loading={filler_isRemoving} + disabled={filler_isPopulating || filler_isRemoving || cannotAddMetadata} + > + {filler_isRemoving ? "Removing..." : "Remove filler info"} + </Button> + </div> + </Modal> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-silence-toggle.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-silence-toggle.tsx new file mode 100644 index 0000000..adbdc0e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-silence-toggle.tsx @@ -0,0 +1,47 @@ +import { useGetAnimeEntrySilenceStatus, useToggleAnimeEntrySilenceStatus } from "@/api/hooks/anime_entries.hooks" +import { IconButton } from "@/components/ui/button" +import { Tooltip } from "@/components/ui/tooltip" +import React from "react" +import { LuBellOff, LuBellRing } from "react-icons/lu" + +type AnimeEntrySilenceToggleProps = { + mediaId: number + size?: "sm" | "md" | "lg" +} + +export function AnimeEntrySilenceToggle(props: AnimeEntrySilenceToggleProps) { + + const { + mediaId, + size = "lg", + ...rest + } = props + + const { isSilenced, isLoading } = useGetAnimeEntrySilenceStatus(mediaId) + + const { + mutate, + isPending, + } = useToggleAnimeEntrySilenceStatus() + + function handleToggleSilenceStatus() { + mutate({ mediaId }) + } + + return ( + <> + <Tooltip + trigger={<IconButton + icon={isSilenced ? <LuBellOff /> : <LuBellRing />} + onClick={handleToggleSilenceStatus} + loading={isLoading || isPending} + intent={isSilenced ? "warning-subtle" : "gray-subtle"} + size={size} + {...rest} + />} + > + {isSilenced ? "Un-silence notifications" : "Silence notifications"} + </Tooltip> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-unmatch-files-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-unmatch-files-modal.tsx new file mode 100644 index 0000000..ecd007b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/entry-actions/anime-entry-unmatch-files-modal.tsx @@ -0,0 +1,101 @@ +import { Anime_Entry } from "@/api/generated/types" +import { useUpdateLocalFiles } from "@/api/hooks/localfiles.hooks" +import { FilepathSelector } from "@/app/(main)/_features/media/_components/filepath-selector" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { atom } from "jotai/index" +import { useAtom } from "jotai/react" +import React from "react" + +export type AnimeEntryUnmatchFilesModalProps = { + entry: Anime_Entry +} + +export const __animeEntryUnmatchFilesModalIsOpenAtom = atom(false) + + +export function AnimeEntryUnmatchFilesModal({ entry }: AnimeEntryUnmatchFilesModalProps) { + + const [open, setOpen] = useAtom(__animeEntryUnmatchFilesModalIsOpenAtom) + + return ( + <Modal + open={open} + onOpenChange={() => setOpen(false)} + contentClass="max-w-2xl" + title={<span>Select files to unmatch</span>} + titleClass="text-center" + + > + <Content entry={entry} /> + </Modal> + ) + +} + +function Content({ entry }: { entry: Anime_Entry }) { + + const [open, setOpen] = useAtom(__animeEntryUnmatchFilesModalIsOpenAtom) + + const [filepaths, setFilepaths] = React.useState<string[]>([]) + + const media = entry.media + + React.useEffect(() => { + if (entry.localFiles) { + setFilepaths(entry.localFiles.map(f => f.path)) + } + }, [entry.localFiles]) + + + const { mutate: updateFiles, isPending: isDeleting } = useUpdateLocalFiles() + + const confirmUnmatch = useConfirmationDialog({ + title: "Unmatch files", + onConfirm: () => { + if (filepaths.length === 0) return + + updateFiles({ + paths: filepaths, + action: "unmatch", + }, { + onSuccess: () => { + setOpen(false) + }, + }) + }, + }) + + if (!media) return null + + return ( + <div className="space-y-2 mt-2"> + + <FilepathSelector + className="max-h-96" + filepaths={filepaths} + allFilepaths={entry.localFiles?.map(n => n.path) ?? []} + onFilepathSelected={setFilepaths} + showFullPath + /> + <div className="flex justify-end gap-2 mt-2"> + <Button + intent="warning" + onClick={() => confirmUnmatch.open()} + loading={isDeleting} + > + Unmatch + </Button> + <Button + intent="white" + onClick={() => setOpen(false)} + disabled={isDeleting} + > + Cancel + </Button> + </div> + <ConfirmationDialog {...confirmUnmatch} /> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/episode-list/episode-item.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/episode-list/episode-item.tsx new file mode 100644 index 0000000..8ea952e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/episode-list/episode-item.tsx @@ -0,0 +1,314 @@ +import { getServerBaseUrl } from "@/api/client/server-url" +import { AL_BaseAnime, Anime_Episode, Anime_LocalFileType } from "@/api/generated/types" +import { useUpdateLocalFileData } from "@/api/hooks/localfiles.hooks" +import { useExternalPlayerLink } from "@/app/(main)/_atoms/playback.atoms" +import { EpisodeGridItem } from "@/app/(main)/_features/anime/_components/episode-grid-item" +import { PluginEpisodeGridItemMenuItems } from "@/app/(main)/_features/plugin/actions/plugin-actions" +import { useNakamaHMACAuth, useServerHMACAuth } from "@/app/(main)/_hooks/use-server-status" +import { IconButton } from "@/components/ui/button" +import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu" +import { defineSchema, Field, Form } from "@/components/ui/form" +import { Modal, ModalProps } from "@/components/ui/modal" +import { Popover, PopoverProps } from "@/components/ui/popover" +import { Separator } from "@/components/ui/separator" +import { getImageUrl } from "@/lib/server/assets" +import { useWindowSize } from "@uidotdev/usehooks" +import { atom } from "jotai" +import { createIsolation } from "jotai-scope" +import Image from "next/image" +import React, { memo } from "react" +import { AiFillWarning } from "react-icons/ai" +import { BiDotsHorizontal, BiLockOpenAlt } from "react-icons/bi" +import { MdInfo, MdOutlineOndemandVideo, MdOutlineRemoveDone } from "react-icons/md" +import { RiEdit2Line } from "react-icons/ri" +import { VscVerified } from "react-icons/vsc" +import { useCopyToClipboard } from "react-use" +import { toast } from "sonner" + +export const EpisodeItemIsolation = createIsolation() + +const __metadataModalIsOpenAtom = atom(false) + +export const EpisodeItem = memo(({ episode, media, isWatched, onPlay, percentageComplete, minutesRemaining }: { + episode: Anime_Episode, + media: AL_BaseAnime, + onPlay?: ({ path, mediaId }: { path: string, mediaId: number }) => void, + isWatched?: boolean + percentageComplete?: number + minutesRemaining?: number + isOffline?: boolean +}) => { + + const { updateLocalFile, isPending } = useUpdateLocalFileData(media.id) + const [_, copyToClipboard] = useCopyToClipboard() + + const { getHMACTokenQueryParam } = useServerHMACAuth() + const { getHMACTokenQueryParam: getNakamaHMACTokenQueryParam } = useNakamaHMACAuth() + + const { encodePath } = useExternalPlayerLink() + + function encodeFilePath(filePath: string) { + if (encodePath) { + return Buffer.from(filePath).toString("base64") + } + return encodeURIComponent(filePath) + } + + return ( + <EpisodeItemIsolation.Provider> + <EpisodeGridItem + media={media} + image={episode.episodeMetadata?.image} + onClick={() => onPlay?.({ path: episode.localFile?.path ?? "", mediaId: media.id })} + isInvalid={episode.isInvalid} + title={episode.displayTitle} + episodeTitle={episode.episodeTitle} + fileName={episode.localFile?.name} + isWatched={episode.progressNumber > 0 && isWatched} + isFiller={episode.episodeMetadata?.isFiller} + length={episode.episodeMetadata?.length} + percentageComplete={percentageComplete} + minutesRemaining={minutesRemaining} + episodeNumber={episode.episodeNumber} + progressNumber={episode.progressNumber} + description={episode.episodeMetadata?.summary || episode.episodeMetadata?.overview} + action={<> + {!episode._isNakamaEpisode && <IconButton + icon={episode.localFile?.locked ? <VscVerified /> : <BiLockOpenAlt />} + intent={episode.localFile?.locked ? "success-basic" : "warning-basic"} + size="md" + className="hover:opacity-60" + loading={isPending} + onClick={() => { + if (episode.localFile) { + updateLocalFile(episode.localFile, { + locked: !episode.localFile?.locked, + }) + } + }} + />} + + <DropdownMenu + trigger={ + <IconButton + icon={<BiDotsHorizontal />} + intent="gray-basic" + size="xs" + /> + } + > + {!episode._isNakamaEpisode && <MetadataModalButton />} + {episode.localFile && <DropdownMenuItem + onClick={async () => { + if (!episode._isNakamaEpisode) { + const endpoint = "/api/v1/mediastream/file?path=" + encodeFilePath(episode.localFile!.path) + const tokenQuery = await getHMACTokenQueryParam("/api/v1/mediastream/file", "&") + copyToClipboard(`${getServerBaseUrl()}${endpoint}${tokenQuery}`) + } else { + const endpoint = "/api/v1/nakama/stream?type=file&path=" + Buffer.from(episode.localFile!.path).toString("base64") + const tokenQuery = await getNakamaHMACTokenQueryParam("/api/v1/nakama/stream", "&") + copyToClipboard(`${getServerBaseUrl()}${endpoint}${tokenQuery}`) + } + toast.info("Stream URL copied") + }} + > + <MdOutlineOndemandVideo /> + Copy stream URL + </DropdownMenuItem>} + + {!episode._isNakamaEpisode && <> + <PluginEpisodeGridItemMenuItems isDropdownMenu={false} type="library" episode={episode} /> + + <DropdownMenuSeparator /> + <DropdownMenuItem + className="text-[--orange]" + onClick={() => { + if (episode.localFile) { + updateLocalFile(episode.localFile, { + mediaId: 0, + locked: false, + ignored: false, + }) + } + }} + > + <MdOutlineRemoveDone /> Unmatch + </DropdownMenuItem> + </>} + </DropdownMenu> + + {(!!episode.episodeMetadata && (episode.type === "main" || episode.type === "special")) && !!episode.episodeMetadata?.anidbId && + <EpisodeItemInfoModalButton episode={episode} />} + </>} + /> + <MetadataModal + episode={episode} + /> + </EpisodeItemIsolation.Provider> + ) + +}) + + +const metadataSchema = defineSchema(({ z }) => z.object({ + episode: z.number().min(0), + aniDBEpisode: z.string().transform(value => value.toUpperCase()), + type: z.string().min(0), +})) + +function MetadataModal({ episode }: { episode: Anime_Episode }) { + + const [isOpen, setIsOpen] = EpisodeItemIsolation.useAtom(__metadataModalIsOpenAtom) + + const { updateLocalFile, isPending } = useUpdateLocalFileData(episode.baseAnime?.id) + + return ( + <Modal + open={isOpen} + onOpenChange={() => setIsOpen(false)} + + title={episode.displayTitle} + titleClass="text-center" + contentClass="max-w-xl" + > + <p className="w-full line-clamp-2 text-sm px-4 text-center py-2 flex-none">{episode.localFile?.name}</p> + <Form + schema={metadataSchema} + onSubmit={(data) => { + if (episode.localFile) { + updateLocalFile(episode.localFile, { + metadata: { + ...episode.localFile?.metadata, + type: data.type as Anime_LocalFileType, + episode: data.episode, + aniDBEpisode: data.aniDBEpisode, + }, + }, () => { + setIsOpen(false) + toast.success("Metadata saved") + }) + } + }} + onError={console.log} + //@ts-ignore + defaultValues={{ ...episode.fileMetadata }} + > + <Field.Number + label="Episode number" name="episode" + help="Relative episode number. If movie, episode number = 1" + required + /> + <Field.Text + label="AniDB episode" + name="aniDBEpisode" + help="Specials typically contain the letter S" + /> + <Field.Select + label="Type" + name="type" + options={[ + { label: "Main", value: "main" }, + { label: "Special", value: "special" }, + { label: "NC/Other", value: "nc" }, + ]} + /> + <div className="w-full flex justify-end"> + <Field.Submit role="save" intent="success" loading={isPending}>Save</Field.Submit> + </div> + </Form> + </Modal> + ) +} + +function MetadataModalButton() { + const [, setIsOpen] = EpisodeItemIsolation.useAtom(__metadataModalIsOpenAtom) + return <DropdownMenuItem onClick={() => setIsOpen(true)}> + <RiEdit2Line /> + Update metadata + </DropdownMenuItem> +} + +function IsomorphicPopover(props: PopoverProps & ModalProps) { + const { title, children, ...rest } = props + const { width } = useWindowSize() + + if (width && width > 1024) { + return <Popover + {...rest} + className="max-w-xl !w-full overflow-hidden" + > + {children} + </Popover> + } + + return <Modal + {...rest} + title={title} + titleClass="text-xl" + contentClass="max-w-2xl overflow-hidden" + > + {children} + </Modal> +} + +export function EpisodeItemInfoModalButton({ episode }: { episode: Anime_Episode }) { + return <IsomorphicPopover + title={episode.displayTitle} + trigger={<IconButton + icon={<MdInfo />} + className="opacity-30 hover:opacity-100 transform-opacity" + intent="gray-basic" + size="xs" + />} + > + + {episode.episodeMetadata?.image && <div + className="h-[8rem] w-full flex-none object-cover object-center overflow-hidden absolute left-0 top-0 z-[0] rounded-t-lg" + > + <Image + src={getImageUrl(episode.episodeMetadata?.image)} + alt="banner" + fill + quality={80} + priority + sizes="20rem" + className="object-cover object-center opacity-30" + /> + <div + className="z-[5] absolute bottom-0 w-full h-[80%] bg-gradient-to-t from-[--background] to-transparent" + /> + </div>} + + <div className="space-y-4 z-[5] relative"> + <p className="text-lg line-clamp-2 font-semibold"> + {episode.episodeTitle?.replaceAll("`", "'")} + {episode.isInvalid && <AiFillWarning />} + </p> + <p className="text-[--muted]"> + {episode.episodeMetadata?.airDate || "Unknown airing date"} - {episode.episodeMetadata?.length || "N/A"} minutes + </p> + <p className="text-gray-300"> + {(episode.episodeMetadata?.summary || episode.episodeMetadata?.overview)?.replaceAll("`", "'")?.replace(/source:.*/gi, "") || "No summary"} + </p> + <Separator /> + <p className="text-[--muted] line-clamp-2 tracking-wide text-sm"> + {episode.localFile?.parsedInfo?.original} + </p> + { + (!!episode.episodeMetadata?.anidbId) && <> + <div className="w-full flex gap-2"> + <p>AniDB Episode: {episode.fileMetadata?.aniDBEpisode}</p> + <a + href={"https://anidb.net/episode/" + episode.episodeMetadata?.anidbId + "#layout-footer"} + target="_blank" + className="hover:underline text-[--muted]" + >Open on AniDB + </a> + </div> + </> + } + + </div> + + </IsomorphicPopover> +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/episode-list/episode-section.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/episode-list/episode-section.tsx new file mode 100644 index 0000000..7894f82 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/episode-list/episode-section.tsx @@ -0,0 +1,232 @@ +"use client" +import { AL_AnimeDetailsById_Media, Anime_Entry, Anime_Episode } from "@/api/generated/types" +import { getEpisodeMinutesRemaining, getEpisodePercentageComplete, useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks" +import { EpisodeCard } from "@/app/(main)/_features/anime/_components/episode-card" + +import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { EpisodeListGrid, EpisodeListPaginatedGrid } from "@/app/(main)/entry/_components/episode-list-grid" +import { useAnimeEntryPageView } from "@/app/(main)/entry/_containers/anime-entry-page" +import { EpisodeItem } from "@/app/(main)/entry/_containers/episode-list/episode-item" +import { UndownloadedEpisodeList } from "@/app/(main)/entry/_containers/episode-list/undownloaded-episode-list" +import { useHandleEpisodeSection } from "@/app/(main)/entry/_lib/handle-episode-section" +import { episodeCardCarouselItemClass } from "@/components/shared/classnames" +import { Alert } from "@/components/ui/alert" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel" +import { useThemeSettings } from "@/lib/theme/hooks" +import React from "react" +import { IoLibrarySharp } from "react-icons/io5" + +type EpisodeSectionProps = { + entry: Anime_Entry + details: AL_AnimeDetailsById_Media | undefined + bottomSection: React.ReactNode +} + +export function EpisodeSection({ entry, details, bottomSection }: EpisodeSectionProps) { + const ts = useThemeSettings() + const serverStatus = useServerStatus() + const { currentView } = useAnimeEntryPageView() + + const { + media, + hasInvalidEpisodes, + episodesToWatch, + mainEpisodes, + specialEpisodes, + ncEpisodes, + playMediaFile, + } = useHandleEpisodeSection({ entry }) + + const { data: watchHistory } = useGetContinuityWatchHistory() + + const { inject, remove } = useSeaCommandInject() + + React.useEffect(() => { + if (!media) return + + // Combine all episode types + const allEpisodes = [ + { ...episodesToWatch?.[0], type: "next" as const }, + ...mainEpisodes.map(ep => ({ ...ep, type: "main" as const })), + ...specialEpisodes.map(ep => ({ ...ep, type: "special" as const })), + ...ncEpisodes.map(ep => ({ ...ep, type: "other" as const })), + ] + + inject("library-episodes", { + items: allEpisodes.filter(n => !!n.episodeTitle).map(episode => ({ + data: episode, + id: `${episode.type}-${episode.localFile?.path || ""}-${episode.episodeNumber}`, + value: `${episode.episodeNumber}`, + heading: episode.type === "next" ? "Next Episode" : + episode.type === "special" ? "Specials" : + episode.type === "other" ? "Others" : "Episodes", + priority: episode.type === "next" ? 2 : + episode.type === "main" ? 1 : 0, + render: () => ( + <div className="flex gap-1 items-center w-full"> + <p className="max-w-[70%] truncate">{episode.displayTitle}</p> + {!!episode.episodeTitle && ( + <p className="text-[--muted] flex-1 truncate">- {episode.episodeTitle}</p> + )} + </div> + ), + onSelect: () => playMediaFile({ + path: episode.localFile?.path ?? "", + mediaId: entry.mediaId, + episode: episode as Anime_Episode, + }), + })), + filter: ({ item, input }) => { + if (!input) return true + return item.value.toLowerCase().includes(input.toLowerCase()) + }, + shouldShow: () => currentView === "library", + priority: 1, + }) + + return () => remove("library-episodes") + }, [media, episodesToWatch, mainEpisodes, specialEpisodes, ncEpisodes, currentView]) + + if (!media) return null + + // if (!!media && entry._isNakamaEntry && !entry.listData && !!entry.libraryData) { + // return <div className="space-y-10"> + // <h4 className="text-yellow-50 flex items-center gap-2"><IoLibrarySharp /> Add this anime to your library to see its episodes</h4> + // </div> + // } + + if (!!media && ((!entry.listData && !entry._isNakamaEntry) || !entry.libraryData) && !serverStatus?.isOffline) { + return <div className="space-y-10"> + {media?.status !== "NOT_YET_RELEASED" + ? <h4 className="text-yellow-50 flex items-center gap-2"><IoLibrarySharp /> Not in {entry._isNakamaEntry + ? "the Nakama's" + : "your"} library</h4> + : <h5 className="text-yellow-50">Not yet released</h5>} + <div className="overflow-y-auto pt-4 lg:pt-0 space-y-10 overflow-x-hidden"> + {!entry._isNakamaEntry && <UndownloadedEpisodeList + downloadInfo={entry.downloadInfo} + media={media} + />} + {bottomSection} + </div> + </div> + } + + return ( + <> + <AppLayoutStack spacing="lg" data-episode-section-stack> + + {hasInvalidEpisodes && <Alert + intent="alert" + description="Some episodes are invalid. Update the metadata to fix this." + />} + + + {episodesToWatch.length > 0 && ( + <> + <Carousel + className="w-full max-w-full" + gap="md" + opts={{ + align: "start", + }} + data-episode-carousel + > + <CarouselDotButtons /> + <CarouselContent> + {episodesToWatch.map((episode, idx) => ( + <CarouselItem + key={episode?.localFile?.path || idx} + className={episodeCardCarouselItemClass(ts.smallerEpisodeCarouselSize)} + > + <EpisodeCard + key={episode.localFile?.path || ""} + episode={episode} + image={episode.episodeMetadata?.image || episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge} + topTitle={episode.episodeTitle || episode?.baseAnime?.title?.userPreferred} + title={episode.displayTitle} + isInvalid={episode.isInvalid} + progressTotal={episode.baseAnime?.episodes} + progressNumber={episode.progressNumber} + episodeNumber={episode.episodeNumber} + length={episode.episodeMetadata?.length} + percentageComplete={getEpisodePercentageComplete(watchHistory, entry.mediaId, episode.episodeNumber)} + minutesRemaining={getEpisodeMinutesRemaining(watchHistory, entry.mediaId, episode.episodeNumber)} + onClick={() => playMediaFile({ + path: episode.localFile?.path ?? "", + mediaId: entry.mediaId, + episode: episode, + })} + anime={{ + id: entry.mediaId, + image: episode.baseAnime?.coverImage?.medium, + title: episode?.baseAnime?.title?.userPreferred, + }} + /> + </CarouselItem> + ))} + </CarouselContent> + </Carousel> + </> + )} + + + <div className="space-y-10" data-episode-list-stack> + <EpisodeListPaginatedGrid + length={mainEpisodes.length} + renderItem={(index) => ( + <EpisodeItem + key={mainEpisodes[index].localFile?.path || ""} + episode={mainEpisodes[index]} + media={media} + isWatched={!!entry.listData?.progress && entry.listData.progress >= mainEpisodes[index].progressNumber} + onPlay={({ path, mediaId }) => playMediaFile({ path, mediaId, episode: mainEpisodes[index] })} + percentageComplete={getEpisodePercentageComplete(watchHistory, entry.mediaId, mainEpisodes[index].episodeNumber)} + minutesRemaining={getEpisodeMinutesRemaining(watchHistory, entry.mediaId, mainEpisodes[index].episodeNumber)} + /> + )} + /> + + {!serverStatus?.isOffline && !entry._isNakamaEntry && <UndownloadedEpisodeList + downloadInfo={entry.downloadInfo} + media={media} + />} + + {specialEpisodes.length > 0 && <> + <h2>Specials</h2> + <EpisodeListGrid data-episode-list-specials> + {specialEpisodes.map(episode => ( + <EpisodeItem + key={episode.localFile?.path || ""} + episode={episode} + media={media} + onPlay={({ path, mediaId }) => playMediaFile({ path, mediaId, episode: episode })} + /> + ))} + </EpisodeListGrid> + </>} + + {ncEpisodes.length > 0 && <> + <h2>Others</h2> + <EpisodeListGrid data-episode-list-others> + {ncEpisodes.map(episode => ( + <EpisodeItem + key={episode.localFile?.path || ""} + episode={episode} + media={media} + onPlay={({ path, mediaId }) => playMediaFile({ path, mediaId, episode: episode })} + /> + ))} + </EpisodeListGrid> + </>} + + {bottomSection} + + </div> + </AppLayoutStack> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/episode-list/undownloaded-episode-list.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/episode-list/undownloaded-episode-list.tsx new file mode 100644 index 0000000..95b6ad3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/episode-list/undownloaded-episode-list.tsx @@ -0,0 +1,88 @@ +import { AL_BaseAnime, Anime_EntryDownloadInfo } from "@/api/generated/types" +import { EpisodeGridItem } from "@/app/(main)/_features/anime/_components/episode-grid-item" +import { PluginEpisodeGridItemMenuItems } from "@/app/(main)/_features/plugin/actions/plugin-actions" +import { useHasTorrentProvider } from "@/app/(main)/_hooks/use-server-status" +import { EpisodeListGrid } from "@/app/(main)/entry/_components/episode-list-grid" +import { + __torrentSearch_selectionAtom, + __torrentSearch_selectionEpisodeAtom, +} from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { useSetAtom } from "jotai" +import React, { startTransition } from "react" +import { BiCalendarAlt, BiDownload } from "react-icons/bi" +import { EpisodeItemInfoModalButton } from "./episode-item" + +export function UndownloadedEpisodeList({ downloadInfo, media }: { + downloadInfo: Anime_EntryDownloadInfo | undefined, + media: AL_BaseAnime +}) { + + const episodes = downloadInfo?.episodesToDownload + + const setTorrentSearchIsOpen = useSetAtom(__torrentSearch_selectionAtom) + const setTorrentSearchEpisode = useSetAtom(__torrentSearch_selectionEpisodeAtom) + + const { hasTorrentProvider } = useHasTorrentProvider() + + const text = hasTorrentProvider ? (downloadInfo?.rewatch + ? "You have not downloaded the following:" + : "You have not watched nor downloaded the following:") : + "The following episodes are not in your library:" + + if (!episodes?.length) return null + + return ( + <div className="space-y-4" data-undownloaded-episode-list> + <p className={""}> + {text} + </p> + <EpisodeListGrid> + {episodes?.sort((a, b) => a.episodeNumber - b.episodeNumber).slice(0, 28).map((ep, idx) => { + if (!ep.episode) return null + const episode = ep.episode + return ( + <EpisodeGridItem + key={ep.episode.localFile?.path || idx} + media={media} + image={episode.episodeMetadata?.image} + isInvalid={episode.isInvalid} + title={episode.displayTitle} + episodeTitle={episode.episodeTitle} + episodeNumber={episode.episodeNumber} + progressNumber={episode.progressNumber} + description={episode.episodeMetadata?.summary || episode.episodeMetadata?.overview} + action={<> + {hasTorrentProvider && <div + data-undownloaded-episode-list-action-download-button + onClick={() => { + setTorrentSearchEpisode(episode.episodeNumber) + startTransition(() => { + setTorrentSearchIsOpen("download") + }) + }} + className="inline-block text-orange-200 text-2xl animate-pulse cursor-pointer py-2" + > + <BiDownload /> + </div>} + + <EpisodeItemInfoModalButton episode={episode} /> + + <PluginEpisodeGridItemMenuItems isDropdownMenu={true} type="undownloaded" episode={episode} /> + </>} + > + <div data-undownloaded-episode-list-episode-metadata-container className="mt-1"> + <p data-undownloaded-episode-list-episode-metadata-text className="flex gap-1 items-center text-sm text-[--muted]"> + <BiCalendarAlt /> {episode.episodeMetadata?.airDate + ? `Aired on ${new Date(episode.episodeMetadata?.airDate).toLocaleDateString()}` + : "Aired"} + </p> + </div> + </EpisodeGridItem> + ) + })} + </EpisodeListGrid> + {episodes.length > 28 && <h3>And more...</h3>} + </div> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-common-helpers.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-common-helpers.tsx new file mode 100644 index 0000000..f5b6003 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-common-helpers.tsx @@ -0,0 +1,309 @@ +import { Torrent_TorrentMetadata } from "@/api/generated/types" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Popover } from "@/components/ui/popover" +import React, { useState } from "react" +import { LiaMicrophoneSolid } from "react-icons/lia" +import { PiChatCircleTextDuotone, PiChatsTeardropDuotone } from "react-icons/pi" +import { TbArrowsSort, TbFilter, TbSortAscending, TbSortDescending } from "react-icons/tb" + +// Define sort types +export type SortField = "seeders" | "size" | "date" | "resolution" | null +export type SortDirection = "asc" | "desc" | null + +// Define filter types +export type TorrentFilters = { + multiSubs: boolean, + dualAudio: boolean, + dubbed: boolean +} + +// Helper to get sort icon for a field +export const getSortIcon = (sortField: SortField, field: SortField, sortDirection: SortDirection) => { + if (sortField !== field) return <TbArrowsSort className="opacity-50 text-lg" /> + return sortDirection === "asc" ? + <TbSortAscending className="text-brand-200 text-lg" /> : + <TbSortDescending className="text-brand-200 text-lg" /> +} + +export const getFilterIcon = (active: boolean) => { + return active ? <TbFilter className="text-brand-200 animate-bounce text-lg" /> : <TbFilter className="opacity-50 text-lg" /> +} + +// Sort handler function +export const handleSort = ( + field: SortField, + sortField: SortField, + sortDirection: SortDirection, + setSortField: (field: SortField) => void, + setSortDirection: (direction: SortDirection) => void, +) => { + if (sortField === field) { + if (sortDirection === "desc") { + setSortDirection("asc") + } else if (sortDirection === "asc") { + setSortField(null) + setSortDirection(null) + } else { + setSortDirection("desc") + } + } else { + setSortField(field) + setSortDirection("desc") + } +} + +// Helper functions for checking torrent properties +export const hasTorrentMultiSubs = (metadata: Torrent_TorrentMetadata | undefined): boolean => { + if (!metadata) return false + return !!metadata.metadata?.subtitles?.some(n => n.toLocaleLowerCase().includes("multi")) +} + +export const hasTorrentDualAudio = (metadata: Torrent_TorrentMetadata | undefined): boolean => { + if (!metadata) return false + return !!metadata.metadata?.audio_term?.some(term => + term.toLowerCase().includes("dual") || term.toLowerCase().includes("multi")) +} + +export const hasTorrentDubbed = (metadata: Torrent_TorrentMetadata | undefined): boolean => { + if (!metadata) return false + return !!metadata.metadata?.subtitles?.some(n => n.toLocaleLowerCase().includes("dub")) +} + +// Generic interface for torrent-like objects +interface TorrentLike { + seeders?: number + size?: number + date: string + resolution?: string + infoHash?: string +} + +// Generic interface for preview-like objects +interface PreviewLike { + torrent?: { + seeders?: number + size?: number + date: string + resolution?: string + infoHash?: string + } +} + +// Generic sort function that works with both torrent types +export function sortItems<T extends TorrentLike | PreviewLike>( + items: T[], + sortField: SortField, + sortDirection: SortDirection, +): T[] { + if (!sortField || !sortDirection) return items + + return [...items].sort((a, b) => { + let valueA: number, valueB: number + + // Handle both direct torrents and preview torrents + const torrentA = "torrent" in a ? a.torrent : a as TorrentLike + const torrentB = "torrent" in b ? b.torrent : b as TorrentLike + + if (!torrentA || !torrentB) return 0 + + switch (sortField) { + case "seeders": + valueA = torrentA.seeders || 0 + valueB = torrentB.seeders || 0 + break + case "size": + valueA = torrentA.size || 0 + valueB = torrentB.size || 0 + break + case "date": + valueA = new Date(torrentA.date).getTime() + valueB = new Date(torrentB.date).getTime() + break + case "resolution": + // Convert resolution to numeric value for sorting + valueA = torrentA.resolution ? parseInt(torrentA.resolution.replace(/[^\d]/g, "") || "0") : 0 + valueB = torrentB.resolution ? parseInt(torrentB.resolution.replace(/[^\d]/g, "") || "0") : 0 + break + default: + return 0 + } + + return sortDirection === "asc" + ? valueA - valueB + : valueB - valueA + }) +} + +// Generic filter function that works with both torrent types +export function filterItems<T extends TorrentLike | PreviewLike>( + items: T[], + torrentMetadata: Record<string, Torrent_TorrentMetadata> | undefined, + filters: TorrentFilters, +): T[] { + if (!torrentMetadata || (!filters.multiSubs && !filters.dualAudio && !filters.dubbed)) { + return items + } + + return items.filter(item => { + // Handle both direct torrents and preview torrents + const torrent = "torrent" in item ? item.torrent : item as TorrentLike + if (!torrent?.infoHash || !torrentMetadata[torrent.infoHash]) return true + + const metadata = torrentMetadata[torrent.infoHash] + + // Apply filters + if (filters.multiSubs && !hasTorrentMultiSubs(metadata)) return false + if (filters.dualAudio && !hasTorrentDualAudio(metadata)) return false + if (filters.dubbed && !hasTorrentDubbed(metadata)) return false + + return true + }) +} + +// Hook for managing sorting state +export function useTorrentSorting() { + const [sortField, setSortField] = useState<SortField>("seeders") + const [sortDirection, setSortDirection] = useState<SortDirection>("desc") + + const handleSortChange = (field: SortField) => { + handleSort(field, sortField, sortDirection, setSortField, setSortDirection) + } + + return { + sortField, + sortDirection, + handleSortChange, + } +} + +// Hook for managing filtering state +export function useTorrentFiltering() { + const [filters, setFilters] = useState<TorrentFilters>({ + multiSubs: false, + dualAudio: false, + dubbed: false, + }) + + const handleFilterChange = (filterName: keyof TorrentFilters, value: boolean | "indeterminate") => { + if (typeof value === "boolean") { + setFilters(prev => ({ + ...prev, + [filterName]: value, + })) + } + } + + const isAnyFilterActive = filters.multiSubs || filters.dualAudio || filters.dubbed + + return { + filters, + handleFilterChange, + isAnyFilterActive, + } +} + +// UI Component for filter and sort controls +export const TorrentFilterSortControls: React.FC<{ + resultCount: number, + sortField: SortField, + sortDirection: SortDirection, + filters: TorrentFilters, + onSortChange: (field: SortField) => void, + onFilterChange: (filterName: keyof TorrentFilters, value: boolean | "indeterminate") => void +}> = ({ + resultCount, + sortField, + sortDirection, + filters, + onSortChange, + onFilterChange, +}) => { + const isAnyFilterActive = filters.multiSubs || filters.dualAudio || filters.dubbed + + return ( + <div className="flex items-center justify-between gap-4"> + <p className="text-sm text-[--muted] flex-none">{resultCount} results</p> + <div className="flex items-center gap-1 flex-wrap"> + <Popover + trigger={<Button + size="xs" + intent="gray-basic" + leftIcon={<> + {getFilterIcon(isAnyFilterActive)} + </>} + > + Filters + </Button>} + > + <p className="text-sm text-[--muted] flex-none pb-2"> + Filters may miss some results + </p> + <div className="space-y-1"> + <Checkbox + label={<div className="flex items-center gap-1"> + <PiChatCircleTextDuotone className="text-lg text-[--orange]" /> Multi Subs + </div>} + value={filters.multiSubs} + onValueChange={(value) => onFilterChange("multiSubs", value)} + /> + <Checkbox + label={<div className="flex items-center gap-1"> + <PiChatsTeardropDuotone className="text-lg text-[--rose]" /> Dual Audio + </div>} + value={filters.dualAudio} + onValueChange={(value) => onFilterChange("dualAudio", value)} + /> + <Checkbox + label={<div className="flex items-center gap-1"> + <LiaMicrophoneSolid className="text-lg text-[--red]" /> Dubbed + </div>} + value={filters.dubbed} + onValueChange={(value) => onFilterChange("dubbed", value)} + /> + </div> + </Popover> + <Button + size="xs" + intent="gray-basic" + leftIcon={<> + {getSortIcon(sortField, "seeders", sortDirection)} + </>} + onClick={() => onSortChange("seeders")} + > + Seeders + </Button> + <Button + size="xs" + intent="gray-basic" + leftIcon={<> + {getSortIcon(sortField, "size", sortDirection)} + </>} + onClick={() => onSortChange("size")} + > + Size + </Button> + <Button + size="xs" + intent="gray-basic" + leftIcon={<> + {getSortIcon(sortField, "date", sortDirection)} + </>} + onClick={() => onSortChange("date")} + > + Date + </Button> + <Button + size="xs" + intent="gray-basic" + leftIcon={<> + {getSortIcon(sortField, "resolution", sortDirection)} + </>} + onClick={() => onSortChange("resolution")} + > + Resolution + </Button> + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-item-badges.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-item-badges.tsx new file mode 100644 index 0000000..ac86f1e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-item-badges.tsx @@ -0,0 +1,132 @@ +import { Torrent_TorrentMetadata } from "@/api/generated/types" +import { Badge } from "@/components/ui/badge" +import { Tooltip } from "@/components/ui/tooltip" +import { startCase } from "lodash" +import React from "react" +import { LiaMicrophoneSolid } from "react-icons/lia" +import { LuGauge } from "react-icons/lu" +import { PiChatCircleTextDuotone, PiChatsTeardropDuotone, PiChatTeardropDuotone } from "react-icons/pi" + +export function TorrentResolutionBadge({ resolution }: { resolution?: string }) { + + if (!resolution) return null + + return ( + <Badge + data-torrent-item-resolution-badge + className="rounded-[--radius-md] border-transparent bg-transparent px-0" + intent={resolution?.includes("1080") + ? "warning" + : (resolution?.includes("2160") || resolution?.toLowerCase().includes("4k")) + ? "success" + : (resolution?.includes("720") + ? "blue" + : "gray")} + > + {resolution} + </Badge> + ) +} + +export function TorrentSeedersBadge({ seeders }: { seeders: number }) { + + if (seeders === 0) return null + + return ( + <Badge + data-torrent-item-seeders-badge + className="rounded-[--radius-md] border-transparent bg-transparent px-0" + intent={(seeders) > 4 ? (seeders) > 19 ? "primary" : "success" : "gray"} + > + <span className="text-sm">{seeders}</span> seeder{seeders > 1 ? "s" : ""} + </Badge> + ) + +} + + +export function TorrentParsedMetadata({ metadata }: { metadata: Torrent_TorrentMetadata | undefined }) { + + if (!metadata) return null + + const hasDubs = metadata.metadata?.subtitles?.some(n => n.toLocaleLowerCase().includes("dub")) + // const hasSubs = metadata.metadata?.subtitles?.some(n => n.toLocaleLowerCase().includes("sub")) + const hasMultiSubs = metadata.metadata?.subtitles?.some(n => n.toLocaleLowerCase().includes("multi")) + + const languages = !!metadata.metadata?.language?.length ? [...new Set(metadata.metadata?.language)] : [] + + return ( + <div className="flex flex-row gap-1 flex-wrap justify-end w-full lg:absolute bottom-0 right-0"> + {!!languages?.length && languages.length == 2 ? languages.slice(0, 2)?.map(term => ( + <Badge + key={term} + className="rounded-md bg-transparent border-transparent px-1" + > + <PiChatTeardropDuotone className="text-lg text-[--blue]" /> {term} + </Badge> + )) : null} + {!!languages?.length && languages.length > 2 ? <Tooltip + trigger={<Badge + className="rounded-md bg-transparent border-transparent px-1" + > + <PiChatTeardropDuotone className="text-lg text-[--blue]" /> Multiple languages + </Badge>} + > + <span> + {languages.join(", ")} + </span> + </Tooltip> : null} + {metadata.metadata?.video_term?.map(term => ( + <Badge + key={term} + className="rounded-md border-transparent bg-[--subtle] !text-[--muted] px-1" + > + {term} + </Badge> + ))} + {metadata.metadata?.audio_term?.filter(term => term.toLowerCase().includes("dual") || term.toLowerCase().includes("multi")).map(term => ( + <Badge + key={term} + className="rounded-md border-transparent bg-[--subtle] px-1" + > + {/* <LuAudioWaveform className="text-lg text-[--blue]" /> {term} */} + <PiChatsTeardropDuotone className="text-lg text-[--rose]" /> {startCase(term)} + </Badge> + ))} + {hasDubs && ( + <Badge + className="rounded-md border-transparent bg-indigo-300 px-1" + > + <LiaMicrophoneSolid className="text-lg text-[--red]" /> Dubbed + </Badge> + )} + {hasMultiSubs && ( + <Badge + className="rounded-md border-transparent bg-indigo-300 px-1" + > + <PiChatCircleTextDuotone className="text-lg text-[--orange]" /> Multi Subs + </Badge> + )} + </div> + ) +} + + +export function TorrentDebridInstantAvailabilityBadge() { + + return ( + <Tooltip + trigger={<Badge + data-torrent-item-debrid-instant-availability-badge + className="rounded-[--radius-md] bg-transparent border-transparent dark:text-[--white] animate-pulse" + intent="white" + leftIcon={<LuGauge className="text-lg" />} + > + Cached + </Badge>} + > + Instantly available on Debrid service + </Tooltip> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-preview-item.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-preview-item.tsx new file mode 100644 index 0000000..889ad29 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-preview-item.tsx @@ -0,0 +1,195 @@ +import { IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Tooltip } from "@/components/ui/tooltip" +import { openTab } from "@/lib/helpers/browser" +import Image from "next/image" +import React, { memo } from "react" +import { AiFillWarning } from "react-icons/ai" +import { BiLinkExternal } from "react-icons/bi" +import { BsFileEarmarkPlayFill } from "react-icons/bs" +import { FcFolder } from "react-icons/fc" +import { LuCircleCheckBig } from "react-icons/lu" + +type TorrentPreviewItemProps = { + link?: string + isSelected?: boolean + isInvalid?: boolean + className?: string + onClick?: () => void + releaseGroup: string + isBatch: boolean + subtitle: string + title: string + children?: React.ReactNode + action?: React.ReactNode + image?: string | null + fallbackImage?: string + isBestRelease?: boolean + confirmed?: boolean + addon?: React.ReactNode + isBasic?: boolean +} + +export const TorrentPreviewItem = memo((props: TorrentPreviewItemProps) => { + + const { + link, + isBasic, + isSelected, + isInvalid, + className, + onClick, + releaseGroup, + isBatch, + title, + subtitle, + children, + action, + image, + fallbackImage, + isBestRelease, + confirmed, + addon, + } = props + + const _title = isBatch ? "" : title + + return ( + <div + data-torrent-preview-item + data-title={title} + data-subtitle={subtitle} + data-release-group={releaseGroup} + data-is-batch={isBatch} + data-is-best-release={isBestRelease} + data-confirmed={confirmed} + data-is-invalid={isInvalid} + data-is-selected={isSelected} + data-link={link} + className={cn( + "border p-3 pr-12 rounded-lg relative transition group/torrent-preview-item overflow-hidden", + // !__isElectronDesktop__ && "lg:hover:scale-[1.01]", + "max-w-full bg-[--background]", + { + "border-brand-200": isSelected, + "hover:border-gray-500": !isSelected, + "border-red-700": isInvalid, + // "opacity-50": isWatched && !isSelected, + }, className, + )} + tabIndex={0} + > + + {addon} + + {confirmed && <div className="absolute left-2 top-2" data-torrent-preview-item-confirmed-badge> + <LuCircleCheckBig + className={cn( + "text-[--gray] text-sm", + isBestRelease ? "text-[--pink] opacity-70" : "opacity-30", + )} + /> + </div>} + + <div className="absolute left-0 top-0 w-full h-full max-w-[160px]" data-torrent-preview-item-image-container> + {(image || fallbackImage) && <Image + data-torrent-preview-item-image + src={image || fallbackImage!} + alt="episode image" + fill + className={cn( + "object-cover object-center absolute w-full h-full group-hover/torrent-preview-item:blur-0 transition-opacity opacity-25 group-hover/torrent-preview-item:opacity-60 z-[0] select-none pointer-events-none", + (!image && fallbackImage) && "opacity-10 group-hover/torrent-preview-item:opacity-30", + isSelected && "opacity-50", + + )} + />} + <div + data-torrent-preview-item-image-end-gradient + className="transition-colors absolute w-full h-full -right-2 bg-gradient-to-l from-[--background] hover:from-[var(--hover-from-background-color)] to-transparent z-[1] select-none pointer-events-none" + ></div> + </div> + + {/*<div*/} + {/* className="absolute w-[calc(100%_-_179px)] h-full bg-[--background] top-0 left-[179px]"*/} + {/*></div>*/} + + <div + data-torrent-preview-item-content + className={cn( + "flex gap-4 relative z-[2]", + { "cursor-pointer": !!onClick }, + )} + onClick={onClick} + > + + + <div + data-torrent-preview-item-release-info-container + className={cn( + "h-24 w-24 lg:w-28 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden", + "flex items-center justify-center", + "text-xs px-2", + isBasic && "h-20", + )} + > + <p + className={cn( + "z-[1] font-bold truncate flex items-center max-w-full w-fit px-2 py-1 rounded-[--radius-md]", + "border-transparent bg-transparent", + // "group-hover/torrent-preview-item:bg-gray-950/50 group-hover/torrent-preview-item:text-white", + )} + data-torrent-preview-item-release-group + > + <span className="truncate">{releaseGroup}</span> + </p> + {isBatch && <FcFolder className="text-7xl absolute opacity-20 group-hover/torrent-preview-item:opacity-30" />} + {!(image || fallbackImage) && !isBatch && <BsFileEarmarkPlayFill className="text-7xl absolute opacity-10" />} + </div> + + <div className="relative overflow-hidden space-y-1 w-full" data-torrent-preview-item-metadata> + {isInvalid && <p className="flex gap-2 text-red-300 items-center"><AiFillWarning + className="text-lg text-red-500" + /> Unidentified</p>} + + <p + className={cn( + "font-normal text-[.9rem] transition line-clamp-2 tracking-wide", + isBasic && "text-sm", + )} + data-torrent-preview-item-title + >{_title}</p> + + {!!subtitle && <p + className={cn( + "text-[.85rem] tracking-wide group-hover/torrent-preview-item:text-gray-200 line-clamp-2 break-all", + !(_title) ? "font-normal transition tracking-wide" : "text-[--muted]", + )} + data-torrent-preview-item-subtitle + > + {subtitle} + </p>} + + <div className="flex flex-col gap-2" data-torrent-preview-item-subcontent> + {children && children} + </div> + </div> + </div> + + <div className="absolute right-1 top-1 flex flex-col items-center" data-torrent-preview-item-actions> + {link && <Tooltip + side="left" + trigger={<IconButton + data-torrent-preview-item-open-in-browser-button + icon={<BiLinkExternal className="text-[--muted]" />} + intent="gray-basic" + size="sm" + onClick={() => openTab(link)} + />} + >Open in browser</Tooltip>} + {action} + </div> + </div> + ) + +}) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-preview-list.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-preview-list.tsx new file mode 100644 index 0000000..46fdd4a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-preview-list.tsx @@ -0,0 +1,137 @@ +import { + Anime_Entry, + Debrid_TorrentItemInstantAvailability, + HibikeTorrent_AnimeTorrent, + Torrent_Preview, + Torrent_TorrentMetadata, +} from "@/api/generated/types" +import { + filterItems, + sortItems, + TorrentFilterSortControls, + useTorrentFiltering, + useTorrentSorting, +} from "@/app/(main)/entry/_containers/torrent-search/_components/torrent-common-helpers" +import { + TorrentDebridInstantAvailabilityBadge, + TorrentParsedMetadata, + TorrentResolutionBadge, + TorrentSeedersBadge, +} from "@/app/(main)/entry/_containers/torrent-search/_components/torrent-item-badges" +import { TorrentPreviewItem } from "@/app/(main)/entry/_containers/torrent-search/_components/torrent-preview-item" +import { TorrentSelectionType } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { LuffyError } from "@/components/shared/luffy-error" +import { ScrollAreaBox } from "@/components/shared/scroll-area-box" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { formatDistanceToNowSafe } from "@/lib/helpers/date" +import React from "react" +import { BiCalendarAlt } from "react-icons/bi" +import { LuGem } from "react-icons/lu" + +type TorrentPreviewList = { + entry: Anime_Entry + previews: Torrent_Preview[] + debridInstantAvailability: Record<string, Debrid_TorrentItemInstantAvailability> + isLoading: boolean + selectedTorrents: HibikeTorrent_AnimeTorrent[] + onToggleTorrent: (t: HibikeTorrent_AnimeTorrent) => void + type: TorrentSelectionType + torrentMetadata: Record<string, Torrent_TorrentMetadata> | undefined +} + +export const TorrentPreviewList = React.memo(( + { + entry, + previews, + isLoading, + selectedTorrents, + onToggleTorrent, + debridInstantAvailability, + type, + torrentMetadata, + }: TorrentPreviewList) => { + // Use hooks for sorting and filtering + const { sortField, sortDirection, handleSortChange } = useTorrentSorting() + const { filters, handleFilterChange } = useTorrentFiltering() + + if (isLoading) return <div className="space-y-2"> + <Skeleton className="h-[96px]" /> + <Skeleton className="h-[96px]" /> + <Skeleton className="h-[96px]" /> + <Skeleton className="h-[96px]" /> + </div> + + if (!isLoading && !previews?.length) { + return <LuffyError title="Nothing found" /> + } + + // Apply filters using the generic helper + const filteredPreviews = filterItems(previews, torrentMetadata, filters) + + // Sort the previews based on current sort settings using the generic helper + const sortedPreviews = sortItems(filteredPreviews, sortField, sortDirection) + + return ( + <div className="space-y-2" data-torrent-preview-list> + + <TorrentFilterSortControls + resultCount={sortedPreviews?.length || 0} + sortField={sortField} + sortDirection={sortDirection} + filters={filters} + onSortChange={handleSortChange} + onFilterChange={handleFilterChange} + /> + <ScrollAreaBox className="h-[calc(100dvh_-_25rem)] bg-gray-950/60"> + {/*<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">*/} + {sortedPreviews.filter(Boolean).map(item => { + if (!item.torrent) return null + // const isReleasedBeforeMedia = differenceInCalendarYears(mediaReleaseDate, item.torrent.date) > 2 + return ( + <TorrentPreviewItem + link={item.torrent?.link} + confirmed={item.torrent?.confirmed} + key={item.torrent.link} + title={item.episode?.displayTitle || item.episode?.baseAnime?.title?.userPreferred || ""} + releaseGroup={item.torrent.releaseGroup || ""} + subtitle={item.torrent.name} + isBatch={item.torrent.isBatch ?? false} + isBestRelease={item.torrent.isBestRelease} + image={item.episode?.episodeMetadata?.image || item.episode?.baseAnime?.coverImage?.large || + (item.torrent.confirmed ? (entry.media?.coverImage?.large || entry.media?.bannerImage) : null)} + fallbackImage={entry.media?.coverImage?.large || entry.media?.bannerImage} + isSelected={selectedTorrents.findIndex(n => n.link === item.torrent!.link) !== -1} + onClick={() => onToggleTorrent(item.torrent!)} + > + <div className="flex flex-wrap gap-2 items-center"> + {item.torrent.isBestRelease && ( + <Badge + className="rounded-[--radius-md] text-[0.8rem] bg-pink-800 border-transparent border" + intent="success-solid" + leftIcon={<LuGem className="text-md" />} + > + Best release + </Badge> + )} + <TorrentResolutionBadge resolution={item.torrent.resolution} /> + {((type === "download" || type === "debridstream-select" || type === "debridstream-select-file") && !!item.torrent.infoHash && debridInstantAvailability[item.torrent.infoHash]) && ( + <TorrentDebridInstantAvailabilityBadge /> + )} + <TorrentSeedersBadge seeders={item.torrent.seeders} /> + {!!item.torrent.size && <p className="text-gray-300 text-sm flex items-center gap-1"> + {item.torrent.formattedSize}</p>} + {item.torrent.date && <p className="text-[--muted] text-sm flex items-center gap-1"> + <BiCalendarAlt /> {formatDistanceToNowSafe(item.torrent.date)} + </p>} + </div> + <TorrentParsedMetadata metadata={torrentMetadata?.[item.torrent.infoHash!]} /> + </TorrentPreviewItem> + ) + })} + {/*</div>*/} + </ScrollAreaBox> + </div> + ) + +}) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-table.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-table.tsx new file mode 100644 index 0000000..9b8fbb8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_components/torrent-table.tsx @@ -0,0 +1,180 @@ +import { + Anime_Entry, + Debrid_TorrentItemInstantAvailability, + HibikeTorrent_AnimeTorrent, + Metadata_AnimeMetadata, + Torrent_TorrentMetadata, +} from "@/api/generated/types" +import { + filterItems, + sortItems, + TorrentFilterSortControls, + useTorrentFiltering, + useTorrentSorting, +} from "@/app/(main)/entry/_containers/torrent-search/_components/torrent-common-helpers" +import { + TorrentDebridInstantAvailabilityBadge, + TorrentParsedMetadata, + TorrentResolutionBadge, + TorrentSeedersBadge, +} from "@/app/(main)/entry/_containers/torrent-search/_components/torrent-item-badges" +import { LuffyError } from "@/components/shared/luffy-error" +import { ScrollAreaBox } from "@/components/shared/scroll-area-box" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { TextInput } from "@/components/ui/text-input" +import { formatDistanceToNowSafe } from "@/lib/helpers/date" +import React, { memo } from "react" +import { BiCalendarAlt } from "react-icons/bi" +import { TorrentPreviewItem } from "./torrent-preview-item" + +type TorrentTable = { + entry?: Anime_Entry + torrents: HibikeTorrent_AnimeTorrent[] + selectedTorrents: HibikeTorrent_AnimeTorrent[] + globalFilter: string, + setGlobalFilter: React.Dispatch<React.SetStateAction<string>> + smartSearch: boolean + supportsQuery: boolean + isLoading: boolean + isFetching: boolean + onToggleTorrent: (t: HibikeTorrent_AnimeTorrent) => void + debridInstantAvailability: Record<string, Debrid_TorrentItemInstantAvailability> + animeMetadata: Metadata_AnimeMetadata | undefined + torrentMetadata: Record<string, Torrent_TorrentMetadata> | undefined +} + +export const TorrentTable = memo(( + { + entry, + torrents, + selectedTorrents, + globalFilter, + supportsQuery, + setGlobalFilter, + smartSearch, + isFetching, + isLoading, + onToggleTorrent, + debridInstantAvailability, + animeMetadata, + torrentMetadata, + }: TorrentTable) => { + // Use hooks for sorting and filtering + const { sortField, sortDirection, handleSortChange } = useTorrentSorting() + const { filters, handleFilterChange } = useTorrentFiltering() + + // Apply filters using the generic helper + const filteredTorrents = filterItems(torrents, torrentMetadata, filters) + + // Sort the torrents after filtering using the generic helper + const sortedTorrents = sortItems(filteredTorrents, sortField, sortDirection) + + return ( + <> + <TextInput + value={globalFilter} + onValueChange={setGlobalFilter} + /> + + {(isLoading || isFetching) ? <div className="space-y-2"> + <Skeleton className="h-[96px]" /> + <Skeleton className="h-[96px]" /> + <Skeleton className="h-[96px]" /> + <Skeleton className="h-[96px]" /> + </div> : !torrents?.length ? <div> + <LuffyError title="Nothing found" /> + </div> : ( + <> + <TorrentFilterSortControls + resultCount={sortedTorrents?.length || 0} + sortField={sortField} + sortDirection={sortDirection} + filters={filters} + onSortChange={handleSortChange} + onFilterChange={handleFilterChange} + /> + <ScrollAreaBox className="h-[calc(100dvh_-_25rem)]"> + {sortedTorrents.map(torrent => { + const parsedEpisodeNumberStr = torrentMetadata?.[torrent.infoHash!]?.metadata?.episode_number?.[0] + const parsedEpisodeNumber = parsedEpisodeNumberStr ? parseInt(parsedEpisodeNumberStr) : undefined + const releaseGroup = torrent.releaseGroup || torrentMetadata?.[torrent.infoHash!]?.metadata?.release_group || "" + let episodeNumber = torrent.episodeNumber || parsedEpisodeNumber || -1 + let totalEpisodes = entry?.media?.episodes || (entry?.media?.nextAiringEpisode?.episode ? entry?.media?.nextAiringEpisode?.episode : 0) + if (episodeNumber > totalEpisodes) { + // normalize episode number + for (const epKey in animeMetadata?.episodes) { + const ep = animeMetadata?.episodes?.[epKey] + if (ep?.absoluteEpisodeNumber === episodeNumber) { + episodeNumber = ep.episodeNumber + } + } + } + + let episodeImage: string | undefined + if (!!animeMetadata && (episodeNumber ?? -1) >= 0) { + const episode = animeMetadata.episodes?.[episodeNumber!.toString()] + if (episode) { + episodeImage = episode.image + } + } + let distance = 9999 + if (!!torrentMetadata && !!torrent.infoHash) { + const metadata = torrentMetadata[torrent.infoHash!] + if (metadata) { + distance = metadata.distance + } + } + if (distance > 20) { + episodeImage = undefined + } + return ( + <TorrentPreviewItem + // isBasic + link={torrent.link} + key={torrent.link} + title={torrent.name} + releaseGroup={releaseGroup} + subtitle={torrent.isBatch ? torrent.name : (episodeNumber ?? -1) >= 0 + ? `Episode ${episodeNumber}` + : ""} + isBatch={torrent.isBatch ?? false} + isBestRelease={torrent.isBestRelease} + image={distance <= 20 ? episodeImage : undefined} + fallbackImage={(entry?.media?.coverImage?.large || entry?.media?.bannerImage)} + isSelected={selectedTorrents.findIndex(n => n.link === torrent!.link) !== -1} + onClick={() => onToggleTorrent(torrent!)} + // confirmed={distance === 0} + > + <div className="flex flex-wrap gap-2 items-center"> + {torrent.isBestRelease && ( + <Badge + className="rounded-[--radius-md] text-[0.8rem] bg-pink-800 border-transparent border" + intent="success-solid" + + > + Best release + </Badge> + )} + <TorrentResolutionBadge resolution={torrent.resolution} /> + {(!!torrent.infoHash && debridInstantAvailability[torrent.infoHash]) && ( + <TorrentDebridInstantAvailabilityBadge /> + )} + <TorrentSeedersBadge seeders={torrent.seeders} /> + {!!torrent.size && <p className="text-gray-300 text-sm flex items-center gap-1"> + {torrent.formattedSize}</p>} + {torrent.date && <p className="text-[--muted] text-sm flex items-center gap-1"> + <BiCalendarAlt /> {formatDistanceToNowSafe(torrent.date)} + </p>} + </div> + <TorrentParsedMetadata metadata={torrentMetadata?.[torrent.infoHash!]} /> + </TorrentPreviewItem> + ) + })} + </ScrollAreaBox> + </> + )} + </> + ) + +}) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-search.ts b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-search.ts new file mode 100644 index 0000000..d88fb7d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-search.ts @@ -0,0 +1,197 @@ +import { Anime_Entry, Anime_EntryDownloadInfo } from "@/api/generated/types" +import { useAnimeListTorrentProviderExtensions } from "@/api/hooks/extensions.hooks" +import { useSearchTorrent } from "@/api/hooks/torrent_search.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { __torrentSearch_selectedTorrentsAtom } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-container" +import { __torrentSearch_selectionEpisodeAtom, TorrentSelectionType } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { useDebounceWithSet } from "@/hooks/use-debounce" +import { logger } from "@/lib/helpers/debug" +import { useAtom } from "jotai/react" +import React, { startTransition } from "react" + +type TorrentSearchHookProps = { + hasEpisodesToDownload: boolean + shouldLookForBatches: boolean + downloadInfo: Anime_EntryDownloadInfo | undefined + entry: Anime_Entry | undefined + isAdult: boolean + type: TorrentSelectionType +} + +export const enum Torrent_SearchType { + SMART = "smart", + SIMPLE = "simple", +} + +export function useHandleTorrentSearch(props: TorrentSearchHookProps) { + + const { + hasEpisodesToDownload, + shouldLookForBatches, + downloadInfo, + entry, + isAdult, + } = props + + const serverStatus = useServerStatus() + + const { data: providerExtensions } = useAnimeListTorrentProviderExtensions() + + // Get the selected provider extension + const defaultProviderExtension = React.useMemo(() => { + return providerExtensions?.find(ext => ext.id === serverStatus?.settings?.library?.torrentProvider) + }, [serverStatus?.settings?.library?.torrentProvider, providerExtensions]) + + // Gives the ability to change the selected provider extension + const [selectedProviderExtensionId, setSelectedProviderExtensionId] = React.useState(defaultProviderExtension?.id || "none") + + // Update the selected provider only when the default provider changes + React.useLayoutEffect(() => { + setSelectedProviderExtensionId(defaultProviderExtension?.id || "none") + }, [defaultProviderExtension]) + + // Get the selected provider extension + const selectedProviderExtension = React.useMemo(() => { + return providerExtensions?.find(ext => ext.id === selectedProviderExtensionId) + }, [selectedProviderExtensionId, providerExtensions]) + + const [soughtEpisode, setSoughtEpisode] = useAtom(__torrentSearch_selectionEpisodeAtom) + + // Smart search is not enabled for adult content + const [searchType, setSearchType] = React.useState(!isAdult ? Torrent_SearchType.SMART : Torrent_SearchType.SIMPLE) + + const [globalFilter, debouncedGlobalFilter, setGlobalFilter] = useDebounceWithSet(hasEpisodesToDownload + ? "" + : (entry?.media?.title?.romaji || ""), 500) + const [selectedTorrents, setSelectedTorrents] = useAtom(__torrentSearch_selectedTorrentsAtom) + const [smartSearchBatch, setSmartSearchBatch] = React.useState<boolean>(shouldLookForBatches || false) + // const [smartSearchEpisode, setSmartSearchEpisode] = React.useState<number>(downloadInfo?.episodesToDownload?.[0]?.episode?.episodeNumber || 1) + const [smartSearchResolution, setSmartSearchResolution] = React.useState("") + const [smartSearchBest, setSmartSearchBest] = React.useState(false) + const [smartSearchEpisode, debouncedSmartSearchEpisode, setSmartSearchEpisode] = useDebounceWithSet(downloadInfo?.episodesToDownload?.[0]?.episode?.episodeNumber || 1, + 500) + + const warnings = { + noProvider: !selectedProviderExtension, + extensionDoesNotSupportAdult: isAdult && selectedProviderExtension && !selectedProviderExtension?.settings?.supportsAdult, + extensionDoesNotSupportSmartSearch: searchType === Torrent_SearchType.SMART && selectedProviderExtension && !selectedProviderExtension?.settings?.canSmartSearch, + extensionDoesNotSupportBestRelease: smartSearchBest && selectedProviderExtension && !selectedProviderExtension?.settings?.smartSearchFilters?.includes( + "bestReleases"), + extensionDoesNotSupportBatchSearch: smartSearchBatch && selectedProviderExtension && !selectedProviderExtension?.settings?.smartSearchFilters?.includes( + "batch"), + } + + // Change fields when changing the selected provider - i.e. when [selectedProviderExtensionId] changes + React.useLayoutEffect(() => { + // If the selected provider supports smart search, enable it if it's not already enabled + if (searchType === Torrent_SearchType.SIMPLE && selectedProviderExtension?.settings?.canSmartSearch) { + setSearchType(Torrent_SearchType.SMART) + } + }, [searchType && warnings.extensionDoesNotSupportSmartSearch, selectedProviderExtension?.settings?.canSmartSearch, selectedProviderExtensionId]) + React.useLayoutEffect(() => { + // If the selected provider does not support smart search, disable it + if (searchType === Torrent_SearchType.SMART && warnings.extensionDoesNotSupportSmartSearch) { + setSearchType(Torrent_SearchType.SIMPLE) + } + }, [warnings.extensionDoesNotSupportSmartSearch, selectedProviderExtensionId, searchType]) + React.useLayoutEffect(() => { + // If the selected provider does not support best release, disable it + if (smartSearchBest && warnings.extensionDoesNotSupportBestRelease) { + setSmartSearchBest(false) + } + }, [warnings.extensionDoesNotSupportBestRelease, selectedProviderExtensionId, smartSearchBest]) + React.useLayoutEffect(() => { + // If the selected provider does not support batch search, disable it + if (smartSearchBatch && warnings.extensionDoesNotSupportBatchSearch) { + setSmartSearchBatch(false) + } + }, [warnings.extensionDoesNotSupportBatchSearch, selectedProviderExtensionId, smartSearchBatch]) + + React.useEffect(() => { + console.log("globalFilter", globalFilter) + }, [globalFilter]) + + console.log("smartSearchResolution", smartSearchResolution) + + /** + * Fetch torrent search data + */ + const { data: _data, isLoading: _isLoading, isFetching: _isFetching } = useSearchTorrent({ + query: debouncedGlobalFilter.trim().toLowerCase(), + episodeNumber: debouncedSmartSearchEpisode, + batch: smartSearchBatch, + media: entry?.media, + absoluteOffset: downloadInfo?.absoluteOffset || 0, + resolution: smartSearchResolution, + type: searchType, + provider: selectedProviderExtension?.id!, + bestRelease: searchType === Torrent_SearchType.SMART && smartSearchBest, + }, + !(searchType === Torrent_SearchType.SIMPLE && debouncedGlobalFilter.length === 0) // If simple search, user input must not be empty + && !warnings.noProvider + && !warnings.extensionDoesNotSupportAdult + && !warnings.extensionDoesNotSupportSmartSearch + && !warnings.extensionDoesNotSupportBestRelease + && !!providerExtensions, // Provider extensions must be loaded + ) + + React.useLayoutEffect(() => { + if (soughtEpisode !== undefined) { + setSmartSearchEpisode(soughtEpisode) + startTransition(() => { + setSoughtEpisode(undefined) + }) + } + }, [soughtEpisode]) + + // const data = React.useMemo(() => isAdult ? _nsfw_data : _data, [_data, _nsfw_data]) + // const isLoading = React.useMemo(() => isAdult ? _nsfw_isLoading : _isLoading, [_isLoading, _nsfw_isLoading]) + // const isFetching = React.useMemo(() => isAdult ? _nsfw_isFetching : _isFetching, [_isFetching, _nsfw_isFetching]) + + React.useEffect(() => { + logger("TORRENT SEARCH").info({ warnings }) + }, [warnings]) + React.useEffect(() => { + logger("TORRENT SEARCH").info({ selectedProviderExtension }) + }, [warnings]) + React.useEffect(() => { + logger("TORRENT SEARCH").info({ + globalFilter, + searchType, + smartSearchBatch, + smartSearchEpisode, + smartSearchResolution, + smartSearchBest, + debouncedSmartSearchEpisode, + }) + }, [globalFilter, searchType, smartSearchBatch, smartSearchEpisode, smartSearchResolution, smartSearchBest, debouncedSmartSearchEpisode]) + + return { + warnings, + hasOneWarning: Object.values(warnings).some(w => w), + providerExtensions, + selectedProviderExtension, + selectedProviderExtensionId, + setSelectedProviderExtensionId, + globalFilter, + setGlobalFilter, + selectedTorrents, + setSelectedTorrents, + searchType, + setSearchType, + smartSearchBatch, + setSmartSearchBatch, + smartSearchEpisode, + setSmartSearchEpisode, + smartSearchResolution, + setSmartSearchResolution, + smartSearchBest, + setSmartSearchBest, + debouncedSmartSearchEpisode, + soughtEpisode, + data: _data, + isLoading: _isLoading, + isFetching: _isFetching, + } + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-selection.ts b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-selection.ts new file mode 100644 index 0000000..3707173 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-selection.ts @@ -0,0 +1,96 @@ +import { Anime_Entry, Anime_Episode } from "@/api/generated/types" +import { useHandleStartDebridStream } from "@/app/(main)/entry/_containers/debrid-stream/_lib/handle-debrid-stream" +import { __torrentSearch_selectedTorrentsAtom } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-container" +import { __torrentSearch_selectionAtom, TorrentSelectionType } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { + useDebridStreamAutoplay, + useHandleStartTorrentStream, + useTorrentStreamAutoplay, +} from "@/app/(main)/entry/_containers/torrent-stream/_lib/handle-torrent-stream" +import { __torrentSearch_torrentstreamSelectedTorrentAtom } from "@/app/(main)/entry/_containers/torrent-stream/torrent-stream-file-selection-modal" +import { atom, useSetAtom } from "jotai/index" +import { useAtom } from "jotai/react" +import React from "react" + +const __torrentSearch_streamingSelectedEpisodeAtom = atom<Anime_Episode | null>(null) + +export function useTorrentSearchSelectedStreamEpisode() { + const [value, setter] = useAtom(__torrentSearch_streamingSelectedEpisodeAtom) + + return { + torrentStreamingSelectedEpisode: value, + setTorrentStreamingSelectedEpisode: setter, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function useTorrentSearchSelection({ type = "download", entry }: { type: TorrentSelectionType | undefined, entry: Anime_Entry }) { + + const [selectedTorrents, setSelectedTorrents] = useAtom(__torrentSearch_selectedTorrentsAtom) + const { handleManualTorrentStreamSelection } = useHandleStartTorrentStream() + const { handleStreamSelection } = useHandleStartDebridStream() + const { torrentStreamingSelectedEpisode } = useTorrentSearchSelectedStreamEpisode() + const setTorrentstreamSelectedTorrent = useSetAtom(__torrentSearch_torrentstreamSelectedTorrentAtom) + const [, setDrawerOpen] = useAtom(__torrentSearch_selectionAtom) + const { setDebridstreamAutoplaySelectedTorrent } = useDebridStreamAutoplay() + const { setTorrentstreamAutoplaySelectedTorrent } = useTorrentStreamAutoplay() + + const onTorrentValidated = () => { + console.log("onTorrentValidated", torrentStreamingSelectedEpisode) + if (type === "torrentstream-select") { + if (selectedTorrents.length && !!torrentStreamingSelectedEpisode?.aniDBEpisode) { + setTorrentstreamAutoplaySelectedTorrent(selectedTorrents[0]) + handleManualTorrentStreamSelection({ + torrent: selectedTorrents[0], + entry, + aniDBEpisode: torrentStreamingSelectedEpisode.aniDBEpisode, + episodeNumber: torrentStreamingSelectedEpisode.episodeNumber, + chosenFileIndex: undefined, + }) + setDrawerOpen(undefined) + React.startTransition(() => { + setSelectedTorrents([]) + }) + } + } else if (type === "torrentstream-select-file") { + // Open the drawer to select the file + if (selectedTorrents.length && !!torrentStreamingSelectedEpisode?.aniDBEpisode) { + // This opens the file selection drawer + setTorrentstreamSelectedTorrent(selectedTorrents[0]) + React.startTransition(() => { + setSelectedTorrents([]) + }) + } + } else if (type === "debridstream-select") { + // Start debrid stream with auto file selection + if (selectedTorrents.length && !!torrentStreamingSelectedEpisode?.aniDBEpisode) { + setDebridstreamAutoplaySelectedTorrent(selectedTorrents[0]) + handleStreamSelection({ + torrent: selectedTorrents[0], + entry, + aniDBEpisode: torrentStreamingSelectedEpisode.aniDBEpisode, + episodeNumber: torrentStreamingSelectedEpisode.episodeNumber, + chosenFileId: "", + }) + setDrawerOpen(undefined) + React.startTransition(() => { + setSelectedTorrents([]) + }) + } + } else if (type === "debridstream-select-file") { + // Open the drawer to select the file + if (selectedTorrents.length && !!torrentStreamingSelectedEpisode?.aniDBEpisode) { + // This opens the file selection drawer + setTorrentstreamSelectedTorrent(selectedTorrents[0]) + React.startTransition(() => { + setSelectedTorrents([]) + }) + } + } + } + + return { + onTorrentValidated, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-confirmation-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-confirmation-modal.tsx new file mode 100644 index 0000000..8de92c1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-confirmation-modal.tsx @@ -0,0 +1,317 @@ +import { AL_BaseAnime, Anime_Entry, HibikeTorrent_AnimeTorrent } from "@/api/generated/types" +import { useDebridAddTorrents } from "@/api/hooks/debrid.hooks" +import { useDownloadTorrentFile } from "@/api/hooks/download.hooks" +import { useTorrentClientDownload } from "@/api/hooks/torrent_client.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { __torrentSearch_selectedTorrentsAtom } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-container" +import { __torrentSearch_selectionAtom, TorrentSelectionType } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { DirectorySelector } from "@/components/shared/directory-selector" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import { Switch } from "@/components/ui/switch" +import { Tooltip } from "@/components/ui/tooltip" +import { openTab } from "@/lib/helpers/browser" +import { upath } from "@/lib/helpers/upath" +import { TORRENT_CLIENT } from "@/lib/server/settings" +import { atom } from "jotai" +import { useAtom, useAtomValue, useSetAtom } from "jotai/react" +import { useRouter } from "next/navigation" +import React, { useMemo, useState } from "react" +import { AiOutlineCloudServer } from "react-icons/ai" +import { BiCollection, BiDownload, BiX } from "react-icons/bi" +import { FcFilmReel, FcFolder } from "react-icons/fc" +import { LuDownload, LuPlay } from "react-icons/lu" + +const confirmationModalOpenAtom = atom(false) + +export function TorrentConfirmationModal({ onToggleTorrent, media, entry }: { + onToggleTorrent: (t: HibikeTorrent_AnimeTorrent) => void, + media: AL_BaseAnime, + entry: Anime_Entry +}) { + + const router = useRouter() + const serverStatus = useServerStatus() + const libraryPath = serverStatus?.settings?.library?.libraryPath + + /** + * Default path for the destination folder + */ + const defaultPath = useMemo(() => { + const fPath = entry.localFiles?.findLast(n => n)?.path // file path + const newPath = libraryPath ? upath.join(libraryPath, sanitizeDirectoryName(media.title?.romaji || "")) : "" + return fPath ? upath.normalize(upath.dirname(fPath)) : newPath + }, [libraryPath, entry.localFiles, media.title?.romaji]) + + const [destination, setDestination] = useState(defaultPath) + + const [isConfirmationModalOpen, setConfirmationModalOpen] = useAtom(confirmationModalOpenAtom) + const setTorrentDrawerIsOpen = useSetAtom(__torrentSearch_selectionAtom) + const selectedTorrents = useAtomValue(__torrentSearch_selectedTorrentsAtom) + + /** + * If the user can auto-select the missing episodes + */ + const canSmartSelect = useMemo(() => { + return selectedTorrents.length === 1 + && selectedTorrents[0].isBatch + && media.format !== "MOVIE" + && media.status === "FINISHED" + && !!media.episodes && media.episodes > 1 + && !!entry.downloadInfo?.episodesToDownload && entry.downloadInfo?.episodesToDownload.length > 0 + && entry.downloadInfo?.episodesToDownload.length !== (media.episodes || (media.nextAiringEpisode?.episode! - 1)) + }, [ + selectedTorrents, + media.format, + media.status, + media.episodes, + entry.downloadInfo?.episodesToDownload, + media.nextAiringEpisode?.episode, + serverStatus?.settings?.torrent?.defaultTorrentClient, + ]) + + + // download via torrent client + const { mutate, isPending } = useTorrentClientDownload(() => { + setConfirmationModalOpen(false) + setTorrentDrawerIsOpen(undefined) + router.push("/torrent-list") + }) + + // download torrent file + const { mutate: downloadTorrentFiles, isPending: isDownloadingFiles } = useDownloadTorrentFile(() => { + setConfirmationModalOpen(false) + setTorrentDrawerIsOpen(undefined) + }) + + // download via debrid service + const { mutate: debridAddTorrents, isPending: isDownloadingDebrid } = useDebridAddTorrents(() => { + setConfirmationModalOpen(false) + setTorrentDrawerIsOpen(undefined) + router.push("/debrid") + }) + + const isDisabled = isPending || isDownloadingFiles || isDownloadingDebrid + + function handleLaunchDownload(smartSelect: boolean) { + if (smartSelect) { + mutate({ + torrents: selectedTorrents, + destination, + smartSelect: { + enabled: true, + missingEpisodeNumbers: entry.downloadInfo?.episodesToDownload?.map(n => n.episodeNumber) || [], + }, + media, + }) + } else { + mutate({ + torrents: selectedTorrents, + destination, + smartSelect: { + enabled: false, + missingEpisodeNumbers: [], + }, + media, + }) + } + } + + function handleDownloadFiles() { + downloadTorrentFiles({ + download_urls: selectedTorrents.map(n => n.downloadUrl), + destination, + media, + }) + } + + function handleDebridAddTorrents() { + debridAddTorrents({ + torrents: selectedTorrents, + destination, + media, + }) + } + + const debridActive = serverStatus?.debridSettings?.enabled && !!serverStatus?.debridSettings?.provider + const [isDebrid, setIsDebrid] = useState(debridActive) + + if (selectedTorrents.length === 0) return null + + return ( + <Modal + open={isConfirmationModalOpen} + onOpenChange={() => setConfirmationModalOpen(false)} + contentClass="max-w-3xl" + title="Choose the destination" + data-torrent-confirmation-modal + > + + {debridActive && ( + <Switch + label="Download with Debrid service" + value={isDebrid} + onValueChange={v => setIsDebrid(v)} + /> + )} + + <DirectorySelector + name="destination" + label="Destination" + leftIcon={<FcFolder />} + value={destination} + defaultValue={destination} + onSelect={setDestination} + shouldExist={false} + /> + + {selectedTorrents.map(torrent => ( + <Tooltip + data-torrent-confirmation-modal-tooltip + key={`${torrent.link}`} + trigger={<div + className={cn( + "ml-12 gap-2 p-2 border rounded-[--radius-md] hover:bg-gray-800 relative", + )} + key={torrent.name} + data-torrent-confirmation-modal-torrent-item + > + <div + data-torrent-confirmation-modal-torrent-item-content + className="flex flex-none items-center gap-2 w-[90%] cursor-pointer" + onClick={() => openTab(torrent.link)} + > + <span className="text-lg" data-torrent-confirmation-modal-torrent-item-icon> + {(!torrent.isBatch || media.format === "MOVIE") ? <FcFilmReel /> : + <FcFolder className="text-2xl" />} {/*<BsCollectionPlayFill/>*/} + </span> + <p className="line-clamp-1" data-torrent-confirmation-modal-torrent-item-name> + {torrent.name} + </p> + </div> + <IconButton + icon={<BiX />} + className="absolute right-2 top-2 rounded-full" + size="xs" + intent="gray-outline" + onClick={() => { + onToggleTorrent(torrent) + }} + data-torrent-confirmation-modal-torrent-item-close-button + /> + </div>} + > + Open in browser + </Tooltip> + ))} + + {isDebrid ? ( + <> + {(serverStatus?.debridSettings?.enabled && !!serverStatus?.debridSettings?.provider) && ( + <Button + data-torrent-confirmation-modal-debrid-button + leftIcon={<AiOutlineCloudServer className="text-2xl" />} + intent="white" + onClick={() => handleDebridAddTorrents()} + disabled={isDisabled} + loading={isDownloadingDebrid} + className="w-full" + > + Download with Debrid service + </Button> + )} + </> + ) : ( + <> + <div className="space-y-2" data-torrent-confirmation-modal-download-buttons> + + <div className="flex w-full gap-2" data-torrent-confirmation-modal-download-buttons-left> + {!!selectedTorrents?.every(t => t.downloadUrl) && <Button + data-torrent-confirmation-modal-download-files-button + leftIcon={<BiDownload />} + intent="gray-outline" + onClick={() => handleDownloadFiles()} + disabled={isDisabled} + loading={isDownloadingFiles} + className="w-full" + >Download '.torrent' files</Button>} + + {selectedTorrents.length > 0 && ( + <Button + data-torrent-confirmation-modal-download-button + leftIcon={<BiDownload />} + intent="white" + onClick={() => handleLaunchDownload(false)} + disabled={isDisabled || serverStatus?.settings?.torrent?.defaultTorrentClient === TORRENT_CLIENT.NONE} + loading={isPending} + className="w-full" + > + {!serverStatus?.debridSettings?.enabled + ? (canSmartSelect ? "Download all" : "Download") + : "Download with torrent client"} + </Button> + )} + </div> + + {(selectedTorrents.length > 0 && canSmartSelect) && ( + <Button + data-torrent-confirmation-modal-download-missing-episodes-button + leftIcon={<BiCollection />} + intent="gray-outline" + onClick={() => handleLaunchDownload(true)} + disabled={isDisabled} + loading={isPending} + className="w-full" + > + Download missing episodes + </Button> + )} + + </div> + </> + )} + </Modal> + ) + +} + + +export function TorrentConfirmationContinueButton({ type, onTorrentValidated }: { type: TorrentSelectionType, onTorrentValidated: () => void }) { + + const st = useAtomValue(__torrentSearch_selectedTorrentsAtom) + const setter = useSetAtom(confirmationModalOpenAtom) + + if (st.length === 0) return null + + return ( + <Button + data-torrent-search-confirmation-continue-button + intent="primary" + className="Sea-TorrentSearchConfirmationContinueButton fixed z-[9999] left-0 right-0 bottom-4 rounded-full max-w-lg mx-auto halo" + size="lg" + onClick={() => { + if (type === "download") { + setter(true) + } else { + onTorrentValidated() + } + }} + leftIcon={type === "download" ? <LuDownload /> : <LuPlay />} + > + {type === "download" ? "Download" : "Stream"} + {type === "download" ? ` (${st.length})` : ""} + </Button> + ) + +} + +function sanitizeDirectoryName(input: string): string { + const disallowedChars = /[<>:"/\\|?*\x00-\x1F]/g // Pattern for disallowed characters + // Replace disallowed characters with an underscore + const sanitized = input.replace(disallowedChars, " ") + // Remove leading/trailing spaces and dots (periods) which are not allowed + const trimmed = sanitized.trim().replace(/^\.+|\.+$/g, "").replace(/\s+/g, " ") + // Ensure the directory name is not empty after sanitization + return trimmed || "Untitled" +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-search-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-search-button.tsx new file mode 100644 index 0000000..8981770 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-search-button.tsx @@ -0,0 +1,34 @@ +import { Anime_Entry } from "@/api/generated/types" +import { AnimeMetaActionButton } from "@/app/(main)/entry/_components/meta-section" +import { __torrentSearch_selectionAtom } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { useSetAtom } from "jotai/react" +import React, { useMemo } from "react" +import { BiDownload } from "react-icons/bi" +import { FiSearch } from "react-icons/fi" + +export function TorrentSearchButton({ entry }: { entry: Anime_Entry }) { + + const setter = useSetAtom(__torrentSearch_selectionAtom) + const count = entry.downloadInfo?.episodesToDownload?.length + const isMovie = useMemo(() => entry.media?.format === "MOVIE", [entry.media?.format]) + + return ( + <div className="contents" data-torrent-search-button-container> + <AnimeMetaActionButton + intent={!entry.downloadInfo?.hasInaccurateSchedule ? (!!count ? "white" : "gray-subtle") : "white-subtle"} + size="md" + leftIcon={(!!count) ? <BiDownload /> : <FiSearch />} + iconClass="text-2xl" + onClick={() => setter("download")} + data-torrent-search-button + > + {(!entry.downloadInfo?.hasInaccurateSchedule && !!count) ? <> + {(!isMovie) && `Download ${entry.downloadInfo?.batchAll ? "batch /" : "next"} ${count > 1 ? `${count} episodes` : "episode"}`} + {(isMovie) && `Download movie`} + </> : <> + Search torrents + </>} + </AnimeMetaActionButton> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-search-container.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-search-container.tsx new file mode 100644 index 0000000..7523e85 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-search-container.tsx @@ -0,0 +1,493 @@ +import { Anime_Entry, Debrid_TorrentItemInstantAvailability, HibikeTorrent_AnimeTorrent } from "@/api/generated/types" +import { useGetTorrentstreamBatchHistory } from "@/api/hooks/torrentstream.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useHandleStartDebridStream } from "@/app/(main)/entry/_containers/debrid-stream/_lib/handle-debrid-stream" +import { DebridStreamFileSelectionModal } from "@/app/(main)/entry/_containers/debrid-stream/debrid-stream-file-selection-modal" +import { + TorrentDebridInstantAvailabilityBadge, + TorrentResolutionBadge, + TorrentSeedersBadge, +} from "@/app/(main)/entry/_containers/torrent-search/_components/torrent-item-badges" +import { TorrentPreviewItem } from "@/app/(main)/entry/_containers/torrent-search/_components/torrent-preview-item" +import { TorrentPreviewList } from "@/app/(main)/entry/_containers/torrent-search/_components/torrent-preview-list" +import { TorrentTable } from "@/app/(main)/entry/_containers/torrent-search/_components/torrent-table" +import { Torrent_SearchType, useHandleTorrentSearch } from "@/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-search" +import { useTorrentSearchSelectedStreamEpisode } from "@/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-selection" +import { TorrentConfirmationModal } from "@/app/(main)/entry/_containers/torrent-search/torrent-confirmation-modal" +import { __torrentSearch_selectionAtom, TorrentSelectionType } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { + useDebridStreamAutoplay, + useHandleStartTorrentStream, + useTorrentStreamAutoplay, +} from "@/app/(main)/entry/_containers/torrent-stream/_lib/handle-torrent-stream" +import { + __torrentSearch_torrentstreamSelectedTorrentAtom, + TorrentstreamFileSelectionModal, +} from "@/app/(main)/entry/_containers/torrent-stream/torrent-stream-file-selection-modal" +import { LuffyError } from "@/components/shared/luffy-error" +import { Alert } from "@/components/ui/alert" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/components/ui/core/styling" +import { DataGridSearchInput } from "@/components/ui/datagrid" +import { NumberInput } from "@/components/ui/number-input" +import { Select } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" +import { formatDistanceToNowSafe } from "@/lib/helpers/date" +import { TORRENT_PROVIDER } from "@/lib/server/settings" +import { subDays, subMonths } from "date-fns" +import { atom, useSetAtom } from "jotai" +import { useAtom } from "jotai/react" +import React, { startTransition } from "react" +import { BiCalendarAlt } from "react-icons/bi" +import { LuCornerLeftDown, LuGem } from "react-icons/lu" +import { RiFolderDownloadFill } from "react-icons/ri" + +export const __torrentSearch_selectedTorrentsAtom = atom<HibikeTorrent_AnimeTorrent[]>([]) + +export function TorrentSearchContainer({ type, entry }: { type: TorrentSelectionType, entry: Anime_Entry }) { + const downloadInfo = React.useMemo(() => entry.downloadInfo, [entry.downloadInfo]) + const serverStatus = useServerStatus() + + const shouldLookForBatches = React.useMemo(() => { + const endedDate = entry.media?.endDate?.year ? new Date(entry.media?.endDate?.year, + entry.media?.endDate?.month ? entry.media?.endDate?.month - 1 : 0, + entry.media?.endDate?.day || 0) : null + const now = new Date() + let flag = true + + if (type === "download") { + if (endedDate && subDays(now, 6) < endedDate) { + flag = false + } + return !!downloadInfo?.canBatch && !!downloadInfo?.episodesToDownload?.length && flag + } else { + if (endedDate && subMonths(now, 1) < endedDate) { + flag = false + } + return !!downloadInfo?.canBatch && flag + } + }, [downloadInfo?.canBatch, downloadInfo?.episodesToDownload?.length, type, entry.media?.endDate]) + + const hasEpisodesToDownload = React.useMemo(() => !!downloadInfo?.episodesToDownload?.length, [downloadInfo?.episodesToDownload?.length]) + const [isAdult, setIsAdult] = React.useState(entry.media?.isAdult === true) + + const { + warnings, + hasOneWarning, + selectedProviderExtension, + selectedProviderExtensionId, + setSelectedProviderExtensionId, + providerExtensions, + globalFilter, + setGlobalFilter, + selectedTorrents, + setSelectedTorrents, + searchType, + setSearchType, + smartSearchBatch, + setSmartSearchBatch, + smartSearchEpisode, + setSmartSearchEpisode, + smartSearchResolution, + setSmartSearchResolution, + smartSearchBest, + setSmartSearchBest, + data, + isLoading, + isFetching, + soughtEpisode, + } = useHandleTorrentSearch({ + isAdult, + hasEpisodesToDownload, + shouldLookForBatches, + downloadInfo, + entry, + type, + }) + + React.useEffect(() => { + setSelectedTorrents([]) + }, []) + + React.useLayoutEffect(() => { + if (searchType === Torrent_SearchType.SMART) { + setGlobalFilter("") + } else if (searchType === Torrent_SearchType.SIMPLE) { + const title = entry.media?.title?.romaji || entry.media?.title?.english || entry.media?.title?.userPreferred + setGlobalFilter(title?.replaceAll(":", "").replaceAll("-", "") || "") + } + }, [searchType, entry.media?.title]) + + const torrents = React.useMemo(() => data?.torrents ?? [], [data?.torrents]) + const previews = React.useMemo(() => data?.previews ?? [], [data?.previews]) + const debridInstantAvailability = React.useMemo(() => serverStatus?.debridSettings?.enabled ? data?.debridInstantAvailability ?? {} : {}, + [data?.debridInstantAvailability, serverStatus?.debridSettings?.enabled]) + + /** + * Select torrent + * - Download: Select multiple torrents + * - Select: Select only one torrent + */ + const handleToggleTorrent = React.useCallback((t: HibikeTorrent_AnimeTorrent) => { + if (type === "download") { + setSelectedTorrents(prev => { + const idx = prev.findIndex(n => n.link === t.link) + if (idx !== -1) { + return prev.filter(n => n.link !== t.link) + } + return [...prev, t] + }) + } else { + setSelectedTorrents(prev => { + const idx = prev.findIndex(n => n.link === t.link) + if (idx !== -1) { + return [] + } + return [t] + }) + } + }, [setSelectedTorrents, smartSearchBest, type]) + + /** + * Handle streams + */ + + + return ( + <> + {(type === "torrentstream-select" || type === "torrentstream-select-file" || type === "debridstream-select-file" || type === "debridstream-select") && + <TorrentSearchTorrentStreamBatchHistory + type={type} + entry={entry} + debridInstantAvailability={debridInstantAvailability} + />} + + <AppLayoutStack className="Sea-TorrentSearchContainer__root space-y-4" data-torrent-search-container> + + <div + className="Sea-TorrentSearchContainer__paramContainer flex flex-wrap gap-3 items-center" + data-torrent-search-container-param-container + > + <div className="w-[200px]" data-torrent-search-container-param-container-provider-select-container> + <Select + name="torrentProvider" + // leftAddon="Torrent Provider" + value={selectedProviderExtension?.id ?? TORRENT_PROVIDER.NONE} + onValueChange={setSelectedProviderExtensionId} + leftIcon={<RiFolderDownloadFill className="text-[--brand]" />} + options={[ + ...(providerExtensions?.map(ext => ({ + label: ext.name, + value: ext.id, + })) ?? []).sort((a, b) => a?.label?.localeCompare(b?.label) ?? 0), + { label: "None", value: TORRENT_PROVIDER.NONE }, + ]} + /> + </div> + + {(selectedProviderExtensionId !== "none" && selectedProviderExtensionId !== "") && <> + <div + className="h-10 rounded-[--radius] px-2 flex items-center" + data-torrent-search-container-param-container-smart-search-switch-container + > + <Switch + // side="right" + label="Smart search" + moreHelp={selectedProviderExtension?.settings?.canSmartSearch + ? "Automatically search based on given parameters" + : "This provider does not support smart search"} + value={searchType === Torrent_SearchType.SMART} + onValueChange={v => setSearchType(v ? Torrent_SearchType.SMART : Torrent_SearchType.SIMPLE)} + disabled={!selectedProviderExtension?.settings?.canSmartSearch} + containerClass="flex-row-reverse gap-1" + /> + </div> + + {entry.media?.isAdult === false && <div + className="h-10 rounded-[--radius] px-2 flex items-center" + data-torrent-search-container-param-container-adult-switch-container + > + <Switch + // side="right" + label="Adult" + moreHelp="If enabled, the adult content flag will be passed to the provider." + value={isAdult} + onValueChange={setIsAdult} + containerClass="flex-row-reverse gap-1" + /> + </div>} + </>} + </div> + + {(selectedProviderExtensionId !== "none" && selectedProviderExtensionId !== "") && Object.keys(warnings)?.map((key) => { + if ((warnings as any)[key]) { + return <Alert + data-torrent-search-container-warning + key={key} + intent="warning" + description={<> + {key === "extensionDoesNotSupportAdult" && "This provider does not support adult content"} + {key === "extensionDoesNotSupportSmartSearch" && "This provider does not support smart search"} + {key === "extensionDoesNotSupportBestRelease" && "This provider does not support best release search"} + </>} + /> + } + return null + })} + + {(selectedProviderExtensionId !== "none" && selectedProviderExtensionId !== "") ? ( + <> + {(searchType === Torrent_SearchType.SMART) && + <AppLayoutStack className="Sea-TorrentSearchContainer__smartSearchContainer" data-torrent-search-smart-search-container> + <div + data-torrent-search-smart-search-provider-param-container + className={cn( + "Sea-TorrentSearchContainer__providerParamContainer flex flex-col items-center flex-wrap justify-around gap-3 md:flex-row w-full border rounded-[--radius] p-3", + { + "hidden": !selectedProviderExtension?.settings?.smartSearchFilters?.includes("episodeNumber") && + !selectedProviderExtension?.settings?.smartSearchFilters?.includes("resolution") + && !selectedProviderExtension?.settings?.smartSearchFilters?.includes("batch") + && !selectedProviderExtension?.settings?.smartSearchFilters?.includes("bestReleases") + && !selectedProviderExtension?.settings?.smartSearchFilters?.includes("search"), + }, + )} + > + + {selectedProviderExtension?.settings?.smartSearchFilters?.includes("episodeNumber") && <NumberInput + data-torrent-search-smart-search-episode-number-input + label="Episode number" + value={smartSearchEpisode} + disabled={entry?.media?.format === "MOVIE" || smartSearchBest} + onValueChange={(value) => { + startTransition(() => { + setSmartSearchEpisode(value) + }) + }} + formatOptions={{ useGrouping: false }} + // hideControls + size="sm" + fieldClass={cn( + "flex flex-none w-fit md:justify-end gap-3 space-y-0", + { "opacity-50 cursor-not-allowed pointer-events-none": (smartSearchBatch || searchType != Torrent_SearchType.SMART) }, + )} + fieldLabelClass={cn( + "flex-none self-center font-normal !text-md sm:text-md lg:text-md", + )} + className="max-w-[4rem]" + />} + + {selectedProviderExtension?.settings?.smartSearchFilters?.includes("resolution") && <Select + data-torrent-search-smart-search-resolution-select + label="Resolution" + value={smartSearchResolution || "-"} + onValueChange={v => setSmartSearchResolution(v != "-" ? v : "")} + options={[ + { value: "-", label: "Any" }, + { value: "1080", label: "1080p" }, + { value: "720", label: "720p" }, + { value: "540", label: "540p" }, + { value: "480", label: "480p" }, + { value: "2160", label: "2160p" }, + ]} + disabled={smartSearchBest || searchType != Torrent_SearchType.SMART} + size="sm" + fieldClass={cn( + "flex flex-none w-fit md:justify-center gap-3 space-y-0", + { "opacity-50 cursor-not-allowed pointer-events-none": searchType != Torrent_SearchType.SMART || smartSearchBest }, + )} + fieldLabelClass="flex-none self-center font-normal !text-md sm:text-md lg:text-md" + className="w-[6rem]" + />} + + {selectedProviderExtension?.settings?.smartSearchFilters?.includes("batch") && <Switch + data-torrent-search-smart-search-batch-switch + label="Batches" + value={smartSearchBatch} + onValueChange={setSmartSearchBatch} + disabled={smartSearchBest || !downloadInfo?.canBatch} + fieldClass={cn( + "flex flex-none w-fit", + { "opacity-50 cursor-not-allowed pointer-events-none": !downloadInfo?.canBatch || smartSearchBest }, + )} + size="sm" + containerClass="flex-row-reverse gap-1" + />} + + {selectedProviderExtension?.settings?.smartSearchFilters?.includes("bestReleases") && <Switch + data-torrent-search-smart-search-best-releases-switch + label="Best releases" + value={smartSearchBest} + onValueChange={setSmartSearchBest} + fieldClass={cn( + "flex flex-none w-fit", + { "opacity-50 cursor-not-allowed pointer-events-none": !downloadInfo?.canBatch }, + )} + size="sm" + containerClass="flex-row-reverse gap-1" + />} + + </div> + + {!hasOneWarning && ( + <> + {selectedProviderExtension?.settings?.smartSearchFilters?.includes("query") && + <div className="py-1" data-torrent-search-smart-search-query-input-container> + <DataGridSearchInput + value={globalFilter ?? ""} + onChange={v => setGlobalFilter(v)} + placeholder={searchType === Torrent_SearchType.SMART + ? `Refine the title (${entry.media?.title?.romaji})` + : "Search"} + fieldClass="md:max-w-full w-full" + /> + </div>} + + <TorrentPreviewList + entry={entry} + previews={previews} + isLoading={isLoading} + selectedTorrents={selectedTorrents} + onToggleTorrent={handleToggleTorrent} + debridInstantAvailability={debridInstantAvailability} + type={type} + torrentMetadata={data?.torrentMetadata} + // animeMetadata={data?.animeMetadata} + /> + </> + )} + </AppLayoutStack>} + + {hasOneWarning && <LuffyError />} + + {((searchType !== Torrent_SearchType.SMART) && !hasOneWarning && !previews?.length) && ( + <> + <TorrentTable + entry={entry} + torrents={torrents} + globalFilter={globalFilter} + setGlobalFilter={setGlobalFilter} + smartSearch={false} + supportsQuery + isLoading={isLoading} + isFetching={isFetching} + selectedTorrents={selectedTorrents} + onToggleTorrent={handleToggleTorrent} + debridInstantAvailability={debridInstantAvailability} + animeMetadata={data?.animeMetadata} + torrentMetadata={data?.torrentMetadata} + /> + </> + )} + + </> + ) : (!!providerExtensions) ? <LuffyError title="No extension selected" /> : <div className="space-y-2"> + <Skeleton className="h-[96px]" /> + <Skeleton className="h-[96px]" /> + <Skeleton className="h-[96px]" /> + <Skeleton className="h-[96px]" /> + </div>} + </AppLayoutStack> + + {type === "download" && <TorrentConfirmationModal + onToggleTorrent={handleToggleTorrent} + media={entry.media!!} + entry={entry} + />} + + {type === "torrentstream-select-file" && <TorrentstreamFileSelectionModal entry={entry} />} + {type === "debridstream-select-file" && <DebridStreamFileSelectionModal entry={entry} />} + </> + ) + +} + +function TorrentSearchTorrentStreamBatchHistory({ entry, type, debridInstantAvailability }: { + entry: Anime_Entry | undefined, + type: TorrentSelectionType, + debridInstantAvailability: Record<string, Debrid_TorrentItemInstantAvailability> +}) { + + const { data: batchHistory } = useGetTorrentstreamBatchHistory(entry?.mediaId, true) + + const { handleManualTorrentStreamSelection } = useHandleStartTorrentStream() + const { handleStreamSelection } = useHandleStartDebridStream() + const { torrentStreamingSelectedEpisode } = useTorrentSearchSelectedStreamEpisode() + const setTorrentstreamSelectedTorrent = useSetAtom(__torrentSearch_torrentstreamSelectedTorrentAtom) + const [, setter] = useAtom(__torrentSearch_selectionAtom) + + const { setDebridstreamAutoplaySelectedTorrent } = useDebridStreamAutoplay() + const { setTorrentstreamAutoplaySelectedTorrent } = useTorrentStreamAutoplay() + + if (!batchHistory?.torrent || !entry) return null + + return ( + <AppLayoutStack> + <h5 className="text-center flex gap-2 items-center"><LuCornerLeftDown className="mt-1" /> Previous selection</h5> + + <TorrentPreviewItem + link={batchHistory?.torrent?.link} + confirmed={batchHistory?.torrent?.confirmed} + key={batchHistory?.torrent.link} + title={""} + releaseGroup={batchHistory?.torrent.releaseGroup || ""} + subtitle={batchHistory?.torrent.name} + isBatch={batchHistory?.torrent.isBatch ?? false} + image={entry?.media?.coverImage?.large || entry?.media?.bannerImage} + fallbackImage={entry?.media?.coverImage?.large || entry?.media?.bannerImage} + isBestRelease={batchHistory?.torrent.isBestRelease} + onClick={() => { + if (!batchHistory?.torrent || !torrentStreamingSelectedEpisode?.aniDBEpisode) return + if (type === "torrentstream-select") { + setTorrentstreamAutoplaySelectedTorrent(batchHistory.torrent) + handleManualTorrentStreamSelection({ + torrent: batchHistory?.torrent, + entry, + aniDBEpisode: torrentStreamingSelectedEpisode.aniDBEpisode, + episodeNumber: torrentStreamingSelectedEpisode.episodeNumber, + chosenFileIndex: undefined, + }) + setter(undefined) + } else if (type === "debridstream-select") { + setDebridstreamAutoplaySelectedTorrent(batchHistory.torrent) + handleStreamSelection({ + torrent: batchHistory?.torrent, + entry, + aniDBEpisode: torrentStreamingSelectedEpisode.aniDBEpisode, + episodeNumber: torrentStreamingSelectedEpisode.episodeNumber, + chosenFileId: "", + }) + setter(undefined) + } else if (type === "torrentstream-select-file" || type === "debridstream-select-file") { + // Open the drawer to select the file + // This opens the file selection drawer + setTorrentstreamSelectedTorrent(batchHistory?.torrent) + } + }} + > + <div className="flex flex-wrap gap-3 items-center"> + {batchHistory?.torrent?.isBestRelease && ( + <Badge + className="rounded-[--radius-md] text-[0.8rem] bg-pink-800 border-transparent border" + intent="success-solid" + leftIcon={<LuGem className="text-md" />} + > + Best release + </Badge> + )} + <TorrentResolutionBadge resolution={batchHistory?.torrent?.resolution} /> + {(!!batchHistory?.torrent?.infoHash && debridInstantAvailability[batchHistory?.torrent?.infoHash]) && ( + <TorrentDebridInstantAvailabilityBadge /> + )} + <TorrentSeedersBadge seeders={batchHistory?.torrent?.seeders} /> + {!!batchHistory?.torrent?.size && <p className="text-gray-300 text-sm flex items-center gap-1"> + {batchHistory?.torrent?.formattedSize}</p>} + {batchHistory?.torrent?.date && <p className="text-[--muted] text-sm flex items-center gap-1"> + <BiCalendarAlt /> {formatDistanceToNowSafe(batchHistory?.torrent?.date)} + </p>} + </div> + </TorrentPreviewItem> + </AppLayoutStack> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-search-drawer.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-search-drawer.tsx new file mode 100644 index 0000000..25d3586 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-search/torrent-search-drawer.tsx @@ -0,0 +1,142 @@ +import { Anime_Entry, Anime_EntryDownloadEpisode } from "@/api/generated/types" +import { useTorrentSearchSelection } from "@/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-selection" +import { TorrentConfirmationContinueButton } from "@/app/(main)/entry/_containers/torrent-search/torrent-confirmation-modal" +import { TorrentSearchContainer } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-container" +import { GlowingEffect } from "@/components/shared/glowing-effect" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Modal } from "@/components/ui/modal" +import { useThemeSettings } from "@/lib/theme/hooks" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import React, { useEffect } from "react" + +export const __torrentSearch_selectionAtom = atom<TorrentSelectionType | undefined>(undefined) +export const __torrentSearch_selectionEpisodeAtom = atom<number | undefined>(undefined) + +export type TorrentSelectionType = + "torrentstream-select" + | "torrentstream-select-file" + | "debridstream-select" + | "debridstream-select-file" + | "download" + +export function TorrentSearchDrawer(props: { entry: Anime_Entry }) { + + const { entry } = props + const ts = useThemeSettings() + + const [selectionType, setSelection] = useAtom(__torrentSearch_selectionAtom) + const searchParams = useSearchParams() + const router = useRouter() + const pathname = usePathname() + const mId = searchParams.get("id") + const downloadParam = searchParams.get("download") + + useEffect(() => { + if (!!downloadParam) { + setSelection("download") + router.replace(pathname + `?id=${mId}`) + } + }, [downloadParam]) + + const { onTorrentValidated } = useTorrentSearchSelection({ entry, type: selectionType }) + + return ( + <Modal + open={selectionType !== undefined} + onOpenChange={() => setSelection(undefined)} + // size="xl" + contentClass="max-w-5xl bg-gray-950 bg-opacity-75 firefox:bg-opacity-100 sm:rounded-xl" + title={`${entry?.media?.title?.userPreferred || "Anime"}`} + titleClass="max-w-[500px] text-ellipsis truncate" + data-torrent-search-drawer + overlayClass="bg-gray-950/70 backdrop-blur-sm" + > + + <GlowingEffect + spread={40} + // blur={1} + glow={true} + disabled={false} + proximity={100} + inactiveZone={0.01} + className="opacity-30" + /> + + {/*{(ts.enableMediaPageBlurredBackground) && <div*/} + {/* data-media-page-header-blurred-background*/} + {/* className={cn(*/} + {/* "absolute top-0 left-0 w-full h-full z-[0] bg-[--background] rounded-xl overflow-hidden",*/} + {/* "opacity-20",*/} + {/* )}*/} + {/*>*/} + {/* <Image*/} + {/* data-media-page-header-blurred-background-image*/} + {/* src={getImageUrl(entry.media?.bannerImage || "")}*/} + {/* alt={""}*/} + {/* fill*/} + {/* quality={100}*/} + {/* sizes="20rem"*/} + {/* className={cn(*/} + {/* "object-cover object-bottom transition opacity-10",*/} + {/* ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && "object-left",*/} + {/* )}*/} + {/* />*/} + + {/* <div*/} + {/* data-media-page-header-blurred-background-blur*/} + {/* className="absolute top-0 w-full h-full backdrop-blur-2xl z-[2]"*/} + {/* ></div>*/} + {/*</div>}*/} + + {/*{entry?.media?.bannerImage && <div*/} + {/* data-torrent-search-drawer-banner-image-container*/} + {/* className="Sea-TorrentSearchDrawer__bannerImage h-36 w-full flex-none object-cover object-center overflow-hidden rounded-t-xl absolute left-0 top-0 z-[-1]"*/} + {/*>*/} + {/* <Image*/} + {/* data-torrent-search-drawer-banner-image*/} + {/* src={getImageUrl(entry?.media?.bannerImage!)}*/} + {/* alt="banner"*/} + {/* fill*/} + {/* quality={80}*/} + {/* priority*/} + {/* sizes="20rem"*/} + {/* className="object-cover object-center opacity-10"*/} + {/* />*/} + {/* <div*/} + {/* data-torrent-search-drawer-banner-image-bottom-gradient*/} + {/* className="Sea-TorrentSearchDrawer__bannerImage-bottomGradient z-[5] absolute bottom-0 w-full h-[70%] bg-gradient-to-t from-[--background] to-transparent"*/} + {/* />*/} + {/*</div>}*/} + + <AppLayoutStack className="relative z-[1]" data-torrent-search-drawer-content> + {selectionType === "download" && <EpisodeList episodes={entry.downloadInfo?.episodesToDownload} />} + {!!selectionType && <TorrentSearchContainer type={selectionType} entry={entry} />} + </AppLayoutStack> + + <TorrentConfirmationContinueButton type={selectionType || "download"} onTorrentValidated={onTorrentValidated} /> + </Modal> + ) + +} + + +function EpisodeList({ episodes }: { episodes: Anime_EntryDownloadEpisode[] | undefined }) { + + if (!episodes || !episodes.length) return null + + const missingEpisodes = episodes.sort((a, b) => a.episodeNumber - b.episodeNumber) + + return ( + <div className="space-y-2" data-torrent-search-drawer-episode-list> + <p><span className="font-semibold">Missing episode{missingEpisodes.length > 1 ? "s" : ""}</span>: {missingEpisodes.slice(0, 5) + .map(n => n.episodeNumber) + .join(", ")}{missingEpisodes.length > 5 + ? `, ..., ${missingEpisodes[missingEpisodes.length - 1].episodeNumber}` + : ""} + </p> + </div> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/_components/torrent-stream-episode-section.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/_components/torrent-stream-episode-section.tsx new file mode 100644 index 0000000..e58d309 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/_components/torrent-stream-episode-section.tsx @@ -0,0 +1,147 @@ +import { Anime_Entry, Anime_Episode, Anime_EpisodeCollection } from "@/api/generated/types" +import { getEpisodeMinutesRemaining, getEpisodePercentageComplete, useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks" +import { EpisodeCard } from "@/app/(main)/_features/anime/_components/episode-card" +import { EpisodeGridItem } from "@/app/(main)/_features/anime/_components/episode-grid-item" +import { MediaEpisodeInfoModal } from "@/app/(main)/_features/media/_components/media-episode-info-modal" +import { PluginEpisodeGridItemMenuItems } from "@/app/(main)/_features/plugin/actions/plugin-actions" +import { EpisodeListPaginatedGrid } from "@/app/(main)/entry/_components/episode-list-grid" +import { usePlayNextVideoOnMount } from "@/app/(main)/entry/_lib/handle-play-on-mount" +import { episodeCardCarouselItemClass } from "@/components/shared/classnames" +import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel" +import { useThemeSettings } from "@/lib/theme/hooks" +import React, { useMemo } from "react" + +type TorrentStreamEpisodeSectionProps = { + entry: Anime_Entry + episodeCollection: Anime_EpisodeCollection | undefined + onEpisodeClick: (episode: Anime_Episode) => void + onPlayNextEpisodeOnMount: (episode: Anime_Episode) => void + bottomSection?: React.ReactNode +} + +export function TorrentStreamEpisodeSection(props: TorrentStreamEpisodeSectionProps) { + const ts = useThemeSettings() + + const { + entry, + episodeCollection, + onEpisodeClick, + onPlayNextEpisodeOnMount, + bottomSection, + ...rest + } = props + + const { data: watchHistory } = useGetContinuityWatchHistory() + + /** + * Organize episodes to watch + */ + const episodesToWatch = useMemo(() => { + if (!episodeCollection?.episodes) return [] + let ret = [...episodeCollection?.episodes] + ret = ((!!entry.listData?.progress && !!entry.media?.episodes && entry.listData?.progress === entry.media?.episodes) + ? ret?.reverse() + : ret?.slice(entry.listData?.progress || 0) + )?.slice(0, 30) || [] + return ret + }, [episodeCollection?.episodes, entry.nextEpisode, entry.listData?.progress]) + + /** + * Play next episode on mount if requested + */ + usePlayNextVideoOnMount({ + onPlay: () => { + onPlayNextEpisodeOnMount(episodesToWatch[0]) + }, + }, !!episodesToWatch[0]) + + if (!entry || !episodeCollection) return null + + return ( + <> + <Carousel + className="w-full max-w-full" + gap="md" + opts={{ + align: "start", + }} + > + <CarouselDotButtons /> + <CarouselContent> + {episodesToWatch.map((episode, idx) => ( + <CarouselItem + key={episode?.localFile?.path || idx} + className={episodeCardCarouselItemClass(ts.smallerEpisodeCarouselSize)} + > + <EpisodeCard + key={episode.localFile?.path || ""} + episode={episode} + image={episode.episodeMetadata?.image || episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge} + topTitle={episode.episodeTitle || episode?.baseAnime?.title?.userPreferred} + title={episode.displayTitle} + // meta={episode.episodeMetadata?.airDate ?? undefined} + isInvalid={episode.isInvalid} + progressTotal={episode.baseAnime?.episodes} + progressNumber={episode.progressNumber} + episodeNumber={episode.episodeNumber} + length={episode.episodeMetadata?.length} + percentageComplete={getEpisodePercentageComplete(watchHistory, entry.mediaId, episode.episodeNumber)} + minutesRemaining={getEpisodeMinutesRemaining(watchHistory, entry.mediaId, episode.episodeNumber)} + hasDiscrepancy={episodeCollection?.episodes?.findIndex(e => e.type === "special") !== -1} + onClick={() => { + onEpisodeClick(episode) + }} + anime={{ + id: entry.mediaId, + image: episode.baseAnime?.coverImage?.medium, + title: episode?.baseAnime?.title?.userPreferred, + }} + /> + </CarouselItem> + ))} + </CarouselContent> + </Carousel> + + <EpisodeListPaginatedGrid + length={episodeCollection?.episodes?.length || 0} + shouldDefaultToPageWithEpisode={entry.listData?.progress ? entry.listData?.progress + 1 : undefined} + renderItem={(index) => { + const episode = episodeCollection?.episodes?.[index] + return (<EpisodeGridItem + key={episode?.episodeNumber + (episode?.displayTitle || "")} + media={episode?.baseAnime as any} + title={episode?.displayTitle || episode?.baseAnime?.title?.userPreferred || ""} + image={episode?.episodeMetadata?.image || episode?.baseAnime?.coverImage?.large} + episodeTitle={episode?.episodeTitle} + onClick={() => { + onEpisodeClick(episode as Anime_Episode) + }} + description={episode?.episodeMetadata?.overview} + isFiller={episode?.episodeMetadata?.isFiller} + length={episode?.episodeMetadata?.length} + isWatched={!!entry.listData?.progress && entry.listData.progress >= (episode?.progressNumber || 0)} + className="flex-none w-full" + episodeNumber={episode?.episodeNumber} + progressNumber={episode?.progressNumber} + action={<> + <MediaEpisodeInfoModal + title={episode?.displayTitle} + image={episode?.episodeMetadata?.image} + episodeTitle={episode?.episodeTitle} + airDate={episode?.episodeMetadata?.airDate} + length={episode?.episodeMetadata?.length} + summary={episode?.episodeMetadata?.overview} + isInvalid={episode?.isInvalid} + /> + + <PluginEpisodeGridItemMenuItems isDropdownMenu={true} type="torrentstream" episode={episode as Anime_Episode} /> + </>} + /> + ) + }} + /> + + {bottomSection} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/_lib/handle-torrent-stream.ts b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/_lib/handle-torrent-stream.ts new file mode 100644 index 0000000..e2ae541 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/_lib/handle-torrent-stream.ts @@ -0,0 +1,219 @@ +import { Anime_Entry, Anime_Episode, HibikeTorrent_AnimeTorrent, Torrentstream_PlaybackType } from "@/api/generated/types" +import { useTorrentstreamStartStream } from "@/api/hooks/torrentstream.hooks" +import { + ElectronPlaybackMethod, + PlaybackTorrentStreaming, + useCurrentDevicePlaybackSettings, + useExternalPlayerLink, +} from "@/app/(main)/_atoms/playback.atoms" +import { __autoplay_nextEpisodeAtom } from "@/app/(main)/_features/progress-tracking/_lib/autoplay" +import { useHandleStartDebridStream } from "@/app/(main)/entry/_containers/debrid-stream/_lib/handle-debrid-stream" +import { + __torrentstream__isLoadedAtom, + __torrentstream__loadingStateAtom, +} from "@/app/(main)/entry/_containers/torrent-stream/torrent-stream-overlay" +import { clientIdAtom } from "@/app/websocket-provider" +import { __isElectronDesktop__ } from "@/types/constants" +import { atom, useAtomValue } from "jotai" +import { useAtom, useSetAtom } from "jotai/react" +import React from "react" +import { toast } from "sonner" + +type ManualTorrentStreamSelectionProps = { + torrent: HibikeTorrent_AnimeTorrent + entry: Anime_Entry + episodeNumber: number + aniDBEpisode: string + chosenFileIndex: number | undefined | null +} +type AutoSelectTorrentStreamProps = { + entry: Anime_Entry + episodeNumber: number + aniDBEpisode: string +} + +export function useHandleStartTorrentStream() { + + const { mutate, isPending } = useTorrentstreamStartStream() + + const setLoadingState = useSetAtom(__torrentstream__loadingStateAtom) + const setIsLoaded = useSetAtom(__torrentstream__isLoadedAtom) + const { torrentStreamingPlayback, electronPlaybackMethod } = useCurrentDevicePlaybackSettings() + const { externalPlayerLink } = useExternalPlayerLink() + const clientId = useAtomValue(clientIdAtom) + + const playbackType = React.useMemo<Torrentstream_PlaybackType>(() => { + if (__isElectronDesktop__ && electronPlaybackMethod === ElectronPlaybackMethod.NativePlayer) { + return "nativeplayer" + } + if (!!externalPlayerLink?.length && torrentStreamingPlayback === PlaybackTorrentStreaming.ExternalPlayerLink) { + return "externalPlayerLink" + } + return "default" + }, [torrentStreamingPlayback, externalPlayerLink, electronPlaybackMethod]) + + const handleManualTorrentStreamSelection = React.useCallback((params: ManualTorrentStreamSelectionProps) => { + mutate({ + mediaId: params.entry.mediaId, + episodeNumber: params.episodeNumber, + torrent: params.torrent, + aniDBEpisode: params.aniDBEpisode, + autoSelect: false, + fileIndex: params.chosenFileIndex ?? undefined, + playbackType: playbackType, + clientId: clientId || "", + }, { + onSuccess: () => { + // setLoadingState(null) + }, + onError: () => { + setLoadingState(null) + setIsLoaded(false) + }, + }) + }, [playbackType, clientId]) + + const handleAutoSelectTorrentStream = React.useCallback((params: AutoSelectTorrentStreamProps) => { + mutate({ + mediaId: params.entry.mediaId, + episodeNumber: params.episodeNumber, + aniDBEpisode: params.aniDBEpisode, + autoSelect: true, + torrent: undefined, + playbackType: playbackType, + clientId: clientId || "", + }, { + onError: () => { + setLoadingState(null) + setIsLoaded(false) + }, + }) + }, [playbackType, clientId]) + + return { + handleManualTorrentStreamSelection, + handleAutoSelectTorrentStream, + isPending, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type AutoplayInfo = { + allEpisodes: Anime_Episode[] + entry: Anime_Entry + episodeNumber: number + aniDBEpisode: string + type: "torrentstream" | "debridstream" +} +const __stream_autoplayAtom = atom<AutoplayInfo | null>(null) +const __stream_autoplaySelectedTorrentAtom = atom<HibikeTorrent_AnimeTorrent | null>(null) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function useTorrentStreamAutoplay() { + const [info, setInfo] = useAtom(__stream_autoplayAtom) + const [nextEpisode, setNextEpisode] = useAtom(__autoplay_nextEpisodeAtom) + + const { handleAutoSelectTorrentStream, handleManualTorrentStreamSelection } = useHandleStartTorrentStream() + const [selectedTorrent, setSelectedTorrent] = useAtom(__stream_autoplaySelectedTorrentAtom) + + function handleAutoplayNextTorrentstreamEpisode() { + if (!info) return + const { entry, episodeNumber, aniDBEpisode, allEpisodes } = info + + if (selectedTorrent?.isBatch) { + // If the user provided a torrent, use it + handleManualTorrentStreamSelection({ + entry, + episodeNumber: episodeNumber, + aniDBEpisode: aniDBEpisode, + torrent: selectedTorrent, + chosenFileIndex: undefined, + }) + } else { + // Otherwise, use the auto-select function + handleAutoSelectTorrentStream({ entry, episodeNumber: episodeNumber, aniDBEpisode }) + } + + const nextEpisode = allEpisodes?.find(e => e.episodeNumber === episodeNumber + 1) + if (nextEpisode && !!nextEpisode.aniDBEpisode) { + setInfo({ + allEpisodes, + entry, + episodeNumber: nextEpisode.episodeNumber, + aniDBEpisode: nextEpisode.aniDBEpisode, + type: "torrentstream", + }) + setNextEpisode(nextEpisode) + } else { + setInfo(null) + } + + toast.info("Requesting next episode") + } + + + return { + hasNextTorrentstreamEpisode: !!info && info.type === "torrentstream", + setTorrentstreamAutoplayInfo: setInfo, + autoplayNextTorrentstreamEpisode: handleAutoplayNextTorrentstreamEpisode, + resetTorrentstreamAutoplayInfo: () => setInfo(null), + setTorrentstreamAutoplaySelectedTorrent: setSelectedTorrent, + } +} + + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function useDebridStreamAutoplay() { + const [info, setInfo] = useAtom(__stream_autoplayAtom) + const [nextEpisode, setNextEpisode] = useAtom(__autoplay_nextEpisodeAtom) + + const { handleAutoSelectStream, handleStreamSelection } = useHandleStartDebridStream() + const [selectedTorrent, setSelectedTorrent] = useAtom(__stream_autoplaySelectedTorrentAtom) + + function handleAutoplayNextTorrentstreamEpisode() { + if (!info) return + const { entry, episodeNumber, aniDBEpisode, allEpisodes } = info + + if (selectedTorrent?.isBatch) { + // If the user provided a torrent, use it + handleStreamSelection({ + entry, + episodeNumber: episodeNumber, + aniDBEpisode: aniDBEpisode, + torrent: selectedTorrent, + chosenFileId: "", + }) + } else { + // Otherwise, use the auto-select function + handleAutoSelectStream({ entry, episodeNumber: episodeNumber, aniDBEpisode }) + } + + const nextEpisode = allEpisodes?.find(e => e.episodeNumber === episodeNumber + 1) + if (nextEpisode && !!nextEpisode.aniDBEpisode) { + setInfo({ + allEpisodes, + entry, + episodeNumber: nextEpisode.episodeNumber, + aniDBEpisode: nextEpisode.aniDBEpisode, + type: "debridstream", + }) + setNextEpisode(nextEpisode) + } else { + setInfo(null) + } + + toast.info("Requesting next episode") + } + + + return { + hasNextDebridstreamEpisode: !!info && info.type === "debridstream", + setDebridstreamAutoplayInfo: setInfo, + autoplayNextDebridstreamEpisode: handleAutoplayNextTorrentstreamEpisode, + resetDebridstreamAutoplayInfo: () => setInfo(null), + setDebridstreamAutoplaySelectedTorrent: setSelectedTorrent, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-button.tsx new file mode 100644 index 0000000..6bf0c09 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-button.tsx @@ -0,0 +1,47 @@ +import { Anime_Entry } from "@/api/generated/types" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { AnimeMetaActionButton } from "@/app/(main)/entry/_components/meta-section" +import { useAnimeEntryPageView } from "@/app/(main)/entry/_containers/anime-entry-page" +import React from "react" +import { AiOutlineArrowLeft } from "react-icons/ai" +import { PiMonitorPlayDuotone } from "react-icons/pi" + +type TorrentStreamButtonProps = { + children?: React.ReactNode + entry: Anime_Entry +} + +export function TorrentStreamButton(props: TorrentStreamButtonProps) { + + const { + children, + entry, + ...rest + } = props + + const serverStatus = useServerStatus() + + const { isLibraryView, isTorrentStreamingView, toggleTorrentStreamingView } = useAnimeEntryPageView() + + if ( + !entry || + entry.media?.status === "NOT_YET_RELEASED" || + !serverStatus?.torrentstreamSettings?.enabled + ) return null + + if (!isLibraryView && !isTorrentStreamingView) return null + + return ( + <> + <AnimeMetaActionButton + data-torrent-stream-button + intent={isTorrentStreamingView ? "gray-subtle" : "white-subtle"} + size="md" + leftIcon={isTorrentStreamingView ? <AiOutlineArrowLeft className="text-xl" /> : <PiMonitorPlayDuotone className="text-2xl" />} + onClick={() => toggleTorrentStreamingView()} + > + {isTorrentStreamingView ? "Close torrent streaming" : "Torrent streaming"} + </AnimeMetaActionButton> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-file-selection-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-file-selection-modal.tsx new file mode 100644 index 0000000..bedf382 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-file-selection-modal.tsx @@ -0,0 +1,174 @@ +import { Anime_Entry, HibikeTorrent_AnimeTorrent } from "@/api/generated/types" +import { useGetTorrentstreamTorrentFilePreviews } from "@/api/hooks/torrentstream.hooks" +import { useTorrentSearchSelectedStreamEpisode } from "@/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-selection" +import { __torrentSearch_selectionAtom } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { useHandleStartTorrentStream } from "@/app/(main)/entry/_containers/torrent-stream/_lib/handle-torrent-stream" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { RadioGroup } from "@/components/ui/radio-group" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Tooltip } from "@/components/ui/tooltip" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" +import { IoPlayCircle } from "react-icons/io5" +import { MdVerified } from "react-icons/md" + +export const __torrentSearch_torrentstreamSelectedTorrentAtom = atom<HibikeTorrent_AnimeTorrent | undefined>(undefined) + +export function TorrentstreamFileSelectionModal({ entry }: { entry: Anime_Entry }) { + const [, setter] = useAtom(__torrentSearch_selectionAtom) + + const [selectedTorrent, setSelectedTorrent] = useAtom(__torrentSearch_torrentstreamSelectedTorrentAtom) + + const [selectedFileIdx, setSelectedFileIdx] = React.useState(-1) + + const { torrentStreamingSelectedEpisode } = useTorrentSearchSelectedStreamEpisode() + + const { data: filePreviews, isLoading } = useGetTorrentstreamTorrentFilePreviews({ + torrent: selectedTorrent, + episodeNumber: torrentStreamingSelectedEpisode?.episodeNumber, + media: entry.media, + }, !!selectedTorrent) + + const { handleManualTorrentStreamSelection } = useHandleStartTorrentStream() + + function onStream() { + if (selectedFileIdx == -1 || !selectedTorrent || !torrentStreamingSelectedEpisode || !torrentStreamingSelectedEpisode.aniDBEpisode) return + + handleManualTorrentStreamSelection({ + torrent: selectedTorrent, + entry, + aniDBEpisode: torrentStreamingSelectedEpisode.aniDBEpisode, + episodeNumber: torrentStreamingSelectedEpisode.episodeNumber, + chosenFileIndex: selectedFileIdx, + }) + + setSelectedTorrent(undefined) + setSelectedFileIdx(-1) + setter(undefined) + } + + const hasLikelyMatch = filePreviews?.some(f => f.isLikely) + const hasOneLikelyMatch = filePreviews?.filter(f => f.isLikely).length === 1 + + const likelyMatchRef = React.useRef<HTMLDivElement>(null) + + const FileSelection = React.useCallback(() => { + return <RadioGroup + value={String(selectedFileIdx)} + onValueChange={v => setSelectedFileIdx(Number(v))} + options={(filePreviews?.toSorted((a, b) => a.path.localeCompare(b.path))?.map((f, i) => { + return { + label: <div + className={cn( + "w-full", + (hasLikelyMatch && !f.isLikely) && "opacity-60", + )} + ref={hasOneLikelyMatch && f.isLikely ? likelyMatchRef : undefined} + > + <p className="mb-1 line-clamp-1"> + {f.displayTitle} + </p> + {f.isLikely && <p className="flex items-center"> + <MdVerified className="text-[--green] mr-1" /> + <span className="text-white">Likely match</span> + </p>} + <Tooltip trigger={<p className="font-normal line-clamp-1 text-sm text-[--muted]">{f.displayPath}</p>}> + {f.path} + </Tooltip> + </div>, + value: String(f.index), + } + }) || [])} + itemContainerClass={cn( + "items-start cursor-pointer transition border-transparent rounded-[--radius] p-2 w-full", + "hover:bg-[--subtle] bg-gray-900 hover:bg-gray-950", + "data-[state=checked]:bg-white dark:data-[state=checked]:bg-gray-950", + "focus:ring-2 ring-brand-100 dark:ring-brand-900 ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition", + "border border-transparent data-[state=checked]:border-[--brand] data-[state=checked]:ring-offset-0", + )} + itemClass={cn( + "border-transparent absolute top-2 right-2 bg-transparent dark:bg-transparent dark:data-[state=unchecked]:bg-transparent", + "data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent dark:data-[state=unchecked]:hover:bg-transparent", + "focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:ring-offset-transparent", + )} + itemIndicatorClass="hidden" + itemLabelClass="font-medium flex flex-col items-center data-[state=checked]:text-[--brand] cursor-pointer" + stackClass="flex flex-col gap-2 space-y-0" + /> + }, [filePreviews, selectedFileIdx]) + + const scrollRef = React.useRef<HTMLDivElement>(null) + + // Scroll to the likely match on mount + React.useEffect(() => { + if (hasOneLikelyMatch && likelyMatchRef.current && scrollRef.current) { + const t = setTimeout(() => { + const element = likelyMatchRef.current + const container = scrollRef.current + + if (element && container) { + const elementRect = element.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + + const scrollTop = elementRect.top - containerRect.top + container.scrollTop - 16 // 16px offset for padding + + container.scrollTo({ + top: scrollTop, + behavior: "smooth", + }) + } + }, 1000) // Increased timeout to ensure DOM is ready + return () => clearTimeout(t) + } + }, [hasOneLikelyMatch, likelyMatchRef.current]) + + + return ( + <Modal + open={!!selectedTorrent} + onOpenChange={open => { + if (!open) { + setSelectedTorrent(undefined) + setSelectedFileIdx(-1) + } + }} + // size="xl" + contentClass="max-w-5xl" + title="Choose a file to stream" + > + <AppLayoutStack className="mt-4"> + {isLoading ? <LoadingSpinner /> : ( + <AppLayoutStack className="pb-0"> + + <div className="flex"> + <div className="flex flex-1"></div> + <Button + intent="primary" + className="" + rightIcon={<IoPlayCircle className="text-xl" />} + disabled={selectedFileIdx === -1 || isLoading} + onClick={onStream} + > + Stream + </Button> + </div> + + <ScrollArea + viewportRef={scrollRef} + className="h-[75dvh] overflow-y-auto p-4 border rounded-[--radius-md]" + > + <FileSelection /> + </ScrollArea> + + </AppLayoutStack> + )} + </AppLayoutStack> + </Modal> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-overlay.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-overlay.tsx new file mode 100644 index 0000000..fb0e4c7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-overlay.tsx @@ -0,0 +1,238 @@ +"use client" +import { Torrentstream_TorrentStatus } from "@/api/generated/types" +import { useTorrentstreamStopStream } from "@/api/hooks/torrentstream.hooks" +import { nativePlayer_stateAtom } from "@/app/(main)/_features/native-player/native-player.atoms" + +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Spinner } from "@/components/ui/loading-spinner" +import { Tooltip } from "@/components/ui/tooltip" +import { WSEvents } from "@/lib/server/ws-events" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import { Inter } from "next/font/google" +import React, { useState } from "react" +import { BiDownArrow, BiGroup, BiStop, BiUpArrow } from "react-icons/bi" + +const inter = Inter({ subsets: ["latin"] }) + +const enum TorrentStreamEvents { + TorrentLoading = "loading", + TorrentLoadingFailed = "loading-failed", + TorrentLoadingStatus = "loading-status", + TorrentLoaded = "loaded", + TorrentStartedPlaying = "started-playing", + TorrentStatus = "status", + TorrentStopped = "stopped", +} + +export const __torrentstream__loadingStateAtom = atom<string | null>(null) +export const __torrentstream__isLoadedAtom = atom<boolean>(false) + +// uncomment for testing +// export const __torrentstream__loadingStateAtom = atom<Torrentstream_TorrentLoadingStatusState | null>("SEARCHING_TORRENTS") +// export const __torrentstream__stateAtom = atom<TorrentStreamState>(TorrentStreamState.Loaded) + +export function TorrentStreamOverlay({ isNativePlayerComponent = false }: { isNativePlayerComponent?: boolean | string }) { + + const [nativePlayerState, setNativePlayerState] = useAtom(nativePlayer_stateAtom) + + const [loadingState, setLoadingState] = useAtom(__torrentstream__loadingStateAtom) + const [isLoaded, setIsLoaded] = useAtom(__torrentstream__isLoadedAtom) + + const [status, setStatus] = useState<Torrentstream_TorrentStatus | null>(null) + const [torrentBeingLoaded, setTorrentBeingLoaded] = useState<string | null>(null) + const [mediaPlayerStartedPlaying, setMediaPlayerStartedPlaying] = useState<boolean>(false) + + const { mutate: stop, isPending } = useTorrentstreamStopStream() + + useWebsocketMessageListener({ + type: WSEvents.TORRENTSTREAM_STATE, + onMessage: ({ state, data }: { state: string, data: any }) => { + switch (state) { + case TorrentStreamEvents.TorrentLoading: + if (!data) { + setTimeout(() => { + setLoadingState("SEARCHING_TORRENTS") + setStatus(null) + setMediaPlayerStartedPlaying(false) + }, 500) + } else { + setLoadingState(data.state) + setTorrentBeingLoaded(data.torrentBeingLoaded) + setMediaPlayerStartedPlaying(false) + } + break + case TorrentStreamEvents.TorrentLoadingFailed: + setLoadingState(null) + setStatus(null) + setMediaPlayerStartedPlaying(false) + break + case TorrentStreamEvents.TorrentLoaded: + setLoadingState("SENDING_STREAM_TO_MEDIA_PLAYER") + setIsLoaded(true) + setMediaPlayerStartedPlaying(false) + break + case TorrentStreamEvents.TorrentStartedPlaying: + setLoadingState(null) + setIsLoaded(true) + setMediaPlayerStartedPlaying(true) + break + case TorrentStreamEvents.TorrentStopped: + setLoadingState(null) + setIsLoaded(false) + setStatus(null) + setMediaPlayerStartedPlaying(false) + break + case TorrentStreamEvents.TorrentStatus: + setIsLoaded(true) + setStatus(data) + break + } + }, + }) + + if (isNativePlayerComponent) { + return ( + <> + {/* Native player is fullscreen */} + {/* It's integrated into the media controller */} + {nativePlayerState.active && status && + <div + className={cn( + "absolute left-0 top-8 w-full flex justify-center z-[100] pointer-events-none", + isNativePlayerComponent === "info" && "relative justify-left w-fit top-0 items-center text-white/90", + isNativePlayerComponent === "control-bar" && "relative justify-left w-fit top-0 h-full flex items-center px-2 truncate", + )} + > + <div + className={cn( + "flex-wrap w-fit h-14 flex gap-3 items-center text-sm pointer-events-auto", + isNativePlayerComponent === "info" && "!font-medium h-auto py-1", + )} + > + + <div className="space-x-1"><BiGroup className="inline-block text-lg" /> + <span>{status.seeders}</span> + </div> + + <div className="space-x-1"> + <BiDownArrow className="inline-block mr-2" /> + {status.downloadSpeed !== "" ? status.downloadSpeed : "0 B/s"} + </div> + + <span + className={cn("text-[--muted]", + { "text-[--muted] animate-pulse": status.progressPercentage < 5 })} + >{status.progressPercentage.toFixed( + 2)}%</span> + + <div className="space-x-1"> + <BiUpArrow className="inline-block mr-2" /> + {status.uploadSpeed !== "" ? status.uploadSpeed : "0 B/s"} + </div> + + {isNativePlayerComponent !== "control-bar" && isNativePlayerComponent !== "info" && <Tooltip + trigger={<IconButton + onClick={() => stop()} + loading={isPending} + intent="alert-basic" + icon={<BiStop />} + />} + > + Stop stream + </Tooltip>} + </div> + </div>} + + {(!!loadingState && loadingState !== "SENDING_STREAM_TO_MEDIA_PLAYER") && + <div className="fixed left-0 top-8 w-full flex justify-center z-[100] pointer-events-none"> + <div className="lg:max-w-[50%] w-fit h-14 px-6 flex gap-2 items-center text-sm lg:text-base pointer-events-auto"> + <Spinner className="w-4 h-4" /> + <div className="truncate max-w-[500px]"> + {loadingState === "LOADING" ? "Loading..." : ""} + {loadingState === "SEARCHING_TORRENTS" ? "Selecting file..." : ""} + {loadingState === "ADDING_TORRENT" ? `Adding torrent "${torrentBeingLoaded}"` : ""} + {loadingState === "CHECKING_TORRENT" ? `Checking torrent "${torrentBeingLoaded}"` : ""} + {loadingState === "SELECTING_FILE" ? `Selecting file...` : ""} + {loadingState === "SENDING_STREAM_TO_MEDIA_PLAYER" ? "Getting metadata..." : ""} + </div> + </div> + </div>} + + </> + ) + } + + if (isLoaded && status) { + return ( + <> + {/*{!mediaPlayerStartedPlaying && !nativePlayerState.active && <div className="w-full bg-gray-950 fixed top-0 left-0 z-[100]">*/} + {/* <ProgressBar size="xs" isIndeterminate />*/} + {/*</div>}*/} + {/* Normal overlay / Native player is not fullscreen */} + {(!nativePlayerState.active) && + <div className="fixed left-0 top-8 w-full flex justify-center z-[100] pointer-events-none"> + <div className="bg-gray-950 flex-wrap rounded-full border lg:max-w-[50%] w-fit h-14 px-6 flex gap-3 items-center text-sm lg:text-base pointer-events-auto"> + + <span + className={cn("text-green-300", + { "text-[--muted] animate-pulse": status.progressPercentage < 70 })} + >{status.progressPercentage.toFixed( + 2)}%</span> + + <div className="space-x-1"><BiGroup className="inline-block text-lg" /> + <span>{status.seeders}</span> + </div> + + <div className="space-x-1"> + <BiDownArrow className="inline-block mr-2" /> + {status.downloadSpeed !== "" ? status.downloadSpeed : "0 B/s"} + </div> + + <div className="space-x-1"> + <BiUpArrow className="inline-block mr-2" /> + {status.uploadSpeed !== "" ? status.uploadSpeed : "0 B/s"} + </div> + + <Tooltip + trigger={<IconButton + onClick={() => stop()} + loading={isPending} + intent="alert-basic" + icon={<BiStop />} + />} + > + Stop stream + </Tooltip> + </div> + </div>} + </> + ) + } + + if (loadingState && !nativePlayerState.active) { + return <> + {/*<div className="w-full bg-gray-950 fixed top-0 left-0 z-[100]">*/} + {/* <ProgressBar size="xs" isIndeterminate />*/} + {/*</div>*/} + <div className="fixed left-0 top-8 w-full flex justify-center z-[100] pointer-events-none"> + <div className="bg-gray-950 rounded-full border lg:max-w-[50%] w-fit h-14 px-6 flex gap-2 items-center text-sm lg:text-base pointer-events-auto"> + <Spinner className="w-4 h-4" /> + <div className="truncate max-w-[500px]"> + {loadingState === "LOADING" ? "Loading..." : ""} + {loadingState === "SEARCHING_TORRENTS" ? "Selecting file..." : ""} + {loadingState === "ADDING_TORRENT" ? `Adding torrent "${torrentBeingLoaded}"` : ""} + {loadingState === "CHECKING_TORRENT" ? `Checking torrent "${torrentBeingLoaded}"` : ""} + {loadingState === "SELECTING_FILE" ? `Selecting file...` : ""} + {loadingState === "SENDING_STREAM_TO_MEDIA_PLAYER" ? "Sending stream to media player" : ""} + </div> + </div> + </div> + </> + } + + return null + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-page.tsx new file mode 100644 index 0000000..8e28e39 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_containers/torrent-stream/torrent-stream-page.tsx @@ -0,0 +1,250 @@ +import { Anime_Entry, Anime_Episode } from "@/api/generated/types" +import { useGetAnimeEpisodeCollection } from "@/api/hooks/anime.hooks" + +import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useTorrentSearchSelectedStreamEpisode } from "@/app/(main)/entry/_containers/torrent-search/_lib/handle-torrent-selection" +import { + __torrentSearch_selectionAtom, + __torrentSearch_selectionEpisodeAtom, +} from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer" +import { TorrentStreamEpisodeSection } from "@/app/(main)/entry/_containers/torrent-stream/_components/torrent-stream-episode-section" +import { useHandleStartTorrentStream, useTorrentStreamAutoplay } from "@/app/(main)/entry/_containers/torrent-stream/_lib/handle-torrent-stream" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Switch } from "@/components/ui/switch" +import { logger } from "@/lib/helpers/debug" +import { useAtom, useSetAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import React from "react" + +type TorrentStreamPageProps = { + children?: React.ReactNode + entry: Anime_Entry + bottomSection?: React.ReactNode +} + +const autoSelectFileAtom = atomWithStorage("sea-torrentstream-auto-select-file", true) + +export function TorrentStreamPage(props: TorrentStreamPageProps) { + + const { + children, + entry, + bottomSection, + ...rest + } = props + + const serverStatus = useServerStatus() + + const [autoSelect, setAutoSelect] = React.useState(serverStatus?.torrentstreamSettings?.autoSelect) + + const [autoSelectFile, setAutoSelectFile] = useAtom(autoSelectFileAtom) + + /** + * Get all episodes to watch + */ + const { data: episodeCollection, isLoading } = useGetAnimeEpisodeCollection(entry.mediaId) + + React.useLayoutEffect(() => { + // Set auto-select to the server status value + if (!episodeCollection?.hasMappingError) { + setAutoSelect(serverStatus?.torrentstreamSettings?.autoSelect) + } else { + // Fall back to manual select if no download info (no Animap data) + setAutoSelect(false) + } + }, [serverStatus?.torrentstreamSettings?.autoSelect, episodeCollection]) + + const setTorrentSearchSelection = useSetAtom(__torrentSearch_selectionAtom) + const setTorrentSearchEpisode = useSetAtom(__torrentSearch_selectionEpisodeAtom) + + // Stores the episode that was clicked + const { setTorrentStreamingSelectedEpisode } = useTorrentSearchSelectedStreamEpisode() + + + /** + * Handle auto-select + */ + const { handleAutoSelectTorrentStream, isPending } = useHandleStartTorrentStream() + const { setTorrentstreamAutoplayInfo } = useTorrentStreamAutoplay() + + // Function to set the torrent stream autoplay info + // It checks if there is a next episode and if it has aniDBEpisode + // If so, it sets the autoplay info + // Otherwise, it resets the autoplay info + function handleSetTorrentstreamAutoplayInfo(episode: Anime_Episode | undefined) { + if (!episode || !episode.aniDBEpisode || !episodeCollection?.episodes) return + const nextEpisode = episodeCollection?.episodes?.find(e => e.episodeNumber === episode.episodeNumber + 1) + logger("TORRENTSTREAM").info("Auto select, Next episode", nextEpisode) + if (nextEpisode && !!nextEpisode.aniDBEpisode) { + setTorrentstreamAutoplayInfo({ + allEpisodes: episodeCollection?.episodes, + entry: entry, + episodeNumber: nextEpisode.episodeNumber, + aniDBEpisode: nextEpisode.aniDBEpisode, + type: "torrentstream", + }) + } else { + setTorrentstreamAutoplayInfo(null) + } + } + + function handleAutoSelect(entry: Anime_Entry, episode: Anime_Episode | undefined) { + if (isPending || !episode || !episode.aniDBEpisode || !episodeCollection?.episodes) return + // Start the torrent stream + handleAutoSelectTorrentStream({ + entry: entry, + episodeNumber: episode.episodeNumber, + aniDBEpisode: episode.aniDBEpisode, + }) + + // Set the torrent stream autoplay info + handleSetTorrentstreamAutoplayInfo(episode) + } + + function handlePlayNextEpisodeOnMount(episode: Anime_Episode) { + if (autoSelect) { + handleAutoSelect(entry, episode) + } else { + handleEpisodeClick(episode) + } + } + + /** + * Handle episode click + * - If auto-select is enabled, send the streaming request + * - If auto-select is disabled, open the torrent drawer + */ + // const setTorrentStreamLoader = useSetTorrentStreamLoader() + const handleEpisodeClick = (episode: Anime_Episode) => { + if (isPending) return + + setTorrentStreamingSelectedEpisode(episode) + + React.startTransition(() => { + // If auto-select is enabled, send the streaming request + if (autoSelect) { + handleAutoSelect(entry, episode) + } else { + + setTorrentSearchEpisode(episode.episodeNumber) + React.startTransition(() => { + // If auto-select file is enabled, open the torrent drawer + if (autoSelectFile) { + setTorrentSearchSelection("torrentstream-select") + + // Set the torrent stream autoplay info + handleSetTorrentstreamAutoplayInfo(episode) + + } else { // Otherwise, open the torrent drawer + setTorrentSearchSelection("torrentstream-select-file") + } + }) + + } + }) + // toast.info("Starting torrent stream...") + } + + const { inject, remove } = useSeaCommandInject() + + // Inject episodes into command palette when they're loaded + React.useEffect(() => { + if (!episodeCollection?.episodes?.length) return + + inject("torrent-stream-episodes", { + items: episodeCollection.episodes.map(episode => ({ + id: `episode-${episode.episodeNumber}`, + value: `${episode.episodeNumber}`, + heading: "Episodes", + render: () => ( + <div className="flex gap-1 items-center w-full"> + <p className="max-w-[70%] truncate">{episode.displayTitle}</p> + {!!episode.episodeTitle && ( + <p className="text-[--muted] flex-1 truncate">- {episode.episodeTitle}</p> + )} + </div> + ), + onSelect: () => handleEpisodeClick(episode), + })), + // Optional custom filter + filter: ({ item, input }) => { + if (!input) return true + return item.value.toLowerCase().includes(input.toLowerCase()) + }, + }) + + return () => remove("torrent-stream-episodes") + }, [episodeCollection?.episodes]) + + if (!entry.media) return null + if (isLoading) return <LoadingSpinner /> + + return ( + <> + <PageWrapper + data-anime-entry-page-torrent-stream-view + key="torrent-streaming-episodes" + className="relative 2xl:order-first pb-10 lg:pt-0" + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + > + <div className="h-10 lg:h-0" /> + <AppLayoutStack data-torrent-stream-page> + <div className="absolute right-0 top-[-3rem]" data-torrent-stream-page-title-container> + <h2 className="text-xl lg:text-3xl flex items-center gap-3">Torrent streaming</h2> + </div> + + <div className="flex flex-col md:flex-row gap-6 pb-6 2xl:py-0" data-torrent-stream-page-content-actions-container> + <Switch + label="Auto-select" + value={autoSelect} + onValueChange={v => { + setAutoSelect(v) + }} + // moreHelp="Automatically select the best torrent and file to stream" + fieldClass="w-fit" + /> + + {!autoSelect && ( + <Switch + label="Auto-select file" + value={autoSelectFile} + onValueChange={v => { + setAutoSelectFile(v) + }} + moreHelp="The episode file will be automatically selected from your chosen batch torrent" + fieldClass="w-fit" + /> + )} + </div> + + {episodeCollection?.hasMappingError && ( + <div data-torrent-stream-page-no-metadata-message-container> + <p className="text-red-200 opacity-50"> + No metadata info available for this anime. You may need to manually select the file to stream. + </p> + </div> + + )} + + <TorrentStreamEpisodeSection + episodeCollection={episodeCollection} + entry={entry} + onEpisodeClick={handleEpisodeClick} + onPlayNextEpisodeOnMount={handlePlayNextEpisodeOnMount} + bottomSection={bottomSection} + /> + </AppLayoutStack> + </PageWrapper> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_lib/handle-episode-section.ts b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_lib/handle-episode-section.ts new file mode 100644 index 0000000..b5f0f17 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_lib/handle-episode-section.ts @@ -0,0 +1,61 @@ +import { Anime_Entry } from "@/api/generated/types" +import { useNakamaPlayVideo } from "@/api/hooks/nakama.hooks" +import { useHandlePlayMedia } from "@/app/(main)/entry/_lib/handle-play-media" +import { usePlayNextVideoOnMount } from "@/app/(main)/entry/_lib/handle-play-on-mount" +import React from "react" + +export function useHandleEpisodeSection(props: { entry: Anime_Entry }) { + const { entry } = props + const media = entry.media + + const { playMediaFile } = useHandlePlayMedia() + + usePlayNextVideoOnMount({ + onPlay: () => { + if (entry.nextEpisode) { + playMediaFile({ path: entry.nextEpisode.localFile?.path ?? "", mediaId: entry.mediaId, episode: entry.nextEpisode }) + } + }, + }, !!entry.nextEpisode) + + const mainEpisodes = React.useMemo(() => { + return entry.episodes?.filter(ep => ep.type === "main") ?? [] + }, [entry.episodes]) + + const specialEpisodes = React.useMemo(() => { + return (entry.episodes?.filter(ep => ep.type === "special") ?? []) + .sort((a, b) => a.displayTitle.localeCompare(b.displayTitle, undefined, { numeric: true })) + }, [entry.episodes]) + + const ncEpisodes = React.useMemo(() => { + return (entry.episodes?.filter(ep => ep.type === "nc" && !!ep.localFile?.path) ?? []).sort((a, + b, + ) => a.localFile!.path!.localeCompare(b.localFile!.path!, undefined, { numeric: true })) + }, [entry.episodes]) + + const hasInvalidEpisodes = React.useMemo(() => { + return entry.episodes?.some(ep => ep.isInvalid) ?? false + }, [entry.episodes]) + + const episodesToWatch = React.useMemo(() => { + + const ret = mainEpisodes.filter(ep => { + if (!entry.nextEpisode) { + return true + } else { + return ep.progressNumber > (entry.listData?.progress ?? 0) + } + }) + return (!!entry.listData?.progress && !entry.nextEpisode) ? ret.reverse() : ret + }, [mainEpisodes, entry.nextEpisode, entry.listData?.progress]) + + return { + media, + playMediaFile, + mainEpisodes, + specialEpisodes, + ncEpisodes, + hasInvalidEpisodes, + episodesToWatch, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_lib/handle-play-media.ts b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_lib/handle-play-media.ts new file mode 100644 index 0000000..5c5cd64 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_lib/handle-play-media.ts @@ -0,0 +1,120 @@ +import { Anime_Episode } from "@/api/generated/types" +import { useDirectstreamPlayLocalFile } from "@/api/hooks/directstream.hooks" +import { useNakamaPlayVideo } from "@/api/hooks/nakama.hooks" +import { usePlaybackPlayVideo, usePlaybackStartManualTracking } from "@/api/hooks/playback_manager.hooks" +import { + ElectronPlaybackMethod, + PlaybackDownloadedMedia, + useCurrentDevicePlaybackSettings, + useExternalPlayerLink, +} from "@/app/(main)/_atoms/playback.atoms" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useTorrentStreamAutoplay } from "@/app/(main)/entry/_containers/torrent-stream/_lib/handle-torrent-stream" +import { useMediastreamActiveOnDevice, useMediastreamCurrentFile } from "@/app/(main)/mediastream/_lib/mediastream.atoms" +import { clientIdAtom } from "@/app/websocket-provider" +import { ExternalPlayerLink } from "@/lib/external-player-link/external-player-link" +import { openTab } from "@/lib/helpers/browser" +import { logger } from "@/lib/helpers/debug" +import { __isElectronDesktop__ } from "@/types/constants" +import { useAtomValue } from "jotai" +import { useRouter } from "next/navigation" +import React from "react" +import { toast } from "sonner" + +export function useHandlePlayMedia() { + const router = useRouter() + const serverStatus = useServerStatus() + const clientId = useAtomValue(clientIdAtom) + + const { activeOnDevice: mediastreamActiveOnDevice } = useMediastreamActiveOnDevice() + const { setFilePath: setMediastreamFilePath } = useMediastreamCurrentFile() + + const { mutate: startManualTracking, isPending: isStarting } = usePlaybackStartManualTracking() + + const { downloadedMediaPlayback, electronPlaybackMethod } = useCurrentDevicePlaybackSettings() + const { externalPlayerLink } = useExternalPlayerLink() + + // Play using desktop external player + const { mutate: playVideo } = usePlaybackPlayVideo() + const { mutate: playNakamaVideo } = useNakamaPlayVideo() + + const { mutate: directstreamPlayLocalFile } = useDirectstreamPlayLocalFile() + + const { setTorrentstreamAutoplayInfo } = useTorrentStreamAutoplay() + + function playMediaFile({ path, mediaId, episode }: { path: string, mediaId: number, episode: Anime_Episode }) { + const anidbEpisode = episode.localFile?.metadata?.aniDBEpisode ?? "" + + setTorrentstreamAutoplayInfo(null) + + if (episode._isNakamaEpisode) { + // If external player link is set, open the media file in the external player + if (downloadedMediaPlayback === PlaybackDownloadedMedia.ExternalPlayerLink) { + const link = new ExternalPlayerLink(externalPlayerLink) + link.setEpisodeNumber(episode.progressNumber) + link.setMediaTitle(episode.baseAnime?.title?.userPreferred) + link.to({ + endpoint: "/api/v1/nakama/stream?type=file&path=" + Buffer.from(path).toString("base64"), + }).then() + openTab(link.getFullUrl()) + + if (episode?.progressNumber && episode.type === "main") { + logger("PLAY MEDIA").error("Starting manual tracking for nakama file") + // Start manual tracking + React.startTransition(() => { + startManualTracking({ + mediaId: mediaId, + episodeNumber: episode?.progressNumber, + clientId: clientId || "", + }) + }) + } else { + logger("PLAY MEDIA").warning("No manual tracking, progress number is not set for nakama file") + } + return + } + return playNakamaVideo({ path, mediaId, anidbEpisode }) + } + + logger("PLAY MEDIA").info("Playing media file", path) + + // + // Electron native player + // + if (__isElectronDesktop__ && electronPlaybackMethod === ElectronPlaybackMethod.NativePlayer) { + directstreamPlayLocalFile({ path, clientId: clientId ?? "" }) + return + } + + // If external player link is set, open the media file in the external player + if (downloadedMediaPlayback === PlaybackDownloadedMedia.ExternalPlayerLink) { + if (!externalPlayerLink) { + toast.error("External player link is not set.") + return + } + + logger("PLAY MEDIA").info("Opening media file in external player", externalPlayerLink, path) + + setMediastreamFilePath(path) + React.startTransition(() => { + router.push(`/medialinks?id=${mediaId}`) + }) + return + } + + // Handle media streaming + if (serverStatus?.mediastreamSettings?.transcodeEnabled && mediastreamActiveOnDevice) { + setMediastreamFilePath(path) + React.startTransition(() => { + router.push(`/mediastream?id=${mediaId}`) + }) + return + } + + return playVideo({ path }) + } + + return { + playMediaFile, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/_lib/handle-play-on-mount.ts b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_lib/handle-play-on-mount.ts new file mode 100644 index 0000000..7f00601 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/_lib/handle-play-on-mount.ts @@ -0,0 +1,31 @@ +import { usePlayNext } from "@/app/(main)/_atoms/playback.atoms" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import React from "react" + +export function usePlayNextVideoOnMount({ onPlay }: { onPlay: () => void }, enabled: boolean = true) { + + const serverStatus = useServerStatus() + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const { playNext, resetPlayNext } = usePlayNext() + + React.useEffect(() => { + if (!enabled) return + + // Automatically play the next episode if param is present in URL + const t = setTimeout(() => { + if (playNext) { + resetPlayNext() + onPlay() + } + }, 500) + + return () => clearTimeout(t) + }, [pathname, playNext, serverStatus, onPlay, enabled]) + + return null + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/anime-entry-drawer.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/anime-entry-drawer.tsx new file mode 100644 index 0000000..d0d8046 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/anime-entry-drawer.tsx @@ -0,0 +1,27 @@ +import { atom, useSetAtom } from "jotai" +import React from "react" + +export const __animeDrawer_entryIdAtom = atom<number | null>(null) + +type AnimeEntryDrawerProps = { + children?: React.ReactNode +} + +export function AnimeEntryDrawer(props: AnimeEntryDrawerProps) { + + const { + children, + ...rest + } = props + + return ( + <> + + </> + ) +} + +export function useSetAnimeDrawerEntryId(entryId: number) { + const setEntryId = useSetAtom(__animeDrawer_entryIdAtom) + setEntryId(entryId) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/layout.tsx new file mode 100644 index 0000000..3efab03 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/layout.tsx @@ -0,0 +1,15 @@ +"use client" +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/entry/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/entry/page.tsx new file mode 100644 index 0000000..526a286 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/entry/page.tsx @@ -0,0 +1,12 @@ +"use client" + +import { AnimeEntryPage } from "@/app/(main)/entry/_containers/anime-entry-page" +import React from "react" + +export const dynamic = "force-static" + +export default function Page() { + return ( + <AnimeEntryPage /> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/error.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/error.tsx new file mode 100644 index 0000000..283f386 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/error.tsx @@ -0,0 +1,36 @@ +"use client" + +import { LuffyError } from "@/components/shared/luffy-error" +import { Button } from "@/components/ui/button" +import React from "react" + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + React.useEffect(() => { + console.error(error) + }, [error]) + + return ( + <div className="flex justify-center"> + <LuffyError + title="Client side error" + > + <p className="max-w-xl text-sm text-[--muted] mb-4"> + {error.message || "An unexpected error occurred."} + </p> + <Button + onClick={ + () => reset() + } + > + Try again + </Button> + </LuffyError> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_components/extension-details.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_components/extension-details.tsx new file mode 100644 index 0000000..4814ac4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_components/extension-details.tsx @@ -0,0 +1,91 @@ +import { Extension_Extension } from "@/api/generated/types" +import { LANGUAGES_LIST } from "@/app/(main)/manga/_lib/language-map" +import { SeaLink } from "@/components/shared/sea-link" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import capitalize from "lodash/capitalize" +import Image from "next/image" +import React from "react" +import { FaLink } from "react-icons/fa" + +type ExtensionDetailsProps = { + extension: Extension_Extension +} + +export function ExtensionDetails(props: ExtensionDetailsProps) { + + const { + extension, + ...rest + } = props + + const isBuiltin = extension.manifestURI === "builtin" + + return ( + <> + <div className="relative rounded-[--radius-md] size-12 bg-gray-900 overflow-hidden"> + {!!extension.icon ? ( + <Image + src={extension.icon} + alt="extension icon" + crossOrigin="anonymous" + fill + quality={100} + priority + className="object-cover" + /> + ) : <div className="w-full h-full flex items-center justify-center"> + <p className="text-2xl font-bold"> + {(extension.name[0]).toUpperCase()} + </p> + </div>} + </div> + + <div className="space-y-2"> + <div className="flex items-center flex-wrap"> + <p className="text-md font-semibold flex gap-2 flex-wrap"> + {extension.name} {!!extension.version && <Badge className="rounded-[--radius-md] text-md"> + {extension.version} + </Badge>}</p> + + <div className="flex flex-1"></div> + + {!!extension.website && <SeaLink + href={extension.website} + target="_blank" + className="inline-block" + > + <Button + size="sm" + intent="gray-outline" + leftIcon={<FaLink />} + > + Website + </Button> + </SeaLink>} + </div> + + <p className="text-[--muted] text-sm text-pretty"> + {extension.description} + </p> + + <p className="text-md line-clamp-1"> + <span className="text-[--muted]">ID:</span> <span className="">{extension.id}</span> + </p> + <p className="text-md line-clamp-1"> + <span className="text-[--muted]">Author:</span> <span className="">{extension.author}</span> + </p> + <p className="text-md line-clamp-1"> + <span className="text-[--muted]">Language: </span> + <span className="">{LANGUAGES_LIST[extension.lang?.toLowerCase()]?.nativeName || extension.lang}</span> + </p> + <p className="text-md line-clamp-1"> + <span className="text-[--muted]">Programming language:</span> <span className="">{capitalize(extension.language)}</span> + </p> + {(!!extension.manifestURI && !isBuiltin) && <p className="text-md w-full"> + <span className="text-[--muted]">Manifest URL:</span> <span className="">{extension.manifestURI}</span> + </p>} + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/add-extension-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/add-extension-modal.tsx new file mode 100644 index 0000000..e8fd657 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/add-extension-modal.tsx @@ -0,0 +1,111 @@ +import { Extension_Extension } from "@/api/generated/types" +import { useFetchExternalExtensionData, useInstallExternalExtension } from "@/api/hooks/extensions.hooks" +import { ExtensionDetails } from "@/app/(main)/extensions/_components/extension-details" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { Separator } from "@/components/ui/separator" +import { TextInput } from "@/components/ui/text-input" +import React from "react" +import { FiSearch } from "react-icons/fi" +import { toast } from "sonner" + +type AddExtensionModalProps = { + extensions: Extension_Extension[] | undefined + children?: React.ReactElement +} + +export function AddExtensionModal(props: AddExtensionModalProps) { + + const { + extensions, + children, + ...rest + } = props + + const [open, setOpen] = React.useState(false) + const [manifestURL, setManifestURL] = React.useState<string>("") + + const { mutate: fetchExtensionData, data: extensionData, isPending, reset } = useFetchExternalExtensionData(null) + + const { + mutate: installExtension, + data: installResponse, + isPending: isInstalling, + } = useInstallExternalExtension() + + React.useEffect(() => { + if (installResponse) { + toast.success(installResponse.message) + setOpen(false) + reset() + } + }, [installResponse]) + + function handleFetchExtensionData() { + if (!manifestURL) { + toast.warning("Please provide a valid URL.") + return + } + + fetchExtensionData({ + manifestUri: manifestURL, + }) + } + + return ( + <> + <Modal + open={open} + onOpenChange={setOpen} + trigger={children} + contentClass="max-w-3xl" + > + <div className="flex gap-4 flex-col lg:flex-row"> + <div className="lg:w-1/3"> + <h3 className="text-2xl font-bold">Install from URL</h3> + <p className="text-[--muted]">Install an extension by entering URL of the manifest file.</p> + </div> + <div className="lg:w-2/3 gap-3 flex flex-col"> + <TextInput + placeholder="https://example.com/extension.json" + value={manifestURL} + onValueChange={setManifestURL} + label="URL" + /> + <Button + leftIcon={<FiSearch />} + intent="gray-outline" + onClick={handleFetchExtensionData} + loading={isPending} + >Check</Button> + </div> + </div> + + {!!extensionData && ( + <> + <Separator /> + + <ExtensionDetails extension={extensionData} /> + + {extensions?.find(n => n.id === extensionData.id) ? ( + <p className="text-center"> + This extension is already installed. + </p> + ) : ( + <Button + intent="white" + loading={isInstalling} + onClick={() => { + installExtension({ + manifestUri: extensionData?.manifestURI, + }) + }} + >Install</Button> + )} + </> + )} + + </Modal> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-card.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-card.tsx new file mode 100644 index 0000000..1094cd0 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-card.tsx @@ -0,0 +1,329 @@ +import { Extension_Extension, Extension_InvalidExtension, ExtensionRepo_UpdateData } from "@/api/generated/types" +import { + useFetchExternalExtensionData, + useInstallExternalExtension, + useReloadExternalExtension, + useUninstallExternalExtension, +} from "@/api/hooks/extensions.hooks" +import { ExtensionDetails } from "@/app/(main)/extensions/_components/extension-details" +import { ExtensionCodeModal } from "@/app/(main)/extensions/_containers/extension-code" +import { ExtensionUserConfigModal } from "@/app/(main)/extensions/_containers/extension-user-config" +import { LANGUAGES_LIST } from "@/app/(main)/manga/_lib/language-map" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Badge } from "@/components/ui/badge" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingOverlay } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { Popover } from "@/components/ui/popover" +import { Tooltip } from "@/components/ui/tooltip" +import Image from "next/image" +import React from "react" +import { FaCode } from "react-icons/fa" +import { GrUpdate } from "react-icons/gr" +import { HiOutlineAdjustments } from "react-icons/hi" +import { LuEllipsisVertical, LuRefreshCcw } from "react-icons/lu" +import { RiDeleteBinLine } from "react-icons/ri" +import { TbCloudDownload } from "react-icons/tb" +import { toast } from "sonner" + +type ExtensionCardProps = { + extension: Extension_Extension + updateData?: ExtensionRepo_UpdateData | undefined + isInstalled: boolean + userConfigError?: Extension_InvalidExtension | undefined + allowReload?: boolean +} + +export function ExtensionCard(props: ExtensionCardProps) { + + const { + extension, + updateData, + isInstalled, + userConfigError, + allowReload, + ...rest + } = props + + const isBuiltin = extension.manifestURI === "builtin" + + const { mutate: reloadExternalExtension, isPending: isReloadingExtension } = useReloadExternalExtension() + + return ( + <div + className={cn( + "group/extension-card border border-[rgb(255_255_255_/_5%)] relative overflow-hidden", + "bg-gray-950 rounded-md p-3", + !!updateData && "border-[--green]", + )} + > + <div + className={cn( + "absolute z-[0] right-0 top-0 h-full w-full max-w-[150px] bg-gradient-to-l to-gray-950", + !isBuiltin && "max-w-[50%] from-indigo-950/20", + )} + ></div> + + <div className="absolute top-3 right-3 z-[2]"> + <div className=" flex flex-row gap-1 z-[2] flex-wrap justify-end"> + + {!!extension.userConfig && ( + <> + <ExtensionUserConfigModal extension={extension} userConfigError={userConfigError}> + <div> + <Tooltip + side="top" + trigger={<IconButton + size="sm" + intent={userConfigError ? "alert" : "gray-basic"} + icon={<HiOutlineAdjustments />} + className={cn( + userConfigError && "animate-bounce", + )} + />} + >Preferences</Tooltip> + </div> + </ExtensionUserConfigModal> + </> + )} + + <ExtensionSettings extension={extension} isInstalled={isInstalled} updateData={updateData}> + <div> + <Tooltip + trigger={<IconButton + size="sm" + intent="gray-basic" + icon={<LuEllipsisVertical />} + />} + >Info</Tooltip> + </div> + </ExtensionSettings> + </div> + <div className="flex flex-row gap-1 z-[2] flex-wrap"> + {!isBuiltin && ( + <ExtensionCodeModal extension={extension}> + <div> + <Tooltip + trigger={<IconButton + size="sm" + intent="gray-basic" + icon={<FaCode />} + />} + side="left" + >Code</Tooltip> + </div> + </ExtensionCodeModal> + )} + + {(allowReload && !isBuiltin) && ( + <div> + <Tooltip + side="right" trigger={<IconButton + size="sm" + intent="gray-basic" + icon={<LuRefreshCcw />} + onClick={() => { + if (!extension.id) return toast.error("Extension has no ID") + reloadExternalExtension({ id: extension.id }) + }} + disabled={isReloadingExtension} + />} + >Reload</Tooltip> + </div> + )} + </div> + </div> + + <div className="z-[1] relative flex flex-col h-full"> + <div className="flex gap-3 pr-16"> + <div className="relative rounded-md size-12 flex-none bg-gray-900 overflow-hidden"> + {!!extension.icon ? ( + <Image + src={extension.icon} + alt="extension icon" + crossOrigin="anonymous" + fill + quality={100} + priority + className="object-cover" + /> + ) : <div className="w-full h-full flex items-center justify-center"> + <p className="text-2xl font-bold"> + {(extension.name[0]).toUpperCase()} + </p> + </div>} + </div> + + <div> + <p className="font-semibold line-clamp-1"> + {extension.name} + </p> + <Popover + className="text-sm cursor-pointer" trigger={<p className="opacity-30 mt-1 text-xs line-clamp-1 tracking-wide"> + {extension.description} + </p>} + > + {extension.description} + </Popover> + </div> + </div> + + <div className="flex gap-2 flex-wrap pt-4 flex-1 items-end"> + {isBuiltin && <Badge className="rounded-md tracking-wide border-transparent px-0 italic opacity-50" intent="unstyled"> + Built-in + </Badge>} + {!!extension.version && <Badge className="rounded-md tracking-wide" intent={!!updateData ? "success" : undefined}> + {extension.version}{!!updateData ? " → " + updateData.version : ""} + </Badge>} + {!isBuiltin && <Badge className="rounded-md" intent="unstyled"> + {extension.author} + </Badge>} + <Badge className="rounded-md" intent="unstyled"> + {/*{extension.lang.toUpperCase()}*/} + {LANGUAGES_LIST[extension.lang?.toLowerCase()]?.nativeName || extension.lang?.toUpperCase() || "Unknown"} + </Badge> + {/*<Badge className="rounded-md" intent="unstyled">*/} + {/* {capitalize(extension.language)}*/} + {/*</Badge>*/} + </div> + + </div> + </div> + ) +} + +type ExtensionSettingsProps = { + extension: Extension_Extension + children?: React.ReactElement + isInstalled: boolean + updateData?: ExtensionRepo_UpdateData | undefined +} + +export function ExtensionSettings(props: ExtensionSettingsProps) { + + const { + extension, + children, + isInstalled, + updateData, + ...rest + } = props + + const isBuiltin = extension.manifestURI === "builtin" + + const { mutate: uninstall, isPending: isUninstalling } = useUninstallExternalExtension() + + const { mutate: fetchExtensionData, data: fetchedExtensionData, isPending: isFetchingData, reset } = useFetchExternalExtensionData(extension.id) + + const confirmUninstall = useConfirmationDialog({ + title: `Remove ${extension.name}`, + description: "This action cannot be undone.", + onConfirm: () => { + uninstall({ + id: extension.id, + }) + }, + }) + + const { + mutate: installExtension, + data: installResponse, + isPending: isInstalling, + } = useInstallExternalExtension() + + React.useEffect(() => { + if (installResponse) { + toast.success(installResponse.message) + reset() + } + }, [installResponse]) + + const checkingForUpdatesRef = React.useRef(false) + + function handleCheckUpdate() { + fetchExtensionData({ + manifestUri: extension.manifestURI, + }) + checkingForUpdatesRef.current = true + } + + React.useEffect(() => { + + if (fetchedExtensionData && checkingForUpdatesRef.current) { + checkingForUpdatesRef.current = false + + if (fetchedExtensionData.version !== extension.version) { + toast.success("Update available") + } else { + toast.info("The extension is up to date") + } + } + }, [fetchedExtensionData]) + + return ( + <Modal + trigger={children} + contentClass="max-w-3xl" + > + {isUninstalling && <LoadingOverlay />} + + <ExtensionDetails extension={extension} /> + + {!isBuiltin && ( + <> + + {isInstalled && ( + <div className="flex gap-2"> + <> + {!!extension.manifestURI && <Button + intent="gray-outline" + leftIcon={<GrUpdate className="text-lg" />} + disabled={!extension.manifestURI} + onClick={handleCheckUpdate} + loading={isFetchingData} + > + Check for updates + </Button>} + + <Button + intent="alert-subtle" + leftIcon={<RiDeleteBinLine className="text-xl" />} + onClick={confirmUninstall.open} + > + Uninstall + </Button> + </> + </div> + )} + + + {((!!fetchedExtensionData && fetchedExtensionData?.version !== extension.version) || !!updateData) && ( + <AppLayoutStack> + <p className=""> + Update available: <span className="font-bold text-white">{fetchedExtensionData?.version || updateData?.version}</span> + </p> + <Button + intent="white" + leftIcon={<TbCloudDownload className="text-lg" />} + loading={isInstalling} + onClick={() => { + installExtension({ + manifestUri: fetchedExtensionData?.manifestURI || updateData?.manifestURI || "", + }) + }} + > + Install update + </Button> + </AppLayoutStack> + )} + + <ConfirmationDialog {...confirmUninstall} /> + + + </> + )} + </Modal> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-code.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-code.tsx new file mode 100644 index 0000000..527a7d9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-code.tsx @@ -0,0 +1,120 @@ +import { Extension_Extension } from "@/api/generated/types" +import { useGetExtensionPayload, useUpdateExtensionCode } from "@/api/hooks/extensions.hooks" +import { Button } from "@/components/ui/button" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { javascript } from "@codemirror/lang-javascript" +import { StreamLanguage } from "@codemirror/language" +import { go } from "@codemirror/legacy-modes/mode/go" +import { vscodeDark } from "@uiw/codemirror-theme-vscode" +import CodeMirror from "@uiw/react-codemirror" +import React from "react" + + +type ExtensionCodeModalProps = { + children?: React.ReactElement + extension: Extension_Extension +} + +export function ExtensionCodeModal(props: ExtensionCodeModalProps) { + + + return ( + <Modal + contentClass="max-w-5xl" + trigger={props.children} + title="Code" + onInteractOutside={e => e.preventDefault()} + // size="xl" + // contentClass="space-y-4" + > + <Content {...props} /> + </Modal> + ) +} + +function Content(props: ExtensionCodeModalProps) { + const { + extension, + } = props + + const [code, setCode] = React.useState("") + + const { data: payload, isLoading } = useGetExtensionPayload(extension.id) + + React.useEffect(() => { + if (payload) { + setCode(payload) + } + }, [payload]) + + const { mutate: updateCode, isPending } = useUpdateExtensionCode() + + React.useLayoutEffect(() => { + setCode(extension.payload) + }, [extension.payload]) + + function handleSave() { + if (isPending) { + return + } + if (code === extension.payload) { + return + } + if (code.length === 0) { + return + } + updateCode({ + id: extension.id, + payload: code, + }) + } + + if (isLoading) { + return <LoadingSpinner /> + } + + return ( + <> + <div> + <p> + {extension.name} + </p> + <div className="text-sm text-[--muted]"> + You can edit the code of the extension here. + </div> + </div> + <div className="flex"> + <Button intent="white" loading={isPending} onClick={handleSave}> + Save + </Button> + <div className="flex flex-1"></div> + </div> + <ExtensionCodeEditor + code={code} + setCode={setCode} + language={extension.language} + /> + </> + ) +} + + +function ExtensionCodeEditor({ + code, + setCode, + language, +}: { code: string, language: string, setCode: any }) { + + return ( + <div className="overflow-hidden rounded-[--radius-md]"> + <CodeMirror + value={code} + height="75vh" + theme={vscodeDark} + extensions={[javascript({ typescript: language === "typescript" }), StreamLanguage.define(go)]} + onChange={setCode} + /> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-list.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-list.tsx new file mode 100644 index 0000000..393feed --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-list.tsx @@ -0,0 +1,268 @@ +import { Extension_Extension } from "@/api/generated/types" +import { useGetAllExtensions, useInstallExternalExtension } from "@/api/hooks/extensions.hooks" +import { AddExtensionModal } from "@/app/(main)/extensions/_containers/add-extension-modal" +import { ExtensionCard } from "@/app/(main)/extensions/_containers/extension-card" +import { InvalidExtensionCard, UnauthorizedExtensionPluginCard } from "@/app/(main)/extensions/_containers/invalid-extension-card" +import { LuffyError } from "@/components/shared/luffy-error" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button, IconButton } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { atom, useSetAtom } from "jotai" +import { orderBy } from "lodash" +import { useRouter } from "next/navigation" +import React from "react" +import { BiDotsVerticalRounded } from "react-icons/bi" +import { CgMediaPodcast } from "react-icons/cg" +import { GrInstallOption } from "react-icons/gr" +import { LuBlocks, LuDownload } from "react-icons/lu" +import { PiBookFill } from "react-icons/pi" +import { RiFolderDownloadFill } from "react-icons/ri" +import { TbReload } from "react-icons/tb" +import { toast } from "sonner" + +type ExtensionListProps = { + children?: React.ReactNode +} + +export const __extensions_currentPageAtom = atom<"installed" | "marketplace">("installed") + +export function ExtensionList(props: ExtensionListProps) { + + const { + children, + ...rest + } = props + + const router = useRouter() + + const [checkForUpdates, setCheckForUpdates] = React.useState(false) + + const { data: allExtensions, isPending: isLoading, refetch } = useGetAllExtensions(checkForUpdates) + + const setPage = useSetAtom(__extensions_currentPageAtom) + + const { + mutate: installExtension, + data: installResponse, + isPending: isInstalling, + } = useInstallExternalExtension() + + function orderExtensions(extensions: Extension_Extension[] | undefined) { + return extensions ? + orderBy(extensions, ["name", "manifestUri"]) + : [] + } + + function isExtensionInstalled(extensionID: string) { + return !!allExtensions?.extensions?.find(n => n.id === extensionID) || + !!allExtensions?.invalidExtensions?.find(n => n.id === extensionID) + } + + const pluginExtensions = orderExtensions(allExtensions?.extensions ?? []).filter(n => n.type === "plugin") + const animeTorrentExtensions = orderExtensions(allExtensions?.extensions ?? []).filter(n => n.type === "anime-torrent-provider") + const mangaExtensions = orderExtensions(allExtensions?.extensions ?? []).filter(n => n.type === "manga-provider") + const onlinestreamExtensions = orderExtensions(allExtensions?.extensions ?? []).filter(n => n.type === "onlinestream-provider") + + const nonvalidExtensions = (allExtensions?.invalidExtensions ?? []).filter(n => n.code !== "plugin_permissions_not_granted") + .sort((a, b) => a.id.localeCompare(b.id)) + const pluginPermissionsNotGrantedExtensions = (allExtensions?.invalidExtensions ?? []).filter(n => n.code === "plugin_permissions_not_granted") + .sort((a, b) => a.id.localeCompare(b.id)) + + if (isLoading) return <LoadingSpinner /> + + if (!allExtensions) return <LuffyError> + Could not get extensions. + </LuffyError> + + return ( + <AppLayoutStack className="gap-6"> + <div className="flex items-center gap-2 flex-wrap"> + <div> + <h2> + Extensions + </h2> + <p className="text-[--muted] text-sm"> + Manage your plugins and content providers. + </p> + </div> + + <div className="flex flex-1"></div> + + <div className="flex items-center gap-2 flex-wrap"> + {!!allExtensions?.hasUpdate?.length && ( + <Button + className="rounded-full animate-pulse" + intent="success" + leftIcon={<LuDownload className="text-lg" />} + loading={isInstalling} + onClick={() => { + toast.info("Installing updates...") + allExtensions?.hasUpdate?.forEach(update => { + installExtension({ + manifestUri: update.manifestURI, + }) + }) + }} + > + Update all + </Button> + )} + <Button + className="rounded-full" + intent="gray-outline" + leftIcon={<TbReload className="text-lg" />} + disabled={isLoading} + onClick={() => { + setCheckForUpdates(true) + // React.startTransition(() => { + // refetch() + // }) + }} + > + Check for updates + </Button> + <AddExtensionModal extensions={allExtensions.extensions}> + <Button + className="rounded-full" + intent="primary-subtle" + leftIcon={<GrInstallOption className="text-lg" />} + > + Add an extension/plugin + </Button> + </AddExtensionModal> + + <DropdownMenu trigger={<IconButton icon={<BiDotsVerticalRounded />} intent="gray-basic" />}> + + <DropdownMenuItem + onClick={() => { + router.push("/extensions/playground") + }} + > + <span>Playground</span> + </DropdownMenuItem> + + <DropdownMenuItem + onClick={() => { + setPage("marketplace") + }} + > + <span>Marketplace</span> + </DropdownMenuItem> + </DropdownMenu> + </div> + </div> + + + {!!pluginPermissionsNotGrantedExtensions?.length && ( + <Card className="p-4 space-y-6"> + <h3 className="flex gap-3 items-center">Permissions required</h3> + + <div className="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-4"> + {pluginPermissionsNotGrantedExtensions.map(extension => ( + <UnauthorizedExtensionPluginCard + key={extension.id} + extension={extension} + isInstalled={isExtensionInstalled(extension.id)} + /> + ))} + </div> + </Card> + )} + {!!nonvalidExtensions?.length && ( + <Card className="p-4 space-y-6 border-red-800"> + + <h3 className="flex gap-3 items-center">Invalid extensions</h3> + + <div className="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-4"> + {nonvalidExtensions.map(extension => ( + <InvalidExtensionCard + key={extension.id} + extension={extension} + isInstalled={isExtensionInstalled(extension.id)} + /> + ))} + </div> + </Card> + )} + + {/*<Card className="p-4 space-y-6">*/} + + {!!pluginExtensions?.length && ( + <Card className="p-4 space-y-6"> + <h3 className="flex gap-3 items-center"><LuBlocks /> Plugins</h3> + <div className="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-4"> + {pluginExtensions.map(extension => ( + <ExtensionCard + key={extension.id} + extension={extension} + updateData={allExtensions?.hasUpdate?.find(n => n.extensionID === extension.id)} + isInstalled={isExtensionInstalled(extension.id)} + userConfigError={allExtensions?.invalidUserConfigExtensions?.find(n => n.id == extension.id)} + allowReload={true} + /> + ))} + </div> + </Card> + )} + + {!!animeTorrentExtensions?.length && ( + <Card className="p-4 space-y-6"> + <h3 className="flex gap-3 items-center"><RiFolderDownloadFill />Anime torrents</h3> + <div className="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-4"> + {animeTorrentExtensions.map(extension => ( + <ExtensionCard + key={extension.id} + extension={extension} + updateData={allExtensions?.hasUpdate?.find(n => n.extensionID === extension.id)} + isInstalled={isExtensionInstalled(extension.id)} + userConfigError={allExtensions?.invalidUserConfigExtensions?.find(n => n.id == extension.id)} + allowReload + /> + ))} + </div> + </Card> + )} + + + {!!mangaExtensions?.length && ( + <Card className="p-4 space-y-6"> + <h3 className="flex gap-3 items-center"><PiBookFill />Manga</h3> + <div className="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-4"> + {mangaExtensions.map(extension => ( + <ExtensionCard + key={extension.id} + extension={extension} + updateData={allExtensions?.hasUpdate?.find(n => n.extensionID === extension.id)} + isInstalled={isExtensionInstalled(extension.id)} + userConfigError={allExtensions?.invalidUserConfigExtensions?.find(n => n.id == extension.id)} + allowReload + /> + ))} + </div> + </Card> + )} + + {!!onlinestreamExtensions?.length && ( + <Card className="p-4 space-y-6"> + <h3 className="flex gap-3 items-center"><CgMediaPodcast /> Online streaming</h3> + <div className="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-4"> + {onlinestreamExtensions.map(extension => ( + <ExtensionCard + key={extension.id} + extension={extension} + updateData={allExtensions?.hasUpdate?.find(n => n.extensionID === extension.id)} + isInstalled={isExtensionInstalled(extension.id)} + userConfigError={allExtensions?.invalidUserConfigExtensions?.find(n => n.id == extension.id)} + allowReload + /> + ))} + </div> + </Card> + )} + + {/*</Card>*/} + </AppLayoutStack> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-user-config.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-user-config.tsx new file mode 100644 index 0000000..7a97996 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/extension-user-config.tsx @@ -0,0 +1,164 @@ +import { Extension_Extension, Extension_InvalidExtension } from "@/api/generated/types" +import { useGetExtensionUserConfig, useSaveExtensionUserConfig } from "@/api/hooks/extensions.hooks" +import { LuffyError } from "@/components/shared/luffy-error" +import { Alert } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { Select } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { TextInput } from "@/components/ui/text-input" +import { atomWithImmer } from "jotai-immer" +import { useAtom } from "jotai/react" +import React from "react" + +type ExtensionUserConfigModalProps = { + children?: React.ReactElement + extension: Extension_Extension + userConfigError?: Extension_InvalidExtension | undefined +} + +export function ExtensionUserConfigModal(props: ExtensionUserConfigModalProps) { + + const { + children, + extension, + userConfigError, + ...rest + } = props + + return ( + <> + <Modal + contentClass="max-w-3xl" + trigger={children} + title="Preferences" + // size="xl" + // contentClass="space-y-4" + > + <Content extension={extension} userConfigError={userConfigError} /> + </Modal> + </> + ) +} + +const userConfigFormValuesAtom = atomWithImmer<Record<string, string>>({}) + +function Content({ extension, userConfigError }: { extension: Extension_Extension, userConfigError?: Extension_InvalidExtension | undefined }) { + + const { data: extUserConfig, isLoading } = useGetExtensionUserConfig(extension.id) + + const { mutate: saveExtUserConfig, isPending } = useSaveExtensionUserConfig() + + const [userConfigFormValues, setUserConfigFormValues] = useAtom(userConfigFormValuesAtom) + + React.useLayoutEffect(() => { + if (extUserConfig) { + for (const field of extUserConfig.userConfig?.fields || []) { + if (extUserConfig.savedUserConfig?.values && field.name in extUserConfig.savedUserConfig?.values) { + setUserConfigFormValues(draft => { + draft[field.name] = extUserConfig.savedUserConfig?.values?.[field.name] || field.default || "" + return + }) + } + } + } + }, [extUserConfig]) + + function handleSave() { + console.log("Saving user config", userConfigFormValues) + let values: Record<string, string> = {} + for (const field of extUserConfig?.userConfig?.fields || []) { + values[field.name] = userConfigFormValues[field.name] || field.default || "" + } + saveExtUserConfig({ + id: extension.id, + version: extUserConfig?.userConfig?.version || 0, + values: values, + }) + } + + if (isLoading) return <LoadingSpinner /> + + if (!extUserConfig) return <LuffyError /> + + return ( + <> + <div> + <p> + {extension.name} + </p> + <div className="text-sm text-[--muted]"> + You can edit the preferences for this extension here. + </div> + </div> + + {userConfigError && ( + <Alert + intent="alert-basic" + title="Config error" + description={userConfigError.reason} + /> + )} + + {extUserConfig?.userConfig?.fields?.map(field => { + if (field.type === "text") { + return ( + <TextInput + key={field.name} + label={field.label} + value={userConfigFormValues[field.name] || field.default} + onValueChange={v => setUserConfigFormValues(draft => { + draft[field.name] = v + return + })} + help={!!field.default ? `Default: ${field.default}` : undefined} + /> + ) + } + if (field.type === "switch") { + return ( + <Switch + key={field.name} + label={field.label} + value={userConfigFormValues[field.name] ? userConfigFormValues[field.name] === "true" : field.default === "true"} + onValueChange={v => setUserConfigFormValues(draft => { + draft[field.name] = v ? "true" : "false" + return + })} + help={!!field.default ? `Default: ${field.default}` : undefined} + /> + ) + } + if (field.type === "select" && field.options) { + return ( + <Select + key={field.name} + label={field.label} + value={userConfigFormValues[field.name] || field.default} + onValueChange={v => setUserConfigFormValues(draft => { + draft[field.name] = v + return + })} + options={field.options} + help={!!field.default ? `Default: ${field.options.find(n => n.value === field.default)?.label ?? "N/A"}` : undefined} + /> + ) + } + })} + + <div className="flex"> + <Button + intent="white" + loading={isPending} + onClick={handleSave} + className={cn(!!userConfigError && "animate-pulse")} + > + Save + </Button> + <div className="flex flex-1"></div> + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/invalid-extension-card.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/invalid-extension-card.tsx new file mode 100644 index 0000000..71f76e8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/invalid-extension-card.tsx @@ -0,0 +1,310 @@ +import { Extension_InvalidExtension } from "@/api/generated/types" +import { useGrantPluginPermissions, useReloadExternalExtension } from "@/api/hooks/extensions.hooks" +import { ExtensionSettings } from "@/app/(main)/extensions/_containers/extension-card" +import { ExtensionCodeModal } from "@/app/(main)/extensions/_containers/extension-code" +import { Badge } from "@/components/ui/badge" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import Image from "next/image" +import React from "react" +import { BiCog, BiInfoCircle } from "react-icons/bi" +import { FaCode } from "react-icons/fa" +import { LuRefreshCcw, LuShieldCheck } from "react-icons/lu" +import { toast } from "sonner" + +type InvalidExtensionCardProps = { + extension: Extension_InvalidExtension + isInstalled: boolean +} + +export function InvalidExtensionCard(props: InvalidExtensionCardProps) { + + const { + extension, + isInstalled, + ...rest + } = props + + const { mutate: reloadExternalExtension, isPending: isReloadingExtension } = useReloadExternalExtension() + + return ( + <div + className={cn( + "group/extension-card relative overflow-hidden", + "bg-gray-950 border border-[rgb(255_255_255_/_5%)] rounded-[--radius-md] p-3", + )} + > + <div + className={cn( + "absolute z-[0] right-0 top-0 h-full w-full max-w-[150px] bg-gradient-to-l to-gray-950", + "max-w-[50%] from-red-950/20", + )} + ></div> + + <div className="absolute top-3 right-3 grid grid-cols-2 gap-1 p-1 rounded-[--radius-md] bg-gray-950 z-[2]"> + <Modal + trigger={<IconButton + size="sm" + intent="alert-basic" + icon={<BiInfoCircle />} + />} + title="Error details" + contentClass="max-w-2xl" + > + <p> + Seanime failed to load this extension. If you aren't sure what this means, please contact the author. + </p> + <p> + Code: <strong>{extension.code}</strong> + </p> + <code className="code text-red-200"> + {extension.reason} + </code> + + <p className="whitespace-pre-wrap w-full max-w-full overflow-x-auto text-xs text-center tracking-wide text-[--muted]"> + {extension.path} + </p> + </Modal> + {/*Show settings if extension has an ID and manifest URI*/} + {/*This will allow the user to fetch updates or uninstall the extension*/} + {(!!extension.extension?.id && !!extension.extension?.manifestURI) && ( + <> + <ExtensionSettings extension={extension?.extension} isInstalled={isInstalled}> + <IconButton + size="sm" + intent="gray-basic" + icon={<BiCog />} + /> + </ExtensionSettings> + </> + )} + + <ExtensionCodeModal extension={extension.extension}> + <IconButton + size="sm" + intent="gray-basic" + icon={<FaCode />} + /> + </ExtensionCodeModal> + + <IconButton + size="sm" + intent="gray-basic" + icon={<LuRefreshCcw />} + onClick={() => { + if (!extension.extension?.id) return toast.error("Extension has no ID") + reloadExternalExtension({ id: extension.extension?.id ?? "" }) + }} + disabled={isReloadingExtension} + /> + </div> + + <div className="z-[1] relative space-y-3"> + <div className="flex gap-3 pr-16"> + <div className="relative rounded-[--radius-md] size-12 bg-gray-900 overflow-hidden"> + {!!extension.extension?.icon ? ( + <Image + src={extension.extension?.icon} + alt="extension icon" + crossOrigin="anonymous" + fill + quality={100} + priority + className="object-cover" + /> + ) : <div className="w-full h-full flex items-center justify-center"> + <p className="text-2xl font-bold"> + {(extension.extension?.name?.[0] ?? "?").toUpperCase()} + </p> + </div>} + </div> + + <div> + <p className="font-semibold line-clamp-1"> + {extension.extension?.name ?? "Unknown"} + </p> + <p className="text-[--muted] text-sm line-clamp-1 italic"> + {extension.extension?.id ?? "Invalid ID"} + </p> + </div> + </div> + + <div> + <p className="text-red-400 text-sm"> + {extension.code === "invalid_manifest" && "Manifest error"} + {extension.code === "invalid_semver_constraint" && "Incompatible with this version of Seanime"} + {extension.code === "invalid_payload" && "Invalid or incompatible code"} + </p> + </div> + + <div className="flex gap-2"> + {!!extension.extension?.version && <Badge className="rounded-[--radius-md]"> + {extension.extension?.version} + </Badge>} + {extension.extension?.lang && <Badge className="rounded-[--radius-md]"> + {extension.extension?.lang?.toUpperCase?.()} + </Badge>} + <Badge className="rounded-[--radius-md]" intent="unstyled"> + {extension.extension?.author ?? "-"} + </Badge> + </div> + + </div> + </div> + ) +} + +type UnauthorizedExtensionPluginCardProps = { + extension: Extension_InvalidExtension + isInstalled: boolean +} + +export function UnauthorizedExtensionPluginCard(props: UnauthorizedExtensionPluginCardProps) { + + const { + extension, + isInstalled, + ...rest + } = props + + const { mutate: grantPluginPermissions, isPending: isGrantingPluginPermissions } = useGrantPluginPermissions() + const { mutate: reloadExternalExtension, isPending: isReloadingExtension } = useReloadExternalExtension() + + return ( + <div + className={cn( + "group/extension-card relative overflow-hidden", + "bg-gray-950 border border-[rgb(255_255_255_/_5%)] rounded-[--radius-md] p-3 border-yellow-900", + )} + > + <div + className={cn( + "absolute z-[0] right-0 top-0 h-full w-full max-w-[150px] bg-gradient-to-l to-gray-950", + "max-w-[50%] from-yellow-950/20", + )} + ></div> + + <div className="absolute top-3 right-3 flex flex-col gap-1 p-1 rounded-[--radius-md] bg-gray-950 z-[2]"> + <Modal + trigger={<Button + size="sm" + intent="warning-basic" + leftIcon={<LuShieldCheck />} + className="animate-bounce" + >Grant</Button>} + title="Permissions required" + contentClass="max-w-2xl" + > + <p> + The plugin <span className="font-bold">{extension.extension?.name}</span> is requesting the following permissions: + </p> + + <p className="whitespace-pre-wrap w-full max-w-full overflow-x-auto text-md leading-relaxed text-left bg-[--subtle] p-2 rounded-md"> + {extension.pluginPermissionDescription} + </p> + + <p className="whitespace-pre-wrap w-full max-w-full overflow-x-auto text-sm text-center text-[--muted]"> + {extension.path} + </p> + + <Button + size="md" + intent="success-subtle" + leftIcon={<LuShieldCheck className="size-5" />} + onClick={() => { + if (!extension.extension?.id) return toast.error("Extension has no ID") + grantPluginPermissions({ id: extension.extension?.id ?? "" }) + }} + loading={isGrantingPluginPermissions} + > + Grant permissions + </Button> + </Modal> + {/*Show settings if extension has an ID and manifest URI*/} + {/*This will allow the user to fetch updates or uninstall the extension*/} + {(!!extension.extension?.id && !!extension.extension?.manifestURI) && ( + <> + <ExtensionSettings extension={extension?.extension} isInstalled={isInstalled}> + <IconButton + size="sm" + intent="gray-basic" + icon={<BiCog />} + /> + </ExtensionSettings> + </> + )} + + {/* <ExtensionCodeModal extension={extension.extension}> + <IconButton + size="sm" + intent="gray-basic" + icon={<FaCode />} + /> + </ExtensionCodeModal> + + <IconButton + size="sm" + intent="gray-basic" + icon={<LuRefreshCcw />} + onClick={() => { + if (!extension.extension?.id) return toast.error("Extension has no ID") + reloadExternalExtension({ id: extension.extension?.id ?? "" }) + }} + disabled={isReloadingExtension} + /> */} + </div> + + <div className="z-[1] relative space-y-3"> + <div className="flex gap-3 pr-16"> + <div className="relative rounded-[--radius-md] size-12 bg-gray-900 overflow-hidden"> + {!!extension.extension?.icon ? ( + <Image + src={extension.extension?.icon} + alt="extension icon" + crossOrigin="anonymous" + fill + quality={100} + priority + className="object-cover" + /> + ) : <div className="w-full h-full flex items-center justify-center"> + <p className="text-2xl font-bold"> + {(extension.extension?.name?.[0] ?? "?").toUpperCase()} + </p> + </div>} + </div> + + <div> + <p className="font-semibold line-clamp-1"> + {extension.extension?.name ?? "Unknown"} + </p> + <p className="text-[--muted] text-xs line-clamp-1 italic"> + {extension.extension?.id ?? "Invalid ID"} + </p> + </div> + </div> + + <div> + <p className="text-red-400 text-sm"> + {extension.code === "invalid_manifest" && "Manifest error"} + {extension.code === "invalid_payload" && "Invalid or incompatible code"} + </p> + </div> + + <div className="flex gap-2"> + {!!extension.extension?.version && <Badge className="rounded-[--radius-md]"> + {extension.extension?.version} + </Badge>} + {extension.extension?.lang && <Badge className="rounded-[--radius-md]" intent="unstyled"> + {extension.extension?.lang?.toUpperCase?.()} + </Badge>} + <Badge className="rounded-[--radius-md]" intent="unstyled"> + {extension.extension?.author ?? "-"} + </Badge> + </div> + + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/marketplace-extensions.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/marketplace-extensions.tsx new file mode 100644 index 0000000..3fe8c63 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_containers/marketplace-extensions.tsx @@ -0,0 +1,546 @@ +import { Extension_Extension } from "@/api/generated/types" +import { + useGetAllExtensions, + useGetMarketplaceExtensions, + useInstallExternalExtension, + useReloadExternalExtension, +} from "@/api/hooks/extensions.hooks" +import { DEFAULT_MARKETPLACE_URL, marketplaceUrlAtom } from "@/app/(main)/extensions/_lib/marketplace.atoms" +import { LANGUAGES_LIST } from "@/app/(main)/manga/_lib/language-map" +import { LuffyError } from "@/components/shared/luffy-error" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Badge } from "@/components/ui/badge" +import { Button, IconButton } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { Popover } from "@/components/ui/popover" +import { Select } from "@/components/ui/select" +import { TextInput } from "@/components/ui/text-input" +import { useAtom } from "jotai/react" +import { orderBy } from "lodash" +import capitalize from "lodash/capitalize" +import Image from "next/image" +import React, { useMemo } from "react" +import { BiSearch } from "react-icons/bi" +import { CgMediaPodcast } from "react-icons/cg" +import { LuBlocks, LuCheck, LuDownload, LuSettings } from "react-icons/lu" +import { PiBookFill } from "react-icons/pi" +import { RiFolderDownloadFill } from "react-icons/ri" +import { toast } from "sonner" + +type MarketplaceExtensionsProps = { + children?: React.ReactNode +} + +export function MarketplaceExtensions(props: MarketplaceExtensionsProps) { + const { + children, + ...rest + } = props + + const [searchTerm, setSearchTerm] = React.useState("") + const [filterType, setFilterType] = React.useState<string>("all") + const [filterLanguage, setFilterLanguage] = React.useState<string>("all") + const [marketplaceUrl, setMarketplaceUrl] = useAtom(marketplaceUrlAtom) + const [isUrlModalOpen, setIsUrlModalOpen] = React.useState(false) + const [tempUrl, setTempUrl] = React.useState(marketplaceUrl) + const [urlError, setUrlError] = React.useState("") + const [isUpdatingUrl, setIsUpdatingUrl] = React.useState(false) + + const { data: marketplaceExtensions, isPending: isLoadingMarketplace, refetch } = useGetMarketplaceExtensions(marketplaceUrl) + const { data: allExtensions, isPending: isLoadingAllExtensions } = useGetAllExtensions(false) + + function orderExtensions(extensions: Extension_Extension[] | undefined) { + return extensions ? + orderBy(extensions, ["name", "manifestUri"]) + : [] + } + + function isExtensionInstalled(extensionID: string) { + return !!allExtensions?.extensions?.find(n => n.id === extensionID) || + !!allExtensions?.invalidExtensions?.find(n => n.id === extensionID) + } + + // Filter extensions based on search term, filter type, and language + const filteredExtensions = React.useMemo(() => { + if (!marketplaceExtensions) return [] + + let filtered = [...marketplaceExtensions] + + // Filter by type if not "all" + if (filterType !== "all") { + filtered = filtered.filter(ext => ext.type === filterType) + } + + // Filter by language if not "all" + if (filterLanguage !== "all") { + filtered = filtered.filter(ext => ext.lang?.toLowerCase() === filterLanguage.toLowerCase()) + } + + // Filter by search term + if (searchTerm) { + const term = searchTerm.toLowerCase() + filtered = filtered.filter(ext => + ext.name.toLowerCase().includes(term) || + ext.description?.toLowerCase().includes(term) || + ext.id.toLowerCase().includes(term), + ) + } + + return orderExtensions(filtered) + }, [marketplaceExtensions, searchTerm, filterType, filterLanguage]) + + // Get available languages from extensions + const availableLanguages = useMemo(() => { + if (!marketplaceExtensions) return [] + + // Get unique languages from extensions + const langSet = new Set<string>() + marketplaceExtensions.forEach(ext => { + if (ext.lang) langSet.add(ext.lang.toLowerCase()) + }) + + // Convert to array and sort + return Array.from(langSet).sort() + }, [marketplaceExtensions]) + + // Create language options for dropdown + const languageOptions = useMemo(() => { + const options = [{ value: "all", label: "All Languages" }] + + availableLanguages.forEach(langCode => { + const langInfo = LANGUAGES_LIST[langCode] + if (langInfo) { + options.push({ + value: langCode, + label: langInfo.name || langCode.toUpperCase(), + }) + } else { + options.push({ + value: langCode, + label: langCode.toUpperCase(), + }) + } + }) + + return options + }, [availableLanguages]) + + // Group extensions by type + const pluginExtensions = filteredExtensions.filter(n => n.type === "plugin") + const animeTorrentExtensions = filteredExtensions.filter(n => n.type === "anime-torrent-provider") + const mangaExtensions = filteredExtensions.filter(n => n.type === "manga-provider") + const onlinestreamExtensions = filteredExtensions.filter(n => n.type === "onlinestream-provider") + + // if (isLoadingMarketplace || isLoadingAllExtensions) return <LoadingSpinner /> + + // validate URL + const validateUrl = (url: string): boolean => { + try { + new URL(url) + setUrlError("") + return true + } + catch (e) { + setUrlError("Please enter a valid URL") + return false + } + } + + // handle URL change + const handleUrlChange = async () => { + if (validateUrl(tempUrl)) { + setIsUpdatingUrl(true) + try { + setMarketplaceUrl(tempUrl) + await refetch() + setIsUrlModalOpen(false) + toast.success("Marketplace URL updated") + } + catch (error) { + toast.error("Failed to fetch extensions from the provided URL") + console.error("Error fetching extensions:", error) + } + finally { + setIsUpdatingUrl(false) + } + } + } + + // reset URL to default + const resetToDefaultUrl = async () => { + setTempUrl(DEFAULT_MARKETPLACE_URL) + setUrlError("") + } + + // apply default URL immediately + const applyDefaultUrl = async () => { + setIsUpdatingUrl(true) + try { + setMarketplaceUrl(DEFAULT_MARKETPLACE_URL) + await refetch() + setIsUrlModalOpen(false) + toast.success("Reset to default marketplace URL") + } + catch (error) { + toast.error("Failed to fetch extensions from the default URL") + console.error("Error fetching extensions:", error) + } + finally { + setIsUpdatingUrl(false) + } + } + + return ( + <AppLayoutStack className="gap-6"> + {/* URL Change Modal */} + <Modal + open={isUrlModalOpen} + onOpenChange={setIsUrlModalOpen} + title="Repository URL" + > + <div className="space-y-4"> + <p className="text-sm text-[--muted]"> + Enter the URL of the repository JSON file. + </p> + + <TextInput + label="Marketplace URL" + value={tempUrl} + onValueChange={(value) => { + setTempUrl(value) + // Validate as user types, but only if there's some input + if (value) validateUrl(value) + }} + error={urlError} + placeholder="Enter marketplace URL" + /> + + <div className="flex justify-between"> + <div className="flex gap-2"> + {/*<Button*/} + {/* intent="gray-outline"*/} + {/* onClick={resetToDefaultUrl}*/} + {/*>*/} + {/* Set to Default*/} + {/*</Button>*/} + <Button + intent="primary-subtle" + onClick={applyDefaultUrl} + loading={isUpdatingUrl} + disabled={isUpdatingUrl} + > + Apply Default + </Button> + </div> + + <div className="flex gap-2"> + <Button + intent="gray-outline" + onClick={() => setIsUrlModalOpen(false)} + > + Cancel + </Button> + + <Button + intent="primary" + onClick={handleUrlChange} + disabled={!tempUrl || !!urlError || isUpdatingUrl} + loading={isUpdatingUrl} + > + Save + </Button> + </div> + </div> + </div> + </Modal> + + <div className="flex items-center gap-2 flex-wrap"> + <div> + <h2> + Marketplace + </h2> + <p className="text-[--muted] text-sm"> + Browse and install extensions from the repository. + </p> + <p className="text-[--muted] text-xs mt-1"> + Source: {marketplaceUrl === DEFAULT_MARKETPLACE_URL ? + <span>Official repository</span> : + <span>{marketplaceUrl}</span> + } + </p> + </div> + + <div className="flex flex-1"></div> + + <div className="flex items-center gap-2"> + <Button + className="rounded-full" + intent="gray-outline" + onClick={() => { + refetch() + toast.success("Refreshed", { duration: 1000 }) + }} + > + Refresh + </Button> + <Button + className="rounded-full" + intent="gray-outline" + leftIcon={<LuSettings />} + onClick={() => { + setTempUrl(marketplaceUrl) + setUrlError("") + setIsUrlModalOpen(true) + }} + > + Change repository + </Button> + </div> + </div> + + {/* Search and filter */} + <div className="flex flex-wrap gap-4"> + + <div className="flex flex-col lg:flex-row w-full gap-2"> + <Select + value={filterType} + onValueChange={setFilterType} + options={[ + { value: "all", label: "All Types" }, + { value: "plugin", label: "Plugins" }, + { value: "anime-torrent-provider", label: "Anime Torrents" }, + { value: "manga-provider", label: "Manga" }, + { value: "onlinestream-provider", label: "Online Streaming" }, + ]} + fieldClass="lg:max-w-[200px]" + /> + <Select + value={filterLanguage} + onValueChange={setFilterLanguage} + options={languageOptions} + fieldClass="lg:max-w-[200px]" + /> + <TextInput + placeholder="Search extensions..." + value={searchTerm} + onValueChange={(v) => setSearchTerm(v)} + className="pl-10" + leftIcon={<BiSearch />} + /> + </div> + {/*<SelectTrigger className="w-[180px]">*/} + {/* <SelectValue placeholder="Filter by type" />*/} + {/*</SelectTrigger>*/} + {/*<SelectContent>*/} + {/* <SelectItem value="all">All Types</SelectItem>*/} + {/* <SelectItem value="plugin">Plugins</SelectItem>*/} + {/* <SelectItem value="anime-torrent-provider">Anime Torrent</SelectItem>*/} + {/* <SelectItem value="manga-provider">Manga</SelectItem>*/} + {/* <SelectItem value="onlinestream-provider">Online Streaming</SelectItem>*/} + {/*</SelectContent>*/} + {/*</Select>*/} + </div> + + {isLoadingMarketplace && <LoadingSpinner />} + + {(!marketplaceExtensions && !isLoadingMarketplace) && <LuffyError> + Could not get marketplace extensions. + </LuffyError>} + + {/* No results message */} + {(!!marketplaceExtensions && filteredExtensions.length === 0) && ( + <Card className="p-8 text-center"> + <p className="text-[--muted]">No extensions found matching your criteria.</p> + </Card> + )} + + {/* Display extensions by type */} + {!!pluginExtensions?.length && ( + <Card className="p-4 space-y-6"> + <h3 className="flex gap-3 items-center"><LuBlocks /> Plugins</h3> + <div className="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-4"> + {pluginExtensions.map(extension => ( + <MarketplaceExtensionCard + key={extension.id} + extension={extension} + isInstalled={isExtensionInstalled(extension.id)} + /> + ))} + </div> + </Card> + )} + + {!!animeTorrentExtensions?.length && ( + <Card className="p-4 space-y-6"> + <h3 className="flex gap-3 items-center"><RiFolderDownloadFill />Anime torrents</h3> + <div className="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-4"> + {animeTorrentExtensions.map(extension => ( + <MarketplaceExtensionCard + key={extension.id} + extension={extension} + isInstalled={isExtensionInstalled(extension.id)} + /> + ))} + </div> + </Card> + )} + + {!!mangaExtensions?.length && ( + <Card className="p-4 space-y-6"> + <h3 className="flex gap-3 items-center"><PiBookFill />Manga</h3> + <div className="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-4"> + {mangaExtensions.map(extension => ( + <MarketplaceExtensionCard + key={extension.id} + extension={extension} + isInstalled={isExtensionInstalled(extension.id)} + /> + ))} + </div> + </Card> + )} + + {!!onlinestreamExtensions?.length && ( + <Card className="p-4 space-y-6"> + <h3 className="flex gap-3 items-center"><CgMediaPodcast /> Online streaming</h3> + <div className="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-4"> + {onlinestreamExtensions.map(extension => ( + <MarketplaceExtensionCard + key={extension.id} + extension={extension} + isInstalled={isExtensionInstalled(extension.id)} + /> + ))} + </div> + </Card> + )} + </AppLayoutStack> + ) +} + +type MarketplaceExtensionCardProps = { + extension: Extension_Extension + updateData?: Extension_Extension | undefined + isInstalled: boolean +} + +function MarketplaceExtensionCard(props: MarketplaceExtensionCardProps) { + + const { + extension, + updateData, + isInstalled, + ...rest + } = props + + const { mutate: reloadExternalExtension, isPending: isReloadingExtension } = useReloadExternalExtension() + + const [installModalOpen, setInstallModalOpen] = React.useState(false) + + const { + mutate: installExtension, + data: installResponse, + isPending: isInstalling, + } = useInstallExternalExtension() + + React.useEffect(() => { + if (installResponse) { + toast.success(installResponse.message) + setInstallModalOpen(false) + } + }, [installResponse]) + + return ( + <div + className={cn( + "group/extension-card border border-[rgb(255_255_255_/_5%)] relative overflow-hidden", + "bg-gray-950 rounded-md p-3", + !!updateData && "border-[--green]", + )} + > + <div className="absolute top-3 right-3 z-[2]"> + <div className=" flex flex-row gap-1 z-[2] flex-wrap justify-end"> + {!isInstalled ? <IconButton + size="sm" + intent="primary-subtle" + icon={<LuDownload />} + loading={isInstalling} + onClick={() => installExtension({ manifestUri: extension.manifestURI })} + /> : <IconButton + size="sm" + disabled + intent="success-subtle" + icon={<LuCheck />} + /> + } + </div> + </div> + + <div className="z-[1] relative space-y-3"> + <div className="flex gap-3 pr-16"> + <div className="relative rounded-md size-12 bg-gray-900 overflow-hidden"> + {!!extension.icon ? ( + <Image + src={extension.icon} + alt="extension icon" + crossOrigin="anonymous" + fill + quality={100} + priority + className="object-cover" + /> + ) : <div className="w-full h-full flex items-center justify-center"> + <p className="text-2xl font-bold"> + {(extension.name[0]).toUpperCase()} + </p> + </div>} + </div> + + <div> + <p className="font-semibold line-clamp-1"> + {extension.name} + </p> + <p className="opacity-30 text-xs line-clamp-1 tracking-wide"> + {extension.id} + </p> + </div> + </div> + + {extension.description && ( + <Popover + trigger={<p className="text-sm text-[--muted] line-clamp-2 cursor-pointer"> + {extension.description} + </p>} + > + <p className="text-sm"> + {extension.description} + </p> + </Popover> + )} + + <div className="flex gap-2 flex-wrap"> + {!!extension.version && <Badge className="rounded-md tracking-wide"> + {extension.version} + </Badge>} + {<Badge className="rounded-md" intent="unstyled"> + By {extension.author} + </Badge>} + <Badge className="rounded-md" intent={extension.lang !== "multi" && extension.lang !== "en" ? "blue" : "unstyled"}> + {/*{extension.lang.toUpperCase()}*/} + {LANGUAGES_LIST[extension.lang?.toLowerCase()]?.nativeName || extension.lang?.toUpperCase() || "Unknown"} + </Badge> + <Badge className="rounded-md" intent="unstyled"> + {capitalize(extension.language)} + </Badge> + {!!updateData && <Badge className="rounded-md" intent="success"> + Update available + </Badge>} + </div> + + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_lib/marketplace.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_lib/marketplace.atoms.ts new file mode 100644 index 0000000..e78250e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/_lib/marketplace.atoms.ts @@ -0,0 +1,12 @@ +import { atomWithStorage } from "jotai/utils" + +// Default marketplace URL +export const DEFAULT_MARKETPLACE_URL = "" + +// Atom to store the marketplace URL in localStorage +export const marketplaceUrlAtom = atomWithStorage<string>( + "marketplace-url", + DEFAULT_MARKETPLACE_URL, + undefined, + { getOnInit: true }, +) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/layout.tsx new file mode 100644 index 0000000..3efab03 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/layout.tsx @@ -0,0 +1,15 @@ +"use client" +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/page.tsx new file mode 100644 index 0000000..ad3cb04 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/page.tsx @@ -0,0 +1,88 @@ +"use client" + +import { useUnauthorizedPluginCount } from "@/api/hooks/extensions.hooks" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { __extensions_currentPageAtom, ExtensionList } from "@/app/(main)/extensions/_containers/extension-list" +import { MarketplaceExtensions } from "@/app/(main)/extensions/_containers/marketplace-extensions" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { StaticTabs } from "@/components/ui/tabs" +import { useAtom } from "jotai" +import { AnimatePresence } from "motion/react" +import React from "react" +import { FaExclamation } from "react-icons/fa" +import { LuDownload, LuGlobe } from "react-icons/lu" + +export default function Page() { + + const [page, setPage] = useAtom(__extensions_currentPageAtom) + const unauthorizedPluginCount = useUnauthorizedPluginCount() + + return ( + <> + <CustomLibraryBanner discrete /> + <PageWrapper className="p-4 sm:p-8 space-y-4"> + + {/*<div className="flex-wrap max-w-full bg-[--paper] p-2 border rounded-lg">*/} + <StaticTabs + data-anilist-collection-lists-tabs + className="h-10 w-fit border rounded-full" + triggerClass="px-4 py-1 text-md" + items={[ + { + name: "Installed", + isCurrent: page === "installed", + onClick: () => setPage("installed"), + iconType: LuDownload, + addon: unauthorizedPluginCount > 0 && ( + <span className="ml-2 bottom-1 right-1 rounded-full relative"> + <FaExclamation className="text-[--orange] animate-bounce size-6" /> + </span> + ), + }, + { + name: "Marketplace", + isCurrent: page === "marketplace", + onClick: () => setPage("marketplace"), + iconType: LuGlobe, + }, + ]} + /> + {/*</div>*/} + + <AnimatePresence mode="wait"> + {page === "installed" && ( + <PageWrapper + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + key="installed" className="pt-0 space-y-8 relative z-[4]" + > + <ExtensionList /> + </PageWrapper> + )} + {page === "marketplace" && ( + <PageWrapper + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + key="marketplace" className="pt-0 space-y-8 relative z-[4]" + > + <MarketplaceExtensions /> + </PageWrapper> + )} + </AnimatePresence> + </PageWrapper> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/playground/_containers/extension-playground.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/playground/_containers/extension-playground.tsx new file mode 100644 index 0000000..b3c4f76 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/playground/_containers/extension-playground.tsx @@ -0,0 +1,838 @@ +import { Extension_Language, Extension_Type } from "@/api/generated/types" +import { useRunExtensionPlaygroundCode } from "@/api/hooks/extensions.hooks" +import { LuffyError } from "@/components/shared/luffy-error" +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/shared/resizable" +import { Alert } from "@/components/ui/alert" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { NumberInput } from "@/components/ui/number-input" +import { Select } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { TextInput } from "@/components/ui/text-input" +import { Textarea } from "@/components/ui/textarea" +import { useDebounce } from "@/hooks/use-debounce" +import { copyToClipboard } from "@/lib/helpers/browser" +import { autocompletion } from "@codemirror/autocomplete" +import { javascript } from "@codemirror/lang-javascript" +import { StreamLanguage } from "@codemirror/language" +import { go } from "@codemirror/legacy-modes/mode/go" +// import { vscodeKeymap } from "@replit/codemirror-vscode-keymap" +import { vscodeDark } from "@uiw/codemirror-theme-vscode" +import CodeMirror, { EditorView } from "@uiw/react-codemirror" +import { withImmer } from "jotai-immer" +import { useAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import mousetrap from "mousetrap" +import React from "react" +import { BiCopy, BiTerminal } from "react-icons/bi" +import { toast } from "sonner" + +type Params = { + animeTorrentProvider: { + mediaId: number + search: { + query: string + }, + smartSearch: { + query: string + batch: boolean + episodeNumber: number + resolution: string + bestReleases: boolean + }, + getTorrentInfoHash: { + torrent: string + }, + getTorrentMagnetLink: { + torrent: string + }, + }, + mangaProvider: { + mediaId: number + findChapters: { + id: string + }, + findChapterPages: { + id: string + }, + }, + onlineStreamingProvider: { + mediaId: number + search: { + dub: boolean + } + findEpisodes: { + id: string + }, + findEpisodeServer: { + episode: string + server: string + }, + } +} + +const DEFAULT_PARAMS: Params = { + animeTorrentProvider: { + mediaId: 0, + search: { + query: "", + }, + smartSearch: { + query: "", + batch: false, + episodeNumber: 0, + resolution: "", + bestReleases: false, + }, + getTorrentInfoHash: { + torrent: "", + }, + getTorrentMagnetLink: { + torrent: "", + }, + }, + mangaProvider: { + mediaId: 0, + findChapters: { + id: "", + }, + findChapterPages: { + id: "", + }, + }, + onlineStreamingProvider: { + mediaId: 0, + search: { + dub: false, + }, + findEpisodes: { + id: "", + }, + findEpisodeServer: { + episode: "", + server: "", + }, + }, +} + +const enum Functions { + AnimeTorrentProviderSearch = "AnimeTorrentProvider.search", + AnimeTorrentProviderSmartSearch = "AnimeTorrentProvider.smartSearch", + AnimeTorrentProviderGetTorrentInfoHash = "AnimeTorrentProvider.getTorrentInfoHash", + AnimeTorrentProviderGetTorrentMagnetLink = "AnimeTorrentProvider.getTorrentMagnetLink", + AnimeTorrentProviderGetLatest = "AnimeTorrentProvider.getLatest", + MangaProviderSearch = "MangaProvider.search", + MangaProviderFindChapters = "MangaProvider.findChapters", + MangaProviderFindChapterPages = "MangaProvider.findChapterPages", + OnlinestreamSearch = "Onlinestream.search", + OnlinestreamFindEpisodes = "Onlinestream.findEpisodes", + OnlinestreamFindEpisodeServer = "Onlinestream.findEpisodeServer", +} + +//---------------------------------------------------------------------------------------------------------------------------------------------------- + + +type ExtensionPlaygroundProps = { + language: Extension_Language + onLanguageChange?: (lang: Extension_Language) => void + type: Extension_Type + onTypeChange?: (type: Extension_Type) => void + code?: string + onCodeChange?: (code: string) => void +} + +const codeAtom = atomWithStorage<string>("sea-extension-playground-code", "", undefined, { getOnInit: true }) +const paramsAtom = atomWithStorage<Params>("sea-extension-playground-params", DEFAULT_PARAMS, undefined, { getOnInit: true }) + +export function ExtensionPlayground(props: ExtensionPlaygroundProps) { + + const { + language, + onLanguageChange, + type, + onTypeChange, + code: EXT_code, + onCodeChange, + } = props + + const { data: response, mutate: runCode, isPending: isRunning } = useRunExtensionPlaygroundCode() + + const [selectedFunction, setSelectedFunction] = React.useState(Functions.AnimeTorrentProviderSearch) + const [inputs, setInputs] = useAtom(withImmer(paramsAtom)) + + React.useLayoutEffect(() => { + if (type === "anime-torrent-provider") { + setSelectedFunction(Functions.AnimeTorrentProviderSearch) + } else if (type === "manga-provider") { + setSelectedFunction(Functions.MangaProviderSearch) + } else if (type === "onlinestream-provider") { + setSelectedFunction(Functions.OnlinestreamSearch) + } + }, [type]) + + // + // Code + // + + const [code, setCode] = useAtom(codeAtom) + const debouncedCode = useDebounce(code, 500) + const codeRef = React.useRef("") + + React.useEffect(() => { + if (!!EXT_code && EXT_code !== codeRef.current) { + setCode(EXT_code) + } + }, [EXT_code]) + + React.useEffect(() => { + codeRef.current = code + if (EXT_code !== code) { + onCodeChange?.(code) + } + }, [debouncedCode]) + + function handleRunCode() { + + let ret = {} + let func = "" + + if (selectedFunction === Functions.AnimeTorrentProviderSearch) { + func = "search" + ret = { + mediaId: inputs.animeTorrentProvider.mediaId, + query: inputs.animeTorrentProvider.search.query, + } + } else if (selectedFunction === Functions.AnimeTorrentProviderSmartSearch) { + func = "smartSearch" + ret = { + mediaId: inputs.animeTorrentProvider.mediaId, + options: { + query: inputs.animeTorrentProvider.smartSearch.query, + episodeNumber: inputs.animeTorrentProvider.smartSearch.episodeNumber, + resolution: inputs.animeTorrentProvider.smartSearch.resolution, + batch: inputs.animeTorrentProvider.smartSearch.batch, + bestReleases: inputs.animeTorrentProvider.smartSearch.bestReleases, + }, + } + } else if (selectedFunction === Functions.AnimeTorrentProviderGetTorrentInfoHash) { + func = "getTorrentInfoHash" + ret = { + mediaId: inputs.animeTorrentProvider.mediaId, + torrent: inputs.animeTorrentProvider.getTorrentInfoHash.torrent, + } + } else if (selectedFunction === Functions.AnimeTorrentProviderGetTorrentMagnetLink) { + func = "getTorrentMagnetLink" + ret = { + mediaId: inputs.animeTorrentProvider.mediaId, + torrent: inputs.animeTorrentProvider.getTorrentMagnetLink.torrent, + } + } else if (selectedFunction === Functions.AnimeTorrentProviderGetLatest) { + func = "getLatest" + ret = { + mediaId: inputs.animeTorrentProvider.mediaId, + } + } else if (selectedFunction === Functions.MangaProviderSearch) { + func = "search" + ret = { + mediaId: inputs.mangaProvider.mediaId, + } + } else if (selectedFunction === Functions.MangaProviderFindChapters) { + func = "findChapters" + ret = { + mediaId: inputs.mangaProvider.mediaId, + id: inputs.mangaProvider.findChapters.id, + } + } else if (selectedFunction === Functions.MangaProviderFindChapterPages) { + func = "findChapterPages" + ret = { + mediaId: inputs.mangaProvider.mediaId, + id: inputs.mangaProvider.findChapterPages.id, + } + } else if (selectedFunction === Functions.OnlinestreamSearch) { + func = "search" + ret = { + mediaId: inputs.onlineStreamingProvider.mediaId, + dub: inputs.onlineStreamingProvider.search.dub, + } + } else if (selectedFunction === Functions.OnlinestreamFindEpisodes) { + func = "findEpisodes" + ret = { + mediaId: inputs.onlineStreamingProvider.mediaId, + id: inputs.onlineStreamingProvider.findEpisodes.id, + } + } else if (selectedFunction === Functions.OnlinestreamFindEpisodeServer) { + func = "findEpisodeServer" + ret = { + mediaId: inputs.onlineStreamingProvider.mediaId, + episode: inputs.onlineStreamingProvider.findEpisodeServer.episode, + server: inputs.onlineStreamingProvider.findEpisodeServer.server, + } + } else { + toast.error("Invalid function selected.") + return + } + + runCode({ + params: { + type: type, + language: language, + code: code, + function: func, + inputs: ret, + }, + }) + } + + React.useEffect(() => { + mousetrap.bind(["cmd+s", "ctrl+s"], () => { + handleRunCode() + }) + + return () => { + mousetrap.unbind(["cmd+s", "ctrl+s"]) + } + }, []) + + + return ( + <> + <div className="w-full"> + + <div className="flex items-center w-full"> + <div className="w-full flex items-center gap-4"> + <h2 className="w-fit">Playground</h2> + + <Select + value={type as string} + intent="filled" + options={[ + { value: "anime-torrent-provider", label: "Anime Torrent Provider" }, + { value: "manga-provider", label: "Manga Provider" }, + { value: "onlinestream-provider", label: "Online Streaming Provider" }, + ]} + onValueChange={v => { + onTypeChange?.(v as Extension_Type) + }} + disabled={!onTypeChange} + fieldClass="max-w-[250px]" + /> + + <Select + value={language as string} + options={[ + { value: "typescript", label: "Typescript" }, + { value: "javascript", label: "Javascript" }, + // { value: "go", label: "Go" }, + ]} + onValueChange={v => { + onLanguageChange?.(v as Extension_Language) + }} + disabled={!onLanguageChange} + fieldClass="max-w-[140px]" + /> + </div> + <div className="flex items-center gap-2 lg:flex-none w-fit"> + + <Button intent="primary" loading={isRunning} onClick={() => handleRunCode()} leftIcon={<BiTerminal className="size-6" />}> + {isRunning ? "Running..." : "Run"} + </Button> + + </div> + </div> + + <div className="block lg:hidden"> + <LuffyError title="Oops!"> + Your screen size is too small. + </LuffyError> + </div> + + <div className="hidden lg:block"> + <ResizablePanelGroup + autoSaveId="sea-extension-playground-1" + direction="horizontal" + className="w-full border rounded-md !h-[calc(100vh-16rem)] xl:!h-[calc(100vh-14rem)] mt-8" + > + <ResizablePanel defaultSize={75}> + <ResizablePanelGroup direction="vertical" autoSaveId="sea-extension-playground-2"> + <ResizablePanel defaultSize={75}> + <div className="flex w-full h-full"> + <div className="overflow-y-auto rounded-tl-sm w-full"> + <CodeMirror + value={code} + height="100%" + theme={vscodeDark} + extensions={[ + autocompletion({ defaultKeymap: false }), + // keymap.of(vscodeKeymap), + javascript({ typescript: language === "typescript" }), + StreamLanguage.define(go), + EditorView.theme({ + "&": { + fontSize: "14px", + font: "'JetBrains Mono', monospace", + }, + }), + ]} + onChange={setCode} + /> + </div> + </div> + </ResizablePanel> + <ResizableHandle /> + <ResizablePanel defaultSize={25} className="!overflow-y-auto"> + <div className="flex w-full h-full p-2"> + <div className="w-full"> + <div className="bg-gray-950 rounded-md border max-w-full overflow-x-auto h-full"> + {/* <p className="font-semibold mb-2 p-2 border-b text-sm">Console</p> */} + <pre className="h-full whitespace-pre-wrap break-all"> + {response?.logs?.split("\n")?.filter(l => l.trim() !== "").map((l, i) => ( + <p + key={i} + className={cn( + "w-full hover:bg-gray-800 hover:text-white text-sm py-1 px-2 tracking-wide leading-6", + i % 2 === 0 ? "bg-gray-950" : "bg-gray-900", + l.includes("|ERR|") && "text-white bg-red-800/10", + l.includes("|WRN|") && "text-orange-500", + l.includes("|INF|") && "text-blue-200", + l.includes("|TRC|") && "text-[--muted]", + l.includes("extension > (console.warn):") && "text-orange-200/80", + )} + > + {l.includes(" |") ? ( + <> + <span className="opacity-40 tracking-normal">{l.split(" |")?.[0]} </span> + {l.includes("|DBG|") && + <span className="text-yellow-200/40 font-medium">|DBG|</span>} + {l.includes("|ERR|") && <span className="text-red-400 font-medium">|ERR|</span>} + {l.includes("|WRN|") && + <span className="text-orange-400 font-medium">|WRN|</span>} + {l.includes("|INF|") && <span className="text-blue-400 font-medium">|INF|</span>} + {l.includes("|TRC|") && + <span className="text-purple-400 font-medium">|TRC|</span>} + <span>{l.split("|")?.[2] + ?.replace("extension > (console.log):", "log >") + ?.replace("extension > (console.error):", "error >") + ?.replace("extension > (console.warn):", "warn >") + ?.replace("extension > (console.info):", "info >") + ?.replace("extension > (console.debug):", "debug >") ?? "" + }</span> + </> + ) : ( + l + )} + </p> + ))} + </pre> + </div> + </div> + </div> + </ResizablePanel> + </ResizablePanelGroup> + </ResizablePanel> + <ResizableHandle /> + <ResizablePanel defaultSize={25} className="!overflow-y-auto"> + <div className="flex w-full h-full max-w-full overflow-y-auto"> + <div className="w-full"> + <ResizablePanelGroup direction="vertical" autoSaveId="sea-extension-playground-3"> + + <ResizablePanel defaultSize={30} className="!overflow-y-auto"> + {/* <div className="p-3 sticky z-[2] top-0 right-0 w-full border-b bg-[--background]"> + <Button intent="primary" size="sm" className="w-full" loading={isRunning} onClick={() => handleRunCode()} leftIcon={<BiTerminal className="size-6" />}> + {isRunning ? "Running..." : "Run"} + </Button> + </div> */} + + <div className="space-y-4 p-3"> + {/*ANIME TORRENT PROVIDER*/} + + {type === "anime-torrent-provider" && ( + <> + <Select + leftAddon="Method" + value={selectedFunction} + options={[ + { value: Functions.AnimeTorrentProviderSearch, label: "search" }, + { value: Functions.AnimeTorrentProviderSmartSearch, label: "smartSearch" }, + { + value: Functions.AnimeTorrentProviderGetTorrentInfoHash, + label: "getTorrentInfoHash", + }, + { + value: Functions.AnimeTorrentProviderGetTorrentMagnetLink, + label: "getTorrentMagnetLink", + }, + { value: Functions.AnimeTorrentProviderGetLatest, label: "getLatest" }, + ]} + onValueChange={v => { + setSelectedFunction(v as Functions) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + + <NumberInput + leftAddon="Media ID" + min={0} + formatOptions={{ useGrouping: false }} + value={inputs.animeTorrentProvider.mediaId} + onValueChange={v => { + setInputs(d => { + d.animeTorrentProvider.mediaId = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + + {selectedFunction === Functions.AnimeTorrentProviderSmartSearch && ( + <> + <TextInput + leftAddon="Query" + type="text" + value={inputs.animeTorrentProvider.smartSearch.query} + onChange={e => { + setInputs(d => { + d.animeTorrentProvider.smartSearch.query = e.target.value + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + + <NumberInput + leftAddon="Episode Number" + value={inputs.animeTorrentProvider.smartSearch.episodeNumber || 0} + min={0} + formatOptions={{ useGrouping: false }} + onValueChange={v => { + setInputs(d => { + d.animeTorrentProvider.smartSearch.episodeNumber = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + + <Select + leftAddon="Resolution" + options={[ + { value: "-", label: "Any" }, + { value: "1080p", label: "1080" }, + { value: "720p", label: "720" }, + { value: "540p", label: "540" }, + { value: "480p", label: "480" }, + ]} + value={inputs.animeTorrentProvider.smartSearch.resolution || "-"} + onValueChange={v => { + setInputs(d => { + d.animeTorrentProvider.smartSearch.resolution = v === "-" ? "" : v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + + <Switch + side="right" + label="Batch" + value={inputs.animeTorrentProvider.smartSearch.batch} + onValueChange={v => { + setInputs(d => { + d.animeTorrentProvider.smartSearch.batch = v + return + }) + }} + /> + + <Switch + side="right" + label="Best Releases" + value={inputs.animeTorrentProvider.smartSearch.bestReleases} + onValueChange={v => { + setInputs(d => { + d.animeTorrentProvider.smartSearch.bestReleases = v + return + }) + }} + /> + </> + )} + + {selectedFunction === Functions.AnimeTorrentProviderSearch && ( + <> + <TextInput + leftAddon="Query" + type="text" + value={inputs.animeTorrentProvider.search.query} + onValueChange={v => { + setInputs(d => { + d.animeTorrentProvider.search.query = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + </> + )} + + {selectedFunction === Functions.AnimeTorrentProviderGetTorrentInfoHash && ( + <> + <Textarea + leftAddon="Torrent JSON" + value={inputs.animeTorrentProvider.getTorrentInfoHash.torrent} + onValueChange={v => { + setInputs(d => { + d.animeTorrentProvider.getTorrentInfoHash.torrent = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + </> + )} + + {selectedFunction === Functions.AnimeTorrentProviderGetTorrentMagnetLink && ( + <> + <Textarea + label="Torrent JSON" + value={inputs.animeTorrentProvider.getTorrentMagnetLink.torrent} + onValueChange={v => { + setInputs(d => { + d.animeTorrentProvider.getTorrentMagnetLink.torrent = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + </> + )} + </> + )} + + {/*MANGA PROVIDER*/} + + {type === "manga-provider" && ( + <> + <Select + leftAddon="Method" + value={selectedFunction} + options={[ + { value: Functions.MangaProviderSearch, label: "search" }, + { value: Functions.MangaProviderFindChapters, label: "findChapters" }, + { value: Functions.MangaProviderFindChapterPages, label: "findChapterPages" }, + ]} + onValueChange={v => { + setSelectedFunction(v as Functions) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + + <NumberInput + leftAddon="Media ID" + min={0} + formatOptions={{ useGrouping: false }} + value={inputs.mangaProvider.mediaId} + onValueChange={v => { + setInputs(d => { + d.mangaProvider.mediaId = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + + {selectedFunction === Functions.MangaProviderSearch && ( + <> + <Alert intent="info"> + Seanime will automatically select the best match based on the manga titles. + </Alert> + </> + )} + + {selectedFunction === Functions.MangaProviderFindChapters && ( + <> + <TextInput + leftAddon="Manga ID" + type="text" + value={inputs.mangaProvider.findChapters.id} + onValueChange={v => { + setInputs(d => { + d.mangaProvider.findChapters.id = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + </> + )} + + {selectedFunction === Functions.MangaProviderFindChapterPages && ( + <> + <TextInput + leftAddon="Chapter ID" + type="text" + value={inputs.mangaProvider.findChapterPages.id} + onValueChange={v => { + setInputs(d => { + d.mangaProvider.findChapterPages.id = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + </> + )} + </> + )} + + {/*ONLINE STREAMING PROVIDER*/} + + {type === "onlinestream-provider" && ( + <> + <Select + leftAddon="Method" + value={selectedFunction} + options={[ + { value: Functions.OnlinestreamSearch, label: "search" }, + { value: Functions.OnlinestreamFindEpisodes, label: "findEpisodes" }, + { value: Functions.OnlinestreamFindEpisodeServer, label: "findEpisodeServer" }, + ]} + onValueChange={v => { + setSelectedFunction(v as Functions) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + + <NumberInput + leftAddon="Media ID" + min={0} + formatOptions={{ useGrouping: false }} + value={inputs.onlineStreamingProvider.mediaId} + onValueChange={v => { + setInputs(d => { + d.onlineStreamingProvider.mediaId = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + + {selectedFunction === Functions.OnlinestreamSearch && ( + <> + <Alert intent="info" className="text-sm"> + Seanime will automatically select the best match based on the anime titles. + </Alert> + + <Switch + side="right" + label="Dubbed" + value={inputs.onlineStreamingProvider.search.dub} + onValueChange={v => { + setInputs(d => { + d.onlineStreamingProvider.search.dub = v + return + }) + }} + /> + </> + )} + + {selectedFunction === Functions.OnlinestreamFindEpisodes && ( + <> + <TextInput + leftAddon="Episode ID" + type="text" + value={inputs.onlineStreamingProvider.findEpisodes.id} + onValueChange={v => { + setInputs(d => { + d.onlineStreamingProvider.findEpisodes.id = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + </> + )} + + {selectedFunction === Functions.OnlinestreamFindEpisodeServer && ( + <> + <Textarea + leftAddon="Episode JSON" + value={inputs.onlineStreamingProvider.findEpisodeServer.episode} + onValueChange={v => { + setInputs(d => { + d.onlineStreamingProvider.findEpisodeServer.episode = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + className="text-sm" + /> + + <TextInput + leftAddon="Server" + type="text" + value={inputs.onlineStreamingProvider.findEpisodeServer.server} + onValueChange={v => { + setInputs(d => { + d.onlineStreamingProvider.findEpisodeServer.server = v + return + }) + }} + addonClass="w-[100px] border-r font-semibold text-sm justify-center text-center" + /> + </> + )} + </> + )} + </div> + </ResizablePanel> + + + <ResizableHandle /> + + <ResizablePanel defaultSize={70}> + <div className="h-full w-full p-2"> + <div className="flex items-center gap-2 justify-between mb-2"> + <p className="font-semibold">Output</p> + <IconButton + intent="gray-subtle" size="sm" onClick={() => { + if (response?.value) { + copyToClipboard(response?.value || "") + toast.success("Copied to clipboard") + } else { + toast.warning("No output to copy") + } + }} icon={<BiCopy className="size-4" />} + /> + </div> + + <div className="bg-gray-950 border rounded-md max-w-full overflow-x-auto h-[calc(100%-2.5rem)]"> + <pre className="text-sm text-white h-full break-all max-w-full"> + {response?.value?.split("\n").map((l, i) => ( + <p + key={i} + className={cn( + "w-full px-2 py-[.15rem] text-[.8rem] tracking-wider break-all", + i % 2 === 0 ? "bg-gray-950" : "bg-gray-900", + "hover:bg-gray-800 hover:text-white", + )} + >{l}</p> + ))} + </pre> + </div> + </div> + </ResizablePanel> + + </ResizablePanelGroup> + </div> + </div> + </ResizablePanel> + </ResizablePanelGroup> + </div> + + </div> + </> + ) +} + + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/extensions/playground/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/playground/page.tsx new file mode 100644 index 0000000..58dc23d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/extensions/playground/page.tsx @@ -0,0 +1,31 @@ +"use client" + +import { Extension_Language, Extension_Type } from "@/api/generated/types" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { ExtensionPlayground } from "@/app/(main)/extensions/playground/_containers/extension-playground" +import { PageWrapper } from "@/components/shared/page-wrapper" +import React from "react" + +export default function Page() { + + const [extensionLanguage, setExtensionLanguage] = React.useState<Extension_Language>("typescript") + const [extensionType, setExtensionType] = React.useState<Extension_Type>("anime-torrent-provider") + const [code, setCode] = React.useState<string>("") + + return ( + <> + <CustomLibraryBanner discrete /> + <PageWrapper className="p-4 sm:p-8 pt-0 space-y-8 relative z-[4]"> + <ExtensionPlayground + language={extensionLanguage} + type={extensionType} + onLanguageChange={setExtensionLanguage} + onTypeChange={setExtensionType} + code={code} + onCodeChange={setCode} + /> + </PageWrapper> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/layout.tsx new file mode 100644 index 0000000..41651af --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/layout.tsx @@ -0,0 +1,50 @@ +"use client" +import { MainLayout } from "@/app/(main)/_features/layout/main-layout" +import { OfflineLayout } from "@/app/(main)/_features/layout/offline-layout" +import { TopNavbar } from "@/app/(main)/_features/layout/top-navbar" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { ServerDataWrapper } from "@/app/(main)/server-data-wrapper" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + + const serverStatus = useServerStatus() + + const [host, setHost] = React.useState<string>("") + + React.useEffect(() => { + setHost(window?.location?.host || "") + }, []) + + if (serverStatus?.isOffline) { + return ( + <ServerDataWrapper host={host}> + <OfflineLayout> + <div data-offline-layout-container className="h-auto"> + <TopNavbar /> + <div data-offline-layout-content> + {children} + </div> + </div> + </OfflineLayout> + </ServerDataWrapper> + ) + } + + return ( + <ServerDataWrapper host={host}> + <MainLayout> + <div data-main-layout-container className="h-auto"> + <TopNavbar /> + <div data-main-layout-content> + {children} + </div> + </div> + </MainLayout> + </ServerDataWrapper> + ) + +} + + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/mal/_page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/mal/_page.tsx new file mode 100644 index 0000000..de315b8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/mal/_page.tsx @@ -0,0 +1,75 @@ +"use client" +import { useMALLogout } from "@/api/hooks/mal.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { Button } from "@/components/ui/button" +import { MAL_CLIENT_ID } from "@/lib/server/config" +import React from "react" +import { BiCheckCircle, BiLogOut, BiXCircle } from "react-icons/bi" +import { SiMyanimelist } from "react-icons/si" + +export const dynamic = "force-static" + +export default function _page() { + const status = useServerStatus() + + // const OAUTH_URL = React.useMemo(() => { + // const challenge = generateRandomString(50) + // const state = generateRandomString(10) + // sessionStorage.setItem("mal-" + state, challenge) + // return + // `https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=${MAL_CLIENT_ID}&state=${state}&code_challenge=${challenge}&code_challenge_method=plain` + // }, []) const { mutate: logout, isPending, isSuccess } = useMALLogout() if (!status?.mal && window?.location?.host === "127.0.0.1:43211") + // return ( <div className="p-12 text-center"> <p className="flex justify-center w-full text-8xl"><SiMyanimelist /></p> <p className="-mt-2 pb-6 + // text-lg">Connect your MyAnimeList account to Seanime</p> <Button onClick={() => { window.open(OAUTH_URL, "_self") }} intent="primary" + // size="lg" >Log in with MAL</Button> </div> ) if (!status?.mal && window?.location?.host !== "127.0.0.1:43211") return ( <div className="p-12 + // text-center"> <p className="flex justify-center w-full text-8xl"><SiMyanimelist /></p> <p className="-mt-2 pb-6 text-lg">Connect your + // MyAnimeList account to Seanime</p> <p className="text-[--muted]"> Due to authentication restrictions, you can only connect your MyAnimeList + // account from <em>127.0.0.1:43211</em> </p> </div> ) + + return ( + <div className="p-12 pt-0 space-y-0"> + {/*<p className="flex justify-between items-center w-full text-8xl relative">*/} + {/* <SiMyanimelist />*/} + {/* <Button*/} + {/* intent="alert-subtle"*/} + {/* size="sm"*/} + {/* loading={isPending || isSuccess}*/} + {/* onClick={() => {*/} + {/* logout()*/} + {/* }}*/} + {/* leftIcon={<BiLogOut />}*/} + {/* >Log out</Button>*/} + {/*</p>*/} + + {/*<div className="border rounded-[--radius] p-4 bg-[--paper] text-lg space-y-2">*/} + {/* <p>*/} + {/* Your MyAnimeList account is connected to Seanime.*/} + {/* </p>*/} + {/* <h4>Integration features:</h4>*/} + {/* <ul className="[&>li]:flex [&>li]:items-center [&>li]:gap-1.5 [&>li]:truncate">*/} + {/* <li><BiCheckCircle className="text-green-300" /> Progress tracking <span className="text-[--muted] italic text-base">*/} + {/* Your progress will be automatically updated on MAL whenever you watch an episode or read a chapter with Seanime.*/} + {/* </span></li>*/} + {/* <li><BiXCircle className="text-red-400" /> List synchronization*/} + {/* <span className="text-[--muted] italic text-base">*/} + {/* To sync your lists, use a third-party service like MAL-Sync.*/} + {/* </span>*/} + {/* </li>*/} + {/* <li><BiXCircle className="text-red-400" /> List management <span className="text-[--muted] italic text-base">*/} + {/* To manage your MyAnimeList lists, use the official MAL website or app.*/} + {/* </span></li>*/} + {/* </ul>*/} + {/*</div>*/} + </div> + ) + +} + +function generateRandomString(length: number): string { + let text = "" + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + for (let i = 0; i < length; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)) + } + return text +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/mal/auth/callback/_page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/mal/auth/callback/_page.tsx new file mode 100644 index 0000000..f5a7679 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/mal/auth/callback/_page.tsx @@ -0,0 +1,54 @@ +"use client" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { useMALAuth } from "@/api/hooks/mal.hooks" +import { LoadingOverlay } from "@/components/ui/loading-spinner" +import { useQueryClient } from "@tanstack/react-query" +import { useRouter } from "next/navigation" +import React from "react" + +export default function _page() { + + const router = useRouter() + const qc = useQueryClient() + + const { code, state, challenge } = React.useMemo(() => { + const urlParams = new URLSearchParams(window?.location?.search || "") + const code = urlParams.get("code") || undefined + const state = urlParams.get("state") || undefined + const challenge = sessionStorage.getItem("mal-" + state) || undefined + return { code, state, challenge } + }, []) + + const { data, isError } = useMALAuth({ + code: code, + state: state, + code_verifier: challenge, + }, !!code && !!state && !!challenge) + + React.useEffect(() => { + if (!!data?.access_token) { + (async function () { + await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] }) + router.push("/mal") + })() + } + }, [data]) + + React.useEffect(() => { + if (isError) router.push("/mal") + }, [isError]) + + if (!state || !code || !challenge) return ( + <div className="p-12 space-y-4 text-center"> + Invalid URL or Challenge + </div> + ) + + return ( + <div> + <LoadingOverlay className="fixed w-full h-full z-[80]"> + <h3 className="mt-2">Authenticating...</h3> + </LoadingOverlay> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_components/library-header.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_components/library-header.tsx new file mode 100644 index 0000000..e77db67 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_components/library-header.tsx @@ -0,0 +1,173 @@ +"use client" +import { AL_BaseManga } from "@/api/generated/types" +import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles" +import { cn } from "@/components/ui/core/styling" +import { getImageUrl } from "@/lib/server/assets" +import { useThemeSettings } from "@/lib/theme/hooks" +import { atom, useAtomValue } from "jotai" +import { useSetAtom } from "jotai/react" +import { AnimatePresence, motion } from "motion/react" +import Image from "next/image" +import React, { useEffect, useState } from "react" +import { useWindowScroll } from "react-use" + +export const __mangaLibraryHeaderImageAtom = atom<string | null>(null) +export const __mangaLibraryHeaderMangaAtom = atom<AL_BaseManga | null>(null) + +const MotionImage = motion.create(Image) + +export function LibraryHeader({ manga }: { manga: AL_BaseManga[] }) { + + const ts = useThemeSettings() + + const image = useAtomValue(__mangaLibraryHeaderImageAtom) + const [actualImage, setActualImage] = useState<string | null>(null) + const [prevImage, setPrevImage] = useState<string | null>(null) + const [dimmed, setDimmed] = useState(false) + + const setHeaderManga = useSetAtom(__mangaLibraryHeaderMangaAtom) + + useEffect(() => { + if (image != actualImage) { + if (actualImage === null) { + setActualImage(image) + } else { + setActualImage(null) + } + } + }, [image]) + + React.useLayoutEffect(() => { + const t = setTimeout(() => { + if (image != actualImage) { + setActualImage(image) + setHeaderManga(manga.find(ep => ep?.bannerImage === image) || null) + } + }, 600) + + return () => { + clearTimeout(t) + } + }, [image]) + + useEffect(() => { + if (actualImage) { + setPrevImage(actualImage) + setHeaderManga(manga.find(ep => ep?.bannerImage === actualImage) || null) + } + }, [actualImage]) + + const { y } = useWindowScroll() + + useEffect(() => { + if (y > 100) + setDimmed(true) + else + setDimmed(false) + }, [(y > 100)]) + + if (!image) return null + + return ( + <> + <div + data-library-header-container + className={cn( + "LIB_HEADER_CONTAINER __header h-[25rem] z-[1] top-0 w-full absolute group/library-header", + // Make it not fixed when the user scrolls down if a background image is set + !ts.libraryScreenCustomBackgroundImage && "fixed", + )} + > + <div + data-library-header-banner-top-gradient + className={cn( + "w-full z-[3] absolute bottom-[-10rem] h-[10rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent", + !ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE, + )} + /> + + <motion.div + data-library-header-inner-container + initial={{ opacity: 0 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0 }} + transition={{ duration: 1, delay: 0.2 }} + className={cn( + "LIB_HEADER_INNER_CONTAINER h-full z-[0] w-full flex-none object-cover object-center absolute top-0 overflow-hidden bg-[--background]", + !ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE, + )} + > + + {!ts.disableSidebarTransparency && <div + data-library-header-banner-top-gradient + className="hidden lg:block h-full absolute z-[2] w-[20%] opacity-70 left-0 top-0 bg-gradient bg-gradient-to-r from-[var(--background)] to-transparent" + />} + + <div + data-library-header-banner-bottom-gradient + className="w-full z-[3] opacity-70 lg:opacity-50 absolute top-0 h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent" + /> + + {/*<div*/} + {/* className="LIB_HEADER_TOP_FADE w-full absolute z-[2] top-0 h-[10rem] opacity-20 bg-gradient-to-b from-[var(--background)] to-transparent via"*/} + {/*/>*/} + <AnimatePresence> + {!!actualImage && ( + <motion.div + key="manga-library-header-banner-image-container" + data-library-header-image-container + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0.4 }} + transition={{ duration: 0.5 }} + > + <MotionImage + data-library-header-banner-image + src={getImageUrl(actualImage || prevImage!)} + alt="banner image" + fill + quality={100} + priority + sizes="100vw" + className={cn( + "object-cover object-center z-[1] opacity-100 transition-opacity duration-700 scroll-locked-offset", + { "opacity-5": dimmed }, + )} + initial={{ scale: 1.01, y: 0 }} + animate={{ + scale: Math.min(1 + y * 0.0002, 1.03), + // y: Math.max(y * -0.9, -10) + }} + exit={{ scale: 1.01, y: 0 }} + transition={{ duration: 0.6, ease: "easeOut" }} + /> + </motion.div> + )} + </AnimatePresence> + {/* {prevImage && <MotionImage + data-library-header-banner-previous-image + src={getImageUrl(prevImage)} + alt="banner image" + fill + quality={100} + priority + sizes="100vw" + className={cn( + "object-cover object-center z-[1] opacity-50 transition-opacity scroll-locked-offset", + { "opacity-5": dimmed }, + )} + initial={{ scale: 1, y: 0 }} + animate={{ scale: Math.min(1 + y * 0.0002, 1.03), y: Math.max(y * -0.9, -10) }} + exit={{ scale: 1, y: 0 }} + transition={{ duration: 0.6, ease: "easeOut" }} + />} */} + <div + data-library-header-banner-bottom-fade + className="LIB_HEADER_IMG_BOTTOM_FADE w-full z-[2] absolute bottom-0 h-[20rem] lg:h-[15rem] bg-gradient-to-t from-[--background] lg:via-opacity-50 lg:via-10% to-transparent" + /> + </motion.div> + </div> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_components/manga-recommendations.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_components/manga-recommendations.tsx new file mode 100644 index 0000000..fb20845 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_components/manga-recommendations.tsx @@ -0,0 +1,72 @@ +import { AL_MangaDetailsById_Media, Manga_Entry, Nullish } from "@/api/generated/types" +import { MediaCardGrid } from "@/app/(main)/_features/media/_components/media-card-grid" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { Badge } from "@/components/ui/badge" +import capitalize from "lodash/capitalize" +import React from "react" + +type MangaRecommendationsProps = { + entry: Nullish<Manga_Entry> + details: Nullish<AL_MangaDetailsById_Media> +} + +export function MangaRecommendations(props: MangaRecommendationsProps) { + + const { + entry, + details, + ...rest + } = props + + const anime = details?.relations?.edges?.filter(Boolean)?.filter(edge => edge?.node?.type === "ANIME" && + (edge?.node?.format === "TV" || edge?.node?.format === "MOVIE" || edge?.node?.format === "TV_SHORT"))?.slice(0, 3) + + const recommendations = details?.recommendations?.edges?.map(edge => edge?.node?.mediaRecommendation)?.filter(Boolean)?.slice(0, 6) || [] + + if (!entry || !details) return null + + return ( + <div className="space-y-4" data-manga-recommendations-container> + {!!anime?.length && ( + <> + <h2>Relations</h2> + <MediaCardGrid> + {anime?.toSorted((a, b) => (a.node?.format === "TV" && b.node?.format !== "TV") + ? -1 + : (a.node?.format !== "TV" && b.node?.format === "TV") ? 1 : 0).map(edge => { + return <div key={edge?.node?.id!} className="col-span-1"> + <MediaEntryCard + media={edge?.node!} + showLibraryBadge + showTrailer + overlay={<Badge + className="font-semibold text-white bg-gray-950 !bg-opacity-90 rounded-[--radius-md] text-base rounded-bl-none rounded-tr-none" + intent="gray" + size="lg" + >{edge?.node?.format === "MOVIE" + ? capitalize(edge.relationType || "").replace("_", " ") + " (Movie)" + : capitalize(edge.relationType || "").replace("_", " ")}</Badge>} + type="anime" + /> + </div> + })} + </MediaCardGrid> + </> + )} + {recommendations.length > 0 && <> + <h2>Recommendations</h2> + <MediaCardGrid> + {recommendations.map(media => { + return <div key={media.id} className="col-span-1"> + <MediaEntryCard + media={media!} + type="manga" + /> + </div> + })} + </MediaCardGrid> + </>} + </div> + ) +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_components/meta-section.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_components/meta-section.tsx new file mode 100644 index 0000000..1fe22fb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_components/meta-section.tsx @@ -0,0 +1,94 @@ +"use client" +import { AL_MangaDetailsById_Media, Manga_Entry } from "@/api/generated/types" +import { + AnimeEntryRankings, + MediaEntryAudienceScore, + MediaEntryGenresList, +} from "@/app/(main)/_features/media/_components/media-entry-metadata-components" +import { + MediaPageHeader, + MediaPageHeaderDetailsContainer, + MediaPageHeaderEntryDetails, +} from "@/app/(main)/_features/media/_components/media-page-header-components" +import { MediaSyncTrackButton } from "@/app/(main)/_features/media/_containers/media-sync-track-button" +import { SeaLink } from "@/components/shared/sea-link" +import { IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { ThemeMediaPageInfoBoxSize, useThemeSettings } from "@/lib/theme/hooks" +import React from "react" +import { SiAnilist } from "react-icons/si" +import { PluginMangaPageButtons } from "../../_features/plugin/actions/plugin-actions" + + +export function MetaSection(props: { entry: Manga_Entry | undefined, details: AL_MangaDetailsById_Media | undefined }) { + + const { entry, details } = props + const ts = useThemeSettings() + + if (!entry?.media) return null + + const Details = () => ( + <> + <div + className={cn( + "flex gap-2 flex-wrap items-center", + ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "justify-center lg:justify-start lg:max-w-[65vw]", + )} + > + <MediaEntryAudienceScore meanScore={entry.media?.meanScore} badgeClass="bg-transparent" /> + + <MediaEntryGenresList genres={details?.genres} type="manga" /> + </div> + + <AnimeEntryRankings rankings={details?.rankings} /> + </> + ) + + return ( + <MediaPageHeader + backgroundImage={entry.media?.bannerImage} + coverImage={entry.media?.coverImage?.extraLarge} + > + + <MediaPageHeaderDetailsContainer> + + <MediaPageHeaderEntryDetails + coverImage={entry.media?.coverImage?.extraLarge || entry.media?.coverImage?.large} + title={entry.media?.title?.userPreferred} + englishTitle={entry.media?.title?.english} + romajiTitle={entry.media?.title?.romaji} + startDate={entry.media?.startDate} + season={entry.media?.season} + color={entry.media?.coverImage?.color} + progressTotal={entry.media?.chapters} + status={entry.media?.status} + description={entry.media?.description} + listData={entry.listData} + media={entry.media} + type="manga" + > + {ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && <Details />} + </MediaPageHeaderEntryDetails> + + {ts.mediaPageBannerInfoBoxSize !== ThemeMediaPageInfoBoxSize.Fluid && <Details />} + + + <div className="w-full flex flex-wrap gap-4 items-center" data-manga-meta-section-buttons-container> + + <SeaLink href={`https://anilist.co/manga/${entry.mediaId}`} target="_blank"> + <IconButton intent="gray-link" className="px-0" icon={<SiAnilist className="text-lg" />} /> + </SeaLink> + + {ts.mediaPageBannerInfoBoxSize !== ThemeMediaPageInfoBoxSize.Fluid && <div className="flex-1 hidden lg:flex"></div>} + + <MediaSyncTrackButton mediaId={entry.mediaId} type="manga" size="md" /> + + <PluginMangaPageButtons media={entry.media} /> + </div> + + </MediaPageHeaderDetailsContainer> + </MediaPageHeader> + + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-button.tsx new file mode 100644 index 0000000..83d90f8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-button.tsx @@ -0,0 +1,39 @@ +"use client" +import { __manga_chapterDownloadsDrawerIsOpenAtom } from "@/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-drawer" +import { Button } from "@/components/ui/button" +import { useSetAtom } from "jotai/react" +import { usePathname } from "next/navigation" +import React from "react" +import { FaDownload } from "react-icons/fa" + +type ChapterDownloadsButtonProps = { + children?: React.ReactNode +} + +export function ChapterDownloadsButton(props: ChapterDownloadsButtonProps) { + + const { + children, + ...rest + } = props + + const pathname = usePathname() + + const openDownloadQueue = useSetAtom(__manga_chapterDownloadsDrawerIsOpenAtom) + + if (!pathname.startsWith("/manga")) return null + + return ( + <> + <Button + onClick={() => openDownloadQueue(true)} + intent="white-subtle" + rounded + size="sm" + leftIcon={<FaDownload />} + > + Downloads + </Button> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-drawer.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-drawer.tsx new file mode 100644 index 0000000..d4cd25f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-drawer.tsx @@ -0,0 +1,296 @@ +"use client" +import { Manga_Collection } from "@/api/generated/types" +import { useGetMangaCollection } from "@/api/hooks/manga.hooks" +import { useGetMangaDownloadsList } from "@/api/hooks/manga_download.hooks" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" + +import { useHandleMangaChapterDownloadQueue } from "@/app/(main)/manga/_lib/handle-manga-downloads" +import { LuffyError } from "@/components/shared/luffy-error" +import { SeaLink } from "@/components/shared/sea-link" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { ProgressBar } from "@/components/ui/progress-bar" +import { ScrollArea } from "@/components/ui/scroll-area" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React from "react" +import { MdClear } from "react-icons/md" +import { PiWarningOctagonDuotone } from "react-icons/pi" +import { TbWorldDownload } from "react-icons/tb" + +export const __manga_chapterDownloadsDrawerIsOpenAtom = atom(false) + +type ChapterDownloadQueueDrawerProps = {} + +export function ChapterDownloadsDrawer(props: ChapterDownloadQueueDrawerProps) { + + const {} = props + + const [isOpen, setIsOpen] = useAtom(__manga_chapterDownloadsDrawerIsOpenAtom) + + const { data: mangaCollection } = useGetMangaCollection() + + return ( + <> + <Modal + open={isOpen} + onOpenChange={setIsOpen} + contentClass="max-w-5xl" + title="Downloaded chapters" + data-chapter-downloads-modal + > + + <div className="py-4 space-y-8" data-chapter-downloads-modal-content> + <ChapterDownloadQueue mangaCollection={mangaCollection} /> + + <ChapterDownloadList /> + </div> + + </Modal> + </> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type ChapterDownloadQueueProps = { + mangaCollection: Manga_Collection | undefined +} + +export function ChapterDownloadQueue(props: ChapterDownloadQueueProps) { + + const { + mangaCollection, + ...rest + } = props + + const { + downloadQueue, + downloadQueueLoading, + downloadQueueError, + startDownloadQueue, + stopDownloadQueue, + isStartingDownloadQueue, + isStoppingDownloadQueue, + resetErroredChapters, + isResettingErroredChapters, + clearDownloadQueue, + isClearingDownloadQueue, + } = useHandleMangaChapterDownloadQueue() + + const isMutating = isStartingDownloadQueue || isStoppingDownloadQueue || isResettingErroredChapters || isClearingDownloadQueue + + return ( + <> + <div className="space-y-4" data-chapter-download-queue-container> + + <div className="flex w-full items-center" data-chapter-download-queue-header> + <h3>Queue</h3> + <div className="flex flex-1" data-chapter-download-queue-header-spacer></div> + {(!downloadQueueLoading && !downloadQueueError) && + <div className="flex gap-2 items-center" data-chapter-download-queue-header-actions> + + {!!downloadQueue?.find(n => n.status === "errored") && <Button + intent="warning-outline" + size="sm" + disabled={isMutating} + onClick={() => resetErroredChapters()} + loading={isResettingErroredChapters} + > + Reset errored chapters + </Button>} + + {!!downloadQueue?.find(n => n.status === "downloading") ? ( + <> + <Button + intent="alert-subtle" + size="sm" + onClick={() => stopDownloadQueue()} + loading={isStoppingDownloadQueue} + > + Stop + </Button> + </> + ) : ( + <> + {!!downloadQueue?.length && <Button + intent="alert-subtle" + size="sm" + disabled={isMutating} + onClick={() => clearDownloadQueue()} + leftIcon={<MdClear className="text-xl" />} + loading={isClearingDownloadQueue} + > + Clear all + </Button>} + + {(!!downloadQueue?.length && !!downloadQueue?.find(n => n.status === "not_started")) && <Button + intent="success" + size="sm" + disabled={isMutating} + onClick={() => startDownloadQueue()} + leftIcon={<TbWorldDownload className="text-xl" />} + loading={isStartingDownloadQueue} + > + Start + </Button>} + </> + )} + </div>} + </div> + + <Card className="p-4 space-y-2" data-chapter-download-queue-card> + + {downloadQueueLoading + ? <LoadingSpinner /> + : (downloadQueueError ? <LuffyError title="Oops!"> + <p>Could not fetch the download queue</p> + </LuffyError> : null)} + + {!!downloadQueue?.length ? ( + <ScrollArea className="h-[14rem]" data-chapter-download-queue-scroll-area> + <div className="space-y-2" data-chapter-download-queue-scroll-area-content> + {downloadQueue.map(item => { + + const media = mangaCollection?.lists?.flatMap(n => n.entries)?.find(n => n?.media?.id === item.mediaId)?.media + + return ( + <Card + key={item.mediaId + item.provider + item.chapterId} className={cn( + "px-3 py-2 bg-gray-900 space-y-1.5", + item.status === "errored" && "border-[--orange]", + )} + > + <div className="flex items-center gap-2"> + {!!media && <SeaLink + className="font-semibold max-w-[180px] text-ellipsis truncate underline" + href={`/manga/entry?id=${media.id}`} + >{media.title?.userPreferred}</SeaLink>} + <p>Chapter {item.chapterNumber} <span className="text-[--muted] italic">(id: {item.chapterId})</span> + </p> + {item.status === "errored" && ( + <div className="flex gap-1 items-center text-[--orange]"> + <PiWarningOctagonDuotone className="text-2xl text-[--orange]" /> + <p> + Errored + </p> + </div> + )} + </div> + {item.status === "downloading" && ( + <ProgressBar size="sm" isIndeterminate /> + )} + </Card> + ) + })} + </div> + </ScrollArea> + ) : ((!downloadQueueLoading && !downloadQueueError) && ( + <p className="text-center text-[--muted] italic" data-chapter-download-queue-empty-state> + Nothing in the queue + </p> + ))} + + </Card> + + </div> + </> + ) +} + +///////////////////////////////////// + +type ChapterDownloadListProps = {} + +export function ChapterDownloadList(props: ChapterDownloadListProps) { + + const {} = props + + const { data, isLoading, isError } = useGetMangaDownloadsList() + + return ( + <> + <div className="space-y-4" data-chapter-download-list-container> + + <div className="flex w-full items-center" data-chapter-download-list-header> + <h3>Downloaded</h3> + <div className="flex flex-1" data-chapter-download-list-header-spacer></div> + </div> + + <div className="py-4 space-y-2" data-chapter-download-list-content> + + {isLoading + ? <LoadingSpinner /> + : (isError ? <LuffyError title="Oops!"> + <p>Could not fetch the download queue</p> + </LuffyError> : null)} + + {!!data?.length ? ( + <> + {data?.filter(n => !n.media) + .sort((a, b) => a.mediaId - b.mediaId) + .sort((a, b) => Object.values(b.downloadData).flatMap(n => n).length - Object.values(a.downloadData) + .flatMap(n => n).length) + .map(item => { + return ( + <Card + key={item.mediaId} className={cn( + "px-3 py-2 bg-gray-900 space-y-1", + )} + > + <SeaLink + className="font-semibold underline" + href={`/manga/entry?id=${item.mediaId}`} + >Media {item.mediaId}</SeaLink> + + <div className="flex items-center gap-2"> + <p>{Object.values(item.downloadData) + .flatMap(n => n).length} chapters</p> - <em className="text-[--muted]">Not in your AniList + collection</em> + </div> + </Card> + ) + })} + + <div + data-chapter-download-list-media-grid + className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4" + > + {data?.filter(n => !!n.media) + .sort((a, b) => a.mediaId - b.mediaId) + .sort((a, b) => Object.values(b.downloadData).flatMap(n => n).length - Object.values(a.downloadData) + .flatMap(n => n).length) + .map(item => { + const nb = Object.values(item.downloadData).flatMap(n => n).length + return <div key={item.media?.id!} className="col-span-1"> + <MediaEntryCard + media={item.media!} + type="manga" + hideUnseenCountBadge + hideAnilistEntryEditButton + overlay={<Badge + className="font-semibold text-white bg-gray-950 !bg-opacity-100 rounded-[--radius-md] text-base rounded-bl-none rounded-tr-none" + intent="gray" + size="lg" + >{nb} chapter{nb === 1 ? "" : "s"}</Badge>} + /> + </div> + })} + </div> + </> + ) : ((!isLoading && !isError) && ( + <p className="text-center text-[--muted] italic" data-chapter-download-list-empty-state> + No chapters downloaded + </p> + ))} + + </div> + + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/_components/chapter-list-bulk-actions.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/_components/chapter-list-bulk-actions.tsx new file mode 100644 index 0000000..91d19c6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/_components/chapter-list-bulk-actions.tsx @@ -0,0 +1,39 @@ +import { HibikeManga_ChapterDetails } from "@/api/generated/types" +import { Button } from "@/components/ui/button" +import React from "react" +import { FaDownload } from "react-icons/fa" + +type ChapterListBulkActionsProps = { + rowSelectedChapters: HibikeManga_ChapterDetails[] | undefined + onDownloadSelected: (chapters: HibikeManga_ChapterDetails[]) => void +} + +export function ChapterListBulkActions(props: ChapterListBulkActionsProps) { + + const { + rowSelectedChapters, + onDownloadSelected, + ...rest + } = props + + const handleDownloadSelected = React.useCallback(() => { + onDownloadSelected(rowSelectedChapters || []) + }, [onDownloadSelected, rowSelectedChapters]) + + if (rowSelectedChapters?.length === 0) return null + + return ( + <> + <Button + onClick={handleDownloadSelected} + intent="white" + size="sm" + leftIcon={<FaDownload />} + className="animate-pulse" + data-download-selected-chapters-button + > + Download selected chapters ({rowSelectedChapters?.length}) + </Button> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/chapter-list.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/chapter-list.tsx new file mode 100644 index 0000000..b648875 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/chapter-list.tsx @@ -0,0 +1,502 @@ +import { AL_MangaDetailsById_Media, HibikeManga_ChapterDetails, Manga_Entry, Manga_MediaDownloadData } from "@/api/generated/types" +import { useEmptyMangaEntryCache } from "@/api/hooks/manga.hooks" +import { SeaCommandInjectableItem, useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject" +import { ChapterListBulkActions } from "@/app/(main)/manga/_containers/chapter-list/_components/chapter-list-bulk-actions" +import { DownloadedChapterList } from "@/app/(main)/manga/_containers/chapter-list/downloaded-chapter-list" +import { MangaManualMappingModal } from "@/app/(main)/manga/_containers/chapter-list/manga-manual-mapping-modal" +import { ChapterReaderDrawer } from "@/app/(main)/manga/_containers/chapter-reader/chapter-reader-drawer" +import { __manga_selectedChapterAtom } from "@/app/(main)/manga/_lib/handle-chapter-reader" +import { useHandleMangaChapters } from "@/app/(main)/manga/_lib/handle-manga-chapters" +import { useHandleDownloadMangaChapter } from "@/app/(main)/manga/_lib/handle-manga-downloads" +import { getChapterNumberFromChapter, useMangaChapterListRowSelection, useMangaDownloadDataUtils } from "@/app/(main)/manga/_lib/handle-manga-utils" +import { LANGUAGES_LIST } from "@/app/(main)/manga/_lib/language-map" +import { primaryPillCheckboxClasses } from "@/components/shared/classnames" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { LuffyError } from "@/components/shared/luffy-error" +import { Button, IconButton } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { useUpdateEffect } from "@/components/ui/core/hooks" +import { DataGrid, defineDataGridColumns } from "@/components/ui/datagrid" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Select } from "@/components/ui/select" +import { useSetAtom } from "jotai/react" +import React from "react" +import { FaRedo } from "react-icons/fa" +import { GiOpenBook } from "react-icons/gi" +import { IoBookOutline, IoLibrary } from "react-icons/io5" +import { LuSearch } from "react-icons/lu" +import { MdOutlineDownloadForOffline, MdOutlineOfflinePin } from "react-icons/md" + +type ChapterListProps = { + mediaId: string | null + entry: Manga_Entry + details: AL_MangaDetailsById_Media | undefined + downloadData: Manga_MediaDownloadData | undefined + downloadDataLoading: boolean +} + +export function ChapterList(props: ChapterListProps) { + + const { + mediaId, + entry, + details, + downloadData, + downloadDataLoading, + ...rest + } = props + + /** + * Find chapter container + */ + const { + selectedExtension, + providerExtensionsLoading, + // Selected provider + providerOptions, // For dropdown + selectedProvider, // Current provider (id) + setSelectedProvider, + // Filters + selectedFilters, + setSelectedLanguage, + setSelectedScanlator, + languageOptions, + scanlatorOptions, + // Chapters + chapterContainer, + chapterContainerLoading, + chapterContainerError, + } = useHandleMangaChapters(mediaId) + + + // Keep track of chapter numbers as integers + // This is used to filter the chapters + // [id]: number + const chapterIdToNumbersMap = React.useMemo(() => { + const map = new Map<string, number>() + + for (const chapter of chapterContainer?.chapters ?? []) { + map.set(chapter.id, getChapterNumberFromChapter(chapter.chapter)) + } + + return map + }, [chapterContainer?.chapters]) + + const [showUnreadChapter, setShowUnreadChapter] = React.useState(false) + const [showDownloadedChapters, setShowDownloadedChapters] = React.useState(false) + + /** + * Set selected chapter + */ + const setSelectedChapter = useSetAtom(__manga_selectedChapterAtom) + /** + * Clear manga cache + */ + const { mutate: clearMangaCache, isPending: isClearingMangaCache } = useEmptyMangaEntryCache() + /** + * Download chapter + */ + const { downloadChapters, isSendingDownloadRequest } = useHandleDownloadMangaChapter(mediaId) + /** + * Download data utils + */ + const { + isChapterQueued, + isChapterDownloaded, + isChapterLocal, + } = useMangaDownloadDataUtils(downloadData, downloadDataLoading) + + const { inject, remove } = useSeaCommandInject() + + /** + * Function to filter unread chapters + */ + const retainUnreadChapters = React.useCallback((chapter: HibikeManga_ChapterDetails) => { + if (!entry.listData || !chapterIdToNumbersMap.has(chapter.id) || !entry.listData?.progress) return true + + const chapterNumber = chapterIdToNumbersMap.get(chapter.id) + return !!chapterNumber && chapterNumber > entry.listData?.progress + }, [chapterIdToNumbersMap, chapterContainer, entry]) + + const confirmReloadSource = useConfirmationDialog({ + title: "Reload sources", + actionIntent: "primary", + actionText: "Reload", + description: "This action will empty the cache for this manga and fetch the latest data from the selected source.", + onConfirm: () => { + if (mediaId) { + clearMangaCache({ mediaId: Number(mediaId) }) + } + }, + }) + + /** + * Chapter columns + */ + const columns = React.useMemo(() => defineDataGridColumns<HibikeManga_ChapterDetails>(() => [ + { + accessorKey: "title", + header: "Name", + size: 90, + }, + ...(selectedExtension?.settings?.supportsMultiScanlator ? [{ + id: "scanlator", + header: "Scanlator", + size: 40, + accessorFn: (row: any) => row.scanlator, + enableSorting: true, + }] : []), + ...(selectedExtension?.settings?.supportsMultiLanguage ? [{ + id: "language", + header: "Language", + size: 20, + accessorFn: (row: any) => LANGUAGES_LIST[row.language]?.nativeName || row.language, + enableSorting: true, + }] : []), + { + id: "number", + header: "Number", + size: 10, + enableSorting: true, + accessorFn: (row) => { + return chapterIdToNumbersMap.get(row.id) + }, + }, + { + id: "_actions", + size: 10, + enableSorting: false, + enableGlobalFilter: false, + cell: ({ row }) => { + return ( + <div className="flex justify-end gap-2 items-center w-full"> + {(!isChapterLocal(row.original) && !isChapterDownloaded(row.original) && !isChapterQueued(row.original)) && <IconButton + intent="gray-basic" + size="sm" + disabled={isSendingDownloadRequest} + onClick={() => downloadChapters([row.original])} + icon={<MdOutlineDownloadForOffline className="text-2xl" />} + />} + {isChapterQueued(row.original) && <p className="text-[--muted]">Queued</p>} + {isChapterDownloaded(row.original) && <p className="text-[--muted] px-1"><MdOutlineOfflinePin className="text-2xl" /></p>} + <IconButton + intent="gray-subtle" + size="sm" + onClick={() => setSelectedChapter({ + chapterId: row.original.id, + chapterNumber: row.original.chapter, + provider: row.original.provider, + mediaId: Number(mediaId), + })} + icon={<GiOpenBook />} + /> + </div> + ) + }, + }, + ]), [chapterIdToNumbersMap, selectedExtension, isSendingDownloadRequest, isChapterDownloaded, downloadData, mediaId]) + + const unreadChapters = React.useMemo(() => chapterContainer?.chapters?.filter(ch => retainUnreadChapters(ch)) ?? [], [chapterContainer, entry]) + const allChapters = React.useMemo(() => chapterContainer?.chapters?.toReversed() ?? [], [chapterContainer]) + + /** + * Set "showUnreadChapter" state if there are unread chapters + */ + useUpdateEffect(() => { + setShowUnreadChapter(!!unreadChapters.length) + }, [unreadChapters?.length]) + + /** + * Filter chapters based on state + */ + const chapters = React.useMemo(() => { + let d = showUnreadChapter ? unreadChapters : allChapters + if (showDownloadedChapters) { + d = d.filter(ch => isChapterDownloaded(ch) || isChapterQueued(ch)) + } + return d + }, [ + showUnreadChapter, unreadChapters, allChapters, showDownloadedChapters, downloadData, selectedExtension, + ]) + + const { + rowSelectedChapters, + onRowSelectionChange, + rowSelection, + setRowSelection, + resetRowSelection, + } = useMangaChapterListRowSelection() + + React.useEffect(() => { + resetRowSelection() + }, []) + + // Inject chapter list command + React.useEffect(() => { + if (!chapterContainer?.chapters?.length) return + + const nextChapter = unreadChapters[0] + const upcomingChapters = unreadChapters.slice(0, 10) + + const commandItems: SeaCommandInjectableItem[] = [ + // Next chapter + ...(nextChapter ? [{ + data: nextChapter, + id: `next-chapter-${nextChapter.id}`, + value: `${nextChapter.chapter}`, + heading: "Next Chapter", + priority: 2, + render: () => ( + <div className="flex gap-1 items-center w-full"> + <p className="max-w-[70%] truncate">Chapter {nextChapter.chapter}</p> + {nextChapter.scanlator && ( + <p className="text-[--muted]">({nextChapter.scanlator})</p> + )} + </div> + ), + onSelect: ({ ctx }) => { + setSelectedChapter({ + chapterId: nextChapter.id, + chapterNumber: nextChapter.chapter, + provider: nextChapter.provider, + mediaId: Number(mediaId), + }) + ctx.close() + }, + } as SeaCommandInjectableItem] : []), + // Upcoming chapters + ...upcomingChapters.map(chapter => ({ + data: chapter, + id: `chapter-${chapter.id}`, + value: `${chapter.chapter}`, + heading: "Upcoming Chapters", + priority: 1, + render: () => ( + <div className="flex gap-1 items-center w-full"> + <p className="max-w-[70%] truncate">Chapter {chapter.chapter}</p> + {chapter.scanlator && ( + <p className="text-[--muted]">({chapter.scanlator})</p> + )} + </div> + ), + onSelect: ({ ctx }) => { + setSelectedChapter({ + chapterId: chapter.id, + chapterNumber: chapter.chapter, + provider: chapter.provider, + mediaId: Number(mediaId), + }) + ctx.close() + }, + } as SeaCommandInjectableItem)), + ] + + inject("manga-chapters", { + items: commandItems, + filter: ({ item, input }) => { + if (!input) return true + return item.value.toLowerCase().includes(input.toLowerCase()) || + (item.data.title?.toLowerCase() || "").includes(input.toLowerCase()) + }, + priority: 100, + }) + + return () => remove("manga-chapters") + }, [chapterContainer?.chapters, unreadChapters, mediaId]) + + if (providerExtensionsLoading) return <LoadingSpinner /> + + return ( + <div + className="space-y-4" + data-chapter-list-container + data-selected-filters={JSON.stringify(selectedFilters)} + data-selected-provider={JSON.stringify(selectedProvider)} + > + + <div data-chapter-list-header-container className="flex flex-wrap gap-2 items-center"> + <Select + fieldClass="w-fit" + options={providerOptions} + value={selectedProvider || ""} + onValueChange={v => setSelectedProvider({ + mId: mediaId, + provider: v, + })} + leftAddon="Source" + size="sm" + disabled={isClearingMangaCache} + /> + + <Button + leftIcon={<FaRedo />} + intent="gray-outline" + onClick={() => confirmReloadSource.open()} + loading={isClearingMangaCache} + size="sm" + > + Reload sources + </Button> + + <MangaManualMappingModal entry={entry}> + <Button + leftIcon={<LuSearch className="text-lg" />} + intent="gray-outline" + size="sm" + > + Manual match + </Button> + </MangaManualMappingModal> + </div> + + {(selectedExtension?.settings?.supportsMultiLanguage || selectedExtension?.settings?.supportsMultiScanlator) && ( + <div data-chapter-list-header-filters-container className="flex gap-2 items-center"> + {selectedExtension?.settings?.supportsMultiScanlator && ( + <> + <Select + fieldClass="w-64" + options={scanlatorOptions} + placeholder="All" + value={selectedFilters.scanlators[0] || ""} + onValueChange={v => setSelectedScanlator({ + mId: mediaId, + scanlators: [v], + })} + leftAddon="Scanlator" + // intent="filled" + // size="sm" + /> + </> + )} + {selectedExtension?.settings?.supportsMultiLanguage && ( + <Select + fieldClass="w-64" + options={languageOptions} + placeholder="All" + value={selectedFilters.language} + onValueChange={v => setSelectedLanguage({ + mId: mediaId, + language: v, + })} + leftAddon="Language" + // intent="filled" + // size="sm" + /> + )} + </div> + )} + + {(chapterContainerLoading || isClearingMangaCache) ? <LoadingSpinner /> : ( + chapterContainerError ? <LuffyError title="No chapters found"><p>Try another source</p></LuffyError> : ( + <> + + {chapterContainer?.chapters?.length === 0 && ( + <LuffyError title="No chapters found"><p>Try another source</p></LuffyError> + )} + + {!!chapterContainer?.chapters?.length && ( + <> + <div data-chapter-list-header-container className="flex gap-2 items-center w-full pb-2"> + <h2 className="px-1">Chapters</h2> + <div className="flex flex-1"></div> + <div> + {!!unreadChapters?.length && <Button + intent="white" + rounded + leftIcon={<IoBookOutline />} + onClick={() => { + setSelectedChapter({ + chapterId: unreadChapters[0].id, + chapterNumber: unreadChapters[0].chapter, + provider: unreadChapters[0].provider, + mediaId: Number(mediaId), + }) + }} + > + Continue reading + </Button>} + </div> + </div> + + <div data-chapter-list-bulk-actions-container className="space-y-4 border rounded-[--radius-md] bg-[--paper] p-4"> + + <div data-chapter-list-bulk-actions-checkboxes-container className="flex flex-wrap items-center gap-4"> + <Checkbox + label="Show unread" + value={showUnreadChapter} + onValueChange={v => setShowUnreadChapter(v as boolean)} + fieldClass="w-fit" + {...primaryPillCheckboxClasses} + /> + {selectedProvider !== "local-manga" && <Checkbox + label={<span className="flex gap-2 items-center"><IoLibrary /> Show downloaded</span>} + value={showDownloadedChapters} + onValueChange={v => setShowDownloadedChapters(v as boolean)} + fieldClass="w-fit" + {...primaryPillCheckboxClasses} + />} + </div> + + <ChapterListBulkActions + rowSelectedChapters={rowSelectedChapters} + onDownloadSelected={chapters => { + downloadChapters(chapters) + resetRowSelection() + }} + /> + + <DataGrid<HibikeManga_ChapterDetails> + columns={columns} + data={chapters} + rowCount={chapters.length} + isLoading={chapterContainerLoading} + rowSelectionPrimaryKey="id" + enableRowSelection={row => (!isChapterDownloaded(row.original) && !isChapterQueued(row.original))} + initialState={{ + pagination: { + pageIndex: 0, + pageSize: 10, + }, + }} + state={{ + rowSelection, + }} + hideColumns={[ + { + below: 1000, + hide: ["number"], + }, + { + below: 600, + hide: ["scanlator", "language"], + }, + ]} + onRowSelect={onRowSelectionChange} + onRowSelectionChange={setRowSelection} + className="" + tableClass="table-fixed lg:table-fixed" + /> + </div> + </> + )} + + </> + ) + )} + + {chapterContainer && <ChapterReaderDrawer + entry={entry} + chapterContainer={chapterContainer} + chapterIdToNumbersMap={chapterIdToNumbersMap} + />} + + <DownloadedChapterList + entry={entry} + data={downloadData} + /> + + <ConfirmationDialog {...confirmReloadSource} /> + </div> + ) +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/downloaded-chapter-list.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/downloaded-chapter-list.tsx new file mode 100644 index 0000000..6ce8870 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/downloaded-chapter-list.tsx @@ -0,0 +1,233 @@ +// /* ------------------------------------------------------------------------------------------------- +// * Download List +// * -----------------------------------------------------------------------------------------------*/ + + +import { Manga_Entry, Manga_MediaDownloadData } from "@/api/generated/types" +import { useDeleteMangaDownloadedChapters } from "@/api/hooks/manga_download.hooks" + +import { useSetCurrentChapter } from "@/app/(main)/manga/_lib/handle-chapter-reader" +import { MangaDownloadChapterItem, useMangaEntryDownloadedChapters } from "@/app/(main)/manga/_lib/handle-manga-downloads" +import { useSelectedMangaProvider } from "@/app/(main)/manga/_lib/handle-manga-selected-provider" +import { getChapterNumberFromChapter } from "@/app/(main)/manga/_lib/handle-manga-utils" +import { primaryPillCheckboxClasses } from "@/components/shared/classnames" +import { Button, IconButton } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { DataGrid, defineDataGridColumns } from "@/components/ui/datagrid" +import { DataGridRowSelectedEvent } from "@/components/ui/datagrid/use-datagrid-row-selection" +import { RowSelectionState } from "@tanstack/react-table" +import React from "react" +import { BiTrash } from "react-icons/bi" +import { GiOpenBook } from "react-icons/gi" +import { MdOutlineOfflinePin } from "react-icons/md" + +type DownloadedChapterListProps = { + entry: Manga_Entry + data: Manga_MediaDownloadData | undefined +} + +export function DownloadedChapterList(props: DownloadedChapterListProps) { + + const { + entry, + data, + ...rest + } = props + + const { selectedProvider } = useSelectedMangaProvider(entry.mediaId) + + /** + * Set selected chapter + */ + const setCurrentChapter = useSetCurrentChapter() + + const [showQueued, setShowQueued] = React.useState(false) + + const { mutate: deleteChapters, isPending: isDeletingChapter } = useDeleteMangaDownloadedChapters(String(entry.mediaId), selectedProvider) + + const downloadedOrQueuedChapters = useMangaEntryDownloadedChapters() + + /** + * Transform downloadedOrQueuedChapters into a dynamic list based on the showQueued state + */ + const tableData = React.useMemo(() => { + if (!showQueued) return downloadedOrQueuedChapters + return downloadedOrQueuedChapters.filter(chapter => chapter.queued) + }, [data, downloadedOrQueuedChapters, showQueued]) + + const chapterIdsToNumber = React.useMemo(() => { + const map = new Map<string, number>() + for (const chapter of tableData ?? []) { + map.set(chapter.chapterId, getChapterNumberFromChapter(chapter.chapterNumber)) + } + return map + }, [tableData]) + + const columns = React.useMemo(() => defineDataGridColumns<MangaDownloadChapterItem>(() => [ + { + accessorKey: "chapterNumber", + header: "Chapter", + size: 90, + cell: info => <span>Chapter {info.getValue<string>()}</span>, + }, + { + id: "number", + header: "Number", + size: 10, + enableSorting: true, + accessorFn: (row) => { + return chapterIdsToNumber.get(row.chapterId) + }, + }, + { + accessorKey: "provider", + header: "Provider", + size: 10, + }, + { + accessorKey: "chapterId", + header: "Chapter ID", + size: 20, + cell: info => <span className="text-[--muted] text-sm italic">{info.getValue<string>()}</span>, + }, + { + id: "_actions", + size: 10, + enableSorting: false, + enableGlobalFilter: false, + cell: ({ row }) => { + return ( + <div className="flex justify-end gap-2 items-center w-full"> + {row.original.queued && <p className="text-[--muted]">Queued</p>} + {row.original.downloaded && <p className="text-[--muted] px-1"><MdOutlineOfflinePin className="text-2xl" /></p>} + + {row.original.downloaded && <IconButton + intent="gray-subtle" + size="sm" + onClick={() => { + /** + * Set the provider to the one of the selected chapter + * This is because the provider is needed to fetch the chapter pages + */ + // setProvider({ + // mId: entry.mediaId, + // provider: row.original.provider as Manga_Provider, + // }) + React.startTransition(() => { + // Set the selected chapter + setCurrentChapter({ + chapterId: row.original.chapterId, + chapterNumber: row.original.chapterNumber, + provider: row.original.provider, + mediaId: Number(entry.mediaId), + }) + }) + }} + icon={<GiOpenBook />} + />} + </div> + ) + }, + }, + ]), [tableData, entry?.mediaId, chapterIdsToNumber]) + + const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) + + const [selectedChapters, setSelectedChapters] = React.useState<MangaDownloadChapterItem[]>([]) + + const onSelectChange = React.useCallback((event: DataGridRowSelectedEvent<MangaDownloadChapterItem>) => { + setSelectedChapters(event.data) + }, []) + + const handleDeleteSelectedChapters = React.useCallback(() => { + if (!!selectedChapters.length) { + deleteChapters({ + downloadIds: selectedChapters.map(chapter => ({ + mediaId: entry.mediaId, + provider: chapter.provider, + chapterId: chapter.chapterId, + chapterNumber: chapter.chapterNumber, + })), + }, { + onSuccess: () => { + }, + }) + setRowSelection({}) + setSelectedChapters([]) + } + }, [selectedChapters]) + + if (!data || Object.keys(data.downloaded).length === 0 && Object.keys(data.queued).length === 0) return null + + return ( + <> + <h3 className="pt-8">Downloaded chapters</h3> + + <div data-downloaded-chapter-list-container className="space-y-4 border rounded-[--radius-md] bg-[--paper] p-4"> + + <div className="flex flex-wrap items-center gap-4"> + <Checkbox + label="Show queued" + value={showQueued} + onValueChange={v => setShowQueued(v as boolean)} + fieldClass="w-fit" + {...primaryPillCheckboxClasses} + /> + </div> + + {!!selectedChapters.length && <div + className="" + > + <Button + onClick={handleDeleteSelectedChapters} + intent="alert" + size="sm" + leftIcon={<BiTrash />} + className="" + loading={isDeletingChapter} + > + Delete selected chapters ({selectedChapters?.length}) + </Button> + </div>} + + <DataGrid<MangaDownloadChapterItem> + columns={columns} + data={tableData} + rowCount={tableData.length} + isLoading={false} + rowSelectionPrimaryKey="chapterId" + enableRowSelection={row => (row.original.downloaded)} + initialState={{ + pagination: { + pageIndex: 0, + pageSize: 10, + }, + sorting: [ + { + id: "number", + desc: false, + }, + ], + }} + state={{ + rowSelection, + }} + hideColumns={[ + { + below: 1000, + hide: ["chapterId", "number"], + }, + { + below: 600, + hide: ["provider"], + }, + ]} + onSortingChange={console.log} + onRowSelect={onSelectChange} + onRowSelectionChange={setRowSelection} + className="" + /> + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/manga-manual-mapping-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/manga-manual-mapping-modal.tsx new file mode 100644 index 0000000..3da195b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-list/manga-manual-mapping-modal.tsx @@ -0,0 +1,203 @@ +import { Manga_Entry } from "@/api/generated/types" +import { useGetMangaMapping, useMangaManualMapping, useMangaManualSearch, useRemoveMangaMapping } from "@/api/hooks/manga.hooks" +import { useSelectedMangaProvider } from "@/app/(main)/manga/_lib/handle-manga-selected-provider" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { imageShimmer } from "@/components/shared/image-helpers" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { defineSchema, Field, Form, InferType } from "@/components/ui/form" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { Separator } from "@/components/ui/separator" +import { Tooltip } from "@/components/ui/tooltip" +import Image from "next/image" +import { useRouter } from "next/navigation" +import React from "react" +import { FiSearch } from "react-icons/fi" + +type MangaManualMappingModalProps = { + entry: Manga_Entry + children: React.ReactElement +} + +export function MangaManualMappingModal(props: MangaManualMappingModalProps) { + + const { + children, + entry, + ...rest + } = props + + return ( + <> + <Modal + data-manga-manual-mapping-modal + title="Manual match" + description="Match this manga to a search result" + trigger={children} + contentClass="max-w-4xl" + > + <Content entry={entry} /> + </Modal> + </> + ) +} + +const searchSchema = defineSchema(({ z }) => z.object({ + query: z.string().min(1), +})) + +function Content({ entry }: { entry: Manga_Entry }) { + const router = useRouter() + const { selectedProvider } = useSelectedMangaProvider(entry.mediaId) + + // Get current mapping + const { data: existingMapping, isLoading: mappingLoading } = useGetMangaMapping({ + provider: selectedProvider || undefined, + mediaId: entry.mediaId, + }) + + // Search + const { mutate: search, data: searchResults, isPending: searchLoading, reset } = useMangaManualSearch(entry.mediaId, selectedProvider) + + function handleSearch(data: InferType<typeof searchSchema>) { + if (selectedProvider) { + search({ + provider: selectedProvider, + query: data.query, + }) + } + } + + // Match + const { mutate: match, isPending: isMatching } = useMangaManualMapping() + + // Unmatch + const { mutate: unmatch, isPending: isUnmatching } = useRemoveMangaMapping() + + const [mangaId, setMangaId] = React.useState<string | null>(null) + const confirmMatch = useConfirmationDialog({ + title: "Manual match", + description: "Are you sure you want to match this manga to the search result?", + actionText: "Confirm", + actionIntent: "success", + onConfirm: () => { + if (mangaId && selectedProvider) { + match({ + provider: selectedProvider, + mediaId: entry.mediaId, + mangaId: mangaId, + }) + reset() + setMangaId(null) + } + }, + }) + + return ( + <> + {mappingLoading ? ( + <LoadingSpinner /> + ) : ( + <AppLayoutStack> + <div className="text-center"> + {!!existingMapping?.mangaId ? ( + <AppLayoutStack> + <p> + Current mapping: <span>{existingMapping.mangaId}</span> + </p> + <Button + intent="alert-subtle" loading={isUnmatching} onClick={() => { + if (selectedProvider) { + unmatch({ + provider: selectedProvider, + mediaId: entry.mediaId, + }) + } + }} + > + Remove mapping + </Button> + </AppLayoutStack> + ) : ( + <p className="text-[--muted] italic">No manual match</p> + )} + </div> + + <Separator /> + + <Form schema={searchSchema} onSubmit={handleSearch}> + <div className="flex gap-2 items-center"> + <Field.Text + name="query" + placeholder="Enter a title..." + leftIcon={<FiSearch className="text-xl text-[--muted]" />} + fieldClass="w-full" + /> + + <Field.Submit intent="white" loading={isMatching || searchLoading || mappingLoading} className="">Search</Field.Submit> + </div> + </Form> + + {searchLoading ? <LoadingSpinner /> : ( + <> + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2"> + {searchResults?.map(item => ( + <div + key={item.id} + className={cn( + "group/sr-item col-span-1 aspect-[6/7] rounded-[--radius-md] relative bg-[--background] cursor-pointer transition-opacity", + )} + onClick={() => { + setMangaId(item.id) + React.startTransition(() => { + confirmMatch.open() + }) + }} + > + + {<Image + src={item.image || "/no-cover.png"} + placeholder={imageShimmer(700, 475)} + sizes="10rem" + fill + alt="" + className={cn( + "object-center object-cover lg:opacity-50 rounded-[--radius-md] transition-opacity lg:group-hover/sr-item:opacity-100", + )} + />} + {/*<Badge intent="gray-solid" size="sm" className="absolute text-sm top-1 left-1">*/} + {/* {item.id}*/} + {/*</Badge>*/} + <Tooltip + trigger={<p className="line-clamp-2 text-sm absolute m-2 bottom-0 font-semibold z-[10]"> + {item.title} {item.year && `(${item.year})`} + </p>} + className="z-[10]" + > + <p> + {item.title} {item.year && `(${item.year})`} + </p> + </Tooltip> + <div + className="z-[5] absolute rounded-br-md rounded-bl-md bottom-0 w-full h-[80%] bg-gradient-to-t from-[--background] to-transparent" + /> + {/*<div*/} + {/* className={cn(*/} + {/* "z-[5] absolute top-0 w-full h-[80%] bg-gradient-to-b from-[--background] to-transparent transition-opacity",*/} + {/* )}*/} + {/*/>*/} + </div> + ))} + </div> + </> + )} + + </AppLayoutStack> + )} + + <ConfirmationDialog {...confirmMatch} /> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/_components/chapter-horizontal-reader.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/_components/chapter-horizontal-reader.tsx new file mode 100644 index 0000000..adc4f7f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/_components/chapter-horizontal-reader.tsx @@ -0,0 +1,264 @@ +import { Manga_PageContainer } from "@/api/generated/types" +import { ChapterPage } from "@/app/(main)/manga/_containers/chapter-reader/_components/chapter-page" +import { useHandleChapterPageStatus, useHydrateMangaPaginationMap } from "@/app/(main)/manga/_lib/handle-chapter-reader" +import { + __manga_currentPageIndexAtom, + __manga_currentPaginationMapIndexAtom, + __manga_hiddenBarAtom, + __manga_isLastPageAtom, + __manga_kbsPageLeft, + __manga_kbsPageRight, + __manga_pageFitAtom, + __manga_pageGapAtom, + __manga_pageGapShadowAtom, + __manga_pageOverflowContainerWidthAtom, + __manga_paginationMapAtom, + __manga_readingDirectionAtom, + __manga_readingModeAtom, + MangaPageFit, + MangaReadingDirection, + MangaReadingMode, +} from "@/app/(main)/manga/_lib/manga-chapter-reader.atoms" +import { cn } from "@/components/ui/core/styling" +import { isMobile } from "@/lib/utils/browser-detection" +import { useAtom, useAtomValue, useSetAtom } from "jotai/react" +import mousetrap from "mousetrap" +import React from "react" +import { useUpdateEffect } from "react-use" + +export type MangaHorizontalReaderProps = { + pageContainer: Manga_PageContainer | undefined +} + +export function MangaHorizontalReader({ pageContainer }: MangaHorizontalReaderProps) { + const containerRef = React.useRef<HTMLDivElement>(null) + const pageWrapperRef = React.useRef<HTMLDivElement>(null) + + const readingMode = useAtomValue(__manga_readingModeAtom) + const setIsLastPage = useSetAtom(__manga_isLastPageAtom) + const readingDirection = useAtomValue(__manga_readingDirectionAtom) + const pageFit = useAtomValue(__manga_pageFitAtom) + const pageGap = useAtomValue(__manga_pageGapAtom) + const pageGapShadow = useAtomValue(__manga_pageGapShadowAtom) + const pageOverflowContainerWidth = useAtomValue(__manga_pageOverflowContainerWidthAtom) + + const [hiddenBar, setHideBar] = useAtom(__manga_hiddenBarAtom) + + const kbsPageLeft = useAtomValue(__manga_kbsPageLeft) + const kbsPageRight = useAtomValue(__manga_kbsPageRight) + + // Global page index + const setCurrentPageIndex = useSetAtom(__manga_currentPageIndexAtom) + + const paginationMap = useAtomValue(__manga_paginationMapAtom) + + /** + * For this horizontal reader [currentMapIndex] is the actual variable that controls what pages are displayed + * [currentPageIndex] is updated AFTER [currentMapIndex] changes + */ + const [currentMapIndex, setCurrentMapIndex] = useAtom(__manga_currentPaginationMapIndexAtom) + + useHydrateMangaPaginationMap(pageContainer) + + const { handlePageLoad } = useHandleChapterPageStatus(pageContainer) + + /** + * When the current map index changes, scroll to the top of the container + */ + useUpdateEffect(() => { + containerRef.current?.scrollTo({ top: 0 }) + }, [currentMapIndex]) + + /** + * Set [isLastPage] state when the current map index changes + */ + React.useEffect(() => { + setIsLastPage(Object.keys(paginationMap).length > 0 && currentMapIndex === Object.keys(paginationMap).length - 1) + }, [currentMapIndex, paginationMap]) + + /** + * Function to handle page navigation when the user clicks on the left or right side of the page + */ + const onPaginate = React.useCallback((dir: "left" | "right") => { + const shouldDecrement = dir === "left" && readingDirection === MangaReadingDirection.LTR || dir === "right" && readingDirection === MangaReadingDirection.RTL + + setCurrentMapIndex((draft) => { + const newIdx = shouldDecrement ? draft - 1 : draft + 1 + if (paginationMap.hasOwnProperty(newIdx)) { + return newIdx + } + return draft + }) + }, [paginationMap, readingDirection]) + + /** + * Key bindings for page navigation + */ + React.useEffect(() => { + mousetrap.bind(kbsPageLeft, () => onPaginate("left")) + mousetrap.bind(kbsPageRight, () => onPaginate("right")) + mousetrap.bind("up", () => { + containerRef.current?.scrollBy(0, -100) + }) + mousetrap.bind("down", () => { + containerRef.current?.scrollBy(0, 100) + }) + + return () => { + mousetrap.unbind(kbsPageLeft) + mousetrap.unbind(kbsPageRight) + mousetrap.unbind("up") + mousetrap.unbind("down") + } + }, [kbsPageLeft, kbsPageRight, paginationMap, readingDirection]) + + /** + * Function to handle page navigation when the user clicks on the left or right side of the page + */ + const onPageWrapperClick = React.useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => { + if (!pageWrapperRef.current) return + + if ((e.target as HTMLElement).id === "retry-button") return + if ((e.target as HTMLElement).id === "retry-icon") return + + const { clientX } = e.nativeEvent + const divWidth = pageWrapperRef.current.offsetWidth + const clickPosition = clientX - pageWrapperRef.current.getBoundingClientRect().left + const clickPercentage = (clickPosition / divWidth) * 100 + + if (clickPercentage <= 40) { + onPaginate("left") + } else if (clickPercentage >= 60) { + onPaginate("right") + } else { + if (!isMobile()) { + setHideBar(prev => !prev) + } + } + }, [onPaginate, pageWrapperRef.current]) + + /** + * Update the current page index when the current map index changes + */ + React.useEffect(() => { + if (!pageContainer?.pages?.length) return + + const currentPages = paginationMap[currentMapIndex] + if (!currentPages) return + + setCurrentPageIndex(currentPages[0]) + }, [currentMapIndex]) + + // Current page indexes displayed + const currentPages = React.useMemo(() => paginationMap[currentMapIndex], [currentMapIndex, paginationMap]) + // Two pages are currently displayed + const twoPages = readingMode === MangaReadingMode.DOUBLE_PAGE && currentPages?.length === 2 + // Show shadows + const showShadows = twoPages && pageGap && !(pageFit === MangaPageFit.COVER || pageFit === MangaPageFit.TRUE_SIZE) && pageGapShadow + + return ( + <div + data-chapter-horizontal-reader-container + className={cn( + "h-[calc(100dvh-3rem)] overflow-y-hidden overflow-x-hidden w-full px-4 select-none relative", + hiddenBar && "h-dvh max-h-full", + "focus-visible:outline-none", + pageFit === MangaPageFit.COVER && "overflow-y-auto", + pageFit === MangaPageFit.TRUE_SIZE && "overflow-y-auto", + pageFit === MangaPageFit.LARGER && "overflow-y-auto", + + // Double page + PageFit = LARGER + pageFit === MangaPageFit.LARGER && readingMode === MangaReadingMode.DOUBLE_PAGE && "w-full px-40 mx-auto", + )} + ref={containerRef} + tabIndex={-1} + > + {/*<div className="absolute w-full h-full right-8 flex z-[5] cursor-pointer" tabIndex={-1}>*/} + {/* <div className="h-full w-full flex flex-1 focus-visible:outline-none" onClick={() => onPaginate("left")} tabIndex={-1} />*/} + {/* <div className="h-full w-full flex flex-1 focus-visible:outline-none" onClick={() => onPaginate("right")} tabIndex={-1} />*/} + {/*</div>*/} + <div + data-chapter-horizontal-reader-page-wrapper + className={cn( + "focus-visible:outline-none", + twoPages && readingMode === MangaReadingMode.DOUBLE_PAGE && "flex transition-transform duration-300", + twoPages && readingMode === MangaReadingMode.DOUBLE_PAGE && pageGap && "gap-2", + twoPages && readingMode === MangaReadingMode.DOUBLE_PAGE && "flex-row-reverse", + )} + ref={pageWrapperRef} + onClick={onPageWrapperClick} + > + {pageContainer?.pages?.toSorted((a, b) => a.index - b.index)?.map((page, index) => ( + <ChapterPage + key={page.url} + page={page} + index={index} + readingMode={readingMode} + pageContainer={pageContainer} + onFinishedLoading={() => { + handlePageLoad(index) + }} + containerClass={cn( + "w-full h-[calc(100dvh-3rem)] scroll-div min-h-[200px] relative page", + hiddenBar && "h-dvh max-h-full", + "focus-visible:outline-none", + !currentPages?.includes(index) ? "hidden" : "displayed", + // Double Page, gap + (showShadows && readingMode === MangaReadingMode.DOUBLE_PAGE && currentPages?.[0] === index) + && "before:content-[''] before:absolute before:w-[3%] before:z-[5] before:h-full before:[background:_linear-gradient(-90deg,_rgba(17,_17,_17,_0)_0,_rgba(17,_17,_17,_.3)_100%)]", + (showShadows && readingMode === MangaReadingMode.DOUBLE_PAGE && currentPages?.[1] === index) + && "before:content-[''] before:absolute before:right-0 before:w-[3%] before:z-[5] before:h-full before:[background:_linear-gradient(90deg,_rgba(17,_17,_17,_0)_0,_rgba(17,_17,_17,_.3)_100%)]", + // Page fit + pageFit === MangaPageFit.LARGER && "h-full", + )} + imageClass={cn( + "focus-visible:outline-none", + "h-full inset-0 object-center select-none z-[4] relative", + + // + // Page fit + // + + // Single page + (readingMode === MangaReadingMode.PAGED + && pageFit === MangaPageFit.CONTAIN) && "object-contain w-full h-full", + (readingMode === MangaReadingMode.PAGED + && pageFit === MangaPageFit.LARGER) && "h-auto object-cover mx-auto", + (readingMode === MangaReadingMode.PAGED + && pageFit === MangaPageFit.COVER) && "w-full h-auto", + (readingMode === MangaReadingMode.PAGED + && pageFit === MangaPageFit.TRUE_SIZE) && "object-none h-auto w-auto mx-auto", + // Double page + (readingMode === MangaReadingMode.DOUBLE_PAGE + && pageFit === MangaPageFit.CONTAIN) && "object-contain w-full h-full", + (readingMode === MangaReadingMode.DOUBLE_PAGE + && pageFit === MangaPageFit.LARGER) && "w-[1400px] h-auto object-cover mx-auto", + (readingMode === MangaReadingMode.DOUBLE_PAGE + && pageFit === MangaPageFit.COVER) && "w-full h-auto", + (readingMode === MangaReadingMode.DOUBLE_PAGE + && pageFit === MangaPageFit.TRUE_SIZE) && cn( + "object-none h-auto w-auto", + (twoPages && currentPages?.[0] === index) + ? "mr-auto" : + (twoPages && currentPages?.[1] === index) + ? "ml-auto" : "mx-auto", + ), + + // + // Double page - Page position + // + (twoPages && currentPages?.[0] === index) + && "[object-position:0%_50%] before:content-['']", + (twoPages && currentPages?.[1] === index) + && "[object-position:100%_50%]", + )} + imageWidth={pageFit === MangaPageFit.LARGER && readingMode === MangaReadingMode.PAGED + ? pageOverflowContainerWidth + "%" + : undefined} + /> + ))} + </div> + + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/_components/chapter-page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/_components/chapter-page.tsx new file mode 100644 index 0000000..d298704 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/_components/chapter-page.tsx @@ -0,0 +1,192 @@ +import { HibikeManga_ChapterPage, Manga_PageContainer } from "@/api/generated/types" +import { useMangaReaderUtils } from "@/app/(main)/manga/_lib/handle-manga-utils" +import { IconButton } from "@/components/ui/button" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { logger } from "@/lib/helpers/debug" +import React from "react" +import { FaRedo } from "react-icons/fa" +import { useUpdateEffect } from "react-use" + +type ChapterPageProps = { + children?: React.ReactNode + index: number + pageContainer: Manga_PageContainer | undefined + page: HibikeManga_ChapterPage | undefined + containerClass: string + imageClass: string + readingMode: string + onFinishedLoading?: () => void + imageWidth?: number | string + imageMaxWidth?: number | string + containerMaxWidth?: number | string +} + +export function ChapterPage(props: ChapterPageProps) { + + const { + index, + pageContainer, + page, + containerClass, + imageClass, + children, + readingMode, + onFinishedLoading, + imageWidth, + imageMaxWidth, + containerMaxWidth, + ...rest + } = props + + const ref = React.useRef<HTMLImageElement>(null) + const { isLoaded, isLoading, hasError, retry } = useImageLoadStatus(ref) + + useUpdateEffect(() => { + if (isLoaded && onFinishedLoading) { + onFinishedLoading() + } + }, [isLoaded]) + + const { getChapterPageUrl } = useMangaReaderUtils() + + if (!page) return null + + return ( + <> + <div + data-chapter-page-container + className={containerClass} + style={{ maxWidth: containerMaxWidth }} + id={`page-${index}`} + tabIndex={-1} + > + {isLoading && + <LoadingSpinner data-chapter-page-loading-spinner containerClass="h-full absolute inset-0 z-[1] w-full mx-auto" tabIndex={-1} />} + {hasError && + <div + data-chapter-page-retry-container + className="h-full w-full flex justify-center items-center absolute inset-0 z-[10]" + id="retry-container" + tabIndex={-1} + > + <IconButton intent="white" icon={<FaRedo id="retry-icon" />} onClick={retry} id="retry-button" tabIndex={-1} /> + </div>} + <img + data-chapter-page-image + data-page-index={index} + src={getChapterPageUrl(page.url, pageContainer?.isDownloaded, page.headers)} + alt={`Page ${index}`} + className={imageClass} + style={{ width: imageWidth, maxWidth: imageMaxWidth }} + ref={ref} + tabIndex={-1} + /> + </div> + </> + ) +} + +export const IMAGE_STATUS = { + LOADING: "loading", + RETRYING: "retrying", + LOADED: "loaded", + ERROR: "error", +} + +const useImageLoadStatus = (imageRef: React.RefObject<HTMLImageElement>) => { + const [imageStatus, setImageStatus] = React.useState(IMAGE_STATUS.LOADING) + const retries = React.useRef(0) + + const isRetrying = imageStatus === IMAGE_STATUS.RETRYING + const isLoaded = imageStatus === IMAGE_STATUS.LOADED + const isLoading = + imageStatus === IMAGE_STATUS.LOADING || + imageStatus === IMAGE_STATUS.RETRYING + const hasError = imageStatus === IMAGE_STATUS.ERROR + + const retry = React.useCallback(() => { + retries.current = 0 + setImageStatus(IMAGE_STATUS.LOADING) + const imgSrc = imageRef.current?.src + if (!imgSrc) { + return + } + imageRef.current.src = imgSrc + }, []) + + React.useEffect(() => { + if (!imageRef.current) { + return + } + + // Keep a stable reference to the image + const image = imageRef.current + if (!image) { + return + } + let timerIds: any[] = [] + + if ( + image && + image.complete && + image.naturalWidth > 0 && + timerIds.length === 0 + ) { + setImageStatus(IMAGE_STATUS.LOADED) + return + } + + /** + * if an image errors retry 3 times + * @param {*} event + */ + const handleError = (event: ErrorEvent) => { + logger("chapter-page").info("retrying") + if (retries.current >= 3) { + setImageStatus(IMAGE_STATUS.ERROR) + return + } + + setImageStatus(IMAGE_STATUS.RETRYING) + + retries.current = retries.current + 1 + + const timerId = setTimeout(() => { + const img = event.target as HTMLImageElement + if (!img) { + return + } + const imgSrc = img.src + + img.src = imgSrc + + // Already removes itself from the list of timerIds + timerIds.splice(timerIds.indexOf(timerId), 1) + }, 1000) + timerIds.push(timerId) + } + const handleLoad = () => { + setImageStatus(IMAGE_STATUS.LOADED) + } + + image.addEventListener("error", handleError) + image.addEventListener("load", handleLoad, { once: true }) + + return () => { + image.removeEventListener("error", handleError) + image.removeEventListener("load", handleLoad) + // Cleanup pending setTimeout's. We use `splice(0)` to clear the list. + for (const timerId of timerIds.splice(0)) { + clearTimeout(timerId) + } + } + }, [imageRef, retries]) + + return { + isLoaded, + isLoading, + isRetrying, + hasError, + retry, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/_components/chapter-vertical-reader.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/_components/chapter-vertical-reader.tsx new file mode 100644 index 0000000..e9b34e5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/_components/chapter-vertical-reader.tsx @@ -0,0 +1,246 @@ +import { Manga_PageContainer } from "@/api/generated/types" +import { ChapterPage } from "@/app/(main)/manga/_containers/chapter-reader/_components/chapter-page" +import { useHandleChapterPageStatus, useHydrateMangaPaginationMap } from "@/app/(main)/manga/_lib/handle-chapter-reader" +import { + __manga_currentPageIndexAtom, + __manga_hiddenBarAtom, + __manga_isLastPageAtom, + __manga_kbsPageLeft, + __manga_kbsPageRight, + __manga_pageFitAtom, + __manga_pageGapAtom, + __manga_pageOverflowContainerWidthAtom, + __manga_pageStretchAtom, + __manga_paginationMapAtom, + MangaPageFit, + MangaPageStretch, +} from "@/app/(main)/manga/_lib/manga-chapter-reader.atoms" +import { useUpdateEffect } from "@/components/ui/core/hooks" +import { cn } from "@/components/ui/core/styling" +import { isMobile } from "@/lib/utils/browser-detection" +import { atom } from "jotai" +import { useAtom, useAtomValue, useSetAtom } from "jotai/react" +import mousetrap from "mousetrap" +import React from "react" +import { useEffectOnce } from "react-use" + +export type MangaVerticalReaderProps = { + pageContainer: Manga_PageContainer | undefined +} + +export const ___manga_scrollSignalAtom = atom(0) + +/** + * MangaVerticalReader component + * + * This component is responsible for rendering the manga pages in a vertical layout. + * It also handles the logic for scrolling and page navigation. + */ +export function MangaVerticalReader({ pageContainer }: MangaVerticalReaderProps) { + + const containerRef = React.useRef<HTMLDivElement>(null) + const setIsLastPage = useSetAtom(__manga_isLastPageAtom) + const pageFit = useAtomValue(__manga_pageFitAtom) + const pageStretch = useAtomValue(__manga_pageStretchAtom) + const pageGap = useAtomValue(__manga_pageGapAtom) + const pageOverflowContainerWidth = useAtomValue(__manga_pageOverflowContainerWidthAtom) + const [currentPageIndex, setCurrentPageIndex] = useAtom(__manga_currentPageIndexAtom) + const paginationMap = useAtom(__manga_paginationMapAtom) + + const [hiddenBar, setHideBar] = useAtom(__manga_hiddenBarAtom) + + const kbsPageLeft = useAtomValue(__manga_kbsPageLeft) + const kbsPageRight = useAtomValue(__manga_kbsPageRight) + + useHydrateMangaPaginationMap(pageContainer) + + const { handlePageLoad } = useHandleChapterPageStatus(pageContainer) + + /** + * When the reader mounts (reading mode changes), scroll to the current page + */ + useEffectOnce(() => { + if (currentPageIndex !== 0) { + const pageDiv = containerRef.current?.querySelector(`#page-${currentPageIndex}`) + pageDiv?.scrollIntoView() + } + }) + + + /** + * When there is a signal, scroll to the current page + */ + const scrollSignal = useAtomValue(___manga_scrollSignalAtom) + useUpdateEffect(() => { + const pageDiv = containerRef.current?.querySelector(`#page-${currentPageIndex}`) + pageDiv?.scrollIntoView() + }, [scrollSignal]) + + /** + * Function to handle scroll event + * + * This function is responsible for handling the scroll event on the container div. + * It checks if the user has scrolled past a certain point and sets the [isLastPage] state accordingly. + * It also checks which page is currently in the viewport and sets the [currentPageIndex] state. + */ + const handleScroll = () => { + if (!!containerRef.current) { + const scrollTop = containerRef.current.scrollTop + const scrollHeight = containerRef.current.scrollHeight + const clientHeight = containerRef.current.clientHeight + + if (scrollTop > 1000 && !!pageContainer?.pages?.length && (scrollTop + clientHeight >= scrollHeight - 1500)) { + setIsLastPage(true) + } else { + setIsLastPage(false) + } + + containerRef.current?.querySelectorAll(".scroll-div")?.forEach((div) => { + if (isElementXPercentInViewport(div) && pageContainer?.pages?.length) { + const idx = Number(div.id.split("-")[1]) + setCurrentPageIndex(idx) + } + }) + } + } + + // Reset isLastPage state when pages change + React.useEffect(() => { + setIsLastPage(false) + }, [pageContainer?.pages]) + + // Add scroll event listener when component mounts + React.useEffect(() => { + // Add a scroll event listener to container + containerRef.current?.addEventListener("scroll", handleScroll) + return () => containerRef.current?.removeEventListener("scroll", handleScroll) + }, [containerRef.current]) + + // Page navigation + React.useEffect(() => { + mousetrap.bind("up", () => { + containerRef.current?.scrollBy(0, -100) + }) + mousetrap.bind("down", () => { + containerRef.current?.scrollBy(0, 100) + }) + + + return () => { + mousetrap.unbind("up") + mousetrap.unbind("down") + } + }, [paginationMap]) + + /** + * Key bindings for page navigation + */ + React.useEffect(() => { + mousetrap.bind(kbsPageLeft, () => { + if (currentPageIndex > 0) { + const pageDiv = containerRef.current?.querySelector(`#page-${currentPageIndex - 1}`) + pageDiv?.scrollIntoView() + } + + }) + mousetrap.bind(kbsPageRight, () => { + if (pageContainer?.pages?.length && currentPageIndex < pageContainer?.pages?.length - 1) { + const pageDiv = containerRef.current?.querySelector(`#page-${currentPageIndex + 1}`) + pageDiv?.scrollIntoView() + } + }) + + return () => { + mousetrap.unbind(kbsPageLeft) + mousetrap.unbind(kbsPageRight) + } + }, [kbsPageLeft, kbsPageRight, paginationMap]) + + return ( + <div + data-chapter-vertical-reader-container + className={cn( + "max-h-[calc(100dvh-3rem)] overflow-hidden relative focus-visible:outline-none", + hiddenBar && "h-full max-h-full", + )} tabIndex={-1} + onClick={() => { + if (!isMobile()) { + setHideBar(prev => !prev) + } + }} + > + <div + data-chapter-vertical-reader-inner-container + className={cn( + "w-full h-[calc(100dvh-3rem)] overflow-y-auto overflow-x-hidden px-4 select-none relative focus-visible:outline-none", + hiddenBar && "h-dvh", + pageGap && "space-y-4", + isMobile() && "hide-scrollbar", + )} + ref={containerRef} + tabIndex={-1} + > + <div + data-chapter-vertical-reader-inner-container-spacer + className="absolute w-full h-full z-[5] focus-visible:outline-none" + tabIndex={-1} + > + + </div> + {pageContainer?.pages?.map((page, index) => ( + <ChapterPage + key={page.url} + page={page} + index={index} + readingMode={"paged"} + pageContainer={pageContainer} + onFinishedLoading={() => { + // If the first page is loaded, set the current page index to 0 + // This is to avoid the current page index to remain incorrect when multiple pages are loading + if (index === 0) { + setCurrentPageIndex(0) + } + handlePageLoad(index) + }} + containerClass={cn( + "mx-auto scroll-div min-h-[200px] relative focus-visible:outline-none", + pageFit === MangaPageFit.CONTAIN && "max-w-full h-[calc(100dvh-60px)]", + pageFit === MangaPageFit.TRUE_SIZE && "max-w-full", + pageFit === MangaPageFit.COVER && "max-w-full", + )} + containerMaxWidth={pageFit === MangaPageFit.LARGER ? pageOverflowContainerWidth + "%" : undefined} + imageClass={cn( + "max-w-full h-auto mx-auto select-none z-[4] relative focus-visible:outline-none", + + // "h-full inset-0 object-center select-none z-[4] relative", + + pageFit === MangaPageFit.CONTAIN ? + pageStretch === MangaPageStretch.NONE ? "w-auto h-full object-center" : "object-fill w-full max-w-[1400px] h-full" : + undefined, + pageFit === MangaPageFit.LARGER ? + pageStretch === MangaPageStretch.NONE ? "w-auto h-full object-center" : "w-full h-auto object-cover mx-auto" : + undefined, + pageFit === MangaPageFit.COVER && "w-full h-auto", + pageFit === MangaPageFit.TRUE_SIZE && "object-none h-auto w-auto mx-auto", + )} + + // imageMaxWidth={pageFit === MangaPageFit.LARGER ? pageOverflowContainerWidth+"%" : undefined} + /> + ))} + + </div> + </div> + ) +} + +// source: https://stackoverflow.com/a/51121566 +const isElementXPercentInViewport = function (el: any, percentVisible = 30) { + let + rect = el.getBoundingClientRect(), + windowHeight = (window.innerHeight || document.documentElement.clientHeight) + + return !( + Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-rect.height) * 100)) < percentVisible || + Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/chapter-reader-drawer.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/chapter-reader-drawer.tsx new file mode 100644 index 0000000..6ed98a6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/chapter-reader-drawer.tsx @@ -0,0 +1,355 @@ +import { AL_BaseManga, Manga_ChapterContainer, Manga_EntryListData } from "@/api/generated/types" +import { useGetMangaEntryPages, useUpdateMangaProgress } from "@/api/hooks/manga.hooks" +import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { MangaHorizontalReader } from "@/app/(main)/manga/_containers/chapter-reader/_components/chapter-horizontal-reader" +import { MangaVerticalReader } from "@/app/(main)/manga/_containers/chapter-reader/_components/chapter-vertical-reader" +import { MangaReaderBar } from "@/app/(main)/manga/_containers/chapter-reader/manga-reader-bar" +import { + useCurrentChapter, + useHandleChapterPagination, + useSetCurrentChapter, + useSwitchSettingsWithKeys, +} from "@/app/(main)/manga/_lib/handle-chapter-reader" +import { useDiscordMangaPresence } from "@/app/(main)/manga/_lib/handle-discord-manga-presence" +import { + __manga_currentPageIndexAtom, + __manga_currentPaginationMapIndexAtom, + __manga_hiddenBarAtom, + __manga_isLastPageAtom, + __manga_kbsChapterLeft, + __manga_kbsChapterRight, + __manga_paginationMapAtom, + __manga_readingDirectionAtom, + __manga_readingModeAtom, + MangaReadingDirection, + MangaReadingMode, +} from "@/app/(main)/manga/_lib/manga-chapter-reader.atoms" +import { LuffyError } from "@/components/shared/luffy-error" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Drawer } from "@/components/ui/drawer" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { __isDesktop__ } from "@/types/constants" +import { useAtom, useAtomValue, useSetAtom } from "jotai/react" +import mousetrap from "mousetrap" +import React from "react" +import { TbLayoutBottombarExpandFilled } from "react-icons/tb" +import { toast } from "sonner" + +type ChapterDrawerProps = { + entry: { media?: AL_BaseManga | undefined, mediaId: number, listData?: Manga_EntryListData } + chapterContainer: Manga_ChapterContainer + chapterIdToNumbersMap: Map<string, number> +} + + +export function ChapterReaderDrawer(props: ChapterDrawerProps) { + + const { + entry, + chapterContainer, + chapterIdToNumbersMap, + ...rest + } = props + + const serverStatus = useServerStatus() + + // Discord rich presence + useDiscordMangaPresence(entry) + + const currentChapter = useCurrentChapter() + const setCurrentChapter = useSetCurrentChapter() + + const setCurrentPageIndex = useSetAtom(__manga_currentPageIndexAtom) + const setCurrentPaginationMapIndex = useSetAtom(__manga_currentPaginationMapIndexAtom) + + const [readingMode, setReadingMode] = useAtom(__manga_readingModeAtom) + const isLastPage = useAtomValue(__manga_isLastPageAtom) + const kbsChapterLeft = useAtomValue(__manga_kbsChapterLeft) + const kbsChapterRight = useAtomValue(__manga_kbsChapterRight) + const paginationMap = useAtomValue(__manga_paginationMapAtom) + const readingDirection = useAtomValue(__manga_readingDirectionAtom) + + const [hiddenBar, setHideBar] = useAtom(__manga_hiddenBarAtom) + + useSwitchSettingsWithKeys() + + const { inject, remove } = useSeaCommandInject() + + /** + * Get the pages + */ + const { + data: pageContainer, + isLoading: pageContainerLoading, + isError: pageContainerError, + refetch: retryFetchPageContainer, + } = useGetMangaEntryPages({ + mediaId: entry?.media?.id, + chapterId: currentChapter?.chapterId, + // provider: chapterContainer.provider as Manga_Provider, + provider: currentChapter?.provider, + doublePage: readingMode === MangaReadingMode.DOUBLE_PAGE, + }) + + /** + * Update the progress when the user confirms + */ + const { mutate: updateProgress, isPending: isUpdatingProgress } = useUpdateMangaProgress(entry.mediaId) + + /** + * Switch back to PAGED mode if the page dimensions could not be fetched efficiently + */ + React.useEffect(() => { + if (currentChapter) { + if ( + readingMode === MangaReadingMode.DOUBLE_PAGE && + !pageContainerLoading && + !pageContainerError && + (!pageContainer?.pageDimensions || Object.keys(pageContainer.pageDimensions).length === 0) + ) { + toast.error("Could not get page dimensions from this provider. Switching to paged mode.") + setReadingMode(MangaReadingMode.PAGED) + } + } + }, [currentChapter, pageContainer, pageContainerLoading, pageContainerError, readingMode]) + + + /** + * Get the previous and next chapters + * Either can be undefined + */ + const { previousChapter, nextChapter, goToChapter } = useHandleChapterPagination(entry.mediaId, chapterContainer) + + /** + * Check if the progress should be updated + * i.e. User progress is less than the current chapter number + */ + const shouldUpdateProgress = React.useMemo(() => { + const currentChapterNumber = chapterIdToNumbersMap.get(currentChapter?.chapterId || "") + if (!currentChapterNumber) return false + if (!entry.listData?.progress) return true + return currentChapterNumber > entry.listData.progress + }, [chapterIdToNumbersMap, entry, currentChapter]) + + const handleUpdateProgress = (goToNext: boolean = true) => { + if (shouldUpdateProgress && !isUpdatingProgress) { + + updateProgress({ + chapterNumber: chapterIdToNumbersMap.get(currentChapter?.chapterId || "") || 0, + mediaId: entry.mediaId, + malId: entry.media?.idMal || undefined, + totalChapters: entry.media?.chapters || 0, + }, { + onSuccess: () => { + if (goToNext) { + goToChapter("next") + } + }, + }) + + } + } + + /** + * Handle auto-updating progress + */ + const lastUpdatedChapterRef = React.useRef<string | null>(null) + React.useEffect(() => { + if ( + serverStatus?.settings?.manga?.mangaAutoUpdateProgress + && currentChapter?.chapterId + && shouldUpdateProgress + && !pageContainerLoading + && !pageContainerError + && isLastPage + ) { + if (lastUpdatedChapterRef.current !== currentChapter?.chapterId) { + handleUpdateProgress(false) + lastUpdatedChapterRef.current = currentChapter?.chapterId + } + } + }, [currentChapter, serverStatus?.settings?.manga?.mangaAutoUpdateProgress, shouldUpdateProgress, isLastPage, pageContainerError, pageContainerLoading]) + + /** + * Reset the current page index when the pageContainer or chapterContainer changes + * This signals that the user has switched chapters + */ + const previousChapterId = React.useRef(currentChapter?.chapterId) + React.useEffect(() => { + // Avoid resetting the page index when we're still on the same chapter + if (currentChapter?.chapterId !== previousChapterId.current) { + setCurrentPageIndex(0) + setCurrentPaginationMapIndex(0) + previousChapterId.current = currentChapter?.chapterId + } + }, [pageContainer?.pages, chapterContainer?.chapters]) + + // Progress update keyboard shortcuts + React.useEffect(() => { + mousetrap.bind("u", () => { + handleUpdateProgress() + }) + + return () => { + mousetrap.unbind("u") + } + }, [pageContainer?.pages, chapterContainer?.chapters, shouldUpdateProgress, isLastPage]) + + // Hide bar shortcut + React.useEffect(() => { + mousetrap.bind("b", () => { + setHideBar((prev) => !prev) + }) + + return () => { + mousetrap.unbind("b") + } + }, []) + + // Navigation + React.useEffect(() => { + mousetrap.bind(kbsChapterLeft, () => { + if (readingDirection === MangaReadingDirection.LTR) { + goToChapter("previous") + } else { + goToChapter("next") + } + }) + mousetrap.bind(kbsChapterRight, () => { + if (readingDirection === MangaReadingDirection.RTL) { + goToChapter("previous") + } else { + goToChapter("next") + } + }) + + return () => { + mousetrap.unbind(kbsChapterLeft) + mousetrap.unbind(kbsChapterRight) + } + }, [kbsChapterLeft, kbsChapterRight, paginationMap, readingDirection, chapterContainer, previousChapter, nextChapter]) + + // Inject close reader command + React.useEffect(() => { + if (!currentChapter) return + + inject("close-manga-reader", { + items: [{ + id: "close-reader", + value: "Close reader", + heading: "Reader", + priority: 100, + render: () => ( + <div className="flex gap-1 items-center w-full"> + <p>Close reader</p> + </div> + ), + onSelect: () => setCurrentChapter(undefined), + }], + filter: ({ item, input }) => { + if (!input) return true + return item.value.toLowerCase().includes(input.toLowerCase()) + }, + priority: 105, + }) + + return () => remove("close-manga-reader") + }, [currentChapter]) + + return ( + <Drawer + data-chapter-reader-drawer + open={!!currentChapter} + onOpenChange={() => setCurrentChapter(undefined)} + size="full" + side="bottom" + headerClass="absolute h-0" + contentClass={cn( + "p-0 pt-0 !m-0 !rounded-none", + "w-full inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + )} + hideCloseButton + borderToBorder + > + + <div + data-chapter-reader-drawer-progress-container + className={cn( + "fixed left-0 w-full z-[6] opacity-0 transition-opacity hidden duration-500", + !__isDesktop__ && "top-0 justify-center", + __isDesktop__ && cn( + "bottom-12", + hiddenBar && "bottom-0 justify-left", + ), + (shouldUpdateProgress && isLastPage && !pageContainerLoading && !pageContainerError) && "flex opacity-100", + )} + tabIndex={-1} + > + <Button + onClick={() => handleUpdateProgress()} + className={cn( + !__isDesktop__ && "rounded-tl-none rounded-tr-none", + __isDesktop__ && "rounded-bl-none rounded-br-none rounded-tl-none", + )} + size="md" + intent="success" + loading={isUpdatingProgress} + disabled={isUpdatingProgress} + > + Update progress ({chapterIdToNumbersMap.get(currentChapter?.chapterId || "")} / {entry?.media?.chapters || "-"}) + </Button> + </div> + + {/*Exit fullscreen button*/} + {hiddenBar && <div data-chapter-reader-drawer-exit-fullscreen-button className="fixed right-0 bottom-4 group/hiddenBarArea z-[10] px-4"> + <IconButton + rounded + icon={<TbLayoutBottombarExpandFilled />} + intent="white-outline" + size="sm" + onClick={() => setHideBar(false)} + className="lg:opacity-0 opacity-30 group-hover/hiddenBarArea:opacity-100 transition-opacity duration-200" + /> + </div>} + + <MangaReaderBar + previousChapter={previousChapter} + nextChapter={nextChapter} + goToChapter={goToChapter} + pageContainer={pageContainer} + entry={entry} + /> + + + <div + data-chapter-reader-drawer-content + className={cn( + "max-h-[calc(100dvh-3rem)] h-full", + hiddenBar && "max-h-dvh", + )} tabIndex={-1} + > + {pageContainerError ? ( + <LuffyError + title="Failed to load pages" + > + <p>An error occurred while trying to load pages for this chapter.</p> + <p>Reload the page, reload sources or change the source.</p> + + <div className="mt-2"> + <Button intent="white" onClick={() => retryFetchPageContainer()}> + Retry + </Button> + </div> + </LuffyError> + ) : (pageContainerLoading) + ? (<LoadingSpinner containerClass="h-full" />) + : (readingMode === MangaReadingMode.LONG_STRIP + ? (<MangaVerticalReader pageContainer={pageContainer} />) + : (readingMode === MangaReadingMode.PAGED || readingMode === MangaReadingMode.DOUBLE_PAGE) + ? (<MangaHorizontalReader pageContainer={pageContainer} />) : null)} + </div> + </Drawer> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/chapter-reader-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/chapter-reader-settings.tsx new file mode 100644 index 0000000..64287ad --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/chapter-reader-settings.tsx @@ -0,0 +1,621 @@ +"use client" +import { + __manga_doublePageOffsetAtom, + __manga_entryReaderSettings, + __manga_hiddenBarAtom, + __manga_kbsChapterLeft, + __manga_kbsChapterRight, + __manga_kbsPageLeft, + __manga_kbsPageRight, + __manga_pageFitAtom, + __manga_pageGapAtom, + __manga_pageGapShadowAtom, + __manga_pageOverflowContainerWidthAtom, + __manga_pageStretchAtom, + __manga_readerProgressBarAtom, + __manga_readingDirectionAtom, + __manga_readingModeAtom, + MANGA_DEFAULT_KBS, + MANGA_KBS_ATOM_KEYS, + MANGA_SETTINGS_ATOM_KEYS, + MangaPageFit, + MangaPageStretch, + MangaReadingDirection, + MangaReadingMode, +} from "@/app/(main)/manga/_lib/manga-chapter-reader.atoms" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Drawer } from "@/components/ui/drawer" +import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { NumberInput } from "@/components/ui/number-input" +import { RadioGroup } from "@/components/ui/radio-group" +import { Separator } from "@/components/ui/separator" +import { Switch } from "@/components/ui/switch" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import React, { useState } from "react" +import { AiOutlineColumnHeight, AiOutlineColumnWidth } from "react-icons/ai" +import { BiCog } from "react-icons/bi" +import { FaRedo, FaRegImage } from "react-icons/fa" +import { GiResize } from "react-icons/gi" +import { MdMenuBook, MdOutlinePhotoSizeSelectLarge } from "react-icons/md" +import { PiArrowCircleLeftDuotone, PiArrowCircleRightDuotone, PiReadCvLogoLight, PiScrollDuotone } from "react-icons/pi" +import { TbArrowAutofitHeight } from "react-icons/tb" +import { useWindowSize } from "react-use" +import { toast } from "sonner" + +export type ChapterReaderSettingsProps = { + mediaId: number +} + +const radioGroupClasses = { + itemClass: cn( + "border-transparent absolute top-2 right-2 bg-transparent dark:bg-transparent dark:data-[state=unchecked]:bg-transparent", + "data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent dark:data-[state=unchecked]:hover:bg-transparent", + "focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:ring-offset-transparent", + ), + stackClass: "space-y-0 flex flex-wrap gap-2", + itemIndicatorClass: "hidden", + itemLabelClass: "font-normal tracking-wide line-clamp-1 truncate flex flex-col items-center data-[state=checked]:text-[--gray] cursor-pointer", + itemContainerClass: cn( + "items-start cursor-pointer transition border-transparent rounded-[--radius] py-1.5 px-3 w-full", + "hover:bg-[--subtle] dark:bg-gray-900", + "data-[state=checked]:bg-white dark:data-[state=checked]:bg-gray-950", + "focus:ring-2 ring-transparent dark:ring-transparent outline-none ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition", + "border border-transparent data-[state=checked]:border-[--gray] data-[state=checked]:ring-offset-0", + "w-fit", + ), +} + +export const MANGA_READING_MODE_OPTIONS = [ + { + value: MangaReadingMode.LONG_STRIP, + label: <span className="flex gap-2 items-center"><PiScrollDuotone className="text-xl" /> <span>Long Strip</span></span>, + }, + { + value: MangaReadingMode.PAGED, + label: <span className="flex gap-2 items-center"><PiReadCvLogoLight className="text-xl" /> <span>Single Page</span></span>, + }, + { + value: MangaReadingMode.DOUBLE_PAGE, + label: <span className="flex gap-2 items-center"><MdMenuBook className="text-xl" /> <span>Double Page</span></span>, + }, +] + +export const MANGA_READING_DIRECTION_OPTIONS = [ + { + value: MangaReadingDirection.LTR, + label: <span className="flex gap-2 items-center"><span>Left to Right</span> <PiArrowCircleRightDuotone className="text-2xl" /></span>, + }, + { + value: MangaReadingDirection.RTL, + label: <span className="flex gap-2 items-center"><PiArrowCircleLeftDuotone className="text-2xl" /> <span>Right to Left</span></span>, + }, +] + +export const MANGA_PAGE_FIT_OPTIONS = [ + { + value: MangaPageFit.CONTAIN, + label: <span className="flex gap-2 items-center"><AiOutlineColumnHeight className="text-xl" /> <span>Contain</span></span>, + }, + { + value: MangaPageFit.LARGER, + label: <span className="flex gap-2 items-center"><TbArrowAutofitHeight className="text-xl" /> <span>Overflow</span></span>, + }, + { + value: MangaPageFit.COVER, + label: <span className="flex gap-2 items-center"><AiOutlineColumnWidth className="text-xl" /> <span>Cover</span></span>, + }, + { + value: MangaPageFit.TRUE_SIZE, + label: <span className="flex gap-2 items-center"><FaRegImage className="text-xl" /> <span>True size</span></span>, + }, +] + +export const MANGA_PAGE_STRETCH_OPTIONS = [ + { + value: MangaPageStretch.NONE, + label: <span className="flex gap-2 items-center"><MdOutlinePhotoSizeSelectLarge className="text-xl" /> <span>None</span></span>, + }, + { + value: MangaPageStretch.STRETCH, + label: <span className="flex gap-2 items-center"><GiResize className="text-xl" /> <span>Stretch</span></span>, + }, +] + + +export const __manga__readerSettingsDrawerOpen = atom(false) + +export function ChapterReaderSettings(props: ChapterReaderSettingsProps) { + + const { + mediaId, + ...rest + } = props + + const [readingDirection, setReadingDirection] = useAtom(__manga_readingDirectionAtom) + const [readingMode, setReadingMode] = useAtom(__manga_readingModeAtom) + const [pageFit, setPageFit] = useAtom(__manga_pageFitAtom) + const [pageStretch, setPageStretch] = useAtom(__manga_pageStretchAtom) + const [pageGap, setPageGap] = useAtom(__manga_pageGapAtom) + const [pageGapShadow, setPageGapShadow] = useAtom(__manga_pageGapShadowAtom) + const [doublePageOffset, setDoublePageOffset] = useAtom(__manga_doublePageOffsetAtom) + const [pageOverflowContainerWidth, setPageOverflowContainerWidth] = useAtom(__manga_pageOverflowContainerWidthAtom) + //--- + const [readerProgressBar, setReaderProgressBar] = useAtom(__manga_readerProgressBarAtom) + const [hiddenBar, setHideBar] = useAtom(__manga_hiddenBarAtom) + + const { width } = useWindowSize() + const isMobile = width < 950 + + const defaultSettings = React.useMemo(() => { + if (isMobile) { + return { + [MangaReadingMode.LONG_STRIP]: { + pageFit: MangaPageFit.COVER, + pageStretch: MangaPageStretch.NONE, + }, + [MangaReadingMode.PAGED]: { + pageFit: MangaPageFit.CONTAIN, + pageStretch: MangaPageStretch.NONE, + }, + [MangaReadingMode.DOUBLE_PAGE]: { + pageFit: MangaPageFit.CONTAIN, + pageStretch: MangaPageStretch.NONE, + }, + } + } else { + return { + [MangaReadingMode.LONG_STRIP]: { + pageFit: MangaPageFit.LARGER, + pageStretch: MangaPageStretch.NONE, + }, + [MangaReadingMode.PAGED]: { + pageFit: MangaPageFit.CONTAIN, + pageStretch: MangaPageStretch.NONE, + }, + [MangaReadingMode.DOUBLE_PAGE]: { + pageFit: MangaPageFit.CONTAIN, + pageStretch: MangaPageStretch.NONE, + }, + } + } + }, [isMobile]) + + /** + * Remember settings for current media + */ + const [entrySettings, setEntrySettings] = useAtom(__manga_entryReaderSettings) + + const mounted = React.useRef(false) + React.useEffect(() => { + if (!mounted.current) { + if (entrySettings[mediaId]) { + const settings = entrySettings[mediaId] + setReadingDirection(settings[MANGA_SETTINGS_ATOM_KEYS.readingDirection]) + setReadingMode(settings[MANGA_SETTINGS_ATOM_KEYS.readingMode]) + setPageFit(settings[MANGA_SETTINGS_ATOM_KEYS.pageFit]) + setPageStretch(settings[MANGA_SETTINGS_ATOM_KEYS.pageStretch]) + setPageGap(settings[MANGA_SETTINGS_ATOM_KEYS.pageGap]) + setPageGapShadow(settings[MANGA_SETTINGS_ATOM_KEYS.pageGapShadow]) + setDoublePageOffset(settings[MANGA_SETTINGS_ATOM_KEYS.doublePageOffset]) + setPageOverflowContainerWidth(settings[MANGA_SETTINGS_ATOM_KEYS.overflowPageContainerWidth]) + } + } + mounted.current = true + }, [entrySettings[mediaId]]) + + React.useEffect(() => { + setEntrySettings(prev => ({ + ...prev, + [mediaId]: { + [MANGA_SETTINGS_ATOM_KEYS.readingDirection]: readingDirection, + [MANGA_SETTINGS_ATOM_KEYS.readingMode]: readingMode, + [MANGA_SETTINGS_ATOM_KEYS.pageFit]: pageFit, + [MANGA_SETTINGS_ATOM_KEYS.pageStretch]: pageStretch, + [MANGA_SETTINGS_ATOM_KEYS.pageGap]: pageGap, + [MANGA_SETTINGS_ATOM_KEYS.pageGapShadow]: pageGapShadow, + [MANGA_SETTINGS_ATOM_KEYS.doublePageOffset]: doublePageOffset, + [MANGA_SETTINGS_ATOM_KEYS.overflowPageContainerWidth]: pageOverflowContainerWidth, + }, + })) + }, [readingDirection, readingMode, pageFit, pageStretch, pageGap, pageGapShadow, doublePageOffset, pageOverflowContainerWidth]) + + const [kbsChapterLeft, setKbsChapterLeft] = useAtom(__manga_kbsChapterLeft) + const [kbsChapterRight, setKbsChapterRight] = useAtom(__manga_kbsChapterRight) + const [kbsPageLeft, setKbsPageLeft] = useAtom(__manga_kbsPageLeft) + const [kbsPageRight, setKbsPageRight] = useAtom(__manga_kbsPageRight) + + const isDefaultSettings = + pageFit === defaultSettings[readingMode].pageFit && + pageStretch === defaultSettings[readingMode].pageStretch + + const resetKeyDefault = React.useCallback((key: string) => { + switch (key) { + case MANGA_KBS_ATOM_KEYS.kbsChapterLeft: + setKbsChapterLeft(MANGA_DEFAULT_KBS[key]) + break + case MANGA_KBS_ATOM_KEYS.kbsChapterRight: + setKbsChapterRight(MANGA_DEFAULT_KBS[key]) + break + case MANGA_KBS_ATOM_KEYS.kbsPageLeft: + setKbsPageLeft(MANGA_DEFAULT_KBS[key]) + break + case MANGA_KBS_ATOM_KEYS.kbsPageRight: + setKbsPageRight(MANGA_DEFAULT_KBS[key]) + break + } + }, []) + + const resetKbsDefaultIfConflict = (currentKey: string, value: string) => { + for (const key of Object.values(MANGA_KBS_ATOM_KEYS)) { + if (key !== currentKey) { + const otherValue = { + [MANGA_KBS_ATOM_KEYS.kbsChapterLeft]: kbsChapterLeft, + [MANGA_KBS_ATOM_KEYS.kbsChapterRight]: kbsChapterRight, + [MANGA_KBS_ATOM_KEYS.kbsPageLeft]: kbsPageLeft, + [MANGA_KBS_ATOM_KEYS.kbsPageRight]: kbsPageRight, + }[key] + if (otherValue === value) { + resetKeyDefault(key) + } + } + } + } + + const setKbs = (e: React.KeyboardEvent, kbs: string) => { + e.preventDefault() + e.stopPropagation() + + const specialKeys = ["Control", "Shift", "Meta", "Command", "Alt", "Option"] + if (!specialKeys.includes(e.key)) { + const keyStr = `${e.metaKey ? "meta+" : ""}${e.ctrlKey ? "ctrl+" : ""}${e.altKey ? "alt+" : ""}${e.shiftKey + ? "shift+" + : ""}${e.key.toLowerCase() + .replace("arrow", "") + .replace("insert", "ins") + .replace("delete", "del") + .replace(" ", "space") + .replace("+", "plus")}` + + const kbsSetter = { + [MANGA_KBS_ATOM_KEYS.kbsChapterLeft]: setKbsChapterLeft, + [MANGA_KBS_ATOM_KEYS.kbsChapterRight]: setKbsChapterRight, + [MANGA_KBS_ATOM_KEYS.kbsPageLeft]: setKbsPageLeft, + [MANGA_KBS_ATOM_KEYS.kbsPageRight]: setKbsPageRight, + } + + kbsSetter[kbs]?.(keyStr) + resetKbsDefaultIfConflict(kbs, keyStr) + } + } + + /** + * Disabled double page on small screens + */ + React.useEffect(() => { + if (readingMode === MangaReadingMode.DOUBLE_PAGE && width < 950) { + setReadingMode(prev => { + toast.error("Double page mode is not supported on small screens.") + return MangaReadingMode.LONG_STRIP + }) + } + }, [width, readingMode]) + + function handleSetReadingMode(mode: string) { + if (mode === MangaReadingMode.DOUBLE_PAGE && width < 950) { + toast.error("Double page mode is not supported on small screens.") + return + } + setReadingMode(mode) + } + + const [open, setOpen] = useAtom(__manga__readerSettingsDrawerOpen) + const [fullscreen, setFullscreen] = useState(false) + + function handleFullscreen() { + const el = document.documentElement + if (fullscreen && document.exitFullscreen) { + document.exitFullscreen() + setFullscreen(false) + } else if (!fullscreen) { + if (el.requestFullscreen) { + el.requestFullscreen() + } else if ((el as any).webkitRequestFullscreen) { + (el as any).webkitRequestFullscreen() + } else if ((el as any).msRequestFullscreen) { + (el as any).msRequestFullscreen() + } + setFullscreen(true) + } + } + return ( + <> + <DropdownMenu + trigger={<IconButton + data-chapter-reader-settings-dropdown-menu-trigger + icon={<BiCog />} + intent="gray-basic" + className="flex lg:hidden" + />} + className="block lg:hidden" + data-chapter-reader-settings-dropdown-menu + > + <DropdownMenuItem + onClick={() => setOpen(true)} + >Open settings</DropdownMenuItem> + <DropdownMenuItem + onClick={handleFullscreen} + >Toggle fullscreen</DropdownMenuItem> + <DropdownMenuItem + onClick={() => setHideBar((prev) => !prev)} + >{hiddenBar ? "Show" : "Hide"} bar</DropdownMenuItem> + </DropdownMenu> + + <Drawer + trigger={ + <IconButton + icon={<BiCog />} + intent="gray-basic" + className="hidden lg:flex" + /> + } + title="Settings" + allowOutsideInteraction={false} + open={open} + onOpenChange={setOpen} + size="lg" + contentClass="z-[51]" + data-chapter-reader-settings-drawer + > + <div className="space-y-4 py-4" data-chapter-reader-settings-drawer-content> + + <RadioGroup + {...radioGroupClasses} + label="Reading Mode" + options={MANGA_READING_MODE_OPTIONS} + value={readingMode} + onValueChange={(value) => handleSetReadingMode(value)} + /> + + <div + className={cn( + readingMode !== MangaReadingMode.DOUBLE_PAGE && "hidden", + )} + > + <NumberInput + label="Offset" + value={doublePageOffset} + onValueChange={(value) => setDoublePageOffset(value)} + /> + </div> + <div + className={cn( + readingMode === MangaReadingMode.LONG_STRIP && "opacity-50 pointer-events-none", + )} + > + <RadioGroup + {...radioGroupClasses} + label="Reading Direction" + options={MANGA_READING_DIRECTION_OPTIONS} + value={readingDirection} + onValueChange={(value) => setReadingDirection(value)} + /> + </div> + + <RadioGroup + {...radioGroupClasses} + label="Page Fit" + options={MANGA_PAGE_FIT_OPTIONS} + value={pageFit} + onValueChange={(value) => setPageFit(value)} + // help={<> + // <p>'Contain': Fit Height</p> + // <p>'Overflow': Height overflow</p> + // <p>'Cover': Fit Width</p> + // <p>'True Size': No scaling, raw sizes</p> + // </>} + /> + + { + pageFit === MangaPageFit.LARGER && ( + <NumberInput + label="Page Container Width" + max={100} + min={0} + rightAddon="%" + value={pageOverflowContainerWidth} + onValueChange={(value) => setPageOverflowContainerWidth(value)} + disabled={readingMode === MangaReadingMode.DOUBLE_PAGE} + /> + ) + } + + <div + className={cn( + (readingMode !== MangaReadingMode.LONG_STRIP || (pageFit !== MangaPageFit.LARGER && pageFit !== MangaPageFit.CONTAIN)) && "opacity-50 pointer-events-none", + )} + > + <RadioGroup + {...radioGroupClasses} + label="Page Stretch" + options={MANGA_PAGE_STRETCH_OPTIONS} + value={pageStretch} + onValueChange={(value) => setPageStretch(value)} + help="'Stretch' forces all pages to have the same width as the container in 'Long Strip' mode." + /> + </div> + + <div className="flex gap-4 flex-wrap items-center"> + <Switch + label="Page Gap" + value={pageGap} + onValueChange={setPageGap} + fieldClass="w-fit" + size="sm" + /> + <Switch + label="Page Gap Shadow" + value={pageGapShadow} + onValueChange={setPageGapShadow} + fieldClass="w-fit" + disabled={!pageGap} + size="sm" + /> + </div> + + + <Button + size="sm" className="rounded-full w-full" intent="white-subtle" + disabled={isDefaultSettings} + onClick={() => { + setPageFit(defaultSettings[readingMode].pageFit) + setPageStretch(defaultSettings[readingMode].pageStretch) + }} + > + <span className="flex flex-none items-center"> + Reset defaults + for <span className="w-2"></span> {MANGA_READING_MODE_OPTIONS.find((option) => option.value === readingMode)?.label} + </span> + </Button> + + <Separator /> + + <div className="flex items-center gap-4"> + <Switch + label="Progress Bar" + value={readerProgressBar} + onValueChange={setReaderProgressBar} + fieldClass="w-fit" + size="sm" + /> + </div> + + <Separator /> + + {!isMobile && ( + <> + <div> + <h4>Editable Keybinds</h4> + <p className="text-[--muted] text-xs">Click to edit</p> + </div> + + {[ + { + key: MANGA_KBS_ATOM_KEYS.kbsChapterLeft, + label: readingDirection === MangaReadingDirection.LTR ? "Previous chapter" : "Next chapter", + value: kbsChapterLeft, + // help: readingDirection === MangaReadingDirection.LTR ? "Previous chapter" : "Next chapter", + }, + { + key: MANGA_KBS_ATOM_KEYS.kbsChapterRight, + label: readingDirection === MangaReadingDirection.LTR ? "Next chapter" : "Previous chapter", + value: kbsChapterRight, + // help: readingDirection === MangaReadingDirection.LTR ? "Next chapter" : "Previous chapter", + }, + { + key: MANGA_KBS_ATOM_KEYS.kbsPageLeft, + label: readingDirection === MangaReadingDirection.LTR ? "Previous page" : "Next page", + value: kbsPageLeft, + // help: readingDirection === MangaReadingDirection.LTR ? "Previous page" : "Next page", + }, + { + key: MANGA_KBS_ATOM_KEYS.kbsPageRight, + label: readingDirection === MangaReadingDirection.LTR ? "Next page" : "Previous page", + value: kbsPageRight, + // help: readingDirection === MangaReadingDirection.LTR ? "Next page" : "Previous page", + }, + ].map(item => { + return ( + <div className="flex gap-2 items-center" key={item.key}> + <div className=""> + <Button + onKeyDownCapture={(e) => setKbs(e, item.key)} + className="focus:ring-2 focus:ring-[--brand] focus:ring-offset-1 focus-visible:ring-2 focus-visible:ring-[--brand] focus-visible:ring-offset-1" + size="sm" + intent="primary-subtle" + id={`chapter-reader-settings-kbs-${item.key}`} + onClick={() => { + const el = document.getElementById(`chapter-reader-settings-kbs-${item.key}`) + if (el) { + el.focus() + } + }} + > + {item.value} + </Button> + </div> + <label className="text-[--gray]"> + <span className="font-semibold">{item.label}</span> + {/*{!!item.help && <span className="ml-2 text-[--muted]">({item.help})</span>}*/} + </label> + { + item.value !== (MANGA_DEFAULT_KBS as any)[item.key] && ( + <Button + onClick={() => { + resetKeyDefault(item.key) + }} + className="rounded-full" + size="sm" + intent="warning-subtle" + leftIcon={<FaRedo />} + > + Reset + </Button> + ) + } + </div> + ) + })} + + <Separator /> + + <h4>Keyboard Shortcuts</h4> + + {[{ + key: "u", + label: "Update progress and go to next chapter", + }, { + key: "b", + label: "Toggle bottom bar visibility", + }, { + key: "m", + label: "Switch reading mode", + }, { + key: "d", + label: "Switch reading direction", + }, { + key: "f", + label: "Switch page fit", + }, { + key: "s", + label: "Switch page stretch", + }, { + key: "shift+right", + label: "Increment double page offset", + }, { + key: "shift+left", + label: "Decrement double page offset", + }].map(item => { + return ( + <div className="flex gap-2 items-center" key={item.key}> + <div> + <Button + size="sm" + intent="white-subtle" + className="pointer-events-none" + > + {item.key} + </Button> + </div> + <p>{item.label}</p> + </div> + ) + })} + </> + )} + </div> + </Drawer> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/manga-reader-bar.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/manga-reader-bar.tsx new file mode 100644 index 0000000..5f3ecfd --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/chapter-reader/manga-reader-bar.tsx @@ -0,0 +1,325 @@ +import { AL_BaseManga, Manga_PageContainer } from "@/api/generated/types" +import { ___manga_scrollSignalAtom } from "@/app/(main)/manga/_containers/chapter-reader/_components/chapter-vertical-reader" +import { + ChapterReaderSettings, + MANGA_PAGE_FIT_OPTIONS, + MANGA_PAGE_STRETCH_OPTIONS, + MANGA_READING_DIRECTION_OPTIONS, + MANGA_READING_MODE_OPTIONS, +} from "@/app/(main)/manga/_containers/chapter-reader/chapter-reader-settings" +import { __manga_selectedChapterAtom, MangaReader_SelectedChapter, useHandleChapterPageStatus } from "@/app/(main)/manga/_lib/handle-chapter-reader" +import { + __manga_currentPageIndexAtom, + __manga_currentPaginationMapIndexAtom, + __manga_hiddenBarAtom, + __manga_pageFitAtom, + __manga_pageStretchAtom, + __manga_paginationMapAtom, + __manga_readerProgressBarAtom, + __manga_readingDirectionAtom, + __manga_readingModeAtom, + MangaPageStretch, + MangaReadingDirection, + MangaReadingMode, +} from "@/app/(main)/manga/_lib/manga-chapter-reader.atoms" +import { Badge } from "@/components/ui/badge" +import { IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Popover } from "@/components/ui/popover" +import { Select } from "@/components/ui/select" +import { useSetAtom } from "jotai" +import { useAtom, useAtomValue } from "jotai/react" +import React from "react" +import { BiX } from "react-icons/bi" +import { LuChevronLeft, LuChevronRight, LuInfo } from "react-icons/lu" + +type MangaReaderBarProps = { + children?: React.ReactNode + previousChapter: MangaReader_SelectedChapter | undefined + nextChapter: MangaReader_SelectedChapter | undefined + goToChapter: (dir: "previous" | "next") => void + pageContainer: Manga_PageContainer | undefined + entry: { mediaId: number, media?: AL_BaseManga } | undefined +} + +export function MangaReaderBar(props: MangaReaderBarProps) { + + const { + children, + previousChapter, + nextChapter, + pageContainer, + entry, + goToChapter, + ...rest + } = props + + const [selectedChapter, setSelectedChapter] = useAtom(__manga_selectedChapterAtom) + + const [currentPageIndex, setCurrentPageIndex] = useAtom(__manga_currentPageIndexAtom) + const paginationMap = useAtomValue(__manga_paginationMapAtom) + const pageFit = useAtomValue(__manga_pageFitAtom) + const pageStretch = useAtomValue(__manga_pageStretchAtom) + const readingMode = useAtomValue(__manga_readingModeAtom) + const readingDirection = useAtomValue(__manga_readingDirectionAtom) + const readerProgressBar = useAtomValue(__manga_readerProgressBarAtom) + + const hiddenBar = useAtomValue(__manga_hiddenBarAtom) + + const ChapterNavButton = React.useCallback(({ dir }: { dir: "left" | "right" }) => { + const reversed = (readingDirection === MangaReadingDirection.RTL && (readingMode === MangaReadingMode.PAGED || readingMode === MangaReadingMode.DOUBLE_PAGE)) + if (reversed) { + if (dir === "left") { + return ( + <IconButton + icon={<LuChevronLeft />} + rounded + intent="white-outline" + size="sm" + onClick={() => { + if (nextChapter && entry) { + goToChapter("next") + } + }} + disabled={!nextChapter} + className="border-transparent" + /> + ) + } else { + return ( + <IconButton + icon={<LuChevronRight />} + rounded + intent="gray-outline" + size="sm" + onClick={() => { + if (previousChapter && entry) { + goToChapter("previous") + } + }} + disabled={!previousChapter} + className="border-transparent" + /> + ) + } + } else { + if (dir === "left") { + return ( + <IconButton + icon={<LuChevronLeft />} + rounded + intent="gray-outline" + size="sm" + onClick={() => { + if (previousChapter && entry) { + goToChapter("previous") + } + }} + disabled={!previousChapter} + className="border-transparent" + /> + ) + } else { + return ( + <IconButton + icon={<LuChevronRight />} + rounded + intent="white-outline" + size="sm" + onClick={() => { + if (nextChapter && entry) { + goToChapter("next") + } + }} + disabled={!nextChapter} + className="border-transparent" + /> + ) + } + } + }, [selectedChapter, nextChapter, previousChapter, readingDirection, readingMode]) + + /** + * Format the second part of pagination text + * e.g. 1-2 / 10 + */ + const secondPageText = React.useMemo(() => { + let secondPageIndex = 0 + for (const [key, values] of Object.entries(paginationMap)) { + if (paginationMap[Number(key)].includes(currentPageIndex)) { + secondPageIndex = values[1] + } + } + if (isNaN(secondPageIndex) || secondPageIndex === 0 || secondPageIndex === currentPageIndex) return "" + return "-" + (secondPageIndex + 1) + }, [currentPageIndex, paginationMap]) + + /** + * Pagination + */ + const [currentMapIndex, setCurrentMapIndex] = useAtom(__manga_currentPaginationMapIndexAtom) + const setScrollSignal = useSetAtom(___manga_scrollSignalAtom) + const handlePageChange = React.useCallback((pageIdx: number) => { + if (readingMode === MangaReadingMode.PAGED) { + setCurrentPageIndex(pageIdx) + setCurrentMapIndex(pageIdx) + } else if (readingMode === MangaReadingMode.DOUBLE_PAGE) { + setCurrentMapIndex(prevMapIdx => { + // Find the new map index based on the page index + // e.g., { 0: [0, 1], 1: [2, 3], 2: [4, 5] } + // if pageIdx is 3, then the new map index is 1 + const newMapIdx = Object.keys(paginationMap).find(key => paginationMap[Number(key)].includes(pageIdx)) + if (newMapIdx === undefined) return prevMapIdx + return Number(newMapIdx) + }) + setCurrentPageIndex(pageIdx) + } else { + setCurrentPageIndex(pageIdx) + setScrollSignal(p => p + 1) + } + }, [readingMode, paginationMap]) + + const { allPagesLoaded } = useHandleChapterPageStatus(pageContainer) + + if (!entry) return null + + return ( + <> + {(pageContainer && readerProgressBar && allPagesLoaded) && <div + data-manga-reader-bar-container + className={cn( + "bottom-12 w-full fixed z-10 hidden lg:block group/bp", + hiddenBar && "bottom-0", + )} + > + <div data-manga-reader-bar-inner-container className="flex max-w-full items-center"> + {pageContainer.pages?.map((_, index) => ( + <div + key={index} + data-manga-reader-bar-pagination + className={cn( + "w-full h-6 cursor-pointer", + "transition-all duration-200 bg-gradient-to-t via-transparent from-transparent from-10% to-transparent hover:from-gray-800", + index === currentPageIndex && "from-gray-800", + index < currentPageIndex && "from-[--subtle] from-5%", + )} + onClick={() => handlePageChange(index)} + > + <p + className={cn( + "w-full h-full flex items-center rounded-t-md justify-center text-transparent group-hover/bp:text-[--muted] transition", + "hover:text-white hover:bg-gray-800", + index === currentPageIndex && "text-white hover:text-white group-hover/bp:text-white", + )} + >{index + 1}</p> + </div> + ))} + </div> + </div>} + + + <div + data-manga-reader-bar + className={cn( + "fixed bottom-0 w-full h-12 gap-4 flex items-center px-4 z-[10] bg-[var(--background)] transition-transform", + hiddenBar && "translate-y-60", + )} id="manga-reader-bar" + > + + <IconButton + icon={<BiX />} + rounded + intent="gray-outline" + size="xs" + onClick={() => setSelectedChapter(undefined)} + /> + + <h4 data-manga-reader-bar-title className="lg:flex gap-1 items-center hidden"> + <span className="max-w-[180px] text-ellipsis truncate block">{entry?.media?.title?.userPreferred}</span> + </h4> + + {!!selectedChapter && + <div data-manga-reader-bar-chapter-nav-container className="flex gap-3 items-center flex-none whitespace-nowrap "> + <ChapterNavButton dir="left" /> + <span className="hidden md:inline-block">Chapter </span> + {`${selectedChapter?.chapterNumber}`} + <ChapterNavButton dir="right" /> + </div>} + + <div data-manga-reader-bar-spacer className="flex flex-1"></div> + + <div data-manga-reader-bar-page-container className="flex items-center gap-2"> + + {pageContainer && <Popover + trigger={ + <Badge + size="lg" + className="w-fit cursor-pointer rounded-[--radius-md] z-[5] flex bg-gray-950 items-center bottom-2 focus-visible:outline-none" + tabIndex={-1} + data-manga-reader-bar-page-container-badge + > + {!!(currentPageIndex + 1) && ( + <p className=""> + {currentPageIndex + 1}{secondPageText} + <span className="text-[--muted]"> / {pageContainer?.pages?.length}</span> + </p> + )} + </Badge> + } + > + <Select + data-manga-reader-bar-page-container-select + options={pageContainer.pages?.map((_, index) => ({ label: String(index + 1), value: String(index) })) ?? []} + value={String(currentPageIndex)} + onValueChange={e => { + handlePageChange(Number(e)) + }} + /> + </Popover>} + + <div data-manga-reader-bar-info-container className="hidden lg:flex"> + <Popover + modal={true} + trigger={ + <IconButton + icon={<LuInfo />} + intent="gray-basic" + className="opacity-50 outline-0" + tabIndex={-1} + /> + } + className="text-[--muted] space-y-1" + > + <div data-manga-reader-bar-info-container-provider className="hidden lg:block"> + <p className="text-[--muted] text-sm">{selectedChapter?.provider}</p> + </div> + <div data-manga-reader-bar-info-container-mode className="flex items-center gap-1"> + <span className="text-white w-6">m:</span> + {MANGA_READING_MODE_OPTIONS.find((option) => option.value === readingMode)?.label} + </div> + <div data-manga-reader-bar-info-container-fit className="flex items-center gap-1"> + <span className="text-white w-6">f:</span> + {MANGA_PAGE_FIT_OPTIONS.find((option) => option.value === pageFit)?.label} + </div> + {pageStretch !== MangaPageStretch.NONE && + <div data-manga-reader-bar-info-container-stretch className="flex items-center gap-1"> + <span className="text-white w-6">s:</span> + {MANGA_PAGE_STRETCH_OPTIONS.find((option) => option.value === pageStretch)?.label} + </div>} + {readingMode !== MangaReadingMode.LONG_STRIP && ( + <div data-manga-reader-bar-info-container-direction className="flex items-center gap-1"> + <span className="text-white w-6">d:</span> + <span>{MANGA_READING_DIRECTION_OPTIONS.find((option) => option.value === readingDirection)?.label}</span> + </div> + )} + </Popover> + </div> + + + <ChapterReaderSettings mediaId={entry.mediaId} /> + + </div> + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/manga-entry-card-unread-badge.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/manga-entry-card-unread-badge.tsx new file mode 100644 index 0000000..bb6c618 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_containers/manga-entry-card-unread-badge.tsx @@ -0,0 +1,61 @@ +import { __mangaLibrary_latestChapterNumbersAtom as __mangaLibrary_currentMangaDataAtom } from "@/app/(main)/manga/_lib/handle-manga-collection" +import { Badge } from "@/components/ui/badge" +import { useThemeSettings } from "@/lib/theme/hooks" +import { useAtom } from "jotai" +import React from "react" +import { IoBookOutline } from "react-icons/io5" +import { getMangaEntryLatestChapterNumber } from "../_lib/handle-manga-selected-provider" + +type MangaEntryCardUnreadBadgeProps = { + mediaId: number + progress?: number + progressTotal?: number +} + +export function MangaEntryCardUnreadBadge(props: MangaEntryCardUnreadBadgeProps) { + + const { + mediaId, + progress, + progressTotal: _progressTotal, + ...rest + } = props + + const { showMangaUnreadCount } = useThemeSettings() + const [mangaData] = useAtom(__mangaLibrary_currentMangaDataAtom) + + const [progressTotal, setProgressTotal] = React.useState(_progressTotal || 0) + + React.useEffect(() => { + const latestChapterNumber = getMangaEntryLatestChapterNumber(mediaId, + mangaData.latestChapterNumbers, + mangaData.storedProviders, + mangaData.storedFilters) + if (latestChapterNumber) { + setProgressTotal(latestChapterNumber) + } + }, [mangaData]) + + if (!showMangaUnreadCount) return null + + const unread = progressTotal - (progress || 0) + + if (unread <= 0) return null + + return ( + <div className="flex w-full z-[5]" data-manga-entry-card-unread-badge-container> + <Badge + intent="unstyled" + size="lg" + className="text-sm tracking-wide rounded-[--radius-md] flex gap-1 items-center rounded-tr-none rounded-bl-none border-0 px-1.5" + data-manga-entry-card-unread-badge + > + <IoBookOutline className="text-sm" /><span className="text-[--foreground] font-normal">{unread}</span> + </Badge> + </div> + ) + + // return ( + // <MediaEntryProgressBadge progress={progress} progressTotal={progressTotal} {...rest} /> + // ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-chapter-reader.ts b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-chapter-reader.ts new file mode 100644 index 0000000..491783a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-chapter-reader.ts @@ -0,0 +1,418 @@ +import { Manga_ChapterContainer, Manga_PageContainer, Nullish } from "@/api/generated/types" +import { useMangaEntryDownloadedChapters } from "@/app/(main)/manga/_lib/handle-manga-downloads" +import { getDecimalFromChapter, isChapterAfter, isChapterBefore } from "@/app/(main)/manga/_lib/handle-manga-utils" +import { + __manga_currentPageIndexAtom, + __manga_currentPaginationMapIndexAtom, + __manga_doublePageOffsetAtom, + __manga_pageFitAtom, + __manga_pageStretchAtom, + __manga_paginationMapAtom, + __manga_readingDirectionAtom, + __manga_readingModeAtom, + MangaPageFit, + MangaPageStretch, + MangaReadingDirection, + MangaReadingMode, +} from "@/app/(main)/manga/_lib/manga-chapter-reader.atoms" +import { logger } from "@/lib/helpers/debug" +import { atom } from "jotai" +import { useAtom, useAtomValue, useSetAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import mousetrap from "mousetrap" +import React from "react" + +const __manga_readerLoadedPagesAtom = atom<number[]>([]) + +export function useHandleChapterPageStatus(pageContainer: Manga_PageContainer | undefined) { + const currentChapter = useCurrentChapter() + + /** + * Keep track of loaded page indexes + * - Well compare the length to the number of pages to determine if we can show the progress bar + */ + const [loadedPages, setLoadedPages] = useAtom(__manga_readerLoadedPagesAtom) + + React.useEffect(() => { + setLoadedPages([]) + }, [currentChapter]) + + const handlePageLoad = React.useCallback((pageIndex: number) => { + setLoadedPages(prev => [...prev, pageIndex]) + }, []) + + return { + allPagesLoaded: loadedPages.length > 0 && loadedPages.length === pageContainer?.pages?.length, + loadedPages, + handlePageLoad, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Current chapter being read + */ +export type MangaReader_SelectedChapter = { + chapterNumber: string + provider: string + chapterId: string + mediaId: number +} + +/** + * Stores the current chapter being read + */ +export const __manga_selectedChapterAtom = atomWithStorage<MangaReader_SelectedChapter | undefined>("sea-manga-chapter", + undefined, + undefined, + { getOnInit: true }) + +export function useSetCurrentChapter() { + return useSetAtom(__manga_selectedChapterAtom) +} + +export function useCurrentChapter() { + return useAtomValue(__manga_selectedChapterAtom) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function useHandleChapterPagination(mId: Nullish<string | number>, chapterContainer: Manga_ChapterContainer | undefined) { + const currentChapter = useCurrentChapter() + const setCurrentChapter = useSetCurrentChapter() + /** + * Get the entry's downloaded chapters from the atom + */ + const entryDownloadedChapters = useMangaEntryDownloadedChapters() + + // Get previous and next chapters + const previousChapter = React.useMemo<MangaReader_SelectedChapter | undefined>(() => { + if (!mId) return undefined + if (!currentChapter) return undefined + + // First, look in downloaded chapters + // e.g., current is 14.2, look for the highest chapter number that is less than 14.2 + const _1 = entryDownloadedChapters + .filter(ch => ch.chapterId !== currentChapter.chapterId) + .sort((a, b) => getDecimalFromChapter(b.chapterNumber) - getDecimalFromChapter(a.chapterNumber)) // Sort in descending order + .find(ch => isChapterBefore(ch.chapterNumber, currentChapter.chapterNumber)) // Find the first chapter that is before the current chapter + // Save the chapter if it exists + const downloadedCh = _1 ? { + chapterId: _1.chapterId, + chapterNumber: _1.chapterNumber, + provider: _1.provider as string, + mediaId: Number(mId), + } : undefined + + // Return it if there's no container + if (!chapterContainer?.chapters) return downloadedCh + + // Look for the previous chapter in the chapter container + const idx = chapterContainer.chapters.findIndex((chapter) => chapter.id === currentChapter?.chapterId) + + let previousContainerCh: MangaReader_SelectedChapter | undefined = undefined + if (idx !== -1 && !!chapterContainer.chapters[idx - 1]) { + previousContainerCh = { + chapterId: chapterContainer.chapters[idx - 1].id, + chapterNumber: chapterContainer.chapters[idx - 1].chapter, + provider: chapterContainer.chapters[idx - 1].provider as string, + mediaId: chapterContainer.mediaId, + } + } + + // Look in the chapter container, but this time, by sorting the chapters in descending order to find the adjacent chapter + let _2 = chapterContainer.chapters + .filter(ch => ch.id !== currentChapter.chapterId) + .sort((a, b) => getDecimalFromChapter(b.chapter) - getDecimalFromChapter(a.chapter)) + .find(ch => isChapterBefore(ch.chapter, currentChapter.chapterNumber)) + const adjacentContainerCh = _2 ? { + chapterId: _2.id, + chapterNumber: _2.chapter, + provider: _2.provider as string, + mediaId: chapterContainer.mediaId, + } : undefined + + // Now we compare the three options and return the one that is closer to the current chapter + const chapters = [downloadedCh, previousContainerCh, adjacentContainerCh].filter(Boolean) + if (chapters.length === 0) return undefined + if (chapters.length === 1) return chapters[0] + + const returnedCh = chapters.reduce((prev, curr) => { + if (!prev) return curr + if (!curr) return prev + const prevDiff = Math.abs(getDecimalFromChapter(prev.chapterNumber) - getDecimalFromChapter(currentChapter.chapterNumber)) + const currDiff = Math.abs(getDecimalFromChapter(curr.chapterNumber) - getDecimalFromChapter(currentChapter.chapterNumber)) + return prevDiff < currDiff ? prev : curr + }, chapters[0]) + + // Make sure to always return the downloaded chapter if it exists + if (!!downloadedCh && getDecimalFromChapter(downloadedCh.chapterNumber) === getDecimalFromChapter(returnedCh.chapterNumber)) { + return downloadedCh + } + + return returnedCh + }, [mId, currentChapter, entryDownloadedChapters, chapterContainer?.chapters]) + + const nextChapter = React.useMemo<MangaReader_SelectedChapter | undefined>(() => { + if (!mId) return undefined + if (!currentChapter) return undefined + + // First, look in downloaded chapters + // e.g., current is 14.2, look for the lowest chapter number that is greater than 14.2 + const _1 = entryDownloadedChapters + .filter(ch => ch.chapterId !== currentChapter.chapterId) + .sort((a, b) => getDecimalFromChapter(a.chapterNumber) - getDecimalFromChapter(b.chapterNumber)) // Sort in ascending order + .find(ch => isChapterAfter(ch.chapterNumber, currentChapter.chapterNumber)) // Find the first chapter that is after the current chapter + // Save the chapter if it exists + const downloadedCh = _1 ? { + chapterId: _1.chapterId, + chapterNumber: _1.chapterNumber, + provider: _1.provider as string, + mediaId: Number(mId), + } : undefined + + // Return it if there's no container + if (!chapterContainer?.chapters) return downloadedCh + + // Look for the next chapter in the chapter container + const idx = chapterContainer.chapters.findIndex((chapter) => chapter.id === currentChapter?.chapterId) + + let nextContainerCh: MangaReader_SelectedChapter | undefined = undefined + if (idx !== -1 && !!chapterContainer.chapters[idx + 1]) { + nextContainerCh = { + chapterId: chapterContainer.chapters[idx + 1].id, + chapterNumber: chapterContainer.chapters[idx + 1].chapter, + provider: chapterContainer.chapters[idx + 1].provider as string, + mediaId: chapterContainer.mediaId, + } + } + + // Look in the chapter container, but this time, by sorting the chapters in ascending order to find the adjacent chapter + let _2 = chapterContainer.chapters + .filter(ch => ch.id !== currentChapter.chapterId) + .sort((a, b) => getDecimalFromChapter(a.chapter) - getDecimalFromChapter(b.chapter)) + .find(ch => isChapterAfter(ch.chapter, currentChapter.chapterNumber)) + const adjacentContainerCh = _2 ? { + chapterId: _2.id, + chapterNumber: _2.chapter, + provider: _2.provider as string, + mediaId: chapterContainer.mediaId, + } : undefined + + // Now we compare the three options and return the one that is closer to the current chapter + const chapters = [downloadedCh, nextContainerCh, adjacentContainerCh].filter(Boolean) + if (chapters.length === 0) return undefined + if (chapters.length === 1) return chapters[0] + + const returnedCh = chapters.reduce((prev, curr) => { + if (!prev) return curr + if (!curr) return prev + const prevDiff = Math.abs(getDecimalFromChapter(prev.chapterNumber) - getDecimalFromChapter(currentChapter.chapterNumber)) + const currDiff = Math.abs(getDecimalFromChapter(curr.chapterNumber) - getDecimalFromChapter(currentChapter.chapterNumber)) + return prevDiff < currDiff ? prev : curr + }, chapters[0]) + + // Make sure to always return the downloaded chapter if it exists + if (!!downloadedCh && getDecimalFromChapter(downloadedCh.chapterNumber) === getDecimalFromChapter(returnedCh.chapterNumber)) { + return downloadedCh + } + + return returnedCh + }, [mId, currentChapter, entryDownloadedChapters, chapterContainer?.chapters]) + + const goToChapter = React.useCallback((dir: "previous" | "next") => { + if (dir === "previous" && previousChapter) { + logger("handle-chapter-reader").info("Going to previous chapter", previousChapter) + setCurrentChapter(previousChapter) + } else if (dir === "next" && nextChapter) { + logger("handle-chapter-reader").info("Going to next chapter", nextChapter) + setCurrentChapter(nextChapter) + } + }, [setCurrentChapter, previousChapter, nextChapter]) + + return { + goToChapter, + previousChapter, + nextChapter, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + +export function useHydrateMangaPaginationMap(pageContainer?: Manga_PageContainer) { + // Current chapter + const selectedChapter = useAtomValue(__manga_selectedChapterAtom) + const readingMode = useAtomValue(__manga_readingModeAtom) + + // Global page index + const currentPageIndex = useAtomValue(__manga_currentPageIndexAtom) + + const [doublePageOffset, setDoublePageOffset] = useAtom(__manga_doublePageOffsetAtom) + + /** + * Pagination map is used to determine how many pages to display at once. + * The key is the index of the map, and the value is an array of page indexes to display. + * e.g. { 0: [0, 1], 1: [2], 2: [3, 4], ... } + */ + const setPaginationMap = useSetAtom(__manga_paginationMapAtom) + const setCurrentMapIndex = useSetAtom(__manga_currentPaginationMapIndexAtom) + + React.useEffect(() => { + if (!pageContainer?.pages?.length) return + + let _map = {} as Record<number, number[]> + /** + * Setting [paginationMap] + * If the reading mode is PAGED or the page dimensions are not set, we display each page individually. + * i.e. [0], [1], [2], [3], ... + */ + if ( + readingMode === MangaReadingMode.PAGED + || readingMode === MangaReadingMode.LONG_STRIP + || (!pageContainer.pageDimensions || Object.keys(pageContainer.pageDimensions).length === 0) + ) { + let i = 0 + while (i < pageContainer?.pages?.length) { + _map[i] = [i] + i++ + } + setPaginationMap(_map) + } else { + // \/ Double Page logic + + /** + * Get the recurring width of the pages to determine the threshold for displaying full spreads. + */ + let fullSpreadThreshold = 2000 + const recWidth = getRecurringNumber(Object.values(pageContainer.pageDimensions).map(n => n.width)) + if (!!recWidth && recWidth > 0) { + fullSpreadThreshold = recWidth + 50 // Add padding to the width to account for any discrepancies + } + + const map = new Map<number, number[]>() + + /** + * Hydrate the map with the page indexes to display. + * This basically groups pages with the same width together. + * Pages with a width greater than [fullSpreadThreshold] are displayed individually. + * e.g. If Page index 2 has a larger width -> [0, 1], [2], [3, 4], ... + */ + let i = 0 + let mapI = 0 + while (i < pageContainer.pages.length) { + + if (doublePageOffset > 0 && i + 1 <= doublePageOffset) { + map.set(mapI, [pageContainer.pages[i].index]) + i++ + mapI++ + continue + } + + const width = pageContainer.pageDimensions?.[i]?.width || 0 + if (width > fullSpreadThreshold) { + map.set(mapI, [pageContainer.pages[i].index]) + i++ + } else if ( + !!pageContainer.pages[i + 1] + && !(!!pageContainer.pageDimensions?.[i + 1]?.width && pageContainer.pageDimensions?.[i + 1]?.width > fullSpreadThreshold) + ) { + map.set(mapI, [pageContainer.pages[i].index, pageContainer.pages[i + 1].index]) + i += 2 + } else { + map.set(mapI, [pageContainer.pages[i].index]) + i++ + } + mapI++ + } + map.forEach((value, key) => { + _map[key] = value + }) + // Set the pagination map to the newly created map + setPaginationMap(_map) + map.clear() + } + + /** + * This handles navigating to the correct map index when switching reading modes + * + * After setting the pagination map, we need to determine which map index to scroll to. + * This is done by finding the map index that contains the current page index. + */ + let mapIndexToScroll = 0 + for (const [index, pages] of Object.entries(_map)) { + if (pages.includes(currentPageIndex)) { + mapIndexToScroll = Number(index) + break + } + } + // Set the current map index to the map index to scroll to + setCurrentMapIndex(mapIndexToScroll) + + }, [pageContainer?.pages, readingMode, selectedChapter, doublePageOffset]) + +} + +export function useSwitchSettingsWithKeys() { + const [readingMode, setReadingMode] = useAtom(__manga_readingModeAtom) + const [readingDirection, setReadingDirection] = useAtom(__manga_readingDirectionAtom) + const [pageFit, setPageFit] = useAtom(__manga_pageFitAtom) + const [pageStretch, setPageStretch] = useAtom(__manga_pageStretchAtom) + const [doublePageOffset, setDoublePageOffset] = useAtom(__manga_doublePageOffsetAtom) + + const switchValue = (currentValue: string, possibleValues: string[], setValue: (v: any) => void) => { + const currentIndex = possibleValues.indexOf(currentValue) + const nextIndex = (currentIndex + 1) % possibleValues.length + setValue(possibleValues[nextIndex]) + } + + const incrementOffset = () => { + setDoublePageOffset(prev => Math.max(0, prev + 1)) + } + + const decrementOffset = () => { + setDoublePageOffset(prev => Math.max(0, prev - 1)) + } + + React.useEffect(() => { + mousetrap.bind("m", () => switchValue(readingMode, Object.values(MangaReadingMode), setReadingMode)) + mousetrap.bind("d", () => switchValue(readingDirection, Object.values(MangaReadingDirection), setReadingDirection)) + mousetrap.bind("f", () => switchValue(pageFit, Object.values(MangaPageFit), setPageFit)) + mousetrap.bind("s", () => switchValue(pageStretch, Object.values(MangaPageStretch), setPageStretch)) + mousetrap.bind("shift+right", () => incrementOffset()) + mousetrap.bind("shift+left", () => decrementOffset()) + + return () => { + mousetrap.unbind("m") + mousetrap.unbind("d") + mousetrap.unbind("f") + mousetrap.unbind("s") + mousetrap.unbind("shift+right") + mousetrap.unbind("shift+left") + } + }, [readingMode, readingDirection, pageFit, pageStretch, doublePageOffset]) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +function getRecurringNumber(arr: number[]): number | undefined { + const counts = new Map<number, number>() + + arr.forEach(num => { + counts.set(num, (counts.get(num) || 0) + 1) + }) + + let highestCount = 0 + let highestNumber: number | undefined + + counts.forEach((count, num) => { + if (count > highestCount) { + highestCount = count + highestNumber = num + } + }) + + return highestNumber +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-discord-manga-presence.ts b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-discord-manga-presence.ts new file mode 100644 index 0000000..2b0bc03 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-discord-manga-presence.ts @@ -0,0 +1,41 @@ +import { AL_BaseManga } from "@/api/generated/types" +import { useCancelDiscordActivity, useSetDiscordMangaActivity } from "@/api/hooks/discord.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" + +import { __manga_selectedChapterAtom } from "@/app/(main)/manga/_lib/handle-chapter-reader" +import { useAtomValue } from "jotai/react" +import React from "react" + +export function useDiscordMangaPresence(entry: { media?: AL_BaseManga } | undefined) { + const serverStatus = useServerStatus() + const currentChapter = useAtomValue(__manga_selectedChapterAtom) + + const { mutate } = useSetDiscordMangaActivity() + const { mutate: cancelActivity } = useCancelDiscordActivity() + + const prevChapter = React.useRef<any>() + + React.useEffect(() => { + if (serverStatus?.isOffline) return + if ( + serverStatus?.settings?.discord?.enableRichPresence && + serverStatus?.settings?.discord?.enableMangaRichPresence + ) { + + if (currentChapter && entry && entry.media) { + mutate({ + mediaId: entry.media?.id ?? 0, + title: entry.media?.title?.userPreferred || entry.media?.title?.romaji || entry.media?.title?.english || "Reading", + image: entry.media?.coverImage?.large || entry.media?.coverImage?.medium || "", + chapter: currentChapter.chapterNumber, + }) + } + + if (!currentChapter) { + cancelActivity() + } + } + + prevChapter.current = currentChapter + }, [currentChapter, entry]) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-chapters.ts b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-chapters.ts new file mode 100644 index 0000000..9bc452b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-chapters.ts @@ -0,0 +1,142 @@ +import { useGetMangaEntryChapters } from "@/api/hooks/manga.hooks" +import { useHandleMangaProviderExtensions } from "@/app/(main)/manga/_lib/handle-manga-providers" +import { useSelectedMangaFilters, useSelectedMangaProvider } from "@/app/(main)/manga/_lib/handle-manga-selected-provider" +import { LANGUAGES_LIST } from "@/app/(main)/manga/_lib/language-map" +import { uniq, uniqBy } from "lodash" +import React from "react" + +export function useHandleMangaChapters( + mediaId: string | null, +) { + + /** + * 1. Fetch the provider extensions + */ + const { providerExtensions, providerOptions, providerExtensionsLoading } = useHandleMangaProviderExtensions(mediaId) + + /** + * 2. Get the selected provider for this entry + */ + const { + selectedExtension, + selectedProvider, + setSelectedProvider, + } = useSelectedMangaProvider(mediaId) + + + /** + * 3. Fetch the chapters for this entry + */ + const { + data: chapterContainer, + isLoading: chapterContainerLoading, + isError: chapterContainerError, + } = useGetMangaEntryChapters({ + mediaId: Number(mediaId), + provider: selectedProvider || undefined, + }) + + + const _scanlatorOptions = React.useMemo(() => { + if (!selectedExtension) return [] + if (!selectedExtension.settings?.supportsMultiScanlator) return [] + + const scanlators = uniq(chapterContainer?.chapters?.map(chapter => chapter.scanlator)?.filter(Boolean) || []) + return scanlators.map(scanlator => ({ value: scanlator, label: scanlator })) + }, [selectedExtension, chapterContainer]) + + const _languageOptions = React.useMemo(() => { + if (!selectedExtension) return [] + if (!selectedExtension.settings?.supportsMultiLanguage) return [] + + const languages = chapterContainer?.chapters?.map(chapter => { + const language = chapter.language + if (!language) return null + return { + language: language, + scanlator: chapter.scanlator, + } + })?.filter(Boolean) || [] + + return languages.map(lang => ({ value: lang, label: ((LANGUAGES_LIST as any)[lang.language as any] as any)?.nativeName || lang })) + }, [selectedExtension, chapterContainer]) + + + + /** + * 4. Filters + */ + const { setSelectedScanlator, setSelectedLanguage, selectedFilters } = useSelectedMangaFilters( + mediaId, + selectedExtension, + selectedProvider, + // languageOptions.map(n => n.value), + // scanlatorOptions.map(n => n.value), + !chapterContainerLoading, + ) + + /** + * 5. Filter chapters based on language and scanlator + */ + const filteredChapterContainer = React.useMemo(() => { + if (!chapterContainer) return chapterContainer + + const filteredChapters = chapterContainer.chapters?.filter(ch => { + if (selectedExtension?.settings?.supportsMultiLanguage && selectedFilters.language) { + if (ch.language !== selectedFilters.language) return false + } + if (selectedExtension?.settings?.supportsMultiScanlator && selectedFilters.scanlators[0]) { + if (ch.scanlator !== selectedFilters.scanlators[0]) return false + } + return true + }) + + return { + ...chapterContainer, + chapters: filteredChapters, + } + }, [chapterContainer, selectedExtension, selectedFilters]) + + // Filter language options based on scanlator + const languageOptions = React.useMemo(() => { + return uniqBy(_languageOptions.filter(lang => { + if (!!selectedFilters?.scanlators?.[0]?.length) { + return lang.value.scanlator === selectedFilters.scanlators[0] + } + return true + })?.map(lang => ({ value: lang.value.language, label: lang.label })) || [], "value") + }, [_languageOptions, selectedFilters]) + + // Filter scanlator options based on language + const scanlatorOptions = React.useMemo(() => { + return uniqBy(_scanlatorOptions.filter(scanlator => { + if (!!selectedFilters?.language?.length) { + return _languageOptions.filter(n => + n.value.scanlator === scanlator.value + && n.value.language === selectedFilters.language, + ).length > 0 + } + return true + })?.map(scanlator => ({ value: scanlator.value, label: scanlator.label })) || [], "value") + }, [_scanlatorOptions, selectedFilters, _languageOptions]) + + return { + selectedExtension, + providerExtensions, + providerExtensionsLoading, + // Selected provider + providerOptions, // For dropdown + selectedProvider, // Current provider + setSelectedProvider, + // Filters + selectedFilters, + setSelectedLanguage, + setSelectedScanlator, + languageOptions, + scanlatorOptions, + // Chapters + chapterContainer: filteredChapterContainer, + chapterContainerLoading, + chapterContainerError, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-collection.ts b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-collection.ts new file mode 100644 index 0000000..7b04857 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-collection.ts @@ -0,0 +1,185 @@ +import { Manga_Collection, Manga_MangaLatestChapterNumberItem } from "@/api/generated/types" +import { useListMangaProviderExtensions } from "@/api/hooks/extensions.hooks" +import { useGetMangaCollection, useGetMangaLatestChapterNumbersMap } from "@/api/hooks/manga.hooks" +import { CollectionParams, DEFAULT_COLLECTION_PARAMS, filterCollectionEntries, filterMangaCollectionEntries } from "@/lib/helpers/filtering" +import { useThemeSettings } from "@/lib/theme/hooks" +import { atomWithImmer } from "jotai-immer" +import { useAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import { useRouter } from "next/navigation" +import React from "react" +import { MangaEntryFilters, useStoredMangaFilters, useStoredMangaProviders } from "./handle-manga-selected-provider" + +export const MANGA_LIBRARY_DEFAULT_PARAMS: CollectionParams<"manga"> = { + ...DEFAULT_COLLECTION_PARAMS, + sorting: "TITLE", + unreadOnly: false, +} + +export const __mangaLibrary_unreadOnlyAtom = atomWithStorage("sea-manga-library-unread-only", false, undefined, { getOnInit: true }) + +export const __mangaLibrary_paramsAtom = atomWithImmer<CollectionParams<"manga">>(MANGA_LIBRARY_DEFAULT_PARAMS) + +export const __mangaLibrary_paramsInputAtom = atomWithImmer<CollectionParams<"manga">>(MANGA_LIBRARY_DEFAULT_PARAMS) + +export const __mangaLibrary_latestChapterNumbersAtom = atomWithImmer<{ + latestChapterNumbers: Record<number, Manga_MangaLatestChapterNumberItem[]> + storedProviders: Record<string, string> + storedFilters: Record<string, MangaEntryFilters> +}>({ + latestChapterNumbers: {}, + storedProviders: {}, + storedFilters: {}, +}) + +/** + * Get the manga collection + */ +export function useHandleMangaCollection() { + const router = useRouter() + const { data, isLoading, isError } = useGetMangaCollection() + + // const { data: chapterCounts } = useGetMangaChapterCountMap() + const { data: latestChapterNumbers } = useGetMangaLatestChapterNumbersMap() + const { data: _extensions } = useListMangaProviderExtensions() + + const { mangaLibraryCollectionDefaultSorting } = useThemeSettings() + + React.useEffect(() => { + if (isError) { + router.push("/") + } + }, [isError]) + + const { storedProviders } = useStoredMangaProviders(_extensions) + const { storedFilters } = useStoredMangaFilters(_extensions, storedProviders) + + const [, setLatestChapterNumbers] = useAtom(__mangaLibrary_latestChapterNumbersAtom) + React.useEffect(() => { + if (latestChapterNumbers) { + setLatestChapterNumbers({ + latestChapterNumbers: latestChapterNumbers, + storedProviders, + storedFilters, + }) + } + }, [storedProviders, storedFilters, latestChapterNumbers]) + + const [params, setParams] = useAtom(__mangaLibrary_paramsAtom) + const [unreadOnly, setUnreadOnly] = useAtom(__mangaLibrary_unreadOnlyAtom) + + const mountedRef = React.useRef(false) + React.useEffect(() => { + if (mountedRef.current) return + setParams(draft => { + draft.unreadOnly = unreadOnly + return + }) + setTimeout(() => { + mountedRef.current = true + }, 500) + }, []) + + // Reset params when data changes + React.useEffect(() => { + if (!!data) { + const defaultParams = { ...MANGA_LIBRARY_DEFAULT_PARAMS, unreadOnly } + setParams(defaultParams) + } + }, [data, unreadOnly]) + + // Sync unreadOnly to persistent storage when params change + React.useEffect(() => { + if (mountedRef.current && params.unreadOnly !== unreadOnly) { + setUnreadOnly(params.unreadOnly) + } + }, [params.unreadOnly]) + + const genres = React.useMemo(() => { + const genresSet = new Set<string>() + data?.lists?.forEach(l => { + l.entries?.forEach(e => { + e.media?.genres?.forEach(g => { + genresSet.add(g) + }) + }) + }) + return Array.from(genresSet)?.sort((a, b) => a.localeCompare(b)) + }, [data]) + + const sortedCollection = React.useMemo(() => { + if (!data || !data.lists) return data + + let _lists = data.lists.map(obj => { + if (!obj) return obj + + const newParams = { ...params, sorting: mangaLibraryCollectionDefaultSorting as any } + let arr = filterMangaCollectionEntries(obj.entries, newParams, true, storedProviders, storedFilters, latestChapterNumbers) + + // Reset `unreadOnly` if it's about to make the list disappear + if (arr.length === 0 && newParams.unreadOnly) { + const newParams = { ...params, unreadOnly: false, sorting: mangaLibraryCollectionDefaultSorting as any } + arr = filterMangaCollectionEntries(obj.entries, newParams, true, storedProviders, storedFilters, latestChapterNumbers) + } + + return { + type: obj.type, + status: obj.status, + entries: arr, + } + }) + + return { + lists: [ + _lists.find(n => n.type === "CURRENT"), + _lists.find(n => n.type === "PAUSED"), + _lists.find(n => n.type === "PLANNING"), + // data.lists.find(n => n.type === "COMPLETED"), // DO NOT SHOW THIS LIST IN MANGA VIEW + // data.lists.find(n => n.type === "DROPPED"), // DO NOT SHOW THIS LIST IN MANGA VIEW + ].filter(Boolean), + } as Manga_Collection + }, [data, params, storedProviders, storedFilters, latestChapterNumbers]) + + const filteredCollection = React.useMemo(() => { + if (!data || !data.lists) return data + + let _lists = data.lists.map(obj => { + if (!obj) return obj + + const newParams = { ...params, sorting: mangaLibraryCollectionDefaultSorting as any } + const arr = filterCollectionEntries("manga", obj.entries, newParams, true) + return { + type: obj.type, + status: obj.status, + entries: arr, + } + }) + return { + lists: [ + _lists.find(n => n.type === "CURRENT"), + _lists.find(n => n.type === "PAUSED"), + _lists.find(n => n.type === "PLANNING"), + // data.lists.find(n => n.type === "COMPLETED"), // DO NOT SHOW THIS LIST IN MANGA VIEW + // data.lists.find(n => n.type === "DROPPED"), // DO NOT SHOW THIS LIST IN MANGA VIEW + ].filter(Boolean), + } as Manga_Collection + }, [data, params]) + + const libraryGenres = React.useMemo(() => { + const allGenres = filteredCollection?.lists?.flatMap(l => { + return l.entries?.flatMap(e => e.media?.genres) ?? [] + }) + return [...new Set(allGenres)].filter(Boolean)?.sort((a, b) => a.localeCompare(b)) + }, [filteredCollection]) + + return { + genres, + hasManga: !!data?.lists?.some(l => !!l.entries?.length), + mangaCollection: sortedCollection, + filteredMangaCollection: filteredCollection, + mangaCollectionGenres: libraryGenres, + mangaCollectionLoading: isLoading, + storedFilters, + storedProviders, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-downloads.ts b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-downloads.ts new file mode 100644 index 0000000..a40cab2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-downloads.ts @@ -0,0 +1,145 @@ +import { HibikeManga_ChapterDetails, Manga_MediaDownloadData, Nullish } from "@/api/generated/types" +import { + useClearAllChapterDownloadQueue, + useDownloadMangaChapters, + useGetMangaDownloadData, + useGetMangaDownloadQueue, + useResetErroredChapterDownloadQueue, + useStartMangaDownloadQueue, + useStopMangaDownloadQueue, +} from "@/api/hooks/manga_download.hooks" +import { useSelectedMangaProvider } from "@/app/(main)/manga/_lib/handle-manga-selected-provider" +import { atom } from "jotai" +import { useAtomValue, useSetAtom } from "jotai/react" +import React from "react" +import { toast } from "sonner" + +/** + * Stores fetched manga download data + */ +const __manga_entryDownloadDataAtom = atom<Manga_MediaDownloadData | undefined>(undefined) + +export type MangaDownloadChapterItem = { provider: string, chapterId: string, chapterNumber: string, queued: boolean, downloaded: boolean } + +/** + * @description + * - This atom transforms the download data into a list of chapters + */ +const __manga_entryDownloadedChaptersAtom = atom<MangaDownloadChapterItem[]>(get => { + let d: MangaDownloadChapterItem[] = [] + const data = get(__manga_entryDownloadDataAtom) + if (data) { + for (const provider in data.downloaded) { + d = d.concat(data.downloaded[provider].map(ch => ({ + provider, + chapterId: ch.chapterId, + chapterNumber: ch.chapterNumber, + queued: false, + downloaded: true, + }))) + } + for (const provider in data.queued) { + d = d.concat(data.queued[provider].map(ch => ({ + provider, + chapterId: ch.chapterId, + chapterNumber: ch.chapterNumber, + queued: true, + downloaded: false, + }))) + } + } + return d +}) + +export function useMangaEntryDownloadedChapters() { + return useAtomValue(__manga_entryDownloadedChaptersAtom) +} + +/** + * @description + * - Fetch manga download data and store it in a state + * - We store the download data in a state, so we can handle chapter pagination. + * For example, clicking "next chapter" will look for a downloaded chapter, and make a request with the appropriate provider + */ +export function useHandleMangaDownloadData(mediaId: Nullish<string | number>) { + const { data, isLoading, isError } = useGetMangaDownloadData({ + mediaId: mediaId ? Number(mediaId) : undefined, + }) + + const setDownloadData = useSetAtom(__manga_entryDownloadDataAtom) + React.useEffect(() => { + setDownloadData(data) + }, [data]) + + return { + downloadData: data, + downloadDataLoading: isLoading, + downloadDataError: isError, + } +} + +export function useMangaEntryDownloadData() { + return useAtomValue(__manga_entryDownloadDataAtom) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Handle downloading manga chapters + */ +export function useHandleDownloadMangaChapter(mediaId: string | undefined | null) { + const { selectedProvider } = useSelectedMangaProvider(mediaId) + + const { mutate, isPending } = useDownloadMangaChapters(mediaId, selectedProvider) + + return { + downloadChapters: (chapters: HibikeManga_ChapterDetails[]) => { + if (selectedProvider) { + mutate({ + mediaId: Number(mediaId), + provider: selectedProvider, + chapterIds: chapters.map(ch => ch.id), + startNow: false, + }, { + onSuccess: () => { + toast.success("Chapters added to download queue") + }, + }) + } + }, + isSendingDownloadRequest: isPending, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Handle the manga chapter download queue + */ +export function useHandleMangaChapterDownloadQueue() { + + const { data, isLoading, isError } = useGetMangaDownloadQueue() + + const { mutate: start, isPending: isStarting } = useStartMangaDownloadQueue() + + const { mutate: stop, isPending: isStopping } = useStopMangaDownloadQueue() + + const { mutate: resetErrored, isPending: isResettingErrored } = useResetErroredChapterDownloadQueue() + + const { mutate: clearQueue, isPending: isClearingQueue } = useClearAllChapterDownloadQueue() + + return { + downloadQueue: data, + downloadQueueLoading: isLoading, + downloadQueueError: isError, + startDownloadQueue: start, + isStartingDownloadQueue: isStarting, + stopDownloadQueue: stop, + isStoppingDownloadQueue: isStopping, + resetErroredChapters: resetErrored, + isResettingErroredChapters: isResettingErrored, + clearDownloadQueue: clearQueue, + isClearingDownloadQueue: isClearingQueue, + } +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-providers.ts b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-providers.ts new file mode 100644 index 0000000..26b6c19 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-providers.ts @@ -0,0 +1,40 @@ +import { useListMangaProviderExtensions } from "@/api/hooks/extensions.hooks" +import { __manga_entryProviderAtom } from "@/app/(main)/manga/_lib/handle-manga-selected-provider" +import { useAtom } from "jotai/react" +import React from "react" + +export function useHandleMangaProviderExtensions(mId: string | null) { + + const { data: providerExtensions, isLoading: providersLoading } = useListMangaProviderExtensions() + + const [selectedProvider, setSelectedProvider] = useAtom(__manga_entryProviderAtom) + + + React.useLayoutEffect(() => { + if (!!providerExtensions?.length && !!selectedProvider && !!mId) { + + // Check if the selected provider is still available + // The provider should default to "DEFAULT_MANGA_PROVIDER" if it's the first time loading the entry + const isProviderAvailable = providerExtensions.some(provider => provider.id === selectedProvider[mId]) + + // Fall back to the first provider if the selected provider is not available + if (!isProviderAvailable && providerExtensions.length > 0) { + setSelectedProvider({ + ...selectedProvider, + [mId]: providerExtensions[0].id, + }) + } + } + }, [providerExtensions, selectedProvider, mId]) + + return { + providerExtensions: providerExtensions, + providerExtensionsLoading: providersLoading, + providerOptions: (providerExtensions ?? []).map(provider => ({ + label: provider.name, + value: provider.id, + })).sort((a, b) => a.label.localeCompare(b.label)), + // selectedProvider: !!mId ? selectedProvider[mId] : null, + } + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-selected-provider.ts b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-selected-provider.ts new file mode 100644 index 0000000..4e712a4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-selected-provider.ts @@ -0,0 +1,308 @@ +import { ExtensionRepo_MangaProviderExtensionItem, Manga_MangaLatestChapterNumberItem, Nullish, Status } from "@/api/generated/types" +import { useListMangaProviderExtensions } from "@/api/hooks/extensions.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { withImmer } from "jotai-immer" +import { useAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import { sortBy } from "lodash" +import React from "react" + +/** + * Stores the selected provider for each manga entry + */ +export const __manga_entryProviderAtom = atomWithStorage<Record<string, string>>("sea-manga-entry-provider", {}, undefined, { getOnInit: true }) + +// Key: "{mediaId}${providerId}" +// Value: { [filter]: string } +export type MangaEntryFilters = { + scanlators: string[] + language: string +} +export const __manga_entryFiltersAtom = atomWithStorage<Record<string, MangaEntryFilters>>("sea-manga-entry-filters", + {}, + undefined, + { getOnInit: true }) + +/** + * Helper function to get the default provider from server status or available extensions + */ +const getDefaultMangaProvider = ( + serverStatus: Status | undefined, + extensions: ExtensionRepo_MangaProviderExtensionItem[] | undefined, +) => { + return serverStatus?.settings?.manga?.defaultMangaProvider || extensions?.[0]?.id || null +} + +/** + * Returns a record of all stored manga providers + */ +export function useStoredMangaProviders(_extensions: ExtensionRepo_MangaProviderExtensionItem[] | undefined) { + const serverStatus = useServerStatus() + + const extensions = React.useMemo(() => { + return _extensions?.toSorted((a, b) => a.name.localeCompare(b.name)) + }, [_extensions]) + + const [storedProvider, setStoredProvider] = useAtom(__manga_entryProviderAtom) + + React.useLayoutEffect(() => { + if (!extensions || !serverStatus) return + const defaultProvider = getDefaultMangaProvider(serverStatus, extensions) + + // Remove invalid providers if there are no providers available + if (!defaultProvider || extensions.length === 0) { + setStoredProvider({}) + return + } + + // Validate all stored providers and replace invalid ones with default + const validatedProviders = { ...storedProvider } + let hasChanges = false + + Object.entries(storedProvider).forEach(([mediaId, providerId]) => { + const isProviderAvailable = extensions.some(provider => provider.id === providerId) + if (!isProviderAvailable) { + validatedProviders[mediaId] = defaultProvider + hasChanges = true + } + }) + + if (hasChanges) { + setStoredProvider(validatedProviders) + } + }, [storedProvider, extensions, serverStatus]) + + return { + storedProviders: storedProvider, + setStoredProvider: ({ mediaId, providerId }: { mediaId: string | number, providerId: string }) => { + if (!mediaId) return + setStoredProvider(prev => ({ + ...prev, + [String(mediaId)]: providerId, + })) + }, + } +} + +/** + * - Get the manga provider for a specific manga entry + * - Set the manga provider for a specific manga entry + */ +export function useSelectedMangaProvider(mId: Nullish<string | number>) { + const serverStatus = useServerStatus() + const { data: _extensions } = useListMangaProviderExtensions() + + const extensions = React.useMemo(() => { + return _extensions?.toSorted((a, b) => a.name.localeCompare(b.name)) + }, [_extensions]) + + const [storedProvider, setStoredProvider] = useAtom(__manga_entryProviderAtom) + + React.useLayoutEffect(() => { + if (!extensions || !serverStatus) return + const defaultProvider = getDefaultMangaProvider(serverStatus, extensions) + + // Remove the stored provider if there are no providers available + if (!defaultProvider || extensions.length === 0) { + setStoredProvider(prev => { + delete prev[String(mId)] + return prev + }) + return + } + + // (Case 1) No provider has been chosen yet for this manga + // -> Set the default provider & filters + if (!storedProvider?.[String(mId)]) { + setStoredProvider(prev => { + return { + ...prev, + [String(mId)]: defaultProvider, + } + }) + } else { + // (Case 2) There is a selected provider for this manga, but it's not available anymore in the extensions + const isProviderAvailable = extensions.some(provider => provider.id === storedProvider?.[String(mId)]) + // -> Fall back to the default provider + if (!isProviderAvailable && extensions.length > 0) { + setStoredProvider(prev => { + return { + ...prev, + [String(mId)]: defaultProvider, + } + }) + } + } + + }, [mId, storedProvider, extensions, serverStatus]) + + return { + selectedExtension: extensions?.find(provider => provider.id === storedProvider?.[String(mId)]), + selectedProvider: storedProvider?.[String(mId)] || null, + setSelectedProvider: ({ mId, provider }: { mId: Nullish<string | number>, provider: string }) => { + if (!mId) return + setStoredProvider(prev => { + return { + ...prev, + [String(mId)]: provider, + } + }) + } + } +} + +/** + * This function takes in the manga id, the selected extension, the selected provider, the languages, the scanlators, and the isLoaded flag + * It returns the stored filters for the manga entry + * It also returns the functions to set the scanlators and the language + */ +export function useSelectedMangaFilters( + mId: Nullish<string | number>, + selectedExtension: Nullish<ExtensionRepo_MangaProviderExtensionItem>, + selectedProvider: Nullish<string>, + isLoaded: boolean, +) { + + const [storedFilters, setStoredFilters] = useAtom(withImmer(__manga_entryFiltersAtom)) + + const key = `${String(mId)}$${selectedProvider}` + + React.useLayoutEffect(() => { + if (!isLoaded) return + + const defaultFilters: MangaEntryFilters = { + scanlators: [], + language: "", + } + + if (!selectedProvider) { + setStoredFilters(draft => { + delete draft[key] + return draft + }) + return + } + + // (Case 1) No filters have been chosen yet for this manga + // -> Set the default filters + if (!storedFilters[key] && (selectedExtension?.settings?.supportsMultiScanlator || selectedExtension?.settings?.supportsMultiLanguage)) { + setStoredFilters(draft => { + draft[key] = defaultFilters + return + }) + } + + }, [isLoaded, selectedExtension]) + + + return { + selectedFilters: storedFilters[key] || { scanlators: [], language: "" }, + setSelectedScanlator: ({ mId, scanlators }: { mId: Nullish<string | number>, scanlators: string[] }) => { + if (!mId) return + setStoredFilters(draft => { + draft[key]["scanlators"] = scanlators + return + }) + }, + setSelectedLanguage: ({ mId, language }: { mId: Nullish<string | number>, language: string }) => { + if (!mId) return + setStoredFilters(draft => { + draft[key]["language"] = language + return + }) + }, + } +} + +export function useStoredMangaFilters(_extensions: ExtensionRepo_MangaProviderExtensionItem[] | undefined, + selectedProviders: Record<string, string>, +) { + const [_storedFilters] = useAtom(withImmer(__manga_entryFiltersAtom)) + + const storedFilters = React.useMemo(() => { + let filters: Record<string, MangaEntryFilters> = {} + Object.entries(_storedFilters).map(([key, value]) => { + const [mangaId, providerId] = key.split("$") + const mangaProvider = selectedProviders[mangaId] + const extension = _extensions?.find(extension => extension.id === mangaProvider) + + if (extension?.settings?.supportsMultiScanlator || extension?.settings?.supportsMultiLanguage) { + filters[mangaId] = { + scanlators: value.scanlators ?? [], + language: value.language ?? "", + } + } + }) + return filters + }, [_storedFilters, _extensions, selectedProviders]) + + return { + storedFilters, + } +} + +export function getMangaEntryLatestChapterNumber( + mangaId: string | number, + latestChapterNumbers: Record<number, Manga_MangaLatestChapterNumberItem[]>, + storedProviders: Record<string, string>, + storedFilters: Record<string, MangaEntryFilters>, +) { + const provider = storedProviders[String(mangaId)] + const filters = storedFilters?.[String(mangaId)] + + if (!provider) return null + + const mangaLatestChapterNumbers = latestChapterNumbers[Number(mangaId)]?.filter(item => { + return item.provider === provider + }) + + let found: Manga_MangaLatestChapterNumberItem | null | undefined = null + + // If filters are set for this manga + if (!!filters) { + // Find entry with matching scanlator & language + found = mangaLatestChapterNumbers?.find(item => { + return !!filters.scanlators[0] && !!filters.language && + filters.scanlators[0] === item.scanlator && filters.language === item.language + }) + + // If no entry with matching scanlator & language is found, find entry with matching language + if (!found) { + // Get all entries with matching language + const entries = mangaLatestChapterNumbers?.filter(item => { + return !!filters.language && filters.language === item.language + }) ?? [] + + // Get the highest chapter number from all entries with matching language + found = sortBy(entries, "number").reverse()[0] + } + + // If no entry with matching language is found, find entry with matching scanlator + if (!found) { + // Get all entries with matching scanlator + const entries = mangaLatestChapterNumbers?.filter(item => { + return !!filters.scanlators[0] && filters.scanlators[0] === item.scanlator + }) ?? [] + + // Get the highest chapter number from all entries with matching scanlator + found = sortBy(entries, "number").reverse()[0] + } + } + + // If no filters are set or no entry is found for the filters, get the highest chapter number + if (!found) { + // Get the highest chapter number from any + const highestChapterNumber = mangaLatestChapterNumbers?.reduce((max, item) => { + return Math.max(max, item.number) + }, 0) + found = { + provider: provider, + language: "", + scanlator: "", + number: highestChapterNumber, + } + } + + return found?.number + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-utils.ts b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-utils.ts new file mode 100644 index 0000000..bb162fa --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/handle-manga-utils.ts @@ -0,0 +1,100 @@ +"use client" +import { getServerBaseUrl } from "@/api/client/server-url" +import { HibikeManga_ChapterDetails, Manga_MediaDownloadData } from "@/api/generated/types" +import { DataGridRowSelectedEvent } from "@/components/ui/datagrid/use-datagrid-row-selection" +import { RowSelectionState } from "@tanstack/react-table" +import React from "react" + +export function getChapterNumberFromChapter(chapter: string): number { + const chapterNumber = chapter.match(/(\d+(\.\d+)?)/)?.[0] + return chapterNumber ? Math.floor(parseFloat(chapterNumber)) : 0 +} + +export function getDecimalFromChapter(chapter: string): number { + const chapterNumber = chapter.match(/(\d+(\.\d+)?)/)?.[0] + return chapterNumber ? parseFloat(chapterNumber) : 0 +} + +export function isChapterBefore(a: string, b: string): boolean { + // compare the decimal part of the chapter number + return getDecimalFromChapter(a) < getDecimalFromChapter(b) +} + +export function isChapterAfter(a: string, b: string): boolean { + // compare the decimal part of the chapter number + return getDecimalFromChapter(a) > getDecimalFromChapter(b) +} + +export function useMangaReaderUtils() { + + const getChapterPageUrl = React.useCallback((url: string, isDownloaded: boolean | undefined, headers?: Record<string, string>) => { + if (url.startsWith("{{manga-local-assets}}")) { + return `${getServerBaseUrl()}/api/v1/manga/local-page/${encodeURIComponent(url)}` + } + + if (!isDownloaded) { + if (headers && Object.keys(headers).length > 0) { + return `${getServerBaseUrl()}/api/v1/image-proxy?url=${encodeURIComponent(url)}&headers=${encodeURIComponent( + JSON.stringify(headers))}` + } + return url + } + + return `${getServerBaseUrl()}/manga-downloads/${url}` + }, []) + return { + getChapterPageUrl, + } + +} + +export function useMangaDownloadDataUtils(data: Manga_MediaDownloadData | undefined, loading: boolean) { + + const isChapterLocal = React.useCallback((chapter: HibikeManga_ChapterDetails | undefined) => { + if (!chapter) return false + return chapter.provider === "local-manga" + }, []) + + const isChapterDownloaded = React.useCallback((chapter: HibikeManga_ChapterDetails | undefined) => { + if (!data || !chapter) return false + return (data?.downloaded[chapter.provider]?.findIndex(n => n.chapterId === chapter.id) ?? -1) !== -1 + }, [data]) + + const isChapterQueued = React.useCallback((chapter: HibikeManga_ChapterDetails | undefined) => { + if (!data || !chapter) return false + return (data?.queued[chapter.provider]?.findIndex(n => n.chapterId === chapter.id) ?? -1) !== -1 + }, [data]) + + const getProviderNumberOfDownloadedChapters = React.useCallback((provider: string) => { + if (!data) return 0 + return Object.keys(data.downloaded[provider] || {}).length + }, [data]) + return { + isChapterDownloaded, + isChapterQueued, + getProviderNumberOfDownloadedChapters, + showActionButtons: !loading, + isChapterLocal, + } + +} + +export function useMangaChapterListRowSelection() { + + const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) + + const [selectedChapters, setSelectedChapters] = React.useState<HibikeManga_ChapterDetails[]>([]) + + const onSelectChange = React.useCallback((event: DataGridRowSelectedEvent<HibikeManga_ChapterDetails>) => { + setSelectedChapters(event.data) + }, []) + return { + rowSelection, setRowSelection, + rowSelectedChapters: selectedChapters, + onRowSelectionChange: onSelectChange, + resetRowSelection: () => { + setRowSelection({}) + setSelectedChapters([]) + }, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/language-map.ts b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/language-map.ts new file mode 100644 index 0000000..58faf00 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/language-map.ts @@ -0,0 +1,791 @@ +export const LANGUAGES_LIST: Record<string, { name: string, nativeName: string }> = { + aa: { + name: "Afar", + nativeName: "Afaraf", + }, + ab: { + name: "Abkhaz", + nativeName: "аҧсуа бызшәа", + }, + ae: { + name: "Avestan", + nativeName: "avesta", + }, + af: { + name: "Afrikaans", + nativeName: "Afrikaans", + }, + ak: { + name: "Akan", + nativeName: "Akan", + }, + am: { + name: "Amharic", + nativeName: "አማርኛ", + }, + an: { + name: "Aragonese", + nativeName: "aragonés", + }, + ar: { + name: "Arabic", + nativeName: "العربية", + }, + as: { + name: "Assamese", + nativeName: "অসমীয়া", + }, + av: { + name: "Avaric", + nativeName: "авар мацӀ", + }, + ay: { + name: "Aymara", + nativeName: "aymar aru", + }, + az: { + name: "Azerbaijani", + nativeName: "azərbaycan dili", + }, + ba: { + name: "Bashkir", + nativeName: "башҡорт теле", + }, + be: { + name: "Belarusian", + nativeName: "беларуская мова", + }, + bg: { + name: "Bulgarian", + nativeName: "български език", + }, + bi: { + name: "Bislama", + nativeName: "Bislama", + }, + bm: { + name: "Bambara", + nativeName: "bamanankan", + }, + bn: { + name: "Bengali", + nativeName: "বাংলা", + }, + bo: { + name: "Tibetan", + nativeName: "བོད་ཡིག", + }, + br: { + name: "Breton", + nativeName: "brezhoneg", + }, + bs: { + name: "Bosnian", + nativeName: "bosanski jezik", + }, + ca: { + name: "Catalan", + nativeName: "Català", + }, + ce: { + name: "Chechen", + nativeName: "нохчийн мотт", + }, + ch: { + name: "Chamorro", + nativeName: "Chamoru", + }, + co: { + name: "Corsican", + nativeName: "corsu", + }, + cr: { + name: "Cree", + nativeName: "ᓀᐦᐃᔭᐍᐏᐣ", + }, + cs: { + name: "Czech", + nativeName: "čeština", + }, + cu: { + name: "Old Church Slavonic", + nativeName: "ѩзыкъ словѣньскъ", + }, + cv: { + name: "Chuvash", + nativeName: "чӑваш чӗлхи", + }, + cy: { + name: "Welsh", + nativeName: "Cymraeg", + }, + da: { + name: "Danish", + nativeName: "Dansk", + }, + de: { + name: "German", + nativeName: "Deutsch", + }, + dv: { + name: "Divehi", + nativeName: "ދިވެހި", + }, + dz: { + name: "Dzongkha", + nativeName: "རྫོང་ཁ", + }, + ee: { + name: "Ewe", + nativeName: "Eʋegbe", + }, + el: { + name: "Greek", + nativeName: "Ελληνικά", + }, + en: { + name: "English", + nativeName: "English", + }, + eo: { + name: "Esperanto", + nativeName: "Esperanto", + }, + es: { + name: "Spanish", + nativeName: "Español", + }, + et: { + name: "Estonian", + nativeName: "eesti", + }, + eu: { + name: "Basque", + nativeName: "euskara", + }, + fa: { + name: "Persian", + nativeName: "فارسی", + }, + ff: { + name: "Fula", + nativeName: "Fulfulde", + }, + fi: { + name: "Finnish", + nativeName: "suomi", + }, + fj: { + name: "Fijian", + nativeName: "vosa Vakaviti", + }, + fo: { + name: "Faroese", + nativeName: "Føroyskt", + }, + fr: { + name: "French", + nativeName: "Français", + }, + fy: { + name: "Western Frisian", + nativeName: "Frysk", + }, + ga: { + name: "Irish", + nativeName: "Gaeilge", + }, + gd: { + name: "Scottish Gaelic", + nativeName: "Gàidhlig", + }, + gl: { + name: "Galician", + nativeName: "galego", + }, + gn: { + name: "Guaraní", + nativeName: "Avañe'ẽ", + }, + gu: { + name: "Gujarati", + nativeName: "ગુજરાતી", + }, + gv: { + name: "Manx", + nativeName: "Gaelg", + }, + ha: { + name: "Hausa", + nativeName: "هَوُسَ", + }, + he: { + name: "Hebrew", + nativeName: "עברית", + }, + hi: { + name: "Hindi", + nativeName: "हिन्दी", + }, + ho: { + name: "Hiri Motu", + nativeName: "Hiri Motu", + }, + hr: { + name: "Croatian", + nativeName: "Hrvatski", + }, + ht: { + name: "Haitian", + nativeName: "Kreyòl ayisyen", + }, + hu: { + name: "Hungarian", + nativeName: "magyar", + }, + hy: { + name: "Armenian", + nativeName: "Հայերեն", + }, + hz: { + name: "Herero", + nativeName: "Otjiherero", + }, + ia: { + name: "Interlingua", + nativeName: "Interlingua", + }, + id: { + name: "Indonesian", + nativeName: "Bahasa Indonesia", + }, + ie: { + name: "Interlingue", + nativeName: "Interlingue", + }, + ig: { + name: "Igbo", + nativeName: "Asụsụ Igbo", + }, + ii: { + name: "Nuosu", + nativeName: "ꆈꌠ꒿ Nuosuhxop", + }, + ik: { + name: "Inupiaq", + nativeName: "Iñupiaq", + }, + io: { + name: "Ido", + nativeName: "Ido", + }, + is: { + name: "Icelandic", + nativeName: "Íslenska", + }, + it: { + name: "Italian", + nativeName: "Italiano", + }, + iu: { + name: "Inuktitut", + nativeName: "ᐃᓄᒃᑎᑐᑦ", + }, + ja: { + name: "Japanese", + nativeName: "日本語", + }, + jv: { + name: "Javanese", + nativeName: "basa Jawa", + }, + ka: { + name: "Georgian", + nativeName: "ქართული", + }, + kg: { + name: "Kongo", + nativeName: "Kikongo", + }, + ki: { + name: "Kikuyu", + nativeName: "Gĩkũyũ", + }, + kj: { + name: "Kwanyama", + nativeName: "Kuanyama", + }, + kk: { + name: "Kazakh", + nativeName: "қазақ тілі", + }, + kl: { + name: "Kalaallisut", + nativeName: "kalaallisut", + }, + km: { + name: "Khmer", + nativeName: "ខេមរភាសា", + }, + kn: { + name: "Kannada", + nativeName: "ಕನ್ನಡ", + }, + ko: { + name: "Korean", + nativeName: "한국어", + }, + kr: { + name: "Kanuri", + nativeName: "Kanuri", + }, + ks: { + name: "Kashmiri", + nativeName: "कश्मीरी", + }, + ku: { + name: "Kurdish", + nativeName: "Kurdî", + }, + kv: { + name: "Komi", + nativeName: "коми кыв", + }, + kw: { + name: "Cornish", + nativeName: "Kernewek", + }, + ky: { + name: "Kyrgyz", + nativeName: "Кыргызча", + }, + la: { + name: "Latin", + nativeName: "latine", + }, + lb: { + name: "Luxembourgish", + nativeName: "Lëtzebuergesch", + }, + lg: { + name: "Ganda", + nativeName: "Luganda", + }, + li: { + name: "Limburgish", + nativeName: "Limburgs", + }, + ln: { + name: "Lingala", + nativeName: "Lingála", + }, + lo: { + name: "Lao", + nativeName: "ພາສາລາວ", + }, + lt: { + name: "Lithuanian", + nativeName: "lietuvių kalba", + }, + lu: { + name: "Luba-Katanga", + nativeName: "Kiluba", + }, + lv: { + name: "Latvian", + nativeName: "latviešu valoda", + }, + mg: { + name: "Malagasy", + nativeName: "fiteny malagasy", + }, + mh: { + name: "Marshallese", + nativeName: "Kajin M̧ajeļ", + }, + mi: { + name: "Māori", + nativeName: "te reo Māori", + }, + mk: { + name: "Macedonian", + nativeName: "македонски јазик", + }, + ml: { + name: "Malayalam", + nativeName: "മലയാളം", + }, + mn: { + name: "Mongolian", + nativeName: "Монгол хэл", + }, + mr: { + name: "Marathi", + nativeName: "मराठी", + }, + ms: { + name: "Malay", + nativeName: "Bahasa Melayu", + }, + mt: { + name: "Maltese", + nativeName: "Malti", + }, + my: { + name: "Burmese", + nativeName: "ဗမာစာ", + }, + na: { + name: "Nauru", + nativeName: "Dorerin Naoero", + }, + nb: { + name: "Norwegian Bokmål", + nativeName: "Norsk bokmål", + }, + nd: { + name: "Northern Ndebele", + nativeName: "isiNdebele", + }, + ne: { + name: "Nepali", + nativeName: "नेपाली", + }, + ng: { + name: "Ndonga", + nativeName: "Owambo", + }, + nl: { + name: "Dutch", + nativeName: "Nederlands", + }, + nn: { + name: "Norwegian Nynorsk", + nativeName: "Norsk nynorsk", + }, + no: { + name: "Norwegian", + nativeName: "Norsk", + }, + nr: { + name: "Southern Ndebele", + nativeName: "isiNdebele", + }, + nv: { + name: "Navajo", + nativeName: "Diné bizaad", + }, + ny: { + name: "Chichewa", + nativeName: "chiCheŵa", + }, + oc: { + name: "Occitan", + nativeName: "occitan", + }, + oj: { + name: "Ojibwe", + nativeName: "ᐊᓂᔑᓈᐯᒧᐎᓐ", + }, + om: { + name: "Oromo", + nativeName: "Afaan Oromoo", + }, + or: { + name: "Oriya", + nativeName: "ଓଡ଼ିଆ", + }, + os: { + name: "Ossetian", + nativeName: "ирон æвзаг", + }, + pa: { + name: "Panjabi", + nativeName: "ਪੰਜਾਬੀ", + }, + pi: { + name: "Pāli", + nativeName: "पाऴि", + }, + pl: { + name: "Polish", + nativeName: "Polski", + }, + ps: { + name: "Pashto", + nativeName: "پښتو", + }, + pt: { + name: "Portuguese", + nativeName: "Português", + }, + qu: { + name: "Quechua", + nativeName: "Runa Simi", + }, + rm: { + name: "Romansh", + nativeName: "rumantsch grischun", + }, + rn: { + name: "Kirundi", + nativeName: "Ikirundi", + }, + ro: { + name: "Romanian", + nativeName: "Română", + }, + ru: { + name: "Russian", + nativeName: "Русский", + }, + rw: { + name: "Kinyarwanda", + nativeName: "Ikinyarwanda", + }, + sa: { + name: "Sanskrit", + nativeName: "संस्कृतम्", + }, + sc: { + name: "Sardinian", + nativeName: "sardu", + }, + sd: { + name: "Sindhi", + nativeName: "सिन्धी", + }, + se: { + name: "Northern Sami", + nativeName: "Davvisámegiella", + }, + sg: { + name: "Sango", + nativeName: "yângâ tî sängö", + }, + si: { + name: "Sinhala", + nativeName: "සිංහල", + }, + sk: { + name: "Slovak", + nativeName: "slovenčina", + }, + sl: { + name: "Slovenian", + nativeName: "slovenščina", + }, + sm: { + name: "Samoan", + nativeName: "gagana fa'a Samoa", + }, + sn: { + name: "Shona", + nativeName: "chiShona", + }, + so: { + name: "Somali", + nativeName: "Soomaaliga", + }, + sq: { + name: "Albanian", + nativeName: "Shqip", + }, + sr: { + name: "Serbian", + nativeName: "српски језик", + }, + ss: { + name: "Swati", + nativeName: "SiSwati", + }, + st: { + name: "Southern Sotho", + nativeName: "Sesotho", + }, + su: { + name: "Sundanese", + nativeName: "Basa Sunda", + }, + sv: { + name: "Swedish", + nativeName: "Svenska", + }, + sw: { + name: "Swahili", + nativeName: "Kiswahili", + }, + ta: { + name: "Tamil", + nativeName: "தமிழ்", + }, + te: { + name: "Telugu", + nativeName: "తెలుగు", + }, + tg: { + name: "Tajik", + nativeName: "тоҷикӣ", + }, + th: { + name: "Thai", + nativeName: "ไทย", + }, + ti: { + name: "Tigrinya", + nativeName: "ትግርኛ", + }, + tk: { + name: "Turkmen", + nativeName: "Türkmençe", + }, + tl: { + name: "Tagalog", + nativeName: "Wikang Tagalog", + }, + tn: { + name: "Tswana", + nativeName: "Setswana", + }, + to: { + name: "Tonga", + nativeName: "faka Tonga", + }, + tr: { + name: "Turkish", + nativeName: "Türkçe", + }, + ts: { + name: "Tsonga", + nativeName: "Xitsonga", + }, + tt: { + name: "Tatar", + nativeName: "татар теле", + }, + tw: { + name: "Twi", + nativeName: "Twi", + }, + ty: { + name: "Tahitian", + nativeName: "Reo Tahiti", + }, + ug: { + name: "Uyghur", + nativeName: "ئۇيغۇرچە‎", + }, + uk: { + name: "Ukrainian", + nativeName: "Українська", + }, + ur: { + name: "Urdu", + nativeName: "اردو", + }, + uz: { + name: "Uzbek", + nativeName: "Ўзбек", + }, + ve: { + name: "Venda", + nativeName: "Tshivenḓa", + }, + vi: { + name: "Vietnamese", + nativeName: "Tiếng Việt", + }, + vo: { + name: "Volapük", + nativeName: "Volapük", + }, + wa: { + name: "Walloon", + nativeName: "walon", + }, + wo: { + name: "Wolof", + nativeName: "Wollof", + }, + xh: { + name: "Xhosa", + nativeName: "isiXhosa", + }, + yi: { + name: "Yiddish", + nativeName: "ייִדיש", + }, + yo: { + name: "Yoruba", + nativeName: "Yorùbá", + }, + za: { + name: "Zhuang", + nativeName: "Saɯ cueŋƅ", + }, + zh: { + name: "Chinese", + nativeName: "中文", + }, + zu: { + name: "Zulu", + nativeName: "isiZulu", + }, + "pt-br": { + name: "Portuguese (Brazil)", + nativeName: "Português (Brasil)", + }, + "zh-hans": { + name: "Chinese (Simplified)", + nativeName: "中文(简体)", + }, + "zh-hant": { + name: "Chinese (Traditional)", + nativeName: "中文(繁體)", + }, + "zh-cn": { + name: "Chinese (China)", + nativeName: "中文(中国)", + }, + "zh-tw": { + name: "Chinese (Traditional)", + nativeName: "中文(台灣)", + }, + "zh-hk": { + name: "Chinese (Hong Kong)", + nativeName: "中文(香港)", + }, + "zh-mo": { + name: "Chinese (Macau)", + nativeName: "中文(澳門)", + }, + "zh-sg": { + name: "Chinese (Singapore)", + nativeName: "中文(新加坡)", + }, + "zh-my": { + name: "Chinese (Malaysia)", + nativeName: "中文(马来西亚)", + }, + "es-419": { + name: "Spanish (Latin America)", + nativeName: "Español (Latinoamérica)", + }, + "es-es": { + name: "Spanish (Spain)", + nativeName: "Español (España)", + }, + "es-mx": { + name: "Spanish (Mexico)", + nativeName: "Español (México)", + }, + "es-ar": { + name: "Spanish (Argentina)", + nativeName: "Español (Argentina)", + }, + "es-cl": { + name: "Spanish (Chile)", + nativeName: "Español (Chile)", + }, + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/manga-chapter-reader.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/manga-chapter-reader.atoms.ts new file mode 100644 index 0000000..88d7cf0 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_lib/manga-chapter-reader.atoms.ts @@ -0,0 +1,106 @@ +"use client" +import { atom } from "jotai/index" +import { atomWithStorage } from "jotai/utils" + +export const __manga_currentPageIndexAtom = atom(0) +export const __manga_currentPaginationMapIndexAtom = atom(0) // HORIZONTAL MODE +export const __manga_paginationMapAtom = atom<Record<number, number[]>>({}) + +export const __manga_hiddenBarAtom = atom(false) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// e.g. { "mangaId": { "sea-manga-reading-mode": "long-stop" } } +// DEVNOTE: change key by adding "-vx" when settings system changes +export const __manga_entryReaderSettings = atomWithStorage<Record<string, Record<string, any>>>("sea-manga-entry-reader-settings", {}) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const MANGA_KBS_ATOM_KEYS = { + kbsChapterLeft: "sea-manga-chapter-left", + kbsChapterRight: "sea-manga-chapter-right", + kbsPageLeft: "sea-manga-page-left", + kbsPageRight: "sea-manga-page-right", +} + +export const MANGA_DEFAULT_KBS = { + [MANGA_KBS_ATOM_KEYS.kbsChapterLeft]: "[", + [MANGA_KBS_ATOM_KEYS.kbsChapterRight]: "]", + [MANGA_KBS_ATOM_KEYS.kbsPageLeft]: "left", + [MANGA_KBS_ATOM_KEYS.kbsPageRight]: "right", +} + + +export const __manga_kbsChapterLeft = atomWithStorage(MANGA_KBS_ATOM_KEYS.kbsChapterLeft, MANGA_DEFAULT_KBS[MANGA_KBS_ATOM_KEYS.kbsChapterLeft]) +export const __manga_kbsChapterRight = atomWithStorage(MANGA_KBS_ATOM_KEYS.kbsChapterRight, MANGA_DEFAULT_KBS[MANGA_KBS_ATOM_KEYS.kbsChapterRight]) +export const __manga_kbsPageLeft = atomWithStorage(MANGA_KBS_ATOM_KEYS.kbsPageLeft, MANGA_DEFAULT_KBS[MANGA_KBS_ATOM_KEYS.kbsPageLeft]) +export const __manga_kbsPageRight = atomWithStorage(MANGA_KBS_ATOM_KEYS.kbsPageRight, MANGA_DEFAULT_KBS[MANGA_KBS_ATOM_KEYS.kbsPageRight]) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const MANGA_SETTINGS_ATOM_KEYS = { + readingMode: "sea-manga-reading-mode", + readingDirection: "sea-manga-reading-direction", + pageFit: "sea-manga-page-fit", + pageStretch: "sea-manga-page-stretch", + pageGap: "sea-manga-page-gap", + pageGapShadow: "sea-manga-page-gap-shadow", + doublePageOffset: "sea-manga-double-page-offset", + overflowPageContainerWidth: "sea-manga-overflow-page-container-width", +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const MangaReadingDirection = { + LTR: "ltr", + RTL: "rtl", +} + +export const __manga_readingDirectionAtom = atomWithStorage<string>(MANGA_SETTINGS_ATOM_KEYS.readingDirection, MangaReadingDirection.LTR) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const MangaReadingMode = { + LONG_STRIP: "long-strip", + PAGED: "paged", + DOUBLE_PAGE: "double-page", +} + +export const __manga_readingModeAtom = atomWithStorage<string>(MANGA_SETTINGS_ATOM_KEYS.readingMode, MangaReadingMode.LONG_STRIP) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const MangaPageFit = { + CONTAIN: "contain", + LARGER: "larger", + COVER: "cover", + TRUE_SIZE: "true-size", +} + +export const __manga_pageFitAtom = atomWithStorage<string>(MANGA_SETTINGS_ATOM_KEYS.pageFit, MangaPageFit.CONTAIN) + +export const __manga_pageOverflowContainerWidthAtom = atomWithStorage<number>(MANGA_SETTINGS_ATOM_KEYS.overflowPageContainerWidth, 50) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const MangaPageStretch = { + NONE: "none", + STRETCH: "stretch", +} + + +export const __manga_pageStretchAtom = atomWithStorage<string>(MANGA_SETTINGS_ATOM_KEYS.pageStretch, MangaPageStretch.NONE) + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const __manga_readerProgressBarAtom = atomWithStorage<boolean>("sea-manga-reader-progress-bar", false) + +export const __manga_pageGapAtom = atomWithStorage<boolean>(MANGA_SETTINGS_ATOM_KEYS.pageGap, true) + +export const __manga_pageGapShadowAtom = atomWithStorage(MANGA_SETTINGS_ATOM_KEYS.pageGapShadow, true) + +export const __manga_doublePageOffsetAtom = atomWithStorage(MANGA_SETTINGS_ATOM_KEYS.doublePageOffset, 0) + +export const __manga_isLastPageAtom = atom(false) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/_screens/manga-library-view.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_screens/manga-library-view.tsx new file mode 100644 index 0000000..5b6b694 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/_screens/manga-library-view.tsx @@ -0,0 +1,350 @@ +import { Manga_Collection, Manga_CollectionList } from "@/api/generated/types" +import { useRefetchMangaChapterContainers } from "@/api/hooks/manga.hooks" +import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector" +import { SeaCommandInjectableItem, useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject" +import { seaCommand_compareMediaTitles } from "@/app/(main)/_features/sea-command/utils" +import { __mangaLibraryHeaderImageAtom, __mangaLibraryHeaderMangaAtom } from "@/app/(main)/manga/_components/library-header" +import { __mangaLibrary_paramsAtom, __mangaLibrary_paramsInputAtom } from "@/app/(main)/manga/_lib/handle-manga-collection" +import { LuffyError } from "@/components/shared/luffy-error" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { TextGenerateEffect } from "@/components/shared/text-generate-effect" +import { Button, IconButton } from "@/components/ui/button" +import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { useDebounce } from "@/hooks/use-debounce" +import { getMangaCollectionTitle } from "@/lib/server/utils" +import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks" +import { useSetAtom } from "jotai/index" +import { useAtom, useAtomValue } from "jotai/react" +import { AnimatePresence } from "motion/react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import React, { memo } from "react" +import { BiDotsVertical } from "react-icons/bi" +import { LuBookOpenCheck, LuRefreshCcw } from "react-icons/lu" +import { toast } from "sonner" +import { CommandItemMedia } from "../../_features/sea-command/_components/command-utils" + +type MangaLibraryViewProps = { + collection: Manga_Collection + filteredCollection: Manga_Collection | undefined + genres: string[] + storedProviders: Record<string, string> + hasManga: boolean +} + +export function MangaLibraryView(props: MangaLibraryViewProps) { + + const { + collection, + filteredCollection, + genres, + storedProviders, + hasManga, + ...rest + } = props + + const [params, setParams] = useAtom(__mangaLibrary_paramsAtom) + + return ( + <> + <PageWrapper + key="lists" + className="relative 2xl:order-first pb-10 p-4" + data-manga-library-view-container + > + <div className="w-full flex justify-end"> + </div> + + <AnimatePresence mode="wait" initial={false}> + + {!!collection && !hasManga && <LuffyError + title="No manga found" + > + <div className="space-y-2"> + <p> + No manga has been added to your library yet. + </p> + + <div className="!mt-4"> + <Link href="/discover?type=manga"> + <Button intent="white-outline" rounded> + Browse manga + </Button> + </Link> + </div> + </div> + </LuffyError>} + + {!params.genre?.length ? + <CollectionLists key="lists" collectionList={collection} genres={genres} storedProviders={storedProviders} /> + : <FilteredCollectionLists key="filtered-collection" collectionList={filteredCollection} genres={genres} /> + } + </AnimatePresence> + </PageWrapper> + </> + ) +} + +export function CollectionLists({ collectionList, genres, storedProviders }: { + collectionList: Manga_Collection | undefined + genres: string[] + storedProviders: Record<string, string> +}) { + + return ( + <PageWrapper + className="p-4 space-y-8 relative z-[4]" + data-manga-library-view-collection-lists-container + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + > + {collectionList?.lists?.map(collection => { + if (!collection.entries?.length) return null + return ( + <React.Fragment key={collection.type}> + <CollectionListItem list={collection} storedProviders={storedProviders} /> + + {(collection.type === "CURRENT" && !!genres?.length) && <GenreSelector genres={genres} />} + </React.Fragment> + ) + })} + </PageWrapper> + ) + +} + +export function FilteredCollectionLists({ collectionList, genres }: { + collectionList: Manga_Collection | undefined + genres: string[] +}) { + + const entries = React.useMemo(() => { + return collectionList?.lists?.flatMap(n => n.entries).filter(Boolean) ?? [] + }, [collectionList]) + + return ( + <PageWrapper + className="p-4 space-y-8 relative z-[4]" + data-manga-library-view-filtered-collection-lists-container + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, scale: 0.99 }, + transition: { + duration: 0.35, + }, + }} + > + + {!!genres?.length && <div className="mt-24"> + <GenreSelector genres={genres} /> + </div>} + + <MediaCardLazyGrid itemCount={entries?.length || 0}> + {entries.map(entry => { + return <div + key={entry.media?.id} + > + <MediaEntryCard + media={entry.media!} + listData={entry.listData} + showListDataButton + withAudienceScore={false} + type="manga" + /> + </div> + })} + </MediaCardLazyGrid> + </PageWrapper> + ) + +} + +const CollectionListItem = memo(({ list, storedProviders }: { list: Manga_CollectionList, storedProviders: Record<string, string> }) => { + + const ts = useThemeSettings() + const [currentHeaderImage, setCurrentHeaderImage] = useAtom(__mangaLibraryHeaderImageAtom) + const headerManga = useAtomValue(__mangaLibraryHeaderMangaAtom) + const [params, setParams] = useAtom(__mangaLibrary_paramsAtom) + const router = useRouter() + + const { mutate: refetchMangaChapterContainers, isPending: isRefetchingMangaChapterContainers } = useRefetchMangaChapterContainers() + + const { inject, remove } = useSeaCommandInject() + + React.useEffect(() => { + if (list.type === "CURRENT") { + if (currentHeaderImage === null && list.entries?.[0]?.media?.bannerImage) { + setCurrentHeaderImage(list.entries?.[0]?.media?.bannerImage) + } + } + }, []) + + // Inject command for currently reading manga + React.useEffect(() => { + if (list.type === "CURRENT" && list.entries?.length) { + inject("currently-reading-manga", { + items: list.entries.map(entry => ({ + data: entry, + id: `manga-${entry.mediaId}`, + value: entry.media?.title?.userPreferred || "", + heading: "Currently Reading", + priority: 100, + render: () => ( + <CommandItemMedia media={entry.media!} /> + ), + onSelect: () => { + router.push(`/manga/entry?id=${entry.mediaId}`) + }, + })), + filter: ({ item, input }: { item: SeaCommandInjectableItem, input: string }) => { + if (!input) return true + return seaCommand_compareMediaTitles((item.data as typeof list.entries[0])?.media?.title, input) + }, + priority: 100, + }) + } + + return () => remove("currently-reading-manga") + }, [list.entries]) + + return ( + <React.Fragment> + + <div className="flex gap-3 items-center" data-manga-library-view-collection-list-item-header-container> + <h2 data-manga-library-view-collection-list-item-header-title>{list.type === "CURRENT" ? "Continue reading" : getMangaCollectionTitle( + list.type)}</h2> + <div className="flex flex-1" data-manga-library-view-collection-list-item-header-spacer></div> + + {list.type === "CURRENT" && params.unreadOnly && ( + <Button + intent="white-link" + size="xs" + className="!px-2 !py-1" + onClick={() => { + setParams(draft => { + draft.unreadOnly = false + return + }) + }} + > + Show all + </Button> + )} + + {list.type === "CURRENT" && <DropdownMenu + trigger={<div className="relative"> + <IconButton + intent="white-basic" + size="xs" + className="mt-1" + icon={<BiDotsVertical />} + // loading={isRefetchingMangaChapterContainers} + /> + {/*{params.unreadOnly && <div className="absolute -top-1 -right-1 bg-[--blue] size-2 rounded-full"></div>}*/} + {isRefetchingMangaChapterContainers && + <div className="absolute -top-1 -right-1 bg-[--orange] size-3 rounded-full animate-ping"></div>} + </div>} + > + <DropdownMenuItem + onClick={() => { + if (isRefetchingMangaChapterContainers) return + + toast.info("Refetching from sources...") + refetchMangaChapterContainers({ + selectedProviderMap: storedProviders, + }) + }} + > + <LuRefreshCcw /> {isRefetchingMangaChapterContainers ? "Refetching..." : "Refresh sources"} + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + setParams(draft => { + draft.unreadOnly = !draft.unreadOnly + return + }) + }} + > + <LuBookOpenCheck /> {params.unreadOnly ? "Show all" : "Unread chapters only"} + </DropdownMenuItem> + </DropdownMenu>} + + </div> + + {(list.type === "CURRENT" && ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && headerManga) && + <TextGenerateEffect + data-manga-library-view-collection-list-item-header-media-title + words={headerManga?.title?.userPreferred || ""} + className="w-full text-xl lg:text-5xl lg:max-w-[50%] h-[3.2rem] !mt-1 line-clamp-1 truncate text-ellipsis hidden lg:block pb-1" + /> + } + + <MediaCardLazyGrid itemCount={list.entries?.length ?? 0}> + {list.entries?.map(entry => { + return <div + key={entry.media?.id} + onMouseEnter={() => { + if (list.type === "CURRENT" && entry.media?.bannerImage) { + React.startTransition(() => { + setCurrentHeaderImage(entry.media?.bannerImage!) + }) + } + }} + > + <MediaEntryCard + media={entry.media!} + listData={entry.listData} + showListDataButton + withAudienceScore={false} + type="manga" + /> + </div> + })} + </MediaCardLazyGrid> + </React.Fragment> + ) +}) + +function GenreSelector({ + genres, +}: { genres: string[] }) { + const [params, setParams] = useAtom(__mangaLibrary_paramsInputAtom) + const setActualParams = useSetAtom(__mangaLibrary_paramsAtom) + const debouncedParams = useDebounce(params, 200) + + React.useEffect(() => { + setActualParams(params) + }, [debouncedParams]) + + if (!genres.length) return null + + return ( + <MediaGenreSelector + // className="bg-gray-950 border p-0 rounded-xl mx-auto" + staticTabsClass="" + items={[ + ...genres.map(genre => ({ + name: genre, + isCurrent: params!.genre?.includes(genre) ?? false, + onClick: () => setParams(draft => { + if (draft.genre?.includes(genre)) { + draft.genre = draft.genre?.filter(g => g !== genre) + } else { + draft.genre = [...(draft.genre || []), genre] + } + return + }), + })), + ]} + /> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/entry/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/entry/layout.tsx new file mode 100644 index 0000000..3efab03 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/entry/layout.tsx @@ -0,0 +1,15 @@ +"use client" +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/entry/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/entry/page.tsx new file mode 100644 index 0000000..91cba9d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/entry/page.tsx @@ -0,0 +1,99 @@ +"use client" +import { useGetMangaEntry, useGetMangaEntryDetails } from "@/api/hooks/manga.hooks" +import { MediaEntryCharactersSection } from "@/app/(main)/_features/media/_components/media-entry-characters-section" +import { MediaEntryPageLoadingDisplay } from "@/app/(main)/_features/media/_components/media-entry-page-loading-display" +import { MangaRecommendations } from "@/app/(main)/manga/_components/manga-recommendations" +import { MetaSection } from "@/app/(main)/manga/_components/meta-section" +import { ChapterList } from "@/app/(main)/manga/_containers/chapter-list/chapter-list" +import { useHandleMangaDownloadData } from "@/app/(main)/manga/_lib/handle-manga-downloads" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { useRouter, useSearchParams } from "next/navigation" +import React from "react" + +export const dynamic = "force-static" + +export default function Page() { + const router = useRouter() + const searchParams = useSearchParams() + const mediaId = searchParams.get("id") + const { data: mangaEntry, isLoading: mangaEntryLoading } = useGetMangaEntry(mediaId) + const { data: mangaDetails, isLoading: mangaDetailsLoading } = useGetMangaEntryDetails(mediaId) + + /** + * Fetch manga download data + */ + const { downloadData, downloadDataLoading } = useHandleMangaDownloadData(mediaId) + + React.useEffect(() => { + if (!mediaId) { + router.push("/") + } else if ((!mangaEntryLoading && !mangaEntry)) { + router.push("/") + } + }, [mangaEntry, mangaEntryLoading]) + + React.useEffect(() => { + try { + if (mangaEntry?.media?.title?.userPreferred) { + document.title = `${mangaEntry?.media?.title?.userPreferred} | Seanime` + } + } + catch { + } + }, [mangaEntry]) + + if (!mangaEntry || mangaEntryLoading || mangaDetailsLoading) return <MediaEntryPageLoadingDisplay /> + + return ( + <div + data-manga-entry-page + data-media={JSON.stringify(mangaEntry.media)} + data-manga-entry-list-data={JSON.stringify(mangaEntry.listData)} + > + <MetaSection entry={mangaEntry} details={mangaDetails} /> + + <div data-manga-entry-page-content-container className="px-4 md:px-8 relative z-[8]"> + + <PageWrapper + data-manga-entry-page-content + key="chapter-list" + className="relative 2xl:order-first pb-10 pt-4 space-y-10" + {...{ + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: 60 }, + transition: { + type: "spring", + damping: 10, + stiffness: 80, + delay: 0.6, + }, + }} + > + + <div + data-manga-entry-page-grid + className="grid gap-8 xl:grid-cols-[1fr,480px] 2xl:grid-cols-[1fr,650px]" + > + <div className="space-y-2"> + <ChapterList + entry={mangaEntry} + mediaId={mediaId} + details={mangaDetails} + downloadData={downloadData} + downloadDataLoading={downloadDataLoading} + /> + </div> + + <div data-manga-entry-page-characters-section-container className="pt-12"> + <MediaEntryCharactersSection details={mangaDetails} isMangaPage /> + </div> + </div> + + <MangaRecommendations entry={mangaEntry} details={mangaDetails} /> + + </PageWrapper> + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/layout.tsx new file mode 100644 index 0000000..3efab03 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/layout.tsx @@ -0,0 +1,15 @@ +"use client" +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/manga/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/manga/page.tsx new file mode 100644 index 0000000..3679eb2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/manga/page.tsx @@ -0,0 +1,69 @@ +"use client" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { MediaEntryPageLoadingDisplay } from "@/app/(main)/_features/media/_components/media-entry-page-loading-display" +import { LibraryHeader } from "@/app/(main)/manga/_components/library-header" +import { useHandleMangaCollection } from "@/app/(main)/manga/_lib/handle-manga-collection" +import { MangaLibraryView } from "@/app/(main)/manga/_screens/manga-library-view" +import { cn } from "@/components/ui/core/styling" +import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks" +import { __isDesktop__ } from "@/types/constants" +import React from "react" + +export const dynamic = "force-static" + +export default function Page() { + const { + mangaCollection, + filteredMangaCollection, + mangaCollectionLoading, + storedFilters, + storedProviders, + mangaCollectionGenres, + hasManga, + } = useHandleMangaCollection() + + const ts = useThemeSettings() + + if (!mangaCollection || mangaCollectionLoading) return <MediaEntryPageLoadingDisplay /> + + return ( + <div + data-manga-page-container + data-stored-filters={JSON.stringify(storedFilters)} + data-stored-providers={JSON.stringify(storedProviders)} + > + {( + (!!ts.libraryScreenCustomBannerImage && ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Custom) + ) && ( + <> + <CustomLibraryBanner isLibraryScreen /> + <div + data-manga-page-custom-banner-spacer + className={cn("h-14")} + ></div> + </> + )} + {ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && ( + <> + <LibraryHeader manga={mangaCollection?.lists?.flatMap(l => l.entries)?.flatMap(e => e?.media)?.filter(Boolean) || []} /> + <div + data-manga-page-dynamic-banner-spacer + className={cn( + !__isDesktop__ && "h-28", + (!__isDesktop__ && ts.hideTopNavbar) && "h-40", + __isDesktop__ && "h-40", + )} + ></div> + </> + )} + + <MangaLibraryView + genres={mangaCollectionGenres} + collection={mangaCollection} + filteredCollection={filteredMangaCollection} + storedProviders={storedProviders} + hasManga={hasManga} + /> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/medialinks/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/medialinks/page.tsx new file mode 100644 index 0000000..83ee8b3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/medialinks/page.tsx @@ -0,0 +1,181 @@ +"use client" + +import { useGetAnimeEntry } from "@/api/hooks/anime_entries.hooks" +import { usePlaybackStartManualTracking } from "@/api/hooks/playback_manager.hooks" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { useExternalPlayerLink } from "@/app/(main)/_atoms/playback.atoms" +import { EpisodeGridItem } from "@/app/(main)/_features/anime/_components/episode-grid-item" +import { MediaEpisodeInfoModal } from "@/app/(main)/_features/media/_components/media-episode-info-modal" +import { EpisodeListGrid } from "@/app/(main)/entry/_components/episode-list-grid" +import { useMediastreamCurrentFile } from "@/app/(main)/mediastream/_lib/mediastream.atoms" +import { clientIdAtom } from "@/app/websocket-provider" +import { SeaLink } from "@/components/shared/sea-link" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { IconButton } from "@/components/ui/button" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { ExternalPlayerLink } from "@/lib/external-player-link/external-player-link" +import { openTab } from "@/lib/helpers/browser" +import { logger } from "@/lib/helpers/debug" +import { useAtomValue } from "jotai" +import { useRouter, useSearchParams } from "next/navigation" +import React from "react" +import { AiOutlineArrowLeft } from "react-icons/ai" +import { toast } from "sonner" +import { PluginEpisodeGridItemMenuItems } from "../_features/plugin/actions/plugin-actions" +import { useServerHMACAuth } from "../_hooks/use-server-status" + +export default function Page() { + + const clientId = useAtomValue(clientIdAtom) + const router = useRouter() + const searchParams = useSearchParams() + const mediaId = searchParams.get("id") + const { data: animeEntry, isLoading: animeEntryLoading } = useGetAnimeEntry(mediaId) + const { filePath, setFilePath } = useMediastreamCurrentFile() + const { getHMACTokenQueryParam } = useServerHMACAuth() + + const { mutate: startManualTracking, isPending: isStarting } = usePlaybackStartManualTracking() + + const { externalPlayerLink, encodePath } = useExternalPlayerLink() + + function encodeFilePath(filePath: string) { + if (encodePath) { + return Buffer.from(filePath).toString("base64") + } + return encodeURIComponent(filePath) + } + + React.useEffect(() => { + // On mount, when the anime entry is loaded, and the file path is set, play the media file + if (animeEntry && filePath) { + const handleMediaPlay = async () => { + // Get the episode + const episode = animeEntry?.episodes?.find(ep => ep.localFile?.path === filePath) + logger("MEDIALINKS").info("Filepath", filePath, "Episode", episode) + + if (!episode) { + logger("MEDIALINKS").error("Episode not found.") + toast.error("Episode not found.") + return + } + + if (episode.type !== "main") { + logger("MEDIALINKS").warning("Episode is not a main episode. Cannot track progress.") + } + + if (!externalPlayerLink) { + logger("MEDIALINKS").error("External player link is not set.") + toast.warning("External player link is not set.") + return + } + + const link = new ExternalPlayerLink(externalPlayerLink) + link.setEpisodeNumber(episode.progressNumber) + link.setMediaTitle(animeEntry.media?.title?.userPreferred) + await link.to({ + endpoint: "/api/v1/mediastream/file?path=" + encodeFilePath(filePath), + onTokenQueryParam: () => getHMACTokenQueryParam("/api/v1/mediastream/file", "&"), + }) + openTab(link.getFullUrl()) + + if (episode?.progressNumber && episode.type === "main") { + logger("MEDIALINKS").error("Starting manual tracking") + // Start manual tracking + React.startTransition(() => { + startManualTracking({ + mediaId: animeEntry.mediaId, + episodeNumber: episode?.progressNumber, + clientId: clientId || "", + }) + }) + } else { + logger("MEDIALINKS").warning("No manual tracking, progress number is not set.") + } + + // Clear the file path + setFilePath(undefined) + } + + handleMediaPlay() + } + }, [animeEntry, filePath, externalPlayerLink, getHMACTokenQueryParam]) + + const mainEpisodes = React.useMemo(() => { + return animeEntry?.episodes?.filter(ep => ep.type === "main") ?? [] + }, [animeEntry?.episodes]) + + const specialEpisodes = React.useMemo(() => { + return animeEntry?.episodes?.filter(ep => ep.type === "special") ?? [] + }, [animeEntry?.episodes]) + + const ncEpisodes = React.useMemo(() => { + return animeEntry?.episodes?.filter(ep => ep.type === "nc") ?? [] + }, [animeEntry?.episodes]) + + const episodes = React.useMemo(() => { + return [...mainEpisodes, ...specialEpisodes, ...ncEpisodes] + }, [mainEpisodes, specialEpisodes, ncEpisodes]) + + if (animeEntryLoading) return <LoadingSpinner /> + + return ( + <> + <CustomLibraryBanner discrete /> + + <AppLayoutStack className="px-4 lg:px-8 z-[5]"> + + <div className="flex flex-col lg:flex-row gap-2 w-full justify-between"> + <div className="flex gap-4 items-center relative w-full"> + <SeaLink href={`/entry?id=${animeEntry?.mediaId}`}> + <IconButton icon={<AiOutlineArrowLeft />} rounded intent="white-outline" size="md" /> + </SeaLink> + <h3 className="max-w-full lg:max-w-[50%] text-ellipsis truncate">{animeEntry?.media?.title?.userPreferred}</h3> + </div> + </div> + + <EpisodeListGrid> + {episodes.map((episode) => ( + <EpisodeGridItem + key={episode.localFile?.path || ""} + id={`episode-${String(episode.episodeNumber)}`} + media={episode?.baseAnime as any} + title={episode?.displayTitle || episode?.baseAnime?.title?.userPreferred || ""} + image={episode?.episodeMetadata?.image || episode?.baseAnime?.coverImage?.large} + episodeTitle={episode?.episodeTitle} + fileName={episode?.localFile?.parsedInfo?.original} + onClick={() => { + if (episode.localFile?.path) { + setFilePath(episode.localFile?.path) + } + }} + description={episode?.episodeMetadata?.summary || episode?.episodeMetadata?.overview} + isWatched={!!animeEntry?.listData?.progress && (animeEntry.listData?.progress >= episode?.progressNumber)} + isFiller={episode.episodeMetadata?.isFiller} + isSelected={episode.localFile?.path === filePath} + length={episode.episodeMetadata?.length} + className="flex-none w-full" + episodeNumber={episode.episodeNumber} + progressNumber={episode.progressNumber} + action={<> + <MediaEpisodeInfoModal + title={episode.displayTitle} + image={episode.episodeMetadata?.image} + episodeTitle={episode.episodeTitle} + airDate={episode.episodeMetadata?.airDate} + length={episode.episodeMetadata?.length} + summary={episode.episodeMetadata?.summary || episode.episodeMetadata?.overview} + isInvalid={episode.isInvalid} + filename={episode.localFile?.parsedInfo?.original} + /> + + <PluginEpisodeGridItemMenuItems isDropdownMenu={true} type="medialinks" episode={episode} /> + </>} + /> + ))} + </EpisodeListGrid> + + </AppLayoutStack> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/_lib/handle-mediastream.ts b/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/_lib/handle-mediastream.ts new file mode 100644 index 0000000..fcd4cd7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/_lib/handle-mediastream.ts @@ -0,0 +1,516 @@ +import { getServerBaseUrl } from "@/api/client/server-url" +import { Anime_Episode, Mediastream_StreamType, Nullish } from "@/api/generated/types" +import { useHandleContinuityWithMediaPlayer, useHandleCurrentMediaContinuity } from "@/api/hooks/continuity.hooks" +import { useGetMediastreamSettings, useMediastreamShutdownTranscodeStream, useRequestMediastreamMediaContainer } from "@/api/hooks/mediastream.hooks" +import { useIsCodecSupported } from "@/app/(main)/_features/sea-media-player/hooks" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { useMediastreamCurrentFile, useMediastreamJassubOffscreenRender } from "@/app/(main)/mediastream/_lib/mediastream.atoms" +import { clientIdAtom } from "@/app/websocket-provider" +import { logger } from "@/lib/helpers/debug" +import { legacy_getAssetUrl } from "@/lib/server/assets" +import { WSEvents } from "@/lib/server/ws-events" +import { + isHLSProvider, + LibASSTextRenderer, + MediaCanPlayDetail, + MediaPlayerInstance, + MediaProviderAdapter, + MediaProviderChangeEvent, + MediaProviderSetupEvent, +} from "@vidstack/react" +import HLS, { LoadPolicy } from "hls.js" +import { useAtomValue } from "jotai" +import { useRouter } from "next/navigation" +import React from "react" +import { toast } from "sonner" + +function uuidv4(): string { + // @ts-ignore + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => + (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16), + ) +} + +let cId = typeof window === "undefined" ? "-" : uuidv4() + +const mediastream_getHlsConfig = () => { + const loadPolicy: LoadPolicy = { + default: { + maxTimeToFirstByteMs: Number.POSITIVE_INFINITY, + maxLoadTimeMs: 300_000, + timeoutRetry: { + maxNumRetry: 2, + retryDelayMs: 0, + maxRetryDelayMs: 0, + }, + errorRetry: { + maxNumRetry: 1, + retryDelayMs: 0, + maxRetryDelayMs: 0, + }, + }, + } + return { + autoStartLoad: true, + abrEwmaDefaultEstimate: 35_000_000, + abrEwmaDefaultEstimateMax: 50_000_000, + // debug: true, + startLevel: 0, // Start at level 0 + lowLatencyMode: false, + initialLiveManifestSize: 0, + fragLoadPolicy: { + default: { + maxTimeToFirstByteMs: Number.POSITIVE_INFINITY, + maxLoadTimeMs: 60_000, + timeoutRetry: { + // maxNumRetry: 15, + maxNumRetry: 5, + retryDelayMs: 100, + maxRetryDelayMs: 0, + }, + errorRetry: { + // maxNumRetry: 5, + // retryDelayMs: 0, + maxNumRetry: 15, + retryDelayMs: 100, + maxRetryDelayMs: 100, + }, + }, + }, + keyLoadPolicy: loadPolicy, + certLoadPolicy: loadPolicy, + playlistLoadPolicy: loadPolicy, + manifestLoadPolicy: loadPolicy, + steeringManifestLoadPolicy: loadPolicy, + } +} + +type HandleMediastreamProps = { + playerRef: React.RefObject<MediaPlayerInstance> + episodes: Anime_Episode[] + mediaId: Nullish<string | number> +} + +export function useHandleMediastream(props: HandleMediastreamProps) { + + const { + playerRef, + episodes, + mediaId, + } = props + const router = useRouter() + const { filePath, setFilePath } = useMediastreamCurrentFile() + + const { data: mediastreamSettings, isFetching: mediastreamSettingsLoading } = useGetMediastreamSettings(true) + + /** + * Stream URL + */ + const prevUrlRef = React.useRef<string | undefined>(undefined) + const definedUrlRef = React.useRef<string | undefined>(undefined) + const [url, setUrl] = React.useState<string | undefined>(undefined) + const [streamType, setStreamType] = React.useState<Mediastream_StreamType>("transcode") // do not chance + + // Refs + const previousCurrentTimeRef = React.useRef(0) + const previousIsPlayingRef = React.useRef(false) + + const sessionId = useAtomValue(clientIdAtom) + + /** + * Watch history + */ + const { waitForWatchHistory } = useHandleCurrentMediaContinuity(mediaId) + + /** + * Fetch media container containing stream URL + */ + const { data: _mediaContainer, isError: isMediaContainerError, isPending, isFetching, refetch } = useRequestMediastreamMediaContainer({ + path: filePath, + streamType: streamType, + clientId: sessionId ?? uuidv4(), + }, !!mediastreamSettings && !mediastreamSettingsLoading && !waitForWatchHistory) + + const mediaContainer = React.useMemo(() => (!isPending && !isFetching) ? _mediaContainer : undefined, [_mediaContainer, isPending, isFetching]) + + // const { mutate: preloadMediaContainer } = usePreloadMediastreamMediaContainer() + // const [preloadedFilePath, setPreloadedFilePath] = React.useState<string | undefined>(undefined) + + + // Whether the playback has errored + const [playbackErrored, setPlaybackErrored] = React.useState<boolean>(false) + + // Duration + const [duration, setDuration] = React.useState<number>(0) + + React.useEffect(() => { + if (isPending) { + logger("MEDIASTREAM").info("Loading media container") + changeUrl(undefined) + logger("MEDIASTREAM").info("Setting URL to undefined") + } + }, [isPending]) + + const { mutate: shutdownTranscode } = useMediastreamShutdownTranscodeStream() + + /** + * This error happens when the media container is available but the URL has been set to undefined + * - This is usually the case when the transcoder has errored out + */ + const isStreamError = !!mediaContainer && !url + + const { isCodecSupported } = useIsCodecSupported() + + /** + * Effect triggered when media container is available + * - Check compatibility + * - Set URL and stream type when media container is available + */ + React.useEffect(() => { + logger("MEDIASTREAM").info("Media container changed, running effect", mediaContainer) + + /** + * Check if codec is supported, if it is, switch to direct play + */ + const codecSupported = isCodecSupported(mediaContainer?.mediaInfo?.mimeCodec ?? "") + logger("MEDIASTREAM").info("Is codec supported", codecSupported) + + // If the codec is supported, switch to direct play + if (mediaContainer?.streamType === "transcode") { + logger("MEDIASTREAM").info("Stream type is transcode") + + if (!codecSupported && mediastreamSettings?.directPlayOnly) { + logger("MEDIASTREAM").warning("Codec not supported for direct play", mediaContainer?.mediaInfo?.mimeCodec) + logger("MEDIASTREAM").warning("Stopping playback") + toast.warning("Codec not supported for direct play") + changeUrl(undefined) + logger("MEDIASTREAM").info("Setting URL to undefined") + return + } + + if (codecSupported && (!mediastreamSettings?.disableAutoSwitchToDirectPlay || mediastreamSettings?.directPlayOnly)) { + logger("MEDIASTREAM").info("Codec supported", mediaContainer?.mediaInfo?.mimeCodec) + logger("MEDIASTREAM").warning("Switching to direct play") + setStreamType("direct") + changeUrl(undefined) + logger("MEDIASTREAM").info("Setting URL to undefined") + return + } else { + logger("MEDIASTREAM").info("Codec not supported for direct play", mediaContainer?.mediaInfo?.mimeCodec) + } + } + // If the codec is not supported, switch to transcode + if (mediaContainer?.streamType === "direct") { + if (!codecSupported) { + logger("MEDIASTREAM").warning("Codec not supported for direct play", mediaContainer?.mediaInfo?.mimeCodec) + logger("MEDIASTREAM").warning("Switching to transcode") + setStreamType("transcode") + changeUrl(undefined) + logger("MEDIASTREAM").info("Setting URL to undefined") + return + } + } + + if (mediaContainer?.streamUrl) { + logger("MEDIASTREAM").info("Stream URL available", mediaContainer.streamUrl) + + const _newUrl = `${getServerBaseUrl()}${mediaContainer.streamUrl}` + + logger("MEDIASTREAM").info("Changing URL", _newUrl, "streamType:", mediaContainer.streamType) + + changeUrl(_newUrl) + } else { + changeUrl(undefined) + logger("MEDIASTREAM").info("Setting URL to undefined") + } + + }, [mediaContainer?.streamUrl, mediastreamSettings?.disableAutoSwitchToDirectPlay]) + + ////////////////////////////////////////////////////////////// + // JASSUB + ////////////////////////////////////////////////////////////// + + const { jassubOffscreenRender } = useMediastreamJassubOffscreenRender() + + /** + * Effect used to set LibASS renderer + * Add subtitle renderer + */ + React.useEffect(() => { + if (playerRef.current && !!mediaContainer?.mediaInfo?.fonts?.length) { + logger("MEDIASTREAM").info("Adding JASSUB renderer to player", mediaContainer?.mediaInfo?.fonts?.length, "fonts") + const legacyWasmUrl = process.env.NODE_ENV === "development" + ? "/jassub/jassub-worker.wasm.js" : legacy_getAssetUrl("/jassub/jassub-worker.wasm.js") + + logger("MEDIASTREAM").info("Loading JASSUB renderer") + + const fonts = mediaContainer?.mediaInfo?.fonts?.map(name => `${getServerBaseUrl()}/api/v1/mediastream/att/${name}`) || [] + + // Extracted fonts + let availableFonts: Record<string, string> = {} + let firstFont = "" + if (!!fonts?.length) { + for (const font of fonts) { + const name = font.split("/").pop()?.split(".")[0] + if (name) { + if (!firstFont) { + firstFont = name.toLowerCase() + } + availableFonts[name.toLowerCase()] = font + } + } + } + + // Fallback font if no fonts are available + if (!firstFont) { + firstFont = "liberation sans" + } + if (Object.keys(availableFonts).length === 0) { + availableFonts = { + "liberation sans": getServerBaseUrl() + `/jassub/default.woff2`, + } + } + + logger("MEDIASTREAM").info("Available fonts:", availableFonts) + logger("MEDIASTREAM").info("Fallback font:", firstFont) + + // @ts-expect-error + const renderer = new LibASSTextRenderer(() => import("jassub"), { + wasmUrl: "/jassub/jassub-worker.wasm", + workerUrl: "/jassub/jassub-worker.js", + legacyWasmUrl: legacyWasmUrl, + // Both parameters needed for subs to work on iOS, ref: jellyfin-vue + offscreenRender: jassubOffscreenRender, // should be false for iOS + prescaleFactor: 0.8, + onDemandRender: false, + fonts: fonts, + availableFonts: availableFonts, + fallbackFont: firstFont, + }) + playerRef.current!.textRenderers.add(renderer) + + logger("MEDIASTREAM").info("JASSUB renderer added to player") + + return () => { + playerRef.current!.textRenderers.remove(renderer) + } + } + }, [ + playerRef.current, + mediaContainer?.streamUrl, + mediaContainer?.mediaInfo?.fonts, + jassubOffscreenRender, + ]) + + /** + * Changes the stream URL + * @param newUrl + */ + function changeUrl(newUrl: string | undefined) { + logger("MEDIASTREAM").info("[changeUrl] called,", "request url:", newUrl) + if (prevUrlRef.current !== newUrl) { + logger("MEDIASTREAM").info("Resetting playback error status") + setPlaybackErrored(false) + } + setUrl(prevUrl => { + if (prevUrl === newUrl) { + logger("MEDIASTREAM").info("[changeUrl] URL has not changed") + return prevUrl + } + prevUrlRef.current = prevUrl + logger("MEDIASTREAM").info("[changeUrl] URL updated") + return newUrl + }) + if (newUrl) { + definedUrlRef.current = newUrl + } + } + + ////////////////////////////////////////////////////////////// + // Media player + ////////////////////////////////////////////////////////////// + + function onProviderChange(provider: MediaProviderAdapter | null, nativeEvent: MediaProviderChangeEvent) { + if (isHLSProvider(provider) && mediaContainer?.streamType === "transcode") { + logger("MEDIASTREAM").info("[onProviderChange] Provider changed to HLS") + provider.library = HLS + provider.config = { + ...mediastream_getHlsConfig(), + } + } else { + logger("MEDIASTREAM").info("[onProviderChange] Provider changed to native") + } + } + + function onProviderSetup(provider: MediaProviderAdapter, nativeEvent: MediaProviderSetupEvent) { + if (isHLSProvider(provider)) { + if (url) { + + if (HLS.isSupported() && url.endsWith(".m3u8")) { + + logger("MEDIASTREAM").info("[onProviderSetup] HLS Provider setup") + logger("MEDIASTREAM").info("[onProviderSetup] Loading source", url) + + provider.instance?.on(HLS.Events.MANIFEST_PARSED, function (event, data) { + logger("MEDIASTREAM").info("onManifestParsed", data) + // Check if the manifest is live or VOD + data.levels.forEach((level) => { + logger("MEDIASTREAM").info(`Level ${level.id} is live:`, level.details?.live) + }) + }) + + provider.instance?.on(HLS.Events.MEDIA_ATTACHED, (event) => { + logger("MEDIASTREAM").info("onMediaAttached") + }) + + provider.instance?.on(HLS.Events.MEDIA_DETACHED, (event) => { + logger("MEDIASTREAM").warning("onMediaDetached") + // When the media is detached, stop the transcoder but only if there was no playback error + if (!playbackErrored) { + if (mediaContainer?.streamType === "transcode") { + // DEVNOTE: Code below kills the transcoder AFTER changing episode due to delay + // shutdownTranscode() + } + changeUrl(undefined) + } + // refetch() + }) + + provider.instance?.on(HLS.Events.FRAG_LOADED, (event, data) => { + previousCurrentTimeRef.current = playerRef.current?.currentTime ?? 0 + }) + + /** + * Fatal error + */ + provider.instance?.on(HLS.Events.ERROR, (event, data) => { + if (data?.fatal) { + // Record current time + previousCurrentTimeRef.current = playerRef.current?.currentTime ?? 0 + logger("MEDIASTREAM").error("handleFatalError", data) + // Shut down transcoder + if (mediaContainer?.streamType === "transcode") { + shutdownTranscode() + } + // Set playback errored + setPlaybackErrored(true) + // Delete URL + changeUrl(undefined) + toast.error("Playback error") + // Refetch media container + refetch() + } + }) + } else if (!HLS.isSupported() && url.endsWith(".m3u8") && provider.video.canPlayType("application/vnd.apple.mpegurl")) { + logger("MEDIASTREAM").info("HLS not supported, using native HLS") + provider.video.src = url + } else { + logger("MEDIASTREAM").info("HLS not supported, using native HLS") + provider.video.src = url + } + } else { + logger("MEDIASTREAM").error("[onProviderSetup] Provider setup - no URL") + } + } else { + logger("MEDIASTREAM").info("[onProviderSetup] Provider setup - not HLS") + } + } + + + /** + * Current episode + */ + const episode = React.useMemo(() => { + return episodes.find(ep => !!ep.localFile?.path && ep.localFile?.path === filePath) + }, [episodes, filePath]) + + /** + * Continuity + */ + const { handleUpdateWatchHistory } = useHandleContinuityWithMediaPlayer(playerRef, episode?.episodeNumber, mediaId) + + + const preloadedNextFileForRef = React.useRef<string | undefined>(undefined) // unused + + const onCanPlay = (e: MediaCanPlayDetail) => { + logger("MEDIASTREAM").info("[onCanPlay] called", e) + preloadedNextFileForRef.current = undefined + setDuration(e.duration) + } + + const playNextEpisode = () => { + logger("MEDIASTREAM").info("[playNextEpisode] called") + const currentEpisodeIndex = episodes.findIndex(ep => !!ep.localFile?.path && ep.localFile?.path === filePath) + if (currentEpisodeIndex !== -1) { + const nextFile = episodes[currentEpisodeIndex + 1] + if (nextFile?.localFile?.path) { + onPlayFile(nextFile.localFile.path) + } + } + } + + const onPlayFile = (filepath: string) => { + logger("MEDIASTREAM").info("Playing file", filepath) + playerRef.current?.destroy?.() + previousCurrentTimeRef.current = 0 + setFilePath(filepath) + } + + ////////////////////////////////////////////////////////////// + // Events + ////////////////////////////////////////////////////////////// + + /** + * Listen for shutdown stream event + * - This event is sent when something goes wrong internally + * - Settings the URL to undefined will unmount the player and thus avoid spamming the server + */ + useWebsocketMessageListener<string | null>({ + type: WSEvents.MEDIASTREAM_SHUTDOWN_STREAM, + onMessage: log => { + if (log) { + toast.error(log) + } + logger("MEDIASTREAM").warning("Shutdown stream event received") + changeUrl(undefined) + }, + }) + + ////////////////////////////////////////////////////////////// + + // Subtitle endpoint URI + const subtitleEndpointUri = React.useMemo(() => { + if (mediaContainer?.streamUrl && mediaContainer?.streamType) { + return `${getServerBaseUrl()}/api/v1/mediastream/subs` + } + return "" + }, [mediaContainer?.streamUrl, mediaContainer?.streamType]) + + return { + url, + streamType, + subtitles: mediaContainer?.mediaInfo?.subtitles, + isMediaContainerLoading: isPending, + isError: isMediaContainerError || isStreamError, + subtitleEndpointUri, + mediaContainer: _mediaContainer, + onPlayFile, + filePath, + episode, + duration, + disabledAutoSwitchToDirectPlay: mediastreamSettings?.disableAutoSwitchToDirectPlay, + setStreamType: (type: Mediastream_StreamType) => { + logger("MEDIASTREAM").info("[setStreamType] Setting stream type", type) + setStreamType(type) + playerRef.current?.destroy?.() + changeUrl(undefined) + }, + onCanPlay, + playNextEpisode, + onProviderChange, + onProviderSetup, + isCodecSupported, + handleUpdateWatchHistory, + } + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/_lib/mediastream.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/_lib/mediastream.atoms.ts new file mode 100644 index 0000000..c08af86 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/_lib/mediastream.atoms.ts @@ -0,0 +1,61 @@ +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import React from "react" + +const __mediastream_filePath = atomWithStorage<string | undefined>("sea-mediastream-filepath", undefined, undefined, { getOnInit: true }) + +export function useMediastreamCurrentFile() { + const [filePath, setFilePath] = useAtom(__mediastream_filePath) + + return { + filePath, + setFilePath, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +const __mediastream_jassubOffscreenRender = atomWithStorage<boolean>("sea-mediastream-jassub-offscreen-render", false, undefined, { getOnInit: true }) + +export function useMediastreamJassubOffscreenRender() { + const [jassubOffscreenRender, setJassubOffscreenRender] = useAtom(__mediastream_jassubOffscreenRender) + + return { + jassubOffscreenRender, + setJassubOffscreenRender, + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Whether media streaming should be done on this device + */ +const __mediastream_activeOnDevice = atomWithStorage<boolean | null>("sea-mediastream-active-on-device", null, undefined, { getOnInit: true }) + +export function useMediastreamActiveOnDevice() { + const serverStatus = useServerStatus() + const [activeOnDevice, setActiveOnDevice] = useAtom(__mediastream_activeOnDevice) + + // Set default behavior + React.useLayoutEffect(() => { + if (!!serverStatus) { + + if (activeOnDevice === null) { + + if (serverStatus?.clientDevice !== "desktop") { + setActiveOnDevice(true) // Always active on mobile devices + } else { + setActiveOnDevice(false) // Always inactive on desktop devices + } + + } + } + }, [serverStatus?.clientUserAgent, activeOnDevice]) + + return { + activeOnDevice, + setActiveOnDevice, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/page.tsx new file mode 100644 index 0000000..9cd334b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/page.tsx @@ -0,0 +1,306 @@ +"use client" +import { useGetAnimeEntry } from "@/api/hooks/anime_entries.hooks" +import { EpisodeGridItem } from "@/app/(main)/_features/anime/_components/episode-grid-item" +import { MediaEntryPageSmallBanner } from "@/app/(main)/_features/media/_components/media-entry-page-small-banner" +import { MediaEpisodeInfoModal } from "@/app/(main)/_features/media/_components/media-episode-info-modal" +import { SeaMediaPlayer } from "@/app/(main)/_features/sea-media-player/sea-media-player" +import { SeaMediaPlayerLayout } from "@/app/(main)/_features/sea-media-player/sea-media-player-layout" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useHandleMediastream } from "@/app/(main)/mediastream/_lib/handle-mediastream" +import { useMediastreamCurrentFile, useMediastreamJassubOffscreenRender } from "@/app/(main)/mediastream/_lib/mediastream.atoms" +import { Alert } from "@/components/ui/alert" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Modal } from "@/components/ui/modal" +import { Separator } from "@/components/ui/separator" +import { Skeleton } from "@/components/ui/skeleton" +import { MediaPlayerInstance } from "@vidstack/react" +import "@/app/vidstack-theme.css" +import "@vidstack/react/player/styles/default/layouts/video.css" +import { uniq } from "lodash" +import { CaptionsFileFormat } from "media-captions" +import { useRouter, useSearchParams } from "next/navigation" +import React from "react" +import "@vidstack/react/player/styles/base.css" +import { BiInfoCircle } from "react-icons/bi" +import { PluginEpisodeGridItemMenuItems } from "../_features/plugin/actions/plugin-actions" +import { SeaMediaPlayerProvider } from "../_features/sea-media-player/sea-media-player-provider" + + +export default function Page() { + const serverStatus = useServerStatus() + const router = useRouter() + const searchParams = useSearchParams() + const mediaId = searchParams.get("id") + const { data: animeEntry, isLoading: animeEntryLoading } = useGetAnimeEntry(mediaId) + const playerRef = React.useRef<MediaPlayerInstance>(null) + const { filePath } = useMediastreamCurrentFile() + + const mainEpisodes = React.useMemo(() => { + return animeEntry?.episodes?.filter(ep => ep.type === "main") ?? [] + }, [animeEntry?.episodes]) + + const specialEpisodes = React.useMemo(() => { + return animeEntry?.episodes?.filter(ep => ep.type === "special") ?? [] + }, [animeEntry?.episodes]) + + const ncEpisodes = React.useMemo(() => { + return animeEntry?.episodes?.filter(ep => ep.type === "nc") ?? [] + }, [animeEntry?.episodes]) + + const episodes = React.useMemo(() => { + return [...mainEpisodes, ...specialEpisodes, ...ncEpisodes] + }, [mainEpisodes, specialEpisodes, ncEpisodes]) + + const { + url, + isError, + isMediaContainerLoading, + mediaContainer, + subtitles, + subtitleEndpointUri, + onProviderChange, + onProviderSetup, + onCanPlay, + playNextEpisode, + onPlayFile, + isCodecSupported, + setStreamType, + disabledAutoSwitchToDirectPlay, + handleUpdateWatchHistory, + episode, + duration, + } = useHandleMediastream({ playerRef, episodes, mediaId }) + + const { jassubOffscreenRender, setJassubOffscreenRender } = useMediastreamJassubOffscreenRender() + + /** + * The episode number of the current file + */ + const episodeNumber = React.useMemo(() => { + return episodes.find(ep => !!ep.localFile?.path && ep.localFile?.path === filePath)?.episodeNumber || -1 + }, [episodes, filePath]) + const episodeTitle = React.useMemo(() => { + return episodes.find(ep => !!ep.localFile?.path && ep.localFile?.path === filePath)?.episodeTitle + }, [episodes, filePath]) + + const progress = animeEntry?.listData?.progress + + /** + * Effect for when media entry changes + * - Redirect if media entry is not found + * - Reset current progress + */ + React.useEffect(() => { + if (!mediaId || (!animeEntryLoading && !animeEntry) || (!animeEntryLoading && !!animeEntry && !filePath)) { + router.push("/") + } + }, [mediaId, animeEntry, animeEntryLoading, filePath]) + + if (animeEntryLoading || !animeEntry?.media) return <div className="px-4 lg:px-8 space-y-4"> + <div className="flex gap-4 items-center relative"> + <Skeleton className="h-12" /> + </div> + <div + className="grid 2xl:grid-cols-[1fr,450px] gap-4 xl:gap-4" + > + <div className="w-full min-h-[70dvh] relative"> + <Skeleton className="h-full w-full absolute" /> + </div> + + <Skeleton className="hidden 2xl:block relative h-[78dvh] overflow-y-auto pr-4 pt-0" /> + + </div> + </div> + + return ( + <SeaMediaPlayerProvider + media={animeEntry?.media} + progress={{ + currentProgress: progress ?? 0, + currentEpisodeNumber: episodeNumber === -1 ? null : episodeNumber, + currentEpisodeTitle: episodeTitle || null, + }} + > + <AppLayoutStack className="p-4 lg:p-8 z-[5]"> + <SeaMediaPlayerLayout + mediaId={mediaId ? Number(mediaId) : undefined} + title={animeEntry?.media?.title?.userPreferred} + episodes={episodes} + rightHeaderActions={<> + <div className=""> + <Modal + title="Playback" + trigger={ + <Button leftIcon={<BiInfoCircle />} className="rounded-full" intent="gray-basic" size="sm"> + Playback info + </Button> + } + contentClass="sm:rounded-3xl" + > + <div className="space-y-2"> + <p className="tracking-wide text-sm text-[--muted] break-all"> + {mediaContainer?.mediaInfo?.path} + </p> + {isCodecSupported(mediaContainer?.mediaInfo?.mimeCodec || "") ? <Alert + intent="success" + description="File video and audio codecs are compatible with this client. Direct play is recommended." + /> : <Alert + intent="warning" + description="File video and audio codecs are not compatible with this client. Transcoding is needed." + />} + + <p> + <span className="font-bold">Video codec: </span> + <span>{mediaContainer?.mediaInfo?.video?.mimeCodec}</span> + </p> + <p> + <span className="font-bold">Audio codec: </span> + <span>{uniq(mediaContainer?.mediaInfo?.audios?.map(n => n.mimeCodec)).join(", ")}</span> + </p> + + <Modal + title="Playback" + trigger={ + <Button size="sm" className="rounded-full" intent="gray-outline"> + More data + </Button> + } + contentClass="max-w-3xl" + > + <pre className="overflow-x-auto overflow-y-auto max-h-[calc(100dvh-300px)] whitespace-pre-wrap p-2 rounded-[--radius-md] bg-gray-900"> + {JSON.stringify(mediaContainer, null, 2)} + </pre> + </Modal> + + <Separator /> + + <p className="font-semibold text-lg"> + Jassub + </p> + + <Checkbox + label="Offscreen rendering" + value={jassubOffscreenRender} + onValueChange={v => setJassubOffscreenRender(v as boolean)} + help="Enable this if you are experiencing performance issues" + /> + + <Separator /> + + {(mediaContainer?.streamType === "direct") && + <div className="space-y-2"> + <Button + intent="primary-subtle" + onClick={() => setStreamType("transcode")} + disabled={!disabledAutoSwitchToDirectPlay} + > + Switch to transcoding + </Button> + {!disabledAutoSwitchToDirectPlay && <p className="text-[--muted] text-sm italic opacity-50"> + Enable 'prefer transcoding' in the media streaming settings if you want to switch to transcoding + </p>} + </div>} + + {(mediaContainer?.streamType === "transcode" && isCodecSupported(mediaContainer?.mediaInfo?.mimeCodec || "")) && + <Button intent="success-subtle" onClick={() => setStreamType("direct")}> + Switch to direct play + </Button>} + </div> + </Modal> + </div> + + {/* {(!!progressItem && animeEntry?.media && progressItem.episodeNumber > currentProgress) && <Button + className="animate-pulse" + loading={isUpdatingProgress} + disabled={hasUpdatedProgress} + onClick={() => { + updateProgress({ + episodeNumber: progressItem.episodeNumber, + mediaId: animeEntry.media!.id, + totalEpisodes: animeEntry.media!.episodes || 0, + malId: animeEntry.media!.idMal || undefined, + }, { + onSuccess: () => setProgressItem(undefined), + }) + setCurrentProgress(progressItem.episodeNumber) + }} + >Update progress</Button>} */} + </>} + mediaPlayer={ + <SeaMediaPlayer + url={mediaContainer?.streamType === "direct" ? { + src: url || "", + type: mediaContainer?.mediaInfo?.extension === "mp4" ? "video/mp4" : + mediaContainer?.mediaInfo?.extension === "avi" ? "video/x-msvideo" : "video/webm", + } : url} + isPlaybackError={isError ? "Playback error" : undefined} + isLoading={isMediaContainerLoading} + playerRef={playerRef} + poster={episodes?.find(n => n.localFile?.path === mediaContainer?.filePath)?.episodeMetadata?.image || + animeEntry?.media?.bannerImage || animeEntry?.media?.coverImage?.extraLarge} + onProviderChange={onProviderChange} + onProviderSetup={onProviderSetup} + onCanPlay={onCanPlay} + onGoToNextEpisode={playNextEpisode} + tracks={subtitles?.map((sub) => ({ + src: subtitleEndpointUri + sub.link, + label: sub.title || sub.language, + lang: sub.language, + type: (sub.extension?.replace(".", "") || "ass") as CaptionsFileFormat, + kind: "subtitles", + default: sub.isDefault || (!subtitles.some(n => n.isDefault) && sub.language?.startsWith("en")), + }))} + mediaInfoDuration={mediaContainer?.mediaInfo?.duration} + loadingText={<> + <p>Extracting video metadata...</p> + <p>This might take a while.</p> + </>} + /> + } + episodeList={episodes.map((episode) => ( + <EpisodeGridItem + key={episode.localFile?.path || ""} + id={`episode-${String(episode.episodeNumber)}`} + media={episode?.baseAnime as any} + title={episode?.displayTitle || episode?.baseAnime?.title?.userPreferred || ""} + image={episode?.episodeMetadata?.image || episode?.baseAnime?.coverImage?.large} + episodeTitle={episode?.episodeTitle} + fileName={episode?.localFile?.parsedInfo?.original} + onClick={() => { + if (episode.localFile?.path) { + onPlayFile(episode.localFile?.path || "") + } + }} + description={episode?.episodeMetadata?.summary || episode?.episodeMetadata?.overview} + isWatched={!!progress && progress >= episode?.progressNumber} + isFiller={episode.episodeMetadata?.isFiller} + isSelected={episode.localFile?.path === filePath} + length={episode.episodeMetadata?.length} + className="flex-none w-full" + episodeNumber={episode.episodeNumber} + progressNumber={episode.progressNumber} + action={<> + <MediaEpisodeInfoModal + title={episode.displayTitle} + image={episode.episodeMetadata?.image} + episodeTitle={episode.episodeTitle} + airDate={episode.episodeMetadata?.airDate} + length={episode.episodeMetadata?.length} + summary={episode.episodeMetadata?.summary || episode.episodeMetadata?.overview} + isInvalid={episode.isInvalid} + filename={episode.localFile?.parsedInfo?.original} + /> + + <PluginEpisodeGridItemMenuItems isDropdownMenu={true} type="mediastream" episode={episode} /> + </>} + /> + ))} + /> + </AppLayoutStack> + + <MediaEntryPageSmallBanner bannerImage={animeEntry?.media?.bannerImage || animeEntry?.media?.coverImage?.extraLarge} /> + </SeaMediaPlayerProvider> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/test/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/test/page.tsx new file mode 100644 index 0000000..4345eea --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/mediastream/test/page.tsx @@ -0,0 +1,119 @@ +"use client" +import React from "react" + +export default function Page() { + + const mediaContainer = { + "filePath": "E:\\ANIME\\WIND BREAKER\\[SubsPlease] Wind Breaker - 01 (1080p) [5D5071F6].mkv", + "hash": "c4862afed30d91ddaafe678d6a68f5b5a6427cf7", + "streamType": "transcode", + "streamUrl": "/api/v1/mediastream/transcode/master.m3u8", + "mediaInfo": { + "sha": "c4862afed30d91ddaafe678d6a68f5b5a6427cf7", + "path": "E:\\ANIME\\WIND BREAKER\\[SubsPlease] Wind Breaker - 01 (1080p) [5D5071F6].mkv", + "extension": "mkv", + "mimeCodec": "video/x-matroska; codecs=\"avc1.640028, mp4a.40.2\"", + "size": 1458683517, + "duration": 1435.086, + "container": "matroska,webm", + "video": { + "codec": "h264", + "mimeCodec": "avc1.640028", + "language": "und", + "quality": "1080p", + "width": 1920, + "height": 1080, + "bitrate": 8131546, + }, + "videos": [ + { + "codec": "h264", + "mimeCodec": "avc1.640028", + "language": "und", + "quality": "1080p", + "width": 1920, + "height": 1080, + "bitrate": 8131546, + }, + ], + "audios": [ + { + "index": 0, + "title": null, + "language": "ja", + "codec": "aac", + "mimeCodec": "mp4a.40.2", + "isDefault": true, + "isForced": false, + "channels": 0, + }, + ], + "subtitles": [ + { + "index": 0, + "title": "English subs", + "language": "en", + "codec": "ass", + "extension": "ass", + "isDefault": true, + "isForced": false, + "link": "/0.ass", + }, + ], + "fonts": [ + "Roboto-Medium.ttf", + "Roboto-MediumItalic.ttf", + "arial.ttf", + "arialbd.ttf", + "comic.ttf", + "comicbd.ttf", + "times.ttf", + "timesbd.ttf", + "trebuc.ttf", + "trebucbd.ttf", + "verdana.ttf", + "verdanab.ttf", + "CONSOLA.TTF", + "CONSOLAB.TTF", + ], + "chapters": [], + }, + } + + const supported = (() => { + try { + if (typeof WebAssembly === "object" + && typeof WebAssembly.instantiate === "function") { + const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)) + if (module instanceof WebAssembly.Module) + return new WebAssembly.Instance(module) instanceof WebAssembly.Instance + } + } + catch (e) { + } + return false + })() + + console.log(supported ? "WebAssembly is supported" : "WebAssembly is not supported") + + + const canPlay = (codec: string) => { + // most chrome based browser (and safari I think) supports matroska but reports they do not. + // for those browsers, only check the codecs and not the container. + if (navigator.userAgent.search("Firefox") === -1) + codec = codec.replace("video/x-matroska", "video/mp4") + const videos = document.getElementsByTagName("video") + const video = videos.item(0) ?? document.createElement("video") + return !!video.canPlayType(codec) + } + + React.useEffect(() => { + console.log(canPlay(mediaContainer.mediaInfo.mimeCodec)) + }, []) + + return ( + <> + Go away. + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_components/episode-pills-grid.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_components/episode-pills-grid.tsx new file mode 100644 index 0000000..84a990a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_components/episode-pills-grid.tsx @@ -0,0 +1,111 @@ +import { cn } from "@/components/ui/core/styling" +import { motion } from "motion/react" +import React from "react" + +type Episode = { + number: number + title?: string | null + isFiller?: boolean +} + +type EpisodePillsGridProps = { + episodes: Episode[] + currentEpisodeNumber: number + onEpisodeSelect: (episodeNumber: number) => void + progress?: number + disabled?: boolean + className?: string + getEpisodeId: (episode: Episode) => string +} + +export function EpisodePillsGrid({ + episodes, + currentEpisodeNumber, + onEpisodeSelect, + progress = 0, + disabled = false, + className, + getEpisodeId, +}: EpisodePillsGridProps) { + return ( + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -20 }} + transition={{ duration: 0.3 }} + className={cn( + "grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-10 xl:grid-cols-10 2xl:grid-cols-6 gap-2 pb-8", + className, + )} + > + {episodes + ?.filter(Boolean) + ?.sort((a, b) => a!.number - b!.number) + ?.map((episode) => { + const isSelected = episode.number === currentEpisodeNumber + const isWatched = progress > 0 && episode.number <= progress + const isFiller = episode.isFiller + + return ( + <motion.button + key={episode.number} + // initial={{ scale: 0.95, opacity: 0 }} + // animate={{ scale: 1, opacity: 1 }} + // whileHover={{ scale: disabled ? 1 : 1.02 }} + // whileTap={{ scale: disabled ? 1 : 0.98 }} + // transition={{ + // duration: 0.15, + // // delay: episode.number * 0.005 + // }} + onClick={() => !disabled && onEpisodeSelect(episode.number)} + disabled={disabled} + title={episode.title || `Episode ${episode.number}`} + id={getEpisodeId(episode)} + className={cn( + "relative flex items-center justify-center", + "w-full h-10 rounded-md font-medium text-sm", + "transition-all duration-150 ease-out", + "focus:outline-none", + !isSelected && [ + "bg-[--subtle]", + "hover:bg-transparent", + ], + isSelected && [ + "bg-brand-500 text-white", + ], + isFiller && !isSelected && [ + "text-orange-300", + ], + isWatched && !isSelected && [ + "text-[--muted]", + ], + disabled && [ + "opacity-50 cursor-not-allowed", + "hover:bg-inherit hover:text-inherit hover:scale-100", + ], + )} + > + <span className="relative z-10">{episode.number}</span> + + {isFiller && ( + <div + className={cn( + "absolute top-1 right-1 w-1.5 h-1.5 rounded-full", + "bg-orange-400", + isSelected && "bg-orange-200", + )} + title="Filler episode" + /> + )} + + {/* {isWatched && !isSelected && ( + <div + className="absolute bottom-1 left-1/2 transform -translate-x-1/2 w-1 h-1 rounded-full bg-[--brand]" + /> + )} */} + </motion.button> + ) + })} + </motion.div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_components/onlinestream-episode-list-item.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_components/onlinestream-episode-list-item.tsx new file mode 100644 index 0000000..3906886 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_components/onlinestream-episode-list-item.tsx @@ -0,0 +1,143 @@ +import { AL_BaseAnime } from "@/api/generated/types" +import { imageShimmer } from "@/components/shared/image-helpers" +import { cn } from "@/components/ui/core/styling" +import Image from "next/image" +import React from "react" +import { AiFillPlayCircle, AiFillWarning } from "react-icons/ai" + +type EpisodeListItemProps = { + media: AL_BaseAnime, + children?: React.ReactNode + action?: React.ReactNode + image?: string | null + onClick?: () => void + title: string, + episodeTitle?: string | null + description?: string | null + fileName?: string + isSelected?: boolean + isWatched?: boolean + unoptimizedImage?: boolean + isInvalid?: boolean + imageClassName?: string + imageContainerClassName?: string + className?: string + actionIcon?: React.ReactElement | null + disabled?: boolean +} + +export const OnlinestreamEpisodeListItem: React.FC<EpisodeListItemProps & React.ComponentPropsWithoutRef<"div">> = (props) => { + + const { + children, + action, + image, + onClick, + episodeTitle, + description, + title, + fileName, + isSelected, + media, + isWatched, + unoptimizedImage, + isInvalid, + imageClassName, + imageContainerClassName, + className, + disabled, + actionIcon = props.actionIcon !== null ? <AiFillPlayCircle className="opacity-70 text-4xl" /> : undefined, + ...rest + } = props + + return <> + <div + className={cn( + "border p-3 pr-12 rounded-lg relative transition hover:bg-gray-900 group/episode-list-item bg-[--background]", + { + "border-zinc-500 bg-gray-900 hover:bg-gray-900": isSelected, + "border-red-700": isInvalid, + "opacity-50 pointer-events-none": disabled, + "opacity-50": isWatched && !isSelected, + }, className, + )} + {...rest} + > + {/*{isCompleted && <div className="absolute top-1 left-1 w-full h-1 bg-brand rounded-full"/>}*/} + + <div + className={cn( + "flex gap-4 relative", + )} + > + <div + className={cn( + "size-20 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden", + "group/ep-item-img-container", + !disabled && "cursor-pointer", + disabled && "pointer-events-none", + imageContainerClassName, + )} + onClick={onClick} + > + {!!onClick && <div + className={cn( + "absolute inset-0 bg-gray-950 bg-opacity-60 z-[1] flex items-center justify-center", + "transition-opacity opacity-0 group-hover/ep-item-img-container:opacity-100", + )} + > + {actionIcon && actionIcon} + </div>} + {(image || media.coverImage?.medium) && <Image + src={image || media.coverImage?.medium || ""} + alt="episode image" + fill + quality={60} + placeholder={imageShimmer(700, 475)} + sizes="10rem" + className={cn("object-cover object-center transition", { + "opacity-25 group-hover/episode-list-item:opacity-100": isWatched, + }, imageClassName)} + data-src={image} + />} + </div> + {(image && unoptimizedImage) && <div + className="h-24 w-24 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden" + > + <img + src={image} + alt="episode image" + className="object-cover object-center absolute w-full h-full" + data-src={image} + /> + </div>} + + <div className="relative overflow-hidden"> + {isInvalid && <p className="flex gap-2 text-red-300 items-center"><AiFillWarning + className="text-lg text-red-500" + /> Unidentified</p>} + {isInvalid && <p className="flex gap-2 text-red-200 text-sm items-center">No metadata found</p>} + + <p + className={cn( + "font-medium transition text-lg line-clamp-2", + { "text-[--muted]": !isSelected }, + // { "opacity-50 group-hover/episode-list-item:opacity-100": isWatched }, + )} + >{!!title ? title?.replaceAll("`", "'") : "No title"}</p> + + {!!episodeTitle && <p className={cn("text-sm text-[--muted] line-clamp-2")}>{episodeTitle?.replaceAll("`", "'")}</p>} + + {!!fileName && <p className="text-sm text-gray-600 line-clamp-1">{fileName}</p>} + {!!description && <p className="text-sm text-[--muted] line-clamp-1 italic">{description}</p>} + {children && children} + </div> + </div> + + {action && <div className="absolute right-1 top-1 flex flex-col items-center"> + {action} + </div>} + </div> + </> + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_components/onlinestream-video-addons.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_components/onlinestream-video-addons.tsx new file mode 100644 index 0000000..19bb469 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_components/onlinestream-video-addons.tsx @@ -0,0 +1,285 @@ +import { useOnlineStreamEmptyCache } from "@/api/hooks/onlinestream.hooks" +import { useOnlinestreamManagerContext } from "@/app/(main)/onlinestream/_lib/onlinestream-manager" +import { + __onlinestream_selectedDubbedAtom, + __onlinestream_selectedProviderAtom, + __onlinestream_selectedServerAtom, +} from "@/app/(main)/onlinestream/_lib/onlinestream.atoms" +import { Button } from "@/components/ui/button" +import { Modal, ModalProps } from "@/components/ui/modal" +import { Popover, PopoverProps } from "@/components/ui/popover" +import { RadioGroup } from "@/components/ui/radio-group" +import { Select } from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { useWindowSize } from "@uidotdev/usehooks" +import { Menu, Tooltip } from "@vidstack/react" +import { ChevronLeftIcon, ChevronRightIcon, RadioButtonIcon, RadioButtonSelectedIcon } from "@vidstack/react/icons" +import { useAtom } from "jotai/react" +import { useRouter } from "next/navigation" +import React from "react" +import { HiOutlineCog6Tooth } from "react-icons/hi2" +import { LuGlobe, LuSpeech } from "react-icons/lu" +import { MdHighQuality, MdOutlineSubtitles } from "react-icons/md" +import { TbCloudSearch } from "react-icons/tb" + +type OnlinestreamServerButtonProps = { + children?: React.ReactNode +} + +export const buttonClass = "ring-media-focus group relative mr-0.5 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-[--radius-md] outline-none ring-inset hover:bg-white/20 data-[focus]:ring-4 aria-hidden:hidden" + +export const tooltipClass = + "animate-out fade-out slide-out-to-bottom-2 data-[visible]:animate-in data-[visible]:fade-in data-[visible]:slide-in-from-bottom-4 z-10 rounded-sm bg-black/90 px-2 py-0.5 text-sm font-medium text-white group-data-[open]/parent:hidden" + +export const menuClass = + "animate-out fade-out slide-out-to-bottom-2 data-[open]:animate-in data-[open]:fade-in data-[open]:slide-in-from-bottom-4 flex h-[var(--menu-height)] max-h-[400px] min-w-[260px] flex-col overflow-y-auto overscroll-y-contain rounded-[--radius-md] border border-white/10 bg-black/95 p-2.5 font-sans text-[15px] font-medium outline-none backdrop-blur-sm transition-[height] duration-300 will-change-[height] data-[resizing]:overflow-hidden" + +export const submenuClass = + "hidden w-full flex-col items-start justify-center outline-none data-[keyboard]:mt-[3px] data-[open]:inline-block" + +const radioGroupItemContainerClass = "px-2 py-1.5 rounded-[--radius-md] hover:bg-[--subtle]" + +export function OnlinestreamVideoQualitySubmenu() { + + const { customQualities, videoSource, changeQuality } = useOnlinestreamManagerContext() + + return ( + <Menu.Root> + <VdsSubmenuButton + label={`Quality `} + hint={videoSource?.quality || ""} + disabled={false} + icon={MdHighQuality} + /> + <Menu.Content className={submenuClass}> + <Menu.RadioGroup value={videoSource?.quality || "-"}> + {customQualities.map((v) => ( + <Radio + value={v} + onSelect={e => { + if (e.target.checked) { + changeQuality(v) + } + }} + key={v} + > + {v} + </Radio> + ))} + </Menu.RadioGroup> + </Menu.Content> + </Menu.Root> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function OnlinestreamParametersButton({ mediaId }: { mediaId: number }) { + + const { servers, providerExtensionOptions, changeProvider, changeServer } = useOnlinestreamManagerContext() + + const router = useRouter() + const [provider] = useAtom(__onlinestream_selectedProviderAtom) + const [selectedServer] = useAtom(__onlinestream_selectedServerAtom) + + const { mutate: emptyCache, isPending } = useOnlineStreamEmptyCache() + + return ( + <> + <Select + value={provider || ""} + options={[ + ...providerExtensionOptions, + { + value: "add-provider", + label: "Find other providers", + }, + ]} + onValueChange={(v) => { + if (v === "add-provider") { + router.push(`/extensions`) + return + } + changeProvider(v) + }} + size="sm" + leftAddon={<LuGlobe />} + fieldClass="w-fit" + className="rounded-full rounded-l-none w-fit" + addonClass="rounded-full rounded-r-none" + /> + {!!servers.length && <Select + size="sm" + value={selectedServer} + options={servers.map((server) => ({ label: server, value: server }))} + onValueChange={(v) => { + changeServer(v) + }} + fieldClass="w-fit" + className="rounded-full w-fit !px-4" + addonClass="rounded-full rounded-r-none" + />} + <IsomorphicPopover + title="Stream" + trigger={<Button + intent="gray-basic" + size="sm" + className="rounded-full" + leftIcon={<HiOutlineCog6Tooth className="text-xl" />} + > + Cache + </Button>} + > + <p className="text-sm text-[--muted]"> + Empty the cache if you are experiencing issues with the stream. + </p> + <Button + size="sm" + intent="alert-subtle" + onClick={() => emptyCache({ mediaId })} + loading={isPending} + > + Empty stream cache + </Button> + </IsomorphicPopover> + </> + ) + + +} + +function IsomorphicPopover(props: PopoverProps & ModalProps) { + const { title, children, ...rest } = props + const { width } = useWindowSize() + + if (width && width > 1024) { + return <Popover + {...rest} + className="max-w-xl !w-full overflow-hidden space-y-2" + > + {children} + </Popover> + } + + return <Modal + {...rest} + title={title} + > + {children} + </Modal> +} + +export function OnlinestreamProviderButton(props: OnlinestreamServerButtonProps) { + + const { + children, + ...rest + } = props + + const { changeProvider, providerExtensionOptions, servers, changeServer } = useOnlinestreamManagerContext() + + const [provider] = useAtom(__onlinestream_selectedProviderAtom) + const [selectedServer] = useAtom(__onlinestream_selectedServerAtom) + + if (!servers.length || !selectedServer) return null + + return ( + <Menu.Root className="parent"> + <Tooltip.Root> + <Tooltip.Trigger asChild> + <Menu.Button className={buttonClass}> + <TbCloudSearch className="text-3xl" /> + </Menu.Button> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement="top"> + Provider + </Tooltip.Content> + </Tooltip.Root> + <Menu.Content className={menuClass} placement="top"> + <p className="text-white px-2 py-1"> + Provider + </p> + <RadioGroup + value={provider || ""} + options={providerExtensionOptions} + onValueChange={(v) => { + changeProvider(v) + }} + itemContainerClass={radioGroupItemContainerClass} + /> + <Separator className="my-1" /> + <p className="text-white px-2 py-1"> + Server + </p> + <RadioGroup + value={selectedServer} + options={servers.map((server) => ({ label: server, value: server }))} + onValueChange={(v) => { + changeServer(v) + }} + itemContainerClass={radioGroupItemContainerClass} + /> + </Menu.Content> + </Menu.Root> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export interface RadioProps extends Menu.RadioProps { +} + +function Radio({ children, ...props }: RadioProps) { + return ( + <Menu.Radio + className="ring-media-focus group relative flex w-full cursor-pointer select-none items-center justify-start rounded-sm p-2.5 outline-none data-[hocus]:bg-white/10 data-[focus]:ring-[3px]" + {...props} + > + <RadioButtonIcon className="h-4 w-4 text-white group-data-[checked]:hidden" /> + <RadioButtonSelectedIcon className="text-media-brand hidden h-4 w-4 group-data-[checked]:block" /> + <span className="ml-2">{children}</span> + </Menu.Radio> + ) +} + +export interface VdsSubmenuButtonProps { + label: string; + hint: string; + disabled?: boolean; + icon: any; +} + +export function VdsSubmenuButton({ label, hint, icon: Icon, disabled }: VdsSubmenuButtonProps) { + return ( + <Menu.Button className="vds-menu-button" disabled={disabled}> + <ChevronLeftIcon className="vds-menu-button-close-icon" /> + <Icon className="vds-menu-button-icon" /> + <span className="vds-menu-button-label mr-2">{label}</span> + <span className="vds-menu-button-hint">{hint}</span> + <ChevronRightIcon className="vds-menu-button-open-icon" /> + </Menu.Button> + ) +} + + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export function SwitchSubOrDubButton() { + const [dubbed] = useAtom(__onlinestream_selectedDubbedAtom) + const { selectedExtension, toggleDubbed } = useOnlinestreamManagerContext() + + if (!selectedExtension || !selectedExtension?.supportsDub) return null + + return ( + <Button + className="" + rounded + intent="gray-basic" + size="sm" + leftIcon={!dubbed ? <LuSpeech className="text-xl" /> : <MdOutlineSubtitles className="text-xl" />} + onClick={() => toggleDubbed()} + > + {dubbed ? "Switch to subs" : "Switch to dub"} + </Button> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_containers/onlinestream-manual-matching.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_containers/onlinestream-manual-matching.tsx new file mode 100644 index 0000000..f9ea287 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_containers/onlinestream-manual-matching.tsx @@ -0,0 +1,201 @@ +import { Anime_Entry } from "@/api/generated/types" +import { + useGetOnlinestreamMapping, + useOnlinestreamManualMapping, + useOnlinestreamManualSearch, + useRemoveOnlinestreamMapping, +} from "@/api/hooks/onlinestream.hooks" +import { __onlinestream_selectedProviderAtom } from "@/app/(main)/onlinestream/_lib/onlinestream.atoms" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { SeaLink } from "@/components/shared/sea-link" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { defineSchema, Field, Form, InferType } from "@/components/ui/form" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { Separator } from "@/components/ui/separator" +import { Tooltip } from "@/components/ui/tooltip" +import { useAtomValue } from "jotai/react" +import React from "react" +import { BiLinkExternal } from "react-icons/bi" +import { FiSearch } from "react-icons/fi" + +type OnlinestreamManualMappingModalProps = { + entry: Anime_Entry + children: React.ReactElement +} + +export function OnlinestreamManualMappingModal(props: OnlinestreamManualMappingModalProps) { + + const { + children, + entry, + ...rest + } = props + + return ( + <> + <Modal + title="Manual match" + description="Match this anime to a search result from the provider." + trigger={children} + contentClass="max-w-4xl" + > + <Content entry={entry} /> + </Modal> + </> + ) +} + +const searchSchema = defineSchema(({ z }) => z.object({ + query: z.string().min(1), + dubbed: z.boolean().default(false), +})) + +function Content({ entry }: { entry: Anime_Entry }) { + const selectedProvider = useAtomValue(__onlinestream_selectedProviderAtom) + + // Get current mapping + const { data: existingMapping, isLoading: mappingLoading } = useGetOnlinestreamMapping({ + provider: selectedProvider || undefined, + mediaId: entry.mediaId, + }) + + // Search + const { mutate: search, data: searchResults, isPending: searchLoading, reset } = useOnlinestreamManualSearch(entry.mediaId, selectedProvider) + + function handleSearch(data: InferType<typeof searchSchema>) { + if (selectedProvider) { + search({ + provider: selectedProvider, + query: data.query, + dubbed: data.dubbed, + }) + } + } + + // Match + const { mutate: match, isPending: isMatching } = useOnlinestreamManualMapping() + + // Unmatch + const { mutate: unmatch, isPending: isUnmatching } = useRemoveOnlinestreamMapping() + + const [animeId, setAnimeId] = React.useState<string | null>(null) + const confirmMatch = useConfirmationDialog({ + title: "Manual match", + description: "Are you sure you want to match this anime to the search result?", + actionText: "Confirm", + actionIntent: "success", + onConfirm: () => { + if (animeId && selectedProvider) { + match({ + provider: selectedProvider, + mediaId: entry.mediaId, + animeId: animeId, + }) + reset() + setAnimeId(null) + } + }, + }) + + return ( + <> + {mappingLoading ? ( + <LoadingSpinner /> + ) : ( + <AppLayoutStack> + <div className="text-center"> + {!!existingMapping?.animeId ? ( + <AppLayoutStack> + <p> + Current mapping: <span>{existingMapping.animeId}</span> + </p> + <Button + intent="alert-subtle" loading={isUnmatching} onClick={() => { + if (selectedProvider) { + unmatch({ + provider: selectedProvider, + mediaId: entry.mediaId, + }) + } + }} + > + Remove mapping + </Button> + </AppLayoutStack> + ) : ( + <p className="text-[--muted] italic">No manual match</p> + )} + </div> + + <Separator /> + + <Form schema={searchSchema} onSubmit={handleSearch}> + <div className="space-y-2"> + <Field.Text + name="query" + placeholder="Enter a title..." + leftIcon={<FiSearch className="text-xl text-[--muted]" />} + fieldClass="w-full" + /> + + <Field.Switch + name="dubbed" + label="Look for dubs" + side="right" + moreHelp="Only applies to providers that support dubs in search results." + /> + + <Field.Submit intent="white" loading={isMatching || searchLoading || mappingLoading} className="">Search</Field.Submit> + </div> + </Form> + + {searchLoading ? <LoadingSpinner /> : ( + <> + <div className="space-y-2"> + {searchResults?.map(item => ( + <div + key={item.id} + className={cn( + "flex justify-between items-center", + )} + > + <p + onClick={() => { + setAnimeId(item.id) + React.startTransition(() => { + confirmMatch.open() + }) + }} + className="cursor-pointer hover:underline" + > + {item.title} + </p> + <div> + <SeaLink href={item.url} target="_blank"> + <Tooltip + trigger={<IconButton + icon={<BiLinkExternal />} + intent="primary-basic" + size="xs" + />} + > + Open in browser + </Tooltip> + </SeaLink> + </div> + </div> + ))} + </div> + </> + )} + + </AppLayoutStack> + )} + + <ConfirmationDialog {...confirmMatch} /> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_containers/onlinestream-page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_containers/onlinestream-page.tsx new file mode 100644 index 0000000..084d616 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_containers/onlinestream-page.tsx @@ -0,0 +1,349 @@ +import { Anime_Entry } from "@/api/generated/types" +import { serverStatusAtom } from "@/app/(main)/_atoms/server-status.atoms" +import { EpisodeGridItem } from "@/app/(main)/_features/anime/_components/episode-grid-item" +import { MediaEpisodeInfoModal } from "@/app/(main)/_features/media/_components/media-episode-info-modal" +import { useNakamaStatus } from "@/app/(main)/_features/nakama/nakama-manager" +import { SeaMediaPlayer } from "@/app/(main)/_features/sea-media-player/sea-media-player" +import { SeaMediaPlayerLayout } from "@/app/(main)/_features/sea-media-player/sea-media-player-layout" +import { SeaMediaPlayerProvider } from "@/app/(main)/_features/sea-media-player/sea-media-player-provider" +import { EpisodePillsGrid } from "@/app/(main)/onlinestream/_components/episode-pills-grid" +import { + OnlinestreamParametersButton, + OnlinestreamProviderButton, + OnlinestreamVideoQualitySubmenu, + SwitchSubOrDubButton, +} from "@/app/(main)/onlinestream/_components/onlinestream-video-addons" +import { OnlinestreamManualMappingModal } from "@/app/(main)/onlinestream/_containers/onlinestream-manual-matching" +import { useHandleOnlinestream } from "@/app/(main)/onlinestream/_lib/handle-onlinestream" +import { OnlinestreamManagerProvider } from "@/app/(main)/onlinestream/_lib/onlinestream-manager" +import { LuffyError } from "@/components/shared/luffy-error" +import { Button, IconButton } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { logger } from "@/lib/helpers/debug" +import { isHLSProvider, MediaPlayerInstance, MediaProviderAdapter, MediaProviderChangeEvent, MediaProviderSetupEvent } from "@vidstack/react" +import { AxiosError } from "axios" +import HLS from "hls.js" +import { atom } from "jotai/index" +import { useAtom, useAtomValue } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import { AnimatePresence, motion } from "motion/react" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import React from "react" +import { BsFillGrid3X3GapFill } from "react-icons/bs" +import { FaSearch } from "react-icons/fa" +import "@/app/vidstack-theme.css" +import "@vidstack/react/player/styles/default/layouts/video.css" +import { PluginEpisodeGridItemMenuItems } from "../../_features/plugin/actions/plugin-actions" + +type OnlinestreamPageProps = { + animeEntry?: Anime_Entry + animeEntryLoading?: boolean + hideBackButton?: boolean +} + +type ProgressItem = { + episodeNumber: number +} +const progressItemAtom = atom<ProgressItem | undefined>(undefined) + +// Episode view mode atom +const episodeViewModeAtom = atomWithStorage<"list" | "grid">("sea-onlinestream-episode-view-mode", "list") + +export function OnlinestreamPage({ animeEntry, animeEntryLoading, hideBackButton }: OnlinestreamPageProps) { + const serverStatus = useAtomValue(serverStatusAtom) + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const mediaId = searchParams.get("id") + const urlEpNumber = searchParams.get("episode") + + const ref = React.useRef<MediaPlayerInstance>(null) + const [episodeViewMode, setEpisodeViewMode] = useAtom(episodeViewModeAtom) + + const media = animeEntry?.media + + const { + episodes, + currentEpisodeDetails, + opts, + url, + onMediaDetached, + onCanPlay: _onCanPlay, + onFatalError, + loadPage, + // media, + episodeSource, + currentEpisodeNumber, + handleChangeEpisodeNumber, + episodeLoading, + isErrorEpisodeSource, + errorEpisodeSource, + isErrorProvider, + provider, + } = useHandleOnlinestream({ + mediaId, + ref, + }) + + const progress = animeEntry?.listData?.progress ?? 0 + + const nakamaStatus = useNakamaStatus() + + /** + * Set episode number on mount + */ + const firstRenderRef = React.useRef(true) + React.useEffect(() => { + // Do not auto set the episode number if the user is in a watch party and is not the host + if (!!nakamaStatus?.currentWatchPartySession && !nakamaStatus.isHost) return + + if (!!media && firstRenderRef.current && !!episodes) { + const episodeNumberFromURL = urlEpNumber ? Number(urlEpNumber) : undefined + const progress = animeEntry?.listData?.progress ?? 0 + let episodeNumber = 1 + const episodeToWatch = episodes.find(e => e.number === progress + 1) + if (episodeToWatch) { + episodeNumber = episodeToWatch.number + } + handleChangeEpisodeNumber(episodeNumberFromURL || episodeNumber || 1) + logger("ONLINESTREAM").info("Setting episode number to", episodeNumberFromURL || episodeNumber || 1) + firstRenderRef.current = false + } + }, [episodes, media, animeEntry?.listData, urlEpNumber]) + + React.useEffect(() => { + const t = setTimeout(() => { + if (urlEpNumber) { + router.replace(pathname + `?id=${mediaId}`) + } + }, 500) + + return () => clearTimeout(t) + }, [mediaId]) + + const episodeTitle = episodes?.find(e => e.number === currentEpisodeNumber)?.title + + function goToNextEpisode() { + // check if the episode exists + if (episodes?.find(e => e.number === currentEpisodeNumber + 1)) { + handleChangeEpisodeNumber(currentEpisodeNumber + 1) + } + } + + function goToPreviousEpisode() { + if (currentEpisodeNumber > 1) { + // check if the episode exists + if (episodes?.find(e => e.number === currentEpisodeNumber - 1)) { + handleChangeEpisodeNumber(currentEpisodeNumber - 1) + } + } + } + + function onProviderChange( + provider: MediaProviderAdapter | null, + nativeEvent: MediaProviderChangeEvent, + ) { + if (isHLSProvider(provider)) { + provider.library = HLS + provider.config = { + // debug: true, + } + } + } + + function onProviderSetup(provider: MediaProviderAdapter, nativeEvent: MediaProviderSetupEvent) { + if (isHLSProvider(provider)) { + if (HLS.isSupported()) { + provider.instance?.on(HLS.Events.MEDIA_DETACHED, (event) => { + onMediaDetached() + }) + provider.instance?.on(HLS.Events.ERROR, (event, data) => { + if (data.fatal) { + onFatalError() + } + }) + } else if (provider.video.canPlayType("application/vnd.apple.mpegurl")) { + provider.video.src = url || "" + } + } + } + + React.useEffect(() => { + const t = setTimeout(() => { + const element = document.querySelector(".vds-quality-menu") + if (opts.hasCustomQualities) { + // Toggle the class + element?.classList?.add("force-hidden") + } else { + // Toggle the class + element?.classList?.remove("force-hidden") + } + }, 1000) + return () => clearTimeout(t) + }, [opts.hasCustomQualities, url, episodeLoading]) + + if (!media || animeEntryLoading) return <div data-onlinestream-page-loading-container className="space-y-4"> + <div className="flex gap-4 items-center relative"> + <Skeleton className="h-12" /> + </div> + <div + className="grid 2xl:grid-cols-[1fr,450px] gap-4 xl:gap-4" + > + <div className="w-full min-h-[70dvh] relative"> + <Skeleton className="h-full w-full absolute" /> + </div> + + <Skeleton className="hidden 2xl:block relative h-[78dvh] overflow-y-auto pr-4 pt-0" /> + + </div> + </div> + + return ( + <SeaMediaPlayerProvider + media={media} + progress={{ + currentProgress: progress, + currentEpisodeNumber, + currentEpisodeTitle: episodeTitle || null, + }} + > + <OnlinestreamManagerProvider opts={opts}> + <SeaMediaPlayerLayout + mediaId={mediaId ? Number(mediaId) : undefined} + title={media?.title?.userPreferred} + hideBackButton={hideBackButton} + episodes={episodes} + loading={loadPage} + leftHeaderActions={<> + {!!mediaId && <OnlinestreamParametersButton mediaId={Number(mediaId)} />} + {animeEntry && <OnlinestreamManualMappingModal entry={animeEntry}> + <Button + size="sm" + intent="gray-basic" + className="rounded-full" + leftIcon={<FaSearch className="" />} + > + Manual match + </Button> + </OnlinestreamManualMappingModal>} + <SwitchSubOrDubButton /> + <div className="hidden lg:flex flex-1"></div> + </>} + rightHeaderActions={<> + <IconButton + size="sm" + intent={episodeViewMode === "list" ? "gray-basic" : "white-subtle"} + icon={<BsFillGrid3X3GapFill />} + onClick={() => setEpisodeViewMode(prev => prev === "list" ? "grid" : "list")} + title={episodeViewMode === "list" ? "Switch to grid view" : "Switch to list view"} + /> + </>} + mediaPlayer={!provider ? ( + <div className="flex items-center flex-col justify-center w-full h-full"> + <LuffyError title="No provider selected" /> + {!!mediaId && <OnlinestreamParametersButton mediaId={Number(mediaId)} />} + </div> + ) : isErrorProvider ? <LuffyError title="Provider error" /> : ( + <SeaMediaPlayer + url={url} + poster={currentEpisodeDetails?.image || media.coverImage?.extraLarge} + isLoading={!loadPage || episodeLoading} + isPlaybackError={isErrorEpisodeSource + ? (errorEpisodeSource as AxiosError<{ error: string }>)?.response?.data?.error + : undefined} + playerRef={ref} + onProviderChange={onProviderChange} + onProviderSetup={onProviderSetup} + onCanPlay={_onCanPlay} + onGoToNextEpisode={goToNextEpisode} + onGoToPreviousEpisode={goToPreviousEpisode} + tracks={episodeSource?.subtitles?.map((sub) => ({ + id: sub.language, + label: sub.language, + kind: "subtitles", + src: sub.url, + language: sub.language, + default: sub.language + ? sub.language?.toLowerCase() === "english" || sub.language?.toLowerCase() === "en-us" + : sub.language?.toLowerCase() === "english" || sub.language?.toLowerCase() === "en-us", + }))} + settingsItems={<> + {opts.hasCustomQualities ? ( + <OnlinestreamVideoQualitySubmenu /> + ) : null} + </>} + videoLayoutSlots={{ + beforeCaptionButton: <> + <div className="flex items-center"> + <OnlinestreamProviderButton /> + </div> + </>, + }} + /> + )} + episodeList={<> + <AnimatePresence mode="wait" initial={false}> + {episodeViewMode === "list" ? ( + <motion.div + key="list-view" + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -20 }} + transition={{ duration: 0.3 }} + className="space-y-3" + > + {episodes?.filter(Boolean)?.sort((a, b) => a!.number - b!.number)?.map((episode, idx) => { + return ( + <EpisodeGridItem + key={idx + (episode.title || "") + episode.number} + id={`episode-${String(episode.number)}`} + onClick={() => handleChangeEpisodeNumber(episode.number)} + title={media.format === "MOVIE" ? "Complete movie" : `Episode ${episode.number}`} + episodeTitle={episode.title} + description={episode.description ?? undefined} + image={episode.image} + media={media} + isSelected={episode.number === currentEpisodeNumber} + disabled={episodeLoading} + isWatched={progress ? episode.number <= progress : undefined} + className="flex-none w-full" + isFiller={episode.isFiller} + episodeNumber={episode.number} + progressNumber={episode.number} + action={<> + <MediaEpisodeInfoModal + title={media.format === "MOVIE" ? "Complete movie" : `Episode ${episode.number}`} + image={episode?.image} + episodeTitle={episode.title} + summary={episode?.description} + /> + + <PluginEpisodeGridItemMenuItems isDropdownMenu={true} type="onlinestream" episode={episode} /> + </>} + /> + ) + })} + {!!episodes?.length && <p className="text-center text-[--muted] py-2">End</p>} + </motion.div> + ) : ( + <EpisodePillsGrid + key="grid-view" + episodes={episodes?.map(ep => ({ + number: ep.number, + title: ep.title, + isFiller: ep.isFiller, + })) || []} + currentEpisodeNumber={currentEpisodeNumber} + onEpisodeSelect={handleChangeEpisodeNumber} + progress={progress} + disabled={episodeLoading} + getEpisodeId={(ep) => `episode-${ep.number}`} + /> + )} + </AnimatePresence> + </>} + /> + </OnlinestreamManagerProvider> + </SeaMediaPlayerProvider> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/handle-onlinestream-providers.ts b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/handle-onlinestream-providers.ts new file mode 100644 index 0000000..3b2e797 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/handle-onlinestream-providers.ts @@ -0,0 +1,38 @@ +import { useListOnlinestreamProviderExtensions } from "@/api/hooks/extensions.hooks" +import { __onlinestream_selectedProviderAtom } from "@/app/(main)/onlinestream/_lib/onlinestream.atoms" +import { logger } from "@/lib/helpers/debug" +import { useAtom } from "jotai/react" +import React from "react" + +export function useHandleOnlinestreamProviderExtensions() { + + const { data: providerExtensions } = useListOnlinestreamProviderExtensions() + + const [provider, setProvider] = useAtom(__onlinestream_selectedProviderAtom) + + /** + * Override the selected provider if it is not available + */ + React.useLayoutEffect(() => { + logger("ONLINESTREAM").info("extensions", providerExtensions) + + if (!providerExtensions) return + + if (provider === null || !providerExtensions.find(p => p.id === provider)) { + if (providerExtensions.length > 0) { + setProvider(providerExtensions[0].id) + } else { + setProvider(null) + } + } + }, [providerExtensions]) + + return { + providerExtensions: providerExtensions ?? [], + providerExtensionOptions: (providerExtensions ?? []).map(provider => ({ + label: provider.name, + value: provider.id, + })).sort((a, b) => a.label.localeCompare(b.label)), + } + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/handle-onlinestream.ts b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/handle-onlinestream.ts new file mode 100644 index 0000000..0a1ebcb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/handle-onlinestream.ts @@ -0,0 +1,536 @@ +import { getServerBaseUrl } from "@/api/client/server-url" +import { ExtensionRepo_OnlinestreamProviderExtensionItem, Onlinestream_EpisodeSource } from "@/api/generated/types" +import { useHandleCurrentMediaContinuity } from "@/api/hooks/continuity.hooks" +import { useGetOnlineStreamEpisodeList, useGetOnlineStreamEpisodeSource } from "@/api/hooks/onlinestream.hooks" +import { useNakamaStatus } from "@/app/(main)/_features/nakama/nakama-manager" +import { useWebsocketSender } from "@/app/(main)/_hooks/handle-websockets" +import { useHandleOnlinestreamProviderExtensions } from "@/app/(main)/onlinestream/_lib/handle-onlinestream-providers" +import { + __onlinestream_qualityAtom, + __onlinestream_selectedDubbedAtom, + __onlinestream_selectedEpisodeNumberAtom, + __onlinestream_selectedProviderAtom, + __onlinestream_selectedServerAtom, +} from "@/app/(main)/onlinestream/_lib/onlinestream.atoms" +import { logger } from "@/lib/helpers/debug" +import { MediaPlayerInstance } from "@vidstack/react" +import { atom } from "jotai" +import { useAtom, useAtomValue, useSetAtom } from "jotai/react" +import { uniq } from "lodash" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import React from "react" +import { useUpdateEffect } from "react-use" +import { toast } from "sonner" + +export function useOnlinestreamEpisodeList(mId: string | null) { + const router = useRouter() + const provider = useAtomValue(__onlinestream_selectedProviderAtom) + const dubbed = useAtomValue(__onlinestream_selectedDubbedAtom) + + const { data, isLoading, isFetching, isSuccess, isError } = useGetOnlineStreamEpisodeList(mId, provider, dubbed) + + React.useEffect(() => { + if (isError) { + router.push("/") + } + }, [isError]) + + return { + media: data?.media, + episodes: data?.episodes, + isLoading, + isFetching, + isSuccess, + isError, + } +} + + +export function useOnlinestreamEpisodeSource(extensions: ExtensionRepo_OnlinestreamProviderExtensionItem[], mId: string | null, isSuccess: boolean) { + + const provider = useAtomValue(__onlinestream_selectedProviderAtom) + const episodeNumber = useAtomValue(__onlinestream_selectedEpisodeNumberAtom) + const dubbed = useAtomValue(__onlinestream_selectedDubbedAtom) + + const extension = React.useMemo(() => extensions.find(p => p.id === provider), [extensions, provider]) + + const { data, isLoading, isFetching, isError, error } = useGetOnlineStreamEpisodeSource( + mId, + provider, + episodeNumber, + (!!extension?.supportsDub) && dubbed, + !!mId && episodeNumber !== undefined && isSuccess, + ) + + return { + episodeSource: data, + isLoading, + isFetching, + isError, + error, + } +} + + +export function useOnlinestreamVideoSource(episodeSource: Onlinestream_EpisodeSource | undefined) { + + const quality = useAtomValue(__onlinestream_qualityAtom) + const selectedServer = useAtomValue(__onlinestream_selectedServerAtom) + + const videoSource = React.useMemo(() => { + if (!episodeSource || !episodeSource.videoSources) return undefined + + let videoSources = episodeSource.videoSources + + logger("ONLINESTREAM").info("Stored quality", quality) + logger("ONLINESTREAM").info("Selected server", selectedServer) + + if (selectedServer && videoSources.some(n => n.server === selectedServer)) { + videoSources = videoSources.filter(s => s.server === selectedServer) + } + + const hasQuality = videoSources.some(n => n.quality === quality) + const hasAuto = videoSources.some(n => n.quality === "auto") + + logger("ONLINESTREAM").info("Selecting quality", { + hasAuto, + hasQuality, + }) + + // If quality is set, filter sources by quality + // Only filter by quality if the quality is present in the sources + if (quality && hasQuality) { + videoSources = videoSources.filter(s => s.quality === quality) + } else if (hasAuto) { + videoSources = videoSources.filter(s => s.quality === "auto") + } else { + + logger("ONLINESTREAM").info("Choosing a quality") + + if (videoSources.some(n => n.quality.includes("1080p"))) { + videoSources = videoSources.filter(s => s.quality.includes("1080p")) + } else if (videoSources.some(n => n.quality.includes("720p"))) { + videoSources = videoSources.filter(s => s.quality.includes("720p")) + } else if (videoSources.some(n => n.quality.includes("480p"))) { + videoSources = videoSources.filter(s => s.quality.includes("480p")) + } else if (videoSources.some(n => n.quality.includes("360p"))) { + videoSources = videoSources.filter(s => s.quality.includes("360p")) + } + + if (videoSources.some(n => n.quality.includes("default"))) { + videoSources = videoSources.filter(s => s.quality.includes("default")) + } + } + + + logger("ONLINESTREAM").info("videoSources", videoSources) + + return videoSources[0] + }, [episodeSource, selectedServer, quality]) + + return { + videoSource, + } +} + + +type HandleOnlinestreamProps = { + mediaId: string | null + ref: React.RefObject<MediaPlayerInstance> +} + +export function useHandleOnlinestream(props: HandleOnlinestreamProps) { + const { mediaId, ref: playerRef } = props + + const { providerExtensions, providerExtensionOptions } = useHandleOnlinestreamProviderExtensions() + + // Nakama Watch Party + const nakamaStatus = useNakamaStatus() + const { streamToLoad, onLoadedStream, hostNotifyStreamStarted } = useNakamaOnlineStreamWatchParty() + + /** + * 1. Get the list of episodes + */ + const { episodes, media, isFetching, isLoading, isSuccess, isError } = useOnlinestreamEpisodeList(mediaId) + + /** + * 2. Watch history + */ + const { waitForWatchHistory } = useHandleCurrentMediaContinuity(mediaId) + + /** + * 3. Get the current episode source + */ + const { + episodeSource, + isLoading: isLoadingEpisodeSource, + isFetching: isFetchingEpisodeSource, + isError: isErrorEpisodeSource, + error: errorEpisodeSource, + } = useOnlinestreamEpisodeSource(providerExtensions, mediaId, (isSuccess && !waitForWatchHistory)) + + /** + * Variables used for episode source query + */ + const setEpisodeNumber = useSetAtom(__onlinestream_selectedEpisodeNumberAtom) + const setServer = useSetAtom(__onlinestream_selectedServerAtom) + const setQuality = useSetAtom(__onlinestream_qualityAtom) + const [dubbed, setDubbed] = useAtom(__onlinestream_selectedDubbedAtom) + const [provider, setProvider] = useAtom(__onlinestream_selectedProviderAtom) + + const [url, setUrl] = React.useState<string | undefined>(undefined) + + // Refs + const currentProviderRef = React.useRef<string | null>(null) + const previousCurrentTimeRef = React.useRef(0) + const previousIsPlayingRef = React.useRef(false) + + // Get current episode details when [episodes] or [episodeSource] changes + const episodeDetails = React.useMemo(() => { + return episodes?.find((episode) => episode.number === episodeSource?.number) + }, [episodes, episodeSource]) + + // Get the list of servers + const servers = React.useMemo(() => { + if (!episodeSource) { + logger("ONLINESTREAM").info("Updating servers, no episode source", []) + return [] + } + const servers = episodeSource.videoSources?.map((source) => source.server) + logger("ONLINESTREAM").info("Updating servers", servers) + return uniq(servers) + }, [episodeSource]) + + /** + * Keep episodeSource number in sync with the episode number + */ + // React.useEffect(() => { + // logger("ONLINESTREAM").info("Episode source has changed", { episodeSource }) + // if (episodeSource) { + // setEpisodeNumber(episodeSource.number) + // } + // }, [episodeSource]) + + /** + * 2. Get the current video source + * This handles selecting the best source + */ + const { videoSource } = useOnlinestreamVideoSource(episodeSource) + + /** + * 3. Change the stream URL when the video source changes + */ + React.useEffect(() => { + logger("ONLINESTREAM").info("Changing stream URL using videoSource", { videoSource }) + setUrl(undefined) + logger("ONLINESTREAM").info("Setting stream URL to undefined") + if (videoSource?.url) { + setServer(videoSource.server) + let _url = videoSource.url + if (videoSource.headers && Object.keys(videoSource.headers).length > 0) { + _url = `${getServerBaseUrl()}/api/v1/proxy?url=${encodeURIComponent(videoSource?.url)}&headers=${encodeURIComponent(JSON.stringify( + videoSource?.headers))}` + } else { + _url = videoSource.url + } + React.startTransition(() => { + logger("ONLINESTREAM").info("Setting stream URL", { url: _url }) + setUrl(_url) + }) + } + }, [videoSource?.url]) + + // When the provider changes, set the currentProviderRef + React.useEffect(() => { + logger("ONLINESTREAM").info("Provider changed", { provider }) + currentProviderRef.current = provider + }, [provider]) + + React.useEffect(() => { + logger("ONLINESTREAM").info("URL changed", { url }) + }, [url]) + + useUpdateEffect(() => { + if (!streamToLoad) return + + logger("ONLINESTREAM").info("Stream to load", { streamToLoad }) + + // Check if we have the provider + if (!providerExtensionOptions.some(p => p.value === streamToLoad.provider)) { + logger("ONLINESTREAM").warning("Provider not found in options", { providerExtensionOptions, provider: streamToLoad.provider }) + toast.error("Watch Party: The provider used by the host is not installed.") + return + } + + setProvider(streamToLoad.provider) + setDubbed(streamToLoad.dubbed) + setEpisodeNumber(streamToLoad.episodeNumber) + setServer(streamToLoad.server) + setQuality(streamToLoad.quality) + + onLoadedStream() + }, [streamToLoad]) + + + ////////////////////////////////////////////////////////////// + // Video player + ////////////////////////////////////////////////////////////// + + // Store the errored servers, so we can switch to the next server + const [erroredServers, setErroredServers] = React.useState<string[]>([]) + // Clear errored servers when the episode details change + React.useEffect(() => { + setErroredServers([]) + }, [episodeDetails]) + // When the media is detached + const onMediaDetached = React.useCallback(() => { + logger("ONLINESTREAM").warning("onMediaDetached") + }, []) + + /** + * Handle fatal errors + * This function is called when the player encounters a fatal error + * - Change the server if the server is errored + * - Change the provider if all servers are errored + */ + const onFatalError = () => { + logger("ONLINESTREAM").error("onFatalError", { + sameProvider: provider == currentProviderRef.current, + }) + if (provider == currentProviderRef.current) { + setUrl(undefined) + logger("ONLINESTREAM").error("Setting stream URL to undefined") + toast.warning("Playback error, trying another server...") + logger("ONLINESTREAM").error("Player encountered a fatal error") + setTimeout(() => { + logger("ONLINESTREAM").error("erroredServers", erroredServers) + if (videoSource?.server) { + const otherServers = servers.filter((server) => server !== videoSource?.server && !erroredServers.includes(server)) + if (otherServers.length > 0) { + setErroredServers((prev) => [...prev, videoSource?.server]) + setServer(otherServers[0]) + } else { + setProvider((prev) => providerExtensionOptions.find((p) => p.value !== prev)?.value ?? null) + } + } + }, 500) + } + } + + /** + * Handle the onCanPlay event + */ + const onCanPlay = () => { + logger("ONLINESTREAM").info("Can play event", { + previousCurrentTime: previousCurrentTimeRef.current, + previousIsPlayingRef: previousIsPlayingRef.current, + }) + + // Handle Nakama Watch Party + // Send the playback info to the server + if (nakamaStatus?.isHost && nakamaStatus.currentWatchPartySession && !nakamaStatus?.currentWatchPartySession.isRelayMode) { + const params = { + mediaId: media?.id ?? 0, + provider: currentProviderRef.current ?? "", + episodeNumber: episodeSource?.number ?? 0, + server: videoSource?.server ?? "", + quality: videoSource?.quality ?? "", + dubbed: dubbed, + } + logger("ONLINESTREAM").info("Watch Party: Notifying peers that stream has started", params) + hostNotifyStreamStarted(params) + } + + // When the onCanPlay event is received + // Restore the previous time if set + if (previousCurrentTimeRef.current > 0) { + // Seek to the previous time + Object.assign(playerRef.current ?? {}, { currentTime: previousCurrentTimeRef.current }) + // Reset the previous time ref + previousCurrentTimeRef.current = 0 + logger("ONLINESTREAM").info("Seeking to previous time", { previousCurrentTime: previousCurrentTimeRef.current }) + } + + // If the player was playing before the onCanPlay event, resume playing + setTimeout(() => { + if (previousIsPlayingRef.current) { + try { + playerRef.current?.play() + } + catch { + } + logger("ONLINESTREAM").info("Resuming playback since past video was playing before the onCanPlay event") + } + }, 500) + } + + + // Quality + const [hasCustomQualities, customQualities] = React.useMemo(() => { + return [ + !!episodeSource?.videoSources?.map(n => n.quality)?.filter(q => q.includes("p"))?.length, + uniq(episodeSource?.videoSources?.map(n => n.quality)), + ] + }, [episodeSource]) + + const changeQuality = React.useCallback((quality: string) => { + try { + previousCurrentTimeRef.current = playerRef.current?.currentTime ?? 0 + previousIsPlayingRef.current = playerRef.current?.paused === false + logger("ONLINESTREAM").info("Changing quality", { quality }) + } + catch { + } + setQuality(quality) + }, [videoSource]) + + // Provider + const changeProvider = React.useCallback((provider: string) => { + try { + previousCurrentTimeRef.current = playerRef.current?.currentTime ?? 0 + previousIsPlayingRef.current = playerRef.current?.paused === false + logger("ONLINESTREAM").info("Changing provider", { provider }) + } + catch { + } + setProvider(provider) + }, [videoSource]) + + // Server + const changeServer = React.useCallback((server: string) => { + try { + previousCurrentTimeRef.current = playerRef.current?.currentTime ?? 0 + previousIsPlayingRef.current = playerRef.current?.paused === false + logger("ONLINESTREAM").info("Changing server", { server }) + } + catch { + } + setServer(server) + }, [videoSource]) + + + // Dubbed + const toggleDubbed = React.useCallback(() => { + try { + previousCurrentTimeRef.current = playerRef.current?.currentTime ?? 0 + previousIsPlayingRef.current = playerRef.current?.paused === false + logger("ONLINESTREAM").info("Toggling dubbed") + } + catch { + } + setDubbed((prev) => !prev) + }, [videoSource]) + + // Episode + const handleChangeEpisodeNumber = (epNumber: number) => { + setEpisodeNumber(_ => { + return epNumber + }) + } + + const selectedExtension = React.useMemo(() => providerExtensions.find(p => p.id === provider), [providerExtensions, provider]) + + return { + currentEpisodeDetails: episodeDetails, + provider, + servers, + videoSource, + onMediaDetached, + onFatalError, + onCanPlay, + url, + episodes, + media: media!, + episodeSource, + loadPage: !isFetching && !isLoading, + currentEpisodeNumber: episodeSource?.number ?? 0, + handleChangeEpisodeNumber, + episodeLoading: isLoadingEpisodeSource || isFetchingEpisodeSource, + isErrorEpisodeSource, + errorEpisodeSource, + isErrorProvider: isError, + opts: { + selectedExtension, + currentEpisodeDetails: episodeDetails, + providerExtensions, + providerExtensionOptions, + servers, + videoSource, + customQualities, + hasCustomQualities, + changeQuality, + changeProvider, + changeServer, + toggleDubbed, + }, + } + +} + +export type OnlinestreamManagerOpts = ReturnType<typeof useHandleOnlinestream> + + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export type OnlineStreamParams = { + mediaId: number + provider: string + episodeNumber: number + server: string + quality: string + dubbed: boolean +} + +const __onlinestream_streamToLoadAtom = atom<OnlineStreamParams | null>(null) + +export function useNakamaOnlineStreamWatchParty() { + const [streamToLoad, setStreamToLoad] = useAtom(__onlinestream_streamToLoadAtom) + const pathname = usePathname() + const searchParams = useSearchParams() + const router = useRouter() + const nakamaStatus = useNakamaStatus() + + const { sendMessage } = useWebsocketSender() + + + return { + // Host + hostNotifyStreamStarted: (params: OnlineStreamParams) => { + if (!nakamaStatus?.isHost) { + logger("ONLINESTREAM").warning("hostNotifyStreamStarted called, but not a host") + return + } + sendMessage({ + type: "nakama", + payload: { + type: "online-stream-started", + payload: { + mediaId: params.mediaId, + provider: params.provider, + episodeNumber: params.episodeNumber, + server: params.server, + quality: params.quality, + dubbed: params.dubbed, + }, + }, + }) + }, + // Peers + streamToLoad, + onLoadedStream: () => { + setStreamToLoad(null) + }, + startOnlineStream(params: OnlineStreamParams) { + if (nakamaStatus?.isHost) { + logger("ONLINESTREAM").info("Start online stream event sent to peers", params) + return + } + logger("ONLINESTREAM").info("Starting online stream", params) + setStreamToLoad(params) + if (pathname !== "/entry" || searchParams.get("id") !== String(params.mediaId)) { + // Navigate to the onlinestream page + router.push("/entry?id=" + params.mediaId + "&tab=onlinestream&provider=" + params.provider + "&episodeNumber=" + params.episodeNumber + "&server=" + params.server + "&quality=" + params.quality + "&dubbed=" + params.dubbed) + } + }, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/onlinestream-manager.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/onlinestream-manager.tsx new file mode 100644 index 0000000..9b9a0b3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/onlinestream-manager.tsx @@ -0,0 +1,19 @@ +import { OnlinestreamManagerOpts } from "@/app/(main)/onlinestream/_lib/handle-onlinestream" +import React from "react" + +//@ts-ignore +const __OnlinestreamManagerContext = React.createContext<OnlinestreamManagerOpts["opts"]>({}) + +export function useOnlinestreamManagerContext() { + return React.useContext(__OnlinestreamManagerContext) +} + +export function OnlinestreamManagerProvider(props: { children?: React.ReactNode, opts: OnlinestreamManagerOpts["opts"] }) { + return ( + <__OnlinestreamManagerContext.Provider + value={props.opts} + > + {props.children} + </__OnlinestreamManagerContext.Provider> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/onlinestream.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/onlinestream.atoms.ts new file mode 100644 index 0000000..7432751 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/onlinestream.atoms.ts @@ -0,0 +1,13 @@ +import { atom } from "jotai/index" +import { atomWithStorage } from "jotai/utils" + +export const __onlinestream_selectedProviderAtom = atomWithStorage<string | null>("sea-onlinestream-provider", null) + +export const __onlinestream_selectedDubbedAtom = atom<boolean>(false) + +// Variable used for the episode source query +export const __onlinestream_selectedEpisodeNumberAtom = atom<number | undefined>(undefined) + +export const __onlinestream_selectedServerAtom = atomWithStorage<string | undefined>("sea-onlinestream-server", undefined) + +export const __onlinestream_qualityAtom = atomWithStorage<string | undefined>("sea-onlinestream-quality", undefined) diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/onlinestream.enums.ts b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/onlinestream.enums.ts new file mode 100644 index 0000000..f5a15a1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/_lib/onlinestream.enums.ts @@ -0,0 +1,4 @@ +export const enum OnlinestreamProvider { + GOGOANIME = "gogoanime", + ZORO = "zoro", +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/layout.tsx new file mode 100644 index 0000000..801e5ef --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/layout.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useRouter } from "next/navigation" +import React from "react" + +export default function Layout({ children }: { children?: React.ReactNode }) { + + const router = useRouter() + const status = useServerStatus() + + React.useEffect(() => { + if (!status?.settings?.library?.enableOnlinestream) { + router.replace("/") + } + }, [status?.settings?.library?.enableOnlinestream]) + + if (!status?.settings?.library?.enableOnlinestream) return null + + return <> + {children} + </> +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/page.tsx new file mode 100644 index 0000000..ac59e0a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/onlinestream/page.tsx @@ -0,0 +1,43 @@ +"use client" + +import { useGetAnimeEntry } from "@/api/hooks/anime_entries.hooks" +import { MediaEntryPageSmallBanner } from "@/app/(main)/_features/media/_components/media-entry-page-small-banner" +import { OnlinestreamPage } from "@/app/(main)/onlinestream/_containers/onlinestream-page" +import { Skeleton } from "@/components/ui/skeleton" +import { useSearchParams } from "next/navigation" +import React from "react" + + +export const dynamic = "force-static" + +export default function Page() { + const searchParams = useSearchParams() + const mediaId = searchParams.get("id") + const { data: animeEntry, isLoading: animeEntryLoading } = useGetAnimeEntry(mediaId) + + if (!animeEntry || animeEntryLoading) return <div data-onlinestream-page-loading-container className="px-4 lg:px-8 space-y-4"> + <div className="flex gap-4 items-center relative"> + <Skeleton className="h-12" /> + </div> + <div + className="grid 2xl:grid-cols-[1fr,450px] gap-4 xl:gap-4" + > + <div className="w-full min-h-[70dvh] relative"> + <Skeleton className="h-full w-full absolute" /> + </div> + + <Skeleton className="hidden 2xl:block relative h-[78dvh] overflow-y-auto pr-4 pt-0" /> + + </div> + </div> + + return ( + <> + <div data-onlinestream-page-container className="relative p-4 lg:p-8 z-[5] space-y-4"> + <OnlinestreamPage animeEntry={animeEntry} animeEntryLoading={animeEntryLoading} /> + </div> + <MediaEntryPageSmallBanner bannerImage={animeEntry?.media?.bannerImage || animeEntry?.media?.coverImage?.extraLarge} /> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/qbittorrent/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/qbittorrent/page.tsx new file mode 100644 index 0000000..1dd5bff --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/qbittorrent/page.tsx @@ -0,0 +1,27 @@ +"use client" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" + +export const dynamic = "force-static" + +export default function Page() { + + const status = useServerStatus() + const settings = status?.settings + + if (!settings) return null + + return ( + <> + <div + className="w-[80%] h-[calc(100vh-15rem)] rounded-xl border overflow-hidden mx-auto mt-10 ring-1 ring-[--border] ring-offset-2" + > + <iframe + src={`http://${settings.torrent?.qbittorrentHost}:${String(settings.torrent?.qbittorrentPort)}`} + className="w-full h-full" + sandbox="allow-forms allow-fullscreen allow-same-origin allow-scripts allow-popups" + referrerPolicy="no-referrer" + /> + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/scan-summaries/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/scan-summaries/layout.tsx new file mode 100644 index 0000000..995ee96 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/scan-summaries/layout.tsx @@ -0,0 +1,16 @@ +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) + +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/scan-summaries/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/scan-summaries/page.tsx new file mode 100644 index 0000000..983530e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/scan-summaries/page.tsx @@ -0,0 +1,419 @@ +"use client" +import { Anime_LocalFile, Summary_ScanSummaryFile, Summary_ScanSummaryLog } from "@/api/generated/types" +import { useGetScanSummaries } from "@/api/hooks/scan_summary.hooks" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { SeaLink } from "@/components/shared/sea-link" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Select } from "@/components/ui/select" +import { TextInput } from "@/components/ui/text-input" +import { useDebounce } from "@/hooks/use-debounce" +import { formatDateAndTimeShort } from "@/lib/server/utils" +import Image from "next/image" +import React from "react" +import { AiFillWarning } from "react-icons/ai" +import { BiCheckCircle, BiInfoCircle, BiXCircle } from "react-icons/bi" +import { BsFileEarmarkExcelFill, BsFileEarmarkPlayFill } from "react-icons/bs" +import { LuFileSearch, LuTextSelect } from "react-icons/lu" +import { TbListSearch } from "react-icons/tb" + +export const dynamic = "force-static" + +export default function Page() { + + const [selectedSummaryId, setSelectedSummaryId] = React.useState<string | undefined | null>(undefined) + const [searchQuery, setSearchQuery] = React.useState("") + const debouncedSearchQuery = useDebounce(searchQuery, 300) + const [expandedAccordions, setExpandedAccordions] = React.useState<Set<string>>(new Set()) + + const { data, isLoading } = useGetScanSummaries() + + React.useEffect(() => { + if (!!data?.length) { + setSelectedSummaryId(data[data.length - 1]?.scanSummary?.id) + } + }, [data]) + + const selectedSummary = React.useMemo(() => { + const summary = data?.find(summary => summary.scanSummary?.id === selectedSummaryId) + if (!summary || !summary?.createdAt || !summary?.scanSummary?.id) return undefined + return { + createdAt: summary?.createdAt, + ...summary.scanSummary, + } + }, [selectedSummaryId, data]) + + // Filter unmatched files based on search query + const filteredUnmatchedFiles = React.useMemo(() => { + if (!selectedSummary?.unmatchedFiles || !debouncedSearchQuery.trim()) { + return selectedSummary?.unmatchedFiles || [] + } + return selectedSummary.unmatchedFiles.filter(file => + file.localFile?.path?.toLowerCase().includes(debouncedSearchQuery.toLowerCase()), + ) + }, [selectedSummary?.unmatchedFiles, debouncedSearchQuery]) + + // Filter media groups and their files based on search query + const filteredGroups = React.useMemo(() => { + if (!selectedSummary?.groups || !debouncedSearchQuery.trim()) { + return selectedSummary?.groups || [] + } + return selectedSummary.groups.map(group => { + const filteredFiles = group.files?.filter(file => + file.localFile?.path?.toLowerCase().includes(debouncedSearchQuery.toLowerCase()), + ) || [] + return { ...group, files: filteredFiles } + }).filter(group => group.files.length > 0) + }, [selectedSummary?.groups, debouncedSearchQuery]) + + // Auto-expand accordions that contain search matches + React.useEffect(() => { + if (debouncedSearchQuery.trim()) { + const newExpandedAccordions = new Set<string>() + + // expand unmatched files accordion if there are matches + if (filteredUnmatchedFiles.length > 0) { + filteredUnmatchedFiles.forEach(file => { + if (file.localFile?.path) { + newExpandedAccordions.add(file.localFile.path) + } + }) + } + + // expand media group accordions if there are matches + filteredGroups.forEach(group => { + if ((group.files?.length ?? 0) > 0) { + newExpandedAccordions.add("i1") + group.files?.forEach(file => { + if (file.localFile?.path) { + newExpandedAccordions.add(file.localFile.path) + } + }) + } + }) + + setExpandedAccordions(newExpandedAccordions) + } else { + setExpandedAccordions(new Set()) + } + }, [debouncedSearchQuery, filteredUnmatchedFiles, filteredGroups]) + + return ( + <> + <CustomLibraryBanner discrete /> + <PageWrapper + className="p-4 sm:p-8 space-y-4" + > + <div className="flex justify-between items-center w-full relative"> + <div className="space-y-4"> + <div> + <h2>Scan summaries</h2> + <p className="text-[--muted]"> + View the logs and details of your latest scans + </p> + </div> + </div> + </div> + + <div className=""> + {isLoading && <LoadingSpinner />} + {(!isLoading && !data?.length) && <div className="p-4 text-[--muted] text-center">No scan summaries available</div>} + {!!data?.length && ( + <div className="space-y-4"> + <Select + value={selectedSummaryId || "-"} + options={data?.filter(n => !!n.scanSummary) + .map((summary) => ({ label: formatDateAndTimeShort(summary.createdAt!), value: summary.scanSummary!.id || "-" })) + .toReversed()} + onValueChange={v => setSelectedSummaryId(v)} + /> + {!!selectedSummary && ( + <div className="w-full lg:max-w-[50%]"> + <TextInput + placeholder="Search filenames..." + value={searchQuery} + onValueChange={setSearchQuery} + leftIcon={<LuFileSearch className="text-[--muted]" />} + /> + </div> + )} + {!!selectedSummary && ( + <div className="space-y-4 rounded-[--radius] "> + <div> + <p className="text-[--muted]"> + Seanime successfully scanned {selectedSummary.groups?.length} media + {debouncedSearchQuery.trim() && ( + <span className="ml-2 text-sm">({filteredGroups.length} matching)</span> + )} + </p> + {!!selectedSummary?.unmatchedFiles?.length && ( + <p className="text-orange-300"> + {selectedSummary?.unmatchedFiles?.length} file{selectedSummary?.unmatchedFiles?.length > 1 + ? "s were " + : " was "}not matched + {debouncedSearchQuery.trim() && ( + <span className="ml-2 text-sm">({filteredUnmatchedFiles.length} matching)</span> + )} + </p> + )} + </div> + + {!!filteredUnmatchedFiles?.length && <div className="space-y-2"> + <h5>Unmatched files</h5> + <Accordion type="single" collapsible> + <div className="grid grid-cols-1 gap-4"> + {filteredUnmatchedFiles?.map(file => ( + <ScanSummaryGroupItem + file={file} + key={file.id} + searchQuery={debouncedSearchQuery} + isExpanded={expandedAccordions.has(file.localFile?.path || "")} + /> + ))} + </div> + </Accordion> + </div>} + + {!!filteredGroups?.length && <div> + <h5>Media scanned</h5> + + <div className="space-y-4 divide-y"> + {filteredGroups?.sort((a, b) => a.mediaTitle?.localeCompare(b.mediaTitle, + undefined, + { numeric: true })).map(group => !!group?.files?.length ? ( + <div className="space-y-4 pt-4" key={group.id}> + <div className="flex gap-2"> + + <div + className="w-[5rem] h-[5rem] rounded-[--radius] flex-none object-cover object-center overflow-hidden relative" + > + <Image + src={group.mediaImage} + alt="banner" + fill + quality={80} + priority + sizes="20rem" + className="object-cover object-center" + /> + </div> + + <div className="space-y-1"> + <SeaLink + href={`/entry?id=${group.mediaId}`} + className="font-medium tracking-wide" + >{group.mediaTitle}</SeaLink> + <p className="flex gap-1 items-center text-sm text-[--muted]"> + <span className="text-lg">{group.mediaIsInCollection ? + <BiCheckCircle className="text-green-200" /> : + <BiXCircle className="text-red-300" />}</span> Anime {group.mediaIsInCollection + ? "is present" + : "is not present"} in your AniList collection</p> + <p className="text-sm flex gap-1 items-center text-[--muted]"> + <span className="text-base"><LuFileSearch className="text-brand-200" /></span>{group.files.length} file{group.files.length > 1 && "s"} scanned + </p> + </div> + + </div> + + {group.files.flatMap(n => n.logs).some(n => n?.level === "error") && + <p className="text-sm flex gap-1 text-red-300 items-center text-[--muted]"> + <span className="text-base"><BiXCircle className="" /></span> Errors found + </p>} + {group.files.flatMap(n => n.logs).some(n => n?.level === "warning") && + <p className="text-sm flex gap-1 text-orange-300 items-center text-[--muted]"> + <span className="text-base"><AiFillWarning className="" /></span> Warnings found + </p>} + + <div> + + + <Accordion type="single" collapsible value={expandedAccordions.has("i1") ? "i1" : undefined}> + <AccordionItem value="i1"> + <AccordionTrigger className="p-0 dark:hover:bg-transparent text-[--muted] dark:hover:text-white"> + <span className="inline-flex text-base items-center gap-2"><LuTextSelect /> View + scanner + logs</span> + </AccordionTrigger> + <AccordionContent className="p-0 bg-[--paper] border mt-4 rounded-[--radius] overflow-hidden relative"> + <Accordion type="single" collapsible> + <div className="grid grid-cols-1"> + {group.files.map(file => ( + <ScanSummaryGroupItem + file={file} + key={file.id} + searchQuery={debouncedSearchQuery} + isExpanded={expandedAccordions.has(file.localFile?.path || "")} + /> + ))} + </div> + </Accordion> + </AccordionContent> + </AccordionItem> + </Accordion> + </div> + </div> + ) : null)} + </div> + </div>} + </div> + )} + </div> + )} + </div> + </PageWrapper> + </> + ) + +} + +type ScanSummaryFileItem = { + file: Summary_ScanSummaryFile + searchQuery?: string + isExpanded?: boolean +} + +function ScanSummaryGroupItem(props: ScanSummaryFileItem) { + const { file, searchQuery, isExpanded } = props + + const hasErrors = file.logs?.some(log => log.level === "error") + const hasWarnings = file.logs?.some(log => log.level === "warning") + + if (!file.localFile || !file.logs) return null + + return ( + <AccordionItem value={file.localFile.path} className="bg-gray-950 overflow-x-auto"> + <AccordionTrigger + className="w-full max-w-full py-2.5" + > + <div className="space-y-1 line-clamp-1 max-w-full w-full tracking-wide text-sm"> + <p + className={cn( + "text-left font-normal text-gray-200 text-sm line-clamp-1 w-full flex items-center gap-2", + hasErrors && "text-red-300", + hasWarnings && "text-orange-300", + )} + > + <span> + {hasErrors ? <BsFileEarmarkExcelFill /> : + hasWarnings ? <BsFileEarmarkPlayFill /> : + <BsFileEarmarkPlayFill />} + </span> + {searchQuery ? ( + <HighlightedText text={file.localFile.name} searchQuery={searchQuery} /> + ) : ( + file.localFile.name + )}</p> + </div> + </AccordionTrigger> + <AccordionContent className="space-y-2 overflow-x-auto"> + <p className="text-sm text-left text-[--muted] italic line-clamp-1 max-w-full"> + {searchQuery ? ( + <HighlightedText text={file.localFile.path} searchQuery={searchQuery} /> + ) : ( + file.localFile.path + )} + </p> + <ScanSummaryFileParsedData localFile={file.localFile} /> + {file.logs.map(log => ( + <ScanSummaryLog key={log.id} log={log} /> + ))} + </AccordionContent> + </AccordionItem> + ) + +} + +function ScanSummaryFileParsedData(props: { localFile: Anime_LocalFile }) { + const { localFile } = props + + const folderTitles = localFile.parsedFolderInfo?.map(i => i.title).filter(Boolean).map(n => `"${n}"`).join(", ") + const folderSeasons = localFile.parsedFolderInfo?.map(i => i.season).filter(Boolean).map(n => `"${n}"`).join(", ") + const folderParts = localFile.parsedFolderInfo?.map(i => i.part).filter(Boolean).map(n => `"${n}"`).join(", ") + + return ( + <div className="flex-none"> + <div className="flex justify-between gap-2 items-center"> + <div className="flex gap-1 items-center"> + <ul className="text-sm space-y-1 [&>li]:flex-none [&>li]:gap-1 [&>li]:line-clamp-1 [&>li]:flex [&>li]:items-center [&>li>span]:text-[--muted] [&>li>span]:uppercase"> + <li><TbListSearch className="text-indigo-200" /> + <span>Title</span> "{localFile.parsedInfo?.title}"{!!folderTitles?.length && `, ${folderTitles}`}</li> + <li><TbListSearch className="text-indigo-200" /> <span>Episode</span> "{localFile.parsedInfo?.episode || ""}"</li> + <li><TbListSearch className="text-indigo-200" /> + <span>Season</span> "{localFile.parsedInfo?.season || ""}"{!!folderSeasons?.length && `, ${folderSeasons}`}</li> + <li><TbListSearch className="text-indigo-200" /> + <span>Part</span> "{localFile.parsedInfo?.part || ""}"{!!folderParts?.length && `, ${folderParts}`}</li> + <li><TbListSearch className="text-indigo-200" /> <span>Episode Title</span> "{localFile.parsedInfo?.episodeTitle || ""}"</li> + </ul> + </div> + </div> + </div> + ) +} + +function ScanSummaryLog(props: { log: Summary_ScanSummaryLog }) { + const { log } = props + + return ( + <div className=""> + <div className="flex justify-between gap-2 items-center w-full"> + <div className="flex gap-1 items-center w-full"> + <div> + {log.level === "info" && <BiInfoCircle className="text-blue-300" />} + {log.level === "error" && <BiXCircle className="text-red-300" />} + {log.level === "warning" && <BiInfoCircle className="text-orange-300" />} + </div> + <ScanSummaryLogMessage message={log.message} level={log.level} /> + </div> + </div> + </div> + ) +} + +function ScanSummaryLogMessage(props: { message: string, level: string }) { + const { message, level } = props + + if (!message.startsWith("PANIC")) { + return <div + className={cn( + "text-[--muted] hover:text-white text-sm tracking-wide flex-none", + level === "error" && "text-red-300", + level === "warning" && "text-orange-300", + )} + >{message}</div> + } + + return ( + <div className="w-full text-sm"> + <p className="text-red-300 text-sm font-bold">Please report this issue on the GitHub repository</p> + <pre className="p-4"> + {message} + </pre> + </div> + ) +} + +function HighlightedText({ text, searchQuery }: { text: string, searchQuery: string }) { + if (!searchQuery.trim() || !text) { + return <>{text}</> + } + + const regex = new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi") + const parts = text.split(regex) + + return ( + <span> + {parts.map((part, index) => + regex.test(part) ? ( + <span key={index} className="bg-yellow-400 text-black px-0 rounded-sm"> + {part} + </span> + ) : ( + part + ), + )} + </span> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_components/missing-episodes.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_components/missing-episodes.tsx new file mode 100644 index 0000000..d381ee4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_components/missing-episodes.tsx @@ -0,0 +1,144 @@ +"use client" +import { Anime_MissingEpisodes } from "@/api/generated/types" +import { EpisodeCard } from "@/app/(main)/_features/anime/_components/episode-card" +import { useHasTorrentProvider } from "@/app/(main)/_hooks/use-server-status" +import { useHandleMissingEpisodes } from "@/app/(main)/schedule/_lib/handle-missing-episodes" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel" +import { useRouter } from "next/navigation" +import React from "react" +import { AiOutlineDownload } from "react-icons/ai" +import { IoLibrary } from "react-icons/io5" +import { LuBellOff } from "react-icons/lu" + +export function MissingEpisodes({ isLoading, data }: { + data: Anime_MissingEpisodes | undefined + isLoading: boolean +}) { + const router = useRouter() + + const { missingEpisodes, silencedEpisodes } = useHandleMissingEpisodes(data) + const { hasTorrentProvider } = useHasTorrentProvider() + + if (!missingEpisodes?.length && !silencedEpisodes?.length) return null + + return ( + <> + <AppLayoutStack spacing="lg"> + + {!!missingEpisodes?.length && ( + <> + <h2 className="flex gap-3 items-center"><IoLibrary /> Missing from your library</h2> + + <Carousel + className="w-full max-w-full" + gap="md" + opts={{ + align: "start", + }} + autoScroll + > + <CarouselDotButtons /> + <CarouselContent> + {!isLoading && missingEpisodes?.map(episode => { + return <CarouselItem + key={episode?.baseAnime?.id + episode.displayTitle} + className="md:basis-1/2 lg:basis-1/3 2xl:basis-1/4 min-[2000px]:basis-1/5" + > + <EpisodeCard + key={episode.displayTitle + episode.baseAnime?.id} + episode={episode} + image={episode.episodeMetadata?.image || episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge} + topTitle={episode.baseAnime?.title?.userPreferred} + title={episode.displayTitle} + meta={episode.episodeMetadata?.airDate ?? undefined} + actionIcon={hasTorrentProvider ? <AiOutlineDownload className="opacity-50" /> : null} + isInvalid={episode.isInvalid} + onClick={() => { + if (hasTorrentProvider) { + router.push(`/entry?id=${episode.baseAnime?.id}&download=${episode.episodeNumber}`) + } else { + router.push(`/entry?id=${episode.baseAnime?.id}`) + } + }} + anime={{ + id: episode.baseAnime?.id, + image: episode.baseAnime?.coverImage?.medium, + title: episode.baseAnime?.title?.userPreferred, + }} + /> + </CarouselItem> + })} + </CarouselContent> + </Carousel> + </> + )} + + {!!silencedEpisodes?.length && ( + <> + + <Accordion + type="multiple" + defaultValue={[]} + triggerClass="py-2 px-0 dark:hover:bg-transparent text-lg dark:text-[--muted] dark:hover:text-white" + > + <AccordionItem value="item-1"> + <AccordionTrigger> + <p className="flex gap-3 items-center text-lg text-inherit"><LuBellOff /> Silenced episodes</p> + </AccordionTrigger> + <AccordionContent className="bg-gray-950 rounded-[--radius]"> + <Carousel + className="w-full max-w-full" + gap="md" + opts={{ + align: "start", + }} + autoScroll + > + <CarouselDotButtons /> + <CarouselContent> + {!isLoading && silencedEpisodes?.map(episode => { + return ( + <CarouselItem + key={episode.baseAnime?.id + episode.displayTitle} + className="md:basis-1/2 lg:basis-1/3 2xl:basis-1/5 min-[2000px]:basis-1/6" + > + <EpisodeCard + key={episode.displayTitle + episode.baseAnime?.id} + episode={episode} + image={episode.episodeMetadata?.image || episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge} + topTitle={episode.baseAnime?.title?.userPreferred} + title={episode.displayTitle} + meta={episode.episodeMetadata?.airDate ?? undefined} + actionIcon={hasTorrentProvider ? <AiOutlineDownload /> : null} + isInvalid={episode.isInvalid} + type="carousel" + onClick={() => { + if (hasTorrentProvider) { + router.push(`/entry?id=${episode.baseAnime?.id}&download=${episode.episodeNumber}`) + } else { + router.push(`/entry?id=${episode.baseAnime?.id}`) + } + }} + anime={{ + id: episode.baseAnime?.id, + image: episode.baseAnime?.coverImage?.medium, + title: episode.baseAnime?.title?.userPreferred, + }} + /> + </CarouselItem> + ) + })} + </CarouselContent> + </Carousel> + </AccordionContent> + </AccordionItem> + </Accordion> + </> + )} + + </AppLayoutStack> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_components/schedule-calendar.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_components/schedule-calendar.tsx new file mode 100644 index 0000000..1e88980 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_components/schedule-calendar.tsx @@ -0,0 +1,506 @@ +import { AL_MediaListStatus, Anime_Episode } from "@/api/generated/types" +import { useGetAnimeCollectionSchedule } from "@/api/hooks/anime_collection.hooks" +import { SeaLink } from "@/components/shared/sea-link" +import { IconButton } from "@/components/ui/button" +import { CheckboxGroup } from "@/components/ui/checkbox" +import { cn } from "@/components/ui/core/styling" +import { Popover } from "@/components/ui/popover" +import { RadioGroup } from "@/components/ui/radio-group" +import { Separator } from "@/components/ui/separator" +import { Switch } from "@/components/ui/switch" +import { addMonths, Day, endOfMonth, endOfWeek, format, isSameMonth, isToday, startOfMonth, startOfWeek, subMonths } from "date-fns" +import { addDays } from "date-fns/addDays" +import { useImmerAtom } from "jotai-immer" +import { useAtom, useAtomValue } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import { sortBy } from "lodash" +import Image from "next/image" +import React, { Fragment } from "react" +import { AiOutlineArrowLeft, AiOutlineArrowRight } from "react-icons/ai" +import { BiCog } from "react-icons/bi" +import { FaCheck, FaFlag } from "react-icons/fa6" +import { __anilist_userAnimeListDataAtom } from "../../_atoms/anilist.atoms" + +type CalendarParams = { + indicateWatchedEpisodes: boolean + listStatuses: AL_MediaListStatus[] +} + +const MAX_EVENT_COUNT = 4 + +export const weekStartsOnAtom = atomWithStorage("sea-calendar-week-starts-on", 1) +export const calendarDisableAnimations = atomWithStorage("sea-calendar-disable-animations", false) +export const calendarParamsAtom = atomWithStorage("sea-release-calendar-params", { + indicateWatchedEpisodes: true, + listStatuses: ["PLANNING", "CURRENT", "COMPLETED", "PAUSED"] as AL_MediaListStatus[], +}) + +type ScheduleCalendarProps = { + children?: React.ReactNode + missingEpisodes: Anime_Episode[] +} + +export function ScheduleCalendar(props: ScheduleCalendarProps) { + + const { + children, + missingEpisodes, + ...rest + } = props + + const anilistListData = useAtomValue(__anilist_userAnimeListDataAtom) + + const { data: schedule } = useGetAnimeCollectionSchedule() + + // State for the current displayed month + const [currentDate, setCurrentDate] = React.useState(new Date()) + + const [calendarParams, setCalendarParams] = useImmerAtom(calendarParamsAtom) + const [animationsDisabled, setAnimationDisabled] = useAtom(calendarDisableAnimations) + + const [weekStartsOn, setWeekStartsOn] = useAtom(weekStartsOnAtom) + + // Function to go to the previous month + const goToPreviousMonth = () => { + setCurrentDate(prevDate => subMonths(prevDate, 1)) + } + + // Function to go to the next month + const goToNextMonth = () => { + setCurrentDate(prevDate => addMonths(prevDate, 1)) + } + + const isSameDayUtc = (dateLeft: Date, dateRight: Date) => { + return ( + dateLeft.getFullYear() === dateRight.getFullYear() && + dateLeft.getMonth() === dateRight.getMonth() && + dateLeft.getDate() === dateRight.getDate() + ) + } + + + function isStatusIncluded(mediaId: number) { + const entry = anilistListData[String(mediaId)] + if (!entry || !entry.status) return false + return calendarParams.listStatuses.includes(entry.status) + } + + function isEpisodeWatched(mediaId: number, episodeNumber: number) { + const entry = anilistListData[String(mediaId)] + if (!entry || !entry.progress) return false + return entry.progress >= episodeNumber + } + + const days = React.useMemo(() => { + const startOfCurrentMonth = startOfMonth(currentDate) + const endOfCurrentMonth = endOfMonth(currentDate) + + const startOfCalendar = startOfWeek(startOfCurrentMonth, { weekStartsOn: weekStartsOn as Day }) + const endOfCalendar = endOfWeek(endOfCurrentMonth, { weekStartsOn: weekStartsOn as Day }) + + const daysArray = [] + let day = startOfCalendar + + while (day <= endOfCalendar) { + let events = schedule?.filter(item => isSameDayUtc(new Date(item.dateTime!), day) && isStatusIncluded(item.mediaId))?.map(item => { + return { + id: String(item.mediaId) + "-" + String(item.episodeNumber) + "-" + String(item.dateTime), + name: item.title, + time: item.time, + datetime: item.dateTime!, + href: `/entry?id=${item.mediaId}`, + image: item.image, + episode: item.episodeNumber || 1, + isSeasonFinale: item.isSeasonFinale && !item.isMovie, + isMovie: item.isMovie, + isWatched: isEpisodeWatched(item.mediaId, item.episodeNumber), + } + }) ?? [] + events = sortBy(events, (e) => e.episode) + events = sortBy(events, (e) => e.datetime) + + daysArray.push({ + date: format(day, "yyyy-MM-dd"), + isCurrentMonth: isSameMonth(day, currentDate), + isToday: isToday(day), + isSelected: false, + events: events, + }) + day = addDays(day, 1) + } + return daysArray + }, [currentDate, missingEpisodes, weekStartsOn, schedule, calendarParams, anilistListData]) + + + return ( + <> + <div className="hidden lg:flex lg:h-full lg:flex-col rounded-[--radius-md] border"> + <header className="relative flex items-center justify-center py-4 px-6 gap-4 lg:flex-none rounded-tr-[--radius-md] rounded-tl-[--radius-md] border-b bg-[--background]"> + <IconButton icon={<AiOutlineArrowLeft />} onClick={goToPreviousMonth} rounded intent="gray-outline" size="sm" /> + <h1 + className={cn( + "text-lg font-semibold text-[--muted] text-center w-[200px]", + isSameMonth(currentDate, new Date()) && "text-gray-100", + )} + > + <time dateTime={format(currentDate, "yyyy-MM")}> + {format(currentDate, "MMMM yyyy")} + </time> + </h1> + <IconButton icon={<AiOutlineArrowRight />} onClick={goToNextMonth} rounded intent="gray-outline" size="sm" /> + + <Popover + trigger={<IconButton icon={<BiCog />} intent="gray-basic" className="absolute right-3 top-4" size="sm" />} + className="w-[400px] space-y-2" + > + <RadioGroup + label="Week starts on" options={[ + { label: "Monday", value: "1" }, + { label: "Sunday", value: "0" }, + ]} value={String(weekStartsOn)} onValueChange={v => setWeekStartsOn(Number(v))} + /> + <Separator /> + <CheckboxGroup + label="Status" options={[ + { label: "Watching", value: "CURRENT" }, + { label: "Planning", value: "PLANNING" }, + { label: "Completed", value: "COMPLETED" }, + { label: "Paused", value: "PAUSED" }, + ]} value={calendarParams.listStatuses} onValueChange={v => setCalendarParams(draft => { + draft.listStatuses = v as AL_MediaListStatus[] + return + })} + stackClass="grid grid-cols-2 gap-0 items-center !space-y-0" + /> + <Separator /> + <Switch + label="Indicate watched episodes" + side="right" + value={calendarParams.indicateWatchedEpisodes} + onValueChange={v => setCalendarParams(draft => { + draft.indicateWatchedEpisodes = v + return + })} + /> + <Separator /> + <Switch + label="Disable image transitions" + side="right" + value={animationsDisabled} + onValueChange={v => setAnimationDisabled(v)} + /> + </Popover> + </header> + <div className="lg:flex lg:flex-auto lg:flex-col rounded-br-[--radius-md] rounded-bl-[--radius-md] overflow-hidden"> + <div className="grid grid-cols-7 gap-px border-b bg-[--background] text-center text-base font-semibold leading-6 text-gray-200 lg:flex-none"> + {weekStartsOn === 0 && <div className="py-2"> + S<span className="sr-only sm:not-sr-only">un</span> + </div>} + <div className="py-2"> + M<span className="sr-only sm:not-sr-only">on</span> + </div> + <div className="py-2"> + T<span className="sr-only sm:not-sr-only">ue</span> + </div> + <div className="py-2"> + W<span className="sr-only sm:not-sr-only">ed</span> + </div> + <div className="py-2"> + T<span className="sr-only sm:not-sr-only">hu</span> + </div> + <div className="py-2"> + F<span className="sr-only sm:not-sr-only">ri</span> + </div> + <div className="py-2"> + S<span className="sr-only sm:not-sr-only">at</span> + </div> + {weekStartsOn === 1 && <div className="py-2"> + S<span className="sr-only sm:not-sr-only">un</span> + </div>} + </div> + <div className="flex bg-[--background] text-xs leading-6 text-gray-200 lg:flex-auto"> + <div className="hidden w-full lg:grid lg:grid-cols-7 lg:grid-rows-6 lg:gap-2 p-2"> + {days.map((day, index) => ( + <CalendarDay + key={index} + day={day} + index={index} + /> + ))} + </div> + <div className="isolate grid w-full grid-cols-7 grid-rows-6 gap-px lg:hidden"> + {days.map((day, index) => ( + <button + key={index} + type="button" + className={cn( + day.isCurrentMonth ? "bg-gray-950" : "bg-gray-900", + (day.isSelected || day.isToday) && "font-semibold", + day.isSelected && "text-white", + !day.isSelected && day.isToday && "text-brand", + !day.isSelected && day.isCurrentMonth && !day.isToday && "text-gray-100", + !day.isSelected && !day.isCurrentMonth && !day.isToday && "text-gray-500", + "flex h-14 flex-col py-2 px-3 hover:bg-gray-800 focus:z-10", + )} + > + <time + dateTime={day.date} + className={cn( + day.isSelected && "flex h-6 w-6 items-center justify-center rounded-full", + day.isSelected && day.isToday && "bg-brand", + day.isSelected && !day.isToday && "bg-gray-900", + "ml-auto", + )} + > + {day.date.split("-")?.pop()?.replace(/^0/, "")} + </time> + <span className="sr-only">{day.events.length} events</span> + {day.events.length > 0 && ( + <span className="-mx-0.5 mt-auto flex flex-wrap-reverse"> + {day.events.map((event) => ( + <span key={event.id} className={cn("mx-0.5 mb-1 h-1.5 w-1.5 rounded-full bg-gray-400")} /> + ))} + </span> + )} + </button> + ))} + </div> + </div> + </div> + </div> + </> + ) +} + +type CalendarEvent = { + id: string + name: string + time: string + datetime: string + href: string + image: string + episode: number + isSeasonFinale: boolean + isMovie: boolean + isWatched: boolean +} + +interface CalendarDayBackgroundProps { + events: CalendarEvent[] + isToday: boolean + hoveredEventId: string | null +} + +function CalendarDayBackground({ events, isToday, hoveredEventId }: CalendarDayBackgroundProps) { + + const [focusedEventIndex, setFocusedEventIndex] = React.useState<number | null>(null) + const transitionDisabled = useAtomValue(calendarDisableAnimations) + React.useEffect(() => { + if (transitionDisabled) { + setFocusedEventIndex(0) + return + } + // carousel + const interval = setInterval(() => { + setFocusedEventIndex(prev => { + if (prev === null) return 0 + if (prev === events.length - 1) return 0 + return prev + 1 + }) + }, 5000) + return () => clearInterval(interval) + }, [events, transitionDisabled]) + + const displayedEvent = React.useMemo(() => { + if (hoveredEventId) { + const hoveredEvent = events.find(e => e.id === hoveredEventId) + if (hoveredEvent) return hoveredEvent + } else if (focusedEventIndex !== null && focusedEventIndex < events.length) { + return events[focusedEventIndex] + } + return events[0] + }, [hoveredEventId, events, focusedEventIndex]) + + return ( + <> + <div + className={cn( + "absolute top-0 left-0 z-[0] w-full h-full overflow-hidden rounded-md transition-all duration-500 ease-out", + isToday ? "opacity-80" : "opacity-20 group-hover:opacity-30", + )} + > + <Image + src={displayedEvent?.image || ""} + alt="banner" + fill + className="object-cover transition-all duration-500 ease-out transform" + key={displayedEvent?.id} + /> + </div> + <div + className={cn( + "absolute left-0 bottom-0 z-[1] w-full h-full bg-gradient-to-t from-gray-950/100 via-gray-950/80 via-40% to-transparent transition-all duration-300", + isToday && "from-gray-950/90 via-gray-950/80 via-40%", + )} + /> + </> + ) +} + +interface CalendarEventListProps { + events: CalendarEvent[] + onEventHover: (eventId: string | null) => void +} + +function CalendarEventList({ events, onEventHover }: CalendarEventListProps) { + const handleEventMouseEnter = (eventId: string) => { + onEventHover(eventId) + } + + const handleEventMouseLeave = () => { + onEventHover(null) + } + + const calendarParams = useAtomValue(calendarParamsAtom) + + return ( + <ol className="mt-2 relative z-[1]"> + {events.slice(0, MAX_EVENT_COUNT).map((event) => ( + <li + key={event.id} + onMouseEnter={() => handleEventMouseEnter(event.id)} + onMouseLeave={handleEventMouseLeave} + > + <SeaLink className="group flex" href={event.href}> + <p + className={cn("flex-auto truncate font-medium text-gray-100 flex items-center gap-2", + event.isWatched && calendarParams.indicateWatchedEpisodes ? "text-[--muted]" : "group-hover:text-gray-200")} + > + {event.isSeasonFinale && !event.isWatched && + <FaFlag className="size-3 text-[--blue] flex-none group-hover:scale-[1.15] transition-transform duration-300" />} + {event.isWatched && calendarParams.indicateWatchedEpisodes && + <FaCheck className="size-3 text-[--muted] flex-none group-hover:scale-[1.15] transition-transform duration-300" />} + {event.name} + </p> + <time + dateTime={event.datetime} + className="ml-3 hidden flex-none text-[--muted] group-hover:text-gray-200 xl:flex items-center" + > + <span className="mr-1 text-sm group-hover:text-[--foreground] font-semibold "> + {event.episode} + </span> + </time> + </SeaLink> + </li> + ))} + {events.length > MAX_EVENT_COUNT && ( + <Popover + className="w-full max-w-sm lg:max-w-sm" + trigger={ + <li className="text-[--muted] cursor-pointer">+ {events.length - MAX_EVENT_COUNT} more</li> + } + > + <ol className="text-sm max-w-full block"> + {events.slice(MAX_EVENT_COUNT).map((event) => ( + <li key={event.id}> + <SeaLink className="group flex gap-2" href={event.href}> + <p + className={cn("flex-auto truncate font-medium text-gray-100 flex items-center gap-2", + event.isWatched && calendarParams.indicateWatchedEpisodes + ? "text-[--muted]" + : "group-hover:text-gray-200")} + > + {event.isSeasonFinale && !event.isWatched && + <FaFlag className="size-3 text-[--blue] flex-none group-hover:scale-[1.15] transition-transform duration-300" />} + {event.isWatched && calendarParams.indicateWatchedEpisodes && + <FaCheck className="size-3 text-[--muted] flex-none group-hover:scale-[1.15] transition-transform duration-300" />} + {event.name} + </p> + <p className="flex-none"> + Ep. {event.episode} + </p> + <time + dateTime={event.datetime} + className="ml-3 hidden flex-none text-[--muted] group-hover:text-gray-200 xl:block" + > + {event.time} + </time> + </SeaLink> + </li> + ))} + </ol> + </Popover> + )} + </ol> + ) +} + +function CalendarDay({ day, index }: { day: any, index: number }) { + const [hoveredEventId, setHoveredEventId] = React.useState<string | null>(null) + + const hoveredEvent = React.useMemo(() => { + if (hoveredEventId) { + return day.events.find((e: CalendarEvent) => e.id === hoveredEventId) + } + return null + }, [hoveredEventId, day.events]) + + return ( + <div + key={index} + className={cn( + day.isCurrentMonth ? "bg-[--background]" : "opacity-20", + "relative py-2 px-3 h-40 rounded-md", + "flex flex-col justify-between group", + )} + > + {day.events[0] && ( + <CalendarDayBackground + events={day.events} + isToday={day.isToday} + hoveredEventId={hoveredEventId} + /> + )} + + {/* Title display for hovered event */} + <div className="absolute -top-2 left-10 right-1 z-[5] pointer-events-none"> + <div + className={cn( + "transition-all duration-300 ease-out", + hoveredEvent ? "opacity-100 transform translate-y-0" : "opacity-0 transform -translate-y-2", + )} + > + {hoveredEvent && ( + <div className="bg-gray-900/70 backdrop-blur-sm rounded-md px-2 py-1.5 border"> + <p className="text-xs font-medium text-gray-100 line-clamp-2 leading-tight"> + <span className="text-[--muted] font-normal">{hoveredEvent.name.slice(0, 20) + (hoveredEvent.name.length > 20 + ? "..." + : "")}</span> + {hoveredEvent.isSeasonFinale && <span className="text-[--blue] ml-1">Finale</span>} + <span className="ml-1"> Ep. {hoveredEvent.episode}</span> + {hoveredEvent.time && <span className="ml-1">- {hoveredEvent.time}</span>} + </p> + </div> + )} + </div> + </div> + + <time + dateTime={day.date} + className={ + day.isToday + ? "z-[1] relative flex h-7 w-7 text-lg items-center justify-center rounded-full bg-brand font-bold group-hover:rotate-12 transition-transform duration-300 ease-out text-white" + : "text-xs md:text-base group-hover:text-white group-hover:font-bold transition-transform duration-300 ease-out" + } + > + {day.date.split("-")?.pop()?.replace(/^0/, "")} + </time> + {day.events.length > 0 && ( + <CalendarEventList + events={day.events} + onEventHover={setHoveredEventId} + /> + )} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_containers/coming-up-next.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_containers/coming-up-next.tsx new file mode 100644 index 0000000..1af4d9c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_containers/coming-up-next.tsx @@ -0,0 +1,106 @@ +import { useGetAnimeCollection } from "@/api/hooks/anilist.hooks" +import { EpisodeCard } from "@/app/(main)/_features/anime/_components/episode-card" +import { useMissingEpisodes } from "@/app/(main)/_hooks/missing-episodes-loader" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { ScheduleCalendar } from "@/app/(main)/schedule/_components/schedule-calendar" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel" +import { addSeconds, formatDistanceToNow } from "date-fns" +import { useRouter } from "next/navigation" +import React from "react" + +/** + * @description + * Displays a carousel of upcoming episodes based on the user's anime list. + */ +export function ComingUpNext() { + const serverStatus = useServerStatus() + const router = useRouter() + + const { data: animeCollection } = useGetAnimeCollection() + const missingEpisodes = useMissingEpisodes() + + const media = React.useMemo(() => { + // get all media + const _media = (animeCollection?.MediaListCollection?.lists?.filter(n => n.status !== "DROPPED") + .map(n => n?.entries) + .flat() ?? []).map(entry => entry?.media)?.filter(Boolean) + // keep media with next airing episodes + let ret = _media.filter(item => !!item.nextAiringEpisode?.episode) + .sort((a, b) => a.nextAiringEpisode!.timeUntilAiring - b.nextAiringEpisode!.timeUntilAiring) + if (serverStatus?.settings?.anilist?.enableAdultContent) { + return ret + } else { + // remove adult media + return ret.filter(item => !item.isAdult) + } + }, [animeCollection]) + + // if (media.length === 0) return ( + // <LuffyError title="No upcoming episodes"> + // <p>There are no upcoming episodes based on your anime list.</p> + // </LuffyError> + // ) + + return ( + <AppLayoutStack className="space-y-8"> + <div className="hidden lg:block space-y-2"> + <h2>Release schedule</h2> + <p className="text-[--muted]">Based on your anime list</p> + </div> + + <ScheduleCalendar + missingEpisodes={missingEpisodes} + /> + + {media.length > 0 && ( + <> + <div> + <h2>Coming up next</h2> + <p className="text-[--muted]">Based on your anime list</p> + </div> + + <Carousel + className="w-full max-w-full" + gap="md" + opts={{ + align: "start", + }} + autoScroll + > + <CarouselDotButtons /> + <CarouselContent> + {media.map(item => { + return ( + <CarouselItem + key={item.id} + className="md:basis-1/2 lg:basis-1/3 2xl:basis-1/4 min-[2000px]:basis-1/5" + > + <EpisodeCard + key={item.id} + image={item.bannerImage || item.coverImage?.large} + topTitle={item.title?.userPreferred} + title={`Episode ${item.nextAiringEpisode?.episode}`} + meta={formatDistanceToNow(addSeconds(new Date(), item.nextAiringEpisode?.timeUntilAiring!), + { addSuffix: true })} + imageClass="opacity-50" + actionIcon={null} + onClick={() => { + router.push(`/entry?id=${item.id}`) + }} + anime={{ + id: item.id, + image: item.coverImage?.large, + title: item.title?.userPreferred, + }} + /> + </CarouselItem> + ) + })} + </CarouselContent> + </Carousel> + </> + )} + </AppLayoutStack> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_containers/recent-releases.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_containers/recent-releases.tsx new file mode 100644 index 0000000..6521b2e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_containers/recent-releases.tsx @@ -0,0 +1,95 @@ +"use client" +import { useAnilistListRecentAiringAnime } from "@/api/hooks/anilist.hooks" +import { EpisodeCard } from "@/app/(main)/_features/anime/_components/episode-card" +import { SeaContextMenu } from "@/app/(main)/_features/context-menu/sea-context-menu" +import { useMediaPreviewModal } from "@/app/(main)/_features/media/_containers/media-preview-modal" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel" +import { ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuTrigger } from "@/components/ui/context-menu" +import { addSeconds, formatDistanceToNow, subDays } from "date-fns" +import { useRouter } from "next/navigation" +import React from "react" + +export function RecentReleases() { + + const router = useRouter() + + const { data } = useAnilistListRecentAiringAnime({ + page: 1, + perPage: 50, + airingAt_lesser: Math.floor(new Date().getTime() / 1000), + airingAt_greater: Math.floor(subDays(new Date(), 14).getTime() / 1000), + }) + + const media = data?.Page?.airingSchedules?.filter(item => item?.media?.isAdult === false + && item?.media?.type === "ANIME" + && item?.media?.countryOfOrigin === "JP" + && item?.media?.format !== "TV_SHORT", + ).filter(Boolean) + + const { setPreviewModalMediaId } = useMediaPreviewModal() + + if (!media?.length) return null + + return ( + <AppLayoutStack className="pb-6"> + <h2>Aired Recently</h2> + <Carousel + className="w-full max-w-full" + gap="md" + opts={{ + align: "start", + }} + autoScroll + > + <CarouselDotButtons /> + <CarouselContent> + {media.map(item => { + return ( + <CarouselItem + key={item.id} + className="md:basis-1/2 lg:basis-1/3 2xl:basis-1/4 min-[2000px]:basis-1/5" + > + <SeaContextMenu + content={<ContextMenuGroup> + <ContextMenuLabel className="text-[--muted] line-clamp-2 py-0 my-2"> + {item.media?.title?.userPreferred} + </ContextMenuLabel> + <ContextMenuItem + onClick={() => { + setPreviewModalMediaId(item.media?.id || 0, "anime") + }} + > + Preview + </ContextMenuItem> + </ContextMenuGroup>} + > + <ContextMenuTrigger> + <EpisodeCard + key={item.id} + title={`Episode ${item.episode}`} + image={item.media?.bannerImage || item.media?.coverImage?.large} + topTitle={item.media?.title?.userPreferred} + progressTotal={item.media?.episodes} + meta={item.airingAt + ? formatDistanceToNow(addSeconds(new Date(), item.timeUntilAiring), { addSuffix: true }) + : undefined} + onClick={() => router.push(`/entry?id=${item.media?.id}`)} + actionIcon={null} + anime={{ + id: item.media?.id, + image: item.media?.coverImage?.medium, + title: item.media?.title?.userPreferred, + }} + /> + </ContextMenuTrigger> + </SeaContextMenu> + + </CarouselItem> + ) + })} + </CarouselContent> + </Carousel> + </AppLayoutStack> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_lib/handle-missing-episodes.ts b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_lib/handle-missing-episodes.ts new file mode 100644 index 0000000..6c9d397 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/_lib/handle-missing-episodes.ts @@ -0,0 +1,36 @@ +import { Anime_MissingEpisodes } from "@/api/generated/types" +import { missingEpisodesAtom, missingSilencedEpisodesAtom } from "@/app/(main)/_atoms/missing-episodes.atoms" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useSetAtom } from "jotai/react" +import { useEffect } from "react" + +/** + * @description + * - Sets missing episodes to the atom so that it can be displayed in other components + * - Filters out adult content if the user has it disabled + * @param data + */ +export function useHandleMissingEpisodes(data: Anime_MissingEpisodes | undefined) { + const serverStatus = useServerStatus() + const setAtom = useSetAtom(missingEpisodesAtom) + const setSilencedAtom = useSetAtom(missingSilencedEpisodesAtom) + + useEffect(() => { + if (!!data) { + if (serverStatus?.settings?.anilist?.enableAdultContent) { + setAtom(data?.episodes ?? []) + } else { + setAtom(data?.episodes?.filter(episode => !episode?.baseAnime?.isAdult) ?? []) + } + setSilencedAtom(data?.silencedEpisodes ?? []) + } + }, [data, serverStatus?.settings?.anilist?.enableAdultContent]) + + return { + missingEpisodes: serverStatus?.settings?.anilist?.enableAdultContent + ? (data?.episodes ?? []) + : (data?.episodes?.filter(episode => !episode?.baseAnime?.isAdult) ?? []), + silencedEpisodes: data?.silencedEpisodes ?? [], + } + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/schedule/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/layout.tsx new file mode 100644 index 0000000..3efab03 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/layout.tsx @@ -0,0 +1,15 @@ +"use client" +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/schedule/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/page.tsx new file mode 100644 index 0000000..782e8ee --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/schedule/page.tsx @@ -0,0 +1,30 @@ +"use client" + +import { useGetMissingEpisodes } from "@/api/hooks/anime_entries.hooks" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { MissingEpisodes } from "@/app/(main)/schedule/_components/missing-episodes" +import { ComingUpNext } from "@/app/(main)/schedule/_containers/coming-up-next" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import React from "react" + +export const dynamic = "force-static" + +export default function Page() { + + const { data, isLoading } = useGetMissingEpisodes() + + if (isLoading) return <LoadingSpinner /> + + return ( + <> + <CustomLibraryBanner discrete /> + <PageWrapper + className="p-4 sm:p-8 space-y-10 pb-10" + > + <MissingEpisodes data={data} isLoading={isLoading} /> + <ComingUpNext /> + </PageWrapper> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/search/_components/advanced-search-list.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/search/_components/advanced-search-list.tsx new file mode 100644 index 0000000..7fdc2c8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/search/_components/advanced-search-list.tsx @@ -0,0 +1,41 @@ +import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { useAnilistAdvancedSearch } from "@/app/(main)/search/_lib/handle-advanced-search" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import React from "react" +import { AiOutlinePlusCircle } from "react-icons/ai" + +export function AdvancedSearchList() { + + const { isLoading, data, fetchNextPage, hasNextPage, type } = useAnilistAdvancedSearch() + + const items = data?.pages.filter(Boolean).flatMap(n => n.Page?.media).filter(Boolean).filter(media => !!media.startDate?.year) + + return <> + {!isLoading && <MediaCardLazyGrid itemCount={items?.length ?? 0}> + {items?.map(media => ( + <MediaEntryCard + key={`${media.id}`} + media={media} + showLibraryBadge={true} + showTrailer + type={type} + /> + ))} + </MediaCardLazyGrid>} + {isLoading && <LoadingSpinner />} + {((data?.pages.filter(Boolean).flatMap(n => n.Page?.media).filter(Boolean) || []).length > 0 && hasNextPage) && + <div + data-advanced-search-list-load-more-container + className={cn( + "relative flex flex-col rounded-[--radius-md] animate-none", + "cursor-pointer border border-none text-[--muted] hover:text-white pt-24 items-center gap-2 transition", + )} + onClick={() => fetchNextPage()} + > + <AiOutlinePlusCircle className="text-4xl" /> + <p className="text-lg font-medium">Load more</p> + </div>} + </> +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/search/_components/advanced-search-options.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/search/_components/advanced-search-options.tsx new file mode 100644 index 0000000..41100a6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/search/_components/advanced-search-options.tsx @@ -0,0 +1,231 @@ +"use client" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { + ADVANCED_SEARCH_COUNTRIES_MANGA, + ADVANCED_SEARCH_FORMATS, + ADVANCED_SEARCH_FORMATS_MANGA, + ADVANCED_SEARCH_MEDIA_GENRES, + ADVANCED_SEARCH_SEASONS, + ADVANCED_SEARCH_SORTING, + ADVANCED_SEARCH_SORTING_MANGA, + ADVANCED_SEARCH_STATUS, + ADVANCED_SEARCH_TYPE, +} from "@/app/(main)/search/_lib/advanced-search-constants" +import { __advancedSearch_paramsAtom } from "@/app/(main)/search/_lib/advanced-search.atoms" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { IconButton } from "@/components/ui/button" +import { Combobox } from "@/components/ui/combobox" +import { cn } from "@/components/ui/core/styling" +import { Select } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { TextInput } from "@/components/ui/text-input" +import { useDebounce } from "@/hooks/use-debounce" +import { getYear } from "date-fns" +import { useAtom } from "jotai/react" +import React, { useState } from "react" +import { BiTrash, BiWorld } from "react-icons/bi" +import { FaRegStar, FaSortAmountDown } from "react-icons/fa" +import { FiSearch } from "react-icons/fi" +import { LuCalendar, LuLeaf } from "react-icons/lu" +import { MdOutlineBook, MdPersonalVideo } from "react-icons/md" +import { RiSignalTowerLine } from "react-icons/ri" +import { TbSwords } from "react-icons/tb" +import { useUpdateEffect } from "react-use" + +export function AdvancedSearchOptions() { + + const serverStatus = useServerStatus() + const [params, setParams] = useAtom(__advancedSearch_paramsAtom) + + const highlightTrash = React.useMemo(() => { + return !(!params.title?.length && + (params.sorting === null || params.sorting?.[0] === "SCORE_DESC") && + (params.genre === null || !params.genre.length) && + (params.status === null || !params.status.length) && + params.format === null && params.season === null && params.year === null && params.isAdult === false && params.minScore === null && + (params.countryOfOrigin === null || params.type === "anime")) + }, [params]) + + return ( + <AppLayoutStack data-advanced-search-options-container className="px-4 xl:px-0"> + <div data-advanced-search-options-header className="flex flex-col md:flex-row xl:flex-col gap-4"> + <TitleInput /> + <Select + className="w-full" + options={ADVANCED_SEARCH_TYPE} + value={params.type} + onValueChange={v => setParams(draft => { + draft.type = v as "anime" | "manga" + return + })} + /> + <Select + // label="Sorting" + leftAddon={ + <FaSortAmountDown className={cn((params.sorting !== null && params.sorting?.[0] !== "SCORE_DESC") && "text-indigo-300 font-bold text-xl")} />} + className="w-full" + options={params.type === "anime" ? ADVANCED_SEARCH_SORTING : ADVANCED_SEARCH_SORTING_MANGA} + value={params.sorting?.[0] || "SCORE_DESC"} + onValueChange={v => setParams(draft => { + draft.sorting = [v] as any + return + })} + disabled={!!params.title && params.title.length > 0} + /> + </div> + <div data-advanced-search-options-content className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-1 gap-4 items-end xl:items-start"> + <Combobox + multiple + leftAddon={<TbSwords className={cn((params.genre !== null && !!params.genre.length) && "text-indigo-300 font-bold text-xl")} />} + emptyMessage="No options found" + label="Genre" placeholder="All genres" className="w-full" + options={ADVANCED_SEARCH_MEDIA_GENRES.map(genre => ({ value: genre, label: genre, textValue: genre }))} + value={params.genre ? params.genre : []} + onValueChange={v => setParams(draft => { + draft.genre = v + return + })} + fieldLabelClass="hidden" + /> + {params.type === "anime" && <Select + leftAddon={<MdPersonalVideo className={cn((params.format !== null && !!params.format) && "text-indigo-300 font-bold text-xl")} />} + label="Format" placeholder="All formats" className="w-full" + options={ADVANCED_SEARCH_FORMATS} + value={params.format || ""} + onValueChange={v => setParams(draft => { + draft.format = v as any + return + })} + fieldLabelClass="hidden" + />} + {params.type === "manga" && <Select + leftAddon={ + <BiWorld className={cn((params.countryOfOrigin !== null && !!params.countryOfOrigin) && "text-indigo-300 font-bold text-xl")} />} + label="Format" placeholder="All countries" className="w-full" + options={ADVANCED_SEARCH_COUNTRIES_MANGA} + value={params.countryOfOrigin || ""} + onValueChange={v => setParams(draft => { + draft.countryOfOrigin = v as any + return + })} + fieldLabelClass="hidden" + />} + {params.type === "manga" && <Select + leftAddon={<MdOutlineBook className={cn((params.format !== null && !!params.format) && "text-indigo-300 font-bold text-xl")} />} + label="Format" placeholder="All formats" className="w-full" + options={ADVANCED_SEARCH_FORMATS_MANGA} + value={params.format || ""} + onValueChange={v => setParams(draft => { + draft.format = v as any + return + })} + fieldLabelClass="hidden" + />} + {params.type === "anime" && <Select + leftAddon={<LuLeaf className={cn((params.season !== null && !!params.season) && "text-indigo-300 font-bold text-xl")} />} + placeholder="All seasons" className="w-full" + options={ADVANCED_SEARCH_SEASONS.map(season => ({ value: season.toUpperCase(), label: season }))} + value={params.season || ""} + onValueChange={v => setParams(draft => { + draft.season = v as any + return + })} + fieldLabelClass="hidden" + />} + <Select + leftAddon={<LuCalendar className={cn((params.year !== null && !!params.year) && "text-indigo-300 font-bold text-xl")} />} + label="Year" placeholder="Timeless" className="w-full" + options={[...Array(70)].map((v, idx) => getYear(new Date()) - idx + 2).map(year => ({ + value: String(year), + label: String(year), + }))} + value={params.year || ""} + onValueChange={v => setParams(draft => { + draft.year = v as any + return + })} + fieldLabelClass="hidden" + /> + <Select + leftAddon={ + <RiSignalTowerLine className={cn((params.status !== null && !!params.status.length) && "text-indigo-300 font-bold text-xl")} />} + label="Status" placeholder="All statuses" className="w-full" + options={ADVANCED_SEARCH_STATUS} + value={params.status?.[0] || ""} + onValueChange={v => setParams(draft => { + draft.status = [v] as any + return + })} + fieldLabelClass="hidden" + /> + <Select + leftAddon={<FaRegStar className={cn((params.minScore !== null && !!params.minScore) && "text-indigo-300 font-bold text-xl")} />} + placeholder="All scores" className="w-full" + options={[...Array(9)].map((v, idx) => 9 - idx).map(score => ({ + value: String(score), + label: String(score), + }))} + value={params.minScore || ""} + onValueChange={v => setParams(draft => { + draft.minScore = v as any + return + })} + /> + {serverStatus?.settings?.anilist?.enableAdultContent && <Switch + label="Adult" + value={params.isAdult} + onValueChange={v => setParams(draft => { + draft.isAdult = v + return + })} + fieldLabelClass="hidden" + />} + <IconButton + icon={<BiTrash />} intent={highlightTrash ? "alert" : "gray-subtle"} className="flex-none" onClick={() => { + setParams(prev => ({ + ...prev, + active: true, + title: null, + sorting: null, + status: null, + genre: null, + format: null, + season: null, + year: null, + minScore: null, + countryOfOrigin: null, + // isAdult: false, + })) + }} + disabled={!highlightTrash} + /> + </div> + + </AppLayoutStack> + ) +} + +function TitleInput() { + const [inputValue, setInputValue] = useState("") + const debouncedTitle = useDebounce(inputValue, 500) + const [params, setParams] = useAtom(__advancedSearch_paramsAtom) + + useUpdateEffect(() => { + setParams(draft => { + draft.title = debouncedTitle + return + }) + }, [debouncedTitle]) + + useUpdateEffect(() => { + setInputValue(params.title || "") + }, [params.title]) + + return ( + <TextInput + leftIcon={<FiSearch />} placeholder="Title" className="w-full" + value={inputValue} + onValueChange={v => setInputValue(v)} + /> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/search/_components/advanced-search-page-title.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/search/_components/advanced-search-page-title.tsx new file mode 100644 index 0000000..d441951 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/search/_components/advanced-search-page-title.tsx @@ -0,0 +1,48 @@ +import { __advancedSearch_getValue, __advancedSearch_paramsAtom } from "@/app/(main)/search/_lib/advanced-search.atoms" +import { useAtomValue } from "jotai/react" +import capitalize from "lodash/capitalize" +import startCase from "lodash/startCase" +import React from "react" + +export function AdvancedSearchPageTitle() { + + const params = useAtomValue(__advancedSearch_paramsAtom) + + const title = React.useMemo(() => { + let str = "" + if (params.title && params.title.length > 0) { + str += startCase(params.title) + return str + } + // if (!!__advancedSearch_getValue(params.genre)) str += params.genre?.join(", ") || "" + if (__advancedSearch_getValue(params.sorting)?.includes("SCORE_DESC")) str += "Highest rated" + if (__advancedSearch_getValue(params.sorting)?.includes("TRENDING_DESC")) str += "Trending" + if (__advancedSearch_getValue(params.sorting)?.includes("POPULARITY_DESC")) str += "Popular" + if (__advancedSearch_getValue(params.sorting)?.includes("START_DATE_DESC")) str += "Latest" + if (__advancedSearch_getValue(params.sorting)?.includes("EPISODES_DESC")) str += "Most episodes" + if (__advancedSearch_getValue(params.sorting)?.includes("CHAPTERS_DESC")) str += "Most chapters" + if (!!__advancedSearch_getValue(params.genre)) str += ` ${params.genre?.join(", ")}` + if (!str) str += "Highest rated" + if (params.type === "anime") str += " shows" + else str += " manga" + if (params.season || params.year) str += " from" + if (params.season) str += ` ${capitalize(params.season)}` + if (params.year) str += ` ${params.year}` + if (!!str) return str + return params.type === "anime" ? "Most liked shows" : "Most liked manga" + }, [params.title, params.genre, params.sorting, params.type, params.season, params.year]) + + // const secondaryTitle = React.useMemo(() => { + // let str = "" + // if (params.season) str += ` ${capitalize(params.season)}` + // if (params.year) str += ` ${params.year}` + // return str || null + // }, [params.genre, params.season, params.year]) + + return ( + <div data-advanced-search-page-title-container> + <h2 data-advanced-search-page-title className="line-clamp-2">{title}</h2> + {/*{secondaryTitle && <p className="text-xl line-clamp-1">{secondaryTitle}</p>}*/} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/search/_lib/advanced-search-constants.ts b/seanime-2.9.10/seanime-web/src/app/(main)/search/_lib/advanced-search-constants.ts new file mode 100644 index 0000000..230d15d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/search/_lib/advanced-search-constants.ts @@ -0,0 +1,80 @@ +import { AL_MediaFormat } from "@/api/generated/types" + +export const ADVANCED_SEARCH_MEDIA_GENRES = [ + "Action", + "Adventure", + "Comedy", + "Drama", + "Ecchi", + "Fantasy", + "Horror", + "Mahou Shoujo", + "Mecha", + "Music", + "Mystery", + "Psychological", + "Romance", + "Sci-Fi", + "Slice of Life", + "Sports", + "Supernatural", + "Thriller", +] + +export const ADVANCED_SEARCH_SEASONS = [ + "Winter", + "Spring", + "Summer", + "Fall", +] + +export const ADVANCED_SEARCH_FORMATS: { value: AL_MediaFormat, label: string }[] = [ + { value: "TV", label: "TV" }, + { value: "MOVIE", label: "Movie" }, + { value: "ONA", label: "ONA" }, + { value: "OVA", label: "OVA" }, + { value: "TV_SHORT", label: "TV Short" }, + { value: "SPECIAL", label: "Special" }, +] + +export const ADVANCED_SEARCH_FORMATS_MANGA: { value: AL_MediaFormat, label: string }[] = [ + { value: "MANGA", label: "Manga" }, + { value: "ONE_SHOT", label: "One Shot" }, +] + + +export const ADVANCED_SEARCH_COUNTRIES_MANGA: { value: string, label: string }[] = [ + { value: "JP", label: "Japan" }, + { value: "KR", label: "South Korea" }, + { value: "CN", label: "China" }, + { value: "TW", label: "Taiwan" }, +] + +export const ADVANCED_SEARCH_STATUS = [ + { value: "FINISHED", label: "Finished" }, + { value: "RELEASING", label: "Releasing" }, + { value: "NOT_YET_RELEASED", label: "Upcoming" }, + { value: "HIATUS", label: "Hiatus" }, + { value: "CANCELLED", label: "Cancelled" }, +] + +export const ADVANCED_SEARCH_SORTING = [ + { value: "TRENDING_DESC", label: "Trending" }, + { value: "START_DATE_DESC", label: "Release date" }, + { value: "SCORE_DESC", label: "Highest score" }, + { value: "POPULARITY_DESC", label: "Most popular" }, + { value: "EPISODES_DESC", label: "Number of episodes" }, +] + +export const ADVANCED_SEARCH_SORTING_MANGA = [ + { value: "TRENDING_DESC", label: "Trending" }, + { value: "START_DATE_DESC", label: "Release date" }, + { value: "SCORE_DESC", label: "Highest score" }, + { value: "POPULARITY_DESC", label: "Most popular" }, + { value: "CHAPTERS_DESC", label: "Number of chapters" }, +] + +export const ADVANCED_SEARCH_TYPE = [ + { value: "anime", label: "Anime" }, + { value: "manga", label: "Manga" }, +] diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/search/_lib/advanced-search.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/search/_lib/advanced-search.atoms.ts new file mode 100644 index 0000000..e733dc6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/search/_lib/advanced-search.atoms.ts @@ -0,0 +1,40 @@ +import { AL_MediaFormat, AL_MediaSeason, AL_MediaSort, AL_MediaStatus } from "@/api/generated/types" +import { atomWithImmer } from "jotai-immer" + +type Params = { + active: boolean + title: string | null + sorting: AL_MediaSort[] | null + genre: string[] | null + status: AL_MediaStatus[] | null + format: AL_MediaFormat | null + season: AL_MediaSeason | null + year: string | null + minScore: string | null + isAdult: boolean + countryOfOrigin: string | null + type: "anime" | "manga" +} + +export const __advancedSearch_paramsAtom = atomWithImmer<Params>({ + active: true, + title: null, + sorting: null, + status: null, + genre: null, + format: null, + season: null, + year: null, + minScore: null, + isAdult: false, + countryOfOrigin: null, + type: "anime", +}) + +export function __advancedSearch_getValue<T extends any>(value: T | ""): any { + if (value === "") return undefined + if (Array.isArray(value) && value.filter(Boolean).length === 0) return undefined + if (typeof value === "string" && !isNaN(parseInt(value))) return Number(value) + if (value === null) return undefined + return value +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/search/_lib/handle-advanced-search.ts b/seanime-2.9.10/seanime-web/src/app/(main)/search/_lib/handle-advanced-search.ts new file mode 100644 index 0000000..33cf5c1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/search/_lib/handle-advanced-search.ts @@ -0,0 +1,108 @@ +import { buildSeaQuery } from "@/api/client/requests" +import { API_ENDPOINTS } from "@/api/generated/endpoints" +import { AL_ListAnime, AL_ListManga } from "@/api/generated/types" +import { serverAuthTokenAtom } from "@/app/(main)/_atoms/server-status.atoms" +import { __advancedSearch_getValue, __advancedSearch_paramsAtom } from "@/app/(main)/search/_lib/advanced-search.atoms" +import { useInfiniteQuery } from "@tanstack/react-query" +import { useAtomValue } from "jotai/react" +import React from "react" + +export function useAnilistAdvancedSearch() { + + const params = useAtomValue(__advancedSearch_paramsAtom) + const password = useAtomValue(serverAuthTokenAtom) + + const { isLoading: isLoading1, data: data1, fetchNextPage: fetchNextPage1, hasNextPage: hasNextPage1 } = useInfiniteQuery({ + queryKey: ["advanced-search-anime", params], + initialPageParam: 1, + queryFn: async ({ pageParam }) => { + const variables = { + page: pageParam, + perPage: 48, + format: __advancedSearch_getValue(params.format)?.toUpperCase(), + search: (params.title === null || params.title === "") ? undefined : params.title, + genres: __advancedSearch_getValue(params.genre), + season: __advancedSearch_getValue(params.season), + seasonYear: __advancedSearch_getValue(params.year), + averageScore_greater: __advancedSearch_getValue(params.minScore) !== undefined + ? __advancedSearch_getValue(params.minScore) + : undefined, + sort: (params.title?.length && params.title.length > 0) ? ["SEARCH_MATCH", + ...(__advancedSearch_getValue(params.sorting) || ["SCORE_DESC"])] : (__advancedSearch_getValue(params.sorting) || ["SCORE_DESC"]), + status: params.sorting?.includes("START_DATE_DESC") ? (__advancedSearch_getValue(params.status) + ?.filter((n: string) => n !== "NOT_YET_RELEASED") || ["FINISHED", "RELEASING"]) : __advancedSearch_getValue(params.status), + isAdult: params.isAdult, + } + + return buildSeaQuery<AL_ListAnime>({ + endpoint: API_ENDPOINTS.ANILIST.AnilistListAnime.endpoint, + method: "POST", + data: variables, + password: password, + }) + }, + getNextPageParam: (lastPage, pages) => { + const curr = lastPage?.Page?.pageInfo?.currentPage + const hasNext = lastPage?.Page?.pageInfo?.hasNextPage + // console.log("lastPage", lastPage, "pages", pages, "curr", curr, "hasNext", hasNext, "nextPage", (!!curr && hasNext) ? pages.length + 1 + // : undefined) + return (!!curr && hasNext) ? pages.length + 1 : undefined + }, + enabled: params.active && params.type === "anime", + refetchOnMount: true, + }) + + const { isLoading: isLoading2, data: data2, fetchNextPage: fetchNextPage2, hasNextPage: hasNextPage2 } = useInfiniteQuery({ + queryKey: ["advanced-search-manga", params], + initialPageParam: 1, + queryFn: async ({ pageParam }) => { + const variables = { + page: pageParam, + perPage: 48, + search: (params.title === null || params.title === "") ? undefined : params.title, + genres: __advancedSearch_getValue(params.genre), + year: __advancedSearch_getValue(params.year), + format: __advancedSearch_getValue(params.format)?.toUpperCase(), + averageScore_greater: __advancedSearch_getValue(params.minScore) !== undefined + ? __advancedSearch_getValue(params.minScore) + : undefined, + sort: (params.title?.length && params.title.length > 0) ? ["SEARCH_MATCH", + ...(__advancedSearch_getValue(params.sorting) || ["SCORE_DESC"])] : (__advancedSearch_getValue(params.sorting) || ["SCORE_DESC"]), + status: params.sorting?.includes("START_DATE_DESC") ? (__advancedSearch_getValue(params.status) + ?.filter((n: string) => n !== "NOT_YET_RELEASED") || ["FINISHED", "RELEASING"]) : __advancedSearch_getValue(params.status), + countryOfOrigin: __advancedSearch_getValue(params.countryOfOrigin), + isAdult: params.isAdult, + } + + return buildSeaQuery<AL_ListManga>({ + endpoint: API_ENDPOINTS.MANGA.AnilistListManga.endpoint, + method: "POST", + data: variables, + password: password, + }) + }, + getNextPageParam: (lastPage, pages) => { + const curr = lastPage?.Page?.pageInfo?.currentPage + const hasNext = lastPage?.Page?.pageInfo?.hasNextPage + // console.log("lastPage", lastPage, "pages", pages, "curr", curr, "hasNext", hasNext, "nextPage", (!!curr && hasNext) ? pages.length + 1 + // : undefined) + return (!!curr && hasNext) ? pages.length + 1 : undefined + }, + enabled: params.active && params.type === "manga", + refetchOnMount: true, + }) + + const isLoading = React.useMemo(() => params.type === "anime" ? isLoading1 : isLoading2, [isLoading1, isLoading2, params.type]) + const data = React.useMemo(() => params.type === "anime" ? data1 : data2, [data1, data2, params.type]) + const fetchNextPage = React.useMemo(() => params.type === "anime" ? fetchNextPage1 : fetchNextPage2, + [fetchNextPage1, fetchNextPage2, params.type]) + const hasNextPage = React.useMemo(() => params.type === "anime" ? hasNextPage1 : hasNextPage2, [hasNextPage1, hasNextPage2, params.type]) + + return { + isLoading, + data, + fetchNextPage, + hasNextPage, + type: params.type, + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/search/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/search/layout.tsx new file mode 100644 index 0000000..995ee96 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/search/layout.tsx @@ -0,0 +1,16 @@ +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) + +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/search/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/search/page.tsx new file mode 100644 index 0000000..44685bb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/search/page.tsx @@ -0,0 +1,74 @@ +"use client" +import { AL_MediaFormat, AL_MediaSeason, AL_MediaSort, AL_MediaStatus } from "@/api/generated/types" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { AdvancedSearchList } from "@/app/(main)/search/_components/advanced-search-list" +import { AdvancedSearchOptions } from "@/app/(main)/search/_components/advanced-search-options" +import { AdvancedSearchPageTitle } from "@/app/(main)/search/_components/advanced-search-page-title" +import { __advancedSearch_paramsAtom } from "@/app/(main)/search/_lib/advanced-search.atoms" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { SeaLink } from "@/components/shared/sea-link" +import { AppLayoutGrid } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { useSetAtom } from "jotai/react" +import { useSearchParams } from "next/navigation" +import React from "react" +import { AiOutlineArrowLeft } from "react-icons/ai" +import { useMount } from "react-use" + +export default function Page() { + + const urlParams = useSearchParams() + const sortingUrlParam = urlParams.get("sorting") + const genreUrlParam = urlParams.get("genre") + const statusUrlParam = urlParams.get("status") + const formatUrlParam = urlParams.get("format") + const seasonUrlParam = urlParams.get("season") + const yearUrlParam = urlParams.get("year") + const typeUrlParam = urlParams.get("type") + + const setParams = useSetAtom(__advancedSearch_paramsAtom) + + useMount(() => { + if (sortingUrlParam || genreUrlParam || statusUrlParam || formatUrlParam || seasonUrlParam || yearUrlParam || typeUrlParam) { + setParams({ + active: true, + title: null, + sorting: sortingUrlParam ? [sortingUrlParam as AL_MediaSort] : null, + status: statusUrlParam ? [statusUrlParam as AL_MediaStatus] : null, + genre: genreUrlParam ? [genreUrlParam] : null, + format: (formatUrlParam as AL_MediaFormat) === "MANGA" ? null : (formatUrlParam as AL_MediaFormat), + season: (seasonUrlParam as AL_MediaSeason) || null, + year: yearUrlParam || null, + minScore: null, + isAdult: false, + countryOfOrigin: null, + type: (formatUrlParam as AL_MediaFormat) === "MANGA" ? "manga" : (typeUrlParam as "anime" | "manga") || "anime", + }) + } + }) + + return ( + <> + <CustomLibraryBanner discrete /> + <PageWrapper data-search-page-container className="space-y-6 px-4 md:p-8 pt-0 pb-10"> + <div className="flex items-center gap-4"> + <SeaLink href={`/discover`}> + <Button leftIcon={<AiOutlineArrowLeft />} rounded intent="gray-outline" size="md"> + Discover + </Button> + </SeaLink> + {/*<h3>Discover</h3>*/} + </div> + <div data-search-page-title className="text-center xl:text-left"> + <AdvancedSearchPageTitle /> + </div> + <AppLayoutGrid cols={6} spacing="lg"> + <AdvancedSearchOptions /> + <div data-search-page-list className="col-span-5"> + <AdvancedSearchList /> + </div> + </AppLayoutGrid> + </PageWrapper> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/server-data-wrapper.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/server-data-wrapper.tsx new file mode 100644 index 0000000..1de255d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/server-data-wrapper.tsx @@ -0,0 +1,209 @@ +import { useGetStatus } from "@/api/hooks/status.hooks" +import { serverAuthTokenAtom } from "@/app/(main)/_atoms/server-status.atoms" +import { GettingStartedPage } from "@/app/(main)/_features/getting-started/getting-started-page" +import { useServerStatus, useSetServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { LoadingOverlayWithLogo } from "@/components/shared/loading-overlay-with-logo" +import { LuffyError } from "@/components/shared/luffy-error" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { defineSchema, Field, Form } from "@/components/ui/form" +import { logger } from "@/lib/helpers/debug" +import { ANILIST_OAUTH_URL, ANILIST_PIN_URL } from "@/lib/server/config" +import { WSEvents } from "@/lib/server/ws-events" +import { __isDesktop__ } from "@/types/constants" +import { useAtomValue } from "jotai" +import Link from "next/link" +import { usePathname, useRouter } from "next/navigation" +import React from "react" +import { useWebsocketMessageListener } from "./_hooks/handle-websockets" + +type ServerDataWrapperProps = { + host: string + children?: React.ReactNode +} + +export function ServerDataWrapper(props: ServerDataWrapperProps) { + + const { + host, + children, + ...rest + } = props + + const pathname = usePathname() + const router = useRouter() + const serverStatus = useServerStatus() + const setServerStatus = useSetServerStatus() + const password = useAtomValue(serverAuthTokenAtom) + const { data: _serverStatus, isLoading, refetch } = useGetStatus() + + React.useEffect(() => { + if (_serverStatus) { + // logger("SERVER").info("Server status", _serverStatus) + setServerStatus(_serverStatus) + } + }, [_serverStatus]) + + useWebsocketMessageListener({ + type: WSEvents.ANILIST_DATA_LOADED, + onMessage: () => { + logger("Data Wrapper").info("Anilist data loaded, refetching server status") + refetch() + }, + }) + + const [authenticated, setAuthenticated] = React.useState(false) + + React.useEffect(() => { + if (serverStatus) { + if (serverStatus?.serverHasPassword && !password && pathname !== "/public/auth") { + window.location.href = "/public/auth" + setAuthenticated(false) + console.warn("Redirecting to auth") + } else { + setAuthenticated(true) + } + } + }, [serverStatus?.serverHasPassword, password, pathname]) + + // Refetch the server status every 2 seconds if serverReady is false + // This is a fallback to the websocket + const intervalId = React.useRef<NodeJS.Timeout | null>(null) + React.useEffect(() => { + if (!serverStatus?.serverReady) { + intervalId.current = setInterval(() => { + logger("Data Wrapper").info("Refetching server status") + refetch() + }, 2000) + } + return () => { + logger("Data Wrapper").info("Clearing interval") + if (intervalId.current) { + clearInterval(intervalId.current) + intervalId.current = null + } + } + }, [serverStatus?.serverReady]) + + /** + * If the server status is loading or doesn't exist, show the loading overlay + */ + if (isLoading || !serverStatus || !authenticated) return <LoadingOverlayWithLogo /> + if (!serverStatus?.serverReady) return <LoadingOverlayWithLogo title="L o a d i n g" /> + + /** + * If the pathname is /auth/callback, show the callback page + */ + if (pathname.startsWith("/auth/callback")) return children + + /** + * If the server status doesn't have settings, show the getting started page + */ + if (!serverStatus?.settings) { + return <GettingStartedPage status={serverStatus} /> + } + + /** + * If the app is updating, show a different screen + */ + if (serverStatus?.updating) { + return <div className="container max-w-3xl py-10"> + <div className="mb-4 flex justify-center w-full"> + <img src="/logo_2.png" alt="logo" className="w-36 h-auto" /> + </div> + <p className="text-center text-lg"> + Seanime is currently updating. Refresh the page once the update is complete and the connection has been reestablished. + </p> + </div> + } + + /** + * Check feature flag routes + */ + + if (!serverStatus?.mediastreamSettings?.transcodeEnabled && pathname.startsWith("/mediastream")) { + return <LuffyError title="Transcoding not enabled" /> + } + + if (!serverStatus?.user && host === "127.0.0.1:43211" && !__isDesktop__) { + return <div className="container max-w-3xl py-10"> + <Card className="md:py-10"> + <AppLayoutStack> + <div className="text-center space-y-4"> + <div className="mb-4 flex justify-center w-full"> + <img src="/logo.png" alt="logo" className="w-24 h-auto" /> + </div> + <h3>Welcome!</h3> + <Button + onClick={() => { + const url = serverStatus?.anilistClientId + ? `https://anilist.co/api/v2/oauth/authorize?client_id=${serverStatus?.anilistClientId}&response_type=token` + : ANILIST_OAUTH_URL + window.open(url, "_self") + }} + leftIcon={<svg + xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" + viewBox="0 0 24 24" role="img" + > + <path + d="M6.361 2.943 0 21.056h4.942l1.077-3.133H11.4l1.052 3.133H22.9c.71 0 1.1-.392 1.1-1.101V17.53c0-.71-.39-1.101-1.1-1.101h-6.483V4.045c0-.71-.392-1.102-1.101-1.102h-2.422c-.71 0-1.101.392-1.101 1.102v1.064l-.758-2.166zm2.324 5.948 1.688 5.018H7.144z" + /> + </svg>} + intent="primary" + size="xl" + >Log in with AniList</Button> + </div> + </AppLayoutStack> + </Card> + </div> + } else if (!serverStatus?.user) { + return <div className="container max-w-3xl py-10"> + <Card className="md:py-10"> + <AppLayoutStack> + <div className="text-center space-y-4"> + <div className="mb-4 flex justify-center w-full"> + <img src="/logo.png" alt="logo" className="w-24 h-auto" /> + </div> + <h3>Welcome!</h3> + <Link + href={ANILIST_PIN_URL} + target="_blank" + > + <Button + leftIcon={<svg + xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" + viewBox="0 0 24 24" role="img" + > + <path + d="M6.361 2.943 0 21.056h4.942l1.077-3.133H11.4l1.052 3.133H22.9c.71 0 1.1-.392 1.1-1.101V17.53c0-.71-.39-1.101-1.1-1.101h-6.483V4.045c0-.71-.392-1.102-1.101-1.102h-2.422c-.71 0-1.101.392-1.101 1.102v1.064l-.758-2.166zm2.324 5.948 1.688 5.018H7.144z" + /> + </svg>} + intent="white" + size="md" + >Get AniList token</Button> + </Link> + + <Form + schema={defineSchema(({ z }) => z.object({ + token: z.string().min(1, "Token is required"), + }))} + onSubmit={data => { + router.push("/auth/callback#access_token=" + data.token.trim()) + }} + > + <Field.Textarea + name="token" + label="Enter the token" + fieldClass="px-4" + /> + <Field.Submit showLoadingOverlayOnSuccess>Continue</Field.Submit> + </Form> + </div> + </AppLayoutStack> + </Card> + </div> + } + + return children +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/mediaplayer-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/mediaplayer-settings.tsx new file mode 100644 index 0000000..4e3737c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/mediaplayer-settings.tsx @@ -0,0 +1,251 @@ +import { useExternalPlayerLink } from "@/app/(main)/_atoms/playback.atoms" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { SettingsCard, SettingsPageHeader } from "@/app/(main)/settings/_components/settings-card" +import { SettingsSubmitButton } from "@/app/(main)/settings/_components/settings-submit-button" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Alert } from "@/components/ui/alert" +import { Field } from "@/components/ui/form" +import { Switch } from "@/components/ui/switch" +import { TextInput } from "@/components/ui/text-input" +import { getDefaultIinaSocket, getDefaultMpvSocket } from "@/lib/server/settings" +import React from "react" +import { useWatch } from "react-hook-form" +import { FcClapperboard, FcVideoCall, FcVlc } from "react-icons/fc" +import { HiPlay } from "react-icons/hi" +import { IoPlayForwardCircleSharp } from "react-icons/io5" +import { LuExternalLink, LuLaptop } from "react-icons/lu" +import { RiSettings3Fill } from "react-icons/ri" + +type MediaplayerSettingsProps = { + isPending: boolean +} + +export function MediaplayerSettings(props: MediaplayerSettingsProps) { + + const { + isPending, + } = props + + const serverStatus = useServerStatus() + const selectedPlayer = useWatch({ name: "defaultPlayer" }) + + return ( + <> + <SettingsPageHeader + title="Desktop Media Player" + description="Seanime has built-in support for MPV, VLC, IINA, and MPC-HC." + icon={LuLaptop} + /> + + <SettingsCard> + <Field.Select + name="defaultPlayer" + label="Default player" + leftIcon={<FcVideoCall />} + options={[ + { label: "MPV", value: "mpv" }, + { label: "VLC", value: "vlc" }, + { label: "MPC-HC (Windows)", value: "mpc-hc" }, + { label: "IINA (macOS)", value: "iina" }, + ]} + help="Player that will be used to open files and track your progress automatically." + /> + {selectedPlayer === "iina" && <Alert + intent="info-basic" + description={<p>For IINA to work correctly with Seanime, make sure <strong>Quit after all windows are closed</strong> is <span + className="underline" + >checked</span> and <strong>Keep window open after playback finishes</strong> is <span className="underline">unchecked</span> in + your IINA general settings.</p>} + />} + </SettingsCard> + + <SettingsCard title="Playback"> + <Field.Switch + side="right" + name="autoPlayNextEpisode" + label="Automatically play next episode" + help="If enabled, Seanime will play the next episode after a delay when the current episode is completed." + /> + </SettingsCard> + + <SettingsCard title="Configuration"> + + + <Field.Text + name="mediaPlayerHost" + label="Host" + help="VLC/MPC-HC" + /> + + <Accordion + type="single" + className="" + triggerClass="text-[--muted] dark:data-[state=open]:text-white px-0 dark:hover:bg-transparent hover:bg-transparent dark:hover:text-white hover:text-black" + itemClass="" + contentClass="p-4 border rounded-[--radius-md]" + collapsible + defaultValue={serverStatus?.settings?.mediaPlayer?.defaultPlayer} + > + <AccordionItem value="vlc"> + <AccordionTrigger> + <h4 className="flex gap-2 items-center"><FcVlc /> VLC</h4> + </AccordionTrigger> + <AccordionContent className="space-y-4"> + <div className="flex flex-col md:flex-row gap-4"> + <Field.Text + name="vlcUsername" + label="Username" + /> + <Field.Text + name="vlcPassword" + label="Password" + /> + <Field.Number + name="vlcPort" + label="Port" + formatOptions={{ + useGrouping: false, + }} + hideControls + /> + </div> + <Field.Text + name="vlcPath" + label="Application path" + /> + </AccordionContent> + </AccordionItem> + + <AccordionItem value="mpc-hc"> + <AccordionTrigger> + <h4 className="flex gap-2 items-center"><FcClapperboard /> MPC-HC</h4> + </AccordionTrigger> + <AccordionContent> + <div className="flex flex-col md:flex-row gap-4"> + <Field.Number + name="mpcPort" + label="Port" + formatOptions={{ + useGrouping: false, + }} + hideControls + /> + <Field.Text + name="mpcPath" + label="Application path" + /> + </div> + </AccordionContent> + </AccordionItem> + + <AccordionItem value="mpv"> + <AccordionTrigger> + <h4 className="flex gap-2 items-center"><HiPlay className="mr-1 text-purple-100" /> MPV</h4> + </AccordionTrigger> + <AccordionContent> + <div className="flex gap-4"> + <Field.Text + name="mpvSocket" + label="Socket" + placeholder={`Default: '${getDefaultMpvSocket(serverStatus?.os ?? "")}'`} + /> + <Field.Text + name="mpvPath" + label="Application path" + placeholder={serverStatus?.os === "windows" ? "e.g. C:/Program Files/mpv/mpv.exe" : serverStatus?.os === "darwin" + ? "e.g. /Applications/mpv.app/Contents/MacOS/mpv" + : "Defaults to CLI"} + help="Leave empty to use the CLI." + /> + </div> + <div> + <Field.Text + name="mpvArgs" + label="Options" + placeholder="e.g. --no-config --mute=yes" + /> + </div> + </AccordionContent> + </AccordionItem> + + <AccordionItem value="iina"> + <AccordionTrigger> + <h4 className="flex gap-2 items-center"><IoPlayForwardCircleSharp className="mr-1 text-purple-100" /> IINA</h4> + </AccordionTrigger> + <AccordionContent> + <div className="flex gap-4"> + <Field.Text + name="iinaSocket" + label="Socket" + placeholder={`Default: '${getDefaultIinaSocket(serverStatus?.os ?? "")}'`} + /> + <Field.Text + name="iinaPath" + label="CLI path" + placeholder={"Path to the IINA CLI"} + help="Leave empty to use the CLI." + /> + </div> + <div> + <Field.Text + name="iinaArgs" + label="Options" + placeholder="e.g. --mpv-mute=yes" + /> + </div> + </AccordionContent> + </AccordionItem> + </Accordion> + </SettingsCard> + + <SettingsSubmitButton isPending={isPending} /> + + </> + ) +} + +export function ExternalPlayerLinkSettings() { + + const { externalPlayerLink, setExternalPlayerLink, encodePath, setEncodePath } = useExternalPlayerLink() + + return ( + <> + <SettingsPageHeader + title="External player link" + description="Send streams to an external player on this device." + icon={LuExternalLink} + /> + + <Alert + intent="info" description={<> + Only applies to this device. + </>} + /> + + <SettingsCard> + <TextInput + label="Custom scheme" + placeholder="Example: outplayer://{url}" + value={externalPlayerLink} + onValueChange={setExternalPlayerLink} + /> + </SettingsCard> + + <SettingsCard> + <Switch + side="right" + name="encodePath" + label="Encode file path in URL (library only)" + help="If enabled, the file path will be base64 encoded in the URL to avoid issues with special characters." + value={encodePath} + onValueChange={setEncodePath} + /> + </SettingsCard> + + <div className="flex items-center gap-2 text-sm text-gray-500 bg-gray-50 dark:bg-gray-900/30 rounded-lg p-3 border border-gray-200 dark:border-gray-800 border-dashed"> + <RiSettings3Fill className="text-base" /> + <span>Settings are saved automatically</span> + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/playback-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/playback-settings.tsx new file mode 100644 index 0000000..6aea48f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/playback-settings.tsx @@ -0,0 +1,296 @@ +import { + ElectronPlaybackMethod, + PlaybackDownloadedMedia, + PlaybackTorrentStreaming, + useCurrentDevicePlaybackSettings, + useExternalPlayerLink, +} from "@/app/(main)/_atoms/playback.atoms" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useMediastreamActiveOnDevice } from "@/app/(main)/mediastream/_lib/mediastream.atoms" +import { SettingsCard, SettingsPageHeader } from "@/app/(main)/settings/_components/settings-card" +import { __settings_tabAtom } from "@/app/(main)/settings/_components/settings-page.atoms" +import { Alert } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Switch } from "@/components/ui/switch" +import { __isElectronDesktop__ } from "@/types/constants" +import { useSetAtom } from "jotai" +import React from "react" +import { BiDesktop, BiPlay } from "react-icons/bi" +import { IoPlayBackCircleSharp } from "react-icons/io5" +import { LuClapperboard, LuExternalLink, LuLaptop } from "react-icons/lu" +import { MdOutlineBroadcastOnHome } from "react-icons/md" +import { RiSettings3Fill } from "react-icons/ri" +import { toast } from "sonner" + +type PlaybackSettingsProps = { + children?: React.ReactNode +} + +export function PlaybackSettings(props: PlaybackSettingsProps) { + + const { + children, + ...rest + } = props + + const serverStatus = useServerStatus() + + const { + downloadedMediaPlayback, + setDownloadedMediaPlayback, + torrentStreamingPlayback, + setTorrentStreamingPlayback, + electronPlaybackMethod, + setElectronPlaybackMethod, + } = useCurrentDevicePlaybackSettings() + + const { activeOnDevice, setActiveOnDevice } = useMediastreamActiveOnDevice() + const { externalPlayerLink } = useExternalPlayerLink() + const setTab = useSetAtom(__settings_tabAtom) + + const usingNativePlayer = __isElectronDesktop__ && electronPlaybackMethod === ElectronPlaybackMethod.NativePlayer + + return ( + <> + <div className="space-y-4"> + <SettingsPageHeader + title="Video playback" + description="Choose how anime is played on this device" + icon={IoPlayBackCircleSharp} + /> + + <div className="flex items-center gap-2 text-sm bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 border border-gray-200 dark:border-gray-800"> + <BiDesktop className="text-lg text-gray-500" /> + <span className="text-gray-600 dark:text-gray-400">Device:</span> + <span className="font-medium">{serverStatus?.clientDevice || "-"}</span> + <span className="text-gray-400">•</span> + <span className="font-medium">{serverStatus?.clientPlatform || "-"}</span> + </div> + </div> + + {(!externalPlayerLink && (downloadedMediaPlayback === PlaybackDownloadedMedia.ExternalPlayerLink || torrentStreamingPlayback === PlaybackTorrentStreaming.ExternalPlayerLink)) && ( + <Alert + intent="alert-basic" + description={ + <div className="flex items-center justify-between gap-3"> + <span>No external player custom scheme has been set</span> + <Button + intent="gray-outline" + size="sm" + onClick={() => setTab("external-player-link")} + > + Add + </Button> + </div> + } + /> + )} + + {__isElectronDesktop__ && ( + <SettingsCard + title="Seanime Denshi" + className="border-2 border-dashed dark:border-gray-700 bg-gradient-to-r from-indigo-50/50 to-pink-50/50 dark:from-gray-900/20 dark:to-gray-900/20" + > + <div className="space-y-4"> + + <div className="flex items-center gap-4"> + <div className="p-3 rounded-lg bg-gradient-to-br from-indigo-500/20 to-indigo-500/20 border border-indigo-500/20"> + <LuClapperboard className="text-2xl text-indigo-600 dark:text-indigo-400" /> + </div> + <div className="flex-1"> + <Switch + label="Use built-in player" + help="When enabled, all media will use the built-in player (overrides settings below)" + value={electronPlaybackMethod === ElectronPlaybackMethod.NativePlayer} + onValueChange={v => { + setElectronPlaybackMethod(v ? ElectronPlaybackMethod.NativePlayer : ElectronPlaybackMethod.Default) + toast.success("Playback settings updated") + }} + /> + </div> + </div> + </div> + </SettingsCard> + )} + + <SettingsCard + title="Downloaded Media" + description="Choose how to play anime files stored on your device" + className={cn( + "transition-all duration-200", + usingNativePlayer && "opacity-50 pointer-events-none", + )} + > + <div className="space-y-4"> + + {/* Option Comparison */} + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> + {/* Desktop Player Option */} + <div + className={cn( + "p-4 rounded-lg border cursor-pointer transition-all", + downloadedMediaPlayback === PlaybackDownloadedMedia.Default && !activeOnDevice + ? "border-[--brand] bg-brand-900/10" + : "border-gray-700 hover:border-gray-600", + )} + onClick={() => { + setDownloadedMediaPlayback(PlaybackDownloadedMedia.Default) + setActiveOnDevice(false) + toast.success("Playback settings updated") + }} + > + <div className="flex items-start gap-3"> + <LuLaptop className="text-xl text-brand-600 dark:text-brand-400 mt-1" /> + <div className="flex-1 space-y-2"> + <div> + <p className="font-medium">Desktop Media Player</p> + <p className="text-xs text-gray-600 dark:text-gray-400">Opens files in your system player with automatic + tracking</p> + </div> + </div> + </div> + </div> + + {/* Web Player Option */} + <div + className={cn( + "p-4 rounded-lg border cursor-pointer transition-all", + downloadedMediaPlayback === PlaybackDownloadedMedia.Default && activeOnDevice + ? "border-[--brand] bg-brand-900/10" + : "border-gray-700 hover:border-gray-600", + !serverStatus?.mediastreamSettings?.transcodeEnabled && "opacity-50", + )} + onClick={() => { + if (serverStatus?.mediastreamSettings?.transcodeEnabled) { + setDownloadedMediaPlayback(PlaybackDownloadedMedia.Default) + setActiveOnDevice(true) + toast.success("Playback settings updated") + } + }} + > + <div className="flex items-start gap-3"> + <MdOutlineBroadcastOnHome className="text-xl text-brand-600 dark:text-brand-400 mt-1" /> + <div className="flex-1 space-y-2"> + <div> + <p className="font-medium">Transcoding / Direct Play</p> + <p className="text-xs text-gray-600 dark:text-gray-400"> + {serverStatus?.mediastreamSettings?.transcodeEnabled + ? "Plays in browser with transcoding" + : "Transcoding not enabled" + } + </p> + </div> + </div> + </div> + </div> + + {/* External Player Option */} + <div + className={cn( + "p-4 rounded-lg border cursor-pointer transition-all", + downloadedMediaPlayback === PlaybackDownloadedMedia.ExternalPlayerLink + ? "border-[--brand] bg-brand-900/10" + : "border-gray-700 hover:border-gray-600", + )} + onClick={() => { + setDownloadedMediaPlayback(PlaybackDownloadedMedia.ExternalPlayerLink) + toast.success("Playback settings updated") + }} + > + <div className="flex items-start gap-3"> + <LuExternalLink className="text-xl text-brand-600 dark:text-brand-400 mt-1" /> + <div className="flex-1 space-y-2"> + <div> + <p className="font-medium">External Player Link</p> + <p className="text-xs text-gray-600 dark:text-gray-400">Send stream URL to another application</p> + </div> + </div> + </div> + </div> + </div> + </div> + </SettingsCard> + + <SettingsCard + title="Torrent & Debrid Streaming" + description="Choose how to play streamed content from torrents and debrid services" + className={cn( + "transition-all duration-200", + usingNativePlayer && "opacity-50 pointer-events-none", + )} + > + <div className="space-y-4"> + + {/* Option Comparison */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* Desktop Player Option */} + <div + className={cn( + "p-4 rounded-lg border cursor-pointer transition-all", + torrentStreamingPlayback === PlaybackTorrentStreaming.Default + ? "border-[--brand] bg-brand-900/10" + : "border-gray-700 hover:border-gray-600", + )} + onClick={() => { + setTorrentStreamingPlayback(PlaybackTorrentStreaming.Default) + toast.success("Playback settings updated") + }} + > + <div className="flex items-start gap-3"> + <LuLaptop className="text-xl text-brand-600 dark:text-brand-400 mt-1" /> + <div className="flex-1 space-y-2"> + <div> + <p className="font-medium">Desktop Media Player</p> + <p className="text-xs text-gray-600 dark:text-gray-400">Opens streams in your system player with automatic + tracking</p> + </div> + </div> + </div> + </div> + + {/* External Player Option */} + <div + className={cn( + "p-4 rounded-lg border cursor-pointer transition-all", + torrentStreamingPlayback === PlaybackTorrentStreaming.ExternalPlayerLink + ? "border-[--brand] bg-brand-900/10" + : "border-gray-700 hover:border-gray-600", + )} + onClick={() => { + setTorrentStreamingPlayback(PlaybackTorrentStreaming.ExternalPlayerLink) + toast.success("Playback settings updated") + }} + > + <div className="flex items-start gap-3"> + <LuExternalLink className="text-xl text-brand-600 dark:text-brand-400 mt-1" /> + <div className="flex-1 space-y-2"> + <div> + <p className="font-medium">External Player Link</p> + <p className="text-xs text-gray-600 dark:text-gray-400">Send stream URL to another application</p> + </div> + </div> + </div> + </div> + </div> + </div> + </SettingsCard> + + <div className="flex items-center gap-2 text-sm text-gray-500 bg-gray-50 dark:bg-gray-900/30 rounded-lg p-3 border border-gray-200 dark:border-gray-800 border-dashed"> + <RiSettings3Fill className="text-base" /> + <span>Settings are saved automatically</span> + </div> + + {usingNativePlayer && ( + <div className="text-center"> + <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-indigo-100 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-700"> + <BiPlay className="text-indigo-600 dark:text-indigo-400" /> + <span className="text-sm text-indigo-600 dark:text-indigo-400 font-medium"> + Native player is active - other settings are disabled + </span> + </div> + </div> + )} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/settings-card.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/settings-card.tsx new file mode 100644 index 0000000..bf75cdb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/settings-card.tsx @@ -0,0 +1,123 @@ +import { GlowingEffect } from "@/components/shared/glowing-effect" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/components/ui/core/styling" +import React, { useRef, useState } from "react" + +type SettingsCardProps = { + title?: string + description?: string + children: React.ReactNode +} + +export function SettingsNavCard({ title, children }: SettingsCardProps) { + const [position, setPosition] = useState({ x: 0, y: 0 }) + const cardRef = useRef<HTMLDivElement>(null) + + const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => { + if (!cardRef.current) return + const rect = cardRef.current.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + setPosition({ x, y }) + } + + return ( + <div className="pb-4"> + <div + ref={cardRef} + onMouseMove={handleMouseMove} + className="lg:p-2 lg:border lg:rounded-[--radius] lg:bg-gray-950/70 contents lg:block relative group/settings-nav" + // className=" contents lg:block relative group/settings-nav overflow-hidden" + > + <GlowingEffect + spread={40} + glow={true} + disabled={false} + proximity={100} + inactiveZone={0.01} + className="opacity-25" + /> + {/* <div + className="pointer-events-none absolute -inset-px transition-opacity duration-300 opacity-0 group-hover/settings-nav:opacity-100 hidden lg:block" + style={{ + background: `radial-gradient(250px circle at ${position.x}px ${position.y}px, rgb(255 255 255 / 0.025), transparent 40%)`, + }} + /> */} + {children} + </div> + </div> + ) +} + +export function SettingsCard({ title, description, children, className }: SettingsCardProps & { className?: string }) { + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [isHovered, setIsHovered] = useState(false) + const cardRef = useRef<HTMLDivElement>(null) + + const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => { + if (!cardRef.current) return + const rect = cardRef.current.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + setPosition({ x, y }) + } + + return ( + <> + <Card + ref={cardRef} + className={cn("group/settings-card relative lg:bg-gray-950/70", className)} + onMouseMove={handleMouseMove} + > + <GlowingEffect + blur={1} + spread={20} + glow={true} + disabled={false} + proximity={100} + inactiveZone={0.01} + className="opacity-25" + /> + {/* <div + className="pointer-events-none absolute -inset-px transition-opacity duration-300 opacity-0 group-hover/settings-card:opacity-100" + style={{ + background: `radial-gradient(700px circle at ${position.x}px ${position.y}px, rgb(255 255 255 / 0.025), transparent 40%)`, + }} + /> */} + {title && <CardHeader className="p-0 pb-4 flex flex-row items-center gap-0"> + {/* <CardTitle className="font-semibold tracking-wide text-base transition-colors duration-300 group-hover/settings-card:text-white bg-gradient-to-br group-hover/settings-card:from-brand-500/10 group-hover/settings-card:to-purple-500/5 px-4 py-2 bg-[--subtle] w-fit rounded-tl-md rounded-br-md "> + {title} + </CardTitle> */} + <CardTitle className="font-bold tracking-widest uppercase text-sm transition-colors duration-300 group-hover/settings-card:text-white group-hover/settings-card:from-brand-500/10 group-hover/settings-card:to-purple-500/5 px-4 py-2 border bg-transparent bg-gradient-to-br bg-[--subtle] border-t-0 border-l-0 w-fit rounded-tl-md rounded-br-md "> + {title} + </CardTitle> + {description && <CardDescription className="px-4 w-fit"> + {description} + </CardDescription>} + </CardHeader>} + <CardContent + className={cn( + !title && "pt-4", + "space-y-3 flex-wrap", + )} + > + {children} + </CardContent> + </Card> + </> + ) +} + +export function SettingsPageHeader({ title, description, icon: Icon }: { title: string, description: string, icon: React.ElementType }) { + return ( + <div className="flex items-center gap-3"> + <div className="p-2 rounded-lg bg-gradient-to-br from-brand-500/20 to-purple-500/20 border border-brand-500/20"> + <Icon className="text-2xl text-brand-600 dark:text-brand-400" /> + </div> + <div> + <h3 className="text-xl font-semibold">{title}</h3> + <p className="text-base text-[--muted]">{description}</p> + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/settings-page.atoms.ts b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/settings-page.atoms.ts new file mode 100644 index 0000000..d018e08 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/settings-page.atoms.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai/index" + +export const __settings_tabAtom = atom<string>("seanime") diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/settings-submit-button.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/settings-submit-button.tsx new file mode 100644 index 0000000..2b5ed5e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_components/settings-submit-button.tsx @@ -0,0 +1,80 @@ +import { Alert } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Field } from "@/components/ui/form" +import { atom, useSetAtom } from "jotai" +import React from "react" +import { useFormContext, useFormState } from "react-hook-form" +import { FiRotateCcw, FiSave } from "react-icons/fi" + +export const settingsFormIsDirtyAtom = atom(false) + +export function SettingsSubmitButton({ isPending }: { isPending: boolean }) { + + const { isDirty } = useFormState() + + const setSettingsFormIsDirty = useSetAtom(settingsFormIsDirtyAtom) + + React.useEffect(() => { + setSettingsFormIsDirty(isDirty) + }, [isDirty]) + + return ( + <> + <Field.Submit + role="save" + size="md" + className={cn( + "text-md transition-all group", + isDirty && "animate-pulse", + )} + intent="white" + rounded + loading={isPending} + leftIcon={<FiSave className="transition-transform duration-200 group-hover:scale-110" />} + > + Save + </Field.Submit> + </> + ) +} + +export function SettingsIsDirty({ className }: { className?: string }) { + const { isDirty, isLoading, isSubmitting, isValidating } = useFormState() + const { reset } = useFormContext() + return isDirty ? <Alert + intent="info" + className={cn( + "absolute -top-4 right-0 p-3 !mt-0 hidden lg:block animate-in slide-in-from-top-2 duration-300", + className, + )} + > + <div className="flex items-center gap-2"> + <span className="text-sm">You have unsaved changes.</span> + <Button + role="save" + size="md" + className={cn( + "text-md text-[--muted] py-0 h-6 px-2 transition-all duration-200 hover:scale-105 group", + )} + intent="white-link" + onClick={() => reset()} + leftIcon={<FiRotateCcw className="transition-transform duration-200 group-hover:rotate-180" />} + > + Reset + </Button> + <Field.Submit + role="save" + size="md" + className={cn( + "text-md py-0 h-6 px-2 transition-all duration-200 hover:scale-105 group", + )} + intent="white-link" + disabled={isLoading || isSubmitting || isValidating} + leftIcon={<FiSave className="transition-transform duration-200 group-hover:scale-110" />} + > + Save + </Field.Submit> + </div> + </Alert> : null +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/anilist-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/anilist-settings.tsx new file mode 100644 index 0000000..7d4d8fc --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/anilist-settings.tsx @@ -0,0 +1,53 @@ +import { useLocalSyncSimulatedDataToAnilist } from "@/api/hooks/local.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { SettingsCard, SettingsPageHeader } from "@/app/(main)/settings/_components/settings-card" +import { SettingsSubmitButton } from "@/app/(main)/settings/_components/settings-submit-button" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { Field } from "@/components/ui/form" +import React from "react" +import { SiAnilist } from "react-icons/si" + +type Props = { + isPending: boolean + children?: React.ReactNode +} + +export function AnilistSettings(props: Props) { + + const { + isPending, + children, + ...rest + } = props + + const serverStatus = useServerStatus() + + const { mutate: upload, isPending: isUploading } = useLocalSyncSimulatedDataToAnilist() + + const confirmDialog = useConfirmationDialog({ + title: "Upload to AniList", + description: "This will upload your local Seanime collection to your AniList account. Are you sure you want to proceed?", + actionText: "Upload", + actionIntent: "primary", + onConfirm: async () => { + upload() + }, + }) + + return ( + <div className="space-y-4"> + + <SettingsPageHeader + title="AniList" + description="Manage your AniList account" + icon={SiAnilist} + /> + + + <SettingsSubmitButton isPending={isPending} /> + + <ConfirmationDialog {...confirmDialog} /> + + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/data-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/data-settings.tsx new file mode 100644 index 0000000..59bcef3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/data-settings.tsx @@ -0,0 +1,108 @@ +import { getServerBaseUrl } from "@/api/client/server-url" +import { useImportLocalFiles } from "@/api/hooks/localfiles.hooks" +import { useServerHMACAuth } from "@/app/(main)/_hooks/use-server-status" +import { Button } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { TextInput } from "@/components/ui/text-input" +import { openTab } from "@/lib/helpers/browser" +import React from "react" +import { CgImport } from "react-icons/cg" +import { TbDatabaseExport } from "react-icons/tb" +import { toast } from "sonner" + +type DataSettingsProps = { + children?: React.ReactNode +} + +export function DataSettings(props: DataSettingsProps) { + + const { + children, + ...rest + } = props + + const { mutate: importLocalFiles, isPending: isImportingLocalFiles } = useImportLocalFiles() + const [localFileDataPath, setLocalFileDataPath] = React.useState("") + + function handleImportLocalFiles() { + if (!localFileDataPath) return + + importLocalFiles({ dataFilePath: localFileDataPath }, { + onSuccess: () => { + setLocalFileDataPath("") + }, + }) + } + + const { getHMACTokenQueryParam } = useServerHMACAuth() + + const handleExportLocalFiles = React.useCallback(async () => { + try { + const endpoint = "/api/v1/library/local-files/dump" + const tokenQuery = await getHMACTokenQueryParam(endpoint) + openTab(`${getServerBaseUrl()}${endpoint}${tokenQuery}`) + } + catch (error) { + toast.error("Failed to generate export token") + } + }, [getHMACTokenQueryParam]) + + return ( + <div className="space-y-4"> + + <div> + <h5>Local files</h5> + + <p className="text-[--muted]"> + Scanned local file data. + </p> + </div> + + <div className="flex flex-wrap gap-2"> + <Button + intent="primary-subtle" + leftIcon={<TbDatabaseExport className="text-xl" />} + size="md" + disabled={isImportingLocalFiles} + onClick={handleExportLocalFiles} + > + Export local file data + </Button> + + <Modal + title="Import local files" + trigger={ + <Button + intent="white-subtle" + leftIcon={<CgImport className="text-xl" />} + size="md" + disabled={isImportingLocalFiles} + > + Import local files + </Button> + } + > + + <p> + This will overwrite your existing library data, make sure you have a backup. + </p> + + <TextInput + label="Data file path" + help="The path to the JSON file containing the local file data." + value={localFileDataPath} + onValueChange={setLocalFileDataPath} + /> + + <Button + intent="white" + rounded + onClick={handleImportLocalFiles} + disabled={isImportingLocalFiles} + >Import</Button> + + </Modal> + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/debrid-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/debrid-settings.tsx new file mode 100644 index 0000000..97f4c3d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/debrid-settings.tsx @@ -0,0 +1,167 @@ +import { useGetDebridSettings, useSaveDebridSettings } from "@/api/hooks/debrid.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { SettingsCard } from "@/app/(main)/settings/_components/settings-card" +import { SettingsIsDirty, SettingsSubmitButton } from "@/app/(main)/settings/_components/settings-submit-button" +import { SeaLink } from "@/components/shared/sea-link" +import { Alert } from "@/components/ui/alert" +import { defineSchema, Field, Form } from "@/components/ui/form" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import React from "react" +import { UseFormReturn } from "react-hook-form" + +const debridSettingsSchema = defineSchema(({ z }) => z.object({ + enabled: z.boolean().default(false), + provider: z.string().default(""), + apiKey: z.string().optional().default(""), + includeDebridStreamInLibrary: z.boolean().default(false), + streamAutoSelect: z.boolean().default(false), + streamPreferredResolution: z.string(), +})) + +type DebridSettingsProps = { + children?: React.ReactNode +} + +export function DebridSettings(props: DebridSettingsProps) { + + const { + children, + ...rest + } = props + + const serverStatus = useServerStatus() + const { data: settings, isLoading } = useGetDebridSettings() + const { mutate, isPending } = useSaveDebridSettings() + + const formRef = React.useRef<UseFormReturn<any>>(null) + + if (isLoading) return <LoadingSpinner /> + + return ( + <div className="space-y-4"> + + <Form + schema={debridSettingsSchema} + mRef={formRef} + onSubmit={data => { + if (settings) { + mutate({ + settings: { + ...settings, + ...data, + provider: data.provider === "-" ? "" : data.provider, + streamPreferredResolution: data.streamPreferredResolution === "-" ? "" : data.streamPreferredResolution, + }, + }, + { + onSuccess: () => { + formRef.current?.reset(formRef.current.getValues()) + }, + }, + ) + } + }} + defaultValues={{ + enabled: settings?.enabled, + provider: settings?.provider || "-", + apiKey: settings?.apiKey, + includeDebridStreamInLibrary: settings?.includeDebridStreamInLibrary, + streamAutoSelect: settings?.streamAutoSelect ?? false, + streamPreferredResolution: settings?.streamPreferredResolution || "-", + }} + stackClass="space-y-4" + > + {(f) => ( + <> + <SettingsIsDirty /> + <SettingsCard> + <Field.Switch + side="right" + name="enabled" + label="Enable" + /> + {(f.watch("enabled") && serverStatus?.settings?.autoDownloader?.enabled && !serverStatus?.settings?.autoDownloader?.useDebrid) && ( + <Alert + intent="info" + title="Auto Downloader not using Debrid" + description={<p> + Auto Downloader is enabled but not using Debrid. Change the <SeaLink + href="/auto-downloader" + className="underline" + >Auto Downloader settings</SeaLink> to use your Debrid service. + </p>} + /> + )} + </SettingsCard> + + + <SettingsCard> + <Field.Select + options={[ + { label: "None", value: "-" }, + { label: "TorBox", value: "torbox" }, + { label: "Real-Debrid", value: "realdebrid" }, + ]} + name="provider" + label="Provider" + /> + + <Field.Text + name="apiKey" + label="API Key" + /> + </SettingsCard> + + <h3> + Debrid Streaming + </h3> + + <SettingsCard title="My library"> + <Field.Switch + side="right" + name="includeDebridStreamInLibrary" + label="Include in library" + help="Add non-downloaded shows that are in your currently watching list to 'My library' for streaming" + /> + </SettingsCard> + + <SettingsCard title="Auto-select"> + <Field.Switch + side="right" + name="streamAutoSelect" + label="Enable" + help="Let Seanime find the best torrent automatically, based on cache and resolution." + /> + + {/*{f.watch("streamAutoSelect") && f.watch("provider") === "torbox" && (*/} + {/* <Alert*/} + {/* intent="warning-basic"*/} + {/* title="Auto-select with TorBox"*/} + {/* description={<p>*/} + {/* Avoid using auto-select if you have a limited amount of downloads on your Debrid service.*/} + {/* </p>}*/} + {/* />*/} + {/*)}*/} + + <Field.Select + name="streamPreferredResolution" + label="Preferred resolution" + help="If auto-select is enabled, Seanime will try to find torrents with this resolution." + options={[ + { label: "Highest", value: "-" }, + { label: "480p", value: "480" }, + { label: "720p", value: "720" }, + { label: "1080p", value: "1080" }, + ]} + /> + </SettingsCard> + + + <SettingsSubmitButton isPending={isPending} /> + </> + )} + </Form> + + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/discord-rich-presence-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/discord-rich-presence-settings.tsx new file mode 100644 index 0000000..c3bcd42 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/discord-rich-presence-settings.tsx @@ -0,0 +1,78 @@ +import { SettingsCard } from "@/app/(main)/settings/_components/settings-card" +import { cn } from "@/components/ui/core/styling" +import { Field } from "@/components/ui/form" +import React from "react" +import { useFormContext } from "react-hook-form" + +type DiscordRichPresenceSettingsProps = { + children?: React.ReactNode +} + +export function DiscordRichPresenceSettings(props: DiscordRichPresenceSettingsProps) { + + const { + children, + ...rest + } = props + + const { watch } = useFormContext() + + const enableRichPresence = watch("enableRichPresence") + + return ( + <> + <SettingsCard title="Rich Presence" description="Show what you are watching or reading in Discord."> + <Field.Switch + side="right" + name="enableRichPresence" + label={<span className="flex gap-1 items-center">Enable</span>} + /> + <div + className={cn( + "flex gap-4 items-center flex-col md:flex-row !mt-3", + enableRichPresence ? "opacity-100" : "opacity-50 pointer-events-none", + )} + > + <Field.Checkbox + name="enableAnimeRichPresence" + label="Anime" + fieldClass="w-fit" + /> + <Field.Checkbox + name="enableMangaRichPresence" + label="Manga" + fieldClass="w-fit" + /> + </div> + + <Field.Switch + side="right" + name="richPresenceHideSeanimeRepositoryButton" + label="Hide Seanime Repository Button" + /> + + {/*<Field.Switch*/} + {/* side="right"*/} + {/* name="richPresenceShowAniListMediaButton"*/} + {/* label="Show AniList Media Button"*/} + {/* help="Show a button to open the media page on AniList."*/} + {/*/>*/} + + <Field.Switch + side="right" + name="richPresenceShowAniListProfileButton" + label="Show AniList Profile Button" + help="Show a button to open your profile page on AniList." + /> + + {/*<Field.Switch*/} + {/* side="right"*/} + {/* name="richPresenceUseMediaTitleStatus"*/} + {/* label={<span className="flex gap-2 items-center">Use Media Title as Status <LuTriangleAlert className="text-[--orange]" /></span>}*/} + {/* moreHelp="Does not work with the default Discord Desktop Client."*/} + {/* help="Replace 'Seanime' with the media title in the activity status. Only works if you use a discord client that utilizes arRPC."*/} + {/*/>*/} + </SettingsCard> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/filecache-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/filecache-settings.tsx new file mode 100644 index 0000000..d67b068 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/filecache-settings.tsx @@ -0,0 +1,66 @@ +import { useClearFileCacheMediastreamVideoFiles, useGetFileCacheTotalSize, useRemoveFileCacheBucket } from "@/api/hooks/filecache.hooks" +import { Button } from "@/components/ui/button" +import React from "react" +import { SettingsCard } from "../_components/settings-card" + +type FilecacheSettingsProps = { + children?: React.ReactNode +} + +export function FilecacheSettings(props: FilecacheSettingsProps) { + + const { + children, + ...rest + } = props + + + const { data: totalSize, mutate: getTotalSize, isPending: isFetchingSize } = useGetFileCacheTotalSize() + + const { mutate: clearBucket, isPending: _isClearing } = useRemoveFileCacheBucket(() => { + getTotalSize() + }) + + const { mutate: clearMediastreamCache, isPending: _isClearing2 } = useClearFileCacheMediastreamVideoFiles(() => { + getTotalSize() + }) + + const isClearing = _isClearing || _isClearing2 + + return ( + <div className="space-y-4"> + <div className="flex gap-2 items-center"> + <Button intent="white-subtle" size="sm" onClick={() => getTotalSize()} disabled={isFetchingSize}> + Show total size + </Button> + {!!totalSize && ( + <p> + {totalSize} + </p> + )} + </div> + + <SettingsCard title="Features"> + <div className="flex gap-2 flex-wrap items-center"> + <Button intent="warning-subtle" onClick={() => clearBucket({ bucket: "manga" })} disabled={isClearing}> + Clear manga cache + </Button> + <Button intent="warning-subtle" onClick={() => clearMediastreamCache()} disabled={isClearing}> + Clear media streaming cache + </Button> + <Button intent="warning-subtle" onClick={() => clearBucket({ bucket: "onlinestream" })} disabled={isClearing}> + Clear online streaming cache + </Button> + </div> + </SettingsCard> + + + <SettingsCard title="TVDB" description="Episode image metadata fetched from TVDB."> + <Button intent="alert-subtle" onClick={() => clearBucket({ bucket: "tvdb" })} disabled={isClearing}> + Clear metadata + </Button> + </SettingsCard> + + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/library-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/library-settings.tsx new file mode 100644 index 0000000..da10531 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/library-settings.tsx @@ -0,0 +1,114 @@ +import { SettingsCard } from "@/app/(main)/settings/_components/settings-card" +import { SettingsSubmitButton } from "@/app/(main)/settings/_components/settings-submit-button" +import { DataSettings } from "@/app/(main)/settings/_containers/data-settings" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Field } from "@/components/ui/form" +import { Separator } from "@/components/ui/separator" +import React from "react" +import { FcFolder } from "react-icons/fc" + +type LibrarySettingsProps = { + isPending: boolean +} + +export function LibrarySettings(props: LibrarySettingsProps) { + + const { + isPending, + ...rest + } = props + + + return ( + <div className="space-y-4"> + + <SettingsCard> + <Field.DirectorySelector + name="libraryPath" + label="Library directory" + leftIcon={<FcFolder />} + help="Path of the directory where your media files ared located. (Keep the casing consistent)" + shouldExist + /> + + <Field.MultiDirectorySelector + name="libraryPaths" + label="Additional library directories" + leftIcon={<FcFolder />} + help="Include additional directory paths if your library is spread across multiple locations." + shouldExist + /> + </SettingsCard> + + <SettingsCard> + + <Field.Switch + side="right" + name="autoScan" + label="Automatically refresh library" + moreHelp={<p> + When adding batches, not all files are guaranteed to be picked up. + </p>} + /> + + <Field.Switch + side="right" + name="refreshLibraryOnStart" + label="Refresh library on startup" + /> + </SettingsCard> + + {/*<SettingsCard title="Advanced">*/} + + <Accordion + type="single" + collapsible + className="border rounded-[--radius-md]" + triggerClass="dark:bg-[--paper]" + contentClass="!pt-2 dark:bg-[--paper]" + > + <AccordionItem value="more"> + <AccordionTrigger className="bg-gray-900 rounded-[--radius-md]"> + Advanced + </AccordionTrigger> + <AccordionContent className="space-y-4"> + <div className="flex flex-col md:flex-row gap-3"> + + <Field.Select + options={[ + { value: "-", label: "Levenshtein + Sorensen-Dice (Default)" }, + { value: "sorensen-dice", label: "Sorensen-Dice" }, + { value: "jaccard", label: "Jaccard" }, + ]} + name="scannerMatchingAlgorithm" + label="Matching algorithm" + help="Choose the algorithm used to match files to AniList entries." + /> + <Field.Number + name="scannerMatchingThreshold" + label="Matching threshold" + placeholder="0.5" + help="The minimum score required for a file to be matched to an AniList entry. Default is 0.5." + formatOptions={{ + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }} + max={1.0} + step={0.1} + /> + </div> + + <Separator /> + + <DataSettings /> + </AccordionContent> + </AccordionItem> + </Accordion> + + {/*</SettingsCard>*/} + + <SettingsSubmitButton isPending={isPending} /> + + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/local-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/local-settings.tsx new file mode 100644 index 0000000..5f086f9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/local-settings.tsx @@ -0,0 +1,82 @@ +import { useLocalSyncSimulatedDataToAnilist } from "@/api/hooks/local.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { SettingsCard, SettingsPageHeader } from "@/app/(main)/settings/_components/settings-card" +import { SettingsSubmitButton } from "@/app/(main)/settings/_components/settings-submit-button" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Field } from "@/components/ui/form" +import { Separator } from "@/components/ui/separator" +import React from "react" +import { LuCloudUpload, LuDatabase, LuUserCog } from "react-icons/lu" + +type Props = { + isPending: boolean + children?: React.ReactNode +} + +export function LocalSettings(props: Props) { + + const { + isPending, + children, + ...rest + } = props + + const serverStatus = useServerStatus() + + const { mutate: upload, isPending: isUploading } = useLocalSyncSimulatedDataToAnilist() + + const confirmDialog = useConfirmationDialog({ + title: "Upload to AniList", + description: "This will upload your local Seanime collection to your AniList account. Are you sure you want to proceed?", + actionText: "Upload", + actionIntent: "primary", + onConfirm: async () => { + upload() + }, + }) + + return ( + <div className="space-y-4"> + + <SettingsPageHeader + title="Local Account" + description="Local anime and manga list managed by Seanime" + icon={LuUserCog} + /> + + <SettingsCard + title="AniList" + // description="You can upload your local Seanime collection to your AniList account." + > + <div className={cn(serverStatus?.user?.isSimulated && "opacity-50 pointer-events-none")}> + <Field.Switch + side="right" + name="autoSyncToLocalAccount" + label="Auto sync from AniList" + help="Periodically update your local collection by using your AniList data." + /> + </div> + <Separator /> + <Button + size="sm" + intent="primary-subtle" + loading={isUploading} + leftIcon={<LuCloudUpload className="size-4" />} + onClick={() => { + confirmDialog.open() + }} + disabled={serverStatus?.user?.isSimulated} + > + Upload to AniList + </Button> + </SettingsCard> + + <SettingsSubmitButton isPending={isPending} /> + + <ConfirmationDialog {...confirmDialog} /> + + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/logs-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/logs-settings.tsx new file mode 100644 index 0000000..af91337 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/logs-settings.tsx @@ -0,0 +1,395 @@ +import { useServerQuery } from "@/api/client/requests" +import { + useDeleteLogs, + useDownloadCPUProfile, + useDownloadGoRoutineProfile, + useDownloadMemoryProfile, + useForceGC, + useGetLogFilenames, + useGetMemoryStats, +} from "@/api/hooks/status.hooks" +import { useHandleCopyLatestLogs } from "@/app/(main)/_hooks/logs" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { DataGrid, defineDataGridColumns } from "@/components/ui/datagrid" +import { DataGridRowSelectedEvent } from "@/components/ui/datagrid/use-datagrid-row-selection" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { NumberInput } from "@/components/ui/number-input" +import { Select } from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { RowSelectionState } from "@tanstack/react-table" +import React from "react" +import { BiRefresh } from "react-icons/bi" +import { FaCopy, FaMemory, FaMicrochip } from "react-icons/fa" +import { FiDownload, FiTrash2 } from "react-icons/fi" +import { toast } from "sonner" +import { SettingsCard } from "../_components/settings-card" + +type LogsSettingsProps = {} + +export function LogsSettings(props: LogsSettingsProps) { + + const {} = props + + const [selectedFilenames, setSelectedFilenames] = React.useState<{ name: string }[]>([]) + const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) + const onSelectChange = React.useCallback((event: DataGridRowSelectedEvent<{ name: string }>) => { + setSelectedFilenames(event.data) + }, []) + const [globalFilter, setGlobalFilter] = React.useState<string>("") + + const { data: filenames, isLoading } = useGetLogFilenames() + + const { mutate: deleteLogs, isPending: isDeleting } = useDeleteLogs() + + const filenamesObj = React.useMemo(() => { + return filenames?.map(f => ({ name: f })) ?? [] + }, [filenames]) + + + const columns = React.useMemo(() => defineDataGridColumns<{ name: string }>(() => [ + { + accessorKey: "name", + header: "Name", + cell: info => ( + <LogModal filename={info.getValue<string>()} /> + ), + }, + ]), [filenamesObj]) + + const { handleCopyLatestLogs } = useHandleCopyLatestLogs() + + return ( + <> + <SettingsCard> + + <div className="pb-3"> + <Button + intent="white-subtle" + onClick={handleCopyLatestLogs} + > + Copy current server logs + </Button> + </div> + + <Select + value={globalFilter === "seanime-" ? "seanime-" : globalFilter === "-scan" ? "-scan" : "-"} + onValueChange={value => { + setGlobalFilter(value === "-" ? "" : value) + }} + options={[ + { value: "-", label: "All" }, + { value: "seanime-", label: "Server" }, + { value: "-scan", label: "Scanner" }, + ]} + /> + + {selectedFilenames.length > 0 && ( + <div className="flex items-center space-x-2"> + <Button + onClick={() => deleteLogs({ filenames: selectedFilenames.map(f => f.name) }, { + onSuccess: () => { + setSelectedFilenames([]) + setRowSelection({}) + }, + })} + intent="alert" + loading={isDeleting} + size="sm" + > + Delete selected + </Button> + </div> + )} + + <DataGrid + data={filenamesObj} + columns={columns} + rowCount={filenamesObj.length} + isLoading={isLoading} + isDataMutating={isDeleting} + rowSelectionPrimaryKey="name" + enableRowSelection + initialState={{ + pagination: { + pageIndex: 0, + pageSize: 5, + }, + }} + state={{ + rowSelection, + globalFilter, + }} + hideGlobalSearchInput + hideColumns={[ + // { + // below: 1000, + // hide: ["number", "scanlator", "language"], + // }, + ]} + onRowSelect={onSelectChange} + onRowSelectionChange={setRowSelection} + onGlobalFilterChange={setGlobalFilter} + className="" + /> + </SettingsCard> + + <MemoryProfilingSettings /> + </> + ) +} + +function LogModal(props: { filename: string }) { + const { filename } = props + const [open, setOpen] = React.useState(false) + + const { data, isPending } = useServerQuery<string>({ + endpoint: `/api/v1/log/${props.filename}`, + method: "GET", + queryKey: ["STATUS-get-log-content", props.filename], + enabled: open, + }) + + function copyToClipboard() { + if (!data) { + return + } + navigator.clipboard.writeText(data) + toast.success("Copied to clipboard") + } + + return ( + <> + <p + onClick={() => setOpen(true)} + className="cursor-pointer hover:text-[--muted]" + >{filename}</p> + <Modal + open={open} + title={filename} + onOpenChange={v => setOpen(v)} + contentClass="max-w-5xl" + > + + <Button + onClick={copyToClipboard} + intent="gray-outline" + leftIcon={<FaCopy />} + className="w-fit" + > + Copy to clipboard + </Button> + + {isPending ? <LoadingSpinner /> : + <div className="bg-gray-900 rounded-[--radius-md] border max-w-full overflow-x-auto"> + <pre className="text-md max-h-[40rem] p-2 min-h-12 whitespace-pre-wrap break-all"> + {data?.split("\n").map((line, i) => ( + <p + key={i} + className={cn( + "w-full", + i % 2 === 0 ? "bg-gray-800" : "bg-gray-900", + line.includes("|ERR|") && "text-white bg-red-800", + line.includes("|WRN|") && "text-orange-500", + line.includes("|INF|") && "text-blue-200", + line.includes("|TRC|") && "text-[--muted]", + )} + >{line}</p> + ))} + </pre> + </div>} + </Modal> + </> + ) +} + +function MemoryProfilingSettings() { + const [cpuDuration, setCpuDuration] = React.useState(30) + + const { data: memoryStats, refetch: refetchMemoryStats, isLoading: isLoadingMemoryStats } = useGetMemoryStats() + const { mutate: forceGC, isPending: isForceGCPending } = useForceGC() + const { mutate: downloadHeapProfile, isPending: isDownloadingHeap } = useDownloadMemoryProfile() + const { mutate: downloadAllocsProfile, isPending: isDownloadingAllocs } = useDownloadMemoryProfile() + const { mutate: downloadGoRoutineProfile, isPending: isDownloadingGoroutine } = useDownloadGoRoutineProfile() + const { mutate: downloadCPUProfile, isPending: isDownloadingCPU } = useDownloadCPUProfile() + + const formatBytes = (bytes: number) => { + if (bytes === 0) return "0 B" + const k = 1024 + const sizes = ["B", "KB", "MB", "GB", "TB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` + } + + const handleRefreshStats = () => { + refetchMemoryStats() + } + + const handleForceGC = () => { + forceGC() + } + + const handleDownloadHeapProfile = () => { + downloadHeapProfile({ profileType: "heap" }) + } + + const handleDownloadAllocsProfile = () => { + downloadAllocsProfile({ profileType: "allocs" }) + } + + const handleDownloadGoRoutineProfile = () => { + downloadGoRoutineProfile() + } + + const handleDownloadCPUProfile = () => { + downloadCPUProfile({ duration: cpuDuration }) + } + + return ( + <SettingsCard title="Profiling"> + <div className="space-y-6"> + <div> + <div className="flex items-center justify-between mb-4"> + <h3 className="text-lg font-medium">Memory Statistics</h3> + <div className="flex gap-2"> + <Button + intent="white-subtle" + size="sm" + leftIcon={<BiRefresh className="text-xl" />} + onClick={handleRefreshStats} + loading={isLoadingMemoryStats} + > + Refresh + </Button> + <Button + intent="gray-outline" + size="sm" + leftIcon={<FiTrash2 />} + onClick={handleForceGC} + loading={isForceGCPending} + > + Force GC + </Button> + </div> + </div> + + {memoryStats && ( + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + <div className="bg-gray-800 p-4 rounded-md"> + <div className="text-sm text-[--muted]">Heap Allocated</div> + <div className="text-xl font-medium">{formatBytes(memoryStats.heapAlloc)}</div> + </div> + <div className="bg-gray-800 p-4 rounded-md"> + <div className="text-sm text-[--muted]">Heap In Use</div> + <div className="text-xl font-medium">{formatBytes(memoryStats.heapInuse)}</div> + </div> + <div className="bg-gray-800 p-4 rounded-md"> + <div className="text-sm text-[--muted]">Heap System</div> + <div className="text-xl font-medium">{formatBytes(memoryStats.heapSys)}</div> + </div> + <div className="bg-gray-800 p-4 rounded-md"> + <div className="text-sm text-[--muted]">Total Allocated</div> + <div className="text-xl font-medium">{formatBytes(memoryStats.totalAlloc)}</div> + </div> + <div className="bg-gray-800 p-4 rounded-md"> + <div className="text-sm text-[--muted]">Goroutines</div> + <div className="text-xl font-medium">{memoryStats.numGoroutine}</div> + </div> + <div className="bg-gray-800 p-4 rounded-md"> + <div className="text-sm text-[--muted]">GC Cycles</div> + <div className="text-xl font-medium">{memoryStats.numGC}</div> + </div> + </div> + )} + + {!memoryStats && !isLoadingMemoryStats && ( + <div className="text-center py-4 text-[--muted]"> + Click "Refresh" to load memory statistics + </div> + )} + + {isLoadingMemoryStats && ( + <div className="flex justify-center py-4"> + <LoadingSpinner /> + </div> + )} + </div> + + <Separator /> + + <div> + <div className="space-y-4"> + <div> + <h4 className="text-md font-medium mb-2 flex items-center gap-2"> + <FaMemory className="text-blue-400" /> + Memory + </h4> + <div className="flex flex-wrap gap-2"> + <Button + intent="gray-subtle" + size="sm" + leftIcon={<FiDownload />} + onClick={handleDownloadHeapProfile} + loading={isDownloadingHeap} + > + Heap Profile + </Button> + <Button + intent="gray-subtle" + size="sm" + leftIcon={<FiDownload />} + onClick={handleDownloadAllocsProfile} + loading={isDownloadingAllocs} + > + Allocations Profile + </Button> + <Button + intent="gray-subtle" + size="sm" + leftIcon={<FiDownload />} + onClick={handleDownloadGoRoutineProfile} + loading={isDownloadingGoroutine} + > + Goroutine Profile + </Button> + </div> + </div> + + <Separator /> + + <div> + <h4 className="text-md font-medium mb-2 flex items-center gap-2"> + <FaMicrochip className="text-green-400" /> + CPU + </h4> + <div className="space-y-2"> + <NumberInput + label="Duration (seconds)" + value={cpuDuration} + onValueChange={(value) => setCpuDuration(value || 30)} + min={1} + max={300} + className="w-32" + size="sm" + /> + <Button + intent="gray-outline" + size="sm" + leftIcon={<FiDownload />} + onClick={handleDownloadCPUProfile} + loading={isDownloadingCPU} + > + Download CPU Profile + </Button> + </div> + <p className="text-xs text-[--muted] mt-1"> + CPU profiling will run for the specified duration (1-300 seconds) + </p> + </div> + </div> + </div> + </div> + </SettingsCard> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/manga-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/manga-settings.tsx new file mode 100644 index 0000000..86cddd7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/manga-settings.tsx @@ -0,0 +1,73 @@ +import { useListMangaProviderExtensions } from "@/api/hooks/extensions.hooks" +import { SettingsCard, SettingsPageHeader } from "@/app/(main)/settings/_components/settings-card" +import { SettingsSubmitButton } from "@/app/(main)/settings/_components/settings-submit-button" +import { Field } from "@/components/ui/form" +import React from "react" +import { FaBookReader } from "react-icons/fa" +import { LuBook, LuBookDashed, LuBookKey } from "react-icons/lu" + +type MangaSettingsProps = { + isPending: boolean +} + +export function MangaSettings(props: MangaSettingsProps) { + + const { + isPending, + ...rest + } = props + + const { data: extensions } = useListMangaProviderExtensions() + + const options = React.useMemo(() => { + return [ + { label: "Auto", value: "-" }, + ...(extensions?.map(provider => ({ + label: provider.name, + value: provider.id, + })) ?? []).sort((a, b) => a.label.localeCompare(b.label)), + ] + }, [extensions]) + + return ( + <> + <SettingsPageHeader + title="Manga" + description="Manage your manga library" + icon={FaBookReader} + /> + + <SettingsCard> + <Field.Switch + side="right" + name="enableManga" + label={<span className="flex gap-1 items-center">Enable</span>} + help="Read manga series, download chapters and track your progress." + /> + <Field.Switch + side="right" + name="mangaAutoUpdateProgress" + label="Automatically update progress" + help="If enabled, your progress will be automatically updated when you reach the end of a chapter." + /> + </SettingsCard> + + <SettingsCard title="Sources"> + <Field.Select + name="defaultMangaProvider" + label="Default Provider" + help="Select the default provider for manga series." + options={options} + /> + + <Field.DirectorySelector + name="mangaLocalSourceDirectory" + label="Local Source Directory" + help="The directory where your manga is stored. This is only used for local manga provider." + /> + </SettingsCard> + + <SettingsSubmitButton isPending={isPending} /> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/mediastream-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/mediastream-settings.tsx new file mode 100644 index 0000000..b0239bd --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/mediastream-settings.tsx @@ -0,0 +1,235 @@ +import { useGetMediastreamSettings, useSaveMediastreamSettings } from "@/api/hooks/mediastream.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { useMediastreamActiveOnDevice } from "@/app/(main)/mediastream/_lib/mediastream.atoms" +import { SettingsCard } from "@/app/(main)/settings/_components/settings-card" +import { SettingsIsDirty, SettingsSubmitButton } from "@/app/(main)/settings/_components/settings-submit-button" +import { defineSchema, Field, Form } from "@/components/ui/form" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import React from "react" +import { UseFormReturn } from "react-hook-form" + +const mediastreamSchema = defineSchema(({ z }) => z.object({ + transcodeEnabled: z.boolean(), + transcodeHwAccel: z.string(), + transcodePreset: z.string().min(2), + // transcodeThreads: z.number(), + // preTranscodeEnabled: z.boolean(), + // preTranscodeLibraryDir: z.string(), + disableAutoSwitchToDirectPlay: z.boolean(), + directPlayOnly: z.boolean(), + ffmpegPath: z.string().min(0), + ffprobePath: z.string().min(0), + transcodeHwAccelCustomSettings: z.string().min(0), +})) + +const MEDIASTREAM_HW_ACCEL_OPTIONS = [ + { label: "CPU (Disabled)", value: "cpu" }, + { label: "NVIDIA (NVENC)", value: "nvidia" }, + { label: "Intel (QSV)", value: "qsv" }, + { label: "VAAPI", value: "vaapi" }, + { label: "Apple VideoToolbox", value: "videotoolbox" }, + { label: "Custom", value: "custom" }, +] + +const MEDIASTREAM_PRESET_OPTIONS = [ + { label: "Ultrafast", value: "ultrafast" }, + { label: "Superfast", value: "superfast" }, + { label: "Veryfast", value: "veryfast" }, + { label: "Fast", value: "fast" }, + { label: "Medium", value: "medium" }, +] + +type MediastreamSettingsProps = { + children?: React.ReactNode +} + +export function MediastreamSettings(props: MediastreamSettingsProps) { + + const { + children, + ...rest + } = props + + const serverStatus = useServerStatus() + + const { data: settings, isLoading } = useGetMediastreamSettings(true) + + const { mutate, isPending } = useSaveMediastreamSettings() + + const { activeOnDevice, setActiveOnDevice } = useMediastreamActiveOnDevice() + + const formRef = React.useRef<UseFormReturn<any>>(null) + + if (!settings) return <LoadingSpinner /> + + return ( + <> + <Form + schema={mediastreamSchema} + mRef={formRef} + onSubmit={data => { + if (settings) { + mutate({ + settings: { + ...settings, + ...data, + preTranscodeLibraryDir: "", + preTranscodeEnabled: false, + transcodeThreads: 0, + }, + }, + { + onSuccess: () => { + formRef.current?.reset(formRef.current.getValues()) + }, + }, + ) + } + }} + defaultValues={{ + transcodeEnabled: settings?.transcodeEnabled ?? false, + transcodeHwAccel: settings?.transcodeHwAccel === "none" ? "cpu" : settings?.transcodeHwAccel || "cpu", + transcodePreset: settings?.transcodePreset || "fast", + // transcodeThreads: settings?.transcodeThreads, + // preTranscodeEnabled: settings?.preTranscodeEnabled ?? false, + // preTranscodeLibraryDir: settings?.preTranscodeLibraryDir, + disableAutoSwitchToDirectPlay: settings?.disableAutoSwitchToDirectPlay ?? false, + directPlayOnly: settings?.directPlayOnly ?? false, + ffmpegPath: settings?.ffmpegPath || "", + ffprobePath: settings?.ffprobePath || "", + transcodeHwAccelCustomSettings: settings?.transcodeHwAccelCustomSettings || "{\n \"name\": \"\",\n \"decodeFlags\": [\n \"-hwaccel\", \"\",\n \"-hwaccel_output_format\", \"\",\n ],\n \"encodeFlags\": [\n \"-c:v\", \"\",\n \"-preset\", \"\",\n \"-pix_fmt\", \"yuv420p\",\n ],\n \"scaleFilter\": \"scale=%d:%d\"\n}", + }} + stackClass="space-y-4" + > + {(f) => ( + <> + <SettingsIsDirty /> + <SettingsCard> + <Field.Switch + side="right" + name="transcodeEnabled" + label="Enable" + /> + </SettingsCard> + + {/* <SettingsCard title="Client Playback"> + <div className="flex gap-4 items-center rounded-[--radius-md]"> + <MdOutlineDevices className="text-4xl" /> + <div className="space-y-1"> + <Checkbox + value={activeOnDevice ?? false} + onValueChange={v => { + setActiveOnDevice((prev) => typeof v === "boolean" ? v : prev) + if (v) { + toast.success("Media streaming is now active on this device.") + } else { + toast.info("Media streaming is now inactive on this device.") + } + }} + label="Use media streaming on this device" + help="Enable this option if you want to use media streaming on this device." + /> + <p className="text-gray-200"> + Current client: {serverStatus?.clientDevice}, {serverStatus?.clientPlatform} + </p> + </div> + </div> + + {(f.watch("transcodeEnabled") && activeOnDevice) && ( + <Alert + intent="info" description={<> + Media streaming will be used instead of your external player on this device. + </>} + /> + )} + </SettingsCard> */} + + <SettingsCard title="Direct play"> + + <Field.Switch + side="right" + name="disableAutoSwitchToDirectPlay" + label="Prefer transcoding" + help="If enabled, Seanime will not automatically switch to direct play if the media codec is supported by the client." + /> + + <Field.Switch + side="right" + name="directPlayOnly" + label="Direct play only" + help="Only allow direct play. Transcoding will never be started." + /> + + </SettingsCard> + + <SettingsCard title="Transcoding"> + <Field.Select + options={MEDIASTREAM_HW_ACCEL_OPTIONS} + name="transcodeHwAccel" + label="Hardware acceleration" + help="Hardware acceleration is highly recommended for a smoother transcoding experience." + /> + + {f.watch("transcodeHwAccel") === "custom" && ( + <Field.Textarea + name="transcodeHwAccelCustomSettings" + label="Custom settings (JSON)" + className="min-h-[400px]" + help="Video stream only, scaleFilter = -vf, -map,-bufsize,-b:v,-maxrate automatically applied." + /> + )} + + <Field.Select + options={MEDIASTREAM_PRESET_OPTIONS} + name="transcodePreset" + label="Transcode preset" + help="'Fast' is recommended. VAAPI does not support presets." + /> + </SettingsCard> + + <SettingsCard title="FFmpeg"> + + <div className="flex gap-3 items-center"> + <Field.Text + name="ffmpegPath" + label="FFmpeg path" + help="Path to the FFmpeg binary. Leave empty if binary is already in your PATH." + /> + + <Field.Text + name="ffprobePath" + label="FFprobe path" + help="Path to the FFprobe binary. Leave empty if binary is already in your PATH." + /> + </div> + </SettingsCard> + + <SettingsSubmitButton isPending={isPending} /> + </> + )} + </Form> + + {/*<Separator />*/} + + {/*<h2>Cache</h2>*/} + + {/*<div className="space-y-4">*/} + {/* <div className="flex gap-2 items-center">*/} + {/* <Button intent="white-subtle" size="sm" onClick={() => getTotalSize()} disabled={isFetchingSize}>*/} + {/* Show total size*/} + {/* </Button>*/} + {/* {!!totalSize && (*/} + {/* <p>*/} + {/* {totalSize}*/} + {/* </p>*/} + {/* )}*/} + {/* </div>*/} + {/* <div className="flex gap-2 flex-wrap items-center">*/} + {/* <Button intent="alert-subtle" size="sm" onClick={() => clearCache()} disabled={isClearing}>*/} + {/* Clear cache*/} + {/* </Button>*/} + {/* </div>*/} + {/*</div>*/} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/nakama-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/nakama-settings.tsx new file mode 100644 index 0000000..0c721b5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/nakama-settings.tsx @@ -0,0 +1,165 @@ +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { SettingsCard, SettingsPageHeader } from "@/app/(main)/settings/_components/settings-card" +import { SettingsSubmitButton } from "@/app/(main)/settings/_components/settings-submit-button" +import { Alert } from "@/components/ui/alert" +import { cn } from "@/components/ui/core/styling" +import { Field } from "@/components/ui/form" +import { Separator } from "@/components/ui/separator" +import React from "react" +import { useWatch } from "react-hook-form" +import { MdOutlineConnectWithoutContact } from "react-icons/md" +import { RiInformation2Line } from "react-icons/ri" + +type Props = { + isPending: boolean + children?: React.ReactNode +} +const tabsRootClass = cn("w-full contents space-y-4") + +const tabsTriggerClass = cn( + "text-base px-6 rounded-[--radius-md] w-fit border-none data-[state=active]:bg-[--subtle] data-[state=active]:text-white dark:hover:text-white", + "h-10 lg:justify-center px-3 flex-1", +) + +const tabsListClass = cn( + "w-full flex flex-row lg:flex-row flex-wrap h-fit mt-4", +) + +export function NakamaSettings(props: Props) { + + const { + isPending, + children, + ...rest + } = props + + const serverStatus = useServerStatus() + const nakamaIsHost = useWatch({ name: "nakamaIsHost" }) + + + return ( + <div className="space-y-4"> + + <SettingsPageHeader + title="Nakama" + description="Communicate with other Seanime instances" + icon={MdOutlineConnectWithoutContact} + /> + + <SettingsCard> + <Field.Switch + side="right" + name="nakamaEnabled" + label="Enable Nakama" + /> + + <Field.Text + label="Username" + name="nakamaUsername" + placeholder="Username" + help="The username to identify this server to other instances. If empty a random ID will be assigned." + /> + </SettingsCard> + + <SettingsCard title="Connect to a host"> + {serverStatus?.settings?.nakama?.isHost && <Alert intent="info" description="Cannot connect to a host while in host mode." />} + + <div + className={cn( + "space-y-4", + serverStatus?.settings?.nakama?.isHost ? "hidden" : "", + )} + > + {!serverStatus?.settings?.nakama?.isHost && + <div className="flex items-center gap-2 text-sm bg-gray-50 dark:bg-gray-900/30 rounded-lg p-3 border border-gray-700 border-dashed text-blue-100"> + <RiInformation2Line className="text-base" /> + <span>The server you're connecting to must be accessible over the internet. + </span> + </div>} + + <Field.Text + label="Nakama Server URL" + name="nakamaRemoteServerURL" + placeholder="http://{address}" + help="The URL of the Nakama host to connect to." + /> + + <Field.Text + label="Nakama Passcode" + name="nakamaRemoteServerPassword" + placeholder="Passcode" + help="The passcode to connect to the Nakama host." + /> + + <Separator className="!my-6" /> + + <h3>Library</h3> + + <Field.Switch + side="right" + name="includeNakamaAnimeLibrary" + label="Use Nakama's anime library" + help="If enabled, the Nakama's anime library will be used as your library if it is being shared." + /> + </div> + </SettingsCard> + + <SettingsCard title="Host"> + <div className="flex items-center gap-2 text-sm bg-gray-50 dark:bg-gray-900/30 rounded-lg p-3 border border-gray-700 border-dashed text-blue-100"> + <RiInformation2Line className="text-base" /> + <span>Enabling host mode does not automatically set up remote access; you must manually expose your server using your + preferred method.</span> + </div> + + {!serverStatus?.serverHasPassword && + <Alert intent="warning" description="Your server is not password protected. Add a password to your config file." />} + + <Field.Switch + side="right" + name="nakamaIsHost" + label="Enable host mode" + // moreHelp="Password must be set in the config file" + help="If enabled, this server will act as a host for other clients. This requires a host password to be set." + /> + + <Field.Text + label="Host Passcode" + name="nakamaHostPassword" + placeholder="Passcode" + help="Set a passcode to secure your host mode. This passcode should be different than your server password." + /> + + {/*<Field.Switch*/} + {/* side="right"*/} + {/* name="nakamaHostEnablePortForwarding"*/} + {/* label="Enable port forwarding"*/} + {/* moreHelp="This might not work for all networks."*/} + {/* help="If enabled, this server will expose its port to the internet. This might be required for other clients to connect to this server."*/} + {/*/>*/} + + {nakamaIsHost && <> + <Separator className="!my-6" /> + + <h3>Host settings</h3> + + <Field.Switch + side="right" + name="nakamaHostShareLocalAnimeLibrary" + label="Share local anime library" + help="If enabled, this server will share its local anime library to other clients." + /> + + <Field.MediaExclusionSelector + name="nakamaHostUnsharedAnimeIds" + label="Exclude anime from sharing" + help="Select anime that you don't want to share with other clients." + /> + </>} + </SettingsCard> + + + <SettingsSubmitButton isPending={isPending} /> + + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/server-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/server-settings.tsx new file mode 100644 index 0000000..802efc8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/server-settings.tsx @@ -0,0 +1,320 @@ +import { useLocalSyncSimulatedDataToAnilist } from "@/api/hooks/local.hooks" +import { __seaCommand_shortcuts } from "@/app/(main)/_features/sea-command/sea-command" +import { SettingsCard } from "@/app/(main)/settings/_components/settings-card" +import { SettingsSubmitButton } from "@/app/(main)/settings/_components/settings-submit-button" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Field } from "@/components/ui/form" +import { Separator } from "@/components/ui/separator" +import { useAtom } from "jotai/react" +import React from "react" +import { useFormContext } from "react-hook-form" +import { FaRedo } from "react-icons/fa" +import { LuCloudUpload } from "react-icons/lu" +import { useServerStatus } from "../../_hooks/use-server-status" + +type ServerSettingsProps = { + isPending: boolean +} + +export function ServerSettings(props: ServerSettingsProps) { + + const { + isPending, + ...rest + } = props + + const serverStatus = useServerStatus() + + const [shortcuts, setShortcuts] = useAtom(__seaCommand_shortcuts) + const f = useFormContext() + + const { mutate: upload, isPending: isUploading } = useLocalSyncSimulatedDataToAnilist() + + const confirmDialog = useConfirmationDialog({ + title: "Upload to AniList", + description: "This will upload your local Seanime collection to your AniList account. Are you sure you want to proceed?", + actionText: "Upload", + actionIntent: "primary", + onConfirm: async () => { + upload() + }, + }) + + return ( + <div className="space-y-4"> + + <SettingsCard> + {/*<p className="text-[--muted]">*/} + {/* Only applies to desktop and integrated players.*/} + {/*</p>*/} + + <Field.Switch + side="right" + name="autoUpdateProgress" + label="Automatically update progress" + help="If enabled, your progress will be automatically updated when you watch 80% of an episode." + moreHelp="Only applies to desktop and integrated players." + /> + {/*<Separator />*/} + <Field.Switch + side="right" + name="enableWatchContinuity" + label="Enable watch history" + help="If enabled, Seanime will remember your watch progress and resume from where you left off." + moreHelp="Only applies to desktop and integrated players." + /> + + <Field.Switch + side="right" + name="disableAnimeCardTrailers" + label="Disable anime card trailers" + help="" + /> + + <Separator /> + + <Field.Switch + side="right" + name="hideAudienceScore" + label="Hide audience score" + help="If enabled, the audience score will be hidden until you decide to view it." + /> + + <Field.Switch + side="right" + name="enableAdultContent" + label="Enable adult content" + help="If disabled, adult content will be hidden from search results and your library." + /> + <Field.Switch + side="right" + name="blurAdultContent" + label="Blur adult content" + help="If enabled, adult content will be blurred." + fieldClass={cn( + !f.watch("enableAdultContent") && "opacity-50", + )} + /> + + </SettingsCard> + + <SettingsCard + title="Local Data" + description="Local data is used when you're not using an AniList account." + > + <div className={cn(serverStatus?.user?.isSimulated && "opacity-50 pointer-events-none")}> + <Field.Switch + side="right" + name="autoSyncToLocalAccount" + label="Auto backup lists from AniList" + help="If enabled, your local lists will be periodically updated by using your AniList data." + /> + </div> + <Separator /> + <Button + size="sm" + intent="primary-subtle" + loading={isUploading} + leftIcon={<LuCloudUpload className="size-4" />} + onClick={() => { + confirmDialog.open() + }} + disabled={serverStatus?.user?.isSimulated} + > + Upload local lists to AniList + </Button> + </SettingsCard> + + <ConfirmationDialog {...confirmDialog} /> + + <SettingsCard title="Offline mode" description="Only available when authenticated with AniList."> + + <Field.Switch + side="right" + name="autoSyncOfflineLocalData" + label="Update local metadata automatically" + help="If disabled, you will need to manually refresh your local metadata by clicking 'Sync now' in the offline mode page." + moreHelp="Only if no offline changes have been made." + /> + + <Field.Switch + side="right" + name="autoSaveCurrentMediaOffline" + label="Save all currently watched/read media for offline use" + help="If enabled, Seanime will automatically save all currently watched/read media for offline use." + /> + + </SettingsCard> + + <SettingsCard title="App"> + <Field.Switch + side="right" + name="disableUpdateCheck" + label="Do not check for updates" + help="If enabled, Seanime will not check for new releases." + /> + {/*<Separator />*/} + <Field.Switch + side="right" + name="openTorrentClientOnStart" + label="Open torrent client on startup" + /> + {/*<Separator />*/} + <Field.Switch + side="right" + name="openWebURLOnStart" + label="Open localhost web URL on startup" + /> + <Field.Switch + side="right" + name="disableNotifications" + label="Disable system notifications" + /> + {/*<Separator />*/} + <Field.Switch + side="right" + name="disableAutoDownloaderNotifications" + label="Disable Auto Downloader system notifications" + /> + {/*<Separator />*/} + <Field.Switch + side="right" + name="disableAutoScannerNotifications" + label="Disable Auto Scanner system notifications" + /> + </SettingsCard> + + <SettingsCard title="Keyboard shortcuts"> + <div className="space-y-4"> + {[ + { + label: "Open command palette", + value: "meta+j", + altValue: "q", + }, + ].map(item => { + return ( + <div className="flex gap-2 items-center" key={item.label}> + <label className="text-[--gray]"> + <span className="font-semibold">{item.label}</span> + </label> + <div className="flex gap-2 items-center"> + <Button + onKeyDownCapture={(e) => { + e.preventDefault() + e.stopPropagation() + + const specialKeys = ["Control", "Shift", "Meta", "Command", "Alt", "Option"] + if (!specialKeys.includes(e.key)) { + const keyStr = `${e.metaKey ? "meta+" : ""}${e.ctrlKey ? "ctrl+" : ""}${e.altKey + ? "alt+" + : ""}${e.shiftKey ? "shift+" : ""}${e.key.toLowerCase() + .replace("arrow", "") + .replace("insert", "ins") + .replace("delete", "del") + .replace(" ", "space") + .replace("+", "plus")}` + + // Update the first shortcut + setShortcuts(prev => [keyStr, prev[1]]) + } + }} + className="focus:ring-2 focus:ring-[--brand] focus:ring-offset-1" + size="sm" + intent="white-subtle" + > + {shortcuts[0]} + </Button> + <span className="text-[--muted]">or</span> + <Button + onKeyDownCapture={(e) => { + e.preventDefault() + e.stopPropagation() + + const specialKeys = ["Control", "Shift", "Meta", "Command", "Alt", "Option"] + if (!specialKeys.includes(e.key)) { + const keyStr = `${e.metaKey ? "meta+" : ""}${e.ctrlKey ? "ctrl+" : ""}${e.altKey + ? "alt+" + : ""}${e.shiftKey ? "shift+" : ""}${e.key.toLowerCase() + .replace("arrow", "") + .replace("insert", "ins") + .replace("delete", "del") + .replace(" ", "space") + .replace("+", "plus")}` + + // Update the second shortcut + setShortcuts(prev => [prev[0], keyStr]) + } + }} + className="focus:ring-2 focus:ring-[--brand] focus:ring-offset-1" + size="sm" + intent="white-subtle" + > + {shortcuts[1]} + </Button> + </div> + {(shortcuts[0] !== "meta+j" || shortcuts[1] !== "q") && ( + <Button + onClick={() => { + setShortcuts(["meta+j", "q"]) + }} + className="rounded-full" + size="sm" + intent="white-basic" + leftIcon={<FaRedo />} + > + Reset + </Button> + )} + </div> + ) + })} + </div> + </SettingsCard> + + {/*<Accordion*/} + {/* type="single"*/} + {/* collapsible*/} + {/* className="border rounded-[--radius-md]"*/} + {/* triggerClass="dark:bg-[--paper]"*/} + {/* contentClass="!pt-2 dark:bg-[--paper]"*/} + {/*>*/} + {/* <AccordionItem value="more">*/} + {/* <AccordionTrigger className="bg-gray-900 rounded-[--radius-md]">*/} + {/* Advanced*/} + {/* </AccordionTrigger>*/} + {/* <AccordionContent className="pt-6 flex flex-col md:flex-row gap-3">*/} + {/* */} + {/* </AccordionContent>*/} + {/* </AccordionItem>*/} + {/*</Accordion>*/} + + + <SettingsSubmitButton isPending={isPending} /> + + </div> + ) +} + +const cardCheckboxStyles = { + itemContainerClass: cn( + "block border border-[--border] cursor-pointer transition overflow-hidden w-full", + "bg-gray-50 hover:bg-[--subtle] dark:bg-gray-950 border-dashed", + "data-[checked=false]:opacity-30", + "data-[checked=true]:bg-white dark:data-[checked=true]:bg-gray-950", + "focus:ring-2 ring-brand-100 dark:ring-brand-900 ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition", + "data-[checked=true]:border data-[checked=true]:ring-offset-0", + ), + itemClass: cn( + "hidden", + ), + // itemLabelClass: cn( + // "border-transparent border data-[checked=true]:border-brand dark:bg-transparent dark:data-[state=unchecked]:bg-transparent", + // "data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent dark:data-[state=unchecked]:hover:bg-transparent", + // "focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:ring-offset-transparent", + // ), + // itemLabelClass: "font-medium flex flex-col items-center data-[state=checked]:text-[--brand] cursor-pointer", + stackClass: "flex md:flex-row flex-col space-y-0 gap-4", +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/torrentstream-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/torrentstream-settings.tsx new file mode 100644 index 0000000..d083050 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/torrentstream-settings.tsx @@ -0,0 +1,230 @@ +import { Models_TorrentstreamSettings } from "@/api/generated/types" +import { useSaveTorrentstreamSettings, useTorrentstreamDropTorrent } from "@/api/hooks/torrentstream.hooks" +import { SettingsCard } from "@/app/(main)/settings/_components/settings-card" +import { SettingsIsDirty, SettingsSubmitButton } from "@/app/(main)/settings/_components/settings-submit-button" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Button } from "@/components/ui/button" +import { defineSchema, Field, Form } from "@/components/ui/form" +import React from "react" +import { UseFormReturn } from "react-hook-form" +import { FcFolder } from "react-icons/fc" +import { SiBittorrent } from "react-icons/si" + +const torrentstreamSchema = defineSchema(({ z }) => z.object({ + enabled: z.boolean(), + downloadDir: z.string(), + autoSelect: z.boolean(), + disableIPv6: z.boolean(), + addToLibrary: z.boolean(), + // streamingServerPort: z.number(), + // streamingServerHost: z.string(), + torrentClientHost: z.string().optional().default(""), + torrentClientPort: z.number(), + preferredResolution: z.string(), + includeInLibrary: z.boolean(), + streamUrlAddress: z.string().optional().default(""), + slowSeeding: z.boolean().optional().default(false), +})) + + +type TorrentstreamSettingsProps = { + children?: React.ReactNode + settings: Models_TorrentstreamSettings | undefined +} + +export function TorrentstreamSettings(props: TorrentstreamSettingsProps) { + + const { + children, + settings, + ...rest + } = props + + const { mutate, isPending } = useSaveTorrentstreamSettings() + + const { mutate: dropTorrent, isPending: droppingTorrent } = useTorrentstreamDropTorrent() + + const formRef = React.useRef<UseFormReturn<any>>(null) + + if (!settings) return null + + return ( + <> + <Form + schema={torrentstreamSchema} + mRef={formRef} + onSubmit={data => { + if (settings) { + mutate({ + settings: { + ...settings, + ...data, + preferredResolution: data.preferredResolution === "-" ? "" : data.preferredResolution, + }, + }, + { + onSuccess: () => { + formRef.current?.reset(formRef.current.getValues()) + }, + }, + ) + } + }} + defaultValues={{ + enabled: settings.enabled, + autoSelect: settings.autoSelect, + downloadDir: settings.downloadDir || "", + disableIPv6: settings.disableIPV6, + addToLibrary: settings.addToLibrary, + // streamingServerPort: settings.streamingServerPort, + // streamingServerHost: settings.streamingServerHost || "", + torrentClientHost: settings.torrentClientHost || "", + torrentClientPort: settings.torrentClientPort, + preferredResolution: settings.preferredResolution || "-", + includeInLibrary: settings.includeInLibrary, + streamUrlAddress: settings.streamUrlAddress || "", + slowSeeding: settings.slowSeeding, + }} + stackClass="space-y-4" + > + <SettingsIsDirty /> + <SettingsCard> + <Field.Switch + side="right" + name="enabled" + label="Enable" + /> + </SettingsCard> + + <SettingsCard title="My library"> + <Field.Switch + side="right" + name="includeInLibrary" + label="Include in library" + help="Add non-downloaded shows that are in your currently watching list to 'My library' for streaming" + /> + </SettingsCard> + + + <SettingsCard title="Auto-select"> + <Field.Switch + side="right" + name="autoSelect" + label="Enable" + help="Let Seanime find the best torrent automatically." + /> + + <Field.Select + name="preferredResolution" + label="Preferred resolution" + help="If auto-select is enabled, Seanime will try to find torrents with this resolution." + options={[ + { label: "Highest", value: "-" }, + { label: "480p", value: "480" }, + { label: "720p", value: "720" }, + { label: "1080p", value: "1080" }, + ]} + /> + </SettingsCard> + + + {/*<Field.Switch + side="right"*/} + {/* name="addToLibrary"*/} + {/* label="Add to library"*/} + {/* help="Keep completely downloaded files in corresponding library entries."*/} + {/*/>*/} + + {/* <SettingsCard title="Torrent Client" description="Seanime uses a built-in torrent client to download torrents."> + + </SettingsCard> */} + + <Accordion + type="single" + collapsible + className="border rounded-[--radius-md]" + triggerClass="dark:bg-[--paper]" + contentClass="!pt-2 dark:bg-[--paper]" + > + <AccordionItem value="more"> + <AccordionTrigger className="bg-gray-900 rounded-[--radius-md]"> + Torrent Client + </AccordionTrigger> + <AccordionContent className="space-y-4"> + <div className="flex items-center gap-3"> + + <Field.Text + name="torrentClientHost" + label="Host" + help="Leave empty for default. The host to listen for new uTP and TCP BitTorrent connections." + /> + + <Field.Number + name="torrentClientPort" + label="Port" + formatOptions={{ + useGrouping: false, + }} + help="Leave empty for default. Default is 43213." + /> + + </div> + + <Field.Switch + side="right" + name="disableIPv6" + label="Disable IPv6" + /> + + <Field.Switch + side="right" + name="slowSeeding" + label="Slow seeding" + moreHelp="This can help avoid issues with your network." + /> + </AccordionContent> + </AccordionItem> + </Accordion> + + <Accordion + type="single" + collapsible + className="border rounded-[--radius-md]" + triggerClass="dark:bg-[--paper]" + contentClass="!pt-2 dark:bg-[--paper]" + > + <AccordionItem value="more"> + <AccordionTrigger className="bg-gray-900 rounded-[--radius-md]"> + Advanced + </AccordionTrigger> + <AccordionContent className="pt-6 space-y-4"> + <Field.Text + name="streamUrlAddress" + label="Stream URL address" + placeholder="e.g. 0.0.0.0:43211" + help="Modify the stream URL formatting. Leave empty for default." + /> + + <Field.DirectorySelector + name="downloadDir" + label="Cache directory" + leftIcon={<FcFolder />} + help="Where the torrents will be downloaded to while streaming. Leave empty to use the default cache directory." + shouldExist + /> + </AccordionContent> + </AccordionItem> + </Accordion> + + + <div className="flex w-full items-center"> + <SettingsSubmitButton isPending={isPending} /> + <div className="flex flex-1"></div> + <Button leftIcon={<SiBittorrent />} intent="alert-subtle" onClick={() => dropTorrent()} disabled={droppingTorrent}> + Drop torrent + </Button> + </div> + </Form> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/ui-settings.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/ui-settings.tsx new file mode 100644 index 0000000..eaf7406 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/_containers/ui-settings.tsx @@ -0,0 +1,795 @@ +"use client" +import { useUpdateTheme } from "@/api/hooks/theme.hooks" +import { useCustomCSS } from "@/components/shared/custom-css-provider" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion/accordion" +import { Alert } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { defineSchema, Field, Form } from "@/components/ui/form" +import { Switch } from "@/components/ui/switch" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ANIME_COLLECTION_SORTING_OPTIONS, CONTINUE_WATCHING_SORTING_OPTIONS, MANGA_COLLECTION_SORTING_OPTIONS } from "@/lib/helpers/filtering" +import { + THEME_DEFAULT_VALUES, + ThemeLibraryScreenBannerType, + ThemeMediaPageBannerSizeOptions, + ThemeMediaPageBannerType, + ThemeMediaPageBannerTypeOptions, + ThemeMediaPageInfoBoxSizeOptions, + useThemeSettings, +} from "@/lib/theme/hooks" +import { THEME_COLOR_BANK } from "@/lib/theme/theme-bank" +import { __isDesktop__ } from "@/types/constants" +import { colord } from "colord" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import { atomWithStorage } from "jotai/utils" +import React, { useState } from "react" +import { useFormContext, UseFormReturn, useWatch } from "react-hook-form" +import { toast } from "sonner" +import { useServerStatus } from "../../_hooks/use-server-status" +import { SettingsCard } from "../_components/settings-card" +import { SettingsIsDirty } from "../_components/settings-submit-button" + +const themeSchema = defineSchema(({ z }) => z.object({ + animeEntryScreenLayout: z.string().min(0).default(THEME_DEFAULT_VALUES.animeEntryScreenLayout), + smallerEpisodeCarouselSize: z.boolean().default(THEME_DEFAULT_VALUES.smallerEpisodeCarouselSize), + expandSidebarOnHover: z.boolean().default(THEME_DEFAULT_VALUES.expandSidebarOnHover), + enableColorSettings: z.boolean().default(false), + backgroundColor: z.string().min(0).default(THEME_DEFAULT_VALUES.backgroundColor).transform(n => n.trim()), + accentColor: z.string().min(0).default(THEME_DEFAULT_VALUES.accentColor).transform(n => n.trim()), + sidebarBackgroundColor: z.string().min(0).default(THEME_DEFAULT_VALUES.sidebarBackgroundColor), + hideTopNavbar: z.boolean().default(THEME_DEFAULT_VALUES.hideTopNavbar), + enableMediaCardBlurredBackground: z.boolean().default(THEME_DEFAULT_VALUES.enableMediaCardBlurredBackground), + + libraryScreenBannerType: z.string().default(THEME_DEFAULT_VALUES.libraryScreenBannerType), + libraryScreenCustomBannerImage: z.string().default(THEME_DEFAULT_VALUES.libraryScreenCustomBannerImage), + libraryScreenCustomBannerPosition: z.string().default(THEME_DEFAULT_VALUES.libraryScreenCustomBannerPosition), + libraryScreenCustomBannerOpacity: z.number().transform(v => v === 0 ? 100 : v).default(THEME_DEFAULT_VALUES.libraryScreenCustomBannerOpacity), + libraryScreenCustomBackgroundImage: z.string().default(THEME_DEFAULT_VALUES.libraryScreenCustomBackgroundImage), + libraryScreenCustomBackgroundOpacity: z.number() + .transform(v => v === 0 ? 100 : v) + .default(THEME_DEFAULT_VALUES.libraryScreenCustomBackgroundOpacity), + libraryScreenCustomBackgroundBlur: z.string().default(THEME_DEFAULT_VALUES.libraryScreenCustomBackgroundBlur), + enableMediaPageBlurredBackground: z.boolean().default(THEME_DEFAULT_VALUES.enableMediaPageBlurredBackground), + disableSidebarTransparency: z.boolean().default(THEME_DEFAULT_VALUES.disableSidebarTransparency), + disableLibraryScreenGenreSelector: z.boolean().default(false), + useLegacyEpisodeCard: z.boolean().default(THEME_DEFAULT_VALUES.useLegacyEpisodeCard), + disableCarouselAutoScroll: z.boolean().default(THEME_DEFAULT_VALUES.disableCarouselAutoScroll), + mediaPageBannerType: z.string().default(THEME_DEFAULT_VALUES.mediaPageBannerType), + mediaPageBannerSize: z.string().default(THEME_DEFAULT_VALUES.mediaPageBannerSize), + mediaPageBannerInfoBoxSize: z.string().default(THEME_DEFAULT_VALUES.mediaPageBannerInfoBoxSize), + showEpisodeCardAnimeInfo: z.boolean().default(THEME_DEFAULT_VALUES.showEpisodeCardAnimeInfo), + continueWatchingDefaultSorting: z.string().default(THEME_DEFAULT_VALUES.continueWatchingDefaultSorting), + animeLibraryCollectionDefaultSorting: z.string().default(THEME_DEFAULT_VALUES.animeLibraryCollectionDefaultSorting), + mangaLibraryCollectionDefaultSorting: z.string().default(THEME_DEFAULT_VALUES.mangaLibraryCollectionDefaultSorting), + showAnimeUnwatchedCount: z.boolean().default(THEME_DEFAULT_VALUES.showAnimeUnwatchedCount), + showMangaUnreadCount: z.boolean().default(THEME_DEFAULT_VALUES.showMangaUnreadCount), + hideEpisodeCardDescription: z.boolean().default(THEME_DEFAULT_VALUES.hideEpisodeCardDescription), + hideDownloadedEpisodeCardFilename: z.boolean().default(THEME_DEFAULT_VALUES.hideDownloadedEpisodeCardFilename), + customCSS: z.string().default(THEME_DEFAULT_VALUES.customCSS), + mobileCustomCSS: z.string().default(THEME_DEFAULT_VALUES.mobileCustomCSS), + unpinnedMenuItems: z.array(z.string()).default(THEME_DEFAULT_VALUES.unpinnedMenuItems), +})) + +export const __ui_fixBorderRenderingArtifacts = atomWithStorage("sea-ui-settings-fix-border-rendering-artifacts", false) + +const selectUISettingTabAtom = atom("main") + +const tabsRootClass = cn("w-full contents space-y-4") + +const tabsTriggerClass = cn( + "text-base px-6 rounded-[--radius-md] w-fit border-none data-[state=active]:bg-[--subtle] data-[state=active]:text-white dark:hover:text-white", + "h-10 lg:justify-center px-3 flex-1", +) + +const tabsListClass = cn( + "w-full flex flex-row lg:flex-row flex-wrap h-fit", +) + +export function UISettings() { + const themeSettings = useThemeSettings() + const serverStatus = useServerStatus() + + const { mutate, isPending } = useUpdateTheme() + const [fixBorderRenderingArtifacts, setFixBorerRenderingArtifacts] = useAtom(__ui_fixBorderRenderingArtifacts) + const [enableLivePreview, setEnableLivePreview] = useState(false) + + const [tab, setTab] = useAtom(selectUISettingTabAtom) + + const formRef = React.useRef<UseFormReturn<any>>(null) + + const { customCSS, setCustomCSS } = useCustomCSS() + + const applyLivePreview = React.useCallback((bgColor: string, accentColor: string) => { + if (!enableLivePreview) return + + let r = document.querySelector(":root") as any + + // Background color + r.style.setProperty("--background", bgColor) + r.style.setProperty("--paper", colord(bgColor).lighten(0.025).toHex()) + r.style.setProperty("--media-card-popup-background", colord(bgColor).lighten(0.025).toHex()) + r.style.setProperty( + "--hover-from-background-color", + colord(bgColor).lighten(0.025).desaturate(0.05).toHex(), + ) + + // Gray colors + r.style.setProperty("--color-gray-400", + `${colord(bgColor).lighten(0.3).desaturate(0.2).toRgb().r} ${colord(bgColor).lighten(0.3).desaturate(0.2).toRgb().g} ${colord(bgColor) + .lighten(0.3) + .desaturate(0.2) + .toRgb().b}`) + r.style.setProperty("--color-gray-500", + `${colord(bgColor).lighten(0.15).desaturate(0.2).toRgb().r} ${colord(bgColor).lighten(0.15).desaturate(0.2).toRgb().g} ${colord(bgColor) + .lighten(0.15) + .desaturate(0.2) + .toRgb().b}`) + r.style.setProperty("--color-gray-600", + `${colord(bgColor).lighten(0.1).desaturate(0.2).toRgb().r} ${colord(bgColor).lighten(0.1).desaturate(0.2).toRgb().g} ${colord(bgColor) + .lighten(0.1) + .desaturate(0.2) + .toRgb().b}`) + r.style.setProperty("--color-gray-700", + `${colord(bgColor).lighten(0.08).desaturate(0.2).toRgb().r} ${colord(bgColor).lighten(0.08).desaturate(0.2).toRgb().g} ${colord(bgColor) + .lighten(0.08) + .desaturate(0.2) + .toRgb().b}`) + r.style.setProperty("--color-gray-800", + `${colord(bgColor).lighten(0.06).desaturate(0.2).toRgb().r} ${colord(bgColor).lighten(0.06).desaturate(0.2).toRgb().g} ${colord(bgColor) + .lighten(0.06) + .desaturate(0.2) + .toRgb().b}`) + r.style.setProperty("--color-gray-900", + `${colord(bgColor).lighten(0.04).desaturate(0.05).toRgb().r} ${colord(bgColor).lighten(0.04).desaturate(0.05).toRgb().g} ${colord(bgColor) + .lighten(0.04) + .desaturate(0.05) + .toRgb().b}`) + r.style.setProperty("--color-gray-950", + `${colord(bgColor).lighten(0.008).desaturate(0.05).toRgb().r} ${colord(bgColor).lighten(0.008).desaturate(0.05).toRgb().g} ${colord( + bgColor).lighten(0.008).desaturate(0.05).toRgb().b}`) + + // Accent color + r.style.setProperty("--color-brand-200", + `${colord(accentColor).lighten(0.35).desaturate(0.05).toRgb().r} ${colord(accentColor).lighten(0.35).desaturate(0.05).toRgb().g} ${colord( + accentColor).lighten(0.35).desaturate(0.05).toRgb().b}`) + r.style.setProperty("--color-brand-300", + `${colord(accentColor).lighten(0.3).desaturate(0.05).toRgb().r} ${colord(accentColor).lighten(0.3).desaturate(0.05).toRgb().g} ${colord( + accentColor).lighten(0.3).desaturate(0.05).toRgb().b}`) + r.style.setProperty("--color-brand-400", + `${colord(accentColor).lighten(0.1).toRgb().r} ${colord(accentColor).lighten(0.1).toRgb().g} ${colord(accentColor) + .lighten(0.1) + .toRgb().b}`) + r.style.setProperty("--color-brand-500", `${colord(accentColor).toRgb().r} ${colord(accentColor).toRgb().g} ${colord(accentColor).toRgb().b}`) + r.style.setProperty("--color-brand-600", + `${colord(accentColor).darken(0.1).toRgb().r} ${colord(accentColor).darken(0.1).toRgb().g} ${colord(accentColor).darken(0.1).toRgb().b}`) + r.style.setProperty("--color-brand-700", + `${colord(accentColor).darken(0.15).toRgb().r} ${colord(accentColor).darken(0.15).toRgb().g} ${colord(accentColor) + .darken(0.15) + .toRgb().b}`) + r.style.setProperty("--color-brand-800", + `${colord(accentColor).darken(0.2).toRgb().r} ${colord(accentColor).darken(0.2).toRgb().g} ${colord(accentColor).darken(0.2).toRgb().b}`) + r.style.setProperty("--color-brand-900", + `${colord(accentColor).darken(0.25).toRgb().r} ${colord(accentColor).darken(0.25).toRgb().g} ${colord(accentColor) + .darken(0.25) + .toRgb().b}`) + r.style.setProperty("--color-brand-950", + `${colord(accentColor).darken(0.3).toRgb().r} ${colord(accentColor).darken(0.3).toRgb().g} ${colord(accentColor).darken(0.3).toRgb().b}`) + r.style.setProperty("--brand", colord(accentColor).lighten(0.35).desaturate(0.1).toHex()) + }, [enableLivePreview]) + + function ObserveColorSettings() { + + const form = useFormContext() + + const accentColor = useWatch({ control: form.control, name: "accentColor" }) + const backgroundColor = useWatch({ control: form.control, name: "backgroundColor" }) + + + React.useEffect(() => { + if (!enableLivePreview) return + applyLivePreview(backgroundColor, accentColor) + }, [enableLivePreview, backgroundColor, accentColor]) + + return null + } + + return ( + <Form + schema={themeSchema} + mRef={formRef} + onSubmit={data => { + if (colord(data.backgroundColor).isLight()) { + toast.error("Seanime does not support light themes") + return + } + + const prevEnableColorSettings = themeSettings?.enableColorSettings + + mutate({ + theme: { + id: 0, + ...themeSettings, + ...data, + libraryScreenCustomBackgroundBlur: data.libraryScreenCustomBackgroundBlur === "-" + ? "" + : data.libraryScreenCustomBackgroundBlur, + }, + }, { + onSuccess() { + if (data.enableColorSettings !== prevEnableColorSettings && !data.enableColorSettings) { + window.location.reload() + } + formRef.current?.reset(formRef.current?.getValues()) + }, + }) + + setCustomCSS({ + customCSS: data.customCSS, + mobileCustomCSS: data.mobileCustomCSS, + }) + }} + defaultValues={{ + enableColorSettings: themeSettings?.enableColorSettings, + animeEntryScreenLayout: themeSettings?.animeEntryScreenLayout, + smallerEpisodeCarouselSize: themeSettings?.smallerEpisodeCarouselSize, + expandSidebarOnHover: themeSettings?.expandSidebarOnHover, + backgroundColor: themeSettings?.backgroundColor, + accentColor: themeSettings?.accentColor, + sidebarBackgroundColor: themeSettings?.sidebarBackgroundColor, + hideTopNavbar: themeSettings?.hideTopNavbar, + enableMediaCardBlurredBackground: themeSettings?.enableMediaCardBlurredBackground, + libraryScreenBannerType: themeSettings?.libraryScreenBannerType, + libraryScreenCustomBannerImage: themeSettings?.libraryScreenCustomBannerImage, + libraryScreenCustomBannerPosition: themeSettings?.libraryScreenCustomBannerPosition, + libraryScreenCustomBannerOpacity: themeSettings?.libraryScreenCustomBannerOpacity, + libraryScreenCustomBackgroundImage: themeSettings?.libraryScreenCustomBackgroundImage, + libraryScreenCustomBackgroundOpacity: themeSettings?.libraryScreenCustomBackgroundOpacity, + disableLibraryScreenGenreSelector: themeSettings?.disableLibraryScreenGenreSelector, + libraryScreenCustomBackgroundBlur: themeSettings?.libraryScreenCustomBackgroundBlur || "-", + enableMediaPageBlurredBackground: themeSettings?.enableMediaPageBlurredBackground, + disableSidebarTransparency: themeSettings?.disableSidebarTransparency, + useLegacyEpisodeCard: themeSettings?.useLegacyEpisodeCard, + disableCarouselAutoScroll: themeSettings?.disableCarouselAutoScroll, + mediaPageBannerType: themeSettings?.mediaPageBannerType ?? ThemeMediaPageBannerType.Default, + mediaPageBannerSize: themeSettings?.mediaPageBannerSize ?? ThemeMediaPageBannerType.Default, + mediaPageBannerInfoBoxSize: themeSettings?.mediaPageBannerInfoBoxSize ?? ThemeMediaPageBannerType.Default, + showEpisodeCardAnimeInfo: themeSettings?.showEpisodeCardAnimeInfo, + continueWatchingDefaultSorting: themeSettings?.continueWatchingDefaultSorting, + animeLibraryCollectionDefaultSorting: themeSettings?.animeLibraryCollectionDefaultSorting, + mangaLibraryCollectionDefaultSorting: themeSettings?.mangaLibraryCollectionDefaultSorting, + showAnimeUnwatchedCount: themeSettings?.showAnimeUnwatchedCount, + showMangaUnreadCount: themeSettings?.showMangaUnreadCount, + hideEpisodeCardDescription: themeSettings?.hideEpisodeCardDescription, + hideDownloadedEpisodeCardFilename: themeSettings?.hideDownloadedEpisodeCardFilename, + customCSS: themeSettings?.customCSS, + mobileCustomCSS: themeSettings?.mobileCustomCSS, + unpinnedMenuItems: themeSettings?.unpinnedMenuItems ?? [], + }} + stackClass="space-y-4 relative" + > + {(f) => ( + <> + <SettingsIsDirty className="-top-14" /> + <ObserveColorSettings /> + + <Tabs + value={tab} + onValueChange={setTab} + className={tabsRootClass} + triggerClass={tabsTriggerClass} + listClass={tabsListClass} + > + <TabsList className="flex-wrap max-w-full bg-[--paper] p-2 border rounded-lg"> + <TabsTrigger value="main">Theme</TabsTrigger> + <TabsTrigger value="media">Media</TabsTrigger> + <TabsTrigger value="navigation">Navigation</TabsTrigger> + <TabsTrigger value="browser-client">Rendering</TabsTrigger> + </TabsList> + + <TabsContent value="main" className="space-y-4"> + + <SettingsCard title="Color scheme"> + <Field.Switch + side="right" + label="Enable color settings" + name="enableColorSettings" + /> + {f.watch("enableColorSettings") && ( + <> + <Switch + side="right" + label="Live preview" + name="enableLivePreview" + help={enableLivePreview && "Disabling will reload the page without applying the changes."} + value={enableLivePreview} + onValueChange={(value) => { + setEnableLivePreview(value) + if (!value) { + // Reset to saved values if disabling preview + window.location.reload() + } else { + // Apply current form values as preview + applyLivePreview(f.watch("backgroundColor"), f.watch("accentColor")) + } + }} + /> + <div className="flex flex-col md:flex-row gap-3"> + <Field.ColorPicker + name="backgroundColor" + label="Background color" + help="Default: #070707" + /> + <Field.ColorPicker + name="accentColor" + label="Accent color" + help="Default: #6152df" + /> + </div> + </> + )} + + {f.watch("enableColorSettings") && ( + <div className="flex flex-wrap gap-3 w-full"> + {THEME_COLOR_BANK.map((opt) => ( + <div + key={opt.name} + className={cn( + "flex gap-3 items-center w-fit rounded-full border p-1 cursor-pointer", + themeSettings.backgroundColor === opt.backgroundColor && themeSettings.accentColor === opt.accentColor && "border-[--brand] ring-[--brand] ring-offset-1 ring-offset-[--background]", + )} + onClick={() => { + f.setValue("backgroundColor", opt.backgroundColor) + f.setValue("accentColor", opt.accentColor) + + if (enableLivePreview) { + applyLivePreview(opt.backgroundColor, opt.accentColor) + } else { + mutate({ + theme: { + id: 0, + ...themeSettings, + enableColorSettings: true, + backgroundColor: opt.backgroundColor, + accentColor: opt.accentColor, + }, + }, { + onSuccess() { + formRef.current?.reset(formRef.current?.getValues()) + }, + }) + } + }} + > + <div + className="flex gap-1" + > + <div + className="w-6 h-6 rounded-full border" + style={{ backgroundColor: opt.backgroundColor }} + /> + <div + className="w-6 h-6 rounded-full border" + style={{ backgroundColor: opt.accentColor }} + /> + </div> + </div> + ))} + </div> + )} + + </SettingsCard> + + <SettingsCard title="Background image"> + + <div className="flex flex-col md:flex-row gap-3"> + <Field.Text + label="Image path" + name="libraryScreenCustomBackgroundImage" + placeholder="e.g., image.png" + help="Background image for all pages. Dimmed on non-library screens." + /> + + <Field.Number + label="Opacity" + name="libraryScreenCustomBackgroundOpacity" + placeholder="Default: 10" + min={1} + max={100} + /> + + {/*<Field.Select*/} + {/* label="Blur"*/} + {/* name="libraryScreenCustomBackgroundBlur"*/} + {/* help="Can cause performance issues."*/} + {/* options={[*/} + {/* { label: "None", value: "-" },*/} + {/* { label: "5px", value: "5px" },*/} + {/* { label: "10px", value: "10px" },*/} + {/* { label: "15px", value: "15px" },*/} + {/* ]}*/} + {/*/>*/} + </div> + + </SettingsCard> + + <SettingsCard title="Banner image"> + + <div className="flex flex-col md:flex-row gap-3"> + <Field.Text + label="Image path" + name="libraryScreenCustomBannerImage" + placeholder="e.g., image.gif" + help="Banner image for all pages." + /> + <Field.Text + label="Position" + name="libraryScreenCustomBannerPosition" + placeholder="Default: 50% 50%" + /> + <Field.Number + label="Opacity" + name="libraryScreenCustomBannerOpacity" + placeholder="Default: 10" + min={1} + max={100} + /> + </div> + + </SettingsCard> + + <Accordion + type="single" + collapsible + className="border rounded-[--radius-md]" + triggerClass="dark:bg-[--paper]" + contentClass="!pt-2 dark:bg-[--paper]" + > + <AccordionItem value="more"> + <AccordionTrigger className="bg-gray-900 rounded-[--radius-md]"> + Advanced + </AccordionTrigger> + <AccordionContent className="space-y-4"> + + {serverStatus?.themeSettings?.customCSS !== customCSS.customCSS || serverStatus?.themeSettings?.mobileCustomCSS !== customCSS.mobileCustomCSS && ( + <Button + intent="white" + disabled={serverStatus?.themeSettings?.customCSS === customCSS.customCSS && serverStatus?.themeSettings?.mobileCustomCSS === customCSS.mobileCustomCSS} + onClick={() => { + setCustomCSS({ + customCSS: serverStatus?.themeSettings?.customCSS || "", + mobileCustomCSS: serverStatus?.themeSettings?.mobileCustomCSS || "", + }) + }} + > + Apply to this client + </Button> + )} + + <p className="text-[--muted] text-sm"> + The custom CSS will be saved on the server and needs to be applied manually to each client. + <br /> + In case of an error rendering the UI unusable, you can always remove it from the local storage using the + devtools. + </p> + + <div className="flex flex-col md:flex-row gap-3"> + + <Field.Textarea + label="Custom CSS" + name="customCSS" + placeholder="Custom CSS" + help="Applied above 1024px screen size." + /> + + <Field.Textarea + label="Mobile custom CSS" + name="mobileCustomCSS" + placeholder="Custom CSS" + help="Applied below 1024px screen size." + /> + + </div> + </AccordionContent> + </AccordionItem> + </Accordion> + + </TabsContent> + + <TabsContent value="navigation" className="space-y-4"> + + <SettingsCard title="Sidebar"> + + <Field.Switch + side="right" + label="Expand sidebar on hover" + name="expandSidebarOnHover" + help="Causes visual glitches with plugin tray." + /> + + <Field.Switch + side="right" + label="Disable transparency" + name="disableSidebarTransparency" + /> + + <Field.Combobox + label="Unpinned menu items" + name="unpinnedMenuItems" + emptyMessage="No items selected" + multiple + options={[ + { + label: "Library", + textValue: "Library", + value: "library", + }, + { + label: "Schedule", + textValue: "Schedule", + value: "schedule", + }, + { + label: "Manga", + textValue: "Manga", + value: "manga", + }, + { + label: "Discover", + textValue: "Discover", + value: "discover", + }, + { + label: "AniList", + textValue: "AniList", + value: "anilist", + }, + { + label: "Nakama", + textValue: "Nakama", + value: "nakama", + }, + { + label: "Auto Downloader", + textValue: "Auto Downloader", + value: "auto-downloader", + }, + { + label: "Torrent list", + textValue: "Torrent list", + value: "torrent-list", + }, + { + label: "Debrid", + textValue: "Debrid", + value: "debrid", + }, + { + label: "Scan summaries", + textValue: "Scan summaries", + value: "scan-summaries", + }, + { + label: "Search", + textValue: "Search", + value: "search", + }, + ]} + /> + + </SettingsCard> + + <SettingsCard title="Navbar"> + + <Field.Switch + side="right" + label={__isDesktop__ ? "Hide top navbar (Web interface)" : "Hide top navbar"} + name="hideTopNavbar" + help="Switches to sidebar-only mode." + /> + + </SettingsCard> + + </TabsContent> + + <TabsContent value="media" className="space-y-4"> + + <SettingsCard title="Collection screens"> + + {!serverStatus?.settings?.library?.enableWatchContinuity && ( + f.watch('continueWatchingDefaultSorting').includes("LAST_WATCHED") || + f.watch('animeLibraryCollectionDefaultSorting').includes("LAST_WATCHED") + ) && ( + <Alert + intent="alert" + description="Watch continuity needs to be enabled to use the last watched sorting options." + /> + )} + + <Field.RadioCards + label="Banner type" + name="libraryScreenBannerType" + options={[ + { + label: "Dynamic Banner", + value: "dynamic", + }, + { + label: "Custom Banner", + value: "custom", + }, + { + label: "None", + value: "none", + }, + ]} + stackClass="flex flex-col md:flex-row flex-wrap gap-2 space-y-0" + help={f.watch("libraryScreenBannerType") === ThemeLibraryScreenBannerType.Custom && "Use the banner image on all library screens."} + /> + + <Field.Switch + side="right" + label="Remove genre selector" + name="disableLibraryScreenGenreSelector" + /> + + <Field.Select + label="Continue watching sorting" + name="continueWatchingDefaultSorting" + options={CONTINUE_WATCHING_SORTING_OPTIONS.map(n => ({ value: n.value, label: n.label }))} + /> + + <Field.Select + label="Anime library sorting" + name="animeLibraryCollectionDefaultSorting" + options={ANIME_COLLECTION_SORTING_OPTIONS.filter(n => !n.value.includes("END")) + .map(n => ({ value: n.value, label: n.label }))} + /> + + <Field.Select + label="Manga library sorting" + name="mangaLibraryCollectionDefaultSorting" + options={MANGA_COLLECTION_SORTING_OPTIONS.filter(n => !n.value.includes("END")) + .map(n => ({ value: n.value, label: n.label }))} + /> + + + </SettingsCard> + + <SettingsCard title="Media page"> + + <Field.RadioCards + label="AniList banner image" + name="mediaPageBannerType" + options={ThemeMediaPageBannerTypeOptions.map(n => ({ value: n.value, label: n.label }))} + stackClass="flex flex-col md:flex-row flex-wrap gap-2 space-y-0" + help={ThemeMediaPageBannerTypeOptions.find(n => n.value === f.watch("mediaPageBannerType"))?.description} + /> + + <Field.RadioCards + label="Banner size" + name="mediaPageBannerSize" + options={ThemeMediaPageBannerSizeOptions.map(n => ({ value: n.value, label: n.label }))} + stackClass="flex flex-col md:flex-row flex-wrap gap-2 space-y-0" + help={ThemeMediaPageBannerSizeOptions.find(n => n.value === f.watch("mediaPageBannerSize"))?.description} + /> + + <Field.RadioCards + label="Banner info layout" + name="mediaPageBannerInfoBoxSize" + options={ThemeMediaPageInfoBoxSizeOptions.map(n => ({ value: n.value, label: n.label }))} + stackClass="flex flex-col md:flex-row flex-wrap gap-2 space-y-0" + /> + + <Field.Switch + side="right" + label="Blurred gradient background" + name="enableMediaPageBlurredBackground" + help="Can cause performance issues." + /> + + </SettingsCard> + + <SettingsCard title="Media card"> + + <Field.Switch + side="right" + label="Show anime unwatched count" + name="showAnimeUnwatchedCount" + /> + + <Field.Switch + side="right" + label="Show manga unread count" + name="showMangaUnreadCount" + /> + + <Field.Switch + side="right" + label="Glassy background" + name="enableMediaCardBlurredBackground" + /> + + </SettingsCard> + + <SettingsCard title="Episode card"> + + {/* <Field.Switch + side="right" + label="Legacy episode cards" + name="useLegacyEpisodeCard" + /> */} + + <Field.Switch + side="right" + label="Show anime info" + name="showEpisodeCardAnimeInfo" + /> + + <Field.Switch + side="right" + label="Hide episode summary" + name="hideEpisodeCardDescription" + /> + + <Field.Switch + side="right" + label="Hide downloaded episode filename" + name="hideDownloadedEpisodeCardFilename" + /> + + + </SettingsCard> + + <SettingsCard title="Carousel"> + + <Field.Switch + side="right" + label="Disable auto-scroll" + name="disableCarouselAutoScroll" + /> + + <Field.Switch + side="right" + label="Smaller episode cards" + name="smallerEpisodeCarouselSize" + /> + + </SettingsCard> + + </TabsContent> + + <TabsContent value="browser-client" className="space-y-4"> + + <SettingsCard> + <Switch + side="right" + label="Fix border rendering artifacts (client-specific)" + name="enableMediaCardStyleFix" + help="Seanime will try to fix border rendering artifacts. This setting only affects this client/browser." + value={fixBorderRenderingArtifacts} + onValueChange={(v) => { + setFixBorerRenderingArtifacts(v) + if (v) { + toast.success("Handling border rendering artifacts") + } else { + toast.success("Border rendering artifacts are no longer handled") + } + }} + /> + </SettingsCard> + + </TabsContent> + + {tab !== "browser-client" && <div className="mt-4"> + <Field.Submit role="save" intent="white" rounded loading={isPending}>Save</Field.Submit> + </div>} + + </Tabs> + </> + )} + </Form> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/layout.tsx new file mode 100644 index 0000000..043694d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/layout.tsx @@ -0,0 +1,18 @@ +"use client" +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) + +} + + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/settings/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/settings/page.tsx new file mode 100644 index 0000000..6249744 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/settings/page.tsx @@ -0,0 +1,856 @@ +"use client" +import { useOpenInExplorer } from "@/api/hooks/explorer.hooks" +import { useAnimeListTorrentProviderExtensions } from "@/api/hooks/extensions.hooks" +import { useSaveSettings } from "@/api/hooks/settings.hooks" +import { useGetTorrentstreamSettings } from "@/api/hooks/torrentstream.hooks" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { __issueReport_overlayOpenAtom } from "@/app/(main)/_features/issue-report/issue-report" +import { useServerStatus, useSetServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { ExternalPlayerLinkSettings, MediaplayerSettings } from "@/app/(main)/settings/_components/mediaplayer-settings" +import { PlaybackSettings } from "@/app/(main)/settings/_components/playback-settings" +import { __settings_tabAtom } from "@/app/(main)/settings/_components/settings-page.atoms" +import { SettingsIsDirty, SettingsSubmitButton } from "@/app/(main)/settings/_components/settings-submit-button" +import { DebridSettings } from "@/app/(main)/settings/_containers/debrid-settings" +import { FilecacheSettings } from "@/app/(main)/settings/_containers/filecache-settings" +import { LibrarySettings } from "@/app/(main)/settings/_containers/library-settings" +import { LogsSettings } from "@/app/(main)/settings/_containers/logs-settings" +import { MangaSettings } from "@/app/(main)/settings/_containers/manga-settings" +import { MediastreamSettings } from "@/app/(main)/settings/_containers/mediastream-settings" +import { ServerSettings } from "@/app/(main)/settings/_containers/server-settings" +import { TorrentstreamSettings } from "@/app/(main)/settings/_containers/torrentstream-settings" +import { UISettings } from "@/app/(main)/settings/_containers/ui-settings" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { SeaLink } from "@/components/shared/sea-link" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Field, Form } from "@/components/ui/form" +import { Separator } from "@/components/ui/separator" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { DEFAULT_TORRENT_CLIENT, DEFAULT_TORRENT_PROVIDER, settingsSchema, TORRENT_PROVIDER } from "@/lib/server/settings" +import { __isElectronDesktop__, __isTauriDesktop__ } from "@/types/constants" +import { useSetAtom } from "jotai" +import { useAtom } from "jotai/react" +import capitalize from "lodash/capitalize" +import { useRouter, useSearchParams } from "next/navigation" +import React from "react" +import { UseFormReturn } from "react-hook-form" +import { BiDonateHeart } from "react-icons/bi" +import { CgMediaPodcast, CgPlayListSearch } from "react-icons/cg" +import { FaBookReader, FaDiscord } from "react-icons/fa" +import { GrTest } from "react-icons/gr" +import { HiOutlineServerStack } from "react-icons/hi2" +import { ImDownload } from "react-icons/im" +import { IoLibrary, IoPlayBackCircleSharp } from "react-icons/io5" +import { LuBookKey, LuExternalLink, LuLaptop, LuLibrary, LuPalette, LuWandSparkles } from "react-icons/lu" +import { MdOutlineBroadcastOnHome, MdOutlineConnectWithoutContact, MdOutlineDownloading, MdOutlinePalette } from "react-icons/md" +import { RiFolderDownloadFill } from "react-icons/ri" +import { SiBittorrent } from "react-icons/si" +import { TbDatabaseExclamation } from "react-icons/tb" +import { VscDebugAlt } from "react-icons/vsc" +import { SettingsCard, SettingsNavCard, SettingsPageHeader } from "./_components/settings-card" +import { DiscordRichPresenceSettings } from "./_containers/discord-rich-presence-settings" +import { LocalSettings } from "./_containers/local-settings" +import { NakamaSettings } from "./_containers/nakama-settings" + +const tabsRootClass = cn("w-full grid grid-cols-1 lg:grid lg:grid-cols-[300px,1fr] gap-4") + +const tabsTriggerClass = cn( + "text-base px-6 rounded-[--radius-md] w-fit lg:w-full border-none data-[state=active]:bg-[--subtle] data-[state=active]:text-white dark:hover:text-white", + "h-9 lg:justify-start px-3 transition-all duration-200 hover:bg-[--subtle]/50 hover:transform", +) + +const tabsListClass = cn( + "w-full flex flex-wrap lg:flex-nowrap h-fit xl:h-10", + "lg:block p-2 lg:p-0", +) + +const tabContentClass = cn( + "space-y-4 animate-in fade-in-0 slide-in-from-right-2 duration-300", +) + +export const dynamic = "force-static" + +export default function Page() { + const status = useServerStatus() + const setServerStatus = useSetServerStatus() + const router = useRouter() + + const searchParams = useSearchParams() + + const { mutate, data, isPending } = useSaveSettings() + + const [tab, setTab] = useAtom(__settings_tabAtom) + const formRef = React.useRef<UseFormReturn<any>>(null) + + const { data: torrentProviderExtensions } = useAnimeListTorrentProviderExtensions() + + const { data: torrentstreamSettings } = useGetTorrentstreamSettings() + + const { mutate: openInExplorer, isPending: isOpening } = useOpenInExplorer() + + React.useEffect(() => { + if (!isPending && !!data?.settings) { + setServerStatus(data) + } + }, [data, isPending]) + + const setIssueRecorderOpen = useSetAtom(__issueReport_overlayOpenAtom) + + function handleOpenIssueRecorder() { + setIssueRecorderOpen(true) + router.push("/") + } + + const previousTab = React.useRef(tab) + React.useEffect(() => { + if (tab !== previousTab.current) { + previousTab.current = tab + formRef.current?.reset() + } + }, [tab]) + + React.useEffect(() => { + const initialTab = searchParams.get("tab") + if (initialTab) { + setTab(initialTab) + setTimeout(() => { + // Remove search param + if (searchParams.has("tab")) { + const newParams = new URLSearchParams(searchParams) + newParams.delete("tab") + router.replace(`?${newParams.toString()}`, { scroll: false }) + } + }, 500) + } + }, [searchParams]) + + return ( + <> + <CustomLibraryBanner discrete /> + <PageWrapper data-settings-page-container className="p-4 sm:p-8 space-y-4"> + {/*<Separator/>*/} + + + {/*<Card className="p-0 overflow-hidden">*/} + <Tabs + value={tab} + onValueChange={setTab} + className={tabsRootClass} + triggerClass={tabsTriggerClass} + listClass={tabsListClass} + data-settings-page-tabs + > + <TabsList className="flex-wrap max-w-full lg:space-y-2"> + <SettingsNavCard> + <div className="flex flex-col gap-4 md:flex-row justify-between items-center"> + <div className="space-y-2 my-3 px-2"> + <h4 className="text-center md:text-left text-xl font-bold">Settings</h4> + <div className="space-y-1"> + <p className="text-[--muted] text-sm text-center md:text-left flex items-center gap-2"> + {status?.version} {status?.versionName} - {capitalize(status?.os)}{__isTauriDesktop__ && + <span className="font-medium"> - Tauri</span>}{__isElectronDesktop__ && + <span className="font-medium"> - Denshi</span>} + </p> + {/* <p className="text-[--muted] text-sm text-center md:text-left">OS: {capitalize(status?.os)} {__isTauriDesktop__ && + <span className="font-medium">- Tauri</span>}{__isElectronDesktop__ && + <span className="font-medium">- Denshi</span>}</p> */} + </div> + </div> + <div> + + </div> + </div> + <div className="overflow-x-none lg:overflow-y-hidden overflow-y-scroll h-40 lg:h-auto rounded-[--radius-md] border lg:border-none space-y-1 lg:space-y-0"> + <TabsTrigger + value="seanime" + className="group" + ><LuWandSparkles className="text-lg mr-3 transition-transform duration-200" /> App</TabsTrigger> + {/* <TabsTrigger + value="local" + className="group" + ><LuUserCog className="text-lg mr-3 transition-transform duration-200" /> Local Account</TabsTrigger> */} + <TabsTrigger + value="library" + className="group" + ><IoLibrary className="text-lg mr-3 transition-transform duration-200" /> Anime Library</TabsTrigger> + + <div className="text-xs lg:text-[--muted] text-center py-1.5 uppercase px-3 border-gray-800 tracking-wide font-medium"> + Anime playback + </div> + + <TabsTrigger + value="playback" + className="group" + ><IoPlayBackCircleSharp className="text-lg mr-3 transition-transform duration-200" /> Video Playback</TabsTrigger> + <TabsTrigger + value="media-player" + className="group" + ><LuLaptop className="text-lg mr-3 transition-transform duration-200" /> Desktop Media Player</TabsTrigger> + <TabsTrigger + value="external-player-link" + className="group" + ><LuExternalLink className="text-lg mr-3 transition-transform duration-200" /> External Player Link</TabsTrigger> + <TabsTrigger + value="mediastream" + className="relative group" + ><MdOutlineBroadcastOnHome className="text-lg mr-3 transition-transform duration-200" /> Transcoding / Direct + play</TabsTrigger> + + <div className="text-xs lg:text-[--muted] text-center py-1.5 uppercase px-3 border-gray-800 tracking-wide font-medium"> + Torrenting + </div> + + <TabsTrigger + value="torrent" + className="group" + ><CgPlayListSearch className="text-lg mr-3 transition-transform duration-200" /> Torrent Provider</TabsTrigger> + <TabsTrigger + value="torrent-client" + className="group" + ><MdOutlineDownloading className="text-lg mr-3 transition-transform duration-200" /> Torrent Client</TabsTrigger> + <TabsTrigger + value="torrentstream" + className="relative group" + ><SiBittorrent className="text-lg mr-3 transition-transform duration-200" /> Torrent Streaming</TabsTrigger> + <TabsTrigger + value="debrid" + className="group" + ><HiOutlineServerStack className="text-lg mr-3 transition-transform duration-200" /> Debrid Service</TabsTrigger> + + <div className="text-xs lg:text-[--muted] text-center py-1.5 uppercase px-3 border-gray-800 tracking-wide font-medium"> + Other features + </div> + + <TabsTrigger + value="onlinestream" + className="group" + ><CgMediaPodcast className="text-lg mr-3 transition-transform duration-200" /> Online Streaming</TabsTrigger> + + <TabsTrigger + value="manga" + className="group" + ><FaBookReader className="text-lg mr-3 transition-transform duration-200" /> Manga</TabsTrigger> + <TabsTrigger + value="nakama" + className="group relative" + ><MdOutlineConnectWithoutContact className="text-lg mr-3 transition-transform duration-200" /> Nakama <GrTest + className="text-md text-orange-300/40 absolute right-2 lg:block hidden" + /></TabsTrigger> + <TabsTrigger + value="discord" + className="group" + ><FaDiscord className="text-lg mr-3 transition-transform duration-200" /> Discord</TabsTrigger> + + <div className="text-xs lg:text-[--muted] text-center py-1.5 uppercase px-3 border-gray-800 tracking-wide font-medium"> + Server & Interface + </div> + + <TabsTrigger + value="ui" + className="group" + ><MdOutlinePalette className="text-lg mr-3 transition-transform duration-200" /> User Interface</TabsTrigger> + {/* <TabsTrigger + value="cache" + className="group" + ><TbDatabaseExclamation className="text-lg mr-3 transition-transform duration-200" /> Cache</TabsTrigger> */} + <TabsTrigger + value="logs" + className="group" + ><LuBookKey className="text-lg mr-3 transition-transform duration-200" /> Logs & Cache</TabsTrigger> + </div> + </SettingsNavCard> + + <div className="flex justify-center !mt-0 pb-4"> + <SeaLink + href="https://github.com/sponsors/5rahim" + target="_blank" + rel="noopener noreferrer" + > + <Button + intent="gray-link" + size="md" + leftIcon={<BiDonateHeart className="text-lg" />} + > + Donate + </Button> + </SeaLink> + </div> + </TabsList> + + <div className=""> + <Form + schema={settingsSchema} + mRef={formRef} + onSubmit={data => { + mutate({ + library: { + libraryPath: data.libraryPath, + autoUpdateProgress: data.autoUpdateProgress, + disableUpdateCheck: data.disableUpdateCheck, + torrentProvider: data.torrentProvider, + autoScan: data.autoScan, + enableOnlinestream: data.enableOnlinestream, + includeOnlineStreamingInLibrary: data.includeOnlineStreamingInLibrary ?? false, + disableAnimeCardTrailers: data.disableAnimeCardTrailers, + enableManga: data.enableManga, + dohProvider: data.dohProvider === "-" ? "" : data.dohProvider, + openTorrentClientOnStart: data.openTorrentClientOnStart, + openWebURLOnStart: data.openWebURLOnStart, + refreshLibraryOnStart: data.refreshLibraryOnStart, + autoPlayNextEpisode: data.autoPlayNextEpisode ?? false, + enableWatchContinuity: data.enableWatchContinuity ?? false, + libraryPaths: data.libraryPaths ?? [], + autoSyncOfflineLocalData: data.autoSyncOfflineLocalData ?? false, + scannerMatchingThreshold: data.scannerMatchingThreshold, + scannerMatchingAlgorithm: data.scannerMatchingAlgorithm === "-" ? "" : data.scannerMatchingAlgorithm, + autoSyncToLocalAccount: data.autoSyncToLocalAccount ?? false, + autoSaveCurrentMediaOffline: data.autoSaveCurrentMediaOffline ?? false, + }, + nakama: { + enabled: data.nakamaEnabled ?? false, + username: data.nakamaUsername, + isHost: data.nakamaIsHost ?? false, + remoteServerURL: data.nakamaRemoteServerURL, + remoteServerPassword: data.nakamaRemoteServerPassword, + hostShareLocalAnimeLibrary: data.nakamaHostShareLocalAnimeLibrary ?? false, + hostPassword: data.nakamaHostPassword, + includeNakamaAnimeLibrary: data.includeNakamaAnimeLibrary ?? false, + hostUnsharedAnimeIds: data?.nakamaHostUnsharedAnimeIds ?? [], + hostEnablePortForwarding: data.nakamaHostEnablePortForwarding ?? false, + }, + manga: { + defaultMangaProvider: data.defaultMangaProvider === "-" ? "" : data.defaultMangaProvider, + mangaAutoUpdateProgress: data.mangaAutoUpdateProgress ?? false, + mangaLocalSourceDirectory: data.mangaLocalSourceDirectory || "", + }, + mediaPlayer: { + host: data.mediaPlayerHost, + defaultPlayer: data.defaultPlayer, + vlcPort: data.vlcPort, + vlcUsername: data.vlcUsername || "", + vlcPassword: data.vlcPassword, + vlcPath: data.vlcPath || "", + mpcPort: data.mpcPort, + mpcPath: data.mpcPath || "", + mpvSocket: data.mpvSocket || "", + mpvPath: data.mpvPath || "", + mpvArgs: data.mpvArgs || "", + iinaSocket: data.iinaSocket || "", + iinaPath: data.iinaPath || "", + iinaArgs: data.iinaArgs || "", + }, + torrent: { + defaultTorrentClient: data.defaultTorrentClient, + qbittorrentPath: data.qbittorrentPath, + qbittorrentHost: data.qbittorrentHost, + qbittorrentPort: data.qbittorrentPort, + qbittorrentPassword: data.qbittorrentPassword, + qbittorrentUsername: data.qbittorrentUsername, + qbittorrentTags: data.qbittorrentTags, + transmissionPath: data.transmissionPath, + transmissionHost: data.transmissionHost, + transmissionPort: data.transmissionPort, + transmissionUsername: data.transmissionUsername, + transmissionPassword: data.transmissionPassword, + showActiveTorrentCount: data.showActiveTorrentCount ?? false, + hideTorrentList: data.hideTorrentList ?? false, + }, + discord: { + enableRichPresence: data?.enableRichPresence ?? false, + enableAnimeRichPresence: data?.enableAnimeRichPresence ?? false, + enableMangaRichPresence: data?.enableMangaRichPresence ?? false, + richPresenceHideSeanimeRepositoryButton: data?.richPresenceHideSeanimeRepositoryButton ?? false, + richPresenceShowAniListMediaButton: data?.richPresenceShowAniListMediaButton ?? false, + richPresenceShowAniListProfileButton: data?.richPresenceShowAniListProfileButton ?? false, + richPresenceUseMediaTitleStatus: data?.richPresenceUseMediaTitleStatus ?? false, + }, + anilist: { + hideAudienceScore: data.hideAudienceScore, + enableAdultContent: data.enableAdultContent, + blurAdultContent: data.blurAdultContent, + }, + notifications: { + disableNotifications: data?.disableNotifications ?? false, + disableAutoDownloaderNotifications: data?.disableAutoDownloaderNotifications ?? false, + disableAutoScannerNotifications: data?.disableAutoScannerNotifications ?? false, + }, + }, { + onSuccess: () => { + formRef.current?.reset(formRef.current.getValues()) + }, + }) + }} + defaultValues={{ + libraryPath: status?.settings?.library?.libraryPath, + mediaPlayerHost: status?.settings?.mediaPlayer?.host, + torrentProvider: status?.settings?.library?.torrentProvider || DEFAULT_TORRENT_PROVIDER, // (Backwards compatibility) + autoScan: status?.settings?.library?.autoScan, + defaultPlayer: status?.settings?.mediaPlayer?.defaultPlayer, + vlcPort: status?.settings?.mediaPlayer?.vlcPort, + vlcUsername: status?.settings?.mediaPlayer?.vlcUsername, + vlcPassword: status?.settings?.mediaPlayer?.vlcPassword, + vlcPath: status?.settings?.mediaPlayer?.vlcPath, + mpcPort: status?.settings?.mediaPlayer?.mpcPort, + mpcPath: status?.settings?.mediaPlayer?.mpcPath, + mpvSocket: status?.settings?.mediaPlayer?.mpvSocket, + mpvPath: status?.settings?.mediaPlayer?.mpvPath, + mpvArgs: status?.settings?.mediaPlayer?.mpvArgs, + iinaSocket: status?.settings?.mediaPlayer?.iinaSocket, + iinaPath: status?.settings?.mediaPlayer?.iinaPath, + iinaArgs: status?.settings?.mediaPlayer?.iinaArgs, + defaultTorrentClient: status?.settings?.torrent?.defaultTorrentClient || DEFAULT_TORRENT_CLIENT, // (Backwards + // compatibility) + hideTorrentList: status?.settings?.torrent?.hideTorrentList ?? false, + qbittorrentPath: status?.settings?.torrent?.qbittorrentPath, + qbittorrentHost: status?.settings?.torrent?.qbittorrentHost, + qbittorrentPort: status?.settings?.torrent?.qbittorrentPort, + qbittorrentPassword: status?.settings?.torrent?.qbittorrentPassword, + qbittorrentUsername: status?.settings?.torrent?.qbittorrentUsername, + qbittorrentTags: status?.settings?.torrent?.qbittorrentTags, + transmissionPath: status?.settings?.torrent?.transmissionPath, + transmissionHost: status?.settings?.torrent?.transmissionHost, + transmissionPort: status?.settings?.torrent?.transmissionPort, + transmissionUsername: status?.settings?.torrent?.transmissionUsername, + transmissionPassword: status?.settings?.torrent?.transmissionPassword, + hideAudienceScore: status?.settings?.anilist?.hideAudienceScore ?? false, + autoUpdateProgress: status?.settings?.library?.autoUpdateProgress ?? false, + disableUpdateCheck: status?.settings?.library?.disableUpdateCheck ?? false, + enableOnlinestream: status?.settings?.library?.enableOnlinestream ?? false, + includeOnlineStreamingInLibrary: status?.settings?.library?.includeOnlineStreamingInLibrary ?? false, + disableAnimeCardTrailers: status?.settings?.library?.disableAnimeCardTrailers ?? false, + enableManga: status?.settings?.library?.enableManga ?? false, + enableRichPresence: status?.settings?.discord?.enableRichPresence ?? false, + enableAnimeRichPresence: status?.settings?.discord?.enableAnimeRichPresence ?? false, + enableMangaRichPresence: status?.settings?.discord?.enableMangaRichPresence ?? false, + enableAdultContent: status?.settings?.anilist?.enableAdultContent ?? false, + blurAdultContent: status?.settings?.anilist?.blurAdultContent ?? false, + dohProvider: status?.settings?.library?.dohProvider || "-", + openTorrentClientOnStart: status?.settings?.library?.openTorrentClientOnStart ?? false, + openWebURLOnStart: status?.settings?.library?.openWebURLOnStart ?? false, + refreshLibraryOnStart: status?.settings?.library?.refreshLibraryOnStart ?? false, + richPresenceHideSeanimeRepositoryButton: status?.settings?.discord?.richPresenceHideSeanimeRepositoryButton ?? false, + richPresenceShowAniListMediaButton: status?.settings?.discord?.richPresenceShowAniListMediaButton ?? false, + richPresenceShowAniListProfileButton: status?.settings?.discord?.richPresenceShowAniListProfileButton ?? false, + richPresenceUseMediaTitleStatus: status?.settings?.discord?.richPresenceUseMediaTitleStatus ?? false, + disableNotifications: status?.settings?.notifications?.disableNotifications ?? false, + disableAutoDownloaderNotifications: status?.settings?.notifications?.disableAutoDownloaderNotifications ?? false, + disableAutoScannerNotifications: status?.settings?.notifications?.disableAutoScannerNotifications ?? false, + defaultMangaProvider: status?.settings?.manga?.defaultMangaProvider || "-", + mangaAutoUpdateProgress: status?.settings?.manga?.mangaAutoUpdateProgress ?? false, + showActiveTorrentCount: status?.settings?.torrent?.showActiveTorrentCount ?? false, + autoPlayNextEpisode: status?.settings?.library?.autoPlayNextEpisode ?? false, + enableWatchContinuity: status?.settings?.library?.enableWatchContinuity ?? false, + libraryPaths: status?.settings?.library?.libraryPaths ?? [], + autoSyncOfflineLocalData: status?.settings?.library?.autoSyncOfflineLocalData ?? false, + scannerMatchingThreshold: status?.settings?.library?.scannerMatchingThreshold ?? 0.5, + scannerMatchingAlgorithm: status?.settings?.library?.scannerMatchingAlgorithm || "-", + mangaLocalSourceDirectory: status?.settings?.manga?.mangaLocalSourceDirectory || "", + autoSyncToLocalAccount: status?.settings?.library?.autoSyncToLocalAccount ?? false, + nakamaEnabled: status?.settings?.nakama?.enabled ?? false, + nakamaUsername: status?.settings?.nakama?.username ?? "", + nakamaIsHost: status?.settings?.nakama?.isHost ?? false, + nakamaRemoteServerURL: status?.settings?.nakama?.remoteServerURL ?? "", + nakamaRemoteServerPassword: status?.settings?.nakama?.remoteServerPassword ?? "", + nakamaHostShareLocalAnimeLibrary: status?.settings?.nakama?.hostShareLocalAnimeLibrary ?? false, + nakamaHostPassword: status?.settings?.nakama?.hostPassword ?? "", + includeNakamaAnimeLibrary: status?.settings?.nakama?.includeNakamaAnimeLibrary ?? false, + nakamaHostUnsharedAnimeIds: status?.settings?.nakama?.hostUnsharedAnimeIds ?? [], + autoSaveCurrentMediaOffline: status?.settings?.library?.autoSaveCurrentMediaOffline ?? false, + }} + stackClass="space-y-0 relative" + > + {(f) => { + return <> + <SettingsIsDirty /> + <TabsContent value="seanime" className={tabContentClass}> + + <SettingsPageHeader + title="App" + description="General app settings" + icon={LuWandSparkles} + /> + + <div className="flex flex-wrap gap-2 slide-in-from-bottom duration-500 delay-150"> + {!!status?.dataDir && <Button + size="sm" + intent="gray-outline" + onClick={() => openInExplorer({ + path: status?.dataDir, + })} + className="transition-all duration-200 hover:scale-105 hover:shadow-md" + leftIcon={ + <RiFolderDownloadFill className="transition-transform duration-200 group-hover:scale-110" />} + > + Open Data directory + </Button>} + <Button + size="sm" + intent="gray-outline" + onClick={handleOpenIssueRecorder} + leftIcon={<VscDebugAlt className="transition-transform duration-200 group-hover:scale-110" />} + className="transition-all duration-200 hover:scale-105 hover:shadow-md group" + > + Record an issue + </Button> + </div> + + <ServerSettings isPending={isPending} /> + + </TabsContent> + + <TabsContent value="library" className={tabContentClass}> + + <SettingsPageHeader + title="Anime Library" + description="Manage your local anime library" + icon={LuLibrary} + /> + + <LibrarySettings isPending={isPending} /> + + </TabsContent> + + <TabsContent value="local" className={tabContentClass}> + + <LocalSettings isPending={isPending} /> + + </TabsContent> + + <TabsContent value="manga" className={tabContentClass}> + + <MangaSettings isPending={isPending} /> + + </TabsContent> + + <TabsContent value="onlinestream" className={tabContentClass}> + + <SettingsPageHeader + title="Online Streaming" + description="Configure online streaming settings" + icon={CgMediaPodcast} + /> + + <SettingsCard> + <Field.Switch + side="right" + name="enableOnlinestream" + label="Enable" + help="Watch anime episodes from online sources." + /> + </SettingsCard> + + <SettingsCard title="My library"> + <Field.Switch + side="right" + name="includeOnlineStreamingInLibrary" + label="Include in library" + help="Add non-downloaded shows that are in your currently watching list to 'My library' for streaming" + /> + </SettingsCard> + + <SettingsSubmitButton isPending={isPending} /> + + </TabsContent> + + <TabsContent value="discord" className={tabContentClass}> + + <SettingsPageHeader + title="Discord" + description="Configure Discord rich presence settings" + icon={FaDiscord} + /> + + <DiscordRichPresenceSettings /> + + <SettingsSubmitButton isPending={isPending} /> + + </TabsContent> + + <TabsContent value="torrent" className={tabContentClass}> + + <SettingsPageHeader + title="Torrent Provider" + description="Configure the torrent provider" + icon={CgPlayListSearch} + /> + + <SettingsCard> + <Field.Select + name="torrentProvider" + // label="Torrent Provider" + help="Used by the search engine and auto downloader. AnimeTosho is recommended for better results. Select 'None' if you don't need torrent support." + leftIcon={<RiFolderDownloadFill className="text-orange-500" />} + options={[ + ...(torrentProviderExtensions?.filter(ext => ext?.settings?.type === "main")?.map(ext => ({ + label: ext.name, + value: ext.id, + })) ?? []).sort((a, b) => a?.label?.localeCompare(b?.label) ?? 0), + { label: "None", value: TORRENT_PROVIDER.NONE }, + ]} + /> + </SettingsCard> + + + {/*<Separator />*/} + + {/*<h3>DNS over HTTPS</h3>*/} + + {/*<Field.Select*/} + {/* name="dohProvider"*/} + {/* // label="Torrent Provider"*/} + {/* help="Choose a DNS over HTTPS provider to resolve domain names for torrent search."*/} + {/* leftIcon={<FcFilingCabinet className="-500" />}*/} + {/* options={[*/} + {/* { label: "None", value: "-" },*/} + {/* { label: "Cloudflare", value: "cloudflare" },*/} + {/* { label: "Quad9", value: "quad9" },*/} + {/* ]}*/} + {/*/>*/} + + <SettingsSubmitButton isPending={isPending} /> + + </TabsContent> + + <TabsContent value="media-player" className={tabContentClass}> + <MediaplayerSettings isPending={isPending} /> + </TabsContent> + + + <TabsContent value="external-player-link" className={tabContentClass}> + <ExternalPlayerLinkSettings /> + </TabsContent> + + <TabsContent value="playback" className={tabContentClass}> + <PlaybackSettings /> + </TabsContent> + + <TabsContent value="torrent-client" className={tabContentClass}> + + <SettingsPageHeader + title="Torrent Client" + description="Configure the torrent client" + icon={MdOutlineDownloading} + /> + + <SettingsCard> + <Field.Select + name="defaultTorrentClient" + label="Default torrent client" + options={[ + { label: "qBittorrent", value: "qbittorrent" }, + { label: "Transmission", value: "transmission" }, + { label: "None", value: "none" }, + ]} + /> + </SettingsCard> + + <SettingsCard> + <Accordion + type="single" + className="" + triggerClass="text-[--muted] dark:data-[state=open]:text-white px-0 dark:hover:bg-transparent hover:bg-transparent dark:hover:text-white hover:text-black transition-all duration-200 hover:translate-x-1" + itemClass="border-b border-[--border] rounded-[--radius] transition-all duration-200 hover:border-[--brand]/30" + contentClass="pb-8 animate-in slide-in-from-top-2 duration-300" + collapsible + defaultValue={status?.settings?.torrent?.defaultTorrentClient} + > + <AccordionItem value="qbittorrent"> + <AccordionTrigger> + <h4 className="flex gap-2 items-center"><ImDownload className="text-blue-400" /> qBittorrent + </h4> + </AccordionTrigger> + <AccordionContent className="p-0 py-4 space-y-4"> + <Field.Text + name="qbittorrentHost" + label="Host" + /> + <div className="flex flex-col md:flex-row gap-4"> + <Field.Text + name="qbittorrentUsername" + label="Username" + /> + <Field.Text + name="qbittorrentPassword" + label="Password" + /> + <Field.Number + name="qbittorrentPort" + label="Port" + formatOptions={{ + useGrouping: false, + }} + /> + </div> + <Field.Text + name="qbittorrentPath" + label="Executable" + /> + <Field.Text + name="qbittorrentTags" + label="Tags" + help="Comma separated tags to apply to downloaded torrents. e.g. seanime,anime" + /> + </AccordionContent> + </AccordionItem> + <AccordionItem value="transmission"> + <AccordionTrigger> + <h4 className="flex gap-2 items-center"> + <ImDownload className="text-orange-200" /> Transmission</h4> + </AccordionTrigger> + <AccordionContent className="p-0 py-4 space-y-4"> + <Field.Text + name="transmissionHost" + label="Host" + /> + <div className="flex flex-col md:flex-row gap-4"> + <Field.Text + name="transmissionUsername" + label="Username" + /> + <Field.Text + name="transmissionPassword" + label="Password" + /> + <Field.Number + name="transmissionPort" + label="Port" + formatOptions={{ + useGrouping: false, + }} + /> + </div> + <Field.Text + name="transmissionPath" + label="Executable" + /> + </AccordionContent> + </AccordionItem> + </Accordion> + </SettingsCard> + + <SettingsCard title="User Interface"> + <Field.Switch + side="right" + name="hideTorrentList" + label="Hide torrent list navigation icon" + /> + <Field.Switch + side="right" + name="showActiveTorrentCount" + label="Show active torrent count" + help="Show the number of active torrents in the sidebar. (Memory intensive)" + /> + </SettingsCard> + + <SettingsSubmitButton isPending={isPending} /> + + </TabsContent> + + <TabsContent value="nakama" className={tabContentClass}> + + <NakamaSettings isPending={isPending} /> + + </TabsContent> + </> + }} + </Form> + + {/* <TabsContent value="cache" className={tabContentClass}> + + <SettingsPageHeader + title="Cache" + description="Manage the cache" + icon={TbDatabaseExclamation} + /> + + <FilecacheSettings /> + + </TabsContent> */} + + <TabsContent value="mediastream" className={tabContentClass}> + + <SettingsPageHeader + title="Transcoding / Direct play" + description="Manage transcoding and direct play settings" + icon={MdOutlineBroadcastOnHome} + /> + + <MediastreamSettings /> + + </TabsContent> + + <TabsContent value="ui" className={tabContentClass}> + + <SettingsPageHeader + title="User Interface" + description="Customize the user interface" + icon={LuPalette} + /> + + <UISettings /> + + </TabsContent> + + <TabsContent value="torrentstream" className={tabContentClass}> + + <SettingsPageHeader + title="Torrent Streaming" + description="Configure torrent streaming settings" + icon={SiBittorrent} + /> + + <TorrentstreamSettings settings={torrentstreamSettings} /> + + </TabsContent> + + <TabsContent value="logs" className={tabContentClass}> + + <SettingsPageHeader + title="Logs" + description="View the logs" + icon={LuBookKey} + /> + + + <LogsSettings /> + + <Separator /> + + <SettingsPageHeader + title="Cache" + description="Manage the cache" + icon={TbDatabaseExclamation} + /> + + <FilecacheSettings /> + + </TabsContent> + + + {/*<TabsContent value="data" className="space-y-4">*/} + + {/* <DataSettings />*/} + + {/*</TabsContent>*/} + + <TabsContent value="debrid" className={tabContentClass}> + + <SettingsPageHeader + title="Debrid Service" + description="Configure your debrid service integration" + icon={HiOutlineServerStack} + /> + + <DebridSettings /> + + </TabsContent> + </div> + </Tabs> + {/*</Card>*/} + + </PageWrapper> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/sync/_containers/sync-add-media-modal.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/sync/_containers/sync-add-media-modal.tsx new file mode 100644 index 0000000..269d1c2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/sync/_containers/sync-add-media-modal.tsx @@ -0,0 +1,429 @@ +import { Anime_LibraryCollection, Anime_LibraryCollectionEntry, Manga_Collection, Manga_CollectionEntry } from "@/api/generated/types" +import { useLocalAddTrackedMedia, useLocalRemoveTrackedMedia } from "@/api/hooks/local.hooks" +import { useGetMangaCollection } from "@/api/hooks/manga.hooks" +import { animeLibraryCollectionWithoutStreamsAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { imageShimmer } from "@/components/shared/image-helpers" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import { useAtomValue } from "jotai/react" +import Image from "next/image" +import React from "react" +import { FaCircleCheck, FaRegCircleCheck } from "react-icons/fa6" +import { MdOutlineDownloadForOffline } from "react-icons/md" + +type SyncAddMediaModalProps = { + savedMediaIds: number[] +} + +export function SyncAddMediaModal(props: SyncAddMediaModalProps) { + + const { savedMediaIds } = props + + const [selectedMedia, setSelectedMedia] = React.useState<{ mediaId: number, type: "manga" | "anime" }[]>([]) + + const { mutate: addMedia, isPending: isAdding } = useLocalAddTrackedMedia() + + function handleSave() { + addMedia({ + media: selectedMedia, + }, { + onSuccess: () => { + setSelectedMedia([]) + }, + }) + } + + return ( + <Modal + title="Saved media" + contentClass="max-w-4xl" + trigger={<Button + intent="white" + rounded + leftIcon={<MdOutlineDownloadForOffline className="text-2xl" />} + loading={isAdding} + > + Save media + </Button>} + > + + <p className="text-[--muted]"> + Select the media you want to save locally. Click on already saved media to remove it from local storage. + </p> + + <MediaSelector + selectedMedia={selectedMedia} + setSelectedMedia={setSelectedMedia} + savedMediaIds={savedMediaIds} + onSave={handleSave} + /> + </Modal> + ) +} + +type MediaSelectorProps = { + children?: React.ReactNode + savedMediaIds: number[] + selectedMedia: { mediaId: number, type: "manga" | "anime" }[] + setSelectedMedia: React.Dispatch<React.SetStateAction<{ mediaId: number, type: "manga" | "anime" }[]>> + onSave: () => void +} + +function MediaSelector(props: MediaSelectorProps) { + + const { + savedMediaIds, + selectedMedia, + setSelectedMedia, + onSave, + } = props + + const animeLibraryCollection = useAtomValue(animeLibraryCollectionWithoutStreamsAtom) + + const { data: mangaLibraryCollection } = useGetMangaCollection() + + const { mutate: removeMedia, isPending: isRemoving } = useLocalRemoveTrackedMedia() + + function handleToggleAnime(mediaId: number) { + setSelectedMedia(prev => { + if (prev.find(n => n.mediaId === mediaId)) { + return prev.filter(n => n.mediaId !== mediaId) + } else { + return [...prev, { mediaId, type: "anime" as const }] + } + }) + } + + function handleToggleManga(mediaId: number) { + setSelectedMedia(prev => { + if (prev.find(n => n.mediaId === mediaId)) { + return prev.filter(n => n.mediaId !== mediaId) + } else { + return [...prev, { mediaId, type: "manga" as const }] + } + }) + } + + function handleBatchSelectAnime(listType: string, entries: (Anime_LibraryCollectionEntry | Manga_CollectionEntry)[], select: boolean) { + const mediaIds = entries.filter(entry => !savedMediaIds.includes(entry.mediaId)).map(entry => entry.mediaId) + + setSelectedMedia(prev => { + if (select) { + const newSelections = mediaIds + .filter(mediaId => !prev.find(n => n.mediaId === mediaId)) + .map(mediaId => ({ mediaId, type: "anime" as const })) + return [...prev, ...newSelections] + } else { + return prev.filter(item => !mediaIds.includes(item.mediaId) || item.type !== "anime") + } + }) + } + + function handleBatchSelectManga(listType: string, entries: (Anime_LibraryCollectionEntry | Manga_CollectionEntry)[], select: boolean) { + const mediaIds = entries.filter(entry => !savedMediaIds.includes(entry.mediaId)).map(entry => entry.mediaId) + + setSelectedMedia(prev => { + if (select) { + const newSelections = mediaIds + .filter(mediaId => !prev.find(n => n.mediaId === mediaId)) + .map(mediaId => ({ mediaId, type: "manga" as const })) + return [...prev, ...newSelections] + } else { + return prev.filter(item => !mediaIds.includes(item.mediaId) || item.type !== "manga") + } + }) + } + + return ( + <div className="space-y-4"> + + <div className="flex items-center"> + <div className="flex flex-1"></div> + + <Button + intent="white" + onClick={onSave} + disabled={selectedMedia.length === 0} + rounded + leftIcon={<MdOutlineDownloadForOffline className="text-2xl" />} + > + Save locally + </Button> + </div> + + {animeLibraryCollection && <> + <h2 className="text-center">Anime</h2> + <MediaList + collection={animeLibraryCollection} + selectedMedia={selectedMedia} + savedMediaIds={savedMediaIds} + onBatchSelect={handleBatchSelectAnime} + entry={entry => ( + <MediaItem + entry={entry} + onClick={() => handleToggleAnime(entry.mediaId)} + isSelected={!!selectedMedia.find(n => n.mediaId === entry.mediaId)} + isSaved={savedMediaIds.includes(entry.mediaId)} + onUntrack={() => { + removeMedia({ mediaId: entry.mediaId, type: "anime" }) + }} + isPending={isRemoving} + /> + )} + /> + </>} + {mangaLibraryCollection && <> + <h2 className="text-center">Manga</h2> + <MediaList + collection={mangaLibraryCollection} + selectedMedia={selectedMedia} + savedMediaIds={savedMediaIds} + onBatchSelect={handleBatchSelectManga} + entry={entry => ( + <MediaItem + entry={entry} + onClick={() => handleToggleManga(entry.mediaId)} + isSelected={!!selectedMedia.find(n => n.mediaId === entry.mediaId)} + isSaved={savedMediaIds.includes(entry.mediaId)} + onUntrack={() => { + removeMedia({ mediaId: entry.mediaId, type: "manga" }) + }} + isPending={isRemoving} + /> + )} + /> + </>} + </div> + ) +} + +function MediaList(props: { + collection: Anime_LibraryCollection | Manga_Collection, + entry: (entry: Anime_LibraryCollectionEntry | Manga_CollectionEntry) => React.ReactElement, + selectedMedia: { mediaId: number, type: "manga" | "anime" }[], + savedMediaIds: number[], + onBatchSelect: (listType: string, entries: (Anime_LibraryCollectionEntry | Manga_CollectionEntry)[], select: boolean) => void +}) { + const { collection, entry, selectedMedia, savedMediaIds, onBatchSelect } = props + + const lists = React.useMemo(() => { + return { + CURRENT: collection.lists?.find(n => n.type === "CURRENT") + ?.entries + ?.filter(Boolean) + ?.toSorted((a, b) => a.media!.title!.userPreferred!.localeCompare(b.media!.title!.userPreferred!)) ?? [], + PLANNING: collection.lists?.find(n => n.type === "PLANNING") + ?.entries + ?.filter(Boolean) + ?.toSorted((a, b) => a.media!.title!.userPreferred!.localeCompare(b.media!.title!.userPreferred!)) ?? [], + COMPLETED: collection.lists?.find(n => n.type === "COMPLETED") + ?.entries + ?.filter(Boolean) + ?.toSorted((a, b) => a.media!.title!.userPreferred!.localeCompare(b.media!.title!.userPreferred!)) ?? [], + PAUSED: collection.lists?.find(n => n.type === "PAUSED") + ?.entries + ?.filter(Boolean) + ?.toSorted((a, b) => a.media!.title!.userPreferred!.localeCompare(b.media!.title!.userPreferred!)) ?? [], + DROPPED: collection.lists?.find(n => n.type === "DROPPED") + ?.entries + ?.filter(Boolean) + ?.toSorted((a, b) => a.media!.title!.userPreferred!.localeCompare(b.media!.title!.userPreferred!)) ?? [], + } + }, [collection]) + + return ( + <> + {!!lists.CURRENT.length && ( + <MediaListSection + listType="CURRENT" + title="Current" + entries={lists.CURRENT} + selectedMedia={selectedMedia} + savedMediaIds={savedMediaIds} + onBatchSelect={onBatchSelect} + entry={entry} + /> + )} + {!!lists.PAUSED.length && ( + <MediaListSection + listType="PAUSED" + title="Paused" + entries={lists.PAUSED} + selectedMedia={selectedMedia} + savedMediaIds={savedMediaIds} + onBatchSelect={onBatchSelect} + entry={entry} + /> + )} + {!!lists.PLANNING.length && ( + <MediaListSection + listType="PLANNING" + title="Planning" + entries={lists.PLANNING} + selectedMedia={selectedMedia} + savedMediaIds={savedMediaIds} + onBatchSelect={onBatchSelect} + entry={entry} + /> + )} + {!!lists.COMPLETED.length && ( + <MediaListSection + listType="COMPLETED" + title="Completed" + entries={lists.COMPLETED} + selectedMedia={selectedMedia} + savedMediaIds={savedMediaIds} + onBatchSelect={onBatchSelect} + entry={entry} + /> + )} + {!!lists.DROPPED.length && ( + <MediaListSection + listType="DROPPED" + title="Dropped" + entries={lists.DROPPED} + selectedMedia={selectedMedia} + savedMediaIds={savedMediaIds} + onBatchSelect={onBatchSelect} + entry={entry} + /> + )} + </> + ) +} + +function MediaListSection(props: { + listType: string + title: string + entries: (Anime_LibraryCollectionEntry | Manga_CollectionEntry)[] + selectedMedia: { mediaId: number, type: "manga" | "anime" }[] + savedMediaIds: number[] + onBatchSelect: (listType: string, entries: (Anime_LibraryCollectionEntry | Manga_CollectionEntry)[], select: boolean) => void + entry: (entry: Anime_LibraryCollectionEntry | Manga_CollectionEntry) => React.ReactElement +}) { + const { listType, title, entries, selectedMedia, savedMediaIds, onBatchSelect, entry } = props + + const checkboxState = React.useMemo(() => { + const selectableEntries = entries.filter(entry => !savedMediaIds.includes(entry.mediaId)) + if (selectableEntries.length === 0) return { value: false } + + const selectedCount = selectableEntries.filter(entry => + selectedMedia.some(item => item.mediaId === entry.mediaId), + ).length + + if (selectedCount === 0) return { value: false } + if (selectedCount === selectableEntries.length) return { value: true } + return { value: "indeterminate" as const } + }, [entries, selectedMedia, savedMediaIds]) + + const selectableCount = entries.filter(entry => !savedMediaIds.includes(entry.mediaId)).length + + const handleValueChange = (newValue: boolean | "indeterminate") => { + onBatchSelect(listType, entries, newValue === true) + } + + return ( + <> + <div className="flex items-center gap-2 border-b pb-1 mb-1"> + <h4 className="flex-1">{title}</h4> + <Checkbox + value={checkboxState.value} + onValueChange={handleValueChange} + disabled={selectableCount === 0} + fieldClass="w-fit items-center justify-end" + /> + </div> + <div className="grid grid-cols-3 md:grid-cols-6 gap-2"> + {entries.map(n => { + return <React.Fragment key={n.mediaId}> + {entry(n)} + </React.Fragment> + })} + </div> + </> + ) +} + +function MediaItem(props: { + entry: Anime_LibraryCollectionEntry | Manga_CollectionEntry, + onClick: () => void, + onUntrack: () => void, + isSelected: boolean, + isSaved: boolean + isPending: boolean +}) { + const { entry, onClick, isSelected, isSaved, onUntrack, isPending } = props + + const confirmUntrack = useConfirmationDialog({ + title: "Remove offline data", + description: "This action will remove the offline data for this media entry. Are you sure you want to proceed?", + onConfirm: () => { + onUntrack() + }, + }) + + return ( + <> + <div + key={entry.mediaId} + className={cn( + "col-span-1 aspect-[6/7] rounded-[--radius-md] overflow-hidden relative bg-[var(--background)] cursor-pointer transition-opacity select-none", + isSaved && "", + isPending && "pointer-events-none", + )} + onClick={() => { + if (isPending) return + if (!isSaved) { + onClick() + } else { + confirmUntrack.open() + } + }} + > + {isSaved && ( + <div className="absolute top-0 left-0 w-full h-full flex items-center justify-center z-[10]"> + <FaCircleCheck className="text-3xl" /> + </div> + )} + {(isSelected && !isSaved) && ( + <div className="absolute top-2 left-2 w-full h-full flex z-[10]"> + <FaRegCircleCheck className="text-xl bg-black/50 rounded-full p-1" /> + </div> + )} + <Image + src={entry.media?.coverImage?.large || entry.media?.bannerImage || ""} + placeholder={imageShimmer(700, 475)} + sizes="10rem" + fill + alt="" + className={cn( + "object-center object-cover rounded-[--radius-md] transition-opacity", + isSelected ? "opacity-100" : "opacity-60", + )} + /> + <p + className={cn( + "line-clamp-2 text-sm absolute m-2 bottom-0 font-semibold z-[10]", + isSaved && "text-[--green]", + )} + > + {entry.media?.title?.userPreferred || entry.media?.title?.romaji} + </p> + <div + className="z-[5] absolute -bottom-1 w-full h-[80%] bg-gradient-to-t from-[--background] to-transparent" + /> + <div + className={cn( + "z-[5] absolute top-0 w-full h-[80%] bg-gradient-to-b from-[--background] to-transparent transition-opacity", + isSelected ? "opacity-0" : "opacity-100 hover:opacity-80", + )} + /> + </div> + + <ConfirmationDialog {...confirmUntrack} /> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/sync/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/sync/layout.tsx new file mode 100644 index 0000000..48ab9bf --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/sync/layout.tsx @@ -0,0 +1,14 @@ +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/sync/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/sync/page.tsx new file mode 100644 index 0000000..d159178 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/sync/page.tsx @@ -0,0 +1,352 @@ +"use client" +import { AL_BaseAnime, AL_BaseManga, Local_QueueState } from "@/api/generated/types" +import { + useLocalGetHasLocalChanges, + useLocalGetLocalStorageSize, + useLocalGetTrackedMediaItems, + useLocalSetHasLocalChanges, + useLocalSyncAnilistData, + useLocalSyncData, + useSetOfflineMode, +} from "@/api/hooks/local.hooks" +import { useGetMangaCollection } from "@/api/hooks/manga.hooks" +import { animeLibraryCollectionWithoutStreamsAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms" +import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid" +import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card" +import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { SyncAddMediaModal } from "@/app/(main)/sync/_containers/sync-add-media-modal" +import { LuffyError } from "@/components/shared/luffy-error" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { Alert } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner, Spinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { Separator } from "@/components/ui/separator" +import { anilist_getListDataFromEntry } from "@/lib/helpers/media" +import { WSEvents } from "@/lib/server/ws-events" +import { useAtomValue } from "jotai/react" +import React from "react" +import { LuCloud, LuCloudDownload, LuCloudOff, LuCloudUpload, LuFolderSync } from "react-icons/lu" +import { VscSyncIgnored } from "react-icons/vsc" +import { toast } from "sonner" + +export const dynamic = "force-static" + +export default function Page() { + const serverStatus = useServerStatus() + + const [syncModalOpen, setSyncModalOpen] = React.useState(false) + + const { data: trackedMediaItems, isLoading } = useLocalGetTrackedMediaItems() + const { mutate: syncLocal, isPending: isSyncingLocal } = useLocalSyncData() + const { mutate: syncAnilist, isPending: isSyncingAnilist } = useLocalSyncAnilistData() + const { data: hasLocalChanges } = useLocalGetHasLocalChanges() + const { mutate: syncHasLocalChanges, isPending: isChangingLocalChangeStatus } = useLocalSetHasLocalChanges() + const { data: localStorageSize } = useLocalGetLocalStorageSize() + const { mutate: setOfflineMode, isPending: isSettingOfflineMode } = useSetOfflineMode() + + const trackedAnimeItems = React.useMemo(() => { + return trackedMediaItems?.filter(n => n.type === "anime" && !!n.animeEntry?.media) ?? [] + }, [trackedMediaItems]) + + const trackedMangaItems = React.useMemo(() => { + return trackedMediaItems?.filter(n => n.type === "manga" && !!n.mangaEntry?.media) ?? [] + }, [trackedMediaItems]) + + const animeLibraryCollection = useAtomValue(animeLibraryCollectionWithoutStreamsAtom) + const { data: mangaLibraryCollection } = useGetMangaCollection() + + const unsavedAnime = React.useMemo(() => { + const trackedIds = new Set(trackedAnimeItems.map(n => n.mediaId)) + const currentList = animeLibraryCollection?.lists?.find(n => n.type === "CURRENT") + let unsavedAnime: AL_BaseAnime[] = [] + // only include entries that have local files + for (const entry of currentList?.entries ?? []) { + if (!trackedIds.has(entry.mediaId)) { + unsavedAnime.push(entry.media!) + } + } + return unsavedAnime + }, [animeLibraryCollection?.lists, trackedAnimeItems]) + + const unsavedManga = React.useMemo(() => { + const trackedIds = new Set(trackedMangaItems.map(n => n.mediaId)) + const currentList = mangaLibraryCollection?.lists?.find(n => n.type === "CURRENT") + let unsavedManga: AL_BaseManga[] = [] + for (const entry of currentList?.entries ?? []) { + if (!trackedIds.has(entry.mediaId)) { + unsavedManga.push(entry.media!) + } + } + return unsavedManga + }, [mangaLibraryCollection?.lists, trackedMangaItems]) + + const [queueState, setQueueState] = React.useState<Local_QueueState | null>(null) + useWebsocketMessageListener<Local_QueueState>({ + type: WSEvents.SYNC_LOCAL_QUEUE_STATE, + onMessage: data => { + setQueueState(data) + }, + }) + + function handleSyncLocal() { + syncLocal(undefined, { + onSuccess: () => { + setSyncModalOpen(false) + }, + }) + } + + function handleSyncAnilist() { + syncAnilist(undefined, { + onSuccess: () => { + setSyncModalOpen(false) + }, + }) + } + + function handleIgnoreLocalChanges() { + syncHasLocalChanges({ + updated: false, + }, { + onSuccess: () => { + toast.success("Local changes ignored.") + handleSyncLocal() + }, + }) + } + + if (isLoading) return <LoadingSpinner /> + + if (serverStatus?.user?.isSimulated) { + return <LuffyError + title="Not authenticated" + > + This feature is only available for authenticated users. + </LuffyError> + } + + return ( + <PageWrapper + className="p-4 sm:p-8 pt-4 relative space-y-8" + > + + <Button + intent="gray-subtle" + rounded + className="" + leftIcon={serverStatus?.isOffline ? <LuCloudOff className="text-2xl" /> : <LuCloud className="text-2xl" />} + loading={isSettingOfflineMode} + onClick={() => { + setOfflineMode({ + enabled: !serverStatus?.isOffline, + }) + }} + > + {serverStatus?.isOffline ? "Disable offline mode" : "Enable offline mode"} + </Button> + + <div className="flex flex-col lg:flex-row gap-2"> + <div> + <h2 className="">Offline media</h2> + <p className="text-[--muted]"> + View the media you've saved locally for offline use. + </p> + </div> + + <div className="flex flex-1"></div> + + <div className="contents"> + <Modal + title="Sync" + open={syncModalOpen} + onOpenChange={v => { + if (isSyncingLocal) return + return setSyncModalOpen(v) + }} + trigger={<Button + intent="white-subtle" + rounded + leftIcon={<LuFolderSync className="text-2xl" />} + loading={isSyncingLocal} + > + Sync now + </Button>} + > + <div className="space-y-4"> + + <Button + intent="white" + rounded + className="w-full" + leftIcon={<LuCloudDownload className="text-2xl" />} + loading={isSyncingLocal} + disabled={isSyncingAnilist} + onClick={handleSyncLocal} + > + Update local data + </Button> + <p className="text-sm"> + Update your local snapshots with the data from AniList. + This will overwrite your offline changes. You can automate this in <kbd>Settings {`>`} Seanime {`>`} Offline</kbd>. + </p> + <Separator /> + <Button + intent="primary-subtle" + rounded + className="w-full" + leftIcon={<LuCloudUpload className="text-2xl" />} + disabled={isSyncingLocal} + loading={isSyncingAnilist} + onClick={handleSyncAnilist} + > + Upload local changes to AniList + </Button> + <p className="text-sm"> + Update your AniList lists with the data from your local snapshots. + This should be done after you've made changes offline. + </p> + + <Alert + intent="warning-basic" + description="Changes are irreversible." + /> + </div> + </Modal> + + <SyncAddMediaModal + savedMediaIds={trackedMediaItems?.map(n => n.mediaId) ?? []} + /> + </div> + </div> + + {(!!unsavedAnime?.length || !!unsavedManga?.length) && ( + <Alert + intent="info-basic" + className="border-transparent" + description={ + <div className="space-y-2"> + <p> + <span>You have not saved {!!unsavedAnime?.length + ? `${unsavedAnime?.length} anime` + : ""}{(!!unsavedAnime?.length && !!unsavedManga?.length) ? " and " : ""}{!!unsavedManga?.length + ? `${unsavedManga?.length} manga` + : ""} that you're currently {!!unsavedAnime?.length + ? "watching" + : ""}{(!!unsavedAnime.length && !!unsavedManga.length) ? " and " : ""}{!!unsavedManga?.length + ? "reading" + : ""}.</span> + </p> + </div> + } + /> + )} + + <p className="text-sm"> + <span>Local storage size: </span> + <span>{localStorageSize}</span> + </p> + + {hasLocalChanges && <> + <Alert + intent="warning" + description={<div className="space-y-2"> + <p> + <span>You have local changes that have not been synced to AniList.</span> + {serverStatus?.settings?.library?.autoSyncOfflineLocalData && + <span> Automatic refreshing of offline data will be paused.</span>} + </p> + <div className="flex items-center gap-2 flex-wrap"> + <Button + intent="white" + leftIcon={<LuCloudUpload className="text-2xl" />} + onClick={() => { + handleSyncAnilist() + syncHasLocalChanges({ + updated: false, + }) + }} + loading={isSyncingAnilist} + disabled={isChangingLocalChangeStatus} + > + Upload local changes + </Button> + <Button + intent="alert" + leftIcon={<VscSyncIgnored className="text-2xl" />} + onClick={handleIgnoreLocalChanges} + loading={isChangingLocalChangeStatus} + disabled={isSyncingAnilist} + > + Delete local changes + </Button> + </div> + </div>} + /> + </>} + + {/*{(queueState && (Object.keys(queueState.animeTasks!).length > 0 || Object.keys(queueState.mangaTasks!).length > 0)) &&*/} + {/* <div className="border rounded-[--radius-md] p-2">*/} + {/* <p className="flex items-center gap-1">*/} + {/* <Spinner className="size-6" />*/} + {/* <span>Syncing in progress</span>*/} + {/* </p>*/} + {/* </div>}*/} + + {(!trackedAnimeItems?.length && !trackedMangaItems?.length) && <LuffyError + title="No tracked media" + />} + + {!!trackedAnimeItems?.length && <div className="space-y-4"> + <h3>Saved anime</h3> + <MediaCardLazyGrid itemCount={trackedAnimeItems?.length}> + {trackedAnimeItems?.map((item) => ( + <MediaEntryCard + key={item.mediaId} + type="anime" + media={item.animeEntry!.media!} + listData={anilist_getListDataFromEntry(item.animeEntry!)} + overlay={!!queueState?.animeTasks?.[item.mediaId] && <SyncingBadge />} + containerClassName={cn(!!queueState?.animeTasks?.[item.mediaId] && "animate-pulse")} + /> + ))} + </MediaCardLazyGrid> + </div>} + + {!!trackedMangaItems?.length && <div className="space-y-4"> + <h3>Saved manga</h3> + <MediaCardLazyGrid itemCount={trackedMangaItems?.length}> + {trackedMangaItems?.map((item) => ( + <MediaEntryCard + key={item.mediaId} + type="manga" + media={item.mangaEntry!.media!} + listData={anilist_getListDataFromEntry(item.mangaEntry!)} + overlay={!!queueState?.mangaTasks?.[item.mediaId] && <SyncingBadge />} + containerClassName={cn(!!queueState?.mangaTasks?.[item.mediaId] && "animate-pulse")} + /> + ))} + </MediaCardLazyGrid> + </div>} + </PageWrapper> + ) +} + +function SyncingBadge() { + return ( + <Badge + intent="gray-solid" + className="rounded-tl-md rounded-bl-none rounded-tr-none rounded-br-md bg-gray-950 border gap-0" + > + <Spinner className="size-4 px-0" /> + <span> + Syncing + </span> + </Badge> + ) +} + + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/test/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/test/page.tsx new file mode 100644 index 0000000..e0ce88b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/test/page.tsx @@ -0,0 +1,20 @@ +"use client" + +import { AppLayoutStack } from "@/components/ui/app-layout" +import { upath } from "@/lib/helpers/upath" +import React from "react" + +export default function TestPage() { + + return <AppLayoutStack className="h-full w-full relative"> + + {/*<VideoCoreProvider>*/} + {/* <VideoCore*/} + {/* active={true}*/} + {/* src="https://stream.mux.com/fXNzVtmtWuyz00xnSrJg4OJH6PyNo6D02UzmgeKGkP5YQ/high.mp4"*/} + {/* />*/} + {/*</VideoCoreProvider>*/} + + </AppLayoutStack> +} + diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/torrent-list/layout.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/torrent-list/layout.tsx new file mode 100644 index 0000000..995ee96 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/torrent-list/layout.tsx @@ -0,0 +1,16 @@ +import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image" +import React from "react" + +export default function Layout({ children }: { children: React.ReactNode }) { + + return ( + <> + {/*[CUSTOM UI]*/} + <CustomBackgroundImage /> + {children} + </> + ) + +} + +export const dynamic = "force-static" diff --git a/seanime-2.9.10/seanime-web/src/app/(main)/torrent-list/page.tsx b/seanime-2.9.10/seanime-web/src/app/(main)/torrent-list/page.tsx new file mode 100644 index 0000000..806b429 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/(main)/torrent-list/page.tsx @@ -0,0 +1,290 @@ +"use client" +import { TorrentClientAction_Variables } from "@/api/generated/endpoint.types" +import { TorrentClient_Torrent } from "@/api/generated/types" +import { useGetActiveTorrentList, useTorrentClientAction } from "@/api/hooks/torrent_client.hooks" +import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog" +import { LuffyError } from "@/components/shared/luffy-error" +import { PageWrapper } from "@/components/shared/page-wrapper" +import { SeaLink } from "@/components/shared/sea-link" +import { AppLayoutStack } from "@/components/ui/app-layout" +import { Button, IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Tooltip } from "@/components/ui/tooltip" +import { upath } from "@/lib/helpers/upath" +import capitalize from "lodash/capitalize" +import React from "react" +import { BiDownArrow, BiFolder, BiLinkExternal, BiPause, BiPlay, BiStop, BiTime, BiTrash, BiUpArrow } from "react-icons/bi" + +export const dynamic = "force-static" + +export default function Page() { + const serverStatus = useServerStatus() + + return ( + <> + <CustomLibraryBanner discrete /> + <PageWrapper + data-torrent-list-page-container + className="space-y-4 p-4 sm:p-8" + > + <div data-torrent-list-page-header className="flex items-center w-full justify-between"> + <div data-torrent-list-page-header-title> + <h2>Active torrents</h2> + <p className="text-[--muted]"> + See torrents currently being downloaded + </p> + </div> + <div data-torrent-list-page-header-actions> + {/*Show embedded client button only for qBittorrent*/} + {serverStatus?.settings?.torrent?.defaultTorrentClient === "qbittorrent" && <SeaLink href={`/qbittorrent`}> + <Button intent="white" rightIcon={<BiLinkExternal />}>Embedded client</Button> + </SeaLink>} + </div> + </div> + + <div data-torrent-list-page-content className="pb-10"> + <Content /> + </div> + </PageWrapper> + </> + ) +} + +function Content() { + const [enabled, setEnabled] = React.useState(true) + + const { data, isLoading, status, refetch } = useGetActiveTorrentList(enabled) + + const { mutate, isPending } = useTorrentClientAction(() => { + refetch() + }) + + React.useEffect(() => { + if (status === "error") { + setEnabled(false) + } + }, [status]) + + const handleTorrentAction = React.useCallback((props: TorrentClientAction_Variables) => { + mutate(props) + }, [mutate]) + + + const confirmStopAllSeedingProps = useConfirmationDialog({ + title: "Stop seeding all torrents", + description: "This action will cause seeding to stop for all completed torrents.", + actionIntent: "warning", + onConfirm: () => { + for (const torrent of data ?? []) { + if (torrent.status !== "seeding") continue + handleTorrentAction({ + hash: torrent.hash, + action: "pause", + dir: torrent.contentPath, + }) + } + }, + }) + + if (!enabled) return <LuffyError title="Failed to connect"> + <div className="flex flex-col gap-4 items-center"> + <p className="max-w-md">Failed to connect to the torrent client, verify your settings and make sure it is running.</p> + <Button + intent="primary-subtle" onClick={() => { + setEnabled(true) + }} + >Retry</Button> + </div> + </LuffyError> + + if (isLoading) return <LoadingSpinner /> + + return ( + <AppLayoutStack className={""}> + + <div> + <ul className="text-[--muted] flex flex-wrap gap-4"> + <li>Downloading: {data?.filter(t => t.status === "downloading" || t.status === "paused")?.length ?? 0}</li> + <li>Seeding: {data?.filter(t => t.status === "seeding")?.length ?? 0}</li> + {!!data?.filter(t => t.status === "seeding")?.length && <li> + <Button + size="xs" + intent="primary-link" + onClick={() => confirmStopAllSeedingProps.open()} + >Stop seeding</Button> + </li>} + </ul> + </div> + + {data?.filter(Boolean)?.map(torrent => { + return <TorrentItem + key={torrent.hash} + torrent={torrent} + onTorrentAction={handleTorrentAction} + isPending={isPending} + /> + })} + {(!isLoading && !data?.length) && <LuffyError title="Nothing to see">No active torrents</LuffyError>} + + <ConfirmationDialog {...confirmStopAllSeedingProps} /> + </AppLayoutStack> + ) + +} + + +type TorrentItemProps = { + torrent: TorrentClient_Torrent + onTorrentAction: (props: TorrentClientAction_Variables) => void + isPending?: boolean +} + +const TorrentItem = React.memo(function TorrentItem({ torrent, onTorrentAction, isPending }: TorrentItemProps) { + + const progress = `${(torrent.progress * 100).toFixed(1)}%` + + const confirmDeleteTorrentProps = useConfirmationDialog({ + title: "Remove torrent", + description: "This action cannot be undone.", + onConfirm: () => { + onTorrentAction({ + hash: torrent.hash, + action: "remove", + dir: torrent.contentPath, + }) + }, + }) + + return ( + <div data-torrent-item-container className="p-4 border rounded-[--radius-md] overflow-hidden relative flex gap-2"> + <div data-torrent-item-progress-bar className="absolute top-0 w-full h-1 z-[1] bg-gray-700 left-0"> + <div + className={cn( + "h-1 absolute z-[2] left-0 bg-gray-200 transition-all", + { + "bg-green-300": torrent.status === "downloading", + "bg-gray-500": torrent.status === "paused", + "bg-blue-500": torrent.status === "seeding", + }, + )} + style={{ width: `${String(Math.floor(torrent.progress * 100))}%` }} + ></div> + </div> + <div data-torrent-item-title-container className="w-full"> + <div + className={cn({ + "opacity-50": torrent.status === "paused", + })} + >{torrent.name}</div> + <div data-torrent-item-info className="text-[--muted]"> + <span className={cn({ "text-green-300": torrent.status === "downloading" })}>{progress}</span> + {` `} + <BiDownArrow className="inline-block mx-2" /> + {torrent.downSpeed} + {` `} + <BiUpArrow className="inline-block mx-2 mb-1" /> + {torrent.upSpeed} + {` `} + <BiTime className="inline-block mx-2 mb-0.5" /> + {torrent.eta} + {` - `} + <span>{torrent.seeds} {torrent.seeds !== 1 ? "seeds" : "seed"}</span> + {/*{` - `}*/} + {/*<span>{torrent.peers} {torrent.peers !== 1 ? "peers" : "peer"}</span>*/} + {` - `} + <strong + className={cn({ + "text-blue-300": torrent.status === "seeding", + })} + >{capitalize(torrent.status)}</strong> + </div> + </div> + <div data-torrent-item-actions className="flex-none flex gap-2 items-center"> + {torrent.status !== "seeding" ? ( + <> + {torrent.status !== "paused" && <Tooltip + trigger={<IconButton + icon={<BiPause />} + size="sm" + intent="gray-subtle" + className="flex-none" + onClick={async () => { + onTorrentAction({ + hash: torrent.hash, + action: "pause", + dir: torrent.contentPath, + }) + }} + disabled={isPending} + />} + >Pause</Tooltip>} + {torrent.status !== "downloading" && <Tooltip + trigger={<IconButton + icon={<BiPlay />} + size="sm" + intent="gray-subtle" + className="flex-none" + onClick={async () => { + onTorrentAction({ + hash: torrent.hash, + action: "resume", + dir: torrent.contentPath, + }) + }} + disabled={isPending} + />} + > + Resume + </Tooltip>} + </> + ) : <Tooltip + trigger={<IconButton + icon={<BiStop />} + size="sm" + intent="primary" + className="flex-none" + onClick={async () => { + onTorrentAction({ + hash: torrent.hash, + action: "pause", + dir: torrent.contentPath, + }) + }} + disabled={isPending} + />} + >End</Tooltip>} + + <div data-torrent-item-actions-buttons className="flex-none flex gap-2 items-center"> + <IconButton + icon={<BiFolder />} + size="sm" + intent="gray-subtle" + className="flex-none" + onClick={async () => { + onTorrentAction({ + hash: torrent.hash, + action: "open", + dir: upath.dirname(torrent.contentPath), + }) + }} + disabled={isPending} + /> + <IconButton + icon={<BiTrash />} + size="sm" + intent="alert-subtle" + className="flex-none" + onClick={async () => { + confirmDeleteTorrentProps.open() + }} + disabled={isPending} + /> + </div> + </div> + <ConfirmationDialog {...confirmDeleteTorrentProps} /> + </div> + ) +}) diff --git a/seanime-2.9.10/seanime-web/src/app/client-providers.tsx b/seanime-2.9.10/seanime-web/src/app/client-providers.tsx new file mode 100644 index 0000000..35f504e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/client-providers.tsx @@ -0,0 +1,52 @@ +"use client" +import { WebsocketProvider } from "@/app/websocket-provider" +import { CustomCSSProvider } from "@/components/shared/custom-css-provider" +import { CustomThemeProvider } from "@/components/shared/custom-theme-provider" +import { Toaster } from "@/components/ui/toaster" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { createStore } from "jotai" +import { Provider as JotaiProvider } from "jotai/react" +import { ThemeProvider } from "next-themes" +import { usePathname } from "next/navigation" +import React from "react" +import { CookiesProvider } from "react-cookie" + +interface ClientProvidersProps { + children?: React.ReactNode +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 0, + }, + }, +}) + +export const ClientProviders: React.FC<ClientProvidersProps> = ({ children }) => { + const [store] = React.useState(createStore()) + const pathname = usePathname() + + return ( + <ThemeProvider attribute="class" defaultTheme="dark" forcedTheme={(pathname === "/docs") ? "light" : "dark"}> + <CookiesProvider> + <JotaiProvider store={store}> + <QueryClientProvider client={queryClient}> + <CustomCSSProvider> + <WebsocketProvider> + {children} + <CustomThemeProvider /> + <Toaster /> + </WebsocketProvider> + </CustomCSSProvider> + {/*{process.env.NODE_ENV === "development" && <React.Suspense fallback={null}>*/} + {/* <ReactQueryDevtools />*/} + {/*</React.Suspense>}*/} + </QueryClientProvider> + </JotaiProvider> + </CookiesProvider> + </ThemeProvider> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/docs/page.tsx b/seanime-2.9.10/seanime-web/src/app/docs/page.tsx new file mode 100644 index 0000000..38e2300 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/docs/page.tsx @@ -0,0 +1,104 @@ +"use client" + +import { useGetDocs } from "@/api/hooks/docs.hooks" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Badge } from "@/components/ui/badge" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Separator } from "@/components/ui/separator" +import React from "react" + +export default function Page() { + + const { data, isLoading } = useGetDocs() + + if (isLoading) return <LoadingSpinner /> + + return ( + <div className="space-y-4 container py-10"> + {data?.toSorted((a, b) => a.filename?.localeCompare(b.filename))?.map((group, i) => ( + <div key={group.filename + i} className="space-y-4"> + <h4 className=""><span>{group.filename}</span> <span className="text-gray-300">/</span> + <span className="text-[--muted]"> {group.filename.replace(".go", "")}.hooks.ts</span></h4> + <Accordion type="multiple" defaultValue={[]}> + {group.handlers?.toSorted((a, b) => a.filename?.localeCompare(b.filename)).map((route, i) => ( + <AccordionItem value={route.name} key={route.name + i} className="space-y-2"> + <AccordionTrigger className="rounded flex-none w-full"> + <p className="flex gap-2 items-center"> + <Badge + className="w-24 py-4" + intent={(route.api!.methods?.includes("GET") && route.api!.methods?.length === 1) ? "success" + : route.api!.methods?.includes("GET") ? "warning" + : route.api!.methods?.includes("DELETE") ? "alert" + : route.api!.methods?.includes("PATCH") ? "warning" : "primary"} + > + {route.api!.methods?.join(", ")} + </Badge> + <span className="font-semibold flex-none whitespace-nowrap">{route.api!.endpoint}</span> + <span className="font-normal text-sm text-[--muted] flex-none whitespace-nowrap">{route.name}</span> + {/*<span className="font-medium text-[--muted] text-sm truncate flex-shrink">({route.name.replace("Handle", "")})</span>*/} + <span className="text-[--muted] text-[.97rem] whitespace-nowrap truncate text-ellipsis"> - {route.api!.summary}</span> + </p> + </AccordionTrigger> + + <AccordionContent className="space-y-4 border rounded mb-4"> + {/*<p className="font-bold">*/} + {/* {route.name}*/} + {/*</p>*/} + {/*<p className="">*/} + {/* Used in: <span className="font-bold">{route.filename.replace(".go", "")}.hooks.ts</span>*/} + {/*</p>*/} + {!!route.api!.descriptions?.length && <div> + {route.api!.descriptions?.map((desc, i) => ( + <p key={desc + i}>{desc}</p> + ))} + </div>} + + {!!route.api!.params?.length && <div className="space-y-2"> + <h5>URL Params</h5> + <ul className="list-disc pl-4"> + {route.api!.params?.map((param, i) => ( + <li key={param.name + i} className="flex gap-2 items-center"> + <p className="font-medium"> + {param.name} + {param.required && <span className="text-red-500">*</span>} + </p> + <p className="text-[--muted]">{param.typescriptType}</p> + {param.descriptions?.map((desc, i) => ( + <p key={desc + i}>{desc}</p> + ))} + </li> + ))} + </ul> + </div>} + + {!!route.api?.bodyFields?.length && <div className="space-y-2"> + <h5>Body</h5> + <ul className="list-disc pl-4"> + {route.api?.bodyFields?.map((field, i) => ( + <li key={field.name + i} className="flex gap-2 items-center"> + <p className="font-medium">{field.jsonName} {field.required && + <span className="text-[--red]">*</span>}</p> + <p className="text-[--muted]">{field.typescriptType}</p> + {field.descriptions?.map((desc, i) => ( + <p key={desc + i}>{desc}</p> + ))} + </li> + ))} + </ul> + </div>} + + <div className="flex gap-2 items-center"> + <p className="font-medium text-[--muted]">Returns</p> + <p className="font-bold text-brand-900">{route.api!.returnTypescriptType}</p> + </div> + </AccordionContent> + </AccordionItem> + ))} + </Accordion> + + <Separator /> + </div> + ))} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/globals.css b/seanime-2.9.10/seanime-web/src/app/globals.css new file mode 100644 index 0000000..21c8588 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/globals.css @@ -0,0 +1,955 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --media-cue-backdrop: blur(0px); + --media-captions-padding: 0%; + --video-captions-offset: 0px; + /*--brand-color-50: #f2f0ff;*/ + /*--brand-color-100: #eeebff;*/ + /*--brand-color-200: #d4d0ff;*/ + /*--brand-color-300: #c7c2ff;*/ + /*--brand-color-400: #9f92ff;*/ + /*--brand-color-500: #6152df;*/ + /*--brand-color-600: #5243cb;*/ + /*--brand-color-700: #3f2eb2;*/ + /*--brand-color-800: #312887;*/ + /*--brand-color-900: #231c6b;*/ + /*--brand-color-950: #1a144f;*/ + /*--brand-color-default: #6152df;*/ + + --color-brand-50: 242 240 255; + --color-brand-100: 238 235 255; + --color-brand-200: 212 208 255; + --color-brand-300: 199 194 255; + --color-brand-400: 159 146 255; + --color-brand-500: 97 82 223; + --color-brand-600: 82 67 203; + --color-brand-700: 63 46 178; + --color-brand-800: 49 40 135; + --color-brand-900: 35 28 107; + --color-brand-950: 26 20 79; + --color-brand-default: 97 82 223; + + /*--gray-color-50: #FAFAFA;*/ + /*--gray-color-100: #F5F5F5;*/ + /*--gray-color-200: #E5E5E5;*/ + /*--gray-color-300: #D4D4D4;*/ + /*--gray-color-400: #A3A3A3;*/ + /*--gray-color-500: #737373;*/ + /*--gray-color-600: #525252;*/ + /*--gray-color-700: #404040;*/ + /*--gray-color-800: #262626;*/ + /*--gray-color-900: #171717;*/ + /*--gray-color-950: #101010;*/ + /*--gray-color-default: #737373;*/ + + /* --color-gray-50: 250 250 250; + --color-gray-100: 245 245 245; + --color-gray-200: 229 229 229; + --color-gray-300: 212 212 212; + --color-gray-400: 163 163 163; + --color-gray-500: 115 115 115; + --color-gray-600: 82 82 82; + --color-gray-700: 64 64 64; + --color-gray-800: 38 38 38; + --color-gray-900: 23 23 23; + --color-gray-950: 16 16 16; + --color-gray-default: 115 115 115;*/ + + --color-gray-50: 230 230 230; + --color-gray-100: 225 225 225; + --color-gray-200: 209 209 209; + --color-gray-300: 202 202 202; + --color-gray-400: 143 143 143; + --color-gray-500: 90 90 90; + --color-gray-600: 72 72 72; + --color-gray-700: 54 54 54; + --color-gray-800: 28 28 28; + --color-gray-900: 16 16 16; + --color-gray-950: 11 11 11; + --color-gray-default: 105 105 105; + + + /*--radius: 0.375rem;*/ + --radius: 0.5rem; + --radius-md: 0.5rem; + + --titlebar-h: theme("height.10"); + + --foreground: theme('colors.gray.800'); + --background: white; + + --brand: theme('colors.brand.300'); + --slate: theme('colors.slate.500'); + --gray: theme('colors.gray.500'); + --zinc: theme('colors.zinc.500'); + --neutral: theme('colors.neutral.500'); + --stone: theme('colors.stone.500'); + --red: theme('colors.red.500'); + --orange: theme('colors.orange.500'); + --amber: theme('colors.amber.500'); + --yellow: theme('colors.yellow.500'); + --lime: theme('colors.lime.500'); + --green: theme('colors.green.500'); + --emerald: theme('colors.emerald.500'); + --teal: theme('colors.teal.500'); + --cyan: theme('colors.cyan.500'); + --sky: theme('colors.sky.500'); + --blue: theme('colors.blue.500'); + --indigo: theme('colors.indigo.500'); + --violet: theme('colors.violet.500'); + --purple: theme('colors.purple.500'); + --fuchsia: theme('colors.fuchsia.500'); + --pink: theme('colors.pink.500'); + --rose: theme('colors.rose.500'); + + --border: theme('colors.gray.200'); + --ring: theme('colors.brand.500'); + + --muted: theme('colors.gray.500'); + --muted-highlight: theme('colors.gray.700'); + + --paper: theme('colors.white'); + --subtle: rgba(0, 0, 0, 0.04); + --subtle-highlight: rgba(0, 0, 0, 0.06); + + --media-card-popup-background: theme('colors.gray.950'); + --hover-from-background-color: theme('colors.gray.900'); + + /*--media-accent-color: theme('colors.brand.400');*/ + --media-accent-color: #fff; + --media-menu-item-hover-background: rgba(255, 255, 255, 0.1); + } + + .dark, + [data-mode="dark"] { + --foreground: theme('colors.gray.200'); + --background: #070707; + + /*--brand: theme('colors.brand.300');*/ + --slate: theme('colors.slate.300'); + --gray: theme('colors.gray.300'); + --zinc: theme('colors.zinc.300'); + --neutral: theme('colors.neutral.300'); + --stone: theme('colors.stone.300'); + --red: theme('colors.red.300'); + --orange: theme('colors.orange.300'); + --amber: theme('colors.amber.300'); + --yellow: theme('colors.yellow.300'); + --lime: theme('colors.lime.300'); + --green: theme('colors.green.300'); + --emerald: theme('colors.emerald.300'); + --teal: theme('colors.teal.300'); + --cyan: theme('colors.cyan.300'); + --sky: theme('colors.sky.300'); + --blue: theme('colors.blue.300'); + --indigo: theme('colors.indigo.300'); + --violet: theme('colors.violet.300'); + --purple: theme('colors.purple.300'); + --fuchsia: theme('colors.fuchsia.300'); + --pink: theme('colors.pink.300'); + --rose: theme('colors.rose.300'); + + --border: rgba(255, 255, 255, 0.1); + --ring: theme('colors.brand.200'); + + --muted: rgba(255, 255, 255, 0.4); + --muted-highlight: rgba(255, 255, 255, 0.6); + + --paper: theme('colors.gray.950'); + --paper-highlighter: theme('colors.gray.950'); + --subtle: rgba(255, 255, 255, 0.06); + --subtle-highlight: rgba(255, 255, 255, 0.08); + + } +} + +html { + background-color: var(--background); + color: var(--foreground); +} + +html * { + border-color: var(--border); +} + +/*h1, h2, h3, h4, h5, h6 {*/ +/* @apply text-gray-800 dark:text-gray-100*/ +/*}*/ + +h1 { + @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl +} + +h2 { + @apply scroll-m-20 text-3xl font-bold tracking-tight first:mt-0 +} + +h3 { + @apply scroll-m-20 text-2xl font-semibold tracking-tight +} + +h4 { + @apply scroll-m-20 text-xl font-semibold tracking-tight +} + +h5 { + @apply scroll-m-20 text-lg font-semibold tracking-tight +} + +h6 { + @apply scroll-m-20 text-base font-semibold tracking-tight +} + +/* width */ +::-webkit-scrollbar { + width: 10px; +} + +/* Track */ +::-webkit-scrollbar-track { + background: var(--background); +} + +/* Handle */ +::-webkit-scrollbar-thumb { + @apply bg-gray-700 rounded-full +} + +/* Handle on hover */ +::-webkit-scrollbar-thumb:hover { + @apply bg-gray-600 +} + +.code { + @apply bg-gray-800 rounded-[--radius-md] px-2 py-1 text-sm font-mono border +} + +.JASSUB { + position: absolute !important; + width: 100%; + z-index: 10; +} + +.force-hidden { + display: none !important; +} + +/*body[data-scroll-locked] {*/ +/* --removed-body-scroll-bar-size: 0 !important;*/ +/* margin-right: 0 !important;*/ +/* overflow-y: auto !important;*/ +/*}*/ + +@media screen and (min-width: 1024px) { + body[data-scroll-locked] .scroll-locked-offset { + padding-right: 10px; + } +} + +body[data-scroll-locked] .media-page-header-scroll-locked { + /*right: 10px;*/ +} + + +/**/ + +pre { + overflow-x: auto; +} + +/*div[data-media-player][data-controls]:not([data-hocus]) .vds-controls {*/ +/* @apply lg:opacity-0*/ +/*}*/ + +.discrete-controls[data-media-player][data-playing][data-controls]:not(:has(.vds-controls-group:hover)) .vds-controls { + @apply lg:opacity-0 +} + +.discrete-controls[data-media-player][data-buffering][data-controls]:not(:has(.vds-controls-group:hover)) .vds-controls { + @apply lg:opacity-0 +} + + +.discrete-controls[data-media-player][data-playing][data-seeking][data-controls]:not(:has(.vds-controls-group:hover)) { + @apply cursor-none +} + +.halo:after { + opacity: .1; + background-image: radial-gradient(at 27% 37%, #fd3a67 0, transparent 50%), radial-gradient(at 97% 21%, #9772fe 0, transparent 70%), radial-gradient(at 52% 99%, #fd3a4e 0, transparent 50%), radial-gradient(at 10% 29%, #fc5ab0 0, transparent 50%), radial-gradient(at 97% 96%, #e4c795 0, transparent 50%), radial-gradient(at 33% 50%, #8ca8e8 0, transparent 50%), radial-gradient(at 79% 53%, #eea5ba 0, transparent 50%); + position: absolute; + content: ""; + width: 100%; + height: 100%; + filter: blur(100px) saturate(150%); + z-index: -1; + top: 50px; + left: 0; + transform: translateZ(0); +} + +.halo-2:after { + opacity: .1; + background-image: radial-gradient(at 27% 37%, #fd3a67 0, transparent 30%), radial-gradient(at 97% 21%, #9772fe 0, transparent 70%), radial-gradient(at 52% 99%, #fd3a4e 0, transparent 20%), radial-gradient(at 10% 29%, #fc5ab0 0, transparent 20%), radial-gradient(at 97% 96%, #e4c795 0, transparent 20%), radial-gradient(at 33% 50%, #8ca8e8 0, transparent 50%), radial-gradient(at 79% 53%, #eea5ba 0, transparent 20%); + position: absolute; + content: ""; + width: 100%; + height: 100%; + filter: blur(100px) saturate(150%); + z-index: -1; + top: 50px; + left: 0; + transform: translateZ(0); +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.hide-scrollbar { + -ms-overflow-style: none; + /* IE and Edge */ + scrollbar-width: none; + /* Firefox */ +} + +/* Mini player controls */ +[data-native-player-container][data-mini-player="true"] .vds-controls-group > *:not(.vds-play-button) { + display: none !important; +} + +[data-native-player-container][data-mini-player="true"] .vds-controls-group > .vds-play-button { + transform: translateY(-5px) !important; +} + +[data-native-player-container][data-mini-player="true"] .vds-controls { + opacity: 1 !important; + visibility: visible !important; + justify-content: center !important; + align-items: center !important; + height: 100% !important; +} + +[data-native-player-container][data-mini-player="true"] .vds-controls-group { + position: static !important; + justify-content: center !important; + align-items: center !important; + height: auto !important; + padding: 0 !important; +} + +/* +Player + */ +media-controller { + --base: 1rem; + font-size: calc(0.95 * var(--base)); + font-family: Inter, Inter Fallback, Roboto, Arial, sans-serif; + --media-font-family: Inter, Inter Fallback, Roboto, helvetica neue, segoe ui, arial, sans-serif; + -webkit-font-smoothing: antialiased; + --media-secondary-color: transparent; + --media-menu-background: rgba(21, 21, 21, 0.9); + --media-control-hover-background: var(--media-secondary-color); + --media-range-track-height: 3px; + --media-range-thumb-height: 13px; + --media-range-thumb-width: 13px; + --media-range-thumb-border-radius: 13px; + --media-preview-thumbnail-border: 2px solid #fff; + --media-preview-thumbnail-border-radius: 2px; + --media-tooltip-display: none; + /*--media-font-family: inherit;*/ +} + +/* The biggest size controller is tied to going fullscreen + instead of a player width */ +media-controller[mediaisfullscreen] { + font-size: 17px; + --media-range-thumb-height: 20px; + --media-range-thumb-width: 20px; + --media-range-thumb-border-radius: 10px; + --media-range-track-height: 4px; +} + +.native-player-button { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 36px; + padding: 0 2px; + height: 100%; + opacity: 0.9; + transition: opacity 0.1s cubic-bezier(0.4, 0, 1, 1); + flex-shrink: 0; +} + +[data-native-player-container][data-mini-player="true"] .native-player-button { + width: 24px; + margin: 0 2px; +} + +[breakpointmd] .native-player-button { + width: 48px; +} + +[mediaisfullscreen] .native-player-button { + width: 54px; +} + +.native-player-button svg { + /* height: 100%; + width: 100%; */ + @apply !size-7; + /*fill: var(--media-primary-color, #fff);*/ + fill-rule: evenodd; +} + +[data-native-player-container][data-mini-player="true"] .native-player-button svg { + @apply !size-5; +} + +.svg-shadow { + stroke: #000; + stroke-opacity: 0.15; + stroke-width: 2px; + fill: none; +} + +.native-player-gradient-bottom { + padding-top: 37px; + position: absolute; + width: 100%; + height: 170px; + bottom: 0; + pointer-events: none; + background-position: bottom; + background-repeat: repeat-x; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAACqCAYAAABsziWkAAAAAXNSR0IArs4c6QAAAQVJREFUOE9lyNdHBQAAhfHb3nvvuu2997jNe29TJJEkkkgSSSSJJJJEEkkiifRH5jsP56Xz8PM5gcC/xfDEmjhKxEOCSaREEiSbFEqkQppJpzJMJiWyINvkUCIX8kw+JQqg0BRRxaaEEqVQZsopUQGVpooS1VBjglStqaNEPTSYRko0QbNpoUQrtJl2qsN0UqILuk0PJXqhz/RTYgAGzRA1bEYoMQpjZpwSExAyk5SYgmkzQ82aOUqEIWKilJiHBbNIiSVYhhVYhTVYhw3YhC3Yhh3YhT3YhwM4hCM4hhM4hTM4hwu4hCu4hhu4hTu4hwd4hCd4hhd4hTd4hw/4hC/4hh/4/QM2/id28uIEJAAAAABJRU5ErkJggg=='); +} + +media-settings-menu, media-audio-track-menu, media-captions-menu { + position: absolute; + border-radius: 8px; + border: 1px solid var(--border); + /* right: 12px; */ + bottom: 61px; + z-index: 70; + transform: translateX(-15px); + padding-block: calc(0.15 * var(--base)); + will-change: width, height, opacity; + text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); + user-select: none; + --media-settings-menu-min-width: 220px; + max-width: 300px; + + display: flex !important; + + opacity: 0; + visibility: hidden; + pointer-events: none; + + transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), + visibility 0s linear 0.2s !important; + + /* background-color: rgba(0, 0, 0, 0.9); */ +} + +media-settings-menu { + /* flex-direction: column; */ + /* overflow-y: auto !important; */ + /* flex-wrap: wrap; */ +} + +media-audio-track-menu, media-captions-menu { + max-height: 300px; +} + +media-settings-menu:not([hidden]), media-audio-track-menu:not([hidden]), media-captions-menu:not([hidden]) { + opacity: 1; + visibility: visible; + pointer-events: auto; + + transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), + visibility 0s linear 0s !important; +} + +[mediaisfullscreen] media-settings-menu, [mediaisfullscreen] media-audio-track-menu, [mediaisfullscreen] media-captions-menu { + --media-settings-menu-min-width: 320px; + right: 24px; + bottom: 70px; +} + +media-chrome-menu-item { + border-radius: 6px !important; +} + +media-settings-menu-item { + height: 40px; + font-size: 13px; + font-weight: 500; + padding-top: 0; + padding-bottom: 0; + border-radius: 6px; +} + +[mediaisfullscreen] media-settings-menu-item { + font-size: 20px; + height: 50px; +} + +media-settings-menu-item, +[role='menu']::part(menu-item) { + flex-shrink: 0; + --media-icon-color: var(--_primary-color); + margin-inline: calc(0.45 * var(--base)); + height: calc(2 * var(--base)); + font-size: calc(0.95 * var(--base)); + /* font-weight: 400; */ + padding: 0; + padding-left: calc(0.4 * var(--base)); + padding-right: calc(0.1 * var(--base)); + border-radius: 6px; + text-shadow: none; + +} + +/* media-settings-menu-item[submenusize='0'] { + display: none; +} */ + +/* Also hide if only `Auto` is added. */ +/* .quality-settings[submenusize='1'] { + display: none; +} */ + +.audio-settings[submenusize='1'] { + display: none; +} + +media-time-range { + position: absolute; + bottom: 36px; + width: 100%; + height: 5px; + --media-range-track-background: rgba(255, 255, 255, 0.2); + --media-range-track-pointer-background: rgba(255, 255, 255, 0.5); + --media-time-range-buffered-color: rgba(255, 255, 255, 0.4); + --media-range-bar-color: var(--media-accent-color, rgb(229, 9, 20)); + --media-range-thumb-border-radius: 13px; + --media-range-thumb-background: var(--media-accent-color, #f00); + --media-range-thumb-transition: transform 0.1s linear; + --media-range-thumb-transform: scale(0) translate(0%, 0%); + --media-chapters-cue-color: rgba(255, 255, 255, 0.8); + --media-chapters-cue-hover-color: rgba(255, 255, 255, 1); + --media-chapters-cue-width: 2px; + --media-chapters-cue-height: 100%; + --media-range-segments-gap: 3px; +} + +.preview-rail > * { + --_box-width: 100px !important; +} + +media-time-range:hover { + --media-range-track-height: 5px; + --media-range-thumb-transform: scale(1) translate(0%, 0%); + --media-chapters-cue-color: rgba(255, 255, 255, 1); + --media-chapters-cue-width: 3px; +} + +/* Chapter cues styling as backup for :part() support */ +media-time-range .chapters-cue, +media-time-range [data-chapters-cue] { + position: absolute; + background-color: var(--media-chapters-cue-color); + width: var(--media-chapters-cue-width); + height: var(--media-chapters-cue-height); + border-radius: 1px; + transition: all 0.2s ease-in-out; + opacity: 0.7; + pointer-events: none; +} + +media-time-range:hover .chapters-cue, +media-time-range:hover [data-chapters-cue] { + background-color: var(--media-chapters-cue-hover-color); + width: var(--media-chapters-cue-width); + opacity: 1; +} + +/* Native Player Loading Indicator */ +.native-player-loading-indicator { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 60; + --media-loading-indicator-opacity: 0; + --media-loading-indicator-transition-delay: 300ms; + transition: opacity 0.2s ease-in-out var(--media-loading-indicator-transition-delay); + pointer-events: none; +} + +.native-player-loading-indicator[medialoading]:not([mediapaused]) { + --media-loading-indicator-opacity: 1; +} + +.native-player-loading-indicator svg { + @apply size-12; + fill: rgba(255, 255, 255, 0.9); + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); +} + +[data-native-player-container][data-mini-player="true"] .native-player-loading-indicator svg { + @apply size-8; +} + +[breakpointmd] media-time-range { + bottom: 47px; +} + +[mediaisfullscreen] media-time-range { + bottom: 52.5px; + height: 8px; + --media-chapters-cue-width: 3px; +} + +[mediaisfullscreen] media-time-range:hover { + --media-range-track-height: 8px; + --media-chapters-cue-width: 4px; +} + +[mediaisfullscreen] media-time-range .chapters-cue, +[mediaisfullscreen] media-time-range [slot="chapters-cue"] { + width: 3px; +} + +[mediaisfullscreen] media-time-range:hover .chapters-cue, +[mediaisfullscreen] media-time-range:hover [slot="chapters-cue"] { + width: 4px; + transform: scaleY(2); +} + +media-preview-chapter-display { + padding-block: 0; + padding: 4px 8px; + background: rgba(0, 0, 0, 0.8); + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.2); + backdrop-filter: blur(8px); + font-weight: 500; + font-size: 13px; + color: rgba(255, 255, 255, 0.95); + white-space: nowrap; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} + +[mediaisfullscreen] media-preview-chapter-display { + font-size: 16px; + padding: 6px 12px; + max-width: 300px; +} + +media-preview-thumbnail { + margin-bottom: 5px; +} + +media-preview-time-display { + padding-top: 0; +} + +media-control-bar { + position: absolute; + height: 36px; + line-height: 36px; + bottom: 0; + left: 12px; + right: 12px; +} + +[breakpointmd] media-control-bar { + height: 48px; + line-height: 48px; +} + +[mediaisfullscreen] media-control-bar { + height: 54px; + line-height: 54px; +} + +media-play-button { + --media-button-icon-width: 30px; + padding: 6px 10px; +} + +media-play-button :is(#play-p1, #play-p2, #pause-p1, #pause-p2) { + transition: clip-path 0.15s ease-in-out; +} + +/* Slow down the play icon part hiding slightly + to achieve the morphing look a little better */ +media-play-button:not([mediapaused]) #play-p2, +media-play-button:not([mediapaused]) #play-p2 { + transition: clip-path 0.35s ease-in; +} + +/* Show icon */ +media-play-button :is(#pause-p1, #pause-p2), +media-play-button[mediapaused] :is(#play-p1, #play-p2) { + clip-path: inset(0); +} + +/* Hide icon wth clip path mask */ +media-play-button #play-p1 { + clip-path: inset(0 100% 0 0); +} + +media-play-button #play-p2 { + clip-path: inset(0 20% 0 100%); +} + +media-play-button[mediapaused] #pause-p1 { + clip-path: inset(50% 0 50% 0); +} + +media-play-button[mediapaused] #pause-p2 { + clip-path: inset(50% 0 50% 0); +} + +/* media-mute-button :is(#icon-muted, #icon-volume) { + transition: clip-path 0.3s ease-out; +} + +media-mute-button #icon-muted { + clip-path: inset(0 0 100% 0); +} + +media-mute-button[mediavolumelevel='off'] #icon-muted { + clip-path: inset(0); +} + +media-mute-button #icon-volume { + clip-path: inset(0); +} + +media-mute-button[mediavolumelevel='off'] #icon-volume { + clip-path: inset(100% 0 0 0); +} + +media-mute-button #volume-high, +media-mute-button[mediavolumelevel='off'] #volume-high { + opacity: 1; + transition: opacity 0.3s; +} + +media-mute-button[mediavolumelevel='low'] #volume-high, +media-mute-button[mediavolumelevel='medium'] #volume-high { + opacity: 0.2; +} */ + +media-volume-range { + height: 36px; + --media-range-track-background: rgba(255, 255, 255, 0.2); +} + +media-mute-button + media-volume-range { + width: 0; + overflow: hidden; + transition: width 0.2s ease-in-out; +} + +/* Expand volume control in all relevant states */ +media-mute-button:hover + media-volume-range, +media-mute-button:focus + media-volume-range, +media-mute-button:focus-within + media-volume-range, +media-volume-range:hover, +media-volume-range:focus, +media-volume-range:focus-within { + width: 70px; +} + +media-time-display { + padding-top: 6px; + padding-bottom: 6px; + font-size: 13px; +} + +[mediaisfullscreen] media-time-display { + font-size: 20px; +} + +.control-spacer { + flex-grow: 1; +} + +media-captions-button { + position: relative; +} + +/* Disble the captions button when no subtitles are available */ +media-captions-button:not([mediasubtitleslist]) svg { + opacity: 0.3; +} + +media-captions-button[mediasubtitleslist]:after { + content: ''; + display: block; + position: absolute; + width: 0; + height: 3px; + border-radius: 3px; + background-color: var(--media-accent-color, #f00); + bottom: 19%; + left: 50%; + transition: all 0.1s cubic-bezier(0, 0, 0.2, 1), width 0.1s cubic-bezier(0, 0, 0.2, 1); +} + +media-captions-button[mediasubtitleslist][aria-checked='true']:after { + left: 25%; + width: 50%; + transition: left 0.25s cubic-bezier(0, 0, 0.2, 1), width 0.25s cubic-bezier(0, 0, 0.2, 1); +} + +media-captions-button[mediasubtitleslist][aria-checked='true']:after { + left: 25%; + width: 50%; + transition: left 0.25s cubic-bezier(0, 0, 0.2, 1), width 0.25s cubic-bezier(0, 0, 0.2, 1); +} + +media-settings-menu-button svg { + transition: all 0.1s cubic-bezier(0.4, 0, 1, 1); + transform: rotateZ(0deg); +} + +media-settings-menu-button[aria-expanded='true'] svg { + transform: rotateZ(30deg) translateY(-2px) scale(1.1); +} + +media-audio-track-menu-button svg, media-captions-menu-button svg { + transition: all 0.1s cubic-bezier(0.4, 0, 1, 1); + transform: translateY(0px); +} + +media-audio-track-menu-button[aria-expanded='true'] svg, media-captions-menu-button[aria-expanded='true'] svg { + transform: translateY(-2px) scale(1.1); +} + +media-settings-menu-button:hover svg, media-audio-track-menu-button:hover svg, media-captions-menu-button:hover svg, media-pip-button:hover svg { + opacity: 0.8; +} + +media-pip-button svg { + transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1); +} + +media-pip-button:not([mediaispip]) #pip-icon-2 { + opacity: 0; +} + +media-pip-button[mediaispip] #pip-icon-2 { + opacity: 1; +} + +media-pip-button[mediaispip] #pip-icon { + opacity: 0; +} + +media-pip-button:not([mediaispip]) #pip-icon { + opacity: 1; +} + +media-fullscreen-button svg { + transition: transform 0.1s cubic-bezier(0.4, 0, 1, 1); + transform: scale(1); +} + +media-fullscreen-button:hover svg { + transform: scale(1.2); +} + +[mediaisfullscreen] media-fullscreen-button:hover svg { + transform: scale(0.8); +} + +[mediaisfullscreen] .native-player-hide-on-fullscreen { + display: none; +} + +media-settings-menu div[slot="title"] { + padding: 6px; + padding-bottom: 4px; + width: 100% !important; + display: flex; + /* font-weight: 600; + font-size: 13px; + margin-bottom: 4px; + color: var(--foreground); */ +} + +[mediaisfullscreen] media-settings-menu { + --media-settings-menu-min-width: 320px; + right: 24px; + bottom: 70px; +} + +/* Force JASSUB subtitles to always be visible regardless of media-chrome control state */ +.discrete-controls .JASSUB, +media-controller .JASSUB, +[data-media-player] .JASSUB { + opacity: 1 !important; + visibility: visible !important; + pointer-events: none !important; + /* z-index: 1000 !important; */ +} + + +media-preview-thumbnail { + /* width: 200px !important; + height: 100px !important; + object-fit: cover !important; */ + border-radius: 4px; +} + +media-preview-chapter-display { + display: block !important; + margin-bottom: 5px; + padding: 0px !important; + background: transparent !important; + border-radius: 4px; + border: none !important; + text-shadow: 0 1.5px 4px rgba(0, 0, 0, 0.85) !important; + backdrop-filter: blur(0px) !important; + color: white !important; + font-weight: 600 !important; + font-size: 16px !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 160px; +} + +/* Ensure JASSUB remains visible even when controls are hidden */ +.discrete-controls[data-media-player][data-playing][data-controls] .JASSUB, +.discrete-controls[data-media-player][data-buffering][data-controls] .JASSUB { + opacity: 1 !important; + visibility: visible !important; +} + +.native-player-video { + color-interpolation: sRGB; + color-interpolation-filters: sRGB; + transform: translateZ(0); + will-change: transform; + color-rendering: optimizeQuality; + image-rendering: -moz-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; +} diff --git a/seanime-2.9.10/seanime-web/src/app/issue-report/page.tsx b/seanime-2.9.10/seanime-web/src/app/issue-report/page.tsx new file mode 100644 index 0000000..f694edb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/issue-report/page.tsx @@ -0,0 +1,413 @@ +"use client" + +import { Report_IssueReport } from "@/api/generated/types" +import { ScanLogViewer } from "@/app/scan-log-viewer/scan-log-viewer" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Drawer } from "@/components/ui/drawer" +import { TextInput } from "@/components/ui/text-input" +import { format, isSameSecond, parseISO } from "date-fns" +import { max, min } from "lodash" +import React, { useLayoutEffect, useRef, useState } from "react" +import { FiMousePointer } from "react-icons/fi" +import { HiServerStack } from "react-icons/hi2" +import { LuBrain, LuNetwork, LuTerminal } from "react-icons/lu" + + +export default function Page() { + const [issueReport, setIssueReport] = useState<Report_IssueReport | null>(null) + const [currentTime, setCurrentTime] = useState<Date | null>(null) + const [startTime, setStartTime] = useState<Date | null>(null) + const [endTime, setEndTime] = useState<Date | null>(null) + const fileInputRef = useRef<HTMLInputElement>(null) + const [errorTimestamps, setErrorTimestamps] = useState<Date[]>([]) + + const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + const reader = new FileReader() + reader.onload = (e) => { + const content = e.target?.result as string + const parsedReport = JSON.parse(content) as Report_IssueReport + setIssueReport(parsedReport) + + const allTimestamps = [ + ...(parsedReport.clickLogs?.map(log => parseISO(log.timestamp!)) || []), + ...(parsedReport.networkLogs?.map(log => parseISO(log.timestamp!)) || []), + ...(parsedReport.reactQueryLogs?.map(log => parseISO(log.timestamp!)) || []), + ...(parsedReport.consoleLogs?.map(log => parseISO(log.timestamp!)) || []), + // ...parsedReport.serverLogs.split("\n").map(log => parseISO(log.substring(0, 19))), + ].filter(date => !isNaN(date.getTime())) + + const earliestTimestamp = min(allTimestamps) + const latestTimestamp = max(allTimestamps) + setEndTime(latestTimestamp ?? null) + setStartTime(earliestTimestamp ?? null) + setCurrentTime(earliestTimestamp ?? null) + + let errorTimestamps: Date[] = [] + if (parsedReport.serverLogs) { + errorTimestamps = [ + ...parsedReport.serverLogs?.split("\n")?.map(log => ({ timestamp: parseISO(log.substring(0, 19)), text: log })), + ].filter(log => log.text.includes("|ERR|")).map(log => log.timestamp) + } + errorTimestamps = [...errorTimestamps, + ...(parsedReport.networkLogs || []).filter(log => log.status >= 400).map(log => parseISO(log.timestamp!))] + setErrorTimestamps(errorTimestamps) + } + reader.readAsText(file) + } + } + + const handleSliderChange = (value: number) => { + if (issueReport && endTime && startTime) { + const newTime = new Date(startTime.getTime() + (value * 1000)) + setCurrentTime(newTime) + } + } + + const filterLogsByTime = <T extends { timestamp?: string }>(logs: T[]): T[] => { + if (!currentTime) return [] + return logs.filter((log) => parseISO(log.timestamp!) <= currentTime || isSameSecond(parseISO(log.timestamp!), currentTime)) + } + + const filterServerLogs = (logs: string): string[] => { + if (!currentTime || !logs) return [] + return logs + .split("\n") + .filter((log) => { + const logTime = parseISO(log.substring(0, 19)) + return logTime <= currentTime + }) + } + + return ( + <div className="container mx-auto bg-gray-900 p-4 min-h-screen relative pb-40 space-y-4"> + <h1 className="text-3xl font-bold mb-6 text-brand-300 text-center">Issue Report Viewer</h1> + <div className="container max-w-2xl"> + <TextInput + type="file" + ref={fileInputRef} + onChange={handleFileChange} + className="mb-6 p-1" + /> + </div> + {issueReport && currentTime && endTime && ( + <div className="relative"> + <TimelineSlider + startTime={startTime} + endTime={endTime} + currentTime={currentTime} + errorTimestamps={errorTimestamps} + onChange={handleSliderChange} + /> + <div className="space-y-6"> + <LogConsole + title="Server Logs" + logs={filterServerLogs(issueReport.serverLogs || "")} + icon={<HiServerStack className="w-5 h-5" />} + type="server" + currentTime={currentTime} + /> + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <LogConsole + title="Click Logs" + logs={filterLogsByTime(issueReport.clickLogs || [])} + icon={<FiMousePointer className="w-5 h-5" />} + type="click" + currentTime={currentTime} + /> + <LogConsole + title="Network Logs" + logs={filterLogsByTime(issueReport.networkLogs || [])} + icon={<LuNetwork className="w-5 h-5" />} + type="network" + currentTime={currentTime} + /> + <LogConsole + title="React Query Logs" + logs={filterLogsByTime(issueReport.reactQueryLogs || [])} + icon={<LuBrain className="w-5 h-5" />} + type="reactQuery" + currentTime={currentTime} + /> + <LogConsole + title="Console Logs" + logs={filterLogsByTime(issueReport.consoleLogs || [])} + icon={<LuTerminal className="w-5 h-5" />} + type="console" + currentTime={currentTime} + /> + </div> + {} + </div> + </div> + )} + {issueReport && issueReport.scanLogs && ( + <div className="bg-gray-950 p-4 rounded-lg shadow-md mb-6"> + <h2 className="text-lg font-semibold">Scan Logs</h2> + <div className="flex gap-2"> + {issueReport.scanLogs.map((log, index) => ( + <Drawer + title={`${index}`} + size="full" + trigger={<Button intent="gray-outline" className="mt-2">{index}</Button>} + key={index} + > + <ScanLogViewer content={log} /> + </Drawer> + ))} + </div> + </div> + )} + </div> + ) +} + +interface TimelineSliderProps { + startTime: Date | null + endTime: Date | null + currentTime: Date | null + errorTimestamps: Date[] + onChange: (value: number) => void +} + +function TimelineSlider({ + startTime, + endTime, + currentTime, + errorTimestamps, + onChange, +}: TimelineSliderProps) { + const sliderRef = useRef<HTMLInputElement>(null) + const [sliderWidth, setSliderWidth] = useState(0) + + useLayoutEffect(() => { + if (sliderRef.current) { + setSliderWidth(sliderRef.current.offsetWidth) + } + }, [sliderRef.current]) + + if (!startTime || !endTime || !currentTime) { + return <div className="text-red-500">Timeline data is not available</div> + } + + const totalSeconds = Math.floor((endTime.getTime() - startTime.getTime()) / 1000) + const currentSeconds = Math.floor((currentTime.getTime() - startTime.getTime()) / 1000) + + const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { + onChange(parseInt(event.target.value, 10)) + } + + // const errorPositions = errorTimestamps.map(timestamp => { + // const errorSeconds = Math.floor((timestamp.getTime() - startTime.getTime()) / 1000) + // return (errorSeconds / totalSeconds) * sliderWidth + // }) + + const errorSeconds = errorTimestamps.map(timestamp => Math.ceil((timestamp.getTime() - startTime.getTime()) / 1000)) + + return ( + <div className="px-4 bottom-0 left-0 fixed w-full"> + <div className="px-4 mb-8 bg-gray-950 p-4 rounded-lg shadow-md w-full "> + <input + ref={sliderRef} + type="range" + min="0" + max={totalSeconds} + value={currentSeconds} + onChange={handleChange} + className="w-full h-2 bg-[--border] rounded-lg appearance-none cursor-pointer" + /> + {/*{errorPositions.map((position, index) => (*/} + {/* <div*/} + {/* key={index}*/} + {/* className="absolute top-0 h-2 w-1 bg-red-500"*/} + {/* style={{ left: `${position}px` }}*/} + {/* />*/} + {/*))}*/} + <div + style={{ + display: "grid", + gridTemplateColumns: `repeat(${totalSeconds}, 1fr)`, + gap: "1px", + height: "4px", + width: "100%", + }} + > + {Array.from({ length: totalSeconds }).map((_, index) => ( + <div + key={index} + className={cn( + "bg-gray-800", + index === currentSeconds && "bg-gray-600", + errorSeconds.includes(index) && "bg-red-500", + )} + /> + ))} + </div> + {/*<div*/} + {/* style={{*/} + {/* background: `linear-gradient(to right, var(--brand) ${currentSeconds / totalSeconds * 100}%, var(--border) ${currentSeconds / totalSeconds * 100}%)`,*/} + {/* height: "2px",*/} + {/* }}*/} + {/*/>*/} + <div className="flex justify-between text-sm text-[--muted] mt-2"> + <span>{format(startTime, "HH:mm:ss")}</span> + <span className="font-semibold text-[--brand]">{format(currentTime, "HH:mm:ss")}</span> + <span>{format(endTime, "HH:mm:ss")}</span> + </div> + </div> + </div> + ) +} + +interface LogConsoleProps { + title: string + logs: any[] | undefined + icon: React.ReactNode + type: "server" | "click" | "network" | "reactQuery" | "console" + currentTime: Date | null +} + +function LogConsole({ title, logs = [], icon, type, currentTime }: LogConsoleProps) { + + const listRef = useRef<HTMLDivElement>(null) + + React.useEffect(() => { + if (listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight + } + }, [logs]) + + const renderLog = (log: any, index: number, isSelected: boolean) => { + switch (type) { + case "server": + return ( + <div + key={index} className={cn( + "mb-2 p-2 rounded bg-gray-900", + isSelected && "bg-gray-800", + log.includes("|ERR|") && "bg-red-950", + log.includes("|WRN|") && "bg-yellow-950", + isSelected && log.includes("|ERR|") && "bg-red-900", + isSelected && log.includes("|WRN|") && "bg-yellow-900", + )} + > + <span className="text-xs font-mono text-white break-all">{log}</span> + </div> + ) + case "click": + return ( + <div + key={index} className={cn( + "mb-2 p-2 rounded bg-gray-900", + isSelected && "bg-gray-800", + )} + > + <p className="font-semibold ">{log.element}</p> + {!!log.text?.length && <p className="text-sm italic text-[--brand]">"{log.text.slice(0, 100)}"</p>} + <p className="text-xs ">{log.pageUrl}</p> + <p className="text-xs ">{log.timestamp}</p> + </div> + ) + case "network": + return ( + <div + key={index} className={cn( + "mb-2 p-2 rounded bg-gray-900", + isSelected && "bg-gray-800", + log.status >= 400 && "bg-red-950", + isSelected && log.status >= 400 && "bg-red-900", + )} + > + <Accordion type="single" collapsible> + <AccordionItem value="v"> + <AccordionTrigger className="p-0"> + <div className="flex flex-col justify-start items-start"> + <p + className={cn( + "font-semibold text-md", + log.method === "POST" && "text-[--green]", + )} + >{log.method} <span className="font-normal text-[--brand]">{log.url}</span></p> + <p className="text-xs ">{log.pageUrl}</p> + <p className="text-xs">Status: {log.status}, Time: {log.timestamp}</p> + </div> + </AccordionTrigger> + <AccordionContent className="space-y-2"> + <p className="text-xs font-mono break-all">Body: {log.body}</p> + <p className="text-xs font-mono break-all">Data: {log.dataPreview}</p> + </AccordionContent> + </AccordionItem> + + </Accordion> + </div> + ) + case "reactQuery": + return ( + <div + key={index} className={cn( + "mb-2 p-2 rounded bg-gray-900", + isSelected && "bg-gray-800", + log.status === "error" && "bg-red-950", + isSelected && log.status === "error" && "bg-red-900", + )} + > + <p className=" font-semibold ">{log.hash}</p> + <p className="text-xs ">{log.pageUrl}</p> + <p className="text-xs ">{log.type}, {log.timestamp}</p> + </div> + ) + case "console": + return ( + <div + key={index} className={cn( + "mb-2 p-2 rounded bg-gray-900", + isSelected && "bg-gray-800", + log.type === "error" && "bg-red-950", + isSelected && log.type === "error" && "bg-red-900", + log.type === "warn" && "bg-yellow-950", + isSelected && log.type === "warn" && "bg-yellow-900", + )} + > + <p className="text-sm font-semibold ">{log.content}</p> + <p className="text-xs ">{log.timestamp}</p> + </div> + ) + default: + return <pre key={index} className="text-xs font-mono mb-2">{JSON.stringify(log, null, 2)}</pre> + } + } + + if (!currentTime) return null + + return ( + <div + className={cn( + "bg-gray-950 p-4 rounded-lg shadow-md", + type === "server" && "col-span-full", + )} + > + <div className="flex items-center mb-2"> + <span className={`p-2 rounded-full mr-2 bg-gray-900`}> + {icon} + </span> + <h2 className="text-lg font-semibold">{title}</h2> + </div> + <div ref={listRef} className={`text-[--foreground] p-3 rounded-[--radius-md] h-72 overflow-y-auto`}> + {logs && logs.length > 0 ? ( + logs.map((log, index) => { + if (type === "server") { + const timestamp = parseISO(log.substring(0, 19)) + return renderLog(log, index, isSameSecond(timestamp, currentTime!)) + } + return renderLog(log, index, isSameSecond(log.timestamp!, currentTime!)) + }) + ) : ( + <p className="text-[--muted] italic">No logs to display</p> + )} + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/layout.tsx b/seanime-2.9.10/seanime-web/src/app/layout.tsx new file mode 100644 index 0000000..df2ef5a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/layout.tsx @@ -0,0 +1,57 @@ +import { ElectronManager } from "@/app/(main)/_electron/electron-manager" +import { TauriManager } from "@/app/(main)/_tauri/tauri-manager" +import { ClientProviders } from "@/app/client-providers" +import { __isElectronDesktop__, __isTauriDesktop__ } from "@/types/constants" +import type { Metadata } from "next" +import { Inter } from "next/font/google" +import "./globals.css" +import React from "react" + +export const dynamic = "force-static" + +const inter = Inter({ subsets: ["latin"] }) + +export const metadata: Metadata = { + title: "Seanime", + description: "Self-hosted, user-friendly media server for anime and manga.", + icons: { + icon: "/icons/favicon.ico", + apple: "/icons/apple-icon.png", + }, + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "Seanime", + }, + formatDetection: { + telephone: false, + }, + other: { + "mobile-web-app-capable": "yes", + "apple-mobile-web-app-capable": "yes", + "apple-mobile-web-app-status-bar-style": "black-translucent", + "apple-mobile-web-app-title": "Seanime", + }, +} + +export default function RootLayout({ children }: { + children: React.ReactNode +}) { + return ( + <html lang="en" suppressHydrationWarning> + {process.env.NODE_ENV === "development" && <head> + <script src="https://unpkg.com/react-scan/dist/auto.global.js" async></script> + </head>} + <body className={inter.className} suppressHydrationWarning> + {/* {process.env.NODE_ENV === "development" && <script src="http://localhost:8097"></script>} */} + <ClientProviders> + {__isTauriDesktop__ && <TauriManager />} + {__isElectronDesktop__ && <ElectronManager />} + {children} + </ClientProviders> + </body> + </html> + ) +} + + diff --git a/seanime-2.9.10/seanime-web/src/app/manifest.ts b/seanime-2.9.10/seanime-web/src/app/manifest.ts new file mode 100644 index 0000000..4ec91ce --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/manifest.ts @@ -0,0 +1,42 @@ +import type { MetadataRoute } from "next" + +export const dynamic = "force-static" + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Seanime", + short_name: "Seanime", + description: + "Self-hosted, user-friendly media server for anime and manga.", + start_url: "/", + display: "standalone", + background_color: "#070707", + theme_color: "#070707", + icons: [ + { + src: "/icons/android-chrome-192x192.png", + sizes: "192x192", + type: "image/png", + purpose: "maskable", + }, + { + src: "/icons/android-chrome-512x512.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + { + src: "/icons/apple-icon.png", + sizes: "180x180", + type: "image/png", + purpose: "any", + }, + ], + scope: "/", + id: "/", + orientation: "portrait-primary", + categories: ["entertainment", "multimedia"], + lang: "en", + dir: "ltr", + } +} diff --git a/seanime-2.9.10/seanime-web/src/app/public/auth/page.tsx b/seanime-2.9.10/seanime-web/src/app/public/auth/page.tsx new file mode 100644 index 0000000..b500d62 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/public/auth/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { ServerAuth } from "./server-auth" + +export default function Page() { + return <ServerAuth /> +} diff --git a/seanime-2.9.10/seanime-web/src/app/public/auth/server-auth.tsx b/seanime-2.9.10/seanime-web/src/app/public/auth/server-auth.tsx new file mode 100644 index 0000000..6439149 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/public/auth/server-auth.tsx @@ -0,0 +1,55 @@ +import { serverAuthTokenAtom } from "@/app/(main)/_atoms/server-status.atoms" +import { defineSchema, Field, Form } from "@/components/ui/form" +import { Modal } from "@/components/ui/modal" +import { useAtom } from "jotai" +import { sha256 } from "js-sha256" +import React, { useState } from "react" + +// async function hashSHA256Hex(str: string): Promise<string> { +// const encoder = new TextEncoder() +// const data = encoder.encode(str) +// const hashBuffer = await window.crypto.subtle.digest("SHA-256", data) +// return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, "0")).join("") +// } + +export function ServerAuth() { + + const [, setAuthToken] = useAtom(serverAuthTokenAtom) + const [loading, setLoading] = useState(false) + + return (<> + <Modal + title="Password required" + description="This Seanime server requires authentication." + open={true} + onOpenChange={(v) => {}} + overlayClass="bg-opacity-100 bg-gray-900" + contentClass="border focus:outline-none focus-visible:outline-none outline-none" + hideCloseButton + > + <Form + schema={defineSchema(({ z }) => z.object({ + password: z.string().min(1, "Password is required"), + }))} + onSubmit={async data => { + setLoading(true) + // const hash = await hashSHA256Hex(data.password) + const hash = sha256(data.password) + setAuthToken(hash) + React.startTransition(() => { + window.location.href = "/" + setLoading(false) + }) + }} + > + <Field.Text + type="password" + name="password" + label="Enter the password" + fieldClass="" + /> + <Field.Submit showLoadingOverlayOnSuccess loading={loading}>Continue</Field.Submit> + </Form> + </Modal> + </>) +} diff --git a/seanime-2.9.10/seanime-web/src/app/scan-log-viewer/page.tsx b/seanime-2.9.10/seanime-web/src/app/scan-log-viewer/page.tsx new file mode 100644 index 0000000..69c5266 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/scan-log-viewer/page.tsx @@ -0,0 +1,37 @@ +"use client" + +import { ScanLogViewer } from "@/app/scan-log-viewer/scan-log-viewer" +import { TextInput } from "@/components/ui/text-input" +import React, { useRef, useState } from "react" + +export default function Page() { + const [content, setContent] = useState<string>("") + const fileInputRef = useRef<HTMLInputElement>(null) + + const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + const reader = new FileReader() + reader.onload = (e) => { + const content = e.target?.result as string + setContent(content) + } + reader.readAsText(file) + } + } + + return ( + <div className="container mx-auto bg-gray-900 p-4 min-h-screen relative"> + {/*<h1 className="text-3xl font-bold mb-6 text-brand-300 text-center">Scan Log Viewer</h1>*/} + <div className="container max-w-2xl"> + <TextInput + type="file" + ref={fileInputRef} + onChange={handleFileChange} + className="mb-6 p-1" + /> + </div> + <ScanLogViewer content={content} /> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/scan-log-viewer/scan-log-viewer.tsx b/seanime-2.9.10/seanime-web/src/app/scan-log-viewer/scan-log-viewer.tsx new file mode 100644 index 0000000..6e3de8f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/scan-log-viewer/scan-log-viewer.tsx @@ -0,0 +1,229 @@ +"use client" + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { TextInput } from "@/components/ui/text-input" +import json2toml from "json2toml" +import React, { useState } from "react" +import { BiCheck, BiFile, BiSearch, BiSolidStar } from "react-icons/bi" +import { RiFileSettingsFill } from "react-icons/ri" +import { Virtuoso } from "react-virtuoso" + + +export function ScanLogViewer({ content }: { content: string }) { + const [lines, setLines] = useState<string[]>([]) + + React.useEffect(() => { + if (content) { + setLines(content.split("\n")) + } + }, [content]) + + const [selected, setSelected] = React.useState<string | null>(null) + + const linesDisplayed = React.useMemo(() => { + if (!selected?.length) return lines + return lines.filter((line) => line.includes(selected)) + }, [lines, selected]) + + return ( + <div className="container mx-auto bg-gray-900 p-4 min-h-[60vh] relative space-y-2"> + <TextInput + value={selected || ""} + onChange={(e) => setSelected(e.target.value)} + placeholder="Search..." + /> + {selected && ( + <Button intent="white" size="sm" onClick={() => setSelected(null)}> + Clear selection + </Button> + )} + <Accordion type={selected ? "multiple" : "single"} collapsible={selected ? undefined : true}> + {linesDisplayed.length > 0 && ( + <div className="h-[calc(100vh-150px)]"> + <Virtuoso + data={linesDisplayed} + itemContent={(index, line) => ( + <Line + index={index} + line={line} + onFileSelect={(file) => setSelected(file)} + /> + )} + className="h-full" + /> + </div> + )} + </Accordion> + </div> + ) +} + +interface LineProps { + line: string + index: number + onFileSelect: (file: string) => void +} + +function Line({ line, index, onFileSelect }: LineProps) { + + const [data, setData] = React.useState<Record<string, any> | null>(null) + + React.useEffect(() => { + try { + setData(JSON.parse(line) as any) + } + catch (e) { + console.log("Not a JSON", e) + } + }, []) + + const isParsedFileLine = data && data.path && data.filename + const isMediaFetcher = data && data.context === "MediaFetcher" + const isMatcher = data && data.context === "Matcher" + const isFileHydrator = data && data.context === "FileHydrator" + const isNotModule = !isParsedFileLine && !isMediaFetcher && !isMatcher && !isFileHydrator + + if (!data) return <div className="h-1"></div> + + return ( + <AccordionItem value={String(index)}> + <div + className={cn( + "bg-gray-950 rounded-[--radius-md]", + )} + > + <span className="font-mono text-white break-all"> + <div + className={cn( + "mb-2 rounded", + //isSelected && "bg-gray-800", + //log.status >= 400 && "bg-red-950", + //isSelected && log.status >= 400 && "bg-red-900", + )} + > + <AccordionTrigger className="p-2 "> + <div className="flex flex-row gap-2"> + {isParsedFileLine && ( + <> + <BiFile className="text-blue-500" /> + <p className="text-sm break-all tracking-wide text-blue-200 text-left"> + {data.path} + </p> + </> + )} + {isMediaFetcher && ( + <p className="text-sm tracking-wide text-green-100"> + {JSON.stringify(data)} + </p> + )} + {isMatcher && ( + <div className="flex flex-col justify-start items-start gap-2"> + <div className="flex gap-2"> + <BiSearch className="text-indigo-500" /> + <p + className={cn( + "text-sm tracking-wide", + data.filename && "text-blue-200", + data.fileRatings && "text-yellow-300", + data.titleVariations && "text-blue-200 opacity-50", + data.id && "text-blue-200", + data.rating && "text-green-200", + data.match && "text-orange-200 opacity-80", + (data.rating && data.threshold && data.rating < data.threshold) && "text-red-400", + data.message?.includes("un-matching") && "text-red-400", + )} + > + {data.filename ? data.filename : + data.fileRatings ? `${data.fileRatings.length} ratings for ${data.mediaId}` : + data.message} + </p> + </div> + + {data.hasOwnProperty("rating") ? ( + <p className="text-sm tracking-wide flex gap-1 items-center "> + <BiSolidStar className="text-[--yellow]" /> {data.rating?.toFixed(2)}{data.highestRating && "/"}{data.highestRating?.toFixed( + 2)}, {data.message} + </p> + ) : data.hasOwnProperty("id") ? ( + <p className="text-sm tracking-wide flex gap-1 items-center "> + {data.message} + <BiCheck className="text-[--green]" /> <span className="text-indigo-300">{data.title}</span> + <span>[{data.id}]</span> + </p> + ) : data.titleVariations ? ( + <p className="text-xs tracking-wide break-words text-left"> + {data.titleVariations.length} title variations + </p> + ) : data.match && ( + <p className="text-sm tracking-wide flex gap-1 items-center "> + {data.message} <BiCheck className="text-[--green]" /> + <span className="text-[--muted]">{data.match.Value}</span> + <span className="flex items-center"> + (<BiSolidStar className="text-[--yellow]" /> {data.match.Rating?.toFixed(2)}{data.match.Distance}) + </span> + </p> + )} + </div> + )} + {isFileHydrator && ( + <div className="flex flex-col justify-start items-start gap-2"> + <div className="flex gap-2"> + <RiFileSettingsFill className="text-cyan-500" /> + <p + className={cn( + "text-sm tracking-wide", + data.filename && "text-blue-200", + data.branches && "text-yellow-300", + data.level === "warn" && "text-orange-300", + data.level === "error" && "text-red-400", + )} + > + {data.filename ? data.filename : + data.fileRatings ? `${data.fileRatings.length} ratings for ${data.mediaId}` : + data.branches ? `${data.branches.length} branches fetched for ${data.mediaId}` : + data.message} + </p> + + </div> + {data.metadata && ( + <p className="text-sm tracking-wide text-green-100"> + {data.message} {JSON.stringify(data.metadata)} + </p> + )} + </div> + )} + {isNotModule && ( + <p className="text-sm tracking-wide text-yellow-100 opacity-70"> + {JSON.stringify(data)} + </p> + )} + </div> + </AccordionTrigger> + <AccordionContent className="space-y-2"> + {data.filename && ( + <Button intent="white" size="xs" onClick={() => onFileSelect(data.filename)}> + Select file + </Button> + )} + {data.titleVariations && ( + <div className="text-xs tracking-wide break-words text-left"> + {data.titleVariations.map((title: string) => { + return ( + <p key={title} className=""> + {title} + </p> + ) + })} + </div> + )} + {<pre className="text-sm">{json2toml(data, { newlineAfterSection: true, indent: 2 })}</pre>} + </AccordionContent> + + </div> + </span> + </div> + </AccordionItem> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/splashscreen/crash/page.tsx b/seanime-2.9.10/seanime-web/src/app/splashscreen/crash/page.tsx new file mode 100644 index 0000000..ac66df2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/splashscreen/crash/page.tsx @@ -0,0 +1,21 @@ +"use client" + +import { ElectronCrashScreenError } from "@/app/(main)/_electron/electron-crash-screen" +import { TauriCrashScreenError } from "@/app/(main)/_tauri/tauri-crash-screen-error" +import { LuffyError } from "@/components/shared/luffy-error" +import { LoadingOverlay } from "@/components/ui/loading-spinner" +import { __isElectronDesktop__, __isTauriDesktop__ } from "@/types/constants" +import React from "react" + +export default function Page() { + + return ( + <LoadingOverlay showSpinner={false}> + <LuffyError title="Something went wrong"> + {__isTauriDesktop__ && <TauriCrashScreenError />} + {__isElectronDesktop__ && <ElectronCrashScreenError />} + </LuffyError> + </LoadingOverlay> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/splashscreen/page.tsx b/seanime-2.9.10/seanime-web/src/app/splashscreen/page.tsx new file mode 100644 index 0000000..877f5e3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/splashscreen/page.tsx @@ -0,0 +1,23 @@ +"use client" + +import { LoadingOverlay } from "@/components/ui/loading-spinner" +import Image from "next/image" +import React from "react" + +export default function Page() { + + return ( + <LoadingOverlay showSpinner={false}> + <Image + src="/logo_2.png" + alt="Launching..." + priority + width={180} + height={180} + className="animate-pulse" + /> + Launching... + </LoadingOverlay> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/app/template.tsx b/seanime-2.9.10/seanime-web/src/app/template.tsx new file mode 100644 index 0000000..20e2a11 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/template.tsx @@ -0,0 +1,16 @@ +"use client" + +import { ElectronWindowTitleBar } from "@/app/(main)/_electron/electron-window-title-bar" +import { TauriWindowTitleBar } from "@/app/(main)/_tauri/tauri-window-title-bar" +import { __isElectronDesktop__, __isTauriDesktop__ } from "@/types/constants" +import React from "react" + +export default function Template({ children }: { children: React.ReactNode }) { + return ( + <> + {__isTauriDesktop__ && <TauriWindowTitleBar />} + {__isElectronDesktop__ && <ElectronWindowTitleBar />} + {children} + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/app/vidstack-theme.css b/seanime-2.9.10/seanime-web/src/app/vidstack-theme.css new file mode 100644 index 0000000..9286e31 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/vidstack-theme.css @@ -0,0 +1,1979 @@ +:root { + --media-slider-thumb-bg: #fff; + --media-slider--bg: #fff; + --media-brand: #fff; + --media-slider-track-fill-bg: #fff; + --media-slider-track-height: 4px; + --media-slider-focused-track-height: calc(var(--media-slider-track-height) * 1.5); + --media-buffering-size: 70px; + --media-slider-track-bg: rgba(255, 255, 255, 0.15); + --media-menu-hint-font-size: 15px; + --media-menu-hint-color: rgba(255, 255, 255, 0.7); + --media-menu-bg: rgba(0, 0, 0, 0.9); + --media-font-family: Inter, sans-serif; + --media-menu-radio-check-size: 14px; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Player + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +[data-media-player] { + width: 100%; + display: inline-flex; + align-items: center; + position: relative; + contain: style; + box-sizing: border-box; + user-select: none; +} + +:where([data-media-player][data-view-type='video']) { + aspect-ratio: 16 / 9; +} + +[data-media-player]:focus, +[data-media-player]:focus-visible { + outline: none; +} + +[data-media-player][data-view-type='video'][data-started]:not([data-controls]) { + pointer-events: auto; + cursor: none; +} + +[data-media-player] slot { + display: contents; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Provider + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +[data-media-provider] { + display: flex; + position: relative; + box-sizing: border-box; + align-items: center; + border-radius: inherit; + width: 100%; + aspect-ratio: inherit; + overflow: hidden; +} + +[data-media-player]:not([data-view-type='audio']) [data-media-provider], +[data-media-player][data-fullscreen] [data-media-provider] { + height: 100%; +} + +[data-media-player][data-view-type='audio'] [data-media-provider] { + display: contents; + background-color: unset; +} + +[data-media-provider] audio { + width: 100%; +} + +:where([data-media-provider] video), +:where([data-media-provider] iframe) { + aspect-ratio: inherit; + display: inline-block; + height: auto; + object-fit: contain; + touch-action: manipulation; + border-radius: inherit; + width: 100%; +} + +[data-media-provider] iframe { + height: 100%; +} + +[data-media-player][data-view-type='audio'] video, +[data-media-player][data-view-type='audio'] iframe { + display: none; +} + +[data-media-player][data-fullscreen] video { + height: 100%; +} + +iframe.vds-youtube[data-no-controls] { + height: 1000%; +} + +.vds-blocker { + position: absolute; + inset: 0; + width: 100%; + height: auto; + aspect-ratio: inherit; + pointer-events: auto; + border-radius: inherit; + z-index: 1; +} + +[data-ended] .vds-blocker { + background-color: black; +} + +.vds-icon:focus { + outline: none; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Google Cast + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +.vds-google-cast { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + color: #dedede; + font-family: sans-serif; + font-weight: 500; +} + +.vds-google-cast svg { + --size: max(18%, 40px); + width: var(--size); + height: var(--size); + margin-bottom: 8px; +} + +.vds-google-cast-info { + font-size: calc(var(--media-height) / 100 * 6); +} + +:where(.vds-buffering-indicator) { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 1; +} + +:where(.vds-buffering-indicator) :where(.vds-buffering-icon, .vds-buffering-spinner) { + opacity: 0; + pointer-events: none; + transition: var(--media-buffering-transition, opacity 200ms ease); +} + +:where(.vds-buffering-indicator) +:where(.vds-buffering-icon, svg.vds-buffering-spinner, .vds-buffering-spinner svg) { + width: var(--media-buffering-size, 96px); + height: var(--media-buffering-size, 96px); +} + +:where(.vds-buffering-indicator) :where(.vds-buffering-track, circle[data-part='track']) { + color: var(--media-buffering-track-color, #f5f5f5); + opacity: var(--media-buffering-track-opacity, 0.25); + stroke-width: var(--media-buffering-track-width, 8); +} + +:where(.vds-buffering-indicator) :where(.vds-buffering-track-fill, circle[data-part='track-fill']) { + color: var(--media-buffering-track-fill-color, var(--media-brand)); + opacity: var(--media-buffering-track-fill-opacity, 0.75); + stroke-width: var(--media-buffering-track-fill-width, 9); + stroke-dasharray: 100; + stroke-dashoffset: var(--media-buffering-track-fill-offset, 50); +} + +:where([data-buffering]) :where(.vds-buffering-icon, .vds-buffering-spinner) { + opacity: 1; + animation: var(--media-buffering-animation, vds-buffering-spin 1s linear infinite); +} + +@keyframes vds-buffering-spin { + to { + transform: rotate(360deg); + } +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Buttons + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-button) { + -webkit-tap-highlight-color: transparent; + position: relative; + display: inline-flex; + justify-content: center; + align-items: center; + user-select: none; + appearance: none; + background: none; + outline: none; + border: none; + padding: var(--media-button-padding, 0px); + border-radius: var(--media-button-border-radius, 8px); + color: var(--media-button-color, var(--media-controls-color, #f5f5f5)); + width: var(--media-button-size, 40px); + height: var(--media-button-size, 40px); + transition: transform 0.2s ease-out; + contain: layout style; + cursor: pointer; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + flex-shrink: 0; +} + +.vds-button { + border: var(--media-button-border); +} + +:where([data-fullscreen] .vds-button) { + width: var(--media-fullscreen-button-size, 42px); + height: var(--media-fullscreen-button-size, 42px); +} + +@media screen and (max-width: 599px) { + :where([data-fullscreen] .vds-button) { + width: var(--media-sm-fullscreen-button-size, 42px); + height: var(--media-sm-fullscreen-button-size, 42px); + } +} + +:where(.vds-button .vds-icon) { + width: var(--media-button-icon-size, 80%); + height: var(--media-button-icon-size, 80%); + border-radius: var(--media-button-border-radius, 8px); +} + +:where(.vds-menu-button .vds-icon) { + display: flex !important; +} + +:where(.vds-button[aria-hidden='true']) { + display: none !important; +} + +@media (hover: hover) and (pointer: fine) { + .vds-button:hover { + background-color: var(--media-button-hover-bg, rgb(255 255 255 / 0.2)); + } + + .vds-button:hover { + transform: var(--media-button-hover-transform, scale(1)); + transition: var(--media-button-hover-transition, transform 0.2s ease-in); + } +} + +@media (pointer: coarse) { + .vds-button:hover { + border-radius: var(--media-button-touch-hover-border-radius, 100%); + background-color: var(--media-button-touch-hover-bg, rgb(255 255 255 / 0.2)); + } +} + +:where(.vds-button:focus) { + outline: none; +} + +:where(.vds-button[data-focus]) { + box-shadow: var(--media-focus-ring); +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Live Button + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-live-button) { + min-width: auto; + min-height: auto; + width: var(--media-live-button-width, 40px); + height: var(--media-live-button-height, 40px); + padding: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + user-select: none; + appearance: none; + background: none; + outline: none; + border: none; +} + +:where(.vds-live-button-text) { + background-color: var(--media-live-button-bg, #8a8a8a); + border-radius: var(--media-live-button-border-radius, 2px); + color: var(--media-live-button-color, #161616); + font-family: var(--media-font-family, sans-serif); + font-size: var(--media-live-button-font-size, 12px); + font-weight: var(--media-live-button-font-weight, 600); + letter-spacing: var(--media-live-button-letter-spacing, 1.5px); + padding: var(--media-live-button-padding, 1px 4px); + transition: color 0.3s ease; +} + +:where(.vds-live-button[data-focus] .vds-live-button-text) { + box-shadow: var(--media-focus-ring); +} + +:where(.vds-live-button[data-edge]) { + cursor: unset; +} + +:where(.vds-live-button[data-edge] .vds-live-button-text) { + background-color: var(--media-live-button-edge-bg, #dc2626); + color: var(--media-live-button-edge-color, #f5f5f5); +} + +@media (pointer: fine) { + :where(.vds-live-button:hover) { + background-color: unset; + } +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * States + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +/* Play Button */ +.vds-button:not([data-paused]) .vds-play-icon, +.vds-button[data-ended] .vds-play-icon, +.vds-button[data-paused] .vds-pause-icon, +.vds-button[data-ended] .vds-pause-icon, +.vds-button:not([data-ended]) .vds-replay-icon, + /* PIP Button */ +.vds-button[data-active] .vds-pip-enter-icon, +.vds-button:not([data-active]) .vds-pip-exit-icon, + /* Fullscreen Button */ +.vds-button[data-active] .vds-fs-enter-icon, +.vds-button:not([data-active]) .vds-fs-exit-icon, + /* Caption Button */ +.vds-button:not([data-active]) .vds-cc-on-icon, +.vds-button[data-active] .vds-cc-off-icon, + /* Mute Button */ +.vds-button:not([data-muted]) .vds-mute-icon, +.vds-button:not([data-state='low']) .vds-volume-low-icon, +.vds-button:not([data-state='high']) .vds-volume-high-icon { + display: none; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Captions + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-captions) { + /* Recommended settings in the WebVTT spec (https://www.w3.org/TR/webvtt1). */ + --overlay-padding: var(--media-captions-padding, 1%); + --cue-color: var(--media-user-text-color, var(--media-cue-color, white)); + --cue-bg-color: var(--media-user-text-bg, var(--media-cue-bg, rgba(0, 0, 0, 0.7))); + --cue-default-font-size: var(--media-cue-font-size, calc(var(--overlay-height) / 100 * 4.5)); + --cue-font-size: calc(var(--cue-default-font-size) * var(--media-user-font-size, 1)); + --cue-line-height: var(--media-cue-line-height, calc(var(--cue-font-size) * 1.2)); + --cue-padding-x: var(--media-cue-padding-x, calc(var(--cue-font-size) * 0.6)); + --cue-padding-y: var(--media-cue-padding-x, calc(var(--cue-font-size) * 0.4)); + --cue-padding: var(--cue-padding-y) var(--cue-padding-x); + position: absolute; + inset: 0; + z-index: 1; + contain: layout style; + margin: var(--overlay-padding); + font-size: var(--cue-font-size); + font-family: var(--media-user-font-family, sans-serif); + box-sizing: border-box; + pointer-events: none; + user-select: none; + word-spacing: normal; + word-break: break-word; +} + +:where([data-view-type='audio'] .vds-captions) { + position: relative; + margin: 0; +} + +:where(.vds-captions[aria-hidden='true']) { + opacity: 0; + visibility: hidden; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * VTT Cues + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-captions [data-part='cue-display']) { + position: absolute; + direction: ltr; + overflow: visible; + contain: content; + top: var(--cue-top); + left: var(--cue-left); + right: var(--cue-right); + bottom: var(--cue-bottom); + width: var(--cue-width, auto); + height: var(--cue-height, auto); + box-sizing: border-box; + transform: var(--cue-transform); + text-align: var(--cue-text-align); + writing-mode: var(--cue-writing-mode, unset); + white-space: pre-line; + unicode-bidi: plaintext; + min-width: min-content; + min-height: min-content; + padding: var(--media-cue-display-padding); + background-color: var(--media-user-display-bg, var(--media-cue-display-bg)); + border-radius: var(--media-cue-display-border-radius); +} + +:where(.vds-captions[data-dir='rtl'] [data-part='cue-display']) { + direction: rtl; +} + +:where(.vds-captions [data-part='cue']) { + display: inline-block; + contain: content; + font-variant: var(--media-user-font-variant); + border: var(--media-cue-border, unset); + border-radius: var(--media-cue-border-radius, 2px); + backdrop-filter: var(--media-cue-backdrop, blur(8px)); + padding: var(--cue-padding); + line-height: var(--cue-line-height); + background-color: var(--cue-bg-color); + box-sizing: border-box; + color: var(--cue-color); + box-shadow: var(--media-cue-box-shadow, var(--cue-box-shadow)); + white-space: var(--cue-white-space, pre-wrap); + outline: var(--cue-outline); + text-shadow: var(--media-user-text-shadow, var(--cue-text-shadow)); +} + +:where(.vds-captions [data-part='cue-display'][data-vertical] [data-part='cue']) { + --cue-padding: var(--cue-padding-x) var(--cue-padding-y); +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * VTT Regions + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-captions [data-part='region']) { + --anchor-x-percent: calc(var(--region-anchor-x) / 100); + --anchor-x: calc(var(--region-width) * var(--anchor-x-percent)); + --anchor-y-percent: calc(var(--region-anchor-y) / 100); + --anchor-y: calc(var(--region-height) * var(--anchor-y-percent)); + --vp-anchor-x: calc(var(--region-viewport-anchor-x) * 1%); + --vp-anchor-y-percent: calc(var(--region-viewport-anchor-y) / 100); + --vp-anchor-y: calc(var(--overlay-height) * var(--vp-anchor-y-percent)); + position: absolute; + display: inline-flex; + flex-flow: column; + justify-content: flex-start; + width: var(--region-width); + height: var(--region-height); + min-height: 0px; + max-height: var(--region-height); + writing-mode: horizontal-tb; + top: var(--region-top, calc(var(--vp-anchor-y) - var(--anchor-y))); + left: var(--region-left, calc(var(--vp-anchor-x) - var(--anchor-x))); + right: var(--region-right); + bottom: var(--region-bottom); + overflow: hidden; + overflow-wrap: break-word; + box-sizing: border-box; +} + +:where(.vds-captions [data-part='region'][data-active]) { +} + +:where(.vds-captions [data-part='region'][data-scroll='up']) { + justify-content: end; +} + +:where(.vds-captions [data-part='region'][data-active][data-scroll='up']) { + transition: top 0.433s; +} + +:where(.vds-captions [data-part='region'] > [data-part='cue-display']) { + position: relative; + width: auto; + left: var(--cue-offset); + height: var(--cue-height, auto); + text-align: var(--cue-text-align); + unicode-bidi: plaintext; + margin-top: 2px; +} + +:where(.vds-captions [data-part='region'] [data-part='cue']) { + position: relative; + border-radius: 0px; +} + +:where(.vds-chapter-title) { + --color: var(--media-chapter-title-color, rgba(255 255 255 / 0.64)); + display: inline-block; + font-family: var(--media-font-family, sans-serif); + font-size: var(--media-chapter-title-font-size, 16px); + font-weight: var(--media-chapter-title-font-weight, 400); + color: var(--color); + flex: 1 1 0%; + padding-inline: 6px; + overflow: hidden; + text-align: start; + white-space: nowrap; + text-overflow: ellipsis; +} + +.vds-chapter-title::before { + content: var(--media-chapter-title-separator, '\2022'); + display: inline-block; + margin-right: var(--media-chapter-title-separator-gap, 6px); + color: var(--media-chapter-title-separator-color, var(--color)); +} + +.vds-chapter-title:empty::before { + content: ''; + margin: 0; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Controls + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-controls), +:where(.vds-controls-group) { + position: relative; + display: inline-block; + width: 100%; + box-sizing: border-box; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Audio Controls + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where([data-view-type='audio'] .vds-controls) { + display: inline-block; + max-width: 100%; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Video Controls + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where([data-view-type='video'] .vds-controls) { + display: flex; + position: absolute; + flex-direction: column; + inset: 0; + width: 100%; + height: 100%; + z-index: 10; + opacity: 0; + visibility: hidden; + pointer-events: none; + padding: var(--media-controls-padding, 0px); + transition: var(--media-controls-out-transition, opacity 0.2s ease-out); +} + +:where([data-view-type='video'] .vds-controls[data-visible]) { + opacity: 1; + visibility: visible; + transition: var(--media-controls-in-transition, opacity 0.2s ease-in); +} + +:where(.vds-controls-spacer) { + flex: 1 1 0%; + pointer-events: none; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Gesture + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-gestures) { + display: contents; +} + +:where(.vds-gesture) { + position: absolute; + display: block; + contain: content; + z-index: 0; + opacity: 0; + visibility: hidden; + pointer-events: none !important; +} + +:where(.vds-icon svg) { + display: block; + width: 100%; + height: 100%; + vertical-align: middle; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Keyboard Action + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-kb-action.hidden) { + display: none; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Keyboard Text + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-kb-text-wrapper) { + text-align: center; + position: absolute; + left: 0; + right: 0; + top: var(--media-kb-text-top, 10%); + z-index: 20; + pointer-events: none; +} + +:where(.vds-kb-text) { + display: inline-block; + padding: var(--media-kb-text-padding, 10px 20px); + font-size: var(--media-kb-text-size, 150%); + font-family: var(--media-font-family, sans-serif); + color: var(--media-kb-text-color, #eee); + background: var(--media-kb-text-bg, rgba(0, 0, 0, 0.5)); + border-radius: var(--media-kb-border-radius, 2.5px); + pointer-events: none; +} + +:where(.vds-kb-text:empty) { + display: none; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Keyboard Bezel + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-kb-bezel) { + --size: var(--media-kb-bezel-size, 52px); + position: absolute; + left: 50%; + top: 45%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: var(--size); + height: var(--size); + margin-left: calc(-1 * calc(var(--size) / 2)); + margin-right: calc(-1 * calc(var(--size) / 2)); + z-index: 20; + background: var(--media-kb-bezel-bg, rgba(0, 0, 0, 0.5)); + border-radius: var(--media-kb-bezel-border-radius, calc(var(--size) / 2)); + animation: var(--media-kb-bezel-animation, vds-bezel-fade 0.35s linear 1 normal forwards); + pointer-events: none; +} + +:where(.vds-kb-bezel:has(slot:empty)) { + display: none; +} + +:where(.vds-kb-action[data-action='seek-forward'] .vds-kb-bezel) { + top: 45%; + left: unset; + right: 10%; +} + +:where(.vds-kb-action[data-action='seek-backward'] .vds-kb-bezel) { + top: 45%; + left: 10%; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Keyboard Icon + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-kb-icon) { + --size: var(--media-kb-icon-size, 38px); + width: var(--size); + height: var(--size); +} + +@keyframes vds-bezel-fade { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + transform: scale(2); + } +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Menus + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-menu:not([data-submenu]) media-menu:not([data-submenu])) { + display: contents; +} + +:where(.vds-menu) { + font-family: var(--media-font-family, sans-serif); + font-size: var(--media-menu-font-size, 14px); + font-weight: var(--media-menu-font-weight, 500); +} + +:where(.vds-menu[data-disabled]:not([data-submenu])) { + display: none; +} + +:where(.vds-menu[data-submenu]) { + display: inline-block; +} + +:where(.vds-menu-items:focus) { + outline: none; +} + +:where(.vds-menu-items) :where([role='menuitem']:focus, [role='menuitemradio']:focus) { + outline: none; +} + +:where(.vds-menu-items) +:where( + [role='menuitem']:focus-visible, + [role='menuitem'][data-focus], + [role='menuitemradio']:focus-visible, + [role='menuitemradio'][data-focus] + ), +:where(.vds-radio[data-focus]) { + outline: none; + box-shadow: var(--media-focus-ring); +} + +:where(.vds-menu[data-open] .vds-tooltip-content) { + display: none !important; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Scroll + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +@media (prefers-reduced-motion: no-preference) { + :where(.vds-menu-items) { + scroll-behavior: smooth; + } +} + +:where(.vds-menu-items) { + --vds-scrollbar-thumb: var(--media-menu-scrollbar-thumb-bg, rgb(245 245 245 / 0.1)); + box-sizing: border-box; + min-width: var(--media-menu-min-width, 280px); + scrollbar-width: thin; + scrollbar-color: var(--vds-scrollbar-thumb) var(--media-menu-scrollbar-track-bg, transparent); +} + +:where(.vds-menu-items)::-webkit-scrollbar { + background-color: black; + border-radius: var(--media-menu-border-radius, 8px); + height: 6px; + width: 5px; +} + +:where(.vds-menu-items)::-webkit-scrollbar-track { + background-color: var(--media-menu-scrollbar-track-bg, rgb(245 245 245 / 0.08)); + border-radius: 4px; +} + +:where(.vds-menu-items)::-webkit-scrollbar-thumb { + background-color: var(--vds-scrollbar-thumb); + border-radius: 4px; +} + +:where(.vds-menu-items)::-webkit-scrollbar-corner { + background-color: var(--vds-scrollbar-thumb); +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Menu Button + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-menu-button) { + outline: none; + border-radius: 8px !important; +} + +:where(.vds-menu-button[role='button'] .vds-rotate-icon) { + transition: transform 0.2s ease-out; +} + +:where(.vds-menu-button[aria-expanded='true'] .vds-rotate-icon) { + transform: rotate(var(--media-menu-button-icon-rotate-deg, 90deg)); + transition: transform 0.2s ease-in; +} + +:where(.vds-menu-button[role='button']) { + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Menu Button Parts + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-menu-button) { + box-sizing: border-box; +} + +/* SR-only. */ +:where(.vds-menu-button[role='button']) :where(.vds-menu-button-label, .vds-menu-button-hint) { + position: absolute; + height: 1px; + width: 1px; + overflow: hidden; + clip: rect(1px, 1px, 1px, 1px); +} + +:where(.vds-menu-button[role='button']) +:where(.vds-menu-button-open-icon, .vds-menu-button-close-icon) { + display: none !important; +} + +:where(.vds-menu-button) :where(.vds-menu-button-hint, .vds-menu-button-open-icon) { + color: var(--media-menu-hint-color, rgb(245 245 245 / 0.5)); + font-size: var(--media-menu-hint-font-size, 14px); +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Menu Items + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-menu-items) { + display: flex; + flex-direction: column; + font-family: var(--media-font-family, sans-serif); + font-size: var(--media-menu-font-size, 15px); + font-weight: var(--media-menu-font-weight, 500); + transition: height 0.35s ease; +} + +:where(.vds-menu-items:not([data-submenu])) { + padding: var(--media-menu-padding, 10px); + background-color: var(--media-menu-bg, rgb(10 10 10)); + border-radius: var(--media-menu-border-radius, 8px); + box-shadow: var(--media-menu-box-shadow, 1px 1px 1px rgb(10 10 10 / 0.5)); + /*backdrop-filter: blur(4px);*/ + height: var(--menu-height, auto); + will-change: width, height; + overflow-y: auto; + overscroll-behavior: contain; + opacity: 0; + z-index: 9999999; + box-sizing: border-box; + max-height: var(--media-menu-max-height, 250px); +} + +.vds-menu-items:not([data-submenu]) { + border: var(--media-menu-border, 1px solid rgb(255 255 255 / 0.1)); +} + +:where([data-view-type='video'], .vds-video-layout) :where(.vds-menu-items:not([data-submenu])) { + max-height: var(--media-menu-max-height, calc(var(--player-height) * 0.7)); +} + +:where(.vds-menu-items[data-resizing]) { + --media-menu-scrollbar-thumb-bg: rgba(0, 0, 0, 0); + pointer-events: none; + overflow: hidden; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Menu Items Animation + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-menu-items:not([data-submenu])) { + --enter-transform: translateY(0px); + --exit-transform: translateY(12px); +} + +/* Mobile Popup */ +:where(.vds-menu-items:not([data-submenu]):not([data-placement])) { + --enter-transform: translateY(-24px); +} + +:where(.vds-menu-items:not([data-submenu])[aria-hidden='true']) { + animation: var(--media-menu-exit-animation, vds-menu-exit 0.2s ease-out); +} + +:where(.vds-menu-items:not([data-submenu])[aria-hidden='false']) { + animation: var(--media-menu-enter-animation, vds-menu-enter 0.3s ease-out); + animation-fill-mode: forwards; +} + +/* Bottom */ +:where(.vds-menu-items[data-placement~='bottom']) { + --enter-transform: translateY(0); + --exit-transform: translateY(-12px); +} + +@keyframes vds-menu-enter { + from { + opacity: 0; + transform: var(--exit-transform); + } + to { + opacity: 1; + transform: var(--enter-transform); + } +} + +@keyframes vds-menu-exit { + from { + opacity: 1; + transform: var(--enter-transform); + } + to { + opacity: 0; + transform: var(--exit-transform); + } +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Menu Portal + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(media-menu-portal) { + display: contents; +} + +:where(.vds-menu-items:not([data-submenu]):not([data-placement])) { + position: fixed; + left: 16px; + right: 16px; + top: unset; + bottom: 0; + max-height: var(--media-sm-menu-portrait-max-height, 40vh); + max-height: var(--media-sm-menu-portrait-max-height, 40dvh); +} + +:where(.vds-menu-items:not([data-submenu]):not([data-placement])) { + max-width: 480px; + margin: 0 auto; +} + +@media (orientation: landscape) and (pointer: coarse) { + :where(.vds-menu-items:not([data-submenu]):not([data-placement])) { + max-height: var(--media-sm-menu-landscape-max-height, min(70vh, 400px)); + max-height: var(--media-sm-menu-landscape-max-height, min(70dvh, 400px)); + } +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Submenu + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-menu[data-submenu] .vds-menu-button) { + display: flex; + align-items: center; + justify-content: flex-start; +} + +:where(.vds-menu-items[data-submenu]) { + width: 100%; +} + +:where(.vds-menu[aria-hidden='true']), +:where(.vds-menu-items[data-submenu][aria-hidden='true']) { + display: none; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Menu Item + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-menu-items) :where([role='menuitem'], [role='menuitemradio']) { + -webkit-tap-highlight-color: transparent; + user-select: none; + display: flex; + align-items: center; + justify-content: left; + width: 100%; + appearance: none; + border-radius: var(--media-menu-item-border-radius, 2px); + box-sizing: border-box; +} + +.vds-menu-items [role='menuitem'], +.vds-menu-items [role='menuitemradio'] { + border: var(--media-menu-item-border, 0); + color: var(--media-menu-item-color, #f5f5f5); + background-color: var(--media-menu-item-bg, transparent); + padding: var(--media-menu-item-padding, 8px); + font-size: var(--media-menu-font-size, 1rem); +} + +.vds-menu-items [role='menuitem']:focus-visible, +.vds-menu-items [role='menuitemradio']:focus-visible, +.vds-menu-items [role='menuitem'][data-focus], +.vds-menu-items [role='menuitemradio'][data-focus] { + cursor: pointer; + background-color: var(--media-menu-item-hover-bg, rgb(245 245 245 / 0.08)); +} + +:where(.vds-menu-items:not([data-submenu]):not([data-placement])) +:where([role='menuitem'], [role='menuitemradio']) { + padding: var(--media-sm-menu-item-padding, 12px); +} + +@media (hover: hover) and (pointer: fine) { + .vds-menu-items [role='menuitem']:hover, + .vds-menu-items [role='menuitemradio']:hover { + cursor: pointer; + background-color: var(--media-menu-item-hover-bg, rgb(245 245 245 / 0.12)); + border-radius: 8px; + } +} + +:where(.vds-menu-items[data-submenu]) { + align-items: flex-start; + justify-content: center; + flex-direction: column; +} + +:where(.vds-menu-button[role='menuitem'][aria-expanded='true']) { + font-weight: bold; + border-radius: 8px; + border-top-left-radius: var(--media-menu-item-border-radius, 2px); + border-top-right-radius: var(--media-menu-item-border-radius, 2px); + border-bottom: var(--media-menu-divider, 1px solid rgb(245 245 245 /0.15)); +} + +:where(.vds-menu-button[role='menuitem'][aria-expanded='true']) { + position: sticky; + top: calc(-1 * var(--media-menu-padding, 10px)); + left: 0; + width: 100%; + z-index: 10; + backdrop-filter: blur(4px); + margin-bottom: 4px; +} + +.vds-menu-button[role='menuitem'][aria-expanded='true'] { + /*background-color: var(--media-menu-top-bar-bg, rgb(10 10 10 / 0.6));*/ + background: transparent !important; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Menu Item Parts + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-menu-items [role='menuitem'] .vds-menu-button-icon) { + width: var(--media-menu-item-icon-size, 22px); + height: var(--media-menu-item-icon-size, 22px); + margin-right: var(--media-menu-item-icon-spacing, 6px); + opacity: 0.5; + display: none !important; +} + +:where(.vds-menu-items [role='menuitem'] .vds-menu-button-close-icon) { + margin-right: var(--media-menu-item-icon-spacing, 6px); +} + +:where(.vds-menu-items [role='menuitem']) +:where(.vds-menu-button-open-icon, .vds-menu-button-close-icon) { + width: 18px; + height: 18px; +} + +:where(.vds-menu-items [role='menuitem']) +:where(.vds-menu-button-hint, .vds-menu-button-open-icon) { + margin-left: auto; +} + +:where(.vds-menu-items [role='menuitem']) +:where(.vds-menu-button-hint + .vds-menu-button-open-icon), +:where(.vds-menu-button-hint + media-icon .vds-menu-button-open-icon), +:where(.vds-menu-button-hint + slot > .vds-menu-button-open-icon) { + margin-left: 2px; +} + +:where(.vds-menu-items [role='menuitem'][aria-hidden='true']), +:where(.vds-menu-items [role='menuitem'][aria-expanded='true'] .vds-menu-button-open-icon) { + display: none !important; +} + +:where(.vds-menu-items) +:where([role='menuitem'][aria-disabled='true'] [role='menuitem'][data-disabled]) +:where(.vds-menu-button-open-icon) { + opacity: 0; +} + +:where(.vds-menu-button .vds-menu-button-close-icon), +:where(.vds-menu-items [role='menuitem'][aria-expanded='true'] .vds-menu-button-icon) { + display: none !important; +} + +:where(.vds-menu-items [role='menuitem'][aria-expanded='true'] .vds-menu-button-close-icon) { + display: inline !important; + margin-left: calc(-1 * var(--media-menu-padding, 10px) / 2); +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Radio + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-radio-group) { + box-sizing: border-box; + width: 100%; + display: flex; + flex-direction: column; +} + +:where(.vds-radio) { + position: relative; + align-items: center; + border-radius: 2px; + box-sizing: border-box; + cursor: pointer; + display: flex; + font-family: var(--media-font-family, sans-serif); + font-size: 15px; + font-weight: 500; + contain: content; + padding: var(--media-menu-item-padding, 12px); +} + +:where(.vds-radio .vds-radio-check) { + align-items: center; + border-radius: 9999px; + box-sizing: border-box; + display: flex; + height: var(--media-menu-radio-check-size, 9px); + justify-content: center; + margin-right: 8px; + width: var(--media-menu-radio-check-size, 9px); + border-width: unset !important; /* prevent tailwind breaking */ +} + +.vds-radio .vds-radio-check { + border: var(--media-menu-radio-check-border, 2px solid rgb(245 245 245 / 0.5)); +} + +.vds-radio[aria-checked='true'] .vds-radio-check { + border: 2px solid var(--media-menu-radio-check-active-color, var(--media-brand)); +} + +:where(.vds-radio[aria-checked='true'] .vds-radio-check)::after { + content: ''; + background-color: var(--media-menu-radio-check-active-color, #f5f5f5); + border-radius: 9999px; + box-sizing: border-box; + height: var(--media-menu-radio-check-inner-size, 4px); + width: var(--media-menu-radio-check-inner-size, 4px); + border-width: unset !important; /* prevent tailwind breaking */ +} + +.vds-radio[aria-checked='true'] .vds-radio-check::after { + border-color: var(--media-menu-radio-check-active-color, #f5f5f5); +} + +:where(.vds-radio .vds-radio-hint) { + color: var(--media-menu-item-info-color, rgb(168, 169, 171)); + font-size: var(--media-menu-item-info-font-size, 13px); + margin-left: auto; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Chapters Menu + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-chapters-menu-items) { + padding: var(--media-chapters-padding, 0); + min-width: var(--media-chapters-min-width, var(--media-menu-min-width, 220px)); +} + +:where(.vds-menu-items:has(.vds-chapters-radio-group[data-thumbnails])) { + min-width: var(--media-chapters-with-thumbnails-min-width, 300px); +} + +:where(.vds-chapter-radio) { + border-radius: 0; +} + +.vds-chapters-radio { + border-bottom: var(--media-chapters-divider, 1px solid rgb(245 245 245 / 0.1)); +} + +:where(.vds-chapter-radio:last-child) { + border-bottom: 0; +} + +:where(.vds-chapter-radio[aria-checked='true']) { + background-color: var(--media-chapters-item-active-bg, rgb(255 255 255 / 0.05)); + border-left: var(--media-chapters-item-active-border-left); +} + +:where(.vds-chapter-radio[aria-checked='true']):after { + content: ' '; + width: var(--progress); + height: var(--media-chapters-progress-height, 3px); + position: absolute; + bottom: 0; + left: 0; + border-radius: var(--media-chapters-progress-border-radius, 0); + background-color: var(--media-chapters-progress-bg, var(--media-brand)); +} + +.vds-chapters-radio-group :where(.vds-thumbnail) { + margin-right: var(--media-chapters-thumbnail-gap, 12px); + flex-shrink: 0; + min-width: var(--media-chapters-thumbnail-min-width, 100px); + min-height: var(--media-chapters-thumbnail-min-height, 56px); + max-width: var(--media-chapters-thumbnail-max-width, 120px); + max-height: var(--media-chapters-thumbnail-max-height, 68px); +} + +.vds-chapters-radio-group .vds-thumbnail { + border: var(--media-chapters-thumbnail-border, 0); +} + +:where(.vds-chapters-radio-group .vds-chapter-radio-label) { + color: var(--media-chapters-label-color, rgb(245 245 245 / 0.64)); + font-size: var(--media-chapters-label-font-size, 15px); + font-weight: var(--media-chapters-label-font-weight, 500); + white-space: var(--media-chapters-label-white-space, nowrap); +} + +:where(.vds-chapter-radio[aria-checked='true'] .vds-chapter-radio-label) { + color: var(--media-chapters-label-active-color, #f5f5f5); + font-weight: var(--media-chapters-label-active-font-weight, 500); +} + +:where(.vds-chapters-radio-group .vds-chapter-radio-start-time) { + display: inline-block; + padding: var(--media-chapters-start-time-padding, 1px 4px); + letter-spacing: var(--media-chapters-start-time-letter-spacing, 0.4px); + border-radius: var(--media-chapters-start-time-border-radius, 2px); + color: var(--media-chapters-start-time-color, rgb(168, 169, 171)); + font-size: var(--media-chapters-start-time-font-size, 12px); + font-weight: var(--media-chapters-start-time-font-weight, 500); + background-color: var(--media-chapters-start-time-bg, rgb(156 163 175 / 0.2)); + margin-top: var(--media-chapters-start-time-gap, 6px); +} + +:where(.vds-chapters-radio-group .vds-chapter-radio-duration) { + color: var(--media-chapters-duration-color, rgb(245 245 245 / 0.5)); + background-color: var(--media-chapters-duration-bg); + font-size: var(--media-chapters-duration-font-size, 12px); + font-weight: var(--media-chapters-duration-font-weight, 500); + border-radius: var(--media-chapters-duration-border-radius, 2px); + margin-top: var(--media-chapters-duration-gap, 6px); +} + +:where(.vds-menu-button[aria-disabled='true'], .vds-menu-button[data-disabled]) { + display: none; +} + +.vds-chapters-radio-group:not([data-thumbnails]) :where(.vds-thumbnail, media-thumbnail) { + display: none; +} + +:where(.vds-chapter-radio-content) { + display: flex; + align-items: flex-start; + flex-direction: column; +} + +:where(.vds-chapters-radio-group:not([data-thumbnails]) .vds-chapter-radio-content) { + width: 100%; + flex-direction: row; + display: flex; + flex-wrap: wrap; + align-items: center; +} + +:where(.vds-chapters-radio-group:not([data-thumbnails]) .vds-chapter-radio-start-time) { + margin-top: 0; + margin-left: auto; +} + +:where(.vds-chapters-radio-group:not([data-thumbnails]) .vds-chapter-radio-duration) { + margin-top: 4px; + flex-basis: 100%; +} + +:where(.vds-menu-items[data-keyboard]) .vds-chapters-radio-group:focus-within { + padding: var(--media-chapters-focus-padding, 4px); +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Poster + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-poster) { + display: block; + contain: content; + position: absolute; + top: 0; + left: 0; + opacity: 0; + width: 100%; + height: 100%; + z-index: 1; + border: 0; + pointer-events: none; + box-sizing: border-box; + transition: opacity 0.2s ease-out; + background-color: var(--media-poster-bg, black); +} + +:where(.vds-poster img) { + object-fit: inherit; + object-position: inherit; + pointer-events: none; + user-select: none; + -webkit-user-select: none; + box-sizing: border-box; +} + +.vds-poster :where(img) { + border: 0; + width: 100%; + height: 100%; + object-fit: contain; +} + +:where(.vds-poster[data-hidden]) { + display: none; +} + +:where(.vds-poster[data-visible]) { + opacity: 1; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Sliders + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-slider) { + --width: var(--media-slider-width, 100%); + --height: var(--media-slider-height, 48px); + + --thumb-size: var(--media-slider-thumb-size, 0px); + --thumb-focus-size: var(--media-slider-focused-thumb-size, 15px); + + --track-width: var(--media-slider-track-width, 100%); + --track-height: var(--media-slider-track-height, 5px); + --track-focus-width: var(--media-slider-focused-track-width, var(--track-width)); + --track-focus-height: var(--media-slider-focused-track-height, calc(var(--track-height) * 1.25)); + + display: inline-flex; + align-items: center; + width: var(--width); + height: var(--height); + /** Prevent thumb flowing out of slider. */ + margin: 0 calc(var(--thumb-size) / 2); + position: relative; + contain: layout style; + outline: none; + pointer-events: auto; + cursor: pointer; + user-select: none; + touch-action: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; +} + +:where(.vds-slider[aria-hidden='true']) { + display: none !important; +} + +:where(.vds-slider[aria-disabled='true']) { + cursor: unset; +} + +:where(.vds-slider:focus) { + outline: none; +} + +:where(.vds-slider:not([data-chapters])[data-focus], .vds-slider:not([data-chapters]):focus-visible) +:where(.vds-slider-track) { + box-shadow: var(--media-focus-ring); +} + +:where(.vds-slider .vds-slider-track) { + z-index: 0; + position: absolute; + width: var(--track-width); + height: var(--track-height); + top: 50%; + left: 0; + border-radius: var(--media-slider-track-border-radius, 9999px); + transform: translateY(-50%) translateZ(0); + background-color: var(--media-slider-track-bg, rgb(255 255 255 / 0.2)); + contain: strict; +} + +:where(.vds-slider[data-focus], .vds-slider:focus-visible) :where(.vds-slider-track) { + outline-offset: var(--thumb-size); +} + +:where(.vds-slider:not([data-chapters])[data-active] .vds-slider-track) { + width: var(--track-focus-width); + height: var(--track-focus-height); +} + +:where(.vds-slider .vds-slider-track-fill) { + z-index: 2; /** above track and track progress. */ + background-color: var(--media-slider-track-fill-bg, var(--media-brand)); + width: var(--slider-fill, 0%); + will-change: width; +} + +:where(.vds-slider .vds-slider-thumb) { + position: absolute; + top: 50%; + left: var(--slider-fill); + opacity: 0; + contain: layout size style; + width: var(--thumb-size); + height: var(--thumb-size); + border: var(--media-slider-thumb-border, 1px solid #cacaca); + border-radius: var(--media-slider-thumb-border-radius, 9999px); + background-color: var(--media-slider-thumb-bg, #fff); + transform: translate(-50%, -50%) translateZ(0); + transition: opacity 0.15s ease-in; + pointer-events: none; + will-change: left; + z-index: 2; /** above track fill. */ +} + +:where(.vds-slider[data-dragging], .vds-slider[data-focus], .vds-slider:focus-visible) +:where(.vds-slider-thumb) { + box-shadow: var(--media-slider-focused-thumb-shadow, 0 0 0 4px hsla(0, 0%, 100%, 0.4)); +} + +:where(.vds-slider[data-active] .vds-slider-thumb) { + opacity: 1; + transition: var(--media-slider-thumb-transition, opacity 0.2s ease-in, box-shadow 0.2s ease); +} + +:where(.vds-slider[data-dragging] .vds-slider-thumb) { + width: var(--thumb-focus-size); + height: var(--thumb-focus-size); +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Slider Value + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-slider-value) { + display: inline-block; + contain: content; + font-size: 14px; + font-family: var(--media-font-family, sans-serif); +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Slider Thumbnail + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-slider-thumbnail) { + display: block; + contain: content; + box-sizing: border-box; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Slider Video + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-slider-video) { + background-color: black; + box-sizing: border-box; + contain: content; + display: inline-block; + border: var(--media-thumbnail-border, 1px solid white); +} + +:where(.vds-slider-video video) { + display: block; + height: auto; + width: 156px; +} + +/* Temporarily hide video while loading. */ +:where(.vds-slider-video[data-loading]) { + opacity: 0; +} + +/* Hide video if it fails to load. */ +:where(.vds-slider-video[data-hidden], .vds-slider-video[data-hidden] video) { + display: none; + width: 0px; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Previews + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-slider .vds-slider-preview) { + display: flex; + flex-direction: column; + align-items: center; + opacity: 0; + background-color: var(--media-slider-preview-bg); + border-radius: var(--media-slider-preview-border-radius, 2px); + pointer-events: none; + transition: opacity 0.2s ease-out; + will-change: left, opacity; + contain: layout paint style; +} + +:where(.vds-slider-preview[data-visible]) { + opacity: 1; + transition: opacity 0.2s ease-in; +} + +:where(.vds-slider-value) { + padding: var(--media-slider-value-padding, 1px 10px); + color: var(--media-slider-value-color, white); + background-color: var(--media-slider-value-bg, black); + border-radius: var(--media-slider-value-border-radius, 2px); +} + +.vds-slider-value { + border: var(--media-slider-value-border); +} + +:where( + .vds-slider-video:not([data-hidden]) + .vds-slider-chapter-title, + .vds-slider-thumbnail:not([data-hidden]) + .vds-slider-chapter-title + ) { + margin-top: var(--media-slider-chapter-title-gap, 6px); +} + +:where( + .vds-slider-video:not([data-hidden]) + .vds-slider-value, + .vds-slider-thumbnail:not([data-hidden]) + .vds-slider-value, + .vds-slider-chapter-title + .vds-slider-value + ) { + margin-top: var(--media-slider-value-gap, 2px); +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Vertical Sliders + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-slider[aria-orientation='vertical']) { + --width: var(--media-slider-width, 48px); + --height: var(--media-slider-height, 100%); + + --track-width: var(--media-slider-track-width, 4px); + --track-height: var(--media-slider-track-height, 100%); + --track-focus-width: var(--media-slider-focused-track-width, calc(var(--track-width) * 1.25)); + --track-focus-height: var(--media-slider-focused-track-height, var(--track-height)); + + /** Prevent thumb flowing out of slider. */ + margin: calc(var(--thumb-size) / 2) 0; +} + +:where(.vds-slider[aria-orientation='vertical'] .vds-slider-track) { + top: unset; + bottom: 0; + left: 50%; + transform: translateX(-50%) translateZ(0); +} + +:where(.vds-slider[aria-orientation='vertical'] .vds-slider-track-fill) { + width: var(--track-width); + height: var(--slider-fill); + will-change: height; + transform: translateX(-50%) translateZ(0); +} + +:where(.vds-slider[aria-orientation='vertical'] .vds-slider-progress) { + top: unset; + bottom: 0; + width: var(--track-width); + height: var(--slider-progress, 0%); + will-change: height; +} + +:where(.vds-slider[aria-orientation='vertical'] .vds-slider-thumb) { + top: unset; + bottom: var(--slider-fill); + left: 50%; + will-change: bottom; + transform: translate(-50%, 50%) translateZ(0); +} + +:where(.vds-slider[aria-orientation='vertical'] .vds-slider-preview) { + will-change: bottom, opacity; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Time Slider + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where([data-live] .vds-time-slider .vds-slider-track-fill) { + background-color: var(--media-slider-track-fill-live-bg, #dc2626); +} + +:where(.vds-time-slider .vds-slider-progress) { + z-index: 1; /** above track. */ + left: 0; + width: var(--slider-progress, 0%); + will-change: width; + background-color: var(--media-slider-track-progress-bg, rgb(255 255 255 / 0.2)); +} + +:where([data-media-player]:not([data-can-play]) .vds-time-slider .vds-slider-value) { + display: none; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Slider Chapters + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-time-slider .vds-slider-chapters) { + position: relative; + display: flex; + align-items: center; + width: 100%; + height: 100%; + contain: layout style; + border-radius: var(--media-slider-track-border-radius, 1px); +} + +:where(.vds-slider[data-focus], .vds-slider:focus-visible) :where(.vds-slider-chapters) { + box-shadow: var(--media-focus-ring); + height: var(--track-height); +} + +:where(.vds-time-slider .vds-slider-chapter) { + margin-right: 2px; +} + +:where(.vds-time-slider .vds-slider-chapter:last-child) { + margin-right: 0; +} + +:where(.vds-time-slider .vds-slider-chapter) { + position: relative; + display: flex; + align-items: center; + width: 100%; + height: 100%; + will-change: height, transform; + contain: layout style; + border-radius: var(--media-slider-track-border-radius, 1px); +} + +:where(.vds-time-slider .vds-slider-chapter .vds-slider-track-fill) { + width: var(--chapter-fill, 0%); + will-change: width; +} + +:where(.vds-time-slider .vds-slider-chapter .vds-slider-progress) { + width: var(--chapter-progress, 0%); + will-change: width; +} + +@media (hover: hover) and (pointer: fine) { + :where(.vds-time-slider:hover .vds-slider-chapters) { + contain: strict; + } + + :where(.vds-time-slider .vds-slider-chapter:hover:not(:only-of-type)) { + transform: var(--media-slider-chapter-hover-transform, scaleY(2)); + transition: var( + --media-slider-chapter-hover-transition, + transform 0.1s cubic-bezier(0.4, 0, 1, 1) + ); + } +} + +:where(.vds-time-slider .vds-slider-chapter-title) { + font-family: var(--media-font-family, sans-serif); + font-size: var(--media-slider-chapter-title-font-size, 14px); + color: var(--media-slider-chapter-title-color, #f5f5f5); + background-color: var(--media-slider-chapter-title-bg); +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Thumbnail + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-thumbnail) { + --aspect-ratio: calc(var(--media-thumbnail-aspect-ratio, 16 / 9)); + --min-width: var(--media-thumbnail-min-width, 140px); + --max-width: var(--media-thumbnail-max-width, 180px); + display: block; + width: var(--thumbnail-width); + height: var(--thumbnail-height); + background-color: var(--media-thumbnail-bg, black); + contain: strict; + overflow: hidden; + box-sizing: border-box; + min-width: var(--min-width); + min-height: var(--media-thumbnail-min-height, calc(var(--min-width) / var(--aspect-ratio))); + max-width: var(--max-width); + max-height: var(--media-thumbnail-max-height, calc(var(--max-width) / var(--aspect-ratio))); +} + +.vds-thumbnail { + border: var(--media-thumbnail-border, 1px solid white); +} + +:where(.vds-thumbnail img) { + min-width: unset !important; + max-width: unset !important; + will-change: width, height, transform; +} + +:where(.vds-thumbnail[data-loading] img) { + opacity: 0; +} + +:where(.vds-thumbnail[aria-hidden='true']) { + display: none !important; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Time + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-time-group) { + display: flex; + align-items: center; +} + +:where(.vds-time-divider) { + margin: 0 var(--media-time-divider-gap, 2.5px); + color: var(--media-time-divider-color, #e0e0e0); +} + +:where(.vds-time) { + display: inline-block; + contain: content; + font-size: var(--media-time-font-size, 15px); + font-weight: var(--media-time-font-weight, 400); + font-family: var(--media-font-family, sans-serif); + color: var(--media-time-color, #f5f5f5); + background-color: var(--media-time-bg); + padding: var(--media-time-padding, 2px); + border-radius: var(--media-time-border-radius, 2px); + letter-spacing: var(--media-time-letter-spacing, 0.025em); +} + +.vds-time { + outline: 0; + border: var(--media-time-border); +} + +:where(.vds-time:focus-visible) { + box-shadow: var(--media-focus-ring); +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Tooltips + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-tooltip, media-tooltip) { + display: contents; +} + +:where(.vds-tooltip-content) { + display: inline-block; + box-sizing: border-box; + color: var(--media-tooltip-color, hsl(0, 0%, 80%)); + background-color: var(--media-tooltip-bg-color, black); + font-family: var(--media-font-family, sans-serif); + font-size: var(--media-tooltip-font-size, 13px); + font-weight: var(--media-tooltip-font-weight, 500); + opacity: 0; + pointer-events: none; + white-space: nowrap; + z-index: 10; + will-change: transform, opacity; + border-radius: var(--media-tooltip-border-radius, 8px); + padding: var(--media-tooltip-padding, 2px 8px); +} + +.vds-tooltip-content { + border: var(--media-tooltip-border, 1px solid rgb(255 255 255 / 0.1)); +} + +:where(.vds-menu .vds-menu-button[role='button'][data-pressed] .vds-tooltip-content) { + opacity: 0; + display: none; +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Tooltip Animation + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-tooltip-content) { + --enter-transform: translateY(0px) scale(1); + --exit-transform: translateY(12px) scale(0.8); +} + +:where(.vds-tooltip-content:not([data-visible])) { + animation: var(--media-tooltip-exit-animation, vds-tooltip-exit 0.2s ease-out); +} + +:where(.vds-tooltip-content[data-visible]) { + animation: var(--media-tooltip-enter-animation, vds-tooltip-enter 0.2s ease-in); + animation-fill-mode: forwards; +} + +/* Bottom */ +:where(.vds-tooltip-content[data-placement~='bottom']) { + --enter-transform: translateY(0) scale(1); + --exit-transform: translateY(-12px) scale(0.8); +} + +/* Left */ +:where(.vds-tooltip-content[data-placement~='left']) { + --enter-transform: translateX(0) scale(1); + --exit-transform: translateX(12px) scale(0.8); +} + +/* Right */ +:where(.vds-tooltip-content[data-placement~='right']) { + --enter-transform: translateX(0) scale(1); + --exit-transform: translateX(-12px) scale(0.8); +} + +@keyframes vds-tooltip-enter { + from { + opacity: 0; + transform: var(--exit-transform); + } + to { + opacity: 1; + transform: var(--enter-transform); + } +} + +@keyframes vds-tooltip-exit { + from { + opacity: 1; + transform: var(--enter-transform); + } + to { + opacity: 0; + transform: var(--exit-transform); + } +} + +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * States + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +/* Play Button */ +[data-media-player]:not([data-paused]) .vds-play-tooltip-text, +[data-media-player][data-paused] .vds-pause-tooltip-text, + /* PIP Button */ +[data-media-player][data-pip] .vds-pip-enter-tooltip-text, +[data-media-player]:not([data-pip]) .vds-pip-exit-tooltip-text, + /* Fullscreen Button */ +[data-media-player][data-fullscreen] .vds-fs-enter-tooltip-text, +[data-media-player]:not([data-fullscreen]) .vds-fs-exit-tooltip-text, + /* Caption Button */ +[data-media-player]:not([data-captions]) .vds-cc-on-tooltip-text, +[data-media-player][data-captions] .vds-cc-off-tooltip-text, + /* Mute Button */ +[data-media-player]:not([data-muted]) .vds-mute-tooltip-text, +[data-media-player][data-muted] .vds-unmute-tooltip-text { + display: none; +} diff --git a/seanime-2.9.10/seanime-web/src/app/websocket-provider.tsx b/seanime-2.9.10/seanime-web/src/app/websocket-provider.tsx new file mode 100644 index 0000000..9d38b75 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/app/websocket-provider.tsx @@ -0,0 +1,278 @@ +import { getServerBaseUrl } from "@/api/client/server-url" +import { websocketAtom, WebSocketContext } from "@/app/(main)/_atoms/websocket.atoms" +import { ElectronRestartServerPrompt } from "@/app/(main)/_electron/electron-restart-server-prompt" +import { TauriRestartServerPrompt } from "@/app/(main)/_tauri/tauri-restart-server-prompt" +import { __openDrawersAtom } from "@/components/ui/drawer" +import { logger } from "@/lib/helpers/debug" +import { __isElectronDesktop__, __isTauriDesktop__ } from "@/types/constants" +import { atom, useAtomValue } from "jotai" +import { useAtom, useSetAtom } from "jotai/react" +import React from "react" +import { useCookies } from "react-cookie" +import { LuLoader } from "react-icons/lu" +import { RemoveScrollBar } from "react-remove-scroll-bar" +import { useEffectOnce } from "react-use" + + +function uuidv4(): string { + // @ts-ignore + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => + (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16), + ) +} + +export const websocketConnectedAtom = atom(false) +export const websocketConnectionErrorCountAtom = atom(0) + +export const clientIdAtom = atom<string | null>(null) + +export function WebsocketProvider({ children }: { children: React.ReactNode }) { + const [socket, setSocket] = useAtom(websocketAtom) + const [isConnected, setIsConnected] = useAtom(websocketConnectedAtom) + const setConnectionErrorCount = useSetAtom(websocketConnectionErrorCountAtom) + const openDrawers = useAtomValue(__openDrawersAtom) + const [cookies, setCookie, removeCookie] = useCookies(["Seanime-Client-Id"]) + + const [, setClientId] = useAtom(clientIdAtom) + + // Refs to manage connection state + const heartbeatRef = React.useRef<NodeJS.Timeout | null>(null) + const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(null) + const reconnectTimeoutRef = React.useRef<NodeJS.Timeout | null>(null) + const lastPongRef = React.useRef<number>(Date.now()) + const socketRef = React.useRef<WebSocket | null>(null) + const wasDisconnected = React.useRef<boolean>(false) + const initialConnection = React.useRef<boolean>(true) + + React.useEffect(() => { + logger("WebsocketProvider").info("Seanime-Client-Id", cookies["Seanime-Client-Id"]) + if (cookies["Seanime-Client-Id"]) { + setClientId(cookies["Seanime-Client-Id"]) + } + }, [cookies]) + + // Effect to handle page reload on reconnection + /* React.useEffect(() => { + // If we're connected now and were previously disconnected (not the first connection) + if (isConnected && wasDisconnected.current && !initialConnection.current) { + logger("WebsocketProvider").info("Connection re-established, reloading page") + // Add a small delay to allow for other components to process the connection status + setTimeout(() => { + window.location.reload() + }, 100) + } + + // Update the wasDisconnected ref when connection status changes + if (!isConnected && !initialConnection.current) { + wasDisconnected.current = true + } + + // After first connection, set initialConnection to false + if (isConnected && initialConnection.current) { + initialConnection.current = false + } + }, [isConnected]) */ + + useEffectOnce(() => { + function clearAllIntervals() { + if (heartbeatRef.current) { + clearInterval(heartbeatRef.current) + heartbeatRef.current = null + } + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + } + + function connectWebSocket() { + // Clear existing connection attempts + clearAllIntervals() + + // Close any existing socket + if (socketRef.current && socketRef.current.readyState !== WebSocket.CLOSED) { + try { + socketRef.current.close() + } + catch (e) { + // Ignore errors on closing + } + } + + const wsUrl = `${document.location.protocol == "https:" ? "wss" : "ws"}://${getServerBaseUrl(true)}/events` + const clientId = cookies["Seanime-Client-Id"] || uuidv4() + + try { + socketRef.current = new WebSocket(`${wsUrl}?id=${clientId}`) + + // Reset the last pong timestamp whenever we connect + lastPongRef.current = Date.now() + + socketRef.current.addEventListener("open", () => { + logger("WebsocketProvider").info("WebSocket connection opened") + setIsConnected(true) + setConnectionErrorCount(0) + + // Set cookie if it doesn't exist + if (!cookies["Seanime-Client-Id"]) { + setCookie("Seanime-Client-Id", clientId, { + path: "/", + sameSite: "lax", + secure: false, + maxAge: 24 * 60 * 60, // 24 hours + }) + } + + // Start heartbeat interval to detect silent disconnections + heartbeatRef.current = setInterval(() => { + const timeSinceLastPong = Date.now() - lastPongRef.current + + // If no pong received for 45 seconds (3 missed pings), consider connection dead + if (timeSinceLastPong > 45000) { + logger("WebsocketProvider").error(`No pong response for ${Math.round(timeSinceLastPong / 1000)}s, reconnecting`) + reconnectSocket() + return + } + + if (socketRef.current?.readyState !== WebSocket.OPEN) { + logger("WebsocketProvider").error("Heartbeat check failed, reconnecting") + reconnectSocket() + } + }, 15000) // check every 15 seconds + + // Implement a ping mechanism to keep the connection alive + // Start the ping interval slightly offset from the heartbeat to avoid race conditions + setTimeout(() => { + pingIntervalRef.current = setInterval(() => { + if (socketRef.current?.readyState === WebSocket.OPEN) { + try { + const timestamp = Date.now() + // Send a ping message to keep the connection alive + socketRef.current?.send(JSON.stringify({ + type: "ping", + payload: { timestamp }, + clientId: clientId, + })) + } + catch (e) { + logger("WebsocketProvider").error("Failed to send ping", e) + reconnectSocket() + } + } else { + logger("WebsocketProvider").error("Failed to send ping, WebSocket not open", socketRef.current?.readyState) + // reconnectSocket() + } + }, 15000) // ping every 15 seconds + }, 5000) // Start ping interval 5 seconds after heartbeat to offset them + }) + + // Add message handler for pong responses + socketRef.current?.addEventListener("message", (event) => { + try { + const data = JSON.parse(event.data) as { type: string; payload?: any } + if (data.type === "pong") { + // Update the last pong timestamp + lastPongRef.current = Date.now() + // For debugging purposes + // logger("WebsocketProvider").info("Pong received, timestamp updated", lastPongRef.current) + } + } + catch (e) { + } + }) + + socketRef.current?.addEventListener("close", (event) => { + logger("WebsocketProvider").info(`WebSocket connection closed: ${event.code} ${event.reason}`) + handleDisconnection() + }) + + socketRef.current?.addEventListener("error", (event) => { + logger("WebsocketProvider").error("WebSocket encountered an error:", event) + reconnectSocket() + }) + + setSocket(socketRef.current) + } + catch (e) { + logger("WebsocketProvider").error("Failed to create WebSocket connection:", e) + handleDisconnection() + } + } + + function handleDisconnection() { + clearAllIntervals() + setIsConnected(false) + scheduleReconnect() + } + + function reconnectSocket() { + if (socketRef.current) { + try { + socketRef.current.close() + } + catch (e) { + // Ignore errors on closing + } + } + handleDisconnection() + } + + function scheduleReconnect() { + // Reconnect after a delay with exponential backoff + setConnectionErrorCount(count => { + const newCount = count + 1 + // Calculate backoff time (1s, 2s, max 3s) + const backoffTime = Math.min(Math.pow(2, Math.min(newCount - 1, 10)) * 1000, 3000) + + logger("WebsocketProvider").info(`Reconnecting in ${backoffTime}ms (attempt ${newCount})`) + + reconnectTimeoutRef.current = setTimeout(() => { + connectWebSocket() + }, backoffTime) + + return newCount + }) + } + + if (!socket || socket.readyState === WebSocket.CLOSED) { + // If the socket is not set or the connection is closed, initiate a new connection + connectWebSocket() + } + + return () => { + if (socketRef.current) { + try { + socketRef.current.close() + } + catch (e) { + // Ignore errors on closing + } + } + setIsConnected(false) + // Cleanup all intervals on unmount + clearAllIntervals() + } + }) + + return ( + <> + {openDrawers.length > 0 && <RemoveScrollBar />} + {__isTauriDesktop__ && <TauriRestartServerPrompt />} + {__isElectronDesktop__ && <ElectronRestartServerPrompt />} + <WebSocketContext.Provider value={socket}> + {!isConnected && <div + className="fixed right-4 bottom-4 bg-gray-900 border text-[--muted] text-sm py-3 px-5 font-semibold rounded-[--radius-md] z-[100] flex gap-2 items-center" + > + <LuLoader className="text-brand-200 animate-spin text-lg" /> + Establishing connection... + </div>} + {children} + </WebSocketContext.Provider> + </> + ) +} + diff --git a/seanime-2.9.10/seanime-web/src/components/shared/_file-selector.tsx b/seanime-2.9.10/seanime-web/src/components/shared/_file-selector.tsx new file mode 100644 index 0000000..4ee52a4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/_file-selector.tsx @@ -0,0 +1,278 @@ +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import { ScrollArea } from "@/components/ui/scroll-area" +import { TextInput } from "@/components/ui/text-input" +import { useDebounce } from "@/hooks/use-debounce" +import React from "react" +import { BiFolderOpen } from "react-icons/bi" +import { FaFolder } from "react-icons/fa" +import { FiChevronDown, FiChevronRight, FiFile, FiFolder } from "react-icons/fi" + +type FileSelectorProps = { + kind: "file" | "directory" | "both" + onSelectPath: (path: string) => void + selectedPath: string + fileExtensions?: string[] +} + +export function FileSelector(props: FileSelectorProps) { + + const { + kind, + onSelectPath, + selectedPath, + fileExtensions, + } = props + + const [path, setPath] = React.useState<string>("") + const debouncedPath = useDebounce(path, 500) + const [modalOpen, setModalOpen] = React.useState(false) + + const firstRender = React.useRef(true) + React.useLayoutEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + setPath(props.selectedPath) + }, [props.selectedPath]) + + React.useEffect(() => { + if (path) { + props.onSelectPath(path) + } + }, [path]) + + const handleManualPathSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault() + const node = findNodeByPath(exampleData, path) + if (node) { + setPath(node.path) + console.log("Manually selected:", node.path) + } else { + console.log("Path not found:", path) + setPath("") + } + } + + return ( + <> + <div className="relative"> + <TextInput + leftIcon={<FaFolder />} + value={path} + onValueChange={setPath} + // rightIcon={<div className="flex"> + // {isLoading ? null : (data?.exists ? + // <BiCheck className="text-green-500" /> : shouldExist ? + // <BiX className="text-red-500" /> : <BiFolderPlus />)} + // </div>} + // onBlur={checkDirectoryExists} + /> + <BiFolderOpen + className="text-2xl cursor-pointer absolute z-[1] top-0 right-0" + onClick={() => setModalOpen(true)} + /> + </div> + + <FileSelectorModal + isOpen={modalOpen} + onOpenChange={() => setModalOpen(!modalOpen)} + kind={kind} + onSelectPath={setPath} + selectedPath={selectedPath} + fileExtensions={fileExtensions} + /> + </> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +const exampleData: TreeNode = { + name: "project", + type: "directory", + path: "/project", + children: [ + { + name: "src", + type: "directory", + path: "/project/src", + children: [ + { name: "index.js", type: "file", path: "/project/src/index.js" }, + { name: "styles.css", type: "file", path: "/project/src/styles.css" }, + { name: "data.json", type: "file", path: "/project/src/data.json" }, + ], + }, + { + name: "public", + type: "directory", + path: "/project/public", + children: [ + { name: "index.html", type: "file", path: "/project/public/index.html" }, + { name: "favicon.ico", type: "file", path: "/project/public/favicon.ico" }, + ], + }, + { name: "package.json", type: "file", path: "/project/package.json" }, + { name: "README.md", type: "file", path: "/project/README.md" }, + ], +} + +const findNodeByPath = (node: TreeNode, path: string): TreeNode | null => { + if (node.path === path) return node + if (node.children) { + for (const child of node.children) { + const found = findNodeByPath(child, path) + if (found) return found + } + } + return null +} + +function FileSelectorModal(props: FileSelectorProps & { isOpen: boolean, onOpenChange: () => void }) { + const { isOpen, onOpenChange, kind, selectedPath, onSelectPath, fileExtensions } = props + + return ( + <Modal + title="Select a file or directory" + open={isOpen} + onOpenChange={onOpenChange} + contentClass="max-w-3xl" + > + <div className="space-y-4"> + <TextInput + value={selectedPath} + onValueChange={onSelectPath} + /> + + <ScrollArea + className={cn( + "h-60 rounded-[--radius-md] border", + )} + > + <TreeNode + data={exampleData} + level={0} + kind={kind} + onSelect={node => onSelectPath(node.path)} + selectedPath={selectedPath} + fileExtensions={fileExtensions} + /> + </ScrollArea> + </div> + </Modal> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type TreeNode = { + name: string + type: "file" | "directory" + path: string + children?: TreeNode[] +} + +type TreeProps = { + data: TreeNode + kind: "file" | "directory" | "both" + onSelect: (node: TreeNode) => void + selectedPath: string | null + fileExtensions?: string[] +} + + +const TreeNode: React.FC<TreeProps & { level: number }> = ({ + data, + kind, + onSelect, + selectedPath, + level, + fileExtensions, +}) => { + const [isOpen, setIsOpen] = React.useState(level === 0) + + React.useEffect(() => { + if (selectedPath && selectedPath.startsWith(data.path)) { + setIsOpen(true) + } + }, [selectedPath, data.path]) + + const toggleOpen = (e: React.MouseEvent) => { + e.stopPropagation() + if (data.type === "directory") { + setIsOpen(!isOpen) + } + if (data.type === "file") { + onSelect(data) + } + } + + const handleSelect = () => { + if (kind === "both" || kind === data.type) { + onSelect(data) + } + } + + const isSelectable = kind === "both" || kind === data.type + const isSelected = selectedPath === data.path + const isVisible = data.type === "directory" || kind !== "directory" + const hasValidExtension = !fileExtensions || + data.type === "directory" || + fileExtensions.some(ext => data.name.endsWith(ext)) + + if (!isVisible || !hasValidExtension) { + return null + } + + return ( + <div> + <div + className={cn( + "flex items-center py-1 px-1", + isSelectable && "cursor-pointer", + isSelected && "bg-gray-800", + (isSelectable && !isSelected) && "hover:bg-gray-950", + )} + onClick={handleSelect} + > + <div className="flex items-center" onClick={toggleOpen}> + {data.type === "directory" && ( + <span className="mr-1"> + {isOpen ? ( + <FiChevronDown className="w-4 h-4" /> + ) : ( + <FiChevronRight className="w-4 h-4" /> + )} + </span> + )} + {data.type === "directory" ? ( + <FiFolder className="w-4 h-4 mr-2 text-[--brand]" /> + ) : ( + <FiFile className="w-4 h-4 mr-2 text-[--muted]" /> + )} + </div> + <span + className={cn( + isSelectable ? "cursor-pointer" : "cursor-default", + )} + >{data.name}</span> + </div> + {data.type === "directory" && isOpen && ( + <div className="ml-4 border-l pl-2"> + {data.children?.map((child, index) => ( + <TreeNode + key={index} + data={child} + kind={kind} + onSelect={onSelect} + selectedPath={selectedPath} + level={level + 1} + fileExtensions={fileExtensions} + /> + ))} + </div> + )} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/beta-badge.tsx b/seanime-2.9.10/seanime-web/src/components/shared/beta-badge.tsx new file mode 100644 index 0000000..f99f34f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/beta-badge.tsx @@ -0,0 +1,15 @@ +import { Badge, BadgeProps } from "@/components/ui/badge" + +type Props = BadgeProps + +export function BetaBadge(props: Props) { + return ( + <Badge intent="warning" size="sm" className="align-middle ml-1.5" {...props}>Experimental</Badge> + ) +} + +export function AlphaBadge(props: Props) { + return ( + <Badge intent="warning" size="sm" className="align-middle ml-1.5" {...props}>Alpha</Badge> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/classnames.ts b/seanime-2.9.10/seanime-web/src/components/shared/classnames.ts new file mode 100644 index 0000000..e998e43 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/classnames.ts @@ -0,0 +1,37 @@ +import { cn } from "@/components/ui/core/styling" + +export const tabsTriggerClass = cn( + "text-base px-6 rounded-[--radius-md] w-fit md:w-full border-none data-[state=active]:bg-[--subtle] data-[state=active]:text-white dark:hover:text-white") + +export const tabsListClass = cn("w-full flex flex-wrap md:flex-nowrap h-fit md:h-12") + +export const monochromeCheckboxClasses = { + className: "hidden", + labelClass: cn( + "items-start cursor-pointer transition border-transparent rounded-[--radius] py-1.5 px-3 w-full", + "hover:bg-[--subtle] dark:bg-gray-900", + "data-[checked=true]:bg-white dark:data-[checked=true]:bg-gray-950", + "focus:ring-2 ring-transparent dark:ring-transparent outline-none ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition", + "border border-transparent data-[checked=true]:border-[--gray] data-[checked=true]:ring-offset-0", + "w-fit", + ), +} +export const primaryPillCheckboxClasses = { + className: "hidden", + labelClass: cn( + "text-gray-300 data-[checked=true]:text-white hover:!bg-[--highlight]", + "items-start cursor-pointer transition border-transparent rounded-[--radius] py-1.5 px-3 w-full", + "hover:bg-[--subtle] dark:bg-gray-900", + "data-[checked=true]:bg-white dark:data-[checked=true]:bg-gray-950", + "focus:ring-2 ring-transparent dark:ring-transparent outline-none ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition", + "border border-transparent data-[checked=true]:border-[--brand] data-[checked=true]:ring-offset-0", + "w-fit", + ), +} + +export const episodeCardCarouselItemClass = (smaller: boolean) => { + return cn( + !smaller && "md:basis-1/2 lg:basis-1/2 2xl:basis-1/3 min-[2000px]:basis-1/4", + smaller && "md:basis-1/2 lg:basis-1/3 2xl:basis-1/4 min-[2000px]:basis-1/5", + ) +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/confirmation-dialog.tsx b/seanime-2.9.10/seanime-web/src/components/shared/confirmation-dialog.tsx new file mode 100644 index 0000000..d2c0c69 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/confirmation-dialog.tsx @@ -0,0 +1,62 @@ +"use client" +import { Button, ButtonProps } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { useDisclosure, UseDisclosureReturn } from "@/hooks/use-disclosure" +import React from "react" + +type ConfirmationDialogHookProps = { + title: string, + description?: string, + actionText?: string, + actionIntent?: ButtonProps["intent"] + onConfirm: () => void +} + +export function useConfirmationDialog(props: ConfirmationDialogHookProps) { + const api = useDisclosure(false) + return { + ...api, + ...props, + } +} + +export const ConfirmationDialog: React.FC<ConfirmationDialogHookProps & UseDisclosureReturn> = (props) => { + + const { + isOpen, + close, + onConfirm, + title, + description = "Are you sure you want to continue?", + actionText = "Confirm", + actionIntent = "alert-subtle", + } = props + + return ( + <> + <Modal + title={title} + titleClass="text-center" + open={isOpen} + onOpenChange={close} + > + <div className="space-y-4"> + <p className="text-center">{description}</p> + <div className="flex gap-2 justify-center items-center"> + <Button + intent={actionIntent} + onClick={() => { + onConfirm() + close() + }} + > + {actionText} + </Button> + <Button intent="white" onClick={close}>Cancel</Button> + </div> + </div> + </Modal> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/custom-css-provider.tsx b/seanime-2.9.10/seanime-web/src/components/shared/custom-css-provider.tsx new file mode 100644 index 0000000..7ba2edc --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/custom-css-provider.tsx @@ -0,0 +1,38 @@ +import { useAtom } from "jotai" +import { atomWithStorage } from "jotai/utils" +import React, { useEffect, useState } from "react" +import { useWindowSize } from "react-use" + +const customCSSAtom = atomWithStorage("sea-custom-css", { + customCSS: "", + mobileCustomCSS: "", +}, undefined, { getOnInit: true }) + +export function CustomCSSProvider({ children }: { children: React.ReactNode }) { + const [customCSS, setCustomCSS] = useAtom(customCSSAtom) + const [mounted, setMounted] = useState(false) + const { width } = useWindowSize() + + const isMobile = width < 1024 + + const usedCSS = React.useMemo(() => isMobile ? customCSS.mobileCustomCSS : customCSS.customCSS, [isMobile, customCSS]) + + useEffect(() => { + setMounted(true) + }, []) + + return ( + <> + {children} + {mounted && usedCSS && ( + <style id="sea-custom-css" dangerouslySetInnerHTML={{ __html: usedCSS }} /> + )} + </> + ) +} + +export function useCustomCSS() { + const [customCSS, setCustomCSS] = useAtom(customCSSAtom) + + return { customCSS, setCustomCSS } +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/custom-theme-provider.tsx b/seanime-2.9.10/seanime-web/src/components/shared/custom-theme-provider.tsx new file mode 100644 index 0000000..f6e7be6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/custom-theme-provider.tsx @@ -0,0 +1,90 @@ +import { THEME_DEFAULT_VALUES, useThemeSettings } from "@/lib/theme/hooks" +import { colord, extend, RgbColor } from "colord" +import mixPlugin from "colord/plugins/mix" +import React from "react" + +extend([mixPlugin]) + + +type CustomColorProviderProps = {} + + +export function CustomThemeProvider(props: CustomColorProviderProps) { + + const {} = props + + const ts = useThemeSettings() + + function setBgColor(r: any, variable: string, defaultColor: string | null, customColor: string | RgbColor) { + if (ts.backgroundColor === THEME_DEFAULT_VALUES.backgroundColor) { + if (defaultColor) r.style.setProperty(variable, defaultColor) + return + } + if (typeof customColor === "string") { + r.style.setProperty(variable, customColor) + } else { + r.style.setProperty(variable, `${customColor.r} ${customColor.g} ${customColor.b}`) + } + } + + function setColor(r: any, variable: string, defaultColor: string | null, customColor: string | RgbColor) { + if (ts.accentColor === THEME_DEFAULT_VALUES.accentColor) { + if (defaultColor) r.style.setProperty(variable, defaultColor) + return + } + if (typeof customColor === "string") { + r.style.setProperty(variable, customColor) + } else { + r.style.setProperty(variable, `${customColor.r} ${customColor.g} ${customColor.b}`) + } + } + + + // e.g. #0a050d -> dark purple + // e.g. #11040d -> dark pink-ish purple + // #050a0d -> dark blue + React.useEffect(() => { + let r = document.querySelector(":root") as any + + if (!ts.enableColorSettings) return + + setBgColor(r, "--background", "#070707", ts.backgroundColor) + setBgColor(r, "--paper", colord("rgba(11 11 11)").toHex(), colord(ts.backgroundColor).lighten(0.025).toHex()) + setBgColor(r, "--media-card-popup-background", colord("rgb(16 16 16)").toHex(), colord(ts.backgroundColor).lighten(0.025).toHex()) + setBgColor(r, + "--hover-from-background-color", + colord("rgb(23 23 23)").toHex(), + colord(ts.backgroundColor).lighten(0.025).desaturate(0.05).toHex()) + + + setBgColor(r, "--color-gray-400", "143 143 143", colord(ts.backgroundColor).lighten(0.3).desaturate(0.2).toRgb()) + setBgColor(r, "--color-gray-500", "90 90 90", colord(ts.backgroundColor).lighten(0.15).desaturate(0.2).toRgb()) + setBgColor(r, "--color-gray-600", "72 72 72", colord(ts.backgroundColor).lighten(0.1).desaturate(0.2).toRgb()) + setBgColor(r, "--color-gray-700", "54 54 54", colord(ts.backgroundColor).lighten(0.08).desaturate(0.2).toRgb()) + setBgColor(r, "--color-gray-800", "28 28 28", colord(ts.backgroundColor).lighten(0.06).desaturate(0.2).toRgb()) + setBgColor(r, "--color-gray-900", "16 16 16", colord(ts.backgroundColor).lighten(0.04).desaturate(0.05).toRgb()) + setBgColor(r, "--color-gray-950", "11 11 11", colord(ts.backgroundColor).lighten(0.008).desaturate(0.05).toRgb()) + // setColor(r, "--color-gray-300", null, colord(ts.backgroundColor).lighten(0.4).desaturate(0.2).toRgb()) + + }, [ts.enableColorSettings, ts.backgroundColor]) + + React.useEffect(() => { + let r = document.querySelector(":root") as any + + if (!ts.enableColorSettings) return + + setColor(r, "--color-brand-200", "212 208 255", colord(ts.accentColor).lighten(0.35).desaturate(0.05).toRgb()) + setColor(r, "--color-brand-300", "199 194 255", colord(ts.accentColor).lighten(0.3).desaturate(0.05).toRgb()) + setColor(r, "--color-brand-400", "159 146 255", colord(ts.accentColor).lighten(0.1).toRgb()) + setColor(r, "--color-brand-500", "97 82 223", colord(ts.accentColor).toRgb()) + setColor(r, "--color-brand-600", "82 67 203", colord(ts.accentColor).darken(0.1).toRgb()) + setColor(r, "--color-brand-700", "63 46 178", colord(ts.accentColor).darken(0.15).toRgb()) + setColor(r, "--color-brand-800", "49 40 135", colord(ts.accentColor).darken(0.2).toRgb()) + setColor(r, "--color-brand-900", "35 28 107", colord(ts.accentColor).darken(0.25).toRgb()) + setColor(r, "--color-brand-950", "26 20 79", colord(ts.accentColor).darken(0.3).toRgb()) + setColor(r, "--brand", colord("rgba(199 194 255)").toHex(), colord(ts.accentColor).lighten(0.35).desaturate(0.1).toHex()) + }, [ts.enableColorSettings, ts.accentColor]) + + return null +} + diff --git a/seanime-2.9.10/seanime-web/src/components/shared/directory-selector.tsx b/seanime-2.9.10/seanime-web/src/components/shared/directory-selector.tsx new file mode 100644 index 0000000..c2de0e7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/directory-selector.tsx @@ -0,0 +1,186 @@ +import { useDirectorySelector } from "@/api/hooks/directory_selector.hooks" +import { IconButton } from "@/components/ui/button" +import { Modal } from "@/components/ui/modal" +import { ScrollArea } from "@/components/ui/scroll-area" +import { TextInput, TextInputProps } from "@/components/ui/text-input" +import { useBoolean } from "@/hooks/use-disclosure" +import { upath } from "@/lib/helpers/upath" +import React from "react" +import { BiCheck, BiFolderOpen, BiFolderPlus, BiX } from "react-icons/bi" +import { FaFolder } from "react-icons/fa" +import { FiChevronLeft, FiFolder } from "react-icons/fi" +import { useUpdateEffect } from "react-use" +import { useDebounce } from "use-debounce" + +export type DirectorySelectorProps = { + defaultValue?: string + onSelect: (path: string) => void + shouldExist?: boolean + value: string +} & Omit<TextInputProps, "onSelect" | "value"> + +export const DirectorySelector = React.memo(React.forwardRef<HTMLInputElement, DirectorySelectorProps>(function (props: DirectorySelectorProps, ref) { + + const { + defaultValue, + onSelect, + value, + shouldExist, + ...rest + } = props + + const firstRender = React.useRef(true) + + const [input, setInput] = React.useState(defaultValue ? upath.normalizeSafe(defaultValue) : "") + const [debouncedInput] = useDebounce(input, 300) + const selectorState = useBoolean(false) + const prevState = React.useRef<string>(input) + const currentState = React.useRef<string>(input) + + const { data, isLoading, error } = useDirectorySelector(debouncedInput) + + React.useEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + if (value !== input) { + setInput(value) + } + }, [value]) + + React.useEffect(() => { + if (value !== currentState.current) { + setInput(value) + } + }, [value]) + + React.useEffect(() => { + currentState.current = input + if (input === ".") { + setInput("") + } + }, [input]) + + useUpdateEffect(() => { + onSelect(debouncedInput) + prevState.current = debouncedInput + + // if (!isLoading && data && shouldExist && !data.exists && input.length > 0) { + // onSelect("") + // } + }, [debouncedInput, data]) + + const checkDirectoryExists = React.useCallback(() => { + if (!isLoading && data && shouldExist && !data.exists && input.length > 0) { + React.startTransition(() => { + setInput("") + }) + } + }, [isLoading, data, input, shouldExist, prevState.current]) + + function sanitizeInput(input: string) { + // cross-platform sanitization + input = input.replace(/[<>"]/g, ''); + return upath.normalizeSafe(input.trim()) + } + + return ( + <> + <div className="space-y-1"> + <div className="relative"> + <TextInput + leftIcon={<FaFolder />} + {...rest} + value={input} + rightIcon={<div className="flex"> + {isLoading ? null : (data?.exists ? + <BiCheck className="text-green-500" /> : shouldExist ? + input.length > 0 ? <BiX className="text-red-500" /> : null : <BiFolderPlus />)} + </div>} + onChange={e => { + setInput(sanitizeInput(e.target.value ?? "")) + }} + ref={ref} + onBlur={checkDirectoryExists} + /> + <BiFolderOpen + className="text-2xl cursor-pointer absolute z-[1] top-0 right-0" + onClick={selectorState.on} + /> + </div> + </div> + <Modal + open={selectorState.active} + onOpenChange={v => { + selectorState.toggle() + if (!v) { + checkDirectoryExists() + } + }} + title="Select a directory" + contentClass="mt-4 space-y-2 max-w-4xl" + > + <div className="flex gap-2 items-center"> + <IconButton + onClick={() => data?.basePath && setInput(data?.basePath)} + intent="gray-basic" + rounded + size="sm" + icon={<FiChevronLeft />} + disabled={(!data?.basePath?.length || data?.basePath?.length === 1)} + /> + <TextInput + leftIcon={<FaFolder />} + value={input} + rightIcon={isLoading ? null : (data?.exists ? + <BiCheck className="text-green-500" /> : shouldExist ? + <BiX className="text-red-500" /> : <BiFolderPlus />)} + onChange={e => { + setInput(upath.normalizeSafe(e.target.value ?? "")) + }} + onClick={() => { + if (shouldExist) selectorState.on() + }} + ref={ref} + /> + </div> + + {(!data?.exists && data?.suggestions && data.suggestions.length > 0) && + <div + className="w-full flex flex-none flex-nowrap overflow-x-auto gap-2 items-center rounded-[--radius-md]" + > + <div className="flex-none">Suggestions:</div> + {data.suggestions.map(folder => ( + <div + key={folder.fullPath} + className="py-1 flex items-center gap-2 text-sm px-3 rounded-[--radius-md] border flex-none cursor-pointer bg-gray-900 hover:bg-gray-800" + onClick={() => setInput(upath.normalizeSafe(folder.fullPath))} + > + <FiFolder className="w-4 h-4 text-[--brand]" /> + <span className="break-normal">{folder.folderName}</span> + </div> + ))} + </div>} + + + {(data && !!data?.content?.length) && + <ScrollArea + className="h-60 rounded-[--radius-md] border !mt-0" + > + {data.content.map(folder => ( + <div + key={folder.fullPath} + className="flex items-center gap-2 py-2 px-3 cursor-pointer hover:bg-gray-800" + onClick={() => setInput(upath.normalizeSafe(folder.fullPath))} + > + <FiFolder className="w-4 h-4 text-[--brand]" /> + <span className="break-normal">{folder.folderName}</span> + </div> + ))} + </ScrollArea>} + </Modal> + </> + ) + +})) diff --git a/seanime-2.9.10/seanime-web/src/components/shared/file-selector.tsx b/seanime-2.9.10/seanime-web/src/components/shared/file-selector.tsx new file mode 100644 index 0000000..0dde312 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/file-selector.tsx @@ -0,0 +1,256 @@ +import { IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { Modal } from "@/components/ui/modal" +import { ScrollArea } from "@/components/ui/scroll-area" +import { TextInput } from "@/components/ui/text-input" +import { useDebounce } from "@/hooks/use-debounce" +import React from "react" +import { BiChevronRight, BiFolderOpen } from "react-icons/bi" +import { FaFolder } from "react-icons/fa" +import { FiFile, FiFolder } from "react-icons/fi" + +type FileSelectorProps = { + kind: "file" | "directory" | "both" + onSelectPath: (path: string) => void + selectedPath: string + fileExtensions?: string[] +} + +export function FileSelector(props: FileSelectorProps) { + + const { + kind, + onSelectPath, + selectedPath, + fileExtensions, + } = props + + const [path, setPath] = React.useState<string>("") + const debouncedPath = useDebounce(path, 500) + const [modalOpen, setModalOpen] = React.useState(false) + + const firstRender = React.useRef(true) + React.useLayoutEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + setPath(props.selectedPath) + }, [props.selectedPath]) + + React.useEffect(() => { + if (path) { + props.onSelectPath(path) + } + }, [path]) + + const handleManualPathSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault() + const node = findNodeByPath(exampleData, path) + if (node) { + setPath(node.path) + console.log("Manually selected:", node.path) + } else { + console.log("Path not found:", path) + setPath("") + } + } + + return ( + <> + <div className="relative"> + <TextInput + leftIcon={<FaFolder />} + value={path} + onValueChange={setPath} + // rightIcon={<div className="flex"> + // {isLoading ? null : (data?.exists ? + // <BiCheck className="text-green-500" /> : shouldExist ? + // <BiX className="text-red-500" /> : <BiFolderPlus />)} + // </div>} + // onBlur={checkDirectoryExists} + /> + <BiFolderOpen + className="text-2xl cursor-pointer absolute z-[1] top-0 right-0" + onClick={() => setModalOpen(true)} + /> + </div> + + <FileSelectorModal + isOpen={modalOpen} + onOpenChange={() => setModalOpen(!modalOpen)} + kind={kind} + onSelectPath={setPath} + selectedPath={selectedPath} + fileExtensions={fileExtensions} + /> + </> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +const exampleData: TreeNode = { + name: "project", + type: "directory", + path: "/project", + children: [ + { + name: "src", + type: "directory", + path: "/project/src", + }, + { + name: "public", + type: "directory", + path: "/project/public", + }, + { name: "package.json", type: "file", path: "/project/package.json" }, + { name: "README.md", type: "file", path: "/project/README.md" }, + ], +} + +const findNodeByPath = (node: TreeNode, path: string): TreeNode | null => { + if (node.path === path) return node + if (node.children) { + for (const child of node.children) { + const found = findNodeByPath(child, path) + if (found) return found + } + } + return null +} + +function FileSelectorModal(props: FileSelectorProps & { isOpen: boolean, onOpenChange: () => void }) { + const { isOpen, onOpenChange, kind, selectedPath, onSelectPath, fileExtensions } = props + + return ( + <Modal + title="Select a file or directory" + open={isOpen} + onOpenChange={onOpenChange} + contentClass="max-w-3xl" + > + <div className="space-y-4"> + <TextInput + value={selectedPath} + onValueChange={onSelectPath} + /> + + <ScrollArea + className={cn( + "h-60 rounded-[--radius-md] border", + )} + > + {exampleData.children?.map(node => ( + <TreeNode + data={node} + level={0} + kind={kind} + onSelect={node => onSelectPath(node.path)} + selectedPath={selectedPath} + fileExtensions={fileExtensions} + /> + ))} + </ScrollArea> + </div> + </Modal> + ) +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +type TreeNode = { + name: string + type: "file" | "directory" + path: string + children?: TreeNode[] +} + +type TreeProps = { + data: TreeNode + kind: "file" | "directory" | "both" + onSelect: (node: TreeNode) => void + selectedPath: string | null + fileExtensions?: string[] +} + + +const TreeNode: React.FC<TreeProps & { level: number }> = ({ + data, + kind, + onSelect, + selectedPath, + level, + fileExtensions, +}) => { + + React.useEffect(() => { + if (selectedPath && selectedPath.startsWith(data.path)) { + + } + }, [selectedPath, data.path]) + + const handleSelect = () => { + if (kind === "both" || kind === data.type) { + onSelect(data) + } + } + + const isSelectable = kind === "both" || kind === data.type + const isSelected = selectedPath === data.path + const isVisible = data.type === "directory" || kind !== "directory" + const hasValidExtension = !fileExtensions || + data.type === "directory" || + fileExtensions.some(ext => data.name.endsWith(ext)) + + if (!isVisible || !hasValidExtension) { + return null + } + + return ( + <div> + <div + className={cn( + "flex items-center", + (isSelectable && !isSelected) && "hover:bg-gray-950", + isSelected && "bg-gray-800", + )} + > + <div + className={cn( + "flex items-center gap-2 py-1 px-2 w-full", + isSelectable && "cursor-pointer", + )} + onClick={handleSelect} + > + <div className="flex items-center"> + {data.type === "directory" ? ( + <FiFolder className="w-4 h-4 text-[--brand]" /> + ) : ( + <FiFile className="w-4 h-4 text-[--muted]" /> + )} + </div> + <span + className={cn( + isSelectable ? "cursor-pointer" : "cursor-default", + )} + >{data.name}</span> + </div> + + <div className="flex flex-1"></div> + + {data.type === "directory" && <IconButton + intent="white-basic" + size="xs" + className="mr-2" + icon={<BiChevronRight />} + onClick={e => { + e.preventDefault() + }} + />} + + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/glowing-effect.tsx b/seanime-2.9.10/seanime-web/src/components/shared/glowing-effect.tsx new file mode 100644 index 0000000..ef675c4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/glowing-effect.tsx @@ -0,0 +1,202 @@ +"use client" + +import { animate } from "motion/react" +import { memo, useCallback, useEffect, useRef } from "react" +import { cn } from "../ui/core/styling" + +interface GlowingEffectProps { + blur?: number; + inactiveZone?: number; + proximity?: number; + spread?: number; + variant?: "default" | "white" | "classic"; + glow?: boolean; + className?: string; + disabled?: boolean; + movementDuration?: number; + borderWidth?: number; +} + +const GlowingEffect = memo( + ({ + blur = 0, + inactiveZone = 0.7, + proximity = 0, + spread = 20, + variant = "default", + glow = false, + className, + movementDuration = 2, + borderWidth = 1, + disabled = true, + }: GlowingEffectProps) => { + const containerRef = useRef<HTMLDivElement>(null) + const lastPosition = useRef({ x: 0, y: 0 }) + const animationFrameRef = useRef<number>(0) + + const handleMove = useCallback( + (e?: MouseEvent | { x: number; y: number }) => { + if (!containerRef.current) return + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + + animationFrameRef.current = requestAnimationFrame(() => { + const element = containerRef.current + if (!element) return + + const { left, top, width, height } = element.getBoundingClientRect() + const mouseX = e?.x ?? lastPosition.current.x + const mouseY = e?.y ?? lastPosition.current.y + + if (e) { + lastPosition.current = { x: mouseX, y: mouseY } + } + + const center = [left + width * 0.5, top + height * 0.5] + const distanceFromCenter = Math.hypot( + mouseX - center[0], + mouseY - center[1], + ) + const inactiveRadius = 0.5 * Math.min(width, height) * inactiveZone + + if (distanceFromCenter < inactiveRadius) { + element.style.setProperty("--active", "0") + return + } + + const isActive = + mouseX > left - proximity && + mouseX < left + width + proximity && + mouseY > top - proximity && + mouseY < top + height + proximity + + element.style.setProperty("--active", isActive ? "1" : "0") + + if (!isActive) return + + const currentAngle = + parseFloat(element.style.getPropertyValue("--start")) || 0 + let targetAngle = + (180 * Math.atan2(mouseY - center[1], mouseX - center[0])) / + Math.PI + + 90 + + const angleDiff = ((targetAngle - currentAngle + 180) % 360) - 180 + const newAngle = currentAngle + angleDiff + + animate(currentAngle, newAngle, { + duration: movementDuration, + ease: [0.16, 1, 0.3, 1], + onUpdate: (value) => { + element.style.setProperty("--start", String(value)) + }, + }) + }) + }, + [inactiveZone, proximity, movementDuration], + ) + + useEffect(() => { + if (disabled) return + + const handleScroll = () => handleMove() + const handlePointerMove = (e: PointerEvent) => handleMove(e) + + window.addEventListener("scroll", handleScroll, { passive: true }) + document.body.addEventListener("pointermove", handlePointerMove, { + passive: true, + }) + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + window.removeEventListener("scroll", handleScroll) + document.body.removeEventListener("pointermove", handlePointerMove) + } + }, [handleMove, disabled]) + + return ( + <> + <div + className={cn( + "pointer-events-none absolute -inset-px hidden rounded-[inherit] border opacity-0 transition-opacity", + glow && "opacity-100", + variant === "white" && "border-white", + disabled && "!block", + )} + /> + <div + ref={containerRef} + style={ + { + "--blur": `${blur}px`, + "--spread": spread, + "--start": "0", + "--active": "0", + "--glowingeffect-border-width": `${borderWidth}px`, + "--repeating-conic-gradient-times": "5", + "--gradient": + variant === "white" + ? `repeating-conic-gradient( + from 236.84deg at 50% 50%, + var(--black), + var(--black) calc(25% / var(--repeating-conic-gradient-times)) + )` + : variant === "classic" ? `radial-gradient(circle, #dd7bbb 10%, #dd7bbb00 20%), + radial-gradient(circle at 40% 40%, #d79f1e 5%, #d79f1e00 15%), + radial-gradient(circle at 60% 60%, #5a922c 10%, #5a922c00 20%), + radial-gradient(circle at 40% 60%, #4c7894 10%, #4c789400 20%), + repeating-conic-gradient( + from 236.84deg at 50% 50%, + rgb(167, 123, 221) 0%, + #d79f1e calc(25% / var(--repeating-conic-gradient-times)), + #5a922c calc(50% / var(--repeating-conic-gradient-times)), + #4c7894 calc(75% / var(--repeating-conic-gradient-times)), +rgb(167, 123, 221) calc(100% / var(--repeating-conic-gradient-times)) + )` : `radial-gradient(circle, rgb(var(--color-brand-500)) 10%, rgb(var(--color-brand-500) / 0) 20%), + radial-gradient(circle at 40% 40%, rgb(var(--color-brand-400)) 5%, rgb(var(--color-brand-400) / 0) 15%), + radial-gradient(circle at 60% 60%, rgb(var(--color-brand-600)) 10%, rgb(var(--color-brand-600) / 0) 20%), + radial-gradient(circle at 40% 60%, rgb(var(--color-brand-300)) 10%, rgb(var(--color-brand-300) / 0) 20%), + repeating-conic-gradient( + from 236.84deg at 50% 50%, + rgb(var(--color-brand-500)) 0%, + rgb(var(--color-brand-400)) calc(25% / var(--repeating-conic-gradient-times)), + rgb(var(--color-brand-600)) calc(50% / var(--repeating-conic-gradient-times)), + rgb(var(--color-brand-300)) calc(75% / var(--repeating-conic-gradient-times)), + rgb(var(--color-brand-500)) calc(100% / var(--repeating-conic-gradient-times)) + )`, + } as React.CSSProperties + } + className={cn( + "pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity lg:block hidden", + glow && "opacity-100", + blur > 0 && "blur-[var(--blur)] ", + className, + disabled && "!hidden", + )} + > + <div + className={cn( + "glow", + "rounded-[inherit]", + "after:content-[\"\"] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]", + "after:[border:var(--glowingeffect-border-width)_solid_transparent]", + "after:[background:var(--gradient)] after:[background-attachment:fixed]", + "after:opacity-[var(--active)] after:transition-opacity after:duration-300", + "after:[mask-clip:padding-box,border-box]", + "after:[mask-composite:intersect]", + "after:[mask-image:linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]", + )} + /> + </div> + </> + ) + }, +) + +GlowingEffect.displayName = "GlowingEffect" + +export { GlowingEffect } diff --git a/seanime-2.9.10/seanime-web/src/components/shared/image-helpers.ts b/seanime-2.9.10/seanime-web/src/components/shared/image-helpers.ts new file mode 100644 index 0000000..d697ce4 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/image-helpers.ts @@ -0,0 +1,21 @@ +const imageShimmerEffect = (w: number, h: number) => ` +<svg width="${w}" height="${h}" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <linearGradient id="g"> + <stop stop-color="#333" offset="20%" /> + <stop stop-color="#222" offset="50%" /> + <stop stop-color="#333" offset="70%" /> + </linearGradient> + </defs> + <rect width="${w}" height="${h}" fill="#333" /> + <rect id="r" width="${w}" height="${h}" fill="url(#g)" /> + <animate xlink:href="#r" attributeName="x" from="-${w}" to="${w}" dur="1s" repeatCount="indefinite" /> +</svg>` + +const toBase64 = (str: string) => + typeof window === "undefined" + ? Buffer.from(str).toString("base64") + : window.btoa(str) + +export const imageShimmer = (w: number, h: number): `data:image/${string}` => + `data:image/svg+xml;base64,${toBase64(imageShimmerEffect(w, h))}` diff --git a/seanime-2.9.10/seanime-web/src/components/shared/loading-overlay-with-logo.tsx b/seanime-2.9.10/seanime-web/src/components/shared/loading-overlay-with-logo.tsx new file mode 100644 index 0000000..627e7d1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/loading-overlay-with-logo.tsx @@ -0,0 +1,29 @@ +import { TextGenerateEffect } from "@/components/shared/text-generate-effect" +import { Button } from "@/components/ui/button" +import { LoadingOverlay } from "@/components/ui/loading-spinner" +import { __isDesktop__ } from "@/types/constants" +import Image from "next/image" +import React from "react" + +export function LoadingOverlayWithLogo({ refetch, title }: { refetch?: () => void, title?: string }) { + return <LoadingOverlay showSpinner={false}> + <Image + src="/logo_2.png" + alt="Loading..." + priority + width={180} + height={180} + className="animate-pulse" + /> + <TextGenerateEffect className="text-lg mt-2 text-[--muted] animate-pulse" words={title ?? "S e a n i m e"} /> + + {(__isDesktop__ && !!refetch) && ( + <Button + onClick={() => window.location.reload()} + className="mt-4" + intent="gray-outline" + size="sm" + >Reload</Button> + )} + </LoadingOverlay> +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/luffy-error.tsx b/seanime-2.9.10/seanime-web/src/components/shared/luffy-error.tsx new file mode 100644 index 0000000..9fa04bf --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/luffy-error.tsx @@ -0,0 +1,57 @@ +"use client" +import { Button } from "@/components/ui/button/button" +import { cn } from "@/components/ui/core/styling" +import Image from "next/image" +import { useRouter } from "next/navigation" +import React from "react" + +interface LuffyErrorProps { + children?: React.ReactNode + className?: string + reset?: () => void + title?: string | null + showRefreshButton?: boolean +} + +export const LuffyError: React.FC<LuffyErrorProps> = (props) => { + + const { children, reset, className, title = "Oops!", showRefreshButton = false, ...rest } = props + + const router = useRouter() + + + return ( + <> + <div data-luffy-error className={cn("w-full flex flex-col items-center mt-10 space-y-4", className)}> + {<div + data-luffy-error-image-container + className="size-[8rem] mx-auto flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden" + > + <Image + data-luffy-error-image + src="/luffy-01.png" + alt={""} + fill + quality={100} + priority + sizes="10rem" + className="object-contain object-top" + /> + </div>} + <div data-luffy-error-content className="text-center space-y-4"> + {!!title && <h3 data-luffy-error-title>{title}</h3>} + <div data-luffy-error-content-children>{children}</div> + <div data-luffy-error-content-buttons> + {(showRefreshButton && !reset) && ( + <Button data-luffy-error-content-button-refresh intent="warning-subtle" onClick={() => router.refresh()}>Retry</Button> + )} + {!!reset && ( + <Button data-luffy-error-content-button-reset intent="warning-subtle" onClick={reset}>Retry</Button> + )} + </div> + </div> + </div> + </> + ) + +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/media-exclusion-selector.tsx b/seanime-2.9.10/seanime-web/src/components/shared/media-exclusion-selector.tsx new file mode 100644 index 0000000..320f830 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/media-exclusion-selector.tsx @@ -0,0 +1,417 @@ +"use client" + +import { Anime_LibraryCollectionEntry } from "@/api/generated/types" +import { animeLibraryCollectionAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms" +import { imageShimmer } from "@/components/shared/image-helpers" +import { BasicField } from "@/components/ui/basic-field" +import { Button } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { Modal } from "@/components/ui/modal" +import { useAtomValue } from "jotai/react" +import Image from "next/image" +import React from "react" +import { BiEdit } from "react-icons/bi" +import { RiCloseCircleFill } from "react-icons/ri" + +export type MediaExclusionSelectorProps = { + value?: number[] + onChange?: (value: number[]) => void + onBlur?: () => void + disabled?: boolean + error?: string + label?: string + help?: string + required?: boolean + name?: string +} + +export const MediaExclusionSelector = React.forwardRef<HTMLDivElement, MediaExclusionSelectorProps>( + (props, ref) => { + const { + value = [], + onChange, + onBlur, + disabled = false, + error, + label, + help, + required, + name, + ...rest + } = props + + const _animeLibraryCollection = useAtomValue(animeLibraryCollectionAtom) + const animeLibraryCollectionEntries = _animeLibraryCollection?.lists?.flatMap(n => n.entries)?.filter(n => !!n?.libraryData)?.filter(Boolean) + const [selectedIds, setSelectedIds] = React.useState<number[]>(value) + const [modalOpen, setModalOpen] = React.useState(false) + + React.useEffect(() => { + setSelectedIds(value) + }, [value]) + + const handleToggleMedia = React.useCallback((mediaId: number) => { + const newSelectedIds = selectedIds.includes(mediaId) + ? selectedIds.filter(id => id !== mediaId) + : [...selectedIds, mediaId] + + setSelectedIds(newSelectedIds) + onChange?.(newSelectedIds) + }, [selectedIds, onChange]) + + const handleSelectAll = React.useCallback(() => { + if (!animeLibraryCollectionEntries) return + + const allMediaIds: number[] = [] + animeLibraryCollectionEntries?.forEach(entry => { + if (entry.mediaId && !allMediaIds.includes(entry.mediaId)) { + allMediaIds.push(entry.mediaId) + } + }) + + setSelectedIds(allMediaIds) + onChange?.(allMediaIds) + }, [animeLibraryCollectionEntries, onChange]) + + const handleDeselectAll = React.useCallback(() => { + setSelectedIds([]) + onChange?.([]) + }, [onChange]) + + const handleSelectAdult = React.useCallback(() => { + if (!animeLibraryCollectionEntries) return + + const adultMediaIds: number[] = [] + animeLibraryCollectionEntries?.forEach(entry => { + if (entry.media?.isAdult && entry.mediaId && !adultMediaIds.includes(entry.mediaId)) { + adultMediaIds.push(entry.mediaId) + } + }) + + const newSelectedIds = [...new Set([...selectedIds, ...adultMediaIds])] + setSelectedIds(newSelectedIds) + onChange?.(newSelectedIds) + }, [animeLibraryCollectionEntries, selectedIds, onChange]) + + const lists = React.useMemo(() => { + if (!animeLibraryCollectionEntries) return { + CURRENT: [], + PLANNING: [], + COMPLETED: [], + PAUSED: [], + DROPPED: [], + } + + return { + CURRENT: animeLibraryCollectionEntries + ?.filter(Boolean) + ?.toSorted((a, b) => a.media!.title!.userPreferred!.localeCompare(b.media!.title!.userPreferred!)) ?? [], + // PLANNING: animeLibraryCollection.lists + // .find(n => n.type === "PLANNING") + // ?.entries?.filter(Boolean) + // ?.toSorted((a, b) => a.media!.title!.userPreferred!.localeCompare(b.media!.title!.userPreferred!)) ?? [], + // COMPLETED: animeLibraryCollection.lists + // .find(n => n.type === "COMPLETED") + // ?.entries?.filter(Boolean) + // ?.toSorted((a, b) => a.media!.title!.userPreferred!.localeCompare(b.media!.title!.userPreferred!)) ?? [], + // PAUSED: animeLibraryCollection.lists + // .find(n => n.type === "PAUSED") + // ?.entries?.filter(Boolean) + // ?.toSorted((a, b) => a.media!.title!.userPreferred!.localeCompare(b.media!.title!.userPreferred!)) ?? [], + // DROPPED: animeLibraryCollection.lists + // .find(n => n.type === "DROPPED") + // ?.entries?.filter(Boolean) + // ?.toSorted((a, b) => a.media!.title!.userPreferred!.localeCompare(b.media!.title!.userPreferred!)) ?? [], + } + }, [animeLibraryCollectionEntries]) + + // Get preview items for display + const selectedEntries = React.useMemo(() => { + if (!animeLibraryCollectionEntries) return [] + + const allEntries: Anime_LibraryCollectionEntry[] = [] + animeLibraryCollectionEntries?.forEach(entry => { + if (selectedIds.includes(entry.mediaId)) { + allEntries.push(entry) + } + }) + return allEntries.slice(0, 5) + }, [animeLibraryCollectionEntries, selectedIds]) + + if (!animeLibraryCollectionEntries) { + return ( + <BasicField + label={label} + help={help} + error={error} + required={required} + > + <div className="flex items-center justify-center p-8"> + <LoadingSpinner /> + </div> + </BasicField> + ) + } + + return ( + <BasicField + label={label} + help={help} + error={error} + required={required} + ref={ref} + {...rest} + > + <div className="space-y-3"> + <div className="flex items-center gap-3 p-4 border rounded-[--radius-md] bg-gray-900"> + <div className="flex-1"> + <div className="flex items-center gap-2 mb-2"> + <span className="text-sm font-medium"> + {selectedIds.length} anime excluded from sharing + </span> + {selectedIds.length > 0 && ( + <span className="text-xs text-[--muted]">(will not be visible to other clients)</span> + )} + </div> + + {selectedEntries.length > 0 && ( + <div className="flex items-center gap-2"> + <div className="flex -space-x-1"> + {selectedEntries.map(entry => ( + <div + key={entry.mediaId} + className="size-8 rounded-md overflow-hidden border-2 border-white dark:border-gray-900" + > + <Image + src={entry.media?.coverImage?.medium || entry.media?.coverImage?.large || ""} + placeholder={imageShimmer(200, 280)} + width={32} + height={32} + alt="" + className="object-cover size-full" + /> + </div> + ))} + </div> + {selectedIds.length > 5 && ( + <span className="text-xs text-[--muted]"> + +{selectedIds.length - 5} more + </span> + )} + </div> + )} + </div> + + <Modal + title="Select anime to exclude from sharing" + contentClass="max-w-6xl" + open={modalOpen} + onOpenChange={setModalOpen} + trigger={ + <Button + type="button" + intent="gray-subtle" + size="sm" + leftIcon={<BiEdit />} + disabled={disabled} + > + {selectedIds.length > 0 ? "Edit selection" : "Select anime"} + </Button> + } + > + <div className="space-y-4"> + <p className="text-[--muted]"> + Select anime that you don't want to share with other clients. Selected anime will not be visible to connected + clients. + </p> + + <div className="flex items-center gap-2 flex-wrap p-4 bg-[--subtle] rounded-[--radius-md]"> + <Button + type="button" + intent="gray-subtle" + size="sm" + onClick={handleSelectAll} + disabled={disabled} + > + Select all + </Button> + <Button + type="button" + intent="gray-subtle" + size="sm" + onClick={handleDeselectAll} + disabled={disabled} + > + Deselect all + </Button> + <Button + type="button" + intent="gray-subtle" + size="sm" + onClick={handleSelectAdult} + disabled={disabled} + > + Select adult + </Button> + <div className="flex-1" /> + <span className="text-sm text-[--muted]"> + {selectedIds.length} selected (will not be shared) + </span> + </div> + + <div className="space-y-6 max-h-[60vh] overflow-y-auto p-1"> + {!!lists.CURRENT.length && ( + <MediaSection + title="All" + entries={lists.CURRENT} + selectedIds={selectedIds} + onToggle={handleToggleMedia} + disabled={disabled} + /> + )} + {/*{!!lists.PAUSED.length && (*/} + {/* <MediaSection*/} + {/* title="Paused"*/} + {/* entries={lists.PAUSED}*/} + {/* selectedIds={selectedIds}*/} + {/* onToggle={handleToggleMedia}*/} + {/* disabled={disabled}*/} + {/* />*/} + {/*)}*/} + {/*{!!lists.PLANNING.length && (*/} + {/* <MediaSection*/} + {/* title="Planning"*/} + {/* entries={lists.PLANNING}*/} + {/* selectedIds={selectedIds}*/} + {/* onToggle={handleToggleMedia}*/} + {/* disabled={disabled}*/} + {/* />*/} + {/*)}*/} + {/*{!!lists.COMPLETED.length && (*/} + {/* <MediaSection*/} + {/* title="Completed"*/} + {/* entries={lists.COMPLETED}*/} + {/* selectedIds={selectedIds}*/} + {/* onToggle={handleToggleMedia}*/} + {/* disabled={disabled}*/} + {/* />*/} + {/*)}*/} + {/*{!!lists.DROPPED.length && (*/} + {/* <MediaSection*/} + {/* title="Dropped"*/} + {/* entries={lists.DROPPED}*/} + {/* selectedIds={selectedIds}*/} + {/* onToggle={handleToggleMedia}*/} + {/* disabled={disabled}*/} + {/* />*/} + {/*)}*/} + </div> + + <div className="flex justify-end pt-4 border-t"> + <Button + type="button" + intent="primary" + onClick={() => setModalOpen(false)} + > + Done ({selectedIds.length} selected) + </Button> + </div> + </div> + </Modal> + </div> + </div> + </BasicField> + ) + }, +) + +MediaExclusionSelector.displayName = "MediaExclusionSelector" + +function MediaSection(props: { + title: string + entries: Anime_LibraryCollectionEntry[] + selectedIds: number[] + onToggle: (mediaId: number) => void + disabled?: boolean +}) { + const { title, entries, selectedIds, onToggle, disabled } = props + + return ( + <div className="space-y-2"> + <h4 className="border-b pb-1 mb-1">{title}</h4> + <div className="grid grid-cols-3 md:grid-cols-6 2xl:grid-cols-7 gap-2"> + {entries.map(entry => ( + <MediaExclusionItem + key={entry.mediaId} + entry={entry} + isSelected={selectedIds.includes(entry.mediaId)} + onToggle={() => onToggle(entry.mediaId)} + disabled={disabled} + /> + ))} + </div> + </div> + ) +} + +function MediaExclusionItem(props: { + entry: Anime_LibraryCollectionEntry + isSelected: boolean + onToggle: () => void + disabled?: boolean +}) { + const { entry, isSelected, onToggle, disabled } = props + + return ( + <div + className={cn( + "col-span-1 aspect-[6/7] rounded-[--radius-md] overflow-hidden relative bg-[var(--background)] cursor-pointer transition-all select-none group", + disabled && "pointer-events-none opacity-50", + isSelected && "ring-2 ring-red-500", + )} + onClick={onToggle} + > + <Image + src={entry.media?.coverImage?.large || entry.media?.bannerImage || ""} + placeholder={imageShimmer(700, 475)} + sizes="10rem" + fill + alt="" + className={cn( + "object-center object-cover rounded-[--radius-md] transition-opacity", + isSelected ? "opacity-50" : "opacity-90 group-hover:opacity-100", + )} + /> + + <p className="line-clamp-2 text-sm absolute m-2 bottom-0 font-semibold z-[10] text-white drop-shadow-lg"> + {entry.media?.title?.userPreferred || entry.media?.title?.romaji} + </p> + + {entry.media?.isAdult && ( + <div className="absolute top-2 left-2 bg-red-600 text-white text-xs px-1.5 py-0.5 rounded font-semibold z-[10]"> + 18+ + </div> + )} + + <div + className={cn( + "absolute top-2 right-2 size-6 rounded-full flex items-center justify-center z-[10] transition-all", + isSelected + ? "bg-red-500 text-white" + : "bg-black/50 text-white/70 group-hover:bg-black/70", + )} + > + {isSelected ? ( + <RiCloseCircleFill className="size-4" /> + ) : ( + <div className="size-3 border border-current rounded-full" /> + )} + </div> + + <div className="z-[5] absolute bottom-0 w-full h-[80%] bg-gradient-to-t from-black/80 to-transparent" /> + {!isSelected && ( + <div className="z-[5] absolute top-0 w-full h-[80%] bg-gradient-to-b from-black/50 to-transparent opacity-100 group-hover:opacity-60 transition-opacity" /> + )} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/page-transition.ts b/seanime-2.9.10/seanime-web/src/components/shared/page-transition.ts new file mode 100644 index 0000000..26d7ee7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/page-transition.ts @@ -0,0 +1,10 @@ +export const PAGE_TRANSITION = { + initial: { opacity: 0, y: 60 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: 60 }, + transition: { + type: "spring", + damping: 20, + stiffness: 100, + }, +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/page-wrapper.tsx b/seanime-2.9.10/seanime-web/src/components/shared/page-wrapper.tsx new file mode 100644 index 0000000..89a0138 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/page-wrapper.tsx @@ -0,0 +1,31 @@ +"use client" +import { PAGE_TRANSITION } from "@/components/shared/page-transition" +import { cn } from "@/components/ui/core/styling" +import { motion } from "motion/react" +import React from "react" + +type PageWrapperProps = { + children?: React.ReactNode +} & React.ComponentPropsWithoutRef<"div"> + +export function PageWrapper(props: PageWrapperProps) { + + const { + children, + className, + ...rest + } = props + + return ( + <div data-page-wrapper-container> + <motion.div + data-page-wrapper + {...PAGE_TRANSITION} + {...rest as any} + className={cn("z-[5] relative", className)} + > + {children} + </motion.div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/particle-bg.tsx b/seanime-2.9.10/seanime-web/src/components/shared/particle-bg.tsx new file mode 100644 index 0000000..e60e70b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/particle-bg.tsx @@ -0,0 +1,286 @@ +"use client" + +import React, { useEffect, useRef, useState } from "react" + +interface MousePosition { + x: number; + y: number; +} + +function useMousePosition(): MousePosition { + const [mousePosition, setMousePosition] = useState<MousePosition>({ + x: 0, + y: 0, + }) + + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + setMousePosition({ x: event.clientX, y: event.clientY }) + } + + window.addEventListener("mousemove", handleMouseMove) + + return () => { + window.removeEventListener("mousemove", handleMouseMove) + } + }, []) + + return mousePosition +} + +interface ParticleBackgroundProps { + className?: string; + quantity?: number; + staticity?: number; + ease?: number; + size?: number; + refresh?: boolean; + color?: string; + vx?: number; + vy?: number; +} + +function hexToRgb(hex: string): number[] { + hex = hex.replace("#", "") + + if (hex.length === 3) { + hex = hex + .split("") + .map((char) => char + char) + .join("") + } + + const hexInt = parseInt(hex, 16) + const red = (hexInt >> 16) & 255 + const green = (hexInt >> 8) & 255 + const blue = hexInt & 255 + return [red, green, blue] +} + +export const ParticleBackground: React.FC<ParticleBackgroundProps> = ({ + className = "", + quantity = 200, + staticity = 50, + ease = 50, + size = 0.4, + refresh = false, + color = "#ffffff", + vx = 0, + vy = 0, +}) => { + const canvasRef = useRef<HTMLCanvasElement>(null) + const canvasContainerRef = useRef<HTMLDivElement>(null) + const context = useRef<CanvasRenderingContext2D | null>(null) + const circles = useRef<any[]>([]) + const mousePosition = useMousePosition() + const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) + const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 }) + const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1 + + const [bgMousePosition, setBgMousePosition] = useState({ x: 0, y: 0 }) + + useEffect(() => { + if (canvasRef.current) { + context.current = canvasRef.current.getContext("2d") + } + initCanvas() + animate() + window.addEventListener("resize", initCanvas) + + return () => { + window.removeEventListener("resize", initCanvas) + } + }, [color]) + + useEffect(() => { + onMouseMove() + }, [mousePosition.x, mousePosition.y]) + + useEffect(() => { + initCanvas() + }, [refresh]) + + const initCanvas = () => { + resizeCanvas() + drawParticleBackground() + } + + const onMouseMove = () => { + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect() + const { w, h } = canvasSize.current + const x = mousePosition.x - rect.left - w / 2 + const y = mousePosition.y - rect.top - h / 2 + const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2 + if (inside) { + mouse.current.x = x + mouse.current.y = y + } + + /// + const x2 = Math.min((mousePosition.x - (rect.left + rect.width / 2)) / 60, 20) + const y2 = Math.min((mousePosition.y - (rect.top + rect.height / 2)) / 60, 20) + setBgMousePosition({ x: x2, y: y2 }) + } + } + + type Circle = { + x: number; + y: number; + translateX: number; + translateY: number; + size: number; + alpha: number; + targetAlpha: number; + dx: number; + dy: number; + magnetism: number; + }; + + const resizeCanvas = () => { + if (canvasRef.current && context.current) { + circles.current.length = 0 + canvasSize.current.w = canvasContainerRef.current?.offsetWidth || window.innerWidth + canvasSize.current.h = canvasContainerRef.current?.offsetHeight || window.innerHeight + canvasRef.current.width = canvasSize.current.w * dpr + canvasRef.current.height = canvasSize.current.h * dpr + canvasRef.current.style.width = `${canvasSize.current.w}px` + canvasRef.current.style.height = `${canvasSize.current.h}px` + context.current.scale(dpr, dpr) + } + } + + const circleParams = (): Circle => { + const x = Math.floor(Math.random() * canvasSize.current.w) + const y = Math.floor(Math.random() * canvasSize.current.h) + const translateX = 0 + const translateY = 0 + const pSize = Math.floor(Math.random() * 2) + size + const alpha = 0 + const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1)) + const dx = (Math.random() - 0.5) * 0.1 + const dy = (Math.random() - 0.5) * 0.1 + const magnetism = 0.1 + Math.random() * 4 + return { + x, + y, + translateX, + translateY, + size: pSize, + alpha, + targetAlpha, + dx, + dy, + magnetism, + } + } + + const rgb = hexToRgb(color) + + const drawCircle = (circle: Circle, update = false) => { + if (context.current) { + const { x, y, translateX, translateY, size, alpha } = circle + context.current.translate(translateX, translateY) + context.current.beginPath() + context.current.arc(x, y, size, 0, 2 * Math.PI) + context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})` + context.current.fill() + context.current.setTransform(dpr, 0, 0, dpr, 0, 0) + + if (!update) { + circles.current.push(circle) + } + } + } + + const clearContext = () => { + if (context.current) { + context.current.clearRect( + 0, + 0, + canvasSize.current.w, + canvasSize.current.h, + ) + } + } + + const drawParticleBackground = () => { + clearContext() + const particleCount = quantity + for (let i = 0; i < particleCount; i++) { + const circle = circleParams() + drawCircle(circle) + } + } + + const remapValue = ( + value: number, + start1: number, + end1: number, + start2: number, + end2: number, + ): number => { + const remapped = + ((value - start1) * (end2 - start2)) / (end1 - start1) + start2 + return remapped > 0 ? remapped : 0 + } + + const animate = () => { + clearContext() + circles.current.forEach((circle: Circle, i: number) => { + // Handle the alpha value + const edge = [ + circle.x + circle.translateX - circle.size, // distance from left edge + canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge + circle.y + circle.translateY - circle.size, // distance from top edge + canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge + ] + const closestEdge = edge.reduce((a, b) => Math.min(a, b)) + const remapClosestEdge = parseFloat( + remapValue(closestEdge, 0, 20, 0, 1).toFixed(2), + ) + if (remapClosestEdge > 1) { + circle.alpha += 0.02 + if (circle.alpha > circle.targetAlpha) { + circle.alpha = circle.targetAlpha + } + } else { + circle.alpha = circle.targetAlpha * remapClosestEdge + } + circle.x += circle.dx + vx + circle.y += circle.dy + vy + circle.translateX += + (mouse.current.x / (staticity / circle.magnetism) - circle.translateX) / + ease + circle.translateY += + (mouse.current.y / (staticity / circle.magnetism) - circle.translateY) / + ease + + drawCircle(circle, true) + + // circle gets out of the canvas + if ( + circle.x < -circle.size || + circle.x > canvasSize.current.w + circle.size || + circle.y < -circle.size || + circle.y > canvasSize.current.h + circle.size + ) { + // remove the circle from the array + circles.current.splice(i, 1) + // create a new circle + const newCircle = circleParams() + drawCircle(newCircle) + // update the circle position + } + }) + window.requestAnimationFrame(animate) + } + + return ( + <> + <div className={className} ref={canvasContainerRef} aria-hidden="true"> + <canvas ref={canvasRef} className="size-full" /> + </div> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/resizable.tsx b/seanime-2.9.10/seanime-web/src/components/shared/resizable.tsx new file mode 100644 index 0000000..43f86d9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/resizable.tsx @@ -0,0 +1,44 @@ +"use client" + +import React from "react" +import { BsThreeDotsVertical } from "react-icons/bs" +import * as ResizablePrimitive from "react-resizable-panels" +import { cn } from "../ui/core/styling" + +export const ResizablePanelGroup = ({ + className, + ...props +}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => ( + <ResizablePrimitive.PanelGroup + className={cn( + "flex h-full w-full data-[panel-group-direction=vertical]:flex-col", + className, + )} + {...props} + /> +) + +export const ResizablePanel = ResizablePrimitive.Panel + +export const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { + withHandle?: boolean +}) => ( + <ResizablePrimitive.PanelResizeHandle + className={cn( + "relative flex w-px items-center justify-center bg-[--border] after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", + className, + )} + {...props} + > + {withHandle && ( + <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-[--border]"> + <BsThreeDotsVertical className="h-2.5 w-2.5" /> + </div> + )} + </ResizablePrimitive.PanelResizeHandle> +) + diff --git a/seanime-2.9.10/seanime-web/src/components/shared/scroll-area-box.tsx b/seanime-2.9.10/seanime-web/src/components/shared/scroll-area-box.tsx new file mode 100644 index 0000000..31ea6e7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/scroll-area-box.tsx @@ -0,0 +1,21 @@ +import { cn } from "@/components/ui/core/styling" +import { ScrollArea, ScrollAreaProps } from "@/components/ui/scroll-area" +import React from "react" + +export function ScrollAreaBox({ listClass, className, children, ...rest }: ScrollAreaProps & { listClass?: string }) { + return <ScrollArea + className={cn( + "h-[calc(100dvh_-_25rem)] min-h-52 relative border rounded-[--radius]", + className, + )} {...rest}> + <div + className="z-[5] absolute bottom-0 w-full h-8 bg-gradient-to-t from-[--background] to-transparent" + /> + <div + className="z-[5] absolute top-0 w-full h-8 bg-gradient-to-b from-[--background] to-transparent" + /> + <div className="space-y-2 p-6"> + {children} + </div> + </ScrollArea> +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/sea-link.tsx b/seanime-2.9.10/seanime-web/src/components/shared/sea-link.tsx new file mode 100644 index 0000000..e7dbba6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/sea-link.tsx @@ -0,0 +1,42 @@ +import { cn } from "@/components/ui/core/styling" +import { __isDesktop__ } from "@/types/constants" +import Link, { LinkProps } from "next/link" +import { useRouter } from "next/navigation" +import React from "react" + +type SeaLinkProps = {} & LinkProps & React.ComponentPropsWithRef<"a"> + +export const SeaLink = React.forwardRef((props: SeaLinkProps, _) => { + + const { + href, + children, + className, + ...rest + } = props + + const router = useRouter() + + if (__isDesktop__ && rest.target !== "_blank") { + return ( + <a + className={cn( + "cursor-pointer", + className, + )} + onClick={() => { + router.push(href as string) + }} + data-current={(rest as any)["data-current"]} + > + {children} + </a> + ) + } + + return ( + <Link href={href} className={cn("cursor-pointer", className)} {...rest}> + {children} + </Link> + ) +}) diff --git a/seanime-2.9.10/seanime-web/src/components/shared/slider-episode-item.tsx b/seanime-2.9.10/seanime-web/src/components/shared/slider-episode-item.tsx new file mode 100644 index 0000000..c2a2c5c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/slider-episode-item.tsx @@ -0,0 +1,74 @@ +import { Anime_Episode } from "@/api/generated/types" +import { EpisodeItemBottomGradient } from "@/app/(main)/_features/custom-ui/item-bottom-gradients" +import { imageShimmer } from "@/components/shared/image-helpers" +import { cn } from "@/components/ui/core/styling" +import Image from "next/image" +import React from "react" +import { AiFillPlayCircle } from "react-icons/ai" + +type SliderEpisodeItemProps = { + episode: Anime_Episode + onPlay?: ({ path }: { path: string }) => void +} & Omit<React.ComponentPropsWithoutRef<"div">, "onPlay"> + +export const SliderEpisodeItem = React.forwardRef<HTMLDivElement, SliderEpisodeItemProps>(({ episode, onPlay, ...rest }, ref) => { + + // const date = episode.episodeMetadata?.airDate ? new Date(episode.episodeMetadata.airDate) : undefined + const offset = episode.progressNumber - episode.episodeNumber + + return ( + <div + ref={ref} + key={episode.localFile?.path} + className={cn( + "rounded-[--radius-md] border overflow-hidden aspect-[4/2] relative flex items-end flex-none group/missed-episode-item cursor-pointer", + "select-none", + "focus-visible:ring-2 ring-[--brand]", + "w-full", + )} + onClick={() => onPlay?.({ path: episode.localFile?.path ?? "" })} + tabIndex={0} + {...rest} + > + <div className="absolute w-full h-full overflow-hidden z-[1]"> + {!!episode.episodeMetadata?.image ? <Image + src={episode.episodeMetadata?.image} + alt={""} + fill + quality={100} + placeholder={imageShimmer(700, 475)} + sizes="20rem" + className="object-cover object-center transition" + /> : <div + className="h-full block absolute w-full bg-gradient-to-t from-gray-800 to-transparent z-[2]" + ></div>} + {/*[CUSTOM UI] BOTTOM GRADIENT*/} + <EpisodeItemBottomGradient /> + </div> + <div + className={cn( + "group-hover/missed-episode-item:opacity-100 text-6xl text-gray-200", + "cursor-pointer opacity-0 transition-opacity bg-gray-950 bg-opacity-60 z-[2] absolute w-[105%] h-[105%] items-center justify-center", + "hidden md:flex", + )} + > + <AiFillPlayCircle className="opacity-50" /> + </div> + <div className="relative z-[3] w-full p-4 space-y-1"> + <p className="w-[80%] line-clamp-1 text-[--muted] font-semibold">{episode.episodeTitle?.replaceAll("`", "'")}</p> + <div className="w-full justify-between flex items-center"> + <p className="text-base md:text-xl lg:text-2xl font-semibold line-clamp-2"> + <span>{episode.displayTitle} {!!episode.baseAnime?.episodes && + (episode.baseAnime.episodes != 1 && + <span className="opacity-40">/{` `}{episode.baseAnime.episodes - offset}</span>)} + </span> + </p> + <div className="flex flex-1"></div> + {!!episode.episodeMetadata?.length && + <p className="text-[--muted] text-sm md:text-base">{episode.episodeMetadata?.length + "m" || ""}</p>} + </div> + {episode.isInvalid && <p className="text-red-300">No metadata found</p>} + </div> + </div> + ) +}) diff --git a/seanime-2.9.10/seanime-web/src/components/shared/slider.tsx b/seanime-2.9.10/seanime-web/src/components/shared/slider.tsx new file mode 100644 index 0000000..9d5d2a3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/slider.tsx @@ -0,0 +1,120 @@ +"use client" +import { cn } from "@/components/ui/core/styling" +import { useDraggableScroll } from "@/hooks/use-draggable-scroll" +import { MdChevronLeft } from "react-icons/md" +import { MdChevronRight } from "react-icons/md" +import React, { useRef, useState } from "react" +import { useIsomorphicLayoutEffect, useUpdateEffect } from "react-use" + +interface SliderProps { + children?: React.ReactNode + sliderClassName?: string + containerClassName?: string + onSlideEnd?: () => void +} + +export const Slider: React.FC<SliderProps> = (props) => { + + const { children, onSlideEnd, ...rest } = props + + const ref = useRef<HTMLDivElement>() as React.MutableRefObject<HTMLInputElement> + const { events } = useDraggableScroll(ref, { + decayRate: 0.96, + safeDisplacement: 15, + applyRubberBandEffect: true, + }) + + const [isScrolledToLeft, setIsScrolledToLeft] = useState(true) + const [isScrolledToRight, setIsScrolledToRight] = useState(false) + const [showChevronRight, setShowChevronRight] = useState(false) + + const handleScroll = () => { + const div = ref.current + + if (div) { + const scrolledToLeft = div.scrollLeft === 0 + const scrolledToRight = div.scrollLeft + div.clientWidth === div.scrollWidth + + setIsScrolledToLeft(scrolledToLeft) + setIsScrolledToRight(scrolledToRight) + } + } + + useUpdateEffect(() => { + if (!isScrolledToLeft && isScrolledToRight) { + onSlideEnd && onSlideEnd() + const t = setTimeout(() => { + const div = ref.current + if (div) { + div.scrollTo({ + left: div.scrollLeft + 500, + behavior: "smooth", + }) + } + }, 1000) + return () => clearTimeout(t) + } + }, [isScrolledToLeft, isScrolledToRight]) + + function slideLeft() { + const div = ref.current + if (div) { + div.scrollTo({ + left: div.scrollLeft - 500, + behavior: "smooth", + }) + } + } + + function slideRight() { + const div = ref.current + if (div) { + div.scrollTo({ + left: div.scrollLeft + 500, + behavior: "smooth", + }) + } + } + + useIsomorphicLayoutEffect(() => { + if (ref.current.clientWidth < ref.current.scrollWidth) { + setShowChevronRight(true) + } else { + setShowChevronRight(false) + } + }, [ref.current]) + + return ( + <div className={cn( + "relative flex items-center lg:gap-2", + props.containerClassName, + )}> + <div + onClick={slideLeft} + className={`flex items-center cursor-pointer hover:text-action absolute left-0 bg-gradient-to-r from-[--background] z-40 h-full w-16 hover:opacity-100 ${ + !isScrolledToLeft ? "lg:visible" : "invisible" + }`} + > + <MdChevronLeft className="w-7 h-7 stroke-2 mx-auto"/> + </div> + <div + onScroll={handleScroll} + className="flex max-w-full w-full space-x-3 overflow-x-scroll scrollbar-hide scroll" + {...events} + ref={ref} + > + {children} + </div> + <div + onClick={slideRight} + className={cn( + "flex items-center invisible cursor-pointer hover:text-action absolute right-0 bg-gradient-to-l from-[--background] z-40 h-full w-16 hover:opacity-100", + { + "lg:visible": !isScrolledToRight && showChevronRight, + })} + > + <MdChevronRight className="w-7 h-7 stroke-2 mx-auto"/> + </div> + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/square-bg.tsx b/seanime-2.9.10/seanime-web/src/components/shared/square-bg.tsx new file mode 100644 index 0000000..60639d3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/square-bg.tsx @@ -0,0 +1,174 @@ +import React, { useEffect, useRef } from "react" +import { cn } from "../ui/core/styling" + +type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern; + +interface GridOffset { + x: number; + y: number; +} + +interface SquaresProps { + direction?: "diagonal" | "up" | "right" | "down" | "left"; + speed?: number; + borderColor?: CanvasStrokeStyle; + squareSize?: number; + hoverFillColor?: CanvasStrokeStyle; + className?: string; +} + +const Squares: React.FC<SquaresProps> = ({ + direction = "diagonal", + speed = 0.4, + borderColor = "#2b2b2b", + squareSize = 40, + hoverFillColor = "#1f1f1f", + className = "", +}) => { + const canvasRef = useRef<HTMLCanvasElement>(null) + const requestRef = useRef<number | null>(null) + const numSquaresX = useRef<number>(0) + const numSquaresY = useRef<number>(0) + const gridOffset = useRef<GridOffset>({ x: 0, y: 0 }) + const hoveredSquareRef = useRef<GridOffset | null>(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext("2d") + + const resizeCanvas = () => { + canvas.width = canvas.offsetWidth + canvas.height = canvas.offsetHeight + numSquaresX.current = Math.ceil(canvas.width / squareSize) + 1 + numSquaresY.current = Math.ceil(canvas.height / squareSize) + 1 + } + + window.addEventListener("resize", resizeCanvas) + resizeCanvas() + + const drawGrid = () => { + if (!ctx) return + + ctx.clearRect(0, 0, canvas.width, canvas.height) + + const startX = Math.floor(gridOffset.current.x / squareSize) * squareSize + const startY = Math.floor(gridOffset.current.y / squareSize) * squareSize + + for (let x = startX; x < canvas.width + squareSize; x += squareSize) { + for (let y = startY; y < canvas.height + squareSize; y += squareSize) { + const squareX = x - (gridOffset.current.x % squareSize) + const squareY = y - (gridOffset.current.y % squareSize) + + if ( + hoveredSquareRef.current && + Math.floor((x - startX) / squareSize) === + hoveredSquareRef.current.x && + Math.floor((y - startY) / squareSize) === hoveredSquareRef.current.y + ) { + ctx.fillStyle = hoverFillColor + ctx.fillRect(squareX, squareY, squareSize, squareSize) + } + + ctx.strokeStyle = borderColor + ctx.strokeRect(squareX, squareY, squareSize, squareSize) + } + } + + const gradient = ctx.createRadialGradient( + canvas.width / 2, + canvas.height / 2, + 0, + canvas.width / 2, + canvas.height / 2, + Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2, + ) + gradient.addColorStop(0, "rgba(0, 0, 0, 0)") + gradient.addColorStop(1, "#060606") + + ctx.fillStyle = gradient + ctx.fillRect(0, 0, canvas.width, canvas.height) + } + + const updateAnimation = () => { + const effectiveSpeed = Math.max(speed, 0.1) + switch (direction) { + case "right": + gridOffset.current.x = + (gridOffset.current.x - effectiveSpeed + squareSize) % squareSize + break + case "left": + gridOffset.current.x = + (gridOffset.current.x + effectiveSpeed + squareSize) % squareSize + break + case "up": + gridOffset.current.y = + (gridOffset.current.y + effectiveSpeed + squareSize) % squareSize + break + case "down": + gridOffset.current.y = + (gridOffset.current.y - effectiveSpeed + squareSize) % squareSize + break + case "diagonal": + gridOffset.current.x = + (gridOffset.current.x - effectiveSpeed + squareSize) % squareSize + gridOffset.current.y = + (gridOffset.current.y - effectiveSpeed + squareSize) % squareSize + break + default: + break + } + + drawGrid() + requestRef.current = requestAnimationFrame(updateAnimation) + } + + const handleMouseMove = (event: MouseEvent) => { + const rect = canvas.getBoundingClientRect() + const mouseX = event.clientX - rect.left + const mouseY = event.clientY - rect.top + + const startX = Math.floor(gridOffset.current.x / squareSize) * squareSize + const startY = Math.floor(gridOffset.current.y / squareSize) * squareSize + + const hoveredSquareX = Math.floor( + (mouseX + gridOffset.current.x - startX) / squareSize, + ) + const hoveredSquareY = Math.floor( + (mouseY + gridOffset.current.y - startY) / squareSize, + ) + + if ( + !hoveredSquareRef.current || + hoveredSquareRef.current.x !== hoveredSquareX || + hoveredSquareRef.current.y !== hoveredSquareY + ) { + hoveredSquareRef.current = { x: hoveredSquareX, y: hoveredSquareY } + } + } + + const handleMouseLeave = () => { + hoveredSquareRef.current = null + } + + canvas.addEventListener("mousemove", handleMouseMove) + canvas.addEventListener("mouseleave", handleMouseLeave) + requestRef.current = requestAnimationFrame(updateAnimation) + + return () => { + window.removeEventListener("resize", resizeCanvas) + if (requestRef.current) cancelAnimationFrame(requestRef.current) + canvas.removeEventListener("mousemove", handleMouseMove) + canvas.removeEventListener("mouseleave", handleMouseLeave) + } + }, [direction, speed, borderColor, hoverFillColor, squareSize]) + + return ( + <canvas + ref={canvasRef} + className={cn("w-full h-full border-none block", className)} + ></canvas> + ) +} + +export default Squares diff --git a/seanime-2.9.10/seanime-web/src/components/shared/text-generate-effect.tsx b/seanime-2.9.10/seanime-web/src/components/shared/text-generate-effect.tsx new file mode 100644 index 0000000..1e428c9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/text-generate-effect.tsx @@ -0,0 +1,53 @@ +import { cn } from "@/components/ui/core/styling" +import { motion, stagger, useAnimate } from "motion/react" +import React, { useEffect } from "react" + +export const TextGenerateEffect = ({ + words, + className, + style, + ...rest +}: { + words: string; + className?: string; + style?: any +} & React.HTMLAttributes<HTMLDivElement>) => { + const [scope, animate] = useAnimate() + let wordsArray = words.split(" ") + + useEffect(() => { + animate( + "span", + { + opacity: 1, + }, + { + duration: 2, + delay: stagger(0.2), + }, + ) + }, [words]) + + const renderWords = () => { + return ( + <motion.div ref={scope}> + {wordsArray.map((word, idx) => { + return ( + <motion.span + key={word + idx} + className="opacity-0" + > + {word}{" "} + </motion.span> + ) + })} + </motion.div> + ) + } + + return ( + <div className={cn("font-bold", className)} style={style} {...rest}> + {renderWords()} + </div> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/components/shared/vidstack.tsx b/seanime-2.9.10/seanime-web/src/components/shared/vidstack.tsx new file mode 100644 index 0000000..fc79e1e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/shared/vidstack.tsx @@ -0,0 +1,47 @@ +import { defaultLayoutIcons } from "@vidstack/react/player/layouts/default" +import { LuCast, LuVolume1, LuVolume2, LuVolumeX } from "react-icons/lu" +import { + RiClosedCaptioningFill, + RiClosedCaptioningLine, + RiFullscreenExitLine, + RiFullscreenLine, + RiPauseLargeLine, + RiPictureInPictureExitLine, + RiPictureInPictureLine, + RiPlayLargeLine, + RiResetLeftFill, + RiSettings4Line, +} from "react-icons/ri" + +export const vidstackLayoutIcons = { + ...defaultLayoutIcons, + PlayButton: { + Play: RiPlayLargeLine, + Pause: RiPauseLargeLine, + Replay: RiResetLeftFill, + }, + MuteButton: { + Mute: LuVolumeX, + VolumeLow: LuVolume1, + VolumeHigh: LuVolume2, + }, + GoogleCastButton: { + Default: LuCast, + }, + PIPButton: { + Enter: RiPictureInPictureLine, + Exit: RiPictureInPictureExitLine, + }, + FullscreenButton: { + Enter: RiFullscreenLine, + Exit: RiFullscreenExitLine, + }, + Menu: { + ...defaultLayoutIcons["Menu"], + Settings: RiSettings4Line, + }, + CaptionButton: { + On: RiClosedCaptioningFill, + Off: RiClosedCaptioningLine, + }, +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/accordion/accordion.tsx b/seanime-2.9.10/seanime-web/src/components/ui/accordion/accordion.tsx new file mode 100644 index 0000000..f62005b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/accordion/accordion.tsx @@ -0,0 +1,202 @@ +"use client" + +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const AccordionAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Accordion__root", + ]), + header: cva([ + "UI-Accordion__header", + "flex text-lg", + ]), + trigger: cva([ + "UI-Accordion__trigger", + "flex flex-1 items-center justify-between px-4 py-2 font-medium transition-all hover:bg-[--subtle] [&[data-state=open]>svg]:rotate-180", + ]), + triggerIcon: cva([ + "UI-Accordion__triggerIcon", + "h-4 w-4 shrink-0 transition-transform duration-200", + ]), + item: cva([ + "UI-Accordion__item", + "", + ]), + contentContainer: cva([ + "UI-Accordion__contentContainer", + "overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down", + ]), + content: cva([ + "UI-Accordion__content", + "p-4", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Accordion + * -----------------------------------------------------------------------------------------------*/ + +const __AccordionAnatomyContext = React.createContext<ComponentAnatomy<typeof AccordionAnatomy>>({}) + +export type AccordionProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root> & ComponentAnatomy<typeof AccordionAnatomy> + +export const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>((props, ref) => { + + const { + className, + headerClass, + triggerClass, + triggerIconClass, + contentContainerClass, + contentClass, + itemClass, + ...rest + } = props + + return ( + <__AccordionAnatomyContext.Provider + value={{ + itemClass, + headerClass, + triggerClass, + triggerIconClass, + contentContainerClass, + contentClass, + }} + > + <AccordionPrimitive.Root + ref={ref} + className={cn(AccordionAnatomy.root(), className)} + {...rest} + /> + </__AccordionAnatomyContext.Provider> + ) + +}) + +Accordion.displayName = "Accordion" + +/* ------------------------------------------------------------------------------------------------- + * AccordionItem + * -----------------------------------------------------------------------------------------------*/ + +export type AccordionItemProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> + +export const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>((props, ref) => { + + const { className, ...rest } = props + + const { itemClass } = React.useContext(__AccordionAnatomyContext) + + return ( + <AccordionPrimitive.Item + ref={ref} + className={cn(AccordionAnatomy.item(), itemClass, className)} + {...rest} + /> + ) + +}) + +AccordionItem.displayName = "AccordionItem" + +/* ------------------------------------------------------------------------------------------------- + * AccordionTrigger + * -----------------------------------------------------------------------------------------------*/ + +export type AccordionTriggerProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & + Pick<ComponentAnatomy<typeof AccordionAnatomy>, "headerClass" | "triggerIconClass"> + +export const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>((props, ref) => { + + const { + className, + headerClass, + triggerIconClass, + children, + ...rest + } = props + + const { + headerClass: _headerClass, + triggerClass: _triggerClass, + triggerIconClass: _triggerIconClass, + } = React.useContext(__AccordionAnatomyContext) + + return ( + <AccordionPrimitive.Header className={cn(AccordionAnatomy.header(), _headerClass, headerClass)}> + <AccordionPrimitive.Trigger + ref={ref} + className={cn( + AccordionAnatomy.trigger(), + _triggerClass, + className, + )} + {...rest} + > + {children} + <svg + className={cn(AccordionAnatomy.triggerIcon(), _triggerIconClass, triggerIconClass)} + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke="currentColor" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth="2" + d="M19 9l-7 7-7-7" + /> + </svg> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> + ) + +}) + +AccordionTrigger.displayName = "AccordionTrigger" + +/* ------------------------------------------------------------------------------------------------- + * AccordionContent + * -----------------------------------------------------------------------------------------------*/ + +export type AccordionContentProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> & + Pick<ComponentAnatomy<typeof AccordionAnatomy>, "contentContainerClass"> + +export const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>((props, ref) => { + + const { + className, + contentContainerClass, + children, + ...rest + } = props + + const { + contentContainerClass: _contentContainerClass, + contentClass: _contentClass, + } = React.useContext(__AccordionAnatomyContext) + + return ( + <AccordionPrimitive.Content + ref={ref} + className={cn(AccordionAnatomy.contentContainer(), _contentContainerClass, contentContainerClass)} + {...rest} + > + <div className={cn(AccordionAnatomy.content(), _contentClass, className)}> + {children} + </div> + </AccordionPrimitive.Content> + ) +}) + +AccordionContent.displayName = "AccordionContent" + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/accordion/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/accordion/index.tsx new file mode 100644 index 0000000..209440c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/accordion/index.tsx @@ -0,0 +1 @@ +export * from "./accordion" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/alert/alert.tsx b/seanime-2.9.10/seanime-web/src/components/ui/alert/alert.tsx new file mode 100644 index 0000000..96739f9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/alert/alert.tsx @@ -0,0 +1,230 @@ +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const AlertAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Alert__root", + "py-3 px-4 flex justify-between rounded-[--radius]", + ], { + variants: { + intent: { + "info": "bg-blue-50 text-blue-500 dark:bg-opacity-10 dark:text-blue-200", + "success": "bg-green-50 text-green-500 dark:bg-opacity-10 dark:text-green-200", + "warning": "bg-orange-50 text-orange-500 dark:bg-opacity-10 dark:text-orange-200", + "alert": "bg-red-50 text-red-500 dark:bg-opacity-10 dark:text-red-200", + "info-basic": "bg-white text-gray-800 border dark:bg-gray-800 dark:text-gray-200", + "success-basic": "bg-white text-gray-800 border dark:bg-gray-800 dark:text-gray-200", + "warning-basic": "bg-white text-gray-800 border dark:bg-gray-800 dark:text-gray-200", + "alert-basic": "bg-white text-gray-800 border dark:bg-gray-800 dark:text-gray-200", + }, + }, + defaultVariants: { + intent: "info", + }, + }), + detailsContainer: cva([ + "UI-Alert__detailsContainer", + "flex", + ]), + textContainer: cva([ + "UI-Alert__textContainer", + "flex flex-col self-center ml-3 gap-.5", + ]), + title: cva([ + "UI-Alert__title", + "font-bold", + ]), + description: cva([ + "UI-Alert__description", + ]), + icon: cva([ + "UI-Alert__icon", + "text-2xl content-evenly", + ], { + variants: { + intent: { + "info-basic": "text-blue-500", + "success-basic": "text-green-500", + "warning-basic": "text-orange-500", + "alert-basic": "text-red-500", + "info": "text-blue-500", + "success": "text-green-500", + "warning": "text-orange-500", + "alert": "text-red-500", + }, + }, + defaultVariants: { + intent: "info-basic", + }, + }), + closeButton: cva([ + "UI-Alert__closeButton", + "flex-none self-start text-2xl hover:opacity-50 transition ease-in cursor-pointer h-5 w-5", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Alert + * -----------------------------------------------------------------------------------------------*/ + +export type AlertProps = React.ComponentPropsWithRef<"div"> & + VariantProps<typeof AlertAnatomy.root> & + ComponentAnatomy<typeof AlertAnatomy> & { + /** + * The title of the alert + */ + title?: string, + /** + * The description text or content of the alert + */ + description?: React.ReactNode + /** + * Replace the default icon with a custom icon + * + * - `iconClass` does not apply to custom icons + */ + icon?: React.ReactNode + /** + * If true, a close button will be rendered + */ + isClosable?: boolean + /** + * Callback invoked when the close button is clicked + */ + onClose?: () => void +} + +export const Alert = React.forwardRef<HTMLDivElement, AlertProps>((props, ref) => { + + const { + children, + className, + title, + description, + isClosable, + onClose, + intent = "info-basic", + iconClass, + detailsContainerClass, + textContainerClass, + titleClass, + descriptionClass, + closeButtonClass, + icon, + ...rest + } = props + + let Icon: any = null + + if (intent === "info-basic" || intent === "info") { + Icon = <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <circle cx="12" cy="12" r="10"></circle> + <path d="M12 16v-4"></path> + <path d="M12 8h.01"></path> + </svg> + } else if (intent === "alert-basic" || intent === "alert") { + Icon = <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <circle cx="12" cy="12" r="10"></circle> + <line x1="12" x2="12" y1="8" y2="12"></line> + <line x1="12" x2="12.01" y1="16" y2="16"></line> + </svg> + } else if (intent === "warning-basic" || intent === "warning") { + Icon = <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path> + <line x1="12" x2="12" y1="9" y2="13"></line> + <line x1="12" x2="12.01" y1="17" y2="17"></line> + </svg> + } else if (intent === "success-basic" || intent === "success") { + Icon = <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path> + <path d="m9 12 2 2 4-4"></path> + </svg> + } + + return ( + <div + className={cn( + AlertAnatomy.root({ intent }), + className, + )} + {...rest} + ref={ref} + > + <div className={cn(AlertAnatomy.detailsContainer(), detailsContainerClass)}> + {icon ? icon : <div className={cn(AlertAnatomy.icon({ intent: intent }), iconClass)}> + {Icon && Icon} + </div>} + <div className={cn(AlertAnatomy.textContainer(), textContainerClass)}> + <span className={cn(AlertAnatomy.title(), titleClass)}> + {title} + </span> + {!!(description || children) && <div className={cn(AlertAnatomy.description(), descriptionClass)}> + {description || children} + </div>} + </div> + </div> + {onClose && <button className={cn(AlertAnatomy.closeButton(), closeButtonClass)} onClick={onClose}> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <line x1="18" x2="6" y1="6" y2="18"></line> + <line x1="6" x2="18" y1="6" y2="18"></line> + </svg> + </button>} + </div> + ) + +}) + +Alert.displayName = "Alert" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/alert/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/alert/index.tsx new file mode 100644 index 0000000..36e19d9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/alert/index.tsx @@ -0,0 +1 @@ +export * from "./alert" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/app-layout/app-layout.tsx b/seanime-2.9.10/seanime-web/src/components/ui/app-layout/app-layout.tsx new file mode 100644 index 0000000..4928863 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/app-layout/app-layout.tsx @@ -0,0 +1,365 @@ +"use client" +import { ElectronSidebarPaddingMacOS } from "@/app/(main)/_electron/electron-padding" +import { TauriSidebarPaddingMacOS } from "@/app/(main)/_tauri/tauri-padding" +import { __isDesktop__, __isElectronDesktop__, __isTauriDesktop__ } from "@/types/constants" +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { __AppSidebarContext } from "." +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const AppLayoutAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-AppLayout__root appLayout", + "flex w-full group/appLayout", + ], { + variants: { + withSidebar: { + true: "flex-row with-sidebar", + false: "flex-col", + }, + sidebarSize: { + slim: "sidebar-slim", + sm: "sidebar-sm", + md: "sidebar-md", + lg: "sidebar-lg", + xl: "sidebar-xl", + }, + }, + defaultVariants: { + withSidebar: false, + sidebarSize: "md", + }, + compoundVariants: [ + { withSidebar: true, sidebarSize: "slim", className: "lg:[&>.appLayout]:pl-20" }, + { withSidebar: true, sidebarSize: "sm", className: "lg:[&>.appLayout]:pl-48" }, + { withSidebar: true, sidebarSize: "md", className: "lg:[&>.appLayout]:pl-64" }, + { withSidebar: true, sidebarSize: "lg", className: "lg:[&>.appLayout]:pl-[20rem]" }, + { withSidebar: true, sidebarSize: "xl", className: "lg:[&>.appLayout]:pl-[25rem]" }, + ], + }), +}) + +export const AppLayoutHeaderAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-AppLayoutHeader__root", + "relative w-full", + ]), +}) + +export const AppLayoutSidebarAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-AppLayoutSidebar__root z-50", + "hidden lg:fixed lg:inset-y-0 lg:flex lg:flex-col grow-0 shrink-0 basis-0", + "group-[.sidebar-slim]/appLayout:w-20", + "group-[.sidebar-sm]/appLayout:w-48", + "group-[.sidebar-md]/appLayout:w-64", + "group-[.sidebar-lg]/appLayout:w-[20rem]", + "group-[.sidebar-xl]/appLayout:w-[25rem]", + ]), +}) + +export const AppLayoutContentAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-AppLayoutContent__root", + "relative", + ]), +}) + +export const AppLayoutFooterAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-AppLayoutFooter__root", + "relative", + ]), +}) + +export const AppLayoutStackAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-AppLayoutStack__root", + "relative", + ], { + variants: { + spacing: { + sm: "space-y-2", + md: "space-y-4", + lg: "space-y-8", + xl: "space-y-10", + }, + }, + defaultVariants: { + spacing: "md", + }, + }), +}) + +export const AppLayoutGridAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-AppLayoutGrid__root", + "relative flex flex-col", + ], { + variants: { + breakBelow: { + sm: "sm:grid sm:space-y-0", + md: "md:grid md:space-y-0", + lg: "lg:grid lg:space-y-0", + xl: "xl:grid xl:space-y-0", + }, + spacing: { + sm: "gap-2", + md: "gap-4", + lg: "gap-8", + xl: "gap-10", + }, + cols: { 1: null, 2: null, 3: null, 4: null, 5: null, 6: null }, + }, + defaultVariants: { + breakBelow: "xl", + spacing: "md", + cols: 3, + }, + compoundVariants: [ + { breakBelow: "sm", cols: 1, className: "sm:grid-cols-1" }, + { breakBelow: "sm", cols: 2, className: "sm:grid-cols-2" }, + { breakBelow: "sm", cols: 3, className: "sm:grid-cols-3" }, + { breakBelow: "sm", cols: 4, className: "sm:grid-cols-4" }, + { breakBelow: "sm", cols: 5, className: "sm:grid-cols-5" }, + { breakBelow: "sm", cols: 6, className: "sm:grid-cols-6" }, + { breakBelow: "md", cols: 1, className: "md:grid-cols-1" }, + { breakBelow: "md", cols: 2, className: "md:grid-cols-2" }, + { breakBelow: "md", cols: 3, className: "md:grid-cols-3" }, + { breakBelow: "md", cols: 4, className: "md:grid-cols-4" }, + { breakBelow: "md", cols: 5, className: "md:grid-cols-5" }, + { breakBelow: "md", cols: 6, className: "md:grid-cols-6" }, + { breakBelow: "lg", cols: 1, className: "lg:grid-cols-1" }, + { breakBelow: "lg", cols: 2, className: "lg:grid-cols-2" }, + { breakBelow: "lg", cols: 3, className: "lg:grid-cols-3" }, + { breakBelow: "lg", cols: 4, className: "lg:grid-cols-4" }, + { breakBelow: "lg", cols: 5, className: "lg:grid-cols-5" }, + { breakBelow: "lg", cols: 6, className: "lg:grid-cols-6" }, + { breakBelow: "xl", cols: 1, className: "xl:grid-cols-1" }, + { breakBelow: "xl", cols: 2, className: "xl:grid-cols-2" }, + { breakBelow: "xl", cols: 3, className: "xl:grid-cols-3" }, + { breakBelow: "xl", cols: 4, className: "xl:grid-cols-4" }, + { breakBelow: "xl", cols: 5, className: "xl:grid-cols-5" }, + { breakBelow: "xl", cols: 6, className: "xl:grid-cols-6" }, + ], + }), +}) + +/* ------------------------------------------------------------------------------------------------- + * AppLayout + * -----------------------------------------------------------------------------------------------*/ + +export type AppLayoutProps = React.ComponentPropsWithRef<"div"> & + ComponentAnatomy<typeof AppLayoutAnatomy> & + VariantProps<typeof AppLayoutAnatomy.root> + +export const AppLayout = React.forwardRef<HTMLDivElement, AppLayoutProps>((props, ref) => { + + const { + children, + className, + withSidebar = false, + sidebarSize, + ...rest + } = props + + const ctx = React.useContext(__AppSidebarContext) + + return ( + <div + ref={ref} + className={cn( + AppLayoutAnatomy.root({ withSidebar, sidebarSize: ctx.size || sidebarSize }), + __isDesktop__ && "pt-4 select-none", + className, + )} + {...rest} + > + {children} + </div> + ) + +}) + +AppLayout.displayName = "AppLayout" + +/* ------------------------------------------------------------------------------------------------- + * AppLayoutHeader + * -----------------------------------------------------------------------------------------------*/ + +export type AppLayoutHeaderProps = React.ComponentPropsWithRef<"header"> + +export const AppLayoutHeader = React.forwardRef<HTMLElement, AppLayoutHeaderProps>((props, ref) => { + + const { + children, + className, + ...rest + } = props + + return ( + <header + ref={ref} + className={cn(AppLayoutHeaderAnatomy.root(), className)} + {...rest} + > + {children} + </header> + ) + +}) + +AppLayoutHeader.displayName = "AppLayoutHeader" + +/* ------------------------------------------------------------------------------------------------- + * AppLayoutSidebar + * -----------------------------------------------------------------------------------------------*/ + +export type AppLayoutSidebarProps = React.ComponentPropsWithRef<"aside"> + +export const AppLayoutSidebar = React.forwardRef<HTMLElement, AppLayoutSidebarProps>((props, ref) => { + + const { + children, + className, + ...rest + } = props + + return ( + <aside + ref={ref} + className={cn(AppLayoutSidebarAnatomy.root(), className)} + {...rest} + > + {__isTauriDesktop__ && <TauriSidebarPaddingMacOS />} + {__isElectronDesktop__ && <ElectronSidebarPaddingMacOS />} + {children} + </aside> + ) + +}) + +AppLayoutSidebar.displayName = "AppLayoutSidebar" + +/* ------------------------------------------------------------------------------------------------- + * AppLayoutContent + * -----------------------------------------------------------------------------------------------*/ + +export type AppLayoutContentProps = React.ComponentPropsWithRef<"main"> + +export const AppLayoutContent = React.forwardRef<HTMLElement, AppLayoutContentProps>((props, ref) => { + + const { + children, + className, + ...rest + } = props + + return ( + <main + ref={ref} + className={cn(AppLayoutContentAnatomy.root(), className)} + {...rest} + > + {children} + </main> + ) + +}) + +AppLayoutContent.displayName = "AppLayoutContent" + +/* ------------------------------------------------------------------------------------------------- + * AppLayoutGrid + * -----------------------------------------------------------------------------------------------*/ + +export type AppLayoutGridProps = React.ComponentPropsWithRef<"section"> & + VariantProps<typeof AppLayoutGridAnatomy.root> + +export const AppLayoutGrid = React.forwardRef<HTMLElement, AppLayoutGridProps>((props, ref) => { + + const { + children, + className, + breakBelow, + cols, + spacing, + ...rest + } = props + + return ( + <section + ref={ref} + className={cn(AppLayoutGridAnatomy.root({ breakBelow, cols, spacing }), className)} + {...rest} + > + {children} + </section> + ) + +}) + +AppLayoutGrid.displayName = "AppLayoutGrid" + +/* ------------------------------------------------------------------------------------------------- + * AppLayoutFooter + * -----------------------------------------------------------------------------------------------*/ + +export type AppLayoutFooterProps = React.ComponentPropsWithRef<"footer"> + +export const AppLayoutFooter = React.forwardRef<HTMLElement, AppLayoutFooterProps>((props, ref) => { + + const { + children, + className, + ...rest + } = props + + return ( + <footer + ref={ref} + className={cn(AppLayoutFooterAnatomy.root(), className)} + {...rest} + > + {children} + </footer> + ) + +}) + +AppLayoutFooter.displayName = "AppLayoutFooter" + +/* ------------------------------------------------------------------------------------------------- + * AppLayoutStack + * -----------------------------------------------------------------------------------------------*/ + +export type AppLayoutStackProps = React.ComponentPropsWithRef<"div"> & + VariantProps<typeof AppLayoutStackAnatomy.root> + +export const AppLayoutStack = React.forwardRef<HTMLDivElement, AppLayoutStackProps>((props, ref) => { + + const { + children, + className, + spacing, + ...rest + } = props + + return ( + <div + ref={ref} + className={cn(AppLayoutStackAnatomy.root({ spacing }), className)} + {...rest} + > + {children} + </div> + ) + +}) + +AppLayoutStack.displayName = "AppLayoutStack" + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/app-layout/app-sidebar.tsx b/seanime-2.9.10/seanime-web/src/components/ui/app-layout/app-sidebar.tsx new file mode 100644 index 0000000..15406a9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/app-layout/app-sidebar.tsx @@ -0,0 +1,200 @@ +"use client" + +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { AppLayoutAnatomy } from "." +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { Drawer, DrawerProps } from "../drawer" + +/* ------------------------------------------------------------------------------------------------- + * Context + * -----------------------------------------------------------------------------------------------*/ + +export const __AppSidebarContext = React.createContext<{ + open: boolean, + setOpen: (open: boolean) => void, + size: VariantProps<typeof AppLayoutAnatomy.root>["sidebarSize"] + setSize: (size: VariantProps<typeof AppLayoutAnatomy.root>["sidebarSize"]) => void, + isBelowBreakpoint: boolean, +}>({ + open: false, + setOpen: () => {}, + setSize: () => {}, + size: "md", + isBelowBreakpoint: false, +}) + +export function useAppSidebarContext() { + const ctx = React.useContext(__AppSidebarContext) + if (!ctx) throw new Error("useAppSidebarContext must be used within a AppSidebarProvider") + return ctx +} + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const AppSidebarAnatomy = defineStyleAnatomy({ + sidebar: cva([ + "UI-AppSidebar__sidebar", + "flex flex-grow flex-col overflow-y-auto border-r border-transparent bg-[--background]", + ]), +}) + +export const AppSidebarTriggerAnatomy = defineStyleAnatomy({ + trigger: cva([ + "UI-AppSidebarTrigger__trigger", + "block lg:hidden", + "items-center justify-center rounded-[--radius] p-2 text-[--muted] hover:bg-[--subtle] hover:text-[--foreground] transition-colors", + "focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[--ring]", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * AppSidebar + * -----------------------------------------------------------------------------------------------*/ + +export type AppSidebarProps = React.ComponentPropsWithoutRef<"div"> & ComponentAnatomy<typeof AppSidebarAnatomy> & { + mobileDrawerProps?: Partial<DrawerProps> +} + +export const AppSidebar = React.forwardRef<HTMLDivElement, AppSidebarProps>((props, ref) => { + + const { + children, + className, + ...rest + } = props + + const ctx = React.useContext(__AppSidebarContext) + + return ( + <> + <div + ref={ref} + className={cn( + AppSidebarAnatomy.sidebar(), + // __isDesktop__ && "pt-4", + className, + )} + {...rest} + > + {children} + </div> + <Drawer + open={ctx.open} + onOpenChange={v => ctx.setOpen(v)} + side="left" + > + {children} + </Drawer> + </> + ) + +}) + +AppSidebar.displayName = "AppSidebar" + +/* ------------------------------------------------------------------------------------------------- + * AppSidebarTrigger + * -----------------------------------------------------------------------------------------------*/ + +export type AppSidebarTriggerProps = React.ComponentPropsWithoutRef<"button"> & ComponentAnatomy<typeof AppSidebarTriggerAnatomy> + +export const AppSidebarTrigger = React.forwardRef<HTMLButtonElement, AppSidebarTriggerProps>((props, ref) => { + + const { + children, + className, + ...rest + } = props + + const ctx = React.useContext(__AppSidebarContext) + + return ( + <button + ref={ref} + className={cn(AppSidebarTriggerAnatomy.trigger(), className)} + onClick={() => ctx.setOpen(!ctx.open)} + {...rest} + > + <span className="sr-only">Open main menu</span> + {ctx.open ? ( + <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="block h-6 w-6" + > + <line x1="18" x2="6" y1="6" y2="18"></line> + <line x1="6" x2="18" y1="6" y2="18"></line> + </svg> + ) : ( + <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="block h-6 w-6" + > + <line x1="4" x2="20" y1="12" y2="12"></line> + <line x1="4" x2="20" y1="6" y2="6"></line> + <line x1="4" x2="20" y1="18" y2="18"></line> + </svg> + )} + </button> + ) + +}) + +AppSidebarTrigger.displayName = "AppSidebarTrigger" + + +/* ------------------------------------------------------------------------------------------------- + * AppSidebarProvider + * -----------------------------------------------------------------------------------------------*/ + +export type AppSidebarProviderProps = { + children?: React.ReactNode, + open?: boolean, + onOpenChange?: (open: boolean) => void, + onSizeChange?: (size: VariantProps<typeof AppLayoutAnatomy.root>["sidebarSize"]) => void, +} + +export const AppSidebarProvider: React.FC<AppSidebarProviderProps> = ({ + children, + onOpenChange, + onSizeChange, +}) => { + + const [open, setOpen] = React.useState(false) + const [size, setSize] = React.useState<VariantProps<typeof AppLayoutAnatomy.root>["sidebarSize"]>(undefined) + + const [isBelowBreakpoint, setIsBelowBreakpoint] = React.useState<boolean>(false) + + React.useEffect(() => { + const handleResize = () => setIsBelowBreakpoint(window.innerWidth < 1024) // lg breakpoint + handleResize() + window.addEventListener("resize", handleResize) + return () => window.removeEventListener("resize", handleResize) + }, [isBelowBreakpoint]) + + return ( + <__AppSidebarContext.Provider + value={{ + open, + setOpen: (open: boolean) => { + onOpenChange?.(open) + setOpen(open) + }, + setSize: (size: VariantProps<typeof AppLayoutAnatomy.root>["sidebarSize"]) => { + onSizeChange?.(size) + setSize(size) + }, + size: size, + isBelowBreakpoint, + }} + > + {children} + </__AppSidebarContext.Provider> + ) +} + +AppSidebarProvider.displayName = "AppSidebarProvider" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/app-layout/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/app-layout/index.tsx new file mode 100644 index 0000000..7ed0823 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/app-layout/index.tsx @@ -0,0 +1,2 @@ +export * from "./app-layout" +export * from "./app-sidebar" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/autocomplete/autocomplete.tsx b/seanime-2.9.10/seanime-web/src/components/ui/autocomplete/autocomplete.tsx new file mode 100644 index 0000000..0034b51 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/autocomplete/autocomplete.tsx @@ -0,0 +1,402 @@ +"use client" + +import { cva } from "class-variance-authority" +import * as React from "react" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandProps } from "../command" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { mergeRefs } from "../core/utils" +import { extractInputPartProps, hiddenInputStyles, InputAddon, InputAnatomy, InputContainer, InputIcon, InputStyling } from "../input" +import { Popover } from "../popover" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const AutocompleteAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Autocomplete__root", + ]), + popover: cva([ + "UI-Autocomplete__popover", + "w-[--radix-popover-trigger-width] p-0", + ]), + checkIcon: cva([ + "UI-Autocomplete__checkIcon", + "h-4 w-4", + "data-[selected=true]:opacity-100 data-[selected=false]:opacity-0", + ]), + container: cva([ + "UI-Autocomplete__container", + "relative w-full", + ]), + command: cva([ + "UI-Autocomplete__command", + "focus-within:ring-2 ring-[--ring] transition", + ]), +}) + + +/* ------------------------------------------------------------------------------------------------- + * Autocomplete + * -----------------------------------------------------------------------------------------------*/ + +export type AutocompleteOption = { value: string | null, label: string } + +export type AutocompleteProps = Omit<React.ComponentPropsWithRef<"input">, "size" | "value" | "defaultValue"> & + BasicFieldOptions & + InputStyling & + ComponentAnatomy<typeof AutocompleteAnatomy> & { + /** + * The selected option + */ + value?: AutocompleteOption | undefined + /** + * Callback invoked when the value changes. + */ + onValueChange?: (value: { value: string | null, label: string } | undefined) => void + /** + * Callback invoked when the input text changes. + */ + onTextChange?: (value: string) => void + /** + * The autocompletion options. + */ + options: AutocompleteOption[] + /** + * The message to display when there are no options. + * + * If not provided, the options list will be hidden when there are no options. + */ + emptyMessage?: React.ReactNode + /** + * The placeholder of the input. + */ + placeholder?: string + /** + * Additional props to pass to the command component. + */ + commandProps?: CommandProps + /** + * Default value of the input when uncontrolled. + */ + defaultValue?: AutocompleteOption + /** + * If true, the options list will be filtered based on the input value. + * Set this to false if you want to filter the options yourself by listening to the `onTextChange` event. + * + * @default true + */ + autoFilter?: boolean + /** + * If true, a loading indicator will be displayed. + */ + isFetching?: boolean + /** + * The type of the autocomplete. + * + * - `custom`: Arbitrary values are allowed + * - `options`: Only values from the options list are allowed. Falls back to last valid option if the input value is not in the options list. + * + * @default "custom" + */ + type?: "custom" | "options" +} + +export const Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>((props, ref) => { + + const [props1, basicFieldProps] = extractBasicFieldProps<AutocompleteProps>(props, React.useId()) + + const [{ + size, + intent, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + className, + popoverClass, + checkIconClass, + containerClass, + commandClass, + /**/ + commandProps, + options, + emptyMessage, + placeholder, + value: controlledValue, + onValueChange, + onTextChange, + onChange, + defaultValue, + autoFilter = true, + isFetching, + type, + ...rest + }, { + inputContainerProps, + leftAddonProps, + leftIconProps, + rightAddonProps, + rightIconProps, + }] = extractInputPartProps<AutocompleteProps>({ + ...props1, + size: props1.size ?? "md", + intent: props1.intent ?? "basic", + leftAddon: props1.leftAddon, + leftIcon: props1.leftIcon, + rightAddon: props1.rightAddon, + rightIcon: props1.rightIcon, + }) + + const isFirst = React.useRef(true) + const isUpdating = React.useRef(false) + + const inputValueRef = React.useRef<string>(controlledValue?.label || defaultValue?.label || "") + const [inputValue, setInputValue] = React.useState<string>(controlledValue?.label || defaultValue?.label || "") + const deferredInputValue = React.useDeferredValue(inputValue) + inputValueRef.current = inputValue + + const optionsTypeValueRef = React.useRef<AutocompleteOption | undefined>(controlledValue || defaultValue || undefined) + const [value, setValue] = React.useState<AutocompleteOption | undefined>(controlledValue || defaultValue || undefined) + + const [open, setOpen] = React.useState(false) + + const filteredOptions = React.useMemo(() => { + if (autoFilter) { + return options.filter(option => option.label.toLowerCase().includes(deferredInputValue.toLowerCase())) + } + return options + }, [autoFilter, options, deferredInputValue]) + + // The options list should open when there are options or when there is an empty message + const _optionListShouldOpen = !!emptyMessage || (options.length > 0 && filteredOptions.length > 0) + + // Function used to compare two labels + const by = React.useCallback((a: string, b: string) => a.toLowerCase() === b.toLowerCase(), []) + + const inputRef = React.useRef<HTMLInputElement>(null) + const commandInputRef = React.useRef<HTMLInputElement>(null) + + // Update the input value when the controlled value changes + // Only when the default value is empty or when it is an updated value + React.useEffect(() => { + if (isUpdating.current) return + if (!defaultValue || !isFirst.current) { + setInputValue(controlledValue?.label ?? "") + setValue(controlledValue) + _updateOptionsTypeValueRef(controlledValue) + } + isFirst.current = false + }, [controlledValue]) + + const handleOnOpenChange = React.useCallback((opening: boolean) => { + // If the input is disabled or readonly, do not open the popover + if (basicFieldProps.disabled || basicFieldProps.readonly) return + // If there are no options and the popover is opening, do not open it + if (options.length === 0 && opening) return + // If the input value has not and there are no filtered options, do not open the popover + // This is to avoid a visual glitch when the popover opens but is empty + if (inputValueRef.current === inputValue && opening && filteredOptions.length === 0) return + + setOpen(opening) + if (!opening) { + React.startTransition(() => { + inputRef.current?.focus() + }) + } + }, [options, inputValue, basicFieldProps.disabled, basicFieldProps.readonly]) + + const handleOnTextInputChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => { + isUpdating.current = true + onChange?.(e) // Emit the change event + setInputValue(e.target.value) // Update the input value + + // Open the popover if there are filtered options + if (autoFilter && filteredOptions.length > 0) { + setOpen(true) + } + }, [filteredOptions]) + + React.useEffect(() => { + const v = deferredInputValue + + const _option = options.find(n => by(n.label, v)) + if (_option) { + handleUpdateValue(_option) + } else if (v.length > 0) { + handleUpdateValue({ value: null, label: v }) + } else if (v.length === 0) { + handleUpdateValue(undefined) + } + + isUpdating.current = false + }, [deferredInputValue, autoFilter]) + + // Called when an option is selected either by clicking on it or entering a valid value + const handleUpdateValue = React.useCallback((value: AutocompleteOption | undefined) => { + setValue(value) + onValueChange?.(value) + onTextChange?.(value?.label ?? "") + _updateOptionsTypeValueRef(value) + }, []) + + // Focus the command input when arrow down is pressed + const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => { + if (!open) { + setOpen(true) + } + if (e.key === "ArrowDown") { + e.preventDefault() + commandInputRef.current?.focus() + } + }, [open]) + + // Conditionally update the options type value ref when it is valid + const _updateOptionsTypeValueRef = React.useCallback((value: AutocompleteOption | undefined) => { + if (!!value?.value || value === undefined) { + optionsTypeValueRef.current = value + } + }, []) + + // If the type is `options`, make sure the value is always a valid option + // If the value entered doesn't match any option, fallback to the last valid option + const handleOptionsTypeOnBlur = React.useCallback(() => { + if (type === "options") { + React.startTransition(() => { + if (optionsTypeValueRef.current) { + setInputValue(optionsTypeValueRef.current.label) + } else { + setInputValue("") + } + }) + } + }, []) + + return ( + <BasicField {...basicFieldProps}> + <InputContainer {...inputContainerProps}> + <InputAddon {...leftAddonProps} /> + <InputIcon {...leftIconProps} /> + + <Popover + open={open && _optionListShouldOpen} + onOpenChange={handleOnOpenChange} + className={cn( + AutocompleteAnatomy.popover(), + popoverClass, + )} + onOpenAutoFocus={e => e.preventDefault()} + trigger={ + <div className={cn(AutocompleteAnatomy.container(), containerClass)}> + <input + ref={mergeRefs([inputRef, ref])} + id={basicFieldProps.id} + name={basicFieldProps.name} + value={inputValue} + onChange={handleOnTextInputChange} + onBlur={handleOptionsTypeOnBlur} + placeholder={placeholder} + className={cn( + InputAnatomy.root({ + size, + intent, + hasError: !!basicFieldProps.error, + isDisabled: !!basicFieldProps.disabled, + isReadonly: !!basicFieldProps.readonly, + hasRightAddon: !!rightAddon, + hasRightIcon: !!rightIcon, + hasLeftAddon: !!leftAddon, + hasLeftIcon: !!leftIcon, + }), + AutocompleteAnatomy.root(), + )} + disabled={basicFieldProps.disabled || basicFieldProps.readonly} + data-disabled={basicFieldProps.disabled} + data-error={!!basicFieldProps.error} + aria-readonly={basicFieldProps.readonly} + data-readonly={basicFieldProps.readonly} + onKeyDown={handleKeyDown} + required={basicFieldProps.required} + {...rest} + /> + </div> + } + > + <Command + className={cn(AutocompleteAnatomy.command(), commandClass)} + inputContainerClass="py-1" + shouldFilter={autoFilter} + {...commandProps} + > + {isFetching && inputValue.length > 0 && <div className="w-full absolute top-0 left-0 px-1"> + <div className="h-1 w-full bg-[--subtle] overflow-hidden relative rounded-full"> + <div className="animate-indeterminate-progress absolute left-0 w-full h-full bg-brand origin-left-right"></div> + </div> + </div>} + <CommandInput + value={inputValue} + onValueChange={setInputValue} + inputContainerClass={hiddenInputStyles} + aria-hidden="true" + ref={commandInputRef} + /> + <CommandList> + {!!emptyMessage && ( + <CommandEmpty>{emptyMessage}</CommandEmpty> + )} + <CommandGroup> + {options.map(option => ( + <CommandItem + key={option.value} + value={option.label} + onSelect={(currentValue) => { + const _option = options.find(n => by(n.label, currentValue)) + if (_option) { + if (value?.value === _option.value) { + handleUpdateValue(undefined) + setInputValue("") + } else { + handleUpdateValue(_option) + setInputValue(_option.label) + } + } + React.startTransition(() => { + inputRef.current?.focus() + }) + }} + leftIcon={ + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn( + AutocompleteAnatomy.checkIcon(), + checkIconClass, + )} + data-selected={by(option.label, inputValue)} + > + <path d="M20 6 9 17l-5-5" /> + </svg> + } + > + {option.label} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </Popover> + + <InputAddon {...rightAddonProps} /> + <InputIcon {...rightIconProps} /> + </InputContainer> + </BasicField> + ) +}) + +Autocomplete.displayName = "Autocomplete" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/autocomplete/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/autocomplete/index.tsx new file mode 100644 index 0000000..fc13cc9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/autocomplete/index.tsx @@ -0,0 +1 @@ +export * from "./autocomplete" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/avatar/avatar.tsx b/seanime-2.9.10/seanime-web/src/components/ui/avatar/avatar.tsx new file mode 100644 index 0000000..3b101d9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/avatar/avatar.tsx @@ -0,0 +1,106 @@ +"use client" + +import * as AvatarPrimitive from "@radix-ui/react-avatar" +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const AvatarAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Avatar__root", + "relative flex shrink-0 overflow-hidden rounded-full", + ], { + variants: { + size: { + xs: "h-6 w-6", + sm: "h-8 w-8", + md: "h-10 w-10", + lg: "h-14 w-14", + xl: "h-20 w-20", + }, + }, + defaultVariants: { + size: "md", + }, + }), + image: cva([ + "UI-Avatar__image", + "aspect-square h-full w-full", + ]), + fallback: cva([ + "UI-Avatar__fallback", + "flex h-full w-full items-center justify-center rounded-full bg-[--muted] text-white dark:text-gray-800 font-semibold", + ]), + fallbackIcon: cva([ + "UI-Avatar__fallback-icon", + "fill-transparent", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Avatar + * -----------------------------------------------------------------------------------------------*/ + +export type AvatarProps = + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & + ComponentAnatomy<typeof AvatarAnatomy> & + VariantProps<typeof AvatarAnatomy.root> & { + fallback?: React.ReactNode + imageRef?: React.Ref<HTMLImageElement> + fallbackRef?: React.Ref<HTMLSpanElement> +} + +export const Avatar = React.forwardRef<HTMLImageElement, AvatarProps>((props, ref) => { + const { + className, + children, + imageRef, + fallbackRef, + asChild, + imageClass, + fallbackClass, + fallback, + fallbackIconClass, + size, + ...rest + } = props + return ( + <AvatarPrimitive.Root + ref={ref} + className={cn(AvatarAnatomy.root({ size }), className)} + > + <AvatarPrimitive.Image + ref={imageRef} + className={cn(AvatarAnatomy.image(), imageClass)} + {...rest} + /> + <AvatarPrimitive.Fallback + ref={fallbackRef} + className={cn(AvatarAnatomy.fallback(), fallbackClass)} + > + {(!fallback) && + <svg + viewBox="0 0 128 128" className={cn(AvatarAnatomy.fallbackIcon(), fallbackIconClass)} + role="img" aria-label="avatar" + > + <path + fill="currentColor" + d="M103,102.1388 C93.094,111.92 79.3504,118 64.1638,118 C48.8056,118 34.9294,111.768 25,101.7892 L25,95.2 C25,86.8096 31.981,80 40.6,80 L87.4,80 C96.019,80 103,86.8096 103,95.2 L103,102.1388 Z" + ></path> + <path + fill="currentColor" + d="M63.9961647,24 C51.2938136,24 41,34.2938136 41,46.9961647 C41,59.7061864 51.2938136,70 63.9961647,70 C76.6985159,70 87,59.7061864 87,46.9961647 C87,34.2938136 76.6985159,24 63.9961647,24" + ></path> + </svg>} + {fallback} + </AvatarPrimitive.Fallback> + </AvatarPrimitive.Root> + ) +}) +Avatar.displayName = "Avatar" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/avatar/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/avatar/index.tsx new file mode 100644 index 0000000..de943ce --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/avatar/index.tsx @@ -0,0 +1 @@ +export * from "./avatar" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/badge/badge.tsx b/seanime-2.9.10/seanime-web/src/components/ui/badge/badge.tsx new file mode 100644 index 0000000..0ef9566 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/badge/badge.tsx @@ -0,0 +1,139 @@ +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const BadgeAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Badge__root", + "inline-flex flex-none text-base w-fit overflow-hidden justify-center items-center gap-2", + "group/badge", + ], { + variants: { + intent: { + "gray": "text-gray-800 bg-gray-100 border border-gray-500 border-opacity-40 dark:text-gray-300 dark:bg-opacity-10", + "primary": "text-indigo bg-indigo-50 border border-indigo-500 border-opacity-40 dark:text-indigo-300 dark:bg-opacity-10", + "success": "text-green bg-green-50 border border-green-500 border-opacity-40 dark:text-green-300 dark:bg-opacity-10", + "warning": "text-orange bg-orange-50 border border-orange-500 border-opacity-40 dark:text-orange-300 dark:bg-opacity-10", + "alert": "text-red bg-red-50 border border-red-500 border-opacity-40 dark:text-red-300 dark:bg-opacity-10", + "blue": "text-blue bg-blue-50 border border-blue-500 border-opacity-40 dark:text-blue-300 dark:bg-opacity-10", + "info": "text-blue bg-blue-50 border border-blue-500 border-opacity-40 dark:text-blue-300 dark:bg-opacity-10", + "white": "text-white bg-gray-800 border border-gray-500 border-opacity-40 dark:text-white dark:bg-opacity-10", + "basic": "text-gray-900 bg-transparent", + "primary-solid": "text-white bg-indigo-500", + "success-solid": "text-white bg-green-500", + "warning-solid": "text-white bg-orange-500", + "info-solid": "text-white bg-blue-500", + "alert-solid": "text-white bg-red-500", + "blue-solid": "text-white bg-blue-500", + "gray-solid": "text-white bg-gray-500", + "zinc-solid": "text-white bg-zinc-500", + "white-solid": "text-gray-900 bg-white", + "unstyled": "border text-gray-300", + }, + size: { + sm: "h-[1.2rem] px-1.5 text-xs", + md: "h-6 px-2 text-xs", + lg: "h-7 px-3 text-md", + xl: "h-8 px-4 text-lg", + }, + tag: { + false: "font-semibold tracking-wide rounded-full", + true: "font-semibold border-none rounded-[--radius]", + }, + }, + defaultVariants: { + intent: "gray", + size: "md", + tag: false, + }, + }), + closeButton: cva([ + "UI-Badge__close-button", + "appearance-none outline-none text-lg -mr-1 cursor-pointer transition ease-in hover:opacity-60", + "focus-visible:ring-2 focus-visible:ring-[--ring]", + ]), + icon: cva([ + "UI-Badge__icon", + "inline-flex self-center flex-shrink-0", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Badge + * -----------------------------------------------------------------------------------------------*/ + +export type BadgeProps = React.ComponentPropsWithRef<"span"> & + VariantProps<typeof BadgeAnatomy.root> & + ComponentAnatomy<typeof BadgeAnatomy> & { + /** + * If true, a close button will be rendered. + */ + isClosable?: boolean, + /** + * Callback invoked when the close button is clicked. + */ + onClose?: () => void, + /** + * The left icon element. + */ + leftIcon?: React.ReactElement + /** + * The right icon element. + */ + rightIcon?: React.ReactElement + /** + * The spacing between the icon and the badge content. + */ + iconSpacing?: React.CSSProperties["marginRight"] +} + +export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>((props, ref) => { + + const { + children, + className, + size, + intent, + tag = false, + isClosable, + onClose, + leftIcon, + rightIcon, + iconSpacing = "0", + closeButtonClass, + iconClass, + ...rest + } = props + + return ( + <span + ref={ref} + className={cn(BadgeAnatomy.root({ size, intent, tag }), className)} + {...rest} + > + {leftIcon && <span className={cn(BadgeAnatomy.icon(), iconClass)} style={{ marginRight: iconSpacing }}>{leftIcon}</span>} + + {children} + + {rightIcon && <span className={cn(BadgeAnatomy.icon(), iconClass)} style={{ marginLeft: iconSpacing }}>{rightIcon}</span>} + + {isClosable && <button className={cn(BadgeAnatomy.closeButton(), closeButtonClass)} onClick={onClose}> + <svg + xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" + fill="currentColor" + > + <path + d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" + ></path> + </svg> + </button>} + </span> + ) + +}) + +Badge.displayName = "Badge" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/badge/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/badge/index.tsx new file mode 100644 index 0000000..051fa6e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/badge/index.tsx @@ -0,0 +1 @@ +export * from "./badge" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/basic-field/basic-field.tsx b/seanime-2.9.10/seanime-web/src/components/ui/basic-field/basic-field.tsx new file mode 100644 index 0000000..db0313e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/basic-field/basic-field.tsx @@ -0,0 +1,188 @@ +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const BasicFieldAnatomy = defineStyleAnatomy({ + fieldLabel: cva([ + "UI-BasicField__fieldLabel", + "text-base w-fit font-semibold self-start", + "data-[error=true]:text-red-500", + ]), + fieldAsterisk: cva("UI-BasicField__fieldAsterisk ml-1 text-red-500 text-sm"), + fieldDetails: cva("UI-BasicField__fieldDetails"), + field: cva("UI-BasicField__field relative w-full space-y-1"), + fieldHelpText: cva("UI-BasicField__fieldHelpText text-sm text-[--muted]"), + fieldErrorText: cva("UI-BasicField__fieldErrorText text-sm text-red-500"), +}) + +/* ------------------------------------------------------------------------------------------------- + * BasicFieldOptions + * - Field components inherit these props + * -----------------------------------------------------------------------------------------------*/ + +export type BasicFieldOptions = ComponentAnatomy<typeof BasicFieldAnatomy> & { + /** + * The id of the field. If not provided, a unique id will be generated. + */ + id?: string | undefined + /** + * The form field name. + */ + name?: string + /** + * The label of the field. + */ + label?: React.ReactNode + /** + * Additional props to pass to the label element. + */ + labelProps?: React.LabelHTMLAttributes<HTMLLabelElement> + /** + * Help or description text to display below the field. + */ + help?: React.ReactNode + /** + * Error text to display below the field. + */ + error?: string + /** + * If `true`, the field will be required. + */ + required?: boolean + /** + * If `true`, the field will be disabled. + */ + disabled?: boolean + /** + * If `true`, the field will be readonly. + */ + readonly?: boolean +} + +/* ------------------------------------------------------------------------------------------------- + * Extract BasicFieldProps + * -----------------------------------------------------------------------------------------------*/ + +export function extractBasicFieldProps<Props extends BasicFieldOptions>(props: Props, id: string) { + const { + name, + label, + labelProps, + help, + error, + required, + disabled = false, + readonly = false, + fieldDetailsClass, + fieldLabelClass, + fieldAsteriskClass, + fieldClass, + fieldErrorTextClass, + fieldHelpTextClass, + id: _id, + ...rest + } = props + return [ + rest, + { + id: _id || id, + name, + label, + help, + error, + disabled, + required, + readonly, + fieldAsteriskClass, + fieldErrorTextClass, + fieldHelpTextClass, + fieldDetailsClass, + fieldLabelClass, + fieldClass, + labelProps, + }, + ] as [ + Omit<Props, + "label" | "name" | "help" | "error" | + "disabled" | "required" | "readonly" | + "fieldDetailsClass" | "fieldLabelClass" | "fieldClass" | "fieldHelpTextClass" | + "fieldErrorTextClass" | "id" | "labelProps" | "fieldAsteriskClass" + >, + Omit<BasicFieldOptions, "id"> & { + id: string + } + ] +} + +/* ------------------------------------------------------------------------------------------------- + * BasicField + * -----------------------------------------------------------------------------------------------*/ + +export type BasicFieldProps = React.ComponentPropsWithoutRef<"div"> & BasicFieldOptions + +export const BasicField = React.memo(React.forwardRef<HTMLDivElement, BasicFieldProps>((props, ref) => { + + const { + children, + className, + labelProps, + id, + label, + error, + help, + disabled, + readonly, + required, + fieldClass, + fieldDetailsClass, + fieldLabelClass, + fieldAsteriskClass, + fieldErrorTextClass, + fieldHelpTextClass, + ...rest + } = props + + return ( + <div + className={cn( + BasicFieldAnatomy.field(), + className, + fieldClass, + )} + {...rest} + ref={ref} + > + {!!label && + <label + htmlFor={disabled ? undefined : id} + className={cn(BasicFieldAnatomy.fieldLabel(), fieldLabelClass)} + data-error={!!error} + {...labelProps} + > + {label} + {required && + <span className={cn(BasicFieldAnatomy.fieldAsterisk(), fieldAsteriskClass)}>*</span> + } + </label> + } + + {children} + + {(!!help || !!error) && + <div className={cn(BasicFieldAnatomy.fieldDetails(), fieldDetailsClass)}> + {!!help && + <div className={cn(BasicFieldAnatomy.fieldHelpText(), fieldHelpTextClass)}>{help}</div>} + {!!error && + <div className={cn(BasicFieldAnatomy.fieldErrorText(), fieldErrorTextClass)}>{error}</div>} + </div> + } + </div> + ) + +})) + +BasicField.displayName = "BasicField" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/basic-field/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/basic-field/index.tsx new file mode 100644 index 0000000..b1c7d5c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/basic-field/index.tsx @@ -0,0 +1 @@ +export * from "./basic-field" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/breadcrumbs/breadcrumbs.tsx b/seanime-2.9.10/seanime-web/src/components/ui/breadcrumbs/breadcrumbs.tsx new file mode 100644 index 0000000..81f94d9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/breadcrumbs/breadcrumbs.tsx @@ -0,0 +1,136 @@ +"use client" + +import { SeaLink } from "@/components/shared/sea-link" +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const BreadcrumbsAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Breadcrumbs__root", + "flex", + ]), + list: cva([ + "UI-Breadcrumbs__list", + "flex items-center space-x-2", + ]), + chevronIcon: cva([ + "UI-Breadcrumbs__chevronIcon", + "h-5 w-5 flex-shrink-0 text-gray-400 mr-4", + ]), + item: cva([ + "UI-Breadcrumbs__item", + "flex items-center", + ]), + itemLink: cva([ + "UI-Breadcrumbs__itemLink", + "text-sm font-medium text-[--muted] hover:text-[--foreground]", + "data-[selected=true]:pointer-events-none data-[selected=true]:font-semibold data-[selected=true]:text-[--foreground]", // Selected + ]), + homeItem: cva([ + "UI-Breadcrumbs__homeItem", + "text-[--muted] hover:text-[--foreground]", + ]), + homeIcon: cva([ + "UI-Breadcrumbs__homeIcon", + "h-5 w-5 flex-shrink-0", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Breadcrumbs + * -----------------------------------------------------------------------------------------------*/ + +export type BreadcrumbsOption = { name: string, href: string | null | undefined, isCurrent: boolean } + +export type BreadcrumbsProps = React.ComponentPropsWithRef<"nav"> & + ComponentAnatomy<typeof BreadcrumbsAnatomy> & { + rootHref?: string + items: BreadcrumbsOption[] + showHomeButton?: boolean + homeIcon?: React.ReactElement +} + +export const Breadcrumbs = React.forwardRef<HTMLElement, BreadcrumbsProps>((props, ref) => { + + const { + children, + listClass, + itemClass, + itemLinkClass, + chevronIconClass, + homeIconClass, + homeItemClass, + className, + items, + rootHref = "/", + showHomeButton = true, + homeIcon, + ...rest + } = props + + return ( + <nav + className={cn(BreadcrumbsAnatomy.root(), className)} + {...rest} + ref={ref} + > + <ol role="list" className={cn(BreadcrumbsAnatomy.list(), listClass)}> + {showHomeButton && + <li> + <div> + <SeaLink + href={rootHref} + className={cn(BreadcrumbsAnatomy.homeItem(), homeItemClass)} + > + {homeIcon ? homeIcon : + <svg + xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" + strokeWidth="2" stroke="currentColor" + className={cn(BreadcrumbsAnatomy.homeIcon(), homeIconClass)} + > + <path + strokeLinecap="round" strokeLinejoin="round" + d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" + /> + </svg>} + </SeaLink> + </div> + </li> + } + {items.map((page, idx) => ( + <li key={page.name}> + <div className={cn(BreadcrumbsAnatomy.item(), itemClass)}> + {(!showHomeButton && idx > 0 || showHomeButton) && + <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className={cn(BreadcrumbsAnatomy.chevronIcon(), chevronIconClass)} + > + <polyline points="9 18 15 12 9 6"></polyline> + </svg> + } + <SeaLink + href={page.href ?? "#"} + className={cn(BreadcrumbsAnatomy.itemLink(), itemLinkClass)} + data-selected={page.isCurrent} + aria-current={page.isCurrent ? "page" : undefined} + > + {page.name} + </SeaLink> + </div> + </li> + ))} + </ol> + </nav> + ) + +}) + +Breadcrumbs.displayName = "Breadcrumbs" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/breadcrumbs/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/breadcrumbs/index.tsx new file mode 100644 index 0000000..82f4b74 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/breadcrumbs/index.tsx @@ -0,0 +1 @@ +export * from "./breadcrumbs" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/button/button.tsx b/seanime-2.9.10/seanime-web/src/components/ui/button/button.tsx new file mode 100644 index 0000000..21ed2c3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/button/button.tsx @@ -0,0 +1,184 @@ +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const ButtonAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Button_root", + "shadow-sm whitespace-nowrap font-semibold rounded-[--radius]", + "inline-flex items-center text-white transition ease-in text-center text-base justify-center", + "focus-visible:outline-none focus-visible:ring-2 ring-offset-1 ring-offset-[--background] focus-visible:ring-[--ring]", + "disabled:opacity-50 disabled:pointer-events-none", + ], { + variants: { + intent: { + "primary": "bg-brand-500 hover:bg-brand-600 active:bg-brand-700 border border-transparent", + "primary-outline": "text-[--brand] border border-[--brand] bg-transparent hover:bg-brand-500 active:bg-brand-600 active:border-transparent hover:text-white dark:hover:border-brand-500 dark:active:bg-brand-600 dark:hover:text-white dark:active:border-transparent dark:active:text-white", + "primary-subtle": "shadow-none text-[--brand] border bg-brand-50 border-transparent hover:bg-brand-100 active:bg-brand-200 dark:bg-opacity-10 dark:hover:bg-opacity-20", + "primary-link": "shadow-none text-[--brand] border border-transparent bg-transparent hover:underline active:text-brand-700 dark:active:text-brand-300", + "primary-basic": "shadow-none text-[--brand] border border-transparent bg-transparent hover:bg-brand-100 active:bg-brand-200 dark:hover:bg-opacity-10 dark:active:text-brand-300", + + "warning": "bg-orange-500 hover:bg-orange-600 active:bg-orange-700 border border-transparent", + "warning-outline": "text-[--orange] border border-[--orange] bg-transparent hover:bg-orange-500 active:bg-orange-600 active:border-transparent hover:text-white dark:hover:border-orange-500 dark:active:bg-orange-600 dark:hover:text-white dark:active:border-transparent dark:active:text-white", + "warning-subtle": "shadow-none text-[--orange] border bg-orange-50 border-transparent hover:bg-orange-100 active:bg-orange-200 dark:bg-opacity-10 dark:hover:bg-opacity-20", + "warning-link": "shadow-none text-[--orange] border border-transparent bg-transparent hover:underline active:text-orange-700 dark:active:text-orange-300", + "warning-basic": "shadow-none text-[--orange] border border-transparent bg-transparent hover:bg-orange-100 active:bg-orange-200 dark:hover:bg-opacity-10 dark:active:text-orange-300", + + "success": "bg-green-500 hover:bg-green-600 active:bg-green-700 border border-transparent", + "success-outline": "text-[--green] border border-[--green] bg-transparent hover:bg-green-500 active:bg-green-600 active:border-transparent hover:text-white dark:hover:border-green-500 dark:active:bg-green-600 dark:hover:text-white dark:active:border-transparent dark:active:text-white", + "success-subtle": "shadow-none text-[--green] border bg-green-50 border-transparent hover:bg-green-100 active:bg-green-200 dark:bg-opacity-10 dark:hover:bg-opacity-20", + "success-link": "shadow-none text-[--green] border border-transparent bg-transparent hover:underline active:text-green-700 dark:active:text-green-300", + "success-basic": "shadow-none text-[--green] border border-transparent bg-transparent hover:bg-green-100 active:bg-green-200 dark:hover:bg-opacity-10 dark:active:text-green-300", + + "alert": "bg-red-500 hover:bg-red-600 active:bg-red-700 border border-transparent", + "alert-outline": "text-[--red] border border-[--red] bg-transparent hover:bg-red-500 active:bg-red-600 active:border-transparent hover:text-white dark:hover:border-red-500 dark:active:bg-red-600 dark:hover:text-white dark:active:border-transparent dark:active:text-white", + "alert-subtle": "shadow-none text-[--red] border bg-red-50 border-transparent hover:bg-red-100 active:bg-red-200 dark:bg-opacity-10 dark:hover:bg-opacity-20", + "alert-link": "shadow-none text-[--red] border border-transparent bg-transparent hover:underline active:text-red-700 dark:active:text-red-300", + "alert-basic": "shadow-none text-[--red] border border-transparent bg-transparent hover:bg-red-100 active:bg-red-200 dark:hover:bg-opacity-10 dark:active:text-red-300", + + "gray": "bg-gray-500 hover:bg-gray-600 active:bg-gray-700 border border-transparent", + "gray-outline": "text-gray-600 border bg-transparent hover:bg-gray-100 active:border-transparent active:bg-gray-200 dark:text-gray-300 dark: dark:hover:bg-gray-800 dark:hover:bg-opacity-50 dark:active:bg-gray-700 dark:active:border-transparent dark:hover:text-gray-100", + "gray-subtle": "shadow-none text-[--gray] border bg-gray-100 border-transparent hover:bg-gray-200 active:bg-gray-300 dark:text-gray-300 dark:bg-opacity-10 dark:hover:bg-opacity-20", + "gray-link": "shadow-none text-[--gray] border border-transparent bg-transparent hover:underline active:text-gray-700 dark:text-gray-300 dark:active:text-gray-200", + "gray-basic": "shadow-none text-[--gray] border border-transparent bg-transparent hover:bg-gray-100 active:bg-gray-200 dark:active:bg-opacity-20 dark:text-gray-200 dark:hover:bg-opacity-10 dark:active:text-gray-200", + + "white": "text-[#000] bg-white hover:bg-gray-200 active:bg-gray-300 border border-transparent", + "white-outline": "text-white border border-gray-200 bg-transparent hover:bg-white hover:text-black active:bg-gray-100 active:text-[#000]", + "white-subtle": "shadow-none text-white bg-white bg-opacity-15 hover:bg-opacity-20 border border-transparent active:bg-opacity-25", + "white-link": "shadow-none text-white border border-transparent bg-transparent hover:underline active:text-gray-200", + "white-basic": "shadow-none text-white border border-transparent bg-transparent hover:bg-white hover:bg-opacity-15 active:bg-opacity-20 active:text-white-300", + }, + rounded: { + true: "rounded-full", + false: null, + }, + contentWidth: { + true: "w-fit", + false: null, + }, + size: { + xs: "text-sm h-6 px-2", + sm: "text-sm h-8 px-3", + md: "text-sm h-10 px-4", + lg: "h-12 px-6 text-lg", + xl: "text-2xl h-14 px-8", + }, + }, + defaultVariants: { + intent: "primary", + size: "md", + }, + }), + icon: cva([ + "UI-Button__icon", + "inline-flex self-center flex-shrink-0", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Button + * -----------------------------------------------------------------------------------------------*/ + + +export type ButtonProps = React.ComponentPropsWithoutRef<"button"> & + VariantProps<typeof ButtonAnatomy.root> & + ComponentAnatomy<typeof ButtonAnatomy> & { + loading?: boolean, + leftIcon?: React.ReactNode + rightIcon?: React.ReactNode + iconSpacing?: React.CSSProperties["marginInline"] + hideTextOnSmallScreen?: boolean +} + +export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => { + + const { + children, + size, + className, + rounded = false, + contentWidth = false, + intent, + leftIcon, + rightIcon, + iconSpacing = "0.5rem", + loading, + iconClass, + disabled, + hideTextOnSmallScreen, + ...rest + } = props + + return ( + <button + type="button" + className={cn( + ButtonAnatomy.root({ + size, + intent, + rounded, + contentWidth, + }), + className, + )} + disabled={disabled || loading} + aria-disabled={disabled} + {...rest} + ref={ref} + > + {loading ? ( + <> + <svg + width="15" + height="15" + fill="currentColor" + className="animate-spin" + viewBox="0 0 1792 1792" + xmlns="http://www.w3.org/2000/svg" + style={{ marginInlineEnd: !hideTextOnSmallScreen ? iconSpacing : 0 }} + > + <path + d="M526 1394q0 53-37.5 90.5t-90.5 37.5q-52 0-90-38t-38-90q0-53 37.5-90.5t90.5-37.5 90.5 37.5 37.5 90.5zm498 206q0 53-37.5 90.5t-90.5 37.5-90.5-37.5-37.5-90.5 37.5-90.5 90.5-37.5 90.5 37.5 37.5 90.5zm-704-704q0 53-37.5 90.5t-90.5 37.5-90.5-37.5-37.5-90.5 37.5-90.5 90.5-37.5 90.5 37.5 37.5 90.5zm1202 498q0 52-38 90t-90 38q-53 0-90.5-37.5t-37.5-90.5 37.5-90.5 90.5-37.5 90.5 37.5 37.5 90.5zm-964-996q0 66-47 113t-113 47-113-47-47-113 47-113 113-47 113 47 47 113zm1170 498q0 53-37.5 90.5t-90.5 37.5-90.5-37.5-37.5-90.5 37.5-90.5 90.5-37.5 90.5 37.5 37.5 90.5zm-640-704q0 80-56 136t-136 56-136-56-56-136 56-136 136-56 136 56 56 136zm530 206q0 93-66 158.5t-158 65.5q-93 0-158.5-65.5t-65.5-158.5q0-92 65.5-158t158.5-66q92 0 158 66t66 158z" + > + </path> + </svg> + {children} + </> + ) : <> + {leftIcon && + <span + className={cn(ButtonAnatomy.icon(), iconClass)} + style={{ marginInlineEnd: !hideTextOnSmallScreen ? iconSpacing : 0 }} + > + {leftIcon} + </span>} + <span + className={cn( + hideTextOnSmallScreen && cn( + "hidden", + leftIcon && "pl-[0.5rem]", + rightIcon && "pr-[0.5rem]", + ), + "md:inline-block", + )} + > + {children} + </span> + {rightIcon && + <span + className={cn(ButtonAnatomy.icon(), iconClass)} + style={{ marginInlineStart: !hideTextOnSmallScreen ? iconSpacing : 0 }} + > + {rightIcon} + </span>} + </>} + </button> + ) + +}) + +Button.displayName = "Button" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/button/close-button.tsx b/seanime-2.9.10/seanime-web/src/components/ui/button/close-button.tsx new file mode 100644 index 0000000..af15e1b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/button/close-button.tsx @@ -0,0 +1,47 @@ +import * as React from "react" +import { IconButton, IconButtonProps } from "." +import { cn } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * CloseButton + * -----------------------------------------------------------------------------------------------*/ + +export type CloseButtonProps = Omit<IconButtonProps, "icon"> & { + icon?: React.ReactNode +} + +export const CloseButton = React.forwardRef<HTMLButtonElement, CloseButtonProps>((props, ref) => { + + const { + className, + icon, + ...rest + } = props + + return ( + <IconButton + type="button" + intent="gray-basic" + size="sm" + className={cn( + "rounded-full text-2xl flex-none", + className, + )} + icon={!icon ? <span> + <svg + xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" + fill="currentColor" + > + <path + d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" + ></path> + </svg> + </span> : icon} + {...rest} + ref={ref} + /> + ) + +}) + +CloseButton.displayName = "CloseButton" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/button/icon-button.tsx b/seanime-2.9.10/seanime-web/src/components/ui/button/icon-button.tsx new file mode 100644 index 0000000..362782d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/button/icon-button.tsx @@ -0,0 +1,64 @@ +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { Button, ButtonProps } from "." +import { cn, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const IconButtonAnatomy = defineStyleAnatomy({ + root: cva("UI-IconButton_root p-0 flex-none", { + variants: { + size: { + xs: "text-xl h-6 w-6", + sm: "text-xl h-8 w-8", + md: "text-2xl h-10 w-10", + lg: "text-3xl h-12 w-12", + xl: "text-4xl h-14 w-14", + }, + }, + defaultVariants: { + size: "md", + }, + }), +}) + +/* ------------------------------------------------------------------------------------------------- + * IconButton + * -----------------------------------------------------------------------------------------------*/ + + +export type IconButtonProps = Omit<ButtonProps, "leftIcon" | "rightIcon" | "iconSpacing" | "iconClass" | "children"> & + VariantProps<typeof IconButtonAnatomy.root> & { + icon?: React.ReactNode +} + +export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>((props, ref) => { + + const { + className, + icon, + size, + loading, + ...rest + } = props + + return ( + <Button + className={cn( + IconButtonAnatomy.root({ size }), + className, + )} + loading={loading} + iconSpacing="0" + {...rest} + ref={ref} + > + {!loading && icon} + </Button> + ) + +}) + +IconButton.displayName = "IconButton" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/button/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/button/index.tsx new file mode 100644 index 0000000..0176112 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/button/index.tsx @@ -0,0 +1,3 @@ +export * from "./button" +export * from "./close-button" +export * from "./icon-button" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/calendar/calendar.tsx b/seanime-2.9.10/seanime-web/src/components/ui/calendar/calendar.tsx new file mode 100644 index 0000000..ae1a6d7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/calendar/calendar.tsx @@ -0,0 +1,209 @@ +"use client" + +import { cva } from "class-variance-authority" +import * as React from "react" +import { DayPicker } from "react-day-picker" +import { ButtonAnatomy } from "../button" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const CalendarAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Calendar__root", + "p-3", + ]), + months: cva([ + "UI-Calendar__months", + "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + ]), + month: cva([ + "UI-Calendar__month", + "space-y-4", + ]), + caption: cva([ + "UI-Calendar__caption", + "flex justify-center pt-1 relative items-center", + ]), + captionLabel: cva([ + "UI-Calendar__captionLabel", + "text-sm font-medium", + ]), + nav: cva([ + "UI-Calendar__nav", + "space-x-1 flex items-center", + ]), + navButton: cva([ + "UI-Calendar__navButton", + ]), + navButtonPrevious: cva([ + "UI-Calendar__navButtonPrevious", + "absolute left-1", + ]), + navButtonNext: cva([ + "UI-Calendar__navButtonNext", + "absolute right-1", + ]), + table: cva([ + "UI-Calendar__table", + "w-full border-collapse space-y-1", + ]), + headRow: cva([ + "UI-Calendar__headRow", + "flex", + ]), + headCell: cva([ + "UI-Calendar__headCell", + "text-[--muted] rounded-[--radius] w-9 font-normal text-[0.8rem]", + ]), + row: cva([ + "UI-Calendar__row", + "flex w-full mt-2", + ]), + cell: cva([ + "UI-Calendar__cell", + "h-9 w-9 text-center text-sm p-0 relative", + "[&:has([aria-selected].day-range-end)]:rounded-r-[--radius]", + "[&:has([aria-selected].day-outside)]:bg-[--subtle]/50", + "[&:has([aria-selected])]:bg-[--subtle]", + "first:[&:has([aria-selected])]:rounded-l-[--radius]", + "last:[&:has([aria-selected])]:rounded-r-[--radius]", + "focus-within:relative focus-within:z-20", + ]), + day: cva([ + "UI-Calendar__day", + "h-9 w-9 p-0 font-normal aria-selected:opacity-100", + ]), + dayRangeEnd: cva([ + "UI-Calendar__dayRangeEnd", + "day-range-end", + ]), + daySelected: cva([ + "UI-Calendar__daySelected", + "bg-brand text-white hover:bg-brand hover:text-white", + "focus:bg-brand focus:text-white rounded-[--radius] font-semibold", + ]), + dayToday: cva([ + "UI-Calendar__dayToday", + "bg-[--subtle] text-[--foreground] rounded-[--radius]", + ]), + dayOutside: cva([ + "UI-Calendar__dayOutside", + "day-outside !text-[--muted] opacity-20", + "aria-selected:bg-transparent", + "aria-selected:opacity-30", + ]), + dayDisabled: cva([ + "UI-Calendar__dayDisabled", + "text-[--muted] opacity-30", + ]), + dayRangeMiddle: cva([ + "UI-Calendar__dayRangeMiddle", + "aria-selected:bg-[--subtle]", + "aria-selected:text-[--foreground]", + ]), + dayHidden: cva([ + "UI-Calendar__dayHidden", + "invisible", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Calendar + * -----------------------------------------------------------------------------------------------*/ + +export type CalendarProps = + React.ComponentProps<typeof DayPicker> & + ComponentAnatomy<typeof CalendarAnatomy> + +export function Calendar(props: CalendarProps) { + + const { + className, + classNames, + monthsClass, + monthClass, + captionClass, + captionLabelClass, + navClass, + navButtonClass, + navButtonPreviousClass, + navButtonNextClass, + tableClass, + headRowClass, + headCellClass, + rowClass, + cellClass, + dayClass, + dayRangeEndClass, + daySelectedClass, + dayTodayClass, + dayOutsideClass, + dayDisabledClass, + dayRangeMiddleClass, + dayHiddenClass, + ...rest + } = props + + return ( + <DayPicker + fixedWeeks + className={cn(CalendarAnatomy.root(), className)} + classNames={{ + months: cn(CalendarAnatomy.months(), monthsClass), + month: cn(CalendarAnatomy.month(), monthClass), + caption: cn(CalendarAnatomy.caption(), captionClass), + caption_label: cn(CalendarAnatomy.captionLabel(), captionLabelClass), + nav: cn(CalendarAnatomy.nav(), navClass), + nav_button: cn(CalendarAnatomy.navButton(), ButtonAnatomy.root({ size: "sm", intent: "gray-basic" }), navButtonClass), + nav_button_previous: cn(CalendarAnatomy.navButtonPrevious(), navButtonPreviousClass), + nav_button_next: cn(CalendarAnatomy.navButtonNext(), navButtonNextClass), + table: cn(CalendarAnatomy.table(), tableClass), + head_row: cn(CalendarAnatomy.headRow(), headRowClass), + head_cell: cn(CalendarAnatomy.headCell(), headCellClass), + row: cn(CalendarAnatomy.row(), rowClass), + cell: cn(CalendarAnatomy.cell(), cellClass), + day: cn(CalendarAnatomy.day(), dayClass), + day_range_end: cn(CalendarAnatomy.dayRangeEnd(), dayRangeEndClass), + day_selected: cn(CalendarAnatomy.daySelected(), daySelectedClass), + day_today: cn(CalendarAnatomy.dayToday(), dayTodayClass), + day_outside: cn(CalendarAnatomy.dayOutside(), dayOutsideClass), + day_disabled: cn(CalendarAnatomy.dayDisabled(), dayDisabledClass), + day_range_middle: cn(CalendarAnatomy.dayRangeMiddle(), dayRangeMiddleClass), + day_hidden: cn(CalendarAnatomy.dayHidden(), dayHiddenClass), + ...classNames, + }} + components={{ + IconLeft: ({ ...props }) => <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="w-4 h-4" + > + <path d="m15 18-6-6 6-6" /> + </svg>, + IconRight: ({ ...props }) => <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="rotate-180 w-4 h-4" + > + <path d="m15 18-6-6 6-6" /> + </svg>, + }} + {...rest} + /> + ) +} + +Calendar.displayName = "Calendar" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/calendar/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/calendar/index.tsx new file mode 100644 index 0000000..33dfcd8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/calendar/index.tsx @@ -0,0 +1 @@ +export * from "./calendar" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/card/card.tsx b/seanime-2.9.10/seanime-web/src/components/ui/card/card.tsx new file mode 100644 index 0000000..c204523 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/card/card.tsx @@ -0,0 +1,145 @@ +"use client" + +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const CardAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Card__root", + "rounded-[--radius] border bg-[--paper] shadow-sm", + ]), + header: cva([ + "UI-Card__header", + "flex flex-col space-y-1.5 p-4", + ]), + title: cva([ + "UI-Card__title", + "text-2xl font-semibold leading-none tracking-tight", + ]), + description: cva([ + "UI-Card__description", + "text-sm text-[--muted]", + ]), + content: cva([ + "UI-Card__content", + "p-4 pt-0", + ]), + footer: cva([ + "UI-Card__footer", + "flex items-center p-4 pt-0", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Card + * -----------------------------------------------------------------------------------------------*/ + +export type CardProps = React.ComponentPropsWithoutRef<"div"> + +export const Card = React.forwardRef<HTMLDivElement, CardProps>((props, ref) => { + const { className, ...rest } = props + return ( + <div + ref={ref} + className={cn(CardAnatomy.root(), className)} + {...rest} + /> + ) +}) +Card.displayName = "Card" + +/* ------------------------------------------------------------------------------------------------- + * CardHeader + * -----------------------------------------------------------------------------------------------*/ + +export type CardHeaderProps = React.ComponentPropsWithoutRef<"div"> + +export const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>((props, ref) => { + const { className, ...rest } = props + return ( + <div + ref={ref} + className={cn(CardAnatomy.header(), className)} + {...rest} + /> + ) +}) +CardHeader.displayName = "CardHeader" + +/* ------------------------------------------------------------------------------------------------- + * CardTitle + * -----------------------------------------------------------------------------------------------*/ + +export type CardTitleProps = React.ComponentPropsWithoutRef<"h3"> + +export const CardTitle = React.forwardRef<HTMLHeadingElement, CardTitleProps>((props, ref) => { + const { className, ...rest } = props + return ( + <h3 + ref={ref} + className={cn(CardAnatomy.title(), className)} + {...rest} + /> + ) +}) +CardTitle.displayName = "CardTitle" + +/* ------------------------------------------------------------------------------------------------- + * CardDescription + * -----------------------------------------------------------------------------------------------*/ + +export type CardDescriptionProps = React.ComponentPropsWithoutRef<"p"> + +export const CardDescription = React.forwardRef<HTMLParagraphElement, CardDescriptionProps>((props, ref) => { + const { className, ...rest } = props + return ( + <p + ref={ref} + className={cn(CardAnatomy.description(), className)} + {...rest} + /> + ) +}) +CardDescription.displayName = "CardDescription" + +/* ------------------------------------------------------------------------------------------------- + * CardContent + * -----------------------------------------------------------------------------------------------*/ + +export type CardContentProps = React.ComponentPropsWithoutRef<"div"> + +export const CardContent = React.forwardRef<HTMLDivElement, CardContentProps>((props, ref) => { + const { className, ...rest } = props + return ( + <div + ref={ref} + className={cn(CardAnatomy.content(), className)} + {...rest} + /> + ) +}) +CardContent.displayName = "CardContent" + +/* ------------------------------------------------------------------------------------------------- + * CardFooter + * -----------------------------------------------------------------------------------------------*/ + +export type CardFooterProps = React.ComponentPropsWithoutRef<"div"> + +export const CardFooter = React.forwardRef<HTMLDivElement, CardFooterProps>((props, ref) => { + const { className, ...rest } = props + return ( + <div + ref={ref} + className={cn(CardAnatomy.footer(), className)} + {...rest} + /> + ) +}) +CardFooter.displayName = "CardFooter" + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/card/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/card/index.tsx new file mode 100644 index 0000000..288c75f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/card/index.tsx @@ -0,0 +1 @@ +export * from "./card" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/carousel/carousel.tsx b/seanime-2.9.10/seanime-web/src/components/ui/carousel/carousel.tsx new file mode 100644 index 0000000..aa94b3e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/carousel/carousel.tsx @@ -0,0 +1,484 @@ +"use client" + +import { useThemeSettings } from "@/lib/theme/hooks" +import { cva } from "class-variance-authority" +import { EmblaCarouselType } from "embla-carousel" +import AutoScroll from "embla-carousel-autoplay" +import useEmblaCarousel, { UseEmblaCarouselType } from "embla-carousel-react" +import * as React from "react" +import { Button, ButtonProps, IconButton } from "../button" +import { cn, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const CarouselAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Carousel__root", + "relative", + ]), + content: cva([ + "UI-Carousel__content", + "overflow-hidden", + ]), + innerContent: cva([ + "UI-Carousel__innerContent", + "flex", + ], { + variants: { + gap: { none: null, sm: null, md: null, lg: null, xl: null }, + orientation: { horizontal: null, vertical: null }, + }, + compoundVariants: [ + { gap: "none", orientation: "horizontal", className: "ml-0" }, + { gap: "sm", orientation: "horizontal", className: "-ml-2" }, + { gap: "md", orientation: "horizontal", className: "-ml-4" }, + { gap: "lg", orientation: "horizontal", className: "-ml-6" }, + { gap: "xl", orientation: "horizontal", className: "-ml-8" }, + /**/ + { gap: "none", orientation: "vertical", className: "-mt-0 flex-col" }, + { gap: "sm", orientation: "vertical", className: "-mt-2 flex-col" }, + { gap: "md", orientation: "vertical", className: "-mt-4 flex-col" }, + { gap: "lg", orientation: "vertical", className: "-mt-6 flex-col" }, + { gap: "xl", orientation: "vertical", className: "-mt-8 flex-col" }, + ], + }), + item: cva([ + "UI-Carousel__item", + "min-w-0 shrink-0 grow-0 basis-full", + ], { + variants: { + gap: { none: null, sm: null, md: null, lg: null, xl: null }, + orientation: { horizontal: null, vertical: null }, + }, + compoundVariants: [ + { gap: "none", orientation: "horizontal", className: "pl-0" }, + { gap: "sm", orientation: "horizontal", className: "pl-2" }, + { gap: "md", orientation: "horizontal", className: "pl-4" }, + { gap: "lg", orientation: "horizontal", className: "pl-6" }, + { gap: "xl", orientation: "horizontal", className: "pl-8" }, + /**/ + { gap: "none", orientation: "vertical", className: "pt-0" }, + { gap: "sm", orientation: "vertical", className: "pt-2" }, + { gap: "md", orientation: "vertical", className: "pt-4" }, + { gap: "lg", orientation: "vertical", className: "pt-6" }, + { gap: "xl", orientation: "vertical", className: "pt-8" }, + ], + }), + button: cva([ + "UI-Carousel__button", + "rounded-full z-[10]", + ], { + variants: { + placement: { previous: null, next: null }, + orientation: { horizontal: null, vertical: null }, + }, + compoundVariants: [ + { placement: "previous", orientation: "horizontal", className: "" }, + { placement: "next", orientation: "horizontal", className: "" }, + + { placement: "previous", orientation: "vertical", className: "-top-12 left-1/2 -translate-x-1/2 rotate-90" }, + { placement: "next", orientation: "vertical", className: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90" }, + ], + }), + chevronIcon: cva([ + "UI-Carousel__chevronIcon", + "size-6", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Carousel + * -----------------------------------------------------------------------------------------------*/ + +export const __CarouselContext = React.createContext<CarouselContextProps | null>(null) + +function useCarousel() { + const context = React.useContext(__CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a <Carousel />") + } + + return context +} + +export type CarouselApi = UseEmblaCarouselType[1] +export type UseCarouselParameters = Parameters<typeof useEmblaCarousel> +export type CarouselOptions = UseCarouselParameters[0] +export type CarouselPlugin = UseCarouselParameters[1] + + +export type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + gap?: "none" | "sm" | "md" | "lg" | "xl" + setApi?: (api: EmblaCarouselType) => void + autoScroll?: boolean + autoScrollDelay?: number +} + +type CarouselContextProps = { + carouselRef: ReturnType<typeof useEmblaCarousel>[0] + api: ReturnType<typeof useEmblaCarousel>[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +export const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>((props, ref) => { + + const { + orientation = "horizontal", + opts, + gap = "md", + setApi, + plugins, + className, + children, + autoScroll, + autoScrollDelay = 5000, + ...rest + } = props + + const ts = useThemeSettings() + + const _plugins = React.useMemo(() => { + return [ + ...(plugins || []), + ...((autoScroll && !ts.disableCarouselAutoScroll) ? [AutoScroll({ + delay: autoScrollDelay, + stopOnMouseEnter: true, + stopOnInteraction: false, + })] : []), + ] + }, [plugins, autoScroll, ts.disableCarouselAutoScroll]) + + const [carouselRef, api] = useEmblaCarousel({ ...opts, axis: orientation === "horizontal" ? "x" : "y" }, _plugins) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: EmblaCarouselType) => { + if (!api) return + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext], + ) + + React.useEffect(() => { + if (!api || !setApi) return + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) return + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + <__CarouselContext.Provider + value={{ + carouselRef, + api: api, + opts, + gap, + orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + }} + > + <div + ref={ref} + onKeyDownCapture={handleKeyDown} + className={cn(CarouselAnatomy.root(), className)} + role="region" + aria-roledescription="carousel" + {...rest} + > + {children} + <CarouselButtons /> + </div> + </__CarouselContext.Provider> + ) +}) +Carousel.displayName = "Carousel" + +type CarouselButtonsProps = { + children?: React.ReactNode +} + +export function CarouselButtons(props: CarouselButtonsProps) { + + const { + children, + ...rest + } = props + + const { scrollSnaps } = useDotButton() + + return ( + <> + {scrollSnaps.length > 30 && <div className="flex gap-2 absolute top-[-3.5rem] right-0"> + <CarouselPrevious /> + <CarouselNext /> + </div>} + </> + ) +} + + +/* ------------------------------------------------------------------------------------------------- + * CarouselContent + * -----------------------------------------------------------------------------------------------*/ + +export type CarouselContentProps = React.ComponentPropsWithoutRef<"div"> & { + contentClass?: string +} + +export const CarouselContent = React.forwardRef<HTMLDivElement, CarouselContentProps>((props, ref) => { + const { className, contentClass, ...rest } = props + const { carouselRef, orientation, gap } = useCarousel() + + return ( + <div ref={carouselRef} className={cn(CarouselAnatomy.content(), contentClass)}> + <div + ref={ref} + className={cn(CarouselAnatomy.innerContent({ orientation, gap }), className)} + {...rest} + /> + </div> + ) +}) +CarouselContent.displayName = "CarouselContent" + +/* ------------------------------------------------------------------------------------------------- + * CarouselItem + * -----------------------------------------------------------------------------------------------*/ + +export type CarouselItemProps = React.ComponentPropsWithoutRef<"div"> + +export const CarouselItem = React.forwardRef<HTMLDivElement, CarouselItemProps>((props, ref) => { + const { className, ...rest } = props + const { orientation, gap } = useCarousel() + + return ( + <div + ref={ref} + role="group" + aria-roledescription="slide" + className={cn(CarouselAnatomy.item({ orientation, gap }), className)} + {...rest} + /> + ) +}) +CarouselItem.displayName = "CarouselItem" + +/* ------------------------------------------------------------------------------------------------- + * CarouselPrevious + * -----------------------------------------------------------------------------------------------*/ + +export type CarouselButtonProps = React.ComponentProps<typeof IconButton> & { chevronIconClass?: string } + +export const CarouselPrevious = React.forwardRef<HTMLButtonElement, CarouselButtonProps>((props, ref) => { + const { className, chevronIconClass, intent = "gray-outline", ...rest } = props + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + <IconButton + ref={ref} + intent={intent} + className={CarouselAnatomy.button({ orientation, placement: "previous" })} + disabled={!canScrollPrev} + onClick={scrollPrev} + icon={<svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(CarouselAnatomy.chevronIcon(), chevronIconClass)} + > + <path d="m15 18-6-6 6-6" /> + </svg>} + {...rest} + /> + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +/* ------------------------------------------------------------------------------------------------- + * CarouselNext + * -----------------------------------------------------------------------------------------------*/ + +export const CarouselNext = React.forwardRef<HTMLButtonElement, CarouselButtonProps>((props, ref) => { + const { className, chevronIconClass, intent = "gray-outline", ...rest } = props + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + <IconButton + ref={ref} + intent={intent} + className={CarouselAnatomy.button({ orientation, placement: "next" })} + disabled={!canScrollNext} + onClick={scrollNext} + icon={<svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(CarouselAnatomy.chevronIcon(), chevronIconClass)} + > + <path d="m9 18 6-6-6-6" /> + </svg>} + {...rest} + /> + ) +}) +CarouselNext.displayName = "CarouselNext" + +/* ------------------------------------------------------------------------------------------------- + * + * -----------------------------------------------------------------------------------------------*/ + +type UseDotButtonType = { + selectedIndex: number + scrollSnaps: number[] + onDotButtonClick: (index: number) => void +} + + +export const useDotButton = (): UseDotButtonType => { + const { api: emblaApi } = useCarousel() + const [selectedIndex, setSelectedIndex] = React.useState(0) + const [scrollSnaps, setScrollSnaps] = React.useState<number[]>([]) + + const onDotButtonClick = React.useCallback( + (index: number) => { + if (!emblaApi) return + emblaApi.scrollTo(index) + }, + [emblaApi], + ) + + const onInit = React.useCallback((emblaApi: EmblaCarouselType) => { + setScrollSnaps(emblaApi.scrollSnapList()) + }, []) + + const onSelect = React.useCallback((emblaApi: EmblaCarouselType) => { + setSelectedIndex(emblaApi.selectedScrollSnap()) + }, []) + + React.useEffect(() => { + if (!emblaApi) return + + onInit(emblaApi) + onSelect(emblaApi) + emblaApi.on("reInit", onInit) + emblaApi.on("reInit", onSelect) + emblaApi.on("select", onSelect) + }, [emblaApi, onInit, onSelect]) + + return { + selectedIndex, + scrollSnaps, + onDotButtonClick, + } +} + +const DotButton = (props: ButtonProps) => { + const { children, className, ...rest } = props + + return ( + <Button + size="xs" + className={cn( + "rounded-full size-4 p-0 bg-gray-600 dark:bg-opacity-50", className, + )} + intent="gray-subtle" + {...rest} + > + {children} + </Button> + ) +} + + +export const CarouselDotButtons = (props: { className?: string, flag?: any }) => { + + const { selectedIndex, scrollSnaps, onDotButtonClick } = useDotButton() + + React.useEffect(() => { + onDotButtonClick(0) + }, [onDotButtonClick, scrollSnaps, props.flag]) + + if (scrollSnaps.length > 30) return null + + return ( + <div className={cn("absolute -top-8 right-0 hidden md:flex gap-2", props.className)}> + {scrollSnaps.map((_, index) => ( + <DotButton + key={index} + onClick={() => onDotButtonClick(index)} + className={cn( + { "bg-white": index === selectedIndex }, + { "size-4 [&:nth-child(odd)]:hidden": scrollSnaps.length > 30 }, + )} + /> + ))} + </div> + ) +} + + +export const CarouselMasks = () => { + + const ts = useThemeSettings() + + if (!!ts.libraryScreenCustomBackgroundImage) return null + + return ( + <> + <div className="absolute hidden md:block left-0 h-full w-8 bg-gradient-to-r from-[--background] to-transparent z-[1]" /> + <div className="absolute hidden md:block right-0 h-full w-8 bg-gradient-to-l from-[--background] to-transparent z-[1]" /> + </> + ) +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/carousel/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/carousel/index.tsx new file mode 100644 index 0000000..97d1eb3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/carousel/index.tsx @@ -0,0 +1 @@ +export * from "./carousel" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/charts/area-chart.tsx b/seanime-2.9.10/seanime-web/src/components/ui/charts/area-chart.tsx new file mode 100644 index 0000000..6e27c47 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/charts/area-chart.tsx @@ -0,0 +1,204 @@ +"use client" + +import * as React from "react" +import { Area, AreaChart as ReChartsAreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" +import type { AxisDomain } from "recharts/types/util/types" +import { cn } from "../core/styling" +import { ChartLegend } from "./chart-legend" +import { ChartTooltip } from "./chart-tooltip" +import { ColorPalette } from "./color-theme" +import { BaseChartProps, ChartCurveType } from "./types" +import { constructCategoryColors, defaultValueFormatter, getYAxisDomain } from "./utils" + +/* ------------------------------------------------------------------------------------------------- + * AreaChart + * -----------------------------------------------------------------------------------------------*/ + +export type AreaChartProps = React.ComponentPropsWithoutRef<"div"> & + BaseChartProps & { + /** + * Controls the visibility of the gradient. + * @default true + */ + showGradient?: boolean + /** + * If true, the areas will be stacked + * @default false + */ + stack?: boolean + /** + * The type of curve to use for the line + * @default "linear" + */ + curveType?: ChartCurveType + /** + * Connect null data points + * @default false + */ + connectNulls?: boolean + /** + * Display dots for each data point + * @default true + */ + showDots?: boolean + /** + * Angle the x-axis labels + * @default false + */ + angledLabels?: boolean + /** + * Interval type for x-axis labels + * @default "preserveStartEnd" + */ + intervalType?: "preserveStart" | "preserveEnd" | "preserveStartEnd" | "equidistantPreserveStart" +} + +export const AreaChart = React.forwardRef<HTMLDivElement, AreaChartProps>((props, ref) => { + + const { + className, + stack = false, + curveType = "linear", + connectNulls = false, + angledLabels = false, + /**/ + data = [], + categories = [], + index, + colors = ColorPalette, + valueFormatter = defaultValueFormatter, + startEndOnly = false, + showXAxis = true, + showYAxis = true, + yAxisWidth = 56, + showAnimation = true, + showTooltip = true, + showLegend = true, + showGridLines = true, + showGradient = true, + autoMinValue = false, + minValue, + maxValue, + allowDecimals = true, + showDots = true, + emptyDisplay = <></>, + intervalType = "preserveStartEnd", + ...rest + } = props + + const [legendHeight, setLegendHeight] = React.useState(60) + + const categoryColors = constructCategoryColors(categories, colors) + const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue) + + return ( + <div + ref={ref} + className={cn("w-full h-80", className)} + {...rest} + > + <ResponsiveContainer width="100%" height="100%"> + {data?.length ? ( + <ReChartsAreaChart data={data}> + {showGridLines ? ( + <CartesianGrid + strokeDasharray="3 3" + horizontal={true} + vertical={false} + className="stroke-gray-300 dark:stroke-gray-600" + /> + ) : null} + <XAxis + hide={!showXAxis} + dataKey={index} + tick={{ transform: "translate(0, 8)" }} + ticks={startEndOnly ? [data[0][index], data[data.length - 1][index]] : undefined} + className="font-medium text-[--muted] text-xs" + interval={intervalType} + axisLine={false} + tickLine={false} + padding={{ left: 10, right: 10 }} + minTickGap={5} + spacing={120} + textAnchor={angledLabels ? "end" : "middle"} + angle={angledLabels ? -40 : undefined} + /> + <YAxis + width={yAxisWidth} + hide={!showYAxis} + axisLine={false} + tickLine={false} + type="number" + domain={yAxisDomain as AxisDomain} + tick={{ transform: "translate(-3, 0)" }} + className="font-medium text-[--muted] text-xs" + tickFormatter={valueFormatter} + allowDecimals={allowDecimals} + /> + <Tooltip + wrapperStyle={{ outline: "none" }} + isAnimationActive={false} + cursor={{ stroke: "var(--gray)", strokeWidth: 1 }} + position={{ y: 0 }} + content={showTooltip ? ({ active, payload, label }) => ( + <ChartTooltip + active={active} + payload={payload} + label={label} + valueFormatter={valueFormatter} + categoryColors={categoryColors} + /> + ) : <></>} + /> + + {categories.map((category) => { + const hexColor = `var(--${categoryColors.get(category)})` + return ( + <defs key={category}> + {showGradient ? ( + <linearGradient id={categoryColors.get(category)} x1="0" y1="0" x2="0" y2="1"> + <stop offset="5%" stopColor={hexColor} stopOpacity={0.2} /> + <stop offset="95%" stopColor={hexColor} stopOpacity={0} /> + </linearGradient> + ) : ( + <linearGradient id={categoryColors.get(category)} x1="0" y1="0" x2="0" y2="1"> + <stop stopColor={hexColor} stopOpacity={0.3} /> + </linearGradient> + )} + </defs> + ) + })} + + {categories.map((category) => ( + <Area + key={category} + name={category} + type={curveType} + dataKey={category} + stroke={`var(--${categoryColors.get(category)})`} + fill={`url(#${categoryColors.get(category)})`} + strokeWidth={2} + dot={showDots} + isAnimationActive={showAnimation} + stackId={stack ? "a" : undefined} + connectNulls={connectNulls} + /> + ))} + + {showLegend ? ( + <Legend + verticalAlign="bottom" + height={legendHeight} + content={({ payload }) => ChartLegend({ payload }, categoryColors, setLegendHeight)} + /> + ) : null} + + </ReChartsAreaChart> + ) : emptyDisplay} + </ResponsiveContainer> + </div> + ) + +}) + +AreaChart.displayName = "AreaChart" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/charts/bar-chart.tsx b/seanime-2.9.10/seanime-web/src/components/ui/charts/bar-chart.tsx new file mode 100644 index 0000000..3a10ce8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/charts/bar-chart.tsx @@ -0,0 +1,199 @@ +"use client" + +import * as React from "react" +import { Bar, BarChart as ReChartsBarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" +import type { AxisDomain } from "recharts/types/util/types" +import { cn } from "../core/styling" +import { ChartLegend } from "./chart-legend" +import { ChartTooltip } from "./chart-tooltip" +import { ColorPalette } from "./color-theme" +import { BaseChartProps } from "./types" +import { constructCategoryColors, defaultValueFormatter, getYAxisDomain } from "./utils" + + +/* ------------------------------------------------------------------------------------------------- + * BarChart + * -----------------------------------------------------------------------------------------------*/ + +export type BarChartProps = React.ComponentPropsWithRef<"div"> & BaseChartProps & { + /** + * Display bars vertically or horizontally + */ + layout?: "vertical" | "horizontal" + /** + * If true, the bars will be stacked + */ + stack?: boolean + /** + * Display bars as a percentage of the total + */ + relative?: boolean + /** + * Interval type for x-axis labels + * @default "equidistantPreserveStart" + */ + intervalType?: "preserveStart" | "preserveEnd" | "preserveStartEnd" | "equidistantPreserveStart" +} + +export const BarChart = React.forwardRef<HTMLDivElement, BarChartProps>((props, ref) => { + + const { + children, + className, + layout = "horizontal", + stack = false, + relative = false, + /**/ + data = [], + categories = [], + index, + colors = ColorPalette, + valueFormatter = defaultValueFormatter, + startEndOnly = false, + showXAxis = true, + showYAxis = true, + yAxisWidth = 56, + showAnimation = true, + showTooltip = true, + showLegend = true, + showGridLines = true, + autoMinValue = false, + minValue, + maxValue, + allowDecimals = true, + intervalType = "equidistantPreserveStart", + emptyDisplay = <></>, + ...rest + } = props + + const [legendHeight, setLegendHeight] = React.useState(60) + + const categoryColors = constructCategoryColors(categories, colors) + const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue) + + return ( + <div + className={cn("w-full h-80", className)} + {...rest} + ref={ref} + > + <ResponsiveContainer width="100%" height="100%"> + {data?.length ? ( + <ReChartsBarChart + data={data} + stackOffset={relative ? "expand" : "none"} + layout={layout === "vertical" ? "vertical" : "horizontal"} + > + {showGridLines ? ( + <CartesianGrid + strokeDasharray="3 3" + horizontal={layout !== "vertical"} + vertical={layout === "vertical"} + className="stroke-gray-300 dark:stroke-gray-600" + /> + ) : null} + + {layout !== "vertical" ? ( + <XAxis + hide={!showXAxis} + dataKey={index} + interval="preserveStartEnd" + tick={{ transform: "translate(0, 6)" }} // Padding between labels and axis + ticks={startEndOnly ? [data[0][index], data[data.length - 1][index]] : undefined} + className="font-medium text-[--muted] text-xs mt-4" + tickLine={false} + axisLine={false} + /> + ) : ( + <XAxis + hide={!showXAxis} + type="number" + tick={{ transform: "translate(-3, 0)" }} + domain={yAxisDomain as AxisDomain} + className="font-medium text-[--muted] text-xs" + tickLine={false} + axisLine={false} + tickFormatter={valueFormatter} + padding={{ left: 10, right: 10 }} + minTickGap={5} + allowDecimals={allowDecimals} + /> + )} + {layout !== "vertical" ? ( + <YAxis + width={yAxisWidth} + hide={!showYAxis} + axisLine={false} + tickLine={false} + type="number" + domain={yAxisDomain as AxisDomain} + tick={{ transform: "translate(-3, 0)" }} + className="font-medium text-[--muted] text-xs" + tickFormatter={ + relative ? (value: number) => `${(value * 100).toString()} %` : valueFormatter + } + allowDecimals={allowDecimals} + /> + ) : ( + <YAxis + width={yAxisWidth} + hide={!showYAxis} + dataKey={index} + axisLine={false} + tickLine={false} + ticks={startEndOnly ? [data[0][index], data[data.length - 1][index]] : undefined} + type="category" + interval="preserveStartEnd" + tick={{ transform: "translate(0, 6)" }} + className="font-medium text-[--muted] text-xs" + /> + )} + <Tooltip + wrapperStyle={{ + outline: "none", + }} + cursor={{ + fill: "var(--gray)", + opacity: 0.05, + }} + isAnimationActive={false} + content={showTooltip ? ({ active, payload, label }) => ( + <ChartTooltip + active={active} + payload={payload} + label={label} + valueFormatter={valueFormatter} + categoryColors={categoryColors} + /> + ) : <></>} + position={{ y: 0 }} + /> + + {categories.map((category) => ( + <Bar + key={category} + name={category} + type="linear" + stackId={stack || relative ? "a" : undefined} + dataKey={category} + fill={`var(--${categoryColors.get(category)})`} + isAnimationActive={showAnimation} + /> + ))} + + {showLegend ? ( + <Legend + verticalAlign="bottom" + height={legendHeight} + content={({ payload }) => ChartLegend({ payload }, categoryColors, setLegendHeight)} + /> + ) : null} + </ReChartsBarChart> + ) : emptyDisplay} + </ResponsiveContainer> + </div> + ) + +}) + +BarChart.displayName = "BarChart" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/charts/chart-legend.tsx b/seanime-2.9.10/seanime-web/src/components/ui/charts/chart-legend.tsx new file mode 100644 index 0000000..5fdc964 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/charts/chart-legend.tsx @@ -0,0 +1,46 @@ +"use client" + +import * as React from "react" +import { ChartColor } from "./color-theme" +import { Legend } from "./legend" + +/* ------------------------------------------------------------------------------------------------- + * ChartLegend + * -----------------------------------------------------------------------------------------------*/ + +export const ChartLegend = ( + { payload }: any, + categoryColors: Map<string, ChartColor>, + setLegendHeight: React.Dispatch<React.SetStateAction<number>>, +) => { + const legendRef = React.useRef<HTMLDivElement>(null) + + const [windowSize, setWindowSize] = React.useState<undefined | number>(undefined) + const deferredWindowSize = React.useDeferredValue(windowSize) + + React.useEffect(() => { + const handleResize = () => { + setWindowSize(window.innerWidth) + const calculateHeight = (height: number | undefined) => + height ? + Number(height) + 20 // 20px extra padding + : 60 // default height + setLegendHeight(calculateHeight(legendRef.current?.clientHeight)) + } + handleResize() + window.addEventListener("resize", handleResize) + + return () => window.removeEventListener("resize", handleResize) + }, [deferredWindowSize]) + + return ( + <div ref={legendRef} className="flex w-full items-center justify-center mt-4"> + <Legend + categories={payload.map((entry: any) => entry.value)} + colors={payload.map((entry: any) => categoryColors.get(entry.value))} + /> + </div> + ) +} + +ChartLegend.displayName = "ChartLegend" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/charts/chart-tooltip.tsx b/seanime-2.9.10/seanime-web/src/components/ui/charts/chart-tooltip.tsx new file mode 100644 index 0000000..baf0520 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/charts/chart-tooltip.tsx @@ -0,0 +1,152 @@ +"use client" + +import { cva } from "class-variance-authority" +import * as React from "react" +import { ChartValueFormatter } from "../charts/types" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { ChartColor } from "./color-theme" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const ChartTooltipAnatomy = defineStyleAnatomy({ + frame: cva([ + "UI-ChartTooltip__frame", + "border bg-[--paper] p-2 rounded-[--radius]", + ]), + header: cva([ + "UI-ChartTooltip__header", + "mb-2 font-semibold", + ]), + label: cva([ + "UI-ChartTooltip__label", + ]), + content: cva([ + "UI-ChartTooltip__content", + "space-y-1", + ]), +}) + +export const ChartTooltipRowAnatomy = defineStyleAnatomy({ + row: cva([ + "UI-ChartTooltip__row", + "flex items-center justify-between space-x-8", + ]), + labelContainer: cva([ + "UI-ChartTooltip__labelContainer", + "flex items-center space-x-2", + ]), + dot: cva([ + "UI-ChartTooltip__dot", + "shrink-0", + "h-3 w-3 bg-[--gray] rounded-full shadow-sm", + ]), + value: cva([ + "UI-ChartTooltip__value", + "font-semibold tabular-nums text-right whitespace-nowrap", + ]), + label: cva([ + "UI-ChartTooltip__label", + "text-sm text-right whitespace-nowrap font-medium text-[--foreground]", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * ChartTooltipFrame + * -----------------------------------------------------------------------------------------------*/ + +export type ChartTooltipFrameProps = React.ComponentPropsWithoutRef<"div"> + +export const ChartTooltipFrame = ({ children, className }: ChartTooltipFrameProps) => ( + <div className={cn(ChartTooltipAnatomy.frame(), className)}> + {children} + </div> +) + +/* ------------------------------------------------------------------------------------------------- + * ChartTooltipRow + * -----------------------------------------------------------------------------------------------*/ + +export type ChartTooltipRowProps = ComponentAnatomy<typeof ChartTooltipRowAnatomy> & { + value: string + name: string + color: ChartColor +} + +export const ChartTooltipRow = ( + { + value, + name, + color, + dotClass, + rowClass, + valueClass, + labelClass, + labelContainerClass, + }: ChartTooltipRowProps) => ( + <div className={cn(ChartTooltipRowAnatomy.row(), rowClass)}> + <div className={cn(ChartTooltipRowAnatomy.labelContainer(), labelContainerClass)}> + <span + className={cn(ChartTooltipRowAnatomy.dot(), dotClass)} + style={{ backgroundColor: `var(--${color})` }} + /> + <p className={cn(ChartTooltipRowAnatomy.label(), labelClass)}> + {name} + </p> + </div> + <p className={cn(ChartTooltipRowAnatomy.value(), valueClass)}> + {value} + </p> + </div> +) + +/* ------------------------------------------------------------------------------------------------- + * ChartTooltip + * -----------------------------------------------------------------------------------------------*/ + +export type ChartTooltipProps = ComponentAnatomy<typeof ChartTooltipAnatomy> & { + active: boolean | undefined + payload: any + label: string + categoryColors: Map<string, ChartColor> + valueFormatter: ChartValueFormatter +} + +export const ChartTooltip = (props: ChartTooltipProps) => { + + const { + active, + payload, + label, + categoryColors, + valueFormatter, + headerClass, + contentClass, + frameClass, + labelClass, + } = props + if (active && payload) { + return ( + <ChartTooltipFrame className={frameClass}> + <div className={cn(ChartTooltipAnatomy.header(), headerClass)}> + <p className={cn(ChartTooltipAnatomy.label(), labelClass)}> + {label} + </p> + </div> + + <div className={cn(ChartTooltipAnatomy.content(), contentClass)}> + {payload.map(({ value, name }: { value: number; name: string }, idx: number) => ( + <ChartTooltipRow + key={`id-${idx}`} + value={valueFormatter(value)} + name={name} + color={categoryColors.get(name) ?? "brand"} + /> + ))} + </div> + </ChartTooltipFrame> + ) + } + return null +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/charts/color-theme.ts b/seanime-2.9.10/seanime-web/src/components/ui/charts/color-theme.ts new file mode 100644 index 0000000..5e5f46c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/charts/color-theme.ts @@ -0,0 +1,30 @@ +/* ------------------------------------------------------------------------------------------------- + * Colors + * -----------------------------------------------------------------------------------------------*/ + +export const ColorPalette = [ + "brand", + "purple", + "blue", + "amber", + "green", + "yellow", + "cyan", + "lime", + "sky", + "red", + "pink", + "orange", + "stone", + "teal", + "neutral", + "fuchsia", + "violet", + "slate", + "zinc", + "emerald", + "indigo", + "gray", + "rose", +] +export type ChartColor = (typeof ColorPalette)[number] diff --git a/seanime-2.9.10/seanime-web/src/components/ui/charts/donut-chart.tsx b/seanime-2.9.10/seanime-web/src/components/ui/charts/donut-chart.tsx new file mode 100644 index 0000000..bfc67d7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/charts/donut-chart.tsx @@ -0,0 +1,208 @@ +"use client" + +import * as React from "react" +import { Pie, PieChart as ReChartsDonutChart, ResponsiveContainer, Sector, Tooltip } from "recharts" +import { cn } from "../core/styling" +import { ChartTooltipFrame, ChartTooltipRow } from "./chart-tooltip" +import { ChartColor, ColorPalette } from "./color-theme" +import { ChartValueFormatter } from "./types" +import { defaultValueFormatter, parseChartData, parseChartLabelInput } from "./utils" + +/* ------------------------------------------------------------------------------------------------- + * DonutChart + * -----------------------------------------------------------------------------------------------*/ + +export type DonutChartProps = React.HTMLAttributes<HTMLDivElement> & { + /** + * The data to be displayed in the chart. + * An array of objects. Each object represents a data point. + */ + data: any[] + /** + * The key containing the quantitative chart values. + */ + category: string + /** + * The key to map the data to the axis. + * e.g. "value" + */ + index: string + /** + * Color palette to be used in the chart. + */ + colors?: ChartColor[] + /** + * The type of chart to display + * @default "donut" + */ + variant?: "donut" | "pie" + /** + * Changes the text formatting of the label. + * This only works when the variant is "donut". + */ + valueFormatter?: ChartValueFormatter + /** + * The text to be placed the center of the donut chart. + * Only available when variant "donut". + */ + label?: string + /** + * If true, the label will be displayed in the center of the chart + * @default true + */ + showLabel?: boolean + /** + * If true, the chart will animate when rendered + */ + showAnimation?: boolean + /** + * If true, a tooltip will be displayed when hovering over a data point + * @default true + */ + showTooltip?: boolean + /** + * The element to be displayed when there is no data + * @default <></> + */ + emptyDisplay?: React.ReactElement +} + +export const DonutChart = React.forwardRef<HTMLDivElement, DonutChartProps>((props, ref) => { + const { + data = [], + category, + index, + colors = ColorPalette, + variant = "donut", + valueFormatter = defaultValueFormatter, + label, + showLabel = true, + showAnimation = true, + showTooltip = true, + className, + emptyDisplay = <></>, + ...other + } = props + const isDonut = variant == "donut" + + const parsedLabelInput = parseChartLabelInput(label, valueFormatter, data, category) + + return ( + <div ref={ref} className={cn("w-full h-44", className)} {...other}> + <ResponsiveContainer width="100%" height="100%"> + {data?.length ? ( + <ReChartsDonutChart> + {showLabel && isDonut ? ( + <text + x="50%" + y="50%" + textAnchor="middle" + dominantBaseline="middle" + className="fill-[--foreground] dark:fill-[--foreground] font-semibold" + > + {parsedLabelInput} + </text> + ) : null} + <Pie + data={parseChartData(data, colors)} + cx="50%" + cy="50%" + startAngle={90} + endAngle={-270} + innerRadius={isDonut ? "75%" : "0%"} + outerRadius="100%" + paddingAngle={0} + stroke="" + strokeLinejoin="round" + dataKey={category} + nameKey={index} + isAnimationActive={showAnimation} + inactiveShape={renderInactiveShape} + style={{ outline: "none" }} + className="stroke-[--background] dark:stroke-[--background]" + /> + <Tooltip + cursorStyle={{ outline: "none" }} + wrapperStyle={{ outline: "none" }} + isAnimationActive={false} + content={showTooltip ? ({ active, payload }) => ( + <DonutChartTooltip + active={active} + payload={payload} + valueFormatter={valueFormatter} + /> + ) : <></>} + /> + </ReChartsDonutChart> + ) : emptyDisplay} + </ResponsiveContainer> + </div> + ) +}) + +DonutChart.displayName = "DonutChart" + +const renderInactiveShape = (props: any) => { + const { + cx, + cy, + // midAngle, + innerRadius, + outerRadius, + startAngle, + endAngle, + // fill, + // payload, + // percent, + // value, + // activeIndex, + className, + } = props + + return ( + <g> + <Sector + cx={cx} + cy={cy} + innerRadius={innerRadius} + outerRadius={outerRadius} + startAngle={startAngle} + endAngle={endAngle} + className={className} + fill="" + opacity={0.3} + style={{ outline: "none" }} + /> + </g> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * DonutChartTooltip + * -----------------------------------------------------------------------------------------------*/ + +type DonutChartTooltipProps = { + active?: boolean + payload: any + valueFormatter: ChartValueFormatter +} + +const DonutChartTooltip = ({ active, payload, valueFormatter }: DonutChartTooltipProps) => { + if (active && payload[0]) { + const payloadRow = payload[0] + return ( + <ChartTooltipFrame> + <div className={cn("py-2 px-2")}> + <ChartTooltipRow + value={valueFormatter(payloadRow.value)} + name={payloadRow.name} + color={payloadRow.payload.color} + /> + </div> + </ChartTooltipFrame> + ) + } + return null +} + +DonutChartTooltip.displayName = "DonutChartTooltip" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/charts/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/charts/index.tsx new file mode 100644 index 0000000..aad722c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/charts/index.tsx @@ -0,0 +1,6 @@ +export * from "./area-chart" +export * from "./bar-chart" +export * from "./line-chart" +export * from "./donut-chart" +export * from "./legend" +export * from "./types" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/charts/legend.tsx b/seanime-2.9.10/seanime-web/src/components/ui/charts/legend.tsx new file mode 100644 index 0000000..e1b657a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/charts/legend.tsx @@ -0,0 +1,102 @@ +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { ChartColor, ColorPalette } from "./color-theme" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const LegendAnatomy = defineStyleAnatomy({ + legend: cva([ + "UI-Legend__legend", + "flex flex-wrap overflow-hidden truncate", + ]), + legendItem: cva([ + "UI-Legend__legendItem", + "inline-flex items-center truncate mr-4", + ]), + dot: cva([ + "UI-Legend__dot", + "shrink-0", + "flex-none h-3 w-3 bg-gray rounded-full shadow-sm mr-2", + ]), + label: cva([ + "UI-Legend__label", + "whitespace-nowrap truncate text-sm font-medium text-[--muted]", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * LegendItem + * -----------------------------------------------------------------------------------------------*/ + +export type LegendItemProps = { + name: string + color: ChartColor + dotClass?: string + labelClass?: string + legendItemClass?: string +} + +const LegendItem = ({ name, color, dotClass, legendItemClass, labelClass }: LegendItemProps) => ( + <li className={cn(LegendAnatomy.legendItem(), legendItemClass)}> + <svg + className={cn(LegendAnatomy.dot(), dotClass)} + style={{ color: `var(--${color})` }} + fill="currentColor" + viewBox="0 0 8 8" + > + <circle cx={4} cy={4} r={4} /> + </svg> + <p className={cn(LegendAnatomy.label(), labelClass)}> + {name} + </p> + </li> +) + +/* ------------------------------------------------------------------------------------------------- + * Legend + * -----------------------------------------------------------------------------------------------*/ + +export type LegendProps = React.ComponentPropsWithRef<"ol"> & ComponentAnatomy<typeof LegendAnatomy> & { + categories: string[] + colors?: ChartColor[] +} + +export const Legend = React.forwardRef<HTMLOListElement, LegendProps>((props, ref) => { + const { + categories, + colors = ColorPalette, + className, + legendClass, + legendItemClass, + labelClass, + dotClass, + ...rest + } = props + return ( + <ol + ref={ref} + className={cn( + LegendAnatomy.legend(), + legendClass, + className, + )} + {...rest} + > + {categories.map((category, idx) => ( + <LegendItem + key={`item-${idx}`} + name={category} + color={colors[idx] ?? "brand"} + dotClass={dotClass} + legendItemClass={legendItemClass} + labelClass={labelClass} + /> + ))} + </ol> + ) +}) + +Legend.displayName = "Legend" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/charts/line-chart.tsx b/seanime-2.9.10/seanime-web/src/components/ui/charts/line-chart.tsx new file mode 100644 index 0000000..682752d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/charts/line-chart.tsx @@ -0,0 +1,166 @@ +"use client" + +import * as React from "react" +import { CartesianGrid, Legend, Line, LineChart as ReChartsLineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" +import type { AxisDomain } from "recharts/types/util/types" +import { cn } from "../core/styling" +import { ChartLegend } from "./chart-legend" +import { ChartTooltip } from "./chart-tooltip" +import { ColorPalette } from "./color-theme" +import { BaseChartProps, ChartCurveType } from "./types" +import { constructCategoryColors, defaultValueFormatter, getYAxisDomain } from "./utils" + +/* ------------------------------------------------------------------------------------------------- + * LineChart + * -----------------------------------------------------------------------------------------------*/ + +export type LineChartProps = React.ComponentPropsWithRef<"div"> & BaseChartProps & { + /** + * The type of curve to use for the line + * @default "linear" + */ + curveType?: ChartCurveType + /** + * Connect null data points + * @default false + */ + connectNulls?: boolean + /** + * Angle the x-axis labels + * @default false + */ + angledLabels?: boolean + /** + * Interval type for x-axis labels + * @default "preserveStartEnd" + */ + intervalType?: "preserveStart" | "preserveEnd" | "preserveStartEnd" | "equidistantPreserveStart" +} + + +export const LineChart = React.forwardRef<HTMLDivElement, LineChartProps>((props, ref) => { + + const { + className, + curveType = "linear", + connectNulls = false, + angledLabels, + /**/ + data = [], + categories = [], + index, + colors = ColorPalette, + valueFormatter = defaultValueFormatter, + startEndOnly = false, + showXAxis = true, + showYAxis = true, + yAxisWidth = 56, + showAnimation = true, + showTooltip = true, + showLegend = true, + showGridLines = true, + autoMinValue = false, + minValue, + maxValue, + allowDecimals = true, + intervalType = "preserveStartEnd", + emptyDisplay = <></>, + ...rest + } = props + + const [legendHeight, setLegendHeight] = React.useState(60) + + const categoryColors = constructCategoryColors(categories, colors) + const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue) + + return ( + <div + ref={ref} + className={cn("w-full h-80", className)} + {...rest} + > + <ResponsiveContainer width="100%" height="100%"> + {data?.length ? ( + <ReChartsLineChart data={data}> + {showGridLines ? ( + <CartesianGrid + strokeDasharray="3 3" + horizontal={true} + vertical={false} + className="stroke-gray-300 dark:stroke-gray-600" + /> + ) : null} + <XAxis + hide={!showXAxis} + dataKey={index} + tick={{ transform: "translate(0, 8)" }} + ticks={startEndOnly ? [data[0][index], data[data.length - 1][index]] : undefined} + className="font-medium text-[--muted] text-xs" + interval={intervalType} + axisLine={false} + tickLine={false} + padding={{ left: 10, right: 10 }} + minTickGap={5} + textAnchor={angledLabels ? "end" : "middle"} + angle={angledLabels ? -40 : undefined} + /> + <YAxis + width={yAxisWidth} + hide={!showYAxis} + axisLine={false} + tickLine={false} + type="number" + textAnchor="end" + domain={yAxisDomain as AxisDomain} + tick={{ transform: "translate(-3, 0)" }} + className="font-medium text-[--muted] text-xs" + tickFormatter={valueFormatter} + allowDecimals={allowDecimals} + /> + <Tooltip + wrapperStyle={{ outline: "none" }} + isAnimationActive={false} + cursor={{ stroke: "var(--gray)", strokeWidth: 1 }} + position={{ y: 0 }} + content={showTooltip ? ({ active, payload, label }) => ( + <ChartTooltip + active={active} + payload={payload} + label={label} + valueFormatter={valueFormatter} + categoryColors={categoryColors} + /> + ) : <></>} + /> + + {categories.map((category) => ( + <Line + key={category} + name={category} + type={curveType} + dataKey={category} + stroke={`var(--${categoryColors.get(category)})`} + strokeWidth={2} + dot={false} + isAnimationActive={showAnimation} + connectNulls={connectNulls} + /> + ))} + + {showLegend ? ( + <Legend + verticalAlign="bottom" + height={legendHeight} + content={({ payload }) => ChartLegend({ payload }, categoryColors, setLegendHeight)} + /> + ) : null} + + </ReChartsLineChart> + ) : emptyDisplay} + </ResponsiveContainer> + </div> + ) + +}) + +LineChart.displayName = "LineChart" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/charts/types.ts b/seanime-2.9.10/seanime-web/src/components/ui/charts/types.ts new file mode 100644 index 0000000..361c4e2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/charts/types.ts @@ -0,0 +1,97 @@ +import * as React from "react" +import { ChartColor } from "./color-theme" + +export type ChartValueFormatter = { + (value: number): string +} + +export type ChartCurveType = "linear" | "natural" | "step" + +export type BaseChartProps = { + /** + * The data to be displayed in the chart. + * An array of objects. Each object represents a data point. + */ + data: any[] | null | undefined + /** + * Data categories. Each string represents a key in a data object. + * e.g. ["Jan", "Feb", "Mar"] + */ + categories: string[] + /** + * The key to map the data to the axis. It should match the key in the data object. + * e.g. "value" + */ + index: string + /** + * Color palette to be used in the chart. + */ + colors?: ChartColor[] + /** + * Changes the text formatting for the y-axis values. + */ + valueFormatter?: ChartValueFormatter + /** + * Show only the first and last elements in the x-axis. Great for smaller charts or sparklines. + * @default false + */ + startEndOnly?: boolean + /** + * Controls the visibility of the X axis. + * @default true + */ + showXAxis?: boolean + /** + * Controls the visibility of the Y axis. + * @default true + */ + showYAxis?: boolean + /** + * Controls width of the vertical axis. + * @default 56 + */ + yAxisWidth?: number + /** + * Sets an animation to the chart when it is loaded. + * @default true + */ + showAnimation?: boolean + /** + * Controls the visibility of the tooltip. + * @default true + */ + showTooltip?: boolean + /** + * Controls the visibility of the legend. + * @default true + */ + showLegend?: boolean + /** + * Controls the visibility of the grid lines. + * @default true + */ + showGridLines?: boolean + /** + * Adjusts the minimum value in relation to the magnitude of the data. + * @default false + */ + autoMinValue?: boolean + /** + * Sets the minimum value of the shown chart data. + */ + minValue?: number + /** + * Sets the maximum value of the shown chart data. + */ + maxValue?: number + /** + * Controls if the ticks of a numeric axis are displayed as decimals or not. + * @default true + */ + allowDecimals?: boolean + /** + * Element to be displayed when there is no data. + * @default `<></>` + */ + emptyDisplay?: React.ReactElement +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/charts/utils.ts b/seanime-2.9.10/seanime-web/src/components/ui/charts/utils.ts new file mode 100644 index 0000000..d78a9a8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/charts/utils.ts @@ -0,0 +1,60 @@ +import { ChartValueFormatter } from "../charts" +import { ChartColor } from "./color-theme" + +/* ------------------------------------------------------------------------------------------------- + * Chart Utils + * -----------------------------------------------------------------------------------------------*/ + +export const constructCategoryColors = ( + categories: string[], + colors: ChartColor[], +): Map<string, ChartColor> => { + const categoryColors = new Map<string, ChartColor>() + categories.forEach((category, idx) => { + categoryColors.set(category, colors[idx] ?? "gray") + }) + return categoryColors +} + +/** + * @internal + */ +export const getYAxisDomain = ( + autoMinValue: boolean, + minValue: number | undefined, + maxValue: number | undefined, +) => { + const minDomain = autoMinValue ? "auto" : minValue ?? 0 + const maxDomain = maxValue ?? "auto" + return [minDomain, maxDomain] +} + +export const defaultValueFormatter: ChartValueFormatter = (value: number) => value.toString() + +/* ------------------------------------------------------------------------------------------------- + * DonutChart Utils + * -----------------------------------------------------------------------------------------------*/ + +export const parseChartData = (data: any[], colors: ChartColor[]) => + data.map((dataPoint: any, idx: number) => { + const baseColor = idx < colors.length ? colors[idx] : "brand" + return { + ...dataPoint, + // explicitly adding color key if not present for tooltip coloring + color: baseColor, + fill: `var(--${baseColor})`, // Color + } + }) + +const sumNumericArray = (arr: number[]) => + arr.reduce((prefixSum, num) => prefixSum + num, 0) + +const calculateDefaultLabel = (data: any[], category: string) => + sumNumericArray(data.map((dataPoint) => dataPoint[category])) + +export const parseChartLabelInput = ( + labelInput: string | undefined, + valueFormatter: ChartValueFormatter, + data: any[], + category: string, +) => (labelInput ? labelInput : valueFormatter(calculateDefaultLabel(data, category))) diff --git a/seanime-2.9.10/seanime-web/src/components/ui/checkbox/checkbox-group.tsx b/seanime-2.9.10/seanime-web/src/components/ui/checkbox/checkbox-group.tsx new file mode 100644 index 0000000..4514f2f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/checkbox/checkbox-group.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { Checkbox, CheckboxProps } from "." +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { cn } from "../core/styling" +import { hiddenInputStyles } from "../input" + +/* ------------------------------------------------------------------------------------------------- + * CheckboxGroup + * -----------------------------------------------------------------------------------------------*/ + +type CheckboxGroupContextValue = { + group_size: CheckboxProps["size"] +} + +export const __CheckboxGroupContext = React.createContext<CheckboxGroupContextValue | null>(null) + +export type CheckboxGroupOption = { value: string, label?: React.ReactNode, disabled?: boolean, readonly?: boolean } + +export type CheckboxGroupProps = BasicFieldOptions & { + /** + * The value of the checkbox group. + */ + value?: string[] + /** + * The default value of the checkbox group when uncontrolled. + */ + defaultValue?: string[] + /** + * Callback invoked when the value of the checkbox group changes. + */ + onValueChange?: (value: string[]) => void + /** + * The size of the checkboxes. + */ + size?: CheckboxProps["size"] + /** + * The options of the checkbox group. + */ + options: CheckboxGroupOption[] + /** + * Class names applied to the container. + */ + stackClass?: string + /** + * Class names applied to each checkbox container. + */ + itemContainerClass?: string + /** + * Class names applied to each checkbox label. + */ + itemLabelClass?: string + /** + * Class names applied to each checkbox button. + */ + itemClass?: string + /** + * Class names applied to each checkbox check icon. + */ + itemCheckIconClass?: string +} + +export const CheckboxGroup = React.forwardRef<HTMLInputElement, CheckboxGroupProps>((props, ref) => { + + const [{ + value: controlledValue, + defaultValue = [], + onValueChange, + stackClass, + itemLabelClass, + itemClass, + itemContainerClass, + itemCheckIconClass, + options, + size = undefined, + }, basicFieldProps] = extractBasicFieldProps<CheckboxGroupProps>(props, React.useId()) + + const [value, setValue] = React.useState<string[]>(controlledValue ?? defaultValue) + + const handleUpdateValue = React.useCallback((v: string) => { + return (checked: boolean | "indeterminate") => { + setValue(p => { + const newArr = checked === true + ? [...p, ...(p.includes(v) ? [] : [v])] + : checked === false + ? p.filter(v1 => v1 !== v) + : [...p] + onValueChange?.(newArr) + return newArr + }) + } + }, []) + + React.useEffect(() => { + if (controlledValue !== undefined) { + setValue(controlledValue) + } + }, [controlledValue]) + + + return ( + <__CheckboxGroupContext.Provider + value={{ + group_size: size, + }} + > + <BasicField {...basicFieldProps}> + <div className={cn("UI-CheckboxGroup__stack space-y-1", stackClass)}> + {options.map((opt) => ( + <Checkbox + key={opt.value} + label={opt.label} + value={value.includes(opt.value)} + onValueChange={handleUpdateValue(opt.value)} + hideError + error={basicFieldProps.error} + className={itemClass} + labelClass={itemLabelClass} + containerClass={itemContainerClass} + checkIconClass={itemCheckIconClass} + disabled={basicFieldProps.disabled || opt.disabled} + readonly={basicFieldProps.readonly || opt.readonly} + tabIndex={0} + /> + ))} + </div> + + <input + ref={ref} + type="text" + id={basicFieldProps.name} + name={basicFieldProps.name} + className={hiddenInputStyles} + value={basicFieldProps.required + ? (!!value.length ? JSON.stringify(value) : "") + : JSON.stringify(value)} + aria-hidden="true" + required={basicFieldProps.required} + tabIndex={-1} + onChange={() => {}} + /> + + </BasicField> + </__CheckboxGroupContext.Provider> + ) + +}) + +CheckboxGroup.displayName = "CheckboxGroup" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/checkbox/checkbox.tsx b/seanime-2.9.10/seanime-web/src/components/ui/checkbox/checkbox.tsx new file mode 100644 index 0000000..268fe54 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/checkbox/checkbox.tsx @@ -0,0 +1,226 @@ +"use client" + +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { __CheckboxGroupContext } from "../checkbox" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { mergeRefs } from "../core/utils" +import { hiddenInputStyles } from "../input" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const CheckboxAnatomy = defineStyleAnatomy({ + container: cva("UI-Checkbox__container inline-flex gap-2 items-center"), + root: cva([ + "UI-Checkbox__root", + "appearance-none peer block relative overflow-hidden transition h-5 w-5 shrink-0 text-white rounded-[--radius-md] ring-offset-1 border ring-offset-[--background]", + "border-gray-300 dark:border-gray-700", + "outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--ring] disabled:cursor-not-allowed data-[disabled=true]:opacity-50", + "data-[state=unchecked]:bg-white dark:data-[state=unchecked]:bg-gray-700", // Unchecked + "data-[state=unchecked]:hover:bg-gray-100 dark:data-[state=unchecked]:hover:bg-gray-600", // Unchecked hover + "data-[state=checked]:bg-brand dark:data-[state=checked]:bg-brand data-[state=checked]:border-brand", // Checked + "data-[state=indeterminate]:bg-[--muted] dark:data-[state=indeterminate]:bg-gray-700 data-[state=indeterminate]:text-white data-[state=indeterminate]:border-transparent", // Checked + "data-[error=true]:border-red-500 data-[error=true]:dark:border-red-500 data-[error=true]:data-[state=checked]:border-red-500 data-[error=true]:dark:data-[state=checked]:border-red-500", // Error + ], { + variants: { + size: { + sm: "h-4 w-4", + md: "h-5 w-5", + lg: "h-6 w-6", + }, + }, + defaultVariants: { + size: "md", + }, + }), + label: cva([ + "UI-Checkbox_label", + "font-normal", + "data-[disabled=true]:opacity-50", + ], { + variants: { + size: { + sm: "text-sm", + md: "text-md", + lg: "text-lg", + }, + }, + defaultVariants: { + size: "md", + }, + }), + indicator: cva([ + "UI-Checkbox__indicator", + "flex h-full w-full items-center justify-center relative", + ]), + checkIcon: cva("UI-Checkbox__checkIcon absolute", { + variants: { + size: { + sm: "h-3 w-3", + md: "h-4 w-4", + lg: "h-5 w-5", + }, + }, + defaultVariants: { + size: "md", + }, + }), +}) + +/* ------------------------------------------------------------------------------------------------- + * Checkbox + * -----------------------------------------------------------------------------------------------*/ + +export type CheckboxProps = + BasicFieldOptions & + VariantProps<typeof CheckboxAnatomy.label> & + ComponentAnatomy<typeof CheckboxAnatomy> & + Omit<React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>, + "value" | "checked" | "disabled" | "required" | "onCheckedChange" | "defaultValue"> & { + /** + * If true, no error message will be shown when the field is invalid. + */ + hideError?: boolean + /** + * The size of the checkbox. + */ + value?: boolean | "indeterminate" + /** + * Default value when uncontrolled + */ + defaultValue?: boolean | "indeterminate" + /** + * Callback fired when the value changes + */ + onValueChange?: (value: boolean | "indeterminate") => void + /** + * Ref to the input element + */ + inputRef?: React.Ref<HTMLInputElement>, +} + +export const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>((props, ref) => { + + const [{ + className, + hideError, + containerClass, + checkIconClass, + labelClass, + indicatorClass, + onValueChange, + defaultValue, + value: controlledValue, + size, + inputRef, + ...rest + }, { label, ...basicFieldProps }] = extractBasicFieldProps<CheckboxProps>(props, React.useId()) + + const groupContext = React.useContext(__CheckboxGroupContext) + + const _size = groupContext?.group_size ?? size + + const isFirst = React.useRef(true) + + const buttonRef = React.useRef<HTMLButtonElement>(null) + + const [_value, _setValue] = React.useState<boolean | "indeterminate">(controlledValue ?? defaultValue ?? false) + + const handleOnValueChange = React.useCallback((value: boolean) => { + _setValue(value) + onValueChange?.(value) + }, []) + + React.useEffect(() => { + if (!defaultValue || !isFirst.current) { + _setValue(controlledValue ?? false) + } + isFirst.current = false + }, [controlledValue]) + + return ( + <BasicField + fieldClass="flex gap-2" + {...basicFieldProps} + error={hideError ? undefined : basicFieldProps.error} // The error message hidden when `hideError` is true + > + <label + className={cn( + CheckboxAnatomy.container(), + containerClass, + )} + htmlFor={basicFieldProps.id} + > + <CheckboxPrimitive.Root + ref={mergeRefs([buttonRef, ref])} + id={basicFieldProps.id} + className={cn(CheckboxAnatomy.root({ size: _size }), className)} + disabled={basicFieldProps.disabled || basicFieldProps.readonly} + data-error={!!basicFieldProps.error} + data-disabled={basicFieldProps.disabled} + aria-readonly={basicFieldProps.readonly} + data-readonly={basicFieldProps.readonly} + checked={_value} + onCheckedChange={handleOnValueChange} + {...rest} + > + <CheckboxPrimitive.CheckboxIndicator className={cn(CheckboxAnatomy.indicator(), indicatorClass)}> + {(_value !== "indeterminate") && <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 16 16" + stroke="currentColor" + fill="currentColor" + className={cn(CheckboxAnatomy.checkIcon({ size: _size }), checkIconClass)} + > + <path + d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" + /> + </svg>} + + {_value === "indeterminate" && <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="3" + className={cn(CheckboxAnatomy.checkIcon({ size: _size }), checkIconClass)} + > + <line x1="5" x2="19" y1="12" y2="12" /> + </svg>} + </CheckboxPrimitive.CheckboxIndicator> + </CheckboxPrimitive.Root> + {!!label && + <label + className={cn(CheckboxAnatomy.label({ size: _size }), labelClass)} + htmlFor={basicFieldProps.id} + data-disabled={basicFieldProps.disabled} + data-checked={_value === true} + > + {label} + </label> + } + + <input + ref={inputRef} + type="checkbox" + name={basicFieldProps.name} + className={hiddenInputStyles} + value={_value === "indeterminate" ? "indeterminate" : _value ? "on" : "off"} + checked={basicFieldProps.required ? _value === true : true} + aria-hidden="true" + required={controlledValue === undefined && basicFieldProps.required} + tabIndex={-1} + onChange={() => {}} + onFocusCapture={() => buttonRef.current?.focus()} + /> + </label> + </BasicField> + ) + +}) + +Checkbox.displayName = "Checkbox" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/checkbox/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/checkbox/index.tsx new file mode 100644 index 0000000..6d93af1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/checkbox/index.tsx @@ -0,0 +1,2 @@ +export * from "./checkbox" +export * from "./checkbox-group" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/collapsible/collapsible.tsx b/seanime-2.9.10/seanime-web/src/components/ui/collapsible/collapsible.tsx new file mode 100644 index 0000000..a2a29dd --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/collapsible/collapsible.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" +import * as React from "react" + +/* ------------------------------------------------------------------------------------------------- + * Collapsible + * -----------------------------------------------------------------------------------------------*/ + +export const Collapsible = CollapsiblePrimitive.Root + +Collapsible.displayName = "Collapsible" + +/* ------------------------------------------------------------------------------------------------- + * CollapsibleTrigger + * -----------------------------------------------------------------------------------------------*/ + +export type CollapsibleTriggerProps = React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Trigger> + +export const CollapsibleTrigger = React.forwardRef<HTMLButtonElement, CollapsibleTriggerProps>((props, ref) => { + const { children, ...rest } = props + + return ( + <CollapsiblePrimitive.Trigger + ref={ref} + asChild + {...rest} + > + {children} + </CollapsiblePrimitive.Trigger> + ) +}) + +CollapsibleTrigger.displayName = "CollapsibleTrigger" + +/* ------------------------------------------------------------------------------------------------- + * CollapsibleContent + * -----------------------------------------------------------------------------------------------*/ + +export type CollapsibleContentProps = React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Content> + +export const CollapsibleContent = React.forwardRef<HTMLDivElement, CollapsibleContentProps>((props, ref) => { + + return ( + <CollapsiblePrimitive.Content + ref={ref} + {...props} + /> + ) +}) + +CollapsibleContent.displayName = "CollapsibleContent" + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/collapsible/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/collapsible/index.tsx new file mode 100644 index 0000000..4c8885d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/collapsible/index.tsx @@ -0,0 +1 @@ +export * from "./collapsible" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/combobox/combobox.tsx b/seanime-2.9.10/seanime-web/src/components/ui/combobox/combobox.tsx new file mode 100644 index 0000000..37fcc2d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/combobox/combobox.tsx @@ -0,0 +1,366 @@ +"use client" + +import { cva } from "class-variance-authority" +import equal from "fast-deep-equal" +import * as React from "react" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandProps } from "../command" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { mergeRefs } from "../core/utils" +import { extractInputPartProps, hiddenInputStyles, InputAddon, InputAnatomy, InputContainer, InputIcon, InputStyling } from "../input" +import { Popover } from "../popover" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const ComboboxAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Combobox__root", + "justify-between h-auto", + ], { + variants: { + size: { + sm: "min-h-8 px-2 py-1 text-sm", + md: "min-h-10 px-3 py-2 ", + lg: "min-h-12 px-4 py-3 text-md", + }, + }, + defaultVariants: { + size: "md", + }, + }), + popover: cva([ + "UI-Combobox__popover", + "w-[--radix-popover-trigger-width] p-0", + ]), + checkIcon: cva([ + "UI-Combobox__checkIcon", + "h-4 w-4", + "data-[selected=true]:opacity-100 data-[selected=false]:opacity-0", + ]), + item: cva([ + "UI-Combobox__item", + "flex gap-1 items-center flex-none truncate bg-gray-100 dark:bg-gray-800 px-2 pr-1 rounded-[--radius] max-w-96", + ]), + placeholder: cva([ + "UI-Combobox__placeholder", + "text-[--muted] truncate", + ]), + inputValuesContainer: cva([ + "UI-Combobox__inputValuesContainer", + "grow flex overflow-hidden gap-2 flex-wrap", + ]), + chevronIcon: cva([ + "UI-Combobox__chevronIcon", + "ml-2 h-4 w-4 shrink-0 opacity-50", + ]), + removeItemButton: cva([ + "UI-Badge__removeItemButton", + "text-lg cursor-pointer transition ease-in hover:opacity-60", + ]), +}) + + +/* ------------------------------------------------------------------------------------------------- + * Combobox + * -----------------------------------------------------------------------------------------------*/ + +export type ComboboxOption = { value: string, textValue?: string, label: React.ReactNode } + +export type ComboboxProps = Omit<React.ComponentPropsWithRef<"button">, "size" | "value"> & + BasicFieldOptions & + InputStyling & + ComponentAnatomy<typeof ComboboxAnatomy> & { + /** + * The selected values + */ + value?: string[] + /** + * Callback fired when the selected values change + */ + onValueChange?: (value: string[]) => void + /** + * Callback fired when the search input changes + */ + onTextChange?: (value: string) => void + /** + * Additional props for the command component + */ + commandProps?: CommandProps + /** + * The options to display in the dropdown + */ + options: ComboboxOption[] + /** + * The message to display when there are no options + */ + emptyMessage: React.ReactNode + /** + * The placeholder text + */ + placeholder?: string + /** + * Allow multiple values to be selected + */ + multiple?: boolean + /** + * Default value when uncontrolled + */ + defaultValue?: string[] + /** + * Ref to the input element + */ + inputRef?: React.Ref<HTMLInputElement> +} + +export const Combobox = React.forwardRef<HTMLButtonElement, ComboboxProps>((props, ref) => { + + const [props1, basicFieldProps] = extractBasicFieldProps<ComboboxProps>(props, React.useId()) + + const [{ + size, + intent, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + className, + popoverClass, + checkIconClass, + itemClass, + placeholderClass, + inputValuesContainerClass, + chevronIconClass, + removeItemButtonClass, + /**/ + commandProps, + options, + emptyMessage, + placeholder, + value: controlledValue, + onValueChange, + onTextChange, + multiple = false, + defaultValue, + inputRef, + ...rest + }, { + inputContainerProps, + leftAddonProps, + leftIconProps, + rightAddonProps, + rightIconProps, + }] = extractInputPartProps<ComboboxProps>({ + ...props1, + size: props1.size ?? "md", + intent: props1.intent ?? "basic", + leftAddon: props1.leftAddon, + leftIcon: props1.leftIcon, + rightAddon: props1.rightAddon, + rightIcon: props1.rightIcon, + }) + + const buttonRef = React.useRef<HTMLButtonElement>(null) + + const valueRef = React.useRef<string[]>(controlledValue || defaultValue || []) + const [value, setValue] = React.useState<string[]>(controlledValue || defaultValue || []) + + const [open, setOpen] = React.useState(false) + + const handleUpdateValue = React.useCallback((value: string[]) => { + setValue(value) + valueRef.current = value + }, []) + + React.useEffect(() => { + if (controlledValue !== undefined && !equal(controlledValue, valueRef.current)) { + handleUpdateValue(controlledValue) + } + }, [controlledValue]) + + React.useEffect(() => { + onValueChange?.(value) + }, [value]) + + const selectedOptions = options.filter((option) => value.includes(option.value)) + + const selectedValues = ( + (!!value.length && !!selectedOptions.length) ? + multiple ? selectedOptions.map((option) => <div key={option.value} className={cn(ComboboxAnatomy.item(), itemClass)}> + <span className="truncate">{option.textValue || option.value}</span> + <span + className={cn(ComboboxAnatomy.removeItemButton(), "rounded-full", removeItemButtonClass)} onClick={(e) => { + e.preventDefault() + handleUpdateValue(value.filter((v) => v !== option.value)) + setOpen(false) + }} + > + <svg + xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" + fill="currentColor" + > + <path + d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" + ></path> + </svg> + </span> + </div>) : <span className="truncate">{selectedOptions[0].label}</span> + : <span className={cn(ComboboxAnatomy.placeholder(), placeholderClass)}>{placeholder}</span> + ) + + return ( + <BasicField{...basicFieldProps}> + <InputContainer {...inputContainerProps}> + <InputAddon {...leftAddonProps} /> + <InputIcon {...leftIconProps} /> + + <Popover + open={open} + onOpenChange={setOpen} + className={cn( + ComboboxAnatomy.popover(), + popoverClass, + )} + trigger={<button + ref={mergeRefs([buttonRef, ref])} + id={basicFieldProps.id} + role="combobox" + aria-expanded={open} + className={cn( + InputAnatomy.root({ + size, + intent, + hasError: !!basicFieldProps.error, + isDisabled: !!basicFieldProps.disabled, + isReadonly: !!basicFieldProps.readonly, + hasRightAddon: !!rightAddon, + hasRightIcon: !!rightIcon, + hasLeftAddon: !!leftAddon, + hasLeftIcon: !!leftIcon, + }), + ComboboxAnatomy.root({ + size, + }), + )} + {...rest} + > + <div className={cn(ComboboxAnatomy.inputValuesContainer())}> + {selectedValues} + </div> + <div className="flex items-center"> + {(!!value.length && !!selectedOptions.length && !multiple) && ( + <span + className={cn(ComboboxAnatomy.removeItemButton(), removeItemButtonClass)} onClick={(e) => { + e.preventDefault() + handleUpdateValue([]) + setOpen(false) + }} + > + <svg + xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" + fill="currentColor" + > + <path + d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" + ></path> + </svg> + </span> + )} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn( + ComboboxAnatomy.chevronIcon(), + chevronIconClass, + )} + > + <path d="m7 15 5 5 5-5" /> + <path d="m7 9 5-5 5 5" /> + </svg> + </div> + </button>} + > + <Command + inputContainerClass="py-1" + {...commandProps} + > + <CommandInput + placeholder={placeholder} + onValueChange={onTextChange} + /> + <CommandList> + <CommandEmpty>{emptyMessage}</CommandEmpty> + <CommandGroup> + {options.map((option) => ( + <CommandItem + key={option.value} + value={option.textValue || option.value} + onSelect={(currentValue) => { + const _option = options.find(n => (n.textValue || n.value).toLowerCase() === currentValue.toLowerCase()) + if (_option) { + if (!multiple) { + handleUpdateValue(value.includes(_option.value) ? [] : [_option.value]) + } else { + handleUpdateValue( + !value.includes(_option.value) + ? [...value, _option.value] + : value.filter((v) => v !== _option.value), + ) + } + } + setOpen(false) + }} + leftIcon={ + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn( + ComboboxAnatomy.checkIcon(), + checkIconClass, + )} + data-selected={value.includes(option.value)} + > + <path d="M20 6 9 17l-5-5" /> + </svg> + } + > + {option.label} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </Popover> + + <input + ref={inputRef} + type="text" + name={basicFieldProps.name} + className={hiddenInputStyles} + value={basicFieldProps.required ? (!!value.length ? JSON.stringify(value) : "") : JSON.stringify(value)} + aria-hidden="true" + required={basicFieldProps.required} + tabIndex={-1} + onChange={() => {}} + onFocusCapture={() => buttonRef.current?.focus()} + /> + + <InputAddon {...rightAddonProps} /> + <InputIcon {...rightIconProps} /> + </InputContainer> + </BasicField> + ) +}) + +Combobox.displayName = "Combobox" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/combobox/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/combobox/index.tsx new file mode 100644 index 0000000..abd73dd --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/combobox/index.tsx @@ -0,0 +1 @@ +export * from "./combobox" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/command/command.tsx b/seanime-2.9.10/seanime-web/src/components/ui/command/command.tsx new file mode 100644 index 0000000..093902f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/command/command.tsx @@ -0,0 +1,373 @@ +"use client" + +import { cva } from "class-variance-authority" +import { Command as CommandPrimitive } from "cmdk" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { InputAnatomy } from "../input" +import { Modal, ModalProps } from "../modal" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const CommandAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Command__root", + "flex h-full w-full flex-col overflow-hidden rounded-[--radius-md] bg-[--paper] text-[--foreground]", + ]), + inputContainer: cva([ + "UI-Command__input", + "flex items-center px-3 py-2", + "cmdk-input-wrapper", + ]), + inputIcon: cva([ + "UI-Command__inputIcon", + "mr-2 h-5 w-5 shrink-0 opacity-50", + ]), + list: cva([ + "UI-Command__list", + "max-h-[300px] overflow-y-auto overflow-x-hidden", + ]), + empty: cva([ + "UI-Command__empty", + "py-6 text-center text-base text-[--muted]", + ]), + group: cva([ + "UI-Command__group", + "overflow-hidden p-1 text-[--foreground]", + "[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-[--muted]", + ]), + separator: cva([ + "UI-Command__separator", + "-mx-1 h-px bg-[--border]", + ]), + item: cva([ + "UI-Command__item", + "relative flex cursor-default select-none items-center rounded-[--radius] px-2 py-1.5 text-base outline-none", + "aria-selected:bg-[--subtle] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50", + "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + ]), + itemIconContainer: cva([ + "UI-Command__itemIconContainer", + "mr-2 text-base shrink-0 w-4", + ]), + shortcut: cva([ + "UI-Command__shortcut", + "ml-auto text-xs tracking-widest text-[--muted]", + ]), +}) + +export const CommandDialogAnatomy = defineStyleAnatomy({ + content: cva([ + "UI-CommandDialog__content", + "overflow-hidden p-0", + ]), + command: cva([ + "UI-CommandDialog__command", + "[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-[--muted]", + "[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pb-2 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5", + "[&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-2 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-5", + ]), +}) + + +/* ------------------------------------------------------------------------------------------------- + * Command + * -----------------------------------------------------------------------------------------------*/ + +const __CommandAnatomyContext = React.createContext<ComponentAnatomy<typeof CommandAnatomy>>({}) + +export type CommandProps = React.ComponentPropsWithoutRef<typeof CommandPrimitive> & ComponentAnatomy<typeof CommandAnatomy> + +export const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, ref) => { + const { + className, + inputContainerClass, + inputIconClass, + listClass, + emptyClass, + groupClass, + separatorClass, + itemClass, + itemIconContainerClass, + shortcutClass, + loop = true, + ...rest + } = props + + return ( + <__CommandAnatomyContext.Provider + value={{ + inputContainerClass, + inputIconClass, + listClass, + emptyClass, + groupClass, + separatorClass, + itemClass, + itemIconContainerClass, + shortcutClass, + }} + > + <CommandPrimitive + ref={ref} + className={cn(CommandAnatomy.root(), className)} + loop={loop} + {...rest} + /> + </__CommandAnatomyContext.Provider> + ) +}) +Command.displayName = CommandPrimitive.displayName + +/* ------------------------------------------------------------------------------------------------- + * CommandInput + * -----------------------------------------------------------------------------------------------*/ + +export type CommandInputProps = + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> + & Pick<ComponentAnatomy<typeof CommandAnatomy>, "inputContainerClass" | "inputIconClass"> + +export const CommandInput = React.forwardRef<HTMLInputElement, CommandInputProps>((props, ref) => { + const { + className, + inputContainerClass, + inputIconClass, + ...rest + } = props + + const { + inputContainerClass: _inputContainerClass, + inputIconClass: _inputIconClass, + } = React.useContext(__CommandAnatomyContext) + + return ( + <div className={cn(CommandAnatomy.inputContainer(), _inputContainerClass, inputContainerClass)} cmdk-input-wrapper=""> + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(CommandAnatomy.inputIcon(), _inputIconClass, inputIconClass)} + > + <circle cx="11" cy="11" r="8" /> + <path d="m21 21-4.3-4.3" /> + </svg> + <CommandPrimitive.Input + ref={ref} + className={cn(InputAnatomy.root({ + intent: "unstyled", + size: "sm", + isDisabled: rest.disabled, + }), className)} + {...rest} + /> + </div> + ) +}) +CommandInput.displayName = "CommandInput" + +/* ------------------------------------------------------------------------------------------------- + * CommandList + * -----------------------------------------------------------------------------------------------*/ + +export type CommandListProps = + React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> + +export const CommandList = React.forwardRef<HTMLDivElement, CommandListProps>((props, ref) => { + const { className, ...rest } = props + + const { listClass } = React.useContext(__CommandAnatomyContext) + + return ( + <CommandPrimitive.List + ref={ref} + className={cn(CommandAnatomy.list(), listClass, className)} + {...rest} + /> + ) +}) +CommandList.displayName = "CommandList" + +/* ------------------------------------------------------------------------------------------------- + * CommandEmpty + * -----------------------------------------------------------------------------------------------*/ + +export type CommandEmptyProps = + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> + +export const CommandEmpty = React.forwardRef<HTMLDivElement, CommandEmptyProps>((props, ref) => { + const { className, ...rest } = props + + const { emptyClass } = React.useContext(__CommandAnatomyContext) + + return ( + <CommandPrimitive.Empty + ref={ref} + className={cn(CommandAnatomy.empty(), emptyClass, className)} + {...rest} + /> + ) +}) +CommandEmpty.displayName = "CommandEmpty" + +/* ------------------------------------------------------------------------------------------------- + * CommandGroup + * -----------------------------------------------------------------------------------------------*/ + +export type CommandGroupProps = + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> + +export const CommandGroup = React.forwardRef<HTMLDivElement, CommandGroupProps>((props, ref) => { + const { className, ...rest } = props + + const { groupClass } = React.useContext(__CommandAnatomyContext) + + return ( + <CommandPrimitive.Group + ref={ref} + className={cn(CommandAnatomy.group(), groupClass, className)} + {...rest} + /> + ) +}) +CommandGroup.displayName = "CommandGroup" + +/* ------------------------------------------------------------------------------------------------- + * CommandSeparator + * -----------------------------------------------------------------------------------------------*/ + +export type CommandSeparatorProps = + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> + +export const CommandSeparator = React.forwardRef<HTMLDivElement, CommandSeparatorProps>((props, ref) => { + const { className, ...rest } = props + + const { separatorClass } = React.useContext(__CommandAnatomyContext) + + return ( + <CommandPrimitive.Separator + ref={ref} + className={cn(CommandAnatomy.separator(), separatorClass, className)} + {...rest} + /> + ) +}) +CommandSeparator.displayName = "CommandSeparator" + +/* ------------------------------------------------------------------------------------------------- + * CommandItem + * -----------------------------------------------------------------------------------------------*/ + +export type CommandItemProps = + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> + & Pick<ComponentAnatomy<typeof CommandAnatomy>, "itemIconContainerClass"> + & { leftIcon?: React.ReactNode } + +export const CommandItem = React.forwardRef<HTMLDivElement, CommandItemProps>((props, ref) => { + const { className, itemIconContainerClass, leftIcon, children, ...rest } = props + + const { + itemClass, + itemIconContainerClass: _itemIconContainerClass, + } = React.useContext(__CommandAnatomyContext) + + const itemRef = React.useRef<HTMLDivElement | null>(null) + + React.useEffect(() => { + const element = itemRef.current + if (!element) return + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === "aria-selected" && element.getAttribute("aria-selected") === "true") { + element.scrollIntoView({ block: "nearest" }) + } + }) + }) + + observer.observe(element, { attributes: true }) + return () => observer.disconnect() + }, []) + + const setRefs = React.useCallback( + (node: HTMLDivElement | null) => { + itemRef.current = node + + if (ref) { + if (typeof ref === "function") { + ref(node) + } + } + }, + [ref], + ) + + return ( + <CommandPrimitive.Item + ref={setRefs} + className={cn(CommandAnatomy.item(), itemClass, className)} + {...rest} + data-cmdkvalue={rest.id} + > + {leftIcon && ( + <span className={cn(CommandAnatomy.itemIconContainer(), _itemIconContainerClass, itemIconContainerClass)}> + {leftIcon} + </span> + )} + {children} + </CommandPrimitive.Item> + ) +}) +CommandItem.displayName = "CommandItem" + +/* ------------------------------------------------------------------------------------------------- + * CommandShortcut + * -----------------------------------------------------------------------------------------------*/ + +export type CommandShortcutProps = React.ComponentPropsWithoutRef<"span"> + +export const CommandShortcut = React.forwardRef<HTMLSpanElement, CommandShortcutProps>((props, ref) => { + const { className, ...rest } = props + + const { shortcutClass } = React.useContext(__CommandAnatomyContext) + + return ( + <span + ref={ref} + className={cn(CommandAnatomy.shortcut(), shortcutClass, className)} + {...rest} + /> + ) +}) +CommandShortcut.displayName = "CommandShortcut" + +/* ------------------------------------------------------------------------------------------------- + * CommandDialog + * -----------------------------------------------------------------------------------------------*/ + +export type CommandDialogProps = ModalProps & ComponentAnatomy<typeof CommandDialogAnatomy> & { + commandProps: React.ComponentPropsWithoutRef<typeof CommandPrimitive> +} + +export const CommandDialog = (props: CommandDialogProps) => { + const { children, commandClass, contentClass, commandProps, ...rest } = props + return ( + <Modal + {...rest} + contentClass={cn(CommandDialogAnatomy.content(), contentClass)} + > + <Command shouldFilter={false} className={cn(CommandDialogAnatomy.command(), commandClass)} {...commandProps}> + {children} + </Command> + </Modal> + ) +} + +CommandDialog.displayName = "CommandDialog" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/command/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/command/index.tsx new file mode 100644 index 0000000..2ce0e8e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/command/index.tsx @@ -0,0 +1 @@ +export * from "./command" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/context-menu/context-menu.tsx b/seanime-2.9.10/seanime-web/src/components/ui/context-menu/context-menu.tsx new file mode 100644 index 0000000..7e7f305 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/context-menu/context-menu.tsx @@ -0,0 +1,385 @@ +"use client" + +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const ContextMenuAnatomy = defineStyleAnatomy({ + subTrigger: cva([ + "UI-ContextMenu__subTrigger", + "focus:bg-[--subtle] data-[state=open]:bg-[--subtle]", + ]), + subContent: cva([ + "UI-ContextMenu__subContent", + "z-50 min-w-[12rem] overflow-hidden rounded-[--radius] border bg-[--background] p-2 text-[--foreground] shadow-sm", + "data-[state=open]:animate-in", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + ]), + trigger: cva([ + "UI-ContextMenu__trigger", + ]), + content: cva([ + "UI-ContextMenu__content", + ]), + root: cva([ + "UI-ContextMenu__root", + "z-50 min-w-[15rem] overflow-hidden rounded-[--radius] border bg-[--background] p-2 text-[--foreground] shadow-sm", + "data-[state=open]:animate-in", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + ]), + item: cva([ + "UI-ContextMenu__item", + "relative flex cursor-default select-none items-center rounded-[--radius] cursor-pointer px-2 py-2 text-sm outline-none transition-colors", + "focus:bg-[--subtle] data-[disabled]:pointer-events-none", + "data-[disabled]:opacity-50", + "[&>svg]:mr-2 [&>svg]:text-lg", + ]), + group: cva([ + "UI-ContextMenu__group", + ]), + label: cva([ + "UI-ContextMenu__label", + "px-2 py-1.5 text-sm font-semibold text-[--muted]", + ]), + separator: cva([ + "UI-ContextMenu__separator", + "-mx-1 my-1 h-px bg-[--border]", + ]), + shortcut: cva([ + "UI-ContextMenu__shortcut", + "ml-auto text-xs tracking-widest opacity-60", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * ContextMenu + * -----------------------------------------------------------------------------------------------*/ + +const __ContextMenuAnatomyContext = React.createContext<ComponentAnatomy<typeof ContextMenuAnatomy> & { className?: string }>({}) + +export type ContextMenuProps = + ComponentAnatomy<typeof ContextMenuAnatomy> & + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root> & + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & { + /** + * Interaction with outside elements will be enabled and other elements will be visible to screen readers. + */ + allowOutsideInteraction?: boolean + /** + * The trigger element that is always visible and is used to open the menu. + */ + trigger?: React.ReactNode +} + +export const ContextMenu = React.forwardRef<HTMLDivElement, ContextMenuProps>((props, ref) => { + const { + children, + trigger, + // Root + onOpenChange, + dir, + allowOutsideInteraction, + className, + subContentClass, + subTriggerClass, + shortcutClass, + itemClass, + labelClass, + separatorClass, + groupClass, + ...rest + } = props + + return ( + <__ContextMenuAnatomyContext.Provider + value={{ + className, + subContentClass, + subTriggerClass, + shortcutClass, + itemClass, + labelClass, + separatorClass, + groupClass, + }} + > + <ContextMenuPrimitive.Root + dir={dir} + modal={!allowOutsideInteraction} + {...rest} + > + + {children} + + </ContextMenuPrimitive.Root> + </__ContextMenuAnatomyContext.Provider> + ) +}) + +ContextMenu.displayName = "ContextMenu" + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuTrigger + * -----------------------------------------------------------------------------------------------*/ + +export type ContextMenuTriggerProps = React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Trigger> + +export const ContextMenuTrigger = React.forwardRef<HTMLDivElement, ContextMenuTriggerProps>((props, ref) => { + const { className, ...rest } = props + + const { triggerClass } = React.useContext(__ContextMenuAnatomyContext) + + return <ContextMenuPrimitive.Trigger ref={ref} className={cn(triggerClass, className)} {...rest} /> +}) + + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuContent + * -----------------------------------------------------------------------------------------------*/ + +export type ContextMenuContentProps = React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> + +export const ContextMenuContent = React.forwardRef<HTMLDivElement, ContextMenuContentProps>((props, ref) => { + const { className, ...rest } = props + + const { className: rootClass, contentClass } = React.useContext(__ContextMenuAnatomyContext) + + return ( + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Content + ref={ref} + className={cn(ContextMenuAnatomy.root(), rootClass, contentClass, className)} + {...rest} + /> + </ContextMenuPrimitive.Portal> + ) +}) + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuGroup + * -----------------------------------------------------------------------------------------------*/ + +export type ContextMenuGroupProps = React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Group> + +export const ContextMenuGroup = React.forwardRef<HTMLDivElement, ContextMenuGroupProps>((props, ref) => { + const { className, ...rest } = props + + const { groupClass } = React.useContext(__ContextMenuAnatomyContext) + + return ( + <ContextMenuPrimitive.Group + ref={ref} + className={cn(ContextMenuAnatomy.group(), groupClass, className)} + {...rest} + /> + ) +}) + +ContextMenuGroup.displayName = "ContextMenuGroup" + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuSub + * -----------------------------------------------------------------------------------------------*/ + +export type ContextMenuSubProps = + Pick<ComponentAnatomy<typeof ContextMenuAnatomy>, "subTriggerClass"> & + Pick<React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Sub>, "defaultOpen" | "open" | "onOpenChange"> & + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> & { + /** + * The content of the default trigger element that will open the sub menu. + * + * By default, the trigger will be an item with a right chevron icon. + */ + triggerContent?: React.ReactNode + /** + * Props to pass to the default trigger element that will open the sub menu. + */ + triggerProps?: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> + triggerInset?: boolean +} + +export const ContextMenuSub = React.forwardRef<HTMLDivElement, ContextMenuSubProps>((props, ref) => { + const { + children, + triggerContent, + triggerProps, + triggerInset, + // Sub + defaultOpen, + open, + onOpenChange, + // SubContent + sideOffset = 8, + className, + subTriggerClass, + ...rest + } = props + + const { subTriggerClass: _subTriggerClass, subContentClass } = React.useContext(__ContextMenuAnatomyContext) + + return ( + <ContextMenuPrimitive.Sub + {...rest} + > + <ContextMenuPrimitive.SubTrigger + className={cn( + ContextMenuAnatomy.item(), + ContextMenuAnatomy.subTrigger(), + triggerInset && "pl-8", + _subTriggerClass, + subTriggerClass, + className, + )} + {...triggerProps} + > + {triggerContent} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn( + ContextMenuAnatomy.shortcut(), + "w-4 h-4 ml-auto", + )} + > + <path d="m9 18 6-6-6-6" /> + </svg> + </ContextMenuPrimitive.SubTrigger> + + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.SubContent + ref={ref} + sideOffset={sideOffset} + className={cn( + ContextMenuAnatomy.subContent(), + subContentClass, + className, + )} + {...rest} + > + {children} + </ContextMenuPrimitive.SubContent> + </ContextMenuPrimitive.Portal> + </ContextMenuPrimitive.Sub> + ) +}) + +ContextMenuSub.displayName = "ContextMenuSub" + + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuItem + * -----------------------------------------------------------------------------------------------*/ + +export type ContextMenuItemProps = React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { + inset?: boolean +} + +export const ContextMenuItem = React.forwardRef<HTMLDivElement, ContextMenuItemProps>((props, ref) => { + const { className, inset, ...rest } = props + + const { itemClass } = React.useContext(__ContextMenuAnatomyContext) + + return ( + <ContextMenuPrimitive.Item + ref={ref} + className={cn( + ContextMenuAnatomy.item(), + inset && "pl-8", + itemClass, + className, + )} + {...rest} + /> + ) +}) +ContextMenuItem.displayName = "ContextMenuItem" + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuLabel + * -----------------------------------------------------------------------------------------------*/ + +export type ContextMenuLabelProps = React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { + inset?: boolean +} + +export const ContextMenuLabel = React.forwardRef<HTMLDivElement, ContextMenuLabelProps>((props, ref) => { + const { className, inset, ...rest } = props + + const { labelClass } = React.useContext(__ContextMenuAnatomyContext) + + return ( + <ContextMenuPrimitive.Label + ref={ref} + className={cn( + ContextMenuAnatomy.label(), + inset && "pl-8", + labelClass, + className, + )} + {...rest} + /> + ) +}) + +ContextMenuLabel.displayName = "ContextMenuLabel" + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuSeparator + * -----------------------------------------------------------------------------------------------*/ + +export type ContextMenuSeparatorProps = React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> + +export const ContextMenuSeparator = React.forwardRef<HTMLDivElement, ContextMenuSeparatorProps>((props, ref) => { + const { className, ...rest } = props + + const { separatorClass } = React.useContext(__ContextMenuAnatomyContext) + + return ( + <ContextMenuPrimitive.Separator + ref={ref} + className={cn(ContextMenuAnatomy.separator(), separatorClass, className)} + {...rest} + /> + ) +}) + +ContextMenuSeparator.displayName = "ContextMenuSeparator" + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuShortcut + * -----------------------------------------------------------------------------------------------*/ + +export type ContextMenuShortcutProps = React.HTMLAttributes<HTMLSpanElement> + +export const ContextMenuShortcut = React.forwardRef<HTMLSpanElement, ContextMenuShortcutProps>((props, ref) => { + const { className, ...rest } = props + + const { shortcutClass } = React.useContext(__ContextMenuAnatomyContext) + + return ( + <span + ref={ref} + className={cn(ContextMenuAnatomy.shortcut(), shortcutClass, className)} + {...rest} + /> + ) +}) + +ContextMenuShortcut.displayName = "ContextMenuShortcut" + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/context-menu/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/context-menu/index.tsx new file mode 100644 index 0000000..1f09a21 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/context-menu/index.tsx @@ -0,0 +1 @@ +export * from "./context-menu" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/core/hooks.ts b/seanime-2.9.10/seanime-web/src/components/ui/core/hooks.ts new file mode 100644 index 0000000..c9eea8c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/core/hooks.ts @@ -0,0 +1,70 @@ +import * as React from "react" + +/* ------------------------------------------------------------------------------------------------- + * useEventListener + * -----------------------------------------------------------------------------------------------*/ + +export function useEventListener< + KW extends keyof WindowEventMap, + KH extends keyof HTMLElementEventMap, + KM extends keyof MediaQueryListEventMap, + T extends HTMLElement | MediaQueryList | void = void, +>( + eventName: KW | KH | KM, + handler: ( + event: + | WindowEventMap[KW] + | HTMLElementEventMap[KH] + | MediaQueryListEventMap[KM] + | Event, + ) => void, + element?: React.RefObject<T>, + options?: boolean | AddEventListenerOptions, +) { + // Create a ref that stores handler + const savedHandler = React.useRef(handler) + + useIsomorphicLayoutEffect(() => { + savedHandler.current = handler + }, [handler]) + + React.useEffect(() => { + // Define the listening target + const targetElement: T | Window = element?.current ?? window + + if (!(targetElement && targetElement.addEventListener)) return + + // Create event listener that calls handler function stored in ref + const listener: typeof handler = event => savedHandler.current(event) + + targetElement.addEventListener(eventName, listener, options) + + // Remove event listener on cleanup + return () => { + targetElement.removeEventListener(eventName, listener, options) + } + }, [eventName, element, options]) +} + + +/* ------------------------------------------------------------------------------------------------- + * useIsomorphicLayoutEffect + * -----------------------------------------------------------------------------------------------*/ + +export const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect + +/* ------------------------------------------------------------------------------------------------- + * useUpdateEffect + * -----------------------------------------------------------------------------------------------*/ + +export function useUpdateEffect(effect: React.EffectCallback, deps?: React.DependencyList) { + const isInitialMount = React.useRef(true) + + React.useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false + } else { + return effect() + } + }, deps) +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/core/styling.ts b/seanime-2.9.10/seanime-web/src/components/ui/core/styling.ts new file mode 100644 index 0000000..2af1a35 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/core/styling.ts @@ -0,0 +1,48 @@ +import { cva } from "class-variance-authority" +import { ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export type Anatomy = { [key: string]: ReturnType<typeof cva> } + +// export type ComponentAnatomy<T extends Anatomy> = { +// [K in keyof T as `${string & K}Class`]?: string +// } + +export type ComponentAnatomy<T extends Anatomy> = { + [K in keyof T as K extends "root" ? never : `${string & K}Class`]?: string; +} + +/** + * @example + * const ComponentAnatomy = defineStyleAnatomy({ + * label: cva(null, { + * variants: { + * intent: { + * "success": "", + * "alert": "", + * }, + * }, + * }), + * ... + * }) + * + * type ComponentProps = ComponentWithAnatomy<typeof ComponentAnatomy> + * + * const MyComponent = React.forwardRef((props, forwardedRef) => { + * const { controlClass, ...rest }: ComponentProps = props + * + * return ( + * <div + * className={cn(ComponentAnatomy.control({ intent: "success" }, controlClass))} + * ref={forwardedRef} + * /> + * ) + * }) + */ +export function defineStyleAnatomy<A extends Anatomy = Anatomy>(config: A) { + return config +} + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/core/utils.ts b/seanime-2.9.10/seanime-web/src/components/ui/core/utils.ts new file mode 100644 index 0000000..358802a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/core/utils.ts @@ -0,0 +1,18 @@ +import type * as React from "react" + +export function mergeRefs<T = any>( + refs: Array<React.MutableRefObject<T> | React.LegacyRef<T> | undefined | null>, +): React.RefCallback<T> { + return (value) => { + refs.forEach((ref) => { + if (typeof ref === "function") { + ref(value) + } else if (ref != null) { + (ref as React.MutableRefObject<T | null>).current = value + } + }) + } +} + +export const isEmpty = (obj: any) => [Object, Array].includes((obj || {}).constructor) && !Object.entries((obj || {})).length + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/currency-input/currency-input.tsx b/seanime-2.9.10/seanime-web/src/components/ui/currency-input/currency-input.tsx new file mode 100644 index 0000000..7e653cb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/currency-input/currency-input.tsx @@ -0,0 +1,238 @@ +"use client" + +import * as React from "react" +import CurrencyInputPrimitive from "react-currency-input-field" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { cn } from "../core/styling" +import { extractInputPartProps, InputAddon, InputAnatomy, InputContainer, InputIcon, InputStyling } from "../input" + +/* ------------------------------------------------------------------------------------------------- + * CurrencyInput + * -----------------------------------------------------------------------------------------------*/ + +export type CurrencyInputIntlConfig = { + /** + * e.g. en-US + */ + locale: string + /** + * e.g. USD + */ + currency?: string +} + +export type CurrentInputValues = { + /** + * Value as float or null if empty + * e.g. "1.99" -> 1.99 | "" -> null + */ + float: number | null + /** + * Value after applying formatting + * e.g. "1000000" -> "1,000,0000" + */ + formatted: string + /** + * Non formatted value as string + */ + value: string +} + +export type CurrencyInputProps = + Omit<React.ComponentPropsWithoutRef<"input">, "size" | "disabled" | "defaultValue"> & + InputStyling & + BasicFieldOptions & { + /** + * Allow decimals + * @default true + */ + allowDecimals?: boolean + /** + * Allow user to enter negative value + * @default true + */ + allowNegativeValue?: boolean + /** + * Maximum characters the user can enter + */ + maxLength?: number + /** + * Limit length of decimals allowed + * @default 2 + */ + decimalsLimit?: number + /** + * Specify decimal scale for padding/trimming + * e.g. 1.5 -> 1.50 | 1.234 -> 1.23 + */ + decimalScale?: number + /** + * Default value if uncontrolled + */ + defaultValue?: number | string + /** + * Value will always have the specified length of decimals + * e.g. 123 -> 1.23 + * Note: This formatting only happens onBlur + */ + fixedDecimalLength?: number + /** + * Placeholder if there is no value + */ + placeholder?: string + /** + * Include a prefix + * e.g. £ + */ + prefix?: string + /** + * Include a suffix + * e.g. € + */ + suffix?: string + /** + * Incremental value change on arrow down and arrow up key press + */ + step?: number + /** + * Separator between integer part and fractional part of value. + */ + decimalSeparator?: string + /** + * Separator between thousand, million and billion. + */ + groupSeparator?: string + /** + * Disable auto adding separator between values + * e.g. 1000 -> 1,000 + * @default false + */ + disableGroupSeparators?: boolean + /** + * Disable abbreviations (m, k, b) + * @default false + */ + disableAbbreviations?: boolean + /** + * International locale config + * e.g. { locale: 'ja-JP', currency: 'JPY' } + * Any prefix, groupSeparator or decimalSeparator options passed in will override Intl Locale config + */ + intlConfig?: CurrencyInputIntlConfig + /** + * Transform the raw value form the input before parsing + */ + transformRawValue?: (rawValue: string) => string + /** + * Callback invoked when value changes + */ + onValueChange?: (value: (string | undefined), values?: CurrentInputValues) => void +} + +export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>((props, ref) => { + + const [props1, basicFieldProps] = extractBasicFieldProps<CurrencyInputProps>(props, React.useId()) + + const [{ + size, + intent, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + className, + /**/ + value, + onValueChange, + transformRawValue, + intlConfig, + allowDecimals, + allowNegativeValue, + decimalsLimit, + decimalScale, + disabled, + fixedDecimalLength, + placeholder, + prefix, + suffix, + step, + decimalSeparator, + groupSeparator, + disableGroupSeparators, + disableAbbreviations, + defaultValue, + ...rest + }, { + inputContainerProps, + leftAddonProps, + leftIconProps, + rightAddonProps, + rightIconProps, + }] = extractInputPartProps<CurrencyInputProps>({ + ...props1, + size: props1.size ?? "md", + intent: props1.intent ?? "basic", + leftAddon: props1.leftAddon, + leftIcon: props1.leftIcon, + rightAddon: props1.rightAddon, + rightIcon: props1.rightIcon, + }) + + return ( + <BasicField{...basicFieldProps}> + <InputContainer {...inputContainerProps}> + <InputAddon {...leftAddonProps} /> + <InputIcon {...leftIconProps} /> + + <CurrencyInputPrimitive + ref={ref} + id={basicFieldProps.id} + name={basicFieldProps.name} + defaultValue={defaultValue} + className={cn( + "form-input", + InputAnatomy.root({ + size, + intent, + hasError: !!basicFieldProps.error, + isDisabled: !!basicFieldProps.disabled, + isReadonly: !!basicFieldProps.readonly, + hasRightAddon: !!rightAddon, + hasRightIcon: !!rightIcon, + hasLeftAddon: !!leftAddon, + hasLeftIcon: !!leftIcon, + }), + className, + )} + disabled={basicFieldProps.disabled || basicFieldProps.readonly} + data-disabled={basicFieldProps.disabled} + required={basicFieldProps.required} + value={value} + onValueChange={(value, _, values) => onValueChange?.(value, values)} + transformRawValue={transformRawValue} + intlConfig={intlConfig} + allowDecimals={allowDecimals} + allowNegativeValue={allowNegativeValue} + decimalsLimit={decimalsLimit} + decimalScale={decimalScale} + fixedDecimalLength={fixedDecimalLength} + placeholder={placeholder} + prefix={prefix} + suffix={suffix} + step={step} + decimalSeparator={decimalSeparator} + groupSeparator={groupSeparator} + disableGroupSeparators={disableGroupSeparators} + disableAbbreviations={disableAbbreviations} + {...rest} + /> + + <InputAddon {...rightAddonProps} /> + <InputIcon {...rightIconProps} /> + </InputContainer> + </BasicField> + ) + +}) + +CurrencyInput.displayName = "CurrencyInput" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/currency-input/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/currency-input/index.tsx new file mode 100644 index 0000000..6053b39 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/currency-input/index.tsx @@ -0,0 +1 @@ +export * from "./currency-input" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid-cell-input-field.tsx b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid-cell-input-field.tsx new file mode 100644 index 0000000..9018a0d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid-cell-input-field.tsx @@ -0,0 +1,118 @@ +"use client" + +import { Cell, Row, Table } from "@tanstack/react-table" +import { cva } from "class-variance-authority" +import * as React from "react" +import { z, ZodTypeAny } from "zod" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { DataGridEditingHelper } from "./helpers" +import { DataGridValidationRowErrors } from "./use-datagrid-editing" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const DataGridCellInputFieldAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-DataGridCellInputField__root", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * DataGridCellInputField + * -----------------------------------------------------------------------------------------------*/ + +/** + * Context passed to a field in order to render a cell input + * @example + * withEditing({ field: (ctx: DataGridCellInputFieldContext) => <></> }) + */ +export type DataGridEditingFieldContext<T> = { + value: T, + onChange: (value: T) => void + ref: React.MutableRefObject<any> +} + +/** + * @internal + */ +export type DataGridEditingValueUpdater<T extends Record<string, any>> = ( + value: unknown, + row: Row<T>, + cell: Cell<T, unknown>, + zodType: ZodTypeAny | undefined, +) => void + +/** + * @internal + */ +export type DataGridCellInputFieldProps<T extends Record<string, any>> = ComponentAnatomy<typeof DataGridCellInputFieldAnatomy> & { + /** + * Meta information about the field from the column definition + * - This is defined by the `withEditing` helper + */ + meta: DataGridEditingHelper + /** Cell being edited */ + cell: Cell<T, unknown> + /** Table instance */ + table: Table<T> + /** Row being edited */ + row: Row<T> + /** Errors coming from the built-in row validation (useDataGridEditing) */ + rowErrors: DataGridValidationRowErrors + /** Emits updates to the hook (useDataGridEditing) */ + onValueUpdated: DataGridEditingValueUpdater<T> + /** Field container class name */ + className?: string +} + +export function DataGridCellInputField<Schema extends z.ZodObject<z.ZodRawShape>, T extends Record<string, any>, Key extends keyof z.infer<Schema>> +(props: DataGridCellInputFieldProps<T>) { + + const { + className, + cell, + table, + row, + rowErrors, + onValueUpdated, + meta: { + field, + zodType, + valueFormatter: _valueFormatter, + }, + } = props + const defaultValueFormatter = (value: any) => value + const valueFormatter = (_valueFormatter ?? defaultValueFormatter) as (value: any) => any + + const cellValue = valueFormatter(cell.getContext().getValue()) + const inputRef = React.useRef<any>(null) + + const [value, setValue] = React.useState<unknown>(cellValue) + + React.useLayoutEffect(() => { + onValueUpdated(cellValue, row, cell, zodType) + inputRef.current?.focus() + }, []) + + return ( + <div className={cn(DataGridCellInputFieldAnatomy.root(), className)}> + {field({ + value: value, + onChange: (value => { + setValue(value) + onValueUpdated(valueFormatter(value), row, cell, zodType) + }), + ref: inputRef, + }, { + rowErrors: rowErrors, + table: table, + row: row, + cell: cell, + })} + </div> + ) + +} + +DataGridCellInputField.displayName = "DataGridCellInputField" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid-filter.tsx b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid-filter.tsx new file mode 100644 index 0000000..13e98a3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid-filter.tsx @@ -0,0 +1,234 @@ +"use client" + +import { Column } from "@tanstack/react-table" +import { cva } from "class-variance-authority" +import * as React from "react" +import { DataGridAnatomy, DataGridFilteringHelper, getColumnHelperMeta, getValueFormatter } from "." +import { CloseButton } from "../button" +import { CheckboxGroup } from "../checkbox" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { DateRangePicker } from "../date-picker" +import { DropdownMenu, DropdownMenuGroup, DropdownMenuItem } from "../dropdown-menu" +import { RadioGroup } from "../radio-group" +import { Select } from "../select" +import translations, { dateFnsLocales } from "./locales" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const DataGridFilterAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-DataGridFilter__root", + "flex items-center max-w-full gap-2", + ]), +}) + +export const DataGridActiveFilterAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-DataGridActiveFilter__root", + "py-1 px-2 rounded-[--radius] border flex gap-2 items-center", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * DataGridFilter + * -----------------------------------------------------------------------------------------------*/ + +export type DataGridFilterProps<T extends Record<string, any>> = React.ComponentPropsWithoutRef<"div"> & + ComponentAnatomy<typeof DataGridFilterAnatomy> & { + column: Column<T> + onRemove: () => void + lng?: string +} + +export function DataGridFilter<T extends Record<string, any>>(props: DataGridFilterProps<T>) { + + const { + children, + className, + column, + onRemove, + lng = "en", + ...rest + } = props + + const filterParams = getColumnHelperMeta(column, "filteringMeta")! + const filterValue = React.useMemo(() => column.getFilterValue(), [column.getFilterValue()]) as any + const setFilterValue = React.useMemo(() => column.setFilterValue, [column.setFilterValue]) + const icon = filterParams.icon + + // Value formatter - if undefined, use the default behavior + const valueFormatter = filterParams.valueFormatter || getValueFormatter(column) + + // Get the options + const options = filterParams.options ?? [] + + // Update handler + const handleUpdate = React.useCallback((value: any) => { + setFilterValue(value) + }, []) + + return ( + <div + className={cn(DataGridFilterAnatomy.root(), className)} + {...rest} + > + {(filterParams.type === "select" && (!options || options.length === 0)) && ( + <div className="text-red-500">/!\ "Select" filtering option passed without options</div> + )} + {/*Select*/} + {(filterParams.type === "select" && !!options && options.length > 0) && ( + <Select + leftIcon={icon ? icon : + <svg + xmlns="http://www.w3.org/2000/svg" width="18" height="24" viewBox="0 0 24 24" fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4" + > + <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /> + </svg>} + leftAddon={filterParams.name} + options={[...options.map(n => ({ value: n.value, label: valueFormatter(n.value) }))]} + onValueChange={v => handleUpdate(v.trim().toLowerCase())} + size="sm" + fieldClass="w-fit" + className="sm:w-auto pr-8 md:max-w-sm" + /> + )} + {/*Boolean*/} + {(filterParams.type === "boolean") && ( + <DropdownMenu + className="right-[inherit] left" + trigger={ + <DataGridActiveFilter + options={filterParams} + value={valueFormatter(filterValue)} + /> + } + > + <DropdownMenuGroup> + <DropdownMenuItem onClick={() => handleUpdate(true)}> + {typeof valueFormatter(true) === "boolean" ? translations["true"][lng] : valueFormatter(true)} + </DropdownMenuItem> + <DropdownMenuItem onClick={() => handleUpdate(false)}> + {typeof valueFormatter(false) === "boolean" ? translations["false"][lng] : valueFormatter(false)} + </DropdownMenuItem> + </DropdownMenuGroup> + </DropdownMenu> + )} + {/*Checkbox*/} + {(filterParams.type === "checkbox" && !!options.length) && ( + <DropdownMenu + className="right-[inherit] left" + trigger={ + <DataGridActiveFilter + options={filterParams} + value={Array.isArray(filterValue) ? + (filterValue as any).map((n: string) => valueFormatter(n)) : + valueFormatter(filterValue) + } + />} + > + <DropdownMenuGroup className="p-1"> + {filterParams.options?.length && ( + <CheckboxGroup + options={filterParams.options} + value={filterValue} + onValueChange={handleUpdate} + itemContainerClass="flex flex-row-reverse w-full justify-between" + itemLabelClass="cursor-pointer" + /> + )} + </DropdownMenuGroup> + </DropdownMenu> + )} + {/*Radio*/} + {(filterParams.type === "radio" && !!options.length) && ( + <DropdownMenu + className="right-[inherit] left" + trigger={ + <DataGridActiveFilter + options={filterParams} + value={Array.isArray(filterValue) ? + (filterValue as any).map((n: string) => valueFormatter(n)) : + valueFormatter(filterValue) + } + />} + > + <DropdownMenuGroup className="p-1"> + {filterParams.options?.length && ( + <RadioGroup + options={filterParams.options} + value={filterValue} + onValueChange={handleUpdate} + itemContainerClass="flex flex-row-reverse w-full justify-between" + itemLabelClass="cursor-pointer" + /> + )} + </DropdownMenuGroup> + </DropdownMenu> + )} + {/*Date*/} + {filterParams.type === "date-range" && ( + <div className={cn(DataGridAnatomy.filterDropdownButton(), "truncate overflow-ellipsis")}> + {filterParams.icon && <span>{filterParams.icon}</span>} + <span>{filterParams.name}:</span> + <DateRangePicker + value={filterValue ? { + from: filterValue.start, + to: filterValue.end, + } : undefined} + onValueChange={value => handleUpdate({ + start: value?.from, + end: value?.to, + })} + placeholder={translations["date-range-placeholder"][lng]} + intent="unstyled" + locale={dateFnsLocales[lng]} + /> + </div> + )} + + <CloseButton + intent="gray-outline" + onClick={onRemove} + size="md" + /> + </div> + ) + +} + +DataGridFilter.displayName = "DataGridFilter" + + +interface DataGridActiveFilterProps extends Omit<React.ComponentPropsWithRef<"button">, "value">, + ComponentAnatomy<typeof DataGridActiveFilterAnatomy> { + children?: React.ReactNode + options: DataGridFilteringHelper<any> + value: unknown +} + +export const DataGridActiveFilter = React.forwardRef<HTMLButtonElement, DataGridActiveFilterProps>((props, ref) => { + + const { children, options, value, ...rest } = props + + // Truncate and join the value to be displayed if it is an array + const displayedValue = Array.isArray(value) ? (value.length > 2 ? [...value.slice(0, 2), "..."].join(", ") : value.join(", ")) : String(value) + + return ( + <button + ref={ref} + className={cn(DataGridAnatomy.filterDropdownButton(), "truncate overflow-ellipsis")} {...rest} + > + {options.icon && <span>{options.icon}</span>} + <span>{options.name}:</span> + <span className="font-semibold flex flex-none overflow-hidden whitespace-normal">{displayedValue}</span> + </button> + ) + +}) + +DataGridActiveFilter.displayName = "DataGridActiveFilter" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid-instance.tsx b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid-instance.tsx new file mode 100644 index 0000000..b75b356 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid-instance.tsx @@ -0,0 +1,352 @@ +import { + ColumnDef, + ColumnFiltersState, + ColumnOrderState, + FilterFn, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + OnChangeFn, + PaginationState, + Row, + RowSelectionState, + SortingState, + useReactTable, + VisibilityState, +} from "@tanstack/react-table" +import * as React from "react" +import { AnyZodObject } from "zod" +import { Checkbox } from "../checkbox" +import { DataGridOnRowEdit, DataGridOnRowValidationError } from "./use-datagrid-editing" +import { dateRangeFilter } from "./use-datagrid-filtering" +import { DataGridOnRowSelect } from "./use-datagrid-row-selection" + +export type DataGridInstanceProps<T extends Record<string, any>> = { + data: T[] | null | undefined + rowCount: number + columns: ColumnDef<T>[] + isLoading?: boolean + + /** + * Hide columns below a certain breakpoint. + */ + hideColumns?: { below: number, hide: string[] }[] + columnOrder?: ColumnOrderState | undefined + + /* ------------------------------------------------------------------------------------------------- + * Row selection + * -----------------------------------------------------------------------------------------------*/ + + /** + * If true, rows will be selectable. + * A checkbox will be shown in the first column of each row. + * - Requires `rowSelectionPrimaryKey` for more accurate selection (default is row index) + */ + enableRowSelection?: boolean | ((row: Row<T>) => boolean) | undefined + /** + * Callback invoked when a row is selected. + */ + onRowSelect?: DataGridOnRowSelect<T> + /** + * The column used to uniquely identify the row. + */ + rowSelectionPrimaryKey?: string + /** + * Requires `rowSelectionPrimaryKey` + */ + enablePersistentRowSelection?: boolean + + /* ------------------------------------------------------------------------------------------------- + * Sorting + * -----------------------------------------------------------------------------------------------*/ + + enableSorting?: boolean + enableManualSorting?: boolean + + /* ------------------------------------------------------------------------------------------------- + * Filters + * -----------------------------------------------------------------------------------------------*/ + + enableColumnFilters?: boolean + enableFilters?: boolean + enableManualFiltering?: boolean + enableGlobalFilter?: boolean + + /* ------------------------------------------------------------------------------------------------- + * Pagination + * -----------------------------------------------------------------------------------------------*/ + + enableManualPagination?: boolean + + /* ------------------------------------------------------------------------------------------------- + * Editing + * -----------------------------------------------------------------------------------------------*/ + + /** + * Requires `enableOptimisticUpdates` + * NOTE: This will not work if your `validationSchema` contains server-side validation. + */ + enableOptimisticUpdates?: boolean + /** + * The column used to uniquely identify the row. + */ + optimisticUpdatePrimaryKey?: string + /** + * If true, a loading indicator will be shown while the row is being updated. + */ + isDataMutating?: boolean + /** + * Zod validation schema for the columns. + */ + validationSchema?: AnyZodObject + /** + * Callback invoked when a cell is successfully edited. + */ + onRowEdit?: DataGridOnRowEdit<T> + /** + * Callback invoked when a cell fails validation. + */ + onRowValidationError?: DataGridOnRowValidationError<T> + + initialState?: { + sorting?: SortingState + pagination?: PaginationState + rowSelection?: RowSelectionState + globalFilter?: string + columnFilters?: ColumnFiltersState + columnVisibility?: VisibilityState + } + + state?: { + sorting?: SortingState + pagination?: PaginationState + rowSelection?: RowSelectionState + globalFilter?: string + columnFilters?: ColumnFiltersState + columnVisibility?: VisibilityState + }, + + onSortingChange?: OnChangeFn<SortingState> + onPaginationChange?: OnChangeFn<PaginationState> + onRowSelectionChange?: OnChangeFn<RowSelectionState> + onGlobalFilterChange?: OnChangeFn<string> + onColumnFiltersChange?: OnChangeFn<ColumnFiltersState> + onColumnVisibilityChange?: OnChangeFn<VisibilityState> + + filterFns?: Record<string, FilterFn<T>> +} + +export function useDataGrid<T extends Record<string, any>>(props: DataGridInstanceProps<T>) { + + const defaultValues: Required<DataGridInstanceProps<T>["state"]> = { + globalFilter: "", + sorting: [], + pagination: { pageIndex: 0, pageSize: 5 }, + rowSelection: {}, + columnFilters: [], + columnVisibility: {}, + } + + const { + data: _actualData, + rowCount: _initialRowCount, + columns, + initialState, + state, + + onRowValidationError, + validationSchema, + + columnOrder, + + onSortingChange, + onPaginationChange, + onRowSelectionChange, + onGlobalFilterChange, + onColumnFiltersChange, + onColumnVisibilityChange, + + enableManualSorting = false, + enableManualFiltering = false, + enableManualPagination = false, + enableRowSelection = false, + enablePersistentRowSelection = false, + enableOptimisticUpdates = false, + + enableColumnFilters = true, + enableSorting = true, + enableFilters = true, + enableGlobalFilter = true, + + filterFns, + + ...rest + } = props + + const [data, setData] = React.useState<T[]>(_actualData ?? []) + + const [rowCount, setRowCount] = React.useState(_initialRowCount) + + React.useEffect(() => { + if (_actualData) setData(_actualData) + }, [_actualData]) + + React.useEffect(() => { + if (_initialRowCount) setRowCount(_initialRowCount) + }, [_initialRowCount]) + + const [globalFilter, setGlobalFilter] = React.useState<string>(initialState?.globalFilter ?? defaultValues.globalFilter) + const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(initialState?.rowSelection ?? defaultValues.rowSelection) + const [sorting, setSorting] = React.useState<SortingState>(initialState?.sorting ?? defaultValues.sorting) + const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(initialState?.columnFilters ?? defaultValues.columnFilters) + const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(initialState?.columnVisibility ?? defaultValues.columnVisibility) + const [pagination, setPagination] = React.useState<PaginationState>(initialState?.pagination ?? defaultValues.pagination) + + const pageCount = React.useMemo(() => Math.ceil(rowCount / pagination.pageSize) ?? -1, [rowCount, pagination.pageSize]) + + const columnsWithSelection = React.useMemo<ColumnDef<T>[]>(() => [{ + id: "_select", + size: 6, + maxSize: 6, + enableSorting: false, + disableSortBy: true, + disableGlobalFilter: true, + header: ({ table }) => { + return ( + <div className="px-1"> + <Checkbox + value={table.getIsSomeRowsSelected() ? "indeterminate" : table.getIsAllRowsSelected()} + onValueChange={() => table.toggleAllRowsSelected()} + fieldClass="w-fit" + /> + </div> + ) + }, + cell: ({ row }) => { + return ( + <div className=""> + <Checkbox + key={row.id} + value={row.getIsSomeSelected() ? "indeterminate" : row.getIsSelected()} + disabled={!row.getCanSelect()} + onValueChange={row.getToggleSelectedHandler()} + fieldClass="w-fit" + /> + </div> + ) + }, + }, ...columns], [columns]) + + const sortingState = React.useMemo(() => state?.sorting ?? sorting, [state?.sorting, sorting]) + const paginationState = React.useMemo(() => state?.pagination ?? pagination, [state?.pagination, pagination]) + const rowSelectionState = React.useMemo(() => state?.rowSelection ?? rowSelection, [state?.rowSelection, rowSelection]) + const globalFilterState = React.useMemo(() => state?.globalFilter ?? globalFilter, [state?.globalFilter, globalFilter]) + const columnFiltersState = React.useMemo(() => state?.columnFilters ?? columnFilters, [state?.columnFilters, columnFilters]) + const columnVisibilityState = React.useMemo(() => state?.columnVisibility ?? columnVisibility, [state?.columnVisibility, columnVisibility]) + + const changeHandler = React.useCallback((func: any, func2: any) => { + return ((updaterOrValue) => { + if (func) func(updaterOrValue) + if (func2) func2(updaterOrValue) + }) as OnChangeFn<any> + }, []) + + const table = useReactTable<T>({ + data: data, + columns: enableRowSelection ? columnsWithSelection : columns, + pageCount: pageCount, + globalFilterFn: (row, columnId, filterValue) => { + const safeValue: string = ((): string => { + const value: any = row.getValue(columnId) + return typeof value === "number" ? String(value) : value + })() + return safeValue?.trim().toLowerCase().includes(filterValue.trim().toLowerCase()) + }, + state: { + sorting: sortingState, + pagination: paginationState, + rowSelection: rowSelectionState, + globalFilter: globalFilterState, + columnFilters: columnFiltersState, + columnVisibility: columnVisibilityState, + columnOrder: columnOrder, + }, + onSortingChange: changeHandler(onSortingChange, setSorting), + onPaginationChange: changeHandler(onPaginationChange, setPagination), + onRowSelectionChange: changeHandler(onRowSelectionChange, setRowSelection), + onGlobalFilterChange: changeHandler(onGlobalFilterChange, setGlobalFilter), + onColumnFiltersChange: changeHandler(onColumnFiltersChange, setColumnFilters), + onColumnVisibilityChange: changeHandler(onColumnVisibilityChange, setColumnVisibility), + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: enableManualSorting ? undefined : getSortedRowModel(), + getFilteredRowModel: enableManualFiltering ? undefined : getFilteredRowModel(), + filterFns: { + dateRangeFilter: dateRangeFilter, + ...filterFns, + }, + manualPagination: enableManualPagination, + manualSorting: enableManualSorting, + manualFiltering: enableManualFiltering, + enableRowSelection: enableRowSelection, + enableSorting: enableSorting, + enableColumnFilters: enableColumnFilters, + enableFilters: enableFilters, + enableGlobalFilter: enableGlobalFilter, + getRowId: !!props.rowSelectionPrimaryKey ? (row) => row[props.rowSelectionPrimaryKey!] : undefined, + }) + + const displayedRows = React.useMemo(() => { + const pn = table.getState().pagination + if (enableManualPagination) { + return table.getRowModel().rows + } + return table.getRowModel().rows.slice(pn.pageIndex * pn.pageSize, (pn.pageIndex + 1) * pn.pageSize) + }, [table.getRowModel().rows, table.getState().pagination]) + + React.useEffect(() => { + table.setPageIndex(0) + }, [table.getState().globalFilter, table.getState().columnFilters]) + + React.useEffect(() => { + if (!enableManualPagination) { + setRowCount(table.getRowModel().rows.length) + } + }, [table.getRowModel().rows.length]) + + return { + ...rest, + + table, + displayedRows, + setData, + data, + pageCount, + rowCount, + columns, + + sorting: sortingState, + pagination: paginationState, + rowSelection: rowSelectionState, + globalFilter: globalFilterState, + columnFilters: columnFiltersState, + columnVisibility: columnVisibilityState, + + enableManualSorting, + enableManualFiltering, + enableManualPagination, + enableRowSelection, + enablePersistentRowSelection, + enableOptimisticUpdates, + enableGlobalFilter, + + validationSchema, + onRowValidationError, + + handleGlobalFilterChange: onGlobalFilterChange ?? setGlobalFilter, + handleColumnFiltersChange: onColumnFiltersChange ?? setColumnFilters, + + } + +} + +export type DataGridApi<T extends Record<string, any>> = ReturnType<typeof useDataGrid<T>> diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid.tsx b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid.tsx new file mode 100644 index 0000000..0addafb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/datagrid.tsx @@ -0,0 +1,699 @@ +"use client" + +import { flexRender } from "@tanstack/react-table" +import { cva } from "class-variance-authority" +import * as React from "react" +import { Button, IconButton } from "../button" +import { Card } from "../card" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { DropdownMenu, DropdownMenuItem } from "../dropdown-menu" +import { LoadingOverlay } from "../loading-spinner" +import { NumberInput } from "../number-input" +import { Pagination, PaginationTrigger } from "../pagination" +import { Select } from "../select" +import { Skeleton } from "../skeleton" +import { TextInput, TextInputProps } from "../text-input" +import { Tooltip } from "../tooltip" +import { DataGridCellInputField } from "./datagrid-cell-input-field" +import { DataGridFilter } from "./datagrid-filter" +import { DataGridApi, DataGridInstanceProps, useDataGrid } from "./datagrid-instance" +import { getColumnHelperMeta, getValueFormatter } from "./helpers" +import translations from "./locales" +import { useDataGridEditing } from "./use-datagrid-editing" +import { useDataGridFiltering } from "./use-datagrid-filtering" +import { useDataGridResponsiveness } from "./use-datagrid-responsiveness" +import { useDataGridRowSelection } from "./use-datagrid-row-selection" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const DataGridAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-DataGrid__root", + ]), + header: cva([ + "UI-DataGrid__header", + "block space-y-4 w-full mb-4", + ]), + toolbar: cva([ + "UI-DataGrid__toolbar", + "flex w-full items-center gap-4 flex-wrap", + ]), + tableContainer: cva([ + "UI-DataGrid__tableContainer", + "align-middle inline-block min-w-full max-w-full overflow-x-auto relative", + ]), + table: cva([ + "UI-DataGrid__table", + "w-full relative table-fixed", + ]), + tableHead: cva([ + "UI-DataGrid__tableHead", + "", + ]), + th: cva([ + "UI-DataGrid__th group/th", + "px-3 h-12 text-left text-sm font-bold", + "data-[is-selection-col=true]:px-3 data-[is-selection-col=true]:sm:px-1 data-[is-selection-col=true]:text-center", + ]), + titleChevronContainer: cva([ + "UI-DataGrid__titleChevronContainer", + "absolute flex items-center inset-y-0 top-1 -right-9 group", + ]), + titleChevron: cva([ + "UI-DataGrid__titleChevron", + "mr-3 h-4 w-4 text-gray-400 group-hover:text-gray-500 relative bottom-0.5", + ]), + tableBody: cva([ + "UI-DataGrid__tableBody", + "w-full relative border-b", + ]), + td: cva([ + "UI-DataGrid__td", + "px-2 py-2 w-full whitespace-nowrap text-base font-normal text-[--foreground]", + "data-[is-selection-col=true]:px-2 data-[is-selection-col=true]:sm:px-0 data-[is-selection-col=true]:text-center", + "data-[action-col=false]:truncate data-[action-col=false]:overflow-ellipsis", + "data-[row-selected=true]:bg-brand-50 dark:data-[row-selected=true]:bg-gray-800", + "data-[editing=true]:ring-1 data-[editing=true]:ring-[--ring] ring-inset", + "data-[editable=true]:hover:bg-[--subtle] md:data-[editable=true]:focus:ring-2 md:data-[editable=true]:focus:ring-[--slate]", + "focus:outline-none", + "border-b", + ]), + tr: cva([ + "UI-DataGrid__tr", + "hover:bg-[--subtle] truncate", + ]), + footer: cva([ + "UI-DataGrid__footer", + "flex flex-col sm:flex-row w-full items-center gap-2 justify-between p-2 mt-2 overflow-x-auto max-w-full", + ]), + footerPageDisplayContainer: cva([ + "UI-DataGrid__footerPageDisplayContainer", + "flex flex-none items-center gap-1 ml-2 text-sm", + ]), + footerPaginationInputContainer: cva([ + "UI-DataGrid__footerPaginationInputContainer", + "flex flex-none items-center gap-2", + ]), + filterDropdownButton: cva([ + "UI-DataGrid__filterDropdownButton", + "flex gap-2 items-center bg-[--paper] border rounded-[--radius] h-10 py-1 px-3 cursor-pointer hover:bg-[--subtle]", + "select-none focus-visible:ring-2 outline-none ring-[--ring]", + ]), + editingCard: cva([ + "UI-DataGrid__editingCard", + "flex items-center gap-2 rounded-[--radius-md] px-3 py-2", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * DataGrid + * -----------------------------------------------------------------------------------------------*/ + +export type DataGridProps<T extends Record<string, any>> = ComponentAnatomy<typeof DataGridAnatomy> & DataGridInstanceProps<T> & { + tableApi?: DataGridApi<T>, + globalSearchInputProps?: Partial<DataGridSearchInputProps> + hideGlobalSearchInput?: boolean + className?: string + lng?: string +} + +export function DataGrid<T extends Record<string, any>>(props: DataGridProps<T>) { + + const { + lng = "en", + className, + headerClass, + toolbarClass, + tableContainerClass, + tableHeadClass, + tableClass, + thClass, + titleChevronClass, + titleChevronContainerClass, + tableBodyClass, + trClass, + tdClass, + footerClass, + footerPageDisplayContainerClass, + footerPaginationInputContainerClass, + filterDropdownButtonClass, + editingCardClass, + tableApi, + globalSearchInputProps, + hideGlobalSearchInput, + ...rest + } = props + + const { + table, + data, + setData, + displayedRows, + globalFilter, + columnFilters, + handleGlobalFilterChange, + handleColumnFiltersChange, + isLoading, + isDataMutating, + hideColumns, + enablePersistentRowSelection, + onRowEdit, + onRowSelect, + rowSelectionPrimaryKey, + enableRowSelection, + enableOptimisticUpdates, + optimisticUpdatePrimaryKey, + enableManualPagination, + enableGlobalFilter, + validationSchema, + onRowValidationError, + } = (tableApi ?? useDataGrid<T>({ ...rest })) as DataGridApi<T> + + const isInLoadingState = isLoading || (!enableOptimisticUpdates && isDataMutating) + const { tableRef } = useDataGridResponsiveness({ table, hideColumns }) + + const { + selectedRowCount, + } = useDataGridRowSelection({ + table: table, + data: data, + displayedRows: displayedRows, + persistent: enablePersistentRowSelection, + onRowSelect: onRowSelect, + rowSelectionPrimaryKey: rowSelectionPrimaryKey, + enabled: !!enableRowSelection, + }) + + const { + getFilterDefaultValue, + unselectedFilterableColumns, + filteredColumns, + filterableColumns, + } = useDataGridFiltering({ + table: table, + columnFilters: columnFilters, + }) + + const { + handleStartEditing, + getIsCellActivelyEditing, + getIsCellEditable, + getIsCurrentlyEditing, + getFirstCellBeingEdited, + handleStopEditing, + handleOnSave, + handleUpdateValue, + rowErrors, + } = useDataGridEditing({ + table: table, + data: data, + rows: displayedRows, + onRowEdit: onRowEdit, + isDataMutating: isDataMutating, + enableOptimisticUpdates: enableOptimisticUpdates, + optimisticUpdatePrimaryKey: optimisticUpdatePrimaryKey, + manualPagination: enableManualPagination, + onDataChange: setData, + schema: validationSchema, + onRowValidationError: onRowValidationError, + }) + + + return ( + <div className={cn(DataGridAnatomy.root(), className)}> + <div className={cn(DataGridAnatomy.header(), headerClass)}> + + <div className={cn(DataGridAnatomy.toolbar(), toolbarClass)}> + {/* Search Box */} + {(enableGlobalFilter && !hideGlobalSearchInput) && ( + <DataGridSearchInput + value={globalFilter ?? ""} + onChange={value => handleGlobalFilterChange(String(value))} + {...globalSearchInputProps} + /> + )} + {/* Filter dropdown */} + {(unselectedFilterableColumns.length > 0) && ( + <DropdownMenu + trigger={ + <button + className={cn(DataGridAnatomy.filterDropdownButton(), filterDropdownButtonClass)} + > + <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className="w-4 h-4" + > + <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /> + </svg> + <span>{translations["filters"][lng]} ({unselectedFilterableColumns.length})</span> + </button> + } + > + {/*Filter list*/} + {unselectedFilterableColumns.map(col => { + const defaultValue = getFilterDefaultValue(col) + const icon = getColumnHelperMeta(col, "filteringMeta")?.icon + const name = getColumnHelperMeta(col, "filteringMeta")?.name + return ( + <DropdownMenuItem + key={col.id} + onClick={() => handleColumnFiltersChange(p => [...p, { + id: col.id, + value: defaultValue, + }])} + > + {icon && <span className="text-md mr-2">{icon}</span>} + <span>{name}</span> + </DropdownMenuItem> + ) + })} + </DropdownMenu> + )} + {/*Remove filters button*/} + {unselectedFilterableColumns.length !== filterableColumns.length && ( + <Tooltip + trigger={<IconButton + icon={ + <svg + xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" + stroke="currentColor" strokeWidth="2" + strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4" + > + <path d="M9 14 4 9l5-5" /> + <path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" /> + </svg> + } + intent="gray-outline" + size="md" + onClick={() => handleColumnFiltersChange([])} + />} + > + {translations["remove-filters"][lng]} + </Tooltip> + )} + {/*Selected row count*/} + {(selectedRowCount > 0) && <div className="text-sm"> + {selectedRowCount} {translations[`row${selectedRowCount > 1 ? "s" : ""}-selected`][lng]} + </div>} + </div> + + {/*Display filters*/} + {(filteredColumns.length > 0) && <div className={cn(DataGridAnatomy.toolbar(), toolbarClass)}> + {/*Display selected filters*/} + {filteredColumns.map(col => { + return ( + <DataGridFilter + key={col.id} + column={col} + onRemove={() => handleColumnFiltersChange(filters => [...filters.filter(filter => filter.id !== col.id)])} + lng={lng} + /> + ) + })} + </div>} + + {/*Manage editing*/} + {getIsCurrentlyEditing() && + <Card className={cn(DataGridAnatomy.editingCard(), editingCardClass)}> + <Button size="sm" onClick={handleOnSave} loading={isDataMutating}> + {translations["save"][lng]} + </Button> + <Button + size="sm" + onClick={handleStopEditing} + intent="gray-outline" + disabled={isDataMutating} + > + {translations["cancel"][lng]} + </Button> + </Card>} + + </div> + + {/* Table */} + <div ref={tableRef} className={cn(DataGridAnatomy.tableContainer(), tableContainerClass)}> + + <table className={cn(DataGridAnatomy.table(), tableClass)}> + + {/*Head*/} + + <thead className={cn(DataGridAnatomy.tableHead(), tableHeadClass)}> + {table.getHeaderGroups().map((headerGroup) => ( + <tr key={headerGroup.id}> + {headerGroup.headers.map((header, index) => ( + <th + key={header.id} + colSpan={header.colSpan} + scope="col" + className={cn(DataGridAnatomy.th(), thClass)} + data-is-selection-col={`${index === 0 && !!enableRowSelection}`} + style={{ width: header.getSize() }} + > + {((index !== 0 && !!enableRowSelection) || !enableRowSelection) ? <div + className={cn( + "flex items-center justify-between", + { + "cursor-pointer": header.column.getCanSort(), + }, + )} + > + {header.isPlaceholder ? null : ( + <div + className="flex relative items-center" + {...{ + onClick: header.column.getToggleSortingHandler(), + }} + > + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + <span + className={cn(DataGridAnatomy.titleChevronContainer(), titleChevronContainerClass)} + > + {header.column.getIsSorted() === "asc" && + <svg + xmlns="http://www.w3.org/2000/svg" width="24" + height="24" viewBox="0 0 24 24" + fill="none" stroke="currentColor" strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(DataGridAnatomy.titleChevron(), titleChevronClass)} + > + <polyline points="18 15 12 9 6 15" /> + </svg> + } + {header.column.getIsSorted() === "desc" && + <svg + xmlns="http://www.w3.org/2000/svg" width="24" + height="24" viewBox="0 0 24 24" + fill="none" stroke="currentColor" strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(DataGridAnatomy.titleChevron(), titleChevronClass)} + > + <polyline points="6 9 12 15 18 9" /> + </svg> + } + {(header.column.getIsSorted() === false && header.column.getCanSort()) && + <svg + xmlns="http://www.w3.org/2000/svg" width="24" + height="24" viewBox="0 0 24 24" + fill="none" stroke="currentColor" strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn( + DataGridAnatomy.titleChevron(), + "w-4 h-4 opacity-0 transition-opacity group-hover/th:opacity-100", + titleChevronClass, + )} + > + <path d="m7 15 5 5 5-5" /> + <path d="m7 9 5-5 5 5" /> + </svg> + } + </span> + </div> + )} + </div> : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + </th> + ))} + </tr> + ))} + </thead> + + {/*Body*/} + + <tbody className={cn(DataGridAnatomy.tableBody(), tableBodyClass)}> + + {displayedRows.map((row) => { + return ( + <tr key={row.id} className={cn(DataGridAnatomy.tr(), trClass)}> + {row.getVisibleCells().map((cell, index) => { + + // If cell is editable and cell's row is being edited + const isCurrentlyEditable = getIsCellEditable(cell.id) && !getIsCellActivelyEditing(cell.id) + && (!getIsCurrentlyEditing() || getFirstCellBeingEdited()?.rowId === cell.row.id) + + return ( + <td + key={cell.id} + className={cn(DataGridAnatomy.td(), tdClass)} + data-is-selection-col={`${index === 0 && enableRowSelection}`} // If cell is in the selection + // column + data-action-col={`${cell.column.id === "_actions"}`} // If cell is in the action column + data-row-selected={cell.getContext().row.getIsSelected()} // If cell's row is currently selected + data-editing={getIsCellActivelyEditing(cell.id)} // If cell is being edited + data-editable={isCurrentlyEditable} // If cell is editable + data-row-editing={getFirstCellBeingEdited()?.rowId === cell.row.id} // If cell's row is being edited + style={{ + width: cell.column.getSize(), + maxWidth: cell.column.columnDef.maxSize, + }} + onDoubleClick={() => React.startTransition(() => { + handleStartEditing(cell.id) + })} + onKeyUp={event => { + if (event.key === "Enter") React.startTransition(() => handleStartEditing(cell.id)) + }} + tabIndex={isCurrentlyEditable ? 0 : undefined} // Is focusable if it can be edited + > + {((!getIsCellEditable(cell.id) || !getIsCellActivelyEditing(cell.id))) && flexRender( + cell.column.columnDef.cell, + { + ...cell.getContext(), + renderValue: () => getValueFormatter(cell.column)(cell.getContext().getValue()), + }, + )} + {getIsCellActivelyEditing(cell.id) && ( + <DataGridCellInputField + cell={cell} + row={cell.row} + table={table} + rowErrors={rowErrors} + meta={getColumnHelperMeta(cell.column, "editingMeta")!} + onValueUpdated={handleUpdateValue} + /> + )} + </td> + ) + })} + </tr> + ) + })} + </tbody> + </table> + + {(isInLoadingState && displayedRows.length > 0) && ( + <LoadingOverlay className="backdrop-blur-[1px] bg-opacity-40 pt-0" /> + )} + + {/*Skeleton*/} + {(isInLoadingState && displayedRows.length === 0) && [...Array(5).keys()].map((i, idx) => ( + <Skeleton key={idx} className="rounded-none h-12" /> + ))} + + {/*No rows*/} + {(displayedRows.length === 0 && !isInLoadingState && filteredColumns.length === 0) && ( + <p className="flex w-full justify-center py-4"> + <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"> + <path + fill="#D1C4E9" + d="M38 7H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2zm0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2zm0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2z" + /> + <circle cx="38" cy="38" r="10" fill="#F44336" /> + <g fill="#fff"> + <path d="m43.31 41.181l-2.12 2.122l-8.485-8.484l2.121-2.122z" /> + <path d="m34.819 43.31l-2.122-2.12l8.484-8.485l2.122 2.121z" /> + </g> + </svg> + </p> + )} + + {/*No results with filters*/} + {(displayedRows.length === 0 && !isInLoadingState && filteredColumns.length > 0) && ( + <div className="w-full text-center py-4"> + <p className="flex w-full justify-center mb-4"> + <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"> + <path + fill="#D1C4E9" + d="M38 7H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2zm0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2zm0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2z" + /> + <circle cx="38" cy="38" r="10" fill="#F44336" /> + <g fill="#fff"> + <path d="m43.31 41.181l-2.12 2.122l-8.485-8.484l2.121-2.122z" /> + <path d="m34.819 43.31l-2.122-2.12l8.484-8.485l2.122 2.121z" /> + </g> + </svg> + </p> + <p>{translations["no-matching-result"][lng]}</p> + </div> + )} + </div> + + <div className={cn(DataGridAnatomy.footer(), footerClass)}> + + <Pagination> + <PaginationTrigger + direction="previous" + isChevrons + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage() || isInLoadingState} + /> + <PaginationTrigger + direction="previous" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage() || isInLoadingState} + /> + <PaginationTrigger + direction="next" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage() || isInLoadingState} + /> + <PaginationTrigger + direction="next" + isChevrons + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage() || isInLoadingState} + /> + </Pagination> + + <div className={cn(DataGridAnatomy.footerPageDisplayContainer(), footerPageDisplayContainerClass)}> + {table.getPageCount() > 0 && ( + <> + <div>{translations["page"][lng]}</div> + <strong> + {table.getState().pagination.pageIndex + 1} / {table.getPageCount()} + </strong> + </> + )} + </div> + + <div className={cn(DataGridAnatomy.footerPaginationInputContainer(), footerPaginationInputContainerClass)}> + {(data.length > 0) && <NumberInput + hideControls + value={table.getState().pagination.pageIndex + 1} + min={1} + onValueChange={v => { + const page = v ? v - 1 : 0 + React.startTransition(() => { + if (v <= table.getPageCount()) { + table.setPageIndex(page) + } + }) + }} + className="inline-flex flex-none items-center w-[3rem]" + size="sm" + />} + <Select + value={String(table.getState().pagination.pageSize)} + onValueChange={v => { + table.setPageSize(Number(v)) + }} + options={[Number(table.getState().pagination.pageSize), + ...[5, 10, 20, 30, 40, 50].filter(n => n !== Number(table.getState().pagination.pageSize))].map(pageSize => ({ + value: String(pageSize), + label: String(pageSize), + }))} + fieldClass="w-auto" + className="w-auto" + disabled={isInLoadingState} + size="sm" + /> + </div> + + </div> + + </div> + ) + +} + +DataGrid.displayName = "DataGrid" + +/* ------------------------------------------------------------------------------------------------- + * DataGridSearchInput + * -----------------------------------------------------------------------------------------------*/ + +type DataGridSearchInputProps = Omit<TextInputProps, "onChange"> & { + value: string, + onChange: (value: string) => void + debounce?: number +} + +export function DataGridSearchInput(props: DataGridSearchInputProps) { + + const { value: initialValue, onChange, debounce = 500, ...rest } = props + + const [value, setValue] = React.useState(initialValue) + + React.useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + React.useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + }, [value]) + + return ( + <TextInput + size="md" + fieldClass="md:max-w-[30rem]" + {...rest} + value={value} + onChange={e => setValue(e.target.value)} + leftIcon={<svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className="w-5 h-5 text-[--muted]" + > + <circle cx="11" cy="11" r="8" /> + <path d="m21 21-4.3-4.3" /> + </svg>} + /> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * DataGridWithApi + * -----------------------------------------------------------------------------------------------*/ + +export type DataGridWithApiProps<T extends Record<string, any>> = ComponentAnatomy<typeof DataGridAnatomy> & { + api: DataGridApi<T> +} + +export function DataGridWithApi<T extends Record<string, any>>(props: DataGridWithApiProps<T>) { + + const { + api, + ...rest + } = props + + const { + data, + rowCount, + columns, + } = api + + return <DataGrid + data={data} + rowCount={rowCount} + columns={columns} + tableApi={api} + {...rest} + /> + +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/helpers.ts b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/helpers.ts new file mode 100644 index 0000000..57d3bdb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/helpers.ts @@ -0,0 +1,145 @@ +import { BuiltInFilterFn, Cell, Column, ColumnDef, Row, Table } from "@tanstack/react-table" +import React from "react" +import { AnyZodObject, z, ZodAny, ZodTypeAny } from "zod" +import { DataGridEditingFieldContext } from "./datagrid-cell-input-field" +import { DataGridValidationRowErrors } from "./use-datagrid-editing" + +/* ------------------------------------------------------------------------------------------------- + * Editing + * -----------------------------------------------------------------------------------------------*/ + +export type DataGridEditingHelper<T extends any = unknown, ZodType extends ZodTypeAny = ZodAny> = { + zodType?: ZodType + field: ( + context: DataGridEditingFieldContext<ZodType extends ZodAny ? T : z.infer<ZodType>>, + options: { + rowErrors: DataGridValidationRowErrors + table: Table<any> + row: Row<any> + cell: Cell<any, unknown> + }, + ) => React.ReactElement + valueFormatter?: <K = z.infer<ZodType>, R = z.infer<ZodType>>(value: K) => R +} + +function withEditing<T extends any = unknown, ZodType extends ZodTypeAny = ZodAny>(params: DataGridEditingHelper<T, ZodType>) { + return { + editingMeta: { + ...params, + }, + } +} + +/* ------------------------------------------------------------------------------------------------- + * Filtering + * -----------------------------------------------------------------------------------------------*/ + +export type DataGridFilteringType = "select" | "radio" | "checkbox" | "boolean" | "date-range" + +export interface FilterFns { + dateRangeFilter: any +} + +type _DefaultFilteringProps = { + type: DataGridFilteringType + name: string, + icon?: React.ReactElement + options?: { value: string, label?: any }[] + valueFormatter?: (value: any) => any +} + +type DefaultFilteringProps<T extends DataGridFilteringType> = { + type: T + name: string, + icon?: React.ReactElement + options: { value: string, label?: T extends "select" ? string : React.ReactNode }[] + valueFormatter?: (value: any) => any +} + +// Improve type safety by removing "options" when the type doesn't need it +export type DataGridFilteringHelper<T extends DataGridFilteringType = "select"> = + T extends Extract<DataGridFilteringType, "select" | "radio" | "checkbox"> + ? DefaultFilteringProps<T> + : Omit<DefaultFilteringProps<T>, "options"> + +/** + * Built-in filter functions supported DataGrid + */ +export type DataGridSupportedFilterFn = + Extract<BuiltInFilterFn, "equals" | "equalsString" | "arrIncludesSome" | "inNumberRange"> + | "dateRangeFilter" + +function withFiltering<T extends DataGridFilteringType>(params: DataGridFilteringHelper<T>) { + return { + filteringMeta: { + ...params, + }, + } +} + +const getFilterFn = (type: DataGridFilteringType) => { + const fns: { [key: string]: DataGridSupportedFilterFn } = { + select: "equalsString", + boolean: "equals", + checkbox: "arrIncludesSome", + radio: "equalsString", + "date-range": "dateRangeFilter", + } + return fns[type] as any +} + +/* ------------------------------------------------------------------------------------------------- + * Value formatter + * -----------------------------------------------------------------------------------------------*/ + +function withValueFormatter<T extends any, R extends any = any>(callback: (value: T) => R) { + return { + valueFormatter: callback, + } +} + +export function getValueFormatter<T>(column: Column<T>): (value: any) => any { + return (column.columnDef.meta as any)?.valueFormatter || ((value: any) => value) +} + +/* ------------------------------------------------------------------------------------------------- + * Column Def Helpers + * -----------------------------------------------------------------------------------------------*/ + +export type DataGridHelpers = "filteringMeta" | "editingMeta" | "valueFormatter" + +export type DataGridColumnDefHelpers<T extends Record<string, any>> = { + withFiltering: typeof withFiltering + getFilterFn: typeof getFilterFn + withEditing: typeof withEditing + withValueFormatter: typeof withValueFormatter +} + +/** + * Return + * @example + * const columns = useMemo(() => defineDataGridColumns<T>(() => [ + * ... + * ]), []) + * @param callback + */ +export function defineDataGridColumns<T extends Record<string, any>, Schema extends AnyZodObject = any>( + callback: (helpers: DataGridColumnDefHelpers<T>, schema?: Schema) => Array<ColumnDef<T>>, +) { + return callback({ + withFiltering, + getFilterFn, + withEditing, + withValueFormatter, + }) +} + + +export function getColumnHelperMeta<T, K extends DataGridHelpers>(column: Column<T>, helper: K) { + return (column.columnDef.meta as any)?.[helper] as ( + K extends "filteringMeta" ? _DefaultFilteringProps : + K extends "editingMeta" ? DataGridEditingHelper : + K extends "valueFormatter" ? ReturnType<typeof withValueFormatter> : + never + ) | undefined +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/index.tsx new file mode 100644 index 0000000..328dbe6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/index.tsx @@ -0,0 +1,3 @@ +export * from "./datagrid" +export * from "./helpers" +export * from "./datagrid-instance" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/locales.ts b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/locales.ts new file mode 100644 index 0000000..e861cce --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/locales.ts @@ -0,0 +1,60 @@ +import { enUS, fr } from "date-fns/locale" + +export const dateFnsLocales = { + "fr": fr, + "en": enUS, +} as { + [key: string]: any, +} +export default { + "filters": { + "fr": "Filtres", + "en": "Filters", + }, + "no-matching-result": { + "fr": "Aucun résultat ne correspond aux filtres", + "en": "No results matching filters", + }, + "remove-filters": { + "fr": "Retirer les filtres", + "en": "Remove all filters", + }, + "page": { + "fr": "Page", + "en": "Page", + }, + "rows-selected": { + "fr": "lignes sélectionnées", + "en": "rows selected", + }, + "row-selected": { + "fr": "ligne sélectionnée", + "en": "row selected", + }, + "save": { + "fr": "Enregistrer", + "en": "Save", + }, + "cancel": { + "fr": "Annuler", + "en": "Cancel", + }, + "updating": { + "fr": "Modification", + "en": "Updating", + }, + "true": { + "fr": "Vrai", + "en": "True", + }, + "false": { + "fr": "Faux", + "en": "False", + }, + "date-range-placeholder": { + "fr": "Sélectionnez une période", + "en": "Select a range", + }, +} as { + [key: string]: { [key: string]: string }, +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-editing.ts b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-editing.ts new file mode 100644 index 0000000..89128b5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-editing.ts @@ -0,0 +1,303 @@ +import { Row, Table } from "@tanstack/react-table" +import equal from "fast-deep-equal" +import * as React from "react" +import { AnyZodObject, ZodIssue } from "zod" +import { DataGridEditingValueUpdater } from "./datagrid-cell-input-field" + + +export type DataGridRowEditedEvent<T extends Record<string, any>> = { + row: Row<T> + originalData: T + data: T +} + +/** + * Type of the `onRowEdit` event + */ +export type DataGridOnRowEdit<T extends Record<string, any>> = (event: DataGridRowEditedEvent<T>) => void + +//---- + +export type DataGridRowValidationError<T extends Record<string, any>> = { + row: Row<T> + originalData: T + data: T + errors: ZodIssue[] +} + +/** + * Type of the `onRowValidationError` event + */ +export type DataGridOnRowValidationError<T extends Record<string, any>> = (event: DataGridRowValidationError<T>) => void + +//---- + +export type DataGridValidationRowErrors = Array<{ rowId: string, key: string, message: string }> + +/** + * Hook props + */ +type Props<T extends Record<string, any>> = { + data: T[] + table: Table<T> + rows: Row<T>[] + onRowEdit?: DataGridOnRowEdit<T> + isDataMutating: boolean | undefined + enableOptimisticUpdates: boolean + onDataChange: React.Dispatch<React.SetStateAction<T[]>> + optimisticUpdatePrimaryKey: string | undefined + manualPagination: boolean + schema: AnyZodObject | undefined + onRowValidationError: DataGridOnRowValidationError<T> | undefined +} + +export function useDataGridEditing<T extends Record<string, any>>(props: Props<T>) { + + const { + data, + table, + rows, + onRowEdit, + isDataMutating, + onDataChange, + enableOptimisticUpdates, + optimisticUpdatePrimaryKey, + manualPagination, + schema, + onRowValidationError, + } = props + + const leafColumns = table.getAllLeafColumns() + // Keep track of the state of each editable cell + const [editableCellStates, setEditableCellStates] = React.useState<{ + id: string, + colId: string, + rowId: string, + isEditing: boolean + }[]>([]) + + // Track updated value + const [activeValue, setActiveValue] = React.useState<unknown>(undefined) + // Track current row data being updated + const [rowData, setRowData] = React.useState<T | undefined>(undefined) + // Track current row being updated + const [row, setRow] = React.useState<Row<T> | undefined>(undefined) + + const [rowErrors, setRowErrors] = React.useState<DataGridValidationRowErrors>([]) + + // Keep track of editable columns (columns defined with the `withEditing` helper) + const editableColumns = React.useMemo(() => { + return leafColumns.filter(n => n.getIsVisible() && !!(n.columnDef.meta as any)?.editingMeta) + }, [leafColumns]) + + React.useEffect(() => { + if (manualPagination) { + setActiveValue(undefined) + setRowData(undefined) + setRow(undefined) + setEditableCellStates([]) + } + }, [table.getState().pagination.pageIndex, table.getState().pagination.pageSize]) + + // Keep track of editable cells (cells whose columns are editable) + const editableCells = React.useMemo(() => { + if (rows.length > 0) { + return rows.flatMap(row => row.getVisibleCells().filter(cell => !!editableColumns.find(col => col.id === cell.column.id)?.id)) + } + return [] + }, [rows]) + + // Set/update editable cells + React.useLayoutEffect(() => { + // Control the states of individual cells that can be edited + if (editableCells.length > 0) { + editableCells.map(cell => { + setEditableCellStates(prev => [...prev, { + id: cell.id, + colId: cell.column.id, + rowId: cell.row.id, + isEditing: false, + }]) + }) + } + }, [editableCells]) + + /**/ + const handleStartEditing = React.useCallback((cellId: string) => { + // Manage editing state of cells + setEditableCellStates(prev => { + const others = prev.filter(prevCell => prevCell.id !== cellId) + const cell = prev.find(prevCell => prevCell.id === cellId) + + if (cell && prev.every(prevCell => !prevCell.isEditing)) { // (Event 1) When we select a cell and nothing else is being edited + return [...others, { ...cell, id: cellId, isEditing: true }] + + } else if (cell && prev.some(prevCell => prevCell.isEditing)) { // (Event 2) When another cell is being edited + const otherCellBeingEdited = prev.find(prevCell => prevCell.isEditing) // Find the cell being edited + + if (otherCellBeingEdited?.rowId === cell?.rowId) { // Only allow cells on the same row to be edited + return [...others, { ...cell, id: cellId, isEditing: true }] + } + } + return prev + }) + }, []) + + /**/ + const getIsCellActivelyEditing = React.useCallback((cellId: string) => { + return editableCellStates.some(cell => cell.id === cellId && cell.isEditing) + }, [editableCellStates]) + /**/ + const getIsCellEditable = React.useCallback((cellId: string) => { + return !!editableCellStates.find(cell => cell.id === cellId) + }, [editableCellStates]) + /**/ + const getIsCurrentlyEditing = React.useCallback(() => { + return editableCellStates.some(cell => cell.isEditing) + }, [editableCellStates]) + /**/ + const getFirstCellBeingEdited = React.useCallback(() => { + return editableCellStates.find(cell => cell.isEditing) + }, [editableCellStates]) + /**/ + const handleStopEditing = React.useCallback(() => { + setEditableCellStates(prev => { + return prev.map(n => ({ ...n, isEditing: false })) + }) + }, []) + + const mutationRef = React.useRef<boolean>(false) + + /** + * When `isDataMutating` is provided to watch mutations, + * Wait for it to be `false` to cancel editing + */ + React.useEffect(() => { + if (isDataMutating !== undefined && !isDataMutating && mutationRef.current) { + handleStopEditing() + mutationRef.current = false + } + }, [isDataMutating]) + + /** + * When `isDataMutating` is not provided, immediately cancel editing + */ + React.useEffect(() => { + if (isDataMutating === undefined) { + handleStopEditing() + } + }, [mutationRef.current]) + + const saveEdit = React.useCallback((transformedData?: T) => { + if (!row || !rowData) return handleStopEditing() + + // Compare data + if (!equal(rowData, row.original)) { + // Return new data + onRowEdit && onRowEdit({ + originalData: row.original, + data: transformedData || rowData, + row: row, + }) + + // Optimistic update + if (enableOptimisticUpdates && optimisticUpdatePrimaryKey) { + let clone = structuredClone(data) + const index = clone.findIndex(p => { + if (!p[optimisticUpdatePrimaryKey] || !rowData[optimisticUpdatePrimaryKey]) return false + return p[optimisticUpdatePrimaryKey] === rowData[optimisticUpdatePrimaryKey] + }) + if (clone[index] && index > -1) { + clone[index] = rowData + onDataChange(clone) // Emit optimistic update + } else { + console.error("[DataGrid] Could not perform optimistic update. Make sure `optimisticUpdatePrimaryKey` is a valid property.") + } + + } else if (enableOptimisticUpdates) { + console.error("[DataGrid] Could not perform optimistic update. Make sure `optimisticUpdatePrimaryKey` is defined.") + } + + // Immediately stop edit if optimistic updates are enabled + if (enableOptimisticUpdates) { + handleStopEditing() + } else { + // Else, we wait for `isDataMutating` to be false + mutationRef.current = true + } + } else { + handleStopEditing() + } + }, [row, rowData]) + + const handleOnSave = React.useCallback(async () => { + if (!row || !rowData) return + setRowErrors([]) + + // Safely parse the schema object when a `validationSchema` is provided + if (schema) { + try { + const parsed = await schema.safeParseAsync(rowData) + if (parsed.success) { + let finalData = structuredClone(rowData) + Object.keys(parsed.data).map(key => { + // @ts-expect-error + finalData[key] = parsed.data[key] + }) + saveEdit(finalData) + } else { + + + parsed.error.errors.map(error => { + setRowErrors(prev => [ + ...prev, + { rowId: row.id, key: String(error.path[0]), message: error.message }, + ]) + }) + + if (onRowValidationError) { + onRowValidationError({ + data: rowData, + originalData: row.original, + row: row, + errors: parsed.error.errors, + }) + } + } + } + catch (e) { + console.error("[DataGrid] Could not perform validation") + } + } else { + saveEdit() + } + + }, [row, rowData]) + + /** + * This fires every time the user updates a cell value + */ + const handleUpdateValue = React.useCallback<DataGridEditingValueUpdater<T>>((value, _row, cell, zodType) => { + setActiveValue(value) // Set the updated value (could be anything) + setRow(_row) // Set the row being updated + setRowData(prev => ({ + // If we are updating a different row, reset the rowData, else keep the past updates + ...((row?.id !== _row.id || !rowData) ? _row.original : rowData), + [cell.column.id]: value, + })) + }, [row, rowData]) + + + return { + handleStartEditing, + getIsCellActivelyEditing, + getIsCellEditable, + getIsCurrentlyEditing, + getFirstCellBeingEdited, + handleStopEditing, + handleOnSave, + handleUpdateValue, + rowErrors, + } + +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-filtering.ts b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-filtering.ts new file mode 100644 index 0000000..1411984 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-filtering.ts @@ -0,0 +1,61 @@ +import { Column, ColumnFiltersState, Table } from "@tanstack/react-table" +import * as React from "react" +import { getColumnHelperMeta } from "./helpers" +import { addDays } from "date-fns/addDays" +import { isSameDay } from "date-fns/isSameDay" + +interface DataGridFilteringHookProps<T> { + table: Table<T>, + columnFilters: ColumnFiltersState, +} + +export function useDataGridFiltering<T>(props: DataGridFilteringHookProps<T>) { + + const { + table, + columnFilters, + } = props + + /** + * Item filtering + */ + const [filterableColumns, filteredColumns] = React.useMemo(() => { + return [ + table.getAllLeafColumns().filter(col => col.getCanFilter() && !!getColumnHelperMeta(col, "filteringMeta")), + table.getAllLeafColumns().filter(col => columnFilters.map(filter => filter.id).includes(col.id)), + ] + }, [table.getAllLeafColumns(), columnFilters]) + const unselectedFilterableColumns = filterableColumns.filter(n => !columnFilters.map(c => c.id).includes(n.id)) + + // Get the default value for a filter when the user selects it + const getFilterDefaultValue = React.useCallback((col: Column<any>) => { + // Since the column is filterable, get options + const options = getColumnHelperMeta(col, "filteringMeta") + if (options) { + if (options.type === "select" || options.type === "radio") { + return options.options?.[0]?.value ?? "" + } else if (options.type === "boolean") { + return true + } else if (options.type === "checkbox") { + return options.options?.map(n => n.value) ?? [] + } else if (options.type === "date-range") { + return { from: new Date(), to: addDays(new Date(), 7) } + } + } + return null + }, []) + + return { + getFilterDefaultValue, + unselectedFilterableColumns, + filteredColumns, + filterableColumns, + } + +} + +export const dateRangeFilter = (row: any, columnId: string, filterValue: any) => { + if (!filterValue || !filterValue.start || !filterValue.end) return true + const value: Date = row.getValue(columnId) + return (value >= filterValue.start && value <= filterValue.end) || isSameDay(value, filterValue.start) || isSameDay(value, filterValue.end) +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-responsiveness.ts b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-responsiveness.ts new file mode 100644 index 0000000..7f22d2b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-responsiveness.ts @@ -0,0 +1,39 @@ +import { useDataGridSize } from "./use-datagrid-size" +import * as React from "react" +import { Table } from "@tanstack/react-table" + +interface DataGridResponsivenessHookProps<T extends Record<string, any>> { + hideColumns: { below: number, hide: string[] }[] | undefined, + table: Table<T> +} + +export function useDataGridResponsiveness<T extends Record<string, any>>(props: DataGridResponsivenessHookProps<T>) { + + const { + hideColumns = [], + table, + } = props + + const [tableRef, { width: tableWidth }] = useDataGridSize<HTMLDivElement>() + const deferredTableWidth = React.useDeferredValue(tableWidth) + + React.useLayoutEffect(() => { + hideColumns.map(({ below, hide }) => { + table.getAllLeafColumns().map(column => { + if (hide.includes(column.id)) { + if (tableWidth !== 0 && tableWidth < below) { + if (column.getIsVisible()) column.toggleVisibility(false) + } else { + if (!column.getIsVisible()) column.toggleVisibility(true) + } + } + }) + }) + }, [hideColumns, deferredTableWidth]) + + return { + tableRef, + tableWidth, + } + +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-row-selection.ts b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-row-selection.ts new file mode 100644 index 0000000..bff8d79 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-row-selection.ts @@ -0,0 +1,120 @@ +import { Row, Table } from "@tanstack/react-table" +import * as React from "react" + +export type DataGridOnRowSelect<T> = (event: DataGridRowSelectedEvent<T>) => void + +type DataGridRowSelectionProps<T> = { + /** + * Whether the row selection is persistent. + * If true, the selected rows will be cached and restored when the table is paginated, filtered, sorted or when the data changes. + */ + persistent: boolean + /** + * Callback fired when a row is selected. + */ + onRowSelect?: DataGridOnRowSelect<T> + /** + * The table instance. + */ + table: Table<T>, + /** + * The data passed to the table. + */ + data: T[] | null + /** + * The rows currently displayed in the table. + */ + displayedRows: Row<T>[] + /** + * The primary key of the data. This is used to identify the rows. + */ + rowSelectionPrimaryKey: string | undefined + /** + * Whether row selection is enabled. + */ + enabled: boolean +} + +export type DataGridRowSelectedEvent<T> = { + data: T[] +} + +export function useDataGridRowSelection<T extends Record<string, any>>(props: DataGridRowSelectionProps<T>) { + + const { + table, + data, + onRowSelect, + persistent, + rowSelectionPrimaryKey: key, + displayedRows, + enabled, + } = props + + + const rowSelection = React.useMemo(() => table.getState().rowSelection, [table.getState().rowSelection]) + const selectedRowsRef = React.useRef<Map<string | number, T>>(new Map()) + + //---------------------------------- + + const canSelect = React.useRef<boolean>(enabled) + + React.useEffect(() => { + selectedRowsRef.current.clear() + + if (enabled && !key) { + console.error( + "[DataGrid] You've enable row selection without providing a primary key. Make sure to define the `rowSelectionPrimaryKey` prop.") + canSelect.current = false + } + }, []) + + const firstCheckRef = React.useRef<boolean>(false) + + React.useEffect(() => { + if (enabled && key && !firstCheckRef.current && displayedRows.length > 0 && !displayedRows.some(row => !!row.original[key])) { + console.error("[DataGrid] The key provided by `rowSelectionPrimaryKey` does not match any property in the data.") + firstCheckRef.current = true + canSelect.current = false + } + }, [displayedRows]) + + /** Client-side row selection **/ + React.useEffect(() => { + if (data && data?.length > 0 && canSelect.current && !!key) { + const selectedKeys = new Set<string | number>(Object.keys(rowSelection)) + + if (persistent) { + // Remove the keys that are no longer selected + selectedRowsRef.current.forEach((_, k) => { + if (!selectedKeys.has(k.toString())) { + selectedRowsRef.current.delete(k) + } + }) + + // Add the selected rows to the selectedRowsRef + selectedKeys.forEach(n => { + const row = data.find((v: any) => v[key] === n) + if (row) { + selectedRowsRef.current.set(n, row) + } + }) + + onRowSelect && onRowSelect({ + data: Array.from(selectedRowsRef.current.values()).filter((v: any) => selectedKeys.has(v[key])) ?? [], + }) + } else { + onRowSelect && onRowSelect({ + data: data.filter((v: any) => selectedKeys.has(v[key])) ?? [], + }) + } + + } + }, [rowSelection]) + + + return { + selectedRowCount: Object.keys(rowSelection).length, + } + +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-size.ts b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-size.ts new file mode 100644 index 0000000..975535f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/datagrid/use-datagrid-size.ts @@ -0,0 +1,29 @@ +import * as React from "react" +import { useEventListener, useIsomorphicLayoutEffect } from "../core/hooks" + +export function useDataGridSize<T extends HTMLElement = HTMLDivElement>(): [ + (node: T | null) => void, + { width: number, height: number }, +] { + const [ref, setRef] = React.useState<T | null>(null) + const [size, setSize] = React.useState<{ width: number, height: number }>({ + width: 0, + height: 0, + }) + + const handleSize = React.useCallback(() => { + setSize({ + width: ref?.offsetWidth || 0, + height: ref?.offsetHeight || 0, + }) + + }, [ref?.offsetHeight, ref?.offsetWidth]) + + useEventListener("resize", handleSize) + + useIsomorphicLayoutEffect(() => { + handleSize() + }, [ref?.offsetHeight, ref?.offsetWidth]) + + return [setRef, size] +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/date-picker/date-picker.tsx b/seanime-2.9.10/seanime-web/src/components/ui/date-picker/date-picker.tsx new file mode 100644 index 0000000..8b68689 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/date-picker/date-picker.tsx @@ -0,0 +1,268 @@ +"use client" + +import { weekStartsOnAtom } from "@/app/(main)/schedule/_components/schedule-calendar" +import { cva } from "class-variance-authority" +import { Day, formatISO, getYear, Locale, setYear } from "date-fns" +import { useAtomValue } from "jotai/react" +import * as React from "react" +import { DayPickerBase } from "react-day-picker" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { Calendar } from "../calendar" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { mergeRefs } from "../core/utils" +import { extractInputPartProps, hiddenInputStyles, InputAddon, InputAnatomy, InputContainer, InputIcon, InputStyling } from "../input" +import { Modal } from "../modal" +import { Popover } from "../popover" +import { Select } from "../select" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const DatePickerAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-DatePicker__root", + "line-clamp-1 text-left", + ]), + placeholder: cva([ + "UI-DatePicker__placeholder", + "text-[--muted]", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * DatePicker + * -----------------------------------------------------------------------------------------------*/ + +export type DatePickerProps = Omit<React.ComponentPropsWithRef<"button">, "size" | "value" | "defaultValue"> & + ComponentAnatomy<typeof DatePickerAnatomy> & + InputStyling & + BasicFieldOptions & { + /** + * The selected date + */ + value?: Date + /** + * Callback fired when the selected date changes + */ + onValueChange?: (value: Date | undefined) => void + /** + * Default value if uncontrolled + */ + defaultValue?: Date + /** + * The placeholder text + */ + placeholder?: string + /** + * The locale for formatting the date + */ + locale?: Locale + /** + * Hide the year selector above the calendar + */ + hideYearSelector?: boolean + /** + * Props to pass to the date picker + * @see https://react-day-picker.js.org/api/interfaces/DayPickerBase + */ + pickerOptions?: Omit<DayPickerBase, "locale"> + /** + * Ref to the input element + */ + inputRef?: React.Ref<HTMLInputElement> +} + +export const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>((props, ref) => { + + const [props1, basicFieldProps] = extractBasicFieldProps<DatePickerProps>(props, React.useId()) + + const [{ + size, + intent, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + className, + placeholderClass, + /**/ + value: controlledValue, + onValueChange, + placeholder, + locale, + hideYearSelector, + pickerOptions, + defaultValue, + inputRef, + ...rest + }, { + inputContainerProps, + leftAddonProps, + leftIconProps, + rightAddonProps, + rightIconProps, + }] = extractInputPartProps<DatePickerProps>({ + ...props1, + size: props1.size ?? "md", + intent: props1.intent ?? "basic", + leftAddon: props1.leftAddon, + leftIcon: props1.leftIcon, + rightAddon: props1.rightAddon, + rightIcon: props1.rightIcon || <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="size-4" + > + <rect width="18" height="18" x="3" y="4" rx="2" ry="2" /> + <line x1="16" x2="16" y1="2" y2="6" /> + <line x1="8" x2="8" y1="2" y2="6" /> + <line x1="3" x2="21" y1="10" y2="10" /> + </svg>, + }) + + const buttonRef = React.useRef<HTMLButtonElement>(null) + + const isFirst = React.useRef(true) + + const [date, setDate] = React.useState<Date | undefined>(controlledValue || defaultValue) + + const weekStartOn = useAtomValue(weekStartsOnAtom) + + const handleOnSelect = React.useCallback((date: Date | undefined) => { + setDate(date) + onValueChange?.(date) + }, []) + + React.useEffect(() => { + if (!defaultValue || !isFirst.current) { + setDate(controlledValue) + } + isFirst.current = false + }, [controlledValue]) + + const Input = ( + <button + ref={mergeRefs([buttonRef, ref])} + id={basicFieldProps.id} + name={basicFieldProps.name} + className={cn( + "form-input", + InputAnatomy.root({ + size, + intent, + hasError: !!basicFieldProps.error, + isDisabled: !!basicFieldProps.disabled, + isReadonly: !!basicFieldProps.readonly, + hasRightAddon: !!rightAddon, + hasRightIcon: !!rightIcon, + hasLeftAddon: !!leftAddon, + hasLeftIcon: !!leftIcon, + }), + DatePickerAnatomy.root(), + className, + )} + disabled={basicFieldProps.disabled || basicFieldProps.readonly} + data-disabled={basicFieldProps.disabled} + data-readonly={basicFieldProps.readonly} + aria-readonly={basicFieldProps.readonly} + {...rest} + > + {date ? + formatISO(date, { representation: "date" }) : + <span className={cn(DatePickerAnatomy.placeholder(), placeholderClass)}>{placeholder || "Select a date"}</span>} + </button> + ) + + const Picker = ( + <div> + {!hideYearSelector && <div className="flex items-center justify-between p-1 sm:border-b"> + <Select + size="sm" + intent="filled" + options={Array(getYear(new Date()) - 1699).fill(0).map((_, i) => ( + { label: String(getYear(new Date()) + 100 - i), value: String(getYear(new Date()) + 100 - i) } + ))} + value={String(getYear(date ?? new Date()))} + onValueChange={value => setDate(setYear(date ?? new Date(), Number(value)))} + /> + </div>} + <Calendar + {...pickerOptions} + mode="single" + month={date ?? new Date()} + onMonthChange={month => setDate(month)} + selected={date} + onSelect={handleOnSelect} + locale={locale} + initialFocus + tableClass="w-auto mx-auto" + weekStartsOn={weekStartOn as Day} + /> + <div className="flex justify-center p-1 border-t"> + <button + onClick={(e) => { + e.stopPropagation() + handleOnSelect(undefined) + }} + className="px-4 py-2 text-sm text-[--muted] hover:text-[--text] transition-colors" + > + Clear + </button> + </div> + </div> + ) + + return ( + <BasicField {...basicFieldProps}> + <InputContainer {...inputContainerProps}> + + <InputAddon {...leftAddonProps} /> + <InputIcon {...leftIconProps} /> + + <div className="hidden sm:block w-full"> + <Popover + className="w-auto p-0" + trigger={Input} + > + {Picker} + </Popover> + </div> + + <div className="block sm:hidden w-full"> + <Modal + title={placeholder || "Select a date"} + trigger={Input} + > + {Picker} + </Modal> + </div> + + <input + ref={inputRef} + type="date" + name={basicFieldProps.name} + className={hiddenInputStyles} + value={date ? date.toISOString().split("T")[0] : ""} + aria-hidden="true" + required={basicFieldProps.required} + tabIndex={-1} + onChange={() => {}} + onFocusCapture={() => buttonRef.current?.focus()} + /> + + <InputAddon {...rightAddonProps} /> + <InputIcon {...rightIconProps} /> + + </InputContainer> + </BasicField> + ) + +}) + +DatePicker.displayName = "DatePicker" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/date-picker/date-range-picker.tsx b/seanime-2.9.10/seanime-web/src/components/ui/date-picker/date-range-picker.tsx new file mode 100644 index 0000000..198e5f0 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/date-picker/date-range-picker.tsx @@ -0,0 +1,233 @@ +"use client" + +import { cva } from "class-variance-authority" +import { format, Locale } from "date-fns" +import * as React from "react" +import { DateRange, DayPickerBase } from "react-day-picker" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { Calendar } from "../calendar" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { mergeRefs } from "../core/utils" +import { extractInputPartProps, hiddenInputStyles, InputAddon, InputAnatomy, InputContainer, InputIcon, InputStyling } from "../input" +import { Modal } from "../modal" +import { Popover } from "../popover" + + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const DateRangePickerAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-DateRangePicker__root", + "line-clamp-1 text-left", + ]), + placeholder: cva([ + "UI-DateRangePicker__placeholder", + "text-[--muted]", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * DateRangePicker + * -----------------------------------------------------------------------------------------------*/ + +export type DateRangePickerProps = Omit<React.ComponentPropsWithRef<"button">, "size" | "value" | "defaultValue"> & + ComponentAnatomy<typeof DateRangePickerAnatomy> & + InputStyling & + BasicFieldOptions & { + /** + * The selected date + */ + value?: DateRange + /** + * Default value if uncontrolled + */ + defaultValue?: DateRange + /** + * Callback fired when the selected date changes + */ + onValueChange?: (value: DateRange | undefined) => void + /** + * The placeholder text + */ + placeholder?: string + /** + * The locale for formatting the date + */ + locale?: Locale + /** + * Props to pass to the date picker + * @see https://react-day-picker.js.org/api/interfaces/DayPickerBase + */ + pickerOptions?: Omit<DayPickerBase, "locale"> + /** + * Ref to the input element + */ + inputRef?: React.Ref<HTMLInputElement> +} + +export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePickerProps>((props, ref) => { + + const [props1, basicFieldProps] = extractBasicFieldProps<DateRangePickerProps>(props, React.useId()) + + const [{ + size, + intent, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + className, + placeholderClass, + /**/ + value: controlledValue, + onValueChange, + placeholder, + locale, + defaultValue, + inputRef, + ...rest + }, { + inputContainerProps, + leftAddonProps, + leftIconProps, + rightAddonProps, + rightIconProps, + }] = extractInputPartProps<DateRangePickerProps>({ + ...props1, + size: props1.size ?? "md", + intent: props1.intent ?? "basic", + leftAddon: props1.leftAddon, + leftIcon: props1.leftIcon, + rightAddon: props1.rightAddon, + rightIcon: props1.rightIcon || <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="size-4" + > + <rect width="18" height="18" x="3" y="4" rx="2" ry="2" /> + <line x1="16" x2="16" y1="2" y2="6" /> + <line x1="8" x2="8" y1="2" y2="6" /> + <line x1="3" x2="21" y1="10" y2="10" /> + </svg>, + }) + + const buttonRef = React.useRef<HTMLButtonElement>(null) + + const isFirst = React.useRef(true) + + const [date, setDate] = React.useState<DateRange | undefined>(controlledValue || defaultValue) + + const handleOnSelect = React.useCallback((date: DateRange | undefined) => { + setDate(date) + onValueChange?.(date) + }, []) + + React.useEffect(() => { + if (!defaultValue || !isFirst.current) { + setDate(controlledValue) + } + isFirst.current = false + }, [controlledValue]) + + const Input = ( + <button + ref={mergeRefs([buttonRef, ref])} + id={basicFieldProps.id} + name={basicFieldProps.name} + className={cn( + "form-input", + InputAnatomy.root({ + size, + intent, + hasError: !!basicFieldProps.error, + isDisabled: !!basicFieldProps.disabled, + isReadonly: !!basicFieldProps.readonly, + hasRightAddon: !!rightAddon, + hasRightIcon: !!rightIcon, + hasLeftAddon: !!leftAddon, + hasLeftIcon: !!leftIcon, + }), + DateRangePickerAnatomy.root(), + className, + )} + disabled={basicFieldProps.disabled || basicFieldProps.readonly} + data-disabled={basicFieldProps.disabled} + data-readonly={basicFieldProps.readonly} + aria-readonly={basicFieldProps.readonly} + {...rest} + > + {date?.from ? ( + date.to ? <span className="line-clamp-1">{`${format(date.from, "P")} - ${format(date.to, "P")}`}</span> : format(date.from, "PPP") + ) : <span className={cn(DateRangePickerAnatomy.placeholder(), placeholderClass)}>{placeholder || "Select a date"}</span>} + </button> + ) + + const Picker = ( + <Calendar + captionLayout="dropdown-buttons" + mode="range" + defaultMonth={date?.from ?? new Date()} + selected={date} + onSelect={handleOnSelect} + locale={locale} + initialFocus + tableClass="w-auto mx-auto" + numberOfMonths={2} + /> + ) + + return ( + <BasicField {...basicFieldProps}> + <InputContainer {...inputContainerProps}> + + <InputAddon {...leftAddonProps} /> + <InputIcon {...leftIconProps} /> + + <div className="hidden sm:block w-full"> + <Popover + className="w-auto p-0" + trigger={Input} + > + {Picker} + </Popover> + </div> + + <div className="block sm:hidden w-full"> + <Modal + title={placeholder || "Select a date"} + trigger={Input} + > + {Picker} + </Modal> + </div> + + <input + ref={inputRef} + type="text" + name={basicFieldProps.name} + className={hiddenInputStyles} + value={date ? `${date.from?.toISOString()?.split("T")?.[0]}${date.to ? "," + date.to.toISOString().split("T")[0] : ""}` : ""} + aria-hidden="true" + required={basicFieldProps.required} + tabIndex={-1} + onChange={() => {}} + onFocusCapture={() => buttonRef.current?.focus()} + /> + + <InputAddon {...rightAddonProps} /> + <InputIcon {...rightIconProps} /> + + </InputContainer> + </BasicField> + ) + +}) + +DateRangePicker.displayName = "DateRangePicker" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/date-picker/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/date-picker/index.tsx new file mode 100644 index 0000000..40e40f2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/date-picker/index.tsx @@ -0,0 +1,2 @@ +export * from "./date-picker" +export * from "./date-range-picker" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/disclosure/disclosure.tsx b/seanime-2.9.10/seanime-web/src/components/ui/disclosure/disclosure.tsx new file mode 100644 index 0000000..5f4e6f3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/disclosure/disclosure.tsx @@ -0,0 +1,134 @@ +"use client" + +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const DisclosureAnatomy = defineStyleAnatomy({ + item: cva([ + "UI-Disclosure__item", + ]), + contentContainer: cva([ + "UI-Disclosure__contentContainer", + "overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down", + ]), + content: cva([ + "UI-Disclosure__content", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Disclosure + * -----------------------------------------------------------------------------------------------*/ + +const __DisclosureAnatomyContext = React.createContext<ComponentAnatomy<typeof DisclosureAnatomy>>({}) + +export type DisclosureProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root> & + ComponentAnatomy<typeof DisclosureAnatomy> + +export const Disclosure = React.forwardRef<HTMLDivElement, DisclosureProps>((props, ref) => { + + const { + contentContainerClass, + contentClass, + itemClass, + ...rest + } = props + + return ( + <__DisclosureAnatomyContext.Provider + value={{ + itemClass, + contentContainerClass, + contentClass, + }} + > + <AccordionPrimitive.Root + ref={ref} + {...rest} + /> + </__DisclosureAnatomyContext.Provider> + ) + +}) +Disclosure.displayName = "Disclosure" + +/* ------------------------------------------------------------------------------------------------- + * DisclosureItem + * -----------------------------------------------------------------------------------------------*/ + +export type DisclosureItemProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> & + ComponentAnatomy<typeof DisclosureAnatomy> + +export const DisclosureItem = React.forwardRef<HTMLDivElement, DisclosureItemProps>((props, ref) => { + + const { className, ...rest } = props + + const { itemClass } = React.useContext(__DisclosureAnatomyContext) + + return ( + <AccordionPrimitive.Item + ref={ref} + className={cn(DisclosureAnatomy.item(), itemClass, className)} + {...rest} + /> + ) + +}) +DisclosureItem.displayName = "DisclosureItem" + +/* ------------------------------------------------------------------------------------------------- + * DisclosureTrigger + * -----------------------------------------------------------------------------------------------*/ + +export type DisclosureTriggerProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> + +export const DisclosureTrigger = React.forwardRef<HTMLButtonElement, DisclosureTriggerProps>((props, ref) => { + return ( + <AccordionPrimitive.Header asChild> + <AccordionPrimitive.Trigger ref={ref} asChild {...props} /> + </AccordionPrimitive.Header> + ) +}) +DisclosureTrigger.displayName = "DisclosureTrigger" + +/* ------------------------------------------------------------------------------------------------- + * DisclosureContent + * -----------------------------------------------------------------------------------------------*/ + +export type DisclosureContentProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> + & Omit<ComponentAnatomy<typeof DisclosureAnatomy>, "contentClass"> + +export const DisclosureContent = React.forwardRef<HTMLDivElement, DisclosureContentProps>((props, ref) => { + + const { + className, + contentContainerClass, + children, + ...rest + } = props + + const { + contentContainerClass: _contentContainerClass, + contentClass: _contentClass, + } = React.useContext(__DisclosureAnatomyContext) + + return ( + <AccordionPrimitive.Content + ref={ref} + className={cn(DisclosureAnatomy.contentContainer(), _contentContainerClass, contentContainerClass)} + {...rest} + > + <div className={cn(DisclosureAnatomy.content(), _contentClass, className)}> + {children} + </div> + </AccordionPrimitive.Content> + ) +}) +DisclosureContent.displayName = "DisclosureContent" + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/disclosure/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/disclosure/index.tsx new file mode 100644 index 0000000..3140e49 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/disclosure/index.tsx @@ -0,0 +1 @@ +export * from "./disclosure" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/drawer/drawer.tsx b/seanime-2.9.10/seanime-web/src/components/ui/drawer/drawer.tsx new file mode 100644 index 0000000..b72be78 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/drawer/drawer.tsx @@ -0,0 +1,267 @@ +"use client" + +import { __isDesktop__ } from "@/types/constants" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { VisuallyHidden } from "@radix-ui/react-visually-hidden" +import { cva, VariantProps } from "class-variance-authority" +import { atom } from "jotai" +import { useAtom } from "jotai/react" +import * as React from "react" +import { CloseButton } from "../button" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +export const __openDrawersAtom = atom<string[]>([]) + +function useDrawerBodyBehavior(id: string, open: boolean | undefined) { + const [openDrawers, setOpenDrawers] = useAtom(__openDrawersAtom) + + React.useEffect(() => { + const body = document.querySelector("body") + if (!body) return + + if (open) { + setOpenDrawers(prev => [...prev, id]) + } else { + setOpenDrawers(prev => { + let next = prev.filter(i => i !== id) + return next + }) + } + + return () => { + setOpenDrawers(prev => prev.filter(i => i !== id)) + } + }, [open]) + +} + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const DrawerAnatomy = defineStyleAnatomy({ + overlay: cva([ + "UI-Drawer__overlay", + "fixed inset-0 z-[50] bg-black/80", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + // "transition-opacity duration-300", + ]), + content: cva([ + "UI-Drawer__content", + "fixed z-50 w-full gap-4 bg-[--background] p-6 shadow-lg overflow-y-auto", + "transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-500 data-[state=open]:duration-500", + "focus:outline-none focus-visible:outline-none", + __isDesktop__ && "select-none", + ], { + variants: { + side: { + mangaReader: "w-full inset-x-0 top-0 border data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + top: "w-full lg:w-[calc(100%_-_20px)] inset-x-0 top-0 border data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: "w-full lg:w-[calc(100%_-_20px)] inset-x-0 bottom-0 border data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full lg:h-[calc(100%_-_20px)] border data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left", + right: "inset-y-0 right-0 h-full lg:h-[calc(100%_-_20px)] border data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right", + }, + size: { sm: null, md: null, lg: null, xl: null, full: null }, + }, + defaultVariants: { + side: "right", + size: "md", + }, + compoundVariants: [ + { size: "sm", side: "left", className: "sm:max-w-sm" }, + { size: "sm", side: "right", className: "sm:max-w-sm" }, + { size: "md", side: "left", className: "sm:max-w-md" }, + { size: "md", side: "right", className: "sm:max-w-md" }, + { size: "lg", side: "left", className: "sm:max-w-2xl" }, + { size: "lg", side: "right", className: "sm:max-w-2xl" }, + { size: "xl", side: "left", className: "sm:max-w-5xl" }, + { size: "xl", side: "right", className: "sm:max-w-5xl" }, + /**/ + { size: "full", side: "top", className: "h-dvh" }, + { size: "full", side: "bottom", className: "h-dvh" }, + ], + }), + close: cva([ + "UI-Drawer__close", + "absolute right-4 top-4", + ]), + header: cva([ + "UI-Drawer__header", + "flex flex-col space-y-1.5 text-center sm:text-left", + ]), + footer: cva([ + "UI-Drawer__footer", + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + ]), + title: cva([ + "UI-Drawer__title", + "text-xl font-semibold leading-none tracking-tight", + ]), + description: cva([ + "UI-Drawer__description", + "text-sm text-[--muted]", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Drawer + * -----------------------------------------------------------------------------------------------*/ + +export type DrawerProps = Omit<React.ComponentPropsWithoutRef<typeof DialogPrimitive.Root>, "modal"> & + Pick<React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>, + "onOpenAutoFocus" | "onCloseAutoFocus" | "onEscapeKeyDown" | "onPointerDownCapture" | "onInteractOutside"> & + VariantProps<typeof DrawerAnatomy.content> & + ComponentAnatomy<typeof DrawerAnatomy> & { + /** + * Interaction with outside elements will be enabled and other elements will be visible to screen readers. + */ + allowOutsideInteraction?: boolean + /** + * The button that opens the modal + */ + trigger?: React.ReactElement + /** + * Title of the modal + */ + title?: React.ReactNode + /** + * An optional accessible description to be announced when the dialog is opened. + */ + description?: React.ReactNode + /** + * Footer of the modal + */ + footer?: React.ReactNode + /** + * Optional replacement for the default close button + */ + closeButton?: React.ReactElement + /** + * Whether to hide the close button + */ + hideCloseButton?: boolean + /** + * Portal container + */ + portalContainer?: HTMLElement + + borderToBorder?: boolean +} + +export function Drawer(props: DrawerProps) { + + const { + allowOutsideInteraction = false, + trigger, + title, + footer, + description, + children, + closeButton, + overlayClass, + contentClass, + closeClass, + headerClass, + footerClass, + titleClass, + descriptionClass, + hideCloseButton, + side = "right", + size, + open, + // Content + onOpenAutoFocus, + onCloseAutoFocus, + onEscapeKeyDown, + onPointerDownCapture, + onInteractOutside, + portalContainer, + borderToBorder: mangaReader, + ...rest + } = props + + const id = React.useId() + + useDrawerBodyBehavior(id, open) + + return ( + <DialogPrimitive.Root modal={!allowOutsideInteraction} open={open} {...rest}> + + {trigger && <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>} + + <DialogPrimitive.Portal container={portalContainer}> + + <DialogPrimitive.Overlay className={cn(DrawerAnatomy.overlay(), overlayClass)} /> + + <DialogPrimitive.Content + className={cn( + DrawerAnatomy.content({ size, side: mangaReader ? "mangaReader" : side }), + // __isDesktop__ && "pt-12", + !mangaReader && "lg:m-[10px] rounded-[--radius]", + contentClass, + )} + style={{ + marginTop: (__isDesktop__ && !mangaReader) ? "30px" : undefined, + height: ( + __isDesktop__ + && !mangaReader + && (side === "left" || side === "right") + ) ? "calc(100dvh - 50px)" : undefined, + }} + onOpenAutoFocus={onOpenAutoFocus} + onCloseAutoFocus={onCloseAutoFocus} + onEscapeKeyDown={onEscapeKeyDown} + onPointerDownCapture={onPointerDownCapture} + onInteractOutside={onInteractOutside} + tabIndex={-1} + > + {!title && !description ? ( + <VisuallyHidden> + <DialogPrimitive.Title>Drawer</DialogPrimitive.Title> + </VisuallyHidden> + ) : ( + <div className={cn(DrawerAnatomy.header(), headerClass)}> + <DialogPrimitive.Title + className={cn( + DrawerAnatomy.title(), + __isDesktop__ && "relative", + titleClass, + )} + > + {title} + </DialogPrimitive.Title> + {description && ( + <DialogPrimitive.Description className={cn(DrawerAnatomy.description(), descriptionClass)}> + {description} + </DialogPrimitive.Description> + )} + </div> + )} + + {children} + + {footer && <div className={cn(DrawerAnatomy.footer(), footerClass)}> + {footer} + </div>} + + {!hideCloseButton && <DialogPrimitive.Close + className={cn( + DrawerAnatomy.close(), + // __isDesktop__ && "!top-10 !right-4", + closeClass, + )} + asChild + > + {closeButton ? closeButton : <CloseButton />} + </DialogPrimitive.Close>} + + </DialogPrimitive.Content> + + </DialogPrimitive.Portal> + + </DialogPrimitive.Root> + ) +} + +Drawer.displayName = "Drawer" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/drawer/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/drawer/index.tsx new file mode 100644 index 0000000..850df08 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/drawer/index.tsx @@ -0,0 +1 @@ +export * from "./drawer" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/dropdown-menu/dropdown-menu.tsx b/seanime-2.9.10/seanime-web/src/components/ui/dropdown-menu/dropdown-menu.tsx new file mode 100644 index 0000000..e89ad1e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/dropdown-menu/dropdown-menu.tsx @@ -0,0 +1,360 @@ +"use client" + +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const DropdownMenuAnatomy = defineStyleAnatomy({ + subTrigger: cva([ + "UI-DropdownMenu__subTrigger", + "focus:bg-[--subtle] data-[state=open]:bg-[--subtle]", + ]), + subContent: cva([ + "UI-DropdownMenu__subContent", + "z-50 min-w-[12rem] overflow-hidden rounded-[--radius] border bg-[--background] p-2 text-[--foreground] shadow-sm", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + ]), + root: cva([ + "UI-DropdownMenu__root", + "z-50 min-w-[15rem] overflow-hidden rounded-[--radius] border bg-[--background] p-2 text-[--foreground] shadow-sm", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + ]), + item: cva([ + "UI-DropdownMenu__item", + "relative flex cursor-default select-none items-center rounded-[--radius] cursor-pointer px-2 py-2 text-sm outline-none transition-colors", + "focus:bg-[--subtle] data-[disabled]:pointer-events-none", + "data-[disabled]:opacity-50", + "[&>svg]:mr-2 [&>svg]:text-lg", + ]), + group: cva([ + "UI-DropdownMenu__group", + ]), + label: cva([ + "UI-DropdownMenu__label", + "px-2 py-1.5 text-sm font-semibold text-[--muted]", + ]), + separator: cva([ + "UI-DropdownMenu__separator", + "-mx-1 my-2 h-px bg-[--border]", + ]), + shortcut: cva([ + "UI-DropdownMenu__shortcut", + "ml-auto text-xs tracking-widest opacity-60", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenu + * -----------------------------------------------------------------------------------------------*/ + +const __DropdownMenuAnatomyContext = React.createContext<ComponentAnatomy<typeof DropdownMenuAnatomy>>({}) + +export type DropdownMenuProps = + ComponentAnatomy<typeof DropdownMenuAnatomy> & + Pick<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Root>, "defaultOpen" | "open" | "onOpenChange" | "dir"> & + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & { + /** + * Interaction with outside elements will be enabled and other elements will be visible to screen readers. + */ + allowOutsideInteraction?: boolean + /** + * The trigger element that is always visible and is used to open the menu. + */ + trigger?: React.ReactNode +} + +export const DropdownMenu = React.forwardRef<HTMLDivElement, DropdownMenuProps>((props, ref) => { + const { + children, + trigger, + // Root + defaultOpen, + open, + onOpenChange, + dir, + allowOutsideInteraction, + // Content + sideOffset = 4, + className, + subContentClass, + subTriggerClass, + shortcutClass, + itemClass, + labelClass, + separatorClass, + groupClass, + ...rest + } = props + + return ( + <__DropdownMenuAnatomyContext.Provider + value={{ + subContentClass, + subTriggerClass, + shortcutClass, + itemClass, + labelClass, + separatorClass, + groupClass, + }} + > + <DropdownMenuPrimitive.Root + defaultOpen={defaultOpen} + open={open} + onOpenChange={onOpenChange} + dir={dir} + modal={!allowOutsideInteraction} + {...rest} + > + <DropdownMenuPrimitive.Trigger asChild> + {trigger} + </DropdownMenuPrimitive.Trigger> + + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn(DropdownMenuAnatomy.root(), className)} + {...rest} + > + {children} + </DropdownMenuPrimitive.Content> + </DropdownMenuPrimitive.Portal> + </DropdownMenuPrimitive.Root> + </__DropdownMenuAnatomyContext.Provider> + ) +}) + +DropdownMenu.displayName = "DropdownMenu" + + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuGroup + * -----------------------------------------------------------------------------------------------*/ + +export type DropdownMenuGroupProps = React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group> + +export const DropdownMenuGroup = React.forwardRef<HTMLDivElement, DropdownMenuGroupProps>((props, ref) => { + const { className, ...rest } = props + + const { groupClass } = React.useContext(__DropdownMenuAnatomyContext) + + return ( + <DropdownMenuPrimitive.Group + ref={ref} + className={cn(DropdownMenuAnatomy.group(), groupClass, className)} + {...rest} + /> + ) +}) + +DropdownMenuGroup.displayName = "DropdownMenuGroup" + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuSub + * -----------------------------------------------------------------------------------------------*/ + +export type DropdownMenuSubProps = + Pick<ComponentAnatomy<typeof DropdownMenuAnatomy>, "subTriggerClass"> & + Pick<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Sub>, "defaultOpen" | "open" | "onOpenChange"> & + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & { + /** + * The content of the default trigger element that will open the sub menu. + * + * By default, the trigger will be an item with a right chevron icon. + */ + triggerContent?: React.ReactNode + /** + * Props to pass to the default trigger element that will open the sub menu. + */ + triggerProps?: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> + triggerInset?: boolean +} + +export const DropdownMenuSub = React.forwardRef<HTMLDivElement, DropdownMenuSubProps>((props, ref) => { + const { + children, + triggerContent, + triggerProps, + triggerInset, + // Sub + defaultOpen, + open, + onOpenChange, + // SubContent + sideOffset = 8, + className, + subTriggerClass, + ...rest + } = props + + const { subTriggerClass: _subTriggerClass, subContentClass } = React.useContext(__DropdownMenuAnatomyContext) + + return ( + <DropdownMenuPrimitive.Sub + {...rest} + > + <DropdownMenuPrimitive.SubTrigger + className={cn( + DropdownMenuAnatomy.item(), + DropdownMenuAnatomy.subTrigger(), + triggerInset && "pl-8", + _subTriggerClass, + subTriggerClass, + className, + )} + {...triggerProps} + > + {triggerContent} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn( + DropdownMenuAnatomy.shortcut(), + "w-4 h-4 ml-auto", + )} + > + <path d="m9 18 6-6-6-6" /> + </svg> + </DropdownMenuPrimitive.SubTrigger> + + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.SubContent + ref={ref} + sideOffset={sideOffset} + className={cn( + DropdownMenuAnatomy.subContent(), + subContentClass, + className, + )} + {...rest} + > + {children} + </DropdownMenuPrimitive.SubContent> + </DropdownMenuPrimitive.Portal> + </DropdownMenuPrimitive.Sub> + ) +}) + +DropdownMenuSub.displayName = "DropdownMenuSub" + + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuItem + * -----------------------------------------------------------------------------------------------*/ + +export type DropdownMenuItemProps = React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean +} + +export const DropdownMenuItem = React.forwardRef<HTMLDivElement, DropdownMenuItemProps>((props, ref) => { + const { className, inset, ...rest } = props + + const { itemClass } = React.useContext(__DropdownMenuAnatomyContext) + + return ( + <DropdownMenuPrimitive.Item + ref={ref} + className={cn( + DropdownMenuAnatomy.item(), + inset && "pl-8", + itemClass, + className, + )} + {...rest} + /> + ) +}) +DropdownMenuItem.displayName = "DropdownMenuItem" + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuLabel + * -----------------------------------------------------------------------------------------------*/ + +export type DropdownMenuLabelProps = React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean +} + +export const DropdownMenuLabel = React.forwardRef<HTMLDivElement, DropdownMenuLabelProps>((props, ref) => { + const { className, inset, ...rest } = props + + const { labelClass } = React.useContext(__DropdownMenuAnatomyContext) + + return ( + <DropdownMenuPrimitive.Label + ref={ref} + className={cn( + DropdownMenuAnatomy.label(), + inset && "pl-8", + labelClass, + className, + )} + {...rest} + /> + ) +}) + +DropdownMenuLabel.displayName = "DropdownMenuLabel" + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuSeparator + * -----------------------------------------------------------------------------------------------*/ + +export type DropdownMenuSeparatorProps = React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> + +export const DropdownMenuSeparator = React.forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>((props, ref) => { + const { className, ...rest } = props + + const { separatorClass } = React.useContext(__DropdownMenuAnatomyContext) + + return ( + <DropdownMenuPrimitive.Separator + ref={ref} + className={cn(DropdownMenuAnatomy.separator(), separatorClass, className)} + {...rest} + /> + ) +}) + +DropdownMenuSeparator.displayName = "DropdownMenuSeparator" + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuShortcut + * -----------------------------------------------------------------------------------------------*/ + +export type DropdownMenuShortcutProps = React.HTMLAttributes<HTMLSpanElement> + +export const DropdownMenuShortcut = React.forwardRef<HTMLSpanElement, DropdownMenuShortcutProps>((props, ref) => { + const { className, ...rest } = props + + const { shortcutClass } = React.useContext(__DropdownMenuAnatomyContext) + + return ( + <span + ref={ref} + className={cn(DropdownMenuAnatomy.shortcut(), shortcutClass, className)} + {...rest} + /> + ) +}) + +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/dropdown-menu/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/dropdown-menu/index.tsx new file mode 100644 index 0000000..c4adece --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/dropdown-menu/index.tsx @@ -0,0 +1 @@ +export * from "./dropdown-menu" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/form/danger-zone.tsx b/seanime-2.9.10/seanime-web/src/components/ui/form/danger-zone.tsx new file mode 100644 index 0000000..64f5f8a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/form/danger-zone.tsx @@ -0,0 +1,144 @@ +"use client" + +import { cva } from "class-variance-authority" +import * as React from "react" +import { Button } from "../button" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { LoadingOverlay } from "../loading-spinner" +import { Modal } from "../modal" +import locales from "./locales.json" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const DangerZoneAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-DangerZone__root", + "p-4 flex flex-col sm:flex-row gap-2 text-center sm:text-left rounded-[--radius-md] border border-dashed", + ]), + icon: cva([ + "UI-DangerZone__icon", + "place-self-center sm:place-self-start text-[--red] w-4 mt-2", + ]), + title: cva([ + "UI-DangerZone__title", + "text-lg text-[--red] font-semibold", + ]), + dialogTitle: cva([ + "UI-DangerZone__dialogTitle", + "text-lg font-medium leading-6", + ]), + dialogBody: cva([ + "UI-DangerZone__dialogBody", + "mt-2 text-base text-[--muted]", + ]), + dialogAction: cva([ + "UI-DangerZone__dialogAction", + "mt-2 flex gap-2 justify-end", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * DangerZone + * -----------------------------------------------------------------------------------------------*/ + +export type DangerZoneProps = React.ComponentPropsWithRef<"div"> & ComponentAnatomy<typeof DangerZoneAnatomy> & { + /** + * Description of the action that will be performed when the delete button is clicked. + */ + actionText: string + /** + * Callback fired when the delete button is clicked. + */ + onDelete?: () => void + /** + * If true, a loading overlay will be shown when the delete button is clicked. + * @default true + **/ + showLoadingOverlayOnDelete?: boolean + locale?: "fr" | "en" +} + +export const DangerZone = React.forwardRef<HTMLDivElement, DangerZoneProps>((props, ref) => { + + const { + children, + actionText, + onDelete, + className, + locale = "en", + showLoadingOverlayOnDelete = true, + titleClass, + iconClass, + dialogBodyClass, + dialogTitleClass, + dialogActionClass, + ...rest + } = props + + const [isOpen, setIsOpen] = React.useState(false) + + const [blockScreen, setBlockScreen] = React.useState<boolean>(false) + + return ( + <> + <LoadingOverlay hide={!blockScreen} /> + + <div ref={ref} className={cn(DangerZoneAnatomy.root(), className)} {...rest}> + <span className={cn(DangerZoneAnatomy.icon(), iconClass)}> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"> + <path + d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" + ></path> + </svg> + </span> + <div> + <h2 className={cn(DangerZoneAnatomy.title(), titleClass)}>{locales["dangerZone"]["name"][locale]}</h2> + <p className=""><span + className="font-semibold" + >{actionText}</span>. {locales["dangerZone"]["irreversible_action"][locale]} + </p> + <Button + size="sm" + intent="alert-subtle" + className="mt-2" + leftIcon={<span className="w-4"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"> + <path + d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z" + ></path> + </svg> + </span>} + onClick={() => setIsOpen(true)} + >{locales["dangerZone"]["delete"][locale]}</Button> + </div> + </div> + + <Modal open={isOpen} onOpenChange={open => setIsOpen(open)} contentClass="gap-2"> + <h3 className={cn(DangerZoneAnatomy.dialogTitle(), dialogTitleClass)}> + {locales["dangerZone"]["confirm_delete"][locale]} + </h3> + <div className={cn(DangerZoneAnatomy.dialogBody(), dialogBodyClass)}> + {locales["dangerZone"]["irreversible_action"][locale]} + </div> + + <div className={cn(DangerZoneAnatomy.dialogAction(), dialogActionClass)}> + <Button + intent="alert" size="sm" onClick={() => { + setIsOpen(false) + showLoadingOverlayOnDelete && setBlockScreen(true) + onDelete && onDelete() + }} + >{locales["dangerZone"]["delete"][locale]}</Button> + <Button + intent="gray-outline" + size="sm" + onClick={() => setIsOpen(false)} + >{locales["dangerZone"]["cancel"][locale]}</Button> + </div> + </Modal> + </> + ) + +}) diff --git a/seanime-2.9.10/seanime-web/src/components/ui/form/define-schema.ts b/seanime-2.9.10/seanime-web/src/components/ui/form/define-schema.ts new file mode 100644 index 0000000..7b5c70c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/form/define-schema.ts @@ -0,0 +1,21 @@ +import { z as zod, ZodType } from "zod" +import { schemaPresets } from "./schema-presets" + +/* ------------------------------------------------------------------------------------------------- + * Helper type + * -----------------------------------------------------------------------------------------------*/ + +export type InferType<S extends ZodType<any, any, any>> = zod.infer<S> + +/* ------------------------------------------------------------------------------------------------- + * Helper functions + * -----------------------------------------------------------------------------------------------*/ + +type DataSchemaCallback<S extends zod.ZodRawShape> = ({ z, presets }: { + z: typeof zod, + presets: typeof schemaPresets +}) => zod.ZodObject<S> + +export const defineSchema = <S extends zod.ZodRawShape>(callback: DataSchemaCallback<S>): zod.ZodObject<S> => { + return callback({ z: zod, presets: schemaPresets }) +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/form/fields.tsx b/seanime-2.9.10/seanime-web/src/components/ui/form/fields.tsx new file mode 100644 index 0000000..e65dfe5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/form/fields.tsx @@ -0,0 +1,592 @@ +"use client" + +import { DirectorySelector, DirectorySelectorProps } from "@/components/shared/directory-selector" +import { MediaExclusionSelector, MediaExclusionSelectorProps } from "@/components/shared/media-exclusion-selector" +import { IconButton } from "@/components/ui/button" +import { cn } from "@/components/ui/core/styling" +import { useDebounce } from "@/hooks/use-debounce" +import { colord } from "colord" +import React, { forwardRef, useMemo } from "react" +import { HexColorPicker } from "react-colorful" +import { Controller, FormState, get, useController, useFormContext } from "react-hook-form" +import { BiPlus, BiTrash } from "react-icons/bi" +import { useUpdateEffect } from "react-use" +import { Autocomplete, AutocompleteProps } from "../autocomplete" +import { BasicFieldOptions } from "../basic-field" +import { Checkbox, CheckboxGroup, CheckboxGroupProps, CheckboxProps } from "../checkbox" +import { Combobox, ComboboxProps } from "../combobox" +import { CurrencyInput, CurrencyInputProps } from "../currency-input" +import { DatePicker, DatePickerProps, DateRangePicker, DateRangePickerProps } from "../date-picker" +import { NativeSelect, NativeSelectProps } from "../native-select" +import { NumberInput, NumberInputProps } from "../number-input" +import { Popover } from "../popover" +import { RadioGroup, RadioGroupProps } from "../radio-group" +import { Select, SelectProps } from "../select" +import { SimpleDropzone, SimpleDropzoneProps } from "../simple-dropzone" +import { Switch, SwitchProps } from "../switch" +import { TextInput, TextInputProps } from "../text-input" +import { Textarea, TextareaProps } from "../textarea" +import { useFormSchema } from "./form" +import { createPolymorphicComponent } from "./polymorphic-component" +import { SubmitField } from "./submit-field" + + +/** + * Add the BasicField types to any Field + */ +export type FieldBaseProps = Omit<BasicFieldOptions, "name"> & { + name: string + onChange?: any + onBlur?: any + required?: boolean +} + +export type FieldComponent<T> = T & FieldBaseProps + +export type FieldProps = React.ComponentPropsWithRef<"div"> + +/** + * @description This wrapper makes it easier to work with custom form components by controlling their state. + * @example + * // Props order + * <Controller> + * <InputComponent + * defaultValue={} // Can be overridden + * onChange={} // Can be overridden + * onBlur={} // Can be overridden + * {...props} // <FieldComponent {...} /> -> <Field.Component {...} /> + * error={} // Cannot be overridden + * /> + * </Controller> + * @param InputComponent + */ +export function withControlledInput<T extends FieldBaseProps>(InputComponent: React.FC<T>) { + return forwardRef<FieldProps, T>( + (inputProps, ref) => { + const { control, formState, ...context } = useFormContext() + const { shape } = useFormSchema() + + /* Get the `required` status from the Schema */ + const required = useMemo(() => { + return !!get(shape, inputProps.name) && + !get(shape, inputProps.name)?.isOptional() && + !get(shape, inputProps.name)?.isNullable() + }, [shape]) + + return ( + <Controller + name={inputProps.name} + control={control} + rules={{ required: inputProps.required }} + render={({ field: { ref: _ref, ...field } }) => ( + /** + * We pass "value, onChange, onBlur, error, required" to all components that will be defined using the wrapper. + * For other components like "Switch" and "Checkbox" which do not use the "value" prop, you need to deconstruct it to avoid it + * being passed. + */ + <InputComponent + value={field.value} // Default prop, can be overridden in Field component definition + onChange={callAllHandlers(inputProps.onChange, field.onChange)} // Default prop, can be overridden in Field component + onBlur={callAllHandlers(inputProps.onBlur, field.onBlur)} // Default prop, can be overridden in Field component + // required={required} + {...inputProps as any} // Props passed in <FieldComponent /> then props passed in <Field.Component /> + // The props below will not be overridden. + // e.g: <Field.ComponentField error="Error" /> will not work + error={getFormError(field.name, formState)?.message} + ref={useMergeRefs(ref, _ref)} + /> + )} + /> + ) + }, + ) +} + +const withUncontrolledInput = <T extends FieldBaseProps>(InputComponent: React.FC<T>) => { + return forwardRef<HTMLInputElement, T>( + (props, ref) => { + const { register, formState } = useFormContext() + const { ref: _ref, ...field } = register(props.name) + + return ( + <InputComponent + {...props as any} + onChange={callAllHandlers(props.onChange, field.onChange)} + onBlur={callAllHandlers(props.onBlur, field.onBlur)} + error={getFormError(props.name, formState)?.message} + name={field.name} + ref={useMergeRefs(ref, _ref)} + /> + ) + }, + ) +} + + +const TextInputField = React.memo(withControlledInput(forwardRef<HTMLInputElement, FieldComponent<TextInputProps>>( + (props, ref) => { + return <TextInput + {...props} + value={props.value ?? ""} + ref={ref} + /> + }, +))) + +const TextareaField = React.memo(withControlledInput(forwardRef<HTMLTextAreaElement, FieldComponent<TextareaProps>>( + (props, ref) => { + return <Textarea + {...props} + value={props.value ?? ""} + ref={ref} + /> + }, +))) + +const DatePickerField = React.memo(withControlledInput(forwardRef<HTMLButtonElement, FieldComponent<DatePickerProps>>(( + { onChange, ...props }, ref) => { + + return <DatePicker + {...props} + onValueChange={onChange} + ref={ref} + /> +}))) + +const DateRangePickerField = React.memo(withControlledInput(forwardRef<HTMLButtonElement, FieldComponent<DateRangePickerProps>>(( + { onChange, ...props }, ref) => { + + return <DateRangePicker + {...props} + onValueChange={onChange} + ref={ref} + /> +}))) + + +const NativeSelectField = React.memo(withControlledInput(forwardRef<HTMLSelectElement, FieldComponent<NativeSelectProps>>( + (props, ref) => { + const context = useFormContext() + const controller = useController({ name: props.name }) + + // Set the default value as the first option if no default value is passed and there is no placeholder + React.useEffect(() => { + if (!get(context.formState.defaultValues, props.name) && !controller.field.value && !props.placeholder) { + controller.field.onChange(props.options?.[0]?.value) + } + }, []) + + return <NativeSelect + {...props} + ref={ref} + /> + }, +))) + +const ColorPickerField = React.memo(withControlledInput(forwardRef<HTMLInputElement, FieldComponent<TextInputProps>>( + (props, ref) => { + const context = useFormContext() + const controller = useController({ name: props.name }) + const validColorRef = React.useRef("#000") + + const [value, setValue] = React.useState(get(context.formState.defaultValues, props.name) || "#000") + const deferredValue = useDebounce(value, 200) + + const valueRef = React.useRef(value) + + React.useEffect(() => { + controller.field.onChange(deferredValue) + if (colord(deferredValue).isValid()) { + validColorRef.current = deferredValue + } + }, [deferredValue]) + + const handleColorChange = React.useCallback(() => { + if (!colord(value).isValid()) { + setValue(validColorRef.current) + } else { + setValue(value) + } + valueRef.current = value + }, [validColorRef.current, value]) + + + useUpdateEffect(() => { + if (controller.field.value !== valueRef.current) { + setValue(controller.field.value) + valueRef.current = controller.field.value + } + }, [controller.field.value]) + + return <TextInput + {...props} + ref={ref} + value={value} + onValueChange={setValue} + onBlur={handleColorChange} + rightAddon={<Popover + className="flex justify-center" + trigger={<div className="cursor-pointer size-7 rounded-[--radius-md]" style={{ backgroundColor: value }} />} + > + <HexColorPicker + color={value} + onChange={color => { + setValue(color) + }} + /> + </Popover>} + className="w-full" + /> + }, +))) + +const SelectField = React.memo(withControlledInput(forwardRef<HTMLButtonElement, FieldComponent<SelectProps>>( + ({ onChange, ...props }, ref) => { + return <Select + {...props} + onValueChange={onChange} + ref={ref} + /> + }, +))) + +const NumberField = React.memo(withControlledInput(forwardRef<HTMLInputElement, FieldComponent<NumberInputProps>>( + ({ onChange, ...props }, ref) => { + return <NumberInput + {...props} + onValueChange={onChange} + ref={ref} + /> + }, +))) + + +const ComboboxField = React.memo(withControlledInput(forwardRef<HTMLButtonElement, FieldComponent<ComboboxProps>>( + ({ onChange, ...props }, ref) => { + return <Combobox + {...props} + onValueChange={onChange} + ref={ref} + /> + }, +))) + +const SwitchField = React.memo(withControlledInput(forwardRef<HTMLButtonElement, FieldComponent<SwitchProps>>( + ({ onChange, ...props }, ref) => { + return <Switch + {...props} + onValueChange={onChange} + ref={ref} + /> + }, +))) + +const CheckboxField = React.memo(withControlledInput(forwardRef<HTMLButtonElement, FieldComponent<CheckboxProps>>( + ({ onChange, ...props }, ref) => { + return <Checkbox + {...props} + onValueChange={onChange} + ref={ref} + /> + }, +))) + +const CheckboxGroupField = React.memo(withControlledInput(forwardRef<HTMLInputElement, FieldComponent<CheckboxGroupProps>>( + ({ onChange, ...props }, ref) => { + return <CheckboxGroup + {...props} + onValueChange={onChange} + ref={ref} + /> + }, +))) + + +const RadioGroupField = React.memo(withControlledInput(forwardRef<HTMLButtonElement, FieldComponent<RadioGroupProps>>( + ({ onChange, ...props }, ref) => { + return <RadioGroup + {...props} + onValueChange={onChange} + ref={ref} + /> + }, +))) + + +const RadioCardsField = React.memo(withControlledInput(forwardRef<HTMLButtonElement, FieldComponent<RadioGroupProps>>( + ({ onChange, ...props }, ref) => { + return <RadioGroup + // itemContainerClass={cn( + // "items-start cursor-pointer transition border-transparent rounded-[--radius] p-4 w-full", + // "bg-gray-50 hover:bg-[--subtle] dark:bg-gray-900", + // "data-[state=checked]:bg-white dark:data-[state=checked]:bg-gray-950", + // "focus:ring-2 ring-brand-100 dark:ring-brand-900 ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition", + // "border border-transparent data-[state=checked]:border-[--brand] data-[state=checked]:ring-offset-0", + // )} + // itemClass={cn( + // "border-transparent absolute top-2 right-2 bg-transparent dark:bg-transparent dark:data-[state=unchecked]:bg-transparent", + // "data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent + // dark:data-[state=unchecked]:hover:bg-transparent", "focus-visible:ring-0 focus-visible:ring-offset-0 + // focus-visible:ring-offset-transparent", )} itemIndicatorClass="hidden" itemLabelClass="font-medium flex flex-col items-center + // data-[state=checked]:text-[--brand] cursor-pointer" + itemContainerClass={cn( + "items-start cursor-pointer transition border-transparent rounded-[--radius] p-3 w-full md:w-fit", + "bg-transparent dark:hover:bg-gray-900 dark:bg-transparent", + "data-[state=checked]:bg-brand-500/5 dark:data-[state=checked]:bg-gray-900", + "focus:ring-2 ring-brand-100 dark:ring-brand-900 ring-offset-1 ring-offset-[--background] focus-within:ring-transparent transition", + "dark:border dark:data-[state=checked]:border-[--border] data-[state=checked]:ring-offset-0", + )} + itemClass={cn( + "border-transparent absolute top-2 right-2 bg-transparent dark:bg-transparent dark:data-[state=unchecked]:bg-transparent", + "data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent dark:data-[state=unchecked]:hover:bg-transparent", + "focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:ring-offset-transparent", + )} + itemIndicatorClass="hidden" + itemLabelClass="font-medium flex flex-col items-center data-[state=unchecked]:hover:text-[--foreground] data-[state=checked]:text-[--brand] text-[--muted] cursor-pointer" + // stackClass="flex flex-col md:flex-row flex-wrap gap-2 space-y-0" + {...props} + onValueChange={onChange} + stackClass="flex flex-col md:flex-row gap-2 space-y-0" + ref={ref} + /> + }, +))) + + +const CurrencyInputField = React.memo(withControlledInput(forwardRef<HTMLInputElement, FieldComponent<CurrencyInputProps>>( + ({ onChange, ...props }, ref) => { + return <CurrencyInput + {...props} + onValueChange={onChange} + ref={ref} + /> + }, +))) + +const AutocompleteField = React.memo(withControlledInput(forwardRef<HTMLInputElement, FieldComponent<AutocompleteProps>>( + ({ onChange, ...props }, ref) => { + return <Autocomplete + {...props} + onValueChange={onChange} + ref={ref} + /> + }, +))) + +const SimpleDropzoneField = React.memo(withControlledInput(forwardRef<HTMLInputElement, FieldComponent<SimpleDropzoneProps>>( + ({ onChange, value, ...props }, ref) => { + + const controller = useController({ name: props.name }) + + // Set the default value to an empty array + React.useEffect(() => { + controller.field.onChange([]) + }, []) + + return <SimpleDropzone + {...props} + onValueChange={onChange} + ref={ref} + /> + }, +))) + +type DirectorySelectorFieldProps = Omit<DirectorySelectorProps, "onSelect" | "value"> & { value?: string } + +const DirectorySelectorField = React.memo(withControlledInput(forwardRef<HTMLInputElement, FieldComponent<DirectorySelectorFieldProps>>( + ({ value, onChange, shouldExist, ...props }, ref) => { + const context = useFormContext() + const controller = useController({ name: props.name }) + + const defaultValue = useMemo(() => get(context.formState.defaultValues, props.name) ?? "", []) + + React.useEffect(() => { + controller.field.onChange(defaultValue) + }, []) + + return <DirectorySelector + shouldExist={shouldExist} + {...props} + value={value ?? ""} + defaultValue={defaultValue} + onSelect={value => controller.field.onChange(value)} + ref={ref} + /> + }, +))) + +type MultiDirectorySelectorFieldProps = Omit<DirectorySelectorProps, "onSelect" | "value"> & { value?: string[] } + +const MultiDirectorySelectorField = React.memo(withControlledInput(forwardRef<HTMLInputElement, FieldComponent<MultiDirectorySelectorFieldProps>>( + ({ value, onChange, shouldExist, label, help, ...props }, ref) => { + const context = useFormContext() + const controller = useController({ name: props.name }) + + const [paths, setPaths] = React.useState<string[]>([]) + + const defaultValue = useMemo(() => get(context.formState.defaultValues, props.name) ?? [], []) + React.useEffect(() => { + setPaths(defaultValue) + }, []) + + + React.useEffect(() => { + controller.field.onChange(paths.filter(p => p)) + }, [paths]) + + return <div className="space-y-2"> + <div> + {label && <label className="block text-md font-medium">{label}</label>} + {help && <p className="text-sm text-[--muted]">{help}</p>} + </div> + {paths?.map((v, i) => ( + <div className="flex items-center gap-2" key={i}> + <div className="w-full"> + <DirectorySelector + shouldExist={shouldExist} + {...props} + label="Directory" + value={v ?? ""} + defaultValue={v ?? ""} + onSelect={value => { + setPaths(prev => { + const newPaths = [...prev] + newPaths[i] = value + return newPaths + }) + }} + ref={ref} + fieldClass="w-full" + /> + </div> + <IconButton + rounded + size="sm" + intent="alert-subtle" + icon={<BiTrash />} + onClick={() => setPaths(prev => prev.filter((_, index) => index !== i))} + /> + </div> + ))} + <IconButton + rounded + size="sm" + intent="gray-subtle" + icon={<BiPlus />} + onClick={() => setPaths(prev => [...prev, ""])} + /> + </div> + }, +))) + +const MediaExclusionSelectorField = React.memo(withControlledInput(forwardRef<HTMLDivElement, FieldComponent<MediaExclusionSelectorProps>>( + ({ onChange, ...props }, ref) => { + return <MediaExclusionSelector + {...props} + onChange={onChange} + ref={ref} + /> + }, +))) + +export const Field = createPolymorphicComponent<"div", FieldProps, { + Text: typeof TextInputField, + Textarea: typeof TextareaField, + Select: typeof SelectField, + NativeSelect: typeof NativeSelectField, + Switch: typeof SwitchField, + Checkbox: typeof CheckboxField, + CheckboxGroup: typeof CheckboxGroupField, + RadioGroup: typeof RadioGroupField, + Currency: typeof CurrencyInputField, + Number: typeof NumberField, + DatePicker: typeof DatePickerField + DateRangePicker: typeof DateRangePickerField + Combobox: typeof ComboboxField + Autocomplete: typeof AutocompleteField + SimpleDropzone: typeof SimpleDropzoneField + DirectorySelector: typeof DirectorySelectorField + MultiDirectorySelector: typeof MultiDirectorySelectorField + RadioCards: typeof RadioCardsField + ColorPicker: typeof ColorPickerField + MediaExclusionSelector: typeof MediaExclusionSelectorField + Submit: typeof SubmitField +}>({ + Text: TextInputField, + Textarea: TextareaField, + Select: SelectField, + NativeSelect: NativeSelectField, + Switch: SwitchField, + Checkbox: CheckboxField, + CheckboxGroup: CheckboxGroupField, + RadioGroup: RadioGroupField, + Currency: CurrencyInputField, + Number: NumberField, + DatePicker: DatePickerField, + DateRangePicker: DateRangePickerField, + Combobox: ComboboxField, + Autocomplete: AutocompleteField, + SimpleDropzone: SimpleDropzoneField, + DirectorySelector: DirectorySelectorField, + MultiDirectorySelector: MultiDirectorySelectorField, + ColorPicker: ColorPickerField, + RadioCards: RadioCardsField, + MediaExclusionSelector: MediaExclusionSelectorField, + Submit: SubmitField, +}) + +Field.displayName = "Field" + +/* ------------------------------------------------------------------------------------------------- + * Utils + * -----------------------------------------------------------------------------------------------*/ + +export const getFormError = (name: string, formState: FormState<{ [x: string]: any }>) => { + return get(formState.errors, name) +} + +export type ReactRef<T> = React.RefCallback<T> | React.MutableRefObject<T> + +export function assignRef<T = any>( + ref: ReactRef<T> | null | undefined, + value: T, +) { + if (ref == null) return + + if (typeof ref === "function") { + ref(value) + return + } + + try { + ref.current = value + } + catch (error) { + throw new Error(`Cannot assign value '${value}' to ref '${ref}'`) + } +} + +export function mergeRefs<T>(...refs: (ReactRef<T> | null | undefined)[]) { + return (node: T | null) => { + refs.forEach((ref) => { + assignRef(ref, node) + }) + } +} + +export function useMergeRefs<T>(...refs: (ReactRef<T> | null | undefined)[]) { + return useMemo(() => mergeRefs(...refs), refs) +} + +type Args<T extends Function> = T extends (...args: infer R) => any ? R : never + +function callAllHandlers<T extends (event: any) => void>( + ...fns: (T | undefined)[] +) { + return function func(event: Args<T>[0]) { + fns.some((fn) => { + fn?.(event) + return event?.defaultPrevented + }) + } +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/form/form.tsx b/seanime-2.9.10/seanime-web/src/components/ui/form/form.tsx new file mode 100644 index 0000000..47b168d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/form/form.tsx @@ -0,0 +1,160 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import * as React from "react" +import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm, UseFormProps, UseFormReturn, WatchObserver } from "react-hook-form" +import { z } from "zod" +import { cn } from "../core/styling" +import { isEmpty } from "../core/utils" +import { getZodDefaults } from "./zod-resolver" + +/* ------------------------------------------------------------------------------------------------- + * Context + * -----------------------------------------------------------------------------------------------*/ + +/** + * @internal + */ +const __FormSchemaContext = React.createContext<{ + shape: z.ZodRawShape, + schema: z.ZodObject<z.ZodRawShape> +} | undefined>(undefined) + +export const useFormSchema = (): { shape: z.ZodRawShape, schema: z.ZodObject<z.ZodRawShape> } => { + return React.useContext(__FormSchemaContext)! +} + +/* ------------------------------------------------------------------------------------------------- + * Form + * -----------------------------------------------------------------------------------------------*/ + +export type FormProps<Schema extends z.ZodObject<z.ZodRawShape> = z.ZodObject<z.ZodRawShape>> = + UseFormProps<z.infer<Schema>> & + Omit<React.ComponentPropsWithRef<"form">, "children" | "onChange" | "onSubmit" | "onError" | "ref"> & { + /** + * The schema of the form. + */ + schema: Schema + /** + * Callback invoked when the form is submitted. + */ + onSubmit: SubmitHandler<z.infer<Schema>> + /** + * Callback invoked when any of the field change. + */ + onChange?: WatchObserver<z.infer<Schema>> + /** + * Callback invoked when there are validation errors. + */ + onError?: SubmitErrorHandler<z.infer<Schema>> + /** + * Ref to the form element. + */ + formRef?: React.RefObject<HTMLFormElement> + + children?: MaybeRenderProp<UseFormReturn<z.infer<Schema>>> + /** + * @default w-full space-y-3 + */ + stackClass?: string + /** + * Ref to the form methods. + */ + mRef?: React.Ref<UseFormReturn<z.infer<Schema>>> +} + +export const Form = <Schema extends z.ZodObject<z.ZodRawShape>>(props: FormProps<Schema>) => { + + const { + mode = "onSubmit", + resolver, + reValidateMode, + shouldFocusError, + shouldUnregister, + shouldUseNativeValidation, + criteriaMode, + delayError, + schema, + defaultValues: _defaultValues, + onChange, + onSubmit, + onError, + formRef, + children, + mRef, + /**/ + stackClass, + ...rest + } = props + + const defaultValues = React.useMemo(() => { + if (isEmpty(getZodDefaults(schema)) && isEmpty(_defaultValues)) return undefined + return { + ...getZodDefaults(schema), + ..._defaultValues, + } as any + }, []) + + const form = { + mode, + resolver, + defaultValues, + reValidateMode, + shouldFocusError, + shouldUnregister, + shouldUseNativeValidation, + criteriaMode, + delayError, + } + + form.resolver = zodResolver(schema) + + const methods = useForm(form) + const { handleSubmit } = methods + + React.useImperativeHandle(mRef, () => methods, [mRef, methods]) + + React.useEffect(() => { + let subscription: ReturnType<typeof methods.watch> | undefined + if (onChange) { + subscription = methods.watch(onChange) + } + return () => subscription?.unsubscribe() + }, [methods, onChange]) + + return ( + <FormProvider {...methods}> + <__FormSchemaContext.Provider value={{ schema, shape: schema.shape }}> + <form + ref={formRef} + onSubmit={handleSubmit(onSubmit, onError)} + {...rest} + > + <div className={cn("w-full space-y-3", stackClass)}> + {runIfFn(children, methods)} + </div> + </form> + </__FormSchemaContext.Provider> + </FormProvider> + ) + +} + +Form.displayName = "Form" + +/* ------------------------------------------------------------------------------------------------- + * Utils + * -----------------------------------------------------------------------------------------------*/ + +type MaybeRenderProp<P> = + | React.ReactNode + | ((props: P) => React.ReactNode) + +const isFunction = <T extends Function = Function>(value: any): value is T => typeof value === "function" + +function runIfFn<T, U>( + valueOrFn: T | ((...fnArgs: U[]) => T), + ...args: U[] +): T { + return isFunction(valueOrFn) ? valueOrFn(...args) : valueOrFn +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/form/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/form/index.tsx new file mode 100644 index 0000000..9cf1df3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/form/index.tsx @@ -0,0 +1,4 @@ +export * from "./form" +export * from "./fields" +export * from "./define-schema" +export * from "./danger-zone" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/form/locales.json b/seanime-2.9.10/seanime-web/src/components/ui/form/locales.json new file mode 100644 index 0000000..dcadd2b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/form/locales.json @@ -0,0 +1,50 @@ +{ + "form": { + "create": { + "fr": "Créer", + "en": "Create" + }, + "add": { + "fr": "Ajouter", + "en": "Add" + }, + "update": { + "fr": "Modifier", + "en": "Update" + }, + "search": { + "fr": "Chercher", + "en": "Search" + }, + "save": { + "fr": "Enregistrer", + "en": "Save" + }, + "submit": { + "fr": "Soumettre", + "en": "Submit" + } + }, + "dangerZone": { + "delete": { + "fr": "Supprimer", + "en": "Delete" + }, + "irreversible_action": { + "fr": "Cette action est irréversible.", + "en": "This action is irreversible." + }, + "name": { + "fr": "Zone de danger", + "en": "Danger Zone" + }, + "confirm_delete": { + "fr": "Êtes-vous sûr de vouloir effectuer cette action ?", + "en": "Are you sure you want to confirm this action ?" + }, + "cancel": { + "fr": "Annuler", + "en": "Cancel" + } + } +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/form/polymorphic-component.ts b/seanime-2.9.10/seanime-web/src/components/ui/form/polymorphic-component.ts new file mode 100644 index 0000000..fb1c7da --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/form/polymorphic-component.ts @@ -0,0 +1,33 @@ +import * as React from "react" + +type ExtendedProps<Props = {}, OverrideProps = {}> = OverrideProps & + Omit<Props, keyof OverrideProps>; +type ElementType = keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>; +type PropsOf<C extends ElementType> = JSX.LibraryManagedAttributes<C, + React.ComponentPropsWithoutRef<C>>; +type ComponentProp<C> = { + component?: C; +}; +type InheritedProps<C extends ElementType, Props = {}> = ExtendedProps<PropsOf<C>, Props>; +export type PolymorphicRef<C> = C extends React.ElementType + ? React.ComponentPropsWithRef<C>["ref"] + : never; +export type PolymorphicComponentProps<C, Props = {}> = C extends React.ElementType + ? InheritedProps<C, Props & ComponentProp<C>> & { ref?: PolymorphicRef<C> } + : Props & { component: React.ElementType }; + +export function createPolymorphicComponent<ComponentDefaultType, + Props, + StaticComponents = Record<string, never>>(component: any) { + type ComponentProps<C> = PolymorphicComponentProps<C, Props>; + + type _PolymorphicComponent = <C = ComponentDefaultType>( + props: ComponentProps<C>, + ) => React.ReactElement; + + type ComponentProperties = Omit<React.FunctionComponent<ComponentProps<any>>, never>; + + type PolymorphicComponent = _PolymorphicComponent & ComponentProperties & StaticComponents; + + return component as PolymorphicComponent +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/form/schema-presets.ts b/seanime-2.9.10/seanime-web/src/components/ui/form/schema-presets.ts new file mode 100644 index 0000000..245a6a5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/form/schema-presets.ts @@ -0,0 +1,25 @@ +import { z } from "zod" + +export const schemaPresets = { + name: z.string().min(2).trim(), + select: z.string().min(1), + checkboxGroup: z.array(z.string()), + multiSelect: z.array(z.string()), + autocomplete: z.object({ label: z.string(), value: z.string().nullable() }), + validAddress: z.object({ + label: z.string(), value: z.string({ + required_error: "Invalid address", + invalid_type_error: "Invalid address", + }), + }), + time: z.object({ hour: z.number().min(0).max(23), minute: z.number().min(0).max(59) }), + phone: z.string().min(10, "Invalid phone number"), + files: z + .array(z.custom<File>()) + .refine((files) => files.every((file) => file instanceof File), { message: "Expected a file" }), + filesOrEmpty: z + .array(z.custom<File>()).min(0) + .refine((files) => files.every((file) => file instanceof File), { message: "Expected a file" }), + dateRangePicker: z.object({ from: z.date(), to: z.date() }), + datePicker: z.date(), +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/form/submit-field.tsx b/seanime-2.9.10/seanime-web/src/components/ui/form/submit-field.tsx new file mode 100644 index 0000000..e62e41b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/form/submit-field.tsx @@ -0,0 +1,81 @@ +import React from "react" +import { useFormContext } from "react-hook-form" +import { Button, ButtonProps } from "../button" +import { LoadingOverlay } from "../loading-spinner" + +/* ------------------------------------------------------------------------------------------------- + * SubmitField + * -----------------------------------------------------------------------------------------------*/ + +export type SubmitFieldProps = Omit<ButtonProps, "type"> & { + /** + * Role of the button. + * - If "create", a loading overlay will be shown when the submission is successful. + * @default "save" + */ + role?: "submit" | "save" | "create" | "add" | "search" | "update" + /** + * If true, the button will be disabled when the submission is successful. + */ + disableOnSuccess?: boolean + /** + * If true, the button will be disabled if the form is invalid. + */ + disableIfInvalid?: boolean + /** + * If true, a loading overlay will be shown when the submission is successful. + */ + showLoadingOverlayOnSuccess?: boolean + /** + * If true, a loading overlay will be shown when the form is submitted when the role is "create". + * @default true + */ + showLoadingOverlayOnCreate?: boolean + /** + * A loading overlay to show when the form is submitted. + */ + loadingOverlay?: React.ReactNode +} + +export const SubmitField = React.forwardRef<HTMLButtonElement, SubmitFieldProps>((props, ref) => { + + const { + children, + loading, + disabled, + role = "save", + disableOnSuccess = role === "create", + disableIfInvalid = false, + showLoadingOverlayOnSuccess = false, + showLoadingOverlayOnCreate = true, + loadingOverlay, + ...rest + } = props + + const { formState } = useFormContext() + + const disableSuccess = disableOnSuccess ? formState.isSubmitSuccessful : false + const disableInvalid = disableIfInvalid ? !formState.isValid : false + + return ( + <> + {(showLoadingOverlayOnSuccess && loadingOverlay) && ( + <LoadingOverlay hide={!formState.isSubmitSuccessful} /> + )} + {(role === "create" && loadingOverlay) && ( + <LoadingOverlay hide={!formState.isSubmitSuccessful} /> + )} + + <Button + type="submit" + loading={formState.isSubmitting || loading} + disabled={disableInvalid || disabled || disableSuccess} + ref={ref} + {...rest} + > + {children} + </Button> + </> + ) + +}) diff --git a/seanime-2.9.10/seanime-web/src/components/ui/form/zod-resolver.ts b/seanime-2.9.10/seanime-web/src/components/ui/form/zod-resolver.ts new file mode 100644 index 0000000..558687d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/form/zod-resolver.ts @@ -0,0 +1,145 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { FieldValues, get } from "react-hook-form" +import * as z from "zod" + +export { zodResolver } + +export type Options = { + min?: number + max?: number +} + +const getType = (field: z.ZodTypeAny) => { + switch (field._def.typeName) { + case "ZodArray": + return "array" + case "ZodObject": + return "object" + case "ZodNumber": + return "number" + case "ZodDate": + return "date" + case "ZodString": + default: + return "text" + } +} + +const getArrayOption = (field: any, name: string) => { + return field._def[name]?.value +} + +/** + * A helper function to render forms automatically based on a Zod schema + * + * @param schema The Yup schema + * @returns {FieldProps[]} + */ +export const getFieldsFromSchema = (schema: z.ZodTypeAny): FieldValues[] => { + const fields: FieldValues[] = [] + + let schemaFields: Record<string, any> = {} + if (schema._def.typeName === "ZodArray") { + schemaFields = schema._def.type.shape + } else if (schema._def.typeName === "ZodObject") { + schemaFields = schema._def.shape() + } else { + return fields + } + + for (const name in schemaFields) { + const field = schemaFields[name] + + const options: Options = {} + if (field._def.typeName === "ZodArray") { + options.min = getArrayOption(field, "minLength") + options.max = getArrayOption(field, "maxLength") + } + + const meta = field.description && zodParseMeta(field.description) + + fields.push({ + name, + label: meta?.label || field.description || name, + type: meta?.type || getType(field), + ...options, + }) + } + return fields +} + + +export const getNestedSchema = (schema: z.ZodTypeAny, path: string) => { + return get(schema._def.shape(), path) +} + +export const zodFieldResolver = <T extends z.ZodTypeAny>(schema: T) => { + return { + getFields() { + return getFieldsFromSchema(schema) + }, + getNestedFields(name: string) { + return getFieldsFromSchema(getNestedSchema(schema, name)) + }, + } +} + +export interface ZodMeta { + label: string + type?: string +} + +export const zodMeta = (meta: ZodMeta) => { + return JSON.stringify(meta) +} + +export const zodParseMeta = (meta: string) => { + try { + return JSON.parse(meta) + } + catch (e) { + return meta + } +} + +/** + * @link https://github.com/colinhacks/zod/discussions/1953#discussioncomment-4811588 + * @param schema + */ +export function getZodDefaults<Schema extends z.AnyZodObject>(schema: Schema) { + return Object.fromEntries( + Object.entries(schema.shape).map(([key, value]) => { + if (value instanceof z.ZodDefault) return [key, value._def.defaultValue()] + return [key, undefined] + }), + ) +} + +/** + * @param schema + */ +export function getZodDescriptions<Schema extends z.AnyZodObject>(schema: Schema) { + return Object.fromEntries( + Object.entries(schema.shape).map(([key, value]) => { + return [key, (value as any)._def.description ?? undefined] + }), + ) +} + +/** + * @example + * const meta = useMemo(() => getZodParsedDescription<{ minValue: CalendarDate }>(schema, props.name), []) + * @param schema + * @param key + */ +export function getZodParsedDescription<T extends { + [p: string]: any +}>(schema: z.AnyZodObject, key: string): T | undefined { + const obj = getZodDescriptions(schema) + const parsedDescription: any = (typeof obj[key] === "string" || obj[key] instanceof String) ? JSON.parse(obj[key]) : undefined + if (parsedDescription.constructor == Object) { + return parsedDescription as T + } + return undefined + +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/horizontal-draggable-scroll/horizontal-draggable-scroll.tsx b/seanime-2.9.10/seanime-web/src/components/ui/horizontal-draggable-scroll/horizontal-draggable-scroll.tsx new file mode 100644 index 0000000..5f16b6a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/horizontal-draggable-scroll/horizontal-draggable-scroll.tsx @@ -0,0 +1,209 @@ +"use client" +import { cva } from "class-variance-authority" +import * as React from "react" +import { useIsomorphicLayoutEffect, useUpdateEffect } from "../core/hooks" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { useDraggableScroll } from "./use-draggable-scroll" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +const HorizontalDraggableScrollAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-HorizontalDraggableScroll__root", + "relative flex items-center lg:gap-2", + ]), + container: cva([ + "UI-HorizontalDraggableScroll__container", + "flex max-w-full w-full space-x-3 overflow-x-scroll scrollbar-hide scroll select-none", + ]), + chevronOverlay: cva([ + "flex flex-none items-center justify-center cursor-pointer hover:text-[--foreground] absolute bg-gradient-to-r from-[--background] z-40", + "h-full w-16 opacity-90 hover:opacity-100 transition-opacity", + "data-[state=hidden]:opacity-0 data-[state=hidden]:pointer-events-none", + "data-[state=visible]:animate-in data-[state=hidden]:animate-out", + "data-[state=visible]:fade-in-0 data-[state=hidden]:fade-out-0", + "data-[state=visible]:duration-600 data-[state=hidden]:duration-600", + "hidden md:flex", + ], { + variants: { + side: { + left: "left-0 bg-gradient-to-r rounded-l-xl", + right: "right-0 bg-gradient-to-l rounded-r-xl", + }, + }, + }), + scrollContainer: cva([ + "flex max-w-full w-full space-x-3 overflow-x-scroll scrollbar-hide scroll select-none", + ]), + chevronIcon: cva([ + "w-7 h-7 stroke-2 mx-auto", + ]), + +}) + +/* ------------------------------------------------------------------------------------------------- + * HorizontalDraggableScroll + * -----------------------------------------------------------------------------------------------*/ + +export type HorizontalDraggableScrollProps = ComponentAnatomy<typeof HorizontalDraggableScrollAnatomy> & { + className?: string + children?: React.ReactNode + /** + * Callback fired when the slider has reached the end + */ + onSlideEnd?: () => void + /** + * The amount of pixels to scroll when the chevron is clicked + * @default 500 + */ + scrollAmount?: number + /** + * Decay rate of the inertial effect by using an optional parameter. + * A value of 0.95 means that at the speed will decay 5% of its current value at every 1/60 seconds. + */ + decayRate?: number + /** + * Control drag sensitivity by specifying the minimum distance in order to distinguish an intentional drag movement from an unwanted one. + */ + safeDisplacement?: number + /** + * Whether to apply a rubber band effect when the slider reaches the end + */ + applyRubberBandEffect?: boolean +} + +export const HorizontalDraggableScroll = React.forwardRef<HTMLDivElement, HorizontalDraggableScrollProps>((props, forwadedRef) => { + + const { + children, + onSlideEnd, + className, + containerClass, + scrollContainerClass, + chevronIconClass, + chevronOverlayClass, + decayRate = 0.95, + safeDisplacement = 20, + applyRubberBandEffect = true, + scrollAmount = 500, + ...rest + } = props + + const ref = React.useRef<HTMLDivElement>(null) as React.MutableRefObject<HTMLDivElement> + const { events } = useDraggableScroll(ref, { + decayRate, + safeDisplacement, + applyRubberBandEffect, + }) + + const [isScrolledToLeft, setIsScrolledToLeft] = React.useState(true) + const [isScrolledToRight, setIsScrolledToRight] = React.useState(false) + const [showChevronRight, setShowRightChevron] = React.useState(false) + + const handleScroll = React.useCallback(() => { + const div = ref.current + + if (div) { + const scrolledToLeft = div.scrollLeft === 0 + const scrolledToRight = div.scrollLeft + div.clientWidth === div.scrollWidth + + setIsScrolledToLeft(scrolledToLeft) + setIsScrolledToRight(scrolledToRight) + } + }, []) + + useUpdateEffect(() => { + if (!isScrolledToLeft && isScrolledToRight) { + onSlideEnd && onSlideEnd() + const t = setTimeout(() => { + const div = ref.current + if (div) { + div.scrollTo({ + left: div.scrollLeft + scrollAmount, + behavior: "smooth", + }) + } + }, 1000) + return () => clearTimeout(t) + } + }, [isScrolledToLeft, isScrolledToRight]) + + const slideLeft = React.useCallback(() => { + const div = ref.current + if (div) { + div.scrollTo({ + left: div.scrollLeft - scrollAmount, + behavior: "smooth", + }) + } + }, [scrollAmount]) + + const slideRight = React.useCallback(() => { + const div = ref.current + if (div) { + div.scrollTo({ + left: div.scrollLeft + scrollAmount, + behavior: "smooth", + }) + } + }, [scrollAmount]) + + useIsomorphicLayoutEffect(() => { + if (ref.current.clientWidth < ref.current.scrollWidth) { + setShowRightChevron(true) + } else { + setShowRightChevron(false) + } + }, []) + + return ( + <div ref={forwadedRef} className={cn(HorizontalDraggableScrollAnatomy.root(), className)}> + <div + onClick={slideLeft} + className={cn(HorizontalDraggableScrollAnatomy.chevronOverlay({ side: "left" }), chevronOverlayClass)} + data-state={isScrolledToLeft ? "hidden" : "visible"} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(HorizontalDraggableScrollAnatomy.chevronIcon(), chevronIconClass)} + > + <path d="m15 18-6-6 6-6" /> + </svg> + </div> + <div + onScroll={handleScroll} + className={cn(HorizontalDraggableScrollAnatomy.container(), containerClass)} + {...events} + ref={ref} + > + {children} + </div> + <div + onClick={slideRight} + className={cn(HorizontalDraggableScrollAnatomy.chevronOverlay({ side: "right" }), chevronOverlayClass)} + data-state={!isScrolledToRight && showChevronRight ? "visible" : "hidden"} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(HorizontalDraggableScrollAnatomy.chevronIcon(), chevronIconClass)} + > + <path d="m9 18 6-6-6-6" /> + </svg> + </div> + </div> + ) +}) diff --git a/seanime-2.9.10/seanime-web/src/components/ui/horizontal-draggable-scroll/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/horizontal-draggable-scroll/index.tsx new file mode 100644 index 0000000..64d4c17 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/horizontal-draggable-scroll/index.tsx @@ -0,0 +1 @@ +export * from "./horizontal-draggable-scroll" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/horizontal-draggable-scroll/use-draggable-scroll.ts b/seanime-2.9.10/seanime-web/src/components/ui/horizontal-draggable-scroll/use-draggable-scroll.ts new file mode 100644 index 0000000..c625b2e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/horizontal-draggable-scroll/use-draggable-scroll.ts @@ -0,0 +1,360 @@ +/* ------------------------------------------------------------------------------------------------- + * @author rfmiotto + * @link https://www.npmjs.com/package/react-use-draggable-scroll/v/0.4.7 + * -----------------------------------------------------------------------------------------------*/ +import * as React from "react" +import { useIsomorphicLayoutEffect } from "../core/hooks" + +type OptionsType = { + decayRate?: number + safeDisplacement?: number + applyRubberBandEffect?: boolean + activeMouseButton?: "Left" | "Middle" | "Right" + isMounted?: boolean +} + +type ReturnType = { + events: { + onMouseDown: (e: React.MouseEvent<HTMLElement>) => void + } +} + +export function useDraggableScroll( + ref: React.MutableRefObject<HTMLElement>, + { + decayRate = 0.95, + safeDisplacement = 10, + applyRubberBandEffect = true, + activeMouseButton = "Left", + isMounted = true, + }: OptionsType = {}, +): ReturnType { + const internalState = React.useRef({ + isMouseDown: false, + isDraggingX: false, + isDraggingY: false, + initialMouseX: 0, + initialMouseY: 0, + lastMouseX: 0, + lastMouseY: 0, + scrollSpeedX: 0, + scrollSpeedY: 0, + lastScrollX: 0, + lastScrollY: 0, + }) + + let isScrollableAlongX = false + let isScrollableAlongY = false + let maxHorizontalScroll = 0 + let maxVerticalScroll = 0 + let cursorStyleOfWrapperElement: string + let cursorStyleOfChildElements: string[] + let transformStyleOfChildElements: string[] + let transitionStyleOfChildElements: string[] + + const timing = (1 / 60) * 1000 // period of most monitors (60fps) + + useIsomorphicLayoutEffect(() => { + if (isMounted) { + isScrollableAlongX = + window.getComputedStyle(ref.current).overflowX === "scroll" + isScrollableAlongY = + window.getComputedStyle(ref.current).overflowY === "scroll" + + maxHorizontalScroll = ref.current.scrollWidth - ref.current.clientWidth + maxVerticalScroll = ref.current.scrollHeight - ref.current.clientHeight + + cursorStyleOfWrapperElement = window.getComputedStyle(ref.current).cursor + + cursorStyleOfChildElements = [] + transformStyleOfChildElements = [] + transitionStyleOfChildElements = []; + + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement) => { + cursorStyleOfChildElements.push( + window.getComputedStyle(child).cursor, + ) + + transformStyleOfChildElements.push( + window.getComputedStyle(child).transform === "none" + ? "" + : window.getComputedStyle(child).transform, + ) + + transitionStyleOfChildElements.push( + window.getComputedStyle(child).transition === "none" + ? "" + : window.getComputedStyle(child).transition, + ) + }, + ) + } + }, [isMounted]) + + const runScroll = () => { + const dx = internalState.current.scrollSpeedX * timing + const dy = internalState.current.scrollSpeedY * timing + const offsetX = ref.current.scrollLeft + dx + const offsetY = ref.current.scrollTop + dy + + ref.current.scrollLeft = offsetX // eslint-disable-line no-param-reassign + ref.current.scrollTop = offsetY // eslint-disable-line no-param-reassign + internalState.current.lastScrollX = offsetX + internalState.current.lastScrollY = offsetY + } + + const rubberBandCallback = (e: MouseEvent) => { + const dx = e.clientX - internalState.current.initialMouseX + const dy = e.clientY - internalState.current.initialMouseY + + const { clientWidth, clientHeight } = ref.current + + let displacementX = 0 + let displacementY = 0 + + if (isScrollableAlongX && isScrollableAlongY) { + displacementX = + 0.3 * + clientWidth * + Math.sign(dx) * + Math.log10(1.0 + (0.5 * Math.abs(dx)) / clientWidth) + displacementY = + 0.3 * + clientHeight * + Math.sign(dy) * + Math.log10(1.0 + (0.5 * Math.abs(dy)) / clientHeight) + } else if (isScrollableAlongX) { + displacementX = + 0.3 * + clientWidth * + Math.sign(dx) * + Math.log10(1.0 + (0.5 * Math.abs(dx)) / clientWidth) + } else if (isScrollableAlongY) { + displacementY = + 0.3 * + clientHeight * + Math.sign(dy) * + Math.log10(1.0 + (0.5 * Math.abs(dy)) / clientHeight) + } + + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement) => { + child.style.transform = `translate3d(${displacementX}px, ${displacementY}px, 0px)` // eslint-disable-line no-param-reassign + child.style.transition = "transform 0ms" // eslint-disable-line no-param-reassign + }, + ) + } + + const recoverChildStyle = () => { + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement, i) => { + child.style.transform = transformStyleOfChildElements[i] // eslint-disable-line no-param-reassign + child.style.transition = transitionStyleOfChildElements[i] // eslint-disable-line no-param-reassign + }, + ) + } + + let rubberBandAnimationTimer: NodeJS.Timeout + let keepMovingX: NodeJS.Timer + let keepMovingY: NodeJS.Timer + + const callbackMomentum = () => { + const minimumSpeedToTriggerMomentum = 0.05 + + keepMovingX = setInterval(() => { + const lastScrollSpeedX = internalState.current.scrollSpeedX + const newScrollSpeedX = lastScrollSpeedX * decayRate + internalState.current.scrollSpeedX = newScrollSpeedX + + const isAtLeft = ref.current.scrollLeft <= 0 + const isAtRight = ref.current.scrollLeft >= maxHorizontalScroll + const hasReachedHorizontalEdges = isAtLeft || isAtRight + + runScroll() + + if ( + Math.abs(newScrollSpeedX) < minimumSpeedToTriggerMomentum || + internalState.current.isMouseDown || + hasReachedHorizontalEdges + ) { + internalState.current.scrollSpeedX = 0 + clearInterval(keepMovingX as any) + } + }, timing) + + keepMovingY = setInterval(() => { + const lastScrollSpeedY = internalState.current.scrollSpeedY + const newScrollSpeedY = lastScrollSpeedY * decayRate + internalState.current.scrollSpeedY = newScrollSpeedY + + const isAtTop = ref.current.scrollTop <= 0 + const isAtBottom = ref.current.scrollTop >= maxVerticalScroll + const hasReachedVerticalEdges = isAtTop || isAtBottom + + runScroll() + + if ( + Math.abs(newScrollSpeedY) < minimumSpeedToTriggerMomentum || + internalState.current.isMouseDown || + hasReachedVerticalEdges + ) { + internalState.current.scrollSpeedY = 0 + clearInterval(keepMovingY as any) + } + }, timing) + + internalState.current.isDraggingX = false + internalState.current.isDraggingY = false + + if (applyRubberBandEffect) { + const transitionDurationInMilliseconds = 250; + + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement) => { + child.style.transform = `translate3d(0px, 0px, 0px)` // eslint-disable-line no-param-reassign + child.style.transition = `transform ${transitionDurationInMilliseconds}ms` // eslint-disable-line no-param-reassign + }, + ) + + rubberBandAnimationTimer = setTimeout( + recoverChildStyle, + transitionDurationInMilliseconds, + ) + } + } + + const preventClick = (e: Event) => { + e.preventDefault() + e.stopImmediatePropagation() + e.stopPropagation() + } + + const getIsMousePressActive = (buttonsCode: number) => { + return ( + (activeMouseButton === "Left" && buttonsCode === 1) || + (activeMouseButton === "Middle" && buttonsCode === 4) || + (activeMouseButton === "Right" && buttonsCode === 2) + ) + } + + const onMouseDown = (e: React.MouseEvent<HTMLElement>) => { + const isMouseActive = getIsMousePressActive(e.buttons) + if (!isMouseActive) { + return + } + + internalState.current.isMouseDown = true + internalState.current.lastMouseX = e.clientX + internalState.current.lastMouseY = e.clientY + internalState.current.initialMouseX = e.clientX + internalState.current.initialMouseY = e.clientY + } + + const onMouseUp = (e: MouseEvent) => { + const isDragging = + internalState.current.isDraggingX || internalState.current.isDraggingY + + const dx = internalState.current.initialMouseX - e.clientX + const dy = internalState.current.initialMouseY - e.clientY + + const isMotionIntentional = + Math.abs(dx) > safeDisplacement || Math.abs(dy) > safeDisplacement + + const isDraggingConfirmed = isDragging && isMotionIntentional + + if (isDraggingConfirmed) { + ref.current.childNodes.forEach((child) => { + child.addEventListener("click", preventClick) + }) + } else { + ref.current.childNodes.forEach((child) => { + child.removeEventListener("click", preventClick) + }) + } + + internalState.current.isMouseDown = false + internalState.current.lastMouseX = 0 + internalState.current.lastMouseY = 0 + + ref.current.style.cursor = cursorStyleOfWrapperElement; // eslint-disable-line no-param-reassign + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement, i) => { + child.style.cursor = cursorStyleOfChildElements[i] // eslint-disable-line no-param-reassign + }, + ) + + if (isDraggingConfirmed) { + callbackMomentum() + } + } + + const onMouseMove = (e: MouseEvent) => { + if (!internalState.current.isMouseDown) { + return + } + + e.preventDefault() + + const dx = internalState.current.lastMouseX - e.clientX + internalState.current.lastMouseX = e.clientX + + internalState.current.scrollSpeedX = dx / timing + internalState.current.isDraggingX = true + + const dy = internalState.current.lastMouseY - e.clientY + internalState.current.lastMouseY = e.clientY + + internalState.current.scrollSpeedY = dy / timing + internalState.current.isDraggingY = true + + ref.current.style.cursor = "grabbing"; // eslint-disable-line no-param-reassign + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement) => { + child.style.cursor = "grabbing" // eslint-disable-line no-param-reassign + }, + ) + + const isAtLeft = ref.current.scrollLeft <= 0 && isScrollableAlongX + const isAtRight = + ref.current.scrollLeft >= maxHorizontalScroll && isScrollableAlongX + const isAtTop = ref.current.scrollTop <= 0 && isScrollableAlongY + const isAtBottom = + ref.current.scrollTop >= maxVerticalScroll && isScrollableAlongY + const isAtAnEdge = isAtLeft || isAtRight || isAtTop || isAtBottom + + if (isAtAnEdge && applyRubberBandEffect) { + rubberBandCallback(e) + } + + runScroll() + } + + const handleResize = () => { + maxHorizontalScroll = ref.current.scrollWidth - ref.current.clientWidth + maxVerticalScroll = ref.current.scrollHeight - ref.current.clientHeight + } + + React.useEffect(() => { + if (isMounted) { + window.addEventListener("mouseup", onMouseUp) + window.addEventListener("mousemove", onMouseMove) + window.addEventListener("resize", handleResize) + } + return () => { + window.removeEventListener("mouseup", onMouseUp) + window.removeEventListener("mousemove", onMouseMove) + window.removeEventListener("resize", handleResize) + + clearInterval(keepMovingX as any) + clearInterval(keepMovingY as any) + clearTimeout(rubberBandAnimationTimer) + } + }, [isMounted]) + + return { + events: { + onMouseDown, + }, + } +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/hover-card/hover-card.tsx b/seanime-2.9.10/seanime-web/src/components/ui/hover-card/hover-card.tsx new file mode 100644 index 0000000..610e8e7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/hover-card/hover-card.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const HoverCardAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-HoverCard__root", + "z-50 w-64 rounded-[--radius-md] border bg-[--paper] p-4 shadow-sm outline-none", + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0", + "data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * HoverCard + * -----------------------------------------------------------------------------------------------*/ + +export type HoverCardProps = React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & { + trigger: React.ReactElement + openDelay?: number + closeDelay?: number +} + +export const HoverCard = React.forwardRef<HTMLDivElement, HoverCardProps>((props, ref) => { + const { + className, + align = "center", + sideOffset = 8, + openDelay = 1, + closeDelay = 0, + ...rest + } = props + + return ( + <HoverCardPrimitive.Root openDelay={openDelay} closeDelay={closeDelay}> + <HoverCardPrimitive.Trigger asChild> + {props.trigger} + </HoverCardPrimitive.Trigger> + + <HoverCardPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn(HoverCardAnatomy.root(), className)} + {...rest} + /> + </HoverCardPrimitive.Root> + ) +}) + +HoverCard.displayName = "HoverCard" + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/hover-card/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/hover-card/index.tsx new file mode 100644 index 0000000..796e13f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/hover-card/index.tsx @@ -0,0 +1 @@ +export * from "./hover-card" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/input/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/input/index.tsx new file mode 100644 index 0000000..5410d0a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/input/index.tsx @@ -0,0 +1 @@ +export * from "./input-parts" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/input/input-parts.tsx b/seanime-2.9.10/seanime-web/src/components/ui/input/input-parts.tsx new file mode 100644 index 0000000..b9a3b43 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/input/input-parts.tsx @@ -0,0 +1,291 @@ +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const InputAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Input__root", + "flex items-center", + "w-full rounded-[--radius]", + "bg-[--paper] border border-[--border] placeholder-gray-400 dark:placeholder-gray-500", + "disabled:cursor-not-allowed", + "data-[disable=true]:shadow-none data-[disable=true]:opacity-50", + "focus:border-brand focus:ring-1 focus:ring-[--ring]", + "outline-0", + "transition duration-150", + "shadow-sm", + ], { + variants: { + size: { + sm: "h-8 px-2 py-1 text-sm", + md: "h-10 px-3", + lg: "h-12 px-4 py-3 text-md", + }, + intent: { + basic: "hover:border-gray-300 dark:hover:border-gray-600", + filled: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 border-transparent focus:bg-white dark:focus:bg-gray-900 shadow-none", + unstyled: "bg-transparent hover:bg-transparent border-0 shadow-none focus:ring-0 rounded-none p-0 text-base", + }, + hasError: { + false: null, + true: "border-red-500 hover:border-red-200 dark:border-red-500", + }, + isDisabled: { + false: null, + true: "shadow-none pointer-events-none opacity-50 cursor-not-allowed bg-gray-50 dark:bg-gray-800", + }, + isReadonly: { + false: null, + true: "pointer-events-none cursor-not-allowed shadow-sm", + }, + hasLeftAddon: { true: null, false: null }, + hasRightAddon: { true: null, false: null }, + hasLeftIcon: { true: null, false: null }, + hasRightIcon: { true: null, false: null }, + }, + compoundVariants: [ + { hasLeftAddon: true, className: "border-l-transparent hover:border-l-transparent rounded-l-none" }, + /**/ + { hasRightAddon: true, className: "border-r-transparent hover:border-r-transparent rounded-r-none" }, + /**/ + { hasLeftAddon: false, hasLeftIcon: true, size: "sm", className: "pl-10" }, + { hasLeftAddon: false, hasLeftIcon: true, size: "md", className: "pl-10" }, + { hasLeftAddon: false, hasLeftIcon: true, size: "lg", className: "pl-12" }, + /**/ + { hasRightAddon: false, hasRightIcon: true, size: "sm", className: "pr-10" }, + { hasRightAddon: false, hasRightIcon: true, size: "md", className: "pr-10" }, + { hasRightAddon: false, hasRightIcon: true, size: "lg", className: "pr-12" }, + ], + defaultVariants: { + size: "md", + intent: "basic", + hasError: false, + isDisabled: false, + hasLeftIcon: false, + hasRightIcon: false, + hasLeftAddon: false, + hasRightAddon: false, + }, + }), +}) + +export const hiddenInputStyles = cn( + "appearance-none absolute bottom-0 border-0 w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap [clip:rect(0px,0px,0px,0px)] [overflow-wrap:normal]") + +/* ------------------------------------------------------------------------------------------------- + * InputContainer + * -----------------------------------------------------------------------------------------------*/ + +export const InputContainerAnatomy = defineStyleAnatomy({ + inputContainer: cva([ + "UI-Input__inputContainer", + "flex relative", + ]), +}) + +export type InputContainerProps = { + className: React.HTMLAttributes<HTMLDivElement>["className"], + children?: React.ReactNode +} + +export const InputContainer = ({ className, children }: InputContainerProps) => { + + return ( + <div className={cn("UI-Input__inputContainer flex relative", className)}> + {children} + </div> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * InputStyling + * -----------------------------------------------------------------------------------------------*/ + +export type InputStyling = Omit<VariantProps<typeof InputAnatomy.root>, + "isDisabled" | "hasError" | "hasLeftAddon" | "hasRightAddon" | "hasLeftIcon" | "hasRightIcon"> & + ComponentAnatomy<typeof InputAddonsAnatomy> & + ComponentAnatomy<typeof InputContainerAnatomy> & { + leftAddon?: React.ReactNode + leftIcon?: React.ReactNode + rightAddon?: React.ReactNode + rightIcon?: React.ReactNode +} + + +/* ------------------------------------------------------------------------------------------------- + * Addons Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const InputAddonsAnatomy = defineStyleAnatomy({ + icon: cva([ + "UI-Input__addons--icon", + "pointer-events-none absolute inset-y-0 grid place-content-center text-gray-500", + "dark:text-gray-300 !z-[1]", + ], { + variants: { + size: { sm: "w-10 text-md", md: "w-12 text-lg", lg: "w-14 text-2xl" }, + isLeftIcon: { true: "left-0", false: null }, + isRightIcon: { true: "right-0", false: null }, + }, + defaultVariants: { + size: "md", + isLeftIcon: false, isRightIcon: false, + }, + }), + addon: cva([ + "UI-Input__addons--addon", + "bg-gray-50 inline-flex items-center flex-none px-3 border border-gray-300 text-gray-800 shadow-sm text-sm sm:text-md", + "dark:bg-[--paper] dark:border-[--border] dark:text-gray-300", + ], { + variants: { + size: { sm: "text-sm", md: "text-md", lg: "text-lg" }, + isLeftAddon: { true: "rounded-l-md border-r-0", false: null }, + isRightAddon: { true: "rounded-r-md border-l-0", false: null }, + hasLeftIcon: { true: null, false: null }, + hasRightIcon: { true: null, false: null }, + }, + compoundVariants: [ + { size: "sm", hasLeftIcon: true, isLeftAddon: true, className: "pl-10" }, + { size: "sm", hasRightIcon: true, isRightAddon: true, className: "pr-10" }, + { size: "md", hasLeftIcon: true, isLeftAddon: true, className: "pl-10" }, + { size: "md", hasRightIcon: true, isRightAddon: true, className: "pr-10" }, + { size: "lg", hasLeftIcon: true, isLeftAddon: true, className: "pl-12" }, + { size: "lg", hasRightIcon: true, isRightAddon: true, className: "pr-12" }, + ], + defaultVariants: { + size: "md", + isLeftAddon: false, isRightAddon: false, hasLeftIcon: false, hasRightIcon: false, + }, + }), +}) + +/* ------------------------------------------------------------------------------------------------- + * InputIcon + * -----------------------------------------------------------------------------------------------*/ + +export type InputIconProps = { + icon: InputStyling["leftIcon"] | undefined, + size: InputStyling["size"], + side: "right" | "left", + props?: Omit<React.ComponentPropsWithoutRef<"span">, "className">, + className?: string, +} + +export const InputIcon = ({ icon, size = "md", side, props, className }: InputIconProps) => { + + if (!!icon) return <span + className={cn(InputAddonsAnatomy.icon({ isRightIcon: side === "right", isLeftIcon: side === "left", size }), className)} + {...props} + > + {icon} + </span> + + return null +} + +/* ------------------------------------------------------------------------------------------------- + * InputAddon + * -----------------------------------------------------------------------------------------------*/ + +export type InputAddonProps = { + addon: InputStyling["rightAddon"] | InputStyling["leftAddon"] | undefined, + rightIcon: InputStyling["leftIcon"] | undefined, + leftIcon: InputStyling["rightIcon"] | undefined, + size: InputStyling["size"], + side: "right" | "left", + props?: Omit<React.ComponentPropsWithoutRef<"span">, "className">, + className?: string, +} + +export const InputAddon = ({ addon, leftIcon, rightIcon, size = "md", side, props, className }: InputAddonProps) => { + + if (!!addon) return ( + <span + className={cn(InputAddonsAnatomy.addon({ + isRightAddon: side === "right", + isLeftAddon: side === "left", + hasRightIcon: !!rightIcon, + hasLeftIcon: !!leftIcon, + size, + }), className)} + {...props} + > + {addon} + </span> + ) + + return null + +} + +/* ------------------------------------------------------------------------------------------------- + * Utils + * -----------------------------------------------------------------------------------------------*/ + +export function extractInputPartProps<T extends InputStyling>(props: T) { + const { + size, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + inputContainerClass, // class + iconClass, // class + addonClass, // class + ...rest + } = props + + return [{ + size, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + ...rest, + }, { + inputContainerProps: { + className: inputContainerClass, + }, + leftAddonProps: { + addon: leftAddon, + leftIcon, + rightIcon, + size, + side: "left", + className: addonClass, + }, + rightAddonProps: { + addon: rightAddon, + leftIcon, + rightIcon, + size, + side: "right", + className: addonClass, + }, + leftIconProps: { + icon: leftIcon, + size, + side: "left", + className: iconClass, + }, + rightIconProps: { + icon: rightIcon, + size, + side: "right", + className: iconClass, + }, + }] as [ + Omit<T, "iconClass" | "addonClass" | "inputContainerClass">, + { + inputContainerProps: InputContainerProps, + leftAddonProps: InputAddonProps, + rightAddonProps: InputAddonProps, + leftIconProps: InputIconProps, + rightIconProps: InputIconProps + } + ] +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/loading-spinner/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/loading-spinner/index.tsx new file mode 100644 index 0000000..957d41d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/loading-spinner/index.tsx @@ -0,0 +1,2 @@ +export * from "./loading-spinner" +export * from "./loading-overlay" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/loading-spinner/loading-overlay.tsx b/seanime-2.9.10/seanime-web/src/components/ui/loading-spinner/loading-overlay.tsx new file mode 100644 index 0000000..3d996e5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/loading-spinner/loading-overlay.tsx @@ -0,0 +1,60 @@ +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, defineStyleAnatomy } from "../core/styling" +import { LoadingSpinner } from "./loading-spinner" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const LoadingOverlayAnatomy = defineStyleAnatomy({ + overlay: cva([ + "UI-LoadingOverlay__overlay", + "absolute bg-[--background]/50 w-full h-full z-10 inset-0 pt-4 flex flex-col items-center justify-center backdrop-blur-sm", + "!mt-0", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * LoadingOverlay + * -----------------------------------------------------------------------------------------------*/ + +export type LoadingOverlayProps = { + children?: React.ReactNode + /** + * Whether to show the loading spinner + */ + showSpinner?: boolean + /** + * If true, the loading overlay will be unmounted + */ + hide?: boolean + className?: string +} + +export const LoadingOverlay = React.forwardRef<HTMLDivElement, LoadingOverlayProps>((props, ref) => { + + const { + children, + hide = false, + showSpinner = true, + className, + ...rest + } = props + + if (hide) return null + + return ( + <div + ref={ref} + className={cn(LoadingOverlayAnatomy.overlay(), className)} + {...rest} + > + {showSpinner && <LoadingSpinner className="justify-auto" />} + {children} + </div> + ) + +}) + +LoadingOverlay.displayName = "LoadingOverlay" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/loading-spinner/loading-spinner.tsx b/seanime-2.9.10/seanime-web/src/components/ui/loading-spinner/loading-spinner.tsx new file mode 100644 index 0000000..a89f0d6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/loading-spinner/loading-spinner.tsx @@ -0,0 +1,100 @@ +import { cva } from "class-variance-authority" +import React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const LoadingSpinnerAnatomy = defineStyleAnatomy({ + container: cva([ + "UI-LoadingSpinner__container", + "flex flex-col w-full items-center h-24 justify-center", + ]), + icon: cva([ + "UI-LoadingSpinner__icon", + "inline w-10 h-10 mr-2 animate-spin", + "text-gray-200 dark:text-gray-600 fill-brand-500", + ]), + title: cva([ + "UI-LoadingSpinner__title", + "text-base font-medium text-[--foreground] py-2", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * LoadingSpinner + * -----------------------------------------------------------------------------------------------*/ + +export type LoadingSpinnerProps = React.ComponentPropsWithRef<"div"> & ComponentAnatomy<typeof LoadingSpinnerAnatomy> & { + spinner?: React.ReactNode +} + +export const LoadingSpinner = React.forwardRef<HTMLDivElement, LoadingSpinnerProps>((props, ref) => { + + const { + children, + className, + containerClass, + iconClass, + spinner, + title, + ...rest + } = props + + return ( + <div + className={cn( + LoadingSpinnerAnatomy.container(), + containerClass, + )} + {...rest} + ref={ref} + > + {spinner ? spinner : <Spinner className={iconClass} />} + {title && <p className={LoadingSpinnerAnatomy.title()}>{title}</p>} + </div> + ) + +}) + +LoadingSpinner.displayName = "LoadingSpinner" + + +/* ------------------------------------------------------------------------------------------------- + * Spinner + * -----------------------------------------------------------------------------------------------*/ + +interface SpinnerProps extends React.ComponentPropsWithRef<"svg"> { + children?: React.ReactNode +} + +export const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>((props, ref) => { + + const { children, className, ...rest } = props + + return ( + <svg + aria-hidden="true" + className={cn( + LoadingSpinnerAnatomy.icon(), + className, + )} + viewBox="0 0 100 101" + fill="none" + xmlns="http://www.w3.org/2000/svg" + ref={ref} + {...rest} + > + <path + d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" + fill="currentColor" + /> + <path + d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" + fill="currentFill" + /> + </svg> + ) + +}) diff --git a/seanime-2.9.10/seanime-web/src/components/ui/modal/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/modal/index.tsx new file mode 100644 index 0000000..a30909e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/modal/index.tsx @@ -0,0 +1 @@ +export * from "./modal" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/modal/modal.tsx b/seanime-2.9.10/seanime-web/src/components/ui/modal/modal.tsx new file mode 100644 index 0000000..6081543 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/modal/modal.tsx @@ -0,0 +1,182 @@ +"use client" + +import { __isDesktop__ } from "@/types/constants" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { VisuallyHidden } from "@radix-ui/react-visually-hidden" +import { cva } from "class-variance-authority" +import * as React from "react" +import { CloseButton } from "../button" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const ModalAnatomy = defineStyleAnatomy({ + overlay: cva([ + "UI-Modal__overlay", + "fixed inset-0 z-50 bg-black/80", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + // "overflow-y-auto p-0 md:p-4 grid place-items-center", + ]), + content: cva([ + "UI-Modal__content", + "z-50 grid relative w-full w-full shadow-xl border border-[rgb(255_255_255_/_5%)] max-w-lg gap-4 bg-[--background] p-6 shadow-xl duration-200", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + // "data-[state=open]:slide-in-from-top-[40%] data-[state=closed]:slide-out-to-bottom-[40%]", + // "data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + // __isDesktop__ && "mt-10", + // __isDesktop__ && "select-none", + "sm:rounded-xl", + ]), + close: cva([ + "UI-Modal__close", + "absolute right-4 top-4 !mt-0", + ]), + header: cva([ + "UI-Modal__header", + "flex flex-col space-y-1.5 text-center sm:text-left", + ]), + footer: cva([ + "UI-Modal__footer", + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + ]), + title: cva([ + "UI-Modal__title", + "text-xl font-semibold leading-none tracking-tight", + ]), + description: cva([ + "UI-Modal__description", + "text-sm text-[--muted]", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Modal + * -----------------------------------------------------------------------------------------------*/ + +export type ModalProps = + Omit<React.ComponentPropsWithoutRef<typeof DialogPrimitive.Root>, "modal"> + & + Pick<React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>, "onOpenAutoFocus" | "onCloseAutoFocus" | "onEscapeKeyDown" | "onPointerDownCapture" | "onInteractOutside"> + & + ComponentAnatomy<typeof ModalAnatomy> + & { + /** + * Interaction with outside elements will be enabled and other elements will be visible to screen readers. + */ + allowOutsideInteraction?: boolean + /** + * The button that opens the modal + */ + trigger?: React.ReactElement + /** + * Title of the modal + */ + title?: React.ReactNode + /** + * An optional accessible description to be announced when the dialog is opened. + */ + description?: React.ReactNode + /** + * Footer of the modal + */ + footer?: React.ReactNode + /** + * Optional replacement for the default close button + */ + closeButton?: React.ReactElement + /** + * Whether to hide the close button + */ + hideCloseButton?: boolean +} + +export function Modal(props: ModalProps) { + + const { + allowOutsideInteraction = false, + trigger, + title, + footer, + description, + children, + closeButton, + overlayClass, + contentClass, + closeClass, + headerClass, + footerClass, + titleClass, + descriptionClass, + hideCloseButton, + // Content + onOpenAutoFocus, + onCloseAutoFocus, + onEscapeKeyDown, + onPointerDownCapture, + onInteractOutside, + ...rest + } = props + + return <DialogPrimitive.Root modal={!allowOutsideInteraction} {...rest}> + + {trigger && <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>} + + <DialogPrimitive.Portal> + <DialogPrimitive.Overlay className={cn(ModalAnatomy.overlay(), overlayClass)}> + <div + className={cn( + "overflow-y-auto absolute inset-0 grid place-items-center p-0 md:p-4", + __isDesktop__ && "md:p-8", + )} + > + <DialogPrimitive.Content + className={cn(ModalAnatomy.content(), contentClass)} + onOpenAutoFocus={onOpenAutoFocus} + onCloseAutoFocus={onCloseAutoFocus} + onEscapeKeyDown={onEscapeKeyDown} + onPointerDownCapture={onPointerDownCapture} + onInteractOutside={onInteractOutside} + > + {!title && !description ? ( + <VisuallyHidden> + <DialogPrimitive.Title>Dialog</DialogPrimitive.Title> + </VisuallyHidden> + ) : ( + <div className={cn(ModalAnatomy.header(), headerClass)}> + <DialogPrimitive.Title className={cn(ModalAnatomy.title(), titleClass)}> + {title} + </DialogPrimitive.Title> + {description && ( + <DialogPrimitive.Description className={cn(ModalAnatomy.description(), descriptionClass)}> + {description} + </DialogPrimitive.Description> + )} + </div> + )} + + {children} + + {footer && <div className={cn(ModalAnatomy.footer(), footerClass)}> + {footer} + </div>} + + {!hideCloseButton && <DialogPrimitive.Close className={cn(ModalAnatomy.close(), closeClass)} asChild> + {closeButton ? closeButton : <CloseButton />} + </DialogPrimitive.Close>} + + </DialogPrimitive.Content> + </div> + + + </DialogPrimitive.Overlay> + </DialogPrimitive.Portal> + + </DialogPrimitive.Root> +} + +Modal.displayName = "Modal" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/native-select/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/native-select/index.tsx new file mode 100644 index 0000000..7a4e337 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/native-select/index.tsx @@ -0,0 +1 @@ +export * from "./native-select" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/native-select/native-select.tsx b/seanime-2.9.10/seanime-web/src/components/ui/native-select/native-select.tsx new file mode 100644 index 0000000..2a4374f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/native-select/native-select.tsx @@ -0,0 +1,106 @@ +import * as React from "react" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { cn } from "../core/styling" +import { extractInputPartProps, InputAddon, InputAnatomy, InputContainer, InputIcon, InputStyling } from "../input" + +/* ------------------------------------------------------------------------------------------------- + * NativeSelect + * -----------------------------------------------------------------------------------------------*/ + +export type NativeSelectProps = Omit<React.ComponentPropsWithRef<"select">, "size"> & + InputStyling & + BasicFieldOptions & { + /** + * The options to display + */ + options: { value: string | number, label?: string }[] | undefined + /** + * The placeholder text + */ + placeholder?: string +} + +export const NativeSelect = React.forwardRef<HTMLSelectElement, NativeSelectProps>((props, ref) => { + + const [props1, basicFieldProps] = extractBasicFieldProps<NativeSelectProps>(props, React.useId()) + + const [{ + size, + intent, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + className, + placeholder, + options, + ...rest + }, { + inputContainerProps, + leftAddonProps, + leftIconProps, + rightAddonProps, + rightIconProps, + }] = extractInputPartProps<NativeSelectProps>({ + ...props1, + size: props1.size ?? "md", + intent: props1.intent ?? "basic", + leftAddon: props1.leftAddon, + leftIcon: props1.leftIcon, + rightAddon: props1.rightAddon, + rightIcon: props1.rightIcon, + }) + + return ( + <BasicField{...basicFieldProps}> + <InputContainer {...inputContainerProps}> + <InputAddon {...leftAddonProps} /> + <InputIcon {...leftIconProps} /> + + <select + id={basicFieldProps.id} + name={basicFieldProps.name} + className={cn( + "form-select", + InputAnatomy.root({ + size, + intent, + hasError: !!basicFieldProps.error, + isDisabled: !!basicFieldProps.disabled, + isReadonly: !!basicFieldProps.readonly, + hasRightAddon: !!rightAddon, + hasRightIcon: !!rightIcon, + hasLeftAddon: !!leftAddon, + hasLeftIcon: !!leftIcon, + }), + className, + )} + disabled={basicFieldProps.disabled || basicFieldProps.readonly} + data-disabled={basicFieldProps.disabled} + data-readonly={basicFieldProps.readonly} + aria-readonly={basicFieldProps.readonly} + required={basicFieldProps.required} + {...rest} + ref={ref} + > + {placeholder && <option value="">{placeholder}</option>} + {options?.map(opt => ( + <option key={opt.value} value={opt.value}>{opt.label ?? opt.value}</option> + ))} + </select> + + <InputAddon {...rightAddonProps} /> + <InputIcon + {...rightIconProps} + className={cn( + rightIconProps.className, + !rightAddon ? "mr-8" : null, + )} + /> + </InputContainer> + </BasicField> + ) + +}) + +NativeSelect.displayName = "NativeSelect" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/navigation-menu/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/navigation-menu/index.tsx new file mode 100644 index 0000000..25086a9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/navigation-menu/index.tsx @@ -0,0 +1 @@ +export * from "./navigation-menu" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/navigation-menu/navigation-menu.tsx b/seanime-2.9.10/seanime-web/src/components/ui/navigation-menu/navigation-menu.tsx new file mode 100644 index 0000000..3916b19 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/navigation-menu/navigation-menu.tsx @@ -0,0 +1,318 @@ +"use client" + +import { SeaLink } from "@/components/shared/sea-link" +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { Drawer } from "../drawer" +import { VerticalMenu, VerticalMenuItem } from "../vertical-menu" + + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const NavigationMenuAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-NavigationMenu__root", + "relative inline-block z-10 max-w-full", + ]), + item: cva([ + "UI-NavigationMenu__item", + "relative group/navigationMenu_item inline-flex items-center h-full select-none rounded-[--radius] leading-none no-underline outline-none transition-colors", + "text-[--muted] hover:bg-[--subtle] hover:text-[--foreground] focus:bg-[--subtle]", + "data-[current=true]:text-[--brand]", // Selected + "font-[600] leading-none", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[--ring]", + ], { + variants: { + size: { + sm: "px-3 h-8 text-sm", + md: "px-3 h-10 text-sm", + lg: "px-3 h-12 text-base", + }, + }, + defaultVariants: { + size: "md", + }, + }), + icon: cva([ + "UI-VerticalNav__icon", + "flex-shrink-0 mr-3", + "text-[--muted] group-hover/navigationMenu_item:text-[--foreground] data-[current=true]:text-[--brand] data-[current=true]:group-hover/navigationMenu_item:text-[--brand]", + ], { + variants: { + size: { + sm: "size-4", + md: "size-5", + lg: "size-6", + }, + }, + defaultVariants: { + size: "md", + }, + }), + itemChevron: cva([ + "UI-VerticalNav__itemChevron", + "ml-2 w-4 h-4 transition-transform duration-200 group-hover/navigationMenu_item:rotate-180", + ]), + desktopList: cva([ + "UI-VerticalNav__desktopList", + "inline-block space-x-1", + ], { + variants: { + switchToDrawerBelow: { + sm: "hidden sm:flex", + md: "hidden md:flex", + lg: "hidden lg:flex", + never: "flex", + }, + }, + defaultVariants: { + switchToDrawerBelow: "md", + }, + }), + mobileTrigger: cva([ + "UI-VerticalNav__mobileTrigger", + "items-center justify-center rounded-[--radius] p-2 text-[--muted] hover:bg-[--subtle] hover:text-[--foreground]", + "focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[--ring]", + ], { + variants: { + switchToDrawerBelow: { + sm: "inline-flex sm:hidden", + md: "inline-flex md:hidden", + lg: "inline-flex lg:hidden", + never: "hidden", + }, + }, + defaultVariants: { + switchToDrawerBelow: "md", + }, + }), + menuContainer: cva([ + "UI-NavigationMenu__menuContainer", + "absolute left-0 top-0 overflow-hidden p-1 data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out", + "data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52", + "data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52", + "data-[motion=to-start]:slide-out-to-left-52 w-full sm:min-w-full", + ]), + viewport: cva([ + "UI-NavigationMenu__viewport", + "relative mt-1.5 duration-300 h-[var(--radix-navigation-menu-viewport-height)]", + "w-full min-w-96 overflow-hidden rounded-[--radius] shadow-sm border bg-[--paper] text-[--foreground]", + "data-[state=open]:animate-in data-[state=open]:zoom-in-90 data-[state=open]:fade-in-25", + "data-[state=closed]:animate-out data-[state=closed]:zoom-out-100 data-[state=closed]:fade-out-0", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * NavigationMenu + * -----------------------------------------------------------------------------------------------*/ + +export type NavigationMenuProps = ComponentAnatomy<typeof NavigationMenuAnatomy> & + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root> & + VariantProps<typeof NavigationMenuAnatomy.desktopList> & + VariantProps<typeof NavigationMenuAnatomy.item> & { + children?: React.ReactNode + items: VerticalMenuItem[], + /** + * Add content to the mobile drawer. The content is appended above the menu + */ + mobileDrawerHeader?: React.ReactNode + /** + * Add content to the mobile drawer. The content is appended below the menu + */ + mobileDrawerContent?: React.ReactNode + /** + * Additional props passed to the mobile drawer + */ + mobileDrawerProps?: Partial<React.ComponentPropsWithoutRef<typeof Drawer>> +} + +export const NavigationMenu = React.forwardRef<HTMLDivElement, NavigationMenuProps>((props, ref) => { + + const { + children, + iconClass, + itemClass, + desktopListClass, + itemChevronClass, + mobileTriggerClass, + menuContainerClass, + viewportClass, + className, + switchToDrawerBelow, + mobileDrawerHeader, + mobileDrawerContent, + mobileDrawerProps, + items, + size, + ...rest + } = props + + const [mobileOpen, setMobileOpen] = React.useState(false) + + const Icon = React.useCallback(({ item }: { item: NavigationMenuProps["items"][number] }) => item.iconType ? <item.iconType + className={cn( + NavigationMenuAnatomy.icon({ size }), + iconClass, + )} + aria-hidden="true" + data-current={item.isCurrent} + /> : null, [iconClass, size]) + + return ( + <NavigationMenuPrimitive.Root + ref={ref} + className={cn( + NavigationMenuAnatomy.root(), + className, + )} + {...rest} + > + {/*Mobile*/} + <button + className={cn( + NavigationMenuAnatomy.mobileTrigger({ + switchToDrawerBelow, + }), + mobileTriggerClass, + )} + onClick={() => setMobileOpen(s => !s)} + > + <span className="sr-only">Open main menu</span> + {mobileOpen ? ( + <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="block h-6 w-6" + > + <line x1="18" x2="6" y1="6" y2="18"></line> + <line x1="6" x2="18" y1="6" y2="18"></line> + </svg> + ) : ( + <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="block h-6 w-6" + > + <line x1="4" x2="20" y1="12" y2="12"></line> + <line x1="4" x2="20" y1="6" y2="6"></line> + <line x1="4" x2="20" y1="18" y2="18"></line> + </svg> + )} + </button> + <Drawer + open={mobileOpen} + onOpenChange={open => setMobileOpen(open)} + side="left" + {...mobileDrawerProps} + > + {mobileDrawerHeader} + <VerticalMenu + items={items} + className="mt-2" + onLinkItemClick={() => setMobileOpen(false)} // Close the drawer when a link item is clicked + /> + {mobileDrawerContent} + </Drawer> + + {/*Desktop*/} + <NavigationMenuPrimitive.List + className={cn( + NavigationMenuAnatomy.desktopList({ + switchToDrawerBelow, + }), + desktopListClass, + )} + > + {items.map(item => { + + if (item.subContent) { + return ( + <NavigationMenuPrimitive.Item key={item.name}> + <NavigationMenuPrimitive.Trigger + className={cn( + NavigationMenuAnatomy.item({ size }), + itemClass, + )} + data-current={item.isCurrent} + > + <Icon item={item} /> + <span className="flex-none">{item.name}</span> + <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className={cn(NavigationMenuAnatomy.itemChevron(), itemChevronClass)} + data-open={`${mobileOpen}`} + > + <polyline points="6 9 12 15 18 9" /> + </svg> + </NavigationMenuPrimitive.Trigger> + <NavigationMenuPrimitive.Content + ref={ref} + className={cn( + NavigationMenuAnatomy.menuContainer(), + menuContainerClass, + )} + > + <div className="w-full"> + {item.subContent && item.subContent} + </div> + </NavigationMenuPrimitive.Content> + </NavigationMenuPrimitive.Item> + ) + } else { + return ( + <NavigationMenuPrimitive.Item key={item.name}> + <NavigationMenuPrimitive.NavigationMenuLink asChild> + {item.href ? ( + <SeaLink + href={item.href} + className={cn( + NavigationMenuAnatomy.item({ size }), + itemClass, + )} + data-current={item.isCurrent} + > + <Icon item={item} /> + <span className="flex-none">{item.name}</span> + {item.addon} + </SeaLink> + ) : ( + <button + className={cn( + NavigationMenuAnatomy.item({ size }), + itemClass, + )} + data-current={item.isCurrent} + > + <Icon item={item} /> + <span className="flex-none">{item.name}</span> + {item.addon} + </button> + )} + </NavigationMenuPrimitive.NavigationMenuLink> + </NavigationMenuPrimitive.Item> + ) + } + + })} + </NavigationMenuPrimitive.List> + <div className={cn("perspective-[2000px] absolute left-0 top-full w-full flex justify-center")}> + <NavigationMenuPrimitive.Viewport + className={cn( + NavigationMenuAnatomy.viewport(), + viewportClass, + )} + /> + </div> + </NavigationMenuPrimitive.Root> + ) + +}) + +NavigationMenu.displayName = "NavigationMenu" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/number-input/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/number-input/index.tsx new file mode 100644 index 0000000..4e4c240 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/number-input/index.tsx @@ -0,0 +1 @@ +export * from "./number-input" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/number-input/number-input.tsx b/seanime-2.9.10/seanime-web/src/components/ui/number-input/number-input.tsx new file mode 100644 index 0000000..c0ddcb6 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/number-input/number-input.tsx @@ -0,0 +1,351 @@ +"use client" + +import type { IntlTranslations } from "@zag-js/number-input" +import * as numberInput from "@zag-js/number-input" +import { normalizeProps, useMachine } from "@zag-js/react" +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { IconButton } from "../button" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { extractInputPartProps, InputAddon, InputAnatomy, InputContainer, InputIcon, InputStyling } from "../input" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const NumberInputAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-NumberInput__root", + "z-[2]", + ], { + variants: { + hideControls: { + true: false, + false: "border-r border-r-transparent hover:border-r-[--border]", + }, + size: { + sm: null, + md: null, + lg: null, + }, + intent: { + basic: null, + filled: null, + unstyled: "border-r-0 hover:border-r-transparent", + }, + }, + defaultVariants: { + hideControls: false, + }, + }), + control: cva([ + "UI-NumberInput__control", + "rounded-none h-[50%] ring-inset", + ]), + controlsContainer: cva([ + "UI-NumberInput__controlsContainer", + "form-input w-auto p-0 flex flex-col items-stretch justify-center overflow-hidden max-h-full", + "border-l-0 relative z-[1]", + "shadow-xs", + ], { + variants: { + size: { + sm: "h-8", + md: "h-10", + lg: "h-12", + }, + intent: { + basic: null, + filled: "hover:bg-gray-100", + unstyled: null, + }, + hasRightAddon: { + true: "border-r-0", + false: null, + }, + }, + }), + chevronIcon: cva([ + "UI-Combobox__chevronIcon", + "h-4 w-4 shrink-0", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * NumberInput + * -----------------------------------------------------------------------------------------------*/ + +export type NumberInputProps = Omit<React.ComponentPropsWithoutRef<"input">, "value" | "size" | "defaultValue"> & + ComponentAnatomy<typeof NumberInputAnatomy> & + Omit<VariantProps<typeof NumberInputAnatomy.root>, "size" | "intent"> & + BasicFieldOptions & + InputStyling & { + /** + * The value of the input + */ + value?: number | string + /** + * The callback to handle value changes + */ + onValueChange?: (value: number, valueAsString: string) => void + /** + * Default value when uncontrolled + */ + defaultValue?: number | string + /** + * The minimum value of the input + */ + min?: number + /** + * The maximum value of the input + */ + max?: number + /** + * The amount to increment or decrement the value by + */ + step?: number + /** + * Whether to allow mouse wheel to change the value + */ + allowMouseWheel?: boolean + /** + * Whether to allow the value overflow the min/max range + */ + allowOverflow?: boolean + /** + * Whether to hide the controls + */ + hideControls?: boolean + /** + * The format options for the value + */ + formatOptions?: Intl.NumberFormatOptions + /** + * Whether to clamp the value when the input loses focus (blur) + */ + clampValueOnBlur?: boolean + /** + * Accessibility + * + * Specifies the localized strings that identifies the accessibility elements and their states + */ + translations?: IntlTranslations, + /** + * The current locale. Based on the BCP 47 definition. + */ + locale?: string + /** + * The document's text/writing direction. + */ + dir?: "ltr" | "rtl" +} + +export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>((props, ref) => { + + const [props1, basicFieldProps] = extractBasicFieldProps<NumberInputProps>(props, React.useId()) + + const [{ + controlClass, + controlsContainerClass, + chevronIconClass, + className, + children, + /**/ + size, + intent, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + placeholder, + onValueChange, + hideControls, + value: controlledValue, + min = 0, + max, + step, + allowMouseWheel = true, + formatOptions = { maximumFractionDigits: 2 }, + clampValueOnBlur = true, + translations, + locale, + dir, + defaultValue, + ...rest + }, { + inputContainerProps, + leftAddonProps, + leftIconProps, + rightAddonProps, + rightIconProps, + }] = extractInputPartProps<NumberInputProps>({ + ...props1, + size: props1.size ?? "md", + intent: props1.intent ?? "basic", + leftAddon: props1.leftAddon, + leftIcon: props1.leftIcon, + rightAddon: props1.rightAddon, + rightIcon: props1.rightIcon, + }) + const service = useMachine(numberInput.machine, { + id: basicFieldProps.id, + name: basicFieldProps.name, + disabled: basicFieldProps.disabled, + readOnly: basicFieldProps.readonly, + value: controlledValue ? String(controlledValue) : (defaultValue ? String(defaultValue) : undefined), + min, + max, + step, + allowMouseWheel, + formatOptions, + clampValueOnBlur, + translations, + locale, + dir, + onValueChange: (details: { valueAsNumber: number; value: string }) => { + onValueChange?.(details.valueAsNumber, details.value) + }, + }) + + const api = numberInput.connect(service, normalizeProps) + + const isFirst = React.useRef(true) + + React.useEffect(() => { + if (!isFirst.current) { + if (typeof controlledValue === "string" && !isNaN(Number(controlledValue))) { + api.setValue(Number(controlledValue)) + } else if (typeof controlledValue === "number") { + api.setValue(controlledValue) + } else if (controlledValue === undefined) { + api.setValue(min) + } + } + isFirst.current = false + }, [controlledValue]) + + return ( + <BasicField + {...basicFieldProps} + id={api.getInputProps().id} + > + <InputContainer {...inputContainerProps}> + <InputAddon {...leftAddonProps} /> + <InputIcon {...leftIconProps} /> + + <input + ref={ref} + type="number" + name={basicFieldProps.name} + className={cn( + "form-input", + InputAnatomy.root({ + size, + intent, + hasError: !!basicFieldProps.error, + isDisabled: !!basicFieldProps.disabled, + hasRightAddon: !!rightAddon || !hideControls, + hasRightIcon: !!rightIcon, + hasLeftAddon: !!leftAddon, + hasLeftIcon: !!leftIcon, + }), + NumberInputAnatomy.root({ hideControls, intent, size }), + className, + )} + disabled={basicFieldProps.disabled || basicFieldProps.readonly} + data-disabled={basicFieldProps.disabled} + data-readonly={basicFieldProps.readonly} + aria-readonly={basicFieldProps.readonly} + required={basicFieldProps.required} + {...api.getInputProps()} + {...rest} + /> + + {!hideControls && (<div + className={cn( + InputAnatomy.root({ + size, + intent, + hasError: !!basicFieldProps.error, + isDisabled: !!basicFieldProps.disabled, + hasRightAddon: !!rightAddon, + hasRightIcon: !!rightIcon, + hasLeftAddon: true, + }), + NumberInputAnatomy.controlsContainer({ + size, + intent, + hasRightAddon: !!rightAddon, + }), + controlsContainerClass, + )} + > + <IconButton + intent="gray-basic" + size="sm" + className={cn( + NumberInputAnatomy.control(), + controlClass, + )} + {...api.getIncrementTriggerProps()} + data-readonly={basicFieldProps.readonly} + data-disabled={basicFieldProps.disabled || api.getIncrementTriggerProps().disabled} + disabled={basicFieldProps.disabled || basicFieldProps.readonly || api.getIncrementTriggerProps().disabled} + tabIndex={0} + icon={<svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(NumberInputAnatomy.chevronIcon(), "rotate-180", chevronIconClass)} + > + <path d="m6 9 6 6 6-6" /> + </svg>} + /> + <IconButton + intent="gray-basic" + size="sm" + className={cn( + NumberInputAnatomy.control(), + controlClass, + )} + {...api.getDecrementTriggerProps()} + data-readonly={basicFieldProps.readonly} + data-disabled={basicFieldProps.disabled || api.getDecrementTriggerProps().disabled} + disabled={basicFieldProps.disabled || basicFieldProps.readonly || api.getDecrementTriggerProps().disabled} + tabIndex={0} + icon={<svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(NumberInputAnatomy.chevronIcon(), chevronIconClass)} + > + <path d="m6 9 6 6 6-6" /> + </svg>} + /> + </div>)} + + <InputAddon {...rightAddonProps} /> + <InputIcon + {...rightIconProps} + className={cn( + "z-3", + rightIconProps.className, + !rightAddon ? "mr-6" : null, + )} + /> + </InputContainer> + </BasicField> + ) + +}) + +NumberInput.displayName = "NumberInput" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/page-header/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/page-header/index.tsx new file mode 100644 index 0000000..67f7a96 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/page-header/index.tsx @@ -0,0 +1 @@ +export * from "./page-header" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/page-header/page-header.tsx b/seanime-2.9.10/seanime-web/src/components/ui/page-header/page-header.tsx new file mode 100644 index 0000000..d1eab54 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/page-header/page-header.tsx @@ -0,0 +1,130 @@ +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const PageHeaderAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-PageHeader__root", + "md:flex md:items-center md:justify-between space-y-2 md:space-y-0 md:space-x-5", + ]), + title: cva([ + "UI-PageHeader__title", + "font-bold text-gray-900 dark:text-gray-200", + ], { + variants: { + size: { + sm: "text-lg sm:text-xl", + md: "text-2xl sm:text-3xl", + lg: "text-3xl sm:text-4xl", + xl: "text-4xl sm:text-5xl", + }, + }, + defaultVariants: { + size: "md", + }, + }), + actionContainer: cva([ + "UI-PageHeader__actionContainer", + "justify-stretch flex flex-col-reverse space-y-4 space-y-reverse sm:flex-row-reverse sm:justify-end", + "sm:space-y-0 sm:space-x-3 sm:space-x-reverse md:mt-0 md:flex-row md:space-x-3", + ]), + textContainer: cva([ + "UI-PageHeader__textContainer", + "space-y-1", + ]), + description: cva([ + "UI-PageHeader__description", + "text-sm font-medium text-gray-500 dark:text-gray-400", + ]), + detailsContainer: cva([ + "UI-PageHeader__detailsContainer", + "block sm:flex items-start sm:space-x-5", + ], { + variants: { + _withImage: { + true: "flex-col sm:flex-row", + false: null, + }, + }, + }), +}) + +/* ------------------------------------------------------------------------------------------------- + * PageHeader + * -----------------------------------------------------------------------------------------------*/ + +export type PageHeaderProps = React.ComponentPropsWithRef<"header"> & + ComponentAnatomy<typeof PageHeaderAnatomy> & + VariantProps<typeof PageHeaderAnatomy.title> & { + /** + * Page title. + */ + title?: string + /** + * Page description. + */ + description?: string + /** + * Elements rendered in the action container. + */ + action?: React.ReactNode + /** + * Image elements rendered next to the title and description. + */ + image?: React.ReactNode +} + +export const PageHeader = React.forwardRef<HTMLDivElement, PageHeaderProps>((props, ref) => { + + const { + children, + className, + size = "md", + title, + description, + action, + image, + titleClass, + actionContainerClass, + descriptionClass, + detailsContainerClass, + textContainerClass, + ...rest + } = props + + return ( + <header + ref={ref} + aria-label={title} + className={cn( + PageHeaderAnatomy.root(), + className, + )} + {...rest} + > + <div className={cn(PageHeaderAnatomy.detailsContainer({ _withImage: !!image }), detailsContainerClass)}> + {image && <div className="flex-shrink-0"> + <div className="relative"> + {image} + </div> + </div>} + <div className={cn(PageHeaderAnatomy.textContainer(), textContainerClass)}> + <h1 className={cn(PageHeaderAnatomy.title({ size }), titleClass)}>{title}</h1> + {description && <p className={cn(PageHeaderAnatomy.description(), descriptionClass)}> + {description} + </p>} + </div> + </div> + {!!action && <div className={cn(PageHeaderAnatomy.actionContainer(), actionContainerClass)}> + {action} + </div>} + </header> + ) + +}) + +PageHeader.displayName = "PageHeader" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/pagination/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/pagination/index.tsx new file mode 100644 index 0000000..cfb9e24 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/pagination/index.tsx @@ -0,0 +1 @@ +export * from "./pagination" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/pagination/pagination.tsx b/seanime-2.9.10/seanime-web/src/components/ui/pagination/pagination.tsx new file mode 100644 index 0000000..2657431 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/pagination/pagination.tsx @@ -0,0 +1,199 @@ +"use client" + +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import * as React from "react" +import { cva } from "class-variance-authority" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const PaginationAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Pagination__root", + "flex gap-1 text-xs font-medium", + ]), + item: cva([ + "UI-Pagination__item", + "bg-transparent dark:bg-transparent text-sm text-[--muted] inline-flex h-8 w-8 items-center justify-center rounded-[--radius] border cursor-pointer", + "hover:bg-[--subtle] dark:hover:bg-[--subtle] hover:border-[--subtle] select-none", + "data-[selected=true]:bg-brand-500 data-[selected=true]:border-transparent data-[selected=true]:text-white data-[selected=true]:hover:bg-brand data-[selected=true]:pointer-events-none", // Selected + "data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed", // Disabled + "outline-none ring-[--ring] focus-visible:ring-2", + ]), + ellipsis: cva([ + "UI-Pagination__ellipsis", + "flex p-2 items-center text-[1.05rem]", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Pagination + * -----------------------------------------------------------------------------------------------*/ + +const __PaginationAnatomyContext = React.createContext<ComponentAnatomy<typeof PaginationAnatomy>>({}) + +export type PaginationProps = React.ComponentPropsWithRef<"ul"> & ComponentAnatomy<typeof PaginationAnatomy> + +export const Pagination = React.forwardRef<HTMLUListElement, PaginationProps>((props, ref) => { + + const { + children, + itemClass, + className, + ellipsisClass, + ...rest + } = props + + return ( + <__PaginationAnatomyContext.Provider + value={{ + itemClass, + ellipsisClass, + }} + > + <ul + ref={ref} + className={cn(PaginationAnatomy.root(), className)} + role="navigation" + {...rest} + > + {children} + </ul> + </__PaginationAnatomyContext.Provider> + ) + +}) + +Pagination.displayName = "Pagination" + + +/* ------------------------------------------------------------------------------------------------- + * PaginationItem + * -----------------------------------------------------------------------------------------------*/ + +export type PaginationItemProps = Omit<React.ComponentPropsWithRef<"button">, "children"> & { + value: string | number +} + +export const PaginationItem = React.forwardRef<HTMLButtonElement, PaginationItemProps>((props, ref) => { + + const { + value, + className, + ...rest + } = props + + const { itemClass } = React.useContext(__PaginationAnatomyContext) + + return ( + <li> + <button + className={cn(PaginationAnatomy.item(), itemClass, className)} + {...rest} + ref={ref} + > + {value} + </button> + </li> + ) + +}) + +PaginationItem.displayName = "PaginationItem" + +/* ------------------------------------------------------------------------------------------------- + * PaginationTrigger + * -----------------------------------------------------------------------------------------------*/ + +export type PaginationTriggerProps = Omit<React.ComponentPropsWithRef<"button">, "children"> & { + direction: "previous" | "next" + isChevrons?: boolean + isDisabled?: boolean +} + +export const PaginationTrigger = React.forwardRef<HTMLButtonElement, PaginationTriggerProps>((props, ref) => { + + const { + isChevrons = false, + isDisabled = false, + direction, + className, + ...rest + } = props + + const { itemClass } = React.useContext(__PaginationAnatomyContext) + + return ( + <li> + <button + className={cn(PaginationAnatomy.item(), itemClass, className)} + data-disabled={isDisabled} + tabIndex={isDisabled ? -1 : undefined} + {...rest} + ref={ref} + > + {direction === "previous" ? ( + <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className="h-4 w-4" + > + {!isChevrons ? <polyline points="15 18 9 12 15 6"></polyline> : <> + <polyline points="11 17 6 12 11 7" /> + <polyline points="18 17 13 12 18 7" /> + </>} + </svg> + ) : ( + <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className="h-4 w-4" + > + {!isChevrons ? <polyline points="9 18 15 12 9 6"></polyline> : <> + <polyline points="13 17 18 12 13 7" /> + <polyline points="6 17 11 12 6 7" /> + </>} + </svg> + + )} + </button> + </li> + ) + +}) + +PaginationTrigger.displayName = "PaginationTrigger" + +/* ------------------------------------------------------------------------------------------------- + * PaginationEllipsis + * -----------------------------------------------------------------------------------------------*/ + +export type PaginationEllipsisProps = Omit<React.ComponentPropsWithRef<"span">, "children"> + +export const PaginationEllipsis = React.forwardRef<HTMLSpanElement, PaginationEllipsisProps>((props, ref) => { + + const { + className, + ...rest + } = props + + const { ellipsisClass } = React.useContext(__PaginationAnatomyContext) + + return ( + <li className={cn(PaginationAnatomy.ellipsis(), ellipsisClass, className)}> + <span + {...rest} + ref={ref} + > + … + </span> + </li> + ) + +}) + +PaginationEllipsis.displayName = "PaginationEllipsis" + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/popover/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/popover/index.tsx new file mode 100644 index 0000000..137ef5d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/popover/index.tsx @@ -0,0 +1 @@ +export * from "./popover" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/popover/popover.tsx b/seanime-2.9.10/seanime-web/src/components/ui/popover/popover.tsx new file mode 100644 index 0000000..2580883 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/popover/popover.tsx @@ -0,0 +1,91 @@ +"use client" + +import * as PopoverPrimitive from "@radix-ui/react-popover" +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const PopoverAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Popover__root", + "z-50 w-72 rounded-[--radius] border bg-[--background] p-4 text-base shadow-sm outline-none", + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0", + "data-[state=open]:fade-in-50 data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Popover + * -----------------------------------------------------------------------------------------------*/ + +export type PopoverProps = + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Root> & + Omit<React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>, "asChild"> & + { + /** + * The trigger element that opens the popover + */ + trigger: React.ReactElement, + /** + * Additional props for the trigger element + */ + triggerProps?: React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger>, + /** + * Portal container for custom mounting (useful for fullscreen mode) + */ + portalContainer?: HTMLElement + } + +export const Popover = React.forwardRef<HTMLDivElement, PopoverProps>((props, ref) => { + const { + trigger, + triggerProps, + // Root + defaultOpen, + open, + onOpenChange, + modal = true, + // Content + className, + align = "center", + sideOffset = 8, + // Portal + portalContainer, + ...contentProps + } = props + + return ( + <PopoverPrimitive.Root + defaultOpen={defaultOpen} + open={open} + onOpenChange={onOpenChange} + modal={modal} + > + <PopoverPrimitive.Trigger + asChild + {...triggerProps} + > + {trigger} + </PopoverPrimitive.Trigger> + <PopoverPrimitive.Portal container={portalContainer}> + <PopoverPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn(PopoverAnatomy.root(), className)} + onOpenAutoFocus={(e) => e.preventDefault()} + {...contentProps} + /> + </PopoverPrimitive.Portal> + </PopoverPrimitive.Root> + ) +}) + +Popover.displayName = "Popover" + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/progress-bar/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/progress-bar/index.tsx new file mode 100644 index 0000000..ad8afe8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/progress-bar/index.tsx @@ -0,0 +1 @@ +export * from "./progress-bar" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/progress-bar/progress-bar.tsx b/seanime-2.9.10/seanime-web/src/components/ui/progress-bar/progress-bar.tsx new file mode 100644 index 0000000..f077769 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/progress-bar/progress-bar.tsx @@ -0,0 +1,80 @@ +"use client" + +import * as ProgressPrimitive from "@radix-ui/react-progress" +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const ProgressBarAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-ProgressBar__root", + "relative w-full overflow-hidden rounded-full bg-[--subtle] translate-z-0", + ], { + variants: { + size: { + xs: "h-1", + sm: "h-2", + md: "h-3", + lg: "h-4", + xl: "h-6", + }, + }, + defaultVariants: { + size: "md", + }, + }), + indicator: cva([ + "UI-ProgressBar__indicator", + "h-full w-full flex-1 bg-brand transition-all flex items-center justify-center relative", + ], { + variants: { + isIndeterminate: { + true: "animate-indeterminate-progress origin-left-right", + false: null, + }, + }, + defaultVariants: { + isIndeterminate: false, + }, + }), +}) + +/* ------------------------------------------------------------------------------------------------- + * ProgressBar + * -----------------------------------------------------------------------------------------------*/ + +export type ProgressBarProps = React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> + & ComponentAnatomy<typeof ProgressBarAnatomy> + & VariantProps<typeof ProgressBarAnatomy.root> + & VariantProps<typeof ProgressBarAnatomy.indicator> + +export const ProgressBar = React.forwardRef<HTMLDivElement, ProgressBarProps>((props, ref) => { + const { + className, + value, + indicatorClass, + size, + isIndeterminate, + ...rest + } = props + + return ( + <ProgressPrimitive.Root + data-progress-bar + ref={ref} + className={cn(ProgressBarAnatomy.root({ size }), className)} + {...rest} + > + <ProgressPrimitive.Indicator + className={cn(ProgressBarAnatomy.indicator({ isIndeterminate }), indicatorClass)} + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} + data-progress-value={value} + /> + </ProgressPrimitive.Root> + ) +}) +ProgressBar.displayName = "ProgressBar" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/radio-group/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/radio-group/index.tsx new file mode 100644 index 0000000..eac62d9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/radio-group/index.tsx @@ -0,0 +1 @@ +export * from "./radio-group" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/radio-group/radio-group.tsx b/seanime-2.9.10/seanime-web/src/components/ui/radio-group/radio-group.tsx new file mode 100644 index 0000000..5c2d3fb --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/radio-group/radio-group.tsx @@ -0,0 +1,253 @@ +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { mergeRefs } from "../core/utils" +import { hiddenInputStyles } from "../input" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const RadioGroupAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-RadioGroup__root", + ]), + item: cva([ + "UI-RadioGroup__item", + "block aspect-square rounded-full border text-brand ring-offset-1 ring-offset-[--background]", + "focus:outline-none focus-visible:ring-2 focus-visible:ring-[--ring] focus-visible:ring-offset-2", + "disabled:cursor-not-allowed data-[disabled=true]:opacity-50 data-[readonly=true]:cursor-not-allowed", + "data-[state=unchecked]:bg-white dark:data-[state=unchecked]:bg-gray-700", // Unchecked + "data-[state=unchecked]:hover:bg-gray-100 dark:data-[state=unchecked]:hover:bg-gray-600", // Unchecked hover + "data-[state=checked]:bg-brand data-[state=checked]:border-transparent", // Checked + "data-[error=true]:border-red-500 data-[error=true]:dark:border-red-500 data-[error=true]:data-[state=checked]:border-red-500 data-[error=true]:dark:data-[state=checked]:border-red-500", // Error + ], { + variants: { + size: { + sm: "h-4 w-4", + md: "h-5 w-5", + lg: "h-6 w-6", + }, + }, + defaultVariants: { + size: "md", + }, + }), + itemIndicator: cva([ + "UI-RadioGroup__itemIndicator", + "flex items-center justify-center", + ]), + itemLabel: cva([ + "UI-Checkbox_itemLabel", + "font-normal block", + "data-[disabled=true]:opacity-50", + ], { + variants: { + size: { + md: "text-md", + lg: "text-lg", + }, + }, + defaultVariants: { + size: "md", + }, + }), + itemContainer: cva([ + "UI-RadioGroup__itemContainer", + "flex gap-2 items-center relative", + ]), + itemCheckIcon: cva([ + "UI-RadioGroup__itemCheckIcon", + "text-white", + ], { + variants: { + size: { + sm: "h-4 w-4", + md: "h-4 w-4", + lg: "h-5 w-5", + }, + }, + defaultVariants: { + size: "md", + }, + }), +}) + +/* ------------------------------------------------------------------------------------------------- + * RadioGroup + * -----------------------------------------------------------------------------------------------*/ + +export type RadioGroupOption = { value: string, label?: React.ReactNode, disabled?: boolean, readonly?: boolean } + +export type RadioGroupProps = BasicFieldOptions & + ComponentAnatomy<typeof RadioGroupAnatomy> & + VariantProps<typeof RadioGroupAnatomy.item> & { + /** + * Selected value + */ + value?: string | undefined + /** + * Default value when uncontrolled + */ + defaultValue?: string | undefined + /** + * Callback fired when the selected value changes + */ + onValueChange?: (value: string) => void + /** + * Radio options + */ + options: RadioGroupOption[] + /** + * Replaces the default check icon + */ + itemCheckIcon?: React.ReactNode + /** + * Ref to the input element + */ + inputRef?: React.Ref<HTMLInputElement> + /** + * Stack div class + */ + stackClass?: string + /** + * Item div class + */ + className?: string +} + +export const RadioGroup = React.forwardRef<HTMLButtonElement, RadioGroupProps>((props, ref) => { + const id = React.useId() + const [{ + size, + className, + stackClass, + value: controlledValue, + onValueChange, + options, + inputRef, + defaultValue, + /**/ + itemClass, + itemIndicatorClass, + itemLabelClass, + itemContainerClass, + itemCheckIcon, + itemCheckIconClass, + }, basicFieldProps] = extractBasicFieldProps<RadioGroupProps>(props, id) + + const isFirst = React.useRef(true) + + const buttonRef = React.useRef<HTMLButtonElement>(null) + + const [_value, _setValue] = React.useState<string | undefined>(controlledValue ?? defaultValue) + + const handleOnValueChange = React.useCallback((value: string) => { + _setValue(value) + onValueChange?.(value) + }, []) + + React.useEffect(() => { + if (!defaultValue || !isFirst.current) { + _setValue(controlledValue) + } + isFirst.current = false + }, [controlledValue]) + + return ( + <BasicField{...basicFieldProps}> + <RadioGroupPrimitive.Root + value={_value} + onValueChange={handleOnValueChange} + defaultValue={defaultValue} + className={cn(RadioGroupAnatomy.root(), className)} + disabled={basicFieldProps.disabled || basicFieldProps.readonly} + data-error={!!basicFieldProps.error} + data-disabled={basicFieldProps.disabled} + data-readonly={basicFieldProps.readonly} + aria-readonly={basicFieldProps.readonly} + loop + > + <div className={cn("UI-RadioGroup__stack space-y-1", stackClass)}> + + {options.map(option => { + return ( + <label + key={option.value} + className={cn(RadioGroupAnatomy.itemContainer(), itemContainerClass)} + htmlFor={id + option.value} + data-error={!!basicFieldProps.error} + data-disabled={basicFieldProps.disabled || option.disabled} + data-readonly={basicFieldProps.readonly || option.readonly} + data-state={_value === option.value ? "checked" : "unchecked"} + > + <RadioGroupPrimitive.Item + ref={mergeRefs([buttonRef, ref])} + id={id + option.value} + key={option.value} + value={option.value} + disabled={basicFieldProps.disabled || basicFieldProps.readonly || option.disabled || option.readonly} + data-error={!!basicFieldProps.error} + data-disabled={basicFieldProps.disabled || option.disabled} + data-readonly={basicFieldProps.readonly || option.readonly} + className={cn(RadioGroupAnatomy.item({ size }), itemClass)} + > + <RadioGroupPrimitive.Indicator + className={cn( + RadioGroupAnatomy.itemIndicator(), + itemIndicatorClass, + )} + data-error={!!basicFieldProps.error} + data-disabled={basicFieldProps.disabled || option.disabled} + data-readonly={basicFieldProps.readonly || option.readonly} + > + {itemCheckIcon ? itemCheckIcon : <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 16 16" + width="16" + height="16" + stroke="currentColor" + fill="currentColor" + className={cn(RadioGroupAnatomy.itemCheckIcon({ size }), itemCheckIconClass)} + > + <path d="M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"></path> + </svg>} + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + <label + className={cn(RadioGroupAnatomy.itemLabel(), itemLabelClass)} + htmlFor={id + option.value} + aria-disabled={option.disabled} + data-error={!!basicFieldProps.error} + data-disabled={basicFieldProps.disabled || option.disabled || option.disabled} + data-readonly={basicFieldProps.readonly || option.readonly} + data-state={_value === option.value ? "checked" : "unchecked"} + > + {option.label ?? option.value} + </label> + </label> + ) + })} + </div> + </RadioGroupPrimitive.Root> + + <input + ref={inputRef} + type="radio" + name={basicFieldProps.name} + className={hiddenInputStyles} + value={_value ?? ""} + checked={!!_value} + aria-hidden="true" + required={basicFieldProps.required} + tabIndex={-1} + onChange={() => {}} + onFocusCapture={() => buttonRef.current?.focus()} + /> + </BasicField> + ) +}) + +RadioGroup.displayName = "RadioGroup" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/scroll-area/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/scroll-area/index.tsx new file mode 100644 index 0000000..da9b575 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/scroll-area/index.tsx @@ -0,0 +1 @@ +export * from "./scroll-area" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/scroll-area/scroll-area.tsx b/seanime-2.9.10/seanime-web/src/components/ui/scroll-area/scroll-area.tsx new file mode 100644 index 0000000..783af27 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/scroll-area/scroll-area.tsx @@ -0,0 +1,117 @@ +"use client" + +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const ScrollAreaAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-ScrollArea__root", + "relative overflow-hidden", + ]), + viewport: cva([ + "UI-ScrollArea__viewport", + "h-full w-full rounded-[inherit]", + "[&>div]:!block", + ]), + scrollbar: + cva([ + "UI-ScrollArea__scrollbar", + "flex touch-none select-none transition-colors", + ], { + variants: { + orientation: { + vertical: "h-full w-2.5 border-l border-l-transparent p-[1px]", + horizontal: "h-2.5 flex-col border-t border-t-transparent p-[1px]", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }), + thumb: cva([ + "UI-ScrollArea__thumb", + "relative flex-1 rounded-full bg-[--border]", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * ScrollArea + * -----------------------------------------------------------------------------------------------*/ + +export type ScrollAreaProps = + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> + & ComponentAnatomy<typeof ScrollAreaAnatomy> & + { + orientation?: "vertical" | "horizontal", + viewportRef?: React.RefObject<HTMLDivElement> + } + +export const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>((props, ref) => { + const { + className, + scrollbarClass, + thumbClass, + viewportClass, + children, + orientation = "vertical", + viewportRef, + ...rest + } = props + return ( + <ScrollAreaPrimitive.Root + ref={ref} + className={cn(ScrollAreaAnatomy.root(), className)} + {...rest} + > + <ScrollAreaPrimitive.Viewport + ref={viewportRef} + className={cn(ScrollAreaAnatomy.viewport(), viewportClass)} + > + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar + className={scrollbarClass} + thumbClass={thumbClass} + orientation={orientation} + /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> + ) + +}) +ScrollArea.displayName = "ScrollArea" + +/* ------------------------------------------------------------------------------------------------- + * ScrollBar + * -----------------------------------------------------------------------------------------------*/ + +type ScrollBarProps = + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> & + Pick<ComponentAnatomy<typeof ScrollAreaAnatomy>, "thumbClass"> + +const ScrollBar = React.forwardRef<HTMLDivElement, ScrollBarProps>((props, ref) => { + const { + className, + thumbClass, + orientation = "vertical", + ...rest + } = props + + return ( + <ScrollAreaPrimitive.ScrollAreaScrollbar + ref={ref} + orientation={orientation} + className={cn(ScrollAreaAnatomy.scrollbar({ orientation }), className)} + {...rest} + > + <ScrollAreaPrimitive.ScrollAreaThumb className={cn(ScrollAreaAnatomy.thumb(), thumbClass)} /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> + ) +}) +ScrollBar.displayName = "ScrollBar" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/select/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/select/index.tsx new file mode 100644 index 0000000..7f86937 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/select/index.tsx @@ -0,0 +1 @@ +export * from "./select" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/select/select.tsx b/seanime-2.9.10/seanime-web/src/components/ui/select/select.tsx new file mode 100644 index 0000000..d3f8bbf --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/select/select.tsx @@ -0,0 +1,333 @@ +"use client" + +import * as SelectPrimitive from "@radix-ui/react-select" +import { SelectItem, SelectItemIndicator, SelectItemText } from "@radix-ui/react-select" +import { cva } from "class-variance-authority" +import * as React from "react" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { mergeRefs } from "../core/utils" +import { extractInputPartProps, hiddenInputStyles, InputAddon, InputAnatomy, InputContainer, InputIcon, InputStyling } from "../input" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const SelectAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Select__root", + "inline-flex items-center justify-between relative whitespace-nowrap truncate", + ]), + chevronIcon: cva([ + "UI-Combobox__chevronIcon", + "ml-2 h-4 w-4 shrink-0 opacity-50", + ]), + scrollButton: cva([ + "UI-Select__scrollButton", + "flex items-center justify-center h-[25px] bg-[--paper] text-base cursor-default", + ]), + content: cva([ + "UI-Select__content", + "w-full overflow-hidden rounded-[--radius] shadow-md bg-[--paper] border leading-none z-50", + ]), + viewport: cva([ + "UI-Select__viewport", + "p-1 z-10", + ]), + item: cva([ + "UI-Select__item", + "text-base leading-none rounded-[--radius] flex items-center h-8 pr-2 pl-8 relative", + "select-none disabled:opacity-50 disabled:pointer-events-none", + "data-highlighted:outline-none data-highlighted:bg-[--subtle]", + "data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none", + ]), + checkIcon: cva([ + "UI-Select__checkIcon", + "absolute left-2 w-4 inline-flex items-center justify-center", + ]), +}) + + +/* ------------------------------------------------------------------------------------------------- + * Select + * -----------------------------------------------------------------------------------------------*/ + +export type SelectOption = { value: string, label?: string, disabled?: boolean } + +export type SelectProps = InputStyling & + BasicFieldOptions & + Omit<React.ComponentPropsWithoutRef<"button">, "value" | "defaultValue"> & + ComponentAnatomy<typeof SelectAnatomy> & { + /** + * The options to display in the dropdown + */ + options: SelectOption[] | undefined + /** + * The placeholder text + */ + placeholder?: string + /** + * Direction of the text + */ + dir?: "ltr" | "rtl" + /** + * The selected value + */ + value?: string | undefined + /** + * Callback fired when the selected value changes + */ + onValueChange?: (value: string) => void + /** + * Callback fired when the dropdown opens or closes + */ + onOpenChange?: (open: boolean) => void + /** + * Default selected value when uncontrolled + */ + defaultValue?: string + /** + * Ref to the input element + */ + inputRef?: React.Ref<HTMLSelectElement> +} + +export const Select = React.forwardRef<HTMLButtonElement, SelectProps>((props, ref) => { + + const [props1, basicFieldProps] = extractBasicFieldProps<SelectProps>(props, React.useId()) + + const [{ + size, + intent, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + /**/ + className, + placeholder, + options, + chevronIconClass, + scrollButtonClass, + contentClass, + viewportClass, + checkIconClass, + itemClass, + /**/ + dir, + value: controlledValue, + onValueChange, + onOpenChange, + defaultValue, + inputRef, + ...rest + }, { + inputContainerProps, + leftAddonProps, + leftIconProps, + rightAddonProps, + rightIconProps, + }] = extractInputPartProps<SelectProps>({ + ...props1, + size: props1.size ?? "md", + intent: props1.intent ?? "basic", + leftAddon: props1.leftAddon, + leftIcon: props1.leftIcon, + rightAddon: props1.rightAddon, + rightIcon: props1.rightIcon, + }) + + const isFirst = React.useRef(true) + + const buttonRef = React.useRef<HTMLButtonElement>(null) + + const [_value, _setValue] = React.useState<string | undefined>(controlledValue ?? defaultValue) + + const handleOnValueChange = React.useCallback((value: string) => { + if (value === "__placeholder__") { + _setValue("") + onValueChange?.("") + return + } + _setValue(value) + onValueChange?.(value) + }, []) + + React.useEffect(() => { + if (!defaultValue || !isFirst.current) { + _setValue(controlledValue) + } + isFirst.current = false + }, [controlledValue]) + + return ( + <BasicField {...basicFieldProps}> + <InputContainer {...inputContainerProps}> + <InputAddon {...leftAddonProps} /> + <InputIcon {...leftIconProps} /> + + <SelectPrimitive.Root + dir={dir} + value={_value} + onValueChange={handleOnValueChange} + onOpenChange={onOpenChange} + defaultValue={defaultValue} + > + + <SelectPrimitive.Trigger + ref={mergeRefs([buttonRef, ref])} + id={basicFieldProps.id} + className={cn( + InputAnatomy.root({ + size, + intent, + hasError: !!basicFieldProps.error, + isDisabled: !!basicFieldProps.disabled, + isReadonly: !!basicFieldProps.readonly, + hasRightAddon: !!rightAddon, + hasRightIcon: !!rightIcon, + hasLeftAddon: !!leftAddon, + hasLeftIcon: !!leftIcon, + }), + SelectAnatomy.root(), + className, + )} + aria-label={basicFieldProps.name || "Select"} + {...rest} + > + <SelectPrimitive.Value placeholder={placeholder} /> + + <SelectPrimitive.Icon className={cn(!!rightIcon && "hidden")}> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(SelectAnatomy.chevronIcon(), chevronIconClass)} + > + <path d="m6 9 6 6 6-6" /> + </svg> + </SelectPrimitive.Icon> + + </SelectPrimitive.Trigger> + + <SelectPrimitive.Portal> + <SelectPrimitive.Content className={cn(SelectAnatomy.content(), contentClass)}> + + <SelectPrimitive.ScrollUpButton className={cn(SelectAnatomy.scrollButton(), scrollButtonClass)}> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(SelectAnatomy.chevronIcon(), "rotate-180", chevronIconClass)} + > + <path d="m6 9 6 6 6-6" /> + </svg> + </SelectPrimitive.ScrollUpButton> + + <SelectPrimitive.Viewport className={cn(SelectAnatomy.viewport(), viewportClass)}> + + {(!!placeholder && !basicFieldProps.required) && ( + <SelectItem + className={cn( + SelectAnatomy.item(), + itemClass, + )} + value="__placeholder__" + > + <SelectItemText className="flex-none whitespace-nowrap truncate">{placeholder}</SelectItemText> + </SelectItem> + )} + + {options?.map(option => ( + <SelectItem + key={option.value} + className={cn( + SelectAnatomy.item(), + itemClass, + )} + value={option.value} + disabled={option.disabled} + data-disabled={option.disabled} + > + <SelectItemText className="flex-none whitespace-nowrap truncate">{option.label}</SelectItemText> + <SelectItemIndicator asChild> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn( + SelectAnatomy.checkIcon(), + checkIconClass, + )} + > + <path d="M20 6 9 17l-5-5" /> + </svg> + </SelectItemIndicator> + </SelectItem> + ))} + + </SelectPrimitive.Viewport> + + <SelectPrimitive.ScrollDownButton className={cn(SelectAnatomy.scrollButton(), scrollButtonClass)}> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(SelectAnatomy.chevronIcon(), chevronIconClass)} + > + <path d="m6 9 6 6 6-6" /> + </svg> + </SelectPrimitive.ScrollDownButton> + + </SelectPrimitive.Content> + </SelectPrimitive.Portal> + + </SelectPrimitive.Root> + + <select + ref={inputRef} + name={basicFieldProps.name} + className={hiddenInputStyles} + aria-hidden="true" + required={basicFieldProps.required} + disabled={basicFieldProps.disabled} + value={_value} + tabIndex={-1} + onChange={() => {}} + onFocusCapture={() => buttonRef.current?.focus()} + > + <option value="" /> + {options?.map(option => ( + <option + key={option.value} + value={option.value} + disabled={option.disabled} + /> + ))} + </select> + + <InputAddon {...rightAddonProps} /> + <InputIcon {...rightIconProps} /> + </InputContainer> + </BasicField> + ) + +}) + +Select.displayName = "Select" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/separator/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/separator/index.tsx new file mode 100644 index 0000000..068cfa8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/separator/index.tsx @@ -0,0 +1 @@ +export * from "./separator" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/separator/separator.tsx b/seanime-2.9.10/seanime-web/src/components/ui/separator/separator.tsx new file mode 100644 index 0000000..ccf7b46 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/separator/separator.tsx @@ -0,0 +1,54 @@ +"use client" + +import { cn } from "../core/styling" +import * as SeparatorPrimitive from "@radix-ui/react-separator" +import { cva } from "class-variance-authority" +import * as React from "react" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const SeparatorAnatomy = { + root: cva([ + "UI-Separator__root", + "shrink-0 bg-[--border]", + ], { + variants: { + orientation: { + horizontal: "w-full h-[1px]", + vertical: "h-full w-[1px]", + }, + }, + }), +} + +/* ------------------------------------------------------------------------------------------------- + * Separator + * -----------------------------------------------------------------------------------------------*/ + +export type SeparatorProps = React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> + +export const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>((props, ref) => { + const { + className, + orientation = "horizontal", + decorative = true, + ...rest + } = props + + return ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn( + SeparatorAnatomy.root({ orientation }), + className, + )} + {...rest} + /> + ) +}) + +Separator.displayName = "Separator" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/simple-dropzone/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/simple-dropzone/index.tsx new file mode 100644 index 0000000..059cf72 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/simple-dropzone/index.tsx @@ -0,0 +1 @@ +export * from "./simple-dropzone" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/simple-dropzone/simple-dropzone.tsx b/seanime-2.9.10/seanime-web/src/components/ui/simple-dropzone/simple-dropzone.tsx new file mode 100644 index 0000000..c407f9f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/simple-dropzone/simple-dropzone.tsx @@ -0,0 +1,411 @@ +"use client" + +import { cva } from "class-variance-authority" +import * as React from "react" +import { Accept, FileError, useDropzone } from "react-dropzone" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { CloseButton, IconButton } from "../button" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { hiddenInputStyles } from "../input" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const SimpleDropzoneAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-SimpleDropzone__root", + "appearance-none w-full mb-2 cursor-pointer hover:text-[--foreground] flex items-center justify-center p-4 border rounded-[--radius] border-dashed", + "gap-3 text-sm sm:text-base", + "outline-none ring-[--ring] focus-visible:ring-2", + "text-[--muted] transition ease-in-out hover:border-[--foreground]", + "data-[drag-active=true]:border-brand-500", + "data-[drag-reject=true]:border-[--red]", + ]), + list: cva([ + "UI-SimpleDropzone__list", + "flex rounded-[--radius-md] flex-wrap divide-y divide-[--border]", + ]), + listItem: cva([ + "UI-SimpleDropzone__listItem", + "flex items-center justify-space-between relative p-1 hover:bg-[--subtle] w-full overflow-hidden", + ]), + listItemDetailsContainer: cva([ + "UI-SimpleDropzone__listItemDetailsContainer", + "flex items-center gap-2 truncate w-full", + ]), + listItemTitle: cva([ + "UI-SimpleDropzone__listItemTitle", + "truncate max-w-[180px] text-[.9rem]", + ]), + listItemSize: cva([ + "UI-SimpleDropzone__listItemSize", + "text-xs uppercase text-center font-semibold align-center text-[--muted]", + ]), + listItemRemoveButton: cva([ + "UI-SimpleDropzone__listItemRemoveButton", + "ml-2 rounded-full", + ]), + imagePreviewGrid: cva([ + "UI-SimpleDropzone__imagePreviewGrid", + "flex gap-2 flex-wrap place-content-center pt-4", + ]), + imagePreviewContainer: cva([ + "UI-SimpleDropzone__imagePreviewContainer", + "col-span-1 row-span-1 aspect-square w-36 h-auto", + ]), + imagePreview: cva([ + "UI-SimpleDropzone__imagePreview", + "relative bg-transparent border h-full bg-center bg-no-repeat bg-contain rounded-[--radius-md] overflow-hidden", + "col-span-1 row-span-1", + ]), + imagePreviewRemoveButton: cva([ + "UI-SimpleDropzone__imagePreviewRemoveButton", + "absolute top-1 right-1", + ]), + fileIcon: cva([ + "UI-SimpleDropzone__fileIcon", + "w-5 h-5 flex-none", + ]), + maxSizeText: cva([ + "UI-SimpleDropzone__maxSizeText", + "text-sm text-[--muted] font-medium", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * SimpleDropzone + * -----------------------------------------------------------------------------------------------*/ + +export type SimpleDropzoneProps = Omit<React.ComponentPropsWithRef<"input">, "size" | "accept" | "type" | "onError" | "onDrop"> & + ComponentAnatomy<typeof SimpleDropzoneAnatomy> & + BasicFieldOptions & { + /** + * Callback fired when files are selected + */ + onValueChange?: (files: File[]) => void, + /** + * Whether to show a preview of the image(s) under the dropzone + */ + withImagePreview?: boolean + /** + * Whether to allow multiple files + */ + multiple?: boolean + /** + * The accepted file types + */ + accept?: Accept + /** + * The minimum file size + */ + minSize?: number + /** + * The maximum file size + */ + maxSize?: number + /** + * The maximum number of files + */ + maxFiles?: number + /** + * If false, allow dropped items to take over the current browser window + */ + preventDropOnDocument?: boolean + /** + * Whether to prevent click to open file dialog + */ + noClick?: boolean + /** + * Whether to prevent drag and drop + */ + noDrag?: boolean + /** + * Callback fired when an error occurs + */ + onError?: (err: Error) => void + /** + * Custom file validator function + */ + validator?: <T extends File>(file: T) => FileError | FileError[] | null + /** + * The dropzoneText text displayed in the dropzone + */ + dropzoneText?: string +} + +export const SimpleDropzone = React.forwardRef<HTMLInputElement, SimpleDropzoneProps>((props, ref) => { + + const [{ + children, + className, + listClass, + listItemClass, + listItemDetailsContainerClass, + listItemRemoveButtonClass, + listItemSizeClass, + listItemTitleClass, + imagePreviewGridClass, + imagePreviewContainerClass, + imagePreviewRemoveButtonClass, + imagePreviewClass, + maxSizeTextClass, + fileIconClass, + onValueChange, + withImagePreview, + dropzoneText, + /**/ + accept, + minSize, + maxSize, + maxFiles, + preventDropOnDocument, + noClick, + noDrag, + onError, + validator, + multiple, + value, // ignored + ...rest + }, basicFieldProps] = extractBasicFieldProps(props, React.useId()) + + const buttonRef = React.useRef<HTMLButtonElement>(null) + + const [files, setFiles] = React.useState<File[]>([]) + + const onDrop = React.useCallback((acceptedFiles: File[]) => { + // Update files - add the preview + setFiles(acceptedFiles.map(file => Object.assign(file, { preview: URL.createObjectURL(file) }))) + }, []) + + const handleRemoveFile = React.useCallback((file: number) => { + setFiles(p => p.toSpliced(file, 1)) + }, []) + + React.useEffect(() => { + onValueChange?.(files) + }, [files]) + + React.useEffect(() => () => { + files.forEach((file: any) => URL.revokeObjectURL(file.preview)) + }, [files]) + + const { + getRootProps, + getInputProps, + isDragActive, + isDragReject, + } = useDropzone({ + onDrop, + multiple, + minSize, + maxSize, + maxFiles, + preventDropOnDocument, + noClick, + noDrag, + validator, + accept, + onError, + }) + + return ( + <BasicField {...basicFieldProps}> + <button + ref={buttonRef} + className={cn( + SimpleDropzoneAnatomy.root(), + className, + )} + data-drag-active={isDragActive} + data-drag-reject={isDragReject} + {...getRootProps()} + tabIndex={0} + > + <input + ref={ref} + id={basicFieldProps.id} + name={basicFieldProps.name ?? "files"} + value="" + onFocusCapture={() => buttonRef.current?.focus()} + aria-hidden="true" + {...getInputProps()} + {...rest} + className={cn("block", hiddenInputStyles)} + style={{ display: "block" }} + /> + <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5" + > + <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> + <polyline points="7 10 12 15 17 10" /> + <line x1="12" x2="12" y1="15" y2="3" /> + </svg> + <span> + {dropzoneText ?? "Click or drag file to this area to upload"} + </span> + </button> + + {maxSize && <div className={cn(SimpleDropzoneAnatomy.maxSizeText(), maxSizeTextClass)}>{`≤`} {humanFileSize(maxSize, 0)}</div>} + + {!withImagePreview && <div className={cn(SimpleDropzoneAnatomy.list(), listClass)}> + {files?.map((file: any, index) => { + + let Icon: React.ReactElement + + if (["image/jpeg", "image/png", "image/jpg", "image/webm"].includes(file.type)) { + Icon = <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" + fill="none" stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className={cn(SimpleDropzoneAnatomy.fileIcon(), fileIconClass)} + > + <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /> + <polyline points="14 2 14 8 20 8" /> + <circle cx="10" cy="13" r="2" /> + <path d="m20 17-1.09-1.09a2 2 0 0 0-2.82 0L10 22" /> + </svg> + } else if (file.type.includes("video")) { + Icon = <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" + fill="none" stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className={cn(SimpleDropzoneAnatomy.fileIcon(), fileIconClass)} + > + <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /> + <polyline points="14 2 14 8 20 8" /> + <path d="m10 11 5 3-5 3v-6Z" /> + </svg> + } else if (file.type.includes("audio")) { + Icon = <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" + fill="none" stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className={cn(SimpleDropzoneAnatomy.fileIcon(), fileIconClass)} + > + <path + d="M17.5 22h.5c.5 0 1-.2 1.4-.6.4-.4.6-.9.6-1.4V7.5L14.5 2H6c-.5 0-1 .2-1.4.6C4.2 3 4 3.5 4 4v3" + /> + <polyline points="14 2 14 8 20 8" /> + <path d="M10 20v-1a2 2 0 1 1 4 0v1a2 2 0 1 1-4 0Z" /> + <path d="M6 20v-1a2 2 0 1 0-4 0v1a2 2 0 1 0 4 0Z" /> + <path d="M2 19v-3a6 6 0 0 1 12 0v3" /> + </svg> + } else if (file.type.includes("pdf") || file.type.includes("document") || file.type.includes("txt") || file.type.includes("text")) { + Icon = <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" + fill="none" stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className={cn(SimpleDropzoneAnatomy.fileIcon(), fileIconClass)} + > + <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /> + <polyline points="14 2 14 8 20 8" /> + <line x1="16" x2="8" y1="13" y2="13" /> + <line x1="16" x2="8" y1="17" y2="17" /> + <line x1="10" x2="8" y1="9" y2="9" /> + </svg> + } else if (file.type.includes("compressed") || file.type.includes("zip") || file.type.includes("archive")) { + Icon = <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" + fill="none" stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className={cn(SimpleDropzoneAnatomy.fileIcon(), fileIconClass)} + > + <path + d="M22 20V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2h6" + /> + <circle cx="16" cy="19" r="2" /> + <path d="M16 11v-1" /> + <path d="M16 17v-2" /> + </svg> + } else { + Icon = <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" + fill="none" stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className={cn(SimpleDropzoneAnatomy.fileIcon(), fileIconClass)} + > + <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /> + <polyline points="14 2 14 8 20 8" /> + </svg> + } + + return ( + + <div + key={file.name} + className={cn(SimpleDropzoneAnatomy.listItem(), listItemClass)} + > + <div + className={cn(SimpleDropzoneAnatomy.listItemDetailsContainer(), listItemDetailsContainerClass)} + > + {Icon} + <p className={cn(SimpleDropzoneAnatomy.listItemTitle(), listItemTitleClass)}>{file.name}</p> + <p className={cn(SimpleDropzoneAnatomy.listItemSize(), listItemSizeClass)}>{humanFileSize(file.size)}</p> + </div> + <IconButton + size="xs" + intent="gray-basic" + icon={ + <svg + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" + fill="none" + stroke="currentColor" strokeWidth="2" strokeLinecap="round" + strokeLinejoin="round" + className="w-4 h-4" + > + <path d="M3 6h18" /> + <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /> + <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /> + <line x1="10" x2="10" y1="11" y2="17" /> + <line x1="14" x2="14" y1="11" y2="17" /> + </svg> + } + className={cn(SimpleDropzoneAnatomy.listItemRemoveButton(), listItemRemoveButtonClass)} + onClick={() => handleRemoveFile(index)} + /> + </div> + ) + })} + </div>} + + {withImagePreview && !!files.length && <div className={cn(SimpleDropzoneAnatomy.imagePreviewGrid(), imagePreviewGridClass)}> + {files?.map((file, index) => { + return ( + <div + key={file.name} + className={cn(SimpleDropzoneAnatomy.imagePreviewContainer(), imagePreviewContainerClass)} + > + <div + className={cn(SimpleDropzoneAnatomy.imagePreview(), imagePreviewClass)} + style={{ backgroundImage: file ? `url(${(file as File & { preview: string }).preview})` : undefined }} + > + <CloseButton + intent="alert" + size="xs" + className={cn(SimpleDropzoneAnatomy.imagePreviewRemoveButton(), imagePreviewRemoveButtonClass)} + onClick={() => handleRemoveFile(index)} + /> + </div> + <div className={cn(SimpleDropzoneAnatomy.listItemDetailsContainer(), listItemDetailsContainerClass)}> + <p className={cn(SimpleDropzoneAnatomy.listItemTitle(), listItemTitleClass)}>{file.name}</p> + <p className={cn(SimpleDropzoneAnatomy.listItemSize(), listItemSizeClass)}>{humanFileSize(file.size)}</p> + </div> + </div> + ) + })} + </div>} + + </BasicField> + ) + +}) + +SimpleDropzone.displayName = "SimpleDropzone" + +function humanFileSize(size: number, precision = 2): string { + const i = Math.floor(Math.log(size) / Math.log(1024)) + return (size / Math.pow(1024, i)).toFixed(precision).toString() + ["bytes", "Kb", "Mb", "Gb", "Tb"][i] +} diff --git a/seanime-2.9.10/seanime-web/src/components/ui/skeleton/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/skeleton/index.tsx new file mode 100644 index 0000000..d889ad7 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/skeleton/index.tsx @@ -0,0 +1 @@ +export * from "./skeleton" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/skeleton/skeleton.tsx b/seanime-2.9.10/seanime-web/src/components/ui/skeleton/skeleton.tsx new file mode 100644 index 0000000..b766843 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/skeleton/skeleton.tsx @@ -0,0 +1,33 @@ +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const SkeletonAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Skeleton__root", + "animate-pulse rounded-[--radius-md] bg-[--subtle] w-full h-12", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Skeleton + * -----------------------------------------------------------------------------------------------*/ + +export type SkeletonProps = React.ComponentPropsWithoutRef<"div"> + +export const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>((props, ref) => { + const { className, ...rest } = props + return ( + <div + ref={ref} + className={cn(SkeletonAnatomy.root(), className)} + {...rest} + /> + ) +}) + +Skeleton.displayName = "Skeleton" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/stats/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/stats/index.tsx new file mode 100644 index 0000000..9199b04 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/stats/index.tsx @@ -0,0 +1 @@ +export * from "./stats" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/stats/stats.tsx b/seanime-2.9.10/seanime-web/src/components/ui/stats/stats.tsx new file mode 100644 index 0000000..48d2275 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/stats/stats.tsx @@ -0,0 +1,155 @@ +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const StatsAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Stats__root", + "grid grid-cols-1 divide-y divide-[--border] overflow-hidden md:grid-cols-3 md:divide-y-0 md:divide-x", + ], { + variants: { + size: { + sm: null, md: null, lg: null, + }, + }, + defaultVariants: { + size: "md", + }, + }), + item: cva([ + "UI-Stats__item", + "relative", + ], { + variants: { + size: { + sm: "p-3 sm:p-4", + md: "p-4 sm:p-6", + lg: "p-4 sm:p-7", + }, + }, + }), + name: cva([ + "UI-Stats__name", + "text-sm font-normal text-[--muted]", + ], { + variants: { + size: { + sm: "text-xs", + md: "text-sm", + lg: "text-base", + }, + }, + }), + value: cva([ + "UI-Stats__value", + "mt-1 flex items-baseline md:block lg:flex font-semibold", + ], { + variants: { + size: { + sm: "text-xl md:text-2xl", + md: "text-2xl md:text-3xl", + lg: "text-3xl md:text-4xl", + }, + }, + }), + unit: cva([ + "UI-Stats__unit", + "ml-2 text-sm font-medium text-[--muted]", + ]), + trend: cva([ + "UI-Stats__trend", + "inline-flex items-baseline text-sm font-medium", + "data-[trend=up]:text-[--green] data-[trend=down]:text-[--red]", + ]), + icon: cva([ + "UI-Stats__icon", + "absolute top-5 right-5 opacity-30", + ], { + variants: { + size: { + sm: "text-xl sm:text-2xl", + md: "text-2xl sm:text-3xl", + lg: "text-3xl sm:text-4xl", + }, + }, + }), +}) + +/* ------------------------------------------------------------------------------------------------- + * Stats + * -----------------------------------------------------------------------------------------------*/ + +export type StatsItem = { + name: string, + value: string | number, + unit?: string | number, + change?: string | number, + trend?: "up" | "down", + icon?: React.ReactElement +} + +export type StatsProps = React.ComponentPropsWithRef<"dl"> & + ComponentAnatomy<typeof StatsAnatomy> & + VariantProps<typeof StatsAnatomy.root> & { + children?: React.ReactNode, + items: StatsItem[] +} + +export const Stats = React.forwardRef<HTMLDListElement, StatsProps>((props, ref) => { + + const { + children, + itemClass, + nameClass, + valueClass, + unitClass, + trendClass, + iconClass, + className, + items, + size = "md", + ...rest + } = props + + return ( + <dl + ref={ref} + className={cn(StatsAnatomy.root({ size }), className)} + {...rest} + > + {items.map((item) => ( + <div key={item.name} className={cn(StatsAnatomy.item({ size }), itemClass)}> + + <dt className={cn(StatsAnatomy.name({ size }), nameClass)}>{item.name}</dt> + + <dd className={cn(StatsAnatomy.value({ size }), valueClass)}> + {item.value} + <span className={cn(StatsAnatomy.unit(), unitClass)}>{item.unit}</span> + </dd> + + {(!!item.change || !!item.trend) && + <div + className={cn(StatsAnatomy.trend(), trendClass)} + data-trend={item.trend} + > + {item.trend && <span> {item.trend === "up" ? "+" : "-"}</span>} + {item.change} + </div> + } + + <div className={cn(StatsAnatomy.icon({ size }), iconClass)}> + {item.icon} + </div> + + </div> + ))} + </dl> + ) + +}) + +Stats.displayName = "Stats" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/switch/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/switch/index.tsx new file mode 100644 index 0000000..b0af0ed --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/switch/index.tsx @@ -0,0 +1 @@ +export * from "./switch" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/switch/switch.tsx b/seanime-2.9.10/seanime-web/src/components/ui/switch/switch.tsx new file mode 100644 index 0000000..2e6bdae --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/switch/switch.tsx @@ -0,0 +1,207 @@ +"use client" + +import { hiddenInputStyles } from "@/components/ui/input" +import { Popover } from "@/components/ui/popover" +import * as SwitchPrimitive from "@radix-ui/react-switch" +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { AiOutlineExclamationCircle } from "react-icons/ai" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { mergeRefs } from "../core/utils" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ +export const SwitchAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Switch__root", + "peer inline-flex shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors", + "disabled:cursor-not-allowed data-[disabled=true]:opacity-50", + "outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--ring] focus-visible:ring-offset-1", + "data-[state=unchecked]:bg-gray-200 dark:data-[state=unchecked]:bg-gray-700", // Unchecked + "data-[state=unchecked]:hover:bg-gray-300 dark:data-[state=unchecked]:hover:bg-gray-600", // Unchecked hover + "data-[state=checked]:bg-brand", // Checked + "data-[error=true]:border-red-500", // Checked + ], { + variants: { + size: { + sm: "h-5 w-9", + md: "h-6 w-11", + lg: "h-7 w-14", + }, + }, + defaultVariants: { + size: "md", + }, + }), + container: cva([ + "UI-Switch__container", + "inline-flex gap-2 items-center", + ], { + variants: { + side: { + left: "", + right: "w-full flex-row-reverse", + }, + }, + defaultVariants: { + side: "left", + }, + }), + thumb: cva([ + "UI-Switch__thumb", + "pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform", + "data-[state=unchecked]:translate-x-1", + ], { + variants: { + size: { + sm: "h-3 w-3 data-[state=checked]:translate-x-[1.1rem]", + md: "h-4 w-4 data-[state=checked]:translate-x-[1.4rem]", + lg: "h-5 w-5 data-[state=checked]:translate-x-[1.9rem]", + }, + }, + defaultVariants: { + size: "md", + }, + }), + label: cva([ + "UI-Switch__label", + "relative font-normal", + "data-[disabled=true]:text-gray-300 cursor-pointer user-select-none select-none", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Switch + * -----------------------------------------------------------------------------------------------*/ + +export type SwitchProps = BasicFieldOptions & + ComponentAnatomy<typeof SwitchAnatomy> & + VariantProps<typeof SwitchAnatomy.root> & + VariantProps<typeof SwitchAnatomy.container> & + Omit<React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>, + "value" | "checked" | "disabled" | "required" | "defaultValue" | "defaultChecked" | "onCheckedChange"> & { + /** + * Whether the switch is checked + */ + value?: boolean + /** + * Callback fired when the value changes + */ + onValueChange?: (value: boolean) => void + /** + * Default value when uncontrolled + */ + defaultValue?: boolean + /** + * Ref to the input element + */ + inputRef?: React.Ref<HTMLInputElement> + className?: string + moreHelp?: React.ReactNode +} + +export const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => { + + const [{ + size, + value: controlledValue, + className, + onValueChange, + labelClass, + containerClass, + thumbClass, + defaultValue, + inputRef, + side, + moreHelp, + ...rest + }, { label, ...basicFieldProps }] = extractBasicFieldProps(props, React.useId()) + + const isFirst = React.useRef(true) + + const buttonRef = React.useRef<HTMLButtonElement>(null) + + const [_value, _setValue] = React.useState<boolean | undefined>(controlledValue ?? defaultValue ?? false) + + const handleOnValueChange = React.useCallback((value: boolean) => { + _setValue(value) + onValueChange?.(value) + }, []) + + React.useEffect(() => { + if (!defaultValue || !isFirst.current) { + _setValue(controlledValue) + } + isFirst.current = false + }, [controlledValue]) + + return ( + <BasicField + {...basicFieldProps} + id={basicFieldProps.id} + fieldClass={cn( + "w-fit", + side === "right" && "w-full group/switch transition-all duration-200 hover:bg-gray-300/5 rounded-[--radius] p-2 w-[calc(100%_+_1rem)] -ml-2 border border-transparent border-dashed hover:border-[--subtle]", + basicFieldProps.fieldClass, + )} + fieldHelpTextClass={cn("")} + > + <div className={cn(SwitchAnatomy.container({ side }), containerClass)}> + <SwitchPrimitive.Root + ref={mergeRefs([buttonRef, ref])} + id={basicFieldProps.id} + className={cn(SwitchAnatomy.root({ size }), className)} + disabled={basicFieldProps.disabled || basicFieldProps.readonly} + data-disabled={basicFieldProps.disabled} + data-readonly={basicFieldProps.readonly} + data-error={!!basicFieldProps.error} + checked={_value} + onCheckedChange={handleOnValueChange} + defaultChecked={defaultValue} + {...rest} + > + <SwitchPrimitive.Thumb className={cn(SwitchAnatomy.thumb({ size }), thumbClass)} /> + </SwitchPrimitive.Root> + <div className="flex flex-1"></div> + {!!label && <div className="flex items-center gap-1"> + <label + className={cn( + SwitchAnatomy.label(), + labelClass, + side === "right" && "font-semibold transition-transform __group-hover/switch:-translate-y-0.5", + )} + htmlFor={basicFieldProps.id} + data-disabled={basicFieldProps.disabled} + > + {label} + </label> + {moreHelp && <Popover + className="text-sm" + trigger={<AiOutlineExclamationCircle className="transition-opacity opacity-45 hover:opacity-90" />} + > + {moreHelp} + </Popover>} + </div>} + + <input + ref={inputRef} + type="checkbox" + name={basicFieldProps.name} + className={hiddenInputStyles} + value={_value ? "on" : "off"} + checked={basicFieldProps.required ? _value : true} + aria-hidden="true" + required={controlledValue === undefined && basicFieldProps.required} + tabIndex={-1} + onChange={() => {}} + onFocusCapture={() => buttonRef.current?.focus()} + /> + </div> + </BasicField> + ) + +}) + +Switch.displayName = "Switch" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/table/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/table/index.tsx new file mode 100644 index 0000000..d3a48f9 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/table/index.tsx @@ -0,0 +1 @@ +export * from "./table" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/table/table.tsx b/seanime-2.9.10/seanime-web/src/components/ui/table/table.tsx new file mode 100644 index 0000000..4596155 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/table/table.tsx @@ -0,0 +1,157 @@ +"use client" + +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const TableAnatomy = defineStyleAnatomy({ + table: cva([ + "UI-Table__table", + "w-full caption-bottom text-sm", + ]), + tableHeader: cva([ + "UI-Table__tableHeader", + "[&_tr]:border-b", + ]), + tableBody: cva([ + "UI-Table__tableBody", + "[&_tr:last-child]:border-0", + ]), + tableFooter: cva([ + "UI-Table__tableFooter", + "border-t bg-gray-100 dark:bg-gray-900 bg-opacity-40 font-medium [&>tr]:last:border-b-0", + ]), + tableRow: cva([ + "UI-Table__tableRow", + "border-b transition-colors hover:bg-[--subtle] data-[state=selected]:bg-[--subtle]", + ]), + tableHead: cva([ + "UI-Table__tableHead", + "h-12 px-4 text-left align-middle font-medium", + "[&:has([role=checkbox])]:pr-0", + ]), + tableCell: cva([ + "UI-Table__tableCell", + "p-4 align-middle [&:has([role=checkbox])]:pr-0", + ]), + tableCaption: cva([ + "UI-Table__tableCaption", + "mt-4 text-sm", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Table + * -----------------------------------------------------------------------------------------------*/ + +export type TableProps = React.ComponentPropsWithoutRef<"table"> + +export const Table = React.forwardRef<HTMLTableElement, TableProps>((props, ref) => { + const { className, ...rest } = props + + return ( + <div className="relative w-full overflow-auto"> + <table + ref={ref} + className={cn(TableAnatomy.table(), className)} + {...rest} + /> + </div> + ) +}) +Table.displayName = "Table" + +/* ------------------------------------------------------------------------------------------------- + * TableHeader + * -----------------------------------------------------------------------------------------------*/ + +export type TableHeaderProps = React.ComponentPropsWithoutRef<"thead"> + +export const TableHeader = React.forwardRef<HTMLTableSectionElement, TableHeaderProps>((props, ref) => { + const { className, ...rest } = props + + return <thead ref={ref} className={cn(TableAnatomy.tableHeader(), className)} {...rest} /> +}) +TableHeader.displayName = "TableHeader" + +/* ------------------------------------------------------------------------------------------------- + * TableBody + * -----------------------------------------------------------------------------------------------*/ + +export type TableBodyProps = React.ComponentPropsWithoutRef<"tbody"> + +export const TableBody = React.forwardRef<HTMLTableSectionElement, TableBodyProps>((props, ref) => { + const { className, ...rest } = props + + return <tbody ref={ref} className={cn(TableAnatomy.tableBody(), className)} {...rest} /> +}) +TableBody.displayName = "TableBody" + +/* ------------------------------------------------------------------------------------------------- + * TableFooter + * -----------------------------------------------------------------------------------------------*/ + +export type TableFooterProps = React.ComponentPropsWithoutRef<"tfoot"> + +export const TableFooter = React.forwardRef<HTMLTableSectionElement, TableFooterProps>((props, ref) => { + const { className, ...rest } = props + + return <tfoot ref={ref} className={cn(TableAnatomy.tableFooter(), className)} {...rest} /> +}) +TableFooter.displayName = "TableFooter" + +/* ------------------------------------------------------------------------------------------------- + * TableRow + * -----------------------------------------------------------------------------------------------*/ + +export type TableRowProps = React.ComponentPropsWithoutRef<"tr"> + +export const TableRow = React.forwardRef<HTMLTableRowElement, TableRowProps>((props, ref) => { + const { className, ...rest } = props + + return <tr ref={ref} className={cn(TableAnatomy.tableRow(), className)} {...rest} /> +}) +TableRow.displayName = "TableRow" + +/* ------------------------------------------------------------------------------------------------- + * TableHead + * -----------------------------------------------------------------------------------------------*/ + +export type TableHeadProps = React.ComponentPropsWithoutRef<"th"> + +export const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>((props, ref) => { + const { className, ...rest } = props + + return <th ref={ref} className={cn(TableAnatomy.tableHead(), className)} {...rest} /> +}) +TableHead.displayName = "TableHead" + +/* ------------------------------------------------------------------------------------------------- + * TableCell + * -----------------------------------------------------------------------------------------------*/ + +export type TableCellProps = React.ComponentPropsWithoutRef<"td"> + +export const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>((props, ref) => { + const { className, ...rest } = props + + return <td ref={ref} className={cn(TableAnatomy.tableCell(), className)} {...rest} /> +}) +TableCell.displayName = "TableCell" + +/* ------------------------------------------------------------------------------------------------- + * TableCaption + * -----------------------------------------------------------------------------------------------*/ + +export type TableCaptionProps = React.ComponentPropsWithoutRef<"caption"> + +export const TableCaption = React.forwardRef<HTMLTableCaptionElement, TableCaptionProps>((props, ref) => { + const { className, ...rest } = props + + return <caption ref={ref} className={cn(TableAnatomy.tableCaption(), className)} {...rest} /> +}) +TableCaption.displayName = "TableCaption" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/tabs/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/tabs/index.tsx new file mode 100644 index 0000000..fc2dd6f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/tabs/index.tsx @@ -0,0 +1,2 @@ +export * from "./tabs" +export * from "./static-tabs" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/tabs/static-tabs.tsx b/seanime-2.9.10/seanime-web/src/components/ui/tabs/static-tabs.tsx new file mode 100644 index 0000000..528a7a8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/tabs/static-tabs.tsx @@ -0,0 +1,116 @@ +import { SeaLink } from "@/components/shared/sea-link" +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const StaticTabsAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-StaticTabs__root", + "flex w-full overflow-hidden overflow-x-auto", + ]), + trigger: cva([ + "UI-StaticTabs__trigger", + "group/staticTabs__trigger inline-flex flex-none shrink-0 basis-auto items-center font-medium text-sm transition outline-none min-w-0 justify-center", + "text-[--muted] hover:text-[--foreground]", + "h-10 px-4 rounded-full", + "data-[current=true]:bg-[--subtle] data-[current=true]:font-semibold data-[current=true]:text-[--foreground]", + "focus-visible:bg-[--subtle]", + ]), + icon: cva([ + "UI-StaticTabs__icon", + "-ml-0.5 mr-2 h-4 w-4", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * StaticTabs + * -----------------------------------------------------------------------------------------------*/ + +export type StaticTabsItem = { + name: string, + href?: string | null | undefined, + iconType?: React.ElementType, + onClick?: () => void, + isCurrent: boolean + addon?: React.ReactNode, +} + +export type StaticTabsProps = React.ComponentPropsWithRef<"nav"> & + ComponentAnatomy<typeof StaticTabsAnatomy> & { + items: StaticTabsItem[] +} + +export const StaticTabs = React.forwardRef<HTMLElement, StaticTabsProps>((props, ref) => { + + const { + children, + className, + triggerClass, + iconClass, + items, + ...rest + } = props + + return ( + <nav + ref={ref} + className={cn(StaticTabsAnatomy.root(), className)} + role="navigation" + {...rest} + > + {items.map((tab) => !!tab.href ? ( + <SeaLink + key={tab.name} + href={tab.href ?? "#"} + className={cn( + StaticTabsAnatomy.trigger(), + triggerClass, + )} + aria-current={tab.isCurrent ? "page" : undefined} + data-current={tab.isCurrent} + > + {tab.iconType && <tab.iconType + className={cn( + StaticTabsAnatomy.icon(), + iconClass, + )} + aria-hidden="true" + data-current={tab.isCurrent} + />} + <span>{tab.name}</span> + {tab.addon} + </SeaLink> + ) : ( + <div + key={tab.name} + className={cn( + StaticTabsAnatomy.trigger(), + "cursor-pointer", + triggerClass, + )} + aria-current={tab.isCurrent ? "page" : undefined} + data-current={tab.isCurrent} + onClick={tab.onClick} + > + {tab.iconType && <tab.iconType + className={cn( + StaticTabsAnatomy.icon(), + iconClass, + )} + aria-hidden="true" + data-current={tab.isCurrent} + />} + <span>{tab.name}</span> + {tab.addon} + </div> + ))} + </nav> + ) + +}) + +StaticTabs.displayName = "StaticTabs" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/tabs/tabs.tsx b/seanime-2.9.10/seanime-web/src/components/ui/tabs/tabs.tsx new file mode 100644 index 0000000..f8388d5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/tabs/tabs.tsx @@ -0,0 +1,137 @@ +"use client" + +import * as TabsPrimitive from "@radix-ui/react-tabs" +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const TabsAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Tabs__root", + ]), + list: cva([ + "UI-Tabs__list", + "inline-flex h-12 items-center justify-center w-full", + ]), + trigger: cva([ + "UI-Tabs__trigger appearance-none shadow-none", + "inline-flex h-full items-center justify-center whitespace-nowrap px-3 py-1.5 text-sm text-[--muted] font-medium ring-offset-[--background]", + "transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "disabled:pointer-events-none disabled:opacity-50", + "border-transparent border-b-2 -mb-px", + "data-[state=active]:border-[--brand] data-[state=active]:text-[--foreground]", + ]), + content: cva([ + "UI-Tabs__content", + "ring-offset-[--background]", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--ring] focus-visible:ring-offset-2", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Tabs + * -----------------------------------------------------------------------------------------------*/ + +const __TabsAnatomyContext = React.createContext<ComponentAnatomy<typeof TabsAnatomy>>({}) + +export type TabsProps = React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & ComponentAnatomy<typeof TabsAnatomy> + +export const Tabs = React.forwardRef<HTMLDivElement, TabsProps>((props, ref) => { + const { + className, + listClass, + triggerClass, + contentClass, + ...rest + } = props + + return ( + <__TabsAnatomyContext.Provider + value={{ + listClass, + triggerClass, + contentClass, + }} + > + <TabsPrimitive.Root + ref={ref} + className={cn(TabsAnatomy.root(), className)} + {...rest} + /> + </__TabsAnatomyContext.Provider> + ) +}) + +Tabs.displayName = "Tabs" + +/* ------------------------------------------------------------------------------------------------- + * TabsList + * -----------------------------------------------------------------------------------------------*/ + +export type TabsListProps = React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> + +export const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>((props, ref) => { + const { className, ...rest } = props + + const { listClass } = React.useContext(__TabsAnatomyContext) + + return ( + <TabsPrimitive.List + ref={ref} + className={cn(TabsAnatomy.list(), listClass, className)} + {...rest} + /> + ) +}) + +TabsList.displayName = "TabsList" + + +/* ------------------------------------------------------------------------------------------------- + * TabsTrigger + * -----------------------------------------------------------------------------------------------*/ + +export type TabsTriggerProps = React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> + +export const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>((props, ref) => { + const { className, ...rest } = props + + const { triggerClass } = React.useContext(__TabsAnatomyContext) + + return ( + <TabsPrimitive.Trigger + ref={ref} + className={cn(TabsAnatomy.trigger(), triggerClass, className)} + {...rest} + /> + ) +}) + +TabsTrigger.displayName = "TabsTrigger" + +/* ------------------------------------------------------------------------------------------------- + * TabsContent + * -----------------------------------------------------------------------------------------------*/ + +export type TabsContentProps = React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> + +export const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>((props, ref) => { + const { className, ...rest } = props + + const { contentClass } = React.useContext(__TabsAnatomyContext) + + return ( + <TabsPrimitive.Content + ref={ref} + className={cn(TabsAnatomy.content(), contentClass, className)} + {...rest} + /> + ) +}) + +TabsContent.displayName = "TabsContent" + diff --git a/seanime-2.9.10/seanime-web/src/components/ui/text-input/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/text-input/index.tsx new file mode 100644 index 0000000..236118c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/text-input/index.tsx @@ -0,0 +1 @@ +export * from "./text-input" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/text-input/text-input.tsx b/seanime-2.9.10/seanime-web/src/components/ui/text-input/text-input.tsx new file mode 100644 index 0000000..0485698 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/text-input/text-input.tsx @@ -0,0 +1,97 @@ +import { cn } from "../core/styling" +import * as React from "react" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { extractInputPartProps, InputAddon, InputAnatomy, InputContainer, InputIcon, InputStyling } from "../input" + +/* ------------------------------------------------------------------------------------------------- + * TextInput + * -----------------------------------------------------------------------------------------------*/ + +export type TextInputProps = Omit<React.ComponentPropsWithRef<"input">, "size"> & + InputStyling & + BasicFieldOptions & { + /** + * Callback invoked when the value changes. Returns the string value. + */ + onValueChange?: (value: string) => void +} + +export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>((props, ref) => { + + const [props1, basicFieldProps] = extractBasicFieldProps<TextInputProps>(props, React.useId()) + + const [{ + size, + intent, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + className, + onValueChange, + onChange, + ...rest + }, { + inputContainerProps, + leftAddonProps, + leftIconProps, + rightAddonProps, + rightIconProps, + }] = extractInputPartProps<TextInputProps>({ + ...props1, + size: props1.size ?? "md", + intent: props1.intent ?? "basic", + leftAddon: props1.leftAddon, + leftIcon: props1.leftIcon, + rightAddon: props1.rightAddon, + rightIcon: props1.rightIcon, + }) + + const handleOnChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => { + onValueChange?.(e.target.value) + onChange?.(e) + }, []) + + return ( + <BasicField{...basicFieldProps}> + <InputContainer {...inputContainerProps}> + <InputAddon {...leftAddonProps} /> + <InputIcon {...leftIconProps} /> + + <input + id={basicFieldProps.id} + name={basicFieldProps.name} + className={cn( + "form-input", + InputAnatomy.root({ + size, + intent, + hasError: !!basicFieldProps.error, + isDisabled: !!basicFieldProps.disabled, + isReadonly: !!basicFieldProps.readonly, + hasRightAddon: !!rightAddon, + hasRightIcon: !!rightIcon, + hasLeftAddon: !!leftAddon, + hasLeftIcon: !!leftIcon, + }), + className, + )} + disabled={basicFieldProps.disabled || basicFieldProps.readonly} + data-disabled={basicFieldProps.disabled} + data-readonly={basicFieldProps.readonly} + aria-readonly={basicFieldProps.readonly} + required={basicFieldProps.required} + onChange={handleOnChange} + {...rest} + ref={ref} + /> + + <InputAddon {...rightAddonProps} /> + <InputIcon {...rightIconProps} /> + </InputContainer> + </BasicField> + ) + +}) + +TextInput.displayName = "TextInput" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/textarea/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/textarea/index.tsx new file mode 100644 index 0000000..331b8c8 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/textarea/index.tsx @@ -0,0 +1 @@ +export * from "./textarea" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/textarea/textarea.tsx b/seanime-2.9.10/seanime-web/src/components/ui/textarea/textarea.tsx new file mode 100644 index 0000000..7233d00 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/textarea/textarea.tsx @@ -0,0 +1,118 @@ +import { cva } from "class-variance-authority" +import * as React from "react" +import { BasicField, BasicFieldOptions, extractBasicFieldProps } from "../basic-field" +import { cn, defineStyleAnatomy } from "../core/styling" +import { extractInputPartProps, InputAddon, InputAnatomy, InputContainer, InputIcon, InputStyling } from "../input" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const TextareaAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Textarea__root", + "w-full p-2", + ], { + variants: { + size: { + sm: "h-20", + md: "h-32", + lg: "h-64", + }, + }, + defaultVariants: { + size: "md", + }, + }), +}) + +/* ------------------------------------------------------------------------------------------------- + * Textarea + * -----------------------------------------------------------------------------------------------*/ + +export type TextareaProps = Omit<React.ComponentPropsWithRef<"textarea">, "size"> & + InputStyling & + BasicFieldOptions & { + /** + * Callback invoked when the value changes. Returns the string value. + */ + onValueChange?: (value: string) => void +} + +export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, ref) => { + + const [props1, basicFieldProps] = extractBasicFieldProps<TextareaProps>(props, React.useId()) + + const [{ + size, + intent, + leftAddon, + leftIcon, + rightAddon, + rightIcon, + className, + onValueChange, + onChange, + ...rest + }, { + inputContainerProps, + leftAddonProps, + leftIconProps, + rightAddonProps, + rightIconProps, + }] = extractInputPartProps<TextareaProps>({ + ...props1, + size: props1.size ?? "md", + intent: props1.intent ?? "basic", + leftAddon: props1.leftAddon, + leftIcon: props1.leftIcon, + rightAddon: props1.rightAddon, + rightIcon: props1.rightIcon, + }) + + const handleOnChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { + onValueChange?.(e.target.value) + onChange?.(e) + }, []) + + return ( + <BasicField {...basicFieldProps}> + <InputContainer {...inputContainerProps}> + <InputAddon {...leftAddonProps} /> + <InputIcon {...leftIconProps} /> + + <textarea + id={basicFieldProps.id} + name={basicFieldProps.name} + className={cn( + "form-textarea", + InputAnatomy.root({ + size, + intent, + hasError: !!basicFieldProps.error, + isDisabled: !!basicFieldProps.disabled, + isReadonly: !!basicFieldProps.readonly, + hasRightAddon: !!rightAddon, + hasRightIcon: !!rightIcon, + hasLeftAddon: !!leftAddon, + hasLeftIcon: !!leftIcon, + }), + TextareaAnatomy.root({ size }), + className, + )} + disabled={basicFieldProps.disabled || basicFieldProps.readonly} + data-disabled={basicFieldProps.disabled} + onChange={handleOnChange} + {...rest} + ref={ref} + /> + + <InputAddon {...rightAddonProps} /> + <InputIcon {...rightIconProps} /> + </InputContainer> + </BasicField> + ) + +}) + +Textarea.displayName = "Textarea" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/timeline/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/timeline/index.tsx new file mode 100644 index 0000000..98df568 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/timeline/index.tsx @@ -0,0 +1 @@ +export * from "./timeline" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/timeline/timeline.tsx b/seanime-2.9.10/seanime-web/src/components/ui/timeline/timeline.tsx new file mode 100644 index 0000000..ee11827 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/timeline/timeline.tsx @@ -0,0 +1,146 @@ +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const TimelineAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Timeline__root", + ]), + item: cva([ + "UI-Timeline__item", + "flex text-md", + ]), + leftSection: cva([ + "UI-Timeline__leftSection", + "flex flex-col items-center mr-4", + ]), + icon: cva([ + "UI-Timeline__icon", + "flex items-center justify-center w-8 h-8 border rounded-full flex-none", + ]), + line: cva([ + "UI-Timeline__line", + "w-px h-full bg-[--border]", + ]), + detailsSection: cva([ + "UI-Timeline__detailsSection", + "pb-8", + ]), + title: cva([ + "UI-Timeline__title", + "text-md font-semibold", + ]), + description: cva([ + "UI-Timeline__description", + "text-[--muted] text-sm", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Timeline + * -----------------------------------------------------------------------------------------------*/ + +export type TimelineItem = { + title: React.ReactNode + description?: React.ReactNode + content?: React.ReactNode + icon: React.ReactNode + unstyledTitle?: boolean + unstyledDescription?: boolean + unstyledIcon?: boolean + titleClass?: string + descriptionClass?: string + iconClass?: string + lineClass?: string +} + +export type TimelineProps = React.ComponentPropsWithoutRef<"div"> & ComponentAnatomy<typeof TimelineAnatomy> & { + children?: React.ReactNode + items: TimelineItem[] +} + +export const Timeline = React.forwardRef<HTMLDivElement, TimelineProps>((props, ref) => { + + const { + children, + itemClass, + leftSectionClass, + descriptionClass, + detailsSectionClass, + titleClass, + lineClass, + iconClass, + className, + items, + ...rest + } = props + + return ( + <div + ref={ref} + className={cn(TimelineAnatomy.root(), className)} + {...rest} + > + {items.map((item, idx) => ( + <div + key={`${idx}`} + className={cn(TimelineAnatomy.item(), itemClass)} + > + {/*Left section*/} + <div className={cn(TimelineAnatomy.leftSection(), leftSectionClass)}> + <div + className={cn( + item.unstyledIcon ? + null : + TimelineAnatomy.icon(), + iconClass, + item.iconClass, + )} + > + {item.icon} + </div> + {(idx < items.length - 1) && <div className={cn(TimelineAnatomy.line(), lineClass, item.lineClass)} />} + </div> + + {/*Details section*/} + <div className={cn(TimelineAnatomy.detailsSection(), detailsSectionClass)}> + + <div + className={cn( + item.unstyledTitle ? + null : + TimelineAnatomy.title(), + titleClass, + item.titleClass, + )} + > + {item.title} + </div> + + {item.description && <div + className={cn( + item.unstyledDescription ? + null : + TimelineAnatomy.description(), + descriptionClass, + item.descriptionClass, + )} + > + {item.description} + </div>} + + {item.content} + + </div> + </div> + ))} + </div> + ) + +}) + +Timeline.displayName = "Timeline" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/toaster/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/toaster/index.tsx new file mode 100644 index 0000000..b338b7c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/toaster/index.tsx @@ -0,0 +1 @@ +export * from "./toaster" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/toaster/toaster.tsx b/seanime-2.9.10/seanime-web/src/components/ui/toaster/toaster.tsx new file mode 100644 index 0000000..48782f1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/toaster/toaster.tsx @@ -0,0 +1,106 @@ +"use client" + +import { cva } from "class-variance-authority" +import * as React from "react" +import { Toaster as Sonner } from "sonner" +import { cn, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const ToasterAnatomy = defineStyleAnatomy({ + toaster: cva(["group toaster z-[150]"]), + toast: cva([ + "group/toast", + "select-none cursor-default", + "group-[.toaster]:py-4 group-[.toaster]:px-6 group-[.toaster]:gap-3", + "group-[.toaster]:text-sm group-[.toaster]:font-medium", + "group-[.toaster]:rounded-xl group-[.toaster]:border group-[.toaster]:backdrop-blur-sm", + // "group-[.toaster]:ring-1 group-[.toaster]:ring-inset", + "group-[.toaster]:transition-all group-[.toaster]:duration-200", + // Default/Base style + // "group-[.toaster]:bg-gradient-to-br group-[.toaster]:from-[--paper] group-[.toaster]:to-[--paper]/80", + "group-[.toaster]:text-[--foreground] group-[.toaster]:border-[--border]", + "group-[.toaster]:ring-[--border]", + // Success + "group-[.toaster]:data-[type=success]:bg-gradient-to-br", + "group-[.toaster]:data-[type=success]:from-emerald-950/95 group-[.toaster]:data-[type=success]:to-emerald-900/60", + "group-[.toaster]:data-[type=success]:text-emerald-100", + "group-[.toaster]:data-[type=success]:border-emerald-800/50", + "group-[.toaster]:data-[type=success]:ring-emerald-700/40", + // Warning + "group-[.toaster]:data-[type=warning]:bg-gradient-to-br", + "group-[.toaster]:data-[type=warning]:from-amber-950/95 group-[.toaster]:data-[type=warning]:to-amber-900/60", + "group-[.toaster]:data-[type=warning]:text-amber-100", + "group-[.toaster]:data-[type=warning]:border-amber-800/50", + "group-[.toaster]:data-[type=warning]:ring-amber-700/40", + // Error + "group-[.toaster]:data-[type=error]:bg-gradient-to-br", + "group-[.toaster]:data-[type=error]:from-red-950/95 group-[.toaster]:data-[type=error]:to-red-900/60", + "group-[.toaster]:data-[type=error]:text-red-100", + "group-[.toaster]:data-[type=error]:border-red-800/50", + "group-[.toaster]:data-[type=error]:ring-red-700/40", + // Info + "group-[.toaster]:data-[type=info]:bg-gradient-to-br", + "group-[.toaster]:data-[type=info]:from-blue-950/95 group-[.toaster]:data-[type=info]:to-blue-900/60", + "group-[.toaster]:data-[type=info]:text-blue-100", + "group-[.toaster]:data-[type=info]:border-blue-800/50", + "group-[.toaster]:data-[type=info]:ring-blue-700/40", + ]), + description: cva([ + "group/toast:text-xs group/toast:font-normal group/toast:mt-1", + "group/toast:opacity-80", + "group-data-[type=success]/toast:text-emerald-300", + "group-data-[type=warning]/toast:text-amber-300", + "group-data-[type=error]/toast:text-red-300", + "group-data-[type=info]/toast:text-blue-300", + "cursor-default", + ]), + actionButton: cva([ + "group/toast:bg-[--subtle] group/toast:text-[--foreground]", + "group/toast:rounded-lg group/toast:px-3 group/toast:py-1.5", + "group/toast:text-xs group/toast:font-medium", + "group/toast:transition-colors group/toast:hover:bg-[--subtle-hover]", + "group/toast:ring-1 group/toast:ring-[--border]/20", + ]), + cancelButton: cva([ + "group/toast:bg-transparent group/toast:text-[--muted]", + "group/toast:rounded-lg group/toast:px-3 group/toast:py-1.5", + "group/toast:text-xs group/toast:font-medium", + "group/toast:transition-colors group/toast:hover:bg-[--subtle]", + "group/toast:ring-1 group/toast:ring-transparent group/toast:hover:ring-[--border]/20", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Toaster + * -----------------------------------------------------------------------------------------------*/ + +export type ToasterProps = React.ComponentProps<typeof Sonner> + +export const Toaster = ({ position = "top-center", ...props }: ToasterProps) => { + + const allProps = React.useMemo(() => ({ + position, + visibleToasts: 4, + className: cn(ToasterAnatomy.toaster()), + toastOptions: { + classNames: { + toast: cn(ToasterAnatomy.toast()), + description: cn(ToasterAnatomy.description()), + actionButton: cn(ToasterAnatomy.actionButton()), + cancelButton: cn(ToasterAnatomy.cancelButton()), + }, + }, + ...props, + } as ToasterProps), []) + + return ( + <> + <Sonner theme="dark" {...allProps} /> + </> + ) +} + +Toaster.displayName = "Toaster" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/tooltip/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/tooltip/index.tsx new file mode 100644 index 0000000..e12712a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/tooltip/index.tsx @@ -0,0 +1 @@ +export * from "./tooltip" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/tooltip/tooltip.tsx b/seanime-2.9.10/seanime-web/src/components/ui/tooltip/tooltip.tsx new file mode 100644 index 0000000..3b87be5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/tooltip/tooltip.tsx @@ -0,0 +1,92 @@ +"use client" + +import * as TooltipPrimitive from "@radix-ui/react-tooltip" +import { cva } from "class-variance-authority" +import * as React from "react" +import { cn, defineStyleAnatomy } from "../core/styling" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const TooltipAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-Tooltip__root", + "z-50 overflow-hidden rounded-[--radius] px-3 py-1.5 text-sm shadow-md animate-in fade-in-50", + "bg-gray-800 text-white", + "data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1", + "data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * Tooltip + * -----------------------------------------------------------------------------------------------*/ + +export type TooltipProps = React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & + React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Root> & { + /** + * The trigger that toggles the tooltip. + * - Passed props: `data-state` ("closed" | "delayed-open" | "instant-open") + */ + trigger: React.ReactElement + /** + * Portal container for custom mounting (useful for fullscreen mode) + */ + portalContainer?: HTMLElement +} + +export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>((props, ref) => { + + const { + children, + className, + trigger, + // Root + delayDuration = 50, + disableHoverableContent, + defaultOpen, + open, + onOpenChange, + // Portal + portalContainer, + ...rest + } = props + + return ( + <TooltipProvider> + <TooltipPrimitive.Root + delayDuration={delayDuration} + disableHoverableContent={disableHoverableContent} + defaultOpen={defaultOpen} + open={open} + onOpenChange={onOpenChange} + > + <TooltipPrimitive.Trigger asChild> + {trigger} + </TooltipPrimitive.Trigger> + <TooltipPrimitive.Portal container={portalContainer}> + <TooltipPrimitive.Content + ref={ref} + className={cn(TooltipAnatomy.root(), className)} + {...rest} + > + {children} + </TooltipPrimitive.Content> + </TooltipPrimitive.Portal> + </TooltipPrimitive.Root> + </TooltipProvider> + ) + +}) + +Tooltip.displayName = "Tooltip" + +/* ------------------------------------------------------------------------------------------------- + * TooltipProvider + * -----------------------------------------------------------------------------------------------*/ + +/** + * Wraps your app to provide global functionality to your tooltips. + */ +export const TooltipProvider = TooltipPrimitive.Provider diff --git a/seanime-2.9.10/seanime-web/src/components/ui/vertical-menu/index.tsx b/seanime-2.9.10/seanime-web/src/components/ui/vertical-menu/index.tsx new file mode 100644 index 0000000..71d529b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/vertical-menu/index.tsx @@ -0,0 +1 @@ +export * from "./vertical-menu" diff --git a/seanime-2.9.10/seanime-web/src/components/ui/vertical-menu/vertical-menu.tsx b/seanime-2.9.10/seanime-web/src/components/ui/vertical-menu/vertical-menu.tsx new file mode 100644 index 0000000..e20049c --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/ui/vertical-menu/vertical-menu.tsx @@ -0,0 +1,310 @@ +"use client" + +import { SeaLink } from "@/components/shared/sea-link" +import { cva, VariantProps } from "class-variance-authority" +import * as React from "react" +import { useContext } from "react" +import { cn, ComponentAnatomy, defineStyleAnatomy } from "../core/styling" +import { Disclosure, DisclosureContent, DisclosureItem, DisclosureTrigger } from "../disclosure" +import { Tooltip, TooltipProps } from "../tooltip" + +/* ------------------------------------------------------------------------------------------------- + * Anatomy + * -----------------------------------------------------------------------------------------------*/ + +export const VerticalMenuAnatomy = defineStyleAnatomy({ + root: cva([ + "UI-VerticalMenu__root", + "flex flex-col gap-1", + ]), + item: cva([ + "UI-VerticalMenu__item", + "group/verticalMenu_item relative flex flex-none truncate items-center w-full font-medium rounded-[--radius] transition cursor-pointer", + "hover:bg-[--subtle] hover:text-[--foreground]", + "focus-visible:bg-[--subtle] outline-none text-[--muted]", + "data-[current=true]:bg-[--subtle] data-[current=true]:text-[--foreground]", + ], { + variants: { + collapsed: { + true: "justify-center", + false: null, + }, + }, + defaultVariants: { + collapsed: false, + }, + }), + itemContent: cva([ + "UI-VerticalMenu__itemContent", + "w-full flex items-center relative", + ], { + variants: { + size: { + sm: "px-3 h-8 text-sm", + md: "px-3 h-10 text-sm", + lg: "px-3 h-12 text-base", + }, + collapsed: { + true: "justify-center", + false: null, + }, + }, + defaultVariants: { + size: "md", + collapsed: false, + }, + }), + parentItem: cva([ + "UI-VerticalMenu__parentItem", + "group/verticalMenu_parentItem", + "cursor-pointer w-full", + ]), + itemChevron: cva([ + "UI-VerticalMenu__itemChevron", + "size-4 absolute transition-transform group-data-[state=open]/verticalMenu_parentItem:rotate-90", + ], { + variants: { + size: { + sm: "right-3", + md: "right-3", + lg: "right-3", + }, + collapsed: { + true: "top-1 left-1 size-3", + false: null, + }, + }, + defaultVariants: { + size: "md", + collapsed: false, + }, + }), + itemIcon: cva([ + "UI-VerticalMenu__itemIcon", + "flex-shrink-0 mr-3", + "text-[--muted] text-xl", + "group-hover/verticalMenu_item:text-[--foreground]", // Item Hover + "group-data-[current=true]/verticalMenu_item:text-[--foreground]", // Item Current + ], { + variants: { + size: { + sm: "size-4", + md: "size-5", + lg: "size-6", + }, + collapsed: { + true: "mr-0", + false: null, + }, + }, + defaultVariants: { + size: "md", + }, + }), + subContent: cva([ + "UI-VerticalMenu__subContent", + "border-b py-1", + ]), +}) + +/* ------------------------------------------------------------------------------------------------- + * VerticalMenu + * -----------------------------------------------------------------------------------------------*/ + +const __VerticalMenuContext = React.createContext<Pick<VerticalMenuProps, "onAnyItemClick" | "onLinkItemClick"> & { collapsed?: boolean }>({}) + +export type VerticalMenuItem = { + name: string + href?: string | null | undefined + iconType?: React.ElementType + className?: string + iconClass?: string + isCurrent?: boolean + onClick?: React.MouseEventHandler<HTMLElement> + addon?: React.ReactNode + subContent?: React.ReactNode + subContentOpen?: boolean + onSubContentOpenChange?: (open: boolean) => void +} + +export type VerticalMenuProps = React.ComponentPropsWithRef<"div"> & + ComponentAnatomy<typeof VerticalMenuAnatomy> & + VariantProps<typeof VerticalMenuAnatomy.itemContent> & { + /** + * The items to render. + */ + items: VerticalMenuItem[] + /** + * Props passed to each item tooltip that is shown when the menu is collapsed. + */ + itemTooltipProps?: Omit<TooltipProps, "trigger"> + /** + * Callback fired when any item is clicked. + */ + onAnyItemClick?: React.MouseEventHandler<HTMLElement> + /** + * Callback fired when a link item is clicked. + */ + onLinkItemClick?: React.MouseEventHandler<HTMLElement> +} + +export const VerticalMenu = React.forwardRef<HTMLDivElement, VerticalMenuProps>((props, ref) => { + + const { + children, + size = "md", + collapsed: _collapsed1, + onAnyItemClick, + onLinkItemClick, + /**/ + itemClass, + itemIconClass, + parentItemClass, + subContentClass, + itemChevronClass, + itemContentClass, + itemTooltipProps, + className, + items, + ...rest + } = props + + const { + onLinkItemClick: _onLinkItemClick, + onAnyItemClick: _onAnyItemClick, + collapsed: _collapsed2, + } = useContext(__VerticalMenuContext) + + const collapsed = _collapsed1 ?? _collapsed2 ?? false + + const itemProps = (item: VerticalMenuItem) => ({ + className: cn( + VerticalMenuAnatomy.item({ collapsed }), + itemClass, + ), + "data-current": item.isCurrent, + onClick: (e: React.MouseEvent<HTMLElement>) => { + if (item.href) { + onLinkItemClick?.(e) + _onLinkItemClick?.(e) + } + onAnyItemClick?.(e) + _onAnyItemClick?.(e) + item.onClick?.(e) + }, + }) + + const ItemContentWrapper = React.useCallback((props: { children: React.ReactElement, name: string }) => { + return !collapsed ? props.children : ( + <Tooltip trigger={props.children} side="right" {...itemTooltipProps}> + {props.name} + </Tooltip> + ) + }, [collapsed, itemTooltipProps]) + + const ItemContent = React.useCallback((item: VerticalMenuItem) => ( + <ItemContentWrapper name={item.name}> + <div + data-vertical-menu-item={item.name} + className={cn( + VerticalMenuAnatomy.itemContent({ size, collapsed }), + itemContentClass, + item.className, + )} + > + {item.iconType && <item.iconType + className={cn( + VerticalMenuAnatomy.itemIcon({ size, collapsed }), + itemIconClass, + item.iconClass, + )} + aria-hidden="true" + data-current={item.isCurrent} + data-collapsed={collapsed} + />} + {!collapsed && <span>{item.name}</span>} + {item.addon} + </div> + </ItemContentWrapper> + ), [collapsed, size, itemContentClass, itemIconClass]) + + return ( + <nav + ref={ref} + className={cn(VerticalMenuAnatomy.root(), className)} + role="navigation" + {...rest} + > + <__VerticalMenuContext.Provider + value={{ + onAnyItemClick, + onLinkItemClick, + collapsed: _collapsed1 ?? false, + }} + > + {items.map((item, idx) => { + return ( + <React.Fragment key={item.name + idx}> + {!item.subContent ? + item.href ? ( + <SeaLink href={item.href} {...itemProps(item)} data-vertical-menu-item-link={item.name}> + <ItemContent {...item} /> + </SeaLink> + ) : ( + <button {...itemProps(item)} data-vertical-menu-item-button={item.name}> + <ItemContent {...item} /> + </button> + ) : ( + <Disclosure + type="single" + collapsible + defaultValue={item.subContentOpen ? item.name : undefined} + onValueChange={v => item.onSubContentOpenChange?.(v.length > 0)} + > + <DisclosureItem value={item.name}> + <DisclosureTrigger> + <button + className={cn( + VerticalMenuAnatomy.item({ collapsed }), + itemClass, + VerticalMenuAnatomy.parentItem(), + parentItemClass, + )} + aria-current={item.isCurrent ? "page" : undefined} + data-current={item.isCurrent} + onClick={item.onClick} + > + <ItemContent {...item} /> + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn(VerticalMenuAnatomy.itemChevron({ size, collapsed }), itemChevronClass)} + > + <polyline points="9 18 15 12 9 6"></polyline> + </svg> + </button> + </DisclosureTrigger> + + <DisclosureContent className={cn(VerticalMenuAnatomy.subContent(), subContentClass)}> + {item.subContent && item.subContent} + </DisclosureContent> + </DisclosureItem> + </Disclosure> + )} + </React.Fragment> + ) + })} + </__VerticalMenuContext.Provider> + </nav> + ) + +}) + +VerticalMenu.displayName = "VerticalMenu" diff --git a/seanime-2.9.10/seanime-web/src/components/vaul/index.tsx b/seanime-2.9.10/seanime-web/src/components/vaul/index.tsx new file mode 100644 index 0000000..fb723e2 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/components/vaul/index.tsx @@ -0,0 +1,129 @@ +"use client" + +import { cn } from "@/components/ui/core/styling" +import * as React from "react" +import { Drawer as VaulPrimitive } from "vaul" + +const Vaul = ({ + shouldScaleBackground = false, + ...props +}: React.ComponentProps<typeof VaulPrimitive.Root>) => ( + <VaulPrimitive.Root + shouldScaleBackground={shouldScaleBackground} + {...props} + /> +) +Vaul.displayName = "Vaul" + +const VaulTrigger = VaulPrimitive.Trigger + +const VaulPortal = VaulPrimitive.Portal + +const VaulClose = VaulPrimitive.Close + +const VaulOverlay = React.forwardRef< + React.ElementRef<typeof VaulPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof VaulPrimitive.Overlay> +>(({ className, ...props }, ref) => { + return ( + <VaulPrimitive.Overlay + ref={ref} + className={cn("fixed inset-0 z-50 bg-black/80", className)} + {...props} + /> + ) +}) +VaulOverlay.displayName = VaulPrimitive.Overlay.displayName + +const VaulContent = React.forwardRef< + React.ElementRef<typeof VaulPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof VaulPrimitive.Content> +>(({ className, children, ...props }, ref) => { + return ( + <VaulPortal> + <VaulOverlay /> + <VaulPrimitive.Content + ref={ref} + className={cn( + "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-[var(--background)]", + className, + )} + {...props} + > + <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-[--subtle]" /> + {children} + </VaulPrimitive.Content> + </VaulPortal> + ) +}) +VaulContent.displayName = "VaulContent" + +const VaulHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => { + return ( + <div + className={cn("grid gap-1.5 text-center sm:text-left", className)} + {...props} + /> + ) +} +VaulHeader.displayName = "VaulHeader" + +const VaulFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => { + return ( + <div + className={cn("mt-auto flex flex-col gap-2 p-4", className)} + {...props} + /> + ) +} +VaulFooter.displayName = "VaulFooter" + +const VaulTitle = React.forwardRef< + React.ElementRef<typeof VaulPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof VaulPrimitive.Title> +>(({ className, ...props }, ref) => { + return ( + <VaulPrimitive.Title + ref={ref} + className={cn( + "text-2xl font-semibold leading-none tracking-tight", + className, + )} + {...props} + /> + ) +}) +VaulTitle.displayName = VaulPrimitive.Title.displayName + +const VaulDescription = React.forwardRef< + React.ElementRef<typeof VaulPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof VaulPrimitive.Description> +>(({ className, ...props }, ref) => { + return ( + <VaulPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> + ) +}) +VaulDescription.displayName = VaulPrimitive.Description.displayName + +export { + Vaul, + VaulPortal, + VaulOverlay, + VaulTrigger, + VaulClose, + VaulContent, + VaulHeader, + VaulFooter, + VaulTitle, + VaulDescription, +} diff --git a/seanime-2.9.10/seanime-web/src/hooks/use-debounce.ts b/seanime-2.9.10/seanime-web/src/hooks/use-debounce.ts new file mode 100644 index 0000000..932fa5f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/hooks/use-debounce.ts @@ -0,0 +1,22 @@ +import React, { useEffect, useState } from "react" + +export function useDebounce<T>(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = useState<T>(value) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} + +export function useDebounceWithSet<T>(value: T, delay?: number): [T, T, React.Dispatch<React.SetStateAction<T>>] { + const [actualValue, setActualValue] = useState<T>(value) + const debouncedValue = useDebounce(actualValue, delay) + + return [actualValue, debouncedValue, setActualValue] +} \ No newline at end of file diff --git a/seanime-2.9.10/seanime-web/src/hooks/use-disclosure.ts b/seanime-2.9.10/seanime-web/src/hooks/use-disclosure.ts new file mode 100644 index 0000000..cae051f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/hooks/use-disclosure.ts @@ -0,0 +1,58 @@ +import { useState } from "react" + +export function useDisclosure( + initialState: boolean, + callbacks?: { onOpen?(): void; onClose?(): void }, +) { + const [opened, setOpened] = useState(initialState) + + const open = () => { + if (!opened) { + setOpened(true) + callbacks?.onOpen?.() + } + } + + const close = () => { + if (opened) { + setOpened(false) + callbacks?.onClose?.() + } + } + + const toggle = () => { + opened ? close() : open() + } + + return { isOpen: opened, open, close, toggle } as const +} + +export type UseDisclosureReturn = ReturnType<typeof useDisclosure> + + +export function useBoolean( + initialState: boolean, + callbacks?: { onOpen?(): void; onClose?(): void }, +) { + const [opened, setOpened] = useState(initialState) + + const open = () => { + if (!opened) { + setOpened(true) + callbacks?.onOpen?.() + } + } + + const close = () => { + if (opened) { + setOpened(false) + callbacks?.onClose?.() + } + } + + const toggle = () => { + opened ? close() : open() + } + + return { active: opened, on: open, off: close, toggle, set: setOpened } as const +} diff --git a/seanime-2.9.10/seanime-web/src/hooks/use-draggable-scroll.ts b/seanime-2.9.10/seanime-web/src/hooks/use-draggable-scroll.ts new file mode 100644 index 0000000..98d7d7e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/hooks/use-draggable-scroll.ts @@ -0,0 +1,360 @@ +/* ------------------------------------------------------------------------------------------------- + * @author rfmiotto + * @link https://www.npmjs.com/package/react-use-draggable-scroll/v/0.4.7 + * -----------------------------------------------------------------------------------------------*/ +import React, { MutableRefObject, useEffect, useRef } from "react" +import { useIsomorphicLayoutEffect } from "react-use" + +type OptionsType = { + decayRate?: number + safeDisplacement?: number + applyRubberBandEffect?: boolean + activeMouseButton?: "Left" | "Middle" | "Right" + isMounted?: boolean +} + +type ReturnType = { + events: { + onMouseDown: (e: React.MouseEvent<HTMLElement>) => void + } +} + +export function useDraggableScroll( + ref: MutableRefObject<HTMLElement>, + { + decayRate = 0.95, + safeDisplacement = 10, + applyRubberBandEffect = false, + activeMouseButton = "Left", + isMounted = true, + }: OptionsType = {}, +): ReturnType { + const internalState = useRef({ + isMouseDown: false, + isDraggingX: false, + isDraggingY: false, + initialMouseX: 0, + initialMouseY: 0, + lastMouseX: 0, + lastMouseY: 0, + scrollSpeedX: 0, + scrollSpeedY: 0, + lastScrollX: 0, + lastScrollY: 0, + }) + + let isScrollableAlongX = false + let isScrollableAlongY = false + let maxHorizontalScroll = 0 + let maxVerticalScroll = 0 + let cursorStyleOfWrapperElement: string + let cursorStyleOfChildElements: string[] + let transformStyleOfChildElements: string[] + let transitionStyleOfChildElements: string[] + + const timing = (1 / 60) * 1000 // period of most monitors (60fps) + + useIsomorphicLayoutEffect(() => { + if (isMounted) { + isScrollableAlongX = + window.getComputedStyle(ref.current).overflowX === "scroll" + isScrollableAlongY = + window.getComputedStyle(ref.current).overflowY === "scroll" + + maxHorizontalScroll = ref.current.scrollWidth - ref.current.clientWidth + maxVerticalScroll = ref.current.scrollHeight - ref.current.clientHeight + + cursorStyleOfWrapperElement = window.getComputedStyle(ref.current).cursor + + cursorStyleOfChildElements = [] + transformStyleOfChildElements = [] + transitionStyleOfChildElements = []; + + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement) => { + cursorStyleOfChildElements.push( + window.getComputedStyle(child).cursor, + ) + + transformStyleOfChildElements.push( + window.getComputedStyle(child).transform === "none" + ? "" + : window.getComputedStyle(child).transform, + ) + + transitionStyleOfChildElements.push( + window.getComputedStyle(child).transition === "none" + ? "" + : window.getComputedStyle(child).transition, + ) + }, + ) + } + }, [isMounted]) + + const runScroll = () => { + const dx = internalState.current.scrollSpeedX * timing + const dy = internalState.current.scrollSpeedY * timing + const offsetX = ref.current.scrollLeft + dx + const offsetY = ref.current.scrollTop + dy + + ref.current.scrollLeft = offsetX // eslint-disable-line no-param-reassign + ref.current.scrollTop = offsetY // eslint-disable-line no-param-reassign + internalState.current.lastScrollX = offsetX + internalState.current.lastScrollY = offsetY + } + + const rubberBandCallback = (e: MouseEvent) => { + const dx = e.clientX - internalState.current.initialMouseX + const dy = e.clientY - internalState.current.initialMouseY + + const { clientWidth, clientHeight } = ref.current + + let displacementX = 0 + let displacementY = 0 + + if (isScrollableAlongX && isScrollableAlongY) { + displacementX = + 0.3 * + clientWidth * + Math.sign(dx) * + Math.log10(1.0 + (0.5 * Math.abs(dx)) / clientWidth) + displacementY = + 0.3 * + clientHeight * + Math.sign(dy) * + Math.log10(1.0 + (0.5 * Math.abs(dy)) / clientHeight) + } else if (isScrollableAlongX) { + displacementX = + 0.3 * + clientWidth * + Math.sign(dx) * + Math.log10(1.0 + (0.5 * Math.abs(dx)) / clientWidth) + } else if (isScrollableAlongY) { + displacementY = + 0.3 * + clientHeight * + Math.sign(dy) * + Math.log10(1.0 + (0.5 * Math.abs(dy)) / clientHeight) + } + + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement) => { + child.style.transform = `translate3d(${displacementX}px, ${displacementY}px, 0px)` // eslint-disable-line no-param-reassign + child.style.transition = "transform 0ms" // eslint-disable-line no-param-reassign + }, + ) + } + + const recoverChildStyle = () => { + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement, i) => { + child.style.transform = transformStyleOfChildElements[i] // eslint-disable-line no-param-reassign + child.style.transition = transitionStyleOfChildElements[i] // eslint-disable-line no-param-reassign + }, + ) + } + + let rubberBandAnimationTimer: NodeJS.Timeout + let keepMovingX: NodeJS.Timer + let keepMovingY: NodeJS.Timer + + const callbackMomentum = () => { + const minimumSpeedToTriggerMomentum = 0.05 + + keepMovingX = setInterval(() => { + const lastScrollSpeedX = internalState.current.scrollSpeedX + const newScrollSpeedX = lastScrollSpeedX * decayRate + internalState.current.scrollSpeedX = newScrollSpeedX + + const isAtLeft = ref.current.scrollLeft <= 0 + const isAtRight = ref.current.scrollLeft >= maxHorizontalScroll + const hasReachedHorizontalEdges = isAtLeft || isAtRight + + runScroll() + + if ( + Math.abs(newScrollSpeedX) < minimumSpeedToTriggerMomentum || + internalState.current.isMouseDown || + hasReachedHorizontalEdges + ) { + internalState.current.scrollSpeedX = 0 + clearInterval(keepMovingX as any) + } + }, timing) + + keepMovingY = setInterval(() => { + const lastScrollSpeedY = internalState.current.scrollSpeedY + const newScrollSpeedY = lastScrollSpeedY * decayRate + internalState.current.scrollSpeedY = newScrollSpeedY + + const isAtTop = ref.current.scrollTop <= 0 + const isAtBottom = ref.current.scrollTop >= maxVerticalScroll + const hasReachedVerticalEdges = isAtTop || isAtBottom + + runScroll() + + if ( + Math.abs(newScrollSpeedY) < minimumSpeedToTriggerMomentum || + internalState.current.isMouseDown || + hasReachedVerticalEdges + ) { + internalState.current.scrollSpeedY = 0 + clearInterval(keepMovingY as any) + } + }, timing) + + internalState.current.isDraggingX = false + internalState.current.isDraggingY = false + + if (applyRubberBandEffect) { + const transitionDurationInMilliseconds = 250; + + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement) => { + child.style.transform = `translate3d(0px, 0px, 0px)` // eslint-disable-line no-param-reassign + child.style.transition = `transform ${transitionDurationInMilliseconds}ms` // eslint-disable-line no-param-reassign + }, + ) + + rubberBandAnimationTimer = setTimeout( + recoverChildStyle, + transitionDurationInMilliseconds, + ) + } + } + + const preventClick = (e: Event) => { + e.preventDefault() + e.stopImmediatePropagation() + // e.stopPropagation(); + } + + const getIsMousePressActive = (buttonsCode: number) => { + return ( + (activeMouseButton === "Left" && buttonsCode === 1) || + (activeMouseButton === "Middle" && buttonsCode === 4) || + (activeMouseButton === "Right" && buttonsCode === 2) + ) + } + + const onMouseDown = (e: React.MouseEvent<HTMLElement>) => { + const isMouseActive = getIsMousePressActive(e.buttons) + if (!isMouseActive) { + return + } + + internalState.current.isMouseDown = true + internalState.current.lastMouseX = e.clientX + internalState.current.lastMouseY = e.clientY + internalState.current.initialMouseX = e.clientX + internalState.current.initialMouseY = e.clientY + } + + const onMouseUp = (e: MouseEvent) => { + const isDragging = + internalState.current.isDraggingX || internalState.current.isDraggingY + + const dx = internalState.current.initialMouseX - e.clientX + const dy = internalState.current.initialMouseY - e.clientY + + const isMotionIntentional = + Math.abs(dx) > safeDisplacement || Math.abs(dy) > safeDisplacement + + const isDraggingConfirmed = isDragging && isMotionIntentional + + if (isDraggingConfirmed) { + ref.current.childNodes.forEach((child) => { + child.addEventListener("click", preventClick) + }) + } else { + ref.current.childNodes.forEach((child) => { + child.removeEventListener("click", preventClick) + }) + } + + internalState.current.isMouseDown = false + internalState.current.lastMouseX = 0 + internalState.current.lastMouseY = 0 + + ref.current.style.cursor = cursorStyleOfWrapperElement; // eslint-disable-line no-param-reassign + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement, i) => { + child.style.cursor = cursorStyleOfChildElements[i] // eslint-disable-line no-param-reassign + }, + ) + + if (isDraggingConfirmed) { + callbackMomentum() + } + } + + const onMouseMove = (e: MouseEvent) => { + if (!internalState.current.isMouseDown) { + return + } + + e.preventDefault() + + const dx = internalState.current.lastMouseX - e.clientX + internalState.current.lastMouseX = e.clientX + + internalState.current.scrollSpeedX = dx / timing + internalState.current.isDraggingX = true + + const dy = internalState.current.lastMouseY - e.clientY + internalState.current.lastMouseY = e.clientY + + internalState.current.scrollSpeedY = dy / timing + internalState.current.isDraggingY = true + + ref.current.style.cursor = "grabbing"; // eslint-disable-line no-param-reassign + (ref.current.childNodes as NodeListOf<HTMLOptionElement>).forEach( + (child: HTMLElement) => { + child.style.cursor = "grabbing" // eslint-disable-line no-param-reassign + }, + ) + + const isAtLeft = ref.current.scrollLeft <= 0 && isScrollableAlongX + const isAtRight = + ref.current.scrollLeft >= maxHorizontalScroll && isScrollableAlongX + const isAtTop = ref.current.scrollTop <= 0 && isScrollableAlongY + const isAtBottom = + ref.current.scrollTop >= maxVerticalScroll && isScrollableAlongY + const isAtAnEdge = isAtLeft || isAtRight || isAtTop || isAtBottom + + if (isAtAnEdge && applyRubberBandEffect) { + rubberBandCallback(e) + } + + runScroll() + } + + const handleResize = () => { + maxHorizontalScroll = ref.current.scrollWidth - ref.current.clientWidth + maxVerticalScroll = ref.current.scrollHeight - ref.current.clientHeight + } + + useEffect(() => { + if (isMounted) { + window.addEventListener("mouseup", onMouseUp) + window.addEventListener("mousemove", onMouseMove) + window.addEventListener("resize", handleResize) + } + return () => { + window.removeEventListener("mouseup", onMouseUp) + window.removeEventListener("mousemove", onMouseMove) + window.removeEventListener("resize", handleResize) + + clearInterval(keepMovingX as any) + clearInterval(keepMovingY as any) + clearTimeout(rubberBandAnimationTimer) + } + }, [isMounted]) + + return { + events: { + onMouseDown, + }, + } +} diff --git a/seanime-2.9.10/seanime-web/src/lib/external-player-link/external-player-link.ts b/seanime-2.9.10/seanime-web/src/lib/external-player-link/external-player-link.ts new file mode 100644 index 0000000..235bd3e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/external-player-link/external-player-link.ts @@ -0,0 +1,99 @@ +import { getServerBaseUrl } from "@/api/client/server-url" +import { logger } from "@/lib/helpers/debug" +import { __isDesktop__ } from "@/types/constants" + +const log = logger("EXTERNAL PLAYER LINK") + +export class ExternalPlayerLink { + + private _playerLink = "" + private _urlToSend = "" + private _episodeNumber: number | null = null + private _mediaTitle: string | null = null + + constructor(playerLink: string) { + this._playerLink = playerLink + } + + setUrl(url: string) { + this._urlToSend = url + } + + setEpisodeNumber(ep: number | undefined) { + this._episodeNumber = ep ?? null + } + + setMediaTitle(title: string | undefined) { + this._mediaTitle = title ?? null + } + + async to(props: { endpoint: string, onTokenQueryParam?: () => Promise<string> }) { + let url = getServerBaseUrl() + props.endpoint + if (props.onTokenQueryParam) { + url += await props.onTokenQueryParam() + } + logger("MEDIALINKS").info("Formatted URL to send", url) + this._urlToSend = url + } + + getFullUrl() { + const urlToSend = this._getUrlToSend() + log.info("Sending URL to external player", urlToSend) + return this._formatFinalUrl(urlToSend) + } + + private _getUrlToSend() { + let urlToSend = this._urlToSend + urlToSend = urlToSend.replace("{{SCHEME}}", window.location.protocol.replace(":", "")) + urlToSend = urlToSend.replace("{{HOST}}", window.location.host) + + if (this._playerLink.includes("?")) { + return encodeURIComponent(urlToSend) + } + return urlToSend + } + + private _cleanTitle(title: string) { + return title.replace(/[\\/:*?"<>|]/g, "") + } + + private _formatFinalUrl(url: string): string { + let link = this._playerLink + link = link.replace("{scheme}", window.location.protocol.replace(":", "")) + link = link.replace("{host}", window.location.host) + link = link.replace("{hostname}", window.location.hostname) + link = link.replace("{mediaTitle}", this._cleanTitle(this._mediaTitle ?? "")) + link = link.replace("{episodeNumber}", this._episodeNumber?.toString?.() ?? "") + link = link.replace("{mime}", "video/webm") + if (link.includes("{formattedTitle}")) { + let title = this._mediaTitle ?? "" + if (this._episodeNumber !== null && !!title.length) { + title = `Episode ${this._episodeNumber} - ${title}` + } + link = link.replace("{formattedTitle}", this._cleanTitle(title ?? "")) + } + log.info("Formatted external player link", link) + if (__isDesktop__) { + let retUrl = link.replace("{url}", url) + if (link.startsWith("intent://")) { + // e.g. "intent://localhost:43214/stream/...#Intent;package=org.videolan.vlc;scheme=http;end" + retUrl = retUrl.replace("intent://http://", "intent://").replace("intent://https://", "intent://") + } + return retUrl + } + + // e.g. "mpv://http://localhost:43214/stream/..." + // e.g. "intent://http://localhost:43214/stream/...#Intent;package=org.videolan.vlc;scheme=http;end" + let retUrl = link.replace("{url}", url) + .replace("127.0.0.1", window.location.hostname) + .replace("localhost", window.location.hostname) + + + if (link.startsWith("intent://")) { + // e.g. "intent://localhost:43214/stream/...#Intent;package=org.videolan.vlc;scheme=http;end" + retUrl = retUrl.replace("intent://http://", "intent://").replace("intent://https://", "intent://") + } + + return retUrl + } +} diff --git a/seanime-2.9.10/seanime-web/src/lib/helpers/browser.ts b/seanime-2.9.10/seanime-web/src/lib/helpers/browser.ts new file mode 100644 index 0000000..34b06ff --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/helpers/browser.ts @@ -0,0 +1,23 @@ +import { __isElectronDesktop__, __isTauriDesktop__ } from "@/types/constants" +import copy from "copy-to-clipboard" + + +export function openTab(url: string) { + if (__isTauriDesktop__) { + const { open } = require("@tauri-apps/plugin-shell") + open(url) + } else { + window.open(url, "_blank") + } +} + +export async function copyToClipboard(text: string) { + if (__isTauriDesktop__) { + const { writeText } = require("@tauri-apps/plugin-clipboard-manager") + await writeText(text) + } else if (__isElectronDesktop__ && window.electron?.clipboard) { + await window.electron.clipboard.writeText(text) + } else { + copy(text) + } +} diff --git a/seanime-2.9.10/seanime-web/src/lib/helpers/css.ts b/seanime-2.9.10/seanime-web/src/lib/helpers/css.ts new file mode 100644 index 0000000..5931f72 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/helpers/css.ts @@ -0,0 +1,135 @@ +const PIXELS_PER_INCH = 96 +const MILLIMETRES_PER_INCH = 25.4 +const POINTS_PER_INCH = 72 +const PICAS_PER_INCH = 6 + +export function getStyle( + element: HTMLElement, + property: keyof CSSStyleDeclaration, +): string { + const view = element.ownerDocument?.defaultView || window + const style = view.getComputedStyle(element) + return ( + style.getPropertyValue(property as string) || (style[property] as string) + ) +} + +function fontSize(element?: HTMLElement | null): string { + return element + ? getStyle(element, "fontSize") || fontSize(element.parentElement) + : getStyle(window.document.documentElement, "fontSize") +} + +function parse(providedLength?: string | null): [number, string] { + const length = providedLength || "0" + + // Check if it's a calc expression + if (length.trim().startsWith("calc(")) { + return parseCalc(length) + } + + const value = Number.parseFloat(length) + const match = length.match(/[\d-.]+(\w+)$/) + const unit = match?.[1] ?? "" + return [value, unit.toLowerCase()] +} + +function parseCalc(calcExpression: string): [number, string] { + // For calc expressions, we'll return a placeholder value and unit + // The actual calculation will be done in evaluateCalcExpression + return [0, "calc"] +} + +function evaluateCalcExpression(calcExpression: string, element?: HTMLElement | null): number { + // Extract the content inside calc() + const content = calcExpression.replace(/^calc\(\s*/, "").replace(/\s*\)$/, "") + + // Replace all CSS length values with their pixel equivalents + const pixelExpression = content.replace(/(-?[\d.]+[a-z%]+)/g, (match) => { + // Skip if it's already a number without unit + if (!isNaN(Number(match))) return match + + return getPixelsFromLength(match, element).toString() + }) + + try { + // Safely evaluate the mathematical expression with all units converted to pixels + // First normalize the expression to handle CSS math operators + const normalizedExpression = pixelExpression + .replace(/\s+/g, " ") // Normalize whitespace + .replace(/\s*([+\-*/()])\s*/g, "$1") // Remove spaces around operators + + return Function(`'use strict'; return (${normalizedExpression})`)() + } + catch (error) { + console.error("Error evaluating calc() expression:", error) + return 0 + } +} + +export function getPixelsFromLength(length: string, element?: HTMLElement | null): number { + // If the length is a calc expression, we need to evaluate each part + if (length.trim().startsWith("calc(")) { + return evaluateCalcExpression(length, element) + } + + const view = element?.ownerDocument?.defaultView ?? window + const root = view.document.documentElement || view.document.body + + const [value, unit] = parse(length) + + switch (unit) { + case "rem": + return value * getPixelsFromLength(fontSize(window.document.documentElement)) + + case "em": + return value * getPixelsFromLength(fontSize(element), element?.parentElement) + + case "in": + return value * PIXELS_PER_INCH + + case "q": + return (value * PIXELS_PER_INCH) / MILLIMETRES_PER_INCH / 4 + + case "mm": + return (value * PIXELS_PER_INCH) / MILLIMETRES_PER_INCH + + case "cm": + return (value * PIXELS_PER_INCH * 10) / MILLIMETRES_PER_INCH + + case "pt": + return (value * PIXELS_PER_INCH) / POINTS_PER_INCH + + case "pc": + return (value * PIXELS_PER_INCH) / PICAS_PER_INCH + + case "vh": + return (value * view.innerHeight || root.clientWidth) / 100 + + case "vw": + return (value * view.innerWidth || root.clientHeight) / 100 + + case "vmin": + return ( + (value * + Math.min( + view.innerWidth || root.clientWidth, + view.innerHeight || root.clientHeight, + )) / + 100 + ) + + case "vmax": + return ( + (value * + Math.max( + view.innerWidth || root.clientWidth, + view.innerHeight || root.clientHeight, + )) / + 100 + ) + + default: + return value + } +} diff --git a/seanime-2.9.10/seanime-web/src/lib/helpers/date.ts b/seanime-2.9.10/seanime-web/src/lib/helpers/date.ts new file mode 100644 index 0000000..129395a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/helpers/date.ts @@ -0,0 +1,44 @@ +import { format, FormatOptions } from "date-fns" +import { formatDistanceToNow } from "date-fns/formatDistanceToNow" + +export function formatDistanceToNowSafe(value: string) { + try { + return formatDistanceToNow(value, { addSuffix: true }) + } + catch (e) { + return "N/A" + } +} + +export function newDateSafe(value: string) { + try { + return new Date(value) + } + catch (e) { + return new Date() + } +} + +export function formatSafe(value: Date, formatString: string, options?: FormatOptions | undefined) { + try { + return format(value, formatString, options) + } + catch (e) { + let v = new Date() + return format(v, formatString, options) + } +} + +export function normalizeDate(value: string) { + try { + let arr = value.split(/[\-\+ :T]/) + let year = parseInt(arr[0]) + let month = parseInt(arr[1]) - 1 + let day = parseInt(arr[2]) + + return new Date(`${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}T00:00:00`) + } + catch (e) { + return new Date(value) + } +} diff --git a/seanime-2.9.10/seanime-web/src/lib/helpers/debug.ts b/seanime-2.9.10/seanime-web/src/lib/helpers/debug.ts new file mode 100644 index 0000000..b9fb1bf --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/helpers/debug.ts @@ -0,0 +1,18 @@ +export const logger = (prefix: string) => { + + return { + info: (...data: any[]) => { + console.log(`[${prefix}]` + " ", ...data) + }, + warning: (...data: any[]) => { + console.log(`[${prefix}]` + " ", ...data) + }, + success: (...data: any[]) => { + console.log(`[${prefix}]` + " ", ...data) + }, + error: (...data: any[]) => { + console.log(`[${prefix}]` + " ", ...data) + }, + } + +} diff --git a/seanime-2.9.10/seanime-web/src/lib/helpers/filtering.ts b/seanime-2.9.10/seanime-web/src/lib/helpers/filtering.ts new file mode 100644 index 0000000..aed46ed --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/helpers/filtering.ts @@ -0,0 +1,518 @@ +import { + AL_AnimeCollection_MediaListCollection_Lists_Entries, + AL_BaseAnime, + AL_BaseManga, + AL_MangaCollection_MediaListCollection_Lists_Entries, + AL_MediaFormat, + AL_MediaSeason, + AL_MediaStatus, + Anime_Episode, + Anime_LibraryCollectionEntry, + Continuity_WatchHistory, + Manga_MangaLatestChapterNumberItem, +} from "@/api/generated/types" +import { getMangaEntryLatestChapterNumber, MangaEntryFilters } from "@/app/(main)/manga/_lib/handle-manga-selected-provider" +import sortBy from "lodash/sortBy" +import { anilist_getUnwatchedCount } from "./media" + +type BaseCollectionSorting = + "START_DATE" + | "START_DATE_DESC" + | "END_DATE" + | "END_DATE_DESC" + | "SCORE" + | "SCORE_DESC" + | "RELEASE_DATE" + | "RELEASE_DATE_DESC" + | "PROGRESS" + | "PROGRESS_DESC" + | "TITLE" + | "TITLE_DESC" + + +type CollectionSorting<T extends CollectionType> = BaseCollectionSorting | (T extends "anime" ? + "PROGRESS_DESC" + | "PROGRESS" + | "AIRDATE" + | "AIRDATE_DESC" + : T extends "manga" ? + "PROGRESS" + | "PROGRESS_DESC" + : never) + + +type ContinueWatchingSorting = + "AIRDATE" + | "AIRDATE_DESC" + | "EPISODE_NUMBER" + | "EPISODE_NUMBER_DESC" + | "UNWATCHED_EPISODES" + | "UNWATCHED_EPISODES_DESC" + | "SCORE" + | "SCORE_DESC" + | "START_DATE" + | "START_DATE_DESC" + | "LAST_WATCHED" + | "LAST_WATCHED_DESC" + +export const CONTINUE_WATCHING_SORTING_OPTIONS = [ + { label: "Aired recently", value: "AIRDATE_DESC" }, + { label: "Aired oldest", value: "AIRDATE" }, + { label: "Highest episode number", value: "EPISODE_NUMBER_DESC" }, + { label: "Lowest episode number", value: "EPISODE_NUMBER" }, + { label: "Most unwatched episodes", value: "UNWATCHED_EPISODES_DESC" }, + { label: "Least unwatched episodes", value: "UNWATCHED_EPISODES" }, + { label: "Highest score", value: "SCORE_DESC" }, + { label: "Lowest score", value: "SCORE" }, + { label: "Started recently", value: "START_DATE_DESC" }, + { label: "Oldest start date", value: "START_DATE" }, + { label: "Most recent watch", value: "LAST_WATCHED_DESC" }, + { label: "Least recent watch", value: "LAST_WATCHED" }, +] + + +export const COLLECTION_SORTING_OPTIONS = [ + { label: "Highest score", value: "SCORE_DESC" }, + { label: "Lowest score", value: "SCORE" }, + { label: "Title", value: "TITLE" }, + { label: "Title (Z-A)", value: "TITLE_DESC" }, + { label: "Highest progress", value: "PROGRESS_DESC" }, + { label: "Lowest progress", value: "PROGRESS" }, + { label: "Started recently", value: "START_DATE_DESC" }, + { label: "Oldest start date", value: "START_DATE" }, + { label: "Completed recently", value: "END_DATE_DESC" }, + { label: "Oldest completion date", value: "END_DATE" }, + { label: "Released recently", value: "RELEASE_DATE_DESC" }, + { label: "Oldest release", value: "RELEASE_DATE" }, +] + +export const ANIME_COLLECTION_SORTING_OPTIONS = [ + { label: "Aired recently and not up-to-date", value: "AIRDATE_DESC" }, + { label: "Aired oldest and not up-to-date", value: "AIRDATE" }, + { label: "Most unwatched episodes", value: "UNWATCHED_EPISODES_DESC" }, + { label: "Least unwatched episodes", value: "UNWATCHED_EPISODES" }, + { label: "Most recent watch", value: "LAST_WATCHED_DESC" }, + { label: "Least recent watch", value: "LAST_WATCHED" }, + ...COLLECTION_SORTING_OPTIONS, +] + +export const MANGA_COLLECTION_SORTING_OPTIONS = [ + { label: "Most unread chapters", value: "UNREAD_CHAPTERS_DESC" }, + { label: "Least unread chapters", value: "UNREAD_CHAPTERS" }, + ...COLLECTION_SORTING_OPTIONS, +] + +export type CollectionType = "anime" | "manga" + +export type CollectionParams<T extends CollectionType> = { + sorting: CollectionSorting<T> + genre: string[] | null + status: AL_MediaStatus | null + format: AL_MediaFormat | null + season: AL_MediaSeason | null + year: string | null + isAdult: boolean +} & (T extends "manga" ? { + unreadOnly: boolean +} : T extends "anime" ? { + continueWatchingOnly: boolean +} : never) + + +export const DEFAULT_COLLECTION_PARAMS: CollectionParams<"anime"> = { + sorting: "SCORE_DESC", + genre: null, + status: null, + format: null, + season: null, + year: null, + isAdult: false, + continueWatchingOnly: false, +} + +export const DEFAULT_ANIME_COLLECTION_PARAMS: CollectionParams<"anime"> = { + sorting: "SCORE_DESC", + genre: null, + status: null, + format: null, + season: null, + year: null, + isAdult: false, + continueWatchingOnly: false, +} + +export const DEFAULT_MANGA_COLLECTION_PARAMS: CollectionParams<"manga"> = { + sorting: "SCORE_DESC", + genre: null, + status: null, + format: null, + season: null, + year: null, + isAdult: false, + unreadOnly: false, +} + + +function getParamValue<T extends any>(value: T | ""): any { + if (value === "") return undefined + if (Array.isArray(value) && value.filter(Boolean).length === 0) return undefined + if (typeof value === "string" && !isNaN(parseInt(value))) return Number(value) + if (value === null) return undefined + return value +} + + +export function filterEntriesByTitle<T extends { media?: AL_BaseAnime | AL_BaseManga }[] | null | undefined>(arr: T, input: string): T { + // @ts-expect-error + if (!arr) return [] + if (arr.length > 0 && input.length > 0) { + const _input = input.toLowerCase().trim().replace(/\s+/g, " ") + // @ts-expect-error + return arr.filter(entry => ( + entry.media?.title?.english?.toLowerCase().includes(_input) + || entry.media?.title?.userPreferred?.toLowerCase().includes(_input) + || entry.media?.title?.romaji?.toLowerCase().includes(_input) + || entry.media?.synonyms?.some(syn => syn?.toLowerCase().includes(_input)) + )) + } + return arr +} + +export function filterListEntries<T extends AL_MangaCollection_MediaListCollection_Lists_Entries[] | AL_AnimeCollection_MediaListCollection_Lists_Entries[], V extends CollectionType>( + type: V, + entries: T | null | undefined, + params: CollectionParams<V>, + showAdultContent: boolean | undefined, +) { + if (!entries) return [] + let arr = [...entries] + + // Filter by isAdult + if (!!arr && params.isAdult) arr = arr.filter(n => n.media?.isAdult) + + // Filter by showAdultContent + if (!showAdultContent) arr = arr.filter(n => !n.media?.isAdult) + + // Filter by format + if (!!arr && !!params.format) arr = arr.filter(n => n.media?.format === params.format) + + // Filter by season + if (!!arr && !!params.season) arr = arr.filter(n => n.media?.season === params.season) + + // Filter by status + if (!!arr && !!params.status) arr = arr.filter(n => n.media?.status === params.status) + + // Filter by year + if (!!arr && !!params.year) arr = arr.filter(n => (n.media as AL_BaseAnime)?.seasonYear ? + ((n.media as AL_BaseAnime)?.seasonYear === Number(params.year) || n.media?.startDate?.year === Number(params.year)) + : n.media?.startDate?.year === Number(params.year)) + + // Filter by genre + if (!!arr && !!params.genre?.length) { + arr = arr.filter(n => { + return params.genre?.every(genre => n.media?.genres?.includes(genre)) + }) + } + + // Initial sort by name + arr = sortBy(arr, n => n?.media?.title?.userPreferred).reverse() + + // Sort by title + if (getParamValue(params.sorting) === "TITLE") + // arr = sortBy(arr, n => n?.media?.title?.userPreferred) + arr.sort((a, b) => a?.media?.title?.userPreferred?.localeCompare(b?.media?.title?.userPreferred!) || 0) + if (getParamValue(params.sorting) === "TITLE_DESC") + // arr = sortBy(arr, n => n?.media?.title?.userPreferred).reverse() + arr.sort((a, b) => b?.media?.title?.userPreferred?.localeCompare(a?.media?.title?.userPreferred!) || 0).reverse() + + // Sort by release date + if (getParamValue(params.sorting) === "RELEASE_DATE" || getParamValue(params.sorting) === "RELEASE_DATE_DESC") { + arr = arr?.filter(n => n.media?.startDate && !!n.media.startDate.year && !!n.media.startDate.month) + } + if (getParamValue(params.sorting) === "RELEASE_DATE") + arr = sortBy(arr, n => new Date(n?.media?.startDate?.year!, n?.media?.startDate?.month! - 1)) + if (getParamValue(params.sorting) === "RELEASE_DATE_DESC") + arr = sortBy(arr, n => new Date(n?.media?.startDate?.year!, n?.media?.startDate?.month! - 1)).reverse() + + // Sort by score + if (getParamValue(params.sorting) === "SCORE") + arr = sortBy(arr, n => n?.score || 999999) + if (getParamValue(params.sorting) === "SCORE_DESC") + arr = sortBy(arr, n => n?.score || 0).reverse() + + // Sort by start date + // if (getParamValue(params.sorting) === "START_DATE" || getParamValue(params.sorting) === "START_DATE_DESC") { + // arr = arr?.filter(n => n.startedAt && !!n.startedAt.year && !!n.startedAt.month && !!n.startedAt.day) + // } + if (getParamValue(params.sorting) === "START_DATE") + arr = sortBy(arr, n => new Date(n?.startedAt?.year || 9999, (n?.startedAt?.month || 1) - 1, n?.startedAt?.day || 1)) + if (getParamValue(params.sorting) === "START_DATE_DESC") + arr = sortBy(arr, n => new Date(n?.startedAt?.year || 1000, (n?.startedAt?.month || 1) - 1, n?.startedAt?.day || 1)).reverse() + + // Sort by end date + if (getParamValue(params.sorting) === "END_DATE" || getParamValue(params.sorting) === "END_DATE_DESC") { + arr = arr?.filter(n => n.completedAt && !!n.completedAt.year && !!n.completedAt.month && !!n.completedAt.day) + } + if (getParamValue(params.sorting) === "END_DATE") + arr = sortBy(arr, n => new Date(n?.completedAt?.year!, n?.completedAt?.month! - 1, n?.completedAt?.day)) + if (getParamValue(params.sorting) === "END_DATE_DESC") + arr = sortBy(arr, n => new Date(n?.completedAt?.year!, n?.completedAt?.month! - 1, n?.completedAt?.day)).reverse() + + // Sort by progress + if (getParamValue(params.sorting) === "PROGRESS") + arr = sortBy(arr, n => n?.progress || 0) + if (getParamValue(params.sorting) === "PROGRESS_DESC") + arr = sortBy(arr, n => n?.progress || 0).reverse() + + return arr +} + +export function filterCollectionEntries<T extends Anime_LibraryCollectionEntry[], V extends CollectionType>( + type: V, + entries: T | null | undefined, + params: CollectionParams<V>, + showAdultContent: boolean | undefined, +) { + if (!entries) return [] + let arr = [...entries] + + // Filter by isAdult + if (!!arr && params.isAdult) arr = arr.filter(n => n.media?.isAdult) + + // Filter by showAdultContent + if (!showAdultContent) arr = arr.filter(n => !n.media?.isAdult) + + // Filter by format + if (!!arr && !!params.format) arr = arr.filter(n => n.media?.format === params.format) + + // Filter by season + if (!!arr && !!params.season) arr = arr.filter(n => n.media?.season === params.season) + + // Filter by status + if (!!arr && !!params.status) arr = arr.filter(n => n.media?.status === params.status) + + // Filter by year + if (!!arr && !!params.year) arr = arr.filter(n => n.media?.seasonYear === Number(params.year) || n.media?.startDate?.year === Number(params.year)) + + // Filter by genre + if (!!arr && !!params.genre?.length) { + arr = arr.filter(n => { + return params.genre?.every(genre => n.media?.genres?.includes(genre)) + }) + } + + // Initial sort by name + arr = sortBy(arr, n => n?.media?.title?.userPreferred).reverse() + + // Sort by title + if (getParamValue(params.sorting) === "TITLE") + // arr = sortBy(arr, n => n?.media?.title?.userPreferred) + arr.sort((a, b) => a?.media?.title?.userPreferred?.localeCompare(b?.media?.title?.userPreferred!) || 0) + if (getParamValue(params.sorting) === "TITLE_DESC") + // arr = sortBy(arr, n => n?.media?.title?.userPreferred).reverse() + arr.sort((a, b) => b?.media?.title?.userPreferred?.localeCompare(a?.media?.title?.userPreferred!) || 0).reverse() + + // Sort by release date + if (getParamValue(params.sorting) === "RELEASE_DATE" || getParamValue(params.sorting) === "RELEASE_DATE_DESC") { + arr = arr?.filter(n => n.media?.startDate && !!n.media.startDate.year && !!n.media.startDate.month) + } + if (getParamValue(params.sorting) === "RELEASE_DATE") + arr = sortBy(arr, n => new Date(n?.media?.startDate?.year!, n?.media?.startDate?.month! - 1)) + if (getParamValue(params.sorting) === "RELEASE_DATE_DESC") + arr = sortBy(arr, n => new Date(n?.media?.startDate?.year!, n?.media?.startDate?.month! - 1)).reverse() + + // Sort by score + if (getParamValue(params.sorting) === "SCORE") + arr = sortBy(arr, n => { + return n?.listData?.score || 999999 + }) + if (getParamValue(params.sorting) === "SCORE_DESC") + arr = sortBy(arr, n => n?.listData?.score || 0).reverse() + + // Sort by start date + // if (getParamValue(params.sorting) === "START_DATE" || getParamValue(params.sorting) === "START_DATE_DESC") { + // arr = arr?.filter(n => !!n.listData?.startedAt) + // } + if (getParamValue(params.sorting) === "START_DATE") + arr = sortBy(arr, n => new Date(n?.listData?.startedAt ?? new Date(9999, 1, 1).toISOString())) + if (getParamValue(params.sorting) === "START_DATE_DESC") + arr = sortBy(arr, n => new Date(n?.listData?.startedAt ?? new Date(1000, 1, 1).toISOString())).reverse() + + // Sort by end date + if (getParamValue(params.sorting) === "END_DATE" || getParamValue(params.sorting) === "END_DATE_DESC") { + arr = arr?.filter(n => !!n.listData?.completedAt) + } + if (getParamValue(params.sorting) === "END_DATE") + arr = sortBy(arr, n => new Date(n?.listData?.completedAt!)) + if (getParamValue(params.sorting) === "END_DATE_DESC") + arr = sortBy(arr, n => new Date(n?.listData?.completedAt!)).reverse() + + // Sort by progress + if (getParamValue(params.sorting) === "PROGRESS") + arr = sortBy(arr, n => n?.listData?.progress || 0) + if (getParamValue(params.sorting) === "PROGRESS_DESC") + arr = sortBy(arr, n => n?.listData?.progress || 0).reverse() + + return arr +} + +/** */ +export function filterAnimeCollectionEntries<T extends Anime_LibraryCollectionEntry[]>( + entries: T | null | undefined, + params: CollectionParams<"anime">, + showAdultContent: boolean | undefined, + continueWatchingList: Anime_Episode[] | null | undefined, + watchHistory: Continuity_WatchHistory | null | undefined, +) { + let arr = filterCollectionEntries("anime", entries, params, showAdultContent) + + if (params.continueWatchingOnly) { + arr = arr.filter(n => continueWatchingList?.findIndex(e => e.baseAnime?.id === n.media?.id) !== -1) + } + + // Sort by airdate + if (getParamValue(params.sorting) === "AIRDATE") { + arr = sortBy(arr, + n => continueWatchingList?.find(c => c.baseAnime?.id === n.media?.id)?.episodeMetadata?.airDate || new Date(9999, 1, 1).toISOString()) + } + if (getParamValue(params.sorting) === "AIRDATE_DESC") { + arr = sortBy(arr, + n => continueWatchingList?.find(c => c.baseAnime?.id === n.media?.id)?.episodeMetadata?.airDate || new Date(1000, 1, 1).toISOString()) + .reverse() + } + + // Sort by unwatched episodes + if (getParamValue(params.sorting) === "UNWATCHED_EPISODES") { + // arr = sortBy(arr, + // n => !!n.libraryData?.mainFileCount ? n.libraryData?.unwatchedCount : (anilist_getUnwatchedCount(n.media, n.listData?.progress) || + // 99999)) + arr = sortBy(arr, + n => !!n.libraryData?.mainFileCount ? n.libraryData?.unwatchedCount : ( + !!n.nakamaLibraryData?.mainFileCount ? n.nakamaLibraryData?.unwatchedCount : (anilist_getUnwatchedCount(n.media, + n.listData?.progress) || 99999) + )) + } + if (getParamValue(params.sorting) === "UNWATCHED_EPISODES_DESC") { + // arr = sortBy(arr, + // n => !!n.libraryData?.mainFileCount ? n.libraryData?.unwatchedCount : anilist_getUnwatchedCount(n.media, + // n.listData?.progress)).reverse() + arr = sortBy(arr, + n => !!n.libraryData?.mainFileCount ? n.libraryData?.unwatchedCount : ( + !!n.nakamaLibraryData?.mainFileCount ? n.nakamaLibraryData?.unwatchedCount : (anilist_getUnwatchedCount(n.media, + n.listData?.progress) || 99999) + )) + .reverse() + } + + // Sort by last watched + if (getParamValue(params.sorting) === "LAST_WATCHED") { + arr = sortBy(arr, n => watchHistory?.[n.media?.id!]?.timeUpdated || new Date(9999, 1, 1).toISOString()) + } + if (getParamValue(params.sorting) === "LAST_WATCHED_DESC") { + arr = sortBy(arr, n => watchHistory?.[n.media?.id!]?.timeUpdated || new Date(1000, 1, 1).toISOString()).reverse() + } + + return arr +} + + +/** */ +export function filterMangaCollectionEntries<T extends Anime_LibraryCollectionEntry[]>( + entries: T | null | undefined, + params: CollectionParams<"manga">, + showAdultContent: boolean | undefined, + storedProviders: Record<string, string> | null | undefined, + storedProviderFilters: Record<number, MangaEntryFilters> | null | undefined, + latestChapterNumbers: Record<number, Manga_MangaLatestChapterNumberItem[]> | null | undefined, +) { + if (!latestChapterNumbers || !storedProviders || !storedProviderFilters) return [] + let arr = filterCollectionEntries("manga", entries, params, showAdultContent) + + + if (params.unreadOnly) { + arr = arr.filter(n => { + const latestChapterNumber = getMangaEntryLatestChapterNumber(n.media?.id!, latestChapterNumbers, storedProviders, storedProviderFilters) + const mangaChapterCount = latestChapterNumber || 999999 + return mangaChapterCount - (n.listData?.progress || 0) > 0 + }) + } + + // Sort by unwatched chapters + if (getParamValue(params.sorting) === "UNREAD_CHAPTERS") { + arr = sortBy(arr, n => { + const latestChapterNumber = getMangaEntryLatestChapterNumber(n.media?.id!, latestChapterNumbers, storedProviders, storedProviderFilters) + // console.log(n.media?.id, latestChapterNumber) + const mangaChapterCount = latestChapterNumber || 999999 + return mangaChapterCount - (n.listData?.progress || 0) + }) + } + if (getParamValue(params.sorting) === "UNREAD_CHAPTERS_DESC") { + arr = sortBy(arr, n => { + const latestChapterNumber = getMangaEntryLatestChapterNumber(n.media?.id!, latestChapterNumbers, storedProviders, storedProviderFilters) + // console.log(n.media?.id, latestChapterNumber) + const mangaChapterCount = latestChapterNumber || 0 + return mangaChapterCount - (n.listData?.progress || 0) + }).reverse() + } + + return arr +} + +export function sortContinueWatchingEntries( + entries: Anime_Episode[] | null | undefined, + sorting: ContinueWatchingSorting, + libraryEntries: Anime_LibraryCollectionEntry[] | null | undefined, + watchHistory: Continuity_WatchHistory | null | undefined, +) { + if (!entries) return [] + let arr = [...entries] + + // Initial sort by name + arr = sortBy(arr, n => n?.displayTitle) + + // Sort by episode number + if (sorting === "EPISODE_NUMBER") + arr = sortBy(arr, n => n?.episodeNumber) + if (sorting === "EPISODE_NUMBER_DESC") + arr = sortBy(arr, n => n?.episodeNumber).reverse() + + // Sort by airdate + if (sorting === "AIRDATE") + arr = sortBy(arr, n => n?.episodeMetadata?.airDate) + if (sorting === "AIRDATE_DESC") + arr = sortBy(arr, n => n?.episodeMetadata?.airDate).reverse() + + // Sort by unwatched episodes + if (sorting === "UNWATCHED_EPISODES") + arr = sortBy(arr, + n => libraryEntries?.find(e => e.media?.id === n.baseAnime?.id)?.libraryData?.unwatchedCount ?? (anilist_getUnwatchedCount(n.baseAnime, + libraryEntries?.find(e => e.media?.id === n.baseAnime?.id)?.listData?.progress) || 99999)) + if (sorting === "UNWATCHED_EPISODES_DESC") + arr = sortBy(arr, + n => libraryEntries?.find(e => e.media?.id === n.baseAnime?.id)?.libraryData?.unwatchedCount ?? anilist_getUnwatchedCount(n.baseAnime, + libraryEntries?.find(e => e.media?.id === n.baseAnime?.id)?.listData?.progress)) + .reverse() + + // Sort by score + if (sorting === "SCORE") + arr = sortBy(arr, n => libraryEntries?.find(e => e.media?.id === n.baseAnime?.id)?.listData?.score || 999999) + if (sorting === "SCORE_DESC") + arr = sortBy(arr, n => libraryEntries?.find(e => e.media?.id === n.baseAnime?.id)?.listData?.score || 0).reverse() + + // Sort by start date + if (sorting === "START_DATE") + arr = sortBy(arr, n => libraryEntries?.find(e => e.media?.id === n.baseAnime?.id)?.listData?.startedAt || new Date(9999, 1, 1).toISOString()) + if (sorting === "START_DATE_DESC") + arr = sortBy(arr, n => libraryEntries?.find(e => e.media?.id === n.baseAnime?.id)?.listData?.startedAt || new Date(1000, 1, 1).toISOString()) + .reverse() + + + // Sort by last watched + if (sorting === "LAST_WATCHED") + arr = sortBy(arr, n => watchHistory?.[n.baseAnime?.id!]?.timeUpdated || new Date(9999, 1, 1).toISOString()) + if (sorting === "LAST_WATCHED_DESC") + arr = sortBy(arr, n => watchHistory?.[n.baseAnime?.id!]?.timeUpdated || new Date(1000, 1, 1).toISOString()) + .reverse() + + return arr +} diff --git a/seanime-2.9.10/seanime-web/src/lib/helpers/media.ts b/seanime-2.9.10/seanime-web/src/lib/helpers/media.ts new file mode 100644 index 0000000..bd8ab08 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/helpers/media.ts @@ -0,0 +1,61 @@ +import { AL_AnimeListEntry, AL_BaseAnime, AL_MangaListEntry, Nullish } from "@/api/generated/types" + +export function anilist_getTotalEpisodes(anime: Nullish<AL_BaseAnime>) { + if (!anime) return -1 + let maxEp = anime?.episodes ?? -1 + if (maxEp === -1) { + if (anime.nextAiringEpisode && anime.nextAiringEpisode.episode) { + maxEp = anime.nextAiringEpisode.episode - 1 + } + } + if (maxEp === -1) { + return 0 + } + return maxEp +} + +export function anilist_getCurrentEpisodes(anime: Nullish<AL_BaseAnime>) { + if (!anime) return -1 + let maxEp = -1 + if (anime.nextAiringEpisode && anime.nextAiringEpisode.episode) { + maxEp = anime.nextAiringEpisode.episode - 1 + } + if (maxEp === -1) { + maxEp = anime.episodes ?? 0 + } + return maxEp +} + +export function anilist_getListDataFromEntry(entry: Nullish<AL_AnimeListEntry | AL_MangaListEntry>) { + return { + progress: entry?.progress, + score: entry?.score, + status: entry?.status, + startedAt: new Date(entry?.startedAt?.year || 0, + entry?.startedAt?.month ? entry?.startedAt?.month - 1 : 0, + entry?.startedAt?.day || 0).toUTCString(), + completedAt: new Date(entry?.completedAt?.year || 0, + entry?.completedAt?.month ? entry?.completedAt?.month - 1 : 0, + entry?.completedAt?.day || 0).toUTCString(), + } +} + + +export function anilist_animeIsMovie(anime: Nullish<AL_BaseAnime>) { + if (!anime) return false + return anime?.format === "MOVIE" + +} + +export function anilist_animeIsSingleEpisode(anime: Nullish<AL_BaseAnime>) { + if (!anime) return false + return anime?.format === "MOVIE" || anime?.episodes === 1 +} + + +export function anilist_getUnwatchedCount(anime: Nullish<AL_BaseAnime>, progress: Nullish<number>) { + if (!anime) return false + const maxEp = anilist_getCurrentEpisodes(anime) + return maxEp - (progress ?? 0) +} + diff --git a/seanime-2.9.10/seanime-web/src/lib/helpers/score.ts b/seanime-2.9.10/seanime-web/src/lib/helpers/score.ts new file mode 100644 index 0000000..ad11b19 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/helpers/score.ts @@ -0,0 +1,50 @@ +import { cn } from "@/components/ui/core/styling" + +export function getScoreColor(score: number, kind: "audience" | "user"): string { + if (score < 40) { // 0-39 + return cn( + // kind === "audience" && "bg-red-800 bg-opacity-70", + kind === "audience" && "text-audienceScore-300 bg-black bg-opacity-20", + kind === "user" && "bg-red-800 bg-opacity-90", + // "text-red-200", + ) + } + if (score < 60) { // 30-59 + return cn( + // kind === "audience" && "bg-amber-800 bg-opacity-70", + kind === "audience" && "text-audienceScore-500 bg-black bg-opacity-20", + kind === "user" && "bg-amber-800 bg-opacity-90", + // "text-amber-200", + ) + } + if (score < 70) { // 60-69 + return cn( + // kind === "audience" && "bg-lime-800 bg-opacity-70", + kind === "audience" && "text-audienceScore-600 bg-black bg-opacity-20", + kind === "user" && "bg-lime-800 bg-opacity-90", + // "text-lime-200", + ) + } + // if (score < 80) { // 70-79 + // return cn( + // // kind === "audience" && "bg-emerald-800 bg-opacity-70", + // // "text-emerald-100", + // kind === "audience" && "text-emerald-300 bg-black bg-opacity-20", + // kind === "user" && "bg-emerald-800 bg-opacity-90 text-white", + // ) + // } + if (score < 82) { + return cn( + // kind === "audience" && "bg-emerald-800 bg-opacity-70", + // "text-emerald-100", + kind === "audience" && "text-audienceScore-700 bg-black bg-opacity-20", + kind === "user" && "bg-emerald-800 bg-opacity-90 text-white", + ) + } + // 90-100 + return cn( + // kind === "audience" && "bg-indigo-600 bg-opacity-60 text-gray-100", + kind === "audience" && "text-indigo-300 bg-black bg-opacity-20", + kind === "user" && "bg-indigo-600 bg-opacity-80 text-white", + ) +} diff --git a/seanime-2.9.10/seanime-web/src/lib/helpers/upath.test.ts b/seanime-2.9.10/seanime-web/src/lib/helpers/upath.test.ts new file mode 100644 index 0000000..26211fa --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/helpers/upath.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest" +import { upath } from "./upath" + +describe("upath", () => { + describe("core functions", () => { + describe("join", () => { + it("should join paths correctly", () => { + expect(upath.join("a", "b", "c")).toBe("a/b/c") + expect(upath.join("/a", "b", "c")).toBe("/a/b/c") + expect(upath.join("a", "/b", "c")).toBe("a/b/c") + expect(upath.join("")).toBe(".") + expect(upath.join("a", "")).toBe("a") + expect(upath.join("a/", "b")).toBe("a/b") + expect(upath.join("a//", "b")).toBe("a/b") + expect(upath.join("a", "..", "b")).toBe("b") + expect(upath.join("a/b", "..")).toBe("a") + }) + + it("should handle Windows-style paths", () => { + expect(upath.join("a\\b", "c")).toBe("a/b/c") + expect(upath.join("a", "b\\c")).toBe("a/b/c") + expect(upath.join("C:", "b\\c")).toBe("C:/b/c") + }) + }) + + describe("resolve", () => { + it("should resolve paths correctly", () => { + expect(upath.resolve("a", "b", "c")).toBe("/a/b/c") + expect(upath.resolve("/a", "b", "c")).toBe("/a/b/c") + expect(upath.resolve("/a", "/b", "c")).toBe("/b/c") + expect(upath.resolve("a", "..", "b")).toBe("/b") + expect(upath.resolve("a/b", "..")).toBe("/a") + }) + + it("should handle absolute paths", () => { + expect(upath.resolve("/a/b", "c")).toBe("/a/b/c") + expect(upath.resolve("/a/b", "/c")).toBe("/c") + }) + }) + + describe("normalize", () => { + it("should normalize paths correctly", () => { + expect(upath.normalize("a/b/c")).toBe("a/b/c") + expect(upath.normalize("/a/b/c")).toBe("/a/b/c") + expect(upath.normalize("a//b/c")).toBe("a/b/c") + expect(upath.normalize("a/b/../c")).toBe("a/c") + expect(upath.normalize("/a/b/../c")).toBe("/a/c") + expect(upath.normalize("a/b/./c")).toBe("a/b/c") + expect(upath.normalize("a/b/c/")).toBe("a/b/c/") + expect(upath.normalize("a/b/c//")).toBe("a/b/c/") + expect(upath.normalize("")).toBe(".") + expect(upath.normalize(".")).toBe(".") + expect(upath.normalize("..")).toBe("..") + }) + + it("should handle special cases", () => { + expect(upath.normalize("/")).toBe("/") + expect(upath.normalize("//")).toBe("//") + expect(upath.normalize("//server/share")).toBe("//server/share") + expect(upath.normalize("a/../..")).toBe("..") + expect(upath.normalize("/a/../..")).toBe("/") + }) + }) + + describe("isAbsolute", () => { + it("should identify absolute paths", () => { + expect(upath.isAbsolute("/a/b")).toBe(true) + expect(upath.isAbsolute("/a")).toBe(true) + expect(upath.isAbsolute("/")).toBe(true) + expect(upath.isAbsolute("//server/share")).toBe(true) + expect(upath.isAbsolute("C:\\a\\b")).toBe(true) + }) + + it("should identify relative paths", () => { + expect(upath.isAbsolute("a/b")).toBe(false) + expect(upath.isAbsolute(".")).toBe(false) + expect(upath.isAbsolute("..")).toBe(false) + expect(upath.isAbsolute("")).toBe(false) + }) + }) + + describe("dirname", () => { + it("should get directory path correctly", () => { + expect(upath.dirname("/a/b/c")).toBe("/a/b") + expect(upath.dirname("/a/b")).toBe("/a") + expect(upath.dirname("/a")).toBe("/") + expect(upath.dirname("a/b")).toBe("a") + expect(upath.dirname("a")).toBe(".") + expect(upath.dirname(".")).toBe(".") + expect(upath.dirname("")).toBe(".") + }) + + it("should handle trailing slashes", () => { + expect(upath.dirname("/a/b/c/")).toBe("/a/b") + expect(upath.dirname("a/b/")).toBe("a") + }) + }) + + describe("basename", () => { + it("should extract basename correctly", () => { + expect(upath.basename("/a/b/c")).toBe("c") + expect(upath.basename("/a/b/c/")).toBe("c") + expect(upath.basename("/a/b")).toBe("b") + expect(upath.basename("/a")).toBe("a") + expect(upath.basename("a/b")).toBe("b") + expect(upath.basename("a")).toBe("a") + expect(upath.basename(".")).toBe(".") + expect(upath.basename("")).toBe("") + }) + + it("should handle extensions", () => { + expect(upath.basename("/a/b/c.txt")).toBe("c.txt") + expect(upath.basename("/a/b/c.txt", ".txt")).toBe("c") + expect(upath.basename("a.txt", ".txt")).toBe("a") + expect(upath.basename(".txt", ".txt")).toBe("") + expect(upath.basename(".bashrc")).toBe(".bashrc") + expect(upath.basename(".bashrc", ".bashrc")).toBe("") + }) + + it("should handle trailing slashes", () => { + expect(upath.basename("/a/b/c/")).toBe("c") + expect(upath.basename("a/b/")).toBe("b") + }) + }) + + describe("extname", () => { + it("should extract extension correctly", () => { + expect(upath.extname("a.txt")).toBe(".txt") + expect(upath.extname("/a/b/c.txt")).toBe(".txt") + expect(upath.extname("/a/b.c/d")).toBe("") + expect(upath.extname("a")).toBe("") + expect(upath.extname("a.")).toBe(".") + expect(upath.extname(".txt")).toBe("") + expect(upath.extname("a.b.c")).toBe(".c") + expect(upath.extname("")).toBe("") + }) + }) + + describe("format", () => { + it("should format path objects correctly", () => { + expect(upath.format({ root: "/", dir: "/a/b", base: "c.txt" })).toBe("/a/b/c.txt") + expect(upath.format({ dir: "a/b", base: "c.txt" })).toBe("a/b/c.txt") + expect(upath.format({ root: "/", base: "c.txt" })).toBe("/c.txt") + expect(upath.format({ root: "//" })).toBe("//") + expect(upath.format({ dir: "a/b" })).toBe("a/b") + expect(upath.format({ name: "file", ext: ".txt" })).toBe("file.txt") + }) + }) + + describe("parse", () => { + it("should parse paths correctly", () => { + expect(upath.parse("/a/b/c.txt")).toEqual({ + root: "/", + dir: "/a/b", + base: "c.txt", + ext: ".txt", + name: "c", + }) + expect(upath.parse("a/b/c")).toEqual({ + root: "", + dir: "a/b", + base: "c", + ext: "", + name: "c", + }) + expect(upath.parse(".bashrc")).toEqual({ + root: "", + dir: "", + base: ".bashrc", + ext: "", + name: ".bashrc", + }) + expect(upath.parse("//server/share/file.txt")).toEqual({ + root: "//", + dir: "//server/share", + base: "file.txt", + ext: ".txt", + name: "file", + }) + }) + }) + + describe("relative", () => { + it("should calculate relative paths correctly", () => { + expect(upath.relative("/a/b/c", "/a/b/d")).toBe("../d") + expect(upath.relative("/a/b", "/a/c")).toBe("../c") + expect(upath.relative("/a/b", "/a/b/c")).toBe("c") + expect(upath.relative("/a/b/c", "/a/b")).toBe("..") + expect(upath.relative("/a/b/c", "/d/e/f")).toBe("../../../d/e/f") + expect(upath.relative("/a/b", "/a/b")).toBe("") + }) + }) + }) + + describe("extra functions", () => { + describe("toUnix", () => { + it("should convert paths to Unix style", () => { + expect(upath.toUnix("a\\b\\c")).toBe("a/b/c") + expect(upath.toUnix("\\a\\b\\c")).toBe("/a/b/c") + expect(upath.toUnix("a//b//c")).toBe("a/b/c") + expect(upath.toUnix("//server//share")).toBe("//server/share") + }) + }) + + describe("normalizeSafe", () => { + it("should normalize paths safely", () => { + expect(upath.normalizeSafe("./a/b/c")).toBe("./a/b/c") + expect(upath.normalizeSafe("./a/../b")).toBe("./b") + expect(upath.normalizeSafe("//server/share")).toBe("//server/share") + expect(upath.normalizeSafe("//./path")).toBe("//./path") + }) + }) + + describe("normalizeTrim", () => { + it("should normalize and trim trailing slashes", () => { + expect(upath.normalizeTrim("a/b/c/")).toBe("a/b/c") + expect(upath.normalizeTrim("./a/b/c/")).toBe("./a/b/c") + expect(upath.normalizeTrim("/")).toBe("/") + expect(upath.normalizeTrim("a/")).toBe("a") + }) + }) + + describe("joinSafe", () => { + it("should join paths safely", () => { + expect(upath.joinSafe("./a", "b")).toBe("./a/b") + expect(upath.joinSafe("//server", "share")).toBe("//server/share") + expect(upath.joinSafe("a", "../b")).toBe("b") + }) + }) + + describe("addExt", () => { + it("should add extension correctly", () => { + expect(upath.addExt("file", "txt")).toBe("file.txt") + expect(upath.addExt("file", ".txt")).toBe("file.txt") + expect(upath.addExt("file.txt", "txt")).toBe("file.txt") + expect(upath.addExt("file.js", "txt")).toBe("file.js.txt") + expect(upath.addExt("file")).toBe("file") + }) + }) + + describe("trimExt", () => { + it("should trim valid extensions", () => { + expect(upath.trimExt("file.txt")).toBe("file") + expect(upath.trimExt("file.js")).toBe("file") + expect(upath.trimExt("file.txt", [".txt"])).toBe("file.txt") + expect(upath.trimExt("file.longext", [], 5)).toBe("file.longext") + expect(upath.trimExt("file")).toBe("file") + expect(upath.trimExt(".gitignore")).toBe(".gitignore") + }) + }) + + describe("removeExt", () => { + it("should remove specific extensions", () => { + expect(upath.removeExt("file.txt", "txt")).toBe("file") + expect(upath.removeExt("file.txt", ".txt")).toBe("file") + expect(upath.removeExt("file.js", "txt")).toBe("file.js") + expect(upath.removeExt("file.js")).toBe("file.js") + expect(upath.removeExt("file")).toBe("file") + }) + }) + + describe("changeExt", () => { + it("should change extensions correctly", () => { + expect(upath.changeExt("file.txt", "js")).toBe("file.js") + expect(upath.changeExt("file.txt", ".js")).toBe("file.js") + expect(upath.changeExt("file", "js")).toBe("file.js") + expect(upath.changeExt("file.txt")).toBe("file") + expect(upath.changeExt("file.txt", "js", [".txt"])).toBe("file.txt.js") + }) + }) + + describe("defaultExt", () => { + it("should add default extension when needed", () => { + expect(upath.defaultExt("file", "txt")).toBe("file.txt") + expect(upath.defaultExt("file.js", "txt")).toBe("file.js") + expect(upath.defaultExt("file", ".txt")).toBe("file.txt") + expect(upath.defaultExt("file.js", "txt", [".js"])).toBe("file.js.txt") + expect(upath.defaultExt("file.longext", "txt", [], 5)).toBe("file.longext.txt") + }) + }) + }) +}) diff --git a/seanime-2.9.10/seanime-web/src/lib/helpers/upath.ts b/seanime-2.9.10/seanime-web/src/lib/helpers/upath.ts new file mode 100644 index 0000000..db46491 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/helpers/upath.ts @@ -0,0 +1,850 @@ +/** + * Represents a path object returned by `parse`. + */ +interface ParsedPath { + root: string; + dir: string; + base: string; + ext: string; + name: string; +} + +/** + * Represents an object used by `format`. + */ +interface FormatInputPathObject { + root?: string; + dir?: string; + base?: string; + ext?: string; + name?: string; +} + +// Define the shape of the exported module +// This interface reflects the public API, including reimplemented core functions +// and added extra functions. +interface UPath { + VERSION: string; + sep: string; + delimiter: string; // Common path property, though less critical in browser/unix + + // Reimplemented core path functions - signatures match Node.js path (posix) + // They internally handle string arguments/results with toUnix where appropriate + join(...paths: string[]): string; + + resolve(...pathSegments: string[]): string; + + normalize(p: string): string; + + isAbsolute(p: string): boolean; + + dirname(p: string): string; + + basename(p: string, ext?: string): string; + + extname(p: string): string; + + format(p: FormatInputPathObject): string; + + parse(p: string): ParsedPath; + + relative(from: string, to: string): string; + + // Extra functions + toUnix(p: string): string; + + normalizeSafe(p: string): string; + + normalizeTrim(p: string): string; + + joinSafe(...p: string[]): string; + + addExt(file: string, ext?: string): string; + + trimExt(filename: string, ignoreExts?: string[], maxSize?: number): string; + + removeExt(filename: string, ext?: string): string; + + changeExt(filename: string, ext?: string, ignoreExts?: string[], maxSize?: number): string; + + defaultExt(filename: string, ext?: string, ignoreExts?: string[], maxSize?: number): string; +} + +// --- Helper Functions --- + +const isString = (val: any): val is string => + typeof val === "string" || + (!!val && typeof val === "object" && Object.prototype.toString.call(val) === "[object String]") + +/** + * Converts a path to a Unix-style path with forward slashes. + * Replaces backslashes with forward slashes. + * Collapses multiple consecutive forward slashes into a single slash, + * except for the leading slashes in a UNC path (e.g., //server/share). + * This replicates the behavior of the original /(?<!^)\/+/g regex without lookbehind + * by using a different regex or checking the match offset. + * @param p The path string. + * @returns The Unix-style path string. + */ +const toUnix = (p: string): string => { + if (!isString(p)) { + return p as any // Coerce non-strings to string-like if necessary, matching original flexibility + } + let unixPath = p.replace(/\\/g, "/") + + // Replace sequences of 2+ forward slashes with a single slash, but only if not at the beginning + // Replicates /(?<!^)\/+/g without lookbehind by checking the match offset. + // Regex /\/{2,}/g matches two or more slashes. + // The check `offset > 0` ensures we don't collapse leading `//` or `///`. + // Example: '/a//b' -> offset=2, matches `//`, replace with `/` -> '/a/b'. + // Example: '//a//b' -> offset=0, matches `//`, keep `//`. offset=4, matches `//`, replace with `/` -> '//a/b'. + // Example: '///a' -> offset=0, matches `///`, keep `///`. -> '///a'. (Matches original regex behavior) + unixPath = unixPath.replace(/\/{2,}/g, (match, offset) => offset > 0 ? "/" : match) + + return unixPath +} + +/** + * Cleans up path segments, resolving '.' and '..'. + * @param segments Array of path segments (already split and filtered). + * @param isAbsolute Boolean indicating if the original path was absolute. + * @returns Array of cleaned segments. + */ +const _processSegments = (segments: string[], isAbsolute: boolean): string[] => { + const res: string[] = [] + + for (const segment of segments) { + if (segment === "." || segment === "") { + continue // Ignore '.' and empty segments + } + if (segment === "..") { + // Pop the last segment unless we are at the effective root + // Effective root for absolute paths is always '/', for relative paths it's the starting point. + // We can pop if there are segments in res AND the last segment isn't '..'. + // For absolute paths, we cannot pop if res is empty (already at root). + if (res.length > 0 && res[res.length - 1] !== "..") { + res.pop() + } else if (!isAbsolute) { + // If not absolute, allow '..' to go above the starting point + res.push("..") + } + // If isAbsolute is true and res is empty or ends in '..', we cannot go up, so just ignore the '..'. + } else { + res.push(segment) + } + } + + return res +} + + +/** + * Checks if a given extension is valid based on ignore list and max size. + * @param ext The extension string (e.g., '.js'). + * @param ignoreExts Array of extensions to ignore (e.g., ['js', '.txt']). + * @param maxSize Maximum allowed extension length. + * @returns True if the extension is valid, false otherwise. + */ +const isValidExt = (ext: string | undefined, ignoreExts: string[] = [], maxSize = 7): boolean => { + if (!ext) { + return false + } + + // Normalize ignoreExts to always start with '.' for comparison + const normalizedIgnoreExts = ignoreExts + .filter(e => !!e) // Filter out null/undefined/empty strings + .map(e => (e[0] !== "." ? "." : "") + e) + + return (ext.length <= maxSize) && !normalizedIgnoreExts.includes(ext) +} + + +// --- Core Path Function Implementations (Browser/Unix style) --- +// These replace the dependency on Node.js 'path'. + +const _isAbsolute = (p: string): boolean => { + if (!isString(p)) return false // Or throw? Node.js path expects string. + p = toUnix(p) // Ensure forward slashes for check + // Check for Unix root '/', UNC path start `//`, or Windows drive letter `C:/` + return p.length > 0 && + (p[0] === "/" || + (p.length > 1 && p[0] === "/" && p[1] === "/") || + /^[a-zA-Z]:\//.test(p)) // Windows drive letter check +} + + +const _normalize = (p: string): string => { + if (!isString(p)) { + return p as any + } + p = toUnix(p) + + if (p.length === 0) { + return "." + } + + const isAbsolute = p.startsWith("/") + const isUNC = p.startsWith("//") + // Keep track of trailing slash for non-root paths + const hasTrailingSlash = p.endsWith("/") && p.length > 1 + + // Split path into segments, filtering out empty ones. + // This gives us ['a', 'b'] for '/a//b/'. + const segments = p.split("/").filter(segment => segment !== "") + + // Process segments (resolve '.', '..') + const processedSegments = _processSegments(segments, isAbsolute) + + // Reconstruct the path + let result = processedSegments.join("/") + + // If originally absolute, prepend root + if (isAbsolute) { + if (isUNC) { + result = "//" + result + } else { + result = "/" + result + } + } + + // Handle edge case: absolute path normalized to empty segments (e.g. '/../') + // Should result in '/' or '//' for UNC paths + if (isAbsolute && result.length === 0) { + return isUNC ? "//" : "/" + } + + // Add trailing slash back based on original path AND if the result wasn't normalized to '.' + // Node.js posix trailing slash rule: preserve if original path had it AND result is not '.' AND result does not end with '/..' + // A simplified rule: preserve if original had it AND the result is not just '.' and not just '/' and not just '//' + if (hasTrailingSlash && result !== "." && result !== "/" && result !== "//") { + result += "/" + } + + // If the result is empty and it's not absolute, return '.' + if (result.length === 0 && !isAbsolute) { + return "." + } + + return result +} + + +// Node.js posix.join is essentially normalize(path1 + '/' + path2 + '/' + ...) +const _join = (...paths: string[]): string => { + // Convert all inputs to Unix style and filter out empty/invalid ones + const unixPaths = paths.map(p => isString(p) ? toUnix(p) : p).filter(p => isString(p) && p.length > 0) + + if (unixPaths.length === 0) { + return "." // Matches Node.js path.join('') -> '.' + } + + // Join all valid non-empty strings with a single slash + const joined = unixPaths.join("/") + + // Then normalize the result + return _normalize(joined) +} + + +const _dirname = (p: string): string => { + if (!isString(p)) return "." // Or throw? Node.js path expects string. + p = toUnix(p) + + if (p.length === 0) { + return "." + } + + // Remove trailing slashes unless it's the root '/' + let end = p.length + while (end > 1 && p[end - 1] === "/") { + end-- + } + p = p.substring(0, end) + + // Find the last slash + const lastSlash = p.lastIndexOf("/") + + if (lastSlash < 0) { + // No slash found, directory is '.' + return "." + } + + // Find the first non-slash character from the start (to detect root part like '/', '//') + let rootEnd = 0 + while (rootEnd < p.length && p[rootEnd] === "/") { + rootEnd++ + } + + // If the last slash is within or at the end of the root sequence, the dirname is the root. + // e.g., '/a' (lastSlash=0, rootEnd=1), '//a' (lastSlash=1, rootEnd=2) + if (lastSlash < rootEnd) { + return p.substring(0, rootEnd) // Return the root string (e.g., '/', '//') + } + + // Otherwise, the dirname is the part before the last slash + return p.substring(0, lastSlash) +} + +const _basename = (p: string, ext?: string): string => { + if (!isString(p)) return "" // Or throw? Node.js path expects string. + p = toUnix(p) + + if (p.length === 0) { + return "" + } + + // Remove trailing slashes + let end = p.length + while (end > 0 && p[end - 1] === "/") { + end-- + } + p = p.substring(0, end) + + // Find the last slash to get the base name + const lastSlash = p.lastIndexOf("/") + + let base = (lastSlash === -1) ? p : p.substring(lastSlash + 1) + + // If extension is provided and matches the end of the base name, remove it + // Node.js rule: only remove ext if base is longer than ext and the character before ext is NOT '.' + // A simpler rule (closer to Node.js): remove if `base` ends with `ext` AND base is not just `.` + if (ext && base.endsWith(ext) && base !== ".") { + // Ensure removing ext doesn't result in '.' from something like '.c' with ext '.c' + const baseWithoutExt = base.slice(0, -ext.length) + if (baseWithoutExt.length > 0 || ext === ".") { // Handles basename('.bashrc', '.rc') -> '.bashrc' + base = baseWithoutExt + } else if (ext.length === base.length) { // Handle basename('.c', '.c') -> '' + base = "" + } + + } + + return base +} + +const _extname = (p: string): string => { + if (!isString(p)) return "" // Or throw? Node.js path expects string. + p = toUnix(p) + + // Special case: empty string + if (p.length === 0) { + return "" + } + + // Remove trailing slashes + let end = p.length + while (end > 0 && p[end - 1] === "/") { + end-- + } + p = p.substring(0, end) + + // Find the last slash (directory separator) + const lastSlash = p.lastIndexOf("/") + // Find the last dot + const lastDot = p.lastIndexOf(".") + + // No dot or dot is before the last slash -> no extension + if (lastDot < 0 || lastDot < lastSlash) { + return "" + } + + // Handle dotfiles: if the dot is the first character of the basename + // e.g., '.bashrc', '.gitignore'. extname should be ''. + // The base name starts after the last slash. + const baseNameStart = lastSlash + 1 + // If lastDot is the first character of the basename AND the base name is not just '.' or '..' + if (lastDot === baseNameStart && p.substring(baseNameStart) !== "." && p.substring(baseNameStart) !== "..") { + return "" + } + + + // Extension is from the last dot to the end + return p.substring(lastDot) +} + +const _resolve = (...pathSegments: string[]): string => { + let resolvedPath = "" + let resolvedAbsolute = false + + // Iterate paths from right to left to find the last absolute path + for (let i = pathSegments.length - 1; i >= 0; i--) { + const path = toUnix(pathSegments[i]) // Ensure unix path + + if (path.length === 0) { + continue // Ignore empty segments + } + + resolvedPath = path + "/" + resolvedPath // Prepend the current segment + if (_isAbsolute(path)) { + resolvedAbsolute = true + break // Found the rightmost absolute path, stop combining further left + } + } + + // Remove trailing slash from combined path before normalization + // Important: Check resolvedPath length before slicing + if (resolvedPath.length > 1 && resolvedPath.endsWith("/")) { + resolvedPath = resolvedPath.slice(0, -1) + } else if (resolvedPath === "/") { + // Keep single slash if that's the whole path after loop + } else if (resolvedPath.endsWith("/") && resolvedPath.length > 0) { + // Remove trailing slash if path is not just '/' + resolvedPath = resolvedPath.slice(0, -1) + } + + + // If no absolute path was found, prepend the root '/' (simulating cwd) + if (!resolvedAbsolute) { + // Simulating resolving relative to root '/' + resolvedPath = "/" + resolvedPath + // Remove trailing slash again if added root results in e.g. "/path/" + if (resolvedPath.length > 1 && resolvedPath.endsWith("/")) { + resolvedPath = resolvedPath.slice(0, -1) + } + } + + + // Normalize the final path + const normalized = _normalize(resolvedPath) + + // If the result of normalizing an absolute path is '.', it should be '/' + // Also handle if normalization of an absolute path resulted in empty string (e.g. resolve('/','..')) + if ((normalized === "." || normalized === "") && resolvedAbsolute) { + return "/" + } + + // Ensure result starts with '/' if it was resolved as absolute + // _normalize should handle this, but as a safeguard + if (resolvedAbsolute && normalized.length > 0 && !normalized.startsWith("/")) { + return "/" + normalized + } + + + return normalized +} + +const _relative = (from: string, to: string): string => { + if (!isString(from) || !isString(to)) { + return "" // Return empty string for invalid inputs + } + + // Resolve both paths first + from = _resolve(from) + to = _resolve(to) + + if (from === to) { + return "" + } + + // Split resolved paths into segments, filtering out the initial root '/' + const fromParts = from.split("/").filter(p => p !== "") + const toParts = to.split("/").filter(p => p !== "") + + // Find the common prefix length + let commonLength = 0 + const maxLength = Math.min(fromParts.length, toParts.length) + while (commonLength < maxLength && fromParts[commonLength] === toParts[commonLength]) { + commonLength++ + } + + // Calculate 'up' moves needed from 'from' to the common prefix + const upMoves = fromParts.length - commonLength + const relativeParts: string[] = [] + + // Add '..' for each segment we need to go up + for (let i = 0; i < upMoves; i++) { + relativeParts.push("..") + } + + // Add segments from 'to' after the common prefix + for (let i = commonLength; i < toParts.length; i++) { + relativeParts.push(toParts[i]) + } + + // If the result is empty (e.g., relative('/a/b', '/a/b/c') when 'b' is common), + // and 'to' is not '/', it means the target was a child, return '.' if no parts added. + // Node.js relative('/a', '/a') -> '', relative('/a/b', '/a') -> '..', relative('/a', '/a/b') -> 'b' + // The logic above already handles these. If relativeParts is empty, from === to. + + if (relativeParts.length === 0) { + // This should only happen if from === to, which is handled at the beginning. + // As a fallback, return '.' if they weren't strictly equal but resulted in no relative path (e.g., different trailing slashes resolving the + // same) + return "." // This is different from Node.js relative which returns '' for identical resolved paths. + // Let's stick to the Node.js behavior and rely on from === to check. + // If from === to, return ''. Otherwise, relativeParts should not be empty unless one is ancestor of other resolving to empty relative part. + // E.g. relative('/a', '/a') -> '', relative('/a/', '/a') -> '' + // Let's remove the '.' fallback and trust the resolved paths logic. + return "" // Should not be reached if from !== to and relativeParts is empty. + } + + + return relativeParts.join("/") +} + + +// Helper for parse to get root, dir, base, ext, name +const _parse = (p: string): ParsedPath => { + if (!isString(p)) { + return { root: "", dir: "", base: "", ext: "", name: "" } + } + + p = toUnix(p) + + const result: ParsedPath = { root: "", dir: "", base: "", ext: "", name: "" } + + if (p.length === 0) { + return result + } + + let rest = p + + // 1. Root + if (rest.startsWith("//")) { + // UNC-like path start + // Node.js posix.parse('//server/share/dir/base') -> { root: '//', dir: '//server/share/dir', base: 'base', ... } + // The root is just '//' in posix. + result.root = "//" + rest = rest.substring(2) // Remove leading '//' + } else if (rest.startsWith("/")) { + // Regular absolute path + result.root = "/" + rest = rest.substring(1) // Remove leading '/' + } + + // Remove trailing slashes from the rest for consistent parsing of base/ext + let end = rest.length + while (end > 0 && rest[end - 1] === "/") { + end-- + } + const cleanedRest = rest.substring(0, end) + + + // 2. Base and Ext + const lastSlash = cleanedRest.lastIndexOf("/") + result.base = (lastSlash === -1) ? cleanedRest : cleanedRest.substring(lastSlash + 1) + + // Determine extname and name from base (using internal function) + const baseLastDot = result.base.lastIndexOf(".") + if (baseLastDot < 0 || baseLastDot === 0 || (baseLastDot === result.base.length - 1 && result.base.length > 1)) { // No dot, dot is first char, or dot is last char (e.g., 'file.') + result.ext = "" + result.name = result.base + } else { + result.ext = result.base.substring(baseLastDot) + result.name = result.base.substring(0, baseLastDot) + } + + // 3. Dir + if (lastSlash === -1) { + // No slash in the rest means the base is the only part after the root. + // The directory is just the root. + result.dir = result.root + // Node behavior: If root was empty, dir remains empty. + } else { + // The directory is the part of cleanedRest before the last slash, combined with the root. + result.dir = result.root + cleanedRest.substring(0, lastSlash) + } + + return result +} + +const _format = (pathObject: FormatInputPathObject): string => { + // Node.js path.format rules (posix): + // 1. If pathObject.base is provided, it takes precedence over pathObject.ext and pathObject.name. + // 2. If pathObject.base is not provided, pathObject.name and pathObject.ext are used. + // 3. pathObject.dir takes precedence over pathObject.root. + // 4. If pathObject.dir is provided: + // Result is dir + (dir ends with / ? '' : '/') + base. + // 5. If pathObject.dir is not provided, but pathObject.root is: + // Result is root + base. + // 6. If none of dir, root, or base are provided, result is '.' + + const root = pathObject.root || "" + const dir = pathObject.dir || "" + const base = pathObject.base ?? ((pathObject.name ?? "") + (pathObject.ext ?? "")) + + if (dir) { + // If dir is provided, root is effectively ignored for the prefix logic. + // Ensure dir is Unix style. + let unixDir = toUnix(dir) + + // Node behavior: If dir ends with a slash, keep it, otherwise don't add one. + + // Join dir and base, adding a slash ONLY if dir doesn't already end with one AND base exists. + if (base) { + if (unixDir.endsWith("/")) { + return unixDir + base + } else { + return unixDir + "/" + base + } + } else { + // If no base, result is just the dir. + return unixDir || "." // Handle empty dir becoming '.' + } + + } else if (root) { + // If dir is not provided, use root + base. + // Root should already be normalized ('/' or '//'). + // If base is empty, result is just root. + // If base is not empty, append it. Ensure base doesn't start with / if root is present. + let cleanedBase = base + while (cleanedBase.startsWith("/")) cleanedBase = cleanedBase.slice(1) + + return root + cleanedBase // Example: format({ root: '/', base: 'a' }) -> '/a' + + } else if (base) { + // Neither dir nor root provided, result is just base. + return base + } else { + // Nothing provided, return '.' + return "." + } + +} + + +// --- Initialize UPath Object --- + +// Create the internal object that will be the public API +const upath_internal: any = { + // Define VERSION (assuming VERSION is a global or module-scoped variable injected elsewhere) + // If VERSION is not injected, this will default to 'NO-VERSION' + VERSION: typeof (globalThis as any).VERSION !== "undefined" ? (globalThis as any).VERSION : "NO-VERSION", + sep: "/", // Explicitly set to Unix style + delimiter: ":", // Standard Posix delimiter +} + +// Assign the implemented core functions +upath_internal.join = _join +upath_internal.resolve = _resolve +upath_internal.normalize = _normalize +upath_internal.isAbsolute = _isAbsolute +upath_internal.dirname = _dirname +upath_internal.basename = _basename +upath_internal.extname = _extname +upath_internal.format = _format +upath_internal.parse = _parse +upath_internal.relative = _relative + + +// --- Extra Functions --- + +const extraFunctions = { + + // Include the internal helper for external use + toUnix: toUnix, + + /** + * Normalizes a path safely using upath.normalize. + * Attempts to restore './' or '//' prefixes if lost during normalization. + * Note: The logic for restoring '//' might be specific or potentially buggy + * depending on the exact scenarios it was designed for, but it matches + * the original source code's behavior. + * @param p The path string. + * @returns The normalized path string with prefixes potentially restored. + */ + normalizeSafe: (p: string): string => { + // Ensure input is string for toUnix, though the interface suggests string input + const originalP = isString(p) ? toUnix(p) : p + if (!isString(originalP)) { + return p // Cannot normalize non-string + } + + // Use the implemented normalize function + const result = upath_internal.normalize(originalP) + + // Restore './' prefix if original started with it, result doesn't, and isn't '..' or '/' + // Check !upath_internal.isAbsolute(result) added to avoid './' for absolute paths. + if (originalP.startsWith("./") && !result.startsWith("./") && !upath_internal.isAbsolute(result) && result !== "..") { + return "./" + result + } + // Special case: Handle "//./..." paths separately to preserve the "//." prefix + else if (originalP.startsWith("//./")) { + // Remove the leading "//" from result before prepending "//." + const resultWithoutLeadingSlashes = result.startsWith("//") ? result.substring(2) : result.startsWith("/") ? result.substring(1) : result + return "//." + (resultWithoutLeadingSlashes ? "/" + resultWithoutLeadingSlashes : "") + } + // Restore '//' prefix if original started with it and result lost it or changed it + else if (originalP.startsWith("//") && !result.startsWith("//")) { + return "/" + result + } + // Otherwise, return the result from upath.normalize + return result + }, + + /** + * Normalizes a path safely using normalizeSafe, then removes a trailing slash + * unless the path is the root ('/'). + * @param p The path string. + * @returns The normalized path string without a trailing slash (unless root). + */ + normalizeTrim: (p: string): string => { + p = upath_internal.normalizeSafe(p) + // Remove trailing slash, unless it's the root '/' + if (p.endsWith("/") && p.length > 1) { + return p.slice(0, p.length - 1) + } else { + return p + } + }, + + /** + * Joins path segments safely. Calls upath.join, then attempts to restore + * './' or '//' prefix based on the first original path segment if it was lost. + * Note: The logic for restoring '//' might be specific or potentially buggy + * depending on the exact scenarios it was designed for, but it matches + * the original source code's behavior. + * @param p Path segments to join. Accepts string[] as per join signature. + * @returns The joined path string with prefixes potentially restored. + */ + joinSafe: (...p: string[]): string => { + // Get the original first argument (before toUnix conversion in join) + const p0Original = p.length > 0 ? p[0] : undefined + + // Use the implemented join function + const result = upath_internal.join.apply(null, p) + + // Apply prefix restore logic based on original first argument (if it was a string) + if (p.length > 0 && isString(p0Original)) { + // Convert original first arg to Unix style for prefix checks + const p0Unix = toUnix(p0Original) + + // Restore './' prefix if original first arg started with it, result doesn't, and isn't '..' or '/' + if (p0Unix.startsWith("./") && !result.startsWith("./") && !upath_internal.isAbsolute(result) && result !== "..") { + return "./" + result + } + // Restore '//' prefix if original first arg started with it and result lost it or changed it + else if (p0Unix.startsWith("//") && !result.startsWith("//")) { + if (p0Unix.startsWith("//./")) { + return "//." + result + } else { + return "/" + result + } + } + } + // Otherwise, return the result from upath.join + return result + }, + + /** + * Adds an extension to a filename if it doesn't already have it. + * Ensures the added extension starts with '.'. + * @param file The filename. + * @param ext The extension to add (e.g., 'js' or '.js'). + * @returns The filename with the extension added. + */ + addExt: (file: string, ext?: string): string => { + if (!ext) { + return file + } else { + // Ensure extension starts with '.' + ext = (ext[0] !== "." ? "." : "") + ext + // Check if file already ends with the exact extension case-sensitively + if (file.endsWith(ext)) { + return file + } else { + return file + ext + } + } + }, + + /** + * Removes the extension from a filename *only if* it is considered a valid + * extension based on the ignore list and max size. + * @param filename The filename. + * @param ignoreExts Array of extensions to ignore (e.g., ['js', '.txt']). + * @param maxSize Maximum allowed extension length. + * @returns The filename without the valid extension, or the original filename. + */ + trimExt: (filename: string, ignoreExts?: string[], maxSize?: number): string => { + const oldExt = upath_internal.extname(filename) // Use the implemented extname + if (isValidExt(oldExt, ignoreExts, maxSize)) { + // Remove the extension part by slicing before where the extension starts + return filename.slice(0, filename.length - oldExt.length) + } else { + return filename // No valid extension to trim, return original filename + } + }, + + /** + * Removes a *specific* extension from a filename *only if* it matches + * the current extension exactly (case-sensitively). + * @param filename The filename. + * @param ext The specific extension to remove (e.g., 'js' or '.js'). + * @returns The filename without the specified extension, or the original filename. + */ + removeExt: (filename: string, ext?: string): string => { + if (!ext) { + return filename + } else { + // Ensure the target extension starts with '.' + ext = (ext[0] === "." ? ext : "." + ext) + // Use implemented extname for consistency in checking + if (upath_internal.extname(filename) === ext) { + // If the current extension matches the target extension, remove it + return filename.slice(0, filename.length - ext.length) + } else { + return filename // Current extension does not match the target extension, return original + } + } + }, + + /** + * Changes the extension of a filename. It first removes the existing + * extension (if it's considered valid by trimExt rules), then adds the new one. + * @param filename The filename. + * @param ext The new extension (e.g., 'js' or '.js'). Can be empty to remove extension. + * @param ignoreExts Array of extensions to ignore for trimming the old one. + * @param maxSize Maximum allowed extension length for trimming the old one. + * @returns The filename with the extension changed. + */ + changeExt: (filename: string, ext?: string, ignoreExts?: string[], maxSize?: number): string => { + // Trim the existing valid extension first using trimExt logic + const trimmed = upath_internal.trimExt(filename, ignoreExts, maxSize) + // Add the new extension if specified + if (!ext) { + return trimmed // No new extension specified, just return trimmed filename + } else { + // Ensure the new extension starts with '.' + ext = (ext[0] === "." ? ext : "." + ext) + return trimmed + ext // Append the new extension + } + }, + + /** + * Adds a default extension to a filename *only if* the existing extension + * is *not* considered valid based on ignore list and max size. + * @param filename The filename. + * @param ext The default extension to add (e.g., 'js' or '.js'). + * @param ignoreExts Array of extensions to ignore for checking validity. + * @param maxSize Maximum allowed extension length for checking validity. + * @returns The filename with the default extension added if needed, or the original filename. + */ + defaultExt: (filename: string, ext?: string, ignoreExts?: string[], maxSize?: number): string => { + const oldExt = upath_internal.extname(filename) // Use implemented extname + // If existing extension is NOT valid, add the default extension + if (!isValidExt(oldExt, ignoreExts, maxSize)) { + return upath_internal.addExt(filename, ext) // Use addExt to ensure format is correct + } else { + return filename // Existing valid extension found, return original filename + } + }, +} + +// Add extra functions to upath_internal, checking for name conflicts +for (const name in extraFunctions) { + if (Object.prototype.hasOwnProperty.call(extraFunctions, name)) { + const extraFn = (extraFunctions as any)[name] + + if (upath_internal[name] !== undefined) { + // Throw an error if the name already exists on upath_internal + throw new Error(`path.${name} already exists.`) + } else { + // Assign the extra function + upath_internal[name] = extraFn + } + } +} + +// Export the internal object, casting it to the UPath interface for correct typing on export. +export const upath: UPath = upath_internal as UPath diff --git a/seanime-2.9.10/seanime-web/src/lib/path-utils.ts b/seanime-2.9.10/seanime-web/src/lib/path-utils.ts new file mode 100644 index 0000000..dac4500 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/path-utils.ts @@ -0,0 +1,39 @@ +import path from "path-browserify" + +/** + * A replacement for upath that doesn't use lookbehind regex + * This utility provides cross-platform path manipulation functions + */ + +/** + * Normalizes a path, converting backslashes to forward slashes + */ +export function normalize(pathStr: string): string { + return path.normalize(pathStr) +} + +export function normalizeSafe(pathStr: string): string { + if (!pathStr) return "" + return path.normalize(pathStr) +} + +/** + * Joins path segments together and normalizes the result + */ +export function join(...paths: string[]): string { + return path.join(...paths) +} + +/** + * Returns the directory name of a path + */ +export function dirname(pathStr: string): string { + return path.dirname(pathStr) +} + +/** + * Returns the last portion of a path + */ +export function basename(pathStr: string): string { + return path.basename(pathStr) +} diff --git a/seanime-2.9.10/seanime-web/src/lib/server/assets.ts b/seanime-2.9.10/seanime-web/src/lib/server/assets.ts new file mode 100644 index 0000000..ba6555b --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/server/assets.ts @@ -0,0 +1,37 @@ +import { getServerBaseUrl } from "@/api/client/server-url" + +export function getImageUrl(path: string) { + if (path.startsWith("{{LOCAL_ASSETS}}")) { + return `${getServerBaseUrl()}/${path.replace("{{LOCAL_ASSETS}}", "offline-assets")}` + } + + return path +} + +export function getAssetUrl(path: string) { + let p = path.replaceAll("\\", "/") + + if (p.startsWith("/")) { + p = p.substring(1) + } + + p = encodeURIComponent(p).replace(/\(/g, "%28").replace(/\)/g, "%29") + + if (p.startsWith("{{LOCAL_ASSETS}}")) { + return `${getServerBaseUrl()}/${p.replace("{{LOCAL_ASSETS}}", "offline-assets")}` + } + + return `${getServerBaseUrl()}/assets/${p}` +} + +export function legacy_getAssetUrl(path: string) { + let p = path.replaceAll("\\", "/") + + if (p.startsWith("/")) { + p = p.substring(1) + } + + p = encodeURIComponent(p).replace(/\(/g, "%28").replace(/\)/g, "%29") + + return `${getServerBaseUrl()}/assets/${p}` +} diff --git a/seanime-2.9.10/seanime-web/src/lib/server/config.ts b/seanime-2.9.10/seanime-web/src/lib/server/config.ts new file mode 100644 index 0000000..c7e052a --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/server/config.ts @@ -0,0 +1,6 @@ +export const ANILIST_OAUTH_URL = `https://anilist.co/api/v2/oauth/authorize?client_id=15168&response_type=token` +export const ANILIST_PIN_URL = `https://anilist.co/api/v2/oauth/authorize?client_id=13985&response_type=token` +export const MAL_CLIENT_ID = `51cb4294feb400f3ddc66a30f9b9a00f` +export const __DEV_SERVER_PORT = 43000 +export const TESTONLY__DEV_SERVER_PORT2 = 43001 +export const TESTONLY__DEV_SERVER_PORT3 = 43002 diff --git a/seanime-2.9.10/seanime-web/src/lib/server/hmac-auth.ts b/seanime-2.9.10/seanime-web/src/lib/server/hmac-auth.ts new file mode 100644 index 0000000..fbb6432 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/server/hmac-auth.ts @@ -0,0 +1,67 @@ +import * as CryptoJS from "crypto-js" + +interface TokenClaims { + endpoint: string + iat: number // issued at (unix timestamp) + exp: number // expires at (unix timestamp) +} + +class HMACAuth { + private secret: string + private ttl: number + + constructor(secret: string, ttl: number) { + this.secret = secret + this.ttl = ttl + } + + async generateToken(endpoint: string): Promise<string> { + const now = Math.floor(Date.now() / 1000) + const claims: TokenClaims = { + endpoint, + iat: now, + exp: now + this.ttl, + } + + const claimsJSON = JSON.stringify(claims) + + // Encode claims as base64 + const claimsB64 = btoa(claimsJSON) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, "") + + // Generate HMAC signature + const signature = await this.generateHMACSignature(claimsB64) + + // Return token in format: claims.signature + return `${claimsB64}.${signature}` + } + + generateQueryParam(endpoint: string, symbol?: string): Promise<string> { + return this.generateToken(endpoint).then(token => { + const sym = symbol || "?" + return `${sym}token=${encodeURIComponent(token)}` + }) + } + + private async generateHMACSignature(data: string): Promise<string> { + const signature = CryptoJS.HmacSHA256(data, this.secret) + + const base64 = CryptoJS.enc.Base64.stringify(signature) + return base64 + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, "") + } +} + +// HMAC auth instance using server password (for server endpoints) +export function createServerPasswordHMACAuth(password: string): HMACAuth { + return new HMACAuth(password, 24 * 60 * 60) +} + +// HMAC auth instance using Nakama password (for Nakama endpoints) +export function createNakamaHMACAuth(nakamaPassword: string): HMACAuth { + return new HMACAuth(nakamaPassword, 24 * 60 * 60) +} diff --git a/seanime-2.9.10/seanime-web/src/lib/server/queries.types.ts b/seanime-2.9.10/seanime-web/src/lib/server/queries.types.ts new file mode 100644 index 0000000..43f926d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/server/queries.types.ts @@ -0,0 +1,5 @@ +export type SeaErrorResponse = { error: string } +export type SeaDataResponse<T> = { data: T | undefined } +export type SeaResponse<T> = SeaDataResponse<T> | SeaErrorResponse +export type SeaWebsocketEvent<T> = { type: string, payload: T } +export type SeaWebsocketPluginEvent<T> = { type: string, extensionId: string, payload: T } \ No newline at end of file diff --git a/seanime-2.9.10/seanime-web/src/lib/server/settings.ts b/seanime-2.9.10/seanime-web/src/lib/server/settings.ts new file mode 100644 index 0000000..bf150c3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/server/settings.ts @@ -0,0 +1,269 @@ +import { GettingStarted_Variables } from "@/api/generated/endpoint.types" +import { z } from "zod" + +export const DEFAULT_TORRENT_PROVIDER = "animetosho" + +export const DEFAULT_TORRENT_CLIENT = "qbittorrent" + +export const DEFAULT_DOH_PROVIDER = "" + +export const DEFAULT_MPV_TYPE = "socket" + +export const enum TORRENT_CLIENT { + QBITTORRENT = "qbittorrent", + TRANSMISSION = "transmission", + NONE = "none", +} + +export const enum TORRENT_PROVIDER { + ANIMETOSHO = "animetosho", + NYAA = "nyaa", + NYAA_NON_ENG = "nyaa-non-eng", + NONE = "none", +} + +export const _gettingStartedSchema = z.object({ + enableTranscode: z.boolean().optional().default(false), + enableTorrentStreaming: z.boolean().optional().default(false), + debridProvider: z.string().optional().default("none"), + debridApiKey: z.string().optional().default(""), +}) + +export const settingsSchema = z.object({ + libraryPath: z.string().optional().default(""), + defaultPlayer: z.string(), + torrentProvider: z.string().default(DEFAULT_TORRENT_PROVIDER), + autoScan: z.boolean().optional().default(false), + mediaPlayerHost: z.string(), + vlcUsername: z.string().optional().default(""), + vlcPassword: z.string().optional().default(""), + vlcPort: z.number(), + vlcPath: z.string().optional().default(""), + mpcPort: z.number(), + mpcPath: z.string().optional().default(""), + mpvSocket: z.string().optional().default(""), + mpvPath: z.string().optional().default(""), + mpvArgs: z.string().optional().default(""), + iinaSocket: z.string().optional().default(""), + iinaPath: z.string().optional().default(""), + iinaArgs: z.string().optional().default(""), + defaultTorrentClient: z.string().optional().default(DEFAULT_TORRENT_CLIENT), + hideTorrentList: z.boolean().optional().default(false), + qbittorrentPath: z.string().optional().default(""), + qbittorrentHost: z.string().optional().default(""), + qbittorrentPort: z.number(), + qbittorrentUsername: z.string().optional().default(""), + qbittorrentPassword: z.string().optional().default(""), + qbittorrentTags: z.string().optional().default(""), + transmissionPath: z.string().optional().default(""), + transmissionHost: z.string().optional().default(""), + transmissionPort: z.number().optional().default(9091), + transmissionUsername: z.string().optional().default(""), + transmissionPassword: z.string().optional().default(""), + hideAudienceScore: z.boolean().optional().default(false), + autoUpdateProgress: z.boolean().optional().default(false), + disableUpdateCheck: z.boolean().optional().default(false), + enableOnlinestream: z.boolean().optional().default(false), + includeOnlineStreamingInLibrary: z.boolean().optional().default(false), + disableAnimeCardTrailers: z.boolean().optional().default(false), + enableManga: z.boolean().optional().default(true), + mangaLocalSourceDirectory: z.string().optional().default(""), + enableRichPresence: z.boolean().optional().default(false), + enableAnimeRichPresence: z.boolean().optional().default(false), + enableMangaRichPresence: z.boolean().optional().default(false), + enableAdultContent: z.boolean().optional().default(false), + blurAdultContent: z.boolean().optional().default(false), + dohProvider: z.string().optional().default(""), + openTorrentClientOnStart: z.boolean().optional().default(false), + openWebURLOnStart: z.boolean().optional().default(false), + refreshLibraryOnStart: z.boolean().optional().default(false), + richPresenceHideSeanimeRepositoryButton: z.boolean().optional().default(false), + richPresenceShowAniListMediaButton: z.boolean().optional().default(false), + richPresenceShowAniListProfileButton: z.boolean().optional().default(false), + richPresenceUseMediaTitleStatus: z.boolean().optional().default(true), + disableNotifications: z.boolean().optional().default(false), + disableAutoDownloaderNotifications: z.boolean().optional().default(false), + disableAutoScannerNotifications: z.boolean().optional().default(false), + defaultMangaProvider: z.string().optional().default(""), + mangaAutoUpdateProgress: z.boolean().optional().default(false), + autoPlayNextEpisode: z.boolean().optional().default(false), + showActiveTorrentCount: z.boolean().optional().default(false), + enableWatchContinuity: z.boolean().optional().default(false), + libraryPaths: z.array(z.string()).optional().default([]), + autoSyncOfflineLocalData: z.boolean().optional().default(false), + scannerMatchingThreshold: z.number().optional().default(0.5), + scannerMatchingAlgorithm: z.string().optional().default(""), + autoSyncToLocalAccount: z.boolean().optional().default(false), + nakamaIsHost: z.boolean().optional().default(false), + nakamaHostPassword: z.string().optional().default(""), + nakamaRemoteServerURL: z.string().optional().default(""), + nakamaRemoteServerPassword: z.string().optional().default(""), + nakamaHostShareLocalAnimeLibrary: z.boolean().optional().default(false), + nakamaEnabled: z.boolean().optional().default(false), + nakamaHostEnablePortForwarding: z.boolean().optional().default(false), + nakamaUsername: z.string().optional().default(""), + includeNakamaAnimeLibrary: z.boolean().optional().default(false), + nakamaHostUnsharedAnimeIds: z.array(z.number()).optional().default([]), + autoSaveCurrentMediaOffline: z.boolean().optional().default(false), +}) + +export const gettingStartedSchema = _gettingStartedSchema.extend(settingsSchema.shape) + +export const getDefaultSettings = (data: z.infer<typeof gettingStartedSchema>): GettingStarted_Variables => ({ + library: { + libraryPath: data.libraryPath, + autoUpdateProgress: true, + disableUpdateCheck: false, + torrentProvider: data.torrentProvider || DEFAULT_TORRENT_PROVIDER, + autoScan: false, + disableAnimeCardTrailers: false, + enableManga: data.enableManga, + enableOnlinestream: data.enableOnlinestream, + dohProvider: DEFAULT_DOH_PROVIDER, + openTorrentClientOnStart: false, + openWebURLOnStart: false, + refreshLibraryOnStart: false, + autoPlayNextEpisode: false, + enableWatchContinuity: data.enableWatchContinuity, + libraryPaths: [], + autoSyncOfflineLocalData: false, + includeOnlineStreamingInLibrary: false, + scannerMatchingThreshold: 0, + scannerMatchingAlgorithm: "", + autoSyncToLocalAccount: false, + autoSaveCurrentMediaOffline: false, + }, + nakama: { + enabled: false, + isHost: false, + hostPassword: "", + remoteServerURL: "", + remoteServerPassword: "", + hostShareLocalAnimeLibrary: false, + username: data.nakamaUsername, + includeNakamaAnimeLibrary: false, + hostUnsharedAnimeIds: [], + hostEnablePortForwarding: false, + }, + manga: { + defaultMangaProvider: "", + mangaAutoUpdateProgress: false, + mangaLocalSourceDirectory: "", + }, + mediaPlayer: { + host: data.mediaPlayerHost, + defaultPlayer: data.defaultPlayer, + vlcPort: data.vlcPort, + vlcUsername: data.vlcUsername || "", + vlcPassword: data.vlcPassword, + vlcPath: data.vlcPath || "", + mpcPort: data.mpcPort, + mpcPath: data.mpcPath || "", + mpvSocket: data.mpvSocket || "", + mpvPath: data.mpvPath || "", + mpvArgs: "", + iinaSocket: data.iinaSocket || "", + iinaPath: data.iinaPath || "", + iinaArgs: "", + }, + discord: { + enableRichPresence: data.enableRichPresence, + enableAnimeRichPresence: true, + enableMangaRichPresence: true, + richPresenceHideSeanimeRepositoryButton: false, + richPresenceShowAniListMediaButton: false, + richPresenceShowAniListProfileButton: false, + richPresenceUseMediaTitleStatus: true, + }, + torrent: { + defaultTorrentClient: data.defaultTorrentClient, + qbittorrentPath: data.qbittorrentPath, + qbittorrentHost: data.qbittorrentHost, + qbittorrentPort: data.qbittorrentPort, + qbittorrentPassword: data.qbittorrentPassword, + qbittorrentUsername: data.qbittorrentUsername, + qbittorrentTags: "", + transmissionPath: data.transmissionPath, + transmissionHost: data.transmissionHost, + transmissionPort: data.transmissionPort, + transmissionUsername: data.transmissionUsername, + transmissionPassword: data.transmissionPassword, + showActiveTorrentCount: false, + hideTorrentList: false, + }, + anilist: { + hideAudienceScore: false, + enableAdultContent: data.enableAdultContent, + blurAdultContent: false, + }, + enableTorrentStreaming: data.enableTorrentStreaming, + enableTranscode: data.enableTranscode, + notifications: { + disableNotifications: false, + disableAutoDownloaderNotifications: false, + disableAutoScannerNotifications: false, + }, + debridProvider: data.debridProvider, + debridApiKey: data.debridApiKey, +}) + + +export function useDefaultSettingsPaths() { + + return { + getDefaultVlcPath: (os: string) => { + switch (os) { + case "windows": + return "C:\\Program Files\\VideoLAN\\VLC\\vlc.exe" + case "linux": + return "/usr/bin/vlc" // Default path for VLC on most Linux distributions + case "darwin": + return "/Applications/VLC.app/Contents/MacOS/VLC" // Default path for VLC on macOS + default: + return "C:\\Program Files\\VideoLAN\\VLC\\vlc.exe" + } + }, + getDefaultQBittorrentPath: (os: string) => { + switch (os) { + case "windows": + return "C:/Program Files/qBittorrent/qbittorrent.exe" + case "linux": + return "/usr/bin/qbittorrent" // Default path for Client on most Linux distributions + case "darwin": + return "/Applications/qbittorrent.app/Contents/MacOS/qbittorrent" // Default path for Client on macOS + default: + return "C:/Program Files/qBittorrent/qbittorrent.exe" + } + }, + getDefaultTransmissionPath: (os: string) => { + switch (os) { + case "windows": + return "C:/Program Files/Transmission/transmission-qt.exe" + case "linux": + return "/usr/bin/transmission-gtk" + case "darwin": + return "/Applications/Transmission.app/Contents/MacOS/Transmission" + default: + return "C:/Program Files/Transmission/transmission-qt.exe" + } + }, + } + +} + +export function getDefaultMpvSocket(os: string) { + switch (os) { + case "windows": + return "\\\\.\\pipe\\mpv_ipc" + case "linux": + return "/tmp/mpv_socket" // Default socket for VLC on most Linux distributions + case "darwin": + return "/tmp/mpv_socket" // Default socket for VLC on macOS + default: + return "/tmp/mpv_socket" + } +} + +export function getDefaultIinaSocket(os: string) { + return "/tmp/iina_socket" +} diff --git a/seanime-2.9.10/seanime-web/src/lib/server/utils.ts b/seanime-2.9.10/seanime-web/src/lib/server/utils.ts new file mode 100644 index 0000000..a54633f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/server/utils.ts @@ -0,0 +1,26 @@ +import capitalize from "lodash/capitalize" + +export function getLibraryCollectionTitle(type?: string) { + switch (type) { + case "CURRENT": + return "Currently watching" + default: + return capitalize(type ?? "") + } +} + +export function getMangaCollectionTitle(type?: string) { + switch (type) { + case "CURRENT": + return "Currently reading" + default: + return capitalize(type ?? "") + } +} + +export function formatDateAndTimeShort(date: string) { + return new Date(date).toLocaleString("en-US", { + dateStyle: "short", + timeStyle: "short", + }) +} diff --git a/seanime-2.9.10/seanime-web/src/lib/server/ws-events.ts b/seanime-2.9.10/seanime-web/src/lib/server/ws-events.ts new file mode 100644 index 0000000..4f9eaa3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/server/ws-events.ts @@ -0,0 +1,72 @@ +export const enum WSEvents { + ANILIST_DATA_LOADED = "server-ready", + SCAN_PROGRESS = "scan-progress", + SCAN_STATUS = "scan-status", + REFRESHED_ANILIST_ANIME_COLLECTION = "refreshed-anilist-anime-collection", + REFRESHED_ANILIST_MANGA_COLLECTION = "refreshed-anilist-manga-collection", + LIBRARY_WATCHER_FILE_ADDED = "library-watcher-file-added", + LIBRARY_WATCHER_FILE_REMOVED = "library-watcher-file-removed", + AUTO_DOWNLOADER_ITEM_ADDED = "auto-downloader-item-added", + AUTO_SCAN_STARTED = "auto-scan-started", + AUTO_SCAN_COMPLETED = "auto-scan-completed", + PLAYBACK_MANAGER_PROGRESS_TRACKING_STARTED = "playback-manager-progress-tracking-started", + PLAYBACK_MANAGER_PROGRESS_TRACKING_STOPPED = "playback-manager-progress-tracking-stopped", + PLAYBACK_MANAGER_PROGRESS_VIDEO_COMPLETED = "playback-manager-progress-video-completed", + PLAYBACK_MANAGER_PROGRESS_PLAYBACK_STATE = "playback-manager-progress-playback-state", + PLAYBACK_MANAGER_PROGRESS_UPDATED = "playback-manager-progress-updated", + PLAYBACK_MANAGER_PLAYLIST_STATE = "playback-manager-playlist-state", + PLAYBACK_MANAGER_MANUAL_TRACKING_PLAYBACK_STATE = "playback-manager-manual-tracking-playback-state", + EXTERNAL_PLAYER_OPEN_URL = "external-player-open-url", + PLAYBACK_MANAGER_MANUAL_TRACKING_STOPPED = "playback-manager-manual-tracking-stopped", + ERROR_TOAST = "error-toast", + SUCCESS_TOAST = "success-toast", + INFO_TOAST = "info-toast", + WARNING_TOAST = "warning-toast", + REFRESHED_MANGA_DOWNLOAD_DATA = "refreshed-manga-download-data", + CHAPTER_DOWNLOAD_QUEUE_UPDATED = "chapter-download-queue-updated", + OFFLINE_SNAPSHOT_CREATED = "offline-snapshot-created", + MEDIASTREAM_SHUTDOWN_STREAM = "mediastream-shutdown-stream", + EXTENSIONS_RELOADED = "extensions-reloaded", + EXTENSION_UPDATES_FOUND = "extension-updates-found", + PLUGIN_UNLOADED = "plugin-unloaded", + PLUGIN_LOADED = "plugin-loaded", + ACTIVE_TORRENT_COUNT_UPDATED = "active-torrent-count-updated", + SYNC_LOCAL_QUEUE_STATE = "sync-local-queue-state", + SYNC_LOCAL_FINISHED = "sync-local-finished", + SYNC_ANILIST_FINISHED = "sync-anilist-finished", + TORRENTSTREAM_STATE = "torrentstream-state", + DEBRID_DOWNLOAD_PROGRESS = "debrid-download-progress", + DEBRID_STREAM_STATE = "debrid-stream-state", + CHECK_FOR_UPDATES = "check-for-updates", + CHECK_FOR_ANNOUNCEMENTS = "check-for-announcements", + INVALIDATE_QUERIES = "invalidate-queries", + CONSOLE_LOG = "console-log", + CONSOLE_WARN = "console-warn", + NATIVE_PLAYER = "native-player", + NAKAMA_HOST_STARTED = "nakama-host-started", + NAKAMA_HOST_STOPPED = "nakama-host-stopped", + NAKAMA_PEER_CONNECTED = "nakama-peer-connected", + NAKAMA_PEER_DISCONNECTED = "nakama-peer-disconnected", + NAKAMA_HOST_CONNECTED = "nakama-host-connected", + NAKAMA_HOST_DISCONNECTED = "nakama-host-disconnected", + NAKAMA_ERROR = "nakama-error", + NAKAMA_STATUS_REQUESTED = "nakama-status-requested", + NAKAMA_STATUS = "nakama-status", + NAKAMA_WATCH_PARTY_STATE = "nakama-watch-party-state", + NAKAMA_WATCH_PARTY_CREATED = "nakama-watch-party-created", + NAKAMA_WATCH_PARTY_JOINED = "nakama-watch-party-joined", + NAKAMA_WATCH_PARTY_LEFT = "nakama-watch-party-left", + NAKAMA_WATCH_PARTY_STOPPED = "nakama-watch-party-stopped", + NAKAMA_WATCH_PARTY_PLAYBACK_INFO = "nakama-watch-party-playback-info", + NAKAMA_WATCH_PARTY_PLAYBACK_STATUS = "nakama-watch-party-playback-status", + NAKAMA_WATCH_PARTY_ENABLE_RELAY_MODE = "nakama-watch-party-enable-relay-mode", + NAKAMA_WATCH_PARTY_RELAY_MODE_TOGGLE_SHARE_LIBRARY_WITH_ORIGIN = "nakama-watch-party-relay-mode-toggle-share-library-with-origin", + SHOW_INDEFINITE_LOADER = "show-indefinite-loader", + HIDE_INDEFINITE_LOADER = "hide-indefinite-loader", + NAKAMA_ONLINE_STREAM_EVENT = "nakama-online-stream-event", + NAKAMA_ONLINE_STREAM_CLIENT_EVENT = "nakama-online-stream-client-event", +} + +export const enum WebviewEvents { + ANIME_ENTRY_PAGE_VIEWED = "anime-entry-page-viewed", +} diff --git a/seanime-2.9.10/seanime-web/src/lib/theme/hooks.ts b/seanime-2.9.10/seanime-web/src/lib/theme/hooks.ts new file mode 100644 index 0000000..43785cd --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/theme/hooks.ts @@ -0,0 +1,248 @@ +import { Models_Theme } from "@/api/generated/types" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import React from "react" +import { useWindowSize } from "react-use" + +export const enum ThemeLibraryScreenBannerType { + Dynamic = "dynamic", + Custom = "custom", +} + +export const enum ThemeMediaPageBannerType { + Default = "default", + BlurWhenUnavailable = "blur-when-unavailable", + DimWhenUnavailable = "dim-when-unavailable", + HideWhenUnavailable = "hide-when-unavailable", + Blur = "blur", + Dim = "dim", + Hide = "hide", +} + +export const ThemeMediaPageBannerTypeOptions = [ + { + value: ThemeMediaPageBannerType.Default as string, label: "Default", + description: "Always show a banner image. If not available, the cover image will be used instead.", + }, + // { + // value: ThemeMediaPageBannerType.BlurWhenUnavailable as string, label: "Blur when unavailable", + // description: "Show the banner image if available. If not available, the cover image will be used and blurred.", + // }, + { + value: ThemeMediaPageBannerType.DimWhenUnavailable as string, label: "Dim if unavailable", + description: "Show the banner image if available. If not available, the banner will be dimmed.", + }, + { + value: ThemeMediaPageBannerType.HideWhenUnavailable as string, label: "Hide if unavailable", + description: "Show the banner image if available. If not available, the banner will be hidden.", + }, + { + value: ThemeMediaPageBannerType.Dim as string, label: "Dim", + description: "Always dim the banner image.", + }, + { + value: ThemeMediaPageBannerType.Blur as string, label: "Blur", + description: "Always blur the banner image.", + }, + { + value: ThemeMediaPageBannerType.Hide as string, label: "Hide", + description: "Always hide the banner image.", + }, +] + +export const enum ThemeMediaPageBannerSize { + Default = "default", // block height + Small = "small", +} + +export const ThemeMediaPageBannerSizeOptions = [ + { + value: ThemeMediaPageBannerSize.Default as string, label: "Large", + description: "Fill a large portion of the screen.", + }, + { + value: ThemeMediaPageBannerSize.Small as string, label: "Smaller", + description: "Use a smaller banner size, displaying more of the image.", + }, +] + +export const enum ThemeMediaPageInfoBoxSize { + // Default = "default", + Fluid = "fluid", + Boxed = "boxed", +} + +export const ThemeMediaPageInfoBoxSizeOptions = [ + { + value: ThemeMediaPageInfoBoxSize.Fluid as string, label: "Fluid", + // description: "Full-width info box with rearrangement of elements.", + }, + { + value: ThemeMediaPageInfoBoxSize.Boxed as string, label: "Boxed", + // description: "Display the media banner as a box", + }, +] + +export type ThemeSettings = Omit<Models_Theme, "id"> +export const THEME_DEFAULT_VALUES: ThemeSettings = { + enableColorSettings: false, + animeEntryScreenLayout: "stacked", + smallerEpisodeCarouselSize: false, + expandSidebarOnHover: false, + backgroundColor: "#070707", + accentColor: "#6152df", + sidebarBackgroundColor: "#070707", + hideTopNavbar: false, + enableMediaCardBlurredBackground: false, + libraryScreenBannerType: ThemeLibraryScreenBannerType.Dynamic, + libraryScreenCustomBannerImage: "", + libraryScreenCustomBannerPosition: "50% 50%", + libraryScreenCustomBannerOpacity: 100, + libraryScreenCustomBackgroundImage: "", + libraryScreenCustomBackgroundOpacity: 10, + disableLibraryScreenGenreSelector: false, + libraryScreenCustomBackgroundBlur: "", + enableMediaPageBlurredBackground: false, + disableSidebarTransparency: false, + useLegacyEpisodeCard: false, + disableCarouselAutoScroll: false, + mediaPageBannerType: ThemeMediaPageBannerType.Default, + mediaPageBannerSize: ThemeMediaPageBannerSize.Default, + mediaPageBannerInfoBoxSize: ThemeMediaPageInfoBoxSize.Fluid, + showEpisodeCardAnimeInfo: false, + continueWatchingDefaultSorting: "AIRDATE_DESC", + animeLibraryCollectionDefaultSorting: "TITLE", + mangaLibraryCollectionDefaultSorting: "TITLE", + showAnimeUnwatchedCount: false, + showMangaUnreadCount: true, + hideEpisodeCardDescription: false, + hideDownloadedEpisodeCardFilename: false, + customCSS: "", + mobileCustomCSS: "", + unpinnedMenuItems: [], +} + +export type ThemeSettingsHook = { + hasCustomBackgroundColor: boolean +} & ThemeSettings + +/** + * Get the current theme settings + * This hook will return the default values if some values are not set + */ +export function useThemeSettings(): ThemeSettingsHook { + const serverStatus = useServerStatus() + return { + enableColorSettings: getThemeValue("enableColorSettings", serverStatus?.themeSettings), + animeEntryScreenLayout: getThemeValue("animeEntryScreenLayout", serverStatus?.themeSettings), + smallerEpisodeCarouselSize: getThemeValue("smallerEpisodeCarouselSize", serverStatus?.themeSettings), + expandSidebarOnHover: getThemeValue("expandSidebarOnHover", serverStatus?.themeSettings), + backgroundColor: getThemeValue("backgroundColor", serverStatus?.themeSettings), + accentColor: getThemeValue("accentColor", serverStatus?.themeSettings), + hideTopNavbar: getThemeValue("hideTopNavbar", serverStatus?.themeSettings), + enableMediaCardBlurredBackground: getThemeValue("enableMediaCardBlurredBackground", serverStatus?.themeSettings), + sidebarBackgroundColor: getThemeValue("sidebarBackgroundColor", serverStatus?.themeSettings), + libraryScreenBannerType: getThemeValue("libraryScreenBannerType", serverStatus?.themeSettings), + libraryScreenCustomBannerImage: getThemeValue("libraryScreenCustomBannerImage", serverStatus?.themeSettings), + libraryScreenCustomBannerPosition: getThemeValue("libraryScreenCustomBannerPosition", serverStatus?.themeSettings), + libraryScreenCustomBannerOpacity: getThemeValue("libraryScreenCustomBannerOpacity", serverStatus?.themeSettings), + libraryScreenCustomBackgroundImage: getThemeValue("libraryScreenCustomBackgroundImage", serverStatus?.themeSettings), + libraryScreenCustomBackgroundOpacity: getThemeValue("libraryScreenCustomBackgroundOpacity", serverStatus?.themeSettings), + disableLibraryScreenGenreSelector: getThemeValue("disableLibraryScreenGenreSelector", serverStatus?.themeSettings), + libraryScreenCustomBackgroundBlur: getThemeValue("libraryScreenCustomBackgroundBlur", serverStatus?.themeSettings), + enableMediaPageBlurredBackground: getThemeValue("enableMediaPageBlurredBackground", serverStatus?.themeSettings), + disableSidebarTransparency: getThemeValue("disableSidebarTransparency", serverStatus?.themeSettings), + useLegacyEpisodeCard: getThemeValue("useLegacyEpisodeCard", serverStatus?.themeSettings), + disableCarouselAutoScroll: getThemeValue("disableCarouselAutoScroll", serverStatus?.themeSettings), + hasCustomBackgroundColor: !!serverStatus?.themeSettings?.backgroundColor && serverStatus?.themeSettings?.backgroundColor !== THEME_DEFAULT_VALUES.backgroundColor, + mediaPageBannerType: getThemeValue("mediaPageBannerType", serverStatus?.themeSettings), + mediaPageBannerSize: getThemeValue("mediaPageBannerSize", serverStatus?.themeSettings), + mediaPageBannerInfoBoxSize: getThemeValue("mediaPageBannerInfoBoxSize", serverStatus?.themeSettings), + showEpisodeCardAnimeInfo: getThemeValue("showEpisodeCardAnimeInfo", serverStatus?.themeSettings), + continueWatchingDefaultSorting: getThemeValue("continueWatchingDefaultSorting", serverStatus?.themeSettings), + animeLibraryCollectionDefaultSorting: getThemeValue("animeLibraryCollectionDefaultSorting", serverStatus?.themeSettings), + mangaLibraryCollectionDefaultSorting: getThemeValue("mangaLibraryCollectionDefaultSorting", serverStatus?.themeSettings), + showAnimeUnwatchedCount: getThemeValue("showAnimeUnwatchedCount", serverStatus?.themeSettings), + showMangaUnreadCount: getThemeValue("showMangaUnreadCount", serverStatus?.themeSettings), + hideEpisodeCardDescription: getThemeValue("hideEpisodeCardDescription", serverStatus?.themeSettings), + hideDownloadedEpisodeCardFilename: getThemeValue("hideDownloadedEpisodeCardFilename", serverStatus?.themeSettings), + customCSS: getThemeValue("customCSS", serverStatus?.themeSettings), + mobileCustomCSS: getThemeValue("mobileCustomCSS", serverStatus?.themeSettings), + unpinnedMenuItems: getThemeValue("unpinnedMenuItems", serverStatus?.themeSettings), + } +} + +function getThemeValue(key: string, settings: ThemeSettings | undefined | null): any { + // @ts-ignore + const defaultValue = THEME_DEFAULT_VALUES[key] + + if (!settings) { + return defaultValue + } + + // Special case for mediaPageBannerInfoBoxSize + if (key === "mediaPageBannerInfoBoxSize") { + if (settings?.mediaPageBannerInfoBoxSize !== "boxed") { + return defaultValue + } + } + + const val = (settings as any)[key] + const defaultType = typeof defaultValue + const valType = typeof val + + // Handle different types based on the default value's type + if (val === null || val === undefined) { + return defaultValue + } + + switch (defaultType) { + case "string": + // For strings: only use default if current value is empty string and default is not empty + if (valType === "string" && val === "" && defaultValue !== "") { + return defaultValue + } + // If types don't match, use default + if (valType !== "string") { + return defaultValue + } + return val + + case "number": + // For numbers: use default if not a valid number + if (valType !== "number" || isNaN(val)) { + return defaultValue + } + return val + + case "boolean": + // For booleans: use actual value if it's a boolean, otherwise use default + if (valType === "boolean") { + return val + } + return defaultValue + + case "object": + if (Array.isArray(defaultValue)) { + // For arrays: use default if not an array + if (!Array.isArray(val)) { + return defaultValue + } + return val + } else { + // For objects: use default if not an object + if (valType !== "object" || val === null) { + return defaultValue + } + return val + } + + default: + // For any other type, return the value + return val + } +} + +export function useIsMobile(): { isMobile: boolean } { + const { width } = useWindowSize() + return { isMobile: React.useMemo(() => width < 1024, [width < 1024]) } +} \ No newline at end of file diff --git a/seanime-2.9.10/seanime-web/src/lib/theme/theme-bank.ts b/seanime-2.9.10/seanime-web/src/lib/theme/theme-bank.ts new file mode 100644 index 0000000..1b86c28 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/theme/theme-bank.ts @@ -0,0 +1,12 @@ +export const THEME_COLOR_BANK = [ + { name: "Seanime", backgroundColor: "#070707", accentColor: "#6152df" }, + { name: "Midnight", backgroundColor: "#000", accentColor: "#5649bb" }, + { name: "Deep purple", backgroundColor: "#0a050d", accentColor: "#8659c9" }, + { name: "Desert flower", backgroundColor: "#090e11", accentColor: "#c08b57" }, + { name: "Pink", backgroundColor: "#0f060c", accentColor: "#ec74b0" }, + { name: "Sun", backgroundColor: "#0b0803", accentColor: "#e5972a" }, + { name: "JJK", backgroundColor: "#0c0d10", accentColor: "#8c1d1d" }, + { name: "Rainforest", backgroundColor: "#050d0c", accentColor: "#1d975a" }, + // { name: "P0", backgroundColor: "#0e060a", accentColor: "#5d294d" }, + // { name: "Ocean", backgroundColor: "#050a0d", accentColor: "#1a72a8" }, +] diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/browser-detection.ts b/seanime-2.9.10/seanime-web/src/lib/utils/browser-detection.ts new file mode 100644 index 0000000..120dcfc --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/browser-detection.ts @@ -0,0 +1,314 @@ +/** + * Utilities to detect the browser and get information on the current environment + * Based on https://github.com/google/shaka-player/blob/master/lib/util/platform.js + * + * @deprecated - Parsing User Agent is a maintenance burden and + * should rely on external libraries only. It's also going to be replaced with Client-Hints. + * Migration paths: + * * Check for platform-specific features *where needed* + * directly (i.e. Chromecast/AirPlay/MSE) instead of a per-browser basis. + * This will always be 100% fault free. + * + * * Use something like https://www.npmjs.com/package/unique-names-generator to + * distinguish between instances. Instance names could be shown and be modified by the user + * at settings. This would make user instances distinguishable in a 100% fault-tolerant way + * and solve incongruences like how a device is named. For instance, + * an instance running in an Android Auto headset will be recognised as Android only, which is less + * than ideal. + */ +export function supportsMediaSource(): boolean { + /* + * Browsers that lack a media source implementation will have no reference + * to |window.MediaSource|. + */ + return !!window.MediaSource +} + +/** + * Check if the user agent of the navigator contains a key. + * + * @private + * @static + * @param key - Key for which to perform a check. + * @returns Determines if user agent of navigator contains a key + */ +function userAgentContains(key: string): boolean { + const userAgent = navigator.userAgent || "" + + return userAgent.includes(key) +} + +/* Desktop Browsers */ + +/** + * Check if the current platform is Mozilla Firefox. + * + * @returns Determines if browser is Mozilla Firefox + */ +export function isFirefox(): boolean { + return userAgentContains("Firefox/") +} + +/** + * Check if the current platform is Microsoft Edge. + * + * @static + * @returns Determines if browser is Microsoft Edge + */ +export function isEdge(): boolean { + return userAgentContains("Edg/") || userAgentContains("Edge/") +} + +/** + * Check if the current platform is Chromium based. + * + * @returns Determines if browser is Chromium based + */ +export function isChromiumBased(): boolean { + return userAgentContains("Chrome") +} + +/** + * Check if the current platform is Google Chrome. + * + * @returns Determines if browser is Google Chrome + */ +export function isChrome(): boolean { + /* + * The Edge user agent will also contain the "Chrome" keyword, so we need + * to make sure this is not Edge. + */ + return userAgentContains("Chrome") && !isEdge() && !isWebOS() +} + +/** + * Check if the current platform is from Apple. + * + * Returns true on all iOS browsers and on desktop Safari. + * + * Returns false for non-Safari browsers on macOS, which are independent of + * Apple. + * + * @returns Determines if current platform is from Apple + */ +export function isApple(): boolean { + return navigator.vendor.includes("Apple") && !isTizen() +} + +/** + * Returns a major version number for Safari, or Safari-based iOS browsers. + * + * @returns The major version number for Safari + */ +export function safariVersion(): number | undefined { + // All iOS browsers and desktop Safari will return true for isApple(). + if (!isApple()) { + return + } + + let userAgent = "" + + if (navigator.userAgent) { + userAgent = navigator.userAgent + } + + /* + * This works for iOS Safari and desktop Safari, which contain something + * like "Version/13.0" indicating the major Safari or iOS version. + */ + let match = /Version\/(\d+)/.exec(userAgent) + + if (match) { + return Number.parseInt(match[1], /* Base= */ 10) + } + + /* + * This works for all other browsers on iOS, which contain something like + * "OS 13_3" indicating the major & minor iOS version. + */ + match = /OS (\d+)(?:_\d+)?/.exec(userAgent) + + if (match) { + return Number.parseInt(match[1], /* Base= */ 10) + } +} + +/* TV Platforms */ + +/** + * Check if the current platform is Tizen. + * + * @returns Determines if current platform is Tizen + */ +export function isTizen(): boolean { + return userAgentContains("Tizen") +} + +/** + * Check if the current platform is Tizen 2 + * + * @returns Determines if current platform is Tizen 2 + */ +export function isTizen2(): boolean { + return userAgentContains("Tizen 2") +} + +/** + * Check if the current platform is Tizen 3 + * + * @returns Determines if current platform is Tizen 3 + * @memberof BrowserDetector + */ +export function isTizen3(): boolean { + return userAgentContains("Tizen 3") +} + +/** + * Check if the current platform is Tizen 4. + * + * @returns Determines if current platform is Tizen 4 + * @memberof BrowserDetector + */ +export function isTizen4(): boolean { + return userAgentContains("Tizen 4") +} + +/** + * Check if the current platform is Tizen 5. + * + * @returns Determines if current platform is Tizen 5 + * @memberof BrowserDetector + */ +export function isTizen5(): boolean { + return userAgentContains("Tizen 5") +} + +/** + * Check if the current platform is Tizen 5.5. + * + * @returns Determines if current platform is Tizen 5.5 + * @memberof BrowserDetector + */ +export function isTizen55(): boolean { + return userAgentContains("Tizen 5.5") +} + +/** + * Check if the current platform is WebOS. + * + * @returns Determines if current platform is WebOS + * @memberof BrowserDetector + */ +export function isWebOS(): boolean { + return userAgentContains("Web0S") +} + +/** + * Determines if current platform is WebOS1 + */ +export function isWebOS1(): boolean { + return ( + isWebOS() + && userAgentContains("AppleWebKit/537") + && !userAgentContains("Chrome/") + ) +} + +/** + * Determines if current platform is WebOS2 + */ +export function isWebOS2(): boolean { + return ( + isWebOS() + && userAgentContains("AppleWebKit/538") + && !userAgentContains("Chrome/") + ) +} + +/** + * Determines if current platform is WebOS3 + */ +export function isWebOS3(): boolean { + return isWebOS() && userAgentContains("Chrome/38") +} + +/** + * Determines if current platform is WebOS4 + */ +export function isWebOS4(): boolean { + return isWebOS() && userAgentContains("Chrome/53") +} + +/** + * Determines if current platform is WebOS5 + */ +export function isWebOS5(): boolean { + return isWebOS() && userAgentContains("Chrome/68") +} + +/* Platform Utilities */ + +/** + * Determines if current platform is Android + */ +export function isAndroid(): boolean { + return userAgentContains("Android") +} + +/** + * Guesses if the platform is a mobile one (iOS or Android). + * + * @returns Determines if current platform is mobile (Guess) + */ +export function isMobile(): boolean { + let userAgent = "" + + if (navigator.userAgent) { + userAgent = navigator.userAgent + } + + if (/iPhone|iPad|iPod|Android/.test(userAgent)) { + // This is Android, iOS, or iPad < 13. + return true + } + + /* + * Starting with iOS 13 on iPad, the user agent string no longer has the + * word "iPad" in it. It looks very similar to desktop Safari. This seems + * to be intentional on Apple's part. + * See: https://forums.developer.apple.com/thread/119186 + * + * So if it's an Apple device with multi-touch support, assume it's a mobile + * device. If some future iOS version starts masking their user agent on + * both iPhone & iPad, this clause should still work. If a future + * multi-touch desktop Mac is released, this will need some adjustment. + */ + return isApple() && navigator.maxTouchPoints > 1 +} + +/** + * Guesses if the platform is a Smart TV (Tizen or WebOS). + * + * @returns Determines if platform is a Smart TV + */ +export function isTv(): boolean { + return isTizen() || isWebOS() +} + +/** + * Guesses if the platform is a PS4 + * + * @returns Determines if the device is a PS4 + */ +export function isPs4(): boolean { + return userAgentContains("playstation 4") +} + +/** + * Guesses if the platform is a Xbox + * + * @returns Determines if the device is a Xbox + */ +export function isXbox(): boolean { + return userAgentContains("xbox") +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/README.md b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/README.md new file mode 100644 index 0000000..78a661e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/README.md @@ -0,0 +1 @@ +source (jellyfin/jellyfin-vue)[https://github.com/jellyfin/jellyfin-vue/tree/master/frontend/src/utils/playback-profiles] \ No newline at end of file diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/directplay-profile.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/directplay-profile.ts new file mode 100644 index 0000000..2a71bbd --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/directplay-profile.ts @@ -0,0 +1,106 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { DirectPlayProfile, DlnaProfileType } from "@/lib/utils/playback-profiles/jellyfin-types" +import { getSupportedAudioCodecs } from "./helpers/audio-formats" +import { getSupportedMP4AudioCodecs } from "./helpers/mp4-audio-formats" +import { getSupportedMP4VideoCodecs } from "./helpers/mp4-video-formats" +import { hasMkvSupport } from "./helpers/transcoding-formats" +import { getSupportedWebMAudioCodecs } from "./helpers/webm-audio-formats" +import { getSupportedWebMVideoCodecs } from "./helpers/webm-video-formats" + +/** + * Returns a valid DirectPlayProfile for the current platform. + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns An array of direct play profiles for the current platform. + */ +export function getDirectPlayProfiles( + videoTestElement: HTMLVideoElement, +): DirectPlayProfile[] { + const DirectPlayProfiles: DirectPlayProfile[] = [] + + const webmVideoCodecs = getSupportedWebMVideoCodecs(videoTestElement) + const webmAudioCodecs = getSupportedWebMAudioCodecs(videoTestElement) + + const mp4VideoCodecs = getSupportedMP4VideoCodecs(videoTestElement) + const mp4AudioCodecs = getSupportedMP4AudioCodecs(videoTestElement) + + if (webmVideoCodecs.length > 0) { + DirectPlayProfiles.push({ + Container: "webm", + Type: DlnaProfileType.Video, + VideoCodec: webmVideoCodecs.join(","), + AudioCodec: webmAudioCodecs.join(","), + }) + } + + if (mp4VideoCodecs.length > 0) { + DirectPlayProfiles.push({ + Container: "mp4,m4v", + Type: DlnaProfileType.Video, + VideoCodec: mp4VideoCodecs.join(","), + AudioCodec: mp4AudioCodecs.join(","), + }) + } + + if (hasMkvSupport(videoTestElement) && mp4VideoCodecs.length > 0) { + DirectPlayProfiles.push({ + Container: "mkv", + Type: DlnaProfileType.Video, + VideoCodec: mp4VideoCodecs.join(","), + AudioCodec: mp4AudioCodecs.join(","), + }) + } + + const supportedAudio = [ + "opus", + "mp3", + "mp2", + "aac", + "flac", + "alac", + "webma", + "wma", + "wav", + "ogg", + "oga", + "eac3", + ] + + for (const audioFormat of supportedAudio.filter(format => + getSupportedAudioCodecs(format), + )) { + DirectPlayProfiles.push({ + Container: audioFormat, + Type: DlnaProfileType.Audio, + }) + + if (audioFormat === "opus" || audioFormat === "webma") { + DirectPlayProfiles.push({ + Container: "webm", + Type: DlnaProfileType.Audio, + AudioCodec: audioFormat, + }) + } + + // Aac also appears in the m4a and m4b container + if (audioFormat === "aac" || audioFormat === "alac") { + DirectPlayProfiles.push( + { + Container: "m4a", + AudioCodec: audioFormat, + Type: DlnaProfileType.Audio, + }, + { + Container: "m4b", + AudioCodec: audioFormat, + Type: DlnaProfileType.Audio, + }, + ) + } + } + + return DirectPlayProfiles +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/audio-formats.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/audio-formats.ts new file mode 100644 index 0000000..000164e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/audio-formats.ts @@ -0,0 +1,47 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isApple, isTizen, isTv, isWebOS } from "../../browser-detection" + +/** + * Determines if audio codec is supported + */ +export function getSupportedAudioCodecs(format: string): boolean { + let typeString: string | undefined + + if (format === "flac" && isTv()) { + return true + } else if (format === "eac3") { + // This is specific to JellyPlayer + return true + } else if (format === "wma" && isTizen()) { + return true + } else if (format === "asf" && isTv()) { + return true + } else if (format === "opus") { + if (!isWebOS()) { + typeString = "audio/ogg; codecs=\"opus\"" + + return !!document + .createElement("audio") + .canPlayType(typeString) + .replace(/no/, "") + } + + return false + } else if (format === "alac" && isApple()) { + return true + } else if (format === "webma") { + typeString = "audio/webm" + } else if (format === "mp2") { + typeString = "audio/mpeg" + } else { + typeString = "audio/" + format + } + + return !!document + .createElement("audio") + .canPlayType(typeString) + .replace(/no/, "") +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/codec-profiles.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/codec-profiles.ts new file mode 100644 index 0000000..0a69180 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/codec-profiles.ts @@ -0,0 +1,347 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + + +import { CodecProfile, CodecType, ProfileCondition, ProfileConditionType, ProfileConditionValue } from "@/lib/utils/playback-profiles/jellyfin-types" +import { isApple, isChromiumBased, isEdge, isMobile, isPs4, isTizen, isTv, isWebOS, isXbox, safariVersion } from "../../browser-detection" + +/** + * Gets the max video bitrate + * + * @returns Returns the MaxVideoBitrate + */ +function getGlobalMaxVideoBitrate(): number | undefined { + let isTizenFhd = false + + if ( + isTizen() && + "webapis" in window && + typeof window.webapis === "object" && + window.webapis && + "productinfo" in window.webapis && + typeof window.webapis.productinfo === "object" && + window.webapis.productinfo && + "isUdPanelSupported" in window.webapis.productinfo && + typeof window.webapis.productinfo.isUdPanelSupported === "function" + ) { + isTizenFhd = !window.webapis.productinfo.isUdPanelSupported() + } + + /* + * TODO: These values are taken directly from Jellyfin-web. + * The source of them needs to be investigated. + */ + if (isPs4()) { + return 8_000_000 + } + + if (isXbox()) { + return 12_000_000 + } + + if (isTizen() && isTizenFhd) { + return 20_000_000 + } +} + +/** + * Creates a profile condition object for use in device playback profiles. + * + * @param Property - Value for the property + * @param Condition - Condition that the property must comply with + * @param Value - Value to check in the condition + * @param IsRequired - Whether this property is required + * @returns - Constructed ProfileCondition object + */ +function createProfileCondition( + Property: ProfileConditionValue, + Condition: ProfileConditionType, + Value: string, + IsRequired = false, +): ProfileCondition { + return { + Condition, + Property, + Value, + IsRequired, + } +} + +/** + * Gets the AAC audio codec profile conditions + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns - Array of ACC Profile conditions + */ +export function getAacCodecProfileConditions( + videoTestElement: HTMLVideoElement, +): ProfileCondition[] { + const supportsSecondaryAudio = isTizen() + + const conditions: ProfileCondition[] = [] + + // Handle he-aac not supported + if ( + !videoTestElement + .canPlayType("video/mp4; codecs=\"avc1.640029, mp4a.40.5\"") + .replace(/no/, "") + ) { + // TODO: This needs to become part of the stream url in order to prevent stream copy + conditions.push( + createProfileCondition( + ProfileConditionValue.AudioProfile, + ProfileConditionType.NotEquals, + "HE-AAC", + ), + ) + } + + if (!supportsSecondaryAudio) { + conditions.push( + createProfileCondition( + ProfileConditionValue.IsSecondaryAudio, + ProfileConditionType.Equals, + "false", + ), + ) + } + + return conditions +} + +/** + * Gets an array with all the codec profiles that this client supports + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns - Array containing the different profiles for the client + */ +export function getCodecProfiles( + videoTestElement: HTMLVideoElement, +): CodecProfile[] { + const CodecProfiles: CodecProfile[] = [] + + const aacProfileConditions = getAacCodecProfileConditions(videoTestElement) + + const supportsSecondaryAudio = isTizen() + + if (aacProfileConditions.length > 0) { + CodecProfiles.push({ + Type: CodecType.VideoAudio, + Codec: "aac", + Conditions: aacProfileConditions, + }) + } + + if (!supportsSecondaryAudio) { + CodecProfiles.push({ + Type: CodecType.VideoAudio, + Conditions: [ + createProfileCondition( + ProfileConditionValue.IsSecondaryAudio, + ProfileConditionType.Equals, + "false", + ), + ], + }) + } + + let maxH264Level = 42 + let h264Profiles = "high|main|baseline|constrained baseline" + + if ( + isTv() || + videoTestElement + .canPlayType("video/mp4; codecs=\"avc1.640833\"") + .replace(/no/, "") + ) { + maxH264Level = 51 + } + + if ( + videoTestElement + .canPlayType("video/mp4; codecs=\"avc1.640834\"") + .replace(/no/, "") + ) { + maxH264Level = 52 + } + + if ( + (isTizen() || + videoTestElement + .canPlayType("video/mp4; codecs=\"avc1.6e0033\"") + .replace(/no/, "")) && // TODO: These tests are passing in Safari, but playback is failing + (!isApple() || !isWebOS() || !(isEdge() && !isChromiumBased())) + ) { + h264Profiles += "|high 10" + } + + let maxHevcLevel = 120 + let hevcProfiles = "main" + const hevcProfilesMain10 = "main|main 10" + + // HEVC Main profile, Level 4.1 + if ( + videoTestElement + .canPlayType("video/mp4; codecs=\"hvc1.1.4.L123\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"hev1.1.4.L123\"") + .replace(/no/, "") + ) { + maxHevcLevel = 123 + } + + // HEVC Main10 profile, Level 4.1 + if ( + videoTestElement + .canPlayType("video/mp4; codecs=\"hvc1.2.4.L123\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"hev1.2.4.L123\"") + .replace(/no/, "") + ) { + maxHevcLevel = 123 + hevcProfiles = hevcProfilesMain10 + } + + // HEVC Main10 profile, Level 5.1 + if ( + videoTestElement + .canPlayType("video/mp4; codecs=\"hvc1.2.4.L153\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"hev1.2.4.L153\"") + .replace(/no/, "") + ) { + maxHevcLevel = 153 + hevcProfiles = hevcProfilesMain10 + } + + // HEVC Main10 profile, Level 6.1 + if ( + videoTestElement + .canPlayType("video/mp4; codecs=\"hvc1.2.4.L183\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"hev1.2.4.L183\"") + .replace(/no/, "") + ) { + maxHevcLevel = 183 + hevcProfiles = hevcProfilesMain10 + } + + const hevcCodecProfileConditions: ProfileCondition[] = [ + createProfileCondition( + ProfileConditionValue.IsAnamorphic, + ProfileConditionType.NotEquals, + "true", + ), + createProfileCondition( + ProfileConditionValue.VideoProfile, + ProfileConditionType.EqualsAny, + hevcProfiles, + ), + createProfileCondition( + ProfileConditionValue.VideoLevel, + ProfileConditionType.LessThanEqual, + maxHevcLevel.toString(), + ), + ] + + const h264CodecProfileConditions: ProfileCondition[] = [ + createProfileCondition( + ProfileConditionValue.IsAnamorphic, + ProfileConditionType.NotEquals, + "true", + ), + createProfileCondition( + ProfileConditionValue.VideoProfile, + ProfileConditionType.EqualsAny, + h264Profiles, + ), + createProfileCondition( + ProfileConditionValue.VideoLevel, + ProfileConditionType.LessThanEqual, + maxH264Level.toString(), + ), + ] + + if (!isTv()) { + h264CodecProfileConditions.push( + createProfileCondition( + ProfileConditionValue.IsInterlaced, + ProfileConditionType.NotEquals, + "true", + ), + ) + hevcCodecProfileConditions.push( + createProfileCondition( + ProfileConditionValue.IsInterlaced, + ProfileConditionType.NotEquals, + "true", + ), + ) + } + + const globalMaxVideoBitrate = (getGlobalMaxVideoBitrate() ?? "").toString() + + if (globalMaxVideoBitrate) { + h264CodecProfileConditions.push( + createProfileCondition( + ProfileConditionValue.VideoBitrate, + ProfileConditionType.LessThanEqual, + globalMaxVideoBitrate, + true, + ), + ) + } + + if (globalMaxVideoBitrate) { + hevcCodecProfileConditions.push( + createProfileCondition( + ProfileConditionValue.VideoBitrate, + ProfileConditionType.LessThanEqual, + globalMaxVideoBitrate, + true, + ), + ) + } + + // On iOS 12.x, for TS container max h264 level is 4.2 + if (isApple() && isMobile() && Number(safariVersion()) < 13) { + const codecProfile = { + Type: CodecType.Video, + Codec: "h264", + Container: "ts", + Conditions: h264CodecProfileConditions.filter((condition) => { + return condition.Property !== "VideoLevel" + }), + } + + codecProfile.Conditions.push( + createProfileCondition( + ProfileConditionValue.VideoLevel, + ProfileConditionType.LessThanEqual, + "42", + ), + ) + + CodecProfiles.push(codecProfile) + } + + CodecProfiles.push( + { + Type: CodecType.Video, + Codec: "h264", + Conditions: h264CodecProfileConditions, + }, + { + Type: CodecType.Video, + Codec: "hevc", + Conditions: hevcCodecProfileConditions, + }, + ) + + return CodecProfiles +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/fmp4-audio-formats.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/fmp4-audio-formats.ts new file mode 100644 index 0000000..7aebda5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/fmp4-audio-formats.ts @@ -0,0 +1,45 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isEdge } from "../../browser-detection" +import { getSupportedAudioCodecs } from "./audio-formats" +import { hasAacSupport, hasAc3InHlsSupport, hasAc3Support, hasEac3Support, hasMp3AudioSupport } from "./mp4-audio-formats" + +/** + * Gets an array with the supported fmp4 codecs + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns List of supported FMP4 audio codecs + */ +export function getSupportedFmp4AudioCodecs( + videoTestElement: HTMLVideoElement, +): string[] { + const codecs = [] + + if (hasAacSupport(videoTestElement)) { + codecs.push("aac") + } + + if (hasMp3AudioSupport(videoTestElement)) { + codecs.push("mp3") + } + + if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) { + codecs.push("ac3") + + if (hasEac3Support(videoTestElement)) { + codecs.push("eac3") + } + } + + if (getSupportedAudioCodecs("flac") && !isEdge()) { + codecs.push("flac") + } + + if (getSupportedAudioCodecs("alac")) { + codecs.push("alac") + } + + return codecs +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/fmp4-video-formats.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/fmp4-video-formats.ts new file mode 100644 index 0000000..de2ab8e --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/fmp4-video-formats.ts @@ -0,0 +1,39 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isApple, isChrome, isEdge, isFirefox, isTizen, isWebOS } from "../../browser-detection" +import { hasH264Support, hasHevcSupport } from "./mp4-video-formats" + +/** + * Gets an array of supported fmp4 video codecs + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns List of supported fmp4 video codecs + */ +export function getSupportedFmp4VideoCodecs( + videoTestElement: HTMLVideoElement, +): string[] { + const codecs = [] + + if ( + (isApple() || isEdge() || isTizen() || isWebOS()) && + hasHevcSupport(videoTestElement) + ) { + codecs.push("hevc") + } + + if ( + hasH264Support(videoTestElement) && + (isChrome() || + isFirefox() || + isApple() || + isEdge() || + isTizen() || + isWebOS()) + ) { + codecs.push("h264") + } + + return codecs +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/hls-formats.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/hls-formats.ts new file mode 100644 index 0000000..fc2c396 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/hls-formats.ts @@ -0,0 +1,89 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isTv } from "../../browser-detection" +import { getSupportedAudioCodecs } from "./audio-formats" +import { hasAacSupport, hasEac3Support } from "./mp4-audio-formats" +import { hasH264Support, hasH265Support } from "./mp4-video-formats" + +/** + * Check if client supports AC3 in HLS stream + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if the browser has AC3 in HLS support + */ +function supportsAc3InHls( + videoTestElement: HTMLVideoElement, +): boolean | string { + if (isTv()) { + return true + } + + if (videoTestElement.canPlayType) { + return ( + videoTestElement + .canPlayType("application/x-mpegurl; codecs=\"avc1.42E01E, ac-3\"") + .replace(/no/, "") || + videoTestElement + .canPlayType( + "application/vnd.apple.mpegURL; codecs=\"avc1.42E01E, ac-3\"", + ) + .replace(/no/, "") + ) + } + + return false +} + +/** + * Gets the supported HLS video codecs + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Array of video codecs supported in HLS + */ +export function getHlsVideoCodecs( + videoTestElement: HTMLVideoElement, +): string[] { + const hlsVideoCodecs = [] + + if (hasH264Support(videoTestElement)) { + hlsVideoCodecs.push("h264") + } + + if (hasH265Support(videoTestElement) || isTv()) { + hlsVideoCodecs.push("h265", "hevc") + } + + return hlsVideoCodecs +} + +/** + * Gets the supported HLS audio codecs + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Array of audio codecs supported in HLS + */ +export function getHlsAudioCodecs( + videoTestElement: HTMLVideoElement, +): string[] { + const hlsVideoAudioCodecs = [] + + if (supportsAc3InHls(videoTestElement)) { + hlsVideoAudioCodecs.push("ac3") + + if (hasEac3Support(videoTestElement)) { + hlsVideoAudioCodecs.push("eac3") + } + } + + if (hasAacSupport(videoTestElement)) { + hlsVideoAudioCodecs.push("aac") + } + + if (getSupportedAudioCodecs("opus")) { + hlsVideoAudioCodecs.push("opus") + } + + return hlsVideoAudioCodecs +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/mp4-audio-formats.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/mp4-audio-formats.ts new file mode 100644 index 0000000..444557f --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/mp4-audio-formats.ts @@ -0,0 +1,195 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isTizen, isTizen4, isTizen5, isTizen55, isTv, isWebOS } from "../../browser-detection" +import { getSupportedAudioCodecs } from "./audio-formats" +import { hasVp8Support } from "./mp4-video-formats" + +/** + * Checks if the client can play the AC3 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if the browser has AC3 support + */ +export function hasAc3Support(videoTestElement: HTMLVideoElement): boolean { + if (isTv()) { + return true + } + + return !!videoTestElement + .canPlayType("audio/mp4; codecs=\"ac-3\"") + .replace(/no/, "") +} + +/** + * Checks if the client can play AC3 in a HLS stream + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if the browser has AC3 support + */ +export function hasAc3InHlsSupport( + videoTestElement: HTMLVideoElement, +): boolean { + if (isTizen() || isWebOS()) { + return true + } + + if (videoTestElement.canPlayType) { + return !!( + videoTestElement + .canPlayType("application/x-mpegurl; codecs=\"avc1.42E01E, ac-3\"") + .replace(/no/, "") || + videoTestElement + .canPlayType( + "application/vnd.apple.mpegURL; codecs=\"avc1.42E01E, ac-3\"", + ) + .replace(/no/, "") + ) + } + + return false +} + +/** + * Checks if the cliemt has E-AC3 codec support + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has EAC3 support + */ +export function hasEac3Support(videoTestElement: HTMLVideoElement): boolean { + if (isTv()) { + return true + } + + return !!videoTestElement + .canPlayType("audio/mp4; codecs=\"ec-3\"") + .replace(/no/, "") +} + +/** + * Checks if the client has AAC codec support + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has AAC support + */ +export function hasAacSupport(videoTestElement: HTMLVideoElement): boolean { + return !!videoTestElement + .canPlayType("video/mp4; codecs=\"avc1.640029, mp4a.40.2\"") + .replace(/no/, "") +} + +/** + * Checks if the client has MP2 codec support + * + * @returns Determines if browser has MP2 support + */ +export function hasMp2AudioSupport(): boolean { + return isTv() +} + +/** + * Checks if the client has MP3 audio codec support + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has Mp3 support + */ +export function hasMp3AudioSupport( + videoTestElement: HTMLVideoElement, +): boolean { + return !!( + videoTestElement + .canPlayType("video/mp4; codecs=\"avc1.640029, mp4a.69\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"avc1.640029, mp4a.6B\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"avc1.640029, mp3\"") + .replace(/no/, "") + ) +} + +/** + * Determines DTS audio support + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browserr has DTS audio support + */ +export function hasDtsSupport( + videoTestElement: HTMLVideoElement, +): boolean | string { + // DTS audio not supported in 2018 models (Tizen 4.0) + if (isTizen4() || isTizen5() || isTizen55()) { + return false + } + + return ( + isTv() || + videoTestElement + .canPlayType("video/mp4; codecs=\"dts-\"") + .replace(/no/, "") || + videoTestElement.canPlayType("video/mp4; codecs=\"dts+\"").replace(/no/, "") + ) +} + +/** + * Gets an array of supported MP4 codecs + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Array of supported MP4 audio codecs + */ +export function getSupportedMP4AudioCodecs( + videoTestElement: HTMLVideoElement, +): string[] { + const codecs = [] + + if (hasAacSupport(videoTestElement)) { + codecs.push("aac") + } + + if (hasMp3AudioSupport(videoTestElement)) { + codecs.push("mp3") + } + + if (hasAc3Support(videoTestElement)) { + codecs.push("ac3") + + if (hasEac3Support(videoTestElement)) { + codecs.push("eac3") + } + } + + if (hasMp2AudioSupport()) { + codecs.push("mp2") + } + + if (hasDtsSupport(videoTestElement)) { + codecs.push("dca", "dts") + } + + if (isTizen() || isWebOS()) { + codecs.push("pcm_s16le", "pcm_s24le") + } + + if (isTizen()) { + codecs.push("aac_latm") + } + + if (getSupportedAudioCodecs("opus")) { + codecs.push("opus") + } + + if (getSupportedAudioCodecs("flac")) { + codecs.push("flac") + } + + if (getSupportedAudioCodecs("alac")) { + codecs.push("alac") + } + + if (hasVp8Support(videoTestElement) || isTizen()) { + codecs.push("vorbis") + } + + return codecs +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/mp4-video-formats.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/mp4-video-formats.ts new file mode 100644 index 0000000..ffcaade --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/mp4-video-formats.ts @@ -0,0 +1,178 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isApple, isTizen, isTizen55, isTv, isWebOS5 } from "../../browser-detection" + +/** + * Checks if the client has support for the H264 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has H264 support + */ +export function hasH264Support(videoTestElement: HTMLVideoElement): boolean { + return !!videoTestElement + .canPlayType("video/mp4; codecs=\"avc1.42E01E, mp4a.40.2\"") + .replace(/no/, "") +} + +/** + * Checks if the client has support for the H265 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has H265 support + */ +export function hasH265Support(videoTestElement: HTMLVideoElement): boolean { + if (isTv()) { + return true + } + + return !!( + videoTestElement.canPlayType && + (videoTestElement + .canPlayType("video/mp4; codecs=\"hvc1.1.L120\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"hev1.1.L120\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"hvc1.1.0.L120\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"hev1.1.0.L120\"") + .replace(/no/, "")) + ) +} + +/** + * Checks if the client has support for the HEVC codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has HEVC Support + */ +export function hasHevcSupport(videoTestElement: HTMLVideoElement): boolean { + if (isTv()) { + return true + } + + return !!( + !!videoTestElement.canPlayType && + (videoTestElement + .canPlayType("video/mp4; codecs=\"hvc1.1.L120\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"hev1.1.L120\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"hvc1.1.0.L120\"") + .replace(/no/, "") || + videoTestElement + .canPlayType("video/mp4; codecs=\"hev1.1.0.L120\"") + .replace(/no/, "")) + ) +} + +/** + * Checks if the client has support for the AV1 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has AV1 support + */ +export function hasAv1Support(videoTestElement: HTMLVideoElement): boolean { + if ( + (isTizen() && isTizen55()) || + (isWebOS5() && window.outerHeight >= 2160) + ) { + return true + } + + return !!videoTestElement + .canPlayType("video/webm; codecs=\"av01.0.15M.10\"") + .replace(/no/, "") +} + +/** + * Check if the client has support for the VC1 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has VC1 support + */ +function hasVc1Support(videoTestElement: HTMLVideoElement): boolean { + return !!( + isTv() || + videoTestElement.canPlayType("video/mp4; codecs=\"vc-1\"").replace(/no/, "") + ) +} + +/** + * Checks if the client has support for the VP8 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has VP8 support + */ +export function hasVp8Support(videoTestElement: HTMLVideoElement): boolean { + return !!videoTestElement + .canPlayType("video/webm; codecs=\"vp8\"") + .replace(/no/, "") +} + +/** + * Checks if the client has support for the VP9 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has VP9 support + */ +export function hasVp9Support(videoTestElement: HTMLVideoElement): boolean { + return !!videoTestElement + .canPlayType("video/webm; codecs=\"vp9\"") + .replace(/no/, "") +} + +/** + * Queries the platform for the codecs suppers in an MP4 container. + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Array of codec identifiers. + */ +export function getSupportedMP4VideoCodecs( + videoTestElement: HTMLVideoElement, +): string[] { + const codecs = [] + + if (hasH264Support(videoTestElement)) { + codecs.push("h264") + } + + if ( + hasHevcSupport(videoTestElement) && // Safari is lying on HDR and 60fps videos, use fMP4 instead + !isApple() + ) { + codecs.push("hevc") + } + + if (isTv()) { + codecs.push("mpeg2video") + } + + if (hasVc1Support(videoTestElement)) { + codecs.push("vc1") + } + + if (isTizen()) { + codecs.push("msmpeg4v2") + } + + if (hasVp8Support(videoTestElement)) { + codecs.push("vp8") + } + + if (hasVp9Support(videoTestElement)) { + codecs.push("vp9") + } + + if (hasAv1Support(videoTestElement)) { + codecs.push("av1") + } + + return codecs +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/transcoding-formats.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/transcoding-formats.ts new file mode 100644 index 0000000..2a7fd53 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/transcoding-formats.ts @@ -0,0 +1,49 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isEdge, isTizen, isTv, supportsMediaSource } from "../../browser-detection" + +/** + * Checks if the client can play native HLS + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if the browser can play native Hls + */ +export function canPlayNativeHls(videoTestElement: HTMLVideoElement): boolean { + if (isTizen()) { + return true + } + + return !!( + videoTestElement.canPlayType("application/x-mpegURL").replace(/no/, "") || + videoTestElement + .canPlayType("application/vnd.apple.mpegURL") + .replace(/no/, "") + ) +} + +/** + * Determines if the browser can play Hls with Media Source Extensions + */ +export function canPlayHlsWithMSE(): boolean { + return supportsMediaSource() +} + +/** + * Determines if the browser can play Mkvs + */ +export function hasMkvSupport(videoTestElement: HTMLVideoElement): boolean { + if (isTv()) { + return true + } + + if ( + videoTestElement.canPlayType("video/x-matroska").replace(/no/, "") || + videoTestElement.canPlayType("video/mkv").replace(/no/, "") + ) { + return true + } + + return !!isEdge() +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/ts-audio-formats.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/ts-audio-formats.ts new file mode 100644 index 0000000..c000502 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/ts-audio-formats.ts @@ -0,0 +1,32 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { hasAacSupport, hasAc3InHlsSupport, hasAc3Support, hasEac3Support, hasMp3AudioSupport } from "./mp4-audio-formats" + +/** + * List of supported Ts audio codecs + */ +export function getSupportedTsAudioCodecs( + videoTestElement: HTMLVideoElement, +): string[] { + const codecs = [] + + if (hasAacSupport(videoTestElement)) { + codecs.push("aac") + } + + if (hasMp3AudioSupport(videoTestElement)) { + codecs.push("mp3") + } + + if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) { + codecs.push("ac3") + + if (hasEac3Support(videoTestElement)) { + codecs.push("eac3") + } + } + + return codecs +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/ts-video-formats.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/ts-video-formats.ts new file mode 100644 index 0000000..ea999f1 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/ts-video-formats.ts @@ -0,0 +1,20 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { hasH264Support } from "./mp4-video-formats" + +/** + * List of supported ts video codecs + */ +export function getSupportedTsVideoCodecs( + videoTestElement: HTMLVideoElement, +): string[] { + const codecs = [] + + if (hasH264Support(videoTestElement)) { + codecs.push("h264") + } + + return codecs +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/webm-audio-formats.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/webm-audio-formats.ts new file mode 100644 index 0000000..fbd7f73 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/webm-audio-formats.ts @@ -0,0 +1,25 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isWebOS } from "../../browser-detection" + +/** + * Get an array of supported codecs + */ +export function getSupportedWebMAudioCodecs( + videoTestElement: HTMLVideoElement, +): string[] { + const codecs = [] + + codecs.push("vorbis") + + if ( + !isWebOS() && + videoTestElement.canPlayType("audio/ogg; codecs=\"opus\"").replace(/no/, "") + ) { + codecs.push("opus") + } + + return codecs +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/webm-video-formats.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/webm-video-formats.ts new file mode 100644 index 0000000..bfe3dc3 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/helpers/webm-video-formats.ts @@ -0,0 +1,28 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { hasAv1Support, hasVp8Support, hasVp9Support } from "./mp4-video-formats" + +/** + * Get an array of supported codecs WebM video codecs + */ +export function getSupportedWebMVideoCodecs( + videoTestElement: HTMLVideoElement, +): string[] { + const codecs = [] + + if (hasVp8Support(videoTestElement)) { + codecs.push("vp8") + } + + if (hasVp9Support(videoTestElement)) { + codecs.push("vp9") + } + + if (hasAv1Support(videoTestElement)) { + codecs.push("av1") + } + + return codecs +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/index.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/index.ts new file mode 100644 index 0000000..1aeba69 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/index.ts @@ -0,0 +1,57 @@ +/** + * @deprecated + * Since we're targeting modern environments/devices only, it makes sense to switch + * to the native MediaCapabilities API, widely supported on modern devices, but not in older. + * + * Given a media file, we should test with MC the compatibility of video, audio and subtitle streams + * independently: + * If success: Don't request transcoding and direct play that specific stream. + * If failure: Request transcoding of the failing streams to a previously hardcoded + * bitrate/codec combination + * + * For the hardcoded bitrate/codecs combination we can use what we know that are universally + * compatible, even without testing for explicit compatibility (we can do simple checks, + * but the more we do, the complex/less portable our solution can get). + * Examples: H264, AAC and VTT/SASS (thanks to JASSUB). + * + * Other codec combinations can be hardcoded, even if they're not direct-playable in + * most browsers (like H265 or AV1), so the few browsers that support them benefits from less bandwidth + * usage (although this will rarely happen: The most expected situations when transcoding + * is when the media's codecs are more "powerful" than what the client is capable of, and H265 is + * pretty modern, so it would've been catched-up by MediaCapabilities. However, + * we must take into account the playback of really old codecs like MPEG or H263, + * whose support are probably likely going to be removed from browsers, + * so MediaCapabilities reports as unsupported, so we would be going from an "inferior" codec to a + * "superior" codec in this situation) + */ + +import { getDirectPlayProfiles } from "./directplay-profile" +import { getCodecProfiles } from "./helpers/codec-profiles" +import { getResponseProfiles } from "./response-profile" +import { getSubtitleProfiles } from "./subtitle-profile" +import { getTranscodingProfiles } from "./transcoding-profile" + +/** + * Creates a device profile containing supported codecs for the active Cast device. + * + * @param videoTestElement - Dummy video element for compatibility tests + */ +function getDeviceProfile(videoTestElement: HTMLVideoElement) { + // MaxStaticBitrate seems to be for offline sync only + return { + MaxStreamingBitrate: 120_000_000, + MaxStaticBitrate: 0, + MusicStreamingTranscodingBitrate: Math.min(120_000_000, 192_000), + DirectPlayProfiles: getDirectPlayProfiles(videoTestElement), + TranscodingProfiles: getTranscodingProfiles(videoTestElement), + ContainerProfiles: [], + CodecProfiles: getCodecProfiles(videoTestElement), + SubtitleProfiles: getSubtitleProfiles(), + ResponseProfiles: getResponseProfiles(), + } +} + +const videoTestElement = document.createElement("video") +const playbackProfile = getDeviceProfile(videoTestElement) + +export default playbackProfile diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/jellyfin-types.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/jellyfin-types.ts new file mode 100644 index 0000000..060dfbd --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/jellyfin-types.ts @@ -0,0 +1,359 @@ +/** + * + * @export + * @interface DirectPlayProfile + */ +export interface DirectPlayProfile { + /** + * + * @type {string} + * @memberof DirectPlayProfile + */ + "Container"?: string | null; + /** + * + * @type {string} + * @memberof DirectPlayProfile + */ + "AudioCodec"?: string | null; + /** + * + * @type {string} + * @memberof DirectPlayProfile + */ + "VideoCodec"?: string | null; + /** + * + * @type {DlnaProfileType} + * @memberof DirectPlayProfile + */ + "Type"?: DlnaProfileType; +} + +export const DlnaProfileType = { + Audio: "Audio", + Video: "Video", + Photo: "Photo", + Subtitle: "Subtitle", +} as const + +export type DlnaProfileType = typeof DlnaProfileType[keyof typeof DlnaProfileType]; + +/** + * Delivery method to use during playback of a specific subtitle format. + * @export + * @enum {string} + */ + +export const SubtitleDeliveryMethod = { + Encode: "Encode", + Embed: "Embed", + External: "External", + Hls: "Hls", + Drop: "Drop", +} as const + +export type SubtitleDeliveryMethod = typeof SubtitleDeliveryMethod[keyof typeof SubtitleDeliveryMethod]; + +/** + * + * @export + * @interface SubtitleProfile + */ +export interface SubtitleProfile { + /** + * + * @type {string} + * @memberof SubtitleProfile + */ + "Format"?: string | null; + /** + * + * @type {SubtitleDeliveryMethod} + * @memberof SubtitleProfile + */ + "Method"?: SubtitleDeliveryMethod; + /** + * + * @type {string} + * @memberof SubtitleProfile + */ + "DidlMode"?: string | null; + /** + * + * @type {string} + * @memberof SubtitleProfile + */ + "Language"?: string | null; + /** + * + * @type {string} + * @memberof SubtitleProfile + */ + "Container"?: string | null; +} + +/** + * + * @export + * @enum {string} + */ + +export const ProfileConditionType = { + Equals: "Equals", + NotEquals: "NotEquals", + LessThanEqual: "LessThanEqual", + GreaterThanEqual: "GreaterThanEqual", + EqualsAny: "EqualsAny", +} as const + +export type ProfileConditionType = typeof ProfileConditionType[keyof typeof ProfileConditionType]; + +/** + * + * @export + * @enum {string} + */ + +export const EncodingContext = { + Streaming: "Streaming", + Static: "Static", +} as const + +export type EncodingContext = typeof EncodingContext[keyof typeof EncodingContext]; + +/** + * + * @export + * @enum {string} + */ + +export const ProfileConditionValue = { + AudioChannels: "AudioChannels", + AudioBitrate: "AudioBitrate", + AudioProfile: "AudioProfile", + Width: "Width", + Height: "Height", + Has64BitOffsets: "Has64BitOffsets", + PacketLength: "PacketLength", + VideoBitDepth: "VideoBitDepth", + VideoBitrate: "VideoBitrate", + VideoFramerate: "VideoFramerate", + VideoLevel: "VideoLevel", + VideoProfile: "VideoProfile", + VideoTimestamp: "VideoTimestamp", + IsAnamorphic: "IsAnamorphic", + RefFrames: "RefFrames", + NumAudioStreams: "NumAudioStreams", + NumVideoStreams: "NumVideoStreams", + IsSecondaryAudio: "IsSecondaryAudio", + VideoCodecTag: "VideoCodecTag", + IsAvc: "IsAvc", + IsInterlaced: "IsInterlaced", + AudioSampleRate: "AudioSampleRate", + AudioBitDepth: "AudioBitDepth", + VideoRangeType: "VideoRangeType", +} as const + +export type ProfileConditionValue = typeof ProfileConditionValue[keyof typeof ProfileConditionValue]; + +/** + * + * @export + * @interface ProfileCondition + */ +export interface ProfileCondition { + /** + * + * @type {ProfileConditionType} + * @memberof ProfileCondition + */ + "Condition"?: ProfileConditionType; + /** + * + * @type {ProfileConditionValue} + * @memberof ProfileCondition + */ + "Property"?: ProfileConditionValue; + /** + * + * @type {string} + * @memberof ProfileCondition + */ + "Value"?: string | null; + /** + * + * @type {boolean} + * @memberof ProfileCondition + */ + "IsRequired"?: boolean; +} + +/** + * + * @export + * @enum {string} + */ + +export const TranscodeSeekInfo = { + Auto: "Auto", + Bytes: "Bytes", +} as const + +export type TranscodeSeekInfo = typeof TranscodeSeekInfo[keyof typeof TranscodeSeekInfo]; + +/** + * + * @export + * @interface TranscodingProfile + */ +export interface TranscodingProfile { + /** + * + * @type {string} + * @memberof TranscodingProfile + */ + "Container"?: string; + /** + * + * @type {DlnaProfileType} + * @memberof TranscodingProfile + */ + "Type"?: DlnaProfileType; + /** + * + * @type {string} + * @memberof TranscodingProfile + */ + "VideoCodec"?: string; + /** + * + * @type {string} + * @memberof TranscodingProfile + */ + "AudioCodec"?: string; + /** + * + * @type {string} + * @memberof TranscodingProfile + */ + "Protocol"?: string; + /** + * + * @type {boolean} + * @memberof TranscodingProfile + */ + "EstimateContentLength"?: boolean; + /** + * + * @type {boolean} + * @memberof TranscodingProfile + */ + "EnableMpegtsM2TsMode"?: boolean; + /** + * + * @type {TranscodeSeekInfo} + * @memberof TranscodingProfile + */ + "TranscodeSeekInfo"?: TranscodeSeekInfo; + /** + * + * @type {boolean} + * @memberof TranscodingProfile + */ + "CopyTimestamps"?: boolean; + /** + * + * @type {EncodingContext} + * @memberof TranscodingProfile + */ + "Context"?: EncodingContext; + /** + * + * @type {boolean} + * @memberof TranscodingProfile + */ + "EnableSubtitlesInManifest"?: boolean; + /** + * + * @type {string} + * @memberof TranscodingProfile + */ + "MaxAudioChannels"?: string | null; + /** + * + * @type {number} + * @memberof TranscodingProfile + */ + "MinSegments"?: number; + /** + * + * @type {number} + * @memberof TranscodingProfile + */ + "SegmentLength"?: number; + /** + * + * @type {boolean} + * @memberof TranscodingProfile + */ + "BreakOnNonKeyFrames"?: boolean; + /** + * + * @type {Array<ProfileCondition>} + * @memberof TranscodingProfile + */ + "Conditions"?: Array<ProfileCondition>; +} + +/** + * + * @export + * @enum {string} + */ + +export const CodecType = { + Video: "Video", + VideoAudio: "VideoAudio", + Audio: "Audio", +} as const + +export type CodecType = typeof CodecType[keyof typeof CodecType]; + +/** + * + * @export + * @interface CodecProfile + */ +export interface CodecProfile { + /** + * + * @type {CodecType} + * @memberof CodecProfile + */ + "Type"?: CodecType; + /** + * + * @type {Array<ProfileCondition>} + * @memberof CodecProfile + */ + "Conditions"?: Array<ProfileCondition> | null; + /** + * + * @type {Array<ProfileCondition>} + * @memberof CodecProfile + */ + "ApplyConditions"?: Array<ProfileCondition> | null; + /** + * + * @type {string} + * @memberof CodecProfile + */ + "Codec"?: string | null; + /** + * + * @type {string} + * @memberof CodecProfile + */ + "Container"?: string | null; +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/response-profile.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/response-profile.ts new file mode 100644 index 0000000..6be060d --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/response-profile.ts @@ -0,0 +1,22 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { DlnaProfileType } from "@/lib/utils/playback-profiles/jellyfin-types" + +/** + * Returns a valid ResponseProfile for the current platform. + * + * @returns An array of subtitle profiles for the current platform. + */ +export function getResponseProfiles() { + const ResponseProfiles = [] + + ResponseProfiles.push({ + Type: DlnaProfileType.Video, + Container: "m4v", + MimeType: "video/mp4", + }) + + return ResponseProfiles +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/subtitle-profile.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/subtitle-profile.ts new file mode 100644 index 0000000..7e80726 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/subtitle-profile.ts @@ -0,0 +1,32 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + + +import { SubtitleDeliveryMethod, SubtitleProfile } from "@/lib/utils/playback-profiles/jellyfin-types" + +/** + * Returns a valid SubtitleProfile for the current platform. + * + * @returns An array of subtitle profiles for the current platform. + */ +export function getSubtitleProfiles(): SubtitleProfile[] { + const SubtitleProfiles: SubtitleProfile[] = [] + + SubtitleProfiles.push( + { + Format: "vtt", + Method: SubtitleDeliveryMethod.External, + }, + { + Format: "ass", + Method: SubtitleDeliveryMethod.External, + }, + { + Format: "ssa", + Method: SubtitleDeliveryMethod.External, + }, + ) + + return SubtitleProfiles +} diff --git a/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/transcoding-profile.ts b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/transcoding-profile.ts new file mode 100644 index 0000000..41394b5 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/lib/utils/playback-profiles/transcoding-profile.ts @@ -0,0 +1,118 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { DlnaProfileType, EncodingContext, TranscodingProfile } from "@/lib/utils/playback-profiles/jellyfin-types" +import { isAndroid, isApple, isChromiumBased, isEdge, isTizen, isTv } from "../browser-detection" +import { getSupportedAudioCodecs } from "./helpers/audio-formats" +import { getSupportedMP4AudioCodecs } from "./helpers/mp4-audio-formats" +import { getSupportedMP4VideoCodecs, hasVp8Support } from "./helpers/mp4-video-formats" +import { canPlayHlsWithMSE, canPlayNativeHls, hasMkvSupport } from "./helpers/transcoding-formats" +import { getSupportedTsAudioCodecs } from "./helpers/ts-audio-formats" +import { getSupportedTsVideoCodecs } from "./helpers/ts-video-formats" + +/** + * Returns a valid TranscodingProfile for the current platform. + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns An array of transcoding profiles for the current platform. + */ +export function getTranscodingProfiles( + videoTestElement: HTMLVideoElement, +): TranscodingProfile[] { + const TranscodingProfiles: TranscodingProfile[] = [] + const physicalAudioChannels = isTv() ? 6 : 2 + + const hlsBreakOnNonKeyFrames = ( + isApple() || + (isEdge() && !isChromiumBased()) || + !canPlayNativeHls(videoTestElement) + ) + + const mp4AudioCodecs = getSupportedMP4AudioCodecs(videoTestElement) + const mp4VideoCodecs = getSupportedMP4VideoCodecs(videoTestElement) + const canPlayHls = canPlayNativeHls(videoTestElement) || canPlayHlsWithMSE() + + if (canPlayHls) { + TranscodingProfiles.push({ + // Hlsjs, edge, and android all seem to require ts container + Container: + !canPlayNativeHls(videoTestElement) || + (isEdge() && !isChromiumBased()) || + isAndroid() + ? "ts" + : "aac", + Type: DlnaProfileType.Audio, + AudioCodec: "aac", + Context: EncodingContext.Streaming, + Protocol: "hls", + MaxAudioChannels: physicalAudioChannels.toString(), + MinSegments: isApple() ? 2 : 1, + BreakOnNonKeyFrames: hlsBreakOnNonKeyFrames, + }) + } + + for (const audioFormat of ["aac", "mp3", "opus", "wav"].filter((format) => + getSupportedAudioCodecs(format), + )) { + TranscodingProfiles.push({ + Container: audioFormat, + Type: DlnaProfileType.Audio, + AudioCodec: audioFormat, + Context: EncodingContext.Streaming, + Protocol: "http", + MaxAudioChannels: physicalAudioChannels.toString(), + }) + } + + const hlsInTsVideoCodecs = getSupportedTsVideoCodecs(videoTestElement) + const hlsInTsAudioCodecs = getSupportedTsAudioCodecs(videoTestElement) + + if ( + canPlayHls && + hlsInTsVideoCodecs.length > 0 && + hlsInTsAudioCodecs.length > 0 + ) { + TranscodingProfiles.push({ + Container: "ts", + Type: DlnaProfileType.Video, + AudioCodec: hlsInTsAudioCodecs.join(","), + VideoCodec: hlsInTsVideoCodecs.join(","), + Context: EncodingContext.Streaming, + Protocol: "hls", + MaxAudioChannels: physicalAudioChannels.toString(), + MinSegments: isApple() ? 2 : 1, + BreakOnNonKeyFrames: hlsBreakOnNonKeyFrames, + }) + } + + if (hasMkvSupport(videoTestElement) && !isTizen()) { + TranscodingProfiles.push({ + Container: "mkv", + Type: DlnaProfileType.Video, + AudioCodec: mp4AudioCodecs.join(","), + VideoCodec: mp4VideoCodecs.join(","), + Context: EncodingContext.Streaming, + MaxAudioChannels: physicalAudioChannels.toString(), + CopyTimestamps: true, + }) + } + + if (hasVp8Support(videoTestElement)) { + TranscodingProfiles.push({ + Container: "webm", + Type: DlnaProfileType.Video, + AudioCodec: "vorbis", + VideoCodec: "vpx", + Context: EncodingContext.Streaming, + Protocol: "http", + /* + * If audio transcoding is needed, limit channels to number of physical audio channels + * Trying to transcode to 5 channels when there are only 2 speakers generally does not sound good + */ + MaxAudioChannels: physicalAudioChannels.toString(), + }) + } + + return TranscodingProfiles +} diff --git a/seanime-2.9.10/seanime-web/src/types/common.ts b/seanime-2.9.10/seanime-web/src/types/common.ts new file mode 100644 index 0000000..02963fd --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/types/common.ts @@ -0,0 +1 @@ +export type Nullish<T> = T | null | undefined diff --git a/seanime-2.9.10/seanime-web/src/types/constants.ts b/seanime-2.9.10/seanime-web/src/types/constants.ts new file mode 100644 index 0000000..57f71d0 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/types/constants.ts @@ -0,0 +1,3 @@ +export const __isDesktop__ = process.env.NEXT_PUBLIC_PLATFORM === "desktop" // Tauri +export const __isElectronDesktop__ = process.env.NEXT_PUBLIC_DESKTOP === "electron" +export const __isTauriDesktop__ = process.env.NEXT_PUBLIC_DESKTOP === "tauri" diff --git a/seanime-2.9.10/seanime-web/src/types/index.d.ts b/seanime-2.9.10/seanime-web/src/types/index.d.ts new file mode 100644 index 0000000..f0f6197 --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/types/index.d.ts @@ -0,0 +1,65 @@ +import "@total-typescript/ts-reset" + +declare global { + interface AudioTrack { + id: string; + kind: string; + label: string; + language: string; + enabled: boolean; + } + + interface AudioTrackList extends EventTarget { + readonly length: number; + onchange: ((this: AudioTrackList, ev: Event) => any) | null; + onaddtrack: ((this: AudioTrackList, ev: TrackEvent) => any) | null; + onremovetrack: ((this: AudioTrackList, ev: TrackEvent) => any) | null; + + [index: number]: AudioTrack; + + getTrackById(id: string): AudioTrack | null; + } + + interface HTMLMediaElement { + readonly audioTracks: AudioTrackList | undefined; + } + + interface Window { + electron?: { + window: { + minimize: () => void; + maximize: () => void; + close: () => void; + isMaximized: () => Promise<boolean>; + isMinimizable: () => Promise<boolean>; + isMaximizable: () => Promise<boolean>; + isClosable: () => Promise<boolean>; + isFullscreen: () => Promise<boolean>; + setFullscreen: (fullscreen: boolean) => void; + toggleMaximize: () => void; + hide: () => void; + show: () => void; + isVisible: () => Promise<boolean>; + setTitleBarStyle: (style: string) => void; + getCurrentWindow: () => Promise<string>; + }; + on: (channel: string, callback: (...args: any[]) => void) => (() => void) | undefined; + // Send events + emit: (channel: string, data?: any) => void; + // General send method + send: (channel: string, ...args: any[]) => void; + platform: NodeJS.Platform; + shell: { + open: (url: string) => Promise<void>; + }; + clipboard: { + writeText: (text: string) => Promise<void>; + }; + checkForUpdates: () => Promise<any>; + installUpdate: () => Promise<any>; + killServer: () => Promise<any>; + }; + + __isElectronDesktop__?: boolean; + } +} diff --git a/seanime-2.9.10/seanime-web/src/types/normalize-path.d.ts b/seanime-2.9.10/seanime-web/src/types/normalize-path.d.ts new file mode 100644 index 0000000..7189dca --- /dev/null +++ b/seanime-2.9.10/seanime-web/src/types/normalize-path.d.ts @@ -0,0 +1,5 @@ +declare module "normalize-path" { + function normalizePath(path: string): string; + + export = normalizePath; +} diff --git a/seanime-2.9.10/seanime-web/tailwind.config.ts b/seanime-2.9.10/seanime-web/tailwind.config.ts new file mode 100644 index 0000000..681eece --- /dev/null +++ b/seanime-2.9.10/seanime-web/tailwind.config.ts @@ -0,0 +1,341 @@ +import type { Config } from "tailwindcss" + +const config: Config = { + darkMode: "class", + content: [ + "./index.html", + "./src/app/**/*.{ts,tsx,mdx}", + "./src/pages/**/*.{ts,tsx,mdx}", + "./src/components/**/*.{ts,tsx,mdx}", + ], + safelist: [ + "bg-amber-900", "bg-amber-800", "bg-amber-700", "bg-amber-600", "bg-amber-500", "bg-amber-400", "bg-amber-400", "bg-amber-300", + "text-amber-300", + "text-amber-200", + "bg-green-900", "bg-green-800", "bg-green-700", "bg-green-600", "bg-green-500", "bg-green-400", "bg-green-400", "bg-green-300", + "text-green-300", + "text-green-200", + "bg-gray-900", "bg-gray-800", "bg-gray-700", "bg-gray-600", "bg-gray-500", "bg-gray-400", "bg-gray-400", "bg-gray-300", "text-gray-300", + "bg-indigo-900", "bg-indigo-800", "bg-indigo-700", "bg-indigo-600", "bg-indigo-500", "bg-indigo-400", "bg-indigo-400", "bg-indigo-300", + "text-indigo-300", "text-indigo-200", + "bg-lime-900", "bg-lime-800", "bg-lime-700", "bg-lime-600", "bg-lime-500", "bg-lime-400", "bg-lime-400", "bg-lime-300", "text-lime-300", + "text-lime-200", + "text-lime-400", + "text-lime-500", + "bg-red-900", "bg-red-800", "bg-red-700", "bg-red-600", "bg-red-500", "bg-red-400", "bg-red-400", "bg-red-300", "text-red-300", + "text-red-200", + "bg-emerald-900", "bg-emerald-800", "bg-emerald-700", "bg-emerald-600", "bg-emerald-500", "bg-emerald-400", "bg-emerald-400", + "bg-emerald-300", "text-emerald-300", "text-emerald-200", "text-emerald-400", "text-emerald-500", + "bg-purple-900", "bg-purple-800", "bg-purple-700", "bg-purple-600", "bg-purple-500", "bg-purple-400", "bg-purple-400", + "bg-green-300", "text-green-300", "text-green-200", "text-green-400", "text-green-500", + "bg-opacity-70", + "bg-opacity-80", + "bg-opacity-70", + "bg-opacity-60", + "bg-opacity-50", + "bg-opacity-30", + "bg-opacity-20", + "bg-opacity-10", + "bg-opacity-5", + "text-audienceScore-100", "text-audienceScore-200", "text-audienceScore-300", "text-audienceScore-400", "text-audienceScore-500", + "text-audienceScore-600", "text-audienceScore-700", "text-audienceScore-800", "text-audienceScore-900", + "drop-shadow-sm", + "-top-10 top-10", + + { + pattern: /bg-(red|green|blue|gray|brand|orange|yellow)-(100|200|300|400|500|600|700|800|900|950)/, + variants: ["hover"], + }, + // { + // pattern: /text-(red|green|blue|gray|brand|orange|yellow)-(100|200|300|400|500|600|700|800|900|950)/, + // variants: ['hover'], + // }, + // { + // pattern: /border-(red|green|blue|gray|brand|orange|yellow)-(100|200|300|400|500|600|700|800|900|950)/, + // }, + + { + pattern: /p-[0-9]+/, + }, + { + pattern: /m-[0-9]+/, + }, + { + pattern: /gap-[0-9]+/, + }, + { + pattern: /(px|py|pt|pb|pl|pr)-[0-9]+/, + }, + { + pattern: /(mx|my|mt|mb|ml|mr)-[0-9]+/, + }, + + { + pattern: /grid-cols-[1-9]+/, + variants: ["lg"], + }, + { + pattern: /col-span-[1-9]+/, + variants: ["lg"], + }, + "flex", "inline-flex", "grid", "inline-grid", + "flex-row", "flex-col", "flex-row-reverse", "flex-col-reverse", + "flex-wrap", "flex-nowrap", "flex-wrap-reverse", + "items-start", "items-center", "items-end", "items-baseline", "items-stretch", + "justify-start", "justify-center", "justify-end", "justify-between", "justify-around", "justify-evenly", + + { + pattern: /flex|inline-flex|grid|inline-grid|flex-row|flex-col|flex-row-reverse|flex-col-reverse|flex-wrap|flex-nowrap|flex-wrap-reverse|items-start|items-center|items-end|items-baseline|items-stretch|justify-start|justify-center|justify-end|justify-between|justify-around|justify-evenly/, + variants: ["lg", "md"], + }, + + + // { + // pattern: /w-[0-9]+/, + // variants: ['lg', 'md', 'sm', 'xl', '2xl'], + // }, + // { + // pattern: /h-[0-9]+/, + // variants: ['lg', 'md', 'sm', 'xl', '2xl'], + // }, + "w-full", "h-full", "w-screen", "h-screen", "w-auto", "h-auto", + "min-w-0", "min-h-0", "max-w-none", "max-h-none", + + { + pattern: /text-xs|text-sm|text-base|text-lg|text-xl|text-2xl|text-3xl/, + variants: ["lg", "md"], + }, + + { + pattern: /font-thin|font-light|font-normal|font-medium|font-semibold|font-bold/, + variants: ["lg", "md"], + }, + + { + pattern: /text-left|text-center|text-right|text-justify/, + variants: ["lg", "md"], + }, + + + "uppercase", "lowercase", "capitalize", "normal-case", + "truncate", "overflow-ellipsis", "overflow-clip", + + "rounded-none", "rounded-sm", "rounded", "rounded-md", "rounded-lg", "rounded-xl", "rounded-2xl", "rounded-3xl", "rounded-full", + "border", "border-0", "border-2", "border-4", "border-8", + // { + // pattern: /border-[0-9]+/, + // variants: ['lg', 'md', 'sm', 'xl', '2xl', 'hover', 'focus'], + // }, + + "shadow-sm", "shadow", "shadow-md", "shadow-lg", "shadow-xl", "shadow-2xl", "shadow-inner", "shadow-none", + "opacity-0", "opacity-25", "opacity-50", "opacity-75", "opacity-100", + + + // "transition", "transition-all", "transition-colors", "transition-opacity", "transition-shadow", "transition-transform", + // "duration-75", "duration-100", "duration-150", "duration-200", "duration-300", "duration-500", "duration-700", "duration-1000", + // "ease-linear", "ease-in", "ease-out", "ease-in-out", + // "scale-0", "scale-50", "scale-75", "scale-90", "scale-95", "scale-100", "scale-105", "scale-110", "scale-125", "scale-150", + // "rotate-0", "rotate-45", "rotate-90", "rotate-180", "-rotate-45", "-rotate-90", "-rotate-180", + // "translate-x-0", "translate-x-1", "translate-x-2", "translate-x-4", "translate-x-8", + // "translate-y-0", "translate-y-1", "translate-y-2", "translate-y-4", "translate-y-8", + + "cursor-pointer", "cursor-not-allowed", "cursor-wait", "cursor-text", "cursor-move", "cursor-help", + "select-none", "select-text", "select-all", "select-auto", + "pointer-events-none", "pointer-events-auto", + "resize", "resize-none", "resize-y", "resize-x", + + "static", "fixed", "absolute", "relative", "sticky", + "top-0", "right-0", "bottom-0", "left-0", + "z-0", "z-10", "z-20", "z-30", "z-40", "z-50", "z-auto", + + "block", "inline-block", "inline", "hidden", + { + pattern: /hidden|block|inline|inline-block/, + variants: ["lg", "md"], + }, + + "overflow-auto", "overflow-hidden", "overflow-visible", "overflow-scroll", + "overflow-x-auto", "overflow-y-auto", "overflow-x-hidden", "overflow-y-hidden", + ], + theme: { + container: { + center: true, + padding: { + DEFAULT: "1rem", + sm: "2rem", + lg: "4rem", + xl: "5rem", + "2xl": "6rem", + }, + screens: { + "2xl": "1400px", + "3xl": "1600px", + "4xl": "1800px", + "5xl": "2000px", + "6xl": "2200px", + "7xl": "2400px", + }, + }, + data: { + checked: "checked", + selected: "selected", + disabled: "disabled", + highlighted: "highlighted", + }, + extend: { + screens: { + "3xl": "1600px", + "4xl": "1800px", + "5xl": "2000px", + "6xl": "2200px", + "7xl": "2400px", + }, + animationDuration: { + DEFAULT: "0.25s", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + "slide-down": { + from: { transform: "translateY(-1rem)", opacity: "0" }, + to: { transform: "translateY(0)", opacity: "1" }, + }, + "slide-up": { + from: { transform: "translateY(0)", opacity: "1" }, + to: { transform: "translateY(-1rem)", opacity: "0" }, + }, + "indeterminate-progress": { + "0%": { transform: " translateX(0) scaleX(0)" }, + "40%": { transform: "translateX(0) scaleX(0.4)" }, + "100%": { transform: "translateX(100%) scaleX(0.5)" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.15s linear", + "accordion-up": "accordion-up 0.15s linear", + "slide-down": "slide-down 0.15s ease-in-out", + "slide-up": "slide-up 0.15s ease-in-out", + "indeterminate-progress": "indeterminate-progress 1s infinite ease-out", + }, + transformOrigin: { + "left-right": "0% 100%", + }, + boxShadow: { + "md": "0 1px 3px 0 rgba(0, 0, 0, 0.1),0 1px 2px 0 rgba(0, 0, 0, 0.06)", + }, + colors: { + brand: { + 50: "rgb(var(--color-brand-50) / <alpha-value>)", + 100: "rgb(var(--color-brand-100) / <alpha-value>)", + 200: "rgb(var(--color-brand-200) / <alpha-value>)", + 300: "rgb(var(--color-brand-300) / <alpha-value>)", + 400: "rgb(var(--color-brand-400) / <alpha-value>)", + 500: "rgb(var(--color-brand-500) / <alpha-value>)", + 600: "rgb(var(--color-brand-600) / <alpha-value>)", + 700: "rgb(var(--color-brand-700) / <alpha-value>)", + 800: "rgb(var(--color-brand-800) / <alpha-value>)", + 900: "rgb(var(--color-brand-900) / <alpha-value>)", + 950: "rgb(var(--color-brand-950) / <alpha-value>)", + DEFAULT: "rgb(var(--color-brand-500) / <alpha-value>)", + }, + gray: { + 50: "rgb(var(--color-gray-50) / <alpha-value>)", + 100: "rgb(var(--color-gray-100) / <alpha-value>)", + 200: "rgb(var(--color-gray-200) / <alpha-value>)", + 300: "rgb(var(--color-gray-300) / <alpha-value>)", + 400: "rgb(var(--color-gray-400) / <alpha-value>)", + 500: "rgb(var(--color-gray-500) / <alpha-value>)", + 600: "rgb(var(--color-gray-600) / <alpha-value>)", + 700: "rgb(var(--color-gray-700) / <alpha-value>)", + 800: "rgb(var(--color-gray-800) / <alpha-value>)", + 900: "rgb(var(--color-gray-900) / <alpha-value>)", + 950: "rgb(var(--color-gray-950) / <alpha-value>)", + DEFAULT: "rgb(var(--color-gray-500) / <alpha-value>)", + }, + green: { + 50: "#e6f7ea", + 100: "#cfead6", + 200: "#7bd0a7", + 300: "#68b695", + 400: "#57a181", + 500: "#258c60", + 600: "#1a6444", + 700: "#154f37", + 800: "#103b29", + 900: "#0a2318", + 950: "#05130d", + DEFAULT: "#258c60", + }, + audienceScore: { + 300: "#b45d5d", + 500: "#9d8741", + 600: "#a0b974", + 700: "#57a181", + }, + background: { + 500: "rgb(var(--background) / <alpha-value>)", + DEFAULT: "rgb(var(--background) / <alpha-value>)", + }, + }, + }, + }, + plugins: [ + require("@tailwindcss/typography"), + require("@tailwindcss/forms"), + require("@headlessui/tailwindcss"), + require("tailwind-scrollbar-hide"), + require("tailwindcss-animate"), + addVariablesForColors, + function ({ addVariant }: { addVariant: (variant: string, selector: string) => void }) { + addVariant("firefox", ":-moz-any(&)") + }, + ], +} +export default config + + +function addVariablesForColors({ addBase, theme }: any) { + let allColors = flattenColorPalette(theme("colors")) + let newVars = Object.fromEntries( + Object.entries(allColors).map(([key, val]) => [`--${key}`, val]), + ) + + addBase({ + ":root": newVars, + }) +} + +type Colors = { + [key: string | number]: string | Colors +} + +function flattenColorPalette(colors: Colors) { + let result: Record<string, string> = {} + + for (let [root, children] of Object.entries(colors ?? {})) { + if (root === "__CSS_VALUES__") continue + if (typeof children === "object" && children !== null) { + for (let [parent, value] of Object.entries(flattenColorPalette(children))) { + result[`${root}${parent === "DEFAULT" ? "" : `-${parent}`}`] = value + } + } else { + result[root] = children + } + } + + if ("__CSS_VALUES__" in colors) { + for (let [key, value] of Object.entries(colors.__CSS_VALUES__)) { + if ((Number(value) & 1 << 2) === 0) { + result[key] = colors[key] as string + } + } + } + + return result +} diff --git a/seanime-2.9.10/seanime-web/tailwind.d.ts b/seanime-2.9.10/seanime-web/tailwind.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/seanime-2.9.10/seanime-web/tsconfig.json b/seanime-2.9.10/seanime-web/tsconfig.json new file mode 100644 index 0000000..8d6387d --- /dev/null +++ b/seanime-2.9.10/seanime-web/tsconfig.json @@ -0,0 +1,44 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "downlevelIteration": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "next-env.d.ts", + "out-denshi/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "out-desktop/types/**/*.ts", + "out-denshi/types/**/*.ts" + ] +} diff --git a/seanime-2.9.10/test/.gitkeep b/seanime-2.9.10/test/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/seanime-2.9.10/test/config.example.toml b/seanime-2.9.10/test/config.example.toml new file mode 100644 index 0000000..a7ef357 --- /dev/null +++ b/seanime-2.9.10/test/config.example.toml @@ -0,0 +1,34 @@ +[flags] +enable_anilist_tests = true +enable_anilist_mutation_tests = false +enable_mal_tests = true +enable_mal_mutation_tests = false +enable_media_player_tests = true + +[provider] +anilist_jwt = '' +anilist_username = '' +mal_jwt = '' +qbittorrent_username = '' +qbittorrent_password = '' +qbittorrent_host = '127.0.0.1' +qbittorrent_port = 8081 +qbittorrent_path = 'C:\Program Files\qBittorrent\qbittorrent.exe' +transmission_host = '127.0.0.1' +transmission_port = 9091 +transmission_path = 'C:\Program Files\Transmission\transmission-qt.exe' +transmission_username = '' +transmission_password = '' +mpc_host = '127.0.0.1' +mpc_port = 13579 +mpc_path = 'C:\Program Files\MPC-HC\mpc-hc64.exe' +vlc_host = '127.0.0.1' +vlc_port = 8080 +vlc_path = 'C:\Program Files\VideoLAN\VLC\vlc.exe' +torbox_api_key = "" + +[path] +dataDir = '' + +[database] +name = 'seanime-test' diff --git a/seanime-2.9.10/test/data/BoilerplateAnimeCollection.json b/seanime-2.9.10/test/data/BoilerplateAnimeCollection.json new file mode 100644 index 0000000..e5b172d --- /dev/null +++ b/seanime-2.9.10/test/data/BoilerplateAnimeCollection.json @@ -0,0 +1,11918 @@ +{ + "MediaListCollection": { + "lists": [ + { + "status": "CURRENT" + }, + { + "status": "PLANNING", + "entries": [ + { + "id": 389433701, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 131586, + "idMal": 48569, + "siteUrl": "https://anilist.co/anime/131586", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/131586-TQED17cUhGnT.jpg", + "episodes": 12, + "synonyms": [ + "86-エイティシックス- 2クール", + "86 -เอทตี้ซิกซ์- พาร์ท 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "86: Eighty Six Part 2", + "romaji": "86: Eighty Six Part 2", + "english": "86 EIGHTY-SIX Part 2", + "native": "86-エイティシックス- 第2クール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx131586-k0X2kVpUOkqX.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx131586-k0X2kVpUOkqX.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx131586-k0X2kVpUOkqX.jpg", + "color": "#e4501a" + }, + "startDate": { + "year": 2021, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 2022, + "month": 3, + "day": 19 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 116589, + "idMal": 41457, + "siteUrl": "https://anilist.co/anime/116589", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/116589-SqwYWzaZCdD5.jpg", + "episodes": 11, + "synonyms": [ + "86--EIGHTY-SIX", + "86 -เอทตี้ซิกซ์-", + "86 ВОСЕМЬДЕСЯТ ШЕСТЬ", + "86 -不存在的战区-" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "86: Eighty Six", + "romaji": "86: Eighty Six", + "english": "86 EIGHTY-SIX", + "native": "86-エイティシックス-" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx116589-WSpNedJdAH3L.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx116589-WSpNedJdAH3L.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx116589-WSpNedJdAH3L.jpg", + "color": "#78aee4" + }, + "startDate": { + "year": 2021, + "month": 4, + "day": 11 + }, + "endDate": { + "year": 2021, + "month": 6, + "day": 20 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 98610, + "idMal": 104039, + "siteUrl": "https://anilist.co/manga/98610", + "status": "RELEASING", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/98610-2DrWY14sn1s2.jpg", + "synonyms": [ + "86 -เอทตี้ซิกซ์- " + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "86: Eighty Six", + "romaji": "86: Eighty Six", + "english": "86―EIGHTY-SIX", + "native": "86―エイティシックス―" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx98610-TIf7R1gkU0vc.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx98610-TIf7R1gkU0vc.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx98610-TIf7R1gkU0vc.jpg", + "color": "#e46b28" + }, + "startDate": { + "year": 2017, + "month": 2, + "day": 10 + }, + "endDate": {} + } + }, + { + "relationType": "OTHER", + "node": { + "id": 140168, + "idMal": 50098, + "siteUrl": "https://anilist.co/anime/140168", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kyoukaisen", + "romaji": "Kyoukaisen", + "native": "境界線" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b140168-2ld4s5ezCJFW.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b140168-2ld4s5ezCJFW.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b140168-2ld4s5ezCJFW.png", + "color": "#43d6ff" + }, + "startDate": { + "year": 2021, + "month": 10, + "day": 10 + }, + "endDate": { + "year": 2021, + "month": 10, + "day": 10 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 128949, + "idMal": 137940, + "siteUrl": "https://anilist.co/manga/128949", + "status": "CANCELLED", + "type": "MANGA", + "format": "MANGA", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "86: Eighty Six - Run Through The Battlefront", + "romaji": "86: Eighty Six - Run Through The Battlefront", + "native": "86―エイティシックス― -ラン・スルー・ザ・バトルフロント-" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx128949-DDR0iO5cbehB.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx128949-DDR0iO5cbehB.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx128949-DDR0iO5cbehB.jpg" + }, + "startDate": { + "year": 2021, + "month": 1, + "day": 24 + }, + "endDate": { + "year": 2021, + "month": 9, + "day": 4 + } + } + } + ] + } + } + }, + { + "id": 389433702, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 143653, + "idMal": 50796, + "siteUrl": "https://anilist.co/anime/143653", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/143653-cvWrYzJPgDkV.jpg", + "episodes": 13, + "synonyms": [ + "Insomniaques", + "ถ้านอนไม่หลับไปนับดาวกันไหม", + "放学后失眠的你", + "Bezsenność po szkole", + "Insomnia Sepulang Sekolah" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi wa Houkago Insomnia", + "romaji": "Kimi wa Houkago Insomnia", + "english": "Insomniacs after school", + "native": "君は放課後インソムニア" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx143653-h6NEdWxKIRza.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx143653-h6NEdWxKIRza.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx143653-h6NEdWxKIRza.png", + "color": "#1a4386" + }, + "startDate": { + "year": 2023, + "month": 4, + "day": 11 + }, + "endDate": { + "year": 2023, + "month": 7, + "day": 4 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 110473, + "idMal": 121213, + "siteUrl": "https://anilist.co/manga/110473", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/110473-Fr0nJcGMmsrR.jpg", + "synonyms": [ + "Insomniaques", + "ถ้านอนไม่หลับไปนับดาวกันไหม", + "Bezsenność po szkole", + "Insones – Caçando Estrelas Depois da Aula", + "Insomnes después de la escuela", + "君ソム", + "Kimisomu", + "너는 방과후 인섬니아" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi wa Houkago Insomnia", + "romaji": "Kimi wa Houkago Insomnia", + "english": "Insomniacs After School", + "native": "君は放課後インソムニア" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx110473-igM02JDDzQXM.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx110473-igM02JDDzQXM.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx110473-igM02JDDzQXM.jpg", + "color": "#e45d50" + }, + "startDate": { + "year": 2019, + "month": 5, + "day": 20 + }, + "endDate": { + "year": 2023, + "month": 8, + "day": 21 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 160205, + "idMal": 52822, + "siteUrl": "https://anilist.co/anime/160205", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 1, + "synonyms": [ + "Insomniacs After School Special Animation PV" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi wa Houkago Insomnia Special Animation PV", + "romaji": "Kimi wa Houkago Insomnia Special Animation PV", + "native": "君は放課後インソムニア スペシャルアニメーションPV" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b160205-qziA59CxF06T.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b160205-qziA59CxF06T.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b160205-qziA59CxF06T.jpg", + "color": "#4378f1" + }, + "startDate": { + "year": 2021, + "month": 10, + "day": 28 + }, + "endDate": { + "year": 2021, + "month": 10, + "day": 28 + } + } + } + ] + } + } + }, + { + "id": 389433703, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 6045, + "idMal": 6045, + "siteUrl": "https://anilist.co/anime/6045", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/6045-PHiyDpX2gc5D.jpg", + "episodes": 25, + "synonyms": [ + "Reaching You", + "Arrivare a te", + "Llegando a ti" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke", + "romaji": "Kimi ni Todoke", + "english": "Kimi ni Todoke: From Me to You", + "native": "君に届け" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx6045-txJOukR5Qve4.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6045-txJOukR5Qve4.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx6045-txJOukR5Qve4.jpg", + "color": "#e45d6b" + }, + "startDate": { + "year": 2009, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2010, + "month": 3, + "day": 31 + }, + "relations": { + "edges": [ + { + "relationType": "SEQUEL", + "node": { + "id": 9656, + "idMal": 9656, + "siteUrl": "https://anilist.co/anime/9656", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/9656-CE0RW5otDH1X.jpg", + "episodes": 13, + "synonyms": [ + "Reaching You 2nd Season", + "Llegando a ti: Temporada 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke 2ND SEASON", + "romaji": "Kimi ni Todoke 2ND SEASON", + "english": "Kimi ni Todoke: From Me to You Season 2", + "native": "君に届け 2ND SEASON" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx9656-vckh2wNj3FwY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx9656-vckh2wNj3FwY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx9656-vckh2wNj3FwY.jpg", + "color": "#f18635" + }, + "startDate": { + "year": 2011, + "month": 1, + "day": 12 + }, + "endDate": { + "year": 2011, + "month": 3, + "day": 30 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 33378, + "idMal": 3378, + "siteUrl": "https://anilist.co/manga/33378", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/33378-9RtoYIQMFGq0.jpg", + "synonyms": [ + "Reaching You", + "Llegando a Ti", + "Que Chegue a Você", + "Sawako", + "Nguyện ước yêu thương" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke", + "romaji": "Kimi ni Todoke", + "english": "Kimi ni Todoke: From Me to You", + "native": "君に届け" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx33378-G9sHqsJryoP2.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx33378-G9sHqsJryoP2.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx33378-G9sHqsJryoP2.jpg", + "color": "#e4a15d" + }, + "startDate": { + "year": 2006, + "month": 5, + "day": 25 + }, + "endDate": { + "year": 2017, + "month": 11, + "day": 13 + } + } + } + ] + } + } + }, + { + "id": 389433704, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 6746, + "idMal": 6746, + "siteUrl": "https://anilist.co/anime/6746", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/6746-84oNA7P9pboV.jpg", + "episodes": 24, + "synonyms": [ + "DRRR!!", + "דורארארה!!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx6746-Q4EmstN2fy0R.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6746-Q4EmstN2fy0R.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx6746-Q4EmstN2fy0R.png", + "color": "#f1e4bb" + }, + "startDate": { + "year": 2010, + "month": 1, + "day": 8 + }, + "endDate": { + "year": 2010, + "month": 6, + "day": 25 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 46816, + "idMal": 16816, + "siteUrl": "https://anilist.co/manga/46816", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/46816-1KVtcolq8c1H.jpg", + "synonyms": [ + "DRRR!!", + "Дюрарара!!", + "DRRR!! โลกบิดเบี้ยวที่อิเคะบุคุโระ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx46816-D2DhHjjCu2ow.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx46816-D2DhHjjCu2ow.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx46816-D2DhHjjCu2ow.png", + "color": "#e4ae50" + }, + "startDate": { + "year": 2004, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2014, + "month": 1, + "day": 10 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 8408, + "idMal": 8408, + "siteUrl": "https://anilist.co/anime/8408", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n8408-QGzgRIr6s4z2.jpg", + "episodes": 2, + "synonyms": [ + "Durarara!! Episode 12.5", + "Durarara!! Episode 25", + "Dhurarara!!", + "Dyurarara!!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!! Specials", + "romaji": "Durarara!! Specials", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx8408-ty3umDE46vVK.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx8408-ty3umDE46vVK.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx8408-ty3umDE46vVK.png", + "color": "#f1d61a" + }, + "startDate": { + "year": 2010, + "month": 8, + "day": 25 + }, + "endDate": { + "year": 2011, + "month": 2, + "day": 23 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 20652, + "idMal": 23199, + "siteUrl": "https://anilist.co/anime/20652", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20652-sCk2BUWiRMLc.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Shou", + "דורארארה!!2x התפתחות" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Shou", + "romaji": "Durarara!!x2 Shou", + "english": "Durarara!! X2", + "native": "デュラララ!!×2 承" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20652-8ft6GZKEoeWn.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20652-8ft6GZKEoeWn.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20652-8ft6GZKEoeWn.png" + }, + "startDate": { + "year": 2015, + "month": 1, + "day": 10 + }, + "endDate": { + "year": 2015, + "month": 3, + "day": 28 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 2251, + "idMal": 2251, + "siteUrl": "https://anilist.co/anime/2251", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2251-FVSbYyJhQPj2.jpg", + "episodes": 13, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Baccano!", + "romaji": "Baccano!", + "english": "Baccano!", + "native": "バッカーノ!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx2251-Wa30L0Abk50O.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2251-Wa30L0Abk50O.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx2251-Wa30L0Abk50O.jpg", + "color": "#e4bb5d" + }, + "startDate": { + "year": 2007, + "month": 7, + "day": 27 + }, + "endDate": { + "year": 2007, + "month": 11, + "day": 2 + } + } + } + ] + } + } + }, + { + "id": 389433705, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 135778, + "idMal": 49154, + "siteUrl": "https://anilist.co/anime/135778", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135778-5QVzKcX4fskO.jpg", + "episodes": 12, + "synonyms": [ + "ハイカード", + "Старшая карта" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "HIGH CARD", + "romaji": "HIGH CARD", + "english": "HIGH CARD", + "native": "HIGH CARD" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135778-Qldd93789wTL.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135778-Qldd93789wTL.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135778-Qldd93789wTL.jpg", + "color": "#e4bb5d" + }, + "startDate": { + "year": 2023, + "month": 1, + "day": 9 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 27 + }, + "relations": { + "edges": [ + { + "relationType": "ADAPTATION", + "node": { + "id": 161651, + "idMal": 152668, + "siteUrl": "https://anilist.co/manga/161651", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "HIGH CARD: ♢9 No Mercy", + "romaji": "HIGH CARD: ♢9 No Mercy", + "native": "HIGH CARD: ♢9 No Mercy" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx161651-bZuejg6Mm1A5.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx161651-bZuejg6Mm1A5.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx161651-bZuejg6Mm1A5.jpg", + "color": "#e4bb35" + }, + "startDate": { + "year": 2022, + "month": 8, + "day": 31 + }, + "endDate": {} + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 163151, + "idMal": 54869, + "siteUrl": "https://anilist.co/anime/163151", + "status": "RELEASING", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163151-0RAW1fABXTDH.jpg", + "episodes": 12, + "synonyms": [ + "Старшая карта 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "HIGH CARD season 2", + "romaji": "HIGH CARD season 2", + "english": "HIGH CARD Season 2", + "native": "HIGH CARD season 2" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163151-yxXcufmMoCmv.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163151-yxXcufmMoCmv.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163151-yxXcufmMoCmv.jpg", + "color": "#e47850" + }, + "startDate": { + "year": 2024, + "month": 1, + "day": 8 + }, + "endDate": { + "year": 2024, + "month": 3, + "day": 25 + } + } + } + ] + } + } + }, + { + "id": 389433706, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21335, + "idMal": 31490, + "siteUrl": "https://anilist.co/anime/21335", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21335-ps20iVSGUXbD.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 13", + "航海王之黄金城" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: GOLD", + "romaji": "ONE PIECE FILM: GOLD", + "english": "One Piece Film: Gold", + "native": "ONE PIECE FILM GOLD" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21335-XsXdE0AeOkkZ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21335-XsXdE0AeOkkZ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21335-XsXdE0AeOkkZ.jpg", + "color": "#f1bb35" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 23 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 23 + }, + "relations": { + "edges": [ + { + "relationType": "PARENT", + "node": { + "id": 21, + "idMal": 21, + "siteUrl": "https://anilist.co/anime/21", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21-wf37VakJmZqs.jpg", + "synonyms": [ + "ワンピース", + "海贼王", + "וואן פיס", + "ون بيس", + "วันพีซ", + "Vua Hải Tặc", + "All'arrembaggio!", + "Tutti all'arrembaggio!", + "Ντρέηκ, το Κυνήγι του Θησαυρού" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE", + "romaji": "ONE PIECE", + "english": "ONE PIECE", + "native": "ONE PIECE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21-tXMN3Y20PIL9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21-tXMN3Y20PIL9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21-tXMN3Y20PIL9.jpg", + "color": "#e4a15d" + }, + "startDate": { + "year": 1999, + "month": 10, + "day": 20 + }, + "endDate": {} + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 21880, + "idMal": 33606, + "siteUrl": "https://anilist.co/anime/21880", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21880-9gGzVvnzqiNA.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: GOLD - episode 0 711ver.", + "romaji": "ONE PIECE FILM: GOLD - episode 0 711ver.", + "native": "ONE PIECE FILM GOLD 〜episode 0〜 711ver." + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21880-uxsZ880LXSdY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21880-uxsZ880LXSdY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21880-uxsZ880LXSdY.jpg", + "color": "#e4a135" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 2 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 2 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 105143, + "idMal": 38234, + "siteUrl": "https://anilist.co/anime/105143", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/105143-y8oSKa8PSsgK.jpg", + "episodes": 1, + "synonyms": [ + "ワンピース スタンピード", + "One Piece: Estampida", + "航海王:狂热行动", + "One Piece Film 14" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE STAMPEDE", + "romaji": "ONE PIECE STAMPEDE", + "english": "One Piece: Stampede", + "native": "ONE PIECE STAMPEDE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx105143-5uBDmhvMr6At.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx105143-5uBDmhvMr6At.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx105143-5uBDmhvMr6At.png", + "color": "#e4e450" + }, + "startDate": { + "year": 2019, + "month": 8, + "day": 9 + }, + "endDate": { + "year": 2019, + "month": 8, + "day": 9 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 21831, + "idMal": 33338, + "siteUrl": "https://anilist.co/anime/21831", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21831-Xl4r2uBaaKU4.png", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Heart of Gold", + "romaji": "ONE PIECE: Heart of Gold", + "english": "One Piece: Heart of Gold", + "native": "ONE PIECE 〜ハートオブゴールド〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21831-qj5IKYiPOupF.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21831-qj5IKYiPOupF.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21831-qj5IKYiPOupF.jpg", + "color": "#f1a10d" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 16 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 16 + } + } + } + ] + } + } + }, + { + "id": 389433707, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 2759, + "idMal": 2759, + "siteUrl": "https://anilist.co/anime/2759", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2759-EzK5WpFQz5ZT.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 1.11", + "Neon Genesis Evangelion: [Nie] Jesteś Sam", + "Reconstrucción de Evangelion", + "Евангелион 1.11: Ты (не) один", + "Реконструкция Евангелиона - Евангелион: 1.0 Ты [Не] Одинок", + "福音戰士新劇場版:序", + "Evangelion 1.0: Você [Não] Está Só", + "Evangelion 1.0: Você [Não] Está Sozinho", + "EVANGELION:1.11 (NO) ESTÁS SOLO" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Jo", + "romaji": "Evangelion Shin Movie: Jo", + "english": "Evangelion: 1.0 You Are (Not) Alone", + "native": "ヱヴァンゲリヲン新劇場版:序" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx2759-z07kq8Pnw5B1.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2759-z07kq8Pnw5B1.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx2759-z07kq8Pnw5B1.jpg", + "color": "#e4bba1" + }, + "startDate": { + "year": 2007, + "month": 9, + "day": 1 + }, + "endDate": { + "year": 2007, + "month": 9, + "day": 1 + }, + "relations": { + "edges": [ + { + "relationType": "SEQUEL", + "node": { + "id": 3784, + "idMal": 3784, + "siteUrl": "https://anilist.co/anime/3784", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3784-OYyfe6vR2687.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 2.22", + "EVANGELION:2.22 VOCÊ (NÃO) PODE AVANÇAR", + "EVANGELION:2.22 (NO) PUEDES AVANZAR" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Ha", + "romaji": "Evangelion Shin Movie: Ha", + "english": "Evangelion: 2.0 You Can (Not) Advance", + "native": "ヱヴァンゲリヲン新劇場版:破" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3784-TGCsqLryKJ2R.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3784-TGCsqLryKJ2R.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3784-TGCsqLryKJ2R.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2009, + "month": 7, + "day": 27 + }, + "endDate": { + "year": 2009, + "month": 7, + "day": 27 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 100330, + "idMal": 32311, + "siteUrl": "https://anilist.co/anime/100330", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "synonyms": [ + "عالم جميل" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Beautiful World", + "romaji": "Beautiful World", + "native": "ビューティフル・ワールド" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx100330-UKBfb9nc9QR1.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx100330-UKBfb9nc9QR1.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx100330-UKBfb9nc9QR1.png", + "color": "#285d78" + }, + "startDate": { + "year": 2014, + "month": 12, + "day": 2 + }, + "endDate": { + "year": 2014, + "month": 12, + "day": 2 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 32, + "idMal": 32, + "siteUrl": "https://anilist.co/anime/32", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n32-BH9yHJBQqeOa.jpg", + "episodes": 1, + "synonyms": [ + "הסוף של אוונגליון", + "אוונגליון של הסוף", + "Конец Евангелиона" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "romaji": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "english": "Neon Genesis Evangelion: The End of Evangelion", + "native": "新世紀エヴァンゲリオン劇場版 Air/まごころを、君に" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx32-i4ijZI4MuPiV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx32-i4ijZI4MuPiV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx32-i4ijZI4MuPiV.jpg", + "color": "#e46b50" + }, + "startDate": { + "year": 1997, + "month": 7, + "day": 19 + }, + "endDate": { + "year": 1997, + "month": 7, + "day": 19 + } + } + } + ] + } + } + }, + { + "id": 389433720, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 3784, + "idMal": 3784, + "siteUrl": "https://anilist.co/anime/3784", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3784-OYyfe6vR2687.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 2.22", + "EVANGELION:2.22 VOCÊ (NÃO) PODE AVANÇAR", + "EVANGELION:2.22 (NO) PUEDES AVANZAR" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Ha", + "romaji": "Evangelion Shin Movie: Ha", + "english": "Evangelion: 2.0 You Can (Not) Advance", + "native": "ヱヴァンゲリヲン新劇場版:破" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3784-TGCsqLryKJ2R.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3784-TGCsqLryKJ2R.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3784-TGCsqLryKJ2R.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2009, + "month": 7, + "day": 27 + }, + "endDate": { + "year": 2009, + "month": 7, + "day": 27 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 2759, + "idMal": 2759, + "siteUrl": "https://anilist.co/anime/2759", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2759-EzK5WpFQz5ZT.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 1.11", + "Neon Genesis Evangelion: [Nie] Jesteś Sam", + "Reconstrucción de Evangelion", + "Евангелион 1.11: Ты (не) один", + "Реконструкция Евангелиона - Евангелион: 1.0 Ты [Не] Одинок", + "福音戰士新劇場版:序", + "Evangelion 1.0: Você [Não] Está Só", + "Evangelion 1.0: Você [Não] Está Sozinho", + "EVANGELION:1.11 (NO) ESTÁS SOLO" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Jo", + "romaji": "Evangelion Shin Movie: Jo", + "english": "Evangelion: 1.0 You Are (Not) Alone", + "native": "ヱヴァンゲリヲン新劇場版:序" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx2759-z07kq8Pnw5B1.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2759-z07kq8Pnw5B1.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx2759-z07kq8Pnw5B1.jpg", + "color": "#e4bba1" + }, + "startDate": { + "year": 2007, + "month": 9, + "day": 1 + }, + "endDate": { + "year": 2007, + "month": 9, + "day": 1 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 3785, + "idMal": 3785, + "siteUrl": "https://anilist.co/anime/3785", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3785-UyfunULvn6PQ.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 3.33", + "Rebuild of Evangelion 3.0 Q Quickening", + "EVANGELION:3.33 VOCÊ (NÃO) PODE REFAZER", + "EVANGELION: 3.33 TÚ (NO) LO PUEDES REHACER" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Kyuu", + "romaji": "Evangelion Shin Movie: Kyuu", + "english": "Evangelion: 3.0 You Can (Not) Redo", + "native": "ヱヴァンゲリヲン新劇場版:Q" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3785-OG857YhQalvS.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3785-OG857YhQalvS.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3785-OG857YhQalvS.png", + "color": "#1a356b" + }, + "startDate": { + "year": 2012, + "month": 11, + "day": 17 + }, + "endDate": { + "year": 2012, + "month": 11, + "day": 17 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 100330, + "idMal": 32311, + "siteUrl": "https://anilist.co/anime/100330", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "synonyms": [ + "عالم جميل" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Beautiful World", + "romaji": "Beautiful World", + "native": "ビューティフル・ワールド" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx100330-UKBfb9nc9QR1.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx100330-UKBfb9nc9QR1.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx100330-UKBfb9nc9QR1.png", + "color": "#285d78" + }, + "startDate": { + "year": 2014, + "month": 12, + "day": 2 + }, + "endDate": { + "year": 2014, + "month": 12, + "day": 2 + } + } + } + ] + } + } + }, + { + "id": 389433722, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21827, + "idMal": 33352, + "siteUrl": "https://anilist.co/anime/21827", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21827-ROucgYiiiSpR.jpg", + "episodes": 13, + "synonyms": [ + "ויולט אברגרדן", + "فيوليت", + "紫罗兰永恒花园" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden", + "romaji": "Violet Evergarden", + "english": "Violet Evergarden", + "native": "ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21827-10F6m50H4GJK.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21827-10F6m50H4GJK.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21827-10F6m50H4GJK.png", + "color": "#3586e4" + }, + "startDate": { + "year": 2018, + "month": 1, + "day": 11 + }, + "endDate": { + "year": 2018, + "month": 4, + "day": 5 + }, + "relations": { + "edges": [ + { + "relationType": "SIDE_STORY", + "node": { + "id": 101432, + "idMal": 37095, + "siteUrl": "https://anilist.co/anime/101432", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n101432-kUA3US0cumo4.jpg", + "episodes": 1, + "synonyms": [ + "فيوليت: رسالة" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden: Kitto \"Ai\" wo Shiru Hi ga Kuru no Darou", + "romaji": "Violet Evergarden: Kitto \"Ai\" wo Shiru Hi ga Kuru no Darou", + "english": "Violet Evergarden: Special", + "native": "ヴァイオレット・エヴァーガーデン きっと\"愛\"を知る日が来るのだろう" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx101432-NQSedsCDQ6dP.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx101432-NQSedsCDQ6dP.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx101432-NQSedsCDQ6dP.png", + "color": "#d6ae6b" + }, + "startDate": { + "year": 2018, + "month": 7, + "day": 4 + }, + "endDate": { + "year": 2018, + "month": 7, + "day": 4 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 97298, + "idMal": 98930, + "siteUrl": "https://anilist.co/manga/97298", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/97298-uybqRwjpsgyX.png", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden", + "romaji": "Violet Evergarden", + "native": "ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx97298-2KETOAaDaTw7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx97298-2KETOAaDaTw7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx97298-2KETOAaDaTw7.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2015, + "month": 12, + "day": 25 + }, + "endDate": { + "year": 2016, + "month": 12, + "day": 26 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 109190, + "idMal": 39741, + "siteUrl": "https://anilist.co/anime/109190", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/109190-SpM8A4w83FnR.jpg", + "episodes": 1, + "synonyms": [ + "Violet Evergarden und das Band der Freundschaft", + "Violet Evergarden Gaiden: La Eternidad y la Muñeca de Recuerdos Automáticos", + "Violet Evergarden Gaiden: Eternidade e a Boneca de Automemória", + "فيوليت: الأبدية وذكريات الدمية الآلية", + "Вайолет Эвергарден: Вечность и призрак пера", + "Violet Evergarden: Věčnost a Píšící panenka" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "romaji": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "english": "Violet Evergarden: Eternity and the Auto Memory Doll", + "native": "ヴァイオレット・エヴァーガーデン 外伝~永遠と自動手記人形~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx109190-e8mv1qdmpjLW.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx109190-e8mv1qdmpjLW.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx109190-e8mv1qdmpjLW.jpg", + "color": "#e4e4a1" + }, + "startDate": { + "year": 2019, + "month": 9, + "day": 6 + }, + "endDate": { + "year": 2019, + "month": 9, + "day": 6 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 154164, + "idMal": 42166, + "siteUrl": "https://anilist.co/anime/154164", + "status": "FINISHED", + "type": "ANIME", + "format": "ONA", + "episodes": 2, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden CM", + "romaji": "Violet Evergarden CM", + "native": "ヴァイオレット・エヴァーガーデン CM" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b154164-3fNKxQJWaFf0.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b154164-3fNKxQJWaFf0.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b154164-3fNKxQJWaFf0.jpg", + "color": "#c9e45d" + }, + "startDate": { + "year": 2016, + "month": 5, + "day": 27 + }, + "endDate": { + "year": 2017, + "month": 3, + "day": 16 + } + } + } + ] + } + } + }, + { + "id": 389433723, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 3786, + "idMal": 3786, + "siteUrl": "https://anilist.co/anime/3786", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3786-IfWKqRp9grgo.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 4.0", + "EVANGELION:3.0+1.01 THRICE UPON A TIME ", + "EVANGELION:3.0+1.01 A ESPERANÇA", + "อีวานเกเลียน:3.0+1.01 สามครั้งก่อน เมื่อเนิ่นนานมาแล้ว", + "Evangelion 3.0+1.11", + "EVANGELION:3.0+1.01 TRIPLE" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Evangelion Movie:||", + "romaji": "Shin Evangelion Movie:||", + "english": "Evangelion: 3.0+1.0 Thrice Upon a Time", + "native": "シン・エヴァンゲリオン劇場版:||" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3786-FPo09WTuoTCV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3786-FPo09WTuoTCV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3786-FPo09WTuoTCV.jpg", + "color": "#50bbe4" + }, + "startDate": { + "year": 2021, + "month": 3, + "day": 8 + }, + "endDate": { + "year": 2021, + "month": 3, + "day": 8 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 3785, + "idMal": 3785, + "siteUrl": "https://anilist.co/anime/3785", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3785-UyfunULvn6PQ.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 3.33", + "Rebuild of Evangelion 3.0 Q Quickening", + "EVANGELION:3.33 VOCÊ (NÃO) PODE REFAZER", + "EVANGELION: 3.33 TÚ (NO) LO PUEDES REHACER" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Kyuu", + "romaji": "Evangelion Shin Movie: Kyuu", + "english": "Evangelion: 3.0 You Can (Not) Redo", + "native": "ヱヴァンゲリヲン新劇場版:Q" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3785-OG857YhQalvS.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3785-OG857YhQalvS.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3785-OG857YhQalvS.png", + "color": "#1a356b" + }, + "startDate": { + "year": 2012, + "month": 11, + "day": 17 + }, + "endDate": { + "year": 2012, + "month": 11, + "day": 17 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 20947, + "idMal": 28149, + "siteUrl": "https://anilist.co/anime/20947", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20947-7uPxrc63bFxM.jpg", + "episodes": 36, + "synonyms": [ + "The Dragon Dentist", + "HILL CLIMB GIRL", + "ME!ME!ME!", + "Carnage", + "Yoshikazu Yasuhiko \u0026 Ichiro Itano: Collection of Key Animation Films", + "20min Walk From Nishi-Ogikubo Station, 2 Bedrooms, Living Room, Dining Room, Kitchen, 2mos Deposit, No Pets Allowed", + "Until you come to me.", + "Tomorrow from there", + "Denkou Choujin Gridman: boys invent great hero", + "YAMADELOID", + "POWER PLANT No. 33", + "Evangelion: Another Impact (Confidential)", + "Kanón", + "SEX and VIOLENCE with MACHSPEED", + "Obake-Chan", + "Tsukikage no Tokio", + "THREE FALLEN WITNESSES", + "The Diary of Ochibi", + "I can Friday by Day!", + "ME!ME!ME! CHRONIC feat.daoko / TeddyLoid", + "(Making of) evangelion:Another Impact", + "ICONIC FIELD", + "IBUSEKI YORUNI", + "Memoirs of amorous gentlemen", + "Rapid Rouge", + "HAMMERHEAD", + "COMEDY SKIT 1989", + "BUBU \u0026 BUBULINA", + "ENDLESS NIGHT", + "BUREAU OF PROTO SOCIETY", + "The Ultraman", + "GIRL", + "Neon Genesis IMPACTS.", + "Ragnarok", + "Robot on the Road", + "Cassette Girl" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Nihon Animator Mihonichi", + "romaji": "Nihon Animator Mihonichi", + "english": "Japan Anima(tor)’s Exhibition", + "native": "日本アニメ(ーター)見本市" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx20947-Qg0QUi31tjeb.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx20947-Qg0QUi31tjeb.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx20947-Qg0QUi31tjeb.jpg", + "color": "#d61a1a" + }, + "startDate": { + "year": 2014, + "month": 11, + "day": 7 + }, + "endDate": { + "year": 2015, + "month": 10, + "day": 9 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 155399, + "idMal": 53246, + "siteUrl": "https://anilist.co/anime/155399", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/155399-yjzWrEjxiHzx.jpg", + "episodes": 1, + "synonyms": [ + "EVANGELION:3.0(-46h) YOU CAN (NOT) REDO." + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "EVANGELION:3.0 (-46h)", + "romaji": "EVANGELION:3.0 (-46h)", + "native": "EVANGELION:3.0(-46h)" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx155399-q78rIZMxNqm0.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx155399-q78rIZMxNqm0.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx155399-q78rIZMxNqm0.jpg" + }, + "startDate": { + "year": 2023, + "month": 3, + "day": 8 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 8 + } + } + } + ] + } + } + }, + { + "id": 389433724, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21284, + "idMal": 31376, + "siteUrl": "https://anilist.co/anime/21284", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21284-9gAbP4x5ziD1.jpg", + "episodes": 12, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Flying Witch", + "romaji": "Flying Witch", + "english": "Flying Witch", + "native": "ふらいんぐうぃっち" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21284-vQcCLIWt1o5O.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21284-vQcCLIWt1o5O.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21284-vQcCLIWt1o5O.png", + "color": "#e4c993" + }, + "startDate": { + "year": 2016, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 76258, + "idMal": 46258, + "siteUrl": "https://anilist.co/manga/76258", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/76258-Vn8vWUXJJfZx.jpg", + "synonyms": [ + "วันธรรมดาของแม่มดว้าวุ่น" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Flying Witch", + "romaji": "Flying Witch", + "english": "Flying Witch", + "native": "ふらいんぐうぃっち" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx76258-iHRY5gdGQ5HY.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx76258-iHRY5gdGQ5HY.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx76258-iHRY5gdGQ5HY.png", + "color": "#f1c986" + }, + "startDate": { + "year": 2012, + "month": 8, + "day": 9 + }, + "endDate": {} + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21721, + "idMal": 32954, + "siteUrl": "https://anilist.co/anime/21721", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21721-heO1DkghgL0d.jpg", + "episodes": 9, + "synonyms": [ + "Flying Witch Puchi" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Flying Witch Petit", + "romaji": "Flying Witch Petit", + "native": "ふらいんぐうぃっち ぷち" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21721-FXrVfwXEgHDd.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21721-FXrVfwXEgHDd.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/21721-FXrVfwXEgHDd.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2016, + "month": 3, + "day": 18 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 24 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 102001, + "idMal": 33681, + "siteUrl": "https://anilist.co/anime/102001", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 1, + "synonyms": [ + "Flying Witch Petit - BD \u0026 DVD Vol. 1 Launch Special" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Flying Witch Petit Special", + "romaji": "Flying Witch Petit Special", + "english": "Flying Witch Petit Special", + "native": "ふらいんぐうぃっち ぷち BD\u0026DVD Vol.1 発売記念編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx102001-oKC1q5MI4oxL.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx102001-oKC1q5MI4oxL.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx102001-oKC1q5MI4oxL.png", + "color": "#e4e443" + }, + "startDate": { + "year": 2016, + "month": 6, + "day": 22 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 22 + } + } + } + ] + } + } + }, + { + "id": 389433726, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 159831, + "idMal": 54112, + "siteUrl": "https://anilist.co/anime/159831", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/159831-FWfdyqpxhLli.jpg", + "episodes": 12, + "synonyms": [ + "Zombie 100 ~100 Things I Want to do Before I Become a Zombie~", + "Zombie 100 ~Zombie ni Naru Made ni Shitai 100 no Koto~", + "100 สิ่งที่อยากทำก่อนจะกลายเป็นซอมบี้", + "Зомби-апокалипсис и 100 предсмертных дел", + "100 Coisas para Fazer Antes de Virar Zumbi" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Zom 100: Zombie ni Naru Made ni Shitai 100 no Koto", + "romaji": "Zom 100: Zombie ni Naru Made ni Shitai 100 no Koto", + "english": "Zom 100: Bucket List of the Dead", + "native": "ゾン100~ゾンビになるまでにしたい100のこと~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx159831-TxAC0ujoLTK6.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx159831-TxAC0ujoLTK6.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx159831-TxAC0ujoLTK6.png", + "color": "#d6e428" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 9 + }, + "endDate": { + "year": 2023, + "month": 12, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 104660, + "idMal": 122392, + "siteUrl": "https://anilist.co/manga/104660", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/104660-dBf2eJ0bjZQ9.jpg", + "synonyms": [ + "Zon 100", + "Bucket list of the dead", + "Zombie 100 ~100 Things I Want to do Before I Become a Zombie~", + "Zombie 100 ~Zombie ni Naru Made ni Shitai 100 no Koto~", + "ซอม 100 : 100 สิ่งที่อยากทำก่อนจะกลายเป็นซอมบี้", + "100 rzeczy do zrobienia, zanim zostanę zombie", + "Zom 100: Coisas para Fazer antes de Virar Zumbi", + "좀100 -좀비가 되기 전에 하고 싶은 100가지-" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Zom 100: Zombie ni Naru Made ni Shitai 100 no Koto", + "romaji": "Zom 100: Zombie ni Naru Made ni Shitai 100 no Koto", + "english": "Zom 100: Bucket List of the Dead", + "native": "ゾン100 ~ゾンビになるまでにしたい100のこと~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx104660-NZZdpLJlgHle.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx104660-NZZdpLJlgHle.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx104660-NZZdpLJlgHle.jpg", + "color": "#fe2886" + }, + "startDate": { + "year": 2018, + "month": 10, + "day": 19 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433727, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 104157, + "idMal": 38329, + "siteUrl": "https://anilist.co/anime/104157", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/104157-PS7tfPpvJKhk.jpg", + "episodes": 1, + "synonyms": [ + "青ブタ", + "Ao Buta ", + "青春猪头少年不会梦到怀梦美少女", + "Этот глупый свин не понимает мечту девочки-зайки. Фильм", + "Негодник, которому не снилась девушка-кролик. Фильм" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "english": "Rascal Does Not Dream of a Dreaming Girl", + "native": "青春ブタ野郎はゆめみる少女の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx104157-rk99XI56PaIC.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx104157-rk99XI56PaIC.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx104157-rk99XI56PaIC.jpg", + "color": "#8643f1" + }, + "startDate": { + "year": 2019, + "month": 6, + "day": 15 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 15 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 101291, + "idMal": 37450, + "siteUrl": "https://anilist.co/anime/101291", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n101291-fqIUvQ6apEtD.jpg", + "episodes": 13, + "synonyms": [ + "AoButa", + "青春猪头少年不会梦到兔女郎学姐", + "Негодник, которому не снилась девушка-кролик", + "Этот глупый свин не понимает мечту девочки-зайки", + "青ブタ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Bunny Girl Senpai no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Bunny Girl Senpai no Yume wo Minai", + "english": "Rascal Does Not Dream of Bunny Girl Senpai", + "native": "青春ブタ野郎はバニーガール先輩の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx101291-L71WpAkZPtgm.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx101291-L71WpAkZPtgm.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx101291-L71WpAkZPtgm.jpg", + "color": "#5078e4" + }, + "startDate": { + "year": 2018, + "month": 10, + "day": 4 + }, + "endDate": { + "year": 2018, + "month": 12, + "day": 27 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 145163, + "siteUrl": "https://anilist.co/manga/145163", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/145163-CpDAjEhHQahZ.jpg", + "synonyms": [ + "Ao Buta", + "青ブタ", + "Seishun Buta Yarou Series", + "青春ブタ野郎シリーズ", + "เรื่องฝันปั่นป่วยของผมกับแม่สาวน้อยช่างฝัน", + "青春豬頭少年不會夢到懷夢美少女 " + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "english": "Rascal Does Not Dream of a Dreaming Girl", + "native": "青春ブタ野郎はゆめみる少女の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx145163-84gshcr9NREV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx145163-84gshcr9NREV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx145163-84gshcr9NREV.jpg", + "color": "#e45da1" + }, + "startDate": { + "year": 2016, + "month": 6, + "day": 10 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 10 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 145164, + "siteUrl": "https://anilist.co/manga/145164", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/145164-KiXZXNNdp3Vk.jpg", + "synonyms": [ + "Ao Buta", + "青ブタ", + "Seishun Buta Yarou Series", + "青春ブタ野郎シリーズ", + "เรื่องฝันปั่นป่วยของผมกับสาวน้อยผู้เป็นรักแรก" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Hatsukoi Shoujo no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Hatsukoi Shoujo no Yume wo Minai", + "english": "Rascal Does Not Dream of His First Love", + "native": "青春ブタ野郎はハツコイ少女の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx145164-3xAszEx3vGGq.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx145164-3xAszEx3vGGq.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx145164-3xAszEx3vGGq.jpg", + "color": "#43aee4" + }, + "startDate": { + "year": 2016, + "month": 10, + "day": 8 + }, + "endDate": { + "year": 2016, + "month": 10, + "day": 8 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 154967, + "idMal": 53129, + "siteUrl": "https://anilist.co/anime/154967", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/154967-uUEtnP9NOTGu.jpg", + "episodes": 1, + "synonyms": [ + "Ao Buta", + "青ブタ", + "เรื่องฝันปั่นป่วยของผมกับน้องสาวออกนอกบ้าน" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "english": "Rascal Does Not Dream of a Sister Venturing Out", + "native": "青春ブタ野郎はおでかけシスターの夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154967-W9cIm0qlz6fj.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154967-W9cIm0qlz6fj.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154967-W9cIm0qlz6fj.jpg", + "color": "#e4d6ae" + }, + "startDate": { + "year": 2023, + "month": 6, + "day": 23 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 23 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 163685, + "idMal": 158943, + "siteUrl": "https://anilist.co/manga/163685", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "native": "青春ブタ野郎はゆめみる少女の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx163685-TMNOkAK96rvA.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx163685-TMNOkAK96rvA.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx163685-TMNOkAK96rvA.jpg", + "color": "#1a93d6" + }, + "startDate": { + "year": 2023, + "month": 4, + "day": 30 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433738, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 137822, + "idMal": 49596, + "siteUrl": "https://anilist.co/anime/137822", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/137822-oevspckMGLuY.jpg", + "episodes": 24, + "synonyms": [ + "BLUE LOCK ขังดวลแข้ง", + " بلو لوك" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Blue Lock", + "romaji": "Blue Lock", + "english": "BLUELOCK", + "native": "ブルーロック" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx137822-4dVWMSHLpGf8.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx137822-4dVWMSHLpGf8.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx137822-4dVWMSHLpGf8.png", + "color": "#286be4" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 9 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 106130, + "idMal": 114745, + "siteUrl": "https://anilist.co/manga/106130", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/106130-4UbnMTU80zur.jpg", + "synonyms": [ + "Bluelock", + "BLUE LOCK ขังดวลแข้ง", + "Синя тюрма", + "블루 록" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Blue Lock", + "romaji": "Blue Lock", + "english": "Blue Lock", + "native": "ブルーロック" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx106130-AZn3dTaSXM4z.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx106130-AZn3dTaSXM4z.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx106130-AZn3dTaSXM4z.jpg", + "color": "#a1f135" + }, + "startDate": { + "year": 2018, + "month": 8, + "day": 1 + }, + "endDate": {} + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 163146, + "idMal": 54865, + "siteUrl": "https://anilist.co/anime/163146", + "status": "NOT_YET_RELEASED", + "type": "ANIME", + "format": "TV", + "synonyms": [ + "BLUELOCK Season 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Blue Lock 2nd Season", + "romaji": "Blue Lock 2nd Season", + "native": "ブルーロック第2期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163146-AL4DrcV2Zp8H.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163146-AL4DrcV2Zp8H.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163146-AL4DrcV2Zp8H.jpg", + "color": "#aef143" + }, + "startDate": { + "year": 2024 + }, + "endDate": {} + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 163147, + "idMal": 54866, + "siteUrl": "https://anilist.co/anime/163147", + "status": "NOT_YET_RELEASED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163147-V6c9XjfvGE0M.jpg", + "episodes": 1, + "synonyms": [ + "BLUELOCK -EPISODE NAGI-", + "Blue Lock Movie", + "劇場版 ブルーロック" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Blue Lock: EPISODE Nagi", + "romaji": "Blue Lock: EPISODE Nagi", + "native": "ブルーロック -EPISODE 凪-" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163147-yyu5aEoO96Jg.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163147-yyu5aEoO96Jg.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163147-yyu5aEoO96Jg.jpg", + "color": "#50a1e4" + }, + "startDate": { + "year": 2024, + "month": 4, + "day": 19 + }, + "endDate": { + "year": 2024, + "month": 4, + "day": 19 + } + } + } + ] + } + } + }, + { + "id": 389433739, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 20652, + "idMal": 23199, + "siteUrl": "https://anilist.co/anime/20652", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20652-sCk2BUWiRMLc.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Shou", + "דורארארה!!2x התפתחות" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Shou", + "romaji": "Durarara!!x2 Shou", + "english": "Durarara!! X2", + "native": "デュラララ!!×2 承" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20652-8ft6GZKEoeWn.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20652-8ft6GZKEoeWn.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20652-8ft6GZKEoeWn.png" + }, + "startDate": { + "year": 2015, + "month": 1, + "day": 10 + }, + "endDate": { + "year": 2015, + "month": 3, + "day": 28 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 6746, + "idMal": 6746, + "siteUrl": "https://anilist.co/anime/6746", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/6746-84oNA7P9pboV.jpg", + "episodes": 24, + "synonyms": [ + "DRRR!!", + "דורארארה!!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx6746-Q4EmstN2fy0R.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6746-Q4EmstN2fy0R.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx6746-Q4EmstN2fy0R.png", + "color": "#f1e4bb" + }, + "startDate": { + "year": 2010, + "month": 1, + "day": 8 + }, + "endDate": { + "year": 2010, + "month": 6, + "day": 25 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 20879, + "idMal": 27831, + "siteUrl": "https://anilist.co/anime/20879", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20879-KRnO8kddef9Q.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Ten", + "דורארארה!!2x תפנית" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ten", + "romaji": "Durarara!!x2 Ten", + "english": "Durarara!! X2 The Second Arc", + "native": "デュラララ!!×2 転" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20879-IqgXMXuUMvRM.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20879-IqgXMXuUMvRM.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20879-IqgXMXuUMvRM.png" + }, + "startDate": { + "year": 2015, + "month": 7, + "day": 4 + }, + "endDate": { + "year": 2015, + "month": 9, + "day": 26 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 46816, + "idMal": 16816, + "siteUrl": "https://anilist.co/manga/46816", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/46816-1KVtcolq8c1H.jpg", + "synonyms": [ + "DRRR!!", + "Дюрарара!!", + "DRRR!! โลกบิดเบี้ยวที่อิเคะบุคุโระ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx46816-D2DhHjjCu2ow.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx46816-D2DhHjjCu2ow.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx46816-D2DhHjjCu2ow.png", + "color": "#e4ae50" + }, + "startDate": { + "year": 2004, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2014, + "month": 1, + "day": 10 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21068, + "idMal": 30191, + "siteUrl": "https://anilist.co/anime/21068", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21068-m0OY3msjv5Fc.jpg", + "episodes": 1, + "synonyms": [ + "Durarara!!x2 Shou Episode 4.5" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Shou: Watashi no Kokoro wa Nabe Moyou", + "romaji": "Durarara!!x2 Shou: Watashi no Kokoro wa Nabe Moyou", + "english": "Durarara!! X2: My Heart is in the Pattern of a Hot Pot", + "native": "デュラララ!!×2 承 私の心は鍋模様" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21068-FtMxCJjPN0BL.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21068-FtMxCJjPN0BL.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21068-FtMxCJjPN0BL.jpg", + "color": "#1a6b50" + }, + "startDate": { + "year": 2015, + "month": 5, + "day": 30 + }, + "endDate": { + "year": 2015, + "month": 5, + "day": 30 + } + } + } + ] + } + } + }, + { + "id": 389433740, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 109190, + "idMal": 39741, + "siteUrl": "https://anilist.co/anime/109190", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/109190-SpM8A4w83FnR.jpg", + "episodes": 1, + "synonyms": [ + "Violet Evergarden und das Band der Freundschaft", + "Violet Evergarden Gaiden: La Eternidad y la Muñeca de Recuerdos Automáticos", + "Violet Evergarden Gaiden: Eternidade e a Boneca de Automemória", + "فيوليت: الأبدية وذكريات الدمية الآلية", + "Вайолет Эвергарден: Вечность и призрак пера", + "Violet Evergarden: Věčnost a Píšící panenka" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "romaji": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "english": "Violet Evergarden: Eternity and the Auto Memory Doll", + "native": "ヴァイオレット・エヴァーガーデン 外伝~永遠と自動手記人形~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx109190-e8mv1qdmpjLW.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx109190-e8mv1qdmpjLW.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx109190-e8mv1qdmpjLW.jpg", + "color": "#e4e4a1" + }, + "startDate": { + "year": 2019, + "month": 9, + "day": 6 + }, + "endDate": { + "year": 2019, + "month": 9, + "day": 6 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 109121, + "idMal": 113819, + "siteUrl": "https://anilist.co/manga/109121", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Gaiden", + "romaji": "Violet Evergarden Gaiden", + "native": "ヴァイオレット・エヴァーガーデン 外伝" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx109121-10xZOnCHKbrs.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx109121-10xZOnCHKbrs.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx109121-10xZOnCHKbrs.png", + "color": "#e4bba1" + }, + "startDate": { + "year": 2018, + "month": 3, + "day": 23 + }, + "endDate": { + "year": 2018, + "month": 3, + "day": 23 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 103047, + "idMal": 37987, + "siteUrl": "https://anilist.co/anime/103047", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/103047-Tjvsh5w1XZP4.jpg", + "episodes": 1, + "synonyms": [ + "Виолетта Эвергарден", + "Вайоллет Эвергарден", + "薇尔莉特·伊芙加登", + "Violet Evergarden – film" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Movie", + "romaji": "Violet Evergarden Movie", + "english": "Violet Evergarden: the Movie", + "native": "劇場版 ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx103047-LYIbLtN2Rb5T.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx103047-LYIbLtN2Rb5T.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx103047-LYIbLtN2Rb5T.jpg", + "color": "#35a1f1" + }, + "startDate": { + "year": 2020, + "month": 9, + "day": 18 + }, + "endDate": { + "year": 2020, + "month": 9, + "day": 18 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 21827, + "idMal": 33352, + "siteUrl": "https://anilist.co/anime/21827", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21827-ROucgYiiiSpR.jpg", + "episodes": 13, + "synonyms": [ + "ויולט אברגרדן", + "فيوليت", + "紫罗兰永恒花园" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden", + "romaji": "Violet Evergarden", + "english": "Violet Evergarden", + "native": "ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21827-10F6m50H4GJK.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21827-10F6m50H4GJK.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21827-10F6m50H4GJK.png", + "color": "#3586e4" + }, + "startDate": { + "year": 2018, + "month": 1, + "day": 11 + }, + "endDate": { + "year": 2018, + "month": 4, + "day": 5 + } + } + } + ] + } + } + }, + { + "id": 389433741, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 3785, + "idMal": 3785, + "siteUrl": "https://anilist.co/anime/3785", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3785-UyfunULvn6PQ.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 3.33", + "Rebuild of Evangelion 3.0 Q Quickening", + "EVANGELION:3.33 VOCÊ (NÃO) PODE REFAZER", + "EVANGELION: 3.33 TÚ (NO) LO PUEDES REHACER" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Kyuu", + "romaji": "Evangelion Shin Movie: Kyuu", + "english": "Evangelion: 3.0 You Can (Not) Redo", + "native": "ヱヴァンゲリヲン新劇場版:Q" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3785-OG857YhQalvS.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3785-OG857YhQalvS.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3785-OG857YhQalvS.png", + "color": "#1a356b" + }, + "startDate": { + "year": 2012, + "month": 11, + "day": 17 + }, + "endDate": { + "year": 2012, + "month": 11, + "day": 17 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 3784, + "idMal": 3784, + "siteUrl": "https://anilist.co/anime/3784", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3784-OYyfe6vR2687.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 2.22", + "EVANGELION:2.22 VOCÊ (NÃO) PODE AVANÇAR", + "EVANGELION:2.22 (NO) PUEDES AVANZAR" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Ha", + "romaji": "Evangelion Shin Movie: Ha", + "english": "Evangelion: 2.0 You Can (Not) Advance", + "native": "ヱヴァンゲリヲン新劇場版:破" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3784-TGCsqLryKJ2R.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3784-TGCsqLryKJ2R.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3784-TGCsqLryKJ2R.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2009, + "month": 7, + "day": 27 + }, + "endDate": { + "year": 2009, + "month": 7, + "day": 27 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 3786, + "idMal": 3786, + "siteUrl": "https://anilist.co/anime/3786", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3786-IfWKqRp9grgo.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 4.0", + "EVANGELION:3.0+1.01 THRICE UPON A TIME ", + "EVANGELION:3.0+1.01 A ESPERANÇA", + "อีวานเกเลียน:3.0+1.01 สามครั้งก่อน เมื่อเนิ่นนานมาแล้ว", + "Evangelion 3.0+1.11", + "EVANGELION:3.0+1.01 TRIPLE" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Evangelion Movie:||", + "romaji": "Shin Evangelion Movie:||", + "english": "Evangelion: 3.0+1.0 Thrice Upon a Time", + "native": "シン・エヴァンゲリオン劇場版:||" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3786-FPo09WTuoTCV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3786-FPo09WTuoTCV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3786-FPo09WTuoTCV.jpg", + "color": "#50bbe4" + }, + "startDate": { + "year": 2021, + "month": 3, + "day": 8 + }, + "endDate": { + "year": 2021, + "month": 3, + "day": 8 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 101092, + "idMal": 34085, + "siteUrl": "https://anilist.co/anime/101092", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/101092-IALRljXAiN1p.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Sakura Nagashi", + "romaji": "Sakura Nagashi", + "native": "桜流し" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx101092-Aan9cFZcLULo.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx101092-Aan9cFZcLULo.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx101092-Aan9cFZcLULo.png", + "color": "#f1a15d" + }, + "startDate": { + "year": 2016, + "month": 9, + "day": 18 + }, + "endDate": { + "year": 2016, + "month": 9, + "day": 18 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 135927, + "idMal": 137967, + "siteUrl": "https://anilist.co/manga/135927", + "status": "FINISHED", + "type": "MANGA", + "format": "ONE_SHOT", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/135927-iI3P1Nrt0AFL.jpg", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "EVANGELION: 3.0 (-120min.)", + "romaji": "EVANGELION: 3.0 (-120min.)", + "native": "EVANGELION:3.0(-120min.)" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx135927-wdQjHYfOFfIe.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx135927-wdQjHYfOFfIe.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx135927-wdQjHYfOFfIe.png", + "color": "#ff5d35" + }, + "startDate": { + "year": 2021, + "month": 6, + "day": 12 + }, + "endDate": { + "year": 2021, + "month": 6, + "day": 12 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 100330, + "idMal": 32311, + "siteUrl": "https://anilist.co/anime/100330", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "synonyms": [ + "عالم جميل" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Beautiful World", + "romaji": "Beautiful World", + "native": "ビューティフル・ワールド" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx100330-UKBfb9nc9QR1.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx100330-UKBfb9nc9QR1.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx100330-UKBfb9nc9QR1.png", + "color": "#285d78" + }, + "startDate": { + "year": 2014, + "month": 12, + "day": 2 + }, + "endDate": { + "year": 2014, + "month": 12, + "day": 2 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 155399, + "idMal": 53246, + "siteUrl": "https://anilist.co/anime/155399", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/155399-yjzWrEjxiHzx.jpg", + "episodes": 1, + "synonyms": [ + "EVANGELION:3.0(-46h) YOU CAN (NOT) REDO." + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "EVANGELION:3.0 (-46h)", + "romaji": "EVANGELION:3.0 (-46h)", + "native": "EVANGELION:3.0(-46h)" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx155399-q78rIZMxNqm0.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx155399-q78rIZMxNqm0.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx155399-q78rIZMxNqm0.jpg" + }, + "startDate": { + "year": 2023, + "month": 3, + "day": 8 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 8 + } + } + } + ] + } + } + }, + { + "id": 389433742, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 154967, + "idMal": 53129, + "siteUrl": "https://anilist.co/anime/154967", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/154967-uUEtnP9NOTGu.jpg", + "episodes": 1, + "synonyms": [ + "Ao Buta", + "青ブタ", + "เรื่องฝันปั่นป่วยของผมกับน้องสาวออกนอกบ้าน" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "english": "Rascal Does Not Dream of a Sister Venturing Out", + "native": "青春ブタ野郎はおでかけシスターの夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154967-W9cIm0qlz6fj.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154967-W9cIm0qlz6fj.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154967-W9cIm0qlz6fj.jpg", + "color": "#e4d6ae" + }, + "startDate": { + "year": 2023, + "month": 6, + "day": 23 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 145165, + "siteUrl": "https://anilist.co/manga/145165", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/145165-g3t5QMRCe9l5.jpg", + "synonyms": [ + "Ao Buta", + "青ブタ", + "Seishun Buta Yarou Series", + "青春ブタ野郎シリーズ", + "เรื่องฝันปั่นป่วยของผมกับน้องสาวออกนอกบ้าน" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "english": "Rascal Does Not Dream of a Sister Venturing Out", + "native": "青春ブタ野郎はおでかけシスターの夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx145165-R0Jx1xRoYRve.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx145165-R0Jx1xRoYRve.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx145165-R0Jx1xRoYRve.jpg", + "color": "#f19350" + }, + "startDate": { + "year": 2018, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2018, + "month": 4, + "day": 10 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 104157, + "idMal": 38329, + "siteUrl": "https://anilist.co/anime/104157", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/104157-PS7tfPpvJKhk.jpg", + "episodes": 1, + "synonyms": [ + "青ブタ", + "Ao Buta ", + "青春猪头少年不会梦到怀梦美少女", + "Этот глупый свин не понимает мечту девочки-зайки. Фильм", + "Негодник, которому не снилась девушка-кролик. Фильм" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "english": "Rascal Does Not Dream of a Dreaming Girl", + "native": "青春ブタ野郎はゆめみる少女の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx104157-rk99XI56PaIC.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx104157-rk99XI56PaIC.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx104157-rk99XI56PaIC.jpg", + "color": "#8643f1" + }, + "startDate": { + "year": 2019, + "month": 6, + "day": 15 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 15 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 161474, + "idMal": 54870, + "siteUrl": "https://anilist.co/anime/161474", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/161474-rUz4D0jx6Ato.jpg", + "episodes": 1, + "synonyms": [ + "Rascal Does Not Dream of a Knapsack Kid", + "Ao Buta", + "青ブタ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Randoseru Girl no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Randoseru Girl no Yume wo Minai", + "english": "Rascal Does Not Dream of a Knapsack Kid", + "native": "青春ブタ野郎はランドセルガールの夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx161474-yLgY2vGrkVHY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx161474-yLgY2vGrkVHY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx161474-yLgY2vGrkVHY.jpg", + "color": "#e41abb" + }, + "startDate": { + "year": 2023, + "month": 12, + "day": 1 + }, + "endDate": { + "year": 2023, + "month": 12, + "day": 1 + } + } + } + ] + } + } + }, + { + "id": 389433743, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 109298, + "idMal": 39792, + "siteUrl": "https://anilist.co/anime/109298", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/109298-ej4YYg87HHoA.jpg", + "episodes": 12, + "synonyms": [ + "Don't mess with the Motion Picture Club!", + "Hands off the Motion Picture Club!", + "别对映像研出手!", + "Ước mơ sản xuất anime" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Eizouken ni wa Te wo Dasu na!", + "romaji": "Eizouken ni wa Te wo Dasu na!", + "english": "Keep Your Hands Off Eizouken!", + "native": "映像研には手を出すな!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx109298-YvjfI88hX76T.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx109298-YvjfI88hX76T.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx109298-YvjfI88hX76T.png", + "color": "#e4a135" + }, + "startDate": { + "year": 2020, + "month": 1, + "day": 6 + }, + "endDate": { + "year": 2020, + "month": 3, + "day": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 109319, + "idMal": 112087, + "siteUrl": "https://anilist.co/manga/109319", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "Hands off the Motion Pictures Club!", + "ชมรมอนิเมะฉันใครอย่าแตะ", + "¡No te Metas con el Club de Cine! – Eizouken" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Eizouken ni wa Te wo Dasu na!", + "romaji": "Eizouken ni wa Te wo Dasu na!", + "english": "Keep Your Hands Off Eizouken!", + "native": "映像研には手を出すな!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx109319-Kdns0F9TG2py.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx109319-Kdns0F9TG2py.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx109319-Kdns0F9TG2py.jpg", + "color": "#e4c928" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 27 + }, + "endDate": {} + } + }, + { + "relationType": "OTHER", + "node": { + "id": 116923, + "idMal": 41454, + "siteUrl": "https://anilist.co/anime/116923", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "ONA", + "episodes": 12, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Eizouken Mini Anime", + "romaji": "Eizouken Mini Anime", + "native": "映像研 ミニアニメ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx116923-ASmRIGda4ZZb.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx116923-ASmRIGda4ZZb.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx116923-ASmRIGda4ZZb.png", + "color": "#f1d6ae" + }, + "startDate": { + "year": 2020, + "month": 1, + "day": 8 + }, + "endDate": { + "year": 2020, + "month": 3, + "day": 24 + } + } + } + ] + } + } + }, + { + "id": 389433744, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 20880, + "idMal": 27833, + "siteUrl": "https://anilist.co/anime/20880", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20880-iQx5G0gz5n6G.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Ketsu", + "דורארארה!!2x סיום" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ketsu", + "romaji": "Durarara!!x2 Ketsu", + "english": "Durarara!! X2 The Third Arc", + "native": "デュラララ!!×2 結" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20880-WsvmgSdL8lhP.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20880-WsvmgSdL8lhP.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20880-WsvmgSdL8lhP.png", + "color": "#e4c943" + }, + "startDate": { + "year": 2016, + "month": 1, + "day": 9 + }, + "endDate": { + "year": 2016, + "month": 3, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 20879, + "idMal": 27831, + "siteUrl": "https://anilist.co/anime/20879", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20879-KRnO8kddef9Q.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Ten", + "דורארארה!!2x תפנית" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ten", + "romaji": "Durarara!!x2 Ten", + "english": "Durarara!! X2 The Second Arc", + "native": "デュラララ!!×2 転" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20879-IqgXMXuUMvRM.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20879-IqgXMXuUMvRM.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20879-IqgXMXuUMvRM.png" + }, + "startDate": { + "year": 2015, + "month": 7, + "day": 4 + }, + "endDate": { + "year": 2015, + "month": 9, + "day": 26 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 46816, + "idMal": 16816, + "siteUrl": "https://anilist.co/manga/46816", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/46816-1KVtcolq8c1H.jpg", + "synonyms": [ + "DRRR!!", + "Дюрарара!!", + "DRRR!! โลกบิดเบี้ยวที่อิเคะบุคุโระ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx46816-D2DhHjjCu2ow.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx46816-D2DhHjjCu2ow.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx46816-D2DhHjjCu2ow.png", + "color": "#e4ae50" + }, + "startDate": { + "year": 2004, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2014, + "month": 1, + "day": 10 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21695, + "idMal": 32915, + "siteUrl": "https://anilist.co/anime/21695", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21695-EOyPwjEwusbk.jpg", + "episodes": 1, + "synonyms": [ + "Durarara!!x2 Ketsu Episode 19.5" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ketsu: DuFuFuFu!!", + "romaji": "Durarara!!x2 Ketsu: DuFuFuFu!!", + "english": "Durarara!! X2 The Third Arc: DuFuFuFu!!", + "native": "デュラララ!!×2 結 デュフフフ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21695-A79FUWY7ZVbw.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21695-A79FUWY7ZVbw.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21695-A79FUWY7ZVbw.png", + "color": "#e4d650" + }, + "startDate": { + "year": 2016, + "month": 5, + "day": 21 + }, + "endDate": { + "year": 2016, + "month": 5, + "day": 21 + } + } + } + ] + } + } + }, + { + "id": 389433745, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 139310, + "idMal": 49834, + "siteUrl": "https://anilist.co/anime/139310", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "episodes": 1, + "synonyms": [ + "Nhắn gửi tất cả các em, những người tôi đã yêu", + "BokuAi" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Boku ga Aishita Subete no Kimi e", + "romaji": "Boku ga Aishita Subete no Kimi e", + "english": "To Every You I’ve Loved Before", + "native": "僕が愛したすべての君へ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx139310-OM1RKpk5YH7g.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx139310-OM1RKpk5YH7g.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx139310-OM1RKpk5YH7g.jpg", + "color": "#86bbe4" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2022, + "month": 10, + "day": 7 + }, + "relations": { + "edges": [ + { + "relationType": "OTHER", + "node": { + "id": 139311, + "idMal": 49835, + "siteUrl": "https://anilist.co/anime/139311", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "episodes": 1, + "synonyms": [ + "Nhắn gửi một tôi, người đã yêu em", + "KimiAi" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi wo Aishita Hitori no Boku e", + "romaji": "Kimi wo Aishita Hitori no Boku e", + "english": "To Me, The One Who Loved You", + "native": "君を愛したひとりの僕へ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx139311-5iHY459iwQ46.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx139311-5iHY459iwQ46.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx139311-5iHY459iwQ46.jpg", + "color": "#ff93c9" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2022, + "month": 10, + "day": 7 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 155634, + "idMal": 53355, + "siteUrl": "https://anilist.co/anime/155634", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/155634-6lwPmGZZy7LV.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kumo wo Kou", + "romaji": "Kumo wo Kou", + "native": "雲を恋う" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx155634-9gxgC9J53hzp.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx155634-9gxgC9J53hzp.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx155634-9gxgC9J53hzp.jpg" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2022, + "month": 10, + "day": 7 + } + } + } + ] + } + } + }, + { + "id": 389433831, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 20879, + "idMal": 27831, + "siteUrl": "https://anilist.co/anime/20879", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20879-KRnO8kddef9Q.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Ten", + "דורארארה!!2x תפנית" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ten", + "romaji": "Durarara!!x2 Ten", + "english": "Durarara!! X2 The Second Arc", + "native": "デュラララ!!×2 転" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20879-IqgXMXuUMvRM.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20879-IqgXMXuUMvRM.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20879-IqgXMXuUMvRM.png" + }, + "startDate": { + "year": 2015, + "month": 7, + "day": 4 + }, + "endDate": { + "year": 2015, + "month": 9, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 20652, + "idMal": 23199, + "siteUrl": "https://anilist.co/anime/20652", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20652-sCk2BUWiRMLc.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Shou", + "דורארארה!!2x התפתחות" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Shou", + "romaji": "Durarara!!x2 Shou", + "english": "Durarara!! X2", + "native": "デュラララ!!×2 承" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20652-8ft6GZKEoeWn.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20652-8ft6GZKEoeWn.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20652-8ft6GZKEoeWn.png" + }, + "startDate": { + "year": 2015, + "month": 1, + "day": 10 + }, + "endDate": { + "year": 2015, + "month": 3, + "day": 28 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 20880, + "idMal": 27833, + "siteUrl": "https://anilist.co/anime/20880", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20880-iQx5G0gz5n6G.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Ketsu", + "דורארארה!!2x סיום" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ketsu", + "romaji": "Durarara!!x2 Ketsu", + "english": "Durarara!! X2 The Third Arc", + "native": "デュラララ!!×2 結" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20880-WsvmgSdL8lhP.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20880-WsvmgSdL8lhP.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20880-WsvmgSdL8lhP.png", + "color": "#e4c943" + }, + "startDate": { + "year": 2016, + "month": 1, + "day": 9 + }, + "endDate": { + "year": 2016, + "month": 3, + "day": 26 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 46816, + "idMal": 16816, + "siteUrl": "https://anilist.co/manga/46816", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/46816-1KVtcolq8c1H.jpg", + "synonyms": [ + "DRRR!!", + "Дюрарара!!", + "DRRR!! โลกบิดเบี้ยวที่อิเคะบุคุโระ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx46816-D2DhHjjCu2ow.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx46816-D2DhHjjCu2ow.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx46816-D2DhHjjCu2ow.png", + "color": "#e4ae50" + }, + "startDate": { + "year": 2004, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2014, + "month": 1, + "day": 10 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21331, + "idMal": 31552, + "siteUrl": "https://anilist.co/anime/21331", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 1, + "synonyms": [ + "Durarara!!x2 Ten Episode 13.5" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ten: Onoroke Chakapoko", + "romaji": "Durarara!!x2 Ten: Onoroke Chakapoko", + "english": "Durarara!! X2 The Second Arc: Onoroke Chakapoko", + "native": "デュラララ!!×2 転 お惚気チャカポコ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21331-5gtFE1JYxL2O.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21331-5gtFE1JYxL2O.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/21331-5gtFE1JYxL2O.jpg", + "color": "#e4355d" + }, + "startDate": { + "year": 2015, + "month": 11, + "day": 14 + }, + "endDate": { + "year": 2015, + "month": 11, + "day": 14 + } + } + } + ] + } + } + }, + { + "id": 389433833, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 154116, + "idMal": 52741, + "siteUrl": "https://anilist.co/anime/154116", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/154116-dFaciqCd2pZU.jpg", + "episodes": 24, + "synonyms": [ + "אל-מת ובלי מזל" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Undead Unluck", + "romaji": "Undead Unluck", + "english": "Undead Unluck", + "native": "アンデッドアンラック" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154116-UetMXpm9W8nC.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154116-UetMXpm9W8nC.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154116-UetMXpm9W8nC.jpg", + "color": "#e44343" + }, + "startDate": { + "year": 2023, + "month": 10, + "day": 7 + }, + "endDate": {}, + "nextAiringEpisode": { + "airingAt": 1710519780, + "timeUntilAiring": 406452, + "episode": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 114791, + "idMal": 123956, + "siteUrl": "https://anilist.co/manga/114791", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/114791-ZRpR1LP3NCc6.jpg", + "synonyms": [ + "不死不運" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Undead Unluck", + "romaji": "Undead Unluck", + "english": "Undead Unluck", + "native": "アンデッドアンラック" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx114791-Rj07uWUnsgLY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx114791-Rj07uWUnsgLY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx114791-Rj07uWUnsgLY.jpg", + "color": "#f15da1" + }, + "startDate": { + "year": 2020, + "month": 1, + "day": 20 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433834, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 9656, + "idMal": 9656, + "siteUrl": "https://anilist.co/anime/9656", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/9656-CE0RW5otDH1X.jpg", + "episodes": 13, + "synonyms": [ + "Reaching You 2nd Season", + "Llegando a ti: Temporada 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke 2ND SEASON", + "romaji": "Kimi ni Todoke 2ND SEASON", + "english": "Kimi ni Todoke: From Me to You Season 2", + "native": "君に届け 2ND SEASON" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx9656-vckh2wNj3FwY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx9656-vckh2wNj3FwY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx9656-vckh2wNj3FwY.jpg", + "color": "#f18635" + }, + "startDate": { + "year": 2011, + "month": 1, + "day": 12 + }, + "endDate": { + "year": 2011, + "month": 3, + "day": 30 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 6045, + "idMal": 6045, + "siteUrl": "https://anilist.co/anime/6045", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/6045-PHiyDpX2gc5D.jpg", + "episodes": 25, + "synonyms": [ + "Reaching You", + "Arrivare a te", + "Llegando a ti" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke", + "romaji": "Kimi ni Todoke", + "english": "Kimi ni Todoke: From Me to You", + "native": "君に届け" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx6045-txJOukR5Qve4.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6045-txJOukR5Qve4.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx6045-txJOukR5Qve4.jpg", + "color": "#e45d6b" + }, + "startDate": { + "year": 2009, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2010, + "month": 3, + "day": 31 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 33378, + "idMal": 3378, + "siteUrl": "https://anilist.co/manga/33378", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/33378-9RtoYIQMFGq0.jpg", + "synonyms": [ + "Reaching You", + "Llegando a Ti", + "Que Chegue a Você", + "Sawako", + "Nguyện ước yêu thương" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke", + "romaji": "Kimi ni Todoke", + "english": "Kimi ni Todoke: From Me to You", + "native": "君に届け" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx33378-G9sHqsJryoP2.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx33378-G9sHqsJryoP2.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx33378-G9sHqsJryoP2.jpg", + "color": "#e4a15d" + }, + "startDate": { + "year": 2006, + "month": 5, + "day": 25 + }, + "endDate": { + "year": 2017, + "month": 11, + "day": 13 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 10536, + "idMal": 10536, + "siteUrl": "https://anilist.co/anime/10536", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 3, + "synonyms": [ + "Kimi ni Todoke 2nd Season: Minitodo Gekijou", + "Kimi ni Todoke: From Me to You 2nd Season Specials", + "Reaching You 2nd Season Specials" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke 2nd Season Specials", + "romaji": "Kimi ni Todoke 2nd Season Specials", + "native": "君に届け ミニトド劇場" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx10536-OWWiu3VOlJn9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx10536-OWWiu3VOlJn9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx10536-OWWiu3VOlJn9.jpg", + "color": "#e4bba1" + }, + "startDate": { + "year": 2011, + "month": 4, + "day": 20 + }, + "endDate": { + "year": 2011, + "month": 9, + "day": 21 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 168872, + "idMal": 56538, + "siteUrl": "https://anilist.co/anime/168872", + "status": "NOT_YET_RELEASED", + "type": "ANIME", + "format": "ONA", + "synonyms": [ + "ฝากใจไปถึงเธอ ซีซั่น 3" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke 3RD SEASON", + "romaji": "Kimi ni Todoke 3RD SEASON", + "english": "Kimi ni Todoke: From Me to You Season 3", + "native": "君に届け 3RD SEASON" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx168872-u7NQPxaG6J1a.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx168872-u7NQPxaG6J1a.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx168872-u7NQPxaG6J1a.jpg", + "color": "#f1d6bb" + }, + "startDate": { + "year": 2024 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433837, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 131083, + "idMal": 48483, + "siteUrl": "https://anilist.co/anime/131083", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/131083-MYE8c1cuzO6C.jpg", + "episodes": 12, + "synonyms": [ + "มิเอรุโกะจัง ใครว่าหนูเห็นผี", + "Mieruko: Gadis yang Bisa Melihat Hantu", + "Girl That Can See It", + "Mieruko-chan. Dziewczyna, która widzi więcej" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mieruko-chan", + "romaji": "Mieruko-chan", + "english": "Mieruko-chan", + "native": "見える子ちゃん" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx131083-fMWFyOFgp6vb.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx131083-fMWFyOFgp6vb.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx131083-fMWFyOFgp6vb.png", + "color": "#93c9e4" + }, + "startDate": { + "year": 2021, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 2021, + "month": 12, + "day": 19 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 105097, + "idMal": 116790, + "siteUrl": "https://anilist.co/manga/105097", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/105097-MW8cS0AOR1eq.jpg", + "synonyms": [ + "Girl That Can See It", + "The Girl Who Sees \"Them\"", + "Can-See-Ghosts-chan", + "Child That Can See It", + "Li'l Miss Can-See-Ghosts", + "Mieruko-chan: Slice of Horror", + "看得见的女孩", + "มิเอรุโกะจัง ใครว่าหนูเห็นผี", + "Mieruko-chan. Dziewczyna, która widzi więcej" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mieruko-chan", + "romaji": "Mieruko-chan", + "english": "Mieruko-chan", + "native": "見える子ちゃん" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx105097-nMpc8bjBeuXE.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx105097-nMpc8bjBeuXE.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx105097-nMpc8bjBeuXE.jpg" + }, + "startDate": { + "year": 2018, + "month": 11, + "day": 2 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433838, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 12189, + "idMal": 12189, + "siteUrl": "https://anilist.co/anime/12189", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/12189-TG0peUcKFqur.jpg", + "episodes": 22, + "synonyms": [ + "Hyouka: Forbidden Secrets", + "เฮียวกะปริศนาความทรงจำ", + "Хёка" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Hyouka", + "romaji": "Hyouka", + "english": "Hyouka", + "native": "氷菓" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx12189-eBb6fcM21Zh7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx12189-eBb6fcM21Zh7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx12189-eBb6fcM21Zh7.jpg", + "color": "#f1e4bb" + }, + "startDate": { + "year": 2012, + "month": 4, + "day": 23 + }, + "endDate": { + "year": 2012, + "month": 9, + "day": 16 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 65513, + "idMal": 35513, + "siteUrl": "https://anilist.co/manga/65513", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/65513-beioOI3VEJ65.jpg", + "synonyms": [ + "Koten-bu Series", + "〈古典部〉シリーズ", + "The niece of time", + "ปริศนาความทรงจำ", + "Kem đá" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Hyouka", + "romaji": "Hyouka", + "native": "氷菓" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx65513-XhhDwKxsNsxZ.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx65513-XhhDwKxsNsxZ.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx65513-XhhDwKxsNsxZ.png", + "color": "#e4ae5d" + }, + "startDate": { + "year": 2001, + "month": 10, + "day": 31 + }, + "endDate": { + "year": 2001, + "month": 10, + "day": 31 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 13469, + "idMal": 13469, + "siteUrl": "https://anilist.co/anime/13469", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n13469-4SXZGXqQSsdp.jpg", + "episodes": 1, + "synonyms": [ + "Hyouka Episode 11.5", + "Hyouka OVA", + "Hyou-ka OVA", + "Hyouka: You can't escape OVA" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Hyouka: Motsubeki Mono wa", + "romaji": "Hyouka: Motsubeki Mono wa", + "english": "Hyouka: What Should Be Had", + "native": "氷菓 持つべきものは" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx13469-bbRQoSUgmf4T.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx13469-bbRQoSUgmf4T.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx13469-bbRQoSUgmf4T.png", + "color": "#e4e493" + }, + "startDate": { + "year": 2012, + "month": 7, + "day": 8 + }, + "endDate": { + "year": 2012, + "month": 7, + "day": 8 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 71629, + "idMal": 41629, + "siteUrl": "https://anilist.co/manga/71629", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/71629-jzpIRCBxQjit.jpg", + "synonyms": [ + "Hyoka", + "Classics Club Series", + "Koten-bu Series", + "Frozen Treat", + "冰果", + "ปริศนาความทรงจำ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Hyouka", + "romaji": "Hyouka", + "english": "Hyouka", + "native": "氷菓" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx71629-b1McvLSvE9X9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx71629-b1McvLSvE9X9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx71629-b1McvLSvE9X9.jpg", + "color": "#50a1e4" + }, + "startDate": { + "year": 2012, + "month": 1, + "day": 26 + }, + "endDate": {} + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 144311, + "siteUrl": "https://anilist.co/manga/144311", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Why didn't she ask EBA?", + "Koten-bu Series", + "〈古典部〉シリーズ", + "บทละครของคนโง่" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Gusha no Endroll", + "romaji": "Gusha no Endroll", + "native": "愚者のエンドロール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx144311-mTUr8DGbVziy.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx144311-mTUr8DGbVziy.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx144311-mTUr8DGbVziy.jpg", + "color": "#e4780d" + }, + "startDate": { + "year": 2002, + "month": 7, + "day": 31 + }, + "endDate": { + "year": 2002, + "month": 7, + "day": 31 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 144312, + "siteUrl": "https://anilist.co/manga/144312", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Welcome to KANYA FESTA", + "Koten-bu Series", + "〈古典部〉シリーズ", + "クドリャフカの順番 「十文字」事件", + "ลำดับแห่งคุดร์ยัฟกา" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kudryavka no Junban", + "romaji": "Kudryavka no Junban", + "native": "クドリャフカの順番" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/b144312-Q5upWAEgYwUH.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/b144312-Q5upWAEgYwUH.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/b144312-Q5upWAEgYwUH.jpg", + "color": "#f1e4bb" + }, + "startDate": { + "year": 2005, + "month": 6, + "day": 30 + }, + "endDate": { + "year": 2005, + "month": 6, + "day": 30 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 144313, + "siteUrl": "https://anilist.co/manga/144313", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Little birds can remember", + "〈古典部〉シリーズ", + "Koten-bu Series", + "เจ้าหญิงเดินอ้อม" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Toomawari suru Hina", + "romaji": "Toomawari suru Hina", + "native": "遠まわりする雛" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx144313-rfGWqIyYdijo.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx144313-rfGWqIyYdijo.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx144313-rfGWqIyYdijo.jpg" + }, + "startDate": { + "year": 2007, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 2007, + "month": 10, + "day": 3 + } + } + } + ] + } + } + }, + { + "id": 389433839, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 146065, + "idMal": 51179, + "siteUrl": "https://anilist.co/anime/146065", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/146065-33RDijfuxLLk.jpg", + "episodes": 13, + "synonyms": [ + "ชาตินี้พี่ต้องเทพ ภาค 2", + "Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season", + "Mushoku Tensei II: Jobless Reincarnation", + "Mushoku Tensei II: Reencarnación desde cero", + "无职转生~到了异世界就拿出真本事~第2季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu", + "romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu", + "english": "Mushoku Tensei: Jobless Reincarnation Season 2", + "native": "無職転生 Ⅱ ~異世界行ったら本気だす~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx146065-IjirxRK26O03.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx146065-IjirxRK26O03.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx146065-IjirxRK26O03.png", + "color": "#35aee4" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 3 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 25 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 127720, + "idMal": 45576, + "siteUrl": "https://anilist.co/anime/127720", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/127720-oBpHiMWQhFVN.jpg", + "episodes": 12, + "synonyms": [ + "Mushoku Tensei: Jobless Reincarnation Part 2", + "ชาตินี้พี่ต้องเทพ พาร์ท 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2", + "english": "Mushoku Tensei: Jobless Reincarnation Cour 2", + "native": "無職転生 ~異世界行ったら本気だす~ 第2クール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx127720-ADJgIrUVMdU9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx127720-ADJgIrUVMdU9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx127720-ADJgIrUVMdU9.jpg", + "color": "#d6bb1a" + }, + "startDate": { + "year": 2021, + "month": 10, + "day": 4 + }, + "endDate": { + "year": 2021, + "month": 12, + "day": 20 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 85470, + "idMal": 70261, + "siteUrl": "https://anilist.co/manga/85470", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85470-akkFSKH9aacB.jpg", + "synonyms": [ + "เกิดชาตินี้พี่ต้องเทพ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "english": "Mushoku Tensei: Jobless Reincarnation", + "native": "無職転生 ~異世界行ったら本気だす~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85470-jt6BF9tDWB2X.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85470-jt6BF9tDWB2X.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85470-jt6BF9tDWB2X.jpg", + "color": "#f1bb1a" + }, + "startDate": { + "year": 2014, + "month": 1, + "day": 23 + }, + "endDate": { + "year": 2022, + "month": 11, + "day": 25 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 142989, + "idMal": 142765, + "siteUrl": "https://anilist.co/manga/142989", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "Mushoku Tensei - Depressed Magician" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen", + "native": "無職転生 ~異世界行ったら本気だす~ 失意の魔術師編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx142989-jYDNHLwdER70.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx142989-jYDNHLwdER70.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx142989-jYDNHLwdER70.png", + "color": "#e4bb28" + }, + "startDate": { + "year": 2021, + "month": 12, + "day": 20 + }, + "endDate": {} + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 85564, + "idMal": 70259, + "siteUrl": "https://anilist.co/manga/85564", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85564-Wy8IQU3Km61c.jpg", + "synonyms": [ + "Mushoku Tensei: Uma segunda chance" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "english": "Mushoku Tensei: Jobless Reincarnation", + "native": "無職転生 ~異世界行ったら本気だす~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx85564-egXRASF0x9B9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx85564-egXRASF0x9B9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx85564-egXRASF0x9B9.jpg", + "color": "#e4ae0d" + }, + "startDate": { + "year": 2014, + "month": 5, + "day": 2 + }, + "endDate": {} + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 166873, + "idMal": 55888, + "siteUrl": "https://anilist.co/anime/166873", + "status": "NOT_YET_RELEASED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/166873-GTi5imE5skM2.jpg", + "episodes": 12, + "synonyms": [ + "Mushoku Tensei: Jobless Reincarnation Season 2 Part 2", + "ชาตินี้พี่ต้องเทพ ภาค 2", + "Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season Part 2", + "Mushoku Tensei II: Jobless Reincarnation Part 2", + "Mushoku Tensei II: Reencarnación desde cero", + "无职转生~到了异世界就拿出真本事~第2季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2", + "romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2", + "native": "無職転生 Ⅱ ~異世界行ったら本気だす~ 第2クール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx166873-jXjrMEDpMFaC.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx166873-jXjrMEDpMFaC.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx166873-jXjrMEDpMFaC.jpg", + "color": "#e4ae78" + }, + "startDate": { + "year": 2024, + "month": 4, + "day": 8 + }, + "endDate": { + "year": 2024, + "month": 6 + } + } + } + ] + } + } + }, + { + "id": 389433841, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 163263, + "idMal": 54898, + "siteUrl": "https://anilist.co/anime/163263", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163263-cx1zOVfatLxW.jpg", + "episodes": 11, + "synonyms": [ + "BSD 5" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 5th Season", + "romaji": "Bungou Stray Dogs 5th Season", + "english": "Bungo Stray Dogs 5", + "native": "文豪ストレイドッグス 第5シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163263-uz881pFAyi7P.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163263-uz881pFAyi7P.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163263-uz881pFAyi7P.jpg", + "color": "#e41aa1" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 12 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 20 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 141249, + "idMal": 50330, + "siteUrl": "https://anilist.co/anime/141249", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/141249-ssUG44UgGOMK.jpg", + "episodes": 13, + "synonyms": [ + "BSD 4", + "BungouSD 4", + "คณะประพันธกรจรจัด ภาค 4", + "文豪野犬第四季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 4th Season", + "romaji": "Bungou Stray Dogs 4th Season", + "english": "Bungo Stray Dogs 4", + "native": "文豪ストレイドッグス 第4シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx141249-8tjavEDHmLoT.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx141249-8tjavEDHmLoT.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx141249-8tjavEDHmLoT.jpg", + "color": "#fe5d50" + }, + "startDate": { + "year": 2023, + "month": 1, + "day": 4 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 29 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 85307, + "idMal": 56529, + "siteUrl": "https://anilist.co/manga/85307", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85307-HPlNkWrkD0nk.jpg", + "synonyms": [ + "Literary Stray Dogs", + "Bezpańscy literaci", + "文豪野犬", + "Проза бродячих псов", + "คณะประพันธกรจรจัด", + "Văn hào lưu lạc", + "문호 스트레이독스", + "文豪Stray Dogs" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85307-cfwdYRhtHRpD.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85307-cfwdYRhtHRpD.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85307-cfwdYRhtHRpD.png" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 4 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433843, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 31, + "idMal": 31, + "siteUrl": "https://anilist.co/anime/31", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/31-pYM5NdKIRa5h.jpg", + "episodes": 1, + "synonyms": [ + "Evangelion: Death (True)", + "Evangelion: Death (True)²", + "Revival of Evangelion" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion Movie: Shi to Shinsei", + "romaji": "Shin Seiki Evangelion Movie: Shi to Shinsei", + "english": "Neon Genesis Evangelion: Death \u0026 Rebirth", + "native": "新世紀エヴァンゲリオン劇場版 シト新生" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx31-3zRThtzQH62E.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx31-3zRThtzQH62E.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx31-3zRThtzQH62E.png", + "color": "#e4785d" + }, + "startDate": { + "year": 1997, + "month": 3, + "day": 15 + }, + "endDate": { + "year": 1997, + "month": 3, + "day": 15 + }, + "relations": { + "edges": [ + { + "relationType": "PARENT", + "node": { + "id": 30, + "idMal": 30, + "siteUrl": "https://anilist.co/anime/30", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/30-gEMoHHIqxDgN.jpg", + "episodes": 26, + "synonyms": [ + "NGE", + "Eva", + "ניאון ג'נסיס אוונגליון", + "อีวานเกเลียน มหาสงครามวันพิพากษา", + "Евангелион" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion", + "romaji": "Shin Seiki Evangelion", + "english": "Neon Genesis Evangelion", + "native": "新世紀エヴァンゲリオン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx30-wmNoX3m2qTzz.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx30-wmNoX3m2qTzz.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx30-wmNoX3m2qTzz.jpg", + "color": "#e4865d" + }, + "startDate": { + "year": 1995, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 1996, + "month": 3, + "day": 27 + } + } + }, + { + "relationType": "PARENT", + "node": { + "id": 32, + "idMal": 32, + "siteUrl": "https://anilist.co/anime/32", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n32-BH9yHJBQqeOa.jpg", + "episodes": 1, + "synonyms": [ + "הסוף של אוונגליון", + "אוונגליון של הסוף", + "Конец Евангелиона" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "romaji": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "english": "Neon Genesis Evangelion: The End of Evangelion", + "native": "新世紀エヴァンゲリオン劇場版 Air/まごころを、君に" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx32-i4ijZI4MuPiV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx32-i4ijZI4MuPiV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx32-i4ijZI4MuPiV.jpg", + "color": "#e46b50" + }, + "startDate": { + "year": 1997, + "month": 7, + "day": 19 + }, + "endDate": { + "year": 1997, + "month": 7, + "day": 19 + } + } + } + ] + } + } + }, + { + "id": 389433844, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 151806, + "idMal": 52305, + "siteUrl": "https://anilist.co/anime/151806", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/151806-1AmWChFo1Ogh.jpg", + "episodes": 13, + "synonyms": [ + "Tomo-chan wa Onna no ko!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tomo-chan wa Onnanoko!", + "romaji": "Tomo-chan wa Onnanoko!", + "english": "Tomo-chan Is a Girl!", + "native": "トモちゃんは女の子!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx151806-IAMi2ctI5xJI.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx151806-IAMi2ctI5xJI.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx151806-IAMi2ctI5xJI.jpg", + "color": "#f1c95d" + }, + "startDate": { + "year": 2023, + "month": 1, + "day": 5 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 30 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 86300, + "idMal": 92149, + "siteUrl": "https://anilist.co/manga/86300", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/86300-sL2KyEMqCE7d.jpg", + "synonyms": [ + "Tomo-chan wa Onna no ko!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tomo-chan wa Onnanoko!", + "romaji": "Tomo-chan wa Onnanoko!", + "english": "Tomo-chan is a Girl!", + "native": "トモちゃんは女の子!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx86300-VQWRaxoTXciF.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx86300-VQWRaxoTXciF.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx86300-VQWRaxoTXciF.jpg" + }, + "startDate": { + "year": 2015, + "month": 4, + "day": 7 + }, + "endDate": { + "year": 2019, + "month": 7, + "day": 14 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 163339, + "idMal": 54925, + "siteUrl": "https://anilist.co/anime/163339", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163339-dlF49zHJusrh.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kurae! Telepathy", + "romaji": "Kurae! Telepathy", + "native": "くらえ!テレパシー" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163339-dkysy3QN6PEx.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163339-dkysy3QN6PEx.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163339-dkysy3QN6PEx.png", + "color": "#ffe443" + }, + "startDate": { + "year": 2023, + "month": 3, + "day": 24 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 24 + } + } + } + ] + } + } + }, + { + "id": 389433845, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 110355, + "idMal": 40059, + "siteUrl": "https://anilist.co/anime/110355", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/110355-NjeP9BVLSWG4.jpg", + "episodes": 12, + "synonyms": [ + "Golden Kamui 3" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy 3rd Season", + "romaji": "Golden Kamuy 3rd Season", + "english": "Golden Kamuy Season 3", + "native": "ゴールデンカムイ 第三期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx110355-yXOXm5tr8kgr.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx110355-yXOXm5tr8kgr.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx110355-yXOXm5tr8kgr.png" + }, + "startDate": { + "year": 2020, + "month": 10, + "day": 5 + }, + "endDate": { + "year": 2020, + "month": 12, + "day": 21 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 86559, + "idMal": 85968, + "siteUrl": "https://anilist.co/manga/86559", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/86559-hHgBaQktjtsd.jpg", + "synonyms": [ + "Golden Kamui" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy", + "romaji": "Golden Kamuy", + "english": "Golden Kamuy", + "native": "ゴールデンカムイ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx86559-YbAt5jybSKyX.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx86559-YbAt5jybSKyX.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx86559-YbAt5jybSKyX.jpg", + "color": "#e4861a" + }, + "startDate": { + "year": 2014, + "month": 8, + "day": 21 + }, + "endDate": { + "year": 2022, + "month": 4, + "day": 28 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 102977, + "idMal": 37989, + "siteUrl": "https://anilist.co/anime/102977", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/102977-kqlenTvimAZj.jpg", + "episodes": 12, + "synonyms": [ + "Golden Kamui 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy 2nd Season", + "romaji": "Golden Kamuy 2nd Season", + "english": "Golden Kamuy Season 2", + "native": "ゴールデンカムイ 第二期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx102977-gUejfwQWpnzX.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx102977-gUejfwQWpnzX.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx102977-gUejfwQWpnzX.png", + "color": "#e4c90d" + }, + "startDate": { + "year": 2018, + "month": 10, + "day": 8 + }, + "endDate": { + "year": 2018, + "month": 12, + "day": 24 + } + } + }, + { + "relationType": "SPIN_OFF", + "node": { + "id": 106506, + "idMal": 37755, + "siteUrl": "https://anilist.co/anime/106506", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n106506-Eb40WFgbVOQE.jpg", + "episodes": 46, + "synonyms": [ + "Golden Kamuy Short Anime: Golden Douga Gekijou" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy: Golden Douga Gekijou", + "romaji": "Golden Kamuy: Golden Douga Gekijou", + "native": "ゴールデンカムイ 「ゴールデン道画劇場」" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106506-wTcpHendmbZA.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106506-wTcpHendmbZA.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/n106506-wTcpHendmbZA.jpg", + "color": "#f1d643" + }, + "startDate": { + "year": 2018, + "month": 4, + "day": 16 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 27 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 142343, + "idMal": 50528, + "siteUrl": "https://anilist.co/anime/142343", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "episodes": 13, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy 4th Season", + "romaji": "Golden Kamuy 4th Season", + "english": "Golden Kamuy Season 4", + "native": "ゴールデンカムイ 第四期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx142343-o8gkjIF434qZ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx142343-o8gkjIF434qZ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx142343-o8gkjIF434qZ.jpg" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 26 + } + } + } + ] + } + } + }, + { + "id": 389433846, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 103047, + "idMal": 37987, + "siteUrl": "https://anilist.co/anime/103047", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/103047-Tjvsh5w1XZP4.jpg", + "episodes": 1, + "synonyms": [ + "Виолетта Эвергарден", + "Вайоллет Эвергарден", + "薇尔莉特·伊芙加登", + "Violet Evergarden – film" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Movie", + "romaji": "Violet Evergarden Movie", + "english": "Violet Evergarden: the Movie", + "native": "劇場版 ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx103047-LYIbLtN2Rb5T.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx103047-LYIbLtN2Rb5T.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx103047-LYIbLtN2Rb5T.jpg", + "color": "#35a1f1" + }, + "startDate": { + "year": 2020, + "month": 9, + "day": 18 + }, + "endDate": { + "year": 2020, + "month": 9, + "day": 18 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 97298, + "idMal": 98930, + "siteUrl": "https://anilist.co/manga/97298", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/97298-uybqRwjpsgyX.png", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden", + "romaji": "Violet Evergarden", + "native": "ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx97298-2KETOAaDaTw7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx97298-2KETOAaDaTw7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx97298-2KETOAaDaTw7.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2015, + "month": 12, + "day": 25 + }, + "endDate": { + "year": 2016, + "month": 12, + "day": 26 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 109190, + "idMal": 39741, + "siteUrl": "https://anilist.co/anime/109190", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/109190-SpM8A4w83FnR.jpg", + "episodes": 1, + "synonyms": [ + "Violet Evergarden und das Band der Freundschaft", + "Violet Evergarden Gaiden: La Eternidad y la Muñeca de Recuerdos Automáticos", + "Violet Evergarden Gaiden: Eternidade e a Boneca de Automemória", + "فيوليت: الأبدية وذكريات الدمية الآلية", + "Вайолет Эвергарден: Вечность и призрак пера", + "Violet Evergarden: Věčnost a Píšící panenka" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "romaji": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "english": "Violet Evergarden: Eternity and the Auto Memory Doll", + "native": "ヴァイオレット・エヴァーガーデン 外伝~永遠と自動手記人形~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx109190-e8mv1qdmpjLW.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx109190-e8mv1qdmpjLW.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx109190-e8mv1qdmpjLW.jpg", + "color": "#e4e4a1" + }, + "startDate": { + "year": 2019, + "month": 9, + "day": 6 + }, + "endDate": { + "year": 2019, + "month": 9, + "day": 6 + } + } + } + ] + } + } + }, + { + "id": 389433847, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 114963, + "idMal": 41168, + "siteUrl": "https://anilist.co/anime/114963", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/114963-UveNLlSCDPVM.jpg", + "episodes": 1, + "synonyms": [ + "Nakineko", + "Amor de Gata", + "Loin de moi, près de toi", + "Olhos de Gato", + "Um ein Schnurrhaar", + "Miyo - Un amore felino", + "Для тебя я стану кошкой" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Nakitai Watashi wa Neko wo Kaburu", + "romaji": "Nakitai Watashi wa Neko wo Kaburu", + "english": "A Whisker Away", + "native": "泣きたい私は猫をかぶる" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx114963-QWMbi5ttovSK.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx114963-QWMbi5ttovSK.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx114963-QWMbi5ttovSK.png", + "color": "#bbe45d" + }, + "startDate": { + "year": 2020, + "month": 6, + "day": 18 + }, + "endDate": { + "year": 2020, + "month": 6, + "day": 18 + }, + "relations": { + "edges": [ + { + "relationType": "ADAPTATION", + "node": { + "id": 118686, + "idMal": 134765, + "siteUrl": "https://anilist.co/manga/118686", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "Wanting to Cry, I Pretend to Be a Cat", + "O mały wąs", + "Loin de moi, près de toi" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Nakitai Watashi wa Neko wo Kaburu", + "romaji": "Nakitai Watashi wa Neko wo Kaburu", + "native": "泣きたい私は猫をかぶる" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx118686-bcOYTBwEbZnS.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx118686-bcOYTBwEbZnS.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx118686-bcOYTBwEbZnS.jpg", + "color": "#a1d6f1" + }, + "startDate": { + "year": 2020, + "month": 5, + "day": 15 + }, + "endDate": { + "year": 2021, + "month": 3, + "day": 12 + } + } + } + ] + } + } + }, + { + "id": 389433848, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 30, + "idMal": 30, + "siteUrl": "https://anilist.co/anime/30", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/30-gEMoHHIqxDgN.jpg", + "episodes": 26, + "synonyms": [ + "NGE", + "Eva", + "ניאון ג'נסיס אוונגליון", + "อีวานเกเลียน มหาสงครามวันพิพากษา", + "Евангелион" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion", + "romaji": "Shin Seiki Evangelion", + "english": "Neon Genesis Evangelion", + "native": "新世紀エヴァンゲリオン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx30-wmNoX3m2qTzz.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx30-wmNoX3m2qTzz.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx30-wmNoX3m2qTzz.jpg", + "color": "#e4865d" + }, + "startDate": { + "year": 1995, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 1996, + "month": 3, + "day": 27 + }, + "relations": { + "edges": [ + { + "relationType": "SEQUEL", + "node": { + "id": 32, + "idMal": 32, + "siteUrl": "https://anilist.co/anime/32", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n32-BH9yHJBQqeOa.jpg", + "episodes": 1, + "synonyms": [ + "הסוף של אוונגליון", + "אוונגליון של הסוף", + "Конец Евангелиона" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "romaji": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "english": "Neon Genesis Evangelion: The End of Evangelion", + "native": "新世紀エヴァンゲリオン劇場版 Air/まごころを、君に" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx32-i4ijZI4MuPiV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx32-i4ijZI4MuPiV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx32-i4ijZI4MuPiV.jpg", + "color": "#e46b50" + }, + "startDate": { + "year": 1997, + "month": 7, + "day": 19 + }, + "endDate": { + "year": 1997, + "month": 7, + "day": 19 + } + } + }, + { + "relationType": "ADAPTATION", + "node": { + "id": 30698, + "idMal": 698, + "siteUrl": "https://anilist.co/manga/30698", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/30698-c4AljmpCyINA.jpg", + "synonyms": [ + "Shinseiki Evangelion", + "Neogénesis Evangelion", + "เอวานเกเลี่ยน", + "Новый век: Евангелион" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion", + "romaji": "Shin Seiki Evangelion", + "english": "Neon Genesis Evangelion", + "native": "新世紀エヴァンゲリオン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx30698-0niTa3yn2rNK.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30698-0niTa3yn2rNK.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx30698-0niTa3yn2rNK.png", + "color": "#e4935d" + }, + "startDate": { + "year": 1994, + "month": 12, + "day": 26 + }, + "endDate": { + "year": 2013, + "month": 6, + "day": 4 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 31, + "idMal": 31, + "siteUrl": "https://anilist.co/anime/31", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/31-pYM5NdKIRa5h.jpg", + "episodes": 1, + "synonyms": [ + "Evangelion: Death (True)", + "Evangelion: Death (True)²", + "Revival of Evangelion" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion Movie: Shi to Shinsei", + "romaji": "Shin Seiki Evangelion Movie: Shi to Shinsei", + "english": "Neon Genesis Evangelion: Death \u0026 Rebirth", + "native": "新世紀エヴァンゲリオン劇場版 シト新生" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx31-3zRThtzQH62E.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx31-3zRThtzQH62E.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx31-3zRThtzQH62E.png", + "color": "#e4785d" + }, + "startDate": { + "year": 1997, + "month": 3, + "day": 15 + }, + "endDate": { + "year": 1997, + "month": 3, + "day": 15 + } + } + }, + { + "relationType": "SPIN_OFF", + "node": { + "id": 4130, + "idMal": 4130, + "siteUrl": "https://anilist.co/anime/4130", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/4130-s7JBj1RQGhFC.jpg", + "episodes": 24, + "synonyms": [ + "Puchi Eva", + " Eva-School", + "EAS", + "EOS" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Petit Eva: Evangelion@School", + "romaji": "Petit Eva: Evangelion@School", + "native": "ぷちえゔぁ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b4130-cNTifeekohY6.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b4130-cNTifeekohY6.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b4130-cNTifeekohY6.jpg", + "color": "#e49328" + }, + "startDate": { + "year": 2007, + "month": 3, + "day": 20 + }, + "endDate": { + "year": 2009, + "month": 3, + "day": 11 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 102096, + "idMal": 23023, + "siteUrl": "https://anilist.co/anime/102096", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Peaceful Times (F02) Petit Film", + "romaji": "Peaceful Times (F02) Petit Film" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/102096-P27SABRbvxqn.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/102096-P27SABRbvxqn.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/102096-P27SABRbvxqn.jpg" + }, + "startDate": { + "year": 2013, + "month": 11, + "day": 23 + }, + "endDate": { + "year": 2013, + "month": 11, + "day": 23 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 55594, + "idMal": 25594, + "siteUrl": "https://anilist.co/manga/55594", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/55594-t9Lm9lTXlyLO.jpg", + "synonyms": [ + "Evangelion Anima" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion: ANIMA", + "romaji": "Evangelion: ANIMA", + "english": "Neon Genesis Evangelion: ANIMA", + "native": "エヴァンゲリオン ANIMA" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx55594-d0muWcognBKS.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx55594-d0muWcognBKS.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx55594-d0muWcognBKS.jpg", + "color": "#e40d1a" + }, + "startDate": { + "year": 2007, + "month": 11, + "day": 24 + }, + "endDate": { + "year": 2013, + "month": 2, + "day": 25 + } + } + }, + { + "relationType": "SPIN_OFF", + "node": { + "id": 101195, + "idMal": 99614, + "siteUrl": "https://anilist.co/manga/101195", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion: Pikopiko Chuugakusei Densetsu", + "romaji": "Shin Seiki Evangelion: Pikopiko Chuugakusei Densetsu", + "english": "Neon Genesis Evangelion: Legend of the Piko Piko Middle School Students", + "native": "新世紀エヴァンゲリオン ピコピコ中学生伝説" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx101195-dFpQ9JBSnil0.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx101195-dFpQ9JBSnil0.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx101195-dFpQ9JBSnil0.jpg", + "color": "#f1ae5d" + }, + "startDate": { + "year": 2014, + "month": 4, + "day": 4 + }, + "endDate": { + "year": 2018, + "month": 8, + "day": 4 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 125785, + "idMal": 43751, + "siteUrl": "https://anilist.co/anime/125785", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/125785-2ynUB7SVV300.jpg", + "episodes": 1, + "synonyms": [ + "KATE 「綾波レイ、はじめての口紅」", + "Kate: Rei Ayanami, hajimete no kuchibeni", + "Rei Ayanami, First Lipstick", + "KATE 「綾波レイ、はじめての口紅、その後」", + "Kate: Rei Ayanami, hajimete no kuchibeni, sonogo" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion x KATE CM", + "romaji": "Evangelion x KATE CM", + "native": "エヴァンゲリオン x KATE CM" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx125785-ocT9A7Lsiui2.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx125785-ocT9A7Lsiui2.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx125785-ocT9A7Lsiui2.png", + "color": "#e45dae" + }, + "startDate": { + "year": 2020, + "month": 11, + "day": 3 + }, + "endDate": { + "year": 2020, + "month": 11, + "day": 3 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 125796, + "idMal": 43745, + "siteUrl": "https://anilist.co/anime/125796", + "status": "FINISHED", + "type": "ANIME", + "format": "ONA", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion × Attack ZERO", + "romaji": "Evangelion × Attack ZERO", + "native": "エヴァンゲリオン × アタックZERO" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx125796-SZ4SfxqcSwUJ.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx125796-SZ4SfxqcSwUJ.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx125796-SZ4SfxqcSwUJ.png", + "color": "#78aed6" + }, + "startDate": { + "year": 2020, + "month": 11, + "day": 2 + }, + "endDate": { + "year": 2020, + "month": 11, + "day": 2 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 100620, + "idMal": 36531, + "siteUrl": "https://anilist.co/anime/100620", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/100620-L63Lj2TUaFNn.jpg", + "episodes": 76, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shinkansen Henkei Robo Shinkalion", + "romaji": "Shinkansen Henkei Robo Shinkalion", + "native": "新幹線変形ロボ シンカリオン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx100620-VnjYL5B1kdTf.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx100620-VnjYL5B1kdTf.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx100620-VnjYL5B1kdTf.jpg", + "color": "#5dbbe4" + }, + "startDate": { + "year": 2018, + "month": 1, + "day": 6 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 29 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 110131, + "idMal": 39967, + "siteUrl": "https://anilist.co/anime/110131", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "episodes": 1, + "synonyms": [ + "Shinkansen-Transforming Robot Shinkalion the Movie: The Mythically Fast ALFA-X That Came From Future", + "Shinkalion the Movie: Godspeed ALFA-X That Comes from the Future", + "Shinkansen-Transforming Robot Shinkalion the Movie:The Marvelous Fast ALFA-X That Came From Future" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shinkansen Henkei Robo Shinkalion: Mirai kara Kita Shinsoku no ALFA-X", + "romaji": "Shinkansen Henkei Robo Shinkalion: Mirai kara Kita Shinsoku no ALFA-X", + "native": "新幹線変形ロボ シンカリオン 未来からきた神速のALFA-X" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx110131-byIGt5Ng6yvk.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx110131-byIGt5Ng6yvk.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx110131-byIGt5Ng6yvk.jpg", + "color": "#35aee4" + }, + "startDate": { + "year": 2019, + "month": 12, + "day": 27 + }, + "endDate": { + "year": 2019, + "month": 12, + "day": 27 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 128737, + "idMal": 46381, + "siteUrl": "https://anilist.co/anime/128737", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "episodes": 41, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shinkansen Henkei Robo Shinkalion Z", + "romaji": "Shinkansen Henkei Robo Shinkalion Z", + "native": "新幹線変形ロボ シンカリオンZ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx128737-CI5WQAM4LXv6.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx128737-CI5WQAM4LXv6.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx128737-CI5WQAM4LXv6.jpg", + "color": "#1aaee4" + }, + "startDate": { + "year": 2021, + "month": 4, + "day": 9 + }, + "endDate": { + "year": 2022, + "month": 3, + "day": 18 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 171643, + "siteUrl": "https://anilist.co/manga/171643", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "It's A Miraculous Win" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mogami Sakura no Detama Hokan Keikaku - Kiseki no Kachi wa", + "romaji": "Mogami Sakura no Detama Hokan Keikaku - Kiseki no Kachi wa", + "native": "最上さくらの出玉補完計画 奇跡の勝ちは" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/b171643-cO6LXBp2Z0qZ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/b171643-cO6LXBp2Z0qZ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/b171643-cO6LXBp2Z0qZ.jpg", + "color": "#f150bb" + }, + "startDate": { + "year": 2006, + "month": 10, + "day": 20 + }, + "endDate": { + "year": 2006, + "month": 10, + "day": 20 + } + } + } + ] + } + } + }, + { + "id": 389450904, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21311, + "idMal": 31478, + "siteUrl": "https://anilist.co/anime/21311", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21311-oVJYXoU38Lm5.jpg", + "episodes": 12, + "synonyms": [ + "כלבי ספרות נודדים", + "Văn hào lưu lạc", + "คณะประพันธกรจรจัด" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21311-hAXyT8Yoh6G9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21311-hAXyT8Yoh6G9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21311-hAXyT8Yoh6G9.jpg", + "color": "#e4bb50" + }, + "startDate": { + "year": 2016, + "month": 4, + "day": 7 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 85307, + "idMal": 56529, + "siteUrl": "https://anilist.co/manga/85307", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85307-HPlNkWrkD0nk.jpg", + "synonyms": [ + "Literary Stray Dogs", + "Bezpańscy literaci", + "文豪野犬", + "Проза бродячих псов", + "คณะประพันธกรจรจัด", + "Văn hào lưu lạc", + "문호 스트레이독스", + "文豪Stray Dogs" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85307-cfwdYRhtHRpD.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85307-cfwdYRhtHRpD.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85307-cfwdYRhtHRpD.png" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 4 + }, + "endDate": {} + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 21679, + "idMal": 32867, + "siteUrl": "https://anilist.co/anime/21679", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21679-cOXDcrmMGXBy.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2016)", + "คณะประพันธกรจรจัด ภาค 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 2nd Season", + "romaji": "Bungou Stray Dogs 2nd Season", + "english": "Bungo Stray Dogs 2", + "native": "文豪ストレイドッグス 第2シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21679-9MKdz1A7YLV7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21679-9MKdz1A7YLV7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21679-9MKdz1A7YLV7.jpg", + "color": "#f1e4ae" + }, + "startDate": { + "year": 2016, + "month": 10, + "day": 6 + }, + "endDate": { + "year": 2016, + "month": 12, + "day": 16 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 98384, + "idMal": 34944, + "siteUrl": "https://anilist.co/anime/98384", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/98384-qxrZmUiAl2Y5.jpg", + "episodes": 1, + "synonyms": [ + "Bungou Stray Dogs Movie" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: DEAD APPLE", + "romaji": "Bungou Stray Dogs: DEAD APPLE", + "english": "Bungo Stray Dogs: DEAD APPLE", + "native": "文豪ストレイドッグス DEAD APPLE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx98384-nXEnNzwiJ9BV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx98384-nXEnNzwiJ9BV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx98384-nXEnNzwiJ9BV.jpg", + "color": "#28a1e4" + }, + "startDate": { + "year": 2018, + "month": 3, + "day": 3 + }, + "endDate": { + "year": 2018, + "month": 3, + "day": 3 + } + } + }, + { + "relationType": "SPIN_OFF", + "node": { + "id": 120150, + "idMal": 42250, + "siteUrl": "https://anilist.co/anime/120150", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV_SHORT", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/120150-bkyCLDwgKG88.jpg", + "episodes": 12, + "synonyms": [ + "คณะประพันธกรจรจัด โฮ่ง!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs Wan!", + "romaji": "Bungou Stray Dogs Wan!", + "english": "Bungo Stray Dogs WAN!", + "native": "文豪ストレイドッグス わん!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx120150-hxvcRrYzgP2F.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx120150-hxvcRrYzgP2F.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx120150-hxvcRrYzgP2F.png", + "color": "#f1865d" + }, + "startDate": { + "year": 2021, + "month": 1, + "day": 13 + }, + "endDate": { + "year": 2021, + "month": 3, + "day": 31 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 97204, + "idMal": 98380, + "siteUrl": "https://anilist.co/manga/97204", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/97204-zBoCPdeq1L6Y.jpg", + "synonyms": [ + "Bungo Stray Dogs Novel 1", + "Văn Hào Lưu Lạc 1 - Dazai Osamu và sát hạch đầu vào", + "คณะประพันธกรจรจัด 1 ตอน การสอบเข้าสำนักงานของดาไซ โอซามุ ", + "Bungou Stray Dogs: Egzamin wstępny Osamu Dazaia " + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Dazai Osamu no Nyuusha Shiken", + "romaji": "Bungou Stray Dogs: Dazai Osamu no Nyuusha Shiken", + "english": "Bungo Stray Dogs: Osamu Dazai's Entrance Exam", + "native": "文豪ストレイドッグス 太宰治の入社試験" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx97204-siqW5Q2bJ7yH.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx97204-siqW5Q2bJ7yH.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx97204-siqW5Q2bJ7yH.jpg", + "color": "#e4bba1" + }, + "startDate": { + "year": 2014, + "month": 4, + "day": 1 + }, + "endDate": { + "year": 2014, + "month": 4, + "day": 1 + } + } + } + ] + } + } + }, + { + "id": 389450905, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 103223, + "idMal": 38003, + "siteUrl": "https://anilist.co/anime/103223", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/103223-noS5nsDOI1Qu.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2019)", + "BSD 3", + "BungouSD 3", + "คณะประพันธกรจรจัด ภาค 3", + "文豪野犬 第三季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 3rd Season", + "romaji": "Bungou Stray Dogs 3rd Season", + "english": "Bungo Stray Dogs 3", + "native": "文豪ストレイドッグス 第3シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx103223-bfdnnKWxE4YE.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx103223-bfdnnKWxE4YE.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx103223-bfdnnKWxE4YE.jpg" + }, + "startDate": { + "year": 2019, + "month": 4, + "day": 12 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 28 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 21679, + "idMal": 32867, + "siteUrl": "https://anilist.co/anime/21679", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21679-cOXDcrmMGXBy.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2016)", + "คณะประพันธกรจรจัด ภาค 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 2nd Season", + "romaji": "Bungou Stray Dogs 2nd Season", + "english": "Bungo Stray Dogs 2", + "native": "文豪ストレイドッグス 第2シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21679-9MKdz1A7YLV7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21679-9MKdz1A7YLV7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21679-9MKdz1A7YLV7.jpg", + "color": "#f1e4ae" + }, + "startDate": { + "year": 2016, + "month": 10, + "day": 6 + }, + "endDate": { + "year": 2016, + "month": 12, + "day": 16 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 85307, + "idMal": 56529, + "siteUrl": "https://anilist.co/manga/85307", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85307-HPlNkWrkD0nk.jpg", + "synonyms": [ + "Literary Stray Dogs", + "Bezpańscy literaci", + "文豪野犬", + "Проза бродячих псов", + "คณะประพันธกรจรจัด", + "Văn hào lưu lạc", + "문호 스트레이독스", + "文豪Stray Dogs" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85307-cfwdYRhtHRpD.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85307-cfwdYRhtHRpD.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85307-cfwdYRhtHRpD.png" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 4 + }, + "endDate": {} + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 123126, + "siteUrl": "https://anilist.co/manga/123126", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Bungo Stray Dogs Novel 7" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Dazai, Chuuya, Juugosai", + "romaji": "Bungou Stray Dogs: Dazai, Chuuya, Juugosai", + "english": "Bungo Stray Dogs: Dazai, Chuuya, Age Fifteen", + "native": "文豪ストレイドッグス 太宰, 中也, 十五歳" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx123126-XqfQktzT3nWO.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx123126-XqfQktzT3nWO.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx123126-XqfQktzT3nWO.jpg" + }, + "startDate": { + "year": 2019, + "month": 8, + "day": 1 + }, + "endDate": { + "year": 2019, + "month": 8, + "day": 1 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 141249, + "idMal": 50330, + "siteUrl": "https://anilist.co/anime/141249", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/141249-ssUG44UgGOMK.jpg", + "episodes": 13, + "synonyms": [ + "BSD 4", + "BungouSD 4", + "คณะประพันธกรจรจัด ภาค 4", + "文豪野犬第四季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 4th Season", + "romaji": "Bungou Stray Dogs 4th Season", + "english": "Bungo Stray Dogs 4", + "native": "文豪ストレイドッグス 第4シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx141249-8tjavEDHmLoT.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx141249-8tjavEDHmLoT.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx141249-8tjavEDHmLoT.jpg", + "color": "#fe5d50" + }, + "startDate": { + "year": 2023, + "month": 1, + "day": 4 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 29 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 155713, + "idMal": 152024, + "siteUrl": "https://anilist.co/manga/155713", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "Bungou Stray Dogs: Dazai, Chuuya, Age Fifteen", + "문호 스트레이독스 -다자이, 추야 15세-" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Dazai, Chuuya, Juugosai", + "romaji": "Bungou Stray Dogs: Dazai, Chuuya, Juugosai", + "english": "Bungo Stray Dogs: Dazai, Chuuya, Age Fifteen", + "native": "文豪ストレイドッグス 太宰、中也、十五歳" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx155713-uwAaRC3g7AGI.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx155713-uwAaRC3g7AGI.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx155713-uwAaRC3g7AGI.jpg", + "color": "#3586ff" + }, + "startDate": { + "year": 2022, + "month": 9, + "day": 26 + }, + "endDate": {} + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21791, + "idMal": 33071, + "siteUrl": "https://anilist.co/anime/21791", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "OVA", + "episodes": 1, + "synonyms": [ + "Bungou Stray Dogs 2 OVA", + "Bungou Stray Dogs 2: Episode 13" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Hitori Ayumu", + "romaji": "Bungou Stray Dogs: Hitori Ayumu", + "english": "Bungo Stray Dogs 2: Walking Alone", + "native": "文豪ストレイドッグス 『独り歩む』;" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21791-OIFsu8KC77Da.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21791-OIFsu8KC77Da.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21791-OIFsu8KC77Da.jpg", + "color": "#d6861a" + }, + "startDate": { + "year": 2017, + "month": 8, + "day": 4 + }, + "endDate": { + "year": 2017, + "month": 8, + "day": 4 + } + } + } + ] + } + } + }, + { + "id": 389450909, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 141249, + "idMal": 50330, + "siteUrl": "https://anilist.co/anime/141249", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/141249-ssUG44UgGOMK.jpg", + "episodes": 13, + "synonyms": [ + "BSD 4", + "BungouSD 4", + "คณะประพันธกรจรจัด ภาค 4", + "文豪野犬第四季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 4th Season", + "romaji": "Bungou Stray Dogs 4th Season", + "english": "Bungo Stray Dogs 4", + "native": "文豪ストレイドッグス 第4シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx141249-8tjavEDHmLoT.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx141249-8tjavEDHmLoT.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx141249-8tjavEDHmLoT.jpg", + "color": "#fe5d50" + }, + "startDate": { + "year": 2023, + "month": 1, + "day": 4 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 29 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 103223, + "idMal": 38003, + "siteUrl": "https://anilist.co/anime/103223", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/103223-noS5nsDOI1Qu.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2019)", + "BSD 3", + "BungouSD 3", + "คณะประพันธกรจรจัด ภาค 3", + "文豪野犬 第三季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 3rd Season", + "romaji": "Bungou Stray Dogs 3rd Season", + "english": "Bungo Stray Dogs 3", + "native": "文豪ストレイドッグス 第3シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx103223-bfdnnKWxE4YE.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx103223-bfdnnKWxE4YE.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx103223-bfdnnKWxE4YE.jpg" + }, + "startDate": { + "year": 2019, + "month": 4, + "day": 12 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 28 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 85307, + "idMal": 56529, + "siteUrl": "https://anilist.co/manga/85307", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85307-HPlNkWrkD0nk.jpg", + "synonyms": [ + "Literary Stray Dogs", + "Bezpańscy literaci", + "文豪野犬", + "Проза бродячих псов", + "คณะประพันธกรจรจัด", + "Văn hào lưu lạc", + "문호 스트레이독스", + "文豪Stray Dogs" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85307-cfwdYRhtHRpD.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85307-cfwdYRhtHRpD.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85307-cfwdYRhtHRpD.png" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 4 + }, + "endDate": {} + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 123118, + "siteUrl": "https://anilist.co/manga/123118", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Bungo Stray Dogs Novel 3", + "คณะประพันธกรจรจัด 3 ตอน เรื่องลับเบื้องหลังการก่อตั้งสำนักงานนักสืบ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Tanteisha Setsuritsu Hiwa", + "romaji": "Bungou Stray Dogs: Tanteisha Setsuritsu Hiwa", + "english": "Bungo Stray Dogs: The Untold Origins of the Detective Agency", + "native": "文豪ストレイドッグス 探偵社設立秘話" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx123118-bc8a8nYKQ2D5.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx123118-bc8a8nYKQ2D5.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx123118-bc8a8nYKQ2D5.jpg", + "color": "#864328" + }, + "startDate": { + "year": 2015, + "month": 5, + "day": 1 + }, + "endDate": { + "year": 2015, + "month": 5, + "day": 1 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 163263, + "idMal": 54898, + "siteUrl": "https://anilist.co/anime/163263", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163263-cx1zOVfatLxW.jpg", + "episodes": 11, + "synonyms": [ + "BSD 5" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 5th Season", + "romaji": "Bungou Stray Dogs 5th Season", + "english": "Bungo Stray Dogs 5", + "native": "文豪ストレイドッグス 第5シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163263-uz881pFAyi7P.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163263-uz881pFAyi7P.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163263-uz881pFAyi7P.jpg", + "color": "#e41aa1" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 12 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 20 + } + } + } + ] + } + } + }, + { + "id": 389450910, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21679, + "idMal": 32867, + "siteUrl": "https://anilist.co/anime/21679", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21679-cOXDcrmMGXBy.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2016)", + "คณะประพันธกรจรจัด ภาค 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 2nd Season", + "romaji": "Bungou Stray Dogs 2nd Season", + "english": "Bungo Stray Dogs 2", + "native": "文豪ストレイドッグス 第2シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21679-9MKdz1A7YLV7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21679-9MKdz1A7YLV7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21679-9MKdz1A7YLV7.jpg", + "color": "#f1e4ae" + }, + "startDate": { + "year": 2016, + "month": 10, + "day": 6 + }, + "endDate": { + "year": 2016, + "month": 12, + "day": 16 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 21311, + "idMal": 31478, + "siteUrl": "https://anilist.co/anime/21311", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21311-oVJYXoU38Lm5.jpg", + "episodes": 12, + "synonyms": [ + "כלבי ספרות נודדים", + "Văn hào lưu lạc", + "คณะประพันธกรจรจัด" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21311-hAXyT8Yoh6G9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21311-hAXyT8Yoh6G9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21311-hAXyT8Yoh6G9.jpg", + "color": "#e4bb50" + }, + "startDate": { + "year": 2016, + "month": 4, + "day": 7 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 23 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 85307, + "idMal": 56529, + "siteUrl": "https://anilist.co/manga/85307", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85307-HPlNkWrkD0nk.jpg", + "synonyms": [ + "Literary Stray Dogs", + "Bezpańscy literaci", + "文豪野犬", + "Проза бродячих псов", + "คณะประพันธกรจรจัด", + "Văn hào lưu lạc", + "문호 스트레이독스", + "文豪Stray Dogs" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85307-cfwdYRhtHRpD.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85307-cfwdYRhtHRpD.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85307-cfwdYRhtHRpD.png" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 4 + }, + "endDate": {} + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 103223, + "idMal": 38003, + "siteUrl": "https://anilist.co/anime/103223", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/103223-noS5nsDOI1Qu.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2019)", + "BSD 3", + "BungouSD 3", + "คณะประพันธกรจรจัด ภาค 3", + "文豪野犬 第三季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 3rd Season", + "romaji": "Bungou Stray Dogs 3rd Season", + "english": "Bungo Stray Dogs 3", + "native": "文豪ストレイドッグス 第3シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx103223-bfdnnKWxE4YE.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx103223-bfdnnKWxE4YE.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx103223-bfdnnKWxE4YE.jpg" + }, + "startDate": { + "year": 2019, + "month": 4, + "day": 12 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 28 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 98384, + "idMal": 34944, + "siteUrl": "https://anilist.co/anime/98384", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/98384-qxrZmUiAl2Y5.jpg", + "episodes": 1, + "synonyms": [ + "Bungou Stray Dogs Movie" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: DEAD APPLE", + "romaji": "Bungou Stray Dogs: DEAD APPLE", + "english": "Bungo Stray Dogs: DEAD APPLE", + "native": "文豪ストレイドッグス DEAD APPLE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx98384-nXEnNzwiJ9BV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx98384-nXEnNzwiJ9BV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx98384-nXEnNzwiJ9BV.jpg", + "color": "#28a1e4" + }, + "startDate": { + "year": 2018, + "month": 3, + "day": 3 + }, + "endDate": { + "year": 2018, + "month": 3, + "day": 3 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 123117, + "siteUrl": "https://anilist.co/manga/123117", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Bungo Stray Dogs Novel 2", + "คณะประพันธกรจรจัด 2 ตอน ดาไซ โอซามุ กับยุคมืด", + "Bungou Stray Dogs: Mroczna przeszłość Osamu Dazaia" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Dazai Osamu to Kuro no Jidai", + "romaji": "Bungou Stray Dogs: Dazai Osamu to Kuro no Jidai", + "english": "Bungo Stray Dogs: Osamu Dazai and The Dark Era", + "native": "文豪ストレイドッグス 太宰治と黒の時代" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx123117-bCfUg3y1tns9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx123117-bCfUg3y1tns9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx123117-bCfUg3y1tns9.jpg" + }, + "startDate": { + "year": 2014, + "month": 8, + "day": 1 + }, + "endDate": { + "year": 2014, + "month": 8, + "day": 1 + } + } + } + ] + } + } + }, + { + "id": 391683774, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 109125, + "idMal": 39753, + "siteUrl": "https://anilist.co/anime/109125", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/109125-npZ3STlicS3a.jpg", + "episodes": 1, + "synonyms": [ + "Love, Be Loved, Leave, Be Left", + "Love Me, Love Me Not", + "Любит — не любит" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Omoi, Omoware, Furi, Furare", + "romaji": "Omoi, Omoware, Furi, Furare", + "native": "思い、思われ、ふり、ふられ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx109125-LRhTuc4RMct7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx109125-LRhTuc4RMct7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx109125-LRhTuc4RMct7.jpg", + "color": "#5daef1" + }, + "startDate": { + "year": 2020, + "month": 9, + "day": 18 + }, + "endDate": { + "year": 2020, + "month": 9, + "day": 18 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 86219, + "idMal": 89217, + "siteUrl": "https://anilist.co/manga/86219", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/86219-2bVGFlmTfEpX.jpg", + "synonyms": [ + "Furi Fura - Amores e Desenganos", + "Amar y Ser Amado, Dejar y Ser Dejado", + "Kocha...nie kocha...", + "Love, be loved Leave, be left" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Omoi, Omoware, Furi, Furare", + "romaji": "Omoi, Omoware, Furi, Furare", + "english": "Love Me, Love Me Not", + "native": "思い、思われ、ふり、ふられ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx86219-2p8Ky38bTQNv.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx86219-2p8Ky38bTQNv.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx86219-2p8Ky38bTQNv.png", + "color": "#f1d65d" + }, + "startDate": { + "year": 2015, + "month": 6, + "day": 13 + }, + "endDate": { + "year": 2019, + "month": 5, + "day": 13 + } + } + } + ] + } + } + }, + { + "id": 391683775, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 100305, + "idMal": 36539, + "siteUrl": "https://anilist.co/anime/100305", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/100305-Z6cZFkrwzf2L.jpg", + "episodes": 1, + "synonyms": [ + "as the moon so beautiful" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tsuki ga Kirei Special", + "romaji": "Tsuki ga Kirei Special", + "native": "月がきれい" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx100305-CRu6RZ5eINHP.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx100305-CRu6RZ5eINHP.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx100305-CRu6RZ5eINHP.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2017, + "month": 9, + "day": 27 + }, + "endDate": { + "year": 2017, + "month": 9, + "day": 27 + }, + "relations": { + "edges": [ + { + "relationType": "PARENT", + "node": { + "id": 98202, + "idMal": 34822, + "siteUrl": "https://anilist.co/anime/98202", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/98202-JlZYSjYB8pux.jpg", + "episodes": 12, + "synonyms": [ + "as the moon, so beautiful." + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tsuki ga Kirei", + "romaji": "Tsuki ga Kirei", + "english": "Tsukigakirei", + "native": "月がきれい" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx98202-H6RtsIMZPALF.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx98202-H6RtsIMZPALF.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx98202-H6RtsIMZPALF.png", + "color": "#1a506b" + }, + "startDate": { + "year": 2017, + "month": 4, + "day": 7 + }, + "endDate": { + "year": 2017, + "month": 6, + "day": 30 + } + } + } + ] + } + } + }, + { + "id": 392285307, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 98202, + "idMal": 34822, + "siteUrl": "https://anilist.co/anime/98202", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/98202-JlZYSjYB8pux.jpg", + "episodes": 12, + "synonyms": [ + "as the moon, so beautiful." + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tsuki ga Kirei", + "romaji": "Tsuki ga Kirei", + "english": "Tsukigakirei", + "native": "月がきれい" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx98202-H6RtsIMZPALF.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx98202-H6RtsIMZPALF.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx98202-H6RtsIMZPALF.png", + "color": "#1a506b" + }, + "startDate": { + "year": 2017, + "month": 4, + "day": 7 + }, + "endDate": { + "year": 2017, + "month": 6, + "day": 30 + }, + "relations": { + "edges": [ + { + "relationType": "SIDE_STORY", + "node": { + "id": 100305, + "idMal": 36539, + "siteUrl": "https://anilist.co/anime/100305", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/100305-Z6cZFkrwzf2L.jpg", + "episodes": 1, + "synonyms": [ + "as the moon so beautiful" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tsuki ga Kirei Special", + "romaji": "Tsuki ga Kirei Special", + "native": "月がきれい" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx100305-CRu6RZ5eINHP.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx100305-CRu6RZ5eINHP.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx100305-CRu6RZ5eINHP.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2017, + "month": 9, + "day": 27 + }, + "endDate": { + "year": 2017, + "month": 9, + "day": 27 + } + } + } + ] + } + } + }, + { + "id": 394257542, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2024, + "month": 3, + "day": 5 + }, + "completedAt": {}, + "media": { + "id": 16782, + "idMal": 16782, + "siteUrl": "https://anilist.co/anime/16782", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/16782.jpg", + "episodes": 1, + "synonyms": [ + "Koto no Ha no Niwa", + "The Garden of Kotonoha", + "El Jardín de las Palabras", + "A szavak kertje", + "ยามสายฝนโปรยปราย", + "Ogród słów", + "Сад изящных слов", + "Il giardino delle parole" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kotonoha no Niwa", + "romaji": "Kotonoha no Niwa", + "english": "The Garden of Words", + "native": "言の葉の庭" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx16782-1AekGIzlPy8a.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx16782-1AekGIzlPy8a.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx16782-1AekGIzlPy8a.jpg", + "color": "#93e45d" + }, + "startDate": { + "year": 2013, + "month": 5, + "day": 31 + }, + "endDate": { + "year": 2013, + "month": 5, + "day": 31 + }, + "relations": { + "edges": [ + { + "relationType": "ADAPTATION", + "node": { + "id": 81747, + "idMal": 51747, + "siteUrl": "https://anilist.co/manga/81747", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "Koto no Ha no Niwa", + "El Jardín de las Palabras", + "Ogród słów", + "O Jardim das Palavras" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kotonoha no Niwa", + "romaji": "Kotonoha no Niwa", + "english": "The Garden of Words", + "native": "言の葉の庭" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx81747-1rb8wa2nuZyj.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx81747-1rb8wa2nuZyj.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx81747-1rb8wa2nuZyj.jpg", + "color": "#e45d93" + }, + "startDate": { + "year": 2013, + "month": 4, + "day": 25 + }, + "endDate": { + "year": 2013, + "month": 10, + "day": 25 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 21519, + "idMal": 32281, + "siteUrl": "https://anilist.co/anime/21519", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21519-1ayMXgNlmByb.jpg", + "episodes": 1, + "synonyms": [ + "Your Name. - Gestern, heute und für immer ", + "Mi a Neved? ", + "你的名字。", + "너의 이름은.", + "Tu nombre", + "Твоё имя", + "หลับตาฝันถึงชื่อเธอ", + "Il tuo nome", + "השם שלך." + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi no Na wa.", + "romaji": "Kimi no Na wa.", + "english": "Your Name.", + "native": "君の名は。" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21519-XIr3PeczUjjF.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21519-XIr3PeczUjjF.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21519-XIr3PeczUjjF.png", + "color": "#0da1e4" + }, + "startDate": { + "year": 2016, + "month": 8, + "day": 26 + }, + "endDate": { + "year": 2016, + "month": 8, + "day": 26 + } + } + } + ] + } + } + }, + { + "id": 394259727, + "score": 0, + "progress": 3, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 142838, + "idMal": 50602, + "siteUrl": "https://anilist.co/anime/142838", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/142838-tynuN00wxmKO.jpg", + "episodes": 13, + "synonyms": [ + "SxF", + "스파이 패밀리", + "间谍过家家", + "スパイファミリー 2クール" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "SPY×FAMILY Part 2", + "romaji": "SPY×FAMILY Part 2", + "english": "SPY x FAMILY Cour 2", + "native": "SPY×FAMILY 第2クール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx142838-ECZSqfknAqAT.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx142838-ECZSqfknAqAT.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx142838-ECZSqfknAqAT.jpg", + "color": "#28bbfe" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 1 + }, + "endDate": { + "year": 2022, + "month": 12, + "day": 24 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 108556, + "idMal": 119161, + "siteUrl": "https://anilist.co/manga/108556", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/108556-iCiPfU0GU4OM.jpg", + "synonyms": [ + "SxF", + "스파이 패밀리", + "SPY×FAMILY: WJ Special Extra Mission!!", + "间谍过家家", + "スパイファミリー", + "Семья шпиона", + "SPY×FAMILY 間諜家家酒" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "SPY×FAMILY", + "romaji": "SPY×FAMILY", + "english": "SPY x FAMILY", + "native": "SPY×FAMILY" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx108556-NHjkz0BNJhLx.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx108556-NHjkz0BNJhLx.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx108556-NHjkz0BNJhLx.jpg", + "color": "#e43543" + }, + "startDate": { + "year": 2019, + "month": 3, + "day": 25 + }, + "endDate": {} + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 140960, + "idMal": 50265, + "siteUrl": "https://anilist.co/anime/140960", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/140960-Z7xSvkRxHKfj.jpg", + "episodes": 12, + "synonyms": [ + "SxF", + "스파이 패밀리", + "间谍过家家", + "Семья шпиона", + "سباي إكس فاميلي" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "SPY×FAMILY", + "romaji": "SPY×FAMILY", + "english": "SPY x FAMILY", + "native": "SPY×FAMILY" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx140960-vN39AmOWrVB5.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx140960-vN39AmOWrVB5.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx140960-vN39AmOWrVB5.jpg", + "color": "#c9f1f1" + }, + "startDate": { + "year": 2022, + "month": 4, + "day": 9 + }, + "endDate": { + "year": 2022, + "month": 6, + "day": 25 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 158927, + "idMal": 53887, + "siteUrl": "https://anilist.co/anime/158927", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/158927-zXtbXUO5iKzX.jpg", + "episodes": 12, + "synonyms": [ + "SxF 2", + "스파이 패밀리", + "Семья шпиона" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "SPY×FAMILY Season 2", + "romaji": "SPY×FAMILY Season 2", + "english": "SPY x FAMILY Season 2", + "native": "SPY×FAMILY Season 2" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b158927-lfO85WVguYgc.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b158927-lfO85WVguYgc.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b158927-lfO85WVguYgc.png", + "color": "#c9f1f1" + }, + "startDate": { + "year": 2023, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2023, + "month": 12, + "day": 23 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 158928, + "idMal": 53888, + "siteUrl": "https://anilist.co/anime/158928", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/158928-PYHI9eHkC1YQ.jpg", + "episodes": 1, + "synonyms": [ + "SxF Movie", + "劇場版 スパイファミリー" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "SPY×FAMILY CODE: White", + "romaji": "SPY×FAMILY CODE: White", + "english": "SPY x FAMILY CODE: White ", + "native": "SPY×FAMILY CODE: White" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx158928-CJ8fFwUQOPbu.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx158928-CJ8fFwUQOPbu.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx158928-CJ8fFwUQOPbu.jpg", + "color": "#5dbbe4" + }, + "startDate": { + "year": 2023, + "month": 12, + "day": 22 + }, + "endDate": { + "year": 2023, + "month": 12, + "day": 22 + } + } + } + ] + } + } + }, + { + "id": 389433700, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2024, + "month": 3, + "day": 2 + }, + "completedAt": {}, + "media": { + "id": 142343, + "idMal": 50528, + "siteUrl": "https://anilist.co/anime/142343", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "episodes": 13, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy 4th Season", + "romaji": "Golden Kamuy 4th Season", + "english": "Golden Kamuy Season 4", + "native": "ゴールデンカムイ 第四期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx142343-o8gkjIF434qZ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx142343-o8gkjIF434qZ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx142343-o8gkjIF434qZ.jpg" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 110355, + "idMal": 40059, + "siteUrl": "https://anilist.co/anime/110355", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/110355-NjeP9BVLSWG4.jpg", + "episodes": 12, + "synonyms": [ + "Golden Kamui 3" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy 3rd Season", + "romaji": "Golden Kamuy 3rd Season", + "english": "Golden Kamuy Season 3", + "native": "ゴールデンカムイ 第三期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx110355-yXOXm5tr8kgr.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx110355-yXOXm5tr8kgr.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx110355-yXOXm5tr8kgr.png" + }, + "startDate": { + "year": 2020, + "month": 10, + "day": 5 + }, + "endDate": { + "year": 2020, + "month": 12, + "day": 21 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 86559, + "idMal": 85968, + "siteUrl": "https://anilist.co/manga/86559", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/86559-hHgBaQktjtsd.jpg", + "synonyms": [ + "Golden Kamui" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy", + "romaji": "Golden Kamuy", + "english": "Golden Kamuy", + "native": "ゴールデンカムイ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx86559-YbAt5jybSKyX.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx86559-YbAt5jybSKyX.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx86559-YbAt5jybSKyX.jpg", + "color": "#e4861a" + }, + "startDate": { + "year": 2014, + "month": 8, + "day": 21 + }, + "endDate": { + "year": 2022, + "month": 4, + "day": 28 + } + } + }, + { + "relationType": "SPIN_OFF", + "node": { + "id": 106506, + "idMal": 37755, + "siteUrl": "https://anilist.co/anime/106506", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n106506-Eb40WFgbVOQE.jpg", + "episodes": 46, + "synonyms": [ + "Golden Kamuy Short Anime: Golden Douga Gekijou" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy: Golden Douga Gekijou", + "romaji": "Golden Kamuy: Golden Douga Gekijou", + "native": "ゴールデンカムイ 「ゴールデン道画劇場」" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106506-wTcpHendmbZA.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106506-wTcpHendmbZA.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/n106506-wTcpHendmbZA.jpg", + "color": "#f1d643" + }, + "startDate": { + "year": 2018, + "month": 4, + "day": 16 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 27 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 166521, + "idMal": 55772, + "siteUrl": "https://anilist.co/anime/166521", + "status": "NOT_YET_RELEASED", + "type": "ANIME", + "format": "TV", + "synonyms": [ + "Golden Kamuy Season 5", + "Golden Kamuy: The Final Chapter" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy: Saishuu Shou", + "romaji": "Golden Kamuy: Saishuu Shou", + "native": "ゴールデンカムイ 最終章" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx166521-C43c7jzYmEUw.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx166521-C43c7jzYmEUw.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx166521-C43c7jzYmEUw.jpg", + "color": "#f1785d" + }, + "startDate": {}, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433721, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 146984, + "idMal": 51535, + "siteUrl": "https://anilist.co/anime/146984", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/146984-yAkTtW2AExVj.jpg", + "episodes": 1, + "synonyms": [ + "Shingeki no Kyojin: The Final Season Final Edition", + "Shingeki no Kyojin: The Final Season Part 3", + "ผ่าพิภพไททัน ภาค 4", + "ผ่าพิภพไททัน ไฟนอล ซีซั่น Part 3", + "Attack on Titan Final Season Part 3 Final Arc Part 1", + "Attack on Titan The Final Season The Final Part Special", + "Attack on Titan The Final Season The Final Part Part 1", + "حمله به تایتان فصل آخر قسمت ویژه 1 " + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shingeki no Kyojin: The Final Season - Kanketsu-hen Zenpen", + "romaji": "Shingeki no Kyojin: The Final Season - Kanketsu-hen Zenpen", + "english": "Attack on Titan Final Season THE FINAL CHAPTERS Special 1", + "native": "進撃の巨人 The Final Season完結編 前編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx146984-EnCsTCpLyIBi.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx146984-EnCsTCpLyIBi.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx146984-EnCsTCpLyIBi.jpg", + "color": "#ffae35" + }, + "startDate": { + "year": 2023, + "month": 3, + "day": 4 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 4 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 131681, + "idMal": 48583, + "siteUrl": "https://anilist.co/anime/131681", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/131681-3tNurPRsqsfz.jpg", + "episodes": 12, + "synonyms": [ + "SnK 4", + "AoT 4", + "L'attaque des titans Saison Finale Partie 2", + "Shingeki no Kyojin: The Final Season (2022)", + "اتک عن تایتان", + "حمله به غول ها", + "حمله به تایتان فصل 4 ", + " ผ่าพิภพไททัน ไฟนอล ซีซั่น Part 2", + "ผ่าพิภพไททัน ภาค 4", + "L'Attacco dei Giganti 4 Parte 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shingeki no Kyojin: The Final Season Part 2", + "romaji": "Shingeki no Kyojin: The Final Season Part 2", + "english": "Attack on Titan Final Season Part 2", + "native": "進撃の巨人 The Final Season Part 2" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx131681-85KUYCnUyio2.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx131681-85KUYCnUyio2.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx131681-85KUYCnUyio2.jpg", + "color": "#35a1e4" + }, + "startDate": { + "year": 2022, + "month": 1, + "day": 10 + }, + "endDate": { + "year": 2022, + "month": 4, + "day": 4 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 53390, + "idMal": 23390, + "siteUrl": "https://anilist.co/manga/53390", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/53390-6Uru5rrjh8zv.jpg", + "synonyms": [ + "Atak Tytanów", + "SnK", + "AoT", + "Ataque dos Titãs", + "Ataque a los Titanes", + "L'Attacco dei Giganti", + "Titana Saldırı", + "Útok titánů", + "Titaanien sota - Attack on Titan", + "Атака на титанов", + "Napad titana", + "ผ่าพิภพไททัน", + "L'Attaque des Titans", + "Atacul Titanilor", + "進擊的巨人", + "진격의 거인" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shingeki no Kyojin", + "romaji": "Shingeki no Kyojin", + "english": "Attack on Titan", + "native": "進撃の巨人" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx53390-1RsuABC34P9D.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx53390-1RsuABC34P9D.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx53390-1RsuABC34P9D.jpg", + "color": "#d6431a" + }, + "startDate": { + "year": 2009, + "month": 9, + "day": 9 + }, + "endDate": { + "year": 2021, + "month": 4, + "day": 9 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 162314, + "siteUrl": "https://anilist.co/anime/162314", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/162314-N5M4XMz6O1j4.jpg", + "episodes": 1, + "synonyms": [ + "Shingeki no Kyojin: The Final Season Final Edition", + "Attack on Titan Final Season Part 3 Final Arc Part 2", + "Attack on Titan: The Final Season Part 4", + "Shingeki no Kyojin: The Final Season Part 4" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shingeki no Kyojin: The Final Season - Kanketsu-hen Kouhen", + "romaji": "Shingeki no Kyojin: The Final Season - Kanketsu-hen Kouhen", + "english": "Attack on Titan Final Season THE FINAL CHAPTERS Special 2", + "native": "進撃の巨人 The Final Season完結編 後編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx162314-ocaEhYmvznFO.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx162314-ocaEhYmvznFO.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx162314-ocaEhYmvznFO.jpg", + "color": "#5daee4" + }, + "startDate": { + "year": 2023, + "month": 11, + "day": 5 + }, + "endDate": { + "year": 2023, + "month": 11, + "day": 5 + } + } + } + ] + } + } + }, + { + "id": 389433725, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21, + "idMal": 21, + "siteUrl": "https://anilist.co/anime/21", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21-wf37VakJmZqs.jpg", + "synonyms": [ + "ワンピース", + "海贼王", + "וואן פיס", + "ون بيس", + "วันพีซ", + "Vua Hải Tặc", + "All'arrembaggio!", + "Tutti all'arrembaggio!", + "Ντρέηκ, το Κυνήγι του Θησαυρού" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE", + "romaji": "ONE PIECE", + "english": "ONE PIECE", + "native": "ONE PIECE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21-tXMN3Y20PIL9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21-tXMN3Y20PIL9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21-tXMN3Y20PIL9.jpg", + "color": "#e4a15d" + }, + "startDate": { + "year": 1999, + "month": 10, + "day": 20 + }, + "endDate": {}, + "nextAiringEpisode": { + "airingAt": 1710635400, + "timeUntilAiring": 522072, + "episode": 1097 + }, + "relations": { + "edges": [ + { + "relationType": "SIDE_STORY", + "node": { + "id": 466, + "idMal": 466, + "siteUrl": "https://anilist.co/anime/466", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/466-uS8J4XwJ35Ws.png", + "episodes": 1, + "synonyms": [ + "Defeat Him! The Pirate Ganzack!", + "ONE PIECE ジャンプスーパーアニメツアー'98", + "ONE PIECE Jump Super Anime Tour '98" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Taose! Kaizoku Ganzack", + "romaji": "ONE PIECE: Taose! Kaizoku Ganzack", + "english": "One Piece: Defeat the Pirate Ganzack!", + "native": "ONE PIECE 倒せ!海賊ギャンザック" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx466-bVP54I7dCB2F.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx466-bVP54I7dCB2F.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx466-bVP54I7dCB2F.jpg", + "color": "#e49328" + }, + "startDate": { + "year": 1998, + "month": 7, + "day": 26 + }, + "endDate": { + "year": 1998, + "month": 7, + "day": 26 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 1094, + "idMal": 1094, + "siteUrl": "https://anilist.co/anime/1094", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n1094-IAIqiFbLBsig.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Avventura nell'ombelico dell'oceano", + "One Piece Special: Adventure in the Ocean's Navel", + "วันพีซ ภาคพิเศษ 1 การผจญภัยใต้มหาสมุทร", + "Vua Hải Tặc: Cuộc phiêu lưu vào rốn đại dương" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE TV Special: Umi no Heso no Daibouken-hen", + "romaji": "ONE PIECE TV Special: Umi no Heso no Daibouken-hen", + "english": "One Piece: Umi no Heso no Daibouken-hen", + "native": "ONE PIECE TVスペシャル 海のヘソの大冒険篇" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx1094-H3YFJ1TR0ZZi.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx1094-H3YFJ1TR0ZZi.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx1094-H3YFJ1TR0ZZi.jpg", + "color": "#f1a10d" + }, + "startDate": { + "year": 2000, + "month": 12, + "day": 20 + }, + "endDate": { + "year": 2000, + "month": 12, + "day": 20 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 1237, + "idMal": 1237, + "siteUrl": "https://anilist.co/anime/1237", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n1237-hHWuRsuVsVpr.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Un tesoro grande un sogno", + "วันพีซ ภาคพิเศษ 2 ออกสู่ทะเลกว้างใหญ่ ความฝันอันยิ่งใหญ่ของพ่อ", + "Vua Hải Tặc: Vươn ra đại dương! Giấc mơ to lớn của bố!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Oounabara ni Hirake! Dekkai Dekkai Chichi no Yume!", + "romaji": "ONE PIECE: Oounabara ni Hirake! Dekkai Dekkai Chichi no Yume!", + "english": "One Piece Special: Open Upon the Great Sea! A Father's Huge, HUGE Dream!", + "native": "ONE PIECE: 大海原にひらけ! でっかいでっカイ父の夢" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/1237.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/1237.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/1237.jpg", + "color": "#e4ae35" + }, + "startDate": { + "year": 2003, + "month": 4, + "day": 6 + }, + "endDate": { + "year": 2003, + "month": 4, + "day": 6 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 1238, + "idMal": 1238, + "siteUrl": "https://anilist.co/anime/1238", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n1238-HdBlNr5vwNau.jpg", + "episodes": 1, + "synonyms": [ + "One Piece TV Special 3", + "One Piece: L'ultima esibizione", + "守れ!最後の大舞台", + "One Piece: Mamore! Saigo no Daibutai", + "One Piece Special: Protect! The Last Great Stage", + "วันพีซ ภาคพิเศษ 3 ป้องกันการแสดงครั้งสุดท้ายอันยิ่งใหญ่ ", + "Vua Hải Tặc: Bảo vệ! Vở diễn lớn cuối cùng" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE TV Special 3: Mamore! Saigo no Oobutai", + "romaji": "ONE PIECE TV Special 3: Mamore! Saigo no Oobutai", + "english": "One Piece Special: Protect! The Last Great Performance", + "native": "ONE PIECE TVスペシャル3 守れ! 最後の大舞台" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx1238-Rf2wqBrCwgvO.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx1238-Rf2wqBrCwgvO.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx1238-Rf2wqBrCwgvO.jpg", + "color": "#5dc9f1" + }, + "startDate": { + "year": 2003, + "month": 12, + "day": 14 + }, + "endDate": { + "year": 2003, + "month": 12, + "day": 14 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 2020, + "idMal": 2020, + "siteUrl": "https://anilist.co/anime/2020", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n2020-k3lHutyP9i06.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Le avventure del detective Cappello di Paglia" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Nenmatsu Tokubetsu Kikaku! Mugiwara no Luffy Oyabun Torimonochou", + "romaji": "ONE PIECE: Nenmatsu Tokubetsu Kikaku! Mugiwara no Luffy Oyabun Torimonochou", + "english": "One Piece Special: The Detective Memoirs of Chief Straw Hat Luffy", + "native": "ONE PIECE 年末特別企画!麦わらのルフィ親分捕物帖" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2020.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2020.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/2020.jpg", + "color": "#f1bb35" + }, + "startDate": { + "year": 2005, + "month": 12, + "day": 18 + }, + "endDate": { + "year": 2005, + "month": 12, + "day": 18 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 2386, + "idMal": 2386, + "siteUrl": "https://anilist.co/anime/2386", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2386-jtduFM8raO2z.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Dream Soccer King!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Yume no Soccer Ou!", + "romaji": "ONE PIECE: Yume no Soccer Ou!", + "native": "ONE PIECE 夢のサッカー王!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2386-NQQkq1taHJ08.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2386-NQQkq1taHJ08.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/2386-NQQkq1taHJ08.jpg", + "color": "#d6931a" + }, + "startDate": { + "year": 2002, + "month": 3, + "day": 2 + }, + "endDate": { + "year": 2002, + "month": 3, + "day": 2 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 2490, + "idMal": 2490, + "siteUrl": "https://anilist.co/anime/2490", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2490-wlPYONPyTibY.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Take Aim! The Pirate Baseball King" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Mezase! Kaizoku Yakyuu Ou", + "romaji": "ONE PIECE: Mezase! Kaizoku Yakyuu Ou", + "native": "ONE PIECE めざせ! 海賊野球王" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2490-AL4WmnCJ5zvE.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2490-AL4WmnCJ5zvE.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/2490-AL4WmnCJ5zvE.jpg", + "color": "#ffbb43" + }, + "startDate": { + "year": 2004, + "month": 3, + "day": 6 + }, + "endDate": { + "year": 2004, + "month": 3, + "day": 6 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 12001, + "idMal": 12001, + "siteUrl": "https://anilist.co/anime/12001", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n12001-IljdXqN8CTU7.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE 3D: Gekisou! Trap Coaster", + "romaji": "ONE PIECE 3D: Gekisou! Trap Coaster", + "native": "ONE PIECE 3D 激走! トラップコースター" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx12001-XX0NNNfaKZ3e.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx12001-XX0NNNfaKZ3e.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx12001-XX0NNNfaKZ3e.jpg", + "color": "#f1e435" + }, + "startDate": { + "year": 2011, + "month": 12, + "day": 1 + }, + "endDate": { + "year": 2011, + "month": 12, + "day": 1 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 16239, + "idMal": 16239, + "siteUrl": "https://anilist.co/anime/16239", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/16239-pov53U1T1dRm.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Luffy - Hand Island no Bouken", + "romaji": "ONE PIECE: Episode of Luffy - Hand Island no Bouken", + "english": "One Piece: Episode of Luffy - Hand Island Adventure", + "native": "ONE PIECE エピソードオブルフィ 〜ハンドアイランドの冒険〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16239-XzoVjd7JK8xJ.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16239-XzoVjd7JK8xJ.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b16239-XzoVjd7JK8xJ.png", + "color": "#f1c900" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 15 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 15 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 16468, + "idMal": 16468, + "siteUrl": "https://anilist.co/anime/16468", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n16468-yOxhsBHFICtu.jpg", + "episodes": 2, + "synonyms": [ + "One Piece: Glorious Island", + "One Piece Special: Glorious Island", + "ワンピース フィルム ゼット グロリアス アイランド" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: Z - GLORIOUS ISLAND", + "romaji": "ONE PIECE FILM: Z - GLORIOUS ISLAND", + "native": "ONE PIECE FILM Z『GLORIOUS ISLAND』" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16468-pMKFwfY8lYZX.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16468-pMKFwfY8lYZX.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b16468-pMKFwfY8lYZX.png", + "color": "#feae28" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 23 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 30 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 15323, + "idMal": 15323, + "siteUrl": "https://anilist.co/anime/15323", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/15323-zrax5OGlGepE.png", + "episodes": 1, + "synonyms": [ + "One Piece Special" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Nami - Koukaishi no Namida to Nakama no Kizuna", + "romaji": "ONE PIECE: Episode of Nami - Koukaishi no Namida to Nakama no Kizuna", + "native": "ONE PIECE エピソードオブナミ 〜航海士の涙と仲間の絆〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/15323.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/15323.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/15323.jpg", + "color": "#fed628" + }, + "startDate": { + "year": 2012, + "month": 8, + "day": 25 + }, + "endDate": { + "year": 2012, + "month": 8, + "day": 25 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 19123, + "idMal": 19123, + "siteUrl": "https://anilist.co/anime/19123", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/19123-QyRShJCfnIWD.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Special" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Merry - Mou Hitori no Nakama no Monogatari", + "romaji": "ONE PIECE: Episode of Merry - Mou Hitori no Nakama no Monogatari", + "english": "One Piece: Episode of Merry - The Tale of One More Friend", + "native": "ONE PIECE エピソードオブメリー 〜もうひとりの仲間の物語〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx19123-leET1CrSJknT.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx19123-leET1CrSJknT.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx19123-leET1CrSJknT.jpg", + "color": "#e4c95d" + }, + "startDate": { + "year": 2013, + "month": 8, + "day": 24 + }, + "endDate": { + "year": 2013, + "month": 8, + "day": 24 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 20835, + "idMal": 25161, + "siteUrl": "https://anilist.co/anime/20835", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20835-aR8CuXvtzqND.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Special 15th Anniversary" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE \"3D2Y\": Ace no shi wo Koete! Luffy Nakama to no Chikai", + "romaji": "ONE PIECE \"3D2Y\": Ace no shi wo Koete! Luffy Nakama to no Chikai", + "english": "One Piece 3D2Y: Overcome Ace’s Death! Luffy’s Vow to his Friends", + "native": "ONE PIECE “3D2Y” エースの死を越えて! ルフィ仲間との誓い" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20835-QVV6LpJlqe1A.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20835-QVV6LpJlqe1A.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20835-QVV6LpJlqe1A.jpg", + "color": "#e47850" + }, + "startDate": { + "year": 2014, + "month": 8, + "day": 30 + }, + "endDate": { + "year": 2014, + "month": 8, + "day": 30 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 21230, + "idMal": 31289, + "siteUrl": "https://anilist.co/anime/21230", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21230-5yju21iK6aMW.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Episode of Sabo - The Three Brothers' Bond: The Miraculous Reunion and the Inherited Will" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Sabo - 3-Kyoudai no Kizuna Kiseki no Saikai to Uketsugareru Ishi", + "romaji": "ONE PIECE: Episode of Sabo - 3-Kyoudai no Kizuna Kiseki no Saikai to Uketsugareru Ishi", + "english": "One Piece - Episode of Sabo: Bond of Three Brothers - A Miraculous Reunion and an Inherited Will", + "native": "ONE PIECE エピソードオブサボ ~3兄弟の絆 奇跡の再会と受け継がれる意志~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21230-rfoUZud1Jn0L.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21230-rfoUZud1Jn0L.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21230-rfoUZud1Jn0L.png", + "color": "#f1a150" + }, + "startDate": { + "year": 2015, + "month": 8, + "day": 22 + }, + "endDate": { + "year": 2015, + "month": 8, + "day": 22 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21485, + "idMal": 32051, + "siteUrl": "https://anilist.co/anime/21485", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21485-lPFbmxGrFsBX.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Adventure of Nebulandia", + "romaji": "ONE PIECE: Adventure of Nebulandia", + "english": "One Piece: Adventure of Nebulandia", + "native": "ONE PIECE 〜アドベンチャー オブ ネブランディア〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21485-KR1sv4rYSe6V.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21485-KR1sv4rYSe6V.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/21485-KR1sv4rYSe6V.jpg", + "color": "#e4bb43" + }, + "startDate": { + "year": 2015, + "month": 12, + "day": 19 + }, + "endDate": { + "year": 2015, + "month": 12, + "day": 19 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21831, + "idMal": 33338, + "siteUrl": "https://anilist.co/anime/21831", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21831-Xl4r2uBaaKU4.png", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Heart of Gold", + "romaji": "ONE PIECE: Heart of Gold", + "english": "One Piece: Heart of Gold", + "native": "ONE PIECE 〜ハートオブゴールド〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21831-qj5IKYiPOupF.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21831-qj5IKYiPOupF.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21831-qj5IKYiPOupF.jpg", + "color": "#f1a10d" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 16 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 16 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 99302, + "idMal": 36215, + "siteUrl": "https://anilist.co/anime/99302", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/99302-v3NwmsSnjj94.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of East Blue - Luffy to 4-nin no Nakama no Daibouken", + "romaji": "ONE PIECE: Episode of East Blue - Luffy to 4-nin no Nakama no Daibouken", + "english": "One Piece: Episode of East Blue", + "native": "ONE PIECE エピソードオブ東の海〜ルフィと4人の仲間の大冒険!!〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx99302-b40WIhc5dylo.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx99302-b40WIhc5dylo.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx99302-b40WIhc5dylo.jpg", + "color": "#bbe45d" + }, + "startDate": { + "year": 2017, + "month": 8, + "day": 26 + }, + "endDate": { + "year": 2017, + "month": 8, + "day": 26 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 459, + "idMal": 459, + "siteUrl": "https://anilist.co/anime/459", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/459-c4uuz0LPvzkS.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: The Movie", + "One Piece (2000)", + "One Piece: La Película", + "วันพีซ เดอะมูฟวี่ 1 เกาะสมบัติแห่งวูนัน", + "Vua Hải Tặc: Đảo Châu Báu", + "One Piece: Golden Island Adventure", + "One Piece - Per tutto l'oro del mondo" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE (Movie)", + "romaji": "ONE PIECE (Movie)", + "english": "ONE PIECE: The Movie", + "native": "ONE PIECE (Movie)" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx459-Ivw65mUXackh.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx459-Ivw65mUXackh.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx459-Ivw65mUXackh.png", + "color": "#e40d0d" + }, + "startDate": { + "year": 2000, + "month": 3, + "day": 4 + }, + "endDate": { + "year": 2000, + "month": 3, + "day": 4 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 460, + "idMal": 460, + "siteUrl": "https://anilist.co/anime/460", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/460-ruLnAj6wDrTL.png", + "episodes": 1, + "synonyms": [ + "One Piece Movie 02", + "วันพีซ เดอะมูฟวี่ 2 การผจญภัยบนเกาะแห่งฟันเฟือง", + "Vua Hải Tặc: Cuộc phiêu lưu đến đảo máy đồng hồ ", + "One Piece - Avventura all'Isola Spirale" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Nejimaki-jima no Bouken", + "romaji": "ONE PIECE: Nejimaki-jima no Bouken", + "english": "One Piece: Clockwork Island Adventure", + "native": "ONE PIECE ねじまき島の冒険" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx460-p9HObfGUhWn0.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx460-p9HObfGUhWn0.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx460-p9HObfGUhWn0.jpg", + "color": "#e4c943" + }, + "startDate": { + "year": 2001, + "month": 3, + "day": 3 + }, + "endDate": { + "year": 2001, + "month": 3, + "day": 3 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 461, + "idMal": 461, + "siteUrl": "https://anilist.co/anime/461", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/461-0pvcIaXC0p70.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Movie 03", + "วันพีซ เดอะมูฟวี่ 3 เกาะแห่งสรรพสัตว์และราชันย์ช็อปเปอร์ ", + "Vua Hải Tặc: Vương quốc Chopper trên đảo của những sinh vật lạ", + "One Piece: Chopper Kingdom of Strange Animal Island", + "One Piece - Il tesoro del re" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Chinjuu-jima no Chopper Oukoku", + "romaji": "ONE PIECE: Chinjuu-jima no Chopper Oukoku", + "english": "One Piece: Chopper’s Kingdom on the Island of Strange Animals", + "native": "ONE PIECE 珍獣島のチョッパー王国" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx461-DC9fMDl3AaK1.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx461-DC9fMDl3AaK1.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx461-DC9fMDl3AaK1.jpg", + "color": "#e49343" + }, + "startDate": { + "year": 2002, + "month": 3, + "day": 2 + }, + "endDate": { + "year": 2002, + "month": 3, + "day": 2 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 462, + "idMal": 462, + "siteUrl": "https://anilist.co/anime/462", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/462-Z074owEvilUu.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Movie 04", + "One Piece: La Aventura sin Salida", + "The Adventure of Deadend", + "One Piece: Dead End", + "วันพีซ เดอะมูฟวี่ 4 การผจญภัยที่เดดเอนด์", + "Vua Hải Tặc: Cuộc đua tử thần", + "One Piece - Trappola mortale" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE THE MOVIE: Dead End no Bouken", + "romaji": "ONE PIECE THE MOVIE: Dead End no Bouken", + "english": "One Piece The Movie: The Dead End Adventure", + "native": "ONE PIECE THE MOVIE デッドエンドの冒険" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx462-Ig8zfdsFWcql.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx462-Ig8zfdsFWcql.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx462-Ig8zfdsFWcql.png", + "color": "#e49343" + }, + "startDate": { + "year": 2003, + "month": 3, + "day": 1 + }, + "endDate": { + "year": 2003, + "month": 3, + "day": 1 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 463, + "idMal": 463, + "siteUrl": "https://anilist.co/anime/463", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/463-zgU5XjUCR7Kv.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Movie 05", + "One Piece: La Maldición de la Espada Sagrada", + "One Piece: The Cursed Holy Sword", + "One Piece : La malédiction de l'épée sacrée", + "วันพีซ เดอะมูฟวี่ 5 วันดวลดาบ ต้องสาปมรณะ ", + "Vua Hải Tặc: Thánh kiếm bị nguyền rủa", + "One Piece - Der Fluch des heiligen Schwerts", + "One Piece - La spada delle sette stelle" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Norowareta Seiken", + "romaji": "ONE PIECE: Norowareta Seiken", + "english": "One Piece: The Curse of the Sacred Sword", + "native": "ONE PIECE 呪われた聖剣" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx463-QDnETPoHp9oD.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx463-QDnETPoHp9oD.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx463-QDnETPoHp9oD.jpg", + "color": "#e45078" + }, + "startDate": { + "year": 2004, + "month": 3, + "day": 6 + }, + "endDate": { + "year": 2004, + "month": 3, + "day": 6 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 464, + "idMal": 464, + "siteUrl": "https://anilist.co/anime/464", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/464-wmQoE1Ywxghs.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Movie 6", + "One Piece: El barón Omatsuri y la isla de los secretos", + "One Piece - L'isola segreta del barone Omatsuri" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE THE MOVIE: Omatsuri Danshaku to Himitsu no Shima", + "romaji": "ONE PIECE THE MOVIE: Omatsuri Danshaku to Himitsu no Shima", + "english": "One Piece: Baron Omatsuri and the Secret Island", + "native": "ONE PIECE THE MOVIE オマツリ男爵と秘密の島" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx464-g4wcZPjbhY5j.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx464-g4wcZPjbhY5j.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx464-g4wcZPjbhY5j.png", + "color": "#e4a15d" + }, + "startDate": { + "year": 2005, + "month": 3, + "day": 5 + }, + "endDate": { + "year": 2005, + "month": 3, + "day": 5 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 465, + "idMal": 465, + "siteUrl": "https://anilist.co/anime/465", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/465-dYMZ6SHNeOkL.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Karakuri Shiro no Mecha Kyohei", + "One Piece Movie 07", + "One Piece: El gran soldado mecánico del castillo Karakuri", + "The Giant Mechanical Soldier of Karakuri Castle", + "One Piece: Schloss Karakuris Metall-Soldaten", + "One Piece - I misteri dell'isola meccanica" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE THE MOVIE: Karakuri-jou no Mecha Kyohei", + "romaji": "ONE PIECE THE MOVIE: Karakuri-jou no Mecha Kyohei", + "english": "One Piece: Mega Mecha Soldier of Karakuri Castle", + "native": "ONE PIECE THE MOVIE カラクリ城のメカ巨兵" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx465-qSRr0MKYhS0I.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx465-qSRr0MKYhS0I.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx465-qSRr0MKYhS0I.jpg", + "color": "#43aeff" + }, + "startDate": { + "year": 2006, + "month": 3, + "day": 4 + }, + "endDate": { + "year": 2006, + "month": 3, + "day": 4 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 2107, + "idMal": 2107, + "siteUrl": "https://anilist.co/anime/2107", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2107-Je0JIEoxx1VF.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Movie 8: Episode of Alabasta - The Desert Princess and the Pirates", + "One Piece Película 8: La saga del Arabasta - Los piratas y la princesa del desierto", + "One Piece Movie 08", + "One Piece - Un'amicizia oltre i confini del mare" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Alabasta - Sabaku no Oujo to Kaizoku-tachi", + "romaji": "ONE PIECE: Episode of Alabasta - Sabaku no Oujo to Kaizoku-tachi", + "english": "One Piece: The Desert Princess and the Pirates, Adventures in Alabasta", + "native": "ONE PIECE エピソードオブアラバスタ 砂漠の王女と海賊たち" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx2107-H8bRuRRbhCIJ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2107-H8bRuRRbhCIJ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx2107-H8bRuRRbhCIJ.jpg", + "color": "#e4a143" + }, + "startDate": { + "year": 2007, + "month": 3, + "day": 3 + }, + "endDate": { + "year": 2007, + "month": 3, + "day": 3 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 3848, + "idMal": 3848, + "siteUrl": "https://anilist.co/anime/3848", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3848-LXsSQtSFu4e9.jpg", + "episodes": 1, + "synonyms": [ + "Miracle Sakura", + "One Piece: Episodio de Chopper + El milagro del cerezo en invierno", + "One Piece: Episode of Chopper Plus - Bloom in the Winter, Miracle Sakura", + "One Piece Movie 09", + "One Piece - Il miracolo dei ciliegi in fiore" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE THE MOVIE: Episode of Chopper Plus - Fuyu ni Saku, Kiseki no Sakura", + "romaji": "ONE PIECE THE MOVIE: Episode of Chopper Plus - Fuyu ni Saku, Kiseki no Sakura", + "english": "One Piece: Episode Of Chopper +: The Miracle Winter Cherry Blossom", + "native": "ONE PIECE THE MOVIE エピソードオブチョッパー+ 冬に咲く、奇跡の桜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3848-SCnYGTn34Llt.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3848-SCnYGTn34Llt.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3848-SCnYGTn34Llt.jpg", + "color": "#e4e450" + }, + "startDate": { + "year": 2008, + "month": 3, + "day": 1 + }, + "endDate": { + "year": 2008, + "month": 3, + "day": 1 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 4155, + "idMal": 4155, + "siteUrl": "https://anilist.co/anime/4155", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/4155-2PkLjTHddz2s.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 10", + "ワンピース ストロング ワールド", + "One Piece: Strong World", + "One Piece - Avventura sulle isole volanti" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: STRONG WORLD", + "romaji": "ONE PIECE FILM: STRONG WORLD", + "english": "One Piece Film: Strong World", + "native": "ONE PIECE FILM STRONG WORLD" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx4155-P5TDf6t6qFwX.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx4155-P5TDf6t6qFwX.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx4155-P5TDf6t6qFwX.png", + "color": "#e4ae50" + }, + "startDate": { + "year": 2009, + "month": 12, + "day": 12 + }, + "endDate": { + "year": 2009, + "month": 12, + "day": 12 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 9999, + "idMal": 9999, + "siteUrl": "https://anilist.co/anime/9999", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/9999-T5jCX3o3cxeN.jpg", + "episodes": 1, + "synonyms": [ + "One Piece 3D: Straw Hat Chase", + "One Piece 3D - L'inseguimento di Cappello di Paglia" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE 3D: Mugiwara Chase", + "romaji": "ONE PIECE 3D: Mugiwara Chase", + "english": "One Piece 3D: Mugiwara Chase", + "native": "ONE PIECE 3D 麦わらチェイス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/9999.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/9999.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/9999.jpg", + "color": "#ffa100" + }, + "startDate": { + "year": 2011, + "month": 3, + "day": 19 + }, + "endDate": { + "year": 2011, + "month": 3, + "day": 19 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 12859, + "idMal": 12859, + "siteUrl": "https://anilist.co/anime/12859", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/12859-XjlBW6o2YwUb.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 12: Z", + "海贼王剧场版Z", + "One Piece Gold - Il film" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: Z", + "romaji": "ONE PIECE FILM: Z", + "english": "One Piece Film: Z", + "native": "ONE PIECE FILM Z" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx12859-uQFENDPzMWz6.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx12859-uQFENDPzMWz6.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx12859-uQFENDPzMWz6.jpg", + "color": "#f1ae5d" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 15 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 15 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21335, + "idMal": 31490, + "siteUrl": "https://anilist.co/anime/21335", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21335-ps20iVSGUXbD.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 13", + "航海王之黄金城" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: GOLD", + "romaji": "ONE PIECE FILM: GOLD", + "english": "One Piece Film: Gold", + "native": "ONE PIECE FILM GOLD" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21335-XsXdE0AeOkkZ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21335-XsXdE0AeOkkZ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21335-XsXdE0AeOkkZ.jpg", + "color": "#f1bb35" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 23 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 23 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 96427, + "idMal": 94534, + "siteUrl": "https://anilist.co/manga/96427", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/n96427-UsjDRx1o4V8V.jpg", + "synonyms": [ + "ONE PIECE: Loguetown Arc", + "Logue Town" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Loguetown-hen", + "romaji": "ONE PIECE: Loguetown-hen", + "native": "ONE PIECE ローグタウン編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx96427-XiDc44cTlf29.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx96427-XiDc44cTlf29.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx96427-XiDc44cTlf29.png", + "color": "#e4861a" + }, + "startDate": { + "year": 2000, + "month": 7, + "day": 17 + }, + "endDate": {} + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 30013, + "idMal": 13, + "siteUrl": "https://anilist.co/manga/30013", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/30013-hbbRZqC5MjYh.jpg", + "synonyms": [ + "원피스", + "וואן פיס ", + "One Piece - Большой куш", + "ワンピース", + "ONE PIECE~航海王~" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE", + "romaji": "ONE PIECE", + "english": "One Piece", + "native": "ONE PIECE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx30013-tZVlfBCHbrNL.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30013-tZVlfBCHbrNL.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx30013-tZVlfBCHbrNL.jpg", + "color": "#e48650" + }, + "startDate": { + "year": 1997, + "month": 7, + "day": 22 + }, + "endDate": {} + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 101918, + "idMal": 37902, + "siteUrl": "https://anilist.co/anime/101918", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/101918-jUG4Mb1hCxVS.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Sorajima", + "romaji": "ONE PIECE: Episode of Sorajima", + "english": "One Piece: Episode of Skypiea", + "native": "ONE PIECE エピソードオブ空島" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx101918-3uyCYHw1syki.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx101918-3uyCYHw1syki.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx101918-3uyCYHw1syki.png", + "color": "#5dc9f1" + }, + "startDate": { + "year": 2018, + "month": 8, + "day": 25 + }, + "endDate": { + "year": 2018, + "month": 8, + "day": 25 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 8740, + "idMal": 8740, + "siteUrl": "https://anilist.co/anime/8740", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/8740-MlFelJndh6Yr.png", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: STRONG WORLD - EPISODE:0", + "romaji": "ONE PIECE FILM: STRONG WORLD - EPISODE:0", + "native": "ONE PIECE FILM STRONG WORLD EPISODE:0" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b8740-oZajT3brPn7b.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b8740-oZajT3brPn7b.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b8740-oZajT3brPn7b.jpg", + "color": "#e493a1" + }, + "startDate": { + "year": 2010, + "month": 4, + "day": 16 + }, + "endDate": { + "year": 2010, + "month": 4, + "day": 16 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 105143, + "idMal": 38234, + "siteUrl": "https://anilist.co/anime/105143", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/105143-y8oSKa8PSsgK.jpg", + "episodes": 1, + "synonyms": [ + "ワンピース スタンピード", + "One Piece: Estampida", + "航海王:狂热行动", + "One Piece Film 14" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE STAMPEDE", + "romaji": "ONE PIECE STAMPEDE", + "english": "One Piece: Stampede", + "native": "ONE PIECE STAMPEDE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx105143-5uBDmhvMr6At.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx105143-5uBDmhvMr6At.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx105143-5uBDmhvMr6At.png", + "color": "#e4e450" + }, + "startDate": { + "year": 2019, + "month": 8, + "day": 9 + }, + "endDate": { + "year": 2019, + "month": 8, + "day": 9 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 102639, + "idMal": 20871, + "siteUrl": "https://anilist.co/anime/102639", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n102639-XXAO6eBYPBmw.jpg", + "episodes": 1, + "synonyms": [ + "Nissan Serena x One Piece 3D: Mugiwara Chase - Sennyuu!! Sauzando Sanii-gou" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Nissan SERENA x One Piece: Sennyuu Sauzando Sanii-gou", + "romaji": "Nissan SERENA x One Piece: Sennyuu Sauzando Sanii-gou", + "native": "日産SERENA×ワンピース 潜入サウザンド・サニー号" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx102639-AjxqMLkusd2Y.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx102639-AjxqMLkusd2Y.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx102639-AjxqMLkusd2Y.jpg", + "color": "#d6ae78" + }, + "startDate": { + "year": 2011, + "month": 3, + "day": 5 + }, + "endDate": { + "year": 2011, + "month": 3, + "day": 5 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 106492, + "idMal": 23933, + "siteUrl": "https://anilist.co/anime/106492", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 1, + "synonyms": [ + "Orb Panic Adventure!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kyutai Panic Adventure!", + "romaji": "Kyutai Panic Adventure!", + "native": "球体パニックアドベンチャー!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b106492-ciLSI5nTJsV7.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b106492-ciLSI5nTJsV7.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b106492-ciLSI5nTJsV7.png", + "color": "#e4c943" + }, + "startDate": { + "year": 2003 + }, + "endDate": {} + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 106572, + "idMal": 28683, + "siteUrl": "https://anilist.co/anime/106572", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n106572-kY1X9fF5zJZz.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Alabasta - Sabaku no Oujo to Kaizoku-tachi", + "romaji": "ONE PIECE: Episode of Alabasta - Sabaku no Oujo to Kaizoku-tachi", + "native": "ONE PIECE エピソードオブアラバスタ 砂漠の王女と海賊たち" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106572-k1gqIsDcqGaV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106572-k1gqIsDcqGaV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/n106572-k1gqIsDcqGaV.jpg", + "color": "#e4bb43" + }, + "startDate": { + "year": 2011, + "month": 8, + "day": 20 + }, + "endDate": { + "year": 2011, + "month": 8, + "day": 20 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 107110, + "idMal": 22661, + "siteUrl": "https://anilist.co/anime/107110", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 3, + "synonyms": [ + "One Piece: Banpresto's \"Cry Heart\" Short", + "Fuyujima ni Furu Sakura", + "Children's Dream", + "Ace no Saigo " + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Cry heart", + "romaji": "ONE PIECE: Cry heart", + "native": "ワンピース Cry heart" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n107110-pF0sMxDe3aLi.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n107110-pF0sMxDe3aLi.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/n107110-pF0sMxDe3aLi.png", + "color": "#f1861a" + }, + "startDate": { + "year": 2014, + "month": 1, + "day": 19 + }, + "endDate": { + "year": 2015, + "month": 4, + "day": 22 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 107256, + "idMal": 23935, + "siteUrl": "https://anilist.co/anime/107256", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 1, + "synonyms": [ + "Orb Panic Adventure Returns!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kyutai Panic Adventure Returns!!!", + "romaji": "Kyutai Panic Adventure Returns!!!", + "native": "球体パニックアドベンチャーリターンズ!!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b107256-Pvgk6VnCmMZq.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b107256-Pvgk6VnCmMZq.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b107256-Pvgk6VnCmMZq.png", + "color": "#f1c9ae" + }, + "startDate": { + "year": 2004 + }, + "endDate": {} + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 2385, + "idMal": 2385, + "siteUrl": "https://anilist.co/anime/2385", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n2385-kYxcxwV0LTvq.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Nejimaki-jima no Bouken - Jango no Dance Carnival", + "romaji": "ONE PIECE: Nejimaki-jima no Bouken - Jango no Dance Carnival", + "english": "One Piece: Django's Dance Carnival", + "native": "ONE PIECE ねじまき島の冒険 ジャンゴのダンスカーニバル" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n2385-us99rub90MzL.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n2385-us99rub90MzL.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/n2385-us99rub90MzL.jpg", + "color": "#f1e41a" + }, + "startDate": { + "year": 2001, + "month": 3, + "day": 3 + }, + "endDate": { + "year": 2001, + "month": 3, + "day": 3 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 101099, + "idMal": 35770, + "siteUrl": "https://anilist.co/anime/101099", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/101099-KarIxewMysiK.jpg", + "episodes": 11, + "synonyms": [ + "Hungry Days: Yokoku-hen", + "Hungry Days: Majo no Takkyuubin-hen", + "Hungry Days: Heidi-hen", + "Hungry Days: Sazae-san-hen", + "Hungry Days: One Piece", + "Hungry Days: Zoro-hen", + "Hungry Days: Nami-hen", + "Hungry Days: Vivi-hen", + "Hungry Days: Choujou Gibasen-hen" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "HUNGRY DAYS: Aoharu ka yo.", + "romaji": "HUNGRY DAYS: Aoharu ka yo.", + "native": "HUNGRY DAYS アオハルかよ。" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b101099-3AoCbJWTj2cV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b101099-3AoCbJWTj2cV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b101099-3AoCbJWTj2cV.jpg", + "color": "#a1a135" + }, + "startDate": { + "year": 2017, + "month": 6, + "day": 7 + }, + "endDate": { + "year": 2020, + "month": 2, + "day": 7 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21880, + "idMal": 33606, + "siteUrl": "https://anilist.co/anime/21880", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21880-9gGzVvnzqiNA.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: GOLD - episode 0 711ver.", + "romaji": "ONE PIECE FILM: GOLD - episode 0 711ver.", + "native": "ONE PIECE FILM GOLD 〜episode 0〜 711ver." + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21880-uxsZ880LXSdY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21880-uxsZ880LXSdY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21880-uxsZ880LXSdY.jpg", + "color": "#e4a135" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 2 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 2 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 5252, + "idMal": 5252, + "siteUrl": "https://anilist.co/anime/5252", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/5252-tlTORLa2dR2u.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Prototype" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: ROMANCE DAWN STORY", + "romaji": "ONE PIECE: ROMANCE DAWN STORY", + "english": "One Piece: Romance Dawn Story", + "native": "ONE PIECE ロマンス ドーン ストーリー" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/5252.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/5252.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/5252.jpg", + "color": "#43a1e4" + }, + "startDate": { + "year": 2008, + "month": 11, + "day": 24 + }, + "endDate": { + "year": 2008, + "month": 11, + "day": 24 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 30793, + "idMal": 793, + "siteUrl": "https://anilist.co/manga/30793", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/n30793-SDlOKsKEVHxa.jpg", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Wanted!", + "romaji": "Wanted!", + "english": "Wanted! Eiichiro Oda Before One Piece ", + "native": "WANTED!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx30793-Zca4SIWG5j8e.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30793-Zca4SIWG5j8e.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx30793-Zca4SIWG5j8e.png", + "color": "#f1ae5d" + }, + "startDate": { + "year": 1998, + "month": 11, + "day": 4 + }, + "endDate": {} + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 113308, + "idMal": 52139, + "siteUrl": "https://anilist.co/anime/113308", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/113308-jvwBxZ1dJcVx.jpg", + "episodes": 1, + "synonyms": [ + "HUNGRY DAYS × BUMP OF CHICKEN 「記念撮影」MV", + "HUNGRY DAYS × BUMP OF CHICKEN - Kinen Satsuei", + "HUNGRY DAYS × BUMP OF CHICKEN - Commemorative Photo", + "One Piece x BUMP OF CHICKEN", + "Hungry Days: One Piece" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kinen Satsuei", + "romaji": "Kinen Satsuei", + "native": "記念撮影" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx113308-u6wWW3d01DSt.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx113308-u6wWW3d01DSt.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx113308-u6wWW3d01DSt.jpg", + "color": "#f1865d" + }, + "startDate": { + "year": 2019, + "month": 11, + "day": 2 + }, + "endDate": { + "year": 2019, + "month": 11, + "day": 2 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 114761, + "idMal": 40970, + "siteUrl": "https://anilist.co/anime/114761", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/114761-cgU5ZD6nQ911.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "A-RA-SHI : Reborn", + "romaji": "A-RA-SHI : Reborn", + "native": "A-RA-SHI : Reborn" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b114761-C2xOMuA44zKY.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b114761-C2xOMuA44zKY.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b114761-C2xOMuA44zKY.png", + "color": "#0dbbfe" + }, + "startDate": { + "year": 2020, + "month": 1, + "day": 4 + }, + "endDate": { + "year": 2020, + "month": 1, + "day": 4 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 10033, + "idMal": 10033, + "siteUrl": "https://anilist.co/anime/10033", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n10033-u9ROjiLizrd4.jpg", + "episodes": 147, + "synonyms": [ + "Toriko (2011)", + "Toriko (TV)", + "Toriko x One Piece Collabo Special" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Toriko", + "romaji": "Toriko", + "native": "トリコ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx10033-V7xnlgAVtaVR.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx10033-V7xnlgAVtaVR.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx10033-V7xnlgAVtaVR.jpg", + "color": "#50aef1" + }, + "startDate": { + "year": 2011, + "month": 4, + "day": 3 + }, + "endDate": { + "year": 2014, + "month": 3, + "day": 30 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 813, + "idMal": 813, + "siteUrl": "https://anilist.co/anime/813", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/813-03ZLvWJgR6Wd.jpg", + "episodes": 291, + "synonyms": [ + "DBZ", + "Dragonball Z", + "דרגון בול זי", + "What's My Destiny Dragon Ball", + "ดราก้อนบอล Z", + "Bảy Viên Ngọc Rồng Z" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Dragon Ball Z", + "romaji": "Dragon Ball Z", + "english": "Dragon Ball Z", + "native": "ドラゴンボールZ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx813-QBIvCQgHcjcF.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx813-QBIvCQgHcjcF.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx813-QBIvCQgHcjcF.png", + "color": "#f1a150" + }, + "startDate": { + "year": 1989, + "month": 4, + "day": 26 + }, + "endDate": { + "year": 1996, + "month": 1, + "day": 31 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 126649, + "idMal": 38419, + "siteUrl": "https://anilist.co/anime/126649", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/126649-pxeImm81vD6l.jpg", + "episodes": 1, + "synonyms": [ + "Treasure Hunting: Tongari Shima no Daibouke" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tokyo One Piece Tower: Tongari Shima no Dai Hihou", + "romaji": "Tokyo One Piece Tower: Tongari Shima no Dai Hihou", + "english": "The Great Tongari Island Treasure Hunting Adventure", + "native": "東京ワンピースタワー トンガリ島の大秘宝" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b126649-sbuKTAghtsln.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b126649-sbuKTAghtsln.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b126649-sbuKTAghtsln.jpg", + "color": "#e48628" + }, + "startDate": { + "year": 2016, + "month": 11, + "day": 1 + }, + "endDate": { + "year": 2016, + "month": 11, + "day": 1 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 134184, + "siteUrl": "https://anilist.co/manga/134184", + "status": "FINISHED", + "type": "MANGA", + "format": "ONE_SHOT", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/134184-My4zIFu5a4j1.jpg", + "synonyms": [ + "Romance Dawn - Version 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Romance Dawn", + "romaji": "Romance Dawn", + "native": "ロマンスドーン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx134184-25CEkbRJEzXB.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx134184-25CEkbRJEzXB.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx134184-25CEkbRJEzXB.png", + "color": "#fe9350" + }, + "startDate": { + "year": 1996, + "month": 9, + "day": 23 + }, + "endDate": { + "year": 1996, + "month": 9, + "day": 23 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 141902, + "idMal": 50410, + "siteUrl": "https://anilist.co/anime/141902", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/141902-SvnRSXnN7DWC.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 15", + "فيلم ون بيس: ريد", + "วันพีซ ฟิล์ม เรด" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: RED", + "romaji": "ONE PIECE FILM: RED", + "english": "One Piece Film: Red", + "native": "ONE PIECE FILM RED" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx141902-fTyoTk8F8qOl.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx141902-fTyoTk8F8qOl.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx141902-fTyoTk8F8qOl.jpg", + "color": "#f1c950" + }, + "startDate": { + "year": 2022, + "month": 8, + "day": 6 + }, + "endDate": { + "year": 2022, + "month": 8, + "day": 6 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 143293, + "siteUrl": "https://anilist.co/anime/143293", + "status": "FINISHED", + "type": "ANIME", + "format": "ONA", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "One Piece: Annecy Festival 60th Anniversary", + "romaji": "One Piece: Annecy Festival 60th Anniversary", + "english": "One Piece: Annecy Festival 60th Anniversary", + "native": "One Piece: Annecy Festival 60th Anniversary" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx143293-1ngwckHDsK95.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx143293-1ngwckHDsK95.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx143293-1ngwckHDsK95.jpg", + "color": "#e4e443" + }, + "startDate": { + "year": 2021, + "month": 6, + "day": 16 + }, + "endDate": { + "year": 2021, + "month": 6, + "day": 16 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 167404, + "idMal": 56055, + "siteUrl": "https://anilist.co/anime/167404", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/167404-Hhvbg3APdan0.jpg", + "episodes": 1, + "synonyms": [ + "الوحوش: لعنة التنين", + "Monsters: El infierno del dragón", + "Monsters 103: Emociones del dragón volador del samurái extremo" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "MONSTERS: Ippaku Sanjou Hiryuu Jigoku", + "romaji": "MONSTERS: Ippaku Sanjou Hiryuu Jigoku", + "english": "MONSTERS: 103 Mercies Dragon Damnation", + "native": "MONSTERS 一百三情飛龍侍極" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx167404-QMZJVARntkbv.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx167404-QMZJVARntkbv.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx167404-QMZJVARntkbv.jpg", + "color": "#ff931a" + }, + "startDate": { + "year": 2024, + "month": 1, + "day": 21 + }, + "endDate": { + "year": 2024, + "month": 1, + "day": 21 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 171630, + "idMal": 57557, + "siteUrl": "https://anilist.co/anime/171630", + "status": "NOT_YET_RELEASED", + "type": "ANIME", + "synonyms": [ + "ザワンピース" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "THE ONE PIECE", + "romaji": "THE ONE PIECE", + "english": "THE ONE PIECE", + "native": "THE ONE PIECE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx171630-CULIbflZbhK1.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx171630-CULIbflZbhK1.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx171630-CULIbflZbhK1.jpg" + }, + "startDate": {}, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433832, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2024, + "month": 2, + "day": 26 + }, + "completedAt": { + "year": 2024, + "month": 2, + "day": 5 + }, + "media": { + "id": 163132, + "idMal": 54856, + "siteUrl": "https://anilist.co/anime/163132", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163132-bxxTKGlmlnOm.jpg", + "episodes": 13, + "synonyms": [ + "Хоримия: Фрагменты" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Horimiya: piece", + "romaji": "Horimiya: piece", + "english": "Horimiya: The Missing Pieces", + "native": "ホリミヤ -piece-" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163132-C220CO5UrTxY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163132-C220CO5UrTxY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163132-C220CO5UrTxY.jpg", + "color": "#e4a143" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 1 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 72451, + "idMal": 42451, + "siteUrl": "https://anilist.co/manga/72451", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/42451-shG1Ksjxm3pw.jpg", + "synonyms": [ + "โฮริมิยะ สาวมั่นกับนายมืดมน", + "Horimiya - Hori and Miyamura" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Horimiya", + "romaji": "Horimiya", + "english": "Horimiya", + "native": "ホリミヤ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx72451-vVXtRwyttjGG.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx72451-vVXtRwyttjGG.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx72451-vVXtRwyttjGG.png", + "color": "#e45d93" + }, + "startDate": { + "year": 2011, + "month": 10, + "day": 18 + }, + "endDate": { + "year": 2023, + "month": 7, + "day": 18 + } + } + }, + { + "relationType": "PARENT", + "node": { + "id": 124080, + "idMal": 42897, + "siteUrl": "https://anilist.co/anime/124080", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/124080-ARyLAHHgikRq.jpg", + "episodes": 13, + "synonyms": [ + "堀与宫村", + "โฮริมิยะ สาวมั่นกับนายมืดมน", + "Хоримия" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Horimiya", + "romaji": "Horimiya", + "english": "Horimiya", + "native": "ホリミヤ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx124080-h8EPH92nyRfS.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx124080-h8EPH92nyRfS.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx124080-h8EPH92nyRfS.jpg", + "color": "#5dc9f1" + }, + "startDate": { + "year": 2021, + "month": 1, + "day": 10 + }, + "endDate": { + "year": 2021, + "month": 4, + "day": 4 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 137278, + "siteUrl": "https://anilist.co/manga/137278", + "status": "FINISHED", + "type": "MANGA", + "format": "ONE_SHOT", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/137278-cffIzMuFlO3s.jpg", + "synonyms": [ + "Horimiya Special", + "Horimiya epilogue", + "卒アル", + "Sotsuari" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Horimiya Bangai-hen", + "romaji": "Horimiya Bangai-hen", + "native": "ホリミヤ番外編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx137278-bSLDxopwCbsu.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx137278-bSLDxopwCbsu.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx137278-bSLDxopwCbsu.jpg" + }, + "startDate": { + "year": 2021, + "month": 7, + "day": 16 + }, + "endDate": { + "year": 2021, + "month": 7, + "day": 16 + } + } + } + ] + } + } + }, + { + "id": 389433835, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2024, + "month": 2, + "day": 27 + }, + "completedAt": {}, + "media": { + "id": 153518, + "idMal": 52701, + "siteUrl": "https://anilist.co/anime/153518", + "status": "RELEASING", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/153518-7uRvV7SLqmHV.jpg", + "episodes": 24, + "synonyms": [ + "Dungeon Food", + "Dungeon Meal", + "Tragones y Mazmorras", + "Gloutons et Dragons", + "Подземелье вкусностей", + "던전밥", + "สูตรลับตำรับดันเจียน", + "Mỹ vị hầm ngục", + "Підземелля смакоти", + "迷宫饭" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Dungeon Meshi", + "romaji": "Dungeon Meshi", + "english": "Delicious in Dungeon", + "native": "ダンジョン飯" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx153518-7FNR7zCxO2X5.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx153518-7FNR7zCxO2X5.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx153518-7FNR7zCxO2X5.jpg", + "color": "#e48643" + }, + "startDate": { + "year": 2024, + "month": 1, + "day": 4 + }, + "endDate": { + "year": 2024, + "month": 6 + }, + "nextAiringEpisode": { + "airingAt": 1710423000, + "timeUntilAiring": 309672, + "episode": 11 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 86082, + "idMal": 85781, + "siteUrl": "https://anilist.co/manga/86082", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/86082-L0gxhGsRsDDE.jpg", + "synonyms": [ + "Dungeon Food", + "Dungeon Meal", + "Tragones y Mazmorras", + "Gloutons et Dragons", + "Подземное питание", + "던전밥", + "สูตรลับตำรับดันเจียน", + "Mỹ vị hầm ngục", + "迷宮飯" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Dungeon Meshi", + "romaji": "Dungeon Meshi", + "english": "Delicious in Dungeon", + "native": "ダンジョン飯" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx86082-it012qMBU8S8.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx86082-it012qMBU8S8.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx86082-it012qMBU8S8.jpg", + "color": "#e4865d" + }, + "startDate": { + "year": 2014, + "month": 2, + "day": 15 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 15 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 111516, + "idMal": 36577, + "siteUrl": "https://anilist.co/anime/111516", + "status": "FINISHED", + "type": "ANIME", + "format": "ONA", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Dungeon Meshi CM", + "romaji": "Dungeon Meshi CM", + "english": "Delicious in Dungeon CM", + "native": "ダンジョン飯 CM" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx111516-JRzOxTmZfwke.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx111516-JRzOxTmZfwke.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx111516-JRzOxTmZfwke.jpg", + "color": "#d6781a" + }, + "startDate": { + "year": 2019, + "month": 9, + "day": 5 + }, + "endDate": { + "year": 2019, + "month": 9, + "day": 5 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 158627, + "siteUrl": "https://anilist.co/anime/158627", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "ONA", + "episodes": 1, + "synonyms": [ + "ダンジョン飯 CM" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Dungeon Meshi: Senshi no Kantan Cooking!", + "romaji": "Dungeon Meshi: Senshi no Kantan Cooking!", + "native": "ダンジョン飯~センシのかんたんクッキング!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b158627-cBmslZa62lju.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b158627-cBmslZa62lju.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b158627-cBmslZa62lju.jpg", + "color": "#e4c993" + }, + "startDate": { + "year": 2017, + "month": 7, + "day": 31 + }, + "endDate": { + "year": 2017, + "month": 7, + "day": 31 + } + } + } + ] + } + } + }, + { + "id": 389433836, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 162983, + "idMal": 54790, + "siteUrl": "https://anilist.co/anime/162983", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/162983-EyEfvGopoWLx.jpg", + "episodes": 13, + "synonyms": [ + "Фарс убитой нежити", + "不死少女的谋杀闹剧" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Undead Girl Murder Farce", + "romaji": "Undead Girl Murder Farce", + "english": "Undead Murder Farce", + "native": "アンデッドガール・マーダーファルス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx162983-T3nZyk6sUlEj.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx162983-T3nZyk6sUlEj.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx162983-T3nZyk6sUlEj.jpg", + "color": "#c98650" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 6 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 28 + }, + "relations": { + "edges": [ + { + "relationType": "ALTERNATIVE", + "node": { + "id": 117306, + "idMal": 98437, + "siteUrl": "https://anilist.co/manga/117306", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/117306-XeN4Q5WoHdG9.jpg", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Undead Girl Murder Farce ", + "romaji": "Undead Girl Murder Farce ", + "english": "Undead Girl Murder Farce", + "native": "アンデッドガール・マーダーファルス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx117306-ijQDGpJ8rd2J.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx117306-ijQDGpJ8rd2J.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx117306-ijQDGpJ8rd2J.jpg", + "color": "#e4c91a" + }, + "startDate": { + "year": 2016, + "month": 5, + "day": 26 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433840, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 161645, + "idMal": 54492, + "siteUrl": "https://anilist.co/anime/161645", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/161645-oqzTZYIvviWI.jpg", + "episodes": 24, + "synonyms": [ + "Drugstore Soliloquy", + "Les Carnets de l'Apothicaire", + "Zapiski zielarki", + "Diários de uma Apotecária", + "Il monologo della Speziale", + "Los diarios de la boticaria", + "สืบคดีปริศนา หมอยาตำรับโคมแดง", + "Записки аптекаря", + "Die Tagebücher der Apothekerin", + "يوميات الصيدلانيّة" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kusuriya no Hitorigoto", + "romaji": "Kusuriya no Hitorigoto", + "english": "The Apothecary Diaries", + "native": "薬屋のひとりごと" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx161645-7I8Cip7XRDhV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx161645-7I8Cip7XRDhV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx161645-7I8Cip7XRDhV.jpg", + "color": "#f1865d" + }, + "startDate": { + "year": 2023, + "month": 10, + "day": 22 + }, + "endDate": {}, + "nextAiringEpisode": { + "airingAt": 1710605100, + "timeUntilAiring": 491772, + "episode": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 99026, + "idMal": 86769, + "siteUrl": "https://anilist.co/manga/99026", + "status": "RELEASING", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/99026-CWFP526DyTGz.jpg", + "synonyms": [ + "สืบคดีปริศนา หมอยาตำรับโคมแดง", + "Dược sư tự sự", + "Les Carnets de l'Apothicaire", + "藥師少女的獨語", + "Los diarios de la boticaria" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kusuriya no Hitorigoto", + "romaji": "Kusuriya no Hitorigoto", + "english": "The Apothecary Diaries", + "native": "薬屋のひとりごと" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx99026-5Eg650WAd9Rj.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx99026-5Eg650WAd9Rj.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx99026-5Eg650WAd9Rj.jpg", + "color": "#e4d6a1" + }, + "startDate": { + "year": 2014, + "month": 8, + "day": 29 + }, + "endDate": {} + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 99022, + "idMal": 107562, + "siteUrl": "https://anilist.co/manga/99022", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/99022-J1LhaA8HbVzV.jpg", + "synonyms": [ + "Zapiski zielarki", + "Les Carnets de l'apothicaire", + "I diari della speziale", + "Dược sư tự sự", + "ตำรับปริศนา หมอยาแห่งวังหลัง", + "Монолог Травниці", + "Diários de Uma Apotecária", + "藥師少女的獨語", + "药屋少女的呢喃", + "Los diarios de la boticaria" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kusuriya no Hitorigoto", + "romaji": "Kusuriya no Hitorigoto", + "english": "The Apothecary Diaries", + "native": "薬屋のひとりごと" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx99022-Hh2WdyNgR8HM.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx99022-Hh2WdyNgR8HM.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx99022-Hh2WdyNgR8HM.jpg" + }, + "startDate": { + "year": 2017, + "month": 5, + "day": 25 + }, + "endDate": {} + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 113322, + "idMal": 110929, + "siteUrl": "https://anilist.co/manga/113322", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "약사의 혼잣말", + "เสียงรำพึงจากหมอยา -บันทึกไขปริศนาแห่งวังหลังของเหมาเหมา-", + "Les Carnets de l'Apothicaire - Enquêtes à la cour" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kusuriya no Hitorigoto: Maomao no Koukyuu Nazotoki Techou", + "romaji": "Kusuriya no Hitorigoto: Maomao no Koukyuu Nazotoki Techou", + "native": "薬屋のひとりごと 猫猫の後宮謎解き手帳" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx113322-gwV3eakaVqZQ.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx113322-gwV3eakaVqZQ.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx113322-gwV3eakaVqZQ.png", + "color": "#e47850" + }, + "startDate": { + "year": 2017, + "month": 8, + "day": 19 + }, + "endDate": {} + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 170508, + "idMal": 56975, + "siteUrl": "https://anilist.co/anime/170508", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "ONA", + "synonyms": [ + "Kusuriya no Hitorigoto Mini Anime", + "The Apothecary Diaries Mini Anime", + "薬屋のひとりごと ミニアニメ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Maomao no Hitorigoto", + "romaji": "Maomao no Hitorigoto", + "native": "猫猫のひとりごと" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx170508-72GLTka7NHeF.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx170508-72GLTka7NHeF.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx170508-72GLTka7NHeF.jpg", + "color": "#4393e4" + }, + "startDate": { + "year": 2023, + "month": 10, + "day": 23 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433842, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 154587, + "idMal": 52991, + "siteUrl": "https://anilist.co/anime/154587", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/154587-ivXNJ23SM1xB.jpg", + "episodes": 28, + "synonyms": [ + "Frieren at the Funeral", + "장송의 프리렌", + "Frieren - Oltre la Fine del Viaggio", + "คำอธิษฐานในวันที่จากลา Frieren", + "Frieren e a Jornada para o Além", + "Frieren – Nach dem Ende der Reise", + "葬送的芙莉蓮", + "Frieren: Más allá del final del viaje", + "Frieren en el funeral", + "Sōsō no Furīren", + "Frieren. U kresu drogi", + "Frieren - Pháp sư tiễn táng", + "Фрирен, провожающая в последний путь", + "فريرن: ما وراء نهاية الرحلة" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Sousou no Frieren", + "romaji": "Sousou no Frieren", + "english": "Frieren: Beyond Journey’s End", + "native": "葬送のフリーレン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154587-n1fmjRv4JQUd.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154587-n1fmjRv4JQUd.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154587-n1fmjRv4JQUd.jpg", + "color": "#d6f1c9" + }, + "startDate": { + "year": 2023, + "month": 9, + "day": 29 + }, + "endDate": { + "year": 2024, + "month": 3 + }, + "nextAiringEpisode": { + "airingAt": 1710511200, + "timeUntilAiring": 397872, + "episode": 27 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 118586, + "idMal": 126287, + "siteUrl": "https://anilist.co/manga/118586", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/118586-1JLJiwaIlnBp.jpg", + "synonyms": [ + "Frieren at the Funeral", + "장송의 프리렌", + "Frieren: Oltre la Fine del Viaggio", + "คำอธิษฐานในวันที่จากลา Frieren", + "Frieren e a Jornada para o Além", + "Frieren – Nach dem Ende der Reise", + "葬送的芙莉蓮", + "Frieren After \"The End\"", + "Frieren: Remnants of the Departed", + "Frieren. U kresu drogi", + "Frieren", + "FRIEREN: Más allá del fin del viaje" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Sousou no Frieren", + "romaji": "Sousou no Frieren", + "english": "Frieren: Beyond Journey’s End", + "native": "葬送のフリーレン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx118586-F0Lp86XQV7du.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx118586-F0Lp86XQV7du.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx118586-F0Lp86XQV7du.jpg", + "color": "#e4ae5d" + }, + "startDate": { + "year": 2020, + "month": 4, + "day": 28 + }, + "endDate": {} + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 169811, + "idMal": 56805, + "siteUrl": "https://anilist.co/anime/169811", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/169811-jgMVZlIdH19a.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Yuusha", + "romaji": "Yuusha", + "english": "The Brave", + "native": "勇者" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx169811-tsuH0SJVJy40.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx169811-tsuH0SJVJy40.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx169811-tsuH0SJVJy40.jpg" + }, + "startDate": { + "year": 2023, + "month": 9, + "day": 29 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 29 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 170068, + "idMal": 56885, + "siteUrl": "https://anilist.co/anime/170068", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "ONA", + "synonyms": [ + "Sousou no Frieren Mini Anime", + "Frieren: Beyond Journey’s End Mini Anime", + "葬送のフリーレン ミニアニメ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Sousou no Frieren: ●● no Mahou", + "romaji": "Sousou no Frieren: ●● no Mahou", + "native": "葬送のフリーレン ~●●の魔法~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx170068-ijY3tCP8KoWP.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx170068-ijY3tCP8KoWP.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx170068-ijY3tCP8KoWP.jpg", + "color": "#bbd678" + }, + "startDate": { + "year": 2023, + "month": 10, + "day": 11 + }, + "endDate": {} + } + }, + { + "relationType": "OTHER", + "node": { + "id": 175691, + "idMal": 58313, + "siteUrl": "https://anilist.co/anime/175691", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Haru", + "romaji": "Haru", + "english": "Sunny", + "native": "晴る" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx175691-zZEjlFuuvmVI.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx175691-zZEjlFuuvmVI.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx175691-zZEjlFuuvmVI.jpg", + "color": "#6baed6" + }, + "startDate": { + "year": 2024, + "month": 3, + "day": 5 + }, + "endDate": { + "year": 2024, + "month": 3, + "day": 5 + } + } + } + ] + } + } + }, + { + "id": 389433849, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2024, + "month": 2, + "day": 5 + }, + "completedAt": { + "year": 2024, + "month": 2, + "day": 5 + }, + "media": { + "id": 12859, + "idMal": 12859, + "siteUrl": "https://anilist.co/anime/12859", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/12859-XjlBW6o2YwUb.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 12: Z", + "海贼王剧场版Z", + "One Piece Gold - Il film" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: Z", + "romaji": "ONE PIECE FILM: Z", + "english": "One Piece Film: Z", + "native": "ONE PIECE FILM Z" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx12859-uQFENDPzMWz6.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx12859-uQFENDPzMWz6.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx12859-uQFENDPzMWz6.jpg", + "color": "#f1ae5d" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 15 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 15 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 16239, + "idMal": 16239, + "siteUrl": "https://anilist.co/anime/16239", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/16239-pov53U1T1dRm.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Luffy - Hand Island no Bouken", + "romaji": "ONE PIECE: Episode of Luffy - Hand Island no Bouken", + "english": "One Piece: Episode of Luffy - Hand Island Adventure", + "native": "ONE PIECE エピソードオブルフィ 〜ハンドアイランドの冒険〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16239-XzoVjd7JK8xJ.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16239-XzoVjd7JK8xJ.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b16239-XzoVjd7JK8xJ.png", + "color": "#f1c900" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 15 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 15 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 16468, + "idMal": 16468, + "siteUrl": "https://anilist.co/anime/16468", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n16468-yOxhsBHFICtu.jpg", + "episodes": 2, + "synonyms": [ + "One Piece: Glorious Island", + "One Piece Special: Glorious Island", + "ワンピース フィルム ゼット グロリアス アイランド" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: Z - GLORIOUS ISLAND", + "romaji": "ONE PIECE FILM: Z - GLORIOUS ISLAND", + "native": "ONE PIECE FILM Z『GLORIOUS ISLAND』" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16468-pMKFwfY8lYZX.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16468-pMKFwfY8lYZX.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b16468-pMKFwfY8lYZX.png", + "color": "#feae28" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 23 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 30 + } + } + }, + { + "relationType": "PARENT", + "node": { + "id": 21, + "idMal": 21, + "siteUrl": "https://anilist.co/anime/21", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21-wf37VakJmZqs.jpg", + "synonyms": [ + "ワンピース", + "海贼王", + "וואן פיס", + "ون بيس", + "วันพีซ", + "Vua Hải Tặc", + "All'arrembaggio!", + "Tutti all'arrembaggio!", + "Ντρέηκ, το Κυνήγι του Θησαυρού" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE", + "romaji": "ONE PIECE", + "english": "ONE PIECE", + "native": "ONE PIECE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21-tXMN3Y20PIL9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21-tXMN3Y20PIL9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21-tXMN3Y20PIL9.jpg", + "color": "#e4a15d" + }, + "startDate": { + "year": 1999, + "month": 10, + "day": 20 + }, + "endDate": {} + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 9999, + "idMal": 9999, + "siteUrl": "https://anilist.co/anime/9999", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/9999-T5jCX3o3cxeN.jpg", + "episodes": 1, + "synonyms": [ + "One Piece 3D: Straw Hat Chase", + "One Piece 3D - L'inseguimento di Cappello di Paglia" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE 3D: Mugiwara Chase", + "romaji": "ONE PIECE 3D: Mugiwara Chase", + "english": "One Piece 3D: Mugiwara Chase", + "native": "ONE PIECE 3D 麦わらチェイス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/9999.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/9999.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/9999.jpg", + "color": "#ffa100" + }, + "startDate": { + "year": 2011, + "month": 3, + "day": 19 + }, + "endDate": { + "year": 2011, + "month": 3, + "day": 19 + } + } + } + ] + } + } + } + ] + }, + { + "status": "COMPLETED" + } + ] + } +} diff --git a/seanime-2.9.10/test/data/BoilerplateAnimeCollectionWithRelations.json b/seanime-2.9.10/test/data/BoilerplateAnimeCollectionWithRelations.json new file mode 100644 index 0000000..e5b172d --- /dev/null +++ b/seanime-2.9.10/test/data/BoilerplateAnimeCollectionWithRelations.json @@ -0,0 +1,11918 @@ +{ + "MediaListCollection": { + "lists": [ + { + "status": "CURRENT" + }, + { + "status": "PLANNING", + "entries": [ + { + "id": 389433701, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 131586, + "idMal": 48569, + "siteUrl": "https://anilist.co/anime/131586", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/131586-TQED17cUhGnT.jpg", + "episodes": 12, + "synonyms": [ + "86-エイティシックス- 2クール", + "86 -เอทตี้ซิกซ์- พาร์ท 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "86: Eighty Six Part 2", + "romaji": "86: Eighty Six Part 2", + "english": "86 EIGHTY-SIX Part 2", + "native": "86-エイティシックス- 第2クール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx131586-k0X2kVpUOkqX.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx131586-k0X2kVpUOkqX.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx131586-k0X2kVpUOkqX.jpg", + "color": "#e4501a" + }, + "startDate": { + "year": 2021, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 2022, + "month": 3, + "day": 19 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 116589, + "idMal": 41457, + "siteUrl": "https://anilist.co/anime/116589", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/116589-SqwYWzaZCdD5.jpg", + "episodes": 11, + "synonyms": [ + "86--EIGHTY-SIX", + "86 -เอทตี้ซิกซ์-", + "86 ВОСЕМЬДЕСЯТ ШЕСТЬ", + "86 -不存在的战区-" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "86: Eighty Six", + "romaji": "86: Eighty Six", + "english": "86 EIGHTY-SIX", + "native": "86-エイティシックス-" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx116589-WSpNedJdAH3L.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx116589-WSpNedJdAH3L.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx116589-WSpNedJdAH3L.jpg", + "color": "#78aee4" + }, + "startDate": { + "year": 2021, + "month": 4, + "day": 11 + }, + "endDate": { + "year": 2021, + "month": 6, + "day": 20 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 98610, + "idMal": 104039, + "siteUrl": "https://anilist.co/manga/98610", + "status": "RELEASING", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/98610-2DrWY14sn1s2.jpg", + "synonyms": [ + "86 -เอทตี้ซิกซ์- " + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "86: Eighty Six", + "romaji": "86: Eighty Six", + "english": "86―EIGHTY-SIX", + "native": "86―エイティシックス―" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx98610-TIf7R1gkU0vc.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx98610-TIf7R1gkU0vc.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx98610-TIf7R1gkU0vc.jpg", + "color": "#e46b28" + }, + "startDate": { + "year": 2017, + "month": 2, + "day": 10 + }, + "endDate": {} + } + }, + { + "relationType": "OTHER", + "node": { + "id": 140168, + "idMal": 50098, + "siteUrl": "https://anilist.co/anime/140168", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kyoukaisen", + "romaji": "Kyoukaisen", + "native": "境界線" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b140168-2ld4s5ezCJFW.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b140168-2ld4s5ezCJFW.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b140168-2ld4s5ezCJFW.png", + "color": "#43d6ff" + }, + "startDate": { + "year": 2021, + "month": 10, + "day": 10 + }, + "endDate": { + "year": 2021, + "month": 10, + "day": 10 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 128949, + "idMal": 137940, + "siteUrl": "https://anilist.co/manga/128949", + "status": "CANCELLED", + "type": "MANGA", + "format": "MANGA", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "86: Eighty Six - Run Through The Battlefront", + "romaji": "86: Eighty Six - Run Through The Battlefront", + "native": "86―エイティシックス― -ラン・スルー・ザ・バトルフロント-" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx128949-DDR0iO5cbehB.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx128949-DDR0iO5cbehB.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx128949-DDR0iO5cbehB.jpg" + }, + "startDate": { + "year": 2021, + "month": 1, + "day": 24 + }, + "endDate": { + "year": 2021, + "month": 9, + "day": 4 + } + } + } + ] + } + } + }, + { + "id": 389433702, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 143653, + "idMal": 50796, + "siteUrl": "https://anilist.co/anime/143653", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/143653-cvWrYzJPgDkV.jpg", + "episodes": 13, + "synonyms": [ + "Insomniaques", + "ถ้านอนไม่หลับไปนับดาวกันไหม", + "放学后失眠的你", + "Bezsenność po szkole", + "Insomnia Sepulang Sekolah" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi wa Houkago Insomnia", + "romaji": "Kimi wa Houkago Insomnia", + "english": "Insomniacs after school", + "native": "君は放課後インソムニア" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx143653-h6NEdWxKIRza.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx143653-h6NEdWxKIRza.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx143653-h6NEdWxKIRza.png", + "color": "#1a4386" + }, + "startDate": { + "year": 2023, + "month": 4, + "day": 11 + }, + "endDate": { + "year": 2023, + "month": 7, + "day": 4 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 110473, + "idMal": 121213, + "siteUrl": "https://anilist.co/manga/110473", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/110473-Fr0nJcGMmsrR.jpg", + "synonyms": [ + "Insomniaques", + "ถ้านอนไม่หลับไปนับดาวกันไหม", + "Bezsenność po szkole", + "Insones – Caçando Estrelas Depois da Aula", + "Insomnes después de la escuela", + "君ソム", + "Kimisomu", + "너는 방과후 인섬니아" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi wa Houkago Insomnia", + "romaji": "Kimi wa Houkago Insomnia", + "english": "Insomniacs After School", + "native": "君は放課後インソムニア" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx110473-igM02JDDzQXM.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx110473-igM02JDDzQXM.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx110473-igM02JDDzQXM.jpg", + "color": "#e45d50" + }, + "startDate": { + "year": 2019, + "month": 5, + "day": 20 + }, + "endDate": { + "year": 2023, + "month": 8, + "day": 21 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 160205, + "idMal": 52822, + "siteUrl": "https://anilist.co/anime/160205", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 1, + "synonyms": [ + "Insomniacs After School Special Animation PV" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi wa Houkago Insomnia Special Animation PV", + "romaji": "Kimi wa Houkago Insomnia Special Animation PV", + "native": "君は放課後インソムニア スペシャルアニメーションPV" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b160205-qziA59CxF06T.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b160205-qziA59CxF06T.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b160205-qziA59CxF06T.jpg", + "color": "#4378f1" + }, + "startDate": { + "year": 2021, + "month": 10, + "day": 28 + }, + "endDate": { + "year": 2021, + "month": 10, + "day": 28 + } + } + } + ] + } + } + }, + { + "id": 389433703, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 6045, + "idMal": 6045, + "siteUrl": "https://anilist.co/anime/6045", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/6045-PHiyDpX2gc5D.jpg", + "episodes": 25, + "synonyms": [ + "Reaching You", + "Arrivare a te", + "Llegando a ti" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke", + "romaji": "Kimi ni Todoke", + "english": "Kimi ni Todoke: From Me to You", + "native": "君に届け" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx6045-txJOukR5Qve4.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6045-txJOukR5Qve4.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx6045-txJOukR5Qve4.jpg", + "color": "#e45d6b" + }, + "startDate": { + "year": 2009, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2010, + "month": 3, + "day": 31 + }, + "relations": { + "edges": [ + { + "relationType": "SEQUEL", + "node": { + "id": 9656, + "idMal": 9656, + "siteUrl": "https://anilist.co/anime/9656", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/9656-CE0RW5otDH1X.jpg", + "episodes": 13, + "synonyms": [ + "Reaching You 2nd Season", + "Llegando a ti: Temporada 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke 2ND SEASON", + "romaji": "Kimi ni Todoke 2ND SEASON", + "english": "Kimi ni Todoke: From Me to You Season 2", + "native": "君に届け 2ND SEASON" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx9656-vckh2wNj3FwY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx9656-vckh2wNj3FwY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx9656-vckh2wNj3FwY.jpg", + "color": "#f18635" + }, + "startDate": { + "year": 2011, + "month": 1, + "day": 12 + }, + "endDate": { + "year": 2011, + "month": 3, + "day": 30 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 33378, + "idMal": 3378, + "siteUrl": "https://anilist.co/manga/33378", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/33378-9RtoYIQMFGq0.jpg", + "synonyms": [ + "Reaching You", + "Llegando a Ti", + "Que Chegue a Você", + "Sawako", + "Nguyện ước yêu thương" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke", + "romaji": "Kimi ni Todoke", + "english": "Kimi ni Todoke: From Me to You", + "native": "君に届け" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx33378-G9sHqsJryoP2.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx33378-G9sHqsJryoP2.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx33378-G9sHqsJryoP2.jpg", + "color": "#e4a15d" + }, + "startDate": { + "year": 2006, + "month": 5, + "day": 25 + }, + "endDate": { + "year": 2017, + "month": 11, + "day": 13 + } + } + } + ] + } + } + }, + { + "id": 389433704, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 6746, + "idMal": 6746, + "siteUrl": "https://anilist.co/anime/6746", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/6746-84oNA7P9pboV.jpg", + "episodes": 24, + "synonyms": [ + "DRRR!!", + "דורארארה!!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx6746-Q4EmstN2fy0R.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6746-Q4EmstN2fy0R.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx6746-Q4EmstN2fy0R.png", + "color": "#f1e4bb" + }, + "startDate": { + "year": 2010, + "month": 1, + "day": 8 + }, + "endDate": { + "year": 2010, + "month": 6, + "day": 25 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 46816, + "idMal": 16816, + "siteUrl": "https://anilist.co/manga/46816", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/46816-1KVtcolq8c1H.jpg", + "synonyms": [ + "DRRR!!", + "Дюрарара!!", + "DRRR!! โลกบิดเบี้ยวที่อิเคะบุคุโระ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx46816-D2DhHjjCu2ow.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx46816-D2DhHjjCu2ow.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx46816-D2DhHjjCu2ow.png", + "color": "#e4ae50" + }, + "startDate": { + "year": 2004, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2014, + "month": 1, + "day": 10 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 8408, + "idMal": 8408, + "siteUrl": "https://anilist.co/anime/8408", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n8408-QGzgRIr6s4z2.jpg", + "episodes": 2, + "synonyms": [ + "Durarara!! Episode 12.5", + "Durarara!! Episode 25", + "Dhurarara!!", + "Dyurarara!!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!! Specials", + "romaji": "Durarara!! Specials", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx8408-ty3umDE46vVK.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx8408-ty3umDE46vVK.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx8408-ty3umDE46vVK.png", + "color": "#f1d61a" + }, + "startDate": { + "year": 2010, + "month": 8, + "day": 25 + }, + "endDate": { + "year": 2011, + "month": 2, + "day": 23 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 20652, + "idMal": 23199, + "siteUrl": "https://anilist.co/anime/20652", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20652-sCk2BUWiRMLc.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Shou", + "דורארארה!!2x התפתחות" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Shou", + "romaji": "Durarara!!x2 Shou", + "english": "Durarara!! X2", + "native": "デュラララ!!×2 承" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20652-8ft6GZKEoeWn.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20652-8ft6GZKEoeWn.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20652-8ft6GZKEoeWn.png" + }, + "startDate": { + "year": 2015, + "month": 1, + "day": 10 + }, + "endDate": { + "year": 2015, + "month": 3, + "day": 28 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 2251, + "idMal": 2251, + "siteUrl": "https://anilist.co/anime/2251", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2251-FVSbYyJhQPj2.jpg", + "episodes": 13, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Baccano!", + "romaji": "Baccano!", + "english": "Baccano!", + "native": "バッカーノ!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx2251-Wa30L0Abk50O.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2251-Wa30L0Abk50O.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx2251-Wa30L0Abk50O.jpg", + "color": "#e4bb5d" + }, + "startDate": { + "year": 2007, + "month": 7, + "day": 27 + }, + "endDate": { + "year": 2007, + "month": 11, + "day": 2 + } + } + } + ] + } + } + }, + { + "id": 389433705, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 135778, + "idMal": 49154, + "siteUrl": "https://anilist.co/anime/135778", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135778-5QVzKcX4fskO.jpg", + "episodes": 12, + "synonyms": [ + "ハイカード", + "Старшая карта" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "HIGH CARD", + "romaji": "HIGH CARD", + "english": "HIGH CARD", + "native": "HIGH CARD" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135778-Qldd93789wTL.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135778-Qldd93789wTL.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135778-Qldd93789wTL.jpg", + "color": "#e4bb5d" + }, + "startDate": { + "year": 2023, + "month": 1, + "day": 9 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 27 + }, + "relations": { + "edges": [ + { + "relationType": "ADAPTATION", + "node": { + "id": 161651, + "idMal": 152668, + "siteUrl": "https://anilist.co/manga/161651", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "HIGH CARD: ♢9 No Mercy", + "romaji": "HIGH CARD: ♢9 No Mercy", + "native": "HIGH CARD: ♢9 No Mercy" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx161651-bZuejg6Mm1A5.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx161651-bZuejg6Mm1A5.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx161651-bZuejg6Mm1A5.jpg", + "color": "#e4bb35" + }, + "startDate": { + "year": 2022, + "month": 8, + "day": 31 + }, + "endDate": {} + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 163151, + "idMal": 54869, + "siteUrl": "https://anilist.co/anime/163151", + "status": "RELEASING", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163151-0RAW1fABXTDH.jpg", + "episodes": 12, + "synonyms": [ + "Старшая карта 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "HIGH CARD season 2", + "romaji": "HIGH CARD season 2", + "english": "HIGH CARD Season 2", + "native": "HIGH CARD season 2" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163151-yxXcufmMoCmv.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163151-yxXcufmMoCmv.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163151-yxXcufmMoCmv.jpg", + "color": "#e47850" + }, + "startDate": { + "year": 2024, + "month": 1, + "day": 8 + }, + "endDate": { + "year": 2024, + "month": 3, + "day": 25 + } + } + } + ] + } + } + }, + { + "id": 389433706, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21335, + "idMal": 31490, + "siteUrl": "https://anilist.co/anime/21335", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21335-ps20iVSGUXbD.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 13", + "航海王之黄金城" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: GOLD", + "romaji": "ONE PIECE FILM: GOLD", + "english": "One Piece Film: Gold", + "native": "ONE PIECE FILM GOLD" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21335-XsXdE0AeOkkZ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21335-XsXdE0AeOkkZ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21335-XsXdE0AeOkkZ.jpg", + "color": "#f1bb35" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 23 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 23 + }, + "relations": { + "edges": [ + { + "relationType": "PARENT", + "node": { + "id": 21, + "idMal": 21, + "siteUrl": "https://anilist.co/anime/21", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21-wf37VakJmZqs.jpg", + "synonyms": [ + "ワンピース", + "海贼王", + "וואן פיס", + "ون بيس", + "วันพีซ", + "Vua Hải Tặc", + "All'arrembaggio!", + "Tutti all'arrembaggio!", + "Ντρέηκ, το Κυνήγι του Θησαυρού" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE", + "romaji": "ONE PIECE", + "english": "ONE PIECE", + "native": "ONE PIECE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21-tXMN3Y20PIL9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21-tXMN3Y20PIL9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21-tXMN3Y20PIL9.jpg", + "color": "#e4a15d" + }, + "startDate": { + "year": 1999, + "month": 10, + "day": 20 + }, + "endDate": {} + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 21880, + "idMal": 33606, + "siteUrl": "https://anilist.co/anime/21880", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21880-9gGzVvnzqiNA.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: GOLD - episode 0 711ver.", + "romaji": "ONE PIECE FILM: GOLD - episode 0 711ver.", + "native": "ONE PIECE FILM GOLD 〜episode 0〜 711ver." + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21880-uxsZ880LXSdY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21880-uxsZ880LXSdY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21880-uxsZ880LXSdY.jpg", + "color": "#e4a135" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 2 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 2 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 105143, + "idMal": 38234, + "siteUrl": "https://anilist.co/anime/105143", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/105143-y8oSKa8PSsgK.jpg", + "episodes": 1, + "synonyms": [ + "ワンピース スタンピード", + "One Piece: Estampida", + "航海王:狂热行动", + "One Piece Film 14" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE STAMPEDE", + "romaji": "ONE PIECE STAMPEDE", + "english": "One Piece: Stampede", + "native": "ONE PIECE STAMPEDE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx105143-5uBDmhvMr6At.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx105143-5uBDmhvMr6At.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx105143-5uBDmhvMr6At.png", + "color": "#e4e450" + }, + "startDate": { + "year": 2019, + "month": 8, + "day": 9 + }, + "endDate": { + "year": 2019, + "month": 8, + "day": 9 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 21831, + "idMal": 33338, + "siteUrl": "https://anilist.co/anime/21831", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21831-Xl4r2uBaaKU4.png", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Heart of Gold", + "romaji": "ONE PIECE: Heart of Gold", + "english": "One Piece: Heart of Gold", + "native": "ONE PIECE 〜ハートオブゴールド〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21831-qj5IKYiPOupF.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21831-qj5IKYiPOupF.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21831-qj5IKYiPOupF.jpg", + "color": "#f1a10d" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 16 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 16 + } + } + } + ] + } + } + }, + { + "id": 389433707, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 2759, + "idMal": 2759, + "siteUrl": "https://anilist.co/anime/2759", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2759-EzK5WpFQz5ZT.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 1.11", + "Neon Genesis Evangelion: [Nie] Jesteś Sam", + "Reconstrucción de Evangelion", + "Евангелион 1.11: Ты (не) один", + "Реконструкция Евангелиона - Евангелион: 1.0 Ты [Не] Одинок", + "福音戰士新劇場版:序", + "Evangelion 1.0: Você [Não] Está Só", + "Evangelion 1.0: Você [Não] Está Sozinho", + "EVANGELION:1.11 (NO) ESTÁS SOLO" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Jo", + "romaji": "Evangelion Shin Movie: Jo", + "english": "Evangelion: 1.0 You Are (Not) Alone", + "native": "ヱヴァンゲリヲン新劇場版:序" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx2759-z07kq8Pnw5B1.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2759-z07kq8Pnw5B1.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx2759-z07kq8Pnw5B1.jpg", + "color": "#e4bba1" + }, + "startDate": { + "year": 2007, + "month": 9, + "day": 1 + }, + "endDate": { + "year": 2007, + "month": 9, + "day": 1 + }, + "relations": { + "edges": [ + { + "relationType": "SEQUEL", + "node": { + "id": 3784, + "idMal": 3784, + "siteUrl": "https://anilist.co/anime/3784", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3784-OYyfe6vR2687.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 2.22", + "EVANGELION:2.22 VOCÊ (NÃO) PODE AVANÇAR", + "EVANGELION:2.22 (NO) PUEDES AVANZAR" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Ha", + "romaji": "Evangelion Shin Movie: Ha", + "english": "Evangelion: 2.0 You Can (Not) Advance", + "native": "ヱヴァンゲリヲン新劇場版:破" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3784-TGCsqLryKJ2R.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3784-TGCsqLryKJ2R.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3784-TGCsqLryKJ2R.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2009, + "month": 7, + "day": 27 + }, + "endDate": { + "year": 2009, + "month": 7, + "day": 27 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 100330, + "idMal": 32311, + "siteUrl": "https://anilist.co/anime/100330", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "synonyms": [ + "عالم جميل" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Beautiful World", + "romaji": "Beautiful World", + "native": "ビューティフル・ワールド" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx100330-UKBfb9nc9QR1.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx100330-UKBfb9nc9QR1.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx100330-UKBfb9nc9QR1.png", + "color": "#285d78" + }, + "startDate": { + "year": 2014, + "month": 12, + "day": 2 + }, + "endDate": { + "year": 2014, + "month": 12, + "day": 2 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 32, + "idMal": 32, + "siteUrl": "https://anilist.co/anime/32", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n32-BH9yHJBQqeOa.jpg", + "episodes": 1, + "synonyms": [ + "הסוף של אוונגליון", + "אוונגליון של הסוף", + "Конец Евангелиона" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "romaji": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "english": "Neon Genesis Evangelion: The End of Evangelion", + "native": "新世紀エヴァンゲリオン劇場版 Air/まごころを、君に" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx32-i4ijZI4MuPiV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx32-i4ijZI4MuPiV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx32-i4ijZI4MuPiV.jpg", + "color": "#e46b50" + }, + "startDate": { + "year": 1997, + "month": 7, + "day": 19 + }, + "endDate": { + "year": 1997, + "month": 7, + "day": 19 + } + } + } + ] + } + } + }, + { + "id": 389433720, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 3784, + "idMal": 3784, + "siteUrl": "https://anilist.co/anime/3784", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3784-OYyfe6vR2687.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 2.22", + "EVANGELION:2.22 VOCÊ (NÃO) PODE AVANÇAR", + "EVANGELION:2.22 (NO) PUEDES AVANZAR" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Ha", + "romaji": "Evangelion Shin Movie: Ha", + "english": "Evangelion: 2.0 You Can (Not) Advance", + "native": "ヱヴァンゲリヲン新劇場版:破" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3784-TGCsqLryKJ2R.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3784-TGCsqLryKJ2R.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3784-TGCsqLryKJ2R.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2009, + "month": 7, + "day": 27 + }, + "endDate": { + "year": 2009, + "month": 7, + "day": 27 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 2759, + "idMal": 2759, + "siteUrl": "https://anilist.co/anime/2759", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2759-EzK5WpFQz5ZT.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 1.11", + "Neon Genesis Evangelion: [Nie] Jesteś Sam", + "Reconstrucción de Evangelion", + "Евангелион 1.11: Ты (не) один", + "Реконструкция Евангелиона - Евангелион: 1.0 Ты [Не] Одинок", + "福音戰士新劇場版:序", + "Evangelion 1.0: Você [Não] Está Só", + "Evangelion 1.0: Você [Não] Está Sozinho", + "EVANGELION:1.11 (NO) ESTÁS SOLO" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Jo", + "romaji": "Evangelion Shin Movie: Jo", + "english": "Evangelion: 1.0 You Are (Not) Alone", + "native": "ヱヴァンゲリヲン新劇場版:序" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx2759-z07kq8Pnw5B1.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2759-z07kq8Pnw5B1.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx2759-z07kq8Pnw5B1.jpg", + "color": "#e4bba1" + }, + "startDate": { + "year": 2007, + "month": 9, + "day": 1 + }, + "endDate": { + "year": 2007, + "month": 9, + "day": 1 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 3785, + "idMal": 3785, + "siteUrl": "https://anilist.co/anime/3785", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3785-UyfunULvn6PQ.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 3.33", + "Rebuild of Evangelion 3.0 Q Quickening", + "EVANGELION:3.33 VOCÊ (NÃO) PODE REFAZER", + "EVANGELION: 3.33 TÚ (NO) LO PUEDES REHACER" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Kyuu", + "romaji": "Evangelion Shin Movie: Kyuu", + "english": "Evangelion: 3.0 You Can (Not) Redo", + "native": "ヱヴァンゲリヲン新劇場版:Q" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3785-OG857YhQalvS.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3785-OG857YhQalvS.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3785-OG857YhQalvS.png", + "color": "#1a356b" + }, + "startDate": { + "year": 2012, + "month": 11, + "day": 17 + }, + "endDate": { + "year": 2012, + "month": 11, + "day": 17 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 100330, + "idMal": 32311, + "siteUrl": "https://anilist.co/anime/100330", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "synonyms": [ + "عالم جميل" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Beautiful World", + "romaji": "Beautiful World", + "native": "ビューティフル・ワールド" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx100330-UKBfb9nc9QR1.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx100330-UKBfb9nc9QR1.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx100330-UKBfb9nc9QR1.png", + "color": "#285d78" + }, + "startDate": { + "year": 2014, + "month": 12, + "day": 2 + }, + "endDate": { + "year": 2014, + "month": 12, + "day": 2 + } + } + } + ] + } + } + }, + { + "id": 389433722, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21827, + "idMal": 33352, + "siteUrl": "https://anilist.co/anime/21827", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21827-ROucgYiiiSpR.jpg", + "episodes": 13, + "synonyms": [ + "ויולט אברגרדן", + "فيوليت", + "紫罗兰永恒花园" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden", + "romaji": "Violet Evergarden", + "english": "Violet Evergarden", + "native": "ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21827-10F6m50H4GJK.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21827-10F6m50H4GJK.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21827-10F6m50H4GJK.png", + "color": "#3586e4" + }, + "startDate": { + "year": 2018, + "month": 1, + "day": 11 + }, + "endDate": { + "year": 2018, + "month": 4, + "day": 5 + }, + "relations": { + "edges": [ + { + "relationType": "SIDE_STORY", + "node": { + "id": 101432, + "idMal": 37095, + "siteUrl": "https://anilist.co/anime/101432", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n101432-kUA3US0cumo4.jpg", + "episodes": 1, + "synonyms": [ + "فيوليت: رسالة" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden: Kitto \"Ai\" wo Shiru Hi ga Kuru no Darou", + "romaji": "Violet Evergarden: Kitto \"Ai\" wo Shiru Hi ga Kuru no Darou", + "english": "Violet Evergarden: Special", + "native": "ヴァイオレット・エヴァーガーデン きっと\"愛\"を知る日が来るのだろう" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx101432-NQSedsCDQ6dP.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx101432-NQSedsCDQ6dP.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx101432-NQSedsCDQ6dP.png", + "color": "#d6ae6b" + }, + "startDate": { + "year": 2018, + "month": 7, + "day": 4 + }, + "endDate": { + "year": 2018, + "month": 7, + "day": 4 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 97298, + "idMal": 98930, + "siteUrl": "https://anilist.co/manga/97298", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/97298-uybqRwjpsgyX.png", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden", + "romaji": "Violet Evergarden", + "native": "ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx97298-2KETOAaDaTw7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx97298-2KETOAaDaTw7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx97298-2KETOAaDaTw7.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2015, + "month": 12, + "day": 25 + }, + "endDate": { + "year": 2016, + "month": 12, + "day": 26 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 109190, + "idMal": 39741, + "siteUrl": "https://anilist.co/anime/109190", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/109190-SpM8A4w83FnR.jpg", + "episodes": 1, + "synonyms": [ + "Violet Evergarden und das Band der Freundschaft", + "Violet Evergarden Gaiden: La Eternidad y la Muñeca de Recuerdos Automáticos", + "Violet Evergarden Gaiden: Eternidade e a Boneca de Automemória", + "فيوليت: الأبدية وذكريات الدمية الآلية", + "Вайолет Эвергарден: Вечность и призрак пера", + "Violet Evergarden: Věčnost a Píšící panenka" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "romaji": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "english": "Violet Evergarden: Eternity and the Auto Memory Doll", + "native": "ヴァイオレット・エヴァーガーデン 外伝~永遠と自動手記人形~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx109190-e8mv1qdmpjLW.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx109190-e8mv1qdmpjLW.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx109190-e8mv1qdmpjLW.jpg", + "color": "#e4e4a1" + }, + "startDate": { + "year": 2019, + "month": 9, + "day": 6 + }, + "endDate": { + "year": 2019, + "month": 9, + "day": 6 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 154164, + "idMal": 42166, + "siteUrl": "https://anilist.co/anime/154164", + "status": "FINISHED", + "type": "ANIME", + "format": "ONA", + "episodes": 2, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden CM", + "romaji": "Violet Evergarden CM", + "native": "ヴァイオレット・エヴァーガーデン CM" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b154164-3fNKxQJWaFf0.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b154164-3fNKxQJWaFf0.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b154164-3fNKxQJWaFf0.jpg", + "color": "#c9e45d" + }, + "startDate": { + "year": 2016, + "month": 5, + "day": 27 + }, + "endDate": { + "year": 2017, + "month": 3, + "day": 16 + } + } + } + ] + } + } + }, + { + "id": 389433723, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 3786, + "idMal": 3786, + "siteUrl": "https://anilist.co/anime/3786", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3786-IfWKqRp9grgo.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 4.0", + "EVANGELION:3.0+1.01 THRICE UPON A TIME ", + "EVANGELION:3.0+1.01 A ESPERANÇA", + "อีวานเกเลียน:3.0+1.01 สามครั้งก่อน เมื่อเนิ่นนานมาแล้ว", + "Evangelion 3.0+1.11", + "EVANGELION:3.0+1.01 TRIPLE" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Evangelion Movie:||", + "romaji": "Shin Evangelion Movie:||", + "english": "Evangelion: 3.0+1.0 Thrice Upon a Time", + "native": "シン・エヴァンゲリオン劇場版:||" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3786-FPo09WTuoTCV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3786-FPo09WTuoTCV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3786-FPo09WTuoTCV.jpg", + "color": "#50bbe4" + }, + "startDate": { + "year": 2021, + "month": 3, + "day": 8 + }, + "endDate": { + "year": 2021, + "month": 3, + "day": 8 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 3785, + "idMal": 3785, + "siteUrl": "https://anilist.co/anime/3785", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3785-UyfunULvn6PQ.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 3.33", + "Rebuild of Evangelion 3.0 Q Quickening", + "EVANGELION:3.33 VOCÊ (NÃO) PODE REFAZER", + "EVANGELION: 3.33 TÚ (NO) LO PUEDES REHACER" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Kyuu", + "romaji": "Evangelion Shin Movie: Kyuu", + "english": "Evangelion: 3.0 You Can (Not) Redo", + "native": "ヱヴァンゲリヲン新劇場版:Q" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3785-OG857YhQalvS.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3785-OG857YhQalvS.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3785-OG857YhQalvS.png", + "color": "#1a356b" + }, + "startDate": { + "year": 2012, + "month": 11, + "day": 17 + }, + "endDate": { + "year": 2012, + "month": 11, + "day": 17 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 20947, + "idMal": 28149, + "siteUrl": "https://anilist.co/anime/20947", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20947-7uPxrc63bFxM.jpg", + "episodes": 36, + "synonyms": [ + "The Dragon Dentist", + "HILL CLIMB GIRL", + "ME!ME!ME!", + "Carnage", + "Yoshikazu Yasuhiko \u0026 Ichiro Itano: Collection of Key Animation Films", + "20min Walk From Nishi-Ogikubo Station, 2 Bedrooms, Living Room, Dining Room, Kitchen, 2mos Deposit, No Pets Allowed", + "Until you come to me.", + "Tomorrow from there", + "Denkou Choujin Gridman: boys invent great hero", + "YAMADELOID", + "POWER PLANT No. 33", + "Evangelion: Another Impact (Confidential)", + "Kanón", + "SEX and VIOLENCE with MACHSPEED", + "Obake-Chan", + "Tsukikage no Tokio", + "THREE FALLEN WITNESSES", + "The Diary of Ochibi", + "I can Friday by Day!", + "ME!ME!ME! CHRONIC feat.daoko / TeddyLoid", + "(Making of) evangelion:Another Impact", + "ICONIC FIELD", + "IBUSEKI YORUNI", + "Memoirs of amorous gentlemen", + "Rapid Rouge", + "HAMMERHEAD", + "COMEDY SKIT 1989", + "BUBU \u0026 BUBULINA", + "ENDLESS NIGHT", + "BUREAU OF PROTO SOCIETY", + "The Ultraman", + "GIRL", + "Neon Genesis IMPACTS.", + "Ragnarok", + "Robot on the Road", + "Cassette Girl" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Nihon Animator Mihonichi", + "romaji": "Nihon Animator Mihonichi", + "english": "Japan Anima(tor)’s Exhibition", + "native": "日本アニメ(ーター)見本市" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx20947-Qg0QUi31tjeb.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx20947-Qg0QUi31tjeb.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx20947-Qg0QUi31tjeb.jpg", + "color": "#d61a1a" + }, + "startDate": { + "year": 2014, + "month": 11, + "day": 7 + }, + "endDate": { + "year": 2015, + "month": 10, + "day": 9 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 155399, + "idMal": 53246, + "siteUrl": "https://anilist.co/anime/155399", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/155399-yjzWrEjxiHzx.jpg", + "episodes": 1, + "synonyms": [ + "EVANGELION:3.0(-46h) YOU CAN (NOT) REDO." + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "EVANGELION:3.0 (-46h)", + "romaji": "EVANGELION:3.0 (-46h)", + "native": "EVANGELION:3.0(-46h)" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx155399-q78rIZMxNqm0.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx155399-q78rIZMxNqm0.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx155399-q78rIZMxNqm0.jpg" + }, + "startDate": { + "year": 2023, + "month": 3, + "day": 8 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 8 + } + } + } + ] + } + } + }, + { + "id": 389433724, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21284, + "idMal": 31376, + "siteUrl": "https://anilist.co/anime/21284", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21284-9gAbP4x5ziD1.jpg", + "episodes": 12, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Flying Witch", + "romaji": "Flying Witch", + "english": "Flying Witch", + "native": "ふらいんぐうぃっち" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21284-vQcCLIWt1o5O.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21284-vQcCLIWt1o5O.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21284-vQcCLIWt1o5O.png", + "color": "#e4c993" + }, + "startDate": { + "year": 2016, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 76258, + "idMal": 46258, + "siteUrl": "https://anilist.co/manga/76258", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/76258-Vn8vWUXJJfZx.jpg", + "synonyms": [ + "วันธรรมดาของแม่มดว้าวุ่น" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Flying Witch", + "romaji": "Flying Witch", + "english": "Flying Witch", + "native": "ふらいんぐうぃっち" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx76258-iHRY5gdGQ5HY.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx76258-iHRY5gdGQ5HY.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx76258-iHRY5gdGQ5HY.png", + "color": "#f1c986" + }, + "startDate": { + "year": 2012, + "month": 8, + "day": 9 + }, + "endDate": {} + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21721, + "idMal": 32954, + "siteUrl": "https://anilist.co/anime/21721", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21721-heO1DkghgL0d.jpg", + "episodes": 9, + "synonyms": [ + "Flying Witch Puchi" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Flying Witch Petit", + "romaji": "Flying Witch Petit", + "native": "ふらいんぐうぃっち ぷち" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21721-FXrVfwXEgHDd.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21721-FXrVfwXEgHDd.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/21721-FXrVfwXEgHDd.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2016, + "month": 3, + "day": 18 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 24 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 102001, + "idMal": 33681, + "siteUrl": "https://anilist.co/anime/102001", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 1, + "synonyms": [ + "Flying Witch Petit - BD \u0026 DVD Vol. 1 Launch Special" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Flying Witch Petit Special", + "romaji": "Flying Witch Petit Special", + "english": "Flying Witch Petit Special", + "native": "ふらいんぐうぃっち ぷち BD\u0026DVD Vol.1 発売記念編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx102001-oKC1q5MI4oxL.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx102001-oKC1q5MI4oxL.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx102001-oKC1q5MI4oxL.png", + "color": "#e4e443" + }, + "startDate": { + "year": 2016, + "month": 6, + "day": 22 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 22 + } + } + } + ] + } + } + }, + { + "id": 389433726, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 159831, + "idMal": 54112, + "siteUrl": "https://anilist.co/anime/159831", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/159831-FWfdyqpxhLli.jpg", + "episodes": 12, + "synonyms": [ + "Zombie 100 ~100 Things I Want to do Before I Become a Zombie~", + "Zombie 100 ~Zombie ni Naru Made ni Shitai 100 no Koto~", + "100 สิ่งที่อยากทำก่อนจะกลายเป็นซอมบี้", + "Зомби-апокалипсис и 100 предсмертных дел", + "100 Coisas para Fazer Antes de Virar Zumbi" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Zom 100: Zombie ni Naru Made ni Shitai 100 no Koto", + "romaji": "Zom 100: Zombie ni Naru Made ni Shitai 100 no Koto", + "english": "Zom 100: Bucket List of the Dead", + "native": "ゾン100~ゾンビになるまでにしたい100のこと~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx159831-TxAC0ujoLTK6.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx159831-TxAC0ujoLTK6.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx159831-TxAC0ujoLTK6.png", + "color": "#d6e428" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 9 + }, + "endDate": { + "year": 2023, + "month": 12, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 104660, + "idMal": 122392, + "siteUrl": "https://anilist.co/manga/104660", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/104660-dBf2eJ0bjZQ9.jpg", + "synonyms": [ + "Zon 100", + "Bucket list of the dead", + "Zombie 100 ~100 Things I Want to do Before I Become a Zombie~", + "Zombie 100 ~Zombie ni Naru Made ni Shitai 100 no Koto~", + "ซอม 100 : 100 สิ่งที่อยากทำก่อนจะกลายเป็นซอมบี้", + "100 rzeczy do zrobienia, zanim zostanę zombie", + "Zom 100: Coisas para Fazer antes de Virar Zumbi", + "좀100 -좀비가 되기 전에 하고 싶은 100가지-" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Zom 100: Zombie ni Naru Made ni Shitai 100 no Koto", + "romaji": "Zom 100: Zombie ni Naru Made ni Shitai 100 no Koto", + "english": "Zom 100: Bucket List of the Dead", + "native": "ゾン100 ~ゾンビになるまでにしたい100のこと~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx104660-NZZdpLJlgHle.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx104660-NZZdpLJlgHle.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx104660-NZZdpLJlgHle.jpg", + "color": "#fe2886" + }, + "startDate": { + "year": 2018, + "month": 10, + "day": 19 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433727, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 104157, + "idMal": 38329, + "siteUrl": "https://anilist.co/anime/104157", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/104157-PS7tfPpvJKhk.jpg", + "episodes": 1, + "synonyms": [ + "青ブタ", + "Ao Buta ", + "青春猪头少年不会梦到怀梦美少女", + "Этот глупый свин не понимает мечту девочки-зайки. Фильм", + "Негодник, которому не снилась девушка-кролик. Фильм" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "english": "Rascal Does Not Dream of a Dreaming Girl", + "native": "青春ブタ野郎はゆめみる少女の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx104157-rk99XI56PaIC.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx104157-rk99XI56PaIC.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx104157-rk99XI56PaIC.jpg", + "color": "#8643f1" + }, + "startDate": { + "year": 2019, + "month": 6, + "day": 15 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 15 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 101291, + "idMal": 37450, + "siteUrl": "https://anilist.co/anime/101291", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n101291-fqIUvQ6apEtD.jpg", + "episodes": 13, + "synonyms": [ + "AoButa", + "青春猪头少年不会梦到兔女郎学姐", + "Негодник, которому не снилась девушка-кролик", + "Этот глупый свин не понимает мечту девочки-зайки", + "青ブタ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Bunny Girl Senpai no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Bunny Girl Senpai no Yume wo Minai", + "english": "Rascal Does Not Dream of Bunny Girl Senpai", + "native": "青春ブタ野郎はバニーガール先輩の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx101291-L71WpAkZPtgm.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx101291-L71WpAkZPtgm.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx101291-L71WpAkZPtgm.jpg", + "color": "#5078e4" + }, + "startDate": { + "year": 2018, + "month": 10, + "day": 4 + }, + "endDate": { + "year": 2018, + "month": 12, + "day": 27 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 145163, + "siteUrl": "https://anilist.co/manga/145163", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/145163-CpDAjEhHQahZ.jpg", + "synonyms": [ + "Ao Buta", + "青ブタ", + "Seishun Buta Yarou Series", + "青春ブタ野郎シリーズ", + "เรื่องฝันปั่นป่วยของผมกับแม่สาวน้อยช่างฝัน", + "青春豬頭少年不會夢到懷夢美少女 " + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "english": "Rascal Does Not Dream of a Dreaming Girl", + "native": "青春ブタ野郎はゆめみる少女の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx145163-84gshcr9NREV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx145163-84gshcr9NREV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx145163-84gshcr9NREV.jpg", + "color": "#e45da1" + }, + "startDate": { + "year": 2016, + "month": 6, + "day": 10 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 10 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 145164, + "siteUrl": "https://anilist.co/manga/145164", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/145164-KiXZXNNdp3Vk.jpg", + "synonyms": [ + "Ao Buta", + "青ブタ", + "Seishun Buta Yarou Series", + "青春ブタ野郎シリーズ", + "เรื่องฝันปั่นป่วยของผมกับสาวน้อยผู้เป็นรักแรก" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Hatsukoi Shoujo no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Hatsukoi Shoujo no Yume wo Minai", + "english": "Rascal Does Not Dream of His First Love", + "native": "青春ブタ野郎はハツコイ少女の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx145164-3xAszEx3vGGq.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx145164-3xAszEx3vGGq.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx145164-3xAszEx3vGGq.jpg", + "color": "#43aee4" + }, + "startDate": { + "year": 2016, + "month": 10, + "day": 8 + }, + "endDate": { + "year": 2016, + "month": 10, + "day": 8 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 154967, + "idMal": 53129, + "siteUrl": "https://anilist.co/anime/154967", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/154967-uUEtnP9NOTGu.jpg", + "episodes": 1, + "synonyms": [ + "Ao Buta", + "青ブタ", + "เรื่องฝันปั่นป่วยของผมกับน้องสาวออกนอกบ้าน" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "english": "Rascal Does Not Dream of a Sister Venturing Out", + "native": "青春ブタ野郎はおでかけシスターの夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154967-W9cIm0qlz6fj.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154967-W9cIm0qlz6fj.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154967-W9cIm0qlz6fj.jpg", + "color": "#e4d6ae" + }, + "startDate": { + "year": 2023, + "month": 6, + "day": 23 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 23 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 163685, + "idMal": 158943, + "siteUrl": "https://anilist.co/manga/163685", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "native": "青春ブタ野郎はゆめみる少女の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx163685-TMNOkAK96rvA.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx163685-TMNOkAK96rvA.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx163685-TMNOkAK96rvA.jpg", + "color": "#1a93d6" + }, + "startDate": { + "year": 2023, + "month": 4, + "day": 30 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433738, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 137822, + "idMal": 49596, + "siteUrl": "https://anilist.co/anime/137822", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/137822-oevspckMGLuY.jpg", + "episodes": 24, + "synonyms": [ + "BLUE LOCK ขังดวลแข้ง", + " بلو لوك" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Blue Lock", + "romaji": "Blue Lock", + "english": "BLUELOCK", + "native": "ブルーロック" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx137822-4dVWMSHLpGf8.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx137822-4dVWMSHLpGf8.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx137822-4dVWMSHLpGf8.png", + "color": "#286be4" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 9 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 106130, + "idMal": 114745, + "siteUrl": "https://anilist.co/manga/106130", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/106130-4UbnMTU80zur.jpg", + "synonyms": [ + "Bluelock", + "BLUE LOCK ขังดวลแข้ง", + "Синя тюрма", + "블루 록" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Blue Lock", + "romaji": "Blue Lock", + "english": "Blue Lock", + "native": "ブルーロック" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx106130-AZn3dTaSXM4z.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx106130-AZn3dTaSXM4z.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx106130-AZn3dTaSXM4z.jpg", + "color": "#a1f135" + }, + "startDate": { + "year": 2018, + "month": 8, + "day": 1 + }, + "endDate": {} + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 163146, + "idMal": 54865, + "siteUrl": "https://anilist.co/anime/163146", + "status": "NOT_YET_RELEASED", + "type": "ANIME", + "format": "TV", + "synonyms": [ + "BLUELOCK Season 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Blue Lock 2nd Season", + "romaji": "Blue Lock 2nd Season", + "native": "ブルーロック第2期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163146-AL4DrcV2Zp8H.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163146-AL4DrcV2Zp8H.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163146-AL4DrcV2Zp8H.jpg", + "color": "#aef143" + }, + "startDate": { + "year": 2024 + }, + "endDate": {} + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 163147, + "idMal": 54866, + "siteUrl": "https://anilist.co/anime/163147", + "status": "NOT_YET_RELEASED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163147-V6c9XjfvGE0M.jpg", + "episodes": 1, + "synonyms": [ + "BLUELOCK -EPISODE NAGI-", + "Blue Lock Movie", + "劇場版 ブルーロック" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Blue Lock: EPISODE Nagi", + "romaji": "Blue Lock: EPISODE Nagi", + "native": "ブルーロック -EPISODE 凪-" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163147-yyu5aEoO96Jg.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163147-yyu5aEoO96Jg.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163147-yyu5aEoO96Jg.jpg", + "color": "#50a1e4" + }, + "startDate": { + "year": 2024, + "month": 4, + "day": 19 + }, + "endDate": { + "year": 2024, + "month": 4, + "day": 19 + } + } + } + ] + } + } + }, + { + "id": 389433739, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 20652, + "idMal": 23199, + "siteUrl": "https://anilist.co/anime/20652", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20652-sCk2BUWiRMLc.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Shou", + "דורארארה!!2x התפתחות" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Shou", + "romaji": "Durarara!!x2 Shou", + "english": "Durarara!! X2", + "native": "デュラララ!!×2 承" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20652-8ft6GZKEoeWn.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20652-8ft6GZKEoeWn.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20652-8ft6GZKEoeWn.png" + }, + "startDate": { + "year": 2015, + "month": 1, + "day": 10 + }, + "endDate": { + "year": 2015, + "month": 3, + "day": 28 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 6746, + "idMal": 6746, + "siteUrl": "https://anilist.co/anime/6746", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/6746-84oNA7P9pboV.jpg", + "episodes": 24, + "synonyms": [ + "DRRR!!", + "דורארארה!!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx6746-Q4EmstN2fy0R.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6746-Q4EmstN2fy0R.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx6746-Q4EmstN2fy0R.png", + "color": "#f1e4bb" + }, + "startDate": { + "year": 2010, + "month": 1, + "day": 8 + }, + "endDate": { + "year": 2010, + "month": 6, + "day": 25 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 20879, + "idMal": 27831, + "siteUrl": "https://anilist.co/anime/20879", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20879-KRnO8kddef9Q.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Ten", + "דורארארה!!2x תפנית" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ten", + "romaji": "Durarara!!x2 Ten", + "english": "Durarara!! X2 The Second Arc", + "native": "デュラララ!!×2 転" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20879-IqgXMXuUMvRM.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20879-IqgXMXuUMvRM.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20879-IqgXMXuUMvRM.png" + }, + "startDate": { + "year": 2015, + "month": 7, + "day": 4 + }, + "endDate": { + "year": 2015, + "month": 9, + "day": 26 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 46816, + "idMal": 16816, + "siteUrl": "https://anilist.co/manga/46816", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/46816-1KVtcolq8c1H.jpg", + "synonyms": [ + "DRRR!!", + "Дюрарара!!", + "DRRR!! โลกบิดเบี้ยวที่อิเคะบุคุโระ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx46816-D2DhHjjCu2ow.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx46816-D2DhHjjCu2ow.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx46816-D2DhHjjCu2ow.png", + "color": "#e4ae50" + }, + "startDate": { + "year": 2004, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2014, + "month": 1, + "day": 10 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21068, + "idMal": 30191, + "siteUrl": "https://anilist.co/anime/21068", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21068-m0OY3msjv5Fc.jpg", + "episodes": 1, + "synonyms": [ + "Durarara!!x2 Shou Episode 4.5" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Shou: Watashi no Kokoro wa Nabe Moyou", + "romaji": "Durarara!!x2 Shou: Watashi no Kokoro wa Nabe Moyou", + "english": "Durarara!! X2: My Heart is in the Pattern of a Hot Pot", + "native": "デュラララ!!×2 承 私の心は鍋模様" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21068-FtMxCJjPN0BL.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21068-FtMxCJjPN0BL.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21068-FtMxCJjPN0BL.jpg", + "color": "#1a6b50" + }, + "startDate": { + "year": 2015, + "month": 5, + "day": 30 + }, + "endDate": { + "year": 2015, + "month": 5, + "day": 30 + } + } + } + ] + } + } + }, + { + "id": 389433740, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 109190, + "idMal": 39741, + "siteUrl": "https://anilist.co/anime/109190", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/109190-SpM8A4w83FnR.jpg", + "episodes": 1, + "synonyms": [ + "Violet Evergarden und das Band der Freundschaft", + "Violet Evergarden Gaiden: La Eternidad y la Muñeca de Recuerdos Automáticos", + "Violet Evergarden Gaiden: Eternidade e a Boneca de Automemória", + "فيوليت: الأبدية وذكريات الدمية الآلية", + "Вайолет Эвергарден: Вечность и призрак пера", + "Violet Evergarden: Věčnost a Píšící panenka" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "romaji": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "english": "Violet Evergarden: Eternity and the Auto Memory Doll", + "native": "ヴァイオレット・エヴァーガーデン 外伝~永遠と自動手記人形~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx109190-e8mv1qdmpjLW.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx109190-e8mv1qdmpjLW.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx109190-e8mv1qdmpjLW.jpg", + "color": "#e4e4a1" + }, + "startDate": { + "year": 2019, + "month": 9, + "day": 6 + }, + "endDate": { + "year": 2019, + "month": 9, + "day": 6 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 109121, + "idMal": 113819, + "siteUrl": "https://anilist.co/manga/109121", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Gaiden", + "romaji": "Violet Evergarden Gaiden", + "native": "ヴァイオレット・エヴァーガーデン 外伝" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx109121-10xZOnCHKbrs.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx109121-10xZOnCHKbrs.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx109121-10xZOnCHKbrs.png", + "color": "#e4bba1" + }, + "startDate": { + "year": 2018, + "month": 3, + "day": 23 + }, + "endDate": { + "year": 2018, + "month": 3, + "day": 23 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 103047, + "idMal": 37987, + "siteUrl": "https://anilist.co/anime/103047", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/103047-Tjvsh5w1XZP4.jpg", + "episodes": 1, + "synonyms": [ + "Виолетта Эвергарден", + "Вайоллет Эвергарден", + "薇尔莉特·伊芙加登", + "Violet Evergarden – film" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Movie", + "romaji": "Violet Evergarden Movie", + "english": "Violet Evergarden: the Movie", + "native": "劇場版 ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx103047-LYIbLtN2Rb5T.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx103047-LYIbLtN2Rb5T.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx103047-LYIbLtN2Rb5T.jpg", + "color": "#35a1f1" + }, + "startDate": { + "year": 2020, + "month": 9, + "day": 18 + }, + "endDate": { + "year": 2020, + "month": 9, + "day": 18 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 21827, + "idMal": 33352, + "siteUrl": "https://anilist.co/anime/21827", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21827-ROucgYiiiSpR.jpg", + "episodes": 13, + "synonyms": [ + "ויולט אברגרדן", + "فيوليت", + "紫罗兰永恒花园" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden", + "romaji": "Violet Evergarden", + "english": "Violet Evergarden", + "native": "ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21827-10F6m50H4GJK.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21827-10F6m50H4GJK.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21827-10F6m50H4GJK.png", + "color": "#3586e4" + }, + "startDate": { + "year": 2018, + "month": 1, + "day": 11 + }, + "endDate": { + "year": 2018, + "month": 4, + "day": 5 + } + } + } + ] + } + } + }, + { + "id": 389433741, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 3785, + "idMal": 3785, + "siteUrl": "https://anilist.co/anime/3785", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3785-UyfunULvn6PQ.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 3.33", + "Rebuild of Evangelion 3.0 Q Quickening", + "EVANGELION:3.33 VOCÊ (NÃO) PODE REFAZER", + "EVANGELION: 3.33 TÚ (NO) LO PUEDES REHACER" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Kyuu", + "romaji": "Evangelion Shin Movie: Kyuu", + "english": "Evangelion: 3.0 You Can (Not) Redo", + "native": "ヱヴァンゲリヲン新劇場版:Q" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3785-OG857YhQalvS.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3785-OG857YhQalvS.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3785-OG857YhQalvS.png", + "color": "#1a356b" + }, + "startDate": { + "year": 2012, + "month": 11, + "day": 17 + }, + "endDate": { + "year": 2012, + "month": 11, + "day": 17 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 3784, + "idMal": 3784, + "siteUrl": "https://anilist.co/anime/3784", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3784-OYyfe6vR2687.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 2.22", + "EVANGELION:2.22 VOCÊ (NÃO) PODE AVANÇAR", + "EVANGELION:2.22 (NO) PUEDES AVANZAR" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion Shin Movie: Ha", + "romaji": "Evangelion Shin Movie: Ha", + "english": "Evangelion: 2.0 You Can (Not) Advance", + "native": "ヱヴァンゲリヲン新劇場版:破" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3784-TGCsqLryKJ2R.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3784-TGCsqLryKJ2R.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3784-TGCsqLryKJ2R.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2009, + "month": 7, + "day": 27 + }, + "endDate": { + "year": 2009, + "month": 7, + "day": 27 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 3786, + "idMal": 3786, + "siteUrl": "https://anilist.co/anime/3786", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3786-IfWKqRp9grgo.jpg", + "episodes": 1, + "synonyms": [ + "Rebuild of Evangelion 4.0", + "EVANGELION:3.0+1.01 THRICE UPON A TIME ", + "EVANGELION:3.0+1.01 A ESPERANÇA", + "อีวานเกเลียน:3.0+1.01 สามครั้งก่อน เมื่อเนิ่นนานมาแล้ว", + "Evangelion 3.0+1.11", + "EVANGELION:3.0+1.01 TRIPLE" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Evangelion Movie:||", + "romaji": "Shin Evangelion Movie:||", + "english": "Evangelion: 3.0+1.0 Thrice Upon a Time", + "native": "シン・エヴァンゲリオン劇場版:||" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3786-FPo09WTuoTCV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3786-FPo09WTuoTCV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3786-FPo09WTuoTCV.jpg", + "color": "#50bbe4" + }, + "startDate": { + "year": 2021, + "month": 3, + "day": 8 + }, + "endDate": { + "year": 2021, + "month": 3, + "day": 8 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 101092, + "idMal": 34085, + "siteUrl": "https://anilist.co/anime/101092", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/101092-IALRljXAiN1p.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Sakura Nagashi", + "romaji": "Sakura Nagashi", + "native": "桜流し" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx101092-Aan9cFZcLULo.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx101092-Aan9cFZcLULo.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx101092-Aan9cFZcLULo.png", + "color": "#f1a15d" + }, + "startDate": { + "year": 2016, + "month": 9, + "day": 18 + }, + "endDate": { + "year": 2016, + "month": 9, + "day": 18 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 135927, + "idMal": 137967, + "siteUrl": "https://anilist.co/manga/135927", + "status": "FINISHED", + "type": "MANGA", + "format": "ONE_SHOT", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/135927-iI3P1Nrt0AFL.jpg", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "EVANGELION: 3.0 (-120min.)", + "romaji": "EVANGELION: 3.0 (-120min.)", + "native": "EVANGELION:3.0(-120min.)" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx135927-wdQjHYfOFfIe.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx135927-wdQjHYfOFfIe.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx135927-wdQjHYfOFfIe.png", + "color": "#ff5d35" + }, + "startDate": { + "year": 2021, + "month": 6, + "day": 12 + }, + "endDate": { + "year": 2021, + "month": 6, + "day": 12 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 100330, + "idMal": 32311, + "siteUrl": "https://anilist.co/anime/100330", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "synonyms": [ + "عالم جميل" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Beautiful World", + "romaji": "Beautiful World", + "native": "ビューティフル・ワールド" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx100330-UKBfb9nc9QR1.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx100330-UKBfb9nc9QR1.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx100330-UKBfb9nc9QR1.png", + "color": "#285d78" + }, + "startDate": { + "year": 2014, + "month": 12, + "day": 2 + }, + "endDate": { + "year": 2014, + "month": 12, + "day": 2 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 155399, + "idMal": 53246, + "siteUrl": "https://anilist.co/anime/155399", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/155399-yjzWrEjxiHzx.jpg", + "episodes": 1, + "synonyms": [ + "EVANGELION:3.0(-46h) YOU CAN (NOT) REDO." + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "EVANGELION:3.0 (-46h)", + "romaji": "EVANGELION:3.0 (-46h)", + "native": "EVANGELION:3.0(-46h)" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx155399-q78rIZMxNqm0.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx155399-q78rIZMxNqm0.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx155399-q78rIZMxNqm0.jpg" + }, + "startDate": { + "year": 2023, + "month": 3, + "day": 8 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 8 + } + } + } + ] + } + } + }, + { + "id": 389433742, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 154967, + "idMal": 53129, + "siteUrl": "https://anilist.co/anime/154967", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/154967-uUEtnP9NOTGu.jpg", + "episodes": 1, + "synonyms": [ + "Ao Buta", + "青ブタ", + "เรื่องฝันปั่นป่วยของผมกับน้องสาวออกนอกบ้าน" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "english": "Rascal Does Not Dream of a Sister Venturing Out", + "native": "青春ブタ野郎はおでかけシスターの夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154967-W9cIm0qlz6fj.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154967-W9cIm0qlz6fj.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154967-W9cIm0qlz6fj.jpg", + "color": "#e4d6ae" + }, + "startDate": { + "year": 2023, + "month": 6, + "day": 23 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 145165, + "siteUrl": "https://anilist.co/manga/145165", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/145165-g3t5QMRCe9l5.jpg", + "synonyms": [ + "Ao Buta", + "青ブタ", + "Seishun Buta Yarou Series", + "青春ブタ野郎シリーズ", + "เรื่องฝันปั่นป่วยของผมกับน้องสาวออกนอกบ้าน" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Odekake Sister no Yume wo Minai", + "english": "Rascal Does Not Dream of a Sister Venturing Out", + "native": "青春ブタ野郎はおでかけシスターの夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx145165-R0Jx1xRoYRve.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx145165-R0Jx1xRoYRve.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx145165-R0Jx1xRoYRve.jpg", + "color": "#f19350" + }, + "startDate": { + "year": 2018, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2018, + "month": 4, + "day": 10 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 104157, + "idMal": 38329, + "siteUrl": "https://anilist.co/anime/104157", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/104157-PS7tfPpvJKhk.jpg", + "episodes": 1, + "synonyms": [ + "青ブタ", + "Ao Buta ", + "青春猪头少年不会梦到怀梦美少女", + "Этот глупый свин не понимает мечту девочки-зайки. Фильм", + "Негодник, которому не снилась девушка-кролик. Фильм" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Yumemiru Shoujo no Yume wo Minai", + "english": "Rascal Does Not Dream of a Dreaming Girl", + "native": "青春ブタ野郎はゆめみる少女の夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx104157-rk99XI56PaIC.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx104157-rk99XI56PaIC.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx104157-rk99XI56PaIC.jpg", + "color": "#8643f1" + }, + "startDate": { + "year": 2019, + "month": 6, + "day": 15 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 15 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 161474, + "idMal": 54870, + "siteUrl": "https://anilist.co/anime/161474", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/161474-rUz4D0jx6Ato.jpg", + "episodes": 1, + "synonyms": [ + "Rascal Does Not Dream of a Knapsack Kid", + "Ao Buta", + "青ブタ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Seishun Buta Yarou wa Randoseru Girl no Yume wo Minai", + "romaji": "Seishun Buta Yarou wa Randoseru Girl no Yume wo Minai", + "english": "Rascal Does Not Dream of a Knapsack Kid", + "native": "青春ブタ野郎はランドセルガールの夢を見ない" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx161474-yLgY2vGrkVHY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx161474-yLgY2vGrkVHY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx161474-yLgY2vGrkVHY.jpg", + "color": "#e41abb" + }, + "startDate": { + "year": 2023, + "month": 12, + "day": 1 + }, + "endDate": { + "year": 2023, + "month": 12, + "day": 1 + } + } + } + ] + } + } + }, + { + "id": 389433743, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 109298, + "idMal": 39792, + "siteUrl": "https://anilist.co/anime/109298", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/109298-ej4YYg87HHoA.jpg", + "episodes": 12, + "synonyms": [ + "Don't mess with the Motion Picture Club!", + "Hands off the Motion Picture Club!", + "别对映像研出手!", + "Ước mơ sản xuất anime" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Eizouken ni wa Te wo Dasu na!", + "romaji": "Eizouken ni wa Te wo Dasu na!", + "english": "Keep Your Hands Off Eizouken!", + "native": "映像研には手を出すな!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx109298-YvjfI88hX76T.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx109298-YvjfI88hX76T.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx109298-YvjfI88hX76T.png", + "color": "#e4a135" + }, + "startDate": { + "year": 2020, + "month": 1, + "day": 6 + }, + "endDate": { + "year": 2020, + "month": 3, + "day": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 109319, + "idMal": 112087, + "siteUrl": "https://anilist.co/manga/109319", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "Hands off the Motion Pictures Club!", + "ชมรมอนิเมะฉันใครอย่าแตะ", + "¡No te Metas con el Club de Cine! – Eizouken" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Eizouken ni wa Te wo Dasu na!", + "romaji": "Eizouken ni wa Te wo Dasu na!", + "english": "Keep Your Hands Off Eizouken!", + "native": "映像研には手を出すな!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx109319-Kdns0F9TG2py.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx109319-Kdns0F9TG2py.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx109319-Kdns0F9TG2py.jpg", + "color": "#e4c928" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 27 + }, + "endDate": {} + } + }, + { + "relationType": "OTHER", + "node": { + "id": 116923, + "idMal": 41454, + "siteUrl": "https://anilist.co/anime/116923", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "ONA", + "episodes": 12, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Eizouken Mini Anime", + "romaji": "Eizouken Mini Anime", + "native": "映像研 ミニアニメ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx116923-ASmRIGda4ZZb.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx116923-ASmRIGda4ZZb.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx116923-ASmRIGda4ZZb.png", + "color": "#f1d6ae" + }, + "startDate": { + "year": 2020, + "month": 1, + "day": 8 + }, + "endDate": { + "year": 2020, + "month": 3, + "day": 24 + } + } + } + ] + } + } + }, + { + "id": 389433744, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 20880, + "idMal": 27833, + "siteUrl": "https://anilist.co/anime/20880", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20880-iQx5G0gz5n6G.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Ketsu", + "דורארארה!!2x סיום" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ketsu", + "romaji": "Durarara!!x2 Ketsu", + "english": "Durarara!! X2 The Third Arc", + "native": "デュラララ!!×2 結" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20880-WsvmgSdL8lhP.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20880-WsvmgSdL8lhP.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20880-WsvmgSdL8lhP.png", + "color": "#e4c943" + }, + "startDate": { + "year": 2016, + "month": 1, + "day": 9 + }, + "endDate": { + "year": 2016, + "month": 3, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 20879, + "idMal": 27831, + "siteUrl": "https://anilist.co/anime/20879", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20879-KRnO8kddef9Q.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Ten", + "דורארארה!!2x תפנית" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ten", + "romaji": "Durarara!!x2 Ten", + "english": "Durarara!! X2 The Second Arc", + "native": "デュラララ!!×2 転" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20879-IqgXMXuUMvRM.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20879-IqgXMXuUMvRM.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20879-IqgXMXuUMvRM.png" + }, + "startDate": { + "year": 2015, + "month": 7, + "day": 4 + }, + "endDate": { + "year": 2015, + "month": 9, + "day": 26 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 46816, + "idMal": 16816, + "siteUrl": "https://anilist.co/manga/46816", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/46816-1KVtcolq8c1H.jpg", + "synonyms": [ + "DRRR!!", + "Дюрарара!!", + "DRRR!! โลกบิดเบี้ยวที่อิเคะบุคุโระ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx46816-D2DhHjjCu2ow.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx46816-D2DhHjjCu2ow.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx46816-D2DhHjjCu2ow.png", + "color": "#e4ae50" + }, + "startDate": { + "year": 2004, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2014, + "month": 1, + "day": 10 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21695, + "idMal": 32915, + "siteUrl": "https://anilist.co/anime/21695", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21695-EOyPwjEwusbk.jpg", + "episodes": 1, + "synonyms": [ + "Durarara!!x2 Ketsu Episode 19.5" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ketsu: DuFuFuFu!!", + "romaji": "Durarara!!x2 Ketsu: DuFuFuFu!!", + "english": "Durarara!! X2 The Third Arc: DuFuFuFu!!", + "native": "デュラララ!!×2 結 デュフフフ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21695-A79FUWY7ZVbw.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21695-A79FUWY7ZVbw.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21695-A79FUWY7ZVbw.png", + "color": "#e4d650" + }, + "startDate": { + "year": 2016, + "month": 5, + "day": 21 + }, + "endDate": { + "year": 2016, + "month": 5, + "day": 21 + } + } + } + ] + } + } + }, + { + "id": 389433745, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 139310, + "idMal": 49834, + "siteUrl": "https://anilist.co/anime/139310", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "episodes": 1, + "synonyms": [ + "Nhắn gửi tất cả các em, những người tôi đã yêu", + "BokuAi" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Boku ga Aishita Subete no Kimi e", + "romaji": "Boku ga Aishita Subete no Kimi e", + "english": "To Every You I’ve Loved Before", + "native": "僕が愛したすべての君へ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx139310-OM1RKpk5YH7g.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx139310-OM1RKpk5YH7g.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx139310-OM1RKpk5YH7g.jpg", + "color": "#86bbe4" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2022, + "month": 10, + "day": 7 + }, + "relations": { + "edges": [ + { + "relationType": "OTHER", + "node": { + "id": 139311, + "idMal": 49835, + "siteUrl": "https://anilist.co/anime/139311", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "episodes": 1, + "synonyms": [ + "Nhắn gửi một tôi, người đã yêu em", + "KimiAi" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi wo Aishita Hitori no Boku e", + "romaji": "Kimi wo Aishita Hitori no Boku e", + "english": "To Me, The One Who Loved You", + "native": "君を愛したひとりの僕へ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx139311-5iHY459iwQ46.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx139311-5iHY459iwQ46.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx139311-5iHY459iwQ46.jpg", + "color": "#ff93c9" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2022, + "month": 10, + "day": 7 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 155634, + "idMal": 53355, + "siteUrl": "https://anilist.co/anime/155634", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/155634-6lwPmGZZy7LV.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kumo wo Kou", + "romaji": "Kumo wo Kou", + "native": "雲を恋う" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx155634-9gxgC9J53hzp.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx155634-9gxgC9J53hzp.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx155634-9gxgC9J53hzp.jpg" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2022, + "month": 10, + "day": 7 + } + } + } + ] + } + } + }, + { + "id": 389433831, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 20879, + "idMal": 27831, + "siteUrl": "https://anilist.co/anime/20879", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20879-KRnO8kddef9Q.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Ten", + "דורארארה!!2x תפנית" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ten", + "romaji": "Durarara!!x2 Ten", + "english": "Durarara!! X2 The Second Arc", + "native": "デュラララ!!×2 転" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20879-IqgXMXuUMvRM.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20879-IqgXMXuUMvRM.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20879-IqgXMXuUMvRM.png" + }, + "startDate": { + "year": 2015, + "month": 7, + "day": 4 + }, + "endDate": { + "year": 2015, + "month": 9, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 20652, + "idMal": 23199, + "siteUrl": "https://anilist.co/anime/20652", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20652-sCk2BUWiRMLc.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Shou", + "דורארארה!!2x התפתחות" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Shou", + "romaji": "Durarara!!x2 Shou", + "english": "Durarara!! X2", + "native": "デュラララ!!×2 承" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20652-8ft6GZKEoeWn.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20652-8ft6GZKEoeWn.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20652-8ft6GZKEoeWn.png" + }, + "startDate": { + "year": 2015, + "month": 1, + "day": 10 + }, + "endDate": { + "year": 2015, + "month": 3, + "day": 28 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 20880, + "idMal": 27833, + "siteUrl": "https://anilist.co/anime/20880", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20880-iQx5G0gz5n6G.jpg", + "episodes": 12, + "synonyms": [ + "DRRR!! 2 Ketsu", + "דורארארה!!2x סיום" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ketsu", + "romaji": "Durarara!!x2 Ketsu", + "english": "Durarara!! X2 The Third Arc", + "native": "デュラララ!!×2 結" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20880-WsvmgSdL8lhP.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20880-WsvmgSdL8lhP.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20880-WsvmgSdL8lhP.png", + "color": "#e4c943" + }, + "startDate": { + "year": 2016, + "month": 1, + "day": 9 + }, + "endDate": { + "year": 2016, + "month": 3, + "day": 26 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 46816, + "idMal": 16816, + "siteUrl": "https://anilist.co/manga/46816", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/46816-1KVtcolq8c1H.jpg", + "synonyms": [ + "DRRR!!", + "Дюрарара!!", + "DRRR!! โลกบิดเบี้ยวที่อิเคะบุคุโระ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!", + "romaji": "Durarara!!", + "english": "Durarara!!", + "native": "デュラララ!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx46816-D2DhHjjCu2ow.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx46816-D2DhHjjCu2ow.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx46816-D2DhHjjCu2ow.png", + "color": "#e4ae50" + }, + "startDate": { + "year": 2004, + "month": 4, + "day": 10 + }, + "endDate": { + "year": 2014, + "month": 1, + "day": 10 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21331, + "idMal": 31552, + "siteUrl": "https://anilist.co/anime/21331", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 1, + "synonyms": [ + "Durarara!!x2 Ten Episode 13.5" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Durarara!!x2 Ten: Onoroke Chakapoko", + "romaji": "Durarara!!x2 Ten: Onoroke Chakapoko", + "english": "Durarara!! X2 The Second Arc: Onoroke Chakapoko", + "native": "デュラララ!!×2 転 お惚気チャカポコ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21331-5gtFE1JYxL2O.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21331-5gtFE1JYxL2O.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/21331-5gtFE1JYxL2O.jpg", + "color": "#e4355d" + }, + "startDate": { + "year": 2015, + "month": 11, + "day": 14 + }, + "endDate": { + "year": 2015, + "month": 11, + "day": 14 + } + } + } + ] + } + } + }, + { + "id": 389433833, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 154116, + "idMal": 52741, + "siteUrl": "https://anilist.co/anime/154116", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/154116-dFaciqCd2pZU.jpg", + "episodes": 24, + "synonyms": [ + "אל-מת ובלי מזל" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Undead Unluck", + "romaji": "Undead Unluck", + "english": "Undead Unluck", + "native": "アンデッドアンラック" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154116-UetMXpm9W8nC.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154116-UetMXpm9W8nC.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154116-UetMXpm9W8nC.jpg", + "color": "#e44343" + }, + "startDate": { + "year": 2023, + "month": 10, + "day": 7 + }, + "endDate": {}, + "nextAiringEpisode": { + "airingAt": 1710519780, + "timeUntilAiring": 406452, + "episode": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 114791, + "idMal": 123956, + "siteUrl": "https://anilist.co/manga/114791", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/114791-ZRpR1LP3NCc6.jpg", + "synonyms": [ + "不死不運" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Undead Unluck", + "romaji": "Undead Unluck", + "english": "Undead Unluck", + "native": "アンデッドアンラック" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx114791-Rj07uWUnsgLY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx114791-Rj07uWUnsgLY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx114791-Rj07uWUnsgLY.jpg", + "color": "#f15da1" + }, + "startDate": { + "year": 2020, + "month": 1, + "day": 20 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433834, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 9656, + "idMal": 9656, + "siteUrl": "https://anilist.co/anime/9656", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/9656-CE0RW5otDH1X.jpg", + "episodes": 13, + "synonyms": [ + "Reaching You 2nd Season", + "Llegando a ti: Temporada 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke 2ND SEASON", + "romaji": "Kimi ni Todoke 2ND SEASON", + "english": "Kimi ni Todoke: From Me to You Season 2", + "native": "君に届け 2ND SEASON" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx9656-vckh2wNj3FwY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx9656-vckh2wNj3FwY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx9656-vckh2wNj3FwY.jpg", + "color": "#f18635" + }, + "startDate": { + "year": 2011, + "month": 1, + "day": 12 + }, + "endDate": { + "year": 2011, + "month": 3, + "day": 30 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 6045, + "idMal": 6045, + "siteUrl": "https://anilist.co/anime/6045", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/6045-PHiyDpX2gc5D.jpg", + "episodes": 25, + "synonyms": [ + "Reaching You", + "Arrivare a te", + "Llegando a ti" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke", + "romaji": "Kimi ni Todoke", + "english": "Kimi ni Todoke: From Me to You", + "native": "君に届け" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx6045-txJOukR5Qve4.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6045-txJOukR5Qve4.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx6045-txJOukR5Qve4.jpg", + "color": "#e45d6b" + }, + "startDate": { + "year": 2009, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2010, + "month": 3, + "day": 31 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 33378, + "idMal": 3378, + "siteUrl": "https://anilist.co/manga/33378", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/33378-9RtoYIQMFGq0.jpg", + "synonyms": [ + "Reaching You", + "Llegando a Ti", + "Que Chegue a Você", + "Sawako", + "Nguyện ước yêu thương" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke", + "romaji": "Kimi ni Todoke", + "english": "Kimi ni Todoke: From Me to You", + "native": "君に届け" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx33378-G9sHqsJryoP2.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx33378-G9sHqsJryoP2.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx33378-G9sHqsJryoP2.jpg", + "color": "#e4a15d" + }, + "startDate": { + "year": 2006, + "month": 5, + "day": 25 + }, + "endDate": { + "year": 2017, + "month": 11, + "day": 13 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 10536, + "idMal": 10536, + "siteUrl": "https://anilist.co/anime/10536", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 3, + "synonyms": [ + "Kimi ni Todoke 2nd Season: Minitodo Gekijou", + "Kimi ni Todoke: From Me to You 2nd Season Specials", + "Reaching You 2nd Season Specials" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke 2nd Season Specials", + "romaji": "Kimi ni Todoke 2nd Season Specials", + "native": "君に届け ミニトド劇場" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx10536-OWWiu3VOlJn9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx10536-OWWiu3VOlJn9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx10536-OWWiu3VOlJn9.jpg", + "color": "#e4bba1" + }, + "startDate": { + "year": 2011, + "month": 4, + "day": 20 + }, + "endDate": { + "year": 2011, + "month": 9, + "day": 21 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 168872, + "idMal": 56538, + "siteUrl": "https://anilist.co/anime/168872", + "status": "NOT_YET_RELEASED", + "type": "ANIME", + "format": "ONA", + "synonyms": [ + "ฝากใจไปถึงเธอ ซีซั่น 3" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi ni Todoke 3RD SEASON", + "romaji": "Kimi ni Todoke 3RD SEASON", + "english": "Kimi ni Todoke: From Me to You Season 3", + "native": "君に届け 3RD SEASON" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx168872-u7NQPxaG6J1a.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx168872-u7NQPxaG6J1a.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx168872-u7NQPxaG6J1a.jpg", + "color": "#f1d6bb" + }, + "startDate": { + "year": 2024 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433837, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 131083, + "idMal": 48483, + "siteUrl": "https://anilist.co/anime/131083", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/131083-MYE8c1cuzO6C.jpg", + "episodes": 12, + "synonyms": [ + "มิเอรุโกะจัง ใครว่าหนูเห็นผี", + "Mieruko: Gadis yang Bisa Melihat Hantu", + "Girl That Can See It", + "Mieruko-chan. Dziewczyna, która widzi więcej" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mieruko-chan", + "romaji": "Mieruko-chan", + "english": "Mieruko-chan", + "native": "見える子ちゃん" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx131083-fMWFyOFgp6vb.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx131083-fMWFyOFgp6vb.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx131083-fMWFyOFgp6vb.png", + "color": "#93c9e4" + }, + "startDate": { + "year": 2021, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 2021, + "month": 12, + "day": 19 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 105097, + "idMal": 116790, + "siteUrl": "https://anilist.co/manga/105097", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/105097-MW8cS0AOR1eq.jpg", + "synonyms": [ + "Girl That Can See It", + "The Girl Who Sees \"Them\"", + "Can-See-Ghosts-chan", + "Child That Can See It", + "Li'l Miss Can-See-Ghosts", + "Mieruko-chan: Slice of Horror", + "看得见的女孩", + "มิเอรุโกะจัง ใครว่าหนูเห็นผี", + "Mieruko-chan. Dziewczyna, która widzi więcej" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mieruko-chan", + "romaji": "Mieruko-chan", + "english": "Mieruko-chan", + "native": "見える子ちゃん" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx105097-nMpc8bjBeuXE.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx105097-nMpc8bjBeuXE.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx105097-nMpc8bjBeuXE.jpg" + }, + "startDate": { + "year": 2018, + "month": 11, + "day": 2 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433838, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 12189, + "idMal": 12189, + "siteUrl": "https://anilist.co/anime/12189", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/12189-TG0peUcKFqur.jpg", + "episodes": 22, + "synonyms": [ + "Hyouka: Forbidden Secrets", + "เฮียวกะปริศนาความทรงจำ", + "Хёка" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Hyouka", + "romaji": "Hyouka", + "english": "Hyouka", + "native": "氷菓" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx12189-eBb6fcM21Zh7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx12189-eBb6fcM21Zh7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx12189-eBb6fcM21Zh7.jpg", + "color": "#f1e4bb" + }, + "startDate": { + "year": 2012, + "month": 4, + "day": 23 + }, + "endDate": { + "year": 2012, + "month": 9, + "day": 16 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 65513, + "idMal": 35513, + "siteUrl": "https://anilist.co/manga/65513", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/65513-beioOI3VEJ65.jpg", + "synonyms": [ + "Koten-bu Series", + "〈古典部〉シリーズ", + "The niece of time", + "ปริศนาความทรงจำ", + "Kem đá" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Hyouka", + "romaji": "Hyouka", + "native": "氷菓" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx65513-XhhDwKxsNsxZ.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx65513-XhhDwKxsNsxZ.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx65513-XhhDwKxsNsxZ.png", + "color": "#e4ae5d" + }, + "startDate": { + "year": 2001, + "month": 10, + "day": 31 + }, + "endDate": { + "year": 2001, + "month": 10, + "day": 31 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 13469, + "idMal": 13469, + "siteUrl": "https://anilist.co/anime/13469", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n13469-4SXZGXqQSsdp.jpg", + "episodes": 1, + "synonyms": [ + "Hyouka Episode 11.5", + "Hyouka OVA", + "Hyou-ka OVA", + "Hyouka: You can't escape OVA" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Hyouka: Motsubeki Mono wa", + "romaji": "Hyouka: Motsubeki Mono wa", + "english": "Hyouka: What Should Be Had", + "native": "氷菓 持つべきものは" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx13469-bbRQoSUgmf4T.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx13469-bbRQoSUgmf4T.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx13469-bbRQoSUgmf4T.png", + "color": "#e4e493" + }, + "startDate": { + "year": 2012, + "month": 7, + "day": 8 + }, + "endDate": { + "year": 2012, + "month": 7, + "day": 8 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 71629, + "idMal": 41629, + "siteUrl": "https://anilist.co/manga/71629", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/71629-jzpIRCBxQjit.jpg", + "synonyms": [ + "Hyoka", + "Classics Club Series", + "Koten-bu Series", + "Frozen Treat", + "冰果", + "ปริศนาความทรงจำ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Hyouka", + "romaji": "Hyouka", + "english": "Hyouka", + "native": "氷菓" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx71629-b1McvLSvE9X9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx71629-b1McvLSvE9X9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx71629-b1McvLSvE9X9.jpg", + "color": "#50a1e4" + }, + "startDate": { + "year": 2012, + "month": 1, + "day": 26 + }, + "endDate": {} + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 144311, + "siteUrl": "https://anilist.co/manga/144311", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Why didn't she ask EBA?", + "Koten-bu Series", + "〈古典部〉シリーズ", + "บทละครของคนโง่" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Gusha no Endroll", + "romaji": "Gusha no Endroll", + "native": "愚者のエンドロール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx144311-mTUr8DGbVziy.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx144311-mTUr8DGbVziy.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx144311-mTUr8DGbVziy.jpg", + "color": "#e4780d" + }, + "startDate": { + "year": 2002, + "month": 7, + "day": 31 + }, + "endDate": { + "year": 2002, + "month": 7, + "day": 31 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 144312, + "siteUrl": "https://anilist.co/manga/144312", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Welcome to KANYA FESTA", + "Koten-bu Series", + "〈古典部〉シリーズ", + "クドリャフカの順番 「十文字」事件", + "ลำดับแห่งคุดร์ยัฟกา" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kudryavka no Junban", + "romaji": "Kudryavka no Junban", + "native": "クドリャフカの順番" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/b144312-Q5upWAEgYwUH.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/b144312-Q5upWAEgYwUH.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/b144312-Q5upWAEgYwUH.jpg", + "color": "#f1e4bb" + }, + "startDate": { + "year": 2005, + "month": 6, + "day": 30 + }, + "endDate": { + "year": 2005, + "month": 6, + "day": 30 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 144313, + "siteUrl": "https://anilist.co/manga/144313", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Little birds can remember", + "〈古典部〉シリーズ", + "Koten-bu Series", + "เจ้าหญิงเดินอ้อม" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Toomawari suru Hina", + "romaji": "Toomawari suru Hina", + "native": "遠まわりする雛" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx144313-rfGWqIyYdijo.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx144313-rfGWqIyYdijo.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx144313-rfGWqIyYdijo.jpg" + }, + "startDate": { + "year": 2007, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 2007, + "month": 10, + "day": 3 + } + } + } + ] + } + } + }, + { + "id": 389433839, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 146065, + "idMal": 51179, + "siteUrl": "https://anilist.co/anime/146065", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/146065-33RDijfuxLLk.jpg", + "episodes": 13, + "synonyms": [ + "ชาตินี้พี่ต้องเทพ ภาค 2", + "Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season", + "Mushoku Tensei II: Jobless Reincarnation", + "Mushoku Tensei II: Reencarnación desde cero", + "无职转生~到了异世界就拿出真本事~第2季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu", + "romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu", + "english": "Mushoku Tensei: Jobless Reincarnation Season 2", + "native": "無職転生 Ⅱ ~異世界行ったら本気だす~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx146065-IjirxRK26O03.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx146065-IjirxRK26O03.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx146065-IjirxRK26O03.png", + "color": "#35aee4" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 3 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 25 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 127720, + "idMal": 45576, + "siteUrl": "https://anilist.co/anime/127720", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/127720-oBpHiMWQhFVN.jpg", + "episodes": 12, + "synonyms": [ + "Mushoku Tensei: Jobless Reincarnation Part 2", + "ชาตินี้พี่ต้องเทพ พาร์ท 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu Part 2", + "english": "Mushoku Tensei: Jobless Reincarnation Cour 2", + "native": "無職転生 ~異世界行ったら本気だす~ 第2クール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx127720-ADJgIrUVMdU9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx127720-ADJgIrUVMdU9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx127720-ADJgIrUVMdU9.jpg", + "color": "#d6bb1a" + }, + "startDate": { + "year": 2021, + "month": 10, + "day": 4 + }, + "endDate": { + "year": 2021, + "month": 12, + "day": 20 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 85470, + "idMal": 70261, + "siteUrl": "https://anilist.co/manga/85470", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85470-akkFSKH9aacB.jpg", + "synonyms": [ + "เกิดชาตินี้พี่ต้องเทพ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "english": "Mushoku Tensei: Jobless Reincarnation", + "native": "無職転生 ~異世界行ったら本気だす~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85470-jt6BF9tDWB2X.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85470-jt6BF9tDWB2X.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85470-jt6BF9tDWB2X.jpg", + "color": "#f1bb1a" + }, + "startDate": { + "year": 2014, + "month": 1, + "day": 23 + }, + "endDate": { + "year": 2022, + "month": 11, + "day": 25 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 142989, + "idMal": 142765, + "siteUrl": "https://anilist.co/manga/142989", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "Mushoku Tensei - Depressed Magician" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu - Shitsui no Majutsushi-hen", + "native": "無職転生 ~異世界行ったら本気だす~ 失意の魔術師編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx142989-jYDNHLwdER70.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx142989-jYDNHLwdER70.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx142989-jYDNHLwdER70.png", + "color": "#e4bb28" + }, + "startDate": { + "year": 2021, + "month": 12, + "day": 20 + }, + "endDate": {} + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 85564, + "idMal": 70259, + "siteUrl": "https://anilist.co/manga/85564", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85564-Wy8IQU3Km61c.jpg", + "synonyms": [ + "Mushoku Tensei: Uma segunda chance" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "romaji": "Mushoku Tensei: Isekai Ittara Honki Dasu", + "english": "Mushoku Tensei: Jobless Reincarnation", + "native": "無職転生 ~異世界行ったら本気だす~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx85564-egXRASF0x9B9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx85564-egXRASF0x9B9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx85564-egXRASF0x9B9.jpg", + "color": "#e4ae0d" + }, + "startDate": { + "year": 2014, + "month": 5, + "day": 2 + }, + "endDate": {} + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 166873, + "idMal": 55888, + "siteUrl": "https://anilist.co/anime/166873", + "status": "NOT_YET_RELEASED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/166873-GTi5imE5skM2.jpg", + "episodes": 12, + "synonyms": [ + "Mushoku Tensei: Jobless Reincarnation Season 2 Part 2", + "ชาตินี้พี่ต้องเทพ ภาค 2", + "Mushoku Tensei: Isekai Ittara Honki Dasu 2nd Season Part 2", + "Mushoku Tensei II: Jobless Reincarnation Part 2", + "Mushoku Tensei II: Reencarnación desde cero", + "无职转生~到了异世界就拿出真本事~第2季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2", + "romaji": "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2", + "native": "無職転生 Ⅱ ~異世界行ったら本気だす~ 第2クール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx166873-jXjrMEDpMFaC.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx166873-jXjrMEDpMFaC.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx166873-jXjrMEDpMFaC.jpg", + "color": "#e4ae78" + }, + "startDate": { + "year": 2024, + "month": 4, + "day": 8 + }, + "endDate": { + "year": 2024, + "month": 6 + } + } + } + ] + } + } + }, + { + "id": 389433841, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 163263, + "idMal": 54898, + "siteUrl": "https://anilist.co/anime/163263", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163263-cx1zOVfatLxW.jpg", + "episodes": 11, + "synonyms": [ + "BSD 5" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 5th Season", + "romaji": "Bungou Stray Dogs 5th Season", + "english": "Bungo Stray Dogs 5", + "native": "文豪ストレイドッグス 第5シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163263-uz881pFAyi7P.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163263-uz881pFAyi7P.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163263-uz881pFAyi7P.jpg", + "color": "#e41aa1" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 12 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 20 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 141249, + "idMal": 50330, + "siteUrl": "https://anilist.co/anime/141249", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/141249-ssUG44UgGOMK.jpg", + "episodes": 13, + "synonyms": [ + "BSD 4", + "BungouSD 4", + "คณะประพันธกรจรจัด ภาค 4", + "文豪野犬第四季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 4th Season", + "romaji": "Bungou Stray Dogs 4th Season", + "english": "Bungo Stray Dogs 4", + "native": "文豪ストレイドッグス 第4シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx141249-8tjavEDHmLoT.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx141249-8tjavEDHmLoT.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx141249-8tjavEDHmLoT.jpg", + "color": "#fe5d50" + }, + "startDate": { + "year": 2023, + "month": 1, + "day": 4 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 29 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 85307, + "idMal": 56529, + "siteUrl": "https://anilist.co/manga/85307", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85307-HPlNkWrkD0nk.jpg", + "synonyms": [ + "Literary Stray Dogs", + "Bezpańscy literaci", + "文豪野犬", + "Проза бродячих псов", + "คณะประพันธกรจรจัด", + "Văn hào lưu lạc", + "문호 스트레이독스", + "文豪Stray Dogs" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85307-cfwdYRhtHRpD.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85307-cfwdYRhtHRpD.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85307-cfwdYRhtHRpD.png" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 4 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433843, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 31, + "idMal": 31, + "siteUrl": "https://anilist.co/anime/31", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/31-pYM5NdKIRa5h.jpg", + "episodes": 1, + "synonyms": [ + "Evangelion: Death (True)", + "Evangelion: Death (True)²", + "Revival of Evangelion" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion Movie: Shi to Shinsei", + "romaji": "Shin Seiki Evangelion Movie: Shi to Shinsei", + "english": "Neon Genesis Evangelion: Death \u0026 Rebirth", + "native": "新世紀エヴァンゲリオン劇場版 シト新生" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx31-3zRThtzQH62E.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx31-3zRThtzQH62E.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx31-3zRThtzQH62E.png", + "color": "#e4785d" + }, + "startDate": { + "year": 1997, + "month": 3, + "day": 15 + }, + "endDate": { + "year": 1997, + "month": 3, + "day": 15 + }, + "relations": { + "edges": [ + { + "relationType": "PARENT", + "node": { + "id": 30, + "idMal": 30, + "siteUrl": "https://anilist.co/anime/30", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/30-gEMoHHIqxDgN.jpg", + "episodes": 26, + "synonyms": [ + "NGE", + "Eva", + "ניאון ג'נסיס אוונגליון", + "อีวานเกเลียน มหาสงครามวันพิพากษา", + "Евангелион" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion", + "romaji": "Shin Seiki Evangelion", + "english": "Neon Genesis Evangelion", + "native": "新世紀エヴァンゲリオン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx30-wmNoX3m2qTzz.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx30-wmNoX3m2qTzz.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx30-wmNoX3m2qTzz.jpg", + "color": "#e4865d" + }, + "startDate": { + "year": 1995, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 1996, + "month": 3, + "day": 27 + } + } + }, + { + "relationType": "PARENT", + "node": { + "id": 32, + "idMal": 32, + "siteUrl": "https://anilist.co/anime/32", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n32-BH9yHJBQqeOa.jpg", + "episodes": 1, + "synonyms": [ + "הסוף של אוונגליון", + "אוונגליון של הסוף", + "Конец Евангелиона" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "romaji": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "english": "Neon Genesis Evangelion: The End of Evangelion", + "native": "新世紀エヴァンゲリオン劇場版 Air/まごころを、君に" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx32-i4ijZI4MuPiV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx32-i4ijZI4MuPiV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx32-i4ijZI4MuPiV.jpg", + "color": "#e46b50" + }, + "startDate": { + "year": 1997, + "month": 7, + "day": 19 + }, + "endDate": { + "year": 1997, + "month": 7, + "day": 19 + } + } + } + ] + } + } + }, + { + "id": 389433844, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 151806, + "idMal": 52305, + "siteUrl": "https://anilist.co/anime/151806", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/151806-1AmWChFo1Ogh.jpg", + "episodes": 13, + "synonyms": [ + "Tomo-chan wa Onna no ko!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tomo-chan wa Onnanoko!", + "romaji": "Tomo-chan wa Onnanoko!", + "english": "Tomo-chan Is a Girl!", + "native": "トモちゃんは女の子!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx151806-IAMi2ctI5xJI.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx151806-IAMi2ctI5xJI.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx151806-IAMi2ctI5xJI.jpg", + "color": "#f1c95d" + }, + "startDate": { + "year": 2023, + "month": 1, + "day": 5 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 30 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 86300, + "idMal": 92149, + "siteUrl": "https://anilist.co/manga/86300", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/86300-sL2KyEMqCE7d.jpg", + "synonyms": [ + "Tomo-chan wa Onna no ko!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tomo-chan wa Onnanoko!", + "romaji": "Tomo-chan wa Onnanoko!", + "english": "Tomo-chan is a Girl!", + "native": "トモちゃんは女の子!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx86300-VQWRaxoTXciF.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx86300-VQWRaxoTXciF.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx86300-VQWRaxoTXciF.jpg" + }, + "startDate": { + "year": 2015, + "month": 4, + "day": 7 + }, + "endDate": { + "year": 2019, + "month": 7, + "day": 14 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 163339, + "idMal": 54925, + "siteUrl": "https://anilist.co/anime/163339", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163339-dlF49zHJusrh.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kurae! Telepathy", + "romaji": "Kurae! Telepathy", + "native": "くらえ!テレパシー" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163339-dkysy3QN6PEx.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163339-dkysy3QN6PEx.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163339-dkysy3QN6PEx.png", + "color": "#ffe443" + }, + "startDate": { + "year": 2023, + "month": 3, + "day": 24 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 24 + } + } + } + ] + } + } + }, + { + "id": 389433845, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 110355, + "idMal": 40059, + "siteUrl": "https://anilist.co/anime/110355", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/110355-NjeP9BVLSWG4.jpg", + "episodes": 12, + "synonyms": [ + "Golden Kamui 3" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy 3rd Season", + "romaji": "Golden Kamuy 3rd Season", + "english": "Golden Kamuy Season 3", + "native": "ゴールデンカムイ 第三期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx110355-yXOXm5tr8kgr.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx110355-yXOXm5tr8kgr.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx110355-yXOXm5tr8kgr.png" + }, + "startDate": { + "year": 2020, + "month": 10, + "day": 5 + }, + "endDate": { + "year": 2020, + "month": 12, + "day": 21 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 86559, + "idMal": 85968, + "siteUrl": "https://anilist.co/manga/86559", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/86559-hHgBaQktjtsd.jpg", + "synonyms": [ + "Golden Kamui" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy", + "romaji": "Golden Kamuy", + "english": "Golden Kamuy", + "native": "ゴールデンカムイ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx86559-YbAt5jybSKyX.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx86559-YbAt5jybSKyX.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx86559-YbAt5jybSKyX.jpg", + "color": "#e4861a" + }, + "startDate": { + "year": 2014, + "month": 8, + "day": 21 + }, + "endDate": { + "year": 2022, + "month": 4, + "day": 28 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 102977, + "idMal": 37989, + "siteUrl": "https://anilist.co/anime/102977", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/102977-kqlenTvimAZj.jpg", + "episodes": 12, + "synonyms": [ + "Golden Kamui 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy 2nd Season", + "romaji": "Golden Kamuy 2nd Season", + "english": "Golden Kamuy Season 2", + "native": "ゴールデンカムイ 第二期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx102977-gUejfwQWpnzX.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx102977-gUejfwQWpnzX.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx102977-gUejfwQWpnzX.png", + "color": "#e4c90d" + }, + "startDate": { + "year": 2018, + "month": 10, + "day": 8 + }, + "endDate": { + "year": 2018, + "month": 12, + "day": 24 + } + } + }, + { + "relationType": "SPIN_OFF", + "node": { + "id": 106506, + "idMal": 37755, + "siteUrl": "https://anilist.co/anime/106506", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n106506-Eb40WFgbVOQE.jpg", + "episodes": 46, + "synonyms": [ + "Golden Kamuy Short Anime: Golden Douga Gekijou" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy: Golden Douga Gekijou", + "romaji": "Golden Kamuy: Golden Douga Gekijou", + "native": "ゴールデンカムイ 「ゴールデン道画劇場」" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106506-wTcpHendmbZA.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106506-wTcpHendmbZA.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/n106506-wTcpHendmbZA.jpg", + "color": "#f1d643" + }, + "startDate": { + "year": 2018, + "month": 4, + "day": 16 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 27 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 142343, + "idMal": 50528, + "siteUrl": "https://anilist.co/anime/142343", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "episodes": 13, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy 4th Season", + "romaji": "Golden Kamuy 4th Season", + "english": "Golden Kamuy Season 4", + "native": "ゴールデンカムイ 第四期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx142343-o8gkjIF434qZ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx142343-o8gkjIF434qZ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx142343-o8gkjIF434qZ.jpg" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 26 + } + } + } + ] + } + } + }, + { + "id": 389433846, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 103047, + "idMal": 37987, + "siteUrl": "https://anilist.co/anime/103047", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/103047-Tjvsh5w1XZP4.jpg", + "episodes": 1, + "synonyms": [ + "Виолетта Эвергарден", + "Вайоллет Эвергарден", + "薇尔莉特·伊芙加登", + "Violet Evergarden – film" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Movie", + "romaji": "Violet Evergarden Movie", + "english": "Violet Evergarden: the Movie", + "native": "劇場版 ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx103047-LYIbLtN2Rb5T.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx103047-LYIbLtN2Rb5T.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx103047-LYIbLtN2Rb5T.jpg", + "color": "#35a1f1" + }, + "startDate": { + "year": 2020, + "month": 9, + "day": 18 + }, + "endDate": { + "year": 2020, + "month": 9, + "day": 18 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 97298, + "idMal": 98930, + "siteUrl": "https://anilist.co/manga/97298", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/97298-uybqRwjpsgyX.png", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden", + "romaji": "Violet Evergarden", + "native": "ヴァイオレット・エヴァーガーデン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx97298-2KETOAaDaTw7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx97298-2KETOAaDaTw7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx97298-2KETOAaDaTw7.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2015, + "month": 12, + "day": 25 + }, + "endDate": { + "year": 2016, + "month": 12, + "day": 26 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 109190, + "idMal": 39741, + "siteUrl": "https://anilist.co/anime/109190", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/109190-SpM8A4w83FnR.jpg", + "episodes": 1, + "synonyms": [ + "Violet Evergarden und das Band der Freundschaft", + "Violet Evergarden Gaiden: La Eternidad y la Muñeca de Recuerdos Automáticos", + "Violet Evergarden Gaiden: Eternidade e a Boneca de Automemória", + "فيوليت: الأبدية وذكريات الدمية الآلية", + "Вайолет Эвергарден: Вечность и призрак пера", + "Violet Evergarden: Věčnost a Píšící panenka" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "romaji": "Violet Evergarden Gaiden: Eien to Jidou Shuki Ningyou", + "english": "Violet Evergarden: Eternity and the Auto Memory Doll", + "native": "ヴァイオレット・エヴァーガーデン 外伝~永遠と自動手記人形~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx109190-e8mv1qdmpjLW.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx109190-e8mv1qdmpjLW.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx109190-e8mv1qdmpjLW.jpg", + "color": "#e4e4a1" + }, + "startDate": { + "year": 2019, + "month": 9, + "day": 6 + }, + "endDate": { + "year": 2019, + "month": 9, + "day": 6 + } + } + } + ] + } + } + }, + { + "id": 389433847, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 114963, + "idMal": 41168, + "siteUrl": "https://anilist.co/anime/114963", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/114963-UveNLlSCDPVM.jpg", + "episodes": 1, + "synonyms": [ + "Nakineko", + "Amor de Gata", + "Loin de moi, près de toi", + "Olhos de Gato", + "Um ein Schnurrhaar", + "Miyo - Un amore felino", + "Для тебя я стану кошкой" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Nakitai Watashi wa Neko wo Kaburu", + "romaji": "Nakitai Watashi wa Neko wo Kaburu", + "english": "A Whisker Away", + "native": "泣きたい私は猫をかぶる" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx114963-QWMbi5ttovSK.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx114963-QWMbi5ttovSK.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx114963-QWMbi5ttovSK.png", + "color": "#bbe45d" + }, + "startDate": { + "year": 2020, + "month": 6, + "day": 18 + }, + "endDate": { + "year": 2020, + "month": 6, + "day": 18 + }, + "relations": { + "edges": [ + { + "relationType": "ADAPTATION", + "node": { + "id": 118686, + "idMal": 134765, + "siteUrl": "https://anilist.co/manga/118686", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "Wanting to Cry, I Pretend to Be a Cat", + "O mały wąs", + "Loin de moi, près de toi" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Nakitai Watashi wa Neko wo Kaburu", + "romaji": "Nakitai Watashi wa Neko wo Kaburu", + "native": "泣きたい私は猫をかぶる" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx118686-bcOYTBwEbZnS.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx118686-bcOYTBwEbZnS.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx118686-bcOYTBwEbZnS.jpg", + "color": "#a1d6f1" + }, + "startDate": { + "year": 2020, + "month": 5, + "day": 15 + }, + "endDate": { + "year": 2021, + "month": 3, + "day": 12 + } + } + } + ] + } + } + }, + { + "id": 389433848, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 30, + "idMal": 30, + "siteUrl": "https://anilist.co/anime/30", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/30-gEMoHHIqxDgN.jpg", + "episodes": 26, + "synonyms": [ + "NGE", + "Eva", + "ניאון ג'נסיס אוונגליון", + "อีวานเกเลียน มหาสงครามวันพิพากษา", + "Евангелион" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion", + "romaji": "Shin Seiki Evangelion", + "english": "Neon Genesis Evangelion", + "native": "新世紀エヴァンゲリオン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx30-wmNoX3m2qTzz.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx30-wmNoX3m2qTzz.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx30-wmNoX3m2qTzz.jpg", + "color": "#e4865d" + }, + "startDate": { + "year": 1995, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 1996, + "month": 3, + "day": 27 + }, + "relations": { + "edges": [ + { + "relationType": "SEQUEL", + "node": { + "id": 32, + "idMal": 32, + "siteUrl": "https://anilist.co/anime/32", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n32-BH9yHJBQqeOa.jpg", + "episodes": 1, + "synonyms": [ + "הסוף של אוונגליון", + "אוונגליון של הסוף", + "Конец Евангелиона" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "romaji": "Shin Seiki Evangelion Movie: Air / Magokoro wo, Kimi ni", + "english": "Neon Genesis Evangelion: The End of Evangelion", + "native": "新世紀エヴァンゲリオン劇場版 Air/まごころを、君に" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx32-i4ijZI4MuPiV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx32-i4ijZI4MuPiV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx32-i4ijZI4MuPiV.jpg", + "color": "#e46b50" + }, + "startDate": { + "year": 1997, + "month": 7, + "day": 19 + }, + "endDate": { + "year": 1997, + "month": 7, + "day": 19 + } + } + }, + { + "relationType": "ADAPTATION", + "node": { + "id": 30698, + "idMal": 698, + "siteUrl": "https://anilist.co/manga/30698", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/30698-c4AljmpCyINA.jpg", + "synonyms": [ + "Shinseiki Evangelion", + "Neogénesis Evangelion", + "เอวานเกเลี่ยน", + "Новый век: Евангелион" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion", + "romaji": "Shin Seiki Evangelion", + "english": "Neon Genesis Evangelion", + "native": "新世紀エヴァンゲリオン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx30698-0niTa3yn2rNK.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30698-0niTa3yn2rNK.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx30698-0niTa3yn2rNK.png", + "color": "#e4935d" + }, + "startDate": { + "year": 1994, + "month": 12, + "day": 26 + }, + "endDate": { + "year": 2013, + "month": 6, + "day": 4 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 31, + "idMal": 31, + "siteUrl": "https://anilist.co/anime/31", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/31-pYM5NdKIRa5h.jpg", + "episodes": 1, + "synonyms": [ + "Evangelion: Death (True)", + "Evangelion: Death (True)²", + "Revival of Evangelion" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion Movie: Shi to Shinsei", + "romaji": "Shin Seiki Evangelion Movie: Shi to Shinsei", + "english": "Neon Genesis Evangelion: Death \u0026 Rebirth", + "native": "新世紀エヴァンゲリオン劇場版 シト新生" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx31-3zRThtzQH62E.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx31-3zRThtzQH62E.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx31-3zRThtzQH62E.png", + "color": "#e4785d" + }, + "startDate": { + "year": 1997, + "month": 3, + "day": 15 + }, + "endDate": { + "year": 1997, + "month": 3, + "day": 15 + } + } + }, + { + "relationType": "SPIN_OFF", + "node": { + "id": 4130, + "idMal": 4130, + "siteUrl": "https://anilist.co/anime/4130", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/4130-s7JBj1RQGhFC.jpg", + "episodes": 24, + "synonyms": [ + "Puchi Eva", + " Eva-School", + "EAS", + "EOS" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Petit Eva: Evangelion@School", + "romaji": "Petit Eva: Evangelion@School", + "native": "ぷちえゔぁ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b4130-cNTifeekohY6.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b4130-cNTifeekohY6.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b4130-cNTifeekohY6.jpg", + "color": "#e49328" + }, + "startDate": { + "year": 2007, + "month": 3, + "day": 20 + }, + "endDate": { + "year": 2009, + "month": 3, + "day": 11 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 102096, + "idMal": 23023, + "siteUrl": "https://anilist.co/anime/102096", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Peaceful Times (F02) Petit Film", + "romaji": "Peaceful Times (F02) Petit Film" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/102096-P27SABRbvxqn.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/102096-P27SABRbvxqn.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/102096-P27SABRbvxqn.jpg" + }, + "startDate": { + "year": 2013, + "month": 11, + "day": 23 + }, + "endDate": { + "year": 2013, + "month": 11, + "day": 23 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 55594, + "idMal": 25594, + "siteUrl": "https://anilist.co/manga/55594", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/55594-t9Lm9lTXlyLO.jpg", + "synonyms": [ + "Evangelion Anima" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion: ANIMA", + "romaji": "Evangelion: ANIMA", + "english": "Neon Genesis Evangelion: ANIMA", + "native": "エヴァンゲリオン ANIMA" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx55594-d0muWcognBKS.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx55594-d0muWcognBKS.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx55594-d0muWcognBKS.jpg", + "color": "#e40d1a" + }, + "startDate": { + "year": 2007, + "month": 11, + "day": 24 + }, + "endDate": { + "year": 2013, + "month": 2, + "day": 25 + } + } + }, + { + "relationType": "SPIN_OFF", + "node": { + "id": 101195, + "idMal": 99614, + "siteUrl": "https://anilist.co/manga/101195", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shin Seiki Evangelion: Pikopiko Chuugakusei Densetsu", + "romaji": "Shin Seiki Evangelion: Pikopiko Chuugakusei Densetsu", + "english": "Neon Genesis Evangelion: Legend of the Piko Piko Middle School Students", + "native": "新世紀エヴァンゲリオン ピコピコ中学生伝説" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx101195-dFpQ9JBSnil0.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx101195-dFpQ9JBSnil0.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx101195-dFpQ9JBSnil0.jpg", + "color": "#f1ae5d" + }, + "startDate": { + "year": 2014, + "month": 4, + "day": 4 + }, + "endDate": { + "year": 2018, + "month": 8, + "day": 4 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 125785, + "idMal": 43751, + "siteUrl": "https://anilist.co/anime/125785", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/125785-2ynUB7SVV300.jpg", + "episodes": 1, + "synonyms": [ + "KATE 「綾波レイ、はじめての口紅」", + "Kate: Rei Ayanami, hajimete no kuchibeni", + "Rei Ayanami, First Lipstick", + "KATE 「綾波レイ、はじめての口紅、その後」", + "Kate: Rei Ayanami, hajimete no kuchibeni, sonogo" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion x KATE CM", + "romaji": "Evangelion x KATE CM", + "native": "エヴァンゲリオン x KATE CM" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx125785-ocT9A7Lsiui2.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx125785-ocT9A7Lsiui2.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx125785-ocT9A7Lsiui2.png", + "color": "#e45dae" + }, + "startDate": { + "year": 2020, + "month": 11, + "day": 3 + }, + "endDate": { + "year": 2020, + "month": 11, + "day": 3 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 125796, + "idMal": 43745, + "siteUrl": "https://anilist.co/anime/125796", + "status": "FINISHED", + "type": "ANIME", + "format": "ONA", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Evangelion × Attack ZERO", + "romaji": "Evangelion × Attack ZERO", + "native": "エヴァンゲリオン × アタックZERO" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx125796-SZ4SfxqcSwUJ.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx125796-SZ4SfxqcSwUJ.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx125796-SZ4SfxqcSwUJ.png", + "color": "#78aed6" + }, + "startDate": { + "year": 2020, + "month": 11, + "day": 2 + }, + "endDate": { + "year": 2020, + "month": 11, + "day": 2 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 100620, + "idMal": 36531, + "siteUrl": "https://anilist.co/anime/100620", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/100620-L63Lj2TUaFNn.jpg", + "episodes": 76, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shinkansen Henkei Robo Shinkalion", + "romaji": "Shinkansen Henkei Robo Shinkalion", + "native": "新幹線変形ロボ シンカリオン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx100620-VnjYL5B1kdTf.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx100620-VnjYL5B1kdTf.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx100620-VnjYL5B1kdTf.jpg", + "color": "#5dbbe4" + }, + "startDate": { + "year": 2018, + "month": 1, + "day": 6 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 29 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 110131, + "idMal": 39967, + "siteUrl": "https://anilist.co/anime/110131", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "episodes": 1, + "synonyms": [ + "Shinkansen-Transforming Robot Shinkalion the Movie: The Mythically Fast ALFA-X That Came From Future", + "Shinkalion the Movie: Godspeed ALFA-X That Comes from the Future", + "Shinkansen-Transforming Robot Shinkalion the Movie:The Marvelous Fast ALFA-X That Came From Future" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shinkansen Henkei Robo Shinkalion: Mirai kara Kita Shinsoku no ALFA-X", + "romaji": "Shinkansen Henkei Robo Shinkalion: Mirai kara Kita Shinsoku no ALFA-X", + "native": "新幹線変形ロボ シンカリオン 未来からきた神速のALFA-X" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx110131-byIGt5Ng6yvk.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx110131-byIGt5Ng6yvk.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx110131-byIGt5Ng6yvk.jpg", + "color": "#35aee4" + }, + "startDate": { + "year": 2019, + "month": 12, + "day": 27 + }, + "endDate": { + "year": 2019, + "month": 12, + "day": 27 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 128737, + "idMal": 46381, + "siteUrl": "https://anilist.co/anime/128737", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "episodes": 41, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shinkansen Henkei Robo Shinkalion Z", + "romaji": "Shinkansen Henkei Robo Shinkalion Z", + "native": "新幹線変形ロボ シンカリオンZ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx128737-CI5WQAM4LXv6.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx128737-CI5WQAM4LXv6.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx128737-CI5WQAM4LXv6.jpg", + "color": "#1aaee4" + }, + "startDate": { + "year": 2021, + "month": 4, + "day": 9 + }, + "endDate": { + "year": 2022, + "month": 3, + "day": 18 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 171643, + "siteUrl": "https://anilist.co/manga/171643", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "It's A Miraculous Win" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Mogami Sakura no Detama Hokan Keikaku - Kiseki no Kachi wa", + "romaji": "Mogami Sakura no Detama Hokan Keikaku - Kiseki no Kachi wa", + "native": "最上さくらの出玉補完計画 奇跡の勝ちは" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/b171643-cO6LXBp2Z0qZ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/b171643-cO6LXBp2Z0qZ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/b171643-cO6LXBp2Z0qZ.jpg", + "color": "#f150bb" + }, + "startDate": { + "year": 2006, + "month": 10, + "day": 20 + }, + "endDate": { + "year": 2006, + "month": 10, + "day": 20 + } + } + } + ] + } + } + }, + { + "id": 389450904, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21311, + "idMal": 31478, + "siteUrl": "https://anilist.co/anime/21311", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21311-oVJYXoU38Lm5.jpg", + "episodes": 12, + "synonyms": [ + "כלבי ספרות נודדים", + "Văn hào lưu lạc", + "คณะประพันธกรจรจัด" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21311-hAXyT8Yoh6G9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21311-hAXyT8Yoh6G9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21311-hAXyT8Yoh6G9.jpg", + "color": "#e4bb50" + }, + "startDate": { + "year": 2016, + "month": 4, + "day": 7 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 85307, + "idMal": 56529, + "siteUrl": "https://anilist.co/manga/85307", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85307-HPlNkWrkD0nk.jpg", + "synonyms": [ + "Literary Stray Dogs", + "Bezpańscy literaci", + "文豪野犬", + "Проза бродячих псов", + "คณะประพันธกรจรจัด", + "Văn hào lưu lạc", + "문호 스트레이독스", + "文豪Stray Dogs" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85307-cfwdYRhtHRpD.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85307-cfwdYRhtHRpD.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85307-cfwdYRhtHRpD.png" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 4 + }, + "endDate": {} + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 21679, + "idMal": 32867, + "siteUrl": "https://anilist.co/anime/21679", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21679-cOXDcrmMGXBy.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2016)", + "คณะประพันธกรจรจัด ภาค 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 2nd Season", + "romaji": "Bungou Stray Dogs 2nd Season", + "english": "Bungo Stray Dogs 2", + "native": "文豪ストレイドッグス 第2シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21679-9MKdz1A7YLV7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21679-9MKdz1A7YLV7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21679-9MKdz1A7YLV7.jpg", + "color": "#f1e4ae" + }, + "startDate": { + "year": 2016, + "month": 10, + "day": 6 + }, + "endDate": { + "year": 2016, + "month": 12, + "day": 16 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 98384, + "idMal": 34944, + "siteUrl": "https://anilist.co/anime/98384", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/98384-qxrZmUiAl2Y5.jpg", + "episodes": 1, + "synonyms": [ + "Bungou Stray Dogs Movie" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: DEAD APPLE", + "romaji": "Bungou Stray Dogs: DEAD APPLE", + "english": "Bungo Stray Dogs: DEAD APPLE", + "native": "文豪ストレイドッグス DEAD APPLE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx98384-nXEnNzwiJ9BV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx98384-nXEnNzwiJ9BV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx98384-nXEnNzwiJ9BV.jpg", + "color": "#28a1e4" + }, + "startDate": { + "year": 2018, + "month": 3, + "day": 3 + }, + "endDate": { + "year": 2018, + "month": 3, + "day": 3 + } + } + }, + { + "relationType": "SPIN_OFF", + "node": { + "id": 120150, + "idMal": 42250, + "siteUrl": "https://anilist.co/anime/120150", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV_SHORT", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/120150-bkyCLDwgKG88.jpg", + "episodes": 12, + "synonyms": [ + "คณะประพันธกรจรจัด โฮ่ง!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs Wan!", + "romaji": "Bungou Stray Dogs Wan!", + "english": "Bungo Stray Dogs WAN!", + "native": "文豪ストレイドッグス わん!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx120150-hxvcRrYzgP2F.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx120150-hxvcRrYzgP2F.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx120150-hxvcRrYzgP2F.png", + "color": "#f1865d" + }, + "startDate": { + "year": 2021, + "month": 1, + "day": 13 + }, + "endDate": { + "year": 2021, + "month": 3, + "day": 31 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 97204, + "idMal": 98380, + "siteUrl": "https://anilist.co/manga/97204", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/97204-zBoCPdeq1L6Y.jpg", + "synonyms": [ + "Bungo Stray Dogs Novel 1", + "Văn Hào Lưu Lạc 1 - Dazai Osamu và sát hạch đầu vào", + "คณะประพันธกรจรจัด 1 ตอน การสอบเข้าสำนักงานของดาไซ โอซามุ ", + "Bungou Stray Dogs: Egzamin wstępny Osamu Dazaia " + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Dazai Osamu no Nyuusha Shiken", + "romaji": "Bungou Stray Dogs: Dazai Osamu no Nyuusha Shiken", + "english": "Bungo Stray Dogs: Osamu Dazai's Entrance Exam", + "native": "文豪ストレイドッグス 太宰治の入社試験" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx97204-siqW5Q2bJ7yH.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx97204-siqW5Q2bJ7yH.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx97204-siqW5Q2bJ7yH.jpg", + "color": "#e4bba1" + }, + "startDate": { + "year": 2014, + "month": 4, + "day": 1 + }, + "endDate": { + "year": 2014, + "month": 4, + "day": 1 + } + } + } + ] + } + } + }, + { + "id": 389450905, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 103223, + "idMal": 38003, + "siteUrl": "https://anilist.co/anime/103223", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/103223-noS5nsDOI1Qu.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2019)", + "BSD 3", + "BungouSD 3", + "คณะประพันธกรจรจัด ภาค 3", + "文豪野犬 第三季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 3rd Season", + "romaji": "Bungou Stray Dogs 3rd Season", + "english": "Bungo Stray Dogs 3", + "native": "文豪ストレイドッグス 第3シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx103223-bfdnnKWxE4YE.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx103223-bfdnnKWxE4YE.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx103223-bfdnnKWxE4YE.jpg" + }, + "startDate": { + "year": 2019, + "month": 4, + "day": 12 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 28 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 21679, + "idMal": 32867, + "siteUrl": "https://anilist.co/anime/21679", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21679-cOXDcrmMGXBy.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2016)", + "คณะประพันธกรจรจัด ภาค 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 2nd Season", + "romaji": "Bungou Stray Dogs 2nd Season", + "english": "Bungo Stray Dogs 2", + "native": "文豪ストレイドッグス 第2シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21679-9MKdz1A7YLV7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21679-9MKdz1A7YLV7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21679-9MKdz1A7YLV7.jpg", + "color": "#f1e4ae" + }, + "startDate": { + "year": 2016, + "month": 10, + "day": 6 + }, + "endDate": { + "year": 2016, + "month": 12, + "day": 16 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 85307, + "idMal": 56529, + "siteUrl": "https://anilist.co/manga/85307", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85307-HPlNkWrkD0nk.jpg", + "synonyms": [ + "Literary Stray Dogs", + "Bezpańscy literaci", + "文豪野犬", + "Проза бродячих псов", + "คณะประพันธกรจรจัด", + "Văn hào lưu lạc", + "문호 스트레이독스", + "文豪Stray Dogs" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85307-cfwdYRhtHRpD.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85307-cfwdYRhtHRpD.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85307-cfwdYRhtHRpD.png" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 4 + }, + "endDate": {} + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 123126, + "siteUrl": "https://anilist.co/manga/123126", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Bungo Stray Dogs Novel 7" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Dazai, Chuuya, Juugosai", + "romaji": "Bungou Stray Dogs: Dazai, Chuuya, Juugosai", + "english": "Bungo Stray Dogs: Dazai, Chuuya, Age Fifteen", + "native": "文豪ストレイドッグス 太宰, 中也, 十五歳" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx123126-XqfQktzT3nWO.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx123126-XqfQktzT3nWO.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx123126-XqfQktzT3nWO.jpg" + }, + "startDate": { + "year": 2019, + "month": 8, + "day": 1 + }, + "endDate": { + "year": 2019, + "month": 8, + "day": 1 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 141249, + "idMal": 50330, + "siteUrl": "https://anilist.co/anime/141249", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/141249-ssUG44UgGOMK.jpg", + "episodes": 13, + "synonyms": [ + "BSD 4", + "BungouSD 4", + "คณะประพันธกรจรจัด ภาค 4", + "文豪野犬第四季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 4th Season", + "romaji": "Bungou Stray Dogs 4th Season", + "english": "Bungo Stray Dogs 4", + "native": "文豪ストレイドッグス 第4シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx141249-8tjavEDHmLoT.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx141249-8tjavEDHmLoT.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx141249-8tjavEDHmLoT.jpg", + "color": "#fe5d50" + }, + "startDate": { + "year": 2023, + "month": 1, + "day": 4 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 29 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 155713, + "idMal": 152024, + "siteUrl": "https://anilist.co/manga/155713", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "Bungou Stray Dogs: Dazai, Chuuya, Age Fifteen", + "문호 스트레이독스 -다자이, 추야 15세-" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Dazai, Chuuya, Juugosai", + "romaji": "Bungou Stray Dogs: Dazai, Chuuya, Juugosai", + "english": "Bungo Stray Dogs: Dazai, Chuuya, Age Fifteen", + "native": "文豪ストレイドッグス 太宰、中也、十五歳" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx155713-uwAaRC3g7AGI.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx155713-uwAaRC3g7AGI.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx155713-uwAaRC3g7AGI.jpg", + "color": "#3586ff" + }, + "startDate": { + "year": 2022, + "month": 9, + "day": 26 + }, + "endDate": {} + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21791, + "idMal": 33071, + "siteUrl": "https://anilist.co/anime/21791", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "OVA", + "episodes": 1, + "synonyms": [ + "Bungou Stray Dogs 2 OVA", + "Bungou Stray Dogs 2: Episode 13" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Hitori Ayumu", + "romaji": "Bungou Stray Dogs: Hitori Ayumu", + "english": "Bungo Stray Dogs 2: Walking Alone", + "native": "文豪ストレイドッグス 『独り歩む』;" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21791-OIFsu8KC77Da.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21791-OIFsu8KC77Da.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21791-OIFsu8KC77Da.jpg", + "color": "#d6861a" + }, + "startDate": { + "year": 2017, + "month": 8, + "day": 4 + }, + "endDate": { + "year": 2017, + "month": 8, + "day": 4 + } + } + } + ] + } + } + }, + { + "id": 389450909, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 141249, + "idMal": 50330, + "siteUrl": "https://anilist.co/anime/141249", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/141249-ssUG44UgGOMK.jpg", + "episodes": 13, + "synonyms": [ + "BSD 4", + "BungouSD 4", + "คณะประพันธกรจรจัด ภาค 4", + "文豪野犬第四季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 4th Season", + "romaji": "Bungou Stray Dogs 4th Season", + "english": "Bungo Stray Dogs 4", + "native": "文豪ストレイドッグス 第4シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx141249-8tjavEDHmLoT.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx141249-8tjavEDHmLoT.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx141249-8tjavEDHmLoT.jpg", + "color": "#fe5d50" + }, + "startDate": { + "year": 2023, + "month": 1, + "day": 4 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 29 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 103223, + "idMal": 38003, + "siteUrl": "https://anilist.co/anime/103223", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/103223-noS5nsDOI1Qu.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2019)", + "BSD 3", + "BungouSD 3", + "คณะประพันธกรจรจัด ภาค 3", + "文豪野犬 第三季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 3rd Season", + "romaji": "Bungou Stray Dogs 3rd Season", + "english": "Bungo Stray Dogs 3", + "native": "文豪ストレイドッグス 第3シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx103223-bfdnnKWxE4YE.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx103223-bfdnnKWxE4YE.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx103223-bfdnnKWxE4YE.jpg" + }, + "startDate": { + "year": 2019, + "month": 4, + "day": 12 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 28 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 85307, + "idMal": 56529, + "siteUrl": "https://anilist.co/manga/85307", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85307-HPlNkWrkD0nk.jpg", + "synonyms": [ + "Literary Stray Dogs", + "Bezpańscy literaci", + "文豪野犬", + "Проза бродячих псов", + "คณะประพันธกรจรจัด", + "Văn hào lưu lạc", + "문호 스트레이독스", + "文豪Stray Dogs" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85307-cfwdYRhtHRpD.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85307-cfwdYRhtHRpD.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85307-cfwdYRhtHRpD.png" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 4 + }, + "endDate": {} + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 123118, + "siteUrl": "https://anilist.co/manga/123118", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Bungo Stray Dogs Novel 3", + "คณะประพันธกรจรจัด 3 ตอน เรื่องลับเบื้องหลังการก่อตั้งสำนักงานนักสืบ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Tanteisha Setsuritsu Hiwa", + "romaji": "Bungou Stray Dogs: Tanteisha Setsuritsu Hiwa", + "english": "Bungo Stray Dogs: The Untold Origins of the Detective Agency", + "native": "文豪ストレイドッグス 探偵社設立秘話" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx123118-bc8a8nYKQ2D5.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx123118-bc8a8nYKQ2D5.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx123118-bc8a8nYKQ2D5.jpg", + "color": "#864328" + }, + "startDate": { + "year": 2015, + "month": 5, + "day": 1 + }, + "endDate": { + "year": 2015, + "month": 5, + "day": 1 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 163263, + "idMal": 54898, + "siteUrl": "https://anilist.co/anime/163263", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163263-cx1zOVfatLxW.jpg", + "episodes": 11, + "synonyms": [ + "BSD 5" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 5th Season", + "romaji": "Bungou Stray Dogs 5th Season", + "english": "Bungo Stray Dogs 5", + "native": "文豪ストレイドッグス 第5シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163263-uz881pFAyi7P.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163263-uz881pFAyi7P.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163263-uz881pFAyi7P.jpg", + "color": "#e41aa1" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 12 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 20 + } + } + } + ] + } + } + }, + { + "id": 389450910, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21679, + "idMal": 32867, + "siteUrl": "https://anilist.co/anime/21679", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21679-cOXDcrmMGXBy.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2016)", + "คณะประพันธกรจรจัด ภาค 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 2nd Season", + "romaji": "Bungou Stray Dogs 2nd Season", + "english": "Bungo Stray Dogs 2", + "native": "文豪ストレイドッグス 第2シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21679-9MKdz1A7YLV7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21679-9MKdz1A7YLV7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21679-9MKdz1A7YLV7.jpg", + "color": "#f1e4ae" + }, + "startDate": { + "year": 2016, + "month": 10, + "day": 6 + }, + "endDate": { + "year": 2016, + "month": 12, + "day": 16 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 21311, + "idMal": 31478, + "siteUrl": "https://anilist.co/anime/21311", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21311-oVJYXoU38Lm5.jpg", + "episodes": 12, + "synonyms": [ + "כלבי ספרות נודדים", + "Văn hào lưu lạc", + "คณะประพันธกรจรจัด" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21311-hAXyT8Yoh6G9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21311-hAXyT8Yoh6G9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21311-hAXyT8Yoh6G9.jpg", + "color": "#e4bb50" + }, + "startDate": { + "year": 2016, + "month": 4, + "day": 7 + }, + "endDate": { + "year": 2016, + "month": 6, + "day": 23 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 85307, + "idMal": 56529, + "siteUrl": "https://anilist.co/manga/85307", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/85307-HPlNkWrkD0nk.jpg", + "synonyms": [ + "Literary Stray Dogs", + "Bezpańscy literaci", + "文豪野犬", + "Проза бродячих псов", + "คณะประพันธกรจรจัด", + "Văn hào lưu lạc", + "문호 스트레이독스", + "文豪Stray Dogs" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs", + "romaji": "Bungou Stray Dogs", + "english": "Bungo Stray Dogs", + "native": "文豪ストレイドッグス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx85307-cfwdYRhtHRpD.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx85307-cfwdYRhtHRpD.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx85307-cfwdYRhtHRpD.png" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 4 + }, + "endDate": {} + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 103223, + "idMal": 38003, + "siteUrl": "https://anilist.co/anime/103223", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/103223-noS5nsDOI1Qu.jpg", + "episodes": 12, + "synonyms": [ + "Bungou Stray Dogs (2019)", + "BSD 3", + "BungouSD 3", + "คณะประพันธกรจรจัด ภาค 3", + "文豪野犬 第三季" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs 3rd Season", + "romaji": "Bungou Stray Dogs 3rd Season", + "english": "Bungo Stray Dogs 3", + "native": "文豪ストレイドッグス 第3シーズン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx103223-bfdnnKWxE4YE.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx103223-bfdnnKWxE4YE.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx103223-bfdnnKWxE4YE.jpg" + }, + "startDate": { + "year": 2019, + "month": 4, + "day": 12 + }, + "endDate": { + "year": 2019, + "month": 6, + "day": 28 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 98384, + "idMal": 34944, + "siteUrl": "https://anilist.co/anime/98384", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/98384-qxrZmUiAl2Y5.jpg", + "episodes": 1, + "synonyms": [ + "Bungou Stray Dogs Movie" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: DEAD APPLE", + "romaji": "Bungou Stray Dogs: DEAD APPLE", + "english": "Bungo Stray Dogs: DEAD APPLE", + "native": "文豪ストレイドッグス DEAD APPLE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx98384-nXEnNzwiJ9BV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx98384-nXEnNzwiJ9BV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx98384-nXEnNzwiJ9BV.jpg", + "color": "#28a1e4" + }, + "startDate": { + "year": 2018, + "month": 3, + "day": 3 + }, + "endDate": { + "year": 2018, + "month": 3, + "day": 3 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 123117, + "siteUrl": "https://anilist.co/manga/123117", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "synonyms": [ + "Bungo Stray Dogs Novel 2", + "คณะประพันธกรจรจัด 2 ตอน ดาไซ โอซามุ กับยุคมืด", + "Bungou Stray Dogs: Mroczna przeszłość Osamu Dazaia" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Bungou Stray Dogs: Dazai Osamu to Kuro no Jidai", + "romaji": "Bungou Stray Dogs: Dazai Osamu to Kuro no Jidai", + "english": "Bungo Stray Dogs: Osamu Dazai and The Dark Era", + "native": "文豪ストレイドッグス 太宰治と黒の時代" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx123117-bCfUg3y1tns9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx123117-bCfUg3y1tns9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx123117-bCfUg3y1tns9.jpg" + }, + "startDate": { + "year": 2014, + "month": 8, + "day": 1 + }, + "endDate": { + "year": 2014, + "month": 8, + "day": 1 + } + } + } + ] + } + } + }, + { + "id": 391683774, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 109125, + "idMal": 39753, + "siteUrl": "https://anilist.co/anime/109125", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/109125-npZ3STlicS3a.jpg", + "episodes": 1, + "synonyms": [ + "Love, Be Loved, Leave, Be Left", + "Love Me, Love Me Not", + "Любит — не любит" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Omoi, Omoware, Furi, Furare", + "romaji": "Omoi, Omoware, Furi, Furare", + "native": "思い、思われ、ふり、ふられ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx109125-LRhTuc4RMct7.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx109125-LRhTuc4RMct7.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx109125-LRhTuc4RMct7.jpg", + "color": "#5daef1" + }, + "startDate": { + "year": 2020, + "month": 9, + "day": 18 + }, + "endDate": { + "year": 2020, + "month": 9, + "day": 18 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 86219, + "idMal": 89217, + "siteUrl": "https://anilist.co/manga/86219", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/86219-2bVGFlmTfEpX.jpg", + "synonyms": [ + "Furi Fura - Amores e Desenganos", + "Amar y Ser Amado, Dejar y Ser Dejado", + "Kocha...nie kocha...", + "Love, be loved Leave, be left" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Omoi, Omoware, Furi, Furare", + "romaji": "Omoi, Omoware, Furi, Furare", + "english": "Love Me, Love Me Not", + "native": "思い、思われ、ふり、ふられ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx86219-2p8Ky38bTQNv.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx86219-2p8Ky38bTQNv.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx86219-2p8Ky38bTQNv.png", + "color": "#f1d65d" + }, + "startDate": { + "year": 2015, + "month": 6, + "day": 13 + }, + "endDate": { + "year": 2019, + "month": 5, + "day": 13 + } + } + } + ] + } + } + }, + { + "id": 391683775, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 100305, + "idMal": 36539, + "siteUrl": "https://anilist.co/anime/100305", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/100305-Z6cZFkrwzf2L.jpg", + "episodes": 1, + "synonyms": [ + "as the moon so beautiful" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tsuki ga Kirei Special", + "romaji": "Tsuki ga Kirei Special", + "native": "月がきれい" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx100305-CRu6RZ5eINHP.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx100305-CRu6RZ5eINHP.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx100305-CRu6RZ5eINHP.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2017, + "month": 9, + "day": 27 + }, + "endDate": { + "year": 2017, + "month": 9, + "day": 27 + }, + "relations": { + "edges": [ + { + "relationType": "PARENT", + "node": { + "id": 98202, + "idMal": 34822, + "siteUrl": "https://anilist.co/anime/98202", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/98202-JlZYSjYB8pux.jpg", + "episodes": 12, + "synonyms": [ + "as the moon, so beautiful." + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tsuki ga Kirei", + "romaji": "Tsuki ga Kirei", + "english": "Tsukigakirei", + "native": "月がきれい" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx98202-H6RtsIMZPALF.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx98202-H6RtsIMZPALF.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx98202-H6RtsIMZPALF.png", + "color": "#1a506b" + }, + "startDate": { + "year": 2017, + "month": 4, + "day": 7 + }, + "endDate": { + "year": 2017, + "month": 6, + "day": 30 + } + } + } + ] + } + } + }, + { + "id": 392285307, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 98202, + "idMal": 34822, + "siteUrl": "https://anilist.co/anime/98202", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/98202-JlZYSjYB8pux.jpg", + "episodes": 12, + "synonyms": [ + "as the moon, so beautiful." + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tsuki ga Kirei", + "romaji": "Tsuki ga Kirei", + "english": "Tsukigakirei", + "native": "月がきれい" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx98202-H6RtsIMZPALF.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx98202-H6RtsIMZPALF.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx98202-H6RtsIMZPALF.png", + "color": "#1a506b" + }, + "startDate": { + "year": 2017, + "month": 4, + "day": 7 + }, + "endDate": { + "year": 2017, + "month": 6, + "day": 30 + }, + "relations": { + "edges": [ + { + "relationType": "SIDE_STORY", + "node": { + "id": 100305, + "idMal": 36539, + "siteUrl": "https://anilist.co/anime/100305", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/100305-Z6cZFkrwzf2L.jpg", + "episodes": 1, + "synonyms": [ + "as the moon so beautiful" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tsuki ga Kirei Special", + "romaji": "Tsuki ga Kirei Special", + "native": "月がきれい" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx100305-CRu6RZ5eINHP.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx100305-CRu6RZ5eINHP.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx100305-CRu6RZ5eINHP.jpg", + "color": "#f1e4c9" + }, + "startDate": { + "year": 2017, + "month": 9, + "day": 27 + }, + "endDate": { + "year": 2017, + "month": 9, + "day": 27 + } + } + } + ] + } + } + }, + { + "id": 394257542, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2024, + "month": 3, + "day": 5 + }, + "completedAt": {}, + "media": { + "id": 16782, + "idMal": 16782, + "siteUrl": "https://anilist.co/anime/16782", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/16782.jpg", + "episodes": 1, + "synonyms": [ + "Koto no Ha no Niwa", + "The Garden of Kotonoha", + "El Jardín de las Palabras", + "A szavak kertje", + "ยามสายฝนโปรยปราย", + "Ogród słów", + "Сад изящных слов", + "Il giardino delle parole" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kotonoha no Niwa", + "romaji": "Kotonoha no Niwa", + "english": "The Garden of Words", + "native": "言の葉の庭" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx16782-1AekGIzlPy8a.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx16782-1AekGIzlPy8a.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx16782-1AekGIzlPy8a.jpg", + "color": "#93e45d" + }, + "startDate": { + "year": 2013, + "month": 5, + "day": 31 + }, + "endDate": { + "year": 2013, + "month": 5, + "day": 31 + }, + "relations": { + "edges": [ + { + "relationType": "ADAPTATION", + "node": { + "id": 81747, + "idMal": 51747, + "siteUrl": "https://anilist.co/manga/81747", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "Koto no Ha no Niwa", + "El Jardín de las Palabras", + "Ogród słów", + "O Jardim das Palavras" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kotonoha no Niwa", + "romaji": "Kotonoha no Niwa", + "english": "The Garden of Words", + "native": "言の葉の庭" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx81747-1rb8wa2nuZyj.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx81747-1rb8wa2nuZyj.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx81747-1rb8wa2nuZyj.jpg", + "color": "#e45d93" + }, + "startDate": { + "year": 2013, + "month": 4, + "day": 25 + }, + "endDate": { + "year": 2013, + "month": 10, + "day": 25 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 21519, + "idMal": 32281, + "siteUrl": "https://anilist.co/anime/21519", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21519-1ayMXgNlmByb.jpg", + "episodes": 1, + "synonyms": [ + "Your Name. - Gestern, heute und für immer ", + "Mi a Neved? ", + "你的名字。", + "너의 이름은.", + "Tu nombre", + "Твоё имя", + "หลับตาฝันถึงชื่อเธอ", + "Il tuo nome", + "השם שלך." + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kimi no Na wa.", + "romaji": "Kimi no Na wa.", + "english": "Your Name.", + "native": "君の名は。" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21519-XIr3PeczUjjF.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21519-XIr3PeczUjjF.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21519-XIr3PeczUjjF.png", + "color": "#0da1e4" + }, + "startDate": { + "year": 2016, + "month": 8, + "day": 26 + }, + "endDate": { + "year": 2016, + "month": 8, + "day": 26 + } + } + } + ] + } + } + }, + { + "id": 394259727, + "score": 0, + "progress": 3, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 142838, + "idMal": 50602, + "siteUrl": "https://anilist.co/anime/142838", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/142838-tynuN00wxmKO.jpg", + "episodes": 13, + "synonyms": [ + "SxF", + "스파이 패밀리", + "间谍过家家", + "スパイファミリー 2クール" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "SPY×FAMILY Part 2", + "romaji": "SPY×FAMILY Part 2", + "english": "SPY x FAMILY Cour 2", + "native": "SPY×FAMILY 第2クール" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx142838-ECZSqfknAqAT.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx142838-ECZSqfknAqAT.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx142838-ECZSqfknAqAT.jpg", + "color": "#28bbfe" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 1 + }, + "endDate": { + "year": 2022, + "month": 12, + "day": 24 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 108556, + "idMal": 119161, + "siteUrl": "https://anilist.co/manga/108556", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/108556-iCiPfU0GU4OM.jpg", + "synonyms": [ + "SxF", + "스파이 패밀리", + "SPY×FAMILY: WJ Special Extra Mission!!", + "间谍过家家", + "スパイファミリー", + "Семья шпиона", + "SPY×FAMILY 間諜家家酒" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "SPY×FAMILY", + "romaji": "SPY×FAMILY", + "english": "SPY x FAMILY", + "native": "SPY×FAMILY" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx108556-NHjkz0BNJhLx.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx108556-NHjkz0BNJhLx.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx108556-NHjkz0BNJhLx.jpg", + "color": "#e43543" + }, + "startDate": { + "year": 2019, + "month": 3, + "day": 25 + }, + "endDate": {} + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 140960, + "idMal": 50265, + "siteUrl": "https://anilist.co/anime/140960", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/140960-Z7xSvkRxHKfj.jpg", + "episodes": 12, + "synonyms": [ + "SxF", + "스파이 패밀리", + "间谍过家家", + "Семья шпиона", + "سباي إكس فاميلي" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "SPY×FAMILY", + "romaji": "SPY×FAMILY", + "english": "SPY x FAMILY", + "native": "SPY×FAMILY" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx140960-vN39AmOWrVB5.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx140960-vN39AmOWrVB5.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx140960-vN39AmOWrVB5.jpg", + "color": "#c9f1f1" + }, + "startDate": { + "year": 2022, + "month": 4, + "day": 9 + }, + "endDate": { + "year": 2022, + "month": 6, + "day": 25 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 158927, + "idMal": 53887, + "siteUrl": "https://anilist.co/anime/158927", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/158927-zXtbXUO5iKzX.jpg", + "episodes": 12, + "synonyms": [ + "SxF 2", + "스파이 패밀리", + "Семья шпиона" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "SPY×FAMILY Season 2", + "romaji": "SPY×FAMILY Season 2", + "english": "SPY x FAMILY Season 2", + "native": "SPY×FAMILY Season 2" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b158927-lfO85WVguYgc.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b158927-lfO85WVguYgc.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b158927-lfO85WVguYgc.png", + "color": "#c9f1f1" + }, + "startDate": { + "year": 2023, + "month": 10, + "day": 7 + }, + "endDate": { + "year": 2023, + "month": 12, + "day": 23 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 158928, + "idMal": 53888, + "siteUrl": "https://anilist.co/anime/158928", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/158928-PYHI9eHkC1YQ.jpg", + "episodes": 1, + "synonyms": [ + "SxF Movie", + "劇場版 スパイファミリー" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "SPY×FAMILY CODE: White", + "romaji": "SPY×FAMILY CODE: White", + "english": "SPY x FAMILY CODE: White ", + "native": "SPY×FAMILY CODE: White" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx158928-CJ8fFwUQOPbu.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx158928-CJ8fFwUQOPbu.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx158928-CJ8fFwUQOPbu.jpg", + "color": "#5dbbe4" + }, + "startDate": { + "year": 2023, + "month": 12, + "day": 22 + }, + "endDate": { + "year": 2023, + "month": 12, + "day": 22 + } + } + } + ] + } + } + }, + { + "id": 389433700, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2024, + "month": 3, + "day": 2 + }, + "completedAt": {}, + "media": { + "id": 142343, + "idMal": 50528, + "siteUrl": "https://anilist.co/anime/142343", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "episodes": 13, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy 4th Season", + "romaji": "Golden Kamuy 4th Season", + "english": "Golden Kamuy Season 4", + "native": "ゴールデンカムイ 第四期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx142343-o8gkjIF434qZ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx142343-o8gkjIF434qZ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx142343-o8gkjIF434qZ.jpg" + }, + "startDate": { + "year": 2022, + "month": 10, + "day": 3 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 26 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 110355, + "idMal": 40059, + "siteUrl": "https://anilist.co/anime/110355", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/110355-NjeP9BVLSWG4.jpg", + "episodes": 12, + "synonyms": [ + "Golden Kamui 3" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy 3rd Season", + "romaji": "Golden Kamuy 3rd Season", + "english": "Golden Kamuy Season 3", + "native": "ゴールデンカムイ 第三期" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx110355-yXOXm5tr8kgr.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx110355-yXOXm5tr8kgr.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx110355-yXOXm5tr8kgr.png" + }, + "startDate": { + "year": 2020, + "month": 10, + "day": 5 + }, + "endDate": { + "year": 2020, + "month": 12, + "day": 21 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 86559, + "idMal": 85968, + "siteUrl": "https://anilist.co/manga/86559", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/86559-hHgBaQktjtsd.jpg", + "synonyms": [ + "Golden Kamui" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy", + "romaji": "Golden Kamuy", + "english": "Golden Kamuy", + "native": "ゴールデンカムイ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx86559-YbAt5jybSKyX.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx86559-YbAt5jybSKyX.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx86559-YbAt5jybSKyX.jpg", + "color": "#e4861a" + }, + "startDate": { + "year": 2014, + "month": 8, + "day": 21 + }, + "endDate": { + "year": 2022, + "month": 4, + "day": 28 + } + } + }, + { + "relationType": "SPIN_OFF", + "node": { + "id": 106506, + "idMal": 37755, + "siteUrl": "https://anilist.co/anime/106506", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n106506-Eb40WFgbVOQE.jpg", + "episodes": 46, + "synonyms": [ + "Golden Kamuy Short Anime: Golden Douga Gekijou" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy: Golden Douga Gekijou", + "romaji": "Golden Kamuy: Golden Douga Gekijou", + "native": "ゴールデンカムイ 「ゴールデン道画劇場」" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106506-wTcpHendmbZA.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106506-wTcpHendmbZA.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/n106506-wTcpHendmbZA.jpg", + "color": "#f1d643" + }, + "startDate": { + "year": 2018, + "month": 4, + "day": 16 + }, + "endDate": { + "year": 2023, + "month": 6, + "day": 27 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 166521, + "idMal": 55772, + "siteUrl": "https://anilist.co/anime/166521", + "status": "NOT_YET_RELEASED", + "type": "ANIME", + "format": "TV", + "synonyms": [ + "Golden Kamuy Season 5", + "Golden Kamuy: The Final Chapter" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Golden Kamuy: Saishuu Shou", + "romaji": "Golden Kamuy: Saishuu Shou", + "native": "ゴールデンカムイ 最終章" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx166521-C43c7jzYmEUw.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx166521-C43c7jzYmEUw.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx166521-C43c7jzYmEUw.jpg", + "color": "#f1785d" + }, + "startDate": {}, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433721, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 146984, + "idMal": 51535, + "siteUrl": "https://anilist.co/anime/146984", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/146984-yAkTtW2AExVj.jpg", + "episodes": 1, + "synonyms": [ + "Shingeki no Kyojin: The Final Season Final Edition", + "Shingeki no Kyojin: The Final Season Part 3", + "ผ่าพิภพไททัน ภาค 4", + "ผ่าพิภพไททัน ไฟนอล ซีซั่น Part 3", + "Attack on Titan Final Season Part 3 Final Arc Part 1", + "Attack on Titan The Final Season The Final Part Special", + "Attack on Titan The Final Season The Final Part Part 1", + "حمله به تایتان فصل آخر قسمت ویژه 1 " + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shingeki no Kyojin: The Final Season - Kanketsu-hen Zenpen", + "romaji": "Shingeki no Kyojin: The Final Season - Kanketsu-hen Zenpen", + "english": "Attack on Titan Final Season THE FINAL CHAPTERS Special 1", + "native": "進撃の巨人 The Final Season完結編 前編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx146984-EnCsTCpLyIBi.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx146984-EnCsTCpLyIBi.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx146984-EnCsTCpLyIBi.jpg", + "color": "#ffae35" + }, + "startDate": { + "year": 2023, + "month": 3, + "day": 4 + }, + "endDate": { + "year": 2023, + "month": 3, + "day": 4 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 131681, + "idMal": 48583, + "siteUrl": "https://anilist.co/anime/131681", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/131681-3tNurPRsqsfz.jpg", + "episodes": 12, + "synonyms": [ + "SnK 4", + "AoT 4", + "L'attaque des titans Saison Finale Partie 2", + "Shingeki no Kyojin: The Final Season (2022)", + "اتک عن تایتان", + "حمله به غول ها", + "حمله به تایتان فصل 4 ", + " ผ่าพิภพไททัน ไฟนอล ซีซั่น Part 2", + "ผ่าพิภพไททัน ภาค 4", + "L'Attacco dei Giganti 4 Parte 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shingeki no Kyojin: The Final Season Part 2", + "romaji": "Shingeki no Kyojin: The Final Season Part 2", + "english": "Attack on Titan Final Season Part 2", + "native": "進撃の巨人 The Final Season Part 2" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx131681-85KUYCnUyio2.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx131681-85KUYCnUyio2.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx131681-85KUYCnUyio2.jpg", + "color": "#35a1e4" + }, + "startDate": { + "year": 2022, + "month": 1, + "day": 10 + }, + "endDate": { + "year": 2022, + "month": 4, + "day": 4 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 53390, + "idMal": 23390, + "siteUrl": "https://anilist.co/manga/53390", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/53390-6Uru5rrjh8zv.jpg", + "synonyms": [ + "Atak Tytanów", + "SnK", + "AoT", + "Ataque dos Titãs", + "Ataque a los Titanes", + "L'Attacco dei Giganti", + "Titana Saldırı", + "Útok titánů", + "Titaanien sota - Attack on Titan", + "Атака на титанов", + "Napad titana", + "ผ่าพิภพไททัน", + "L'Attaque des Titans", + "Atacul Titanilor", + "進擊的巨人", + "진격의 거인" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shingeki no Kyojin", + "romaji": "Shingeki no Kyojin", + "english": "Attack on Titan", + "native": "進撃の巨人" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx53390-1RsuABC34P9D.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx53390-1RsuABC34P9D.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx53390-1RsuABC34P9D.jpg", + "color": "#d6431a" + }, + "startDate": { + "year": 2009, + "month": 9, + "day": 9 + }, + "endDate": { + "year": 2021, + "month": 4, + "day": 9 + } + } + }, + { + "relationType": "SEQUEL", + "node": { + "id": 162314, + "siteUrl": "https://anilist.co/anime/162314", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/162314-N5M4XMz6O1j4.jpg", + "episodes": 1, + "synonyms": [ + "Shingeki no Kyojin: The Final Season Final Edition", + "Attack on Titan Final Season Part 3 Final Arc Part 2", + "Attack on Titan: The Final Season Part 4", + "Shingeki no Kyojin: The Final Season Part 4" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Shingeki no Kyojin: The Final Season - Kanketsu-hen Kouhen", + "romaji": "Shingeki no Kyojin: The Final Season - Kanketsu-hen Kouhen", + "english": "Attack on Titan Final Season THE FINAL CHAPTERS Special 2", + "native": "進撃の巨人 The Final Season完結編 後編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx162314-ocaEhYmvznFO.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx162314-ocaEhYmvznFO.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx162314-ocaEhYmvznFO.jpg", + "color": "#5daee4" + }, + "startDate": { + "year": 2023, + "month": 11, + "day": 5 + }, + "endDate": { + "year": 2023, + "month": 11, + "day": 5 + } + } + } + ] + } + } + }, + { + "id": 389433725, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 21, + "idMal": 21, + "siteUrl": "https://anilist.co/anime/21", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21-wf37VakJmZqs.jpg", + "synonyms": [ + "ワンピース", + "海贼王", + "וואן פיס", + "ون بيس", + "วันพีซ", + "Vua Hải Tặc", + "All'arrembaggio!", + "Tutti all'arrembaggio!", + "Ντρέηκ, το Κυνήγι του Θησαυρού" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE", + "romaji": "ONE PIECE", + "english": "ONE PIECE", + "native": "ONE PIECE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21-tXMN3Y20PIL9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21-tXMN3Y20PIL9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21-tXMN3Y20PIL9.jpg", + "color": "#e4a15d" + }, + "startDate": { + "year": 1999, + "month": 10, + "day": 20 + }, + "endDate": {}, + "nextAiringEpisode": { + "airingAt": 1710635400, + "timeUntilAiring": 522072, + "episode": 1097 + }, + "relations": { + "edges": [ + { + "relationType": "SIDE_STORY", + "node": { + "id": 466, + "idMal": 466, + "siteUrl": "https://anilist.co/anime/466", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/466-uS8J4XwJ35Ws.png", + "episodes": 1, + "synonyms": [ + "Defeat Him! The Pirate Ganzack!", + "ONE PIECE ジャンプスーパーアニメツアー'98", + "ONE PIECE Jump Super Anime Tour '98" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Taose! Kaizoku Ganzack", + "romaji": "ONE PIECE: Taose! Kaizoku Ganzack", + "english": "One Piece: Defeat the Pirate Ganzack!", + "native": "ONE PIECE 倒せ!海賊ギャンザック" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx466-bVP54I7dCB2F.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx466-bVP54I7dCB2F.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx466-bVP54I7dCB2F.jpg", + "color": "#e49328" + }, + "startDate": { + "year": 1998, + "month": 7, + "day": 26 + }, + "endDate": { + "year": 1998, + "month": 7, + "day": 26 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 1094, + "idMal": 1094, + "siteUrl": "https://anilist.co/anime/1094", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n1094-IAIqiFbLBsig.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Avventura nell'ombelico dell'oceano", + "One Piece Special: Adventure in the Ocean's Navel", + "วันพีซ ภาคพิเศษ 1 การผจญภัยใต้มหาสมุทร", + "Vua Hải Tặc: Cuộc phiêu lưu vào rốn đại dương" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE TV Special: Umi no Heso no Daibouken-hen", + "romaji": "ONE PIECE TV Special: Umi no Heso no Daibouken-hen", + "english": "One Piece: Umi no Heso no Daibouken-hen", + "native": "ONE PIECE TVスペシャル 海のヘソの大冒険篇" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx1094-H3YFJ1TR0ZZi.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx1094-H3YFJ1TR0ZZi.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx1094-H3YFJ1TR0ZZi.jpg", + "color": "#f1a10d" + }, + "startDate": { + "year": 2000, + "month": 12, + "day": 20 + }, + "endDate": { + "year": 2000, + "month": 12, + "day": 20 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 1237, + "idMal": 1237, + "siteUrl": "https://anilist.co/anime/1237", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n1237-hHWuRsuVsVpr.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Un tesoro grande un sogno", + "วันพีซ ภาคพิเศษ 2 ออกสู่ทะเลกว้างใหญ่ ความฝันอันยิ่งใหญ่ของพ่อ", + "Vua Hải Tặc: Vươn ra đại dương! Giấc mơ to lớn của bố!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Oounabara ni Hirake! Dekkai Dekkai Chichi no Yume!", + "romaji": "ONE PIECE: Oounabara ni Hirake! Dekkai Dekkai Chichi no Yume!", + "english": "One Piece Special: Open Upon the Great Sea! A Father's Huge, HUGE Dream!", + "native": "ONE PIECE: 大海原にひらけ! でっかいでっカイ父の夢" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/1237.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/1237.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/1237.jpg", + "color": "#e4ae35" + }, + "startDate": { + "year": 2003, + "month": 4, + "day": 6 + }, + "endDate": { + "year": 2003, + "month": 4, + "day": 6 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 1238, + "idMal": 1238, + "siteUrl": "https://anilist.co/anime/1238", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n1238-HdBlNr5vwNau.jpg", + "episodes": 1, + "synonyms": [ + "One Piece TV Special 3", + "One Piece: L'ultima esibizione", + "守れ!最後の大舞台", + "One Piece: Mamore! Saigo no Daibutai", + "One Piece Special: Protect! The Last Great Stage", + "วันพีซ ภาคพิเศษ 3 ป้องกันการแสดงครั้งสุดท้ายอันยิ่งใหญ่ ", + "Vua Hải Tặc: Bảo vệ! Vở diễn lớn cuối cùng" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE TV Special 3: Mamore! Saigo no Oobutai", + "romaji": "ONE PIECE TV Special 3: Mamore! Saigo no Oobutai", + "english": "One Piece Special: Protect! The Last Great Performance", + "native": "ONE PIECE TVスペシャル3 守れ! 最後の大舞台" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx1238-Rf2wqBrCwgvO.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx1238-Rf2wqBrCwgvO.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx1238-Rf2wqBrCwgvO.jpg", + "color": "#5dc9f1" + }, + "startDate": { + "year": 2003, + "month": 12, + "day": 14 + }, + "endDate": { + "year": 2003, + "month": 12, + "day": 14 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 2020, + "idMal": 2020, + "siteUrl": "https://anilist.co/anime/2020", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n2020-k3lHutyP9i06.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Le avventure del detective Cappello di Paglia" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Nenmatsu Tokubetsu Kikaku! Mugiwara no Luffy Oyabun Torimonochou", + "romaji": "ONE PIECE: Nenmatsu Tokubetsu Kikaku! Mugiwara no Luffy Oyabun Torimonochou", + "english": "One Piece Special: The Detective Memoirs of Chief Straw Hat Luffy", + "native": "ONE PIECE 年末特別企画!麦わらのルフィ親分捕物帖" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2020.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2020.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/2020.jpg", + "color": "#f1bb35" + }, + "startDate": { + "year": 2005, + "month": 12, + "day": 18 + }, + "endDate": { + "year": 2005, + "month": 12, + "day": 18 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 2386, + "idMal": 2386, + "siteUrl": "https://anilist.co/anime/2386", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2386-jtduFM8raO2z.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Dream Soccer King!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Yume no Soccer Ou!", + "romaji": "ONE PIECE: Yume no Soccer Ou!", + "native": "ONE PIECE 夢のサッカー王!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2386-NQQkq1taHJ08.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2386-NQQkq1taHJ08.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/2386-NQQkq1taHJ08.jpg", + "color": "#d6931a" + }, + "startDate": { + "year": 2002, + "month": 3, + "day": 2 + }, + "endDate": { + "year": 2002, + "month": 3, + "day": 2 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 2490, + "idMal": 2490, + "siteUrl": "https://anilist.co/anime/2490", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2490-wlPYONPyTibY.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Take Aim! The Pirate Baseball King" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Mezase! Kaizoku Yakyuu Ou", + "romaji": "ONE PIECE: Mezase! Kaizoku Yakyuu Ou", + "native": "ONE PIECE めざせ! 海賊野球王" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2490-AL4WmnCJ5zvE.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/2490-AL4WmnCJ5zvE.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/2490-AL4WmnCJ5zvE.jpg", + "color": "#ffbb43" + }, + "startDate": { + "year": 2004, + "month": 3, + "day": 6 + }, + "endDate": { + "year": 2004, + "month": 3, + "day": 6 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 12001, + "idMal": 12001, + "siteUrl": "https://anilist.co/anime/12001", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n12001-IljdXqN8CTU7.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE 3D: Gekisou! Trap Coaster", + "romaji": "ONE PIECE 3D: Gekisou! Trap Coaster", + "native": "ONE PIECE 3D 激走! トラップコースター" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx12001-XX0NNNfaKZ3e.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx12001-XX0NNNfaKZ3e.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx12001-XX0NNNfaKZ3e.jpg", + "color": "#f1e435" + }, + "startDate": { + "year": 2011, + "month": 12, + "day": 1 + }, + "endDate": { + "year": 2011, + "month": 12, + "day": 1 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 16239, + "idMal": 16239, + "siteUrl": "https://anilist.co/anime/16239", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/16239-pov53U1T1dRm.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Luffy - Hand Island no Bouken", + "romaji": "ONE PIECE: Episode of Luffy - Hand Island no Bouken", + "english": "One Piece: Episode of Luffy - Hand Island Adventure", + "native": "ONE PIECE エピソードオブルフィ 〜ハンドアイランドの冒険〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16239-XzoVjd7JK8xJ.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16239-XzoVjd7JK8xJ.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b16239-XzoVjd7JK8xJ.png", + "color": "#f1c900" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 15 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 15 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 16468, + "idMal": 16468, + "siteUrl": "https://anilist.co/anime/16468", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n16468-yOxhsBHFICtu.jpg", + "episodes": 2, + "synonyms": [ + "One Piece: Glorious Island", + "One Piece Special: Glorious Island", + "ワンピース フィルム ゼット グロリアス アイランド" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: Z - GLORIOUS ISLAND", + "romaji": "ONE PIECE FILM: Z - GLORIOUS ISLAND", + "native": "ONE PIECE FILM Z『GLORIOUS ISLAND』" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16468-pMKFwfY8lYZX.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16468-pMKFwfY8lYZX.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b16468-pMKFwfY8lYZX.png", + "color": "#feae28" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 23 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 30 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 15323, + "idMal": 15323, + "siteUrl": "https://anilist.co/anime/15323", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/15323-zrax5OGlGepE.png", + "episodes": 1, + "synonyms": [ + "One Piece Special" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Nami - Koukaishi no Namida to Nakama no Kizuna", + "romaji": "ONE PIECE: Episode of Nami - Koukaishi no Namida to Nakama no Kizuna", + "native": "ONE PIECE エピソードオブナミ 〜航海士の涙と仲間の絆〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/15323.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/15323.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/15323.jpg", + "color": "#fed628" + }, + "startDate": { + "year": 2012, + "month": 8, + "day": 25 + }, + "endDate": { + "year": 2012, + "month": 8, + "day": 25 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 19123, + "idMal": 19123, + "siteUrl": "https://anilist.co/anime/19123", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/19123-QyRShJCfnIWD.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Special" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Merry - Mou Hitori no Nakama no Monogatari", + "romaji": "ONE PIECE: Episode of Merry - Mou Hitori no Nakama no Monogatari", + "english": "One Piece: Episode of Merry - The Tale of One More Friend", + "native": "ONE PIECE エピソードオブメリー 〜もうひとりの仲間の物語〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx19123-leET1CrSJknT.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx19123-leET1CrSJknT.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx19123-leET1CrSJknT.jpg", + "color": "#e4c95d" + }, + "startDate": { + "year": 2013, + "month": 8, + "day": 24 + }, + "endDate": { + "year": 2013, + "month": 8, + "day": 24 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 20835, + "idMal": 25161, + "siteUrl": "https://anilist.co/anime/20835", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/20835-aR8CuXvtzqND.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Special 15th Anniversary" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE \"3D2Y\": Ace no shi wo Koete! Luffy Nakama to no Chikai", + "romaji": "ONE PIECE \"3D2Y\": Ace no shi wo Koete! Luffy Nakama to no Chikai", + "english": "One Piece 3D2Y: Overcome Ace’s Death! Luffy’s Vow to his Friends", + "native": "ONE PIECE “3D2Y” エースの死を越えて! ルフィ仲間との誓い" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx20835-QVV6LpJlqe1A.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx20835-QVV6LpJlqe1A.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx20835-QVV6LpJlqe1A.jpg", + "color": "#e47850" + }, + "startDate": { + "year": 2014, + "month": 8, + "day": 30 + }, + "endDate": { + "year": 2014, + "month": 8, + "day": 30 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 21230, + "idMal": 31289, + "siteUrl": "https://anilist.co/anime/21230", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21230-5yju21iK6aMW.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Episode of Sabo - The Three Brothers' Bond: The Miraculous Reunion and the Inherited Will" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Sabo - 3-Kyoudai no Kizuna Kiseki no Saikai to Uketsugareru Ishi", + "romaji": "ONE PIECE: Episode of Sabo - 3-Kyoudai no Kizuna Kiseki no Saikai to Uketsugareru Ishi", + "english": "One Piece - Episode of Sabo: Bond of Three Brothers - A Miraculous Reunion and an Inherited Will", + "native": "ONE PIECE エピソードオブサボ ~3兄弟の絆 奇跡の再会と受け継がれる意志~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21230-rfoUZud1Jn0L.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21230-rfoUZud1Jn0L.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21230-rfoUZud1Jn0L.png", + "color": "#f1a150" + }, + "startDate": { + "year": 2015, + "month": 8, + "day": 22 + }, + "endDate": { + "year": 2015, + "month": 8, + "day": 22 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21485, + "idMal": 32051, + "siteUrl": "https://anilist.co/anime/21485", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21485-lPFbmxGrFsBX.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Adventure of Nebulandia", + "romaji": "ONE PIECE: Adventure of Nebulandia", + "english": "One Piece: Adventure of Nebulandia", + "native": "ONE PIECE 〜アドベンチャー オブ ネブランディア〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21485-KR1sv4rYSe6V.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/21485-KR1sv4rYSe6V.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/21485-KR1sv4rYSe6V.jpg", + "color": "#e4bb43" + }, + "startDate": { + "year": 2015, + "month": 12, + "day": 19 + }, + "endDate": { + "year": 2015, + "month": 12, + "day": 19 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21831, + "idMal": 33338, + "siteUrl": "https://anilist.co/anime/21831", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21831-Xl4r2uBaaKU4.png", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Heart of Gold", + "romaji": "ONE PIECE: Heart of Gold", + "english": "One Piece: Heart of Gold", + "native": "ONE PIECE 〜ハートオブゴールド〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21831-qj5IKYiPOupF.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx21831-qj5IKYiPOupF.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx21831-qj5IKYiPOupF.jpg", + "color": "#f1a10d" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 16 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 16 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 99302, + "idMal": 36215, + "siteUrl": "https://anilist.co/anime/99302", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/99302-v3NwmsSnjj94.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of East Blue - Luffy to 4-nin no Nakama no Daibouken", + "romaji": "ONE PIECE: Episode of East Blue - Luffy to 4-nin no Nakama no Daibouken", + "english": "One Piece: Episode of East Blue", + "native": "ONE PIECE エピソードオブ東の海〜ルフィと4人の仲間の大冒険!!〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx99302-b40WIhc5dylo.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx99302-b40WIhc5dylo.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx99302-b40WIhc5dylo.jpg", + "color": "#bbe45d" + }, + "startDate": { + "year": 2017, + "month": 8, + "day": 26 + }, + "endDate": { + "year": 2017, + "month": 8, + "day": 26 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 459, + "idMal": 459, + "siteUrl": "https://anilist.co/anime/459", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/459-c4uuz0LPvzkS.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: The Movie", + "One Piece (2000)", + "One Piece: La Película", + "วันพีซ เดอะมูฟวี่ 1 เกาะสมบัติแห่งวูนัน", + "Vua Hải Tặc: Đảo Châu Báu", + "One Piece: Golden Island Adventure", + "One Piece - Per tutto l'oro del mondo" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE (Movie)", + "romaji": "ONE PIECE (Movie)", + "english": "ONE PIECE: The Movie", + "native": "ONE PIECE (Movie)" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx459-Ivw65mUXackh.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx459-Ivw65mUXackh.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx459-Ivw65mUXackh.png", + "color": "#e40d0d" + }, + "startDate": { + "year": 2000, + "month": 3, + "day": 4 + }, + "endDate": { + "year": 2000, + "month": 3, + "day": 4 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 460, + "idMal": 460, + "siteUrl": "https://anilist.co/anime/460", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/460-ruLnAj6wDrTL.png", + "episodes": 1, + "synonyms": [ + "One Piece Movie 02", + "วันพีซ เดอะมูฟวี่ 2 การผจญภัยบนเกาะแห่งฟันเฟือง", + "Vua Hải Tặc: Cuộc phiêu lưu đến đảo máy đồng hồ ", + "One Piece - Avventura all'Isola Spirale" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Nejimaki-jima no Bouken", + "romaji": "ONE PIECE: Nejimaki-jima no Bouken", + "english": "One Piece: Clockwork Island Adventure", + "native": "ONE PIECE ねじまき島の冒険" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx460-p9HObfGUhWn0.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx460-p9HObfGUhWn0.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx460-p9HObfGUhWn0.jpg", + "color": "#e4c943" + }, + "startDate": { + "year": 2001, + "month": 3, + "day": 3 + }, + "endDate": { + "year": 2001, + "month": 3, + "day": 3 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 461, + "idMal": 461, + "siteUrl": "https://anilist.co/anime/461", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/461-0pvcIaXC0p70.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Movie 03", + "วันพีซ เดอะมูฟวี่ 3 เกาะแห่งสรรพสัตว์และราชันย์ช็อปเปอร์ ", + "Vua Hải Tặc: Vương quốc Chopper trên đảo của những sinh vật lạ", + "One Piece: Chopper Kingdom of Strange Animal Island", + "One Piece - Il tesoro del re" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Chinjuu-jima no Chopper Oukoku", + "romaji": "ONE PIECE: Chinjuu-jima no Chopper Oukoku", + "english": "One Piece: Chopper’s Kingdom on the Island of Strange Animals", + "native": "ONE PIECE 珍獣島のチョッパー王国" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx461-DC9fMDl3AaK1.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx461-DC9fMDl3AaK1.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx461-DC9fMDl3AaK1.jpg", + "color": "#e49343" + }, + "startDate": { + "year": 2002, + "month": 3, + "day": 2 + }, + "endDate": { + "year": 2002, + "month": 3, + "day": 2 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 462, + "idMal": 462, + "siteUrl": "https://anilist.co/anime/462", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/462-Z074owEvilUu.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Movie 04", + "One Piece: La Aventura sin Salida", + "The Adventure of Deadend", + "One Piece: Dead End", + "วันพีซ เดอะมูฟวี่ 4 การผจญภัยที่เดดเอนด์", + "Vua Hải Tặc: Cuộc đua tử thần", + "One Piece - Trappola mortale" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE THE MOVIE: Dead End no Bouken", + "romaji": "ONE PIECE THE MOVIE: Dead End no Bouken", + "english": "One Piece The Movie: The Dead End Adventure", + "native": "ONE PIECE THE MOVIE デッドエンドの冒険" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx462-Ig8zfdsFWcql.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx462-Ig8zfdsFWcql.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx462-Ig8zfdsFWcql.png", + "color": "#e49343" + }, + "startDate": { + "year": 2003, + "month": 3, + "day": 1 + }, + "endDate": { + "year": 2003, + "month": 3, + "day": 1 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 463, + "idMal": 463, + "siteUrl": "https://anilist.co/anime/463", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/463-zgU5XjUCR7Kv.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Movie 05", + "One Piece: La Maldición de la Espada Sagrada", + "One Piece: The Cursed Holy Sword", + "One Piece : La malédiction de l'épée sacrée", + "วันพีซ เดอะมูฟวี่ 5 วันดวลดาบ ต้องสาปมรณะ ", + "Vua Hải Tặc: Thánh kiếm bị nguyền rủa", + "One Piece - Der Fluch des heiligen Schwerts", + "One Piece - La spada delle sette stelle" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Norowareta Seiken", + "romaji": "ONE PIECE: Norowareta Seiken", + "english": "One Piece: The Curse of the Sacred Sword", + "native": "ONE PIECE 呪われた聖剣" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx463-QDnETPoHp9oD.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx463-QDnETPoHp9oD.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx463-QDnETPoHp9oD.jpg", + "color": "#e45078" + }, + "startDate": { + "year": 2004, + "month": 3, + "day": 6 + }, + "endDate": { + "year": 2004, + "month": 3, + "day": 6 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 464, + "idMal": 464, + "siteUrl": "https://anilist.co/anime/464", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/464-wmQoE1Ywxghs.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Movie 6", + "One Piece: El barón Omatsuri y la isla de los secretos", + "One Piece - L'isola segreta del barone Omatsuri" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE THE MOVIE: Omatsuri Danshaku to Himitsu no Shima", + "romaji": "ONE PIECE THE MOVIE: Omatsuri Danshaku to Himitsu no Shima", + "english": "One Piece: Baron Omatsuri and the Secret Island", + "native": "ONE PIECE THE MOVIE オマツリ男爵と秘密の島" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx464-g4wcZPjbhY5j.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx464-g4wcZPjbhY5j.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx464-g4wcZPjbhY5j.png", + "color": "#e4a15d" + }, + "startDate": { + "year": 2005, + "month": 3, + "day": 5 + }, + "endDate": { + "year": 2005, + "month": 3, + "day": 5 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 465, + "idMal": 465, + "siteUrl": "https://anilist.co/anime/465", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/465-dYMZ6SHNeOkL.jpg", + "episodes": 1, + "synonyms": [ + "One Piece: Karakuri Shiro no Mecha Kyohei", + "One Piece Movie 07", + "One Piece: El gran soldado mecánico del castillo Karakuri", + "The Giant Mechanical Soldier of Karakuri Castle", + "One Piece: Schloss Karakuris Metall-Soldaten", + "One Piece - I misteri dell'isola meccanica" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE THE MOVIE: Karakuri-jou no Mecha Kyohei", + "romaji": "ONE PIECE THE MOVIE: Karakuri-jou no Mecha Kyohei", + "english": "One Piece: Mega Mecha Soldier of Karakuri Castle", + "native": "ONE PIECE THE MOVIE カラクリ城のメカ巨兵" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx465-qSRr0MKYhS0I.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx465-qSRr0MKYhS0I.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx465-qSRr0MKYhS0I.jpg", + "color": "#43aeff" + }, + "startDate": { + "year": 2006, + "month": 3, + "day": 4 + }, + "endDate": { + "year": 2006, + "month": 3, + "day": 4 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 2107, + "idMal": 2107, + "siteUrl": "https://anilist.co/anime/2107", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/2107-Je0JIEoxx1VF.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Movie 8: Episode of Alabasta - The Desert Princess and the Pirates", + "One Piece Película 8: La saga del Arabasta - Los piratas y la princesa del desierto", + "One Piece Movie 08", + "One Piece - Un'amicizia oltre i confini del mare" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Alabasta - Sabaku no Oujo to Kaizoku-tachi", + "romaji": "ONE PIECE: Episode of Alabasta - Sabaku no Oujo to Kaizoku-tachi", + "english": "One Piece: The Desert Princess and the Pirates, Adventures in Alabasta", + "native": "ONE PIECE エピソードオブアラバスタ 砂漠の王女と海賊たち" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx2107-H8bRuRRbhCIJ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2107-H8bRuRRbhCIJ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx2107-H8bRuRRbhCIJ.jpg", + "color": "#e4a143" + }, + "startDate": { + "year": 2007, + "month": 3, + "day": 3 + }, + "endDate": { + "year": 2007, + "month": 3, + "day": 3 + } + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 3848, + "idMal": 3848, + "siteUrl": "https://anilist.co/anime/3848", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/3848-LXsSQtSFu4e9.jpg", + "episodes": 1, + "synonyms": [ + "Miracle Sakura", + "One Piece: Episodio de Chopper + El milagro del cerezo en invierno", + "One Piece: Episode of Chopper Plus - Bloom in the Winter, Miracle Sakura", + "One Piece Movie 09", + "One Piece - Il miracolo dei ciliegi in fiore" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE THE MOVIE: Episode of Chopper Plus - Fuyu ni Saku, Kiseki no Sakura", + "romaji": "ONE PIECE THE MOVIE: Episode of Chopper Plus - Fuyu ni Saku, Kiseki no Sakura", + "english": "One Piece: Episode Of Chopper +: The Miracle Winter Cherry Blossom", + "native": "ONE PIECE THE MOVIE エピソードオブチョッパー+ 冬に咲く、奇跡の桜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx3848-SCnYGTn34Llt.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx3848-SCnYGTn34Llt.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx3848-SCnYGTn34Llt.jpg", + "color": "#e4e450" + }, + "startDate": { + "year": 2008, + "month": 3, + "day": 1 + }, + "endDate": { + "year": 2008, + "month": 3, + "day": 1 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 4155, + "idMal": 4155, + "siteUrl": "https://anilist.co/anime/4155", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/4155-2PkLjTHddz2s.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 10", + "ワンピース ストロング ワールド", + "One Piece: Strong World", + "One Piece - Avventura sulle isole volanti" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: STRONG WORLD", + "romaji": "ONE PIECE FILM: STRONG WORLD", + "english": "One Piece Film: Strong World", + "native": "ONE PIECE FILM STRONG WORLD" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx4155-P5TDf6t6qFwX.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx4155-P5TDf6t6qFwX.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx4155-P5TDf6t6qFwX.png", + "color": "#e4ae50" + }, + "startDate": { + "year": 2009, + "month": 12, + "day": 12 + }, + "endDate": { + "year": 2009, + "month": 12, + "day": 12 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 9999, + "idMal": 9999, + "siteUrl": "https://anilist.co/anime/9999", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/9999-T5jCX3o3cxeN.jpg", + "episodes": 1, + "synonyms": [ + "One Piece 3D: Straw Hat Chase", + "One Piece 3D - L'inseguimento di Cappello di Paglia" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE 3D: Mugiwara Chase", + "romaji": "ONE PIECE 3D: Mugiwara Chase", + "english": "One Piece 3D: Mugiwara Chase", + "native": "ONE PIECE 3D 麦わらチェイス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/9999.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/9999.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/9999.jpg", + "color": "#ffa100" + }, + "startDate": { + "year": 2011, + "month": 3, + "day": 19 + }, + "endDate": { + "year": 2011, + "month": 3, + "day": 19 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 12859, + "idMal": 12859, + "siteUrl": "https://anilist.co/anime/12859", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/12859-XjlBW6o2YwUb.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 12: Z", + "海贼王剧场版Z", + "One Piece Gold - Il film" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: Z", + "romaji": "ONE PIECE FILM: Z", + "english": "One Piece Film: Z", + "native": "ONE PIECE FILM Z" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx12859-uQFENDPzMWz6.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx12859-uQFENDPzMWz6.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx12859-uQFENDPzMWz6.jpg", + "color": "#f1ae5d" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 15 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 15 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21335, + "idMal": 31490, + "siteUrl": "https://anilist.co/anime/21335", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21335-ps20iVSGUXbD.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 13", + "航海王之黄金城" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: GOLD", + "romaji": "ONE PIECE FILM: GOLD", + "english": "One Piece Film: Gold", + "native": "ONE PIECE FILM GOLD" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21335-XsXdE0AeOkkZ.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21335-XsXdE0AeOkkZ.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21335-XsXdE0AeOkkZ.jpg", + "color": "#f1bb35" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 23 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 23 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 96427, + "idMal": 94534, + "siteUrl": "https://anilist.co/manga/96427", + "status": "FINISHED", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/n96427-UsjDRx1o4V8V.jpg", + "synonyms": [ + "ONE PIECE: Loguetown Arc", + "Logue Town" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Loguetown-hen", + "romaji": "ONE PIECE: Loguetown-hen", + "native": "ONE PIECE ローグタウン編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx96427-XiDc44cTlf29.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx96427-XiDc44cTlf29.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx96427-XiDc44cTlf29.png", + "color": "#e4861a" + }, + "startDate": { + "year": 2000, + "month": 7, + "day": 17 + }, + "endDate": {} + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 30013, + "idMal": 13, + "siteUrl": "https://anilist.co/manga/30013", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/30013-hbbRZqC5MjYh.jpg", + "synonyms": [ + "원피스", + "וואן פיס ", + "One Piece - Большой куш", + "ワンピース", + "ONE PIECE~航海王~" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE", + "romaji": "ONE PIECE", + "english": "One Piece", + "native": "ONE PIECE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx30013-tZVlfBCHbrNL.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30013-tZVlfBCHbrNL.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx30013-tZVlfBCHbrNL.jpg", + "color": "#e48650" + }, + "startDate": { + "year": 1997, + "month": 7, + "day": 22 + }, + "endDate": {} + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 101918, + "idMal": 37902, + "siteUrl": "https://anilist.co/anime/101918", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/101918-jUG4Mb1hCxVS.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Sorajima", + "romaji": "ONE PIECE: Episode of Sorajima", + "english": "One Piece: Episode of Skypiea", + "native": "ONE PIECE エピソードオブ空島" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx101918-3uyCYHw1syki.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx101918-3uyCYHw1syki.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx101918-3uyCYHw1syki.png", + "color": "#5dc9f1" + }, + "startDate": { + "year": 2018, + "month": 8, + "day": 25 + }, + "endDate": { + "year": 2018, + "month": 8, + "day": 25 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 8740, + "idMal": 8740, + "siteUrl": "https://anilist.co/anime/8740", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/8740-MlFelJndh6Yr.png", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: STRONG WORLD - EPISODE:0", + "romaji": "ONE PIECE FILM: STRONG WORLD - EPISODE:0", + "native": "ONE PIECE FILM STRONG WORLD EPISODE:0" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b8740-oZajT3brPn7b.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b8740-oZajT3brPn7b.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b8740-oZajT3brPn7b.jpg", + "color": "#e493a1" + }, + "startDate": { + "year": 2010, + "month": 4, + "day": 16 + }, + "endDate": { + "year": 2010, + "month": 4, + "day": 16 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 105143, + "idMal": 38234, + "siteUrl": "https://anilist.co/anime/105143", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/105143-y8oSKa8PSsgK.jpg", + "episodes": 1, + "synonyms": [ + "ワンピース スタンピード", + "One Piece: Estampida", + "航海王:狂热行动", + "One Piece Film 14" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE STAMPEDE", + "romaji": "ONE PIECE STAMPEDE", + "english": "One Piece: Stampede", + "native": "ONE PIECE STAMPEDE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx105143-5uBDmhvMr6At.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx105143-5uBDmhvMr6At.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx105143-5uBDmhvMr6At.png", + "color": "#e4e450" + }, + "startDate": { + "year": 2019, + "month": 8, + "day": 9 + }, + "endDate": { + "year": 2019, + "month": 8, + "day": 9 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 102639, + "idMal": 20871, + "siteUrl": "https://anilist.co/anime/102639", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n102639-XXAO6eBYPBmw.jpg", + "episodes": 1, + "synonyms": [ + "Nissan Serena x One Piece 3D: Mugiwara Chase - Sennyuu!! Sauzando Sanii-gou" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Nissan SERENA x One Piece: Sennyuu Sauzando Sanii-gou", + "romaji": "Nissan SERENA x One Piece: Sennyuu Sauzando Sanii-gou", + "native": "日産SERENA×ワンピース 潜入サウザンド・サニー号" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx102639-AjxqMLkusd2Y.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx102639-AjxqMLkusd2Y.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx102639-AjxqMLkusd2Y.jpg", + "color": "#d6ae78" + }, + "startDate": { + "year": 2011, + "month": 3, + "day": 5 + }, + "endDate": { + "year": 2011, + "month": 3, + "day": 5 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 106492, + "idMal": 23933, + "siteUrl": "https://anilist.co/anime/106492", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 1, + "synonyms": [ + "Orb Panic Adventure!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kyutai Panic Adventure!", + "romaji": "Kyutai Panic Adventure!", + "native": "球体パニックアドベンチャー!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b106492-ciLSI5nTJsV7.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b106492-ciLSI5nTJsV7.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b106492-ciLSI5nTJsV7.png", + "color": "#e4c943" + }, + "startDate": { + "year": 2003 + }, + "endDate": {} + } + }, + { + "relationType": "SUMMARY", + "node": { + "id": 106572, + "idMal": 28683, + "siteUrl": "https://anilist.co/anime/106572", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n106572-kY1X9fF5zJZz.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Alabasta - Sabaku no Oujo to Kaizoku-tachi", + "romaji": "ONE PIECE: Episode of Alabasta - Sabaku no Oujo to Kaizoku-tachi", + "native": "ONE PIECE エピソードオブアラバスタ 砂漠の王女と海賊たち" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106572-k1gqIsDcqGaV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n106572-k1gqIsDcqGaV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/n106572-k1gqIsDcqGaV.jpg", + "color": "#e4bb43" + }, + "startDate": { + "year": 2011, + "month": 8, + "day": 20 + }, + "endDate": { + "year": 2011, + "month": 8, + "day": 20 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 107110, + "idMal": 22661, + "siteUrl": "https://anilist.co/anime/107110", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 3, + "synonyms": [ + "One Piece: Banpresto's \"Cry Heart\" Short", + "Fuyujima ni Furu Sakura", + "Children's Dream", + "Ace no Saigo " + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Cry heart", + "romaji": "ONE PIECE: Cry heart", + "native": "ワンピース Cry heart" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n107110-pF0sMxDe3aLi.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n107110-pF0sMxDe3aLi.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/n107110-pF0sMxDe3aLi.png", + "color": "#f1861a" + }, + "startDate": { + "year": 2014, + "month": 1, + "day": 19 + }, + "endDate": { + "year": 2015, + "month": 4, + "day": 22 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 107256, + "idMal": 23935, + "siteUrl": "https://anilist.co/anime/107256", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "episodes": 1, + "synonyms": [ + "Orb Panic Adventure Returns!" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kyutai Panic Adventure Returns!!!", + "romaji": "Kyutai Panic Adventure Returns!!!", + "native": "球体パニックアドベンチャーリターンズ!!!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b107256-Pvgk6VnCmMZq.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b107256-Pvgk6VnCmMZq.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b107256-Pvgk6VnCmMZq.png", + "color": "#f1c9ae" + }, + "startDate": { + "year": 2004 + }, + "endDate": {} + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 2385, + "idMal": 2385, + "siteUrl": "https://anilist.co/anime/2385", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n2385-kYxcxwV0LTvq.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Nejimaki-jima no Bouken - Jango no Dance Carnival", + "romaji": "ONE PIECE: Nejimaki-jima no Bouken - Jango no Dance Carnival", + "english": "One Piece: Django's Dance Carnival", + "native": "ONE PIECE ねじまき島の冒険 ジャンゴのダンスカーニバル" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n2385-us99rub90MzL.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/n2385-us99rub90MzL.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/n2385-us99rub90MzL.jpg", + "color": "#f1e41a" + }, + "startDate": { + "year": 2001, + "month": 3, + "day": 3 + }, + "endDate": { + "year": 2001, + "month": 3, + "day": 3 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 101099, + "idMal": 35770, + "siteUrl": "https://anilist.co/anime/101099", + "status": "FINISHED", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/101099-KarIxewMysiK.jpg", + "episodes": 11, + "synonyms": [ + "Hungry Days: Yokoku-hen", + "Hungry Days: Majo no Takkyuubin-hen", + "Hungry Days: Heidi-hen", + "Hungry Days: Sazae-san-hen", + "Hungry Days: One Piece", + "Hungry Days: Zoro-hen", + "Hungry Days: Nami-hen", + "Hungry Days: Vivi-hen", + "Hungry Days: Choujou Gibasen-hen" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "HUNGRY DAYS: Aoharu ka yo.", + "romaji": "HUNGRY DAYS: Aoharu ka yo.", + "native": "HUNGRY DAYS アオハルかよ。" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b101099-3AoCbJWTj2cV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b101099-3AoCbJWTj2cV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b101099-3AoCbJWTj2cV.jpg", + "color": "#a1a135" + }, + "startDate": { + "year": 2017, + "month": 6, + "day": 7 + }, + "endDate": { + "year": 2020, + "month": 2, + "day": 7 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 21880, + "idMal": 33606, + "siteUrl": "https://anilist.co/anime/21880", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21880-9gGzVvnzqiNA.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: GOLD - episode 0 711ver.", + "romaji": "ONE PIECE FILM: GOLD - episode 0 711ver.", + "native": "ONE PIECE FILM GOLD 〜episode 0〜 711ver." + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21880-uxsZ880LXSdY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21880-uxsZ880LXSdY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21880-uxsZ880LXSdY.jpg", + "color": "#e4a135" + }, + "startDate": { + "year": 2016, + "month": 7, + "day": 2 + }, + "endDate": { + "year": 2016, + "month": 7, + "day": 2 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 5252, + "idMal": 5252, + "siteUrl": "https://anilist.co/anime/5252", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "OVA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/5252-tlTORLa2dR2u.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Prototype" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: ROMANCE DAWN STORY", + "romaji": "ONE PIECE: ROMANCE DAWN STORY", + "english": "One Piece: Romance Dawn Story", + "native": "ONE PIECE ロマンス ドーン ストーリー" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/5252.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/5252.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/5252.jpg", + "color": "#43a1e4" + }, + "startDate": { + "year": 2008, + "month": 11, + "day": 24 + }, + "endDate": { + "year": 2008, + "month": 11, + "day": 24 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 30793, + "idMal": 793, + "siteUrl": "https://anilist.co/manga/30793", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/n30793-SDlOKsKEVHxa.jpg", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Wanted!", + "romaji": "Wanted!", + "english": "Wanted! Eiichiro Oda Before One Piece ", + "native": "WANTED!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx30793-Zca4SIWG5j8e.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30793-Zca4SIWG5j8e.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx30793-Zca4SIWG5j8e.png", + "color": "#f1ae5d" + }, + "startDate": { + "year": 1998, + "month": 11, + "day": 4 + }, + "endDate": {} + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 113308, + "idMal": 52139, + "siteUrl": "https://anilist.co/anime/113308", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/113308-jvwBxZ1dJcVx.jpg", + "episodes": 1, + "synonyms": [ + "HUNGRY DAYS × BUMP OF CHICKEN 「記念撮影」MV", + "HUNGRY DAYS × BUMP OF CHICKEN - Kinen Satsuei", + "HUNGRY DAYS × BUMP OF CHICKEN - Commemorative Photo", + "One Piece x BUMP OF CHICKEN", + "Hungry Days: One Piece" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kinen Satsuei", + "romaji": "Kinen Satsuei", + "native": "記念撮影" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx113308-u6wWW3d01DSt.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx113308-u6wWW3d01DSt.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx113308-u6wWW3d01DSt.jpg", + "color": "#f1865d" + }, + "startDate": { + "year": 2019, + "month": 11, + "day": 2 + }, + "endDate": { + "year": 2019, + "month": 11, + "day": 2 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 114761, + "idMal": 40970, + "siteUrl": "https://anilist.co/anime/114761", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/114761-cgU5ZD6nQ911.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "A-RA-SHI : Reborn", + "romaji": "A-RA-SHI : Reborn", + "native": "A-RA-SHI : Reborn" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b114761-C2xOMuA44zKY.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b114761-C2xOMuA44zKY.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b114761-C2xOMuA44zKY.png", + "color": "#0dbbfe" + }, + "startDate": { + "year": 2020, + "month": 1, + "day": 4 + }, + "endDate": { + "year": 2020, + "month": 1, + "day": 4 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 10033, + "idMal": 10033, + "siteUrl": "https://anilist.co/anime/10033", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n10033-u9ROjiLizrd4.jpg", + "episodes": 147, + "synonyms": [ + "Toriko (2011)", + "Toriko (TV)", + "Toriko x One Piece Collabo Special" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Toriko", + "romaji": "Toriko", + "native": "トリコ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx10033-V7xnlgAVtaVR.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx10033-V7xnlgAVtaVR.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx10033-V7xnlgAVtaVR.jpg", + "color": "#50aef1" + }, + "startDate": { + "year": 2011, + "month": 4, + "day": 3 + }, + "endDate": { + "year": 2014, + "month": 3, + "day": 30 + } + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 813, + "idMal": 813, + "siteUrl": "https://anilist.co/anime/813", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/813-03ZLvWJgR6Wd.jpg", + "episodes": 291, + "synonyms": [ + "DBZ", + "Dragonball Z", + "דרגון בול זי", + "What's My Destiny Dragon Ball", + "ดราก้อนบอล Z", + "Bảy Viên Ngọc Rồng Z" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Dragon Ball Z", + "romaji": "Dragon Ball Z", + "english": "Dragon Ball Z", + "native": "ドラゴンボールZ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx813-QBIvCQgHcjcF.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx813-QBIvCQgHcjcF.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx813-QBIvCQgHcjcF.png", + "color": "#f1a150" + }, + "startDate": { + "year": 1989, + "month": 4, + "day": 26 + }, + "endDate": { + "year": 1996, + "month": 1, + "day": 31 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 126649, + "idMal": 38419, + "siteUrl": "https://anilist.co/anime/126649", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/126649-pxeImm81vD6l.jpg", + "episodes": 1, + "synonyms": [ + "Treasure Hunting: Tongari Shima no Daibouke" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Tokyo One Piece Tower: Tongari Shima no Dai Hihou", + "romaji": "Tokyo One Piece Tower: Tongari Shima no Dai Hihou", + "english": "The Great Tongari Island Treasure Hunting Adventure", + "native": "東京ワンピースタワー トンガリ島の大秘宝" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b126649-sbuKTAghtsln.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b126649-sbuKTAghtsln.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b126649-sbuKTAghtsln.jpg", + "color": "#e48628" + }, + "startDate": { + "year": 2016, + "month": 11, + "day": 1 + }, + "endDate": { + "year": 2016, + "month": 11, + "day": 1 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 134184, + "siteUrl": "https://anilist.co/manga/134184", + "status": "FINISHED", + "type": "MANGA", + "format": "ONE_SHOT", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/134184-My4zIFu5a4j1.jpg", + "synonyms": [ + "Romance Dawn - Version 2" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Romance Dawn", + "romaji": "Romance Dawn", + "native": "ロマンスドーン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx134184-25CEkbRJEzXB.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx134184-25CEkbRJEzXB.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx134184-25CEkbRJEzXB.png", + "color": "#fe9350" + }, + "startDate": { + "year": 1996, + "month": 9, + "day": 23 + }, + "endDate": { + "year": 1996, + "month": 9, + "day": 23 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 141902, + "idMal": 50410, + "siteUrl": "https://anilist.co/anime/141902", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/141902-SvnRSXnN7DWC.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 15", + "فيلم ون بيس: ريد", + "วันพีซ ฟิล์ม เรด" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: RED", + "romaji": "ONE PIECE FILM: RED", + "english": "One Piece Film: Red", + "native": "ONE PIECE FILM RED" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx141902-fTyoTk8F8qOl.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx141902-fTyoTk8F8qOl.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx141902-fTyoTk8F8qOl.jpg", + "color": "#f1c950" + }, + "startDate": { + "year": 2022, + "month": 8, + "day": 6 + }, + "endDate": { + "year": 2022, + "month": 8, + "day": 6 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 143293, + "siteUrl": "https://anilist.co/anime/143293", + "status": "FINISHED", + "type": "ANIME", + "format": "ONA", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "One Piece: Annecy Festival 60th Anniversary", + "romaji": "One Piece: Annecy Festival 60th Anniversary", + "english": "One Piece: Annecy Festival 60th Anniversary", + "native": "One Piece: Annecy Festival 60th Anniversary" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx143293-1ngwckHDsK95.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx143293-1ngwckHDsK95.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx143293-1ngwckHDsK95.jpg", + "color": "#e4e443" + }, + "startDate": { + "year": 2021, + "month": 6, + "day": 16 + }, + "endDate": { + "year": 2021, + "month": 6, + "day": 16 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 167404, + "idMal": 56055, + "siteUrl": "https://anilist.co/anime/167404", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "ONA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/167404-Hhvbg3APdan0.jpg", + "episodes": 1, + "synonyms": [ + "الوحوش: لعنة التنين", + "Monsters: El infierno del dragón", + "Monsters 103: Emociones del dragón volador del samurái extremo" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "MONSTERS: Ippaku Sanjou Hiryuu Jigoku", + "romaji": "MONSTERS: Ippaku Sanjou Hiryuu Jigoku", + "english": "MONSTERS: 103 Mercies Dragon Damnation", + "native": "MONSTERS 一百三情飛龍侍極" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx167404-QMZJVARntkbv.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx167404-QMZJVARntkbv.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx167404-QMZJVARntkbv.jpg", + "color": "#ff931a" + }, + "startDate": { + "year": 2024, + "month": 1, + "day": 21 + }, + "endDate": { + "year": 2024, + "month": 1, + "day": 21 + } + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 171630, + "idMal": 57557, + "siteUrl": "https://anilist.co/anime/171630", + "status": "NOT_YET_RELEASED", + "type": "ANIME", + "synonyms": [ + "ザワンピース" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "THE ONE PIECE", + "romaji": "THE ONE PIECE", + "english": "THE ONE PIECE", + "native": "THE ONE PIECE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx171630-CULIbflZbhK1.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx171630-CULIbflZbhK1.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx171630-CULIbflZbhK1.jpg" + }, + "startDate": {}, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433832, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2024, + "month": 2, + "day": 26 + }, + "completedAt": { + "year": 2024, + "month": 2, + "day": 5 + }, + "media": { + "id": 163132, + "idMal": 54856, + "siteUrl": "https://anilist.co/anime/163132", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/163132-bxxTKGlmlnOm.jpg", + "episodes": 13, + "synonyms": [ + "Хоримия: Фрагменты" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Horimiya: piece", + "romaji": "Horimiya: piece", + "english": "Horimiya: The Missing Pieces", + "native": "ホリミヤ -piece-" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx163132-C220CO5UrTxY.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx163132-C220CO5UrTxY.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx163132-C220CO5UrTxY.jpg", + "color": "#e4a143" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 1 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 72451, + "idMal": 42451, + "siteUrl": "https://anilist.co/manga/72451", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/42451-shG1Ksjxm3pw.jpg", + "synonyms": [ + "โฮริมิยะ สาวมั่นกับนายมืดมน", + "Horimiya - Hori and Miyamura" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Horimiya", + "romaji": "Horimiya", + "english": "Horimiya", + "native": "ホリミヤ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx72451-vVXtRwyttjGG.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx72451-vVXtRwyttjGG.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx72451-vVXtRwyttjGG.png", + "color": "#e45d93" + }, + "startDate": { + "year": 2011, + "month": 10, + "day": 18 + }, + "endDate": { + "year": 2023, + "month": 7, + "day": 18 + } + } + }, + { + "relationType": "PARENT", + "node": { + "id": 124080, + "idMal": 42897, + "siteUrl": "https://anilist.co/anime/124080", + "status": "FINISHED", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/124080-ARyLAHHgikRq.jpg", + "episodes": 13, + "synonyms": [ + "堀与宫村", + "โฮริมิยะ สาวมั่นกับนายมืดมน", + "Хоримия" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Horimiya", + "romaji": "Horimiya", + "english": "Horimiya", + "native": "ホリミヤ" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx124080-h8EPH92nyRfS.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx124080-h8EPH92nyRfS.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx124080-h8EPH92nyRfS.jpg", + "color": "#5dc9f1" + }, + "startDate": { + "year": 2021, + "month": 1, + "day": 10 + }, + "endDate": { + "year": 2021, + "month": 4, + "day": 4 + } + } + }, + { + "relationType": "SOURCE", + "node": { + "id": 137278, + "siteUrl": "https://anilist.co/manga/137278", + "status": "FINISHED", + "type": "MANGA", + "format": "ONE_SHOT", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/137278-cffIzMuFlO3s.jpg", + "synonyms": [ + "Horimiya Special", + "Horimiya epilogue", + "卒アル", + "Sotsuari" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Horimiya Bangai-hen", + "romaji": "Horimiya Bangai-hen", + "native": "ホリミヤ番外編" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx137278-bSLDxopwCbsu.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx137278-bSLDxopwCbsu.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx137278-bSLDxopwCbsu.jpg" + }, + "startDate": { + "year": 2021, + "month": 7, + "day": 16 + }, + "endDate": { + "year": 2021, + "month": 7, + "day": 16 + } + } + } + ] + } + } + }, + { + "id": 389433835, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2024, + "month": 2, + "day": 27 + }, + "completedAt": {}, + "media": { + "id": 153518, + "idMal": 52701, + "siteUrl": "https://anilist.co/anime/153518", + "status": "RELEASING", + "season": "WINTER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/153518-7uRvV7SLqmHV.jpg", + "episodes": 24, + "synonyms": [ + "Dungeon Food", + "Dungeon Meal", + "Tragones y Mazmorras", + "Gloutons et Dragons", + "Подземелье вкусностей", + "던전밥", + "สูตรลับตำรับดันเจียน", + "Mỹ vị hầm ngục", + "Підземелля смакоти", + "迷宫饭" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Dungeon Meshi", + "romaji": "Dungeon Meshi", + "english": "Delicious in Dungeon", + "native": "ダンジョン飯" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx153518-7FNR7zCxO2X5.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx153518-7FNR7zCxO2X5.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx153518-7FNR7zCxO2X5.jpg", + "color": "#e48643" + }, + "startDate": { + "year": 2024, + "month": 1, + "day": 4 + }, + "endDate": { + "year": 2024, + "month": 6 + }, + "nextAiringEpisode": { + "airingAt": 1710423000, + "timeUntilAiring": 309672, + "episode": 11 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 86082, + "idMal": 85781, + "siteUrl": "https://anilist.co/manga/86082", + "status": "FINISHED", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/86082-L0gxhGsRsDDE.jpg", + "synonyms": [ + "Dungeon Food", + "Dungeon Meal", + "Tragones y Mazmorras", + "Gloutons et Dragons", + "Подземное питание", + "던전밥", + "สูตรลับตำรับดันเจียน", + "Mỹ vị hầm ngục", + "迷宮飯" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Dungeon Meshi", + "romaji": "Dungeon Meshi", + "english": "Delicious in Dungeon", + "native": "ダンジョン飯" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx86082-it012qMBU8S8.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx86082-it012qMBU8S8.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx86082-it012qMBU8S8.jpg", + "color": "#e4865d" + }, + "startDate": { + "year": 2014, + "month": 2, + "day": 15 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 15 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 111516, + "idMal": 36577, + "siteUrl": "https://anilist.co/anime/111516", + "status": "FINISHED", + "type": "ANIME", + "format": "ONA", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Dungeon Meshi CM", + "romaji": "Dungeon Meshi CM", + "english": "Delicious in Dungeon CM", + "native": "ダンジョン飯 CM" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx111516-JRzOxTmZfwke.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx111516-JRzOxTmZfwke.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx111516-JRzOxTmZfwke.jpg", + "color": "#d6781a" + }, + "startDate": { + "year": 2019, + "month": 9, + "day": 5 + }, + "endDate": { + "year": 2019, + "month": 9, + "day": 5 + } + } + }, + { + "relationType": "OTHER", + "node": { + "id": 158627, + "siteUrl": "https://anilist.co/anime/158627", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "ONA", + "episodes": 1, + "synonyms": [ + "ダンジョン飯 CM" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Dungeon Meshi: Senshi no Kantan Cooking!", + "romaji": "Dungeon Meshi: Senshi no Kantan Cooking!", + "native": "ダンジョン飯~センシのかんたんクッキング!" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b158627-cBmslZa62lju.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b158627-cBmslZa62lju.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b158627-cBmslZa62lju.jpg", + "color": "#e4c993" + }, + "startDate": { + "year": 2017, + "month": 7, + "day": 31 + }, + "endDate": { + "year": 2017, + "month": 7, + "day": 31 + } + } + } + ] + } + } + }, + { + "id": 389433836, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 162983, + "idMal": 54790, + "siteUrl": "https://anilist.co/anime/162983", + "status": "FINISHED", + "season": "SUMMER", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/162983-EyEfvGopoWLx.jpg", + "episodes": 13, + "synonyms": [ + "Фарс убитой нежити", + "不死少女的谋杀闹剧" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Undead Girl Murder Farce", + "romaji": "Undead Girl Murder Farce", + "english": "Undead Murder Farce", + "native": "アンデッドガール・マーダーファルス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx162983-T3nZyk6sUlEj.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx162983-T3nZyk6sUlEj.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx162983-T3nZyk6sUlEj.jpg", + "color": "#c98650" + }, + "startDate": { + "year": 2023, + "month": 7, + "day": 6 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 28 + }, + "relations": { + "edges": [ + { + "relationType": "ALTERNATIVE", + "node": { + "id": 117306, + "idMal": 98437, + "siteUrl": "https://anilist.co/manga/117306", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/117306-XeN4Q5WoHdG9.jpg", + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Undead Girl Murder Farce ", + "romaji": "Undead Girl Murder Farce ", + "english": "Undead Girl Murder Farce", + "native": "アンデッドガール・マーダーファルス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx117306-ijQDGpJ8rd2J.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx117306-ijQDGpJ8rd2J.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx117306-ijQDGpJ8rd2J.jpg", + "color": "#e4c91a" + }, + "startDate": { + "year": 2016, + "month": 5, + "day": 26 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433840, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 161645, + "idMal": 54492, + "siteUrl": "https://anilist.co/anime/161645", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/161645-oqzTZYIvviWI.jpg", + "episodes": 24, + "synonyms": [ + "Drugstore Soliloquy", + "Les Carnets de l'Apothicaire", + "Zapiski zielarki", + "Diários de uma Apotecária", + "Il monologo della Speziale", + "Los diarios de la boticaria", + "สืบคดีปริศนา หมอยาตำรับโคมแดง", + "Записки аптекаря", + "Die Tagebücher der Apothekerin", + "يوميات الصيدلانيّة" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kusuriya no Hitorigoto", + "romaji": "Kusuriya no Hitorigoto", + "english": "The Apothecary Diaries", + "native": "薬屋のひとりごと" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx161645-7I8Cip7XRDhV.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx161645-7I8Cip7XRDhV.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx161645-7I8Cip7XRDhV.jpg", + "color": "#f1865d" + }, + "startDate": { + "year": 2023, + "month": 10, + "day": 22 + }, + "endDate": {}, + "nextAiringEpisode": { + "airingAt": 1710605100, + "timeUntilAiring": 491772, + "episode": 23 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 99026, + "idMal": 86769, + "siteUrl": "https://anilist.co/manga/99026", + "status": "RELEASING", + "type": "MANGA", + "format": "NOVEL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/99026-CWFP526DyTGz.jpg", + "synonyms": [ + "สืบคดีปริศนา หมอยาตำรับโคมแดง", + "Dược sư tự sự", + "Les Carnets de l'Apothicaire", + "藥師少女的獨語", + "Los diarios de la boticaria" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kusuriya no Hitorigoto", + "romaji": "Kusuriya no Hitorigoto", + "english": "The Apothecary Diaries", + "native": "薬屋のひとりごと" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx99026-5Eg650WAd9Rj.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx99026-5Eg650WAd9Rj.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx99026-5Eg650WAd9Rj.jpg", + "color": "#e4d6a1" + }, + "startDate": { + "year": 2014, + "month": 8, + "day": 29 + }, + "endDate": {} + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 99022, + "idMal": 107562, + "siteUrl": "https://anilist.co/manga/99022", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/99022-J1LhaA8HbVzV.jpg", + "synonyms": [ + "Zapiski zielarki", + "Les Carnets de l'apothicaire", + "I diari della speziale", + "Dược sư tự sự", + "ตำรับปริศนา หมอยาแห่งวังหลัง", + "Монолог Травниці", + "Diários de Uma Apotecária", + "藥師少女的獨語", + "药屋少女的呢喃", + "Los diarios de la boticaria" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kusuriya no Hitorigoto", + "romaji": "Kusuriya no Hitorigoto", + "english": "The Apothecary Diaries", + "native": "薬屋のひとりごと" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/nx99022-Hh2WdyNgR8HM.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx99022-Hh2WdyNgR8HM.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/nx99022-Hh2WdyNgR8HM.jpg" + }, + "startDate": { + "year": 2017, + "month": 5, + "day": 25 + }, + "endDate": {} + } + }, + { + "relationType": "ALTERNATIVE", + "node": { + "id": 113322, + "idMal": 110929, + "siteUrl": "https://anilist.co/manga/113322", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "synonyms": [ + "약사의 혼잣말", + "เสียงรำพึงจากหมอยา -บันทึกไขปริศนาแห่งวังหลังของเหมาเหมา-", + "Les Carnets de l'Apothicaire - Enquêtes à la cour" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Kusuriya no Hitorigoto: Maomao no Koukyuu Nazotoki Techou", + "romaji": "Kusuriya no Hitorigoto: Maomao no Koukyuu Nazotoki Techou", + "native": "薬屋のひとりごと 猫猫の後宮謎解き手帳" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx113322-gwV3eakaVqZQ.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx113322-gwV3eakaVqZQ.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx113322-gwV3eakaVqZQ.png", + "color": "#e47850" + }, + "startDate": { + "year": 2017, + "month": 8, + "day": 19 + }, + "endDate": {} + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 170508, + "idMal": 56975, + "siteUrl": "https://anilist.co/anime/170508", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "ONA", + "synonyms": [ + "Kusuriya no Hitorigoto Mini Anime", + "The Apothecary Diaries Mini Anime", + "薬屋のひとりごと ミニアニメ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Maomao no Hitorigoto", + "romaji": "Maomao no Hitorigoto", + "native": "猫猫のひとりごと" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx170508-72GLTka7NHeF.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx170508-72GLTka7NHeF.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx170508-72GLTka7NHeF.jpg", + "color": "#4393e4" + }, + "startDate": { + "year": 2023, + "month": 10, + "day": 23 + }, + "endDate": {} + } + } + ] + } + } + }, + { + "id": 389433842, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": {}, + "completedAt": {}, + "media": { + "id": 154587, + "idMal": 52991, + "siteUrl": "https://anilist.co/anime/154587", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/154587-ivXNJ23SM1xB.jpg", + "episodes": 28, + "synonyms": [ + "Frieren at the Funeral", + "장송의 프리렌", + "Frieren - Oltre la Fine del Viaggio", + "คำอธิษฐานในวันที่จากลา Frieren", + "Frieren e a Jornada para o Além", + "Frieren – Nach dem Ende der Reise", + "葬送的芙莉蓮", + "Frieren: Más allá del final del viaje", + "Frieren en el funeral", + "Sōsō no Furīren", + "Frieren. U kresu drogi", + "Frieren - Pháp sư tiễn táng", + "Фрирен, провожающая в последний путь", + "فريرن: ما وراء نهاية الرحلة" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Sousou no Frieren", + "romaji": "Sousou no Frieren", + "english": "Frieren: Beyond Journey’s End", + "native": "葬送のフリーレン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx154587-n1fmjRv4JQUd.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx154587-n1fmjRv4JQUd.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx154587-n1fmjRv4JQUd.jpg", + "color": "#d6f1c9" + }, + "startDate": { + "year": 2023, + "month": 9, + "day": 29 + }, + "endDate": { + "year": 2024, + "month": 3 + }, + "nextAiringEpisode": { + "airingAt": 1710511200, + "timeUntilAiring": 397872, + "episode": 27 + }, + "relations": { + "edges": [ + { + "relationType": "SOURCE", + "node": { + "id": 118586, + "idMal": 126287, + "siteUrl": "https://anilist.co/manga/118586", + "status": "RELEASING", + "type": "MANGA", + "format": "MANGA", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/manga/banner/118586-1JLJiwaIlnBp.jpg", + "synonyms": [ + "Frieren at the Funeral", + "장송의 프리렌", + "Frieren: Oltre la Fine del Viaggio", + "คำอธิษฐานในวันที่จากลา Frieren", + "Frieren e a Jornada para o Além", + "Frieren – Nach dem Ende der Reise", + "葬送的芙莉蓮", + "Frieren After \"The End\"", + "Frieren: Remnants of the Departed", + "Frieren. U kresu drogi", + "Frieren", + "FRIEREN: Más allá del fin del viaje" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Sousou no Frieren", + "romaji": "Sousou no Frieren", + "english": "Frieren: Beyond Journey’s End", + "native": "葬送のフリーレン" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx118586-F0Lp86XQV7du.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx118586-F0Lp86XQV7du.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx118586-F0Lp86XQV7du.jpg", + "color": "#e4ae5d" + }, + "startDate": { + "year": 2020, + "month": 4, + "day": 28 + }, + "endDate": {} + } + }, + { + "relationType": "CHARACTER", + "node": { + "id": 169811, + "idMal": 56805, + "siteUrl": "https://anilist.co/anime/169811", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/169811-jgMVZlIdH19a.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Yuusha", + "romaji": "Yuusha", + "english": "The Brave", + "native": "勇者" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx169811-tsuH0SJVJy40.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx169811-tsuH0SJVJy40.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx169811-tsuH0SJVJy40.jpg" + }, + "startDate": { + "year": 2023, + "month": 9, + "day": 29 + }, + "endDate": { + "year": 2023, + "month": 9, + "day": 29 + } + } + }, + { + "relationType": "SIDE_STORY", + "node": { + "id": 170068, + "idMal": 56885, + "siteUrl": "https://anilist.co/anime/170068", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "ONA", + "synonyms": [ + "Sousou no Frieren Mini Anime", + "Frieren: Beyond Journey’s End Mini Anime", + "葬送のフリーレン ミニアニメ" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Sousou no Frieren: ●● no Mahou", + "romaji": "Sousou no Frieren: ●● no Mahou", + "native": "葬送のフリーレン ~●●の魔法~" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx170068-ijY3tCP8KoWP.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx170068-ijY3tCP8KoWP.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx170068-ijY3tCP8KoWP.jpg", + "color": "#bbd678" + }, + "startDate": { + "year": 2023, + "month": 10, + "day": 11 + }, + "endDate": {} + } + }, + { + "relationType": "OTHER", + "node": { + "id": 175691, + "idMal": 58313, + "siteUrl": "https://anilist.co/anime/175691", + "status": "FINISHED", + "type": "ANIME", + "format": "MUSIC", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "Haru", + "romaji": "Haru", + "english": "Sunny", + "native": "晴る" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx175691-zZEjlFuuvmVI.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx175691-zZEjlFuuvmVI.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx175691-zZEjlFuuvmVI.jpg", + "color": "#6baed6" + }, + "startDate": { + "year": 2024, + "month": 3, + "day": 5 + }, + "endDate": { + "year": 2024, + "month": 3, + "day": 5 + } + } + } + ] + } + } + }, + { + "id": 389433849, + "score": 0, + "progress": 0, + "status": "PLANNING", + "repeat": 0, + "private": false, + "startedAt": { + "year": 2024, + "month": 2, + "day": 5 + }, + "completedAt": { + "year": 2024, + "month": 2, + "day": 5 + }, + "media": { + "id": 12859, + "idMal": 12859, + "siteUrl": "https://anilist.co/anime/12859", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/12859-XjlBW6o2YwUb.jpg", + "episodes": 1, + "synonyms": [ + "One Piece Film 12: Z", + "海贼王剧场版Z", + "One Piece Gold - Il film" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: Z", + "romaji": "ONE PIECE FILM: Z", + "english": "One Piece Film: Z", + "native": "ONE PIECE FILM Z" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx12859-uQFENDPzMWz6.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx12859-uQFENDPzMWz6.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx12859-uQFENDPzMWz6.jpg", + "color": "#f1ae5d" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 15 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 15 + }, + "relations": { + "edges": [ + { + "relationType": "PREQUEL", + "node": { + "id": 16239, + "idMal": 16239, + "siteUrl": "https://anilist.co/anime/16239", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/16239-pov53U1T1dRm.jpg", + "episodes": 1, + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE: Episode of Luffy - Hand Island no Bouken", + "romaji": "ONE PIECE: Episode of Luffy - Hand Island no Bouken", + "english": "One Piece: Episode of Luffy - Hand Island Adventure", + "native": "ONE PIECE エピソードオブルフィ 〜ハンドアイランドの冒険〜" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16239-XzoVjd7JK8xJ.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16239-XzoVjd7JK8xJ.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b16239-XzoVjd7JK8xJ.png", + "color": "#f1c900" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 15 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 15 + } + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 16468, + "idMal": 16468, + "siteUrl": "https://anilist.co/anime/16468", + "status": "FINISHED", + "season": "FALL", + "type": "ANIME", + "format": "SPECIAL", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/n16468-yOxhsBHFICtu.jpg", + "episodes": 2, + "synonyms": [ + "One Piece: Glorious Island", + "One Piece Special: Glorious Island", + "ワンピース フィルム ゼット グロリアス アイランド" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE FILM: Z - GLORIOUS ISLAND", + "romaji": "ONE PIECE FILM: Z - GLORIOUS ISLAND", + "native": "ONE PIECE FILM Z『GLORIOUS ISLAND』" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16468-pMKFwfY8lYZX.png", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/b16468-pMKFwfY8lYZX.png", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/b16468-pMKFwfY8lYZX.png", + "color": "#feae28" + }, + "startDate": { + "year": 2012, + "month": 12, + "day": 23 + }, + "endDate": { + "year": 2012, + "month": 12, + "day": 30 + } + } + }, + { + "relationType": "PARENT", + "node": { + "id": 21, + "idMal": 21, + "siteUrl": "https://anilist.co/anime/21", + "status": "RELEASING", + "season": "FALL", + "type": "ANIME", + "format": "TV", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/21-wf37VakJmZqs.jpg", + "synonyms": [ + "ワンピース", + "海贼王", + "וואן פיס", + "ون بيس", + "วันพีซ", + "Vua Hải Tặc", + "All'arrembaggio!", + "Tutti all'arrembaggio!", + "Ντρέηκ, το Κυνήγι του Θησαυρού" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE", + "romaji": "ONE PIECE", + "english": "ONE PIECE", + "native": "ONE PIECE" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21-tXMN3Y20PIL9.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/nx21-tXMN3Y20PIL9.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/nx21-tXMN3Y20PIL9.jpg", + "color": "#e4a15d" + }, + "startDate": { + "year": 1999, + "month": 10, + "day": 20 + }, + "endDate": {} + } + }, + { + "relationType": "PREQUEL", + "node": { + "id": 9999, + "idMal": 9999, + "siteUrl": "https://anilist.co/anime/9999", + "status": "FINISHED", + "season": "SPRING", + "type": "ANIME", + "format": "MOVIE", + "bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/9999-T5jCX3o3cxeN.jpg", + "episodes": 1, + "synonyms": [ + "One Piece 3D: Straw Hat Chase", + "One Piece 3D - L'inseguimento di Cappello di Paglia" + ], + "isAdult": false, + "countryOfOrigin": "JP", + "title": { + "userPreferred": "ONE PIECE 3D: Mugiwara Chase", + "romaji": "ONE PIECE 3D: Mugiwara Chase", + "english": "One Piece 3D: Mugiwara Chase", + "native": "ONE PIECE 3D 麦わらチェイス" + }, + "coverImage": { + "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/9999.jpg", + "large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/9999.jpg", + "medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/9999.jpg", + "color": "#ffa100" + }, + "startDate": { + "year": 2011, + "month": 3, + "day": 19 + }, + "endDate": { + "year": 2011, + "month": 3, + "day": 19 + } + } + } + ] + } + } + } + ] + }, + { + "status": "COMPLETED" + } + ] + } +}